《游戏服务端编程实践》9.2.3 日常任务与成就系统

日常任务的核心不是每天把计数清零,而是用稳定的周期键判断是否跨期。本篇讨论自然日与 ISO 周的时区边界、跨天在线与幂等重置、一次性与累计成就、进度与奖励分离,并给出防刷与离线补算策略,最后用跨天、重复重置、跨天在线和时区边界测试验证实现。

一、日常任务不是“每天清零”

“每天零点把日常计数清零”听起来简单,落到线上会碰到三个问题:零点用哪个时区、玩家跨天时还在线上怎么办、重置过程中服务重启了怎么办。

如果每个服务器用自己的本地时间,同一大区的玩家会在不同时刻看到日常刷新。跨时区运营时,有人凌晨刷新,有人上午刷新,客服每天都要解释“为什么我的日常还没重置”。

清零还会丢信息。玩家昨天完成了多少、哪些奖励没领,清零后只剩一个 0。没有历史记录,运营补偿、异常排查、成就累计都失去依据。

日常系统的正确抽象是“周期键加计数器”:当前属于哪个周期,由服务器时间与配置时区共同计算;周期变化时归档旧计数并开始新计数,而不是无条件执行清零命令。

二、周期键:自然日、ISO 周与时区

周期键是一个字符串。自然日可以用 2006-01-02,自然周用 ISO 年的第几周。键相同表示同一周期,键变化才触发重置。

周期键的组成边界常见坑
自然日本地日期本地时间 00:00用 UTC 日期导致东八区早上八点才刷新
自然周ISO 年 + ISO 周周一 00:00用月份或自定义周起始,跨年时周号重复
活动周期配置的开始与结束时间配置指定与自然日混用,活动结束但日常仍重置
赛季赛季 ID运营配置用时间推算赛季,维护延迟后赛季错位

ISO 周有一个容易忽略的边界:跨年那一周可能属于上一年的第 52 或 53 周。直接用“年份 + 周数”拼接时,如果年份取自然年、周数取 ISO 周,会出现重复或跳号。必须使用同一套 ISO 年与 ISO 周。

时区应该作为运营配置的一部分,而不是读服务器操作系统的本地时区。服务器可能部署在 UTC 容器里,也可能因为迁移换到另一个机房。配置固定为 Asia/Shanghai 这类 IANA 时区,行为才可复现。

存储时间用 UTC 时间点,计算周期键时再转换到配置时区。不要把“本地时间”直接存成时间戳,否则夏令时切换时会出现一小时的空洞或重复。

三、重置的幂等与跨天在线

重置必须是幂等的。调度器重试、手动补偿、服务重启都可能让同一个周期触发两次重置,第二次不能把刚积累的进度又清掉。

实现方式很简单:记录上一次生效的周期键。新键与旧键相同就直接返回,不归档、不清零。只有键变化时才执行一次“归档旧计数、创建新计数”。

跨天在线的玩家不会主动触发登录重置。服务端需要在玩家每次产生进度时顺带检查周期,或者在游戏循环里定期扫描在线玩家。两种方式都要调用同一个重置函数,不能各写一套。

如果只在登录时重置,玩家从 23:59 挂到 00:01,期间的击杀会记到昨天。第二天再登录时,昨天的计数被归档,玩家会发现自己白打了一轮。

如果只靠定时任务,任务延迟或服务重启会漏掉整个周期。定时任务适合批量刷新离线玩家,在线玩家仍然要在进度入口做惰性检查。

重置时要先归档再清零。先清零再归档,崩溃窗口里旧计数已经丢失,历史记录却是空的。归档记录应该是深拷贝,不能把内部 map 的引用直接交出去。

四、成就:一次性、累计与隐藏

成就和日常任务共享事件,但生命周期不同。日常按周期重置,成就通常跨周期累计,或者只在第一次达成时触发。

类型进度来源是否重置示例
一次性单次事件否首次击杀精英怪
累计多次事件累加否累计击杀一万只怪
周期累计周期内事件累加是单日完成十个日常
隐藏任意条件视定义在特定天气进入隐藏区域

一次性成就只需要一个布尔标记,但标记必须和奖励发放一起持久化。只写内存标记,服务重启后玩家可以再领一次。

累计成就要设上限,并且进度封顶在目标值。玩家击杀数远超目标时,继续累加没有意义,还会让显示出现“10000/100”这种奇怪状态。

隐藏成就的“隐藏”只影响展示,不影响服务端判定。客户端不下发未解锁的隐藏成就名称,但服务端仍然要记录进度,否则解锁条件永远无法满足。

成就进度和奖励领取要分开。进度达标只说明“可以领”,领取动作才发奖励。把两者合成一个“达成即发”的流程,重复事件或重试就会重复发奖。

五、进度与奖励分离

日常任务也适用同样的原则:计数、可领取、已领取是三个不同的事实。计数达标不等于奖励已发,奖励已发也不应该让计数消失。

可领取状态可以由计数推导,但“已领取”必须单独存储。只靠计数判断是否领过,重置后计数归零,玩家又能领一次;只靠领取标记判断进度,又无法处理补发。

领取接口要幂等。同一个日常奖励的领取键应该包含玩家 ID、周期键和任务 ID。重复请求返回“已领取”,而不是再发一份奖励。

未领取的奖励在重置时怎么处理,是产品规则而非技术细节。常见做法是重置前通过邮件补发,或者给一个短暂的领取宽限期。直接清零会让玩家觉得奖励被吞了。

奖励发放要有流水,流水里带周期键。玩家申诉“昨天的日常没到账”时,客服能根据周期键查到计数、领取状态和发放记录,而不是只看当前计数。

六、防刷:重复计数、异常速度、离线补算

日常与成就都是可被刷的目标。防刷不能只靠客户端,必须在服务端对事件做校验和限流。

风险表现服务端手段
重复计数同一事件被多次投递事件幂等键去重,计数入口再校验
异常速度短时间击杀数远超正常滑动窗口限速,超限记录并降权
离线补算离线期间按在线速度累计补算上限、按实际离线时长折算
多端并发同一账号两个客户端同时上报会话唯一,旧会话失效
改本地时间客户端伪造跨天只信服务器时间与周期键

异常速度的阈值要按玩法配置,不能全站一个数字。挂机玩法和手动副本的击杀速度差几个数量级,统一阈值要么误伤要么形同虚设。

离线补算要有上限。玩家离线一年和离线一天都按同一公式补,经济系统会被一次性击穿。常见做法是设置最大补算时长,并只补基础收益。

防刷命中后的处理要分级。轻微超速可以只记录,明显异常才冻结进度或转人工。直接封号会把网络抖动和客户端卡顿误判成作弊。

七、失败场景:用服务器本地时间导致跨区不一致

某游戏在三个地区部署服务器,代码里直接用 time.Now() 取日期做日常键。容器默认时区是 UTC,运维在其中一台机器上改成了当地时区,结果这台机器的玩家比其他人晚八小时刷新。

更隐蔽的是跨天瞬间。玩家在 23:59:59 完成最后一次击杀,事件在 00:00:00 到达服务端。如果事件时间与重置时间来自不同时钟,击杀可能被记到新周期,也可能被归档到旧周期,取决于处理顺序。

修复方式不是“统一改成 UTC”这么简单。UTC 对东八区玩家意味着早上八点刷新,产品上不可接受。正确做法是固定运营时区,所有周期计算都显式传入该时区,测试覆盖本地午夜前后各一分钟。

另一个常见故障是重置丢奖励。玩家昨天完成了日常但没领,零点重置直接把计数清零,奖励没有补发。玩家看到任务变成 0/10,昨天的努力完全消失。

这类问题不能靠“下次注意”解决。重置流程要把未领取奖励归档,或者生成补发邮件;至少保留历史计数,让客服可以手工补偿。

八、Go 实现:DailyReset 与 AchievementLedger

下面的实现把周期键、幂等重置、历史归档和成就领取放在一个包里。ResetNow 只在周期键变化时归档,AddAt 在累加前先做同样的检查,覆盖跨天在线的情况。

// file: ch09/quest/daily.go
// tier: L1
// verify: go test ./ch09/quest/

package quest

import (
	"errors"
	"fmt"
	"sync"
	"time"
)

var (
	ErrAchievementUnknown  = errors.New("achievement: unknown achievement")
	ErrAchievementNotReady = errors.New("achievement: not complete")
	ErrAchievementClaimed  = errors.New("achievement: reward already claimed")
)

type Period int

const (
	Daily Period = iota
	Weekly
)

// Key 把时间点映射为周期键。所有计算都先换到 loc,再截断日期或取 ISO 周。
func (p Period) Key(now time.Time, loc *time.Location) string {
	t := now.In(loc)
	if p == Weekly {
		year, week := t.ISOWeek()
		return fmt.Sprintf("%04d-W%02d", year, week)
	}
	return t.Format("2006-01-02")
}

type ResetRecord struct {
	Key      string
	At       time.Time
	Counters map[string]int
}

type DailyReset struct {
	mu       sync.Mutex
	loc      *time.Location
	period   Period
	counters map[string]int
	lastKey  string
	history  []ResetRecord
}

func NewDailyReset(loc *time.Location, period Period) *DailyReset {
	if loc == nil {
		loc = time.UTC
	}
	return &DailyReset{
		loc:      loc,
		period:   period,
		counters: make(map[string]int),
	}
}

func (d *DailyReset) Add(id string, n int) int {
	d.mu.Lock()
	defer d.mu.Unlock()
	if n > 0 {
		d.counters[id] += n
	}
	return d.counters[id]
}

// AddAt 先按当前时刻滚动周期,再累加,用于“跨天仍在线上”的玩家。
func (d *DailyReset) AddAt(now time.Time, id string, n int) (int, bool) {
	d.mu.Lock()
	defer d.mu.Unlock()
	_, reset := d.rollLocked(now)
	if n > 0 {
		d.counters[id] += n
	}
	return d.counters[id], reset
}

func (d *DailyReset) ResetNow(now time.Time) (ResetRecord, bool) {
	d.mu.Lock()
	defer d.mu.Unlock()
	return d.rollLocked(now)
}

// rollLocked 只在周期键变化时归档并清零,因此重复调用是幂等的。
func (d *DailyReset) rollLocked(now time.Time) (ResetRecord, bool) {
	key := d.period.Key(now, d.loc)
	if key == d.lastKey {
		return ResetRecord{}, false
	}
	rec := ResetRecord{Key: key, At: now, Counters: make(map[string]int, len(d.counters))}
	for id, n := range d.counters {
		rec.Counters[id] = n
	}
	d.history = append(d.history, rec)
	d.counters = make(map[string]int)
	d.lastKey = key
	return rec, true
}

func (d *DailyReset) Count(id string) int {
	d.mu.Lock()
	defer d.mu.Unlock()
	return d.counters[id]
}

func (d *DailyReset) PeriodKey(now time.Time) string {
	return d.period.Key(now, d.loc)
}

// History 返回深拷贝,调用方修改记录不会影响内部状态。
func (d *DailyReset) History() []ResetRecord {
	d.mu.Lock()
	defer d.mu.Unlock()
	out := make([]ResetRecord, len(d.history))
	for i, rec := range d.history {
		out[i] = rec
		out[i].Counters = make(map[string]int, len(rec.Counters))
		for id, n := range rec.Counters {
			out[i].Counters[id] = n
		}
	}
	return out
}

type Achievement struct {
	ID       string
	Target   int
	Hidden   bool
	Progress int
	Claimed  bool
}

// AchievementLedger 只记录累计进度与领取状态,奖励内容由调用方按 ID 发放。
type AchievementLedger struct {
	mu    sync.Mutex
	items map[string]*Achievement
}

func NewAchievementLedger() *AchievementLedger {
	return &AchievementLedger{items: make(map[string]*Achievement)}
}

func (a *AchievementLedger) Add(id string, target int, hidden bool, n int) Achievement {
	a.mu.Lock()
	defer a.mu.Unlock()
	it := a.items[id]
	if it == nil {
		it = &Achievement{ID: id, Target: target, Hidden: hidden}
		a.items[id] = it
	}
	if target > 0 {
		it.Target = target
	}
	if n > 0 && it.Progress < it.Target {
		it.Progress += n
		if it.Progress > it.Target {
			it.Progress = it.Target
		}
	}
	return *it
}

func (a *AchievementLedger) Claim(id string) (Achievement, error) {
	a.mu.Lock()
	defer a.mu.Unlock()
	it := a.items[id]
	if it == nil {
		return Achievement{}, ErrAchievementUnknown
	}
	if it.Claimed {
		return *it, ErrAchievementClaimed
	}
	if it.Progress < it.Target {
		return *it, ErrAchievementNotReady
	}
	it.Claimed = true
	return *it, nil
}

func (a *AchievementLedger) Get(id string) (Achievement, bool) {
	a.mu.Lock()
	defer a.mu.Unlock()
	it := a.items[id]
	if it == nil {
		return Achievement{}, false
	}
	return *it, true
}

History 返回深拷贝。调用方拿到历史记录后修改计数,不会影响内部状态;这一点在补偿和审计场景里很重要,否则一次误改会污染后续对账。

AchievementLedger 把进度与领取分开。Add 只累加并封顶,Claim 才检查目标与领取标记。第二次领取返回 ErrAchievementClaimed,不会重复发放。

九、测试与运行

测试覆盖跨天重置、重复重置幂等、时区边界、ISO 周边界、跨天在线累加,以及成就重复领取。时区测试使用固定偏移的 UTC+8,避免测试机缺少时区数据库导致结果不稳定。

cd .local/checks/gs-practice-lab
gofmt -l ch09/quest
go vet ./ch09/quest/
go test ./ch09/quest/ -count=1

本机执行结果如下。gofmt 与 go vet 没有输出,表示格式与静态检查通过。

ok  	gspractice/ch09/quest	0.475s

小结

日常任务的核心是周期键,而不是定时清零。键由服务器时间与固定运营时区计算,键变化才归档并重置;重复调用同一个键必须无副作用。

成就与日常共享事件,但进度、可领取、已领取是三个事实。进度可以自动累加,奖励领取必须单独幂等;未领取奖励在重置时要有补发或宽限策略。

常见错误与边界

  • 直接用服务器本地时间计算日期,不同机器或不同地区刷新时间不一致。
  • 用 UTC 日期代替运营时区,东八区玩家在早上八点才看到日常重置。
  • 定时任务与登录重置各写一套逻辑,边界行为不一致。
  • 重置不幂等,调度重试把新周期的进度清零。
  • 先清零再归档,崩溃窗口里旧计数丢失。
  • 重置时直接丢弃未领取奖励,玩家完成日常却拿不到奖励。
  • 成就进度达标就自动发奖,重复事件导致重复发放。
  • 领取标记只存内存,服务重启后可以再次领取。
  • 离线补算没有上限,长期离线玩家一次性获得过量资源。
  • 异常速度阈值全站统一,挂机玩法与手动副本互相误伤。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist