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