Make route weather authoritative in multiplayer
This commit is contained in:
parent
05d9c11075
commit
0fa667460b
216
server/main.go
216
server/main.go
@ -40,6 +40,13 @@ const (
|
||||
respawnDelay = 2200 * time.Millisecond
|
||||
simTick = time.Second / 30
|
||||
snapshotTick = 50 * time.Millisecond
|
||||
windPatternMS = 16000.0
|
||||
windMinStrength = 7.0
|
||||
windMaxStrength = 24.0
|
||||
routeMinCoreRadius = 220.0
|
||||
routeEdgeDepth = 320.0
|
||||
stormFrontPeriodMS = 110000.0
|
||||
stormFrontRadius = 720.0
|
||||
)
|
||||
|
||||
type config struct {
|
||||
@ -149,14 +156,33 @@ type projectileState struct {
|
||||
bornAt time.Time
|
||||
}
|
||||
|
||||
type weatherRoute struct {
|
||||
ID string `json:"id"`
|
||||
FromX float64 `json:"fromX"`
|
||||
FromY float64 `json:"fromY"`
|
||||
ToX float64 `json:"toX"`
|
||||
ToY float64 `json:"toY"`
|
||||
Width float64 `json:"width"`
|
||||
}
|
||||
|
||||
type weatherWind struct {
|
||||
X float64
|
||||
Y float64
|
||||
Strength float64
|
||||
RouteInfluence float64
|
||||
RouteEdge float64
|
||||
StormIntensity float64
|
||||
}
|
||||
|
||||
type clientMessage struct {
|
||||
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 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"`
|
||||
Routes []weatherRoute `json:"routes,omitempty"`
|
||||
}
|
||||
|
||||
type serverMessage struct {
|
||||
@ -193,6 +219,7 @@ type hub struct {
|
||||
projectiles map[string]projectileState
|
||||
nextProjectile uint64
|
||||
worldSeed string
|
||||
weatherRoutes []weatherRoute
|
||||
}
|
||||
|
||||
func newHub(seed string, fakePlayers ...int) *hub {
|
||||
@ -281,6 +308,152 @@ func (h *hub) count() int {
|
||||
return len(h.clients)
|
||||
}
|
||||
|
||||
func (h *hub) setWeatherRoutes(routes []weatherRoute) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if len(h.weatherRoutes) > 0 || len(routes) == 0 {
|
||||
return
|
||||
}
|
||||
count := len(routes)
|
||||
if count > 24 {
|
||||
count = 24
|
||||
}
|
||||
h.weatherRoutes = make([]weatherRoute, 0, count)
|
||||
for _, route := range routes[:count] {
|
||||
h.weatherRoutes = append(h.weatherRoutes, weatherRoute{
|
||||
ID: strings.TrimSpace(route.ID),
|
||||
FromX: clampFloat(route.FromX, 0, worldSize),
|
||||
FromY: clampFloat(route.FromY, 0, worldSize),
|
||||
ToX: clampFloat(route.ToX, 0, worldSize),
|
||||
ToY: clampFloat(route.ToY, 0, worldSize),
|
||||
Width: clampFloat(route.Width, 120, 320),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func smootherStep01(value float64) float64 {
|
||||
t := clampFloat(value, 0, 1)
|
||||
return t * t * t * (t*(t*6-15) + 10)
|
||||
}
|
||||
|
||||
func hash01(index float64, salt float64) float64 {
|
||||
value := math.Sin(index*12.9898+salt*78.233) * 43758.5453123
|
||||
return value - math.Floor(value)
|
||||
}
|
||||
|
||||
func shortestAngleDeltaRad(from, to float64) float64 {
|
||||
delta := math.Mod(to-from, math.Pi*2)
|
||||
if delta > math.Pi {
|
||||
delta -= math.Pi * 2
|
||||
}
|
||||
if delta < -math.Pi {
|
||||
delta += math.Pi * 2
|
||||
}
|
||||
return delta
|
||||
}
|
||||
|
||||
func windTargetForSlot(slot float64) (float64, float64) {
|
||||
angle := hash01(slot, 1) * math.Pi * 2
|
||||
strength := windMinStrength + hash01(slot, 2)*(windMaxStrength-windMinStrength)
|
||||
return angle, strength
|
||||
}
|
||||
|
||||
func semiRandomWindPatternAt(timeMs float64) (float64, float64) {
|
||||
if timeMs < 0 {
|
||||
timeMs = 0
|
||||
}
|
||||
slot := math.Floor(timeMs / windPatternMS)
|
||||
phase := math.Mod(timeMs, windPatternMS) / windPatternMS
|
||||
currentAngle, currentStrength := windTargetForSlot(slot)
|
||||
nextAngle, nextStrength := windTargetForSlot(slot + 1)
|
||||
blend := smootherStep01((phase - 0.18) / 0.82)
|
||||
angle := currentAngle + shortestAngleDeltaRad(currentAngle, nextAngle)*blend
|
||||
strength := currentStrength + (nextStrength-currentStrength)*blend
|
||||
gust := math.Sin(timeMs/2300+slot*0.73) * (1.1 + hash01(slot, 3)*1.8)
|
||||
return angle, math.Max(4, strength+gust)
|
||||
}
|
||||
|
||||
func pointSegmentDistanceXY(x, y float64, route weatherRoute) float64 {
|
||||
abX := route.ToX - route.FromX
|
||||
abY := route.ToY - route.FromY
|
||||
apX := x - route.FromX
|
||||
apY := y - route.FromY
|
||||
lengthSquared := abX*abX + abY*abY
|
||||
if lengthSquared <= 1 {
|
||||
return math.Hypot(apX, apY)
|
||||
}
|
||||
t := clampFloat((apX*abX+apY*abY)/lengthSquared, 0, 1)
|
||||
nearestX := route.FromX + abX*t
|
||||
nearestY := route.FromY + abY*t
|
||||
return math.Hypot(x-nearestX, y-nearestY)
|
||||
}
|
||||
|
||||
func (h *hub) routeWeatherAtLocked(x, y float64) (float64, float64) {
|
||||
if len(h.weatherRoutes) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
distance := math.Inf(1)
|
||||
width := 220.0
|
||||
for _, route := range h.weatherRoutes {
|
||||
candidate := pointSegmentDistanceXY(x, y, route)
|
||||
if candidate < distance {
|
||||
distance = candidate
|
||||
width = route.Width
|
||||
}
|
||||
}
|
||||
core := math.Max(routeMinCoreRadius, width*0.9)
|
||||
outer := core + routeEdgeDepth
|
||||
calmStart := core * 0.65
|
||||
influence := 1 - smootherStep01((distance-calmStart)/math.Max(1, outer-calmStart))
|
||||
edgeCenter := core + routeEdgeDepth*0.5
|
||||
edgeHalf := routeEdgeDepth * 0.62
|
||||
edge := smootherStep01(1 - math.Abs(distance-edgeCenter)/edgeHalf)
|
||||
return clampFloat(influence, 0, 1), clampFloat(edge, 0, 1)
|
||||
}
|
||||
|
||||
func stormFrontIntensityAt(x, y, timeMs float64) float64 {
|
||||
raw := math.Mod(timeMs, stormFrontPeriodMS)
|
||||
if raw < 0 {
|
||||
raw += stormFrontPeriodMS
|
||||
}
|
||||
phase := raw / stormFrontPeriodMS
|
||||
front := func(offset, yBase, yWave, scale float64) float64 {
|
||||
t := math.Mod(phase+offset, 1)
|
||||
centerX := -900 + t*(worldSize+1800)
|
||||
centerY := worldSize * (yBase + math.Sin(t*math.Pi*2+offset*4.7)*yWave)
|
||||
distance := math.Hypot((x-centerX)/1.55, y-centerY)
|
||||
return clampFloat(1-distance/stormFrontRadius, 0, 1) * scale
|
||||
}
|
||||
return math.Max(
|
||||
front(0, 0.35, 0.18, 0.95),
|
||||
front(0.53, 0.72, 0.13, 0.74),
|
||||
)
|
||||
}
|
||||
|
||||
func (h *hub) weatherWindAtLocked(x, y float64, now time.Time) weatherWind {
|
||||
timeMs := float64(now.UnixMilli())
|
||||
timeSeconds := timeMs / 1000
|
||||
angle, strength := semiRandomWindPatternAt(timeMs)
|
||||
routeInfluence, routeEdge := h.routeWeatherAtLocked(x, y)
|
||||
openExposure := 1 - routeInfluence
|
||||
storm := stormFrontIntensityAt(x, y, timeMs)
|
||||
directionalVariance := 0.022 + openExposure*0.09 + routeEdge*0.055 + storm*0.2
|
||||
localAngle := angle +
|
||||
math.Sin((x+y)/880+timeSeconds/31)*directionalVariance +
|
||||
math.Sin(timeSeconds/5.2+x/1400)*storm*0.12
|
||||
strengthScale := 0.72 + openExposure*0.5 + routeEdge*0.16 + storm*0.68
|
||||
localStrength := strength*strengthScale +
|
||||
math.Sin((x-y)/760+timeSeconds/4.7)*(0.45+openExposure*0.9+storm*1.2)
|
||||
return weatherWind{
|
||||
X: math.Cos(localAngle) * localStrength,
|
||||
Y: math.Sin(localAngle) * localStrength,
|
||||
Strength: math.Abs(localStrength),
|
||||
RouteInfluence: routeInfluence,
|
||||
RouteEdge: routeEdge,
|
||||
StormIntensity: storm,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *hub) updateInput(id string, msg clientMessage) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
@ -322,6 +495,7 @@ func (h *hub) fireLocked(c *client, side string, now time.Time) bool {
|
||||
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
|
||||
shotWind := h.weatherWindAtLocked(c.state.X, c.state.Y, now)
|
||||
|
||||
for _, offset := range []float64{-7, 0, 7} {
|
||||
angle := base + offset*math.Pi/180
|
||||
@ -331,8 +505,8 @@ func (h *hub) fireLocked(c *client, side string, now time.Time) bool {
|
||||
ID: projectileID, OwnerID: c.state.ID,
|
||||
X: c.state.X + math.Cos(base)*28,
|
||||
Y: c.state.Y + math.Sin(base)*28,
|
||||
VX: math.Cos(angle)*shotSpeed + shipVX*0.25,
|
||||
VY: math.Sin(angle)*shotSpeed + shipVY*0.25,
|
||||
VX: math.Cos(angle)*shotSpeed + shipVX*0.25 + shotWind.X*0.22,
|
||||
VY: math.Sin(angle)*shotSpeed + shipVY*0.25 + shotWind.Y*0.22,
|
||||
Damage: shotDamage,
|
||||
bornAt: now,
|
||||
}
|
||||
@ -345,16 +519,17 @@ func (h *hub) step(now time.Time, dt float64) {
|
||||
defer h.mu.Unlock()
|
||||
|
||||
for _, c := range h.clients {
|
||||
wind := h.weatherWindAtLocked(c.state.X, c.state.Y, now)
|
||||
if c.state.Bot {
|
||||
input, fireSide := h.fakeCaptainDecisionLocked(c, now)
|
||||
c.input = input
|
||||
stepPlayer(&c.state, c.input, now, dt)
|
||||
stepPlayerWithWind(&c.state, c.input, now, dt, wind.X, wind.Y)
|
||||
if fireSide != "" {
|
||||
h.fireLocked(c, fireSide, now)
|
||||
}
|
||||
continue
|
||||
}
|
||||
stepPlayer(&c.state, c.input, now, dt)
|
||||
stepPlayerWithWind(&c.state, c.input, now, dt, wind.X, wind.Y)
|
||||
}
|
||||
|
||||
for id, shot := range h.projectiles {
|
||||
@ -482,6 +657,17 @@ func (h *hub) fakeCaptainDecisionLocked(c *client, now time.Time) (inputState, s
|
||||
}
|
||||
|
||||
func stepPlayer(state *playerState, input inputState, now time.Time, dt float64) {
|
||||
stepPlayerWithWind(state, input, now, dt, 0, 0)
|
||||
}
|
||||
|
||||
func stepPlayerWithWind(
|
||||
state *playerState,
|
||||
input inputState,
|
||||
now time.Time,
|
||||
dt float64,
|
||||
windX float64,
|
||||
windY float64,
|
||||
) {
|
||||
state.InputSeq = input.Seq
|
||||
if !state.Alive {
|
||||
if state.RespawnAt > 0 && now.UnixMilli() >= state.RespawnAt {
|
||||
@ -522,8 +708,8 @@ func stepPlayer(state *playerState, input inputState, now time.Time, dt float64)
|
||||
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)
|
||||
state.X = clampFloat(state.X+(math.Cos(heading)*state.Speed+windX)*dt, 0, worldSize)
|
||||
state.Y = clampFloat(state.Y+(math.Sin(heading)*state.Speed+windY)*dt, 0, worldSize)
|
||||
}
|
||||
|
||||
func headingRadians(angle float64) float64 { return (angle - 90) * math.Pi / 180 }
|
||||
@ -854,7 +1040,7 @@ func (a *app) ws(w http.ResponseWriter, r *http.Request) {
|
||||
Type: "welcome", PlayerID: t.Subject, WorldSeed: a.cfg.WorldSeed,
|
||||
ServerTime: time.Now().UnixMilli(),
|
||||
})
|
||||
conn.SetReadLimit(4096)
|
||||
conn.SetReadLimit(8192)
|
||||
for {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(45 * time.Second))
|
||||
var msg clientMessage
|
||||
@ -866,6 +1052,8 @@ func (a *app) ws(w http.ResponseWriter, r *http.Request) {
|
||||
a.hub.updateInput(t.Subject, msg)
|
||||
case "fire":
|
||||
a.hub.fire(t.Subject, strings.ToLower(msg.Side), time.Now())
|
||||
case "weather-layout":
|
||||
a.hub.setWeatherRoutes(msg.Routes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -226,3 +226,51 @@ func TestFakeCaptainDoesNotAggroHumanAcrossWholeWorld(t *testing.T) {
|
||||
t.Fatalf("bot should patrol when human is far: %+v", input)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func TestShippingRouteWeatherCalmsAuthoritativeWind(t *testing.T) {
|
||||
h := newHub("TEST")
|
||||
h.setWeatherRoutes([]weatherRoute{{
|
||||
ID: "route-1", FromX: 1000, FromY: 1000, ToX: 3600, ToY: 1000, Width: 220,
|
||||
}})
|
||||
now := time.UnixMilli(0)
|
||||
lane := h.weatherWindAtLocked(2200, 1000, now)
|
||||
open := h.weatherWindAtLocked(2200, 2200, now)
|
||||
if lane.RouteInfluence < 0.9 {
|
||||
t.Fatalf("lane influence=%v", lane.RouteInfluence)
|
||||
}
|
||||
if open.RouteInfluence > 0.05 {
|
||||
t.Fatalf("open influence=%v", open.RouteInfluence)
|
||||
}
|
||||
if lane.Strength >= open.Strength {
|
||||
t.Fatalf("lane wind=%v open wind=%v", lane.Strength, open.Strength)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeatherLayoutIsSanitizedAndOnlyAcceptedOnce(t *testing.T) {
|
||||
h := newHub("TEST")
|
||||
h.setWeatherRoutes([]weatherRoute{{
|
||||
ID: "route", FromX: -20, FromY: 100, ToX: 9000, ToY: 200, Width: 900,
|
||||
}})
|
||||
if len(h.weatherRoutes) != 1 {
|
||||
t.Fatalf("routes=%d", len(h.weatherRoutes))
|
||||
}
|
||||
route := h.weatherRoutes[0]
|
||||
if route.FromX != 0 || route.ToX != worldSize || route.Width != 320 {
|
||||
t.Fatalf("unsanitized route=%+v", route)
|
||||
}
|
||||
h.setWeatherRoutes([]weatherRoute{{ID: "replacement", FromX: 1, FromY: 1, ToX: 2, ToY: 2, Width: 120}})
|
||||
if h.weatherRoutes[0].ID != "route" {
|
||||
t.Fatal("weather layout should be immutable after first accepted layout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthoritativeMovementIncludesWeatherDrift(t *testing.T) {
|
||||
state := newPlayerState("weather-player", "Weather", 0, 0)
|
||||
state.X, state.Y = 2000, 2000
|
||||
startX, startY := state.X, state.Y
|
||||
stepPlayerWithWind(&state, inputState{Seq: 1}, time.Unix(100, 0), 1, 12, -7)
|
||||
if state.X == startX || state.Y == startY {
|
||||
t.Fatalf("weather drift not applied: x=%v y=%v", state.X, state.Y)
|
||||
}
|
||||
}
|
||||
|
||||
@ -351,6 +351,32 @@ test('larger shared world uses smaller player ships and wider fake-captain spaci
|
||||
assert.match(server, /distance <= 1100/);
|
||||
});
|
||||
|
||||
test('shipping-route weather is shared by client visuals and authoritative multiplayer movement', async () => {
|
||||
const [game, environment, multiplayer, server] = await Promise.all([
|
||||
read('src/game-v4.js'),
|
||||
read('src/environment-physics.js'),
|
||||
read('src/multiplayer.js'),
|
||||
read('server/main.go'),
|
||||
]);
|
||||
|
||||
assert.match(environment, /routeWeatherAt/);
|
||||
assert.match(environment, /stormFrontAt/);
|
||||
assert.match(environment, /zone: 'shipping-lane'/);
|
||||
assert.match(environment, /zone: 'route-edge'/);
|
||||
assert.match(game, /routes: this\.world\?\.tradeRoutes/);
|
||||
assert.match(game, /routeCloudiness/);
|
||||
assert.match(game, /multiplayerServerClockOffsetMs/);
|
||||
assert.match(game, /setWeatherRoutes\?\.\(this\.world\.tradeRoutes\)/);
|
||||
assert.match(game, /weatherZone: this\.environmentWind\?\.zone/);
|
||||
assert.match(multiplayer, /type: 'weather-layout'/);
|
||||
assert.match(multiplayer, /setWeatherRoutes/);
|
||||
assert.match(server, /type weatherRoute struct/);
|
||||
assert.match(server, /func \(h \*hub\) setWeatherRoutes/);
|
||||
assert.match(server, /func \(h \*hub\) weatherWindAtLocked/);
|
||||
assert.match(server, /stepPlayerWithWind/);
|
||||
assert.match(server, /case "weather-layout":/);
|
||||
});
|
||||
|
||||
test('authoritative multiplayer server seeds fake captains that move and fight like players', async () => {
|
||||
const server = await read('server/main.go');
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user