服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
128 lines
5.1 KiB
Go
128 lines
5.1 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"strconv"
|
|
"testing"
|
|
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
func expandedRosterRecords(count int) []dnfrepo.CharacterRecord {
|
|
characters := make([]dnfrepo.CharacterRecord, 0, count)
|
|
for slot := 0; slot < count; slot++ {
|
|
id := strconv.Itoa(slot + 1)
|
|
characters = append(characters, dnfrepo.CharacterRecord{
|
|
CharacterID: id,
|
|
AccountID: "dnf:1",
|
|
Slot: slot,
|
|
Name: "character-" + id,
|
|
Job: "0",
|
|
Level: 1,
|
|
})
|
|
}
|
|
return characters
|
|
}
|
|
|
|
// 回归:账号记录缺失或元数据损坏时限额回落到基线 32,但库里的角色仍然坐在 32..59 号
|
|
// 槽位上。投影不能跟着回落,否则那批角色会整批从选角页消失。
|
|
func TestEffectiveCharacterSlotLimitCoversOccupiedSlots(t *testing.T) {
|
|
characters := expandedRosterRecords(40)
|
|
if got := effectiveCharacterSlotLimit(characters, defaultCharacterSlots); got != 40 {
|
|
t.Fatalf("fallback limit = %d, want 40", got)
|
|
}
|
|
if got := effectiveCharacterSlotLimit(characters, maxCharacterSlots); got != maxCharacterSlots {
|
|
t.Fatalf("persisted limit was lowered to %d", got)
|
|
}
|
|
if got := effectiveCharacterSlotLimit(nil, defaultCharacterSlots); got != defaultCharacterSlots {
|
|
t.Fatalf("empty roster limit = %d, want the baseline %d", got, defaultCharacterSlots)
|
|
}
|
|
last := []dnfrepo.CharacterRecord{{CharacterID: "1", AccountID: "a", Slot: maxCharacterSlots - 1}}
|
|
if got := effectiveCharacterSlotLimit(last, defaultCharacterSlots); got != maxCharacterSlots {
|
|
t.Fatalf("last-slot limit = %d, want %d", got, maxCharacterSlots)
|
|
}
|
|
}
|
|
|
|
// 回归:限额回落到基线时,mode-2 快照必须仍然按真实占用槽位算总数和页数,
|
|
// op679 扩展批次也必须仍然能取到 36..39 号槽位。
|
|
func TestRosterSurvivesSlotLimitFallbackWithExpandedSlots(t *testing.T) {
|
|
characters := expandedRosterRecords(40)
|
|
body := buildNoPackRosterBodyForLimit(characters, defaultCharacterSlots)
|
|
if len(body) < 15 {
|
|
t.Fatalf("roster body too short: %d", len(body))
|
|
}
|
|
if body[1] != byte(len(characters)) {
|
|
t.Fatalf("roster total count = %d, want %d", body[1], len(characters))
|
|
}
|
|
// mode-2 只承载 0..35,剩下的走扩展批次。
|
|
if got := binary.LittleEndian.Uint16(body[13:15]); got != currentRosterExtensionBatchSize {
|
|
t.Fatalf("mode-2 row count = %d, want %d", got, currentRosterExtensionBatchSize)
|
|
}
|
|
offset := 15
|
|
for _, character := range characters[:currentRosterExtensionBatchSize] {
|
|
if got := int(binary.LittleEndian.Uint16(body[offset : offset+2])); got != character.Slot {
|
|
t.Fatalf("mode-2 entry at offset %d carries slot %d, want %d", offset, got, character.Slot)
|
|
}
|
|
offset += noPackRosterEntryLen(character)
|
|
}
|
|
if got := body[offset]; got != 2 {
|
|
t.Fatalf("roster page count = %d, want 2", got)
|
|
}
|
|
|
|
extension, ok := buildNoPackRosterExtensionBodyForLimit(characters, defaultCharacterSlots, currentRosterExtensionBatchSize)
|
|
if !ok {
|
|
t.Fatal("extension batch was rejected after the slot limit fell back to the baseline")
|
|
}
|
|
if extension[0] != 4 {
|
|
t.Fatalf("extension mode = %d, want 4", extension[0])
|
|
}
|
|
if got := binary.LittleEndian.Uint16(extension[1:3]); got != uint16(len(characters)) {
|
|
t.Fatalf("extension total count = %d, want %d", got, len(characters))
|
|
}
|
|
if got := binary.LittleEndian.Uint16(extension[3:5]); got != 4 {
|
|
t.Fatalf("extension row count = %d, want 4", got)
|
|
}
|
|
// 扩展批次的行键必须是账号级真实槽位,不是批次内的零基序号。
|
|
offset = 5
|
|
for _, character := range characters[currentRosterExtensionBatchSize:] {
|
|
if got := int(binary.LittleEndian.Uint16(extension[offset : offset+2])); got != character.Slot {
|
|
t.Fatalf("extension entry at offset %d carries slot %d, want %d", offset, got, character.Slot)
|
|
}
|
|
offset += noPackRosterEntryLen(character)
|
|
}
|
|
}
|
|
|
|
// 回归:nextCharacterSlot 找不到空位时曾返回 len(records),槽位有空洞时这个下标
|
|
// 正好落在一个已占用的槽位上,写进去会撞唯一约束。
|
|
func TestNextCharacterSlotReusesHolesAndRefusesWhenFull(t *testing.T) {
|
|
characters := expandedRosterRecords(40)
|
|
withHole := make([]dnfrepo.CharacterRecord, 0, len(characters)-1)
|
|
for _, character := range characters {
|
|
if character.Slot == 35 {
|
|
continue
|
|
}
|
|
withHole = append(withHole, character)
|
|
}
|
|
if got := nextCharacterSlot(withHole, dnfrepo.MaxCharacterSlotLimit); got != 35 {
|
|
t.Fatalf("next slot = %d, want the free slot 35", got)
|
|
}
|
|
if got := nextCharacterSlot(characters, dnfrepo.MaxCharacterSlotLimit); got != 40 {
|
|
t.Fatalf("next slot = %d, want 40", got)
|
|
}
|
|
full := expandedRosterRecords(dnfrepo.MaxCharacterSlotLimit)
|
|
if got := nextCharacterSlot(full, dnfrepo.MaxCharacterSlotLimit); got != -1 {
|
|
t.Fatalf("full roster next slot = %d, want -1", got)
|
|
}
|
|
// 空洞出现在扩栏区间里时同样要复用,而不是回一个重复槽位。
|
|
fullWithHole := make([]dnfrepo.CharacterRecord, 0, len(full)-1)
|
|
for _, character := range full {
|
|
if character.Slot == 47 {
|
|
continue
|
|
}
|
|
fullWithHole = append(fullWithHole, character)
|
|
}
|
|
if got := nextCharacterSlot(fullWithHole, dnfrepo.MaxCharacterSlotLimit); got != 47 {
|
|
t.Fatalf("next slot = %d, want the free slot 47", got)
|
|
}
|
|
}
|