服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
396 lines
14 KiB
Go
396 lines
14 KiB
Go
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
|
|
}
|