diff --git a/server/main.go b/server/main.go index f6af221..6f5aee3 100644 --- a/server/main.go +++ b/server/main.go @@ -6,11 +6,12 @@ import ( "encoding/base64" "encoding/json" "errors" - "fmt" + "hash/fnv" "log" "math" "net/http" "os" + "strconv" "strings" "sync" "time" @@ -20,6 +21,27 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) +const ( + worldSize = 3600.0 + spawnX = 1200.0 + spawnY = 1550.0 + playerMaxHull = 100 + playerMaxSpeed = 180.0 + playerReverseSpeed = -55.0 + playerAcceleration = 92.0 + playerBrake = 118.0 + playerTurnRate = 76.0 + playerCoastDrag = 0.992 + broadsideReload = 850 * time.Millisecond + shotSpeed = 520.0 + shotLifetime = 900 * time.Millisecond + shotDamage = 16 + shipHitRadius = 44.0 + respawnDelay = 2200 * time.Millisecond + simTick = time.Second / 30 + snapshotTick = 50 * time.Millisecond +) + type config struct { Port string DatabaseURL string @@ -53,6 +75,7 @@ type ticketStore struct { } func newTicketStore() *ticketStore { return &ticketStore{items: map[string]ticket{}} } + func (s *ticketStore) issue(subject, name string) (string, error) { buf := make([]byte, 24) if _, err := rand.Read(buf); err != nil { @@ -64,6 +87,7 @@ func (s *ticketStore) issue(subject, name string) (string, error) { s.mu.Unlock() return value, nil } + func (s *ticketStore) consume(value string) (ticket, bool) { s.mu.Lock() defer s.mu.Unlock() @@ -74,6 +98,7 @@ func (s *ticketStore) consume(value string) (ticket, bool) { } return item, true } + func (s *ticketStore) prune() { s.mu.Lock() defer s.mu.Unlock() @@ -86,35 +111,67 @@ func (s *ticketStore) prune() { } type playerState struct { - ID string `json:"id"` - Name string `json:"name"` - X float64 `json:"x"` - Y float64 `json:"y"` - Angle float64 `json:"angle"` - Hull int `json:"hull"` - Online bool `json:"online"` + ID string `json:"id"` + Name string `json:"name"` + X float64 `json:"x"` + Y float64 `json:"y"` + Angle float64 `json:"angle"` + Speed float64 `json:"speed"` + Hull int `json:"hull"` + MaxHull int `json:"maxHull"` + Alive bool `json:"alive"` + Online bool `json:"online"` + Kills int `json:"kills"` + Deaths int `json:"deaths"` + InputSeq int64 `json:"inputSeq"` + RespawnAt int64 `json:"respawnAt,omitempty"` + LastKiller string `json:"lastKiller,omitempty"` +} + +type inputState struct { + Throttle float64 + Steer float64 + Stop bool + Docked bool + Seq int64 +} + +type projectileState struct { + ID string `json:"id"` + OwnerID string `json:"ownerId"` + X float64 `json:"x"` + Y float64 `json:"y"` + VX float64 `json:"vx"` + VY float64 `json:"vy"` + Damage int `json:"damage"` + bornAt time.Time } type clientMessage struct { - Type string `json:"type"` - X float64 `json:"x"` - Y float64 `json:"y"` - Angle float64 `json:"angle"` - Hull int `json:"hull"` + Type string `json:"type"` + Seq int64 `json:"seq"` + Throttle float64 `json:"throttle"` + Steer float64 `json:"steer"` + Stop bool `json:"stop"` + Docked bool `json:"docked"` + Side string `json:"side"` } type serverMessage struct { - Type string `json:"type"` - PlayerID string `json:"playerId,omitempty"` - WorldSeed string `json:"worldSeed,omitempty"` - Players []playerState `json:"players,omitempty"` - ServerTime int64 `json:"serverTime,omitempty"` + Type string `json:"type"` + PlayerID string `json:"playerId,omitempty"` + WorldSeed string `json:"worldSeed,omitempty"` + Players []playerState `json:"players,omitempty"` + Projectiles []projectileState `json:"projectiles,omitempty"` + ServerTime int64 `json:"serverTime,omitempty"` } type client struct { - conn *websocket.Conn - writeMu sync.Mutex - state playerState + conn *websocket.Conn + writeMu sync.Mutex + state playerState + input inputState + lastFire map[string]time.Time } func (c *client) writeJSON(v any) error { @@ -125,12 +182,39 @@ func (c *client) writeJSON(v any) error { } type hub struct { - mu sync.RWMutex - clients map[string]*client - worldSeed string + mu sync.RWMutex + clients map[string]*client + projectiles map[string]projectileState + nextProjectile uint64 + worldSeed string +} + +func newHub(seed string) *hub { + return &hub{ + clients: map[string]*client{}, + projectiles: map[string]projectileState{}, + worldSeed: seed, + } +} + +func newPlayerState(id, name string, kills, deaths int) playerState { + x, y := spawnFor(id) + return playerState{ + ID: id, Name: name, X: x, Y: y, Angle: 0, Speed: 0, + Hull: playerMaxHull, MaxHull: playerMaxHull, Alive: true, Online: true, + Kills: kills, Deaths: deaths, + } +} + +func spawnFor(id string) (float64, float64) { + h := fnv.New32a() + _, _ = h.Write([]byte(id)) + v := h.Sum32() + dx := float64(int(v%241) - 120) + dy := float64(int((v/241)%181) - 90) + return clampFloat(spawnX+dx, 90, worldSize-90), clampFloat(spawnY+dy, 90, worldSize-90) } -func newHub(seed string) *hub { return &hub{clients: map[string]*client{}, worldSeed: seed} } func (h *hub) add(c *client) { h.mu.Lock() if previous := h.clients[c.state.ID]; previous != nil && previous != c { @@ -139,6 +223,7 @@ func (h *hub) add(c *client) { h.clients[c.state.ID] = c h.mu.Unlock() } + func (h *hub) remove(id string, c *client) { h.mu.Lock() if h.clients[id] == c { @@ -146,37 +231,208 @@ func (h *hub) remove(id string, c *client) { } h.mu.Unlock() } -func (h *hub) update(id string, msg clientMessage) { - h.mu.Lock() - if c := h.clients[id]; c != nil { - c.state.X = clampFloat(msg.X, 0, 3600) - c.state.Y = clampFloat(msg.Y, 0, 3600) - c.state.Angle = normalizeAngle(msg.Angle) - c.state.Hull = clampInt(msg.Hull, 0, 500) - } - h.mu.Unlock() + +func (h *hub) count() int { + h.mu.RLock() + defer h.mu.RUnlock() + return len(h.clients) } -func (h *hub) snapshot() ([]*client, []playerState) { + +func (h *hub) updateInput(id string, msg clientMessage) { + h.mu.Lock() + defer h.mu.Unlock() + c := h.clients[id] + if c == nil || msg.Seq <= c.input.Seq { + return + } + c.input = inputState{ + Throttle: clampFloat(msg.Throttle, -1, 1), + Steer: clampFloat(msg.Steer, -1, 1), + Stop: msg.Stop, + Docked: msg.Docked, + Seq: msg.Seq, + } +} + +func (h *hub) fire(id, side string, now time.Time) bool { + h.mu.Lock() + defer h.mu.Unlock() + c := h.clients[id] + if c == nil || !c.state.Alive || (side != "port" && side != "starboard") { + return false + } + if c.lastFire == nil { + c.lastFire = map[string]time.Time{} + } + if last := c.lastFire[side]; !last.IsZero() && now.Sub(last) < broadsideReload { + return false + } + c.lastFire[side] = now + + sideSign := 1.0 + if side == "port" { + sideSign = -1 + } + base := headingRadians(c.state.Angle) + sideSign*math.Pi/2 + shipVX := math.Cos(headingRadians(c.state.Angle)) * c.state.Speed + shipVY := math.Sin(headingRadians(c.state.Angle)) * c.state.Speed + + for _, offset := range []float64{-7, 0, 7} { + angle := base + offset*math.Pi/180 + h.nextProjectile++ + projectileID := id + ":" + strconv.FormatUint(h.nextProjectile, 10) + h.projectiles[projectileID] = projectileState{ + ID: projectileID, OwnerID: id, + X: c.state.X + math.Cos(base)*34, + Y: c.state.Y + math.Sin(base)*34, + VX: math.Cos(angle)*shotSpeed + shipVX*0.25, + VY: math.Sin(angle)*shotSpeed + shipVY*0.25, + Damage: shotDamage, + bornAt: now, + } + } + return true +} + +func (h *hub) step(now time.Time, dt float64) { + h.mu.Lock() + defer h.mu.Unlock() + + for _, c := range h.clients { + stepPlayer(&c.state, c.input, now, dt) + } + + for id, shot := range h.projectiles { + if now.Sub(shot.bornAt) >= shotLifetime { + delete(h.projectiles, id) + continue + } + shot.X += shot.VX * dt + shot.Y += shot.VY * dt + if shot.X < -80 || shot.X > worldSize+80 || shot.Y < -80 || shot.Y > worldSize+80 { + delete(h.projectiles, id) + continue + } + + hit := false + for _, target := range h.clients { + if target.state.ID == shot.OwnerID || !target.state.Alive { + continue + } + dx := target.state.X - shot.X + dy := target.state.Y - shot.Y + if dx*dx+dy*dy > shipHitRadius*shipHitRadius { + continue + } + target.state.Hull = clampInt(target.state.Hull-shot.Damage, 0, target.state.MaxHull) + if target.state.Hull <= 0 { + target.state.Alive = false + target.state.Speed = 0 + target.state.Deaths++ + target.state.RespawnAt = now.Add(respawnDelay).UnixMilli() + if owner := h.clients[shot.OwnerID]; owner != nil { + owner.state.Kills++ + target.state.LastKiller = owner.state.Name + } + } + delete(h.projectiles, id) + hit = true + break + } + if hit { + continue + } + h.projectiles[id] = shot + } +} + +func stepPlayer(state *playerState, input inputState, now time.Time, dt float64) { + state.InputSeq = input.Seq + if !state.Alive { + if state.RespawnAt > 0 && now.UnixMilli() >= state.RespawnAt { + x, y := spawnFor(state.ID) + state.X = x + state.Y = y + state.Angle = 0 + state.Speed = 0 + state.Hull = state.MaxHull + state.Alive = true + state.RespawnAt = 0 + state.LastKiller = "" + } + return + } + if input.Docked { + state.Speed = 0 + return + } + + if input.Throttle > 0 { + state.Speed += playerAcceleration * input.Throttle * dt + } else if input.Throttle < 0 { + state.Speed += playerBrake * input.Throttle * dt + } else if !input.Stop { + state.Speed *= math.Pow(playerCoastDrag, dt*60) + } + if input.Stop { + state.Speed *= math.Pow(0.025, dt) + } + state.Speed = clampFloat(state.Speed, playerReverseSpeed, playerMaxSpeed) + + turnStrength := clampFloat(math.Abs(state.Speed)/100, 0.28, 1) + directionSign := 1.0 + if state.Speed < 0 { + directionSign = -1 + } + state.Angle = normalizeAngle(state.Angle + playerTurnRate*turnStrength*directionSign*input.Steer*dt) + + heading := headingRadians(state.Angle) + state.X = clampFloat(state.X+math.Cos(heading)*state.Speed*dt, 0, worldSize) + state.Y = clampFloat(state.Y+math.Sin(heading)*state.Speed*dt, 0, worldSize) +} + +func headingRadians(angle float64) float64 { return (angle - 90) * math.Pi / 180 } + +func (h *hub) snapshot() ([]*client, []playerState, []projectileState) { h.mu.RLock() defer h.mu.RUnlock() clients := make([]*client, 0, len(h.clients)) players := make([]playerState, 0, len(h.clients)) + projectiles := make([]projectileState, 0, len(h.projectiles)) for _, c := range h.clients { clients = append(clients, c) players = append(players, c.state) } - return clients, players + for _, shot := range h.projectiles { + projectiles = append(projectiles, shot) + } + return clients, players, projectiles } + func (h *hub) run(ctx context.Context) { - ticker := time.NewTicker(100 * time.Millisecond) - defer ticker.Stop() + simTicker := time.NewTicker(simTick) + snapshotTicker := time.NewTicker(snapshotTick) + defer simTicker.Stop() + defer snapshotTicker.Stop() + lastSim := time.Now() + for { select { case <-ctx.Done(): return - case now := <-ticker.C: - clients, players := h.snapshot() - msg := serverMessage{Type: "snapshot", Players: players, ServerTime: now.UnixMilli()} + case now := <-simTicker.C: + dt := now.Sub(lastSim).Seconds() + if dt <= 0 || dt > 0.1 { + dt = simTick.Seconds() + } + lastSim = now + h.step(now, dt) + case now := <-snapshotTicker.C: + clients, players, projectiles := h.snapshot() + msg := serverMessage{ + Type: "snapshot", Players: players, Projectiles: projectiles, + ServerTime: now.UnixMilli(), + } for _, c := range clients { if err := c.writeJSON(msg); err != nil { _ = c.conn.Close() @@ -198,6 +454,7 @@ func clampFloat(v, min, max float64) float64 { } return v } + func clampInt(v, min, max int) int { if v < min { return min @@ -207,6 +464,7 @@ func clampInt(v, min, max int) int { } return v } + func normalizeAngle(v float64) float64 { if math.IsNaN(v) || math.IsInf(v, 0) { return 0 @@ -236,6 +494,7 @@ func loadConfig() (config, error) { } return cfg, nil } + func env(key, fallback string) string { if v := os.Getenv(key); v != "" { return v @@ -251,13 +510,18 @@ func migrate(ctx context.Context, db *pgxpool.Pool) error { credits INTEGER NOT NULL DEFAULT 650, scrap INTEGER NOT NULL DEFAULT 0, progression JSONB NOT NULL DEFAULT '{}'::jsonb, + pvp_kills INTEGER NOT NULL DEFAULT 0, + pvp_deaths INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now() - ) + ); + ALTER TABLE player_profiles ADD COLUMN IF NOT EXISTS pvp_kills INTEGER NOT NULL DEFAULT 0; + ALTER TABLE player_profiles ADD COLUMN IF NOT EXISTS pvp_deaths INTEGER NOT NULL DEFAULT 0 `) return err } + func ensurePlayer(ctx context.Context, db *pgxpool.Pool, sub, name string) error { _, err := db.Exec(ctx, ` INSERT INTO player_profiles (keycloak_sub, display_name) @@ -270,6 +534,24 @@ func ensurePlayer(ctx context.Context, db *pgxpool.Pool, sub, name string) error return err } +func loadPvPStats(ctx context.Context, db *pgxpool.Pool, sub string) (int, int, error) { + var kills, deaths int + err := db.QueryRow(ctx, `SELECT pvp_kills, pvp_deaths FROM player_profiles WHERE keycloak_sub = $1`, sub).Scan(&kills, &deaths) + return kills, deaths, err +} + +func savePvPStats(ctx context.Context, db *pgxpool.Pool, state playerState) error { + _, err := db.Exec(ctx, ` + UPDATE player_profiles SET + pvp_kills = $2, + pvp_deaths = $3, + updated_at = now(), + last_seen_at = now() + WHERE keycloak_sub = $1 + `, state.ID, state.Kills, state.Deaths) + return err +} + type app struct { cfg config db *pgxpool.Pool @@ -314,6 +596,7 @@ func (a *app) authenticate(next http.Handler) http.Handler { next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), claimsKey, c))) }) } + func audienceMatches(c claims, clientID string) bool { if c.AuthorizedParty == clientID { return true @@ -330,6 +613,7 @@ func audienceMatches(c claims, clientID string) bool { } return false } + func (a *app) cors(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") @@ -346,11 +630,13 @@ func (a *app) cors(next http.Handler) http.Handler { next.ServeHTTP(w, r) }) } + func jsonResponse(w http.ResponseWriter, status int, value any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(value) } + func (a *app) health(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), time.Second) defer cancel() @@ -358,12 +644,17 @@ func (a *app) health(w http.ResponseWriter, r *http.Request) { jsonResponse(w, 503, map[string]any{"ok": false, "database": false}) return } - jsonResponse(w, 200, map[string]any{"ok": true, "database": true, "players": len(a.hub.clients), "worldSeed": a.cfg.WorldSeed}) + jsonResponse(w, 200, map[string]any{ + "ok": true, "database": true, "players": a.hub.count(), + "worldSeed": a.cfg.WorldSeed, "authority": "movement+pvp", + }) } + func (a *app) me(w http.ResponseWriter, r *http.Request) { c := r.Context().Value(claimsKey).(claims) jsonResponse(w, 200, map[string]any{"id": c.Subject, "name": c.PreferredUsername, "worldSeed": a.cfg.WorldSeed}) } + func (a *app) wsTicket(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -377,6 +668,7 @@ func (a *app) wsTicket(w http.ResponseWriter, r *http.Request) { } jsonResponse(w, 201, map[string]any{"ticket": value, "expiresIn": 30}) } + func (a *app) ws(w http.ResponseWriter, r *http.Request) { value := r.URL.Query().Get("ticket") t, ok := a.tickets.consume(value) @@ -388,7 +680,16 @@ func (a *app) ws(w http.ResponseWriter, r *http.Request) { if err != nil { return } - c := &client{conn: conn, state: playerState{ID: t.Subject, Name: t.Name, X: 1200, Y: 1550, Angle: 0, Hull: 100, Online: true}} + + kills, deaths, err := loadPvPStats(r.Context(), a.db, t.Subject) + if err != nil { + kills, deaths = 0, 0 + } + c := &client{ + conn: conn, + state: newPlayerState(t.Subject, t.Name, kills, deaths), + lastFire: map[string]time.Time{}, + } a.hub.add(c) defer func() { a.hub.remove(t.Subject, c) @@ -396,8 +697,13 @@ func (a *app) ws(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() _ = ensurePlayer(ctx, a.db, t.Subject, t.Name) + _ = savePvPStats(ctx, a.db, c.state) }() - _ = c.writeJSON(serverMessage{Type: "welcome", PlayerID: t.Subject, WorldSeed: a.cfg.WorldSeed, ServerTime: time.Now().UnixMilli()}) + + _ = c.writeJSON(serverMessage{ + Type: "welcome", PlayerID: t.Subject, WorldSeed: a.cfg.WorldSeed, + ServerTime: time.Now().UnixMilli(), + }) conn.SetReadLimit(4096) for { _ = conn.SetReadDeadline(time.Now().Add(45 * time.Second)) @@ -405,8 +711,11 @@ func (a *app) ws(w http.ResponseWriter, r *http.Request) { if err := conn.ReadJSON(&msg); err != nil { return } - if msg.Type == "state" { - a.hub.update(t.Subject, msg) + switch msg.Type { + case "input": + a.hub.updateInput(t.Subject, msg) + case "fire": + a.hub.fire(t.Subject, strings.ToLower(msg.Side), time.Now()) } } } @@ -433,7 +742,10 @@ func main() { log.Fatalf("oidc provider: %v", err) } verifier := provider.Verifier(&oidc.Config{SkipClientIDCheck: true}) - application := &app{cfg: cfg, db: db, verifier: verifier, tickets: newTicketStore(), hub: newHub(cfg.WorldSeed)} + application := &app{ + cfg: cfg, db: db, verifier: verifier, + tickets: newTicketStore(), hub: newHub(cfg.WorldSeed), + } application.upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { origin := r.Header.Get("Origin") return origin == "" || cfg.AllowedOrigins[origin] @@ -446,14 +758,16 @@ func main() { application.tickets.prune() } }() + mux := http.NewServeMux() mux.HandleFunc("/health", application.health) mux.Handle("/api/me", application.cors(application.authenticate(http.HandlerFunc(application.me)))) mux.Handle("/api/ws-ticket", application.cors(application.authenticate(http.HandlerFunc(application.wsTicket)))) mux.HandleFunc("/ws", application.ws) - server := &http.Server{Addr: ":" + cfg.Port, Handler: mux, ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second} - log.Printf("airship game server listening on :%s world=%s", cfg.Port, cfg.WorldSeed) + server := &http.Server{ + Addr: ":" + cfg.Port, Handler: mux, + ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second, + } + log.Printf("airship game server listening on :%s world=%s authority=movement+pvp", cfg.Port, cfg.WorldSeed) log.Fatal(server.ListenAndServe()) } - -func init() { _ = fmt.Sprintf } diff --git a/server/main_test.go b/server/main_test.go index 8c2d70d..7b062cc 100644 --- a/server/main_test.go +++ b/server/main_test.go @@ -3,6 +3,7 @@ package main import ( "math" "testing" + "time" ) func TestTicketSingleUse(t *testing.T) { @@ -20,21 +21,18 @@ func TestTicketSingleUse(t *testing.T) { } func TestTransformSanitizers(t *testing.T) { - if got := clampFloat(-5, 0, 3600); got != 0 { + if got := clampFloat(-5, 0, worldSize); got != 0 { t.Fatalf("clamp low=%v", got) } - if got := clampFloat(4000, 0, 3600); got != 3600 { + if got := clampFloat(4000, 0, worldSize); got != worldSize { t.Fatalf("clamp high=%v", got) } - if got := clampFloat(math.NaN(), 0, 3600); got != 0 { + if got := clampFloat(math.NaN(), 0, worldSize); got != 0 { t.Fatalf("nan=%v", got) } if got := normalizeAngle(-90); got != 270 { t.Fatalf("angle=%v", got) } - if got := clampInt(900, 0, 500); got != 500 { - t.Fatalf("hull=%v", got) - } } func TestAudienceMatch(t *testing.T) { @@ -48,3 +46,99 @@ func TestAudienceMatch(t *testing.T) { t.Fatal("wrong client must not match") } } + +func TestServerMovementUsesInputNotClientTransform(t *testing.T) { + state := newPlayerState("player-1", "Dave", 0, 0) + startX, startY := state.X, state.Y + input := inputState{Throttle: 1, Steer: 0.4, Seq: 7} + now := time.Unix(100, 0) + for i := 0; i < 30; i++ { + stepPlayer(&state, input, now.Add(time.Duration(i)*simTick), simTick.Seconds()) + } + if state.InputSeq != 7 { + t.Fatalf("input seq=%d", state.InputSeq) + } + if state.Speed <= 0 || state.Speed > playerMaxSpeed { + t.Fatalf("speed=%v", state.Speed) + } + if state.X == startX && state.Y == startY { + t.Fatal("authoritative simulation did not move player") + } + if state.Angle == 0 { + t.Fatal("steer input did not change angle") + } +} + +func TestDockedInputFreezesAuthoritativeShip(t *testing.T) { + state := newPlayerState("player-1", "Dave", 0, 0) + state.Speed = 150 + x, y := state.X, state.Y + stepPlayer(&state, inputState{Docked: true, Seq: 1}, time.Now(), simTick.Seconds()) + if state.Speed != 0 || state.X != x || state.Y != y { + t.Fatalf("docked ship moved: speed=%v x=%v y=%v", state.Speed, state.X, state.Y) + } +} + +func TestBroadsideSpawnsThreeServerProjectilesAndEnforcesReload(t *testing.T) { + h := newHub("TEST") + c := &client{state: newPlayerState("one", "One", 0, 0), lastFire: map[string]time.Time{}} + h.clients[c.state.ID] = c + now := time.Unix(100, 0) + if !h.fire("one", "port", now) { + t.Fatal("first fire should succeed") + } + if len(h.projectiles) != 3 { + t.Fatalf("projectiles=%d", len(h.projectiles)) + } + if h.fire("one", "port", now.Add(100*time.Millisecond)) { + t.Fatal("reload should reject early second broadside") + } + if !h.fire("one", "port", now.Add(broadsideReload)) { + t.Fatal("broadside should fire after reload") + } + if len(h.projectiles) != 6 { + t.Fatalf("projectiles after reload=%d", len(h.projectiles)) + } +} + +func TestServerProjectileAppliesDamageDeathAndKill(t *testing.T) { + h := newHub("TEST") + shooter := &client{state: newPlayerState("one", "One", 0, 0), lastFire: map[string]time.Time{}} + target := &client{state: newPlayerState("two", "Two", 0, 0), lastFire: map[string]time.Time{}} + shooter.state.X, shooter.state.Y = 1000, 1000 + target.state.X, target.state.Y = 1050, 1000 + target.state.Hull = shotDamage + h.clients[shooter.state.ID] = shooter + h.clients[target.state.ID] = target + now := time.Unix(100, 0) + h.projectiles["shot"] = projectileState{ + ID: "shot", OwnerID: shooter.state.ID, X: target.state.X, Y: target.state.Y, + Damage: shotDamage, bornAt: now, + } + h.step(now.Add(simTick), simTick.Seconds()) + if target.state.Alive { + t.Fatal("target should be dead") + } + if target.state.Deaths != 1 || shooter.state.Kills != 1 { + t.Fatalf("kills/deaths=%d/%d", shooter.state.Kills, target.state.Deaths) + } + if target.state.LastKiller != "One" { + t.Fatalf("killer=%q", target.state.LastKiller) + } + if _, ok := h.projectiles["shot"]; ok { + t.Fatal("hit projectile should be removed") + } +} + +func TestServerRespawnsDestroyedPlayer(t *testing.T) { + state := newPlayerState("player-1", "Dave", 0, 1) + now := time.Unix(100, 0) + state.Alive = false + state.Hull = 0 + state.Speed = 120 + state.RespawnAt = now.Add(respawnDelay).UnixMilli() + stepPlayer(&state, inputState{}, now.Add(respawnDelay+time.Millisecond), simTick.Seconds()) + if !state.Alive || state.Hull != playerMaxHull || state.Speed != 0 { + t.Fatalf("bad respawn: %+v", state) + } +} diff --git a/src/game-v4.js b/src/game-v4.js index 24645f4..979317e 100644 --- a/src/game-v4.js +++ b/src/game-v4.js @@ -145,6 +145,11 @@ class GameScene extends Phaser.Scene { this.tradeLosses = 0; this.playerTradeLosses = 0; this.remotePlayers = new Map(); + this.multiplayerProjectiles = new Map(); + this.multiplayerAuthoritativeSelf = null; + this.multiplayerHasAuthoritativeState = false; + this.multiplayerDead = false; + this.multiplayerLastHull = null; this.multiplayerSendAccumulatorMs = 0; airshipLog('scene:init', { @@ -185,9 +190,22 @@ class GameScene extends Phaser.Scene { this.createPlayerSmoke(); this.handleMultiplayerSnapshot = (event) => { - this.syncRemotePlayers(event.detail?.players || []); + this.syncMultiplayerSnapshot(event.detail || {}); + }; + this.handleMultiplayerState = (event) => { + if (event.detail?.state === 'online') return; + this.clearMultiplayerEntities(); + this.multiplayerAuthoritativeSelf = null; + this.multiplayerHasAuthoritativeState = false; + this.multiplayerDead = false; + this.multiplayerLastHull = null; + if (this.player && this.gameState === 'playing') { + this.player.setVisible(true); + this.playerHull = Math.max(1, this.playerHull || this.playerStats?.maxHull || PLAYER_MAX_HULL); + } }; window.addEventListener('airship:multiplayer-snapshot', this.handleMultiplayerSnapshot); + window.addEventListener('airship:multiplayer-state', this.handleMultiplayerState); this.pirates = this.physics.add.group(); this.merchants = this.physics.add.group(); @@ -284,11 +302,8 @@ class GameScene extends Phaser.Scene { window.removeEventListener('airship:helm-vector', this.handleHelmVector); window.removeEventListener('airship:action', this.handleMobileAction); window.removeEventListener('airship:multiplayer-snapshot', this.handleMultiplayerSnapshot); - for (const remote of this.remotePlayers.values()) { - remote.sprite?.destroy(); - remote.label?.destroy(); - } - this.remotePlayers.clear(); + window.removeEventListener('airship:multiplayer-state', this.handleMultiplayerState); + this.clearMultiplayerEntities(); airshipLog('scene:shutdown', { gameState: this.gameState, hull: this.playerHull, @@ -831,6 +846,7 @@ class GameScene extends Phaser.Scene { // Airships can cross islands, but lose momentum while flying through the terrain envelope. this.physics.add.overlap(this.player, obstacle, () => { + if (window.airshipMultiplayer?.isConnected?.()) return; this.playerSpeed *= 0.985; }); this.physics.add.overlap( @@ -1451,6 +1467,71 @@ class GameScene extends Phaser.Scene { } } + clearMultiplayerEntities() { + for (const remote of this.remotePlayers.values()) { + remote.sprite?.destroy(); + remote.label?.destroy(); + } + this.remotePlayers.clear(); + for (const projectile of this.multiplayerProjectiles.values()) { + projectile.sprite?.destroy(); + } + this.multiplayerProjectiles.clear(); + } + + syncMultiplayerSnapshot(snapshot = {}) { + this.syncAuthoritativeSelf(snapshot.self || null); + this.syncRemotePlayers(snapshot.players || []); + this.syncMultiplayerProjectiles(snapshot.projectiles || []); + } + + syncAuthoritativeSelf(state) { + if (!state || !this.player) return; + + const wasDead = this.multiplayerDead; + const firstState = !this.multiplayerHasAuthoritativeState; + const previousHull = this.multiplayerLastHull; + this.multiplayerAuthoritativeSelf = { ...state }; + this.multiplayerHasAuthoritativeState = true; + this.multiplayerDead = state.alive === false; + this.multiplayerLastHull = Math.max(0, Number(state.hull) || 0); + + if (this.multiplayerDead) { + if (!wasDead) { + this.spawnExplosion(this.player.x, this.player.y, 1.2); + this.status.setText(`SHOT DOWN${state.lastKiller ? ` BY ${state.lastKiller}` : ''}\nRESPAWNING...`); + } + this.playerHull = 0; + this.playerSpeed = 0; + this.player.setVelocity(0, 0); + this.player.setVisible(false); + return; + } + + if (firstState || wasDead) { + this.player.setVisible(true); + this.player.setPosition(Number(state.x) || this.world.spawn.x, Number(state.y) || this.world.spawn.y); + this.player.angle = Number(state.angle) || 0; + this.playerSpeed = Number(state.speed) || 0; + this.playerHull = Math.max(0, Number(state.hull) || PLAYER_MAX_HULL); + if (wasDead) { + this.status.setText('REDEPLOYED'); + this.time.delayedCall(900, () => { + if (this.gameState === 'playing' && !this.multiplayerDead) this.status.setText(''); + }); + } + return; + } + + if (previousHull != null && state.hull < previousHull) { + const damage = previousHull - state.hull; + this.playerHitSequence += 1; + this.spawnExplosion(this.player.x, this.player.y, 0.32); + this.playPlayerHitReaction(damage); + } + this.playerHull = Math.max(0, Number(state.hull) || 0); + } + syncRemotePlayers(players = []) { const seen = new Set(); @@ -1488,16 +1569,23 @@ class GameScene extends Phaser.Scene { targetX: sprite.x, targetY: sprite.y, targetAngle: Number(state.angle) || 0, - hull: Number(state.hull) || 100, + hull: Number(state.hull) || PLAYER_MAX_HULL, + alive: state.alive !== false, }; this.remotePlayers.set(state.id, remote); } + const wasAlive = remote.alive; remote.targetX = clamp(Number(state.x) || 0, 0, this.world.width); remote.targetY = clamp(Number(state.y) || 0, 0, this.world.height); remote.targetAngle = normalizeDegrees(Number(state.angle) || 0); remote.hull = Math.max(0, Number(state.hull) || 0); - remote.label.setText(`${state.name || 'CAPTAIN'} ${Math.round(remote.hull)}`); + remote.alive = state.alive !== false; + if (wasAlive && !remote.alive) this.spawnExplosion(remote.sprite.x, remote.sprite.y, 1.05); + remote.sprite.setVisible(remote.alive); + remote.label.setText(remote.alive + ? `${state.name || 'CAPTAIN'} ${Math.round(remote.hull)}HP K${state.kills || 0}/D${state.deaths || 0}` + : `${state.name || 'CAPTAIN'} • DOWN`); } for (const [id, remote] of this.remotePlayers) { @@ -1508,16 +1596,89 @@ class GameScene extends Phaser.Scene { } } + syncMultiplayerProjectiles(projectiles = []) { + const seen = new Set(); + const selfId = window.airshipMultiplayer?.getSelfId?.(); + + for (const state of projectiles) { + if (!state?.id) continue; + seen.add(state.id); + let projectile = this.multiplayerProjectiles.get(state.id); + if (!projectile) { + const sprite = this.add.image(Number(state.x) || 0, Number(state.y) || 0, 'cannonball'); + sprite + .setDepth(8) + .setDisplaySize(14, 14) + .setTint(state.ownerId === selfId ? 0xffdf91 : 0x8cd3ff); + projectile = { sprite, vx: 0, vy: 0 }; + this.multiplayerProjectiles.set(state.id, projectile); + } + + const targetX = Number(state.x) || 0; + const targetY = Number(state.y) || 0; + const error = Math.hypot(projectile.sprite.x - targetX, projectile.sprite.y - targetY); + if (error > 90) { + projectile.sprite.setPosition(targetX, targetY); + } else { + projectile.sprite.x = Phaser.Math.Linear(projectile.sprite.x, targetX, 0.48); + projectile.sprite.y = Phaser.Math.Linear(projectile.sprite.y, targetY, 0.48); + } + projectile.vx = Number(state.vx) || 0; + projectile.vy = Number(state.vy) || 0; + } + + for (const [id, projectile] of this.multiplayerProjectiles) { + if (seen.has(id)) continue; + projectile.sprite?.destroy(); + this.multiplayerProjectiles.delete(id); + } + } + + multiplayerControlInput() { + if (!this.cursors || !this.keys) return { throttle: 0, steer: 0, stop: false, docked: true }; + const rawHelmX = Math.abs(this.helmVector.x) > HELM_DEAD_ZONE ? this.helmVector.x : 0; + const helmX = Math.sign(rawHelmX) * Math.pow(Math.abs(rawHelmX), HELM_STEER_CURVE); + const helmY = Math.abs(this.helmVector.y) > HELM_DEAD_ZONE ? this.helmVector.y : 0; + const throttleForward = this.cursors.up.isDown ? 1 : Math.max(0, -helmY); + const throttleReverse = this.cursors.down.isDown ? 1 : Math.max(0, helmY); + const keyboardTurn = (this.cursors.right.isDown ? 1 : 0) - (this.cursors.left.isDown ? 1 : 0); + return { + throttle: clamp(throttleForward - throttleReverse, -1, 1), + steer: keyboardTurn || helmX, + stop: this.keys.stop.isDown, + docked: this.dockOpen || this.shipyardOpen || this.multiplayerDead, + }; + } + updateMultiplayer(deltaMs) { + const online = Boolean(window.airshipMultiplayer?.isConnected?.()); + if (!online) return; + this.multiplayerSendAccumulatorMs += deltaMs; - if (this.multiplayerSendAccumulatorMs >= 100) { - this.multiplayerSendAccumulatorMs %= 100; - window.airshipMultiplayer?.sendState?.({ - x: this.player?.x || 0, - y: this.player?.y || 0, - angle: this.player?.angle || 0, - hull: this.playerHull, - }); + if (this.multiplayerSendAccumulatorMs >= 50) { + this.multiplayerSendAccumulatorMs %= 50; + window.airshipMultiplayer?.sendInput?.(this.multiplayerControlInput()); + } + + const authoritative = this.multiplayerAuthoritativeSelf; + if (authoritative && !this.multiplayerDead && this.player?.visible) { + const targetX = Number(authoritative.x) || 0; + const targetY = Number(authoritative.y) || 0; + const positionError = Math.hypot(this.player.x - targetX, this.player.y - targetY); + if (positionError > 180) { + this.player.setPosition(targetX, targetY); + } else { + this.player.x = Phaser.Math.Linear(this.player.x, targetX, 0.14); + this.player.y = Phaser.Math.Linear(this.player.y, targetY, 0.14); + } + this.player.angle += signedAngleDelta(Number(authoritative.angle) || 0, this.player.angle) * 0.16; + this.playerSpeed = Phaser.Math.Linear(this.playerSpeed, Number(authoritative.speed) || 0, 0.16); + } + + const dt = Math.min(deltaMs / 1000, 0.05); + for (const projectile of this.multiplayerProjectiles.values()) { + projectile.sprite.x += projectile.vx * dt; + projectile.sprite.y += projectile.vy * dt; } for (const remote of this.remotePlayers.values()) { @@ -1530,8 +1691,52 @@ class GameScene extends Phaser.Scene { } } + updateMultiplayerPredictedPlayer(dt, deltaMs) { + if (!this.player?.body || this.multiplayerDead) return; + const input = this.multiplayerControlInput(); + + if (input.throttle > 0) this.playerSpeed += PLAYER_ACCELERATION * input.throttle * dt; + if (input.throttle < 0) this.playerSpeed += PLAYER_BRAKE * input.throttle * dt; + if (input.stop) this.playerSpeed *= Math.pow(0.025, dt); + if (input.throttle === 0 && !input.stop) { + this.playerSpeed *= Math.pow(PLAYER_COAST_DRAG, deltaMs / 16.667); + } + + this.playerSpeed = clamp(this.playerSpeed, PLAYER_REVERSE_SPEED, PLAYER_MAX_SPEED); + const turnStrength = clamp(Math.abs(this.playerSpeed) / 100, 0.28, 1); + const directionSign = this.playerSpeed < 0 ? -1 : 1; + this.player.angle += PLAYER_TURN_RATE * turnStrength * directionSign * input.steer * dt; + + const effectiveForwardThrust = clamp(this.playerSpeed / PLAYER_MAX_SPEED, 0, 1); + if (effectiveForwardThrust > 0.06) { + if (!this.player.anims.isPlaying || this.player.anims.currentAnim?.key !== 'player-forward') { + this.player.play('player-forward'); + } + this.player.anims.timeScale = 0.65 + effectiveForwardThrust * 1.35; + } else { + if (this.player.anims.isPlaying) this.player.anims.stop(); + this.player.setFrame(0); + this.player.anims.timeScale = 1; + } + + this.environmentWind = environmentWindAt({ + x: this.player.x, + y: this.player.y, + timeMs: this.time.now, + islands: this.world?.islands, + }); + const h = headingRad(this.player); + this.player.setVelocity(Math.cos(h) * this.playerSpeed, Math.sin(h) * this.playerSpeed); + + if (Phaser.Input.Keyboard.JustDown(this.keys.firePort)) this.playerBroadside(-1); + if (Phaser.Input.Keyboard.JustDown(this.keys.fireStarboard)) this.playerBroadside(1); + } updatePlayer(dt, deltaMs) { if (!this.player?.body) return; + if (window.airshipMultiplayer?.isConnected?.()) { + this.updateMultiplayerPredictedPlayer(dt, deltaMs); + return; + } const stats = this.playerStats || deriveShipStats(this.progression?.loadout, this.progression?.installedConditions); const rawHelmX = Math.abs(this.helmVector.x) > HELM_DEAD_ZONE ? this.helmVector.x : 0; @@ -1673,7 +1878,10 @@ class GameScene extends Phaser.Scene { playerBroadside(side) { const broadside = side < 0 ? 'port' : 'starboard'; - if (this.time.now - this.lastPlayerShot[broadside] < this.playerBroadsideReloadMs) return; + const multiplayerOnline = Boolean(window.airshipMultiplayer?.isConnected?.()); + const reloadMs = multiplayerOnline ? PLAYER_BROADSIDE_RELOAD_MS : this.playerBroadsideReloadMs; + if (this.time.now - this.lastPlayerShot[broadside] < reloadMs) return; + if (multiplayerOnline) window.airshipMultiplayer?.fire?.(broadside); this.fireBroadside( this.player, side, @@ -1706,6 +1914,9 @@ class GameScene extends Phaser.Scene { const y = ship.y + Math.sin(base) * 34; const shot = group.create(x, y, texture); shot.setDepth(8).setDisplaySize(14, 14); + if (actor === 'player' && window.airshipMultiplayer?.isConnected?.()) { + shot.setVisible(false); + } shot.body.setAllowGravity(false); shot.setData('bornAt', this.time.now); shot.setData('damage', projectileDamage); @@ -1785,6 +1996,8 @@ class GameScene extends Phaser.Scene { hitPlayer(first, second) { if (this.gameState !== 'playing') return; + // Online player damage is owned by the Go server; local pirates remain client-side until AI migrates. + if (window.airshipMultiplayer?.isConnected?.()) return; const shot = resolveTaggedProjectile(first, second, 'pirate'); if (!shot?.active || shot === this.player) { @@ -1923,6 +2136,7 @@ class GameScene extends Phaser.Scene { } validateLivePlayerState() { + if (this.multiplayerDead) return; if (this.gameState !== 'playing' || this.playerHull <= 0 || !this.player) return; const issues = []; @@ -2037,7 +2251,9 @@ class GameScene extends Phaser.Scene { kills: this.defeated, player: { hull: this.playerHull, - maxHull: this.playerStats?.maxHull || PLAYER_MAX_HULL, + maxHull: window.airshipMultiplayer?.isConnected?.() + ? this.multiplayerAuthoritativeSelf?.maxHull || PLAYER_MAX_HULL + : this.playerStats?.maxHull || PLAYER_MAX_HULL, hitSequence: this.playerHitSequence, }, target: nearest ? { @@ -2251,6 +2467,7 @@ class GameScene extends Phaser.Scene { } checkGameState() { + if (window.airshipMultiplayer?.isConnected?.()) return; if (this.playerHull <= 0) this.finishGame(); } diff --git a/src/multiplayer.js b/src/multiplayer.js index ba1b3c3..06ae6af 100644 --- a/src/multiplayer.js +++ b/src/multiplayer.js @@ -8,12 +8,15 @@ const KEYCLOAK_CONFIG = { clientId: 'airship-web', }; +const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); + const keycloak = new Keycloak(KEYCLOAK_CONFIG); let socket = null; let selfId = null; let reconnectTimer = null; let authenticated = false; let playerCount = 0; +let inputSequence = 0; const statusButton = document.createElement('button'); statusButton.id = 'multiplayer-status'; @@ -30,7 +33,7 @@ style.textContent = ` left: max(10px, env(safe-area-inset-left)); z-index: 410; min-height: 38px; - max-width: 180px; + max-width: 190px; padding: 7px 11px; border: 1px solid rgba(220, 168, 79, .72); border-radius: 999px; @@ -95,6 +98,7 @@ const connect = async () => { socket = ws; ws.addEventListener('open', () => { + inputSequence = 0; setStatus('online', 'MULTIPLAYER • ONLINE'); emitConnection('online'); }); @@ -110,13 +114,24 @@ const connect = async () => { } if (message.type === 'snapshot') { - const players = Array.isArray(message.players) - ? message.players.filter((player) => player?.id && player.id !== selfId) + const allPlayers = Array.isArray(message.players) + ? message.players.filter((player) => player?.id) : []; - playerCount = players.length + 1; - setStatus('online', `MULTIPLAYER • ${playerCount}`); + const self = allPlayers.find((player) => player.id === selfId) || null; + const players = allPlayers.filter((player) => player.id !== selfId); + const projectiles = Array.isArray(message.projectiles) + ? message.projectiles.filter((shot) => shot?.id) + : []; + playerCount = allPlayers.length; + const score = self ? ` • ${self.kills || 0}/${self.deaths || 0}` : ''; + setStatus('online', `MULTIPLAYER • ${playerCount}${score}`); window.dispatchEvent(new CustomEvent('airship:multiplayer-snapshot', { - detail: { players, serverTime: message.serverTime || 0 }, + detail: { + self, + players, + projectiles, + serverTime: message.serverTime || 0, + }, })); } }); @@ -134,15 +149,24 @@ const connect = async () => { }); }; -const sendState = (state) => { - if (socket?.readyState !== WebSocket.OPEN || !state) return false; +const sendInput = (input = {}) => { + if (socket?.readyState !== WebSocket.OPEN) return 0; + inputSequence += 1; socket.send(JSON.stringify({ - type: 'state', - x: Number(state.x) || 0, - y: Number(state.y) || 0, - angle: Number(state.angle) || 0, - hull: Math.max(0, Math.round(Number(state.hull) || 0)), + type: 'input', + seq: inputSequence, + throttle: clamp(Number(input.throttle) || 0, -1, 1), + steer: clamp(Number(input.steer) || 0, -1, 1), + stop: Boolean(input.stop), + docked: Boolean(input.docked), })); + return inputSequence; +}; + +const fire = (side) => { + if (socket?.readyState !== WebSocket.OPEN) return false; + const normalized = side === 'port' || side === -1 ? 'port' : 'starboard'; + socket.send(JSON.stringify({ type: 'fire', side: normalized })); return true; }; @@ -158,10 +182,12 @@ statusButton.addEventListener('click', () => { }); window.airshipMultiplayer = { - sendState, + sendInput, + fire, login, logout, connect, + getSelfId: () => selfId, isAuthenticated: () => authenticated, isConnected: () => socket?.readyState === WebSocket.OPEN, }; diff --git a/tests/wiring.test.mjs b/tests/wiring.test.mjs index cfb1170..1a7a2fa 100644 --- a/tests/wiring.test.mjs +++ b/tests/wiring.test.mjs @@ -178,7 +178,8 @@ test('player firing and reload HUD share one reload duration source', async () = const source = await read('src/game-v4.js'); assert.match(source, /this\.playerBroadsideReloadMs = PLAYER_BROADSIDE_RELOAD_MS/); - assert.match(source, /this\.time\.now - this\.lastPlayerShot\[broadside\] < this\.playerBroadsideReloadMs/); + assert.match(source, /const reloadMs = multiplayerOnline \? PLAYER_BROADSIDE_RELOAD_MS : this\.playerBroadsideReloadMs/); + assert.match(source, /this\.time\.now - this\.lastPlayerShot\[broadside\] < reloadMs/); assert.match(source, /Math\.max\(1, this\.playerBroadsideReloadMs\)/); }); @@ -263,7 +264,7 @@ test('environment physics adds wind, soft island collision, turbulence, and proj assert.match(source, /windSpeed: this\.environmentWind\?\.strength/); }); -test('multiplayer transport uses Keycloak tickets and renders remote Sparrows without physics authority', async () => { +test('multiplayer transport uses Keycloak tickets with server-authoritative movement and PvP', async () => { const [game, entry, multiplayer, pkg, server] = await Promise.all([ read('src/game-v4.js'), read('src/game-entry.js'), @@ -276,18 +277,28 @@ test('multiplayer transport uses Keycloak tickets and renders remote Sparrows wi assert.match(pkg, /"keycloak-js"/); assert.match(multiplayer, /clientId: 'airship-web'/); assert.match(multiplayer, /\/api\/ws-ticket/); - assert.match(multiplayer, /Authorization:/); - assert.match(multiplayer, /new WebSocket/); + assert.match(multiplayer, /type: 'input'/); + assert.match(multiplayer, /type: 'fire'/); + assert.doesNotMatch(multiplayer, /type: 'state'/); assert.match(game, /airship:multiplayer-snapshot/); - assert.match(game, /this\.remotePlayers = new Map\(\)/); - assert.match(game, /window\.airshipMultiplayer\?\.sendState/); - assert.match(game, /this\.add\.sprite\([\s\S]*?'player-airship'/); - assert.match(server, /CREATE TABLE IF NOT EXISTS player_profiles/); - assert.match(server, /a\.tickets\.consume/); - assert.match(server, /clampFloat\(msg\.X, 0, 3600\)/); - assert.match(server, /time\.NewTicker\(100 \* time\.Millisecond\)/); + assert.match(game, /syncAuthoritativeSelf/); + assert.match(game, /syncMultiplayerProjectiles/); + assert.match(game, /updateMultiplayerPredictedPlayer/); + assert.match(game, /window\.airshipMultiplayer\?\.sendInput/); + assert.match(game, /window\.airshipMultiplayer\?\.fire/); + assert.match(game, /this\.multiplayerDead/); + assert.match(server, /type inputState struct/); + assert.match(server, /func \(h \*hub\) updateInput/); + assert.match(server, /func \(h \*hub\) fire/); + assert.match(server, /func \(h \*hub\) step/); + assert.match(server, /stepPlayer\(&c\.state, c\.input/); + assert.match(server, /target\.state\.Hull = clampInt/); + assert.match(server, /target\.state\.RespawnAt/); + assert.match(server, /time\.NewTicker\(simTick\)/); + assert.match(server, /snapshotTick/); + assert.doesNotMatch(server, /msg\.X/); + assert.doesNotMatch(server, /msg\.Hull/); }); - test('mobile controls enter a dock hub before shipyard, trade, or missions', async () => { const [game, hud, index, entry, dockUi] = await Promise.all([ read('src/game-v4.js'),