Initial commit
This commit is contained in:
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Local config/state
|
||||||
|
server/config.toml
|
||||||
|
server/runtime/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
server/.venv/
|
||||||
|
**/__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Node/Vite
|
||||||
|
client/node_modules/
|
||||||
|
client/dist/
|
||||||
|
|
||||||
|
# OS/editor/log noise
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
*.bak
|
||||||
36
AGENTS.md
Normal file
36
AGENTS.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
- `client/`: Vite + TypeScript web app.
|
||||||
|
- `src/main.ts`: connect flow, key commands, status/audio cues.
|
||||||
|
- `src/audio`, `src/network`, `src/state`, `src/render`, `src/webrtc`, `src/input`: feature modules.
|
||||||
|
- `public/version.js`: single source of truth for web version.
|
||||||
|
- `public/sounds/`: all client sound assets.
|
||||||
|
- `server/`: Python signaling service.
|
||||||
|
- `app/server.py`: websocket lifecycle + packet routing.
|
||||||
|
- `app/client.py`: client connection model.
|
||||||
|
- `app/item_service.py`: item persistence + hydration.
|
||||||
|
- `app/item_catalog.py`: global item-type properties.
|
||||||
|
- `app/models.py`: packet/data schemas.
|
||||||
|
- `deploy/`: Apache snippet + systemd unit examples.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
- Client dev: `cd client && npm install && npm run dev -- --host 0.0.0.0 --port 5173`
|
||||||
|
- Client build: `cd client && npm run build`
|
||||||
|
- Server run: `cd server && cp config.example.toml config.toml && uv run python main.py --config config.toml`
|
||||||
|
- Server tests: `cd server && uv run --extra dev pytest`
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
- TypeScript: strict typing, `camelCase`, small focused modules.
|
||||||
|
- Python: PEP 8, 4 spaces, `snake_case`, typed Pydantic models.
|
||||||
|
- Keep protocol changes synced in `client/src/network/protocol.ts` and `server/app/models.py`.
|
||||||
|
|
||||||
|
## Versioning & Configuration
|
||||||
|
- Bump `client/public/version.js` on every user-visible change using `YYYY.MM.DD Rn`.
|
||||||
|
- Do not duplicate version constants elsewhere in client code.
|
||||||
|
- `server/config.toml` is deployment-local and must not be committed.
|
||||||
|
- Production should use TLS (`network.allow_insecure_ws = false`).
|
||||||
|
|
||||||
|
## Audio Asset Rules
|
||||||
|
- Keep all runtime sounds in `client/public/sounds/`.
|
||||||
|
- Reference sounds as absolute web paths (example: `/sounds/roll.ogg`).
|
||||||
40
README.md
Normal file
40
README.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Chat Grid
|
||||||
|
|
||||||
|
Realtime spatial chat grid with:
|
||||||
|
- `client/` TypeScript web app
|
||||||
|
- `server/` Python websocket signaling server
|
||||||
|
|
||||||
|
## Local Run
|
||||||
|
|
||||||
|
1) Start server
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
cp config.example.toml config.toml
|
||||||
|
uv run python main.py --config config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
2) Start client
|
||||||
|
```bash
|
||||||
|
cd client
|
||||||
|
npm install
|
||||||
|
npm run dev -- --host 0.0.0.0 --port 5173
|
||||||
|
```
|
||||||
|
|
||||||
|
3) Open `http://localhost:5173`
|
||||||
|
|
||||||
|
## Production Deploy (quick path)
|
||||||
|
|
||||||
|
Use `deploy/README.md`.
|
||||||
|
|
||||||
|
Summary:
|
||||||
|
1. Copy repo to `/home/bestmidi/chgrid`.
|
||||||
|
2. Build client and publish `client/dist/` to `/home/bestmidi/public_html/chgrid/`.
|
||||||
|
3. Configure server `config.toml` and run it via `systemd`.
|
||||||
|
4. Add Apache `/ws` websocket proxy from `deploy/apache/chgrid-vhost-snippet.conf`.
|
||||||
|
|
||||||
|
## Key Paths
|
||||||
|
|
||||||
|
- Client version: `client/public/version.js`
|
||||||
|
- Client sounds: `client/public/sounds/`
|
||||||
|
- Server config template: `server/config.example.toml`
|
||||||
|
- Server runtime items: `server/runtime/items.json`
|
||||||
11
client/README.md
Normal file
11
client/README.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# chgrid client
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:5173`.
|
||||||
84
client/index.html
Normal file
84
client/index.html
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Chat Grid</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="app">
|
||||||
|
<h1>Chat Grid</h1>
|
||||||
|
<div id="nicknameContainer" class="nickname-row">
|
||||||
|
<label for="preconnectNickname">Nickname</label>
|
||||||
|
<input id="preconnectNickname" type="text" maxlength="32" autocomplete="nickname" />
|
||||||
|
</div>
|
||||||
|
<div class="controls" id="button-container">
|
||||||
|
<button id="connectButton">Connect</button>
|
||||||
|
<button id="settingsButton">Settings</button>
|
||||||
|
<button id="disconnectButton" class="hidden">Disconnect</button>
|
||||||
|
<button id="focusGridButton" class="hidden" aria-controls="gameCanvas">Chat Grid</button>
|
||||||
|
</div>
|
||||||
|
<div id="deviceSummary">
|
||||||
|
<p id="audioInputCurrent" class="hidden"></p>
|
||||||
|
<p id="audioOutputCurrent" class="hidden"></p>
|
||||||
|
</div>
|
||||||
|
<div id="status" role="region" aria-live="polite"></div>
|
||||||
|
<canvas
|
||||||
|
id="gameCanvas"
|
||||||
|
width="600"
|
||||||
|
height="600"
|
||||||
|
tabindex="0"
|
||||||
|
class="hidden"
|
||||||
|
aria-label="Chat Grid, press arrows to move."
|
||||||
|
></canvas>
|
||||||
|
<div id="instructions" class="hidden">
|
||||||
|
<h2>Help</h2>
|
||||||
|
|
||||||
|
<h3>Movement</h3>
|
||||||
|
<p><b>Arrow Keys:</b> Move</p>
|
||||||
|
<p><b>C:</b> Coordinates</p>
|
||||||
|
<p><b>Escape:</b> Disconnect/cancel</p>
|
||||||
|
|
||||||
|
<h3>Users, Nickname, and Chat</h3>
|
||||||
|
<p><b>L:</b> Locate nearest user</p>
|
||||||
|
<p><b>Shift+L:</b> List users</p>
|
||||||
|
<p><b>Shift+U:</b> List connected users</p>
|
||||||
|
<p><b>N:</b> Change nickname</p>
|
||||||
|
<p><b>Apostrophe:</b> Start chat</p>
|
||||||
|
<p><b>Comma / Period:</b> Previous/next chat message</p>
|
||||||
|
<p><b>Less Than / Greater Than:</b> First/last chat message</p>
|
||||||
|
|
||||||
|
<h3>Items</h3>
|
||||||
|
<p><b>I:</b> Locate nearest item</p>
|
||||||
|
<p><b>Shift+I:</b> List items</p>
|
||||||
|
<p><b>A:</b> Add item</p>
|
||||||
|
<p><b>O:</b> Edit item properties</p>
|
||||||
|
<p><b>D:</b> Pick up/drop item</p>
|
||||||
|
<p><b>Shift+D:</b> Delete item</p>
|
||||||
|
<p><b>U:</b> Use item</p>
|
||||||
|
|
||||||
|
<h3>Audio</h3>
|
||||||
|
<p><b>P:</b> Ping server</p>
|
||||||
|
<p><b>M:</b> Mute/unmute</p>
|
||||||
|
<p><b>Shift+M:</b> Toggle stereo/mono output</p>
|
||||||
|
<p><b>E:</b> Cycle voice effect</p>
|
||||||
|
<p><b>Dash or Equals:</b> Lower/raise active effect value</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<small id="appVersion">Another AI experiment with Jage. Version</small>
|
||||||
|
|
||||||
|
<div id="settingsModal" class="hidden" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2 id="modalTitle">Audio Settings</h2>
|
||||||
|
<label for="audioInputSelect">Microphone (Input)</label>
|
||||||
|
<select id="audioInputSelect"></select>
|
||||||
|
<label for="audioOutputSelect">Speakers (Output)</label>
|
||||||
|
<select id="audioOutputSelect"></select>
|
||||||
|
<button id="closeSettingsButton">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="%BASE_URL%version.js"></script>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3942
client/package-lock.json
generated
Normal file
3942
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
client/package.json
Normal file
24
client/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "chat-grid-client",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"lint": "eslint src --ext .ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^3.24.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.19.0",
|
||||||
|
"@typescript-eslint/parser": "^8.19.0",
|
||||||
|
"eslint": "^9.17.0",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^6.0.5",
|
||||||
|
"vitest": "^2.1.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
client/public/sounds/logon.ogg
Normal file
BIN
client/public/sounds/logon.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/logout.ogg
Normal file
BIN
client/public/sounds/logout.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/notify.ogg
Normal file
BIN
client/public/sounds/notify.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/roll.ogg
Normal file
BIN
client/public/sounds/roll.ogg
Normal file
Binary file not shown.
3
client/public/version.js
Normal file
3
client/public/version.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// Maintainer-controlled web client version.
|
||||||
|
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
|
||||||
|
window.CHGRID_WEB_VERSION = "2026.02.20 R57";
|
||||||
535
client/src/audio/audioEngine.ts
Normal file
535
client/src/audio/audioEngine.ts
Normal file
@@ -0,0 +1,535 @@
|
|||||||
|
import { HEARING_RADIUS } from '../state/gameState';
|
||||||
|
|
||||||
|
export type SpatialPeerRuntime = {
|
||||||
|
nickname: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
gain?: GainNode;
|
||||||
|
panner?: StereoPannerNode;
|
||||||
|
audioElement?: HTMLAudioElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SoundSpec = {
|
||||||
|
freq: number;
|
||||||
|
duration: number;
|
||||||
|
type?: OscillatorType;
|
||||||
|
gain?: number;
|
||||||
|
sourcePosition?: { x: number; y: number };
|
||||||
|
delay?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EffectId = 'reverb' | 'echo' | 'flanger' | 'high_pass' | 'low_pass' | 'off';
|
||||||
|
type OutputMode = 'stereo' | 'mono';
|
||||||
|
|
||||||
|
type EffectPreset = { id: EffectId; label: string; defaultValue: number };
|
||||||
|
|
||||||
|
const EFFECT_SEQUENCE: EffectPreset[] = [
|
||||||
|
{ id: 'reverb', label: 'Reverb', defaultValue: 50 },
|
||||||
|
{ id: 'echo', label: 'Echo', defaultValue: 50 },
|
||||||
|
{ id: 'flanger', label: 'Flanger', defaultValue: 50 },
|
||||||
|
{ id: 'high_pass', label: 'High Pass', defaultValue: 50 },
|
||||||
|
{ id: 'low_pass', label: 'Low Pass', defaultValue: 50 },
|
||||||
|
{ id: 'off', label: 'Off', defaultValue: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export class AudioEngine {
|
||||||
|
private audioCtx: AudioContext | null = null;
|
||||||
|
private sfxGainNode: GainNode | null = null;
|
||||||
|
private readonly sampleCache = new Map<string, AudioBuffer>();
|
||||||
|
private readonly sampleLoaders = new Map<string, Promise<AudioBuffer>>();
|
||||||
|
|
||||||
|
private outboundSource: MediaStreamAudioSourceNode | null = null;
|
||||||
|
private outboundInputGain: GainNode | null = null;
|
||||||
|
private outboundDestination: MediaStreamAudioDestinationNode | null = null;
|
||||||
|
private outboundEffectNodes: AudioNode[] = [];
|
||||||
|
private flangerLfo: OscillatorNode | null = null;
|
||||||
|
private flangerLfoGain: GainNode | null = null;
|
||||||
|
private outputMode: OutputMode = 'stereo';
|
||||||
|
private effectIndex = EFFECT_SEQUENCE.findIndex((effect) => effect.id === 'off');
|
||||||
|
private readonly effectValues: Record<EffectId, number> = {
|
||||||
|
reverb: 50,
|
||||||
|
echo: 50,
|
||||||
|
flanger: 50,
|
||||||
|
high_pass: 50,
|
||||||
|
low_pass: 50,
|
||||||
|
off: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
async ensureContext(): Promise<void> {
|
||||||
|
if (!this.audioCtx) {
|
||||||
|
const Ctor =
|
||||||
|
window.AudioContext ||
|
||||||
|
(window as Window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||||
|
if (!Ctor) return;
|
||||||
|
this.audioCtx = new Ctor();
|
||||||
|
this.sfxGainNode = this.audioCtx.createGain();
|
||||||
|
this.sfxGainNode.connect(this.audioCtx.destination);
|
||||||
|
}
|
||||||
|
if (this.audioCtx.state === 'suspended') {
|
||||||
|
await this.audioCtx.resume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get context(): AudioContext | null {
|
||||||
|
return this.audioCtx;
|
||||||
|
}
|
||||||
|
|
||||||
|
supportsStereoPanner(): boolean {
|
||||||
|
return !!this.audioCtx && typeof this.audioCtx.createStereoPanner === 'function';
|
||||||
|
}
|
||||||
|
|
||||||
|
supportsSinkId(element: HTMLMediaElement): boolean {
|
||||||
|
return (
|
||||||
|
typeof (element as HTMLMediaElement & { setSinkId?: (id: string) => Promise<void> }).setSinkId ===
|
||||||
|
'function'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async configureOutboundStream(inputStream: MediaStream): Promise<MediaStream> {
|
||||||
|
await this.ensureContext();
|
||||||
|
if (!this.audioCtx) {
|
||||||
|
return inputStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.outboundSource) {
|
||||||
|
this.outboundSource.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.outboundSource = this.audioCtx.createMediaStreamSource(inputStream);
|
||||||
|
if (!this.outboundInputGain) {
|
||||||
|
this.outboundInputGain = this.audioCtx.createGain();
|
||||||
|
}
|
||||||
|
if (!this.outboundDestination) {
|
||||||
|
this.outboundDestination = this.audioCtx.createMediaStreamDestination();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.outboundSource.connect(this.outboundInputGain);
|
||||||
|
this.rebuildOutboundEffectGraph();
|
||||||
|
|
||||||
|
return this.outboundDestination.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
cycleOutboundEffect(): { id: EffectId; label: string } {
|
||||||
|
this.effectIndex = (this.effectIndex + 1) % EFFECT_SEQUENCE.length;
|
||||||
|
this.rebuildOutboundEffectGraph();
|
||||||
|
return EFFECT_SEQUENCE[this.effectIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentEffect(): { id: EffectId; label: string; value: number; defaultValue: number } {
|
||||||
|
const effect = EFFECT_SEQUENCE[this.effectIndex];
|
||||||
|
return {
|
||||||
|
id: effect.id,
|
||||||
|
label: effect.label,
|
||||||
|
value: this.effectValues[effect.id],
|
||||||
|
defaultValue: effect.defaultValue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
adjustCurrentEffectLevel(step: number): { id: EffectId; label: string; value: number; defaultValue: number } | null {
|
||||||
|
const effect = EFFECT_SEQUENCE[this.effectIndex];
|
||||||
|
if (effect.id === 'off') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = this.clampLevel(this.effectValues[effect.id] + step);
|
||||||
|
this.effectValues[effect.id] = next;
|
||||||
|
this.rebuildOutboundEffectGraph();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: effect.id,
|
||||||
|
label: effect.label,
|
||||||
|
value: next,
|
||||||
|
defaultValue: effect.defaultValue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setEffectLevels(levels: Partial<Record<EffectId, number>>): void {
|
||||||
|
for (const effect of EFFECT_SEQUENCE) {
|
||||||
|
if (effect.id === 'off') continue;
|
||||||
|
const value = levels[effect.id];
|
||||||
|
if (typeof value !== 'number') continue;
|
||||||
|
this.effectValues[effect.id] = this.clampLevel(value);
|
||||||
|
}
|
||||||
|
this.rebuildOutboundEffectGraph();
|
||||||
|
}
|
||||||
|
|
||||||
|
getEffectLevels(): Record<EffectId, number> {
|
||||||
|
return { ...this.effectValues };
|
||||||
|
}
|
||||||
|
|
||||||
|
setOutputMode(mode: OutputMode): void {
|
||||||
|
this.outputMode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleOutputMode(): OutputMode {
|
||||||
|
this.outputMode = this.outputMode === 'stereo' ? 'mono' : 'stereo';
|
||||||
|
return this.outputMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
async attachRemoteStream(
|
||||||
|
peer: SpatialPeerRuntime,
|
||||||
|
stream: MediaStream,
|
||||||
|
outputDeviceId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.ensureContext();
|
||||||
|
if (!this.audioCtx) return;
|
||||||
|
|
||||||
|
const audioElement = new Audio();
|
||||||
|
audioElement.srcObject = stream;
|
||||||
|
audioElement.muted = true;
|
||||||
|
|
||||||
|
if (outputDeviceId && this.supportsSinkId(audioElement)) {
|
||||||
|
const sinkTarget = audioElement as HTMLMediaElement & { setSinkId?: (id: string) => Promise<void> };
|
||||||
|
await sinkTarget.setSinkId?.(outputDeviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await audioElement.play().catch(() => undefined);
|
||||||
|
document.body.appendChild(audioElement);
|
||||||
|
|
||||||
|
const sourceNode = this.audioCtx.createMediaStreamSource(stream);
|
||||||
|
const gainNode = this.audioCtx.createGain();
|
||||||
|
sourceNode.connect(gainNode);
|
||||||
|
|
||||||
|
let pannerNode: StereoPannerNode | undefined;
|
||||||
|
if (this.supportsStereoPanner()) {
|
||||||
|
pannerNode = this.audioCtx.createStereoPanner();
|
||||||
|
gainNode.connect(pannerNode).connect(this.audioCtx.destination);
|
||||||
|
} else {
|
||||||
|
gainNode.connect(this.audioCtx.destination);
|
||||||
|
}
|
||||||
|
|
||||||
|
peer.audioElement = audioElement;
|
||||||
|
peer.gain = gainNode;
|
||||||
|
peer.panner = pannerNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSpatialAudio(peers: Iterable<SpatialPeerRuntime>, playerPosition: { x: number; y: number }): void {
|
||||||
|
if (!this.audioCtx) return;
|
||||||
|
|
||||||
|
for (const peer of peers) {
|
||||||
|
if (!peer.gain) continue;
|
||||||
|
const dist = Math.hypot(peer.x - playerPosition.x, peer.y - playerPosition.y);
|
||||||
|
let gainValue = 0;
|
||||||
|
let panValue = 0;
|
||||||
|
if (dist < HEARING_RADIUS) {
|
||||||
|
gainValue = Math.pow(1 - dist / HEARING_RADIUS, 2);
|
||||||
|
panValue = Math.sin(((peer.x - playerPosition.x) / HEARING_RADIUS) * (Math.PI / 2));
|
||||||
|
}
|
||||||
|
if (dist < 1.5) gainValue = 1;
|
||||||
|
peer.gain.gain.linearRampToValueAtTime(gainValue, this.audioCtx.currentTime + 0.1);
|
||||||
|
if (peer.panner) {
|
||||||
|
const resolvedPan = this.outputMode === 'mono' ? 0 : Math.max(-1, Math.min(1, panValue));
|
||||||
|
peer.panner.pan.setValueAtTime(resolvedPan, this.audioCtx.currentTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sfxMove(player: { x: number; y: number }): void {
|
||||||
|
void player;
|
||||||
|
this.playSound({ freq: 165, duration: 0.05, type: 'triangle', gain: 0.13 });
|
||||||
|
}
|
||||||
|
|
||||||
|
sfxPeerMove(peer: { x: number; y: number }): void {
|
||||||
|
this.playSound({ freq: 330, duration: 0.05, type: 'triangle', gain: 0.12, sourcePosition: peer });
|
||||||
|
}
|
||||||
|
|
||||||
|
sfxLocate(peer: { x: number; y: number }): void {
|
||||||
|
this.playSound({ freq: 880, duration: 0.2, type: 'sine', gain: 0.5, sourcePosition: peer });
|
||||||
|
}
|
||||||
|
|
||||||
|
sfxUiConfirm(): void {
|
||||||
|
this.playSound({ freq: 880, duration: 0.1, gain: 0.5 });
|
||||||
|
}
|
||||||
|
|
||||||
|
sfxUiCancel(): void {
|
||||||
|
this.playSound({ freq: 440, duration: 0.1, type: 'sawtooth', gain: 0.3 });
|
||||||
|
}
|
||||||
|
|
||||||
|
sfxUiBlip(): void {
|
||||||
|
this.playSound({ freq: 660, duration: 0.05, type: 'triangle', gain: 0.35 });
|
||||||
|
}
|
||||||
|
|
||||||
|
sfxEffectLevel(isDefault: boolean): void {
|
||||||
|
this.playSound({ freq: isDefault ? 659.25 : 440, duration: 0.1, type: 'sine', gain: 0.35 });
|
||||||
|
}
|
||||||
|
|
||||||
|
sfxTileOccupantPing(): void {
|
||||||
|
this.playSound({ freq: 1320, duration: 0.12, type: 'sine', gain: 0.45 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async playSpatialSample(url: string, sourcePosition: { x: number; y: number }, gain = 1): Promise<void> {
|
||||||
|
await this.ensureContext();
|
||||||
|
const { audioCtx, sfxGainNode } = this;
|
||||||
|
if (!audioCtx || !sfxGainNode) return;
|
||||||
|
|
||||||
|
const resolved = this.resolveSpatialMix(sourcePosition, gain);
|
||||||
|
if (!resolved) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const buffer = await this.getSampleBuffer(url);
|
||||||
|
const source = audioCtx.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
const gainNode = audioCtx.createGain();
|
||||||
|
gainNode.gain.value = resolved.gain;
|
||||||
|
source.connect(gainNode);
|
||||||
|
if (resolved.pan !== undefined && this.supportsStereoPanner() && this.outputMode === 'stereo') {
|
||||||
|
const panner = audioCtx.createStereoPanner();
|
||||||
|
panner.pan.setValueAtTime(resolved.pan, audioCtx.currentTime);
|
||||||
|
gainNode.connect(panner).connect(sfxGainNode);
|
||||||
|
} else {
|
||||||
|
gainNode.connect(sfxGainNode);
|
||||||
|
}
|
||||||
|
source.start();
|
||||||
|
} catch {
|
||||||
|
// Ignore sample decode/load errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async playSample(url: string, gain = 1): Promise<void> {
|
||||||
|
await this.ensureContext();
|
||||||
|
const { audioCtx, sfxGainNode } = this;
|
||||||
|
if (!audioCtx || !sfxGainNode) return;
|
||||||
|
if (gain <= 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const buffer = await this.getSampleBuffer(url);
|
||||||
|
const source = audioCtx.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
const gainNode = audioCtx.createGain();
|
||||||
|
gainNode.gain.value = gain;
|
||||||
|
source.connect(gainNode).connect(sfxGainNode);
|
||||||
|
source.start();
|
||||||
|
} catch {
|
||||||
|
// Ignore sample decode/load errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupPeerAudio(peer: SpatialPeerRuntime): void {
|
||||||
|
peer.audioElement?.remove();
|
||||||
|
peer.gain?.disconnect();
|
||||||
|
peer.panner?.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private rebuildOutboundEffectGraph(): void {
|
||||||
|
if (!this.audioCtx || !this.outboundInputGain || !this.outboundDestination) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cleanupEffectNodes();
|
||||||
|
this.outboundInputGain.disconnect();
|
||||||
|
|
||||||
|
const effect = EFFECT_SEQUENCE[this.effectIndex].id;
|
||||||
|
const effectMix = this.effectValues[effect] / 100;
|
||||||
|
|
||||||
|
if (effect === 'off') {
|
||||||
|
this.outboundInputGain.connect(this.outboundDestination);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effect === 'high_pass' || effect === 'low_pass') {
|
||||||
|
const filter = this.audioCtx.createBiquadFilter();
|
||||||
|
filter.type = effect === 'high_pass' ? 'highpass' : 'lowpass';
|
||||||
|
if (effect === 'high_pass') {
|
||||||
|
filter.frequency.value = 120 + effectMix * 7000;
|
||||||
|
} else {
|
||||||
|
filter.frequency.value = 7800 - effectMix * 7600;
|
||||||
|
}
|
||||||
|
filter.Q.value = 0.7 + effectMix * 8;
|
||||||
|
this.outboundInputGain.connect(filter);
|
||||||
|
filter.connect(this.outboundDestination);
|
||||||
|
this.outboundEffectNodes.push(filter);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effect === 'echo') {
|
||||||
|
const delay = this.audioCtx.createDelay(1);
|
||||||
|
delay.delayTime.value = 0.04 + effectMix * 0.76;
|
||||||
|
const feedback = this.audioCtx.createGain();
|
||||||
|
feedback.gain.value = 0.04 + effectMix * 0.88;
|
||||||
|
const wetGain = this.audioCtx.createGain();
|
||||||
|
wetGain.gain.value = 0.08 + effectMix * 0.92;
|
||||||
|
const dryGain = this.audioCtx.createGain();
|
||||||
|
dryGain.gain.value = 1 - effectMix * 0.85;
|
||||||
|
|
||||||
|
this.outboundInputGain.connect(dryGain);
|
||||||
|
dryGain.connect(this.outboundDestination);
|
||||||
|
this.outboundInputGain.connect(delay);
|
||||||
|
delay.connect(wetGain);
|
||||||
|
wetGain.connect(this.outboundDestination);
|
||||||
|
delay.connect(feedback);
|
||||||
|
feedback.connect(delay);
|
||||||
|
|
||||||
|
this.outboundEffectNodes.push(delay, feedback, wetGain, dryGain);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effect === 'reverb') {
|
||||||
|
const convolver = this.audioCtx.createConvolver();
|
||||||
|
convolver.buffer = this.createImpulseResponse(0.4 + effectMix * 4.2, 1 + effectMix * 3.6);
|
||||||
|
const wetGain = this.audioCtx.createGain();
|
||||||
|
wetGain.gain.value = 0.06 + effectMix * 0.94;
|
||||||
|
const dryGain = this.audioCtx.createGain();
|
||||||
|
dryGain.gain.value = 1 - effectMix * 0.8;
|
||||||
|
|
||||||
|
this.outboundInputGain.connect(dryGain);
|
||||||
|
dryGain.connect(this.outboundDestination);
|
||||||
|
this.outboundInputGain.connect(convolver);
|
||||||
|
convolver.connect(wetGain);
|
||||||
|
wetGain.connect(this.outboundDestination);
|
||||||
|
|
||||||
|
this.outboundEffectNodes.push(convolver, wetGain, dryGain);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = this.audioCtx.createDelay(0.05);
|
||||||
|
delay.delayTime.value = 0.0005 + effectMix * 0.012;
|
||||||
|
const feedback = this.audioCtx.createGain();
|
||||||
|
feedback.gain.value = 0.04 + effectMix * 0.9;
|
||||||
|
const wetGain = this.audioCtx.createGain();
|
||||||
|
wetGain.gain.value = 0.05 + effectMix * 0.95;
|
||||||
|
const dryGain = this.audioCtx.createGain();
|
||||||
|
dryGain.gain.value = 1 - effectMix * 0.82;
|
||||||
|
|
||||||
|
const lfo = this.audioCtx.createOscillator();
|
||||||
|
lfo.type = 'sine';
|
||||||
|
lfo.frequency.value = 0.05 + effectMix * 1.8;
|
||||||
|
const lfoGain = this.audioCtx.createGain();
|
||||||
|
lfoGain.gain.value = 0.0002 + effectMix * 0.015;
|
||||||
|
|
||||||
|
lfo.connect(lfoGain);
|
||||||
|
lfoGain.connect(delay.delayTime);
|
||||||
|
lfo.start();
|
||||||
|
|
||||||
|
this.outboundInputGain.connect(dryGain);
|
||||||
|
dryGain.connect(this.outboundDestination);
|
||||||
|
this.outboundInputGain.connect(delay);
|
||||||
|
delay.connect(wetGain);
|
||||||
|
wetGain.connect(this.outboundDestination);
|
||||||
|
delay.connect(feedback);
|
||||||
|
feedback.connect(delay);
|
||||||
|
|
||||||
|
this.flangerLfo = lfo;
|
||||||
|
this.flangerLfoGain = lfoGain;
|
||||||
|
this.outboundEffectNodes.push(delay, feedback, wetGain, lfoGain, dryGain);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanupEffectNodes(): void {
|
||||||
|
for (const node of this.outboundEffectNodes) {
|
||||||
|
node.disconnect();
|
||||||
|
}
|
||||||
|
this.outboundEffectNodes = [];
|
||||||
|
|
||||||
|
if (this.flangerLfo) {
|
||||||
|
this.flangerLfo.stop();
|
||||||
|
this.flangerLfo.disconnect();
|
||||||
|
this.flangerLfo = null;
|
||||||
|
}
|
||||||
|
if (this.flangerLfoGain) {
|
||||||
|
this.flangerLfoGain.disconnect();
|
||||||
|
this.flangerLfoGain = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createImpulseResponse(duration: number, decay: number): AudioBuffer {
|
||||||
|
if (!this.audioCtx) {
|
||||||
|
throw new Error('Audio context not initialized');
|
||||||
|
}
|
||||||
|
const length = Math.floor(this.audioCtx.sampleRate * duration);
|
||||||
|
const impulse = this.audioCtx.createBuffer(2, length, this.audioCtx.sampleRate);
|
||||||
|
for (let channel = 0; channel < impulse.numberOfChannels; channel += 1) {
|
||||||
|
const data = impulse.getChannelData(channel);
|
||||||
|
for (let i = 0; i < length; i += 1) {
|
||||||
|
const noise = Math.random() * 2 - 1;
|
||||||
|
data[i] = noise * Math.pow(1 - i / length, decay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return impulse;
|
||||||
|
}
|
||||||
|
|
||||||
|
private clampLevel(value: number): number {
|
||||||
|
const clamped = Math.max(0, Math.min(100, value));
|
||||||
|
return Math.round(clamped / 5) * 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
private playSound(spec: SoundSpec): void {
|
||||||
|
const { audioCtx, sfxGainNode } = this;
|
||||||
|
if (!audioCtx || !sfxGainNode) return;
|
||||||
|
|
||||||
|
const baseGain = spec.gain ?? 1;
|
||||||
|
const resolved = this.resolveSpatialMix(spec.sourcePosition, baseGain);
|
||||||
|
if (!resolved) return;
|
||||||
|
const finalGain = resolved.gain;
|
||||||
|
const panValue = resolved.pan;
|
||||||
|
|
||||||
|
if (finalGain <= 0) return;
|
||||||
|
|
||||||
|
const startTime = audioCtx.currentTime + (spec.delay ?? 0);
|
||||||
|
const oscillator = audioCtx.createOscillator();
|
||||||
|
oscillator.type = spec.type ?? 'sine';
|
||||||
|
oscillator.frequency.setValueAtTime(spec.freq, startTime);
|
||||||
|
|
||||||
|
const gainNode = audioCtx.createGain();
|
||||||
|
gainNode.gain.setValueAtTime(finalGain, startTime);
|
||||||
|
gainNode.gain.exponentialRampToValueAtTime(0.001, startTime + spec.duration);
|
||||||
|
|
||||||
|
oscillator.connect(gainNode);
|
||||||
|
if (panValue !== undefined && this.supportsStereoPanner() && this.outputMode === 'stereo') {
|
||||||
|
const panner = audioCtx.createStereoPanner();
|
||||||
|
panner.pan.setValueAtTime(Math.max(-1, Math.min(1, panValue)), startTime);
|
||||||
|
gainNode.connect(panner).connect(sfxGainNode);
|
||||||
|
} else {
|
||||||
|
gainNode.connect(sfxGainNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
oscillator.start(startTime);
|
||||||
|
oscillator.stop(startTime + spec.duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveSpatialMix(
|
||||||
|
sourcePosition: { x: number; y: number } | undefined,
|
||||||
|
baseGain: number,
|
||||||
|
): { gain: number; pan?: number } | null {
|
||||||
|
if (!sourcePosition) {
|
||||||
|
return { gain: baseGain };
|
||||||
|
}
|
||||||
|
const distance = Math.hypot(sourcePosition.x, sourcePosition.y);
|
||||||
|
if (distance > HEARING_RADIUS) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const volumeRatio = Math.max(0, 1 - distance / HEARING_RADIUS);
|
||||||
|
const finalGain = baseGain * Math.pow(volumeRatio, 2);
|
||||||
|
const clampedX = Math.max(-HEARING_RADIUS, Math.min(HEARING_RADIUS, sourcePosition.x));
|
||||||
|
const pan = Math.sin((clampedX / HEARING_RADIUS) * (Math.PI / 2));
|
||||||
|
return { gain: finalGain, pan };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSampleBuffer(url: string): Promise<AudioBuffer> {
|
||||||
|
if (!this.audioCtx) {
|
||||||
|
throw new Error('Audio context not initialized');
|
||||||
|
}
|
||||||
|
if (this.sampleCache.has(url)) {
|
||||||
|
return this.sampleCache.get(url)!;
|
||||||
|
}
|
||||||
|
if (!this.sampleLoaders.has(url)) {
|
||||||
|
this.sampleLoaders.set(
|
||||||
|
url,
|
||||||
|
fetch(url)
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.ok) throw new Error(`Failed to fetch sample: ${url}`);
|
||||||
|
return response.arrayBuffer();
|
||||||
|
})
|
||||||
|
.then((data) => this.audioCtx!.decodeAudioData(data))
|
||||||
|
.then((buffer) => {
|
||||||
|
this.sampleCache.set(url, buffer);
|
||||||
|
this.sampleLoaders.delete(url);
|
||||||
|
return buffer;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.sampleLoaders.delete(url);
|
||||||
|
throw error;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.sampleLoaders.get(url)!;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
client/src/input/textInput.ts
Normal file
30
client/src/input/textInput.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export function applyTextInput(
|
||||||
|
key: string,
|
||||||
|
currentString: string,
|
||||||
|
cursorPos: number,
|
||||||
|
maxLength: number,
|
||||||
|
): { newString: string; newCursorPos: number } {
|
||||||
|
let newString = currentString;
|
||||||
|
let newCursorPos = cursorPos;
|
||||||
|
const lowerKey = key.toLowerCase();
|
||||||
|
|
||||||
|
if (lowerKey === 'arrowleft') {
|
||||||
|
newCursorPos = Math.max(0, cursorPos - 1);
|
||||||
|
} else if (lowerKey === 'arrowright') {
|
||||||
|
newCursorPos = Math.min(newString.length, cursorPos + 1);
|
||||||
|
} else if (lowerKey === 'backspace') {
|
||||||
|
if (cursorPos > 0) {
|
||||||
|
newString = newString.slice(0, cursorPos - 1) + newString.slice(cursorPos);
|
||||||
|
newCursorPos = cursorPos - 1;
|
||||||
|
}
|
||||||
|
} else if (lowerKey === 'home') {
|
||||||
|
newCursorPos = 0;
|
||||||
|
} else if (lowerKey === 'end') {
|
||||||
|
newCursorPos = newString.length;
|
||||||
|
} else if (key.length === 1 && newString.length < maxLength) {
|
||||||
|
newString = newString.slice(0, cursorPos) + key + newString.slice(cursorPos);
|
||||||
|
newCursorPos = cursorPos + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { newString, newCursorPos };
|
||||||
|
}
|
||||||
1779
client/src/main.ts
Normal file
1779
client/src/main.ts
Normal file
File diff suppressed because it is too large
Load Diff
149
client/src/network/protocol.ts
Normal file
149
client/src/network/protocol.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const itemSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
type: z.enum(['radio_station', 'dice']),
|
||||||
|
title: z.string(),
|
||||||
|
x: z.number().int(),
|
||||||
|
y: z.number().int(),
|
||||||
|
createdBy: z.string(),
|
||||||
|
createdAt: z.number().int(),
|
||||||
|
updatedAt: z.number().int(),
|
||||||
|
version: z.number().int(),
|
||||||
|
capabilities: z.array(z.string()),
|
||||||
|
useSound: z.string().optional(),
|
||||||
|
params: z.record(z.string(), z.unknown()),
|
||||||
|
carrierId: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const welcomeMessageSchema = z.object({
|
||||||
|
type: z.literal('welcome'),
|
||||||
|
id: z.string(),
|
||||||
|
users: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
nickname: z.string(),
|
||||||
|
x: z.number().int(),
|
||||||
|
y: z.number().int(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
items: z.array(itemSchema).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const signalMessageSchema = z.object({
|
||||||
|
type: z.literal('signal'),
|
||||||
|
senderId: z.string(),
|
||||||
|
senderNickname: z.string().optional(),
|
||||||
|
x: z.number().int().optional(),
|
||||||
|
y: z.number().int().optional(),
|
||||||
|
targetId: z.string().optional(),
|
||||||
|
sdp: z.any().optional(),
|
||||||
|
ice: z.any().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updatePositionSchema = z.object({
|
||||||
|
type: z.literal('update_position'),
|
||||||
|
id: z.string(),
|
||||||
|
x: z.number().int(),
|
||||||
|
y: z.number().int(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateNicknameSchema = z.object({
|
||||||
|
type: z.literal('update_nickname'),
|
||||||
|
id: z.string(),
|
||||||
|
nickname: z.string().min(1).max(32),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const userLeftSchema = z.object({
|
||||||
|
type: z.literal('user_left'),
|
||||||
|
id: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const chatMessageSchema = z.object({
|
||||||
|
type: z.literal('chat_message'),
|
||||||
|
message: z.string(),
|
||||||
|
senderId: z.string().optional(),
|
||||||
|
senderNickname: z.string().optional(),
|
||||||
|
system: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pongSchema = z.object({
|
||||||
|
type: z.literal('pong'),
|
||||||
|
clientSentAt: z.number().int(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const nicknameResultSchema = z.object({
|
||||||
|
type: z.literal('nickname_result'),
|
||||||
|
accepted: z.boolean(),
|
||||||
|
requestedNickname: z.string(),
|
||||||
|
effectiveNickname: z.string(),
|
||||||
|
reason: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const itemUpsertSchema = z.object({
|
||||||
|
type: z.literal('item_upsert'),
|
||||||
|
item: itemSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const itemRemoveSchema = z.object({
|
||||||
|
type: z.literal('item_remove'),
|
||||||
|
itemId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const itemActionResultSchema = z.object({
|
||||||
|
type: z.literal('item_action_result'),
|
||||||
|
ok: z.boolean(),
|
||||||
|
action: z.enum(['add', 'pickup', 'drop', 'delete', 'use', 'update']),
|
||||||
|
message: z.string(),
|
||||||
|
itemId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const itemUseSoundSchema = z.object({
|
||||||
|
type: z.literal('item_use_sound'),
|
||||||
|
itemId: z.string(),
|
||||||
|
sound: z.string(),
|
||||||
|
x: z.number().int(),
|
||||||
|
y: z.number().int(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const incomingMessageSchema = z.discriminatedUnion('type', [
|
||||||
|
welcomeMessageSchema,
|
||||||
|
signalMessageSchema,
|
||||||
|
updatePositionSchema,
|
||||||
|
updateNicknameSchema,
|
||||||
|
userLeftSchema,
|
||||||
|
chatMessageSchema,
|
||||||
|
pongSchema,
|
||||||
|
nicknameResultSchema,
|
||||||
|
itemUpsertSchema,
|
||||||
|
itemRemoveSchema,
|
||||||
|
itemActionResultSchema,
|
||||||
|
itemUseSoundSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type IncomingMessage = z.infer<typeof incomingMessageSchema>;
|
||||||
|
|
||||||
|
export type OutgoingMessage =
|
||||||
|
| { type: 'signal'; targetId: string; sdp?: RTCSessionDescriptionInit; ice?: RTCIceCandidateInit }
|
||||||
|
| { type: 'update_position'; x: number; y: number }
|
||||||
|
| { type: 'update_nickname'; nickname: string }
|
||||||
|
| { type: 'chat_message'; message: string }
|
||||||
|
| { type: 'ping'; clientSentAt: number }
|
||||||
|
| { type: 'item_add'; itemType: 'radio_station' | 'dice' }
|
||||||
|
| { type: 'item_pickup'; itemId: string }
|
||||||
|
| { type: 'item_drop'; itemId: string; x: number; y: number }
|
||||||
|
| { type: 'item_delete'; itemId: string }
|
||||||
|
| { type: 'item_use'; itemId: string }
|
||||||
|
| {
|
||||||
|
type: 'item_update';
|
||||||
|
itemId: string;
|
||||||
|
title?: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RemoteUser = {
|
||||||
|
id: string;
|
||||||
|
nickname: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
76
client/src/network/signalingClient.ts
Normal file
76
client/src/network/signalingClient.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { incomingMessageSchema, type IncomingMessage, type OutgoingMessage } from './protocol';
|
||||||
|
|
||||||
|
type MessageHandler = (message: IncomingMessage) => void | Promise<void>;
|
||||||
|
type StatusHandler = (message: string) => void;
|
||||||
|
|
||||||
|
export class SignalingClient {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private timeoutId: number | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly url: string, private readonly status: StatusHandler) {}
|
||||||
|
|
||||||
|
async connect(onMessage: MessageHandler): Promise<void> {
|
||||||
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
|
||||||
|
|
||||||
|
this.ws = new WebSocket(this.url);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
if (!this.ws) {
|
||||||
|
reject(new Error('WebSocket unavailable'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.timeoutId = window.setTimeout(() => {
|
||||||
|
this.status('Connection timed out.');
|
||||||
|
this.disconnect();
|
||||||
|
reject(new Error('Connection timed out'));
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
this.clearTimeout();
|
||||||
|
this.status('Connected.');
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = () => {
|
||||||
|
this.clearTimeout();
|
||||||
|
reject(new Error('WebSocket error'));
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = async (event) => {
|
||||||
|
const parsed = JSON.parse(String(event.data));
|
||||||
|
const validated = incomingMessageSchema.safeParse(parsed);
|
||||||
|
if (!validated.success) return;
|
||||||
|
await onMessage(validated.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
this.clearTimeout();
|
||||||
|
this.status('Disconnected.');
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
send(payload: OutgoingMessage): void {
|
||||||
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
this.ws.send(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
this.clearTimeout();
|
||||||
|
if (!this.ws) return;
|
||||||
|
this.ws.onopen = null;
|
||||||
|
this.ws.onmessage = null;
|
||||||
|
this.ws.onclose = null;
|
||||||
|
this.ws.onerror = null;
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTimeout(): void {
|
||||||
|
if (this.timeoutId !== null) {
|
||||||
|
window.clearTimeout(this.timeoutId);
|
||||||
|
this.timeoutId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
86
client/src/render/canvasRenderer.ts
Normal file
86
client/src/render/canvasRenderer.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { GRID_SIZE, type GameState, type PeerState, type WorldItem } from '../state/gameState';
|
||||||
|
|
||||||
|
export class CanvasRenderer {
|
||||||
|
private readonly ctx: CanvasRenderingContext2D;
|
||||||
|
private readonly squarePixelSize: number;
|
||||||
|
|
||||||
|
constructor(private readonly canvas: HTMLCanvasElement) {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('Unable to create 2D context');
|
||||||
|
}
|
||||||
|
this.ctx = ctx;
|
||||||
|
this.squarePixelSize = canvas.width / GRID_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
draw(state: GameState): void {
|
||||||
|
const { ctx } = this;
|
||||||
|
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
ctx.strokeStyle = '#374151';
|
||||||
|
for (let i = 0; i <= GRID_SIZE; i += 1) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(i * this.squarePixelSize, 0);
|
||||||
|
ctx.lineTo(i * this.squarePixelSize, this.canvas.height);
|
||||||
|
ctx.moveTo(0, i * this.squarePixelSize);
|
||||||
|
ctx.lineTo(this.canvas.width, i * this.squarePixelSize);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const peer of state.peers.values()) {
|
||||||
|
this.drawObject(peer, '#f87171', peer.nickname);
|
||||||
|
}
|
||||||
|
for (const item of state.items.values()) {
|
||||||
|
if (item.carrierId) continue;
|
||||||
|
this.drawItem(item);
|
||||||
|
}
|
||||||
|
this.drawObject(state.player, '#34d399', state.player.nickname);
|
||||||
|
|
||||||
|
if (state.mode === 'nickname' || state.mode === 'chat' || state.mode === 'itemPropertyEdit') {
|
||||||
|
const label =
|
||||||
|
state.mode === 'nickname' ? 'New Nickname' : state.mode === 'chat' ? 'Message' : 'Property Value';
|
||||||
|
this.drawTextOverlay(state, label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawObject(obj: Pick<PeerState, 'x' | 'y' | 'nickname'>, color: string, name: string): void {
|
||||||
|
const drawX = obj.x * this.squarePixelSize;
|
||||||
|
const drawY = this.canvas.height - (obj.y * this.squarePixelSize) - this.squarePixelSize;
|
||||||
|
this.ctx.fillStyle = color;
|
||||||
|
this.ctx.fillRect(drawX, drawY, this.squarePixelSize, this.squarePixelSize);
|
||||||
|
this.ctx.fillStyle = 'white';
|
||||||
|
this.ctx.font = '12px Courier New';
|
||||||
|
this.ctx.textAlign = 'center';
|
||||||
|
this.ctx.fillText(name, drawX + this.squarePixelSize / 2, drawY - 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawTextOverlay(state: GameState, label: string): void {
|
||||||
|
const { ctx } = this;
|
||||||
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
||||||
|
ctx.fillRect(0, this.canvas.height / 2 - 30, this.canvas.width, 60);
|
||||||
|
ctx.fillStyle = 'white';
|
||||||
|
ctx.font = '24px Courier New';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
|
||||||
|
const text = `${label}: ${state.nicknameInput}`;
|
||||||
|
const textMetrics = ctx.measureText(text);
|
||||||
|
const preCursorText = `${label}: ${state.nicknameInput.substring(0, state.cursorPos)}`;
|
||||||
|
const preCursorWidth = ctx.measureText(preCursorText).width;
|
||||||
|
const textX = this.canvas.width / 2;
|
||||||
|
|
||||||
|
ctx.fillText(text, textX, this.canvas.height / 2);
|
||||||
|
if (state.cursorVisible) {
|
||||||
|
ctx.fillRect(textX - textMetrics.width / 2 + preCursorWidth, this.canvas.height / 2 - 20, 2, 24);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawItem(item: WorldItem): void {
|
||||||
|
const drawX = item.x * this.squarePixelSize;
|
||||||
|
const drawY = this.canvas.height - (item.y * this.squarePixelSize) - this.squarePixelSize;
|
||||||
|
this.ctx.fillStyle = item.type === 'radio_station' ? '#fbbf24' : '#60a5fa';
|
||||||
|
this.ctx.fillRect(drawX, drawY, this.squarePixelSize, this.squarePixelSize);
|
||||||
|
this.ctx.fillStyle = '#111827';
|
||||||
|
this.ctx.font = 'bold 12px Courier New';
|
||||||
|
this.ctx.textAlign = 'center';
|
||||||
|
this.ctx.fillText(item.type === 'radio_station' ? 'R' : 'D', drawX + this.squarePixelSize / 2, drawY + 13);
|
||||||
|
}
|
||||||
|
}
|
||||||
149
client/src/state/gameState.ts
Normal file
149
client/src/state/gameState.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
export const GRID_SIZE = 40;
|
||||||
|
export const HEARING_RADIUS = 15;
|
||||||
|
export const MOVE_COOLDOWN_MS = 100;
|
||||||
|
|
||||||
|
export type ItemType = 'radio_station' | 'dice';
|
||||||
|
|
||||||
|
export type WorldItem = {
|
||||||
|
id: string;
|
||||||
|
type: ItemType;
|
||||||
|
title: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
version: number;
|
||||||
|
capabilities: string[];
|
||||||
|
useSound?: string;
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
carrierId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SelectionContext = 'pickup' | 'drop' | 'delete' | 'edit' | 'use' | null;
|
||||||
|
|
||||||
|
export type GameMode =
|
||||||
|
| 'normal'
|
||||||
|
| 'nickname'
|
||||||
|
| 'chat'
|
||||||
|
| 'listUsers'
|
||||||
|
| 'listItems'
|
||||||
|
| 'addItem'
|
||||||
|
| 'selectItem'
|
||||||
|
| 'itemProperties'
|
||||||
|
| 'itemPropertyEdit';
|
||||||
|
|
||||||
|
export type Player = {
|
||||||
|
id: string | null;
|
||||||
|
nickname: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
lastMoveTime: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PeerState = {
|
||||||
|
id: string;
|
||||||
|
nickname: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GameState = {
|
||||||
|
running: boolean;
|
||||||
|
mode: GameMode;
|
||||||
|
keysPressed: Record<string, boolean>;
|
||||||
|
nicknameInput: string;
|
||||||
|
cursorPos: number;
|
||||||
|
cursorVisible: boolean;
|
||||||
|
sortedPeerIds: string[];
|
||||||
|
listIndex: number;
|
||||||
|
sortedItemIds: string[];
|
||||||
|
itemListIndex: number;
|
||||||
|
selectedItemIds: string[];
|
||||||
|
selectionContext: SelectionContext;
|
||||||
|
selectedItemIndex: number;
|
||||||
|
selectedItemId: string | null;
|
||||||
|
itemPropertyKeys: string[];
|
||||||
|
itemPropertyIndex: number;
|
||||||
|
editingPropertyKey: string | null;
|
||||||
|
addItemTypeIndex: number;
|
||||||
|
isMuted: boolean;
|
||||||
|
player: Player;
|
||||||
|
peers: Map<string, PeerState>;
|
||||||
|
items: Map<string, WorldItem>;
|
||||||
|
carriedItemId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createInitialState(): GameState {
|
||||||
|
return {
|
||||||
|
running: false,
|
||||||
|
mode: 'normal',
|
||||||
|
keysPressed: {},
|
||||||
|
nicknameInput: '',
|
||||||
|
cursorPos: 0,
|
||||||
|
cursorVisible: true,
|
||||||
|
sortedPeerIds: [],
|
||||||
|
listIndex: 0,
|
||||||
|
sortedItemIds: [],
|
||||||
|
itemListIndex: 0,
|
||||||
|
selectedItemIds: [],
|
||||||
|
selectionContext: null,
|
||||||
|
selectedItemIndex: 0,
|
||||||
|
selectedItemId: null,
|
||||||
|
itemPropertyKeys: [],
|
||||||
|
itemPropertyIndex: 0,
|
||||||
|
editingPropertyKey: null,
|
||||||
|
addItemTypeIndex: 0,
|
||||||
|
isMuted: false,
|
||||||
|
player: {
|
||||||
|
id: null,
|
||||||
|
nickname: 'anon',
|
||||||
|
x: 20,
|
||||||
|
y: 20,
|
||||||
|
lastMoveTime: 0,
|
||||||
|
},
|
||||||
|
peers: new Map(),
|
||||||
|
items: new Map(),
|
||||||
|
carriedItemId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNearestPeer(state: GameState): { peerId: string | null; distance: number } {
|
||||||
|
let nearest: string | null = null;
|
||||||
|
let minDist = Infinity;
|
||||||
|
for (const [id, peer] of state.peers.entries()) {
|
||||||
|
const dist = Math.hypot(peer.x - state.player.x, peer.y - state.player.y);
|
||||||
|
if (dist < minDist) {
|
||||||
|
minDist = dist;
|
||||||
|
nearest = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { peerId: nearest, distance: minDist };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDirection(px: number, py: number, tx: number, ty: number): string {
|
||||||
|
const dx = tx - px;
|
||||||
|
const dy = ty - py;
|
||||||
|
if (dx === 0 && dy === 0) return 'here';
|
||||||
|
let vDir = '';
|
||||||
|
let hDir = '';
|
||||||
|
if (dy > 0) vDir = 'north';
|
||||||
|
if (dy < 0) vDir = 'south';
|
||||||
|
if (dx > 0) hDir = 'east';
|
||||||
|
if (dx < 0) hDir = 'west';
|
||||||
|
return `${vDir} ${hDir}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNearestItem(state: GameState): { itemId: string | null; distance: number } {
|
||||||
|
let nearest: string | null = null;
|
||||||
|
let minDist = Infinity;
|
||||||
|
for (const [id, item] of state.items.entries()) {
|
||||||
|
if (item.carrierId) continue;
|
||||||
|
const dist = Math.hypot(item.x - state.player.x, item.y - state.player.y);
|
||||||
|
if (dist < minDist) {
|
||||||
|
minDist = dist;
|
||||||
|
nearest = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { itemId: nearest, distance: minDist };
|
||||||
|
}
|
||||||
124
client/src/styles.css
Normal file
124
client/src/styles.css
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family: "Courier New", Courier, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: radial-gradient(circle at top, #1f2937, #0b1220 50%, #030712);
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
width: min(860px, 100%);
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#appVersion {
|
||||||
|
display: block;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#deviceSummary {
|
||||||
|
color: #94a3b8;
|
||||||
|
margin: 0 auto 0.75rem;
|
||||||
|
min-height: 2.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#deviceSummary p {
|
||||||
|
margin: 0.15rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nickname-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nickname-row label {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nickname-row input {
|
||||||
|
background: #111827;
|
||||||
|
color: #e5e7eb;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
width: min(320px, 70vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
select {
|
||||||
|
background: #1d4ed8;
|
||||||
|
color: white;
|
||||||
|
border: 1px solid #3b82f6;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
background: #475569;
|
||||||
|
border-color: #64748b;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
background: #111827;
|
||||||
|
border: 2px solid #60a5fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 0 20px rgb(96 165 250 / 35%);
|
||||||
|
}
|
||||||
|
|
||||||
|
#status {
|
||||||
|
height: 2rem;
|
||||||
|
color: #86efac;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#instructions {
|
||||||
|
color: #94a3b8;
|
||||||
|
text-align: left;
|
||||||
|
margin: 0.75rem auto;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settingsModal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(0 0 0 / 70%);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
min-width: 300px;
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
173
client/src/webrtc/peerManager.ts
Normal file
173
client/src/webrtc/peerManager.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { AudioEngine, type SpatialPeerRuntime } from '../audio/audioEngine';
|
||||||
|
import type { RemoteUser } from '../network/protocol';
|
||||||
|
|
||||||
|
export type PeerRuntime = SpatialPeerRuntime & {
|
||||||
|
id: string;
|
||||||
|
pc: RTCPeerConnection;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SendSignal = (targetId: string, payload: { sdp?: RTCSessionDescriptionInit; ice?: RTCIceCandidateInit }) => void;
|
||||||
|
|
||||||
|
type StatusHandler = (message: string) => void;
|
||||||
|
|
||||||
|
export class PeerManager {
|
||||||
|
private readonly peers = new Map<string, PeerRuntime>();
|
||||||
|
private outputDeviceId = '';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly audio: AudioEngine,
|
||||||
|
private readonly sendSignal: SendSignal,
|
||||||
|
private readonly getLocalStream: () => MediaStream | null,
|
||||||
|
private readonly status: StatusHandler,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getPeer(id: string): PeerRuntime | undefined {
|
||||||
|
return this.peers.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPeers(): Iterable<PeerRuntime> {
|
||||||
|
return this.peers.values();
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOrGetPeer(targetId: string, isInitiator: boolean, userData: Partial<RemoteUser>): Promise<PeerRuntime> {
|
||||||
|
const existing = this.peers.get(targetId);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
|
||||||
|
|
||||||
|
const peer: PeerRuntime = {
|
||||||
|
id: targetId,
|
||||||
|
nickname: userData.nickname ?? 'user...',
|
||||||
|
x: userData.x ?? 20,
|
||||||
|
y: userData.y ?? 20,
|
||||||
|
pc,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.peers.set(targetId, peer);
|
||||||
|
|
||||||
|
const stream = this.getLocalStream();
|
||||||
|
if (stream) {
|
||||||
|
stream.getTracks().forEach((track) => pc.addTrack(track, stream));
|
||||||
|
}
|
||||||
|
|
||||||
|
pc.onicecandidate = (event) => {
|
||||||
|
if (event.candidate) {
|
||||||
|
this.sendSignal(targetId, { ice: event.candidate.toJSON() });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pc.ontrack = async (event) => {
|
||||||
|
await this.audio.attachRemoteStream(peer, event.streams[0], this.outputDeviceId);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isInitiator) {
|
||||||
|
let offer = await pc.createOffer();
|
||||||
|
offer = this.tuneOpus(offer);
|
||||||
|
await pc.setLocalDescription(offer);
|
||||||
|
this.sendSignal(targetId, { sdp: pc.localDescription ?? undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
return peer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleSignal(data: {
|
||||||
|
senderId: string;
|
||||||
|
senderNickname?: string;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
sdp?: RTCSessionDescriptionInit;
|
||||||
|
ice?: RTCIceCandidateInit;
|
||||||
|
}): Promise<PeerRuntime> {
|
||||||
|
const peer = await this.createOrGetPeer(data.senderId, false, {
|
||||||
|
id: data.senderId,
|
||||||
|
nickname: data.senderNickname,
|
||||||
|
x: data.x,
|
||||||
|
y: data.y,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.sdp) {
|
||||||
|
await peer.pc.setRemoteDescription(new RTCSessionDescription(data.sdp));
|
||||||
|
if (data.sdp.type === 'offer') {
|
||||||
|
let answer = await peer.pc.createAnswer();
|
||||||
|
answer = this.tuneOpus(answer);
|
||||||
|
await peer.pc.setLocalDescription(answer);
|
||||||
|
this.sendSignal(data.senderId, { sdp: peer.pc.localDescription ?? undefined });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.ice) {
|
||||||
|
await peer.pc.addIceCandidate(new RTCIceCandidate(data.ice)).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
return peer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async replaceOutgoingTrack(stream: MediaStream): Promise<void> {
|
||||||
|
for (const peer of this.peers.values()) {
|
||||||
|
const sender = peer.pc.getSenders().find((candidate) => candidate.track?.kind === 'audio');
|
||||||
|
const newTrack = stream.getAudioTracks()[0];
|
||||||
|
if (sender && newTrack) {
|
||||||
|
await sender.replaceTrack(newTrack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removePeer(id: string): void {
|
||||||
|
const peer = this.peers.get(id);
|
||||||
|
if (!peer) return;
|
||||||
|
peer.pc.close();
|
||||||
|
this.audio.cleanupPeerAudio(peer);
|
||||||
|
this.peers.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupAll(): void {
|
||||||
|
for (const id of this.peers.keys()) {
|
||||||
|
this.removePeer(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setPeerPosition(id: string, x: number, y: number): void {
|
||||||
|
const peer = this.peers.get(id);
|
||||||
|
if (!peer) return;
|
||||||
|
peer.x = x;
|
||||||
|
peer.y = y;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPeerNickname(id: string, nickname: string): void {
|
||||||
|
const peer = this.peers.get(id);
|
||||||
|
if (!peer) return;
|
||||||
|
peer.nickname = nickname;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setOutputDevice(deviceId: string): Promise<void> {
|
||||||
|
this.outputDeviceId = deviceId;
|
||||||
|
for (const peer of this.peers.values()) {
|
||||||
|
if (!peer.audioElement) continue;
|
||||||
|
const sinkTarget = peer.audioElement as HTMLMediaElement & {
|
||||||
|
setSinkId?: (id: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
await sinkTarget.setSinkId?.(deviceId).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private tuneOpus(desc: RTCSessionDescriptionInit): RTCSessionDescriptionInit {
|
||||||
|
if (!desc.sdp) return desc;
|
||||||
|
const lines = desc.sdp.split('\r\n');
|
||||||
|
let opusPayload: string | undefined;
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.includes('opus/48000')) {
|
||||||
|
const match = line.match(/(\d+) opus\/48000/);
|
||||||
|
if (match) opusPayload = match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (opusPayload) {
|
||||||
|
for (let index = 0; index < lines.length; index += 1) {
|
||||||
|
if (lines[index].includes(`a=fmtp:${opusPayload}`)) {
|
||||||
|
lines[index] += ';maxaveragebitrate=128000;stereo=1;sprop-stereo=1;useinbandfec=1;usedtx=0';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...desc, sdp: lines.join('\r\n') };
|
||||||
|
}
|
||||||
|
}
|
||||||
14
client/tsconfig.json
Normal file
14
client/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
17
client/vite.config.ts
Normal file
17
client/vite.config.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
const base = process.env.VITE_BASE_PATH || '/';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base,
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/ws': {
|
||||||
|
target: 'ws://127.0.0.1:8765',
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
116
deploy/README.md
Normal file
116
deploy/README.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# Deployment Guide
|
||||||
|
|
||||||
|
Target example: AlmaLinux/cPanel host with files under `/home/bestmidi`.
|
||||||
|
|
||||||
|
## 1) Place project files
|
||||||
|
- Repo root: `/home/bestmidi/chgrid`
|
||||||
|
|
||||||
|
## 2) Make deploy scripts executable (once)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/bestmidi/chgrid
|
||||||
|
chmod +x deploy/scripts/*.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3) Install server (uv)
|
||||||
|
|
||||||
|
Verify server files first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -l /home/bestmidi/chgrid/server/pyproject.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Run install scripts from repo root (`/home/bestmidi/chgrid`), not from `server/`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/bestmidi/chgrid
|
||||||
|
./deploy/scripts/install_server.sh /home/bestmidi/chgrid
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Script defaults to Python `3.13` (`PYTHON_SPEC=3.13`).
|
||||||
|
- It reuses existing `.venv` instead of replacing it interactively.
|
||||||
|
- If you need to force a fresh 3.13 env:
|
||||||
|
- `rm -rf /home/bestmidi/chgrid/server/.venv`
|
||||||
|
- rerun `./deploy/scripts/install_server.sh /home/bestmidi/chgrid`
|
||||||
|
|
||||||
|
This creates:
|
||||||
|
- `/home/bestmidi/chgrid/server/.venv`
|
||||||
|
- `/home/bestmidi/chgrid/server/config.toml` (if missing)
|
||||||
|
|
||||||
|
Edit `/home/bestmidi/chgrid/server/config.toml`:
|
||||||
|
- `server.bind_ip = "127.0.0.1"`
|
||||||
|
- `server.port = 8765`
|
||||||
|
- `network.allow_insecure_ws = true`
|
||||||
|
- `tls.cert_file = ""`
|
||||||
|
- `tls.key_file = ""`
|
||||||
|
- `storage.state_file = "runtime/items.json"`
|
||||||
|
|
||||||
|
## 4) Build and publish client
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/bestmidi/chgrid
|
||||||
|
./deploy/scripts/deploy_client.sh /home/bestmidi/chgrid /home/bestmidi/public_html/chgrid /chgrid/
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Third arg is Vite base path for production assets.
|
||||||
|
- For `https://bestmidi.com/chgrid/`, use `/chgrid/`.
|
||||||
|
- For site root deploy (`https://bestmidi.com/`), use `/`.
|
||||||
|
|
||||||
|
## 5) Install/restart signaling service (systemd)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/bestmidi/chgrid
|
||||||
|
./deploy/scripts/install_service.sh /home/bestmidi/chgrid
|
||||||
|
```
|
||||||
|
|
||||||
|
Logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -u chgrid-signaling.service -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6) Apache websocket proxy
|
||||||
|
|
||||||
|
Install using script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/bestmidi/chgrid
|
||||||
|
./deploy/scripts/install_apache.sh \
|
||||||
|
/home/bestmidi/chgrid \
|
||||||
|
/etc/apache2/conf.d/userdata/ssl/2_4/bestmidi/yourdomain.com/chgrid.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Replace `yourdomain.com` with your real domain.
|
||||||
|
- Script copies `deploy/apache/chgrid-vhost-snippet.conf`, runs `rebuildhttpdconf`, then restarts Apache via WHM restart command.
|
||||||
|
|
||||||
|
## 7) Optional HTTPS relay for HTTP radio streams
|
||||||
|
|
||||||
|
If stream sources are plain HTTP (for example ports `8000`, `8010`, `8020`, `8030`), add relays in:
|
||||||
|
|
||||||
|
`/etc/apache2/conf.d/userdata/ssl/2_4/bestmidi/bestmidi.com/chgrid.conf`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```apache
|
||||||
|
ProxyPass /listen/8000/ http://127.0.0.1:8000/
|
||||||
|
ProxyPassReverse /listen/8000/ http://127.0.0.1:8000/
|
||||||
|
ProxyPass /listen/8010/ http://127.0.0.1:8010/
|
||||||
|
ProxyPassReverse /listen/8010/ http://127.0.0.1:8010/
|
||||||
|
ProxyPass /listen/8020/ http://127.0.0.1:8020/
|
||||||
|
ProxyPassReverse /listen/8020/ http://127.0.0.1:8020/
|
||||||
|
ProxyPass /listen/8030/ http://127.0.0.1:8030/
|
||||||
|
ProxyPassReverse /listen/8030/ http://127.0.0.1:8030/
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo /usr/local/cpanel/scripts/rebuildhttpdconf
|
||||||
|
sudo /usr/local/cpanel/scripts/restartsrv_httpd
|
||||||
|
```
|
||||||
|
|
||||||
|
Usage example in Chat Grid:
|
||||||
|
- `https://bestmidi.com/listen/8000/stream`
|
||||||
7
deploy/apache/chgrid-vhost-snippet.conf
Normal file
7
deploy/apache/chgrid-vhost-snippet.conf
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Add inside your SSL VirtualHost include for bestmidi.com.
|
||||||
|
# Keep your existing main DocumentRoot unchanged when hosting Chat Grid under /chgrid.
|
||||||
|
# Required modules: proxy, proxy_http, proxy_wstunnel
|
||||||
|
|
||||||
|
# Proxy websocket signaling endpoint to local Python service.
|
||||||
|
ProxyPass /ws ws://127.0.0.1:8765
|
||||||
|
ProxyPassReverse /ws ws://127.0.0.1:8765
|
||||||
27
deploy/scripts/deploy_client.sh
Executable file
27
deploy/scripts/deploy_client.sh
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="${1:-/home/bestmidi/chgrid}"
|
||||||
|
PUBLISH_DIR="${2:-/home/bestmidi/public_html/chgrid}"
|
||||||
|
BASE_PATH="${3:-/chgrid/}"
|
||||||
|
CLIENT_DIR="$REPO_ROOT/client"
|
||||||
|
|
||||||
|
if [[ ! -d "$CLIENT_DIR" ]]; then
|
||||||
|
echo "error: client directory not found: $CLIENT_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v rsync >/dev/null 2>&1; then
|
||||||
|
echo "error: rsync is required but not found in PATH" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$CLIENT_DIR"
|
||||||
|
npm install
|
||||||
|
VITE_BASE_PATH="$BASE_PATH" npm run build
|
||||||
|
|
||||||
|
mkdir -p "$PUBLISH_DIR"
|
||||||
|
rsync -a --delete dist/ "$PUBLISH_DIR/"
|
||||||
|
|
||||||
|
echo "client deploy complete: $PUBLISH_DIR"
|
||||||
|
echo "client base path: $BASE_PATH"
|
||||||
41
deploy/scripts/install_apache.sh
Executable file
41
deploy/scripts/install_apache.sh
Executable file
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="${1:-/home/bestmidi/chgrid}"
|
||||||
|
INCLUDE_PATH="${2:-}"
|
||||||
|
RESTART_CMD="${3:-/usr/local/cpanel/scripts/restartsrv_httpd}"
|
||||||
|
SNIPPET_PATH="$REPO_ROOT/deploy/apache/chgrid-vhost-snippet.conf"
|
||||||
|
|
||||||
|
if [[ -z "$INCLUDE_PATH" ]]; then
|
||||||
|
echo "usage: $0 <repo_root> <apache_include_path> [restart_cmd]" >&2
|
||||||
|
echo "example: $0 /home/bestmidi/chgrid /etc/apache2/conf.d/userdata/ssl/2_4/bestmidi/example.com/chgrid.conf" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$SNIPPET_PATH" ]]; then
|
||||||
|
echo "error: snippet not found: $SNIPPET_PATH" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo mkdir -p "$(dirname "$INCLUDE_PATH")"
|
||||||
|
sudo cp "$SNIPPET_PATH" "$INCLUDE_PATH"
|
||||||
|
|
||||||
|
echo "installed apache include: $INCLUDE_PATH"
|
||||||
|
|
||||||
|
if [[ -x /usr/local/cpanel/scripts/rebuildhttpdconf ]]; then
|
||||||
|
sudo /usr/local/cpanel/scripts/rebuildhttpdconf
|
||||||
|
else
|
||||||
|
echo "warning: /usr/local/cpanel/scripts/rebuildhttpdconf not found; skipping rebuild" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -x "$RESTART_CMD" ]]; then
|
||||||
|
sudo "$RESTART_CMD"
|
||||||
|
elif [[ -x /scripts/restartsrv_httpd ]]; then
|
||||||
|
sudo /scripts/restartsrv_httpd
|
||||||
|
else
|
||||||
|
echo "error: apache restart command not found" >&2
|
||||||
|
echo "tried: $RESTART_CMD and /scripts/restartsrv_httpd" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "apache include applied and apache restarted"
|
||||||
53
deploy/scripts/install_server.sh
Executable file
53
deploy/scripts/install_server.sh
Executable file
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="${1:-/home/bestmidi/chgrid}"
|
||||||
|
SERVER_DIR="$REPO_ROOT/server"
|
||||||
|
PYTHON_SPEC="${PYTHON_SPEC:-3.13}"
|
||||||
|
|
||||||
|
if ! command -v uv >/dev/null 2>&1; then
|
||||||
|
echo "error: uv is required but not found in PATH" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -d "$SERVER_DIR" ]]; then
|
||||||
|
echo "error: server directory not found: $SERVER_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! -f "$SERVER_DIR/pyproject.toml" ]]; then
|
||||||
|
echo "error: missing $SERVER_DIR/pyproject.toml" >&2
|
||||||
|
echo " verify repository files were copied to /home/bestmidi/chgrid/server" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$SERVER_DIR"
|
||||||
|
|
||||||
|
# Avoid interactive prompts: reuse existing venv; create only when missing.
|
||||||
|
if [[ ! -d .venv ]]; then
|
||||||
|
uv venv .venv --python "$PYTHON_SPEC"
|
||||||
|
echo "created .venv with Python $PYTHON_SPEC"
|
||||||
|
else
|
||||||
|
echo "using existing .venv"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -x .venv/bin/python ]]; then
|
||||||
|
VENV_PYTHON_VERSION="$(
|
||||||
|
.venv/bin/python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")'
|
||||||
|
)"
|
||||||
|
if [[ "$VENV_PYTHON_VERSION" != "$PYTHON_SPEC" ]]; then
|
||||||
|
echo "warning: .venv uses Python $VENV_PYTHON_VERSION (requested $PYTHON_SPEC)" >&2
|
||||||
|
echo " remove .venv and rerun script to recreate with Python $PYTHON_SPEC" >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
uv sync --no-dev --project "$SERVER_DIR"
|
||||||
|
|
||||||
|
if [[ ! -f config.toml ]]; then
|
||||||
|
cp config.example.toml config.toml
|
||||||
|
echo "created $SERVER_DIR/config.toml from template"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p runtime
|
||||||
|
|
||||||
|
echo "server install complete"
|
||||||
|
echo "next: edit $SERVER_DIR/config.toml (TLS, bind_ip, port)"
|
||||||
18
deploy/scripts/install_service.sh
Executable file
18
deploy/scripts/install_service.sh
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="${1:-/home/bestmidi/chgrid}"
|
||||||
|
UNIT_NAME="${2:-chgrid-signaling.service}"
|
||||||
|
SRC_UNIT="$REPO_ROOT/deploy/systemd/$UNIT_NAME"
|
||||||
|
DST_UNIT="/etc/systemd/system/$UNIT_NAME"
|
||||||
|
|
||||||
|
if [[ ! -f "$SRC_UNIT" ]]; then
|
||||||
|
echo "error: unit file not found: $SRC_UNIT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo cp "$SRC_UNIT" "$DST_UNIT"
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now "$UNIT_NAME"
|
||||||
|
sudo systemctl restart "$UNIT_NAME"
|
||||||
|
sudo systemctl status "$UNIT_NAME" --no-pager
|
||||||
16
deploy/systemd/chgrid-client-preview.service
Normal file
16
deploy/systemd/chgrid-client-preview.service
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=chgrid client preview server (vite)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=chgrid
|
||||||
|
Group=chgrid
|
||||||
|
WorkingDirectory=/opt/chgrid/client
|
||||||
|
Environment=PATH=/usr/bin:/bin
|
||||||
|
ExecStart=/usr/bin/npm run preview -- --host 0.0.0.0 --port 4173
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
16
deploy/systemd/chgrid-signaling.service
Normal file
16
deploy/systemd/chgrid-signaling.service
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=chgrid signaling server
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=bestmidi
|
||||||
|
Group=bestmidi
|
||||||
|
WorkingDirectory=/home/bestmidi/chgrid/server
|
||||||
|
Environment=PATH=/home/bestmidi/chgrid/server/.venv/bin:/usr/bin:/bin
|
||||||
|
ExecStart=/home/bestmidi/chgrid/server/.venv/bin/python main.py --config /home/bestmidi/chgrid/server/config.toml
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
118
docs/item-schema.md
Normal file
118
docs/item-schema.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# Item Schema
|
||||||
|
|
||||||
|
## World Item (server-authoritative)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "string",
|
||||||
|
"type": "radio_station | dice",
|
||||||
|
"title": "string",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"createdBy": "user-id",
|
||||||
|
"createdAt": 1735689600000,
|
||||||
|
"updatedAt": 1735689600000,
|
||||||
|
"version": 1,
|
||||||
|
"capabilities": ["editable", "carryable", "deletable", "usable"],
|
||||||
|
"useSound": "sounds/roll.ogg",
|
||||||
|
"params": {},
|
||||||
|
"carrierId": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `useSound`: optional client-played sound path when item `use` succeeds; global item field and not user-editable in V1.
|
||||||
|
- `capabilities` and `useSound` are derived from global item-type definitions at runtime (not stored per-instance in persisted state).
|
||||||
|
|
||||||
|
## Persisted Item State (`server/runtime/items.json`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "string",
|
||||||
|
"type": "radio_station | dice",
|
||||||
|
"title": "string",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"createdBy": "user-id",
|
||||||
|
"createdAt": 1735689600000,
|
||||||
|
"updatedAt": 1735689600000,
|
||||||
|
"version": 1,
|
||||||
|
"params": {},
|
||||||
|
"carrierId": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Persisted state stores only instance data.
|
||||||
|
- Global/type-level properties are loaded from server registry in `server/app/item_catalog.py`.
|
||||||
|
|
||||||
|
## Type Params
|
||||||
|
|
||||||
|
### `radio_station`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"streamUrl": "",
|
||||||
|
"enabled": true,
|
||||||
|
"volume": 50
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `streamUrl`: string, empty allowed until configured.
|
||||||
|
- `enabled`: boolean on/off flag.
|
||||||
|
- UI behavior: in property menu, `Enter` toggles on/off directly.
|
||||||
|
- `volume`: integer, range `0-100`, default `50`.
|
||||||
|
|
||||||
|
### `dice`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sides": 6,
|
||||||
|
"number": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `sides`: integer, range `1-100`.
|
||||||
|
- `number`: integer, range `1-100`.
|
||||||
|
|
||||||
|
## Packet Shapes
|
||||||
|
|
||||||
|
- `item_upsert`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "item_upsert",
|
||||||
|
"item": { "..." : "World Item" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `item_remove`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "item_remove",
|
||||||
|
"itemId": "item-id"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `item_action_result`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "item_action_result",
|
||||||
|
"ok": true,
|
||||||
|
"action": "add | pickup | drop | delete | use | update",
|
||||||
|
"message": "human-readable status",
|
||||||
|
"itemId": "optional-item-id"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `item_use_sound`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "item_use_sound",
|
||||||
|
"itemId": "item-id",
|
||||||
|
"sound": "sounds/roll.ogg",
|
||||||
|
"x": 12,
|
||||||
|
"y": 8
|
||||||
|
}
|
||||||
|
```
|
||||||
33
docs/local.md
Normal file
33
docs/local.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Local Development
|
||||||
|
|
||||||
|
## Start Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/jjm/code/chgrid/server
|
||||||
|
.venv/bin/python main.py --config config.toml --port 8765
|
||||||
|
```
|
||||||
|
|
||||||
|
## Start Client
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/jjm/code/chgrid/client
|
||||||
|
npm run dev -- --host 0.0.0.0 --port 5173
|
||||||
|
```
|
||||||
|
|
||||||
|
Open: `http://localhost:5173`
|
||||||
|
|
||||||
|
## Quick Restarts
|
||||||
|
|
||||||
|
Server:
|
||||||
|
```bash
|
||||||
|
lsof -tiTCP:8765 -sTCP:LISTEN | xargs -r kill
|
||||||
|
cd /home/jjm/code/chgrid/server
|
||||||
|
nohup .venv/bin/python main.py --config config.toml --port 8765 > /tmp/chgrid-server.log 2>&1 &
|
||||||
|
```
|
||||||
|
|
||||||
|
Client:
|
||||||
|
```bash
|
||||||
|
lsof -tiTCP:5173 -sTCP:LISTEN | xargs -r kill
|
||||||
|
cd /home/jjm/code/chgrid/client
|
||||||
|
nohup npm run dev -- --host 0.0.0.0 --port 5173 > /tmp/chgrid-client.log 2>&1 &
|
||||||
|
```
|
||||||
92
item.md
Normal file
92
item.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# Chat Grid Item System Plan
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
- Add world items without hard-coding every new feature.
|
||||||
|
- Start with `radio_station` as the first real item type.
|
||||||
|
- Keep design compatible with future carry/use/object mechanics.
|
||||||
|
|
||||||
|
## Commands (V1)
|
||||||
|
- `A`: Add-item mode.
|
||||||
|
- Opens list of available item types.
|
||||||
|
- `Enter` places selected type on current square.
|
||||||
|
- `O`: Edit item properties on current square.
|
||||||
|
- If one item on square: open property edit mode for that item.
|
||||||
|
- If multiple items: open selector first, then property edit.
|
||||||
|
- `U`: Use item on current square (or held item if carrying one).
|
||||||
|
- If multiple usable items are available, open selector first.
|
||||||
|
- V1 behavior: implemented for `dice`; `radio_station` is configurable but not "used" yet.
|
||||||
|
- `Shift+U`: List connected users (moves old `U` users-list behavior here).
|
||||||
|
- `I`: Locate nearest item (name/type, distance, direction, coordinates).
|
||||||
|
- `Shift+I`: List items mode (nearest-first; arrows navigate; `Enter` focuses/moves, same pattern as user list).
|
||||||
|
- `D`: Carry/drop toggle.
|
||||||
|
- If not carrying: pick up one item from current square.
|
||||||
|
- If carrying: drop held item on current square.
|
||||||
|
- If multiple items exist on square, open short selector first.
|
||||||
|
- `Shift+D`: Delete item on current square.
|
||||||
|
- If multiple items, open selector first, then delete selected item.
|
||||||
|
|
||||||
|
## Add Flow Options
|
||||||
|
- Option 1: Add with required properties immediately.
|
||||||
|
- Pros: item is valid at creation time.
|
||||||
|
- Cons: slower flow due to prompts.
|
||||||
|
- Option 2: Add placeholder first, then edit with `O`. (Recommended for V1)
|
||||||
|
- Pros: faster placement, cleaner keyboard flow, scales to many item types.
|
||||||
|
- Cons: requires incomplete-item handling.
|
||||||
|
|
||||||
|
### Recommended V1 behavior
|
||||||
|
- `A` places item immediately with defaults.
|
||||||
|
- `radio_station` defaults:
|
||||||
|
- `title`: `New station`
|
||||||
|
- `params.streamUrl`: empty string (no default URL)
|
||||||
|
- `params.enabled`: `true`
|
||||||
|
- `params.volume`: `50`
|
||||||
|
- Incomplete rule:
|
||||||
|
- Item exists in world, but does not activate until required params are set.
|
||||||
|
- `O` is the standard command to complete/update params.
|
||||||
|
|
||||||
|
## Property Editor (`O`) Behavior
|
||||||
|
- `O` opens a property list for the selected item.
|
||||||
|
- Arrow keys move between properties.
|
||||||
|
- Focused property announces: property name + current value.
|
||||||
|
- `Enter` on a property starts edit mode for that value.
|
||||||
|
- For switch properties (V1: `radio_station.enabled`), `Enter` toggles directly between `on` and `off`.
|
||||||
|
- `Enter` saves value after validation.
|
||||||
|
- `Escape` exits edit mode or closes the property menu.
|
||||||
|
- Validation failures are announced and also pushed to message buffer.
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### Global fields (all item types)
|
||||||
|
- `id`: unique item id.
|
||||||
|
- `type`: item type key (ex: `radio_station`, `dice`).
|
||||||
|
- `title`: spoken/display label.
|
||||||
|
- `x`, `y`: world position.
|
||||||
|
- `createdBy`, `createdAt`, `updatedAt`.
|
||||||
|
- `version`: schema version for migration.
|
||||||
|
- `capabilities`: list of supported actions (examples: `editable`, `carryable`, `usable`, `deletable`).
|
||||||
|
- `useSound`: optional sound path played on successful `U` use (global field, not editable in V1).
|
||||||
|
- `params`: per-type payload object.
|
||||||
|
|
||||||
|
### Per-item fields (inside `params`)
|
||||||
|
- `radio_station` (V1):
|
||||||
|
- `streamUrl` (required for playback; may be empty until configured)
|
||||||
|
- `enabled` (boolean on/off flag)
|
||||||
|
- `volume` (number `0-100`, default `50`)
|
||||||
|
- future: `filter`.
|
||||||
|
- `dice` (V1):
|
||||||
|
- `sides` (number, default `6`, range `1-100`)
|
||||||
|
- `number` (number of dice, default `2`, range `1-100`)
|
||||||
|
- `dice` (future example):
|
||||||
|
- optional future: `lastRoll`, `rollMode`, `modifier`.
|
||||||
|
|
||||||
|
## Networking and Authority
|
||||||
|
- Server-authoritative item state.
|
||||||
|
- Client sends intent packets (`add`, `pickup`, `drop`, `delete`, later `use`).
|
||||||
|
- Server validates and returns:
|
||||||
|
- success result + broadcast item state update, or
|
||||||
|
- reject result with reason (also added to message buffer).
|
||||||
|
|
||||||
|
## Why this structure
|
||||||
|
- Stable global schema with extensible `params`.
|
||||||
|
- New item types can be added without changing core item pipeline.
|
||||||
|
- Supports shared multiplayer consistency and future inventory/carry rules.
|
||||||
101
refactor.md
Normal file
101
refactor.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Rewrite Plan: Modern Cross-Browser Spatial Grid
|
||||||
|
|
||||||
|
## What This Code Is Today
|
||||||
|
The current code is a functional prototype: one large HTML file with tightly coupled UI/game/network/audio logic, plus a single Python signaling script. It proves the concept, but it is hard to test, hard to evolve safely, and brittle across browser differences.
|
||||||
|
At its core, the product is a realtime spatial chat grid with movement and command-driven interaction.
|
||||||
|
|
||||||
|
## Rewrite Strategy (No Backward-Compatibility Constraints)
|
||||||
|
Build a new app in parallel, then cut over once parity + quality gates pass.
|
||||||
|
V1 explicitly ships without TURN relay infrastructure.
|
||||||
|
Design the new core so future features (objects, pickups, walls, collisions, and interaction rules) can be added without architectural rework.
|
||||||
|
|
||||||
|
## Target Architecture
|
||||||
|
- `client/`: TypeScript + Vite + Canvas renderer + state store.
|
||||||
|
- `server/`: Python signaling service using `websockets` (schema-validated).
|
||||||
|
- `shared/`: Message contracts (JSON schema + generated TS types).
|
||||||
|
- `tests/`: Unit + Playwright E2E (Chromium, Firefox, WebKit).
|
||||||
|
- `docs/`: Browser compatibility matrix and ops runbook.
|
||||||
|
- Core domain model:
|
||||||
|
- world map + tile metadata (walkable/blocked/zone)
|
||||||
|
- entities (`player`, `object`, future NPC/system entities)
|
||||||
|
- actions/commands (move, rename, locate, interact, pickup, use)
|
||||||
|
- simulation rules (movement, collision, proximity effects)
|
||||||
|
|
||||||
|
## Technology Choices
|
||||||
|
- Client: TypeScript, Vite, ESLint, Prettier, Vitest.
|
||||||
|
- Realtime: WebSocket signaling + WebRTC media.
|
||||||
|
- Server runtime: Python + `websockets`.
|
||||||
|
- Validation: Zod/JSON Schema for all inbound/outbound messages.
|
||||||
|
- Deployability: Environment-based config only (no hardcoded cert paths).
|
||||||
|
- ICE for v1: STUN-only (`stun:stun.l.google.com:19302`) with robust retry and failure handling.
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
### Phase 1: Parity Baseline + Browser Hardening (5-8 days)
|
||||||
|
- Scaffold monorepo structure.
|
||||||
|
- Define protocol schemas: `welcome`, `signal`, `update_position`, `update_nickname`, `user_left`.
|
||||||
|
- Implement strict server validation and structured logging.
|
||||||
|
- Rebuild current behavior with parity:
|
||||||
|
- grid render + movement + presence sync
|
||||||
|
- existing commands: `c`, `l`, `shift+l`, `u`, `n`, `m`, `escape`
|
||||||
|
- nickname flow and reconnect/disconnect behavior
|
||||||
|
- Cross-browser hardening for latest Chrome/Edge/Firefox/Safari:
|
||||||
|
- keyboard handling via `event.code`
|
||||||
|
- capability checks for `setSinkId`, `StereoPannerNode`, autoplay/permissions differences
|
||||||
|
- explicit no-TURN recovery UX and bounded retry/backoff
|
||||||
|
- Keep grid/presence functional even if voice fails on restrictive networks.
|
||||||
|
- V1 server requirements (Python-focused):
|
||||||
|
- Use Python `websockets` for signaling transport.
|
||||||
|
- Enforce strict message validation (Pydantic/JSON schema) on receive and send.
|
||||||
|
- Add structured logging and websocket behavior tests.
|
||||||
|
|
||||||
|
### Phase 2: World + Extensibility Architecture (3-5 days)
|
||||||
|
- Introduce world + entity foundation:
|
||||||
|
- tile map abstraction with collision checks
|
||||||
|
- player entity and object entity schema
|
||||||
|
- action dispatcher for current commands and future interactions
|
||||||
|
- Keep simulation pure and testable (state in, state out).
|
||||||
|
|
||||||
|
### Phase 3: Advanced Audio + WebRTC Robustness (2-4 days)
|
||||||
|
- Implement peer connection manager and retry/timeout policy.
|
||||||
|
- Build browser capability layer:
|
||||||
|
- `setSinkId` optional
|
||||||
|
- `StereoPannerNode` optional
|
||||||
|
- autoplay/promise failure handling
|
||||||
|
- Graceful degradation: if unsupported, keep grid/presence fully functional and reduce to basic audio.
|
||||||
|
- Implement explicit no-TURN recovery UX:
|
||||||
|
- Detect ICE `failed`/`disconnected` states and auto-retry with bounded backoff.
|
||||||
|
- Surface actionable status text (network-restricted, retrying, voice unavailable).
|
||||||
|
- Keep text/status + grid presence fully functional when voice cannot connect.
|
||||||
|
|
||||||
|
### Phase 4: Quality Gates (2-4 days)
|
||||||
|
- Unit tests for state, protocol, input, and audio math.
|
||||||
|
- Playwright multi-user E2E in Chromium/Firefox/WebKit.
|
||||||
|
- Add CI for lint, typecheck, unit tests, and cross-browser smoke tests.
|
||||||
|
- Add world-rule tests:
|
||||||
|
- wall collision and blocked-tile movement rejection
|
||||||
|
- object pickup/use action validation
|
||||||
|
- deterministic command outcomes
|
||||||
|
|
||||||
|
### Phase 5: Cutover (1-2 days)
|
||||||
|
- Deploy rewrite behind new route or domain.
|
||||||
|
- Run soak tests and monitor connection/error metrics.
|
||||||
|
- Decommission old prototype once stable.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
- Grid + movement + presence stable in latest Chrome, Edge, Firefox, Safari.
|
||||||
|
- Audio works where supported and degrades cleanly where not.
|
||||||
|
- Zero runtime dependence on inline scripts or CDN Tailwind runtime.
|
||||||
|
- One-command local startup and passing CI.
|
||||||
|
- Known no-TURN limitation documented: some restrictive NAT/firewall networks may not establish voice.
|
||||||
|
|
||||||
|
## Post-v1 TURN Trigger
|
||||||
|
Add TURN when either condition is met:
|
||||||
|
- Voice connection failure rate exceeds agreed threshold in production telemetry.
|
||||||
|
- Target users include enterprise/school/mobile networks where relay need is expected.
|
||||||
|
|
||||||
|
## Recommended First PR
|
||||||
|
Create repo skeleton + protocol schema + server validator + parity client slice that renders grid, syncs positions, and supports current commands.
|
||||||
|
|
||||||
|
## Recommended Second PR
|
||||||
|
Add browser hardening completion (capability fallbacks, reconnect UX) and Playwright parity tests across Chromium/Firefox/WebKit.
|
||||||
35
server/README.md
Normal file
35
server/README.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# chgrid signaling server
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
Copy `config.example.toml` to `config.toml` and set values.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
cp config.example.toml config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Key options:
|
||||||
|
- `server.bind_ip`, `server.port`
|
||||||
|
- `network.max_message_bytes`
|
||||||
|
- `network.allow_insecure_ws`
|
||||||
|
- `tls.cert_file`, `tls.key_file`
|
||||||
|
|
||||||
|
If `network.allow_insecure_ws = false`, TLS cert/key are required and server runs as `wss://`.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -e .[dev]
|
||||||
|
python main.py --config config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI overrides
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python main.py --config config.toml --host 127.0.0.1 --port 8765
|
||||||
|
python main.py --config config.toml --ssl-cert /path/cert.pem --ssl-key /path/key.pem
|
||||||
|
```
|
||||||
0
server/app/__init__.py
Normal file
0
server/app/__init__.py
Normal file
17
server/app/client.py
Normal file
17
server/app/client.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from websockets.asyncio.server import ServerConnection
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ClientConnection:
|
||||||
|
websocket: ServerConnection
|
||||||
|
id: str
|
||||||
|
nickname: str = "user..."
|
||||||
|
x: int = 20
|
||||||
|
y: int = 20
|
||||||
|
|
||||||
|
def summary(self) -> dict[str, str | int]:
|
||||||
|
return {"id": self.id, "nickname": self.nickname, "x": self.x, "y": self.y}
|
||||||
60
server/app/config.py
Normal file
60
server/app/config.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ServerConfigSection(BaseModel):
|
||||||
|
bind_ip: str = "127.0.0.1"
|
||||||
|
port: int = 8765
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkConfigSection(BaseModel):
|
||||||
|
max_message_bytes: int = Field(default=2_000_000, gt=0)
|
||||||
|
allow_insecure_ws: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class TlsConfigSection(BaseModel):
|
||||||
|
cert_file: str = ""
|
||||||
|
key_file: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class LoggingConfigSection(BaseModel):
|
||||||
|
level: str = "INFO"
|
||||||
|
|
||||||
|
|
||||||
|
class StorageConfigSection(BaseModel):
|
||||||
|
state_file: str = "runtime/items.json"
|
||||||
|
|
||||||
|
|
||||||
|
class AppConfig(BaseModel):
|
||||||
|
server: ServerConfigSection = ServerConfigSection()
|
||||||
|
network: NetworkConfigSection = NetworkConfigSection()
|
||||||
|
tls: TlsConfigSection = TlsConfigSection()
|
||||||
|
logging: LoggingConfigSection = LoggingConfigSection()
|
||||||
|
storage: StorageConfigSection = StorageConfigSection()
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: Path | None) -> AppConfig:
|
||||||
|
if path is None:
|
||||||
|
return AppConfig()
|
||||||
|
|
||||||
|
if not path.exists():
|
||||||
|
raise FileNotFoundError(f"Config file not found: {path}")
|
||||||
|
|
||||||
|
with path.open("rb") as fp:
|
||||||
|
data = tomllib.load(fp)
|
||||||
|
|
||||||
|
config = AppConfig.model_validate(data)
|
||||||
|
|
||||||
|
cert = config.tls.cert_file.strip()
|
||||||
|
key = config.tls.key_file.strip()
|
||||||
|
|
||||||
|
if not config.network.allow_insecure_ws and (not cert or not key):
|
||||||
|
raise ValueError(
|
||||||
|
"TLS is required when network.allow_insecure_ws=false; set tls.cert_file and tls.key_file"
|
||||||
|
)
|
||||||
|
|
||||||
|
return config
|
||||||
34
server/app/item_catalog.py
Normal file
34
server/app/item_catalog.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
ItemType = Literal["radio_station", "dice"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ItemDefinition:
|
||||||
|
default_title: str
|
||||||
|
capabilities: tuple[str, ...]
|
||||||
|
use_sound: str | None
|
||||||
|
default_params: dict
|
||||||
|
|
||||||
|
|
||||||
|
ITEM_DEFINITIONS: dict[ItemType, ItemDefinition] = {
|
||||||
|
"radio_station": ItemDefinition(
|
||||||
|
default_title="radio",
|
||||||
|
capabilities=("editable", "carryable", "deletable"),
|
||||||
|
use_sound=None,
|
||||||
|
default_params={"streamUrl": "", "enabled": True, "volume": 50},
|
||||||
|
),
|
||||||
|
"dice": ItemDefinition(
|
||||||
|
default_title="Dice",
|
||||||
|
capabilities=("editable", "carryable", "deletable", "usable"),
|
||||||
|
use_sound="sounds/roll.ogg",
|
||||||
|
default_params={"sides": 6, "number": 2},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_item_definition(item_type: ItemType) -> ItemDefinition:
|
||||||
|
return ITEM_DEFINITIONS[item_type]
|
||||||
131
server/app/item_service.py
Normal file
131
server/app/item_service.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from copy import deepcopy
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from .client import ClientConnection
|
||||||
|
from .item_catalog import get_item_definition
|
||||||
|
from .models import PersistedWorldItem, WorldItem
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("chgrid.server")
|
||||||
|
|
||||||
|
|
||||||
|
class ItemService:
|
||||||
|
def __init__(self, state_file: Path | None = None):
|
||||||
|
self.state_file = state_file
|
||||||
|
self.items: dict[str, WorldItem] = {}
|
||||||
|
self.load_state()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def now_ms() -> int:
|
||||||
|
return int(time.time() * 1000)
|
||||||
|
|
||||||
|
def default_item(self, client: ClientConnection, item_type: Literal["radio_station", "dice"]) -> WorldItem:
|
||||||
|
item_def = get_item_definition(item_type)
|
||||||
|
now = self.now_ms()
|
||||||
|
return WorldItem(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
type=item_type,
|
||||||
|
title=item_def.default_title,
|
||||||
|
x=client.x,
|
||||||
|
y=client.y,
|
||||||
|
createdBy=client.id,
|
||||||
|
createdAt=now,
|
||||||
|
updatedAt=now,
|
||||||
|
version=1,
|
||||||
|
capabilities=list(item_def.capabilities),
|
||||||
|
useSound=item_def.use_sound,
|
||||||
|
params=deepcopy(item_def.default_params),
|
||||||
|
carrierId=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_item(self, item: WorldItem) -> None:
|
||||||
|
self.items[item.id] = item
|
||||||
|
|
||||||
|
def remove_item(self, item_id: str) -> None:
|
||||||
|
if item_id in self.items:
|
||||||
|
del self.items[item_id]
|
||||||
|
|
||||||
|
def find_carried_item(self, client_id: str) -> WorldItem | None:
|
||||||
|
for item in self.items.values():
|
||||||
|
if item.carrierId == client_id:
|
||||||
|
return item
|
||||||
|
return None
|
||||||
|
|
||||||
|
def items_on_square(self, x: int, y: int) -> list[WorldItem]:
|
||||||
|
return [item for item in self.items.values() if item.carrierId is None and item.x == x and item.y == y]
|
||||||
|
|
||||||
|
def drop_carried_items_for_disconnect(self, client: ClientConnection) -> list[WorldItem]:
|
||||||
|
changed: list[WorldItem] = []
|
||||||
|
for item in self.items.values():
|
||||||
|
if item.carrierId == client.id:
|
||||||
|
item.carrierId = None
|
||||||
|
item.x = client.x
|
||||||
|
item.y = client.y
|
||||||
|
item.updatedAt = self.now_ms()
|
||||||
|
changed.append(item)
|
||||||
|
return changed
|
||||||
|
|
||||||
|
def load_state(self) -> None:
|
||||||
|
if not self.state_file:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if not self.state_file.exists():
|
||||||
|
return
|
||||||
|
raw = json.loads(self.state_file.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
return
|
||||||
|
loaded: dict[str, WorldItem] = {}
|
||||||
|
for entry in raw:
|
||||||
|
persisted = PersistedWorldItem.model_validate(entry)
|
||||||
|
item_def = get_item_definition(persisted.type)
|
||||||
|
item = WorldItem(
|
||||||
|
id=persisted.id,
|
||||||
|
type=persisted.type,
|
||||||
|
title=persisted.title,
|
||||||
|
x=persisted.x,
|
||||||
|
y=persisted.y,
|
||||||
|
createdBy=persisted.createdBy,
|
||||||
|
createdAt=persisted.createdAt,
|
||||||
|
updatedAt=persisted.updatedAt,
|
||||||
|
version=persisted.version,
|
||||||
|
capabilities=list(item_def.capabilities),
|
||||||
|
useSound=item_def.use_sound,
|
||||||
|
params=persisted.params,
|
||||||
|
carrierId=persisted.carrierId,
|
||||||
|
)
|
||||||
|
loaded[item.id] = item
|
||||||
|
self.items = loaded
|
||||||
|
LOGGER.info("loaded %d persisted items from %s", len(self.items), self.state_file)
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.warning("failed to load persisted item state from %s: %s", self.state_file, exc)
|
||||||
|
|
||||||
|
def save_state(self) -> None:
|
||||||
|
if not self.state_file:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
payload = [
|
||||||
|
PersistedWorldItem(
|
||||||
|
id=item.id,
|
||||||
|
type=item.type,
|
||||||
|
title=item.title,
|
||||||
|
x=item.x,
|
||||||
|
y=item.y,
|
||||||
|
createdBy=item.createdBy,
|
||||||
|
createdAt=item.createdAt,
|
||||||
|
updatedAt=item.updatedAt,
|
||||||
|
version=item.version,
|
||||||
|
params=item.params,
|
||||||
|
carrierId=item.carrierId,
|
||||||
|
).model_dump(exclude_none=True)
|
||||||
|
for item in self.items.values()
|
||||||
|
]
|
||||||
|
self.state_file.write_text(json.dumps(payload, ensure_ascii=True, separators=(",", ":")), encoding="utf-8")
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.warning("failed to persist item state to %s: %s", self.state_file, exc)
|
||||||
207
server/app/models.py
Normal file
207
server/app/models.py
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class BasePacket(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
type: str
|
||||||
|
|
||||||
|
|
||||||
|
class SignalPacket(BasePacket):
|
||||||
|
type: Literal["signal"]
|
||||||
|
targetId: str
|
||||||
|
sdp: dict | None = None
|
||||||
|
ice: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UpdatePositionPacket(BasePacket):
|
||||||
|
type: Literal["update_position"]
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateNicknamePacket(BasePacket):
|
||||||
|
type: Literal["update_nickname"]
|
||||||
|
nickname: str = Field(min_length=1, max_length=32)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessagePacket(BasePacket):
|
||||||
|
type: Literal["chat_message"]
|
||||||
|
message: str = Field(min_length=1, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class PingPacket(BasePacket):
|
||||||
|
type: Literal["ping"]
|
||||||
|
clientSentAt: int
|
||||||
|
|
||||||
|
|
||||||
|
class ItemAddPacket(BasePacket):
|
||||||
|
type: Literal["item_add"]
|
||||||
|
itemType: Literal["radio_station", "dice"]
|
||||||
|
|
||||||
|
|
||||||
|
class ItemPickupPacket(BasePacket):
|
||||||
|
type: Literal["item_pickup"]
|
||||||
|
itemId: str
|
||||||
|
|
||||||
|
|
||||||
|
class ItemDropPacket(BasePacket):
|
||||||
|
type: Literal["item_drop"]
|
||||||
|
itemId: str
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
|
||||||
|
|
||||||
|
class ItemDeletePacket(BasePacket):
|
||||||
|
type: Literal["item_delete"]
|
||||||
|
itemId: str
|
||||||
|
|
||||||
|
|
||||||
|
class ItemUsePacket(BasePacket):
|
||||||
|
type: Literal["item_use"]
|
||||||
|
itemId: str
|
||||||
|
|
||||||
|
|
||||||
|
class ItemUpdatePacket(BasePacket):
|
||||||
|
type: Literal["item_update"]
|
||||||
|
itemId: str
|
||||||
|
title: str | None = Field(default=None, max_length=80)
|
||||||
|
params: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
ClientPacket = (
|
||||||
|
SignalPacket
|
||||||
|
| UpdatePositionPacket
|
||||||
|
| UpdateNicknamePacket
|
||||||
|
| ChatMessagePacket
|
||||||
|
| PingPacket
|
||||||
|
| ItemAddPacket
|
||||||
|
| ItemPickupPacket
|
||||||
|
| ItemDropPacket
|
||||||
|
| ItemDeletePacket
|
||||||
|
| ItemUsePacket
|
||||||
|
| ItemUpdatePacket
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteUser(BaseModel):
|
||||||
|
id: str
|
||||||
|
nickname: str
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
|
||||||
|
|
||||||
|
class WelcomePacket(BasePacket):
|
||||||
|
type: Literal["welcome"]
|
||||||
|
id: str
|
||||||
|
users: list[RemoteUser]
|
||||||
|
items: list[dict] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserLeftPacket(BasePacket):
|
||||||
|
type: Literal["user_left"]
|
||||||
|
id: str
|
||||||
|
|
||||||
|
|
||||||
|
class BroadcastPositionPacket(BasePacket):
|
||||||
|
type: Literal["update_position"]
|
||||||
|
id: str
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
|
||||||
|
|
||||||
|
class BroadcastNicknamePacket(BasePacket):
|
||||||
|
type: Literal["update_nickname"]
|
||||||
|
id: str
|
||||||
|
nickname: str
|
||||||
|
|
||||||
|
|
||||||
|
class ForwardSignalPacket(BasePacket):
|
||||||
|
type: Literal["signal"]
|
||||||
|
senderId: str
|
||||||
|
senderNickname: str
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
sdp: dict | None = None
|
||||||
|
ice: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BroadcastChatMessagePacket(BasePacket):
|
||||||
|
type: Literal["chat_message"]
|
||||||
|
message: str
|
||||||
|
senderId: str | None = None
|
||||||
|
senderNickname: str | None = None
|
||||||
|
system: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class PongPacket(BasePacket):
|
||||||
|
type: Literal["pong"]
|
||||||
|
clientSentAt: int
|
||||||
|
|
||||||
|
|
||||||
|
class NicknameResultPacket(BasePacket):
|
||||||
|
type: Literal["nickname_result"]
|
||||||
|
accepted: bool
|
||||||
|
requestedNickname: str
|
||||||
|
effectiveNickname: str
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WorldItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
type: Literal["radio_station", "dice"]
|
||||||
|
title: str
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
createdBy: str
|
||||||
|
createdAt: int
|
||||||
|
updatedAt: int
|
||||||
|
version: int
|
||||||
|
capabilities: list[str]
|
||||||
|
useSound: str | None = None
|
||||||
|
params: dict
|
||||||
|
carrierId: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PersistedWorldItem(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
id: str
|
||||||
|
type: Literal["radio_station", "dice"]
|
||||||
|
title: str
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
createdBy: str
|
||||||
|
createdAt: int
|
||||||
|
updatedAt: int
|
||||||
|
version: int
|
||||||
|
params: dict
|
||||||
|
carrierId: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ItemUpsertPacket(BasePacket):
|
||||||
|
type: Literal["item_upsert"]
|
||||||
|
item: WorldItem
|
||||||
|
|
||||||
|
|
||||||
|
class ItemRemovePacket(BasePacket):
|
||||||
|
type: Literal["item_remove"]
|
||||||
|
itemId: str
|
||||||
|
|
||||||
|
|
||||||
|
class ItemActionResultPacket(BasePacket):
|
||||||
|
type: Literal["item_action_result"]
|
||||||
|
ok: bool
|
||||||
|
action: Literal["add", "pickup", "drop", "delete", "use", "update"]
|
||||||
|
message: str
|
||||||
|
itemId: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ItemUseSoundPacket(BasePacket):
|
||||||
|
type: Literal["item_use_sound"]
|
||||||
|
itemId: str
|
||||||
|
sound: str
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
599
server/app/server.py
Normal file
599
server/app/server.py
Normal file
@@ -0,0 +1,599 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import ssl
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import ValidationError, TypeAdapter
|
||||||
|
from websockets.asyncio.server import ServerConnection, serve
|
||||||
|
|
||||||
|
from .client import ClientConnection
|
||||||
|
from .config import load_config
|
||||||
|
from .item_service import ItemService
|
||||||
|
from .models import (
|
||||||
|
BroadcastChatMessagePacket,
|
||||||
|
BroadcastNicknamePacket,
|
||||||
|
BroadcastPositionPacket,
|
||||||
|
ChatMessagePacket,
|
||||||
|
ClientPacket,
|
||||||
|
ForwardSignalPacket,
|
||||||
|
ItemActionResultPacket,
|
||||||
|
ItemAddPacket,
|
||||||
|
ItemDeletePacket,
|
||||||
|
ItemDropPacket,
|
||||||
|
ItemPickupPacket,
|
||||||
|
ItemRemovePacket,
|
||||||
|
ItemUpdatePacket,
|
||||||
|
ItemUpsertPacket,
|
||||||
|
ItemUsePacket,
|
||||||
|
ItemUseSoundPacket,
|
||||||
|
NicknameResultPacket,
|
||||||
|
PingPacket,
|
||||||
|
PongPacket,
|
||||||
|
RemoteUser,
|
||||||
|
UpdateNicknamePacket,
|
||||||
|
UpdatePositionPacket,
|
||||||
|
UserLeftPacket,
|
||||||
|
WelcomePacket,
|
||||||
|
WorldItem,
|
||||||
|
)
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("chgrid.server")
|
||||||
|
PACKET_LOGGER = logging.getLogger("chgrid.server.packet")
|
||||||
|
CLIENT_PACKET_ADAPTER = TypeAdapter(ClientPacket)
|
||||||
|
|
||||||
|
|
||||||
|
class SignalingServer:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
ssl_cert: str | None,
|
||||||
|
ssl_key: str | None,
|
||||||
|
max_message_size: int = 2_000_000,
|
||||||
|
state_file: Path | None = None,
|
||||||
|
):
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.max_message_size = max_message_size
|
||||||
|
self._ssl_context = self._build_ssl_context(ssl_cert, ssl_key)
|
||||||
|
self.clients: dict[ServerConnection, ClientConnection] = {}
|
||||||
|
self.item_service = ItemService(state_file=state_file)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def items(self) -> dict[str, WorldItem]:
|
||||||
|
return self.item_service.items
|
||||||
|
|
||||||
|
def _nickname_key(self, nickname: str) -> str:
|
||||||
|
return nickname.casefold()
|
||||||
|
|
||||||
|
def _is_nickname_taken(self, nickname: str, exclude_client_id: str | None = None) -> bool:
|
||||||
|
wanted = self._nickname_key(nickname)
|
||||||
|
for other in self.clients.values():
|
||||||
|
if exclude_client_id is not None and other.id == exclude_client_id:
|
||||||
|
continue
|
||||||
|
if self._nickname_key(other.nickname) == wanted:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _item_type_label(item: WorldItem) -> str:
|
||||||
|
return "radio" if item.type == "radio_station" else item.type
|
||||||
|
|
||||||
|
async def _send_item_result(
|
||||||
|
self,
|
||||||
|
client: ClientConnection,
|
||||||
|
ok: bool,
|
||||||
|
action: Literal["add", "pickup", "drop", "delete", "use", "update"],
|
||||||
|
message: str,
|
||||||
|
item_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
await self._send(
|
||||||
|
client.websocket,
|
||||||
|
ItemActionResultPacket(
|
||||||
|
type="item_action_result",
|
||||||
|
ok=ok,
|
||||||
|
action=action,
|
||||||
|
message=message,
|
||||||
|
itemId=item_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _broadcast_item(self, item: WorldItem) -> None:
|
||||||
|
await self._broadcast(ItemUpsertPacket(type="item_upsert", item=item))
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
protocol = "wss" if self._ssl_context else "ws"
|
||||||
|
LOGGER.info("starting signaling server on %s://%s:%d", protocol, self.host, self.port)
|
||||||
|
async with serve(
|
||||||
|
self._handle_client,
|
||||||
|
self.host,
|
||||||
|
self.port,
|
||||||
|
ssl=self._ssl_context,
|
||||||
|
max_size=self.max_message_size,
|
||||||
|
):
|
||||||
|
await asyncio.Future()
|
||||||
|
|
||||||
|
async def _handle_client(self, websocket: ServerConnection) -> None:
|
||||||
|
client = ClientConnection(websocket=websocket, id=str(uuid.uuid4()))
|
||||||
|
self.clients[websocket] = client
|
||||||
|
LOGGER.info("client connected id=%s total=%d", client.id, len(self.clients))
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._send_welcome(client)
|
||||||
|
async for raw_message in websocket:
|
||||||
|
await self._handle_message(client, raw_message)
|
||||||
|
finally:
|
||||||
|
if websocket in self.clients:
|
||||||
|
disconnected = self.clients.pop(websocket)
|
||||||
|
for item in self.item_service.drop_carried_items_for_disconnect(disconnected):
|
||||||
|
await self._broadcast_item(item)
|
||||||
|
self.item_service.save_state()
|
||||||
|
LOGGER.info("client disconnected id=%s total=%d", disconnected.id, len(self.clients))
|
||||||
|
await self._broadcast(UserLeftPacket(type="user_left", id=disconnected.id), exclude=websocket)
|
||||||
|
await self._broadcast(
|
||||||
|
BroadcastChatMessagePacket(
|
||||||
|
type="chat_message",
|
||||||
|
message=f"{disconnected.nickname} has logged out.",
|
||||||
|
system=True,
|
||||||
|
),
|
||||||
|
exclude=websocket,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _send_welcome(self, client: ClientConnection) -> None:
|
||||||
|
users = [
|
||||||
|
RemoteUser(id=other.id, nickname=other.nickname, x=other.x, y=other.y)
|
||||||
|
for ws, other in self.clients.items()
|
||||||
|
if ws is not client.websocket
|
||||||
|
]
|
||||||
|
packet = WelcomePacket(
|
||||||
|
type="welcome",
|
||||||
|
id=client.id,
|
||||||
|
users=users,
|
||||||
|
items=[item.model_dump(exclude_none=True) for item in self.items.values()],
|
||||||
|
)
|
||||||
|
await self._send(client.websocket, packet)
|
||||||
|
|
||||||
|
async def _handle_message(self, client: ClientConnection, raw_message: str) -> None:
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw_message)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
PACKET_LOGGER.warning("non-json packet from id=%s", client.id)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
packet = CLIENT_PACKET_ADAPTER.validate_python(payload)
|
||||||
|
except ValidationError as exc:
|
||||||
|
PACKET_LOGGER.warning("invalid packet from id=%s: %s", client.id, exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(packet, UpdatePositionPacket):
|
||||||
|
client.x = packet.x
|
||||||
|
client.y = packet.y
|
||||||
|
await self._broadcast(
|
||||||
|
BroadcastPositionPacket(type="update_position", id=client.id, x=client.x, y=client.y),
|
||||||
|
exclude=client.websocket,
|
||||||
|
)
|
||||||
|
carried = self.item_service.find_carried_item(client.id)
|
||||||
|
if carried:
|
||||||
|
carried.x = client.x
|
||||||
|
carried.y = client.y
|
||||||
|
carried.updatedAt = self.item_service.now_ms()
|
||||||
|
await self._broadcast_item(carried)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(packet, UpdateNicknamePacket):
|
||||||
|
requested_nickname = packet.nickname.strip()
|
||||||
|
if not requested_nickname:
|
||||||
|
await self._send(
|
||||||
|
client.websocket,
|
||||||
|
NicknameResultPacket(
|
||||||
|
type="nickname_result",
|
||||||
|
accepted=False,
|
||||||
|
requestedNickname=packet.nickname,
|
||||||
|
effectiveNickname=client.nickname,
|
||||||
|
reason="Nickname is required.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
old_nickname = client.nickname
|
||||||
|
if self._is_nickname_taken(requested_nickname, exclude_client_id=client.id):
|
||||||
|
await self._send(
|
||||||
|
client.websocket,
|
||||||
|
NicknameResultPacket(
|
||||||
|
type="nickname_result",
|
||||||
|
accepted=False,
|
||||||
|
requestedNickname=requested_nickname,
|
||||||
|
effectiveNickname=client.nickname,
|
||||||
|
reason="Nickname already in use.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if requested_nickname == old_nickname:
|
||||||
|
await self._send(
|
||||||
|
client.websocket,
|
||||||
|
NicknameResultPacket(
|
||||||
|
type="nickname_result",
|
||||||
|
accepted=True,
|
||||||
|
requestedNickname=requested_nickname,
|
||||||
|
effectiveNickname=client.nickname,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
client.nickname = requested_nickname
|
||||||
|
await self._send(
|
||||||
|
client.websocket,
|
||||||
|
NicknameResultPacket(
|
||||||
|
type="nickname_result",
|
||||||
|
accepted=True,
|
||||||
|
requestedNickname=requested_nickname,
|
||||||
|
effectiveNickname=client.nickname,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await self._broadcast(
|
||||||
|
BroadcastNicknamePacket(type="update_nickname", id=client.id, nickname=client.nickname),
|
||||||
|
exclude=client.websocket,
|
||||||
|
)
|
||||||
|
if old_nickname == "user...":
|
||||||
|
await self._broadcast(
|
||||||
|
BroadcastChatMessagePacket(
|
||||||
|
type="chat_message",
|
||||||
|
message=f"{client.nickname} has logged in.",
|
||||||
|
system=True,
|
||||||
|
),
|
||||||
|
exclude=client.websocket,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self._broadcast(
|
||||||
|
BroadcastChatMessagePacket(
|
||||||
|
type="chat_message",
|
||||||
|
message=f"{old_nickname} is now known as {client.nickname}.",
|
||||||
|
system=True,
|
||||||
|
),
|
||||||
|
exclude=client.websocket,
|
||||||
|
)
|
||||||
|
self_message = (
|
||||||
|
f"Welcome. Logged in as {client.nickname}."
|
||||||
|
if old_nickname == "user..."
|
||||||
|
else f"You are now known as {client.nickname}."
|
||||||
|
)
|
||||||
|
await self._send(
|
||||||
|
client.websocket,
|
||||||
|
BroadcastChatMessagePacket(
|
||||||
|
type="chat_message",
|
||||||
|
message=self_message,
|
||||||
|
system=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(packet, ChatMessagePacket):
|
||||||
|
await self._broadcast(
|
||||||
|
BroadcastChatMessagePacket(
|
||||||
|
type="chat_message",
|
||||||
|
message=packet.message,
|
||||||
|
senderId=client.id,
|
||||||
|
senderNickname=client.nickname,
|
||||||
|
system=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(packet, PingPacket):
|
||||||
|
await self._send(
|
||||||
|
client.websocket,
|
||||||
|
PongPacket(type="pong", clientSentAt=packet.clientSentAt),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(packet, ItemAddPacket):
|
||||||
|
item = self.item_service.default_item(client, packet.itemType)
|
||||||
|
self.item_service.add_item(item)
|
||||||
|
await self._broadcast_item(item)
|
||||||
|
self.item_service.save_state()
|
||||||
|
item_text = f"{item.title} ({self._item_type_label(item)})"
|
||||||
|
await self._broadcast(
|
||||||
|
BroadcastChatMessagePacket(
|
||||||
|
type="chat_message",
|
||||||
|
message=f"{client.nickname} placed {item_text} at {item.x}, {item.y}.",
|
||||||
|
system=True,
|
||||||
|
),
|
||||||
|
exclude=client.websocket,
|
||||||
|
)
|
||||||
|
await self._send_item_result(
|
||||||
|
client,
|
||||||
|
True,
|
||||||
|
"add",
|
||||||
|
f"You placed {item_text} at {item.x}, {item.y}.",
|
||||||
|
item.id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(packet, ItemPickupPacket):
|
||||||
|
item = self.items.get(packet.itemId)
|
||||||
|
if not item:
|
||||||
|
await self._send_item_result(client, False, "pickup", "Item not found.")
|
||||||
|
return
|
||||||
|
if item.carrierId and item.carrierId != client.id:
|
||||||
|
await self._send_item_result(client, False, "pickup", "Item is already being carried.", item.id)
|
||||||
|
return
|
||||||
|
carried = self.item_service.find_carried_item(client.id)
|
||||||
|
if carried and carried.id != item.id:
|
||||||
|
await self._send_item_result(client, False, "pickup", "You are already carrying an item.", item.id)
|
||||||
|
return
|
||||||
|
if item.carrierId is None and (item.x != client.x or item.y != client.y):
|
||||||
|
await self._send_item_result(client, False, "pickup", "Item is not on your square.", item.id)
|
||||||
|
return
|
||||||
|
item.carrierId = client.id
|
||||||
|
item.x = client.x
|
||||||
|
item.y = client.y
|
||||||
|
item.updatedAt = self.item_service.now_ms()
|
||||||
|
await self._broadcast_item(item)
|
||||||
|
self.item_service.save_state()
|
||||||
|
await self._send_item_result(client, True, "pickup", f"Picked up {item.title}.", item.id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(packet, ItemDropPacket):
|
||||||
|
item = self.items.get(packet.itemId)
|
||||||
|
if not item:
|
||||||
|
await self._send_item_result(client, False, "drop", "Item not found.")
|
||||||
|
return
|
||||||
|
if item.carrierId != client.id:
|
||||||
|
await self._send_item_result(client, False, "drop", "You are not carrying that item.", item.id)
|
||||||
|
return
|
||||||
|
item.carrierId = None
|
||||||
|
item.x = packet.x
|
||||||
|
item.y = packet.y
|
||||||
|
item.updatedAt = self.item_service.now_ms()
|
||||||
|
await self._broadcast_item(item)
|
||||||
|
self.item_service.save_state()
|
||||||
|
await self._send_item_result(client, True, "drop", f"Dropped {item.title}.", item.id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(packet, ItemDeletePacket):
|
||||||
|
item = self.items.get(packet.itemId)
|
||||||
|
if not item:
|
||||||
|
await self._send_item_result(client, False, "delete", "Item not found.")
|
||||||
|
return
|
||||||
|
if item.carrierId and item.carrierId != client.id:
|
||||||
|
await self._send_item_result(client, False, "delete", "Item is being carried by another user.", item.id)
|
||||||
|
return
|
||||||
|
if item.carrierId is None and (item.x != client.x or item.y != client.y):
|
||||||
|
await self._send_item_result(client, False, "delete", "Item is not on your square.", item.id)
|
||||||
|
return
|
||||||
|
self.item_service.remove_item(item.id)
|
||||||
|
await self._broadcast(ItemRemovePacket(type="item_remove", itemId=item.id))
|
||||||
|
self.item_service.save_state()
|
||||||
|
await self._send_item_result(client, True, "delete", f"Deleted {item.title}.", item.id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(packet, ItemUsePacket):
|
||||||
|
item = self.items.get(packet.itemId)
|
||||||
|
if not item:
|
||||||
|
await self._send_item_result(client, False, "use", "Item not found.")
|
||||||
|
return
|
||||||
|
if item.carrierId not in (None, client.id):
|
||||||
|
await self._send_item_result(client, False, "use", "Item is not available.", item.id)
|
||||||
|
return
|
||||||
|
if item.carrierId is None and (item.x != client.x or item.y != client.y):
|
||||||
|
await self._send_item_result(client, False, "use", "Item is not on your square.", item.id)
|
||||||
|
return
|
||||||
|
if item.type != "dice":
|
||||||
|
await self._send_item_result(client, False, "use", "This item cannot be used yet.", item.id)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
sides = max(1, min(100, int(item.params.get("sides", 6))))
|
||||||
|
number = max(1, min(100, int(item.params.get("number", 2))))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
sides = 6
|
||||||
|
number = 2
|
||||||
|
rolls = [random.randint(1, sides) for _ in range(number)]
|
||||||
|
total = sum(rolls)
|
||||||
|
others_message = (
|
||||||
|
f"{client.nickname} rolled {item.title}: {', '.join(str(value) for value in rolls)} (total {total})."
|
||||||
|
)
|
||||||
|
self_message = f"You rolled {item.title}: {', '.join(str(value) for value in rolls)} (total {total})."
|
||||||
|
await self._broadcast(
|
||||||
|
BroadcastChatMessagePacket(type="chat_message", message=others_message, system=True),
|
||||||
|
exclude=client.websocket,
|
||||||
|
)
|
||||||
|
if item.useSound:
|
||||||
|
await self._broadcast(
|
||||||
|
ItemUseSoundPacket(
|
||||||
|
type="item_use_sound",
|
||||||
|
itemId=item.id,
|
||||||
|
sound=item.useSound,
|
||||||
|
x=item.x,
|
||||||
|
y=item.y,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await self._send_item_result(client, True, "use", self_message, item.id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(packet, ItemUpdatePacket):
|
||||||
|
item = self.items.get(packet.itemId)
|
||||||
|
if not item:
|
||||||
|
await self._send_item_result(client, False, "update", "Item not found.")
|
||||||
|
return
|
||||||
|
if item.carrierId not in (None, client.id):
|
||||||
|
await self._send_item_result(client, False, "update", "Item is not available for editing.", item.id)
|
||||||
|
return
|
||||||
|
if item.carrierId is None and (item.x != client.x or item.y != client.y):
|
||||||
|
await self._send_item_result(client, False, "update", "Item is not on your square.", item.id)
|
||||||
|
return
|
||||||
|
if packet.title is not None:
|
||||||
|
title = packet.title.strip()
|
||||||
|
if not title:
|
||||||
|
await self._send_item_result(client, False, "update", "Title cannot be empty.", item.id)
|
||||||
|
return
|
||||||
|
item.title = title[:80]
|
||||||
|
if packet.params:
|
||||||
|
next_params = {**item.params, **packet.params}
|
||||||
|
if item.type == "dice":
|
||||||
|
try:
|
||||||
|
sides = int(next_params.get("sides", 6))
|
||||||
|
number = int(next_params.get("number", 2))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
await self._send_item_result(client, False, "update", "Dice values must be numbers.", item.id)
|
||||||
|
return
|
||||||
|
if not (1 <= sides <= 100 and 1 <= number <= 100):
|
||||||
|
await self._send_item_result(
|
||||||
|
client, False, "update", "Dice sides and number must be between 1 and 100.", item.id
|
||||||
|
)
|
||||||
|
return
|
||||||
|
next_params["sides"] = sides
|
||||||
|
next_params["number"] = number
|
||||||
|
if item.type == "radio_station":
|
||||||
|
stream_url = str(next_params.get("streamUrl", "")).strip()
|
||||||
|
previous_stream_url = str(item.params.get("streamUrl", "")).strip()
|
||||||
|
next_params["streamUrl"] = stream_url
|
||||||
|
enabled_value = next_params.get("enabled", True)
|
||||||
|
if isinstance(enabled_value, bool):
|
||||||
|
enabled = enabled_value
|
||||||
|
elif isinstance(enabled_value, (int, float)):
|
||||||
|
enabled = bool(enabled_value)
|
||||||
|
elif isinstance(enabled_value, str):
|
||||||
|
token = enabled_value.strip().lower()
|
||||||
|
if token in {"on", "true", "1", "yes"}:
|
||||||
|
enabled = True
|
||||||
|
elif token in {"off", "false", "0", "no"}:
|
||||||
|
enabled = False
|
||||||
|
else:
|
||||||
|
await self._send_item_result(
|
||||||
|
client, False, "update", "enabled must be true/false or on/off.", item.id
|
||||||
|
)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
await self._send_item_result(
|
||||||
|
client, False, "update", "enabled must be true/false or on/off.", item.id
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if stream_url and stream_url != previous_stream_url:
|
||||||
|
enabled = True
|
||||||
|
if not stream_url:
|
||||||
|
enabled = False
|
||||||
|
next_params["enabled"] = enabled
|
||||||
|
|
||||||
|
try:
|
||||||
|
volume = int(next_params.get("volume", 50))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
await self._send_item_result(client, False, "update", "volume must be a number.", item.id)
|
||||||
|
return
|
||||||
|
if not (0 <= volume <= 100):
|
||||||
|
await self._send_item_result(
|
||||||
|
client, False, "update", "volume must be between 0 and 100.", item.id
|
||||||
|
)
|
||||||
|
return
|
||||||
|
next_params["volume"] = volume
|
||||||
|
item.params = next_params
|
||||||
|
item.updatedAt = self.item_service.now_ms()
|
||||||
|
item.version += 1
|
||||||
|
await self._broadcast_item(item)
|
||||||
|
self.item_service.save_state()
|
||||||
|
await self._send_item_result(client, True, "update", f"Updated {item.title}.", item.id)
|
||||||
|
return
|
||||||
|
|
||||||
|
target = self._find_by_id(packet.targetId)
|
||||||
|
if not target:
|
||||||
|
PACKET_LOGGER.info("signal target not found sender=%s target=%s", client.id, packet.targetId)
|
||||||
|
return
|
||||||
|
|
||||||
|
await self._send(
|
||||||
|
target.websocket,
|
||||||
|
ForwardSignalPacket(
|
||||||
|
type="signal",
|
||||||
|
senderId=client.id,
|
||||||
|
senderNickname=client.nickname,
|
||||||
|
x=client.x,
|
||||||
|
y=client.y,
|
||||||
|
sdp=packet.sdp,
|
||||||
|
ice=packet.ice,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _broadcast(self, packet: object, exclude: ServerConnection | None = None) -> None:
|
||||||
|
for websocket in list(self.clients.keys()):
|
||||||
|
if websocket is exclude:
|
||||||
|
continue
|
||||||
|
await self._send(websocket, packet)
|
||||||
|
|
||||||
|
async def _send(self, websocket: ServerConnection, packet: object) -> None:
|
||||||
|
try:
|
||||||
|
if hasattr(packet, "model_dump"):
|
||||||
|
data = packet.model_dump(exclude_none=True)
|
||||||
|
else:
|
||||||
|
data = packet
|
||||||
|
await websocket.send(json.dumps(data))
|
||||||
|
except Exception as exc: # intentionally broad to keep server alive per client error
|
||||||
|
LOGGER.debug("send failure: %s", exc)
|
||||||
|
|
||||||
|
def _find_by_id(self, client_id: str) -> ClientConnection | None:
|
||||||
|
for client in self.clients.values():
|
||||||
|
if client.id == client_id:
|
||||||
|
return client
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_ssl_context(cert: str | None, key: str | None) -> ssl.SSLContext | None:
|
||||||
|
if not cert or not key:
|
||||||
|
return None
|
||||||
|
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||||
|
context.load_cert_chain(certfile=Path(cert), keyfile=Path(key))
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
def run() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="chgrid signaling server")
|
||||||
|
parser.add_argument("--config", default="config.toml")
|
||||||
|
parser.add_argument("--host", default=None)
|
||||||
|
parser.add_argument("--port", type=int, default=None)
|
||||||
|
parser.add_argument("--ssl-cert", default=None)
|
||||||
|
parser.add_argument("--ssl-key", default=None)
|
||||||
|
parser.add_argument("--allow-insecure-ws", action="store_true", default=None)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
config_path = Path(args.config) if args.config else None
|
||||||
|
if config_path and not config_path.exists() and args.config == "config.toml":
|
||||||
|
config_path = None
|
||||||
|
config = load_config(config_path)
|
||||||
|
|
||||||
|
host = args.host or config.server.bind_ip
|
||||||
|
port = args.port or config.server.port
|
||||||
|
allow_insecure_ws = config.network.allow_insecure_ws
|
||||||
|
if args.allow_insecure_ws is True:
|
||||||
|
allow_insecure_ws = True
|
||||||
|
|
||||||
|
ssl_cert = args.ssl_cert if args.ssl_cert is not None else config.tls.cert_file or None
|
||||||
|
ssl_key = args.ssl_key if args.ssl_key is not None else config.tls.key_file or None
|
||||||
|
state_file_value = config.storage.state_file.strip()
|
||||||
|
state_file: Path | None = None
|
||||||
|
if state_file_value:
|
||||||
|
base_dir = config_path.parent if config_path is not None else Path.cwd()
|
||||||
|
state_file = Path(state_file_value)
|
||||||
|
if not state_file.is_absolute():
|
||||||
|
state_file = base_dir / state_file
|
||||||
|
|
||||||
|
if not allow_insecure_ws and (not ssl_cert or not ssl_key):
|
||||||
|
raise SystemExit(
|
||||||
|
"TLS is required when insecure ws is disabled. Set tls.cert_file/tls.key_file in config.toml."
|
||||||
|
)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, config.logging.level.upper(), logging.INFO),
|
||||||
|
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||||
|
)
|
||||||
|
server = SignalingServer(
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
ssl_cert,
|
||||||
|
ssl_key,
|
||||||
|
max_message_size=config.network.max_message_bytes,
|
||||||
|
state_file=state_file,
|
||||||
|
)
|
||||||
|
asyncio.run(server.start())
|
||||||
23
server/config.example.toml
Normal file
23
server/config.example.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[server]
|
||||||
|
# Bind IP for signaling server.
|
||||||
|
bind_ip = "127.0.0.1"
|
||||||
|
# Listen port for signaling websocket server.
|
||||||
|
port = 8765
|
||||||
|
|
||||||
|
[network]
|
||||||
|
# Maximum inbound websocket message size in bytes.
|
||||||
|
max_message_bytes = 2000000
|
||||||
|
# If false, TLS cert and key are required and server runs as wss:// only.
|
||||||
|
allow_insecure_ws = true
|
||||||
|
|
||||||
|
[tls]
|
||||||
|
# Required when allow_insecure_ws = false.
|
||||||
|
cert_file = ""
|
||||||
|
key_file = ""
|
||||||
|
|
||||||
|
[logging]
|
||||||
|
level = "INFO"
|
||||||
|
|
||||||
|
[storage]
|
||||||
|
# Item persistence file. Relative paths are resolved from this config file directory.
|
||||||
|
state_file = "runtime/items.json"
|
||||||
5
server/main.py
Normal file
5
server/main.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from app.server import run
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
19
server/pyproject.toml
Normal file
19
server/pyproject.toml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[project]
|
||||||
|
name = "chat-grid-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Chat Grid signaling server"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"pydantic>=2.10.4",
|
||||||
|
"websockets>=15.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.3.4",
|
||||||
|
"pytest-asyncio>=0.25.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
0
server/tests/__init__.py
Normal file
0
server/tests/__init__.py
Normal file
24
server/tests/test_config.py
Normal file
24
server/tests/test_config.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.config import load_config
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_defaults_when_path_none() -> None:
|
||||||
|
cfg = load_config(None)
|
||||||
|
assert cfg.server.bind_ip == "127.0.0.1"
|
||||||
|
assert cfg.network.allow_insecure_ws is True
|
||||||
|
assert cfg.storage.state_file == "runtime/items.json"
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_requires_tls_when_insecure_disabled(tmp_path: Path) -> None:
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
config_path.write_text(
|
||||||
|
"""
|
||||||
|
[network]
|
||||||
|
allow_insecure_ws = false
|
||||||
|
""".strip()
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
load_config(config_path)
|
||||||
35
server/tests/test_item_persistence.py
Normal file
35
server/tests/test_item_persistence.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from websockets.asyncio.server import ServerConnection
|
||||||
|
|
||||||
|
from app.client import ClientConnection
|
||||||
|
from app.item_service import ItemService
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_ws() -> ServerConnection:
|
||||||
|
return cast(ServerConnection, object())
|
||||||
|
|
||||||
|
|
||||||
|
def test_item_persistence_omits_global_type_properties(tmp_path: Path) -> None:
|
||||||
|
state_file = tmp_path / "items.json"
|
||||||
|
service = ItemService(state_file=state_file)
|
||||||
|
client = ClientConnection(websocket=_fake_ws(), id="u1", x=3, y=4)
|
||||||
|
|
||||||
|
item = service.default_item(client, "dice")
|
||||||
|
service.add_item(item)
|
||||||
|
service.save_state()
|
||||||
|
|
||||||
|
saved = json.loads(state_file.read_text(encoding="utf-8"))
|
||||||
|
assert isinstance(saved, list)
|
||||||
|
assert len(saved) == 1
|
||||||
|
assert "capabilities" not in saved[0]
|
||||||
|
assert "useSound" not in saved[0]
|
||||||
|
|
||||||
|
reloaded = ItemService(state_file=state_file)
|
||||||
|
loaded_item = reloaded.items[item.id]
|
||||||
|
assert loaded_item.useSound == "sounds/roll.ogg"
|
||||||
|
assert "usable" in loaded_item.capabilities
|
||||||
18
server/tests/test_models.py
Normal file
18
server/tests/test_models.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from pydantic import ValidationError, TypeAdapter
|
||||||
|
|
||||||
|
from app.models import ClientPacket
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_position_validates() -> None:
|
||||||
|
adapter = TypeAdapter(ClientPacket)
|
||||||
|
packet = adapter.validate_python({"type": "update_position", "x": 10, "y": 12})
|
||||||
|
assert packet.type == "update_position"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_type_rejected() -> None:
|
||||||
|
adapter = TypeAdapter(ClientPacket)
|
||||||
|
try:
|
||||||
|
adapter.validate_python({"type": "unknown"})
|
||||||
|
except ValidationError:
|
||||||
|
return
|
||||||
|
assert False, "validation should fail"
|
||||||
28
server/tests/test_nickname_uniqueness.py
Normal file
28
server/tests/test_nickname_uniqueness.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from websockets.asyncio.server import ServerConnection
|
||||||
|
|
||||||
|
from app.server import ClientConnection, SignalingServer
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_ws() -> ServerConnection:
|
||||||
|
return cast(ServerConnection, object())
|
||||||
|
|
||||||
|
|
||||||
|
def test_nickname_taken_is_case_insensitive() -> None:
|
||||||
|
server = SignalingServer("127.0.0.1", 8765, None, None)
|
||||||
|
first_ws = _fake_ws()
|
||||||
|
second_ws = _fake_ws()
|
||||||
|
server.clients[first_ws] = ClientConnection(websocket=first_ws, id="1", nickname="Jage")
|
||||||
|
server.clients[second_ws] = ClientConnection(websocket=second_ws, id="2", nickname="Alice")
|
||||||
|
|
||||||
|
assert server._is_nickname_taken("jage", exclude_client_id="2")
|
||||||
|
assert server._is_nickname_taken("JAGE", exclude_client_id="2")
|
||||||
|
assert not server._is_nickname_taken("jage", exclude_client_id="1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_nickname_key_uses_casefold() -> None:
|
||||||
|
server = SignalingServer("127.0.0.1", 8765, None, None)
|
||||||
|
assert server._nickname_key("Jage") == server._nickname_key("jage")
|
||||||
73
server/tests/test_nickname_updates.py
Normal file
73
server/tests/test_nickname_updates.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from websockets.asyncio.server import ServerConnection
|
||||||
|
|
||||||
|
from app.models import BroadcastChatMessagePacket, BroadcastNicknamePacket, NicknameResultPacket
|
||||||
|
from app.server import ClientConnection, SignalingServer
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_ws() -> ServerConnection:
|
||||||
|
return cast(ServerConnection, object())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_same_nickname_same_case_is_noop(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
server = SignalingServer("127.0.0.1", 8765, None, None)
|
||||||
|
ws = _fake_ws()
|
||||||
|
client = ClientConnection(websocket=ws, id="1", nickname="Jage")
|
||||||
|
server.clients[ws] = client
|
||||||
|
|
||||||
|
sent_packets: list[object] = []
|
||||||
|
broadcast_packets: list[object] = []
|
||||||
|
|
||||||
|
async def fake_send(websocket: ServerConnection, packet: object) -> None:
|
||||||
|
sent_packets.append(packet)
|
||||||
|
|
||||||
|
async def fake_broadcast(packet: object, exclude: ServerConnection | None = None) -> None:
|
||||||
|
broadcast_packets.append(packet)
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "_send", fake_send)
|
||||||
|
monkeypatch.setattr(server, "_broadcast", fake_broadcast)
|
||||||
|
|
||||||
|
await server._handle_message(client, json.dumps({"type": "update_nickname", "nickname": "Jage"}))
|
||||||
|
|
||||||
|
assert client.nickname == "Jage"
|
||||||
|
assert broadcast_packets == []
|
||||||
|
assert any(
|
||||||
|
isinstance(packet, NicknameResultPacket) and packet.accepted and packet.effectiveNickname == "Jage"
|
||||||
|
for packet in sent_packets
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_case_only_change_is_allowed_and_broadcast(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
server = SignalingServer("127.0.0.1", 8765, None, None)
|
||||||
|
ws = _fake_ws()
|
||||||
|
client = ClientConnection(websocket=ws, id="1", nickname="jage")
|
||||||
|
server.clients[ws] = client
|
||||||
|
|
||||||
|
sent_packets: list[object] = []
|
||||||
|
broadcast_packets: list[object] = []
|
||||||
|
|
||||||
|
async def fake_send(websocket: ServerConnection, packet: object) -> None:
|
||||||
|
sent_packets.append(packet)
|
||||||
|
|
||||||
|
async def fake_broadcast(packet: object, exclude: ServerConnection | None = None) -> None:
|
||||||
|
broadcast_packets.append(packet)
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "_send", fake_send)
|
||||||
|
monkeypatch.setattr(server, "_broadcast", fake_broadcast)
|
||||||
|
|
||||||
|
await server._handle_message(client, json.dumps({"type": "update_nickname", "nickname": "Jage"}))
|
||||||
|
|
||||||
|
assert client.nickname == "Jage"
|
||||||
|
assert any(
|
||||||
|
isinstance(packet, NicknameResultPacket) and packet.accepted and packet.effectiveNickname == "Jage"
|
||||||
|
for packet in sent_packets
|
||||||
|
)
|
||||||
|
assert any(isinstance(packet, BroadcastNicknamePacket) for packet in broadcast_packets)
|
||||||
|
assert any(isinstance(packet, BroadcastChatMessagePacket) for packet in broadcast_packets)
|
||||||
302
server/uv.lock
generated
Normal file
302
server/uv.lock
generated
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotated-types"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chat-grid-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "websockets" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-asyncio" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "pydantic", specifier = ">=2.10.4" },
|
||||||
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.4" },
|
||||||
|
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.25.2" },
|
||||||
|
{ name = "websockets", specifier = ">=15.0" },
|
||||||
|
]
|
||||||
|
provides-extras = ["dev"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic"
|
||||||
|
version = "2.12.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "annotated-types" },
|
||||||
|
{ name = "pydantic-core" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic-core"
|
||||||
|
version = "2.41.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.19.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-asyncio"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.15.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-inspection"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "websockets"
|
||||||
|
version = "16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user