hero meet
parent
0c7468cc55
commit
532c4f4dfd
@ -0,0 +1,944 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
// HeroMeetAutoLineSlugs are stable ids for auto-dialogue (client localizes hero_meet.auto.<slug>).
|
||||||
|
var HeroMeetAutoLineSlugs = []string{
|
||||||
|
"nod_traveler",
|
||||||
|
"quiet_road_today",
|
||||||
|
"heard_rumors_beasts",
|
||||||
|
"gear_clinks_soft",
|
||||||
|
"stay_safe_out_there",
|
||||||
|
"short_rest_then_go",
|
||||||
|
"sun_in_eyes",
|
||||||
|
"paths_cross_again",
|
||||||
|
"trade_news_smile",
|
||||||
|
"wind_picks_up",
|
||||||
|
"good_luck_hunt",
|
||||||
|
"watch_the_brush",
|
||||||
|
}
|
||||||
|
|
||||||
|
// HeroMeetAutoPhraseKey returns phrase key e.g. hero_meet.auto.nod_traveler for log / WS.
|
||||||
|
func HeroMeetAutoPhraseKey(lineIdx int) string {
|
||||||
|
if len(HeroMeetAutoLineSlugs) == 0 {
|
||||||
|
return "hero_meet.auto.fallback"
|
||||||
|
}
|
||||||
|
if lineIdx < 0 {
|
||||||
|
lineIdx = 0
|
||||||
|
}
|
||||||
|
slug := HeroMeetAutoLineSlugs[lineIdx%len(HeroMeetAutoLineSlugs)]
|
||||||
|
return "hero_meet.auto." + slug
|
||||||
|
}
|
||||||
@ -0,0 +1,141 @@
|
|||||||
|
import { useCallback, useState, type CSSProperties } from 'react';
|
||||||
|
import type { GameWebSocket } from '../network/websocket';
|
||||||
|
import { useT } from '../i18n';
|
||||||
|
import { sendHeroMeetEndConversation, sendHeroMeetMessage } from '../game/ws-handler';
|
||||||
|
|
||||||
|
const panelStyle: CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 130,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
minWidth: 240,
|
||||||
|
maxWidth: 340,
|
||||||
|
backgroundColor: 'rgba(15, 15, 25, 0.94)',
|
||||||
|
border: '1px solid rgba(34, 211, 238, 0.35)',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: '10px 14px 12px',
|
||||||
|
zIndex: 125,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.5)',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface HeroMeetPanelProps {
|
||||||
|
partnerName: string;
|
||||||
|
anySideOnline: boolean;
|
||||||
|
ws: GameWebSocket | null;
|
||||||
|
maxChars?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeroMeetPanel({
|
||||||
|
partnerName,
|
||||||
|
anySideOnline,
|
||||||
|
ws,
|
||||||
|
maxChars = 140,
|
||||||
|
}: HeroMeetPanelProps) {
|
||||||
|
const tr = useT();
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
|
||||||
|
const send = useCallback(() => {
|
||||||
|
const t = text.trim();
|
||||||
|
if (!t || !ws) return;
|
||||||
|
sendHeroMeetMessage(ws, t);
|
||||||
|
setText('');
|
||||||
|
}, [text, ws]);
|
||||||
|
|
||||||
|
const endConv = useCallback(() => {
|
||||||
|
if (ws) sendHeroMeetEndConversation(ws);
|
||||||
|
}, [ws]);
|
||||||
|
|
||||||
|
const len = [...text].length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={panelStyle}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#e0f7fa',
|
||||||
|
marginBottom: 8,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tr.heroMeetTitle}: {partnerName}
|
||||||
|
</div>
|
||||||
|
{anySideOnline ? (
|
||||||
|
<>
|
||||||
|
<textarea
|
||||||
|
value={text}
|
||||||
|
maxLength={maxChars}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
placeholder={interpolate(tr.heroMeetPlaceholder, { max: maxChars })}
|
||||||
|
rows={2}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
resize: 'none',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid rgba(255,255,255,0.15)',
|
||||||
|
background: 'rgba(0,0,0,0.35)',
|
||||||
|
color: '#e8f8ff',
|
||||||
|
fontSize: 13,
|
||||||
|
padding: '8px 10px',
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: 10, color: '#7dd3fc', marginBottom: 6, textAlign: 'right' }}>
|
||||||
|
{interpolate(tr.heroMeetCharCount, { current: len, max: maxChars })}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={send}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '8px 10px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: 'none',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 12,
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: 'rgba(34, 211, 238, 0.25)',
|
||||||
|
color: '#a5f3fc',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tr.heroMeetSend}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={endConv}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '8px 10px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid rgba(248, 113, 113, 0.4)',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 12,
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: 'rgba(248, 113, 113, 0.12)',
|
||||||
|
color: '#fecaca',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tr.heroMeetEndConversation}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 11, color: '#94a3b8', textAlign: 'center' }}>
|
||||||
|
{partnerName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolate(template: string, vars: Record<string, string | number>): string {
|
||||||
|
let s = template;
|
||||||
|
for (const [k, v] of Object.entries(vars)) {
|
||||||
|
const needle = `{${k}}`;
|
||||||
|
s = s.split(needle).join(String(v));
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,2 @@
|
|||||||
|
declare const _default: import("vite").UserConfig;
|
||||||
|
export default _default;
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': '/src',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/ws': {
|
||||||
|
target: 'ws://localhost:8080',
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
target: 'es2020',
|
||||||
|
sourcemap: true,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
pixi: ['pixi.js'],
|
||||||
|
react: ['react', 'react-dom'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue