Files
90CNServer/server/internal/modules/dnf/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

838 lines
30 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 raid
import (
"fmt"
"maps"
"math/rand/v2"
"slices"
"strings"
"time"
)
type FiendWarUnit struct {
ID uint32
Type byte
}
type FiendWarAreaState struct {
Enemy, Monster FiendWarUnit
Group byte
Warning bool
Deadline time.Time
}
type FiendWarTimerKey struct {
Area int
Kind string
}
type FiendWarTimer struct{ StartedAt, Deadline time.Time }
type FiendWarEvent struct {
Kind string
ID int
Name string
}
type FiendWarBattle struct {
Config *FiendWarBattleConfig
Symbols map[uint32]int
Areas map[int]FiendWarAreaState
Timers map[FiendWarTimerKey]FiendWarTimer
DungeonStates map[int]string
GroupAreas map[byte]int
MemberAreas map[uint16]int
RecoveryUntil map[uint16]time.Time
ImprisonCounts map[uint16]int
MerchantArea int
MerchantKind int
MerchantDuration time.Duration
NextUnitID uint32
Complete, Failed bool
// BossWindowDone[side] = 这一侧已经就**本轮**破防窗口报过尾(kind=2 带上自己
// 那一侧 BOSS 的剩余血量)。两侧都报齐之后才判定:都归零 = 通关,
// 否则血量均摊、整轮重开。旧字段名叫 BossPatternDone,语义是"两侧都报了同一颗球"
// —— 那个"锁步"模型是错的(见 fiendWarCloneStates 上面的说明)。
BossWindowDone [2]bool
// PickClone 只有测试会注入:官方这一版四颗球的出场顺序是随机的,
// 但测试需要钉住序列。nil → rand.IntN。
PickClone func(candidates []int) int
Retreats map[byte]int
// FixtureMazes 是"这个设施区域的领主是哪一张图",key = [AREA INDEX]。
//
// 它**不是抽签结果,是固定的**。操作员 2026-10-01 复核历史截图后定的口径:
//
// 「我所说的这些格子里的怪物是固定的。雷达打完前显示问号、服务端随机抽没问题;
// 雷达打完之后客户端就是这么画的,服务端也理应按照客户端画的去让怪物真正
// 百分百地出现 —— 换句话说,雷达打完前服务端随机抽签生效,打完后就不再抽签。」
//
// 客户端那张 kind→脸 的表是按格子位置排的(上哨=宾柏 / 中哨=薛丁恶 / 下哨=克鲁瑟,
// 兵站本营同理),而战况表上的 type 也正是这个位置 kind。所以服务端只要把
// "实际进的那张图"钉在同一位置上,表上画的那只与进图打的那只就是同一只,
// 不需要(也不允许)靠随机去碰。见 fiendWarFixtureMazeForKind。
//
// 只有 maze 张数 > 1 的设施区域才登记;雷达/炮塔/传送站只有一张图,表里没有
// 它们,取用时退回 0。
FixtureMazes map[int]int
pending []FiendWarEvent
}
// fiendWarFixtureMazeForKind 把设施的位置翻译成它**固定**的那张 maze。
//
// 上哨 / 上兵营 → 0 中哨 / 中兵营 → 1 下哨 / 下兵营 → 2
//
// 顺序与 FiendWarFixtureBossIDs 一致(那张表是从 PVF 的 maze 顺序抄下来的):
// 0 = 人类背叛者:宾柏 / 光之恶魔:欧格里,1 = 引力恶魔:薛丁恶 / 暗之恶魔:艾森,
// 2 = 宇宙粉碎机:克鲁瑟 / 重力恶魔:白斯。
func fiendWarFixtureMazeForKind(kind string) (int, bool) {
switch kind {
case "top outpost", "top command center":
return 0, true
case "mid outpost", "mid command center":
return 1, true
case "bot outpost", "bot command center":
return 2, true
}
return 0, false
}
func NewFiendWarBattle(config *FiendWarBattleConfig) (*FiendWarBattle, error) {
if config == nil || config.Raid == nil || len(config.Areas) == 0 || len(config.Rules) == 0 {
return nil, fmt.Errorf("fiendwar battle configuration missing")
}
b := &FiendWarBattle{Config: config, Symbols: map[uint32]int{}, Areas: map[int]FiendWarAreaState{}, Timers: map[FiendWarTimerKey]FiendWarTimer{}, DungeonStates: map[int]string{}, GroupAreas: map[byte]int{}, Retreats: map[byte]int{}, FixtureMazes: map[int]int{}}
b.MemberAreas, b.RecoveryUntil, b.ImprisonCounts = map[uint16]int{}, map[uint16]time.Time{}, map[uint16]int{}
for _, id := range config.Symbols {
b.Symbols[id] = 0
}
for id, area := range config.Areas {
b.Areas[id] = FiendWarAreaState{}
// 多图设施:按位置钉死是哪一张(不抽签)。
if bosses := FiendWarFixtureBossIDs[area.Dungeon]; len(bosses) > 1 {
if maze, ok := fiendWarFixtureMazeForKind(area.Kind); ok && maze < len(bosses) {
b.FixtureMazes[id] = maze
}
}
}
return b, nil
}
// MazeForArea 返回这一格固定用的那张 maze。只有多图设施区域会登记;
// 未登记时 ok 为 false,调用方按自己的语义决定(单图副本恒为 0)。
func (b *FiendWarBattle) MazeForArea(area int) (int, bool) {
if b == nil || b.FixtureMazes == nil {
return 0, false
}
maze, ok := b.FixtureMazes[area]
return maze, ok
}
func (b *FiendWarBattle) Clone() *FiendWarBattle {
if b == nil {
return nil
}
c := *b
c.Symbols = maps.Clone(b.Symbols)
c.Areas = maps.Clone(b.Areas)
c.Timers = maps.Clone(b.Timers)
c.DungeonStates = maps.Clone(b.DungeonStates)
c.GroupAreas = maps.Clone(b.GroupAreas)
c.MemberAreas = maps.Clone(b.MemberAreas)
c.RecoveryUntil = maps.Clone(b.RecoveryUntil)
c.ImprisonCounts = maps.Clone(b.ImprisonCounts)
c.Retreats = maps.Clone(b.Retreats)
c.FixtureMazes = maps.Clone(b.FixtureMazes)
c.pending = append([]FiendWarEvent(nil), b.pending...)
return &c
}
func (b *FiendWarBattle) Value(name string) int { return b.Symbols[b.Config.Symbols[name]] }
func (b *FiendWarBattle) set(name string, value int) { b.setSymbol(b.Config.Symbols[name], value) }
func (b *FiendWarBattle) setSymbol(id uint32, value int) {
if id == 0 || b.Symbols[id] == value {
return
}
b.Symbols[id] = value
b.pending = append(b.pending, FiendWarEvent{Kind: "on change symbol", ID: int(id)})
}
// Apply processes the PVF's paired rules in source order, then follows only
// actual symbol/area changes. A bounded queue rejects cyclic script updates.
func (b *FiendWarBattle) Apply(event FiendWarEvent, now time.Time) error {
if b.Complete || b.Failed {
return nil
}
b.pending = append(b.pending, event)
for count := 0; len(b.pending) > 0; count++ {
if count > 4096 {
return fmt.Errorf("fiendwar trigger cycle")
}
e := b.pending[0]
b.pending = b.pending[1:]
for _, rule := range b.Config.Rules {
matched := true
for _, c := range rule.Conditions {
if !b.matches(c, e) {
matched = false
break
}
}
if matched {
for _, a := range rule.Actions {
b.act(a, now)
}
}
if b.Complete || b.Failed {
b.pending = nil
return nil
}
}
}
return nil
}
func (b *FiendWarBattle) matches(c FiendWarInstruction, e FiendWarEvent) bool {
arg := 0
if len(c.Args) > 0 {
arg = int(c.Args[0].Int)
}
switch c.Op {
case "if":
return b.Symbols[c.Symbol] == arg
case "less":
return b.Symbols[c.Symbol] < arg
case "on change symbol":
return e.Kind == c.Op && e.ID == int(c.Symbol)
case "on change area state", "on giveup area", "on enter dungeon", "on enter map", "on giveup dungeon", "on change dungeon state":
return e.Kind == c.Op && e.ID == arg
case "check area state":
return b.areaState(arg) == c.Args[1].Value
case "check dungeon state":
return b.DungeonStates[arg] == c.Args[1].Value
case "check timer end":
return e.Kind == c.Op && e.ID == int(c.Args[1].Int) && e.Name == c.Args[0].Value
}
return false
}
func (b *FiendWarBattle) areaState(id int) string {
a := b.Areas[id]
if a.Group != 0 {
if a.Enemy.ID != 0 {
return "user_enemy"
}
if a.Monster.ID != 0 {
return "user_monster"
}
}
if a.Enemy.ID != 0 && a.Monster.ID != 0 {
return "enemy_monster"
}
if a.Enemy.ID != 0 {
return "enemy"
}
if a.Monster.ID != 0 {
return "monster"
}
return "empty"
}
func (b *FiendWarBattle) updateArea(id int, a FiendWarAreaState) {
previous := b.areaState(id)
b.Areas[id] = a
current := b.areaState(id)
if previous != current {
// sub_382A400: empty, enemy, monster, user_enemy, user_monster, enemy_monster.
b.Symbols[b.Config.Areas[id].Symbol] = map[string]int{"empty": 0, "enemy": 1, "monster": 2, "user_enemy": 3, "user_monster": 4, "enemy_monster": 5}[current]
b.pending = append(b.pending, FiendWarEvent{Kind: "on change area state", ID: id})
}
count := 0
for _, area := range b.Areas {
if area.Monster.ID != 0 {
count++
}
}
b.set("alive monster count", count)
}
func (b *FiendWarBattle) act(a FiendWarInstruction, now time.Time) {
id := 0
if len(a.Args) > 0 {
id = int(a.Args[0].Int)
}
switch a.Op {
case "set":
b.setSymbol(a.Symbol, id)
case "set timer":
key := FiendWarTimerKey{int(a.Args[1].Int), a.Args[0].Value}
b.Timers[key] = FiendWarTimer{now, now.Add(time.Duration(a.Args[2].Int) * time.Second)}
case "reset timer":
delete(b.Timers, FiendWarTimerKey{int(a.Args[1].Int), a.Args[0].Value})
case "create enemy":
area := b.Areas[id]
if area.Enemy.ID != 0 {
return
}
total := 0
for i := 1; i < len(b.Config.EnemyRates); i += 2 {
total += b.Config.EnemyRates[i]
}
roll := rand.IntN(total)
kind := 1
for i := 0; i < len(b.Config.EnemyRates); i += 2 {
roll -= b.Config.EnemyRates[i+1]
if roll < 0 {
kind = b.Config.EnemyRates[i]
break
}
}
b.NextUnitID++
area.Enemy = FiendWarUnit{b.NextUnitID, byte(kind)}
b.updateArea(id, area)
case "move enemy":
to := int(a.Args[1].Int)
src, dst := b.Areas[id], b.Areas[to]
if src.Enemy.ID == 0 || src.Group != 0 {
return
}
if dst.Enemy.ID == 0 {
dst.Enemy = src.Enemy
}
dst.Warning = false
src.Enemy = FiendWarUnit{}
src.Warning = false
delete(b.Timers, FiendWarTimerKey{id, "move_enemy"})
delete(b.Timers, FiendWarTimerKey{id, "warning_enemy"})
b.updateArea(id, src)
b.updateArea(to, dst)
case "delete monster":
area := b.Areas[id]
area.Monster = FiendWarUnit{}
b.updateArea(id, area)
case "delete all enemy":
for _, id := range b.areaIDs() {
area := b.Areas[id]
area.Enemy = FiendWarUnit{}
area.Warning = false
b.updateArea(id, area)
}
for key := range b.Timers {
if strings.Contains(key.Kind, "enemy") {
delete(b.Timers, key)
}
}
case "warning enemy":
area := b.Areas[id]
area.Warning = true
b.Areas[id] = area
case "create monsters":
count := 0
var available []int
for _, id := range b.areaIDs() {
area := b.Areas[id]
if area.Monster.ID != 0 {
count++
}
// Orcs appear in empty areas. Enemy/monster combat starts when an
// army reaches them, not when a spawn overlaps a stationary army.
if b.Config.Areas[id].Kind == "" && b.Config.Areas[id].Dungeon > 0 && area.Group == 0 && area.Enemy.ID == 0 && area.Monster.ID == 0 {
available = append(available, id)
}
}
for ; count < 3 && len(available) > 0; count++ {
i := rand.IntN(len(available))
id := available[i]
available = append(available[:i], available[i+1:]...)
area := b.Areas[id]
b.NextUnitID++
area.Monster = FiendWarUnit{b.NextUnitID, byte(1 + rand.IntN(2))}
b.updateArea(id, area)
}
case "create merchant":
if len(b.Config.MerchantPositions) > 0 {
b.MerchantArea = b.Config.MerchantPositions[rand.IntN(len(b.Config.MerchantPositions))]
}
if len(b.Config.MerchantRates) > 0 {
total := 0
for _, rate := range b.Config.MerchantRates {
total += rate.Weight
}
if total > 0 {
roll := rand.IntN(total)
for _, rate := range b.Config.MerchantRates {
roll -= rate.Weight
if roll < 0 {
b.MerchantKind = rate.Kind
b.MerchantDuration = time.Duration(rate.DurationMS) * time.Millisecond
break
}
}
}
}
case "boss pattern init":
b.resetBossPattern()
b.set("kick out character", 0)
case "clear phase":
b.Complete = true
case "fail fiendwar":
b.Failed = true
}
}
func (b *FiendWarBattle) areaIDs() []int {
ids := slices.Collect(maps.Keys(b.Config.Areas))
slices.Sort(ids)
return ids
}
func (b *FiendWarBattle) AreaForDungeon(dungeon int) (FiendWarArea, bool) {
for _, a := range b.Config.Areas {
if a.Dungeon == dungeon {
return a, true
}
}
return FiendWarArea{}, false
}
func (b *FiendWarBattle) AreaOpen(id int) bool {
a, ok := b.Config.Areas[id]
if !ok || b.Complete || b.Failed {
return false
}
if strings.HasSuffix(a.Kind, "boss") {
return b.Value(a.Kind+" state") == 1
}
if a.Kind == "radar" || a.Kind == "battery" {
return b.Value(a.Kind+" state") == 0
}
if a.Kind != "" {
return b.Value(a.Kind+" state") == 1
}
return a.Dungeon > 0 && (b.Areas[id].Enemy.ID != 0 || b.Areas[id].Monster.ID != 0)
}
func (b *FiendWarBattle) Move(group byte, target int) error {
if group == 0 || b.Complete || b.Failed {
return fmt.Errorf("fiendwar group inactive")
}
from, ok := b.GroupAreas[group]
if !ok {
from = b.Config.WaitingArea
}
if from == target {
return nil
}
if !slices.Contains(b.Config.Areas[from].Next, target) {
return fmt.Errorf("fiendwar areas are not adjacent")
}
// 超时空的区域是**共享战场**,不是小队的格子:
//
// - 客户端点进已有同伴的区域时自己弹的是「移动后,会与处于当前城镇的同伴
// 一同开始战斗。确认要移动吗?」—— 语义就是"进去一起打";
// - PVF 的 [AREA INFO] 只有 [MEMBER MAX](本 PVF 每区域 4 人),
// **没有"归属小队"这个概念**。
//
// 所以这里不能按小队独占。人数上限由 MoveMember 的 [MEMBER MAX] 管,
// 死锁/占位交给上层(谁是这一区域的战斗单元由 encounter 决定)。
// 原实现的两条判断(Areas[target].Group != 0 → "fiendwar area occupied",
// 以及 GroupAreas 扫描 → "fiendwar area occupied by another group")
// 正是真机上「9999 进了作战区域-01,礼白666 就进不去」的原因。
// 原来这里还有一条"站在没打完的区域里不许再走"("fiendwar source area
// requires combat")。它已经害过两次真机事故,2026-10-01 这次是第二次:
//
// 玩家在作战区域 A 里没打,直接走进了有怪的 B —— 客户端**照走不误**(它自己不
// 认这条规则),服务端却在 town_move.go 里静默 return,于是出现两种后果:
// 1) 客户端的角色在 B,服务端的 MemberAreas 还记着 A —— 两个人明明站在同一格,
// runtimeRaidFiendWarAreaParty 却看不到同伴,"同区域一起进图"直接落空;
// 2) 之后这个人再按进图(target 还是 B,from 仍是 A)又被同一条拒绝挡下,
// 而拒绝路径**一个包都不发** —— 礼白666 就是这样被卡死的。
//
// 客户端的行为就是官方规则的证据:它能走,说明官方允许走。地图的推进由
// [AREA INFO] 的相邻关系 + 设施/BOSS 的 symbol 状态把关(下面那条 BOSS 门禁),
// 不需要"踩到怪就必须打完"这条自造规则。
if strings.HasSuffix(b.Config.Areas[target].Kind, "boss") && !b.AreaOpen(target) {
return fmt.Errorf("fiendwar boss gate closed")
}
b.GroupAreas[group] = target
return nil
}
func (b *FiendWarBattle) MemberArea(character uint16, group byte) int {
if area, ok := b.MemberAreas[character]; ok {
return area
}
if area, ok := b.GroupAreas[group]; ok {
return area
}
return b.Config.WaitingArea
}
func (b *FiendWarBattle) MoveMember(character uint16, group byte, target int, now time.Time) error {
area, found := b.Config.Areas[target]
if character == 0 || !found {
return fmt.Errorf("invalid fiendwar member or area")
}
if now.Before(b.RecoveryUntil[character]) {
return fmt.Errorf("fiendwar member recovering")
}
occupants := 0
for other, location := range b.MemberAreas {
if other != character && location == target {
occupants++
}
}
if area.MemberMax > 0 && occupants >= area.MemberMax {
return fmt.Errorf("fiendwar area member limit reached")
}
previous, exists := b.GroupAreas[group]
b.GroupAreas[group] = b.MemberArea(character, group)
err := b.Move(group, target)
if exists {
b.GroupAreas[group] = previous
} else {
delete(b.GroupAreas, group)
}
if err != nil {
return err
}
if b.MemberAreas == nil {
b.MemberAreas = make(map[uint16]int)
}
b.MemberAreas[character] = target
return nil
}
func (b *FiendWarBattle) ImprisonMember(character uint16, now time.Time) {
if b.MemberAreas == nil {
b.MemberAreas = make(map[uint16]int)
}
if b.RecoveryUntil == nil {
b.RecoveryUntil = make(map[uint16]time.Time)
}
if b.ImprisonCounts == nil {
b.ImprisonCounts = make(map[uint16]int)
}
durations := b.Config.DieImprisonSeconds
if len(durations) == 0 {
return
}
seconds := durations[min(b.ImprisonCounts[character], len(durations)-1)]
b.ImprisonCounts[character]++
b.MemberAreas[character] = b.Config.WaitingArea
b.RecoveryUntil[character] = now.Add(time.Duration(seconds) * time.Second)
}
func (b *FiendWarBattle) Enter(group byte, dungeon int, now time.Time) error {
a, ok := b.AreaForDungeon(dungeon)
if !ok || b.GroupAreas[group] != a.Index || !b.AreaOpen(a.Index) || b.Areas[a.Index].Group != 0 {
return fmt.Errorf("fiendwar dungeon admission rejected")
}
area := b.Areas[a.Index]
delete(b.Retreats, group)
delete(b.DungeonStates, dungeon)
if strings.HasSuffix(a.Kind, "boss") {
b.set("kick out character", 0)
}
area.Group = group
area.Deadline = now.Add(time.Duration(a.LimitSeconds) * time.Second)
area.Warning = false
b.updateArea(a.Index, area)
delete(b.Timers, FiendWarTimerKey{a.Index, "move_enemy"})
delete(b.Timers, FiendWarTimerKey{a.Index, "warning_enemy"})
return b.Apply(FiendWarEvent{Kind: "on enter dungeon", ID: dungeon}, now)
}
func (b *FiendWarBattle) Clear(dungeon int, now time.Time) error {
a, ok := b.AreaForDungeon(dungeon)
if !ok || b.Areas[a.Index].Group == 0 {
return fmt.Errorf("fiendwar dungeon not occupied")
}
// 每个 BOSS 房的门槛只看自己这一侧:PVF 的两条推进规则是独立的
// ([ON CHANGE DUNGEON STATE] 4978 … [IF TOP BOSS STATE] 9 → 10,
// 4953 那条同理),谁先倒下谁先记 10,两个都到 10 才 [CLEAR PHASE]。
// 原来这里额外要求**双方**的 hp 都已归零,等于规定两个 BOSS 必须同时死,
// 分队先后击破时先倒的那一侧永远记不上账。
if strings.HasSuffix(a.Kind, "boss") && (b.Value(a.Kind+" state") != 9 || b.Value(a.Kind+" hp") != 0) {
return fmt.Errorf("fiendwar boss mechanisms unfinished")
}
area := b.Areas[a.Index]
area.Enemy = FiendWarUnit{}
area.Monster = FiendWarUnit{}
area.Group = 0
area.Deadline = time.Time{}
b.updateArea(a.Index, area)
if a.Kind == "radar" || a.Kind == "battery" {
b.set(a.Kind+" state", 1)
} else if a.Kind != "" && !strings.HasSuffix(a.Kind, "boss") {
b.set(a.Kind+" state", 0)
}
b.DungeonStates[dungeon] = "clear"
return b.Apply(FiendWarEvent{Kind: "on change dungeon state", ID: dungeon}, now)
}
// ClearClientBossRoom is Clear for a BOSS room whose death the CLIENT proved.
//
// 背景(2026-10-01 真机,packet_log-20261001-007):
//
// 这个客户端在 BOSS 房里只发一种 op1511 报文 —— kind=0(BOSS 血量百分比),
// 每 2 秒一条,value 恒为 100,直到 BOSS 死亡那一刻为止;全程 34 条,一条
// kind=1(机制)都没有。而 PVF 的 BOSS 状态机只能靠 kind=1 从 5 走到 9
// (BossReport case 1),所以 `topic boss state` 永远停在 2/4/5,
// Clear 必然以 "fiendwar boss mechanisms unfinished" 失败,
// 通关记账整段被跳过 —— 玩家看到的就是「BOSS 打死了,什么都没发生」:
// 没有 1453{3,0}、没有结算、没有回城,两个人一直卡在 BOSS 房里。
//
// 结论:本客户端下 PVF 的 BOSS 机制握手是不可达的。副本通关本身已经是
// 权威事实(op39 BOSS 死亡 → op115/op117 → ordinary_final_clear_committed),
// 所以这里用它当作"这一侧 BOSS 已死"的证据,把该侧的机制收尾到 PVF 允许
// 通关的那个状态(state 9 + hp 0),再走原有的 Clear —— 于是
// [IF … BOSS STATE 9] → 10、两个 10 → [CLEAR PHASE] 全部照 PVF 走。
//
// 非 BOSS 区域逐字走 Clear;客户端真的报过 kind=1 的场合(state 已经是 9)
// 这里不改任何状态,行为与原来完全一致。
func (b *FiendWarBattle) ClearClientBossRoom(dungeon int, now time.Time) error {
if b == nil {
return fmt.Errorf("fiendwar battle missing")
}
a, ok := b.AreaForDungeon(dungeon)
if !ok || !strings.HasSuffix(a.Kind, "boss") {
return b.Clear(dungeon, now)
}
if b.Areas[a.Index].Group == 0 {
return fmt.Errorf("fiendwar dungeon not occupied")
}
if b.Value(a.Kind+" state") != 9 {
// 机制条归零、这一侧血量归零,然后把它推进到"伤害窗口已开"的 9。
b.set("boss pattern state", 0)
b.set(a.Kind+" hp", 0)
b.set(a.Kind+" view hp", 0)
b.set(a.Kind+" state", 9)
}
return b.Clear(dungeon, now)
}
func (b *FiendWarBattle) Abandon(dungeon int, now time.Time) error {
a, ok := b.AreaForDungeon(dungeon)
if !ok {
return fmt.Errorf("unknown fiendwar dungeon")
}
area := b.Areas[a.Index]
group := area.Group
if group == 0 {
return nil
}
area.Group = 0
area.Deadline = time.Time{}
b.updateArea(a.Index, area)
b.Retreats[group] = dungeon
b.pending = append(b.pending, FiendWarEvent{Kind: "on giveup area", ID: a.Index})
if err := b.Apply(FiendWarEvent{Kind: "on giveup dungeon", ID: dungeon}, now); err != nil {
return err
}
if strings.HasSuffix(a.Kind, "boss") {
// PVF requests both rooms to retreat when either boss run is abandoned.
for _, other := range b.areaIDs() {
if strings.HasSuffix(b.Config.Areas[other].Kind, "boss") && b.Areas[other].Group != 0 {
if err := b.Abandon(b.Config.Areas[other].Dungeon, now); err != nil {
return err
}
}
}
b.BossWindowDone = [2]bool{}
b.set("boss pattern state", 0)
}
return nil
}
func (b *FiendWarBattle) Tick(now time.Time) error {
for _, id := range b.areaIDs() {
a := b.Areas[id]
if a.Group != 0 && !a.Deadline.IsZero() && !now.Before(a.Deadline) {
if err := b.Abandon(b.Config.Areas[id].Dungeon, now); err != nil {
return err
}
}
}
keys := slices.Collect(maps.Keys(b.Timers))
slices.SortFunc(keys, func(a, c FiendWarTimerKey) int {
if a.Area != c.Area {
return a.Area - c.Area
}
return strings.Compare(a.Kind, c.Kind)
})
for _, key := range keys {
timer, ok := b.Timers[key]
if ok && !now.Before(timer.Deadline) {
delete(b.Timers, key)
if err := b.Apply(FiendWarEvent{Kind: "check timer end", ID: key.Area, Name: key.Kind}, now); err != nil {
return err
}
}
}
return nil
}
// ---------------------------------------------------------------------------
// 罗什(宇宙恶魔)的四颗属性球
// ---------------------------------------------------------------------------
//
// 一切以**客户端自己的表**为准(`DNF.exe` 0x9D80A0 那张 if/else 链,反汇编见
// `_re_badge_20261001/boss_native.py`):客户端把"我这一侧现在该打哪颗球"读成
// `top boss state` / `bot boss state`(PVF 符号 92/93),取值与分身怪物一一对应:
//
// 5 → 110996 cosmofiend_planet_crystal(宝石/虚无)
// 6 → 110997 cosmofiend_planet_fire (火)
// 7 → 110998 cosmofiend_planet_water (水/冰,动画是 cosmofiend_planet_ice.ani)
// 8 → 110999 cosmofiend_planet_wind (风)
//
// 9 = 这一侧没有球可打了(本体出现);10 = 这一侧已通关(PVF rule 137/139)。
//
// **球池是两队共享的**:`boss pattern state`(符号 94)的四个十进制位就是四颗球,
// 1 = 那颗球还在。客户端拿它画罗什身边那几颗球(自己正在打的那颗不画)。
// 所以两侧**不必打同一颗球** —— 官方攻略「超时空漩涡宇宙恶魔罗什攻略」(3DM/COLG)
// 的原话:
//
// 「罗什四属性球出现后开始随机选择:黑暗队伍视角第一个分身为虚无、
// 纯白队伍视角第一个分身为火 … 当黑暗队伍消灭虚无分身:罗什选择了风分身、
// 纯白队伍视角虚无球消失 … 当纯白队伍消灭冰分身 → 两队视角所有球消失本体出现」
//
// 也就是:每一侧**各拿一颗**(两颗不同),谁打掉自己那颗,那颗球从池子里永久消失、
// 该侧再从剩下的球里补一颗;四颗全清 → 两侧都到 9 → 本体破防(官方:14.8 秒,
// 本机实测正好 15 秒)。破防结束两队**血量均摊(向下取整)**,然后整轮重开。
var fiendWarCloneStates = [4]int{5, 6, 7, 8}
// fiendWarCloneWeights 是机制条 boss pattern state 的四个十进制位(1=球还在)。
// 位与分身的对应关系只影响客户端画哪一颗球灭掉,不影响判定。
var fiendWarCloneWeights = [4]int{1000, 100, 10, 1}
// cloneInPattern 判断某颗球还在不在机制条上。
func cloneInPattern(pattern, state int) bool {
if state < 5 || state > 8 {
return false
}
weight := fiendWarCloneWeights[state-5]
return (pattern/weight)%10 == 1
}
func bossPatternSideName(side int) string {
if side == 1 {
return "bot boss"
}
return "top boss"
}
// resetBossPattern 重开一轮:四颗球复原、两侧各分一颗**不同**的球。
//
// 调用点有两个:PVF 的 `[BOSS PATTERN INIT]`(两队都进了 BOSS 房)和"破防窗口
// 两队没打完、血量均摊之后"。
func (b *FiendWarBattle) resetBossPattern() {
if b == nil {
return
}
b.set("boss pattern state", 1111)
b.BossWindowDone = [2]bool{}
// 先把两侧都清空,再各分一颗 —— 分配时会避开对面手上那颗。
b.set("top boss state", 0)
b.set("bot boss state", 0)
b.assignBossPatternClone(0)
b.assignBossPatternClone(1)
}
// assignBossPatternClone 从共享球池里给这一侧补一颗球。
//
// 池子 = 机制条上还亮着的球;**对面正拿着的那颗不给**(两侧同时各打一颗不同的)。
// 没有可给的了(池子空了,或只剩对面手上那颗)→ 该侧置 9:本体出现,
// 这一侧转入"等对面收尾"(官方攻略里的黄地板等待)。
func (b *FiendWarBattle) assignBossPatternClone(side int) {
pattern := b.Value("boss pattern state")
otherHeld := b.Value(bossPatternSideName(1-side) + " state")
candidates := make([]int, 0, len(fiendWarCloneStates))
for _, state := range fiendWarCloneStates {
if !cloneInPattern(pattern, state) {
continue
}
if state == otherHeld {
continue
}
candidates = append(candidates, state)
}
if len(candidates) == 0 {
b.set(bossPatternSideName(side)+" state", 9)
return
}
pick := 0
if b.PickClone != nil {
pick = b.PickClone(candidates)
} else {
// 官方这一版(2018 超时空之战)四颗球的出场顺序是随机的:
// 攻略原文「分身与BOSS的血量/抗性/机制一致,分身顺序也是随机」。
pick = rand.IntN(len(candidates))
}
if pick < 0 || pick >= len(candidates) {
pick = 0
}
b.set(bossPatternSideName(side)+" state", candidates[pick])
}
// Boss reports are accepted only after the bridge binds a live dungeon host.
// A zero HP report never grants a clear; the dungeon death owner must follow.
func (b *FiendWarBattle) BossReport(dungeon, kind, value int, now time.Time) error {
a, ok := b.AreaForDungeon(dungeon)
if !ok || !strings.HasSuffix(a.Kind, "boss") || b.Areas[a.Index].Group == 0 {
return fmt.Errorf("fiendwar boss host missing")
}
side := 0
if a.Kind == "bot boss" {
side = 1
}
state := b.Value(a.Kind + " state")
switch kind {
case 0:
// BOSS 血量是客户端自己的 HUD 事实,任何"这一侧 BOSS 房被占着"的时刻都
// 应当被接收:原来只允许 5..9,结果这个客户端(只会发 kind=0)的每一条
// 上报都被 "invalid fiendwar boss HP" 拒掉,战况表上的 BOSS 血量永远是
// 初始值,与实际不符。上界 9 保留 —— 10 是"该侧已通关"。
if state < 1 || state > 9 || value < 0 || value > 100 {
return fmt.Errorf("invalid fiendwar boss HP")
}
if state == 9 && value < b.Value(a.Kind+" hp") {
b.set(a.Kind+" hp", value)
}
b.set(a.Kind+" view hp", value)
case 1:
// **只认自己手上那颗**:客户端报的 value 就是它这一侧的 boss state。
// 打掉之后那颗球从共享池里永久消失,这一侧再从池子里补一颗;
// 池子空了(四颗都清)→ 两侧都到 9,本体破防开始。
if state < 5 || state > 8 || value != state || !cloneInPattern(b.Value("boss pattern state"), state) {
return fmt.Errorf("stale fiendwar boss mechanism")
}
b.set("boss pattern state", b.Value("boss pattern state")-fiendWarCloneWeights[state-5])
b.assignBossPatternClone(side)
case 2:
if state != 9 || value < 0 || value > 100 {
return fmt.Errorf("invalid fiendwar boss damage-window report")
}
b.set(a.Kind+" hp", min(value, b.Value(a.Kind+" hp")))
b.BossWindowDone[side] = true
if b.BossWindowDone[0] && b.BossWindowDone[1] {
topHP, botHP := b.Value("top boss hp"), b.Value("bot boss hp")
if topHP != 0 || botHP != 0 {
// 官方攻略:「击杀所有属性分身后,全团拥有15秒输出时间,15秒内两队将
// 各自的BOSS击杀即可顺利通关,如果未击杀成功,则会将BOSS剩余血量
// **均摊给两个队伍**,同上机制重新再打一轮即可。」
// 另一份攻略补了取整口径:「破防结束后两队会平衡血量,结果**向下取整**;
// 相应也会出现一队0%另一队1%,从而直接把1%平衡掉的情况。」
balanced := (topHP + botHP) / 2
b.set("top boss hp", balanced)
b.set("top boss view hp", balanced)
b.set("bot boss hp", balanced)
b.set("bot boss view hp", balanced)
// 均摊之后还有血 → 整轮重开(球池复原、两侧重新分球);
// 均摊成 0(0% / 1% 那一档)→ 两边本体都算死了,不再重开,
// 交给客户端证死那条路收尾。
if balanced != 0 {
b.resetBossPattern()
}
}
b.BossWindowDone = [2]bool{}
}
default:
return fmt.Errorf("unknown fiendwar boss operation")
}
return b.Apply(FiendWarEvent{}, now)
}