Files
90CNServer/server/internal/services/dnfbridge/raid_fiendwar_sim_fixture_test.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

764 lines
28 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"
"path/filepath"
"sort"
"strconv"
"strings"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/channelcatalog"
"longheng.io/server/internal/modules/dnf/raid"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/platform/logcrypt"
)
// ===========================================================================
// 超时空之战:8 个模拟客户端的全流程仿真
// ===========================================================================
//
// 目的:把真实客户端会走的每一步**用生产入口**跑一遍(不是直接摆状态),
// 每一步都断言账本/战况表/目标计数,覆盖正常流程与异常分支。
//
// 走的是这些生产入口:
//
// op36 区域移动 handleTownSetUserArea (含移动后的遭遇/进图那一段)
// op1511 BOSS 机制 handleRuntimeFiendWarBoss
// op42 放弃/撤退 handleDungeonGiveupGame(及战况表撤退的整队通道)
// 名册/编制 handleOnlineRaidCommand 之外,编制移动走 moveRuntimeRaidMember
// 副本生命周期 applyRuntimeRaidDungeonLifecycleEvent(selected/loaded/completed)
// 结算与翻牌 applyRuntimeRaidJoinedSquadClear / autoFlipRuntimeRaidPendingCards
//
// 每个客户端 = 一个 gameSession(真 PVF 的城镇/区域/副本数据 + 内存仓库)。
type fiendWarSimClient struct {
id uint16
name string
session *gameSession
}
type fiendWarSim struct {
t *testing.T
service *Service
raidKey uint32
config *raid.FiendWarConfig
clients []*fiendWarSimClient
now time.Time
logger *packetLogger
privateKey []byte
logErr string
bindings map[uint16]runtimeRaidDungeonBinding
localTokens map[uint16]uint64
}
// startLog 把服务端的包日志落到临时文件 —— 模拟失败时能直接看"服务端说了什么",
// 与真机排查同一口径(game-fiendwar-* 那些行)。
//
// 这里**自己生成一对密钥**并把 flush 间隔压到 5ms:日志文件是加密的,模拟结束前
// 要回读(logcrypt.ReadFilePlaintext),用工作区那把私钥会引入外部依赖。
func (sim *fiendWarSim) startLog() {
sim.t.Helper()
publicKey, privateKey, err := logcrypt.GenerateKeyPair()
if err != nil {
sim.t.Fatalf("generate log key: %v", err)
}
recipient, err := logcrypt.ParseRecipientPublicKey(publicKey)
if err != nil {
sim.t.Fatalf("parse log key: %v", err)
}
writer, err := logcrypt.Open(logcrypt.Config{
Dir: filepath.Join(sim.t.TempDir(), "packet"),
Prefix: "sim",
Ext: ".log.enc",
Recipient: recipient,
FlushInterval: 5 * time.Millisecond,
QueueSize: 4096,
MaxBytes: 1 << 20,
RetentionDays: 1,
Now: func() time.Time { return time.Now().UTC() },
})
if err != nil {
sim.t.Fatalf("open log writer: %v", err)
}
logger := &packetLogger{writer: writer}
sim.service.packetLog.Store(logger)
sim.logger, sim.privateKey = logger, privateKey
sim.t.Cleanup(func() {
sim.service.packetLog.Store(nil)
_ = writer.Close()
})
}
// events 返回日志里所有 EVENT 行(按时间)。
func (sim *fiendWarSim) events() []string {
if sim.logger == nil || len(sim.privateKey) == 0 {
return nil
}
// 写满 MaxBytes 会滚动到下一个分片,所以要把目录里所有 .enc 按名字顺序读全。
dir := filepath.Dir(sim.logger.Path())
files, globErr := filepath.Glob(filepath.Join(dir, "*.enc"))
if globErr != nil || len(files) == 0 {
sim.logErr = fmt.Sprintf("glob %s: %v (%d files)", dir, globErr, len(files))
return nil
}
sort.Strings(files)
out := make([]string, 0, 128)
for _, file := range files {
plain, records, truncated, readErr := logcrypt.ReadFilePlaintext(sim.privateKey, file)
sim.logErr = fmt.Sprintf("%s records=%d truncated=%t plain=%d err=%v",
filepath.Base(file), records, truncated, len(plain), readErr)
for _, line := range strings.Split(string(plain), "\n") {
if strings.Contains(line, "EVENT") {
out = append(out, strings.TrimSpace(strings.TrimRight(line, "\r")))
}
}
}
return out
}
// eventsWith 只取某一种事件(子串匹配)。
func (sim *fiendWarSim) eventsWith(kind string) []string {
out := make([]string, 0, 8)
for _, line := range sim.events() {
if strings.Contains(line, kind) {
out = append(out, line)
}
}
return out
}
// trace 给断言失败用:把最后 n 条超时空相关事件拼成一段人能读的文字。
func (sim *fiendWarSim) trace(n int) string {
lines := make([]string, 0, n)
all := sim.events()
for _, line := range all {
if !strings.Contains(line, "fiendwar") && !strings.Contains(line, "party-dungeon") &&
!strings.Contains(line, "dungeon-entry") && !strings.Contains(line, "state-user-area") {
continue
}
if index := strings.Index(line, "EVENT "); index >= 0 {
line = line[index:]
}
if len(line) > 200 {
line = line[:200]
}
lines = append(lines, line)
}
if len(lines) == 0 {
path := ""
if sim.logger != nil {
path = sim.logger.Path()
}
return fmt.Sprintf("\n (trace 不可用:logger=%t path=%q 已读事件=%d %s)",
sim.logger != nil, path, len(all), sim.logErr)
}
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return "\n " + strings.Join(lines, "\n ")
}
// diagnose 给断言失败用:把"为什么没进图"的证据一次列全 —— 账本区域、客户端坐标、
// 选图页是否就绪、这一格的开关与占位、以及心跳那一趟能看到的名单。
func (sim *fiendWarSim) diagnose(area int) string {
lines := make([]string, 0, 16)
battle := sim.battle()
lines = append(lines, fmt.Sprintf("area %d: dungeon=%d open=%t group=%d monster=%+v deadline=%s pattern=%d",
area, sim.config.Battle.Areas[area].Dungeon, battle.AreaOpen(area), battle.Areas[area].Group,
battle.Areas[area].Monster, battle.Areas[area].Deadline.Format(time.RFC3339), battle.Value("boss pattern state")))
for _, client := range sim.clients {
session := client.session
position := session.townPositionSnapshot
ready, reason := sim.service.currentTownEnterSelectReady(session)
session.dungeon.mu.Lock()
inDungeon := session.dungeon.runtime != nil
session.dungeon.mu.Unlock()
planned := sim.service.runtimeFiendWarPlannedEncounterPending(session, sim.now)
lines = append(lines, fmt.Sprintf("char %d: 账本=%d 坐标=(%d,%d) selectReady=%t/%s 副本运行中=%t plannedPending=%t",
client.id, battle.MemberArea(client.id, sim.groupOf(client.id)),
position.TownID, position.AreaID, ready, reason, inDungeon, planned))
}
if state, ok := sim.service.runtimeRaidForMember(sim.clients[0].id); ok {
objective := state.Lifecycle.Objectives[sim.config.Battle.Areas[area].Dungeon]
lines = append(lines, fmt.Sprintf("status=%v started=%t entryPaid=%t objective=%v hp=%d 目标数=%d",
state.Lifecycle.Status, state.Started, state.EntryCostPaid,
objective.State, objective.RemainingHP, len(state.Lifecycle.Objectives)))
}
return "\n " + strings.Join(lines, "\n ")
}
// traceAll 不挑事件种类,只取最后 n 条 —— 排查"这一步根本没进到 Fiend War 代码"时用。
func (sim *fiendWarSim) traceAll(n int) string {
all := sim.events()
if len(all) == 0 {
return fmt.Sprintf("\n (没有任何事件:%s)", sim.logErr)
}
if len(all) > n {
all = all[len(all)-n:]
}
out := make([]string, 0, len(all))
for _, line := range all {
if index := strings.Index(line, "EVENT "); index >= 0 {
line = line[index:]
}
if len(line) > 200 {
line = line[:200]
}
out = append(out, line)
}
return "\n " + strings.Join(out, "\n ")
}
// traceKeys 取"含任一关键字"的最后 n 条事件 —— 排查进图被拒时用(blocked/failed/rejected)。
func (sim *fiendWarSim) traceKeys(keys []string, n int) string {
lines := make([]string, 0, n)
for _, line := range sim.events() {
matched := false
for _, key := range keys {
if strings.Contains(line, key) {
matched = true
break
}
}
if !matched {
continue
}
if index := strings.Index(line, "EVENT "); index >= 0 {
line = line[index:]
}
if len(line) > 500 {
line = line[:500]
}
lines = append(lines, line)
}
if len(lines) == 0 {
return fmt.Sprintf("\n (没有匹配 %v 的事件;%s)", keys, sim.logErr)
}
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return "\n " + strings.Join(lines, "\n ")
}
func (sim *fiendWarSim) state() *runtimeRaidState {
sim.service.raidMu.Lock()
defer sim.service.raidMu.Unlock()
state := sim.service.raids[sim.raidKey]
if state == nil {
sim.t.Fatal("攻坚状态不见了")
}
snapshot := cloneRuntimeRaidState(*state)
return &snapshot
}
func (sim *fiendWarSim) battle() *raid.FiendWarBattle {
if b := sim.state().Lifecycle.FiendWar; b != nil {
return b
}
sim.t.Fatal("战场不见了")
return nil
}
func (sim *fiendWarSim) client(id uint16) *fiendWarSimClient {
for _, client := range sim.clients {
if client.id == id {
return client
}
}
sim.t.Fatalf("没有 char %d 这个模拟客户端", id)
return nil
}
func (sim *fiendWarSim) ids() []uint16 {
out := make([]uint16, 0, len(sim.clients))
for _, client := range sim.clients {
out = append(out, client.id)
}
return out
}
// newFiendWarSim 建一个 memberCount 人的超时空攻坚(默认 8 个编制 × 1 人)。
func newFiendWarSim(t *testing.T, memberCount int) *fiendWarSim {
t.Helper()
path := os.Getenv("DNF_FIENDWAR_REAL_PVF")
if path == "" {
t.Skip("DNF_FIENDWAR_REAL_PVF not set")
}
if memberCount <= 0 || memberCount > 8 {
t.Fatalf("超时空最多 8 个编制,memberCount=%d", memberCount)
}
fixture := newRuntimeRaidRewardTestFixture(t, true)
service := fixture.service
service.options.pvfPath = path
if err := service.preloadDungeonWorldMap(context.Background()); err != nil {
t.Fatalf("preload world map: %v", err)
}
if err := service.preloadTownCatalog(context.Background()); err != nil {
t.Fatalf("preload town catalog: %v", err)
}
config, err := service.runtimeRaidFiendWarConfig()
if err != nil {
t.Fatalf("真 PVF 超时空配置: %v", err)
}
state := service.raids[fixture.raidKey]
state.RaidMode, state.RouteOrRaidType = runtimeRaidOwnerFiendWar, 2
state.AntonConfig, state.FiendWarConfig = nil, config
state.Lifecycle, err = newRuntimeRaidLifecycleStateFromModeAndType(
config.Battle.Raid, runtimeRaidOwnerFiendWar, fixture.now, false, 0)
if err != nil {
t.Fatalf("lifecycle: %v", err)
}
battle, err := raid.NewFiendWarBattle(config.Battle)
if err != nil {
t.Fatalf("战场: %v", err)
}
state.Lifecycle.FiendWar = battle
state.Lifecycle.Status = runtimeRaidLifecyclePhaseActive
// **阶段开始那一趟**:PVF 里没有条件的规则就是"开局初始化"(设施/区域状态),
// 生产端建完战场就会 Apply 一次;不跑它,兵营/前哨/雷达的状态全是 0,
// 于是"这一格开着吗"永远是否 —— 真机上表现为"设施进不去、BOSS 门永远不开"。
if err := battle.Apply(raid.FiendWarEvent{}, time.Now().UTC()); err != nil {
t.Fatalf("阶段开局规则: %v", err)
}
battle.GroupAreas[1] = int(currentFiendWarWaitingAreaID)
battle.GroupAreas[2] = int(currentFiendWarWaitingAreaID)
// 8 个成员:每人一个编制(操作员定案:超时空 8 编制,每个编制只能放 1 人)。
members := make([]runtimeRaidMemberState, 0, memberCount)
charged := make([]uint16, 0, memberCount)
for index := 0; index < memberCount; index++ {
charID := uint16(1001 + index)
members = append(members, runtimeRaidMemberState{
CharID: charID, Name: fmt.Sprintf("c%d", charID), GroupIndex: byte(index + 1), SlotOrder: 0,
})
charged = append(charged, charID)
}
state.Members = append([]runtimeRaidMemberState(nil), members...)
state.FrozenMembers = append([]runtimeRaidMemberState(nil), members...)
state.ChargedMembers = charged
state.LeaderID = members[0].CharID
service.onlinePlayers = newOnlinePlayerManager()
sim := &fiendWarSim{
t: t, service: service, raidKey: fixture.raidKey, config: config, now: time.Now().UTC(),
}
reused := map[uint16]*gameSession{1001: fixture.leader, 1002: fixture.member}
for index := range members {
charID := members[index].CharID
session, ok := reused[charID]
if !ok {
session = &gameSession{conn: &bufferConn{}}
}
service.bindGameSessionCharacter(session, charID)
session.channel = channelcatalog.Channel{ID: 261, Type: 42}
// 注意 ID 要 > 0xff:currentPartySceneOwnerContext 用 channel.ID(<=0xff) 当场景归属,
// 投影是按 townActorOwnerChannel(=0) 建的,ID 落在低字节就会判成 actor projection owner is stale。
// 副本选择会核对"会话的账号 == 角色记录里的账号",两边必须写成同一个。
session.accountID = "account-" + strconv.Itoa(int(charID))
session.selectPreviewObjectStateSent, session.currentSceneObjectListSent = true, true
if err := fixture.repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
CharacterID: strconv.Itoa(int(charID)),
AccountID: "account-" + strconv.Itoa(int(charID)),
Name: members[index].Name,
Level: 90,
Stats: map[string]int64{
"fatigue": 156, "town_id": 98, "area_id": int64(currentFiendWarWaitingAreaID),
"pos_x": 240, "pos_y": 484, "direction": 5, "area_state": 0,
},
}); err != nil {
t.Fatalf("save character %d: %v", charID, err)
}
if err := fixture.repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
CharacterID: strconv.Itoa(int(charID)),
Slots: make(map[string]dnfrepo.ItemStack),
}); err != nil {
t.Fatalf("seed inventory %d: %v", charID, err)
}
quests := dnfrepo.QuestRecord{
CharacterID: strconv.Itoa(int(charID)),
States: map[int64]dnfrepo.QuestState{},
}
for _, areaID := range []int64{2, 3, 4, 5} {
area, found := service.townArea(98, areaID)
if !found {
t.Fatalf("待机城镇 98 的区域 %d 在 PVF 里不存在", areaID)
}
for _, quest := range area.NeedQuests {
quests.States[quest] = dnfrepo.QuestState{Status: "completed"}
}
}
if err := fixture.repositories.Quest.Save(context.Background(), quests); err != nil {
t.Fatalf("save quests %d: %v", charID, err)
}
bindDungeonSelectorOriginForTestAt(t, service, session, 98, currentFiendWarWaitingAreaID, 240, 484)
registerPartyPeerEndpointForTest(session, byte(index+2), uint16(5200+index))
seedPartyOwnerActorProjectionForTest(t, service, session, charID, 0)
service.onlinePlayers.EnterArea(&onlinePlayerInfo{
CharacterID: charID,
ChannelID: townPresenceChannelID(session),
TownID: 98,
AreaID: currentFiendWarWaitingAreaID,
InstanceKey: fixture.raidKey,
PositionX: 240,
PositionY: 484,
Session: session,
})
battle.MemberAreas[charID] = int(currentFiendWarWaitingAreaID)
sim.clients = append(sim.clients, &fiendWarSimClient{id: charID, name: members[index].Name, session: session})
}
if err := service.applyRuntimeRaidParties(*state); err != nil {
t.Fatalf("投影小队: %v", err)
}
syncRuntimeFiendWarObjectives(state)
sim.startLog()
return sim
}
// ---------------------------------------------------------------------------
// 动作(全部走生产入口)
// ---------------------------------------------------------------------------
// moveTo 走 op36(客户端"走到某一格"),顺带触发移动后的遭遇/进图那一段。
func (sim *fiendWarSim) moveTo(charID uint16, area int) error {
sim.t.Helper()
client := sim.client(charID)
return sim.service.handleTownSetUserArea(client.session,
buildTownMoveRequest(98, byte(area), 240+int16(area), 484, 5))
}
// reportPosition 走 op35(客户端报"我现在站在这儿")。真机每次移动/场景加载完都会
// 发一条;超时空的遭遇战要求 selectReady(= 这一格报过坐标)才会把人带进副本,
// 所以移动之后必须补这一条 —— 少发它就是"站在格子里但永远不进图"。
func (sim *fiendWarSim) reportPosition(charID uint16) error {
sim.t.Helper()
client := sim.client(charID)
area := sim.battle().MemberArea(charID, sim.groupOf(charID))
return sim.service.handleTownSetUserPosition(client.session,
buildTownPositionRequest(240+uint16(area), 484, 6, 100))
}
// beat 模拟服务端 1 Hz 的超时空心跳(生产实现是 scheduleRuntimeFiendWarTick):
// 推进战场 Tick(区域时限/计时器/撤退),广播战况表,然后对每个在线成员尝试一次
// 遭遇战 —— 真机上"走进一格"之后最多一秒就会被这一趟带进副本。
func (sim *fiendWarSim) beat(times int) {
sim.t.Helper()
for index := 0; index < times; index++ {
sim.now = sim.now.Add(time.Second)
sim.service.raidMu.Lock()
state := sim.service.raids[sim.raidKey]
battle := state.Lifecycle.FiendWar.Clone()
tickErr := battle.Tick(sim.now)
if tickErr == nil {
captureRuntimeFiendWarRetreats(state, battle)
state.Lifecycle.FiendWar = battle
syncRuntimeFiendWarObjectives(state)
state.Lifecycle.Revision++
}
snapshot := cloneRuntimeRaidState(*state)
sim.service.raidMu.Unlock()
if tickErr != nil {
sim.t.Fatalf("战场 Tick 失败: %v", tickErr)
}
if err := sim.service.broadcastRuntimeFiendWarBoard(snapshot, false, sim.now); err != nil {
sim.t.Fatalf("战况表广播失败: %v", err)
}
for _, client := range sim.clients {
if err := sim.service.tryRuntimeFiendWarEncounter(client.session, sim.now); err != nil {
sim.t.Fatalf("char %d 的心跳遭遇返回错误: %v%s", client.id, err, sim.trace(10))
}
}
}
}
// placeGuard 在某一格放一只守军(真机上是 PVF 的 [ON ENTER DUNGEON]/随机中立怪布下的),
// 让这一格成为"有副本可打"的格子,并把目标表同步成 open。
func (sim *fiendWarSim) placeGuard(area int) {
sim.t.Helper()
sim.service.raidMu.Lock()
state := sim.service.raids[sim.raidKey]
battle := state.Lifecycle.FiendWar
current := battle.Areas[area]
current.Monster = raid.FiendWarUnit{ID: 1, Type: 1}
battle.Areas[area] = current
syncRuntimeFiendWarObjectives(state)
sim.service.raidMu.Unlock()
}
// walkTo 沿区域图走一条合法路径(客户端也只能一格一格走),返回走过的格子。
func (sim *fiendWarSim) walkTo(charID uint16, target int) error {
sim.t.Helper()
from := sim.battle().MemberArea(charID, sim.groupOf(charID))
if from == target {
return nil
}
path := sim.areaPath(from, target)
if len(path) == 0 {
sim.t.Fatalf("区域图里 %d 到 %d 没有通路", from, target)
}
for _, area := range path[1:] {
if err := sim.moveTo(charID, area); err != nil {
return fmt.Errorf("走到 %d(从 %d 出发,路径 %v): %w", area, from, path, err)
}
}
return nil
}
// areaPath 是区域图上的最短路(客户端眼里的"相邻才能走")。
func (sim *fiendWarSim) areaPath(from, target int) []int {
type node struct {
area int
path []int
}
queue := []node{{area: from, path: []int{from}}}
seen := map[int]bool{from: true}
for len(queue) > 0 {
current := queue[0]
queue = queue[1:]
if current.area == target {
return current.path
}
next := append([]int(nil), sim.config.Battle.Areas[current.area].Next...)
sort.Ints(next)
for _, area := range next {
if seen[area] {
continue
}
seen[area] = true
step := append(append([]int(nil), current.path...), area)
queue = append(queue, node{area: area, path: step})
}
}
return nil
}
// openBossRooms 走 PVF 那条路:三个兵站本营清掉之后两只 BOSS 才开门。
func (sim *fiendWarSim) openBossRooms() {
sim.t.Helper()
sim.service.raidMu.Lock()
battle := sim.service.raids[sim.raidKey].Lifecycle.FiendWar
for _, name := range []string{"top command center state", "mid command center state", "bot command center state"} {
battle.Symbols[battle.Config.Symbols[name]] = 0
}
sim.service.raidMu.Unlock()
if err := battle.Apply(raid.FiendWarEvent{}, sim.now); err != nil {
sim.t.Fatalf("清掉兵站本营之后跑规则: %v", err)
}
}
// reportBoss 走 op1511。
func (sim *fiendWarSim) reportBoss(charID uint16, kind, value int) error {
payload := make([]byte, 8)
binary.LittleEndian.PutUint32(payload[:4], uint32(kind))
binary.LittleEndian.PutUint32(payload[4:], uint32(value))
return sim.service.handleRuntimeFiendWarBoss(sim.client(charID).session, payload)
}
// moveSquad 走生产路径的编制移动(团长把某人拖进某一队)。
func (sim *fiendWarSim) moveSquad(sourceID, memberID uint16, group byte) (runtimeRaidState, bool) {
sim.t.Helper()
return sim.service.moveRuntimeRaidMember(sourceID, memberID, group)
}
// dungeonIDForArea 返回这一格的副本号。
func (sim *fiendWarSim) dungeonIDForArea(area int) int {
sim.t.Helper()
a, ok := sim.config.Battle.Areas[area]
if !ok || a.Dungeon <= 0 {
sim.t.Fatalf("区域 %d 没有副本", area)
}
return a.Dungeon
}
// areaForDungeon 反向查副本在哪一格。
func (sim *fiendWarSim) areaForDungeon(dungeon int) int {
sim.t.Helper()
area, ok := sim.battle().AreaForDungeon(dungeon)
if !ok {
sim.t.Fatalf("副本 %d 不在区域表里", dungeon)
}
return area.Index
}
// soloEnter 一个人走进某一格并进副本(真机上单人也能打设施:兵营/前哨/雷达/炮塔)。
func (sim *fiendWarSim) soloEnter(driver uint16, area int) {
sim.t.Helper()
if err := sim.walkTo(driver, area); err != nil {
sim.t.Fatalf("char %d 走到 %d:%v%s", driver, area, err,
sim.traceKeys([]string{"move-refused", "blocked"}, 4))
}
if err := sim.reportPosition(driver); err != nil {
sim.t.Fatalf("char %d 报坐标:%v", driver, err)
}
sim.beat(2)
sim.captureBindings()
if sim.client(driver).session.dungeon.runtime == nil && sim.bindings[driver].RaidKey == 0 {
sim.t.Fatalf("char %d 没有进 %d 这一格的副本%s", driver, area,
sim.diagnose(area)+sim.traceKeys([]string{"dungeon-entry", "blocked", "fiendwar-encounter"}, 8))
}
}
// enterArea 把整队带进一个有守军的区域:每个人先走到中转格并报坐标(op35),
// 驱动者再走进目标格、报坐标,然后跑两拍心跳 —— 这就是真机上"一起进图"的完整动作。
func (sim *fiendWarSim) enterArea(driver uint16, roster []uint16, staging, area int) {
sim.t.Helper()
for _, charID := range roster {
if err := sim.moveTo(charID, staging); err != nil {
sim.t.Fatalf("char %d 走到 %d:%v%s", charID, staging, err, sim.traceKeys([]string{"move-refused", "blocked"}, 4))
}
if err := sim.reportPosition(charID); err != nil {
sim.t.Fatalf("char %d 报坐标:%v", charID, err)
}
}
if err := sim.moveTo(driver, area); err != nil {
sim.t.Fatalf("驱动者 char %d 走进 %d:%v%s", driver, area, err, sim.traceKeys([]string{"move-refused", "blocked"}, 4))
}
if err := sim.reportPosition(driver); err != nil {
sim.t.Fatalf("驱动者报坐标:%v", err)
}
sim.beat(2)
sim.captureBindings()
for _, charID := range roster {
if sim.client(charID).session.dungeon.runtime == nil && sim.bindings[charID].RaidKey == 0 {
sim.t.Fatalf("同格 %d 的 char %d 没有进副本%s", area, charID,
sim.diagnose(area)+sim.traceKeys([]string{"dungeon-entry", "blocked", "fiendwar-encounter"}, 10))
}
}
}
// captureBindings 记下每个人手上的副本绑定(token 是各自的那一份)。
//
// 真机上每个成员的 run token 不同(一个区域一条 run,adopt 进来的人带的是开局人
// 的 token、本地 token 是另一个),而通关处理会把 run 从账本里删掉、也会把当事人的
// runtime 收走 —— 所以"报通关"要用**进图时抓下来的那份绑定**,不能事后回查。
func (sim *fiendWarSim) captureBindings() map[uint16]runtimeRaidDungeonBinding {
sim.t.Helper()
out := make(map[uint16]runtimeRaidDungeonBinding, len(sim.clients))
locals := make(map[uint16]uint64, len(sim.clients))
for _, client := range sim.clients {
client.session.dungeon.mu.Lock()
if runtime := client.session.dungeon.runtime; runtime != nil {
out[client.id] = runtime.raidDungeon
// 本地 token 是**每个成员自己那一份**:副本生命周期上报走的是它。
locals[client.id] = runtime.lifecycleToken
}
client.session.dungeon.mu.Unlock()
}
sim.bindings, sim.localTokens = out, locals
return out
}
// leaveDungeon 模拟"这一格打完了":按真实顺序走一遍副本生命周期
// (selected → loaded → completed);生命周期那本账要求顺序,跳步会报 out of order。
func (sim *fiendWarSim) leaveDungeon(charID uint16, dungeonID int) {
sim.t.Helper()
client := sim.client(charID)
client.session.dungeon.mu.Lock()
runtime := client.session.dungeon.runtime
token := uint64(0)
if runtime != nil {
token = runtime.raidDungeon.LifecycleToken
}
client.session.dungeon.mu.Unlock()
if runtime == nil {
// run 已经被处理过(别人先报完):用进图时抓下来的那份绑定。
if binding, ok := sim.bindings[charID]; ok {
token = binding.LifecycleToken
dungeonID = binding.MapID
} else {
sim.t.Fatalf("char %d 手上没有副本运行时,也没抓过绑定,没法收尾副本 %d", charID, dungeonID)
}
}
sim.service.raidMu.Lock()
phase := sim.service.raids[sim.raidKey].Lifecycle.Phase
sim.service.raidMu.Unlock()
for _, kind := range []runtimeRaidDungeonLifecycleKind{
runtimeRaidDungeonSelected, runtimeRaidDungeonLoaded, runtimeRaidDungeonCompleted,
} {
event := runtimeRaidDungeonLifecycleEvent{
Kind: kind,
RaidKey: sim.raidKey,
Phase: phase,
MapID: dungeonID,
LifecycleToken: token,
CharacterID: charID,
}
if err := sim.service.applyRuntimeRaidDungeonLifecycleEvent(event); err != nil {
sim.t.Fatalf("char %d 报副本 %d %s: %v%s", charID, dungeonID, kind, err,
sim.traceKeys([]string{"dungeon-lifecycle", "publish-failed", "joined-clear"}, 6))
}
}
}
// enterDungeonRoom 模拟"在副本里走到某一张房间地图"(真机是 op45 提交之后的房间边钩子)。
// BOSS 房的 [ON ENTER MAP] 规则就挂在这一脚上:两队都进屋才会 [BOSS PATTERN INIT]。
func (sim *fiendWarSim) enterDungeonRoom(charID uint16, roomMapID int64) {
sim.t.Helper()
client := sim.client(charID)
client.session.dungeon.mu.Lock()
runtime := client.session.dungeon.runtime
if runtime == nil {
client.session.dungeon.mu.Unlock()
sim.t.Fatalf("char %d 不在副本里,进不了房间 %d", charID, roomMapID)
}
sim.service.notifyRuntimeFiendWarMapEnteredLocked(client.session, runtime, roomMapID, "sim_room_transition")
client.session.dungeon.mu.Unlock()
}
// clearCounts 返回战况表上每个人的通关数。
func (sim *fiendWarSim) clearCounts() map[uint16]uint32 {
sim.service.raidMu.Lock()
defer sim.service.raidMu.Unlock()
state := sim.service.raids[sim.raidKey]
out := make(map[uint16]uint32, len(state.Lifecycle.PhaseClearCounts))
for id, count := range state.Lifecycle.PhaseClearCounts {
out[id] = count
}
return out
}
// ---------------------------------------------------------------------------
// 断言辅助
// ---------------------------------------------------------------------------
func (sim *fiendWarSim) requireArea(charID uint16, area int, what string) {
sim.t.Helper()
if got := sim.battle().MemberArea(charID, sim.groupOf(charID)); got != area {
sim.t.Fatalf("%s:char %d 的账本区域 = %d,want %d", what, charID, got, area)
}
}
func (sim *fiendWarSim) groupOf(charID uint16) byte {
sim.service.raidMu.Lock()
defer sim.service.raidMu.Unlock()
state := sim.service.raids[sim.raidKey]
member, ok := runtimeRaidFrozenMember(*state, charID)
if !ok {
sim.t.Fatalf("名册里没有 char %d", charID)
}
return runtimeRaidNormalizedGroup(member.GroupIndex)
}
func (sim *fiendWarSim) memberState(charID uint16) runtimeRaidMemberState {
sim.service.raidMu.Lock()
defer sim.service.raidMu.Unlock()
state := sim.service.raids[sim.raidKey]
for _, member := range state.Members {
if member.CharID == charID {
return member
}
}
sim.t.Fatalf("名册里没有 char %d", charID)
return runtimeRaidMemberState{}
}
// sortedIDs 便于稳定输出。
func sortedIDs(values []uint16) []uint16 {
out := append([]uint16(nil), values...)
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}