Files
90CNServer/server/internal/services/dnfbridge/raid_fiendwar_gold_card_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

225 lines
9.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dnfbridge
import (
"os"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/raid"
platformpvf "longheng.io/server/internal/platform/pvf"
)
// 超时空的翻牌金牌:**PVF 里 flag=1 的牌必须仍然是金牌**。
//
// 链路(证据 raid_reward_runtime.go:554-562、raid_reward_runtime_test.go:52-57):
//
// fiendwar.etc 的 [CLEAR REWARD ITEM] → [STATE REWARD] 行第 5 列 flag
// → anton_pvf.go:693/700 AntonStateReward.Flag
// → anton_reward_pvf.go:280 PoolEntry.Flag
// → anton_reward_pvf.go:559 Resolved.Flag (ResolvePool 带出来)
// → raid_reward_runtime.go:564 RaidSettlementReward.Kind ← 一条奖励行的**首字节**
// → result_flow_protocol.go:152 0x0258 行首字节
//
// 客户端把这一字节存在牌对象 `+0x44`,`sub_167FE00` 在**全队板(resultType 3)**
// 上按 `+0x44 == 1` 画金牌(0x0167FE1E)。
//
// 真机数据(tools/…/超时空_攻坚结束翻牌提交_根因与修复_20261002.md:56-67,PVF 实证):
//
// party_card -1 800 490008037 0 ← 翻牌池四档,**全是 0**
// party_card -1 150 490008035 0
// party_card -1 35 490008036 0
// party_card -1 15 490008034 0
// pcroom_card -1 100 10094733 0
// squad_item -1 90 490008038 0 ← 团队奖励五档,**后四档是 1**
// squad_item -1 5 490008672 1
// squad_item -1 3 490008039 1
// squad_item -1 1 490008040 1
// squad_item -1 1 490008818 1
//
// 也就是说超时空的金牌只可能从 `squad_item` 来;`party_card`(翻牌那四张)恒为 0。
// 本用例走**生产那条路**(`runtimeRaidRewardCatalogForState` → `resolveRuntimeRaidRewardPlan`
// → `runtimeRaidTeamSquadRewardBody`),并且用**真 Script.pvf**,所以:
//
// 把 raid_reward_runtime.go:564 的 `Kind: resolved.Flag` 改回写死的 0 → 必红;
// 把 runtimeRaidRewardPoolKinds 的 {"party_card","squad_item"} 调换顺序 → 必红
// (全队板取的是最后一行的位置启发式,调换后拿到的就是恒为 0 的 party_card)。
func TestRealPVFFiendWarGoldSquadItemRowStaysGoldOnTheWire(t *testing.T) {
path := os.Getenv("DNF_FIENDWAR_REAL_PVF")
if path == "" {
t.Skip("DNF_FIENDWAR_REAL_PVF not set")
}
archive, err := platformpvf.Open(path)
if err != nil {
t.Fatalf("open real Script.pvf: %v", err)
}
config, err := raid.LoadFiendWarConfig(archive)
if err != nil {
t.Fatalf("load Fiend War config: %v", err)
}
now := time.Date(2026, 10, 2, 6, 0, 0, 0, time.UTC)
members := []runtimeRaidMemberState{
{CharID: 1001, Name: "leader", GroupIndex: 1, SlotOrder: 0},
{CharID: 1002, Name: "member", GroupIndex: 2, SlotOrder: 0},
}
lifecycle := newRuntimeRaidLifecycleState(now.Add(-time.Hour))
lifecycle.Status = runtimeRaidLifecycleAwaitingRewards
lifecycle.Phase = runtimeRaidDungeonPhaseOne
lifecycle.RewardOpenedAt = now.Add(-time.Minute)
lifecycle.RewardSelections[1001] = runtimeRaidRewardSelection{Mode: 0, Index: 2, ConfirmedAt: now.Add(-time.Minute)}
lifecycle.RewardSelections[1002] = runtimeRaidRewardSelection{Mode: 0, Index: 3, ConfirmedAt: now.Add(-time.Minute)}
lifecycle.RewardSkippedMembers = make(map[uint16]bool)
// 结算只赔"清过门槛的人":PhaseClearCounts 是战况表那一列,
// FiendWarClearCounts 是超时空自己的 [MINIMAL DUNGEON CLEAR COUNT] 门(=1)。
seedRuntimeRaidPhaseClearsForTest(&lifecycle, 1001, 1002)
lifecycle.FiendWarClearCounts = map[uint16]int{1001: 1, 1002: 1}
const raidKey = uint32(0x00c80452)
state := runtimeRaidState{
RaidKey: raidKey,
RaidMode: runtimeRaidOwnerFiendWar,
LeaderID: 1001,
Started: true,
EntryCostPaid: true,
ChargedMembers: []uint16{1001, 1002},
StartAttempt: 1,
Members: append([]runtimeRaidMemberState(nil), members...),
FrozenMembers: append([]runtimeRaidMemberState(nil), members...),
FiendWarConfig: config,
Lifecycle: lifecycle,
}
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain, pvfPath: path},
raids: map[uint32]*runtimeRaidState{raidKey: &state},
raidRewardCompleted: make(map[runtimeRaidRewardCompletionKey]runtimeRaidSettlementRewardCompletion),
// 99 % 1000 = 99 → party_card 第一行(标记 0);
// 99 % 100 = 99 → squad_item 最后一行(权重 1、标记 1)。
raidRewardRandom: func() uint64 { return 99 },
}
catalog, err := service.runtimeRaidRewardCatalogForState(state)
if err != nil {
t.Fatalf("超时空奖励目录加载失败: %v", err)
}
// (1) 先钉住 PVF 数据本身:金牌只能来自 squad_item。
goldRows := 0
for _, entry := range fixturePoolEntries(t, catalog, "squad_item") {
if entry.Flag == 1 {
goldRows++
}
}
if goldRows == 0 {
t.Fatal("fiendwar.etc 的 squad_item 池里一条 flag=1 的行都没有:真机上就不该有金牌了")
}
for _, entry := range fixturePoolEntries(t, catalog, "party_card") {
if entry.Flag != 0 {
t.Fatalf("party_card 出现 flag=%d 的行:翻牌那四张按 PVF 恒为 0", entry.Flag)
}
}
// (2) 走生产的 plan:投出来的每一行都要带上它自己那条 PVF 行的 flag。
plan, err := service.resolveRuntimeRaidRewardPlan(state, runtimeRaidDungeonPhaseOne, now, catalog)
if err != nil {
t.Fatalf("结算奖励 plan: %v", err)
}
if len(plan.Rewards.Members) != 2 {
t.Fatalf("plan 的成员数 = %d, want 2", len(plan.Rewards.Members))
}
for _, member := range plan.Rewards.Members {
if len(member.Rewards) != 2 {
t.Fatalf("成员 %d 的奖励行 = %+v, want party_card + squad_item", member.MemberKey, member.Rewards)
}
if got := member.Rewards[0].Kind; got != 0 {
t.Fatalf("成员 %d 的 party_card 首字节 = %d, want 0(PVF 标记 0)", member.MemberKey, got)
}
if got := member.Rewards[1].Kind; got != 1 {
t.Fatalf("成员 %d 的 squad_item 首字节 = %d, want 1 —— "+
"PVF 里那一条 flag=1 的稀有 squad_item 必须以 1 出现在 0x0258 行首字节上,"+
"否则客户端 sub_167FE00 读到的 +0x44 不是 1,金牌永远画不出来",
member.MemberKey, got)
}
if member.Rewards[1].ItemIDOrValue == 0 || member.Rewards[1].CountOrAux == 0 {
t.Fatalf("成员 %d 的 squad_item 行 = %+v, want 有物品与数量", member.MemberKey, member.Rewards[1])
}
}
// (3) 冻住的翻牌包体(resultType 1,每队一张揭示板)里,行首字节同样要是 1。
// 第 0 行是 party_card(恒 0),第 1 行是 squad_item(这条 PVF 里是 1)。
revealKinds := fixtureRewardRowKinds(t, plan.Body)
if !fixtureEveryRowHasKind(t, revealKinds, 1, 1) {
t.Fatalf("翻牌包体里各成员的第二行首字节 = %v, want 每一行都是 1\nbody=% X", revealKinds, plan.Body)
}
// (4) 全队板(resultType 3)——客户端就是在这张板上按 +0x44 == 1 画金牌的。
boardBody, ok, err := runtimeRaidTeamSquadRewardBody(runtimeRaidSettlementRewardDelivery{Rewards: plan.Rewards})
if err != nil || !ok {
t.Fatalf("全队板 ok=%t err=%v", ok, err)
}
if boardBody[0] != runtimeRaidTeamRewardResultType {
t.Fatalf("全队板 resultType = %d, want %d", boardBody[0], runtimeRaidTeamRewardResultType)
}
boardKinds := fixtureRewardRowKinds(t, boardBody)
if !fixtureEveryRowHasKind(t, boardKinds, 0, 1) {
t.Fatalf("全队板(resultType 3)里各成员那一行的首字节 = %v, want 每一行都是 1 —— "+
"这一行必须是 squad_item(有 flag=1 的池),不是恒为 0 的 party_card\nbody=% X",
boardKinds, boardBody)
}
}
// fixturePoolEntries 读一个奖励池的原始行(含 flag)。
func fixturePoolEntries(t *testing.T, catalog *antonRewardPVFCatalog, kind string) []antonRewardPVFPoolEntry {
t.Helper()
pool, found := catalog.Pool(0, kind)
if !found || len(pool.Entries) == 0 {
t.Fatalf("超时空 phase0 %s 池为空 (found=%t)", kind, found)
}
return pool.Entries
}
// fixtureRewardRowKinds 把一条 0x0258 包体解成"每名成员的每一行首字节"。
// 布局:u8 resultType, u8 memberCount, 每人 {u16 key, u8 cardIndex, u8 rowCount,
// 行{u8 kind, u32 item, u16 count}}(BuildRaidSettlementRewardsBody)。
func fixtureRewardRowKinds(t *testing.T, body []byte) [][]byte {
t.Helper()
if len(body) < 2 {
t.Fatalf("0x0258 包体太短: % X", body)
}
count := int(body[1])
offset := 2
out := make([][]byte, 0, count)
for member := 0; member < count; member++ {
if offset+4 > len(body) {
t.Fatalf("0x0258 包体在成员 %d 处截断: % X", member, body)
}
rows := int(body[offset+3])
offset += 4
kinds := make([]byte, 0, rows)
for row := 0; row < rows; row++ {
if offset+7 > len(body) {
t.Fatalf("0x0258 包体在成员 %d 第 %d 行截断: % X", member, row, body)
}
kinds = append(kinds, body[offset])
offset += 7
}
out = append(out, kinds)
}
if offset != len(body) {
t.Fatalf("0x0258 包体尾部多出 %d 字节: % X", len(body)-offset, body)
}
return out
}
// fixtureEveryRowHasKind 检查每名成员的第 index 行首字节都等于 want,且行数足够。
func fixtureEveryRowHasKind(t *testing.T, kinds [][]byte, index int, want byte) bool {
t.Helper()
for member, rows := range kinds {
if len(rows) <= index {
t.Fatalf("成员 %d 只有 %d 行,取不到第 %d 行", member, len(rows), index)
}
if rows[index] != want {
return false
}
}
return len(kinds) != 0
}