Background predictive chunk streaming

Chunk generation and sending no longer block the read loop. The read
loop pushes a non-blocking recenter request and stays free to handle
movement, chat, and keep-alive acks immediately; a per-connection
streamer goroutine generates chunks in a worker pool and sends them
serially under the write mutex.

- network/streamer.go: per-connection streamer. spiralOrder emits
  chunks centre-outward; parallelSend fans Cache.Frame across a worker
  pool (Cache.Frame is already goroutine-safe), parallelGenerate warms
  a one-ring predictive border so movement finds ready chunks; the
  loaded-set is owned solely by the streamer.
- network/play.go: beginPlay launches the streamer and pushes an
  initial recenter instead of the old blocking streamAround;
  onPlayerMove now just calls requestRecenter (non-blocking).
- network/handler.go: ctx (connection lifetime) + streamer field; the
  streamer stops when the read loop ends (cancel on serve exit).
- network/configuration.go: client view_distance is saved (clamped
  2..16) and drives the streamer radius.
- Tests: spiral order (centre-first, ring structure) and the
  non-blocking recenter guarantee the read loop relies on.
This commit is contained in:
Master290 2026-06-25 00:54:57 +03:00
parent d1cc29bb60
commit d7c80a8858
5 changed files with 478 additions and 58 deletions

View file

@ -0,0 +1,84 @@
package network
import (
"testing"
"time"
)
// TestSpiralOrderCenterFirst confirms the centre coordinate is returned first
// and the ring order expands outward (Chebyshev distance non-decreasing).
func TestSpiralOrderCenterFirst(t *testing.T) {
order := spiralOrder(0, 0, 2)
if order[0] != [2]int32{0, 0} {
t.Errorf("first = %v, want centre (0,0)", order[0])
}
// Each entry's Chebyshev distance must not exceed the next's... actually it
// only needs to be non-decreasing within ring blocks. Verify the max
// distance of the first k entries grows as expected by checking the full
// set contains exactly the (2*2+1)² = 49 distinct coords.
want := (2*2 + 1) * (2 * 2 + 1)
if len(order) != want {
t.Errorf("spiralOrder len = %d, want %d", len(order), want)
}
seen := make(map[[2]int32]bool, len(order))
for _, c := range order {
if seen[c] {
t.Errorf("duplicate %v in spiral order", c)
}
seen[c] = true
}
}
// TestSpiralOrderRingStructure checks that all distance-0 coords come before
// distance-1, which come before distance-2 (centre-outward ordering).
func TestSpiralOrderRingStructure(t *testing.T) {
order := spiralOrder(5, -3, 2)
cheb := func(c [2]int32) int32 {
dx := c[0] - 5
if dx < 0 {
dx = -dx
}
dz := c[1] - -3
if dz < 0 {
dz = -dz
}
if dx > dz {
return dx
}
return dz
}
// Track the max ring seen so far; it must never decrease (centre-first).
var maxRing int32
for _, c := range order {
r := cheb(c)
if r < maxRing {
t.Errorf("ring %d appeared after ring %d — not centre-first", r, maxRing)
}
if r > maxRing {
maxRing = r
}
}
if maxRing != 2 {
t.Errorf("max ring = %d, want 2", maxRing)
}
}
// TestRequestRecenterNonBlocking confirms requestRecenter never blocks the
// caller even when many requests are pushed rapidly (the streamer drains stale
// ones). This is the property the read loop relies on to stay responsive.
func TestRequestRecenterNonBlocking(t *testing.T) {
s := newStreamer(nil, nil, nil, 4)
done := make(chan struct{})
go func() {
for i := 0; i < 1000; i++ {
s.requestRecenter(int32(i), int32(i))
}
close(done)
}()
select {
case <-done:
// good: 1000 rapid requests returned without blocking
case <-time.After(2 * time.Second):
t.Fatal("requestRecenter blocked for 2s")
}
}