Compare commits

...

9 Commits

Author SHA1 Message Date
b6650119a0 Merge pull request 'docker changes' (#5) from swengineer/curltastic:docker-changes into main
Reviewed-on: eros/curltastic#5
2026-09-07 19:17:46 -07:00
Jason Dekarske
32e9d4ebbc docker changes 2026-09-08 02:16:58 +00:00
5877bd3d8a Merge pull request 'docs: move product spec to .pm/board.yaml' (#3) from swengineer/curltastic:pm-board-spec into main
Reviewed-on: eros/curltastic#3
2026-09-07 17:59:46 -07:00
440d383067 Merge branch 'main' into pm-board-spec 2026-09-07 17:58:50 -07:00
Jason Dekarske
78b8ebeb7d docs: move product spec to .pm/board.yaml
Replace REQUIREMENTS.md as source of truth with the pm-board yaml.
Drop README friction-scalar lies. Include pending HUD frontend work.
2026-09-07 23:06:31 +00:00
5d3b3b992d docs: algorithm grill 2026-09-03 todos 2026-09-03 16:07:26 +00:00
665e192edb docs: algorithm grill 2026-09-01 todos (add backend unit-test delete) 2026-09-01 09:05:12 -07:00
190fb2b7b9 docs: algorithm grill 2026-09-01 todos 2026-09-01 09:04:41 -07:00
Jason Dekarske
3bdd67f815 docs: record product requirements as complete sentences
Capture the agreed 34 requirements in REQUIREMENTS.md. Implementation
details are omitted.
2026-09-01 05:55:27 +00:00
17 changed files with 551 additions and 136 deletions

22
.dockerignore Normal file
View File

@ -0,0 +1,22 @@
# Allow only the files required by the two builds. No credentials or host dependencies.
**
!Dockerfile
!docker/
!docker/nginx.conf
!frontend/
!frontend/package.json
!frontend/package-lock.json
!frontend/tsconfig.json
!frontend/index.html
!frontend/src/
!frontend/src/**
!frontend/public/
!frontend/public/**
!backend/
!backend/Cargo.toml
!backend/Cargo.lock
!backend/src/
!backend/src/**
**/.env*
**/*.pem
**/*.key

1
.gitignore vendored
View File

@ -11,3 +11,4 @@ e2e/node_modules/
# Misc
*.log
.DS_Store
.omo/

122
.pm/board.yaml Normal file
View File

@ -0,0 +1,122 @@
requirements:
- id: R1
title: Mobile 2D overhead curling
body: Players play curling in a mobile browser from a 2D overhead view.
- id: R2
title: Portrait sheet
body: The sheet is shown in portrait.
- id: R3
title: Two teams, unlimited clients
body: A room has two teams and an unlimited number of clients.
- id: R4
title: Team pick per device
body: Each client picks Team 1 or Team 2 on that device.
- id: R5
title: Anyone on turn may throw
body: Anyone on the team whose turn it is may throw.
- id: R6
title: Shareable room link
body: Players join a room from a shareable link.
- id: R7
title: Create room from empty link
body: If the link has no room, the client creates one and writes that room into the URL.
- id: R8
title: Ten ends, eight stones
body: A match has 10 ends. Each team has 8 stones per end.
- id: R9
title: Extra ends until a lead
body: Extra ends are played while the score is tied after 10 ends. The match ends when one team leads after an end.
- id: R10
title: Random hammer
body: Hammer is random at the start. The team without hammer throws first in the end.
- id: R11
title: Hammer after score or blank
body: The team that scores gives hammer away. A blank end keeps hammer.
- id: R12
title: Alternate throws
body: After a throw, that team has one fewer stone and the other team throws.
- id: R13
title: Score when stones are gone
body: When all throws have completed, the end is scored.
- id: R14
title: House only scores
body: Only stones touching the house can score.
- id: R15
title: Count to opponent nearest
body: The team closest to the tee scores one point for each of its stones nearer than the opponent's nearest stone.
- id: R16
title: Blank end is 0-0
body: If no stone can score, the end is 0-0.
- id: R17
title: Shared live state
body: Every client sees the current end, both scores, hammer, whose turn it is, stones remaining, stone positions, trajectories from all shots, and whether the match is over.
- id: R18
title: Winner on match end
body: When the match ends, both scores are shown with the winning team.
- id: R19
title: Hack to broom
body: A thrown stone starts at the hack and travels toward the broom. The player chooses initial speed and curl.
- id: R20
title: Clockwise is thrower's right
body: Clockwise curl moves the stone to the thrower's right.
- id: R21
title: Stone bounce, no line bounce
body: Stones bounce off other stones. Stones do not bounce back from a sideline or the backline.
- id: R22
title: Out of play rules
body: A stone is out if it touches a sideline. A stone must fully cross the backline to be out. A stone is out if it never fully crosses the hog line.
- id: R23
title: Sheet markings
body: The ice is a curling sheet with a house, hog line, sidelines, and a backline on the 12-foot ring.
- id: R24
title: Default house framing
body: The default view frames the house, sidelines, and backline above the controls.
- id: R25
title: Pan to hog
body: The player can pan up and down the sheet to the hog line.
- id: R26
title: Broom anywhere then throw
body: On turn, the player drags to place the broom anywhere on the sheet, then throws.
- id: R27
title: Discrete throw weights
body: The player sets throw weight on a discrete scale of draw weights 1 through 10, then named takeouts hack, board, control, normal, and peel.
- id: R28
title: Curl direction
body: The player sets curl clockwise or counterclockwise. Clockwise is the default.
- id: R29
title: Throw only on turn while still
body: Throw controls work only on that team's turn and only while stones are not moving.
- id: R30
title: Stones, hammer, scoreboard HUD
body: The HUD shows eight remaining-stone markers per team, a hammer marker, and a scoreboard with each end and a total.
- id: R31
title: End overlay
body: After the stones stop and the end is scored, an end-result overlay appears. The player dismisses it.
- id: R32
title: Shared motion, hide out stones
body: All clients see the same stone motion. A stone that leaves play is hidden when it leaves the sheet.
- id: R33
title: Copyable errors
body: Errors and the final result appear on the page as copyable text, not as a blocking alert.
- id: R34
title: No pinch-zoom, remember team
body: Pinch-zoom is off. The client remembers the last team choice.
choices:
- id: C1
title: Stack
body: Backend is Rust, Axum, WebSocket, Rapier2D, server-authoritative at 120 Hz. Frontend is TypeScript, Vite, Canvas2D, portrait-first touch.
- id: C2
title: Team colours
body: Team 1 is red. Team 2 is yellow.
- id: C3
title: Wire protocol
body: WebSocket JSON with snake_case tags. game_state carries totals, hammer, turn_team, per-end scoreboard, stones_remaining, and stones. Trajectories sample at 40 Hz from the 120 Hz sim. No room-full limit. No end_scored message.
- id: C4
title: Physics how
body: Ice friction is a server µ(v) table shared by linear and angular damping. Curl uses initial |ω| = 5 rot / 14 s with clockwise to the thrower's right. Stone-stone restitution is 0.9. Sidelines and backline are not colliders; out-of-play is applied after sim. Stone ids are { team, n } with n 1 through 8. Lengths use FEET_TO_METERS = 0.3048.
- id: C5
title: Weight mapping
body: Discrete UI weights map to calibrated open-ice stop speeds in SPEED_TABLE. The server takes velocity and curl, not a local friction scalar.
- id: C6
title: Out of scope
body: No accounts, persistence, anti-cheat, replay log, turn timer, or sweeping.

1
.pm/outbox.md Normal file
View File

@ -0,0 +1 @@
dirty: board.yaml

38
Dockerfile Normal file
View File

@ -0,0 +1,38 @@
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS frontend-build
WORKDIR /app
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
ARG VITE_BASE_PATH=/curltastic/
RUN npm test -- --pool=forks --poolOptions.forks.singleFork \
&& npm run build -- --base="${VITE_BASE_PATH}"
FROM rust:1.96-bookworm AS backend-build
WORKDIR /app
COPY backend/Cargo.toml backend/Cargo.lock ./
COPY backend/src/ ./src/
RUN cargo build --locked --release
FROM debian:bookworm-slim AS backend
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --gid 10001 curltastic \
&& useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin curltastic
COPY --from=backend-build /app/target/release/curltastic-backend /usr/local/bin/curltastic-backend
USER 10001:10001
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl --fail --silent --show-error http://127.0.0.1:3000/ || exit 1
ENTRYPOINT ["/usr/local/bin/curltastic-backend"]
FROM nginx:1.28-alpine AS frontend
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY --from=frontend-build /app/dist/ /usr/share/nginx/html/
USER 101:101
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1
ENTRYPOINT ["nginx"]
CMD ["-g", "daemon off;"]

View File

@ -2,6 +2,8 @@
A multiplayer 2D curling game for mobile browser. Any number of clients can join a room, pick **Team 1** or **Team 2** freely, and throw when it is that team's turn (solo pass-and-play or remote opponents). Default palette: Team 1 red, Team 2 yellow.
Product must-haves and decided hows live in `.pm/board.yaml`.
- **Backend** — Rust, Axum, WebSocket, Rapier2D physics (server-authoritative, 120 Hz).
- **Frontend** — TypeScript, Vite, Canvas2D, portrait-first touch UI.
@ -32,15 +34,15 @@ A multiplayer 2D curling game for mobile browser. Any number of clients can join
- On your team's turn, drag on solid ice to place the broom (aim is **not** limited to the house).
- Otherwise, drag to pan (default framing shows house + sidelines; pan up toward the hog line).
- Team dropdown: free switch mid-game (solo: throw for Team 1, switch, throw for Team 2).
- Velocity slider (release speed, m/s), curl buttons, friction scalar **0.51.5** (local; multiplies ice µ(v) table).
- Weight slider: draws 110 then takeouts hack, board, control, normal, peel. Curl buttons.
- Tap **THROW**. Anyone joined as the current turn team may throw.
- Parallel multi-stone trajectories; rotation θ comes from physics.
- Skeuomorphic 2×8 stones-left HUD; end-of-end modal with scoreboard (auto ~5s + manual dismiss).
- Skeuomorphic 2×8 stones-left HUD; end-of-end overlay with scoreboard (player dismisses).
### Physics (high level)
- Pure initial **velocity** (m/s), not discrete weight.
- Ice friction µ(v) table (interpolated) × local scalar; linear and angular damping share the table.
- Discrete weight labels map to calibrated release speeds (m/s) on the server.
- Ice friction µ(v) table (interpolated) on the server; linear and angular damping share the table.
- Curl: initial |ω| = 5 rot / 14 s; lateral continuous model (clockwise → right).
- Stonestone contacts are **nearly elastic** (`STONE_RESTITUTION = 0.9`) so takeouts launch both rocks along the impact line instead of plastic-sticking.
- Back line and sidelines are **not** colliders — touch → out of play after sim.
@ -72,13 +74,8 @@ node load_test.cjs # optional concurrent rooms
Unit tests: `cd backend && cargo test`, `cd frontend && npm test`.
### Branches (this work)
- **`jasonlooked`** — PR-A refactor: multi-client, multi-path animation, free broom, camera, FEET_TO_METERS.
- **`pr2-features`** — PR-B features: physics rewrite, team1/2 wire, scoreboard, HUD, modal (branched from PR-A tip).
### Limitations
- No accounts, persistence, anti-cheat, replay log, or turn timer.
- Sweeping not implemented; friction/curl not shared-room state.
- Sweeping not implemented.
- Stones past back/sideline/hog rules are removed from play after simulation.

16
TODO.md Normal file
View File

@ -0,0 +1,16 @@
# TODO
## Algorithm 2026-09-01
- [ ] Unused Vite leftovers are gone: `frontend/src/counter.ts` and `frontend/src/assets/{hero.png,typescript.svg,vite.svg}` are deleted and nothing imports them.
- [ ] Frontend unit-test/screenshot stack is gone: no `vitest` dep or `npm test` script, no `frontend/src/*.test.ts`, no `@playwright/test`, no `frontend/screenshot.cjs`.
- [ ] Backend `#[cfg(test)]` modules in `backend/src/game.rs` and `backend/src/protocol.rs` are gone.
- [ ] `e2e/load_test.cjs` is deleted and README no longer lists it.
- [ ] `POST /room` and `generate_room_code` are removed from `backend/src/main.rs`; rooms are only created via `?room=`.
- [ ] Baseball scoreboard is gone (`frontend/src/scoreboard.ts`, `#scoreboard-baseball`); HUD keeps stones-left chips and running totals only.
- [ ] README no longer claims a continuous m/s slider, a local friction scalar, or live `jasonlooked`/`pr2-features` branches; e2e `throwFor` no longer sends `friction`.
## Algorithm 2026-09-03
- [ ] Backend `#[cfg(test)]` module in `backend/src/physics.rs` is gone, and test-only `PhysicsWorld::new_open` is gone with it.
- [ ] Unused Vite leftover `frontend/public/icons.svg` is deleted; nothing in HTML/CSS/TS references it (favicon.svg stays).

70
docker/README.md Normal file
View File

@ -0,0 +1,70 @@
# Production images
Build from the repository root (no source mounts or runtime package installs):
```sh
docker build --target frontend -t curltastic-frontend .
docker build --target backend -t curltastic-backend .
```
| Target | Listener | Runtime user | Healthcheck |
| --- | --- | --- | --- |
| `frontend` (default final target) | HTTP `8080` | `101:101` | `GET /healthz` |
| `backend` | HTTP/WebSocket `3000` | `10001:10001` | `GET /` |
The frontend build argument `VITE_BASE_PATH` defaults to `/curltastic/` and is
passed to `vite build --base`. There are no required runtime environment
variables, credentials, writable application directories, or persistent volumes.
The Rust server keeps rooms in memory; rebuilding/restarting it loses games.
## Compose integration contract
- Publish/route only frontend port **8080**. Explicitly set Traefik's load-balancer
port to 8080; do not infer it from the upstream nginx image metadata.
- Traefik routes `/curltastic/` and strips `/curltastic` before forwarding.
- Attach frontend to Traefik's external `web` network and a private application
network. Put backend only on that private network, with the service/DNS name
**`curltastic-backend`**. Do not publish backend port 3000 or enable Traefik on it.
- The browser requests public `/curltastic/ws` using its existing host, port and
TLS scheme. nginx receives `/ws` and proxies the upgrade and query string to
`curltastic-backend:3000/ws`. It also proxies `POST /room`, exposed publicly as
`/curltastic/room`. The current frontend creates room IDs locally and does not
make HTTP room requests; there is no separate frontend HTTP API URL to set.
- Docker DNS is re-resolved every five seconds so backend replacement during
Compose watch does not leave nginx using a stale container address.
- Recommended runtime settings: `read_only: true`, `cap_drop: [ALL]`,
`security_opt: [no-new-privileges:true]`. Give frontend a writable `/tmp` tmpfs
(mode 1777) for nginx PID/temp files. Backend needs no writable mount.
- Never mount source code, a Docker socket, or host credentials. The build context
is allowlisted by `.dockerignore`; host dependencies and `.env*` are excluded.
- Use Compose watch `action: rebuild`: frontend changes rebuild the `frontend`
target, backend changes rebuild the `backend` target. Ignore node_modules,
dist, target, .git and other generated files. Watch Dockerfile for both services
and `docker/nginx.conf` for frontend.
The nginx listener expects already-stripped paths; a raw host-port request to
`/curltastic/` does not simulate Traefik. Test the public route through a
prefix-stripping test proxy, or request `/`, `/assets/...`, `/ws`, `/room` directly
when testing nginx alone.
## Development and verification
Local `npm run dev` still defaults to `/` and connects to the page hostname on
backend port 3000. Existing `VITE_WS_URL` overrides are preserved for custom local
setups; Vite variables are build-time settings, not runtime container settings.
Room navigation and share links retain the page path.
```sh
(cd frontend && npm ci && npm test -- --pool=forks --poolOptions.forks.singleFork)
(cd frontend && npm run build -- --base=/curltastic/)
(cd backend && cargo test --locked)
```
The frontend Docker build runs its tests and TypeScript checking before producing
static assets. The backend build uses the committed Cargo lockfile.
Verification at implementation: frontend 26 tests passed, Rust 22 tests passed,
and the production subpath frontend build passed. Both Docker build attempts were
denied by the execution approval layer; image builds, nginx syntax, and live
HTTP/WebSocket proxy behavior therefore remain unverified. No servers were
started and no deployment was performed.

69
docker/nginx.conf Normal file
View File

@ -0,0 +1,69 @@
worker_processes auto;
pid /tmp/nginx.pid;
error_log /dev/stderr warn;
events { worker_connections 1024; }
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /dev/stdout;
server_tokens off;
sendfile on;
client_body_temp_path /tmp/client_temp;
proxy_temp_path /tmp/proxy_temp;
fastcgi_temp_path /tmp/fastcgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
scgi_temp_path /tmp/scgi_temp;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# Re-resolve the private service after Compose watch replaces its container.
resolver 127.0.0.11 valid=5s ipv6=off;
server {
listen 8080;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 16k;
set $curltastic_backend curltastic-backend:3000;
location = /healthz {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
# Traefik removes /curltastic before these requests reach nginx.
location = /ws {
proxy_pass http://$curltastic_backend$request_uri;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
location = /room {
proxy_pass http://$curltastic_backend$request_uri;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
}
location /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
}
}

View File

@ -139,7 +139,9 @@ export class GameModel {
if (thrownId !== null) {
const thrownPath =
stones.find((p) => stoneIdsEqual(p.stone_id, thrownId!))?.trajectory ?? []
this.pathClockOffset = hogTrimStartIndex(thrownPath)
const hogIdx = hogTrimStartIndex(thrownPath)
// Leave at least one segment after offset so hog start still plays.
this.pathClockOffset = Math.min(hogIdx, Math.max(0, thrownPath.length - 2))
}
const pathMap = new Map<
@ -155,9 +157,7 @@ export class GameModel {
}
this.activePaths = pathMap
this.state.animating = Array.from(pathMap.values()).some(
(p) => p.path.length > this.pathClockOffset + 1,
)
this.state.animating = Array.from(pathMap.values()).some((p) => p.path.length > 1)
this.animationStartTime = performance.now()
// Fresh throw: wait for the matching game_state.
this.pendingStones = []

37
frontend/src/game.test.ts Normal file
View File

@ -0,0 +1,37 @@
import { afterEach, expect, it, vi } from 'vitest'
import { startGame } from './game'
import { connect } from './net'
import { createHud } from './hud'
vi.mock('./net', () => ({ connect: vi.fn(), sendThrow: vi.fn() }))
vi.mock('./renderer', () => ({ createRenderer: () => ({ draw: vi.fn() }) }))
vi.mock('./hud', () => ({
createHud: vi.fn(),
createVelocitySelector: () => ({ setEnabled: vi.fn() }),
createCurlSelector: () => ({ setEnabled: vi.fn() }),
}))
afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks() })
it.each(['', '?room=INVITE'])('keeps room navigation and share links under the app path (%s)', (search) => {
const element = { addEventListener: vi.fn(), value: 'team1' }
const setShareLink = vi.fn()
vi.mocked(createHud).mockReturnValue({
root: element, velocityControl: element, curlSelector: element,
throwButton: element, teamSelect: element,
setShareLink, setTeam: vi.fn(), update: vi.fn(),
} as unknown as ReturnType<typeof createHud>)
vi.stubGlobal('document', { querySelector: () => ({
querySelector: () => element, appendChild: vi.fn(),
}) })
const location = new URL(`https://example.test/curltastic/${search}`)
const replaceState = vi.fn()
vi.stubGlobal('window', { location, history: { replaceState } })
vi.stubGlobal('localStorage', { getItem: () => null })
vi.stubGlobal('requestAnimationFrame', vi.fn())
startGame()
const room = vi.mocked(connect).mock.calls[0][0]
expect(setShareLink).toHaveBeenCalledWith(`https://example.test/curltastic/?room=${room}`)
if (!search) expect(replaceState).toHaveBeenCalledWith({}, '', `/curltastic/?room=${room}`)
else expect(replaceState).not.toHaveBeenCalled()
})

View File

@ -27,10 +27,10 @@ export function startGame(): void {
let room = params.get('room')
if (!room) {
room = generateRoomCode()
window.history.replaceState({}, '', `/?room=${room}`)
window.history.replaceState({}, '', `${window.location.pathname}?room=${room}`)
}
const shareLink = `${window.location.origin}/?room=${room}`
const shareLink = `${window.location.origin}${window.location.pathname}?room=${room}`
hud.setShareLink(shareLink)
const model = new GameModel()
@ -43,6 +43,8 @@ export function startGame(): void {
scoreboard: typeof model.state.scoreboard
}
let deferredEndModal: EndModalPayload | null = null
/** True after trajectories for the current throw until animation ends. */
let throwAnimPending = false
const stored = localStorage.getItem('curltastic-team')
const initialTeam: Team = stored === 'team2' || stored === 'yellow' ? 'team2' : 'team1'
@ -78,7 +80,7 @@ export function startGame(): void {
}
const flushEndModal = () => {
if (!deferredEndModal || model.state.animating) return
if (!deferredEndModal || model.state.animating || throwAnimPending) return
hud.showEndModal(deferredEndModal)
deferredEndModal = null
}
@ -92,6 +94,7 @@ export function startGame(): void {
const wasAnimating = model.state.animating
const activeStonePos = model.tick(performance.now())
if (wasAnimating && !model.state.animating) {
throwAnimPending = false
flushEndModal()
updateControls()
}
@ -121,14 +124,17 @@ export function startGame(): void {
onGameState: (msg) => {
model.updateGameState(msg)
queueEndModalIfNeeded()
// Never flush here: game_state can arrive before/during throw anim.
// Modal is flushed when the trajectory animation ends (or traj is a no-op).
// Only flush if this throw is not mid-animation (or never animated).
flushEndModal()
updateControls()
},
onTrajectories: (stones) => {
model.startTrajectory(stones)
// Short / empty paths finish immediately — show end modal if already queued.
flushEndModal()
// Hold end modal until playback finishes (even if sim paths were short).
throwAnimPending = model.state.animating
if (!throwAnimPending) {
flushEndModal()
}
updateControls()
},
onGameOver: (scores, winner) => {
@ -202,6 +208,8 @@ export function startGame(): void {
throwBtn.addEventListener('click', () => {
if (!model.isMyTurn) return
// Hold end-of-end modal until trajectories + playback complete.
throwAnimPending = true
sendThrow(
hud.teamSelect.value as Team,
model.broom.x,

View File

@ -71,6 +71,25 @@ const TEAM_LABELS: Record<Team, string> = {
team2: 'Team 2',
}
/** "Team 1 stole 1!" / "Team 2 scored 4!" from the completed end row. */
function endResultHeadline(payload: {
team1: number
team2: number
scoreboard: readonly EndScore[]
}): string {
const entry = payload.scoreboard[payload.scoreboard.length - 1]
const hammer = entry?.hammer
const t1 = payload.team1
const t2 = payload.team2
if (t1 === 0 && t2 === 0) return 'Blank end!'
if (t1 > 0) {
const stole = hammer === 'team2'
return stole ? `Team 1 stole ${t1}!` : `Team 1 scored ${t1}!`
}
const stole = hammer === 'team1'
return stole ? `Team 2 stole ${t2}!` : `Team 2 scored ${t2}!`
}
function buildStoneChipsHtml(team: Team): string {
const chips = Array.from({ length: STONES_PER_TEAM }, (_, i) => {
return `<span class="stone-chip stone-chip--${team}" data-index="${i}" aria-hidden="true"></span>`
@ -79,31 +98,22 @@ function buildStoneChipsHtml(team: Team): string {
return `<div class="stones-row stones-row--${team}" data-team="${team}" role="img" aria-label="${TEAM_LABELS[team]} stones remaining"><span class="hammer-badge" title="Hammer" aria-hidden="true">🔨</span>${chips}</div>`
}
function scoreboardTotals(scoreboard: readonly EndScore[]): [number, number] {
let t1 = 0
let t2 = 0
for (const e of scoreboard) {
t1 += e.team1
t2 += e.team2
}
return [t1, t2]
}
export function createHud(): Hud {
const root = document.createElement('div')
root.id = 'hud'
root.innerHTML = `
<div id="hud-top-group">
<div class="hud-row" id="share-row">
<div id="hud-status-row" aria-live="polite">
<div id="stones-hud">
${buildStoneChipsHtml('team1')}
${buildStoneChipsHtml('team2')}
</div>
<div id="scoreboard-baseball" class="scoreboard-baseball-wrap"></div>
</div>
<div class="hud-row" id="hud-meta-row">
<div id="share"><button type="button">Copy share link</button></div>
</div>
<div id="stones-hud" aria-live="polite">
${buildStoneChipsHtml('team1')}
${buildStoneChipsHtml('team2')}
</div>
<div id="scoreboard-baseball" class="scoreboard-baseball-wrap" aria-live="polite"></div>
<div class="hud-row">
<div id="end-info">End 1 · Waiting</div>
<div id="turn-info">Waiting</div>
<select id="team-select" aria-label="Team">
<option value="team1">Team 1</option>
<option value="team2">Team 2</option>
@ -120,7 +130,7 @@ export function createHud(): Hud {
<div id="waiting">Waiting</div>
`
const endInfoEl = root.querySelector<HTMLDivElement>('#end-info')!
const turnInfoEl = root.querySelector<HTMLDivElement>('#turn-info')!
const teamSelect = root.querySelector<HTMLSelectElement>('#team-select')!
const waitingEl = root.querySelector<HTMLDivElement>('#waiting')!
const stonesHud = root.querySelector<HTMLDivElement>('#stones-hud')!
@ -207,9 +217,8 @@ export function createHud(): Hud {
state.phase === 'playing'
? `${TEAM_LABELS[state.turnTeam]}'s turn`
: state.phase.replace(/_/g, ' ')
endInfoEl.textContent = `End ${state.end} · ${phaseText}`
turnInfoEl.textContent = phaseText
waitingEl.classList.toggle('visible', state.phase !== 'game_complete' && state.animating)
// Hammer class first so stones-remaining aria can mention it.
updateHammerBadge(state.hammer)
updateStonesRemaining(state.stonesRemaining)
updateScoreboard(state.scoreboard, state.scores, {
@ -224,26 +233,11 @@ export function createHud(): Hud {
modal.setAttribute('role', 'dialog')
modal.setAttribute('aria-modal', 'true')
modal.setAttribute('aria-labelledby', 'end-modal-title')
const boardHtml = renderBaseballScoreboard(
payload.scoreboard,
scoreboardTotals(payload.scoreboard),
{
ends: ENDS,
hammer: payload.nextHammer,
currentEnd: payload.end,
},
)
const headline = endResultHeadline(payload)
modal.innerHTML = `
<div class="end-modal-backdrop" data-dismiss="1"></div>
<div class="end-modal-card">
<h2 id="end-modal-title">End ${payload.end} complete</h2>
<p class="end-modal-points">
<span class="end-modal-team end-modal-team--team1">Team 1 <strong>${payload.team1}</strong></span>
<span class="end-modal-vs">vs</span>
<span class="end-modal-team end-modal-team--team2">Team 2 <strong>${payload.team2}</strong></span>
</p>
<p class="end-modal-hammer">Next hammer: <strong>${TEAM_LABELS[payload.nextHammer]}</strong></p>
<div class="end-modal-board">${boardHtml}</div>
<div class="end-modal-card end-modal-card--compact">
<h2 id="end-modal-title">${headline}</h2>
<button type="button" class="end-modal-dismiss" data-dismiss="1">Dismiss</button>
</div>
`
@ -285,57 +279,24 @@ export function createVelocitySelector(
const wrap = document.createElement('div')
wrap.className = 'velocity-control-inner'
const title = document.createElement('label')
title.textContent = 'Weight'
wrap.appendChild(title)
const slider = document.createElement('input')
slider.type = 'range'
slider.min = '0'
slider.max = String(14) // 1..10 + hack/board/control/normal/peel
slider.max = '14'
slider.step = '1'
slider.value = String(state.index)
slider.className = 'velocity-slider'
slider.setAttribute('list', 'weight-marks')
slider.ariaLabel = 'Throw weight'
const marks = document.createElement('datalist')
marks.id = 'weight-marks'
const tickLabels = [1, 5, 7, 10, 'hack', 'board', 'control', 'normal', 'peel'] as const
for (const tick of tickLabels) {
const opt = document.createElement('option')
opt.value = String(indexOfLabel(tick))
opt.label = String(tick)
marks.appendChild(opt)
}
const ticks = document.createElement('div')
ticks.className = 'velocity-ticks'
ticks.setAttribute('aria-hidden', 'true')
const named = ['hack', 'board', 'control', 'normal', 'peel'] as const
ticks.innerHTML = named
.map((name) => `<span class="velocity-tick" data-label="${name}">${name}</span>`)
.join('')
const readout = document.createElement('div')
readout.className = 'velocity-readout'
wrap.appendChild(slider)
wrap.appendChild(marks)
wrap.appendChild(ticks)
wrap.appendChild(readout)
const update = () => {
state.index = clampSpeedIndex(Number(slider.value))
const label = labelAtIndex(state.index)
const v = velocityForLabel(label)
const name = formatSpeedLabel(label)
const isTakeout = typeof label === 'string'
readout.textContent = isTakeout
? `${name} · ${v.toFixed(3)} m/s`
: `${name} · ${v.toFixed(3)} m/s`
ticks.querySelectorAll<HTMLSpanElement>('.velocity-tick').forEach((el) => {
el.classList.toggle('velocity-tick--active', el.dataset.label === String(label))
})
readout.textContent = formatSpeedLabel(label)
onSelect()
}
slider.addEventListener('input', update)

31
frontend/src/net.test.ts Normal file
View File

@ -0,0 +1,31 @@
import { afterEach, expect, it, vi } from 'vitest'
const callbacks = {
onJoined: vi.fn(), onWaiting: vi.fn(), onGameState: vi.fn(),
onTrajectories: vi.fn(), onGameOver: vi.fn(), onError: vi.fn(), onClose: vi.fn(),
}
afterEach(() => {
vi.unstubAllGlobals()
vi.unstubAllEnvs()
vi.resetModules()
})
it.each([
[false, 'https://example.test:8443/curltastic/', '/curltastic/', '', 'wss://example.test:8443/curltastic/ws'],
[false, 'http://localhost:4001/curltastic/', '/curltastic/', '', 'ws://localhost:4001/curltastic/ws'],
[false, 'https://example.test/', '/', '', 'wss://example.test/ws'],
[true, 'http://localhost:5173/', '/', '', 'ws://localhost:3000/ws'],
[true, 'https://example.test:5173/', '/', '', 'wss://example.test:3000/ws'],
[false, 'https://example.test/curltastic/', '/curltastic/', 'wss://custom.test/ws', 'wss://custom.test/ws'],
])('resolves the WebSocket endpoint (dev=%s, page=%s)', async (dev, page, base, override, expected) => {
vi.stubEnv('DEV', dev)
vi.stubEnv('BASE_URL', base)
vi.stubEnv('VITE_WS_URL', override)
vi.stubGlobal('window', { location: new URL(page) })
const WebSocket = vi.fn()
vi.stubGlobal('WebSocket', WebSocket)
const { connect } = await import('./net')
connect('A&B C', callbacks)
expect(WebSocket).toHaveBeenCalledWith(`${expected}?room=A%26B%20C`)
})

View File

@ -10,7 +10,10 @@ const WS_URL = import.meta.env.VITE_WS_URL
function resolveWsUrl(): string {
if (WS_URL) return WS_URL
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${protocol}//${window.location.hostname}:3000/ws`
if (import.meta.env.DEV) {
return `${protocol}//${window.location.hostname}:3000/ws`
}
return `${protocol}//${window.location.host}${import.meta.env.BASE_URL}ws`
}
export interface NetCallbacks {

View File

@ -178,9 +178,6 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
ctx.arc(0, 0, r, 0, Math.PI * 2)
ctx.fillStyle = bodyGrad
ctx.fill()
ctx.strokeStyle = 'rgba(255,255,255,0.9)'
ctx.lineWidth = Math.max(1.5, scale() * 0.02)
ctx.stroke()
// Asymmetric handle: bright bar + dark toe so spin reads clearly.
ctx.fillStyle = 'rgba(255,255,255,0.92)'

View File

@ -87,29 +87,57 @@ html, body {
#hud-top-group {
display: flex;
flex-direction: column;
gap: 4px;
gap: 6px;
}
/* —— Stones remaining (2×8 skeuomorphic chips) —— */
#hud-status-row {
display: flex;
flex-direction: row;
align-items: stretch;
justify-content: space-between;
gap: 8px;
width: 100%;
}
#hud-meta-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
}
#turn-info {
flex: 1;
text-align: center;
font-size: 13px;
font-weight: 600;
opacity: 0.92;
}
/* —— Stones remaining (2×8 chips) —— */
#stones-hud {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 6px 10px;
margin: 0 auto 2px;
justify-content: center;
gap: 4px;
flex: 1 1 50%;
min-width: 0;
padding: 4px 6px;
margin: 0;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 14px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 4px 12px rgba(0, 0, 0, 0.25);
border-radius: 10px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 2px 8px rgba(0, 0, 0, 0.25);
pointer-events: none;
}
.stones-row {
display: grid;
/* Fixed hammer column so both team chips align vertically under each other. */
grid-template-columns: 18px repeat(8, 18px);
column-gap: 5px;
grid-template-columns: 12px repeat(8, 12px);
column-gap: 3px;
align-items: center;
justify-items: center;
width: max-content;
@ -117,12 +145,12 @@ html, body {
.hammer-badge {
grid-column: 1;
width: 18px;
height: 18px;
width: 12px;
height: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-size: 10px;
line-height: 1;
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.55));
/* Keep layout space when the other team has hammer (no collapse / shift). */
@ -134,14 +162,14 @@ html, body {
}
.stone-chip {
width: 18px;
height: 18px;
width: 12px;
height: 12px;
border-radius: 50%;
border: 1.5px solid rgba(255, 255, 255, 0.85);
border: none;
box-shadow:
inset 0 2px 3px rgba(255, 255, 255, 0.45),
inset 0 -2px 3px rgba(0, 0, 0, 0.35),
0 1px 2px rgba(0, 0, 0, 0.4);
inset 0 1px 2px rgba(255, 255, 255, 0.35),
inset 0 -1px 2px rgba(0, 0, 0, 0.35),
0 1px 2px rgba(0, 0, 0, 0.35);
transition: opacity 0.2s ease, transform 0.2s ease, filter 0.2s ease;
}
@ -158,19 +186,19 @@ html, body {
transform: scale(0.72);
filter: grayscale(0.6);
box-shadow: none;
border-color: rgba(255, 255, 255, 0.25);
}
/* —— Baseball-style scoreboard matrix —— */
.scoreboard-baseball-wrap {
display: flex;
justify-content: center;
width: 100%;
align-items: center;
flex: 1 1 50%;
min-width: 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding: 4px 0 6px;
padding: 0;
pointer-events: none;
flex-shrink: 0;
}
.scoreboard-baseball {
@ -179,47 +207,49 @@ html, body {
border-collapse: collapse;
table-layout: fixed;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
font-size: 12px;
font-size: 9px;
font-weight: 600;
line-height: 1.25;
line-height: 1.15;
letter-spacing: 0.02em;
color: #fff;
background: rgba(0, 0, 0, 0.55);
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 8px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 2px 10px rgba(0, 0, 0, 0.35);
border-radius: 6px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 2px 8px rgba(0, 0, 0, 0.3);
overflow: hidden;
transform: scale(0.92);
transform-origin: center center;
}
.scoreboard-baseball th,
.scoreboard-baseball td {
min-width: 1.35em;
padding: 3px 4px;
min-width: 1.1em;
padding: 2px 3px;
text-align: center;
border: 1px solid rgba(255, 255, 255, 0.1);
white-space: nowrap;
}
.scoreboard-baseball thead th {
font-size: 10px;
font-size: 8px;
font-weight: 700;
color: rgba(255, 255, 255, 0.72);
background: rgba(255, 255, 255, 0.06);
}
.scoreboard-baseball__corner {
min-width: 3.6em;
min-width: 2.6em;
border-left: none;
border-top: none;
background: transparent;
}
.scoreboard-baseball__label {
min-width: 3.6em;
min-width: 2.6em;
text-align: left;
padding-left: 6px;
padding-right: 6px;
font-size: 10px;
padding-left: 4px;
padding-right: 4px;
font-size: 8px;
font-weight: 700;
color: rgba(255, 255, 255, 0.92);
background: rgba(255, 255, 255, 0.04);
@ -410,7 +440,7 @@ html, body {
width: 100%;
}
#share-row {
#share-row, #hud-meta-row #share {
justify-content: center;
margin-bottom: 4px;
}
@ -468,7 +498,7 @@ html, body {
width: 100%;
}
#velocity-control label {
#velocity-control label { display: none;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
@ -622,3 +652,15 @@ html, body {
.velocity-tick--active {
color: #7dffc0;
}
.end-modal-card--compact {
max-width: min(360px, 92vw);
text-align: center;
padding: 20px 22px;
}
.end-modal-card--compact h2 {
margin: 0 0 16px;
font-size: 22px;
}