Add game mode categories, character creation, on-foot ship/hub mode, RAM currency, and in-ship navigation

Bundles several sessions' worth of previously uncommitted work: map
categories with Domination/Conquest/King of the Hill mode stubs and a
server-list mode filter; a procedurally-drawn character-creation screen
replacing the old callsign-only PROFILE overlay; the on-foot groundwork
(walkable station hub, ship interior, character controller) plus the RAM
currency backend; and today's addition, an in-ship Helldivers-2-style
navigation table that QUICK PLAY's queue/connect flow and the Belters/
Military hub travel now live behind, with hub-and-ship return paths and a
context-aware pause menu usable both in-match and inside the ship.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 12:30:22 -04:00
parent be5c41f82f
commit d3bed34c6c
124 changed files with 3347 additions and 159 deletions
+10
View File
@@ -15,5 +15,15 @@ class Settings(BaseSettings):
# to "offline" and back.
server_stale_seconds: int = 20
# RAM payout formula for POST /matches/report (see overview/onfoot.md) --
# every reported player gets ram_payout_participation_kb just for being
# in the match, plus ram_payout_per_kill_kb per kill, plus
# ram_payout_win_bonus_kb if they won. Placeholder numbers, not balanced
# against anything -- tune freely, nothing else in the schema depends on
# the specific values.
ram_payout_participation_kb: int = 50
ram_payout_per_kill_kb: int = 15
ram_payout_win_bonus_kb: int = 200
settings = Settings()
+2
View File
@@ -45,6 +45,8 @@ async def _seed_demo_servers() -> None:
status=ServerStatus.available,
player_count=player_count,
max_players=50,
game_mode="team_deathmatch",
map_name="Sector Alpha",
)
)
await db.commit()
+18 -1
View File
@@ -2,7 +2,7 @@ import enum
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String
from sqlalchemy import BigInteger, DateTime, Float, ForeignKey, Integer, String
from sqlalchemy import Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -38,6 +38,13 @@ class Player(Base):
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)
# In-game currency, see overview/onfoot.md -- named RAM, stored as a
# single integer count of kilobytes (the smallest denomination); the
# client formats it up into KB/MB/GB/TB (1000 per step, not 1024) for
# display. BigInteger since a long-lived player's total is expected to
# climb well past 32-bit Integer's ~2.1 billion ceiling (2.1 billion KB
# is only ~2.1 TB).
ram_kb: Mapped[int] = mapped_column(BigInteger, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
@@ -57,6 +64,16 @@ class GameServer(Base):
# once that probe answers.
player_count: Mapped[int] = mapped_column(Integer, default=0)
max_players: Mapped[int] = mapped_column(Integer, default=50)
# The actual in-match GameMode id (e.g. "team_deathmatch") and map
# display name (e.g. "Sector Alpha") this server is running -- distinct
# from `mode` above, which is only the matchmaking queue mode ("casual").
# Plain strings, not a Mode-style enum: the set of GameMode ids lives in
# the Godot client (spacewar/world/game_modes/, world.gd's MAPS), which
# this API has no reason to duplicate/validate against. Reported by the
# hosting server on every register/heartbeat call (see
# spacewar/autoload/network_manager.gd's host_server()).
game_mode: Mapped[str] = mapped_column(String(64), default="team_deathmatch")
map_name: Mapped[str] = mapped_column(String(64), default="Sector Alpha")
class Match(Base):
+6
View File
@@ -3,6 +3,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app import crud
from app.config import settings
from app.database import get_db
from app.models import GameServer, Match, MatchPlayer
from app.schemas import MatchReportRequest
@@ -48,6 +49,11 @@ async def report_match(req: MatchReportRequest, db: AsyncSession = Depends(get_d
player.kills += result.kills
player.deaths += result.deaths
player.hours_played += result.seconds_played / 3600.0
player.ram_kb += (
settings.ram_payout_participation_kb
+ result.kills * settings.ram_payout_per_kill_kb
+ (settings.ram_payout_win_bonus_kb if result.is_winner else 0)
)
if result.is_winner:
player.wins += 1
elif req.winner_team is not None:
+6
View File
@@ -34,6 +34,8 @@ async def register_server(req: ServerRegisterRequest, db: AsyncSession = Depends
existing.player_count = req.player_count
existing.max_players = req.max_players
existing.last_heartbeat = datetime.utcnow()
existing.game_mode = req.game_mode
existing.map_name = req.map_name
else:
db.add(
GameServer(
@@ -43,6 +45,8 @@ async def register_server(req: ServerRegisterRequest, db: AsyncSession = Depends
status=status,
player_count=req.player_count,
max_players=req.max_players,
game_mode=req.game_mode,
map_name=req.map_name,
)
)
await db.commit()
@@ -80,6 +84,8 @@ async def list_servers(db: AsyncSession = Depends(get_db)) -> list[dict]:
"player_count": s.player_count,
"max_players": s.max_players,
"last_heartbeat": s.last_heartbeat,
"game_mode": s.game_mode,
"map_name": s.map_name,
}
for s in servers
]
+1
View File
@@ -22,4 +22,5 @@ async def get_stats(callsign: str, db: AsyncSession = Depends(get_db)) -> Player
kills=player.kills,
deaths=player.deaths,
hours_played=player.hours_played,
ram_kb=player.ram_kb,
)
+5
View File
@@ -29,6 +29,10 @@ class ServerRegisterRequest(BaseModel):
mode: Mode
player_count: int = 0
max_players: int = 50
# See GameServer.game_mode/map_name in app/models.py -- the in-match
# GameMode id and map display name, distinct from `mode` (queue mode).
game_mode: str = "team_deathmatch"
map_name: str = "Sector Alpha"
class PlayerStatsResponse(BaseModel):
@@ -39,6 +43,7 @@ class PlayerStatsResponse(BaseModel):
kills: int
deaths: int
hours_played: float
ram_kb: int
# Reported by the hosting game server (never a client directly -- see