v1.0.0 — Hexagonal architecture rewrite
All checks were successful
CI / ci (push) Successful in 7m16s
All checks were successful
CI / ci (push) Successful in 7m16s
Restructure the monolithic 252-line main.rs into a 10-crate workspace with clean hexagonal architecture, swappable adapters, and a production- ready deployment pipeline. Backend architecture: - domain: Canvas, Color/Position/PixelUpdate value objects, port traits (CanvasStore, CanvasPersistence, EventBroadcaster), BroadcastEvent - application: use cases (place_pixel, get_state, save/restore snapshot, connect/disconnect), AppState with Arc snapshot cache - config: AppConfig structs + ConfigSource trait - api-types: shared DTOs, event name constants - adapters: config-env, canvas-file, http-axum (rust-embed), socketio, websocket — all behind port traits, swappable via feature flags - server: composition root with graceful shutdown (SIGTERM/SIGINT) Frontend: - Transport abstraction: Socket.IO and native WebSocket via VITE_TRANSPORT - Canvas zoom/pan with mouse wheel, pinch-to-zoom, and +/- buttons - ImageData rendering (~50x faster than fillRect loop) - Touch support, responsive CSS scaling, mobile-friendly layout - OG/Twitter Card meta tags for rich link previews Production: - Docker: musl static build on scratch — 2.73MB image - CI workflows for Gitea and GitHub Actions (fmt, clippy, test, Docker push) - deploy.sh for private registry - 39 unit tests across domain and application - Zero unwraps, zero unsafe, graceful error handling with tracing - Periodic canvas snapshots with rotation, restored on startup - All config via environment variables with typed defaults
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
target/
|
||||||
|
painter-js/node_modules/
|
||||||
|
painter-js/dist/
|
||||||
|
painter-js/.env
|
||||||
|
snapshots/
|
||||||
|
.git/
|
||||||
|
.claude/
|
||||||
|
.env
|
||||||
|
*.DS_Store
|
||||||
12
.env.example
12
.env.example
@@ -1,3 +1,13 @@
|
|||||||
ADDRESS=0.0.0.0
|
ADDRESS=0.0.0.0
|
||||||
PORT=3000
|
PORT=3000
|
||||||
ENABLE_CORS=true
|
ENABLE_CORS=true
|
||||||
|
CANVAS_WIDTH=500
|
||||||
|
CANVAS_HEIGHT=500
|
||||||
|
COOLDOWN_SECS=10
|
||||||
|
RATE_LIMIT_BURST=10
|
||||||
|
RATE_LIMIT_PER_SECOND=10
|
||||||
|
BROADCAST_CAPACITY=1024
|
||||||
|
SNAPSHOT_ENABLED=true
|
||||||
|
SNAPSHOT_INTERVAL_SECS=300
|
||||||
|
SNAPSHOT_MAX=5
|
||||||
|
SNAPSHOT_DIR=snapshots/
|
||||||
|
|||||||
40
.gitea/workflows/ci.yml
Normal file
40
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["*"]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt, clippy
|
||||||
|
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
run: cd painter-js && bun install --frozen-lockfile && bun run build
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: |
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cd painter-js && bun run fmt:check
|
||||||
|
|
||||||
|
- name: Clippy
|
||||||
|
run: cargo clippy --workspace -- -D warnings
|
||||||
|
|
||||||
|
- name: Tests
|
||||||
|
run: cargo test --workspace
|
||||||
74
.github/workflows/ci.yml
vendored
Normal file
74
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["*"]
|
||||||
|
tags: ["v*"]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt, clippy
|
||||||
|
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
run: cd painter-js && bun install --frozen-lockfile && bun run build
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: |
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cd painter-js && bun run fmt:check
|
||||||
|
|
||||||
|
- name: Clippy
|
||||||
|
run: cargo clippy --workspace -- -D warnings
|
||||||
|
|
||||||
|
- name: Tests
|
||||||
|
run: cargo test --workspace
|
||||||
|
|
||||||
|
docker:
|
||||||
|
needs: ci
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v'))
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- uses: docker/metadata-action@v5
|
||||||
|
id: meta
|
||||||
|
with:
|
||||||
|
images: ghcr.io/${{ github.repository }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=sha
|
||||||
|
|
||||||
|
- uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,2 +1,4 @@
|
|||||||
/target
|
/target
|
||||||
.env
|
.env
|
||||||
|
/snapshots
|
||||||
|
*.DS_Store
|
||||||
1307
Cargo.lock
generated
1307
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
72
Cargo.toml
72
Cargo.toml
@@ -1,22 +1,60 @@
|
|||||||
[package]
|
[workspace]
|
||||||
name = "painter"
|
members = [
|
||||||
version = "0.1.0"
|
"crates/config",
|
||||||
edition = "2021"
|
"crates/domain",
|
||||||
|
"crates/application",
|
||||||
|
"crates/api-types",
|
||||||
|
"crates/adapters/canvas-file",
|
||||||
|
"crates/adapters/config-env",
|
||||||
|
"crates/adapters/http-axum",
|
||||||
|
"crates/adapters/socketio",
|
||||||
|
"crates/adapters/websocket",
|
||||||
|
"crates/server",
|
||||||
|
]
|
||||||
|
default-members = [
|
||||||
|
"crates/config",
|
||||||
|
"crates/domain",
|
||||||
|
"crates/application",
|
||||||
|
"crates/api-types",
|
||||||
|
"crates/adapters/canvas-file",
|
||||||
|
"crates/adapters/config-env",
|
||||||
|
"crates/adapters/http-axum",
|
||||||
|
"crates/server",
|
||||||
|
]
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
[workspace.package]
|
||||||
|
version = "1.0.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[workspace.dependencies]
|
||||||
axum = "0.7.5"
|
config = { path = "crates/config" }
|
||||||
bincode = "1.3.3"
|
domain = { path = "crates/domain" }
|
||||||
bytes = "1.6.0"
|
application = { path = "crates/application" }
|
||||||
chrono = "0.4.38"
|
api-types = { path = "crates/api-types" }
|
||||||
|
canvas-file = { path = "crates/adapters/canvas-file" }
|
||||||
|
config-env = { path = "crates/adapters/config-env" }
|
||||||
|
http-axum = { path = "crates/adapters/http-axum" }
|
||||||
|
socketio = { path = "crates/adapters/socketio" }
|
||||||
|
websocket = { path = "crates/adapters/websocket" }
|
||||||
|
futures = "0.3"
|
||||||
|
|
||||||
|
axum = "0.8.9"
|
||||||
dotenv = "0.15.0"
|
dotenv = "0.15.0"
|
||||||
memory-stats = "1.1.0"
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde = { version = "1.0.201", features = ["derive"] }
|
serde_json = "1.0"
|
||||||
serde_json = "1.0.117"
|
socketioxide = "0.18.6"
|
||||||
socketioxide = "0.13.1"
|
thiserror = "2"
|
||||||
tokio = { version = "1.37.0", features = ["full"] }
|
tokio = { version = "1.37", features = ["full"] }
|
||||||
tower-http = { version = "0.5.2", features = ["cors", "fs"] }
|
rust-embed = { version = "8", features = ["interpolate-folder-path", "mime-guess"] }
|
||||||
tower_governor = "0.4.2"
|
tower-http = { version = "0.7.0", features = ["cors"] }
|
||||||
|
tower_governor = "0.8.0"
|
||||||
tracing = "0.1.40"
|
tracing = "0.1.40"
|
||||||
tracing-subscriber = "0.3.18"
|
tracing-subscriber = "0.3.18"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = true
|
||||||
|
opt-level = "z"
|
||||||
|
codegen-units = 1
|
||||||
|
panic = "abort"
|
||||||
|
strip = true
|
||||||
|
|||||||
29
Dockerfile
29
Dockerfile
@@ -1,19 +1,24 @@
|
|||||||
FROM rust:1.76 as builder
|
FROM oven/bun:1 AS frontend
|
||||||
|
WORKDIR /app/painter-js
|
||||||
|
COPY painter-js/package.json painter-js/bun.lock ./
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
COPY painter-js/ .
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
FROM rust:1-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
RUN apk add --no-cache musl-dev
|
||||||
|
|
||||||
COPY Cargo.toml Cargo.lock ./
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY crates/ ./crates/
|
||||||
|
|
||||||
COPY src ./src
|
RUN mkdir -p painter-js/dist && echo '<html></html>' > painter-js/dist/index.html
|
||||||
|
RUN cargo build --release --bin server 2>&1 || true
|
||||||
|
|
||||||
RUN cargo build --release
|
COPY --from=frontend /app/painter-js/dist ./painter-js/dist
|
||||||
|
RUN touch crates/adapters/http-axum/src/routes.rs && cargo build --release --bin server
|
||||||
FROM rust:1.76
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY --from=builder /app/target/release/painter .
|
|
||||||
COPY painter-js/dist ./dist
|
|
||||||
COPY .env .env
|
|
||||||
|
|
||||||
|
FROM scratch
|
||||||
|
COPY --from=builder /app/target/release/server /painter
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
ENTRYPOINT ["/painter"]
|
||||||
CMD ["./painter"]
|
|
||||||
|
|||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Gabriel Kaszewski
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
33
Makefile
Normal file
33
Makefile
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
.PHONY: build dev check check-all test fmt run clean fix
|
||||||
|
|
||||||
|
build:
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
dev:
|
||||||
|
RUST_LOG=debug cargo run
|
||||||
|
|
||||||
|
check:
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cargo clippy -- -D warnings
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
check-all:
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cargo clippy --workspace -- -D warnings
|
||||||
|
cargo test --workspace
|
||||||
|
|
||||||
|
test:
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
cargo fmt --all
|
||||||
|
|
||||||
|
run:
|
||||||
|
cargo run --release
|
||||||
|
|
||||||
|
fix:
|
||||||
|
cargo fmt --all
|
||||||
|
cargo clippy --fix --allow-dirty --allow-staged
|
||||||
|
|
||||||
|
clean:
|
||||||
|
cargo clean
|
||||||
122
README.md
122
README.md
@@ -1,3 +1,121 @@
|
|||||||
# painter
|
# Painter
|
||||||
|
|
||||||
Web app inspired by r/place
|
A collaborative pixel canvas inspired by r/place. Users connect in real-time and place colored pixels on a shared 500x500 canvas.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install frontend dependencies and build
|
||||||
|
cd painter-js && bun install && bun run build && cd ..
|
||||||
|
|
||||||
|
# Run the server (serves embedded frontend)
|
||||||
|
cargo run --release
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:3000` in your browser.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All configuration is via environment variables (or `.env` file):
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `ADDRESS` | `0.0.0.0` | Bind address |
|
||||||
|
| `PORT` | `3000` | Bind port |
|
||||||
|
| `ENABLE_CORS` | `true` | Enable CORS headers |
|
||||||
|
| `CANVAS_WIDTH` | `500` | Canvas width in pixels |
|
||||||
|
| `CANVAS_HEIGHT` | `500` | Canvas height in pixels |
|
||||||
|
| `COOLDOWN_SECS` | `10` | Seconds between pixel placements per user |
|
||||||
|
| `RATE_LIMIT_BURST` | `10` | HTTP rate limit burst size |
|
||||||
|
| `RATE_LIMIT_PER_SECOND` | `10` | HTTP rate limit per second |
|
||||||
|
| `BROADCAST_CAPACITY` | `1024` | Broadcast channel buffer size |
|
||||||
|
| `SNAPSHOT_ENABLED` | `true` | Enable periodic canvas snapshots |
|
||||||
|
| `SNAPSHOT_INTERVAL_SECS` | `300` | Seconds between snapshots |
|
||||||
|
| `SNAPSHOT_MAX` | `5` | Maximum snapshot files to keep |
|
||||||
|
| `SNAPSHOT_DIR` | `snapshots/` | Snapshot storage directory |
|
||||||
|
|
||||||
|
## Transport Adapters
|
||||||
|
|
||||||
|
The server supports two real-time transport protocols, selected at compile time:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Socket.IO (default)
|
||||||
|
cargo run --release
|
||||||
|
|
||||||
|
# Native WebSocket
|
||||||
|
cargo run --release --no-default-features --features websocket
|
||||||
|
```
|
||||||
|
|
||||||
|
The frontend auto-detects via `VITE_TRANSPORT` environment variable (`socketio` or `websocket`).
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build
|
||||||
|
docker build -t painter .
|
||||||
|
|
||||||
|
# Run
|
||||||
|
docker run -p 3000:3000 painter
|
||||||
|
|
||||||
|
# With custom config
|
||||||
|
docker run -p 3000:3000 \
|
||||||
|
-e CANVAS_WIDTH=1000 \
|
||||||
|
-e CANVAS_HEIGHT=1000 \
|
||||||
|
-e COOLDOWN_SECS=30 \
|
||||||
|
-v ./snapshots:/app/snapshots \
|
||||||
|
painter
|
||||||
|
```
|
||||||
|
|
||||||
|
The Docker image is a single statically-linked binary on `scratch` — **~3MB** total, no OS, no runtime dependencies. The frontend is embedded at compile time.
|
||||||
|
|
||||||
|
The server shuts down gracefully on SIGTERM/SIGINT (Ctrl+C) — in-flight connections are drained and a final canvas snapshot is saved before exit.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend (watches for changes)
|
||||||
|
RUST_LOG=debug cargo run
|
||||||
|
|
||||||
|
# Frontend (Vite dev server with HMR)
|
||||||
|
cd painter-js
|
||||||
|
VITE_IS_DEBUG=true bun run dev
|
||||||
|
|
||||||
|
# Run all checks (fmt + clippy + tests)
|
||||||
|
make check
|
||||||
|
|
||||||
|
# Format
|
||||||
|
make fmt
|
||||||
|
cd painter-js && bun run fmt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Hexagonal architecture with clean dependency boundaries. See [architecture.mmd](architecture.mmd) for the full diagram.
|
||||||
|
|
||||||
|
```
|
||||||
|
crates/
|
||||||
|
config/ Config structs, ConfigSource trait
|
||||||
|
domain/ Canvas, value objects, port traits, events
|
||||||
|
application/ Use cases, AppState, InProcessBroadcaster
|
||||||
|
api-types/ Shared DTOs, event constants
|
||||||
|
adapters/
|
||||||
|
config-env/ Environment variable config loader
|
||||||
|
canvas-file/ File-based snapshot persistence
|
||||||
|
http-axum/ HTTP routes, rate limiting, embedded static files
|
||||||
|
socketio/ Socket.IO transport adapter
|
||||||
|
websocket/ Native WebSocket transport adapter
|
||||||
|
server/ Composition root
|
||||||
|
```
|
||||||
|
|
||||||
|
All infrastructure is behind port traits:
|
||||||
|
|
||||||
|
| Port | Adapter | Swappable to |
|
||||||
|
|---|---|---|
|
||||||
|
| `CanvasStore` | In-memory | Redis, mmap'd file |
|
||||||
|
| `CanvasPersistence` | File system | S3, SQLite |
|
||||||
|
| `EventBroadcaster` | tokio broadcast | NATS, Redis pub/sub |
|
||||||
|
| `ConfigSource` | Env vars | TOML, JSON, remote API |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE)
|
||||||
|
|||||||
83
architecture.mmd
Normal file
83
architecture.mmd
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
graph TB
|
||||||
|
subgraph Binary ["Server (Composition Root)"]
|
||||||
|
server["server<br/><i>wires adapters, starts Axum</i>"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Application ["Application Layer"]
|
||||||
|
AppState["AppState"]
|
||||||
|
PlacePixel["canvas::place_pixel"]
|
||||||
|
GetState["canvas::get_state"]
|
||||||
|
SaveSnapshot["canvas::save_snapshot"]
|
||||||
|
RestoreSnapshot["canvas::restore_snapshot"]
|
||||||
|
Connect["soldiers::connect"]
|
||||||
|
Disconnect["soldiers::disconnect"]
|
||||||
|
InMemoryCanvas["InMemoryCanvasStore"]
|
||||||
|
InProcessBroadcast["InProcessBroadcaster"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Domain ["Domain Layer (0 infra deps)"]
|
||||||
|
Canvas["Canvas"]
|
||||||
|
ValueObjects["Color | Position | PixelUpdate"]
|
||||||
|
BroadcastEvent["BroadcastEvent"]
|
||||||
|
DomainError["DomainError"]
|
||||||
|
|
||||||
|
subgraph Ports ["Port Traits"]
|
||||||
|
CanvasStore["CanvasStore"]
|
||||||
|
CanvasPersistence["CanvasPersistence"]
|
||||||
|
EventBroadcaster["EventBroadcaster"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Shared ["Shared Types"]
|
||||||
|
Config["config<br/><i>AppConfig, ConfigSource trait</i>"]
|
||||||
|
ApiTypes["api-types<br/><i>PixelUpdatePayload, event constants</i>"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Adapters ["Adapters"]
|
||||||
|
subgraph Transport ["Transport (driving)"]
|
||||||
|
SocketIO["socketio<br/><i>Socket.IO protocol</i>"]
|
||||||
|
WebSocket["websocket<br/><i>native WebSocket + JSON</i>"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Infrastructure ["Infrastructure (driven)"]
|
||||||
|
ConfigEnv["config-env<br/><i>env var loader</i>"]
|
||||||
|
CanvasFile["canvas-file<br/><i>file snapshots</i>"]
|
||||||
|
HttpAxum["http-axum<br/><i>HTTP routes, rate limit,<br/>embedded static files</i>"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
server --> Application
|
||||||
|
server --> Transport
|
||||||
|
server --> Infrastructure
|
||||||
|
server --> Config
|
||||||
|
|
||||||
|
PlacePixel --> CanvasStore
|
||||||
|
PlacePixel --> EventBroadcaster
|
||||||
|
SaveSnapshot --> CanvasStore
|
||||||
|
SaveSnapshot --> CanvasPersistence
|
||||||
|
RestoreSnapshot --> CanvasStore
|
||||||
|
RestoreSnapshot --> CanvasPersistence
|
||||||
|
Connect --> EventBroadcaster
|
||||||
|
Disconnect --> EventBroadcaster
|
||||||
|
|
||||||
|
InMemoryCanvas -.->|implements| CanvasStore
|
||||||
|
InProcessBroadcast -.->|implements| EventBroadcaster
|
||||||
|
CanvasFile -.->|implements| CanvasPersistence
|
||||||
|
ConfigEnv -.->|implements| Config
|
||||||
|
|
||||||
|
SocketIO --> Application
|
||||||
|
WebSocket --> Application
|
||||||
|
SocketIO --> ApiTypes
|
||||||
|
HttpAxum --> Config
|
||||||
|
|
||||||
|
classDef domain fill:#e8f5e9,stroke:#2e7d32
|
||||||
|
classDef app fill:#e3f2fd,stroke:#1565c0
|
||||||
|
classDef adapter fill:#fff3e0,stroke:#e65100
|
||||||
|
classDef shared fill:#f3e5f5,stroke:#6a1b9a
|
||||||
|
classDef binary fill:#fce4ec,stroke:#c62828
|
||||||
|
|
||||||
|
class Canvas,ValueObjects,BroadcastEvent,DomainError,CanvasStore,CanvasPersistence,EventBroadcaster domain
|
||||||
|
class AppState,PlacePixel,GetState,SaveSnapshot,RestoreSnapshot,Connect,Disconnect,InMemoryCanvas,InProcessBroadcast app
|
||||||
|
class SocketIO,WebSocket,ConfigEnv,CanvasFile,HttpAxum adapter
|
||||||
|
class Config,ApiTypes shared
|
||||||
|
class server binary
|
||||||
9
crates/adapters/canvas-file/Cargo.toml
Normal file
9
crates/adapters/canvas-file/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[package]
|
||||||
|
name = "canvas-file"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
config = { workspace = true }
|
||||||
|
domain = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
158
crates/adapters/canvas-file/src/lib.rs
Normal file
158
crates/adapters/canvas-file/src/lib.rs
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::io::{BufWriter, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use config::SnapshotConfig;
|
||||||
|
use domain::ports::CanvasPersistence;
|
||||||
|
use domain::{Color, DomainError};
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
pub struct FileCanvasPersistence {
|
||||||
|
directory: PathBuf,
|
||||||
|
max_snapshots: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileCanvasPersistence {
|
||||||
|
pub fn new(config: &SnapshotConfig) -> Result<Self, DomainError> {
|
||||||
|
let directory = PathBuf::from(&config.directory);
|
||||||
|
fs::create_dir_all(&directory).map_err(|err| {
|
||||||
|
DomainError::Persistence(format!(
|
||||||
|
"Failed to create snapshot directory '{}': {err}",
|
||||||
|
directory.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
directory,
|
||||||
|
max_snapshots: config.max_snapshots,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot_path(&self, index: usize) -> PathBuf {
|
||||||
|
self.directory.join(format!("canvas_{index}.bin"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn latest_index_path(&self) -> PathBuf {
|
||||||
|
self.directory.join("latest")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_latest_index(&self) -> Option<usize> {
|
||||||
|
let path = self.latest_index_path();
|
||||||
|
fs::read_to_string(&path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|content| content.trim().parse().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_latest_index(&self, index: usize) -> Result<(), DomainError> {
|
||||||
|
let path = self.latest_index_path();
|
||||||
|
fs::write(&path, index.to_string())
|
||||||
|
.map_err(|err| DomainError::Persistence(format!("Failed to write latest index: {err}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rotate_and_cleanup(&self, new_index: usize) {
|
||||||
|
if self.max_snapshots == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let oldest_to_keep = new_index.saturating_sub(self.max_snapshots - 1);
|
||||||
|
for stale_index in 0..oldest_to_keep {
|
||||||
|
let stale_path = self.snapshot_path(stale_index);
|
||||||
|
if stale_path.exists()
|
||||||
|
&& let Err(err) = fs::remove_file(&stale_path)
|
||||||
|
{
|
||||||
|
error!(
|
||||||
|
"Failed to remove old snapshot '{}': {err}",
|
||||||
|
stale_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CanvasPersistence for FileCanvasPersistence {
|
||||||
|
fn save(&self, pixels: &[Color]) -> Result<(), DomainError> {
|
||||||
|
let next_index = self.read_latest_index().map(|i| i + 1).unwrap_or(0);
|
||||||
|
let path = self.snapshot_path(next_index);
|
||||||
|
|
||||||
|
write_pixels_to_file(&path, pixels)?;
|
||||||
|
self.write_latest_index(next_index)?;
|
||||||
|
self.rotate_and_cleanup(next_index);
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Saved canvas snapshot #{next_index} to '{}'",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_latest(&self) -> Result<Option<Vec<Color>>, DomainError> {
|
||||||
|
let Some(index) = self.read_latest_index() else {
|
||||||
|
info!("No canvas snapshots found");
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let path = self.snapshot_path(index);
|
||||||
|
if !path.exists() {
|
||||||
|
info!("Snapshot file '{}' not found", path.display());
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pixels = read_pixels_from_file(&path)?;
|
||||||
|
info!(
|
||||||
|
"Loaded canvas snapshot #{index} from '{}' ({} pixels)",
|
||||||
|
path.display(),
|
||||||
|
pixels.len()
|
||||||
|
);
|
||||||
|
Ok(Some(pixels))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_pixels_to_file(path: &Path, pixels: &[Color]) -> Result<(), DomainError> {
|
||||||
|
let file = fs::File::create(path).map_err(|err| {
|
||||||
|
DomainError::Persistence(format!(
|
||||||
|
"Failed to create snapshot '{}': {err}",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let mut writer = BufWriter::new(file);
|
||||||
|
for color in pixels {
|
||||||
|
writer
|
||||||
|
.write_all(&color.as_u32().to_ne_bytes())
|
||||||
|
.map_err(|err| {
|
||||||
|
DomainError::Persistence(format!(
|
||||||
|
"Failed to write snapshot '{}': {err}",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
writer.flush().map_err(|err| {
|
||||||
|
DomainError::Persistence(format!(
|
||||||
|
"Failed to flush snapshot '{}': {err}",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_pixels_from_file(path: &Path) -> Result<Vec<Color>, DomainError> {
|
||||||
|
let bytes = fs::read(path).map_err(|err| {
|
||||||
|
DomainError::Persistence(format!(
|
||||||
|
"Failed to read snapshot '{}': {err}",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if bytes.len() % 4 != 0 {
|
||||||
|
return Err(DomainError::Persistence(format!(
|
||||||
|
"Snapshot '{}' has invalid size: {} bytes (not a multiple of 4)",
|
||||||
|
path.display(),
|
||||||
|
bytes.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let pixels = bytes
|
||||||
|
.chunks_exact(4)
|
||||||
|
.map(|chunk| Color::new(u32::from_ne_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(pixels)
|
||||||
|
}
|
||||||
7
crates/adapters/config-env/Cargo.toml
Normal file
7
crates/adapters/config-env/Cargo.toml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[package]
|
||||||
|
name = "config-env"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
config = { workspace = true }
|
||||||
70
crates/adapters/config-env/src/lib.rs
Normal file
70
crates/adapters/config-env/src/lib.rs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
use config::{
|
||||||
|
AppConfig, BroadcastConfig, CanvasConfig, ConfigError, ConfigSource, CooldownConfig,
|
||||||
|
RateLimitConfig, ServerConfig, SnapshotConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_ADDRESS: &str = "0.0.0.0";
|
||||||
|
const DEFAULT_PORT: u16 = 3000;
|
||||||
|
const DEFAULT_CANVAS_WIDTH: u32 = 500;
|
||||||
|
const DEFAULT_CANVAS_HEIGHT: u32 = 500;
|
||||||
|
const DEFAULT_COOLDOWN_SECS: u64 = 10;
|
||||||
|
const DEFAULT_RATE_LIMIT_BURST: u32 = 10;
|
||||||
|
const DEFAULT_RATE_LIMIT_PER_SECOND: u64 = 10;
|
||||||
|
const DEFAULT_BROADCAST_CAPACITY: usize = 1024;
|
||||||
|
const DEFAULT_SNAPSHOT_INTERVAL_SECS: u64 = 300;
|
||||||
|
const DEFAULT_SNAPSHOT_MAX: usize = 5;
|
||||||
|
const DEFAULT_SNAPSHOT_DIR: &str = "snapshots/";
|
||||||
|
|
||||||
|
pub struct EnvConfigSource;
|
||||||
|
|
||||||
|
impl ConfigSource for EnvConfigSource {
|
||||||
|
fn load(&self) -> Result<AppConfig, ConfigError> {
|
||||||
|
Ok(AppConfig {
|
||||||
|
server: ServerConfig {
|
||||||
|
address: env_or("ADDRESS", DEFAULT_ADDRESS),
|
||||||
|
port: parse_env("PORT", DEFAULT_PORT)?,
|
||||||
|
enable_cors: parse_bool_env("ENABLE_CORS", true),
|
||||||
|
},
|
||||||
|
canvas: CanvasConfig {
|
||||||
|
width: parse_env("CANVAS_WIDTH", DEFAULT_CANVAS_WIDTH)?,
|
||||||
|
height: parse_env("CANVAS_HEIGHT", DEFAULT_CANVAS_HEIGHT)?,
|
||||||
|
},
|
||||||
|
cooldown: CooldownConfig {
|
||||||
|
placement_secs: parse_env("COOLDOWN_SECS", DEFAULT_COOLDOWN_SECS)?,
|
||||||
|
},
|
||||||
|
rate_limit: RateLimitConfig {
|
||||||
|
burst_size: parse_env("RATE_LIMIT_BURST", DEFAULT_RATE_LIMIT_BURST)?,
|
||||||
|
per_second: parse_env("RATE_LIMIT_PER_SECOND", DEFAULT_RATE_LIMIT_PER_SECOND)?,
|
||||||
|
},
|
||||||
|
broadcast: BroadcastConfig {
|
||||||
|
channel_capacity: parse_env("BROADCAST_CAPACITY", DEFAULT_BROADCAST_CAPACITY)?,
|
||||||
|
},
|
||||||
|
snapshot: SnapshotConfig {
|
||||||
|
enabled: parse_bool_env("SNAPSHOT_ENABLED", true),
|
||||||
|
interval_secs: parse_env("SNAPSHOT_INTERVAL_SECS", DEFAULT_SNAPSHOT_INTERVAL_SECS)?,
|
||||||
|
max_snapshots: parse_env("SNAPSHOT_MAX", DEFAULT_SNAPSHOT_MAX)?,
|
||||||
|
directory: env_or("SNAPSHOT_DIR", DEFAULT_SNAPSHOT_DIR),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env_or(key: &str, default: &str) -> String {
|
||||||
|
std::env::var(key).unwrap_or_else(|_| default.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_bool_env(key: &str, default: bool) -> bool {
|
||||||
|
std::env::var(key)
|
||||||
|
.map(|value| value == "true")
|
||||||
|
.unwrap_or(default)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_env<T: std::str::FromStr>(key: &str, default: T) -> Result<T, ConfigError> {
|
||||||
|
match std::env::var(key) {
|
||||||
|
Ok(value) => value.parse().map_err(|_| ConfigError::InvalidValue {
|
||||||
|
field: key.to_string(),
|
||||||
|
reason: format!("'{value}' is not a valid {}", std::any::type_name::<T>()),
|
||||||
|
}),
|
||||||
|
Err(_) => Ok(default),
|
||||||
|
}
|
||||||
|
}
|
||||||
12
crates/adapters/http-axum/Cargo.toml
Normal file
12
crates/adapters/http-axum/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "http-axum"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
config = { workspace = true }
|
||||||
|
axum = { workspace = true }
|
||||||
|
rust-embed = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tower-http = { workspace = true }
|
||||||
|
tower_governor = { workspace = true }
|
||||||
3
crates/adapters/http-axum/src/lib.rs
Normal file
3
crates/adapters/http-axum/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
mod routes;
|
||||||
|
|
||||||
|
pub use routes::build_router;
|
||||||
80
crates/adapters/http-axum/src/routes.rs
Normal file
80
crates/adapters/http-axum/src/routes.rs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::http::{StatusCode, header};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::{Router, routing::get};
|
||||||
|
use config::RateLimitConfig;
|
||||||
|
use rust_embed::Embed;
|
||||||
|
use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder};
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
|
||||||
|
const RATE_LIMIT_CLEANUP_INTERVAL_SECS: u64 = 1;
|
||||||
|
|
||||||
|
#[derive(Embed)]
|
||||||
|
#[folder = "$CARGO_MANIFEST_DIR/../../../painter-js/dist/"]
|
||||||
|
struct StaticAssets;
|
||||||
|
|
||||||
|
async fn serve_static(path: axum::extract::Path<String>) -> Response {
|
||||||
|
let path = path.0;
|
||||||
|
serve_embedded_file(&path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve_index() -> Response {
|
||||||
|
serve_embedded_file("index.html")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serve_embedded_file(path: &str) -> Response {
|
||||||
|
match StaticAssets::get(path) {
|
||||||
|
Some(file) => {
|
||||||
|
let content_type = file.metadata.mimetype();
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
[(header::CONTENT_TYPE, content_type.to_string())],
|
||||||
|
file.data,
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
None => serve_embedded_file("index.html"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_router(
|
||||||
|
enable_cors: bool,
|
||||||
|
rate_limit_config: &RateLimitConfig,
|
||||||
|
) -> Result<Router, String> {
|
||||||
|
let rate_governor = Arc::new(
|
||||||
|
GovernorConfigBuilder::default()
|
||||||
|
.burst_size(rate_limit_config.burst_size)
|
||||||
|
.per_second(rate_limit_config.per_second)
|
||||||
|
.finish()
|
||||||
|
.ok_or("Invalid rate limit configuration")?,
|
||||||
|
);
|
||||||
|
|
||||||
|
let governor = rate_governor.limiter().clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(
|
||||||
|
RATE_LIMIT_CLEANUP_INTERVAL_SECS,
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
governor.retain_recent();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let router = Router::new()
|
||||||
|
.route("/check/", get(|| async { "OK" }))
|
||||||
|
.route("/{*path}", get(serve_static))
|
||||||
|
.fallback(get(serve_index))
|
||||||
|
.layer(GovernorLayer::new(rate_governor));
|
||||||
|
|
||||||
|
Ok(if enable_cors {
|
||||||
|
router.layer(
|
||||||
|
CorsLayer::new()
|
||||||
|
.allow_origin(Any)
|
||||||
|
.allow_methods(Any)
|
||||||
|
.allow_headers(Any),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
router
|
||||||
|
})
|
||||||
|
}
|
||||||
13
crates/adapters/socketio/Cargo.toml
Normal file
13
crates/adapters/socketio/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "socketio"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
application = { workspace = true }
|
||||||
|
api-types = { workspace = true }
|
||||||
|
domain = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
socketioxide = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
109
crates/adapters/socketio/src/handlers.rs
Normal file
109
crates/adapters/socketio/src/handlers.rs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use api_types::{self, PixelUpdatePayload};
|
||||||
|
use application::AppState;
|
||||||
|
use application::canvas::place_pixel;
|
||||||
|
use domain::{BroadcastEvent, BroadcastSubscription, Color, Position};
|
||||||
|
use socketioxide::extract::{Data, SocketRef};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
pub async fn on_connect(socket: SocketRef, state: Arc<AppState>) {
|
||||||
|
info!("Socket connected: {:?} {:?}", socket.ns(), socket.id);
|
||||||
|
|
||||||
|
let subscription = state.broadcaster().subscribe();
|
||||||
|
|
||||||
|
send_canvas_state(&socket, &state);
|
||||||
|
register_soldier(&state, &socket);
|
||||||
|
spawn_broadcast_forwarder(socket.clone(), subscription);
|
||||||
|
register_place_pixel_handler(&socket, state.clone());
|
||||||
|
register_disconnect_handler(&socket, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_canvas_state(socket: &SocketRef, state: &AppState) {
|
||||||
|
let canvas_pixels = application::canvas::get_state::execute(state);
|
||||||
|
let pixel_values = Color::collect_as_u32(&canvas_pixels);
|
||||||
|
socket
|
||||||
|
.emit(api_types::events::CANVAS_STATE, &pixel_values)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_soldier(state: &AppState, socket: &SocketRef) {
|
||||||
|
let socket_id = socket.id.to_string();
|
||||||
|
application::soldiers::connect::execute(state, socket_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_broadcast_forwarder(socket: SocketRef, mut subscription: BroadcastSubscription) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(event) = subscription.recv().await {
|
||||||
|
if forward_broadcast_event(&socket, &event).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn forward_broadcast_event(socket: &SocketRef, event: &BroadcastEvent) -> Result<(), ()> {
|
||||||
|
match event {
|
||||||
|
BroadcastEvent::PixelUpdated(update) => {
|
||||||
|
let payload = PixelUpdatePayload::from(*update);
|
||||||
|
socket
|
||||||
|
.emit(api_types::events::PIXEL_UPDATED, &payload)
|
||||||
|
.map_err(|_| ())
|
||||||
|
}
|
||||||
|
BroadcastEvent::SoldierCountChanged(count) => socket
|
||||||
|
.emit(api_types::events::CURRENT_SOLDIERS, count)
|
||||||
|
.map_err(|_| ()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_place_pixel_handler(socket: &SocketRef, state: Arc<AppState>) {
|
||||||
|
socket.on(
|
||||||
|
api_types::events::PLACE_PIXEL,
|
||||||
|
move |socket: SocketRef, Data::<PixelUpdatePayload>(payload)| async move {
|
||||||
|
handle_place_pixel(&socket, &state, payload);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_place_pixel(socket: &SocketRef, state: &AppState, payload: PixelUpdatePayload) {
|
||||||
|
let position = Position::new(payload.x, payload.y);
|
||||||
|
let color = Color::new(payload.color);
|
||||||
|
|
||||||
|
info!("Received pixel update: {position} color={}", color.as_u32());
|
||||||
|
|
||||||
|
let socket_id = socket.id.to_string();
|
||||||
|
let command = place_pixel::Command {
|
||||||
|
user_id: &socket_id,
|
||||||
|
position,
|
||||||
|
color,
|
||||||
|
};
|
||||||
|
|
||||||
|
match place_pixel::execute(state, command) {
|
||||||
|
Ok(place_pixel::Outcome::Placed(_)) => {}
|
||||||
|
Ok(place_pixel::Outcome::CooldownActive) => {
|
||||||
|
emit_error(socket, domain::COOLDOWN_MESSAGE);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
emit_error(socket, &err.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_disconnect_handler(socket: &SocketRef, state: Arc<AppState>) {
|
||||||
|
socket.on_disconnect(move |socket: SocketRef| async move {
|
||||||
|
handle_disconnect(&socket, &state);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_disconnect(socket: &SocketRef, state: &AppState) {
|
||||||
|
info!("Socket disconnected: {:?}", socket.id);
|
||||||
|
let socket_id = socket.id.to_string();
|
||||||
|
application::soldiers::disconnect::execute(state, &socket_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_error(socket: &SocketRef, message: &str) {
|
||||||
|
let _ = socket.emit(
|
||||||
|
api_types::events::ERROR,
|
||||||
|
&serde_json::Value::String(message.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
12
crates/adapters/socketio/src/lib.rs
Normal file
12
crates/adapters/socketio/src/lib.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
mod handlers;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use application::AppState;
|
||||||
|
use socketioxide::{SocketIo, extract::SocketRef};
|
||||||
|
|
||||||
|
pub fn setup_namespaces(io: &SocketIo, state: Arc<AppState>) {
|
||||||
|
io.ns("/", move |socket: SocketRef| async move {
|
||||||
|
handlers::on_connect(socket, state).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
14
crates/adapters/websocket/Cargo.toml
Normal file
14
crates/adapters/websocket/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "websocket"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
application = { workspace = true }
|
||||||
|
domain = { workspace = true }
|
||||||
|
axum = { workspace = true, features = ["ws"] }
|
||||||
|
futures = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
163
crates/adapters/websocket/src/handler.rs
Normal file
163
crates/adapters/websocket/src/handler.rs
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use domain::{BroadcastEvent, BroadcastSubscription, Color, Position};
|
||||||
|
use futures::{SinkExt, StreamExt, stream::SplitSink};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
use crate::WsState;
|
||||||
|
use crate::messages::{ClientMessage, ServerMessage};
|
||||||
|
use application::AppState;
|
||||||
|
use application::canvas::place_pixel;
|
||||||
|
|
||||||
|
type WsSender = SplitSink<WebSocket, Message>;
|
||||||
|
|
||||||
|
pub async fn ws_upgrade(
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
|
State(state): State<Arc<WsState>>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
ws.on_upgrade(|socket| handle_connection(socket, state))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_connection(socket: WebSocket, state: Arc<WsState>) {
|
||||||
|
let (mut sender, mut receiver) = socket.split();
|
||||||
|
|
||||||
|
let connection_id = state
|
||||||
|
.connection_counter
|
||||||
|
.fetch_add(1, Ordering::Relaxed)
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
info!("WebSocket connected: {connection_id}");
|
||||||
|
|
||||||
|
// Subscribe before snapshotting to avoid missing updates
|
||||||
|
let subscription = state.app_state.broadcaster().subscribe();
|
||||||
|
|
||||||
|
if !send_canvas_snapshot(&mut sender, &state.app_state).await {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
application::soldiers::connect::execute(&state.app_state, connection_id.clone());
|
||||||
|
|
||||||
|
let (error_sender, error_receiver) = mpsc::unbounded_channel::<String>();
|
||||||
|
|
||||||
|
let mut send_task = tokio::spawn(run_send_loop(sender, subscription, error_receiver));
|
||||||
|
|
||||||
|
let app_state = state.app_state.clone();
|
||||||
|
let recv_connection_id = connection_id.clone();
|
||||||
|
let mut recv_task = tokio::spawn(async move {
|
||||||
|
while let Some(Ok(message)) = receiver.next().await {
|
||||||
|
if let Message::Text(text) = message {
|
||||||
|
handle_client_message(&app_state, &error_sender, &recv_connection_id, &text);
|
||||||
|
} else if let Message::Close(_) = message {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = &mut send_task => recv_task.abort(),
|
||||||
|
_ = &mut recv_task => send_task.abort(),
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("WebSocket disconnected: {connection_id}");
|
||||||
|
application::soldiers::disconnect::execute(&state.app_state, &connection_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_canvas_snapshot(sender: &mut WsSender, state: &AppState) -> bool {
|
||||||
|
let pixels = application::canvas::get_state::execute(state);
|
||||||
|
let bytes = Color::collect_as_bytes(&pixels);
|
||||||
|
sender.send(Message::Binary(bytes.into())).await.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_send_loop(
|
||||||
|
mut sender: WsSender,
|
||||||
|
mut subscription: BroadcastSubscription,
|
||||||
|
mut error_receiver: mpsc::UnboundedReceiver<String>,
|
||||||
|
) {
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
Some(event) = subscription.recv() => {
|
||||||
|
let Some(json) = serialize_broadcast_event(&event) else { continue };
|
||||||
|
if sender.send(Message::Text(json.into())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(error_json) = error_receiver.recv() => {
|
||||||
|
if sender.send(Message::Text(error_json.into())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serialize_broadcast_event(event: &BroadcastEvent) -> Option<String> {
|
||||||
|
let message = match event {
|
||||||
|
BroadcastEvent::PixelUpdated(update) => ServerMessage::from(*update),
|
||||||
|
BroadcastEvent::SoldierCountChanged(count) => {
|
||||||
|
ServerMessage::CurrentSoldiers { count: *count }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
serde_json::to_string(&message)
|
||||||
|
.inspect_err(|err| error!("Failed to serialize broadcast event: {err}"))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_client_message(
|
||||||
|
state: &AppState,
|
||||||
|
error_sender: &mpsc::UnboundedSender<String>,
|
||||||
|
connection_id: &str,
|
||||||
|
text: &str,
|
||||||
|
) {
|
||||||
|
let Ok(message) = serde_json::from_str::<ClientMessage>(text) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
match message {
|
||||||
|
ClientMessage::PlacePixel { x, y, color } => {
|
||||||
|
handle_place_pixel(state, error_sender, connection_id, x, y, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_place_pixel(
|
||||||
|
state: &AppState,
|
||||||
|
error_sender: &mpsc::UnboundedSender<String>,
|
||||||
|
connection_id: &str,
|
||||||
|
x: u32,
|
||||||
|
y: u32,
|
||||||
|
color: u32,
|
||||||
|
) {
|
||||||
|
let command = place_pixel::Command {
|
||||||
|
user_id: connection_id,
|
||||||
|
position: Position::new(x, y),
|
||||||
|
color: Color::new(color),
|
||||||
|
};
|
||||||
|
|
||||||
|
match place_pixel::execute(state, command) {
|
||||||
|
Ok(place_pixel::Outcome::Placed(_)) => {}
|
||||||
|
Ok(place_pixel::Outcome::CooldownActive) => {
|
||||||
|
send_error(error_sender, domain::COOLDOWN_MESSAGE);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
send_error(error_sender, &err.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_error(error_sender: &mpsc::UnboundedSender<String>, message: &str) {
|
||||||
|
let error_message = ServerMessage::Error {
|
||||||
|
message: message.to_string(),
|
||||||
|
};
|
||||||
|
match serde_json::to_string(&error_message) {
|
||||||
|
Ok(json) => {
|
||||||
|
let _ = error_sender.send(json);
|
||||||
|
}
|
||||||
|
Err(err) => error!("Failed to serialize error message: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
24
crates/adapters/websocket/src/lib.rs
Normal file
24
crates/adapters/websocket/src/lib.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
mod handler;
|
||||||
|
mod messages;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::AtomicU64;
|
||||||
|
|
||||||
|
use application::AppState;
|
||||||
|
use axum::{Router, routing::get};
|
||||||
|
|
||||||
|
pub(crate) struct WsState {
|
||||||
|
app_state: Arc<AppState>,
|
||||||
|
connection_counter: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_router(state: Arc<AppState>) -> Router {
|
||||||
|
let ws_state = Arc::new(WsState {
|
||||||
|
app_state: state,
|
||||||
|
connection_counter: AtomicU64::new(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
Router::new()
|
||||||
|
.route("/ws", get(handler::ws_upgrade))
|
||||||
|
.with_state(ws_state)
|
||||||
|
}
|
||||||
30
crates/adapters/websocket/src/messages.rs
Normal file
30
crates/adapters/websocket/src/messages.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
use domain::PixelUpdate;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum ClientMessage {
|
||||||
|
#[serde(rename = "place-pixel")]
|
||||||
|
PlacePixel { x: u32, y: u32, color: u32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum ServerMessage {
|
||||||
|
#[serde(rename = "pixel-updated")]
|
||||||
|
PixelUpdated { x: u32, y: u32, color: u32 },
|
||||||
|
#[serde(rename = "current_soldiers")]
|
||||||
|
CurrentSoldiers { count: usize },
|
||||||
|
#[serde(rename = "error")]
|
||||||
|
Error { message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PixelUpdate> for ServerMessage {
|
||||||
|
fn from(update: PixelUpdate) -> Self {
|
||||||
|
Self::PixelUpdated {
|
||||||
|
x: update.position().x(),
|
||||||
|
y: update.position().y(),
|
||||||
|
color: update.color().as_u32(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
crates/api-types/Cargo.toml
Normal file
8
crates/api-types/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[package]
|
||||||
|
name = "api-types"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
27
crates/api-types/src/lib.rs
Normal file
27
crates/api-types/src/lib.rs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
use domain::PixelUpdate;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub mod events {
|
||||||
|
pub const CANVAS_STATE: &str = "canvas_state";
|
||||||
|
pub const PIXEL_UPDATED: &str = "pixel-updated";
|
||||||
|
pub const PLACE_PIXEL: &str = "place-pixel";
|
||||||
|
pub const CURRENT_SOLDIERS: &str = "current_soldiers";
|
||||||
|
pub const ERROR: &str = "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct PixelUpdatePayload {
|
||||||
|
pub x: u32,
|
||||||
|
pub y: u32,
|
||||||
|
pub color: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PixelUpdate> for PixelUpdatePayload {
|
||||||
|
fn from(update: PixelUpdate) -> Self {
|
||||||
|
Self {
|
||||||
|
x: update.position().x(),
|
||||||
|
y: update.position().y(),
|
||||||
|
color: update.color().as_u32(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
crates/application/Cargo.toml
Normal file
10
crates/application/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "application"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
9
crates/application/src/canvas/get_state.rs
Normal file
9
crates/application/src/canvas/get_state.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::Color;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
pub fn execute(state: &AppState) -> Arc<[Color]> {
|
||||||
|
state.canvas().pixels()
|
||||||
|
}
|
||||||
4
crates/application/src/canvas/mod.rs
Normal file
4
crates/application/src/canvas/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod get_state;
|
||||||
|
pub mod place_pixel;
|
||||||
|
pub mod restore_snapshot;
|
||||||
|
pub mod save_snapshot;
|
||||||
29
crates/application/src/canvas/place_pixel.rs
Normal file
29
crates/application/src/canvas/place_pixel.rs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
use domain::{BroadcastEvent, Color, PixelUpdate, Position};
|
||||||
|
|
||||||
|
use crate::{AppState, ApplicationError};
|
||||||
|
|
||||||
|
pub struct Command<'a> {
|
||||||
|
pub user_id: &'a str,
|
||||||
|
pub position: Position,
|
||||||
|
pub color: Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Outcome {
|
||||||
|
Placed(PixelUpdate),
|
||||||
|
CooldownActive,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn execute(state: &AppState, command: Command<'_>) -> Result<Outcome, ApplicationError> {
|
||||||
|
if state.cooldowns().is_on_cooldown(command.user_id) {
|
||||||
|
return Ok(Outcome::CooldownActive);
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.canvas()
|
||||||
|
.place_pixel(command.position, command.color)?;
|
||||||
|
state.cooldowns().record(command.user_id);
|
||||||
|
let update = PixelUpdate::new(command.position, command.color);
|
||||||
|
state
|
||||||
|
.broadcaster()
|
||||||
|
.publish(BroadcastEvent::PixelUpdated(update));
|
||||||
|
Ok(Outcome::Placed(update))
|
||||||
|
}
|
||||||
14
crates/application/src/canvas/restore_snapshot.rs
Normal file
14
crates/application/src/canvas/restore_snapshot.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
use crate::{AppState, ApplicationError};
|
||||||
|
|
||||||
|
pub fn execute(state: &AppState) -> Result<bool, ApplicationError> {
|
||||||
|
let Some(persistence) = state.persistence() else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
match persistence.load_latest()? {
|
||||||
|
Some(pixels) => {
|
||||||
|
state.canvas().restore(pixels)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
None => Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
10
crates/application/src/canvas/save_snapshot.rs
Normal file
10
crates/application/src/canvas/save_snapshot.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
use crate::{AppState, ApplicationError};
|
||||||
|
|
||||||
|
pub fn execute(state: &AppState) -> Result<(), ApplicationError> {
|
||||||
|
let Some(persistence) = state.persistence() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let pixels = state.canvas().pixels();
|
||||||
|
persistence.save(&pixels)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
7
crates/application/src/errors.rs
Normal file
7
crates/application/src/errors.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ApplicationError {
|
||||||
|
#[error(transparent)]
|
||||||
|
Domain(#[from] domain::DomainError),
|
||||||
|
}
|
||||||
8
crates/application/src/lib.rs
Normal file
8
crates/application/src/lib.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
mod errors;
|
||||||
|
mod state;
|
||||||
|
|
||||||
|
pub mod canvas;
|
||||||
|
pub mod soldiers;
|
||||||
|
|
||||||
|
pub use errors::ApplicationError;
|
||||||
|
pub use state::{AppState, InMemoryCanvasStore, InProcessBroadcaster};
|
||||||
11
crates/application/src/soldiers/connect.rs
Normal file
11
crates/application/src/soldiers/connect.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
use domain::BroadcastEvent;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
pub fn execute(state: &AppState, user_id: String) -> usize {
|
||||||
|
let count = state.soldiers().add(user_id);
|
||||||
|
state
|
||||||
|
.broadcaster()
|
||||||
|
.publish(BroadcastEvent::SoldierCountChanged(count));
|
||||||
|
count
|
||||||
|
}
|
||||||
12
crates/application/src/soldiers/disconnect.rs
Normal file
12
crates/application/src/soldiers/disconnect.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
use domain::BroadcastEvent;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
pub fn execute(state: &AppState, user_id: &str) -> usize {
|
||||||
|
state.cooldowns().remove(user_id);
|
||||||
|
let count = state.soldiers().remove(user_id);
|
||||||
|
state
|
||||||
|
.broadcaster()
|
||||||
|
.publish(BroadcastEvent::SoldierCountChanged(count));
|
||||||
|
count
|
||||||
|
}
|
||||||
2
crates/application/src/soldiers/mod.rs
Normal file
2
crates/application/src/soldiers/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod connect;
|
||||||
|
pub mod disconnect;
|
||||||
199
crates/application/src/state.rs
Normal file
199
crates/application/src/state.rs
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::future::Future;
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::sync::{Arc, Mutex, MutexGuard};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use domain::ports::{
|
||||||
|
BroadcastReceiverInner, BroadcastSubscription, CanvasPersistence, CanvasStore, EventBroadcaster,
|
||||||
|
};
|
||||||
|
use domain::{BroadcastEvent, Canvas, Color, DomainError, Position};
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
const INITIAL_CONNECTION_CAPACITY: usize = 128;
|
||||||
|
|
||||||
|
pub struct AppState {
|
||||||
|
canvas: Box<dyn CanvasStore>,
|
||||||
|
broadcaster: Box<dyn EventBroadcaster>,
|
||||||
|
persistence: Option<Box<dyn CanvasPersistence>>,
|
||||||
|
cooldowns: CooldownTracker,
|
||||||
|
soldiers: SoldierTracker,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn new(
|
||||||
|
canvas: Box<dyn CanvasStore>,
|
||||||
|
broadcaster: Box<dyn EventBroadcaster>,
|
||||||
|
cooldown: Duration,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
canvas,
|
||||||
|
broadcaster,
|
||||||
|
persistence: None,
|
||||||
|
cooldowns: CooldownTracker::new(cooldown),
|
||||||
|
soldiers: SoldierTracker::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_persistence(mut self, persistence: Box<dyn CanvasPersistence>) -> Self {
|
||||||
|
self.persistence = Some(persistence);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn canvas(&self) -> &dyn CanvasStore {
|
||||||
|
&*self.canvas
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn broadcaster(&self) -> &dyn EventBroadcaster {
|
||||||
|
&*self.broadcaster
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn persistence(&self) -> Option<&dyn CanvasPersistence> {
|
||||||
|
self.persistence.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cooldowns(&self) -> &CooldownTracker {
|
||||||
|
&self.cooldowns
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn soldiers(&self) -> &SoldierTracker {
|
||||||
|
&self.soldiers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct InProcessBroadcaster {
|
||||||
|
sender: broadcast::Sender<BroadcastEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InProcessBroadcaster {
|
||||||
|
pub fn new(sender: broadcast::Sender<BroadcastEvent>) -> Self {
|
||||||
|
Self { sender }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventBroadcaster for InProcessBroadcaster {
|
||||||
|
fn publish(&self, event: BroadcastEvent) {
|
||||||
|
let _ = self.sender.send(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscribe(&self) -> BroadcastSubscription {
|
||||||
|
BroadcastSubscription::new(Box::new(TokioBroadcastReceiver(self.sender.subscribe())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TokioBroadcastReceiver(broadcast::Receiver<BroadcastEvent>);
|
||||||
|
|
||||||
|
impl BroadcastReceiverInner for TokioBroadcastReceiver {
|
||||||
|
fn recv_boxed(&mut self) -> Pin<Box<dyn Future<Output = Option<BroadcastEvent>> + Send + '_>> {
|
||||||
|
Box::pin(async { self.0.recv().await.ok() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn acquire_lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
|
||||||
|
mutex.lock().unwrap_or_else(|poisoned| {
|
||||||
|
warn!("Recovered from poisoned mutex");
|
||||||
|
poisoned.into_inner()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CanvasState {
|
||||||
|
canvas: Canvas,
|
||||||
|
snapshot: Option<Arc<[Color]>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct InMemoryCanvasStore {
|
||||||
|
state: Mutex<CanvasState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InMemoryCanvasStore {
|
||||||
|
pub fn new(width: u32, height: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
state: Mutex::new(CanvasState {
|
||||||
|
canvas: Canvas::new(width, height),
|
||||||
|
snapshot: None,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CanvasStore for InMemoryCanvasStore {
|
||||||
|
fn pixels(&self) -> Arc<[Color]> {
|
||||||
|
let mut state = acquire_lock(&self.state);
|
||||||
|
if let Some(ref cached) = state.snapshot {
|
||||||
|
return cached.clone();
|
||||||
|
}
|
||||||
|
let new_snapshot: Arc<[Color]> = Arc::from(state.canvas.pixels());
|
||||||
|
state.snapshot = Some(new_snapshot.clone());
|
||||||
|
new_snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
fn place_pixel(&self, position: Position, color: Color) -> Result<(), DomainError> {
|
||||||
|
let mut state = acquire_lock(&self.state);
|
||||||
|
state.canvas.place_pixel(position, color)?;
|
||||||
|
state.snapshot = None;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore(&self, pixels: Vec<Color>) -> Result<(), DomainError> {
|
||||||
|
let mut state = acquire_lock(&self.state);
|
||||||
|
let new_canvas = Canvas::from_pixels(state.canvas.width(), state.canvas.height(), pixels)?;
|
||||||
|
state.canvas = new_canvas;
|
||||||
|
state.snapshot = None;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CooldownTracker {
|
||||||
|
entries: Mutex<HashMap<String, Instant>>,
|
||||||
|
cooldown: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CooldownTracker {
|
||||||
|
pub fn new(cooldown: Duration) -> Self {
|
||||||
|
Self {
|
||||||
|
entries: Mutex::new(HashMap::with_capacity(INITIAL_CONNECTION_CAPACITY)),
|
||||||
|
cooldown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_on_cooldown(&self, user_id: &str) -> bool {
|
||||||
|
let entries = acquire_lock(&self.entries);
|
||||||
|
entries
|
||||||
|
.get(user_id)
|
||||||
|
.map(|last| last.elapsed() < self.cooldown)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record(&self, user_id: &str) {
|
||||||
|
acquire_lock(&self.entries).insert(user_id.to_string(), Instant::now());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(&self, user_id: &str) {
|
||||||
|
acquire_lock(&self.entries).remove(user_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SoldierTracker {
|
||||||
|
connected: Mutex<HashSet<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SoldierTracker {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
connected: Mutex::new(HashSet::with_capacity(INITIAL_CONNECTION_CAPACITY)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add(&self, user_id: String) -> usize {
|
||||||
|
let mut connected = acquire_lock(&self.connected);
|
||||||
|
connected.insert(user_id);
|
||||||
|
connected.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(&self, user_id: &str) -> usize {
|
||||||
|
let mut connected = acquire_lock(&self.connected);
|
||||||
|
connected.remove(user_id);
|
||||||
|
connected.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
109
crates/application/tests/common/mod.rs
Normal file
109
crates/application/tests/common/mod.rs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
use std::future::Future;
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use application::{AppState, InMemoryCanvasStore};
|
||||||
|
use domain::ports::{
|
||||||
|
BroadcastReceiverInner, BroadcastSubscription, CanvasPersistence, EventBroadcaster,
|
||||||
|
};
|
||||||
|
use domain::{BroadcastEvent, Color, DomainError};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SpyBroadcaster {
|
||||||
|
events: Arc<Mutex<Vec<BroadcastEvent>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SpyBroadcaster {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
events: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn events(&self) -> Vec<BroadcastEvent> {
|
||||||
|
self.events.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn event_count(&self) -> usize {
|
||||||
|
self.events.lock().unwrap().len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventBroadcaster for SpyBroadcaster {
|
||||||
|
fn publish(&self, event: BroadcastEvent) {
|
||||||
|
self.events.lock().unwrap().push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscribe(&self) -> BroadcastSubscription {
|
||||||
|
BroadcastSubscription::new(Box::new(NoopReceiver))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NoopReceiver;
|
||||||
|
|
||||||
|
impl BroadcastReceiverInner for NoopReceiver {
|
||||||
|
fn recv_boxed(&mut self) -> Pin<Box<dyn Future<Output = Option<BroadcastEvent>> + Send + '_>> {
|
||||||
|
Box::pin(async { None })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FakePersistence {
|
||||||
|
saved: Arc<Mutex<Vec<Vec<Color>>>>,
|
||||||
|
to_load: Mutex<Option<Vec<Color>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakePersistence {
|
||||||
|
pub fn empty() -> Self {
|
||||||
|
Self {
|
||||||
|
saved: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
to_load: Mutex::new(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_snapshot(pixels: Vec<Color>) -> Self {
|
||||||
|
Self {
|
||||||
|
saved: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
to_load: Mutex::new(Some(pixels)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn saved_ref(&self) -> Arc<Mutex<Vec<Vec<Color>>>> {
|
||||||
|
self.saved.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CanvasPersistence for FakePersistence {
|
||||||
|
fn save(&self, pixels: &[Color]) -> Result<(), DomainError> {
|
||||||
|
self.saved.lock().unwrap().push(pixels.to_vec());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_latest(&self) -> Result<Option<Vec<Color>>, DomainError> {
|
||||||
|
Ok(self.to_load.lock().unwrap().clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn test_state() -> (Arc<AppState>, SpyBroadcaster) {
|
||||||
|
test_state_sized(10, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn test_state_sized(width: u32, height: u32) -> (Arc<AppState>, SpyBroadcaster) {
|
||||||
|
let spy = SpyBroadcaster::new();
|
||||||
|
let state = AppState::new(
|
||||||
|
Box::new(InMemoryCanvasStore::new(width, height)),
|
||||||
|
Box::new(spy.clone()),
|
||||||
|
Duration::from_secs(10),
|
||||||
|
);
|
||||||
|
(Arc::new(state), spy)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn test_state_no_cooldown() -> (Arc<AppState>, SpyBroadcaster) {
|
||||||
|
let spy = SpyBroadcaster::new();
|
||||||
|
let state = AppState::new(
|
||||||
|
Box::new(InMemoryCanvasStore::new(10, 10)),
|
||||||
|
Box::new(spy.clone()),
|
||||||
|
Duration::ZERO,
|
||||||
|
);
|
||||||
|
(Arc::new(state), spy)
|
||||||
|
}
|
||||||
91
crates/application/tests/place_pixel.rs
Normal file
91
crates/application/tests/place_pixel.rs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
mod common;
|
||||||
|
|
||||||
|
use application::canvas::place_pixel::{Command, Outcome};
|
||||||
|
use domain::{BroadcastEvent, Color, Position};
|
||||||
|
|
||||||
|
macro_rules! place {
|
||||||
|
($state:expr, $user:expr, $x:expr, $y:expr, $color:expr) => {
|
||||||
|
application::canvas::place_pixel::execute(
|
||||||
|
&$state,
|
||||||
|
Command {
|
||||||
|
user_id: $user,
|
||||||
|
position: Position::new($x, $y),
|
||||||
|
color: Color::new($color),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn successful_placement_returns_update() {
|
||||||
|
let (state, _) = common::test_state();
|
||||||
|
let result = place!(state, "user-1", 3, 4, 0xFF0000).unwrap();
|
||||||
|
|
||||||
|
let Outcome::Placed(update) = result else {
|
||||||
|
panic!("expected Placed outcome");
|
||||||
|
};
|
||||||
|
assert_eq!(update.position(), Position::new(3, 4));
|
||||||
|
assert_eq!(update.color(), Color::new(0xFF0000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn placement_updates_canvas() {
|
||||||
|
let (state, _) = common::test_state();
|
||||||
|
place!(state, "user-1", 5, 5, 0xAA).unwrap();
|
||||||
|
|
||||||
|
let pixels = application::canvas::get_state::execute(&state);
|
||||||
|
let idx = 5 * 10 + 5;
|
||||||
|
assert_eq!(pixels[idx], Color::new(0xAA));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn placement_publishes_broadcast_event() {
|
||||||
|
let (state, spy) = common::test_state();
|
||||||
|
place!(state, "user-1", 0, 0, 0xFF).unwrap();
|
||||||
|
|
||||||
|
let events = spy.events();
|
||||||
|
assert_eq!(events.len(), 1);
|
||||||
|
assert!(matches!(events[0], BroadcastEvent::PixelUpdated(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cooldown_blocks_rapid_placement() {
|
||||||
|
let (state, _) = common::test_state();
|
||||||
|
place!(state, "user-1", 0, 0, 0xFF).unwrap();
|
||||||
|
|
||||||
|
let result = place!(state, "user-1", 1, 1, 0xAA).unwrap();
|
||||||
|
assert!(matches!(result, Outcome::CooldownActive));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cooldown_is_per_user() {
|
||||||
|
let (state, _) = common::test_state();
|
||||||
|
place!(state, "user-1", 0, 0, 0xFF).unwrap();
|
||||||
|
|
||||||
|
let result = place!(state, "user-2", 1, 1, 0xAA).unwrap();
|
||||||
|
assert!(matches!(result, Outcome::Placed(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_cooldown_allows_rapid_placement() {
|
||||||
|
let (state, _) = common::test_state_no_cooldown();
|
||||||
|
place!(state, "user-1", 0, 0, 0xFF).unwrap();
|
||||||
|
|
||||||
|
let result = place!(state, "user-1", 1, 1, 0xAA).unwrap();
|
||||||
|
assert!(matches!(result, Outcome::Placed(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn out_of_bounds_returns_error() {
|
||||||
|
let (state, _) = common::test_state();
|
||||||
|
assert!(place!(state, "user-1", 99, 99, 0xFF).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_placement_does_not_trigger_cooldown() {
|
||||||
|
let (state, _) = common::test_state();
|
||||||
|
let _ = place!(state, "user-1", 99, 99, 0xFF);
|
||||||
|
|
||||||
|
let result = place!(state, "user-1", 0, 0, 0xFF).unwrap();
|
||||||
|
assert!(matches!(result, Outcome::Placed(_)));
|
||||||
|
}
|
||||||
75
crates/application/tests/snapshots.rs
Normal file
75
crates/application/tests/snapshots.rs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
mod common;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use application::AppState;
|
||||||
|
use domain::Color;
|
||||||
|
|
||||||
|
fn state_with_persistence(persistence: common::FakePersistence) -> Arc<AppState> {
|
||||||
|
let spy = common::SpyBroadcaster::new();
|
||||||
|
let state = AppState::new(
|
||||||
|
Box::new(application::InMemoryCanvasStore::new(10, 10)),
|
||||||
|
Box::new(spy),
|
||||||
|
std::time::Duration::from_secs(10),
|
||||||
|
)
|
||||||
|
.with_persistence(Box::new(persistence));
|
||||||
|
Arc::new(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_snapshot_persists_current_canvas() {
|
||||||
|
let persistence = common::FakePersistence::empty();
|
||||||
|
let saved_ref = persistence.saved_ref();
|
||||||
|
let state = state_with_persistence(persistence);
|
||||||
|
|
||||||
|
application::canvas::place_pixel::execute(
|
||||||
|
&state,
|
||||||
|
application::canvas::place_pixel::Command {
|
||||||
|
user_id: "user",
|
||||||
|
position: domain::Position::new(0, 0),
|
||||||
|
color: Color::new(0xFF),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
application::canvas::save_snapshot::execute(&state).unwrap();
|
||||||
|
|
||||||
|
let snapshots = saved_ref.lock().unwrap();
|
||||||
|
assert_eq!(snapshots.len(), 1);
|
||||||
|
assert_eq!(snapshots[0][0], Color::new(0xFF));
|
||||||
|
assert_eq!(snapshots[0].len(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_snapshot_loads_canvas() {
|
||||||
|
let mut snapshot = vec![Color::white(); 100];
|
||||||
|
snapshot[0] = Color::new(0xDEAD);
|
||||||
|
snapshot[99] = Color::new(0xBEEF);
|
||||||
|
|
||||||
|
let state = state_with_persistence(common::FakePersistence::with_snapshot(snapshot));
|
||||||
|
|
||||||
|
let restored = application::canvas::restore_snapshot::execute(&state).unwrap();
|
||||||
|
assert!(restored);
|
||||||
|
|
||||||
|
let pixels = application::canvas::get_state::execute(&state);
|
||||||
|
assert_eq!(pixels[0], Color::new(0xDEAD));
|
||||||
|
assert_eq!(pixels[99], Color::new(0xBEEF));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_returns_false_when_no_snapshot() {
|
||||||
|
let state = state_with_persistence(common::FakePersistence::empty());
|
||||||
|
assert!(!application::canvas::restore_snapshot::execute(&state).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_without_persistence_is_noop() {
|
||||||
|
let (state, _) = common::test_state();
|
||||||
|
assert!(application::canvas::save_snapshot::execute(&state).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_without_persistence_returns_false() {
|
||||||
|
let (state, _) = common::test_state();
|
||||||
|
assert!(!application::canvas::restore_snapshot::execute(&state).unwrap());
|
||||||
|
}
|
||||||
96
crates/application/tests/soldiers.rs
Normal file
96
crates/application/tests/soldiers.rs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
mod common;
|
||||||
|
|
||||||
|
use domain::BroadcastEvent;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connect_increments_count() {
|
||||||
|
let (state, _) = common::test_state();
|
||||||
|
assert_eq!(
|
||||||
|
application::soldiers::connect::execute(&state, "a".into()),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
application::soldiers::connect::execute(&state, "b".into()),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
application::soldiers::connect::execute(&state, "c".into()),
|
||||||
|
3
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disconnect_decrements_count() {
|
||||||
|
let (state, _) = common::test_state();
|
||||||
|
application::soldiers::connect::execute(&state, "a".into());
|
||||||
|
application::soldiers::connect::execute(&state, "b".into());
|
||||||
|
|
||||||
|
assert_eq!(application::soldiers::disconnect::execute(&state, "a"), 1);
|
||||||
|
assert_eq!(application::soldiers::disconnect::execute(&state, "b"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disconnect_unknown_user_is_harmless() {
|
||||||
|
let (state, _) = common::test_state();
|
||||||
|
application::soldiers::connect::execute(&state, "a".into());
|
||||||
|
assert_eq!(
|
||||||
|
application::soldiers::disconnect::execute(&state, "unknown"),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connect_publishes_soldier_count() {
|
||||||
|
let (state, spy) = common::test_state();
|
||||||
|
application::soldiers::connect::execute(&state, "a".into());
|
||||||
|
|
||||||
|
let events = spy.events();
|
||||||
|
assert_eq!(events.len(), 1);
|
||||||
|
assert!(matches!(events[0], BroadcastEvent::SoldierCountChanged(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disconnect_publishes_soldier_count() {
|
||||||
|
let (state, spy) = common::test_state();
|
||||||
|
application::soldiers::connect::execute(&state, "a".into());
|
||||||
|
application::soldiers::disconnect::execute(&state, "a");
|
||||||
|
|
||||||
|
let events = spy.events();
|
||||||
|
assert_eq!(events.len(), 2);
|
||||||
|
assert!(matches!(events[1], BroadcastEvent::SoldierCountChanged(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disconnect_clears_cooldown() {
|
||||||
|
let (state, _) = common::test_state();
|
||||||
|
application::soldiers::connect::execute(&state, "user-1".into());
|
||||||
|
|
||||||
|
application::canvas::place_pixel::execute(
|
||||||
|
&state,
|
||||||
|
application::canvas::place_pixel::Command {
|
||||||
|
user_id: "user-1",
|
||||||
|
position: domain::Position::new(0, 0),
|
||||||
|
color: domain::Color::new(0xFF),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
application::soldiers::disconnect::execute(&state, "user-1");
|
||||||
|
|
||||||
|
// Reconnect with same ID — cooldown should be gone
|
||||||
|
application::soldiers::connect::execute(&state, "user-1".into());
|
||||||
|
let result = application::canvas::place_pixel::execute(
|
||||||
|
&state,
|
||||||
|
application::canvas::place_pixel::Command {
|
||||||
|
user_id: "user-1",
|
||||||
|
position: domain::Position::new(1, 1),
|
||||||
|
color: domain::Color::new(0xAA),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
result,
|
||||||
|
application::canvas::place_pixel::Outcome::Placed(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
7
crates/config/Cargo.toml
Normal file
7
crates/config/Cargo.toml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[package]
|
||||||
|
name = "config"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
thiserror = { workspace = true }
|
||||||
61
crates/config/src/lib.rs
Normal file
61
crates/config/src/lib.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ConfigError {
|
||||||
|
#[error("invalid value for '{field}': {reason}")]
|
||||||
|
InvalidValue { field: String, reason: String },
|
||||||
|
|
||||||
|
#[error("failed to load config: {0}")]
|
||||||
|
LoadFailed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
pub server: ServerConfig,
|
||||||
|
pub canvas: CanvasConfig,
|
||||||
|
pub cooldown: CooldownConfig,
|
||||||
|
pub rate_limit: RateLimitConfig,
|
||||||
|
pub broadcast: BroadcastConfig,
|
||||||
|
pub snapshot: SnapshotConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ServerConfig {
|
||||||
|
pub address: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub enable_cors: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CanvasConfig {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CooldownConfig {
|
||||||
|
pub placement_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RateLimitConfig {
|
||||||
|
pub burst_size: u32,
|
||||||
|
pub per_second: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BroadcastConfig {
|
||||||
|
pub channel_capacity: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SnapshotConfig {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub interval_secs: u64,
|
||||||
|
pub max_snapshots: usize,
|
||||||
|
pub directory: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait ConfigSource {
|
||||||
|
fn load(&self) -> Result<AppConfig, ConfigError>;
|
||||||
|
}
|
||||||
7
crates/domain/Cargo.toml
Normal file
7
crates/domain/Cargo.toml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[package]
|
||||||
|
name = "domain"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
thiserror = { workspace = true }
|
||||||
57
crates/domain/src/canvas.rs
Normal file
57
crates/domain/src/canvas.rs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
use crate::{Color, DomainError, PixelUpdate, Position};
|
||||||
|
|
||||||
|
pub struct Canvas {
|
||||||
|
pixels: Vec<Color>,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Canvas {
|
||||||
|
pub fn new(width: u32, height: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
pixels: vec![Color::white(); (width * height) as usize],
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_pixels(width: u32, height: u32, pixels: Vec<Color>) -> Result<Self, DomainError> {
|
||||||
|
let expected = (width * height) as usize;
|
||||||
|
if pixels.len() != expected {
|
||||||
|
return Err(DomainError::InvalidCanvasData {
|
||||||
|
expected_width: width,
|
||||||
|
expected_height: height,
|
||||||
|
actual: pixels.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
pixels,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn place_pixel(
|
||||||
|
&mut self,
|
||||||
|
position: Position,
|
||||||
|
color: Color,
|
||||||
|
) -> Result<PixelUpdate, DomainError> {
|
||||||
|
if position.x() >= self.width || position.y() >= self.height {
|
||||||
|
return Err(DomainError::PixelOutOfBounds(position));
|
||||||
|
}
|
||||||
|
self.pixels[(position.y() * self.width + position.x()) as usize] = color;
|
||||||
|
Ok(PixelUpdate::new(position, color))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pixels(&self) -> &[Color] {
|
||||||
|
&self.pixels
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn width(&self) -> u32 {
|
||||||
|
self.width
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn height(&self) -> u32 {
|
||||||
|
self.height
|
||||||
|
}
|
||||||
|
}
|
||||||
21
crates/domain/src/errors.rs
Normal file
21
crates/domain/src/errors.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::Position;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum DomainError {
|
||||||
|
#[error("pixel position {0} is out of bounds")]
|
||||||
|
PixelOutOfBounds(Position),
|
||||||
|
|
||||||
|
#[error(
|
||||||
|
"invalid canvas dimensions: expected {expected_width}x{expected_height}, got {actual} pixels"
|
||||||
|
)]
|
||||||
|
InvalidCanvasData {
|
||||||
|
expected_width: u32,
|
||||||
|
expected_height: u32,
|
||||||
|
actual: usize,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[error("persistence error: {0}")]
|
||||||
|
Persistence(String),
|
||||||
|
}
|
||||||
7
crates/domain/src/events.rs
Normal file
7
crates/domain/src/events.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
use crate::PixelUpdate;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum BroadcastEvent {
|
||||||
|
PixelUpdated(PixelUpdate),
|
||||||
|
SoldierCountChanged(usize),
|
||||||
|
}
|
||||||
13
crates/domain/src/lib.rs
Normal file
13
crates/domain/src/lib.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
pub mod canvas;
|
||||||
|
pub mod errors;
|
||||||
|
pub mod events;
|
||||||
|
pub mod ports;
|
||||||
|
pub mod value_objects;
|
||||||
|
|
||||||
|
pub use canvas::Canvas;
|
||||||
|
pub use errors::DomainError;
|
||||||
|
pub use events::BroadcastEvent;
|
||||||
|
pub use ports::BroadcastSubscription;
|
||||||
|
pub use value_objects::{Color, PixelUpdate, Position};
|
||||||
|
|
||||||
|
pub const COOLDOWN_MESSAGE: &str = "You can only place one pixel per minute";
|
||||||
39
crates/domain/src/ports.rs
Normal file
39
crates/domain/src/ports.rs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
use std::future::Future;
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::{BroadcastEvent, Color, DomainError, Position};
|
||||||
|
|
||||||
|
pub trait CanvasStore: Send + Sync {
|
||||||
|
fn pixels(&self) -> Arc<[Color]>;
|
||||||
|
fn place_pixel(&self, position: Position, color: Color) -> Result<(), DomainError>;
|
||||||
|
fn restore(&self, pixels: Vec<Color>) -> Result<(), DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait CanvasPersistence: Send + Sync {
|
||||||
|
fn save(&self, pixels: &[Color]) -> Result<(), DomainError>;
|
||||||
|
fn load_latest(&self) -> Result<Option<Vec<Color>>, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait EventBroadcaster: Send + Sync {
|
||||||
|
fn publish(&self, event: BroadcastEvent);
|
||||||
|
fn subscribe(&self) -> BroadcastSubscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait BroadcastReceiverInner: Send {
|
||||||
|
fn recv_boxed(&mut self) -> Pin<Box<dyn Future<Output = Option<BroadcastEvent>> + Send + '_>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct BroadcastSubscription {
|
||||||
|
inner: Box<dyn BroadcastReceiverInner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BroadcastSubscription {
|
||||||
|
pub fn new(inner: Box<dyn BroadcastReceiverInner>) -> Self {
|
||||||
|
Self { inner }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn recv(&mut self) -> Option<BroadcastEvent> {
|
||||||
|
self.inner.recv_boxed().await
|
||||||
|
}
|
||||||
|
}
|
||||||
76
crates/domain/src/value_objects.rs
Normal file
76
crates/domain/src/value_objects.rs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
#[repr(transparent)]
|
||||||
|
pub struct Color(u32);
|
||||||
|
|
||||||
|
impl Color {
|
||||||
|
pub fn new(value: u32) -> Self {
|
||||||
|
Self(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_u32(self) -> u32 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn white() -> Self {
|
||||||
|
Self(0xFFFFFFFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn collect_as_u32(colors: &[Color]) -> Vec<u32> {
|
||||||
|
colors.iter().map(|color| color.as_u32()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn collect_as_bytes(colors: &[Color]) -> Vec<u8> {
|
||||||
|
colors
|
||||||
|
.iter()
|
||||||
|
.flat_map(|color| color.as_u32().to_ne_bytes())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Position {
|
||||||
|
x: u32,
|
||||||
|
y: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Position {
|
||||||
|
pub fn new(x: u32, y: u32) -> Self {
|
||||||
|
Self { x, y }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn x(self) -> u32 {
|
||||||
|
self.x
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn y(self) -> u32 {
|
||||||
|
self.y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Position {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "({}, {})", self.x, self.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct PixelUpdate {
|
||||||
|
position: Position,
|
||||||
|
color: Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PixelUpdate {
|
||||||
|
pub fn new(position: Position, color: Color) -> Self {
|
||||||
|
Self { position, color }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn position(self) -> Position {
|
||||||
|
self.position
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn color(self) -> Color {
|
||||||
|
self.color
|
||||||
|
}
|
||||||
|
}
|
||||||
125
crates/domain/tests/canvas.rs
Normal file
125
crates/domain/tests/canvas.rs
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
use domain::{Canvas, Color, DomainError, Position};
|
||||||
|
|
||||||
|
macro_rules! pos {
|
||||||
|
($x:expr, $y:expr) => {
|
||||||
|
Position::new($x, $y)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! color {
|
||||||
|
($v:expr) => {
|
||||||
|
Color::new($v)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! assert_pixel {
|
||||||
|
($canvas:expr, $x:expr, $y:expr, $expected:expr) => {{
|
||||||
|
let (x, y): (u32, u32) = ($x, $y);
|
||||||
|
let idx = y as usize * $canvas.width() as usize + x as usize;
|
||||||
|
let expected = color!($expected);
|
||||||
|
assert_eq!($canvas.pixels()[idx], expected, "pixel at ({x}, {y})");
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn small_canvas() -> Canvas {
|
||||||
|
Canvas::new(10, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_canvas_is_all_white() {
|
||||||
|
let canvas = small_canvas();
|
||||||
|
assert_eq!(canvas.pixels().len(), 100);
|
||||||
|
assert!(canvas.pixels().iter().all(|&c| c == Color::white()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dimensions_match_construction() {
|
||||||
|
let canvas = Canvas::new(42, 17);
|
||||||
|
assert_eq!(canvas.width(), 42);
|
||||||
|
assert_eq!(canvas.height(), 17);
|
||||||
|
assert_eq!(canvas.pixels().len(), 42 * 17);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn place_pixel_updates_correct_position() {
|
||||||
|
let mut canvas = small_canvas();
|
||||||
|
let update = canvas.place_pixel(pos!(3, 4), color!(0xFF0000)).unwrap();
|
||||||
|
|
||||||
|
assert_pixel!(canvas, 3, 4, 0xFF0000);
|
||||||
|
assert_eq!(update.position(), pos!(3, 4));
|
||||||
|
assert_eq!(update.color(), color!(0xFF0000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn place_pixel_does_not_affect_neighbors() {
|
||||||
|
let mut canvas = small_canvas();
|
||||||
|
canvas.place_pixel(pos!(5, 5), color!(0xFF)).unwrap();
|
||||||
|
|
||||||
|
for (x, y) in [(4, 5), (6, 5), (5, 4), (5, 6)] {
|
||||||
|
assert_pixel!(canvas, x, y, 0xFFFFFFFF);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn place_pixel_overwrites_previous() {
|
||||||
|
let mut canvas = small_canvas();
|
||||||
|
canvas.place_pixel(pos!(0, 0), color!(0xAA)).unwrap();
|
||||||
|
canvas.place_pixel(pos!(0, 0), color!(0xBB)).unwrap();
|
||||||
|
assert_pixel!(canvas, 0, 0, 0xBB);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn place_pixel_at_boundary() {
|
||||||
|
let mut canvas = small_canvas();
|
||||||
|
assert!(canvas.place_pixel(pos!(9, 9), color!(0xFF)).is_ok());
|
||||||
|
assert!(canvas.place_pixel(pos!(0, 0), color!(0xFF)).is_ok());
|
||||||
|
assert!(canvas.place_pixel(pos!(9, 0), color!(0xFF)).is_ok());
|
||||||
|
assert!(canvas.place_pixel(pos!(0, 9), color!(0xFF)).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn place_pixel_out_of_bounds() {
|
||||||
|
let mut canvas = small_canvas();
|
||||||
|
|
||||||
|
for (x, y) in [(10, 0), (0, 10), (10, 10), (100, 100)] {
|
||||||
|
let result = canvas.place_pixel(pos!(x, y), color!(0xFF));
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(DomainError::PixelOutOfBounds(_))),
|
||||||
|
"({x}, {y}) should be out of bounds"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_pixels_with_correct_size() {
|
||||||
|
let pixels = vec![color!(0xAA); 25];
|
||||||
|
let canvas = Canvas::from_pixels(5, 5, pixels).unwrap();
|
||||||
|
assert_eq!(canvas.width(), 5);
|
||||||
|
assert_eq!(canvas.height(), 5);
|
||||||
|
assert!(canvas.pixels().iter().all(|&c| c == color!(0xAA)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_pixels_with_wrong_size() {
|
||||||
|
let too_few = vec![color!(0); 10];
|
||||||
|
let too_many = vec![color!(0); 30];
|
||||||
|
|
||||||
|
for pixels in [too_few, too_many] {
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
Canvas::from_pixels(5, 5, pixels),
|
||||||
|
Err(DomainError::InvalidCanvasData { .. })
|
||||||
|
),
|
||||||
|
"should reject pixel vec that doesn't match dimensions"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_pixels_preserves_content() {
|
||||||
|
let mut pixels = vec![Color::white(); 9];
|
||||||
|
pixels[4] = color!(0xFF0000); // center pixel of 3x3
|
||||||
|
let canvas = Canvas::from_pixels(3, 3, pixels).unwrap();
|
||||||
|
assert_pixel!(canvas, 1, 1, 0xFF0000);
|
||||||
|
assert_pixel!(canvas, 0, 0, 0xFFFFFFFF);
|
||||||
|
}
|
||||||
69
crates/domain/tests/value_objects.rs
Normal file
69
crates/domain/tests/value_objects.rs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
use domain::{Color, PixelUpdate, Position};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn color_roundtrips_through_u32() {
|
||||||
|
for value in [0, 0xFF, 0xFF0000, 0xFFFFFFFF, 0xDEADBEEF] {
|
||||||
|
assert_eq!(Color::new(value).as_u32(), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn color_white_is_full_alpha() {
|
||||||
|
assert_eq!(Color::white().as_u32(), 0xFFFFFFFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn color_equality() {
|
||||||
|
assert_eq!(Color::new(42), Color::new(42));
|
||||||
|
assert_ne!(Color::new(1), Color::new(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collect_as_u32_preserves_values() {
|
||||||
|
let colors = [Color::new(1), Color::new(2), Color::new(3)];
|
||||||
|
assert_eq!(Color::collect_as_u32(&colors), vec![1, 2, 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collect_as_bytes_length() {
|
||||||
|
let colors = vec![Color::white(); 10];
|
||||||
|
assert_eq!(Color::collect_as_bytes(&colors).len(), 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collect_as_bytes_roundtrips() {
|
||||||
|
let original = vec![Color::new(0x01020304), Color::new(0xAABBCCDD)];
|
||||||
|
let bytes = Color::collect_as_bytes(&original);
|
||||||
|
let restored: Vec<Color> = bytes
|
||||||
|
.chunks_exact(4)
|
||||||
|
.map(|c| Color::new(u32::from_ne_bytes([c[0], c[1], c[2], c[3]])))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(original, restored);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn position_accessors() {
|
||||||
|
let pos = Position::new(42, 17);
|
||||||
|
assert_eq!(pos.x(), 42);
|
||||||
|
assert_eq!(pos.y(), 17);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn position_display() {
|
||||||
|
assert_eq!(Position::new(3, 7).to_string(), "(3, 7)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn position_equality() {
|
||||||
|
assert_eq!(Position::new(1, 2), Position::new(1, 2));
|
||||||
|
assert_ne!(Position::new(1, 2), Position::new(2, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pixel_update_carries_position_and_color() {
|
||||||
|
let pos = Position::new(5, 10);
|
||||||
|
let color = Color::new(0xFF00FF);
|
||||||
|
let update = PixelUpdate::new(pos, color);
|
||||||
|
assert_eq!(update.position(), pos);
|
||||||
|
assert_eq!(update.color(), color);
|
||||||
|
}
|
||||||
26
crates/server/Cargo.toml
Normal file
26
crates/server/Cargo.toml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
[package]
|
||||||
|
name = "server"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["socketio"]
|
||||||
|
socketio = ["dep:socketio", "dep:socketioxide"]
|
||||||
|
websocket = ["dep:websocket"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
application = { workspace = true }
|
||||||
|
canvas-file = { workspace = true }
|
||||||
|
config = { workspace = true }
|
||||||
|
config-env = { workspace = true }
|
||||||
|
axum = { workspace = true }
|
||||||
|
domain = { workspace = true }
|
||||||
|
http-axum = { workspace = true }
|
||||||
|
dotenv = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
|
||||||
|
socketio = { workspace = true, optional = true }
|
||||||
|
socketioxide = { workspace = true, optional = true }
|
||||||
|
websocket = { workspace = true, optional = true }
|
||||||
133
crates/server/src/main.rs
Normal file
133
crates/server/src/main.rs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
#[cfg(not(any(feature = "socketio", feature = "websocket")))]
|
||||||
|
compile_error!("Enable either the `socketio` or `websocket` feature");
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use application::{AppState, InMemoryCanvasStore, InProcessBroadcaster};
|
||||||
|
use canvas_file::FileCanvasPersistence;
|
||||||
|
use config::{AppConfig, ConfigSource};
|
||||||
|
use config_env::EnvConfigSource;
|
||||||
|
use domain::BroadcastEvent;
|
||||||
|
use tokio::signal;
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
use tracing_subscriber::FmtSubscriber;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
dotenv::dotenv().ok();
|
||||||
|
tracing::subscriber::set_global_default(FmtSubscriber::new())?;
|
||||||
|
|
||||||
|
let config = EnvConfigSource.load()?;
|
||||||
|
let state = build_state(&config)?;
|
||||||
|
|
||||||
|
if config.snapshot.enabled {
|
||||||
|
if let Err(err) = application::canvas::restore_snapshot::execute(&state) {
|
||||||
|
warn!("Failed to restore canvas snapshot: {err}");
|
||||||
|
}
|
||||||
|
spawn_snapshot_scheduler(state.clone(), config.snapshot.interval_secs);
|
||||||
|
}
|
||||||
|
|
||||||
|
let app = build_app(state.clone(), &config)?;
|
||||||
|
|
||||||
|
let server_address = format!("{}:{}", config.server.address, config.server.port);
|
||||||
|
info!("Starting server on {server_address}");
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(server_address).await?;
|
||||||
|
axum::serve(
|
||||||
|
listener,
|
||||||
|
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||||
|
)
|
||||||
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
info!("Shutting down gracefully...");
|
||||||
|
|
||||||
|
if config.snapshot.enabled {
|
||||||
|
info!("Saving final canvas snapshot...");
|
||||||
|
if let Err(err) = application::canvas::save_snapshot::execute(&state) {
|
||||||
|
error!("Failed to save final snapshot: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Server stopped");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn shutdown_signal() {
|
||||||
|
let ctrl_c = async {
|
||||||
|
signal::ctrl_c()
|
||||||
|
.await
|
||||||
|
.expect("Failed to install Ctrl+C handler");
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
let terminate = async {
|
||||||
|
signal::unix::signal(signal::unix::SignalKind::terminate())
|
||||||
|
.expect("Failed to install SIGTERM handler")
|
||||||
|
.recv()
|
||||||
|
.await;
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let terminate = std::future::pending::<()>();
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
() = ctrl_c => info!("Received Ctrl+C"),
|
||||||
|
() = terminate => info!("Received SIGTERM"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_state(config: &AppConfig) -> Result<Arc<AppState>, Box<dyn std::error::Error>> {
|
||||||
|
let (broadcast_tx, _) = broadcast::channel::<BroadcastEvent>(config.broadcast.channel_capacity);
|
||||||
|
|
||||||
|
let canvas_store = InMemoryCanvasStore::new(config.canvas.width, config.canvas.height);
|
||||||
|
let broadcaster = InProcessBroadcaster::new(broadcast_tx);
|
||||||
|
let cooldown = Duration::from_secs(config.cooldown.placement_secs);
|
||||||
|
|
||||||
|
let mut state = AppState::new(Box::new(canvas_store), Box::new(broadcaster), cooldown);
|
||||||
|
|
||||||
|
if config.snapshot.enabled {
|
||||||
|
let persistence = FileCanvasPersistence::new(&config.snapshot)?;
|
||||||
|
state = state.with_persistence(Box::new(persistence));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Arc::new(state))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_snapshot_scheduler(state: Arc<AppState>, interval_secs: u64) {
|
||||||
|
let interval = Duration::from_secs(interval_secs);
|
||||||
|
info!("Snapshot scheduler started (every {interval_secs}s)");
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(interval).await;
|
||||||
|
if let Err(err) = application::canvas::save_snapshot::execute(&state) {
|
||||||
|
error!("Failed to save canvas snapshot: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "socketio")]
|
||||||
|
fn build_app(
|
||||||
|
state: Arc<AppState>,
|
||||||
|
config: &AppConfig,
|
||||||
|
) -> Result<axum::Router, Box<dyn std::error::Error>> {
|
||||||
|
let (layer, io) = socketioxide::SocketIo::new_layer();
|
||||||
|
socketio::setup_namespaces(&io, state);
|
||||||
|
let router = http_axum::build_router(config.server.enable_cors, &config.rate_limit)?;
|
||||||
|
Ok(router.layer(layer))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "websocket")]
|
||||||
|
fn build_app(
|
||||||
|
state: Arc<AppState>,
|
||||||
|
config: &AppConfig,
|
||||||
|
) -> Result<axum::Router, Box<dyn std::error::Error>> {
|
||||||
|
let ws_router = websocket::build_router(state);
|
||||||
|
let http_router = http_axum::build_router(config.server.enable_cors, &config.rate_limit)?;
|
||||||
|
Ok(ws_router.merge(http_router))
|
||||||
|
}
|
||||||
21
deploy.sh
Executable file
21
deploy.sh
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REGISTRY="registry.gabrielkaszewski.dev"
|
||||||
|
REPO="painter"
|
||||||
|
|
||||||
|
tag="latest"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
--tag) tag="$2"; shift 2 ;;
|
||||||
|
*) echo "usage: $0 [--tag T]" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
image="${REGISTRY}/${REPO}:${tag}"
|
||||||
|
|
||||||
|
echo "building ${image}"
|
||||||
|
docker buildx build --platform linux/amd64 \
|
||||||
|
-t "$image" --push .
|
||||||
|
echo "pushed $image"
|
||||||
315
painter-js/bun.lock
Normal file
315
painter-js/bun.lock
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "painter-js",
|
||||||
|
"dependencies": {
|
||||||
|
"socket.io-client": "^4.7.5",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"autoprefixer": "^10.4.19",
|
||||||
|
"postcss": "^8.4.38",
|
||||||
|
"prettier": "^3.9.6",
|
||||||
|
"tailwindcss": "^3.4.3",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vite": "^5.2.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||||
|
|
||||||
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
|
||||||
|
|
||||||
|
"@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
|
||||||
|
|
||||||
|
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
|
||||||
|
|
||||||
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
|
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||||
|
|
||||||
|
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||||
|
|
||||||
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
|
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
|
||||||
|
|
||||||
|
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||||
|
|
||||||
|
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||||
|
|
||||||
|
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.4", "", { "os": "android", "cpu": "arm" }, "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.4", "", { "os": "android", "cpu": "arm64" }, "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.4", "", { "os": "linux", "cpu": "arm" }, "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.4", "", { "os": "linux", "cpu": "arm" }, "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.4", "", { "os": "linux", "cpu": "x64" }, "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.4", "", { "os": "linux", "cpu": "x64" }, "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.4", "", { "os": "none", "cpu": "arm64" }, "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.4", "", { "os": "win32", "cpu": "x64" }, "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.4", "", { "os": "win32", "cpu": "x64" }, "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q=="],
|
||||||
|
|
||||||
|
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
|
||||||
|
|
||||||
|
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||||
|
|
||||||
|
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
|
||||||
|
|
||||||
|
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
|
||||||
|
|
||||||
|
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
|
||||||
|
|
||||||
|
"autoprefixer": ["autoprefixer@10.5.4", "", { "dependencies": { "browserslist": "^4.28.6", "caniuse-lite": "^1.0.30001806", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA=="],
|
||||||
|
|
||||||
|
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.15", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA=="],
|
||||||
|
|
||||||
|
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||||
|
|
||||||
|
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||||
|
|
||||||
|
"browserslist": ["browserslist@4.28.8", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="],
|
||||||
|
|
||||||
|
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
|
||||||
|
|
||||||
|
"caniuse-lite": ["caniuse-lite@1.0.30001809", "", {}, "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ=="],
|
||||||
|
|
||||||
|
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||||
|
|
||||||
|
"commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
||||||
|
|
||||||
|
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||||
|
|
||||||
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
|
||||||
|
|
||||||
|
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
||||||
|
|
||||||
|
"electron-to-chromium": ["electron-to-chromium@1.5.408", "", {}, "sha512-SLoprcYpJ/OH2v2ps0+N5biv9H4/KBT3+YmmDew64TwK5y9j2wv7pMOFY7IorVkyMtEyLSCRlXKLsNlakeAlPw=="],
|
||||||
|
|
||||||
|
"engine.io-client": ["engine.io-client@6.6.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.21.0", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q=="],
|
||||||
|
|
||||||
|
"engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="],
|
||||||
|
|
||||||
|
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||||
|
|
||||||
|
"esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
|
||||||
|
|
||||||
|
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||||
|
|
||||||
|
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||||
|
|
||||||
|
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
|
||||||
|
|
||||||
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
|
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||||
|
|
||||||
|
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
||||||
|
|
||||||
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
|
|
||||||
|
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||||
|
|
||||||
|
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
|
||||||
|
|
||||||
|
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
|
||||||
|
|
||||||
|
"is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="],
|
||||||
|
|
||||||
|
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||||
|
|
||||||
|
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||||
|
|
||||||
|
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||||
|
|
||||||
|
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
|
||||||
|
|
||||||
|
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
||||||
|
|
||||||
|
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||||
|
|
||||||
|
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||||
|
|
||||||
|
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||||
|
|
||||||
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
||||||
|
|
||||||
|
"nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
|
||||||
|
|
||||||
|
"node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="],
|
||||||
|
|
||||||
|
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||||
|
|
||||||
|
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||||
|
|
||||||
|
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
|
||||||
|
|
||||||
|
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||||
|
|
||||||
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
|
"picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||||
|
|
||||||
|
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
|
||||||
|
|
||||||
|
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
|
||||||
|
|
||||||
|
"postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
|
||||||
|
|
||||||
|
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
|
||||||
|
|
||||||
|
"postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
|
||||||
|
|
||||||
|
"postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
|
||||||
|
|
||||||
|
"postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
|
||||||
|
|
||||||
|
"postcss-selector-parser": ["postcss-selector-parser@6.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="],
|
||||||
|
|
||||||
|
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||||
|
|
||||||
|
"prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="],
|
||||||
|
|
||||||
|
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||||
|
|
||||||
|
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
|
||||||
|
|
||||||
|
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||||
|
|
||||||
|
"resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
|
||||||
|
|
||||||
|
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||||
|
|
||||||
|
"rollup": ["rollup@4.62.4", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.62.4", "@rollup/rollup-android-arm64": "4.62.4", "@rollup/rollup-darwin-arm64": "4.62.4", "@rollup/rollup-darwin-x64": "4.62.4", "@rollup/rollup-freebsd-arm64": "4.62.4", "@rollup/rollup-freebsd-x64": "4.62.4", "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", "@rollup/rollup-linux-arm-musleabihf": "4.62.4", "@rollup/rollup-linux-arm64-gnu": "4.62.4", "@rollup/rollup-linux-arm64-musl": "4.62.4", "@rollup/rollup-linux-loong64-gnu": "4.62.4", "@rollup/rollup-linux-loong64-musl": "4.62.4", "@rollup/rollup-linux-ppc64-gnu": "4.62.4", "@rollup/rollup-linux-ppc64-musl": "4.62.4", "@rollup/rollup-linux-riscv64-gnu": "4.62.4", "@rollup/rollup-linux-riscv64-musl": "4.62.4", "@rollup/rollup-linux-s390x-gnu": "4.62.4", "@rollup/rollup-linux-x64-gnu": "4.62.4", "@rollup/rollup-linux-x64-musl": "4.62.4", "@rollup/rollup-openbsd-x64": "4.62.4", "@rollup/rollup-openharmony-arm64": "4.62.4", "@rollup/rollup-win32-arm64-msvc": "4.62.4", "@rollup/rollup-win32-ia32-msvc": "4.62.4", "@rollup/rollup-win32-x64-gnu": "4.62.4", "@rollup/rollup-win32-x64-msvc": "4.62.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg=="],
|
||||||
|
|
||||||
|
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||||
|
|
||||||
|
"socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="],
|
||||||
|
|
||||||
|
"socket.io-parser": ["socket.io-parser@4.2.7", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg=="],
|
||||||
|
|
||||||
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
|
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
|
||||||
|
|
||||||
|
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||||
|
|
||||||
|
"tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
|
||||||
|
|
||||||
|
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
|
||||||
|
|
||||||
|
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||||
|
|
||||||
|
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||||
|
|
||||||
|
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||||
|
|
||||||
|
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"update-browserslist-db": ["update-browserslist-db@1.3.1", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ=="],
|
||||||
|
|
||||||
|
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|
||||||
|
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
|
||||||
|
|
||||||
|
"ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
|
||||||
|
|
||||||
|
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
|
||||||
|
|
||||||
|
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|
||||||
|
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|
||||||
|
"tinyglobby/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,44 +1,126 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/png" href="/pixel_war_icon.png" />
|
<link rel="icon" type="image/png" href="/pixel_war_icon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta
|
||||||
<title>Pixel War</title>
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||||
|
/>
|
||||||
|
<title>Painter</title>
|
||||||
|
<meta name="description" content="Place pixels on a shared canvas with other players in real-time. Inspired by r/place." />
|
||||||
|
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:title" content="Painter" />
|
||||||
|
<meta property="og:description" content="Place pixels on a shared canvas with other players in real-time." />
|
||||||
|
<meta property="og:image" content="/og-image.png" />
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="Painter" />
|
||||||
|
<meta name="twitter:description" content="Place pixels on a shared canvas with other players in real-time." />
|
||||||
|
<meta name="twitter:image" content="/og-image.png" />
|
||||||
|
|
||||||
|
<meta name="theme-color" content="#f1f5f9" />
|
||||||
<link rel="stylesheet" href="/src/style.css" />
|
<link rel="stylesheet" href="/src/style.css" />
|
||||||
</head>
|
</head>
|
||||||
<body class="relative flex flex-col items-center justify-center min-h-screen bg-slate-100">
|
<body
|
||||||
<div id="challenge" class="absolute z-10 w-full min-h-screen"></div>
|
class="relative flex flex-col items-center justify-center min-h-screen bg-slate-100 gap-1"
|
||||||
<div class="z-20 flex flex-wrap gap-2 p-2">
|
>
|
||||||
<button id="red" class="w-8 h-8 bg-red-500 border border-black shadow"></button>
|
<div id="connection-status" class="text-sm"></div>
|
||||||
<button id="green" class="w-8 h-8 bg-green-500 border border-black shadow"></button>
|
<div class="z-20 flex flex-wrap justify-center gap-2 p-1">
|
||||||
<button id="blue" class="w-8 h-8 bg-blue-500 border border-black shadow"></button>
|
<button
|
||||||
<button id="cyan" class="w-8 h-8 border border-black shadow bg-cyan-500"></button>
|
id="red"
|
||||||
<button id="yellow" class="w-8 h-8 bg-yellow-500 border border-black shadow"></button>
|
class="w-8 h-8 bg-red-500 border border-black shadow"
|
||||||
<button id="black" class="w-8 h-8 bg-black border border-black shadow"></button>
|
></button>
|
||||||
<button id="white" class="w-8 h-8 bg-white border border-black shadow"></button>
|
<button
|
||||||
<button id="pink" class="w-8 h-8 bg-pink-500 border border-black shadow"></button>
|
id="green"
|
||||||
<button id="purple" class="w-8 h-8 bg-purple-500 border border-black shadow"></button>
|
class="w-8 h-8 bg-green-500 border border-black shadow"
|
||||||
<button id="orange" class="w-8 h-8 bg-orange-500 border border-black shadow"></button>
|
></button>
|
||||||
<button id="brown" class="w-8 h-8 bg-orange-800 border border-black shadow"></button>
|
<button
|
||||||
<input class="w-8 h-8 border border-black shadow" type="color" id="color-picker" value="#000000" />
|
id="blue"
|
||||||
|
class="w-8 h-8 bg-blue-500 border border-black shadow"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
id="cyan"
|
||||||
|
class="w-8 h-8 border border-black shadow bg-cyan-500"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
id="yellow"
|
||||||
|
class="w-8 h-8 bg-yellow-500 border border-black shadow"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
id="black"
|
||||||
|
class="w-8 h-8 bg-black border border-black shadow"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
id="white"
|
||||||
|
class="w-8 h-8 bg-white border border-black shadow"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
id="pink"
|
||||||
|
class="w-8 h-8 bg-pink-500 border border-black shadow"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
id="purple"
|
||||||
|
class="w-8 h-8 bg-purple-500 border border-black shadow"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
id="orange"
|
||||||
|
class="w-8 h-8 bg-orange-500 border border-black shadow"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
id="brown"
|
||||||
|
class="w-8 h-8 bg-orange-800 border border-black shadow"
|
||||||
|
></button>
|
||||||
|
<input
|
||||||
|
class="w-8 h-8 border border-black shadow"
|
||||||
|
type="color"
|
||||||
|
id="color-picker"
|
||||||
|
value="#000000"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p class="z-20">Your selected color <span id="current-color-span" class="text-white">COLOR</span></p>
|
<p class="z-20 text-sm">
|
||||||
<div id="countdown" class="z-20">You can place a pixel now</div>
|
Your selected color
|
||||||
<div class="z-20 flex items-center gap-2 mx-1 my-4">
|
<span id="current-color-span" class="px-2 rounded text-white">COLOR</span>
|
||||||
<label for="toggle-grid">Toggle Grid</label>
|
</p>
|
||||||
<input id="toggle-grid" type="checkbox" />
|
<div id="countdown" class="z-20 text-xs">You can place a pixel now</div>
|
||||||
<button id="place-pixel" class="px-2 py-1 rounded-md shadow-lg bg-cyan-400 hover:bg-cyan-500 active:bg-cyan-600">Place Pixel</button>
|
<div class="z-20 flex items-center gap-2 mx-1">
|
||||||
|
<button
|
||||||
|
id="zoom-out"
|
||||||
|
class="w-8 h-8 text-lg font-bold rounded shadow bg-slate-200 hover:bg-slate-300"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
<span id="zoom-level" class="text-xs font-mono w-10 text-center"
|
||||||
|
>1.0x</span
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
id="zoom-in"
|
||||||
|
class="w-8 h-8 text-lg font-bold rounded shadow bg-slate-200 hover:bg-slate-300"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="place-pixel"
|
||||||
|
class="px-3 py-1 text-sm rounded-md shadow-lg bg-cyan-400 hover:bg-cyan-500 active:bg-cyan-600"
|
||||||
|
>
|
||||||
|
Place Pixel
|
||||||
|
</button>
|
||||||
|
<p id="coords" class="text-xs font-mono"></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="z-20 flex gap-1">
|
<div class="canvas-viewport" id="canvas-viewport">
|
||||||
<p id="coords"></p>
|
<canvas class="bg-white" id="canvas" width="500" height="500"></canvas>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative z-20 p-1 rainbow-border">
|
<div class="z-20 flex items-center gap-4 p-1">
|
||||||
<canvas class="bg-white rounded-md" id="canvas" width="500" height="500"></canvas>
|
<p class="text-sm">
|
||||||
</div>
|
Soldiers: <span id="current-soldiers" class="font-bold">0</span>
|
||||||
<div class="z-20 flex flex-col gap-2 p-2">
|
</p>
|
||||||
<p>Current soldiers: <span id="current-soldiers" class="font-bold">0</span></p>
|
<button
|
||||||
<button id="save-canvas" class="px-2 py-1 bg-green-400 rounded-md shadow-lg hover:bg-green-500 active:bg-green-600">Save Canvas</button>
|
id="save-canvas"
|
||||||
|
class="px-3 py-1 text-sm bg-green-400 rounded-md shadow-lg hover:bg-green-500 active:bg-green-600"
|
||||||
|
>
|
||||||
|
Save Canvas
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
917
painter-js/package-lock.json
generated
917
painter-js/package-lock.json
generated
@@ -1,917 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "painter-js",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "painter-js",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"socket.io-client": "^4.7.5"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"typescript": "^5.2.2",
|
|
||||||
"vite": "^5.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
|
||||||
"version": "0.20.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz",
|
|
||||||
"integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"aix"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/android-arm": {
|
|
||||||
"version": "0.20.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz",
|
|
||||||
"integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/android-arm64": {
|
|
||||||
"version": "0.20.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz",
|
|
||||||
"integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/android-x64": {
|
|
||||||
"version": "0.20.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz",
|
|
||||||
"integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/darwin-arm64": {
|
|
||||||
"version": "0.20.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz",
|
|
||||||
"integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/darwin-x64": {
|
|
||||||
"version": "0.20.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz",
|
|
||||||
"integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/freebsd-arm64": {
|
|
||||||
"version": "0.20.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz",
|
|
||||||
"integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/freebsd-x64": {
|
|
||||||
"version": "0.20.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz",
|
|
||||||
"integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-arm": {
|
|
||||||
"version": "0.20.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz",
|
|
||||||
"integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-arm64": {
|
|
||||||
"version": "0.20.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz",
|
|
||||||
"integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-ia32": {
|
|
||||||
"version": "0.20.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz",
|
|
||||||
"integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-loong64": {
|
|
||||||
"version": "0.20.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz",
|
|
||||||
"integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==",
|
|
||||||
"cpu": [
|
|
||||||
"loong64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-mips64el": {
|
|
||||||
"version": "0.20.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz",
|
|
||||||
"integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==",
|
|
||||||
"cpu": [
|
|
||||||
"mips64el"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-ppc64": {
|
|
||||||
"version": "0.20.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz",
|
|
||||||
"integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-riscv64": {
|
|
||||||
"version": "0.20.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz",
|
|
||||||
"integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==",
|
|
||||||
"cpu": [
|
|
||||||
"riscv64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-s390x": {
|
|
||||||
"version": "0.20.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz",
|
|
||||||
"integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==",
|
|
||||||
"cpu": [
|
|
||||||
"s390x"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-x64": {
|
|
||||||
"version": "0.20.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz",
|
|
||||||
"integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/netbsd-x64": {
|
|
||||||
"version": "0.20.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz",
|
|
||||||
"integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/openbsd-x64": {
|
|
||||||
"version": "0.20.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz",
|
|
||||||
"integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/sunos-x64": {
|
|
||||||
"version": "0.20.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz",
|
|
||||||
"integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"sunos"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/win32-arm64": {
|
|
||||||
"version": "0.20.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz",
|
|
||||||
"integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/win32-ia32": {
|
|
||||||
"version": "0.20.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz",
|
|
||||||
"integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/win32-x64": {
|
|
||||||
"version": "0.20.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz",
|
|
||||||
"integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
|
||||||
"version": "4.17.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.17.2.tgz",
|
|
||||||
"integrity": "sha512-NM0jFxY8bB8QLkoKxIQeObCaDlJKewVlIEkuyYKm5An1tdVZ966w2+MPQ2l8LBZLjR+SgyV+nRkTIunzOYBMLQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@rollup/rollup-android-arm64": {
|
|
||||||
"version": "4.17.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.17.2.tgz",
|
|
||||||
"integrity": "sha512-yeX/Usk7daNIVwkq2uGoq2BYJKZY1JfyLTaHO/jaiSwi/lsf8fTFoQW/n6IdAsx5tx+iotu2zCJwz8MxI6D/Bw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
|
||||||
"version": "4.17.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.17.2.tgz",
|
|
||||||
"integrity": "sha512-kcMLpE6uCwls023+kknm71ug7MZOrtXo+y5p/tsg6jltpDtgQY1Eq5sGfHcQfb+lfuKwhBmEURDga9N0ol4YPw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@rollup/rollup-darwin-x64": {
|
|
||||||
"version": "4.17.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.17.2.tgz",
|
|
||||||
"integrity": "sha512-AtKwD0VEx0zWkL0ZjixEkp5tbNLzX+FCqGG1SvOu993HnSz4qDI6S4kGzubrEJAljpVkhRSlg5bzpV//E6ysTQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
|
||||||
"version": "4.17.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.17.2.tgz",
|
|
||||||
"integrity": "sha512-3reX2fUHqN7sffBNqmEyMQVj/CKhIHZd4y631duy0hZqI8Qoqf6lTtmAKvJFYa6bhU95B1D0WgzHkmTg33In0A==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
|
||||||
"version": "4.17.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.17.2.tgz",
|
|
||||||
"integrity": "sha512-uSqpsp91mheRgw96xtyAGP9FW5ChctTFEoXP0r5FAzj/3ZRv3Uxjtc7taRQSaQM/q85KEKjKsZuiZM3GyUivRg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
|
||||||
"version": "4.17.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.17.2.tgz",
|
|
||||||
"integrity": "sha512-EMMPHkiCRtE8Wdk3Qhtciq6BndLtstqZIroHiiGzB3C5LDJmIZcSzVtLRbwuXuUft1Cnv+9fxuDtDxz3k3EW2A==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
|
||||||
"version": "4.17.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.17.2.tgz",
|
|
||||||
"integrity": "sha512-NMPylUUZ1i0z/xJUIx6VUhISZDRT+uTWpBcjdv0/zkp7b/bQDF+NfnfdzuTiB1G6HTodgoFa93hp0O1xl+/UbA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
|
|
||||||
"version": "4.17.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.17.2.tgz",
|
|
||||||
"integrity": "sha512-T19My13y8uYXPw/L/k0JYaX1fJKFT/PWdXiHr8mTbXWxjVF1t+8Xl31DgBBvEKclw+1b00Chg0hxE2O7bTG7GQ==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
|
||||||
"version": "4.17.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.17.2.tgz",
|
|
||||||
"integrity": "sha512-BOaNfthf3X3fOWAB+IJ9kxTgPmMqPPH5f5k2DcCsRrBIbWnaJCgX2ll77dV1TdSy9SaXTR5iDXRL8n7AnoP5cg==",
|
|
||||||
"cpu": [
|
|
||||||
"riscv64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
|
||||||
"version": "4.17.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.17.2.tgz",
|
|
||||||
"integrity": "sha512-W0UP/x7bnn3xN2eYMql2T/+wpASLE5SjObXILTMPUBDB/Fg/FxC+gX4nvCfPBCbNhz51C+HcqQp2qQ4u25ok6g==",
|
|
||||||
"cpu": [
|
|
||||||
"s390x"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
|
||||||
"version": "4.17.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.17.2.tgz",
|
|
||||||
"integrity": "sha512-Hy7pLwByUOuyaFC6mAr7m+oMC+V7qyifzs/nW2OJfC8H4hbCzOX07Ov0VFk/zP3kBsELWNFi7rJtgbKYsav9QQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
|
||||||
"version": "4.17.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.17.2.tgz",
|
|
||||||
"integrity": "sha512-h1+yTWeYbRdAyJ/jMiVw0l6fOOm/0D1vNLui9iPuqgRGnXA0u21gAqOyB5iHjlM9MMfNOm9RHCQ7zLIzT0x11Q==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
|
||||||
"version": "4.17.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.17.2.tgz",
|
|
||||||
"integrity": "sha512-tmdtXMfKAjy5+IQsVtDiCfqbynAQE/TQRpWdVataHmhMb9DCoJxp9vLcCBjEQWMiUYxO1QprH/HbY9ragCEFLA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
|
||||||
"version": "4.17.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.17.2.tgz",
|
|
||||||
"integrity": "sha512-7II/QCSTAHuE5vdZaQEwJq2ZACkBpQDOmQsE6D6XUbnBHW8IAhm4eTufL6msLJorzrHDFv3CF8oCA/hSIRuZeQ==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
|
||||||
"version": "4.17.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.17.2.tgz",
|
|
||||||
"integrity": "sha512-TGGO7v7qOq4CYmSBVEYpI1Y5xDuCEnbVC5Vth8mOsW0gDSzxNrVERPc790IGHsrT2dQSimgMr9Ub3Y1Jci5/8w==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@socket.io/component-emitter": {
|
|
||||||
"version": "3.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
|
||||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="
|
|
||||||
},
|
|
||||||
"node_modules/@types/estree": {
|
|
||||||
"version": "1.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
|
|
||||||
"integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"node_modules/debug": {
|
|
||||||
"version": "4.3.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
|
||||||
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "2.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"supports-color": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/engine.io-client": {
|
|
||||||
"version": "6.5.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.3.tgz",
|
|
||||||
"integrity": "sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==",
|
|
||||||
"dependencies": {
|
|
||||||
"@socket.io/component-emitter": "~3.1.0",
|
|
||||||
"debug": "~4.3.1",
|
|
||||||
"engine.io-parser": "~5.2.1",
|
|
||||||
"ws": "~8.11.0",
|
|
||||||
"xmlhttprequest-ssl": "~2.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/engine.io-parser": {
|
|
||||||
"version": "5.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.2.tgz",
|
|
||||||
"integrity": "sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/esbuild": {
|
|
||||||
"version": "0.20.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz",
|
|
||||||
"integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"bin": {
|
|
||||||
"esbuild": "bin/esbuild"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@esbuild/aix-ppc64": "0.20.2",
|
|
||||||
"@esbuild/android-arm": "0.20.2",
|
|
||||||
"@esbuild/android-arm64": "0.20.2",
|
|
||||||
"@esbuild/android-x64": "0.20.2",
|
|
||||||
"@esbuild/darwin-arm64": "0.20.2",
|
|
||||||
"@esbuild/darwin-x64": "0.20.2",
|
|
||||||
"@esbuild/freebsd-arm64": "0.20.2",
|
|
||||||
"@esbuild/freebsd-x64": "0.20.2",
|
|
||||||
"@esbuild/linux-arm": "0.20.2",
|
|
||||||
"@esbuild/linux-arm64": "0.20.2",
|
|
||||||
"@esbuild/linux-ia32": "0.20.2",
|
|
||||||
"@esbuild/linux-loong64": "0.20.2",
|
|
||||||
"@esbuild/linux-mips64el": "0.20.2",
|
|
||||||
"@esbuild/linux-ppc64": "0.20.2",
|
|
||||||
"@esbuild/linux-riscv64": "0.20.2",
|
|
||||||
"@esbuild/linux-s390x": "0.20.2",
|
|
||||||
"@esbuild/linux-x64": "0.20.2",
|
|
||||||
"@esbuild/netbsd-x64": "0.20.2",
|
|
||||||
"@esbuild/openbsd-x64": "0.20.2",
|
|
||||||
"@esbuild/sunos-x64": "0.20.2",
|
|
||||||
"@esbuild/win32-arm64": "0.20.2",
|
|
||||||
"@esbuild/win32-ia32": "0.20.2",
|
|
||||||
"@esbuild/win32-x64": "0.20.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fsevents": {
|
|
||||||
"version": "2.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ms": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
|
|
||||||
},
|
|
||||||
"node_modules/nanoid": {
|
|
||||||
"version": "3.3.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
|
||||||
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
|
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/ai"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"bin": {
|
|
||||||
"nanoid": "bin/nanoid.cjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/picocolors": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"node_modules/postcss": {
|
|
||||||
"version": "8.4.38",
|
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz",
|
|
||||||
"integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==",
|
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/postcss/"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "tidelift",
|
|
||||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/ai"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"dependencies": {
|
|
||||||
"nanoid": "^3.3.7",
|
|
||||||
"picocolors": "^1.0.0",
|
|
||||||
"source-map-js": "^1.2.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^10 || ^12 || >=14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/rollup": {
|
|
||||||
"version": "4.17.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.17.2.tgz",
|
|
||||||
"integrity": "sha512-/9ClTJPByC0U4zNLowV1tMBe8yMEAxewtR3cUNX5BoEpGH3dQEWpJLr6CLp0fPdYRF/fzVOgvDb1zXuakwF5kQ==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@types/estree": "1.0.5"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"rollup": "dist/bin/rollup"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0",
|
|
||||||
"npm": ">=8.0.0"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@rollup/rollup-android-arm-eabi": "4.17.2",
|
|
||||||
"@rollup/rollup-android-arm64": "4.17.2",
|
|
||||||
"@rollup/rollup-darwin-arm64": "4.17.2",
|
|
||||||
"@rollup/rollup-darwin-x64": "4.17.2",
|
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": "4.17.2",
|
|
||||||
"@rollup/rollup-linux-arm-musleabihf": "4.17.2",
|
|
||||||
"@rollup/rollup-linux-arm64-gnu": "4.17.2",
|
|
||||||
"@rollup/rollup-linux-arm64-musl": "4.17.2",
|
|
||||||
"@rollup/rollup-linux-powerpc64le-gnu": "4.17.2",
|
|
||||||
"@rollup/rollup-linux-riscv64-gnu": "4.17.2",
|
|
||||||
"@rollup/rollup-linux-s390x-gnu": "4.17.2",
|
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.17.2",
|
|
||||||
"@rollup/rollup-linux-x64-musl": "4.17.2",
|
|
||||||
"@rollup/rollup-win32-arm64-msvc": "4.17.2",
|
|
||||||
"@rollup/rollup-win32-ia32-msvc": "4.17.2",
|
|
||||||
"@rollup/rollup-win32-x64-msvc": "4.17.2",
|
|
||||||
"fsevents": "~2.3.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io-client": {
|
|
||||||
"version": "4.7.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.5.tgz",
|
|
||||||
"integrity": "sha512-sJ/tqHOCe7Z50JCBCXrsY3I2k03iOiUe+tj1OmKeD2lXPiGH/RUCdTZFoqVyN7l1MnpIzPrGtLcijffmeouNlQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"@socket.io/component-emitter": "~3.1.0",
|
|
||||||
"debug": "~4.3.2",
|
|
||||||
"engine.io-client": "~6.5.2",
|
|
||||||
"socket.io-parser": "~4.2.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io-parser": {
|
|
||||||
"version": "4.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
|
|
||||||
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
|
|
||||||
"dependencies": {
|
|
||||||
"@socket.io/component-emitter": "~3.1.0",
|
|
||||||
"debug": "~4.3.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/source-map-js": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
|
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/typescript": {
|
|
||||||
"version": "5.4.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
|
|
||||||
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
|
|
||||||
"dev": true,
|
|
||||||
"bin": {
|
|
||||||
"tsc": "bin/tsc",
|
|
||||||
"tsserver": "bin/tsserver"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.17"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite": {
|
|
||||||
"version": "5.2.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.2.11.tgz",
|
|
||||||
"integrity": "sha512-HndV31LWW05i1BLPMUCE1B9E9GFbOu1MbenhS58FuK6owSO5qHm7GiCotrNY1YE5rMeQSFBGmT5ZaLEjFizgiQ==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"esbuild": "^0.20.1",
|
|
||||||
"postcss": "^8.4.38",
|
|
||||||
"rollup": "^4.13.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"vite": "bin/vite.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.0.0 || >=20.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/vitejs/vite?sponsor=1"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"fsevents": "~2.3.3"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/node": "^18.0.0 || >=20.0.0",
|
|
||||||
"less": "*",
|
|
||||||
"lightningcss": "^1.21.0",
|
|
||||||
"sass": "*",
|
|
||||||
"stylus": "*",
|
|
||||||
"sugarss": "*",
|
|
||||||
"terser": "^5.4.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/node": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"less": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"lightningcss": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"sass": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"stylus": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"sugarss": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"terser": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ws": {
|
|
||||||
"version": "8.11.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz",
|
|
||||||
"integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"bufferutil": "^4.0.1",
|
|
||||||
"utf-8-validate": "^5.0.2"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"bufferutil": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"utf-8-validate": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/xmlhttprequest-ssl": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.4.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,17 +6,19 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"fmt": "prettier --write 'src/**/*.{js,ts,css,html}'",
|
||||||
|
"fmt:check": "prettier --check 'src/**/*.{js,ts,css,html}'"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
|
"prettier": "^3.9.6",
|
||||||
"tailwindcss": "^3.4.3",
|
"tailwindcss": "^3.4.3",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"vite": "^5.2.0"
|
"vite": "^5.2.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"socket.io-client": "^4.7.5",
|
"socket.io-client": "^4.7.5"
|
||||||
"three": "^0.164.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1466
painter-js/pnpm-lock.yaml
generated
1466
painter-js/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
painter-js/public/og-image.png
Normal file
BIN
painter-js/public/og-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
Binary file not shown.
@@ -1,267 +0,0 @@
|
|||||||
import {
|
|
||||||
hexToU32,
|
|
||||||
u32ToHex,
|
|
||||||
getColorFromElementCSS,
|
|
||||||
rgbToHex,
|
|
||||||
} from "./utils.js";
|
|
||||||
import {
|
|
||||||
pixelSize,
|
|
||||||
pixelCooldown,
|
|
||||||
canvasEndpoint,
|
|
||||||
WIDTH,
|
|
||||||
HEIGHT,
|
|
||||||
} from "./constants.js";
|
|
||||||
|
|
||||||
let socket = null;
|
|
||||||
|
|
||||||
const canvas = document.getElementById("canvas");
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
const countdownDiv = document.getElementById("countdown");
|
|
||||||
let lastPixelTime = parseInt(localStorage.getItem("lastPixelTime") || "0");
|
|
||||||
|
|
||||||
const colorPicker = document.getElementById("color-picker");
|
|
||||||
const redButton = document.getElementById("red");
|
|
||||||
const greenButton = document.getElementById("green");
|
|
||||||
const blueButton = document.getElementById("blue");
|
|
||||||
const yellowButton = document.getElementById("yellow");
|
|
||||||
const purpleButton = document.getElementById("purple");
|
|
||||||
const pinkButton = document.getElementById("pink");
|
|
||||||
const cyanButton = document.getElementById("cyan");
|
|
||||||
const whiteButton = document.getElementById("white");
|
|
||||||
const blackButton = document.getElementById("black");
|
|
||||||
const orangeButton = document.getElementById("orange");
|
|
||||||
const brownButton = document.getElementById("brown");
|
|
||||||
|
|
||||||
const currentColorSpan = document.getElementById("current-color-span");
|
|
||||||
const toggleGridToggle = document.getElementById("toggle-grid");
|
|
||||||
const placePixelButton = document.getElementById("place-pixel");
|
|
||||||
const saveCanvasButton = document.getElementById("save-canvas");
|
|
||||||
|
|
||||||
colorPicker.value = localStorage.getItem("currentColor") || "#000000";
|
|
||||||
|
|
||||||
let currentColor = colorPicker.value;
|
|
||||||
let showGrid = toggleGridToggle.checked;
|
|
||||||
currentColorSpan.style.backgroundColor = currentColor;
|
|
||||||
|
|
||||||
let canvasState = [];
|
|
||||||
let confirmPlacePixel = false;
|
|
||||||
let previewPixel = null;
|
|
||||||
|
|
||||||
const setCurrentColor = (color, isColorPicker = false) => {
|
|
||||||
const hexColor = isColorPicker ? color : rgbToHex(color);
|
|
||||||
currentColor = hexColor;
|
|
||||||
currentColorSpan.style.backgroundColor = hexColor;
|
|
||||||
localStorage.setItem("currentColor", hexColor);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleColorPicker = () => {
|
|
||||||
colorPicker.addEventListener("input", (event) => {
|
|
||||||
setCurrentColor(event.target.value, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
redButton.addEventListener("click", () => {
|
|
||||||
setCurrentColor(getColorFromElementCSS(redButton));
|
|
||||||
});
|
|
||||||
|
|
||||||
greenButton.addEventListener("click", () => {
|
|
||||||
setCurrentColor(getColorFromElementCSS(greenButton));
|
|
||||||
});
|
|
||||||
|
|
||||||
blueButton.addEventListener("click", () => {
|
|
||||||
setCurrentColor(getColorFromElementCSS(blueButton));
|
|
||||||
});
|
|
||||||
|
|
||||||
yellowButton.addEventListener("click", () => {
|
|
||||||
setCurrentColor(getColorFromElementCSS(yellowButton));
|
|
||||||
});
|
|
||||||
|
|
||||||
purpleButton.addEventListener("click", () => {
|
|
||||||
setCurrentColor(getColorFromElementCSS(purpleButton));
|
|
||||||
});
|
|
||||||
|
|
||||||
pinkButton.addEventListener("click", () => {
|
|
||||||
setCurrentColor(getColorFromElementCSS(pinkButton));
|
|
||||||
});
|
|
||||||
|
|
||||||
cyanButton.addEventListener("click", () => {
|
|
||||||
setCurrentColor(getColorFromElementCSS(cyanButton));
|
|
||||||
});
|
|
||||||
|
|
||||||
whiteButton.addEventListener("click", () => {
|
|
||||||
setCurrentColor(getColorFromElementCSS(whiteButton));
|
|
||||||
});
|
|
||||||
|
|
||||||
blackButton.addEventListener("click", () => {
|
|
||||||
setCurrentColor(getColorFromElementCSS(blackButton));
|
|
||||||
});
|
|
||||||
|
|
||||||
orangeButton.addEventListener("click", () => {
|
|
||||||
setCurrentColor(getColorFromElementCSS(orangeButton));
|
|
||||||
});
|
|
||||||
|
|
||||||
brownButton.addEventListener("click", () => {
|
|
||||||
setCurrentColor(getColorFromElementCSS(brownButton));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchCanvasState = async () => {
|
|
||||||
fetch(canvasEndpoint)
|
|
||||||
.then((response) => response.json())
|
|
||||||
.then((data) => {
|
|
||||||
canvasState = data;
|
|
||||||
drawCanvasState(data);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
alert("Error fetching canvas state from server. Please try again later.");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const drawCanvasState = (canvasState) => {
|
|
||||||
for (let y = 0; y < HEIGHT; y++) {
|
|
||||||
for (let x = 0; x < WIDTH; x++) {
|
|
||||||
const index = y * WIDTH + x;
|
|
||||||
const color = u32ToHex(canvasState[index]);
|
|
||||||
ctx.fillStyle = color;
|
|
||||||
ctx.fillRect(x * pixelSize, y * pixelSize, pixelSize, pixelSize);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkIfCanPlacePixel = () => {
|
|
||||||
const now = Date.now();
|
|
||||||
return now - lastPixelTime >= pixelCooldown;
|
|
||||||
};
|
|
||||||
|
|
||||||
const setLastPixelTime = () => {
|
|
||||||
lastPixelTime = Date.now();
|
|
||||||
localStorage.setItem("lastPixelTime", lastPixelTime.toString());
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePlacePixel = (pixelData) => {
|
|
||||||
if (!checkIfCanPlacePixel()) {
|
|
||||||
alert("You can't place a pixel yet");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.emit("place-pixel", pixelData);
|
|
||||||
const index = pixelData.y * WIDTH + pixelData.x;
|
|
||||||
canvasState[index] = pixelData.color;
|
|
||||||
setLastPixelTime();
|
|
||||||
pixelData = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
canvas.addEventListener("click", (event) => {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const x = Math.floor((event.clientX - rect.left) / pixelSize);
|
|
||||||
const y = Math.floor((event.clientY - rect.top) / pixelSize);
|
|
||||||
|
|
||||||
const color = hexToU32(currentColor);
|
|
||||||
const oldPreviewPixel = previewPixel;
|
|
||||||
|
|
||||||
const update = { x, y, color };
|
|
||||||
|
|
||||||
if (confirmPlacePixel) {
|
|
||||||
handlePlacePixel(update);
|
|
||||||
} else {
|
|
||||||
previewPixel = update;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (previewPixel) {
|
|
||||||
if (oldPreviewPixel) {
|
|
||||||
ctx.clearRect(
|
|
||||||
oldPreviewPixel.x * pixelSize,
|
|
||||||
oldPreviewPixel.y * pixelSize,
|
|
||||||
pixelSize,
|
|
||||||
pixelSize
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.fillStyle = `rgba(${(color >> 16) & 0xff}, ${(color >> 8) & 0xff}, ${
|
|
||||||
color & 0xff
|
|
||||||
}, 0.5)`;
|
|
||||||
ctx.fillRect(x * pixelSize, y * pixelSize, pixelSize, pixelSize);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const removePreviewPixel = () => {
|
|
||||||
previewPixel = null;
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
||||||
drawCanvasState(canvasState);
|
|
||||||
};
|
|
||||||
|
|
||||||
const drawGrid = () => {
|
|
||||||
ctx.strokeStyle = "#000";
|
|
||||||
for (let x = 0; x < canvas.width; x += pixelSize) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x, 0);
|
|
||||||
ctx.lineTo(x, canvas.height);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
for (let y = 0; y < canvas.height; y += pixelSize) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(0, y);
|
|
||||||
ctx.lineTo(canvas.width, y);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleGrid = () => {
|
|
||||||
toggleGridToggle.addEventListener("change", (event) => {
|
|
||||||
showGrid = event.target.checked;
|
|
||||||
localStorage.setItem("showGrid", showGrid);
|
|
||||||
if (showGrid) {
|
|
||||||
drawGrid();
|
|
||||||
} else {
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
||||||
drawCanvasState(canvasState);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
window.onkeydown = (event) => {
|
|
||||||
// on enter (keycode 13 is enter)
|
|
||||||
if (event.keyCode === 13) {
|
|
||||||
if (previewPixel) {
|
|
||||||
handlePlacePixel(previewPixel);
|
|
||||||
removePreviewPixel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
placePixelButton.addEventListener("click", () => {
|
|
||||||
if (previewPixel) {
|
|
||||||
handlePlacePixel(previewPixel);
|
|
||||||
removePreviewPixel();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
saveCanvasButton.addEventListener("click", () => {
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = canvas.toDataURL();
|
|
||||||
a.download = "canvas.png";
|
|
||||||
a.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
handleColorPicker();
|
|
||||||
handleToggleGrid();
|
|
||||||
|
|
||||||
export const handleSocketEvents = (_socket) => {
|
|
||||||
socket = _socket;
|
|
||||||
socket.on("connect", () => {
|
|
||||||
fetchCanvasState();
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on("pixel-updated", (update) => {
|
|
||||||
const color = u32ToHex(update.color);
|
|
||||||
ctx.fillStyle = color;
|
|
||||||
ctx.fillRect(
|
|
||||||
update.x * pixelSize,
|
|
||||||
update.y * pixelSize,
|
|
||||||
pixelSize,
|
|
||||||
pixelSize
|
|
||||||
);
|
|
||||||
|
|
||||||
const index = update.y * WIDTH + update.x;
|
|
||||||
canvasState[index] = update.color;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import * as three from 'three';
|
|
||||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
|
||||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
|
||||||
|
|
||||||
let scene, camera, renderer, character, mixer, clock;
|
|
||||||
let walkAction, idleAction;
|
|
||||||
let moving = false;
|
|
||||||
const keys = {}
|
|
||||||
|
|
||||||
let boxes = [];
|
|
||||||
|
|
||||||
const container = document.getElementById('challenge');
|
|
||||||
|
|
||||||
const onWindowResize = () => {
|
|
||||||
camera.aspect = container.clientWidth / container.clientHeight;
|
|
||||||
camera.updateProjectionMatrix();
|
|
||||||
renderer.setSize(container.clientWidth, container.clientHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
const onKeyDown = (e) => {
|
|
||||||
keys[e.code] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const onKeyUp = (e) => {
|
|
||||||
keys[e.code] = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const init = () => {
|
|
||||||
scene = new three.Scene();
|
|
||||||
camera = new three.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
|
|
||||||
renderer = new three.WebGLRenderer({ antialias: true});
|
|
||||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
||||||
container.appendChild(renderer.domElement);
|
|
||||||
|
|
||||||
clock = new three.Clock();
|
|
||||||
|
|
||||||
// Adjust light positions and intensity
|
|
||||||
const light = new three.DirectionalLight(0xffffff, 1);
|
|
||||||
light.position.set(5, 10, 7.5);
|
|
||||||
light.castShadow = true;
|
|
||||||
scene.add(light);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
const box = new three.Mesh(
|
|
||||||
new three.BoxGeometry(1, 1, 1),
|
|
||||||
new three.MeshStandardMaterial({ color: 0x00ff00 })
|
|
||||||
);
|
|
||||||
box.position.set(Math.random() * 10 - 5, 0.5, Math.random() * 10 - 5);
|
|
||||||
box.castShadow = true;
|
|
||||||
scene.add(box);
|
|
||||||
boxes.push(box);
|
|
||||||
}
|
|
||||||
|
|
||||||
const loader = new GLTFLoader();
|
|
||||||
loader.load('src/Astronaut.glb',(gltf) => {
|
|
||||||
character = gltf.scene;
|
|
||||||
scene.add(character);
|
|
||||||
|
|
||||||
mixer = new three.AnimationMixer(character);
|
|
||||||
gltf.animations.forEach((clip) => {
|
|
||||||
if (clip.name === 'CharacterArmature|Walk') {
|
|
||||||
walkAction = mixer.clipAction(clip);
|
|
||||||
}
|
|
||||||
if (clip.name === 'CharacterArmature|Idle') {
|
|
||||||
idleAction = mixer.clipAction(clip);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (walkAction) walkAction.play();
|
|
||||||
if (idleAction) idleAction.play();
|
|
||||||
|
|
||||||
character.position.set(-10, 0, -60);
|
|
||||||
character.rotation.y = Math.PI;
|
|
||||||
})
|
|
||||||
|
|
||||||
//blue sky
|
|
||||||
scene.background = new three.Color(0x87ceeb);
|
|
||||||
|
|
||||||
camera.position.set(20, 10, 10)
|
|
||||||
camera.lookAt(0, 10, 0)
|
|
||||||
|
|
||||||
window.addEventListener('resize', onWindowResize, false);
|
|
||||||
document.addEventListener('keydown', onKeyDown, false);
|
|
||||||
document.addEventListener('keyup', onKeyUp, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
const animate = () => {
|
|
||||||
requestAnimationFrame(animate);
|
|
||||||
|
|
||||||
const delta = clock.getDelta();
|
|
||||||
if (mixer) mixer.update(delta);
|
|
||||||
|
|
||||||
if (character) {
|
|
||||||
if (keys['KeyW']) {
|
|
||||||
character.position.z -= 0.1;
|
|
||||||
character.rotation.y = Math.PI; // North
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keys['KeyS']) {
|
|
||||||
character.position.z += 0.1;
|
|
||||||
character.rotation.y = 0; // South
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keys['KeyA']) {
|
|
||||||
character.position.x -= 0.1;
|
|
||||||
character.rotation.y = Math.PI / 2; // West
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keys['KeyD']) {
|
|
||||||
character.position.x += 0.1;
|
|
||||||
character.rotation.y = -Math.PI / 2; // East
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if any key is pressed
|
|
||||||
if (keys['KeyW'] || keys['KeyS'] || keys['KeyA'] || keys['KeyD']) {
|
|
||||||
moving = true;
|
|
||||||
} else {
|
|
||||||
moving = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Play walking animation if moving
|
|
||||||
if (mixer) {
|
|
||||||
if (walkAction && idleAction) {
|
|
||||||
walkAction.enabled = moving;
|
|
||||||
idleAction.enabled = !moving;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
renderer.render(scene, camera);
|
|
||||||
}
|
|
||||||
|
|
||||||
const threejs = import.meta.env.VITE_THREE_JS === "true";
|
|
||||||
if (threejs) {
|
|
||||||
init();
|
|
||||||
animate();
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
const isDebug = import.meta.env.VITE_IS_DEBUG === "true";
|
|
||||||
|
|
||||||
export const pixelSize = 10;
|
|
||||||
export const pixelCooldown = 10 * 1000; // 10 seconds
|
|
||||||
export const canvasEndpoint = isDebug
|
|
||||||
? "http://localhost:3000/canvas/"
|
|
||||||
: "/canvas/";
|
|
||||||
export const checkEndpoint = isDebug
|
|
||||||
? "http://localhost:3000/check/"
|
|
||||||
: "/check/";
|
|
||||||
export const WIDTH = 500;
|
|
||||||
export const HEIGHT = 500;
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { pixelCooldown } from "./constants.js";
|
|
||||||
|
|
||||||
const countdownDiv = document.getElementById("countdown");
|
|
||||||
|
|
||||||
export const updateCountdown = () => {
|
|
||||||
setInterval(() => {
|
|
||||||
const lastPixelTime = parseInt(
|
|
||||||
localStorage.getItem("lastPixelTime") || "0"
|
|
||||||
);
|
|
||||||
const now = Date.now();
|
|
||||||
const timeLeft = Math.max(0, pixelCooldown - (now - lastPixelTime));
|
|
||||||
if (timeLeft > 0) {
|
|
||||||
countdownDiv.textContent = `You can place a pixel in ${Math.ceil(
|
|
||||||
timeLeft / 1000
|
|
||||||
)} seconds`;
|
|
||||||
} else {
|
|
||||||
countdownDiv.textContent = "You can place a pixel now";
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
};
|
|
||||||
11
painter-js/src/domain/canvas-state.js
Normal file
11
painter-js/src/domain/canvas-state.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { WIDTH } from "./constants.js";
|
||||||
|
|
||||||
|
export const getPixelIndex = (x, y) => y * WIDTH + x;
|
||||||
|
|
||||||
|
export const setPixel = (state, x, y, color) => {
|
||||||
|
state[getPixelIndex(x, y)] = color;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPixel = (state, x, y) => {
|
||||||
|
return state[getPixelIndex(x, y)];
|
||||||
|
};
|
||||||
@@ -6,13 +6,16 @@ export const hexToU32 = (color) => {
|
|||||||
return parseInt(color.slice(1), 16);
|
return parseInt(color.slice(1), 16);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getColorFromElementCSS = (element) => {
|
|
||||||
return window.getComputedStyle(element).backgroundColor;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const rgbToHex = (rgbProperty) => {
|
export const rgbToHex = (rgbProperty) => {
|
||||||
const rgb = rgbProperty.match(/\d+/g);
|
const rgb = rgbProperty.match(/\d+/g);
|
||||||
return `#${rgb
|
return `#${rgb
|
||||||
.map((x) => parseInt(x).toString(16).padStart(2, "0"))
|
.map((x) => parseInt(x).toString(16).padStart(2, "0"))
|
||||||
.join("")}`;
|
.join("")}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const u32ToRGBA = (color, alpha) => {
|
||||||
|
const r = (color >> 16) & 0xff;
|
||||||
|
const g = (color >> 8) & 0xff;
|
||||||
|
const b = color & 0xff;
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
};
|
||||||
4
painter-js/src/domain/constants.js
Normal file
4
painter-js/src/domain/constants.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const PIXEL_SIZE = 1;
|
||||||
|
export const PIXEL_COOLDOWN = 10 * 1000;
|
||||||
|
export const WIDTH = 500;
|
||||||
|
export const HEIGHT = 500;
|
||||||
9
painter-js/src/domain/cooldown.js
Normal file
9
painter-js/src/domain/cooldown.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { PIXEL_COOLDOWN } from "./constants.js";
|
||||||
|
|
||||||
|
export const canPlacePixel = (lastPlacementTime) => {
|
||||||
|
return Date.now() - lastPlacementTime >= PIXEL_COOLDOWN;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const timeRemaining = (lastPlacementTime) => {
|
||||||
|
return Math.max(0, PIXEL_COOLDOWN - (Date.now() - lastPlacementTime));
|
||||||
|
};
|
||||||
19
painter-js/src/domain/coords.js
Normal file
19
painter-js/src/domain/coords.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { WIDTH, HEIGHT } from "./constants.js";
|
||||||
|
|
||||||
|
export const getCanvasCoords = (event, canvas) => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const clientX = event.touches ? event.touches[0].clientX : event.clientX;
|
||||||
|
const clientY = event.touches ? event.touches[0].clientY : event.clientY;
|
||||||
|
const scaleX = canvas.width / rect.width;
|
||||||
|
const scaleY = canvas.height / rect.height;
|
||||||
|
return {
|
||||||
|
x: Math.min(
|
||||||
|
Math.max(Math.floor((clientX - rect.left) * scaleX), 0),
|
||||||
|
WIDTH - 1,
|
||||||
|
),
|
||||||
|
y: Math.min(
|
||||||
|
Math.max(Math.floor((clientY - rect.top) * scaleY), 0),
|
||||||
|
HEIGHT - 1,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
5
painter-js/src/infrastructure/api.js
Normal file
5
painter-js/src/infrastructure/api.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
const isDebug = import.meta.env.VITE_IS_DEBUG === "true";
|
||||||
|
|
||||||
|
const CHECK_ENDPOINT = isDebug ? "http://localhost:3000/check/" : "/check/";
|
||||||
|
|
||||||
|
export const checkServer = () => fetch(CHECK_ENDPOINT);
|
||||||
72
painter-js/src/infrastructure/socket-client.js
Normal file
72
painter-js/src/infrastructure/socket-client.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import io from "socket.io-client";
|
||||||
|
|
||||||
|
const isDebug = import.meta.env.VITE_IS_DEBUG === "true";
|
||||||
|
const transport = import.meta.env.VITE_TRANSPORT || "socketio";
|
||||||
|
|
||||||
|
const createSocketIoTransport = () => {
|
||||||
|
const url = isDebug ? "ws://localhost:3000" : undefined;
|
||||||
|
const socket = url ? io(url) : io({ transports: ["websocket"] });
|
||||||
|
|
||||||
|
return {
|
||||||
|
on: (event, handler) => socket.on(event, handler),
|
||||||
|
emit: (event, data) => socket.emit(event, data),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createWebSocketTransport = () => {
|
||||||
|
const handlers = {};
|
||||||
|
const wsUrl = isDebug
|
||||||
|
? "ws://localhost:3000/ws"
|
||||||
|
: `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws`;
|
||||||
|
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
ws.binaryType = "arraybuffer";
|
||||||
|
|
||||||
|
const on = (event, handler) => {
|
||||||
|
if (!handlers[event]) handlers[event] = [];
|
||||||
|
handlers[event].push(handler);
|
||||||
|
};
|
||||||
|
|
||||||
|
const emit = (event, data) => {
|
||||||
|
if (ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
ws.send(JSON.stringify({ type: event, ...data }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const dispatch = (event, ...args) => {
|
||||||
|
(handlers[event] || []).forEach((handler) => handler(...args));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.addEventListener("open", () => dispatch("connect"));
|
||||||
|
|
||||||
|
ws.addEventListener("message", (event) => {
|
||||||
|
if (event.data instanceof ArrayBuffer) {
|
||||||
|
const pixels = new Uint32Array(event.data);
|
||||||
|
dispatch("canvas_state", Array.from(pixels));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
switch (message.type) {
|
||||||
|
case "pixel-updated":
|
||||||
|
dispatch("pixel-updated", message);
|
||||||
|
break;
|
||||||
|
case "current_soldiers":
|
||||||
|
dispatch("current_soldiers", message.count);
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
dispatch("error", message.message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener("close", () => dispatch("disconnect"));
|
||||||
|
|
||||||
|
return { on, emit };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createSocketConnection = () => {
|
||||||
|
if (transport === "websocket") {
|
||||||
|
return createWebSocketTransport();
|
||||||
|
}
|
||||||
|
return createSocketIoTransport();
|
||||||
|
};
|
||||||
@@ -1,58 +1,88 @@
|
|||||||
import { connectToWS } from "./socket.js";
|
import { createSocketConnection } from "./infrastructure/socket-client.js";
|
||||||
import "./canvas.js";
|
import { checkServer } from "./infrastructure/api.js";
|
||||||
import "./counter.js";
|
import { createCanvasRenderer } from "./ui/canvas-renderer.js";
|
||||||
import { updateCountdown } from "./counter.js";
|
import { createColorPalette } from "./ui/color-palette.js";
|
||||||
import { checkEndpoint, pixelSize } from "./constants.js";
|
import { createPixelPlacer } from "./ui/pixel-placer.js";
|
||||||
import { handleSocketEvents } from "./canvas.js";
|
import { startCooldownDisplay } from "./ui/cooldown-display.js";
|
||||||
import "./challenge.js"
|
import { createCanvasViewport } from "./ui/canvas-viewport.js";
|
||||||
|
import { setPixel } from "./domain/canvas-state.js";
|
||||||
|
import { getCanvasCoords } from "./domain/coords.js";
|
||||||
|
|
||||||
const isDebug = import.meta.env.VITE_IS_DEBUG === "true";
|
const canvasEl = document.getElementById("canvas");
|
||||||
|
|
||||||
const currentSoldiersSpan = document.getElementById("current-soldiers");
|
|
||||||
|
|
||||||
let coords = [];
|
|
||||||
const canvas = document.getElementById("canvas");
|
|
||||||
const coordsText = document.getElementById("coords");
|
const coordsText = document.getElementById("coords");
|
||||||
const ogCanvasStyle = canvas.style.display;
|
const currentSoldiersSpan = document.getElementById("current-soldiers");
|
||||||
canvas.style.display = "none";
|
const statusEl = document.getElementById("connection-status");
|
||||||
|
|
||||||
fetch(checkEndpoint)
|
const savedDisplay = canvasEl.style.display;
|
||||||
|
canvasEl.style.display = "none";
|
||||||
|
|
||||||
|
let canvasState = [];
|
||||||
|
|
||||||
|
const renderer = createCanvasRenderer(canvasEl);
|
||||||
|
const palette = createColorPalette();
|
||||||
|
|
||||||
|
startCooldownDisplay();
|
||||||
|
createCanvasViewport(canvasEl);
|
||||||
|
|
||||||
|
canvasEl.addEventListener("mousemove", (event) => {
|
||||||
|
const { x, y } = getCanvasCoords(event, canvasEl);
|
||||||
|
coordsText.textContent = `${x}, ${y}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("save-canvas").addEventListener("click", () => {
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = renderer.toDataURL();
|
||||||
|
a.download = "canvas.png";
|
||||||
|
a.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
const showStatus = (message, isError) => {
|
||||||
|
if (!statusEl) return;
|
||||||
|
statusEl.textContent = message;
|
||||||
|
statusEl.className = isError
|
||||||
|
? "text-red-500 text-sm"
|
||||||
|
: "text-green-500 text-sm";
|
||||||
|
};
|
||||||
|
|
||||||
|
checkServer()
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response.ok) {
|
if (!response.ok) throw new Error("Server unavailable");
|
||||||
const socket = connectToWS();
|
|
||||||
|
|
||||||
socket.on("connect", () => {
|
const socket = createSocketConnection();
|
||||||
canvas.style.display = ogCanvasStyle;
|
|
||||||
console.log("connect");
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on("error", (message) => {
|
socket.on("connect", () => {
|
||||||
alert(message);
|
canvasEl.style.display = savedDisplay;
|
||||||
});
|
showStatus("Connected", false);
|
||||||
|
});
|
||||||
|
|
||||||
socket.on("current_soldiers", (currentSoldiers) => {
|
socket.on("canvas_state", (data) => {
|
||||||
currentSoldiersSpan.textContent = currentSoldiers;
|
canvasState = data;
|
||||||
});
|
renderer.drawState(data);
|
||||||
|
});
|
||||||
|
|
||||||
handleSocketEvents(socket);
|
socket.on("error", (message) => showStatus(message, true));
|
||||||
|
|
||||||
requestAnimationFrame(updateCountdown);
|
socket.on("current_soldiers", (count) => {
|
||||||
|
currentSoldiersSpan.textContent = count;
|
||||||
|
});
|
||||||
|
|
||||||
window.addEventListener("mousemove", (event) => {
|
socket.on("pixel-updated", (update) => {
|
||||||
// get coordinates of the mouse inside the canvas
|
renderer.drawPixel(update.x, update.y, update.color);
|
||||||
const rect = canvas.getBoundingClientRect();
|
setPixel(canvasState, update.x, update.y, update.color);
|
||||||
const x = Math.floor((event.clientX - rect.left) / pixelSize);
|
});
|
||||||
const y = Math.floor((event.clientY - rect.top) / pixelSize);
|
|
||||||
coords = [x, y];
|
|
||||||
|
|
||||||
coordsText.textContent = `${x}, ${y}`;
|
socket.on("disconnect", () => {
|
||||||
});
|
showStatus("Disconnected — reconnecting...", true);
|
||||||
} else {
|
});
|
||||||
throw new Error("Can't connect to the server");
|
|
||||||
}
|
createPixelPlacer({
|
||||||
|
canvas: canvasEl,
|
||||||
|
renderer,
|
||||||
|
getColor: palette.getColor,
|
||||||
|
getState: () => canvasState,
|
||||||
|
socket,
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch(() => {
|
||||||
alert(
|
showStatus("Cannot connect to server", true);
|
||||||
"You have already connected to the server from another tab or window. Please close the other tab or window and refresh this page."
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import io from "socket.io-client";
|
|
||||||
|
|
||||||
const isDebug = import.meta.env.VITE_IS_DEBUG === "true";
|
|
||||||
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
||||||
const wsHost = window.location.host;
|
|
||||||
|
|
||||||
let socket;
|
|
||||||
|
|
||||||
export const connectToWS = () => {
|
|
||||||
if (isDebug) {
|
|
||||||
socket = io("ws://localhost:3000");
|
|
||||||
} else {
|
|
||||||
socket = io(`${wsProtocol}//${wsHost}`, {
|
|
||||||
transports: ["websocket"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return socket;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default socket;
|
|
||||||
@@ -2,20 +2,17 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer base {
|
.canvas-viewport {
|
||||||
.rainbow-border {
|
overflow: auto;
|
||||||
position: relative;
|
width: min(92vw, 70vh);
|
||||||
display: inline-block;
|
height: min(92vw, 70vh);
|
||||||
background: linear-gradient(to right, red, orange, yellow, green, blue, indigo, violet);
|
border: 2px solid #94a3b8;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: crosshair;
|
||||||
}
|
}
|
||||||
.rainbow-border::before {
|
|
||||||
content: '';
|
#canvas {
|
||||||
position: absolute;
|
image-rendering: pixelated;
|
||||||
top: 0;
|
image-rendering: crisp-edges;
|
||||||
right: 0;
|
display: block;
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
background: white;
|
|
||||||
z-index: -1; /* Place behind the content */
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
39
painter-js/src/ui/canvas-renderer.js
Normal file
39
painter-js/src/ui/canvas-renderer.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { u32ToHex, u32ToRGBA } from "../domain/color.js";
|
||||||
|
import { WIDTH, HEIGHT } from "../domain/constants.js";
|
||||||
|
|
||||||
|
export const createCanvasRenderer = (canvas) => {
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
|
||||||
|
const drawState = (state) => {
|
||||||
|
const imageData = ctx.createImageData(WIDTH, HEIGHT);
|
||||||
|
const data = imageData.data;
|
||||||
|
for (let i = 0; i < state.length; i++) {
|
||||||
|
const color = state[i];
|
||||||
|
const offset = i * 4;
|
||||||
|
data[offset] = (color >> 16) & 0xff;
|
||||||
|
data[offset + 1] = (color >> 8) & 0xff;
|
||||||
|
data[offset + 2] = color & 0xff;
|
||||||
|
data[offset + 3] = 255;
|
||||||
|
}
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawPixel = (x, y, colorU32) => {
|
||||||
|
ctx.fillStyle = u32ToHex(colorU32);
|
||||||
|
ctx.fillRect(x, y, 1, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawPreview = (x, y, colorU32) => {
|
||||||
|
ctx.fillStyle = u32ToRGBA(colorU32, 0.5);
|
||||||
|
ctx.fillRect(x, y, 1, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const restorePixel = (state, x, y) => {
|
||||||
|
ctx.fillStyle = u32ToHex(state[y * WIDTH + x]);
|
||||||
|
ctx.fillRect(x, y, 1, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toDataURL = () => canvas.toDataURL();
|
||||||
|
|
||||||
|
return { drawState, drawPixel, drawPreview, restorePixel, toDataURL };
|
||||||
|
};
|
||||||
78
painter-js/src/ui/canvas-viewport.js
Normal file
78
painter-js/src/ui/canvas-viewport.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
const MIN_ZOOM = 1;
|
||||||
|
const MAX_ZOOM = 40;
|
||||||
|
|
||||||
|
export const createCanvasViewport = (canvas) => {
|
||||||
|
const viewport = canvas.parentElement;
|
||||||
|
const zoomLabel = document.getElementById("zoom-level");
|
||||||
|
let zoom = 1;
|
||||||
|
let baseSize = viewport.clientWidth;
|
||||||
|
|
||||||
|
const applyZoom = () => {
|
||||||
|
const size = baseSize * zoom;
|
||||||
|
canvas.style.width = `${size}px`;
|
||||||
|
canvas.style.height = `${size}px`;
|
||||||
|
if (zoomLabel) zoomLabel.textContent = `${zoom.toFixed(1)}x`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setZoom = (newZoom, centerX, centerY) => {
|
||||||
|
const oldZoom = zoom;
|
||||||
|
zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, newZoom));
|
||||||
|
if (zoom === oldZoom) return;
|
||||||
|
|
||||||
|
if (centerX !== undefined && centerY !== undefined) {
|
||||||
|
const ratio = zoom / oldZoom;
|
||||||
|
viewport.scrollLeft = (viewport.scrollLeft + centerX) * ratio - centerX;
|
||||||
|
viewport.scrollTop = (viewport.scrollTop + centerY) * ratio - centerY;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyZoom();
|
||||||
|
};
|
||||||
|
|
||||||
|
viewport.addEventListener("wheel", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const factor = event.deltaY > 0 ? 0.8 : 1.25;
|
||||||
|
const rect = viewport.getBoundingClientRect();
|
||||||
|
setZoom(zoom * factor, event.clientX - rect.left, event.clientY - rect.top);
|
||||||
|
});
|
||||||
|
|
||||||
|
let lastPinchDist = 0;
|
||||||
|
viewport.addEventListener("touchmove", (event) => {
|
||||||
|
if (event.touches.length !== 2) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const dist = Math.hypot(
|
||||||
|
event.touches[0].clientX - event.touches[1].clientX,
|
||||||
|
event.touches[0].clientY - event.touches[1].clientY,
|
||||||
|
);
|
||||||
|
if (lastPinchDist > 0) {
|
||||||
|
const midX =
|
||||||
|
(event.touches[0].clientX + event.touches[1].clientX) / 2 -
|
||||||
|
viewport.getBoundingClientRect().left;
|
||||||
|
const midY =
|
||||||
|
(event.touches[0].clientY + event.touches[1].clientY) / 2 -
|
||||||
|
viewport.getBoundingClientRect().top;
|
||||||
|
setZoom(zoom * (dist / lastPinchDist), midX, midY);
|
||||||
|
}
|
||||||
|
lastPinchDist = dist;
|
||||||
|
});
|
||||||
|
|
||||||
|
viewport.addEventListener("touchend", () => {
|
||||||
|
lastPinchDist = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("zoom-in")?.addEventListener("click", () => {
|
||||||
|
setZoom(zoom * 1.5);
|
||||||
|
applyZoom();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("zoom-out")?.addEventListener("click", () => {
|
||||||
|
setZoom(zoom / 1.5);
|
||||||
|
applyZoom();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
baseSize = viewport.clientWidth;
|
||||||
|
applyZoom();
|
||||||
|
});
|
||||||
|
|
||||||
|
applyZoom();
|
||||||
|
};
|
||||||
53
painter-js/src/ui/color-palette.js
Normal file
53
painter-js/src/ui/color-palette.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { rgbToHex } from "../domain/color.js";
|
||||||
|
|
||||||
|
const PALETTE_IDS = [
|
||||||
|
"red",
|
||||||
|
"green",
|
||||||
|
"blue",
|
||||||
|
"yellow",
|
||||||
|
"purple",
|
||||||
|
"pink",
|
||||||
|
"cyan",
|
||||||
|
"white",
|
||||||
|
"black",
|
||||||
|
"orange",
|
||||||
|
"brown",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const createColorPalette = () => {
|
||||||
|
const colorPicker = document.getElementById("color-picker");
|
||||||
|
const currentColorSpan = document.getElementById("current-color-span");
|
||||||
|
let activeButton = null;
|
||||||
|
|
||||||
|
let currentColor = localStorage.getItem("currentColor") || "#000000";
|
||||||
|
colorPicker.value = currentColor;
|
||||||
|
currentColorSpan.style.backgroundColor = currentColor;
|
||||||
|
|
||||||
|
const setColor = (hex, button) => {
|
||||||
|
currentColor = hex;
|
||||||
|
currentColorSpan.textContent = hex;
|
||||||
|
currentColorSpan.style.backgroundColor = hex;
|
||||||
|
localStorage.setItem("currentColor", hex);
|
||||||
|
|
||||||
|
if (activeButton)
|
||||||
|
activeButton.classList.remove("ring-2", "ring-offset-2", "ring-cyan-400");
|
||||||
|
if (button) {
|
||||||
|
button.classList.add("ring-2", "ring-offset-2", "ring-cyan-400");
|
||||||
|
activeButton = button;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
colorPicker.addEventListener("input", (e) => setColor(e.target.value, null));
|
||||||
|
|
||||||
|
for (const id of PALETTE_IDS) {
|
||||||
|
const button = document.getElementById(id);
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
setColor(
|
||||||
|
rgbToHex(window.getComputedStyle(button).backgroundColor),
|
||||||
|
button,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getColor: () => currentColor };
|
||||||
|
};
|
||||||
16
painter-js/src/ui/cooldown-display.js
Normal file
16
painter-js/src/ui/cooldown-display.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { timeRemaining } from "../domain/cooldown.js";
|
||||||
|
|
||||||
|
export const startCooldownDisplay = () => {
|
||||||
|
const countdownDiv = document.getElementById("countdown");
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
const lastPlacementTime = parseInt(
|
||||||
|
localStorage.getItem("lastPixelTime") || "0",
|
||||||
|
);
|
||||||
|
const remaining = timeRemaining(lastPlacementTime);
|
||||||
|
countdownDiv.textContent =
|
||||||
|
remaining > 0
|
||||||
|
? `You can place a pixel in ${Math.ceil(remaining / 1000)} seconds`
|
||||||
|
: "You can place a pixel now";
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
57
painter-js/src/ui/pixel-placer.js
Normal file
57
painter-js/src/ui/pixel-placer.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { hexToU32 } from "../domain/color.js";
|
||||||
|
import { canPlacePixel } from "../domain/cooldown.js";
|
||||||
|
import { setPixel } from "../domain/canvas-state.js";
|
||||||
|
import { getCanvasCoords } from "../domain/coords.js";
|
||||||
|
|
||||||
|
export const createPixelPlacer = ({
|
||||||
|
canvas,
|
||||||
|
renderer,
|
||||||
|
getColor,
|
||||||
|
getState,
|
||||||
|
socket,
|
||||||
|
}) => {
|
||||||
|
let previewPixel = null;
|
||||||
|
|
||||||
|
const place = (update) => {
|
||||||
|
const lastPixelTime = parseInt(
|
||||||
|
localStorage.getItem("lastPixelTime") || "0",
|
||||||
|
);
|
||||||
|
if (!canPlacePixel(lastPixelTime)) return;
|
||||||
|
|
||||||
|
socket.emit("place-pixel", update);
|
||||||
|
setPixel(getState(), update.x, update.y, update.color);
|
||||||
|
localStorage.setItem("lastPixelTime", Date.now().toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearPreview = () => {
|
||||||
|
if (!previewPixel) return;
|
||||||
|
renderer.restorePixel(getState(), previewPixel.x, previewPixel.y);
|
||||||
|
previewPixel = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmPlacement = () => {
|
||||||
|
if (!previewPixel) return;
|
||||||
|
place(previewPixel);
|
||||||
|
clearPreview();
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectPixel = (event) => {
|
||||||
|
const { x, y } = getCanvasCoords(event, canvas);
|
||||||
|
const color = hexToU32(getColor());
|
||||||
|
|
||||||
|
clearPreview();
|
||||||
|
previewPixel = { x, y, color };
|
||||||
|
renderer.drawPreview(x, y, color);
|
||||||
|
};
|
||||||
|
|
||||||
|
canvas.addEventListener("click", selectPixel);
|
||||||
|
|
||||||
|
window.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Enter") confirmPlacement();
|
||||||
|
if (event.key === "Escape") clearPreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
document
|
||||||
|
.getElementById("place-pixel")
|
||||||
|
.addEventListener("click", confirmPlacement);
|
||||||
|
};
|
||||||
251
src/main.rs
251
src/main.rs
@@ -1,251 +0,0 @@
|
|||||||
use std::{
|
|
||||||
collections::{HashMap, HashSet},
|
|
||||||
net::SocketAddr,
|
|
||||||
sync::{Arc, Mutex},
|
|
||||||
};
|
|
||||||
|
|
||||||
use axum::{routing::get, Extension, Json, Router};
|
|
||||||
use chrono::{DateTime, Duration, Utc};
|
|
||||||
use memory_stats::memory_stats;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::Value;
|
|
||||||
use socketioxide::{
|
|
||||||
extract::{Bin, Data, SocketRef},
|
|
||||||
SocketIo,
|
|
||||||
};
|
|
||||||
use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer};
|
|
||||||
use tower_http::{
|
|
||||||
cors::{Any, CorsLayer},
|
|
||||||
services::ServeDir,
|
|
||||||
};
|
|
||||||
use tracing::info;
|
|
||||||
use tracing_subscriber::FmtSubscriber;
|
|
||||||
|
|
||||||
type Canvas = Arc<Mutex<Vec<u32>>>;
|
|
||||||
type LastUpdate = Arc<Mutex<HashMap<String, DateTime<Utc>>>>;
|
|
||||||
type Soldiers = Arc<Mutex<HashSet<String>>>;
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
|
||||||
struct PixelUpdate {
|
|
||||||
x: u32,
|
|
||||||
y: u32,
|
|
||||||
color: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_connect(
|
|
||||||
socket: SocketRef,
|
|
||||||
canvas: Canvas,
|
|
||||||
last_update: LastUpdate,
|
|
||||||
soldiers: Soldiers,
|
|
||||||
) {
|
|
||||||
info!("Socket connected: {:?} {:?}", socket.ns(), socket.id,);
|
|
||||||
|
|
||||||
let socket_id = socket.id.to_string();
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut soldiers = soldiers.lock().unwrap();
|
|
||||||
soldiers.insert(socket_id.clone());
|
|
||||||
let soldiers_num = soldiers.len();
|
|
||||||
|
|
||||||
socket.emit("current_soldiers", &soldiers_num).ok();
|
|
||||||
socket
|
|
||||||
.broadcast()
|
|
||||||
.emit("current_soldiers", &soldiers_num)
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("Memory usage after connection and before cloning state: ");
|
|
||||||
print_memory_usage();
|
|
||||||
|
|
||||||
let canvas_clone = canvas.clone();
|
|
||||||
let last_update_clone = last_update.clone();
|
|
||||||
|
|
||||||
info!("Memory usage after connection and after cloning state: ");
|
|
||||||
print_memory_usage();
|
|
||||||
|
|
||||||
socket.on(
|
|
||||||
"place-pixel",
|
|
||||||
move |socket: SocketRef, Data::<PixelUpdate>(update), Bin(_bin)| {
|
|
||||||
let socket_id = socket.id.to_string();
|
|
||||||
let last_update = last_update_clone.clone();
|
|
||||||
let canvas = canvas_clone.clone();
|
|
||||||
|
|
||||||
{
|
|
||||||
let now = Utc::now();
|
|
||||||
let mut last_update = last_update.lock().unwrap();
|
|
||||||
|
|
||||||
if let Some(&last_time) = last_update.get(&socket_id) {
|
|
||||||
if now < last_time + Duration::seconds(10) {
|
|
||||||
let _ = socket.emit(
|
|
||||||
"error",
|
|
||||||
Value::String("You can only place one pixel per minute".to_string()),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
last_update.insert(socket_id.clone(), now);
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("Received pixel update: {:?}", update);
|
|
||||||
let mut canvas = canvas.lock().unwrap();
|
|
||||||
canvas[update.y as usize * 500 + update.x as usize] = update.color;
|
|
||||||
|
|
||||||
info!("Emitting pixel update");
|
|
||||||
socket.emit("pixel-updated", &update).ok(); // Send to the user who placed the pixel
|
|
||||||
socket.broadcast().emit("pixel-updated", &update).ok(); // Send to all other users
|
|
||||||
|
|
||||||
info!("Memory usage after pixel update: ");
|
|
||||||
print_memory_usage();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
socket.on_disconnect(move |socket: SocketRef| {
|
|
||||||
info!("Socket disconnected: {:?}", socket.id);
|
|
||||||
info!("Memory usage after disconnection: ");
|
|
||||||
print_memory_usage();
|
|
||||||
let socket_id = socket.id.to_string();
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut soldiers = soldiers.lock().unwrap();
|
|
||||||
soldiers.remove(&socket_id.to_string());
|
|
||||||
info!("Soldiers: {:?}", soldiers.len());
|
|
||||||
|
|
||||||
let soldiers_num = soldiers.len();
|
|
||||||
socket
|
|
||||||
.broadcast()
|
|
||||||
.emit("current_soldiers", &soldiers_num)
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut last_update = last_update.lock().unwrap();
|
|
||||||
last_update.remove(&socket_id);
|
|
||||||
|
|
||||||
info!("Last update: {:?}", last_update.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("Memory usage after disconnection and cleanup: ");
|
|
||||||
print_memory_usage();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_memory_usage() {
|
|
||||||
if let Some(usage) = memory_stats() {
|
|
||||||
info!(
|
|
||||||
"Current physical memory usage: {} MB",
|
|
||||||
usage.physical_mem as f64 / 1024.0 / 1024.0
|
|
||||||
);
|
|
||||||
info!(
|
|
||||||
"Current virtual memory usage: {} MB",
|
|
||||||
usage.virtual_mem as f64 / 1024.0 / 1024.0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_canvas_state(Extension(canvas): Extension<Canvas>) -> Json<Vec<u32>> {
|
|
||||||
let canvas = canvas.lock().unwrap();
|
|
||||||
Json(canvas.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
dotenv::dotenv().ok();
|
|
||||||
tracing::subscriber::set_global_default(FmtSubscriber::new())?;
|
|
||||||
|
|
||||||
let rate_governor = Arc::new(
|
|
||||||
GovernorConfigBuilder::default()
|
|
||||||
.burst_size(10)
|
|
||||||
.per_second(10)
|
|
||||||
.finish()
|
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let governor = rate_governor.limiter().clone();
|
|
||||||
let interval = std::time::Duration::from_secs(1);
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
loop {
|
|
||||||
tokio::time::sleep(interval).await;
|
|
||||||
governor.retain_recent();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let (layer, io) = SocketIo::new_layer();
|
|
||||||
|
|
||||||
let canvas = Arc::new(Mutex::new(vec![0xFFFFFFFF; 500 * 500]));
|
|
||||||
let last_update = Arc::new(Mutex::new(HashMap::new()));
|
|
||||||
let soldiers = Arc::new(Mutex::new(HashSet::new()));
|
|
||||||
|
|
||||||
let used_memory = std::mem::size_of_val(&*canvas)
|
|
||||||
+ std::mem::size_of_val(&*last_update)
|
|
||||||
+ std::mem::size_of_val(&*soldiers);
|
|
||||||
info!(
|
|
||||||
"Used memory of state: {} bytes, {} KB, {} MB",
|
|
||||||
used_memory,
|
|
||||||
used_memory / 1024,
|
|
||||||
used_memory / 1024 / 1024
|
|
||||||
);
|
|
||||||
|
|
||||||
info!("Memory usage after state setup and before socket.io setup: ");
|
|
||||||
print_memory_usage();
|
|
||||||
|
|
||||||
let canvas_for_socket = canvas.clone();
|
|
||||||
|
|
||||||
io.ns("/", move |socket: SocketRef| {
|
|
||||||
tokio::spawn(on_connect(
|
|
||||||
socket,
|
|
||||||
canvas_for_socket.clone(),
|
|
||||||
last_update.clone(),
|
|
||||||
soldiers.clone(),
|
|
||||||
));
|
|
||||||
});
|
|
||||||
|
|
||||||
info!("Memory usage after socket.io setup: ");
|
|
||||||
print_memory_usage();
|
|
||||||
|
|
||||||
let address = std::env::var("ADDRESS").unwrap_or_else(|_| "0.0.0.0".to_string());
|
|
||||||
let port: u16 = std::env::var("PORT")
|
|
||||||
.unwrap_or_else(|_| "3000".to_string())
|
|
||||||
.parse()
|
|
||||||
.expect("Invalid port");
|
|
||||||
let enable_cors = std::env::var("ENABLE_CORS").unwrap_or_else(|_| "true".to_string()) == "true";
|
|
||||||
|
|
||||||
let cors = if enable_cors {
|
|
||||||
Some(
|
|
||||||
CorsLayer::new()
|
|
||||||
.allow_origin(Any)
|
|
||||||
.allow_methods(Any)
|
|
||||||
.allow_headers(Any),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let app = Router::new()
|
|
||||||
.route("/canvas/", get(get_canvas_state))
|
|
||||||
.route("/check/", get(|| async { "OK" }))
|
|
||||||
.fallback_service(ServeDir::new("dist/"))
|
|
||||||
.layer(GovernorLayer {
|
|
||||||
config: rate_governor,
|
|
||||||
})
|
|
||||||
.layer(layer)
|
|
||||||
.layer(Extension(canvas.clone()));
|
|
||||||
|
|
||||||
let app = if let Some(cors) = cors {
|
|
||||||
app.layer(cors)
|
|
||||||
} else {
|
|
||||||
app
|
|
||||||
};
|
|
||||||
|
|
||||||
let server_address = format!("{}:{}", address, port);
|
|
||||||
info!("Starting server on {}", server_address);
|
|
||||||
let listener = tokio::net::TcpListener::bind(server_address).await?;
|
|
||||||
axum::serve(
|
|
||||||
listener,
|
|
||||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user