some fixes

master
Denis Ranneft 13 hours ago
parent d4b99a0f3c
commit 70bbda4337

@ -775,7 +775,7 @@ func (e *Engine) processMovementTick(now time.Time) {
} }
for heroID, hm := range e.movements { for heroID, hm := range e.movements {
ProcessSingleHeroMovementTick(heroID, hm, e.roadGraph, now, e.sender, startCombat) ProcessSingleHeroMovementTick(heroID, hm, e.roadGraph, now, e.sender, startCombat, nil)
} }
} }
@ -791,7 +791,7 @@ func (e *Engine) processPositionSync(now time.Time) {
for heroID, hm := range e.movements { for heroID, hm := range e.movements {
if hm.State == model.StateWalking { if hm.State == model.StateWalking {
e.sender.SendToHero(heroID, "position_sync", hm.PositionSyncPayload()) e.sender.SendToHero(heroID, "position_sync", hm.PositionSyncPayload(now))
} }
} }
} }

@ -33,12 +33,19 @@ func agentDebugLog(hypothesisID, location, message string, data map[string]any)
if err != nil { if err != nil {
return return
} }
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) candidates := []string{logPath}
if filepath.Base(wd) == "backend" {
candidates = append([]string{filepath.Join(wd, "..", "debug-cbb64d.log")}, candidates...)
}
for _, p := range candidates {
f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil { if err != nil {
return continue
} }
_, _ = f.Write(append(b, '\n')) _, _ = f.Write(append(b, '\n'))
_ = f.Close() _ = f.Close()
return
}
} }
// #endregion // #endregion
@ -53,11 +60,22 @@ const (
// PositionSyncRate is how often the server sends a full position_sync (drift correction). // PositionSyncRate is how often the server sends a full position_sync (drift correction).
PositionSyncRate = 10 * time.Second PositionSyncRate = 10 * time.Second
// EncounterCooldownBase is the minimum gap between random encounters. // EncounterCooldownBase is the minimum gap between road encounters (monster or merchant).
EncounterCooldownBase = 15 * time.Second EncounterCooldownBase = 12 * time.Second
// EncounterActivityBase scales per-tick chance to roll an encounter after cooldown.
// Effective activity is higher deep off-road (see rollRoadEncounter).
EncounterActivityBase = 0.035
// StartAdventurePerTick is the chance per movement tick to leave the road for a timed excursion.
StartAdventurePerTick = 0.0004
// EncounterChancePerTick is the probability of an encounter on each movement tick. // AdventureDurationMin/Max bound how long an off-road excursion lasts.
EncounterChancePerTick = 0.04 AdventureDurationMin = 15 * time.Minute
AdventureDurationMax = 20 * time.Minute
// AdventureMaxLateral is max perpendicular offset from the road spine (world units) at peak wilderness.
AdventureMaxLateral = 3.5
// TownRestMin is the minimum rest duration when arriving at a town. // TownRestMin is the minimum rest duration when arriving at a town.
TownRestMin = 5 * 60 * time.Second TownRestMin = 5 * 60 * time.Second
@ -97,6 +115,11 @@ type HeroMovement struct {
// TownNPCQueue: NPC ids still to visit this stay (nil = not on NPC tour). Cleared in LeaveTown. // TownNPCQueue: NPC ids still to visit this stay (nil = not on NPC tour). Cleared in LeaveTown.
TownNPCQueue []int64 TownNPCQueue []int64
NextTownNPCRollAt time.Time NextTownNPCRollAt time.Time
// Off-road excursion ("looking for trouble"): not persisted; cleared on town enter and when it ends.
AdventureStartAt time.Time
AdventureEndAt time.Time
AdventureSide int // +1 or -1 perpendicular direction while adventuring; 0 = not adventuring
} }
// NewHeroMovement creates a HeroMovement for a hero that just connected. // NewHeroMovement creates a HeroMovement for a hero that just connected.
@ -155,42 +178,103 @@ func NewHeroMovement(hero *model.Hero, graph *RoadGraph, now time.Time) *HeroMov
hm.pickDestination(graph) hm.pickDestination(graph)
} }
hm.assignRoad(graph) hm.assignRoad(graph)
if hm.Road == nil {
hm.pickDestination(graph)
hm.assignRoad(graph)
}
hm.State = model.StateWalking hm.State = model.StateWalking
return hm return hm
} }
// firstReachableOnRing returns the first town along TownOrder (stepping by Direction)
// that has a direct road from CurrentTownID, or 0 if none.
func (hm *HeroMovement) firstReachableOnRing(graph *RoadGraph, fromIdx int) int64 {
n := len(graph.TownOrder)
if n < 2 || fromIdx < 0 {
return 0
}
for step := 1; step < n; step++ {
raw := fromIdx + hm.Direction*step
nextIdx := ((raw % n) + n) % n
candidate := graph.TownOrder[nextIdx]
if candidate == hm.CurrentTownID {
continue
}
if graph.FindRoad(hm.CurrentTownID, candidate) != nil {
return candidate
}
}
return 0
}
func (hm *HeroMovement) firstOutgoingDestination(graph *RoadGraph) int64 {
for _, r := range graph.TownRoads[hm.CurrentTownID] {
if r != nil && r.ToTownID != hm.CurrentTownID {
return r.ToTownID
}
}
return 0
}
func (hm *HeroMovement) firstReachableAny(graph *RoadGraph) int64 {
for _, tid := range graph.TownOrder {
if tid == hm.CurrentTownID {
continue
}
if graph.FindRoad(hm.CurrentTownID, tid) != nil {
return tid
}
}
return 0
}
// pickDestination selects the next town the hero should walk toward. // pickDestination selects the next town the hero should walk toward.
// Only towns connected by a roads row are chosen — TownOrder alone is not enough.
func (hm *HeroMovement) pickDestination(graph *RoadGraph) { func (hm *HeroMovement) pickDestination(graph *RoadGraph) {
if hm.CurrentTownID == 0 { if hm.CurrentTownID == 0 {
// Hero is not associated with any town yet, pick nearest.
hm.CurrentTownID = graph.NearestTown(hm.CurrentX, hm.CurrentY) hm.CurrentTownID = graph.NearestTown(hm.CurrentX, hm.CurrentY)
} }
n := len(graph.TownOrder)
if n == 0 {
hm.DestinationTownID = 0
return
}
if n == 1 {
hm.DestinationTownID = hm.CurrentTownID
return
}
idx := graph.TownOrderIndex(hm.CurrentTownID) idx := graph.TownOrderIndex(hm.CurrentTownID)
if idx < 0 { if idx < 0 {
// Fallback. if d := hm.firstOutgoingDestination(graph); d != 0 {
hm.DestinationTownID = d
return
}
if d := hm.firstReachableAny(graph); d != 0 {
hm.DestinationTownID = d
return
}
if len(graph.TownOrder) > 0 { if len(graph.TownOrder) > 0 {
hm.DestinationTownID = graph.TownOrder[0] hm.DestinationTownID = graph.TownOrder[0]
} }
return return
} }
n := len(graph.TownOrder) if dest := hm.firstReachableOnRing(graph, idx); dest != 0 {
if n <= 1 { hm.DestinationTownID = dest
hm.DestinationTownID = hm.CurrentTownID
return return
} }
if d := hm.firstOutgoingDestination(graph); d != 0 {
nextIdx := idx + hm.Direction hm.DestinationTownID = d
if nextIdx >= n { return
nextIdx = 0
} }
if nextIdx < 0 { if d := hm.firstReachableAny(graph); d != 0 {
nextIdx = n - 1 hm.DestinationTownID = d
return
} }
hm.DestinationTownID = hm.CurrentTownID
hm.DestinationTownID = graph.TownOrder[nextIdx]
} }
// assignRoad finds and configures the road from CurrentTownID to DestinationTownID. // assignRoad finds and configures the road from CurrentTownID to DestinationTownID.
@ -207,7 +291,7 @@ func (hm *HeroMovement) assignRoad(graph *RoadGraph) {
// #region agent log // #region agent log
agentDebugLog("H5", "movement.go:assignRoad", "no road after nearest retry", map[string]any{ agentDebugLog("H5", "movement.go:assignRoad", "no road after nearest retry", map[string]any{
"currentTownID": hm.CurrentTownID, "destinationTownID": hm.DestinationTownID, "currentTownID": hm.CurrentTownID, "destinationTownID": hm.DestinationTownID,
"x": hm.CurrentX, "y": hm.CurrentY, "x": hm.CurrentX, "y": hm.CurrentY, "runId": "post-fix",
}) })
// #endregion // #endregion
// No road available, will retry next tick. // No road available, will retry next tick.
@ -344,12 +428,124 @@ func (hm *HeroMovement) TargetPoint() (float64, float64) {
return wp.X, wp.Y return wp.X, wp.Y
} }
// ShouldEncounter rolls for a random encounter, respecting the cooldown. func (hm *HeroMovement) adventureActive(now time.Time) bool {
func (hm *HeroMovement) ShouldEncounter(now time.Time) bool { return !hm.AdventureStartAt.IsZero() && now.Before(hm.AdventureEndAt)
if now.Sub(hm.LastEncounterAt) < EncounterCooldownBase { }
return false
func (hm *HeroMovement) expireAdventureIfNeeded(now time.Time) {
if hm.AdventureEndAt.IsZero() {
return
}
if now.Before(hm.AdventureEndAt) {
return
}
hm.AdventureStartAt = time.Time{}
hm.AdventureEndAt = time.Time{}
hm.AdventureSide = 0
}
// tryStartAdventure begins a timed off-road excursion with small probability.
func (hm *HeroMovement) tryStartAdventure(now time.Time) {
if hm.adventureActive(now) {
return
}
if rand.Float64() >= StartAdventurePerTick {
return
}
hm.AdventureStartAt = now
spanNs := (AdventureDurationMax - AdventureDurationMin).Nanoseconds()
if spanNs < 1 {
spanNs = 1
}
hm.AdventureEndAt = now.Add(AdventureDurationMin + time.Duration(rand.Int63n(spanNs+1)))
if rand.Float64() < 0.5 {
hm.AdventureSide = 1
} else {
hm.AdventureSide = -1
}
}
// wildernessFactor is 0 on the road, then 0→1→0 over the excursion (triangle: out, then back).
func (hm *HeroMovement) wildernessFactor(now time.Time) float64 {
if !hm.adventureActive(now) {
return 0
}
total := hm.AdventureEndAt.Sub(hm.AdventureStartAt).Seconds()
if total <= 0 {
return 0
}
elapsed := now.Sub(hm.AdventureStartAt).Seconds()
p := elapsed / total
if p < 0 {
p = 0
} else if p > 1 {
p = 1
} }
return rand.Float64() < EncounterChancePerTick if p < 0.5 {
return p * 2
}
return (1 - p) * 2
}
func (hm *HeroMovement) roadPerpendicularUnit() (float64, float64) {
if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
return 0, 1
}
idx := hm.WaypointIndex
if idx >= len(hm.Road.Waypoints)-1 {
idx = len(hm.Road.Waypoints) - 2
}
if idx < 0 {
return 0, 1
}
from := hm.Road.Waypoints[idx]
to := hm.Road.Waypoints[idx+1]
dx := to.X - from.X
dy := to.Y - from.Y
L := math.Hypot(dx, dy)
if L < 1e-6 {
return 0, 1
}
return -dy / L, dx / L
}
func (hm *HeroMovement) displayOffset(now time.Time) (float64, float64) {
w := hm.wildernessFactor(now)
if w <= 0 || hm.AdventureSide == 0 {
return 0, 0
}
px, py := hm.roadPerpendicularUnit()
mag := float64(hm.AdventureSide) * AdventureMaxLateral * w
return px * mag, py * mag
}
// WanderingMerchantCost matches REST encounter / npc alms pricing.
func WanderingMerchantCost(level int) int64 {
return int64(20 + level*5)
}
// rollRoadEncounter returns whether to trigger an encounter; if so, monster true means combat.
func (hm *HeroMovement) rollRoadEncounter(now time.Time) (monster bool, enemy model.Enemy, hit bool) {
if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
return false, model.Enemy{}, false
}
if now.Sub(hm.LastEncounterAt) < EncounterCooldownBase {
return false, model.Enemy{}, false
}
w := hm.wildernessFactor(now)
activity := EncounterActivityBase * (0.45 + 0.55*w)
if rand.Float64() >= activity {
return false, model.Enemy{}, false
}
monsterW := 0.08 + 0.92*w*w
merchantW := 0.08 + 0.92*(1-w)*(1-w)
total := monsterW + merchantW
r := rand.Float64() * total
if r < monsterW {
e := PickEnemyForLevel(hm.Hero.Level)
return true, e, true
}
return false, model.Enemy{}, true
} }
// EnterTown transitions the hero into the destination town: NPC tour (StateInTown) when there // EnterTown transitions the hero into the destination town: NPC tour (StateInTown) when there
@ -361,6 +557,9 @@ func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) {
hm.Road = nil hm.Road = nil
hm.TownNPCQueue = nil hm.TownNPCQueue = nil
hm.NextTownNPCRollAt = time.Time{} hm.NextTownNPCRollAt = time.Time{}
hm.AdventureStartAt = time.Time{}
hm.AdventureEndAt = time.Time{}
hm.AdventureSide = 0
ids := graph.TownNPCIDs(destID) ids := graph.TownNPCIDs(destID)
if len(ids) == 0 { if len(ids) == 0 {
@ -432,14 +631,15 @@ func (hm *HeroMovement) SyncToHero() {
hm.Hero.MoveState = string(hm.State) hm.Hero.MoveState = string(hm.State)
} }
// MovePayload builds the hero_move WS payload. // MovePayload builds the hero_move WS payload (includes off-road lateral offset for display).
func (hm *HeroMovement) MovePayload() model.HeroMovePayload { func (hm *HeroMovement) MovePayload(now time.Time) model.HeroMovePayload {
tx, ty := hm.TargetPoint() tx, ty := hm.TargetPoint()
ox, oy := hm.displayOffset(now)
return model.HeroMovePayload{ return model.HeroMovePayload{
X: hm.CurrentX, X: hm.CurrentX + ox,
Y: hm.CurrentY, Y: hm.CurrentY + oy,
TargetX: tx, TargetX: tx + ox,
TargetY: ty, TargetY: ty + oy,
Speed: hm.Speed, Speed: hm.Speed,
Heading: hm.Heading(), Heading: hm.Heading(),
} }
@ -463,10 +663,11 @@ func (hm *HeroMovement) RoutePayload() *model.RouteAssignedPayload {
} }
// PositionSyncPayload builds the position_sync WS payload. // PositionSyncPayload builds the position_sync WS payload.
func (hm *HeroMovement) PositionSyncPayload() model.PositionSyncPayload { func (hm *HeroMovement) PositionSyncPayload(now time.Time) model.PositionSyncPayload {
ox, oy := hm.displayOffset(now)
return model.PositionSyncPayload{ return model.PositionSyncPayload{
X: hm.CurrentX, X: hm.CurrentX + ox,
Y: hm.CurrentY, Y: hm.CurrentY + oy,
WaypointIndex: hm.WaypointIndex, WaypointIndex: hm.WaypointIndex,
WaypointFraction: hm.WaypointFraction, WaypointFraction: hm.WaypointFraction,
State: string(hm.State), State: string(hm.State),
@ -483,6 +684,9 @@ func randomRestDuration() time.Duration {
// offline: synchronous SimulateOneFight via callback). // offline: synchronous SimulateOneFight via callback).
type EncounterStarter func(hm *HeroMovement, enemy *model.Enemy, now time.Time) type EncounterStarter func(hm *HeroMovement, enemy *model.Enemy, now time.Time)
// MerchantEncounterHook is called for wandering-merchant road events when there is no WS sender (offline).
type MerchantEncounterHook func(hm *HeroMovement, now time.Time, cost int64)
// ProcessSingleHeroMovementTick applies one movement-system step as of logical time now. // ProcessSingleHeroMovementTick applies one movement-system step as of logical time now.
// It mirrors the online engine's 500ms cadence: callers should advance now in MovementTickRate // It mirrors the online engine's 500ms cadence: callers should advance now in MovementTickRate
// steps (plus a final partial step to real time) for catch-up simulation. // steps (plus a final partial step to real time) for catch-up simulation.
@ -496,6 +700,7 @@ func ProcessSingleHeroMovementTick(
now time.Time, now time.Time,
sender MessageSender, sender MessageSender,
onEncounter EncounterStarter, onEncounter EncounterStarter,
onMerchantEncounter MerchantEncounterHook,
) { ) {
if graph == nil { if graph == nil {
return return
@ -546,15 +751,23 @@ func ProcessSingleHeroMovementTick(
} }
case model.StateWalking: case model.StateWalking:
hm.expireAdventureIfNeeded(now)
if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
hm.Road = nil
hm.pickDestination(graph)
hm.assignRoad(graph)
}
hm.tryStartAdventure(now)
// #region agent log // #region agent log
if hm.Road == nil { if hm.Road == nil {
agentDebugLog("H1", "movement.go:StateWalking", "walking with nil Road", map[string]any{ agentDebugLog("H1", "movement.go:StateWalking", "walking with nil Road", map[string]any{
"heroID": heroID, "currentTownID": hm.CurrentTownID, "destinationTownID": hm.DestinationTownID, "heroID": heroID, "currentTownID": hm.CurrentTownID, "destinationTownID": hm.DestinationTownID,
"x": hm.CurrentX, "y": hm.CurrentY, "x": hm.CurrentX, "y": hm.CurrentY, "runId": "post-fix",
}) })
} else if len(hm.Road.Waypoints) < 2 { } else if len(hm.Road.Waypoints) < 2 {
agentDebugLog("H2", "movement.go:StateWalking", "road has fewer than 2 waypoints", map[string]any{ agentDebugLog("H2", "movement.go:StateWalking", "road has fewer than 2 waypoints", map[string]any{
"heroID": heroID, "roadID": hm.Road.ID, "waypointCount": len(hm.Road.Waypoints), "heroID": heroID, "roadID": hm.Road.ID, "waypointCount": len(hm.Road.Waypoints),
"runId": "post-fix",
}) })
} }
// #endregion // #endregion
@ -588,26 +801,49 @@ func ProcessSingleHeroMovementTick(
return return
} }
if onEncounter != nil && hm.ShouldEncounter(now) { canRollEncounter := hm.Road != nil && len(hm.Road.Waypoints) >= 2
if canRollEncounter && (onEncounter != nil || sender != nil || onMerchantEncounter != nil) {
monster, enemy, hit := hm.rollRoadEncounter(now)
if hit {
// #region agent log // #region agent log
agentDebugLog("H3", "movement.go:encounter", "encounter starting", map[string]any{ nWP := len(hm.Road.Waypoints)
"heroID": heroID, "roadNil": hm.Road == nil, "waypointCount": func() int { agentDebugLog("H3", "movement.go:encounter", "road encounter", map[string]any{
if hm.Road == nil { "heroID": heroID, "waypointCount": nWP, "monster": monster,
return -1 "wilderness": hm.wildernessFactor(now),
} "x": hm.CurrentX, "y": hm.CurrentY,
return len(hm.Road.Waypoints) "runId": "post-fix",
}(),
"x": hm.CurrentX, "y": hm.CurrentY, "currentTownID": hm.CurrentTownID,
}) })
// #endregion // #endregion
enemy := PickEnemyForLevel(hm.Hero.Level) if monster {
if onEncounter != nil {
hm.LastEncounterAt = now hm.LastEncounterAt = now
onEncounter(hm, &enemy, now) onEncounter(hm, &enemy, now)
return return
} }
// No monster handler — skip consuming the roll (extremely rare).
} else {
cost := WanderingMerchantCost(hm.Hero.Level)
if sender != nil || onMerchantEncounter != nil {
hm.LastEncounterAt = now
if sender != nil {
sender.SendToHero(heroID, "npc_encounter", model.NPCEncounterPayload{
NPCID: 0,
NPCName: "Wandering Merchant",
Role: "alms",
Cost: cost,
})
}
if onMerchantEncounter != nil {
onMerchantEncounter(hm, now, cost)
}
return
}
}
}
}
if sender != nil { if sender != nil {
sender.SendToHero(heroID, "hero_move", hm.MovePayload()) sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
} }
hm.Hero.PositionX = hm.CurrentX hm.Hero.PositionX = hm.CurrentX

@ -134,7 +134,12 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
if !next.After(hm.LastMoveTick) { if !next.After(hm.LastMoveTick) {
break break
} }
ProcessSingleHeroMovementTick(hero.ID, hm, s.graph, next, nil, encounter) onMerchant := func(hm *HeroMovement, tickNow time.Time, cost int64) {
_ = tickNow
_ = cost
s.addLog(ctx, hm.Hero.ID, "Encountered a Wandering Merchant on the road")
}
ProcessSingleHeroMovementTick(hero.ID, hm, s.graph, next, nil, encounter, onMerchant)
if hm.Hero.State == model.StateDead || hm.Hero.HP <= 0 { if hm.Hero.State == model.StateDead || hm.Hero.HP <= 0 {
break break
} }

@ -141,6 +141,15 @@ type TownNPCVisitPayload struct {
// TownExitPayload is sent when the hero leaves a town. // TownExitPayload is sent when the hero leaves a town.
type TownExitPayload struct{} type TownExitPayload struct{}
// NPCEncounterPayload is sent when the hero meets a wandering NPC on the road (e.g. merchant).
type NPCEncounterPayload struct {
NPCID int64 `json:"npcId"`
NPCName string `json:"npcName"`
Role string `json:"role"`
Dialogue string `json:"dialogue,omitempty"`
Cost int64 `json:"cost"`
}
// LevelUpPayload is sent on level-up. // LevelUpPayload is sent on level-up.
type LevelUpPayload struct { type LevelUpPayload struct {
NewLevel int `json:"newLevel"` NewLevel int `json:"newLevel"`

@ -73,7 +73,7 @@ CREATE INDEX IF NOT EXISTS idx_hero_quests_hero ON hero_quests(hero_id);
CREATE INDEX IF NOT EXISTS idx_hero_quests_status ON hero_quests(hero_id, status); CREATE INDEX IF NOT EXISTS idx_hero_quests_status ON hero_quests(hero_id, status);
-- ============================================================ -- ============================================================
-- Seed data: towns -- Seed data: towns (idempotent — DB may already have these names)
-- ============================================================ -- ============================================================
INSERT INTO towns (name, biome, world_x, world_y, radius, level_min, level_max) VALUES INSERT INTO towns (name, biome, world_x, world_y, radius, level_min, level_max) VALUES
('Willowdale', 'meadow', 50, 15, 8.0, 1, 5), ('Willowdale', 'meadow', 50, 15, 8.0, 1, 5),
@ -82,116 +82,166 @@ INSERT INTO towns (name, biome, world_x, world_y, radius, level_min, level_max)
('Redcliff', 'canyon', 650, 195, 8.0, 16, 22), ('Redcliff', 'canyon', 650, 195, 8.0, 16, 22),
('Boghollow', 'swamp', 900, 270, 8.0, 22, 28), ('Boghollow', 'swamp', 900, 270, 8.0, 22, 28),
('Cinderkeep', 'volcanic', 1200, 360, 8.0, 28, 34), ('Cinderkeep', 'volcanic', 1200, 360, 8.0, 28, 34),
('Starfall', 'astral', 1550, 465, 8.0, 34, 40); ('Starfall', 'astral', 1550, 465, 8.0, 34, 40)
ON CONFLICT (name) DO NOTHING;
-- ============================================================ -- ============================================================
-- Seed data: NPCs (2-3 per town) -- Seed data: NPCs (2-3 per town; resolve town_id by name)
-- ============================================================ -- ============================================================
INSERT INTO npcs (town_id, name, type, offset_x, offset_y) VALUES INSERT INTO npcs (town_id, name, type, offset_x, offset_y)
-- Willowdale (meadow) SELECT t.id, v.npc_name, v.npc_type, v.ox, v.oy
(1, 'Elder Maren', 'quest_giver', -2.0, 1.0), FROM (VALUES
(1, 'Peddler Finn', 'merchant', 3.0, 0.0), ('Willowdale', 'Elder Maren', 'quest_giver', -2.0::double precision, 1.0::double precision),
(1, 'Sister Asha', 'healer', 0.0, -2.5), ('Willowdale', 'Peddler Finn', 'merchant', 3.0, 0.0),
-- Thornwatch (forest) ('Willowdale', 'Sister Asha', 'healer', 0.0, -2.5),
(2, 'Guard Halric', 'quest_giver', -3.0, 0.5), ('Thornwatch', 'Guard Halric', 'quest_giver', -3.0, 0.5),
(2, 'Trader Wynn', 'merchant', 2.0, 2.0), ('Thornwatch', 'Trader Wynn', 'merchant', 2.0, 2.0),
-- Ashengard (ruins) ('Ashengard', 'Scholar Orin', 'quest_giver', 1.0, -2.0),
(3, 'Scholar Orin', 'quest_giver', 1.0, -2.0), ('Ashengard', 'Bone Merchant', 'merchant', -2.0, 3.0),
(3, 'Bone Merchant', 'merchant', -2.0, 3.0), ('Ashengard', 'Priestess Liora', 'healer', 3.0, 1.0),
(3, 'Priestess Liora', 'healer', 3.0, 1.0), ('Redcliff', 'Foreman Brak', 'quest_giver', -1.0, 2.0),
-- Redcliff (canyon) ('Redcliff', 'Miner Supplies', 'merchant', 2.5, -1.0),
(4, 'Foreman Brak', 'quest_giver', -1.0, 2.0), ('Boghollow', 'Witch Nessa', 'quest_giver', 0.0, 3.0),
(4, 'Miner Supplies', 'merchant', 2.5, -1.0), ('Boghollow', 'Swamp Trader', 'merchant', -3.0, -1.0),
-- Boghollow (swamp) ('Boghollow', 'Marsh Healer Ren', 'healer', 2.0, 0.0),
(5, 'Witch Nessa', 'quest_giver', 0.0, 3.0), ('Cinderkeep', 'Forge-master Kael', 'quest_giver', -2.5, 0.0),
(5, 'Swamp Trader', 'merchant', -3.0, -1.0), ('Cinderkeep', 'Ember Merchant', 'merchant', 1.0, 2.5),
(5, 'Marsh Healer Ren', 'healer', 2.0, 0.0), ('Starfall', 'Seer Aelith', 'quest_giver', 0.0, -3.0),
-- Cinderkeep (volcanic) ('Starfall', 'Void Trader', 'merchant', 3.0, 1.0),
(6, 'Forge-master Kael','quest_giver', -2.5, 0.0), ('Starfall', 'Astral Mender', 'healer', -2.0, 2.0)
(6, 'Ember Merchant', 'merchant', 1.0, 2.5), ) AS v(town_name, npc_name, npc_type, ox, oy)
-- Starfall (astral) JOIN towns t ON t.name = v.town_name
(7, 'Seer Aelith', 'quest_giver', 0.0, -3.0), WHERE NOT EXISTS (
(7, 'Void Trader', 'merchant', 3.0, 1.0), SELECT 1 FROM npcs n WHERE n.town_id = t.id AND n.name = v.npc_name
(7, 'Astral Mender', 'healer', -2.0, 2.0); );
-- ============================================================ -- ============================================================
-- Seed data: quests -- Seed data: quests (resolve npc_id / target_town_id by name; skip duplicates)
-- ============================================================ -- ============================================================
-- Willowdale quests (Elder Maren, npc_id = 1) -- Willowdale — Elder Maren
INSERT INTO quests (npc_id, title, description, type, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions) VALUES INSERT INTO quests (npc_id, title, description, type, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions)
(1, 'Wolf Cull', SELECT n.id, s.title, s.description, s.qtype, s.target_count, s.target_enemy_type, s.target_town_id, s.drop_chance, s.min_level, s.max_level, s.reward_xp, s.reward_gold, s.reward_potions
FROM npcs n
JOIN towns t ON t.id = n.town_id
CROSS JOIN (VALUES
('Wolf Cull',
'The wolves near Willowdale are getting bolder. Thin their numbers.', 'The wolves near Willowdale are getting bolder. Thin their numbers.',
'kill_count', 5, 'wolf', NULL, 0.0, 1, 5, 30, 15, 0), 'kill_count', 5, 'wolf'::text, NULL::bigint, 0.0::double precision, 1, 5, 30::bigint, 15::bigint, 0),
(1, 'Boar Hunt', ('Boar Hunt',
'Wild boars are trampling the crops. Take care of them.', 'Wild boars are trampling the crops. Take care of them.',
'kill_count', 8, 'boar', NULL, 0.0, 2, 6, 50, 25, 1), 'kill_count', 8, 'boar', NULL, 0.0, 2, 6, 50::bigint, 25::bigint, 1),
(1, 'Deliver to Thornwatch', ('Deliver to Thornwatch',
'Carry this supply manifest to Guard Halric in Thornwatch.', 'Carry this supply manifest to Guard Halric in Thornwatch.',
'visit_town', 1, NULL, 2, 0.0, 1, 10, 40, 20, 0); 'visit_town', 1, NULL, (SELECT id FROM towns WHERE name = 'Thornwatch' LIMIT 1), 0.0, 1, 10, 40::bigint, 20::bigint, 0)
) AS s(title, description, qtype, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions)
-- Thornwatch quests (Guard Halric, npc_id = 4) WHERE t.name = 'Willowdale' AND n.name = 'Elder Maren'
INSERT INTO quests (npc_id, title, description, type, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions) VALUES AND NOT EXISTS (SELECT 1 FROM quests q WHERE q.npc_id = n.id AND q.title = s.title);
(4, 'Spider Infestation',
-- Thornwatch — Guard Halric
INSERT INTO quests (npc_id, title, description, type, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions)
SELECT n.id, s.title, s.description, s.qtype, s.target_count, s.target_enemy_type, s.target_town_id, s.drop_chance, s.min_level, s.max_level, s.reward_xp, s.reward_gold, s.reward_potions
FROM npcs n
JOIN towns t ON t.id = n.town_id
CROSS JOIN (VALUES
('Spider Infestation',
'Cave spiders have overrun the logging trails. Clear them out.', 'Cave spiders have overrun the logging trails. Clear them out.',
'kill_count', 12, 'spider', NULL, 0.0, 5, 10, 80, 40, 1), 'kill_count', 12, 'spider'::text, NULL::bigint, 0.0::double precision, 5, 10, 80::bigint, 40::bigint, 1),
(4, 'Spider Fang Collection', ('Spider Fang Collection',
'We need spider fangs for antivenom. Collect them from slain spiders.', 'We need spider fangs for antivenom. Collect them from slain spiders.',
'collect_item', 5, 'spider', NULL, 0.3, 5, 10, 100, 60, 1), 'collect_item', 5, 'spider', NULL, 0.3, 5, 10, 100::bigint, 60::bigint, 1),
(4, 'Forest Patrol', ('Forest Patrol',
'Slay any 15 creatures along the forest road to keep it safe.', 'Slay any 15 creatures along the forest road to keep it safe.',
'kill_count', 15, NULL, NULL, 0.0, 5, 12, 120, 70, 1); 'kill_count', 15, NULL::text, NULL::bigint, 0.0, 5, 12, 120::bigint, 70::bigint, 1)
) AS s(title, description, qtype, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions)
-- Ashengard quests (Scholar Orin, npc_id = 6) WHERE t.name = 'Thornwatch' AND n.name = 'Guard Halric'
INSERT INTO quests (npc_id, title, description, type, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions) VALUES AND NOT EXISTS (SELECT 1 FROM quests q WHERE q.npc_id = n.id AND q.title = s.title);
(6, 'Undead Purge',
-- Ashengard — Scholar Orin
INSERT INTO quests (npc_id, title, description, type, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions)
SELECT n.id, s.title, s.description, s.qtype, s.target_count, s.target_enemy_type, s.target_town_id, s.drop_chance, s.min_level, s.max_level, s.reward_xp, s.reward_gold, s.reward_potions
FROM npcs n
JOIN towns t ON t.id = n.town_id
CROSS JOIN (VALUES
('Undead Purge',
'The ruins are crawling with undead. Destroy the zombies.', 'The ruins are crawling with undead. Destroy the zombies.',
'kill_count', 15, 'zombie', NULL, 0.0, 10, 16, 150, 80, 1), 'kill_count', 15, 'zombie'::text, NULL::bigint, 0.0::double precision, 10, 16, 150::bigint, 80::bigint, 1),
(6, 'Ancient Relics', ('Ancient Relics',
'Search fallen enemies for fragments of the old kingdom.', 'Search fallen enemies for fragments of the old kingdom.',
'collect_item', 8, NULL, NULL, 0.25, 10, 16, 200, 120, 2), 'collect_item', 8, NULL::text, NULL::bigint, 0.25, 10, 16, 200::bigint, 120::bigint, 2),
(6, 'Report to Redcliff', ('Report to Redcliff',
'Warn Foreman Brak about the growing undead threat.', 'Warn Foreman Brak about the growing undead threat.',
'visit_town', 1, NULL, 4, 0.0, 10, 20, 120, 60, 0); 'visit_town', 1, NULL::text, (SELECT id FROM towns WHERE name = 'Redcliff' LIMIT 1), 0.0, 10, 20, 120::bigint, 60::bigint, 0)
) AS s(title, description, qtype, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions)
-- Redcliff quests (Foreman Brak, npc_id = 9) WHERE t.name = 'Ashengard' AND n.name = 'Scholar Orin'
INSERT INTO quests (npc_id, title, description, type, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions) VALUES AND NOT EXISTS (SELECT 1 FROM quests q WHERE q.npc_id = n.id AND q.title = s.title);
(9, 'Orc Raider Cleanup',
-- Redcliff — Foreman Brak
INSERT INTO quests (npc_id, title, description, type, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions)
SELECT n.id, s.title, s.description, s.qtype, s.target_count, s.target_enemy_type, s.target_town_id, s.drop_chance, s.min_level, s.max_level, s.reward_xp, s.reward_gold, s.reward_potions
FROM npcs n
JOIN towns t ON t.id = n.town_id
CROSS JOIN (VALUES
('Orc Raider Cleanup',
'Orc warriors are raiding the mine carts. Stop them.', 'Orc warriors are raiding the mine carts. Stop them.',
'kill_count', 20, 'orc', NULL, 0.0, 16, 22, 250, 150, 2), 'kill_count', 20, 'orc'::text, NULL::bigint, 0.0::double precision, 16, 22, 250::bigint, 150::bigint, 2),
(9, 'Ore Samples', ('Ore Samples',
'Collect glowing ore fragments from defeated enemies near the canyon.', 'Collect glowing ore fragments from defeated enemies near the canyon.',
'collect_item', 6, NULL, NULL, 0.3, 16, 22, 200, 120, 1); 'collect_item', 6, NULL::text, NULL::bigint, 0.3, 16, 22, 200::bigint, 120::bigint, 1)
) AS s(title, description, qtype, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions)
-- Boghollow quests (Witch Nessa, npc_id = 11) WHERE t.name = 'Redcliff' AND n.name = 'Foreman Brak'
INSERT INTO quests (npc_id, title, description, type, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions) VALUES AND NOT EXISTS (SELECT 1 FROM quests q WHERE q.npc_id = n.id AND q.title = s.title);
(11, 'Swamp Creatures',
-- Boghollow — Witch Nessa
INSERT INTO quests (npc_id, title, description, type, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions)
SELECT n.id, s.title, s.description, s.qtype, s.target_count, s.target_enemy_type, s.target_town_id, s.drop_chance, s.min_level, s.max_level, s.reward_xp, s.reward_gold, s.reward_potions
FROM npcs n
JOIN towns t ON t.id = n.town_id
CROSS JOIN (VALUES
('Swamp Creatures',
'The swamp beasts grow more aggressive by the day. Cull 25.', 'The swamp beasts grow more aggressive by the day. Cull 25.',
'kill_count', 25, NULL, NULL, 0.0, 22, 28, 350, 200, 2), 'kill_count', 25, NULL::text, NULL::bigint, 0.0::double precision, 22, 28, 350::bigint, 200::bigint, 2),
(11, 'Venomous Harvest', ('Venomous Harvest',
'Collect venom sacs from swamp creatures for my brews.', 'Collect venom sacs from swamp creatures for my brews.',
'collect_item', 10, NULL, NULL, 0.25, 22, 28, 400, 250, 2), 'collect_item', 10, NULL::text, NULL::bigint, 0.25, 22, 28, 400::bigint, 250::bigint, 2),
(11, 'Message to Cinderkeep', ('Message to Cinderkeep',
'The forgemaster needs to know about the corruption spreading here.', 'The forgemaster needs to know about the corruption spreading here.',
'visit_town', 1, NULL, 6, 0.0, 22, 34, 200, 100, 1); 'visit_town', 1, NULL::text, (SELECT id FROM towns WHERE name = 'Cinderkeep' LIMIT 1), 0.0, 22, 34, 200::bigint, 100::bigint, 1)
) AS s(title, description, qtype, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions)
-- Cinderkeep quests (Forge-master Kael, npc_id = 14) WHERE t.name = 'Boghollow' AND n.name = 'Witch Nessa'
INSERT INTO quests (npc_id, title, description, type, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions) VALUES AND NOT EXISTS (SELECT 1 FROM quests q WHERE q.npc_id = n.id AND q.title = s.title);
(14, 'Demon Slayer',
-- Cinderkeep — Forge-master Kael
INSERT INTO quests (npc_id, title, description, type, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions)
SELECT n.id, s.title, s.description, s.qtype, s.target_count, s.target_enemy_type, s.target_town_id, s.drop_chance, s.min_level, s.max_level, s.reward_xp, s.reward_gold, s.reward_potions
FROM npcs n
JOIN towns t ON t.id = n.town_id
CROSS JOIN (VALUES
('Demon Slayer',
'Fire demons are emerging from the vents. Destroy them.', 'Fire demons are emerging from the vents. Destroy them.',
'kill_count', 10, 'fire_demon', NULL, 0.0, 28, 34, 500, 300, 2), 'kill_count', 10, 'fire_demon'::text, NULL::bigint, 0.0::double precision, 28, 34, 500::bigint, 300::bigint, 2),
(14, 'Infernal Cores', ('Infernal Cores',
'Retrieve smoldering cores from defeated fire demons.', 'Retrieve smoldering cores from defeated fire demons.',
'collect_item', 5, 'fire_demon', NULL, 0.3, 28, 34, 600, 350, 3); 'collect_item', 5, 'fire_demon'::text, NULL::bigint, 0.3::double precision, 28, 34, 600::bigint, 350::bigint, 3)
) AS s(title, description, qtype, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions)
-- Starfall quests (Seer Aelith, npc_id = 16) WHERE t.name = 'Cinderkeep' AND n.name = 'Forge-master Kael'
INSERT INTO quests (npc_id, title, description, type, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions) VALUES AND NOT EXISTS (SELECT 1 FROM quests q WHERE q.npc_id = n.id AND q.title = s.title);
(16, 'Titan''s Challenge',
-- Starfall — Seer Aelith
INSERT INTO quests (npc_id, title, description, type, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions)
SELECT n.id, s.title, s.description, s.qtype, s.target_count, s.target_enemy_type, s.target_town_id, s.drop_chance, s.min_level, s.max_level, s.reward_xp, s.reward_gold, s.reward_potions
FROM npcs n
JOIN towns t ON t.id = n.town_id
CROSS JOIN (VALUES
('Titan''s Challenge',
'The Lightning Titans must be stopped before they breach the gate.', 'The Lightning Titans must be stopped before they breach the gate.',
'kill_count', 8, 'lightning_titan', NULL, 0.0, 34, 40, 800, 500, 3), 'kill_count', 8, 'lightning_titan'::text, NULL::bigint, 0.0::double precision, 34, 40, 800::bigint, 500::bigint, 3),
(16, 'Void Fragments', ('Void Fragments',
'Gather crystallized void energy from the astral enemies.', 'Gather crystallized void energy from the astral enemies.',
'collect_item', 8, NULL, NULL, 0.2, 34, 40, 1000, 600, 3), 'collect_item', 8, NULL::text, NULL::bigint, 0.2, 34, 40, 1000::bigint, 600::bigint, 3),
(16, 'Full Circle', ('Full Circle',
'Return to Willowdale and tell Elder Maren of your journey.', 'Return to Willowdale and tell Elder Maren of your journey.',
'visit_town', 1, NULL, 1, 0.0, 34, 40, 500, 300, 2); 'visit_town', 1, NULL::text, (SELECT id FROM towns WHERE name = 'Willowdale' LIMIT 1), 0.0, 34, 40, 500::bigint, 300::bigint, 2)
) AS s(title, description, qtype, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions)
WHERE t.name = 'Starfall' AND n.name = 'Seer Aelith'
AND NOT EXISTS (SELECT 1 FROM quests q WHERE q.npc_id = n.id AND q.title = s.title);

@ -4,6 +4,10 @@ server {
listen [::]:80; listen [::]:80;
server_name _; server_name _;
# Docker embedded DNS defer resolving "backend" until request time (avoids
# startup race: nginx loads config before the backend container is registered).
resolver 127.0.0.11 ipv6=off valid=10s;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
@ -19,7 +23,8 @@ server {
# Proxy REST API to backend # Proxy REST API to backend
location /api/ { location /api/ {
proxy_pass http://backend:8080; set $backend_host backend;
proxy_pass http://$backend_host:8080;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@ -28,7 +33,8 @@ server {
# Proxy WebSocket to backend # Proxy WebSocket to backend
location /ws { location /ws {
proxy_pass http://backend:8080; set $backend_host backend;
proxy_pass http://$backend_host:8080;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
@ -62,6 +68,8 @@ server {
ssl_ciphers HIGH:!aNULL:!MD5; ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on; ssl_prefer_server_ciphers on;
resolver 127.0.0.11 ipv6=off valid=10s;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
@ -77,7 +85,8 @@ server {
# Proxy REST API to backend # Proxy REST API to backend
location /api/ { location /api/ {
proxy_pass http://backend:8080; set $backend_host backend;
proxy_pass http://$backend_host:8080;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@ -86,7 +95,8 @@ server {
# Proxy WebSocket to backend # Proxy WebSocket to backend
location /ws { location /ws {
proxy_pass http://backend:8080; set $backend_host backend;
proxy_pass http://$backend_host:8080;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";

Loading…
Cancel
Save