服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
283 lines
11 KiB
Go
283 lines
11 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"longheng.io/server/internal/modules/dnf/raid"
|
|
)
|
|
|
|
// Luke phase-one generators ("破坏目标 x/5").
|
|
//
|
|
// Each phase-one sanctum names its own counter in [DESTROY OBJECT INFO]
|
|
// ([BIRTH SANCTUM OBJECT] = symbol 117, extinction 118, spread 119) and caps it
|
|
// at [DEFAULT OBJECT COUNT] 5. luke.etc only zeroes them ([1PHASE INIT]) and
|
|
// resets one on giving up its sanctum ([RESET SANCTUM OBJECT]); [2PHASE INIT]
|
|
// leaves them alone. What reads them is [1PHASE OBJ DESTROY]: their total picks
|
|
// the tier stored in [ENERGY CONTROL OPEN] (133), and the war board draws each
|
|
// sanctum's five target icons lit for cap - symbol (DNF.exe sub_10D84D0: cap =
|
|
// sub_10D8450 clamped to 5, value = the raid symbol sub_17C2CF0 names). A fresh
|
|
// sanctum therefore shows 5/5 and loses one icon per destroyed generator.
|
|
//
|
|
// The generator is the sanctum's main machine, not the batteries or dry cells
|
|
// around it (湮灭之圣所炼金炉 = sanctum_of_extinction/mainmchine_raid.obj). Its
|
|
// PVF is [hp destroy] 0: at HP <= 1 its basic action switches to the destroy
|
|
// action, whose only behaviour is [RAID SET SYMBOL] <sanctum symbol> [+] 1:
|
|
//
|
|
// birth luke_mainmchine_destroy_raid.act / _mistral_raid.act 117 + 1
|
|
// extinction mainmchine_destroy_raid.act 118 + 1
|
|
// spread mainmchine_destroy.act / censor_machine_broken_front_raid.act 119 + 1
|
|
//
|
|
// The client turns that into C2S op662 {u32 symbol, u32 operand, u8 operation}
|
|
// (sub_17B1600). No op39 is sent for the machine. The script runs on every
|
|
// member's client, so one machine arrives once per member standing in the
|
|
// room; a room is credited with the most reports any single member made there.
|
|
|
|
type runtimeRaidLukeGeneratorReportKey struct {
|
|
MapID int
|
|
LifecycleToken uint64
|
|
Room runtimeDungeonRoomKey
|
|
}
|
|
|
|
type runtimeRaidLukeGeneratorRoomReports struct {
|
|
ByMember map[uint16]uint32
|
|
Credited uint32
|
|
}
|
|
|
|
func cloneRuntimeRaidLukeGeneratorReports(
|
|
reports map[runtimeRaidLukeGeneratorReportKey]runtimeRaidLukeGeneratorRoomReports,
|
|
) map[runtimeRaidLukeGeneratorReportKey]runtimeRaidLukeGeneratorRoomReports {
|
|
if reports == nil {
|
|
return nil
|
|
}
|
|
out := make(map[runtimeRaidLukeGeneratorReportKey]runtimeRaidLukeGeneratorRoomReports, len(reports))
|
|
for key, room := range reports {
|
|
byMember := make(map[uint16]uint32, len(room.ByMember))
|
|
for member, count := range room.ByMember {
|
|
byMember[member] = count
|
|
}
|
|
out[key] = runtimeRaidLukeGeneratorRoomReports{ByMember: byMember, Credited: room.Credited}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func runtimeRaidLukeSanctumObjectSymbol(mapID int) (uint32, bool) {
|
|
switch mapID {
|
|
case lukeBirthSanctumMapID:
|
|
return lukeSymbolBirthSanctumObject, true
|
|
case lukeExtinctionSanctumMapID:
|
|
return lukeSymbolExtinctionSanctumObject, true
|
|
case lukeSpreadSanctumMapID:
|
|
return lukeSymbolSpreadSanctumObject, true
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
// runtimeRaidLukeEnergyControlTierLocked is the [1PHASE OBJ DESTROY] tier of
|
|
// the three sanctum counters. A raid without its Luke config falls back to tier
|
|
// 0, the shortest respawn. Caller holds raidMu.
|
|
func runtimeRaidLukeEnergyControlTierLocked(state *runtimeRaidState) uint32 {
|
|
if state == nil || state.LukeConfig == nil {
|
|
return 0
|
|
}
|
|
total := runtimeRaidLukeSymbolLocked(state, lukeSymbolBirthSanctumObject) +
|
|
runtimeRaidLukeSymbolLocked(state, lukeSymbolExtinctionSanctumObject) +
|
|
runtimeRaidLukeSymbolLocked(state, lukeSymbolSpreadSanctumObject)
|
|
tier, ok := state.LukeConfig.EnergyControlTier(int(total))
|
|
if !ok || tier < 0 {
|
|
return 0
|
|
}
|
|
return uint32(tier)
|
|
}
|
|
|
|
type runtimeRaidLukeGeneratorCount struct {
|
|
RaidKey uint32
|
|
Phase byte
|
|
Symbol uint32
|
|
Before uint32
|
|
After uint32
|
|
Cap int
|
|
}
|
|
|
|
// countRuntimeRaidLukeGeneratorLocked credits one destroyed generator to the
|
|
// member's sanctum counter, saturating at [DEFAULT OBJECT COUNT]. Caller holds
|
|
// raidMu.
|
|
func countRuntimeRaidLukeGeneratorLocked(state *runtimeRaidState, mapID int) (runtimeRaidLukeGeneratorCount, bool) {
|
|
if state == nil || state.TailFlag == 1 || !state.Started ||
|
|
runtimeRaidTeamGraphOwner(*state) != runtimeRaidOwnerLuke ||
|
|
state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive ||
|
|
state.Lifecycle.Phase != runtimeRaidDungeonPhaseOne || state.LukeConfig == nil {
|
|
return runtimeRaidLukeGeneratorCount{}, false
|
|
}
|
|
symbol, ok := runtimeRaidLukeSanctumObjectSymbol(mapID)
|
|
if !ok {
|
|
return runtimeRaidLukeGeneratorCount{}, false
|
|
}
|
|
capacity, ok := state.LukeConfig.SanctumObjectCount(uint32(mapID))
|
|
if !ok || capacity <= 0 {
|
|
return runtimeRaidLukeGeneratorCount{}, false
|
|
}
|
|
result := runtimeRaidLukeGeneratorCount{
|
|
RaidKey: state.RaidKey,
|
|
Phase: state.Lifecycle.Phase,
|
|
Symbol: symbol,
|
|
Before: runtimeRaidLukeSymbolLocked(state, symbol),
|
|
Cap: capacity,
|
|
}
|
|
result.After = result.Before
|
|
if int(result.Before) >= capacity {
|
|
return result, false
|
|
}
|
|
result.After = result.Before + 1
|
|
setRuntimeRaidLukeSymbolLocked(state, symbol, result.After)
|
|
return result, true
|
|
}
|
|
|
|
// recordRuntimeRaidLukeGeneratorReportLocked books one member's op662 for a
|
|
// room and reports whether it is a machine no other member has reported yet.
|
|
// Caller holds raidMu.
|
|
func recordRuntimeRaidLukeGeneratorReportLocked(
|
|
state *runtimeRaidState,
|
|
key runtimeRaidLukeGeneratorReportKey,
|
|
characterID uint16,
|
|
) (runtimeRaidLukeGeneratorRoomReports, bool) {
|
|
if state.Lifecycle.LukeGeneratorReports == nil {
|
|
state.Lifecycle.LukeGeneratorReports = make(map[runtimeRaidLukeGeneratorReportKey]runtimeRaidLukeGeneratorRoomReports)
|
|
}
|
|
room := state.Lifecycle.LukeGeneratorReports[key]
|
|
if room.ByMember == nil {
|
|
room.ByMember = make(map[uint16]uint32)
|
|
}
|
|
room.ByMember[characterID]++
|
|
fresh := room.ByMember[characterID] > room.Credited
|
|
if fresh {
|
|
room.Credited = room.ByMember[characterID]
|
|
}
|
|
state.Lifecycle.LukeGeneratorReports[key] = room
|
|
return room, fresh
|
|
}
|
|
|
|
// runtimeRaidCurrentRaidDungeonRoom is runtimeRaidCurrentRaidDungeonRun plus the
|
|
// room the session stands in.
|
|
//
|
|
// The binding's token is the SQUAD's run token, never the adopting member's own
|
|
// (see the adoption branch of beginRuntimeRaidDungeon), so it must NOT be compared
|
|
// with the session's local token here -- exactly the trap the Anton twin
|
|
// (runtimeRaidCurrentRaidDungeonRun) documents. Upstream's Anton helper had that
|
|
// comparison removed on 2026-09-19 (raid 1507338: every shield report but the
|
|
// opener's was silently dropped) while this Luke helper kept it, and the port
|
|
// brought the bug across:
|
|
//
|
|
// live 2026-09-29 09:21-09:23Z, raid 1835009: char 22 opened the 湮灭之圣所 run
|
|
// (binding token 7), char 1 walked in behind it (its own token 8, per
|
|
// game-raid-dungeon-lifecycle-joined-clear). The machine's hand-written
|
|
// [RAID SET SYMBOL] 118 [+] 1 arrived as op662 {118, 1, add} and was declined
|
|
// here, fell through to the Anton barrier owner and was logged as
|
|
// game-raid-barrier-request-rejected reason=not_inside_energy_or_hatchery_run.
|
|
// 破坏目标 stayed 5/5 for the whole run while the same reports from the run's
|
|
// opener had counted fine earlier that day.
|
|
//
|
|
// What a report has to prove is that this member stands inside the gate its
|
|
// binding names; the binding's token stays the caller's per-run dedupe key.
|
|
func runtimeRaidCurrentRaidDungeonRoom(session *gameSession) (runtimeRaidDungeonBinding, runtimeDungeonRoomKey, bool) {
|
|
if session == nil {
|
|
return runtimeRaidDungeonBinding{}, runtimeDungeonRoomKey{}, false
|
|
}
|
|
session.dungeon.mu.Lock()
|
|
defer session.dungeon.mu.Unlock()
|
|
runtime := session.dungeon.runtime
|
|
if runtime == nil || runtime.Room == nil || runtime.raidDungeon.RaidKey == 0 ||
|
|
runtime.raidDungeon.LifecycleToken == 0 ||
|
|
!dungeonRuntimeOwnsCharacter(runtime, session.selectedCharacterID) {
|
|
return runtimeRaidDungeonBinding{}, runtimeDungeonRoomKey{}, false
|
|
}
|
|
runtime.Room.mu.RLock()
|
|
room := runtimeDungeonRoomKey{X: runtime.Room.coordinate.X, Y: runtime.Room.coordinate.Y, MapID: runtime.Room.mapID}
|
|
runtime.Room.mu.RUnlock()
|
|
return runtime.raidDungeon, room, true
|
|
}
|
|
|
|
// applyRuntimeRaidLukeGeneratorReport owns an op662 sent from inside a Luke
|
|
// phase-one sanctum for that sanctum's own counter. It returns false for any
|
|
// other op662 so the phase-two shield owner still sees it.
|
|
func (s *Service) applyRuntimeRaidLukeGeneratorReport(session *gameSession, req raid.SetSymbolRequest) bool {
|
|
characterID := selectedCharacterID(session)
|
|
if s == nil || characterID == 0 {
|
|
return false
|
|
}
|
|
run, room, inRun := runtimeRaidCurrentRaidDungeonRoom(session)
|
|
if !inRun {
|
|
return false
|
|
}
|
|
symbol, sanctum := runtimeRaidLukeSanctumObjectSymbol(run.MapID)
|
|
if !sanctum || req.SourceValue != symbol {
|
|
return false
|
|
}
|
|
if req.Symbol != runtimeRaidSymbolOperationAdd || req.TargetValue != 1 || run.Phase != runtimeRaidDungeonPhaseOne {
|
|
s.logGameEvent(session, "game-raid-luke-generator-report-rejected",
|
|
"char_id", characterID, "sanctum", run.MapID, "symbol", req.SourceValue,
|
|
"operand", req.TargetValue, "operation", req.Symbol, "run_phase", run.Phase,
|
|
"reason", "not_a_single_generator_increment")
|
|
return true
|
|
}
|
|
|
|
s.raidMu.Lock()
|
|
state := s.runtimeRaidByMemberLocked(characterID)
|
|
if state == nil || state.RaidKey != run.RaidKey {
|
|
s.raidMu.Unlock()
|
|
s.logGameEvent(session, "game-raid-luke-generator-report-rejected",
|
|
"char_id", characterID, "sanctum", run.MapID, "reason", "raid_not_found")
|
|
return true
|
|
}
|
|
key := runtimeRaidLukeGeneratorReportKey{MapID: run.MapID, LifecycleToken: run.LifecycleToken, Room: room}
|
|
reports, fresh := recordRuntimeRaidLukeGeneratorReportLocked(state, key, characterID)
|
|
var count runtimeRaidLukeGeneratorCount
|
|
counted := false
|
|
if fresh {
|
|
count, counted = countRuntimeRaidLukeGeneratorLocked(state, run.MapID)
|
|
}
|
|
s.raidMu.Unlock()
|
|
|
|
s.logGameEvent(session, "game-raid-luke-generator-reported",
|
|
"char_id", characterID,
|
|
"raid_key", run.RaidKey,
|
|
"sanctum", run.MapID,
|
|
"room", fmt.Sprintf("%d:%d", room.X, room.Y),
|
|
"map_id", room.MapID,
|
|
"member_reports", reports.ByMember[characterID],
|
|
"room_credited", reports.Credited,
|
|
"fresh", fresh,
|
|
"symbol", symbol,
|
|
"count_before", count.Before,
|
|
"count_after", count.After,
|
|
"cap", count.Cap,
|
|
"counted", counted)
|
|
if counted {
|
|
s.scheduleRuntimeRaidLukeProgressPublish(count.RaidKey, count.Phase, fmt.Sprintf("generator:%d:%d", run.MapID, count.After))
|
|
}
|
|
return true
|
|
}
|
|
|
|
// scheduleRuntimeRaidLukeProgressPublish publishes the phase snapshot once the
|
|
// caller's locks are gone (see scheduleRuntimeRaidLukeEntryProgressPublish).
|
|
func (s *Service) scheduleRuntimeRaidLukeProgressPublish(raidKey uint32, phase byte, reason string) {
|
|
if s == nil || raidKey == 0 {
|
|
return
|
|
}
|
|
queue := s.ensureGameplayTimeQueue()
|
|
if queue == nil {
|
|
return
|
|
}
|
|
name := fmt.Sprintf("raid-luke-progress:%d:%s", raidKey, reason)
|
|
if err := queue.ScheduleAfter(name, 0, func(time.Time) {
|
|
if err := s.publishRuntimeRaidObjectiveProgress(raidKey, phase); err != nil {
|
|
s.logPacketEvent("game-raid-luke-progress-publish-failed",
|
|
"raid_key", raidKey, "reason", reason, "error", err)
|
|
}
|
|
}); err != nil {
|
|
s.logPacketEvent("game-raid-luke-progress-schedule-failed",
|
|
"raid_key", raidKey, "reason", reason, "error", err)
|
|
}
|
|
}
|