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

373 lines
17 KiB
Go

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)
}