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