Files
90CNServer/server/internal/modules/dnf/profession/profile_real_pvf_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

465 lines
18 KiB
Go

package profession
import (
"context"
"fmt"
"os"
"reflect"
"sort"
"testing"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfskill "longheng.io/server/internal/modules/dnf/skill"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestRealScriptPVFProfessionProfiles(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify profession profiles")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
index, err := dnfpvf.Build(context.Background(), archive, dnfpvf.BuildOptions{Lists: []string{dnfskill.DefaultList}})
if err != nil {
t.Fatal(err)
}
catalog, err := dnfskill.Load(context.Background(), index, dnfskill.Options{})
if err != nil {
t.Fatal(err)
}
explicitClassPreviewSkills := 0
contradictoryFitness := make(map[dnfskill.Key]int)
for _, definition := range catalog.Skills() {
explicit := make(map[int]struct{}, len(definition.GrowTypes))
for _, branch := range definition.GrowTypes {
explicit[branch] = struct{}{}
}
if len(explicit) == 0 {
continue
}
if _, allowsBase := explicit[0]; !allowsBase && len(definition.GrowTypeMaximumLevels) > 0 && definition.GrowTypeMaximumLevels[0] > 0 {
explicitClassPreviewSkills++
}
intersects := false
for branch := range explicit {
if branch >= 0 && branch < len(definition.GrowTypeMaximumLevels) && definition.GrowTypeMaximumLevels[branch] > 0 {
intersects = true
break
}
}
exclusive := -1
for branch := 1; branch < len(definition.GrowTypeMaximumLevels); branch++ {
if definition.GrowTypeMaximumLevels[branch] <= 0 {
continue
}
if exclusive >= 0 {
exclusive = -2
break
}
exclusive = branch
}
if !intersects && exclusive >= 0 {
contradictoryFitness[definition.Key()] = exclusive
}
}
if explicitClassPreviewSkills != 98 {
t.Fatalf("explicit class skills with client base preview=%d want=98", explicitClassPreviewSkills)
}
wantContradictory := map[dnfskill.Key]int{
{Job: 5, ID: 16}: 4,
{Job: 7, ID: 236}: 2,
{Job: 7, ID: 237}: 2,
{Job: 12, ID: 126}: 4,
}
if !reflect.DeepEqual(contradictoryFitness, wantContradictory) {
t.Fatalf("exclusive contradictory fitness=%v want=%v", contradictoryFitness, wantContradictory)
}
for _, skillID := range []uint16{236, 237} {
definition, found := catalog.Find(7, skillID)
if !found || definition.SupportsCharacterGrowth(0x21) || !definition.SupportsCharacterGrowth(0x22) {
t.Fatalf("male Striker contradictory-fitness skill=%d definition=%+v found=%t", skillID, definition, found)
}
}
for _, example := range []struct {
job byte
id uint16
branch int
path string
}{
{job: 2, id: 11, branch: 1, path: "skill/gunner/fastshoot.skl"},
{job: 13, id: 90, branch: 4, path: "skill/demoniclancer/doublethrow.skl"},
} {
definition, ok := catalog.Find(example.job, example.id)
if !ok || !definition.SupportsGrowType(example.branch) || definition.SupportsGrowType(0) || definition.Path != example.path {
t.Fatalf("strict profession ownership mismatch for %+v: definition=%+v found=%t", example, definition, ok)
}
}
profiles, err := LoadProfiles(context.Background(), archive, catalog)
if err != nil {
t.Fatal(err)
}
berserkerGrants, berserkerSupported := profiles.FreeGrantsAtLevel(catalog, 0, 0x03, 15)
if !berserkerSupported || grantLevel(berserkerGrants, 10) != 1 || grantLevel(berserkerGrants, 248) != 1 {
t.Fatalf("berserker level15 system grants=%v supported=%t want skills 10 and 248", berserkerGrants, berserkerSupported)
}
snakeSword, snakeSwordFound := catalog.Find(11, 31)
swordDemonGrants, swordDemonSupported := profiles.FreeGrantsAtLevel(catalog, 11, 0x03, 15)
if !snakeSwordFound || snakeSword.Active || snakeSword.SkillClass != 3 ||
!snakeSword.SupportsCharacterGrowth(0x03) ||
!swordDemonSupported || grantLevel(swordDemonGrants, 31) != 1 {
t.Fatalf(
"Sword Demon Snake Sword contract definition=%+v found=%t grants=%v supported=%t",
snakeSword, snakeSwordFound, swordDemonGrants, swordDemonSupported,
)
}
battleMageClass, battleMageClassSupported := profiles.FreeGrantsAtLevel(catalog, 3, 0x03, 90)
battleMageFirst, battleMageFirstSupported := profiles.FreeGrantsAtLevel(catalog, 3, 0x13, 90)
battleMageSecond, battleMageSecondSupported := profiles.FreeGrantsAtLevel(catalog, 3, 0x23, 90)
if !battleMageClassSupported || len(battleMageClass) != 25 ||
grantLevel(battleMageClass, 5) != 1 || grantLevel(battleMageClass, 6) != 1 ||
grantLevel(battleMageClass, 18) != 1 || grantLevel(battleMageClass, 197) != 1 ||
containsGrant(battleMageClass, 83) || containsGrant(battleMageClass, 84) {
t.Fatalf("battle-mage class-change baseline=%v supported=%t", battleMageClass, battleMageClassSupported)
}
if !battleMageFirstSupported || len(battleMageFirst) != 28 ||
grantLevel(battleMageFirst, 5) != 1 || grantLevel(battleMageFirst, 6) != 1 ||
grantLevel(battleMageFirst, 83) != 9 || containsGrant(battleMageFirst, 84) ||
grantLevel(battleMageFirst, 301) != 1 || grantLevel(battleMageFirst, 305) != 1 ||
containsGrant(battleMageFirst, 300) {
t.Fatalf("battle-mage first-awakening baseline=%v supported=%t", battleMageFirst, battleMageFirstSupported)
}
if !battleMageSecondSupported || len(battleMageSecond) != 29 ||
grantLevel(battleMageSecond, 5) != 1 || grantLevel(battleMageSecond, 6) != 1 ||
grantLevel(battleMageSecond, 83) != 9 || containsGrant(battleMageSecond, 84) || containsGrant(battleMageSecond, 126) ||
grantLevel(battleMageSecond, 300) != 1 || grantLevel(battleMageSecond, 301) != 1 || grantLevel(battleMageSecond, 305) != 1 {
t.Fatalf("battle-mage second-awakening baseline=%v supported=%t", battleMageSecond, battleMageSecondSupported)
}
paidAwakeningSkills := 0
freeAwakeningSkills := 0
for job := byte(0); job <= 15; job++ {
firstBranch, lastBranch := byte(1), byte(4)
if job == 8 {
lastBranch = 5
}
if job == 9 || job == 10 {
firstBranch, lastBranch = 0, 0
}
for branch := firstBranch; branch <= lastBranch; branch++ {
previous, previousOK := profiles.FreeGrants(job, branch, 0)
if !previousOK {
continue
}
previousLevels := make(map[uint16]int, len(previous))
for _, grant := range previous {
previousLevels[grant.SkillID] = grant.Level
}
for stage := byte(1); stage <= 2; stage++ {
declared, declaredOK := profiles.FreeGrants(job, branch, stage)
protected, protectedOK := profiles.FreeGrantsAtLevel(catalog, job, stage<<4|branch, 90)
if !declaredOK || !protectedOK {
t.Fatalf("job=%d branch=%d stage=%d declared=%t protected=%t", job, branch, stage, declaredOK, protectedOK)
}
for _, grant := range declared {
if grant.Level <= previousLevels[grant.SkillID] {
continue
}
definition, found := catalog.Find(job, grant.SkillID)
if !found {
t.Fatalf("awakening grant has no skill definition: job=%d branch=%d stage=%d skill=%d", job, branch, stage, grant.SkillID)
}
if len(definition.PurchaseCost) == 0 {
t.Fatalf("awakening grant has no purchase table: job=%d branch=%d stage=%d skill=%d", job, branch, stage, grant.SkillID)
}
if definition.LevelCost(0) > 0 {
paidAwakeningSkills++
if containsGrant(protected, grant.SkillID) {
t.Fatalf("paid awakening skill entered protected baseline: job=%d branch=%d stage=%d skill=%d cost=%d", job, branch, stage, grant.SkillID, definition.LevelCost(0))
}
} else {
freeAwakeningSkills++
if grantLevel(protected, grant.SkillID) < grant.Level {
t.Fatalf("free awakening skill missing from protected baseline: job=%d branch=%d stage=%d skill=%d", job, branch, stage, grant.SkillID)
}
}
}
previousLevels = make(map[uint16]int, len(declared))
for _, grant := range declared {
previousLevels[grant.SkillID] = grant.Level
}
}
}
}
if paidAwakeningSkills != 57 || freeAwakeningSkills != 61 {
t.Fatalf("awakening purchase classification paid=%d free=%d want paid=57 free=61", paidAwakeningSkills, freeAwakeningSkills)
}
expectedSupplements := map[string]struct{}{
"0/3/10": {}, "0/3/248": {}, "1/3/33": {},
"1/3/114": {},
"2/3/256": {}, "3/3/319": {},
"3/1/33": {}, "3/2/314": {}, "3/3/300": {}, "3/3/301": {},
"3/3/305": {}, "3/4/195": {}, "3/4/199": {}, "4/3/136": {},
"4/3/257": {}, "7/3/33": {},
"7/3/110": {}, "12/1/31": {}, "12/1/45": {}, "12/1/46": {},
"12/1/47": {}, "12/1/48": {}, "12/1/49": {}, "12/1/77": {},
"12/1/106": {}, "12/3/13": {}, "12/3/77": {}, "12/4/13": {},
"12/4/77": {}, "14/1/84": {}, "14/2/84": {},
"14/4/84": {}, "10/0/268": {},
}
foundSupplements := make(map[string]struct{}, len(expectedSupplements))
for job := byte(0); job <= 15; job++ {
firstBranch := byte(1)
lastBranch := byte(4)
if job == 9 || job == 10 {
firstBranch, lastBranch = 0, 0
} else if job == 8 {
lastBranch = 5
}
for branch := firstBranch; branch <= lastBranch; branch++ {
raw, rawOK := profiles.FreeGrants(job, branch, 2)
augmented, augmentedOK := profiles.FreeGrantsAtLevel(catalog, job, 0x20|branch, 90)
if !rawOK || !augmentedOK {
continue
}
rawIDs := make(map[uint16]struct{}, len(raw))
for _, grant := range raw {
rawIDs[grant.SkillID] = struct{}{}
}
for _, grant := range augmented {
if _, found := rawIDs[grant.SkillID]; found {
continue
}
key := fmt.Sprintf("%d/%d/%d", job, branch, grant.SkillID)
if _, expected := expectedSupplements[key]; !expected {
t.Fatalf("unexpected profession system supplement %s", key)
}
foundSupplements[key] = struct{}{}
}
}
}
if len(foundSupplements) != len(expectedSupplements) {
for key := range expectedSupplements {
if _, found := foundSupplements[key]; !found {
t.Errorf("missing profession system supplement %s", key)
}
}
t.Fatalf("profession system supplements=%d want=%d", len(foundSupplements), len(expectedSupplements))
}
witchToggle, found := catalog.Find(3, 167)
witchBaseline, witchSupported := profiles.FreeGrantsAtLevel(catalog, 3, 0x04, 90)
if !found || witchToggle.Path != "skill/mage/clearflapper.skl" || len(witchToggle.Prerequisites) != 1 ||
witchToggle.Prerequisites[0].SkillID != 95 || witchToggle.Prerequisites[0].Level != 1 ||
!witchSupported || containsGrant(witchBaseline, 167) {
t.Fatalf("witch clear-flapper optional toggle definition=%+v found=%t baseline=%v supported=%t", witchToggle, found, witchBaseline, witchSupported)
}
snapshot := profiles.Snapshot()
if snapshot.Jobs != 16 || snapshot.InitialGrants != 116 || snapshot.ClassGrants != 453 || snapshot.AwakeningGrants != 118 || snapshot.MissingSkills != 3 {
t.Fatalf("profession snapshot = %+v", snapshot)
}
if name, ok := profiles.DisplayName(11, 0x21); !ok || name != "剑皇" {
t.Fatalf("female-slayer second-awakening display=%q found=%t want=剑皇", name, ok)
}
if name, ok := profiles.DisplayName(2, 0x01); !ok || name != "漫游枪手" {
t.Fatalf("gunner class display=%q found=%t want=漫游枪手", name, ok)
}
fixedLevelSkills := 0
fixedLevelAt90 := 0
for _, definition := range catalog.Skills() {
if !definition.FixedLevelSkill {
continue
}
fixedLevelSkills++
if definition.FixedLevelForCharacter(90) > 0 {
fixedLevelAt90++
}
}
gunbladerFirst, ok := profiles.FreeGrants(15, 1, 1)
if !ok || len(gunbladerFirst) == 0 {
t.Fatalf("gunblader class1 awakening1 grants = %v, found=%t", gunbladerFirst, ok)
}
gunbladerSecond, ok := profiles.FreeGrants(15, 1, 2)
if !ok || !sameGrants(gunbladerFirst, gunbladerSecond) {
t.Fatalf("gunblader second awakening must not fabricate free grants: first=%v second=%v", gunbladerFirst, gunbladerSecond)
}
thiefFirst, _ := profiles.FreeGrants(6, 3, 1)
thiefSecond, _ := profiles.FreeGrants(6, 3, 2)
if containsGrant(thiefFirst, 73) || !containsGrant(thiefSecond, 73) {
t.Fatalf("thief branch3 second-awakening grant mismatch: first=%v second=%v", thiefFirst, thiefSecond)
}
atFighterFirst, _ := profiles.FreeGrants(7, 1, 1)
atFighterSecond, _ := profiles.FreeGrants(7, 1, 2)
if containsGrant(atFighterFirst, 242) || !containsGrant(atFighterSecond, 242) {
t.Fatalf("at-fighter branch1 second-awakening grant mismatch: first=%v second=%v", atFighterFirst, atFighterSecond)
}
atSwordMaster, ok := profiles.FreeGrantsAtLevel(catalog, 11, 0x21, 90)
if !ok || containsGrant(atSwordMaster, 52) || grantLevel(atSwordMaster, 54) <= 0 {
t.Fatalf("female-slayer sword-master level90 free baseline=%v found=%t", atSwordMaster, ok)
}
professionMasteryBranches := 0
for job := byte(0); job <= 15; job++ {
if job == 9 || job == 10 { // Dark Knight and Creator are external professions.
continue
}
base, supported := profiles.FreeGrants(job, 0, 0)
if !supported || containsGrant(base, 197) {
t.Fatalf("ordinary job=%d base must not own profession mastery skill 197: grants=%v supported=%t", job, base, supported)
}
lastBranch := byte(4)
if job == 8 { // Female mage owns the fifth ordinary class branch in this PVF.
lastBranch = 5
}
for branch := byte(1); branch <= lastBranch; branch++ {
grants, branchSupported := profiles.FreeGrants(job, branch, 0)
if !branchSupported || !containsGrant(grants, 197) {
t.Fatalf("ordinary job=%d branch=%d must grant profession mastery skill 197: grants=%v supported=%t", job, branch, grants, branchSupported)
}
professionMasteryBranches++
}
}
if professionMasteryBranches != 57 {
t.Fatalf("ordinary profession mastery branch count=%d want=57", professionMasteryBranches)
}
branchZeroFirstRequest, _ := ParseReward(2, "[awakening type]", []int64{1})
branchZeroFirst, err := profiles.PlanTransition(9, 0x00, branchZeroFirstRequest)
if err != nil || branchZeroFirst.NewGrowType != 0x10 {
t.Fatalf("real PVF branch-zero first awakening = %+v, %v", branchZeroFirst, err)
}
branchZeroSecondRequest, _ := ParseReward(3, "[awakening type]", []int64{2})
branchZeroSecond, err := profiles.PlanTransition(10, 0x10, branchZeroSecondRequest)
if err != nil || branchZeroSecond.NewGrowType != 0x20 {
t.Fatalf("real PVF branch-zero second awakening = %+v, %v", branchZeroSecond, err)
}
if _, err := profiles.PlanTransition(8, 0x00, branchZeroFirstRequest); err == nil {
t.Fatal("ordinary branch-zero job unexpectedly exposes awakening capability")
}
t.Logf("profession snapshot=%+v fixed_level_skills=%d active_at_90=%d gunblader_class1_awaken1=%v", snapshot, fixedLevelSkills, fixedLevelAt90, gunbladerFirst)
}
func TestRealScriptPVFProfessionGrantFloorsAboveTransitionLevel(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify profession grant floors")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
index, err := dnfpvf.Build(context.Background(), archive, dnfpvf.BuildOptions{Lists: []string{dnfskill.DefaultList}})
if err != nil {
t.Fatal(err)
}
catalog, err := dnfskill.Load(context.Background(), index, dnfskill.Options{})
if err != nil {
t.Fatal(err)
}
profiles, err := LoadProfiles(context.Background(), archive, catalog)
if err != nil {
t.Fatal(err)
}
type professionBoundary struct {
growType byte
level int
}
risky := make([]string, 0)
boundaries := 0
levelRisks := 0
prerequisiteRisks := 0
for rawJob, profile := range profiles.jobs {
states := []professionBoundary{{growType: 0, level: 1}}
firstBranch, lastBranch := byte(1), byte(4)
if rawJob == 9 || rawJob == 10 {
firstBranch, lastBranch = 0, 0
} else if rawJob == 8 {
lastBranch = 5
}
for branch := firstBranch; branch <= lastBranch; branch++ {
if branch > 0 {
states = append(states, professionBoundary{growType: branch, level: 15})
}
for stage := byte(1); stage <= 2; stage++ {
if !profile.awakeningSupported[branch][stage] {
continue
}
level := 50
if stage == 2 {
level = 75
}
states = append(states, professionBoundary{growType: stage<<4 | branch, level: level})
}
}
for _, state := range states {
boundaries++
grants, ok := profiles.FreeGrantsAtLevel(catalog, rawJob, state.growType, state.level)
if !ok {
t.Fatalf("job=%d grow_type=0x%02x boundary=%d has no free baseline", rawJob, state.growType, state.level)
}
grantLevels := make(map[uint16]int, len(grants))
for _, grant := range grants {
if grant.Level > grantLevels[grant.SkillID] {
grantLevels[grant.SkillID] = grant.Level
}
}
for _, grant := range grants {
definition, found := catalog.Find(rawJob, grant.SkillID)
if !found {
continue // MissingSkills is audited by TestRealScriptPVFProfessionProfiles.
}
if int(definition.RequiredLevel) > state.level {
levelRisks++
risky = append(risky, fmt.Sprintf(
"level:%d/0x%02x/%d:floor%d:req%d:level%d",
rawJob, state.growType, grant.SkillID, grant.Level, definition.RequiredLevel, state.level,
))
}
for _, prerequisite := range definition.Prerequisites {
if grantLevels[prerequisite.SkillID] >= prerequisite.Level {
continue
}
prerequisiteRisks++
risky = append(risky, fmt.Sprintf(
"prerequisite:%d/0x%02x/%d:floor%d:needs%d@%d:has%d",
rawJob, state.growType, grant.SkillID, grant.Level,
prerequisite.SkillID, prerequisite.Level, grantLevels[prerequisite.SkillID],
))
break
}
}
}
}
sort.Strings(risky)
if boundaries != 191 || levelRisks != 63 || prerequisiteRisks != 1 || len(risky) != 64 {
t.Fatalf(
"profession floor audit boundaries=%d level_risks=%d prerequisite_risks=%d total=%d risks=%v",
boundaries, levelRisks, prerequisiteRisks, len(risky), risky,
)
}
t.Logf(
"profession boundaries=%d permanent-floor risks=%d (level=%d prerequisite=%d): %v",
boundaries, len(risky), levelRisks, prerequisiteRisks, risky,
)
}
func containsGrant(grants []Grant, skillID uint16) bool {
for _, grant := range grants {
if grant.SkillID == skillID {
return true
}
}
return false
}
func sameGrants(left []Grant, right []Grant) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}