10.3 每日重置与时区窗口
10.2 的任务都是「一次性」的:接了、做完了,就永远完成。但 MMO 里还有一大类任务是按日历重复触发的——每日委托、周常副本、签到奖励。它们每到一个固定时刻就要清空、重新可接。看上去只是「每天零点重置一下」,实际却是整条时间链上最容易翻车的地方。
卷一 14.1 服务器时间线与时钟同步 已经把一条铁律立住了:玩法只认单调时间线,绝不读墙钟。 但「每日重置」偏偏是一个日历概念——「今天」不是「过了 24 小时」,而是「日历翻了一页」。你没法用单调钟回答「现在是哪一天」,就像你没法用一个只增不减的计数器算出今天星期几。这一节要处理的,正是这条铁律的唯一例外:在哪一个受控的点上,允许服务器去读一次日历,并且保证读错、读重、读跳都不会出事。
三个具体的坑:第一,主机在哪个时区?如果直接读 time.Local,一台部署在 UTC、一台部署在 +08:00 的机器会对「今天」给出不同答案,玩家在两台之间跳服就能把每日奖励领两遍。第二,夏令时(DST)切换那天,一天不是 24 小时,任何「加 86400 秒」的写法都会错位。第三,时钟被 NTP 往回拨一下,「跨天」的判定会不会被触发两次?
为什么「今天」必须钉在固定时区
最省事的写法是 time.Now() 直接拿本地日期,但它把「今天」绑在了部署环境上。正确做法是把「今天」钉在游戏世界的固定时区上:一个固定的 UTC 偏移(比如 CN 大区 +08:00),加一个固定的时刻(比如 05:00)。这样无论进程跑在哪个机房,全球所有分片对「今天」的答案都一致——DailyKey 只依赖那个固定偏移,不依赖 time.Local。
时刻选 05:00 而不是 00:00,是为了照顾「跨零点还在玩的玩家」。凌晨一点上线的人,如果重置发生在零点,他会发现「昨天的每日任务还没做完就没了」;把重置推到 05:00,凌晨的游玩仍然算在同一个「今天」里。这是运营层面的取舍,落到代码上只是一个常量 DailyAt = 5h。
固定偏移还顺带绕开了 DST:我们不读任何带夏令时规则的时区数据库,只做一次 Add(offset)。DST 会让真实的一天变成 23 或 25 小时,但我们的窗口是按日历日切的,本来就不该跟着 DST 抖动。
把这两个常量画到时间轴上,一个「日」的样子就清楚了:
游戏时区 +08:00,重置点 05:00
...── 04:59 ──┤ 05:00 ├── 12:00 ── 23:00 ── 04:59 ──┤ 05:00 ├── ...
└─────────── 日键 = N ────────────┘ └──── 日键 = N+1 ────┘
▲ 重置在这里发生,且只发生一次
注意窗口的边界落在 05:00,而不是日历的 00:00。所以「10 月 8 日的每日任务」实际覆盖的是 10-08 05:00 到 10-09 04:59 这一段——玩家凌晨一点上线,看到的仍然是「今天」,而不是已经翻页的「昨天」。这个偏移正是 DailyAt 存在的全部意义。
用「日键」代替时间减法
判断「跨天」有两种思路。一种是时间减法:记住上次重置的时刻,看现在减上次是否超过 24 小时。这个思路错得很隐蔽——它假设一天恒等于 24 小时,DST 那天就崩了;而且服务器重启后「上次时刻」丢了,得从持久化里捞,复杂。
另一种是日键(day key):把任意时刻映射成一个整数,同一天的两个时刻映射到同一个整数,跨天则整数 +1。重置就退化成「键变了没有」——不需要记住上次的绝对时刻,只需要记住上次的键。这个键的计算是:先把时刻平移到游戏时区,再减去重置时刻(让边界对齐到 00:00),最后按 86400 秒向下取整。
下面这段是 L1,go test 可以直接跑。它实现了日键、周键、到下次重置的倒计时,以及只向前触发的跟踪器:
// file: internal/quest/reset.go
// tier: L1
// verify: go test ./internal/quest/
package quest
import "time"
// ResetSchedule describes when daily and weekly quests roll over. The roll is
// anchored to a *fixed* UTC offset and a fixed time-of-day, never to the host's
// local zone: every process in the fleet must flip at the same instant no matter
// where the box sits, otherwise two shards disagree about which day it is and a
// player can claim yesterday's dailies twice by hopping servers.
type ResetSchedule struct {
Offset time.Duration // fixed UTC offset, e.g. 8h for a CN-region realm
DailyAt time.Duration // time-of-day for the daily roll, e.g. 5h = 05:00
Weekday time.Weekday // day the weekly roll lands on, e.g. time.Monday
}
// CNRegion is the default realm schedule: reset at 05:00, offset +08:00. 05:00
// is chosen over midnight so the "yesterday" a player sees at 01:00 still counts
// as the same day.
func CNRegion() ResetSchedule {
return ResetSchedule{Offset: 8 * time.Hour, DailyAt: 5 * time.Hour, Weekday: time.Monday}
}
// DailyKey returns an integer that increments exactly once per daily boundary.
// Two instants share a key iff they fall in the same daily window, so a reset is
// just "the key changed" — no arithmetic on the wall clock is needed at the call
// site, and a duplicated tick cannot fire it twice.
func (s ResetSchedule) DailyKey(t time.Time) int64 {
shifted := t.UTC().Add(s.Offset - s.DailyAt)
return floorDiv(shifted.Unix(), 86400)
}
// WeeklyKey is the same idea one level up: the key increments when the schedule's
// weekday boundary is crossed.
func (s ResetSchedule) WeeklyKey(t time.Time) int64 {
shifted := t.UTC().Add(s.Offset - s.DailyAt)
day := floorDiv(shifted.Unix(), 86400)
// shifted is a UTC-labelled instant, so its weekday is the realm-local one.
delta := (int(shifted.Weekday()) - int(s.Weekday) + 7) % 7
return floorDiv(day-int64(delta), 7)
}
// TimeToNextDaily returns how long until the next daily boundary, in (0, 24h].
// This is the number the client shows as "dailies reset in 3h 12m". It is a
// duration, not an instant, so it survives the client's clock being wrong.
func (s ResetSchedule) TimeToNextDaily(t time.Time) time.Duration {
shifted := t.UTC().Add(s.Offset - s.DailyAt)
sec := shifted.Unix()
next := (floorDiv(sec, 86400) + 1) * 86400
return time.Duration(next-sec) * time.Second
}
// Tracker watches a schedule and fires callbacks exactly once per boundary
// crossing. It stores only the last observed keys, so a backward wall-clock step
// (an NTP correction) cannot re-fire a reset that already happened: the key would
// have to *change*, and moving the clock back to a previous day is absorbed as
// "still the same window". The monotonic gameplay timeline from vol-1 14.1
// supplies elapsed time for combat and buffs; this tracker is the one place that
// deliberately reads the calendar, and it reads it in a key-compare, not a
// subtract, so a stepping clock is harmless.
type Tracker struct {
sched ResetSchedule
day int64
week int64
primed bool
}
// NewTracker builds a tracker already primed at t, so the first Tick after
// startup does not fire a spurious reset.
func NewTracker(s ResetSchedule, t time.Time) *Tracker {
return &Tracker{sched: s, day: s.DailyKey(t), week: s.WeeklyKey(t), primed: true}
}
// Tick compares the current keys against the last ones and reports which
// boundaries were crossed. Call it on every world tick; crossing two days at once
// (a server that was paused) is reported as a single daily event. Only *forward*
// key movement fires: a backward wall-clock step (an NTP correction) leaves the
// last key untouched, so when the clock comes back the boundary does not re-fire.
func (t *Tracker) Tick(now time.Time) (daily, weekly bool) {
d := t.sched.DailyKey(now)
w := t.sched.WeeklyKey(now)
if !t.primed {
t.day, t.week, t.primed = d, w, true
return false, false
}
if d > t.day {
daily = true
t.day = d
}
if w > t.week {
weekly = true
t.week = w
}
return daily, weekly
}
// floorDiv is integer division that rounds toward negative infinity, matching
// the way calendar days are counted before the Unix epoch.
func floorDiv(a, b int64) int64 {
q := a / b
if a%b != 0 && (a < 0) != (b < 0) {
q--
}
return q
}
floorDiv 不能用 Go 的 /——Go 的整数除法向零取整,而日历日必须向负无穷取整。对 1970 年之前的时刻(负的 Unix 秒),-1 / 86400 会得到 0 而不是 −1,日键就会算错一天。这个 bug 在今天的生产数据里几乎不会触发,但「几乎不会」不是「不会」,而 floorDiv 只多两行。
把几个关键时刻代进去,日键的跳变位置一目了然(N 是某个基准值):
| 时刻(+08:00) | 平移再减去 05:00 后 | 日键 |
|---|---|---|
| 2026-10-08 04:59 | 10-07 23:59 | N |
| 2026-10-08 05:00 | 10-08 00:00 | N+1 |
| 2026-10-08 23:00 | 10-08 18:00 | N+1 |
| 2026-10-09 04:59 | 10-08 23:59 | N+1 |
| 2026-10-09 05:00 | 10-09 00:00 | N+2 |
04:59 和 05:00 只差一分钟,却落在两个不同的日键上——这就是「窗口边界」的含义:边界前后哪怕只差一秒,也是两个不同的「今天」。而 10-08 23:00 和 10-09 04:59 跨越了日历的零点,却共享同一个日键,因为它们同属一个游戏日窗口。
重置跟踪器:只向前触发
有了日键,跟踪器只需要保存「上次看到的键」,每次世界 tick 比一下。这里最关键的一条规则是:只在键变大时触发。 如果键只是「变了」就触发,那么时钟被 NTP 往回拨一天(虽然罕见,但虚拟机会迁移、运维会校时),键会从 N+1 变回 N,跟踪器就会以为「又跨天了」,把每日任务再重置一遍——玩家白捡一份奖励。
只认「变大」,就天然吸收了向后跳变:往回拨时 d > t.day 为假,什么都不做,t.day 保持不变;等时钟回到正常,键重新等于 t.day,也不会触发。这正是卷一 14.1 里 Timeline 处理向后跳变的同一种思路——宁可停一拍,绝不倒退——只不过那边管的是毫秒级的流逝时间,这边管的是天级的日历窗口。
跟踪器报出「跨天了」之后,玩家身上的每日状态靠日键来判断要不要清空,而不是靠「收到一条重置广播」。这样即便玩家在重置时刻恰好离线、重连时才补上,逻辑也一致——重连时比一次键,过期就清:
// snippet: 每日状态以日键为版本,跨天自动失效
type DailyState struct {
Key int64 // the DailyKey this state belongs to
Done map[QuestID]bool // dailies already claimed in this window
}
// ResetIfStale returns true and clears the state when the day has turned over.
func (d *DailyState) ResetIfStale(key int64) bool {
if d.Key == key {
return false
}
d.Key = key
d.Done = map[QuestID]bool{}
return true
}
用日键当版本号,有一个额外好处:它是幂等的。重复调用 ResetIfStale 只要键没变,永远是 false;服务器重放、玩家重连补状态,都不会把已经做完的每日任务清掉。
下面这段测试覆盖了日边界、时区无关性、周边界,以及一次时钟回拨:
// snippet: internal/quest/reset_test.go(节选)
func TestTrackerFiresOncePerBoundary(t *testing.T) {
tr := NewTracker(CNRegion(), at(2026, 10, 8, 4, 59))
if d, w := tr.Tick(at(2026, 10, 8, 4, 59)); d || w {
t.Fatalf("same window fired: daily=%v weekly=%v", d, w)
}
if d, w := tr.Tick(at(2026, 10, 8, 5, 0)); !d || w {
t.Fatalf("05:00 daily=%v weekly=%v, want daily only", d, w)
}
if d, _ := tr.Tick(at(2026, 10, 8, 5, 0)); d {
t.Fatal("same boundary fired twice")
}
// Backward NTP step must not re-arm the boundary.
if d, _ := tr.Tick(at(2026, 10, 8, 4, 0)); d {
t.Fatal("backward clock step re-fired the daily reset")
}
if d, _ := tr.Tick(at(2026, 10, 8, 5, 0)); d {
t.Fatal("clock recovery re-fired the daily reset")
}
}
测试里 at(2026, 10, 8, 4, 59) 和 at(2026, 10, 8, 5, 0) 相差一分钟,但落在两个不同的日键上——这就是「窗口边界」的意义:边界前后哪怕只差一秒,也是两个不同的「今天」。而 4:00 那次回拨落在旧窗口里,d > t.day 为假,重置不会重来。
本节小结
- 「今天」钉在固定时区 + 固定时刻上:不读
time.Local,用固定的 UTC 偏移,全球分片对同一天给出一致答案,玩家跳服也领不了两遍。 - 用「日键」代替时间减法:把时刻平移到游戏时区、减去重置时刻、按 86400 取整;跨天判定退化成「键变没变」,服务器重启只需记住一个整数。
floorDiv必须向负无穷取整:Go 的/向零取整,1970 年前会把日键算错一天。- 跟踪器只向前触发:时钟被 NTP 往回拨时
d > t.day为假,向后跳变被吸收,这正是卷一Timeline的同一种「宁可停一拍」哲学。
到这里,任务系统的三块拼图——依赖图(10.1)、进度服务(10.2)、时间窗口(10.3)——就齐了。下一章离开「单人玩法」,进入玩家之间的交互:聊天频道、敏感词治理与好友关系一致性。
阅读导航:上一节:10.2 事件上报与进度服务 · 下一节:11.1 频道模型与权限 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。