DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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

No files matched your search

+698
View File
@@ -0,0 +1,698 @@
package script
import (
"context"
"fmt"
"path"
"sort"
"strings"
"pvfine/internal/pvf"
)
// BatchAPI is the Go-side host facade used by GojaRuntime. It owns no live
// archive; all mutations flow through tx.Stage().
type BatchAPI struct {
ctx context.Context
tx *Transaction
logFn func(LogEntry)
progressFn func(Progress)
// fileSets is the baseline snapshot plus staged mutations for the user's
// file sets. It stays nil when the service did not supply one, which makes
// every file set call fail rather than silently operate on nothing.
fileSets *FileSetStage
scannedFiles int
}
// NewBatchAPI creates a host facade for one transaction.
func NewBatchAPI(ctx context.Context, tx *Transaction, logFn func(LogEntry), progressFn func(Progress)) *BatchAPI {
if ctx == nil {
ctx = context.Background()
}
return &BatchAPI{ctx: ctx, tx: tx, logFn: logFn, progressFn: progressFn}
}
// SetFileSetStage attaches the file set snapshot and staging area for one run.
func (a *BatchAPI) SetFileSetStage(stage *FileSetStage) {
if a == nil {
return
}
a.fileSets = stage
}
// FileSetStage returns the attached staging area, or nil when file sets are
// unavailable for this run.
func (a *BatchAPI) FileSetStage() *FileSetStage {
if a == nil {
return nil
}
return a.fileSets
}
func (a *BatchAPI) checkContext() error {
if a == nil || a.tx == nil || a.tx.Stage() == nil {
return fmt.Errorf("脚本事务不可用")
}
select {
case <-a.ctx.Done():
return a.ctx.Err()
default:
return nil
}
}
// GetOriStrValue resolves references against the staged string tables, keeping
// legacy literal strings and unresolved references unchanged.
func (a *BatchAPI) GetOriStrValue(reference string) (string, error) {
if err := a.checkContext(); err != nil {
return "", err
}
if table, key, ok := pvf.ParsePlaceholder(reference); ok {
if text, found := a.tx.Stage().LookupStringTableText(table, key); found {
return text, nil
}
}
return a.tx.Stage().ResolvePlaceholders(reference), nil
}
// Files returns every archive entry in deterministic path order.
func (a *BatchAPI) Files() ([]*FileHandle, error) {
if err := a.checkContext(); err != nil {
return nil, err
}
archive := a.tx.Stage()
entries := make([]*FileHandle, 0, archive.FileCount())
for index := int32(0); index < archive.FileCount(); index++ {
if err := a.checkContext(); err != nil {
return nil, err
}
entries = append(entries, a.newFileHandle(archive.Path(index)))
}
a.scannedFiles += int(archive.FileCount())
sort.SliceStable(entries, func(left, right int) bool {
leftPath := strings.ToLower(entries[left].Path())
rightPath := strings.ToLower(entries[right].Path())
if leftPath != rightPath {
return leftPath < rightPath
}
return entries[left].Index() < entries[right].Index()
})
return entries, nil
}
// Find resolves one archive entry using the same normalized lookup semantics
// as pvf.Archive.Find.
func (a *BatchAPI) Find(filePath string) (*FileHandle, bool, error) {
if err := a.checkContext(); err != nil {
return nil, false, err
}
index, ok := a.tx.Stage().Find(filePath)
if !ok {
return nil, false, nil
}
return a.newFileHandle(a.tx.Stage().Path(index)), true, nil
}
// Glob resolves slash-normalized case-insensitive patterns. '*' and '?' do
// not cross '/', while '**' may cross directory boundaries.
func (a *BatchAPI) Glob(pattern string) ([]*FileHandle, error) {
if err := a.checkContext(); err != nil {
return nil, err
}
pattern = normalizeGlob(pattern)
if pattern == "" {
return nil, fmt.Errorf("glob 模式不能为空")
}
archive := a.tx.Stage()
result := make([]*FileHandle, 0)
for index := int32(0); index < archive.FileCount(); index++ {
if err := a.checkContext(); err != nil {
return nil, err
}
if globMatch(pattern, normalizeGlob(archive.Path(index))) {
result = append(result, a.newFileHandle(archive.Path(index)))
}
}
a.scannedFiles += int(archive.FileCount())
return result, nil
}
func normalizeGlob(value string) string {
return strings.ToLower(strings.Trim(strings.ReplaceAll(strings.TrimSpace(value), "\\", "/"), "/"))
}
func globMatch(pattern, value string) bool {
patternRunes := []rune(pattern)
valueRunes := []rune(value)
states := make([][]bool, len(patternRunes)+1)
for index := range states {
states[index] = make([]bool, len(valueRunes)+1)
}
states[0][0] = true
for pi, token := range patternRunes {
for vi := 0; vi <= len(valueRunes); vi++ {
if !states[pi][vi] {
continue
}
switch token {
case '*':
if pi+1 < len(patternRunes) && patternRunes[pi+1] == '*' {
if pi+2 < len(patternRunes) && patternRunes[pi+2] == '/' {
// A recursive directory wildcard may match zero complete
// path components, including the following slash.
states[pi+3][vi] = true
} else {
states[pi+2][vi] = true
}
if vi < len(valueRunes) {
states[pi][vi+1] = true
}
} else {
states[pi+1][vi] = true
if vi < len(valueRunes) && valueRunes[vi] != '/' {
states[pi][vi+1] = true
}
}
case '?':
if vi < len(valueRunes) && valueRunes[vi] != '/' {
states[pi+1][vi+1] = true
}
default:
if vi < len(valueRunes) && token == valueRunes[vi] {
states[pi+1][vi+1] = true
}
}
}
}
return states[len(patternRunes)][len(valueRunes)]
}
// ScannedCount reports how many archive entries host queries inspected.
func (a *BatchAPI) ScannedCount() int {
if a == nil {
return 0
}
return a.scannedFiles
}
// ModifiedCount reports the number of unique final changes, including created
// and deleted entries.
func (a *BatchAPI) ModifiedCount() int {
if a == nil || a.tx == nil {
return 0
}
changes, err := a.tx.Changes()
if err != nil {
return 0
}
return len(changes)
}
// CreateFile stages a new entry and returns its handle. text is the initial
// decompiled content; an empty text creates an empty file.
func (a *BatchAPI) CreateFile(filePath string, dataType int32, text string) (*FileHandle, error) {
if err := a.checkContext(); err != nil {
return nil, err
}
path, err := a.tx.CreateFile(filePath, dataType, nil)
if err != nil {
return nil, err
}
if text != "" {
if err := a.tx.SetText(path, text); err != nil {
return nil, err
}
}
return a.newFileHandle(path), nil
}
// CopyFile stages a copy of an existing entry.
func (a *BatchAPI) CopyFile(from, to string, overwrite bool) (*FileHandle, error) {
if err := a.checkContext(); err != nil {
return nil, err
}
path, err := a.tx.CopyFile(from, to, overwrite)
if err != nil {
return nil, err
}
return a.newFileHandle(path), nil
}
// DeleteFile stages removal of one entry.
func (a *BatchAPI) DeleteFile(filePath string) (bool, error) {
if err := a.checkContext(); err != nil {
return false, err
}
return a.tx.DeleteFile(filePath)
}
// OpenList resolves a .lst file and returns its list facade.
func (a *BatchAPI) OpenList(filePath string) (*ListHandle, error) {
if err := a.checkContext(); err != nil {
return nil, err
}
index, ok := a.tx.Stage().FindList(filePath)
if !ok {
return nil, fmt.Errorf("列表文件不存在: %s", filePath)
}
if a.tx.Stage().File(index).DataType != pvf.TypeScript {
return nil, fmt.Errorf("文件 %s 不是 .lst 列表", a.tx.Stage().Path(index))
}
return &ListHandle{api: a, path: a.tx.Stage().Path(index)}, nil
}
// ListHandle is the narrow Go host object behind a JavaScript PVFList. Entry
// paths follow the archive's list layout: directory-relative for legacy lists,
// archive-root-relative for Paged110 and centralized list/ lists.
type ListHandle struct {
api *BatchAPI
path string
}
// Path returns the archive path of the list file.
func (l *ListHandle) Path() string {
if l == nil {
return ""
}
return l.path
}
// Get returns the id/path pairs in file order.
func (l *ListHandle) Get() ([]pvf.ListPair, error) {
if err := l.api.checkContext(); err != nil {
return nil, err
}
return l.api.tx.ListPairs(l.path)
}
// Set inserts or updates one id/path entry.
func (l *ListHandle) Set(id, entryPath string) error {
if err := l.api.checkContext(); err != nil {
return err
}
stored, err := l.storagePath(entryPath)
if err != nil {
return err
}
return l.api.tx.SetListPairs(l.path, []pvf.ListPair{{ID: id, Path: stored}})
}
// MSet inserts or updates several id/path entries at once.
func (l *ListHandle) MSet(pairs []pvf.ListPair) error {
if err := l.api.checkContext(); err != nil {
return err
}
if len(pairs) == 0 {
return nil
}
resolved := make([]pvf.ListPair, 0, len(pairs))
for _, pair := range pairs {
stored, err := l.storagePath(pair.Path)
if err != nil {
return err
}
resolved = append(resolved, pvf.ListPair{ID: pair.ID, Path: stored})
}
return l.api.tx.SetListPairs(l.path, resolved)
}
// Unset removes every entry whose id matches and reports whether any was
// removed.
func (l *ListHandle) Unset(id string) (bool, error) {
if err := l.api.checkContext(); err != nil {
return false, err
}
id = strings.TrimSpace(id)
if id == "" {
return false, fmt.Errorf("列表 id 不能为空")
}
removed, err := l.api.tx.UnsetListIDs(l.path, []string{id})
if err != nil {
return false, err
}
return removed > 0, nil
}
// GetID returns the id registered for entryPath, or false when the list has no
// matching entry. Unlike Set, it neither validates nor requires the target file
// to exist: it normalizes the path and compares it with the list content.
func (l *ListHandle) GetID(entryPath string) (string, bool, error) {
if err := l.api.checkContext(); err != nil {
return "", false, err
}
raw, err := normalizeListPath(entryPath)
if err != nil {
return "", false, err
}
for _, candidate := range l.candidatePaths(raw) {
id, found, err := l.api.tx.ListID(l.path, candidate)
if err != nil {
return "", false, err
}
if found {
return id, true, nil
}
}
return "", false, nil
}
// storagePath resolves an entry path to the native form the .lst file stores.
// Both full archive paths and list-directory-relative spellings are accepted.
// The target must exist in the archive so a typo cannot register a dangling
// entry.
func (l *ListHandle) storagePath(entryPath string) (string, error) {
raw, err := normalizeListPath(entryPath)
if err != nil {
return "", err
}
for _, candidate := range l.candidatePaths(raw) {
if l.targetExists(candidate) {
return candidate, nil
}
}
return "", fmt.Errorf("列表条目指向的文件不存在: %s", raw)
}
// candidatePaths lists native storage spellings, preferring the literal input
// when both interpretations resolve. Root-relative lists never strip prefixes.
func (l *ListHandle) candidatePaths(raw string) []string {
candidates := []string{raw}
if l.rootRelative() {
if joined := path.Join(path.Dir(l.path), raw); joined != raw {
candidates = append(candidates, joined)
}
return candidates
}
if rebased, ok := l.rebase(raw); ok && rebased != raw {
candidates = append(candidates, rebased)
}
return candidates
}
func (l *ListHandle) rootRelative() bool {
return l.api.tx.Stage().IsPaged() || strings.EqualFold(path.Dir(l.path), "list")
}
// rebase converts a full archive path into the list-relative form, when the
// path lives under the list file's directory.
func (l *ListHandle) rebase(archivePath string) (string, bool) {
dir := path.Dir(l.path)
if dir == "." {
return archivePath, true
}
prefix := strings.ToLower(dir) + "/"
if !strings.HasPrefix(strings.ToLower(archivePath), prefix) {
return "", false
}
relative := archivePath[len(prefix):]
if relative == "" {
return "", false
}
return relative, true
}
// targetExists reports whether a native storage path resolves to an archive
// entry, mirroring the service lookup that also accepts a "(r)" sibling.
func (l *ListHandle) targetExists(stored string) bool {
archivePath := stored
if !l.rootRelative() {
archivePath = path.Join(path.Dir(l.path), stored)
}
if _, ok := l.api.tx.Stage().Find(archivePath); ok {
return true
}
base := path.Base(archivePath)
if strings.HasPrefix(strings.ToLower(base), "(r)") {
return false
}
_, ok := l.api.tx.Stage().Find(path.Join(path.Dir(archivePath), "(r)"+base))
return ok
}
// normalizeListPath trims an incoming entry path to its archive spelling.
func normalizeListPath(entryPath string) (string, error) {
raw := strings.TrimSpace(strings.ReplaceAll(entryPath, "\\", "/"))
raw = strings.TrimPrefix(raw, "./")
raw = strings.Trim(raw, "/")
if raw == "" {
return "", fmt.Errorf("列表条目路径不能为空")
}
if raw == "." || raw == ".." || strings.HasPrefix(raw, "../") {
return "", fmt.Errorf("列表条目路径无效: %q", entryPath)
}
return raw, nil
}
// OpenFileSet resolves one file set from the run's staged view. A set created
// earlier in the same run is visible here, because the stage merges the
// baseline snapshot with pending mutations. found is false for an unknown name,
// which the script sees as null.
func (a *BatchAPI) OpenFileSet(name string) (*FileSetHandle, bool, error) {
if err := a.checkContext(); err != nil {
return nil, false, err
}
if a.fileSets == nil {
return nil, false, fmt.Errorf("当前运行不支持文件集")
}
normalized := NormalizeFileSetName(name)
if normalized == "" {
return nil, false, fmt.Errorf("文件集名称不能为空")
}
if _, ok := a.fileSets.Lookup(normalized); !ok {
return nil, false, nil
}
return &FileSetHandle{api: a, name: normalized}, true, nil
}
// CreateFileSet stages a new file set with the supplied entry paths.
func (a *BatchAPI) CreateFileSet(name string, paths []string) (*FileSetHandle, error) {
if err := a.checkContext(); err != nil {
return nil, err
}
if a.fileSets == nil {
return nil, fmt.Errorf("当前运行不支持文件集")
}
fileSet, err := a.fileSets.Create(name, paths)
if err != nil {
return nil, err
}
return &FileSetHandle{api: a, name: fileSet.Name}, nil
}
// FileSetHandle is the narrow Go host object behind a JavaScript PVFFileSet.
type FileSetHandle struct {
api *BatchAPI
name string
}
// Name returns the resolved set name.
func (h *FileSetHandle) Name() string {
if h == nil {
return ""
}
return h.name
}
// Get returns the staged entry paths in stored order.
func (h *FileSetHandle) Get() ([]string, error) {
fileSet, err := h.current()
if err != nil {
return nil, err
}
paths := make([]string, 0, len(fileSet.Entries))
for _, entry := range fileSet.Entries {
paths = append(paths, entry.Path)
}
return paths, nil
}
// Set replaces the entire entry list, preserving metadata of surviving paths.
func (h *FileSetHandle) Set(paths []string) (int, error) {
if err := h.api.checkContext(); err != nil {
return 0, err
}
fileSet, err := h.api.fileSets.Replace(h.name, paths)
if err != nil {
return 0, err
}
return len(fileSet.Entries), nil
}
// current re-resolves the set on every call: an earlier call in the same run
// may have replaced or created it after this handle was captured.
func (h *FileSetHandle) current() (FileSet, error) {
if h == nil || h.api == nil {
return FileSet{}, fmt.Errorf("文件集句柄无效")
}
if err := h.api.checkContext(); err != nil {
return FileSet{}, err
}
if h.api.fileSets == nil {
return FileSet{}, fmt.Errorf("当前运行不支持文件集")
}
fileSet, ok := h.api.fileSets.Lookup(h.name)
if !ok {
return FileSet{}, fmt.Errorf("文件集不存在: %s", h.name)
}
return fileSet, nil
}
// Log records a message and forwards it to the service event sink.
func (a *BatchAPI) Log(level, message string) {
if a == nil || a.logFn == nil {
return
}
a.logFn(LogEntry{Level: level, Message: message})
}
// Progress forwards a user-provided progress update.
func (a *BatchAPI) Progress(done, total int, message string) error {
if err := a.checkContext(); err != nil {
return err
}
if done < 0 || total < 0 || (total > 0 && done > total) {
return fmt.Errorf("进度范围无效: %d/%d", done, total)
}
if a.progressFn != nil {
a.progressFn(Progress{Done: done, Total: total, Message: message})
}
return nil
}
func (a *BatchAPI) newFileHandle(path string) *FileHandle {
return &FileHandle{api: a, path: path, deleteRevision: a.tx.DeleteRevision()}
}
// FileHandle is the narrow Go host object behind a JavaScript PVFFile. It is
// addressed by path because staged creates and deletes shift entry indexes.
type FileHandle struct {
api *BatchAPI
path string
deleteRevision int
}
func (f *FileHandle) Index() int32 {
index, ok := f.lookup()
if !ok {
return -1
}
return index
}
func (f *FileHandle) Path() string {
if f == nil {
return ""
}
return f.path
}
func (f *FileHandle) Type() int32 {
index, ok := f.lookup()
if !ok {
return 0
}
return f.api.tx.Stage().File(index).DataType
}
func (f *FileHandle) Size() int32 {
index, ok := f.lookup()
if !ok {
return 0
}
return f.api.tx.Stage().File(index).DataSize
}
// lookup resolves the handle and rejects handles captured before a staged
// removal, because that removal may have renumbered the entry.
func (f *FileHandle) lookup() (int32, bool) {
if f == nil || f.api == nil || f.api.tx == nil || f.api.tx.Stage() == nil {
return 0, false
}
if f.deleteRevision != f.api.tx.DeleteRevision() {
return 0, false
}
return f.api.tx.Stage().Find(f.path)
}
// staleError explains why a handle is unusable.
func (f *FileHandle) staleError() error {
if f != nil && f.api != nil && f.api.tx != nil && f.deleteRevision != f.api.tx.DeleteRevision() {
return fmt.Errorf("文件 %s 的句柄已失效,请重新查询后再操作", f.path)
}
return fmt.Errorf("文件不存在: %s", f.Path())
}
func (f *FileHandle) Text() (string, error) {
if err := f.api.checkContext(); err != nil {
return "", err
}
index, ok := f.lookup()
if !ok {
return "", f.staleError()
}
return f.api.tx.Stage().Text(index)
}
func (f *FileHandle) SetText(text string) error {
if err := f.api.checkContext(); err != nil {
return err
}
if _, ok := f.lookup(); !ok {
return f.staleError()
}
return f.api.tx.SetText(f.path, text)
}
func (f *FileHandle) Parse() (*pvf.ScriptDocument, error) {
if err := f.api.checkContext(); err != nil {
return nil, err
}
index, ok := f.lookup()
if !ok {
return nil, f.staleError()
}
if f.Type() != pvf.TypeScript {
return nil, fmt.Errorf("文件 %s 不是 TypeScript", f.Path())
}
raw, err := f.api.tx.Stage().RawBytes(index)
if err != nil {
return nil, err
}
document, err := f.api.tx.Stage().ParseScriptDocument(raw)
if err != nil {
return nil, err
}
for _, warning := range document.Warnings() {
f.api.Log(
LogLevelWarn,
fmt.Sprintf("%s: [%s] %s", f.Path(), warning.Code, warning.Message),
)
}
return document, nil
}
func (f *FileHandle) Write(document *pvf.ScriptDocument) error {
if err := f.api.checkContext(); err != nil {
return err
}
if _, ok := f.lookup(); !ok {
return f.staleError()
}
if f.Type() != pvf.TypeScript {
return fmt.Errorf("文件 %s 不是 TypeScript", f.Path())
}
raw, err := f.api.tx.Stage().EncodeScriptDocument(document)
if err != nil {
return err
}
return f.api.tx.SetRawBytes(f.path, raw)
}
// PathPatternMatch is exported for focused unit tests and future callers that
// need to validate the same virtual path pattern grammar.
func PathPatternMatch(pattern, value string) bool {
return globMatch(normalizeGlob(pattern), normalizeGlob(value))
}
+277
View File
@@ -0,0 +1,277 @@
package script
import (
"fmt"
"sort"
"strings"
)
// FileSetEntry is one entry inside a file set. Only Path is meaningful to a
// script; the remaining fields preserve the metadata the persistence layer
// stores so a rewritten set does not lose names or ids.
type FileSetEntry struct {
Path string
Name string
IDs []string
Size int32
DataType int32
}
// FileSet is an immutable snapshot of one file set.
type FileSet struct {
ID string
Name string
Entries []FileSetEntry
}
// FileSetChange is one staged file set mutation. Name addresses the target set;
// Created reports whether the script introduced a set absent from the baseline.
// IDs are assigned by the persistence layer when the plan is applied, so a
// change never carries one.
type FileSetChange struct {
Name string
Created bool
Entries []FileSetEntry
}
// FileSetStage holds the file sets a run may read plus the mutations it stages.
// It is name-addressed on purpose: ids belong to the persistence layer, and the
// stage must not invent them.
type FileSetStage struct {
baseline map[string]FileSet
current map[string]FileSet
created map[string]struct{}
order []string
}
// NewFileSetStage builds a stage over the supplied baseline snapshot. Later sets
// with a duplicate name are ignored so lookup stays deterministic.
func NewFileSetStage(sets []FileSet) *FileSetStage {
stage := &FileSetStage{
baseline: make(map[string]FileSet, len(sets)),
current: make(map[string]FileSet, len(sets)),
created: make(map[string]struct{}),
}
for _, fileSet := range sets {
name := NormalizeFileSetName(fileSet.Name)
if name == "" {
continue
}
if _, exists := stage.baseline[name]; exists {
continue
}
snapshot := FileSet{ID: fileSet.ID, Name: name, Entries: cloneFileSetEntries(fileSet.Entries)}
stage.baseline[name] = snapshot
stage.current[name] = snapshot
stage.order = append(stage.order, name)
}
return stage
}
// Lookup returns the current state of a named file set.
func (s *FileSetStage) Lookup(name string) (FileSet, bool) {
if s == nil {
return FileSet{}, false
}
fileSet, ok := s.current[NormalizeFileSetName(name)]
return fileSet, ok
}
// Create adds a new file set. It fails when the name is already taken by the
// baseline or by an earlier call in the same run.
func (s *FileSetStage) Create(name string, paths []string) (FileSet, error) {
if s == nil {
return FileSet{}, fmt.Errorf("文件集暂存区不可用")
}
normalized, err := normalizeFileSetNameStrict(name)
if err != nil {
return FileSet{}, err
}
if _, exists := s.current[normalized]; exists {
return FileSet{}, fmt.Errorf("文件集已存在: %s", normalized)
}
fileSet := FileSet{Name: normalized, Entries: buildFileSetEntries(paths, nil)}
s.current[normalized] = fileSet
s.created[normalized] = struct{}{}
s.order = append(s.order, normalized)
return fileSet, nil
}
// Replace overwrites the entry list of an existing file set, keeping the
// metadata of entries whose path survives.
func (s *FileSetStage) Replace(name string, paths []string) (FileSet, error) {
if s == nil {
return FileSet{}, fmt.Errorf("文件集暂存区不可用")
}
normalized, err := normalizeFileSetNameStrict(name)
if err != nil {
return FileSet{}, err
}
existing, exists := s.current[normalized]
if !exists {
return FileSet{}, fmt.Errorf("文件集不存在: %s", normalized)
}
existing.Entries = buildFileSetEntries(paths, existing.Entries)
s.current[normalized] = existing
return existing, nil
}
// Baseline returns the snapshot state of a named file set, reporting false when
// the set did not exist before the run.
func (s *FileSetStage) Baseline(name string) (FileSet, bool) {
if s == nil {
return FileSet{}, false
}
fileSet, ok := s.baseline[NormalizeFileSetName(name)]
return fileSet, ok
}
// Changes returns every staged mutation in stable name order, omitting sets
// whose entry list is byte-for-byte equivalent to the baseline.
func (s *FileSetStage) Changes() []FileSetChange {
if s == nil {
return nil
}
changes := make([]FileSetChange, 0)
for name, current := range s.current {
baseline, existed := s.baseline[name]
if existed && fileSetEntriesEqual(baseline.Entries, current.Entries) {
continue
}
_, isCreated := s.created[name]
changes = append(changes, FileSetChange{
Name: name,
Created: isCreated || !existed,
Entries: cloneFileSetEntries(current.Entries),
})
}
sort.SliceStable(changes, func(left, right int) bool {
return changes[left].Name < changes[right].Name
})
return changes
}
// buildFileSetEntries normalizes a path list into entries, reusing the metadata
// of a previous entry with the same path so a rewrite keeps names and ids.
func buildFileSetEntries(paths []string, previous []FileSetEntry) []FileSetEntry {
known := make(map[string]FileSetEntry, len(previous))
for _, entry := range previous {
key := NormalizeFileSetPath(entry.Path)
if key == "" {
continue
}
if _, exists := known[key]; !exists {
known[key] = entry
}
}
entries := make([]FileSetEntry, 0, len(paths))
seen := make(map[string]struct{}, len(paths))
for _, raw := range paths {
key := NormalizeFileSetPath(raw)
if key == "" {
continue
}
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
entry, ok := known[key]
if !ok {
entry = FileSetEntry{Name: fileSetPathBase(key)}
}
entry.Path = key
if entry.Name == "" {
entry.Name = fileSetPathBase(key)
}
entry.IDs = uniqueFileSetStrings(entry.IDs)
entries = append(entries, entry)
}
return entries
}
// NormalizeFileSetName trims a set name for lookup. An empty result means the
// name is unusable.
func NormalizeFileSetName(name string) string {
return strings.TrimSpace(name)
}
func normalizeFileSetNameStrict(name string) (string, error) {
normalized := NormalizeFileSetName(name)
if normalized == "" {
return "", fmt.Errorf("文件集名称不能为空")
}
return normalized, nil
}
// NormalizeFileSetPath trims an entry path to its storage spelling, mirroring
// the persistence layer's normalization.
func NormalizeFileSetPath(path string) string {
return strings.Trim(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/"), "/")
}
func fileSetPathBase(path string) string {
if slash := strings.LastIndexByte(path, '/'); slash >= 0 {
return path[slash+1:]
}
return path
}
// fileSetEntriesEqual reports whether a rewrite is semantically a no-op. Only
// the path order and the ids decide that: name, size and data type are
// archive-derived metadata the sidebar refreshes on its own, so a rebuild that
// merely backfills them must not look like a script change.
func fileSetEntriesEqual(left, right []FileSetEntry) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index].Path != right[index].Path ||
!fileSetStringsEqual(left[index].IDs, right[index].IDs) {
return false
}
}
return true
}
func fileSetStringsEqual(left, right []string) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
func cloneFileSetEntries(entries []FileSetEntry) []FileSetEntry {
if len(entries) == 0 {
return nil
}
result := make([]FileSetEntry, len(entries))
for index, entry := range entries {
result[index] = entry
result[index].IDs = append([]string(nil), entry.IDs...)
}
return result
}
func uniqueFileSetStrings(values []string) []string {
if len(values) == 0 {
return nil
}
result := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
if value == "" {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
+197
View File
@@ -0,0 +1,197 @@
package script
import (
"context"
"reflect"
"strings"
"testing"
)
func runFileSetScript(t *testing.T, host *BatchAPI, source string) RunResult {
t.Helper()
result, err := NewGojaRuntime().Run(context.Background(), source, host)
if err != nil {
t.Fatal(err)
}
if result.Status != RunStatusCompleted || result.Error != nil {
t.Fatalf("run result = %#v error=%#v", result, result.Error)
}
return result
}
func TestFileSetStageChangesDedupeAndPreserveMetadata(t *testing.T) {
stage := NewFileSetStage([]FileSet{
{ID: "set-1", Name: "默认文件集", Entries: []FileSetEntry{
{Path: "equipment/a.equ", Name: "a", IDs: []string{"1008"}, Size: 12, DataType: 1},
}},
})
// Rewriting a set keeps surviving entries and drops the removed one.
if _, err := stage.Replace("默认文件集", []string{"equipment\\a.equ", "/equipment/a.equ/", "equipment/b.equ", "equipment/b.equ"}); err != nil {
t.Fatal(err)
}
fileSet, ok := stage.Lookup("默认文件集")
if !ok {
t.Fatal("file set missing after replace")
}
wantPaths := []string{"equipment/a.equ", "equipment/b.equ"}
gotPaths := []string{fileSet.Entries[0].Path, fileSet.Entries[1].Path}
if !reflect.DeepEqual(gotPaths, wantPaths) {
t.Fatalf("paths = %#v, want %#v", gotPaths, wantPaths)
}
if fileSet.Entries[0].IDs[0] != "1008" || fileSet.Entries[0].Size != 12 {
t.Fatalf("metadata not preserved: %#v", fileSet.Entries[0])
}
if fileSet.Entries[1].Name != "b.equ" {
t.Fatalf("new entry name = %q", fileSet.Entries[1].Name)
}
changes := stage.Changes()
if len(changes) != 1 || changes[0].Created {
t.Fatalf("changes = %#v", changes)
}
if len(changes[0].Entries) != 2 {
t.Fatalf("change entries = %#v", changes[0].Entries)
}
}
func TestFileSetStageOmitsUnchangedAndRejectsDuplicateCreate(t *testing.T) {
stage := NewFileSetStage([]FileSet{{ID: "set-1", Name: "默认文件集", Entries: []FileSetEntry{{Path: "a.equ"}}}})
// Replacing with an equivalent list (only spelling differs) stages nothing.
if _, err := stage.Replace("默认文件集", []string{"/a.equ"}); err != nil {
t.Fatal(err)
}
if changes := stage.Changes(); len(changes) != 0 {
t.Fatalf("unchanged rewrite staged changes = %#v", changes)
}
if _, err := stage.Create("默认文件集", nil); err == nil {
t.Fatal("creating a duplicate name should fail")
}
if _, err := stage.Replace("不存在", nil); err == nil {
t.Fatal("replacing a missing set should fail")
}
if _, err := stage.Create(" ", nil); err == nil {
t.Fatal("blank name should fail")
}
created, err := stage.Create("新建文件集", []string{"b.equ", "b.equ", ""})
if err != nil {
t.Fatal(err)
}
if len(created.Entries) != 1 {
t.Fatalf("created entries = %#v", created.Entries)
}
changes := stage.Changes()
if len(changes) != 1 || !changes[0].Created || changes[0].Name != "新建文件集" {
t.Fatalf("create changes = %#v", changes)
}
}
func TestGojaRuntimeFileSetReadAndWrite(t *testing.T) {
archive := scriptTestArchive(t)
tx := NewTransaction(archive)
host := NewBatchAPI(context.Background(), tx, nil, nil)
host.SetFileSetStage(NewFileSetStage([]FileSet{
{ID: "set-1", Name: "默认文件集", Entries: []FileSetEntry{
{Path: "equipment/a.equ", Name: "a", Size: 10, DataType: 1},
{Path: "equipment/b.equ", Name: "b", Size: 20, DataType: 1},
}},
}))
runFileSetScript(t, host, `
const set = pvf.fileset("默认文件集");
if (set === null) throw new Error("set not found");
if (set.name !== "默认文件集") throw new Error("wrong name");
const paths = set.getAll();
if (paths.length !== 2 || paths[0] !== "equipment/a.equ") throw new Error("wrong paths: " + JSON.stringify(paths));
const kept = set.setAll(["equipment/b.equ", "equipment/c.equ"]);
if (kept !== 2) throw new Error("setAll returned " + kept);
if (set.getAll().join("|") !== "equipment/b.equ|equipment/c.equ") throw new Error("setAll not applied");
const created = pvf.createFileset("新建文件集", ["equipment/a.equ"]);
if (created.name !== "新建文件集") throw new Error("wrong created name");
if (pvf.fileset("新建文件集").getAll().length !== 1) throw new Error("created set not readable");
const empty = pvf.createFileset("空文件集");
if (empty.getAll().length !== 0) throw new Error("empty set should have no paths");
if (pvf.fileset("不存在") !== null) throw new Error("unknown set should be null");
`)
changes := host.FileSetStage().Changes()
if len(changes) != 3 {
t.Fatalf("changes = %#v", changes)
}
byName := make(map[string]FileSetChange, len(changes))
for _, change := range changes {
byName[change.Name] = change
}
edited, ok := byName["默认文件集"]
if !ok || edited.Created || len(edited.Entries) != 2 {
t.Fatalf("edited change = %#v", edited)
}
// The surviving entry keeps its stored metadata across setAll.
if edited.Entries[0].Name != "b" || edited.Entries[0].Size != 20 {
t.Fatalf("surviving metadata lost: %#v", edited.Entries[0])
}
if created, ok := byName["新建文件集"]; !ok || !created.Created {
t.Fatalf("created change = %#v", created)
}
// The archive is untouched: a file set edit is not an archive mutation.
if archive.ModifiedCount() != 0 {
t.Fatalf("file set API changed the archive: %d", archive.ModifiedCount())
}
if staged, err := tx.Changes(); err != nil || len(staged) != 0 {
t.Fatalf("file set API staged archive changes = %#v err=%v", staged, err)
}
}
func TestGojaRuntimeFileSetErrorsAreReported(t *testing.T) {
archive := scriptTestArchive(t)
cases := []struct {
name string
source string
message string
}{
{"blank name", `pvf.fileset(" ");`, "文件集名称不能为空"},
{"duplicate create", `pvf.createFileset("a"); pvf.createFileset("a");`, "文件集已存在"},
{"setAll needs array", `pvf.createFileset("a").setAll("b.equ");`, "路径列表必须是数组"},
{"setAll rejects non-string", `pvf.createFileset("a").setAll([1]);`, "路径列表第 1 项必须是字符串"},
{"blank create name", `pvf.createFileset("");`, "文件集名称不能为空"},
}
for _, testCase := range cases {
// Each case needs its own stage: a create in one case would otherwise
// make the next case fail on a duplicate name instead of its own error.
host := NewBatchAPI(context.Background(), NewTransaction(archive), nil, nil)
host.SetFileSetStage(NewFileSetStage(nil))
result, err := NewGojaRuntime().Run(context.Background(), testCase.source, host)
if err != nil {
t.Fatalf("%s: unexpected error %v", testCase.name, err)
}
if result.Status != RunStatusFailed || result.Error == nil {
t.Fatalf("%s: result = %#v", testCase.name, result)
}
if !strings.Contains(result.Error.Message, testCase.message) {
t.Fatalf("%s: message = %q, want contains %q", testCase.name, result.Error.Message, testCase.message)
}
}
}
func TestGojaRuntimeFileSetUnavailableWithoutStage(t *testing.T) {
archive := scriptTestArchive(t)
host := NewBatchAPI(context.Background(), NewTransaction(archive), nil, nil)
result, err := NewGojaRuntime().Run(context.Background(), `pvf.fileset("默认文件集");`, host)
if err != nil {
t.Fatal(err)
}
if result.Status != RunStatusFailed || result.Error == nil {
t.Fatalf("result = %#v", result)
}
if !strings.Contains(result.Error.Message, "不支持文件集") {
t.Fatalf("message = %q", result.Error.Message)
}
}
File diff suppressed because it is too large. Load diff
+968
View File
@@ -0,0 +1,968 @@
package script
import (
"bytes"
"context"
"encoding/binary"
"strings"
"testing"
"time"
"pvfine/internal/pvf"
)
func TestGojaRuntimeStringReferences(t *testing.T) {
archive := pvf.New()
for _, file := range []struct {
path string
text string
kind int32
}{
{"list/n_string.lst", "31 `string/names.str`", pvf.TypeScript},
{"string/names.str", "first>Original\nsecond>Replacement\n", pvf.TypeUnicode},
{"equipment/item.equ", "[name]\n{8=`<31::first>`}\n[legacy]\n`literal`\n[multi]\n{10=`<31::first>`}\n[price]\n1", pvf.TypeScript},
} {
if _, err := archive.AddFileText(file.path, file.text, file.kind); err != nil {
t.Fatal(err)
}
}
tx := NewTransaction(archive)
host := NewBatchAPI(context.Background(), tx, nil, nil)
result, err := NewGojaRuntime().Run(context.Background(), `
const file = pvf.find("equipment/item.equ");
const doc = file.parse();
const name = doc.section("name");
if (name.get() !== "<31::first>") throw new Error("get changed");
if (name.getOriStrValue() !== "Original") throw new Error("section resolution");
if (doc.getOriStrValue("multi") !== "Original") throw new Error("type 10 resolution");
if (doc.getOriStrValue("legacy") !== "literal") throw new Error("legacy resolution");
if (doc.getOriStrValue("missing") !== undefined || name.getOriStrValue(3) !== undefined) throw new Error("missing value");
if (pvf.getOriStrValue("<31::missing>") !== "<31::missing>") throw new Error("missing reference");
if (pvf.getOriStrValue("prefix <31::first>") !== "prefix Original") throw new Error("direct resolution");
let rejected = false;
try { doc.getOriStrValue("price"); } catch { rejected = true; }
if (!rejected) throw new Error("numeric read accepted");
rejected = false;
try { name.setStrValue(123); } catch { rejected = true; }
if (!rejected) throw new Error("numeric reference accepted");
pvf.find("string/names.str").setText("first>Staged\nsecond>Replacement\n");
if (name.getOriStrValue() !== "Staged") throw new Error("stale table");
name.set({type: "block8", value: "<31::second>"});
if (name.getValue().type !== "block8") throw new Error("type 8 inference");
doc.section("multi").set({type: "block10", value: "<31::second>"});
doc.section("legacy").setStrValue("<31::second>");
if (doc.section("legacy").getValue().type !== "quoted") throw new Error("legacy conversion");
if (doc.setStrValue("missing", "<31::second>") !== false) throw new Error("missing set");
if (!doc.setStrValue("new", "<31::second>", {create: true})) throw new Error("create reference");
name.setStrValue("<31::first>");
file.write(doc);
const roundTrip = file.parse();
if (roundTrip.getOriStrValue("legacy") !== "Replacement") throw new Error("round trip");
if (roundTrip.getValue("multi").type !== "block10") throw new Error("type 10 write");
if (roundTrip.getValue("name").type !== "quoted" || roundTrip.getValue("new").type !== "quoted") throw new Error("forced legacy write");
if (!roundTrip.getValue("new").pool) throw new Error("pool metadata");
`, host)
if err != nil || result.Status != RunStatusCompleted {
t.Fatalf("result = %#v diagnostic=%#v err=%v", result, result.Error, err)
}
if archive.ResolvePlaceholder("<31::first>") != "Original" {
t.Fatal("preview mutated live string table")
}
if _, err := tx.Commit(map[string]struct{}{"equipment/item.equ": {}, "string/names.str": {}}); err != nil {
t.Fatal(err)
}
index, _ := archive.Find("equipment/item.equ")
raw, err := archive.RawBytes(index)
if err != nil {
t.Fatal(err)
}
var types []byte
for pos := 0; pos+5 <= len(raw); pos += 5 {
if raw[pos] == 8 || raw[pos] == 10 {
types = append(types, raw[pos])
if text := archive.ResolveString(int32(binary.LittleEndian.Uint32(raw[pos+1:]))); text != "<31::first>" && text != "<31::second>" {
t.Fatalf("reference = %q", text)
}
}
}
if !bytes.Equal(types, []byte{10}) {
t.Fatalf("reference token types = %v", types)
}
}
func scriptTestArchive(t *testing.T) *pvf.Archive {
t.Helper()
built := pvf.New()
if _, err := built.AddFileText("equipment/a.equ", "[price]\n100\n[name]\n`a`", pvf.TypeScript); err != nil {
t.Fatal(err)
}
if _, err := built.AddFileText("equipment/b.equ", "[price]\n200\n[name]\n`b`", pvf.TypeScript); err != nil {
t.Fatal(err)
}
var data bytes.Buffer
if err := built.SaveTo(&data); err != nil {
t.Fatal(err)
}
archive, err := pvf.Parse(data.Bytes())
if err != nil {
t.Fatal(err)
}
return archive
}
func TestGojaRuntimeStructuredScript(t *testing.T) {
archive := scriptTestArchive(t)
tx := NewTransaction(archive)
host := NewBatchAPI(context.Background(), tx, nil, nil)
result, err := NewGojaRuntime().Run(context.Background(), `
const files = pvf.glob("equipment/**/*.equ");
if (files.length !== 2) throw new Error("unexpected file count");
for (let i = 0; i < files.length; i++) {
const file = files[i];
const doc = file.parse();
const price = doc.section("price");
if (!price) throw new Error("missing price");
if (price.getValue(0).type !== "integer") throw new Error("wrong type");
price.set(price.get(0) + 50);
file.write(doc);
}
if (pvf.modifiedCount !== 2) throw new Error("wrong modified count");
`, host)
if err != nil {
t.Fatal(err)
}
if result.Status != RunStatusCompleted || result.Error != nil {
t.Fatalf("run result = %#v error=%#v", result, result.Error)
}
changes, err := tx.Changes()
if err != nil || len(changes) != 2 {
t.Fatalf("changes = %#v err=%v", changes, err)
}
for _, change := range changes {
if change.Kind != pvf.ChangeKindChanged {
t.Fatalf("change kind = %q", change.Kind)
}
if !strings.Contains(change.AfterText, "150") && !strings.Contains(change.AfterText, "250") {
t.Fatalf("staged text = %q", change.AfterText)
}
}
if archive.ModifiedCount() != 0 {
t.Fatalf("runtime changed live archive: %d", archive.ModifiedCount())
}
}
func TestGojaRuntimeParseWarningsAreNonFatalAndLogged(t *testing.T) {
archive := pvf.New()
if _, err := archive.AddFileText("equipment/malformed.equ", "[parent]\n1\n[/orphan]\n2\n[/parent]", pvf.TypeScript); err != nil {
t.Fatal(err)
}
tx := NewTransaction(archive)
var logs []LogEntry
host := NewBatchAPI(context.Background(), tx, func(entry LogEntry) {
logs = append(logs, entry)
}, nil)
result, err := NewGojaRuntime().Run(context.Background(), `
const file = pvf.find("equipment/malformed.equ");
const document = file.parse();
const warnings = document.warnings();
if (warnings.length !== 1 || warnings[0].code !== "orphan-close") throw new Error("warning was not exposed");
if (warnings[0].tokenIndex !== 2 || warnings[0].path[0] !== "parent") throw new Error("warning location was not exposed");
file.write(document);
`, host)
if err != nil || result.Status != RunStatusCompleted {
t.Fatalf("warning result = %#v error=%#v err=%v", result, result.Error, err)
}
if len(logs) != 1 || logs[0].Level != LogLevelWarn || !strings.Contains(logs[0].Message, "orphan-close") {
t.Fatalf("warning logs = %#v", logs)
}
if changes, changedErr := tx.Changes(); changedErr != nil || len(changes) != 0 {
t.Fatalf("warning-only write changed entries = %#v err=%v", changes, changedErr)
}
}
func TestGojaRuntimeLogsAndProgress(t *testing.T) {
archive := scriptTestArchive(t)
tx := NewTransaction(archive)
var logs []LogEntry
var progress []Progress
host := NewBatchAPI(context.Background(), tx, func(entry LogEntry) {
logs = append(logs, entry)
}, func(value Progress) {
progress = append(progress, value)
})
result, err := NewGojaRuntime().Run(context.Background(), `
pvf.log("hello", 3);
console.warn("careful");
pvf.progress(1, 2, "scan");
`, host)
if err != nil || result.Status != RunStatusCompleted {
t.Fatalf("log result = %#v error=%#v err=%v", result, result.Error, err)
}
if len(logs) != 2 || logs[0].Level != LogLevelInfo || logs[0].Message != "hello 3" || logs[1].Level != LogLevelWarn {
t.Fatalf("logs = %#v", logs)
}
if len(progress) != 1 || progress[0].Done != 1 || progress[0].Total != 2 || progress[0].Message != "scan" {
t.Fatalf("progress = %#v", progress)
}
}
func TestGojaCompileDiagnostics(t *testing.T) {
result := NewGojaRuntime().Compile("const =")
if result.Valid || len(result.Diagnostics) == 0 {
t.Fatalf("compile result = %#v", result)
}
if result.Diagnostics[0].Kind != ErrorKindCompile || result.Diagnostics[0].Line <= 0 {
t.Fatalf("compile diagnostic = %#v", result.Diagnostics[0])
}
}
func TestGojaRuntimeDuplicateSelectionAndExactValue(t *testing.T) {
built := pvf.New()
if _, err := built.AddFileText("equipment/item.equ", "[price]\n100\n[price]\n200", pvf.TypeScript); err != nil {
t.Fatal(err)
}
var data bytes.Buffer
if err := built.SaveTo(&data); err != nil {
t.Fatal(err)
}
archive, err := pvf.Parse(data.Bytes())
if err != nil {
t.Fatal(err)
}
tx := NewTransaction(archive)
host := NewBatchAPI(context.Background(), tx, nil, nil)
result, err := NewGojaRuntime().Run(context.Background(), `
const file = pvf.find("EQUIPMENT/ITEM.EQU");
const doc = file.parse();
const prices = doc.sections("price");
if (prices.length !== 2 || prices[1].occurrence !== 1) throw new Error("bad occurrences");
prices[1].set({ type: "float", value: 3.5 });
file.write(doc);
`, host)
if err != nil || result.Status != RunStatusCompleted {
t.Fatalf("run result = %#v error=%#v err=%v", result, result.Error, err)
}
changes, err := tx.Changes()
if err != nil || len(changes) != 1 {
t.Fatalf("staged duplicate changes = %#v err=%v", changes, err)
}
if text := changes[0].AfterText; !strings.Contains(text, "3.5") || strings.Contains(text, "200") {
t.Fatalf("staged duplicate text = %q", text)
}
}
func TestGojaRuntimeUnicodeTextAndCrossFileDocumentGuard(t *testing.T) {
built := pvf.New()
if _, err := built.AddFileText("text/name.str", "初始", pvf.TypeUnicode); err != nil {
t.Fatal(err)
}
if _, err := built.AddFileText("equipment/item.equ", "[price]\n1", pvf.TypeScript); err != nil {
t.Fatal(err)
}
var data bytes.Buffer
if err := built.SaveTo(&data); err != nil {
t.Fatal(err)
}
archive, err := pvf.Parse(data.Bytes())
if err != nil {
t.Fatal(err)
}
tx := NewTransaction(archive)
host := NewBatchAPI(context.Background(), tx, nil, nil)
result, err := NewGojaRuntime().Run(context.Background(), `
const unicode = pvf.find("text/name.str");
if (unicode.type !== pvf.types.unicode || unicode.text() !== "初始") throw new Error("unicode read failed");
unicode.setText("更新");
if (unicode.text() !== "更新") throw new Error("unicode write failed");
let parseRejected = false;
try { unicode.parse(); } catch { parseRejected = true; }
if (!parseRejected) throw new Error("unicode parse was accepted");
const first = pvf.find("equipment/item.equ");
const document = first.parse();
try { unicode.write(document); throw new Error("cross-file document was accepted"); } catch (error) {
if (!String(error).includes("不属于当前文件")) throw error;
}
`, host)
if err != nil || result.Status != RunStatusCompleted {
t.Fatalf("unicode result = %#v error=%#v err=%v", result, result.Error, err)
}
changes, changeErr := tx.Changes()
if changeErr != nil || len(changes) != 1 || changes[0].Path != "text/name.str" || changes[0].AfterText != "更新" {
t.Fatalf("unicode staged change = %#v err=%v", changes, changeErr)
}
if archive.ModifiedCount() != 0 {
t.Fatal("unicode runtime changed live archive")
}
}
func TestGojaRuntimeRollbackAndSecurity(t *testing.T) {
archive := scriptTestArchive(t)
tx := NewTransaction(archive)
host := NewBatchAPI(context.Background(), tx, nil, nil)
result, err := NewGojaRuntime().Run(context.Background(), `
if (typeof require !== "undefined" || typeof process !== "undefined" || typeof fetch !== "undefined") {
throw new Error("host capability leaked");
}
pvf.find("equipment/a.equ").setText("changed");
throw new Error("rollback");
`, host)
if err != nil {
t.Fatal(err)
}
if result.Status != RunStatusFailed || result.Error == nil || !strings.Contains(result.Error.Message, "rollback") {
t.Fatalf("failure result = %#v error=%#v", result, result.Error)
}
if archive.ModifiedCount() != 0 {
t.Fatalf("failed script changed live archive: %d", archive.ModifiedCount())
}
// The service owns rollback by dropping the transaction; the staged change
// is intentionally observable here so the test proves it never crossed the
// transaction boundary.
if changes, changedErr := tx.Changes(); changedErr != nil || len(changes) != 1 {
t.Fatalf("staged rollback candidate = %#v err=%v", changes, changedErr)
}
}
func TestGojaRuntimeInterruptsOnContext(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
result, err := NewGojaRuntime().Run(ctx, `for (;;) {}`, NewBatchAPI(ctx, NewTransaction(scriptTestArchive(t)), nil, nil))
if err != nil {
t.Fatal(err)
}
if result.Status != RunStatusCancelled || result.Error == nil || result.Error.Kind != ErrorKindTimeout {
t.Fatalf("interrupt result = %#v", result)
}
}
func TestPathPatternMatch(t *testing.T) {
for _, test := range []struct {
pattern string
value string
want bool
}{
{pattern: "equipment/**/*.equ", value: "equipment/a.equ", want: true},
{pattern: "equipment/**/*.equ", value: "equipment/character/a.equ", want: true},
{pattern: "equipment/*.equ", value: "equipment/character/a.equ", want: false},
{pattern: "EQUIPMENT/**/A.EQU", value: "equipment/character/a.equ", want: true},
} {
if got := PathPatternMatch(test.pattern, test.value); got != test.want {
t.Errorf("PathPatternMatch(%q, %q) = %v, want %v", test.pattern, test.value, got, test.want)
}
}
}
func TestGojaRuntimeCreateCopyDeleteFiles(t *testing.T) {
archive := scriptTestArchive(t)
tx := NewTransaction(archive)
host := NewBatchAPI(context.Background(), tx, nil, nil)
result, err := NewGojaRuntime().Run(context.Background(), `
const created = pvf.createFile("equipment/new.equ", pvf.types.script, "[price]\n500");
if (!created || created.path !== "equipment/new.equ") throw new Error("create did not return a handle");
if (created.type !== pvf.types.script) throw new Error("wrong created type");
const found = pvf.find("equipment/new.equ");
if (!found || !found.text().includes("500")) throw new Error("created file not found");
const copied = pvf.copyFile("equipment/a.equ", "equipment/copy.equ");
if (!copied || copied.type !== pvf.types.script) throw new Error("copy lost its data type");
if (!pvf.find("equipment/copy.equ").text().includes("100")) throw new Error("copy content mismatch");
let rejected = false;
try { pvf.copyFile("equipment/a.equ", "equipment/b.equ"); } catch { rejected = true; }
if (!rejected) throw new Error("copy overwrote without the overwrite flag");
pvf.copyFile("equipment/a.equ", "equipment/b.equ", true);
if (!pvf.find("equipment/b.equ").text().includes("100")) throw new Error("overwrite did not apply");
if (pvf.deleteFile("equipment/b.equ") !== true) throw new Error("delete reported no removal");
if (pvf.find("equipment/b.equ") !== null) throw new Error("deleted file still resolves");
if (pvf.deleteFile("equipment/b.equ") !== false) throw new Error("second delete should be a no-op");
`, host)
if err != nil || result.Status != RunStatusCompleted {
t.Fatalf("run result = %#v error=%#v err=%v", result, result.Error, err)
}
changes, err := tx.Changes()
if err != nil {
t.Fatal(err)
}
kinds := make(map[string]string, len(changes))
for _, change := range changes {
kinds[change.Normalized] = change.Kind
}
if kinds["equipment/new.equ"] != pvf.ChangeKindCreated {
t.Fatalf("new file kind = %q changes=%#v", kinds["equipment/new.equ"], changes)
}
if kinds["equipment/copy.equ"] != pvf.ChangeKindCreated {
t.Fatalf("copy kind = %q changes=%#v", kinds["equipment/copy.equ"], changes)
}
// b.equ was overwritten first and then deleted, so the final intent is a
// removal; the overwrite itself was asserted inside the script.
if kinds["equipment/b.equ"] != pvf.ChangeKindDeleted {
t.Fatalf("deleted file kind = %q changes=%#v", kinds["equipment/b.equ"], changes)
}
if archive.ModifiedCount() != 0 {
t.Fatalf("runtime changed live archive: %d", archive.ModifiedCount())
}
}
func TestGojaRuntimeCreateFilesInNewDirectories(t *testing.T) {
archive := scriptTestArchive(t)
tx := NewTransaction(archive)
host := NewBatchAPI(context.Background(), tx, nil, nil)
result, err := NewGojaRuntime().Run(context.Background(), `
// Neither brand/ nor copy/ exists in the fixture; both calls must
// introduce the missing directory chain instead of failing.
const created = pvf.createFile("brand/new/deep/added.equ", pvf.types.script, "[price]\n500");
if (created.path !== "brand/new/deep/added.equ") throw new Error("nested create lost its path");
const found = pvf.find("brand/new/deep/added.equ");
if (!found || !found.text().includes("500")) throw new Error("nested create not readable");
const copied = pvf.copyFile("equipment/a.equ", "copy/nested/dir/dup.equ");
if (copied.path !== "copy/nested/dir/dup.equ") throw new Error("nested copy lost its path");
if (!pvf.find("copy/nested/dir/dup.equ").text().includes("100")) throw new Error("nested copy content mismatch");
`, host)
if err != nil || result.Status != RunStatusCompleted {
t.Fatalf("run result = %#v error=%#v err=%v", result, result.Error, err)
}
changes, err := tx.Changes()
if err != nil {
t.Fatal(err)
}
kinds := make(map[string]string, len(changes))
for _, change := range changes {
kinds[change.Normalized] = change.Kind
}
for _, path := range []string{"brand/new/deep/added.equ", "copy/nested/dir/dup.equ"} {
if kinds[path] != pvf.ChangeKindCreated {
t.Fatalf("kind for %s = %q changes=%#v", path, kinds[path], changes)
}
}
}
func TestGojaRuntimeDeleteThenCreateSamePath(t *testing.T) {
archive := scriptTestArchive(t)
tx := NewTransaction(archive)
host := NewBatchAPI(context.Background(), tx, nil, nil)
result, err := NewGojaRuntime().Run(context.Background(), `
pvf.deleteFile("equipment/a.equ");
const recreated = pvf.createFile("equipment/a.equ", pvf.types.script, "[price]\n999");
if (recreated.text().includes("100")) throw new Error("recreated file kept old content");
`, host)
if err != nil || result.Status != RunStatusCompleted {
t.Fatalf("run result = %#v error=%#v err=%v", result, result.Error, err)
}
changes, err := tx.Changes()
if err != nil || len(changes) != 1 {
t.Fatalf("changes = %#v err=%v", changes, err)
}
if changes[0].Kind != pvf.ChangeKindCreated || !strings.Contains(changes[0].AfterText, "999") {
t.Fatalf("recreated change = %#v", changes[0])
}
}
func TestGojaRuntimeCreateThenDeleteLeavesNoChange(t *testing.T) {
archive := scriptTestArchive(t)
tx := NewTransaction(archive)
host := NewBatchAPI(context.Background(), tx, nil, nil)
result, err := NewGojaRuntime().Run(context.Background(), `
pvf.createFile("equipment/temporary.equ", pvf.types.script, "[price]\n1");
if (pvf.deleteFile("equipment/temporary.equ") !== true) throw new Error("delete failed");
if (pvf.modifiedCount !== 0) throw new Error("create+delete should net to zero, got " + pvf.modifiedCount);
`, host)
if err != nil || result.Status != RunStatusCompleted {
t.Fatalf("run result = %#v error=%#v err=%v", result, result.Error, err)
}
if changes, changeErr := tx.Changes(); changeErr != nil || len(changes) != 0 {
t.Fatalf("create+delete staged a change = %#v err=%v", changes, changeErr)
}
// The live archive must not gain or lose an entry on commit.
before := archive.FileCount()
if _, err := tx.Commit(map[string]struct{}{"equipment/temporary.equ": {}}); err != nil {
t.Fatal(err)
}
if archive.FileCount() != before {
t.Fatalf("file count = %d want %d", archive.FileCount(), before)
}
}
func TestGojaRuntimeStaleHandleAfterDelete(t *testing.T) {
archive := scriptTestArchive(t)
tx := NewTransaction(archive)
host := NewBatchAPI(context.Background(), tx, nil, nil)
result, err := NewGojaRuntime().Run(context.Background(), `
const stale = pvf.find("equipment/a.equ");
pvf.deleteFile("equipment/b.equ");
let rejected = false;
try { stale.setText("should not land"); } catch (error) {
if (!String(error).includes("句柄已失效")) throw error;
rejected = true;
}
if (!rejected) throw new Error("stale handle was accepted after a delete");
`, host)
if err != nil || result.Status != RunStatusCompleted {
t.Fatalf("run result = %#v error=%#v err=%v", result, result.Error, err)
}
changes, err := tx.Changes()
if err != nil {
t.Fatal(err)
}
for _, change := range changes {
if change.Normalized == "equipment/a.equ" {
t.Fatalf("stale handle mutated an unrelated entry: %#v", change)
}
}
}
func TestGojaRuntimeMultipleDeletesUsePathIdentity(t *testing.T) {
built := pvf.New()
for _, path := range []string{"equipment/a.equ", "equipment/b.equ", "equipment/c.equ"} {
if _, err := built.AddFileText(path, "[price]\n1", pvf.TypeScript); err != nil {
t.Fatal(err)
}
}
var data bytes.Buffer
if err := built.SaveTo(&data); err != nil {
t.Fatal(err)
}
archive, err := pvf.Parse(data.Bytes())
if err != nil {
t.Fatal(err)
}
tx := NewTransaction(archive)
host := NewBatchAPI(context.Background(), tx, nil, nil)
// Delete from the middle outward so every removal renumbers later entries;
// path-addressed staging must still remove exactly the requested paths.
result, err := NewGojaRuntime().Run(context.Background(), `
pvf.deleteFile("equipment/a.equ");
pvf.deleteFile("equipment/c.equ");
if (pvf.find("equipment/a.equ") !== null || pvf.find("equipment/c.equ") !== null) {
throw new Error("deleted paths still resolve");
}
const survivor = pvf.find("equipment/b.equ");
if (!survivor) throw new Error("survivor disappeared");
survivor.setText("[price]\n77");
`, host)
if err != nil || result.Status != RunStatusCompleted {
t.Fatalf("run result = %#v error=%#v err=%v", result, result.Error, err)
}
if _, err := tx.Commit(map[string]struct{}{
"equipment/a.equ": {},
"equipment/c.equ": {},
"equipment/b.equ": {},
}); err != nil {
t.Fatal(err)
}
if _, ok := archive.Find("equipment/a.equ"); ok {
t.Fatal("a.equ survived commit")
}
if _, ok := archive.Find("equipment/c.equ"); ok {
t.Fatal("c.equ survived commit")
}
survivor, ok := archive.Find("equipment/b.equ")
if !ok {
t.Fatal("b.equ was removed by mistake")
}
if text, textErr := archive.Text(survivor); textErr != nil || !strings.Contains(text, "77") {
t.Fatalf("survivor text = %q err=%v", text, textErr)
}
}
// scriptListArchive builds an archive containing an equipment .lst plus the
// files its entries point at.
func scriptListArchive(t *testing.T) *pvf.Archive {
t.Helper()
built := pvf.New()
if _, err := built.AddFileText(
"equipment/equipment.lst",
"1008 `character/a.equ` 1009 `character/b.equ`",
pvf.TypeScript,
); err != nil {
t.Fatal(err)
}
for _, path := range []string{
"equipment/character/a.equ",
"equipment/character/b.equ",
"equipment/character/c.equ",
} {
if _, err := built.AddFileText(path, "[name]\n`x`", pvf.TypeScript); err != nil {
t.Fatal(err)
}
}
var data bytes.Buffer
if err := built.SaveTo(&data); err != nil {
t.Fatal(err)
}
archive, err := pvf.Parse(data.Bytes())
if err != nil {
t.Fatal(err)
}
return archive
}
func TestGojaRuntimeListReadAndWrite(t *testing.T) {
archive := scriptListArchive(t)
tx := NewTransaction(archive)
host := NewBatchAPI(context.Background(), tx, nil, nil)
result, err := NewGojaRuntime().Run(context.Background(), `
const lst = pvf.lst("equipment/equipment.lst");
const entries = lst.get();
if (Object.keys(entries).length !== 2) throw new Error("unexpected entry count");
if (entries["1008"] !== "character/a.equ") throw new Error("wrong entry: " + entries["1008"]);
// Update an existing id in place.
lst.set("1008", "character/c.equ");
// Append a new id.
lst.set("1010", "character/a.equ");
// mset handles several ids at once.
lst.mset({ "1009": "character/c.equ", "1011": "character/b.equ" });
const after = lst.get();
if (after["1008"] !== "character/c.equ") throw new Error("update failed");
if (after["1009"] !== "character/c.equ") throw new Error("mset update failed");
if (after["1010"] !== "character/a.equ") throw new Error("append failed");
if (after["1011"] !== "character/b.equ") throw new Error("mset append failed");
if (Object.keys(after).length !== 4) throw new Error("wrong final count");
// getId resolves the reverse direction.
if (lst.getId("character/c.equ") !== "1008") throw new Error("getId mismatch");
if (lst.getId("equipment/character/b.equ") !== "1011") throw new Error("getId did not accept an archive path");
if (lst.getId("character/missing.equ") !== null) throw new Error("getId should return null when unregistered");
// unset reports whether anything was removed.
if (lst.unset("1011") !== true) throw new Error("unset reported no removal");
if (lst.unset("1011") !== false) throw new Error("second unset should be a no-op");
if (lst.get()["1011"] !== undefined) throw new Error("entry survived unset");
`, host)
if err != nil || result.Status != RunStatusCompleted {
t.Fatalf("run result = %#v error=%#v err=%v", result, result.Error, err)
}
changes, err := tx.Changes()
if err != nil || len(changes) != 1 {
t.Fatalf("changes = %#v err=%v", changes, err)
}
if changes[0].Kind != pvf.ChangeKindChanged || changes[0].Path != "equipment/equipment.lst" {
t.Fatalf("change = %#v", changes[0])
}
if archive.ModifiedCount() != 0 {
t.Fatalf("runtime changed the live archive: %d", archive.ModifiedCount())
}
// Staging keeps the edits isolated from the live list.
liveIndex, _ := archive.Find("equipment/equipment.lst")
livePairs, err := archive.ListPairs(liveIndex)
if err != nil || len(livePairs) != 2 {
t.Fatalf("live pairs changed: %#v err=%v", livePairs, err)
}
}
func TestGojaRuntimeListIdCollapsedOnSet(t *testing.T) {
built := pvf.New()
if _, err := built.AddFileText(
"equipment/equipment.lst",
"1008 `character/a.equ` 1009 `character/b.equ` 1008 `character/c.equ`",
pvf.TypeScript,
); err != nil {
t.Fatal(err)
}
for _, path := range []string{"equipment/character/a.equ", "equipment/character/b.equ", "equipment/character/c.equ"} {
if _, err := built.AddFileText(path, "[name]\n`x`", pvf.TypeScript); err != nil {
t.Fatal(err)
}
}
var data bytes.Buffer
if err := built.SaveTo(&data); err != nil {
t.Fatal(err)
}
archive, err := pvf.Parse(data.Bytes())
if err != nil {
t.Fatal(err)
}
tx := NewTransaction(archive)
host := NewBatchAPI(context.Background(), tx, nil, nil)
result, err := NewGojaRuntime().Run(context.Background(), `
const lst = pvf.lst("equipment/equipment.lst");
lst.set("1008", "character/b.equ");
const entries = lst.get();
if (Object.keys(entries).length !== 2) throw new Error("duplicate id was not collapsed");
// unset removes every remaining record for the id.
if (lst.unset("1008") !== true) throw new Error("unset failed");
if (lst.get()["1008"] !== undefined) throw new Error("entry survived unset");
`, host)
if err != nil || result.Status != RunStatusCompleted {
t.Fatalf("run result = %#v error=%#v err=%v", result, result.Error, err)
}
}
func TestGojaRuntimeListRejectsMissingTargetAndList(t *testing.T) {
archive := scriptListArchive(t)
tx := NewTransaction(archive)
host := NewBatchAPI(context.Background(), tx, nil, nil)
result, err := NewGojaRuntime().Run(context.Background(), `
const lst = pvf.lst("equipment/equipment.lst");
// A missing target file must be refused instead of registering a dangling entry.
let rejected = false;
try { lst.set("2000", "character/does-not-exist.equ"); } catch (error) {
if (!String(error).includes("不存在")) throw error;
rejected = true;
}
if (!rejected) throw new Error("set accepted a missing target");
// Lookup does not require the file to exist, so the reverse query stays available.
if (lst.getId("character/does-not-exist.equ") !== null) throw new Error("getId should be null");
// The failed write must not have changed anything.
if (Object.keys(lst.get()).length !== 2) throw new Error("failed set mutated the list");
// An unknown list file is an error.
let missingList = false;
try { pvf.lst("equipment/nope.lst"); } catch { missingList = true; }
if (!missingList) throw new Error("unknown list file was accepted");
`, host)
if err != nil || result.Status != RunStatusCompleted {
t.Fatalf("run result = %#v error=%#v err=%v", result, result.Error, err)
}
if changes, changeErr := tx.Changes(); changeErr != nil || len(changes) != 0 {
t.Fatalf("rejected list writes staged a change = %#v err=%v", changes, changeErr)
}
}
func TestGojaRuntimeListAbsolutePathRebased(t *testing.T) {
archive := scriptListArchive(t)
tx := NewTransaction(archive)
// The archive path resolves to the same list-relative entry, so it is
// accepted and stored in the file's native relative form.
host := NewBatchAPI(context.Background(), tx, nil, nil)
result, err := NewGojaRuntime().Run(context.Background(), `
const lst = pvf.lst("equipment/equipment.lst");
lst.set("2000", "equipment/character/a.equ");
lst.mset({"2001": "equipment/character/b.equ"});
if (pvf.lst("list/equipment.lst").path !== lst.path) throw new Error("legacy list lookup");
const stored = lst.getId("character/a.equ");
if (stored !== "2000" && stored !== "1008") throw new Error("absolute path was not rebased: " + stored);
if (lst.get()["2000"] !== "character/a.equ") throw new Error("stored value is not list-relative");
if (lst.get()["2001"] !== "character/b.equ") throw new Error("mset value is not list-relative");
`, host)
if err != nil || result.Status != RunStatusCompleted {
t.Fatalf("run result = %#v error=%#v err=%v", result, result.Error, err)
}
}
func TestGojaRuntimeListRootRelativePaths(t *testing.T) {
built := pvf.New()
if _, err := built.AddFileText("list/equipment.lst", "1008 `equipment/character/a.equ` 1009 `equipment/missing.equ`", pvf.TypeScript); err != nil {
t.Fatal(err)
}
for _, filePath := range []string{
"equipment/character/a.equ", "equipment/character/b.equ",
"equipment/character/(r)c.equ", "list/equipment/character/a.equ", "list/local.equ",
} {
if _, err := built.AddFileText(filePath, "[name]\n`x`", pvf.TypeScript); err != nil {
t.Fatal(err)
}
}
var data bytes.Buffer
if err := built.SaveTo(&data); err != nil {
t.Fatal(err)
}
archive, err := pvf.Parse(data.Bytes())
if err != nil {
t.Fatal(err)
}
tx := NewTransaction(archive)
host := NewBatchAPI(context.Background(), tx, nil, nil)
result, err := NewGojaRuntime().Run(context.Background(), `
const lst = pvf.lst("EQUIPMENT/EQUIPMENT.LST");
if (lst.path !== "list/equipment.lst") throw new Error("list layout lookup");
if (lst.getId("equipment\\character\\a.equ") !== "1008") throw new Error("root getId");
if (lst.getId("equipment/missing.equ") !== "1009") throw new Error("missing target lookup");
lst.set("1008", "/equipment/character/b.equ/");
lst.mset({
"1010": "equipment/character/a.equ",
"1011": "equipment/character/c.equ",
"1012": "list/equipment/character/a.equ",
"1013": "local.equ"
});
const stored = lst.get();
if (stored["1008"] !== "equipment/character/b.equ") throw new Error("set rebased root path");
if (stored["1010"] !== "equipment/character/a.equ") throw new Error("mset rebased root path");
if (stored["1011"] !== "equipment/character/c.equ") throw new Error("(r) lookup");
if (stored["1012"] !== "list/equipment/character/a.equ") throw new Error("list prefix stripped");
if (stored["1013"] !== "list/local.equ") throw new Error("list-relative input");
if (lst.getId("local.equ") !== "1013" || lst.getId("list/local.equ") !== "1013") throw new Error("relative getId");
let rejected = false;
try { lst.mset({"2000": "equipment/character/a.equ", "2001": "equipment/nope.equ"}); } catch { rejected = true; }
if (!rejected || lst.get()["2000"] !== undefined) throw new Error("partial mset");
const seen = [];
lst.forEach((id, path) => seen.push(path));
if (!seen.includes("list/equipment/character/a.equ")) throw new Error("forEach changed storage");
`, host)
if err != nil || result.Status != RunStatusCompleted {
t.Fatalf("result = %#v diagnostic=%#v err=%v", result, result.Error, err)
}
changes, err := tx.Changes()
if err != nil || len(changes) != 1 || changes[0].Path != "list/equipment.lst" {
t.Fatalf("changes = %#v err=%v", changes, err)
}
if _, err := tx.Commit(map[string]struct{}{"list/equipment.lst": {}}); err != nil {
t.Fatal(err)
}
index, _ := archive.Find("list/equipment.lst")
pairs, err := archive.ListPairs(index)
if err != nil || len(pairs) != 6 || pairs[0].Path != "equipment/character/b.equ" || pairs[4].Path != "list/equipment/character/a.equ" {
t.Fatalf("committed pairs = %#v err=%v", pairs, err)
}
}
func TestGojaRuntimeListRejectsInvalidMSetArgument(t *testing.T) {
archive := scriptListArchive(t)
tx := NewTransaction(archive)
host := NewBatchAPI(context.Background(), tx, nil, nil)
result, err := NewGojaRuntime().Run(context.Background(), `
const lst = pvf.lst("equipment/equipment.lst");
let rejected = false;
try { lst.mset("not-an-object"); } catch { rejected = true; }
if (!rejected) throw new Error("mset accepted a non-object");
`, host)
if err != nil || result.Status != RunStatusCompleted {
t.Fatalf("run result = %#v error=%#v err=%v", result, result.Error, err)
}
if changes, changeErr := tx.Changes(); changeErr != nil || len(changes) != 0 {
t.Fatalf("rejected mset staged a change = %#v err=%v", changes, changeErr)
}
}
func TestGojaRuntimeListRollbackLeavesLiveArchiveUntouched(t *testing.T) {
archive := scriptListArchive(t)
index, _ := archive.Find("equipment/equipment.lst")
before, err := archive.RawBytes(index)
if err != nil {
t.Fatal(err)
}
tx := NewTransaction(archive)
host := NewBatchAPI(context.Background(), tx, nil, nil)
result, err := NewGojaRuntime().Run(context.Background(), `
pvf.lst("equipment/equipment.lst").set("1008", "character/c.equ");
throw new Error("abort");
`, host)
if err != nil {
t.Fatal(err)
}
if result.Status != RunStatusFailed {
t.Fatalf("run result = %#v", result)
}
after, err := archive.RawBytes(index)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(before, after) {
t.Fatal("failed script mutated the live list")
}
}
func TestGojaRuntimeListForEachIteratesInFileOrder(t *testing.T) {
// Ids are deliberately out of numeric order so the test can prove the
// callback follows the .lst file order rather than sorted keys.
built := pvf.New()
if _, err := built.AddFileText(
"equipment/equipment.lst",
"3000 `character/a.equ` 1008 `character/b.equ` 2000 `character/c.equ`",
pvf.TypeScript,
); err != nil {
t.Fatal(err)
}
for _, path := range []string{"equipment/character/a.equ", "equipment/character/b.equ", "equipment/character/c.equ"} {
if _, err := built.AddFileText(path, "[name]\n`x`", pvf.TypeScript); err != nil {
t.Fatal(err)
}
}
var data bytes.Buffer
if err := built.SaveTo(&data); err != nil {
t.Fatal(err)
}
archive, err := pvf.Parse(data.Bytes())
if err != nil {
t.Fatal(err)
}
tx := NewTransaction(archive)
host := NewBatchAPI(context.Background(), tx, nil, nil)
result, err := NewGojaRuntime().Run(context.Background(), `
const seen = [];
pvf.lst("equipment/equipment.lst").forEach((id, path) => {
seen.push(id + "=" + path);
});
const expected = ["3000=character/a.equ", "1008=character/b.equ", "2000=character/c.equ"];
if (JSON.stringify(seen) !== JSON.stringify(expected)) {
throw new Error("forEach order = " + JSON.stringify(seen));
}
`, host)
if err != nil || result.Status != RunStatusCompleted {
t.Fatalf("run result = %#v error=%#v err=%v", result, result.Error, err)
}
}
func TestGojaRuntimeListForEachValidationAndMutation(t *testing.T) {
archive := scriptListArchive(t)
tx := NewTransaction(archive)
host := NewBatchAPI(context.Background(), tx, nil, nil)
result, err := NewGojaRuntime().Run(context.Background(), `
const lst = pvf.lst("equipment/equipment.lst");
// A missing or non-callable argument is an error.
let rejected = false;
try { lst.forEach("not-a-function"); } catch { rejected = true; }
if (!rejected) throw new Error("forEach accepted a non-function");
// The callback may edit the list it is iterating; the snapshot stays stable.
const touched = [];
lst.forEach((id) => { touched.push(id); lst.unset(id); });
if (touched.length !== 2) throw new Error("forEach did not visit every entry");
if (Object.keys(lst.get()).length !== 0) throw new Error("entries survived unset");
`, host)
if err != nil || result.Status != RunStatusCompleted {
t.Fatalf("run result = %#v error=%#v err=%v", result, result.Error, err)
}
}
func TestGojaRuntimeListAcceptsNumericIDs(t *testing.T) {
archive := scriptListArchive(t)
tx := NewTransaction(archive)
host := NewBatchAPI(context.Background(), tx, nil, nil)
// .lst ids are integers in the token stream, so a JS number must be
// accepted and normalised to the same string key.
result, err := NewGojaRuntime().Run(context.Background(), `
const lst = pvf.lst("equipment/equipment.lst");
lst.set(2000, "character/c.equ");
if (lst.get()["2000"] !== "character/c.equ") throw new Error("numeric id was not normalised");
if (lst.getId("character/c.equ") !== "2000") throw new Error("getId mismatch");
if (lst.unset(2000) !== true) throw new Error("unset rejected a numeric id");
if (lst.get()["2000"] !== undefined) throw new Error("numeric-id entry survived unset");
// Existing integer ids can also be addressed as numbers.
lst.set(1008, "character/c.equ");
if (lst.get()["1008"] !== "character/c.equ") throw new Error("existing numeric id was not updated");
`, host)
if err != nil || result.Status != RunStatusCompleted {
t.Fatalf("run result = %#v error=%#v err=%v", result, result.Error, err)
}
}
func TestGojaRuntimeCreateRejectsInvalidPaths(t *testing.T) {
archive := scriptTestArchive(t)
tx := NewTransaction(archive)
host := NewBatchAPI(context.Background(), tx, nil, nil)
result, err := NewGojaRuntime().Run(context.Background(), `
for (const path of ["", " ", "../escape.equ", "dir/../escape.equ", "equipment/a.equ"]) {
let rejected = false;
try { pvf.createFile(path, pvf.types.script); } catch { rejected = true; }
if (!rejected) throw new Error("create accepted " + JSON.stringify(path));
}
`, host)
if err != nil || result.Status != RunStatusCompleted {
t.Fatalf("run result = %#v error=%#v err=%v", result, result.Error, err)
}
if changes, changeErr := tx.Changes(); changeErr != nil || len(changes) != 0 {
t.Fatalf("invalid create staged a change = %#v err=%v", changes, changeErr)
}
}
+70
View File
@@ -0,0 +1,70 @@
// Package script contains the sandboxed JavaScript automation layer.
//
// The package deliberately exposes a small host surface to the JavaScript
// runtime. It must not be widened to pass pvf.Archive or operating-system
// handles into Goja.
package script
import "context"
const (
RunStatusCompleted = "completed"
RunStatusFailed = "failed"
RunStatusCancelled = "cancelled"
ErrorKindCompile = "compile"
ErrorKindRuntime = "runtime"
ErrorKindHost = "host"
ErrorKindCancelled = "cancelled"
ErrorKindTimeout = "timeout"
LogLevelInfo = "info"
LogLevelWarn = "warn"
LogLevelError = "error"
)
// Diagnostic is a source-positioned compiler or runtime diagnostic.
type Diagnostic struct {
Kind string
Message string
Stack string
Line int
Column int
}
// CompileResult is the engine-independent syntax-check result.
type CompileResult struct {
Valid bool
Diagnostics []Diagnostic
}
// LogEntry is one message emitted by console or pvf.log.
type LogEntry struct {
Level string
Message string
}
// Progress is a user-visible progress update emitted by pvf.progress.
type Progress struct {
Done int
Total int
Message string
CurrentPath string
}
// RunResult is the engine-independent result of one sandbox execution.
type RunResult struct {
Status string
Error *Diagnostic
ScannedFiles int
ModifiedFiles int
Logs []LogEntry
}
// ScriptRuntime is the replaceable engine boundary. Implementations must run
// all JavaScript callbacks on one goroutine and must not expose the concrete
// PVF archive to the script.
type ScriptRuntime interface {
Compile(source string) CompileResult
Run(ctx context.Context, source string, host *BatchAPI) (RunResult, error)
}
+88
View File
@@ -0,0 +1,88 @@
package script
import (
"crypto/sha256"
"encoding/binary"
"fmt"
"strconv"
"strings"
"pvfine/internal/pvf"
)
func (t *Transaction) referencesStringTable(raw []byte, dependency string) bool {
for pos := 0; pos+5 <= len(raw); pos += 5 {
if raw[pos] != 8 && raw[pos] != 10 {
continue
}
offset := int32(binary.LittleEndian.Uint32(raw[pos+1:]))
table, _, ok := pvf.ParsePlaceholder(t.stage.ResolveString(offset))
if !ok {
continue
}
for _, candidate := range t.stage.StringTablePaths(table) {
if pvf.NormalizePath(candidate) == dependency {
return true
}
}
}
return false
}
// RegisterStringReference uses content-keyed entries so changing one script
// never overwrites the text behind a reference shared by other scripts.
func (t *Transaction) RegisterStringReference(owner, text string, existing *pvf.ScriptValue) (pvf.ScriptValue, error) {
if strings.ContainsAny(text, "\r\n\x00") {
return pvf.ScriptValue{}, fmt.Errorf("字符串表文本不能包含换行或 NUL,请使用 setStrValue")
}
key := fmt.Sprintf("pvfine_%x", sha256.Sum256([]byte(text)))
tables := make([]int, 0)
if existing != nil {
if reference, ok := existing.Value.(string); ok {
if table, _, ok := pvf.ParsePlaceholder(reference); ok {
tables = append(tables, table)
}
}
}
for _, mapPath := range []string{"list/n_string.lst", "n_string.lst"} {
index, ok := t.stage.Find(mapPath)
if !ok {
continue
}
pairs, err := t.stage.ListPairs(index)
if err != nil {
return pvf.ScriptValue{}, err
}
for _, pair := range pairs {
if table, err := strconv.Atoi(pair.ID); err == nil && table >= 0 {
tables = append(tables, table)
}
}
}
for _, table := range tables {
index, ok := t.stage.StringTableEntryIndex(table, key)
if !ok {
continue
}
if value, found := t.stage.LookupStringTableText(table, key); found && value != text {
return pvf.ScriptValue{}, fmt.Errorf("自动字符串键已被占用: %s", key)
}
tablePath := t.stage.Path(index)
if err := t.Mark(index); err != nil {
return pvf.ScriptValue{}, err
}
if err := t.stage.SetStringTableEntryAt(index, key, text); err != nil {
return pvf.ScriptValue{}, err
}
if t.stringDependencies == nil {
t.stringDependencies = make(map[string]map[string]struct{})
}
owner = pvf.NormalizePath(owner)
if t.stringDependencies[owner] == nil {
t.stringDependencies[owner] = make(map[string]struct{})
}
t.stringDependencies[owner][pvf.NormalizePath(tablePath)] = struct{}{}
return pvf.NewScriptValue(pvf.ScriptTokenBlock8, fmt.Sprintf("<%d::%s>", table, key), pvf.ScriptPoolUTF16)
}
return pvf.ScriptValue{}, fmt.Errorf("未找到可写的字符串表,请使用 setStrValue")
}
+166
View File
@@ -0,0 +1,166 @@
package script
import (
"context"
"os"
"strings"
"testing"
"pvfine/internal/pvf"
)
func TestRegisterStringReferenceIsolationAndCommit(t *testing.T) {
archive := pvf.New()
for _, file := range []struct {
path string
text string
kind int32
}{
{"list/n_string.lst", "31 `string/names.str`", pvf.TypeScript},
{"string/names.str", "shared>Original\n", pvf.TypeUnicode},
{"equipment/item.equ", "[name]\n{8=`<31::shared>`}", pvf.TypeScript},
} {
if _, err := archive.AddFileText(file.path, file.text, file.kind); err != nil {
t.Fatal(err)
}
}
tx := NewTransaction(archive)
index, _ := tx.Stage().Find("equipment/item.equ")
raw, _ := tx.Stage().RawBytes(index)
doc, err := tx.Stage().ParseScriptDocument(raw)
if err != nil {
t.Fatal(err)
}
existing, _ := doc.GetValue([]string{"name"}, 0, 0)
value, err := tx.RegisterStringReference("equipment/item.equ", "Replacement", &existing)
if err != nil {
t.Fatal(err)
}
repeated, err := tx.RegisterStringReference("equipment/item.equ", "Replacement", &existing)
if err != nil || repeated.Value != value.Value {
t.Fatalf("reused value = %#v err=%v", repeated, err)
}
if value.Type != pvf.ScriptTokenBlock8 || tx.Stage().ResolvePlaceholder(value.Value.(string)) != "Replacement" {
t.Fatalf("registered value = %#v", value)
}
if tx.Stage().ResolvePlaceholder("<31::shared>") != "Original" {
t.Fatal("shared reference changed")
}
empty, err := tx.RegisterStringReference("equipment/item.equ", "", &existing)
if err != nil {
t.Fatal(err)
}
host := NewBatchAPI(context.Background(), tx, nil, nil)
if text, err := host.GetOriStrValue(empty.Value.(string)); err != nil || text != "" {
t.Fatalf("empty reference = %q err=%v", text, err)
}
if _, err := tx.RegisterStringReference("equipment/item.equ", "invalid\nline", &existing); err == nil {
t.Fatal("multiline text was accepted into a line-based table")
}
if _, err := doc.Set([]string{"name"}, 0, 0, value, false, false); err != nil {
t.Fatal(err)
}
encoded, err := tx.Stage().EncodeScriptDocument(doc)
if err != nil {
t.Fatal(err)
}
if err := tx.SetRawBytes("equipment/item.equ", encoded); err != nil {
t.Fatal(err)
}
if _, err := tx.Commit(map[string]struct{}{"equipment/item.equ": {}}); err == nil {
t.Fatal("commit accepted missing string-table dependency")
}
if archive.ResolvePlaceholder(value.Value.(string)) != value.Value {
t.Fatal("preview mutated live table")
}
if _, err := tx.Commit(map[string]struct{}{"equipment/item.equ": {}, "string/names.str": {}}); err != nil {
t.Fatal(err)
}
if archive.ResolvePlaceholder(value.Value.(string)) != "Replacement" {
t.Fatal("committed reference cannot be resolved")
}
}
func TestGojaRuntimeRemoveChildrenAndLegacySet(t *testing.T) {
archive := pvf.New()
if _, err := archive.AddFileText("test/item.equ",
"[parent]\n1\n[child]\n2\n[grandchild]\n3\n[/grandchild]\n[/child]\n[keep]\n4\n[/keep]\n[child]\n5\n[/child]\n[/parent]\n[name]\n{8=`<31::old>`}", pvf.TypeScript); err != nil {
t.Fatal(err)
}
tx := NewTransaction(archive)
host := NewBatchAPI(context.Background(), tx, nil, nil)
result, err := NewGojaRuntime().Run(context.Background(), `
const file = pvf.find("test/item.equ");
const doc = file.parse();
const parent = doc.section("parent");
const removed = parent.children()[0];
const grandchild = removed.children()[0];
if (parent.removeChildren("[child]") !== 2) throw new Error("remove count");
if (parent.removeChildren("child") !== 0) throw new Error("repeat remove");
if (parent.children().length !== 1 || parent.children()[0].name !== "keep" || parent.get() !== 1) throw new Error("unrelated content changed");
let rejected = false;
try { grandchild.set(9); } catch { rejected = true; }
if (!rejected) throw new Error("removed subtree handle remains usable");
rejected = false;
try { parent.removeChildren(""); } catch { rejected = true; }
if (!rejected) throw new Error("empty name accepted");
doc.set("name", "Plain");
if (doc.getValue("name").type !== "quoted") throw new Error("legacy set retained reference type");
parent.setStrValue("Literal");
file.write(doc);
const parsed = file.parse();
if (parsed.sections(["parent", "child"]).length !== 0) throw new Error("children survived write");
if (parsed.get("parent") !== "Literal") throw new Error("legacy text lost");
`, host)
if err != nil || result.Status != RunStatusCompleted {
t.Fatalf("result=%#v diagnostic=%#v err=%v", result, result.Error, err)
}
}
func TestGojaRuntimePaged110SetStrings(t *testing.T) {
filePath := os.Getenv("PVF_TESTFILE")
if filePath == "" {
t.Skip("PVF_TESTFILE not set")
}
archive, err := pvf.Open(filePath)
if err != nil {
t.Fatal(err)
}
if !archive.ContentRules().SupportsStringReferences {
t.Skip("archive does not use string references")
}
tx := NewTransaction(archive)
host := NewBatchAPI(context.Background(), tx, nil, nil)
result, err := NewGojaRuntime().Run(context.Background(), `
const file = pvf.createFile("pvfine-script-test/set.equ", pvf.types.script, "[name]\n`+"`old`"+`");
const doc = file.parse();
doc.set("name", "Automatically registered");
const name = doc.section("name");
if (name.getValue().type !== "block8" || name.getOriStrValue() !== "Automatically registered") throw new Error("document registration");
name.set("Section text");
if (name.getValue().type !== "block8" || name.getOriStrValue() !== "Section text") throw new Error("section registration");
if (!doc.set("description", "New section", {create:true})) throw new Error("create section");
name.set("");
if (name.getValue().type !== "block8" || name.getOriStrValue() !== "") throw new Error("empty string");
doc.setStrValue("name", "Forced legacy");
if (name.getValue().type !== "quoted") throw new Error("forced document legacy");
name.setStrValue("Section legacy");
if (name.getValue().type !== "quoted") throw new Error("forced section legacy");
file.write(doc);
`, host)
if err != nil || result.Status != RunStatusCompleted {
t.Fatalf("result=%#v diagnostic=%#v err=%v", result, result.Error, err)
}
changes, err := tx.Changes()
if err != nil || len(changes) < 2 {
t.Fatalf("changes=%d err=%v", len(changes), err)
}
foundTable := false
for _, change := range changes {
foundTable = foundTable || strings.HasSuffix(strings.ToLower(change.Path), ".str")
}
if !foundTable {
t.Fatal("automatic registration was not included in preview")
}
tx.Rollback()
}
+460
View File
@@ -0,0 +1,460 @@
package script
import (
"bytes"
"fmt"
"path"
"sort"
"strings"
"pvfine/internal/pvf"
)
// Change is one staged archive mutation: an edited payload, a newly created
// file, or a deletion. Normalized is the stable identity because structural
// edits shift entry indexes.
type Change struct {
Kind string
Path string
Normalized string
BeforeText string
AfterText string
DataType int32
raw []byte
}
// Transaction is an isolated mutable archive view. It snapshots only the
// entries touched by the script; the full archive remains owned by services.
type Transaction struct {
base *pvf.Archive
stage *pvf.Archive
// original holds the pre-mutation payload and text of every touched
// existing entry, keyed by normalized path.
original map[string][]byte
originalText map[string]string
// paths keeps the display path last seen for a normalized key, which is
// the only way to name an entry after it has been removed from the stage.
paths map[string]string
// created and deleted record structural intents by normalized path. A
// created entry keeps its payload in the stage overlay.
created map[string]struct{}
deleted map[string]struct{}
// deleteRevision advances whenever a removal shifts entry indexes, which
// is the only staged mutation that invalidates outstanding file handles.
deleteRevision int
stringDependencies map[string]map[string]struct{}
}
// NewTransaction creates an isolated stage from the current archive. Callers
// must hold the service read lock while cloning the archive.
func NewTransaction(base *pvf.Archive) *Transaction {
tx := &Transaction{
original: make(map[string][]byte),
originalText: make(map[string]string),
paths: make(map[string]string),
created: make(map[string]struct{}),
deleted: make(map[string]struct{}),
}
if base == nil {
return tx
}
tx.base = base
tx.stage = base.CloneForBatch()
return tx
}
// Stage returns the isolated archive used by host file handles.
func (t *Transaction) Stage() *pvf.Archive {
if t == nil {
return nil
}
return t.stage
}
// DeleteRevision reports how many removals were staged. File handles captured
// before a removal must not be reused afterwards.
func (t *Transaction) DeleteRevision() int {
if t == nil {
return 0
}
return t.deleteRevision
}
// Mark records the staged payload before the first mutation of an entry.
func (t *Transaction) Mark(index int32) error {
if t == nil || t.stage == nil {
return fmt.Errorf("脚本事务不可用")
}
if index < 0 || index >= t.stage.FileCount() {
return pvf.ErrBadIndex
}
return t.markPath(t.stage.Path(index))
}
func (t *Transaction) markPath(path string) error {
key := pvf.NormalizePath(path)
if key == "" {
return fmt.Errorf("文件路径无效")
}
if _, exists := t.original[key]; exists {
return nil
}
index, ok := t.resolve(path)
if !ok {
return fmt.Errorf("文件不存在: %s", path)
}
raw, err := t.stage.RawBytes(index)
if err != nil {
return err
}
t.original[key] = append([]byte(nil), raw...)
t.paths[key] = t.stage.Path(index)
if text, textErr := t.stage.Text(index); textErr == nil {
t.originalText[key] = text
}
return nil
}
// resolve maps a path to its current staged index.
func (t *Transaction) resolve(path string) (int32, bool) {
if t == nil || t.stage == nil {
return 0, false
}
return t.stage.Find(path)
}
// SetText stages a text edit addressed by archive path. Paths are the stable
// identity because staged removals renumber entry indexes.
func (t *Transaction) SetText(path, text string) error {
if err := t.markPath(path); err != nil {
return err
}
index, ok := t.resolve(path)
if !ok {
return fmt.Errorf("文件不存在: %s", path)
}
if err := t.stage.SetText(index, text); err != nil {
return err
}
t.invalidateStringTables(path)
return nil
}
// SetRawBytes stages a binary payload produced by a parsed script document.
func (t *Transaction) SetRawBytes(path string, raw []byte) error {
if err := t.markPath(path); err != nil {
return err
}
index, ok := t.resolve(path)
if !ok {
return fmt.Errorf("文件不存在: %s", path)
}
if err := t.stage.SetRawBytes(index, raw); err != nil {
return err
}
t.invalidateStringTables(path)
return nil
}
func (t *Transaction) invalidateStringTables(filePath string) {
switch strings.ToLower(path.Ext(filePath)) {
case ".str", ".lst":
t.stage.InvalidateStringTables()
}
}
// ListPairs reads the staged id/path pairs of a .lst file.
func (t *Transaction) ListPairs(path string) ([]pvf.ListPair, error) {
index, ok := t.resolve(path)
if !ok {
return nil, fmt.Errorf("文件不存在: %s", path)
}
return t.stage.ListPairs(index)
}
// SetListPairs stages an insert-or-update of id/path pairs in a .lst file.
func (t *Transaction) SetListPairs(path string, pairs []pvf.ListPair) error {
if len(pairs) == 0 {
return nil
}
if err := t.markPath(path); err != nil {
return err
}
index, ok := t.resolve(path)
if !ok {
return fmt.Errorf("文件不存在: %s", path)
}
if err := t.stage.SetListPairs(index, pairs); err != nil {
return err
}
t.invalidateStringTables(path)
return nil
}
// UnsetListIDs stages removal of every entry matching the supplied ids.
func (t *Transaction) UnsetListIDs(path string, ids []string) (int, error) {
if len(ids) == 0 {
return 0, nil
}
if err := t.markPath(path); err != nil {
return 0, err
}
index, ok := t.resolve(path)
if !ok {
return 0, fmt.Errorf("文件不存在: %s", path)
}
count, err := t.stage.RemoveListIDs(index, ids)
if err == nil && count > 0 {
t.invalidateStringTables(path)
}
return count, err
}
// ListID resolves the id registered for a path in a .lst file.
func (t *Transaction) ListID(path, entryPath string) (string, bool, error) {
index, ok := t.resolve(path)
if !ok {
return "", false, fmt.Errorf("文件不存在: %s", path)
}
return t.stage.ListID(index, entryPath)
}
// CreateFile stages a new entry. It fails when the path already exists so a
// typo cannot silently overwrite content the script never read.
func (t *Transaction) CreateFile(rawPath string, dataType int32, raw []byte) (string, error) {
if t == nil || t.stage == nil {
return "", fmt.Errorf("脚本事务不可用")
}
path, err := pvf.NormalizeNewFilePath(rawPath)
if err != nil {
return "", err
}
if _, exists := t.resolve(path); exists {
return "", fmt.Errorf("文件已存在: %s", path)
}
if dataType != pvf.TypeScript && dataType != pvf.TypeUnicode {
return "", fmt.Errorf("不支持的文件类型: %d", dataType)
}
t.stage.AddFile(path, append([]byte(nil), raw...), dataType)
t.invalidateStringTables(path)
key := pvf.NormalizePath(path)
// Recreating a path deleted earlier in the same run cancels the removal.
delete(t.deleted, key)
t.created[key] = struct{}{}
t.paths[key] = path
return path, nil
}
// CopyFile stages a copy of an existing entry. The final staged payload is
// copied, so a file edited earlier in the same run carries its new content.
func (t *Transaction) CopyFile(from, to string, overwrite bool) (string, error) {
if t == nil || t.stage == nil {
return "", fmt.Errorf("脚本事务不可用")
}
source, ok := t.resolve(from)
if !ok {
return "", fmt.Errorf("源文件不存在: %s", from)
}
target, err := pvf.NormalizeNewFilePath(to)
if err != nil {
return "", err
}
if pvf.NormalizePath(target) == pvf.NormalizePath(from) {
return "", fmt.Errorf("源文件和目标文件不能相同: %s", target)
}
raw, err := t.stage.RawBytes(source)
if err != nil {
return "", err
}
dataType := t.stage.File(source).DataType
if existing, exists := t.resolve(target); exists {
if !overwrite {
return "", fmt.Errorf("目标文件已存在: %s", target)
}
if err := t.markPath(target); err != nil {
return "", err
}
if err := t.stage.SetRawBytes(existing, append([]byte(nil), raw...)); err != nil {
return "", err
}
t.invalidateStringTables(target)
t.paths[pvf.NormalizePath(target)] = t.stage.Path(existing)
return t.stage.Path(existing), nil
}
t.stage.AddFile(target, append([]byte(nil), raw...), dataType)
t.invalidateStringTables(target)
key := pvf.NormalizePath(target)
// Recreating a path deleted earlier in the same run cancels the removal.
delete(t.deleted, key)
t.created[key] = struct{}{}
t.paths[key] = target
return target, nil
}
// DeleteFile stages removal of one entry and reports whether it existed.
func (t *Transaction) DeleteFile(path string) (bool, error) {
if t == nil || t.stage == nil {
return false, fmt.Errorf("脚本事务不可用")
}
index, ok := t.resolve(path)
if !ok {
return false, nil
}
displayPath := t.stage.Path(index)
if err := t.markPath(displayPath); err != nil {
return false, err
}
if _, err := t.stage.RemoveFiles([]int32{index}); err != nil {
return false, err
}
t.invalidateStringTables(displayPath)
key := pvf.NormalizePath(displayPath)
// A path created and then deleted in the same run leaves no trace: the
// base archive never had it, so there is nothing to remove at commit and
// no entry index shifts. Discard the snapshot markPath just recorded.
_, wasCreated := t.created[key]
delete(t.created, key)
if wasCreated {
delete(t.paths, key)
delete(t.deleted, key)
delete(t.original, key)
delete(t.originalText, key)
return true, nil
}
t.deleted[key] = struct{}{}
t.deleteRevision++
return true, nil
}
// Changes returns every staged mutation in stable path order. Entries whose
// payload is unchanged and that were not created or deleted are omitted.
func (t *Transaction) Changes() ([]Change, error) {
if t == nil || t.stage == nil {
return nil, fmt.Errorf("脚本事务不可用")
}
keys := make(map[string]struct{}, len(t.original)+len(t.created))
for key := range t.original {
keys[key] = struct{}{}
}
for key := range t.created {
keys[key] = struct{}{}
}
changes := make([]Change, 0, len(keys))
for key := range keys {
change := Change{Normalized: key, Path: t.paths[key], DataType: pvf.TypeScript}
if _, isDeleted := t.deleted[key]; isDeleted {
change.Kind = pvf.ChangeKindDeleted
change.BeforeText = t.originalText[key]
change.raw = t.original[key]
changes = append(changes, change)
continue
}
index, ok := t.resolve(key)
if !ok {
continue
}
afterRaw, err := t.stage.RawBytes(index)
if err != nil {
return nil, err
}
if _, isCreated := t.created[key]; isCreated {
change.Kind = pvf.ChangeKindCreated
} else {
if bytes.Equal(t.original[key], afterRaw) {
continue
}
change.Kind = pvf.ChangeKindChanged
change.BeforeText = t.originalText[key]
}
afterText, err := t.stage.Text(index)
if err != nil {
return nil, err
}
change.Path = t.stage.Path(index)
change.AfterText = afterText
change.DataType = t.stage.File(index).DataType
change.raw = append([]byte(nil), afterRaw...)
changes = append(changes, change)
}
sort.SliceStable(changes, func(left, right int) bool {
return changes[left].Normalized < changes[right].Normalized
})
return changes, nil
}
// Commit copies the selected staged mutations into the live archive and
// reports whether any of them changed the entry table. The caller must hold
// the service write lock and validate its revision first.
func (t *Transaction) Commit(selected map[string]struct{}) (bool, error) {
if t == nil || t.base == nil || t.stage == nil {
return false, fmt.Errorf("脚本事务不可用")
}
if len(selected) == 0 {
return false, nil
}
changes, err := t.Changes()
if err != nil {
return false, err
}
changedPaths := make(map[string]struct{}, len(changes))
for _, change := range changes {
changedPaths[change.Normalized] = struct{}{}
}
for _, change := range changes {
if _, ok := selected[change.Normalized]; !ok || change.Kind == pvf.ChangeKindDeleted {
continue
}
for dependency := range t.stringDependencies[change.Normalized] {
if !t.referencesStringTable(change.raw, dependency) {
continue
}
if _, changed := changedPaths[dependency]; !changed {
continue
}
if _, included := selected[dependency]; !included {
return false, fmt.Errorf("文件 %s 的字符串引用依赖 %s,请一并选择应用", change.Path, dependency)
}
}
}
applied := make([]pvf.ScriptChange, 0, len(selected))
structural := false
for _, change := range changes {
if _, ok := selected[change.Normalized]; !ok {
continue
}
if change.Kind == pvf.ChangeKindCreated || change.Kind == pvf.ChangeKindDeleted {
structural = true
}
applied = append(applied, pvf.ScriptChange{
Kind: change.Kind,
Path: change.Normalized,
Raw: change.raw,
DataType: change.DataType,
})
}
if len(applied) == 0 {
return false, nil
}
return structural, t.base.ApplyScriptChanges(t.stage, applied)
}
// Rollback releases the isolated stage. It never mutates the live archive.
func (t *Transaction) Rollback() {
if t == nil {
return
}
t.stage = nil
t.original = nil
t.originalText = nil
t.paths = nil
t.created = nil
t.deleted = nil
t.stringDependencies = nil
}
+191
View File
@@ -0,0 +1,191 @@
package script
import (
"fmt"
"math"
"sort"
"strconv"
"strings"
"unicode/utf8"
"github.com/dop251/goja"
"pvfine/internal/pvf"
)
// editUpgrade evaluates every callback before staging one complete edit.
func (b *gojaBindings) editUpgrade(file *FileHandle, call goja.FunctionCall) (goja.Value, error) {
options, err := objectArgument(call.Argument(0))
if err != nil {
return nil, err
}
if options == nil {
return nil, fmt.Errorf("editUpgrade 需要 options")
}
for _, key := range options.Keys() {
switch key {
case "equipment", "fromLevel", "toLevel", "successRate", "costCount":
default:
return nil, fmt.Errorf("未知强化选项 %s", key)
}
}
path := strings.ToLower(strings.ReplaceAll(file.Path(), "\\", "/"))
kind := "normal"
if path == "etc/amplifyupgrade.etc" {
kind = "amplify"
} else if path != "etc/upgrade.etc" {
return nil, fmt.Errorf("editUpgrade 仅支持强化/增幅文件")
}
equipment := "weapon"
if !isMissingValue(options.Get("equipment")) {
equipment, err = requiredString(options.Get("equipment"), "装备类型")
if err != nil {
return nil, err
}
}
if equipment != "weapon" && equipment != "armor" {
return nil, fmt.Errorf("equipment 必须是 weapon 或 armor")
}
text, err := file.Text()
if err != nil {
return nil, err
}
tokens, typed, err := pvf.SelectUpgradeTableTokens(text, kind)
if err != nil {
return nil, err
}
layout, err := pvf.DetectUpgradeTableLayout(tokens, typed)
if err != nil {
return nil, err
}
if equipment == "armor" && !layout.HasT2 {
return nil, fmt.Errorf("旧版表格没有独立防具数据")
}
rateColumn := layout.RateColumn
if equipment == "armor" {
rateColumn = layout.ColumnsT2[0]
}
countColumn := layout.CountColumn
width := layout.Width
for _, token := range tokens {
if token.TokenType != 0 && token.TokenType != 2 {
return nil, fmt.Errorf("表格包含非数值数据")
}
}
maximum := len(tokens) / width
if !typed && maximum > 31 {
maximum = 31
}
from, err := optionInteger(options, "fromLevel", 1)
if err != nil {
return nil, err
}
to, err := optionInteger(options, "toLevel", int64(maximum))
if err != nil {
return nil, err
}
if from < 1 || to < from || to > int64(maximum) {
return nil, fmt.Errorf("等级范围必须位于 1~%d", maximum)
}
type update struct {
name string
column int
value goja.Value
}
updates := []update{}
for _, item := range []update{{"successRate", rateColumn, options.Get("successRate")}, {"costCount", countColumn, options.Get("costCount")}} {
if !isMissingValue(item.value) {
updates = append(updates, item)
}
}
if len(updates) == 0 {
return nil, fmt.Errorf("请提供 successRate 或 costCount")
}
type patch struct {
start, end int
value string
}
patches := []patch{}
offsets := upgradeByteOffsets(text)
for level := int(from); level <= int(to); level++ {
if err := b.host.checkContext(); err != nil {
return nil, err
}
for _, item := range updates {
token := tokens[(level-1)*width+item.column]
current, err := strconv.ParseFloat(token.Value, 64)
if err != nil || token.TokenType != 0 {
return nil, fmt.Errorf("第 %d 级 %s 必须是整数", level, item.name)
}
if item.name == "successRate" {
if current < 0 || current > 100000 {
return nil, fmt.Errorf("原始失败概率超出范围")
}
current = 100 - current/1000
}
value := item.value
if fn, ok := goja.AssertFunction(value); ok {
value, err = fn(goja.Undefined(), b.vm.ToValue(level), b.vm.ToValue(current))
if err != nil {
return nil, err
}
}
switch value.Export().(type) {
case int64, float64, int, int32, float32:
default:
return nil, fmt.Errorf("第 %d 级 %s 计算结果必须是数字", level, item.name)
}
number := value.ToFloat()
if math.IsNaN(number) || math.IsInf(number, 0) || number < 0 {
return nil, fmt.Errorf("第 %d 级 %s 计算结果无效", level, item.name)
}
if item.name == "successRate" {
if number > 100 {
return nil, fmt.Errorf("成功率必须位于 0~100")
}
number = math.Round((100 - number) * 1000)
} else if number > math.MaxInt32 || math.Trunc(number) != number {
return nil, fmt.Errorf("消耗数量必须是非负 32 位整数")
}
if token.Start < 0 || token.End >= len(offsets) {
return nil, fmt.Errorf("表格位置无效")
}
patches = append(patches, patch{offsets[token.Start], offsets[token.End], strconv.FormatInt(int64(number), 10)})
}
}
sort.Slice(patches, func(i, j int) bool { return patches[i].start > patches[j].start })
updated := text
for _, patch := range patches {
updated = updated[:patch.start] + patch.value + updated[patch.end:]
}
latest, err := file.Text()
if err != nil {
return nil, err
}
if latest != text {
return nil, fmt.Errorf("计算期间文件发生变化,已拒绝覆盖")
}
if updated != text {
if err := file.SetText(updated); err != nil {
return nil, err
}
}
return b.vm.ToValue(to - from + 1), nil
}
// Translate normalized UTF-16 token offsets to original UTF-8 byte offsets,
// retaining CRLF and lone CR line endings in source patches.
func upgradeByteOffsets(text string) []int {
offsets := []int{0}
for i := 0; i < len(text); {
r, size := utf8.DecodeRuneInString(text[i:])
if r == '\r' && i+size < len(text) && text[i+size] == '\n' {
size++
}
if r > 0xffff {
offsets = append(offsets, i)
}
i += size
offsets = append(offsets, i)
}
return offsets
}
+144
View File
@@ -0,0 +1,144 @@
package script
import (
"context"
"strconv"
"strings"
"testing"
"pvfine/internal/pvf"
)
const scriptUpgradeRow = "2.0 0 0 0 0 0.6 0 0.828 90000 88000 2 3 3 0 3037 10 0.3 0.018 0.1 0.033 0.17 0.228 0 0.2622"
func TestScriptUpgradeFunctionsAndAtomicValidation(t *testing.T) {
for _, test := range []struct {
name, source string
failed bool
}{
{"range", `const f=pvf.find("etc/upgrade.etc"); if(f.editUpgrade({equipment:"armor",fromLevel:2,toLevel:3,successRate:level=>100-level*5,costCount:(level,current)=>current+level*2})!==2)throw Error("count");`, false},
{"invalid late callback", `const f=pvf.find("etc/upgrade.etc");const before=f.text();let rejected=false;try{f.editUpgrade({successRate:level=>level===3?101:50,costCount:level=>level*20});}catch(e){rejected=true;}if(!rejected||f.text()!==before)throw Error("atomic validation failed");`, false},
{"fraction count", `pvf.find("etc/upgrade.etc").editUpgrade({costCount:level=>level/2});`, true},
{"string result", `pvf.find("etc/upgrade.etc").editUpgrade({successRate:()=>"50"});`, true},
{"bad range", `pvf.find("etc/upgrade.etc").editUpgrade({fromLevel:0,successRate:50});`, true},
} {
t.Run(test.name, func(t *testing.T) {
a := pvf.New()
defer a.Release()
text := "[name]\n`😀`\n[table]\n[table type]\n`event` 1 2 3\n[/table]\n[table]\n[table type]\n`normal` " + strings.Repeat(scriptUpgradeRow+"\n", 3) + "[/table]\n[table]\n[table type]\n`amplify` " + scriptUpgradeRow + "\n[/table]"
index, err := a.AddFileText("etc/upgrade.etc", text, pvf.TypeScript)
if err != nil {
t.Fatal(err)
}
tx := NewTransaction(a)
defer tx.Rollback()
host := NewBatchAPI(context.Background(), tx, nil, nil)
before, err := tx.Stage().Text(index)
if err != nil {
t.Fatal(err)
}
result, err := NewGojaRuntime().Run(context.Background(), test.source, host)
if err != nil {
t.Fatal(err)
}
if test.failed {
if result.Status != RunStatusFailed {
t.Fatalf("expected failure: %+v", result)
}
} else if result.Status != RunStatusCompleted {
t.Fatalf("run: %+v", result)
}
after, _ := tx.Stage().Text(index)
if test.name != "range" {
if after != before {
t.Fatal("invalid batch changed file")
}
return
}
tokens, _, err := pvf.SelectUpgradeTableTokens(after, "normal")
if err != nil {
t.Fatal(err)
}
for level := 1; level <= 3; level++ {
row := tokens[(level-1)*24 : level*24]
if row[8].Value != "90000" {
t.Fatal("weapon modified")
}
wantRate, wantCount := 88000, 10
if level >= 2 {
wantRate, wantCount = level*5000, 10+level*2
}
if row[9].Value != strconv.Itoa(wantRate) || row[15].Value != strconv.Itoa(wantCount) {
t.Fatalf("level %d: %+v", level, row)
}
}
original, _ := a.Text(index)
if original != before {
t.Fatal("script changed base archive")
}
unchanged, _, _ := pvf.SelectUpgradeTableTokens(after, "amplify")
if unchanged[9].Value != "88000" || unchanged[15].Value != "10" {
t.Fatal("wrong table changed")
}
})
}
}
func TestUpgradeSourceOffsets(t *testing.T) {
text := "`😀`\r\n123\r456"
offsets := upgradeByteOffsets(text)
for _, e := range pvf.ParseScriptView(text).Elements {
if e.TokenType == 0 {
if text[offsets[e.Start]:offsets[e.End]] != e.Value {
t.Fatal("invalid CRLF/UTF-16 positions")
}
}
}
}
const scriptUpgrade19Row = "2.2 0.018 0.1 0.033 0.2 0.6 0 10000 2 1 3037 10 1 0.018 0.1 0.033 0.17 0.228 0"
func TestScriptUpgrade19Columns(t *testing.T) {
a := pvf.New()
defer a.Release()
text := "[table]\n[table type]\n`normal`\n" + strings.Repeat(scriptUpgrade19Row+"\n", 5) + "[/table]"
index, err := a.AddFileText("etc/upgrade.etc", text, pvf.TypeScript)
if err != nil {
t.Fatal(err)
}
tx := NewTransaction(a)
defer tx.Rollback()
host := NewBatchAPI(context.Background(), tx, nil, nil)
// equipment: armor 应报错
res, err := NewGojaRuntime().Run(context.Background(), `pvf.find("etc/upgrade.etc").editUpgrade({equipment:"armor",successRate:100});`, host)
if err != nil || res.Status != RunStatusFailed {
t.Fatalf("expected armor edit to fail on 19 column table: %+v", res)
}
// 成功率与数量修改
res, err = NewGojaRuntime().Run(context.Background(), `pvf.find("etc/upgrade.etc").editUpgrade({fromLevel:1,toLevel:2,successRate:100,costCount:88});`, host)
if err != nil || res.Status != RunStatusCompleted {
t.Fatalf("run failed: %+v %v", res, err)
}
after, err := tx.Stage().Text(index)
if err != nil {
t.Fatal(err)
}
tokens, _, err := pvf.SelectUpgradeTableTokens(after, "normal")
if err != nil {
t.Fatal(err)
}
// Level 1: 失败概率在 offset+7 应为 0 (100% 成功率), 数量在 offset+11 应为 88
if tokens[7].Value != "0" || tokens[11].Value != "88" {
t.Fatalf("level 1 not updated correctly: tokens[7]=%s, tokens[11]=%s", tokens[7].Value, tokens[11].Value)
}
// Level 2: 同样被更新
if tokens[19+7].Value != "0" || tokens[19+11].Value != "88" {
t.Fatalf("level 2 not updated correctly: tokens[26]=%s, tokens[30]=%s", tokens[19+7].Value, tokens[19+11].Value)
}
// Level 3: 保持未变 (原本失败率 10000, 数量 10)
if tokens[38+7].Value != "10000" || tokens[38+11].Value != "10" {
t.Fatalf("level 3 should be unchanged: tokens[45]=%s, tokens[49]=%s", tokens[38+7].Value, tokens[38+11].Value)
}
}