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,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
|
||||
}
|
||||
Reference in new issue
Block a user