Files
90CNServer/server/internal/modules/dnf/companion/ai_artifact.go
T
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

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[:])
}