diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go index 9d96f4a..e9648fd 100644 --- a/backend/internal/game/engine.go +++ b/backend/internal/game/engine.go @@ -775,7 +775,7 @@ func (e *Engine) processMovementTick(now time.Time) { } 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 { if hm.State == model.StateWalking { - e.sender.SendToHero(heroID, "position_sync", hm.PositionSyncPayload()) + e.sender.SendToHero(heroID, "position_sync", hm.PositionSyncPayload(now)) } } } diff --git a/backend/internal/game/movement.go b/backend/internal/game/movement.go index 7c2154f..e948e2b 100644 --- a/backend/internal/game/movement.go +++ b/backend/internal/game/movement.go @@ -33,12 +33,19 @@ func agentDebugLog(hypothesisID, location, message string, data map[string]any) if err != nil { return } - f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { + 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 { + continue + } + _, _ = f.Write(append(b, '\n')) + _ = f.Close() return } - _, _ = f.Write(append(b, '\n')) - _ = f.Close() } // #endregion @@ -53,11 +60,22 @@ const ( // PositionSyncRate is how often the server sends a full position_sync (drift correction). PositionSyncRate = 10 * time.Second - // EncounterCooldownBase is the minimum gap between random encounters. - EncounterCooldownBase = 15 * time.Second + // EncounterCooldownBase is the minimum gap between road encounters (monster or merchant). + 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. - EncounterChancePerTick = 0.04 + // AdventureDurationMin/Max bound how long an off-road excursion lasts. + 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 = 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 []int64 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. @@ -155,42 +178,103 @@ func NewHeroMovement(hero *model.Hero, graph *RoadGraph, now time.Time) *HeroMov hm.pickDestination(graph) } hm.assignRoad(graph) + if hm.Road == nil { + hm.pickDestination(graph) + hm.assignRoad(graph) + } hm.State = model.StateWalking 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. +// Only towns connected by a roads row are chosen — TownOrder alone is not enough. func (hm *HeroMovement) pickDestination(graph *RoadGraph) { if hm.CurrentTownID == 0 { - // Hero is not associated with any town yet, pick nearest. 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) 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 { hm.DestinationTownID = graph.TownOrder[0] } return } - n := len(graph.TownOrder) - if n <= 1 { - hm.DestinationTownID = hm.CurrentTownID + if dest := hm.firstReachableOnRing(graph, idx); dest != 0 { + hm.DestinationTownID = dest return } - - nextIdx := idx + hm.Direction - if nextIdx >= n { - nextIdx = 0 + if d := hm.firstOutgoingDestination(graph); d != 0 { + hm.DestinationTownID = d + return } - if nextIdx < 0 { - nextIdx = n - 1 + if d := hm.firstReachableAny(graph); d != 0 { + hm.DestinationTownID = d + return } - - hm.DestinationTownID = graph.TownOrder[nextIdx] + hm.DestinationTownID = hm.CurrentTownID } // assignRoad finds and configures the road from CurrentTownID to DestinationTownID. @@ -207,7 +291,7 @@ func (hm *HeroMovement) assignRoad(graph *RoadGraph) { // #region agent log agentDebugLog("H5", "movement.go:assignRoad", "no road after nearest retry", map[string]any{ "currentTownID": hm.CurrentTownID, "destinationTownID": hm.DestinationTownID, - "x": hm.CurrentX, "y": hm.CurrentY, + "x": hm.CurrentX, "y": hm.CurrentY, "runId": "post-fix", }) // #endregion // No road available, will retry next tick. @@ -344,12 +428,124 @@ func (hm *HeroMovement) TargetPoint() (float64, float64) { return wp.X, wp.Y } -// ShouldEncounter rolls for a random encounter, respecting the cooldown. -func (hm *HeroMovement) ShouldEncounter(now time.Time) bool { - if now.Sub(hm.LastEncounterAt) < EncounterCooldownBase { - return false +func (hm *HeroMovement) adventureActive(now time.Time) bool { + return !hm.AdventureStartAt.IsZero() && now.Before(hm.AdventureEndAt) +} + +func (hm *HeroMovement) expireAdventureIfNeeded(now time.Time) { + if hm.AdventureEndAt.IsZero() { + return } - return rand.Float64() < EncounterChancePerTick + 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 + } + 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 @@ -361,6 +557,9 @@ func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) { hm.Road = nil hm.TownNPCQueue = nil hm.NextTownNPCRollAt = time.Time{} + hm.AdventureStartAt = time.Time{} + hm.AdventureEndAt = time.Time{} + hm.AdventureSide = 0 ids := graph.TownNPCIDs(destID) if len(ids) == 0 { @@ -432,14 +631,15 @@ func (hm *HeroMovement) SyncToHero() { hm.Hero.MoveState = string(hm.State) } -// MovePayload builds the hero_move WS payload. -func (hm *HeroMovement) MovePayload() model.HeroMovePayload { +// MovePayload builds the hero_move WS payload (includes off-road lateral offset for display). +func (hm *HeroMovement) MovePayload(now time.Time) model.HeroMovePayload { tx, ty := hm.TargetPoint() + ox, oy := hm.displayOffset(now) return model.HeroMovePayload{ - X: hm.CurrentX, - Y: hm.CurrentY, - TargetX: tx, - TargetY: ty, + X: hm.CurrentX + ox, + Y: hm.CurrentY + oy, + TargetX: tx + ox, + TargetY: ty + oy, Speed: hm.Speed, Heading: hm.Heading(), } @@ -463,10 +663,11 @@ func (hm *HeroMovement) RoutePayload() *model.RouteAssignedPayload { } // 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{ - X: hm.CurrentX, - Y: hm.CurrentY, + X: hm.CurrentX + ox, + Y: hm.CurrentY + oy, WaypointIndex: hm.WaypointIndex, WaypointFraction: hm.WaypointFraction, State: string(hm.State), @@ -483,6 +684,9 @@ func randomRestDuration() time.Duration { // offline: synchronous SimulateOneFight via callback). 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. // 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. @@ -496,6 +700,7 @@ func ProcessSingleHeroMovementTick( now time.Time, sender MessageSender, onEncounter EncounterStarter, + onMerchantEncounter MerchantEncounterHook, ) { if graph == nil { return @@ -546,15 +751,23 @@ func ProcessSingleHeroMovementTick( } 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 if hm.Road == nil { agentDebugLog("H1", "movement.go:StateWalking", "walking with nil Road", map[string]any{ "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 { 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), + "runId": "post-fix", }) } // #endregion @@ -588,26 +801,49 @@ func ProcessSingleHeroMovementTick( return } - if onEncounter != nil && hm.ShouldEncounter(now) { - // #region agent log - agentDebugLog("H3", "movement.go:encounter", "encounter starting", map[string]any{ - "heroID": heroID, "roadNil": hm.Road == nil, "waypointCount": func() int { - if hm.Road == nil { - return -1 + 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 + nWP := len(hm.Road.Waypoints) + agentDebugLog("H3", "movement.go:encounter", "road encounter", map[string]any{ + "heroID": heroID, "waypointCount": nWP, "monster": monster, + "wilderness": hm.wildernessFactor(now), + "x": hm.CurrentX, "y": hm.CurrentY, + "runId": "post-fix", + }) + // #endregion + if monster { + if onEncounter != nil { + hm.LastEncounterAt = now + onEncounter(hm, &enemy, now) + return } - return len(hm.Road.Waypoints) - }(), - "x": hm.CurrentX, "y": hm.CurrentY, "currentTownID": hm.CurrentTownID, - }) - // #endregion - enemy := PickEnemyForLevel(hm.Hero.Level) - hm.LastEncounterAt = now - onEncounter(hm, &enemy, now) - 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 { - sender.SendToHero(heroID, "hero_move", hm.MovePayload()) + sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) } hm.Hero.PositionX = hm.CurrentX diff --git a/backend/internal/game/offline.go b/backend/internal/game/offline.go index 10c0bfc..6a9f64a 100644 --- a/backend/internal/game/offline.go +++ b/backend/internal/game/offline.go @@ -134,7 +134,12 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her if !next.After(hm.LastMoveTick) { 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 { break } diff --git a/backend/internal/model/ws_message.go b/backend/internal/model/ws_message.go index c883cf8..426bb1e 100644 --- a/backend/internal/model/ws_message.go +++ b/backend/internal/model/ws_message.go @@ -141,6 +141,15 @@ type TownNPCVisitPayload struct { // TownExitPayload is sent when the hero leaves a town. 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. type LevelUpPayload struct { NewLevel int `json:"newLevel"` diff --git a/backend/migrations/000006_quest_system.sql b/backend/migrations/000006_quest_system.sql index 52d42e4..af77347 100644 --- a/backend/migrations/000006_quest_system.sql +++ b/backend/migrations/000006_quest_system.sql @@ -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); -- ============================================================ --- 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 ('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), ('Boghollow', 'swamp', 900, 270, 8.0, 22, 28), ('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 - -- Willowdale (meadow) - (1, 'Elder Maren', 'quest_giver', -2.0, 1.0), - (1, 'Peddler Finn', 'merchant', 3.0, 0.0), - (1, 'Sister Asha', 'healer', 0.0, -2.5), - -- Thornwatch (forest) - (2, 'Guard Halric', 'quest_giver', -3.0, 0.5), - (2, 'Trader Wynn', 'merchant', 2.0, 2.0), - -- Ashengard (ruins) - (3, 'Scholar Orin', 'quest_giver', 1.0, -2.0), - (3, 'Bone Merchant', 'merchant', -2.0, 3.0), - (3, 'Priestess Liora', 'healer', 3.0, 1.0), - -- Redcliff (canyon) - (4, 'Foreman Brak', 'quest_giver', -1.0, 2.0), - (4, 'Miner Supplies', 'merchant', 2.5, -1.0), - -- Boghollow (swamp) - (5, 'Witch Nessa', 'quest_giver', 0.0, 3.0), - (5, 'Swamp Trader', 'merchant', -3.0, -1.0), - (5, 'Marsh Healer Ren', 'healer', 2.0, 0.0), - -- Cinderkeep (volcanic) - (6, 'Forge-master Kael','quest_giver', -2.5, 0.0), - (6, 'Ember Merchant', 'merchant', 1.0, 2.5), - -- Starfall (astral) - (7, 'Seer Aelith', 'quest_giver', 0.0, -3.0), - (7, 'Void Trader', 'merchant', 3.0, 1.0), - (7, 'Astral Mender', 'healer', -2.0, 2.0); +INSERT INTO npcs (town_id, name, type, offset_x, offset_y) +SELECT t.id, v.npc_name, v.npc_type, v.ox, v.oy +FROM (VALUES + ('Willowdale', 'Elder Maren', 'quest_giver', -2.0::double precision, 1.0::double precision), + ('Willowdale', 'Peddler Finn', 'merchant', 3.0, 0.0), + ('Willowdale', 'Sister Asha', 'healer', 0.0, -2.5), + ('Thornwatch', 'Guard Halric', 'quest_giver', -3.0, 0.5), + ('Thornwatch', 'Trader Wynn', 'merchant', 2.0, 2.0), + ('Ashengard', 'Scholar Orin', 'quest_giver', 1.0, -2.0), + ('Ashengard', 'Bone Merchant', 'merchant', -2.0, 3.0), + ('Ashengard', 'Priestess Liora', 'healer', 3.0, 1.0), + ('Redcliff', 'Foreman Brak', 'quest_giver', -1.0, 2.0), + ('Redcliff', 'Miner Supplies', 'merchant', 2.5, -1.0), + ('Boghollow', 'Witch Nessa', 'quest_giver', 0.0, 3.0), + ('Boghollow', 'Swamp Trader', 'merchant', -3.0, -1.0), + ('Boghollow', 'Marsh Healer Ren', 'healer', 2.0, 0.0), + ('Cinderkeep', 'Forge-master Kael', 'quest_giver', -2.5, 0.0), + ('Cinderkeep', 'Ember Merchant', 'merchant', 1.0, 2.5), + ('Starfall', 'Seer Aelith', 'quest_giver', 0.0, -3.0), + ('Starfall', 'Void Trader', 'merchant', 3.0, 1.0), + ('Starfall', 'Astral Mender', 'healer', -2.0, 2.0) +) AS v(town_name, npc_name, npc_type, ox, oy) +JOIN towns t ON t.name = v.town_name +WHERE NOT EXISTS ( + SELECT 1 FROM npcs n WHERE n.town_id = t.id AND n.name = v.npc_name +); -- ============================================================ --- Seed data: quests +-- Seed data: quests (resolve npc_id / target_town_id by name; skip duplicates) -- ============================================================ --- Willowdale quests (Elder Maren, npc_id = 1) -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 - (1, 'Wolf Cull', +-- 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) +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.', - 'kill_count', 5, 'wolf', NULL, 0.0, 1, 5, 30, 15, 0), - (1, 'Boar Hunt', + 'kill_count', 5, 'wolf'::text, NULL::bigint, 0.0::double precision, 1, 5, 30::bigint, 15::bigint, 0), + ('Boar Hunt', 'Wild boars are trampling the crops. Take care of them.', - 'kill_count', 8, 'boar', NULL, 0.0, 2, 6, 50, 25, 1), - (1, 'Deliver to Thornwatch', + 'kill_count', 8, 'boar', NULL, 0.0, 2, 6, 50::bigint, 25::bigint, 1), + ('Deliver to Thornwatch', 'Carry this supply manifest to Guard Halric in Thornwatch.', - 'visit_town', 1, NULL, 2, 0.0, 1, 10, 40, 20, 0); - --- Thornwatch quests (Guard Halric, npc_id = 4) -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 - (4, 'Spider Infestation', + '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) +WHERE t.name = 'Willowdale' AND n.name = 'Elder Maren' + AND NOT EXISTS (SELECT 1 FROM quests q WHERE q.npc_id = n.id AND q.title = s.title); + +-- 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.', - 'kill_count', 12, 'spider', NULL, 0.0, 5, 10, 80, 40, 1), - (4, 'Spider Fang Collection', + 'kill_count', 12, 'spider'::text, NULL::bigint, 0.0::double precision, 5, 10, 80::bigint, 40::bigint, 1), + ('Spider Fang Collection', 'We need spider fangs for antivenom. Collect them from slain spiders.', - 'collect_item', 5, 'spider', NULL, 0.3, 5, 10, 100, 60, 1), - (4, 'Forest Patrol', + 'collect_item', 5, 'spider', NULL, 0.3, 5, 10, 100::bigint, 60::bigint, 1), + ('Forest Patrol', 'Slay any 15 creatures along the forest road to keep it safe.', - 'kill_count', 15, NULL, NULL, 0.0, 5, 12, 120, 70, 1); - --- Ashengard quests (Scholar Orin, npc_id = 6) -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 - (6, 'Undead Purge', + '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) +WHERE t.name = 'Thornwatch' AND n.name = 'Guard Halric' + AND NOT EXISTS (SELECT 1 FROM quests q WHERE q.npc_id = n.id AND q.title = s.title); + +-- 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.', - 'kill_count', 15, 'zombie', NULL, 0.0, 10, 16, 150, 80, 1), - (6, 'Ancient Relics', + 'kill_count', 15, 'zombie'::text, NULL::bigint, 0.0::double precision, 10, 16, 150::bigint, 80::bigint, 1), + ('Ancient Relics', 'Search fallen enemies for fragments of the old kingdom.', - 'collect_item', 8, NULL, NULL, 0.25, 10, 16, 200, 120, 2), - (6, 'Report to Redcliff', + 'collect_item', 8, NULL::text, NULL::bigint, 0.25, 10, 16, 200::bigint, 120::bigint, 2), + ('Report to Redcliff', 'Warn Foreman Brak about the growing undead threat.', - 'visit_town', 1, NULL, 4, 0.0, 10, 20, 120, 60, 0); - --- Redcliff quests (Foreman Brak, npc_id = 9) -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 - (9, 'Orc Raider Cleanup', + '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) +WHERE t.name = 'Ashengard' AND n.name = 'Scholar Orin' + AND NOT EXISTS (SELECT 1 FROM quests q WHERE q.npc_id = n.id AND q.title = s.title); + +-- 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.', - 'kill_count', 20, 'orc', NULL, 0.0, 16, 22, 250, 150, 2), - (9, 'Ore Samples', + 'kill_count', 20, 'orc'::text, NULL::bigint, 0.0::double precision, 16, 22, 250::bigint, 150::bigint, 2), + ('Ore Samples', 'Collect glowing ore fragments from defeated enemies near the canyon.', - 'collect_item', 6, NULL, NULL, 0.3, 16, 22, 200, 120, 1); - --- Boghollow quests (Witch Nessa, npc_id = 11) -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 - (11, 'Swamp Creatures', + '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) +WHERE t.name = 'Redcliff' AND n.name = 'Foreman Brak' + AND NOT EXISTS (SELECT 1 FROM quests q WHERE q.npc_id = n.id AND q.title = s.title); + +-- 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.', - 'kill_count', 25, NULL, NULL, 0.0, 22, 28, 350, 200, 2), - (11, 'Venomous Harvest', + 'kill_count', 25, NULL::text, NULL::bigint, 0.0::double precision, 22, 28, 350::bigint, 200::bigint, 2), + ('Venomous Harvest', 'Collect venom sacs from swamp creatures for my brews.', - 'collect_item', 10, NULL, NULL, 0.25, 22, 28, 400, 250, 2), - (11, 'Message to Cinderkeep', + 'collect_item', 10, NULL::text, NULL::bigint, 0.25, 22, 28, 400::bigint, 250::bigint, 2), + ('Message to Cinderkeep', 'The forgemaster needs to know about the corruption spreading here.', - 'visit_town', 1, NULL, 6, 0.0, 22, 34, 200, 100, 1); - --- Cinderkeep quests (Forge-master Kael, npc_id = 14) -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 - (14, 'Demon Slayer', + '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) +WHERE t.name = 'Boghollow' AND n.name = 'Witch Nessa' + AND NOT EXISTS (SELECT 1 FROM quests q WHERE q.npc_id = n.id AND q.title = s.title); + +-- 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.', - 'kill_count', 10, 'fire_demon', NULL, 0.0, 28, 34, 500, 300, 2), - (14, 'Infernal Cores', + 'kill_count', 10, 'fire_demon'::text, NULL::bigint, 0.0::double precision, 28, 34, 500::bigint, 300::bigint, 2), + ('Infernal Cores', 'Retrieve smoldering cores from defeated fire demons.', - 'collect_item', 5, 'fire_demon', NULL, 0.3, 28, 34, 600, 350, 3); - --- Starfall quests (Seer Aelith, npc_id = 16) -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 - (16, 'Titan''s Challenge', + '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) +WHERE t.name = 'Cinderkeep' AND n.name = 'Forge-master Kael' + AND NOT EXISTS (SELECT 1 FROM quests q WHERE q.npc_id = n.id AND q.title = s.title); + +-- 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.', - 'kill_count', 8, 'lightning_titan', NULL, 0.0, 34, 40, 800, 500, 3), - (16, 'Void Fragments', + 'kill_count', 8, 'lightning_titan'::text, NULL::bigint, 0.0::double precision, 34, 40, 800::bigint, 500::bigint, 3), + ('Void Fragments', 'Gather crystallized void energy from the astral enemies.', - 'collect_item', 8, NULL, NULL, 0.2, 34, 40, 1000, 600, 3), - (16, 'Full Circle', + 'collect_item', 8, NULL::text, NULL::bigint, 0.2, 34, 40, 1000::bigint, 600::bigint, 3), + ('Full Circle', '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); diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 41c1584..5fda8b0 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -4,6 +4,10 @@ server { listen [::]:80; 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; index index.html; @@ -19,7 +23,8 @@ server { # Proxy REST API to backend 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 X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -28,7 +33,8 @@ server { # Proxy WebSocket to backend location /ws { - proxy_pass http://backend:8080; + set $backend_host backend; + proxy_pass http://$backend_host:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; @@ -62,6 +68,8 @@ server { ssl_ciphers HIGH:!aNULL:!MD5; ssl_prefer_server_ciphers on; + resolver 127.0.0.11 ipv6=off valid=10s; + root /usr/share/nginx/html; index index.html; @@ -77,7 +85,8 @@ server { # Proxy REST API to backend 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 X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -86,7 +95,8 @@ server { # Proxy WebSocket to backend location /ws { - proxy_pass http://backend:8080; + set $backend_host backend; + proxy_pass http://$backend_host:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";