Run the world clock so day and night happen

set_time was declared nowhere and sent never, so the client's sky was frozen
wherever it started: the sun did not move, night did not fall, and nothing in
the world had a time.

26.1.2 replaced the old (gameTime, dayTime, doDaylightCycle) triple with a
registry of named clocks. The packet now carries a fixed-width game time plus a
map from world clock to (totalTicks, partialTick, rate); the client advances
each clock locally at its rate and drives the minecraft:day timeline -- a
keyframe track over a 24000-tick period -- from the overworld clock. The clock
registry is already among the 28 we sync, so the id is looked up rather than
hardcoded and the server refuses to start if it is missing.

The counter rides the entity tick loop because that is the only loop already
running at 20 TPS. It belongs on the single authoritative tick the engine still
needs; putting a sixth ticker beside the five that exist would make that worse.
Broadcast every second, which is what vanilla does -- the client interpolates in
between, so the resend only corrects drift.

The clock also persists now. The world metadata file was written once and never
touched again; it is atomically rewritable, carries gameTime and dayTime, and a
file written before those fields existed still opens and resumes at dawn as it
did. Saved every 30 seconds alongside the chunk autosave, and once more on
shutdown after the final flush.
This commit is contained in:
Master290 2026-07-27 02:45:28 +03:00
parent 4db7638377
commit 57214fbd76
9 changed files with 386 additions and 15 deletions

View file

@ -0,0 +1,117 @@
package server
import (
"bytes"
"context"
"encoding/binary"
"sync"
"testing"
"time"
"regionio/internal/protocol"
"regionio/internal/world"
)
// TestEncodeSetTime pins the wire layout of ClientboundSetTimePacket. 26.1.2
// replaced the old three-field packet with a game time plus a map of world
// clock to clock state, so there is no older shape to fall back on if this
// drifts — the client simply reads garbage.
func TestEncodeSetTime(t *testing.T) {
body := encodeSetTime(0x0102030405060708, 300)
var want bytes.Buffer
// gameTime is a fixed-width long, not a VarLong.
binary.Write(&want, binary.BigEndian, int64(0x0102030405060708))
want.WriteByte(1) // map size, VarInt
want.WriteByte(byte(worldClockOverworld))
want.Write([]byte{0xac, 0x02}) // dayTime 300 as a VarLong
binary.Write(&want, binary.BigEndian, float32(0)) // partialTick
binary.Write(&want, binary.BigEndian, float32(1)) // rate
if !bytes.Equal(body, want.Bytes()) {
t.Errorf("encodeSetTime =\n%x\nwant\n%x", body, want.Bytes())
}
}
// TestWorldClockAdvances checks the counters move together and that the sync
// cadence lands on whole seconds.
func TestWorldClockAdvances(t *testing.T) {
s := &Server{}
if gameTime, dayTime := s.WorldTime(); gameTime != 0 || dayTime != 0 {
t.Fatalf("a fresh world starts at %d/%d, want 0/0", gameTime, dayTime)
}
syncs := 0
for i := 0; i < TicksPerDay; i++ {
gameTime, dayTime := s.advanceWorldTime()
if gameTime != dayTime {
t.Fatalf("tick %d: gameTime %d and dayTime %d diverged with no command to move them", i, gameTime, dayTime)
}
if gameTime%timeSyncTicks == 0 {
syncs++
}
}
if want := TicksPerDay / timeSyncTicks; syncs != want {
t.Errorf("%d clock broadcasts over a full day, want %d", syncs, want)
}
gameTime, _ := s.WorldTime()
if gameTime != TicksPerDay {
t.Errorf("after a full day the clock reads %d, want %d", gameTime, TicksPerDay)
}
s.SetWorldTime(500, 18000)
if gameTime, dayTime := s.WorldTime(); gameTime != 500 || dayTime != 18000 {
t.Errorf("SetWorldTime gave %d/%d, want 500/18000", gameTime, dayTime)
}
}
// TestTickLoopBroadcastsTime drives the real 20 TPS loop and checks the clock
// actually reaches a connected player. The encoding test above cannot catch a
// clock that never gets sent.
func TestTickLoopBroadcastsTime(t *testing.T) {
cfg := DefaultConfig()
cfg.WorldDir = ""
srv, err := NewWithCache(cfg, world.NewCache(-1, world.GenerateFlat))
if err != nil {
t.Fatal(err)
}
var mu sync.Mutex
var bodies [][]byte
profile := Profile{Name: "Clockwatcher", UUID: OfflineUUID("Clockwatcher")}
session, err := srv.RegisterPlayer(profile, func(id int32, body []byte) error {
if id != protocol.PlaySetTime {
return nil
}
mu.Lock()
bodies = append(bodies, append([]byte(nil), body...))
mu.Unlock()
return nil
})
if err != nil {
t.Fatal(err)
}
defer srv.UnregisterPlayer(session)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
srv.StartSpawning(ctx, nil)
// Two sync intervals plus slack: the loop broadcasts once per 20 ticks.
time.Sleep(time.Duration(2*timeSyncTicks+8) * 50 * time.Millisecond)
cancel()
mu.Lock()
got := len(bodies)
var first []byte
if got > 0 {
first = bodies[0]
}
mu.Unlock()
if got < 1 {
t.Fatal("no set_time reached the player in two sync intervals")
}
if len(first) != 8+1+1+len(protocol.AppendVarLong(nil, int64(timeSyncTicks)))+4+4 {
t.Errorf("set_time body is %d bytes: %x", len(first), first)
}
if gameTime, _ := srv.WorldTime(); gameTime < timeSyncTicks {
t.Errorf("clock only reached %d ticks; the loop is not advancing it", gameTime)
}
}