服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
229 lines
9.4 KiB
Go
229 lines
9.4 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
dnfdungeonadmission "longheng.io/server/internal/modules/dnf/dungeonadmission"
|
|
)
|
|
|
|
// raid_luke_weekly_observation.go —— 「周卢克 %d/%d」的观测层。
|
|
//
|
|
// etc/raid/luke/luke.etc declares [WEEKLY ENTER COUNT] `20 2`, and the current
|
|
// client draws a weekly counter of its own (dstr 75388, "周卢克 %d/%d"). Nothing
|
|
// in this tree consumed that pair until now: `周卢克 %d/%d` never moved and never
|
|
// refused, so a Luke raid could be run without limit.
|
|
//
|
|
// This file deliberately does NOT start refusing. The document declares the two
|
|
// operands but NOT the reset weekday, and the node the live service rolls on is
|
|
// still unconfirmed -- three candidate nodes are attested (a Sunday node for the
|
|
// ticket-box count, a Thursday 06:00 node for the weekly reward cap, and the
|
|
// 2017-06 "opens Tuesday and Saturday" schedule). Enforcing a wrongly guessed
|
|
// node locks players out of a raid they are entitled to enter, which is far worse
|
|
// than the current unbounded state. So the server observes first.
|
|
//
|
|
// What it produces:
|
|
//
|
|
// - one structured line per successful Luke gate entry carrying the character,
|
|
// the gate, the entries counted so far, and the exact window the entry was
|
|
// charged to -- i.e. the node the server currently believes in, printed as
|
|
// `window_start`/`window_end`/`reset_weekday` so the real node can be read
|
|
// straight off production logs;
|
|
// - a distinct rollover line the first time a character's computed window
|
|
// moves, carrying prev/next, which is the event that actually settles the node.
|
|
//
|
|
// The counter is in-memory on purpose. It is an observation aid, not durable
|
|
// state: persisting it before the node is confirmed would freeze a wrong window
|
|
// into the database. The authoritative owner is the catalog Policy
|
|
// (dungeonadmission.LukeWeeklyCharacterPolicyKey), which already derives the
|
|
// window from service time; when the node is confirmed, the durable counter and
|
|
// its commit point go there.
|
|
//
|
|
// NOT WIRED YET (deliberate): the durable counter is never committed. Committing
|
|
// it needs a decision this file cannot make on its own -- whether a "weekly
|
|
// count" is charged on raid CLEAR (the official wording: "通关2次后,该角色无法
|
|
// 进入") or on gate ENTRY. The clear-side hook lives in the phase machine, which
|
|
// this change is explicitly scoped out of.
|
|
|
|
const (
|
|
// runtimeRaidLukeWeeklyPolicyKey is the catalog policy that owns Luke's
|
|
// per-character weekly count.
|
|
runtimeRaidLukeWeeklyPolicyKey = dnfdungeonadmission.LukeWeeklyCharacterPolicyKey
|
|
)
|
|
|
|
// runtimeRaidLukeWeeklyObservationEntry is one character's observed position in
|
|
// one weekly window.
|
|
type runtimeRaidLukeWeeklyObservationEntry struct {
|
|
WindowStart time.Time
|
|
Entries uint32
|
|
}
|
|
|
|
type runtimeRaidLukeWeeklyObservationRegistry struct {
|
|
mu sync.Mutex
|
|
entries map[uint16]runtimeRaidLukeWeeklyObservationEntry
|
|
}
|
|
|
|
var runtimeRaidLukeWeeklyObservations = runtimeRaidLukeWeeklyObservationRegistry{}
|
|
|
|
// record charges one entry and reports what stood before it. rolledOver is true
|
|
// when the character already had a record in a DIFFERENT window, which is the
|
|
// signal that the server's node has just moved; the count then restarts.
|
|
func (r *runtimeRaidLukeWeeklyObservationRegistry) record(
|
|
characterID uint16,
|
|
windowStart time.Time,
|
|
) (previous runtimeRaidLukeWeeklyObservationEntry, hadPrevious bool, rolledOver bool, entries uint32) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.entries == nil {
|
|
r.entries = make(map[uint16]runtimeRaidLukeWeeklyObservationEntry)
|
|
}
|
|
previous, hadPrevious = r.entries[characterID]
|
|
rolledOver = hadPrevious && !previous.WindowStart.Equal(windowStart)
|
|
base := uint32(0)
|
|
if hadPrevious && !rolledOver {
|
|
base = previous.Entries
|
|
}
|
|
entries = base + 1
|
|
r.entries[characterID] = runtimeRaidLukeWeeklyObservationEntry{WindowStart: windowStart, Entries: entries}
|
|
return previous, hadPrevious, rolledOver, entries
|
|
}
|
|
|
|
// snapshotForTest reports one character's observed entry without charging it.
|
|
func (r *runtimeRaidLukeWeeklyObservationRegistry) snapshotForTest(characterID uint16) (runtimeRaidLukeWeeklyObservationEntry, bool) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
entry, ok := r.entries[characterID]
|
|
return entry, ok
|
|
}
|
|
|
|
// resetRuntimeRaidLukeWeeklyObservationsForTest drops the in-memory table so one
|
|
// test cannot observe another's counts.
|
|
func resetRuntimeRaidLukeWeeklyObservationsForTest() {
|
|
runtimeRaidLukeWeeklyObservations.mu.Lock()
|
|
runtimeRaidLukeWeeklyObservations.entries = nil
|
|
runtimeRaidLukeWeeklyObservations.mu.Unlock()
|
|
}
|
|
|
|
// runtimeRaidLukeWeeklyLimitEnforced reports whether the operator has switched
|
|
// the weekly cap from observation to enforcement.
|
|
//
|
|
// OFF by default, and it stays off until the reset node has been read off real
|
|
// traffic: turning it on before that refuses entries the official service would
|
|
// have allowed. See the file header.
|
|
func (s *Service) runtimeRaidLukeWeeklyLimitEnforced() bool {
|
|
return s != nil && s.options.raidLukeWeeklyLimitEnforced
|
|
}
|
|
|
|
// runtimeRaidLukeWeeklyWindow reports the weekly window the catalog charges Luke
|
|
// entries to, together with the per-character limit the same document declares.
|
|
// ok is false when the catalog is unavailable or does not carry the policy.
|
|
func (s *Service) runtimeRaidLukeWeeklyWindow(now time.Time) (window dnfdungeonadmission.Window, limit uint32, ok bool) {
|
|
policy, found, err := s.currentDungeonAdmissionPolicy(runtimeRaidLukeWeeklyPolicyKey)
|
|
if err != nil || !found || policy.Limit == 0 {
|
|
return dnfdungeonadmission.Window{}, 0, false
|
|
}
|
|
resolved, err := dnfdungeonadmission.ServiceWindow(policy, now)
|
|
if err != nil {
|
|
return dnfdungeonadmission.Window{}, 0, false
|
|
}
|
|
return resolved, policy.Limit, true
|
|
}
|
|
|
|
// observeRuntimeRaidLukeWeeklyEntry logs one successful entry into a team-Luke
|
|
// gate against the weekly window the server currently believes in.
|
|
//
|
|
// It is called after the dungeon has finished loading, so it runs with no lock
|
|
// held by its caller and cannot perturb entry. Every failure path is silent: an
|
|
// observation line must never be able to fail an entry.
|
|
func (s *Service) observeRuntimeRaidLukeWeeklyEntry(session *gameSession, source string) {
|
|
if s == nil || session == nil || session.selectedCharacterID == 0 {
|
|
return
|
|
}
|
|
characterID := session.selectedCharacterID
|
|
session.dungeon.mu.Lock()
|
|
mapID := 0
|
|
if session.dungeon.runtime != nil {
|
|
mapID = int(session.dungeon.runtime.Dungeon.ID)
|
|
}
|
|
session.dungeon.mu.Unlock()
|
|
if mapID == 0 {
|
|
return
|
|
}
|
|
// Team-Luke gates only: the weekly count is a raid-entry limit, and a
|
|
// single-mode run is a different catalog (and a different policy owner).
|
|
if _, isLukeGate := runtimeRaidLukeTeamDungeonPhases[mapID]; !isLukeGate {
|
|
return
|
|
}
|
|
now := s.gameplayNow()
|
|
window, limit, ok := s.runtimeRaidLukeWeeklyWindow(now)
|
|
if !ok {
|
|
return
|
|
}
|
|
previous, hadPrevious, rolledOver, entries := runtimeRaidLukeWeeklyObservations.record(characterID, window.Start)
|
|
if rolledOver {
|
|
s.logGameEvent(session, "game-raid-luke-weekly-rolled-over",
|
|
"source", source,
|
|
"char_id", characterID,
|
|
"raid_map_id", mapID,
|
|
"prev_window_start", previous.WindowStart.UTC().Format(time.RFC3339),
|
|
"next_window_start", window.Start.UTC().Format(time.RFC3339),
|
|
"prev_entries", previous.Entries,
|
|
"next_entries", entries,
|
|
"week_limit", limit,
|
|
"enforced", s.runtimeRaidLukeWeeklyLimitEnforced(),
|
|
"note", "the server's weekly window just moved for this character; this line is what settles the reset node")
|
|
} else if !hadPrevious {
|
|
s.logGameEvent(session, "game-raid-luke-weekly-window-first-seen",
|
|
"source", source,
|
|
"char_id", characterID,
|
|
"window_start", window.Start.UTC().Format(time.RFC3339),
|
|
"window_end", window.End.UTC().Format(time.RFC3339),
|
|
"reset_weekday", window.Start.Weekday().String(),
|
|
"week_limit", limit,
|
|
"enforced", s.runtimeRaidLukeWeeklyLimitEnforced())
|
|
}
|
|
s.logGameEvent(session, "game-raid-luke-weekly-entry",
|
|
"source", source,
|
|
"char_id", characterID,
|
|
"raid_map_id", mapID,
|
|
"week_entries", entries,
|
|
"week_limit", limit,
|
|
"week_entries_over_limit", entries > limit,
|
|
"window_start", window.Start.UTC().Format(time.RFC3339),
|
|
"window_end", window.End.UTC().Format(time.RFC3339),
|
|
"reset_weekday", window.Start.Weekday().String(),
|
|
"enforced", s.runtimeRaidLukeWeeklyLimitEnforced(),
|
|
"durable_counter_committed", false)
|
|
}
|
|
|
|
// runtimeRaidLukeWeeklyRefusal reports whether the catalog's own durable weekly
|
|
// counter already holds this character at the cap. It is consulted ONLY when
|
|
// enforcement is explicitly enabled; the default path never calls it, so no
|
|
// extra SQL runs per entry.
|
|
func (s *Service) runtimeRaidLukeWeeklyRefusal(characterID uint16) (used, limit uint32, refuse bool) {
|
|
if s == nil || characterID == 0 || !s.runtimeRaidLukeWeeklyLimitEnforced() {
|
|
return 0, 0, false
|
|
}
|
|
policy, found, err := s.currentDungeonAdmissionPolicy(runtimeRaidLukeWeeklyPolicyKey)
|
|
if err != nil || !found || policy.Limit == 0 {
|
|
return 0, 0, false
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok {
|
|
return 0, policy.Limit, false
|
|
}
|
|
owner, err := dnfdungeonadmission.NewOwner(repositories)
|
|
if err != nil {
|
|
return 0, policy.Limit, false
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
snapshot, ok := currentRaidDailyAvailabilitySnapshot(ctx, owner, fmt.Sprint(characterID), policy, s.gameplayNow())
|
|
if !ok {
|
|
return 0, policy.Limit, false
|
|
}
|
|
return snapshot.Used, snapshot.EffectiveLimit, snapshot.Used >= snapshot.EffectiveLimit
|
|
}
|