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