Files
x0gp/server/web/main.js
T
x0gp 978d36c505 feat(server): initial implementation
This commit captures the full server code accumulated across
several development sessions. It includes the following layers,
applied in roughly this order during development:

1. Base infrastructure
   - cmd/poc-server HTTP+WebSocket server, /health, /stats
   - internal/transport wire types (Envelope + all message types)
   - internal/catalog, internal/realtime, internal/stats
   - internal/storage/postgres with migrations 001-004
   - .env, .env.example, docker-compose, Dockerfile, scripts/

2. Swagger documentation
   - go get github.com/swaggo/http-swagger
   - swag init produces docs/docs.go
   - /swagger/index.html UI, /swagger/doc.json spec
   - annotations on health/stats/ws and catalog handlers

3. Races API
   - internal/races/{store,service,keyset,types}.go
   - migration 005_races.sql: finished_races, race_plans, race_queue
   - GET /api/races with keyset pagination, status filter
   - GET /api/races/upcoming
   - POST /api/races/queue/join, GET, DELETE
   - POST/GET /api/races/plans, DELETE /{id}
   - lobby.RaceMeta simplification (no host_id/host_name)

4. Races seeder
   - internal/races/seed with deterministic generator
   - --seed-races / --reset CLI flags in main.go
   - 30 finished + 5 live + 5 plans + 4 queue entries

5. Drivers and clans
   - internal/drivers, internal/clans (CRUD, validation)
   - migration 008_drivers_clans.sql
   - /api/clans and /api/drivers endpoints
   - 3-letter uppercase nickname/tag validation
   - lobby.DriverMeta: Nickname, AvatarURL, ClanID, ClanTag

6. Podium
   - internal/transport.RacePodiumEntry
   - lobby.SetDriverProfile for in-memory metadata sync
   - migration 007_podium.sql (podium JSONB column)
   - seeder populates top-3 per finished race

7. Live races persistence
   - migration 009_live_persistence.sql: live_races,
     live_race_drivers, lobby_drivers
   - internal/races/live_store.go: LiveStore with write-side
     mirror for lobby.Service mutations
   - Service.collectLive and Upcoming read from Postgres
   - main.go RestoreFromDB rehydrates lobby on startup

8. Unify live + finished into one races table
   - migration 010_unify_races.sql: rename finished_races to
     races, expand status CHECK, merge live rows
   - PgStore now hosts both write paths; LiveStore is a
     thin facade implementing lobby.Persistence
   - seeder resetAll drops only finished/cancelled rows and
     race_plans / race_queue / lobby_drivers

Each layer is consistent on its own; cross-layer changes are
visible in the file history. A future refactor may split this
commit into the per-stage boundaries listed above.
2026-06-22 22:01:09 +04:00

468 lines
15 KiB
JavaScript

// x0gp · PoC web client (vanilla JS)
// Протокол совпадает с internal/transport/codec.go (envelope + payload).
// В PoC сервер говорит по JSON — потом заменим на binary protobuf.
(function () {
'use strict';
// -------- DOM --------
const $ = (id) => document.getElementById(id);
const ui = {
wsUrl: $('wsUrl'),
driverId: $('driverId'),
raceId: $('raceId'),
btnConnect: $('btnConnect'),
btnDisconnect: $('btnDisconnect'),
btnJoin: $('btnJoin'),
btnLeave: $('btnLeave'),
connState: $('connState'),
tickHz: $('tickHz'),
rtt: $('rtt'),
seq: $('seq'),
canvas: $('track'),
log: $('log'),
barThrottle: $('barThrottle'),
barBrake: $('barBrake'),
barSteer: $('barSteer'),
lblThrottle: $('lblThrottle'),
lblBrake: $('lblBrake'),
lblSteer: $('lblSteer'),
wheelDisc: $('wheelDisc'),
tSpeed: $('tSpeed'),
tHeading: $('tHeading'),
tLap: $('tLap'),
tSector: $('tSector'),
tPos: $('tPos'),
tTsm: $('tTsm'),
tCars: $('tCars'),
};
// -------- State --------
const state = {
ws: null,
connected: false,
joined: false,
mySessionId: null,
myCarId: null,
inputSeq: 0,
inputSendAt: 0,
lastPingAt: 0,
lastPongRtt: 0,
lastSnapshot: null,
lastSnapshotAt: 0,
snapshotCount: 0,
snapshotWindow: [],
keys: new Set(),
input: { throttle: 0, brake: 0, steer: 0, handbrake: false, buttons: 0 },
pingTimer: null,
inputTimer: null,
slotHint: 0, // сервер выдаёт слот; 0 по умолчанию
};
const ctx = ui.canvas.getContext('2d');
const CANVAS_W = ui.canvas.width;
const CANVAS_H = ui.canvas.height;
// Простая кольцевая трасса для PoC (центр в (CANVAS_W/2, CANVAS_H/2))
const TRACK = {
cx: CANVAS_W / 2,
cy: CANVAS_H / 2,
rx: 260, // внешний радиус по X
ry: 150, // внешний радиус по Y
lane: 40, // ширина трассы в пикселях
};
// Коэффициент пересчёта: 1 метр = ~ 20 пикселей (для PoC)
const M2PX = 20;
// -------- Logging --------
function log(msg, kind) {
const t = new Date().toLocaleTimeString();
const line = document.createElement('div');
line.className = 'l-' + (kind || 'info');
line.textContent = `[${t}] ${msg}`;
ui.log.appendChild(line);
ui.log.scrollTop = ui.log.scrollHeight;
// обрезаем лог до 500 строк
while (ui.log.childNodes.length > 500) ui.log.removeChild(ui.log.firstChild);
}
// -------- Helpers --------
function setConn(text, cls) {
ui.connState.textContent = text;
ui.connState.className = 'conn ' + (cls || '');
}
function clamp(v, a, b) { return v < a ? a : v > b ? b : v; }
function deadzone(v, d) { return Math.abs(v) < d ? 0 : v; }
// -------- Input handling (keyboard) --------
// W/S — газ/тормоз; A/D — руль; Space — ручник; R — reset (debug)
function pollInput() {
const k = state.keys;
// Throttle & brake — дискретные «клавиши», но мы шлём 1.0 при нажатой.
// Для плавности можно отдавать ramp-rate — в PoC держим 0/1.
const throttle = (k.has('w') || k.has('arrowup')) ? 1.0 : 0.0;
const brake = (k.has('s') || k.has('arrowdown')) ? 1.0 : 0.0;
// Steer — это «удержание» крайнего положения. 0 если ничего не нажато.
let steer = 0.0;
if (k.has('a') || k.has('arrowleft')) steer -= 1.0;
if (k.has('d') || k.has('arrowright')) steer += 1.0;
steer = clamp(steer, -1, 1);
const handbrake = k.has(' ');
state.input.throttle = throttle;
state.input.brake = brake;
state.input.steer = steer;
state.input.handbrake = handbrake;
// Update UI bars
ui.lblThrottle.textContent = throttle.toFixed(2);
ui.lblBrake.textContent = brake.toFixed(2);
ui.lblSteer.textContent = steer.toFixed(2);
// bars: позитивная шкала; для руля — симметричная вокруг центра
ui.barThrottle.style.left = '0%';
ui.barThrottle.style.width = (throttle * 100).toFixed(1) + '%';
ui.barBrake.style.left = '0%';
ui.barBrake.style.width = (brake * 100).toFixed(1) + '%';
// руль: центр = 50%
const steerPct = (steer + 1) * 50;
ui.barSteer.style.left = (steerPct - 1) + '%';
ui.barSteer.style.width = '2%';
ui.wheelDisc.style.transform = `rotate(${steer * 90}deg)`;
}
// -------- Send helpers --------
function sendEnvelope(env) {
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return;
const data = JSON.stringify(env);
try {
state.ws.send(data);
if (env.type !== 'ping') log('→ ' + env.type, 'tx');
} catch (e) {
log('send error: ' + e.message, 'error');
}
}
function sendClientHello() {
sendEnvelope({
type: 'client_hello',
seq: ++state.inputSeq,
tsMs: Date.now(),
payload: {
version: 'x0gp/0.1',
token: '',
locale: (navigator.language || 'en').slice(0, 8),
client_id: '',
},
});
}
function sendJoinRace() {
const name = ui.driverId.value.trim() || 'driver-anon';
sendEnvelope({
type: 'join_race',
seq: ++state.inputSeq,
tsMs: Date.now(),
payload: {
race_id: ui.raceId.value.trim() || 'demo-race',
car_slot: state.slotHint,
car_name: name,
},
});
}
function sendLeaveRace() {
sendEnvelope({
type: 'leave_race',
seq: ++state.inputSeq,
tsMs: Date.now(),
payload: {},
});
}
function sendInputState() {
if (!state.joined) return;
state.inputSendAt = performance.now();
sendEnvelope({
type: 'input_state',
seq: ++state.inputSeq,
tsMs: Date.now(),
payload: {
steering: state.input.steer,
throttle: state.input.throttle,
brake: state.input.brake,
gear: 1,
buttons: state.input.buttons,
},
});
}
function sendPing() {
state.lastPingAt = performance.now();
sendEnvelope({
type: 'ping',
seq: ++state.inputSeq,
tsMs: Date.now(),
payload: {
client_ts_ms: Date.now(),
},
});
}
// -------- Receive --------
function handleServerEnvelope(env) {
switch (env.type) {
case 'server_hello': {
const p = env.payload || {};
state.mySessionId = p.session_id || p.sessionId;
const cfg = p.config || {};
log('server hello: session=' + state.mySessionId +
' tick=' + (cfg.tick_rate_hz || '?') + 'Hz' +
' snapshot=' + (cfg.snapshot_rate_hz || '?') + 'Hz', 'meta');
break;
}
case 'race_snapshot': {
handleSnapshot(env.payload);
break;
}
case 'input_ack': {
// сервер подтверждает вход по seq; можно использовать для RTT-оценки
const seq = env.seq;
log('input_ack seq=' + seq, 'rx');
break;
}
case 'pong': {
const now = performance.now();
state.lastPongRtt = Math.round(now - state.lastPingAt);
ui.rtt.textContent = String(state.lastPongRtt);
break;
}
case 'race_event': {
const p = env.payload || {};
log('event: ' + (p.eventType || '?') + ' ' + JSON.stringify(p), 'warn');
break;
}
case 'error': {
log('server error: ' + JSON.stringify(env.payload), 'error');
break;
}
case 'correction': {
log('correction: ' + JSON.stringify(env.payload), 'warn');
break;
}
default:
log('unknown type: ' + env.type, 'warn');
}
}
function handleSnapshot(payload) {
if (!payload) return;
state.lastSnapshot = payload;
state.lastSnapshotAt = performance.now();
state.snapshotCount++;
const now = performance.now();
state.snapshotWindow.push(now);
while (state.snapshotWindow.length && now - state.snapshotWindow[0] > 1000) {
state.snapshotWindow.shift();
}
ui.tickHz.textContent = String(state.snapshotWindow.length);
ui.seq.textContent = String(env_seq_current());
ui.tTsm.textContent = String(payload.ts_ms || '--');
ui.tCars.textContent = String((payload.cars || []).length);
// Найти свою машинку по driver_name (== driverId из формы)
const myName = ui.driverId.value.trim();
const me = (payload.cars || []).find((c) => c.driver_name === myName);
if (me) {
state.myCarId = me.id;
ui.tSpeed.textContent = (me.speed || 0).toFixed(2) + ' m/s';
ui.tHeading.textContent = ((me.heading || 0) * 180 / Math.PI).toFixed(1) + '°';
ui.tLap.textContent = String(me.lap || 0);
ui.tSector.textContent = String(me.sector != null ? me.sector : '-');
ui.tPos.textContent = `(${(me.x || 0).toFixed(2)}, ${(me.y || 0).toFixed(2)})`;
}
drawSnapshot(payload);
}
function env_seq_current() { return state.inputSeq; }
// -------- Rendering --------
function drawSnapshot(snap) {
ctx.clearRect(0, 0, CANVAS_W, CANVAS_H);
// фон-сетка
ctx.strokeStyle = '#1f2630';
ctx.lineWidth = 1;
for (let x = 0; x < CANVAS_W; x += 40) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, CANVAS_H); ctx.stroke();
}
for (let y = 0; y < CANVAS_H; y += 40) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(CANVAS_W, y); ctx.stroke();
}
// Кольцевая трасса (овал). Внешний радиус = rx, ry; внутренний = rx-lane, ry-lane.
ctx.lineWidth = TRACK.lane;
ctx.strokeStyle = '#2d333b';
ctx.beginPath();
ctx.ellipse(TRACK.cx, TRACK.cy, TRACK.rx, TRACK.ry, 0, 0, Math.PI * 2);
ctx.stroke();
// Центральная разметка
ctx.lineWidth = 2;
ctx.strokeStyle = '#484f58';
ctx.setLineDash([8, 8]);
ctx.beginPath();
ctx.ellipse(TRACK.cx, TRACK.cy,
TRACK.rx - TRACK.lane / 2, TRACK.ry - TRACK.lane / 2, 0, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
// Стартовая линия
ctx.strokeStyle = '#f0883e';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(TRACK.cx, TRACK.cy - TRACK.ry);
ctx.lineTo(TRACK.cx, TRACK.cy - TRACK.ry + TRACK.lane);
ctx.stroke();
// Машинки. Мировые координаты (метры) → пиксели:
// центр трассы в (0,0), X — вправо, Y — вниз (как на канвасе).
// В PoC считаем, что 1 метр = M2PX пикселей, начало трассы в (0,0).
// TODO: после появления настоящей карты — масштабировать под неё.
const myDriver = ui.driverId.value.trim();
for (const car of (snap.cars || [])) {
const isMe = car.driver_name === myDriver;
const px = TRACK.cx + (car.x || 0) * M2PX;
const py = TRACK.cy + (car.y || 0) * M2PX;
ctx.save();
ctx.translate(px, py);
ctx.rotate(car.heading || 0);
ctx.fillStyle = isMe ? '#58a6ff' : '#f0883e';
ctx.strokeStyle = '#0e1117';
ctx.lineWidth = 1;
// корпус машинки
ctx.fillRect(-10, -5, 20, 10);
ctx.strokeRect(-10, -5, 20, 10);
// направляющая полоска
ctx.fillStyle = isMe ? '#a5c6ff' : '#ffc685';
ctx.fillRect(8, -1, 4, 2);
ctx.restore();
// метка driver_name
ctx.fillStyle = '#e6edf3';
ctx.font = '11px ui-monospace, Menlo, Consolas, monospace';
ctx.textAlign = 'center';
ctx.fillText((car.driver_name || '?') + (isMe ? ' ★' : ''), px, py - 14);
}
}
// -------- Connection --------
function connect() {
const url = ui.wsUrl.value.trim();
log('connecting to ' + url, 'info');
setConn('connecting…', 'connecting');
const ws = new WebSocket(url);
state.ws = ws;
ws.addEventListener('open', () => {
state.connected = true;
setConn('connected', 'connected');
log('ws open', 'info');
ui.btnConnect.disabled = true;
ui.btnDisconnect.disabled = false;
ui.btnJoin.disabled = false;
sendClientHello();
// Timers: input 30Hz, ping 1Hz
if (state.inputTimer) clearInterval(state.inputTimer);
state.inputTimer = setInterval(() => {
pollInput();
sendInputState();
}, 33); // ~30 Hz (snapshots — 30 Hz, чтобы не заливать канал)
if (state.pingTimer) clearInterval(state.pingTimer);
state.pingTimer = setInterval(sendPing, 1000);
});
ws.addEventListener('message', (ev) => {
let env;
try { env = JSON.parse(ev.data); }
catch (e) { log('bad json: ' + e.message, 'error'); return; }
if (env.type !== 'pong') log('← ' + env.type, 'rx');
handleServerEnvelope(env);
});
ws.addEventListener('close', (ev) => {
state.connected = false;
state.joined = false;
setConn('disconnected', '');
log('ws close code=' + ev.code + ' reason=' + (ev.reason || ''), 'warn');
ui.btnConnect.disabled = false;
ui.btnDisconnect.disabled = true;
ui.btnJoin.disabled = true;
ui.btnLeave.disabled = true;
if (state.inputTimer) { clearInterval(state.inputTimer); state.inputTimer = null; }
if (state.pingTimer) { clearInterval(state.pingTimer); state.pingTimer = null; }
});
ws.addEventListener('error', (ev) => {
log('ws error', 'error');
setConn('error', 'error');
});
}
function disconnect() {
if (state.ws) state.ws.close(1000, 'client-bye');
}
function joinRace() {
if (!state.connected) return;
state.joined = true;
ui.btnJoin.disabled = true;
ui.btnLeave.disabled = false;
sendJoinRace();
}
function leaveRace() {
if (!state.connected) return;
state.joined = false;
ui.btnJoin.disabled = false;
ui.btnLeave.disabled = true;
sendLeaveRace();
}
// -------- Keyboard --------
function keyName(e) {
if (e.key === ' ') return ' ';
if (e.key === 'ArrowUp') return 'arrowup';
if (e.key === 'ArrowDown') return 'arrowdown';
if (e.key === 'ArrowLeft') return 'arrowleft';
if (e.key === 'ArrowRight') return 'arrowright';
return e.key.toLowerCase();
}
window.addEventListener('keydown', (e) => {
const k = keyName(e);
// Не даём странице скроллить на стрелках/пробеле, если фокус на инпутах — пропускаем.
const tag = (e.target && e.target.tagName) || '';
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if (['arrowup','arrowdown','arrowleft','arrowright',' '].includes(k)) e.preventDefault();
state.keys.add(k);
});
window.addEventListener('keyup', (e) => {
state.keys.delete(keyName(e));
});
window.addEventListener('blur', () => state.keys.clear());
// -------- Buttons --------
ui.btnConnect.addEventListener('click', connect);
ui.btnDisconnect.addEventListener('click', disconnect);
ui.btnJoin.addEventListener('click', joinRace);
ui.btnLeave.addEventListener('click', leaveRace);
// -------- Boot --------
setConn('disconnected', '');
log('x0gp PoC client ready. Press Connect.', 'meta');
})();