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

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

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

No files matched your search

@@ -0,0 +1,597 @@
package dnfbridge
import (
"encoding/binary"
"strconv"
"sync"
"time"
dnfguild "longheng.io/server/internal/modules/dnf/guild"
"longheng.io/server/internal/modules/dnf/premium"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// 公会地下城 (guild dungeon) server side.
//
// The client refuses to enter a guild dungeon entirely on its own:
// sub_3424A10 sees the runtime "is a guild dungeon" byte at +0x909 and asks
// sub_1142E10 whether the stage is unlocked, which reads a registry the server
// has to seed. With that registry empty the client shows dstr 60457
// 「该地下城已锁定, 无法进入。」 and never sends an entry request, so nothing
// server-side can help -- the packet below is the only lever.
//
// opcode 741 (0x2E5) -> sub_1146E70, registered in the same table as op14/op15/
// op33 (sub_2262A00 hands all three registration blocks the same registry), so
// it goes out with classification 0. Body, decoded from the parser:
//
// u8 flag
// u32 v1, u32 v2, u32 v3
// u32 count
// count * (u32 dungeon index, u32 clear count)
//
// The second value is the number of CLEARS, not the gauge. The client derives
// the displayed 净化率 itself as clears * [gauge on clear] / [gauge limit],
// which two live readings pin exactly: sending 1 rendered 1.5% (8500's
// [gauge on clear] is 150 of 10000), and sending 9000 rendered 100% because it
// was taken as nine thousand clears and clamped. The gauge stays the server's
// stored truth -- it is what the PVF describes and what conquest compares
// against -- and is divided back into clears on the wire.
//
// 那个 clears * [gauge on clear] / [gauge limit] 的除法在客户端自己的 PVF 里,
// 服务端改不动。所以"每次通关的净化率翻倍"只能在入账侧做,见
// currentGuildDungeonGaugeClearMultiplier。
const currentGuildDungeonUnlockMsg uint16 = 741
// currentGuildDungeonUnlockFlag is the leading byte. 1 is what makes the client
// accept the table; the three u32 that follow are not understood yet and are
// sent as the widest harmless range, so they cannot narrow anything.
const (
currentGuildDungeonUnlockFlag byte = 1
currentGuildDungeonUnlockV1 uint32 = 20200101
currentGuildDungeonUnlockV2 uint32 = 20991231
currentGuildDungeonUnlockV3 uint32 = 0
)
type currentGuildDungeonCatalogCache struct {
once sync.Once
catalog dnfguild.DungeonCatalog
err error
}
// currentGuildDungeonCatalog parses the PVF layout once per process.
func (s *Service) currentGuildDungeonCatalog() (dnfguild.DungeonCatalog, error) {
s.guildDungeonCatalog.once.Do(func() {
items, err := s.currentPVFItemCatalog()
if err != nil {
s.guildDungeonCatalog.err = err
return
}
s.guildDungeonCatalog.catalog, s.guildDungeonCatalog.err =
dnfguild.LoadDungeonCatalog(items.source)
})
return s.guildDungeonCatalog.catalog, s.guildDungeonCatalog.err
}
// currentGuildDungeonGauges returns the selected character's guild gauges. A
// character with no guild gets nil, which still publishes the stages that have
// no prerequisite -- matching the client, where 伤城 is always open.
func (s *Service) currentGuildDungeonGauges(session *gameSession) (map[uint32]uint32, uint32) {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return nil, 0
}
ctx, cancel := s.currentGuildContext()
defer cancel()
var (
gauges map[uint32]uint32
guildID uint32
)
characterID := strconv.Itoa(int(session.selectedCharacterID))
if err := s.readCurrentGuild(ctx, func(_ dnfrepo.Group, registry dnfguild.Registry) error {
guild, _, found := registry.GuildForCharacter(characterID)
if !found || guild == nil {
return nil
}
guildID = guild.ID
gauges = registry.DungeonGauges(guild.ID)
return nil
}); err != nil {
s.logGameEvent(session, "game-guild-dungeon-gauges-unavailable",
"char_id", session.selectedCharacterID, "error", err)
}
return gauges, guildID
}
func buildCurrentGuildDungeonUnlockBody(
catalog dnfguild.DungeonCatalog,
gauges map[uint32]uint32,
) []byte {
body := make([]byte, 0, 17+8*len(catalog.Stages))
body = append(body, currentGuildDungeonUnlockFlag)
for _, value := range []uint32{
currentGuildDungeonUnlockV1,
currentGuildDungeonUnlockV2,
currentGuildDungeonUnlockV3,
uint32(len(catalog.Stages)),
} {
body = binary.LittleEndian.AppendUint32(body, value)
}
for _, stage := range catalog.Stages {
body = binary.LittleEndian.AppendUint32(body, stage.Index)
body = binary.LittleEndian.AppendUint32(body, currentGuildDungeonClears(stage, gauges[stage.Index]))
}
return body
}
// currentGuildDungeonGaugeClearMultiplier 是一次通关记几份净化槽。
//
// 客户端自己按 clears * [gauge on clear] / [gauge limit] 算净化率,8500 的
// [gauge on clear] 是 10000 里的 150,所以原版一次通关 1.5%。运营要求把这个速度
// 翻倍(2026-09-15),而客户端那个除法在它自己的 PVF 里、服务端改不动,所以只能
// 在**入账**侧做:一次通关记 2×[gauge on clear],于是 currentGuildDungeonClears
// 除回来得到 2,客户端显示 3%。
//
// 这样做的好处是净化槽仍然是服务端的唯一真相:解锁、征服、99%/100% 的公会贡献
// 证书全都按同一个被翻倍的槽值比较,不会出现"显示 3% 但奖励按 1.5% 结算"。
const currentGuildDungeonGaugeClearMultiplier uint32 = 2
// currentGuildDungeonGaugeOnClear 是一次通关实际记入的槽值。
func currentGuildDungeonGaugeOnClear(stage dnfguild.DungeonStage) uint32 {
if stage.GaugeOnClear == 0 {
return 0
}
if stage.GaugeOnClear > ^uint32(0)/currentGuildDungeonGaugeClearMultiplier {
return stage.GaugeOnClear
}
return stage.GaugeOnClear * currentGuildDungeonGaugeClearMultiplier
}
// currentGuildDungeonClears converts a stored gauge into the clear count the
// client expects, clamped to a full bar so an over-credited gauge cannot render
// past 100%.
func currentGuildDungeonClears(stage dnfguild.DungeonStage, gauge uint32) uint32 {
if stage.GaugeOnClear == 0 {
return 0
}
if stage.GaugeLimit != 0 && gauge > stage.GaugeLimit {
gauge = stage.GaugeLimit
}
return gauge / stage.GaugeOnClear
}
// sendCurrentGuildDungeonState publishes the selected character's guild
// progression. Must run after the town player state exists, like every other
// state republish on this route. Failures are logged and swallowed: a guild
// dungeon table must never be able to break a town entry.
func (s *Service) sendCurrentGuildDungeonState(session *gameSession, source string) {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return
}
catalog, err := s.currentGuildDungeonCatalog()
if err != nil {
s.logGameEvent(session, "game-guild-dungeon-catalog-unavailable",
"char_id", session.selectedCharacterID, "source", source, "error", err)
return
}
gauges, guildID := s.currentGuildDungeonGauges(session)
body := buildCurrentGuildDungeonUnlockBody(catalog, gauges)
unlocked := make([]uint32, 0, len(catalog.Stages))
for _, stage := range catalog.Stages {
if catalog.Unlocked(gauges, stage.Index) {
unlocked = append(unlocked, stage.Index)
}
}
s.logGameEvent(session, "game-guild-dungeon-state-send",
"char_id", session.selectedCharacterID,
"source", source,
"guild_id", guildID,
"msg_id", currentGuildDungeonUnlockMsg,
"classification", 0,
"stage_count", len(catalog.Stages),
"unlocked", unlocked,
"gauges", gauges,
"body_len", len(body),
"client_handler", "sub_1146E70")
if err := s.sendGameUpperRawClassCodec(
session, currentGuildDungeonUnlockMsg, body, 0, true,
); err != nil {
s.logGameEvent(session, "game-guild-dungeon-state-send-failed",
"char_id", session.selectedCharacterID, "source", source, "error", err)
}
// op741 carries the per-stage purification only; the reward-box number is a
// separate single-u32 packet the client keeps in the same panel object.
s.sendCurrentGuildDungeonRepeatBonusProgress(session, source)
}
// creditCurrentGuildDungeonClear adds one clear's worth of purification to the
// character's guild. A non-guild-dungeon run, or a character with no guild, is
// a no-op.
//
// The refreshed table reaches the client from the town-return route
// (sendCurrentFullDungeonReturnToTown), which both a cleared run and a forced
// exit go through. This used to say "the next town route" while
// sendCurrentGuildDungeonState was only wired to the initial (character
// selection) route, so a freshly credited gauge - and the stage it unlocked -
// stayed invisible until the player reselected the character.
func (s *Service) creditCurrentGuildDungeonClear(session *gameSession, dungeonID int64, source string) {
if s == nil || session == nil || session.selectedCharacterID == 0 ||
dungeonID <= 0 || dungeonID > int64(^uint32(0)) {
return
}
catalog, err := s.currentGuildDungeonCatalog()
if err != nil {
return
}
stage, found := catalog.Stage(uint32(dungeonID))
if !found || stage.GaugeOnClear == 0 {
return
}
ctx, cancel := s.currentGuildContext()
defer cancel()
characterID := strconv.Itoa(int(session.selectedCharacterID))
weekID := currentGuildDungeonWeekID(s.gameplayNow())
var (
guildID uint32
gauge uint32
weekReset bool
earned []dnfguild.DungeonReward
)
if err := s.mutateCurrentGuild(ctx, func(_ dnfrepo.Group, registry *dnfguild.Registry) error {
guild, _, membership := registry.GuildForCharacter(characterID)
if !membership || guild == nil {
return nil
}
guildID = guild.ID
// Settle the week before crediting, so the first clear of a new week
// starts from an empty bar rather than topping up last week's.
reset, resetErr := registry.ResetDungeonWeek(guild.ID, weekID)
if resetErr != nil {
return resetErr
}
weekReset = reset
previous, settled, addErr := registry.AddDungeonGauge(
guild.ID, stage.Index, currentGuildDungeonGaugeOnClear(stage), stage.GaugeLimit)
if addErr != nil {
return addErr
}
gauge = settled
for _, reward := range stage.RewardsCrossed(previous, settled) {
claimed, claimErr := registry.ClaimDungeonReward(guild.ID, stage.Index, reward.GaugeRatio)
if claimErr != nil {
return claimErr
}
if claimed {
earned = append(earned, reward)
}
}
return nil
}); err != nil {
s.logGameEvent(session, "game-guild-dungeon-clear-credit-failed",
"char_id", session.selectedCharacterID,
"dungeon_id", dungeonID, "source", source, "error", err)
return
}
if guildID == 0 {
return
}
s.logGameEvent(session, "game-guild-dungeon-clear-credited",
"char_id", session.selectedCharacterID,
"guild_id", guildID,
"dungeon_id", stage.Index,
"gauge_on_clear", stage.GaugeOnClear,
"gauge_credited", currentGuildDungeonGaugeOnClear(stage),
"gauge", gauge,
"gauge_limit", stage.GaugeLimit,
"conquest_gauge", stage.ConquestGauge(),
"conquered", gauge >= stage.ConquestGauge(),
"week_id", weekID,
"week_reset", weekReset,
"rewards_earned", len(earned),
"source", source)
for _, reward := range earned {
s.grantCurrentGuildDungeonReward(session, guildID, stage, reward)
}
// 重复通关奖励与净化槽是两套独立的东西:槽只在 99%/100% 发公会贡献证书,
// 礼盒按通关次数每 20 次发一个、没有上限。
s.grantCurrentGuildDungeonRepeatClearBonus(session, guildID, stage.Index)
}
// grantCurrentGuildDungeonReward pays one [gauge ratio] block: the item into
// the character's inventory and the guild contribution into their mileage. The
// claim was already recorded, so a delivery failure is logged loudly rather
// than retried -- silently re-running it would double-pay the whole guild.
func (s *Service) grantCurrentGuildDungeonReward(
session *gameSession,
guildID uint32,
stage dnfguild.DungeonStage,
reward dnfguild.DungeonReward,
) {
if reward.ItemID != 0 && reward.ItemCount != 0 {
if err := s.grantCurrentGuildDungeonRewardItem(session, reward); err != nil {
s.logGameEvent(session, "game-guild-dungeon-reward-item-failed",
"char_id", session.selectedCharacterID,
"guild_id", guildID,
"dungeon_id", stage.Index,
"gauge_ratio", reward.GaugeRatio,
"item_id", reward.ItemID,
"item_count", reward.ItemCount,
"error", err)
}
}
if reward.Contribute != 0 {
if err := s.creditCurrentGuildDungeonContribution(session, reward.Contribute); err != nil {
s.logGameEvent(session, "game-guild-dungeon-reward-contribute-failed",
"char_id", session.selectedCharacterID,
"guild_id", guildID,
"dungeon_id", stage.Index,
"gauge_ratio", reward.GaugeRatio,
"contribute", reward.Contribute,
"error", err)
}
}
s.logGameEvent(session, "game-guild-dungeon-reward-granted",
"char_id", session.selectedCharacterID,
"guild_id", guildID,
"dungeon_id", stage.Index,
"gauge_ratio", reward.GaugeRatio,
"item_id", reward.ItemID,
"item_count", reward.ItemCount,
"contribute", reward.Contribute)
}
// grantCurrentGuildDungeonRewardItem delivers the reward by mail. A cleared run
// ends in a settlement the player may be watching with a full bag, and mail is
// the one delivery path that cannot fail on inventory space.
func (s *Service) grantCurrentGuildDungeonRewardItem(session *gameSession, reward dnfguild.DungeonReward) error {
ctx, cancel := s.currentGuildContext()
defer cancel()
_, err := s.GMSendItemMail(
ctx,
strconv.Itoa(int(session.selectedCharacterID)),
reward.ItemID,
int64(reward.ItemCount),
currentGuildDungeonRewardMailTitle,
currentGuildDungeonRewardMailBody,
)
return err
}
func (s *Service) creditCurrentGuildDungeonContribution(session *gameSession, amount uint32) error {
ctx, cancel := s.currentGuildContext()
defer cancel()
characterID := strconv.Itoa(int(session.selectedCharacterID))
return s.mutateCurrentGuild(ctx, func(_ dnfrepo.Group, registry *dnfguild.Registry) error {
return registry.AddDungeonContribution(characterID, amount, s.gameplayNow())
})
}
const (
currentGuildDungeonRewardMailTitle = "公会地下城"
currentGuildDungeonRewardMailBody = "公会地下城净化奖励。"
)
// 公会地下城奖励礼盒 —— PVF 布局末尾的 [repeat clear bonus reward]。
//
// [repeat clear bonus reward]
// 10156320 1
// [level difference] 10
// [delete item] 10154439
//
// parseDungeonCatalog 只处理 [dungeon] 区块内的键,以及区块外的 final dungeon,
// 所以这一整节从来没有被解析过,全服务端也没有任何路径发放过 10156320 —— 礼盒
// 因此永远拿不到。净化槽(gauge)是另一套东西:它驱动解锁与征服,奖励只在
// [gauge ratio] 99/100 两档发公会贡献证书,与礼盒无关。
//
// 运营给定的规则:**每通关 N 次公会地下城发 1 个礼盒,没有上限**,也就是每通关
// 一次涨 100/N %。计数按角色累计、跨全部公会副本,存在角色 Stats 的溢出字段里
// (与虚弱的恢复时间戳同一种存法),不需要改 schema。
//
// N 在 2026-09-15 由 20 调成 10(一次通关从 5% 变成 10%)。百分比是从 N 算出来
// 的,不是另一个常量,所以改 N 一处就够。旧的 20 是运营从网络搜索来的口径,PVF
// 的 [repeat clear bonus reward] 段里没有任何次数字段,改它不用翻 PVF。
//
// 这里刻意不实现 [level difference] 与 [delete item]:它们的确切语义尚无证据,
// 而按运营规则发放不依赖它们。补齐之前不要假装实现。
const (
currentGuildDungeonRepeatClearBonusItem uint32 = 10156320
currentGuildDungeonRepeatClearsPerBonus int64 = 10
currentGuildDungeonClearCountStat = "guild_dungeon_clear_count"
currentGuildDungeonBonusMailTitle = "公会地下城"
currentGuildDungeonBonusMailBody = "公会地下城重复通关奖励。"
)
// currentGuildDungeonRepeatBonusOwed is how many boxes a counter moving from
// before to after has just completed. Expressed as a difference of block
// indices rather than "after % 20 == 0" so a jump of several clears pays every
// block it crossed and a replayed call pays nothing.
func currentGuildDungeonRepeatBonusOwed(before int64, after int64) int64 {
if before < 0 {
before = 0
}
if after <= before {
return 0
}
return after/currentGuildDungeonRepeatClearsPerBonus - before/currentGuildDungeonRepeatClearsPerBonus
}
// currentGuildDungeonRepeatBonusPercent is the progress toward the next box:
// one clear of currentGuildDungeonRepeatClearsPerBonus is 100/N %, so with
// N=10 it steps 0,10,...,90. A completed block reads 0 again, which is the same
// thing the box grant reports on that clear.
func currentGuildDungeonRepeatBonusPercent(count int64) int64 {
if count <= 0 {
return 0
}
return count % currentGuildDungeonRepeatClearsPerBonus * 100 / currentGuildDungeonRepeatClearsPerBonus
}
// currentGuildDungeonRepeatBonusMsg (op742) is the packet the reward-box
// progress is drawn from. Found by disassembly 2026-09-15:
//
// - op742 registers two handlers like op741 does: sub_11470F0 on the guild
// dungeon table and sub_1D0D380 as the failure/notice dispatcher.
// - sub_11470F0 reads **exactly one u32** and stores it at panel+0x40
// (0x517E380 = 0x517E340 + 0x40); it reads nothing else.
// - 0x011457DA loads that field, formats it and draws it immediately before
// 0x01145851 places the 公会地下城奖励礼盒 icon (item 10156320) - i.e. it is
// the number beside the box, the bar the player sees inside the dungeon.
//
// The server never sent op742, so that number stayed at its initial 0 no matter
// how many clears were credited. op741 could not carry it: its body is fully
// consumed by sub_1146E70 (u8 + 4×u32 header, then exactly two u32 per stage).
const currentGuildDungeonRepeatBonusMsg uint16 = 742
// sendCurrentGuildDungeonRepeatBonusProgress publishes the box progress.
//
// Units pinned by live reading 2026-09-15: the value is a **0..100 percentage**.
// Sending the raw clears-toward-next-box (19 of 20) rendered the in-dungeon bar
// at roughly 19%, not the 95% a count-of-twenty would have drawn, so the client
// takes the u32 as a percent directly. The format string itself stays unreadable
// statically (obfuscated, resolved through sub_34D9B20), which is why this was
// settled by A/B instead.
func (s *Service) sendCurrentGuildDungeonRepeatBonusProgress(session *gameSession, source string) {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return
}
count := s.currentGuildDungeonClearCount(session)
toward := count % currentGuildDungeonRepeatClearsPerBonus
sent := currentGuildDungeonRepeatBonusPercent(count)
body := binary.LittleEndian.AppendUint32(make([]byte, 0, 4), uint32(sent))
s.logGameEvent(session, "game-guild-dungeon-repeat-bonus-send",
"char_id", session.selectedCharacterID,
"source", source,
"msg_id", currentGuildDungeonRepeatBonusMsg,
"classification", 0,
"clear_count", count,
"sent_value", sent,
"candidate_clears_toward_bonus", toward,
"candidate_percent", currentGuildDungeonRepeatBonusPercent(count),
"client_handler", "sub_11470F0")
if err := s.sendGameUpperRawClassCodec(
session, currentGuildDungeonRepeatBonusMsg, body, 0, true,
); err != nil {
s.logGameEvent(session, "game-guild-dungeon-repeat-bonus-send-failed",
"char_id", session.selectedCharacterID, "source", source, "error", err)
}
}
// currentGuildDungeonClearCount reads the stored guild dungeon clear counter.
func (s *Service) currentGuildDungeonClearCount(session *gameSession) int64 {
repositories, ok := s.repositoryGroup()
if !ok || repositories.Character == nil {
return 0
}
ctx, cancel := s.currentGuildContext()
defer cancel()
character, found, err := repositories.Character.Load(ctx, strconv.Itoa(int(session.selectedCharacterID)))
if err != nil || !found || character.Stats == nil {
return 0
}
count := character.Stats[currentGuildDungeonClearCountStat]
if count < 0 {
return 0
}
return count
}
// grantCurrentGuildDungeonRepeatClearBonus counts one more guild dungeon clear
// for this character and mails one 公会地下城奖励礼盒 for every completed block
// of currentGuildDungeonRepeatClearsPerBonus. The counter only ever moves
// forward, so the number of boxes owed is the difference between the old and
// the new block index -- a server that was down for several clears cannot
// double-pay, and a single call cannot skip a box either.
func (s *Service) grantCurrentGuildDungeonRepeatClearBonus(session *gameSession, guildID uint32, dungeonID uint32) {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.CharacterAssets == nil {
return
}
ctx, cancel := s.currentGuildContext()
defer cancel()
characterID := strconv.Itoa(int(session.selectedCharacterID))
now := s.gameplayNow().UTC()
var before, after int64
err := repositories.CharacterAssets.WithinCharacterAssets(
ctx,
characterID,
func(
characters dnfrepo.CharacterRepository,
_ dnfrepo.InventoryRepository,
_ dnfrepo.EquipmentRepository,
) error {
character, found, loadErr := characters.Load(ctx, characterID)
if loadErr != nil {
return loadErr
}
if !found {
return nil
}
character = dnfrepo.CloneCharacter(character)
if character.Stats == nil {
character.Stats = make(map[string]int64, 1)
}
before = character.Stats[currentGuildDungeonClearCountStat]
if before < 0 {
before = 0
}
after = before + 1
character.Stats[currentGuildDungeonClearCountStat] = after
character.UpdatedAt = now
return characters.Save(ctx, character)
},
)
if err != nil {
s.logGameEvent(session, "game-guild-dungeon-repeat-bonus-failed",
"char_id", session.selectedCharacterID,
"guild_id", guildID, "dungeon_id", dungeonID, "error", err)
return
}
if after == 0 {
return
}
owed := currentGuildDungeonRepeatBonusOwed(before, after)
s.logGameEvent(session, "game-guild-dungeon-repeat-bonus-progress",
"char_id", session.selectedCharacterID,
"guild_id", guildID,
"dungeon_id", dungeonID,
"clear_count", after,
"clears_per_bonus", currentGuildDungeonRepeatClearsPerBonus,
"progress_percent", currentGuildDungeonRepeatBonusPercent(after),
"bonus_owed", owed)
for index := int64(0); index < owed; index++ {
if _, err := s.GMSendItemMail(
ctx,
characterID,
currentGuildDungeonRepeatClearBonusItem,
1,
currentGuildDungeonBonusMailTitle,
currentGuildDungeonBonusMailBody,
); err != nil {
// The counter is already committed, so a delivery failure is logged
// loudly rather than retried: re-running it would pay twice.
s.logGameEvent(session, "game-guild-dungeon-repeat-bonus-delivery-failed",
"char_id", session.selectedCharacterID,
"guild_id", guildID,
"dungeon_id", dungeonID,
"clear_count", after,
"item_id", currentGuildDungeonRepeatClearBonusItem,
"error", err)
return
}
s.logGameEvent(session, "game-guild-dungeon-repeat-bonus-granted",
"char_id", session.selectedCharacterID,
"guild_id", guildID,
"dungeon_id", dungeonID,
"clear_count", after,
"item_id", currentGuildDungeonRepeatClearBonusItem)
}
}
// currentGuildDungeonWeekID is the server's existing weekly boundary (Monday
// 06:00), shared with the altar's own weekly records rather than invented here.
// The layout's [weekly reward] block names 8508 as the cycle's target but
// carries no period of its own, so the boundary is the server's, not a new one.
func currentGuildDungeonWeekID(now time.Time) int64 {
return (premium.ServiceDayID(now) + 3) / 7
}