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

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

3142 lines
144 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dnfbridge
import (
"context"
"encoding/binary"
"fmt"
"os"
"slices"
"sort"
"strconv"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/dungeoncmd"
"longheng.io/server/internal/modules/dnf/raid"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/worldmap"
)
// 超时空符号分两路走,分界线不是"121",而是 op1460 自己认的区间。
//
// op1460 的 per-row 处理器 `0x009AF190` 第一件事就是
//
// lea ecx, [eax-0x51] ; key - 81
// cmp ecx, 0x2c ; 81..125
// ja default ; 区间外的行被静默丢弃
//
// 而 `etc/raid/fiendwar/fiendwar.etc.symbol` 给这些 id 的正是:
// 81 雷达 / 82 炮塔 / 83-88 前哨与兵站本营 / 92-93 BOSS 状态 / 94 BOSS 机制位 /
// 95-96 BOSS HP / 102-103 KILL ALL、KICK OUT / 105 FIENDWAR INIT /
// 110-111 BOSS 显示 HP / 121-125 演出开关。
//
// 客户端把 81..88 写进战况表的 board+0x1C、92..96 与 102..103 写进 board+0x34
// 的字符串覆盖、110/111 直接调 UI 0x784150(BOSS 显示 HP)。
//
// 改动前只有 `key >= 121` 的行走 1460,也就是 81..111 全被发到了 op583 ——
// 客户端在那几个结构里读不到它们,于是雷达/炮塔状态、BOSS 状态与 BOSS 血条全空。
// 581..583 那条路(op583 = RaidSymbolMsgID)保持原样,只把 1460 的区间放宽。
const (
// currentFiendWarNativeSymbolKeyMin/Max 是 op1460 认的 key 闭区间。
currentFiendWarNativeSymbolKeyMin = 81
currentFiendWarNativeSymbolKeyMax = 125
// currentFiendWarRaidSymbolKeyMax 是 op583 那条路的上界(原行为:key < 121)。
currentFiendWarRaidSymbolKeyMax = 121
)
// countRuntimeRaidGroupedMembers counts the LIVE roster rows that carry a real squad
// label. It exists for the failure log, which has to prove the 攻坚队 came through the
// failure with its 编制 intact.
func countRuntimeRaidGroupedMembers(state runtimeRaidState) int {
grouped := 0
for _, member := range state.Members {
if runtimeRaidMemberSquad(member.GroupIndex) != 0 {
grouped++
}
}
return grouped
}
func syncRuntimeFiendWarObjectives(state *runtimeRaidState) {
b := state.Lifecycle.FiendWar
if b == nil {
return
}
for id, o := range state.Lifecycle.Objectives {
a, ok := b.AreaForDungeon(id)
if !ok {
continue
}
o.State = raid.AntonDungeonHide
if b.AreaOpen(a.Index) || b.Areas[a.Index].Group != 0 {
o.State = raid.AntonDungeonOpen
o.RemainingHP = 1
} else {
o.RemainingHP = 0
}
state.Lifecycle.Objectives[id] = o
}
}
func runtimeFiendWarRewardEligible(state runtimeRaidState, characterID uint16) bool {
if state.RaidMode != runtimeRaidOwnerFiendWar {
return true
}
config := runtimeRaidBaseConfig(state)
return config != nil && len(config.Phases) == 1 && state.Lifecycle.FiendWarClearCounts[characterID] >= config.Phases[0].MinimalDungeonClearCount
}
// Called once by the dungeon-return owner before freezing the town transition.
func (s *Service) prepareRuntimeFiendWarRetreat(session *gameSession, transition currentDungeonTownTransition, died bool) (currentDungeonTownTransition, error) {
state, ok := s.runtimeRaidForMember(session.selectedCharacterID)
if !ok || state.Lifecycle.FiendWar == nil || state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive {
return transition, nil
}
config := state.Lifecycle.FiendWar.Config
waiting, found := s.townArea(int64(config.WaitingTown), int64(config.WaitingArea))
if !found {
return transition, fmt.Errorf("fiendwar waiting town absent from PVF")
}
var row currentSceneTransitionRow
for _, npc := range waiting.NPCs {
if slices.Contains(config.WaitingNPCs, int(npc.NPCID)) && npc.X >= 0 && npc.X <= 32767 && npc.Y >= 0 && npc.Y <= 32767 {
row = currentSceneTransitionRow{ObjectOrResourceKey: currentSceneActorObjectKey(session.selectedCharacterID), Value1: uint16(npc.X), Value2: uint16(npc.Y), Value3: currentAntonRaidDirection, Value4: currentAntonRaidAreaState}
break
}
}
if row.ObjectOrResourceKey == 0 {
return transition, fmt.Errorf("fiendwar recovery NPC position absent from PVF")
}
town, area := byte(config.WaitingTown), byte(config.WaitingArea)
transition.TownID, transition.AreaID = town, area
transition.PositionX, transition.PositionY = int16(row.Value1), int16(row.Value2)
transition.Direction, transition.AreaState = row.Value3, row.Value4
transition.PositionSource = "fiendwar_retreat_waiting_room"
if died {
transition.PositionSource = "fiendwar_death_recovery"
}
body, err := buildCurrentSceneTransitionBody(town, area, []currentSceneTransitionRow{row})
if err != nil {
return transition, err
}
transition.Body = body
s.raidMu.Lock()
defer s.raidMu.Unlock()
current := s.raids[state.RaidKey]
if current == nil || current.Lifecycle.FiendWar == nil || current.Lifecycle.Status != runtimeRaidLifecyclePhaseActive {
return transition, errRuntimeRaidNotStarted
}
battle := current.Lifecycle.FiendWar.Clone()
member, found := runtimeRaidFrozenMember(*current, session.selectedCharacterID)
if !found {
return transition, errRuntimeRaidNotMember
}
if battle.MemberAreas == nil {
battle.MemberAreas = make(map[uint16]int)
}
// Interrupted runs return to the PVF camp; leaving a member in an uncleared
// area both blocks movement and immediately starts another encounter.
battle.MemberAreas[member.CharID] = config.WaitingArea
if died {
battle.ImprisonMember(member.CharID, s.gameplayNow())
}
group := runtimeRaidNormalizedGroup(member.GroupIndex)
if !slices.ContainsFunc(current.FrozenMembers, func(peer runtimeRaidMemberState) bool {
return peer.CharID != member.CharID && runtimeRaidNormalizedGroup(peer.GroupIndex) == group && battle.MemberArea(peer.CharID, group) != config.WaitingArea
}) {
battle.GroupAreas[group] = config.WaitingArea
}
current.Lifecycle.FiendWar = battle
current.Lifecycle.Revision++
return transition, nil
}
func (s *Service) selectRuntimeFiendWarMaze(characterID uint16, dungeon worldmap.Dungeon) (dungeonMazeSelection, error) {
state, ok := s.runtimeRaidForMember(characterID)
if !ok || state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive || state.Lifecycle.FiendWar == nil {
return dungeonMazeSelection{}, errRuntimeRaidNotStarted
}
b := state.Lifecycle.FiendWar
a, ok := b.AreaForDungeon(int(dungeon.ID))
member, present := runtimeRaidFrozenMember(state, characterID)
if !ok || !present || b.MemberArea(characterID, runtimeRaidNormalizedGroup(member.GroupIndex)) != a.Index || !b.AreaOpen(a.Index) {
return dungeonMazeSelection{}, errRuntimeRaidDungeonUnavailable
}
index, reason := 0, "fiendwar_area_unit_pvf_maze"
if a.Kind == "" {
unit := b.Areas[a.Index]
// Actual PVF mazes: succubus/fusion/buster/alloy, then two orc variants.
if unit.Enemy.ID != 0 {
index = int(unit.Enemy.Type) - 1
} else if unit.Monster.ID != 0 {
index = int(unit.Monster.Type) + 3
} else {
return dungeonMazeSelection{}, errRuntimeRaidDungeonUnavailable
}
} else if rolled, ok := b.MazeForArea(a.Index); ok {
// 设施区域(前哨基地/兵站本营):这一格用哪张图是**固定的**,由它的位置决定
// (上 → maze 0、中 → 1、下 → 2,见 raid.fiendWarFixtureMazeForKind)。
// 它与战况表上画的那只同源,所以"表上看到的"与"进图打到的"必然是同一只。
index = rolled
reason = "fiendwar_fixture_maze_fixed"
} else {
// 没登记的战场(旧存档、或测试直接构造的 battle)保持原行为:当场取一张。
// 单图副本(雷达/炮塔/传送站)只有一张,这里恒为 0。
var err error
index, err = s.chooseDungeonIndex(len(dungeon.Mazes))
if err != nil {
return dungeonMazeSelection{}, err
}
reason = "fiendwar_fixture_maze_default"
}
if index < 0 || index >= len(dungeon.Mazes) {
return dungeonMazeSelection{}, worldmap.ErrMazeNotFound
}
// 记一条:这一场到底按哪条规则进的图。真机上报过"战况表画的是人类背叛者、
// 进去打的却是宇宙粉碎机",而两条路都读同一张 FixtureMazes —— 所以要么这里落到了
// random 兜底分支(说明抽签没生效),要么两者其实一致、是别的环节对不上。
// board_boss 是战况表对同一格会画的那只,直接放在一行里对照。
if session, online := s.onlineGameSession(characterID); online && session != nil {
rolledMaze, rolled := b.MazeForArea(a.Index)
boardBoss, _ := raid.FiendWarFixtureBossID(a.Dungeon, rolledMaze)
s.logGameEvent(session, "game-fiendwar-maze-selected",
"char_id", characterID,
"dungeon_id", dungeon.ID,
"area", a.Index,
"kind", a.Kind,
"maze_index", index,
"maze_count", len(dungeon.Mazes),
"reason", reason,
"rolled", rolled,
"rolled_maze", rolledMaze,
"board_boss", boardBoss,
"source", "select_runtime_fiendwar_maze")
}
return dungeonMazeSelection{Index: index, Reason: reason}, nil
}
// Native enemy types 5..13 are the fixed PVF objectives, not roaming units.
//
// 返回值是这一格的**底账**(哪个单位站在这里),不带"给不给玩家看"这一层 ——
// 揭示与否由 runtimeFiendWarRevealEnemy 决定,因为客户端是靠单位 ID 画头像的,
// 而揭示开关(雷达)与单位本身是两条独立的线。
func runtimeFiendWarBoardEnemy(b *raid.FiendWarBattle, id int) raid.FiendWarUnit {
if b == nil {
return raid.FiendWarUnit{}
}
a := b.Config.Areas[id]
kind := runtimeFiendWarFixtureKind(a.Kind)
if kind == 0 {
return b.Areas[id].Enemy
}
if b.AreaOpen(id) || b.Areas[id].Group != 0 {
return raid.FiendWarUnit{ID: uint32(a.Dungeon), Type: kind}
}
return raid.FiendWarUnit{}
}
// runtimeFiendWarFixtureKind 把 PVF 的 [AREA INFO] 里的设施种类映射成客户端认的
// type:5 雷达 / 6 炮塔 / 7..9 前哨基地 / 10..12 兵站本营 / 13 量子时空传送站。
//
// 这个 type 同时就是客户端那张 kind→脸 表的下标,而**那张表是按格子的位置排的**:
//
// 7 人类背叛者:宾柏 / 8 引力恶魔:薛丁恶 / 9 宇宙粉碎机:克鲁瑟 (上/中/下 前哨基地)
// 10 光之恶魔:欧格里 / 11 暗之恶魔:艾森 / 12 重力恶魔:白斯 (上/中/下 兵站本营)
//
// 操作员复核历史截图确认:**每个格子画出来的怪是固定的**(不论哪一局都是这几只)。
// 所以真正要固定的是"实际打到的那只",规则由 raid.fiendWarFixtureMazeForKind 给出
// (上格 → maze 0、中格 → 1、下格 → 2),两边就此对齐,不再靠抽签去碰。
func runtimeFiendWarFixtureKind(kind string) byte {
return map[string]byte{"radar": 5, "battery": 6, "top outpost": 7, "mid outpost": 8, "bot outpost": 9, "top command center": 10, "mid command center": 11, "bot command center": 12, "top boss": 13, "bot boss": 13}[kind]
}
// runtimeFiendWarRadarRevealed 报告"地图中怪物头像"是否已经对全团解锁。
//
// 权威出处(百度百科「超时空之战」· 地图构造):
//
// 雷达:该副本通关后增加所有玩家10%技能伤害,并显示地图中怪物头像。
// (反之怪物头像显示问号)
//
// 符号 81 RADAR STATE:PVF 在 [FIENDWAR INIT] 里置 0,雷达房被打通时由 battle.Clear
// 置 1。所以 0 = 未通关 = 问号,1 = 已通关 = 显示头像。
func runtimeFiendWarRadarRevealed(b *raid.FiendWarBattle) bool {
if b == nil {
return false
}
return b.Value("radar state") == 1
}
// runtimeFiendWarRevealEnemy 把这一格的底账换算成**发给客户端的单位行**。
//
// fiendWarFaceUnknown 是"雷达未通关"时发给这一格的 type —— 也就是那张**问号**。
//
// 它是实测出来的,不是猜的。2026-10-01 一次进团把候选值铺到不同格子上(红框一律
// 照画,所以"空框 / 头像 / 问号"一眼可分),结果:
//
// type 0 → 红框里一个红问号 ← 就是它(雷达15、炮塔17、上哨19、上兵营27
// 以及所有普通作战区域的数字格子同时命中)
// type 1..13 → 怪物头像(13 是 BOSS 格,一直是头像)
// type 14/15/16 → 红框照画、**里面是空的**(非法值)
// type 17 → 头像(高值里也有合法的)
// type 18/20 → 空框
//
// 为什么以前一直发不出 0:服务端拿 Type==0 当"这一格没有单位"的哨兵,带 0 的行
// 在发出去之前就被自己的守卫吃掉了。现在"有没有单位"由 runtimeFiendWarRevealEnemy
// 的第二个返回值(present)表示,与 Type 彻底分开,0 才能真的上线。
//
// 另一个已经证实的事实:**红框本身只由 unitID 决定** —— ID 认得出就画框,脸画不画
// 得出来是 type 的事。所以不能靠"ID=0"来表示未侦察(那样整格空白,既没框也没问号),
// 必须 **ID 照发 + type 换成 0**。
const fiendWarFaceUnknown byte = 0
// fiendWarUnknownFace 允许运维用环境变量临时换一张"未侦察"脸做 A/B:
//
// set FIENDWAR_UNKNOWN_FACE=13
//
// 变量名不带 DNF 前缀是刻意的 —— 启动器的 runtimeEnvironment 会清掉继承的
// DNFBRIDGE_/DNF_/DFO_*,只有别的名字能透传到服务端进程。
func fiendWarUnknownFace() byte {
raw := strings.TrimSpace(os.Getenv("FIENDWAR_UNKNOWN_FACE"))
if raw == "" {
return fiendWarFaceUnknown
}
value, err := strconv.ParseUint(raw, 10, 8)
if err != nil {
return fiendWarFaceUnknown
}
return byte(value)
}
// runtimeFiendWarRevealEnemy 把这一格的底账换算成**发给客户端的单位行**。
// 第二个返回值是"这一格到底有没有单位",它与 Type 必须分开:
//
// Type == 0 在底账里表示"没有单位",而 0 又正好是客户端那张"问号脸"的下标,
// 所以问号脸必须靠这个 bool 才能发得出去(以前用 Type != 0 当守卫,问号永远发不出去)。
//
// 红框只由 unitID 决定:ID 认得出就画框,脸由 type 决定。因此雷达没通关时
// **ID 照发、只换 type**:
//
// 雷达未通关 → ID = 这一格实际会打到的那只怪,Type = 问号
// 雷达已通关 → 同一个 ID,Type = 这一格自己的头像
func runtimeFiendWarRevealEnemy(b *raid.FiendWarBattle, id int, unit raid.FiendWarUnit) (raid.FiendWarUnit, bool) {
if unit.Type == 0 {
return raid.FiendWarUnit{}, false
}
scouted := runtimeFiendWarScoutedEnemy(b, id, unit)
if !runtimeFiendWarRadarRevealed(b) {
scouted.Type = fiendWarUnknownFace()
}
return scouted, true
}
// runtimeFiendWarScoutedEnemy 是"雷达已通关"时应有的那一行:
// - 普通作战区域的敌军军团 → 该兵种会走进的那张 maze 的领主;
// - 原生怪(中立兽人)→ 同上,走 maze 4/5;
// - 设施区域 → 该副本【开团时抽定】的那张图的领主(单图副本恒定)。
func runtimeFiendWarScoutedEnemy(b *raid.FiendWarBattle, id int, unit raid.FiendWarUnit) raid.FiendWarUnit {
a := b.Config.Areas[id]
if runtimeFiendWarFixtureKind(a.Kind) != 0 {
maze, _ := b.MazeForArea(id)
if boss, ok := raid.FiendWarFixtureBossID(a.Dungeon, maze); ok {
return raid.FiendWarUnit{ID: boss, Type: unit.Type}
}
return raid.FiendWarUnit{Type: unit.Type}
}
// 普通作战区域:这里的 type 是兵种 1..4,它决定这只军团会走进哪张 maze
// (maze = type-1),头像就用那张 maze 的领主。
if boss, ok := raid.FiendWarArmyBossID(unit.Type); ok {
return raid.FiendWarUnit{ID: boss, Type: unit.Type}
}
return raid.FiendWarUnit{Type: unit.Type}
}
// runtimeFiendWarRevealMonster 是原生怪/中立怪(op1456)的揭示:monster type 1..2
// 决定 maze 4/5,头像用那张 maze 的领主;雷达未通关时同样只把脸换成问号。
func runtimeFiendWarRevealMonster(b *raid.FiendWarBattle, id int, unit raid.FiendWarUnit) (raid.FiendWarUnit, bool) {
if unit.Type == 0 {
return raid.FiendWarUnit{}, false
}
scouted := raid.FiendWarUnit{Type: unit.Type}
if boss, ok := raid.FiendWarOrcBossID(unit.Type); ok {
scouted.ID = boss
}
if !runtimeFiendWarRadarRevealed(b) {
scouted.Type = fiendWarUnknownFace()
}
return scouted, true
}
func runtimeFiendWarBoardPackets(state, previous runtimeRaidState, viewer uint16, now time.Time) ([]runtimeRaidWireNotification, error) {
b := state.Lifecycle.FiendWar
if b == nil {
return nil, errRuntimeRaidLifecycleState
}
old := previous.Lifecycle.FiendWar
if previous.RaidKey != state.RaidKey {
old = nil
}
var packets []runtimeRaidWireNotification
appendRows := func(op uint16, rows [][]byte) {
for len(rows) > 0 {
n := min(len(rows), 255)
body := []byte{byte(n)}
for _, r := range rows[:n] {
body = append(body, r...)
}
packets = append(packets, runtimeRaidWireNotification{op, body})
rows = rows[n:]
}
}
var symbols []raid.RaidSymbolRow
var nativeSymbols [][]byte
keys := make([]int, 0, len(b.Symbols))
for key := range b.Symbols {
keys = append(keys, int(key))
}
sort.Ints(keys)
for _, key := range keys {
value := b.Symbols[uint32(key)]
if key < currentFiendWarRaidSymbolKeyMax {
symbols = append(symbols, raid.RaidSymbolRow{Key: uint32(key), Value: uint32(value)})
}
if old != nil && old.Symbols[uint32(key)] == value {
continue
}
if key < currentFiendWarNativeSymbolKeyMin || key > currentFiendWarNativeSymbolKeyMax {
continue
}
if key >= currentFiendWarRaidSymbolKeyMax && (old == nil || value == 0) {
continue
}
row := binary.LittleEndian.AppendUint32(nil, uint32(key))
row = binary.LittleEndian.AppendUint32(row, uint32(value))
nativeSymbols = append(nativeSymbols, row)
}
body, err := raid.BuildRaidSymbolBody(symbols)
if err != nil {
return nil, err
}
packets = append(packets, runtimeRaidWireNotification{raid.RaidSymbolMsgID, body})
appendRows(1460, nativeSymbols)
ids := make([]int, 0, len(b.Areas))
for id := range b.Areas {
ids = append(ids, id)
}
sort.Ints(ids)
var enemies, monsters, warnings, timers [][]byte
// unitRow 的第四个字段 const:客户端自己把默认值预置成 2,我们一直跟着发 2
// (逐像素 A/B 证明换成 0/1 画出来一模一样,见 tools/_tmp_logs/re_badge/cmp2_badge_zoom.png)。
unitRow := func(area int, unit raid.FiendWarUnit, op uint32, enemy bool) []byte {
r := []byte{byte(area)}
r = binary.LittleEndian.AppendUint32(r, unit.ID)
r = append(r, unit.Type)
r = binary.LittleEndian.AppendUint32(r, op)
if enemy {
r = binary.LittleEndian.AppendUint32(r, 2)
}
return r
}
for _, id := range ids {
a := b.Areas[id]
prev := raid.FiendWarAreaState{}
if old != nil {
prev = old.Areas[id]
}
// 底账(谁在这一格)与揭示(给不给看)分开比较:
// - 底账变了 → 老规矩,先 2(移除) 再 1(新增);
// - 只有揭示变了(雷达刚通关)→ 只补一条 1(新增),让客户端把问号换成头像。
// 刻意不发 2:那一条要带"上一次公布过的 ID",而问号那一行的 ID 是 0,
// 拿 0 去删很可能删不掉,反而在同一格留下两行。
enemy, previousEnemy := runtimeFiendWarBoardEnemy(b, id), runtimeFiendWarBoardEnemy(old, id)
enemyRow, enemyPresent := runtimeFiendWarRevealEnemy(b, id, enemy)
previousRow, previousPresent := runtimeFiendWarRevealEnemy(old, id, previousEnemy)
switch {
case previousEnemy != enemy, enemyPresent != previousPresent:
if previousPresent {
enemies = append(enemies, unitRow(id, previousRow, 2, true))
}
if enemyPresent {
enemies = append(enemies, unitRow(id, enemyRow, 1, true))
}
case enemyPresent && enemyRow != previousRow:
enemies = append(enemies, unitRow(id, enemyRow, 1, true))
}
monsterRow, monsterPresent := runtimeFiendWarRevealMonster(b, id, a.Monster)
previousMonsterRow, previousMonsterPresent := runtimeFiendWarRevealMonster(old, id, prev.Monster)
switch {
case prev.Monster != a.Monster, monsterPresent != previousMonsterPresent:
if previousMonsterPresent {
monsters = append(monsters, unitRow(id, previousMonsterRow, 2, false))
}
if monsterPresent {
monsters = append(monsters, unitRow(id, monsterRow, 1, false))
}
case monsterPresent && monsterRow != previousMonsterRow:
monsters = append(monsters, unitRow(id, monsterRow, 1, false))
}
// sub_9B1380 ignores row[1]; every row starts a six-second alert.
if a.Warning && !prev.Warning {
warnings = append(warnings, []byte{byte(id), 1})
}
// sub_9B19E0 derives start=deadline-duration for bars; sub_A48D20
// compares it with the Unix-second server clock (sub_2276FE0).
// Kind 12 keeps the deadline directly for the dungeon countdown.
//
// Kind 12 是**按区域**表达的"这一次进攻还剩多久",也就是玩家在副本里看到的
// 那个倒计时。它必须"有在跑的进攻才有钟":
//
// 有 deadline -> 发 {area,12,deadline,limit},客户端 AreaRecord 上的钟跟着走;
// deadline 刚没 -> 补**一条** {area,12,0,0} 把钟撤掉(op1458 是逐行更新不是
// 全量快照 —— appendRows 会按 255 行分片,全量快照语义在分片
// 下自相矛盾;op1455/1456/1457 也用 op=2 撤行);
// 本来就空闲 -> **一行都不发**。
//
// 以前这里对全部 30 个区域无条件补 3 行,于是每个 tick 有 30 条
// {area,12,0,0} 在断言"这一格的副本倒计时是 0":玩家站在没开打的格子里时,
// 客户端按 0 画,0x0255 的**攻坚倒计时**再也切不回来;通关/退出把 deadline
// 清空后,同一行又把它钉死在 0 上 —— 操作员报的"进副本前/通关后看不到攻坚
// 倒计时"就是这两段。空闲格现在不发行,客户端回落到 0x0255。
for _, kind := range []byte{0, 2, 12} {
var start, deadline time.Time
if kind == 12 {
if a.Deadline.IsZero() {
if !prev.Deadline.IsZero() {
timers = append(timers, []byte{byte(id), 12, 0, 0, 0, 0, 0, 0, 0, 0})
}
continue
}
deadline = a.Deadline
start = deadline.Add(-time.Duration(b.Config.Areas[id].LimitSeconds) * time.Second)
} else {
for key, timer := range b.Timers {
wire := raid.FiendWarTimerKinds[key.Kind]
if key.Area == id && (kind == 0 && wire == 0 || kind == 2 && wire >= 2 && wire <= 9) {
if deadline.IsZero() || timer.Deadline.Before(deadline) {
start = timer.StartedAt
deadline = timer.Deadline
}
}
}
}
end, duration := uint32(0), uint32(0)
if !start.IsZero() && !deadline.IsZero() && deadline.After(start) && deadline.After(now) && start.Unix() > 0 && deadline.Unix() <= 1<<31-1 {
end = uint32(deadline.Unix())
duration = uint32(deadline.Unix() - start.Unix())
}
r := []byte{byte(id), kind}
r = binary.LittleEndian.AppendUint32(r, end)
r = binary.LittleEndian.AppendUint32(r, duration)
timers = append(timers, r)
}
}
appendRows(1457, enemies)
appendRows(1456, monsters)
appendRows(1454, warnings)
appendRows(1458, timers)
var users [][]byte
recoveryDeadline := func(battle *raid.FiendWarBattle, id uint16) uint32 {
if battle == nil {
return 0
}
if until := battle.RecoveryUntil[id]; !until.IsZero() {
return uint32(until.Unix())
}
return 0
}
userRow := func(op uint32, id uint16, area int, deadline uint32) []byte {
r := binary.LittleEndian.AppendUint32(nil, op)
r = append(r, byte(area))
r = binary.LittleEndian.AppendUint32(r, uint32(id))
// sub_9ADDC0 subtracts the server clock from this recovery deadline.
return binary.LittleEndian.AppendUint32(r, deadline)
}
for _, member := range previous.FrozenMembers {
_, present := runtimeRaidFrozenMember(state, member.CharID)
if old != nil && !present {
users = append(users, userRow(2, member.CharID, old.MemberArea(member.CharID, runtimeRaidNormalizedGroup(member.GroupIndex)), recoveryDeadline(old, member.CharID)))
}
}
for _, member := range state.FrozenMembers {
group := runtimeRaidNormalizedGroup(member.GroupIndex)
area := b.MemberArea(member.CharID, group)
deadline := recoveryDeadline(b, member.CharID)
previousMember, present := runtimeRaidFrozenMember(previous, member.CharID)
if old == nil || !present {
users = append(users, userRow(1, member.CharID, area, deadline))
continue
}
previousArea := old.MemberArea(member.CharID, runtimeRaidNormalizedGroup(previousMember.GroupIndex))
if previousArea != area {
users = append(users, userRow(2, member.CharID, previousArea, recoveryDeadline(old, member.CharID)))
users = append(users, userRow(1, member.CharID, area, deadline))
continue
}
if recoveryDeadline(old, member.CharID) != deadline {
users = append(users, userRow(1, member.CharID, area, deadline))
}
}
appendRows(1455, users)
packets = append(packets, runtimeRaidWireNotification{1459, []byte{byte(b.MerchantArea)}})
var dungeons []raid.RaidDungeonStateRow
// 区域倒计时(0x0254)——"这一格打起来还剩多久"。它和战况表上的条不是同一个通道:
//
// 0x0254(596) → 客户端存储 A:map<pair<timerType, value0>, {start, end}>,
// value0 = **地牢 ID**(就是下面 585 行里那个 DungeonID);
// 0x0255(597) → 客户端存储 B:map<timerType, endMs>,只看 timerType。
// (证据 tools/攻坚队两个倒计时_根因与改法_20260916.md:12-25、:80-97。)
//
// 客户端的门渲染器按 585/586 给的门状态去查存储 A,9 个调用点守卫一致:
// 状态 0 → 查 {timerType 1, dungeonID},状态 1|3 → 查 {timerType 2, dungeonID},
// 状态 2(隐藏)什么都不画。超时空的战况表把整张通用快照替换掉了
// (raid_phase_runtime.go:276 / raid_current_compat.go:303 / raid_rejoin_runtime.go:264
// 三条都直接 return 到这张表),而通用路径每一条都是 **0x0254 紧跟 0x0255** 成对发的
// (raid_phase_runtime.go:363-377、raid_current_compat.go:339-352、
// raid_result_flow.go:314-318、raid_rejoin_runtime.go:246-250、
// raid_online.go:2362-2371),安徒恩的门计时器也是同一形状
// (raid_anton_phase_one.go:587-595)。于是超时空这一半从来没发过:玩家进图后
// 客户端查存储 A 查不到这一格 → 倒计时根本不画,只剩下 0x0255 的攻坚剩余时间。
//
// 只在该区域的 deadline **发生变化**时发一条(首次 / 进图 / 重连补帧各一次),
// 不跟着 1 Hz 心跳重复:形状与安徒恩一致(value1=绝对 deadline、value2=时长),
// 万一本频道的分派表走的是"相对"分支(start=now、end=now+value2),重复发会
// 把倒计时永远钉在满值 —— 只发一次则两种语义都正确。
var areaTimers []runtimeRaidWireNotification
for _, id := range ids {
a := b.Config.Areas[id]
if a.Dungeon <= 0 {
continue
}
o := state.Lifecycle.Objectives[a.Dungeon]
wire := runtimeRaidObjectiveWireState(o.State)
dungeons = append(dungeons, raid.RaidDungeonStateRow{DungeonID: uint32(a.Dungeon), State: wire})
area := b.Areas[id]
var previousArea raid.FiendWarAreaState
// 零值目标状态(AntonDungeonState(""))在 wire 上就是 hide(2),
// 所以首次帧 / 没有旧账时拿 2 当"上一帧"是对的。
previousWire := uint32(2)
if old != nil {
previousArea = old.Areas[id]
previousWire = runtimeRaidObjectiveWireState(previous.Lifecycle.Objectives[a.Dungeon].State)
}
// 触发条件是 **(deadline, 门状态) 这一对**变了,不是只有 deadline。
// 只比 deadline 会漏掉"进图时状态还没同步"的那一帧:deadline 已经在
// Enter 里设好了,可 Objectives 要等下一次 sync 才变 open —— 那一帧按
// hide 被跳过之后,deadline 再也不会"变",这条倒计时就永远发不出去了。
if area.Deadline.IsZero() ||
(area.Deadline.Equal(previousArea.Deadline) && wire == previousWire) {
continue
}
timerType, drawable := runtimeRaidGateCountdownTimerType(wire)
if !drawable {
continue
}
areaTimers = append(areaTimers, runtimeRaidWireNotification{
msgID: raid.RaidTimerMsgID,
body: raid.BuildRaidTimerBody(raid.RaidTimer{
TimerType: timerType,
Value0: uint32(a.Dungeon),
Value1: uint32(area.Deadline.Unix()),
Value2: runtimeRaidDurationSeconds(time.Duration(a.LimitSeconds) * time.Second),
}),
})
}
body, err = raid.BuildRaidDungeonStateBody(raid.RaidDungeonStateSnapshot{Rows: dungeons})
if err != nil {
return nil, err
}
packets = append(packets, runtimeRaidWireNotification{raid.RaidDungeonStateMsgID, body})
packets = append(packets, areaTimers...)
packets = append(packets, runtimeRaidWireNotification{raid.RaidRemainTimeMsgID, raid.BuildRaidRemainTimeBody(raid.RaidRemainTime{TimerType: 0, Seconds: runtimeFiendWarViewerClockSeconds(state, viewer, now)})})
return packets, nil
}
// runtimeFiendWarViewerClockSeconds 是这一名**观看者**此刻 HUD 上"剩余时间"该显示的秒数。
//
// 操作员的要求是**一个显示、三种状态**:进副本前显示攻坚倒计时、进副本后显示该副本
// 倒计时、通关/退出后切回攻坚倒计时。
//
// 那个显示读的是客户端**存储 B**(`map<timerType,endMs>`,键只有 timerType,唯一写者
// 是 0x0255 —— tools/攻坚队两个倒计时_根因与改法_20260916.md:12-25、:101-120 的
// 全局渲染器 `cmp [raidObj+0x28],5` 决定读 type 1 还是 type 0)。所以"切换"这件事只能由
// 服务端按**收件人**决定这一次 0x0255 带哪个值:
//
// 这一位此刻站在一个**有在跑 deadline 的区域**里 -> 带那个区域还剩多久;
// 否则(在战况表上、在待机区、刚通关回城) -> 带整个攻坚阶段的剩余。
//
// 2026-10-02 的教训:我先前把 0x0254(存储 A)当成这个显示的来源,补了整整一条包,
// 真机上截图仍然是 `00:59:39`。存储 A 的 9 个消费点画的是**战况表上的门/格子按钮**,
// 而玩家在副本里时那张表是关着的 —— 存储 A 填得再对,也照不到这个 HUD。真正要改的是
// 这一行 0x0255。
func runtimeFiendWarViewerClockSeconds(state runtimeRaidState, viewer uint16, now time.Time) uint32 {
raidClock := runtimeRaidDurationSeconds(state.Lifecycle.PhaseDeadline.Sub(now))
b := state.Lifecycle.FiendWar
if b == nil || viewer == 0 {
return raidClock
}
// 先看"这一位本人被记在哪一格"。MemberAreas 是进图/回城时逐人写的账本
// (game-fiendwar-member-area-marked),它才是"这一个人在不在图里"的正解。
if areaID, tracked := b.MemberAreas[viewer]; tracked {
if seconds, live := runtimeFiendWarAreaClockSeconds(b, areaID, now); live {
return seconds
}
}
// 账本区域会滞后(2026-10-01「走不动」那轮的病根),所以再用**小队的区域**兜一次。
// 两条都拿不到"有在跑的钟"就回落到攻坚倒计时 —— 战况表上、待机区、刚通关回城
// 都是这一支。
if member, ok := runtimeRaidFrozenMember(state, viewer); ok {
group := runtimeRaidNormalizedGroup(member.GroupIndex)
if areaID, tracked := b.GroupAreas[group]; tracked {
if seconds, live := runtimeFiendWarAreaClockSeconds(b, areaID, now); live {
return seconds
}
}
}
return raidClock
}
// runtimeFiendWarAreaClockSeconds 报告这一格是不是"真的被一个小队占着、而且钟还在跑",
// 是的话返回还剩多少秒。通关那一刻 battle.Clear 会把 Group 与 Deadline 一起清掉
// (fiendwar_battle.go:556),所以这一条同时覆盖了"通关/退出后切回攻坚倒计时"。
func runtimeFiendWarAreaClockSeconds(b *raid.FiendWarBattle, areaID int, now time.Time) (uint32, bool) {
if b == nil {
return 0, false
}
area := b.Areas[areaID]
if area.Group == 0 || area.Deadline.IsZero() || !area.Deadline.After(now) {
return 0, false
}
return runtimeRaidDurationSeconds(area.Deadline.Sub(now)), true
}
// runtimeRaidGateCountdownTimerType 把 0x0249 的门状态映射成客户端**去存储 A 里查**
// 用的 timerType(tools/攻坚队两个倒计时_根因与改法_20260916.md:80-97):
//
// state 0 -> timerType 1(开门/生效窗倒计时)
// state 1 | 3 -> timerType 2(关闭/已清的恢复倒计时)
// 其它(-1|2) -> 客户端不画,所以也不必发
func runtimeRaidGateCountdownTimerType(wireState uint32) (byte, bool) {
switch wireState {
case 0:
return 1, true
case 1, 3:
return 2, true
default:
return 0, false
}
}
func (s *Service) sendRuntimeFiendWarBoard(session *gameSession, state runtimeRaidState, announce bool, now time.Time) error {
session.fiendWarBoardMu.Lock()
defer session.fiendWarBoardMu.Unlock()
current, member := s.runtimeRaidForMember(session.selectedCharacterID)
if !member || current.RaidKey != state.RaidKey {
return nil
}
if current.Lifecycle.Revision > state.Lifecycle.Revision {
state = current
}
prev := session.fiendWarBoard
if prev.RaidKey == state.RaidKey && prev.Lifecycle.Revision > state.Lifecycle.Revision {
return nil
}
packets, err := runtimeFiendWarBoardPackets(state, prev, session.selectedCharacterID, now)
if err != nil {
return err
}
// 战况表这一版准备给每个设施区域画哪只怪。和进图那条 `game-fiendwar-maze-selected`
// 放在一起,就能证明"表上画的"与"进去打的"是不是同一只(真机上报过不一致)。
// 只在变化时记一行,不会刷屏。
if b := state.Lifecycle.FiendWar; b != nil {
shown := make([]string, 0, len(b.Config.Areas))
for id, areaCfg := range b.Config.Areas {
if runtimeFiendWarFixtureKind(areaCfg.Kind) == 0 {
continue
}
maze, _ := b.MazeForArea(id)
boss, ok := raid.FiendWarFixtureBossID(areaCfg.Dungeon, maze)
if !ok {
continue
}
shown = append(shown, fmt.Sprintf("area%d:dungeon%d:maze%d:boss%d", id, areaCfg.Dungeon, maze, boss))
}
sort.Strings(shown)
signature := strings.Join(shown, ",")
if signature != session.fiendWarBoardFixtureSignature {
session.fiendWarBoardFixtureSignature = signature
s.logGameEvent(session, "game-fiendwar-board-fixtures",
"raid_key", state.RaidKey,
"char_id", session.selectedCharacterID,
"entries", shown,
"source", "send_runtime_fiendwar_board")
}
}
if prev.RaidKey != state.RaidKey {
packets = append([]runtimeRaidWireNotification{{1453, []byte{1, 0}}}, packets...)
}
if announce || prev.RaidKey != state.RaidKey {
packets = append(packets, runtimeRaidWireNotification{raid.RaidStateMsgID, raid.BuildRaidStateBody(raid.RaidState{State: 2, Argument: 0})}, runtimeRaidWireNotification{1453, []byte{2, 0}}, runtimeRaidWireNotification{1452, nil})
}
for _, packet := range packets {
if err := s.sendGameUpperRawClassCodec(session, packet.msgID, packet.body, 0, true); err != nil {
return err
}
}
session.fiendWarBoard = cloneRuntimeRaidState(state)
return nil
}
func (s *Service) broadcastRuntimeFiendWarBoard(state runtimeRaidState, announce bool, now time.Time) error {
for _, member := range state.FrozenMembers {
if session, ok := s.onlineGameSession(member.CharID); ok {
if !isCurrentFiendWarRaidChannel(session) {
continue
}
if err := s.sendRuntimeFiendWarBoard(session, state, announce, now); err != nil {
return err
}
}
}
return nil
}
func (s *Service) scheduleRuntimeFiendWarTick(state runtimeRaidState) error {
queue := s.ensureGameplayTimeQueue()
if queue == nil {
return errGameplayTimeQueueUnavailable
}
return queue.ScheduleAfter(fmt.Sprintf("fiendwar:%d:%d", state.RaidKey, state.StartAttempt), time.Second, func(now time.Time) {
s.raidMu.Lock()
current := s.raids[state.RaidKey]
if current == nil || current.StartAttempt != state.StartAttempt || current.Lifecycle.Status != runtimeRaidLifecyclePhaseActive || current.Lifecycle.FiendWar == nil {
s.raidMu.Unlock()
return
}
battle := current.Lifecycle.FiendWar.Clone()
err := battle.Tick(now)
if err == nil {
captureRuntimeFiendWarRetreats(current, battle)
current.Lifecycle.FiendWar = battle
syncRuntimeFiendWarObjectives(current)
current.Lifecycle.Revision++
}
next := cloneRuntimeRaidState(*current)
s.raidMu.Unlock()
if err != nil {
s.logPacketEvent("game-fiendwar-tick-failed", "raid_key", state.RaidKey, "error", err)
return
}
if battle.Failed {
if err := s.grantRuntimeFiendWarRetry(next, now); err != nil {
s.logPacketEvent("game-fiendwar-retry-ticket-grant-failed", "raid_key", next.RaidKey, "error", err)
s.scheduleRuntimeFiendWarRetry(next, now)
}
// 攻坚失败:不解散攻坚队,只结束这一次"攻坚运行"。
// failRuntimeFiendWarRaid 取代 abandonRuntimeRaid —— 后者会删掉攻坚队行,
// 于是 town_move 之后拒绝一切 AreaID>=2 的 op36 (连等待区也要求成员资格),
// 站在城镇作战区的人自己走不回去。收尾见 publishFailedFiendWar。
if failed, failErr := s.failRuntimeFiendWarRaid(next.RaidKey, now); failErr == nil {
if err := s.publishFailedFiendWar(failed); err != nil {
s.logPacketEvent("game-fiendwar-failure-publish-failed", "raid_key", next.RaidKey, "error", err)
}
// 战斗失败也是"攻坚失败":与倒计时到点同一条返还(超时空特有,安东/卢克
// 只有倒计时那一种)。放在演出之后,见 refundRuntimeRaidEntryOnFailure。
s.refundFailedRuntimeRaidEntry(failed, now)
} else {
s.logPacketEvent("game-fiendwar-failure-transition-failed", "raid_key", next.RaidKey, "error", failErr)
}
return
}
if err := s.returnRuntimeFiendWarDungeons(next, next.Lifecycle.FiendWarRetreats, false); err != nil {
s.logPacketEvent("game-fiendwar-retreat-failed", "raid_key", state.RaidKey, "error", err)
}
if err := s.broadcastRuntimeFiendWarBoard(next, false, now); err != nil {
s.logPacketEvent("game-fiendwar-board-send-failed", "raid_key", state.RaidKey, "error", err)
}
for _, member := range next.FrozenMembers {
identity, online := s.onlineGameSessionCharacterSnapshot(member.CharID)
if !online || identity.session == nil {
continue
}
if err := s.postGameSessionCharacterEvent(identity.session, "fiendwar-encounter", identity.character, identity.generation, func() error {
return s.tryRuntimeFiendWarEncounter(identity.session, now)
}); err != nil {
s.logPacketEvent("game-fiendwar-encounter-post-failed", "raid_key", next.RaidKey, "error", err)
}
}
if err := s.scheduleRuntimeFiendWarTick(next); err != nil {
s.logPacketEvent("game-fiendwar-tick-schedule-failed", "raid_key", state.RaidKey, "error", err)
}
})
}
// Consume each domain retreat under raidMu while its original attempt still
// owns the room. Area-only markers must not survive into another selection.
func captureRuntimeFiendWarRetreats(state *runtimeRaidState, battle *raid.FiendWarBattle) {
if state.Lifecycle.FiendWarRetreats == nil {
state.Lifecycle.FiendWarRetreats = make(map[int]runtimeRaidDungeonProgress)
}
for group, dungeon := range battle.Retreats {
// 本服务端的一次区域尝试按 (副本, 座位) 记账,座位就是发起那次进攻的小队
// (runtimeRaidDungeonSeat{Group: g})。超时空战场自己已经保证"一个区域只被
// 一个小队占用",所以 group 直接映射成座位即可;未编队成员 (group 0) 在
// 超时空不允许入场,不会出现以角色为座位的记录。
seat := runtimeRaidDungeonSeat{Group: group}
key := runtimeRaidDungeonRunKey{MapID: dungeon, Seat: seat}
if progress, ok := state.Lifecycle.Dungeons[key]; ok && progress.Seat == seat {
state.Lifecycle.FiendWarRetreats[dungeon] = progress
delete(state.Lifecycle.Dungeons, key)
delete(state.Lifecycle.GroupOccupancy, seat)
state.Lifecycle.ClosedDungeonAttempts[runtimeRaidDungeonAttemptKey{progress.CharacterID, progress.LifecycleToken}] = runtimeRaidDungeonTerminal{Phase: progress.Phase, MapID: dungeon, Kind: runtimeRaidDungeonAbandoned}
}
delete(battle.Retreats, group)
}
}
// The caller holds townMu and has already validated/committed the area move.
// fiendWarPlannedEncounterWindow 是"计划进图正在跑"的兜底时长:正常一趟(含
// prepareRuntimePartyDungeonEntry 把 4 个人逐个备好)实测都在 1 秒内,真机最长
// 的一次(23:07 驱动者卡住那次)也没超过 3 秒。5 秒只是"清标记那一行万一没跑到"
// 的安全网 —— 它不是节流,正常路径由 defer 立刻清掉。
const fiendWarPlannedEncounterWindow = 5 * time.Second
// beginRuntimeFiendWarPlannedEncounter 在把计划进图投进本人信箱**之前**立牌。
//
// 顺序是刻意的:牌必须在任何人读到"本人实时坐标已被推到目标区域"之前立好,
// 否则落在 [推坐标, runtime 落地] 这 ~300ms 窗口里的战况表心跳照样会单人开图。
func (s *Service) beginRuntimeFiendWarPlannedEncounter(session *gameSession, now time.Time) {
if session == nil {
return
}
session.dungeon.mu.Lock()
session.fiendWarPlannedEncounterUntil = now.Add(fiendWarPlannedEncounterWindow)
session.dungeon.mu.Unlock()
}
// endRuntimeFiendWarPlannedEncounter 在计划那一趟跑完(成功、失败、或者退化成
// 普通移动)之后立刻落牌,战况表心跳随即恢复。
func (s *Service) endRuntimeFiendWarPlannedEncounter(session *gameSession) {
if session == nil {
return
}
session.dungeon.mu.Lock()
session.fiendWarPlannedEncounterUntil = time.Time{}
session.dungeon.mu.Unlock()
}
// runtimeFiendWarPlannedEncounterPending 报告"这一刻是否有计划进图在飞"。只看
// 本人自己的信箱:牌是每个人自己立的,别人的进图不影响我的心跳。
func (s *Service) runtimeFiendWarPlannedEncounterPending(session *gameSession, now time.Time) bool {
if session == nil {
return false
}
session.dungeon.mu.Lock()
until := session.fiendWarPlannedEncounterUntil
session.dungeon.mu.Unlock()
return !until.IsZero() && now.Before(until)
}
// fiendWarEncounterPeerRefusal reports why one area companion cannot be dragged
// into this encounter yet, or "" when they can.
//
// 区域一起进图的代价就在这条判定上:一个人没好(离线 / 还在切场景 / 坐标还没落),
// 整格就都不进。这是刻意的 —— 把一个正在切场景的客户端硬拖进副本会把它弄坏。
// 但它必须**可观测**:原来这里全是静默 `return false, nil`,真机上只能看到
// "走进了区域却没开打",没有任何线索。所以每条拒绝都带 peer_id 与 reason 落盘。
func (s *Service) fiendWarEncounterPeerRefusal(peer runtimeRaidMemberState, townID byte, area int) string {
other, online := s.onlineGameSession(peer.CharID)
if !online || other == nil {
return "peer_offline"
}
if ready, _ := s.currentTownEnterSelectReady(other); !ready {
return "peer_scene_not_ready"
}
other.townMu.Lock()
position := other.townPositionSnapshot
other.townMu.Unlock()
if position.TownID != townID || int(position.AreaID) != area {
return "peer_not_in_area"
}
return ""
}
// Queue on the leader mailbox so a last-arriving follower never blocks its own
// mailbox while the leader's ordinary dungeon entry prepares that follower.
func (s *Service) queueRuntimeFiendWarMoveEncounterLocked(session *gameSession, request currentTownSetUserAreaRequest, row currentSceneTransitionRow, body []byte) (bool, error) {
session.townMu.Unlock()
defer session.townMu.Lock()
state, ok := s.runtimeRaidForMember(session.selectedCharacterID)
if !ok || state.Lifecycle.FiendWar == nil {
return false, nil
}
b := state.Lifecycle.FiendWar
area := int(request.AreaID)
member, found := runtimeRaidFrozenMember(state, session.selectedCharacterID)
if !found || !b.AreaOpen(area) || b.Areas[area].Group != 0 {
return false, nil
}
// 超时空的进图单位是**编制小队**(操作员 2026-10-01 的定案:「也不做附近自动组队的
// 机制了,暂时就按小队编制走吧」)。所以名单就是发起者自己那支小队,且全队都得站在
// **他按下进图时所在的那个格子**(originArea)—— 不是 request.AreaID:后者是隔壁
// 那个要打的区域(真机 op36:area=6 / opaque=(98,3))。
//
// 驱动者仍然按官方口径是**按下进图的人**(「第一个进入新副本的玩家为队长」):
// 若挂到名册序第一个队友的邮箱上,他没动过,selectRuntimeFiendWarMaze
// 的"驱动者必须站在该区域"就会把整场拒掉 —— 真机看到的就是队伍刚组成又解散。
//
// 编制本身不动:进图时编出来的仍是一支临时普通队伍(tryRuntimeFiendWarEncounter →
// replaceManagedRuntimeParty),攻坚队/小队的名册与座位一概不改。
session.townMu.Lock()
origin := session.townPositionSnapshot
session.townMu.Unlock()
originArea := int(origin.AreaID)
if !origin.PositionValid || originArea < 2 || int(origin.TownID) != int(request.TownID) {
// 实时坐标还没落下来时,用客户端自己报的"我从哪一格来"兜底。
if from := int(request.OpaqueU16B); from >= 2 {
originArea = from
} else {
originArea = int(b.MemberArea(member.CharID, runtimeRaidNormalizedGroup(member.GroupIndex)))
}
}
plan := fiendWarEncounterPlan{Area: area, OriginArea: originArea, Requester: session.selectedCharacterID}
leaderID := session.selectedCharacterID
// 原来这里还有一段「队友没准备好就整脚 defer」的守卫:遍历发起者**小队**的成员,
// 只要有一个人不在原格(离线 / 还在切场景 / 坐标没落),就直接 return false,
// 让这一脚退化成普通移动。
//
// 2026-10-01 按客户端原生规格去掉(弹窗 sub_9AE390,dstr 75467):
//
// - 进图单位是**原格里站着的人**,不是小队 —— 一个不相关的小队成员没有资格
// 挡住这一脚;
// - 名单由 tryRuntimeFiendWarEncounter 里的 runtimeRaidFiendWarAreaRoster
// 现算,它会把"此刻进不去的人"剔掉并记一行日志,而不是把所有人一起拖住。
//
// 留着一个"谁都能把这一脚按停"的守卫,正是真机上"走进了区域却什么都没发生"
// 的来源之一(13:27 那次的 game-fiendwar-squad-encounter-deferred 就是它)。
leader, online := s.onlineGameSessionCharacterSnapshot(leaderID)
if !online || leader.session == nil {
return false, nil
}
session.townMu.Lock()
previousPosition := session.townPositionSnapshot
previousOrigin, previousBound := session.townSelectorOriginSnapshot, session.townSelectorOriginBound
setCurrentTownPositionSceneFromTransitionLocked(session, session.selectedCharacterID, request.TownID, request.AreaID, row)
characterID, generation := session.selectedCharacterID, session.characterGeneration
moveBody := append([]byte(nil), body...)
session.townMu.Unlock()
// 立牌必须在投递之前:从这一行起,本人的实时坐标已经指向目标区域,落在
// [这里, runtime 落地] 之间的战况表心跳会单人开图(真机 2026-10-01 00:03:53)。
s.beginRuntimeFiendWarPlannedEncounter(session, s.gameplayNow())
err := s.postGameSessionCharacterEvent(leader.session, "fiendwar-move-encounter", leader.character, leader.generation, func() error {
defer s.endRuntimeFiendWarPlannedEncounter(session)
if err := s.tryRuntimeFiendWarEncounterPlanned(leader.session, s.gameplayNow(), &plan); err != nil {
return err
}
return s.postGameSessionCharacterEvent(session, "fiendwar-move-encounter-result", characterID, generation, func() error {
session.dungeon.mu.Lock()
entered := session.dungeon.runtime != nil
session.dungeon.mu.Unlock()
if entered {
return nil
}
session.townMu.Lock()
position := session.townPositionSnapshot
session.townMu.Unlock()
if position.TownID != request.TownID || position.AreaID != request.AreaID {
return nil // A newer move owns the scene now.
}
// Admission can change while queued; complete the validated field
// move once instead of leaving the client waiting or retrying it here.
return s.handleTownSetUserAreaAfterEncounter(session, moveBody, true)
})
})
session.townMu.Lock()
if err != nil {
session.dungeon.mu.Lock()
entered := session.dungeon.runtime != nil
session.dungeon.mu.Unlock()
if !entered {
session.townPositionSnapshot = previousPosition
session.townSelectorOriginSnapshot, session.townSelectorOriginBound = previousOrigin, previousBound
}
}
session.townMu.Unlock()
// 投递本身失败时闭包一次都没跑,defer 也就没机会落牌 —— 这里补一下,别让心跳
// 白等满 5 秒的安全网。
if err != nil {
s.endRuntimeFiendWarPlannedEncounter(session)
}
return true, err
}
// currentFiendWarSquadlessNotice is what a Fiend War member standing in an
// uncleared area with no squad is told.
//
// It is deliberately phrased as an instruction rather than a refusal: the state
// itself is legitimate (the operator's ruling makes a member who walked home
// alone squadless), and the raid manager's regroup command is the gesture that
// ends it.
const currentFiendWarSquadlessNotice = "你不在任何小队里,请先在攻坚队管理器中重新编队。"
// runtimeRaidFiendWarSquadSource names which roster answered "which squad does this
// member fight in".
type runtimeRaidFiendWarSquadSource string
const (
// runtimeRaidFiendWarSquadSourceLive: the member's LIVE roster row named a squad
// that the live roster also has rows for. This is the ordinary case and the only
// source an encounter is built from.
runtimeRaidFiendWarSquadSourceLive runtimeRaidFiendWarSquadSource = "live"
// runtimeRaidFiendWarSquadSourceFrozen: the member has no LIVE row at all, so the
// frozen phase roster answered instead.
runtimeRaidFiendWarSquadSourceFrozen runtimeRaidFiendWarSquadSource = "frozen"
)
// runtimeRaidFiendWarSquad is the resolved answer to "which squad does this member
// walk into a Fiend War area with".
//
// Refusal is empty exactly when Group/Members are usable; otherwise it names the
// fact that stopped the resolution, for the log and for the player-facing notice.
type runtimeRaidFiendWarSquad struct {
Group byte
Members []runtimeRaidMemberState
Source runtimeRaidFiendWarSquadSource
Refusal string
}
// runtimeRaidLiveMemberSquad reports the squad the member's LIVE roster row carries,
// or 0 when the row is unassigned or absent. It is the live-row counterpart of
// runtimeRaidMemberSquad(runtimeRaidFrozenMember(...).GroupIndex).
func runtimeRaidLiveMemberSquad(state runtimeRaidState, characterID uint16) byte {
index := runtimeRaidMemberIndex(state, characterID)
if index < 0 {
return 0
}
return runtimeRaidMemberSquad(state.Members[index].GroupIndex)
}
// runtimeRaidLiveSquadMembers lists the LIVE roster rows of one squad in roster
// order, which is the order runtimeRaidPartyGroups produces and therefore the order
// runtimeRaidGroupPartyState projects onto a native party.
func runtimeRaidLiveSquadMembers(state runtimeRaidState, group byte) []runtimeRaidMemberState {
if group == 0 {
return nil
}
for _, members := range runtimeRaidPartyGroups(state) {
if len(members) > 0 && runtimeRaidMemberSquad(members[0].GroupIndex) == group {
return members
}
}
return nil
}
// runtimeRaidFiendWarEncounterSquad resolves the squad a Fiend War encounter is
// started under. It exists because the LIVE roster and the FROZEN phase roster can
// disagree about one member's squad, and that disagreement used to be a silent
// dead end.
//
// The measured failure (live 2026-10-01, raid 12058625, char 1):
//
// 11:34:24 game-raid-squad-unassigned-on-solo-town-return char_id=1 previous_group=1
// group=0 slot=0 frozen_row=kept_for_phase_credit
// source=current_exe_bodyless_op42_active_run_giveup
// 11:35:55 game-town-set-user-area-blocked area_id=10
// reason=fiendwar source area requires combat
//
// unassignRuntimeRaidSquadOnSoloTownReturn rewrites the live row only
// (runtimeRaidSquadLabelLiveRowOnly) because the frozen row still credits the phase.
// This function read the group from the FROZEN row (group 1), searched the LIVE
// groups for it, found none, and returned nil without a word: the encounter never
// fired, so the area's monster could never be cleared, and FiendWarBattle.Move
// refuses to let anyone walk past an uncleared area. The member was locked in.
//
// The precedence is the operator's ruling (「如果是队内成员单独自己回城 应该取消这个
// 人的编制」 plus 「在战况表里撤退 应该保持编制」): the LIVE row is the roster the
// 攻击队 window draws and the squad -> ordinary-party projection is built from, so
// it decides. The frozen row answers only when the live roster has no row for the
// member at all, which is the one divergence a live row cannot explain.
//
// Group 0 with a live row is NOT a fallback case: it is the legitimate squadless
// state the solo town return produces, and the member is expected to re-assign
// himself in the raid manager (game-raid-manager-regrouped / type=669) before the
// encounter can fire.
func runtimeRaidFiendWarEncounterSquad(state runtimeRaidState, characterID uint16) runtimeRaidFiendWarSquad {
frozen, ok := runtimeRaidFrozenMember(state, characterID)
if !ok {
return runtimeRaidFiendWarSquad{Refusal: "member_not_in_frozen_roster"}
}
hasLiveRow := runtimeRaidMemberIndex(state, characterID) >= 0
squad, source := runtimeRaidLiveMemberSquad(state, characterID), runtimeRaidFiendWarSquadSourceLive
members := []runtimeRaidMemberState(nil)
switch {
case squad != 0:
members = runtimeRaidLiveSquadMembers(state, squad)
case hasLiveRow:
// The live row exists and is unassigned: the legitimate squadless state the
// solo town return produces. Deliberately NOT answered from the frozen row --
// that is the ghost squad the live bug dragged the member into.
return runtimeRaidFiendWarSquad{Refusal: "member_not_in_any_squad"}
default:
// The live roster has no row for this member at all, which only the frozen
// phase roster can explain. Its squad still exists if the live roster has its
// peers, and the member joins them: the fused list is the frozen row plus the
// squad's live rows, put back in roster order (the same order
// runtimeRaidPartyGroups produces).
squad, source = runtimeRaidMemberSquad(frozen.GroupIndex), runtimeRaidFiendWarSquadSourceFrozen
members = append(members, frozen)
members = append(members, runtimeRaidLiveSquadMembers(state, squad)...)
runtimeRaidSortMembers(members)
}
if squad == 0 {
return runtimeRaidFiendWarSquad{Refusal: "member_not_in_any_squad"}
}
if len(members) == 0 {
// Nobody in the live roster is in the squad this row names, so there is no
// party to walk in with.
return runtimeRaidFiendWarSquad{Group: squad, Source: source, Refusal: "squad_has_no_live_members"}
}
if !slices.ContainsFunc(members, func(member runtimeRaidMemberState) bool {
return member.CharID == characterID
}) {
return runtimeRaidFiendWarSquad{Group: squad, Source: source, Refusal: "member_missing_from_its_own_squad"}
}
return runtimeRaidFiendWarSquad{Group: squad, Members: members, Source: source}
}
// fiendWarEncounterPlan 是"由一次移动触发的遭遇战"的既定事实,由**发起移动的那一次
// 调用**算好,交给(很可能挂在别人邮箱上的)遭遇战执行体。
//
// 为什么必须传,而不能在执行时重算:真机 op36 的语义是
//
// RECV op36 char=22 town=98 area=6 x=177 y=597 dir=5 opaque=(98, 3)
// ^^^^^^ 要打的那个区域(带副本 4954)
// ^^^^^^^^^^^^^ 他**按下进图时站的格子**
//
// 也就是说界面上"作战区域 03"是格子 3,而 request.AreaID 是隔壁那个有怪的区域 6。
// 执行体跑起来时发起者已经被挪进 6 了,这时任何"按实时坐标重算队伍"的做法都会得出
// "这一格里只有他一个人" —— 真机就是这么变成"一个人进图"的。
type fiendWarEncounterPlan struct {
// Area 是要打的那个区域(决定进哪张副本)。
Area int
// OriginArea 是发起者按下进图时所在的格子 —— **同伴必须站在这儿**,
// 客户端自己的提示就是这句:「移动后,会与处于当前城镇的同伴一同开始战斗」。
OriginArea int
// Requester 是按下进图的人:他随后会站到 Area,队友留在 OriginArea。
// 他同时也是这一场的驱动者 —— 官方口径「第一个进入新副本的玩家为队长」。
Requester uint16
}
// runtimeRaidRosterLedBy 把 leader 挪到名册序第一位,其余顺序逐字保留。
//
// runtimeRaidGroupPartyState 拿 members[0] 当这支普通队伍的队长(UserID)与 0 号
// 座位,而 prepareRuntimePartyDungeonEntry 只在"驱动进图的 session 就是队伍队长"
// 时才会把队友搬进同一个副本(dungeon_navigation_runtime.go:753)。所以"谁驱动
// 这一场"必须排在最前面,否则队友一个都带不走。
//
// leader 不在 members 里时原样返回:调用方(超时空遭遇战)已经保证它在,
// 静默不动比在这里造一个不存在的成员安全。
func runtimeRaidRosterLedBy(members []runtimeRaidMemberState, leader uint16) []runtimeRaidMemberState {
roster := append([]runtimeRaidMemberState(nil), members...)
if leader == 0 || len(roster) < 2 || roster[0].CharID == leader {
return roster
}
for index, member := range roster {
if member.CharID != leader {
continue
}
led := make([]runtimeRaidMemberState, 0, len(roster))
led = append(led, member)
led = append(led, roster[:index]...)
led = append(led, roster[index+1:]...)
return led
}
return roster
}
// runtimeRaidFiendWarAreaRoster 是超时空这一场进图的名单:**触发者 + 原格子里
// 实时站着的所有人**,按区域的 [MEMBER MAX] 封顶,**与小队编制无关**。
//
// 依据是客户端原生机制(2026-10-01 逆向确认,见
// 超时空_同区域进图_客户端逆向规格_20261001.md):弹窗 sub_9AE390 只在频道类型 42
// 下出现,文案 dstr 75467「移动后,会与处于当前城镇的同伴一同开始战斗。确认要
// 移动吗?」,而弹窗分支只是暂停并返回 —— 客户端不组队、不收集成员,把这一格的
// 人一起带进副本完全是服务端的事。
//
// 三条取舍,都是刻意的:
//
// 1. **触发者永远第一个**:官方口径「第一个进入新副本的玩家为队长」,
// runtimeRaidRosterLedBy 之后他就是临时队伍的队长(prepareRuntimePartyDungeonEntry
// 的守卫要求驱动者就是队伍队长,见那一段的注释)。
// 2. **只收"能进图"的人**:队友搬运有个硬约定 —— 一个人准备不好,整场进图会被
// 整个拒掉("A party that cannot be prepared is REFUSED, never split")。
// 2026-10-01 21:24 那次事故就是这条把所有人挡在门外。所以这里先把不满足条件的
// 人剔除并记一行日志,绝不让他们把触发者一起拖住。
// 3. **不按小队筛**:同格里站着的人不问编制,这正是"同格里有人却只有一个人进副本"
// 的根因(原先的名单是 squad.Members)。
//
// 位置一律以**实时坐标**为准(onlinePlayers 的 area 实例表),不用战场账本 ——
// 账本滞后是今天反复出事故的那一类判断。
func (s *Service) runtimeRaidFiendWarAreaRoster(
session *gameSession,
state runtimeRaidState,
townID byte,
area int,
now time.Time,
) []runtimeRaidMemberState {
if s == nil || session == nil || area < 2 || state.Lifecycle.FiendWar == nil {
return nil
}
limit := 0
if a, ok := state.Lifecycle.FiendWar.Config.Areas[area]; ok {
limit = a.MemberMax
}
roster := make([]runtimeRaidMemberState, 0, raid.MaxAttackPartyMembers)
seen := make(map[uint16]bool, raid.MaxAttackPartyMembers)
// 触发者:他刚被 town_move.go 挪进目标区域,所以位置校验交给调用方的
// origin/area 判定;这里只做"是不是本团成员 + 复活禁锢 + 名额"。
add := func(charID uint16) bool {
if charID == 0 || seen[charID] || (limit > 0 && len(roster) >= limit) {
return false
}
member, ok := runtimeRaidFrozenMember(state, charID)
if !ok {
return false // 不是本团成员:不进图
}
if now.Before(state.Lifecycle.FiendWar.RecoveryUntil[charID]) {
return false // 复活禁锢中
}
seen[charID] = true
roster = append(roster, member)
return true
}
add(session.selectedCharacterID)
if s.onlinePlayers == nil {
return roster
}
// 原格里**实时站着**的人。「当前城镇」在超时空就等于"当前区域":每一格是独立
// 的一张 map(Fiendwar_TownNN.map),而 area 实例表同时按 channel 与 RaidKey
// 分区,所以别的频道/别的团的人不会串进来。
peers := s.onlinePlayers.GetAreaInstancePlayers(
townPresenceChannelID(session), townID, byte(area), state.RaidKey)
extra := make([]runtimeRaidMemberState, 0, len(peers))
for _, peer := range peers {
if peer.CharacterID == 0 || peer.CharacterID == session.selectedCharacterID {
continue
}
member, ok := runtimeRaidFrozenMember(state, peer.CharacterID)
if !ok {
continue
}
if reason := s.fiendWarEncounterPeerRefusal(member, townID, area); reason != "" {
// 让开,但必须说出来:一个卡在切场景的人不该把整格一起拖住。
s.logGameEvent(session, "game-fiendwar-area-peer-skipped",
"raid_key", state.RaidKey,
"char_id", peer.CharacterID,
"area", area,
"reason", reason,
"source", "runtime_raid_fiendwar_area_roster")
continue
}
extra = append(extra, member)
}
runtimeRaidSortMembers(extra)
for _, member := range extra {
if limit > 0 && len(roster) >= limit {
s.logGameEvent(session, "game-fiendwar-area-peer-over-limit",
"raid_key", state.RaidKey,
"char_id", member.CharID,
"area", area,
"limit", limit,
"roster", len(roster),
"source", "runtime_raid_fiendwar_area_roster")
break
}
add(member.CharID)
}
return roster
}
// runtimeFiendWarSquadPartyLeader 读这一支小队当前的队长标志(SquadPartyLeaders),
// 读不到就报 false。读也要持 raidMu:那张 map 是活的,战况表心跳、掉线移交和别的
// Party publish 都会写它。
func (s *Service) runtimeFiendWarSquadPartyLeader(raidKey uint32, squad byte) (uint16, bool) {
if s == nil || squad == 0 {
return 0, false
}
s.raidMu.Lock()
defer s.raidMu.Unlock()
state := s.raids[raidKey]
if state == nil || state.SquadPartyLeaders == nil {
return 0, false
}
leader, found := state.SquadPartyLeaders[squad]
return leader, found && leader != 0
}
// restoreRuntimeFiendWarSquadPartyLeader 把小队队长标志放回操作员排的那个人,并且
// **只放回被这一场进图换掉的那一个**:值已经不是 driver,就说明期间有人正经改过队长
// (掉线移交、在攻坚队管理器里重新编队),那就不该被这条覆盖。
//
// 返回这次是否真的动了它,调用方只在真的发生时写日志 —— 这条例外永远不静默。
func (s *Service) restoreRuntimeFiendWarSquadPartyLeader(raidKey uint32, squad byte, driver, leader uint16) bool {
if s == nil || squad == 0 || leader == 0 || driver == leader {
return false
}
s.raidMu.Lock()
defer s.raidMu.Unlock()
state := s.raids[raidKey]
if state == nil || state.SquadPartyLeaders == nil || state.SquadPartyLeaders[squad] != driver {
return false
}
state.SquadPartyLeaders[squad] = leader
return true
}
// Called on the character mailbox. The board tick also covers enemies arriving
// at a stationary squad; packet delivery alone is not town-load confirmation.
func (s *Service) tryRuntimeFiendWarEncounter(session *gameSession, now time.Time) error {
return s.tryRuntimeFiendWarEncounterPlanned(session, now, nil)
}
// tryRuntimeFiendWarEncounterPlanned 是遭遇战执行体。plan 为 nil 时是战况表 1 Hz 心跳
// 那一趟("我站在一个有怪的区域里" → 就地开打);非 nil 时是**一次移动触发的**那一趟,
// 目标区域与同伴名单都已由发起移动的那次调用算好(见 fiendWarEncounterPlan:
// op36 的 area 是要打的区域,发起者站的格子才是同伴的锚点)。
func (s *Service) tryRuntimeFiendWarEncounterPlanned(session *gameSession, now time.Time, plan *fiendWarEncounterPlan) error {
if !isCurrentFiendWarRaidChannel(session) {
return nil
}
state, ok := s.runtimeRaidForMember(session.selectedCharacterID)
if !ok || state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive || state.Lifecycle.FiendWar == nil {
return nil
}
b := state.Lifecycle.FiendWar
member, ok := runtimeRaidFrozenMember(state, session.selectedCharacterID)
if !ok {
return nil
}
// The native Fiend War scene does not expose the ordinary result exit.
// Its existing board tick retries the full return on the character mailbox.
session.dungeon.mu.Lock()
runtime := session.dungeon.runtime
if runtime != nil {
// 清完自动回城 —— **只看这一局对本人有没有结束,不看是谁**。
//
// 操作员 2026-10-01 定案:「不管是谁 谁是驱动者 谁收尾 这都不重要
// 通关直接回城就是了;当然了 这些逻辑只适用于超时空」。
//
// 这里原来写的是 `runtime.raidDungeon.LifecycleToken == runtime.lifecycleToken`,
// 那只对**开局的人**成立:超时空一个区域共用一条 run,而
// prepareRuntimePartyDungeonEntry **先备队友** ⇒ 常常是队友开局、驱动者 adopt,
// 驱动者手上那份 binding 带的是开局人的 token,永远不等于自己的本地 token。
// 我第 4 轮试过用"攻坚账本里还留着这条 run"兜底 —— 那也是错的:通关处理本身
// 就会把那条 run 从账本里删掉,等该回城时账本已经空了。
//
// 真机把两种兜底各打脸一次:23:38 开局人收尾失败(账本还在)⇒ 驱动者回了城
// ("队长自动回城了 队员没回去");23:54 开局人收尾成功(账本被删)⇒ 同一个
// 驱动者又回不去("队员回了 队长没回去")。凡是"谁开的、谁报的、账本还剩什么"
// 这类判断都会再翻一次,所以全部拿掉。
//
// 剩下的三条都不是"谁":我这份绑定指向的确实是此刻在打的这局攻坚
//(RaidKey/MapID 对齐,见 runtimeFiendWarClearReturnBindingAccepts),
// 以及"这一局对我已经结束并且结算做完了"(completedRuntimeRaidDungeonExitReady)。
// 安东/卢克走不到这里(上面 isCurrentFiendWarRaidChannel + RaidMode 已经挡过)。
ready := state.Started && state.EntryCostPaid && state.RaidMode == runtimeRaidOwnerFiendWar &&
runtime.lifecycleToken != 0 && runtime.raidDungeon.RaidKey == state.RaidKey &&
runtime.raidDungeon.Phase == state.Lifecycle.Phase &&
runtimeFiendWarClearReturnBindingAccepts(runtime) &&
completedRuntimeRaidDungeonExitReady(runtime, session.selectedCharacterID)
if ready {
// 清完一个区域后的自动回城由服务器驱动,玩家没有按任何东西:它不是
// 「队内成员单独自己回城」,所以编制跟着小队留下。
runtime.townReturnKeepsSquadLabel = true
}
session.dungeon.mu.Unlock()
if !ready {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
transition, err := s.prepareCurrentDungeonTownTransitionForSession(ctx, session, session.selectedCharacterID)
if err != nil {
return err
}
// 超时空:BOSS 房(量子时空传送站)通关是**整场攻坚的收尾**,落点必须是待机区。
//
// 通用路径给的是"回到你刚才所在的作战区域"(原点快照,见 position_source=
// current_exe_op35_session_scoped_luke_runtime_origin_snapshot)。普通作战区域
// 有真实坐标(实测 area14 → 113,462),所以那条路一直是对的;但 BOSS 区域
// 30/31 在快照里**没有坐标** —— 2026-10-01 16:06 真机 char_id=1 通关 4978 后
// 服务端发的是 `town_id=98 area_id=30 position_x=0 position_y=0`,客户端拿不到
// 可用落点,于是通关结算都发了、人也留在副本里不回城。
//
// 待机区坐标只有一个来源(PVF 等待室的 NPC),也就是撤退/掉线那条路用的
// fiendWarWaitingAreaScene —— 这里复用它,不另造一套坐标。
if area, found := b.AreaForDungeon(int(runtime.Dungeon.ID)); found && strings.HasSuffix(area.Kind, "boss") {
transition, err = s.fiendWarWaitingRoomTransition(state, session.selectedCharacterID, transition)
if err != nil {
return err
}
}
s.logGameEvent(session, "game-fiendwar-clear-auto-return", "raid_key", state.RaidKey,
"dungeon_id", runtime.Dungeon.ID, "lifecycle_token", runtime.lifecycleToken,
"town_id", transition.TownID, "area_id", transition.AreaID, "position_source", transition.PositionSource)
// 账本里本人的区域要跟着落到**目的地那一格**:这一脚之后他站在原区域/待机区,
// 而不是他刚被带进去那张副本所在的格子。不落就会被下一脚的相邻判定挡死
// (真机 2026-10-02 14:20 礼白666,见 markRuntimeFiendWarMemberArea 的注释)。
if markErr := s.markRuntimeFiendWarMemberArea(session.selectedCharacterID, int(transition.AreaID), "fiendwar_clear_auto_return"); markErr != nil {
s.logGameEvent(session, "game-fiendwar-member-area-mark-failed",
"char_id", session.selectedCharacterID, "area", transition.AreaID, "error", markErr)
}
// op132 是它自己的处理器,不持有 world lock,与既有 op132 调用点一致
// (dungeon_return_town.go:352)。
return s.sendCurrentCompletedDungeonReturnToTown(session, runtime, transition, 132, "fiendwar_clear_auto_return", false)
}
session.dungeon.mu.Unlock()
// Which squad this member walks in with is resolved LIVE-first, with a defined
// answer for the live/frozen divergence instead of the silent return nil this
// used to be; see runtimeRaidFiendWarEncounterSquad for the measured deadlock.
squad := runtimeRaidFiendWarEncounterSquad(state, session.selectedCharacterID)
groupID := runtimeRaidNormalizedGroup(member.GroupIndex)
if squad.Group != 0 {
groupID = squad.Group
}
session.townMu.Lock()
position := session.townPositionSnapshot
session.townMu.Unlock()
// 区域以**实时坐标**为准(客户端眼里的"我在哪一格"),战场账本只兜底。
// 账本滞后时(真机 2026-10-01:人在 14、账本记着 10)按账本算会去开错副本,
// 甚至因为 position.AreaID != area 直接静默放弃整场遭遇战。
area := b.MemberArea(member.CharID, groupID)
originArea := area
switch {
case plan != nil:
// 移动触发的那一趟:目标是"要打的区域",锚点是发起者按下进图时站的格子。
area, originArea = plan.Area, plan.OriginArea
case int(position.TownID) == b.Config.WaitingTown && int(position.AreaID) >= 2:
if liveArea := int(position.AreaID); liveArea != area {
s.logGameEvent(session, "game-fiendwar-encounter-area-from-live-position",
"raid_key", state.RaidKey, "char_id", session.selectedCharacterID,
"live_area", liveArea, "battle_area", area,
"reason", "battle_member_area_lags_the_client_position",
"source", "try_runtime_fiendwar_encounter")
area = liveArea
}
originArea = area
}
// 战况表心跳(plan==nil)与计划进图的竞态收口 —— 见
// fiendWarPlannedEncounterUntil 的注释。
//
// 只挡 plan==nil 这一路:计划那一趟(plan!=nil)正是被保护的对象,不能挡。
// 挡在这里而不是更早:本人副本里"清完自动回城"那段在上面已经跑完,心跳即使
// 让位也不会漏掉收尾。
//
// 真机 2026-10-01 本地 00:03:53(op36 53.743 / 心跳刻度 53.73 / 单人进图 53.790):
// 这一趟算出来的 originArea 是**目标区域 7**(计划那一脚刚把本人坐标推过去),
// 格 4 里的 char 22 于是完全不在名单里,日志上一行拒绝都没有 —— 只留下
// game-current-party-frame-op9-send party_id=4 slot_count=1 这条"临时队伍只有
// 一个人"的铁证。现在这一趟会明着让位并记一行,不再静默单人开图。
if plan == nil && s.runtimeFiendWarPlannedEncounterPending(session, now) {
s.logGameEvent(session, "game-fiendwar-encounter-heartbeat-deferred",
"raid_key", state.RaidKey, "char_id", session.selectedCharacterID,
"area", area, "origin_area", originArea,
"reason", "a_planned_move_encounter_is_in_flight_on_this_mailbox",
"source", "try_runtime_fiendwar_encounter")
return nil
}
if now.Before(b.RecoveryUntil[member.CharID]) {
return nil
}
if !b.AreaOpen(area) || b.Areas[area].Group != 0 {
session.fiendWarEncounterAt = time.Time{}
return nil
}
if ready, _ := s.currentTownEnterSelectReady(session); !ready {
return nil
}
if plan == nil && (int(position.TownID) != b.Config.WaitingTown || int(position.AreaID) != originArea) {
return nil
}
// 进图名单 = 发起者的**编制小队**(squad 已经由 runtimeRaidFiendWarEncounterSquad
// 解出)。这里不再按"附近的人"重算:操作员 2026-10-01 明确定案不做附近自动组队,
// 暂时就按小队编制走。
newEncounter := session.fiendWarEncounterRaidKey != state.RaidKey || session.fiendWarEncounterArea != position.AreaID || session.fiendWarEncounterAt.IsZero()
if newEncounter {
session.fiendWarEncounterRaidKey, session.fiendWarEncounterArea = state.RaidKey, position.AreaID
session.fiendWarEncounterAt = now
}
if squad.Source == runtimeRaidFiendWarSquadSourceFrozen {
// The live roster has no row for this member at all, so the frozen phase
// roster answered instead. Kept loud. 注意:它只影响 battle 的 group 记法,
// **不再**影响进图资格 —— 见下面名单那一段。
s.logGameEvent(session, "game-fiendwar-encounter-squad-from-frozen-row",
"raid_key", state.RaidKey,
"char_id", session.selectedCharacterID,
"area", area,
"group", squad.Group,
"reason", "member_absent_from_the_live_roster",
"source", "try_runtime_fiendwar_encounter")
}
// 进图名单 = **触发者 + 原格子里实时站着的所有人**,按区域 [MEMBER MAX] 封顶,
// **与小队编制无关**。
//
// 这是客户端原生机制,已逆向确认(2026-10-01):
//
// 弹窗函数 sub_9AE390(VA 0x9AE390)第一件事就是 `cmp eax,0x2a` —— 频道类型
// 42(超时空)才弹窗;文案 dstr 75467「移动后,会与处于当前城镇的同伴一同
// 开始战斗。确认要移动吗?」。调用点 sub_1195060 里,弹窗分支
// `test al,al / jne 0x11951a8` 只是**暂停并返回**,不组任何包;玩家点"确认"
// 之后走的是普通移动 sub_11936E0。
//
// 也就是说:客户端不组队、不做成员收集,**把这一格的人一起带进副本完全是
// 服务端的事**。「当前城镇」在超时空就等于"当前区域"—— 每一格是独立的一张
// map(map/GContents/180621_Fiendwar/Town/Fiendwar_TownNN.map)。
//
// 所以名单不再取 squad.Members:那正是"同格里站着别人、却只有一个人进副本"
// 的根因。小队概念只剩两个用途 —— battle 的 group 记法,以及下面小队队长
// 标志的保护。
members := s.runtimeRaidFiendWarAreaRoster(session, state, position.TownID, originArea, now)
if len(members) == 0 {
return nil
}
// 每个人都必须**确实站在该站的地方**并且准备好进图:
// - 发起者已经挪进目标区域(他的 op36 已经被应用),所以查 Area;
// - 其余人留在发起者按下进图的那个格子里,所以查 OriginArea。
// 这里同样以实时坐标为准:上面那句 `b.MemberArea(...) != area` 用的是战场账本,
// 账本一滞后就会把整场遭遇战静默掐掉(2026-10-01 真机:人在 14、账本记着 10)。
for _, member := range members {
if now.Before(b.RecoveryUntil[member.CharID]) {
return nil
}
want := originArea
if plan != nil && member.CharID == plan.Requester {
want = area
}
if reason := s.fiendWarEncounterPeerRefusal(member, position.TownID, want); reason != "" {
s.logGameEvent(session, "game-fiendwar-encounter-peer-not-ready",
"raid_key", state.RaidKey,
"char_id", session.selectedCharacterID,
"peer_id", member.CharID,
"area", area,
"origin_area", originArea,
"want_area", want,
"reason", reason,
"source", "try_runtime_fiendwar_encounter")
return nil
}
peer, online := s.onlineGameSession(member.CharID)
if !online {
return nil
}
if ready, _ := s.currentTownEnterSelectReady(peer); !ready {
return nil
}
if _, bound := bindCurrentTownSelectorOrigin(peer); !bound {
return nil
}
}
// 这一场进图的**队长必须是把它驱动起来的那个人**(移动那一趟就是
// plan.Requester;战况表心跳那一趟上面已经保证就是当前 session)。
// 两件事同时依赖它:
//
// 1) 客户端侧:选中页归"选择的所有者"(队长)所有,队友是被队长带着走的;
// 2) 服务端侧:prepareRuntimePartyDungeonEntry 的第一道守卫就是
// `state.UserID != leader.selectedCharacterID → return nil, nil`,
// 而 RuntimePartySnapshot.StateFor 给**每个**成员的 UserID 都是
// snapshot.Leader(runtime_manager.go:652)。
//
// runtimeRaidGroupPartyState 原来把 UserID 定成 members[0],也就是名册序第一个
// (runtimeRaidSortMembers 按 SlotOrder 排)。只要按下进图的不是名册序第一,
// 这支临时队伍就会被建出来、op11/op9/op153 也发出去,然后在上面那条守卫上
// **静默**丢掉:队友一个都不搬,驱动者一个人进图。真机 2026-10-01 20:56:48
// 正是如此 —— party_id=4 slot_count=2 发给了 char 1 与 char 20,紧接着只有
// char 1 拿到 op29;日志里连 game-party-dungeon-entry-blocked 都没有,
// 因为那趟 fan-out 根本没跑。20:56:11~13 那三条
// `game-fiendwar-encounter-deferred reason=the_squad_leader_owns_the_encounter`
// 是同一个"队长 ≠ 按进图的人"的另一面。
//
// 编制不动:这里重排的只是**这一支临时普通队伍**的名册序(它同时决定这支队伍的
// 0 号座位),攻坚队/小队的名册与座位一个字节都不改。
driver := session.selectedCharacterID
if plan != nil && plan.Requester != 0 {
driver = plan.Requester
}
roster := runtimeRaidRosterLedBy(members, driver)
// 临时队伍的队长换了,但**攻坚队里那一支小队的队长标志不跟着换**:
// replaceManagedRuntimeParty → publishRuntimePartySnapshot 会把刚发布的普通队伍的
// 队长无条件镜像进 SquadPartyLeaders,而客户端每个成员的 队长标志 与战况表的
// 队长按钮读的就是它(runtimeRaidMemberLeadsSquad)。进图这一场为了把队友带走,
// 队长必须让给驱动者,可"谁是这支小队的队长"是操作员在攻坚队管理器里排的编制
// 事实,不该因为"这次是谁按的进图"而改手。所以记下发布前的值,发布完立刻放回去。
squadLeader, hadSquadLeader := s.runtimeFiendWarSquadPartyLeader(state.RaidKey, squad.Group)
managed, ok := s.replaceManagedRuntimeParty(runtimeRaidGroupPartyState(state, roster))
if !ok {
return fmt.Errorf("fiendwar encounter party unavailable")
}
if hadSquadLeader && squadLeader != driver &&
s.restoreRuntimeFiendWarSquadPartyLeader(state.RaidKey, squad.Group, driver, squadLeader) {
s.logGameEvent(session, "game-fiendwar-squad-leader-restored",
"raid_key", state.RaidKey,
"squad", squad.Group,
"driver", driver,
"leader", squadLeader,
"reason", "the_entry_party_is_led_by_the_driver_while_the_squad_keeps_its_own_leader")
}
request := dungeoncmd.SelectDungeonRequest{DungeonID: uint32(b.Config.Areas[area].Dungeon)}
err := s.sendManagedRuntimePartySnapshots(managed)
if err == nil {
err = s.handleDungeonSelectUpper(session, encodeCurrentDungeonSelectRequest(request))
}
session.dungeon.mu.Lock()
entered := session.dungeon.runtime != nil
session.dungeon.mu.Unlock()
if !entered {
for _, member := range members {
if peer, online := s.onlineGameSession(member.CharID); online {
if cleanupErr := s.clearSingleRaidOrdinaryPartyProjection(peer, "fiendwar_encounter_rejected"); cleanupErr != nil && err == nil {
err = cleanupErr
}
}
}
// 兜底:队伍搬不动时,**至少让触发者自己进去**。
//
// 2026-10-01 21:24 那次事故就是 fan-out 失败把整场进图一起拒了,于是
// "谁都进不去"——比不改还糟(不改时单人至少能进)。临时队伍刚在上面被
// 清干净,所以这一趟走的是**无队伍的 select**,也就是改动前的老行为。
// 最坏情况因此收敛回"一个人进",而不是"一个人都进不去"。
//
// 只重试一次;成功就把 err 清掉,失败则保留原始错误。
// 注意:handleDungeonSelectUpper 自己会取 session.dungeon.mu,
// 所以这里**不能**持锁调用。
retryErr := s.handleDungeonSelectUpper(session, encodeCurrentDungeonSelectRequest(request))
session.dungeon.mu.Lock()
entered = session.dungeon.runtime != nil
session.dungeon.mu.Unlock()
if entered {
err = nil
} else if retryErr != nil {
err = retryErr
}
s.logGameEvent(session, "game-fiendwar-encounter-solo-fallback",
"raid_key", state.RaidKey, "area", area,
"dungeon_id", request.DungeonID,
"entered", entered, "error", retryErr,
"reason", "the_party_fanout_failed_so_the_trigger_member_enters_alone")
session.fiendWarEncounterAt = now
}
s.logGameEvent(session, "game-fiendwar-encounter-entry", "raid_key", state.RaidKey, "area", area, "dungeon_id", request.DungeonID, "entered", entered, "error", err)
if entered {
// 整支队伍确实进去了 —— 把**每个人**在战场账本里的区域补成这一格。
//
// 队友是被 prepareRuntimePartyDungeonEntry 备好、由驱动者按进图那一脚一起
// 搬进去的,**他们没有走过 op36 那一步**,所以 battle.MemberAreas 里他们
// 仍然停在"进图之前站的那一格"。战况表的 op1455 成员行取的就是 MemberArea,
// 账本一滞后,客户端眼里这一格里就只剩驱动者一个人 ——「简易战况表」上该侧
// 于是只画得出一个编制徽章。
//
// 真机 2026-10-02 13:50(raid 12058625):
// 上侧 c1 走进 30、c20 被带进 4978,账本里 c20 仍停在 28;
// 下侧 c22 走进 31、c21 被带进 4953,账本里 c21 仍停在 29。
// 操作员看到的正是「黑暗有 1、2 队进了只显示 1 队(队长编制 1);
// 纯白 3、4 队进了只显示 4 队(队长编制 4)」。
//
// 官方资料图里那一行徽章是**这一侧房间里的所有编制**(1/3/4 与 7/5/6/8),
// 所以这一笔必须按"所有参与者"补,而不是只补驱动者。
//
// **但"参与者"必须是真的进去了的人**:生产路径上 entered 也可以由"兜底单人进图"
// 成立(队友那一趟 fan-out 失败时只有 trigger 者进去),那时候把整队都记进这一格
// 就是**反向**的账本漂移 —— 人在原格、账本写新格,下一脚移动又会被"旧格子不相邻"
// 挡死(8 客户端仿真 TestFiendWarSimSharedCellEntryTakesEveryone 抓到的就是这个)。
// 判据:他手上确实握着这一局这条 run。
entrants := make([]runtimeRaidMemberState, 0, len(members))
for _, member := range members {
peer, online := s.onlineGameSession(member.CharID)
if !online || peer == nil {
continue
}
peer.dungeon.mu.Lock()
bound := peer.dungeon.runtime != nil && peer.dungeon.runtime.raidDungeon.RaidKey == state.RaidKey
peer.dungeon.mu.Unlock()
if bound {
entrants = append(entrants, member)
}
}
if markErr := s.markRuntimeFiendWarPartyInArea(state.RaidKey, entrants, area, "fiendwar_encounter_entry"); markErr != nil {
s.logGameEvent(session, "game-fiendwar-encounter-area-mark-failed",
"raid_key", state.RaidKey, "area", area, "error", markErr)
}
}
return err
}
// markRuntimeFiendWarMemberArea 把某人战场账本里的区域改成 area。
//
// 它和 markRuntimeFiendWarPartyInArea 是一对"进/出":
// - **进**:整队被带进某一格(key 在 encounter entry);进的那一格是**副本所在**的格子;
// - **出**:他打完/撤退/被送回去时,落点是原区域或待机区 —— 账本必须跟着落到**那一格**。
//
// 不跟着落就会卡死,而且卡的是"走不动":
// `MoveMember` 的相邻判定用的是账本里的个人区域(module 层
// `b.GroupAreas[group] = b.MemberArea(character, group)` → `Move`),账本一滞后,
// 玩家点隔壁那一格会被按"旧格子与目标不相邻"拒掉。
//
// 真机 2026-10-02 14:20(礼白666,raid 12058625):
//
// 06:18:58 game-fiendwar-party-area-marked area=16 char_ids=1 ← 他被队友带进 area16 的副本
// 06:19:05 game-dungeon-full-town-route-send ... area_id=11 ← 打完回到 11(他的原区域)
// 06:20:59 game-fiendwar-move-refused requested_area_id=7 keep_area_id=11
// reason=fiendwar areas are not adjacent
// 而 area 11 的 next = [7 10 12 16] **明明含 7** —— 因为判定用的是账本里的 16。
func (s *Service) markRuntimeFiendWarMemberArea(characterID uint16, area int, source string) error {
if s == nil || characterID == 0 || area < 2 {
return nil
}
s.raidMu.Lock()
state := s.runtimeRaidByMemberLocked(characterID)
if state == nil || state.Lifecycle.FiendWar == nil {
s.raidMu.Unlock()
return nil
}
member, ok := runtimeRaidFrozenMember(*state, characterID)
if !ok {
s.raidMu.Unlock()
return nil
}
group := runtimeRaidNormalizedGroup(member.GroupIndex)
if state.Lifecycle.FiendWar.MemberArea(characterID, group) == area {
s.raidMu.Unlock()
return nil
}
battle := state.Lifecycle.FiendWar.Clone()
if battle.MemberAreas == nil {
battle.MemberAreas = make(map[uint16]int)
}
battle.MemberAreas[characterID] = area
peerElsewhere := slices.ContainsFunc(state.FrozenMembers, func(peer runtimeRaidMemberState) bool {
return peer.CharID != characterID && runtimeRaidNormalizedGroup(peer.GroupIndex) == group &&
battle.MemberArea(peer.CharID, group) != area
})
if !peerElsewhere {
battle.GroupAreas[group] = area
}
state.Lifecycle.FiendWar = battle
state.Lifecycle.Revision++
raidKeyCopy := state.RaidKey
raidPhase := state.Lifecycle.Phase
s.raidMu.Unlock()
s.logPacketEvent("game-fiendwar-member-area-marked",
"raid_key", raidKeyCopy,
"raid_phase", raidPhase,
"char_id", characterID,
"area", area,
"source", source)
return nil
}
// syncRuntimeFiendWarMemberAreaFromLivePosition 把"这个人现在实际站在哪一格"拉回战场账本,
// 并返回更新后的 state(没变就原样返回)。
//
// 这是移动路径上的**兜底**:账本永远可能有滞后(被带进副本、重连、服务端搬运……),
// 而相邻判定读的就是账本。与遭遇战那条路同口径(live-first,见
// game-fiendwar-encounter-area-from-live-position)。
func (s *Service) syncRuntimeFiendWarMemberAreaFromLivePosition(characterID uint16, liveArea int, source string) runtimeRaidState {
if s == nil || characterID == 0 || liveArea < 2 {
return runtimeRaidState{}
}
s.raidMu.Lock()
state := s.runtimeRaidByMemberLocked(characterID)
if state == nil || state.Lifecycle.FiendWar == nil {
s.raidMu.Unlock()
return runtimeRaidState{}
}
member, ok := runtimeRaidFrozenMember(*state, characterID)
if !ok {
s.raidMu.Unlock()
return runtimeRaidState{}
}
group := runtimeRaidNormalizedGroup(member.GroupIndex)
ledger := state.Lifecycle.FiendWar.MemberArea(characterID, group)
if ledger == liveArea {
ready := cloneRuntimeRaidState(*state)
s.raidMu.Unlock()
return ready
}
battle := state.Lifecycle.FiendWar.Clone()
if battle.MemberAreas == nil {
battle.MemberAreas = make(map[uint16]int)
}
battle.MemberAreas[characterID] = liveArea
peerElsewhere := slices.ContainsFunc(state.FrozenMembers, func(peer runtimeRaidMemberState) bool {
return peer.CharID != characterID && runtimeRaidNormalizedGroup(peer.GroupIndex) == group &&
battle.MemberArea(peer.CharID, group) != liveArea
})
if !peerElsewhere {
battle.GroupAreas[group] = liveArea
}
state.Lifecycle.FiendWar = battle
state.Lifecycle.Revision++
ready := cloneRuntimeRaidState(*state)
raidKeyCopy := state.RaidKey
s.raidMu.Unlock()
s.logGameEvent(nil, "game-fiendwar-member-area-from-live-position",
"raid_key", raidKeyCopy,
"char_id", characterID,
"live_area", liveArea,
"battle_area", ledger,
"reason", "battle_member_area_lags_the_client_position",
"source", source)
return ready
}
// markRuntimeFiendWarPartyInArea 把"这支临时队伍刚被整体带进 area 这一格"记进战场账本。
//
// 与 runtimeFiendWarRejoinLocation(重连回到待机区那一笔)同形:本人一格、
// 他所在编制也指向这一格(只有当他那一队没有别人还留在原格时才动编制)。
func (s *Service) markRuntimeFiendWarPartyInArea(raidKey uint32, members []runtimeRaidMemberState, area int, source string) error {
if s == nil || raidKey == 0 || area <= 0 || len(members) == 0 {
return nil
}
s.raidMu.Lock()
state := s.raids[raidKey]
if state == nil || state.Lifecycle.FiendWar == nil ||
state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive {
s.raidMu.Unlock()
return nil
}
battle := state.Lifecycle.FiendWar.Clone()
if battle.MemberAreas == nil {
battle.MemberAreas = make(map[uint16]int)
}
group := func(member runtimeRaidMemberState) byte {
return runtimeRaidNormalizedGroup(member.GroupIndex)
}
moved := make([]uint16, 0, len(members))
for _, member := range members {
if _, ok := runtimeRaidFrozenMember(*state, member.CharID); !ok {
continue
}
if battle.MemberArea(member.CharID, group(member)) == area {
continue
}
battle.MemberAreas[member.CharID] = area
moved = append(moved, member.CharID)
}
if len(moved) == 0 {
s.raidMu.Unlock()
return nil
}
// 编制指针只在"这一队没有别人还留在别处"时跟着走(同重连那一笔)。
for _, member := range members {
if !slices.Contains(moved, member.CharID) {
continue
}
groupID := group(member)
peersElsewhere := slices.ContainsFunc(state.FrozenMembers, func(peer runtimeRaidMemberState) bool {
return peer.CharID != member.CharID && group(peer) == groupID && battle.MemberArea(peer.CharID, groupID) != area
})
if !peersElsewhere {
battle.GroupAreas[groupID] = area
}
}
state.Lifecycle.FiendWar = battle
state.Lifecycle.Revision++
raidKeyCopy := state.RaidKey
raidPhase := state.Lifecycle.Phase
s.raidMu.Unlock()
marked := make([]string, 0, len(moved))
for _, charID := range moved {
marked = append(marked, strconv.Itoa(int(charID)))
}
s.logPacketEvent("game-fiendwar-party-area-marked",
"raid_key", raidKeyCopy,
"raid_phase", raidPhase,
"area", area,
"char_ids", strings.Join(marked, ","),
"source", source,
"reason", "the_party_was_carried_into_this_area_without_walking_in")
return nil
}
// fiendWarMoveRefusalNotice 把一次被拒的移动翻译成能给玩家看的一句话。
func fiendWarMoveRefusalNotice(reason string) string {
switch {
case strings.Contains(reason, "member limit"):
return "这个区域已经满员了(每个区域最多 4 人)。"
case strings.Contains(reason, "recovering"):
return "你还在复活禁锢中,暂时无法移动。"
case strings.Contains(reason, "boss gate"):
return "量子时空传送站尚未激活:先打掉前哨基地与兵站本营。"
case strings.Contains(reason, "not adjacent"):
return "这个区域与你现在的位置不相邻,过不去。"
case strings.Contains(reason, "group inactive"):
return currentFiendWarSquadlessNotice
default:
return "现在无法移动到该区域。"
}
}
// refuseRuntimeFiendWarMoveLocked 是"拒绝也要回话"的收口。
//
// 血泪教训(2026-10-01 真机,礼白666):超时空里客户端**自己**允许玩家走到任何相邻
// 格子,服务端一旦拒绝而**一个包都不发**,客户端就永远停在"正在进入"上 —— 玩家看到
// 的就是被卡死。所以任何一条拒绝路径都必须:
//
// 1. 用 op0x16(坐标)+op0x17(区域) 把客户端拽回服务端认定的位置(宁可按老位置弹回,
// 也不能让客户端等一个永远不来的场景切换);
// 2. 给一句人话提示,说明为什么过不去。
//
// 调用者持 session.townMu(与 town_move.go 的两处拒绝点一致)。
func (s *Service) refuseRuntimeFiendWarMoveLocked(session *gameSession, request currentTownSetUserAreaRequest, reason string) {
if session == nil {
return
}
position := session.townPositionSnapshot
s.logGameEvent(session, "game-fiendwar-move-refused",
"char_id", session.selectedCharacterID,
"requested_town_id", request.TownID, "requested_area_id", request.AreaID,
"keep_town_id", position.TownID, "keep_area_id", position.AreaID,
"position_valid", position.PositionValid,
"reason", reason, "source", "fiendwar_move_refusal_answered")
if position.TownID == 0 || !position.PositionValid {
// 连"他站在哪"都不知道就没法弹回去;日志留痕,至少能查。
return
}
row := currentSceneTransitionRow{
ObjectOrResourceKey: currentSceneActorObjectKey(session.selectedCharacterID),
Value1: position.PositionX,
Value2: position.PositionY,
Value3: position.MovementCode,
}
if err := s.sendCurrentTownLocationNotifications(
session, session.selectedCharacterID, position.TownID, position.AreaID,
row, "fiendwar_move_refused"); err != nil {
s.logGameEvent(session, "game-fiendwar-move-refused-answer-failed",
"char_id", session.selectedCharacterID, "error", err)
return
}
_ = s.sendNotice(session, noticeRequest{
Surface: noticeSurfaceSystemMagenta,
Text: fiendWarMoveRefusalNotice(reason),
Source: "fiendwar_move_refused",
})
}
// fiendWarCompletedDungeonExitReady 报告"这个人现在就在超时空副本里,而且这一场
// 攻坚已经打完" —— 这时"返回城镇"必须放行,结算由服务端发还是客户端自己放都行。
//
// 真机 2026-10-01 20:35(9999):他那一场走的是 dungeon_boss_die_settlement.go 里
// 的 native 路线(game-dungeon-native-raid-settlement-owned,服务端一个结算包都没发),
// 于是 settlementClearRewardSent 永远是 false → 连按十几次返回城镇全部被
// game-dungeon-card-exit-blocked reason=settlement_result_not_ready 挡掉 —— 死按钮。
func (s *Service) fiendWarCompletedDungeonExitReady(session *gameSession, runtime *runtimeDungeonState) bool {
if session == nil || runtime == nil || runtime.raidDungeon.RaidKey == 0 {
return false
}
state, ok := s.runtimeRaidForMember(session.selectedCharacterID)
if !ok || state.RaidMode != runtimeRaidOwnerFiendWar || state.Lifecycle.FiendWar == nil {
return false
}
if state.Lifecycle.Status == runtimeRaidLifecycleAwaitingMovie ||
state.Lifecycle.Status == runtimeRaidLifecycleAwaitingRewards ||
state.Lifecycle.Status == runtimeRaidLifecycleStandby ||
state.Lifecycle.Status == runtimeRaidLifecycleFinished {
return true
}
// 阶段目标完成 = 罗什已死 = 整场收尾([PHASE MAX] 1)。
return runtimeRaidPhaseObjectivesComplete(state, state.Lifecycle.Phase)
}
func (s *Service) moveRuntimeFiendWarGroup(characterID uint16, target int) (runtimeRaidState, error) {
s.raidMu.Lock()
defer s.raidMu.Unlock()
state := s.runtimeRaidByMemberLocked(characterID)
if state == nil || state.Lifecycle.FiendWar == nil || state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive {
return runtimeRaidState{}, errRuntimeRaidNotStarted
}
member, ok := runtimeRaidFrozenMember(*state, characterID)
if !ok {
return runtimeRaidState{}, errRuntimeRaidNotMember
}
if state.Lifecycle.FiendWar == nil {
// 超时空:区域是共享战场,一个人物理上只站在一个区域里,"一支小队一次只打
// 一个门"这条占位规则不适用于它(后到的人要能走进正在打的区域一起打)。
// 安东/卢克逐字保留。
if _, occupied := state.Lifecycle.GroupOccupancy[runtimeRaidSeatFor(member)]; occupied {
return runtimeRaidState{}, errRuntimeRaidGroupOccupied
}
}
battle := state.Lifecycle.FiendWar.Clone()
if battle.MemberAreas == nil {
battle.MemberAreas = make(map[uint16]int)
}
for _, peer := range state.FrozenMembers {
if _, exists := battle.MemberAreas[peer.CharID]; !exists {
battle.MemberAreas[peer.CharID] = battle.MemberArea(peer.CharID, runtimeRaidNormalizedGroup(peer.GroupIndex))
}
}
group := runtimeRaidNormalizedGroup(member.GroupIndex)
if err := battle.MoveMember(characterID, group, target, s.gameplayNow()); err != nil {
return runtimeRaidState{}, err
}
battle.GroupAreas[group] = target
state.Lifecycle.FiendWar = battle
state.Lifecycle.Revision++
return cloneRuntimeRaidState(*state), nil
}
func (s *Service) rollbackRuntimeFiendWarMove(before runtimeRaidState, characterID uint16, target int) {
s.raidMu.Lock()
defer s.raidMu.Unlock()
state := s.raids[before.RaidKey]
if state == nil || state.StartAttempt != before.StartAttempt || state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive || before.Lifecycle.FiendWar == nil || state.Lifecycle.FiendWar == nil {
return
}
member, ok := runtimeRaidFrozenMember(*state, characterID)
if !ok {
return
}
group := runtimeRaidNormalizedGroup(member.GroupIndex)
battle := state.Lifecycle.FiendWar.Clone()
if battle.MemberArea(characterID, group) != target || battle.RecoveryUntil[characterID] != before.Lifecycle.FiendWar.RecoveryUntil[characterID] {
return
}
battle.MemberAreas[characterID] = before.Lifecycle.FiendWar.MemberArea(characterID, group)
// A failed write must not roll back another member's movement or a board tick.
if battle.GroupAreas[group] == target {
peerAtTarget := false
for _, peer := range state.FrozenMembers {
if peer.CharID != characterID && runtimeRaidNormalizedGroup(peer.GroupIndex) == group && battle.MemberArea(peer.CharID, group) == target {
peerAtTarget = true
}
}
if !peerAtTarget {
battle.GroupAreas[group] = before.Lifecycle.FiendWar.GroupAreas[group]
}
}
state.Lifecycle.FiendWar = battle
state.Lifecycle.Revision++
}
func (s *Service) handleRuntimeFiendWarBoss(session *gameSession, body []byte) error {
if len(body) != 8 {
return nil
}
kind, value := int(binary.LittleEndian.Uint32(body[:4])), int(binary.LittleEndian.Uint32(body[4:]))
session.dungeon.mu.Lock()
defer session.dungeon.mu.Unlock()
runtime := session.dungeon.runtime
// **不能要求 runtime.raidDungeon.loadedNotified。**
//
// 一个区域共用一条 run,而 run 上的 token 是**开局人**的;adopt 进来的成员
// (往往就是**队长** —— prepareRuntimePartyDungeonEntry 先备队友)自己那份绑定
// 带着开局人的 token、本地 token 是另一个,于是
// notifyRuntimeRaidDungeonLifecycleLocked 走"加入者"分支:**`loaded` 直接跳过**,
// 只有 `completed` 才在本地留一笔。也就是说**他的 loadedNotified 永远是 false**。
//
// 真机 2026-10-02 12:34~12:38(raid 12058625,分片 012,两两进罗什):
// 4978 的 run 由 char 20 开(token=11)→ 队长 char 1 adopt
// 4953 的 run 由 char 21 开(token=13)→ 队长 char 22 adopt
// lifecycle 行里只有这两个**队员**的 selected/loaded,两个队长一条都没有;
// 而 kind=1(机制)上报**只有队长在发**(队员只发 kind=0 血条心跳)——
// 于是每一条机制上报都在这里被静默丢掉:机制条停在 1111、两侧 state 停在 5,
// 客户端反复刷同一颗球。
// 排查时"rejected 0 / dropped 0"正是因为这个出口既不记日志也不说话。
//
// 真正该问的是"**这一场对他还在跑吗**":completed/abandoned 两个标记足够了 ——
// 加上下面账本里那条 run 的存在性(runtimeRaidDungeonLifecycleReportAccepted),
// 已经能保证他确实在这场攻坚的这个副本里。
if runtime == nil || runtime.raidDungeon.RaidKey == 0 || runtime.raidDungeon.completedNotified || runtime.raidDungeon.abandonedNotified {
if runtime != nil {
s.logGameEvent(session, "game-fiendwar-boss-report-dropped",
"raid_key", runtime.raidDungeon.RaidKey,
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Request.DungeonID,
"kind", kind,
"value", value,
"loaded_notified", runtime.raidDungeon.loadedNotified,
"completed_notified", runtime.raidDungeon.completedNotified,
"abandoned_notified", runtime.raidDungeon.abandonedNotified,
"reason", "no_live_raid_dungeon_binding")
}
return nil
}
if owner := runtime.partyDungeonOwner; owner != nil {
owner.mu.Lock()
host := !owner.retired && owner.leaderID == session.selectedCharacterID
owner.mu.Unlock()
_, current := owner.member(session.selectedCharacterID, session.characterGeneration)
if !current {
// 报告者必须**此刻确实在这支临时队伍里**(掉线/换人之后的残留会话不算)。
// 原来这里是静默 return —— "谁被允许报机制"在日志上完全不可见。
s.logGameEvent(session, "game-fiendwar-boss-report-dropped",
"char_id", session.selectedCharacterID,
"reason", "reporter_is_not_a_current_party_member",
"party_host", host,
"member_current", current)
return nil
}
if !host {
// **队员也能报。** 旧代码在这里要求"只有队长能报",而机制上报天然幂等
// (kind=1 必须 value == 这一侧当前的 boss state、且那颗球还在共享池里,
// 打掉之后这一侧立刻被分到下一颗,同一条重复报必被 stale 拒掉),
// 所以按人区分没有任何好处 —— 只会留下"队员打掉那颗球、上报被丢掉、
// 球永远不切换"这种坑。操作员 2026-10-02 的顾虑原话:
// 「只有队长能报是什么意思?队员走机制就要卡住?」
//
// 真机那一局(11:49~11:58)恰好是队长在报(char 1 / char 20),队员
// (char 21 / char 22)只发 HP 心跳,所以没暴露出来 —— 那是运气不是保证。
// 现在照样收下,只记一行,便于回查是谁报的。
s.logGameEvent(session, "game-fiendwar-boss-report-from-a-non-host",
"char_id", session.selectedCharacterID,
"reason", "party_members_may_report_the_mechanic_too",
"kind", kind,
"value", value)
}
}
s.raidMu.Lock()
state := s.raids[runtime.raidDungeon.RaidKey]
if state == nil || state.Lifecycle.FiendWar == nil || state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive {
// 同样是**静默**出口之一,而且是最可疑的那一个:机制上报到得了这里,
// 却因为"这一局不是超时空的进行中阶段"被丢掉,日志上什么都不留。
lifecycleStatus := runtimeRaidLifecycleStandby
fiendWarPresent := false
raidKey := runtime.raidDungeon.RaidKey
if state != nil {
lifecycleStatus = state.Lifecycle.Status
fiendWarPresent = state.Lifecycle.FiendWar != nil
}
s.raidMu.Unlock()
s.logGameEvent(session, "game-fiendwar-boss-report-dropped",
"raid_key", raidKey,
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Request.DungeonID,
"kind", kind,
"value", value,
"raid_state_present", state != nil,
"fiendwar_present", fiendWarPresent,
"lifecycle_status", int(lifecycleStatus),
"reason", "raid_is_not_in_an_active_fiendwar_phase")
return nil
}
// 本服务端的一次区域尝试按 (副本, 座位) 记账。**报告者既不必是开局那个人,也不必是队长**
// —— 只要他此刻确实在这支临时队伍里(上面那句),并且这次报告对得上账本里的那条 run。
//
// **判据不能要求「本人本地 token == run 的 token」**:一个区域共用一条 run,
// run 上的 token 是**开局人**的,而队长手上那份绑定带的正是开局人的 token、
// 他自己的本地 token 是另一个 —— 因为 prepareRuntimePartyDungeonEntry **先备队友**,
// 开局人常常就是队友。于是"队长 == 唯一能报的人"与"token 必须相等"一叠加,
// **每一条 BOSS 机制上报都被静默丢掉**:
//
// 真机 2026-10-02 11:49:36~11:58:21(raid 12058625,分片 010,两两进罗什)
// 4978(上):run 由 char 21 开(token=11),队长 char 1 adopt;
// 4953(下):run 由 char 22 开(token=13),队长 char 20 adopt;
// 两侧客户端**都在**报 kind=1 value=5(char 1 六条、char 20 六条),
// 服务端一条 rejected 都没有 —— 因为这里是静默 return。
// ⇒ 机制条停在 1111、两侧 state 停在 5 ⇒ 客户端(state=5)**反复刷同一颗球**
// —— 操作员看到的「一直循环第一个球 不切换」。
//
// 现在与生命周期那条路同口径(runtimeRaidDungeonLifecycleReportAccepted):
// run 在账本里、属于本阶段、属于这一张地图就接受,谁报的都算。
member, ok := runtimeRaidFrozenMember(*state, session.selectedCharacterID)
if !ok {
s.raidMu.Unlock()
s.logGameEvent(session, "game-fiendwar-boss-report-dropped",
"raid_key", state.RaidKey,
"char_id", session.selectedCharacterID,
"reason", "reporter_is_not_a_frozen_raid_member")
return nil
}
key := runtimeRaidDungeonRunKey{MapID: int(runtime.Request.DungeonID), Seat: runtimeRaidDungeonSeatFor(*state, member, int(runtime.Request.DungeonID))}
progress, exists := state.Lifecycle.Dungeons[key]
raidKey := state.RaidKey
phase := state.Lifecycle.Phase
if !runtimeRaidDungeonLifecycleReportAccepted(state, key, progress, exists,
runtimeRaidDungeonLifecycleEvent{
Kind: runtimeRaidDungeonLoaded,
RaidKey: raidKey,
Phase: phase,
MapID: key.MapID,
LifecycleToken: runtime.lifecycleToken,
CharacterID: session.selectedCharacterID,
}) {
s.raidMu.Unlock()
s.logGameEvent(session, "game-fiendwar-boss-report-dropped",
"raid_key", raidKey,
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Request.DungeonID,
"raid_map_id", key.MapID,
"run_seat_group", key.Seat.Group,
"run_exists", exists,
"run_lifecycle_token", progress.LifecycleToken,
"local_lifecycle_token", runtime.lifecycleToken,
"kind", kind,
"value", value,
"reason", "no_run_in_the_ledger_for_this_map_and_phase")
return nil
}
battle := state.Lifecycle.FiendWar.Clone()
// 观测:报之前这一侧的 BOSS 状态、以及报完之后两侧状态/机制条/两侧标志位。
// 真机排查"球不切换"时缺的就是这一组数:rejected 0、dropped 0,但机制没动,
// 只能靠猜。现在每一条上报都留一行。
area, areaFound := state.Lifecycle.FiendWar.AreaForDungeon(int(runtime.Request.DungeonID))
side, stateBefore := -1, -1
if areaFound {
stateBefore = state.Lifecycle.FiendWar.Value(area.Kind + " state")
if area.Kind == "bot boss" {
side = 1
} else {
side = 0
}
}
err := battle.BossReport(int(runtime.Request.DungeonID), kind, value, time.Now().UTC())
if err != nil {
s.raidMu.Unlock()
s.logGameEvent(session, "game-fiendwar-boss-report-rejected",
"kind", kind, "value", value,
"raid_key", raidKey,
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Request.DungeonID,
"area", area.Index, "area_kind", area.Kind, "side", side,
"boss_state_before", stateBefore,
"pattern_state", state.Lifecycle.FiendWar.Value("boss pattern state"),
"error", err)
return nil
}
state.Lifecycle.FiendWar = battle
state.Lifecycle.Revision++
snapshot := cloneRuntimeRaidState(*state)
topAfter := battle.Value("top boss state")
botAfter := battle.Value("bot boss state")
patternAfter := battle.Value("boss pattern state")
windowTop, windowBot := battle.BossWindowDone[0], battle.BossWindowDone[1]
s.raidMu.Unlock()
s.logGameEvent(session, "game-fiendwar-boss-report-accepted",
"raid_key", raidKey,
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Request.DungeonID,
"kind", kind,
"value", value,
"area", area.Index,
"area_kind", area.Kind,
"side", side,
"boss_state_before", stateBefore,
"top_boss_state", topAfter,
"bot_boss_state", botAfter,
"pattern_state", patternAfter,
"window_done_top", windowTop,
"window_done_bot", windowBot)
return s.broadcastRuntimeFiendWarBoard(snapshot, false, time.Now().UTC())
}
func (s *Service) runtimeFiendWarPartyBinding(session *gameSession, runtime *runtimeDungeonState) (runtimeRaidDungeonBinding, bool) {
if !isCurrentFiendWarRaidChannel(session) || runtime.partyDungeonOwner == nil {
return runtimeRaidDungeonBinding{}, false
}
s.raidMu.Lock()
defer s.raidMu.Unlock()
state := s.runtimeRaidByMemberLocked(session.selectedCharacterID)
if state == nil || !state.Started || !state.EntryCostPaid || state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive {
return runtimeRaidDungeonBinding{}, false
}
member, ok := runtimeRaidFrozenMember(*state, session.selectedCharacterID)
if !ok {
return runtimeRaidDungeonBinding{}, false
}
seat := runtimeRaidSeatFor(member)
key := runtimeRaidDungeonRunKey{MapID: int(runtime.Request.DungeonID), Seat: seat}
progress, ok := state.Lifecycle.Dungeons[key]
if !ok || progress.Seat != seat {
return runtimeRaidDungeonBinding{}, false
}
return runtimeRaidDungeonBinding{RaidKey: state.RaidKey, Phase: progress.Phase, MapID: progress.MapID, LifecycleToken: runtime.lifecycleToken}, true
}
func (s *Service) notifyRuntimeFiendWarMapLoaded(session *gameSession) error {
if !isCurrentFiendWarRaidChannel(session) {
return nil
}
session.dungeon.mu.Lock()
defer session.dungeon.mu.Unlock()
runtime := session.dungeon.runtime
if runtime == nil || runtime.Room == nil || !runtime.raidDungeon.loadedNotified {
return nil
}
mapID := int(runtime.Room.mapID)
s.raidMu.Lock()
state := s.raids[runtime.raidDungeon.RaidKey]
if state == nil || state.Lifecycle.FiendWar == nil || state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive {
s.raidMu.Unlock()
return nil
}
a, ok := state.Lifecycle.FiendWar.AreaForDungeon(int(runtime.Request.DungeonID))
if !ok || state.Lifecycle.FiendWar.Value(a.Kind+" state") != 2 {
s.raidMu.Unlock()
return nil
}
battle := state.Lifecycle.FiendWar.Clone()
err := battle.Apply(raid.FiendWarEvent{Kind: "on enter map", ID: mapID}, time.Now().UTC())
if err != nil {
s.raidMu.Unlock()
return err
}
state.Lifecycle.FiendWar = battle
syncRuntimeFiendWarObjectives(state)
state.Lifecycle.Revision++
snapshot := cloneRuntimeRaidState(*state)
s.raidMu.Unlock()
return s.broadcastRuntimeFiendWarBoard(snapshot, false, time.Now().UTC())
}
// notifyRuntimeFiendWarMapEnteredLocked 是超时空的 [ON ENTER MAP] 的**房间切换**那一半。
//
// 为什么必须有它(2026-10-02 真机,两两进罗什):
//
// 罗什那一侧的四颗星球**不是服务端刷出来的怪**,是罗什自己的动作动画
// (`Attack_Summon_Crystal/Fire/Water/Wind_Planet` → `cosmofiend_planet_*.ani`),
// 客户端放不放它们,看的是这一侧 BOSS 的机制状态;而 PVF 的状态机是:
//
// [ON ENTER DUNGEON] 4978 → top boss state = 2 (还没进机制)
// [ON ENTER MAP] 306000463 → top boss state = 5 (四颗星球出场)
// 客户端每打掉一颗报一条 kind=1 → 5→6→7→8 → 9(伤害窗口打开)→ BOSS 死 → 10
//
// 上面那个 notifyRuntimeFiendWarMapLoaded 只挂在**副本 finish-loading** 上,而那一刻
// 人还在 0:0 起始房(map 306000461 / 306000458)——`on enter map` 带的是**起始地图**,
// PVF 那条规则是**按 BOSS 那张地图**(306000463 / 306000460)认的,于是什么都没发生;
// 玩家随后用 op45 走进 BOSS 房时,**没有任何代码再触发 `on enter map`**:
// state 永远停在 2,客户端看到 2 就不放球、也不开伤害窗口。
//
// 真机 2026-10-02 10:27:51~10:29:10 两次罗什的形状正是如此:四个球一个都不出现,
// 罗什不给打,62 条 op1511 **全是** kind=0 value=100(客户端压根没进机制阶段)。
// 旁证:同一天早些时候那次(10-01 04:53:47 / 04:54:25)确实对 306000463 / 306000460
// 触发过 `on enter map`(所以那一次 state 到了 5)——差别就在这个钩子,
// 它是**确定性缺陷,不是竞态**。
//
// 位置与卢克逐字对应(dungeon_navigation_runtime.go 的
// observeRuntimeRaidLukeEnterMapLocked:走到王座的 boss 房才开能源储藏室),
// 理由也一样:[ON ENTER MAP] 是**房间**边,不是副本边。
//
// 调用者持 session.dungeon.mu(dungeon.mu -> raidMu 是本服务端允许的顺序)。
// 战况表**不在这里下发**:它由 1 Hz 的心跳连同新状态一起带出去,避免在持有本会话
// dungeon 锁的时候去戳别的成员的会话状态。
func (s *Service) notifyRuntimeFiendWarMapEnteredLocked(
session *gameSession,
runtime *runtimeDungeonState,
roomMapID int64,
source string,
) {
if s == nil || session == nil || runtime == nil || roomMapID == 0 {
return
}
if !isCurrentFiendWarRaidChannel(session) {
return
}
if runtime.raidDungeon.RaidKey == 0 || runtime.Request.DungeonID == 0 || !runtime.raidDungeon.loadedNotified {
return
}
s.raidMu.Lock()
state := s.raids[runtime.raidDungeon.RaidKey]
if state == nil || state.Lifecycle.FiendWar == nil || state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive {
s.raidMu.Unlock()
return
}
a, ok := state.Lifecycle.FiendWar.AreaForDungeon(int(runtime.Request.DungeonID))
if !ok || state.Lifecycle.FiendWar.Value(a.Kind+" state") != 2 {
// 不是 BOSS 侧那次(或者已经推进过了):一个字都不改。state != 2 这条守卫
// 同时保证"进第二次房"不会重复推进。
s.raidMu.Unlock()
return
}
battle := state.Lifecycle.FiendWar.Clone()
if err := battle.Apply(raid.FiendWarEvent{Kind: "on enter map", ID: int(roomMapID)}, time.Now().UTC()); err != nil {
s.raidMu.Unlock()
s.logGameEvent(session, "game-fiendwar-map-entered-failed",
"raid_key", runtime.raidDungeon.RaidKey,
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Request.DungeonID,
"room_map_id", roomMapID,
"area", a.Index,
"error", err,
"source", source)
return
}
state.Lifecycle.FiendWar = battle
syncRuntimeFiendWarObjectives(state)
state.Lifecycle.Revision++
raidKey := state.RaidKey
stateAfter := battle.Value(a.Kind + " state")
s.raidMu.Unlock()
s.logGameEvent(session, "game-fiendwar-map-entered",
"raid_key", raidKey,
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Request.DungeonID,
"area", a.Index,
"area_kind", a.Kind,
"room_map_id", roomMapID,
"state_before", 2,
"state_after", stateAfter,
"pattern_state", battle.Value("boss pattern state"),
"reason", "on_enter_map_room_edge_opens_the_boss_pattern",
"source", source)
}
func (s *Service) markRuntimeFiendWarSettlementFinished(state runtimeRaidState) error {
for _, member := range state.FrozenMembers {
session, online := s.onlineGameSession(member.CharID)
if !online {
continue
}
session.dungeon.mu.Lock()
runtime := session.dungeon.runtime
if runtime != nil && runtime.raidDungeon.RaidKey == state.RaidKey && runtime.raidDungeon.completedNotified {
runtime.raidNativeSettlementFinished = true
}
session.dungeon.mu.Unlock()
}
return s.broadcastRuntimeRaidWireSequence(state, runtimeRaidWireNotification{1453, []byte{4, 0}})
}
// Forced retreats reuse the normal town-return owner on each character mailbox.
// The frozen attempt prevents a delayed timeout from retiring a newer run.
// Keep the latest attempt per map so every member can retry a failed return.
//
// Two gestures share this entry point and they are NOT the same exit:
//
// - ended == false is a retreat (区域超时 / PVF 的整队撤退 / 放弃双 BOSS 一边).
// The whole squad pulls out together, so it takes the same WHOLE-party exit the
// 战况表 leader-led return already uses and the squad label survives -- the
// operator's ruling 「在战况表里撤退 应该保持编制」. Before 2026-10-01 this used
// the plain interrupted-run exit, which detached the member from the ordinary
// party and cleared his 编制, so a retreat silently dissolved the squad.
// - ended == true is the failure tail: EVERY member ends in the waiting room
// (town 98 / area 2) and the 攻坚队 survives; see publishFailedFiendWar.
func (s *Service) returnRuntimeFiendWarDungeons(state runtimeRaidState, attempts map[int]runtimeRaidDungeonProgress, ended bool) error {
source := "fiendwar_retreat"
if ended {
source = "fiendwar_failed_waiting_area"
}
for _, member := range state.FrozenMembers {
identity, online := s.onlineGameSessionCharacterSnapshot(member.CharID)
if !online || identity.session == nil {
continue
}
session := identity.session
if err := s.postGameSessionCharacterEvent(session, "fiendwar-retreat", identity.character, identity.generation, func() error {
if current, ok := s.runtimeRaidForMember(identity.character); ok && current.RaidKey != state.RaidKey {
return nil
}
session.dungeon.mu.Lock()
runtime := session.dungeon.runtime
if runtime == nil || runtime.raidDungeon.RaidKey != state.RaidKey {
session.dungeon.mu.Unlock()
if !ended {
return nil
}
// Failure (Fix D): this member owns no dungeon of this raid, so the
// town-return owner has nothing to rebuild for him. He is standing
// in a town operation area -- and once the raid row lost its party,
// town_move refused every op36 with AreaID >= 2
// (fiendwar_membership_required), including the waiting area itself,
// so the client hung and the player could not walk back. Push the
// waiting-area scene directly instead; it needs no inbound op36.
return s.pushRuntimeFiendWarWaitingArea(session, state, source)
}
attempt, found := attempts[int(runtime.Request.DungeonID)]
// 本服务端的一次尝试按 (副本, 座位) 记账,没有独立的 owner token。
// 这条守卫的意图是"迟到的超时不能退掉更新的一场",所以比对的必须是
// 当前这一次尝试的座位与副本 token,而不是谁先开的局。
seat := runtimeRaidSeatFor(member)
if !ended && (!found || attempt.Seat != seat || attempt.LifecycleToken != runtime.lifecycleToken) {
session.dungeon.mu.Unlock()
return nil
}
runtime.raidDungeon.abandonedNotified = true
if ended {
runtime.raidDungeon = runtimeRaidDungeonBinding{}
// 失败收尾必须保住全员编制(「攻坚队不应该被解散」):这是服务器把
// 所有人送回待机区,不是「队内成员单独自己回城」。
runtime.townReturnKeepsSquadLabel = true
}
status := runtime.Session.Snapshot().Run.Status
session.dungeon.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
transition, err := s.prepareCurrentDungeonTownTransitionForSession(ctx, session, identity.character)
if err != nil {
return err
}
if ended {
// 失败落点必须是待机区 town 98 / area 2,不是频道门口 (98/0)。
// 98/2 的坐标只能从等待区 NPC 取 —— currentRaidTownTransition 只返回
// spec.areaID,且硬要求目标区域有 Gate。
town, area, row, rowErr := s.fiendWarWaitingAreaScene(state, identity.character)
if rowErr != nil {
return rowErr
}
transition.TownID, transition.AreaID = town, area
transition.PositionX, transition.PositionY = int16(row.Value1), int16(row.Value2)
transition.Direction, transition.AreaState = row.Value3, row.Value4
transition.PositionSource = source
transition.Body, err = buildCurrentSceneTransitionBody(town, area, []currentSceneTransitionRow{row})
if err != nil {
return err
}
}
switch {
case status == worldmap.DungeonRunCompleted:
// A cleared run keeps its own exit; the failure tail only re-targets
// where it lands.
err = s.sendCurrentCompletedDungeonReturnToTown(session, runtime, transition, 132, source, false)
case ended:
err = s.sendCurrentActiveDungeonReturnToTown(session, runtime, transition, 132, source)
default:
err = s.sendCurrentActiveDungeonReturnToTownKeepingParty(session, runtime, transition, 132, source)
}
return err
}); err != nil {
return err
}
}
return nil
}
// fiendWarWaitingAreaScene builds the waiting-room (town 98 / area 2) transition row
// for one member from the PVF waiting-area NPCs.
//
// This is the same source prepareRuntimeFiendWarRetreat and
// runtimeFiendWarRejoinLocation use, and it is the ONLY source that has a coordinate
// for 98/2: currentRaidTownTransition returns spec.areaID alone (the channel's gate
// area, 98/0) and hard-requires the target area to own a non-nil Gate.
func (s *Service) fiendWarWaitingAreaScene(state runtimeRaidState, characterID uint16) (byte, byte, currentSceneTransitionRow, error) {
config := (*raid.FiendWarBattleConfig)(nil)
if state.FiendWarConfig != nil {
config = state.FiendWarConfig.Battle
}
if config == nil {
return 0, 0, currentSceneTransitionRow{}, errRuntimeRaidLifecycleState
}
waiting, found := s.townArea(int64(config.WaitingTown), int64(config.WaitingArea))
if !found {
return 0, 0, currentSceneTransitionRow{}, fmt.Errorf("fiendwar waiting town absent from PVF")
}
for _, npc := range waiting.NPCs {
if slices.Contains(config.WaitingNPCs, int(npc.NPCID)) && npc.X >= 0 && npc.X <= 32767 && npc.Y >= 0 && npc.Y <= 32767 {
row := currentSceneTransitionRow{
ObjectOrResourceKey: currentSceneActorObjectKey(characterID),
Value1: uint16(npc.X),
Value2: uint16(npc.Y),
Value3: currentAntonRaidDirection,
Value4: currentAntonRaidAreaState,
}
return byte(config.WaitingTown), byte(config.WaitingArea), row, nil
}
}
return 0, 0, currentSceneTransitionRow{}, fmt.Errorf("fiendwar waiting NPC position absent from PVF")
}
// fiendWarWaitingRoomTransition rewrites a town transition so it lands in the
// Fiend War waiting room (town 98 / area 2) at a waiting NPC.
//
// It exists because the generic "completed dungeon" transition answers "where was
// this member standing when the run started", and that answer only has coordinates
// for the ordinary operation areas. The two 量子时空传送站 areas (30/31) have none:
// live 2026-10-01 16:06, clearing 4978 produced `town_id=98 area_id=30
// position_x=0 position_y=0`, the client could not place the character, and the
// member stayed inside the dungeon after a fully settled clear.
//
// The field set mirrors prepareRuntimeFiendWarRetreat (the retreat path, which is
// the one that already works) so the two routes cannot drift apart.
func (s *Service) fiendWarWaitingRoomTransition(
state runtimeRaidState,
characterID uint16,
transition currentDungeonTownTransition,
) (currentDungeonTownTransition, error) {
town, area, row, err := s.fiendWarWaitingAreaScene(state, characterID)
if err != nil {
return transition, err
}
body, err := buildCurrentSceneTransitionBody(town, area, []currentSceneTransitionRow{row})
if err != nil {
return transition, err
}
transition.TownID, transition.AreaID = town, area
transition.PositionX, transition.PositionY = int16(row.Value1), int16(row.Value2)
transition.Direction, transition.AreaState = row.Value3, row.Value4
transition.PositionSource = "fiendwar_clear_waiting_room"
transition.Body = body
return transition, nil
}
// pushRuntimeFiendWarWaitingArea is the server-driven town push for a Fiend War
// member whose screen has to move to the waiting room with NO inbound op36.
//
// It follows the shape the sibling route proved
// (finishRuntimeFiendWarLeave, raid_fiendwar_rejoin_leave.go:178-225) with the three
// differences that route needs and this one must not inherit:
//
// - no cleanupOnlinePlayer: that broadcasts a spurious offline to friends and the
// guild (online_player_manager.go:2072) while the player is still online;
// publishCurrentTownPresence publishes the town/social state itself.
// - the ready flag is written AFTER resetDungeonEntrySceneGates, which zeroes
// townSceneReadyCharacterID (dungeon_return_town.go:2511).
// - the pending dungeon-select context and selector origin are retired first, the
// same two calls the interrupted-run town route makes.
//
// The caller must hold no session lock: with no event loop this runs inline on the
// posting goroutine (game_session_events.go:272-274).
func (s *Service) pushRuntimeFiendWarWaitingArea(session *gameSession, state runtimeRaidState, source string) error {
if s == nil || session == nil || session.selectedCharacterID == 0 || session.conn == nil {
return nil
}
characterID := session.selectedCharacterID
town, area, row, err := s.fiendWarWaitingAreaScene(state, characterID)
if err != nil {
return err
}
body, err := buildCurrentSceneTransitionBody(town, area, []currentSceneTransitionRow{row})
if err != nil {
return err
}
clearCurrentDungeonSelectContext(session)
clearCurrentTownSelectorOrigin(session)
resetDungeonEntrySceneGates(session)
if err := s.sendCurrentTownLocationNotifications(session, characterID, town, area, row, source); err != nil {
return err
}
if err := s.sendGameUpperRawClass(session, currentSceneTransitionMsgID, body, 0); err != nil {
return err
}
session.townMu.Lock()
setCurrentTownPositionSceneFromTransitionLocked(session, characterID, town, area, row)
session.townSceneReadyCharacterID = characterID
session.townMu.Unlock()
// 离开城镇等于放弃"走进区域即开战"的去重记录,下次抵达要能重新开。
session.fiendWarEncounterRaidKey, session.fiendWarEncounterArea = 0, 0
session.fiendWarEncounterAt = time.Time{}
character := dnfrepo.CharacterRecord{CharacterID: strconv.Itoa(int(characterID))}
if repositories, ok := s.repositoryGroup(); ok && repositories.Character != nil {
if loaded, found, loadErr := repositories.Character.Load(context.Background(), character.CharacterID); loadErr == nil && found {
character = loaded
}
}
mapPath := ""
if loaded, found := s.townArea(int64(town), int64(area)); found {
mapPath = loaded.MapPath
}
s.publishCurrentTownPresence(session, character, town, area, row, state.RaidKey, mapPath, source)
s.logGameEvent(session, "game-fiendwar-force-waiting-area",
"raid_key", state.RaidKey,
"char_id", characterID,
"town_id", town,
"area_id", area,
"position_x", row.Value1,
"position_y", row.Value2,
"reason", "the_run_ended_and_the_whole_raid_returns_to_the_waiting_room",
"source", source)
return nil
}
// publishFailedFiendWar is the Fiend War failure tail. It replaces the old
// publishExpiredFiendWar, whose two halves were both wrong for the operator's rule:
//
// 1. It returned only members who were physically inside a dungeon
// (raid_fiendwar_battle.go:905-906 returned early when session.dungeon.runtime
// was nil) and answered everybody else with op665
// (sendRuntimeSingleRaidEnded(session, 665, "fiendwar_expired")). op665 is
// exactly what makes the client print 46791「已经强制退出攻坚队。」
// (raid_online.go:1501-1503) -- the popup the operator reported as "攻坚队被解散".
// 2. It landed members in the CHANNEL GATE area 98/0 (the
// fiendwar_expired_public_town destination), not in the waiting room 98/2.
//
// Fix D's ruling: 「攻坚失败后 所有人应被传送回待机区 并且攻坚队不应该被解散」.
// Fix C's banner is published here too, as the 587/601 pair below.
func (s *Service) publishFailedFiendWar(state runtimeRaidState) error {
// 过期退场要把"此刻还占着区域的所有尝试"都退掉。本服务端的 Dungeons 按
// (副本, 座位) 记账,而 returnRuntimeFiendWarDungeons 按副本查 —— 在入口处
// 拍平一次,键取 MapID。超时空一个区域只允许一个小队占用,所以不会撞键。
attempts := make(map[int]runtimeRaidDungeonProgress, len(state.Lifecycle.Dungeons))
for key, progress := range state.Lifecycle.Dungeons {
attempts[key.MapID] = progress
}
if err := s.returnRuntimeFiendWarDungeons(state, attempts, true); err != nil {
return err
}
return s.publishFiendWarFailurePresentation(state)
}
// The Fiend War failure presentation. Both constants are named so the in-game test
// is a one-line change and the packet log alone is enough to diagnose it.
const (
// runtimeRaidFiendWarFailureState is the 587 state byte the presentation holds.
//
// The client's banner gate is the conjunction raidObj+0x28 == 6 (this byte) and
// raidObj+0x2C == 1 (the 601 ResultType below), so state 6 is what makes the
// 30210「作战失败,无法再继续进行攻坚。」 banner reachable at all. It is ALSO
// what blocks C2S 666: the only START_RAID sender (FUN_017B2820) allows a start
// for state 0 and state 5 and denies everything else, which is why the
// presentation releases to runtimeRaidFiendWarFailureReleaseState afterwards.
// Flip this to 4 if the banner is gated out in game; 4 is the value Anton/Luke
// ship for a failed phase.
runtimeRaidFiendWarFailureState byte = runtimeRaidSettlementStateFinished
// runtimeRaidFiendWarFailureStateArgument is the 587 argument byte.
runtimeRaidFiendWarFailureStateArgument int8 = 1
// runtimeRaidFiendWarFailureHold is how long state 6 is held before the release.
// The client's HUD predicate is a level test evaluated every frame, so this is
// presentation time only; it is the parameter most likely to need tuning in game.
runtimeRaidFiendWarFailureHold = 5 * time.Second
// runtimeRaidFiendWarFailureBannerReassert is when the banner pair is repeated,
// measured from the first publication.
//
// 「开始/成功都有横幅,失败没有」的那一半原因不在包上,在**顺序**上:失败一收尾
// 就把全员传送回待机区,真机实测两者只差几十到三百毫秒 ——
//
// 16:00:36.619 1453 / 585 / 597 / 587{6,1} / 601{ResultType 1} ← 横幅门点亮
// 16:00:36.666 game-dungeon-full-town-route-send source=fiendwar_failed_waiting_area
// 16:00:36.927 game-dungeon-full-town-route-committed
// 16:00:41.620 587{0} 释放
//
// 客户端那个 30210 横幅是**逐帧**判断 raidObj+0x28 == 6 && raidObj+0x2C == 1 的
// (FUN_01f67bc0,见 tools/_fw3_fixes_spec.md:240-256),场景一切换就被新的
// 城镇场景盖住,于是那 5 秒的"演出时间"根本没人在看。这里在待机区落地之后再补
// 一次同样的 587{6,1} + 601{1,…}:两个字节是电平判断不是边沿触发,重复发同值
// 只是把门重新点亮,不改任何状态。
runtimeRaidFiendWarFailureBannerReassert = 2 * time.Second
// runtimeRaidFiendWarFailureReleaseState re-opens the client's start gate: 0 is
// in FUN_017B2820's allow set, and the state-0 branch of the 587 handler does not
// touch raidObj+0x2C, so the banner's result-type byte survives the release.
runtimeRaidFiendWarFailureReleaseState byte = 0
// runtimeRaidFiendWarFailResultType 是超时空**失败**要发的 601 结果类型。
//
// 它必须是 3,不是安东/卢克那个 1 —— 客户端反汇编(DNF.exe sha256 8A7AF614…AA4D):
//
// 0x009B2910(byte* body) ← 1453{u8 kind, u8 arg} 的分派器
// mov eax,[ebp+8] / movsx eax,[eax] / dec eax / cmp eax,5 / ja -> return
// jmp dword ptr [eax*4 + 0x9B2B7C] ; 6 个 case,kind 1..6
// case 1 -> 0x9B29BD case 2 -> 0x9B2A04 case 3 -> 0x9B2A8D
// case 4 -> 0x9B2B0B case 5 -> 0x9B2B18 case 6 -> 0x9B2B18
//
// 0x009B2B18(case 5/6 的实体):
// mov ecx,[0x51b2748] / call 0x17B22C0 ; raidObj
// mov eax,[eax+0x2C] ; ★ 结果类型
// sub eax,2 / je 0x9B2B40 ; == 2 -> 75518「量子时空传送站破坏成功」
// dec eax / jne 0x9B2B60 ; != 3 -> 什么都不发
// push 0x126EF (75503「未能破坏量子传送站。」) / jmp 0x9B2B45
//
// 而 raidObj+0x2C 全客户端只有 601 的处理器会写(sub_1D6B840 尾部
// 0x01D6C3E5 mov ecx,[ebp-0x64] / 0x01D6C3E8 mov [eax+0x2C],ecx),
// 写的就是 body 的第一个字节。
//
// ⇒ 所以"攻坚失败没提示"的完整根因是一条**顺序 + 取值**的双重错误:
// ① 我们发的 1453{6,0} 落在 case 6,那一刻读 +0x2C;
// ② 可是 601 是**在 1453 之后**才发的,而且带的是 1 —— 读到的既不是 2 也不是 3。
// 修法:先发 601{ResultType 3},再发 1453{6,0}。
//
// 代价说明:30210「作战失败,无法再继续进行攻坚。」那条通用横幅(HUD 逐帧函数
// FUN_01f67bc0 0x01F67E7D)要的是 +0x2C == 1,与 3 互斥;实测它在真机上从来没亮过
// (还差一个 UI 谓词 global[0x60746d8]->vslot5(0x11,0)),所以选客户端自己那条
// 超时空失败文案。要让三条(开始/成功/失败)都用原生文案,成功那一条对应的是
// +0x2C == 2(75518),但那会动到已经验收过的成功路径,先不碰。
runtimeRaidFiendWarFailResultType byte = 3
)
// publishFiendWarFailurePresentation writes the failure sequence.
//
// ORDER IS THE FIX (see runtimeRaidFiendWarFailResultType): the 601 must land
// BEFORE 1453{6,0}, because the 1453 case-6 body reads raidObj+0x2C at that
// instant and only prints 75503 when it reads 3.
//
// 601{ResultType 3} writes raidObj+0x2C = 3 (the only packet that writes it)
// 1453{6,0} case 6 -> reads +0x2C == 3 -> 75503「未能破坏量子传送站。」
// 585 every gate of the phase closed
// 597{0,0} remaining time zeroed
// 587{6,1} lights the banner gate's state half
//
// and then hands the release (587{0}) to scheduleRuntimeFiendWarFailureRelease,
// which also re-asserts the pair once the waiting-room teleport has landed.
//
// NOT sent, deliberately: op665. See publishFailedFiendWar.
func (s *Service) publishFiendWarFailurePresentation(state runtimeRaidState) error {
if state.RaidKey == 0 || len(state.FrozenMembers) == 0 {
return errRuntimeRaidSettlementRoster
}
_, dungeonBody, dungeonRows, err := runtimeRaidPhaseSnapshotBodies(state)
if err != nil {
return err
}
wirePhase, err := runtimeRaidSettlementWirePhase(state.Lifecycle.Phase)
if err != nil {
return err
}
resultBody, err := raid.BuildRaidSettlementResultBody(raid.RaidSettlementResult{
ResultType: runtimeRaidFiendWarFailResultType,
PhaseIndex: wirePhase,
ClearSeconds: runtimeRaidDurationSeconds(runtimeRaidPhaseElapsed(state, s.gameplayNow())),
DeathCount: s.runtimeRaidPhaseDeathCount(state.RaidKey, state.Lifecycle.Phase),
Score: 0,
Flags: 1,
})
if err != nil {
return err
}
// The 585 rows travel once so every client redraws the selector from the same
// closed state; the local table is recorded for the same reason
// publishFailedRuntimeRaid records it.
s.recordRuntimeRaidDungeonStateRows(state.RaidKey, state.Lifecycle.Phase, dungeonRows, runtimeRaidInfectionMapIDForWire(state))
// 601 FIRST: it is what writes raidObj+0x2C, and the 1453 that follows reads it.
var firstErr error
for _, member := range state.FrozenMembers {
session, online := s.onlineGameSession(member.CharID)
if !online || session == nil {
continue
}
if err := s.sendGameUpperRawClassCodec(session, raid.RaidResultMsgID, resultBody, 0, true); err != nil {
if firstErr == nil {
firstErr = err
}
s.logGameEvent(session, "game-fiendwar-failure-result-failed",
"char_id", member.CharID, "raid_key", state.RaidKey, "error", err)
continue
}
s.logGameEvent(session, "game-fiendwar-failure-published",
"char_id", member.CharID,
"raid_key", state.RaidKey,
"raid_phase", state.Lifecycle.Phase,
"result_type", runtimeRaidFiendWarFailResultType,
"death_count", s.runtimeRaidPhaseDeathCount(state.RaidKey, state.Lifecycle.Phase),
"failure_state", runtimeRaidFiendWarFailureState,
"failure_state_argument", runtimeRaidFiendWarFailureStateArgument,
"release_state", runtimeRaidFiendWarFailureReleaseState,
"hold_seconds", int(runtimeRaidFiendWarFailureHold/time.Second),
"disband_sent", false,
"source", "fiendwar_failure_presentation")
}
notifications := []runtimeRaidWireNotification{
{msgID: 1453, body: []byte{6, 0}},
{msgID: raid.RaidDungeonStateMsgID, body: dungeonBody},
{msgID: raid.RaidRemainTimeMsgID, body: raid.BuildRaidRemainTimeBody(raid.RaidRemainTime{TimerType: 0, Seconds: 0})},
{msgID: raid.RaidStateMsgID, body: raid.BuildRaidStateBody(raid.RaidState{
State: runtimeRaidFiendWarFailureState,
Argument: runtimeRaidFiendWarFailureStateArgument,
})},
}
if err := s.broadcastRuntimeRaidWireSequence(state, notifications...); err != nil {
return err
}
s.scheduleRuntimeFiendWarFailureRelease(state, resultBody)
return firstErr
}
// reassertFiendWarFailureBanner repeats the failure banner pair after the
// waiting-room teleport has replaced the player's scene.
//
// It re-reads the row under raidMu and does nothing once the release has already
// reset the raid to its lobby, so a late/duplicate callback is a no-op. Every
// packet is the same value the first publication sent: 587{6,1} writes
// raidObj+0x28 and 601{ResultType 1} writes raidObj+0x2C, and the HUD gate is the
// conjunction of the two, evaluated every frame -- re-sending only re-lights a
// level, it never advances anything.
func (s *Service) reassertFiendWarFailureBanner(raidKey uint32, resultBody []byte) {
if len(resultBody) == 0 {
return
}
s.raidMu.Lock()
state := s.raids[raidKey]
if state == nil || state.Lifecycle.Status != runtimeRaidLifecycleFailed || len(state.FrozenMembers) == 0 {
s.raidMu.Unlock()
return
}
snapshot := cloneRuntimeRaidState(*state)
s.raidMu.Unlock()
if err := s.broadcastRuntimeRaidWireSequence(snapshot, runtimeRaidWireNotification{
msgID: raid.RaidStateMsgID,
body: raid.BuildRaidStateBody(raid.RaidState{
State: runtimeRaidFiendWarFailureState,
Argument: runtimeRaidFiendWarFailureStateArgument,
}),
}); err != nil {
s.logPacketEvent("game-fiendwar-failure-banner-reassert-send-failed",
"raid_key", raidKey, "error", err)
return
}
delivered := 0
for _, member := range snapshot.FrozenMembers {
session, online := s.onlineGameSession(member.CharID)
if !online || session == nil {
continue
}
if err := s.sendGameUpperRawClassCodec(session, raid.RaidResultMsgID, resultBody, 0, true); err != nil {
s.logGameEvent(session, "game-fiendwar-failure-banner-reassert-failed",
"char_id", member.CharID, "raid_key", raidKey, "error", err)
continue
}
delivered++
}
s.logPacketEvent("game-fiendwar-failure-banner-reasserted",
"raid_key", raidKey,
"failure_state", runtimeRaidFiendWarFailureState,
"result_type", runtimeRaidFailResultType,
"recipients", delivered,
"reason", "the_waiting_room_teleport_replaced_the_scene_the_banner_was_first_lit_in",
"source", "fiendwar_failure_presentation")
}
// scheduleRuntimeFiendWarFailureRelease arms the two post-failure steps:
//
// +2s re-assert 587{6,1} + 601{1,…} (reassertFiendWarFailureBanner)
// +5s release 587{0} + return the 攻坚队 to its lobby
//
// The re-assert is scheduled FIRST so the release stays the last schedule of the
// pair -- the one-shot assertions elsewhere read the most recent schedule.
//
// Releasing is what keeps 「攻坚队不应该被解散」 true in practice: the START_RAID gate
// allows only state 0 and state 5, so a party left holding state 6 could never begin
// another run. The party roster, its squad labels and the raid row itself are all
// untouched by the release -- what ends is this RUN, not the 攻坚队.
func (s *Service) scheduleRuntimeFiendWarFailureRelease(state runtimeRaidState, resultBody []byte) {
release := func() {
// Read the row back under the lock: a member may have left the raid during
// the hold, and the release must describe the party as it is now.
current, ok := s.runtimeRaidForMember(state.LeaderID)
if !ok || current.RaidKey != state.RaidKey || len(current.Members) == 0 {
return
}
if err := s.broadcastRuntimeRaidWireSequence(current, runtimeRaidWireNotification{
msgID: raid.RaidStateMsgID,
body: raid.BuildRaidStateBody(raid.RaidState{State: runtimeRaidFiendWarFailureReleaseState}),
}); err != nil {
s.logPacketEvent("game-fiendwar-failure-release-failed", "raid_key", state.RaidKey, "error", err)
}
if err := s.resetRuntimeFiendWarRaidToLobby(state.RaidKey); err != nil {
s.logPacketEvent("game-fiendwar-failure-lobby-reset-failed", "raid_key", state.RaidKey, "error", err)
}
}
queue := s.ensureGameplayTimeQueue()
if queue == nil {
s.reassertFiendWarFailureBanner(state.RaidKey, resultBody)
release()
return
}
reassertName := fmt.Sprintf("fiendwar-failure-banner:%d:%d", state.RaidKey, state.StartAttempt)
if err := queue.ScheduleAfter(reassertName, runtimeRaidFiendWarFailureBannerReassert, func(time.Time) {
s.reassertFiendWarFailureBanner(state.RaidKey, resultBody)
}); err != nil {
s.logPacketEvent("game-fiendwar-failure-banner-reassert-schedule-failed",
"raid_key", state.RaidKey, "error", err)
s.reassertFiendWarFailureBanner(state.RaidKey, resultBody)
}
name := fmt.Sprintf("fiendwar-failure-release:%d:%d", state.RaidKey, state.StartAttempt)
if err := queue.ScheduleAfter(name, runtimeRaidFiendWarFailureHold, func(time.Time) { release() }); err != nil {
s.logPacketEvent("game-fiendwar-failure-release-schedule-failed", "raid_key", state.RaidKey, "error", err)
release()
}
}
// resetRuntimeFiendWarRaidToLobby puts a failed Fiend War 攻坚队 back into the state a
// created-but-not-started raid has, so the same party can press 开始攻坚 again.
//
// The raid row is NOT deleted and the roster is NOT touched: state.Members carries
// every 编制 label and survives verbatim. What is dropped is this run's frozen phase
// roster and its billing, both of which the next start re-creates
// (reserveRuntimeRaidStart + commitRuntimeRaidStartWithOwner, which builds a fresh
// FiendWar battle and re-places every member in the waiting area).
func (s *Service) resetRuntimeFiendWarRaidToLobby(raidKey uint32) error {
// Read the clock before taking raidMu: gameplayNow reaches for the gameplay
// timer queue under Service.mu, and this file never nests that inside raidMu.
now := s.gameplayNow()
s.raidMu.Lock()
state := s.raids[raidKey]
if state == nil || state.RaidMode != runtimeRaidOwnerFiendWar {
s.raidMu.Unlock()
return nil
}
if state.Lifecycle.Status != runtimeRaidLifecycleFailed {
// A member's own leave/abandon already took the row somewhere else.
s.raidMu.Unlock()
return nil
}
lobby := newRuntimeRaidLifecycleState(now)
lobby.Status = runtimeRaidLifecycleCreated
lobby.Phase = 0
lobby.StartedAt = time.Time{}
lobby.PhaseStartedAt = time.Time{}
lobby.PhaseDeadline = time.Time{}
lobby.ReadyDeadline = time.Time{}
lobby.FailedAt = time.Time{}
lobby.FiendWar = nil
state.Lifecycle = lobby
state.Started = false
state.StartPending = false
state.EntryCostPaid = false
state.ChargedMembers = nil
state.FrozenMembers = nil
state.PendingCostSync = nil
state.StartAttempt++
if state.StartAttempt == 0 {
state.StartAttempt++
}
state.Lifecycle.Revision++
snapshot := cloneRuntimeRaidState(*state)
s.raidMu.Unlock()
// The client has to be handed back its lobby: the raid object (state 0, no
// phase) is what the 攻击队 window is built from, and the roster refresh carries
// the squad labels the failure left alone.
if err := s.broadcastRuntimeRaidCreate(snapshot); err != nil {
return err
}
if err := s.broadcastRuntimeRaidRefresh(snapshot); err != nil {
return err
}
s.logPacketEvent("game-fiendwar-party-survived-failure",
"raid_key", snapshot.RaidKey,
"leader_id", snapshot.LeaderID,
"member_count", len(snapshot.Members),
"grouped_members", countRuntimeRaidGroupedMembers(snapshot),
"reason", "the_run_failed_and_the_attack_party_stays_together",
"source", "fiendwar_failure_release")
return nil
}
// failRuntimeFiendWarRaid is abandonRuntimeRaid's Fiend War replacement: it marks the
// RUN failed and stops every later entry, completion or tick, but it KEEPS the raid
// row (and therefore the 攻坚队) in s.raids.
//
// abandonRuntimeRaid deletes the row, which is what made the operator's
// 「攻坚队不应该被解散」 impossible: town_move then refused every op36 with
// AreaID >= 2 (fiendwar_membership_required, town_move.go:135-145) -- including the
// waiting area at :120-129 -- so a member standing in a town operation area could
// not even walk back.
//
// It mirrors expireRuntimeRaidPhase's bookkeeping (gates closed, mechanisms cleared,
// Status = Failed) without its ten-second remover: resetRuntimeFiendWarRaidToLobby
// is what returns this row to the lobby, and removeRuntimeRaidFailedPhase must never
// run for a Fiend War raid.
func (s *Service) failRuntimeFiendWarRaid(raidKey uint32, now time.Time) (runtimeRaidState, error) {
now = runtimeRaidNormalizeTime(now)
s.raidMu.Lock()
defer s.raidMu.Unlock()
state := s.raids[raidKey]
if state == nil {
return runtimeRaidState{}, errRuntimeRaidNotFound
}
if state.RaidMode != runtimeRaidOwnerFiendWar {
return runtimeRaidState{}, errRuntimeRaidLifecycleState
}
if state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive {
return runtimeRaidState{}, errRuntimeRaidLifecycleState
}
state.ensureRuntimeRaidLifecycleMapsLocked()
// Every gate of the failed phase closes, exactly as expireRuntimeRaidPhase closes
// them before it announces the failure. The clear count and remaining HP stay:
// the state row is what tells the client the gate is gone, and a later re-read
// must not look like a completed objective.
for mapID, objective := range state.Lifecycle.Objectives {
objective.State = raid.AntonDungeonHide
state.Lifecycle.Objectives[mapID] = objective
}
state.clearRuntimeRaidMechanismsLocked()
state.Lifecycle.Status = runtimeRaidLifecycleFailed
state.Lifecycle.FailedAt = now
state.Lifecycle.Revision++
return cloneRuntimeRaidState(*state), nil
}