服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
764 lines
28 KiB
Go
764 lines
28 KiB
Go
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
|
||
}
|