服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
384 lines
18 KiB
Go
384 lines
18 KiB
Go
package dnfbridge
|
||
|
||
import (
|
||
"testing"
|
||
"time"
|
||
|
||
"longheng.io/server/internal/modules/dnf/raid"
|
||
)
|
||
|
||
// 超时空:队友先把区域那一格的 run 开出来之后,驱动者带着**自己那份** token 来报
|
||
// lifecycle 事件,会被归属校验当成非法 token 拒掉 —— 而这一拒是致命的:整场进图
|
||
// 被拆,谁都进不去。
|
||
//
|
||
// 真机 2026-10-01 21:27:11(本工作区短命上线、21:29:13 已回退的那一版):
|
||
//
|
||
// 13:27:11.010 game-party-dungeon-entry-member-prepared party_id=4
|
||
// leader_char_id=1 char_id=20 dungeon_id=4960
|
||
// 13:27:11.120 game-raid-dungeon-lifecycle char_id=20 event=selected
|
||
// dungeon_lifecycle_token=1
|
||
// 13:27:11.123 game-dungeon-select-blocked dungeon_id=4960
|
||
// reason=dungeon_runtime_commit_failed
|
||
// error=runtime raid dungeon lifecycle token is invalid:
|
||
// raid=12058625 phase=1 map=4960 token=2
|
||
// 13:27:11.124 game-fiendwar-encounter-entry entered=false
|
||
// 13:27:11.124 game-single-raid-ordinary-party-cleared
|
||
// source=fiendwar_encounter_rejected char_id=1 party_id=4
|
||
//
|
||
// 之后心跳永远重试 game-fiendwar-encounter-peer-not-ready。
|
||
//
|
||
// 设计意图是"一场只报一次、由 owner 报"(beginRuntimeRaidDungeonForRuntime 里
|
||
// binding.LifecycleToken != runtime.lifecycleToken 那条 return nil),所以这里正确
|
||
// 的行为是**静默接受**,不是报错。这个用例钉的就是那条判定
|
||
// runtimeRaidDungeonLifecycleReportAccepted —— 生产代码与用例共用同一个谓词。
|
||
func TestFiendWarTeammatesRunAcceptsTheSecondMembersOwnTokenReport(t *testing.T) {
|
||
service, raidKey := newRuntimeRaidLifecycleService(time.Now().UTC())
|
||
state := service.raids[raidKey]
|
||
// 一支小队:1001 是驱动者,1002 是队友(真机上队友先被 prepare)。
|
||
state.Members[1].GroupIndex, state.FrozenMembers[1].GroupIndex = 1, 1
|
||
|
||
const (
|
||
areaID = 12
|
||
dungeonID = 4960
|
||
openerID = 1002
|
||
driverID = 1001
|
||
)
|
||
newFiendWarBattleForSeatTest(t, state, areaID, dungeonID)
|
||
|
||
// 队友先把这一格的 run 开出来。真机上这一步发生在
|
||
// prepareRuntimePartyDungeonEntry 准备队友的时候。
|
||
opener, err := service.beginRuntimeRaidDungeon(openerID, dungeonID, 1)
|
||
if err != nil {
|
||
t.Fatalf("队友开这一格的 run: %v", err)
|
||
}
|
||
// 驱动者随后提交:必须采用队友那一份,而不是各开一份。
|
||
adopter, err := service.beginRuntimeRaidDungeon(driverID, dungeonID, 2)
|
||
if err != nil {
|
||
t.Fatalf("驱动者没能采用队友的绑定: %v", err)
|
||
}
|
||
if adopter != opener {
|
||
t.Fatalf("驱动者拿到 %+v,want 队友的 %+v", adopter, opener)
|
||
}
|
||
|
||
// 驱动者报自己的 selected,事件上带的是**他自己那份** token = 2。
|
||
driverEvent := runtimeRaidDungeonLifecycleEvent{
|
||
Kind: runtimeRaidDungeonSelected,
|
||
RaidKey: adopter.RaidKey,
|
||
Phase: adopter.Phase,
|
||
MapID: adopter.MapID,
|
||
LifecycleToken: 2,
|
||
CharacterID: driverID,
|
||
}
|
||
runKey, progress, exists := runtimeRaidRunKeyForAttemptLocked(state, driverID, dungeonID, driverEvent.LifecycleToken)
|
||
if !runtimeRaidDungeonLifecycleReportAccepted(state, runKey, progress, exists, driverEvent) {
|
||
t.Fatalf("队友开的那条 run 不认驱动者(tag=%d)带自己 token 的事件:runKey=%+v progress=%+v exists=%t —— "+
|
||
"notifyRuntimeRaidDungeonLifecycleLocked 会以 errRuntimeRaidDungeonToken 拒掉,"+
|
||
"整场进图被拆,这正是真机 token=2 那条报错",
|
||
driverEvent.LifecycleToken, runKey, progress, exists)
|
||
}
|
||
|
||
// owner 本人带着自己的 token 照旧必须被接受(常规那半条不能被我改坏)。
|
||
ownerEvent := runtimeRaidDungeonLifecycleEvent{
|
||
Kind: runtimeRaidDungeonSelected,
|
||
RaidKey: opener.RaidKey,
|
||
Phase: opener.Phase,
|
||
MapID: opener.MapID,
|
||
LifecycleToken: opener.LifecycleToken,
|
||
CharacterID: openerID,
|
||
}
|
||
ownerKey, ownerProgress, ownerExists := runtimeRaidRunKeyForAttemptLocked(state, openerID, dungeonID, ownerEvent.LifecycleToken)
|
||
if !runtimeRaidDungeonLifecycleReportAccepted(state, ownerKey, ownerProgress, ownerExists, ownerEvent) {
|
||
t.Fatalf("owner 本人的事件被拒了:runKey=%+v progress=%+v", ownerKey, ownerProgress)
|
||
}
|
||
|
||
// owner 用**新** token 重开自己的局(racing duplicate)必须继续被拒:
|
||
// 那条 run 的 owner 就是他本人。
|
||
duplicate := ownerEvent
|
||
duplicate.LifecycleToken = 99
|
||
dupKey, dupProgress, dupExists := runtimeRaidRunKeyForAttemptLocked(state, openerID, dungeonID, duplicate.LifecycleToken)
|
||
if runtimeRaidDungeonLifecycleReportAccepted(state, dupKey, dupProgress, dupExists, duplicate) {
|
||
t.Fatalf("本人用新 token 重开自己的局被放行了:runKey=%+v progress=%+v", dupKey, dupProgress)
|
||
}
|
||
|
||
// 完全不存在的 run 依旧必须被拒(state 为空 / 查不到)。
|
||
if runtimeRaidDungeonLifecycleReportAccepted(state, runKey, progress, false, driverEvent) {
|
||
t.Fatal("查不到的 run 被放行了")
|
||
}
|
||
}
|
||
|
||
// 安东/卢克逐字不变:同一形状(队友先开 run、第二个人带自己的 token 来报)在
|
||
// **没有超时空战场**时必须继续被拒。这是"超时空另走一套、安东/卢克原样"这条边界的
|
||
// 守门用例 —— 它红了就说明那条放松漏到安东/卢克去了。
|
||
func TestAntonAndLukeTeammatesRunStillRejectsTheSecondMembersOwnTokenReport(t *testing.T) {
|
||
service, raidKey := newRuntimeRaidLifecycleService(time.Now().UTC())
|
||
state := service.raids[raidKey]
|
||
// 同一支小队,但 Lifecycle.FiendWar == nil,也就是安东/卢克那条路。
|
||
state.Members[1].GroupIndex, state.FrozenMembers[1].GroupIndex = 1, 1
|
||
if state.Lifecycle.FiendWar != nil {
|
||
t.Fatal("fixture 本意是安东/卢克,却带着超时空战场")
|
||
}
|
||
|
||
const (
|
||
gateMapID = 212
|
||
openerID = 1002
|
||
driverID = 1001
|
||
)
|
||
opener, err := service.beginRuntimeRaidDungeon(openerID, gateMapID, 1)
|
||
if err != nil {
|
||
t.Fatalf("队友开 run: %v", err)
|
||
}
|
||
adopter, err := service.beginRuntimeRaidDungeon(driverID, gateMapID, 2)
|
||
if err != nil {
|
||
t.Fatalf("第二个人 adopt: %v", err)
|
||
}
|
||
if adopter != opener {
|
||
t.Fatalf("安东/卢克的 adopt 语义变了:拿到 %+v,want %+v", adopter, opener)
|
||
}
|
||
|
||
driverEvent := runtimeRaidDungeonLifecycleEvent{
|
||
Kind: runtimeRaidDungeonSelected,
|
||
RaidKey: adopter.RaidKey,
|
||
Phase: adopter.Phase,
|
||
MapID: adopter.MapID,
|
||
LifecycleToken: 2,
|
||
CharacterID: driverID,
|
||
}
|
||
runKey, progress, exists := runtimeRaidRunKeyForAttemptLocked(state, driverID, gateMapID, driverEvent.LifecycleToken)
|
||
if runtimeRaidDungeonLifecycleReportAccepted(state, runKey, progress, exists, driverEvent) {
|
||
t.Fatalf("超时空的放松漏到安东/卢克了:runKey=%+v progress=%+v", runKey, progress)
|
||
}
|
||
}
|
||
|
||
// newFiendWarBattleForSeatTest 给一份最小的超时空战场:等待区 2 + 一个带副本的区域,
|
||
// 小队已经走进那一格(真机上这是 town_move.go 的账本落地)。只用到座位解析,
|
||
// 所以不需要真实 Script.pvf —— 安东/卢克的用例仍然走它们原来的 fixture,一个字不改。
|
||
func newFiendWarBattleForSeatTest(t *testing.T, state *runtimeRaidState, areaID, dungeonID int) {
|
||
t.Helper()
|
||
phase, ok := runtimeRaidDungeonPhaseForMap(dungeonID)
|
||
if !ok {
|
||
t.Fatalf("副本 %d 不在超时空的 map 区间里", dungeonID)
|
||
}
|
||
battle, err := raid.NewFiendWarBattle(&raid.FiendWarBattleConfig{
|
||
Raid: runtimeRaidBridgeTestAntonConfig(),
|
||
WaitingTown: 98,
|
||
WaitingArea: 2,
|
||
Areas: map[int]raid.FiendWarArea{
|
||
2: {Index: 2},
|
||
areaID: {Index: areaID, Dungeon: dungeonID, Next: []int{2}},
|
||
},
|
||
Rules: []raid.FiendWarRule{{}},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("超时空战场 fixture: %v", err)
|
||
}
|
||
state.Lifecycle.FiendWar = battle
|
||
state.Lifecycle.Phase = phase
|
||
state.Lifecycle.Status = runtimeRaidLifecyclePhaseActive
|
||
state.Lifecycle.Objectives = nil
|
||
battle.GroupAreas[1] = areaID
|
||
}
|
||
|
||
// 队友开的那条 run 报 loaded 时,战场必须真的把**这一格**标记成"被占"。
|
||
//
|
||
// 起因是 2026-10-01 23:22~23:23 真机(raid 12058625,分片 034):组队进图之后能打、
|
||
// 能清完,但清完**没有任何反应** —— 单人时一直有的自动回城没了。两条日志:
|
||
//
|
||
// 15:22:40.369 game-raid-dungeon-loaded-publish-deferred char_id=22 party_id=4
|
||
// loading_cycle=1 raid_key=12058625 reason=party_loading_release_continues
|
||
// error=fiendwar dungeon admission rejected
|
||
// 15:23:05.986 game-raid-dungeon-completion-publish-failed char_id=22 dungeon_id=4956
|
||
// op115_sent=true error=fiendwar dungeon not occupied
|
||
//
|
||
// 链条:applyRuntimeRaidDungeonLifecycleEvent 的 loaded 分支原来用
|
||
// `battle.GroupAreas[seat.Group] = battle.MemberArea(报告者, seat.Group)`。
|
||
// 报告者**不一定是按下进图的人**:prepareRuntimePartyDungeonEntry 先备队友,
|
||
// 所以经常是**队友**把这条 run 开出来(真机 23:22:38 char_id=22 event=selected token=1,
|
||
// 驱动者 char 1 是 adopt 的、本地 token=2)。而队友按设计**留在原格** ——
|
||
// MemberAreas 钉在他按下进图时站的格子 —— 于是那一行把 GroupAreas[小队] 覆盖成**原格**,
|
||
// 紧接着 Enter 就以 "fiendwar dungeon admission rejected" 失败,
|
||
// 战场里这一格永远没被标记成被占;通关时 Clear 报 "fiendwar dungeon not occupied",
|
||
// 结算推进不下去,自动回城的整段判定因此永远不成立。
|
||
//
|
||
// 区域本来就由**副本**决定(AreaForDungeon),不由报告者的实时格子决定。
|
||
func TestFiendWarLoadedFromThePinnedFollowerStillOccupiesTheArea(t *testing.T) {
|
||
service, raidKey := newRuntimeRaidLifecycleService(time.Now().UTC())
|
||
state := service.raids[raidKey]
|
||
// helper 给的是 1001(1 队,队长) 与 1002(2 队);这里让**队友 1002** 开局。
|
||
const (
|
||
areaID = 12
|
||
dungeonID = 4960
|
||
openerID = 1002
|
||
)
|
||
newFiendWarBattleForSeatTest(t, state, areaID, dungeonID)
|
||
if state.Lifecycle.FiendWar == nil {
|
||
t.Fatal("fixture 没有战场")
|
||
}
|
||
battle := state.Lifecycle.FiendWar
|
||
// 区域 12 要"开着":站着一只守军(与真机的"这一格有怪"一致)。
|
||
battle.Areas[areaID] = raid.FiendWarAreaState{Monster: raid.FiendWarUnit{ID: 1, Type: 1}}
|
||
// 队友按设计留在**原格**(等待区 2);驱动者那一队已经走进目标区域。
|
||
battle.MemberAreas[openerID] = 2
|
||
battle.GroupAreas[1] = areaID
|
||
|
||
binding, err := service.beginRuntimeRaidDungeon(openerID, dungeonID, 1)
|
||
if err != nil {
|
||
t.Fatalf("队友开这一格的 run:%v", err)
|
||
}
|
||
if binding.MapID != dungeonID {
|
||
t.Fatalf("binding=%+v,want map=%d", binding, dungeonID)
|
||
}
|
||
|
||
// 队友报 loaded:run 就是他开的,所以他手上的 token 与 binding 一致。
|
||
if err := service.applyRuntimeRaidDungeonLifecycleEvent(
|
||
runtimeRaidDungeonEventForTest(binding, openerID, runtimeRaidDungeonLoaded),
|
||
); err != nil {
|
||
t.Fatalf("队友报 loaded 被战场拒了:%v\n"+
|
||
"(真机 2026-10-01 23:22:40 就是这句 fiendwar dungeon admission rejected;"+
|
||
"战场里这一格没被标记成被占,通关时 Clear 会报 fiendwar dungeon not occupied,"+
|
||
"自动回城整段不触发)", err)
|
||
}
|
||
|
||
after := state.Lifecycle.FiendWar
|
||
area, found := after.AreaForDungeon(dungeonID)
|
||
if !found {
|
||
t.Fatalf("剧本里没有拥有副本 %d 的区域", dungeonID)
|
||
}
|
||
if after.Areas[area.Index].Group == 0 {
|
||
t.Fatalf("区域 %d 没有被标记成被占(Areas[%d].Group=%d):通关结算会失败、不会自动回城",
|
||
area.Index, area.Index, after.Areas[area.Index].Group)
|
||
}
|
||
// 收尾那一步必须真的能过 —— 这就是真机卡住的那一步。
|
||
if err := after.Clear(dungeonID, time.Now().UTC()); err != nil {
|
||
t.Fatalf("清完这一格时战场结算失败:%v", err)
|
||
}
|
||
}
|
||
|
||
// 一个区域共用一条 run:**队友先报通关**把这一格的那一点 HP 花掉之后,
|
||
// **开局人**再报通关仍然必须能过 —— 否则这条 run 永远关不掉、开局人永远不自动回城。
|
||
//
|
||
// 真机 2026-10-01 23:38(raid 12058625,分片 035)就是这条:
|
||
//
|
||
// 15:38:57.846 game-raid-joined-clear-booked char_id=1
|
||
// booked=true remaining_hp_before=1 remaining_hp_after=0
|
||
// 15:38:57.860 game-raid-dungeon-completion-publish-failed char_id=22 dungeon_id=4954
|
||
// op115_sent=true error=runtime raid dungeon objective is not open
|
||
// 15:38:58.751 game-fiendwar-clear-auto-return char_id=1 ... ← 只有驱动者回城了
|
||
// (char 22 再也没有回城)
|
||
//
|
||
// 原因:completed 分支里的超时空那一段**无条件**要求
|
||
// `RemainingHP > 0 && State == AntonDungeonOpen`,而队友那一笔已经走
|
||
// applyRuntimeRaidJoinedSquadClear 把 HP 花掉、把 RunClearCredits 记上了
|
||
// ("一条 run 一次通关"),于是开局人这一笔被判成 errRuntimeRaidDungeonUnavailable。
|
||
// 而**只有开局人那一笔**会把这条 run 标成 completed(队友那一笔只记本地)——
|
||
// 于是 run 关不掉、开局人也永远不自动回城。
|
||
func TestFiendWarOwnerCompletionSucceedsAfterASquadmateAlreadyBookedTheClear(t *testing.T) {
|
||
service, raidKey := newRuntimeRaidLifecycleService(time.Now().UTC())
|
||
state := service.raids[raidKey]
|
||
const (
|
||
areaID = 12
|
||
dungeonID = 4960
|
||
openerID = 1002 // 2 队、被钉在原格:这条 run 由他开
|
||
joinedID = 1001 // 1 队:adopt 进来、先报 completed 的那位
|
||
)
|
||
newFiendWarBattleForSeatTest(t, state, areaID, dungeonID)
|
||
battle := state.Lifecycle.FiendWar
|
||
battle.Areas[areaID] = raid.FiendWarAreaState{Monster: raid.FiendWarUnit{ID: 1, Type: 1}}
|
||
battle.MemberAreas[openerID] = 2
|
||
battle.GroupAreas[1] = areaID
|
||
phase := state.Lifecycle.Phase
|
||
// 这一格的目标还开着(1 点 HP)。
|
||
state.Lifecycle.Objectives = map[int]runtimeRaidDungeonObjective{
|
||
dungeonID: {Phase: phase, MapID: dungeonID, State: raid.AntonDungeonOpen, MaxHP: 1, RemainingHP: 1},
|
||
}
|
||
state.Lifecycle.RunClearCredits = map[runtimeRaidDungeonRunKey]uint64{}
|
||
|
||
binding, err := service.beginRuntimeRaidDungeon(openerID, dungeonID, 1)
|
||
if err != nil {
|
||
t.Fatalf("开局人开出这一格的 run:%v", err)
|
||
}
|
||
if err := service.applyRuntimeRaidDungeonLifecycleEvent(
|
||
runtimeRaidDungeonEventForTest(binding, openerID, runtimeRaidDungeonLoaded),
|
||
); err != nil {
|
||
t.Fatalf("开局人报 loaded:%v", err)
|
||
}
|
||
|
||
runKey := runtimeRaidDungeonRunKey{}
|
||
for key := range state.Lifecycle.Dungeons {
|
||
if key.MapID == dungeonID {
|
||
runKey = key
|
||
}
|
||
}
|
||
if runKey.MapID != dungeonID {
|
||
t.Fatalf("账本里没有副本 %d 的 run:%+v", dungeonID, state.Lifecycle.Dungeons)
|
||
}
|
||
|
||
// 队友先报通关。这里**直接铺出它留下的账本状态**,而不是调用
|
||
// applyRuntimeRaidJoinedSquadClear —— 后者会在 booked 之后调
|
||
// publishRuntimeRaidObjectiveProgress → releaseRuntimeRaidStaleGateOccupancy,
|
||
// 而单元 fixture 里没有"活着持有这条 run 的会话",那条 run 会被当成过期回收
|
||
// 成 abandoned(本用例第一版就是这么红的)。真机上持有 run 的会话是活的,
|
||
// 不会被回收。
|
||
//
|
||
// applyRuntimeRaidJoinedSquadClear 留下的正是这三样(读代码核对过):
|
||
// · objective 被 advanceRuntimeRaidObjectiveLocked 扣掉那一点 HP
|
||
// · runtimeRaidCreditRunClearLocked:这条 run 的 clear 已用
|
||
// · ClosedDungeonAttempts[{队友, 他自己的 localToken}] = completed
|
||
state.Lifecycle.Objectives[dungeonID] = runtimeRaidDungeonObjective{
|
||
Phase: phase, MapID: dungeonID, State: raid.AntonDungeonClosed,
|
||
MaxHP: 1, RemainingHP: 0, ClearCount: 1,
|
||
}
|
||
state.Lifecycle.RunClearCredits[runKey] = binding.LifecycleToken
|
||
state.Lifecycle.ClosedDungeonAttempts[runtimeRaidDungeonAttemptKey{
|
||
CharacterID: joinedID, LifecycleKey: binding.LifecycleToken + 1,
|
||
}] = runtimeRaidDungeonTerminal{Phase: phase, MapID: dungeonID, Kind: runtimeRaidDungeonCompleted}
|
||
|
||
if objective := state.Lifecycle.Objectives[dungeonID]; objective.RemainingHP != 0 {
|
||
t.Fatalf("fixture 前提没成立:这一格的 HP 没被花掉(remaining=%d)", objective.RemainingHP)
|
||
}
|
||
if credited, ok := state.Lifecycle.RunClearCredits[runKey]; !ok || credited != binding.LifecycleToken {
|
||
t.Fatalf("fixture 前提没成立:这条 run 的 clear 没被记上(%v/%t)",
|
||
state.Lifecycle.RunClearCredits, ok)
|
||
}
|
||
|
||
// 开局人再报通关:必须能过,并且把这条 run 标成 completed。
|
||
if err := service.applyRuntimeRaidDungeonLifecycleEvent(
|
||
runtimeRaidDungeonEventForTest(binding, openerID, runtimeRaidDungeonCompleted),
|
||
); err != nil {
|
||
t.Fatalf("队友先记过账之后,开局人的通关被拒:%v\n"+
|
||
"(真机 2026-10-01 23:38:57.860 就是这句 runtime raid dungeon objective is not open;"+
|
||
"run 关不掉 ⇒ 开局人永远不自动回城 —— 现象「队长回城了、队员没回去」)\n"+
|
||
"closed=%+v runs=%+v", err, state.Lifecycle.ClosedDungeonAttempts, state.Lifecycle.Dungeons)
|
||
}
|
||
if progress, ok := state.Lifecycle.Dungeons[runKey]; ok &&
|
||
progress.Status != runtimeRaidDungeonProgressCompleted {
|
||
t.Fatalf("这一条 run 没有被标成 completed:%+v", progress)
|
||
}
|
||
if attempt, closed := state.Lifecycle.ClosedDungeonAttempts[runtimeRaidDungeonAttemptKey{
|
||
CharacterID: openerID, LifecycleKey: binding.LifecycleToken}]; !closed ||
|
||
attempt.Kind != runtimeRaidDungeonCompleted {
|
||
t.Fatalf("开局人那一笔没有被记成完成:%+v closed=%t", attempt, closed)
|
||
}
|
||
|
||
// **开局人自己的"地下城通关"也要记上。**
|
||
//
|
||
// 真机 2026-10-02 00:05:22(raid 12058625,分片 20261002-002):
|
||
//
|
||
// game-raid-joined-clear-booked char_id=1 booked=true
|
||
// game-raid-run-clear-already-credited char_id=22 group=2
|
||
// game-raid-phase-clear-registration char_id=22 member_clear_counts=1:1
|
||
// ← 只有 char 1 记到了,char 22 是 0
|
||
//
|
||
// 原因是 runCredited 那一支原来只写日志、不记账;两人**同一小队**时看不出来
|
||
// (队友记账那下已经把整队记上),但超时空的进图单位是区域名单、与编制无关,
|
||
// 两个人常常不在同一小队。操作员原话:「通关以后 队友的通关次数居然没计数」。
|
||
if got := state.Lifecycle.PhaseClearCounts[openerID]; got < 1 {
|
||
t.Fatalf("开局人自己的通关数没被记上:PhaseClearCounts[%d]=%d(想要 >=1);全表=%v",
|
||
openerID, got, state.Lifecycle.PhaseClearCounts)
|
||
}
|
||
// 而且不许把队友那一队重复记 —— 超时空的上限是 1,两次也只停在 1。
|
||
if got := state.Lifecycle.PhaseClearCounts[joinedID]; got > 1 {
|
||
t.Fatalf("队友的通关数被重复记了:PhaseClearCounts[%d]=%d;全表=%v",
|
||
joinedID, got, state.Lifecycle.PhaseClearCounts)
|
||
}
|
||
}
|