服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
366 lines
15 KiB
Go
366 lines
15 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"longheng.io/server/internal/modules/dnf/dnfenum"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
const (
|
|
currentPVPTownID int64 = 10
|
|
currentPVPAreaID int64 = 0
|
|
currentAntonRaidTownID int64 = 19
|
|
currentAntonRaidAreaID int64 = 0
|
|
currentAntonRaidWaitingAreaID byte = 2
|
|
currentAntonRaidDirection = newCharacterInitialDirection
|
|
currentAntonRaidAreaState = newCharacterInitialAreaState
|
|
currentLukeRaidTownID int64 = 32
|
|
currentLukeRaidAreaID int64 = 0
|
|
currentLukeRaidWaitingAreaID byte = 4
|
|
// 超时空攻坚战 (FiendWar). The etc declares [WAITING ROOM] `98 2`, and the
|
|
// channel-entry landmark is town 98 area 0 exactly like town 19 and town 32:
|
|
// town/town.lst maps 98 -> `180621_Cosmofiend.twn`, whose area 0 is
|
|
// `GContents/180621_Fiendwar/Town/Gate_Fiend.map` with gate (474,234) --
|
|
// the same gate coordinate the other two raid towns publish.
|
|
currentFiendWarRaidTownID int64 = 98
|
|
currentFiendWarRaidAreaID int64 = 0
|
|
currentFiendWarWaitingAreaID byte = 2
|
|
)
|
|
|
|
type runtimeRaidTownSpec struct {
|
|
townID int64
|
|
areaID int64
|
|
waitingArea byte
|
|
ownerName string
|
|
}
|
|
|
|
// isCurrentAntonRaidChannel identifies the public Anton team-channel
|
|
// environment. The channel type is the authoritative discriminator; channel
|
|
// IDs are deployment data and may differ between channel_info.etc variants.
|
|
func isCurrentAntonRaidChannel(session *gameSession) bool {
|
|
return session != nil && session.channel.Type == dnfenum.RaidChannelType
|
|
}
|
|
|
|
func isCurrentLukeRaidChannel(session *gameSession) bool {
|
|
return session != nil && session.channel.Type == dnfenum.HiddenRaidType
|
|
}
|
|
|
|
// isCurrentFiendWarRaidChannel identifies the public Fiend War (超时空攻坚战)
|
|
// team-channel environment. As with Anton and Luke the channel type is the
|
|
// discriminator and the ids stay deployment data: this deployment publishes the
|
|
// three `[fiendwar]` rows as 184..186, remapped from the stock 261..263 because
|
|
// a published id above 254 leaves the client's u8 town-actor owner uncommitted
|
|
// (see channel_info.etc).
|
|
//
|
|
// Recognising the channel is what routes it to town 98 instead of the character's
|
|
// ordinary town. It deliberately does NOT make the raid runnable: the owner
|
|
// runtime, roster and board land in a later step, so a channel that reaches here
|
|
// today can build the raid town scene and nothing more.
|
|
func isCurrentFiendWarRaidChannel(session *gameSession) bool {
|
|
return session != nil && session.channel.Type == dnfenum.FiendWarRaidChannelType
|
|
}
|
|
|
|
func isCurrentImplementedRaidChannel(session *gameSession) bool {
|
|
return isCurrentAntonRaidChannel(session) || isCurrentLukeRaidChannel(session) || isCurrentFiendWarRaidChannel(session)
|
|
}
|
|
|
|
func currentRaidTownSpecForSession(session *gameSession) (runtimeRaidTownSpec, bool) {
|
|
switch {
|
|
case isCurrentAntonRaidChannel(session):
|
|
return runtimeRaidTownSpec{townID: currentAntonRaidTownID, areaID: currentAntonRaidAreaID, waitingArea: currentAntonRaidWaitingAreaID, ownerName: "anton"}, true
|
|
case isCurrentLukeRaidChannel(session):
|
|
return runtimeRaidTownSpec{townID: currentLukeRaidTownID, areaID: currentLukeRaidAreaID, waitingArea: currentLukeRaidWaitingAreaID, ownerName: "luke"}, true
|
|
case isCurrentFiendWarRaidChannel(session):
|
|
return runtimeRaidTownSpec{townID: currentFiendWarRaidTownID, areaID: currentFiendWarRaidAreaID, waitingArea: currentFiendWarWaitingAreaID, ownerName: "fiendwar"}, true
|
|
default:
|
|
return runtimeRaidTownSpec{}, false
|
|
}
|
|
}
|
|
|
|
// currentAntonRaidTownTransition builds the temporary team-channel scene
|
|
// from the runtime PVF. The raid town is session-scoped: its location is sent
|
|
// to the client but is deliberately not written into the character's durable
|
|
// ordinary-town stats.
|
|
func (s *Service) currentAntonRaidTownTransition(
|
|
session *gameSession,
|
|
characterID uint16,
|
|
character dnfrepo.CharacterRecord,
|
|
locationSource string,
|
|
) (dnfrepo.CharacterRecord, byte, byte, currentSceneTransitionRow, string, error) {
|
|
return s.currentRaidTownTransition(session, characterID, character, locationSource, runtimeRaidTownSpec{
|
|
townID: currentAntonRaidTownID, areaID: currentAntonRaidAreaID, waitingArea: currentAntonRaidWaitingAreaID, ownerName: "anton",
|
|
})
|
|
}
|
|
|
|
func (s *Service) currentLukeRaidTownTransition(
|
|
session *gameSession,
|
|
characterID uint16,
|
|
character dnfrepo.CharacterRecord,
|
|
locationSource string,
|
|
) (dnfrepo.CharacterRecord, byte, byte, currentSceneTransitionRow, string, error) {
|
|
return s.currentRaidTownTransition(session, characterID, character, locationSource, runtimeRaidTownSpec{
|
|
townID: currentLukeRaidTownID, areaID: currentLukeRaidAreaID, waitingArea: currentLukeRaidWaitingAreaID, ownerName: "luke",
|
|
})
|
|
}
|
|
|
|
func (s *Service) currentPVPTownTransition(
|
|
session *gameSession,
|
|
characterID uint16,
|
|
character dnfrepo.CharacterRecord,
|
|
locationSource string,
|
|
) (dnfrepo.CharacterRecord, byte, byte, currentSceneTransitionRow, string, error) {
|
|
return s.currentRaidTownTransition(session, characterID, character, locationSource, runtimeRaidTownSpec{
|
|
townID: currentPVPTownID, areaID: currentPVPAreaID, ownerName: "pvp",
|
|
})
|
|
}
|
|
|
|
func (s *Service) currentRaidTownTransition(
|
|
session *gameSession,
|
|
characterID uint16,
|
|
character dnfrepo.CharacterRecord,
|
|
locationSource string,
|
|
spec runtimeRaidTownSpec,
|
|
) (dnfrepo.CharacterRecord, byte, byte, currentSceneTransitionRow, string, error) {
|
|
if characterID == 0 || character.Stats == nil {
|
|
return dnfrepo.CharacterRecord{}, 0, 0, currentSceneTransitionRow{}, "", fmt.Errorf("selected character raid location is unavailable")
|
|
}
|
|
// 超时空:登录/重连/换频道进超时空频道时,先去问战场"这个人现在该落在哪" ——
|
|
// 它会在等待室 NPC 位与战场记录之间给出答案,并把不在场的成员拉回等待室。
|
|
// 只有这三个来源会问:普通掷骰进频道仍走下面的 PVF 大门。
|
|
if spec.ownerName == "fiendwar" && (locationSource == "selected_character_fiendwar_raid_channel_runtime_pvf_gate" ||
|
|
locationSource == "channel_reconnect_fiendwar_raid_channel_runtime_pvf_gate" ||
|
|
locationSource == "character_list_login_fiendwar_raid_channel_runtime_pvf_gate") {
|
|
if row, areaID, path, restored, err := s.runtimeFiendWarRejoinLocation(session, characterID); restored || err != nil {
|
|
return character, byte(spec.townID), areaID, row, path, err
|
|
}
|
|
}
|
|
area, found := s.townArea(spec.townID, spec.areaID)
|
|
if !found || area.MapPath == "" || area.Gate == nil {
|
|
return dnfrepo.CharacterRecord{}, 0, 0, currentSceneTransitionRow{}, "", fmt.Errorf(
|
|
"runtime PVF %s raid town %d area %d has no gate",
|
|
spec.ownerName,
|
|
spec.townID,
|
|
spec.areaID,
|
|
)
|
|
}
|
|
if area.MinLevel > int64(character.Level) {
|
|
return dnfrepo.CharacterRecord{}, 0, 0, currentSceneTransitionRow{}, "", fmt.Errorf(
|
|
"%s raid town %d area %d requires level %d, character level %d",
|
|
spec.ownerName,
|
|
spec.townID,
|
|
spec.areaID,
|
|
area.MinLevel,
|
|
character.Level,
|
|
)
|
|
}
|
|
if area.Gate.X < -1<<15 || area.Gate.X > 1<<15-1 || area.Gate.Y < -1<<15 || area.Gate.Y > 1<<15-1 {
|
|
return dnfrepo.CharacterRecord{}, 0, 0, currentSceneTransitionRow{}, "", fmt.Errorf(
|
|
"runtime PVF %s raid gate (%d,%d) is outside i16",
|
|
spec.ownerName,
|
|
area.Gate.X,
|
|
area.Gate.Y,
|
|
)
|
|
}
|
|
if locationSource == "" {
|
|
locationSource = spec.ownerName + "_raid_channel_runtime_pvf_gate"
|
|
}
|
|
townID := byte(spec.townID)
|
|
areaID := byte(spec.areaID)
|
|
row := currentSceneTransitionRow{
|
|
ObjectOrResourceKey: currentSceneActorObjectKey(characterID),
|
|
Value1: uint16(int16(area.Gate.X)),
|
|
Value2: uint16(int16(area.Gate.Y)),
|
|
Value3: currentAntonRaidDirection,
|
|
Value4: currentAntonRaidAreaState,
|
|
}
|
|
s.logGameEvent(session, "game-raid-town-location-used",
|
|
"raid_owner", spec.ownerName,
|
|
"char_id", characterID,
|
|
"channel_id", session.channel.ID,
|
|
"channel_type", session.channel.Type,
|
|
"town_id", townID,
|
|
"area_id", areaID,
|
|
"position_x", int16(row.Value1),
|
|
"position_y", int16(row.Value2),
|
|
"direction", row.Value3,
|
|
"area_state", row.Value4,
|
|
"map_path", area.MapPath,
|
|
"min_level", area.MinLevel,
|
|
"durable_character_location_written", false,
|
|
"source", locationSource)
|
|
// 卢克团队频道入场:官方系统说明「塔罗卡牌在团队频道入场时可以看见1次,之后
|
|
// 可以在攻坚战进行中的状况表上确认」。这条就是"登录/重连落进卢克团队频道城镇"
|
|
// 的真实入口 —— 本函数是 game-raid-town-location-used 的唯一发出点,线上实测
|
|
// 2026-09-29T10:19:44Z / 10:19:47Z 两个角色正是从这里落进 town 32。
|
|
// 安徒恩(town 19)与 PVP 频道(town 10)在这一行就被 townID 挡掉,一个字不改。
|
|
if spec.townID == currentLukeRaidTownID {
|
|
s.publishRuntimeRaidLukeCardDisplay(session, 0, "luke_raid_channel_town_"+locationSource)
|
|
}
|
|
return character, townID, areaID, row, area.MapPath, nil
|
|
}
|
|
|
|
func (s *Service) currentInitialTownTransition(
|
|
ctx context.Context,
|
|
session *gameSession,
|
|
characterID uint16,
|
|
character dnfrepo.CharacterRecord,
|
|
) (dnfrepo.CharacterRecord, byte, byte, currentSceneTransitionRow, string, error) {
|
|
if session != nil && isPVPChannel(session.channel) {
|
|
return s.currentPVPTownTransition(
|
|
session,
|
|
characterID,
|
|
character,
|
|
"selected_character_pvp_channel_runtime_pvf_gate",
|
|
)
|
|
}
|
|
if spec, ok := currentRaidTownSpecForSession(session); ok {
|
|
return s.currentRaidTownTransition(
|
|
session,
|
|
characterID,
|
|
character,
|
|
"selected_character_"+spec.ownerName+"_raid_channel_runtime_pvf_gate",
|
|
spec,
|
|
)
|
|
}
|
|
return s.currentPersistedTownTransition(ctx, session, characterID, character, "selected_character_persisted_location_read_only")
|
|
}
|
|
|
|
func (s *Service) currentChannelReconnectTownTransition(
|
|
ctx context.Context,
|
|
session *gameSession,
|
|
characterID uint16,
|
|
character dnfrepo.CharacterRecord,
|
|
) (dnfrepo.CharacterRecord, byte, byte, currentSceneTransitionRow, string, error) {
|
|
if session != nil && isPVPChannel(session.channel) {
|
|
return s.currentPVPTownTransition(
|
|
session,
|
|
characterID,
|
|
character,
|
|
"channel_reconnect_pvp_channel_runtime_pvf_gate",
|
|
)
|
|
}
|
|
if spec, ok := currentRaidTownSpecForSession(session); ok {
|
|
return s.currentRaidTownTransition(
|
|
session,
|
|
characterID,
|
|
character,
|
|
"channel_reconnect_"+spec.ownerName+"_raid_channel_runtime_pvf_gate",
|
|
spec,
|
|
)
|
|
}
|
|
if session != nil && session.channelReconnectNewCharacter {
|
|
s.logGameEvent(session, "game-channel-reconnect-new-character-login",
|
|
"char_id", characterID,
|
|
"target_channel", session.channel.ID,
|
|
"source", "registered_client_changed_character_before_reconnect")
|
|
return s.currentCharacterListLoginTransition(ctx, session, characterID, character)
|
|
}
|
|
// Earlier type-23 op36 handling accidentally persisted its temporary town
|
|
// 19 location. Town 19 has no ordinary-channel ownership, so repair only
|
|
// that impossible durable state through the proven ordinary login owner
|
|
// before constructing the reconnect scene.
|
|
if character.Stats != nil && character.Stats["town_id"] == currentAntonRaidTownID {
|
|
s.logGameEvent(session, "game-channel-reconnect-repair-leaked-anton-town",
|
|
"char_id", characterID,
|
|
"channel_id", session.channel.ID,
|
|
"channel_type", session.channel.Type,
|
|
"leaked_town_id", currentAntonRaidTownID,
|
|
"recovery_town_id", newCharacterInitialTownID,
|
|
"recovery_area_id", newCharacterInitialAreaID,
|
|
"source", "type23_op36_prevented_from_persisting_temporary_raid_town")
|
|
return s.currentCharacterListLoginTransition(ctx, session, characterID, character)
|
|
}
|
|
return s.currentPersistedTownTransition(ctx, session, characterID, character, "channel_reconnect_persisted_location_read_only")
|
|
}
|
|
|
|
func (s *Service) currentPersistedTownTransition(
|
|
ctx context.Context,
|
|
session *gameSession,
|
|
characterID uint16,
|
|
character dnfrepo.CharacterRecord,
|
|
locationSource string,
|
|
) (dnfrepo.CharacterRecord, byte, byte, currentSceneTransitionRow, string, error) {
|
|
townID, err := requiredCurrentInitialTownU8(character, "town_id")
|
|
if err != nil {
|
|
return dnfrepo.CharacterRecord{}, 0, 0, currentSceneTransitionRow{}, "", err
|
|
}
|
|
areaID, err := requiredCurrentInitialTownU8(character, "area_id")
|
|
if err != nil {
|
|
return dnfrepo.CharacterRecord{}, 0, 0, currentSceneTransitionRow{}, "", err
|
|
}
|
|
positionX, err := requiredCurrentInitialTownCoordinate(character, "pos_x")
|
|
if err != nil {
|
|
return dnfrepo.CharacterRecord{}, 0, 0, currentSceneTransitionRow{}, "", err
|
|
}
|
|
positionY, err := requiredCurrentInitialTownCoordinate(character, "pos_y")
|
|
if err != nil {
|
|
return dnfrepo.CharacterRecord{}, 0, 0, currentSceneTransitionRow{}, "", err
|
|
}
|
|
direction, err := requiredCurrentInitialTownU8(character, "direction")
|
|
if err != nil {
|
|
return dnfrepo.CharacterRecord{}, 0, 0, currentSceneTransitionRow{}, "", err
|
|
}
|
|
areaState, err := requiredCurrentInitialTownU8(character, "area_state")
|
|
if err != nil {
|
|
return dnfrepo.CharacterRecord{}, 0, 0, currentSceneTransitionRow{}, "", err
|
|
}
|
|
area, found := s.townArea(int64(townID), int64(areaID))
|
|
if !found || area.MapPath == "" {
|
|
return dnfrepo.CharacterRecord{}, 0, 0, currentSceneTransitionRow{}, "", fmt.Errorf(
|
|
"persisted town %d area %d has no runtime PVF map path",
|
|
townID, areaID,
|
|
)
|
|
}
|
|
if locationSource == "" {
|
|
locationSource = "persisted_location_read_only"
|
|
}
|
|
s.logGameEvent(session, "game-initial-town-persisted-location-used",
|
|
"char_id", characterID,
|
|
"town_id", townID,
|
|
"area_id", areaID,
|
|
"position_x", int16(positionX),
|
|
"position_y", int16(positionY),
|
|
"direction", direction,
|
|
"area_state", areaState,
|
|
"map_path", area.MapPath,
|
|
"need_quest_unlock_allowed", false,
|
|
"source", locationSource)
|
|
return character, townID, areaID, currentSceneTransitionRow{
|
|
ObjectOrResourceKey: currentSceneActorObjectKey(characterID),
|
|
Value1: positionX,
|
|
Value2: positionY,
|
|
Value3: direction,
|
|
Value4: areaState,
|
|
}, area.MapPath, nil
|
|
}
|
|
|
|
func requiredCurrentInitialTownCoordinate(character dnfrepo.CharacterRecord, key string) (uint16, error) {
|
|
if character.Stats == nil {
|
|
return 0, fmt.Errorf("selected character stats not loaded")
|
|
}
|
|
value, found := character.Stats[key]
|
|
if !found {
|
|
return 0, fmt.Errorf("selected character %s not loaded", key)
|
|
}
|
|
if value < -1<<15 || value > 1<<15-1 {
|
|
return 0, fmt.Errorf("selected character %s %d is outside i16", key, value)
|
|
}
|
|
return uint16(int16(value)), nil
|
|
}
|
|
|
|
func requiredCurrentInitialTownU8(character dnfrepo.CharacterRecord, key string) (byte, error) {
|
|
if character.Stats == nil {
|
|
return 0, fmt.Errorf("selected character stats not loaded")
|
|
}
|
|
value, found := character.Stats[key]
|
|
if !found {
|
|
return 0, fmt.Errorf("selected character %s not loaded", key)
|
|
}
|
|
if value < 0 || value > int64(^byte(0)) {
|
|
return 0, fmt.Errorf("selected character %s %d is outside u8", key, value)
|
|
}
|
|
return byte(value), nil
|
|
}
|