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

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

192 lines
5.6 KiB
Go

package services
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"pvfine/internal/npk"
)
func writeImageBatchTestNPK(t *testing.T, path string, names ...string) {
t.Helper()
data := append([]byte("NeoplePack_Bill"), 0)
putImageTestInt32(&data, len(names))
member := append([]byte("Neople Img File"), 0)
putImageTestInt32(&member, 72, 0, 2, 2)
for range 2 {
putImageTestInt32(&member, 16, 5, 1, 1, 4, 0, 0, 1, 1)
}
member = append(member, 3, 2, 1, 255, 6, 5, 4, 255)
key := []byte("puchikon@neople dungeon and fighter " + strings.Repeat("DNF", 73) + "\x00")
for i, name := range names {
putImageTestInt32(&data, 20+264*len(names)+i*len(member), len(member))
var encrypted [256]byte
for j := range encrypted {
var value byte
if j < len(name) {
value = name[j]
}
encrypted[j] = value ^ key[j%len(key)]
}
data = append(data, encrypted[:]...)
}
for range names {
data = append(data, member...)
}
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatal(err)
}
}
func imageBatchTestService(t *testing.T, packages int) *ImageService {
t.Helper()
root := t.TempDir()
for n := 0; n < packages; n++ {
writeImageBatchTestNPK(t, filepath.Join(root, fmt.Sprintf("%d.npk", n)), fmt.Sprintf("sprite/item/%d-a.img", n), fmt.Sprintf("sprite/item/%d-b.img", n))
}
s := &ImageService{directory: root, generation: 1, cachePath: filepath.Join(t.TempDir(), "index.json")}
s.buildIndex(context.Background(), root, 1, time.Now())
if s.IndexStatus().State != ImageIndexStateReady {
t.Fatal("image index not ready")
}
return s
}
func TestImageBatchGroupsPackagesMembersAndDuplicateFrames(t *testing.T) {
s := imageBatchTestService(t, 2)
var opens, parses atomic.Int32
s.batchIO = imageBatchIO{
open: func(path string) (*os.File, error) { opens.Add(1); return os.Open(path) },
parse: func(r io.ReaderAt, offset, size int64) (*npk.IMGFile, error) {
parses.Add(1)
return npk.ParseIMGAt(r, offset, size)
},
}
refs := []ImageReference{
{Path: "item/0-b.img"}, {Path: "ITEM\\0-A.IMG"}, {Path: "item/1-a.img"},
{Path: "sprite/item/0-a.img"}, {Path: "item/0-a.img", Index: 1}, {Path: "item/0-a.img", Index: 9},
{Path: "missing.img"}, {Path: "item/0-a.img", Index: -1},
}
results, err := s.GetImages(refs, 1)
if err != nil || len(results) != len(refs) {
t.Fatalf("batch result length: %d, %v", len(results), err)
}
for i, result := range results {
if result.Error != "" {
t.Fatalf("result %d: %s", i, result.Error)
}
if i < 5 && (result.Data == nil || result.Data.Width != 1) {
t.Fatalf("result %d missing", i)
}
if i >= 5 && result.Data != nil {
t.Fatalf("invalid result %d returned data", i)
}
}
if results[1].Data != results[3].Data {
t.Fatal("duplicate frame was decoded separately")
}
if opens.Load() != 2 || parses.Load() != 3 {
t.Fatalf("opens/parses = %d/%d, want 2/3", opens.Load(), parses.Load())
}
if results[1].Data.DataURL == results[4].Data.DataURL {
t.Fatal("different frames returned the same pixels")
}
_, err = s.GetImages(refs[:5], 1)
if err != nil || opens.Load() != 2 || parses.Load() != 3 {
t.Fatalf("cache did IO: %v, %d/%d", err, opens.Load(), parses.Load())
}
}
func TestImageBatchIsolatesErrorsAndRejectsOldGeneration(t *testing.T) {
s := imageBatchTestService(t, 2)
if err := os.WriteFile(filepath.Join(s.directory, "0.npk"), []byte("corrupt"), 0600); err != nil {
t.Fatal(err)
}
refs := []ImageReference{{Path: "item/0-a.img"}, {Path: "item/1-a.img"}}
results, err := s.GetImages(refs, 1)
if err != nil || results[0].Error == "" || results[1].Data == nil {
t.Fatalf("partial results = %#v, %v", results, err)
}
results, err = s.GetImages(refs, 0)
if err != nil || results[0].Error != "" || results[1].Data != nil {
t.Fatalf("stale request = %#v, %v", results, err)
}
if _, err := s.GetImages(make([]ImageReference, maxImageBatchSize+1), 1); err == nil {
t.Fatal("oversize batch accepted")
}
}
func TestImageBatchDiscardsResultsWhenGenerationChangesDuringRead(t *testing.T) {
s := imageBatchTestService(t, 1)
s.batchIO.open = func(path string) (*os.File, error) {
file, err := os.Open(path)
s.mu.Lock()
s.generation++
s.clearCacheLocked()
s.mu.Unlock()
return file, err
}
results, err := s.GetImages([]ImageReference{{Path: "item/0-a.img"}}, 1)
if err != nil || results[0].Data != nil || results[0].Error != "" || len(s.cache) != 0 {
t.Fatalf("old generation published: %#v, %v, cache=%d", results, err, len(s.cache))
}
}
func TestImageBatchBoundsDecodersAcrossConcurrentRequests(t *testing.T) {
s := imageBatchTestService(t, 8)
var active, peak atomic.Int32
entered := make(chan struct{}, 32)
release := make(chan struct{})
s.batchIO.open = func(path string) (*os.File, error) {
value := active.Add(1)
for old := peak.Load(); value > old; old = peak.Load() {
if peak.CompareAndSwap(old, value) {
break
}
}
entered <- struct{}{}
<-release
active.Add(-1)
return os.Open(path)
}
refs := make([]ImageReference, 8)
for i := range refs {
refs[i] = ImageReference{Path: fmt.Sprintf("item/%d-a.img", i)}
}
var requests sync.WaitGroup
requests.Add(3)
for range 3 {
go func() {
defer requests.Done()
if _, err := s.GetImages(refs, 1); err != nil {
t.Error(err)
}
}()
}
defer requests.Wait()
defer close(release)
for range maxImageDecodeWorkers {
select {
case <-entered:
case <-time.After(5 * time.Second):
t.Fatal("decoders did not start")
}
}
select {
case <-entered:
t.Fatal("decoder concurrency limit exceeded")
case <-time.After(20 * time.Millisecond):
}
if peak.Load() != maxImageDecodeWorkers {
t.Fatalf("peak=%d", peak.Load())
}
}