DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,245 @@
package sessioncontract
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
)
var ErrAuthStateEventInvalid = errors.New("auth state event is invalid")
type AuthState string
const (
AuthStateBound AuthState = "bound"
AuthStateOnline AuthState = "online"
AuthStateOffline AuthState = "offline"
AuthStateKicked AuthState = "kicked"
AuthStateBanned AuthState = "banned"
AuthStateRestricted AuthState = "restricted"
AuthStateDraining AuthState = "draining"
)
// AuthStateEvent 是龙恒向外部账号/认证控制面回写在线状态的通用事件。
// 它只携带通用身份和状态,不绑定任何工作室账号库、渠道 SDK 或风控供应商。
type AuthStateEvent struct {
AccountID string `json:"account_id"`
SessionID string `json:"session_id,omitempty"`
GatewayNodeID string `json:"gateway_node_id,omitempty"`
LogicNodeID string `json:"logic_node_id,omitempty"`
State AuthState `json:"state"`
Reason string `json:"reason,omitempty"`
At time.Time `json:"at"`
TraceID string `json:"trace_id,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
type AuthStateNotifier interface {
NotifyAuthState(context.Context, AuthStateEvent) error
}
func NotifyAuthState(ctx context.Context, notifier AuthStateNotifier, event AuthStateEvent) error {
if notifier == nil {
return nil
}
return notifier.NotifyAuthState(ctx, event)
}
type MemoryAuthStateNotifier struct {
mu sync.Mutex
maxEvents int
events []AuthStateEvent
}
func NewMemoryAuthStateNotifier(maxEvents int) *MemoryAuthStateNotifier {
return &MemoryAuthStateNotifier{maxEvents: maxEvents}
}
func (n *MemoryAuthStateNotifier) NotifyAuthState(ctx context.Context, event AuthStateEvent) error {
if n == nil {
return nil
}
if err := contextErr(ctx); err != nil {
return err
}
event, err := normAuthEvent(event, time.Now().UTC())
if err != nil {
return err
}
n.mu.Lock()
n.events = append(n.events, event)
if n.maxEvents > 0 && len(n.events) > n.maxEvents {
n.events = append([]AuthStateEvent(nil), n.events[len(n.events)-n.maxEvents:]...)
}
n.mu.Unlock()
return nil
}
func (n *MemoryAuthStateNotifier) Events() []AuthStateEvent {
if n == nil {
return nil
}
n.mu.Lock()
defer n.mu.Unlock()
out := make([]AuthStateEvent, len(n.events))
for idx, event := range n.events {
out[idx] = cloneAuthStateEvent(event)
}
return out
}
type AuthNotifierOptions struct {
Endpoint string
Token string
Headers map[string]string
Client *http.Client
Timeout time.Duration
Now func() time.Time
}
type HTTPAuthStateNotifier struct {
endpoint string
token string
headers map[string]string
client *http.Client
timeout time.Duration
now func() time.Time
}
func NewHTTPAuthStateNotifier(options AuthNotifierOptions) (*HTTPAuthStateNotifier, error) {
endpoint := strings.TrimSpace(options.Endpoint)
if endpoint == "" {
return nil, fmt.Errorf("%w: endpoint is required", ErrAuthStateEventInvalid)
}
client := options.Client
if client == nil {
client = http.DefaultClient
}
timeout := options.Timeout
if timeout <= 0 {
timeout = 5 * time.Second
}
now := options.Now
if now == nil {
now = time.Now
}
return &HTTPAuthStateNotifier{
endpoint: endpoint,
token: strings.TrimSpace(options.Token),
headers: cloneStringMap(options.Headers),
client: client,
timeout: timeout,
now: now,
}, nil
}
func (n *HTTPAuthStateNotifier) NotifyAuthState(ctx context.Context, event AuthStateEvent) error {
if n == nil {
return nil
}
if err := contextErr(ctx); err != nil {
return err
}
event, err := normAuthEvent(event, n.now().UTC())
if err != nil {
return err
}
body, err := json.Marshal(event)
if err != nil {
return fmt.Errorf("marshal auth state event: %w", err)
}
ctx, cancel := context.WithTimeout(contextOrBackground(ctx), n.timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, n.endpoint, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create auth state request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if n.token != "" {
req.Header.Set("Authorization", "Bearer "+n.token)
}
for key, value := range n.headers {
key = strings.TrimSpace(key)
if key == "" {
continue
}
req.Header.Set(key, value)
}
resp, err := n.client.Do(req)
if err != nil {
return fmt.Errorf("send auth state request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("auth state request failed: status=%d", resp.StatusCode)
}
return nil
}
func normAuthEvent(event AuthStateEvent, now time.Time) (AuthStateEvent, error) {
event.AccountID = strings.TrimSpace(event.AccountID)
event.SessionID = strings.TrimSpace(event.SessionID)
event.GatewayNodeID = strings.TrimSpace(event.GatewayNodeID)
event.LogicNodeID = strings.TrimSpace(event.LogicNodeID)
event.Reason = strings.TrimSpace(event.Reason)
event.TraceID = strings.TrimSpace(event.TraceID)
event.State = AuthState(strings.TrimSpace(string(event.State)))
if event.AccountID == "" || event.State == "" {
return AuthStateEvent{}, fmt.Errorf("%w: account_id and state are required", ErrAuthStateEventInvalid)
}
if !validAuthState(event.State) {
return AuthStateEvent{}, fmt.Errorf("%w: unsupported state %q", ErrAuthStateEventInvalid, event.State)
}
if event.At.IsZero() {
event.At = now
} else {
event.At = event.At.UTC()
}
event.Metadata = cloneStringMap(event.Metadata)
return event, nil
}
func validAuthState(state AuthState) bool {
switch state {
case AuthStateBound, AuthStateOnline, AuthStateOffline, AuthStateKicked, AuthStateBanned, AuthStateRestricted, AuthStateDraining:
return true
default:
return false
}
}
func cloneAuthStateEvent(event AuthStateEvent) AuthStateEvent {
event.Metadata = cloneStringMap(event.Metadata)
return event
}
func cloneStringMap(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, len(in))
for key, value := range in {
out[key] = value
}
return out
}
func contextOrBackground(ctx context.Context) context.Context {
if ctx == nil {
return context.Background()
}
return ctx
}
func contextErr(ctx context.Context) error {
if ctx == nil {
return nil
}
return ctx.Err()
}
@@ -0,0 +1,395 @@
package sessioncontract
import (
"fmt"
"sort"
"strings"
"longheng.io/server/internal/platform/dispatch"
)
type Contract struct {
SchemaVersion int `json:"schema_version"`
Frontend FrontendSession `json:"frontend"`
Backend BackendSession `json:"backend"`
Transitions []Transition `json:"transitions"`
RoutePolicies []RoutePolicy `json:"route_policies"`
Invariants []Invariant `json:"invariants"`
Forbidden []ForbiddenCoupling `json:"forbidden"`
}
type FrontendSession struct {
Owner string `json:"owner"`
Identity []string `json:"identity"`
State []string `json:"state"`
Operations []string `json:"operations"`
Publishes []string `json:"publishes"`
MayRead []string `json:"may_read"`
MustNotOwn []string `json:"must_not_own"`
Storage string `json:"storage"`
ClosePolicy string `json:"close_policy"`
}
type BackendSession struct {
Owner string `json:"owner"`
Projection []string `json:"projection"`
State []string `json:"state"`
Operations []string `json:"operations"`
Consumes []string `json:"consumes"`
Publishes []string `json:"publishes"`
MayRead []string `json:"may_read"`
MustNotOwn []string `json:"must_not_own"`
Storage string `json:"storage"`
Idempotency string `json:"idempotency"`
ReplayContract string `json:"replay_contract"`
}
type Transition struct {
Name string `json:"name"`
From string `json:"from"`
To string `json:"to"`
Actor string `json:"actor"`
RequiredData []string `json:"required_data,omitempty"`
Events []string `json:"events,omitempty"`
Guarantees []string `json:"guarantees,omitempty"`
}
type RoutePolicy struct {
Name string `json:"name"`
Internal bool `json:"internal,omitempty"`
Stateful bool `json:"stateful,omitempty"`
Authorized bool `json:"authorized,omitempty"`
Idempotent bool `json:"idempotent,omitempty"`
FrontendCallable bool `json:"frontend_callable,omitempty"`
BackendOnly bool `json:"backend_only,omitempty"`
}
type Invariant struct {
Name string `json:"name"`
Description string `json:"description"`
}
type ForbiddenCoupling struct {
Source string `json:"source"`
Target string `json:"target"`
Reason string `json:"reason"`
}
func Default() Contract {
contract := Contract{
SchemaVersion: 1,
Frontend: FrontendSession{
Owner: "gateway",
Identity: []string{
"session_id",
"account_id",
"gateway_node_id",
"transport",
"remote_addr",
},
State: []string{
"accepting",
"handshaking",
"bound",
"draining",
"closed",
},
Operations: []string{
"accept_transport",
"login_handshake",
"bind_account_session",
"touch_last_seen",
"write_client_packet",
"close_socket",
},
Publishes: []string{
"session_connected",
"session_bound",
"session_disconnected",
"gateway_push_result",
},
MayRead: []string{
"route_manifest",
"presence_locator",
"backend_registry",
},
MustNotOwn: []string{
"profile_state",
"player_actor_state",
"business_idempotency_commit",
},
Storage: "gateway memory session store + optional Redis presence locator",
ClosePolicy: "same account new bind closes stale frontend session and publishes disconnect",
},
Backend: BackendSession{
Owner: "logic/playerd/scene/social",
Projection: []string{
"account_id",
"frontend_session_id",
"gateway_node_id",
"player_actor_pid",
"profile_revision",
},
State: []string{
"projected",
string(PlayerStateLoading),
string(PlayerStateOnline),
string(PlayerStateValid),
string(PlayerStateReconnecting),
string(PlayerStateClosing),
string(PlayerStateKicked),
string(PlayerStateInvalid),
"stateful_route_running",
"dirty",
"flushing",
"detached",
},
Operations: []string{
"authorize_route",
"load_profile",
"mutate_game_state",
"commit_idempotency",
"cache_response",
"emit_gateway_push",
},
Consumes: []string{
"gateway_client_packet",
"session_connected",
"session_disconnected",
"presence_update",
},
Publishes: []string{
"logic_player_response",
"profile_dirty_event",
"gateway_push_request",
"auth_state_notify",
},
MayRead: []string{
"frontend_presence_projection",
"profile_store",
"idempotency_store",
"route_manifest",
},
MustNotOwn: []string{
"client_socket",
"transport_acceptor",
"frontend_session_close_without_gateway",
},
Storage: "profile store + idempotency store + actor mailbox; never frontend socket storage",
Idempotency: "stateful authorized routes must reserve, execute, commit, and replay cached response",
ReplayContract: "duplicate stateful request replays cached response without rerunning handler",
},
Transitions: []Transition{
{
Name: "login_bind",
From: "frontend.handshaking",
To: "frontend.bound",
Actor: "gateway",
RequiredData: []string{"session_id", "account_id", "gateway_node_id"},
Events: []string{"session_bound"},
Guarantees: []string{"one_active_frontend_session_per_account"},
},
{
Name: "packet_forward",
From: "frontend.bound",
To: "backend.stateful_route_running",
Actor: "gateway -> logic",
RequiredData: []string{"session_id", "account_id", "msg_id", "request_id"},
Events: []string{"gateway_client_packet"},
Guarantees: []string{"route_policy_checked_before_handler"},
},
{
Name: "backend_response",
From: "backend.stateful_route_running",
To: "frontend.bound",
Actor: "logic -> gateway",
RequiredData: []string{"session_id", "msg_id", "response_body"},
Events: []string{"logic_player_response"},
Guarantees: []string{"duplicate_request_replays_cached_response"},
},
{
Name: "disconnect",
From: "frontend.bound",
To: "backend.reconnecting",
Actor: "gateway -> logic",
RequiredData: []string{"session_id", "account_id"},
Events: []string{"session_disconnected"},
Guarantees: []string{"backend_projection_can_detach_without_socket_access", "profile_kept_until_idle_reclaim"},
},
{
Name: "backend_load_profile",
From: "backend.projected",
To: "backend.loading",
Actor: "logic/playerd",
RequiredData: []string{"account_id", "session_id"},
Events: []string{"session_connected"},
Guarantees: []string{"handler_not_executed_before_profile_loaded"},
},
{
Name: "backend_route_valid",
From: "backend.online",
To: "backend.valid",
Actor: "logic/playerd",
RequiredData: []string{"account_id", "session_id", "msg_id"},
Events: []string{"stateful_handler_success"},
Guarantees: []string{"stateful_route_has_current_session_projection"},
},
{
Name: "backend_reclaim_flush",
From: "backend.reconnecting",
To: "backend.closing",
Actor: "logic/playerd",
RequiredData: []string{"account_id"},
Events: []string{"idle_reclaim"},
Guarantees: []string{"new_packets_rejected_before_profile_flush", "actor_mailbox_drained_before_unload"},
},
{
Name: "backend_flush_done",
From: "backend.closing",
To: "backend.invalid",
Actor: "logic/playerd",
RequiredData: []string{"account_id"},
Events: []string{"profile_flushed"},
Guarantees: []string{"profile_unloaded_after_successful_flush"},
},
},
RoutePolicies: []RoutePolicy{
{Name: "internal", Internal: true, BackendOnly: true},
{Name: "authorized", Authorized: true, FrontendCallable: true},
{Name: "stateful", Stateful: true, Authorized: true, Idempotent: true, FrontendCallable: true},
{Name: "public_read", FrontendCallable: true},
},
Invariants: []Invariant{
{Name: "frontend owns sockets", Description: "Only gateway may accept, write, close, or retain client sockets."},
{Name: "backend owns state mutation", Description: "Logic and gameplay modules own profile and business state mutations through route policy."},
{Name: "session projection is explicit", Description: "Backend session state is a projection of gateway events and must be rebuildable."},
{Name: "stateful routes replay", Description: "Stateful authorized requests must not be dropped on retry; they must execute once or replay the cached response."},
{Name: "disconnect keeps reclaim window", Description: "Backend disconnect moves player runtime to reconnecting; idle reclaim must flush and unload through closing before invalid."},
},
Forbidden: []ForbiddenCoupling{
{Source: "internal/platform", Target: "internal/modules", Reason: "platform layer must remain gameplay agnostic"},
{Source: "internal/platform", Target: "internal/services/gateway", Reason: "platform abstractions must not depend on service wiring"},
{Source: "internal/modules", Target: "transport_acceptor", Reason: "gameplay modules must not own sockets or transport loops"},
{Source: "backend", Target: "client_socket", Reason: "backend push must cross GatewayPush/RPC/event bus contracts"},
},
}
return Normalize(contract)
}
func Normalize(contract Contract) Contract {
contract.Frontend.Identity = cleanSorted(contract.Frontend.Identity)
contract.Frontend.State = cleanSorted(contract.Frontend.State)
contract.Frontend.Operations = cleanSorted(contract.Frontend.Operations)
contract.Frontend.Publishes = cleanSorted(contract.Frontend.Publishes)
contract.Frontend.MayRead = cleanSorted(contract.Frontend.MayRead)
contract.Frontend.MustNotOwn = cleanSorted(contract.Frontend.MustNotOwn)
contract.Backend.Projection = cleanSorted(contract.Backend.Projection)
contract.Backend.State = cleanSorted(contract.Backend.State)
contract.Backend.Operations = cleanSorted(contract.Backend.Operations)
contract.Backend.Consumes = cleanSorted(contract.Backend.Consumes)
contract.Backend.Publishes = cleanSorted(contract.Backend.Publishes)
contract.Backend.MayRead = cleanSorted(contract.Backend.MayRead)
contract.Backend.MustNotOwn = cleanSorted(contract.Backend.MustNotOwn)
for i := range contract.Transitions {
contract.Transitions[i].Name = strings.TrimSpace(contract.Transitions[i].Name)
contract.Transitions[i].From = strings.TrimSpace(contract.Transitions[i].From)
contract.Transitions[i].To = strings.TrimSpace(contract.Transitions[i].To)
contract.Transitions[i].Actor = strings.TrimSpace(contract.Transitions[i].Actor)
contract.Transitions[i].RequiredData = cleanSorted(contract.Transitions[i].RequiredData)
contract.Transitions[i].Events = cleanSorted(contract.Transitions[i].Events)
contract.Transitions[i].Guarantees = cleanSorted(contract.Transitions[i].Guarantees)
}
sort.Slice(contract.Transitions, func(i, j int) bool {
return contract.Transitions[i].Name < contract.Transitions[j].Name
})
for i := range contract.RoutePolicies {
contract.RoutePolicies[i].Name = strings.TrimSpace(contract.RoutePolicies[i].Name)
}
sort.Slice(contract.RoutePolicies, func(i, j int) bool {
return contract.RoutePolicies[i].Name < contract.RoutePolicies[j].Name
})
return contract
}
func Validate(contract Contract) error {
contract = Normalize(contract)
var missing []string
if contract.SchemaVersion <= 0 {
missing = append(missing, "schema_version")
}
if contract.Frontend.Owner == "" {
missing = append(missing, "frontend.owner")
}
if !contains(contract.Frontend.Operations, "write_client_packet") || !contains(contract.Frontend.MustNotOwn, "profile_state") {
missing = append(missing, "frontend socket/state ownership split")
}
if contract.Backend.Owner == "" {
missing = append(missing, "backend.owner")
}
if !contains(contract.Backend.Operations, "commit_idempotency") || !contains(contract.Backend.MustNotOwn, "client_socket") {
missing = append(missing, "backend idempotency/socket ownership split")
}
if len(contract.Transitions) == 0 {
missing = append(missing, "transitions")
}
if len(contract.RoutePolicies) == 0 {
missing = append(missing, "route_policies")
}
if len(contract.Invariants) == 0 {
missing = append(missing, "invariants")
}
if len(missing) > 0 {
return fmt.Errorf("session contract incomplete: %s", strings.Join(missing, "; "))
}
return nil
}
func RoutePolicyFromHandler(meta dispatch.HandlerMeta) RoutePolicy {
return RoutePolicy{
Name: strings.TrimSpace(meta.Name),
Internal: meta.Internal,
Stateful: meta.Stateful || meta.PlayerScoped,
Authorized: meta.Authorized || meta.AuthRequired,
Idempotent: meta.Idempotent,
FrontendCallable: !meta.Internal,
BackendOnly: meta.Internal,
}
}
func ValidateHandlerMeta(meta dispatch.HandlerMeta) error {
policy := RoutePolicyFromHandler(meta)
if policy.Stateful && !policy.Authorized {
return fmt.Errorf("handler %s stateful routes must be authorized", policy.Name)
}
if policy.Stateful && !policy.Idempotent {
return fmt.Errorf("handler %s stateful routes must be idempotent", policy.Name)
}
return nil
}
func cleanSorted(values []string) []string {
out := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
sort.Strings(out)
return out
}
func contains(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
@@ -0,0 +1,191 @@
package sessioncontract
import (
"fmt"
"strings"
)
type PlayerLifecycleState string
const (
PlayerStateInvalid PlayerLifecycleState = "invalid"
PlayerStateLoading PlayerLifecycleState = "loading"
PlayerStateOnline PlayerLifecycleState = "online"
PlayerStateValid PlayerLifecycleState = "valid"
PlayerStateReconnecting PlayerLifecycleState = "reconnecting"
PlayerStateClosing PlayerLifecycleState = "closing"
PlayerStateKicked PlayerLifecycleState = "kicked"
)
type PlayerLifecycleEvent string
const (
PlayerEventLoginStart PlayerLifecycleEvent = "login_start"
PlayerEventLoginBound PlayerLifecycleEvent = "login_bound"
PlayerEventActivate PlayerLifecycleEvent = "activate"
PlayerEventDisconnect PlayerLifecycleEvent = "disconnect"
PlayerEventKick PlayerLifecycleEvent = "kick"
PlayerEventCloseStart PlayerLifecycleEvent = "close_start"
PlayerEventFlushDone PlayerLifecycleEvent = "flush_done"
PlayerEventReclaim PlayerLifecycleEvent = "reclaim"
EventRestoreFail PlayerLifecycleEvent = "restore_after_failure"
)
type PlayerLifecycleTransition struct {
Event PlayerLifecycleEvent `json:"event"`
From []PlayerLifecycleState `json:"from"`
To PlayerLifecycleState `json:"to"`
FlushBefore []string `json:"flush_before,omitempty"`
Notes string `json:"notes,omitempty"`
}
var defaultTransitions = []PlayerLifecycleTransition{
{
Event: PlayerEventLoginStart,
From: []PlayerLifecycleState{
PlayerStateInvalid,
PlayerStateLoading,
PlayerStateOnline,
PlayerStateValid,
PlayerStateReconnecting,
PlayerStateClosing,
PlayerStateKicked,
},
To: PlayerStateLoading,
Notes: "登录或重连开始后先进入 loading,业务 handler 不应在此阶段执行。",
},
{
Event: PlayerEventLoginBound,
From: []PlayerLifecycleState{PlayerStateLoading},
To: PlayerStateOnline,
Notes: "前端 session 绑定完成且 Profile 已加载后进入 online。",
},
{
Event: PlayerEventActivate,
From: []PlayerLifecycleState{PlayerStateLoading, PlayerStateOnline, PlayerStateValid},
To: PlayerStateValid,
Notes: "首个玩家作用域 handler 成功后进入 valid,表示可承载状态变更。",
},
{
Event: PlayerEventDisconnect,
From: []PlayerLifecycleState{PlayerStateLoading, PlayerStateOnline, PlayerStateValid, PlayerStateReconnecting},
To: PlayerStateReconnecting,
Notes: "断线后先保留 Profile 和 actor,等待同账号重连或 idle 回收。",
},
{
Event: PlayerEventKick,
From: []PlayerLifecycleState{
PlayerStateLoading,
PlayerStateOnline,
PlayerStateValid,
PlayerStateReconnecting,
},
To: PlayerStateKicked,
FlushBefore: []string{"reject_new_packets", "flush_profile", "publish_kick"},
Notes: "踢人先阻断新请求,再落盘并发出踢人结果。",
},
{
Event: PlayerEventCloseStart,
From: []PlayerLifecycleState{
PlayerStateLoading,
PlayerStateOnline,
PlayerStateValid,
PlayerStateReconnecting,
PlayerStateKicked,
},
To: PlayerStateClosing,
FlushBefore: []string{"reject_new_packets", "drain_actor_mailbox", "flush_profile", "close_runtime"},
Notes: "服务停机或主动关闭时先进入 closing,等待 actor drain 和 Profile flush。",
},
{
Event: PlayerEventFlushDone,
From: []PlayerLifecycleState{PlayerStateClosing, PlayerStateKicked},
To: PlayerStateInvalid,
FlushBefore: []string{"remove_runtime"},
Notes: "Profile 已持久化并卸载后,runtime 才能进入 invalid。",
},
{
Event: PlayerEventReclaim,
From: []PlayerLifecycleState{PlayerStateReconnecting},
To: PlayerStateClosing,
FlushBefore: []string{"reject_new_packets", "flush_profile", "unload_profile", "remove_runtime"},
Notes: "断线保留超过 TTL 后触发 idle 回收。",
},
{
Event: EventRestoreFail,
From: []PlayerLifecycleState{PlayerStateClosing, PlayerStateInvalid},
To: PlayerStateReconnecting,
Notes: "回收 flush 失败时恢复为 reconnecting,继续等待下一轮回收或重连。",
},
}
func DefaultPlayerLifecycleTransitions() []PlayerLifecycleTransition {
return cloneTransitions(defaultTransitions)
}
func cloneTransitions(in []PlayerLifecycleTransition) []PlayerLifecycleTransition {
out := make([]PlayerLifecycleTransition, len(in))
for i := range in {
out[i] = in[i]
out[i].From = append([]PlayerLifecycleState(nil), in[i].From...)
out[i].FlushBefore = append([]string(nil), in[i].FlushBefore...)
}
return out
}
func NextPlayerLifecycleState(current PlayerLifecycleState, event PlayerLifecycleEvent) (PlayerLifecycleState, error) {
current = normLifeState(current)
event = PlayerLifecycleEvent(strings.TrimSpace(string(event)))
for _, transition := range DefaultPlayerLifecycleTransitions() {
if transition.Event != event {
continue
}
for _, from := range transition.From {
if normLifeState(from) == current {
return transition.To, nil
}
}
return "", fmt.Errorf("player lifecycle event %q cannot move from %q", event, current)
}
return "", fmt.Errorf("unknown player lifecycle event %q", event)
}
func ValidatePlayerLifecycleTransition(current PlayerLifecycleState, event PlayerLifecycleEvent) error {
_, err := NextPlayerLifecycleState(current, event)
return err
}
func PlayerLifecycleFlushOrder(event PlayerLifecycleEvent) []string {
event = PlayerLifecycleEvent(strings.TrimSpace(string(event)))
for _, transition := range DefaultPlayerLifecycleTransitions() {
if transition.Event != event {
continue
}
return append([]string(nil), transition.FlushBefore...)
}
return nil
}
func normLifeState(state PlayerLifecycleState) PlayerLifecycleState {
trimmed := PlayerLifecycleState(strings.TrimSpace(string(state)))
switch trimmed {
case "":
return PlayerStateInvalid
case PlayerStateLoading:
return PlayerStateLoading
case PlayerStateOnline:
return PlayerStateOnline
case PlayerStateValid:
return PlayerStateValid
case PlayerStateReconnecting:
return PlayerStateReconnecting
case PlayerStateClosing:
return PlayerStateClosing
case PlayerStateKicked:
return PlayerStateKicked
case PlayerStateInvalid:
return PlayerStateInvalid
default:
return trimmed
}
}