DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,372 @@
|
||||
package dnfbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||||
dnflimitedcube "longheng.io/server/internal/modules/dnf/limitedcube"
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
currentLimitedCubeUseBodySize = 8
|
||||
currentLimitedCubeConsumeCount = int64(1)
|
||||
)
|
||||
|
||||
// handleCurrentLimitedCubeUseRequest owns current-client op338. Current
|
||||
// NoPack sub_314B2B0 sends target slot, target item and source ticket slot for
|
||||
// the PVF [upgrade limit cube] transformation UI. Crystal Contract does not
|
||||
// send this command; its dungeon consumption is server lifecycle-owned.
|
||||
func (s *Service) handleCurrentLimitedCubeUseRequest(session *gameSession, body []byte) error {
|
||||
request, policy, recognized, err := s.currentLimitedCubePolicyForRequest(session, body)
|
||||
if !recognized {
|
||||
s.logGameEvent(session, "game-limited-cube-use-blocked",
|
||||
"msg_id", uint16(dnfenum.CmdPacketUseLimitCube),
|
||||
"body_len", len(body),
|
||||
"reason", "request_not_proven_as_runtime_pvf_upgrade_limit_cube",
|
||||
"error", err)
|
||||
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketUseLimitCube), 1)
|
||||
}
|
||||
if err != nil {
|
||||
s.logGameEvent(session, "game-limited-cube-use-blocked",
|
||||
"msg_id", uint16(dnfenum.CmdPacketUseLimitCube),
|
||||
"reason", "runtime_pvf_invalid",
|
||||
"error", err)
|
||||
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketUseLimitCube), 1)
|
||||
}
|
||||
return s.handleCurrentLimitedCubeUse(session, request, policy)
|
||||
}
|
||||
|
||||
// currentLimitedCubeUseRequest matches the PVF [limited cube] client action:
|
||||
// target bead slot, target bead ID, then the originating ticket slot.
|
||||
type currentLimitedCubeUseRequest struct {
|
||||
TargetSlot int16
|
||||
TargetItemID int64
|
||||
TicketSlot int16
|
||||
}
|
||||
|
||||
func (s *Service) currentLimitedCubePolicyForRequest(session *gameSession, body []byte) (currentLimitedCubeUseRequest, dnflimitedcube.Policy, bool, error) {
|
||||
if len(body) != currentLimitedCubeUseBodySize {
|
||||
return currentLimitedCubeUseRequest{}, dnflimitedcube.Policy{}, false, nil
|
||||
}
|
||||
request := currentLimitedCubeUseRequest{
|
||||
TargetSlot: int16(binary.LittleEndian.Uint16(body[0:2])),
|
||||
TargetItemID: int64(binary.LittleEndian.Uint32(body[2:6])),
|
||||
TicketSlot: int16(binary.LittleEndian.Uint16(body[6:8])),
|
||||
}
|
||||
if request.TargetSlot < 0 || request.TicketSlot < 0 || request.TargetSlot == request.TicketSlot || request.TargetItemID <= 0 {
|
||||
return request, dnflimitedcube.Policy{}, false, nil
|
||||
}
|
||||
catalog, err := s.currentPVFItemCatalog()
|
||||
if err != nil {
|
||||
return request, dnflimitedcube.Policy{}, false, err
|
||||
}
|
||||
repositories, ok := s.repositoryGroup()
|
||||
if !ok || repositories.Inventory == nil || session == nil || session.selectedCharacterID == 0 {
|
||||
return request, dnflimitedcube.Policy{}, false, nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), currentRepositorySnapshotTimeout)
|
||||
defer cancel()
|
||||
characterID := strconv.Itoa(int(session.selectedCharacterID))
|
||||
inventory, found, err := repositories.Inventory.Load(ctx, characterID)
|
||||
if err != nil || !found {
|
||||
return request, dnflimitedcube.Policy{}, false, err
|
||||
}
|
||||
ticket, found := inventory.Slots[currentLimitedCubeMainSlotKey(request.TicketSlot)]
|
||||
if !found || ticket.ItemID <= 0 || ticket.Count <= 0 {
|
||||
return request, dnflimitedcube.Policy{}, false, nil
|
||||
}
|
||||
policy, recognized, err := resolveCurrentLimitedCubePolicy(catalog, ticket.ItemID)
|
||||
if err != nil || !recognized || !currentLimitedCubePolicyAllowsTarget(policy, request.TargetItemID) {
|
||||
return request, policy, false, err
|
||||
}
|
||||
return request, policy, true, nil
|
||||
}
|
||||
|
||||
func currentLimitedCubeMainSlotKey(slot int16) string {
|
||||
return "0:" + strconv.FormatInt(int64(slot), 10)
|
||||
}
|
||||
|
||||
func currentLimitedCubePolicyAllowsTarget(policy dnflimitedcube.Policy, itemID int64) bool {
|
||||
for _, condition := range policy.Conditions {
|
||||
if condition.ItemID == itemID && condition.Count > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// resolveCurrentLimitedCubePolicy recognizes the exact PVF action family used
|
||||
// by the [upgrade limit cube] tickets. The ticket itself is always a stackable;
|
||||
// what it transforms may be a stackable (pet beads) or an equipment instance
|
||||
// (titles). An ordinary or unresolved op338 source is deliberately left to the
|
||||
// crystal-contract compatibility path.
|
||||
func resolveCurrentLimitedCubePolicy(catalog *pvfDungeonDropCatalog, ticketItemID int64) (dnflimitedcube.Policy, bool, error) {
|
||||
if catalog == nil || catalog.source == nil || ticketItemID <= 0 || ticketItemID > math.MaxUint32 {
|
||||
return dnflimitedcube.Policy{}, false, nil
|
||||
}
|
||||
definition, err := catalog.ResolveItem(uint32(ticketItemID))
|
||||
if err != nil {
|
||||
if errors.Is(err, errDungeonDropItemUnresolved) {
|
||||
return dnflimitedcube.Policy{}, false, nil
|
||||
}
|
||||
return dnflimitedcube.Policy{}, false, err
|
||||
}
|
||||
if definition.Kind != dungeonDropItemStackable || !currentLimitedCubeTagEqual(definition.StackableType, "[upgrade limit cube]") {
|
||||
return dnflimitedcube.Policy{}, false, nil
|
||||
}
|
||||
document, err := parseDungeonCardPVFDocument(catalog.source, definition.PVFPath)
|
||||
if err != nil {
|
||||
return dnflimitedcube.Policy{}, true, fmt.Errorf("parse limited cube item=%d path=%s: %w", ticketItemID, definition.PVFPath, err)
|
||||
}
|
||||
actionType, actionFound := document.Text("action type")
|
||||
usablePlace, placeFound := document.Text("action usable place")
|
||||
if !actionFound || !currentLimitedCubeTagEqual(actionType, "[limited cube]") ||
|
||||
!placeFound || !currentLimitedCubeTagEqual(usablePlace, "[village]") {
|
||||
return dnflimitedcube.Policy{}, false, nil
|
||||
}
|
||||
conditions, err := currentLimitedCubeRequirements(document, "A condition item")
|
||||
if err != nil {
|
||||
return dnflimitedcube.Policy{}, true, fmt.Errorf("limited cube item=%d A condition: %w", ticketItemID, err)
|
||||
}
|
||||
materials, err := currentLimitedCubeRequirements(document, "B condition item")
|
||||
if err != nil {
|
||||
return dnflimitedcube.Policy{}, true, fmt.Errorf("limited cube item=%d B condition: %w", ticketItemID, err)
|
||||
}
|
||||
results, err := currentLimitedCubeResults(catalog, document)
|
||||
if err != nil {
|
||||
return dnflimitedcube.Policy{}, true, fmt.Errorf("limited cube item=%d results: %w", ticketItemID, err)
|
||||
}
|
||||
return dnflimitedcube.Policy{
|
||||
TicketItemID: ticketItemID,
|
||||
Conditions: conditions,
|
||||
Materials: materials,
|
||||
Results: results,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func currentLimitedCubeRequirements(document *dnfpvf.Document, section string) ([]dnflimitedcube.Requirement, error) {
|
||||
values := document.Ints(section)
|
||||
if len(values) == 0 || len(values)%2 != 0 {
|
||||
return nil, fmt.Errorf("section %q has %d values, want nonempty item/count pairs", section, len(values))
|
||||
}
|
||||
requirements := make([]dnflimitedcube.Requirement, 0, len(values)/2)
|
||||
for offset := 0; offset < len(values); offset += 2 {
|
||||
itemID, count := values[offset], values[offset+1]
|
||||
if itemID <= 0 || itemID > math.MaxUint32 || count <= 0 {
|
||||
return nil, fmt.Errorf("section %q item=%d count=%d", section, itemID, count)
|
||||
}
|
||||
requirements = append(requirements, dnflimitedcube.Requirement{ItemID: itemID, Count: count})
|
||||
}
|
||||
return requirements, nil
|
||||
}
|
||||
|
||||
func currentLimitedCubeResults(catalog *pvfDungeonDropCatalog, document *dnfpvf.Document) ([]dnflimitedcube.WeightedResult, error) {
|
||||
values := document.Ints("result item")
|
||||
if len(values) == 0 || len(values)%3 != 0 {
|
||||
return nil, fmt.Errorf("section %q has %d values, want nonempty item/count/weight triples", "result item", len(values))
|
||||
}
|
||||
results := make([]dnflimitedcube.WeightedResult, 0, len(values)/3)
|
||||
for offset := 0; offset < len(values); offset += 3 {
|
||||
itemID, count, weight := values[offset], values[offset+1], values[offset+2]
|
||||
if itemID <= 0 || itemID > math.MaxUint32 || count <= 0 || weight <= 0 {
|
||||
return nil, fmt.Errorf("item=%d count=%d weight=%d", itemID, count, weight)
|
||||
}
|
||||
definition, err := catalog.ResolveItem(uint32(itemID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve item=%d: %w", itemID, err)
|
||||
}
|
||||
stack, err := buildCurrentLimitedCubeResultStack(definition, count)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build item=%d: %w", itemID, err)
|
||||
}
|
||||
results = append(results, dnflimitedcube.WeightedResult{Stack: stack, Weight: weight})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// buildCurrentLimitedCubeResultStack turns one `[result item]` triple into the
|
||||
// durable stack the owner writes back into the transformed slot.
|
||||
//
|
||||
// The runtime PVF uses two [upgrade limit cube] families and both are live in
|
||||
// the 90CN Script.pvf:
|
||||
//
|
||||
// - pet-bead change tickets transform one stackable into another stackable
|
||||
// (91 weighted candidates, e.g. 兽人族宠物宝珠变更券 chn_490007246.stk);
|
||||
// - title change coupons transform an equipment instance into another
|
||||
// equipment template (热舞一夏称号随机变更券 chn_490702318.stk,
|
||||
// 劳动节称号随机变更券 chn_490700080.stk,
|
||||
// 2014劳动节称号变更券 chn_ticket_title_change_2683522.stk,
|
||||
// 欢迎来到地狱称号变更券 10301395.stk, ...).
|
||||
//
|
||||
// Only the first family was modelled, so every equipment result was refused
|
||||
// here and the client answered each coupon attempt with the bare upper-failure
|
||||
// code. The two kinds are shaped differently on purpose: a stackable carries
|
||||
// stack metadata, an equipment row is a single instance whose raw+0x06 word is
|
||||
// the grade seed the client recomputes 品级 from.
|
||||
func buildCurrentLimitedCubeResultStack(definition dungeonDropItemDefinition, count int64) (dnfrepo.ItemStack, error) {
|
||||
if definition.ItemID == 0 || count <= 0 {
|
||||
return dnfrepo.ItemStack{}, fmt.Errorf("result item must be resolved with a positive count")
|
||||
}
|
||||
switch definition.Kind {
|
||||
case dungeonDropItemStackable, dungeonDropItemEquipment:
|
||||
default:
|
||||
return dnfrepo.ItemStack{}, fmt.Errorf(
|
||||
"result item=%d kind %q has no inventory row encoding", definition.ItemID, definition.Kind)
|
||||
}
|
||||
// A 0x77 equipment row is one instance: the current client has no stacked
|
||||
// equipment frame, and every equipment cube in the runtime PVF writes 1.
|
||||
if definition.Kind == dungeonDropItemEquipment && count != 1 {
|
||||
return dnfrepo.ItemStack{}, fmt.Errorf("result equipment item=%d count=%d, want exactly 1", definition.ItemID, count)
|
||||
}
|
||||
extra := map[string]string{
|
||||
"source": "limited_cube_result",
|
||||
"item_kind": string(definition.Kind),
|
||||
"pvf_path": definition.PVFPath,
|
||||
}
|
||||
if definition.Kind == dungeonDropItemStackable {
|
||||
extra["stackable_type"] = definition.StackableType
|
||||
if definition.StackLimit > 0 {
|
||||
extra["stack_limit"] = strconv.FormatInt(definition.StackLimit, 10)
|
||||
}
|
||||
}
|
||||
stack := dnfrepo.ItemStack{ItemID: int64(definition.ItemID), Count: count, Extra: extra}
|
||||
if definition.Kind == dungeonDropItemEquipment {
|
||||
// Reuse the shared classifier so a transformed title carries the same
|
||||
// durable template keys every other equipment grant writes:
|
||||
// item_kind, pvf_path, equipment_type and the template durability.
|
||||
// (applyCurrentPVFItemExpiration below re-runs it, which is idempotent.)
|
||||
stack, _ = applyCurrentPVFItemClassification(stack, definition)
|
||||
}
|
||||
if !definition.ExpirationDate.IsZero() {
|
||||
stack, _ = applyCurrentPVFItemExpiration(stack, definition)
|
||||
}
|
||||
if definition.Kind == dungeonDropItemEquipment {
|
||||
// Every other equipment grant in this server mints a fresh valid seed
|
||||
// (see dungeon_card_reward_runtime.go). Leaving raw+0x06 at zero makes
|
||||
// the projection fall back to the stack count, which repaints the item
|
||||
// with the grade of seed 1 until the next character-list rebuild.
|
||||
seed, err := newCurrentEquipmentQualitySeed()
|
||||
if err != nil {
|
||||
return dnfrepo.ItemStack{}, fmt.Errorf("result equipment item=%d quality seed: %w", definition.ItemID, err)
|
||||
}
|
||||
if stack.Extra == nil {
|
||||
stack.Extra = make(map[string]string, 1)
|
||||
}
|
||||
stack.Extra["quality_seed"] = strconv.FormatUint(uint64(seed), 10)
|
||||
}
|
||||
return stack, nil
|
||||
}
|
||||
|
||||
func currentLimitedCubeTagEqual(value, want string) bool {
|
||||
normalize := func(raw string) string {
|
||||
raw = strings.TrimSpace(strings.ReplaceAll(raw, "`", ""))
|
||||
return strings.ToLower(strings.Join(strings.Fields(raw), " "))
|
||||
}
|
||||
return normalize(value) == normalize(want)
|
||||
}
|
||||
|
||||
func (s *Service) handleCurrentLimitedCubeUse(session *gameSession, request currentLimitedCubeUseRequest, policy dnflimitedcube.Policy) error {
|
||||
if currentCrystalContractDungeonOwnedBySession(session) {
|
||||
s.logGameEvent(session, "game-limited-cube-use-blocked",
|
||||
"msg_id", uint16(dnfenum.CmdPacketUseLimitCube),
|
||||
"ticket_slot", request.TicketSlot,
|
||||
"target_slot", request.TargetSlot,
|
||||
"target_item", request.TargetItemID,
|
||||
"reason", "pvf_action_usable_place_village")
|
||||
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketUseLimitCube), 1)
|
||||
}
|
||||
repositories, ok := s.repositoryGroup()
|
||||
if !ok {
|
||||
s.logGameEvent(session, "game-limited-cube-use-blocked", "msg_id", uint16(dnfenum.CmdPacketUseLimitCube), "reason", "repositories_unavailable")
|
||||
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketUseLimitCube), 1)
|
||||
}
|
||||
owner, err := dnflimitedcube.NewOwner(repositories)
|
||||
if err != nil {
|
||||
s.logGameEvent(session, "game-limited-cube-use-blocked", "msg_id", uint16(dnfenum.CmdPacketUseLimitCube), "reason", "owner_unavailable", "error", err)
|
||||
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketUseLimitCube), 1)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||||
defer cancel()
|
||||
result, err := owner.Use(ctx, dnflimitedcube.Command{
|
||||
AccountID: s.accountIDForSession(session),
|
||||
CharacterID: strconv.Itoa(int(session.selectedCharacterID)),
|
||||
TicketSlot: request.TicketSlot,
|
||||
TicketItemID: policy.TicketItemID,
|
||||
TargetSlot: request.TargetSlot,
|
||||
TargetItemID: request.TargetItemID,
|
||||
}, policy)
|
||||
if err != nil {
|
||||
s.logGameEvent(session, "game-limited-cube-use-blocked",
|
||||
"msg_id", uint16(dnfenum.CmdPacketUseLimitCube),
|
||||
"ticket_slot", request.TicketSlot,
|
||||
"target_slot", request.TargetSlot,
|
||||
"target_item", request.TargetItemID,
|
||||
"reason", "domain_rejected",
|
||||
"error", err)
|
||||
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketUseLimitCube), 1)
|
||||
}
|
||||
|
||||
var response packetWriter
|
||||
// The limited-cube success handler resolves this item ID for its result
|
||||
// prompt. Crystal-contract op338 uses its consumed source ID instead.
|
||||
response.writeUint32(uint32(result.ResultItemID))
|
||||
response.writeUint16(uint16(currentLimitedCubeConsumeCount))
|
||||
response.writeUint32(uint32(result.TicketRemaining))
|
||||
response.writeByte(1)
|
||||
if err := s.sendGameUpperSuccess(session, uint16(dnfenum.CmdPacketUseLimitCube), response.bytes()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.sendCurrentLimitedCubeTargetReplacementClear(session, result.InputSlot); err != nil {
|
||||
return err
|
||||
}
|
||||
refreshes := make([]alignedcmd.ItemSlotRefresh, 0, len(result.ChangedSlots)+len(result.AccountChangedSlots))
|
||||
for _, changedSlot := range result.ChangedSlots {
|
||||
refreshes = append(refreshes, alignedcmd.ItemSlotRefresh{ListType: dnfrepo.MainInventoryListType, SlotIndex: changedSlot})
|
||||
}
|
||||
for _, changedSlot := range result.AccountChangedSlots {
|
||||
refreshes = append(refreshes, alignedcmd.ItemSlotRefresh{ListType: dnfrepo.MainInventoryListType, SlotIndex: changedSlot})
|
||||
}
|
||||
if err := s.sendSelectedIncrementalItemSlotRefreshes(session, "limited_cube_use", refreshes); err != nil {
|
||||
return err
|
||||
}
|
||||
s.logGameEvent(session, "game-limited-cube-use-committed",
|
||||
"msg_id", uint16(dnfenum.CmdPacketUseLimitCube),
|
||||
"ticket_slot", result.TicketSlot,
|
||||
"ticket_item", result.TicketItemID,
|
||||
"ticket_remaining", result.TicketRemaining,
|
||||
"input_slot", result.InputSlot,
|
||||
"input_item", result.InputItemID,
|
||||
"result_item", result.ResultItemID,
|
||||
"character_material_and_item_refresh_slots", fmt.Sprint(result.ChangedSlots),
|
||||
"account_material_refresh_slots", fmt.Sprint(result.AccountChangedSlots),
|
||||
"state_source", "runtime_pvf_limited_cube_policy_atomic_account_and_character_inventory")
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendCurrentLimitedCubeTargetReplacementClear makes the current client
|
||||
// discard the old item object before the repository-backed result row arrives.
|
||||
// An op14 row that changes only the template ID keeps the old bead tooltip and
|
||||
// lock state cached in the already-instantiated object.
|
||||
func (s *Service) sendCurrentLimitedCubeTargetReplacementClear(session *gameSession, targetSlot int16) error {
|
||||
var removed currentItemListEntry
|
||||
removed.patchCore(targetSlot, math.MaxUint32, 0)
|
||||
body := buildCurrentItemUpdateBody(dnfrepo.MainInventoryListType, []currentItemListEntry{removed})
|
||||
s.logPacketEvent("game-limited-cube-target-replacement-clear-send",
|
||||
"conn_id", session.connID,
|
||||
"char_id", session.selectedCharacterID,
|
||||
"msg_id", uint16(dnfenum.CmdPacketWalkoutPartyMember),
|
||||
"classification", 0,
|
||||
"list_type", dnfrepo.MainInventoryListType,
|
||||
"slot", targetSlot,
|
||||
"sequence", "op338_ack_then_target_delete_then_repository_backed_result_op14")
|
||||
return s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketWalkoutPartyMember), body, 0)
|
||||
}
|
||||
Reference in new issue
Block a user