Initial commit

This commit is contained in:
Jage9
2026-02-20 08:16:43 -05:00
commit b246c9a7fd
53 changed files with 9538 additions and 0 deletions

18
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

24
client/package.json Normal file
View 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"
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

3
client/public/version.js Normal file
View 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";

View 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)!;
}
}

View 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

File diff suppressed because it is too large Load Diff

View 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;
};

View 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;
}
}
}

View 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);
}
}

View 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
View 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;
}

View 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
View 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
View 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
View 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`

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

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

View 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)"

View 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

View 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

View 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
View 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
View 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
View 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
View 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
View 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
View File

17
server/app/client.py Normal file
View 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
View 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

View 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
View 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
View 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
View 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())

View 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
View File

@@ -0,0 +1,5 @@
from app.server import run
if __name__ == "__main__":
run()

19
server/pyproject.toml Normal file
View 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
View File

View 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)

View 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

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

View 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")

View 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
View 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" },
]