some fixes

master
Denis Ranneft 11 hours ago
parent d4b99a0f3c
commit 70bbda4337

@ -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))
}
}
}

@ -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)
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 {
return
continue
}
_, _ = f.Write(append(b, '\n'))
_ = f.Close()
return
}
}
// #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
}
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
@ -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) {
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
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
}
return len(hm.Road.Waypoints)
}(),
"x": hm.CurrentX, "y": hm.CurrentY, "currentTownID": hm.CurrentTownID,
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
enemy := PickEnemyForLevel(hm.Hero.Level)
if monster {
if onEncounter != nil {
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

@ -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
}

@ -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"`

@ -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);

@ -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";

Loading…
Cancel
Save