服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
265 lines
8.5 KiB
Go
265 lines
8.5 KiB
Go
package companion
|
|
|
|
import (
|
|
"reflect"
|
|
"testing"
|
|
"time"
|
|
|
|
"longheng.io/server/internal/modules/dnf/equip"
|
|
"longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
func visualFixture(
|
|
sourceID, name, job string,
|
|
grow int64,
|
|
weapon int64,
|
|
avatars map[string]repository.EquipmentEntry,
|
|
) (repository.CompanionSourceState, Selection) {
|
|
entries := map[string]repository.EquipmentEntry{
|
|
"weapon": {
|
|
SlotIndex: 12,
|
|
ItemID: weapon,
|
|
RawEntry: []byte{1, 2, 3},
|
|
Extra: map[string]string{
|
|
"item_kind": "equipment",
|
|
"equipment_type": "17",
|
|
"current_exe_equipment_type": "12",
|
|
"pvf_path": "equipment/character/test/weapon/test.equ",
|
|
},
|
|
},
|
|
}
|
|
for key, value := range avatars {
|
|
entries[key] = value
|
|
}
|
|
state := repository.CompanionSourceState{
|
|
Owner: repository.CharacterRecord{
|
|
CharacterID: "1",
|
|
AccountID: "account-test",
|
|
Stats: map[string]int64{"delete_flag": 0},
|
|
},
|
|
Source: repository.CharacterRecord{
|
|
CharacterID: sourceID,
|
|
AccountID: "account-test",
|
|
Name: name,
|
|
Job: job,
|
|
Level: 86,
|
|
Stats: map[string]int64{"delete_flag": 0, "grow_type": grow},
|
|
},
|
|
Equipment: repository.EquipmentRecord{
|
|
CharacterID: sourceID,
|
|
Entries: entries,
|
|
},
|
|
HasEquipment: true,
|
|
}
|
|
selection := Selection{
|
|
AccountID: "account-test",
|
|
OwnerCharacterID: "1",
|
|
SourceCharacterID: sourceID,
|
|
OnlineStateKnown: true,
|
|
}
|
|
return state, selection
|
|
}
|
|
|
|
func avatarFixture(slot int16, item int64, equipmentType string) repository.EquipmentEntry {
|
|
return repository.EquipmentEntry{
|
|
SlotIndex: slot,
|
|
ItemID: item,
|
|
RawEntry: []byte{4, 5, 6},
|
|
Extra: map[string]string{
|
|
"item_kind": "avatar",
|
|
"equipment_type": equipmentType,
|
|
"pvf_path": "equipment/character/test/avatar/test.equ",
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestVisualProjectionSwitchesABAndDoesNotMutateSource(t *testing.T) {
|
|
aState, aSelection := visualFixture(
|
|
"2", "DETSCS", "0", 35, 101010912,
|
|
map[string]repository.EquipmentEntry{
|
|
"hat": avatarFixture(0, 401550018, "[hat avatar]"),
|
|
"hair": avatarFixture(1, 101560869, "[hair avatar]"),
|
|
},
|
|
)
|
|
bState, bSelection := visualFixture(
|
|
"8", "DETSDT", "13", 3, 114000248,
|
|
map[string]repository.EquipmentEntry{
|
|
"weapon-avatar": avatarFixture(10, 414600011, "[weapon avatar]"),
|
|
},
|
|
)
|
|
aBefore := repository.CompanionSourceState{
|
|
Owner: repository.CloneCharacter(aState.Owner),
|
|
Source: repository.CloneCharacter(aState.Source),
|
|
Equipment: repository.CloneEquipment(aState.Equipment),
|
|
HasEquipment: aState.HasEquipment,
|
|
}
|
|
bBefore := repository.CompanionSourceState{
|
|
Owner: repository.CloneCharacter(bState.Owner),
|
|
Source: repository.CloneCharacter(bState.Source),
|
|
Equipment: repository.CloneEquipment(bState.Equipment),
|
|
HasEquipment: bState.HasEquipment,
|
|
}
|
|
|
|
now := time.Unix(1_800_000_000, 0)
|
|
a, err := BuildVisualProjection(aState, aSelection, now)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, err := BuildVisualProjection(bState, bSelection, now)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if a.Name != "DETSCS" || b.Name != "DETSDT" ||
|
|
a.Job != 0 || b.Job != 13 ||
|
|
a.GrowType != 35 || b.GrowType != 3 {
|
|
t.Fatalf("identity projection A=%+v B=%+v", a, b)
|
|
}
|
|
if a.Weapon == nil || b.Weapon == nil ||
|
|
a.Weapon.ItemID != 101010912 ||
|
|
b.Weapon.ItemID != 114000248 {
|
|
t.Fatalf("weapon projection A=%+v B=%+v", a.Weapon, b.Weapon)
|
|
}
|
|
if len(a.Avatars) != 2 || len(b.Avatars) != 1 ||
|
|
a.Avatars[0].DisplayItemID != 401550018 ||
|
|
b.Avatars[0].DisplayItemID != 414600011 {
|
|
t.Fatalf("avatar projection A=%+v B=%+v", a.Avatars, b.Avatars)
|
|
}
|
|
if a.Fingerprint() == b.Fingerprint() {
|
|
t.Fatal("different source characters produced same visual fingerprint")
|
|
}
|
|
if !reflect.DeepEqual(aState.Owner, aBefore.Owner) ||
|
|
!reflect.DeepEqual(aState.Source, aBefore.Source) ||
|
|
!reflect.DeepEqual(aState.Equipment, aBefore.Equipment) ||
|
|
!reflect.DeepEqual(bState.Owner, bBefore.Owner) ||
|
|
!reflect.DeepEqual(bState.Source, bBefore.Source) ||
|
|
!reflect.DeepEqual(bState.Equipment, bBefore.Equipment) {
|
|
t.Fatal("BuildVisualProjection mutated source records")
|
|
}
|
|
|
|
a.Weapon.ItemID = 1
|
|
a.Avatars[0].ItemID = 2
|
|
if aState.Equipment.Entries["weapon"].ItemID != 101010912 ||
|
|
aState.Equipment.Entries["hat"].ItemID != 401550018 {
|
|
t.Fatal("returned projection aliases source equipment")
|
|
}
|
|
}
|
|
|
|
func TestVisualProjectionAcceptsFreshCharacterWeaponWithoutDerivedKind(t *testing.T) {
|
|
state, selection := visualFixture(
|
|
"2", "DETXX", "11", 0, 101010912, nil,
|
|
)
|
|
weapon := state.Equipment.Entries["weapon"]
|
|
delete(weapon.Extra, "item_kind")
|
|
delete(weapon.Extra, "current_exe_equipment_type")
|
|
weapon.Extra["pvf_path"] = "equipment/character/swordman/weapon/katana/101010912.equ"
|
|
weapon.Extra["equipment_type"] = "17"
|
|
state.Equipment.Entries["weapon"] = weapon
|
|
|
|
got, err := BuildVisualProjection(
|
|
state, selection, time.Unix(1_800_000_000, 0),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Weapon == nil || got.Weapon.ItemID != 101010912 {
|
|
t.Fatalf("fresh-character weapon projection=%+v", got.Weapon)
|
|
}
|
|
}
|
|
|
|
func TestVisualProjectionUsesCloneAvatarDisplayID(t *testing.T) {
|
|
clone := avatarFixture(3, 499999999, "[coat avatar]")
|
|
clone.Extra[equip.CloneAvatarDisplayItemIDExtraKey] = "412500009"
|
|
state, selection := visualFixture(
|
|
"8", "DETSDT", "13", 3, 114000248,
|
|
map[string]repository.EquipmentEntry{"coat": clone},
|
|
)
|
|
got, err := BuildVisualProjection(
|
|
state, selection, time.Unix(1_800_000_000, 0),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got.Avatars) != 1 ||
|
|
got.Avatars[0].ItemID != 499999999 ||
|
|
got.Avatars[0].DisplayItemID != 412500009 {
|
|
t.Fatalf("clone avatar projection=%+v", got.Avatars)
|
|
}
|
|
}
|
|
|
|
func TestVisualProjectionAcceptsPrimaryAndSupportWeapon(t *testing.T) {
|
|
state, selection := visualFixture("13", "Vagabond", "11", 4, 28246, nil)
|
|
secondary := state.Equipment.Entries["weapon"]
|
|
secondary.SlotIndex, secondary.ItemID = 24, 28239
|
|
state.Equipment.Entries["support"] = secondary
|
|
before := repository.CloneEquipment(state.Equipment)
|
|
got, err := BuildVisualProjection(state, selection, time.Unix(1_800_000_000, 0))
|
|
if err != nil || got.Weapon == nil || got.Weapon.Slot != 12 || got.Weapon.ItemID != 28246 {
|
|
t.Fatalf("valid dual weapons rejected or primary changed: %+v err=%v", got, err)
|
|
}
|
|
if !reflect.DeepEqual(before, state.Equipment) {
|
|
t.Fatal("display projection changed source equipment")
|
|
}
|
|
delete(state.Equipment.Entries, "weapon")
|
|
got, err = BuildVisualProjection(state, selection, time.Unix(1_800_000_000, 0))
|
|
if err != nil || got.Weapon != nil {
|
|
t.Fatalf("support weapon substituted for missing primary: %+v err=%v", got, err)
|
|
}
|
|
}
|
|
|
|
func TestVisualProjectionRejectsDuplicateWeapons(t *testing.T) {
|
|
state, selection := visualFixture(
|
|
"2", "DETSCS", "0", 35, 101010912, nil,
|
|
)
|
|
state.Equipment.Entries["weapon2"] = repository.EquipmentEntry{
|
|
SlotIndex: 20,
|
|
ItemID: 101010913,
|
|
Extra: map[string]string{
|
|
"item_kind": "equipment",
|
|
"pvf_path": "equipment/character/test/weapon/other.equ",
|
|
},
|
|
}
|
|
if _, err := BuildVisualProjection(
|
|
state, selection, time.Unix(1_800_000_000, 0),
|
|
); err != ErrSnapshot {
|
|
t.Fatalf("duplicate weapon err=%v want=%v", err, ErrSnapshot)
|
|
}
|
|
}
|
|
|
|
func TestProjectionSettingsSelectExactDifferentCharacter(t *testing.T) {
|
|
scope, err := SettingsScope("3")
|
|
if err != nil || scope != "character:3:companion-v1" {
|
|
t.Fatalf("scope=%q err=%v", scope, err)
|
|
}
|
|
record := repository.SettingsRecord{
|
|
Scope: scope,
|
|
Values: map[string]string{
|
|
"source_character_id": "2",
|
|
},
|
|
}
|
|
source, found, err := SourceCharacterIDFromSettings(record, "3")
|
|
if err != nil || !found || source != "2" {
|
|
t.Fatalf("source=%q found=%t err=%v", source, found, err)
|
|
}
|
|
record.Values["source_character_id"] = "8"
|
|
source, found, err = SourceCharacterIDFromSettings(record, "3")
|
|
if err != nil || !found || source != "8" {
|
|
t.Fatalf("source=%q found=%t err=%v", source, found, err)
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
record repository.SettingsRecord
|
|
owner string
|
|
want error
|
|
}{
|
|
{"self", repository.SettingsRecord{Scope: scope, Values: map[string]string{"source_character_id": "3"}}, "3", ErrSelf},
|
|
{"leading zero", repository.SettingsRecord{Scope: scope, Values: map[string]string{"source_character_id": "02"}}, "3", ErrSelection},
|
|
{"wrong scope", repository.SettingsRecord{Scope: "character:4:companion-v1", Values: map[string]string{"source_character_id": "2"}}, "3", ErrSelection},
|
|
} {
|
|
if _, _, got := SourceCharacterIDFromSettings(tc.record, tc.owner); got != tc.want {
|
|
t.Fatalf("%s err=%v want=%v", tc.name, got, tc.want)
|
|
}
|
|
}
|
|
}
|