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,604 @@
|
||||
package dnfbridge
|
||||
|
||||
// Attack-party squads and ordinary four-member parties are two different
|
||||
// objects. When members of one attack party form a real ordinary party while
|
||||
// the squads are still editable, the raid roster follows: those members are
|
||||
// projected into one squad so the attack-group window agrees with the party
|
||||
// they actually fight in. The projection is one-way. The ordinary party is
|
||||
// never rewritten from the raid, and an ordinary party that dissolves leaves
|
||||
// the squad labels untouched (the native grid has no unassigned row).
|
||||
//
|
||||
// Hypothesis source (never authority): the read-only 86JP C# server,
|
||||
// RaidHandler.cs HandleNormalPartyFormedAsync / TrySynchronizeNormalPartySquadLocked.
|
||||
|
||||
// syncRuntimeRaidSquadsWithOrdinaryParty is called after every ordinary-party
|
||||
// snapshot publication with that party's member ids. It reports whether the
|
||||
// raid roster changed.
|
||||
func (s *Service) syncRuntimeRaidSquadsWithOrdinaryParty(memberIDs []uint16) bool {
|
||||
return s.syncRuntimeRaidSquadsWithOrdinaryPartyLedBy(memberIDs, 0)
|
||||
}
|
||||
|
||||
// syncRuntimeRaidSquadsWithOrdinaryPartyLedBy is the same sync with the ordinary
|
||||
// party's own leader, which is what decides whose squad number the party keeps
|
||||
// ("队长则保持队伍号不变").
|
||||
func (s *Service) syncRuntimeRaidSquadsWithOrdinaryPartyLedBy(memberIDs []uint16, leaderID uint16) bool {
|
||||
if s == nil || len(memberIDs) < 2 {
|
||||
return false
|
||||
}
|
||||
// A party cannot even be formed while its members are inside dungeon runs, so a
|
||||
// party snapshot that names members currently inside a raid gate is not a real
|
||||
// party gesture: those snapshots are republished by the dungeon-entry and
|
||||
// loading-barrier machinery itself. Following one re-labelled members who never
|
||||
// stood together in town. Live 2026-09-19, raid 1507338: 14:24:22 and 14:35:10
|
||||
// folded two members who were fighting inside 218 into squad 1, and 14:38:05
|
||||
// pulled all four of them into one squad -- the operator's "那两个人还在副本里
|
||||
// 也没有组队的条件啊".
|
||||
if inside := s.raidMembersInsideDungeonRuns(memberIDs); len(inside) > 0 {
|
||||
s.logPacketEvent("game-raid-squad-sync-skipped-members-inside-dungeon",
|
||||
"members", memberIDs,
|
||||
"inside_members", inside,
|
||||
"reason", "an_ordinary_party_cannot_be_formed_from_inside_a_dungeon_run")
|
||||
return false
|
||||
}
|
||||
s.raidMu.Lock()
|
||||
var state *runtimeRaidState
|
||||
inRaid := make([]uint16, 0, len(memberIDs))
|
||||
for _, id := range memberIDs {
|
||||
candidate := s.runtimeRaidByMemberLocked(id)
|
||||
if candidate == nil {
|
||||
continue
|
||||
}
|
||||
if state == nil {
|
||||
state = candidate
|
||||
}
|
||||
if candidate != state {
|
||||
// Members of two different attack parties formed one ordinary party:
|
||||
// neither raid may claim the other's roster.
|
||||
s.raidMu.Unlock()
|
||||
return false
|
||||
}
|
||||
inRaid = append(inRaid, id)
|
||||
}
|
||||
if state == nil || len(inRaid) < 2 || !runtimeRaidSquadsFollowOrdinaryParty(*state) {
|
||||
s.raidMu.Unlock()
|
||||
return false
|
||||
}
|
||||
target, ok := runtimeRaidSquadForOrdinaryPartyLedBy(*state, inRaid, leaderID)
|
||||
if !ok {
|
||||
s.raidMu.Unlock()
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
for _, id := range inRaid {
|
||||
index := runtimeRaidMemberIndex(*state, id)
|
||||
if index < 0 || state.Members[index].GroupIndex == target {
|
||||
continue
|
||||
}
|
||||
state.Members[index].GroupIndex = target
|
||||
state.Members[index].SlotOrder = nextRuntimeRaidSlotExcluding(*state, target, id)
|
||||
if state.Started {
|
||||
for i := range state.FrozenMembers {
|
||||
if state.FrozenMembers[i].CharID == id {
|
||||
state.FrozenMembers[i].GroupIndex = target
|
||||
state.FrozenMembers[i].SlotOrder = state.Members[index].SlotOrder
|
||||
}
|
||||
}
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
s.raidMu.Unlock()
|
||||
return false
|
||||
}
|
||||
runtimeRaidSortMembers(state.Members)
|
||||
if state.Started {
|
||||
runtimeRaidSortMembers(state.FrozenMembers)
|
||||
}
|
||||
snapshot := cloneRuntimeRaidState(*state)
|
||||
s.raidMu.Unlock()
|
||||
|
||||
if err := s.broadcastRuntimeRaidRefresh(snapshot); err != nil {
|
||||
s.logPacketEvent("game-raid-squad-sync-refresh-failed", "raid_key", snapshot.RaidKey, "error", err)
|
||||
}
|
||||
if err := s.broadcastRuntimeRaidBuffRegistration(snapshot, 0); err != nil {
|
||||
s.logPacketEvent("game-raid-squad-sync-buff-failed", "raid_key", snapshot.RaidKey, "error", err)
|
||||
}
|
||||
s.logPacketEvent("game-raid-squad-synced-from-ordinary-party",
|
||||
"raid_key", snapshot.RaidKey, "squad", target, "members", inRaid)
|
||||
return true
|
||||
}
|
||||
|
||||
// raidMembersInsideDungeonRuns reports which of the given characters currently hold
|
||||
// a committed raid dungeon run, i.e. are standing inside a raid gate of the running
|
||||
// raid. A member in town holds no runtime at all, so this is exactly the "cannot be
|
||||
// forming a party right now" set.
|
||||
func (s *Service) raidMembersInsideDungeonRuns(characterIDs []uint16) []uint16 {
|
||||
return s.charactersInsideDungeonRuns(characterIDs, true)
|
||||
}
|
||||
|
||||
// charactersInsideDungeonRuns reports which of the given characters are standing
|
||||
// inside one of their own dungeon runs right now. raidOnly narrows that to the
|
||||
// attack-party gates, which is the question the squad bookkeeping asks; the party
|
||||
// gestures ask the wider question because 86JP refuses them for any run at all
|
||||
// (Player.CurrentRun != null).
|
||||
func (s *Service) charactersInsideDungeonRuns(characterIDs []uint16, raidOnly bool) []uint16 {
|
||||
if s == nil || len(characterIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
inside := make([]uint16, 0, len(characterIDs))
|
||||
for _, characterID := range characterIDs {
|
||||
if characterID == 0 {
|
||||
continue
|
||||
}
|
||||
session, ok := s.onlineGameSession(characterID)
|
||||
if !ok || session == nil {
|
||||
continue
|
||||
}
|
||||
session.dungeon.mu.Lock()
|
||||
runtime := session.dungeon.runtime
|
||||
inRun := runtime != nil && dungeonRuntimeOwnsCharacter(runtime, characterID)
|
||||
if inRun && raidOnly {
|
||||
inRun = runtime.raidDungeon.RaidKey != 0
|
||||
}
|
||||
session.dungeon.mu.Unlock()
|
||||
if inRun {
|
||||
inside = append(inside, characterID)
|
||||
}
|
||||
}
|
||||
return inside
|
||||
}
|
||||
|
||||
// partyGestureInsideDungeonRun answers whether an ordinary-party gesture between
|
||||
// the named characters has to be refused because one of them is inside a dungeon
|
||||
// run, and names the first such character.
|
||||
//
|
||||
// 86JP refuses every party formation path while either side holds
|
||||
// Player.CurrentRun or the manager reports the party dungeon-active: REQUEST_PEER
|
||||
// (PartyHandler.cs:1633-1644, including the trade variant at :1543-1556),
|
||||
// RES_PEER for an application (:1921-1929) and for an invitation (:1998-2009,
|
||||
// re-checked after the party object is created at :2034-2045), and CHANGE_HOST
|
||||
// (:1439-1445). Trade invites are refused with the same gate. The refusal travels
|
||||
// as a peer-command failure carrying error code 0x04 (BuildPeerCommandFailureBody),
|
||||
// which this server already answers with as sendPartyCommandFailure.
|
||||
//
|
||||
// This is the second half of the "4 个人突然组队到一起了" report: the raid roster no
|
||||
// longer follows a party formed from inside a run (see
|
||||
// syncRuntimeRaidSquadsWithOrdinaryParty), and a party cannot be formed from inside
|
||||
// one in the first place.
|
||||
func (s *Service) partyGestureInsideDungeonRun(characterIDs ...uint16) (uint16, bool) {
|
||||
inside := s.charactersInsideDungeonRuns(characterIDs, false)
|
||||
if len(inside) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return inside[0], true
|
||||
}
|
||||
|
||||
// runtimeRaidSquadLabelTargets selects which of the two raid roster lists a
|
||||
// squad-label clear rewrites. The lists are NOT interchangeable:
|
||||
//
|
||||
// - state.Members is the LIVE roster. The client's 攻击队 window is built from
|
||||
// it (runtimeRaidObjectMode0 / runtimeRaidMemberRefresh), runtimeRaidPartyGroups
|
||||
// projects an ordinary party from it, and runtimeRaidFirstAvailablePosition
|
||||
// counts its occupied grid slots.
|
||||
// - state.FrozenMembers is the phase roster snapshot taken at start
|
||||
// (runtimeRaidStartReservation). It owns the phase-clear registration --
|
||||
// runtimeRaidNotePhaseClearLocked credits EVERY frozen member of the clearing
|
||||
// member's squad -- plus the 战况表 gate rows (runtimeRaidMembersInSquad) and
|
||||
// the settlement board split (runtimeRaidRewardSquadBodies).
|
||||
//
|
||||
// A member dropped from the frozen roster therefore stops being credited for his
|
||||
// squad's clears, and "no phase clear" is the same fact as "no phase reward"
|
||||
// (runtimeRaidRewardEligibleMembersLocked). That coupling is why the two scopes
|
||||
// exist instead of one.
|
||||
type runtimeRaidSquadLabelTargets int
|
||||
|
||||
const (
|
||||
// runtimeRaidSquadLabelBothRows is the ordinary party leave and the kick: the
|
||||
// member is out of the party for good, so both rows follow.
|
||||
runtimeRaidSquadLabelBothRows runtimeRaidSquadLabelTargets = iota
|
||||
// runtimeRaidSquadLabelLiveRowOnly is the solo town return. Nothing about it
|
||||
// says the member stopped contributing to the phase he is still a member of,
|
||||
// so only the row the 攻击队 window draws is rewritten and the frozen roster
|
||||
// (credits, 战况表 rows, reward board) stays exactly as it was.
|
||||
runtimeRaidSquadLabelLiveRowOnly
|
||||
)
|
||||
|
||||
// unassignRuntimeRaidSquadOnPartyLeave clears one member's squad label when that
|
||||
// member leaves the ordinary party.
|
||||
//
|
||||
// The squad -> party projection above is deliberately one-way: a raid never
|
||||
// rewrites somebody's party. The reverse gesture is not symmetric, though. A
|
||||
// member who walks out of the party is no longer fighting in that squad, and
|
||||
// leaving the label behind keeps the 攻击队 window claiming a squad the player is
|
||||
// not in -- the client draws the row from the roster it was last sent, and no
|
||||
// later push corrects it. Live report 2026-09-17: "攻坚开始后 已经有队伍编制的人
|
||||
// 退出组队应该同时退出组队编制".
|
||||
//
|
||||
// 86JP says the same thing: RaidHandler.HandleNormalPartyLeftAsync assigns the
|
||||
// leaver to party index 0 and rebroadcasts the raid object, the member list and
|
||||
// the monster status, logging "RAID_PARTY_UNASSIGN" (ServerS4A21-master,
|
||||
// RaidHandler.cs:251-266). That helper is never called anywhere in that tree, so
|
||||
// it is a design statement rather than verified traffic; the gate used here is
|
||||
// this service's own (runtimeRaidSquadsFollowOrdinaryParty), which keeps the
|
||||
// ready countdown, the settlement and a single raid out of it.
|
||||
func (s *Service) unassignRuntimeRaidSquadOnPartyLeave(characterID uint16) bool {
|
||||
if s == nil || characterID == 0 {
|
||||
return false
|
||||
}
|
||||
// A member who is inside a raid gate is not walking out of anything: the party
|
||||
// object is being republished by the entry and loading-barrier machinery, not by
|
||||
// a player gesture. Their run is keyed by the seat it was opened under, so
|
||||
// stripping the label mid-run orphans that seat's occupancy and can drop the
|
||||
// clear they are about to report. 86JP refuses the same move while a member
|
||||
// holds a participation (RaidManager.cs:315-321). Live 2026-09-19, raid
|
||||
// 1507338: 14:24:07 and 14:24:16 unassigned two members who were fighting inside
|
||||
// 218 at that very second.
|
||||
if len(s.raidMembersInsideDungeonRuns([]uint16{characterID})) > 0 {
|
||||
s.logPacketEvent("game-raid-squad-unassign-skipped-inside-dungeon",
|
||||
"char_id", characterID,
|
||||
"reason", "a_member_inside_a_dungeon_run_is_not_leaving_the_party")
|
||||
return false
|
||||
}
|
||||
return s.clearRuntimeRaidSquadLabel(
|
||||
characterID,
|
||||
runtimeRaidSquadLabelBothRows,
|
||||
"game-raid-squad-unassigned-on-party-leave",
|
||||
"game-raid-squad-unassign",
|
||||
"party_leave",
|
||||
)
|
||||
}
|
||||
|
||||
// unassignRuntimeRaidSquadOnSoloTownReturn clears the squad label of a member who
|
||||
// walked home ALONE out of a dungeon run, because that gesture is already a party
|
||||
// leave (「只要是自己单独回城了 视作直接退出队伍」, see
|
||||
// party_solo_return_leave.go and party_forced_exit_leave.go) and the label is
|
||||
// derived from that party.
|
||||
//
|
||||
// Operator report 2026-09-26: "小队成员在城镇退队 或者被踢出 是回到单人的(没编制)
|
||||
// 但是在副本中直接返回城镇…却还在小队编制里", including the leader: "队长自己单独
|
||||
// 返回也要清 相当于队长自己退队了 按现有逻辑 队长顺延". Live raid 1507329, party 5 =
|
||||
// [1,2,20,21] all in squad 1: chars 20/21/2 each pressed 返回城镇 inside gate 219
|
||||
// (14:05:18/33/36Z) and every one of them kept GroupIndex 1 until char 20 happened
|
||||
// to be KICKED in town 1m53s later
|
||||
// (game-raid-squad-unassigned-on-party-leave char_id=20 previous_group=1).
|
||||
//
|
||||
// Two deliberate differences from the party-leave variant above:
|
||||
//
|
||||
// 1. No "inside a dungeon run" guard. This caller IS the town-return route, and
|
||||
// at that point in it the returning session still owns its gate runtime --
|
||||
// sendCurrentFullDungeonReturnToTown detaches the party at :1291-1353 and only
|
||||
// retires the runtime at :1693 -- so the guard would turn every one of these
|
||||
// into a no-op. What the guard protects against (a party snapshot republished
|
||||
// by the entry/barrier machinery, not a player gesture) cannot reach here: the
|
||||
// call site is inside the branch that has already decided this member leaves
|
||||
// the party, and a resumed return is classified exitClassSquadKept.
|
||||
// 2. Live row only. See runtimeRaidSquadLabelTargets: the frozen roster is what
|
||||
// credits the member for his squad's clears and what the phase reward is
|
||||
// built from, and the member is still in the raid and can still re-enter.
|
||||
func (s *Service) unassignRuntimeRaidSquadOnSoloTownReturn(characterID uint16, source string) bool {
|
||||
if s == nil || characterID == 0 {
|
||||
return false
|
||||
}
|
||||
return s.clearRuntimeRaidSquadLabel(
|
||||
characterID,
|
||||
runtimeRaidSquadLabelLiveRowOnly,
|
||||
"game-raid-squad-unassigned-on-solo-town-return",
|
||||
"game-raid-squad-town-return-unassign",
|
||||
source,
|
||||
)
|
||||
}
|
||||
|
||||
// currentDungeonTownReturnClearsRaidSquad answers the one question the town-return
|
||||
// route has to ask before it clears a member's squad label: is this the gesture the
|
||||
// operator's rule is about?
|
||||
//
|
||||
// His words, 2026-10-01, verbatim: 「在战况表里撤退 应该保持编制 如果是队内成员单独
|
||||
// 自己回城 应该取消这个人的编制 差不多是这么个逻辑」. Two gestures, two outcomes:
|
||||
//
|
||||
// - 战况表撤退 (leader-led whole-squad return, op72{1,2} ->
|
||||
// leadRuntimePartyHomeFromActiveRun -> sendCurrentActiveDungeonReturnToTownKeepingParty),
|
||||
// a raid objective closing its gates, a resumed return: all of those are
|
||||
// exitClassSquadKept (runtime.townReturnKeepParty / townReturnPartyDetached,
|
||||
// see currentDungeonTownExitClass) and never reach the clear at all.
|
||||
// - 单独自己回城, i.e. the member's own 返回城镇 inside the dungeon
|
||||
// (op42 handleDungeonGiveupGame / op132 handleDungeonBackToVillage), which is
|
||||
// already a party leave and therefore takes the label with it.
|
||||
//
|
||||
// The Fiend War returns the SERVER drives are neither of those, and they used to
|
||||
// fall into the "leaves the party" class and clear the label:
|
||||
//
|
||||
// - runtime.townReturnKeepsSquadLabel: the failure tail
|
||||
// (returnRuntimeFiendWarDungeons with ended=true, which must keep every squad
|
||||
// label by ruling 「攻坚队不应该被解散」) and a cleared area's auto-return;
|
||||
// - the death recovery (runtime.deathReturnWaiting): prepareRuntimeFiendWarRetreat
|
||||
// puts the member back in the waiting room and imprisons him for the PVF's
|
||||
// DIE IMPRISON TIME. He was killed, he did not walk home, and the battle model
|
||||
// still has him in his squad -- so the label stays and the next area he walks
|
||||
// into after the imprisonment still resolves a squad.
|
||||
//
|
||||
// Scope: the Fiend War clauses are gated on isCurrentFiendWarRaidChannel, and only
|
||||
// Fiend War sets townReturnKeepsSquadLabel, so Anton and Luke keep their existing
|
||||
// (unchanged) semantics byte for byte.
|
||||
func (s *Service) currentDungeonTownReturnClearsRaidSquad(
|
||||
session *gameSession,
|
||||
class currentDungeonTownExitClass,
|
||||
fiendWarSquadLabelKept bool,
|
||||
) bool {
|
||||
if class == exitClassSquadKept {
|
||||
return false
|
||||
}
|
||||
if !isCurrentFiendWarRaidChannel(session) {
|
||||
return true
|
||||
}
|
||||
return !fiendWarSquadLabelKept
|
||||
}
|
||||
|
||||
// unassignRuntimeRaidSquadOnSessionLeave clears the squad label of a member
|
||||
// whose CONNECTION went away -- closing the game, returning to the character
|
||||
// select screen, a dropped transport, or a replacement session taking the
|
||||
// character over (another client, a channel move).
|
||||
//
|
||||
// Operator rule (2026-09-27): 「无论是否主动退队 只要游戏关闭了或者小退到选择角色
|
||||
// 界面了 一律按退队处理」, and for the raid 「攻坚队被顶号 掉线 本来就要掉编制」. All
|
||||
// of those paths detach the ordinary party through detachRuntimePartySession, and
|
||||
// the squad label is derived from that party, so it has to follow. Without this
|
||||
// the roster kept claiming a squad for a member who is no longer in any party,
|
||||
// and a reconnect inside the five-minute grace window (raid_disconnect.go) came
|
||||
// back still wearing it.
|
||||
//
|
||||
// Two deliberate differences from unassignRuntimeRaidSquadOnPartyLeave:
|
||||
//
|
||||
// 1. No "inside a dungeon run" guard. That guard protects a player gesture that
|
||||
// is not a leave -- a party snapshot republished by the entry/barrier
|
||||
// machinery -- but this caller IS the disconnect. The departing session's
|
||||
// gate run is released a few statements later in the same teardown
|
||||
// (noteRuntimeRaidSessionDisconnected -> releaseRuntimeRaidRunsOwnedByLocked,
|
||||
// "Releasing the run here is what keeps a client crash from squatting on a
|
||||
// gate forever"), so at this point the guard would turn every raid
|
||||
// disconnect into a no-op -- which is precisely the defect being fixed.
|
||||
// 2. Live row only, like the solo town return above: the member is still in the
|
||||
// raid and holds a reconnect reservation, so the frozen phase roster keeps
|
||||
// crediting him. The expiry of that reservation removes him from the raid
|
||||
// outright, frozen row included.
|
||||
func (s *Service) unassignRuntimeRaidSquadOnSessionLeave(characterID uint16, source string) bool {
|
||||
if s == nil || characterID == 0 {
|
||||
return false
|
||||
}
|
||||
return s.clearRuntimeRaidSquadLabel(
|
||||
characterID,
|
||||
runtimeRaidSquadLabelLiveRowOnly,
|
||||
"game-raid-squad-unassigned-on-session-leave",
|
||||
"game-raid-squad-session-leave-unassign",
|
||||
source,
|
||||
)
|
||||
}
|
||||
|
||||
// clearRuntimeRaidSquadLabel is the shared body of the clears above.
|
||||
func (s *Service) clearRuntimeRaidSquadLabel(
|
||||
characterID uint16,
|
||||
targets runtimeRaidSquadLabelTargets,
|
||||
event string,
|
||||
failureEvent string,
|
||||
source string,
|
||||
) bool {
|
||||
if s == nil || characterID == 0 {
|
||||
return false
|
||||
}
|
||||
s.raidMu.Lock()
|
||||
state := s.runtimeRaidByMemberLocked(characterID)
|
||||
if state == nil || !runtimeRaidSquadsFollowOrdinaryParty(*state) {
|
||||
s.raidMu.Unlock()
|
||||
return false
|
||||
}
|
||||
index := runtimeRaidMemberIndex(*state, characterID)
|
||||
if index < 0 || state.Members[index].GroupIndex == 0 {
|
||||
// Already standby: nothing to correct, and no packet is worth sending.
|
||||
s.raidMu.Unlock()
|
||||
return false
|
||||
}
|
||||
previousGroup := state.Members[index].GroupIndex
|
||||
previousSlot := state.Members[index].SlotOrder
|
||||
state.Members[index].GroupIndex = 0
|
||||
state.Members[index].SlotOrder = 0
|
||||
// The member was its squad's leader: the squad is now led by whoever the squad's
|
||||
// ordinary party hands it to (or by its lowest remaining slot), so drop the stale
|
||||
// name here instead of leaving the roster claiming a member who is not in it --
|
||||
// runtimeRaidMemberLeadsSquad ignores such a row, and the next party publish (or
|
||||
// the lowest-slot fallback) answers for the squad from then on.
|
||||
if squad := runtimeRaidMemberSquad(previousGroup); squad != 0 && state.SquadPartyLeaders[squad] == characterID {
|
||||
delete(state.SquadPartyLeaders, squad)
|
||||
}
|
||||
if targets == runtimeRaidSquadLabelBothRows && state.Started {
|
||||
for i := range state.FrozenMembers {
|
||||
if state.FrozenMembers[i].CharID == characterID {
|
||||
state.FrozenMembers[i].GroupIndex = 0
|
||||
state.FrozenMembers[i].SlotOrder = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
runtimeRaidSortMembers(state.Members)
|
||||
if targets == runtimeRaidSquadLabelBothRows && state.Started {
|
||||
runtimeRaidSortMembers(state.FrozenMembers)
|
||||
}
|
||||
snapshot := cloneRuntimeRaidState(*state)
|
||||
s.raidMu.Unlock()
|
||||
|
||||
// The mode0 object is what the current client materializes the attack-party
|
||||
// panel from and mode3 is the roster row (see the assignGroup branch in
|
||||
// handleOnlineRaidManagerWork); both have to follow the label. The buff
|
||||
// registration follows the same publish so the party buff rows agree.
|
||||
if err := s.broadcastRuntimeRaidCreate(snapshot); err != nil {
|
||||
s.logPacketEvent(failureEvent+"-create-failed", "raid_key", snapshot.RaidKey, "char_id", characterID, "error", err)
|
||||
}
|
||||
if err := s.broadcastRuntimeRaidRefresh(snapshot); err != nil {
|
||||
s.logPacketEvent(failureEvent+"-refresh-failed", "raid_key", snapshot.RaidKey, "char_id", characterID, "error", err)
|
||||
}
|
||||
if err := s.broadcastRuntimeRaidBuffRegistration(snapshot, 0); err != nil {
|
||||
s.logPacketEvent(failureEvent+"-buff-failed", "raid_key", snapshot.RaidKey, "char_id", characterID, "error", err)
|
||||
}
|
||||
s.logPacketEvent(event,
|
||||
"raid_key", snapshot.RaidKey,
|
||||
"char_id", characterID,
|
||||
"previous_group", previousGroup,
|
||||
"previous_slot", previousSlot,
|
||||
"group", 0,
|
||||
"slot", 0,
|
||||
"frozen_row", func() string {
|
||||
if targets == runtimeRaidSquadLabelBothRows {
|
||||
return "cleared_with_live_row"
|
||||
}
|
||||
return "kept_for_phase_credit"
|
||||
}(),
|
||||
"source", source)
|
||||
return true
|
||||
}
|
||||
|
||||
// runtimeRaidSquadsFollowOrdinaryParty reports whether an ordinary party formed by
|
||||
// raid members may drag the raid roster into one squad.
|
||||
//
|
||||
// runtimeRaidGroupsEditable answers a different question: whether the raid
|
||||
// window's own squad controls work. Those freeze once the raid starts, but an
|
||||
// ordinary party is a separate gesture -- after start it is the ONLY way to pull a
|
||||
// member into your squad, because the raid window no longer offers the move. Live
|
||||
// report 2026-09-16: the leader invited a member into the party while the raid was
|
||||
// running, the party formed, and the invited member stayed a solo row in the raid
|
||||
// roster.
|
||||
//
|
||||
// Excluded on purpose: a single raid never owns a squad, the three-second
|
||||
// preparation window has a frozen roster, and a settlement in progress has already
|
||||
// frozen the reward roster.
|
||||
func runtimeRaidSquadsFollowOrdinaryParty(state runtimeRaidState) bool {
|
||||
// 超时空:进副本时组的是"这个区域里的人"(临时普通队伍),而**编制不变**是硬约束
|
||||
// (操作员 2026-10-01:「相当于组个临时队伍 但是攻坚小队编制不变」)。
|
||||
//
|
||||
// 所以超时空的普通队伍快照**不许**反过来改攻坚编制:跨编制的区域队伍一发布,
|
||||
// 这条同步就会把 1 队 + 2 队并成 1 队 —— 实测(raid_fiendwar_area_entry_test.go)
|
||||
// 进图后 live 编制从 1/2 变成 1/1,玩家在攻坚队窗口里能直接看见编制被并了。
|
||||
//
|
||||
// 安东/卢克逐字不变:那边"开团之后唯一能并队的途径"就是这条同步。
|
||||
if state.Lifecycle.FiendWar != nil {
|
||||
return false
|
||||
}
|
||||
if state.StartPending || state.TailFlag == 1 {
|
||||
return false
|
||||
}
|
||||
if !state.Started {
|
||||
return true
|
||||
}
|
||||
switch state.Lifecycle.Status {
|
||||
case runtimeRaidLifecyclePhaseActive, runtimeRaidLifecycleStandby:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// runtimeRaidSquadForOrdinaryParty picks the squad that will hold the whole
|
||||
// ordinary party.
|
||||
//
|
||||
// The operator's rule (2026-09-20), verbatim: "单人和单人的组队 应该找一个最小的
|
||||
// 数字组成新的小队 比如1 2队都被占用 就自动组成3队 … 1 2 3小队都被占用 2队解散了
|
||||
// 原2队成员全部变单人 此时 3队不要动 还是3队 … 再重新组队还是从2队开始".
|
||||
// So a squad belongs to exactly ONE ordinary party: a new party takes the SMALLEST
|
||||
// squad number that no outsider occupies, a party that already holds a squad keeps
|
||||
// it, and a squad whose members all became solo frees its number for the next party.
|
||||
//
|
||||
// 86JP decides the same way: ResolveNormalPartySquadIndex (RaidHandler.cs:1206-1237)
|
||||
// collects the PartyIndex of every raid member OUTSIDE the party, prefers an index one
|
||||
// of the party's own members already sits in when no outsider is there, and otherwise
|
||||
// returns the lowest index that no outsider occupies.
|
||||
//
|
||||
// Backfilling a squad that still has a free slot is what produced the operator's
|
||||
// "多队伍成员错误显示为同属一支队伍" / "开始攻坚后 单人和单人组队会跳到1队". Live
|
||||
// 2026-09-20T10:56:55Z, raid 1507338:
|
||||
//
|
||||
// game-raid-squad-synced-from-ordinary-party raid_key=1507338 squad=1 members=[10 12]
|
||||
//
|
||||
// while 13 and 9 -- two members of a DIFFERENT ordinary party -- were already sitting
|
||||
// in squad 1 from the 10:56:51 sync of [13 9]. The roster really did say GroupIndex=1
|
||||
// for both parties at once.
|
||||
//
|
||||
// An unassigned member (GroupIndex 0) occupies nothing here: runtimeRaidMemberSquad is
|
||||
// the accessor for what a member actually is (see its own note), while
|
||||
// runtimeRaidNormalizedGroup folds 0 onto squad 1 for keys and presentation only --
|
||||
// which is the second reason every fresh pairing used to land in squad 1.
|
||||
func runtimeRaidSquadForOrdinaryParty(state runtimeRaidState, memberIDs []uint16) (byte, bool) {
|
||||
return runtimeRaidSquadForOrdinaryPartyLedBy(state, memberIDs, 0)
|
||||
}
|
||||
|
||||
// runtimeRaidSquadForOrdinaryPartyLedBy adds the one case the plain rule got wrong:
|
||||
// a party that already holds a squad KEEPS that squad, its leader's first.
|
||||
//
|
||||
// Operator's report, 2026-09-20: "从1队踢了个人 然后由2队队长邀请进队 进了2队 然后2队踢出
|
||||
// 这个人 2队变3队了 这种情况应保持2队不变". Live raid 1507329:
|
||||
//
|
||||
// 13:12:02 game-raid-squad-synced-from-ordinary-party squad=2 members=[21 22 20]
|
||||
// 13:12:09 game-raid-squad-synced-from-ordinary-party squad=3 members=[21 22] ← 2 became 3
|
||||
// 13:12:09 game-raid-squad-unassigned-on-party-leave char_id=20 previous_group=2 ← the kick
|
||||
//
|
||||
// The kicked member's label is cleared by unassignRuntimeRaidSquadOnPartyLeave, but the
|
||||
// party snapshot is published first, so at sync time squad 2 still named him as an
|
||||
// "outsider" and the whole party was relocated to the lowest squad nobody occupied.
|
||||
// Outsiders labelled in the party's OWN squad must not veto it: they are leavers whose
|
||||
// label is cleared in the same gesture, and moving the party is exactly the complaint.
|
||||
// A squad the party does not hold is still never backfilled, so the "多队伍成员错误显示
|
||||
// 为同属一支队伍" fix stands: two solos pairing up still take the lowest free number.
|
||||
func runtimeRaidSquadForOrdinaryPartyLedBy(
|
||||
state runtimeRaidState,
|
||||
memberIDs []uint16,
|
||||
leaderID uint16,
|
||||
) (byte, bool) {
|
||||
// 一小队能站几个人由团决定:超时空是 1(每队后面三格是锁住的),别的团还是 4。
|
||||
// 于是超时空里两个人在镇上组队,会被分到**两个不同的编制**,而不是挤进同一队。
|
||||
size := runtimeRaidGroupSizeFor(state)
|
||||
maxGroups := runtimeRaidGroupMaxFor(state)
|
||||
if len(memberIDs) == 0 || len(memberIDs) > size {
|
||||
return 0, false
|
||||
}
|
||||
partyMember := make(map[uint16]bool, len(memberIDs))
|
||||
for _, id := range memberIDs {
|
||||
partyMember[id] = true
|
||||
}
|
||||
occupiedOutside := make(map[byte]bool, maxGroups)
|
||||
outsiders := make(map[byte]int, maxGroups)
|
||||
heldByParty := make(map[byte]bool, maxGroups)
|
||||
leaderSquad := byte(0)
|
||||
for _, member := range state.Members {
|
||||
squad := runtimeRaidMemberSquad(member.GroupIndex)
|
||||
if squad == 0 {
|
||||
continue
|
||||
}
|
||||
if partyMember[member.CharID] {
|
||||
heldByParty[squad] = true
|
||||
if leaderID != 0 && member.CharID == leaderID {
|
||||
leaderSquad = squad
|
||||
}
|
||||
continue
|
||||
}
|
||||
occupiedOutside[squad] = true
|
||||
outsiders[squad]++
|
||||
}
|
||||
// The party keeps its own squad: the leader's number first ("队长则保持队伍号不变"),
|
||||
// then the lowest squad any of its members already holds. It must still FIT there
|
||||
// (outsiders + the whole party within the squad size); a party that cannot fit moves
|
||||
// out rather than overflowing a squad.
|
||||
fits := func(group byte) bool {
|
||||
return heldByParty[group] && outsiders[group]+len(memberIDs) <= size
|
||||
}
|
||||
if leaderSquad != 0 && fits(leaderSquad) {
|
||||
return leaderSquad, true
|
||||
}
|
||||
for group := byte(1); group <= maxGroups; group++ {
|
||||
if fits(group) {
|
||||
return group, true
|
||||
}
|
||||
}
|
||||
// A fresh party takes the smallest squad number no outsider occupies -- "1 2 队都被
|
||||
// 占用 就自动组成3队", and a number freed by a disbanded squad is reused first.
|
||||
for group := byte(1); group <= maxGroups; group++ {
|
||||
if !occupiedOutside[group] {
|
||||
return group, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
Reference in new issue
Block a user