Add match structure (countdown, timer, scoreboard, banner, persistent stats) and capital-ship fleet polish

Match phases (autoload/match_manager.gd, world/game_modes/):
- New server-authoritative MatchManager loops PRE_MATCH (30s countdown,
  ships held invisible/uncollidable, flagships creep into formation) ->
  IN_PROGRESS (active GameMode's clock runs) -> POST_MATCH (winner
  banner, result reported to matchmaking-api) -> back to a fresh
  PRE_MATCH forever, matching the always-on server-pool model instead
  of kicking players to the menu at match end.
- Win-condition logic lives in a new GameMode abstraction (game_mode.gd
  base + team_deathmatch_mode.gd, the only mode so far) so a future
  mode is a new subclass plus one factory branch, no timer/scoreboard/
  banner code changes needed.
- Three new HUD pieces: match_timer.gd (countdown/clock), scoreboard.gd
  (hold-Tab two-team panel), match_banner.gd (winner banner).
- New MatchStats autoload tracks per-match kills/deaths by peer_id
  (including bots), hooked into kill_feed_manager's existing
  report_kill() call site.

Persistent stats (matchmaking-api/):
- Player gains kills/deaths/hours_played columns; new
  POST /matches/report endpoint (server-only, called once at
  POST_MATCH) upserts each real player's totals by callsign via a
  shared app/crud.py helper also used by the matchmaking-queue join
  path. GET /stats/{callsign} returns the new fields alongside mmr/
  wins/losses.

Capital-ship fleet polish (world/flagship.gd, world/world.gd,
ships/ship_movement.gd, autoload/game_config.gd):
- Flagship formations are now a clean vertical line (no per-ship
  position/rotation jitter) so play_creep_in()'s rigid-group tween
  reads as one disciplined fleet arriving together, rising from
  directly below (not a random compass direction) over the full 30s
  countdown.
- Fixed a real bug where the creep-in tween only ever played on the
  server -- MatchManager._run_pre_match() called straight into World,
  server-only code a remote client's own process never runs, leaving
  their flagships static all match. World now triggers it off
  MatchManager.phase_changed instead, which fires identically on every
  peer.
- Fixed a second bug (only reachable on a fresh server boot's very
  first spawn): a phase==PRE_MATCH check that's true even before the
  match loop has genuinely started that phase for the first time fired
  play_creep_in() with a bogus zero-duration tween, corrupting the
  target the real 30s tween read moments later -- flagships would
  settle 4000 units off from their intended formation slot and fire
  from there instead. Guarded on get_remaining_seconds() > 0 too.
- The held ship's camera now actively tracks its own team's flagship
  centroid every tick during the countdown (position_smoothing
  disabled for the hold, since it fights a manually-driven target and
  was the reason the fleet read as invisible) instead of sitting fixed
  and wide-angle; local offset/zoom/smoothing are explicitly reset on
  release so control handback doesn't inherit a stale camera transform.
- _apply_pre_match_hold()/_apply_respawn() now set _dead/
  _held_for_pre_match inside the RPC itself, not just in the
  server-only caller -- those flags never reached remote clients
  before, so WASD wasn't actually blocked for them during the hold.
- Ships now launch from a narrow point directly beneath their own
  flagship formation instead of a full-circle scatter around the spawn
  marker (which could land a spawn behind/inside a hull). Bumped
  flagship_defense_radius so it still comfortably reaches a player who
  flies a straight line to the enemy side without correcting for that
  new offset.

ISN gets a Stealth Corvette hull mixed into its flagship formation
(assets/images/ships/isn/), banner art renamed off opaque UUID
filenames to isn_banner.jpeg/orc_banner.jpeg.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 11:40:15 -04:00
parent a94bccc57b
commit be5c41f82f
59 changed files with 1793 additions and 207 deletions
+17
View File
@@ -0,0 +1,17 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Player
# Shared by app/routers/matchmaking.py (queue join) and app/routers/matches.py
# (match result reporting) -- both need "find this callsign's Player row, or
# create one" with no account/auth system yet (see Player's own docstring).
async def get_or_create_player(db: AsyncSession, callsign: str) -> Player:
player = (await db.execute(select(Player).where(Player.callsign == callsign))).scalars().first()
if player is None:
player = Player(callsign=callsign)
db.add(player)
await db.commit()
await db.refresh(player)
return player
+2 -1
View File
@@ -8,7 +8,7 @@ from app.config import settings
from app.database import Base, async_session, engine
from app.models import GameServer, Mode, ServerStatus
from app.queue_manager import queue_manager
from app.routers import matchmaking, ranks, servers, stats
from app.routers import matches, matchmaking, ranks, servers, stats
from app.routers.servers import sweep_stale_servers
@@ -72,6 +72,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="Spacewar Matchmaking API", lifespan=lifespan)
app.include_router(matchmaking.router)
app.include_router(matches.router)
app.include_router(stats.router)
app.include_router(ranks.router)
app.include_router(servers.router)
+9 -1
View File
@@ -2,7 +2,7 @@ import enum
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String
from sqlalchemy import Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -30,6 +30,14 @@ class Player(Base):
mmr: Mapped[int] = mapped_column(Integer, default=1000)
wins: Mapped[int] = mapped_column(Integer, default=0)
losses: Mapped[int] = mapped_column(Integer, default=0)
# Match-history stats (see spacewar/autoload/match_manager.gd's
# _report_match_result(), POST /matches/report) -- kills/deaths accumulate
# across every match reported for this callsign; hours_played is derived
# from each match's full duration for every player present at match end
# (not precise per-player join/leave timing -- a documented simplification).
kills: Mapped[int] = mapped_column(Integer, default=0)
deaths: Mapped[int] = mapped_column(Integer, default=0)
hours_played: Mapped[float] = mapped_column(Float, default=0.0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+59
View File
@@ -0,0 +1,59 @@
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app import crud
from app.database import get_db
from app.models import GameServer, Match, MatchPlayer
from app.schemas import MatchReportRequest
router = APIRouter(prefix="/matches", tags=["matches"])
# Called once by the hosting server's MatchManager (spacewar/autoload/
# match_manager.gd's _report_match_result()) when a match's POST_MATCH phase
# begins -- persists this match's final kills/deaths/win-loss into each
# player's running Player totals (kills, deaths, wins, losses, hours_played),
# and records a Match/MatchPlayer row if the reporting server is a known,
# registered GameServer. No auth -- same posture as /servers/register today,
# not a new gap introduced here (see overview/map1.md's design notes).
#
# Independent of any Match row the matchmaking queue may have already created
# when this match was *formed* (see queue_manager.py's _try_form_match) --
# this always writes a fresh Match/MatchPlayer pair representing how the
# match actually ended. A queued match can therefore end up with two Match
# rows (one "how it was formed", one "how it ended"); acceptable since no
# existing code reads these tables today and there's no migrations tooling to
# reconcile the schema around it.
@router.post("/report")
async def report_match(req: MatchReportRequest, db: AsyncSession = Depends(get_db)) -> dict:
server = (
await db.execute(
select(GameServer).where(
GameServer.ip == req.server_ip,
GameServer.port == req.server_port,
GameServer.mode == req.mode,
)
)
).scalars().first()
match_row = None
if server is not None:
match_row = Match(mode=req.mode, server_id=server.id)
db.add(match_row)
await db.flush()
for result in req.players:
player = await crud.get_or_create_player(db, result.callsign)
player.kills += result.kills
player.deaths += result.deaths
player.hours_played += result.seconds_played / 3600.0
if result.is_winner:
player.wins += 1
elif req.winner_team is not None:
player.losses += 1
if match_row is not None:
db.add(MatchPlayer(match_id=match_row.id, player_id=player.id, team=result.team))
await db.commit()
return {"status": "recorded"}
+2 -13
View File
@@ -1,30 +1,19 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app import crud
from app.database import get_db
from app.models import Player
from app.queue_manager import queue_manager
from app.schemas import QueueJoinRequest, QueueJoinResponse, QueueStatusResponse
router = APIRouter(prefix="/matchmaking", tags=["matchmaking"])
async def _get_or_create_player(db: AsyncSession, callsign: str) -> Player:
player = (await db.execute(select(Player).where(Player.callsign == callsign))).scalars().first()
if player is None:
player = Player(callsign=callsign)
db.add(player)
await db.commit()
await db.refresh(player)
return player
@router.post("/queue/join", response_model=QueueJoinResponse)
async def join_queue(req: QueueJoinRequest, db: AsyncSession = Depends(get_db)) -> QueueJoinResponse:
player = await _get_or_create_player(db, req.callsign)
player = await crud.get_or_create_player(db, req.callsign)
ticket = await queue_manager.join(player.id, player.callsign, req.mode, player.mmr)
return QueueJoinResponse(ticket_id=ticket.ticket_id, status=ticket.status)
+9 -1
View File
@@ -14,4 +14,12 @@ async def get_stats(callsign: str, db: AsyncSession = Depends(get_db)) -> Player
player = (await db.execute(select(Player).where(Player.callsign == callsign))).scalars().first()
if player is None:
raise HTTPException(status_code=404, detail="Player not found")
return PlayerStatsResponse(callsign=player.callsign, mmr=player.mmr, wins=player.wins, losses=player.losses)
return PlayerStatsResponse(
callsign=player.callsign,
mmr=player.mmr,
wins=player.wins,
losses=player.losses,
kills=player.kills,
deaths=player.deaths,
hours_played=player.hours_played,
)
+25
View File
@@ -36,3 +36,28 @@ class PlayerStatsResponse(BaseModel):
mmr: int
wins: int
losses: int
kills: int
deaths: int
hours_played: float
# Reported by the hosting game server (never a client directly -- see
# spacewar/autoload/network_manager.gd's register_server()/heartbeat being
# server-only-triggered the same way) once a match's MatchManager POST_MATCH
# phase begins. team here is a race_id (races double as teams, see
# menu/team_select.gd's RACES), not a 0/1 index.
class MatchPlayerResult(BaseModel):
callsign: str
team: int
kills: int
deaths: int
is_winner: bool
seconds_played: float
class MatchReportRequest(BaseModel):
server_ip: str
server_port: int
mode: Mode = Mode.casual
winner_team: int | None = None # None = draw
players: list[MatchPlayerResult]