c0b5f421c5
- In-game chat: T=all, Y=team (race doubles as team), server-relayed and team-filtered via new ChatManager autoload; last 10 messages shown bottom-left (chat/chat_box.gd). - Bot fill for casual (bots/): keeps each of the match's 2 offered races at a minimum of 7 total humans+bots, spawning/despawning reactively as players join/leave. Bots always fly their race's fighter and use negative peer_ids so they ride the existing networked-ship stack (spawning, loadout sync, health/position sync, bullet attribution) with no special-casing. Casual matchmaking now forms with just 1 real player queued instead of waiting for a second (matchmaking-api/). - Borderless fullscreen with stretch scaling disabled instead of scaling the Steam-Deck-matched 1280x800 canvas up to fill PC monitors (which read as zoomed in) — PC monitors now reveal more world/HUD at native size instead. Reworked main_menu/team_select/chat_box to position via anchors relative to the real window instead of hardcoded coordinates. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
118 lines
4.4 KiB
Python
118 lines
4.4 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"]
|
|
if mode == Mode.ranked:
|
|
# Simple MMR-sorted grouping. No widening-window-by-wait-time yet
|
|
# (real ranked matchmaking will want that) — nearest-neighbor by
|
|
# mmr is a reasonable placeholder until ranked queue volume
|
|
# exists to tune against.
|
|
tickets.sort(key=lambda t: t.mmr)
|
|
else:
|
|
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:
|
|
# Casual has bot fill (spacewar/bots/bot_manager.gd) — bots pad
|
|
# 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.
|
|
# Ranked has no bots, so it still needs the full
|
|
# ranked_team_size*2 real players queued before forming.
|
|
await self._try_form_match(Mode.casual, required=1, cap=settings.casual_team_size * 2, db=db)
|
|
await self._try_form_match(
|
|
Mode.ranked,
|
|
required=settings.ranked_team_size * 2,
|
|
cap=settings.ranked_team_size * 2,
|
|
db=db,
|
|
)
|
|
|
|
|
|
queue_manager = QueueManager()
|