package game import ( "context" "encoding/json" "fmt" "math" "math/rand" "time" "unicode/utf8" "github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/profanity" "github.com/denisovdennis/autohero/internal/storage" "github.com/denisovdennis/autohero/internal/tuning" ) func heroMeetOrderedPair(a, b int64) (lo, hi int64) { if a < b { return a, b } return b, a } func (e *Engine) heroMeetAnySubscriberOnline(a, b int64) bool { if e.heroSubscriber == nil { return false } return e.heroSubscriber(a) || e.heroSubscriber(b) } // heroMeetPlayerOnline is true when this hero has an active WebSocket (player connected). func (e *Engine) heroMeetPlayerOnline(heroID int64) bool { if e.heroSubscriber == nil { return false } return e.heroSubscriber(heroID) } func (e *Engine) copyExcursionHeroMeetFields(dst, src *model.ExcursionSession) { if dst == nil || src == nil { return } dst.HeroMeetPartnerID = src.HeroMeetPartnerID dst.HeroMeetSubPhase = src.HeroMeetSubPhase dst.HeroMeetPromptUntil = src.HeroMeetPromptUntil dst.HeroMeetNextAutoAt = src.HeroMeetNextAutoAt dst.HeroMeetTurnHeroID = src.HeroMeetTurnHeroID dst.HeroMeetHadPlayerMessage = src.HeroMeetHadPlayerMessage dst.HeroMeetOfflineDeadline = src.HeroMeetOfflineDeadline dst.HeroMeetOfflineTimerRunning = src.HeroMeetOfflineTimerRunning dst.HeroMeetOfflineRemainingMs = src.HeroMeetOfflineRemainingMs dst.HeroMeetAutoLineIdx = src.HeroMeetAutoLineIdx dst.HeroMeetAnchorX = src.HeroMeetAnchorX dst.HeroMeetAnchorY = src.HeroMeetAnchorY dst.Phase = src.Phase } func (e *Engine) syncHeroMeetPartnerExcursion(leader *HeroMovement, partner *HeroMovement) { if leader == nil || partner == nil { return } lid := leader.HeroID e.copyExcursionHeroMeetFields(&partner.Excursion, &leader.Excursion) partner.Excursion.HeroMeetPartnerID = lid } func randomHeroMeetOfflineDuration(cfg tuning.Values) time.Duration { minMs := cfg.HeroMeetOfflineMinMs maxMs := cfg.HeroMeetOfflineMaxMs if minMs <= 0 { minMs = tuning.DefaultValues().HeroMeetOfflineMinMs } if maxMs <= 0 { maxMs = tuning.DefaultValues().HeroMeetOfflineMaxMs } if maxMs < minMs { maxMs = minMs } delta := maxMs - minMs return time.Duration(minMs+rand.Int63n(delta+1)) * time.Millisecond } func heroMeetHeroNearAttractor(hm *HeroMovement) bool { if hm == nil || !hm.Excursion.AttractorSet { return false } eps := ExcursionArrivalEpsilonWorld() dx := hm.Excursion.AttractorX - hm.CurrentX dy := hm.Excursion.AttractorY - hm.CurrentY return math.Hypot(dx, dy) <= eps } func (e *Engine) transitionHeroMeetDialogueTimersLocked(lo, hi int64, now time.Time) { leader := e.movements[lo] partner := e.movements[hi] if leader == nil || partner == nil { return } ex := &leader.Excursion cfg := tuning.Get() anyOnline := e.heroMeetAnySubscriberOnline(lo, hi) offlineBudget := time.Duration(ex.HeroMeetOfflineRemainingMs) * time.Millisecond if offlineBudget <= 0 { offlineBudget = randomHeroMeetOfflineDuration(cfg) ex.HeroMeetOfflineRemainingMs = offlineBudget.Milliseconds() } promptMs := cfg.HeroMeetPromptWindowMs if promptMs <= 0 { promptMs = tuning.DefaultValues().HeroMeetPromptWindowMs } autoInt := cfg.HeroMeetAutoLineIntervalMs if autoInt <= 0 { autoInt = tuning.DefaultValues().HeroMeetAutoLineIntervalMs } if anyOnline { ex.HeroMeetSubPhase = model.HeroMeetSubPrompt ex.HeroMeetPromptUntil = now.Add(time.Duration(promptMs) * time.Millisecond) ex.HeroMeetOfflineTimerRunning = false ex.HeroMeetNextAutoAt = ex.HeroMeetPromptUntil } else { ex.HeroMeetSubPhase = model.HeroMeetSubAuto ex.HeroMeetOfflineTimerRunning = true ex.HeroMeetOfflineDeadline = now.Add(offlineBudget) ex.HeroMeetNextAutoAt = now.Add(time.Duration(autoInt) * time.Millisecond) } e.syncHeroMeetPartnerExcursion(leader, partner) } // checkHeroMeetApproachArrivalLocked promotes out → meet when both heroes reach approach attractors. func (e *Engine) checkHeroMeetApproachArrivalLocked(now time.Time) { seen := make(map[string]struct{}) for id, hm := range e.movements { if hm == nil || hm.Excursion.Kind != model.ExcursionKindHeroMeet || !hm.Excursion.Active() { continue } if hm.Excursion.Phase != model.ExcursionOut { continue } pid := hm.Excursion.HeroMeetPartnerID if pid == 0 { continue } lo, hi := heroMeetOrderedPair(id, pid) key := fmt.Sprintf("%d_%d", lo, hi) if _, dup := seen[key]; dup { continue } seen[key] = struct{}{} a := e.movements[lo] b := e.movements[hi] if a == nil || b == nil { continue } if !heroMeetHeroNearAttractor(a) || !heroMeetHeroNearAttractor(b) { continue } a.Excursion.Phase = model.ExcursionPhaseHeroMeet b.Excursion.Phase = model.ExcursionPhaseHeroMeet a.Excursion.AttractorSet = false b.Excursion.AttractorSet = false e.transitionHeroMeetDialogueTimersLocked(lo, hi, now) cfg := tuning.Get() linger := cfg.HeroMeetPartnerLingerMs if linger <= 0 { linger = tuning.DefaultValues().HeroMeetPartnerLingerMs } e.pushHeroMeetStartLocked(lo, linger, "meet") e.pushHeroMeetStartLocked(hi, linger, "meet") e.persistHeroPairAfterMeetChangeLocked(lo, hi) } } func heroMeetHeroNearReturnPoint(hm *HeroMovement) bool { if hm == nil || hm.Excursion.Kind != model.ExcursionKindHeroMeet || hm.Excursion.Phase != model.ExcursionReturn { return false } eps := ExcursionArrivalEpsilonWorld() dx := hm.Excursion.StartX - hm.CurrentX dy := hm.Excursion.StartY - hm.CurrentY return math.Hypot(dx, dy) <= eps } // checkHeroMeetReturnArrivalLocked clears the meet when both heroes return to pre-meet positions. func (e *Engine) checkHeroMeetReturnArrivalLocked(now time.Time) { seen := make(map[string]struct{}) for id, hm := range e.movements { if hm == nil || hm.Excursion.Kind != model.ExcursionKindHeroMeet || !hm.Excursion.Active() { continue } if hm.Excursion.Phase != model.ExcursionReturn { continue } pid := hm.Excursion.HeroMeetPartnerID if pid == 0 { continue } lo, hi := heroMeetOrderedPair(id, pid) key := fmt.Sprintf("%d_%d", lo, hi) if _, dup := seen[key]; dup { continue } seen[key] = struct{}{} a := e.movements[lo] b := e.movements[hi] if a == nil || b == nil { continue } if !heroMeetHeroNearReturnPoint(a) || !heroMeetHeroNearReturnPoint(b) { continue } for _, hm2 := range []*HeroMovement{a, b} { hm2.clearHeroMeetResumeWalking(now) hm2.SyncToHero() if e.sender != nil && hm2.Hero != nil { hm2.Hero.EnsureGearMap() hm2.Hero.RefreshDerivedCombatStats(now) e.sender.SendToHero(hm2.HeroID, "hero_state", hm2.Hero) e.sender.SendToHero(hm2.HeroID, "hero_move", hm2.MovePayload(now)) if route := hm2.RoutePayload(); route != nil { e.sender.SendToHero(hm2.HeroID, "route_assigned", route) } } } if e.heroStore != nil { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) if a.Hero != nil { _ = e.heroStore.Save(ctx, a.Hero) } if b.Hero != nil { _ = e.heroStore.Save(ctx, b.Hero) } cancel() } } } // teleportHeroTowardWorldPoint moves hm to lie `sep` world units from (tx,ty) along the segment toward hm (only if farther than sep). func teleportHeroTowardWorldPoint(hm *HeroMovement, tx, ty, sep float64, heroStore *storage.HeroStore) { if hm == nil || sep <= 0 { return } ox, oy := hm.CurrentX, hm.CurrentY vx, vy := ox-tx, oy-ty d := math.Hypot(vx, vy) if d < 1e-6 { return } if d <= sep { return } scale := sep / d hm.CurrentX = tx + vx*scale hm.CurrentY = ty + vy*scale hm.SyncToHero() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := heroStore.Save(ctx, hm.Hero); err != nil { return } } // BeginHeroMeetPairLocked starts a meet between two resident heroes. Caller must hold e.mu. // On failure, reason is a stable code for logs and admin API (not shown to players). func (e *Engine) BeginHeroMeetPairLocked(now time.Time, idA, idB int64) (ok bool, reason string) { if e.roadGraph == nil { return false, "road_graph_nil" } if idA == idB { return false, "same_hero" } ha, okA := e.movements[idA] hb, okB := e.movements[idB] if !okA || !okB || ha == nil || hb == nil || ha.Hero == nil || hb.Hero == nil { return false, "movement_or_hero_nil" } if _, inA := e.combats[idA]; inA { return false, "hero_a_in_combat" } if _, inB := e.combats[idB]; inB { return false, "hero_b_in_combat" } if ha.State != model.StateWalking || hb.State != model.StateWalking { return false, "not_both_walking" } if ha.Excursion.Active() || hb.Excursion.Active() { return false, "excursion_already_active" } if !ha.WanderingMerchantDeadline.IsZero() || !hb.WanderingMerchantDeadline.IsZero() { return false, "wandering_merchant_pending" } x1, y1 := ha.worldPositionAt(now) x2, y2 := hb.worldPositionAt(now) if e.roadGraph.HeroInTownAt(x1, y1) || e.roadGraph.HeroInTownAt(x2, y2) { return false, "hero_position_in_town_radius" } cfg := tuning.Get() offlineBudget := randomHeroMeetOfflineDuration(cfg) offlineMs := offlineBudget.Milliseconds() if offlineMs < 0 { offlineMs = 0 } anchorX := (x1 + x2) / 2 anchorY := (y1 + y2) / 2 off := cfg.HeroMeetStandHalfOffsetWorld if off <= 0 { off = tuning.DefaultValues().HeroMeetStandHalfOffsetWorld } ax, ay := anchorX-off, anchorY bx, by := anchorX+off, anchorY lo, _ := heroMeetOrderedPair(idA, idB) leader := ha follower := hb if idA != lo { leader, follower = hb, ha } base := model.ExcursionSession{ Kind: model.ExcursionKindHeroMeet, Phase: model.ExcursionOut, StartedAt: now, HeroMeetAnchorX: anchorX, HeroMeetAnchorY: anchorY, HeroMeetTurnHeroID: lo, HeroMeetAutoLineIdx: 0, HeroMeetOfflineRemainingMs: offlineMs, } leader.Excursion = base leader.Excursion.HeroMeetPartnerID = follower.HeroID leader.Excursion.RoadFreezeWaypoint = leader.WaypointIndex leader.Excursion.RoadFreezeFraction = leader.WaypointFraction leader.Excursion.StartX = leader.CurrentX leader.Excursion.StartY = leader.CurrentY if leader.HeroID == idA { leader.Excursion.AttractorX, leader.Excursion.AttractorY = ax, ay } else { leader.Excursion.AttractorX, leader.Excursion.AttractorY = bx, by } leader.Excursion.AttractorSet = true follower.Excursion = base follower.Excursion.HeroMeetPartnerID = leader.HeroID follower.Excursion.RoadFreezeWaypoint = follower.WaypointIndex follower.Excursion.RoadFreezeFraction = follower.WaypointFraction follower.Excursion.StartX = follower.CurrentX follower.Excursion.StartY = follower.CurrentY if follower.HeroID == idA { follower.Excursion.AttractorX, follower.Excursion.AttractorY = ax, ay } else { follower.Excursion.AttractorX, follower.Excursion.AttractorY = bx, by } follower.Excursion.AttractorSet = true e.syncHeroMeetPartnerExcursion(leader, follower) e.heroMeetLastRoll[idA] = now e.heroMeetLastRoll[idB] = now e.persistHeroPairAfterMeetChangeLocked(idA, idB) lingerOut := cfg.HeroMeetPartnerLingerMs if lingerOut <= 0 { lingerOut = tuning.DefaultValues().HeroMeetPartnerLingerMs } e.pushHeroMeetStartLocked(idA, lingerOut, "out") e.pushHeroMeetStartLocked(idB, lingerOut, "out") return true, "" } // pushHeroMeetStartLocked sends hero_meet_start. meetPhase is "out" (approach) or "meet" (dialogue). func (e *Engine) pushHeroMeetStartLocked(heroID int64, lingerMs int64, meetPhase string) { hm, ok := e.movements[heroID] if !ok || hm == nil || hm.Hero == nil || e.sender == nil { return } if hm.Excursion.Kind != model.ExcursionKindHeroMeet || !hm.Excursion.Active() { return } switch meetPhase { case "out": if hm.Excursion.Phase != model.ExcursionOut { return } case "meet": if hm.Excursion.Phase != model.ExcursionPhaseHeroMeet { return } default: return } pid := hm.Excursion.HeroMeetPartnerID ph, okp := e.movements[pid] if !okp || ph == nil || ph.Hero == nil { return } // Frozen meet stand: sync partner model so snapshot matches DB / hero_move (no drift during session). ph.SyncToHero() hm.SyncToHero() px, py := ph.Hero.PositionX, ph.Hero.PositionY partner := model.HeroMeetPartnerSnapshot{ ID: ph.Hero.ID, Name: ph.Hero.Name, Level: ph.Hero.Level, PositionX: px, PositionY: py, } anyOnline := e.heroMeetAnySubscriberOnline(heroID, pid) var promptEnds *time.Time if meetPhase == "meet" && anyOnline && hm.Excursion.HeroMeetSubPhase == model.HeroMeetSubPrompt { t := hm.Excursion.HeroMeetPromptUntil promptEnds = &t } hm.Hero.EnsureGearMap() hm.Hero.RefreshDerivedCombatStats(time.Now()) e.sender.SendToHero(heroID, "hero_state", hm.Hero) e.sender.SendToHero(heroID, "hero_meet_start", model.HeroMeetStartPayload{ Partner: partner, AnySideOnline: anyOnline, PromptEndsAt: promptEnds, PartnerLingerMs: lingerMs, MeetPhase: meetPhase, }) } func (e *Engine) persistHeroPairAfterMeetChangeLocked(a, b int64) { if e.heroStore == nil { return } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() for _, id := range []int64{a, b} { hm := e.movements[id] if hm == nil || hm.Hero == nil { continue } hm.SyncToHero() _ = e.heroStore.Save(ctx, hm.Hero) } } // EndHeroMeetPairLocked ends the session for both heroes. Caller must hold e.mu. // user_end while in dialogue starts a return walk to pre-meet positions; abrupt reasons snap back to the road immediately. func (e *Engine) EndHeroMeetPairLocked(lo, hi int64, now time.Time, reason string) { ha, okA := e.movements[lo] hb, okB := e.movements[hi] if !okA || !okB { return } cfg := tuning.Get() linger := cfg.HeroMeetPartnerLingerMs if linger <= 0 { linger = tuning.DefaultValues().HeroMeetPartnerLingerMs } endPayload := model.HeroMeetEndPayload{Reason: reason, PartnerLingerMs: linger} userEndEarly := reason == "user_end" && ha.Excursion.Kind == model.ExcursionKindHeroMeet && (ha.Excursion.Phase != model.ExcursionPhaseHeroMeet || hb.Excursion.Phase != model.ExcursionPhaseHeroMeet) abruptEnd := reason == "partner_gone" || reason == "offline_timer" || userEndEarly if abruptEnd { for _, hm := range []*HeroMovement{ha, hb} { if hm == nil || hm.Hero == nil { continue } if hm.Excursion.Kind != model.ExcursionKindHeroMeet { continue } hm.clearHeroMeetResumeWalking(now) hm.SyncToHero() if e.sender != nil { hm.Hero.EnsureGearMap() hm.Hero.RefreshDerivedCombatStats(now) e.sender.SendToHero(hm.HeroID, "hero_meet_end", endPayload) e.sender.SendToHero(hm.HeroID, "hero_state", hm.Hero) e.sender.SendToHero(hm.HeroID, "hero_move", hm.MovePayload(now)) if route := hm.RoutePayload(); route != nil { e.sender.SendToHero(hm.HeroID, "route_assigned", route) } } } if e.heroStore != nil { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = e.heroStore.Save(ctx, ha.Hero) _ = e.heroStore.Save(ctx, hb.Hero) } return } for _, hm := range []*HeroMovement{ha, hb} { if hm == nil || hm.Hero == nil { continue } if hm.Excursion.Kind != model.ExcursionKindHeroMeet { continue } hm.Excursion.Phase = model.ExcursionReturn hm.Excursion.AttractorX = hm.Excursion.StartX hm.Excursion.AttractorY = hm.Excursion.StartY hm.Excursion.AttractorSet = true hm.SyncToHero() if e.sender != nil { hm.Hero.EnsureGearMap() hm.Hero.RefreshDerivedCombatStats(now) e.sender.SendToHero(hm.HeroID, "hero_meet_end", endPayload) e.sender.SendToHero(hm.HeroID, "hero_state", hm.Hero) e.sender.SendToHero(hm.HeroID, "hero_move", hm.MovePayload(now)) if route := hm.RoutePayload(); route != nil { e.sender.SendToHero(hm.HeroID, "route_assigned", route) } } } if la, lb := e.movements[lo], e.movements[hi]; la != nil && lb != nil { e.syncHeroMeetPartnerExcursion(la, lb) } if e.heroStore != nil { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = e.heroStore.Save(ctx, ha.Hero) _ = e.heroStore.Save(ctx, hb.Hero) } } func (e *Engine) tryRandomHeroMeetProximityLocked(now time.Time) { if e.roadGraph == nil || len(e.movements) < 2 { return } cfg := tuning.Get() radius := cfg.HeroMeetRadiusWorld if radius <= 0 { radius = tuning.DefaultValues().HeroMeetRadiusWorld } ch := cfg.HeroMeetChancePerTick if ch <= 0 { ch = tuning.DefaultValues().HeroMeetChancePerTick } cd := time.Duration(cfg.HeroMeetCooldownMs) * time.Millisecond if cd <= 0 { cd = time.Duration(tuning.DefaultValues().HeroMeetCooldownMs) * time.Millisecond } ids := make([]int64, 0, len(e.movements)) for id := range e.movements { ids = append(ids, id) } for i := 0; i < len(ids); i++ { for j := i + 1; j < len(ids); j++ { a, b := ids[i], ids[j] lo, hi := heroMeetOrderedPair(a, b) ha := e.movements[lo] hb := e.movements[hi] if ha == nil || hb == nil { continue } if now.Sub(e.heroMeetLastRoll[lo]) < cd || now.Sub(e.heroMeetLastRoll[hi]) < cd { continue } x1, y1 := ha.worldPositionAt(now) x2, y2 := hb.worldPositionAt(now) dx, dy := x1-x2, y1-y2 if dx*dx+dy*dy > radius*radius { continue } if rand.Float64() >= ch { continue } if started, _ := e.BeginHeroMeetPairLocked(now, lo, hi); started { return } } } } func (e *Engine) processHeroMeetTickLocked(now time.Time) { if len(e.movements) < 2 { return } seen := make(map[string]struct{}) for id, hm := range e.movements { if hm == nil || hm.Excursion.Kind != model.ExcursionKindHeroMeet || !hm.Excursion.Active() { continue } partnerID := hm.Excursion.HeroMeetPartnerID if partnerID == 0 { continue } lo, hi := heroMeetOrderedPair(id, partnerID) key := fmt.Sprintf("%d_%d", lo, hi) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} leader := e.movements[lo] partner := e.movements[hi] if leader == nil || partner == nil { e.EndHeroMeetPairLocked(lo, hi, now, "partner_gone") continue } ex := &leader.Excursion if ex.Phase != model.ExcursionPhaseHeroMeet { e.syncHeroMeetPartnerExcursion(leader, partner) continue } anyOnline := e.heroMeetAnySubscriberOnline(lo, hi) cfg := tuning.Get() autoInt := cfg.HeroMeetAutoLineIntervalMs if autoInt <= 0 { autoInt = tuning.DefaultValues().HeroMeetAutoLineIntervalMs } // Offline wall timer if anyOnline { if ex.HeroMeetOfflineTimerRunning { rem := ex.HeroMeetOfflineDeadline.Sub(now) if rem < 0 { rem = 0 } ex.HeroMeetOfflineRemainingMs = rem.Milliseconds() ex.HeroMeetOfflineTimerRunning = false } } else { if !ex.HeroMeetOfflineTimerRunning { ex.HeroMeetOfflineTimerRunning = true ex.HeroMeetOfflineDeadline = now.Add(time.Duration(ex.HeroMeetOfflineRemainingMs) * time.Millisecond) } if now.After(ex.HeroMeetOfflineDeadline) { e.EndHeroMeetPairLocked(lo, hi, now, "offline_timer") continue } } // Scripted auto lines: off if both players have WS; both offline → alternate; one offline → only that hero speaks. loOn := e.heroMeetPlayerOnline(lo) hiOn := e.heroMeetPlayerOnline(hi) emitAuto := !(loOn && hiOn) if emitAuto && !now.Before(ex.HeroMeetNextAutoAt) { e.emitHeroMeetAutoLineLocked(lo, hi, now) ex.HeroMeetNextAutoAt = now.Add(time.Duration(autoInt) * time.Millisecond) } e.syncHeroMeetPartnerExcursion(leader, partner) } } func (e *Engine) emitHeroMeetAutoLineLocked(lo, hi int64, now time.Time) { leader := e.movements[lo] if leader == nil || leader.Hero == nil { return } if leader.Excursion.Phase != model.ExcursionPhaseHeroMeet { return } ex := &leader.Excursion loOn := e.heroMeetPlayerOnline(lo) hiOn := e.heroMeetPlayerOnline(hi) if loOn && hiOn { return } var speakerID int64 if !loOn && !hiOn { speakerID = ex.HeroMeetTurnHeroID if speakerID != lo && speakerID != hi { speakerID = lo } } else { // Exactly one online: scripted lines only from the offline hero. if !loOn { speakerID = lo } else { speakerID = hi } } sh := e.movements[speakerID] if sh == nil || sh.Hero == nil { return } lineKey := model.HeroMeetAutoPhraseKey(ex.HeroMeetAutoLineIdx) ex.HeroMeetAutoLineIdx++ if !loOn && !hiOn { if speakerID == lo { ex.HeroMeetTurnHeroID = hi } else { ex.HeroMeetTurnHeroID = lo } } else { // Mixed: point turn at the online hero so when they disconnect, alternation resumes naturally. if speakerID == lo { ex.HeroMeetTurnHeroID = hi } else { ex.HeroMeetTurnHeroID = lo } } name := sh.Hero.Name if e.adventureLog != nil { line := model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogPhraseHeroMeetScripted, Args: map[string]any{"speaker": name, "lineKey": lineKey}, }, } e.adventureLog(lo, line) e.adventureLog(hi, line) } if e.sender != nil { payload := model.HeroMeetLinePayload{ FromHeroID: speakerID, Kind: "scripted", LineKey: lineKey, } e.sender.SendToHero(lo, "hero_meet_line", payload) e.sender.SendToHero(hi, "hero_meet_line", payload) } partner := e.movements[hi] if partner != nil { e.syncHeroMeetPartnerExcursion(leader, partner) } } // pushHeroMeetIfActiveLocked sends hero_meet_start when the hero is in dialogue meet phase. Caller holds e.mu. func (e *Engine) pushHeroMeetIfActiveLocked(heroID int64) { hm := e.movements[heroID] if hm == nil || hm.Excursion.Kind != model.ExcursionKindHeroMeet || !hm.Excursion.Active() { return } cfg := tuning.Get() linger := cfg.HeroMeetPartnerLingerMs if linger <= 0 { linger = tuning.DefaultValues().HeroMeetPartnerLingerMs } if hm.Excursion.Phase != "" { e.pushHeroMeetStartLocked(heroID, linger, string(hm.Excursion.Phase)) return } if e.sender != nil && hm.Hero != nil { hm.SyncToHero() e.sender.SendToHero(heroID, "hero_state", hm.Hero) e.sender.SendToHero(heroID, "hero_move", hm.MovePayload(time.Now())) } } // ApplyAdminStartHeroMeet teleports other to primary and starts a meet (online heroes only). // reason is set when ok is false (for logs and admin JSON detail). func (e *Engine) ApplyAdminStartHeroMeet(primaryID, otherID int64) (hero *model.Hero, ok bool, reason string) { e.mu.Lock() defer e.mu.Unlock() logReject := func(r string, extra ...any) (*model.Hero, bool, string) { if e.logger != nil { args := append([]any{"reason", r, "primary_id", primaryID, "other_id", otherID}, extra...) e.logger.Warn("admin hero_meet start rejected", args...) } return nil, false, r } if primaryID == otherID { return logReject("same_hero") } hp := e.movements[primaryID] ho := e.movements[otherID] if hp == nil || ho == nil || hp.Hero == nil || ho.Hero == nil { return logReject("movement_not_in_engine", "has_primary_movement", hp != nil, "has_other_movement", ho != nil) } if _, ok := e.combats[primaryID]; ok { return logReject("primary_in_combat") } if _, ok := e.combats[otherID]; ok { return logReject("other_in_combat") } now := time.Now() px, py := hp.worldPositionAt(now) cfg := tuning.Get() sep := cfg.HeroMeetAdminSnapSeparationWorld if sep <= 0 { sep = tuning.DefaultValues().HeroMeetAdminSnapSeparationWorld } ho.DestinationTownID = hp.DestinationTownID ho.CurrentTownID = hp.CurrentTownID ho.WaypointIndex = hp.WaypointIndex ho.WaypointFraction = hp.WaypointFraction ho.Excursion.RoadFreezeFraction = hp.Excursion.RoadFreezeFraction ho.Excursion.RoadFreezeWaypoint = hp.Excursion.RoadFreezeWaypoint teleportHeroTowardWorldPoint(ho, px, py, sep, e.heroStore) if started, r := e.BeginHeroMeetPairLocked(now, primaryID, otherID); !started { if e.logger != nil { e.logger.Warn("admin hero_meet start rejected after teleport snap", "reason", r, "primary_id", primaryID, "other_id", otherID, "primary_state", hp.State, "other_state", ho.State, "primary_excursion_active", hp.Excursion.Active(), "other_excursion_active", ho.Excursion.Active(), "primary_wm_deadline_set", !hp.WanderingMerchantDeadline.IsZero(), "other_wm_deadline_set", !ho.WanderingMerchantDeadline.IsZero(), ) } return nil, false, r } out := e.movements[primaryID] if out == nil || out.Hero == nil { if e.logger != nil { e.logger.Error("admin hero_meet inconsistent: primary missing after begin", "primary_id", primaryID, "other_id", otherID) } return nil, false, "internal_primary_missing_after_begin" } return out.Hero, true, "" } // NotifyHeroMeetAfterWSConnect pushes meet UI state after WS connect (dialogue start or approach snapshot). func (e *Engine) NotifyHeroMeetAfterWSConnect(heroID int64) { e.mu.Lock() defer e.mu.Unlock() hm := e.movements[heroID] if hm == nil || hm.Excursion.Kind != model.ExcursionKindHeroMeet || !hm.Excursion.Active() { return } e.pushHeroMeetIfActiveLocked(heroID) pid := hm.Excursion.HeroMeetPartnerID if pid != 0 { e.pushHeroMeetIfActiveLocked(pid) } } func (e *Engine) handleHeroMeetSendMessage(msg IncomingMessage) { var p model.HeroMeetSendMessagePayload if err := json.Unmarshal(msg.Payload, &p); err != nil { e.sendError(msg.HeroID, "invalid_payload", "invalid hero_meet_send_message") return } text := trimHeroMeetMessage(p.Text) maxR := tuning.Get().HeroMeetMessageMaxRunes if maxR <= 0 { maxR = tuning.DefaultValues().HeroMeetMessageMaxRunes } if utf8.RuneCountInString(text) > maxR { e.sendError(msg.HeroID, "message_too_long", "message too long") return } if text == "" { e.sendError(msg.HeroID, "empty_message", "empty message") return } if profanity.ChatMessageIsProfane(text) { e.sendError(msg.HeroID, "profanity", "message rejected") return } e.mu.Lock() defer e.mu.Unlock() hm := e.movements[msg.HeroID] if hm == nil || hm.Hero == nil { e.sendError(msg.HeroID, "no_hero", "hero not active") return } if hm.Excursion.Kind != model.ExcursionKindHeroMeet || !hm.Excursion.Active() { e.sendError(msg.HeroID, "not_in_meet", "not in hero meet") return } if hm.Excursion.Phase != model.ExcursionPhaseHeroMeet { e.sendError(msg.HeroID, "not_in_dialogue", "meet dialogue not active") return } if !e.heroMeetAnySubscriberOnline(msg.HeroID, hm.Excursion.HeroMeetPartnerID) { e.sendError(msg.HeroID, "offline", "cannot chat while offline") return } cd := time.Duration(tuning.Get().HeroMeetMessageCooldownMs) * time.Millisecond if cd <= 0 { cd = time.Duration(tuning.DefaultValues().HeroMeetMessageCooldownMs) * time.Millisecond } now := time.Now() if t := e.heroMeetLastMsg[msg.HeroID]; !t.IsZero() && now.Sub(t) < cd { e.sendError(msg.HeroID, "rate_limited", "slow down") return } e.heroMeetLastMsg[msg.HeroID] = now pid := hm.Excursion.HeroMeetPartnerID lo, hi := heroMeetOrderedPair(msg.HeroID, pid) leader := e.movements[lo] partner := e.movements[hi] if leader == nil || partner == nil { return } leader.Excursion.HeroMeetHadPlayerMessage = true e.syncHeroMeetPartnerExcursion(leader, partner) name := hm.Hero.Name if e.adventureLog != nil { line := model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogPhraseHeroMeetPlayerSaid, Args: map[string]any{"speaker": name, "text": text}, }, } e.adventureLog(lo, line) e.adventureLog(hi, line) } payload := model.HeroMeetLinePayload{FromHeroID: msg.HeroID, Kind: "player", Text: text} if e.sender != nil { e.sender.SendToHero(lo, "hero_meet_line", payload) e.sender.SendToHero(hi, "hero_meet_line", payload) } } func (e *Engine) handleHeroMeetEndConversation(msg IncomingMessage) { e.mu.Lock() defer e.mu.Unlock() hm := e.movements[msg.HeroID] if hm == nil || hm.Excursion.Kind != model.ExcursionKindHeroMeet || !hm.Excursion.Active() { return } pid := hm.Excursion.HeroMeetPartnerID if pid == 0 { return } if !e.heroMeetAnySubscriberOnline(msg.HeroID, pid) { return } lo, hi := heroMeetOrderedPair(msg.HeroID, pid) e.EndHeroMeetPairLocked(lo, hi, time.Now(), "user_end") } func trimHeroMeetMessage(s string) string { // trim ASCII whitespace; keep inner spaces for len(s) > 0 && (s[0] == ' ' || s[0] == '\t' || s[0] == '\n' || s[0] == '\r') { s = s[1:] } for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t' || s[len(s)-1] == '\n' || s[len(s)-1] == '\r') { s = s[:len(s)-1] } return s }