服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
206 lines
6.1 KiB
Go
206 lines
6.1 KiB
Go
package companion
|
|
|
|
import "sync"
|
|
|
|
type RunKey struct {
|
|
Instance string
|
|
SessionGeneration uint64
|
|
OwnerCharacterID string
|
|
LifecycleToken uint64
|
|
SpawnSequence uint64
|
|
Nonce string
|
|
}
|
|
|
|
func (k RunKey) valid() bool {
|
|
return validIdentity(k.Instance) && k.SessionGeneration > 0 && validCharacterID(k.OwnerCharacterID) &&
|
|
k.LifecycleToken > 0 && k.SpawnSequence > 0 && validHash(k.Nonce)
|
|
}
|
|
|
|
type Phase string
|
|
|
|
const (
|
|
Prepared Phase = "prepared"
|
|
Ready Phase = "room_ready"
|
|
Created Phase = "spawned_inactive"
|
|
Bound Phase = "owner_bound"
|
|
Active Phase = "active"
|
|
Transitioning Phase = "transitioning"
|
|
Dead Phase = "dead"
|
|
Retiring Phase = "retiring"
|
|
Disposed Phase = "disposed"
|
|
Skipped Phase = "skipped"
|
|
)
|
|
|
|
type Event struct {
|
|
Run RunKey
|
|
Sequence uint64
|
|
Kind string
|
|
RoomGeneration uint64
|
|
ObjectKey uint16
|
|
ObjectGeneration uint64
|
|
}
|
|
|
|
type Status struct {
|
|
Run RunKey
|
|
Phase Phase
|
|
ConfigRevision uint64
|
|
SnapshotHash string
|
|
LastSequence uint64
|
|
RoomGeneration uint64
|
|
ObjectKey uint16
|
|
ObjectGeneration uint64
|
|
OwnerBound bool
|
|
}
|
|
|
|
// Instance is an isolated coordinator, not a client message handler.
|
|
// Callers must authenticate events and hold the real runtime/object admission
|
|
// boundary. An object-confirmation event is never proof of native creation by
|
|
// itself. No network endpoint or game callback currently invokes this type.
|
|
type Instance struct {
|
|
mu sync.Mutex
|
|
status Status
|
|
snapshot Snapshot
|
|
last Event
|
|
playerObjectKey uint16
|
|
}
|
|
|
|
func NewInstance(key RunKey, snapshot Snapshot, configRevision uint64, playerObjectKey uint16) (*Instance, error) {
|
|
if !key.valid() || playerObjectKey == 0 || playerObjectKey == 65535 {
|
|
return nil, ErrStaleRun
|
|
}
|
|
if snapshot.SchemaVersion != 1 || snapshot.OwnerCharacterID != key.OwnerCharacterID ||
|
|
!validCharacterID(snapshot.SourceCharacterID) || snapshot.SourceCharacterID == key.OwnerCharacterID ||
|
|
snapshot.ProfileID == "" || !validHash(snapshot.ClientSHA256) || !validHash(snapshot.PVFSHA256) {
|
|
return nil, ErrSnapshot
|
|
}
|
|
frozen := snapshot.Clone()
|
|
return &Instance{status: Status{Run: key, Phase: Prepared, ConfigRevision: configRevision,
|
|
SnapshotHash: frozen.Fingerprint()}, snapshot: frozen, playerObjectKey: playerObjectKey}, nil
|
|
}
|
|
|
|
func (i *Instance) Status() Status { i.mu.Lock(); defer i.mu.Unlock(); return i.status }
|
|
func (i *Instance) Snapshot() Snapshot { i.mu.Lock(); defer i.mu.Unlock(); return i.snapshot.Clone() }
|
|
|
|
// occupied contains other actors only, freshly inspected by the future bridge.
|
|
// nil means collision evidence missing and rejects a first native creation.
|
|
// Successful repeated messages are no-ops; a reused sequence with new content
|
|
// is an error. No transition can create another APC or respawn a dead one.
|
|
func (i *Instance) Apply(e Event, occupied map[uint16]bool) error {
|
|
i.mu.Lock()
|
|
defer i.mu.Unlock()
|
|
s := i.status
|
|
if e.Run != s.Run {
|
|
return ErrStaleRun
|
|
}
|
|
if e.Sequence == 0 {
|
|
return ErrTransition
|
|
}
|
|
if e.Sequence == s.LastSequence && e == i.last {
|
|
return nil
|
|
}
|
|
if e.Sequence <= s.LastSequence {
|
|
return ErrTransition
|
|
}
|
|
switch e.Kind {
|
|
case "room_ready":
|
|
if s.Phase != Prepared && s.Phase != Transitioning {
|
|
return ErrTransition
|
|
}
|
|
if e.RoomGeneration <= s.RoomGeneration {
|
|
return ErrRoom
|
|
}
|
|
s.RoomGeneration = e.RoomGeneration
|
|
if s.Phase == Prepared {
|
|
s.Phase = Ready
|
|
} else {
|
|
s.Phase = Active
|
|
}
|
|
case "native_created":
|
|
if (s.Phase != Ready && s.Phase != Retiring) || e.RoomGeneration != s.RoomGeneration {
|
|
return ErrRoom
|
|
}
|
|
// A native create may finish after cancellation. Track it for exact
|
|
// cleanup, but never authorize it or replace an existing actor.
|
|
if s.Phase == Retiring && s.ObjectKey != 0 {
|
|
return ErrCollision
|
|
}
|
|
if occupied == nil || e.ObjectKey == 0 || e.ObjectKey == 65535 || e.ObjectKey == i.playerObjectKey || occupied[e.ObjectKey] || e.ObjectGeneration == 0 {
|
|
return ErrCollision
|
|
}
|
|
s.ObjectKey = e.ObjectKey
|
|
s.ObjectGeneration = e.ObjectGeneration
|
|
if s.Phase == Ready {
|
|
s.Phase = Created
|
|
}
|
|
case "owner_bound", "activate":
|
|
if e.ObjectKey != s.ObjectKey || e.ObjectGeneration != s.ObjectGeneration || e.RoomGeneration != s.RoomGeneration {
|
|
return ErrCollision
|
|
}
|
|
if e.Kind == "owner_bound" {
|
|
if s.Phase != Created {
|
|
return ErrTransition
|
|
}
|
|
s.Phase = Bound
|
|
s.OwnerBound = true
|
|
} else {
|
|
if s.Phase != Bound {
|
|
return ErrTransition
|
|
}
|
|
s.Phase = Active
|
|
}
|
|
case "room_leaving":
|
|
if s.Phase != Active || e.RoomGeneration != s.RoomGeneration {
|
|
return ErrTransition
|
|
}
|
|
s.Phase = Transitioning
|
|
case "dead":
|
|
if s.Phase != Active && s.Phase != Transitioning {
|
|
return ErrTransition
|
|
}
|
|
if e.ObjectKey != s.ObjectKey || e.ObjectGeneration != s.ObjectGeneration {
|
|
return ErrCollision
|
|
}
|
|
s.Phase = Dead
|
|
case "retire":
|
|
if s.Phase == Disposed || s.Phase == Skipped {
|
|
return ErrTransition
|
|
}
|
|
s.Phase = Retiring
|
|
case "disposed":
|
|
if s.Phase != Retiring {
|
|
return ErrTransition
|
|
}
|
|
// The bridge must also prove the native operation/callbacks drained;
|
|
// a missing room manager is not proof that the actor was destroyed.
|
|
if e.ObjectKey != s.ObjectKey || e.ObjectGeneration != s.ObjectGeneration {
|
|
return ErrCollision
|
|
}
|
|
if e.RoomGeneration != s.RoomGeneration {
|
|
return ErrRoom
|
|
}
|
|
s.Phase = Disposed
|
|
s.OwnerBound = false
|
|
case "skip":
|
|
if s.Phase != Prepared && s.Phase != Ready {
|
|
return ErrTransition
|
|
}
|
|
s.Phase = Skipped
|
|
default:
|
|
return ErrTransition
|
|
}
|
|
s.LastSequence = e.Sequence
|
|
i.status = s
|
|
i.last = e
|
|
return nil
|
|
}
|
|
|
|
// Retiring/dead owners remain valid for already-in-flight combat. The bridge
|
|
// must validate targets and safely drain reports before reporting disposed.
|
|
// Object generation is internal evidence; no existing game wire is changed.
|
|
func (i *Instance) Owns(run RunKey, objectKey uint16, generation uint64) bool {
|
|
i.mu.Lock()
|
|
defer i.mu.Unlock()
|
|
s := i.status
|
|
return run == s.Run && s.OwnerBound && objectKey != 0 && objectKey == s.ObjectKey && generation == s.ObjectGeneration
|
|
}
|