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,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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user