Files
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

568 lines
17 KiB
Go

// Package companion owns detached policies, not native APC calls.
// No production activation is provided until the native contract is verified.
package companion
import (
"strconv"
"strings"
)
type ErrorCode string
func (e ErrorCode) Error() string { return string(e) }
const (
ErrSelection ErrorCode = "INVALID_SELECTION"
ErrSelf ErrorCode = "SELF_SELECTED"
ErrAccount ErrorCode = "ACCOUNT_MISMATCH"
ErrSource ErrorCode = "SOURCE_UNAVAILABLE"
ErrOnlineUnknown ErrorCode = "ONLINE_STATE_UNKNOWN"
ErrOnline ErrorCode = "SOURCE_ONLINE"
ErrProfile ErrorCode = "PROFILE_UNSUPPORTED"
ErrAssets ErrorCode = "ASSET_MISMATCH"
ErrSnapshot ErrorCode = "INVALID_SNAPSHOT"
ErrStaleRun ErrorCode = "STALE_RUN"
ErrTransition ErrorCode = "INVALID_TRANSITION"
ErrCollision ErrorCode = "OWNER_COLLISION"
ErrRoom ErrorCode = "ROOM_NOT_READY"
ErrRevision ErrorCode = "REVISION_CONFLICT"
)
func validCharacterID(value string) bool {
n, err := strconv.ParseUint(value, 10, 16)
return err == nil && n != 0 && strconv.FormatUint(n, 10) == value
}
func validIdentity(value string) bool {
if value == "" || len(value) > 128 || strings.TrimSpace(value) != value {
return false
}
for _, r := range value {
if r < 32 || r == 127 {
return false
}
}
return true
}
type Config struct {
SchemaVersion int `json:"schema_version"`
Revision uint64 `json:"revision"`
Enabled bool `json:"enabled"`
BalanceEnabled bool `json:"balance_enabled"`
ProgressionEnabled bool `json:"progression_enabled"`
RandomEquipmentEnabled bool `json:"random_equipment_enabled"`
AI AIConfig `json:"ai"`
AIProfiles map[string]AIProfile `json:"ai_profiles,omitempty"`
SourceCharacterID string `json:"source_character_id,omitempty"`
SourceCharacterIDs []string `json:"source_character_ids,omitempty"`
}
// AITuning contains the bounded, user-facing timing and distance controls
// used by the Lua-to-AIC strategy compiler. It intentionally contains no
// raw skill ids or arbitrary PVF tokens; those are selected by the
// profession compiler from a verified allow-list.
//
// A nil tuning pointer keeps old settings rows byte-compatible. Callers that
// need effective values should use EffectiveAITuning, which supplies the
// safe defaults below.
type AITuning struct {
ThinkIntervalMS int `json:"think_interval_ms,omitempty"`
AssistAttackIntervalMS int `json:"assist_attack_interval_ms,omitempty"`
AssistLeashX int `json:"assist_leash_x,omitempty"`
AssistLeashY int `json:"assist_leash_y,omitempty"`
ChaseRange int `json:"chase_range,omitempty"`
VisionRange int `json:"vision_range,omitempty"`
TargetStickinessMS int `json:"target_stickiness_ms,omitempty"`
HealLeaderPercent int `json:"heal_leader_percent,omitempty"`
HealSelfPercent int `json:"heal_self_percent,omitempty"`
RetryMS int `json:"retry_ms,omitempty"`
SupportRetryMS int `json:"support_retry_ms,omitempty"`
BusyMoveMS int `json:"busy_move_ms,omitempty"`
StuckMS int `json:"stuck_ms,omitempty"`
DetourMS int `json:"detour_ms,omitempty"`
GiveUpMS int `json:"give_up_ms,omitempty"`
FollowDistance int `json:"follow_distance,omitempty"`
}
func DefaultAITuning() AITuning {
return AITuning{
ThinkIntervalMS: 120,
AssistAttackIntervalMS: 1800,
AssistLeashX: 180,
AssistLeashY: 90,
ChaseRange: 50,
VisionRange: 10000,
TargetStickinessMS: 1200,
HealLeaderPercent: 85,
HealSelfPercent: 85,
RetryMS: 300,
SupportRetryMS: 1500,
BusyMoveMS: 2500,
StuckMS: 700,
DetourMS: 900,
GiveUpMS: 3500,
FollowDistance: 75,
}
}
func EffectiveAITuning(t *AITuning) AITuning {
if t == nil {
return DefaultAITuning()
}
out := *t
defaults := DefaultAITuning()
if out.ThinkIntervalMS == 0 {
out.ThinkIntervalMS = defaults.ThinkIntervalMS
}
if out.AssistAttackIntervalMS == 0 {
out.AssistAttackIntervalMS = defaults.AssistAttackIntervalMS
}
if out.AssistLeashX == 0 {
out.AssistLeashX = defaults.AssistLeashX
}
if out.AssistLeashY == 0 {
out.AssistLeashY = defaults.AssistLeashY
}
if out.ChaseRange == 0 {
out.ChaseRange = defaults.ChaseRange
}
if out.VisionRange == 0 {
out.VisionRange = defaults.VisionRange
}
if out.TargetStickinessMS == 0 {
out.TargetStickinessMS = defaults.TargetStickinessMS
}
if out.HealLeaderPercent == 0 {
out.HealLeaderPercent = defaults.HealLeaderPercent
}
if out.HealSelfPercent == 0 {
out.HealSelfPercent = defaults.HealSelfPercent
}
if out.RetryMS == 0 {
out.RetryMS = defaults.RetryMS
}
if out.SupportRetryMS == 0 {
out.SupportRetryMS = defaults.SupportRetryMS
}
if out.BusyMoveMS == 0 {
out.BusyMoveMS = defaults.BusyMoveMS
}
if out.StuckMS == 0 {
out.StuckMS = defaults.StuckMS
}
if out.DetourMS == 0 {
out.DetourMS = defaults.DetourMS
}
if out.GiveUpMS == 0 {
out.GiveUpMS = defaults.GiveUpMS
}
if out.FollowDistance == 0 {
out.FollowDistance = defaults.FollowDistance
}
return out
}
// Validate checks user-editable limits before a strategy can be compiled.
func (t AITuning) Validate() error {
positive := map[string]int{
"think_interval_ms": t.ThinkIntervalMS,
"assist_attack_interval_ms": t.AssistAttackIntervalMS,
"assist_leash_x": t.AssistLeashX,
"assist_leash_y": t.AssistLeashY,
"chase_range": t.ChaseRange,
"vision_range": t.VisionRange,
"target_stickiness_ms": t.TargetStickinessMS,
"retry_ms": t.RetryMS,
"support_retry_ms": t.SupportRetryMS,
"busy_move_ms": t.BusyMoveMS,
"stuck_ms": t.StuckMS,
"detour_ms": t.DetourMS,
"give_up_ms": t.GiveUpMS,
"follow_distance": t.FollowDistance,
}
limits := map[string][2]int{
"think_interval_ms": {50, 2000}, "assist_attack_interval_ms": {200, 10000},
"assist_leash_x": {30, 500}, "assist_leash_y": {20, 300},
"chase_range": {20, 1000}, "vision_range": {100, 20000},
"target_stickiness_ms": {0, 10000}, "retry_ms": {50, 5000},
"support_retry_ms": {100, 10000}, "busy_move_ms": {100, 10000},
"stuck_ms": {100, 5000}, "detour_ms": {100, 5000},
"give_up_ms": {500, 20000}, "follow_distance": {20, 300},
}
for name, value := range positive {
if value == 0 {
continue
}
limit := limits[name]
if value < limit[0] || value > limit[1] {
return ErrSelection
}
}
if t.HealLeaderPercent != 0 && (t.HealLeaderPercent < 1 || t.HealLeaderPercent > 100) {
return ErrSelection
}
if t.HealSelfPercent != 0 && (t.HealSelfPercent < 1 || t.HealSelfPercent > 100) {
return ErrSelection
}
return nil
}
// AIProfile is the user-facing strategy selector for one support soldier.
type AIProfile struct {
Behavior string `json:"behavior"` // assist or aggressive; kept for wire compatibility
ClassID uint16 `json:"class_id,omitempty"`
BuffPolicy string `json:"buff_policy"`
ComboPreset string `json:"combo_preset"`
ComboEnabled bool `json:"combo_enabled"`
AOEMinTargets int `json:"aoe_min_targets"`
ProtectLeader bool `json:"protect_leader"`
Tuning *AITuning `json:"tuning,omitempty"`
}
func DefaultAIProfile(classID uint16) AIProfile {
return AIProfile{
Behavior: "assist",
ClassID: classID,
BuffPolicy: "leader_first",
ComboPreset: "auto",
ComboEnabled: true,
AOEMinTargets: 3,
ProtectLeader: true,
Tuning: nil,
}
}
const (
AIBuffPolicyLeaderFirst = "leader_first"
AIBuffPolicyAssistOnly = "assist_only"
AIBuffPolicyOff = "off"
)
func normalizeBehavior(value string) string {
switch value {
case "", "assist":
return "assist"
case "aggressive":
return "aggressive"
case "custom":
// The old custom mode had no client owner. Existing rows are safely
// migrated to the deterministic assist policy.
return "assist"
default:
return value
}
}
func normalizeBuffPolicy(value string) string {
switch value {
case "":
return AIBuffPolicyLeaderFirst
case AIBuffPolicyAssistOnly, AIBuffPolicyOff:
return value
default:
return value
}
}
func normalizeAOEMinTargets(value int) int {
if value == 0 {
return 3
}
return value
}
func NormalizeAIProfile(p AIProfile) AIProfile {
p.Behavior = normalizeBehavior(p.Behavior)
p.BuffPolicy = normalizeBuffPolicy(p.BuffPolicy)
if strings.TrimSpace(p.ComboPreset) == "" {
p.ComboPreset = "auto"
}
p.AOEMinTargets = normalizeAOEMinTargets(p.AOEMinTargets)
return p
}
func (p AIProfile) Validate() error {
p = NormalizeAIProfile(p)
if p.Behavior != "assist" && p.Behavior != "aggressive" {
return ErrSelection
}
if p.BuffPolicy != AIBuffPolicyLeaderFirst &&
p.BuffPolicy != AIBuffPolicyAssistOnly && p.BuffPolicy != AIBuffPolicyOff {
return ErrSelection
}
if p.AOEMinTargets < 2 || p.AOEMinTargets > 6 {
return ErrSelection
}
if p.ComboPreset == "" {
return ErrSelection
}
if len(p.ComboPreset) > 64 || strings.TrimSpace(p.ComboPreset) != p.ComboPreset {
return ErrSelection
}
for _, r := range p.ComboPreset {
if r < 32 || r == 127 {
return ErrSelection
}
}
if p.Tuning != nil {
if err := p.Tuning.Validate(); err != nil {
return err
}
}
return nil
}
func cloneAIProfiles(profiles map[string]AIProfile) map[string]AIProfile {
if profiles == nil {
return nil
}
cloned := make(map[string]AIProfile, len(profiles))
for key, profile := range profiles {
cloned[key] = NormalizeAIProfile(profile)
}
return cloned
}
// AIConfig contains account-level defaults for the strategy compiler. A
// profile's tuning takes precedence over this optional account default.
type AIConfig struct {
BuffPolicy string `json:"buff_policy"`
ComboPreset string `json:"combo_preset"`
ComboEnabled bool `json:"combo_enabled"`
AOEMinTargets int `json:"aoe_min_targets"`
ProtectLeader bool `json:"protect_leader"`
Tuning *AITuning `json:"tuning,omitempty"`
}
// DefaultAIConfig is the only account-level default exposed to GM. All
// distance, timing, target and skill decisions belong to the client executor.
func DefaultAIConfig() AIConfig {
return AIConfig{
BuffPolicy: AIBuffPolicyLeaderFirst,
ComboPreset: "auto",
ComboEnabled: true,
AOEMinTargets: 3,
ProtectLeader: true,
}
}
func (a AIConfig) IsZero() bool { return a == (AIConfig{}) }
func NormalizeAIConfig(a AIConfig) AIConfig {
if a.IsZero() {
return DefaultAIConfig()
}
if a.BuffPolicy == "" {
a.BuffPolicy = AIBuffPolicyLeaderFirst
}
if strings.TrimSpace(a.ComboPreset) == "" {
a.ComboPreset = "auto"
}
if a.AOEMinTargets == 0 {
a.AOEMinTargets = 3
}
return a
}
// EffectiveAI keeps legacy rows working without rewriting them on read.
func (c Config) EffectiveAI() AIConfig {
return NormalizeAIConfig(c.AI)
}
func (a AIConfig) Validate() error {
if a.IsZero() {
return nil
}
if a.BuffPolicy != "" && a.BuffPolicy != AIBuffPolicyLeaderFirst &&
a.BuffPolicy != AIBuffPolicyAssistOnly && a.BuffPolicy != AIBuffPolicyOff {
return ErrSelection
}
if a.ComboPreset != "" && (len(a.ComboPreset) > 64 || strings.TrimSpace(a.ComboPreset) != a.ComboPreset) {
return ErrSelection
}
if a.AOEMinTargets != 0 && (a.AOEMinTargets < 2 || a.AOEMinTargets > 6) {
return ErrSelection
}
if a.Tuning != nil {
if err := a.Tuning.Validate(); err != nil {
return err
}
}
return nil
}
func DefaultConfig() Config { return Config{SchemaVersion: 1} }
func (c Config) Sources() []string {
values := c.SourceCharacterIDs
if len(values) == 0 && c.SourceCharacterID != "" {
values = []string{c.SourceCharacterID}
}
out := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func (c Config) Validate() error {
if c.SchemaVersion != 1 {
return ErrSelection
}
sources := c.Sources()
if c.Enabled && len(sources) == 0 {
return ErrSelection
}
for _, source := range sources {
if !validCharacterID(source) {
return ErrSelection
}
}
if c.SourceCharacterID != "" {
if !validCharacterID(c.SourceCharacterID) ||
len(sources) == 0 || sources[0] != c.SourceCharacterID {
return ErrSelection
}
}
if err := c.AI.Validate(); err != nil {
return err
}
for characterID, profile := range c.AIProfiles {
if !validCharacterID(characterID) {
return ErrSelection
}
if err := profile.Validate(); err != nil {
return err
}
}
return nil
}
func canonicalSourceCharacters(sources []string) ([]string, error) {
out := make([]string, 0, len(sources))
seen := make(map[string]struct{}, len(sources))
for _, source := range sources {
source = strings.TrimSpace(source)
if source == "" || !validCharacterID(source) {
return nil, ErrSelection
}
if _, ok := seen[source]; ok {
continue
}
seen[source] = struct{}{}
out = append(out, source)
}
return out, nil
}
// NextConfigMany is pure validation, NOT an atomic persistent compare-and-swap.
// The account-scoped policy supports any number of distinct source characters.
// Selection is frozen for the next dungeon and no source respawns during a run.
func NextConfigMany(c Config, expected uint64, enabled bool, sources []string, owner string, switches ...bool) (Config, error) {
return NextConfigManyWithAI(c, expected, enabled, sources, owner, c.AI, switches...)
}
// NextConfigManyWithAI updates the account policy and freezes the requested AI
// controls for the next dungeon. Passing a zero AIConfig preserves the
// current value, which keeps older callers source-compatible.
func NextConfigManyWithAI(c Config, expected uint64, enabled bool, sources []string, owner string, ai AIConfig, switches ...bool) (Config, error) {
return NextConfigManyWithAIProfiles(c, expected, enabled, sources, owner, ai, nil, switches...)
}
// NextConfigManyWithAIProfiles is the account-scoped update entry point used
// by the GM when it edits individual support-soldier roles. A nil profile map
// preserves the existing map for legacy callers; a non-nil map replaces it
// atomically with the same revision as the source selection and AI controls.
func NextConfigManyWithAIProfiles(c Config, expected uint64, enabled bool, sources []string, owner string, ai AIConfig, profiles map[string]AIProfile, switches ...bool) (Config, error) {
if err := c.Validate(); err != nil {
return Config{}, err
}
if c.Revision != expected || c.Revision == ^uint64(0) {
return Config{}, ErrRevision
}
if !validCharacterID(owner) {
return Config{}, ErrSelection
}
normalized, err := canonicalSourceCharacters(sources)
if err != nil {
return Config{}, err
}
if enabled && len(normalized) == 0 {
return Config{}, ErrSelection
}
if ai.IsZero() {
ai = c.AI
}
if !ai.IsZero() {
ai = NormalizeAIConfig(ai)
}
if err := ai.Validate(); err != nil {
return Config{}, err
}
if profiles != nil {
for characterID, profile := range profiles {
if !validCharacterID(characterID) {
return Config{}, ErrSelection
}
if err := profile.Validate(); err != nil {
return Config{}, err
}
}
}
balanced := c.BalanceEnabled
if len(switches) > 0 {
balanced = switches[0]
}
progressionEnabled := c.ProgressionEnabled
if len(switches) > 1 {
progressionEnabled = switches[1]
}
randomEquipmentEnabled := c.RandomEquipmentEnabled
if len(switches) > 2 {
randomEquipmentEnabled = switches[2]
}
next := Config{
SchemaVersion: 1,
Revision: c.Revision + 1,
Enabled: enabled,
BalanceEnabled: balanced,
ProgressionEnabled: progressionEnabled,
RandomEquipmentEnabled: randomEquipmentEnabled,
AI: ai,
AIProfiles: cloneAIProfiles(c.AIProfiles),
SourceCharacterIDs: normalized,
}
if profiles != nil {
next.AIProfiles = cloneAIProfiles(profiles)
}
if len(normalized) != 0 {
next.SourceCharacterID = normalized[0]
}
return next, nil
}
// NextConfig preserves the original single-source API for older callers/tests.
func NextConfig(c Config, expected uint64, enabled bool, source, owner string, eligibility error, switches ...bool) (Config, error) {
if enabled && source == owner {
return Config{}, ErrSelf
}
if enabled && eligibility != nil {
return Config{}, eligibility
}
sources := []string{}
if strings.TrimSpace(source) != "" {
sources = append(sources, source)
}
return NextConfigMany(c, expected, enabled, sources, owner, switches...)
}