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

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

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

No files matched your search

@@ -0,0 +1,913 @@
// 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
}