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,264 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AICArtifact is the deterministic, text representation of the part of an
|
||||
// AI profile that is already expressible by the checked-in AIC grammar. It
|
||||
// is deliberately separate from a packed PVF: callers may inspect and
|
||||
// validate this artifact without opening or modifying a runtime archive.
|
||||
//
|
||||
// The Lua source remains the source of truth for the full profile. AICText
|
||||
// contains only whitelisted scalar AIC tags, so unsupported Lua capabilities
|
||||
// cannot accidentally turn into arbitrary PVF tokens.
|
||||
type AICArtifact struct {
|
||||
Path string `json:"path"`
|
||||
TemplateKey string `json:"template_key"`
|
||||
Job uint8 `json:"job"`
|
||||
ClassID uint16 `json:"class_id"`
|
||||
Behavior string `json:"behavior"`
|
||||
ComboPreset string `json:"combo_preset"`
|
||||
APCCode int64 `json:"apc_code"`
|
||||
AICPath string `json:"aic_path"`
|
||||
AICText string `json:"aic_text"`
|
||||
SHA256 string `json:"sha256"`
|
||||
LuaSHA256 string `json:"lua_sha256"`
|
||||
StrategyStatus string `json:"strategy_status"`
|
||||
MissingCapabilities []StrategyCapability `json:"missing_capabilities,omitempty"`
|
||||
}
|
||||
|
||||
// PVFOverlayManifest is a small offline hand-off artifact. The
|
||||
// companionpvf command is expected to consume Path/AICText and encode it
|
||||
// with the PVF archive's string pool in a copied archive; this manifest
|
||||
// itself never writes runtime/data/dnf/Script.pvf.
|
||||
type PVFOverlayManifest struct {
|
||||
Format string `json:"format"`
|
||||
BasePVF string `json:"base_pvf"`
|
||||
BaseSHA256 string `json:"base_sha256,omitempty"`
|
||||
Artifacts []AICArtifact `json:"artifacts"`
|
||||
ManifestHash string `json:"manifest_sha256"`
|
||||
}
|
||||
|
||||
var aicScalarTags = []string{
|
||||
"[think term]",
|
||||
"[destination change term]",
|
||||
"[keep distance with target]",
|
||||
"[warlike]",
|
||||
"[vision]",
|
||||
}
|
||||
|
||||
var aicScalarLine = regexp.MustCompile(`^\[([^\]]+)\]\s*(-?[0-9]+)\s*$`)
|
||||
|
||||
// CompileAICArtifact lowers a validated profile into an inspectable AIC
|
||||
// artifact. It intentionally does not claim that the artifact has been
|
||||
// consumed by a native client; that claim belongs to the action-receipt gate.
|
||||
func CompileAICArtifact(profile AIProfile, caps StrategyCapabilities) (AICArtifact, error) {
|
||||
if !caps.ThinkTerm || !caps.Distance {
|
||||
return AICArtifact{}, fmt.Errorf("AIC scalar compiler requires think_term and target_distance capabilities")
|
||||
}
|
||||
job := uint8(profile.ClassID)
|
||||
return CompileAICArtifactForJob(profile, caps, job)
|
||||
}
|
||||
|
||||
// CompileAICArtifactForJob is the job-aware form used by dungeon projection.
|
||||
// ClassID selects the learned combo branch; job selects the native profession
|
||||
// family and therefore the verified APC/AIC pair.
|
||||
func CompileAICArtifactForJob(profile AIProfile, caps StrategyCapabilities, job uint8) (AICArtifact, error) {
|
||||
compiled, err := CompileAIProfile(profile, caps)
|
||||
if err != nil {
|
||||
return AICArtifact{}, err
|
||||
}
|
||||
profile = NormalizeAIProfile(profile)
|
||||
selector := SelectAITemplate(profile, uint16(job))
|
||||
tuning := EffectiveAITuning(profile.Tuning)
|
||||
lines := []string{
|
||||
"[think term]", strconv.Itoa(tuning.ThinkIntervalMS),
|
||||
"[destination change term]", strconv.Itoa(tuning.RetryMS),
|
||||
"[keep distance with target]", strconv.Itoa(tuning.FollowDistance),
|
||||
"[warlike]", strconv.Itoa(aicWarlike(profile)),
|
||||
"[vision]", strconv.Itoa(tuning.VisionRange),
|
||||
}
|
||||
text := strings.Join(lines, "\n") + "\n"
|
||||
if _, err := ReadAICArtifact(text); err != nil {
|
||||
return AICArtifact{}, fmt.Errorf("generated AIC failed readback: %w", err)
|
||||
}
|
||||
return AICArtifact{
|
||||
// Path is the real AICharacter.lst path that the PVF overlay replaces.
|
||||
// TemplateKey/LuaSHA256 keep the profile identity auditable even when
|
||||
// two profiles share one native profession template.
|
||||
Path: selector.AICPath, TemplateKey: selector.Key,
|
||||
Job: job, ClassID: selector.ClassID, Behavior: selector.Behavior,
|
||||
ComboPreset: selector.ComboPreset,
|
||||
APCCode: selector.APCCode, AICPath: selector.AICPath,
|
||||
AICText: text,
|
||||
SHA256: sha256Hex([]byte(text)),
|
||||
LuaSHA256: compiled.LuaSHA256,
|
||||
StrategyStatus: compiled.Status,
|
||||
MissingCapabilities: append([]StrategyCapability(nil), compiled.MissingCapabilities...),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func aicWarlike(profile AIProfile) int {
|
||||
if profile.Behavior == "aggressive" {
|
||||
return 80
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func artifactSlug(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
var b strings.Builder
|
||||
lastDash := false
|
||||
for _, r := range value {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' {
|
||||
b.WriteRune(r)
|
||||
lastDash = false
|
||||
continue
|
||||
}
|
||||
if !lastDash {
|
||||
b.WriteByte('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "-")
|
||||
if out == "" {
|
||||
return "auto"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ReadAICArtifact validates and indexes a generated AIC text artifact. The
|
||||
// parser accepts exactly one scalar value for each generated tag and rejects
|
||||
// unknown tags, duplicate tags, malformed numbers, or non-whitespace data.
|
||||
func ReadAICArtifact(text string) (map[string]int, error) {
|
||||
allowed := make(map[string]struct{}, len(aicScalarTags))
|
||||
for _, tag := range aicScalarTags {
|
||||
allowed[tag] = struct{}{}
|
||||
}
|
||||
values := make(map[string]int, len(aicScalarTags))
|
||||
lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
|
||||
for i := 0; i < len(lines); {
|
||||
line := strings.TrimSpace(lines[i])
|
||||
if line == "" {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
match := aicScalarLine.FindStringSubmatch(line)
|
||||
var tag string
|
||||
var number string
|
||||
if len(match) == 3 {
|
||||
tag = "[" + strings.ToLower(match[1]) + "]"
|
||||
number = match[2]
|
||||
} else if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
tag = "[" + strings.ToLower(strings.TrimSuffix(strings.TrimPrefix(line, "["), "]")) + "]"
|
||||
if i+1 >= len(lines) || strings.TrimSpace(lines[i+1]) == "" {
|
||||
return nil, fmt.Errorf("line %d: scalar tag %q has no value", i+1, tag)
|
||||
}
|
||||
number = strings.TrimSpace(lines[i+1])
|
||||
} else {
|
||||
return nil, fmt.Errorf("line %d: expected [tag] followed by an integer", i+1)
|
||||
}
|
||||
if _, ok := allowed[tag]; !ok {
|
||||
return nil, fmt.Errorf("line %d: unsupported AIC tag %q", i+1, tag)
|
||||
}
|
||||
if _, ok := values[tag]; ok {
|
||||
return nil, fmt.Errorf("line %d: duplicate AIC tag %q", i+1, tag)
|
||||
}
|
||||
value, err := strconv.Atoi(number)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("line %d: invalid value: %w", i+1, err)
|
||||
}
|
||||
values[tag] = value
|
||||
if len(match) == 3 {
|
||||
i++
|
||||
} else {
|
||||
i += 2
|
||||
}
|
||||
}
|
||||
if len(values) != len(aicScalarTags) {
|
||||
return nil, fmt.Errorf("AIC scalar set incomplete: got %d of %d tags", len(values), len(aicScalarTags))
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// NewPVFOverlayManifest builds a stable JSON manifest for an offline PVF
|
||||
// overlay. The hash is computed over canonical JSON with ManifestHash
|
||||
// cleared, making repeat compilation byte-for-byte deterministic.
|
||||
func NewPVFOverlayManifest(basePVF, baseSHA256 string, artifacts []AICArtifact) (PVFOverlayManifest, error) {
|
||||
if strings.TrimSpace(basePVF) == "" {
|
||||
return PVFOverlayManifest{}, fmt.Errorf("base PVF path is required")
|
||||
}
|
||||
copyArtifacts := append([]AICArtifact(nil), artifacts...)
|
||||
sort.Slice(copyArtifacts, func(i, j int) bool { return copyArtifacts[i].Path < copyArtifacts[j].Path })
|
||||
manifest := PVFOverlayManifest{
|
||||
Format: "dnf90-companion-pvf-overlay-v1",
|
||||
BasePVF: basePVF,
|
||||
BaseSHA256: baseSHA256,
|
||||
Artifacts: copyArtifacts,
|
||||
}
|
||||
manifest.ManifestHash = manifestHash(manifest)
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func manifestHash(manifest PVFOverlayManifest) string {
|
||||
manifest.ManifestHash = ""
|
||||
data, _ := json.Marshal(manifest)
|
||||
return sha256Hex(data)
|
||||
}
|
||||
|
||||
// ValidatePVFOverlayManifest checks both each AIC readback and the manifest
|
||||
// hash. It is suitable for a CI/readback step after an offline artifact was
|
||||
// copied to a review directory.
|
||||
func ValidatePVFOverlayManifest(manifest PVFOverlayManifest) error {
|
||||
if manifest.Format != "dnf90-companion-pvf-overlay-v1" {
|
||||
return fmt.Errorf("unsupported overlay format %q", manifest.Format)
|
||||
}
|
||||
if strings.TrimSpace(manifest.BasePVF) == "" || len(manifest.Artifacts) == 0 {
|
||||
return fmt.Errorf("overlay must contain base PVF and at least one AIC artifact")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(manifest.Artifacts))
|
||||
for _, artifact := range manifest.Artifacts {
|
||||
if artifact.Path == "" {
|
||||
return fmt.Errorf("artifact path is empty")
|
||||
}
|
||||
selector := AITemplateSelector{
|
||||
Key: artifact.TemplateKey, Job: artifact.Job, ClassID: artifact.ClassID,
|
||||
Behavior: artifact.Behavior, ComboPreset: artifact.ComboPreset,
|
||||
APCCode: artifact.APCCode, AICPath: artifact.AICPath,
|
||||
BaseAICPath: artifact.AICPath,
|
||||
}
|
||||
if err := ValidateAITemplateSelector(selector); err != nil {
|
||||
return fmt.Errorf("%s: invalid template selector: %w", artifact.Path, err)
|
||||
}
|
||||
if !strings.EqualFold(artifact.Path, artifact.AICPath) {
|
||||
return fmt.Errorf("%s: artifact path must equal AIC path %s", artifact.Path, artifact.AICPath)
|
||||
}
|
||||
if _, ok := seen[artifact.Path]; ok {
|
||||
return fmt.Errorf("duplicate artifact path %q", artifact.Path)
|
||||
}
|
||||
seen[artifact.Path] = struct{}{}
|
||||
if _, err := ReadAICArtifact(artifact.AICText); err != nil {
|
||||
return fmt.Errorf("%s: %w", artifact.Path, err)
|
||||
}
|
||||
if want := sha256Hex([]byte(artifact.AICText)); artifact.SHA256 != want {
|
||||
return fmt.Errorf("%s: artifact hash mismatch", artifact.Path)
|
||||
}
|
||||
}
|
||||
if manifest.ManifestHash == "" || manifest.ManifestHash != manifestHash(manifest) {
|
||||
return fmt.Errorf("manifest hash mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sha256Hex(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompileAICArtifactRoundTripsAndDiffersByBehavior(t *testing.T) {
|
||||
assist := DefaultAIProfile(9)
|
||||
aggressive := assist
|
||||
aggressive.Behavior = "aggressive"
|
||||
|
||||
a, err := CompileAICArtifact(assist, DefaultStrategyCapabilities())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := CompileAICArtifact(aggressive, DefaultStrategyCapabilities())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.SHA256 == b.SHA256 || a.AICText == b.AICText {
|
||||
t.Fatal("assist and aggressive artifacts must differ")
|
||||
}
|
||||
values, err := ReadAICArtifact(a.AICText)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if values["[warlike]"] != 0 {
|
||||
t.Fatalf("assist warlike=%d, want 0", values["[warlike]"])
|
||||
}
|
||||
values, err = ReadAICArtifact(b.AICText)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if values["[warlike]"] != 80 {
|
||||
t.Fatalf("aggressive warlike=%d, want 80", values["[warlike]"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileAICArtifactUsesTimeForDestinationRetryAndRejectsMissingPrimitives(t *testing.T) {
|
||||
profile := DefaultAIProfile(4)
|
||||
tuning := DefaultAITuning()
|
||||
tuning.RetryMS = 700
|
||||
tuning.AssistLeashX = 240
|
||||
profile.Tuning = &tuning
|
||||
artifact, err := CompileAICArtifact(profile, DefaultStrategyCapabilities())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
values, err := ReadAICArtifact(artifact.AICText)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if values["[destination change term]"] != 700 {
|
||||
t.Fatal("destination change term must use a time value")
|
||||
}
|
||||
if _, err := CompileAICArtifact(profile, StrategyCapabilities{}); err == nil {
|
||||
t.Fatal("missing static AIC primitives unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPVFOverlayManifestIsStableAndValidatesTampering(t *testing.T) {
|
||||
profile := DefaultAIProfile(15)
|
||||
artifact, err := CompileAICArtifact(profile, DefaultStrategyCapabilities())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest, err := NewPVFOverlayManifest("runtime/data/dnf/Script.pvf", "base-hash", []AICArtifact{artifact})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ValidatePVFOverlayManifest(manifest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), "dnf90-companion-pvf-overlay-v1") {
|
||||
t.Fatal("manifest format missing")
|
||||
}
|
||||
manifest.Artifacts[0].AICText = strings.Replace(manifest.Artifacts[0].AICText, "[warlike]\n0", "[warlike]\n80", 1)
|
||||
if err := ValidatePVFOverlayManifest(manifest); err == nil {
|
||||
t.Fatal("tampered artifact unexpectedly validated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAICArtifactRejectsUnknownAndDuplicateTags(t *testing.T) {
|
||||
profile := DefaultAIProfile(1)
|
||||
artifact, err := CompileAICArtifact(profile, DefaultStrategyCapabilities())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadAICArtifact(artifact.AICText + "[custom tag]\n1\n"); err == nil {
|
||||
t.Fatal("unknown tag unexpectedly accepted")
|
||||
}
|
||||
if _, err := ReadAICArtifact(artifact.AICText + "[vision]\n1\n"); err == nil {
|
||||
t.Fatal("duplicate tag unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StrategyCapability is a named primitive that the offline compiler is
|
||||
// allowed to consume. Keeping this list explicit prevents a Lua profile from
|
||||
// injecting arbitrary AIC tags or silently claiming a missing client feature.
|
||||
type StrategyCapability string
|
||||
|
||||
const (
|
||||
CapabilityThinkTerm StrategyCapability = "think_term"
|
||||
CapabilityDistance StrategyCapability = "target_distance"
|
||||
CapabilityCooldown StrategyCapability = "key_cooldown"
|
||||
CapabilityActionStream StrategyCapability = "action_stream"
|
||||
CapabilityFriendAnchor StrategyCapability = "friend_anchor"
|
||||
CapabilityPartyHP StrategyCapability = "party_hp"
|
||||
CapabilityEnemyCount StrategyCapability = "enemy_count_in_area"
|
||||
CapabilityLeaderFollow StrategyCapability = "leader_follow"
|
||||
CapabilityLeaderBuff StrategyCapability = "leader_buff"
|
||||
CapabilityActionAck StrategyCapability = "action_ack"
|
||||
CapabilityTargetSticky StrategyCapability = "target_sticky"
|
||||
CapabilityStuckDetour StrategyCapability = "stuck_detour"
|
||||
)
|
||||
|
||||
// StrategyCapabilities describes the currently proven compiler/runtime
|
||||
// surface. The defaults reflect the AIC tokens found in the checked-in PVF;
|
||||
// the dynamic leader/action features remain intentionally false until a real
|
||||
// client implementation proves them.
|
||||
type StrategyCapabilities struct {
|
||||
ThinkTerm bool `json:"think_term"`
|
||||
Distance bool `json:"target_distance"`
|
||||
Cooldown bool `json:"key_cooldown"`
|
||||
ActionStream bool `json:"action_stream"`
|
||||
FriendAnchor bool `json:"friend_anchor"`
|
||||
PartyHP bool `json:"party_hp"`
|
||||
EnemyCount bool `json:"enemy_count_in_area"`
|
||||
LeaderFollow bool `json:"leader_follow"`
|
||||
LeaderBuff bool `json:"leader_buff"`
|
||||
ActionAck bool `json:"action_ack"`
|
||||
TargetSticky bool `json:"target_sticky"`
|
||||
StuckDetour bool `json:"stuck_detour"`
|
||||
}
|
||||
|
||||
func DefaultStrategyCapabilities() StrategyCapabilities {
|
||||
return StrategyCapabilities{
|
||||
ThinkTerm: true, Distance: true, Cooldown: true, ActionStream: true,
|
||||
FriendAnchor: true, PartyHP: true, EnemyCount: true,
|
||||
LeaderFollow: false, LeaderBuff: false, ActionAck: false,
|
||||
TargetSticky: false, StuckDetour: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (c StrategyCapabilities) Has(cap StrategyCapability) bool {
|
||||
switch cap {
|
||||
case CapabilityThinkTerm:
|
||||
return c.ThinkTerm
|
||||
case CapabilityDistance:
|
||||
return c.Distance
|
||||
case CapabilityCooldown:
|
||||
return c.Cooldown
|
||||
case CapabilityActionStream:
|
||||
return c.ActionStream
|
||||
case CapabilityFriendAnchor:
|
||||
return c.FriendAnchor
|
||||
case CapabilityPartyHP:
|
||||
return c.PartyHP
|
||||
case CapabilityEnemyCount:
|
||||
return c.EnemyCount
|
||||
case CapabilityLeaderFollow:
|
||||
return c.LeaderFollow
|
||||
case CapabilityLeaderBuff:
|
||||
return c.LeaderBuff
|
||||
case CapabilityActionAck:
|
||||
return c.ActionAck
|
||||
case CapabilityTargetSticky:
|
||||
return c.TargetSticky
|
||||
case CapabilityStuckDetour:
|
||||
return c.StuckDetour
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type StrategyStep struct {
|
||||
Name string `json:"name"`
|
||||
Priority int `json:"priority"`
|
||||
Value int `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
// CompiledStrategy is an offline artifact. It can be written into a
|
||||
// generated AIC/PVF overlay by the companionpvf tool, but it is never marked
|
||||
// as applied until a future client executor reports a real action receipt.
|
||||
type CompiledStrategy struct {
|
||||
Status string `json:"status"`
|
||||
LuaSource string `json:"lua_source"`
|
||||
LuaSHA256 string `json:"lua_sha256"`
|
||||
Steps []StrategyStep `json:"steps"`
|
||||
MissingCapabilities []StrategyCapability `json:"missing_capabilities,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
func (r CompiledStrategy) ReadyForStaticAIC() bool {
|
||||
return r.Status == "compiled_static" || r.Status == "compiled_with_gaps"
|
||||
}
|
||||
|
||||
// CompileAIProfile converts the high-level GM profile to a small, auditable
|
||||
// strategy IR and a Lua configuration source. The IR is deliberately limited
|
||||
// to known decisions; unproven dynamic features are reported as gaps instead
|
||||
// of being silently treated as live behaviour.
|
||||
func CompileAIProfile(profile AIProfile, caps StrategyCapabilities) (CompiledStrategy, error) {
|
||||
profile = NormalizeAIProfile(profile)
|
||||
if err := profile.Validate(); err != nil {
|
||||
return CompiledStrategy{}, err
|
||||
}
|
||||
tuning := EffectiveAITuning(profile.Tuning)
|
||||
steps := []StrategyStep{
|
||||
{Name: "think", Priority: 100, Value: tuning.ThinkIntervalMS},
|
||||
{Name: "vision", Priority: 95, Value: tuning.VisionRange},
|
||||
{Name: "keep_distance", Priority: 90, Value: tuning.FollowDistance},
|
||||
}
|
||||
missing := make(map[StrategyCapability]struct{})
|
||||
warnings := []string{}
|
||||
need := func(cap StrategyCapability, step StrategyStep, warning string) {
|
||||
if caps.Has(cap) {
|
||||
steps = append(steps, step)
|
||||
return
|
||||
}
|
||||
missing[cap] = struct{}{}
|
||||
if warning != "" {
|
||||
warnings = append(warnings, warning)
|
||||
}
|
||||
}
|
||||
|
||||
if profile.Behavior == "assist" {
|
||||
need(CapabilityFriendAnchor,
|
||||
StrategyStep{Name: "assist_anchor", Priority: 85, Value: tuning.AssistLeashX},
|
||||
"辅助站位使用已验证的 friend anchor 近似;固定队长后方坐标仍需客户端能力。")
|
||||
need(CapabilityLeaderFollow,
|
||||
StrategyStep{Name: "follow_leader", Priority: 84, Value: tuning.FollowDistance},
|
||||
"当前没有经过验证的队长坐标读取,未宣称实时躲在主角后方。")
|
||||
if profile.BuffPolicy != AIBuffPolicyOff {
|
||||
need(CapabilityLeaderBuff,
|
||||
StrategyStep{Name: "buff_leader", Priority: 120},
|
||||
"BUFF 优先级已保存,但当前编译器没有真实队长施法回执。")
|
||||
}
|
||||
if tuning.HealLeaderPercent > 0 {
|
||||
need(CapabilityPartyHP,
|
||||
StrategyStep{Name: "heal_leader", Priority: 118, Value: tuning.HealLeaderPercent},
|
||||
"治疗阈值可编译为 party hp 条件;技能实际生效仍需 AIC/PVF 回读。")
|
||||
}
|
||||
steps = append(steps, StrategyStep{Name: "assist_attack_gate", Priority: 40, Value: tuning.AssistAttackIntervalMS})
|
||||
} else {
|
||||
if profile.BuffPolicy != AIBuffPolicyOff {
|
||||
need(CapabilityActionStream,
|
||||
StrategyStep{Name: "buff_first", Priority: 120},
|
||||
"先 BUFF 后攻击仅能生成有序技能流,当前没有动作回执。")
|
||||
}
|
||||
steps = append(steps, StrategyStep{Name: "attack_rotation", Priority: 70, Value: tuning.ChaseRange})
|
||||
}
|
||||
|
||||
if profile.ComboEnabled {
|
||||
need(CapabilityCooldown,
|
||||
StrategyStep{Name: "combo_rotation", Priority: 80},
|
||||
"职业连招使用白名单 key stream;没有冷却原语时拒绝生成。")
|
||||
}
|
||||
if profile.AOEMinTargets >= 2 {
|
||||
need(CapabilityEnemyCount,
|
||||
StrategyStep{Name: "aoe_when_many", Priority: 105, Value: profile.AOEMinTargets},
|
||||
"范围技能条件依赖已验证的区域敌人数;技能键仍由职业模板提供。")
|
||||
}
|
||||
if tuning.TargetStickinessMS > 0 {
|
||||
need(CapabilityTargetSticky, StrategyStep{Name: "target_sticky", Priority: 60, Value: tuning.TargetStickinessMS},
|
||||
"目标黏性参数已保存,当前 AIC 没有可证明的目标锁定回执。")
|
||||
}
|
||||
if tuning.StuckMS > 0 || tuning.DetourMS > 0 || tuning.GiveUpMS > 0 {
|
||||
need(CapabilityStuckDetour, StrategyStep{Name: "stuck_detour_give_up", Priority: 30, Value: tuning.GiveUpMS},
|
||||
"卡住重试、绕行和放弃目标等待客户端位置/目标状态。")
|
||||
}
|
||||
|
||||
missingList := make([]StrategyCapability, 0, len(missing))
|
||||
for cap := range missing {
|
||||
missingList = append(missingList, cap)
|
||||
}
|
||||
sort.Slice(missingList, func(i, j int) bool { return missingList[i] < missingList[j] })
|
||||
sort.Slice(steps, func(i, j int) bool {
|
||||
if steps[i].Priority != steps[j].Priority {
|
||||
return steps[i].Priority > steps[j].Priority
|
||||
}
|
||||
return steps[i].Name < steps[j].Name
|
||||
})
|
||||
lua := renderLuaPolicy(profile, tuning, steps)
|
||||
hash := sha256.Sum256([]byte(lua))
|
||||
status := "compiled_static"
|
||||
if len(missingList) > 0 {
|
||||
status = "compiled_with_gaps"
|
||||
}
|
||||
return CompiledStrategy{
|
||||
Status: status, LuaSource: lua, LuaSHA256: hex.EncodeToString(hash[:]),
|
||||
Steps: steps, MissingCapabilities: missingList, Warnings: warnings,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func luaString(value string) string {
|
||||
var b strings.Builder
|
||||
b.WriteByte('"')
|
||||
for _, r := range value {
|
||||
switch r {
|
||||
case '\\':
|
||||
b.WriteString("\\\\")
|
||||
case '"':
|
||||
b.WriteString("\\\"")
|
||||
case '\n':
|
||||
b.WriteString("\\n")
|
||||
case '\r':
|
||||
b.WriteString("\\r")
|
||||
case '\t':
|
||||
b.WriteString("\\t")
|
||||
default:
|
||||
if r < 32 || r == 127 {
|
||||
fmt.Fprintf(&b, "\\%03d", r)
|
||||
} else {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
b.WriteByte('"')
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func renderLuaPolicy(profile AIProfile, tuning AITuning, steps []StrategyStep) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("-- generated by companion strategy compiler; data only\nreturn {\n")
|
||||
fmt.Fprintf(&b, " version = 1, behavior = %s, class_id = %d, buff_policy = %s, combo_preset = %s, combo_enabled = %s, aoe_min_targets = %d, protect_leader = %s,\n",
|
||||
luaString(profile.Behavior), profile.ClassID, luaString(profile.BuffPolicy), luaString(profile.ComboPreset), strconv.FormatBool(profile.ComboEnabled), profile.AOEMinTargets, strconv.FormatBool(profile.ProtectLeader))
|
||||
fmt.Fprintf(&b, " tuning = { think_interval_ms = %d, assist_attack_interval_ms = %d, assist_leash_x = %d, assist_leash_y = %d, chase_range = %d, vision_range = %d, target_stickiness_ms = %d, heal_leader_percent = %d, heal_self_percent = %d, retry_ms = %d, support_retry_ms = %d, busy_move_ms = %d, stuck_ms = %d, detour_ms = %d, give_up_ms = %d, follow_distance = %d },\n",
|
||||
tuning.ThinkIntervalMS, tuning.AssistAttackIntervalMS, tuning.AssistLeashX, tuning.AssistLeashY, tuning.ChaseRange, tuning.VisionRange, tuning.TargetStickinessMS, tuning.HealLeaderPercent, tuning.HealSelfPercent, tuning.RetryMS, tuning.SupportRetryMS, tuning.BusyMoveMS, tuning.StuckMS, tuning.DetourMS, tuning.GiveUpMS, tuning.FollowDistance)
|
||||
b.WriteString(" steps = {\n")
|
||||
for _, step := range steps {
|
||||
fmt.Fprintf(&b, " { name = %s, priority = %d, value = %d },\n", luaString(step.Name), step.Priority, step.Value)
|
||||
}
|
||||
b.WriteString(" },\n}\n")
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompileAIProfileProducesDeterministicLuaAndReportsGaps(t *testing.T) {
|
||||
profile := DefaultAIProfile(9)
|
||||
one, err := CompileAIProfile(profile, DefaultStrategyCapabilities())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !one.ReadyForStaticAIC() || one.Status != "compiled_with_gaps" {
|
||||
t.Fatalf("compile status=%+v", one)
|
||||
}
|
||||
if one.LuaSHA256 == "" || !strings.Contains(one.LuaSource, "behavior = \"assist\"") {
|
||||
t.Fatalf("lua artifact missing: %+v", one)
|
||||
}
|
||||
two, err := CompileAIProfile(profile, DefaultStrategyCapabilities())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if one.LuaSHA256 != two.LuaSHA256 || one.LuaSource != two.LuaSource {
|
||||
t.Fatalf("compiler is not deterministic: %q %q", one.LuaSHA256, two.LuaSHA256)
|
||||
}
|
||||
for _, want := range []StrategyCapability{CapabilityLeaderFollow, CapabilityLeaderBuff, CapabilityTargetSticky, CapabilityStuckDetour} {
|
||||
found := false
|
||||
for _, got := range one.MissingCapabilities {
|
||||
if got == want {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("missing capability %q not reported: %v", want, one.MissingCapabilities)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileAIProfileAggressiveOrdersBuffBeforeAttack(t *testing.T) {
|
||||
profile := DefaultAIProfile(1)
|
||||
profile.Behavior = "aggressive"
|
||||
profile.ComboPreset = "sword_guard"
|
||||
result, err := CompileAIProfile(profile, StrategyCapabilities{
|
||||
ThinkTerm: true, Distance: true, Cooldown: true, ActionStream: true, EnemyCount: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.ReadyForStaticAIC() {
|
||||
t.Fatalf("status=%s missing=%v", result.Status, result.MissingCapabilities)
|
||||
}
|
||||
if len(result.Steps) == 0 || result.Steps[0].Name != "buff_first" {
|
||||
t.Fatalf("steps=%+v", result.Steps)
|
||||
}
|
||||
if !strings.Contains(result.LuaSource, "combo_preset = \"sword_guard\"") {
|
||||
t.Fatalf("lua preset missing: %s", result.LuaSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAITuningRejectsUnsafeValues(t *testing.T) {
|
||||
profile := DefaultAIProfile(1)
|
||||
profile.Tuning = &AITuning{ThinkIntervalMS: 1}
|
||||
if err := profile.Validate(); err != ErrSelection {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
profile.Tuning = &AITuning{HealLeaderPercent: 101}
|
||||
if err := profile.Validate(); err != ErrSelection {
|
||||
t.Fatalf("heal err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectAITemplateUsesNativeBaselineForClassAndBehavior(t *testing.T) {
|
||||
assist := SelectAITemplate(DefaultAIProfile(4), 4)
|
||||
aggressiveProfile := DefaultAIProfile(4)
|
||||
aggressiveProfile.Behavior = "aggressive"
|
||||
aggressive := SelectAITemplate(aggressiveProfile, 4)
|
||||
other := SelectAITemplate(DefaultAIProfile(9), 2)
|
||||
for name, selector := range map[string]AITemplateSelector{
|
||||
"assist": assist, "aggressive": aggressive, "other": other,
|
||||
} {
|
||||
if !selector.Valid() {
|
||||
t.Fatalf("%s selector invalid: %+v", name, selector)
|
||||
}
|
||||
}
|
||||
if assist.APCCode != 20902 || assist.AICPath != "aicharacter/_bizarre/priest/mirror_priest/mirror_priest.aic" {
|
||||
t.Fatalf("priest assist did not use native baseline: %+v", assist)
|
||||
}
|
||||
if aggressive.APCCode != assist.APCCode || aggressive.AICPath != assist.AICPath {
|
||||
t.Fatalf("behavior selectors must patch the same native baseline: assist=%+v aggressive=%+v", assist, aggressive)
|
||||
}
|
||||
if assist.APCCode == other.APCCode || assist.AICPath == other.AICPath {
|
||||
t.Fatalf("class selectors aliased: assist=%+v other=%+v", assist, other)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
// Synthetic fixtures test policies only, not any real DNF job/skill ABI.
|
||||
func fixture(t *testing.T) (repository.CompanionSourceState, Selection, Profile) {
|
||||
t.Helper()
|
||||
data := repository.CompanionSourceState{
|
||||
Owner: repository.CharacterRecord{CharacterID: "1", AccountID: "account-test"},
|
||||
Source: repository.CharacterRecord{CharacterID: "2", AccountID: "account-test", Name: "Fixture", Job: "0", Level: 40, Stats: map[string]int64{"grow_type": 1}},
|
||||
Equipment: repository.EquipmentRecord{CharacterID: "2", Entries: map[string]repository.EquipmentEntry{
|
||||
"weapon": {SlotIndex: 12, ItemID: 10000, RawEntry: []byte{1, 2}, Extra: map[string]string{"test": "unchanged"}},
|
||||
}},
|
||||
Skill: repository.SkillRecord{CharacterID: "2", Skills: map[int64]repository.SkillState{
|
||||
10: {Level: 3, Enabled: true}, 20: {Level: 7, Enabled: true}, 30: {Level: 4, Enabled: false},
|
||||
}, Cooldowns: map[int64]time.Time{10: time.Unix(100, 0)}}, HasEquipment: true, HasSkill: true,
|
||||
}
|
||||
s := Selection{AccountID: "account-test", OwnerCharacterID: "1", SourceCharacterID: "2", OnlineStateKnown: true,
|
||||
ClientSHA256: strings.Repeat("a", 64), PVFSHA256: strings.Repeat("b", 64)}
|
||||
p := Profile{ID: "synthetic-policy-test-only", Version: 1, EvidenceID: "unit-test-not-native-evidence",
|
||||
ClientSHA256: s.ClientSHA256, PVFSHA256: s.PVFSHA256, Job: 0, GrowType: 1,
|
||||
BasicAttackVerified: true, SkillLevelLimits: map[int64]int{10: 5}}
|
||||
return data, s, p
|
||||
}
|
||||
func prepared(t *testing.T) Snapshot {
|
||||
t.Helper()
|
||||
data, s, p := fixture(t)
|
||||
snapshot, err := Prepare(data, s, p, time.Unix(1000, 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
func runKey() RunKey {
|
||||
return RunKey{Instance: "test-instance", SessionGeneration: 1, OwnerCharacterID: "1", LifecycleToken: 2, SpawnSequence: 1, Nonce: strings.Repeat("c", 64)}
|
||||
}
|
||||
|
||||
func TestCompanionProductionProfilesFailClosed(t *testing.T) {
|
||||
if len(VerifiedProfiles()) != 0 {
|
||||
t.Fatal("unverified native profile advertised")
|
||||
}
|
||||
data, s, p := fixture(t)
|
||||
p.BasicAttackVerified = false
|
||||
if _, err := Prepare(data, s, p, time.Unix(1000, 0)); !errors.Is(err, ErrProfile) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestCompanionConfigRevisionAndDisable(t *testing.T) {
|
||||
c := DefaultConfig()
|
||||
if _, err := NextConfig(c, 1, true, "2", "1", nil); !errors.Is(err, ErrRevision) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := NextConfig(c, 0, true, "1", "1", nil); !errors.Is(err, ErrSelf) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := NextConfig(c, 0, true, "2", "1", ErrProfile); !errors.Is(err, ErrProfile) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next, err := NextConfig(c, 0, false, "2", "1", ErrProfile)
|
||||
if err != nil || next.Enabled || next.Revision != 1 || c.Revision != 0 {
|
||||
t.Fatalf("%+v %v", next, err)
|
||||
}
|
||||
c.Revision = ^uint64(0)
|
||||
if _, err := NextConfig(c, c.Revision, false, "", "1", nil); !errors.Is(err, ErrRevision) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestCompanionInvalidCharacterIDs(t *testing.T) {
|
||||
for _, id := range []string{"0", "01", " 2", "65536", "-1", "2.0", ""} {
|
||||
t.Run(id, func(t *testing.T) {
|
||||
if validCharacterID(id) {
|
||||
t.Fatal("accepted invalid ID")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestCompanionSelectionRejections(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
edit func(*repository.CompanionSourceState, *Selection, *Profile)
|
||||
want error
|
||||
}{
|
||||
{"self", func(d *repository.CompanionSourceState, s *Selection, p *Profile) { s.SourceCharacterID = "1" }, ErrSelf},
|
||||
{"other-account", func(d *repository.CompanionSourceState, s *Selection, p *Profile) { d.Source.AccountID = "another" }, ErrAccount},
|
||||
{"deleted", func(d *repository.CompanionSourceState, s *Selection, p *Profile) { d.Source.Stats["delete_flag"] = 1 }, ErrSource},
|
||||
{"online", func(d *repository.CompanionSourceState, s *Selection, p *Profile) { s.SourceOnline = true }, ErrOnline},
|
||||
{"online-unknown", func(d *repository.CompanionSourceState, s *Selection, p *Profile) { s.OnlineStateKnown = false }, ErrOnlineUnknown},
|
||||
{"client-version", func(d *repository.CompanionSourceState, s *Selection, p *Profile) {
|
||||
s.ClientSHA256 = strings.Repeat("d", 64)
|
||||
}, ErrAssets},
|
||||
{"job", func(d *repository.CompanionSourceState, s *Selection, p *Profile) { d.Source.Job = "3" }, ErrProfile},
|
||||
{"missing-grow", func(d *repository.CompanionSourceState, s *Selection, p *Profile) {
|
||||
delete(d.Source.Stats, "grow_type")
|
||||
}, ErrProfile},
|
||||
{"missing-skills", func(d *repository.CompanionSourceState, s *Selection, p *Profile) { d.HasSkill = false }, ErrSnapshot},
|
||||
{"cross-equipment", func(d *repository.CompanionSourceState, s *Selection, p *Profile) { d.Equipment.CharacterID = "3" }, ErrSnapshot},
|
||||
{"duplicate-slot", func(d *repository.CompanionSourceState, s *Selection, p *Profile) {
|
||||
d.Equipment.Entries["duplicate"] = d.Equipment.Entries["weapon"]
|
||||
}, ErrSnapshot},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
data, s, p := fixture(t)
|
||||
tc.edit(&data, &s, &p)
|
||||
if _, err := Prepare(data, s, p, time.Unix(1000, 0)); !errors.Is(err, tc.want) {
|
||||
t.Fatalf("got %v want %v", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestCompanionSnapshotDetachedAndStable(t *testing.T) {
|
||||
data, s, p := fixture(t)
|
||||
a, err := Prepare(data, s, p, time.Unix(1000, 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(a.Skills, []SkillSpec{{ID: 10, Level: 3}}) {
|
||||
t.Fatal(a.Skills)
|
||||
}
|
||||
b, err := Prepare(data, s, p, time.Unix(1000, 0))
|
||||
if err != nil || a.Fingerprint() != b.Fingerprint() {
|
||||
t.Fatal("unstable fingerprint")
|
||||
}
|
||||
a.Equipment[0].ItemID = 123
|
||||
a.Skills[0].Level = 1
|
||||
if data.Equipment.Entries["weapon"].ItemID != 10000 || data.Skill.Skills[10].Level != 3 {
|
||||
t.Fatal("source mutation")
|
||||
}
|
||||
data.Source.Name = "changed"
|
||||
data.Skill.Skills[10] = repository.SkillState{Level: 5, Enabled: true}
|
||||
if b.Name != "Fixture" || b.Skills[0].Level != 3 {
|
||||
t.Fatal("snapshot mutation")
|
||||
}
|
||||
}
|
||||
func TestCompanionEmptySnapshotClonePreservesFingerprint(t *testing.T) {
|
||||
data, s, p := fixture(t)
|
||||
data.Equipment.Entries = map[string]repository.EquipmentEntry{}
|
||||
data.Skill.Skills = nil
|
||||
a, err := Prepare(data, s, p, time.Unix(1000, 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Fingerprint() != a.Clone().Fingerprint() {
|
||||
t.Fatal("empty-list clone changed content hash")
|
||||
}
|
||||
}
|
||||
func TestCompanionExpiredEquipmentExcluded(t *testing.T) {
|
||||
data, s, p := fixture(t)
|
||||
gear := data.Equipment.Entries["weapon"]
|
||||
gear.ExpireAt = time.Unix(999, 0)
|
||||
data.Equipment.Entries["weapon"] = gear
|
||||
a, err := Prepare(data, s, p, time.Unix(1000, 0))
|
||||
if err != nil || len(a.Equipment) != 0 {
|
||||
t.Fatalf("%+v %v", a, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompanionLifecycleFullRun(t *testing.T) {
|
||||
key := runKey()
|
||||
source := prepared(t)
|
||||
i, err := NewInstance(key, source, 3, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source.Name = "external change"
|
||||
event := func(seq uint64, kind string, room uint64) Event {
|
||||
return Event{Run: key, Sequence: seq, Kind: kind, RoomGeneration: room, ObjectKey: 24, ObjectGeneration: 7}
|
||||
}
|
||||
apply := func(e Event) {
|
||||
t.Helper()
|
||||
if err := i.Apply(e, map[uint16]bool{1: true, 400: true}); err != nil {
|
||||
t.Fatal(e.Kind, err)
|
||||
}
|
||||
}
|
||||
apply(event(1, "room_ready", 1))
|
||||
apply(event(2, "native_created", 1))
|
||||
if i.Owns(key, 24, 7) {
|
||||
t.Fatal("owner allowed before binding")
|
||||
}
|
||||
if err := i.Apply(event(3, "activate", 1), nil); !errors.Is(err, ErrTransition) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
apply(event(3, "owner_bound", 1))
|
||||
apply(event(4, "activate", 1))
|
||||
apply(event(4, "activate", 1))
|
||||
if !i.Owns(key, 24, 7) || i.Owns(key, 24, 8) {
|
||||
t.Fatal("owner generation validation")
|
||||
}
|
||||
apply(event(5, "room_leaving", 1))
|
||||
apply(event(6, "room_ready", 2))
|
||||
if i.Status().ObjectKey != 24 || i.Snapshot().Name != "Fixture" || i.Status().ConfigRevision != 3 {
|
||||
t.Fatal("cross-room instance replaced")
|
||||
}
|
||||
apply(event(7, "dead", 2))
|
||||
if err := i.Apply(event(8, "activate", 2), nil); !errors.Is(err, ErrTransition) {
|
||||
t.Fatal("dead actor revived", err)
|
||||
}
|
||||
if !i.Owns(key, 24, 7) {
|
||||
t.Fatal("in-flight ownership lost prematurely")
|
||||
}
|
||||
apply(event(8, "retire", 2))
|
||||
apply(event(9, "disposed", 2))
|
||||
if i.Owns(key, 24, 7) {
|
||||
t.Fatal("disposed owner retained")
|
||||
}
|
||||
if err := i.Apply(event(10, "room_ready", 3), nil); err == nil {
|
||||
t.Fatal("disposed run resurrected")
|
||||
}
|
||||
}
|
||||
func TestCompanionLifecycleRejectsStaleAndCollision(t *testing.T) {
|
||||
key := runKey()
|
||||
i, err := NewInstance(key, prepared(t), 1, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stale := key
|
||||
stale.LifecycleToken++
|
||||
if err := i.Apply(Event{Run: stale, Sequence: 1, Kind: "room_ready", RoomGeneration: 1}, nil); !errors.Is(err, ErrStaleRun) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := i.Apply(Event{Run: key, Sequence: 1, Kind: "room_ready", RoomGeneration: 1}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, n := range []uint16{0, 1, 400, 65535} {
|
||||
e := Event{Run: key, Sequence: 2, Kind: "native_created", RoomGeneration: 1, ObjectKey: n, ObjectGeneration: 1}
|
||||
if err := i.Apply(e, map[uint16]bool{400: true}); !errors.Is(err, ErrCollision) {
|
||||
t.Fatalf("key %d: %v", n, err)
|
||||
}
|
||||
}
|
||||
if i.Status().LastSequence != 1 {
|
||||
t.Fatal("rejected event advanced ledger")
|
||||
}
|
||||
if err := i.Apply(Event{Run: key, Sequence: 1, Kind: "skip"}, nil); !errors.Is(err, ErrTransition) {
|
||||
t.Fatal("sequence reused")
|
||||
}
|
||||
e := Event{Run: key, Sequence: 2, Kind: "native_created", RoomGeneration: 1, ObjectKey: 24, ObjectGeneration: 1}
|
||||
if err := i.Apply(e, nil); !errors.Is(err, ErrCollision) {
|
||||
t.Fatal("missing collision evidence accepted")
|
||||
}
|
||||
}
|
||||
func TestCompanionLifecycleConcurrentDuplicates(t *testing.T) {
|
||||
key := runKey()
|
||||
i, err := NewInstance(key, prepared(t), 1, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := Event{Run: key, Sequence: 1, Kind: "room_ready", RoomGeneration: 1}
|
||||
var wg sync.WaitGroup
|
||||
for n := 0; n < 32; n++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := i.Apply(e, nil); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
_ = i.Snapshot()
|
||||
_ = i.Status()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if i.Status().LastSequence != 1 || i.Status().Phase != Ready {
|
||||
t.Fatal(i.Status())
|
||||
}
|
||||
clone := i.Snapshot()
|
||||
clone.Equipment[0].ItemID = 12
|
||||
if i.Snapshot().Equipment[0].ItemID == 12 {
|
||||
t.Fatal("status query mutated run")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package companion
|
||||
|
||||
import "sync"
|
||||
|
||||
type RunKey struct {
|
||||
Instance string
|
||||
SessionGeneration uint64
|
||||
OwnerCharacterID string
|
||||
LifecycleToken uint64
|
||||
SpawnSequence uint64
|
||||
Nonce string
|
||||
}
|
||||
|
||||
func (k RunKey) valid() bool {
|
||||
return validIdentity(k.Instance) && k.SessionGeneration > 0 && validCharacterID(k.OwnerCharacterID) &&
|
||||
k.LifecycleToken > 0 && k.SpawnSequence > 0 && validHash(k.Nonce)
|
||||
}
|
||||
|
||||
type Phase string
|
||||
|
||||
const (
|
||||
Prepared Phase = "prepared"
|
||||
Ready Phase = "room_ready"
|
||||
Created Phase = "spawned_inactive"
|
||||
Bound Phase = "owner_bound"
|
||||
Active Phase = "active"
|
||||
Transitioning Phase = "transitioning"
|
||||
Dead Phase = "dead"
|
||||
Retiring Phase = "retiring"
|
||||
Disposed Phase = "disposed"
|
||||
Skipped Phase = "skipped"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
Run RunKey
|
||||
Sequence uint64
|
||||
Kind string
|
||||
RoomGeneration uint64
|
||||
ObjectKey uint16
|
||||
ObjectGeneration uint64
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
Run RunKey
|
||||
Phase Phase
|
||||
ConfigRevision uint64
|
||||
SnapshotHash string
|
||||
LastSequence uint64
|
||||
RoomGeneration uint64
|
||||
ObjectKey uint16
|
||||
ObjectGeneration uint64
|
||||
OwnerBound bool
|
||||
}
|
||||
|
||||
// Instance is an isolated coordinator, not a client message handler.
|
||||
// Callers must authenticate events and hold the real runtime/object admission
|
||||
// boundary. An object-confirmation event is never proof of native creation by
|
||||
// itself. No network endpoint or game callback currently invokes this type.
|
||||
type Instance struct {
|
||||
mu sync.Mutex
|
||||
status Status
|
||||
snapshot Snapshot
|
||||
last Event
|
||||
playerObjectKey uint16
|
||||
}
|
||||
|
||||
func NewInstance(key RunKey, snapshot Snapshot, configRevision uint64, playerObjectKey uint16) (*Instance, error) {
|
||||
if !key.valid() || playerObjectKey == 0 || playerObjectKey == 65535 {
|
||||
return nil, ErrStaleRun
|
||||
}
|
||||
if snapshot.SchemaVersion != 1 || snapshot.OwnerCharacterID != key.OwnerCharacterID ||
|
||||
!validCharacterID(snapshot.SourceCharacterID) || snapshot.SourceCharacterID == key.OwnerCharacterID ||
|
||||
snapshot.ProfileID == "" || !validHash(snapshot.ClientSHA256) || !validHash(snapshot.PVFSHA256) {
|
||||
return nil, ErrSnapshot
|
||||
}
|
||||
frozen := snapshot.Clone()
|
||||
return &Instance{status: Status{Run: key, Phase: Prepared, ConfigRevision: configRevision,
|
||||
SnapshotHash: frozen.Fingerprint()}, snapshot: frozen, playerObjectKey: playerObjectKey}, nil
|
||||
}
|
||||
|
||||
func (i *Instance) Status() Status { i.mu.Lock(); defer i.mu.Unlock(); return i.status }
|
||||
func (i *Instance) Snapshot() Snapshot { i.mu.Lock(); defer i.mu.Unlock(); return i.snapshot.Clone() }
|
||||
|
||||
// occupied contains other actors only, freshly inspected by the future bridge.
|
||||
// nil means collision evidence missing and rejects a first native creation.
|
||||
// Successful repeated messages are no-ops; a reused sequence with new content
|
||||
// is an error. No transition can create another APC or respawn a dead one.
|
||||
func (i *Instance) Apply(e Event, occupied map[uint16]bool) error {
|
||||
i.mu.Lock()
|
||||
defer i.mu.Unlock()
|
||||
s := i.status
|
||||
if e.Run != s.Run {
|
||||
return ErrStaleRun
|
||||
}
|
||||
if e.Sequence == 0 {
|
||||
return ErrTransition
|
||||
}
|
||||
if e.Sequence == s.LastSequence && e == i.last {
|
||||
return nil
|
||||
}
|
||||
if e.Sequence <= s.LastSequence {
|
||||
return ErrTransition
|
||||
}
|
||||
switch e.Kind {
|
||||
case "room_ready":
|
||||
if s.Phase != Prepared && s.Phase != Transitioning {
|
||||
return ErrTransition
|
||||
}
|
||||
if e.RoomGeneration <= s.RoomGeneration {
|
||||
return ErrRoom
|
||||
}
|
||||
s.RoomGeneration = e.RoomGeneration
|
||||
if s.Phase == Prepared {
|
||||
s.Phase = Ready
|
||||
} else {
|
||||
s.Phase = Active
|
||||
}
|
||||
case "native_created":
|
||||
if (s.Phase != Ready && s.Phase != Retiring) || e.RoomGeneration != s.RoomGeneration {
|
||||
return ErrRoom
|
||||
}
|
||||
// A native create may finish after cancellation. Track it for exact
|
||||
// cleanup, but never authorize it or replace an existing actor.
|
||||
if s.Phase == Retiring && s.ObjectKey != 0 {
|
||||
return ErrCollision
|
||||
}
|
||||
if occupied == nil || e.ObjectKey == 0 || e.ObjectKey == 65535 || e.ObjectKey == i.playerObjectKey || occupied[e.ObjectKey] || e.ObjectGeneration == 0 {
|
||||
return ErrCollision
|
||||
}
|
||||
s.ObjectKey = e.ObjectKey
|
||||
s.ObjectGeneration = e.ObjectGeneration
|
||||
if s.Phase == Ready {
|
||||
s.Phase = Created
|
||||
}
|
||||
case "owner_bound", "activate":
|
||||
if e.ObjectKey != s.ObjectKey || e.ObjectGeneration != s.ObjectGeneration || e.RoomGeneration != s.RoomGeneration {
|
||||
return ErrCollision
|
||||
}
|
||||
if e.Kind == "owner_bound" {
|
||||
if s.Phase != Created {
|
||||
return ErrTransition
|
||||
}
|
||||
s.Phase = Bound
|
||||
s.OwnerBound = true
|
||||
} else {
|
||||
if s.Phase != Bound {
|
||||
return ErrTransition
|
||||
}
|
||||
s.Phase = Active
|
||||
}
|
||||
case "room_leaving":
|
||||
if s.Phase != Active || e.RoomGeneration != s.RoomGeneration {
|
||||
return ErrTransition
|
||||
}
|
||||
s.Phase = Transitioning
|
||||
case "dead":
|
||||
if s.Phase != Active && s.Phase != Transitioning {
|
||||
return ErrTransition
|
||||
}
|
||||
if e.ObjectKey != s.ObjectKey || e.ObjectGeneration != s.ObjectGeneration {
|
||||
return ErrCollision
|
||||
}
|
||||
s.Phase = Dead
|
||||
case "retire":
|
||||
if s.Phase == Disposed || s.Phase == Skipped {
|
||||
return ErrTransition
|
||||
}
|
||||
s.Phase = Retiring
|
||||
case "disposed":
|
||||
if s.Phase != Retiring {
|
||||
return ErrTransition
|
||||
}
|
||||
// The bridge must also prove the native operation/callbacks drained;
|
||||
// a missing room manager is not proof that the actor was destroyed.
|
||||
if e.ObjectKey != s.ObjectKey || e.ObjectGeneration != s.ObjectGeneration {
|
||||
return ErrCollision
|
||||
}
|
||||
if e.RoomGeneration != s.RoomGeneration {
|
||||
return ErrRoom
|
||||
}
|
||||
s.Phase = Disposed
|
||||
s.OwnerBound = false
|
||||
case "skip":
|
||||
if s.Phase != Prepared && s.Phase != Ready {
|
||||
return ErrTransition
|
||||
}
|
||||
s.Phase = Skipped
|
||||
default:
|
||||
return ErrTransition
|
||||
}
|
||||
s.LastSequence = e.Sequence
|
||||
i.status = s
|
||||
i.last = e
|
||||
return nil
|
||||
}
|
||||
|
||||
// Retiring/dead owners remain valid for already-in-flight combat. The bridge
|
||||
// must validate targets and safely drain reports before reporting disposed.
|
||||
// Object generation is internal evidence; no existing game wire is changed.
|
||||
func (i *Instance) Owns(run RunKey, objectKey uint16, generation uint64) bool {
|
||||
i.mu.Lock()
|
||||
defer i.mu.Unlock()
|
||||
s := i.status
|
||||
return run == s.Run && s.OwnerBound && objectKey != 0 && objectKey == s.ObjectKey && generation == s.ObjectGeneration
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package companion
|
||||
|
||||
import "testing"
|
||||
|
||||
func retirementInstance(t *testing.T, withActor bool) (*Instance, RunKey) {
|
||||
t.Helper()
|
||||
key := runKey()
|
||||
i, err := NewInstance(key, prepared(t), 1, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := i.Apply(Event{Run: key, Sequence: 1, Kind: "room_ready", RoomGeneration: 1}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if withActor {
|
||||
for n, kind := range []string{"native_created", "owner_bound", "activate"} {
|
||||
if err := i.Apply(Event{Run: key, Sequence: uint64(n + 2), Kind: kind, RoomGeneration: 1, ObjectKey: 24, ObjectGeneration: 7}, map[uint16]bool{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
seq := i.Status().LastSequence + 1
|
||||
if err := i.Apply(Event{Run: key, Sequence: seq, Kind: "retire", RoomGeneration: 1}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return i, key
|
||||
}
|
||||
|
||||
func TestCompanionDisposedRequiresExactActorAndRoom(t *testing.T) {
|
||||
i, key := retirementInstance(t, true)
|
||||
before := i.Status()
|
||||
valid := Event{Run: key, Sequence: before.LastSequence + 1, Kind: "disposed", RoomGeneration: 1, ObjectKey: 24, ObjectGeneration: 7}
|
||||
for _, field := range []string{"key", "generation", "room", "empty"} {
|
||||
e := valid
|
||||
switch field {
|
||||
case "key":
|
||||
e.ObjectKey++
|
||||
case "generation":
|
||||
e.ObjectGeneration++
|
||||
case "room":
|
||||
e.RoomGeneration++
|
||||
case "empty":
|
||||
e.ObjectKey = 0
|
||||
e.ObjectGeneration = 0
|
||||
}
|
||||
if err := i.Apply(e, nil); err == nil {
|
||||
t.Fatalf("accepted mismatched %s disposal", field)
|
||||
}
|
||||
if i.Status() != before || !i.Owns(key, 24, 7) {
|
||||
t.Fatalf("invalid disposal revoked owner: %s", field)
|
||||
}
|
||||
}
|
||||
if err := i.Apply(valid, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if i.Owns(key, 24, 7) || i.Status().Phase != Disposed {
|
||||
t.Fatal("valid disposal did not close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompanionLateCreationTracksCleanupWithoutAuthorization(t *testing.T) {
|
||||
i, key := retirementInstance(t, false)
|
||||
e := Event{Run: key, Sequence: 3, Kind: "native_created", RoomGeneration: 1, ObjectKey: 24, ObjectGeneration: 7}
|
||||
if err := i.Apply(e, map[uint16]bool{}); err != nil {
|
||||
t.Fatal("late creation lost:", err)
|
||||
}
|
||||
if i.Status().Phase != Retiring || i.Owns(key, 24, 7) {
|
||||
t.Fatal("late creation resurrected cancelled run")
|
||||
}
|
||||
if err := i.Apply(e, map[uint16]bool{}); err != nil {
|
||||
t.Fatal("exact duplicate should be idempotent", err)
|
||||
}
|
||||
if err := i.Apply(Event{Run: key, Sequence: 4, Kind: "owner_bound", RoomGeneration: 1, ObjectKey: 24, ObjectGeneration: 7}, nil); err == nil {
|
||||
t.Fatal("late actor authorized")
|
||||
}
|
||||
if err := i.Apply(Event{Run: key, Sequence: 4, Kind: "disposed", RoomGeneration: 1, ObjectKey: 24, ObjectGeneration: 7}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if i.Status().Phase != Disposed {
|
||||
t.Fatal(i.Status())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompanionLateCreationCannotReplaceExistingActor(t *testing.T) {
|
||||
i, key := retirementInstance(t, true)
|
||||
before := i.Status()
|
||||
e := Event{Run: key, Sequence: before.LastSequence + 1, Kind: "native_created", RoomGeneration: 1, ObjectKey: 25, ObjectGeneration: 8}
|
||||
if err := i.Apply(e, map[uint16]bool{}); err == nil {
|
||||
t.Fatal("replaced retiring actor")
|
||||
}
|
||||
if i.Status() != before {
|
||||
t.Fatal("rejected late creation changed state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompanionCancellationWithoutActorRequiresEmptyDisposal(t *testing.T) {
|
||||
i, key := retirementInstance(t, false)
|
||||
before := i.Status()
|
||||
e := Event{Run: key, Sequence: 3, Kind: "disposed", RoomGeneration: 1, ObjectKey: 24, ObjectGeneration: 7}
|
||||
if err := i.Apply(e, nil); err == nil {
|
||||
t.Fatal("undisclosed actor disposal accepted")
|
||||
}
|
||||
if i.Status() != before {
|
||||
t.Fatal("bad disposal changed state")
|
||||
}
|
||||
e.ObjectKey = 0
|
||||
e.ObjectGeneration = 0
|
||||
if err := i.Apply(e, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if i.Status().Phase != Disposed {
|
||||
t.Fatal(i.Status())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
// 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...)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/equip"
|
||||
"longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
sourceCharacterSettingKey = "source_character_id"
|
||||
sourceCharactersSettingKey = "source_character_ids"
|
||||
)
|
||||
|
||||
func SettingsScope(ownerCharacterID string) (string, error) {
|
||||
ownerCharacterID = strings.TrimSpace(ownerCharacterID)
|
||||
if !validCharacterID(ownerCharacterID) {
|
||||
return "", ErrSelection
|
||||
}
|
||||
return "character:" + ownerCharacterID + ":companion-v1", nil
|
||||
}
|
||||
|
||||
func AccountSettingsScope(accountID string) (string, error) {
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
if !validIdentity(accountID) {
|
||||
return "", ErrSelection
|
||||
}
|
||||
return "account:" + accountID + ":companion-v1", nil
|
||||
}
|
||||
|
||||
func SourceCharacterIDFromSettings(
|
||||
record repository.SettingsRecord,
|
||||
ownerCharacterID string,
|
||||
) (string, bool, error) {
|
||||
cfg, err := ConfigFromSettings(record, ownerCharacterID)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if !cfg.Enabled {
|
||||
return "", false, nil
|
||||
}
|
||||
if cfg.SourceCharacterID == strings.TrimSpace(ownerCharacterID) {
|
||||
return "", false, ErrSelf
|
||||
}
|
||||
return cfg.SourceCharacterID, true, nil
|
||||
}
|
||||
|
||||
// VisualProjection is detached display data for one source character.
|
||||
// It contains template IDs only and never owns inventory rows, native pointers,
|
||||
// source cooldowns or mutable APC template state.
|
||||
type VisualProjection struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
AccountID string `json:"account_id"`
|
||||
OwnerCharacterID string `json:"owner_character_id"`
|
||||
SourceCharacterID string `json:"source_character_id"`
|
||||
Name string `json:"name"`
|
||||
Job uint8 `json:"job"`
|
||||
GrowType uint8 `json:"grow_type"`
|
||||
Level int `json:"level"`
|
||||
Weapon *VisualItem `json:"weapon,omitempty"`
|
||||
Avatars []VisualItem `json:"avatars"`
|
||||
}
|
||||
|
||||
type VisualItem struct {
|
||||
Slot int16 `json:"slot"`
|
||||
ItemID int64 `json:"item_id"`
|
||||
DisplayItemID int64 `json:"display_item_id"`
|
||||
EquipmentType string `json:"equipment_type,omitempty"`
|
||||
PVFPath string `json:"pvf_path,omitempty"`
|
||||
}
|
||||
|
||||
func BuildVisualProjection(
|
||||
source repository.CompanionSourceState,
|
||||
s Selection,
|
||||
now time.Time,
|
||||
) (VisualProjection, error) {
|
||||
if !validIdentity(s.AccountID) ||
|
||||
!validCharacterID(s.OwnerCharacterID) ||
|
||||
!validCharacterID(s.SourceCharacterID) {
|
||||
return VisualProjection{}, ErrSelection
|
||||
}
|
||||
if s.OwnerCharacterID == s.SourceCharacterID {
|
||||
return VisualProjection{}, ErrSelf
|
||||
}
|
||||
if source.Owner.CharacterID != s.OwnerCharacterID ||
|
||||
source.Source.CharacterID != s.SourceCharacterID ||
|
||||
source.Owner.Stats["delete_flag"] != 0 ||
|
||||
source.Source.Stats["delete_flag"] != 0 {
|
||||
return VisualProjection{}, ErrSource
|
||||
}
|
||||
if source.Owner.AccountID != s.AccountID ||
|
||||
source.Source.AccountID != s.AccountID {
|
||||
return VisualProjection{}, ErrAccount
|
||||
}
|
||||
if !s.OnlineStateKnown {
|
||||
return VisualProjection{}, ErrOnlineUnknown
|
||||
}
|
||||
if s.SourceOnline {
|
||||
return VisualProjection{}, ErrOnline
|
||||
}
|
||||
job, err := strconv.ParseUint(source.Source.Job, 10, 8)
|
||||
grow, hasGrow := source.Source.Stats["grow_type"]
|
||||
if err != nil || !hasGrow || grow < 0 || grow > 255 {
|
||||
return VisualProjection{}, ErrSnapshot
|
||||
}
|
||||
if now.IsZero() || source.Source.Level < 1 || source.Source.Level > 90 ||
|
||||
!validIdentity(source.Source.Name) || !utf8.ValidString(source.Source.Name) ||
|
||||
!source.HasEquipment ||
|
||||
source.Equipment.CharacterID != s.SourceCharacterID {
|
||||
return VisualProjection{}, ErrSnapshot
|
||||
}
|
||||
|
||||
out := VisualProjection{
|
||||
SchemaVersion: 1,
|
||||
AccountID: s.AccountID,
|
||||
OwnerCharacterID: s.OwnerCharacterID,
|
||||
SourceCharacterID: s.SourceCharacterID,
|
||||
Name: source.Source.Name,
|
||||
Job: uint8(job),
|
||||
GrowType: uint8(grow),
|
||||
Level: source.Source.Level,
|
||||
Avatars: []VisualItem{},
|
||||
}
|
||||
for _, entry := range source.Equipment.Entries {
|
||||
if entry.ItemID <= 0 || entry.ItemID > int64(^uint32(0)) {
|
||||
return VisualProjection{}, ErrSnapshot
|
||||
}
|
||||
if !entry.ExpireAt.IsZero() && !entry.ExpireAt.After(now) {
|
||||
continue
|
||||
}
|
||||
item := visualItem(entry)
|
||||
if visualAvatar(entry) {
|
||||
out.Avatars = append(out.Avatars, item)
|
||||
continue
|
||||
}
|
||||
// Freshly-created characters do not always persist item_kind or
|
||||
// current_exe_equipment_type, while pvf_path/equipment_type are already
|
||||
// authoritative enough to identify a worn weapon. Do not drop a real
|
||||
// weapon solely because those derived extras have not been populated yet.
|
||||
if visualWeapon(entry) {
|
||||
if out.Weapon != nil {
|
||||
return VisualProjection{}, ErrSnapshot
|
||||
}
|
||||
copy := item
|
||||
out.Weapon = ©
|
||||
}
|
||||
}
|
||||
sort.Slice(out.Avatars, func(i, j int) bool {
|
||||
if out.Avatars[i].Slot != out.Avatars[j].Slot {
|
||||
return out.Avatars[i].Slot < out.Avatars[j].Slot
|
||||
}
|
||||
return out.Avatars[i].DisplayItemID < out.Avatars[j].DisplayItemID
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// visualAvatar 判断一条装备记录是不是**时裝**(外观)。
|
||||
//
|
||||
// 不能只看 extra["item_kind"] == "avatar":本部署的 dnf90.db 里,所有装备记录的
|
||||
// item_kind 都是 "equipment",时裝是靠下面这些标记体现的 ——
|
||||
//
|
||||
// slot 0..10 且 current_exe_equipment_type 0..10 (本部署实测:角色 1 的 10 件时裝)
|
||||
// extra["avatar_ability_no"] / extra["avatar_duration_days"] 存在
|
||||
//
|
||||
// 旧实现只认 item_kind,导致**任何角色的时裝都被丢弃**,投影里 avatar_count 恒为 0,
|
||||
// 支援兵永远是光板外壳。这里改成按槽位/标记判定,并保留 item_kind 作为兜底。
|
||||
func visualAvatar(entry repository.EquipmentEntry) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(entry.Extra["item_kind"]), "avatar") {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(entry.Extra["avatar_ability_no"]) != "" ||
|
||||
strings.TrimSpace(entry.Extra["avatar_duration_days"]) != "" {
|
||||
return true
|
||||
}
|
||||
// 实测取证(女圣职者时装 415550013 等):GM 系统邮件发放并穿上后,条目里
|
||||
// 既没有 item_kind="avatar" 也没有 avatar_* 字段,只有
|
||||
// equipment_type = "[hat avatar]" ← 直接带 avatar 字样
|
||||
// current_exe_equipment_type = "0" ← 时裝槽 0..10
|
||||
// pvf_path = .../priest/at_avatar/cap/...
|
||||
// 所以把 equipment_type 也算作判定依据。
|
||||
if strings.Contains(strings.ToLower(strings.TrimSpace(entry.Extra["equipment_type"])), "avatar") {
|
||||
return true
|
||||
}
|
||||
// 时裝槽固定是 0..10;武器在 12,support/offhand 在 24,都不会落进这个区间。
|
||||
if entry.SlotIndex >= 0 && entry.SlotIndex <= 10 {
|
||||
if raw := strings.TrimSpace(entry.Extra["current_exe_equipment_type"]); raw != "" {
|
||||
if parsed, err := strconv.Atoi(raw); err == nil && parsed >= 0 && parsed <= 10 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func visualWeapon(entry repository.EquipmentEntry) bool {
|
||||
// Slot 24 is the stored support/offhand weapon (including Vagabond's
|
||||
// second sword). It is not a second primary display weapon. Keep it in
|
||||
// the detached equipment snapshot, but never reject a valid dual setup
|
||||
// or substitute it for slot 12's current weapon.
|
||||
if entry.SlotIndex == 24 {
|
||||
return false
|
||||
}
|
||||
path := strings.ToLower(strings.TrimSpace(entry.Extra["pvf_path"]))
|
||||
if strings.Contains(path, "/weapon/") {
|
||||
return true
|
||||
}
|
||||
return strings.TrimSpace(entry.Extra["current_exe_equipment_type"]) == "12" &&
|
||||
strings.TrimSpace(entry.Extra["equipment_type"]) == "17"
|
||||
}
|
||||
|
||||
func visualItem(entry repository.EquipmentEntry) VisualItem {
|
||||
displayID := entry.ItemID
|
||||
if raw := strings.TrimSpace(entry.Extra[equip.CloneAvatarDisplayItemIDExtraKey]); raw != "" {
|
||||
if parsed, err := strconv.ParseInt(raw, 10, 64); err == nil &&
|
||||
parsed > 0 && parsed <= int64(^uint32(0)) {
|
||||
displayID = parsed
|
||||
}
|
||||
}
|
||||
return VisualItem{
|
||||
Slot: entry.SlotIndex,
|
||||
ItemID: entry.ItemID,
|
||||
DisplayItemID: displayID,
|
||||
EquipmentType: strings.TrimSpace(entry.Extra["equipment_type"]),
|
||||
PVFPath: strings.TrimSpace(entry.Extra["pvf_path"]),
|
||||
}
|
||||
}
|
||||
|
||||
func (p VisualProjection) Clone() VisualProjection {
|
||||
if p.Weapon != nil {
|
||||
weapon := *p.Weapon
|
||||
p.Weapon = &weapon
|
||||
}
|
||||
if p.Avatars != nil {
|
||||
p.Avatars = append([]VisualItem(nil), p.Avatars...)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (p VisualProjection) Fingerprint() string {
|
||||
body, _ := json.Marshal(p)
|
||||
sum := sha256.Sum256(body)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/equip"
|
||||
"longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
func visualFixture(
|
||||
sourceID, name, job string,
|
||||
grow int64,
|
||||
weapon int64,
|
||||
avatars map[string]repository.EquipmentEntry,
|
||||
) (repository.CompanionSourceState, Selection) {
|
||||
entries := map[string]repository.EquipmentEntry{
|
||||
"weapon": {
|
||||
SlotIndex: 12,
|
||||
ItemID: weapon,
|
||||
RawEntry: []byte{1, 2, 3},
|
||||
Extra: map[string]string{
|
||||
"item_kind": "equipment",
|
||||
"equipment_type": "17",
|
||||
"current_exe_equipment_type": "12",
|
||||
"pvf_path": "equipment/character/test/weapon/test.equ",
|
||||
},
|
||||
},
|
||||
}
|
||||
for key, value := range avatars {
|
||||
entries[key] = value
|
||||
}
|
||||
state := repository.CompanionSourceState{
|
||||
Owner: repository.CharacterRecord{
|
||||
CharacterID: "1",
|
||||
AccountID: "account-test",
|
||||
Stats: map[string]int64{"delete_flag": 0},
|
||||
},
|
||||
Source: repository.CharacterRecord{
|
||||
CharacterID: sourceID,
|
||||
AccountID: "account-test",
|
||||
Name: name,
|
||||
Job: job,
|
||||
Level: 86,
|
||||
Stats: map[string]int64{"delete_flag": 0, "grow_type": grow},
|
||||
},
|
||||
Equipment: repository.EquipmentRecord{
|
||||
CharacterID: sourceID,
|
||||
Entries: entries,
|
||||
},
|
||||
HasEquipment: true,
|
||||
}
|
||||
selection := Selection{
|
||||
AccountID: "account-test",
|
||||
OwnerCharacterID: "1",
|
||||
SourceCharacterID: sourceID,
|
||||
OnlineStateKnown: true,
|
||||
}
|
||||
return state, selection
|
||||
}
|
||||
|
||||
func avatarFixture(slot int16, item int64, equipmentType string) repository.EquipmentEntry {
|
||||
return repository.EquipmentEntry{
|
||||
SlotIndex: slot,
|
||||
ItemID: item,
|
||||
RawEntry: []byte{4, 5, 6},
|
||||
Extra: map[string]string{
|
||||
"item_kind": "avatar",
|
||||
"equipment_type": equipmentType,
|
||||
"pvf_path": "equipment/character/test/avatar/test.equ",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisualProjectionSwitchesABAndDoesNotMutateSource(t *testing.T) {
|
||||
aState, aSelection := visualFixture(
|
||||
"2", "DETSCS", "0", 35, 101010912,
|
||||
map[string]repository.EquipmentEntry{
|
||||
"hat": avatarFixture(0, 401550018, "[hat avatar]"),
|
||||
"hair": avatarFixture(1, 101560869, "[hair avatar]"),
|
||||
},
|
||||
)
|
||||
bState, bSelection := visualFixture(
|
||||
"8", "DETSDT", "13", 3, 114000248,
|
||||
map[string]repository.EquipmentEntry{
|
||||
"weapon-avatar": avatarFixture(10, 414600011, "[weapon avatar]"),
|
||||
},
|
||||
)
|
||||
aBefore := repository.CompanionSourceState{
|
||||
Owner: repository.CloneCharacter(aState.Owner),
|
||||
Source: repository.CloneCharacter(aState.Source),
|
||||
Equipment: repository.CloneEquipment(aState.Equipment),
|
||||
HasEquipment: aState.HasEquipment,
|
||||
}
|
||||
bBefore := repository.CompanionSourceState{
|
||||
Owner: repository.CloneCharacter(bState.Owner),
|
||||
Source: repository.CloneCharacter(bState.Source),
|
||||
Equipment: repository.CloneEquipment(bState.Equipment),
|
||||
HasEquipment: bState.HasEquipment,
|
||||
}
|
||||
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
a, err := BuildVisualProjection(aState, aSelection, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := BuildVisualProjection(bState, bSelection, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if a.Name != "DETSCS" || b.Name != "DETSDT" ||
|
||||
a.Job != 0 || b.Job != 13 ||
|
||||
a.GrowType != 35 || b.GrowType != 3 {
|
||||
t.Fatalf("identity projection A=%+v B=%+v", a, b)
|
||||
}
|
||||
if a.Weapon == nil || b.Weapon == nil ||
|
||||
a.Weapon.ItemID != 101010912 ||
|
||||
b.Weapon.ItemID != 114000248 {
|
||||
t.Fatalf("weapon projection A=%+v B=%+v", a.Weapon, b.Weapon)
|
||||
}
|
||||
if len(a.Avatars) != 2 || len(b.Avatars) != 1 ||
|
||||
a.Avatars[0].DisplayItemID != 401550018 ||
|
||||
b.Avatars[0].DisplayItemID != 414600011 {
|
||||
t.Fatalf("avatar projection A=%+v B=%+v", a.Avatars, b.Avatars)
|
||||
}
|
||||
if a.Fingerprint() == b.Fingerprint() {
|
||||
t.Fatal("different source characters produced same visual fingerprint")
|
||||
}
|
||||
if !reflect.DeepEqual(aState.Owner, aBefore.Owner) ||
|
||||
!reflect.DeepEqual(aState.Source, aBefore.Source) ||
|
||||
!reflect.DeepEqual(aState.Equipment, aBefore.Equipment) ||
|
||||
!reflect.DeepEqual(bState.Owner, bBefore.Owner) ||
|
||||
!reflect.DeepEqual(bState.Source, bBefore.Source) ||
|
||||
!reflect.DeepEqual(bState.Equipment, bBefore.Equipment) {
|
||||
t.Fatal("BuildVisualProjection mutated source records")
|
||||
}
|
||||
|
||||
a.Weapon.ItemID = 1
|
||||
a.Avatars[0].ItemID = 2
|
||||
if aState.Equipment.Entries["weapon"].ItemID != 101010912 ||
|
||||
aState.Equipment.Entries["hat"].ItemID != 401550018 {
|
||||
t.Fatal("returned projection aliases source equipment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisualProjectionAcceptsFreshCharacterWeaponWithoutDerivedKind(t *testing.T) {
|
||||
state, selection := visualFixture(
|
||||
"2", "DETXX", "11", 0, 101010912, nil,
|
||||
)
|
||||
weapon := state.Equipment.Entries["weapon"]
|
||||
delete(weapon.Extra, "item_kind")
|
||||
delete(weapon.Extra, "current_exe_equipment_type")
|
||||
weapon.Extra["pvf_path"] = "equipment/character/swordman/weapon/katana/101010912.equ"
|
||||
weapon.Extra["equipment_type"] = "17"
|
||||
state.Equipment.Entries["weapon"] = weapon
|
||||
|
||||
got, err := BuildVisualProjection(
|
||||
state, selection, time.Unix(1_800_000_000, 0),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Weapon == nil || got.Weapon.ItemID != 101010912 {
|
||||
t.Fatalf("fresh-character weapon projection=%+v", got.Weapon)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisualProjectionUsesCloneAvatarDisplayID(t *testing.T) {
|
||||
clone := avatarFixture(3, 499999999, "[coat avatar]")
|
||||
clone.Extra[equip.CloneAvatarDisplayItemIDExtraKey] = "412500009"
|
||||
state, selection := visualFixture(
|
||||
"8", "DETSDT", "13", 3, 114000248,
|
||||
map[string]repository.EquipmentEntry{"coat": clone},
|
||||
)
|
||||
got, err := BuildVisualProjection(
|
||||
state, selection, time.Unix(1_800_000_000, 0),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Avatars) != 1 ||
|
||||
got.Avatars[0].ItemID != 499999999 ||
|
||||
got.Avatars[0].DisplayItemID != 412500009 {
|
||||
t.Fatalf("clone avatar projection=%+v", got.Avatars)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisualProjectionAcceptsPrimaryAndSupportWeapon(t *testing.T) {
|
||||
state, selection := visualFixture("13", "Vagabond", "11", 4, 28246, nil)
|
||||
secondary := state.Equipment.Entries["weapon"]
|
||||
secondary.SlotIndex, secondary.ItemID = 24, 28239
|
||||
state.Equipment.Entries["support"] = secondary
|
||||
before := repository.CloneEquipment(state.Equipment)
|
||||
got, err := BuildVisualProjection(state, selection, time.Unix(1_800_000_000, 0))
|
||||
if err != nil || got.Weapon == nil || got.Weapon.Slot != 12 || got.Weapon.ItemID != 28246 {
|
||||
t.Fatalf("valid dual weapons rejected or primary changed: %+v err=%v", got, err)
|
||||
}
|
||||
if !reflect.DeepEqual(before, state.Equipment) {
|
||||
t.Fatal("display projection changed source equipment")
|
||||
}
|
||||
delete(state.Equipment.Entries, "weapon")
|
||||
got, err = BuildVisualProjection(state, selection, time.Unix(1_800_000_000, 0))
|
||||
if err != nil || got.Weapon != nil {
|
||||
t.Fatalf("support weapon substituted for missing primary: %+v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisualProjectionRejectsDuplicateWeapons(t *testing.T) {
|
||||
state, selection := visualFixture(
|
||||
"2", "DETSCS", "0", 35, 101010912, nil,
|
||||
)
|
||||
state.Equipment.Entries["weapon2"] = repository.EquipmentEntry{
|
||||
SlotIndex: 20,
|
||||
ItemID: 101010913,
|
||||
Extra: map[string]string{
|
||||
"item_kind": "equipment",
|
||||
"pvf_path": "equipment/character/test/weapon/other.equ",
|
||||
},
|
||||
}
|
||||
if _, err := BuildVisualProjection(
|
||||
state, selection, time.Unix(1_800_000_000, 0),
|
||||
); err != ErrSnapshot {
|
||||
t.Fatalf("duplicate weapon err=%v want=%v", err, ErrSnapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectionSettingsSelectExactDifferentCharacter(t *testing.T) {
|
||||
scope, err := SettingsScope("3")
|
||||
if err != nil || scope != "character:3:companion-v1" {
|
||||
t.Fatalf("scope=%q err=%v", scope, err)
|
||||
}
|
||||
record := repository.SettingsRecord{
|
||||
Scope: scope,
|
||||
Values: map[string]string{
|
||||
"source_character_id": "2",
|
||||
},
|
||||
}
|
||||
source, found, err := SourceCharacterIDFromSettings(record, "3")
|
||||
if err != nil || !found || source != "2" {
|
||||
t.Fatalf("source=%q found=%t err=%v", source, found, err)
|
||||
}
|
||||
record.Values["source_character_id"] = "8"
|
||||
source, found, err = SourceCharacterIDFromSettings(record, "3")
|
||||
if err != nil || !found || source != "8" {
|
||||
t.Fatalf("source=%q found=%t err=%v", source, found, err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
record repository.SettingsRecord
|
||||
owner string
|
||||
want error
|
||||
}{
|
||||
{"self", repository.SettingsRecord{Scope: scope, Values: map[string]string{"source_character_id": "3"}}, "3", ErrSelf},
|
||||
{"leading zero", repository.SettingsRecord{Scope: scope, Values: map[string]string{"source_character_id": "02"}}, "3", ErrSelection},
|
||||
{"wrong scope", repository.SettingsRecord{Scope: "character:4:companion-v1", Values: map[string]string{"source_character_id": "2"}}, "3", ErrSelection},
|
||||
} {
|
||||
if _, _, got := SourceCharacterIDFromSettings(tc.record, tc.owner); got != tc.want {
|
||||
t.Fatalf("%s err=%v want=%v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
configSchemaKey = "schema_version"
|
||||
configRevisionKey = "revision"
|
||||
configEnabledKey = "enabled"
|
||||
configBalanceKey = "balance_enabled"
|
||||
configProgressionKey = "progression_enabled"
|
||||
configRandomEquipmentKey = "random_equipment_enabled"
|
||||
configAISchemaKey = "ai_schema_version"
|
||||
configAIDefaultsKey = "ai_defaults"
|
||||
configAIProfilesKey = "ai_profiles"
|
||||
)
|
||||
|
||||
func ConfigFromSettings(
|
||||
record repository.SettingsRecord,
|
||||
ownerCharacterID string,
|
||||
) (Config, error) {
|
||||
scope, err := SettingsScope(ownerCharacterID)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return configFromSettingsScope(record, scope)
|
||||
}
|
||||
|
||||
func ConfigFromAccountSettings(
|
||||
record repository.SettingsRecord,
|
||||
accountID string,
|
||||
) (Config, error) {
|
||||
scope, err := AccountSettingsScope(accountID)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return configFromSettingsScope(record, scope)
|
||||
}
|
||||
|
||||
func configFromSettingsScope(
|
||||
record repository.SettingsRecord,
|
||||
expectedScope string,
|
||||
) (Config, error) {
|
||||
if strings.TrimSpace(record.Scope) != expectedScope {
|
||||
return Config{}, ErrSelection
|
||||
}
|
||||
values := record.Values
|
||||
if values == nil {
|
||||
return DefaultConfig(), nil
|
||||
}
|
||||
|
||||
source := strings.TrimSpace(values[sourceCharacterSettingKey])
|
||||
sourcesRaw, hasSources := values[sourceCharactersSettingKey]
|
||||
_, hasSchema := values[configSchemaKey]
|
||||
_, hasRevision := values[configRevisionKey]
|
||||
_, hasEnabled := values[configEnabledKey]
|
||||
|
||||
// Phase-1 rows stored only source_character_id. Keep them readable so the
|
||||
// phase-2 account-scope migration does not invalidate accepted setups.
|
||||
if !hasSchema && !hasRevision && !hasEnabled {
|
||||
if source == "" {
|
||||
return DefaultConfig(), nil
|
||||
}
|
||||
cfg := Config{
|
||||
SchemaVersion: 1,
|
||||
Revision: 0,
|
||||
Enabled: true,
|
||||
SourceCharacterID: source,
|
||||
SourceCharacterIDs: []string{source},
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
schema, err := parseCanonicalUint(values[configSchemaKey], 32)
|
||||
if err != nil || schema != 1 {
|
||||
return Config{}, ErrSelection
|
||||
}
|
||||
revision, err := parseCanonicalUint(values[configRevisionKey], 64)
|
||||
if err != nil {
|
||||
return Config{}, ErrSelection
|
||||
}
|
||||
enabled, err := strconv.ParseBool(strings.TrimSpace(values[configEnabledKey]))
|
||||
if err != nil {
|
||||
return Config{}, ErrSelection
|
||||
}
|
||||
sources := []string{}
|
||||
if hasSources {
|
||||
if strings.TrimSpace(sourcesRaw) != sourcesRaw {
|
||||
return Config{}, ErrSelection
|
||||
}
|
||||
if sourcesRaw != "" {
|
||||
var err error
|
||||
sources, err = canonicalSourceCharacters(strings.Split(sourcesRaw, ","))
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
}
|
||||
} else if source != "" {
|
||||
sources = []string{source}
|
||||
}
|
||||
if len(sources) != 0 {
|
||||
if source == "" {
|
||||
source = sources[0]
|
||||
} else if source != sources[0] {
|
||||
return Config{}, ErrSelection
|
||||
}
|
||||
}
|
||||
balanceEnabled := false
|
||||
if raw, exists := values[configBalanceKey]; exists {
|
||||
balanceEnabled, err = strconv.ParseBool(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return Config{}, ErrSelection
|
||||
}
|
||||
}
|
||||
progressionEnabled := false
|
||||
if raw, exists := values[configProgressionKey]; exists {
|
||||
progressionEnabled, err = strconv.ParseBool(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return Config{}, ErrSelection
|
||||
}
|
||||
}
|
||||
randomEquipmentEnabled := false
|
||||
if raw, exists := values[configRandomEquipmentKey]; exists {
|
||||
randomEquipmentEnabled, err = strconv.ParseBool(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return Config{}, ErrSelection
|
||||
}
|
||||
}
|
||||
// Old rows may still contain the former distance/timing ai_* keys. They
|
||||
// are intentionally ignored; those values never had a verified action
|
||||
// owner. Schema 2 stores only the high-level profile and its bounded tuning
|
||||
// object, never raw Lua/AIC instructions.
|
||||
ai := AIConfig{}
|
||||
aiSchemaV2 := false
|
||||
if raw, exists := values[configAISchemaKey]; exists && strings.TrimSpace(raw) != "2" {
|
||||
return Config{}, ErrSelection
|
||||
} else if exists {
|
||||
aiSchemaV2 = true
|
||||
}
|
||||
if raw, exists := values[configAIDefaultsKey]; exists {
|
||||
if strings.TrimSpace(raw) == "" || json.Unmarshal([]byte(raw), &ai) != nil {
|
||||
return Config{}, ErrSelection
|
||||
}
|
||||
if err := ai.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
ai = NormalizeAIConfig(ai)
|
||||
}
|
||||
profiles := map[string]AIProfile(nil)
|
||||
if raw, exists := values[configAIProfilesKey]; exists {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return Config{}, ErrSelection
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &profiles); err != nil || profiles == nil {
|
||||
return Config{}, ErrSelection
|
||||
}
|
||||
for characterID, profile := range profiles {
|
||||
if !validCharacterID(characterID) {
|
||||
return Config{}, ErrSelection
|
||||
}
|
||||
profile = NormalizeAIProfile(profile)
|
||||
if !aiSchemaV2 {
|
||||
// Old profiles had no protection field; preserve the new safe
|
||||
// default when they are first read.
|
||||
profile.ProtectLeader = true
|
||||
}
|
||||
profiles[characterID] = profile
|
||||
if err := profile.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
cfg := Config{
|
||||
SchemaVersion: int(schema),
|
||||
Revision: revision,
|
||||
Enabled: enabled,
|
||||
BalanceEnabled: balanceEnabled,
|
||||
ProgressionEnabled: progressionEnabled,
|
||||
RandomEquipmentEnabled: randomEquipmentEnabled,
|
||||
AI: ai,
|
||||
AIProfiles: profiles,
|
||||
SourceCharacterID: source,
|
||||
SourceCharacterIDs: sources,
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func ConfigSettingsRecord(
|
||||
ownerCharacterID string,
|
||||
cfg Config,
|
||||
now time.Time,
|
||||
) (repository.SettingsRecord, error) {
|
||||
scope, err := SettingsScope(ownerCharacterID)
|
||||
if err != nil {
|
||||
return repository.SettingsRecord{}, err
|
||||
}
|
||||
return configSettingsRecordScope(scope, cfg, now)
|
||||
}
|
||||
|
||||
func AccountConfigSettingsRecord(
|
||||
accountID string,
|
||||
cfg Config,
|
||||
now time.Time,
|
||||
) (repository.SettingsRecord, error) {
|
||||
scope, err := AccountSettingsScope(accountID)
|
||||
if err != nil {
|
||||
return repository.SettingsRecord{}, err
|
||||
}
|
||||
return configSettingsRecordScope(scope, cfg, now)
|
||||
}
|
||||
|
||||
func configSettingsRecordScope(
|
||||
scope string,
|
||||
cfg Config,
|
||||
now time.Time,
|
||||
) (repository.SettingsRecord, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return repository.SettingsRecord{}, err
|
||||
}
|
||||
if now.IsZero() {
|
||||
return repository.SettingsRecord{}, ErrSelection
|
||||
}
|
||||
values := map[string]string{
|
||||
configSchemaKey: strconv.Itoa(cfg.SchemaVersion),
|
||||
configRevisionKey: strconv.FormatUint(cfg.Revision, 10),
|
||||
configEnabledKey: strconv.FormatBool(cfg.Enabled),
|
||||
configBalanceKey: strconv.FormatBool(cfg.BalanceEnabled),
|
||||
configProgressionKey: strconv.FormatBool(cfg.ProgressionEnabled),
|
||||
configRandomEquipmentKey: strconv.FormatBool(cfg.RandomEquipmentEnabled),
|
||||
}
|
||||
if !cfg.AI.IsZero() {
|
||||
values[configAISchemaKey] = "2"
|
||||
encoded, err := json.Marshal(NormalizeAIConfig(cfg.AI))
|
||||
if err != nil {
|
||||
return repository.SettingsRecord{}, ErrSelection
|
||||
}
|
||||
values[configAIDefaultsKey] = string(encoded)
|
||||
}
|
||||
if cfg.AIProfiles != nil {
|
||||
values[configAISchemaKey] = "2"
|
||||
profiles := cloneAIProfiles(cfg.AIProfiles)
|
||||
encoded, err := json.Marshal(profiles)
|
||||
if err != nil {
|
||||
return repository.SettingsRecord{}, ErrSelection
|
||||
}
|
||||
values[configAIProfilesKey] = string(encoded)
|
||||
}
|
||||
sources := cfg.Sources()
|
||||
if len(sources) != 0 {
|
||||
values[sourceCharacterSettingKey] = sources[0]
|
||||
values[sourceCharactersSettingKey] = strings.Join(sources, ",")
|
||||
}
|
||||
return repository.SettingsRecord{
|
||||
Scope: scope,
|
||||
Values: values,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseCanonicalUint(value string, bits int) (uint64, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0, ErrSelection
|
||||
}
|
||||
parsed, err := strconv.ParseUint(value, 10, bits)
|
||||
if err != nil || strconv.FormatUint(parsed, 10) != value {
|
||||
return 0, ErrSelection
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
func TestDefaultAIConfigIsProductConfig(t *testing.T) {
|
||||
got := DefaultAIConfig()
|
||||
want := AIConfig{BuffPolicy: "leader_first", ComboPreset: "auto", ComboEnabled: true, AOEMinTargets: 3, ProtectLeader: true}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("default AI=%+v want=%+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIConfigRejectsInvalidProductValues(t *testing.T) {
|
||||
ai := DefaultAIConfig()
|
||||
for name, edit := range map[string]func(*AIConfig){
|
||||
"buff_policy": func(v *AIConfig) { v.BuffPolicy = "unknown" },
|
||||
"aoe_low": func(v *AIConfig) { v.AOEMinTargets = 1 },
|
||||
"aoe_high": func(v *AIConfig) { v.AOEMinTargets = 7 },
|
||||
"preset": func(v *AIConfig) { v.ComboPreset = strings.Repeat("x", 65) },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
value := ai
|
||||
edit(&value)
|
||||
if err := value.Validate(); err != ErrSelection {
|
||||
t.Fatalf("validate=%v want=%v", err, ErrSelection)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIConfigSettingsRoundTrip(t *testing.T) {
|
||||
want := Config{SchemaVersion: 1, Revision: 3, Enabled: true, SourceCharacterID: "8", SourceCharacterIDs: []string{"8"}, AI: DefaultAIConfig(), AIProfiles: map[string]AIProfile{
|
||||
"8": {Behavior: "aggressive", ClassID: 9, BuffPolicy: "leader_first", ComboPreset: "sword_guard", ComboEnabled: true, AOEMinTargets: 3, ProtectLeader: true},
|
||||
}}
|
||||
record, err := AccountConfigSettingsRecord("ai-round-trip", want, time.Date(2026, 10, 3, 5, 0, 0, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.Values["ai_schema_version"] != "2" || record.Values["ai_defaults"] == "" {
|
||||
t.Fatalf("product AI fields missing: %+v", record.Values)
|
||||
}
|
||||
for key := range record.Values {
|
||||
if strings.HasPrefix(key, "ai_") && key != "ai_schema_version" && key != "ai_defaults" && key != "ai_profiles" {
|
||||
t.Fatalf("legacy AI field was written: %q", key)
|
||||
}
|
||||
}
|
||||
got, err := ConfigFromAccountSettings(record, "ai-round-trip")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("round trip=%+v want=%+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAITuningSettingsRoundTrip(t *testing.T) {
|
||||
tuning := &AITuning{ThinkIntervalMS: 200, FollowDistance: 60, HealLeaderPercent: 70, StuckMS: 900, DetourMS: 1100, GiveUpMS: 4000}
|
||||
want := Config{
|
||||
SchemaVersion: 1, Revision: 4, Enabled: true,
|
||||
SourceCharacterID: "8", SourceCharacterIDs: []string{"8"},
|
||||
AIProfiles: map[string]AIProfile{"8": {
|
||||
Behavior: "assist", ClassID: 9, BuffPolicy: AIBuffPolicyLeaderFirst,
|
||||
ComboPreset: "priest_support", ComboEnabled: false, AOEMinTargets: 3,
|
||||
ProtectLeader: true, Tuning: tuning,
|
||||
}},
|
||||
}
|
||||
record, err := AccountConfigSettingsRecord("ai-tuning", want, time.Date(2026, 10, 4, 5, 0, 0, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ConfigFromAccountSettings(record, "ai-tuning")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("tuning round trip=%+v want=%+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyAIInternalFieldsAreIgnoredAndCustomMigrates(t *testing.T) {
|
||||
record := repository.SettingsRecord{
|
||||
Scope: "account:ai-legacy:companion-v1",
|
||||
Values: map[string]string{
|
||||
"schema_version": "1", "revision": "2", "enabled": "true",
|
||||
"source_character_id": "8", "source_character_ids": "8",
|
||||
"ai_tempo": "custom", "ai_leader_leash_x": "999", "ai_give_up_ms": "20000",
|
||||
"ai_profiles": `{"8":{"behavior":"custom","class_id":9,"combo_preset":"auto","combo_enabled":true}}`,
|
||||
},
|
||||
}
|
||||
cfg, err := ConfigFromAccountSettings(record, "ai-legacy")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profile := cfg.AIProfiles["8"]
|
||||
if profile.Behavior != "assist" || profile.BuffPolicy != AIBuffPolicyLeaderFirst || profile.AOEMinTargets != 3 || !profile.ProtectLeader {
|
||||
t.Fatalf("legacy profile was not normalized: %+v", profile)
|
||||
}
|
||||
if !cfg.AI.IsZero() {
|
||||
t.Fatalf("legacy internal scalar fields were retained: %+v", cfg.AI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIProfileRejectsInvalidModeOrPreset(t *testing.T) {
|
||||
for name, profile := range map[string]AIProfile{
|
||||
"mode": {Behavior: "wander", ComboPreset: "auto"},
|
||||
"preset": {Behavior: "assist", ComboPreset: strings.Repeat("x", 65)},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := profile.Validate(); err != ErrSelection {
|
||||
t.Fatalf("validate=%v want=%v", err, ErrSelection)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFromSettingsLegacySourceRow(t *testing.T) {
|
||||
record := repository.SettingsRecord{
|
||||
Scope: "character:3:companion-v1",
|
||||
Values: map[string]string{"source_character_id": "2"},
|
||||
}
|
||||
cfg, err := ConfigFromSettings(record, "3")
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigFromSettings legacy: %v", err)
|
||||
}
|
||||
if !cfg.Enabled || cfg.Revision != 0 || cfg.SourceCharacterID != "2" {
|
||||
t.Fatalf("legacy cfg = %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSettingsRecordRoundTrip(t *testing.T) {
|
||||
now := time.Date(2026, 9, 25, 5, 0, 0, 0, time.UTC)
|
||||
want := Config{SchemaVersion: 1, Revision: 7, Enabled: true, BalanceEnabled: true, RandomEquipmentEnabled: true, SourceCharacterID: "8", SourceCharacterIDs: []string{"8"}}
|
||||
record, err := ConfigSettingsRecord("3", want, now)
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigSettingsRecord: %v", err)
|
||||
}
|
||||
if !record.UpdatedAt.Equal(now) {
|
||||
t.Fatalf("updated_at = %v want %v", record.UpdatedAt, now)
|
||||
}
|
||||
got, err := ConfigFromSettings(record, "3")
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigFromSettings: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("round trip = %+v want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFromSettingsRejectsNonCanonicalRevision(t *testing.T) {
|
||||
record := repository.SettingsRecord{
|
||||
Scope: "character:3:companion-v1",
|
||||
Values: map[string]string{
|
||||
"schema_version": "1",
|
||||
"revision": "007",
|
||||
"enabled": "true",
|
||||
"source_character_id": "2",
|
||||
},
|
||||
}
|
||||
if _, err := ConfigFromSettings(record, "3"); err != ErrSelection {
|
||||
t.Fatalf("err = %v want %v", err, ErrSelection)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceCharacterIDFromSettingsDisabled(t *testing.T) {
|
||||
record := repository.SettingsRecord{
|
||||
Scope: "character:3:companion-v1",
|
||||
Values: map[string]string{
|
||||
"schema_version": "1",
|
||||
"revision": "2",
|
||||
"enabled": "false",
|
||||
"source_character_id": "8",
|
||||
},
|
||||
}
|
||||
source, selected, err := SourceCharacterIDFromSettings(record, "3")
|
||||
if err != nil {
|
||||
t.Fatalf("SourceCharacterIDFromSettings: %v", err)
|
||||
}
|
||||
if selected || source != "" {
|
||||
t.Fatalf("disabled source = %q selected=%v", source, selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextConfigPersistsBalanceSwitch(t *testing.T) {
|
||||
current := Config{
|
||||
SchemaVersion: 1,
|
||||
Revision: 4,
|
||||
Enabled: true,
|
||||
BalanceEnabled: false,
|
||||
SourceCharacterID: "2",
|
||||
}
|
||||
next, err := NextConfig(current, 4, true, "2", "3", nil, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if next.Revision != 5 || !next.Enabled || !next.BalanceEnabled ||
|
||||
next.SourceCharacterID != "2" {
|
||||
t.Fatalf("next=%+v", next)
|
||||
}
|
||||
|
||||
original, err := NextConfig(next, 5, true, "2", "3", nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if original.BalanceEnabled {
|
||||
t.Fatalf("balance switch did not turn off: %+v", original)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextConfigPersistsProgressionSwitchDefaultOff(t *testing.T) {
|
||||
current := Config{
|
||||
SchemaVersion: 1, Revision: 4, Enabled: true,
|
||||
SourceCharacterID: "2", SourceCharacterIDs: []string{"2"},
|
||||
}
|
||||
next, err := NextConfigMany(current, 4, true, []string{"2"}, "3", false, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !next.ProgressionEnabled || next.BalanceEnabled {
|
||||
t.Fatalf("next=%+v", next)
|
||||
}
|
||||
record, err := AccountConfigSettingsRecord("account-progression", next, time.Date(2026, 9, 25, 5, 0, 0, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.Values[configProgressionKey] != "true" {
|
||||
t.Fatalf("stored progression switch=%q", record.Values[configProgressionKey])
|
||||
}
|
||||
legacy := repository.SettingsRecord{
|
||||
Scope: "account:account-progression:companion-v1",
|
||||
Values: map[string]string{
|
||||
"schema_version": "1", "revision": "1", "enabled": "true",
|
||||
"source_character_id": "2",
|
||||
},
|
||||
}
|
||||
legacyCfg, err := ConfigFromAccountSettings(legacy, "account-progression")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if legacyCfg.ProgressionEnabled {
|
||||
t.Fatalf("legacy config unexpectedly enabled progression: %+v", legacyCfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextConfigPersistsRandomEquipmentSwitchDefaultOff(t *testing.T) {
|
||||
current := Config{
|
||||
SchemaVersion: 1, Revision: 4, Enabled: true,
|
||||
SourceCharacterID: "2", SourceCharacterIDs: []string{"2"},
|
||||
}
|
||||
next, err := NextConfigMany(current, 4, true, []string{"2"}, "3", false, false, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !next.RandomEquipmentEnabled || next.BalanceEnabled || next.ProgressionEnabled {
|
||||
t.Fatalf("next=%+v", next)
|
||||
}
|
||||
record, err := AccountConfigSettingsRecord("account-random-equipment", next, time.Date(2026, 9, 25, 5, 0, 0, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.Values[configRandomEquipmentKey] != "true" {
|
||||
t.Fatalf("stored random equipment switch=%q", record.Values[configRandomEquipmentKey])
|
||||
}
|
||||
legacy := repository.SettingsRecord{
|
||||
Scope: "account:account-random-equipment:companion-v1",
|
||||
Values: map[string]string{
|
||||
"schema_version": "1", "revision": "1", "enabled": "true",
|
||||
"source_character_id": "2",
|
||||
},
|
||||
}
|
||||
legacyCfg, err := ConfigFromAccountSettings(legacy, "account-random-equipment")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if legacyCfg.RandomEquipmentEnabled {
|
||||
t.Fatalf("legacy config unexpectedly enabled random equipment: %+v", legacyCfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountConfigSettingsRoundTrip(t *testing.T) {
|
||||
now := time.Date(2026, 9, 25, 15, 0, 0, 0, time.FixedZone("CST", 8*60*60))
|
||||
cfg := Config{
|
||||
SchemaVersion: 1,
|
||||
Revision: 7,
|
||||
Enabled: true,
|
||||
BalanceEnabled: true,
|
||||
RandomEquipmentEnabled: true,
|
||||
SourceCharacterID: "11",
|
||||
SourceCharacterIDs: []string{"11"},
|
||||
}
|
||||
record, err := AccountConfigSettingsRecord("account-projection", cfg, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.Scope != "account:account-projection:companion-v1" {
|
||||
t.Fatalf("scope=%q", record.Scope)
|
||||
}
|
||||
got, err := ConfigFromAccountSettings(record, "account-projection")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, cfg) {
|
||||
t.Fatalf("got=%+v want=%+v", got, cfg)
|
||||
}
|
||||
if _, err := ConfigFromAccountSettings(record, "other-account"); err != ErrSelection {
|
||||
t.Fatalf("wrong-account scope err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountConfigSettingsMultiSourceRoundTrip(t *testing.T) {
|
||||
now := time.Date(2026, 9, 27, 2, 0, 0, 0, time.FixedZone("CST", 8*60*60))
|
||||
cfg := Config{
|
||||
SchemaVersion: 1, Revision: 9, Enabled: true,
|
||||
SourceCharacterID: "2",
|
||||
SourceCharacterIDs: []string{"2", "8", "11"},
|
||||
}
|
||||
record, err := AccountConfigSettingsRecord("account-multi", cfg, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := record.Values[sourceCharactersSettingKey]; got != "2,8,11" {
|
||||
t.Fatalf("stored sources=%q", got)
|
||||
}
|
||||
got, err := ConfigFromAccountSettings(record, "account-multi")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, cfg) {
|
||||
t.Fatalf("got=%+v want=%+v", got, cfg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
// Profile is developer-owned evidence, never a client-submitted capability.
|
||||
type Profile struct {
|
||||
ID string
|
||||
Version uint32
|
||||
EvidenceID string
|
||||
ClientSHA256 string
|
||||
PVFSHA256 string
|
||||
Job uint8
|
||||
GrowType uint8
|
||||
BasicAttackVerified bool
|
||||
SkillLevelLimits map[int64]int
|
||||
}
|
||||
|
||||
// There are no accepted native profiles yet. Tests use synthetic profiles
|
||||
// only to exercise policies; those profiles are not shipped in this registry.
|
||||
func VerifiedProfiles() []Profile { return nil }
|
||||
|
||||
func validHash(value string) bool {
|
||||
decoded, err := hex.DecodeString(value)
|
||||
return err == nil && len(decoded) == 32 && hex.EncodeToString(decoded) == value
|
||||
}
|
||||
|
||||
func (p Profile) validate(client, pvf string) error {
|
||||
if !validIdentity(p.ID) || p.Version == 0 || !validIdentity(p.EvidenceID) || !p.BasicAttackVerified {
|
||||
return ErrProfile
|
||||
}
|
||||
if !validHash(client) || !validHash(pvf) || client != p.ClientSHA256 || pvf != p.PVFSHA256 {
|
||||
return ErrAssets
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Selection struct {
|
||||
AccountID string
|
||||
OwnerCharacterID string
|
||||
SourceCharacterID string
|
||||
OnlineStateKnown bool
|
||||
SourceOnline bool
|
||||
ClientSHA256 string
|
||||
PVFSHA256 string
|
||||
}
|
||||
|
||||
type EquipmentSpec struct {
|
||||
Slot int16 `json:"slot"`
|
||||
ItemID int64 `json:"item_id"`
|
||||
}
|
||||
type SkillSpec struct {
|
||||
ID int64 `json:"id"`
|
||||
Level int `json:"level"`
|
||||
}
|
||||
type Exclusion struct {
|
||||
Kind string `json:"kind"`
|
||||
ID int64 `json:"id,omitempty"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// Snapshot contains declarative template IDs, not inventory serials, raw
|
||||
// item-instance bytes, live pointers, source cooldowns, wallets or quests.
|
||||
// It is NOT the native ABI or a claim of gear/stat projection parity.
|
||||
type Snapshot struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
AccountID string `json:"account_id"`
|
||||
OwnerCharacterID string `json:"owner_character_id"`
|
||||
SourceCharacterID string `json:"source_character_id"`
|
||||
Name string `json:"name"`
|
||||
Job uint8 `json:"job"`
|
||||
GrowType uint8 `json:"grow_type"`
|
||||
Level int `json:"level"`
|
||||
ProfileID string `json:"profile_id"`
|
||||
ProfileVersion uint32 `json:"profile_version"`
|
||||
ClientSHA256 string `json:"client_sha256"`
|
||||
PVFSHA256 string `json:"pvf_sha256"`
|
||||
Equipment []EquipmentSpec `json:"equipment"`
|
||||
Skills []SkillSpec `json:"skills"`
|
||||
Exclusions []Exclusion `json:"exclusions"`
|
||||
}
|
||||
|
||||
// Prepare consumes a single transaction's detached source state. The caller
|
||||
// must keep the source offline check valid through preparation/run binding.
|
||||
// It does not load or save repositories and cannot enable a native adapter.
|
||||
func Prepare(source repository.CompanionSourceState, s Selection, p Profile, now time.Time) (Snapshot, error) {
|
||||
if !validIdentity(s.AccountID) || !validCharacterID(s.OwnerCharacterID) || !validCharacterID(s.SourceCharacterID) {
|
||||
return Snapshot{}, ErrSelection
|
||||
}
|
||||
if s.OwnerCharacterID == s.SourceCharacterID {
|
||||
return Snapshot{}, ErrSelf
|
||||
}
|
||||
if source.Owner.CharacterID != s.OwnerCharacterID || source.Source.CharacterID != s.SourceCharacterID ||
|
||||
source.Owner.Stats["delete_flag"] != 0 || source.Source.Stats["delete_flag"] != 0 {
|
||||
return Snapshot{}, ErrSource
|
||||
}
|
||||
if source.Owner.AccountID != s.AccountID || source.Source.AccountID != s.AccountID {
|
||||
return Snapshot{}, ErrAccount
|
||||
}
|
||||
if !s.OnlineStateKnown {
|
||||
return Snapshot{}, ErrOnlineUnknown
|
||||
}
|
||||
if s.SourceOnline {
|
||||
return Snapshot{}, ErrOnline
|
||||
}
|
||||
if err := p.validate(s.ClientSHA256, s.PVFSHA256); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
job, err := strconv.ParseUint(source.Source.Job, 10, 8)
|
||||
grow, hasGrow := source.Source.Stats["grow_type"]
|
||||
if err != nil || !hasGrow || grow < 0 || grow > 255 || uint8(job) != p.Job || uint8(grow) != p.GrowType {
|
||||
return Snapshot{}, ErrProfile
|
||||
}
|
||||
if now.IsZero() || source.Source.Level < 1 || source.Source.Level > 90 ||
|
||||
!validIdentity(source.Source.Name) || !utf8.ValidString(source.Source.Name) || !source.HasEquipment || !source.HasSkill ||
|
||||
source.Equipment.CharacterID != s.SourceCharacterID || source.Skill.CharacterID != s.SourceCharacterID {
|
||||
return Snapshot{}, ErrSnapshot
|
||||
}
|
||||
if len(source.Equipment.Entries) > 64 || len(source.Skill.Skills) > 1024 {
|
||||
return Snapshot{}, ErrSnapshot
|
||||
}
|
||||
out := Snapshot{SchemaVersion: 1, AccountID: s.AccountID, OwnerCharacterID: s.OwnerCharacterID,
|
||||
SourceCharacterID: s.SourceCharacterID, Name: source.Source.Name, Job: uint8(job), GrowType: uint8(grow),
|
||||
Level: source.Source.Level, ProfileID: p.ID, ProfileVersion: p.Version,
|
||||
ClientSHA256: s.ClientSHA256, PVFSHA256: s.PVFSHA256,
|
||||
Equipment: []EquipmentSpec{}, Skills: []SkillSpec{}, Exclusions: []Exclusion{
|
||||
{Kind: "attributes", Reason: "native_base_stats_and_equipment_projection_not_verified"},
|
||||
{Kind: "equipment", Reason: "instance_options_set_and_proc_effects_not_projected"},
|
||||
}}
|
||||
slots := map[int16]bool{}
|
||||
for _, item := range source.Equipment.Entries {
|
||||
if item.SlotIndex < 0 || item.SlotIndex > 255 || item.ItemID <= 0 || item.ItemID > int64(^uint32(0)) || slots[item.SlotIndex] {
|
||||
return Snapshot{}, ErrSnapshot
|
||||
}
|
||||
slots[item.SlotIndex] = true
|
||||
if !item.ExpireAt.IsZero() && !item.ExpireAt.After(now) {
|
||||
out.Exclusions = append(out.Exclusions, Exclusion{Kind: "equipment", ID: item.ItemID, Reason: "expired"})
|
||||
continue
|
||||
}
|
||||
out.Equipment = append(out.Equipment, EquipmentSpec{Slot: item.SlotIndex, ItemID: item.ItemID})
|
||||
}
|
||||
for id, skill := range source.Skill.Skills {
|
||||
if id <= 0 || id > 65535 || skill.Level < 0 || skill.Level > 65535 {
|
||||
return Snapshot{}, ErrSnapshot
|
||||
}
|
||||
if !skill.Enabled || skill.Level == 0 {
|
||||
continue
|
||||
}
|
||||
limit, supported := p.SkillLevelLimits[id]
|
||||
if !supported || limit <= 0 || skill.Level > limit {
|
||||
out.Exclusions = append(out.Exclusions, Exclusion{Kind: "skill", ID: id, Reason: "native_skill_or_level_unverified"})
|
||||
continue
|
||||
}
|
||||
out.Skills = append(out.Skills, SkillSpec{ID: id, Level: skill.Level})
|
||||
}
|
||||
sort.Slice(out.Equipment, func(i, j int) bool { return out.Equipment[i].Slot < out.Equipment[j].Slot })
|
||||
sort.Slice(out.Skills, func(i, j int) bool { return out.Skills[i].ID < out.Skills[j].ID })
|
||||
sort.Slice(out.Exclusions, func(i, j int) bool {
|
||||
a, b := out.Exclusions[i], out.Exclusions[j]
|
||||
if a.Kind != b.Kind {
|
||||
return a.Kind < b.Kind
|
||||
}
|
||||
if a.ID != b.ID {
|
||||
return a.ID < b.ID
|
||||
}
|
||||
return a.Reason < b.Reason
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s Snapshot) Clone() Snapshot {
|
||||
if s.Equipment != nil {
|
||||
s.Equipment = append([]EquipmentSpec{}, s.Equipment...)
|
||||
}
|
||||
if s.Skills != nil {
|
||||
s.Skills = append([]SkillSpec{}, s.Skills...)
|
||||
}
|
||||
if s.Exclusions != nil {
|
||||
s.Exclusions = append([]Exclusion{}, s.Exclusions...)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Fingerprint is content addressing, NOT an authentication token.
|
||||
func (s Snapshot) Fingerprint() string {
|
||||
body, _ := json.Marshal(s)
|
||||
sum := sha256.Sum256(body)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AITemplateSelector binds one saved profile to a verified AICharacter.lst
|
||||
// entry. The selector is part of the offline contract; it does not imply
|
||||
// that the current client has loaded or executed the entry.
|
||||
type AITemplateSelector struct {
|
||||
Key string `json:"key"`
|
||||
Job uint8 `json:"job"`
|
||||
ClassID uint16 `json:"class_id"`
|
||||
Behavior string `json:"behavior"`
|
||||
ComboPreset string `json:"combo_preset"`
|
||||
APCCode int64 `json:"apc_code"`
|
||||
AICPath string `json:"aic_path"`
|
||||
BaseAICPath string `json:"base_aic_path"`
|
||||
Legacy bool `json:"legacy"`
|
||||
}
|
||||
|
||||
type aiTemplateVariant struct {
|
||||
Code int64
|
||||
Path string
|
||||
}
|
||||
|
||||
type aiTemplatePair struct {
|
||||
Profession string
|
||||
Assist aiTemplateVariant
|
||||
Aggressive aiTemplateVariant
|
||||
}
|
||||
|
||||
// These are the stable APC/AIC pairs selected by the checked-in client native
|
||||
// projection contract (90CN_companion_p0.h) and verified against the current
|
||||
// AICharacter.lst. The server must patch the AIC that the client actually
|
||||
// spawns. The old friendly-doll entries (for example christian_ronaldo and
|
||||
// yugo) are valid archive assets, but they are not the active baseline APCs
|
||||
// used by the native companion projection and therefore cannot change live
|
||||
// behaviour.
|
||||
//
|
||||
// Assist and aggressive profiles intentionally share the active baseline AIC
|
||||
// for a job. The profile-specific action/destination/move programs are
|
||||
// compiled into that AIC; the caller must build/select one profile for a
|
||||
// baseline at a time because a single PVF cannot contain two different
|
||||
// programs at the same archive path.
|
||||
var aiTemplatePairs = map[uint8]aiTemplatePair{
|
||||
0: {"swordman", aiTemplateVariant{900, "aicharacter/_bizarre/swordman/mirror_swordman/mirror_swordman.aic"}, aiTemplateVariant{900, "aicharacter/_bizarre/swordman/mirror_swordman/mirror_swordman.aic"}},
|
||||
1: {"fighter", aiTemplateVariant{5405, "aicharacter/fighter/arad_fighter/arad_fighter_doll.aic"}, aiTemplateVariant{5405, "aicharacter/fighter/arad_fighter/arad_fighter_doll.aic"}},
|
||||
2: {"gunner", aiTemplateVariant{10903, "aicharacter/_bizarre/gunner/mirror_gunner/mirror_gunner.aic"}, aiTemplateVariant{10903, "aicharacter/_bizarre/gunner/mirror_gunner/mirror_gunner.aic"}},
|
||||
3: {"mage", aiTemplateVariant{15902, "aicharacter/_bizarre/magician/mirror_magician/mirror_magician.aic"}, aiTemplateVariant{15902, "aicharacter/_bizarre/magician/mirror_magician/mirror_magician.aic"}},
|
||||
4: {"priest", aiTemplateVariant{20902, "aicharacter/_bizarre/priest/mirror_priest/mirror_priest.aic"}, aiTemplateVariant{20902, "aicharacter/_bizarre/priest/mirror_priest/mirror_priest.aic"}},
|
||||
5: {"atgunner", aiTemplateVariant{25900, "aicharacter/_bizarre/atgunner/mirror_atgunner/mirror_atgunner.aic"}, aiTemplateVariant{25900, "aicharacter/_bizarre/atgunner/mirror_atgunner/mirror_atgunner.aic"}},
|
||||
6: {"thief", aiTemplateVariant{30901, "aicharacter/_bizarre/Thief/mirror_Thief/mirror_Thief.aic"}, aiTemplateVariant{30901, "aicharacter/_bizarre/Thief/mirror_Thief/mirror_Thief.aic"}},
|
||||
7: {"atfighter", aiTemplateVariant{35903, "aicharacter/_bizarre/atfighter/mirror_atfighter/mirror_atfighter.aic"}, aiTemplateVariant{35903, "aicharacter/_bizarre/atfighter/mirror_atfighter/mirror_atfighter.aic"}},
|
||||
8: {"atmage", aiTemplateVariant{40800, "aicharacter/autocharacter/atmage/auto1/auto_atmage1.aic"}, aiTemplateVariant{40800, "aicharacter/autocharacter/atmage/auto1/auto_atmage1.aic"}},
|
||||
9: {"demonic_swordman", aiTemplateVariant{902, "aicharacter/_bizarre/swordman/despair_darkknight/despair_darkknight.aic"}, aiTemplateVariant{902, "aicharacter/_bizarre/swordman/despair_darkknight/despair_darkknight.aic"}},
|
||||
10: {"creator", aiTemplateVariant{15903, "aicharacter/_bizarre/magician/despair_creator/despair_creator.aic"}, aiTemplateVariant{15903, "aicharacter/_bizarre/magician/despair_creator/despair_creator.aic"}},
|
||||
11: {"atswordman", aiTemplateVariant{56901, "aicharacter/_bizarre/atswordman/despair_atswordman/despair_atswordman.aic"}, aiTemplateVariant{56901, "aicharacter/_bizarre/atswordman/despair_atswordman/despair_atswordman.aic"}},
|
||||
12: {"knight", aiTemplateVariant{60901, "aicharacter/_bizarre/knight/despair_knight/despair_knight.aic"}, aiTemplateVariant{60901, "aicharacter/_bizarre/knight/despair_knight/despair_knight.aic"}},
|
||||
13: {"demonic_lancer", aiTemplateVariant{65001, "aicharacter/demoniclancer/mirror_demonicLancer/mirror_demonicLancer.aic"}, aiTemplateVariant{65001, "aicharacter/demoniclancer/mirror_demonicLancer/mirror_demonicLancer.aic"}},
|
||||
// job 14(女圣职者)必须保持 70003 `atpriest/despair_atpriest.aic`,**不要改成 70002**。
|
||||
//
|
||||
// 曾试过改成 70002 `atpriest/mirror_demoniclancer.aic`,实测完全无效。原因是
|
||||
// 选哪个 APC 由**客户端内置基线表**决定,服务端的 ai_template 只决定“补哪条 AIC”。
|
||||
//
|
||||
// 90CN_companion_p0.h 的基线表格式是 {job, sourceGrow, apcCode, level, aicJob, aicGrow}:
|
||||
//
|
||||
// {14,0,70003,70,14,0}, // <- stable 外壳(sourceGrow=0 且 aicGrow=0)
|
||||
// {14,1,71200,86,14,1},
|
||||
// {14,2,71203,86,14,2},
|
||||
//
|
||||
// ProjectionBaselineContract::TrySelect 的逻辑是
|
||||
//
|
||||
// selected = stable ? *stable : *exact; // 优先 grow=0 的稳定外壳
|
||||
//
|
||||
// 所以奶妈 grow_type=33(firstGrow=1, awakening=2)虽然 exact 命中 {14,1,71200},
|
||||
// 最终仍被 stable 抢走,**客户端永远用 APC 70003 的 AIC**。服务端把模板改成 70002
|
||||
// 只会让覆盖打到一条没人用的 AIC 上,反而让 70003 丢掉 12 格快捷栏程序 —— 表现为
|
||||
// “能召唤但不放技能、不攻击”。
|
||||
//
|
||||
// 另外注意:外壳 APC 固定是 grow=0 的形态,客户端只把源角色的 grow/觉醒/技能桥接
|
||||
// 到 AIC 的每实例副本上,**视觉职业仍然是那个 grow=0 外壳**。所以支援兵看起来
|
||||
// “未觉醒”是这套投影设计的固有限制,不是模板选错。
|
||||
14: {"atpriest", aiTemplateVariant{70003, "aicharacter/atpriest/despair_atpriest/despair_atpriest.aic"}, aiTemplateVariant{70003, "aicharacter/atpriest/despair_atpriest/despair_atpriest.aic"}},
|
||||
15: {"gunblader", aiTemplateVariant{75001, "aicharacter/gunblader/mirror_gunblader/mirror_gunblader.aic"}, aiTemplateVariant{75001, "aicharacter/gunblader/mirror_gunblader/mirror_gunblader.aic"}},
|
||||
}
|
||||
|
||||
func SelectAITemplate(profile AIProfile, job uint16) AITemplateSelector {
|
||||
profile = NormalizeAIProfile(profile)
|
||||
job8 := uint8(job)
|
||||
pair, ok := aiTemplatePairs[job8]
|
||||
if !ok {
|
||||
job8 = 1
|
||||
pair = aiTemplatePairs[job8]
|
||||
}
|
||||
classID := profile.ClassID
|
||||
if classID == 0 {
|
||||
classID = job
|
||||
}
|
||||
variant := pair.Assist
|
||||
if profile.Behavior == "aggressive" {
|
||||
variant = pair.Aggressive
|
||||
}
|
||||
return AITemplateSelector{
|
||||
Key: fmt.Sprintf("%s/%s/job-%02d/class-%d/%s", pair.Profession, profile.Behavior, job8, classID, artifactSlug(profile.ComboPreset)),
|
||||
Job: job8,
|
||||
ClassID: classID,
|
||||
Behavior: profile.Behavior,
|
||||
ComboPreset: profile.ComboPreset,
|
||||
APCCode: variant.Code,
|
||||
AICPath: variant.Path,
|
||||
BaseAICPath: variant.Path,
|
||||
Legacy: variant.Code == 5405,
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveAITemplate is the descriptive name used by server projection code;
|
||||
// SelectAITemplate remains the compiler-facing alias.
|
||||
func ResolveAITemplate(profile AIProfile, job uint16) AITemplateSelector {
|
||||
return SelectAITemplate(profile, job)
|
||||
}
|
||||
|
||||
func ValidateAITemplateSelector(selector AITemplateSelector) error {
|
||||
if strings.TrimSpace(selector.Key) == "" || selector.APCCode <= 0 || selector.AICPath == "" {
|
||||
return ErrSelection
|
||||
}
|
||||
want := SelectAITemplate(AIProfile{Behavior: selector.Behavior, ClassID: selector.ClassID, ComboPreset: selector.ComboPreset}, uint16(selector.Job))
|
||||
if selector.APCCode != want.APCCode || !strings.EqualFold(selector.AICPath, want.AICPath) {
|
||||
return ErrSelection
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s AITemplateSelector) Valid() bool {
|
||||
return ValidateAITemplateSelector(s) == nil
|
||||
}
|
||||
Reference in new issue
Block a user