Files
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

914 lines
39 KiB
Go
Raw Permalink 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.
// weakness_state.go owns the weakness (虚弱) penalty a forced
// dungeon exit applies, the way that penalty wears off, and the server-side
// rule for who is exempt.
//
// State. The penalty is a level in character stat `stamina`, 0 = healthy. The
// column already existed (TINYINT UNSIGNED) and was already projected into the
// select-character userinfo tail, but nothing in this server had ever written
// it, so a force quit carried no penalty at all. The client models the same
// value as a 0..100 condition; the EXE compares it against 100 everywhere
// (sub_30C6B10 `cmp eax, 0x64` and nine other sites) and 100 means healthy.
//
// Shape of the penalty, per operator decision 2026-09-12: a forced exit applies
// it whole (currentWeaknessForcedReturnLevel, a 19% health cap) rather than one
// level at a time, and it is then worked off by time, one level per
// currentWeaknessRecoveryInterval. The paid/contract 解除虚弱 NPC recovery
// clears whatever is left outright.
//
// Exemption. 黑钻 is a passive account subscription: while it is active the
// penalty is skipped and nothing is consumed. 魔王契約 is the selectable
// devil-contract perk in slot 5 (premium.DevilSlotFreeWeakness); it is no longer
// an exemption for a forced exit at all - the server only ever spends that quota
// when the player picks the contract button in the recovery UI (op9), so a
// give-up can never burn a daily use the player never chose to pay with. Rooms
// on the currentWeaknessExemptDungeonIDs whitelist (修炼场 5000、转职体验「领悟时间」
// 7142), the op16 practice protocol mode and PVP channels never weaken at all.
//
// Superseded 2026-09-11/12: this header used to claim the condition had "a
// single write site that always stores 100", so a stored penalty could never be
// rendered. That is wrong. The client renders the stored level as the health
// cap, and class-0 opcode 33 publishes any 0..100 value on demand - see
// currentWeaknessConditionMsg for the three write sites and which to use.
//
// Known client limit, unchanged: this EXE draws no dedicated weakness HUD
// widget. What the player sees is the reduced health cap itself.
package dnfbridge
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/premium"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfweaknesspotion "longheng.io/server/internal/modules/dnf/weaknesspotion"
"longheng.io/server/internal/modules/dnf/worldmap"
)
const (
// currentWeaknessStatKey is the character stat the penalty level lives in.
currentWeaknessStatKey = "stamina"
// currentWeaknessMaximumLevel matches the client's own clamp: the condition
// value bottoms out at 100 - 9*10 = 10.
currentWeaknessMaximumLevel int64 = 10
// currentWeaknessConditionStep is the condition lost per penalty level.
currentWeaknessConditionStep int64 = 9
// currentWeaknessHealthyValue is the condition the client treats as healthy.
currentWeaknessHealthyValue int64 = 100
// currentWeaknessForcedReturnLevel is what one forced exit costs. Operator
// decision 2026-09-12: the penalty lands at its full weight immediately -
// 9 levels, i.e. a 19% health cap - and is then worked off by time, rather
// than creeping down 9 points per give-up. Recovery, not repetition, is
// what varies the number now.
currentWeaknessForcedReturnLevel int64 = 9
// currentWeaknessRecoveryInterval is how long one penalty level takes to
// wear off on its own. Operator decision 2026-09-14: one level per minute
// (was five), so a forced exit's 9 levels clear in 9 minutes and even the
// 10-level ceiling clears in 10 - no penalty can outlast ten minutes. Until
// recovery existed the penalty only ever went up - the NPC recovery was the
// single way down - which is how a test character reached level 9 (19%
// health) purely by abandoning six dungeon runs.
currentWeaknessRecoveryInterval = time.Minute
// currentWeaknessRecoveryAtStatKey holds the **在线秒**(不是 unix 秒)当前这一级
// 开始倒计时的位置。用在线秒而不是墙上时钟,是因为虚弱计时在离线期间必须冻结
// (运营要求 2026-09-15)。它是 overflow stat(dnf_character_stats),不需要改表结构。
currentWeaknessRecoveryAtStatKey = "weakness_recovery_at"
// currentWeaknessOnlineSecondsStatKey 累计该角色**已结清**的真实在线秒数,
// 是虚弱恢复唯一的时钟来源。离线时段不计入,所以"离线时计时锁定、上线继续走"。
currentWeaknessOnlineSecondsStatKey = "weakness_online_seconds"
// currentWeaknessOnlineWindowStatKey 是"本次在线窗口的墙上起点"。离线时被清零
// (见 stopCurrentWeaknessOnlineClock),于是离线期间在线秒不会前进;
// 老存档没有这个键(=0)时在线秒也不前进,惩罚不会被凭空勾销。
currentWeaknessOnlineWindowStatKey = "weakness_online_window_start"
)
// Reason codes the current EXE's op9 failure branch (sub_1D11900) decodes.
const (
currentWeaknessReasonNotWeakened byte = 0x12
currentWeaknessReasonCannotAfford byte = 0x16
)
var (
errCurrentWeaknessUnavailable = errors.New("current weakness runtime is unavailable")
errCurrentWeaknessNotWeakened = errors.New("current character carries no weakness")
errCurrentWeaknessCannotPay = errors.New("current character cannot pay the weakness recovery cost")
)
// currentWeaknessExemption records which rule spared a forced exit, for the log
// and for the tests that pin the precedence.
type currentWeaknessExemption string
const (
currentWeaknessExemptionNone currentWeaknessExemption = "none"
currentWeaknessExemptionBlackDiamond currentWeaknessExemption = "black_diamond"
// currentWeaknessExemptionDevilSlot is the player-requested op9 contract
// route, not a forced-exit exemption: the quota is spent only when the
// player presses the contract button in the 解除虚弱 UI.
currentWeaknessExemptionDevilSlot currentWeaknessExemption = "devil_contract_slot5"
// currentWeaknessExemptionPotItem is the 防虚弱药剂 route. The potion's buff
// lives in the client only - a live capture shows the use arrives as the
// ordinary class-1/op44 request and no later packet carries the state - so
// the server credits the durable immunity window the inventory owner writes
// when one of the family's 70 usable items is consumed.
currentWeaknessExemptionPotItem currentWeaknessExemption = "weakness_potion_item"
// currentWeaknessExemptionPotHeld is the passive variant (十周年防虚弱青石):
// merely carrying it in the inventory already prevents the penalty, so no
// use and no window are involved.
currentWeaknessExemptionPotHeld currentWeaknessExemption = "weakness_potion_held_item"
)
// currentWeaknessGoldSource names where a paid 解除虚弱 actually took its gold
// from, so the caller can refresh the matching client-side wallet.
type currentWeaknessGoldSource string
const (
currentWeaknessGoldSourceNone currentWeaknessGoldSource = "none"
// currentWeaknessGoldSourceCharacter is the authoritative store on this
// server: gold is the character's own `gold` stat, and that column is what
// sendSelectedMainWalletsRefresh projects into main-list slot 0.
currentWeaknessGoldSourceCharacter currentWeaknessGoldSource = "character_gold_stat"
// currentWeaknessGoldSourceLegacySlot is the main-list slot-0 inventory row
// this file used to charge. Nothing else in this tree reads or writes that
// row as a wallet, so it is honoured only when the character column cannot
// cover the price: a deployment still carrying such a row keeps paying
// instead of being answered "cannot afford".
currentWeaknessGoldSourceLegacySlot currentWeaknessGoldSource = "legacy_main_list_slot0_wallet_row"
)
// currentWeaknessLevel reads the stored penalty level, clamped to the range the
// client can render.
func currentWeaknessLevel(character dnfrepo.CharacterRecord) int64 {
if character.Stats == nil {
return 0
}
level := character.Stats[currentWeaknessStatKey]
if level < 0 {
return 0
}
if level > currentWeaknessMaximumLevel {
return currentWeaknessMaximumLevel
}
return level
}
// startCurrentWeaknessOnlineClock 在某角色刚上线(被选中)时调用一次:
// 结算上一次在线窗口的秒数,并把"本次在线窗口起点"设为 now。
//
// 离线时段因此不出现在任何一项里:虚弱恢复只累加真实在线窗口。
// 调用是幂等的 —— 已经在线的角色重复调用只会把窗口起点往后推,最多丢一点秒数。
func (s *Service) startCurrentWeaknessOnlineClock(session *gameSession) {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Character == nil {
return
}
characterID := strconv.Itoa(int(session.selectedCharacterID))
now := s.gameplayNow()
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
character, found, err := repositories.Character.Load(ctx, characterID)
if err != nil || !found {
return
}
character = dnfrepo.CloneCharacter(character)
if character.Stats == nil {
character.Stats = make(map[string]int64)
}
if currentWeaknessOnlineWindowStart(character) == 0 {
// 窗内累计秒 + 起点:两者一起落库,"上次在线到哪一刻"就持久了。
character.Stats[currentWeaknessOnlineSecondsStatKey] = currentWeaknessOnlineSeconds(character, now)
character.Stats[currentWeaknessOnlineWindowStatKey] = now.Unix()
character.UpdatedAt = now
if err := repositories.Character.Save(ctx, character); err != nil {
s.logGameEvent(session, "game-weakness-online-clock-start-failed",
"char_id", characterID, "error", err)
return
}
}
s.logGameEvent(session, "game-weakness-online-clock-started",
"char_id", characterID,
"online_seconds", character.Stats[currentWeaknessOnlineSecondsStatKey],
"window_start", character.Stats[currentWeaknessOnlineWindowStatKey])
}
// stopCurrentWeaknessOnlineClock 在角色下线时调用:把本次窗口的秒数结清并清掉窗口标记,
// 这样离线期间再有任何读路径调用 settle,在线秒都不会前进。
func (s *Service) stopCurrentWeaknessOnlineClock(session *gameSession) {
if s == nil || session == nil {
return
}
characterID := ""
if session.selectedCharacterID != 0 {
characterID = strconv.Itoa(int(session.selectedCharacterID))
}
repositories, ok := s.repositoryGroup()
if characterID == "" || !ok || repositories.Character == nil {
return
}
now := s.gameplayNow()
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
character, found, err := repositories.Character.Load(ctx, characterID)
if err != nil || !found {
return
}
character = dnfrepo.CloneCharacter(character)
if character.Stats == nil {
character.Stats = make(map[string]int64)
}
// 结算前先算总量(= 已结清 + 本次窗口),再清窗口标记,顺序不能反。
total := currentWeaknessOnlineSeconds(character, now)
character.Stats[currentWeaknessOnlineSecondsStatKey] = total
character.Stats[currentWeaknessOnlineWindowStatKey] = 0
character.UpdatedAt = now
if err := repositories.Character.Save(ctx, character); err != nil {
s.logGameEvent(session, "game-weakness-online-clock-stop-failed",
"char_id", characterID, "error", err)
return
}
s.logGameEvent(session, "game-weakness-online-clock-stopped",
"char_id", characterID,
"online_seconds", total,
"weakness_level", currentWeaknessLevel(character))
}
// currentWeaknessOnlineWindowStart 读"本次在线窗口起点":先看进程内,再看持久化的 stat。
func currentWeaknessOnlineWindowStart(character dnfrepo.CharacterRecord) int64 {
if character.Stats == nil {
return 0
}
return character.Stats[currentWeaknessOnlineWindowStatKey]
}
// currentWeaknessOnlineSeconds 返回该角色"到目前为止"的累计在线秒。
//
// = 已结清窗口的累计秒 + 本次在线窗口里流逝的秒数。离线时段不在任何一项里,
// 所以离线时这个值不动 —— 这正是"离线锁定计时"的实现方式。
// 窗口起点缺失(老存档、或角色不在线)时只返回已累计值:既不发奖励,也不勾销惩罚。
func currentWeaknessOnlineSeconds(character dnfrepo.CharacterRecord, now time.Time) int64 {
if character.Stats == nil {
return 0
}
stored := character.Stats[currentWeaknessOnlineSecondsStatKey]
if stored < 0 {
stored = 0
}
start := currentWeaknessOnlineWindowStart(character)
if start <= 0 || now.Unix() <= start {
return stored
}
return stored + (now.Unix() - start)
}
// settleCurrentWeaknessRecovery ages a stored penalty forward to `now` and
// reports the settled level plus whether the record actually changed.
//
// **时钟是在线秒,不是墙上时钟**:离线时段不计入恢复(运营要求 2026-09-15:
// "角色不在线就锁定当前虚弱计时,上线才继续")。因此它不再"边离线边恢复",
// 但仍然对服务器重启安全 —— 累计在线秒存在角色 stat 里,重启后继续累加。
//
// The leftover remainder is carried (the clock advances by whole intervals
// only), otherwise every settle would restart the countdown and a character
// that is looked at often would never recover.
func settleCurrentWeaknessRecovery(character *dnfrepo.CharacterRecord, now time.Time) (int64, bool) {
if character == nil {
return 0, false
}
if character.Stats == nil {
character.Stats = make(map[string]int64)
}
level := currentWeaknessLevel(*character)
if level <= 0 {
// A healthy character carries no countdown. Drop a stale marker so the
// next penalty starts its own clock from scratch.
if _, found := character.Stats[currentWeaknessRecoveryAtStatKey]; found {
delete(character.Stats, currentWeaknessRecoveryAtStatKey)
return 0, true
}
return 0, false
}
interval := int64(currentWeaknessRecoveryInterval / time.Second)
// 在线秒是唯一的时钟:先把"已结清窗口 + 本次在线窗口"算出来并落库,
// 再拿它和倒计时起点比较。
online := currentWeaknessOnlineSeconds(*character, now)
if online < 0 {
online = 0
}
character.Stats[currentWeaknessOnlineSecondsStatKey] = online
startedAt, present := character.Stats[currentWeaknessRecoveryAtStatKey]
// 倒计时锚点的语义:
// 缺失 -> 没有在跑的倒计时(惩罚等级为 0 时就是这样,见下);
// -1 -> 惩罚刚下、还没有锚点,从当前在线秒起算;
// 其它 -> 在线秒位置。老存档里可能是 unix 秒(远大于在线秒),
// 那不能当成"已经过了很久",重锚到当前在线秒而不是免罚。
if !present {
return level, false
}
if startedAt < 0 || startedAt > online {
character.Stats[currentWeaknessRecoveryAtStatKey] = online
return level, true
}
steps := (online - startedAt) / interval
if steps <= 0 {
return level, false
}
// 一次结算最多掉一级:设计口径是"每在线一分钟掉一级"(currentWeaknessRecoveryInterval),
// 而 (online-startedAt) 的两端取整差可能刚好凑出两个 interval(实测 2026-09-15:
// 8 -> 6 一跳两级)。多出来的余量不会浪费 —— 下次结算按同一公式自然补上。
if steps > 1 {
steps = 1
}
if steps > level {
steps = level
}
level -= steps
character.Stats[currentWeaknessStatKey] = level
if level == 0 {
// 恢复完成:删掉倒计时,下一次惩罚重新起算。
delete(character.Stats, currentWeaknessRecoveryAtStatKey)
} else {
character.Stats[currentWeaknessRecoveryAtStatKey] = startedAt + steps*interval
}
return level, true
}
// currentWeaknessConditionMsg is the class-0 notification that publishes the
// health cap on its own.
//
// Found by disassembly on 2026-09-11, which also corrects this file's original
// claim that the condition had "a single write site that always stores 100".
// Three sites write the local player's condition setter (vtable+0xAD4):
//
// - sub_1D11820, the op9 success ACK, with a hardcoded 0x64. That is the only
// one the server had been using, so the client could only ever be told
// "fully healthy" - a partial value was unsendable.
// - sub_1D7FA40, class-0 opcode 4. Reads one byte and sets it. Shows the
// "recovered" message when the byte is exactly 0x64, nothing otherwise.
// - sub_1D3A260, class-0 opcode 33, used here. Also reads one byte, but when
// the byte is not 0x64 it first runs the weakened-state presentation
// (0x1D3A289..0x1D3A307) before setting the value, and at exactly 0x64 it
// jumps straight to the set. So it both shows the penalty properly and
// clears it quietly - op4 can do neither half as well.
//
// Opcode 33 is also what 86JPPP sends (GamePacketEnvelopeBuilder.Build(0x00,
// 0x0021, {100}) in its StaminaHandler), so this one notification id really
// does carry across from 86JP to this 90CN client - which is not the general
// rule for notification ids and is worth remembering as the exception.
//
// Class 0 is populated by three registration functions (sub_2262A00 calls
// sub_1D927E0, sub_1D50DB0 and sub_1D7E7D0 on one table), which is why a scan
// of the block owning op14/op15 alone shows class 0 skipping from 1 to 9 and
// misses both of these.
const currentWeaknessConditionMsg uint16 = 33
// sendCurrentWeaknessConditionRefresh publishes the character's current health
// cap. Must be called after the town player-state rebuild, never before it.
// It also re-arms the recovery tick, so a weakened player standing still in
// town keeps seeing the cap move.
func (s *Service) sendCurrentWeaknessConditionRefresh(session *gameSession, source string) error {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return nil
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Character == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), currentRepositorySnapshotTimeout)
defer cancel()
character, found, err := repositories.Character.Load(ctx, strconv.Itoa(int(session.selectedCharacterID)))
if err != nil || !found {
s.logGameEvent(session, "game-weakness-condition-refresh-skipped",
"char_id", session.selectedCharacterID, "source", source, "found", found, "error", err)
return nil
}
// Age the penalty forward first so the value published is the one the
// player has actually recovered to, then persist it.
changed := s.settleWeaknessForProjection(ctx, repositories, session, &character)
level := currentWeaknessLevel(character)
// A healthy character that did not just become healthy needs no packet at
// all; publish a penalty, or the one push that clears a penalty.
if level == 0 && !changed {
return nil
}
condition := currentWeaknessCondition(level)
s.logGameEvent(session, "game-weakness-condition-refresh-send",
"char_id", session.selectedCharacterID,
"source", source,
"weakness_level", level,
"client_condition", condition,
"msg_id", currentWeaknessConditionMsg,
"classification", 0,
"client_handler", "sub_1D3A260")
if err := s.sendGameUpperRawClassCodec(
session, currentWeaknessConditionMsg, []byte{byte(condition)}, 0, true,
); err != nil {
return err
}
s.armCurrentWeaknessRecoveryTimer(session, character, level, source)
return nil
}
// currentWeaknessRecoveryTimerName keys the per-character recovery tick.
func currentWeaknessRecoveryTimerName(characterID uint16) string {
return "weakness-recovery:" + strconv.Itoa(int(characterID))
}
// currentWeaknessRecoveryDue is how long until the next level wears off, given
// the countdown the settle left behind. A record without a countdown owes a
// full interval.
func currentWeaknessRecoveryDue(character dnfrepo.CharacterRecord, now time.Time) time.Duration {
startedAt := int64(0)
if character.Stats != nil {
startedAt = character.Stats[currentWeaknessRecoveryAtStatKey]
}
if startedAt <= 0 || now.Unix() < startedAt {
return currentWeaknessRecoveryInterval
}
remainder := (time.Duration(now.Unix()-startedAt) * time.Second) % currentWeaknessRecoveryInterval
due := currentWeaknessRecoveryInterval - remainder
if due <= 0 {
return currentWeaknessRecoveryInterval
}
return due
}
// armCurrentWeaknessRecoveryTimer keeps the health cap moving while the player
// is just standing in town. Recovery itself is lazy and was already correct --
// the stored level ages on read -- but nothing republished it between rebuilds,
// so a player watching their own bar saw it frozen until they re-selected a
// character. A healthy character cancels the tick instead of re-arming.
func (s *Service) armCurrentWeaknessRecoveryTimer(
session *gameSession,
character dnfrepo.CharacterRecord,
level int64,
source string,
) {
if s == nil || s.gameplayTimers == nil || session == nil || session.selectedCharacterID == 0 {
return
}
characterID := session.selectedCharacterID
name := currentWeaknessRecoveryTimerName(characterID)
s.gameplayTimers.Cancel(name)
if level <= 0 {
return
}
due := currentWeaknessRecoveryDue(character, s.gameplayNow())
if err := s.gameplayTimers.ScheduleAfter(name, due, func(time.Time) {
// Re-resolve the session: the character may have logged out, switched
// or reconnected since this was armed.
live, online := s.onlineGameSession(characterID)
if !online || live == nil || live.selectedCharacterID != characterID {
return
}
// Settles, persists, publishes, and re-arms itself while the penalty
// lasts.
if err := s.sendCurrentWeaknessConditionRefresh(live, "weakness_recovery_tick"); err != nil {
s.logGameEvent(live, "game-weakness-recovery-tick-failed",
"char_id", characterID, "error", err)
}
}); err != nil {
s.logGameEvent(session, "game-weakness-recovery-timer-failed",
"char_id", characterID, "source", source, "error", err)
}
}
// settleWeaknessForProjection ages the penalty forward and persists it right
// before subtype0 publishes it. A persistence failure is logged and swallowed:
// the character has still recovered as far as the caller's copy says, and a
// bookkeeping error must not block character selection.
func (s *Service) settleWeaknessForProjection(
ctx context.Context,
repositories dnfrepo.Group,
session *gameSession,
character *dnfrepo.CharacterRecord,
) bool {
if s == nil || character == nil || repositories.Character == nil {
return false
}
before := currentWeaknessLevel(*character)
level, changed := settleCurrentWeaknessRecovery(character, s.gameplayNow())
if !changed {
return false
}
character.UpdatedAt = s.gameplayNow()
if err := repositories.Character.Save(ctx, *character); err != nil {
if session != nil {
s.logGameEvent(session, "game-weakness-recovery-persist-failed",
"char_id", character.CharacterID, "error", err)
}
return level != before
}
if session != nil && level != before {
s.logGameEvent(session, "game-weakness-recovered-over-time",
"char_id", character.CharacterID,
"previous_level", before,
"weakness_level", level,
"client_condition", currentWeaknessCondition(level),
"interval_seconds", int64(currentWeaknessRecoveryInterval/time.Second))
}
return level != before
}
// currentWeaknessCondition converts a penalty level into the 0..100 condition
// the client compares against 100.
func currentWeaknessCondition(level int64) int64 {
condition := currentWeaknessHealthyValue - level*currentWeaknessConditionStep
if condition < 0 {
return 0
}
return condition
}
// currentWeaknessExemptDungeonIDs is the room whitelist: leaving one of these,
// however abruptly, never weakens. The value is the rule name reported in the
// log, so an operator can tell which room's arm spared the run without
// cross-checking the dungeon id.
//
// 修炼场 (PoongjinTrainingRoom, dungeon 5000) is the case this exists for.
// The town-return path already carried a comment saying the practice room must
// never weaken, but it guarded on currentDungeonRuntimeIsPractice, which is the
// op16 *protocol* mode (state 1 + token 0), not the room. The training room's
// own direct-entry button sends state 1 + token 0xFFFF, which
// currentDungeonProtocolModeForWire deliberately maps back to normal mode - so
// the guard never fired for the very room it named, and every visit to 修炼场
// cost a 19% health cap on the way out.
//
// The room carries no PVF flag to key this off: its .dgn has no fatigue,
// prohibit-practice or free-room section, so the identity has to be the
// dungeon id.
//
// 转职体验「领悟时间」(dungeon 7142) joined the whitelist 2026-09-17. It shares
// 修炼场's direct-entry button shape, and leaving it is an ordinary forced exit:
// 结束体验 walks the client's own give-up route back to town (bodyless op42),
// the run commits as abandoned, and the trial - the one room a player is
// supposed to try a class in for free - charged the full 19% health cap. Live
// evidence from the same day's packet log, right after a 14-second visit:
// `char_id=4 dungeon_id=7142 exemption=none weakness_level=9
// client_condition=19`. 体验免费 is the whole point of the feature (see
// ApplyGrowupExperience), so its room must not bill a penalty on the way out
// either.
//
// 新手教程副本这一条豁免的说明见下面 currentWeaknessExemptRoom 的注释。
var currentWeaknessExemptDungeonIDs = map[int64]string{
int64(currentPungjinTrainingDungeonID): currentWeaknessExemptRuleDungeonWhitelist,
int64(currentJobChangeTrialDungeonID): currentWeaknessExemptRuleJobChangeTrialDungeon,
}
// Weakness room exemption rule names, reported in the log so an operator can
// tell which arm spared the run.
const (
currentWeaknessExemptRulePracticeProtocol = "practice_protocol_mode"
currentWeaknessExemptRuleDungeonWhitelist = "whitelisted_dungeon"
// currentWeaknessExemptRuleJobChangeTrialDungeon: 转职体验副本「领悟时间」(7142)。
// 现场 2026-09-17(packet_log):体验 14 秒后退出回城就吃到满档虚弱
// (exemption=none weakness_level=9 client_condition=19)。
currentWeaknessExemptRuleJobChangeTrialDungeon = "job_change_trial_dungeon"
// currentWeaknessExemptRuleTutorialDungeon: PVF [tutorial dungeon],即新建角色的
// 教程副本。现场 2026-09-15:教程中途退出会被套上 19% 血量上限的虚弱,而教程正是
// 最不该罚、新建角色最不该带着虚弱进城镇的地方。
currentWeaknessExemptRuleTutorialDungeon = "tutorial_dungeon"
currentWeaknessExemptRulePVPChannel = "pvp_channel"
)
// currentWeaknessExemptRoom reports whether this run is one the weakness
// penalty must skip, and names the rule for the log.
//
// Four ways in:
// - the op16 practice protocol mode (unchanged behaviour);
// - the dungeon-id whitelist above (修炼场 5000、转职体验「领悟时间」7142),它自己
// 带规则名,所以日志能分辨是哪个房间放行的;
// - any PVF [tutorial dungeon] (新建角色的教程副本,2026-09-15 加);
// - any run on a PVP channel. 决斗场 never builds a dungeon runtime today
// (pvp matching requires session.dungeon.runtime == nil), so this arm is
// not reachable through the current PVP flow; it is here so a future PVP
// room that does take the dungeon route cannot start charging weakness by
// accident. Operator instruction 2026-09-14: 修炼场和 PVP 都不触发虚弱.
func currentWeaknessExemptRoom(session *gameSession, runtime *runtimeDungeonState) (string, bool) {
if currentDungeonRuntimeIsPractice(runtime) {
return currentWeaknessExemptRulePracticeProtocol, true
}
if runtime != nil {
if rule, whitelisted := currentWeaknessExemptDungeonIDs[runtime.Dungeon.ID]; whitelisted {
return rule, true
}
if isPVFTutorialDungeon(runtime) {
return currentWeaknessExemptRuleTutorialDungeon, true
}
}
if session != nil && isPVPChannel(session.channel) {
return currentWeaknessExemptRulePVPChannel, true
}
return "", false
}
// currentForcedReturnWeaknessRaidExempt 报告这一次"未通关直接回城"要不要按**攻坚战**
// 免掉虚弱惩罚,以及依据哪一条规则。
//
// 两条依据:
//
// 1. `raid_gate_exit_never_weakens` —— 这一次 run 本身就带着攻坚绑定
// (`runtime.raidDungeon.RaidKey != 0`)。原来的规则,安东/卢克/超时空的门都走这条。
//
// 2. `raid_channel_exit_never_weakens` —— 退出发生在**超时空**(频道类型 42),
// 但绑定已经被**服务器自己**清空了。失败/撤退收尾就是这么做的:
// returnRuntimeFiendWarDungeons 在 ended 时先把 `runtime.raidDungeon`
// 清成零值再走未通关回城。只看绑定就会把服务器自己发起的"超时空失败收尾"
// 误判成普通副本中途退出,于是给全队挂上虚弱。
//
// 真机 2026-10-02 00:00:36(raid 12058625,分片 20261002-001)—— 就是第 2 种:
//
// game-dungeon-forced-return-weakness-applied char_id=22
// source=fiendwar_failed_waiting_area_abandoned_run_town_return
// exemption=none weakness_level=9 client_condition=19
//
// 对照同一天 00:04:24 走**门**(绑定还在)的那一次,第 1 条就把虚弱免掉了:
//
// game-dungeon-forced-return-weakness-raid-exempt char_id=1
// source=current_exe_bodyless_op42_active_run_giveup_abandoned_run_town_return
// rule=raid_gate_exit_never_weakens
//
// 操作员 2026-10-02 定案:「攻坚战无论怎么中途放弃 都不应该虚弱的啊」。
//
// 作用域刻意只加**超时空**这一条:安东/卢克的 run 一直带着绑定、走第 1 条就豁免了,
// 而清空绑定的收尾只有超时空有(returnRuntimeFiendWarDungeons),
// 所以加这一条对安东/卢克没有任何影响。
func (s *Service) currentForcedReturnWeaknessRaidExempt(session *gameSession, runtime *runtimeDungeonState) (string, bool) {
if s == nil || session == nil {
return "", false
}
if s.currentDungeonRuntimeIsRaidGate(runtime) {
return "raid_gate_exit_never_weakens", true
}
if isCurrentFiendWarRaidChannel(session) {
return "raid_channel_exit_never_weakens", true
}
return "", false
}
// applyCurrentForcedReturnWeakness is called once a dungeon run is committed as
// abandoned. It resolves the exemption rules first and only writes a penalty
// when none applies. A failure is logged and swallowed: the town return is
// already committed and must not be undone by a penalty bookkeeping error.
// It returns the exemption that was applied so the caller can publish the
// refreshed premium state once the town player state exists. That refresh must
// not be sent from here: this runs before the player-state rebuild, and this
// project has repeatedly broken town co-presence by injecting state packets
// ahead of that rebuild.
func (s *Service) applyCurrentForcedReturnWeakness(
session *gameSession,
source string,
) currentWeaknessExemption {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return currentWeaknessExemptionNone
}
level, exemption, err := s.commitCurrentForcedReturnWeakness(session)
if err != nil {
s.logGameEvent(session, "game-dungeon-forced-return-weakness-skipped",
"char_id", session.selectedCharacterID,
"source", source,
"error", err)
return currentWeaknessExemptionNone
}
s.logGameEvent(session, "game-dungeon-forced-return-weakness-applied",
"char_id", session.selectedCharacterID,
"source", source,
"exemption", string(exemption),
"weakness_level", level,
"client_condition", currentWeaknessCondition(level),
"contract_quota_spent", false,
"rule", "operator_decision_black_diamond_exempts_a_forced_exit_and_nothing_is_spent")
return exemption
}
// currentHeldWeaknessPotion reports whether the character still carries one of
// the passive 防虚弱 items (十周年防虚弱青石) in the inventory. Those items say the
// effect applies while the item is held in the material slots, so the question
// is "is it still in the bag", not "was it used".
func currentHeldWeaknessPotion(
ctx context.Context,
inventory dnfrepo.InventoryRepository,
characterID string,
) bool {
if inventory == nil {
return false
}
record, found, err := inventory.Load(ctx, characterID)
if err != nil || !found {
return false
}
for _, stack := range record.Slots {
if stack.Count > 0 && dnfweaknesspotion.HeldExempts(stack.ItemID) {
return true
}
}
return false
}
func (s *Service) commitCurrentForcedReturnWeakness(
session *gameSession,
) (int64, currentWeaknessExemption, error) {
repositories, ok := s.repositoryGroup()
if !ok || repositories.Account == nil || repositories.CharacterAssets == nil {
return 0, currentWeaknessExemptionNone, errCurrentWeaknessUnavailable
}
accountID := strings.TrimSpace(s.accountIDForSession(session))
characterID := strconv.Itoa(int(session.selectedCharacterID))
if accountID == "" {
return 0, currentWeaknessExemptionNone, errCurrentWeaknessUnavailable
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
account, found, err := repositories.Account.Load(ctx, accountID)
if err != nil {
return 0, currentWeaknessExemptionNone, err
}
if !found {
return 0, currentWeaknessExemptionNone, errCurrentWeaknessUnavailable
}
now := s.gameplayNow()
// 黑钻 is the only exemption. 魔王契约 used to exempt here too, spending a
// daily quota automatically; operator decision 2026-09-12 removed that.
// The contract is now only ever spent when the player chooses the contract
// button in the 解除虚弱 UI (op9), so a quota can no longer disappear into
// a give-up the player never meant to pay for.
if premium.BlackDiamondActive(account, now) {
return 0, currentWeaknessExemptionBlackDiamond, nil
}
var (
level int64
exemption = currentWeaknessExemptionNone
)
err = repositories.CharacterAssets.WithinCharacterAssets(
ctx,
characterID,
func(
characters dnfrepo.CharacterRepository,
inventory dnfrepo.InventoryRepository,
_ dnfrepo.EquipmentRepository,
) error {
character, characterFound, loadErr := characters.Load(ctx, characterID)
if loadErr != nil {
return loadErr
}
if !characterFound {
return errCurrentWeaknessUnavailable
}
character = dnfrepo.CloneCharacter(character)
if character.Stats == nil {
character.Stats = make(map[string]int64)
}
// 防虚弱药剂, used route: the potion started a durable immunity window
// and already cleared the standing penalty, so this forced exit is
// free and nothing is written.
if dnfweaknesspotion.Active(character.Stats, now) {
exemption = currentWeaknessExemptionPotItem
return nil
}
// 防虚弱药剂, passive route: carrying the item is the whole effect.
if currentHeldWeaknessPotion(ctx, inventory, characterID) {
exemption = currentWeaknessExemptionPotHeld
return nil
}
// Age any existing penalty forward first, so the new level builds
// on what the player has actually recovered to rather than on a
// stale high-water mark.
settleCurrentWeaknessRecovery(&character, now)
// The penalty is applied whole, not incremented. An existing
// heavier penalty is kept: a forced exit must never heal.
level = currentWeaknessForcedReturnLevel
if settled := currentWeaknessLevel(character); settled > level {
level = settled
}
if level > currentWeaknessMaximumLevel {
level = currentWeaknessMaximumLevel
}
character.Stats[currentWeaknessStatKey] = level
// A fresh penalty restarts the countdown: the player owes the full
// interval again before the next level wears off. 倒计时锚点是**在线秒**
// (settleCurrentWeaknessRecovery 已经把累计值写进 stat,这里直接复用),
// 所以离线期间这一级不会继续往下扣。
character.Stats[currentWeaknessRecoveryAtStatKey] = character.Stats[currentWeaknessOnlineSecondsStatKey]
character.UpdatedAt = now
return characters.Save(ctx, character)
},
)
if err != nil {
return 0, currentWeaknessExemptionNone, err
}
return level, exemption, nil
}
// clearCurrentWeakness removes the penalty and returns the level it removed,
// the gold left behind, and which store paid.
//
// Used by both op9 recovery routes: the player-requested contract route passes
// no gold cost, the player-requested paid route passes the PVF price.
func (s *Service) clearCurrentWeakness(
session *gameSession,
goldCost int64,
) (int64, int64, currentWeaknessGoldSource, error) {
repositories, ok := s.repositoryGroup()
if !ok || repositories.CharacterAssets == nil {
return 0, 0, currentWeaknessGoldSourceNone, errCurrentWeaknessUnavailable
}
characterID := strconv.Itoa(int(session.selectedCharacterID))
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
var (
clearedLevel int64
remainingGold int64
goldSource = currentWeaknessGoldSourceNone
)
err := repositories.CharacterAssets.WithinCharacterAssets(
ctx,
characterID,
func(
characters dnfrepo.CharacterRepository,
inventories dnfrepo.InventoryRepository,
_ dnfrepo.EquipmentRepository,
) error {
character, characterFound, loadErr := characters.Load(ctx, characterID)
if loadErr != nil {
return loadErr
}
if !characterFound {
return errCurrentWeaknessUnavailable
}
character = dnfrepo.CloneCharacter(character)
// Settle first: a penalty that already timed out must report
// "not weakened" rather than charge the player to remove nothing.
clearedLevel, _ = settleCurrentWeaknessRecovery(&character, s.gameplayNow())
if clearedLevel == 0 {
return errCurrentWeaknessNotWeakened
}
if character.Stats == nil {
character.Stats = make(map[string]int64)
}
if goldCost > 0 {
// Gold is the character's own `gold` column, which is what the
// client's list-0 wallet is projected from. The legacy
// main-list slot-0 row is only consulted when that column
// cannot cover the price; see currentWeaknessGoldSource.
held := character.Stats["gold"]
if held >= goldCost {
remainingGold = held - goldCost
character.Stats["gold"] = remainingGold
goldSource = currentWeaknessGoldSourceCharacter
} else {
inventory, inventoryFound, inventoryErr := inventories.Load(ctx, characterID)
if inventoryErr != nil {
return inventoryErr
}
if !inventoryFound {
return fmt.Errorf("%w: need=%d have=%d",
errCurrentWeaknessCannotPay, goldCost, held)
}
inventory = dnfrepo.CloneInventory(inventory)
key := currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, 0)
wallet, walletFound := inventory.Slots[key]
if !walletFound || wallet.ItemID != 0 || wallet.Count < goldCost {
return fmt.Errorf("%w: need=%d have=%d",
errCurrentWeaknessCannotPay, goldCost, wallet.Count)
}
wallet.Count -= goldCost
inventory.Slots[key] = wallet
remainingGold = wallet.Count
goldSource = currentWeaknessGoldSourceLegacySlot
inventory.UpdatedAt = s.gameplayNow()
if saveErr := dnfrepo.SaveInventoryFields(
ctx, inventories, inventory, dnfrepo.InventoryFieldSlots,
); saveErr != nil {
return saveErr
}
}
}
character.Stats[currentWeaknessStatKey] = 0
// A cleared penalty drops its countdown, so the next one starts a
// fresh clock instead of instantly expiring.
delete(character.Stats, currentWeaknessRecoveryAtStatKey)
character.UpdatedAt = s.gameplayNow()
return characters.Save(ctx, character)
},
)
if err != nil {
return 0, 0, currentWeaknessGoldSourceNone, err
}
return clearedLevel, remainingGold, goldSource, nil
}
// currentForcedReturnAppliesWeakness reports whether a committed run status is
// the forced exit the penalty attaches to. A cleared run never weakens.
func currentForcedReturnAppliesWeakness(status worldmap.DungeonRunStatus) bool {
return status == worldmap.DungeonRunAbandoned
}