DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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

No files matched your search

@@ -0,0 +1,549 @@
package dnfbridge
import (
"context"
"encoding/binary"
"os"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/dnfenum"
"longheng.io/server/internal/modules/dnf/premium"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
"longheng.io/server/internal/modules/dnf/worldmap"
platformpvf "longheng.io/server/internal/platform/pvf"
)
// The live [stamina recovery cost] row, trimmed: nineteen free levels then the
// real curve. Index is level-1, so level 20 is the first charged level.
const currentWeaknessCostFixture = `
[stamina recovery cost]
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 5000 5210 5420 5630 5840 6050
[/stamina recovery cost]
`
func prepareCurrentWeaknessTest(
t *testing.T,
characterLevel int,
weakness int64,
gold int64,
premiums map[int64]int64,
) (*Service, *gameSession, dnfrepo.Group, *bufferConn) {
t.Helper()
repositories := dnfrepomemory.NewMemoryGroup()
account := dnfrepo.AccountRecord{AccountID: "account-1", Metadata: map[string]string{}}
now := time.Unix(1_700_000_000, 0).UTC()
for premiumType, seconds := range premiums {
premium.Upsert(&account, premiumType, seconds, 1, now)
}
if err := repositories.Account.Save(context.Background(), account); err != nil {
t.Fatal(err)
}
stats := map[string]int64{}
if weakness != 0 {
stats[currentWeaknessStatKey] = weakness
}
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
CharacterID: "5",
AccountID: "account-1",
Level: characterLevel,
Stats: stats,
}); err != nil {
t.Fatal(err)
}
slots := map[string]dnfrepo.ItemStack{}
if gold > 0 {
// The legacy paid-recovery wallet: main-list slot 0. This server keeps
// gold in the character's own `gold` stat, which the client's wallet is
// projected from, so this fixture deliberately exercises the fallback
// store that weakness_state.go still honours. A test that wants the
// authoritative column calls seedCurrentWeaknessCharacterGold.
slots[currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, 0)] =
dnfrepo.ItemStack{ItemID: 0, Count: gold}
}
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
CharacterID: "5", Slots: slots,
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
queue := newFakeCurrentDungeonDeathTimerQueue()
queue.now = now
service := &Service{
options: options{accountID: "account-1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
gameplayTimers: queue,
}
document, err := dnfpvf.Parse(currentWeaknessCostArchivePath, currentWeaknessCostFixture)
if err != nil {
t.Fatal(err)
}
prices := document.Ints(currentWeaknessCostSection)
if len(prices) == 0 {
t.Fatal("cost fixture produced no prices")
}
service.weaknessCostTable = &currentWeaknessCostTable{basePrices: prices}
session := &gameSession{conn: connection, connID: "weakness", selectedCharacterID: 5}
return service, session, repositories, connection
}
func currentWeaknessStoredLevel(t *testing.T, repositories dnfrepo.Group) int64 {
t.Helper()
character, found, err := repositories.Character.Load(context.Background(), "5")
if err != nil || !found {
t.Fatalf("load character found=%t err=%v", found, err)
}
return currentWeaknessLevel(character)
}
// seedCurrentWeaknessCharacterGold writes gold into the character's own column,
// which is the store this server actually spends from.
func seedCurrentWeaknessCharacterGold(t *testing.T, repositories dnfrepo.Group, gold int64) {
t.Helper()
character, found, err := repositories.Character.Load(context.Background(), "5")
if err != nil || !found {
t.Fatalf("load character found=%t err=%v", found, err)
}
character = dnfrepo.CloneCharacter(character)
if character.Stats == nil {
character.Stats = map[string]int64{}
}
character.Stats["gold"] = gold
if err := repositories.Character.Save(context.Background(), character); err != nil {
t.Fatal(err)
}
}
// A forced exit weakens a character that has neither exemption, and it lands at
// full weight on the very first give-up rather than creeping down one level at
// a time. Repeating it changes nothing; only time does.
func TestForcedReturnAppliesTheWholePenaltyOnTheFirstGiveUp(t *testing.T) {
service, session, repositories, _ := prepareCurrentWeaknessTest(t, 40, 0, 0, nil)
for round := 1; round <= 3; round++ {
service.applyCurrentForcedReturnWeakness(session, "test_forced_exit")
if got := currentWeaknessStoredLevel(t, repositories); got != currentWeaknessForcedReturnLevel {
t.Fatalf("round %d weakness=%d want %d", round, got, currentWeaknessForcedReturnLevel)
}
}
// The operator specifies this penalty by what the player sees: 19% health.
if got := currentWeaknessCondition(currentWeaknessForcedReturnLevel); got != 19 {
t.Fatalf("forced-exit condition=%d want 19", got)
}
// The clamp still floors at 10 for any heavier level that reaches the wire.
if got := currentWeaknessCondition(currentWeaknessMaximumLevel); got != 10 {
t.Fatalf("condition at max weakness=%d want 10", got)
}
}
// A forced exit must never heal: a character already carrying a heavier
// penalty keeps it.
func TestForcedReturnDoesNotReduceAHeavierPenalty(t *testing.T) {
service, session, repositories, _ := prepareCurrentWeaknessTest(t, 40, currentWeaknessMaximumLevel, 0, nil)
service.applyCurrentForcedReturnWeakness(session, "test_forced_exit")
if got := currentWeaknessStoredLevel(t, repositories); got != currentWeaknessMaximumLevel {
t.Fatalf("weakness=%d want %d (a forced exit must not heal)", got, currentWeaknessMaximumLevel)
}
}
// 黑钻 is the only thing that skips the penalty, and a forced exit may never
// spend anything. The devil contract used to exempt here and silently burn a
// daily use; operator decision 2026-09-12 made the contract purely a player
// choice in the 解除虚弱 UI, so an active contract must now weaken like any
// other character and leave its quota untouched.
func TestForcedReturnIsExemptedOnlyByBlackDiamondAndSpendsNothing(t *testing.T) {
devilType := premium.DevilSlotType(premium.DevilSlotFreeWeakness)
for name, test := range map[string]struct {
premiums map[int64]int64
wantLevel int64
wantExemption currentWeaknessExemption
wantQuotaUsed bool
}{
"no premium at all": {
premiums: nil, wantLevel: currentWeaknessForcedReturnLevel, wantExemption: currentWeaknessExemptionNone,
},
"black diamond": {
premiums: map[int64]int64{premium.TypeBlackDiamondEntitlement: 86400},
wantLevel: 0,
wantExemption: currentWeaknessExemptionBlackDiamond,
},
// An active devil contract no longer exempts and must not be charged.
"devil contract": {
premiums: map[int64]int64{devilType: 86400},
wantLevel: currentWeaknessForcedReturnLevel,
wantExemption: currentWeaknessExemptionNone,
},
// An unrelated active contract must not exempt anything.
"over-equip contract": {
premiums: map[int64]int64{premium.TypeOverEquip: 86400},
wantLevel: currentWeaknessForcedReturnLevel, wantExemption: currentWeaknessExemptionNone,
},
// An expired devil contract is not an exemption either.
"expired devil contract": {
premiums: map[int64]int64{devilType: -1},
wantLevel: currentWeaknessForcedReturnLevel, wantExemption: currentWeaknessExemptionNone,
},
} {
t.Run(name, func(t *testing.T) {
service, session, repositories, _ := prepareCurrentWeaknessTest(t, 40, 0, 0, test.premiums)
level, exemption, err := service.commitCurrentForcedReturnWeakness(session)
if err != nil {
t.Fatal(err)
}
if exemption != test.wantExemption {
t.Fatalf("exemption=%q want %q", exemption, test.wantExemption)
}
if got := currentWeaknessStoredLevel(t, repositories); got != test.wantLevel {
t.Fatalf("stored weakness=%d want %d (returned %d)", got, test.wantLevel, level)
}
character, _, err := repositories.Character.Load(context.Background(), "5")
if err != nil {
t.Fatal(err)
}
used := premium.DailyUsage(character, premium.DevilSlotFreeWeakness, service.gameplayNow())
if test.wantQuotaUsed && used == 0 {
t.Fatal("the devil contract exemption must spend its daily quota")
}
if !test.wantQuotaUsed && used != 0 {
t.Fatalf("no quota may be spent here, used=%d", used)
}
})
}
}
// Repeated forced exits on an active contract must never touch its daily
// quota. The quota is the player's to spend in the 解除虚弱 UI; burning it on
// a give-up they did not choose to pay for is the behaviour this replaces.
func TestRepeatedForcedExitsNeverSpendTheDevilContractQuota(t *testing.T) {
devilType := premium.DevilSlotType(premium.DevilSlotFreeWeakness)
service, session, repositories, _ := prepareCurrentWeaknessTest(
t, 40, 0, 0, map[int64]int64{devilType: 86400},
)
for round := 0; round < 3; round++ {
_, exemption, err := service.commitCurrentForcedReturnWeakness(session)
if err != nil {
t.Fatal(err)
}
if exemption != currentWeaknessExemptionNone {
t.Fatalf("round %d exemption=%q want none", round, exemption)
}
}
if got := currentWeaknessStoredLevel(t, repositories); got != currentWeaknessForcedReturnLevel {
t.Fatalf("weakness=%d want %d", got, currentWeaknessForcedReturnLevel)
}
character, _, err := repositories.Character.Load(context.Background(), "5")
if err != nil {
t.Fatal(err)
}
if used := premium.DailyUsage(character, premium.DevilSlotFreeWeakness, service.gameplayNow()); used != 0 {
t.Fatalf("a forced exit spent %d contract uses; the quota is the player's own", used)
}
}
// A cleared run never weakens; only the abandoned commit does.
func TestOnlyAnAbandonedRunAppliesWeakness(t *testing.T) {
if currentForcedReturnAppliesWeakness(worldmap.DungeonRunCompleted) {
t.Fatal("a cleared run must not apply weakness")
}
if !currentForcedReturnAppliesWeakness(worldmap.DungeonRunAbandoned) {
t.Fatal("an abandoned run must apply weakness")
}
}
// op9 with the contract flag must be judged by the server: an inactive contract
// is refused with the EXE's own reason code and nothing is cleared or spent.
func TestWeaknessRecoveryRefusesAContractTheServerCannotHonour(t *testing.T) {
service, session, repositories, connection := prepareCurrentWeaknessTest(t, 40, 3, 0, nil)
body := make([]byte, 2)
binary.LittleEndian.PutUint16(body, 1)
if err := service.handleCurrentPremiumFreeWeakness(session, body); err != nil {
t.Fatal(err)
}
packet, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
if packet.Header.MsgID != uint16(dnfenum.CmdPacketRecoverStamina) ||
len(packet.Body) != 2 || packet.Body[0] != 0 ||
packet.Body[1] != currentWeaknessReasonCannotAfford || len(trailing) != 0 {
t.Fatalf("refusal packet=%+v body=%x trailing=%x", packet.Header, packet.Body, trailing)
}
if got := currentWeaknessStoredLevel(t, repositories); got != 3 {
t.Fatalf("a refused recovery must not clear weakness, got %d", got)
}
}
// The contract route clears without charging gold and spends exactly one quota.
func TestWeaknessRecoveryByContractClearsWithoutGold(t *testing.T) {
devilType := premium.DevilSlotType(premium.DevilSlotFreeWeakness)
service, session, repositories, connection := prepareCurrentWeaknessTest(
t, 40, 4, 100_000, map[int64]int64{devilType: 86400},
)
body := make([]byte, 2)
binary.LittleEndian.PutUint16(body, 1)
if err := service.handleCurrentPremiumFreeWeakness(session, body); err != nil {
t.Fatal(err)
}
// The premium refresh follows the ACK; TestContractQuotaSpendRepublishesThePremiumState
// pins its contents.
packet, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
if packet.Header.MsgID != uint16(dnfenum.CmdPacketRecoverStamina) ||
len(packet.Body) != 1 || packet.Body[0] != 1 || len(trailing) == 0 {
t.Fatalf("success packet=%+v body=%x trailing=%x", packet.Header, packet.Body, trailing)
}
if got := currentWeaknessStoredLevel(t, repositories); got != 0 {
t.Fatalf("weakness after contract recovery=%d want 0", got)
}
inventory, _, err := repositories.Inventory.Load(context.Background(), "5")
if err != nil {
t.Fatal(err)
}
wallet := inventory.Slots[currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, 0)]
if wallet.Count != 100_000 {
t.Fatalf("the contract route must not charge gold, wallet=%d", wallet.Count)
}
character, _, err := repositories.Character.Load(context.Background(), "5")
if err != nil {
t.Fatal(err)
}
if premium.DailyUsage(character, premium.DevilSlotFreeWeakness, service.gameplayNow()) != 1 {
t.Fatalf("the contract route must spend exactly one quota: %v", character.Stats)
}
}
// The paid route prices the clear from the PVF table and refuses when the gold
// is short, without clearing anything.
func TestWeaknessRecoveryByGoldUsesThePVFPriceAndRefusesWhenShort(t *testing.T) {
// Level 20 base price 5000, weakness 10 -> full 90 points lost -> 5000.
service, session, repositories, connection := prepareCurrentWeaknessTest(t, 20, 10, 4_999, nil)
if err := service.handleCurrentPremiumFreeWeakness(session, []byte{0, 0}); err != nil {
t.Fatal(err)
}
packet, _ := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(packet.Body) != 2 || packet.Body[0] != 0 || packet.Body[1] != currentWeaknessReasonCannotAfford {
t.Fatalf("short-gold packet body=%x", packet.Body)
}
if got := currentWeaknessStoredLevel(t, repositories); got != 10 {
t.Fatalf("a refused paid recovery must not clear weakness, got %d", got)
}
service, session, repositories, connection = prepareCurrentWeaknessTest(t, 20, 10, 5_000, nil)
if err := service.handleCurrentPremiumFreeWeakness(session, []byte{0, 0}); err != nil {
t.Fatal(err)
}
success, _ := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(success.Body) != 1 || success.Body[0] != 1 {
t.Fatalf("paid success body=%x", success.Body)
}
if got := currentWeaknessStoredLevel(t, repositories); got != 0 {
t.Fatalf("weakness after paid recovery=%d want 0", got)
}
inventory, _, err := repositories.Inventory.Load(context.Background(), "5")
if err != nil {
t.Fatal(err)
}
wallet := inventory.Slots[currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, 0)]
if wallet.Count != 0 {
t.Fatalf("paid recovery wallet=%d want 0", wallet.Count)
}
}
// The paid route charges the character's gold column, which is what the client
// wallet is projected from and what every other gold spender in this tree
// spends.
func TestWeaknessRecoveryByGoldChargesTheCharacterGoldColumn(t *testing.T) {
service, session, repositories, connection := prepareCurrentWeaknessTest(t, 20, 10, 0, nil)
seedCurrentWeaknessCharacterGold(t, repositories, 7_500)
if err := service.handleCurrentPremiumFreeWeakness(session, []byte{0, 0}); err != nil {
t.Fatal(err)
}
character, _, err := repositories.Character.Load(context.Background(), "5")
if err != nil {
t.Fatal(err)
}
if character.Stats["gold"] != 2_500 {
t.Fatalf("gold after paid recovery=%d want 2500 (7500 - 5000)", character.Stats["gold"])
}
if got := currentWeaknessLevel(character); got != 0 {
t.Fatalf("weakness after paid recovery=%d want 0", got)
}
// A cleared penalty drops its countdown so the next one starts fresh.
if _, found := character.Stats[currentWeaknessRecoveryAtStatKey]; found {
t.Fatalf("cleared weakness kept a recovery timestamp: %+v", character.Stats)
}
// And the client has to be told: the wallet is projected from this column,
// so the balance keeps showing the pre-payment value unless a refresh
// follows the success ACK.
_, afterAck := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(afterAck) == 0 {
t.Fatal("paid recovery sent the ACK but no wallet refresh after it")
}
}
// The class-0 notification is the only way to publish a partial health cap; the
// op9 ACK can only ever say 100. Body is one byte, the 0..100 condition.
func TestWeaknessConditionRefreshPublishesTheSettledCondition(t *testing.T) {
service, session, repositories, connection := prepareCurrentWeaknessTest(t, 20, 4, 0, nil)
if err := service.sendCurrentWeaknessConditionRefresh(session, "test"); err != nil {
t.Fatal(err)
}
packet, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if packet.Header.MsgID != currentWeaknessConditionMsg || packet.Header.Classification != 0 {
t.Fatalf("condition packet msg=%d class=%d want %d/0",
packet.Header.MsgID, packet.Header.Classification, currentWeaknessConditionMsg)
}
// Level 4 -> 100 - 4*9 = 64. Not 100: a partial cap must be sendable.
if len(packet.Body) != 1 || packet.Body[0] != 64 || len(rest) != 0 {
t.Fatalf("condition body=%x trailing=%d want 0x40 (64)", packet.Body, len(rest))
}
// Republishing must never itself reduce the penalty. This is the invariant
// behind "re-selecting the character cured the weakness": selecting rebuilds
// the local player at a healthy 100, so the route republishes the cap, and a
// republish that healed would make the penalty evaporate on every relog.
character, _, err := repositories.Character.Load(context.Background(), "5")
if err != nil {
t.Fatal(err)
}
if got := currentWeaknessLevel(character); got != 4 {
t.Fatalf("stored weakness after a refresh=%d want 4 (a republish must not heal)", got)
}
}
// Recovering while healthy is refused with the EXE's "not weakened" code rather
// than silently charging.
func TestWeaknessRecoveryRefusesAHealthyCharacter(t *testing.T) {
for name, body := range map[string][]byte{
"contract route": {1, 0},
"gold route": {0, 0},
} {
t.Run(name, func(t *testing.T) {
service, session, _, connection := prepareCurrentWeaknessTest(t, 40, 0, 100_000, nil)
if err := service.handleCurrentPremiumFreeWeakness(session, body); err != nil {
t.Fatal(err)
}
packet, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(packet.Body) != 2 || packet.Body[0] != 0 ||
packet.Body[1] != currentWeaknessReasonNotWeakened || len(trailing) != 0 {
t.Fatalf("healthy refusal body=%x trailing=%x", packet.Body, trailing)
}
})
}
}
// The cost curve must come from the table, not from a hard-coded formula.
func TestWeaknessRecoveryCostScalesWithLevelAndPenalty(t *testing.T) {
document, err := dnfpvf.Parse(currentWeaknessCostArchivePath, currentWeaknessCostFixture)
if err != nil {
t.Fatal(err)
}
table := &currentWeaknessCostTable{basePrices: document.Ints(currentWeaknessCostSection)}
if got := table.recoveryCost(10, 20); got != 5000 {
t.Fatalf("full penalty at level 20=%d want the base price 5000", got)
}
if got := table.recoveryCost(5, 20); got != 2500 {
t.Fatalf("half penalty at level 20=%d want 2500", got)
}
if got := table.recoveryCost(0, 20); got != 0 {
t.Fatalf("no penalty must cost nothing, got %d", got)
}
// Below the charged band the table itself is zero.
if got := table.recoveryCost(10, 5); got != 0 {
t.Fatalf("level 5 cost=%d want 0", got)
}
// A level past the shipped row clamps into the last entry instead of failing.
if got := table.recoveryCost(10, 999); got != 6050 {
t.Fatalf("clamped level cost=%d want the last entry 6050", got)
}
}
func TestCurrentWeaknessCostTableParsesTheLiveArchive(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify the runtime weakness cost table")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatalf("load real Script.pvf: %v", err)
}
table, err := parseCurrentWeaknessCostTable(archive)
if err != nil {
t.Fatal(err)
}
// The shipped row carries one entry per level, indexed level-1: nineteen
// free levels then 5000 at level 20.
if len(table.basePrices) < 20 {
t.Fatalf("live [stamina recovery cost] entries=%d want at least 20", len(table.basePrices))
}
if table.basePrice(19) != 0 || table.basePrice(20) != 5000 {
t.Fatalf("live curve level19=%d level20=%d", table.basePrice(19), table.basePrice(20))
}
// A level past the shipped row clamps rather than failing, and a full
// penalty always costs exactly the level's base price.
if got := table.recoveryCost(10, 90); got != table.basePrice(90) {
t.Fatalf("a full penalty must cost the whole base price: %d vs %d", got, table.basePrice(90))
}
last := int64(len(table.basePrices))
if table.basePrice(999) != table.basePrice(last) {
t.Fatalf("an out-of-range level must clamp into the table")
}
}
// Spending a contract quota without republishing the premium state is what the
// operator saw as "the contract was not deducted": the count is right in the
// database and stale on screen. Both consuming routes must refresh it.
func TestContractQuotaSpendRepublishesThePremiumState(t *testing.T) {
devilType := premium.DevilSlotType(premium.DevilSlotFreeWeakness)
service, session, repositories, connection := prepareCurrentWeaknessTest(
t, 40, 4, 0, map[int64]int64{devilType: 86400},
)
body := make([]byte, 2)
binary.LittleEndian.PutUint16(body, 1)
if err := service.handleCurrentPremiumFreeWeakness(session, body); err != nil {
t.Fatal(err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != uint16(dnfenum.CmdPacketRecoverStamina) ||
len(ack.Body) != 1 || ack.Body[0] != 1 {
t.Fatalf("recovery ACK=%+v body=%x", ack.Header, ack.Body)
}
if len(rest) == 0 {
t.Fatal("a spent contract quota must be followed by a premium state refresh")
}
state, trailing := splitGameServerUpperPacket(t, rest)
if state.Header.MsgID != uint16(dnfenum.CmdPacketPremiumService) || len(trailing) != 0 {
t.Fatalf("premium refresh=%+v trailing=%x", state.Header, trailing)
}
// The refresh must carry the post-use count, not the pre-use one.
if got := premiumServicePacketUsage(t, rest, premium.DevilSlotFreeWeakness); got != 1 {
t.Fatalf("republished free-weakness usage=%d want 1", got)
}
character, _, err := repositories.Character.Load(context.Background(), "5")
if err != nil {
t.Fatal(err)
}
if got := premium.DailyUsage(character, premium.DevilSlotFreeWeakness, service.gameplayNow()); got != 1 {
t.Fatalf("persisted usage=%d want 1", got)
}
}
// The select-character projection is the only place a stored weakness level can
// currently reach the client. It preferred a legacy subtype0 row that does not
// exist on this runtime, so it always published 0 and the penalty was invisible
// even after a relog.
func TestSelectedUserInfoProjectsTheStoredWeaknessLevel(t *testing.T) {
character := dnfrepo.CharacterRecord{
CharacterID: "5",
AccountID: "account-1",
Level: 90,
Stats: map[string]int64{currentWeaknessStatKey: 7, "fatigue_penalty": 3},
}
if got := rowStatU8(nil, character, currentWeaknessStatKey, 0); got != 7 {
t.Fatalf("projected weakness=%d want the stored 7", got)
}
if got := rowStatU32(nil, character, "fatigue_penalty", 0); got != 3 {
t.Fatalf("projected fatigue penalty=%d want 3", got)
}
// A healthy character still projects zero rather than inventing a penalty.
healthy := dnfrepo.CharacterRecord{CharacterID: "6", Stats: map[string]int64{}}
if got := rowStatU8(nil, healthy, currentWeaknessStatKey, 0); got != 0 {
t.Fatalf("healthy weakness projection=%d want 0", got)
}
}