DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user