package game import ( "context" "fmt" "math" "math/rand" "sort" "github.com/jackc/pgx/v5/pgxpool" "github.com/denisovdennis/autohero/internal/model" ) // Point is a 2D coordinate on the world map. type Point struct { X, Y float64 } // Road connects two towns with a sequence of waypoints. type Road struct { ID int64 FromTownID int64 ToTownID int64 Waypoints []Point // ordered: first = from-town center, last = to-town center Distance float64 } // TownNPC is a quest/shop NPC placed in a town (from npcs table). type TownNPC struct { ID int64 Name string Type string } // RoadGraph is an immutable in-memory graph of all roads and towns, // loaded once at startup. type RoadGraph struct { Roads map[int64]*Road // road ID -> road TownRoads map[int64][]*Road // town ID -> outgoing roads Towns map[int64]*model.Town // town ID -> town TownOrder []int64 // ordered town IDs for sequential traversal TownNPCs map[int64][]TownNPC // town ID -> NPCs (stable order) NPCByID map[int64]TownNPC // NPC id -> row } // LoadRoadGraph reads roads and towns from the database, generates waypoints // deterministically, and returns an immutable RoadGraph. func LoadRoadGraph(ctx context.Context, pool *pgxpool.Pool) (*RoadGraph, error) { g := &RoadGraph{ Roads: make(map[int64]*Road), TownRoads: make(map[int64][]*Road), Towns: make(map[int64]*model.Town), TownNPCs: make(map[int64][]TownNPC), NPCByID: make(map[int64]TownNPC), } // Load towns. rows, err := pool.Query(ctx, `SELECT id, name, biome, world_x, world_y, radius, level_min, level_max FROM towns ORDER BY level_min ASC`) if err != nil { return nil, fmt.Errorf("load towns: %w", err) } defer rows.Close() for rows.Next() { var t model.Town if err := rows.Scan(&t.ID, &t.Name, &t.Biome, &t.WorldX, &t.WorldY, &t.Radius, &t.LevelMin, &t.LevelMax); err != nil { return nil, fmt.Errorf("scan town: %w", err) } g.Towns[t.ID] = &t g.TownOrder = append(g.TownOrder, t.ID) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate towns: %w", err) } npcRows, err := pool.Query(ctx, `SELECT id, town_id, name, type FROM npcs ORDER BY town_id, id`) if err != nil { return nil, fmt.Errorf("load npcs: %w", err) } defer npcRows.Close() for npcRows.Next() { var n TownNPC var townID int64 if err := npcRows.Scan(&n.ID, &townID, &n.Name, &n.Type); err != nil { return nil, fmt.Errorf("scan npc: %w", err) } g.NPCByID[n.ID] = n g.TownNPCs[townID] = append(g.TownNPCs[townID], n) } if err := npcRows.Err(); err != nil { return nil, fmt.Errorf("iterate npcs: %w", err) } // Load roads. roadRows, err := pool.Query(ctx, `SELECT id, from_town_id, to_town_id, distance FROM roads`) if err != nil { return nil, fmt.Errorf("load roads: %w", err) } defer roadRows.Close() for roadRows.Next() { var r Road if err := roadRows.Scan(&r.ID, &r.FromTownID, &r.ToTownID, &r.Distance); err != nil { return nil, fmt.Errorf("scan road: %w", err) } fromTown, ok := g.Towns[r.FromTownID] if !ok { continue } toTown, ok := g.Towns[r.ToTownID] if !ok { continue } r.Waypoints = generateWaypoints(fromTown, toTown, r.ID) r.Distance = totalWaypointDistance(r.Waypoints) g.Roads[r.ID] = &r g.TownRoads[r.FromTownID] = append(g.TownRoads[r.FromTownID], &r) } if err := roadRows.Err(); err != nil { return nil, fmt.Errorf("iterate roads: %w", err) } return g, nil } // TownNPCIDs returns NPC ids for a town in stable DB order (for visit queues). func (g *RoadGraph) TownNPCIDs(townID int64) []int64 { list := g.TownNPCs[townID] ids := make([]int64, len(list)) for i := range list { ids[i] = list[i].ID } return ids } // FindRoad returns the road from fromTownID to toTownID, if it exists. func (g *RoadGraph) FindRoad(fromTownID, toTownID int64) *Road { for _, r := range g.TownRoads[fromTownID] { if r.ToTownID == toTownID { return r } } return nil } // NextTownInChain returns the next town along the ring (TownOrder order, wraps after last). func (g *RoadGraph) NextTownInChain(currentTownID int64) int64 { n := len(g.TownOrder) if n == 0 { return currentTownID } idx := -1 for i, id := range g.TownOrder { if id == currentTownID { idx = i break } } if idx < 0 { return g.TownOrder[0] } return g.TownOrder[(idx+1)%n] } // HeroInTownAt returns true if (x, y) lies inside any town's radius (vendor / sell zone). func (g *RoadGraph) HeroInTownAt(x, y float64) bool { if g == nil { return false } for _, t := range g.Towns { if t == nil { continue } dx := x - t.WorldX dy := y - t.WorldY if dx*dx+dy*dy <= t.Radius*t.Radius { return true } } return false } // NearestTown returns the town ID closest to the given world position. func (g *RoadGraph) NearestTown(x, y float64) int64 { bestDist := math.MaxFloat64 var bestID int64 for _, t := range g.Towns { d := math.Hypot(t.WorldX-x, t.WorldY-y) if d < bestDist { bestDist = d bestID = t.ID } } return bestID } // TownOrderIndex returns the index of a town in the ordered chain, or -1 if not found. func (g *RoadGraph) TownOrderIndex(townID int64) int { for i, id := range g.TownOrder { if id == townID { return i } } return -1 } const ( waypointSpacing = 20.0 // tiles between jitter points waypointJitter = 2.0 // +/- tile jitter ) // generateWaypoints creates a deterministic sequence of points from one town center // to another, with jitter applied every waypointSpacing tiles. func generateWaypoints(from, to *model.Town, roadID int64) []Point { dx := to.WorldX - from.WorldX dy := to.WorldY - from.WorldY totalDist := math.Hypot(dx, dy) if totalDist < 1 { return []Point{{from.WorldX, from.WorldY}, {to.WorldX, to.WorldY}} } numSegments := int(totalDist / waypointSpacing) if numSegments < 1 { numSegments = 1 } // Deterministic RNG per road. rng := rand.New(rand.NewSource(roadID * 7919)) points := make([]Point, 0, numSegments+2) points = append(points, Point{from.WorldX, from.WorldY}) for i := 1; i < numSegments; i++ { t := float64(i) / float64(numSegments) px := from.WorldX + dx*t + (rng.Float64()*2-1)*waypointJitter py := from.WorldY + dy*t + (rng.Float64()*2-1)*waypointJitter points = append(points, Point{px, py}) } points = append(points, Point{to.WorldX, to.WorldY}) return points } // totalWaypointDistance computes the sum of segment lengths along a waypoint path. func totalWaypointDistance(pts []Point) float64 { var total float64 for i := 1; i < len(pts); i++ { total += math.Hypot(pts[i].X-pts[i-1].X, pts[i].Y-pts[i-1].Y) } return total } // SortedTownsByDistance returns town IDs sorted by Euclidean distance from (x,y). func (g *RoadGraph) SortedTownsByDistance(x, y float64) []int64 { type td struct { id int64 dist float64 } tds := make([]td, 0, len(g.Towns)) for _, t := range g.Towns { tds = append(tds, td{id: t.ID, dist: math.Hypot(t.WorldX-x, t.WorldY-y)}) } sort.Slice(tds, func(i, j int) bool { return tds[i].dist < tds[j].dist }) ids := make([]int64, len(tds)) for i, v := range tds { ids[i] = v.id } return ids }