From e00c57ca25cc2b4b5b8814229eeba0ee5164e15c Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 21 Sep 2026 06:11:10 +0000 Subject: [PATCH] Add authoritative fake multiplayer captains --- server/main.go | 171 ++++++++++++++++++++++++++++++++++++++++-- server/main_test.go | 48 ++++++++++++ tests/wiring.test.mjs | 15 ++++ 3 files changed, 226 insertions(+), 8 deletions(-) diff --git a/server/main.go b/server/main.go index 6f5aee3..23b5cf6 100644 --- a/server/main.go +++ b/server/main.go @@ -49,6 +49,7 @@ type config struct { ClientID string AllowedOrigins map[string]bool WorldSeed string + FakePlayers int } type claims struct { @@ -126,6 +127,7 @@ type playerState struct { InputSeq int64 `json:"inputSeq"` RespawnAt int64 `json:"respawnAt,omitempty"` LastKiller string `json:"lastKiller,omitempty"` + Bot bool `json:"bot,omitempty"` } type inputState struct { @@ -172,9 +174,13 @@ type client struct { state playerState input inputState lastFire map[string]time.Time + botIndex int } func (c *client) writeJSON(v any) error { + if c.conn == nil { + return nil + } c.writeMu.Lock() defer c.writeMu.Unlock() _ = c.conn.SetWriteDeadline(time.Now().Add(2 * time.Second)) @@ -189,12 +195,49 @@ type hub struct { worldSeed string } -func newHub(seed string) *hub { - return &hub{ +func newHub(seed string, fakePlayers ...int) *hub { + h := &hub{ clients: map[string]*client{}, projectiles: map[string]projectileState{}, worldSeed: seed, } + count := 0 + if len(fakePlayers) > 0 { + count = clampInt(fakePlayers[0], 0, 8) + } + h.addFakeCaptains(count) + return h +} + +var fakeCaptainNames = []string{ + "Mara Voss", + "Brass Jack", + "Finn Gale", + "Iris Vane", + "Rowan Pike", + "Ada Flint", + "Silas Crow", + "Nell Mercer", +} + +func (h *hub) addFakeCaptains(count int) { + if count <= 0 { + return + } + for index := 0; index < count; index++ { + name := fakeCaptainNames[index%len(fakeCaptainNames)] + id := "bot:" + strings.ToLower(strings.ReplaceAll(name, " ", "-")) + state := newPlayerState(id, name, 0, 0) + phase := math.Pi * 2 * float64(index) / float64(count) + state.X = clampFloat(spawnX+math.Cos(phase)*300, 90, worldSize-90) + state.Y = clampFloat(spawnY+math.Sin(phase)*300, 90, worldSize-90) + state.Angle = normalizeAngle(phase*180/math.Pi + 90) + state.Bot = true + h.clients[id] = &client{ + state: state, botIndex: index, + lastFire: map[string]time.Time{}, + } + } } func newPlayerState(id, name string, kills, deaths int) playerState { @@ -257,7 +300,10 @@ func (h *hub) updateInput(id string, msg clientMessage) { func (h *hub) fire(id, side string, now time.Time) bool { h.mu.Lock() defer h.mu.Unlock() - c := h.clients[id] + return h.fireLocked(h.clients[id], side, now) +} + +func (h *hub) fireLocked(c *client, side string, now time.Time) bool { if c == nil || !c.state.Alive || (side != "port" && side != "starboard") { return false } @@ -280,9 +326,9 @@ func (h *hub) fire(id, side string, now time.Time) bool { for _, offset := range []float64{-7, 0, 7} { angle := base + offset*math.Pi/180 h.nextProjectile++ - projectileID := id + ":" + strconv.FormatUint(h.nextProjectile, 10) + projectileID := c.state.ID + ":" + strconv.FormatUint(h.nextProjectile, 10) h.projectiles[projectileID] = projectileState{ - ID: projectileID, OwnerID: id, + ID: projectileID, OwnerID: c.state.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, @@ -299,6 +345,15 @@ func (h *hub) step(now time.Time, dt float64) { defer h.mu.Unlock() for _, c := range h.clients { + if c.state.Bot { + input, fireSide := h.fakeCaptainDecisionLocked(c, now) + c.input = input + stepPlayer(&c.state, c.input, now, dt) + if fireSide != "" { + h.fireLocked(c, fireSide, now) + } + continue + } stepPlayer(&c.state, c.input, now, dt) } @@ -346,6 +401,86 @@ func (h *hub) step(now time.Time, dt float64) { } } +func signedAngleDelta(value float64) float64 { + value = math.Mod(value+180, 360) + if value < 0 { + value += 360 + } + return value - 180 +} + +func bearingDegrees(fromX, fromY, toX, toY float64) float64 { + return normalizeAngle(math.Atan2(toY-fromY, toX-fromX)*180/math.Pi + 90) +} + +func (h *hub) fakeCaptainDecisionLocked(c *client, now time.Time) (inputState, string) { + seq := c.input.Seq + 1 + if !c.state.Alive { + return inputState{Seq: seq}, "" + } + + var target *client + bestRange := math.Inf(1) + for _, candidate := range h.clients { + if candidate == c || candidate.state.Bot || !candidate.state.Alive { + continue + } + dx := candidate.state.X - c.state.X + dy := candidate.state.Y - c.state.Y + distance := math.Hypot(dx, dy) + if distance < bestRange { + target = candidate + bestRange = distance + } + } + + if target == nil { + phase := float64(c.botIndex)*2.1 + float64(now.UnixMilli()%60000)/60000*math.Pi*2 + radius := 420.0 + float64(c.botIndex)*75 + targetX := spawnX + math.Cos(phase)*radius + targetY := spawnY + math.Sin(phase)*radius + bearing := bearingDegrees(c.state.X, c.state.Y, targetX, targetY) + delta := signedAngleDelta(bearing - c.state.Angle) + return inputState{ + Throttle: 0.58, + Steer: clampFloat(delta/55, -1, 1), + Seq: seq, + }, "" + } + + bearing := bearingDegrees(c.state.X, c.state.Y, target.state.X, target.state.Y) + relative := signedAngleDelta(bearing - c.state.Angle) + if bestRange > 620 { + return inputState{ + Throttle: 0.9, + Steer: clampFloat(relative/55, -1, 1), + Seq: seq, + }, "" + } + + side := "starboard" + desiredRelative := 90.0 + if relative < 0 { + side = "port" + desiredRelative = -90 + } + broadsideError := signedAngleDelta(relative - desiredRelative) + throttle := 0.62 + if bestRange < 250 { + throttle = 0.28 + } + fireSide := "" + if bestRange >= 130 && bestRange <= 560 && math.Abs(broadsideError) <= 20 { + fireSide = side + } + + return inputState{ + Throttle: throttle, + Steer: clampFloat(broadsideError/50, -1, 1), + Seq: seq, + }, fireSide +} + func stepPlayer(state *playerState, input inputState, now time.Time, dt float64) { state.InputSeq = input.Seq if !state.Alive { @@ -400,7 +535,9 @@ func (h *hub) snapshot() ([]*client, []playerState, []projectileState) { players := make([]playerState, 0, len(h.clients)) projectiles := make([]projectileState, 0, len(h.projectiles)) for _, c := range h.clients { - clients = append(clients, c) + if c.conn != nil { + clients = append(clients, c) + } players = append(players, c.state) } for _, shot := range h.projectiles { @@ -488,6 +625,7 @@ func loadConfig() (config, error) { Port: env("PORT", "8080"), DatabaseURL: os.Getenv("DATABASE_URL"), Issuer: os.Getenv("KEYCLOAK_ISSUER"), ClientID: os.Getenv("KEYCLOAK_CLIENT_ID"), AllowedOrigins: origins, WorldSeed: env("WORLD_SEED", "SKYWARD-001"), + FakePlayers: envInt("FAKE_PLAYERS", 3), } if cfg.DatabaseURL == "" || cfg.Issuer == "" || cfg.ClientID == "" { return cfg, errors.New("DATABASE_URL, KEYCLOAK_ISSUER and KEYCLOAK_CLIENT_ID are required") @@ -502,6 +640,18 @@ func env(key, fallback string) string { return fallback } +func envInt(key string, fallback int) int { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + parsed, err := strconv.Atoi(value) + if err != nil { + return fallback + } + return parsed +} + func migrate(ctx context.Context, db *pgxpool.Pool) error { _, err := db.Exec(ctx, ` CREATE TABLE IF NOT EXISTS player_profiles ( @@ -744,7 +894,7 @@ func main() { verifier := provider.Verifier(&oidc.Config{SkipClientIDCheck: true}) application := &app{ cfg: cfg, db: db, verifier: verifier, - tickets: newTicketStore(), hub: newHub(cfg.WorldSeed), + tickets: newTicketStore(), hub: newHub(cfg.WorldSeed, cfg.FakePlayers), } application.upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { origin := r.Header.Get("Origin") @@ -768,6 +918,11 @@ func main() { 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.Printf( + "airship game server listening on :%s world=%s authority=movement+pvp fake_players=%d", + cfg.Port, + cfg.WorldSeed, + cfg.FakePlayers, + ) log.Fatal(server.ListenAndServe()) } diff --git a/server/main_test.go b/server/main_test.go index 7b062cc..f6c60fe 100644 --- a/server/main_test.go +++ b/server/main_test.go @@ -2,6 +2,7 @@ package main import ( "math" + "strings" "testing" "time" ) @@ -142,3 +143,50 @@ func TestServerRespawnsDestroyedPlayer(t *testing.T) { t.Fatalf("bad respawn: %+v", state) } } + + +func TestFakeCaptainsJoinSnapshotsWithoutSocketRecipients(t *testing.T) { + h := newHub("TEST", 3) + recipients, players, _ := h.snapshot() + if len(recipients) != 0 { + t.Fatalf("fake captains must not be websocket recipients: %d", len(recipients)) + } + if len(players) != 3 { + t.Fatalf("fake captain count=%d", len(players)) + } + for _, player := range players { + if !player.Bot || !strings.HasPrefix(player.ID, "bot:") { + t.Fatalf("expected bot snapshot state: %+v", player) + } + } +} + +func TestFakeCaptainEngagesNearbyHumanWithAuthoritativeBroadside(t *testing.T) { + h := newHub("TEST", 1) + var bot *client + for _, candidate := range h.clients { + if candidate.state.Bot { + bot = candidate + break + } + } + if bot == nil { + t.Fatal("missing fake captain") + } + human := &client{ + state: newPlayerState("human", "Human", 0, 0), + lastFire: map[string]time.Time{}, + } + bot.state.X, bot.state.Y, bot.state.Angle = 1000, 1000, 0 + human.state.X, human.state.Y = 1300, 1000 + h.clients[human.state.ID] = human + + now := time.Unix(200, 0) + h.step(now, simTick.Seconds()) + if len(h.projectiles) != 3 { + t.Fatalf("expected fake captain broadside, projectiles=%d", len(h.projectiles)) + } + if bot.input.Seq == 0 { + t.Fatal("fake captain input sequence did not advance") + } +} diff --git a/tests/wiring.test.mjs b/tests/wiring.test.mjs index 623e792..7804f25 100644 --- a/tests/wiring.test.mjs +++ b/tests/wiring.test.mjs @@ -329,6 +329,21 @@ test('environment physics adds wind, soft island collision, turbulence, and proj assert.match(source, /windSpeed: this\.environmentWind\?\.strength/); }); +test('authoritative multiplayer server seeds fake captains that move and fight like players', async () => { + const server = await read('server/main.go'); + + assert.match(server, /FakePlayers:\s+envInt\("FAKE_PLAYERS", 3\)/); + assert.match(server, /fakeCaptainNames/); + assert.match(server, /"Mara Voss"/); + assert.match(server, /"Brass Jack"/); + assert.match(server, /"Finn Gale"/); + assert.match(server, /Bot\s+bool\s+\`json:"bot,omitempty"\`/); + assert.match(server, /fakeCaptainDecisionLocked/); + assert.match(server, /h\.fireLocked\(c, fireSide, now\)/); + assert.match(server, /if c\.conn != nil/); + assert.match(server, /hub: newHub\(cfg\.WorldSeed, cfg\.FakePlayers\)/); +}); + 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'),