mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-16 20:47:55 +00:00
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.
468 lines
15 KiB
JavaScript
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');
|
|
})(); |