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

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

598 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}