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