7b44b2f2dc
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>
103 lines
3.7 KiB
Python
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()
|