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