Init multiplayer
All checks were successful
Deploy Feature / deploy-feature (push) Successful in 28s

This commit is contained in:
Johnny322
2026-02-24 20:54:14 +01:00
parent 6dde7eedb6
commit 9945f8163e
14 changed files with 2027 additions and 53 deletions

View File

@@ -0,0 +1,46 @@
name: Deploy Feature
on:
push:
branches:
- feature-*
jobs:
deploy-feature:
runs-on: pusher
container:
volumes:
- /repos:/repos
- /www/jeopardy-test:/www/jeopardy-test
steps:
- name: Install Node.js and npm
run: |
set -e
apk add --no-cache nodejs npm
node -v
npm -v
- name: Build app (feature)
run: |
set -euo pipefail
BRANCH="${GITHUB_REF_NAME:-}"
if [ -z "$BRANCH" ] && [ -n "${GITHUB_REF:-}" ]; then
BRANCH="${GITHUB_REF#refs/heads/}"
fi
if [ -z "$BRANCH" ]; then
echo "Unable to determine branch name."
exit 1
fi
cd /repos/music-jeopardy
git fetch origin "$BRANCH"
git checkout -B "$BRANCH" "origin/$BRANCH"
npm ci --include=dev
npm run generate:data
npm run build
- name: Deploy static files to test
run: |
set -euo pipefail
mkdir -p /www/jeopardy-test
find /www/jeopardy-test -mindepth 1 -maxdepth 1 -exec rm -rf {} +
cp -r /repos/music-jeopardy/dist/. /www/jeopardy-test/

View File

@@ -0,0 +1,49 @@
name: Deploy Master
on:
push:
branches:
- master
jobs:
deploy-master:
runs-on: pusher
container:
volumes:
- /repos:/repos
- /www/jeopardy:/www/jeopardy
steps:
- name: Install Node.js and npm
run: |
set -e
apk add --no-cache nodejs npm
node -v
npm -v
- name: Build app (master)
run: |
set -euo pipefail
cd /repos/music-jeopardy
git fetch origin master
git checkout -B master origin/master
npm ci --include=dev
npm run generate:data
npm run build
- name: Deploy static files to production
run: |
set -euo pipefail
mkdir -p /www/jeopardy
find /www/jeopardy -mindepth 1 -maxdepth 1 -exec rm -rf {} +
cp -r /repos/music-jeopardy/dist/. /www/jeopardy/
- name: Restart realtime service (if available)
run: |
set -e
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload
systemctl enable --now music-jeopardy-realtime.service
systemctl restart music-jeopardy-realtime.service
else
echo "systemctl not available in this runner container. Restart on host manually."
fi

View File

@@ -1,32 +0,0 @@
name: On Push Deploy
on:
push:
jobs:
deploy:
runs-on: pusher
container:
volumes:
- /repos:/repos
- /www/jeopardy:/www/jeopardy
steps:
- name: Install Node.js & npm
run: |
apk add --no-cache nodejs npm # if your job container is Alpine
node -v
npm -v
- name: Pull and pull
run: |
ls /www/jeopardy
- name: Install dependencies
run: |
cd /repos/music-jeopardy
git pull
npm install
- name: Build production bundle
run: |
cd /repos/music-jeopardy
npm run generate:data
npm run build
cp -r /repos/music-jeopardy/dist/* /www/jeopardy

1
.gitignore vendored
View File

@@ -1 +1,2 @@
node_modules/ node_modules/
dist/

25
deploy/SETUP.md Normal file
View File

@@ -0,0 +1,25 @@
# Production Setup
## 1) Install systemd service
```bash
sudo cp /repos/music-jeopardy/deploy/systemd/music-jeopardy-realtime.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now music-jeopardy-realtime.service
sudo systemctl status music-jeopardy-realtime.service
```
## 2) Install nginx config
```bash
sudo cp /repos/music-jeopardy/deploy/nginx/jeopardy.toppit.net.conf /etc/nginx/sites-available/jeopardy.toppit.net
sudo ln -sf /etc/nginx/sites-available/jeopardy.toppit.net /etc/nginx/sites-enabled/jeopardy.toppit.net
sudo nginx -t
sudo systemctl reload nginx
```
## 3) Verify
1. Open `https://jeopardy.toppit.net`
2. Create a session in the app (`Game ID`)
3. Open a second browser/device and join by same `Game ID`

View File

@@ -0,0 +1,27 @@
server {
listen 443 ssl;
server_name jeopardy.toppit.net;
root /www/jeopardy;
index index.html;
# SPA routing for Vue Router / client-side routes
location / {
try_files $uri $uri/ /index.html;
}
# Realtime session API + websocket
location /realtime/ {
proxy_pass http://127.0.0.1:8787/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
ssl_certificate /etc/letsencrypt/live/jeopardy.toppit.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/jeopardy.toppit.net/privkey.pem;
}

View File

@@ -0,0 +1,16 @@
[Unit]
Description=Music Jeopardy Realtime Server
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/repos/music-jeopardy
Environment=NODE_ENV=production
Environment=REALTIME_PORT=8787
ExecStart=/usr/bin/npm run realtime:server
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target

771
package-lock.json generated
View File

@@ -8,10 +8,12 @@
"name": "music-jeopardy", "name": "music-jeopardy",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"vue": "^3.2.47" "vue": "^3.2.47",
"ws": "^8.18.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^2.3.4", "@vitejs/plugin-vue": "^2.3.4",
"tsx": "^4.7.0",
"typescript": "^4.9.5", "typescript": "^4.9.5",
"vite": "^2.9.16", "vite": "^2.9.16",
"vue-tsc": "^1.0.24" "vue-tsc": "^1.0.24"
@@ -59,6 +61,182 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": { "node_modules/@esbuild/linux-loong64": {
"version": "0.14.54", "version": "0.14.54",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz",
@@ -75,6 +253,230 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
"cpu": [
"mips64el"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5", "version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
@@ -627,6 +1029,18 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/get-tsconfig": {
"version": "4.13.6",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
"dev": true,
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -773,6 +1187,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "2.77.3", "version": "2.77.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.77.3.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.77.3.tgz",
@@ -820,6 +1243,82 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-loong64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
"dev": true,
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.3",
"@esbuild/android-arm": "0.27.3",
"@esbuild/android-arm64": "0.27.3",
"@esbuild/android-x64": "0.27.3",
"@esbuild/darwin-arm64": "0.27.3",
"@esbuild/darwin-x64": "0.27.3",
"@esbuild/freebsd-arm64": "0.27.3",
"@esbuild/freebsd-x64": "0.27.3",
"@esbuild/linux-arm": "0.27.3",
"@esbuild/linux-arm64": "0.27.3",
"@esbuild/linux-ia32": "0.27.3",
"@esbuild/linux-loong64": "0.27.3",
"@esbuild/linux-mips64el": "0.27.3",
"@esbuild/linux-ppc64": "0.27.3",
"@esbuild/linux-riscv64": "0.27.3",
"@esbuild/linux-s390x": "0.27.3",
"@esbuild/linux-x64": "0.27.3",
"@esbuild/netbsd-arm64": "0.27.3",
"@esbuild/netbsd-x64": "0.27.3",
"@esbuild/openbsd-arm64": "0.27.3",
"@esbuild/openbsd-x64": "0.27.3",
"@esbuild/openharmony-arm64": "0.27.3",
"@esbuild/sunos-x64": "0.27.3",
"@esbuild/win32-arm64": "0.27.3",
"@esbuild/win32-ia32": "0.27.3",
"@esbuild/win32-x64": "0.27.3"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "4.9.5", "version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
@@ -953,6 +1452,26 @@
"peerDependencies": { "peerDependencies": {
"vue": "3.5.27" "vue": "3.5.27"
} }
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"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
}
}
} }
}, },
"dependencies": { "dependencies": {
@@ -983,6 +1502,83 @@
"@babel/helper-validator-identifier": "^7.28.5" "@babel/helper-validator-identifier": "^7.28.5"
} }
}, },
"@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
"dev": true,
"optional": true
},
"@esbuild/android-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
"dev": true,
"optional": true
},
"@esbuild/android-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
"dev": true,
"optional": true
},
"@esbuild/android-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
"dev": true,
"optional": true
},
"@esbuild/darwin-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
"dev": true,
"optional": true
},
"@esbuild/darwin-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
"dev": true,
"optional": true
},
"@esbuild/freebsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
"dev": true,
"optional": true
},
"@esbuild/freebsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
"dev": true,
"optional": true
},
"@esbuild/linux-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
"dev": true,
"optional": true
},
"@esbuild/linux-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
"dev": true,
"optional": true
},
"@esbuild/linux-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
"dev": true,
"optional": true
},
"@esbuild/linux-loong64": { "@esbuild/linux-loong64": {
"version": "0.14.54", "version": "0.14.54",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz",
@@ -990,6 +1586,104 @@
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"@esbuild/linux-mips64el": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
"dev": true,
"optional": true
},
"@esbuild/linux-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
"dev": true,
"optional": true
},
"@esbuild/linux-riscv64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
"dev": true,
"optional": true
},
"@esbuild/linux-s390x": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
"dev": true,
"optional": true
},
"@esbuild/linux-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
"dev": true,
"optional": true
},
"@esbuild/netbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
"dev": true,
"optional": true
},
"@esbuild/netbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
"dev": true,
"optional": true
},
"@esbuild/openbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
"dev": true,
"optional": true
},
"@esbuild/openbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
"dev": true,
"optional": true
},
"@esbuild/openharmony-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
"dev": true,
"optional": true
},
"@esbuild/sunos-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
"dev": true,
"optional": true
},
"@esbuild/win32-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
"dev": true,
"optional": true
},
"@esbuild/win32-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
"dev": true,
"optional": true
},
"@esbuild/win32-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
"dev": true,
"optional": true
},
"@jridgewell/sourcemap-codec": { "@jridgewell/sourcemap-codec": {
"version": "1.5.5", "version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
@@ -1333,6 +2027,15 @@
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true "dev": true
}, },
"get-tsconfig": {
"version": "4.13.6",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
"dev": true,
"requires": {
"resolve-pkg-maps": "^1.0.0"
}
},
"hasown": { "hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -1423,6 +2126,12 @@
"supports-preserve-symlinks-flag": "^1.0.0" "supports-preserve-symlinks-flag": "^1.0.0"
} }
}, },
"resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true
},
"rollup": { "rollup": {
"version": "2.77.3", "version": "2.77.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.77.3.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.77.3.tgz",
@@ -1449,6 +2158,60 @@
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true "dev": true
}, },
"tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"requires": {
"esbuild": "~0.27.0",
"fsevents": "~2.3.3",
"get-tsconfig": "^4.7.5"
},
"dependencies": {
"@esbuild/linux-loong64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
"dev": true,
"optional": true
},
"esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
"dev": true,
"requires": {
"@esbuild/aix-ppc64": "0.27.3",
"@esbuild/android-arm": "0.27.3",
"@esbuild/android-arm64": "0.27.3",
"@esbuild/android-x64": "0.27.3",
"@esbuild/darwin-arm64": "0.27.3",
"@esbuild/darwin-x64": "0.27.3",
"@esbuild/freebsd-arm64": "0.27.3",
"@esbuild/freebsd-x64": "0.27.3",
"@esbuild/linux-arm": "0.27.3",
"@esbuild/linux-arm64": "0.27.3",
"@esbuild/linux-ia32": "0.27.3",
"@esbuild/linux-loong64": "0.27.3",
"@esbuild/linux-mips64el": "0.27.3",
"@esbuild/linux-ppc64": "0.27.3",
"@esbuild/linux-riscv64": "0.27.3",
"@esbuild/linux-s390x": "0.27.3",
"@esbuild/linux-x64": "0.27.3",
"@esbuild/netbsd-arm64": "0.27.3",
"@esbuild/netbsd-x64": "0.27.3",
"@esbuild/openbsd-arm64": "0.27.3",
"@esbuild/openbsd-x64": "0.27.3",
"@esbuild/openharmony-arm64": "0.27.3",
"@esbuild/sunos-x64": "0.27.3",
"@esbuild/win32-arm64": "0.27.3",
"@esbuild/win32-ia32": "0.27.3",
"@esbuild/win32-x64": "0.27.3"
}
}
}
},
"typescript": { "typescript": {
"version": "4.9.5", "version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
@@ -1530,6 +2293,12 @@
} }
} }
} }
},
"ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"requires": {}
} }
} }
} }

View File

@@ -6,10 +6,12 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"generate:data": "tsx scripts/generate-data-manifest.ts", "generate:data": "tsx scripts/generate-data-manifest.ts",
"preview": "vite preview" "preview": "vite preview",
"realtime:server": "tsx server/realtime-server.ts"
}, },
"dependencies": { "dependencies": {
"vue": "^3.2.47" "vue": "^3.2.47",
"ws": "^8.18.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^2.3.4", "@vitejs/plugin-vue": "^2.3.4",

472
public/data.json Normal file
View File

@@ -0,0 +1,472 @@
[
{
"name": "HCO",
"categories": [
{
"name": "Animals in Songs",
"clues": [
{
"number": 1,
"points": 100,
"song": "/Data/HCO/Animals in Songs/songs/1.mp3",
"answer": "/Data/HCO/Animals in Songs/answers/1.mp3"
},
{
"number": 2,
"points": 200,
"song": "/Data/HCO/Animals in Songs/songs/2.mp3",
"answer": "/Data/HCO/Animals in Songs/answers/2.mp3"
},
{
"number": 3,
"points": 300,
"song": "/Data/HCO/Animals in Songs/songs/3.mp3",
"answer": "/Data/HCO/Animals in Songs/answers/3.mp3"
},
{
"number": 4,
"points": 400,
"song": "/Data/HCO/Animals in Songs/songs/4.mp3",
"answer": "/Data/HCO/Animals in Songs/answers/4.mp3"
},
{
"number": 5,
"points": 500,
"song": "/Data/HCO/Animals in Songs/songs/5.mp3",
"answer": "/Data/HCO/Animals in Songs/answers/5.mp3"
}
]
},
{
"name": "Billboard Number 1's",
"clues": [
{
"number": 1,
"points": 100,
"song": "/Data/HCO/Billboard Number 1's/songs/1.mp3",
"answer": "/Data/HCO/Billboard Number 1's/answers/1.mp3"
},
{
"number": 2,
"points": 200,
"song": "/Data/HCO/Billboard Number 1's/songs/2.mp3",
"answer": "/Data/HCO/Billboard Number 1's/answers/2.mp3"
},
{
"number": 3,
"points": 300,
"song": "/Data/HCO/Billboard Number 1's/songs/3.mp3",
"answer": "/Data/HCO/Billboard Number 1's/answers/3.mp3"
},
{
"number": 4,
"points": 400,
"song": "/Data/HCO/Billboard Number 1's/songs/4.mp3",
"answer": "/Data/HCO/Billboard Number 1's/answers/4.mp3"
},
{
"number": 5,
"points": 500,
"song": "/Data/HCO/Billboard Number 1's/songs/5.mp3",
"answer": "/Data/HCO/Billboard Number 1's/answers/5.mp3"
}
]
},
{
"name": "Kitchen Classics",
"clues": [
{
"number": 1,
"points": 100,
"song": "/Data/HCO/Kitchen Classics/songs/1.mp3",
"answer": "/Data/HCO/Kitchen Classics/answers/1.mp3"
},
{
"number": 2,
"points": 200,
"song": "/Data/HCO/Kitchen Classics/songs/2.mp3",
"answer": "/Data/HCO/Kitchen Classics/answers/2.mp3"
},
{
"number": 3,
"points": 300,
"song": "/Data/HCO/Kitchen Classics/songs/3.mp3",
"answer": "/Data/HCO/Kitchen Classics/answers/3.mp3"
},
{
"number": 4,
"points": 400,
"song": "/Data/HCO/Kitchen Classics/songs/4.mp3",
"answer": "/Data/HCO/Kitchen Classics/answers/4.mp3"
},
{
"number": 5,
"points": 500,
"song": "/Data/HCO/Kitchen Classics/songs/5.mp3",
"answer": "/Data/HCO/Kitchen Classics/answers/5.mp3"
}
]
},
{
"name": "Memes",
"clues": [
{
"number": 1,
"points": 100,
"song": "/Data/HCO/Memes/songs/1.mp3",
"answer": "/Data/HCO/Memes/answers/1.mp3"
},
{
"number": 2,
"points": 200,
"song": "/Data/HCO/Memes/songs/2.mp3",
"answer": "/Data/HCO/Memes/answers/2.mp3"
},
{
"number": 3,
"points": 300,
"song": "/Data/HCO/Memes/songs/3.mp3",
"answer": "/Data/HCO/Memes/answers/3.mp3"
},
{
"number": 4,
"points": 400,
"song": "/Data/HCO/Memes/songs/4.mp3",
"answer": "/Data/HCO/Memes/answers/4.mp3"
},
{
"number": 5,
"points": 500,
"song": "/Data/HCO/Memes/songs/5.mp3",
"answer": "/Data/HCO/Memes/answers/5.mp3"
}
]
},
{
"name": "Rock",
"clues": [
{
"number": 1,
"points": 100,
"song": "/Data/HCO/Rock/songs/1.mp3",
"answer": "/Data/HCO/Rock/answers/1.mp3"
},
{
"number": 2,
"points": 200,
"song": "/Data/HCO/Rock/songs/2.mp3",
"answer": "/Data/HCO/Rock/answers/2.mp3"
},
{
"number": 3,
"points": 300,
"song": "/Data/HCO/Rock/songs/3.mp3",
"answer": "/Data/HCO/Rock/answers/3.mp3"
},
{
"number": 4,
"points": 400,
"song": "/Data/HCO/Rock/songs/4.mp3",
"answer": "/Data/HCO/Rock/answers/4.mp3"
},
{
"number": 5,
"points": 500,
"song": "/Data/HCO/Rock/songs/5.mp3",
"answer": "/Data/HCO/Rock/answers/5.mp3"
}
]
}
]
},
{
"name": "Misc",
"categories": [
{
"name": "Bardcore",
"clues": [
{
"number": 1,
"points": 100,
"song": "/Data/Misc/Bardcore/songs/1.mp3",
"answer": "/Data/Misc/Bardcore/answers/1.mp3"
},
{
"number": 2,
"points": 200,
"song": "/Data/Misc/Bardcore/songs/2.mp3",
"answer": "/Data/Misc/Bardcore/answers/2.mp3"
},
{
"number": 3,
"points": 300,
"song": "/Data/Misc/Bardcore/songs/3.mp3",
"answer": "/Data/Misc/Bardcore/answers/3.mp3"
},
{
"number": 4,
"points": 400,
"song": "/Data/Misc/Bardcore/songs/4.mp3",
"answer": "/Data/Misc/Bardcore/answers/4.mp3"
},
{
"number": 5,
"points": 500,
"song": "/Data/Misc/Bardcore/songs/5.mp3",
"answer": "/Data/Misc/Bardcore/answers/5.mp3"
}
]
},
{
"name": "Disney",
"clues": [
{
"number": 1,
"points": 100,
"song": "/Data/Misc/Disney/songs/1.mp3",
"answer": "/Data/Misc/Disney/answers/1.mp3"
},
{
"number": 2,
"points": 200,
"song": "/Data/Misc/Disney/songs/2.mp3",
"answer": "/Data/Misc/Disney/answers/2.mp3"
},
{
"number": 3,
"points": 300,
"song": "/Data/Misc/Disney/songs/3.mp3",
"answer": "/Data/Misc/Disney/answers/3.mp3"
},
{
"number": 4,
"points": 400,
"song": "/Data/Misc/Disney/songs/4.mp3",
"answer": "/Data/Misc/Disney/answers/4.mp3"
},
{
"number": 5,
"points": 500,
"song": "/Data/Misc/Disney/songs/5.mp3",
"answer": "/Data/Misc/Disney/answers/5.mp3"
}
]
},
{
"name": "One Hit Wonders",
"clues": [
{
"number": 1,
"points": 100,
"song": "/Data/Misc/One Hit Wonders/songs/1.mp3",
"answer": "/Data/Misc/One Hit Wonders/answers/1.mp3"
},
{
"number": 2,
"points": 200,
"song": "/Data/Misc/One Hit Wonders/songs/2.mp3",
"answer": "/Data/Misc/One Hit Wonders/answers/2.mp3"
},
{
"number": 3,
"points": 300,
"song": "/Data/Misc/One Hit Wonders/songs/3.mp3",
"answer": "/Data/Misc/One Hit Wonders/answers/3.mp3"
},
{
"number": 4,
"points": 400,
"song": "/Data/Misc/One Hit Wonders/songs/4.mp3",
"answer": "/Data/Misc/One Hit Wonders/answers/4.mp3"
},
{
"number": 5,
"points": 500,
"song": "/Data/Misc/One Hit Wonders/songs/5.mp3",
"answer": "/Data/Misc/One Hit Wonders/answers/5.mp3"
}
]
},
{
"name": "Tal i Titlen",
"clues": [
{
"number": 1,
"points": 100,
"song": "/Data/Misc/Tal i Titlen/songs/1.mp3",
"answer": "/Data/Misc/Tal i Titlen/answers/1.mp3"
},
{
"number": 2,
"points": 200,
"song": "/Data/Misc/Tal i Titlen/songs/2.mp3",
"answer": "/Data/Misc/Tal i Titlen/answers/2.mp3"
},
{
"number": 3,
"points": 300,
"song": "/Data/Misc/Tal i Titlen/songs/3.mp3",
"answer": "/Data/Misc/Tal i Titlen/answers/3.mp3"
},
{
"number": 4,
"points": 400,
"song": "/Data/Misc/Tal i Titlen/songs/4.mp3",
"answer": "/Data/Misc/Tal i Titlen/answers/4.mp3"
},
{
"number": 5,
"points": 500,
"song": "/Data/Misc/Tal i Titlen/songs/5.mp3",
"answer": "/Data/Misc/Tal i Titlen/answers/5.mp3"
}
]
}
]
},
{
"name": "Years",
"categories": [
{
"name": "2010's",
"clues": [
{
"number": 1,
"points": 100,
"song": "/Data/Years/2010's/songs/1.mp3",
"answer": "/Data/Years/2010's/answers/1.mp3"
},
{
"number": 2,
"points": 200,
"song": "/Data/Years/2010's/songs/2.mp3",
"answer": "/Data/Years/2010's/answers/2.mp3"
},
{
"number": 3,
"points": 300,
"song": "/Data/Years/2010's/songs/3.mp3",
"answer": "/Data/Years/2010's/answers/3.mp3"
},
{
"number": 4,
"points": 400,
"song": "/Data/Years/2010's/songs/4.mp3",
"answer": "/Data/Years/2010's/answers/4.mp3"
},
{
"number": 5,
"points": 500,
"song": "/Data/Years/2010's/songs/5.mp3",
"answer": "/Data/Years/2010's/answers/5.mp3"
}
]
},
{
"name": "2020",
"clues": [
{
"number": 1,
"points": 100,
"song": "/Data/Years/2020/songs/1.mp3",
"answer": "/Data/Years/2020/answers/1.mp3"
},
{
"number": 2,
"points": 200,
"song": "/Data/Years/2020/songs/2.mp3",
"answer": "/Data/Years/2020/answers/2.mp3"
},
{
"number": 3,
"points": 300,
"song": "/Data/Years/2020/songs/3.mp3",
"answer": "/Data/Years/2020/answers/3.mp3"
},
{
"number": 4,
"points": 400,
"song": "/Data/Years/2020/songs/4.mp3",
"answer": "/Data/Years/2020/answers/4.mp3"
},
{
"number": 5,
"points": 500,
"song": "/Data/Years/2020/songs/5.mp3",
"answer": "/Data/Years/2020/answers/5.mp3"
}
]
},
{
"name": "2024",
"clues": [
{
"number": 1,
"points": 100,
"song": "/Data/Years/2024/songs/1.mp3",
"answer": "/Data/Years/2024/answers/1.mp3"
},
{
"number": 2,
"points": 200,
"song": "/Data/Years/2024/songs/2.mp3",
"answer": "/Data/Years/2024/answers/2.mp3"
},
{
"number": 3,
"points": 300,
"song": "/Data/Years/2024/songs/3.mp3",
"answer": "/Data/Years/2024/answers/3.mp3"
},
{
"number": 4,
"points": 400,
"song": "/Data/Years/2024/songs/4.mp3",
"answer": "/Data/Years/2024/answers/4.mp3"
},
{
"number": 5,
"points": 500,
"song": "/Data/Years/2024/songs/5.mp3",
"answer": "/Data/Years/2024/answers/5.mp3"
}
]
},
{
"name": "Roskilde Gennem Årene",
"clues": [
{
"number": 1,
"points": 100,
"song": "/Data/Years/Roskilde Gennem Årene/songs/1.mp3",
"answer": "/Data/Years/Roskilde Gennem Årene/answers/1.mp3"
},
{
"number": 2,
"points": 200,
"song": "/Data/Years/Roskilde Gennem Årene/songs/2.mp3",
"answer": "/Data/Years/Roskilde Gennem Årene/answers/2.mp3"
},
{
"number": 3,
"points": 300,
"song": "/Data/Years/Roskilde Gennem Årene/songs/3.mp3",
"answer": "/Data/Years/Roskilde Gennem Årene/answers/3.mp3"
},
{
"number": 4,
"points": 400,
"song": "/Data/Years/Roskilde Gennem Årene/songs/4.mp3",
"answer": "/Data/Years/Roskilde Gennem Årene/answers/4.mp3"
},
{
"number": 5,
"points": 500,
"song": "/Data/Years/Roskilde Gennem Årene/songs/5.mp3",
"answer": "/Data/Years/Roskilde Gennem Årene/answers/5.mp3"
}
]
}
]
}
]

33
server/README.md Normal file
View File

@@ -0,0 +1,33 @@
# Realtime Sessions
This app now supports shared sessions by `gameId` using a lightweight WebSocket server.
## Run
```bash
npm run realtime:server
```
Default port: `8787`
Override: `REALTIME_PORT=9000 npm run realtime:server`
## Nginx Proxy
Proxy `/realtime/*` from your public domain to the realtime server:
```nginx
location /realtime/ {
proxy_pass http://127.0.0.1:8787/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
Notes:
- `proxy_pass` with trailing `/` is important, because frontend calls `/realtime/sessions` and `/realtime/ws`.
- Sessions are kept in memory; restarting the realtime server resets all active game sessions.

205
server/realtime-server.ts Normal file
View File

@@ -0,0 +1,205 @@
import http from 'http'
import crypto from 'crypto'
import { URL } from 'url'
import { WebSocketServer, type WebSocket } from 'ws'
type SessionState = Record<string, unknown>
type Session = {
id: string
createdAt: number
updatedAt: number
state: SessionState | null
}
type ClientInfo = {
gameId: string
socket: WebSocket
}
const port = Number(process.env.REALTIME_PORT || 8787)
const sessionTtlMs = 1000 * 60 * 60 * 6
const sessions = new Map<string, Session>()
const clients = new Map<string, ClientInfo>()
const json = (res: http.ServerResponse, statusCode: number, body: unknown) => {
res.writeHead(statusCode, {
'content-type': 'application/json',
'cache-control': 'no-store'
})
res.end(JSON.stringify(body))
}
const createGameId = () => crypto.randomBytes(4).toString('hex').toUpperCase()
const normalizeGameId = (value: string) => value.toUpperCase().replace(/[^A-Z0-9]/g, '')
const ensureSession = (gameId: string) => {
const now = Date.now()
const existing = sessions.get(gameId)
if (existing) return existing
const session: Session = {
id: gameId,
createdAt: now,
updatedAt: now,
state: null
}
sessions.set(gameId, session)
return session
}
const broadcast = (gameId: string, payload: unknown, exceptSocket?: WebSocket) => {
const message = JSON.stringify(payload)
clients.forEach((client) => {
if (client.gameId !== gameId) return
if (client.socket.readyState !== client.socket.OPEN) return
if (exceptSocket && client.socket === exceptSocket) return
client.socket.send(message)
})
}
const server = http.createServer((req, res) => {
if (!req.url) {
json(res, 400, { error: 'Missing URL' })
return
}
const requestUrl = new URL(req.url, `http://localhost:${port}`)
const pathname = requestUrl.pathname
if (req.method === 'GET' && pathname === '/health') {
json(res, 200, { ok: true })
return
}
if (req.method === 'POST' && pathname === '/sessions') {
let body = ''
req.on('data', (chunk) => {
body += chunk.toString()
})
req.on('end', () => {
let requestedId = ''
if (body.trim()) {
try {
const parsed = JSON.parse(body) as { gameId?: string }
if (typeof parsed.gameId === 'string') {
requestedId = normalizeGameId(parsed.gameId)
}
} catch {
json(res, 400, { error: 'Invalid JSON payload' })
return
}
}
let gameId = requestedId
if (!gameId) {
gameId = createGameId()
while (sessions.has(gameId)) gameId = createGameId()
} else if (sessions.has(gameId)) {
json(res, 409, { error: 'Game ID already exists' })
return
}
const session = ensureSession(gameId)
json(res, 201, { gameId: session.id })
})
return
}
if (req.method === 'GET' && pathname.startsWith('/sessions/')) {
const gameId = pathname.split('/').filter(Boolean)[1]
if (!gameId) {
json(res, 400, { error: 'Missing gameId' })
return
}
const session = sessions.get(gameId.toUpperCase())
if (!session) {
json(res, 404, { error: 'Session not found' })
return
}
json(res, 200, { gameId: session.id, hasState: !!session.state })
return
}
json(res, 404, { error: 'Not found' })
})
const wss = new WebSocketServer({ noServer: true })
server.on('upgrade', (req, socket, head) => {
if (!req.url) {
socket.destroy()
return
}
const requestUrl = new URL(req.url, `http://localhost:${port}`)
if (requestUrl.pathname !== '/ws') {
socket.destroy()
return
}
const gameId = requestUrl.searchParams.get('gameId')?.toUpperCase()
const clientId = requestUrl.searchParams.get('clientId') || crypto.randomUUID()
if (!gameId) {
socket.destroy()
return
}
const session = ensureSession(gameId)
session.updatedAt = Date.now()
wss.handleUpgrade(req, socket, head, (ws) => {
clients.set(clientId, { gameId, socket: ws })
ws.send(
JSON.stringify({
type: 'session:init',
gameId,
state: session.state
})
)
ws.on('message', (buffer) => {
let parsed: { type?: string; state?: SessionState } | null = null
try {
parsed = JSON.parse(buffer.toString()) as { type?: string; state?: SessionState }
} catch {
return
}
if (!parsed || parsed.type !== 'state:update' || !parsed.state) return
const targetSession = ensureSession(gameId)
targetSession.state = parsed.state
targetSession.updatedAt = Date.now()
broadcast(
gameId,
{
type: 'state:update',
gameId,
state: targetSession.state
},
ws
)
})
ws.on('close', () => {
clients.delete(clientId)
})
})
})
setInterval(() => {
const threshold = Date.now() - sessionTtlMs
sessions.forEach((session, id) => {
if (session.updatedAt >= threshold) return
const hasClient = Array.from(clients.values()).some((client) => client.gameId === id)
if (!hasClient) sessions.delete(id)
})
}, 1000 * 60)
server.listen(port, () => {
// eslint-disable-next-line no-console
console.log(`Realtime server listening on :${port}`)
})

View File

@@ -7,7 +7,11 @@
</div> </div>
<div v-if="step === 'game'" class="selector-pill"> <div v-if="step === 'game'" class="selector-pill">
<span class="label">Selecting</span> <span class="label">Selecting</span>
<span class="value">{{ currentSelector?.name || '' }}</span> <span class="value">{{ currentSelector?.name || '-' }}</span>
</div>
<div v-if="gameId" class="selector-pill session-pill">
<span class="label">Game ID</span>
<span class="value">{{ gameId }}</span>
</div> </div>
</header> </header>
@@ -15,6 +19,37 @@
<section v-if="step === 'setup'" class="panel"> <section v-if="step === 'setup'" class="panel">
<h2>Team Setup</h2> <h2>Team Setup</h2>
<p class="hint">Add teams, name them, and create their color gradients.</p> <p class="hint">Add teams, name them, and create their color gradients.</p>
<div class="session-box">
<h3>Live Session</h3>
<p class="hint">
Create a game ID as host, or join an existing game ID as viewer.
</p>
<div class="session-row">
<input
v-model.trim="gameIdInput"
class="input"
placeholder="Game ID"
:disabled="sessionBusy || !!gameId"
/>
<button class="ghost" @click="createSession()" :disabled="sessionBusy || !!gameId || !normalizedGameIdInput">
Create
</button>
<button
class="ghost"
@click="joinSession"
:disabled="sessionBusy || !!gameId || !normalizedGameIdInput"
>
Join
</button>
<button class="ghost" v-if="gameId" @click="leaveSession" :disabled="sessionBusy">
Leave
</button>
</div>
<p class="session-meta">
<span>Status: {{ connectionLabel }}</span>
<span v-if="syncError">Error: {{ syncError }}</span>
</p>
</div>
<div class="teams-grid"> <div class="teams-grid">
<div v-for="team in teams" :key="team.id" class="team-card"> <div v-for="team in teams" :key="team.id" class="team-card">
@@ -23,28 +58,29 @@
v-model="team.name" v-model="team.name"
class="input" class="input"
placeholder="Team name" placeholder="Team name"
:disabled="!canControlGame"
/> />
<button class="ghost" @click="removeTeam(team.id)">Remove</button> <button class="ghost" @click="removeTeam(team.id)" :disabled="!canControlGame">Remove</button>
</div> </div>
<div class="gradient-preview" :style="teamGradient(team)"></div> <div class="gradient-preview" :style="teamGradient(team)"></div>
<div class="color-row"> <div class="color-row">
<label> <label>
Color A Color A
<input v-model="team.colorA" type="color" /> <input v-model="team.colorA" type="color" :disabled="!canControlGame" />
</label> </label>
<label> <label>
Color B Color B
<input v-model="team.colorB" type="color" /> <input v-model="team.colorB" type="color" :disabled="!canControlGame" />
</label> </label>
</div> </div>
</div> </div>
<button class="add-card" @click="addTeam"> <button class="add-card" @click="addTeam" :disabled="!canControlGame">
<span>+ Add Team</span> <span>+ Add Team</span>
</button> </button>
</div> </div>
<div class="actions"> <div class="actions">
<button class="primary" :disabled="!canProceed" @click="goToSelect"> <button class="primary" :disabled="!canProceedToSelect" @click="goToSelect">
Next: Choose Game Next: Choose Game
</button> </button>
</div> </div>
@@ -53,12 +89,12 @@
<section v-if="step === 'select'" class="panel"> <section v-if="step === 'select'" class="panel">
<h2>Select a Game</h2> <h2>Select a Game</h2>
<p class="hint"> <p class="hint">
Games are loaded from <code>src/Data</code>. Each subfolder becomes a Games are loaded from <code>public/Data</code>. Each subfolder becomes a
playable game. playable game.
</p> </p>
<div v-if="loadingGames" class="empty-state"> <div v-if="loadingGames" class="empty-state">
<p>Loading games</p> <p>Loading games...</p>
</div> </div>
<div v-else-if="loadError" class="empty-state"> <div v-else-if="loadError" class="empty-state">
@@ -81,6 +117,7 @@
:key="game.name" :key="game.name"
class="game-card" class="game-card"
@click="startGame(game)" @click="startGame(game)"
:disabled="!canControlGame"
> >
<h3>{{ game.name }}</h3> <h3>{{ game.name }}</h3>
<p>{{ game.categories.length }} categories</p> <p>{{ game.categories.length }} categories</p>
@@ -88,7 +125,7 @@
</div> </div>
<div class="actions"> <div class="actions">
<button class="ghost" @click="step = 'setup'">Back</button> <button class="ghost" @click="step = 'setup'" :disabled="!canControlGame">Back</button>
</div> </div>
</section> </section>
@@ -103,7 +140,7 @@
:class="{ active: team.id === currentSelectorId }" :class="{ active: team.id === currentSelectorId }"
:style="teamGradient(team)" :style="teamGradient(team)"
@click="awardPoints(team.id)" @click="awardPoints(team.id)"
:disabled="!canAward" :disabled="!canAward || !canControlGame"
> >
<div class="team-name">{{ team.name }}</div> <div class="team-name">{{ team.name }}</div>
<div class="team-score">{{ team.score }}</div> <div class="team-score">{{ team.score }}</div>
@@ -113,7 +150,7 @@
</button> </button>
</div> </div>
<div class="controls"> <div class="controls">
<button class="ghost" @click="resetGame">Reset Game</button> <button class="ghost" @click="resetGame" :disabled="!canControlGame">Reset Game</button>
</div> </div>
</aside> </aside>
@@ -131,7 +168,7 @@
class="tile" class="tile"
:class="tileClass(cIndex, qIndex)" :class="tileClass(cIndex, qIndex)"
:style="tileStyle(cIndex, qIndex)" :style="tileStyle(cIndex, qIndex)"
:disabled="tileDisabled(cIndex, qIndex)" :disabled="tileDisabled(cIndex, qIndex) || !canControlGame"
@click="handleTileClick(cIndex, qIndex)" @click="handleTileClick(cIndex, qIndex)"
@contextmenu.prevent="handleTileRightClick(cIndex, qIndex)" @contextmenu.prevent="handleTileRightClick(cIndex, qIndex)"
> >
@@ -252,6 +289,8 @@
import { nextTick } from 'vue' import { nextTick } from 'vue'
import { loadGameData, type Game } from './dataLoader' import { loadGameData, type Game } from './dataLoader'
type Step = 'setup' | 'select' | 'game' | 'end'
type Team = { type Team = {
id: string id: string
name: string name: string
@@ -267,6 +306,25 @@ type TileEntry = {
type TileMap = Record<string, TileEntry> type TileMap = Record<string, TileEntry>
type RealtimeState = {
step: Step
teams: Team[]
selectedGameName: string | null
tiles: TileMap
currentTileKey: string | null
currentClipUrl: string
currentSelectorId: string | null
lastAwardedTeamId: string | null
isAnswerClip: boolean
}
type RealtimeMessage = {
type: 'session:init' | 'state:update'
state: RealtimeState | null
}
const REALTIME_BASE = '/realtime'
const makeTeam = (index: number): Team => ({ const makeTeam = (index: number): Team => ({
id: `team-${Date.now()}-${index}`, id: `team-${Date.now()}-${index}`,
name: `Team ${index + 1}`, name: `Team ${index + 1}`,
@@ -275,20 +333,27 @@ const makeTeam = (index: number): Team => ({
score: 0 score: 0
}) })
const makeClientId = () => {
const segment = Math.random().toString(36).slice(2, 10)
return `client-${Date.now()}-${segment}`
}
const cloneTiles = (tiles: TileMap): TileMap => JSON.parse(JSON.stringify(tiles)) as TileMap
export default { export default {
data() { data() {
return { return {
step: 'setup', step: 'setup' as Step,
teams: [makeTeam(0), makeTeam(1)] as Team[], teams: [makeTeam(0), makeTeam(1)] as Team[],
games: [] as Game[], games: [] as Game[],
loadingGames: true, loadingGames: true,
loadError: '', loadError: '',
selectedGame: null as Game | null, selectedGame: null as Game | null,
tiles: {} as TileMap, tiles: {} as TileMap,
currentTileKey: null, currentTileKey: null as string | null,
currentClipUrl: '', currentClipUrl: '',
currentSelectorId: null, currentSelectorId: null as string | null,
lastAwardedTeamId: null, lastAwardedTeamId: null as string | null,
isPlaying: false, isPlaying: false,
pulseLevel: 0, pulseLevel: 0,
audioContext: null as AudioContext | null, audioContext: null as AudioContext | null,
@@ -301,13 +366,33 @@ export default {
rayLevel: 0, rayLevel: 0,
rayHue: 200, rayHue: 200,
isAnswerClip: false, isAnswerClip: false,
tentacleLevels: Array.from({ length: 12 }, () => 0) tentacleLevels: Array.from({ length: 12 }, () => 0),
gameId: '',
gameIdInput: '',
sessionBusy: false,
socket: null as WebSocket | null,
socketConnected: false,
isHost: false,
clientId: makeClientId(),
syncError: '',
isApplyingRemote: false,
queuedRemoteState: null as RealtimeState | null,
syncTimer: 0
} }
}, },
async mounted() { async mounted() {
try { try {
this.loadingGames = true this.loadingGames = true
this.games = await loadGameData() this.games = await loadGameData()
if (this.queuedRemoteState) {
await this.applyRemoteState(this.queuedRemoteState)
this.queuedRemoteState = null
}
const fromUrl = new URL(window.location.href).searchParams.get('game')
if (fromUrl) {
this.gameIdInput = fromUrl.toUpperCase()
await this.joinSession()
}
} catch (error) { } catch (error) {
this.loadError = error instanceof Error ? error.message : 'Failed to load games.' this.loadError = error instanceof Error ? error.message : 'Failed to load games.'
} finally { } finally {
@@ -315,9 +400,18 @@ export default {
} }
}, },
computed: { computed: {
normalizedGameIdInput() {
return this.normalizeGameId(this.gameIdInput)
},
canProceed() { canProceed() {
return this.teams.length > 0 && this.teams.every((team) => team.name.trim()) return this.teams.length > 0 && this.teams.every((team) => team.name.trim())
}, },
canProceedToSelect() {
return this.canProceed && this.canControlGame && !!this.normalizedGameIdInput && !this.sessionBusy
},
canControlGame() {
return !this.gameId || this.isHost
},
currentSelector() { currentSelector() {
return this.teams.find((team) => team.id === this.currentSelectorId) || null return this.teams.find((team) => team.id === this.currentSelectorId) || null
}, },
@@ -333,6 +427,12 @@ export default {
.filter((team) => this.winnerIds.includes(team.id)) .filter((team) => this.winnerIds.includes(team.id))
.map((team) => team.name) .map((team) => team.name)
.join(', ') .join(', ')
},
connectionLabel() {
if (!this.gameId) return 'Local mode'
if (this.sessionBusy) return 'Connecting'
if (!this.socketConnected) return this.isHost ? 'Host disconnected' : 'Viewer disconnected'
return this.isHost ? 'Connected as host' : 'Connected as viewer'
} }
}, },
watch: { watch: {
@@ -345,6 +445,190 @@ export default {
} }
}, },
methods: { methods: {
normalizeGameId(value: string) {
return value.toUpperCase().replace(/[^A-Z0-9]/g, '')
},
setGameInUrl(gameId: string) {
const url = new URL(window.location.href)
if (gameId) {
url.searchParams.set('game', gameId)
} else {
url.searchParams.delete('game')
}
window.history.replaceState({}, '', url.toString())
},
closeSocket() {
if (this.socket) {
this.socket.close()
this.socket = null
}
this.socketConnected = false
},
async createSession(preferredId?: string) {
if (this.sessionBusy || this.gameId) return
const requestedId = this.normalizeGameId(preferredId || this.gameIdInput)
if (!requestedId) {
this.syncError = 'Game ID is required'
return
}
this.sessionBusy = true
this.syncError = ''
try {
const response = await fetch(`${REALTIME_BASE}/sessions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ gameId: requestedId })
})
if (!response.ok) {
let errorMessage = 'Failed to create session'
try {
const payload = (await response.json()) as { error?: string }
if (payload?.error) errorMessage = payload.error
} catch {
// ignore parse errors and keep default message
}
throw new Error(errorMessage)
}
const payload = (await response.json()) as { gameId: string }
const gameId = this.normalizeGameId(payload.gameId)
this.gameId = gameId
this.gameIdInput = gameId
this.isHost = true
this.setGameInUrl(gameId)
await this.connectSession()
} catch (error) {
this.syncError = error instanceof Error ? error.message : 'Failed to create session'
} finally {
this.sessionBusy = false
}
},
async joinSession() {
if (this.sessionBusy || this.gameId) return
const normalized = this.normalizeGameId(this.gameIdInput)
if (!normalized) return
this.sessionBusy = true
this.syncError = ''
try {
const existsResponse = await fetch(`${REALTIME_BASE}/sessions/${normalized}`)
if (!existsResponse.ok) throw new Error('Session not found')
this.gameId = normalized
this.gameIdInput = normalized
this.isHost = false
this.setGameInUrl(normalized)
await this.connectSession()
} catch (error) {
this.syncError = error instanceof Error ? error.message : 'Failed to join session'
} finally {
this.sessionBusy = false
}
},
leaveSession() {
this.closeSocket()
this.gameId = ''
this.gameIdInput = ''
this.isHost = false
this.syncError = ''
this.setGameInUrl('')
},
async connectSession() {
this.closeSocket()
if (!this.gameId) return
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${protocol}//${window.location.host}${REALTIME_BASE}/ws?gameId=${encodeURIComponent(
this.gameId
)}&clientId=${encodeURIComponent(this.clientId)}`
const socket = new WebSocket(wsUrl)
this.socket = socket
await new Promise<void>((resolve, reject) => {
socket.onopen = () => {
if (this.socket !== socket) return
this.socketConnected = true
this.syncError = ''
if (this.isHost) this.publishState()
resolve()
}
socket.onerror = () => {
if (this.socket !== socket) return
this.socketConnected = false
this.syncError = 'Realtime connection failed'
reject(new Error('Realtime connection failed'))
}
}).catch(() => {})
socket.onmessage = async (event) => {
let message: RealtimeMessage | null = null
try {
message = JSON.parse(event.data as string) as RealtimeMessage
} catch {
return
}
if (!message) return
if (message.type === 'session:init' || message.type === 'state:update') {
if (message.state) await this.applyRemoteState(message.state)
}
}
socket.onclose = () => {
if (this.socket === socket) {
this.socketConnected = false
}
}
},
buildRealtimeState(): RealtimeState {
return {
step: this.step,
teams: this.teams.map((team) => ({ ...team })),
selectedGameName: this.selectedGame?.name || null,
tiles: cloneTiles(this.tiles),
currentTileKey: this.currentTileKey,
currentClipUrl: this.currentClipUrl,
currentSelectorId: this.currentSelectorId,
lastAwardedTeamId: this.lastAwardedTeamId,
isAnswerClip: this.isAnswerClip
}
},
publishState() {
if (!this.isHost || !this.socketConnected || !this.socket) return
if (this.isApplyingRemote) return
const payload = {
type: 'state:update',
state: this.buildRealtimeState()
}
this.socket.send(JSON.stringify(payload))
},
queueStateSync() {
if (!this.isHost || !this.socketConnected || !this.socket) return
if (this.isApplyingRemote) return
if (this.syncTimer) window.clearTimeout(this.syncTimer)
this.syncTimer = window.setTimeout(() => {
this.publishState()
}, 80)
},
async applyRemoteState(state: RealtimeState) {
if (this.isHost) return
if (this.loadingGames) {
this.queuedRemoteState = state
return
}
this.isApplyingRemote = true
try {
const selected = state.selectedGameName
? this.games.find((game) => game.name === state.selectedGameName) || null
: null
this.step = state.step
this.teams = state.teams.map((team) => ({ ...team }))
this.selectedGame = selected
this.tiles = cloneTiles(state.tiles)
this.currentTileKey = state.currentTileKey
this.currentClipUrl = state.currentClipUrl
this.currentSelectorId = state.currentSelectorId
this.lastAwardedTeamId = state.lastAwardedTeamId
this.isAnswerClip = state.isAnswerClip
} finally {
this.isApplyingRemote = false
}
},
getPlayer() { getPlayer() {
return this.$refs.player as HTMLAudioElement | undefined return this.$refs.player as HTMLAudioElement | undefined
}, },
@@ -496,16 +780,32 @@ export default {
} }
}, },
addTeam() { addTeam() {
if (!this.canControlGame) return
this.teams.push(makeTeam(this.teams.length)) this.teams.push(makeTeam(this.teams.length))
this.queueStateSync()
}, },
removeTeam(id: string) { removeTeam(id: string) {
if (!this.canControlGame) return
if (this.teams.length === 1) return if (this.teams.length === 1) return
this.teams = this.teams.filter((team) => team.id !== id) this.teams = this.teams.filter((team) => team.id !== id)
this.queueStateSync()
}, },
goToSelect() { async goToSelect() {
if (!this.canControlGame) return
const requestedId = this.normalizeGameId(this.gameIdInput)
if (!requestedId) {
this.syncError = 'Game ID is required'
return
}
if (!this.gameId) {
await this.createSession(requestedId)
if (!this.gameId) return
}
this.step = 'select' this.step = 'select'
this.queueStateSync()
}, },
startGame(game: Game) { startGame(game: Game) {
if (!this.canControlGame) return
this.selectedGame = game this.selectedGame = game
this.tiles = {} this.tiles = {}
this.currentTileKey = null this.currentTileKey = null
@@ -515,8 +815,10 @@ export default {
const randomTeam = this.teams[Math.floor(Math.random() * this.teams.length)] const randomTeam = this.teams[Math.floor(Math.random() * this.teams.length)]
this.currentSelectorId = randomTeam?.id || null this.currentSelectorId = randomTeam?.id || null
this.step = 'game' this.step = 'game'
this.queueStateSync()
}, },
resetGame() { resetGame() {
if (!this.canControlGame) return
this.step = 'select' this.step = 'select'
this.selectedGame = null this.selectedGame = null
this.tiles = {} this.tiles = {}
@@ -525,6 +827,7 @@ export default {
this.lastAwardedTeamId = null this.lastAwardedTeamId = null
this.isAnswerClip = false this.isAnswerClip = false
this.teardownAudio() this.teardownAudio()
this.queueStateSync()
}, },
tileKey(cIndex: number, qIndex: number) { tileKey(cIndex: number, qIndex: number) {
return `${cIndex}-${qIndex}` return `${cIndex}-${qIndex}`
@@ -558,6 +861,7 @@ export default {
return this.teamGradient(team) return this.teamGradient(team)
}, },
async handleTileClick(cIndex: number, qIndex: number) { async handleTileClick(cIndex: number, qIndex: number) {
if (!this.canControlGame) return
const key = this.tileKey(cIndex, qIndex) const key = this.tileKey(cIndex, qIndex)
const status = this.tileStatus(cIndex, qIndex) const status = this.tileStatus(cIndex, qIndex)
const clue = this.selectedGame?.categories[cIndex].clues[qIndex] const clue = this.selectedGame?.categories[cIndex].clues[qIndex]
@@ -577,6 +881,7 @@ export default {
player.load() player.load()
player.play().catch(() => {}) player.play().catch(() => {})
} }
this.queueStateSync()
return return
} }
@@ -584,6 +889,7 @@ export default {
this.tiles[key].status = 'paused' this.tiles[key].status = 'paused'
const player = this.getPlayer() const player = this.getPlayer()
player?.pause() player?.pause()
this.queueStateSync()
return return
} }
@@ -602,6 +908,7 @@ export default {
player.play().catch(() => {}) player.play().catch(() => {})
} }
} }
this.queueStateSync()
return return
} }
@@ -614,9 +921,11 @@ export default {
this.currentClipUrl = '' this.currentClipUrl = ''
this.lastAwardedTeamId = null this.lastAwardedTeamId = null
this.checkEnd() this.checkEnd()
this.queueStateSync()
} }
}, },
handleTileRightClick(cIndex: number, qIndex: number) { handleTileRightClick(cIndex: number, qIndex: number) {
if (!this.canControlGame) return
const key = this.tileKey(cIndex, qIndex) const key = this.tileKey(cIndex, qIndex)
const status = this.tileStatus(cIndex, qIndex) const status = this.tileStatus(cIndex, qIndex)
if (key !== this.currentTileKey) return if (key !== this.currentTileKey) return
@@ -625,6 +934,7 @@ export default {
this.tiles[key].status = 'playing' this.tiles[key].status = 'playing'
const player = this.getPlayer() const player = this.getPlayer()
player?.play().catch(() => {}) player?.play().catch(() => {})
this.queueStateSync()
return return
} }
@@ -637,9 +947,11 @@ export default {
player?.pause() player?.pause()
this.teardownAudio() this.teardownAudio()
this.checkEnd() this.checkEnd()
this.queueStateSync()
} }
}, },
awardPoints(teamId: string) { awardPoints(teamId: string) {
if (!this.canControlGame) return
if (!this.canAward) return if (!this.canAward) return
const key = this.currentTileKey const key = this.currentTileKey
if (!key || !this.selectedGame) return if (!key || !this.selectedGame) return
@@ -652,6 +964,7 @@ export default {
if (this.tiles[key]) { if (this.tiles[key]) {
this.tiles[key].lastTeamId = teamId this.tiles[key].lastTeamId = teamId
} }
this.queueStateSync()
}, },
checkEnd() { checkEnd() {
if (!this.selectedGame) return if (!this.selectedGame) return
@@ -669,6 +982,9 @@ export default {
}, },
unmounted() { unmounted() {
this.teardownAudio() this.teardownAudio()
this.closeSocket()
if (this.syncTimer) window.clearTimeout(this.syncTimer)
} }
} }
</script> </script>

View File

@@ -72,6 +72,10 @@ code {
font-weight: 600; font-weight: 600;
} }
.session-pill {
border-color: rgba(61, 214, 255, 0.5);
}
.app-main { .app-main {
flex: 1; flex: 1;
} }
@@ -96,6 +100,38 @@ code {
color: rgba(255, 255, 255, 0.7); color: rgba(255, 255, 255, 0.7);
} }
.session-box {
margin-top: 18px;
margin-bottom: 20px;
padding: 14px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.04);
}
.session-box h3 {
margin: 0 0 6px;
}
.session-row {
display: flex;
gap: 10px;
align-items: center;
}
.session-row .input {
max-width: 220px;
}
.session-meta {
margin: 12px 0 0;
display: flex;
gap: 14px;
flex-wrap: wrap;
color: rgba(255, 255, 255, 0.75);
font-size: 0.9rem;
}
.teams-grid, .teams-grid,
.games-grid { .games-grid {
display: grid; display: grid;
@@ -171,6 +207,11 @@ input[type='color'] {
background: linear-gradient(135deg, rgba(61, 214, 255, 0.12), rgba(251, 215, 43, 0.12)); background: linear-gradient(135deg, rgba(61, 214, 255, 0.12), rgba(251, 215, 43, 0.12));
} }
.add-card:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.game-card { .game-card {
cursor: pointer; cursor: pointer;
transition: transform 0.2s ease; transition: transform 0.2s ease;
@@ -534,4 +575,8 @@ audio.hidden-audio {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
} }
.session-row {
flex-wrap: wrap;
}
} }