Files
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

355 lines
12 KiB
Go

package dungeon
import (
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/premium"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// Tower of Despair and Tower of Grief share one floor/progress model. The two
// kinds differ only in the PVF document and in how the clear reward is drawn:
// despair grants pickups, grief draws ten independent PVF card slots.
type TowerKind string
const (
TowerDespair TowerKind = "despair"
TowerGrief TowerKind = "grief"
TowerRewardSlots = 10
towerDailyUsageDayKey = "tower_daily_usage_day"
towerDailyUsageKeyPrefix = "tower_daily_used_"
)
var ErrTowerDailyLimitReached = errors.New("tower daily entry limit reached")
type TowerReward struct{ ItemID, Count uint32 }
type TowerClear struct {
Kind TowerKind
Floor uint16
Rewards [TowerRewardSlots]TowerReward
Grant func(*dnfrepo.InventoryRecord, uint32, uint32) error
}
func towerStat(kind TowerKind, suffix string) string { return "tower_" + string(kind) + "_" + suffix }
// Each tower has one character entry in addition to the shared account pool.
// Legacy clears only consumed the account pool, so an absent marker preserves
// the character entry instead of charging the same old clear to both pools.
func TowerCharacterDailyRemaining(character dnfrepo.CharacterRecord, kind TowerKind, now time.Time) byte {
if kind != TowerDespair && kind != TowerGrief {
return 0
}
if day, found := character.Stats[towerStat(kind, "character_entry_day")]; found && day >= premium.ServiceDayID(now) {
return 0
}
return 1
}
func recordTowerEntryClear(account *dnfrepo.AccountRecord, character *dnfrepo.CharacterRecord, kind TowerKind, now time.Time) error {
if TowerCharacterDailyRemaining(*character, kind, now) == 0 {
return RecordTowerDailyClear(account, kind, now)
}
if character.Stats == nil {
character.Stats = make(map[string]int64)
}
character.Stats[towerStat(kind, "character_entry_day")] = premium.ServiceDayID(now)
return nil
}
func TowerFloor(character dnfrepo.CharacterRecord, kind TowerKind) (uint16, error) {
if kind != TowerDespair && kind != TowerGrief {
return 0, ErrSettlementStateInvalid
}
floor := character.Stats[towerStat(kind, "floor")]
if floor == 0 {
return 1, nil
}
if floor < 1 || floor > 100 {
return 0, ErrSettlementStateInvalid
}
return uint16(floor), nil
}
// TowerDailyEnterableLimit reads the account-wide daily cap from the active
// tower PVF document. The client uses this value for the account tooltip.
func TowerDailyEnterableLimit(source dnfpvf.Source, kind TowerKind, adventureLevel int) (uint32, error) {
if kind != TowerDespair && kind != TowerGrief {
return 0, ErrSettlementStateInvalid
}
doc, err := towerDocument(source, "etc/towerof"+string(kind)+".etc")
if err != nil {
return 0, err
}
value, ok := doc.Int("account enterable max count")
if !ok || value <= 0 || value > math.MaxUint32 {
return 0, ErrSettlementStateInvalid
}
rows := doc.Ints("adventure group add enterable count")
if len(rows)%3 != 0 {
return 0, ErrSettlementStateInvalid
}
var previousEnd int64
for i := 0; i < len(rows); i += 3 {
low, high, extra := rows[i], rows[i+1], rows[i+2]
if low <= previousEnd || high < low || extra < 0 || extra > math.MaxUint32-value {
return 0, ErrSettlementStateInvalid
}
previousEnd = high
if int64(adventureLevel) >= low && int64(adventureLevel) <= high {
value += extra
}
}
return uint32(value), nil
}
func towerDailyUsageKey(kind TowerKind) string {
return towerDailyUsageKeyPrefix + string(kind)
}
// TowerDailyUsed returns the number of successful clears in the current DNF
// service day. Stale metadata is treated as zero without mutating storage.
func TowerDailyUsed(account dnfrepo.AccountRecord, kind TowerKind, now time.Time) (uint32, error) {
if kind != TowerDespair && kind != TowerGrief {
return 0, ErrSettlementStateInvalid
}
if account.Metadata == nil || account.Metadata[towerDailyUsageDayKey] != strconv.FormatInt(premium.ServiceDayID(now), 10) {
return 0, nil
}
value := strings.TrimSpace(account.Metadata[towerDailyUsageKey(kind)])
if value == "" {
return 0, nil
}
used, err := strconv.ParseUint(value, 10, 32)
if err != nil {
return 0, fmt.Errorf("%w: invalid tower daily usage", ErrSettlementStateInvalid)
}
return uint32(used), nil
}
// RecordTowerDailyClear updates account-wide usage inside the settlement
// transaction. Admission owns the limit check: an accepted, completed run must
// still receive its rewards. The settlement receipt makes retries apply once.
func RecordTowerDailyClear(account *dnfrepo.AccountRecord, kind TowerKind, now time.Time) error {
if account == nil || (kind != TowerDespair && kind != TowerGrief) {
return ErrSettlementStateInvalid
}
used, err := TowerDailyUsed(*account, kind, now)
if err != nil {
return err
}
if used == math.MaxUint32 {
return ErrSettlementStateInvalid
}
if account.Metadata == nil {
account.Metadata = make(map[string]string)
}
day := strconv.FormatInt(premium.ServiceDayID(now), 10)
if account.Metadata[towerDailyUsageDayKey] != day {
delete(account.Metadata, towerDailyUsageKey(TowerDespair))
delete(account.Metadata, towerDailyUsageKey(TowerGrief))
}
account.Metadata[towerDailyUsageDayKey] = day
account.Metadata[towerDailyUsageKey(kind)] = strconv.FormatUint(uint64(used+1), 10)
return nil
}
func tower100ClearMetadataKey(kind TowerKind) string {
switch kind {
case TowerDespair:
return "adventure_group_despair_100_clears"
case TowerGrief:
return "adventure_group_grief_100_clears"
default:
return ""
}
}
// RecordTower100FloorClear increments the account-wide adventure-group
// counter only when the cleared floor is 100. A missing counter means zero.
func RecordTower100FloorClear(account *dnfrepo.AccountRecord, kind TowerKind, floor uint16) error {
if account == nil || floor != 100 {
return nil
}
key := tower100ClearMetadataKey(kind)
if key == "" {
return ErrSettlementStateInvalid
}
if account.Metadata == nil {
account.Metadata = make(map[string]string)
}
var count uint64
if raw := strings.TrimSpace(account.Metadata[key]); raw != "" {
parsed, err := strconv.ParseUint(raw, 10, 32)
if err != nil {
return fmt.Errorf("%w: invalid tower 100 clear counter", ErrSettlementStateInvalid)
}
count = parsed
}
if count == math.MaxUint32 {
return ErrSettlementStateInvalid
}
account.Metadata[key] = strconv.FormatUint(count+1, 10)
return nil
}
func applyTowerClear(character *dnfrepo.CharacterRecord, inventory *dnfrepo.InventoryRecord, clear *TowerClear, now time.Time) error {
if clear == nil {
return nil
}
if _, err := TowerFloor(*character, clear.Kind); err != nil {
return err
}
if clear.Floor == 0 || clear.Floor > 100 {
return ErrSettlementStateInvalid
}
for slot, reward := range clear.Rewards {
if (reward.ItemID == 0) != (reward.Count == 0) || reward.ItemID == math.MaxUint32 {
return ErrSettlementStateInvalid
}
if reward.ItemID != 0 {
if clear.Grant == nil {
return ErrSettlementStateInvalid
}
if err := clear.Grant(inventory, reward.ItemID, reward.Count); err != nil {
return err
}
}
character.Stats[towerStat(clear.Kind, fmt.Sprintf("reward_%d_item", slot))] = int64(reward.ItemID)
character.Stats[towerStat(clear.Kind, fmt.Sprintf("reward_%d_count", slot))] = int64(reward.Count)
}
character.Stats[towerStat(clear.Kind, "floor")] = int64(clear.Floor%100 + 1)
character.Stats[towerStat(clear.Kind, "last_clear_floor")] = int64(clear.Floor)
character.Stats[towerStat(clear.Kind, "last_clear_at")] = now.Unix()
return nil
}
func TowerLastRewards(character dnfrepo.CharacterRecord, kind TowerKind) ([TowerRewardSlots]TowerReward, error) {
var out [TowerRewardSlots]TowerReward
for slot := range out {
id := character.Stats[towerStat(kind, fmt.Sprintf("reward_%d_item", slot))]
count := character.Stats[towerStat(kind, fmt.Sprintf("reward_%d_count", slot))]
if id < 0 || id >= math.MaxUint32 || count < 0 || count > math.MaxUint32 || (id == 0) != (count == 0) {
return out, ErrSettlementStateInvalid
}
out[slot] = TowerReward{uint32(id), uint32(count)}
}
return out, nil
}
func towerDocument(source dnfpvf.Source, path string) (*dnfpvf.Document, error) {
text, err := source.ReadText(path)
if err != nil {
return nil, err
}
return dnfpvf.Parse(path, text)
}
func TowerDungeonID(source dnfpvf.Source, kind TowerKind, floor uint16) (int64, error) {
if kind != TowerDespair && kind != TowerGrief {
return 0, ErrSettlementStateInvalid
}
doc, err := towerDocument(source, "etc/towerof"+string(kind)+".etc")
if err != nil {
return 0, err
}
rows := doc.Ints("each layer matching dungeon")
if len(rows) == 0 || len(rows)%3 != 0 {
return 0, ErrSettlementStateInvalid
}
for i := 0; i < len(rows); i += 3 {
if rows[i] == int64(floor) && rows[i+1] > 0 {
return rows[i+1], nil
}
}
return 0, fmt.Errorf("tower %s floor %d missing from PVF", kind, floor)
}
// Each card has an independent 10000-point probability range. Unallocated
// probability produces the native empty slot. The server-owned completion key
// freezes the draw across retries; committed values are stored with progress.
func GriefTowerRewards(source dnfpvf.Source, floor uint16, completionKey string) ([TowerRewardSlots]TowerReward, error) {
var out [TowerRewardSlots]TowerReward
if floor == 0 || floor > 100 || completionKey == "" {
return out, ErrSettlementStateInvalid
}
doc, err := towerDocument(source, "etc/towerofgrief.etc")
if err != nil {
return out, err
}
rows, ok := doc.Section("each layer dungeon clear reward")
if !ok || len(rows)%3 != 0 {
return out, ErrSettlementStateInvalid
}
name := ""
for i := 0; i < len(rows); i += 3 {
if rows[i].Kind != dnfpvf.TokenInt || rows[i+1].Kind != dnfpvf.TokenInt || rows[i].Int < 1 || rows[i+1].Int < rows[i].Int || rows[i+1].Int > 100 {
return out, ErrSettlementStateInvalid
}
if rows[i].Int <= int64(floor) && int64(floor) <= rows[i+1].Int {
if name != "" {
return out, ErrSettlementStateInvalid
}
name = rows[i+2].Value
}
}
if name == "" {
return out, fmt.Errorf("grief floor %d reward table missing", floor)
}
doc, err = towerDocument(source, "etc/itemdropinfo_togclearreward.etc")
if err != nil {
return out, err
}
var table []dnfpvf.Token
for _, section := range doc.Sections {
if section.Name != "table" || section.Start >= section.End {
continue
}
tokens := doc.Tokens[section.Start:section.End]
if tokens[0].Value == name {
table = tokens[1:]
break
}
}
if len(table) == 0 || len(table)%4 != 0 {
return out, fmt.Errorf("invalid grief table %s", name)
}
var rolls, totals [TowerRewardSlots]uint32
for slot := range rolls {
hash := sha256.Sum256([]byte(completionKey + "/grief/" + strconv.Itoa(slot)))
rolls[slot] = binary.LittleEndian.Uint32(hash[:4]) % 10000
}
for i := 0; i < len(table); i += 4 {
label := table[i].Value
if !strings.HasPrefix(label, "card ") {
return out, ErrSettlementStateInvalid
}
slot, err := strconv.Atoi(strings.TrimPrefix(label, "card "))
if err != nil || slot < 1 || slot > TowerRewardSlots {
return out, ErrSettlementStateInvalid
}
slot--
id, probability, count := table[i+1], table[i+2], table[i+3]
if id.Kind != dnfpvf.TokenInt || probability.Kind != dnfpvf.TokenInt || count.Kind != dnfpvf.TokenInt || id.Int <= 0 || id.Int >= math.MaxUint32 || count.Int <= 0 || count.Int > math.MaxUint32 || probability.Int < 0 || probability.Int > 10000 {
return out, ErrSettlementStateInvalid
}
low := totals[slot]
totals[slot] += uint32(probability.Int)
if totals[slot] > 10000 {
return out, ErrSettlementStateInvalid
}
if rolls[slot] >= low && rolls[slot] < totals[slot] {
out[slot] = TowerReward{uint32(id.Int), uint32(count.Int)}
}
}
return out, nil
}