Files
anekdotin 7b44b2f2dc Add real server pool, race roster overhaul, and ship energy system
Server browsing & matchmaking:
- HL2-style main menu (QUICK PLAY/SERVER SELECT/OPTIONS/PROFILE/QUIT)
  replaces the old CASUAL/RANKED tiles; RANKED removed end-to-end
  (menu, matchmaking-api's Mode.ranked queue path, MMR matching) until
  ranked is real.
- New menu/server_select.gd lists real servers from the matchmaking
  API's GET /servers and connects directly, retiring the old
  unreachable menu/server_browser.gd.
- Real game-server pool: NetworkManager.host_server() self-registers on
  boot and heartbeats every 8s with live player counts; API computes
  available/full status from player_count, and a background sweep marks
  any server offline once its heartbeat goes stale (catches a crashed
  server that never deregistered).
- Server-select rows get a live UDP ping probe (query port = game port
  + 10000) instead of trusting stale DB numbers; post-connect HUD shows
  live RTT off ENet's own peer stats.

Race roster overhaul:
- Swapped Terran/Mechanos/Vorg for the pivoted roster — Apex Dynamics,
  Inner Sphere Navy, Outer Rim Collective — each with a 3-ship
  Fighter/Gunner/Tank lineup, art cropped from concept sheets with
  background removal + orientation fixes per sheet.
- Live headcount + roster + "TEAM FULL" lock on the race-select screen,
  shared between the initial pre-spawn pick and the pause menu's live
  SELECT TEAM swap.
- Bot personalities (bots/bot_personality.gd): aggression/caution/
  accuracy/reaction/awareness traits rolled per bot instead of one
  fixed AI profile.

HUD additions:
- Player list (roster, teammates white/enemies yellow, bots flagged),
  kill feed, minimap, and explosion VFX on death.
- Health and energy now render as bars (hud/stat_bar.gd) instead of
  text in the top-left HUD.

Ship energy system:
- Per-role max energy (Fighter 100 / Gunner 150 / Tank 250), 75 energy
  per shot, flat regen (100 per 2.5s), fully server-authoritative and
  piggybacked on the existing per-tick state broadcast alongside health.
- New blue "mirrored" bar top-middle of the screen (hud/energy_bar.gd)
  whose fill drains from both edges toward the center instead of
  left-to-right.

Ship handling tuning:
- Turn rate reduced (4.0 -> 1.0 rad/s) so a quick tap no longer
  over-rotates; holding past 0.15s ramps to double speed (2.0 rad/s) for
  fast full turns, gated the same way damage already is so replay during
  reconciliation can't double-count the hold timer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 17:56:33 -04:00

103 lines
3.7 KiB
Python

import asyncio
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import async_session
from app.models import GameServer, Match, MatchPlayer, Mode, ServerStatus
@dataclass
class Ticket:
ticket_id: uuid.UUID
player_id: uuid.UUID
callsign: str
mode: Mode
mmr: int
queued_at: datetime = field(default_factory=datetime.utcnow)
status: str = "queued" # queued | matched | cancelled
server_ip: str | None = None
server_port: int | None = None
team: int | None = None
class QueueManager:
"""In-memory matchmaking queue. No Redis: a single API process holds all
ticket state, which is fine at this scale and simpler to run — see
memory/spacewar_networking_plan for why Redis was deferred rather than
included from the start.
"""
def __init__(self) -> None:
self._tickets: dict[uuid.UUID, Ticket] = {}
self._lock = asyncio.Lock()
async def join(self, player_id: uuid.UUID, callsign: str, mode: Mode, mmr: int) -> Ticket:
ticket = Ticket(ticket_id=uuid.uuid4(), player_id=player_id, callsign=callsign, mode=mode, mmr=mmr)
async with self._lock:
self._tickets[ticket.ticket_id] = ticket
return ticket
async def leave(self, ticket_id: uuid.UUID) -> bool:
async with self._lock:
ticket = self._tickets.get(ticket_id)
if ticket is None or ticket.status != "queued":
return False
ticket.status = "cancelled"
return True
def get(self, ticket_id: uuid.UUID) -> Ticket | None:
return self._tickets.get(ticket_id)
def _waiting(self, mode: Mode) -> list[Ticket]:
tickets = [t for t in self._tickets.values() if t.mode == mode and t.status == "queued"]
tickets.sort(key=lambda t: t.queued_at)
return tickets
async def _try_form_match(self, mode: Mode, required: int, cap: int, db: AsyncSession) -> None:
async with self._lock:
waiting = self._waiting(mode)
if len(waiting) < required:
return
group = waiting[:cap]
server = (
await db.execute(
select(GameServer).where(GameServer.mode == mode, GameServer.status == ServerStatus.available)
)
).scalars().first()
if server is None:
return # no server free this cycle; leave everyone queued and retry next tick
match = Match(mode=mode, server_id=server.id)
db.add(match)
await db.flush()
for i, ticket in enumerate(group):
team = i % 2
db.add(MatchPlayer(match_id=match.id, player_id=ticket.player_id, team=team))
ticket.status = "matched"
ticket.server_ip = server.ip
ticket.server_port = server.port
ticket.team = team
await db.commit()
async def run_matching_loop(self) -> None:
while True:
await asyncio.sleep(2)
async with async_session() as db:
# Bot fill (spacewar/bots/bot_manager.gd) pads both teams up
# to GameConfig.bot_min_team_size, so a single queued player
# is enough to form a match. Still group in anyone else who
# queues in the same tick, up to the real casual_team_size*2
# target, rather than capping at 1v1.
await self._try_form_match(Mode.casual, required=1, cap=settings.casual_team_size * 2, db=db)
queue_manager = QueueManager()