DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,407 @@
package onlineevent
import (
"context"
"errors"
"fmt"
"path"
"strings"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
const (
// AttendanceEventPVFPath is the only attendance table proved in the
// current production Script.pvf. Loading it does not imply that the event
// is enabled, dated, or attached to a client command.
AttendanceEventPVFPath = "event/chn_event/chn_attendanceevent.evt"
attendanceStackableListPath = "stackable/stackable.lst"
attendanceRewardCount = 4
)
var (
ErrAttendancePVFMalformed = errors.New("online-event attendance PVF is malformed")
ErrAttendanceRewardUnresolved = errors.New("online-event attendance reward item is unresolved")
)
const (
processTimeSecondsSection = "process time seconds"
rewardItemSection = "reward item"
processSecondsForMaxCountSection = "process seconds for max count"
rewardItemForSumSection = "reward item for sum"
rewardActivityForSumSection = "reward activity for sum"
)
// AttendancePVFItemReward is one item/count pair copied directly from an
// attendance PVF section. DefinitionPath is resolved through stackable.lst
// and verified readable while the catalog is loaded.
type AttendancePVFItemReward struct {
ItemID int64
Count int64
DefinitionPath string
}
// AttendancePVFSnapshot is a detached view of the five proved PVF sections.
// ProcessDurationsSeconds deliberately preserves each raw duration; it is not
// converted to cumulative time. SumThresholds likewise retains the source
// values without supplying reset, date, activation, or protocol semantics.
type AttendancePVFSnapshot struct {
ProcessDurationsSeconds []int64
RewardItems []AttendancePVFItemReward
SumThresholds []int64
RewardItemsForSum []AttendancePVFItemReward
RewardActivityForSum []int64
}
// AttendancePVFCatalog is an immutable, protocol-independent projection of
// chn_attendanceevent.evt. It is intentionally not converted into Definition:
// the current PVF does not prove an event ID, dates, enabled state, daily reset
// rule, or whether the raw process durations are cumulative.
type AttendancePVFCatalog struct {
snapshot AttendancePVFSnapshot
}
// LoadAttendancePVFCatalog parses the five proved attendance sections and
// verifies all eight reward item definitions through stackable.lst. It does
// not register a handler or publish any client packet.
func LoadAttendancePVFCatalog(ctx context.Context, source dnfpvf.Source) (*AttendancePVFCatalog, error) {
if source == nil {
return nil, dnfpvf.ErrSourceRequired
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return nil, err
}
text, err := source.ReadText(AttendanceEventPVFPath)
if err != nil {
return nil, fmt.Errorf("read online-event attendance PVF %q: %w", AttendanceEventPVFPath, err)
}
if err := ctx.Err(); err != nil {
return nil, err
}
document, err := dnfpvf.Parse(AttendanceEventPVFPath, text)
if err != nil {
return nil, fmt.Errorf("parse online-event attendance PVF %q: %w", AttendanceEventPVFPath, err)
}
if err := validateAttendanceSectionLayout(document); err != nil {
return nil, err
}
processDurations, err := attendanceIntegerSection(document, processTimeSecondsSection, attendanceRewardCount)
if err != nil {
return nil, err
}
if err := requirePositiveAttendanceValues(processTimeSecondsSection, processDurations); err != nil {
return nil, err
}
rewardValues, err := attendanceIntegerSection(document, rewardItemSection, attendanceRewardCount*2)
if err != nil {
return nil, err
}
rewards, err := parseAttendanceRewards(rewardItemSection, rewardValues)
if err != nil {
return nil, err
}
sumThresholds, err := attendanceIntegerSection(document, processSecondsForMaxCountSection, attendanceRewardCount)
if err != nil {
return nil, err
}
if err := requirePositiveAttendanceValues(processSecondsForMaxCountSection, sumThresholds); err != nil {
return nil, err
}
sumRewardValues, err := attendanceIntegerSection(document, rewardItemForSumSection, attendanceRewardCount*2)
if err != nil {
return nil, err
}
sumRewards, err := parseAttendanceRewards(rewardItemForSumSection, sumRewardValues)
if err != nil {
return nil, err
}
rewardActivityForSum, err := attendanceIntegerSection(document, rewardActivityForSumSection, 1)
if err != nil {
return nil, err
}
allRewards := make([]AttendancePVFItemReward, 0, len(rewards)+len(sumRewards))
allRewards = append(allRewards, rewards...)
allRewards = append(allRewards, sumRewards...)
definitionPaths, err := resolveAttendanceRewardDefinitions(ctx, source, allRewards)
if err != nil {
return nil, err
}
attachAttendanceDefinitionPaths(rewards, definitionPaths)
attachAttendanceDefinitionPaths(sumRewards, definitionPaths)
return &AttendancePVFCatalog{snapshot: AttendancePVFSnapshot{
ProcessDurationsSeconds: append([]int64(nil), processDurations...),
RewardItems: append([]AttendancePVFItemReward(nil), rewards...),
SumThresholds: append([]int64(nil), sumThresholds...),
RewardItemsForSum: append([]AttendancePVFItemReward(nil), sumRewards...),
RewardActivityForSum: append([]int64(nil), rewardActivityForSum...),
}}, nil
}
// Snapshot returns copies of every slice so callers cannot mutate the loaded
// catalog.
func (c *AttendancePVFCatalog) Snapshot() AttendancePVFSnapshot {
if c == nil {
return AttendancePVFSnapshot{}
}
snapshot := c.snapshot
snapshot.ProcessDurationsSeconds = append([]int64(nil), snapshot.ProcessDurationsSeconds...)
snapshot.RewardItems = append([]AttendancePVFItemReward(nil), snapshot.RewardItems...)
snapshot.SumThresholds = append([]int64(nil), snapshot.SumThresholds...)
snapshot.RewardItemsForSum = append([]AttendancePVFItemReward(nil), snapshot.RewardItemsForSum...)
snapshot.RewardActivityForSum = append([]int64(nil), snapshot.RewardActivityForSum...)
return snapshot
}
func attendanceIntegerSection(document *dnfpvf.Document, name string, exactCount int) ([]int64, error) {
if document == nil {
return nil, fmt.Errorf("%w: path=%s section=[%s] document is nil", ErrAttendancePVFMalformed, AttendanceEventPVFPath, name)
}
sectionCount := 0
for _, section := range document.Sections {
if strings.EqualFold(strings.TrimSpace(section.Name), name) {
sectionCount++
}
}
if sectionCount != 1 {
return nil, fmt.Errorf("%w: path=%s section=[%s] count=%d want=1", ErrAttendancePVFMalformed, AttendanceEventPVFPath, name, sectionCount)
}
tokens, ok := document.Section(name)
if !ok {
return nil, fmt.Errorf("%w: path=%s section=[%s] is unreadable", ErrAttendancePVFMalformed, AttendanceEventPVFPath, name)
}
if len(tokens) != exactCount {
return nil, fmt.Errorf("%w: path=%s section=[%s] values=%d want=%d", ErrAttendancePVFMalformed, AttendanceEventPVFPath, name, len(tokens), exactCount)
}
values := make([]int64, len(tokens))
for index, token := range tokens {
if token.Kind != dnfpvf.TokenInt {
return nil, fmt.Errorf(
"%w: path=%s section=[%s] index=%d token=%q at %d:%d is not an integer",
ErrAttendancePVFMalformed,
AttendanceEventPVFPath,
name,
index,
token.Raw,
token.Line,
token.Column,
)
}
values[index] = token.Int
}
return values, nil
}
func validateAttendanceSectionLayout(document *dnfpvf.Document) error {
if document == nil {
return fmt.Errorf("%w: path=%s document is nil", ErrAttendancePVFMalformed, AttendanceEventPVFPath)
}
names := []string{
processTimeSecondsSection,
rewardItemSection,
processSecondsForMaxCountSection,
rewardItemForSumSection,
rewardActivityForSumSection,
}
expected := make([]string, 0, len(names)*2)
for _, name := range names {
expected = append(expected, strings.ToLower(name), "/"+strings.ToLower(name))
}
if len(document.Sections) != len(expected) {
return fmt.Errorf(
"%w: path=%s section_markers=%d want=%d",
ErrAttendancePVFMalformed,
AttendanceEventPVFPath,
len(document.Sections),
len(expected),
)
}
for index, section := range document.Sections {
name := strings.ToLower(strings.TrimSpace(section.Name))
if name != expected[index] {
return fmt.Errorf(
"%w: path=%s section_marker[%d]=[%s] want=[%s]",
ErrAttendancePVFMalformed,
AttendanceEventPVFPath,
index,
section.Name,
expected[index],
)
}
markerIndex := section.Start - 1
if markerIndex < 0 || markerIndex >= len(document.Tokens) ||
document.Tokens[markerIndex].Kind != dnfpvf.TokenSection ||
!strings.EqualFold(strings.TrimSpace(document.Tokens[markerIndex].Value), strings.TrimSpace(section.Name)) {
return fmt.Errorf(
"%w: path=%s section_marker[%d] token range is invalid",
ErrAttendancePVFMalformed,
AttendanceEventPVFPath,
index,
)
}
if index == 0 && markerIndex != 0 {
return fmt.Errorf(
"%w: path=%s has %d root tokens before the first section",
ErrAttendancePVFMalformed,
AttendanceEventPVFPath,
markerIndex,
)
}
if strings.HasPrefix(name, "/") && section.Start != section.End {
return fmt.Errorf(
"%w: path=%s closing section=[%s] contains %d values",
ErrAttendancePVFMalformed,
AttendanceEventPVFPath,
section.Name,
section.End-section.Start,
)
}
}
return nil
}
func requirePositiveAttendanceValues(section string, values []int64) error {
for index, value := range values {
if value <= 0 {
return fmt.Errorf("%w: path=%s section=[%s] index=%d value=%d is not positive", ErrAttendancePVFMalformed, AttendanceEventPVFPath, section, index, value)
}
}
return nil
}
func parseAttendanceRewards(section string, values []int64) ([]AttendancePVFItemReward, error) {
if len(values) != attendanceRewardCount*2 {
return nil, fmt.Errorf("%w: path=%s section=[%s] values=%d want=%d", ErrAttendancePVFMalformed, AttendanceEventPVFPath, section, len(values), attendanceRewardCount*2)
}
rewards := make([]AttendancePVFItemReward, 0, attendanceRewardCount)
for index := 0; index < len(values); index += 2 {
itemID, count := values[index], values[index+1]
if itemID <= 0 || count <= 0 {
return nil, fmt.Errorf(
"%w: path=%s section=[%s] pair=%d item_id=%d count=%d",
ErrAttendancePVFMalformed,
AttendanceEventPVFPath,
section,
index/2,
itemID,
count,
)
}
rewards = append(rewards, AttendancePVFItemReward{ItemID: itemID, Count: count})
}
return rewards, nil
}
func resolveAttendanceRewardDefinitions(
ctx context.Context,
source dnfpvf.Source,
rewards []AttendancePVFItemReward,
) (map[int64]string, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
listText, err := source.ReadText(attendanceStackableListPath)
if err != nil {
return nil, fmt.Errorf("read online-event reward list %q: %w", attendanceStackableListPath, err)
}
if err := ctx.Err(); err != nil {
return nil, err
}
listDocument, err := dnfpvf.Parse(attendanceStackableListPath, listText)
if err != nil {
return nil, fmt.Errorf("parse online-event reward list %q: %w", attendanceStackableListPath, err)
}
wanted := make(map[int64]struct{}, len(rewards))
wantedOrder := make([]int64, 0, len(rewards))
for _, reward := range rewards {
if _, duplicate := wanted[reward.ItemID]; !duplicate {
wantedOrder = append(wantedOrder, reward.ItemID)
}
wanted[reward.ItemID] = struct{}{}
}
listed := make(map[int64]string, len(wanted))
for _, entry := range dnfpvf.ParseList(listDocument) {
if _, needed := wanted[entry.ID]; !needed {
continue
}
if previous, duplicate := listed[entry.ID]; duplicate {
return nil, fmt.Errorf(
"%w: list=%s item_id=%d duplicate paths=%q,%q",
ErrAttendancePVFMalformed,
attendanceStackableListPath,
entry.ID,
previous,
entry.Path,
)
}
listed[entry.ID] = entry.Path
}
resolved := make(map[int64]string, len(wanted))
for _, itemID := range wantedOrder {
if err := ctx.Err(); err != nil {
return nil, err
}
listedPath, ok := listed[itemID]
if !ok {
return nil, fmt.Errorf("%w: list=%s item_id=%d", ErrAttendanceRewardUnresolved, attendanceStackableListPath, itemID)
}
definitionPath, err := attendanceStackableDefinitionPath(listedPath)
if err != nil {
return nil, fmt.Errorf("%w: list=%s item_id=%d path=%q: %v", ErrAttendanceRewardUnresolved, attendanceStackableListPath, itemID, listedPath, err)
}
if _, err := source.ReadText(definitionPath); err != nil {
return nil, fmt.Errorf("%w: item_id=%d definition=%s: %v", ErrAttendanceRewardUnresolved, itemID, definitionPath, err)
}
if err := ctx.Err(); err != nil {
return nil, err
}
resolved[itemID] = definitionPath
}
return resolved, nil
}
func attendanceStackableDefinitionPath(listedPath string) (string, error) {
clean := strings.TrimSpace(strings.ReplaceAll(listedPath, "\\", "/"))
if strings.HasPrefix(clean, "/") || (len(clean) >= 2 && clean[1] == ':') {
return "", errors.New("definition path is absolute")
}
clean = strings.TrimPrefix(clean, "./")
for _, segment := range strings.Split(clean, "/") {
if segment == ".." {
return "", errors.New("definition path escapes stackable root")
}
}
clean = path.Clean(clean)
if clean == "" || clean == "." {
return "", errors.New("empty definition path")
}
if clean == ".." || strings.HasPrefix(clean, "../") {
return "", errors.New("definition path escapes stackable root")
}
root := path.Dir(attendanceStackableListPath)
if !strings.HasPrefix(strings.ToLower(clean), strings.ToLower(root)+"/") {
clean = path.Join(root, clean)
}
if !strings.HasPrefix(strings.ToLower(clean), strings.ToLower(root)+"/") {
return "", errors.New("definition path escapes stackable root")
}
if !strings.EqualFold(path.Ext(clean), ".stk") {
return "", errors.New("definition path is not a stackable .stk file")
}
return clean, nil
}
func attachAttendanceDefinitionPaths(rewards []AttendancePVFItemReward, paths map[int64]string) {
for index := range rewards {
rewards[index].DefinitionPath = paths[rewards[index].ItemID]
}
}
@@ -0,0 +1,46 @@
package onlineevent
import (
"context"
"os"
"reflect"
"testing"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestRealScriptPVFAttendanceCatalog(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify the production attendance catalog")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
catalog, err := LoadAttendancePVFCatalog(context.Background(), archive)
if err != nil {
t.Fatal(err)
}
snapshot := catalog.Snapshot()
if !reflect.DeepEqual(snapshot.ProcessDurationsSeconds, []int64{1800, 1800, 3600, 3600}) {
t.Fatalf("production raw process durations=%v", snapshot.ProcessDurationsSeconds)
}
if !reflect.DeepEqual(snapshot.SumThresholds, []int64{5, 10, 15, 25}) {
t.Fatalf("production raw sum thresholds=%v", snapshot.SumThresholds)
}
if !reflect.DeepEqual(snapshot.RewardActivityForSum, []int64{0}) {
t.Fatalf("production raw reward activity for sum=%v", snapshot.RewardActivityForSum)
}
wantItemIDs := []int64{490006574, 490006575, 490006576, 490006577, 490006578, 490006579, 490006580, 490006581}
gotRewards := append([]AttendancePVFItemReward(nil), snapshot.RewardItems...)
gotRewards = append(gotRewards, snapshot.RewardItemsForSum...)
if len(gotRewards) != len(wantItemIDs) {
t.Fatalf("production rewards=%+v", gotRewards)
}
for index, reward := range gotRewards {
if reward.ItemID != wantItemIDs[index] || reward.Count != 1 || reward.DefinitionPath == "" {
t.Fatalf("production reward[%d]=%+v want_item_id=%d", index, reward, wantItemIDs[index])
}
}
}
@@ -0,0 +1,322 @@
package onlineevent
import (
"context"
"errors"
"fmt"
"reflect"
"strings"
"testing"
)
type attendanceCatalogTestSource map[string]string
func (s attendanceCatalogTestSource) ReadText(relativePath string) (string, error) {
text, ok := s[relativePath]
if !ok {
return "", fmt.Errorf("missing %s", relativePath)
}
return text, nil
}
type cancelingAttendanceCatalogSource struct {
attendanceCatalogTestSource
cancel context.CancelFunc
cancelPath string
}
func (s cancelingAttendanceCatalogSource) ReadText(relativePath string) (string, error) {
text, err := s.attendanceCatalogTestSource.ReadText(relativePath)
if err == nil && relativePath == s.cancelPath {
s.cancel()
}
return text, err
}
func TestLoadAttendancePVFCatalogPreservesRawSections(t *testing.T) {
catalog, err := LoadAttendancePVFCatalog(context.Background(), validAttendanceCatalogSource())
if err != nil {
t.Fatal(err)
}
snapshot := catalog.Snapshot()
if !reflect.DeepEqual(snapshot.ProcessDurationsSeconds, []int64{1800, 1800, 3600, 3600}) {
t.Fatalf("raw process durations=%v", snapshot.ProcessDurationsSeconds)
}
if !reflect.DeepEqual(snapshot.SumThresholds, []int64{5, 10, 15, 25}) {
t.Fatalf("raw sum thresholds=%v", snapshot.SumThresholds)
}
if !reflect.DeepEqual(snapshot.RewardActivityForSum, []int64{0}) {
t.Fatalf("raw reward activity for sum=%v", snapshot.RewardActivityForSum)
}
wantRewards := []AttendancePVFItemReward{
{ItemID: 490006574, Count: 1, DefinitionPath: "stackable/490006001/chn_490006574.stk"},
{ItemID: 490006575, Count: 1, DefinitionPath: "stackable/490006001/chn_490006575.stk"},
{ItemID: 490006576, Count: 1, DefinitionPath: "stackable/490006001/chn_490006576.stk"},
{ItemID: 490006577, Count: 1, DefinitionPath: "stackable/490006001/chn_490006577.stk"},
}
if !reflect.DeepEqual(snapshot.RewardItems, wantRewards) {
t.Fatalf("reward items=%+v", snapshot.RewardItems)
}
wantSumRewards := []AttendancePVFItemReward{
{ItemID: 490006578, Count: 1, DefinitionPath: "stackable/490006001/chn_490006578.stk"},
{ItemID: 490006579, Count: 1, DefinitionPath: "stackable/490006001/chn_490006579.stk"},
{ItemID: 490006580, Count: 1, DefinitionPath: "stackable/490006001/chn_490006580.stk"},
{ItemID: 490006581, Count: 1, DefinitionPath: "stackable/490006001/chn_490006581.stk"},
}
if !reflect.DeepEqual(snapshot.RewardItemsForSum, wantSumRewards) {
t.Fatalf("sum reward items=%+v", snapshot.RewardItemsForSum)
}
// Every accessor result must be detached from the immutable catalog.
snapshot.ProcessDurationsSeconds[0] = 999
snapshot.RewardItems[0].ItemID = 1
snapshot.SumThresholds[0] = 999
snapshot.RewardItemsForSum[0].DefinitionPath = "changed"
snapshot.RewardActivityForSum[0] = 999
again := catalog.Snapshot()
if again.ProcessDurationsSeconds[0] != 1800 || again.RewardItems[0].ItemID != 490006574 ||
again.SumThresholds[0] != 5 || again.RewardItemsForSum[0].DefinitionPath != "stackable/490006001/chn_490006578.stk" ||
again.RewardActivityForSum[0] != 0 {
t.Fatalf("catalog leaked mutable state: %+v", again)
}
}
func TestLoadAttendancePVFCatalogRejectsMalformedSections(t *testing.T) {
valid := validAttendanceCatalogSource()[AttendanceEventPVFPath]
tests := []struct {
name string
text string
}{
{
name: "missing required section",
text: strings.Replace(valid, "[reward activity for sum]\n0\n[/reward activity for sum]\n", "", 1),
},
{
name: "duplicate section",
text: valid + "\n[process time seconds]\n1 2 3 4\n[/process time seconds]\n",
},
{
name: "missing closing section",
text: strings.Replace(valid, "[/reward activity for sum]\n", "", 1),
},
{
name: "unexpected section",
text: valid + "\n[guessed event id]\n1\n[/guessed event id]\n",
},
{
name: "mismatched closing section order",
text: swapAttendanceClosingMarkers(valid, "process time seconds", "reward item"),
},
{
name: "closing section contains a value",
text: strings.Replace(valid, "[/process time seconds]\n", "[/process time seconds]\n999\n", 1),
},
{
name: "root value before first section",
text: "999\n" + valid,
},
{
name: "non integer duration",
text: strings.Replace(valid, "1800 1800 3600 3600", "1800 invalid 3600 3600", 1),
},
{
name: "wrong duration count",
text: strings.Replace(valid, "1800 1800 3600 3600", "1800 1800 3600", 1),
},
{
name: "non positive duration",
text: strings.Replace(valid, "1800 1800 3600 3600", "1800 0 3600 3600", 1),
},
{
name: "non positive reward item",
text: strings.Replace(valid, "490006574 1", "0 1", 1),
},
{
name: "non positive reward count",
text: strings.Replace(valid, "490006574 1", "490006574 0", 1),
},
{
name: "wrong sum reward count",
text: strings.Replace(valid, "490006578 1 490006579 1 490006580 1 490006581 1", "490006578 1 490006579 1 490006580 1", 1),
},
{
name: "non positive sum threshold",
text: strings.Replace(valid, "5 10 15 25", "5 10 0 25", 1),
},
{
name: "wrong raw activity shape",
text: strings.Replace(valid, "[reward activity for sum]\n0", "[reward activity for sum]\n0 1", 1),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
source := validAttendanceCatalogSource()
source[AttendanceEventPVFPath] = test.text
_, err := LoadAttendancePVFCatalog(context.Background(), source)
if !errors.Is(err, ErrAttendancePVFMalformed) {
t.Fatalf("error=%v, want %v", err, ErrAttendancePVFMalformed)
}
})
}
}
func TestLoadAttendancePVFCatalogRequiresListedReadableDefinitions(t *testing.T) {
tests := []struct {
name string
mutate func(attendanceCatalogTestSource)
want error
}{
{
name: "reward absent from stackable list",
mutate: func(source attendanceCatalogTestSource) {
source[attendanceStackableListPath] = strings.Replace(
source[attendanceStackableListPath],
"490006574 `490006001/chn_490006574.stk`\n",
"",
1,
)
},
want: ErrAttendanceRewardUnresolved,
},
{
name: "duplicate reward list entry",
mutate: func(source attendanceCatalogTestSource) {
source[attendanceStackableListPath] += "490006574 `other/duplicate.stk`\n"
},
want: ErrAttendancePVFMalformed,
},
{
name: "definition is unreadable",
mutate: func(source attendanceCatalogTestSource) {
delete(source, "stackable/490006001/chn_490006574.stk")
},
want: ErrAttendanceRewardUnresolved,
},
{
name: "definition escapes stackable root",
mutate: func(source attendanceCatalogTestSource) {
source[attendanceStackableListPath] = strings.Replace(
source[attendanceStackableListPath],
"490006574 `490006001/chn_490006574.stk`",
"490006574 `../equipment/invalid.equ`",
1,
)
},
want: ErrAttendanceRewardUnresolved,
},
{
name: "definition normalizes outside stackable root",
mutate: func(source attendanceCatalogTestSource) {
source[attendanceStackableListPath] = strings.Replace(
source[attendanceStackableListPath],
"490006574 `490006001/chn_490006574.stk`",
"490006574 `stackable/../equipment/invalid.stk`",
1,
)
},
want: ErrAttendanceRewardUnresolved,
},
{
name: "definition uses an absolute drive path",
mutate: func(source attendanceCatalogTestSource) {
source[attendanceStackableListPath] = strings.Replace(
source[attendanceStackableListPath],
"490006574 `490006001/chn_490006574.stk`",
"490006574 `C:/outside/invalid.stk`",
1,
)
},
want: ErrAttendanceRewardUnresolved,
},
{
name: "definition is readable but not stackable extension",
mutate: func(source attendanceCatalogTestSource) {
source[attendanceStackableListPath] = strings.Replace(
source[attendanceStackableListPath],
"490006574 `490006001/chn_490006574.stk`",
"490006574 `490006001/chn_490006574.equ`",
1,
)
source["stackable/490006001/chn_490006574.equ"] = "[name] `wrong kind`\n"
},
want: ErrAttendanceRewardUnresolved,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
source := validAttendanceCatalogSource()
test.mutate(source)
_, err := LoadAttendancePVFCatalog(context.Background(), source)
if !errors.Is(err, test.want) {
t.Fatalf("error=%v, want %v", err, test.want)
}
})
}
}
func TestLoadAttendancePVFCatalogHonorsCanceledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := LoadAttendancePVFCatalog(ctx, validAttendanceCatalogSource())
if !errors.Is(err, context.Canceled) {
t.Fatalf("error=%v, want context canceled", err)
}
}
func TestLoadAttendancePVFCatalogHonorsCancellationDuringLastDefinitionRead(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
source := cancelingAttendanceCatalogSource{
attendanceCatalogTestSource: validAttendanceCatalogSource(),
cancel: cancel,
cancelPath: "stackable/490006001/chn_490006581.stk",
}
_, err := LoadAttendancePVFCatalog(ctx, source)
if !errors.Is(err, context.Canceled) {
t.Fatalf("error=%v, want context canceled", err)
}
}
func swapAttendanceClosingMarkers(text string, left string, right string) string {
placeholder := "[/__attendance_swap_marker__]"
text = strings.Replace(text, "[/"+left+"]", placeholder, 1)
text = strings.Replace(text, "[/"+right+"]", "[/"+left+"]", 1)
return strings.Replace(text, placeholder, "[/"+right+"]", 1)
}
func validAttendanceCatalogSource() attendanceCatalogTestSource {
source := attendanceCatalogTestSource{
AttendanceEventPVFPath: `[process time seconds]
1800 1800 3600 3600
[/process time seconds]
[reward item]
490006574 1 490006575 1 490006576 1 490006577 1
[/reward item]
[process seconds for max count]
5 10 15 25
[/process seconds for max count]
[reward item for sum]
490006578 1 490006579 1 490006580 1 490006581 1
[/reward item for sum]
[reward activity for sum]
0
[/reward activity for sum]
`,
attendanceStackableListPath: `490006574 ` + "`490006001/chn_490006574.stk`" + `
490006575 ` + "`490006001/chn_490006575.stk`" + `
490006576 ` + "`490006001/chn_490006576.stk`" + `
490006577 ` + "`490006001/chn_490006577.stk`" + `
490006578 ` + "`490006001/chn_490006578.stk`" + `
490006579 ` + "`490006001/chn_490006579.stk`" + `
490006580 ` + "`490006001/chn_490006580.stk`" + `
490006581 ` + "`490006001/chn_490006581.stk`" + `
`,
}
for itemID := int64(490006574); itemID <= 490006581; itemID++ {
source[fmt.Sprintf("stackable/490006001/chn_%d.stk", itemID)] = fmt.Sprintf("[name] `reward-%d`\n", itemID)
}
return source
}
@@ -0,0 +1,10 @@
// Package onlineevent owns protocol-independent daily online-time progress and
// reward claims.
//
// The package deliberately does not register a game command or encode a
// packet. A transport owner must first prove the current EXE's activity
// descriptor, progress, and claim layouts. It may then feed server-observed
// presence intervals into Owner.Observe and supply a PVF/current-client item
// allocator to Owner.Claim. AttendancePVFCatalog exposes only the raw proved
// PVF values; it does not activate an event or assign protocol semantics.
package onlineevent
@@ -0,0 +1,315 @@
package onlineevent
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const LaborDayPolicyScope = "global:labor_day_2348:v1"
const LaborDayMetadataKey = "event_labor_day_2348_v1"
const LaborDayDuration = 30 * 24 * time.Hour
const LaborDayPVFPath = "event/chn_event/chn_playdnfatlaborday.evt"
// The 2016 NPK advertises three login mails and ten daily 10-minute gifts.
// Original distribution: https://dnf.qq.com/cp/a20160421burning/
// Daily stages 1..4 are basic, 5..9 intermediate, and 10 advanced.
type LaborDayPolicy struct {
Starts time.Time `json:"starts"`
Ends time.Time `json:"ends"`
}
func (p LaborDayPolicy) ActiveAt(at time.Time) bool { return !at.Before(p.Starts) && at.Before(p.Ends) }
func (p LaborDayPolicy) valid() bool {
return !p.Starts.IsZero() && p.Ends.Sub(p.Starts) == LaborDayDuration
}
// Call under the service's catalog lock at startup. A persisted window is
// never extended, including after it has expired.
func LoadOrCreateLaborDayPolicy(ctx context.Context, repo dnfrepo.SettingsRepository, now time.Time) (LaborDayPolicy, error) {
if repo == nil || now.IsZero() {
return LaborDayPolicy{}, ErrOwnerUnavailable
}
r, found, err := repo.Load(ctx, LaborDayPolicyScope)
if err != nil {
return LaborDayPolicy{}, err
}
var p LaborDayPolicy
if found {
if err := json.Unmarshal([]byte(r.Values["state"]), &p); err != nil {
return p, err
}
if !p.valid() {
return p, ErrStateInvalid
}
return p, nil
}
p.Starts = now.UTC().Truncate(time.Second)
p.Ends = p.Starts.Add(LaborDayDuration)
b, err := json.Marshal(p)
if err != nil {
return p, err
}
err = repo.Save(ctx, dnfrepo.SettingsRecord{Scope: LaborDayPolicyScope, Values: map[string]string{"state": string(b)}, UpdatedAt: now})
return p, err
}
type LaborDayCatalog struct {
Policy LaborDayPolicy
Login [3]AttendancePVFItemReward
Online [3]AttendancePVFItemReward
}
func LoadLaborDayCatalog(ctx context.Context, source dnfpvf.Source) (*LaborDayCatalog, error) {
if source == nil {
return nil, ErrOwnerUnavailable
}
c := &LaborDayCatalog{}
text, err := source.ReadText(LaborDayPVFPath)
if err != nil {
return nil, err
}
doc, err := dnfpvf.Parse(LaborDayPVFPath, text)
if err != nil {
return nil, err
}
login, ok := doc.Section("continuous login reward")
if !ok || len(login) != 6 {
return nil, fmt.Errorf("labor day login reward section has %d tokens", len(login))
}
for i := range c.Login {
id, count := login[i*2], login[i*2+1]
if id.Kind != dnfpvf.TokenInt || count.Kind != dnfpvf.TokenInt || id.Int <= 0 || count.Int <= 0 {
return nil, fmt.Errorf("labor day login reward %d is invalid", i)
}
c.Login[i] = AttendancePVFItemReward{ItemID: id.Int, Count: count.Int}
c.Online[i] = AttendancePVFItemReward{ItemID: int64(490001502 + i), Count: 1}
}
rewards := append(append([]AttendancePVFItemReward{}, c.Login[:]...), c.Online[:]...)
paths, err := resolveAttendanceRewardDefinitions(ctx, source, rewards)
if err != nil {
return nil, err
}
attachAttendanceDefinitionPaths(c.Login[:], paths)
attachAttendanceDefinitionPaths(c.Online[:], paths)
// Online boxes must belong to this Labor Day edition. Login boxes are the
// older 10005051..53 PVF stackables and have a different title by design.
for _, r := range c.Online {
t, err := source.ReadText(r.DefinitionPath)
if err != nil {
return nil, err
}
if !strings.Contains(t, "惊喜礼盒") {
return nil, fmt.Errorf("labor day online item %d edition mismatch", r.ItemID)
}
}
return c, nil
}
type laborDayProgress struct {
Intervals []creditedInterval `json:"intervals,omitempty"`
Paid uint32 `json:"paid"`
}
type laborDayState struct {
Version int `json:"version"`
Starts time.Time `json:"starts"`
LoginDays []string `json:"login_days"`
Days map[string]laborDayProgress `json:"days"`
}
type LaborDaySnapshot struct {
LoginDays, TodaySeconds, TodayPaid uint32
MailIDs []string
}
type LaborDayCommand struct {
AccountID, CharacterID string
Now time.Time
From, To time.Time
Login bool
Deliver func(dnfrepo.MailboxRepository, AttendancePVFItemReward, string) (string, error)
}
func LaborDayOnlineTier(paid uint32) int {
if paid < 4 {
return 0
}
if paid < 9 {
return 1
}
return 2
}
// Mail, distinct login days, merged presence intervals and award receipts are
// one account-locked transaction. There is no client-submitted time or claim.
func SettleLaborDay(ctx context.Context, repos dnfrepo.Group, c *LaborDayCatalog, cmd LaborDayCommand) (LaborDaySnapshot, error) {
var result LaborDaySnapshot
if c == nil || !c.Policy.valid() || repos.MailboxAssets == nil || cmd.Deliver == nil || cmd.Now.IsZero() || cmd.AccountID == "" || cmd.CharacterID == "" {
return result, ErrOwnerUnavailable
}
if !c.Policy.ActiveAt(cmd.Now) {
return result, nil
}
if cmd.From.After(cmd.To) || cmd.To.After(cmd.Now) {
return result, ErrObservationInvalid
}
err := repos.MailboxAssets.WithinMailboxAssets(ctx, cmd.CharacterID, cmd.CharacterID, func(accounts dnfrepo.AccountRepository, _ dnfrepo.AccountInventoryRepository, characters dnfrepo.CharacterRepository, _ dnfrepo.InventoryRepository, mailboxes dnfrepo.MailboxRepository) error {
ch, found, err := characters.Load(ctx, cmd.CharacterID)
if err != nil {
return err
}
if !found || ch.AccountID != cmd.AccountID {
return ErrAccountMismatch
}
a, found, err := accounts.Load(ctx, cmd.AccountID)
if err != nil {
return err
}
if !found {
return ErrAccountNotFound
}
raw := a.Metadata[LaborDayMetadataKey]
state := laborDayState{Version: 1, Starts: c.Policy.Starts, Days: map[string]laborDayProgress{}}
if raw != "" {
d := json.NewDecoder(strings.NewReader(raw))
d.DisallowUnknownFields()
if err := d.Decode(&state); err != nil {
return err
}
if err := rejectTrailingJSON(d); err != nil {
return err
}
if state.Version != 1 || !state.Starts.Equal(c.Policy.Starts) || len(state.LoginDays) > 3 || state.Days == nil || len(state.Days) > 31 {
return ErrStateInvalid
}
previous := ""
for _, day := range state.LoginDays {
if day <= previous {
return ErrStateInvalid
}
previous = day
if _, ok := state.Days[day]; !ok {
return ErrStateInvalid
}
}
first, _ := calendarDay(ChinaCalendar, defaultDailyBoundary, c.Policy.Starts)
last, _ := calendarDay(ChinaCalendar, defaultDailyBoundary, c.Policy.Ends.Add(-time.Nanosecond))
for day, p := range state.Days {
if _, err := time.Parse(time.DateOnly, day); err != nil || day < first || day > last || p.Paid > 10 {
return ErrStateInvalid
}
var end uint32
var seconds uint32
for i, v := range p.Intervals {
if v.Start >= v.End || v.End > 86400 || (i > 0 && v.Start <= end) {
return ErrStateInvalid
}
seconds += v.End - v.Start
end = v.End
}
if p.Paid > seconds/600 {
return ErrStateInvalid
}
}
}
deliver := func(reward AttendancePVFItemReward, key string) error {
id, err := cmd.Deliver(mailboxes, reward, LaborDayMetadataKey+":"+c.Policy.Starts.Format(time.RFC3339)+":"+key)
if err != nil {
return err
}
if id == "" {
return ErrRewardInvalid
}
result.MailIDs = append(result.MailIDs, id)
return nil
}
ensureDay := func(day string) (laborDayProgress, error) {
if p, ok := state.Days[day]; ok {
return p, nil
}
// Never invent a new login day behind an already observed day.
for known := range state.Days {
if day < known {
return laborDayProgress{}, ErrObservationStale
}
}
if len(state.LoginDays) < 3 {
if err := deliver(c.Login[len(state.LoginDays)], fmt.Sprintf("login:%d", len(state.LoginDays)+1)); err != nil {
return laborDayProgress{}, err
}
state.LoginDays = append(state.LoginDays, day)
}
p := laborDayProgress{}
state.Days[day] = p
return p, nil
}
from, to := cmd.From, cmd.To
if from.Before(c.Policy.Starts) {
from = c.Policy.Starts
}
if to.After(c.Policy.Ends) {
to = c.Policy.Ends
}
if !cmd.From.IsZero() && from.Before(to) {
for from.Before(to) {
day, start := calendarDay(ChinaCalendar, defaultDailyBoundary, from)
end := start.Add(24 * time.Hour)
if end.After(to) {
end = to
}
p, err := ensureDay(day)
if err != nil {
return err
}
u := persistedState{Intervals: p.Intervals}
for _, v := range p.Intervals {
u.OnlineSeconds += uint64(v.End - v.Start)
}
lo, hi := intervalSeconds(start, from, end)
u.creditInterval(lo, hi)
p.Intervals = u.Intervals
eligible := uint32(u.OnlineSeconds / 600)
if eligible > 10 {
eligible = 10
}
for p.Paid < eligible {
tier := LaborDayOnlineTier(p.Paid)
if err := deliver(c.Online[tier], fmt.Sprintf("online:%s:%d", day, p.Paid+1)); err != nil {
return err
}
p.Paid++
}
state.Days[day] = p
from = end
}
}
if cmd.Login && c.Policy.ActiveAt(cmd.Now) {
day, _ := calendarDay(ChinaCalendar, defaultDailyBoundary, cmd.Now)
if _, err := ensureDay(day); err != nil {
return err
}
}
result.LoginDays = uint32(len(state.LoginDays))
today, _ := calendarDay(ChinaCalendar, defaultDailyBoundary, cmd.Now)
p := state.Days[today]
result.TodayPaid = p.Paid
for _, v := range p.Intervals {
result.TodaySeconds += v.End - v.Start
}
b, err := json.Marshal(state)
if err != nil {
return err
}
if string(b) == raw || (len(state.Days) == 0 && raw == "") {
return nil
}
return dnfrepo.SaveAccountMetadataEntry(ctx, accounts, a, LaborDayMetadataKey, string(b), cmd.Now)
})
if err != nil {
return LaborDaySnapshot{}, err
}
return result, nil
}
@@ -0,0 +1,116 @@
package onlineevent
import (
"context"
"os"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/repository/memory"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestRealLaborDayPVFCatalog(t *testing.T) {
p := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if p == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE")
}
a, err := platformpvf.LoadArchive(platformpvf.Options{Path: p})
if err != nil {
t.Fatal(err)
}
c, err := LoadLaborDayCatalog(context.Background(), a)
if err != nil {
t.Fatal(err)
}
for i, id := range []int64{10005051, 10005052, 10005053} {
if c.Login[i].ItemID != id || c.Login[i].DefinitionPath == "" {
t.Fatalf("login[%d]=%+v", i, c.Login[i])
}
}
for i, id := range []int64{490001502, 490001503, 490001504} {
if c.Online[i].ItemID != id || c.Online[i].DefinitionPath == "" {
t.Fatalf("online[%d]=%+v", i, c.Online[i])
}
}
}
func TestLaborDayOnlineTierMatchesNativeTable(t *testing.T) {
for paid, want := range map[uint32]int{0: 0, 3: 0, 4: 1, 8: 1, 9: 2, 10: 2} {
if got := LaborDayOnlineTier(paid); got != want {
t.Fatalf("paid=%d tier=%d want=%d", paid, got, want)
}
}
}
func TestSettleLaborDayLoginAndTenMinuteDistribution(t *testing.T) {
ctx := context.Background()
repos := memory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "a"}); err != nil {
t.Fatal(err)
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "1", AccountID: "a"}); err != nil {
t.Fatal(err)
}
start := time.Date(2026, 9, 16, 6, 0, 0, 0, ChinaCalendar)
c := &LaborDayCatalog{Policy: LaborDayPolicy{Starts: start, Ends: start.Add(LaborDayDuration)}}
for i := range c.Login {
c.Login[i] = AttendancePVFItemReward{ItemID: int64(499 + i), Count: 1}
c.Online[i] = AttendancePVFItemReward{ItemID: int64(502 + i), Count: 1}
}
var delivered []int64
deliver := func(_ dnfrepo.MailboxRepository, r AttendancePVFItemReward, _ string) (string, error) {
delivered = append(delivered, r.ItemID)
return "mail", nil
}
cmd := LaborDayCommand{AccountID: "a", CharacterID: "1", Now: start, Login: true, Deliver: deliver}
s, err := SettleLaborDay(ctx, repos, c, cmd)
if err != nil {
t.Fatal(err)
}
if s.LoginDays != 1 || len(delivered) != 1 || delivered[0] != 499 {
t.Fatalf("login snapshot=%+v delivered=%v", s, delivered)
}
cmd.Login = false
cmd.From = start
cmd.To = start.Add(100 * time.Minute)
cmd.Now = cmd.To
s, err = SettleLaborDay(ctx, repos, c, cmd)
if err != nil {
t.Fatal(err)
}
if s.TodayPaid != 10 || s.TodaySeconds != 6000 || len(delivered) != 11 {
t.Fatalf("online snapshot=%+v delivered=%v", s, delivered)
}
for i, want := range []int64{502, 502, 502, 502, 503, 503, 503, 503, 503, 504} {
if delivered[i+1] != want {
t.Fatalf("award %d=%d want=%d", i, delivered[i+1], want)
}
}
cmd.From, cmd.To, cmd.Now = cmd.To, cmd.To.Add(10*time.Minute), cmd.To.Add(10*time.Minute)
s, err = SettleLaborDay(ctx, repos, c, cmd)
if err != nil {
t.Fatal(err)
}
if s.TodayPaid != 10 || len(delivered) != 11 {
t.Fatalf("daily cap snapshot=%+v delivered=%v", s, delivered)
}
}
func TestLaborDayPolicyPersistsFixedThirtyDays(t *testing.T) {
ctx := context.Background()
repos := memory.NewMemoryGroup()
now := time.Date(2026, 9, 16, 12, 0, 0, 0, ChinaCalendar)
p, err := LoadOrCreateLaborDayPolicy(ctx, repos.Settings, now)
if err != nil {
t.Fatal(err)
}
p2, err := LoadOrCreateLaborDayPolicy(ctx, repos.Settings, now.Add(2*time.Hour))
if err != nil {
t.Fatal(err)
}
if !p.valid() || !p.Starts.Equal(p2.Starts) || !p.Ends.Equal(p2.Ends) {
t.Fatalf("p=%+v p2=%+v", p, p2)
}
}
@@ -0,0 +1,203 @@
package onlineevent
import (
"context"
"encoding/json"
"fmt"
"strconv"
"time"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const LegacyLevelPVFPath = "event/decidelevelupevent.evt"
const LegacyLevelMetadataKey = "event_legacy_levelup_116_v1"
type LegacyLevelReward struct {
Level uint32
Item AttendancePVFItemReward
}
type LegacyLevelCatalog struct {
Main []LegacyLevelReward
Stage []LegacyLevelReward
}
func LoadLegacyLevelCatalog(ctx context.Context, source dnfpvf.Source) (*LegacyLevelCatalog, error) {
text, err := source.ReadText(LegacyLevelPVFPath)
if err != nil {
return nil, err
}
doc, err := dnfpvf.Parse(LegacyLevelPVFPath, text)
if err != nil {
return nil, err
}
catalog := &LegacyLevelCatalog{}
all := []AttendancePVFItemReward{}
for _, spec := range []struct {
name string
count int
dst *[]LegacyLevelReward
}{
{"Main Reward", 6, &catalog.Main}, {"Sub Reward", 8, &catalog.Stage},
} {
values, err := attendanceIntegerSection(doc, spec.name, spec.count*4)
if err != nil {
return nil, err
}
var previous int64
for i := 0; i < len(values); i += 4 {
level, id, count, enabled := values[i], values[i+1], values[i+2], values[i+3]
if level <= previous || level > 90 || id <= 0 || id > 0xffffffff || count <= 0 || count > 0x7fffffff || enabled != 1 {
return nil, fmt.Errorf("legacy level reward [%s] row=%d is invalid", spec.name, i/4)
}
reward := AttendancePVFItemReward{ItemID: id, Count: count}
*spec.dst = append(*spec.dst, LegacyLevelReward{Level: uint32(level), Item: reward})
all = append(all, reward)
previous = level
}
}
paths, err := resolveAttendanceRewardDefinitions(ctx, source, all)
if err != nil {
return nil, err
}
for _, rows := range [][]LegacyLevelReward{catalog.Main, catalog.Stage} {
for i := range rows {
rows[i].Item.DefinitionPath = paths[rows[i].Item.ItemID]
}
}
return catalog, nil
}
type LegacyLevelState struct {
Target uint32 `json:"target"`
Start uint32 `json:"start"`
Stages uint32 `json:"stages"`
Claimed bool `json:"claimed"`
}
type LegacyLevelResult struct {
LegacyLevelState
MailIDs []string
}
type LegacyLevelCommand struct {
AccountID, CharacterID string
Target uint32
Claim bool
Now time.Time
Deliver func(dnfrepo.MailboxRepository, AttendancePVFItemReward, string) (string, error)
}
// Reservation and mail receipts share the existing mailbox/account transaction.
func SettleLegacyLevel(ctx context.Context, repos dnfrepo.Group, catalog *LegacyLevelCatalog, cmd LegacyLevelCommand) (LegacyLevelResult, error) {
if repos.MailboxAssets == nil || catalog == nil || len(catalog.Main) == 0 || cmd.AccountID == "" || cmd.CharacterID == "" || cmd.Now.IsZero() || cmd.Deliver == nil {
return LegacyLevelResult{}, ErrOwnerUnavailable
}
var result LegacyLevelResult
err := repos.MailboxAssets.WithinMailboxAssets(ctx, cmd.CharacterID, cmd.CharacterID, func(
accounts dnfrepo.AccountRepository, _ dnfrepo.AccountInventoryRepository, characters dnfrepo.CharacterRepository, _ dnfrepo.InventoryRepository, mailboxes dnfrepo.MailboxRepository,
) error {
character, found, err := characters.Load(ctx, cmd.CharacterID)
if err != nil {
return err
}
if !found || character.AccountID != cmd.AccountID || character.Level < 1 || character.Level > 90 {
return fmt.Errorf("legacy level character ownership/level mismatch")
}
account, found, err := accounts.Load(ctx, cmd.AccountID)
if err != nil {
return err
}
if !found {
return fmt.Errorf("legacy level account missing")
}
states := map[string]LegacyLevelState{}
if raw := account.Metadata[LegacyLevelMetadataKey]; raw != "" {
if err := json.Unmarshal([]byte(raw), &states); err != nil {
return err
}
if states == nil {
return fmt.Errorf("legacy level state is null")
}
}
state := states[cmd.CharacterID]
findMain := func(target uint32) (AttendancePVFItemReward, bool) {
for _, row := range catalog.Main {
if row.Level == target {
return row.Item, true
}
}
return AttendancePVFItemReward{}, false
}
main, valid := findMain(state.Target)
if state.Target == 0 && state != (LegacyLevelState{}) {
return fmt.Errorf("legacy level unreserved state is invalid")
}
if state.Target != 0 && (!valid || state.Start < 1 || state.Start >= state.Target || state.Stages>>len(catalog.Stage) != 0) {
return fmt.Errorf("legacy level saved state is invalid")
}
changed := false
if cmd.Target != 0 {
if state.Target != 0 && state.Target != cmd.Target {
return fmt.Errorf("legacy level already reserved")
}
if state.Target == 0 {
main, valid = findMain(cmd.Target)
if !valid || cmd.Target <= uint32(character.Level) {
return fmt.Errorf("legacy level target is unavailable")
}
state.Target, state.Start = cmd.Target, uint32(character.Level)
changed = true
}
}
if cmd.Claim && (state.Target == 0 || uint32(character.Level) < state.Target) {
return fmt.Errorf("legacy level target is not reached")
}
deliver := func(reward AttendancePVFItemReward, suffix string) error {
key := LegacyLevelMetadataKey + ":" + cmd.CharacterID + ":" + suffix
id, err := cmd.Deliver(mailboxes, reward, key)
if err != nil {
return err
}
if id == "" {
return fmt.Errorf("legacy level reward mail missing")
}
result.MailIDs = append(result.MailIDs, id)
changed = true
return nil
}
if state.Target != 0 {
for i, row := range catalog.Stage {
if row.Level <= state.Start || row.Level > uint32(character.Level) || row.Level > state.Target || state.Stages&(1<<i) != 0 {
continue
}
if err := deliver(row.Item, "stage:"+strconv.Itoa(int(row.Level))); err != nil {
return err
}
state.Stages |= 1 << i
}
if cmd.Claim && !state.Claimed {
if err := deliver(main, "main"); err != nil {
return err
}
state.Claimed = true
}
}
result.LegacyLevelState = state
if !changed {
return nil
}
states[cmd.CharacterID] = state
raw, err := json.Marshal(states)
if err != nil {
return err
}
return dnfrepo.SaveAccountMetadataEntry(ctx, accounts, account, LegacyLevelMetadataKey, string(raw), cmd.Now)
})
if err != nil {
return LegacyLevelResult{}, err
}
return result, nil
}
@@ -0,0 +1,87 @@
package onlineevent
import (
"context"
"errors"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestLegacyLevelReservationDeliveryRollbackAndReplay(t *testing.T) {
ctx := context.Background()
repos := memory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "a"}); err != nil {
t.Fatal(err)
}
save := func(id string, level int) {
t.Helper()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: id, AccountID: "a", Level: level}); err != nil {
t.Fatal(err)
}
}
save("1", 15)
save("2", 15)
catalog := &LegacyLevelCatalog{
Main: []LegacyLevelReward{{35, AttendancePVFItemReward{ItemID: 100, Count: 1}}, {45, AttendancePVFItemReward{ItemID: 101, Count: 1}}},
Stage: []LegacyLevelReward{{10, AttendancePVFItemReward{ItemID: 10, Count: 1}}, {20, AttendancePVFItemReward{ItemID: 20, Count: 1}}, {30, AttendancePVFItemReward{ItemID: 30, Count: 1}}, {40, AttendancePVFItemReward{ItemID: 40, Count: 1}}},
}
cmd := LegacyLevelCommand{AccountID: "a", CharacterID: "1", Now: time.Now(), Target: 35}
fail := false
cmd.Deliver = func(mailboxes dnfrepo.MailboxRepository, reward AttendancePVFItemReward, key string) (string, error) {
if fail && reward.ItemID == 30 {
return "", errors.New("mail failure")
}
return dnfrepo.AppendSystemMail(ctx, mailboxes, dnfrepo.SystemMailDelivery{RecipientCharacterID: cmd.CharacterID, Source: "test", CompletionKey: key, Attachments: []dnfrepo.MailAttachment{{ItemID: reward.ItemID, Count: reward.Count}}})
}
state, err := SettleLegacyLevel(ctx, repos, catalog, cmd)
if err != nil || state.Target != 35 || state.Start != 15 || state.Stages != 0 {
t.Fatalf("reserve=%+v err=%v", state, err)
}
cmd.Target = 45
if _, err := SettleLegacyLevel(ctx, repos, catalog, cmd); err == nil {
t.Fatal("reservation changed")
}
cmd.Target, cmd.Claim = 0, true
if _, err := SettleLegacyLevel(ctx, repos, catalog, cmd); err == nil {
t.Fatal("early claim accepted")
}
save("1", 45)
fail = true
if _, err := SettleLegacyLevel(ctx, repos, catalog, cmd); err == nil {
t.Fatal("failed mail committed")
}
mailbox, _, _ := repos.Mailbox.Load(ctx, "1")
if len(mailbox.Mails) != 0 {
t.Fatal("rollback leaked mail")
}
fail = false
state, err = SettleLegacyLevel(ctx, repos, catalog, cmd)
if err != nil || !state.Claimed || state.Stages != 6 || len(state.MailIDs) != 3 {
t.Fatalf("claim=%+v err=%v", state, err)
}
state, err = SettleLegacyLevel(ctx, repos, catalog, cmd)
if err != nil || len(state.MailIDs) != 0 {
t.Fatalf("replay=%+v err=%v", state, err)
}
cmd.CharacterID, cmd.Claim = "2", false
state, err = SettleLegacyLevel(ctx, repos, catalog, cmd)
if err != nil || state.Target != 0 || state.Claimed {
t.Fatalf("character isolation=%+v err=%v", state, err)
}
cmd.AccountID = "foreign"
if _, err := SettleLegacyLevel(ctx, repos, catalog, cmd); err == nil {
t.Fatal("foreign account accepted")
}
cmd.AccountID = "a"
account, _, _ := repos.Account.Load(ctx, "a")
account.Metadata[LegacyLevelMetadataKey] = `{"2":{"target":0,"claimed":true}}`
if err := repos.Account.Save(ctx, account); err != nil {
t.Fatal(err)
}
if _, err := SettleLegacyLevel(ctx, repos, catalog, cmd); err == nil {
t.Fatal("corrupt reservation accepted")
}
}
@@ -0,0 +1,277 @@
package onlineevent
import (
"context"
"encoding/json"
"fmt"
"slices"
"strconv"
"time"
"longheng.io/server/internal/modules/dnf/jobmap"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const LevelAimingPVFPath = "event/levelaimingevent.evt"
const LevelAimingMetadataKey = "event_level_aiming_222_v1"
const LevelAimingRoleStat = "level_aiming_222_role"
// These are the ten targets in the current EXE's dword_435A72C.
var LevelAimingTargets = [...]uint32{20, 30, 40, 50, 60, 70, 75, 80, 85, 86}
type LevelAimingCatalog struct {
Limit, MinLevel, MaxLevel, Job int
Starts, Ends time.Time
Main AttendancePVFItemReward
Points map[uint32]int64
Stages []LegacyLevelReward
}
func LoadLevelAimingCatalog(ctx context.Context, source dnfpvf.Source) (*LevelAimingCatalog, error) {
text, err := source.ReadText(LevelAimingPVFPath)
if err != nil {
return nil, err
}
doc, err := dnfpvf.Parse(LevelAimingPVFPath, text)
if err != nil {
return nil, err
}
control, ok := doc.Section("control panel")
if !ok || len(control) != 10 {
return nil, fmt.Errorf("level aiming control panel requires 10 values")
}
for i := 0; i < 5; i++ {
if control[i].Kind != dnfpvf.TokenInt {
return nil, fmt.Errorf("level aiming control[%d] is not an integer", i)
}
}
c := &LevelAimingCatalog{Limit: int(control[1].Int), MinLevel: int(control[2].Int), MaxLevel: int(control[3].Int), Job: int(control[4].Int), Points: map[uint32]int64{}}
if control[0].Int != 2 || c.Limit < 1 || c.Limit > 5 || c.MinLevel != 1 || c.MaxLevel != 86 || (c.Job != 99 && !jobmap.Valid(c.Job)) {
return nil, fmt.Errorf("unsupported level aiming control panel")
}
loc := time.FixedZone("CST", 8*3600)
c.Starts, err = time.ParseInLocation("2006-01-02 15:04:05", control[5].Value, loc)
if err != nil {
return nil, err
}
c.Ends, err = time.ParseInLocation("2006-01-02 15:04:05", control[6].Value, loc)
if err != nil || !c.Ends.After(c.Starts) {
return nil, fmt.Errorf("invalid level aiming dates")
}
// The currently deployed new-character reward ratio is exactly one.
if control[5].Kind != dnfpvf.TokenString || control[6].Kind != dnfpvf.TokenString || control[9].Kind != dnfpvf.TokenString || control[7].Float != 1 || control[8].Float != 1 {
return nil, fmt.Errorf("unsupported level aiming control types or reward ratio")
}
reward, err := attendanceIntegerSection(doc, "reward", 3)
if err != nil {
return nil, err
}
if reward[0] != 0 || reward[1] <= 0 || reward[1] > 0xffffffff || reward[2] != 1 {
return nil, fmt.Errorf("invalid level aiming main reward")
}
c.Main = AttendancePVFItemReward{ItemID: reward[1], Count: reward[2]}
points, err := attendanceIntegerSection(doc, "main reward point table", 180)
if err != nil {
return nil, err
}
for i := 0; i < len(points); i += 2 {
if points[i] != int64(i/2+1) || points[i+1] < 0 || points[i+1] > 0x7fffffff {
return nil, fmt.Errorf("invalid level aiming point row")
}
c.Points[uint32(points[i])] = points[i+1]
}
stages, err := attendanceIntegerSection(doc, "sub reward lv table", 48)
if err != nil {
return nil, err
}
all := []AttendancePVFItemReward{c.Main}
for i := 0; i < len(stages); i += 3 {
if stages[i] < 1 || stages[i] > int64(c.MaxLevel) || (i > 0 && stages[i] <= stages[i-3]) || stages[i+1] <= 0 || stages[i+1] > 0xffffffff || stages[i+2] <= 0 || stages[i+2] > 0x7fffffff {
return nil, fmt.Errorf("invalid level aiming stage row")
}
r := AttendancePVFItemReward{ItemID: stages[i+1], Count: stages[i+2]}
c.Stages = append(c.Stages, LegacyLevelReward{Level: uint32(stages[i]), Item: r})
all = append(all, r)
}
paths, err := resolveAttendanceRewardDefinitions(ctx, source, all)
if err != nil {
return nil, err
}
c.Main.DefinitionPath = paths[c.Main.ItemID]
for i := range c.Stages {
c.Stages[i].Item.DefinitionPath = paths[c.Stages[i].Item.ItemID]
}
return c, nil
}
type LevelAimingState struct {
CharacterID string `json:"character_id"`
Start, Target, Reservations uint32
Stages uint32
Awarded bool
}
func ReadLevelAimingState(account dnfrepo.AccountRecord) (LevelAimingState, error) {
var state LevelAimingState
if raw := account.Metadata[LevelAimingMetadataKey]; raw != "" {
if err := json.Unmarshal([]byte(raw), &state); err != nil {
return state, err
}
id, err := strconv.ParseUint(state.CharacterID, 10, 16)
if err != nil || id == 0 || state.Start < 1 || state.Start > 86 || state.Reservations > 5 || state.Stages>>16 != 0 || (state.Target == 0 && (state.Start != 1 || state.Reservations != 0 || state.Awarded || state.Stages != 0)) || (state.Target != 0 && (!slices.Contains(LevelAimingTargets[:], state.Target) || state.Reservations == 0 || state.Start > state.Target)) {
return state, fmt.Errorf("invalid saved level aiming state")
}
if (state.Reservations <= 1 && state.Start != 1) || (state.Reservations > 1 && !slices.Contains(LevelAimingTargets[:], state.Start-1)) {
return state, fmt.Errorf("invalid saved level aiming interval")
}
}
return state, nil
}
type LevelAimingCommand struct {
AccountID, CharacterID string
Register bool
Target uint32
Now time.Time
Deliver func(dnfrepo.MailboxRepository, AttendancePVFItemReward, string) (string, error)
}
type LevelAimingResult struct {
LevelAimingState
Character dnfrepo.CharacterRecord
MailIDs []string
}
func SettleLevelAiming(ctx context.Context, repos dnfrepo.Group, c *LevelAimingCatalog, cmd LevelAimingCommand) (LevelAimingResult, error) {
if c == nil || repos.MailboxAssets == nil || cmd.AccountID == "" || cmd.CharacterID == "" || cmd.Now.IsZero() {
return LevelAimingResult{}, ErrOwnerUnavailable
}
var result LevelAimingResult
err := repos.MailboxAssets.WithinMailboxAssets(ctx, cmd.CharacterID, cmd.CharacterID, func(accounts dnfrepo.AccountRepository, _ dnfrepo.AccountInventoryRepository, characters dnfrepo.CharacterRepository, _ dnfrepo.InventoryRepository, mailboxes dnfrepo.MailboxRepository) error {
ch, found, err := characters.Load(ctx, cmd.CharacterID)
if err != nil {
return err
}
if !found || ch.AccountID != cmd.AccountID {
return fmt.Errorf("level aiming character ownership mismatch")
}
account, found, err := accounts.Load(ctx, cmd.AccountID)
if err != nil {
return err
}
if !found {
return fmt.Errorf("level aiming account missing")
}
state, err := ReadLevelAimingState(account)
if err != nil {
return err
}
result.Character = ch
result.LevelAimingState = state
active := !cmd.Now.Before(c.Starts) && cmd.Now.Before(c.Ends)
if (cmd.Register || cmd.Target != 0) && !active {
return fmt.Errorf("level aiming event is outside its dates")
}
changed := false
if cmd.Register && state.CharacterID == "" {
job, err := strconv.Atoi(ch.Job)
if err != nil || !jobmap.Valid(job) || (c.Job != 99 && c.Job != job) || ch.Level != 1 || ch.CreatedAt.Before(c.Starts) || !ch.CreatedAt.Before(c.Ends) {
return fmt.Errorf("level aiming requires a new eligible level-one character")
}
state = LevelAimingState{CharacterID: ch.CharacterID, Start: 1}
changed = true
}
if (cmd.Register || cmd.Target != 0) && state.CharacterID != ch.CharacterID {
return fmt.Errorf("level aiming account already owns another event character")
}
if state.CharacterID != ch.CharacterID || !active {
return nil
}
deliver := func(reward AttendancePVFItemReward, key string) error {
if cmd.Deliver == nil {
return ErrOwnerUnavailable
}
id, err := cmd.Deliver(mailboxes, reward, LevelAimingMetadataKey+":"+ch.CharacterID+":"+key)
if err != nil {
return err
}
if id == "" {
return fmt.Errorf("level aiming reward mail missing")
}
result.MailIDs = append(result.MailIDs, id)
changed = true
return nil
}
// Settle the preceding target before accepting the next reservation.
if state.Target != 0 && uint32(ch.Level) >= state.Target && !state.Awarded {
var count int64
for level := state.Start; level <= state.Target; level++ {
count += c.Points[level]
}
if count > 0 {
reward := c.Main
if reward.Count <= 0 || count > 0x7fffffff/reward.Count {
return fmt.Errorf("level aiming reward count overflow")
}
reward.Count *= count
if err := deliver(reward, "target:"+strconv.Itoa(int(state.Target))); err != nil {
return err
}
}
state.Awarded = true
changed = true
}
if cmd.Target != 0 && cmd.Target != state.Target {
if !slices.Contains(LevelAimingTargets[:], cmd.Target) || cmd.Target <= uint32(ch.Level) || cmd.Target <= state.Target || state.Reservations >= uint32(c.Limit) || (state.Target != 0 && !state.Awarded) {
return fmt.Errorf("level aiming target is unavailable")
}
start := uint32(1)
if state.Target != 0 {
start = state.Target + 1
}
state.Start, state.Target, state.Awarded = start, cmd.Target, false
state.Reservations++
changed = true
}
if state.Target != 0 {
for i, row := range c.Stages {
if row.Level > uint32(ch.Level) || state.Stages&(1<<i) != 0 {
continue
}
if err := deliver(row.Item, "stage:"+strconv.Itoa(int(row.Level))); err != nil {
return err
}
state.Stages |= 1 << i
}
}
if changed {
ch = dnfrepo.CloneCharacter(ch)
if ch.Stats == nil {
ch.Stats = map[string]int64{}
}
role := int64(3)
if state.Target == 0 {
role = 5
}
ch.Stats[LevelAimingRoleStat] = role
ch.UpdatedAt = cmd.Now
if err := characters.Save(ctx, ch); err != nil {
return err
}
raw, err := json.Marshal(state)
if err != nil {
return err
}
if err := dnfrepo.SaveAccountMetadataEntry(ctx, accounts, account, LevelAimingMetadataKey, string(raw), cmd.Now); err != nil {
return err
}
}
result.LevelAimingState, result.Character = state, ch
return nil
})
if err != nil {
return LevelAimingResult{}, err
}
return result, nil
}
@@ -0,0 +1,130 @@
package onlineevent
import (
"context"
"errors"
"strconv"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestLevelAimingAllJobsReservationRollbackAndReplay(t *testing.T) {
ctx := context.Background()
now := time.Date(2026, 9, 8, 8, 0, 0, 0, time.UTC)
c := &LevelAimingCatalog{Limit: 5, MinLevel: 1, MaxLevel: 86, Job: 99, Starts: now.Add(-time.Hour), Ends: now.Add(time.Hour), Main: AttendancePVFItemReward{ItemID: 100, Count: 1}, Points: map[uint32]int64{20: 5, 30: 10}, Stages: []LegacyLevelReward{{Level: 10, Item: AttendancePVFItemReward{ItemID: 101, Count: 1}}, {Level: 20, Item: AttendancePVFItemReward{ItemID: 102, Count: 1}}}}
for job := 0; job < 16; job++ {
repos := memory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "a"}); err != nil {
t.Fatal(err)
}
save := func(id string, level int) {
t.Helper()
ch, _, _ := repos.Character.Load(ctx, id)
ch.CharacterID = id
ch.AccountID = "a"
ch.Job = strconv.Itoa(job)
ch.Level = level
ch.CreatedAt = now
if err := repos.Character.Save(ctx, ch); err != nil {
t.Fatal(err)
}
}
save("1", 1)
save("2", 1)
cmd := LevelAimingCommand{AccountID: "a", CharacterID: "1", Now: now, Register: true}
fail := false
cmd.Deliver = func(mailboxes dnfrepo.MailboxRepository, r AttendancePVFItemReward, key string) (string, error) {
if fail && r.ItemID == 102 {
return "", errors.New("mail failure")
}
return dnfrepo.AppendSystemMail(ctx, mailboxes, dnfrepo.SystemMailDelivery{RecipientCharacterID: cmd.CharacterID, Source: "test", CompletionKey: key, Attachments: []dnfrepo.MailAttachment{{ItemID: r.ItemID, Count: r.Count}}})
}
result, err := SettleLevelAiming(ctx, repos, c, cmd)
if err != nil || result.Target != 0 || result.Character.Stats[LevelAimingRoleStat] != 5 {
t.Fatalf("job=%d register=%+v err=%v", job, result, err)
}
cmd.CharacterID = "2"
if _, err = SettleLevelAiming(ctx, repos, c, cmd); err == nil {
t.Fatal("second event character accepted")
}
cmd.CharacterID = "1"
cmd.Register = false
cmd.Target = 20
result, err = SettleLevelAiming(ctx, repos, c, cmd)
if err != nil || result.Reservations != 1 || result.Character.Stats[LevelAimingRoleStat] != 3 {
t.Fatalf("reserve=%+v err=%v", result, err)
}
cmd.Target = 30
if _, err = SettleLevelAiming(ctx, repos, c, cmd); err == nil {
t.Fatal("unfinished reservation changed")
}
save("1", 20)
cmd.Target = 0
fail = true
if _, err = SettleLevelAiming(ctx, repos, c, cmd); err == nil {
t.Fatal("failed delivery committed")
}
box, _, _ := repos.Mailbox.Load(ctx, "1")
if len(box.Mails) != 0 {
t.Fatal("rollback leaked mail")
}
fail = false
result, err = SettleLevelAiming(ctx, repos, c, cmd)
if err != nil || !result.Awarded || len(result.MailIDs) != 3 {
t.Fatalf("settle=%+v err=%v", result, err)
}
result, err = SettleLevelAiming(ctx, repos, c, cmd)
if err != nil || len(result.MailIDs) != 0 {
t.Fatalf("replay=%+v err=%v", result, err)
}
cmd.Target = 30
result, err = SettleLevelAiming(ctx, repos, c, cmd)
if err != nil || result.Start != 21 || result.Reservations != 2 {
t.Fatalf("next=%+v err=%v", result, err)
}
save("1", 30)
cmd.Target = 0
result, err = SettleLevelAiming(ctx, repos, c, cmd)
if err != nil || len(result.MailIDs) != 1 {
t.Fatalf("next reward=%+v err=%v", result, err)
}
box, _, _ = repos.Mailbox.Load(ctx, "1")
var coins int64
for _, mail := range box.Mails {
for _, a := range mail.Attachments {
if a.ItemID == 100 {
coins += a.Count
}
}
}
if coins != 15 {
t.Fatalf("cumulative coins=%d", coins)
}
cmd.AccountID = "foreign"
if _, err = SettleLevelAiming(ctx, repos, c, cmd); err == nil {
t.Fatal("foreign account accepted")
}
cmd.AccountID = "a"
cmd.Target = 85
if _, err = SettleLevelAiming(ctx, repos, c, cmd); err != nil {
t.Fatal(err)
}
save("1", 85)
cmd.Target = 86
if _, err = SettleLevelAiming(ctx, repos, c, cmd); err != nil {
t.Fatal(err)
}
cmd.Target = 0
if result, err = SettleLevelAiming(ctx, repos, c, cmd); err != nil || result.Start != 86 || result.Target != 86 {
t.Fatalf("85 to 86 interval=%+v err=%v", result, err)
}
cmd.Target = 40
cmd.Now = c.Ends
if _, err = SettleLevelAiming(ctx, repos, c, cmd); err == nil {
t.Fatal("expired event accepted")
}
}
}
@@ -0,0 +1,385 @@
package onlineevent
import (
"context"
"errors"
"fmt"
"math"
"strings"
"time"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const MarblePVFPath = "event/attendancemarble.evt"
const MarbleBoardSize = 28
const MarbleDayKey = "attendance_marble_calendar_day"
// etc/variousbufflist.etc, index 29: unique_rarity_item_drop_rate (3, 120).
const MarbleUniqueBonusPercent int64 = 120
func MarbleEquipmentRarityThresholds(thresholds [7]int64) [7]int64 {
weight := max(int64(0), min(thresholds[3], int64(1000000))-thresholds[2])
extra := weight * MarbleUniqueBonusPercent / 100
// Take the added unique probability from lower rarities; higher tiers retain
// their PVF probabilities and the cumulative denominator remains unchanged.
for i := 0; i < 3; i++ {
thresholds[i] = max(int64(0), thresholds[i]-extra)
}
return thresholds
}
var ErrMarbleState = errors.New("attendance marble state invalid")
var ErrMarbleSpent = errors.New("attendance marble earned turns exhausted")
var ErrMarbleGold = errors.New("attendance marble gold insufficient")
type MarbleSlot struct {
Index, Type, Goto, Buff int
ItemID, ItemCount, Gold int64
}
type MarbleStageReward struct {
Round int
ItemID, Count int64
}
type MarbleCatalog struct {
Slots [MarbleBoardSize]MarbleSlot
StageRewards []MarbleStageReward
ClearRewards [3][2]int64
PostalDays int
}
func ParseMarbleCatalog(doc *dnfpvf.Document) (*MarbleCatalog, error) {
if doc == nil {
return nil, ErrMarbleState
}
c := &MarbleCatalog{}
clears, err := attendanceIntegerSection(doc, "reward clear", 6)
if err != nil {
return nil, err
}
for i := range c.ClearRewards {
if clears[i*2] != int64((i+1)*4) || clears[i*2+1] != 2 {
return nil, fmt.Errorf("marble clear rewards differ from native four records")
}
c.ClearRewards[i] = [2]int64{clears[i*2], clears[i*2+1]}
}
postal, err := attendanceIntegerSection(doc, "reward postal day", 1)
if err != nil || postal[0] < 1 || postal[0] > 365 {
return nil, fmt.Errorf("marble postal duration invalid")
}
c.PostalDays = int(postal[0])
stages, err := attendanceIntegerSection(doc, "accumulate race info", 15)
if err != nil {
return nil, err
}
for i := 0; i < 3; i++ {
v := stages[i*5:]
if v[0] != int64((i+1)*4) || v[1] < 1 || v[1] > 0xffffffff || v[2] < 1 || v[2] > 0xffffffff {
return nil, fmt.Errorf("marble lap reward invalid")
}
c.StageRewards = append(c.StageRewards, MarbleStageReward{Round: int(v[0]), ItemID: v[1], Count: v[2]})
}
seen := [MarbleBoardSize]bool{}
for i := 0; i < len(doc.Tokens); i++ {
t := doc.Tokens[i]
if t.Kind != dnfpvf.TokenSection || !strings.EqualFold(t.Value, "slot") {
continue
}
fields := map[string][]int64{}
name := ""
i++
for ; i < len(doc.Tokens); i++ {
t = doc.Tokens[i]
if t.Kind == dnfpvf.TokenSection {
name = strings.ToLower(strings.TrimSpace(t.Value))
if name == "/slot" {
break
}
} else if t.Kind == dnfpvf.TokenInt {
fields[name] = append(fields[name], t.Int)
}
}
if i == len(doc.Tokens) || len(fields["index"]) != 1 || len(fields["type"]) != 1 {
return nil, ErrMarbleState
}
index, kind := int(fields["index"][0]), int(fields["type"][0])
if index < 0 || index >= MarbleBoardSize || seen[index] {
return nil, ErrMarbleState
}
slot := MarbleSlot{Index: index, Type: kind, Goto: -1}
switch kind {
case 1, 5:
v := fields["item"]
if len(v) != 2 || v[0] < 1 || v[0] > 0xffffffff || v[1] < 1 || v[1] > 0xffffffff {
return nil, ErrMarbleState
}
slot.ItemID, slot.ItemCount = v[0], v[1]
if kind == 5 {
v = fields["gold"]
if len(v) != 1 || v[0] < 1 {
return nil, ErrMarbleState
}
slot.Gold = v[0]
}
case 3:
v := fields["goto"]
if len(v) != 1 || v[0] < 0 || v[0] >= MarbleBoardSize {
return nil, ErrMarbleState
}
slot.Goto = int(v[0])
case 7:
v := fields["buff index"]
if len(v) != 1 || v[0] < 29 || v[0] > 32 {
return nil, ErrMarbleState
}
slot.Buff = int(v[0])
default:
return nil, fmt.Errorf("marble unsupported slot type %d", kind)
}
seen[index], c.Slots[index] = true, slot
}
for _, present := range seen {
if !present {
return nil, ErrMarbleState
}
}
for _, slot := range c.Slots {
if slot.Type == 3 && c.Slots[slot.Goto].Type == 3 {
return nil, ErrMarbleState
}
}
return c, nil
}
func MarbleDay(now time.Time) int64 {
now = now.In(ChinaCalendar).Add(-6 * time.Hour)
return int64(now.Year()*10000 + int(now.Month())*100 + now.Day())
}
type MarbleState struct {
Position, Round, DiceUsed, Double, DoubleChain int64
Reward4, Reward8, Reward12, CalendarDay int64
Clears, Turns, Serial int64
MailIDs []string
GoldChanged bool
GoldAfter int64
BuffIndex int
PendingBuffs [4]int64
}
func (s MarbleState) Remaining() int64 { return s.Turns - s.DiceUsed }
func ReadMarbleState(character dnfrepo.CharacterRecord, c *MarbleCatalog, now time.Time) (MarbleState, error) {
if c == nil {
return MarbleState{}, ErrMarbleState
}
v := character.Stats
for i := 29; i <= 32; i++ {
if count := v[fmt.Sprintf("attendance_marble_buff_%d", i)]; count < 0 || count > 10000 {
return MarbleState{}, ErrMarbleState
}
}
s := MarbleState{Position: v["attendance_marble_position"], Round: v["attendance_marble_round"], DiceUsed: v["attendance_marble_dice_used"], Double: v["attendance_marble_double"], DoubleChain: v["attendance_marble_double_chain"],
Reward4: v["attendance_marble_reward_4"], Reward8: v["attendance_marble_reward_8"], Reward12: v["attendance_marble_reward_12"], CalendarDay: v[MarbleDayKey], Clears: v["attendance_marble_clears"], Serial: v["attendance_marble_serial"]}
if s.CalendarDay != MarbleDay(now) {
s.CalendarDay, s.Clears, s.DiceUsed, s.Double, s.DoubleChain = MarbleDay(now), 0, 0, 0, 0
}
s.Turns = 2
for _, r := range c.ClearRewards {
if s.Clears >= r[0] {
s.Turns += r[1]
}
}
if s.Position < 0 || s.Position >= MarbleBoardSize || s.Round < 0 || s.Round > 0xffffffff || s.Clears < 0 || s.Clears > 12 || s.DiceUsed < 0 || s.DiceUsed > 8 || s.Double < 0 || s.Double > 1 || s.DoubleChain < 0 || s.DoubleChain > 5 || s.Serial < 0 || s.Reward4 < 0 || s.Reward4 > 1 || s.Reward8 < 0 || s.Reward8 > 1 || s.Reward12 < 0 || s.Reward12 > 1 {
return MarbleState{}, ErrMarbleState
}
// Earlier builds allowed three unearned turns; preserve them as spent, never negative on the wire.
if s.Turns < s.DiceUsed {
s.Turns = s.DiceUsed
}
return s, nil
}
func writeMarbleState(character *dnfrepo.CharacterRecord, s MarbleState) {
if character.Stats == nil {
character.Stats = map[string]int64{}
}
v := character.Stats
for k, value := range map[string]int64{
"position": s.Position, "round": s.Round, "dice_used": s.DiceUsed, "double": s.Double, "double_chain": s.DoubleChain,
"reward_4": s.Reward4, "reward_8": s.Reward8, "reward_12": s.Reward12, "calendar_day": s.CalendarDay, "clears": s.Clears, "serial": s.Serial,
} {
v["attendance_marble_"+k] = value
}
}
func RecordMarbleClear(character *dnfrepo.CharacterRecord, c *MarbleCatalog, now time.Time) error {
if c == nil {
return nil
}
s, err := ReadMarbleState(*character, c, now)
if err != nil {
return err
}
if s.Clears < 12 {
s.Clears++
}
writeMarbleState(character, s)
return nil
}
type MarbleCommand struct {
AccountID, CharacterID string
Now time.Time
Dice *[2]byte
CloseDouble bool
Deliver func(dnfrepo.MailboxRepository, AttendancePVFItemReward, string) (string, error)
}
func SettleMarble(ctx context.Context, repos dnfrepo.Group, c *MarbleCatalog, cmd MarbleCommand) (MarbleState, error) {
if c == nil || repos.MailboxAssets == nil || cmd.AccountID == "" || cmd.CharacterID == "" || cmd.Now.IsZero() {
return MarbleState{}, ErrOwnerUnavailable
}
var result MarbleState
err := repos.MailboxAssets.WithinMailboxAssets(ctx, cmd.CharacterID, cmd.CharacterID, func(_ dnfrepo.AccountRepository, _ dnfrepo.AccountInventoryRepository, characters dnfrepo.CharacterRepository, _ dnfrepo.InventoryRepository, mailboxes dnfrepo.MailboxRepository) error {
character, found, err := characters.Load(ctx, cmd.CharacterID)
if err != nil {
return err
}
if !found || character.CharacterID != cmd.CharacterID || character.AccountID != cmd.AccountID {
return ErrMarbleState
}
s, err := ReadMarbleState(character, c, cmd.Now)
if err != nil {
return err
}
writeMarbleState(&character, s)
if cmd.CloseDouble {
s.Double, s.DoubleChain = 0, 0
}
if cmd.Dice != nil {
if s.Serial == math.MaxInt64 || s.Round == math.MaxUint32 {
return ErrMarbleState
}
dice := *cmd.Dice
if dice[0] < 1 || dice[0] > 6 || dice[1] < 1 || dice[1] > 6 || cmd.Deliver == nil {
return ErrMarbleState
}
if s.Double != 0 {
s.DoubleChain++
} else {
if s.Remaining() <= 0 {
return ErrMarbleSpent
}
s.DiceUsed++
s.DoubleChain = 0
}
s.Serial++
move := s.Position + int64(dice[0]) + int64(dice[1])
s.Round += move / MarbleBoardSize
s.Position = move % MarbleBoardSize
slot := c.Slots[s.Position]
if slot.Type == 3 {
s.Position = int64(slot.Goto)
slot = c.Slots[s.Position]
}
deliver := func(id, count int64, key string) error {
mail, err := cmd.Deliver(mailboxes, AttendancePVFItemReward{ItemID: id, Count: count}, "attendance_marble:"+key)
if err != nil {
return err
}
if mail == "" {
return ErrMarbleState
}
s.MailIDs = append(s.MailIDs, mail)
return nil
}
if slot.Type == 1 || slot.Type == 5 {
if slot.Type == 5 {
gold := character.Stats["gold"]
if gold < slot.Gold {
return ErrMarbleGold
}
character.Stats["gold"] = gold - slot.Gold
s.GoldChanged, s.GoldAfter = true, gold-slot.Gold
}
if err := deliver(slot.ItemID, slot.ItemCount, fmt.Sprintf("turn:%d", s.Serial)); err != nil {
return err
}
}
if slot.Type == 7 {
key := fmt.Sprintf("attendance_marble_buff_%d", slot.Buff)
character.Stats[key] = min(character.Stats[key]+1, int64(10000))
s.BuffIndex = slot.Buff
}
for _, stage := range c.StageRewards {
var claimed *int64
switch stage.Round {
case 4:
claimed = &s.Reward4
case 8:
claimed = &s.Reward8
case 12:
claimed = &s.Reward12
}
if claimed == nil {
return ErrMarbleState
}
if s.Round >= int64(stage.Round) && *claimed == 0 {
if err := deliver(stage.ItemID, stage.Count, fmt.Sprintf("lap:%d", stage.Round)); err != nil {
return err
}
*claimed = 1
}
}
s.Double = 0
if dice[0] == dice[1] && s.DoubleChain < 5 {
s.Double = 1
}
}
writeMarbleState(&character, s)
if err := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); err != nil {
return err
}
for i := 29; i <= 32; i++ {
s.PendingBuffs[i-29] = character.Stats[fmt.Sprintf("attendance_marble_buff_%d", i)]
}
result = s
return nil
})
return result, err
}
// VariousBuff 29..32 use dungeon_one_match (1, 0): reserve the pending
// effects for one accepted run; its runtime retains them across room changes.
func ConsumeMarbleDungeonBuffs(ctx context.Context, repos dnfrepo.Group, accountID, characterID string) (byte, error) {
if repos.CharacterAssets == nil || accountID == "" || characterID == "" {
return 0, ErrOwnerUnavailable
}
var mask byte
err := repos.CharacterAssets.WithinCharacterAssets(ctx, characterID, func(chars dnfrepo.CharacterRepository, _ dnfrepo.InventoryRepository, _ dnfrepo.EquipmentRepository) error {
ch, found, err := chars.Load(ctx, characterID)
if err != nil {
return err
}
if !found || ch.CharacterID != characterID || ch.AccountID != accountID {
return ErrMarbleState
}
for i := 29; i <= 32; i++ {
key := fmt.Sprintf("attendance_marble_buff_%d", i)
if ch.Stats[key] > 0 {
mask |= 1 << (i - 29)
ch.Stats[key]--
}
}
if mask == 0 {
return nil
}
return dnfrepo.SaveCharacterFields(ctx, chars, ch, dnfrepo.CharacterFieldStats)
})
if err != nil {
return 0, err
}
return mask, nil
}
@@ -0,0 +1,228 @@
package onlineevent
import (
"context"
"errors"
"reflect"
"sync"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/repository/memory"
)
func marbleTestCatalog() *MarbleCatalog {
c := &MarbleCatalog{ClearRewards: [3][2]int64{{4, 2}, {8, 2}, {12, 2}}, PostalDays: 15,
StageRewards: []MarbleStageReward{{4, 104, 1}, {8, 108, 1}, {12, 112, 1}}}
for i := range c.Slots {
c.Slots[i] = MarbleSlot{Index: i, Type: 1, ItemID: 100, ItemCount: 1}
}
return c
}
func TestMarbleLoginClearsResetAndLegacyProgress(t *testing.T) {
c := marbleTestCatalog()
now := time.Date(2026, 9, 12, 5, 59, 59, 0, ChinaCalendar)
character := dnfrepo.CharacterRecord{}
for i := 0; i <= 15; i++ {
if i > 0 {
if err := RecordMarbleClear(&character, c, now); err != nil {
t.Fatal(err)
}
}
s, err := ReadMarbleState(character, c, now)
if err != nil || s.Turns != 2+int64(min(i, 12)/4)*2 || s.Clears != int64(min(i, 12)) || s.CalendarDay != 20260911 {
t.Fatalf("clear %d: %+v %v", i, s, err)
}
}
character.Stats["attendance_marble_dice_used"] = 8
character.Stats["attendance_marble_position"] = 27
character.Stats["attendance_marble_round"] = 4
character.Stats["attendance_marble_reward_4"] = 1
character.Stats["attendance_marble_buff_30"] = 1
if err := RecordMarbleClear(&character, c, now.Add(time.Second)); err != nil {
t.Fatal(err)
}
s, err := ReadMarbleState(character, c, now.Add(time.Second))
if err != nil || s.Remaining() != 2 || s.Clears != 1 || s.Position != 27 || s.Round != 4 || s.Reward4 != 1 || character.Stats["attendance_marble_buff_30"] != 1 {
t.Fatalf("06:00 reset: %+v %v", s, err)
}
character.Stats["attendance_marble_dice_used"] = 3
s, err = ReadMarbleState(character, c, now.Add(time.Second))
if err != nil || s.Remaining() != 0 {
t.Fatalf("legacy spent: %+v %v", s, err)
}
}
func TestMarbleOneDungeonBuffsAndRarity(t *testing.T) {
ctx := context.Background()
repos := memory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "1", AccountID: "a", Stats: map[string]int64{"attendance_marble_buff_29": 2, "attendance_marble_buff_30": 1, "attendance_marble_buff_32": 1}}); err != nil {
t.Fatal(err)
}
if _, err := ConsumeMarbleDungeonBuffs(ctx, repos, "other", "1"); !errors.Is(err, ErrMarbleState) {
t.Fatal("foreign account consumed buffs")
}
mask, err := ConsumeMarbleDungeonBuffs(ctx, repos, "a", "1")
if err != nil || mask != 11 {
t.Fatalf("first run buffs %d %v", mask, err)
}
mask, err = ConsumeMarbleDungeonBuffs(ctx, repos, "a", "1")
if err != nil || mask != 1 {
t.Fatalf("second run reused buffs %d %v", mask, err)
}
mask, err = ConsumeMarbleDungeonBuffs(ctx, repos, "a", "1")
if err != nil || mask != 0 {
t.Fatalf("spent buff used again %d %v", mask, err)
}
base := [7]int64{700000, 964900, 990000, 1000000, 1000001, 1000002, 1000003}
got := MarbleEquipmentRarityThresholds(base)
if got != [7]int64{688000, 952900, 978000, 1000000, 1000001, 1000002, 1000003} {
t.Fatalf("unique thresholds %v", got)
}
}
func TestMarbleTransactionalRewardsDoublesAndFailures(t *testing.T) {
ctx := context.Background()
repos := memory.NewMemoryGroup()
c := marbleTestCatalog()
now := time.Date(2026, 9, 12, 12, 0, 0, 0, ChinaCalendar)
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "1", AccountID: "a", Stats: map[string]int64{"gold": 10000}}); err != nil {
t.Fatal(err)
}
cmd := MarbleCommand{AccountID: "a", CharacterID: "1", Now: now}
failMail := false
cmd.Deliver = func(m dnfrepo.MailboxRepository, r AttendancePVFItemReward, key string) (string, error) {
id, err := dnfrepo.AppendSystemMail(ctx, m, dnfrepo.SystemMailDelivery{RecipientCharacterID: "1", Source: "test", CompletionKey: key, CreatedAt: now, Attachments: []dnfrepo.MailAttachment{{ItemID: r.ItemID, Count: r.Count}}})
if failMail {
return "", errors.New("mail transaction failed after write")
}
return id, err
}
settle := func() MarbleState {
t.Helper()
s, err := SettleMarble(ctx, repos, c, cmd)
if err != nil {
t.Fatal(err)
}
return s
}
s := settle()
if s.Remaining() != 2 {
t.Fatalf("fresh %+v", s)
}
cmd.Dice = &[2]byte{1, 1}
for i := 0; i < 6; i++ {
s = settle()
if s.DiceUsed != 1 || s.Position != int64((i+1)*2) || s.DoubleChain != int64(i) || (s.Double == 1) != (i < 5) {
t.Fatalf("double %d: %+v", i, s)
}
}
cmd.Dice = &[2]byte{1, 2}
s = settle()
if s.Remaining() != 0 || s.Double != 0 {
t.Fatalf("last paid %+v", s)
}
before, _, _ := repos.Character.Load(ctx, "1")
if _, err := SettleMarble(ctx, repos, c, cmd); !errors.Is(err, ErrMarbleSpent) {
t.Fatalf("exhausted %v", err)
}
after, _, _ := repos.Character.Load(ctx, "1")
if !reflect.DeepEqual(before, after) {
t.Fatal("exhausted roll changed state")
}
cmd.AccountID = "other"
if _, err := SettleMarble(ctx, repos, c, cmd); !errors.Is(err, ErrMarbleState) {
t.Fatalf("ownership %v", err)
}
cmd.AccountID = "a"
cmd.Now = now.Add(24 * time.Hour)
cmd.Dice = &[2]byte{2, 2}
s = settle()
cmd.Dice = nil
cmd.CloseDouble = true
s = settle()
if s.Double != 0 || s.DoubleChain != 0 || s.Remaining() != 1 {
t.Fatalf("close %+v", s)
}
cmd.CloseDouble = false
// A teleport reaches the paid slot; all gold, mail and progress changes share the transaction.
c.Slots[22] = MarbleSlot{Index: 22, Type: 3, Goto: 14}
c.Slots[14] = MarbleSlot{Index: 14, Type: 5, Gold: 10000, ItemID: 105, ItemCount: 5}
cmd.Dice = &[2]byte{1, 2}
before, _, _ = repos.Character.Load(ctx, "1")
mailBefore, _, _ := repos.Mailbox.Load(ctx, "1")
failMail = true
if _, err := SettleMarble(ctx, repos, c, cmd); err == nil {
t.Fatal("mail failure accepted")
}
after, _, _ = repos.Character.Load(ctx, "1")
mailAfter, _, _ := repos.Mailbox.Load(ctx, "1")
if !reflect.DeepEqual(before, after) || !reflect.DeepEqual(mailBefore, mailAfter) {
t.Fatal("mail failure did not roll back assets")
}
failMail = false
s = settle()
if s.Position != 14 || s.GoldAfter != 0 || !s.GoldChanged || len(s.MailIDs) != 1 {
t.Fatalf("teleport paid slot %+v", s)
}
// Cross each cumulative threshold once; reopening and subsequent rolls do not repeat it.
for _, lap := range []int64{4, 8, 12} {
ch, _, _ := repos.Character.Load(ctx, "1")
ch.Stats["attendance_marble_position"] = 27
ch.Stats["attendance_marble_round"] = lap - 1
ch.Stats["attendance_marble_dice_used"] = 0
if err := repos.Character.Save(ctx, ch); err != nil {
t.Fatal(err)
}
s = settle()
if s.Round != lap || s.Position != 2 || len(s.MailIDs) != 2 {
t.Fatalf("lap %+v", s)
}
s = settle()
if len(s.MailIDs) != 1 {
t.Fatal("cumulative reward repeated")
}
}
ch, _, _ := repos.Character.Load(ctx, "1")
ch.Stats["attendance_marble_position"] = 11
ch.Stats["attendance_marble_dice_used"] = 0
if err := repos.Character.Save(ctx, ch); err != nil {
t.Fatal(err)
}
if _, err := SettleMarble(ctx, repos, c, cmd); !errors.Is(err, ErrMarbleGold) {
t.Fatalf("gold check %v", err)
}
}
func TestMarbleConcurrentRollsCannotOverspend(t *testing.T) {
ctx := context.Background()
repos := memory.NewMemoryGroup()
c := marbleTestCatalog()
for i := range c.Slots {
c.Slots[i] = MarbleSlot{Index: i, Type: 7, Buff: 30}
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "1", AccountID: "a"}); err != nil {
t.Fatal(err)
}
cmd := MarbleCommand{AccountID: "a", CharacterID: "1", Now: time.Now(), Dice: &[2]byte{1, 2}, Deliver: func(dnfrepo.MailboxRepository, AttendancePVFItemReward, string) (string, error) {
t.Error("buff unexpectedly mailed")
return "", ErrMarbleState
}}
var wg sync.WaitGroup
for range 10 {
wg.Go(func() {
_, err := SettleMarble(ctx, repos, c, cmd)
if err != nil && !errors.Is(err, ErrMarbleSpent) {
t.Error(err)
}
})
}
wg.Wait()
ch, _, _ := repos.Character.Load(ctx, "1")
s, err := ReadMarbleState(ch, c, cmd.Now)
if err != nil || s.DiceUsed != 2 || s.Position != 6 || ch.Stats["attendance_marble_buff_30"] != 2 {
t.Fatalf("concurrent %+v %v", s, err)
}
}
@@ -0,0 +1,269 @@
package onlineevent
import (
"context"
"encoding/json"
"fmt"
"sort"
"strconv"
"time"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const MonthlyPVFPath = "event/chn_event/chn_dailyattendanceanytimeevent.evt"
const MonthlyMetadataKey = "event_monthly_attendance_v1"
const MonthlyRequiredClears = 2
type MonthlyCatalog struct {
Daily [28]AttendancePVFItemReward
Bonus [3]AttendancePVFItemReward
Thresholds [3]uint32
}
func LoadMonthlyCatalog(ctx context.Context, source dnfpvf.Source) (*MonthlyCatalog, error) {
text, err := source.ReadText(MonthlyPVFPath)
if err != nil {
return nil, err
}
doc, err := dnfpvf.Parse(MonthlyPVFPath, text)
if err != nil {
return nil, err
}
catalog := &MonthlyCatalog{}
all := make([]AttendancePVFItemReward, 0, 31)
for _, spec := range []struct {
name string
count int
}{
{"daily attendance reward", 28}, {"accumulate attendance reward", 3},
} {
values, err := attendanceIntegerSection(doc, spec.name, spec.count*3)
if err != nil {
return nil, err
}
for i := 0; i < spec.count; i++ {
key, id, count := values[i*3], values[i*3+1], values[i*3+2]
if id <= 0 || count <= 0 {
return nil, fmt.Errorf("monthly reward %s[%d] is invalid", spec.name, i)
}
reward := AttendancePVFItemReward{ItemID: id, Count: count}
if spec.count == 28 {
if key != int64(i) {
return nil, fmt.Errorf("monthly daily index=%d want=%d", key, i)
}
catalog.Daily[i] = reward
} else {
if key <= 0 || key > 28 || (i > 0 && uint32(key) <= catalog.Thresholds[i-1]) {
return nil, fmt.Errorf("monthly bonus threshold=%d", key)
}
catalog.Thresholds[i] = uint32(key)
catalog.Bonus[i] = reward
}
all = append(all, reward)
}
}
paths, err := resolveAttendanceRewardDefinitions(ctx, source, all)
if err != nil {
return nil, err
}
attachAttendanceDefinitionPaths(catalog.Daily[:], paths)
attachAttendanceDefinitionPaths(catalog.Bonus[:], paths)
return catalog, nil
}
type monthlyCycle struct {
Clears [31]uint8 `json:"clears"`
Mailed uint32 `json:"mailed"`
Bonus uint8 `json:"bonus"`
}
type monthlyState map[string]*monthlyCycle
func monthlyDate(now time.Time) (string, int) {
date := now.In(ChinaCalendar).Add(-6 * time.Hour)
return date.Format("2006-01"), date.Day() - 1
}
func loadMonthlyState(account dnfrepo.AccountRecord) (monthlyState, error) {
state := monthlyState{}
if raw := account.Metadata[MonthlyMetadataKey]; raw != "" {
if err := json.Unmarshal([]byte(raw), &state); err != nil || state == nil {
return nil, fmt.Errorf("monthly attendance state is invalid")
}
}
for month, cycle := range state {
date, err := time.Parse("2006-01", month)
if err != nil || cycle == nil || cycle.Bonus > 7 || cycle.Mailed>>31 != 0 {
return nil, fmt.Errorf("monthly attendance cycle is invalid")
}
days := time.Date(date.Year(), date.Month()+1, 0, 0, 0, 0, 0, time.UTC).Day()
count := 0
for day, clears := range cycle.Clears {
if clears > MonthlyRequiredClears || (day >= days && clears != 0) || (cycle.Mailed&(1<<day) != 0 && clears != MonthlyRequiredClears) {
return nil, fmt.Errorf("monthly attendance day is invalid")
}
if clears == MonthlyRequiredClears {
count++
}
}
if count > 28 {
return nil, fmt.Errorf("monthly attendance total exceeds 28")
}
}
return state, nil
}
func monthlyDays(cycle *monthlyCycle) uint32 {
var count uint32
if cycle != nil {
for _, clears := range cycle.Clears {
if clears == MonthlyRequiredClears {
count++
}
}
}
return count
}
// RecordMonthlyClear runs inside the existing idempotent dungeon settlement.
// Delivery is retried from this durable state after a disconnect or restart.
func RecordMonthlyClear(account *dnfrepo.AccountRecord, now time.Time) error {
state, err := loadMonthlyState(*account)
if err != nil {
return err
}
month, day := monthlyDate(now)
cycle := state[month]
if cycle == nil {
cycle = &monthlyCycle{}
state[month] = cycle
}
if cycle.Clears[day] >= MonthlyRequiredClears || monthlyDays(cycle) >= 28 {
return nil
}
cycle.Clears[day]++
raw, err := json.Marshal(state)
if err != nil {
return err
}
if account.Metadata == nil {
account.Metadata = map[string]string{}
}
account.Metadata[MonthlyMetadataKey] = string(raw)
return nil
}
type MonthlySnapshot struct {
Days uint32
Bonus [3]uint32
Clears uint32
MailIDs []string
}
type MonthlyCommand struct {
AccountID, CharacterID string
Now time.Time
ClaimBonus bool
Deliver func(dnfrepo.MailboxRepository, AttendancePVFItemReward, string) (string, error)
}
func SettleMonthlyAttendance(ctx context.Context, repos dnfrepo.Group, catalog *MonthlyCatalog, cmd MonthlyCommand) (MonthlySnapshot, error) {
if repos.MailboxAssets == nil || catalog == nil || cmd.AccountID == "" || cmd.CharacterID == "" || cmd.Now.IsZero() || cmd.Deliver == nil {
return MonthlySnapshot{}, ErrOwnerUnavailable
}
var result MonthlySnapshot
err := repos.MailboxAssets.WithinMailboxAssets(ctx, cmd.CharacterID, cmd.CharacterID, func(
accounts dnfrepo.AccountRepository, _ dnfrepo.AccountInventoryRepository, characters dnfrepo.CharacterRepository, _ dnfrepo.InventoryRepository, mailboxes dnfrepo.MailboxRepository,
) error {
character, found, err := characters.Load(ctx, cmd.CharacterID)
if err != nil {
return err
}
if !found || character.AccountID != cmd.AccountID {
return fmt.Errorf("monthly attendance character ownership mismatch")
}
account, found, err := accounts.Load(ctx, cmd.AccountID)
if err != nil {
return err
}
if !found {
return fmt.Errorf("monthly attendance account missing")
}
state, err := loadMonthlyState(account)
if err != nil {
return err
}
months := make([]string, 0, len(state))
for month := range state {
months = append(months, month)
}
sort.Strings(months)
changed := false
for _, month := range months {
cycle := state[month]
rewardIndex := 0
for day, clears := range cycle.Clears {
if clears != MonthlyRequiredClears {
continue
}
if cycle.Mailed&(1<<day) == 0 {
key := MonthlyMetadataKey + ":" + month + ":day:" + strconv.Itoa(day+1)
mailID, err := cmd.Deliver(mailboxes, catalog.Daily[rewardIndex], key)
if err != nil {
return err
}
if mailID == "" {
return fmt.Errorf("monthly daily reward mail missing")
}
cycle.Mailed |= 1 << day
changed = true
result.MailIDs = append(result.MailIDs, mailID)
}
rewardIndex++
}
}
month, day := monthlyDate(cmd.Now)
if cycle := state[month]; cycle != nil {
result.Days = monthlyDays(cycle)
result.Clears = uint32(cycle.Clears[day])
for i, threshold := range catalog.Thresholds {
if cycle.Bonus&(1<<i) != 0 {
result.Bonus[i] = 2
continue
}
if result.Days < threshold {
continue
}
result.Bonus[i] = 1
if cmd.ClaimBonus {
key := MonthlyMetadataKey + ":" + month + ":bonus:" + strconv.Itoa(i)
mailID, err := cmd.Deliver(mailboxes, catalog.Bonus[i], key)
if err != nil {
return err
}
if mailID == "" {
return fmt.Errorf("monthly bonus reward mail missing")
}
cycle.Bonus |= 1 << i
changed = true
result.Bonus[i] = 2
result.MailIDs = append(result.MailIDs, mailID)
}
}
}
if !changed {
return nil
}
raw, err := json.Marshal(state)
if err != nil {
return err
}
return dnfrepo.SaveAccountMetadataEntry(ctx, accounts, account, MonthlyMetadataKey, string(raw), cmd.Now)
})
if err != nil {
return MonthlySnapshot{}, err
}
return result, nil
}
@@ -0,0 +1,126 @@
package onlineevent
import (
"context"
"errors"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestMonthlyAttendanceDeliveryRetryAccountSharingAndBoundary(t *testing.T) {
ctx := context.Background()
repos := memory.NewMemoryGroup()
account := dnfrepo.AccountRecord{AccountID: "account"}
catalog := &MonthlyCatalog{Thresholds: [3]uint32{7, 14, 21}}
for i := range catalog.Daily {
catalog.Daily[i] = AttendancePVFItemReward{ItemID: int64(100 + i), Count: 1}
}
for i := range catalog.Bonus {
catalog.Bonus[i] = AttendancePVFItemReward{ItemID: int64(200 + i), Count: 1}
}
for _, id := range []string{"1", "2"} {
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: id, AccountID: "account"}); err != nil {
t.Fatal(err)
}
}
now := time.Date(2026, 9, 1, 6, 0, 0, 0, ChinaCalendar)
for day := 0; day < 7; day++ {
for clear := 0; clear < 5; clear++ {
if err := RecordMonthlyClear(&account, now.AddDate(0, 0, day)); err != nil {
t.Fatal(err)
}
}
}
if err := repos.Account.Save(ctx, account); err != nil {
t.Fatal(err)
}
cmd := MonthlyCommand{AccountID: "account", CharacterID: "1", Now: now.AddDate(0, 0, 6), ClaimBonus: true}
cmd.Deliver = func(mailboxes dnfrepo.MailboxRepository, reward AttendancePVFItemReward, key string) (string, error) {
if reward.ItemID == 101 {
return "", errors.New("delivery failed")
}
return dnfrepo.AppendSystemMail(ctx, mailboxes, dnfrepo.SystemMailDelivery{RecipientCharacterID: cmd.CharacterID, Source: "test", CompletionKey: key, Attachments: []dnfrepo.MailAttachment{{ItemID: reward.ItemID, Count: reward.Count}}})
}
if _, err := SettleMonthlyAttendance(ctx, repos, catalog, cmd); err == nil {
t.Fatal("failed delivery committed")
}
mailbox, _, _ := repos.Mailbox.Load(ctx, "1")
if len(mailbox.Mails) != 0 {
t.Fatal("failed transaction leaked mail")
}
cmd.Deliver = func(mailboxes dnfrepo.MailboxRepository, reward AttendancePVFItemReward, key string) (string, error) {
return dnfrepo.AppendSystemMail(ctx, mailboxes, dnfrepo.SystemMailDelivery{RecipientCharacterID: cmd.CharacterID, Source: "test", CompletionKey: key, Attachments: []dnfrepo.MailAttachment{{ItemID: reward.ItemID, Count: reward.Count}}})
}
state, err := SettleMonthlyAttendance(ctx, repos, catalog, cmd)
if err != nil || state.Days != 7 || state.Clears != 2 || state.Bonus != [3]uint32{2, 0, 0} || len(state.MailIDs) != 8 {
t.Fatalf("state=%+v err=%v", state, err)
}
cmd.CharacterID = "2"
state, err = SettleMonthlyAttendance(ctx, repos, catalog, cmd)
if err != nil || len(state.MailIDs) != 0 || state.Days != 7 {
t.Fatalf("alternate character replay=%+v err=%v", state, err)
}
cmd.AccountID = "foreign"
if _, err := SettleMonthlyAttendance(ctx, repos, catalog, cmd); err == nil {
t.Fatal("foreign account accepted")
}
cmd.AccountID = "account"
cmd.ClaimBonus = false
for day := 7; day < 30; day++ {
cmd.Now = now.AddDate(0, 0, day)
account, _, err = repos.Account.Load(ctx, "account")
if err != nil {
t.Fatal(err)
}
for clear := 1; clear <= 3; clear++ {
if err := RecordMonthlyClear(&account, cmd.Now); err != nil {
t.Fatal(err)
}
if err := repos.Account.Save(ctx, account); err != nil {
t.Fatal(err)
}
state, err = SettleMonthlyAttendance(ctx, repos, catalog, cmd)
wantDays := min(day, 28)
if clear >= 2 {
wantDays = min(day+1, 28)
}
wantMail := 0
if day < 28 && clear == 2 {
wantMail = 1
}
if err != nil || state.Days != uint32(wantDays) || len(state.MailIDs) != wantMail {
t.Fatalf("day=%d clear=%d state=%+v err=%v", day, clear, state, err)
}
// Keep the mail receipt written by the settlement before the next clear.
account, _, err = repos.Account.Load(ctx, "account")
if err != nil {
t.Fatal(err)
}
}
}
if state.Bonus != [3]uint32{2, 1, 1} {
t.Fatalf("unclaimed accumulated rewards=%v", state.Bonus)
}
cmd.ClaimBonus = true
state, err = SettleMonthlyAttendance(ctx, repos, catalog, cmd)
if err != nil || state.Bonus != [3]uint32{2, 2, 2} || len(state.MailIDs) != 2 {
t.Fatalf("remaining accumulated rewards=%+v err=%v", state, err)
}
state, err = SettleMonthlyAttendance(ctx, repos, catalog, cmd)
if err != nil || len(state.MailIDs) != 0 {
t.Fatalf("repeat claim=%+v err=%v", state, err)
}
cmd.Now = time.Date(2026, 10, 1, 5, 59, 59, 0, ChinaCalendar)
state, err = SettleMonthlyAttendance(ctx, repos, catalog, cmd)
if err != nil || state.Days != 28 {
t.Fatalf("before monthly reset=%+v err=%v", state, err)
}
cmd.Now = cmd.Now.Add(time.Second)
state, err = SettleMonthlyAttendance(ctx, repos, catalog, cmd)
if err != nil || state.Days != 0 || state.Bonus != [3]uint32{} {
t.Fatalf("after monthly reset=%+v err=%v", state, err)
}
}
@@ -0,0 +1,467 @@
package onlineevent
import (
"context"
"fmt"
"math"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/carrygold"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// Owner reuses CharacterSettlement because it is the existing transaction
// that locks account metadata, the selected character wallet, and inventory
// together. That makes the claim receipt and its rewards one atomic commit.
type Owner struct {
settlements dnfrepo.CharacterSettlementUnitOfWork
}
func NewOwner(repositories dnfrepo.Group) (*Owner, error) {
if repositories.CharacterSettlement == nil {
return nil, ErrOwnerUnavailable
}
return &Owner{settlements: repositories.CharacterSettlement}, nil
}
// Status returns the authoritative progress used by a future proved login/UI
// projection. Reading status also persists a service-day reset, so a stale
// prior-day claim ledger cannot survive indefinitely when no new interval has
// yet been credited.
func (o *Owner) Status(ctx context.Context, command StatusCommand) (Snapshot, error) {
if o == nil || o.settlements == nil {
return Snapshot{}, ErrOwnerUnavailable
}
accountID := strings.TrimSpace(command.AccountID)
characterID := strings.TrimSpace(command.CharacterID)
if accountID == "" || characterID == "" {
return Snapshot{}, ErrObservationInvalid
}
if err := command.Definition.Validate(); err != nil {
return Snapshot{}, err
}
observedAt := command.ObservedAt
if observedAt.IsZero() {
observedAt = time.Now()
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return Snapshot{}, err
}
var snapshot Snapshot
err := o.settlements.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
account, _, err := loadAccountAndCharacter(ctx, tx, accountID, characterID)
if err != nil {
return err
}
state, err := parseState(account.Metadata[metadataKey(command.Definition.ID)], command.Definition)
if err != nil {
return err
}
date, _ := calendarDay(command.Definition.calendar(), command.Definition.boundary(), observedAt)
if state.CalendarDate != "" && date < state.CalendarDate {
return fmt.Errorf(
"%w: persisted=%s observed=%s",
ErrObservationStale,
state.CalendarDate,
date,
)
}
if state.CalendarDate != date {
state.resetTo(date)
if err := saveState(ctx, tx.Account, account, state, observedAt); err != nil {
return err
}
}
snapshot = state.snapshot()
return nil
})
if err != nil {
return Snapshot{}, err
}
return snapshot.clone(), nil
}
// Observe credits a server-observed presence interval. It stores the union of
// intervals for the current local day, so retries, overlapping local sessions,
// and reordered ticks cannot increase progress twice. Partial seconds are
// conservatively discarded instead of rounded up.
func (o *Owner) Observe(ctx context.Context, command ObserveCommand) (ObserveResult, error) {
if o == nil || o.settlements == nil {
return ObserveResult{}, ErrOwnerUnavailable
}
accountID := strings.TrimSpace(command.AccountID)
characterID := strings.TrimSpace(command.CharacterID)
if accountID == "" || characterID == "" || !command.IntervalFrom.Before(command.IntervalTo) {
return ObserveResult{}, ErrObservationInvalid
}
if err := command.Definition.Validate(); err != nil {
return ObserveResult{}, err
}
from, to, err := activeObservationInterval(command.Definition, command.IntervalFrom, command.IntervalTo)
if err != nil {
return ObserveResult{}, err
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return ObserveResult{}, err
}
var result ObserveResult
err = o.settlements.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
account, character, err := loadAccountAndCharacter(ctx, tx, accountID, characterID)
if err != nil {
return err
}
state, err := parseState(account.Metadata[metadataKey(command.Definition.ID)], command.Definition)
if err != nil {
return err
}
date, dayStart, intervalStart, intervalEnd := dailyInterval(
command.Definition.calendar(),
command.Definition.boundary(),
from,
to,
)
if state.CalendarDate != "" && date < state.CalendarDate {
return fmt.Errorf(
"%w: persisted=%s observed=%s",
ErrObservationStale,
state.CalendarDate,
date,
)
}
changed := false
if state.CalendarDate != date {
state.resetTo(date)
changed = true
}
startSecond, endSecond := intervalSeconds(dayStart, intervalStart, intervalEnd)
credited := state.creditInterval(startSecond, endSecond)
changed = changed || credited > 0
if changed {
if err := saveState(ctx, tx.Account, account, state, to); err != nil {
return err
}
}
_ = character // loadAccountAndCharacter proves account ownership.
result = ObserveResult{
Snapshot: state.snapshot(),
CreditedSeconds: credited,
Changed: changed,
}
return nil
})
if err != nil {
return ObserveResult{}, err
}
result.Snapshot = result.Snapshot.clone()
return result, nil
}
// Claim grants one unlocked stage. The account claim receipt is written only
// after every caller-resolved item and wallet mutation has succeeded inside
// the same CharacterSettlement transaction. Replays return the stored receipt
// and never invoke the allocator again.
func (o *Owner) Claim(ctx context.Context, command ClaimCommand) (ClaimResult, error) {
if o == nil || o.settlements == nil {
return ClaimResult{}, ErrOwnerUnavailable
}
accountID := strings.TrimSpace(command.AccountID)
characterID := strings.TrimSpace(command.CharacterID)
stageID := strings.TrimSpace(command.StageID)
if accountID == "" || characterID == "" || stageID == "" {
return ClaimResult{}, ErrRewardInvalid
}
if err := command.Definition.Validate(); err != nil {
return ClaimResult{}, err
}
stage, found := command.Definition.stage(stageID)
if !found {
return ClaimResult{}, fmt.Errorf("%w: event=%s stage=%s", ErrStageNotFound, command.Definition.ID, stageID)
}
if len(stage.Items) > 0 && command.Allocate == nil {
return ClaimResult{}, ErrAllocatorRequired
}
claimedAt := command.ClaimedAt
if claimedAt.IsZero() {
claimedAt = time.Now()
}
if !command.Definition.activeAt(claimedAt) {
return ClaimResult{}, fmt.Errorf("%w: event=%s", ErrEventInactive, command.Definition.ID)
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return ClaimResult{}, err
}
var result ClaimResult
err := o.settlements.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
account, character, err := loadAccountAndCharacter(ctx, tx, accountID, characterID)
if err != nil {
return err
}
state, err := parseState(account.Metadata[metadataKey(command.Definition.ID)], command.Definition)
if err != nil {
return err
}
date, _ := calendarDay(command.Definition.calendar(), command.Definition.boundary(), claimedAt)
if state.CalendarDate != "" && date < state.CalendarDate {
return fmt.Errorf(
"%w: persisted=%s claimed=%s",
ErrObservationStale,
state.CalendarDate,
date,
)
}
if state.CalendarDate != date {
state.resetTo(date)
}
if receipt, replayed := state.Claims[stageID]; replayed {
result = resultFromReceipt(command.Definition.ID, state, receipt, true)
return nil
}
if state.OnlineSeconds < stage.RequiredSeconds {
return fmt.Errorf(
"%w: event=%s stage=%s have=%d need=%d",
ErrStageLocked,
command.Definition.ID,
stageID,
state.OnlineSeconds,
stage.RequiredSeconds,
)
}
character = dnfrepo.CloneCharacter(character)
if character.Stats == nil {
character.Stats = make(map[string]int64)
}
goldBefore := character.Stats["gold"]
if goldBefore < 0 || stage.Gold > math.MaxInt64-goldBefore {
return ErrGoldOverflow
}
// The stage reward is consumed by the claim receipt, so the carry cap
// discards the excess instead of failing a claim that also grants items.
goldAfter, _ := carrygold.Credit(character, stage.Gold)
var inventory dnfrepo.InventoryRecord
if len(stage.Items) > 0 {
var inventoryFound bool
inventory, inventoryFound, err = tx.Inventory.Load(ctx, characterID)
if err != nil {
return err
}
if !inventoryFound || strings.TrimSpace(inventory.CharacterID) != characterID {
return fmt.Errorf("%w: character=%s", ErrInventoryNotFound, characterID)
}
inventory = dnfrepo.CloneInventory(inventory)
if inventory.Slots == nil {
inventory.Slots = make(map[string]dnfrepo.ItemStack)
}
}
items := make([]CommittedItem, 0, len(stage.Items))
for _, reward := range stage.Items {
committed, err := command.Allocate(&inventory, reward)
if err != nil {
return err
}
if err := validateCommittedItem(committed, reward); err != nil {
return err
}
committed.RawEntry = append([]byte(nil), committed.RawEntry...)
items = append(items, committed)
}
character.Stats["gold"] = goldAfter
character.UpdatedAt = claimedAt.UTC()
if err := dnfrepo.SaveCharacterFields(ctx, tx.Character, character, dnfrepo.CharacterFieldStats); err != nil {
return err
}
if len(stage.Items) > 0 {
inventory.UpdatedAt = claimedAt.UTC()
if err := dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots); err != nil {
return err
}
}
receipt := claimReceipt{
StageID: stageID,
CharacterID: characterID,
Items: cloneCommittedItems(items),
GoldBefore: goldBefore,
GoldAfter: goldAfter,
ClaimedAt: claimedAt.UTC(),
}
state.Claims[stageID] = receipt
if err := saveState(ctx, tx.Account, account, state, claimedAt); err != nil {
return err
}
result = resultFromReceipt(command.Definition.ID, state, receipt, false)
return nil
})
if err != nil {
return ClaimResult{}, err
}
result.Items = cloneCommittedItems(result.Items)
result.PostSnapshot = result.PostSnapshot.clone()
return result, nil
}
func loadAccountAndCharacter(
ctx context.Context,
tx dnfrepo.Group,
accountID string,
characterID string,
) (dnfrepo.AccountRecord, dnfrepo.CharacterRecord, error) {
if tx.Account == nil || tx.Character == nil {
return dnfrepo.AccountRecord{}, dnfrepo.CharacterRecord{}, ErrOwnerUnavailable
}
character, found, err := tx.Character.Load(ctx, characterID)
if err != nil {
return dnfrepo.AccountRecord{}, dnfrepo.CharacterRecord{}, err
}
if !found || strings.TrimSpace(character.CharacterID) != characterID {
return dnfrepo.AccountRecord{}, dnfrepo.CharacterRecord{}, fmt.Errorf(
"%w: character=%s",
ErrCharacterNotFound,
characterID,
)
}
if strings.TrimSpace(character.AccountID) != accountID {
return dnfrepo.AccountRecord{}, dnfrepo.CharacterRecord{}, fmt.Errorf(
"%w: account=%s character=%s",
ErrAccountMismatch,
accountID,
characterID,
)
}
account, found, err := tx.Account.Load(ctx, accountID)
if err != nil {
return dnfrepo.AccountRecord{}, dnfrepo.CharacterRecord{}, err
}
if !found || strings.TrimSpace(account.AccountID) != accountID {
return dnfrepo.AccountRecord{}, dnfrepo.CharacterRecord{}, fmt.Errorf(
"%w: account=%s",
ErrAccountNotFound,
accountID,
)
}
return dnfrepo.CloneAccount(account), dnfrepo.CloneCharacter(character), nil
}
func saveState(
ctx context.Context,
accounts dnfrepo.AccountRepository,
account dnfrepo.AccountRecord,
state persistedState,
updatedAt time.Time,
) error {
encoded, err := encodeState(state)
if err != nil {
return err
}
if account.Metadata == nil {
account.Metadata = make(map[string]string)
}
account.Metadata[metadataKey(state.EventID)] = encoded
account.UpdatedAt = updatedAt.UTC()
return accounts.Save(ctx, account)
}
func resultFromReceipt(eventID string, state persistedState, receipt claimReceipt, replayed bool) ClaimResult {
return ClaimResult{
EventID: eventID,
CalendarDate: state.CalendarDate,
StageID: receipt.StageID,
CharacterID: receipt.CharacterID,
Replayed: replayed,
Items: cloneCommittedItems(receipt.Items),
GoldBefore: receipt.GoldBefore,
GoldAfter: receipt.GoldAfter,
ClaimedAt: receipt.ClaimedAt,
PostSnapshot: state.snapshot(),
}
}
func activeObservationInterval(definition Definition, from time.Time, to time.Time) (time.Time, time.Time, error) {
if !definition.ActiveFrom.IsZero() && from.Before(definition.ActiveFrom) {
from = definition.ActiveFrom
}
if !definition.ActiveUntil.IsZero() && to.After(definition.ActiveUntil) {
to = definition.ActiveUntil
}
if !from.Before(to) {
return time.Time{}, time.Time{}, fmt.Errorf("%w: event=%s", ErrEventInactive, definition.ID)
}
return from, to, nil
}
func dailyInterval(
location *time.Location,
boundary DailyBoundary,
from time.Time,
to time.Time,
) (string, time.Time, time.Time, time.Time) {
// Observe owns a half-open [from,to) interval. When to is exactly the
// service-day boundary, its final represented instant still belongs to the
// preceding day. Selecting by to directly would instead reset the ledger
// before those final seconds can be credited or claimed.
selector := to
if from.Before(to) {
selector = to.Add(-time.Nanosecond)
}
date, dayStart := calendarDay(location, boundary, selector)
nextDay := dayStart.AddDate(0, 0, 1)
if from.Before(dayStart) {
from = dayStart
}
if to.After(nextDay) {
to = nextDay
}
return date, dayStart, from, to
}
func calendarDay(location *time.Location, boundary DailyBoundary, at time.Time) (string, time.Time) {
local := at.In(location)
dayStart := time.Date(
local.Year(),
local.Month(),
local.Day(),
boundary.Hour,
boundary.Minute,
boundary.Second,
0,
location,
)
if local.Before(dayStart) {
dayStart = dayStart.AddDate(0, 0, -1)
}
return dayStart.Format(time.DateOnly), dayStart
}
func intervalSeconds(dayStart time.Time, from time.Time, to time.Time) (uint32, uint32) {
fromFloor := from.Truncate(time.Second)
if !fromFloor.Equal(from) {
from = fromFloor.Add(time.Second)
}
to = to.Truncate(time.Second)
if !from.Before(to) {
return 0, 0
}
start := uint64(from.Sub(dayStart) / time.Second)
end := uint64(to.Sub(dayStart) / time.Second)
if start > secondsPerDay {
start = secondsPerDay
}
if end > secondsPerDay {
end = secondsPerDay
}
return uint32(start), uint32(end)
}
@@ -0,0 +1,587 @@
package onlineevent
import (
"context"
"errors"
"fmt"
"reflect"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestOwnerObserveMergesReplayOverlapAndGapWithoutDoubleCredit(t *testing.T) {
ctx := context.Background()
repositories := seededRepositories(t, ctx)
owner := mustOwner(t, repositories)
definition := testDefinition()
location := definition.calendar()
base := time.Date(2026, 8, 3, 10, 0, 0, 0, location)
observe := func(from time.Time, to time.Time) ObserveResult {
t.Helper()
result, err := owner.Observe(ctx, ObserveCommand{
AccountID: "account-1",
CharacterID: "character-1",
Definition: definition,
IntervalFrom: from,
IntervalTo: to,
})
if err != nil {
t.Fatalf("Observe(%s, %s): %v", from, to, err)
}
return result
}
first := observe(base, base.Add(10*time.Second))
if first.CreditedSeconds != 10 || first.Snapshot.OnlineSeconds != 10 || !first.Changed {
t.Fatalf("first=%+v", first)
}
replay := observe(base, base.Add(10*time.Second))
if replay.CreditedSeconds != 0 || replay.Snapshot.OnlineSeconds != 10 || replay.Changed {
t.Fatalf("replay=%+v", replay)
}
overlap := observe(base.Add(5*time.Second), base.Add(15*time.Second))
if overlap.CreditedSeconds != 5 || overlap.Snapshot.OnlineSeconds != 15 {
t.Fatalf("overlap=%+v", overlap)
}
gap := observe(base.Add(20*time.Second), base.Add(25*time.Second))
if gap.CreditedSeconds != 5 || gap.Snapshot.OnlineSeconds != 20 {
t.Fatalf("gap=%+v", gap)
}
account, found, err := repositories.Account.Load(ctx, "account-1")
if err != nil || !found {
t.Fatalf("load account found=%t err=%v", found, err)
}
if account.Metadata["unrelated"] != "preserved" {
t.Fatalf("unrelated metadata=%v", account.Metadata)
}
state, err := parseState(account.Metadata[metadataKey(definition.ID)], definition)
if err != nil {
t.Fatal(err)
}
if state.OnlineSeconds != 20 || !reflect.DeepEqual(state.Intervals, []creditedInterval{
{Start: 4 * 60 * 60, End: 4*60*60 + 15},
{Start: 4*60*60 + 20, End: 4*60*60 + 25},
}) {
t.Fatalf("state=%+v", state)
}
}
func TestOwnerObserveResetsProgressAndClaimsAtDefaultShanghaiSixAM(t *testing.T) {
ctx := context.Background()
repositories := seededRepositories(t, ctx)
owner := mustOwner(t, repositories)
definition := testDefinition()
location := definition.calendar()
dayOne := time.Date(2026, 8, 4, 5, 59, 40, 0, location)
if _, err := owner.Observe(ctx, ObserveCommand{
AccountID: "account-1",
CharacterID: "character-1",
Definition: definition,
IntervalFrom: dayOne,
IntervalTo: dayOne.Add(10 * time.Second),
}); err != nil {
t.Fatal(err)
}
if _, err := owner.Claim(ctx, ClaimCommand{
AccountID: "account-1",
CharacterID: "character-1",
Definition: definition,
StageID: "ten_seconds",
ClaimedAt: dayOne.Add(11 * time.Second),
Allocate: testAllocator(nil),
}); err != nil {
t.Fatal(err)
}
crossMidnight, err := owner.Observe(ctx, ObserveCommand{
AccountID: "account-1",
CharacterID: "character-1",
Definition: definition,
IntervalFrom: dayOne.Add(15 * time.Second),
IntervalTo: dayOne.Add(30 * time.Second),
})
if err != nil {
t.Fatal(err)
}
if crossMidnight.Snapshot.CalendarDate != "2026-08-04" ||
crossMidnight.CreditedSeconds != 10 || crossMidnight.Snapshot.OnlineSeconds != 10 ||
len(crossMidnight.Snapshot.ClaimedStageIDs) != 0 {
t.Fatalf("crossBoundary=%+v", crossMidnight)
}
secondClaim, err := owner.Claim(ctx, ClaimCommand{
AccountID: "account-1",
CharacterID: "character-1",
Definition: definition,
StageID: "ten_seconds",
ClaimedAt: dayOne.Add(31 * time.Second),
Allocate: testAllocator(nil),
})
if err != nil || secondClaim.Replayed {
t.Fatalf("secondClaim=%+v err=%v", secondClaim, err)
}
}
func TestOwnerObserveEndingExactlyAtSixAMCreditsThePriorServiceDay(t *testing.T) {
ctx := context.Background()
repositories := seededRepositories(t, ctx)
owner := mustOwner(t, repositories)
definition := testDefinition()
location := definition.calendar()
boundary := time.Date(2026, 8, 4, 6, 0, 0, 0, location)
result, err := owner.Observe(ctx, ObserveCommand{
AccountID: "account-1",
CharacterID: "character-1",
Definition: definition,
IntervalFrom: boundary.Add(-10 * time.Second),
IntervalTo: boundary,
})
if err != nil {
t.Fatal(err)
}
if result.Snapshot.CalendarDate != "2026-08-03" ||
result.CreditedSeconds != 10 || result.Snapshot.OnlineSeconds != 10 {
t.Fatalf("prior-day result=%+v", result)
}
status, err := owner.Status(ctx, StatusCommand{
AccountID: "account-1",
CharacterID: "character-1",
Definition: definition,
ObservedAt: boundary,
})
if err != nil {
t.Fatal(err)
}
if status.CalendarDate != "2026-08-04" || status.OnlineSeconds != 0 || len(status.ClaimedStageIDs) != 0 {
t.Fatalf("new-day status=%+v", status)
}
}
func TestOwnerObserveDoesNotResetAtMidnightWithDefaultBoundary(t *testing.T) {
ctx := context.Background()
repositories := seededRepositories(t, ctx)
owner := mustOwner(t, repositories)
definition := testDefinition()
location := definition.calendar()
from := time.Date(2026, 8, 3, 23, 59, 55, 0, location)
result, err := owner.Observe(ctx, ObserveCommand{
AccountID: "account-1",
CharacterID: "character-1",
Definition: definition,
IntervalFrom: from,
IntervalTo: from.Add(15 * time.Second),
})
if err != nil {
t.Fatal(err)
}
if result.Snapshot.CalendarDate != "2026-08-03" || result.CreditedSeconds != 15 ||
result.Snapshot.OnlineSeconds != 15 {
t.Fatalf("result=%+v", result)
}
}
func TestOwnerObserveSupportsExplicitMidnightBoundary(t *testing.T) {
ctx := context.Background()
repositories := seededRepositories(t, ctx)
owner := mustOwner(t, repositories)
definition := testDefinition()
boundary := DailyBoundary{}
definition.Boundary = &boundary
location := definition.calendar()
from := time.Date(2026, 8, 3, 23, 59, 55, 0, location)
result, err := owner.Observe(ctx, ObserveCommand{
AccountID: "account-1",
CharacterID: "character-1",
Definition: definition,
IntervalFrom: from,
IntervalTo: from.Add(15 * time.Second),
})
if err != nil {
t.Fatal(err)
}
if result.Snapshot.CalendarDate != "2026-08-04" || result.CreditedSeconds != 10 ||
result.Snapshot.OnlineSeconds != 10 {
t.Fatalf("result=%+v", result)
}
}
func TestOwnerStatusPersistsDailyResetWithoutCreditingTime(t *testing.T) {
ctx := context.Background()
repositories := seededRepositories(t, ctx)
owner := mustOwner(t, repositories)
definition := testDefinition()
location := definition.calendar()
dayOne := time.Date(2026, 8, 3, 12, 0, 0, 0, location)
if _, err := owner.Observe(ctx, ObserveCommand{
AccountID: "account-1",
CharacterID: "character-1",
Definition: definition,
IntervalFrom: dayOne,
IntervalTo: dayOne.Add(10 * time.Second),
}); err != nil {
t.Fatal(err)
}
if _, err := owner.Claim(ctx, ClaimCommand{
AccountID: "account-1",
CharacterID: "character-1",
Definition: definition,
StageID: "ten_seconds",
ClaimedAt: dayOne.Add(11 * time.Second),
Allocate: testAllocator(nil),
}); err != nil {
t.Fatal(err)
}
status, err := owner.Status(ctx, StatusCommand{
AccountID: "account-1",
CharacterID: "character-1",
Definition: definition,
ObservedAt: dayOne.AddDate(0, 0, 1),
})
if err != nil {
t.Fatal(err)
}
if status.CalendarDate != "2026-08-04" || status.OnlineSeconds != 0 || len(status.ClaimedStageIDs) != 0 {
t.Fatalf("status=%+v", status)
}
account, _, _ := repositories.Account.Load(ctx, "account-1")
state, err := parseState(account.Metadata[metadataKey(definition.ID)], definition)
if err != nil || state.CalendarDate != "2026-08-04" || state.OnlineSeconds != 0 || len(state.Claims) != 0 {
t.Fatalf("state=%+v err=%v", state, err)
}
}
func TestOwnerClaimAtomicallyGrantsAndReplaysStoredReceipt(t *testing.T) {
ctx := context.Background()
repositories := seededRepositories(t, ctx)
owner := mustOwner(t, repositories)
definition := testDefinition()
location := definition.calendar()
base := time.Date(2026, 8, 3, 12, 0, 0, 0, location)
if _, err := owner.Observe(ctx, ObserveCommand{
AccountID: "account-1",
CharacterID: "character-1",
Definition: definition,
IntervalFrom: base,
IntervalTo: base.Add(12 * time.Second),
}); err != nil {
t.Fatal(err)
}
allocatorCalls := 0
command := ClaimCommand{
AccountID: "account-1",
CharacterID: "character-1",
Definition: definition,
StageID: "ten_seconds",
ClaimedAt: base.Add(13 * time.Second),
Allocate: testAllocator(&allocatorCalls),
}
first, err := owner.Claim(ctx, command)
if err != nil {
t.Fatal(err)
}
if first.Replayed || allocatorCalls != 1 || first.GoldBefore != 7 || first.GoldAfter != 12 ||
len(first.Items) != 1 || first.Items[0].ItemID != 2001 || first.Items[0].PostCount != 2 {
t.Fatalf("first=%+v calls=%d", first, allocatorCalls)
}
first.Items[0].RawEntry[0] = 0xff
replay, err := owner.Claim(ctx, command)
if err != nil {
t.Fatal(err)
}
if !replay.Replayed || allocatorCalls != 1 || replay.Items[0].RawEntry[0] != 0x20 {
t.Fatalf("replay=%+v calls=%d", replay, allocatorCalls)
}
character, found, err := repositories.Character.Load(ctx, "character-1")
if err != nil || !found || character.Stats["gold"] != 12 {
t.Fatalf("character=%+v found=%t err=%v", character, found, err)
}
inventory, found, err := repositories.Inventory.Load(ctx, "character-1")
if err != nil || !found || inventory.Slots["0:9"].ItemID != 2001 || inventory.Slots["0:9"].Count != 2 {
t.Fatalf("inventory=%+v found=%t err=%v", inventory, found, err)
}
account, found, err := repositories.Account.Load(ctx, "account-1")
if err != nil || !found {
t.Fatalf("account found=%t err=%v", found, err)
}
state, err := parseState(account.Metadata[metadataKey(definition.ID)], definition)
if err != nil || len(state.Claims) != 1 || state.Claims["ten_seconds"].GoldAfter != 12 {
t.Fatalf("state=%+v err=%v", state, err)
}
}
func TestOwnerClaimRollbackKeepsRewardAndLedgerTogether(t *testing.T) {
ctx := context.Background()
repositories := seededRepositories(t, ctx)
owner := mustOwner(t, repositories)
definition := testDefinition()
location := definition.calendar()
base := time.Date(2026, 8, 3, 12, 0, 0, 0, location)
if _, err := owner.Observe(ctx, ObserveCommand{
AccountID: "account-1",
CharacterID: "character-1",
Definition: definition,
IntervalFrom: base,
IntervalTo: base.Add(12 * time.Second),
}); err != nil {
t.Fatal(err)
}
wantErr := errors.New("reject event settlement")
rejecting := mustOwner(t, dnfrepo.Group{
CharacterSettlement: rejectAfterApplySettlement{
inner: repositories.CharacterSettlement,
err: wantErr,
},
})
_, err := rejecting.Claim(ctx, ClaimCommand{
AccountID: "account-1",
CharacterID: "character-1",
Definition: definition,
StageID: "ten_seconds",
ClaimedAt: base.Add(13 * time.Second),
Allocate: testAllocator(nil),
})
if !errors.Is(err, wantErr) {
t.Fatalf("Claim error=%v, want=%v", err, wantErr)
}
character, _, _ := repositories.Character.Load(ctx, "character-1")
if character.Stats["gold"] != 7 {
t.Fatalf("gold=%d", character.Stats["gold"])
}
inventory, _, _ := repositories.Inventory.Load(ctx, "character-1")
if len(inventory.Slots) != 0 {
t.Fatalf("inventory=%v", inventory.Slots)
}
account, _, _ := repositories.Account.Load(ctx, "account-1")
state, parseErr := parseState(account.Metadata[metadataKey(definition.ID)], definition)
if parseErr != nil || len(state.Claims) != 0 || state.OnlineSeconds != 12 {
t.Fatalf("state=%+v err=%v", state, parseErr)
}
}
func TestOwnerRejectsLockedCorruptAndStaleStateWithoutAllocating(t *testing.T) {
ctx := context.Background()
repositories := seededRepositories(t, ctx)
owner := mustOwner(t, repositories)
definition := testDefinition()
location := definition.calendar()
base := time.Date(2026, 8, 3, 12, 0, 0, 0, location)
if _, err := owner.Observe(ctx, ObserveCommand{
AccountID: "account-1",
CharacterID: "character-1",
Definition: definition,
IntervalFrom: base,
IntervalTo: base.Add(5 * time.Second),
}); err != nil {
t.Fatal(err)
}
allocatorCalls := 0
if _, err := owner.Claim(ctx, ClaimCommand{
AccountID: "account-1",
CharacterID: "character-1",
Definition: definition,
StageID: "ten_seconds",
ClaimedAt: base.Add(6 * time.Second),
Allocate: testAllocator(&allocatorCalls),
}); !errors.Is(err, ErrStageLocked) || allocatorCalls != 0 {
t.Fatalf("locked err=%v calls=%d", err, allocatorCalls)
}
account, _, _ := repositories.Account.Load(ctx, "account-1")
account.Metadata[metadataKey(definition.ID)] = `{"version":1,"event_id":"daily-online","calendar_date":"2026-08-03","online_seconds":99,"intervals":[]}`
if err := repositories.Account.Save(ctx, account); err != nil {
t.Fatal(err)
}
if _, err := owner.Claim(ctx, ClaimCommand{
AccountID: "account-1",
CharacterID: "character-1",
Definition: definition,
StageID: "ten_seconds",
ClaimedAt: base.Add(7 * time.Second),
Allocate: testAllocator(&allocatorCalls),
}); !errors.Is(err, ErrStateInvalid) || allocatorCalls != 0 {
t.Fatalf("corrupt err=%v calls=%d", err, allocatorCalls)
}
valid := newState(definition.ID)
valid.resetTo("2026-08-04")
encoded, err := encodeState(valid)
if err != nil {
t.Fatal(err)
}
account, _, _ = repositories.Account.Load(ctx, "account-1")
account.Metadata[metadataKey(definition.ID)] = encoded
if err := repositories.Account.Save(ctx, account); err != nil {
t.Fatal(err)
}
if _, err := owner.Observe(ctx, ObserveCommand{
AccountID: "account-1",
CharacterID: "character-1",
Definition: definition,
IntervalFrom: base,
IntervalTo: base.Add(time.Second),
}); !errors.Is(err, ErrObservationStale) {
t.Fatalf("stale err=%v", err)
}
}
func TestDefinitionRejectsAmbiguousOrImpossibleDailyCatalog(t *testing.T) {
badBoundary := DailyBoundary{Hour: 24}
tests := []Definition{
{ID: "bad event", Stages: []Stage{{ID: "a", RequiredSeconds: 1, Gold: 1}}},
{ID: "event", Stages: []Stage{{ID: "a", RequiredSeconds: 0, Gold: 1}}},
{ID: "event", Stages: []Stage{{ID: "a", RequiredSeconds: secondsPerDay + 1, Gold: 1}}},
{ID: "event", Stages: []Stage{{ID: "a", RequiredSeconds: 2, Gold: 1}, {ID: "b", RequiredSeconds: 1, Gold: 1}}},
{ID: "event", Stages: []Stage{{ID: "a", RequiredSeconds: 1}}},
{ID: "event", Stages: []Stage{{ID: "a", RequiredSeconds: 1, Items: []ItemReward{{ItemID: 0, Count: 1}}}}},
{ID: "event", Boundary: &badBoundary, Stages: []Stage{{ID: "a", RequiredSeconds: 1, Gold: 1}}},
}
for index, definition := range tests {
if err := definition.Validate(); !errors.Is(err, ErrDefinitionInvalid) {
t.Fatalf("case %d error=%v", index, err)
}
}
}
func TestParseStateValidatesClaimAgainstConfiguredServiceDate(t *testing.T) {
definition := testDefinition()
location := definition.calendar()
state := newState(definition.ID)
state.resetTo("2026-08-03")
state.Intervals = []creditedInterval{{Start: 0, End: 10}}
state.OnlineSeconds = 10
state.Claims["ten_seconds"] = claimReceipt{
StageID: "ten_seconds",
CharacterID: "character-1",
Items: []CommittedItem{{
SlotKey: "0:9",
SlotIndex: 9,
ItemID: 2001,
Delta: 2,
PostCount: 2,
}},
GoldBefore: 7,
GoldAfter: 12,
ClaimedAt: time.Date(2026, 8, 4, 5, 59, 0, 0, location),
}
encoded, err := encodeState(state)
if err != nil {
t.Fatal(err)
}
if _, err := parseState(encoded, definition); err != nil {
t.Fatalf("05:59 should share service date: %v", err)
}
receipt := state.Claims["ten_seconds"]
receipt.ClaimedAt = time.Date(2026, 8, 4, 6, 0, 0, 0, location)
state.Claims["ten_seconds"] = receipt
encoded, err = encodeState(state)
if err != nil {
t.Fatal(err)
}
if _, err := parseState(encoded, definition); !errors.Is(err, ErrStateInvalid) {
t.Fatalf("06:00 claim date error=%v", err)
}
}
func seededRepositories(t *testing.T, ctx context.Context) dnfrepo.Group {
t.Helper()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "account-1",
Metadata: map[string]string{"unrelated": "preserved"},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "character-1",
AccountID: "account-1",
Stats: map[string]int64{"gold": 7},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "character-1",
Slots: make(map[string]dnfrepo.ItemStack),
}); err != nil {
t.Fatal(err)
}
return repositories
}
func testDefinition() Definition {
return Definition{
ID: "daily-online",
Calendar: time.FixedZone("Asia/Shanghai", 8*60*60),
Stages: []Stage{{
ID: "ten_seconds",
RequiredSeconds: 10,
Gold: 5,
Items: []ItemReward{{ItemID: 2001, Count: 2}},
}},
}
}
func mustOwner(t *testing.T, repositories dnfrepo.Group) *Owner {
t.Helper()
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
return owner
}
func testAllocator(calls *int) ItemAllocator {
return func(record *dnfrepo.InventoryRecord, reward ItemReward) (CommittedItem, error) {
if calls != nil {
*calls++
}
if record == nil || record.Slots == nil {
return CommittedItem{}, ErrInventoryNotFound
}
const slotKey = "0:9"
stack := record.Slots[slotKey]
if stack.ItemID != 0 && stack.ItemID != reward.ItemID {
return CommittedItem{}, fmt.Errorf("occupied test slot")
}
stack.ItemID = reward.ItemID
stack.Count += reward.Count
stack.RawEntry = []byte{0x20, byte(stack.Count)}
record.Slots[slotKey] = stack
return CommittedItem{
SlotKey: slotKey,
SlotIndex: 9,
ItemID: reward.ItemID,
Delta: reward.Count,
PostCount: stack.Count,
RawEntry: append([]byte(nil), stack.RawEntry...),
}, nil
}
}
type rejectAfterApplySettlement struct {
inner dnfrepo.CharacterSettlementUnitOfWork
err error
}
func (r rejectAfterApplySettlement) WithinCharacterSettlement(
ctx context.Context,
characterID string,
apply func(dnfrepo.Group) error,
) error {
return r.inner.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
if err := apply(tx); err != nil {
return err
}
return r.err
})
}
@@ -0,0 +1,268 @@
package onlineevent
import (
"context"
"encoding/json"
"fmt"
"math"
"strings"
"time"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const PCRoomTimePointPVFPath = "etc/pcroomtimepoint.etc"
const PCRoomTimePointMetadataKey = "online_pcroom_timepoint_228_v1"
// Current EXE 0x1BD4670/0x3885270 loads these rewards independently of
// chn_AttendanceEvent.evt. Durations and period requirements are incremental.
type PCRoomTimePointCatalog struct {
Daily [4]AttendancePVFItemReward
Period [4]AttendancePVFItemReward
Seconds [4]uint32
Counts [4]uint32
UIPath string
}
func LoadPCRoomTimePointCatalog(ctx context.Context, source dnfpvf.Source) (*PCRoomTimePointCatalog, error) {
if source == nil {
return nil, dnfpvf.ErrSourceRequired
}
if err := ctx.Err(); err != nil {
return nil, err
}
text, err := source.ReadText(PCRoomTimePointPVFPath)
if err != nil {
return nil, err
}
doc, err := dnfpvf.Parse(PCRoomTimePointPVFPath, text)
if err != nil {
return nil, err
}
c := &PCRoomTimePointCatalog{}
for name, want := range map[string]string{"daily reward autoget": "false", "daily reward loop": "1", "period reward loop": "4"} {
tokens, ok := doc.Section(name)
if !ok || len(tokens) != 1 || tokens[0].Value != want {
return nil, fmt.Errorf("pcroom [%s] differs from current manual four-stage edition", name)
}
}
for _, spec := range []struct {
name string
width int
}{{"daily reward items", 6}, {"period reward item", 5}} {
tokens, ok := doc.Section(spec.name)
if !ok || len(tokens) != 4*spec.width {
return nil, fmt.Errorf("pcroom [%s] has invalid shape", spec.name)
}
var cumulative uint64
for i := 0; i < 4; i++ {
row := tokens[i*spec.width : (i+1)*spec.width]
for j := 0; j < 4; j++ {
if row[j].Kind != dnfpvf.TokenInt || row[j].Int <= 0 || row[j].Int > math.MaxUint32 {
return nil, fmt.Errorf("pcroom [%s] row %d integer %d invalid", spec.name, i, j)
}
}
for j := 4; j < spec.width; j++ {
if row[j].Value != "true" {
return nil, fmt.Errorf("pcroom [%s] row %d unsupported flag", spec.name, i)
}
}
r := AttendancePVFItemReward{ItemID: row[1].Int, Count: row[2].Int}
if spec.width == 6 {
if row[0].Int != 1 || row[3].Int%1000 != 0 {
return nil, fmt.Errorf("pcroom daily group/duration invalid")
}
cumulative += uint64(row[3].Int / 1000)
if cumulative > secondsPerDay {
return nil, fmt.Errorf("pcroom daily duration exceeds service day")
}
c.Daily[i], c.Seconds[i] = r, uint32(cumulative)
} else {
if row[0].Int != int64(i+1) {
return nil, fmt.Errorf("pcroom period slot invalid")
}
cumulative += uint64(row[3].Int)
if cumulative > math.MaxInt32 {
return nil, fmt.Errorf("pcroom period count overflows client")
}
c.Period[i], c.Counts[i] = r, uint32(cumulative)
}
}
}
ui, ok := doc.Section("ui file")
if !ok || len(ui) != 1 || strings.ToLower(ui[0].Value) != "ui/event/pcroomtimepoint/pcroomtimepointrewarditeminfowindow_chn.xui" {
return nil, fmt.Errorf("pcroom native UI path differs from verified current client")
}
c.UIPath = ui[0].Value
if _, err := source.ReadText(c.UIPath); err != nil {
return nil, err
}
paths, err := resolveAttendanceRewardDefinitions(ctx, source, append(append([]AttendancePVFItemReward{}, c.Daily[:]...), c.Period[:]...))
if err != nil {
return nil, err
}
attachAttendanceDefinitionPaths(c.Daily[:], paths)
attachAttendanceDefinitionPaths(c.Period[:], paths)
return c, nil
}
type pcroomTimePointState struct {
Version int `json:"version"`
Date string `json:"date"`
DailyMask byte `json:"daily_mask"`
DayComplete bool `json:"day_complete"`
Total uint32 `json:"total"`
PeriodMask byte `json:"period_mask"`
}
type PCRoomTimePointSnapshot struct {
Seconds uint32
Total uint32
DailyMask byte
PeriodMask uint32
MailIDs []string
}
type PCRoomTimePointCommand struct {
AccountID, CharacterID string
Now time.Time
// Reuse the existing authoritative interval-union clock, including its
// 06:00 boundary, cross-character accounting and already persisted time.
ClockDefinition Definition
DailyClaim byte
PeriodClaim int // -1 means no period claim; 0..3 are native button indices.
Deliver func(dnfrepo.MailboxRepository, AttendancePVFItemReward, string) (string, error)
}
// SettlePCRoomTimePoint counts one fully completed service day, independently
// of clicking the fourth reward. Mail and masks commit in the same transaction.
// The current PVF edition is kept open locally; counts stop at its final tier.
func SettlePCRoomTimePoint(ctx context.Context, repos dnfrepo.Group, c *PCRoomTimePointCatalog, cmd PCRoomTimePointCommand) (PCRoomTimePointSnapshot, error) {
if c == nil || repos.MailboxAssets == nil || cmd.AccountID == "" || cmd.CharacterID == "" || cmd.Now.IsZero() || cmd.Deliver == nil {
return PCRoomTimePointSnapshot{}, ErrOwnerUnavailable
}
if cmd.DailyClaim&^byte(15) != 0 || cmd.PeriodClaim < -1 || cmd.PeriodClaim > 3 || (cmd.DailyClaim != 0 && cmd.PeriodClaim >= 0) {
return PCRoomTimePointSnapshot{}, ErrRewardInvalid
}
if err := cmd.ClockDefinition.Validate(); err != nil {
return PCRoomTimePointSnapshot{}, err
}
var result PCRoomTimePointSnapshot
err := repos.MailboxAssets.WithinMailboxAssets(ctx, cmd.CharacterID, cmd.CharacterID, func(accounts dnfrepo.AccountRepository, _ dnfrepo.AccountInventoryRepository, characters dnfrepo.CharacterRepository, _ dnfrepo.InventoryRepository, mailboxes dnfrepo.MailboxRepository) error {
character, found, err := characters.Load(ctx, cmd.CharacterID)
if err != nil {
return err
}
if !found || character.AccountID != cmd.AccountID {
return ErrAccountMismatch
}
account, found, err := accounts.Load(ctx, cmd.AccountID)
if err != nil {
return err
}
if !found {
return ErrAccountNotFound
}
clock, err := parseState(account.Metadata[metadataKey(cmd.ClockDefinition.ID)], cmd.ClockDefinition)
if err != nil {
return err
}
date, _ := calendarDay(cmd.ClockDefinition.calendar(), cmd.ClockDefinition.boundary(), cmd.Now)
if clock.CalendarDate > date {
return ErrObservationStale
}
var seconds uint32
if clock.CalendarDate == date {
seconds = uint32(clock.OnlineSeconds)
}
state := pcroomTimePointState{Version: 1, Date: date}
raw := account.Metadata[PCRoomTimePointMetadataKey]
if raw != "" {
decoder := json.NewDecoder(strings.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&state); err != nil {
return fmt.Errorf("%w: pcroom %v", ErrStateInvalid, err)
}
if err := rejectTrailingJSON(decoder); err != nil {
return err
}
if _, err := time.Parse(time.DateOnly, state.Date); err != nil {
return ErrStateInvalid
}
if state.Version != 1 || state.DailyMask > 15 || state.PeriodMask > 15 || state.Total > c.Counts[3] {
return ErrStateInvalid
}
for i, n := range c.Counts {
if state.PeriodMask&(1<<i) != 0 && state.Total < n {
return ErrStateInvalid
}
}
if state.Date > date {
return ErrObservationStale
}
if state.Date != date {
state.Date = date
state.DailyMask = 0
state.DayComplete = false
}
} else if clock.CalendarDate == date {
// One-time cutover: retain today's already delivered legacy rewards as
// claimed, so switching the UI does not grant a second set that day.
for i, stage := range cmd.ClockDefinition.Stages {
if i < 4 {
if _, ok := clock.Claims[stage.ID]; ok {
state.DailyMask |= 1 << i
}
}
}
}
if !state.DayComplete && seconds >= c.Seconds[3] {
state.DayComplete = true
if state.Total < c.Counts[3] {
state.Total++
}
}
result = PCRoomTimePointSnapshot{Seconds: seconds, Total: state.Total, DailyMask: state.DailyMask, PeriodMask: uint32(state.PeriodMask)}
deliver := func(reward AttendancePVFItemReward, key string) error {
id, err := cmd.Deliver(mailboxes, reward, PCRoomTimePointMetadataKey+":"+key)
if err != nil {
return err
}
if id == "" {
return fmt.Errorf("pcroom reward mail missing")
}
result.MailIDs = append(result.MailIDs, id)
return nil
}
for i, reward := range c.Daily {
bit := byte(1 << i)
if cmd.DailyClaim&bit == 0 || state.DailyMask&bit != 0 || seconds < c.Seconds[i] {
continue
}
if err := deliver(reward, fmt.Sprintf("daily:%s:%d", date, i)); err != nil {
return err
}
state.DailyMask |= bit
}
if i := cmd.PeriodClaim; i >= 0 && state.Total >= c.Counts[i] && state.PeriodMask&(1<<i) == 0 {
if err := deliver(c.Period[i], fmt.Sprintf("period:%d", i)); err != nil {
return err
}
state.PeriodMask |= 1 << i
}
result.DailyMask, result.PeriodMask = state.DailyMask, uint32(state.PeriodMask)
encoded, err := json.Marshal(state)
if err != nil {
return err
}
if string(encoded) == raw {
return nil
}
return dnfrepo.SaveAccountMetadataEntry(ctx, accounts, account, PCRoomTimePointMetadataKey, string(encoded), cmd.Now)
})
if err != nil {
return PCRoomTimePointSnapshot{}, err
}
return result, nil
}
@@ -0,0 +1,212 @@
package onlineevent
import (
"context"
"errors"
"fmt"
"os"
"sync"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/repository/memory"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestRealPCRoomTimePointPVFCatalog(t *testing.T) {
p := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if p == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE")
}
a, err := platformpvf.LoadArchive(platformpvf.Options{Path: p})
if err != nil {
t.Fatal(err)
}
c, err := LoadPCRoomTimePointCatalog(context.Background(), a)
if err != nil {
t.Fatal(err)
}
if c.Seconds != [4]uint32{1800, 3600, 7200, 10800} || c.Counts != [4]uint32{5, 10, 15, 25} {
t.Fatalf("catalog=%+v", c)
}
for i, id := range []int64{490008561, 490008562, 490008564, 490008565} {
if c.Daily[i].ItemID != id || c.Daily[i].DefinitionPath == "" {
t.Fatalf("daily[%d]=%+v", i, c.Daily[i])
}
}
for i, id := range []int64{490008566, 490008567, 490008568, 490008570} {
if c.Period[i].ItemID != id || c.Period[i].DefinitionPath == "" {
t.Fatalf("period[%d]=%+v", i, c.Period[i])
}
}
}
func pcroomTestCatalog() (*PCRoomTimePointCatalog, Definition) {
c := &PCRoomTimePointCatalog{Seconds: [4]uint32{1800, 3600, 7200, 10800}, Counts: [4]uint32{5, 10, 15, 25}}
d := Definition{ID: "2347"}
for i := range c.Daily {
c.Daily[i] = AttendancePVFItemReward{ItemID: int64(100 + i), Count: 1}
c.Period[i] = AttendancePVFItemReward{ItemID: int64(200 + i), Count: 1}
d.Stages = append(d.Stages, Stage{ID: fmt.Sprintf("stage-%d", i+1), RequiredSeconds: uint64(c.Seconds[i]), Items: []ItemReward{{ItemID: int64(300 + i), Count: 1}}})
}
return c, d
}
func TestPCRoomTimePointClockClaimsCrossDayAndRollback(t *testing.T) {
ctx := context.Background()
repos := memory.NewMemoryGroup()
c, d := pcroomTestCatalog()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "a"}); err != nil {
t.Fatal(err)
}
for _, id := range []string{"1", "2"} {
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: id, AccountID: "a"}); err != nil {
t.Fatal(err)
}
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
start := time.Date(2026, 9, 16, 6, 0, 0, 0, ChinaCalendar)
cmd := PCRoomTimePointCommand{AccountID: "a", CharacterID: "1", Now: start, ClockDefinition: d, PeriodClaim: -1}
fail := false
cmd.Deliver = func(m dnfrepo.MailboxRepository, r AttendancePVFItemReward, key string) (string, error) {
id, err := dnfrepo.AppendSystemMail(ctx, m, dnfrepo.SystemMailDelivery{RecipientCharacterID: cmd.CharacterID, Source: "pcroom-test", CompletionKey: key, CreatedAt: cmd.Now, Attachments: []dnfrepo.MailAttachment{{ItemID: r.ItemID, Count: r.Count}}})
if fail {
return "", errors.New("failure after mail write")
}
return id, err
}
observe := func(from, to time.Time) {
t.Helper()
if _, err := owner.Observe(ctx, ObserveCommand{AccountID: "a", CharacterID: "1", Definition: d, IntervalFrom: from, IntervalTo: to}); err != nil {
t.Fatal(err)
}
cmd.Now = to
}
settle := func() PCRoomTimePointSnapshot {
t.Helper()
s, err := SettlePCRoomTimePoint(ctx, repos, c, cmd)
if err != nil {
t.Fatal(err)
}
return s
}
cmd.DailyClaim = 1
observe(start, start.Add(1799*time.Second))
if s := settle(); s.DailyMask != 0 || len(s.MailIDs) != 0 {
t.Fatalf("early claim=%+v", s)
}
observe(start, start.Add(1800*time.Second))
fail = true
if _, err := SettlePCRoomTimePoint(ctx, repos, c, cmd); err == nil {
t.Fatal("mail failure committed")
}
mb, _, _ := repos.Mailbox.Load(ctx, "1")
if len(mb.Mails) != 0 {
t.Fatal("failed mail leaked")
}
fail = false
if s := settle(); s.DailyMask != 1 || len(s.MailIDs) != 1 {
t.Fatalf("claim=%+v", s)
}
cmd.CharacterID = "2"
if s := settle(); s.DailyMask != 1 || len(s.MailIDs) != 0 {
t.Fatalf("cross-character replay=%+v", s)
}
cmd.DailyClaim = 0
observe(start, start.Add(3*time.Hour))
if s := settle(); s.Total != 1 || s.DailyMask != 1 {
t.Fatalf("full day independent of claim=%+v", s)
}
if s := settle(); s.Total != 1 {
t.Fatalf("double count=%+v", s)
}
for day := 1; day < 25; day++ {
at := start.AddDate(0, 0, day)
cmd.Now = at
if s := settle(); s.Seconds != 0 || s.DailyMask != 0 || s.Total != uint32(day) {
t.Fatalf("reset=%+v day=%d", s, day)
}
observe(at, at.Add(3*time.Hour))
s := settle()
if s.Total != uint32(day+1) {
t.Fatalf("count=%+v day=%d", s, day)
}
for i, n := range c.Counts {
cmd.PeriodClaim = i
s = settle()
if s.Total < n && s.PeriodMask&(1<<i) != 0 {
t.Fatalf("locked period=%+v", s)
}
if s.Total == n && len(s.MailIDs) != 1 {
t.Fatalf("threshold %d state=%+v", n, s)
}
if again := settle(); len(again.MailIDs) != 0 {
t.Fatal("period replay mailed")
}
}
cmd.PeriodClaim = -1
}
if s := settle(); s.Total != 25 || s.PeriodMask != 15 {
t.Fatalf("final=%+v", s)
}
cmd.Now = start // stale requests never clear the current ledger.
if _, err := SettlePCRoomTimePoint(ctx, repos, c, cmd); !errors.Is(err, ErrObservationStale) {
t.Fatalf("stale=%v", err)
}
cmd.Now = start.AddDate(0, 0, 24).Add(3 * time.Hour)
cmd.AccountID = "wrong"
if _, err := SettlePCRoomTimePoint(ctx, repos, c, cmd); !errors.Is(err, ErrAccountMismatch) {
t.Fatalf("ownership=%v", err)
}
}
func TestPCRoomTimePointConcurrentClaimAndLegacyCutover(t *testing.T) {
ctx := context.Background()
repos := memory.NewMemoryGroup()
c, d := pcroomTestCatalog()
now := time.Date(2026, 9, 16, 12, 0, 0, 0, ChinaCalendar)
legacy := newState(d.ID)
legacy.resetTo("2026-09-16")
legacy.creditInterval(0, 10800)
legacy.Claims["stage-1"] = claimReceipt{StageID: "stage-1", CharacterID: "1", ClaimedAt: now, Items: []CommittedItem{{SlotKey: "0:9", ItemID: 300, Delta: 1, PostCount: 1}}}
raw, err := encodeState(legacy)
if err != nil {
t.Fatal(err)
}
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "a", Metadata: map[string]string{metadataKey(d.ID): raw}}); err != nil {
t.Fatal(err)
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "1", AccountID: "a"}); err != nil {
t.Fatal(err)
}
cmd := PCRoomTimePointCommand{AccountID: "a", CharacterID: "1", Now: now, ClockDefinition: d, DailyClaim: 1, PeriodClaim: -1}
cmd.Deliver = func(m dnfrepo.MailboxRepository, r AttendancePVFItemReward, key string) (string, error) {
return dnfrepo.AppendSystemMail(ctx, m, dnfrepo.SystemMailDelivery{RecipientCharacterID: "1", Source: "pcroom-test", CompletionKey: key, CreatedAt: now, Attachments: []dnfrepo.MailAttachment{{ItemID: r.ItemID, Count: r.Count}}})
}
s, err := SettlePCRoomTimePoint(ctx, repos, c, cmd)
if err != nil || s.DailyMask != 1 || s.Total != 1 || len(s.MailIDs) != 0 {
t.Fatalf("cutover=%+v %v", s, err)
}
cmd.DailyClaim = 2
var wg sync.WaitGroup
errs := make(chan error, 20)
for range 20 {
wg.Add(1)
go func() { defer wg.Done(); _, err := SettlePCRoomTimePoint(ctx, repos, c, cmd); errs <- err }()
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
mb, _, err := repos.Mailbox.Load(ctx, "1")
if err != nil || len(mb.Mails) != 1 {
t.Fatalf("concurrent mail count=%d err=%v", len(mb.Mails), err)
}
}
@@ -0,0 +1,340 @@
package onlineevent
import (
"context"
"encoding/json"
"fmt"
"time"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// Test edition requested by the owner. This is elapsed time, not a date rollover.
const ReturneeOfflinePeriod = 24 * time.Hour
const ReturneeDuration = 30 * 24 * time.Hour
// Owner-selected local rule: the returning player's monster and clear EXP
// each gain 20%, in solo and party play. This is not an inferred PVF formula.
const ReturneeWelcomeExperiencePercent int64 = 20
const ReturneeMetadataKey = "event_seria_returnee_v1"
const ReturneeQuestCountKey = "seria_returnee_quest_count"
const ReturneeDisjointCountKey = "seria_returnee_disjoint_count"
const ReturneeGiftCountKey = "seria_returnee_gift_count"
const ReturneeSkillCountKey = "seria_returnee_skill_count"
type ReturneeMission struct {
ID uint32
Condition string
Required uint32
Reward AttendancePVFItemReward
}
type ReturneeCatalog struct {
Missions [10]ReturneeMission
MailTitle, MailBody string
}
func LoadReturneeCatalog(ctx context.Context, source dnfpvf.Source) (*ReturneeCatalog, error) {
text, err := source.ReadText("etc/seriawelcome.etc")
if err != nil {
return nil, err
}
doc, err := dnfpvf.Parse("etc/seriawelcome.etc", text)
if err != nil {
return nil, err
}
c := &ReturneeCatalog{}
definitions := map[uint32]ReturneeMission{}
var m ReturneeMission
inList, inGroups, inMission, inCondition := false, false, false, false
group := int64(-1)
var ids []uint32
for _, section := range doc.Sections {
v := doc.Tokens[section.Start:section.End]
integer := func(i int) int64 {
if i < len(v) && v[i].Kind == dnfpvf.TokenInt {
return v[i].Int
}
return -1
}
switch section.Name {
case "mission list":
if inGroups && group == 1 {
for _, t := range v {
if t.Kind != dnfpvf.TokenInt || t.Int < 1 {
return nil, fmt.Errorf("invalid returnee mission group")
}
ids = append(ids, uint32(t.Int))
}
} else if !inGroups {
inList = true
}
case "/mission list":
if !inGroups {
inList = false
}
case "mission group":
inGroups = true
case "/mission group":
inGroups = false
case "group":
group = integer(0)
case "mission":
if inList {
m = ReturneeMission{}
inMission = true
}
case "/mission":
if inMission {
definitions[m.ID] = m
inMission = false
}
inCondition = false
case "index":
if inMission {
m.ID = uint32(integer(0))
}
case "condition":
if inMission {
inCondition = true
}
case "/condition":
inCondition = false
case "reward item":
if inMission && len(v) == 2 {
m.Reward = AttendancePVFItemReward{ItemID: integer(0), Count: integer(1)}
}
case "reward mail":
if len(v) == 2 {
c.MailTitle, c.MailBody = v[0].Value, v[1].Value
}
default:
if inCondition && section.Name != "condition value" && len(v) == 1 && integer(0) > 0 {
m.Condition, m.Required = section.Name, uint32(integer(0))
}
}
}
if len(ids) != len(c.Missions) || c.MailTitle == "" {
return nil, fmt.Errorf("returnee native layout requires ten missions and mail text")
}
for i, id := range ids {
m, ok := definitions[id]
if !ok || m.Required == 0 || m.Reward.ItemID <= 0 || m.Reward.Count <= 0 {
return nil, fmt.Errorf("invalid returnee mission %d", id)
}
switch m.Condition {
case "clear quest", "join guild", "learn skill", "proper dungeon clear", "disjoint item", "level up count", "present npc", "mission clear count":
default:
return nil, fmt.Errorf("unsupported returnee condition %q", m.Condition)
}
c.Missions[i] = m
}
if err := ctx.Err(); err != nil {
return nil, err
}
return c, nil
}
type ReturneeObservation struct {
Level, Quests, Clears, Disjoint, Gifts, Learned int64
Guild bool
}
type ReturneeState struct {
LastSeen time.Time `json:"last_seen"`
Started time.Time `json:"started,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
Guide uint64 `json:"guide,omitempty"`
Progress [10]uint32 `json:"progress"`
Claimed uint16 `json:"claimed"`
Observed map[string]ReturneeObservation `json:"observed,omitempty"`
}
func (s ReturneeState) ActiveAt(now time.Time) bool {
// Mission claims do not shorten the owner's 30-day welcome benefit.
if s.Started.IsZero() {
return false
}
expiresAt := s.ExpiresAt
if expiresAt.IsZero() {
expiresAt = s.Started.Add(ReturneeDuration)
}
if now.IsZero() {
now = time.Now()
}
return now.Before(expiresAt)
}
func (s ReturneeState) Active() bool { return s.ActiveAt(time.Now()) }
func LoadReturneeState(a dnfrepo.AccountRecord) (ReturneeState, error) {
var s ReturneeState
if raw := a.Metadata[ReturneeMetadataKey]; raw != "" {
if err := json.Unmarshal([]byte(raw), &s); err != nil {
return s, fmt.Errorf("invalid returnee state: %w", err)
}
}
if s.Claimed > 0x3ff {
return s, fmt.Errorf("invalid returnee claim mask")
}
if !s.Started.IsZero() {
if s.ExpiresAt.IsZero() {
s.ExpiresAt = s.Started.Add(ReturneeDuration)
} else if s.ExpiresAt.Before(s.Started) {
return s, fmt.Errorf("invalid returnee expiration")
}
}
return s, nil
}
// Observe uses durable gameplay counters; client progress reports
// cannot complete a mission. Each account/character snapshot is consumed once.
func (s *ReturneeState) Observe(c *ReturneeCatalog, characterID string, o ReturneeObservation, now time.Time, login bool) {
if login && !s.ActiveAt(now) && !s.LastSeen.IsZero() && now.Sub(s.LastSeen) >= ReturneeOfflinePeriod {
*s = ReturneeState{Started: now, ExpiresAt: now.Add(ReturneeDuration), LastSeen: s.LastSeen, Guide: s.Guide}
}
if now.After(s.LastSeen) {
s.LastSeen = now
}
if !s.ActiveAt(now) {
return
}
if s.Observed == nil {
s.Observed = map[string]ReturneeObservation{}
}
old, known := s.Observed[characterID]
if !known {
old = o
}
for i, m := range c.Missions {
var n int64
switch m.Condition {
case "clear quest":
n = o.Quests - old.Quests
case "proper dungeon clear":
n = o.Clears - old.Clears
case "disjoint item":
n = o.Disjoint - old.Disjoint
case "present npc":
n = o.Gifts - old.Gifts
case "level up count":
n = o.Level - old.Level
case "learn skill":
n = o.Learned - old.Learned
case "join guild":
if o.Guild {
n = int64(m.Required)
}
}
s.Progress[i] = uint32(min(int64(m.Required), int64(s.Progress[i])+max(n, 0)))
}
var completed uint32
for i, m := range c.Missions {
if m.Condition != "mission clear count" && s.Progress[i] >= m.Required {
completed++
}
}
for i, m := range c.Missions {
if m.Condition == "mission clear count" {
s.Progress[i] = min(completed, m.Required)
}
}
s.Observed[characterID] = o
}
type ReturneeCommand struct {
AccountID, CharacterID string
Now time.Time
Login bool
Claim uint32
Guide *uint32
Observation ReturneeObservation
Deliver func(dnfrepo.MailboxRepository, AttendancePVFItemReward, string) (string, error)
}
func SettleReturnee(ctx context.Context, repos dnfrepo.Group, c *ReturneeCatalog, cmd ReturneeCommand) (ReturneeState, []string, error) {
var state ReturneeState
var mails []string
if c == nil || repos.MailboxAssets == nil || cmd.Now.IsZero() || cmd.AccountID == "" || cmd.CharacterID == "" {
return state, nil, ErrOwnerUnavailable
}
err := repos.MailboxAssets.WithinMailboxAssets(ctx, cmd.CharacterID, cmd.CharacterID, func(accounts dnfrepo.AccountRepository, _ dnfrepo.AccountInventoryRepository, chars dnfrepo.CharacterRepository, _ dnfrepo.InventoryRepository, mailbox dnfrepo.MailboxRepository) error {
ch, found, err := chars.Load(ctx, cmd.CharacterID)
if err != nil {
return err
}
if !found || ch.AccountID != cmd.AccountID {
return fmt.Errorf("returnee character ownership mismatch")
}
a, found, err := accounts.Load(ctx, cmd.AccountID)
if err != nil {
return err
}
if !found {
return fmt.Errorf("returnee account missing")
}
state, err = LoadReturneeState(a)
if err != nil {
return err
}
o := cmd.Observation
o.Level, o.Quests, o.Disjoint, o.Gifts = int64(ch.Level), ch.Stats[ReturneeQuestCountKey], ch.Stats[ReturneeDisjointCountKey], ch.Stats[ReturneeGiftCountKey]
o.Clears = ch.Stats["adventure_recommended_dungeon_clears"]
o.Learned = ch.Stats[ReturneeSkillCountKey]
state.Observe(c, cmd.CharacterID, o, cmd.Now, cmd.Login)
if cmd.Guide != nil && *cmd.Guide < 41 {
state.Guide |= uint64(1) << *cmd.Guide
}
if cmd.Claim != 0 {
if !state.ActiveAt(cmd.Now) {
return fmt.Errorf("returnee period expired or inactive")
}
index := -1
for i, m := range c.Missions {
if m.ID == cmd.Claim {
index = i
}
}
if index < 0 || state.Started.IsZero() || state.Progress[index] < c.Missions[index].Required {
return fmt.Errorf("returnee mission not complete")
}
if state.Claimed&(1<<index) == 0 {
if cmd.Deliver == nil {
return ErrOwnerUnavailable
}
key := fmt.Sprintf("%s:%d:%d", ReturneeMetadataKey, state.Started.UnixNano(), cmd.Claim)
id, err := cmd.Deliver(mailbox, c.Missions[index].Reward, key)
if err != nil {
return err
}
if id == "" {
return fmt.Errorf("returnee reward mail missing")
}
mails = append(mails, id)
state.Claimed |= 1 << index
}
}
wantFlag := int64(0)
if state.ActiveAt(cmd.Now) {
wantFlag = 1
}
if ch.Stats["is_return_user"] != wantFlag {
ch = dnfrepo.CloneCharacter(ch)
if ch.Stats == nil {
ch.Stats = map[string]int64{}
}
ch.Stats["is_return_user"] = wantFlag
ch.UpdatedAt = cmd.Now
if err := dnfrepo.SaveCharacterFields(ctx, chars, ch, dnfrepo.CharacterFieldStats); err != nil {
return err
}
}
raw, err := json.Marshal(state)
if err != nil {
return err
}
return dnfrepo.SaveAccountMetadataEntry(ctx, accounts, a, ReturneeMetadataKey, string(raw), cmd.Now)
})
return state, mails, err
}
@@ -0,0 +1,188 @@
package onlineevent
import (
"context"
"errors"
"fmt"
"math"
"math/rand/v2"
"path"
"strconv"
"time"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var (
ErrNPCGiftLevel = errors.New("NPC gift level requirement")
ErrNPCGiftItem = errors.New("NPC gift item not allowed")
ErrNPCGiftCount = errors.New("NPC gift item count insufficient")
ErrNPCGiftLimit = errors.New("NPC daily gift limit")
)
type ReturneeGiftRules struct {
NPC uint32
Level int64
Count, Limit, MaxPoints int64
Points map[int64][2]int64
}
func LoadReturneeGiftRules(source dnfpvf.Source, npc uint32) (ReturneeGiftRules, error) {
r := ReturneeGiftRules{NPC: npc, Points: map[int64][2]int64{}}
read := func(p string) (*dnfpvf.Document, error) {
s, e := source.ReadText(p)
if e != nil {
return nil, e
}
return dnfpvf.Parse(p, s)
}
d, e := read("etc/npcfavorsystem.etc")
if e != nil {
return r, e
}
for name, dst := range map[string]*int64{"favor condition level": &r.Level, "favor gift item count": &r.Count, "favor gift limit": &r.Limit} {
v, ok := d.Section(name)
if !ok || len(v) != 1 || v[0].Kind != dnfpvf.TokenInt || v[0].Int <= 0 {
return r, fmt.Errorf("invalid NPC favor %s", name)
}
*dst = v[0].Int
}
v, ok := d.Section("favor level point up")
if !ok || len(v)%3 != 0 {
return r, fmt.Errorf("invalid NPC gift point rows")
}
for i := 0; i < len(v); i += 3 {
if v[i].Int <= 0 || v[i+1].Int <= 0 || v[i+2].Int < v[i+1].Int {
return r, fmt.Errorf("invalid NPC gift points")
}
r.Points[v[i].Int] = [2]int64{v[i+1].Int, v[i+2].Int}
}
list, e := read("npc/npc.lst")
if e != nil {
return r, e
}
for _, entry := range dnfpvf.ParseList(list) {
if entry.ID != int64(npc) {
continue
}
if path.IsAbs(entry.Path) || path.Clean(entry.Path) == ".." {
return r, fmt.Errorf("invalid NPC path")
}
d, e = read(path.Join("npc", entry.Path))
if e != nil {
return r, e
}
if _, ok := d.Section("favorable relationship 2"); !ok {
break
}
v, ok := d.Section("favor level point")
if !ok || len(v) != 3 {
break
}
r.MaxPoints = v[2].Int
break
}
if r.MaxPoints <= 0 || r.MaxPoints > math.MaxInt32 || r.Count > math.MaxUint16 || r.Limit > math.MaxUint8 {
return r, fmt.Errorf("NPC %d has no supported favor rules", npc)
}
return r, nil
}
type ReturneeGiftResult struct {
Points, Used, Remaining int64
Added int64
Stack dnfrepo.ItemStack
}
// Native op812 gift consumes the configured cube amount. Inventory, affection,
// daily per-NPC limit and the welcome mission counter share one transaction.
func GiveReturneeNPCGift(ctx context.Context, repos dnfrepo.Group, r ReturneeGiftRules, accountID, characterID string, container byte, slot int16, now time.Time) (ReturneeGiftResult, error) {
var result ReturneeGiftResult
if repos.AccountAssets == nil || r.Count <= 0 || r.MaxPoints <= 0 || now.IsZero() {
return result, ErrOwnerUnavailable
}
warehouse := slot >= dnfrepo.CrystalWarehouseFirstSlot && slot <= dnfrepo.CrystalWarehouseLastSlot
if (container != 0 && container != 36) || slot < 0 || (container == 36 && !warehouse) || (!warehouse && slot >= dnfrepo.CrystalWarehouseFirstSlot) {
return result, ErrNPCGiftItem
}
e := repos.AccountAssets.WithinAccountCharacterAssets(ctx, accountID, characterID, func(accounts dnfrepo.AccountInventoryRepository, characters dnfrepo.CharacterRepository, items dnfrepo.InventoryRepository, _ dnfrepo.EquipmentRepository) error {
ch, ok, e := characters.Load(ctx, characterID)
if e != nil {
return e
}
if !ok || ch.AccountID != accountID {
return fmt.Errorf("NPC gift character requirement")
}
if int64(ch.Level) < r.Level {
return ErrNPCGiftLevel
}
var account dnfrepo.AccountInventoryRecord
var inventory dnfrepo.InventoryRecord
var slots map[string]dnfrepo.ItemStack
if warehouse {
account, _, e = accounts.Load(ctx, accountID)
account = dnfrepo.CloneAccountInventory(account)
slots = account.Slots
} else {
inventory, _, e = items.Load(ctx, characterID)
inventory = dnfrepo.CloneInventory(inventory)
slots = inventory.Slots
}
if e != nil {
return e
}
key := dnfrepo.AccountSharedInventorySlotKey(slot)
stack, ok := slots[key]
points, allowed := r.Points[stack.ItemID]
if !ok || !allowed || points[0] <= 0 || points[1] < points[0] {
return ErrNPCGiftItem
}
if stack.Count < r.Count {
return ErrNPCGiftCount
}
prefix := "npc_favor_" + strconv.FormatUint(uint64(r.NPC), 10) + "_"
day := now.In(time.FixedZone("CST", 8*3600)).Add(-6 * time.Hour).Format("20060102")
dayID, _ := strconv.ParseInt(day, 10, 64)
ch = dnfrepo.CloneCharacter(ch)
if ch.Stats == nil {
ch.Stats = map[string]int64{}
}
if ch.Stats[prefix+"day"] != dayID {
ch.Stats[prefix+"day"] = dayID
ch.Stats[prefix+"gifts"] = 0
}
if ch.Stats[prefix+"gifts"] >= r.Limit {
return ErrNPCGiftLimit
}
ch.Stats[prefix+"gifts"]++
before := min(max(ch.Stats[prefix+"points"], 0), r.MaxPoints)
ch.Stats[prefix+"points"] = min(before+points[0]+rand.Int64N(points[1]-points[0]+1), r.MaxPoints)
if ch.Stats["is_return_user"] != 0 {
ch.Stats[ReturneeGiftCountKey] = min(max(ch.Stats[ReturneeGiftCountKey], 0), math.MaxInt64-1) + 1
}
stack.Count -= r.Count
stack.RawEntry = nil
slots[key] = stack
if !warehouse && stack.Count == 0 {
delete(slots, key)
}
ch.UpdatedAt = now
if warehouse {
account.UpdatedAt = now
e = accounts.Save(ctx, account)
} else {
inventory.UpdatedAt = now
e = items.Save(ctx, inventory)
}
if e != nil {
return e
}
if e := dnfrepo.SaveCharacterFields(ctx, characters, ch, dnfrepo.CharacterFieldStats); e != nil {
return e
}
result = ReturneeGiftResult{Points: ch.Stats[prefix+"points"], Added: ch.Stats[prefix+"points"] - before, Used: ch.Stats[prefix+"gifts"], Remaining: stack.Count, Stack: stack}
return nil
})
return result, e
}
@@ -0,0 +1,90 @@
package onlineevent
import (
"context"
"errors"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/repository/memory"
"testing"
"time"
)
func TestReturneeGiftConsumptionAndLimit(t *testing.T) {
ctx := context.Background()
repos := memory.NewMemoryGroup()
now := time.Date(2026, 9, 12, 12, 0, 0, 0, time.UTC)
if e := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "7", AccountID: "a", Level: 20, Stats: map[string]int64{"is_return_user": 1}}); e != nil {
t.Fatal(e)
}
if e := repos.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{AccountID: "a", Slots: map[string]dnfrepo.ItemStack{"0:354": {ItemID: 3037, Count: 600}}}); e != nil {
t.Fatal(e)
}
r := ReturneeGiftRules{NPC: 2, Level: 20, Count: 100, Limit: 5, MaxPoints: 40000, Points: map[int64][2]int64{3037: {100, 300}}}
for i := 1; i <= 5; i++ {
got, e := GiveReturneeNPCGift(ctx, repos, r, "a", "7", 36, 354, now)
if e != nil {
t.Fatal(e)
}
if got.Remaining != int64(600-100*i) || got.Used != int64(i) {
t.Fatalf("result=%+v", got)
}
}
if _, e := GiveReturneeNPCGift(ctx, repos, r, "a", "7", 36, 354, now); e == nil {
t.Fatal("daily limit ignored")
}
ch, _, _ := repos.Character.Load(ctx, "7")
if ch.Stats[ReturneeGiftCountKey] != 5 {
t.Fatal("gift mission counter")
}
if _, e := GiveReturneeNPCGift(ctx, repos, r, "foreign", "7", 36, 354, now.Add(24*time.Hour)); e == nil {
t.Fatal("ownership ignored")
}
if _, e := GiveReturneeNPCGift(ctx, repos, r, "a", "7", 36, 354, now.Add(24*time.Hour)); e != nil {
t.Fatal(e)
}
if _, e := GiveReturneeNPCGift(ctx, repos, r, "a", "7", 36, 354, now.Add(24*time.Hour)); e == nil {
t.Fatal("spent absent cubes")
}
}
func TestNPCGiftBackpackAndFailuresAreAtomic(t *testing.T) {
ctx := context.Background()
r := ReturneeGiftRules{NPC: 2, Level: 20, Count: 100, Limit: 5, MaxPoints: 40000, Points: map[int64][2]int64{3033: {200, 600}}}
now := time.Date(2026, 9, 12, 12, 0, 0, 0, time.UTC)
for _, tc := range []struct {
name string
level int
count, item, used int64
want error
}{
{"success", 37, 100, 3033, 0, nil},
{"too few", 37, 99, 3033, 0, ErrNPCGiftCount},
{"wrong item", 37, 100, 100, 0, ErrNPCGiftItem},
{"low level", 19, 100, 3033, 0, ErrNPCGiftLevel},
{"daily limit", 37, 100, 3033, 5, ErrNPCGiftLimit},
} {
t.Run(tc.name, func(t *testing.T) {
repos := memory.NewMemoryGroup()
ch := dnfrepo.CharacterRecord{AccountID: "a", CharacterID: "7", Level: tc.level, Stats: map[string]int64{"npc_favor_2_day": 20260912, "npc_favor_2_gifts": tc.used, "npc_favor_2_points": 39900}}
if err := repos.Character.Save(ctx, ch); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "7", Slots: map[string]dnfrepo.ItemStack{"0:61": {ItemID: tc.item, Count: tc.count}}}); err != nil {
t.Fatal(err)
}
got, err := GiveReturneeNPCGift(ctx, repos, r, "a", "7", 0, 61, now)
if !errors.Is(err, tc.want) {
t.Fatalf("error=%v want=%v", err, tc.want)
}
inventory, _, _ := repos.Inventory.Load(ctx, "7")
stored, _, _ := repos.Character.Load(ctx, "7")
if tc.want != nil {
if inventory.Slots["0:61"].Count != tc.count || stored.Stats["npc_favor_2_gifts"] != tc.used || stored.Stats["npc_favor_2_points"] != 39900 {
t.Fatal("rejection changed assets")
}
} else if got.Added != 100 || got.Points != 40000 || len(inventory.Slots) != 0 || got.Used != 1 {
t.Fatalf("result=%+v slots=%v", got, inventory.Slots)
}
})
}
}
@@ -0,0 +1,37 @@
package onlineevent
import (
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func TestReturneeStateUsesThirtyDayWindow(t *testing.T) {
start := time.Date(2026, 9, 15, 8, 0, 0, 0, time.UTC)
state := ReturneeState{Started: start, ExpiresAt: start.Add(ReturneeDuration)}
if !state.ActiveAt(start.Add(29*24*time.Hour + 23*time.Hour)) {
t.Fatal("returnee state expired before 30 days")
}
if state.ActiveAt(start.Add(ReturneeDuration)) {
t.Fatal("returnee state remained active at the 30-day boundary")
}
account := dnfrepo.AccountRecord{Metadata: map[string]string{ReturneeMetadataKey: `{"started":"2026-09-15T08:00:00Z"}`}}
loaded, err := LoadReturneeState(account)
if err != nil {
t.Fatal(err)
}
if !loaded.ExpiresAt.Equal(start.Add(ReturneeDuration)) {
t.Fatalf("legacy expiration=%v", loaded.ExpiresAt)
}
}
func TestReturneeActivationStoresExpiration(t *testing.T) {
now := time.Date(2026, 9, 15, 8, 0, 0, 0, time.UTC)
state := ReturneeState{LastSeen: now.Add(-ReturneeOfflinePeriod)}
state.Observe(&ReturneeCatalog{}, "character", ReturneeObservation{}, now, true)
if !state.Started.Equal(now) || !state.ExpiresAt.Equal(now.Add(ReturneeDuration)) {
t.Fatalf("activation window: started=%v expires=%v", state.Started, state.ExpiresAt)
}
}
@@ -0,0 +1,196 @@
package onlineevent
import (
"bytes"
"encoding/json"
"fmt"
"io"
"sort"
"strings"
"time"
)
type creditedInterval struct {
Start uint32 `json:"start"`
End uint32 `json:"end"`
}
type claimReceipt struct {
StageID string `json:"stage_id"`
CharacterID string `json:"character_id"`
Items []CommittedItem `json:"items,omitempty"`
GoldBefore int64 `json:"gold_before,omitempty"`
GoldAfter int64 `json:"gold_after,omitempty"`
ClaimedAt time.Time `json:"claimed_at"`
}
type persistedState struct {
Version int `json:"version"`
EventID string `json:"event_id"`
CalendarDate string `json:"calendar_date,omitempty"`
OnlineSeconds uint64 `json:"online_seconds,omitempty"`
Intervals []creditedInterval `json:"intervals,omitempty"`
Claims map[string]claimReceipt `json:"claims,omitempty"`
}
func newState(eventID string) persistedState {
return persistedState{
Version: stateVersion,
EventID: eventID,
Claims: make(map[string]claimReceipt),
}
}
func parseState(raw string, definition Definition) (persistedState, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return newState(definition.ID), nil
}
decoder := json.NewDecoder(bytes.NewBufferString(raw))
decoder.DisallowUnknownFields()
var state persistedState
if err := decoder.Decode(&state); err != nil {
return persistedState{}, fmt.Errorf("%w: decode: %v", ErrStateInvalid, err)
}
if err := rejectTrailingJSON(decoder); err != nil {
return persistedState{}, err
}
if err := validateState(state, definition); err != nil {
return persistedState{}, err
}
if state.Claims == nil {
state.Claims = make(map[string]claimReceipt)
}
state.Intervals = append([]creditedInterval(nil), state.Intervals...)
for stageID, receipt := range state.Claims {
receipt.Items = cloneCommittedItems(receipt.Items)
state.Claims[stageID] = receipt
}
return state, nil
}
func rejectTrailingJSON(decoder *json.Decoder) error {
var trailing any
err := decoder.Decode(&trailing)
if err == io.EOF {
return nil
}
if err == nil {
return fmt.Errorf("%w: trailing json", ErrStateInvalid)
}
return fmt.Errorf("%w: trailing json: %v", ErrStateInvalid, err)
}
func validateState(state persistedState, definition Definition) error {
if state.Version != stateVersion || state.EventID != definition.ID {
return fmt.Errorf(
"%w: version=%d event=%q",
ErrStateInvalid,
state.Version,
state.EventID,
)
}
if state.CalendarDate == "" {
if state.OnlineSeconds != 0 || len(state.Intervals) != 0 || len(state.Claims) != 0 {
return fmt.Errorf("%w: undated state contains progress", ErrStateInvalid)
}
return nil
}
if _, err := time.ParseInLocation(time.DateOnly, state.CalendarDate, definition.calendar()); err != nil {
return fmt.Errorf("%w: calendar_date=%q", ErrStateInvalid, state.CalendarDate)
}
var total uint64
var previousEnd uint32
for index, interval := range state.Intervals {
if interval.Start >= interval.End || uint64(interval.End) > secondsPerDay ||
(index > 0 && interval.Start <= previousEnd) {
return fmt.Errorf("%w: interval[%d]=%+v", ErrStateInvalid, index, interval)
}
total += uint64(interval.End - interval.Start)
previousEnd = interval.End
}
if total != state.OnlineSeconds {
return fmt.Errorf(
"%w: seconds=%d interval_total=%d",
ErrStateInvalid,
state.OnlineSeconds,
total,
)
}
for stageID, receipt := range state.Claims {
stage, found := definition.stage(stageID)
if !found || receipt.StageID != stageID || strings.TrimSpace(receipt.CharacterID) == "" ||
receipt.ClaimedAt.IsZero() || receipt.GoldBefore < 0 || receipt.GoldAfter < receipt.GoldBefore ||
receipt.GoldAfter-receipt.GoldBefore != stage.Gold || len(receipt.Items) != len(stage.Items) {
return fmt.Errorf("%w: claim=%q", ErrStateInvalid, stageID)
}
claimDate, _ := calendarDay(definition.calendar(), definition.boundary(), receipt.ClaimedAt)
if claimDate != state.CalendarDate {
return fmt.Errorf("%w: claim=%q date=%s", ErrStateInvalid, stageID, claimDate)
}
for index, item := range receipt.Items {
if err := validateCommittedItem(item, stage.Items[index]); err != nil {
return fmt.Errorf("%w: claim=%q item=%d", ErrStateInvalid, stageID, index)
}
}
}
return nil
}
func encodeState(state persistedState) (string, error) {
encoded, err := json.Marshal(state)
if err != nil {
return "", fmt.Errorf("%w: encode: %v", ErrStateInvalid, err)
}
return string(encoded), nil
}
func (s persistedState) snapshot() Snapshot {
return Snapshot{
EventID: s.EventID,
CalendarDate: s.CalendarDate,
OnlineSeconds: s.OnlineSeconds,
ClaimedStageIDs: sortedClaimIDs(s.Claims),
}
}
func (s *persistedState) resetTo(calendarDate string) {
s.CalendarDate = calendarDate
s.OnlineSeconds = 0
s.Intervals = nil
s.Claims = make(map[string]claimReceipt)
}
// creditInterval merges one half-open second interval into the day's union.
// Keeping the compact union makes retries, overlapping sessions, and
// out-of-order server ticks idempotent without trusting a client counter.
func (s *persistedState) creditInterval(start uint32, end uint32) uint64 {
if start >= end {
return 0
}
before := s.OnlineSeconds
intervals := append(append([]creditedInterval(nil), s.Intervals...), creditedInterval{Start: start, End: end})
sort.Slice(intervals, func(left, right int) bool {
if intervals[left].Start == intervals[right].Start {
return intervals[left].End < intervals[right].End
}
return intervals[left].Start < intervals[right].Start
})
merged := make([]creditedInterval, 0, len(intervals))
for _, interval := range intervals {
if len(merged) == 0 || interval.Start > merged[len(merged)-1].End {
merged = append(merged, interval)
continue
}
if interval.End > merged[len(merged)-1].End {
merged[len(merged)-1].End = interval.End
}
}
var total uint64
for _, interval := range merged {
total += uint64(interval.End - interval.Start)
}
s.Intervals = merged
s.OnlineSeconds = total
return total - before
}
@@ -0,0 +1,275 @@
package onlineevent
import (
"errors"
"fmt"
"regexp"
"sort"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
stateVersion = 1
metadataKeyPrefix = "online_event_daily_v1:"
secondsPerDay = uint64(24 * 60 * 60)
)
var (
ErrOwnerUnavailable = errors.New("online-event owner is unavailable")
ErrDefinitionInvalid = errors.New("online-event definition is invalid")
ErrEventInactive = errors.New("online-event is inactive")
ErrObservationInvalid = errors.New("online-event observation is invalid")
ErrObservationStale = errors.New("online-event observation is older than persisted progress")
ErrStateInvalid = errors.New("online-event persisted state is invalid")
ErrAccountNotFound = errors.New("online-event account is not found")
ErrCharacterNotFound = errors.New("online-event character is not found")
ErrAccountMismatch = errors.New("online-event character account mismatch")
ErrInventoryNotFound = errors.New("online-event inventory is not found")
ErrStageNotFound = errors.New("online-event reward stage is not found")
ErrStageLocked = errors.New("online-event reward stage is not yet unlocked")
ErrAllocatorRequired = errors.New("online-event item allocator is required")
ErrRewardInvalid = errors.New("online-event reward is invalid")
ErrGoldOverflow = errors.New("online-event gold reward would overflow")
)
var identifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
// ChinaCalendar is the local DNF profile's UTC+8 event calendar. Callers may
// provide a different fixed/event location in Definition.Calendar.
var ChinaCalendar = time.FixedZone("Asia/Shanghai", 8*60*60)
// DailyBoundary is one local wall-clock service-day boundary. A nil
// Definition.Boundary uses the 86JP ClockService default of 06:00:00. Keeping
// it explicit lets another proved event opt into midnight without overloading
// a zero value as both "unset" and "00:00".
type DailyBoundary struct {
Hour int
Minute int
Second int
}
var defaultDailyBoundary = DailyBoundary{Hour: 6}
// ItemReward is a catalog-resolved item delta. The event domain never invents
// item IDs, category ranges, stack limits, expiry, or raw entries.
type ItemReward struct {
ItemID int64
Count int64
}
type Stage struct {
ID string
RequiredSeconds uint64
Gold int64
Items []ItemReward
}
// Definition is a server-owned activity definition. ActiveFrom is inclusive;
// ActiveUntil is exclusive. Zero bounds mean unbounded. Daily stages must fit
// inside one service day because progress and claims reset at the configured
// local wall-clock boundary (06:00 by default).
type Definition struct {
ID string
Calendar *time.Location
Boundary *DailyBoundary
ActiveFrom time.Time
ActiveUntil time.Time
Stages []Stage
}
func (d Definition) Validate() error {
if !validIdentifier(d.ID) {
return fmt.Errorf("%w: event_id=%q", ErrDefinitionInvalid, d.ID)
}
if !d.ActiveFrom.IsZero() && !d.ActiveUntil.IsZero() && !d.ActiveFrom.Before(d.ActiveUntil) {
return fmt.Errorf("%w: event=%s active range", ErrDefinitionInvalid, d.ID)
}
boundary := d.boundary()
if boundary.Hour < 0 || boundary.Hour > 23 || boundary.Minute < 0 || boundary.Minute > 59 ||
boundary.Second < 0 || boundary.Second > 59 {
return fmt.Errorf("%w: event=%s boundary=%+v", ErrDefinitionInvalid, d.ID, boundary)
}
if len(d.Stages) == 0 {
return fmt.Errorf("%w: event=%s has no stages", ErrDefinitionInvalid, d.ID)
}
seen := make(map[string]struct{}, len(d.Stages))
var previous uint64
for index, stage := range d.Stages {
if !validIdentifier(stage.ID) {
return fmt.Errorf("%w: event=%s stage_id=%q", ErrDefinitionInvalid, d.ID, stage.ID)
}
if _, duplicate := seen[stage.ID]; duplicate {
return fmt.Errorf("%w: event=%s duplicate_stage=%s", ErrDefinitionInvalid, d.ID, stage.ID)
}
seen[stage.ID] = struct{}{}
if stage.RequiredSeconds == 0 || stage.RequiredSeconds > secondsPerDay ||
(index > 0 && stage.RequiredSeconds <= previous) {
return fmt.Errorf(
"%w: event=%s stage=%s required_seconds=%d",
ErrDefinitionInvalid,
d.ID,
stage.ID,
stage.RequiredSeconds,
)
}
previous = stage.RequiredSeconds
if stage.Gold < 0 || (stage.Gold == 0 && len(stage.Items) == 0) {
return fmt.Errorf("%w: event=%s stage=%s empty/negative reward", ErrDefinitionInvalid, d.ID, stage.ID)
}
for _, item := range stage.Items {
if item.ItemID <= 0 || item.Count <= 0 {
return fmt.Errorf(
"%w: event=%s stage=%s item=%d count=%d",
ErrDefinitionInvalid,
d.ID,
stage.ID,
item.ItemID,
item.Count,
)
}
}
}
return nil
}
func (d Definition) calendar() *time.Location {
if d.Calendar != nil {
return d.Calendar
}
return ChinaCalendar
}
func (d Definition) boundary() DailyBoundary {
if d.Boundary != nil {
return *d.Boundary
}
return defaultDailyBoundary
}
func (d Definition) stage(stageID string) (Stage, bool) {
stageID = strings.TrimSpace(stageID)
for _, stage := range d.Stages {
if stage.ID == stageID {
stage.Items = append([]ItemReward(nil), stage.Items...)
return stage, true
}
}
return Stage{}, false
}
func (d Definition) activeAt(at time.Time) bool {
return (d.ActiveFrom.IsZero() || !at.Before(d.ActiveFrom)) &&
(d.ActiveUntil.IsZero() || at.Before(d.ActiveUntil))
}
func validIdentifier(value string) bool {
return value == strings.TrimSpace(value) && identifierPattern.MatchString(value)
}
type ObserveCommand struct {
AccountID string
CharacterID string
Definition Definition
IntervalFrom time.Time
IntervalTo time.Time
}
type ObserveResult struct {
Snapshot Snapshot
CreditedSeconds uint64
Changed bool
}
type StatusCommand struct {
AccountID string
CharacterID string
Definition Definition
ObservedAt time.Time
}
type ClaimCommand struct {
AccountID string
CharacterID string
Definition Definition
StageID string
ClaimedAt time.Time
Allocate ItemAllocator
}
// CommittedItem is the detached post-allocation receipt. RawEntry must be the
// current-client row after the supplied allocator mutated the transaction
// clone; it is retained so an idempotent replay can return the same receipt.
type CommittedItem struct {
SlotKey string `json:"slot_key"`
SlotIndex uint16 `json:"slot_index"`
ItemID int64 `json:"item_id"`
Delta int64 `json:"delta"`
PostCount int64 `json:"post_count"`
RawEntry []byte `json:"raw_entry,omitempty"`
}
type ItemAllocator func(*dnfrepo.InventoryRecord, ItemReward) (CommittedItem, error)
type ClaimResult struct {
EventID string
CalendarDate string
StageID string
CharacterID string
Replayed bool
Items []CommittedItem
GoldBefore int64
GoldAfter int64
ClaimedAt time.Time
PostSnapshot Snapshot
}
type Snapshot struct {
EventID string
CalendarDate string
OnlineSeconds uint64
ClaimedStageIDs []string
}
func (s Snapshot) clone() Snapshot {
s.ClaimedStageIDs = append([]string(nil), s.ClaimedStageIDs...)
return s
}
func sortedClaimIDs(claims map[string]claimReceipt) []string {
ids := make([]string, 0, len(claims))
for stageID := range claims {
ids = append(ids, stageID)
}
sort.Strings(ids)
return ids
}
func cloneCommittedItems(items []CommittedItem) []CommittedItem {
cloned := make([]CommittedItem, len(items))
for index, item := range items {
cloned[index] = item
cloned[index].RawEntry = append([]byte(nil), item.RawEntry...)
}
return cloned
}
func validateCommittedItem(receipt CommittedItem, reward ItemReward) error {
if strings.TrimSpace(receipt.SlotKey) == "" || receipt.ItemID != reward.ItemID ||
receipt.Delta != reward.Count || receipt.PostCount < receipt.Delta {
return fmt.Errorf(
"%w: item=%d count=%d receipt=%+v",
ErrRewardInvalid,
reward.ItemID,
reward.Count,
receipt,
)
}
return nil
}
func metadataKey(eventID string) string {
return metadataKeyPrefix + eventID
}
@@ -0,0 +1,285 @@
package onlineevent
import (
"context"
"encoding/json"
"fmt"
"math/bits"
"time"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const WeeklyPVFPath = "event/chn_event/chn_totalattendance.evt"
const WeeklyMetadataKey = "event_weekly_attendance_20260910_v1"
// The local twelve-week edition starts on Thursday at the DNF reset hour.
func WeeklyStarts() time.Time { return time.Date(2026, 9, 10, 6, 0, 0, 0, ChinaCalendar) }
func WeeklyEnds() time.Time { return WeeklyStarts().AddDate(0, 0, 12*7) }
func WeeklyWeek(now time.Time) int {
if now.Before(WeeklyStarts()) || !now.Before(WeeklyEnds()) {
return -1
}
return int(now.Sub(WeeklyStarts()) / (7 * 24 * time.Hour))
}
type WeeklyCatalog struct {
RequiredClears, CatchupClears, CatchupLimit uint32
Rewards [12]AttendancePVFItemReward
Bonus [3]AttendancePVFItemReward
Thresholds [3]uint32
}
func LoadWeeklyCatalog(ctx context.Context, source dnfpvf.Source) (*WeeklyCatalog, error) {
text, err := source.ReadText(WeeklyPVFPath)
if err != nil {
return nil, err
}
doc, err := dnfpvf.Parse(WeeklyPVFPath, text)
if err != nil {
return nil, err
}
c := &WeeklyCatalog{}
for _, spec := range []struct {
name string
count int
}{
{"event duration", 1}, {"attendance condition", 2}, {"bonus attendance count", 1}, {"reward by the week", 1},
{"attendance week", 36}, {"total attendance week", 9},
} {
v, err := attendanceIntegerSection(doc, spec.name, spec.count)
if err != nil {
return nil, err
}
switch spec.name {
case "event duration":
if v[0] != 12 {
return nil, fmt.Errorf("weekly duration must match twelve native slots")
}
case "attendance condition":
if v[0] < 1 || v[0] > 10000 || v[1] < 1 || v[1] > 10000 {
return nil, fmt.Errorf("weekly clear thresholds invalid")
}
c.RequiredClears, c.CatchupClears = uint32(v[0]), uint32(v[1])
case "bonus attendance count":
if v[0] < 0 || v[0] > 12 {
return nil, fmt.Errorf("weekly catchup limit invalid")
}
c.CatchupLimit = uint32(v[0])
case "reward by the week":
if v[0] != 0 {
return nil, fmt.Errorf("weekly reward mode is not sequential")
}
default:
for i := 0; i < len(v)/3; i++ {
key, id, count := v[i*3], v[i*3+1], v[i*3+2]
if id <= 0 || id > 0xffffffff || count <= 0 || count > 0xffffffff {
return nil, fmt.Errorf("weekly reward invalid")
}
r := AttendancePVFItemReward{ItemID: id, Count: count}
if spec.name == "attendance week" {
if key != int64(i+1) {
return nil, fmt.Errorf("weekly reward index invalid")
}
c.Rewards[i] = r
} else {
if key != []int64{4, 8, 11}[i] {
return nil, fmt.Errorf("weekly bonus threshold differs from native slots")
}
c.Bonus[i], c.Thresholds[i] = r, uint32(key)
}
}
}
}
all := append(append([]AttendancePVFItemReward{}, c.Rewards[:]...), c.Bonus[:]...)
paths, err := resolveAttendanceRewardDefinitions(ctx, source, all)
if err != nil {
return nil, err
}
attachAttendanceDefinitionPaths(c.Rewards[:], paths)
attachAttendanceDefinitionPaths(c.Bonus[:], paths)
return c, nil
}
type weeklyState struct {
Clears [12]uint32 `json:"clears"`
Signed uint16 `json:"signed"`
Catchups uint32 `json:"catchups"`
Bonus uint8 `json:"bonus"`
}
func loadWeeklyState(account dnfrepo.AccountRecord, c *WeeklyCatalog) (weeklyState, error) {
var state weeklyState
if raw := account.Metadata[WeeklyMetadataKey]; raw != "" {
if err := json.Unmarshal([]byte(raw), &state); err != nil {
return state, fmt.Errorf("weekly state invalid: %w", err)
}
}
if state.Signed>>12 != 0 || state.Bonus > 7 || state.Catchups > c.CatchupLimit || state.Catchups > uint32(bits.OnesCount16(state.Signed)) {
return state, fmt.Errorf("weekly state exceeds catalog limits")
}
for _, count := range state.Clears {
if count > c.RequiredClears+c.CatchupClears*c.CatchupLimit {
return state, fmt.Errorf("weekly clear count invalid")
}
}
for i, threshold := range c.Thresholds {
if state.Bonus&(1<<i) != 0 && uint32(bits.OnesCount16(state.Signed)) < threshold {
return state, fmt.Errorf("weekly bonus state invalid")
}
}
return state, nil
}
// Called inside the dungeon settlement receipt transaction, never from a client count.
func RecordWeeklyClear(account *dnfrepo.AccountRecord, c *WeeklyCatalog, now time.Time) error {
week := WeeklyWeek(now)
if week < 0 || c == nil {
return nil
}
state, err := loadWeeklyState(*account, c)
if err != nil {
return err
}
if state.Clears[week] >= c.RequiredClears+c.CatchupClears*c.CatchupLimit {
return nil
}
state.Clears[week]++
raw, err := json.Marshal(state)
if err != nil {
return err
}
if account.Metadata == nil {
account.Metadata = map[string]string{}
}
account.Metadata[WeeklyMetadataKey] = string(raw)
return nil
}
type WeeklySnapshot struct {
Week, Total, Clears, Catchups, BonusMask uint32
SignedThisWeek, CanSign, CanCatchup, CanBonus bool
SignedWeeks [20]byte
MailIDs []string
}
func weeklySnapshot(state weeklyState, c *WeeklyCatalog, week int) WeeklySnapshot {
s := WeeklySnapshot{Total: uint32(bits.OnesCount16(state.Signed)), Catchups: state.Catchups}
for i, threshold := range c.Thresholds {
if state.Bonus&(1<<i) != 0 {
s.BonusMask |= 1 << threshold
} else if s.Total >= threshold && week >= 0 {
s.CanBonus = true
}
}
index := 0
for i := 0; i < 12; i++ {
if state.Signed&(1<<i) != 0 {
s.SignedWeeks[index] = byte(i + 1)
index++
}
}
if week < 0 {
s.Week = 13
return s
}
s.Week = uint32(week + 1)
s.Clears = state.Clears[week]
s.SignedThisWeek = state.Signed&(1<<week) != 0
// Each earlier missing week can be restored once. The current week is signed normally first.
s.CanCatchup = s.SignedThisWeek && s.Total < uint32(week+1) && state.Catchups < c.CatchupLimit
s.CanSign = (!s.SignedThisWeek && s.Clears >= c.RequiredClears) || (s.CanCatchup && s.Clears >= c.CatchupClears)
return s
}
type WeeklyCommand struct {
AccountID, CharacterID string
Now time.Time
Claim, ClaimBonus bool
Deliver func(dnfrepo.MailboxRepository, AttendancePVFItemReward, string) (string, error)
}
func SettleWeeklyAttendance(ctx context.Context, repos dnfrepo.Group, c *WeeklyCatalog, cmd WeeklyCommand) (WeeklySnapshot, error) {
if c == nil || repos.MailboxAssets == nil || cmd.AccountID == "" || cmd.CharacterID == "" || cmd.Now.IsZero() || cmd.Deliver == nil {
return WeeklySnapshot{}, ErrOwnerUnavailable
}
var result WeeklySnapshot
err := repos.MailboxAssets.WithinMailboxAssets(ctx, cmd.CharacterID, cmd.CharacterID, func(accounts dnfrepo.AccountRepository, _ dnfrepo.AccountInventoryRepository, characters dnfrepo.CharacterRepository, _ dnfrepo.InventoryRepository, mailboxes dnfrepo.MailboxRepository) error {
character, found, err := characters.Load(ctx, cmd.CharacterID)
if err != nil {
return err
}
if !found || character.AccountID != cmd.AccountID {
return fmt.Errorf("weekly character ownership mismatch")
}
account, found, err := accounts.Load(ctx, cmd.AccountID)
if err != nil {
return err
}
if !found {
return fmt.Errorf("weekly account missing")
}
state, err := loadWeeklyState(account, c)
if err != nil {
return err
}
week := WeeklyWeek(cmd.Now)
before := weeklySnapshot(state, c, week)
var mails []string
deliver := func(r AttendancePVFItemReward, key string) error {
id, err := cmd.Deliver(mailboxes, r, WeeklyMetadataKey+":"+key)
if err != nil {
return err
}
if id == "" {
return fmt.Errorf("weekly mail missing")
}
mails = append(mails, id)
return nil
}
if cmd.Claim && before.CanSign {
target := week
cost := c.RequiredClears
if before.SignedThisWeek {
for target = 0; target < week; target++ {
if state.Signed&(1<<target) == 0 {
break
}
}
cost = c.CatchupClears
state.Catchups++
}
if err := deliver(c.Rewards[before.Total], fmt.Sprintf("week:%d", target+1)); err != nil {
return err
}
state.Signed |= 1 << target
state.Clears[week] -= cost
}
if cmd.ClaimBonus && week >= 0 {
for i, threshold := range c.Thresholds {
if uint32(bits.OnesCount16(state.Signed)) >= threshold && state.Bonus&(1<<i) == 0 {
if err := deliver(c.Bonus[i], fmt.Sprintf("bonus:%d", threshold)); err != nil {
return err
}
state.Bonus |= 1 << i
}
}
}
result = weeklySnapshot(state, c, week)
result.MailIDs = mails
if len(mails) == 0 {
return nil
}
raw, err := json.Marshal(state)
if err != nil {
return err
}
return dnfrepo.SaveAccountMetadataEntry(ctx, accounts, account, WeeklyMetadataKey, string(raw), cmd.Now)
})
if err != nil {
return WeeklySnapshot{}, err
}
return result, nil
}
@@ -0,0 +1,186 @@
package onlineevent
import (
"context"
"errors"
"sync"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/repository/memory"
)
func weeklyTestCatalog() *WeeklyCatalog {
c := &WeeklyCatalog{RequiredClears: 4, CatchupClears: 20, CatchupLimit: 2, Thresholds: [3]uint32{4, 8, 11}}
for i := range c.Rewards {
c.Rewards[i] = AttendancePVFItemReward{ItemID: int64(100 + i), Count: 1}
}
for i := range c.Bonus {
c.Bonus[i] = AttendancePVFItemReward{ItemID: int64(200 + i), Count: 1}
}
return c
}
func TestWeeklyAttendanceClaimsCatchupsRollbackAndBoundary(t *testing.T) {
ctx := context.Background()
repos := memory.NewMemoryGroup()
c := weeklyTestCatalog()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "a"}); err != nil {
t.Fatal(err)
}
for _, id := range []string{"1", "2"} {
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{AccountID: "a", CharacterID: id}); err != nil {
t.Fatal(err)
}
}
cmd := WeeklyCommand{AccountID: "a", CharacterID: "1", Now: WeeklyStarts().AddDate(0, 0, 21), Claim: true}
deliveryFailure := false
cmd.Deliver = func(mailboxes dnfrepo.MailboxRepository, reward AttendancePVFItemReward, key string) (string, error) {
if deliveryFailure {
return "", errors.New("mail write failed")
}
return dnfrepo.AppendSystemMail(ctx, mailboxes, dnfrepo.SystemMailDelivery{RecipientCharacterID: cmd.CharacterID, Source: "test", CompletionKey: key, Attachments: []dnfrepo.MailAttachment{{ItemID: reward.ItemID, Count: reward.Count}}})
}
clear := func(count int) {
t.Helper()
a, _, err := repos.Account.Load(ctx, "a")
if err != nil {
t.Fatal(err)
}
for range count {
if err := RecordWeeklyClear(&a, c, cmd.Now); err != nil {
t.Fatal(err)
}
}
if err := repos.Account.Save(ctx, a); err != nil {
t.Fatal(err)
}
}
claim := func(total, clears, catchups uint32, mails int) WeeklySnapshot {
t.Helper()
s, err := SettleWeeklyAttendance(ctx, repos, c, cmd)
if err != nil || s.Total != total || s.Clears != clears || s.Catchups != catchups || len(s.MailIDs) != mails {
t.Fatalf("state=%+v err=%v", s, err)
}
return s
}
clear(3)
claim(0, 3, 0, 0)
clear(1)
deliveryFailure = true
if _, err := SettleWeeklyAttendance(ctx, repos, c, cmd); err == nil {
t.Fatal("mail failure committed")
}
mailbox, _, _ := repos.Mailbox.Load(ctx, "1")
if len(mailbox.Mails) != 0 {
t.Fatal("failed mail leaked")
}
deliveryFailure = false
s := claim(1, 0, 0, 1)
if !s.SignedThisWeek || !s.CanCatchup || s.SignedWeeks[0] != 4 {
t.Fatalf("normal week=%+v", s)
}
cmd.CharacterID = "2"
claim(1, 0, 0, 0)
clear(19)
claim(1, 19, 0, 0)
clear(1)
s = claim(2, 0, 1, 1)
if s.SignedWeeks[0] != 1 || s.SignedWeeks[1] != 4 {
t.Fatalf("catchup target=%+v", s)
}
clear(20)
s = claim(3, 0, 2, 1)
if s.CanCatchup || s.SignedWeeks[1] != 2 {
t.Fatalf("catchup limit=%+v", s)
}
clear(100)
claim(3, 44, 2, 0)
cmd.Now = WeeklyStarts().AddDate(0, 0, 28).Add(-time.Nanosecond)
claim(3, 44, 2, 0)
cmd.Now = cmd.Now.Add(time.Nanosecond)
s = claim(3, 0, 2, 0)
if s.Week != 5 || s.SignedThisWeek {
t.Fatalf("weekly reset=%+v", s)
}
clear(4)
claim(4, 0, 2, 1)
cmd.ClaimBonus = true
s = claim(4, 0, 2, 1)
if s.BonusMask != 1<<4 || s.CanBonus {
t.Fatalf("bonus=%+v", s)
}
claim(4, 0, 2, 0)
cmd.AccountID = "other"
if _, err := SettleWeeklyAttendance(ctx, repos, c, cmd); err == nil {
t.Fatal("foreign account accepted")
}
cmd.AccountID = "a"
cmd.Now = WeeklyEnds()
clear(4)
s = claim(4, 0, 2, 0)
if s.Week != 13 || s.CanSign || s.CanCatchup || s.CanBonus {
t.Fatalf("expired edition=%+v", s)
}
if WeeklyWeek(WeeklyStarts().Add(-time.Nanosecond)) != -1 || WeeklyWeek(WeeklyStarts()) != 0 || WeeklyWeek(WeeklyEnds().Add(-time.Nanosecond)) != 11 {
t.Fatal("edition boundary invalid")
}
mailbox, _, _ = repos.Mailbox.Load(ctx, "2")
seen := map[int64]int{}
for _, mail := range mailbox.Mails {
seen[mail.Attachments[0].ItemID]++
}
for _, id := range []int64{101, 102, 103, 200} {
if seen[id] != 1 {
t.Fatalf("sequential reward %d count=%d", id, seen[id])
}
}
}
func TestWeeklyAttendanceConcurrentAccountBonusClaims(t *testing.T) {
ctx := context.Background()
repos := memory.NewMemoryGroup()
c := weeklyTestCatalog()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "a", Metadata: map[string]string{WeeklyMetadataKey: `{"signed":2047}`}}); err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
for _, id := range []string{"1", "2"} {
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{AccountID: "a", CharacterID: id}); err != nil {
t.Fatal(err)
}
}
for _, id := range []string{"1", "2"} {
wg.Add(1)
go func() {
defer wg.Done()
_, err := SettleWeeklyAttendance(ctx, repos, c, WeeklyCommand{AccountID: "a", CharacterID: id, Now: WeeklyStarts().AddDate(0, 0, 70), ClaimBonus: true,
Deliver: func(mailboxes dnfrepo.MailboxRepository, reward AttendancePVFItemReward, key string) (string, error) {
return dnfrepo.AppendSystemMail(ctx, mailboxes, dnfrepo.SystemMailDelivery{RecipientCharacterID: id, Source: "test", CompletionKey: key, Attachments: []dnfrepo.MailAttachment{{ItemID: reward.ItemID, Count: reward.Count}}})
},
})
if err != nil {
t.Error(err)
}
}()
}
wg.Wait()
seen := map[int64]int{}
for _, id := range []string{"1", "2"} {
m, _, _ := repos.Mailbox.Load(ctx, id)
for _, mail := range m.Mails {
seen[mail.Attachments[0].ItemID]++
}
}
for _, id := range []int64{200, 201, 202} {
if seen[id] != 1 {
t.Fatalf("concurrent bonus %d count=%d", id, seen[id])
}
}
a, _, _ := repos.Account.Load(ctx, "a")
state, err := loadWeeklyState(a, c)
if err != nil || weeklySnapshot(state, c, 10).BonusMask != 1<<4|1<<8|1<<11 {
t.Fatalf("bonus flags=%+v err=%v", state, err)
}
}