Sync authoritative fake multiplayer captains
This commit is contained in:
parent
1a5178bca5
commit
50f277256a
171
server/main.go
171
server/main.go
@ -49,6 +49,7 @@ type config struct {
|
|||||||
ClientID string
|
ClientID string
|
||||||
AllowedOrigins map[string]bool
|
AllowedOrigins map[string]bool
|
||||||
WorldSeed string
|
WorldSeed string
|
||||||
|
FakePlayers int
|
||||||
}
|
}
|
||||||
|
|
||||||
type claims struct {
|
type claims struct {
|
||||||
@ -126,6 +127,7 @@ type playerState struct {
|
|||||||
InputSeq int64 `json:"inputSeq"`
|
InputSeq int64 `json:"inputSeq"`
|
||||||
RespawnAt int64 `json:"respawnAt,omitempty"`
|
RespawnAt int64 `json:"respawnAt,omitempty"`
|
||||||
LastKiller string `json:"lastKiller,omitempty"`
|
LastKiller string `json:"lastKiller,omitempty"`
|
||||||
|
Bot bool `json:"bot,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type inputState struct {
|
type inputState struct {
|
||||||
@ -172,9 +174,13 @@ type client struct {
|
|||||||
state playerState
|
state playerState
|
||||||
input inputState
|
input inputState
|
||||||
lastFire map[string]time.Time
|
lastFire map[string]time.Time
|
||||||
|
botIndex int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *client) writeJSON(v any) error {
|
func (c *client) writeJSON(v any) error {
|
||||||
|
if c.conn == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
c.writeMu.Lock()
|
c.writeMu.Lock()
|
||||||
defer c.writeMu.Unlock()
|
defer c.writeMu.Unlock()
|
||||||
_ = c.conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
|
_ = c.conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
|
||||||
@ -189,12 +195,49 @@ type hub struct {
|
|||||||
worldSeed string
|
worldSeed string
|
||||||
}
|
}
|
||||||
|
|
||||||
func newHub(seed string) *hub {
|
func newHub(seed string, fakePlayers ...int) *hub {
|
||||||
return &hub{
|
h := &hub{
|
||||||
clients: map[string]*client{},
|
clients: map[string]*client{},
|
||||||
projectiles: map[string]projectileState{},
|
projectiles: map[string]projectileState{},
|
||||||
worldSeed: seed,
|
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 {
|
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 {
|
func (h *hub) fire(id, side string, now time.Time) bool {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
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") {
|
if c == nil || !c.state.Alive || (side != "port" && side != "starboard") {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@ -280,9 +326,9 @@ func (h *hub) fire(id, side string, now time.Time) bool {
|
|||||||
for _, offset := range []float64{-7, 0, 7} {
|
for _, offset := range []float64{-7, 0, 7} {
|
||||||
angle := base + offset*math.Pi/180
|
angle := base + offset*math.Pi/180
|
||||||
h.nextProjectile++
|
h.nextProjectile++
|
||||||
projectileID := id + ":" + strconv.FormatUint(h.nextProjectile, 10)
|
projectileID := c.state.ID + ":" + strconv.FormatUint(h.nextProjectile, 10)
|
||||||
h.projectiles[projectileID] = projectileState{
|
h.projectiles[projectileID] = projectileState{
|
||||||
ID: projectileID, OwnerID: id,
|
ID: projectileID, OwnerID: c.state.ID,
|
||||||
X: c.state.X + math.Cos(base)*34,
|
X: c.state.X + math.Cos(base)*34,
|
||||||
Y: c.state.Y + math.Sin(base)*34,
|
Y: c.state.Y + math.Sin(base)*34,
|
||||||
VX: math.Cos(angle)*shotSpeed + shipVX*0.25,
|
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()
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
for _, c := range h.clients {
|
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)
|
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) {
|
func stepPlayer(state *playerState, input inputState, now time.Time, dt float64) {
|
||||||
state.InputSeq = input.Seq
|
state.InputSeq = input.Seq
|
||||||
if !state.Alive {
|
if !state.Alive {
|
||||||
@ -400,7 +535,9 @@ func (h *hub) snapshot() ([]*client, []playerState, []projectileState) {
|
|||||||
players := make([]playerState, 0, len(h.clients))
|
players := make([]playerState, 0, len(h.clients))
|
||||||
projectiles := make([]projectileState, 0, len(h.projectiles))
|
projectiles := make([]projectileState, 0, len(h.projectiles))
|
||||||
for _, c := range h.clients {
|
for _, c := range h.clients {
|
||||||
clients = append(clients, c)
|
if c.conn != nil {
|
||||||
|
clients = append(clients, c)
|
||||||
|
}
|
||||||
players = append(players, c.state)
|
players = append(players, c.state)
|
||||||
}
|
}
|
||||||
for _, shot := range h.projectiles {
|
for _, shot := range h.projectiles {
|
||||||
@ -488,6 +625,7 @@ func loadConfig() (config, error) {
|
|||||||
Port: env("PORT", "8080"), DatabaseURL: os.Getenv("DATABASE_URL"),
|
Port: env("PORT", "8080"), DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||||
Issuer: os.Getenv("KEYCLOAK_ISSUER"), ClientID: os.Getenv("KEYCLOAK_CLIENT_ID"),
|
Issuer: os.Getenv("KEYCLOAK_ISSUER"), ClientID: os.Getenv("KEYCLOAK_CLIENT_ID"),
|
||||||
AllowedOrigins: origins, WorldSeed: env("WORLD_SEED", "SKYWARD-001"),
|
AllowedOrigins: origins, WorldSeed: env("WORLD_SEED", "SKYWARD-001"),
|
||||||
|
FakePlayers: envInt("FAKE_PLAYERS", 3),
|
||||||
}
|
}
|
||||||
if cfg.DatabaseURL == "" || cfg.Issuer == "" || cfg.ClientID == "" {
|
if cfg.DatabaseURL == "" || cfg.Issuer == "" || cfg.ClientID == "" {
|
||||||
return cfg, errors.New("DATABASE_URL, KEYCLOAK_ISSUER and KEYCLOAK_CLIENT_ID are required")
|
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
|
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 {
|
func migrate(ctx context.Context, db *pgxpool.Pool) error {
|
||||||
_, err := db.Exec(ctx, `
|
_, err := db.Exec(ctx, `
|
||||||
CREATE TABLE IF NOT EXISTS player_profiles (
|
CREATE TABLE IF NOT EXISTS player_profiles (
|
||||||
@ -744,7 +894,7 @@ func main() {
|
|||||||
verifier := provider.Verifier(&oidc.Config{SkipClientIDCheck: true})
|
verifier := provider.Verifier(&oidc.Config{SkipClientIDCheck: true})
|
||||||
application := &app{
|
application := &app{
|
||||||
cfg: cfg, db: db, verifier: verifier,
|
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 {
|
application.upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool {
|
||||||
origin := r.Header.Get("Origin")
|
origin := r.Header.Get("Origin")
|
||||||
@ -768,6 +918,11 @@ func main() {
|
|||||||
Addr: ":" + cfg.Port, Handler: mux,
|
Addr: ":" + cfg.Port, Handler: mux,
|
||||||
ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second,
|
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())
|
log.Fatal(server.ListenAndServe())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
"math"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@ -142,3 +143,50 @@ func TestServerRespawnsDestroyedPlayer(t *testing.T) {
|
|||||||
t.Fatalf("bad respawn: %+v", state)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -329,6 +329,21 @@ test('environment physics adds wind, soft island collision, turbulence, and proj
|
|||||||
assert.match(source, /windSpeed: this\.environmentWind\?\.strength/);
|
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 () => {
|
test('multiplayer transport uses Keycloak tickets with server-authoritative movement and PvP', async () => {
|
||||||
const [game, entry, multiplayer, pkg, server] = await Promise.all([
|
const [game, entry, multiplayer, pkg, server] = await Promise.all([
|
||||||
read('src/game-v4.js'),
|
read('src/game-v4.js'),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user