Compare commits
No commits in common. 'bc510fb9c72515f6ca5d11949ff184c35dce49f6' and 'a138b6583d2a81b4d486d1fc85ba5f7d0e9d08e0' have entirely different histories.
bc510fb9c7
...
a138b6583d
@ -1,65 +0,0 @@
|
|||||||
// Command seedrandomheroes inserts N level-1 heroes with random spawn/starter gear (not a migration).
|
|
||||||
// Usage: from backend/: go run ./cmd/seedrandomheroes -n 500
|
|
||||||
// Requires DB env vars (same as server): DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"math/rand"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/denisovdennis/autohero/internal/config"
|
|
||||||
"github.com/denisovdennis/autohero/internal/storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
n := flag.Int("n", 500, "number of heroes to create")
|
|
||||||
telegramBase := flag.Int64("telegram-base", 8_100_000_000_000, "first synthetic telegram_id (each hero gets base+0..n-1)")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
|
||||||
cfg := config.Load()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
pool, err := storage.NewPostgres(ctx, cfg.DB, logger)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("postgres", "error", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer pool.Close()
|
|
||||||
|
|
||||||
store := storage.NewHeroStore(pool, logger)
|
|
||||||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
|
||||||
|
|
||||||
prefixes := []string{
|
|
||||||
"Aldo", "Bree", "Cade", "Dara", "Ewan", "Fira", "Gorn", "Hett", "Ivor", "Jesa",
|
|
||||||
"Kael", "Lina", "Miro", "Nyx", "Orin", "Pike", "Quin", "Riva", "Sven", "Tess",
|
|
||||||
"Ulric", "Venn", "Wren", "Yara", "Zara",
|
|
||||||
}
|
|
||||||
|
|
||||||
var ok, fail int
|
|
||||||
for i := 0; i < *n; i++ {
|
|
||||||
tg := *telegramBase + int64(i)
|
|
||||||
// Unique per batch: idx_heroes_name_lower — include telegramBase so re-runs with new -telegram-base never collide.
|
|
||||||
name := fmt.Sprintf("%s_%d_%05d", prefixes[rng.Intn(len(prefixes))], *telegramBase, i)
|
|
||||||
_, err := store.CreateHeroWithSpawn(ctx, tg, name)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warn("create failed", "i", i, "telegram_id", tg, "name", name, "error", err)
|
|
||||||
fail++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ok++
|
|
||||||
if ok%100 == 0 {
|
|
||||||
logger.Info("progress", "created", ok)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("done", "created", ok, "failed", fail, "requested", *n)
|
|
||||||
if fail > 0 {
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,927 +0,0 @@
|
|||||||
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 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 = randomDurationBetweenMs(240_000, 360_000)
|
|
||||||
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 := randomDurationBetweenMs(240_000, 360_000)
|
|
||||||
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.RandomHeroMeetAutoPhraseKey()
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import "math/rand"
|
|
||||||
|
|
||||||
// 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",
|
|
||||||
"same_road_twice",
|
|
||||||
"water_skin_low",
|
|
||||||
"campfire_smoke_ahead",
|
|
||||||
"no_coin_no_story",
|
|
||||||
"armor_pinch_reminder",
|
|
||||||
"storm_smell_air",
|
|
||||||
"map_wrong_fold",
|
|
||||||
"heard_city_bells",
|
|
||||||
"strap_mended_maybe",
|
|
||||||
"monster_or_mud",
|
|
||||||
"share_rations_nod",
|
|
||||||
"night_cold_early",
|
|
||||||
"footprints_cross_yours",
|
|
||||||
"quiet_not_safe",
|
|
||||||
"merchant_lied_once",
|
|
||||||
"birds_flew_strange",
|
|
||||||
}
|
|
||||||
|
|
||||||
// HeroMeetAutoPhraseKey returns phrase key by index (legacy tests); prefer RandomHeroMeetAutoPhraseKey for emits.
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// RandomHeroMeetAutoPhraseKey picks a random auto line (offline / scripted hero-meet dialogue).
|
|
||||||
func RandomHeroMeetAutoPhraseKey() string {
|
|
||||||
if len(HeroMeetAutoLineSlugs) == 0 {
|
|
||||||
return "hero_meet.auto.fallback"
|
|
||||||
}
|
|
||||||
slug := HeroMeetAutoLineSlugs[rand.Intn(len(HeroMeetAutoLineSlugs))]
|
|
||||||
return "hero_meet.auto." + slug
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestRandomHeroMeetAutoPhraseKeyNonEmpty(t *testing.T) {
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
k := RandomHeroMeetAutoPhraseKey()
|
|
||||||
if k == "" || len(k) < len("hero_meet.auto.") {
|
|
||||||
t.Fatalf("unexpected key %q", k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE public.heroes
|
|
||||||
DROP COLUMN IF EXISTS move_state;
|
|
||||||
@ -1,283 +0,0 @@
|
|||||||
-- 20 new towns: 5 inner spiral + 15 outer spiral; roads; NPCs (duplicate merchants / dual quest givers where noted); quests; localization keys.
|
|
||||||
-- Town order in engine remains ORDER BY level_min from DB — existing towns unchanged.
|
|
||||||
|
|
||||||
INSERT INTO public.towns (id, name, name_key, biome, world_x, world_y, radius, level_min, level_max, created_at) VALUES
|
|
||||||
(12, 'Silverstep', 'town.silverstep.v1', 'meadow', 6172.1, 4478.1, 8, 4, 10, now()),
|
|
||||||
(13, 'Copperfield', 'town.copperfield.v1', 'forest', 4959.5, 5590.9, 9, 6, 12, now()),
|
|
||||||
(14, 'Ashford', 'town.ashford.v1', 'ruins', 3526.5, 4781.5, 7, 3, 9, now()),
|
|
||||||
(15, 'Millbrook', 'town.millbrook.v1', 'meadow', 3853.4, 3168.5, 8, 5, 11, now()),
|
|
||||||
(16, 'Stonebend', 'town.stonebend.v1', 'canyon', 5488.5, 2981, 10, 8, 14, now()),
|
|
||||||
(17, 'Highspire', 'town.highspire.v1', 'astral', 10992.3, 4509.9, 18, 40, 46, now()),
|
|
||||||
(18, 'Saltmere', 'town.saltmere.v1', 'swamp', 10330.9, 7001.7, 16, 38, 44, now()),
|
|
||||||
(19, 'Ironpost', 'town.ironpost.v1', 'canyon', 8713.1, 9009.1, 17, 42, 48, now()),
|
|
||||||
(20, 'Greyfen', 'town.greyfen.v1', 'swamp', 6418.8, 10184.9, 15, 35, 41, now()),
|
|
||||||
(21, 'Dunewatch', 'town.dunewatch.v1', 'meadow', 3844.6, 10325.9, 14, 36, 42, now()),
|
|
||||||
(22, 'Coldbarrow', 'town.coldbarrow.v1', 'ruins', 1435.5, 9407.7, 14, 33, 39, now()),
|
|
||||||
(23, 'Mistral', 'town.mistral.v1', 'forest', -391.8, 7589, 13, 31, 37, now()),
|
|
||||||
(24, 'Hollowmere', 'town.hollowmere.v1', 'swamp', -1321.4, 5184.3, 12, 29, 35, now()),
|
|
||||||
(25, 'Ashfen', 'town.ashfen.v1', 'volcanic', -1192.5, 2609.5, 12, 27, 33, now()),
|
|
||||||
(26, 'Thornmere', 'town.thornmere.v1', 'forest', -27.5, 309.6, 11, 25, 31, now()),
|
|
||||||
(27, 'Windgarde', 'town.windgarde.v1', 'meadow', 1972.2, -1317.6, 11, 23, 29, now()),
|
|
||||||
(28, 'Frosthollow', 'town.frosthollow.v1', 'ruins', 4460.9, -1990.7, 19, 45, 52, now()),
|
|
||||||
(29, 'Sungrasp', 'town.sungrasp.v1', 'canyon', 7008.2, -1593.4, 20, 47, 53, now()),
|
|
||||||
(30, 'Glimmerford', 'town.glimmerford.v1', 'meadow', 9173.7, -194.4, 21, 49, 55, now()),
|
|
||||||
(31, 'Starveil', 'town.starveil.v1', 'astral', 10582.9, 1964.5, 22, 51, 60, now());
|
|
||||||
|
|
||||||
-- Buildings: 12 & 20 have two quest_giver halls + two merchants + healer + dec.
|
|
||||||
INSERT INTO public.town_buildings (id, town_id, building_type, offset_x, offset_y, facing, footprint_w, footprint_h, created_at) VALUES
|
|
||||||
(49, 12, 'house.quest_giver', -6.3, -3.5, 'south', 2.5, 2, now()),
|
|
||||||
(50, 12, 'house.quest_giver', 6.3, -3.5, 'south', 2.5, 2, now()),
|
|
||||||
(51, 12, 'house.merchant', -5.5, 3.2, 'south', 2.5, 2, now()),
|
|
||||||
(52, 12, 'house.merchant', 5.5, 3.2, 'south', 2.5, 2, now()),
|
|
||||||
(53, 12, 'house.healer', 0, 7.8, 'south', 2.5, 2, now()),
|
|
||||||
(54, 12, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
|
|
||||||
(55, 12, 'decoration.signpost', 0, 9.6, 'south', 0.5, 0.5, now()),
|
|
||||||
(56, 13, 'house.quest_giver', -4.8, -3.5, 'south', 2.5, 2, now()),
|
|
||||||
(57, 13, 'house.merchant', 6.3, -3.5, 'south', 2.5, 2, now()),
|
|
||||||
(58, 13, 'house.merchant', -4.5, 3.5, 'south', 2.5, 2, now()),
|
|
||||||
(59, 13, 'house.healer', 3, 7.2, 'south', 2.5, 2, now()),
|
|
||||||
(60, 13, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
|
|
||||||
(61, 13, 'decoration.signpost', 0, 8.4, 'south', 0.5, 0.5, now()),
|
|
||||||
(62, 14, 'house.quest_giver', -4.8, -3.5, 'south', 2.5, 2, now()),
|
|
||||||
(63, 14, 'house.merchant', 6.3, -3.5, 'south', 2.5, 2, now()),
|
|
||||||
(64, 14, 'house.merchant', -4.5, 3.5, 'south', 2.5, 2, now()),
|
|
||||||
(65, 14, 'house.healer', 3, 7.2, 'south', 2.5, 2, now()),
|
|
||||||
(66, 14, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
|
|
||||||
(67, 14, 'decoration.signpost', 0, 8.4, 'south', 0.5, 0.5, now()),
|
|
||||||
(68, 15, 'house.quest_giver', -4.8, -3.5, 'south', 2.5, 2, now()),
|
|
||||||
(69, 15, 'house.merchant', 6.3, -3.5, 'south', 2.5, 2, now()),
|
|
||||||
(70, 15, 'house.merchant', -4.5, 3.5, 'south', 2.5, 2, now()),
|
|
||||||
(71, 15, 'house.healer', 3, 7.2, 'south', 2.5, 2, now()),
|
|
||||||
(72, 15, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
|
|
||||||
(73, 15, 'decoration.signpost', 0, 8.4, 'south', 0.5, 0.5, now()),
|
|
||||||
(74, 16, 'house.quest_giver', -6.3, -3.5, 'south', 2.5, 2, now()),
|
|
||||||
(75, 16, 'house.merchant', 7.8, -3.5, 'south', 2.5, 2, now()),
|
|
||||||
(76, 16, 'house.merchant', -5.5, 3.2, 'south', 2.5, 2, now()),
|
|
||||||
(77, 16, 'house.healer', 3, 8.1, 'south', 2.5, 2, now()),
|
|
||||||
(78, 16, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
|
|
||||||
(79, 16, 'decoration.signpost', 0, 9.6, 'south', 0.5, 0.5, now()),
|
|
||||||
(80, 17, 'house.quest_giver', -8.1, -4.5, 'south', 2.5, 2, now()),
|
|
||||||
(81, 17, 'house.merchant', 9.6, -4.5, 'south', 2.5, 2, now()),
|
|
||||||
(82, 17, 'house.merchant', -7, 4.5, 'south', 2.5, 2, now()),
|
|
||||||
(83, 17, 'house.healer', 3, 9.3, 'south', 2.5, 2, now()),
|
|
||||||
(84, 17, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
|
|
||||||
(85, 17, 'decoration.signpost', 0, 11.4, 'south', 0.5, 0.5, now()),
|
|
||||||
(86, 18, 'house.quest_giver', -7.2, -4, 'south', 2.5, 2, now()),
|
|
||||||
(87, 18, 'house.merchant', 8.7, -4, 'south', 2.5, 2, now()),
|
|
||||||
(88, 18, 'house.merchant', -6, 4, 'south', 2.5, 2, now()),
|
|
||||||
(89, 18, 'house.healer', 3, 8.4, 'south', 2.5, 2, now()),
|
|
||||||
(90, 18, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
|
|
||||||
(91, 18, 'decoration.signpost', 0, 10.2, 'south', 0.5, 0.5, now()),
|
|
||||||
(92, 19, 'house.quest_giver', -7.2, -4, 'south', 2.5, 2, now()),
|
|
||||||
(93, 19, 'house.merchant', 8.7, -4, 'south', 2.5, 2, now()),
|
|
||||||
(94, 19, 'house.merchant', -6, 4, 'south', 2.5, 2, now()),
|
|
||||||
(95, 19, 'house.healer', 3, 8.4, 'south', 2.5, 2, now()),
|
|
||||||
(96, 19, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
|
|
||||||
(97, 19, 'decoration.signpost', 0, 10.2, 'south', 0.5, 0.5, now()),
|
|
||||||
(98, 20, 'house.quest_giver', -6.3, -3.5, 'south', 2.5, 2, now()),
|
|
||||||
(99, 20, 'house.quest_giver', 6.3, -3.5, 'south', 2.5, 2, now()),
|
|
||||||
(100, 20, 'house.merchant', -5.5, 3.2, 'south', 2.5, 2, now()),
|
|
||||||
(101, 20, 'house.merchant', 5.5, 3.2, 'south', 2.5, 2, now()),
|
|
||||||
(102, 20, 'house.healer', 0, 7.8, 'south', 2.5, 2, now()),
|
|
||||||
(103, 20, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
|
|
||||||
(104, 20, 'decoration.signpost', 0, 9.6, 'south', 0.5, 0.5, now());
|
|
||||||
|
|
||||||
INSERT INTO public.town_buildings (id, town_id, building_type, offset_x, offset_y, facing, footprint_w, footprint_h, created_at) VALUES
|
|
||||||
(105, 21, 'house.quest_giver', -4.8, -3.5, 'south', 2.5, 2, now()),
|
|
||||||
(106, 21, 'house.merchant', 6.3, -3.5, 'south', 2.5, 2, now()),
|
|
||||||
(107, 21, 'house.merchant', -4.5, 3.5, 'south', 2.5, 2, now()),
|
|
||||||
(108, 21, 'house.healer', 3, 7.2, 'south', 2.5, 2, now()),
|
|
||||||
(109, 21, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
|
|
||||||
(110, 21, 'decoration.signpost', 0, 8.4, 'south', 0.5, 0.5, now()),
|
|
||||||
(111, 22, 'house.quest_giver', -4.8, -3.5, 'south', 2.5, 2, now()),
|
|
||||||
(112, 22, 'house.merchant', 6.3, -3.5, 'south', 2.5, 2, now()),
|
|
||||||
(113, 22, 'house.merchant', -4.5, 3.5, 'south', 2.5, 2, now()),
|
|
||||||
(114, 22, 'house.healer', 3, 7.2, 'south', 2.5, 2, now()),
|
|
||||||
(115, 22, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
|
|
||||||
(116, 22, 'decoration.signpost', 0, 8.4, 'south', 0.5, 0.5, now()),
|
|
||||||
(117, 23, 'house.quest_giver', -4.8, -3.5, 'south', 2.5, 2, now()),
|
|
||||||
(118, 23, 'house.merchant', 6.3, -3.5, 'south', 2.5, 2, now()),
|
|
||||||
(119, 23, 'house.merchant', -4.5, 3.5, 'south', 2.5, 2, now()),
|
|
||||||
(120, 23, 'house.healer', 3, 7.2, 'south', 2.5, 2, now()),
|
|
||||||
(121, 23, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
|
|
||||||
(122, 23, 'decoration.signpost', 0, 8.4, 'south', 0.5, 0.5, now()),
|
|
||||||
(123, 24, 'house.quest_giver', -4.8, -3.5, 'south', 2.5, 2, now()),
|
|
||||||
(124, 24, 'house.merchant', 6.3, -3.5, 'south', 2.5, 2, now()),
|
|
||||||
(125, 24, 'house.merchant', -4.5, 3.5, 'south', 2.5, 2, now()),
|
|
||||||
(126, 24, 'house.healer', 3, 7.2, 'south', 2.5, 2, now()),
|
|
||||||
(127, 24, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
|
|
||||||
(128, 24, 'decoration.signpost', 0, 8.4, 'south', 0.5, 0.5, now()),
|
|
||||||
(129, 25, 'house.quest_giver', -4.8, -3.5, 'south', 2.5, 2, now()),
|
|
||||||
(130, 25, 'house.merchant', 6.3, -3.5, 'south', 2.5, 2, now()),
|
|
||||||
(131, 25, 'house.merchant', -4.5, 3.5, 'south', 2.5, 2, now()),
|
|
||||||
(132, 25, 'house.healer', 3, 7.2, 'south', 2.5, 2, now()),
|
|
||||||
(133, 25, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
|
|
||||||
(134, 25, 'decoration.signpost', 0, 8.4, 'south', 0.5, 0.5, now()),
|
|
||||||
(135, 26, 'house.quest_giver', -4.8, -3.5, 'south', 2.5, 2, now()),
|
|
||||||
(136, 26, 'house.merchant', 6.3, -3.5, 'south', 2.5, 2, now()),
|
|
||||||
(137, 26, 'house.merchant', -4.5, 3.5, 'south', 2.5, 2, now()),
|
|
||||||
(138, 26, 'house.healer', 3, 7.2, 'south', 2.5, 2, now()),
|
|
||||||
(139, 26, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
|
|
||||||
(140, 26, 'decoration.signpost', 0, 8.4, 'south', 0.5, 0.5, now()),
|
|
||||||
(141, 27, 'house.quest_giver', -4.8, -3.5, 'south', 2.5, 2, now()),
|
|
||||||
(142, 27, 'house.merchant', 6.3, -3.5, 'south', 2.5, 2, now()),
|
|
||||||
(143, 27, 'house.merchant', -4.5, 3.5, 'south', 2.5, 2, now()),
|
|
||||||
(144, 27, 'house.healer', 3, 7.2, 'south', 2.5, 2, now()),
|
|
||||||
(145, 27, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
|
|
||||||
(146, 27, 'decoration.signpost', 0, 8.4, 'south', 0.5, 0.5, now()),
|
|
||||||
(147, 28, 'house.quest_giver', -8.1, -4.5, 'south', 2.5, 2, now()),
|
|
||||||
(148, 28, 'house.merchant', 9.6, -4.5, 'south', 2.5, 2, now()),
|
|
||||||
(149, 28, 'house.merchant', -7, 4.5, 'south', 2.5, 2, now()),
|
|
||||||
(150, 28, 'house.healer', 3, 9.3, 'south', 2.5, 2, now()),
|
|
||||||
(151, 28, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
|
|
||||||
(152, 28, 'decoration.signpost', 0, 11.4, 'south', 0.5, 0.5, now()),
|
|
||||||
(153, 29, 'house.quest_giver', -8.1, -4.5, 'south', 2.5, 2, now()),
|
|
||||||
(154, 29, 'house.merchant', 9.6, -4.5, 'south', 2.5, 2, now()),
|
|
||||||
(155, 29, 'house.merchant', -7, 4.5, 'south', 2.5, 2, now()),
|
|
||||||
(156, 29, 'house.healer', 3, 9.3, 'south', 2.5, 2, now()),
|
|
||||||
(157, 29, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
|
|
||||||
(158, 29, 'decoration.signpost', 0, 11.4, 'south', 0.5, 0.5, now()),
|
|
||||||
(159, 30, 'house.quest_giver', -8.1, -4.5, 'south', 2.5, 2, now()),
|
|
||||||
(160, 30, 'house.merchant', 9.6, -4.5, 'south', 2.5, 2, now()),
|
|
||||||
(161, 30, 'house.merchant', -7, 4.5, 'south', 2.5, 2, now()),
|
|
||||||
(162, 30, 'house.healer', 3, 9.3, 'south', 2.5, 2, now()),
|
|
||||||
(163, 30, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
|
|
||||||
(164, 30, 'decoration.signpost', 0, 11.4, 'south', 0.5, 0.5, now()),
|
|
||||||
(165, 31, 'house.quest_giver', -8.1, -4.5, 'south', 2.5, 2, now()),
|
|
||||||
(166, 31, 'house.merchant', 9.6, -4.5, 'south', 2.5, 2, now()),
|
|
||||||
(167, 31, 'house.merchant', -7, 4.5, 'south', 2.5, 2, now()),
|
|
||||||
(168, 31, 'house.healer', 3, 9.3, 'south', 2.5, 2, now()),
|
|
||||||
(169, 31, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
|
|
||||||
(170, 31, 'decoration.signpost', 0, 11.4, 'south', 0.5, 0.5, now());
|
|
||||||
|
|
||||||
INSERT INTO public.npcs (id, town_id, name, name_key, type, offset_x, offset_y, created_at, building_id) VALUES
|
|
||||||
(27, 12, 'Clerk Sera', 'npc.clerk_sera.v1', 'quest_giver', -6.3, -2.3, now(), 49),
|
|
||||||
(28, 12, 'Notary Bram', 'npc.notary_bram.v1', 'quest_giver', 6.3, -2.3, now(), 50),
|
|
||||||
(29, 12, 'Copper Nils', 'npc.copper_nils.v1', 'merchant', -5.5, 4.4, now(), 51),
|
|
||||||
(30, 12, 'Tin Mara', 'npc.tin_mara.v1', 'merchant', 5.5, 4.4, now(), 52),
|
|
||||||
(31, 12, 'Sister Calm', 'npc.sister_calm.v1', 'healer', 0, 8.7, now(), 53),
|
|
||||||
(32, 13, 'Foreman Rook', 'npc.foreman_rook.v1', 'quest_giver', -4.8, -2.3, now(), 56),
|
|
||||||
(33, 13, 'Wire Merchant', 'npc.wire_merchant.v1', 'merchant', 6.3, -2.3, now(), 57),
|
|
||||||
(34, 13, 'Bolt Jada', 'npc.bolt_jada.v1', 'merchant', -4.5, 4.4, now(), 58),
|
|
||||||
(35, 13, 'Sage Mottle', 'npc.sage_mottle.v1', 'healer', 3, 8.1, now(), 59),
|
|
||||||
(36, 14, 'Warden Pike', 'npc.warden_pike.v1', 'quest_giver', -4.8, -2.3, now(), 62),
|
|
||||||
(37, 14, 'Ash Vendor', 'npc.ash_vendor.v1', 'merchant', 6.3, -2.3, now(), 63),
|
|
||||||
(38, 14, 'Scrap Yori', 'npc.scrap_yori.v1', 'merchant', -4.5, 4.4, now(), 64),
|
|
||||||
(39, 14, 'Herb Rill', 'npc.herb_rill.v1', 'healer', 3, 8.1, now(), 65),
|
|
||||||
(40, 15, 'Miller Tove', 'npc.miller_tove.v1', 'quest_giver', -4.8, -2.3, now(), 68),
|
|
||||||
(41, 15, 'Grain Peddler', 'npc.grain_peddler.v1', 'merchant', 6.3, -2.3, now(), 69),
|
|
||||||
(42, 15, 'Sack Ren', 'npc.sack_ren.v1', 'merchant', -4.5, 4.4, now(), 70),
|
|
||||||
(43, 15, 'Brother Salve', 'npc.brother_salve.v1', 'healer', 3, 8.1, now(), 71),
|
|
||||||
(44, 16, 'Stone Judge', 'npc.stone_judge.v1', 'quest_giver', -6.3, -2.3, now(), 74),
|
|
||||||
(45, 16, 'Edge Trader', 'npc.edge_trader.v1', 'merchant', 7.8, -2.3, now(), 75),
|
|
||||||
(46, 16, 'Crack Merchant', 'npc.crack_merchant.v1', 'merchant', -5.5, 4.4, now(), 76),
|
|
||||||
(47, 16, 'Sister Flint', 'npc.sister_flint.v1', 'healer', 3, 9.0, now(), 77),
|
|
||||||
(48, 17, 'Starward Oren', 'npc.starward_oren.v1', 'quest_giver', -8.1, -3.3, now(), 80),
|
|
||||||
(49, 17, 'Spire Imports', 'npc.spire_imports.v1', 'merchant', 9.6, -3.3, now(), 81),
|
|
||||||
(50, 17, 'Comet Outfitter', 'npc.comet_outfitter.v1', 'merchant', -7, 5.7, now(), 82),
|
|
||||||
(51, 17, 'Void Medic', 'npc.void_medic.v1', 'healer', 3, 10.2, now(), 83),
|
|
||||||
(52, 18, 'Brine Archivist', 'npc.brine_archivist.v1', 'quest_giver', -7.2, -2.8, now(), 86),
|
|
||||||
(53, 18, 'Salt Broker', 'npc.salt_broker.v1', 'merchant', 8.7, -2.8, now(), 87),
|
|
||||||
(54, 18, 'Reed Trader', 'npc.reed_trader.v1', 'merchant', -6, 5.2, now(), 88),
|
|
||||||
(55, 18, 'Mud Healer', 'npc.mud_healer.v1', 'healer', 3, 9.0, now(), 89),
|
|
||||||
(56, 19, 'Post Warden', 'npc.post_warden.v1', 'quest_giver', -7.2, -2.8, now(), 92),
|
|
||||||
(57, 19, 'Ironmonger', 'npc.ironmonger.v1', 'merchant', 8.7, -2.8, now(), 93),
|
|
||||||
(58, 19, 'Rivet Seller', 'npc.rivet_seller.v1', 'merchant', -6, 5.2, now(), 94),
|
|
||||||
(59, 19, 'Forge Medic', 'npc.forge_medic.v1', 'healer', 3, 9.0, now(), 95),
|
|
||||||
(60, 20, 'Bog Chronicler', 'npc.bog_chronicler.v1', 'quest_giver', -6.3, -2.3, now(), 98),
|
|
||||||
(61, 20, 'Fen Notary', 'npc.fen_notary.v1', 'quest_giver', 6.3, -2.3, now(), 99),
|
|
||||||
(62, 20, 'Mire Merchant', 'npc.mire_merchant.v1', 'merchant', -5.5, 4.4, now(), 100),
|
|
||||||
(63, 20, 'Reed Coin', 'npc.reed_coin.v1', 'merchant', 5.5, 4.4, now(), 101),
|
|
||||||
(64, 20, 'Swamp Mender', 'npc.swamp_mender.v1', 'healer', 0, 8.7, now(), 102),
|
|
||||||
(65, 21, 'Dune Scout', 'npc.dune_scout.v1', 'quest_giver', -4.8, -2.3, now(), 105),
|
|
||||||
(66, 21, 'Silt Trader', 'npc.silt_trader.v1', 'merchant', 6.3, -2.3, now(), 106),
|
|
||||||
(67, 21, 'Sand Peddler', 'npc.sand_peddler.v1', 'merchant', -4.5, 4.4, now(), 107),
|
|
||||||
(68, 21, 'Grit Healer', 'npc.grit_healer.v1', 'healer', 3, 8.1, now(), 108),
|
|
||||||
(69, 22, 'Barrow Keeper', 'npc.barrow_keeper.v1', 'quest_giver', -4.8, -2.3, now(), 111),
|
|
||||||
(70, 22, 'Bone Outfitter', 'npc.bone_outfitter.v1', 'merchant', 6.3, -2.3, now(), 112),
|
|
||||||
(71, 22, 'Cold Peddler', 'npc.cold_peddler.v1', 'merchant', -4.5, 4.4, now(), 113),
|
|
||||||
(72, 22, 'Shroud Medic', 'npc.shroud_medic.v1', 'healer', 3, 8.1, now(), 114),
|
|
||||||
(73, 23, 'Mist Ranger', 'npc.mist_ranger.v1', 'quest_giver', -4.8, -2.3, now(), 117),
|
|
||||||
(74, 23, 'Fog Trader', 'npc.fog_trader.v1', 'merchant', 6.3, -2.3, now(), 118),
|
|
||||||
(75, 23, 'Dew Merchant', 'npc.dew_merchant.v1', 'merchant', -4.5, 4.4, now(), 119),
|
|
||||||
(76, 23, 'Vapor Healer', 'npc.vapor_healer.v1', 'healer', 3, 8.1, now(), 120),
|
|
||||||
(77, 24, 'Hollow Scribe', 'npc.hollow_scribe.v1', 'quest_giver', -4.8, -2.3, now(), 123),
|
|
||||||
(78, 24, 'Mer Imports', 'npc.mer_imports.v1', 'merchant', 6.3, -2.3, now(), 124),
|
|
||||||
(79, 24, 'Rot Trader', 'npc.rot_trader.v1', 'merchant', -4.5, 4.4, now(), 125),
|
|
||||||
(80, 24, 'Bog Medic', 'npc.bog_medic.v1', 'healer', 3, 8.1, now(), 126),
|
|
||||||
(81, 25, 'Ash Herald', 'npc.herald_ash.v1', 'quest_giver', -4.8, -2.3, now(), 129),
|
|
||||||
(82, 25, 'Cinder Seller', 'npc.cinder_seller.v1', 'merchant', 6.3, -2.3, now(), 130),
|
|
||||||
(83, 25, 'Ember Peddler', 'npc.ember_peddler.v1', 'merchant', -4.5, 4.4, now(), 131),
|
|
||||||
(84, 25, 'Ash Healer', 'npc.ash_healer.v1', 'healer', 3, 8.1, now(), 132),
|
|
||||||
(85, 26, 'Thorn Watcher', 'npc.thorn_watcher.v1', 'quest_giver', -4.8, -2.3, now(), 135),
|
|
||||||
(86, 26, 'Briar Trader', 'npc.briar_trader.v1', 'merchant', 6.3, -2.3, now(), 136),
|
|
||||||
(87, 26, 'Root Seller', 'npc.root_seller.v1', 'merchant', -4.5, 4.4, now(), 137),
|
|
||||||
(88, 26, 'Leaf Medic', 'npc.leaf_medic.v1', 'healer', 3, 8.1, now(), 138),
|
|
||||||
(89, 27, 'Gale Factor', 'npc.gale_factor.v1', 'quest_giver', -4.8, -2.3, now(), 141),
|
|
||||||
(90, 27, 'Wind Outfitter', 'npc.wind_outfitter.v1', 'merchant', 6.3, -2.3, now(), 142),
|
|
||||||
(91, 27, 'Gust Peddler', 'npc.gust_peddler.v1', 'merchant', -4.5, 4.4, now(), 143),
|
|
||||||
(92, 27, 'Breeze Healer', 'npc.breeze_healer.v1', 'healer', 3, 8.1, now(), 144),
|
|
||||||
(93, 28, 'Frost Archivist', 'npc.frost_archivist.v1', 'quest_giver', -8.1, -3.3, now(), 147),
|
|
||||||
(94, 28, 'Rime Trader', 'npc.rime_trader.v1', 'merchant', 9.6, -3.3, now(), 148),
|
|
||||||
(95, 28, 'Hoarfrost Seller', 'npc.hoarfrost_seller.v1', 'merchant', -7, 5.7, now(), 149),
|
|
||||||
(96, 28, 'Ice Medic', 'npc.ice_medic.v1', 'healer', 3, 10.2, now(), 150),
|
|
||||||
(97, 29, 'Sun Warden', 'npc.sun_warden.v1', 'quest_giver', -8.1, -3.3, now(), 153),
|
|
||||||
(98, 29, 'Cliff Merchant', 'npc.cliff_merchant.v1', 'merchant', 9.6, -3.3, now(), 154),
|
|
||||||
(99, 29, 'Crag Peddler', 'npc.crag_peddler.v1', 'merchant', -7, 5.7, now(), 155),
|
|
||||||
(100, 29, 'Dust Healer', 'npc.dust_healer.v1', 'healer', 3, 10.2, now(), 156),
|
|
||||||
(101, 30, 'Ford Marshal', 'npc.ford_marshal.v1', 'quest_giver', -8.1, -3.3, now(), 159),
|
|
||||||
(102, 30, 'River Trader', 'npc.river_trader.v1', 'merchant', 9.6, -3.3, now(), 160),
|
|
||||||
(103, 30, 'Bridge Seller', 'npc.bridge_seller.v1', 'merchant', -7, 5.7, now(), 161),
|
|
||||||
(104, 30, 'Stream Medic', 'npc.stream_medic.v1', 'healer', 3, 10.2, now(), 162),
|
|
||||||
(105, 31, 'Veil Seer', 'npc.veil_seer.v1', 'quest_giver', -8.1, -3.3, now(), 165),
|
|
||||||
(106, 31, 'Star Trader', 'npc.star_trader.v1', 'merchant', 9.6, -3.3, now(), 166),
|
|
||||||
(107, 31, 'Nebula Peddler', 'npc.nebula_peddler.v1', 'merchant', -7, 5.7, now(), 167),
|
|
||||||
(108, 31, 'Veil Mender', 'npc.veil_mender_starveil.v1', 'healer', 3, 10.2, now(), 168);
|
|
||||||
|
|
||||||
-- Directed roads (pairwise both ways). Distance is recomputed at load from waypoints.
|
|
||||||
INSERT INTO public.roads (id, from_town_id, to_town_id, distance) VALUES
|
|
||||||
(51, 12, 4, 1000), (52, 4, 12, 1000), (53, 12, 10, 1000), (54, 10, 12, 1000), (55, 12, 13, 1000), (56, 13, 12, 1000),
|
|
||||||
(57, 13, 11, 1000), (58, 11, 13, 1000), (59, 13, 5, 1000), (60, 5, 13, 1000), (61, 13, 14, 1000), (62, 14, 13, 1000),
|
|
||||||
(63, 14, 5, 1000), (64, 5, 14, 1000), (65, 14, 6, 1000), (66, 6, 14, 1000), (67, 14, 15, 1000), (68, 15, 14, 1000),
|
|
||||||
(69, 15, 5, 1000), (70, 5, 15, 1000), (71, 15, 16, 1000), (72, 16, 15, 1000), (73, 16, 1, 1000), (74, 1, 16, 1000),
|
|
||||||
(75, 16, 8, 1000), (76, 8, 16, 1000), (77, 16, 12, 1000), (78, 12, 16, 1000),
|
|
||||||
(79, 17, 18, 1000), (80, 18, 17, 1000), (81, 18, 19, 1000), (82, 19, 18, 1000), (83, 19, 20, 1000), (84, 20, 19, 1000),
|
|
||||||
(85, 20, 21, 1000), (86, 21, 20, 1000), (87, 21, 22, 1000), (88, 22, 21, 1000), (89, 22, 23, 1000), (90, 23, 22, 1000),
|
|
||||||
(91, 23, 24, 1000), (92, 24, 23, 1000), (93, 24, 25, 1000), (94, 25, 24, 1000), (95, 25, 26, 1000), (96, 26, 25, 1000),
|
|
||||||
(97, 26, 27, 1000), (98, 27, 26, 1000), (99, 27, 28, 1000), (100, 28, 27, 1000), (101, 28, 29, 1000), (102, 29, 28, 1000),
|
|
||||||
(103, 29, 30, 1000), (104, 30, 29, 1000), (105, 30, 31, 1000), (106, 31, 30, 1000), (107, 31, 17, 1000), (108, 17, 31, 1000),
|
|
||||||
(109, 17, 3, 1000), (110, 3, 17, 1000), (111, 18, 10, 1000), (112, 10, 18, 1000), (113, 19, 4, 1000), (114, 4, 19, 1000),
|
|
||||||
(115, 20, 11, 1000), (116, 11, 20, 1000), (117, 21, 11, 1000), (118, 11, 21, 1000), (119, 22, 5, 1000), (120, 5, 22, 1000),
|
|
||||||
(121, 23, 6, 1000), (122, 6, 23, 1000), (123, 24, 6, 1000), (124, 6, 24, 1000), (125, 25, 7, 1000), (126, 7, 25, 1000),
|
|
||||||
(127, 26, 7, 1000), (128, 7, 26, 1000), (129, 27, 7, 1000), (130, 7, 27, 1000), (131, 28, 1, 1000), (132, 1, 28, 1000),
|
|
||||||
(133, 29, 1, 1000), (134, 1, 29, 1000), (135, 30, 1, 1000), (136, 1, 30, 1000), (137, 31, 2, 1000), (138, 2, 31, 1000);
|
|
||||||
|
|
||||||
INSERT INTO public.quests (npc_id, quest_key, title, description, type, target_count, target_enemy_type, target_enemy_archetype, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions) VALUES
|
|
||||||
(27, 'quest.silverstep_bandits.v1', 'Bandit Echo', 'Thin bandit packs along the inner roads.', 'kill_count', 5, NULL, 'bandit', NULL, 0, 4, 10, 90, 48, 0),
|
|
||||||
(28, 'quest.silverstep_visit_mossharbor.v1', 'Letter to Mossharbor', 'Carry a sealed note to Harbor-ward Lissa.', 'visit_town', 1, NULL, NULL, 8, 0, 4, 10, 55, 30, 0),
|
|
||||||
(32, 'quest.copperfield_wolves.v1', 'Copperfield Wolves', 'Wolves circle the smelting sheds.', 'kill_count', 6, NULL, 'wolf', NULL, 0, 6, 12, 120, 62, 0),
|
|
||||||
(36, 'quest.ashford_skeletons.v1', 'Ashford Bones', 'Risen bones worry the ruins lane.', 'kill_count', 7, NULL, 'skeleton', NULL, 0, 3, 9, 85, 44, 0),
|
|
||||||
(40, 'quest.millbrook_boars.v1', 'Millbrook Boars', 'Boars ruin the grain path.', 'kill_count', 6, NULL, 'boar', NULL, 0, 5, 11, 100, 52, 0),
|
|
||||||
(44, 'quest.stonebend_orcs.v1', 'Stonebend Orcs', 'Orc scouts press the canyon shelf.', 'kill_count', 8, NULL, 'orc', NULL, 0, 8, 14, 140, 75, 1),
|
|
||||||
(48, 'quest.highspire_shades.v1', 'Shade at the Spire', 'Shades cling to the high astral road.', 'kill_count', 6, NULL, 'shade', NULL, 0, 40, 46, 520, 300, 2),
|
|
||||||
(52, 'quest.saltmere_spiders.v1', 'Saltmere Silk', 'Spiders infest the brine posts.', 'kill_count', 8, NULL, 'spider', NULL, 0, 38, 44, 480, 280, 1),
|
|
||||||
(56, 'quest.ironpost_golems.v1', 'Ironpost Sentinels', 'Golems block the iron road.', 'kill_count', 5, NULL, 'golem', NULL, 0, 42, 48, 560, 320, 2),
|
|
||||||
(60, 'quest.greyfen_harpies.v1', 'Greyfen Harpies', 'Harpies pick at the fen docks.', 'kill_count', 7, NULL, 'harpy', NULL, 0, 35, 41, 420, 240, 1),
|
|
||||||
(61, 'quest.greyfen_visit_duskwatch.v1', 'Warning to Duskwatch', 'Bring tidings to Sister Morah.', 'visit_town', 1, NULL, NULL, 11, 0, 35, 41, 200, 110, 0),
|
|
||||||
(65, 'quest.dunewatch_zombies.v1', 'Dune Dead', 'Zombies wander the silt flats.', 'kill_count', 10, NULL, 'zombie', NULL, 0, 36, 42, 440, 250, 1),
|
|
||||||
(69, 'quest.coldbarrow_wraiths.v1', 'Coldbarrow Wraiths', 'Wraiths drift between the barrows.', 'kill_count', 8, NULL, 'wraith', NULL, 0, 33, 39, 400, 230, 1),
|
|
||||||
(73, 'quest.mistral_cultists.v1', 'Mistral Cultists', 'Cultists chant in the fog line.', 'kill_count', 9, NULL, 'cultist', NULL, 0, 31, 37, 380, 220, 1),
|
|
||||||
(77, 'quest.hollowmere_treants.v1', 'Hollowmere Roots', 'Treants root in the hollow mere.', 'kill_count', 4, NULL, 'treant', NULL, 0, 29, 35, 360, 210, 1),
|
|
||||||
(81, 'quest.ashfen_demons.v1', 'Ashfen Embers', 'Demons leave cinders on the ash fen.', 'kill_count', 5, NULL, 'demon', NULL, 0, 27, 33, 340, 200, 1),
|
|
||||||
(85, 'quest.thornmere_lizards.v1', 'Thornmere Scalebacks', 'Battle lizards bask by the thorns.', 'kill_count', 8, NULL, 'battle_lizard', NULL, 0, 25, 31, 320, 190, 1),
|
|
||||||
(89, 'quest.windgarde_visit_willowdale.v1', 'Parcel for Willowdale', 'Deliver a parcel to Elder Maren.', 'visit_town', 1, NULL, NULL, 1, 0, 23, 29, 150, 85, 0),
|
|
||||||
(93, 'quest.frosthollow_titans.v1', 'Frost Titan Steps', 'Titans loom past the frost hollow.', 'kill_count', 4, NULL, 'titan', NULL, 0, 45, 52, 640, 380, 2),
|
|
||||||
(97, 'quest.sungrasp_wyverns.v1', 'Sungrasp Wyverns', 'Wyverns circle the sun cliffs.', 'kill_count', 6, NULL, 'wyvern', NULL, 0, 47, 53, 680, 400, 2),
|
|
||||||
(101, 'quest.glimmerford_manticores.v1', 'Glimmerford Alphas', 'Manticores claim the ford approaches.', 'kill_count', 5, NULL, 'manticore', NULL, 0, 49, 55, 720, 420, 2),
|
|
||||||
(105, 'quest.starveil_wardens.v1', 'Veil Wardens', 'Forest wardens dispute the star road.', 'kill_count', 3, NULL, 'forest_warden', NULL, 0, 51, 60, 800, 480, 3);
|
|
||||||
|
|
||||||
SELECT pg_catalog.setval('public.towns_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.towns), true);
|
|
||||||
SELECT pg_catalog.setval('public.town_buildings_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.town_buildings), true);
|
|
||||||
SELECT pg_catalog.setval('public.npcs_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.npcs), true);
|
|
||||||
SELECT pg_catalog.setval('public.roads_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.roads), true);
|
|
||||||
SELECT pg_catalog.setval('public.quests_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.quests), true);
|
|
||||||
@ -1,141 +0,0 @@
|
|||||||
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
@ -1,2 +0,0 @@
|
|||||||
declare const _default: import("vite").UserConfig;
|
|
||||||
export default _default;
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
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