|
|
|
|
@ -77,8 +77,17 @@ type Engine struct {
|
|
|
|
|
|
|
|
|
|
// npcAlmsHandler runs when the client accepts a wandering merchant offer (WS).
|
|
|
|
|
npcAlmsHandler func(context.Context, int64) error
|
|
|
|
|
|
|
|
|
|
digestStore *storage.OfflineDigestStore
|
|
|
|
|
// heroSubscriber reports whether the hero has at least one WebSocket client (optional).
|
|
|
|
|
heroSubscriber func(heroID int64) bool
|
|
|
|
|
// lastDisconnectedFullSave tracks periodic DB full saves for heroes without a WS subscriber.
|
|
|
|
|
lastDisconnectedFullSave map[int64]time.Time
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// offlineDisconnectedFullSaveInterval is how often we persist a full hero row when no WS client is connected.
|
|
|
|
|
const offlineDisconnectedFullSaveInterval = 30 * time.Second
|
|
|
|
|
|
|
|
|
|
// NewEngine creates a new game engine with the given tick rate.
|
|
|
|
|
func NewEngine(tickRate time.Duration, eventCh chan model.CombatEvent, logger *slog.Logger) *Engine {
|
|
|
|
|
e := &Engine{
|
|
|
|
|
@ -89,6 +98,7 @@ func NewEngine(tickRate time.Duration, eventCh chan model.CombatEvent, logger *s
|
|
|
|
|
incomingCh: make(chan IncomingMessage, 256),
|
|
|
|
|
eventCh: eventCh,
|
|
|
|
|
logger: logger,
|
|
|
|
|
lastDisconnectedFullSave: make(map[int64]time.Time),
|
|
|
|
|
}
|
|
|
|
|
heap.Init(&e.queue)
|
|
|
|
|
return e
|
|
|
|
|
@ -98,7 +108,24 @@ func (e *Engine) GetMovements(heroId int64) *HeroMovement {
|
|
|
|
|
return e.movements[heroId]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// HeroHasActiveMovement is true while the hero has an in-engine movement session (typically WebSocket-connected).
|
|
|
|
|
// MergeResidentHeroState copies the authoritative in-engine hero into dst after SyncToHero.
|
|
|
|
|
// Returns false if the hero is not resident. Used by REST init so the client sees the same state the Engine simulates.
|
|
|
|
|
func (e *Engine) MergeResidentHeroState(dst *model.Hero) bool {
|
|
|
|
|
if dst == nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
e.mu.RLock()
|
|
|
|
|
hm := e.movements[dst.ID]
|
|
|
|
|
e.mu.RUnlock()
|
|
|
|
|
if hm == nil || hm.Hero == nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
hm.SyncToHero()
|
|
|
|
|
*dst = *hm.Hero
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// HeroHasActiveMovement is true while the hero has an in-engine movement session (resident world actor).
|
|
|
|
|
func (e *Engine) HeroHasActiveMovement(heroID int64) bool {
|
|
|
|
|
e.mu.RLock()
|
|
|
|
|
defer e.mu.RUnlock()
|
|
|
|
|
@ -272,6 +299,28 @@ func (e *Engine) SetAdventureLog(w AdventureLogWriter) {
|
|
|
|
|
e.adventureLog = w
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SetDigestStore wires persistent offline digest accumulation (after disconnect grace).
|
|
|
|
|
func (e *Engine) SetDigestStore(d *storage.OfflineDigestStore) {
|
|
|
|
|
e.mu.Lock()
|
|
|
|
|
defer e.mu.Unlock()
|
|
|
|
|
e.digestStore = d
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SetHeroSubscriber sets an optional callback: return true if the hero has at least one WebSocket client.
|
|
|
|
|
// Used for periodic full saves when the world keeps simulating without a subscriber.
|
|
|
|
|
func (e *Engine) SetHeroSubscriber(fn func(heroID int64) bool) {
|
|
|
|
|
e.mu.Lock()
|
|
|
|
|
defer e.mu.Unlock()
|
|
|
|
|
e.heroSubscriber = fn
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (e *Engine) applyOfflineDigest(ctx context.Context, heroID int64, hero *model.Hero, now time.Time, delta storage.OfflineDigestDelta) {
|
|
|
|
|
if e.digestStore == nil || hero == nil || !OfflineDigestCollecting(hero.WsDisconnectedAt, now) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
_ = e.digestStore.ApplyDelta(ctx, heroID, delta)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// IncomingCh returns the channel for routing client WS commands into the engine.
|
|
|
|
|
func (e *Engine) IncomingCh() chan<- IncomingMessage {
|
|
|
|
|
return e.incomingCh
|
|
|
|
|
@ -594,6 +643,7 @@ func (e *Engine) RegisterHeroMovement(hero *model.Hero) {
|
|
|
|
|
// Reconnect while the previous socket is still tearing down: keep live movement so we
|
|
|
|
|
// do not replace (x,y) and route with a stale DB snapshot.
|
|
|
|
|
if existing, ok := e.movements[hero.ID]; ok {
|
|
|
|
|
existing.Hero.WsDisconnectedAt = hero.WsDisconnectedAt
|
|
|
|
|
existing.Hero.EnsureGearMap()
|
|
|
|
|
existing.Hero.RefreshDerivedCombatStats(now)
|
|
|
|
|
e.logger.Info("hero movement reattached (existing session)",
|
|
|
|
|
@ -621,6 +671,19 @@ func (e *Engine) RegisterHeroMovement(hero *model.Hero) {
|
|
|
|
|
hm.MarkTownPausePersisted(hm.townPausePersistSignature())
|
|
|
|
|
hm.SyncToHero()
|
|
|
|
|
|
|
|
|
|
// DB said fighting but engine has no combat (e.g. after restart): attach a new encounter.
|
|
|
|
|
if hm.State == model.StateFighting {
|
|
|
|
|
if _, exists := e.combats[hero.ID]; !exists {
|
|
|
|
|
en := PickEnemyForLevel(hero.Level)
|
|
|
|
|
if en.Slug != "" {
|
|
|
|
|
e.startCombatLocked(hm.Hero, &en)
|
|
|
|
|
} else {
|
|
|
|
|
hm.State = model.StateWalking
|
|
|
|
|
hm.Hero.State = model.StateWalking
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
e.logger.Info("hero movement registered",
|
|
|
|
|
"hero_id", hero.ID,
|
|
|
|
|
"state", hm.State,
|
|
|
|
|
@ -647,15 +710,16 @@ func (e *Engine) RegisterHeroMovement(hero *model.Hero) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// HeroSocketDetached persists hero state on every WS disconnect and removes in-memory
|
|
|
|
|
// movement only when lastConnection is true (no other tabs/sockets for this hero).
|
|
|
|
|
func (e *Engine) HeroSocketDetached(heroID int64, lastConnection bool) {
|
|
|
|
|
// HeroSocketDetached persists hero state on every WS disconnect. Movement and combat stay in the engine
|
|
|
|
|
// so the world keeps simulating; disconnectedAt is stored on the in-memory hero for offline digest timing.
|
|
|
|
|
func (e *Engine) HeroSocketDetached(heroID int64, lastConnection bool, disconnectedAt time.Time) {
|
|
|
|
|
e.mu.Lock()
|
|
|
|
|
hm, ok := e.movements[heroID]
|
|
|
|
|
if ok {
|
|
|
|
|
hm.SyncToHero()
|
|
|
|
|
if lastConnection {
|
|
|
|
|
delete(e.movements, heroID)
|
|
|
|
|
if lastConnection && !disconnectedAt.IsZero() && hm.Hero != nil {
|
|
|
|
|
t := disconnectedAt
|
|
|
|
|
hm.Hero.WsDisconnectedAt = &t
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
var heroSnap *model.Hero
|
|
|
|
|
@ -1225,11 +1289,15 @@ func (e *Engine) GetCombat(heroID int64) (*model.CombatState, bool) {
|
|
|
|
|
return cs, ok
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// processCombatTick is the 100ms combat processing tick.
|
|
|
|
|
// processCombatTick is the combat processing tick (typically 100ms cadence).
|
|
|
|
|
func (e *Engine) processCombatTick(now time.Time) {
|
|
|
|
|
e.mu.Lock()
|
|
|
|
|
defer e.mu.Unlock()
|
|
|
|
|
e.processCombatTickLocked(now)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// processCombatTickLocked runs combat logic; caller must hold e.mu.
|
|
|
|
|
func (e *Engine) processCombatTickLocked(now time.Time) {
|
|
|
|
|
// Heroes resting or touring town must not keep fighting in the background.
|
|
|
|
|
var purgeCombat []int64
|
|
|
|
|
for heroID := range e.combats {
|
|
|
|
|
@ -1299,6 +1367,9 @@ func (e *Engine) processCombatTick(now time.Time) {
|
|
|
|
|
if hm, ok := e.movements[heroID]; ok {
|
|
|
|
|
hm.Die()
|
|
|
|
|
}
|
|
|
|
|
dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
|
|
|
e.applyOfflineDigest(dctx, heroID, cs.Hero, now, storage.OfflineDigestDelta{Deaths: 1})
|
|
|
|
|
cancel()
|
|
|
|
|
delete(e.combats, heroID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@ -1462,6 +1533,9 @@ func (e *Engine) processEnemyAttack(cs *model.CombatState, now time.Time) {
|
|
|
|
|
if hm, ok := e.movements[cs.HeroID]; ok {
|
|
|
|
|
hm.Die()
|
|
|
|
|
}
|
|
|
|
|
dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
|
|
|
e.applyOfflineDigest(dctx, cs.HeroID, cs.Hero, now, storage.OfflineDigestDelta{Deaths: 1})
|
|
|
|
|
cancel()
|
|
|
|
|
delete(e.combats, cs.HeroID)
|
|
|
|
|
|
|
|
|
|
e.logger.Info("hero died",
|
|
|
|
|
@ -1515,6 +1589,18 @@ func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) {
|
|
|
|
|
victoryDrops = e.onEnemyDeath(hero, enemy, now)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if hero != nil {
|
|
|
|
|
dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
|
|
|
e.applyOfflineDigest(dctx, cs.HeroID, hero, now, storage.OfflineDigestDelta{
|
|
|
|
|
MonstersKilled: 1,
|
|
|
|
|
XPGained: enemy.XPReward,
|
|
|
|
|
GoldGained: model.SumGoldFromLootDrops(victoryDrops),
|
|
|
|
|
LevelsGained: hero.Level - oldLevel,
|
|
|
|
|
LootAppend: NonGoldLootForDigest(victoryDrops),
|
|
|
|
|
})
|
|
|
|
|
cancel()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
e.emitEvent(model.CombatEvent{
|
|
|
|
|
Type: "combat_end",
|
|
|
|
|
HeroID: cs.HeroID,
|
|
|
|
|
@ -1572,6 +1658,50 @@ func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) {
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// processAutoReviveLocked revives dead heroes after AutoReviveAfterMs downtime. Caller holds e.mu.
|
|
|
|
|
func (e *Engine) processAutoReviveLocked(now time.Time) {
|
|
|
|
|
if e.heroStore == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
gap := time.Duration(tuning.Get().AutoReviveAfterMs) * time.Millisecond
|
|
|
|
|
for heroID, hm := range e.movements {
|
|
|
|
|
if hm == nil || hm.Hero == nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
h := hm.Hero
|
|
|
|
|
if h.State != model.StateDead && h.HP > 0 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if now.Sub(h.UpdatedAt) <= gap {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
h.HP = int(float64(h.MaxHP) * tuning.Get().ReviveHpPercent)
|
|
|
|
|
if h.HP < 1 {
|
|
|
|
|
h.HP = 1
|
|
|
|
|
}
|
|
|
|
|
h.State = model.StateWalking
|
|
|
|
|
h.Debuffs = nil
|
|
|
|
|
hm.State = model.StateWalking
|
|
|
|
|
hm.SyncToHero()
|
|
|
|
|
dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
|
|
|
e.applyOfflineDigest(dctx, heroID, h, now, storage.OfflineDigestDelta{Revives: 1})
|
|
|
|
|
cancel()
|
|
|
|
|
if e.adventureLog != nil {
|
|
|
|
|
e.adventureLog(heroID, model.AdventureLogLine{
|
|
|
|
|
Event: &model.AdventureLogEvent{
|
|
|
|
|
Code: model.LogAutoReviveAfterSec,
|
|
|
|
|
Args: map[string]any{"seconds": int64(gap.Round(time.Second) / time.Second)},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
ctx, cancelSave := context.WithTimeout(context.Background(), 5*time.Second)
|
|
|
|
|
if err := e.heroStore.Save(ctx, h); err != nil && e.logger != nil {
|
|
|
|
|
e.logger.Error("persist hero after auto-revive", "hero_id", heroID, "error", err)
|
|
|
|
|
}
|
|
|
|
|
cancelSave()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// processMovementTick advances all walking heroes and checks for encounters.
|
|
|
|
|
// Runs on the configured movement cadence.
|
|
|
|
|
func (e *Engine) processMovementTick(now time.Time) {
|
|
|
|
|
@ -1582,6 +1712,8 @@ func (e *Engine) processMovementTick(now time.Time) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
e.processAutoReviveLocked(now)
|
|
|
|
|
|
|
|
|
|
startCombat := func(hm *HeroMovement, enemy *model.Enemy, t time.Time) {
|
|
|
|
|
e.startCombatLocked(hm.Hero, enemy)
|
|
|
|
|
}
|
|
|
|
|
@ -1612,6 +1744,21 @@ func (e *Engine) processMovementTick(now time.Time) {
|
|
|
|
|
hm.MarkTownPausePersisted(sig)
|
|
|
|
|
e.syncTownSessionRedis(heroID, hm)
|
|
|
|
|
}
|
|
|
|
|
if e.heroStore != nil && e.heroSubscriber != nil && hm.Hero != nil && !e.heroSubscriber(heroID) {
|
|
|
|
|
last := e.lastDisconnectedFullSave[heroID]
|
|
|
|
|
if last.IsZero() || now.Sub(last) >= offlineDisconnectedFullSaveInterval {
|
|
|
|
|
hm.SyncToHero()
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
|
|
|
if err := e.heroStore.Save(ctx, hm.Hero); err != nil {
|
|
|
|
|
if e.logger != nil {
|
|
|
|
|
e.logger.Error("persist disconnected resident hero", "hero_id", heroID, "error", err)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
e.lastDisconnectedFullSave[heroID] = now
|
|
|
|
|
}
|
|
|
|
|
cancel()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|