package mindustry.ai;

import arc.*;
import arc.graphics.*;
import arc.graphics.g2d.*;
import arc.math.*;
import arc.math.geom.*;
import arc.struct.*;
import arc.util.*;
import mindustry.annotations.Annotations.*;
import mindustry.content.*;
import mindustry.core.*;
import mindustry.game.EventType.*;
import mindustry.game.*;
import mindustry.gen.*;
import mindustry.graphics.*;
import mindustry.world.*;

import static mindustry.Vars.*;
import static mindustry.ai.Pathfinder.*;

//https://webdocs.cs.ualberta.ca/~mmueller/ps/hpastar.pdf
//https://www.gameaipro.com/GameAIPro/GameAIPro_Chapter23_Crowd_Pathfinding_and_Steering_Using_Flow_Field_Tiles.pdf
public class ControlPathfinder implements Runnable{
    private static final int wallImpassableCap = 1_000_000;
    private static final int solidCap = 7000;
    private static boolean initialized;

    public static boolean showDebug;

    public static final PathCost

    costGround = (team, tile) ->
    //deep is impassable
    PathTile.allDeep(tile) ? impassable :
    //impassable same-team or neutral block
    PathTile.solid(tile) && ((PathTile.team(tile) == team && !PathTile.teamPassable(tile)) || PathTile.team(tile) == 0) ? impassable :
    //impassable synthetic enemy block
    ((PathTile.team(tile) != team && PathTile.team(tile) != 0) && PathTile.solid(tile) ? wallImpassableCap : 0) +
    1 +
    (PathTile.nearSolid(tile) ? 6 : 0) +
    (PathTile.nearLiquid(tile) ? 8 : 0) +
    (PathTile.deep(tile) ? 6000 : 0) +
    (PathTile.damages(tile) ? 50 : 0),

    //same as ground but ignores liquids/deep stuff
    costHover = (team, tile) ->
    //impassable same-team or neutral block
    PathTile.solid(tile) && ((PathTile.team(tile) == team && !PathTile.teamPassable(tile)) || PathTile.team(tile) == 0) ? impassable :
    //impassable synthetic enemy block
    ((PathTile.team(tile) != team && PathTile.team(tile) != 0) && PathTile.solid(tile) ? wallImpassableCap : 0) +
    1 +
    (PathTile.nearSolid(tile) ? 6 : 0),

    costLegs = (team, tile) ->
    PathTile.legSolid(tile) ? impassable : 1 +
    (PathTile.nearDeep(tile) ? 8 : 0) +
    (PathTile.deep(tile) ? 6000 : 0) +
    (PathTile.nearLegSolid(tile) ? 3 : 0),

    costNaval = (team, tile) ->
    //impassable same-team neutral block, or non-liquid
    (PathTile.solid(tile) && ((PathTile.team(tile) == team && !PathTile.teamPassable(tile)) || PathTile.team(tile) == 0)) ? impassable :
    (!PathTile.liquid(tile) ? 6000 : 1) +
    //impassable synthetic enemy block
    ((PathTile.team(tile) != team && PathTile.team(tile) != 0) && PathTile.solid(tile) ? wallImpassableCap : 0) +
    (PathTile.nearGround(tile) || PathTile.nearSolid(tile) ? 6 : 0);

    public static final int
    costIdGround = 0,
    costIdHover = 1,
    costIdLegs = 2,
    costIdNaval = 3;

    public static final Seq<PathCost> costTypes = Seq.with(
    costGround,
    costHover,
    costLegs,
    costNaval
    );

    private static final long maxUpdate = Time.millisToNanos(12);
    private static final int updateStepInterval = 200;
    private static final int updateFPS = 30;
    private static final int updateInterval = 1000 / updateFPS, invalidateCheckInterval = 1000;
    private static final PathfindResult pathResult = new PathfindResult();

    static final int clusterSize = 12;

    static final int[] offsets = {
    1, 0, //right: bottom to top
    0, 1, //top: left to right
    0, 0, //left: bottom to top
    0, 0  //bottom: left to right
    };

    static final int[] moveDirs = {
    0, 1,
    1, 0,
    0, 1,
    1, 0
    };

    static final int[] nextOffsets = {
    1, 0,
    0, 1,
    -1, 0,
    0, -1
    };

    //maps team -> pathCost -> flattened array of clusters in 2D
    //(what about teams? different path costs?)
    final Cluster[][][] clusters = new Cluster[256][][];
    final int cwidth = Mathf.ceil((float)world.width() / clusterSize), cheight = Mathf.ceil((float)world.height() / clusterSize);

    //temporarily used for resolving connections for intra-edges
    final IntSet usedEdges = new IntSet();
    //tasks to run on pathfinding thread
    final TaskQueue queue = new TaskQueue();

    //individual requests based on unit - MAIN THREAD ONLY
    final ObjectMap<Unit, PathRequest> unitRequests = new ObjectMap<>();

    final Seq<PathRequest> threadPathRequests = new Seq<>(false);

    //TODO: very dangerous usage;
    //TODO - it is accessed from the main thread
    //TODO - it is written to on the pathfinding thread
    //maps position in world in (x + y * width format) | path type | team (bitpacked to long with FieldIndex.get) to a cache of flow fields
    final LongMap<FieldCache> fields = new LongMap<>();
    //MAIN THREAD ONLY
    final Seq<FieldCache> fieldList = new Seq<>(false);

    //these are for inner edge A* (temporary!)
    final IntFloatMap innerCosts = new IntFloatMap();
    final PathfindQueue innerFrontier = new PathfindQueue();

    //ONLY modify on pathfinding thread.
    final IntSet clustersToUpdate = new IntSet();
    final IntSet clustersToInnerUpdate = new IntSet();

    //PATHFINDING THREAD - requests that should be recomputed
    final ObjectSet<PathRequest> invalidRequests = new ObjectSet<>();

    /** Current pathfinding thread */
    @Nullable Thread thread;

    /** If true, this pathfinder is no longer relevant (stopped) and its errors can be ignored. */
    volatile boolean invalidated;

    //path requests are per-unit
    static class PathRequest{
        final Unit unit;
        final int destination, team, costId;
        //resulting path of nodes
        final IntSeq resultPath = new IntSeq();

        //node index -> total cost
        @Nullable IntFloatMap costs = new IntFloatMap();
        //node index (NodeIndex struct) -> node it came from TODO merge them, make properties of FieldCache?
        @Nullable IntIntMap cameFrom = new IntIntMap();
        //frontier for A*
        @Nullable PathfindQueue frontier = new PathfindQueue();

        //main thread only!
        long lastUpdateId = state.updateId;

        //both threads
        volatile boolean notFound = false;
        volatile boolean invalidated = false;
        //old field assigned before everything was recomputed
        @Nullable volatile FieldCache oldCache;

        boolean lastRaycastResult = false;
        int lastRaycastTile, lastWorldUpdate;
        int lastTile;
        @Nullable Tile lastTargetTile;

        PathRequest(Unit unit, int team, int costId, int destination){
            this.unit = unit;
            this.costId = costId;
            this.team = team;
            this.destination = destination;
        }
    }

    static class FieldCache{
        final PathCost cost;
        final int costId;
        final int team;
        final int goalPos;
        //frontier for flow fields
        final IntQueue frontier = new IntQueue();
        //maps cluster index to field weights; 0 means uninitialized
        final IntMap<int[]> fields = new IntMap<>();
        //packed (goalPos | costId | team) long key to use in the global fields map
        final long mapKey;

        //main thread only!
        long lastUpdateId = state.updateId;

        //TODO: how are the nodes merged? CAN they be merged?

        FieldCache(PathCost cost, int costId, int team, int goalPos){
            this.cost = cost;
            this.team = team;
            this.goalPos = goalPos;
            this.costId = costId;
            this.mapKey = FieldIndex.get(goalPos, costId, team);
        }
    }

    static class Cluster{
        IntSeq[] portals = new IntSeq[4];
        //maps rotation + index of portal to list of IntraEdge objects
        LongSeq[][] portalConnections = new LongSeq[4][];
    }

    //this method is not run in a static initializer because it must only happen after Pathfinder registers its events, which means it should happen in the ControlPathfinder constructor
    static void checkEvents(){
        if(initialized) return;
        initialized = true;

        Events.on(ResetEvent.class, event -> controlPath.stop());

        Events.on(WorldLoadEvent.class, event -> {
            controlPath.stop();
            //create a new pathfinder to avoid contaminating the new pathfinding state with the old thread, which may still be running
            controlPath = new ControlPathfinder();
            controlPath.start();
        });

        Events.on(TileChangeEvent.class, e -> {
            controlPath.updateTile(e.tile);
        });

        //invalidate paths
        Events.run(Trigger.update, () -> {
            for(var req : controlPath.unitRequests.values()){
                //skipped N update -> drop it
                if(req.lastUpdateId <= state.updateId - 10 || !req.unit.isAdded()){
                    req.invalidated = true;
                    //concurrent modification!
                    controlPath.queue.post(() -> controlPath.threadPathRequests.remove(req));
                    Time.run(0f, () -> controlPath.unitRequests.remove(req.unit));
                }
            }

            for(var field : controlPath.fieldList){
                //skipped N update -> drop it
                if(field.lastUpdateId <= state.updateId - 30){
                    //make sure it's only modified on the main thread...? but what about calling get() on this thread??
                    controlPath.queue.post(() -> controlPath.fields.remove(field.mapKey));
                    Time.run(0f, () -> controlPath.fieldList.remove(field));
                }
            }
        });

        if(showDebug){
            Events.run(Trigger.draw, () -> {
                int team = player.team().id;
                int cost = 0;

                Draw.draw(Layer.overlayUI, () -> {
                    Lines.stroke(1f);

                    if(controlPath.clusters[team] != null && controlPath.clusters[team][cost] != null){
                        for(int cx = 0; cx < controlPath.cwidth; cx++){
                            for(int cy = 0; cy < controlPath.cheight; cy++){

                                var cluster = controlPath.clusters[team][cost][cy * controlPath.cwidth + cx];
                                if(cluster != null){
                                    Lines.stroke(0.5f);
                                    Draw.color(Color.gray);
                                    Lines.stroke(1f);

                                    Lines.rect(cx * clusterSize * tilesize - tilesize/2f, cy * clusterSize * tilesize - tilesize/2f, clusterSize * tilesize, clusterSize * tilesize);


                                    for(int d = 0; d < 4; d++){
                                        IntSeq portals = cluster.portals[d];
                                        if(portals != null){

                                            for(int i = 0; i < portals.size; i++){
                                                int pos = portals.items[i];
                                                int from = Point2.x(pos), to = Point2.y(pos);
                                                float width = tilesize * (Math.abs(from - to) + 1), height = tilesize;

                                                controlPath.portalToVec(cluster, cx, cy, d, i, Tmp.v1);

                                                Draw.color(Color.brown);
                                                Lines.ellipse(30, Tmp.v1.x, Tmp.v1.y, width / 2f, height / 2f, d * 90f - 90f);

                                                LongSeq connections = cluster.portalConnections[d] == null ? null : cluster.portalConnections[d][i];

                                                if(connections != null){
                                                    Draw.color(Color.forest);
                                                    for(int coni = 0; coni < connections.size; coni ++){
                                                        long con = connections.items[coni];

                                                        controlPath.portalToVec(cluster, cx, cy, IntraEdge.dir(con), IntraEdge.portal(con), Tmp.v2);

                                                        float
                                                        x1 = Tmp.v1.x, y1 = Tmp.v1.y,
                                                        x2 = Tmp.v2.x, y2 = Tmp.v2.y;
                                                        Lines.line(x1, y1, x2, y2);

                                                    }
                                                }
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    }

                    for(var fields : controlPath.fieldList){
                        try{
                            for(var entry : fields.fields){
                                int cx = entry.key % controlPath.cwidth, cy = entry.key / controlPath.cwidth;
                                for(int y = 0; y < clusterSize; y++){
                                    for(int x = 0; x < clusterSize; x++){
                                        int value = entry.value[x + y * clusterSize];
                                        Tmp.c1.a = 1f;
                                        Lines.stroke(0.8f, Tmp.c1.fromHsv(value * 3f, 1f, 1f));
                                        Draw.alpha(0.5f);
                                        Fill.square((x + cx * clusterSize) * tilesize, (y + cy * clusterSize) * tilesize, tilesize / 2f);
                                    }
                                }
                            }
                        }catch(Exception ignored){} //probably has some concurrency issues when iterating but I don't care, this is for debugging
                    }
                });

                Draw.reset();
            });
        }
    }

    public ControlPathfinder(){
        checkEvents();
    }

    public void updateTile(Tile tile){
        tile.getLinkedTiles(this::updateSingleTile);
    }

    public void updateSingleTile(Tile t){
        int x = t.x, y = t.y, mx = x % clusterSize, my = y % clusterSize, cx = x / clusterSize, cy = y / clusterSize, cluster = cx + cy * cwidth;

        //is at the edge of a cluster; this means the portals may have changed.
        if(mx == 0 || my == 0 || mx == clusterSize - 1 || my == clusterSize - 1){

            if(mx == 0) queueClusterUpdate(cx - 1, cy); //left
            if(my == 0) queueClusterUpdate(cx, cy - 1); //bottom
            if(mx == clusterSize - 1) queueClusterUpdate(cx + 1, cy); //right
            if(my == clusterSize - 1) queueClusterUpdate(cx, cy + 1); //top

            queueClusterUpdate(cx, cy);
            //TODO: recompute edge clusters too.
        }else{
            //there is no need to recompute portals for block updates that are not on the edge.
            queue.post(() -> clustersToInnerUpdate.add(cluster));
        }
    }

    void queueClusterUpdate(int cx, int cy){
        if(cx >= 0 && cy >= 0 && cx < cwidth && cy < cheight){
            queue.post(() -> clustersToUpdate.add(cx + cy * cwidth));
        }
    }

    //debugging only!
    void portalToVec(Cluster cluster, int cx, int cy, int direction, int portalIndex, Vec2 out){
        int pos = cluster.portals[direction].items[portalIndex];
        int from = Point2.x(pos), to = Point2.y(pos);
        int addX = moveDirs[direction * 2], addY = moveDirs[direction * 2 + 1];
        float average = (from + to) / 2f;

        float
        x = (addX * average + cx * clusterSize + offsets[direction * 2] * (clusterSize - 1) + nextOffsets[direction * 2] / 2f) * tilesize,
        y = (addY * average + cy * clusterSize + offsets[direction * 2 + 1] * (clusterSize - 1) + nextOffsets[direction * 2 + 1] / 2f) * tilesize;

        out.set(x, y);
    }

    /** Starts or restarts the pathfinding thread. */
    private void start(){
        if(net.client() || thread != null) return;

        thread = new Thread(this, "Control Pathfinder");
        thread.setPriority(Thread.MIN_PRIORITY);
        thread.setDaemon(true);
        thread.start();
    }

    /** Stops the pathfinding thread. */
    private void stop(){
        if(thread != null){
            thread.interrupt();
            thread = null;
        }
        invalidated = true;
        queue.clear();
    }

    /** @return a cluster at coordinates; can be null if not cluster was created yet*/
    @Nullable Cluster getCluster(int team, int pathCost, int cx, int cy){
        return getCluster(team, pathCost, cx + cy * cwidth);
    }

    /** @return a cluster at coordinates; can be null if not cluster was created yet*/
    @Nullable Cluster getCluster(int team, int pathCost, int clusterIndex){
        if(clusters == null) return null;

        Cluster[][] dim1 = clusters[team];

        if(dim1 == null) return null;

        Cluster[] dim2 = dim1[pathCost];

        //TODO: how can index out of bounds happen? || clusterIndex >= dim2.length
        if(dim2 == null) return null;

        return dim2[clusterIndex];
    }

    /** @return the cluster at specified coordinates; never null. */
    Cluster getCreateCluster(int team, int pathCost, int cx, int cy){
        return getCreateCluster(team, pathCost, cx + cy * cwidth);
    }

    /** @return the cluster at specified coordinates; never null. */
    Cluster getCreateCluster(int team, int pathCost, int clusterIndex){
        Cluster result = getCluster(team, pathCost, clusterIndex);
        if(result == null){
            return updateCluster(team, pathCost, clusterIndex % cwidth, clusterIndex / cwidth);
        }else{
            return result;
        }
    }

    Cluster updateCluster(int team, int pathCost, int cx, int cy){
        //TODO: what if clusters are null for thread visibility reasons?

        Cluster[][] dim1 = clusters[team];

        if(dim1 == null){
            dim1 = clusters[team] = new Cluster[Team.all.length][];
        }

        Cluster[] dim2 = dim1[pathCost];

        if(dim2 == null){
            dim2 = dim1[pathCost] = new Cluster[cwidth * cheight];
        }

        Cluster cluster = dim2[cy * cwidth + cx];
        if(cluster == null){
            cluster = dim2[cy * cwidth + cx] = new Cluster();
        }else{
            //reset data
            for(var p : cluster.portals){
                if(p != null){
                    p.clear();
                }
            }
        }

        PathCost cost = idToCost(pathCost);

        for(int direction = 0; direction < 4; direction++){
            int otherX = cx + Geometry.d4x(direction), otherY = cy + Geometry.d4y(direction);
            //out of bounds, no portals in this direction
            if(otherX < 0 || otherY < 0 || otherX >= cwidth || otherY >= cheight){
                continue;
            }

            Cluster other = dim2[otherX + otherY * cwidth];
            IntSeq portals;

            if(other == null){
                //create new portals at direction
                portals = cluster.portals[direction] = new IntSeq(4);
            }else{
                //share portals with the other cluster
                portals = cluster.portals[direction] = other.portals[(direction + 2) % 4];

                //apparently this is somehow possible...?
                if(portals == null){
                    portals = cluster.portals[direction] = other.portals[(direction + 2) % 4] = new IntSeq();
                }

                //clear the portals, they're being recalculated now
                portals.clear();
            }

            int addX = moveDirs[direction * 2], addY = moveDirs[direction * 2 + 1];
            int
            baseX = cx * clusterSize + offsets[direction * 2] * (clusterSize - 1),
            baseY = cy * clusterSize + offsets[direction * 2 + 1] * (clusterSize - 1),
            nextBaseX = baseX + Geometry.d4[direction].x,
            nextBaseY = baseY + Geometry.d4[direction].y;

            int lastPortal = -1;
            boolean prevSolid = true;

            for(int i = 0; i < clusterSize; i++){
                int x = baseX + addX * i, y = baseY + addY * i;

                //scan for portals
                if(solid(team, cost, x, y) || solid(team, cost, nextBaseX + addX * i, nextBaseY + addY * i)){
                    int previous = i - 1;
                    //hit a wall, create portals between the two points
                    if(!prevSolid && previous >= lastPortal){
                        //portals are an inclusive range
                        portals.add(Point2.pack(previous, lastPortal));
                    }
                    prevSolid = true;
                }else{
                    //empty area encountered, mark the location of portal start
                    if(prevSolid){
                        lastPortal = i;
                    }
                    prevSolid = false;
                }
            }

            //at the end of the loop, close any un-initialized portals; this is copy pasted code
            int previous = clusterSize - 1;
            if(!prevSolid && previous >= lastPortal){
                //portals are an inclusive range
                portals.add(Point2.pack(previous, lastPortal));
            }
        }

        updateInnerEdges(team, cost, cx, cy, cluster);

        return cluster;
    }

    void updateInnerEdges(int team, int cost, int cx, int cy, Cluster cluster){
        updateInnerEdges(team, idToCost(cost), cx, cy, cluster);
    }

    void updateInnerEdges(int team, PathCost cost, int cx, int cy, Cluster cluster){
        int minX = cx * clusterSize, minY = cy * clusterSize, maxX = Math.min(minX + clusterSize - 1, wwidth - 1), maxY = Math.min(minY + clusterSize - 1, wheight - 1);

        usedEdges.clear();

        //clear all connections, since portals changed, they need to be recomputed.
        cluster.portalConnections = new LongSeq[4][];

        for(int direction = 0; direction < 4; direction++){
            var portals = cluster.portals[direction];
            if(portals == null) continue;

            int addX = moveDirs[direction * 2], addY = moveDirs[direction * 2 + 1];

            for(int i = 0; i < portals.size; i++){
                usedEdges.add(Point2.pack(direction, i));

                int
                portal = portals.items[i],
                from = Point2.x(portal), to = Point2.y(portal),
                average = (from + to) / 2,
                x = (addX * average + cx * clusterSize + offsets[direction * 2] * (clusterSize - 1)),
                y = (addY * average + cy * clusterSize + offsets[direction * 2 + 1] * (clusterSize - 1));

                for(int otherDir = 0; otherDir < 4; otherDir++){
                    var otherPortals = cluster.portals[otherDir];
                    if(otherPortals == null) continue;

                    for(int j = 0; j < otherPortals.size; j++){

                        if(!usedEdges.contains(Point2.pack(otherDir, j))){

                            int
                            other = otherPortals.items[j],
                            otherFrom = Point2.x(other), otherTo = Point2.y(other),
                            otherAverage = (otherFrom + otherTo) / 2,
                            ox = cx * clusterSize + offsets[otherDir * 2] * (clusterSize - 1),
                            oy = cy * clusterSize + offsets[otherDir * 2 + 1] * (clusterSize - 1),
                            otherX = (moveDirs[otherDir * 2] * otherAverage + ox),
                            otherY = (moveDirs[otherDir * 2 + 1] * otherAverage + oy);

                            //duplicate portal; should never happen.
                            if(Point2.pack(x, y) == Point2.pack(otherX, otherY)){
                                continue;
                            }

                            float connectionCost = innerAstar(
                                team, cost,
                                minX, minY, maxX, maxY,
                                x + y * wwidth,
                                otherX + otherY * wwidth,
                                (moveDirs[otherDir * 2] * otherFrom + ox),
                                (moveDirs[otherDir * 2 + 1] * otherFrom + oy),
                                (moveDirs[otherDir * 2] * otherTo + ox),
                                (moveDirs[otherDir * 2 + 1] * otherTo + oy)
                            );

                            if(connectionCost != -1f){
                                if(cluster.portalConnections[direction] == null) cluster.portalConnections[direction] = new LongSeq[cluster.portals[direction].size];
                                if(cluster.portalConnections[otherDir] == null) cluster.portalConnections[otherDir] = new LongSeq[cluster.portals[otherDir].size];
                                if(cluster.portalConnections[direction][i] == null) cluster.portalConnections[direction][i] = new LongSeq(8);
                                if(cluster.portalConnections[otherDir][j] == null) cluster.portalConnections[otherDir][j] = new LongSeq(8);

                                //TODO: can there be duplicate edges??
                                cluster.portalConnections[direction][i].add(IntraEdge.get(otherDir, j, connectionCost));
                                cluster.portalConnections[otherDir][j].add(IntraEdge.get(direction, i, connectionCost));
                            }
                        }
                    }
                }
            }
        }
    }

    //distance heuristic: manhattan
    private static float heuristic(int a, int b){
        int x = a % wwidth, x2 = b % wwidth, y = a / wwidth, y2 = b / wwidth;
        return Math.abs(x - x2) + Math.abs(y - y2);
    }

    private static int tcost(int team, PathCost cost, int tilePos){
        return cost.getCost(team, pathfinder.tiles[tilePos]);
    }

    private static float tileCost(int team, PathCost type, int a, int b){
        //currently flat cost
        return cost(team, type, b);
    }

    /** @return -1 if no path was found */
    float innerAstar(int team, PathCost cost, int minX, int minY, int maxX, int maxY, int startPos, int goalPos, int goalX1, int goalY1, int goalX2, int goalY2){
        var frontier = innerFrontier;
        var costs = innerCosts;

        frontier.clear();
        costs.clear();

        //TODO: this can be faster and more memory efficient by making costs a NxN array... probably?
        costs.put(startPos, 0);
        frontier.add(startPos, 0);

        if(goalX2 < goalX1){
            int tmp = goalX1;
            goalX1 = goalX2;
            goalX2 = tmp;
        }

        if(goalY2 < goalY1){
            int tmp = goalY1;
            goalY1 = goalY2;
            goalY2 = tmp;
        }

        while(frontier.size > 0){
            int current = frontier.poll();

            int cx = current % wwidth, cy = current / wwidth;

            //found the goal (it's in the portal rectangle)
            if((cx >= goalX1 && cy >= goalY1 && cx <= goalX2 && cy <= goalY2) || current == goalPos){
                return costs.get(current);
            }

            for(Point2 point : Geometry.d4){
                int newx = cx + point.x, newy = cy + point.y;
                int next = newx + wwidth * newy;

                if(newx > maxX || newy > maxY || newx < minX || newy < minY || tcost(team, cost, next) == impassable) continue;

                float add = tileCost(team, cost, current, next);

                if(add < 0) continue;

                float newCost = costs.get(current) + add;

                if(newCost < costs.get(next, Float.POSITIVE_INFINITY)){
                    costs.put(next, newCost);
                    float priority = newCost + heuristic(next, goalPos);
                    frontier.add(next, priority);
                }
            }
        }

        return -1f;
    }

    int makeNodeIndex(int cx, int cy, int dir, int portal){
        //to make sure there's only one way to refer to each node, the direction must be 0 or 1 (referring to portals on the top or right edge)

        //direction can only be 2 if cluster X is 0 (left edge of map)
        if(dir == 2 && cx != 0){
            dir = 0;
            cx --;
        }

        //direction can only be 3 if cluster Y is 0 (bottom edge of map)
        if(dir == 3 && cy != 0){
            dir = 1;
            cy --;
        }

        return NodeIndex.get(cx + cy * cwidth, dir, portal);
    }

    //uses A* to find the closest node index to specified coordinates
    //this node is used in cluster A*
    /** @return MAX_VALUE if no node is found */
    private int findClosestNode(int team, int pathCost, int tileX, int tileY){
        int cx = tileX / clusterSize, cy = tileY / clusterSize;

        if(cx < 0 || cy < 0 || cx >= cwidth || cy >= cheight){
            return Integer.MAX_VALUE;
        }

        PathCost cost = idToCost(pathCost);
        Cluster cluster = getCreateCluster(team, pathCost, cx, cy);
        int minX = cx * clusterSize, minY = cy * clusterSize, maxX = Math.min(minX + clusterSize - 1, wwidth - 1), maxY = Math.min(minY + clusterSize - 1, wheight - 1);

        int bestPortalPair = Integer.MAX_VALUE;
        float bestCost = Float.MAX_VALUE;

        //A* to every node, find the best one (I know there's a better algorithm for this, probably dijkstra)
        for(int dir = 0; dir < 4; dir++){
            var portals = cluster.portals[dir];
            if(portals == null) continue;

            for(int j = 0; j < portals.size; j++){

                int
                other = portals.items[j],
                otherFrom = Point2.x(other), otherTo = Point2.y(other),
                otherAverage = (otherFrom + otherTo) / 2,
                ox = cx * clusterSize + offsets[dir * 2] * (clusterSize - 1),
                oy = cy * clusterSize + offsets[dir * 2 + 1] * (clusterSize - 1),
                otherX = (moveDirs[dir * 2] * otherAverage + ox),
                otherY = (moveDirs[dir * 2 + 1] * otherAverage + oy);

                float connectionCost = innerAstar(
                team, cost,
                minX, minY, maxX, maxY,
                tileX + tileY * wwidth,
                otherX + otherY * wwidth,
                (moveDirs[dir * 2] * otherFrom + ox),
                (moveDirs[dir * 2 + 1] * otherFrom + oy),
                (moveDirs[dir * 2] * otherTo + ox),
                (moveDirs[dir * 2 + 1] * otherTo + oy)
                );

                //better cost found, update and return
                if(connectionCost != -1f && connectionCost < bestCost){
                    bestPortalPair = Point2.pack(dir, j);
                    bestCost = connectionCost;
                }
            }
        }

        if(bestPortalPair != Integer.MAX_VALUE){
            return makeNodeIndex(cx, cy, Point2.x(bestPortalPair), Point2.y(bestPortalPair));
        }


        return Integer.MAX_VALUE;
    }

    //distance heuristic: manhattan
    private float clusterNodeHeuristic(int team, int pathCost, int nodeA, int nodeB){
        int
        clusterA = NodeIndex.cluster(nodeA),
        dirA = NodeIndex.dir(nodeA),
        portalA = NodeIndex.portal(nodeA),
        clusterB = NodeIndex.cluster(nodeB),
        dirB = NodeIndex.dir(nodeB),
        portalB = NodeIndex.portal(nodeB),
        rangeA = getCreateCluster(team, pathCost, clusterA).portals[dirA].items[portalA],
        rangeB = getCreateCluster(team, pathCost, clusterB).portals[dirB].items[portalB];

        float
        averageA = (Point2.x(rangeA) + Point2.y(rangeA)) / 2f,
        x1 = (moveDirs[dirA * 2] * averageA + (clusterA % cwidth) * clusterSize + offsets[dirA * 2] * (clusterSize - 1) + nextOffsets[dirA * 2] / 2f),
        y1 = (moveDirs[dirA * 2 + 1] * averageA + (clusterA / cwidth) * clusterSize + offsets[dirA * 2 + 1] * (clusterSize - 1) + nextOffsets[dirA * 2 + 1] / 2f),

        averageB = (Point2.x(rangeB) + Point2.y(rangeB)) / 2f,
        x2 = (moveDirs[dirB * 2] * averageB + (clusterB % cwidth) * clusterSize + offsets[dirB * 2] * (clusterSize - 1) + nextOffsets[dirB * 2] / 2f),
        y2 = (moveDirs[dirB * 2 + 1] * averageB + (clusterB / cwidth) * clusterSize + offsets[dirB * 2 + 1] * (clusterSize - 1) + nextOffsets[dirB * 2 + 1] / 2f);

        return Math.abs(x1 - x2) + Math.abs(y1 - y2);
    }

    @Nullable IntSeq clusterAstar(PathRequest request, int pathCost, int startNodeIndex, int endNodeIndex){
        var result = request.resultPath;

        if(startNodeIndex == endNodeIndex){
            result.clear();
            result.add(startNodeIndex);
            return result;
        }

        var team = request.team;

        if(request.costs == null) request.costs = new IntFloatMap();
        if(request.cameFrom == null) request.cameFrom = new IntIntMap();
        if(request.frontier == null) request.frontier = new PathfindQueue();

        //note: these are NOT cleared, it is assumed that this function cleans up after itself at the end
        //is this a good idea? don't know, might hammer the GC with unnecessary objects too
        var costs = request.costs;
        var cameFrom = request.cameFrom;
        var frontier = request.frontier;

        cameFrom.put(startNodeIndex, startNodeIndex);
        costs.put(startNodeIndex, 0);
        frontier.add(startNodeIndex, 0);

        boolean foundEnd = false;

        while(frontier.size > 0){
            int current = frontier.poll();

            if(current == endNodeIndex){
                foundEnd = true;
                break;
            }

            int cluster = NodeIndex.cluster(current), dir = NodeIndex.dir(current), portal = NodeIndex.portal(current);
            int cx = cluster % cwidth, cy = cluster / cwidth;

            //invalid cluster index (TODO: how?)
            if(cx >= cwidth || cy >= cheight || cx < 0 || cy < 0) continue;

            Cluster clust = getCreateCluster(team, pathCost, cluster);
            LongSeq innerCons = clust.portalConnections[dir] == null || portal >= clust.portalConnections[dir].length ? null : clust.portalConnections[dir][portal];

            //edges for the cluster the node is 'in'
            if(innerCons != null){
                checkEdges(request, team, pathCost, current, endNodeIndex, cx, cy, innerCons);
            }

            //edges that this node 'faces' from the other side
            int nextCx = cx + Geometry.d4[dir].x, nextCy = cy + Geometry.d4[dir].y;
            if(nextCx >= 0 && nextCy >= 0 && nextCx < cwidth && nextCy < cheight){
                Cluster nextCluster = getCreateCluster(team, pathCost, nextCx, nextCy);
                int relativeDir = (dir + 2) % 4;
                LongSeq outerCons = nextCluster.portalConnections[relativeDir] == null || nextCluster.portalConnections[relativeDir].length <= portal ? null : nextCluster.portalConnections[relativeDir][portal];
                if(outerCons != null){
                    checkEdges(request, team, pathCost, current, endNodeIndex, nextCx, nextCy, outerCons);
                }
            }
        }

        //null them out, so they get GC'ed later
        //there's no reason to keep them around and waste memory, since this path may never be recalculated
        request.costs = null;
        request.cameFrom = null;
        request.frontier = null;

        if(foundEnd){
            result.clear();

            int cur = endNodeIndex;
            while(cur != startNodeIndex){
                result.add(cur);
                cur = cameFrom.get(cur);
            }

            result.reverse();

            return result;
        }
        return null;
    }

    private void checkEdges(PathRequest request, int team, int pathCost, int current, int goal, int cx, int cy, LongSeq connections){
        for(int i = 0; i < connections.size; i++){
            long con = connections.items[i];
            float cost = IntraEdge.cost(con);
            int otherDir = IntraEdge.dir(con), otherPortal = IntraEdge.portal(con);
            int next = makeNodeIndex(cx, cy, otherDir, otherPortal);

            float newCost = request.costs.get(current) + cost;

            if(newCost < request.costs.get(next, Float.POSITIVE_INFINITY)){
                request.costs.put(next, newCost);

                request.frontier.add(next, newCost + clusterNodeHeuristic(team, pathCost, next, goal));
                request.cameFrom.put(next, current);
            }
        }
    }

    private void updateFields(FieldCache cache, long nsToRun){
        var frontier = cache.frontier;
        var fields = cache.fields;
        var goalPos = cache.goalPos;
        var pcost = cache.cost;
        var team = cache.team;

        long start = Time.nanos();
        int counter = 0;

        //actually do the flow field part
        while(frontier.size > 0){
            int tile = frontier.removeLast();
            int baseX = tile % wwidth, baseY = tile / wwidth;
            int curWeightIndex = (baseX / clusterSize) + (baseY / clusterSize) * cwidth;

            //TODO: how can this be null??? serious problem!
            int[] curWeights = fields.get(curWeightIndex);
            if(curWeights == null) continue;

            int cost = curWeights[baseX % clusterSize + ((baseY % clusterSize) * clusterSize)];

            if(cost != impassable){
                for(Point2 point : Geometry.d4){

                    int
                    dx = baseX + point.x, dy = baseY + point.y,
                    clx = dx / clusterSize, cly = dy / clusterSize;

                    if(clx < 0 || cly < 0 || dx >= wwidth || dy >= wheight) continue;

                    int nextWeightIndex = clx + cly * cwidth;

                    int[] weights = nextWeightIndex == curWeightIndex ? curWeights : fields.get(nextWeightIndex);

                    //out of bounds; not allowed to move this way because no weights were registered here
                    if(weights == null) continue;

                    int newPos = tile + point.x + point.y * wwidth;

                    //can't move back to the goal
                    if(newPos == goalPos) continue;

                    if(dx - clx * clusterSize < 0 || dy - cly * clusterSize < 0) continue;

                    int newPosArray = (dx - clx * clusterSize) + (dy - cly * clusterSize) * clusterSize;

                    int otherCost = pcost.getCost(team, pathfinder.tiles[newPos]);
                    int oldCost = weights[newPosArray];

                    //a cost of 0 means uninitialized, OR it means we're at the goal position, but that's handled above
                    if((oldCost == 0 || oldCost > cost + otherCost) && otherCost != impassable){
                        frontier.addFirst(newPos);
                        weights[newPosArray] = cost + otherCost;
                    }
                }
            }

            //every N iterations, check the time spent - this prevents extra calls to nano time, which itself is slow
            if(nsToRun >= 0 && (counter++) >= updateStepInterval){
                counter = 0;
                if(Time.timeSinceNanos(start) >= nsToRun){
                    return;
                }
            }
        }
    }

    private void addFlowCluster(FieldCache cache, int cluster, boolean addingFrontier){
        addFlowCluster(cache, cluster % cwidth, cluster / cwidth, addingFrontier);
    }

    private void addFlowCluster(FieldCache cache, int cx, int cy, boolean addingFrontier){
        //out of bounds
        if(cx < 0 || cy < 0 || cx >= cwidth || cy >= cheight) return;

        var fields = cache.fields;
        int key = cx + cy * cwidth;

        if(!fields.containsKey(key)){
            fields.put(key, new int[clusterSize * clusterSize]);

            if(addingFrontier){
                for(int dir = 0; dir < 4; dir++){
                    int ox = cx + nextOffsets[dir * 2], oy = cy + nextOffsets[dir * 2 + 1];

                    if(ox < 0 || oy < 0 || ox >= cwidth || oy >= cheight) continue;

                    var otherField = fields.get(ox + oy * cwidth);

                    if(otherField == null) continue;

                    int
                    relOffset = (dir + 2) % 4,
                    movex = moveDirs[relOffset * 2],
                    movey = moveDirs[relOffset * 2 + 1],
                    otherx1 = offsets[relOffset * 2] * (clusterSize - 1),
                    othery1 = offsets[relOffset * 2 + 1] * (clusterSize - 1);

                    //scan the edge of the cluster
                    for(int i = 0; i < clusterSize; i++){
                        int x = otherx1 + movex * i, y = othery1 + movey * i;

                        //check to make sure it's not 0 (uninitialized flowfield data)
                        if(otherField[x + y * clusterSize] > 0){
                            int worldX = x + ox * clusterSize, worldY = y + oy * clusterSize;

                            //add the world-relative position to the frontier, so it recalculates
                            cache.frontier.addFirst(worldX + worldY * wwidth);

                            if(showDebug){
                                Core.app.post(() -> Fx.placeBlock.at(worldX *tilesize, worldY * tilesize, 1f));
                            }
                        }
                    }
                }
            }
        }
    }

    private void initializePathRequest(PathRequest request, int team, int costId, int unitX, int unitY, int goalX, int goalY){
        PathCost pcost = idToCost(costId);

        int goalPos = (goalX + goalY * wwidth);

        int node = findClosestNode(team, costId, unitX, unitY);
        int dest = findClosestNode(team, costId, goalX, goalY);

        if(dest == Integer.MAX_VALUE){
            request.notFound = true;
            //no node found (TODO: invalid state??)
            return;
        }

        var nodePath = clusterAstar(request, costId, node, dest);

        //no result found, bail out.
        if(nodePath == null){
            request.notFound = true;
            //stop following the old path, it's not relevant now, it's just not possible to reach the destination anymore
            request.oldCache = null;
            return;
        }

        FieldCache cache = fields.get(FieldIndex.get(goalPos, costId, team));
        //if true, extra values are added on the sides of existing field cells that face new cells.
        boolean addingFrontier = true;

        //create the cache if it doesn't exist, and initialize it
        if(cache == null){
            cache = new FieldCache(pcost, costId, team, goalPos);
            fields.put(cache.mapKey, cache);
            FieldCache fcache = cache;
            //register field in main thread for iteration
            Core.app.post(() -> fieldList.add(fcache));
            cache.frontier.addFirst(goalPos);
            addingFrontier = false; //when it's a new field, there is no need to add to the frontier to merge the flowfield
        }

        if(nodePath != null){
            int cx = unitX / clusterSize, cy = unitY / clusterSize;

            addFlowCluster(cache, cx, cy, addingFrontier);

            for(int i = -1; i < nodePath.size; i++){
                int
                current = i == -1 ? node : nodePath.items[i],
                cluster = NodeIndex.cluster(current),
                dir = NodeIndex.dir(current),
                dx = Geometry.d4[dir].x,
                dy = Geometry.d4[dir].y,
                ox = cluster % cwidth + dx,
                oy = cluster / cwidth + dy;

                addFlowCluster(cache, cluster, addingFrontier);

                //store directional/flipped version of cluster
                if(ox >= 0 && oy >= 0 && ox < cwidth && oy < cheight){
                    int other = ox + oy * cwidth;

                    addFlowCluster(cache, other, addingFrontier);
                }
            }
        }
    }

    private PathCost idToCost(int costId){
        return ControlPathfinder.costTypes.get(costId);
    }

    public static boolean isNearObstacle(Unit unit, int x1, int y1, int x2, int y2){
        return raycast(unit.team().id, unit.type.pathCost, x1, y1, x2, y2);
    }

    public static class PathfindResult{
        /** If true, the request cannot be satisfied. */
        public boolean unreachable;
        /** If true, the request is ready to execute. */
        public boolean move;
        /** If move is true, this is the next tile to move on. Don't use this unless you know what you are doing. */
        public @Nullable Tile next;
        /** Destination vector. Only valid if move is true. */
        public Vec2 dest = new Vec2();
    }

    @Deprecated
    public boolean getPathPosition(Unit unit, Vec2 destination, Vec2 out, @Nullable boolean[] noResultFound){
        var result = getPathPosition(unit, destination, destination);
        out.set(result.dest);
        if(noResultFound != null) noResultFound[0] = result.unreachable;
        return result.move;
    }

    @Deprecated
    public boolean getPathPosition(Unit unit, Vec2 destination, Vec2 mainDestination, Vec2 out, @Nullable boolean[] noResultFound){
        var result = getPathPosition(unit, destination, mainDestination);
        out.set(result.dest);
        if(noResultFound != null) noResultFound[0] = result.unreachable;
        return result.move;
    }

    public PathfindResult getPathPosition(Unit unit, Vec2 destination){
        return getPathPosition(unit, destination, destination);
    }

    public PathfindResult getPathPosition(Unit unit, Vec2 destination, Vec2 mainDestination){
        pathResult.unreachable = false;
        pathResult.move = false;
        pathResult.next = null;
        pathResult.dest.setZero();

        int costId = unit.type.pathCostId;
        PathCost cost = idToCost(costId);
        Tile tileOn = unit.tileOn();

        int
        team = unit.team.id,
        tileX = unit.tileX(),
        tileY = unit.tileY(),
        packedPos = world.packArray(tileX, tileY),
        destX = World.toTile(mainDestination.x),
        destY = World.toTile(mainDestination.y),
        actualDestX = World.toTile(destination.x),
        actualDestY = World.toTile(destination.y),
        actualDestPos = actualDestX + actualDestY * wwidth,
        initialCost = tileOn == null ? 0 : cost.getCost(team, pathfinder.tiles[tileOn.array()]),
        destPos = destX + destY * wwidth;

        PathRequest request = unitRequests.get(unit);

        unit.hitboxTile(Tmp.r3);
        //tile rect size has tile size factored in, since the ray cannot have thickness
        float tileRectSize = tilesize + Tmp.r3.height;

        int lastRaycastTile = request == null || world.tileChanges != request.lastWorldUpdate ? -1 : request.lastRaycastTile;
        boolean raycastResult = request != null && request.lastRaycastResult;

        //cache raycast results to run every time the world updates, and every tile the unit crosses
        if(lastRaycastTile != packedPos){
            //near the destination, standard raycasting tends to break down, so use the more permissive 'near' variant that doesn't take into account edges of walls
            raycastResult = unit.within(destination, tilesize * 2.5f) ?
                !raycastRect(initialCost, unit.x, unit.y, destination.x, destination.y, team, cost, tileX, tileY, actualDestX, actualDestY, tileRectSize) :
                !raycast(team, cost, tileX, tileY, actualDestX, actualDestY);

            if(request != null){
                request.lastRaycastTile = packedPos;
                request.lastRaycastResult = raycastResult;
                request.lastWorldUpdate = world.tileChanges;
            }
        }

        //if the destination can be trivially reached in a straight line, do that.
        if(raycastResult){
            pathResult.dest.set(destination);
            pathResult.move = true;
            return pathResult;
        }

        boolean any = false;

        long fieldKey = FieldIndex.get(destPos, costId, team);

        //use existing request if it exists.
        if(request != null && request.destination == destPos){
            request.lastUpdateId = state.updateId;

            Tile initialTileOn = tileOn;
            //TODO: should fields be accessible from this thread?
            FieldCache fieldCache = null;
            try{
                fieldCache = fields.get(fieldKey);
            }catch(Exception ignored){ //TODO fix this, rare crash due to remove() elsewhere
            }
            if(fieldCache == null) fieldCache = request.oldCache;

            if(fieldCache != null && tileOn != null){
                FieldCache old = request.oldCache;
                FieldCache targetCache = old != null ? old : fieldCache;
                boolean requeue = old == null;
                //nullify the old field to be GCed, as it cannot be relevant anymore (this path is complete)
                if(fieldCache != request.oldCache && fieldCache.frontier.isEmpty() && old != null){
                    request.oldCache = null;
                }

                fieldCache.lastUpdateId = state.updateId;
                int maxIterations = 30; //TODO higher/lower number? is this still too slow?
                int i = 0;
                boolean recalc = false;

                if(packedPos == actualDestPos){
                    request.lastTargetTile = tileOn;
                //TODO last pos can change if the flowfield changes.
                }else if(initialTileOn.pos() != request.lastTile || request.lastTargetTile == null){
                    boolean anyNearSolid = false;

                    //find the next tile until one near a solid block is discovered
                    while(i ++ < maxIterations){
                        int value = getCost(targetCache, tileOn.x, tileOn.y, requeue);

                        Tile current = null;
                        int minCost = 0;
                        for(int dir = 0; dir < 4; dir ++){
                            Point2 point = Geometry.d4[dir];
                            int dx = tileOn.x + point.x, dy = tileOn.y + point.y;

                            Tile other = world.tile(dx, dy);

                            if(other == null) continue;

                            int packed = world.packArray(dx, dy);
                            int otherCost = getCost(targetCache, dx, dy, requeue), relCost = otherCost - value;

                            if(relCost > 2 || otherCost <= 0){
                                anyNearSolid = true;
                            }

                            if((value == 0 || otherCost < value) && otherCost != impassable && ((otherCost != 0 && (current == null || otherCost < minCost)) || packed == actualDestPos || packed == destPos) && passable(team, cost, packed)){
                                current = other;
                                minCost = otherCost;
                                //no need to keep searching.
                                if(packed == destPos || packed == actualDestPos){
                                    break;
                                }
                            }
                        }

                        //TODO raycast spam = extremely slow
                        //...flowfield integration spam is also really slow.
                        if(!(current == null || (unit.type.canDrown && current.dangerous() && !tileOn.dangerous()))){

                            //when anyNearSolid is false, no solid tiles have been encountered anywhere so far, so raycasting is a waste of time
                            if(anyNearSolid && !(tileOn.dangerous() && costId == costIdGround) && raycastRect(initialCost, unit.x, unit.y, current.x * tilesize, current.y * tilesize, team, cost, initialTileOn.x, initialTileOn.y, current.x, current.y, tileRectSize)){

                                //TODO this may be a mistake
                                if(tileOn == initialTileOn){
                                    recalc = true;
                                    any = true;
                                }
                                pathResult.next = current;

                                break;
                            }else{

                                tileOn = current;
                                any = true;

                                int a = current.array();

                                if(a == destPos || a == actualDestPos){
                                    break;
                                }
                            }

                        }else{
                            break;
                        }
                    }

                    request.lastTargetTile = any ? tileOn : null;
                    if(showDebug && tileOn != null && Core.graphics.getFrameId() % 30 == 0){
                        Fx.placeBlock.at(tileOn.worldx(), tileOn.worldy(), 1);
                    }
                }

                if(request.lastTargetTile != null){
                    if(showDebug && Core.graphics.getFrameId() % 30 == 0){
                        Fx.breakBlock.at(request.lastTargetTile.worldx(), request.lastTargetTile.worldy(), 1);
                    }
                    pathResult.dest.set(request.lastTargetTile.worldx(), request.lastTargetTile.worldy());
                    pathResult.move = true;
                    request.lastTile = recalc ? -1 : initialTileOn.pos();
                    return pathResult;
                }
            }
        }else{
            //destroy the old one immediately, it's invalid now
            if(request != null){
                request.lastUpdateId = -1000;
            }

            //queue new request.
            unitRequests.put(unit, request = new PathRequest(unit, team, costId, destPos));

            PathRequest f = request;

            //on the pathfinding thread: initialize the request
            queue.post(() -> {
                threadPathRequests.add(f);
                recalculatePath(f);
            });

            pathResult.move = false;
            return pathResult;
        }

        pathResult.move = false;
        pathResult.unreachable = request.notFound;
        return pathResult;
    }

    private void recalculatePath(ControlPathfinder.PathRequest request){
        initializePathRequest(request, request.team, request.costId, request.unit.tileX(), request.unit.tileY(), request.destination % wwidth, request.destination / wwidth);
    }

    private int getCost(FieldCache cache, int x, int y, boolean requeue){
        try{
            int[] field = cache.fields.get(x / clusterSize + (y / clusterSize) * cwidth);
            if(field == null){
                if(!requeue) return 0;
                //request a new flow cluster if one wasn't found; this may be a spammed a bit, but the function will return early once it's created the first time
                queue.post(() -> addFlowCluster(cache, x / clusterSize, y / clusterSize, true));
                return 0;
            }
            return field[(x % clusterSize) + (y % clusterSize) * clusterSize];
        }catch(ArrayIndexOutOfBoundsException e){
            //TODO: this crashes because the fields are being added while they're accessed. really bad. needs a long-term solution and some way to cache the map lookup results.
            //using an array instead of a map would be nice, but that can mean something like 2500 entries in a sparse array, which is pretty terrible...
            return 0;
        }
    }

    private static boolean raycast(int team, PathCost type, int x1, int y1, int x2, int y2){
        int ww = wwidth, wh = wheight;
        int x = x1, dx = Math.abs(x2 - x), sx = x < x2 ? 1 : -1;
        int y = y1, dy = Math.abs(y2 - y), sy = y < y2 ? 1 : -1;
        int e2, err = dx - dy;

        while(x >= 0 && y >= 0 && x < ww && y < wh){
            if(avoid(team, type, x + y * wwidth)) return true;
            if(x == x2 && y == y2) return false;

            //diagonal ver
            e2 = 2 * err;
            if(e2 > -dy){
                err -= dy;
                x += sx;
            }

            if(e2 < dx){
                err += dx;
                y += sy;
            }
        }

        return true;
    }

    /** @return 0 if nothing was hit, otherwise the packed coordinates. This is an internal function and will likely be moved - do not use!*/
    public static int raycastFast(int team, PathCost type, int x1, int y1, int x2, int y2){
        int ww = world.width(), wh = world.height();
        int x = x1, dx = Math.abs(x2 - x), sx = x < x2 ? 1 : -1;
        int y = y1, dy = Math.abs(y2 - y), sy = y < y2 ? 1 : -1;
        int err = dx - dy;

        while(x >= 0 && y >= 0 && x < ww && y < wh){
            if(solid(team, type, x + y * wwidth, true)) return Point2.pack(x, y);
            if(x == x2 && y == y2) return 0;

            //no diagonals
            if(2 * err + dy > dx - 2 * err){
                err -= dy;
                x += sx;
            }else{
                err += dx;
                y += sy;
            }
        }

        return 0;
    }

    /** @return 0 if nothing was hit, otherwise the packed coordinates. This is an internal function and will likely be moved - do not use!*/
    public static int raycastFastAvoid(int team, PathCost type, int x1, int y1, int x2, int y2){
        int ww = world.width(), wh = world.height();
        int x = x1, dx = Math.abs(x2 - x), sx = x < x2 ? 1 : -1;
        int y = y1, dy = Math.abs(y2 - y), sy = y < y2 ? 1 : -1;
        int err = dx - dy;

        while(x >= 0 && y >= 0 && x < ww && y < wh){
            if(avoid(team, type, x + y * wwidth)) return Point2.pack(x, y);
            if(x == x2 && y == y2) return 0;

            //no diagonals
            if(2 * err + dy > dx - 2 * err){
                err -= dy;
                x += sx;
            }else{
                err += dx;
                y += sy;
            }
        }

        return 0;
    }

    private static boolean overlap(int initialCost, int team, PathCost type, int x, int y, float startX, float startY, float endX, float endY, float rectSize){
        if(x < 0 || y < 0 || x >= wwidth || y >= wheight) return false;
        if(!nearPassable(initialCost, team, type, x + y * wwidth)){
            return Intersector.intersectSegmentRectangleFast(startX, startY, endX, endY, x * tilesize - rectSize/2f, y * tilesize - rectSize/2f, rectSize, rectSize);
        }
        return false;
    }

    private static boolean raycastRect(int initialCost, float startX, float startY, float endX, float endY, int team, PathCost type, int x1, int y1, int x2, int y2, float rectSize){
        int ww = wwidth, wh = wheight;
        int x = x1, dx = Math.abs(x2 - x), sx = x < x2 ? 1 : -1;
        int y = y1, dy = Math.abs(y2 - y), sy = y < y2 ? 1 : -1;
        int e2, err = dx - dy;

        while(x >= 0 && y >= 0 && x < ww && y < wh){
            if(
            !nearPassable(initialCost, team, type, x + y * wwidth) ||
            overlap(initialCost,team, type, x + 1, y, startX, startY, endX, endY, rectSize) ||
            overlap(initialCost,team, type, x - 1, y, startX, startY, endX, endY, rectSize) ||
            overlap(initialCost,team, type, x, y + 1, startX, startY, endX, endY, rectSize) ||
            overlap(initialCost,team, type, x, y - 1, startX, startY, endX, endY, rectSize)
            ) return true;

            if(x == x2 && y == y2) return false;

            //diagonal ver
            e2 = 2 * err;
            if(e2 > -dy){
                err -= dy;
                x += sx;
            }

            if(e2 < dx){
                err += dx;
                y += sy;
            }
        }

        return true;
    }

    private static boolean avoid(int team, PathCost type, int tilePos){
        int cost = cost(team, type, tilePos);
        return cost == impassable || cost >= 2;
    }

    private static boolean passable(int team, PathCost cost, int pos){
        int amount = cost.getCost(team, pathfinder.tiles[pos]);
        return amount != impassable && amount < solidCap;
    }

    private static boolean nearPassable(int initialCost, int team, PathCost cost, int pos){
        int amount = cost.getCost(team, pathfinder.tiles[pos]);
        return amount != impassable && amount < Math.min(Math.max(50, initialCost + 1), solidCap);
    }

    private static boolean solid(int team, PathCost type, int x, int y){
        return x < 0 || y < 0 || x >= wwidth || y >= wheight || solid(team, type, x + y * wwidth, true);
    }

    private static boolean solid(int team, PathCost type, int tilePos, boolean checkWall){
        int cost = cost(team, type, tilePos);
        return cost == impassable || cost >= solidCap;
    }

    private static int cost(int team, PathCost cost, int tilePos){
        if(state.rules.limitMapArea && !Team.get(team).isAI()){
            int x = tilePos % wwidth, y = tilePos / wwidth;
            if(x < state.rules.limitX || y < state.rules.limitY || x > state.rules.limitX + state.rules.limitWidth || y > state.rules.limitY + state.rules.limitHeight){
                return impassable;
            }
        }
        return cost.getCost(team, pathfinder.tiles[tilePos]);
    }

    private void clusterChanged(int team, int pathCost, int cx, int cy){
        int index = cx + cy * cwidth;

        for(var req : threadPathRequests){
            long mapKey = FieldIndex.get(req.destination, pathCost, team);
            var field = fields.get(mapKey);
            if((field != null && field.fields.containsKey(index)) || req.notFound){
                invalidRequests.add(req);
            }
        }

    }

    private void updateClustersComplete(int clusterIndex){
        for(int team = 0; team < clusters.length; team++){
            var dim1 = clusters[team];
            if(dim1 != null){
                for(int pathCost = 0; pathCost < dim1.length; pathCost++){
                    var dim2 = dim1[pathCost];
                    if(dim2 != null){
                        var cluster = dim2[clusterIndex];
                        if(cluster != null){
                            updateCluster(team, pathCost, clusterIndex % cwidth, clusterIndex / cwidth);
                            clusterChanged(team, pathCost, clusterIndex % cwidth, clusterIndex / cwidth);
                        }
                    }
                }
            }
        }
    }

    private void updateClustersInner(int clusterIndex){
        for(int team = 0; team < clusters.length; team++){
            var dim1 = clusters[team];
            if(dim1 != null){
                for(int pathCost = 0; pathCost < dim1.length; pathCost++){
                    var dim2 = dim1[pathCost];
                    if(dim2 != null){
                        var cluster = dim2[clusterIndex];
                        if(cluster != null){
                            updateInnerEdges(team, pathCost, clusterIndex % cwidth, clusterIndex / cwidth, cluster);
                            clusterChanged(team, pathCost, clusterIndex % cwidth, clusterIndex / cwidth);
                        }
                    }
                }
            }
        }
    }

    @Override
    public void run(){
        long lastInvalidCheck = Time.millis() + invalidateCheckInterval;

        while(true){
            if(net.client() || invalidated) return;
            try{
                if(state.isPlaying()){
                    queue.run();

                    clustersToUpdate.each(cluster -> {
                        updateClustersComplete(cluster);

                        //just in case: don't redundantly update inner clusters after you've recalculated it entirely
                        clustersToInnerUpdate.remove(cluster);
                    });

                    clustersToInnerUpdate.each(cluster -> {
                        //only recompute the inner links
                        updateClustersInner(cluster);
                    });

                    clustersToInnerUpdate.clear();
                    clustersToUpdate.clear();

                    //periodically check for invalidated paths
                    if(Time.timeSinceMillis(lastInvalidCheck) > invalidateCheckInterval){
                        lastInvalidCheck = Time.millis();

                        var it = invalidRequests.iterator();
                        while(it.hasNext()){
                            var request = it.next();

                            //invalid request, ignore it
                            if(request.invalidated){
                                it.remove();
                                continue;
                            }

                            long mapKey = FieldIndex.get(request.destination, request.costId, request.team);

                            var field = fields.get(mapKey);

                            if(field != null){
                                //it's only worth recalculating a path when the current frontier has finished; otherwise the unit will be following something incomplete.
                                if(field.frontier.isEmpty()){

                                    //remove the field, to be recalculated next update once recalculatePath is processed
                                    fields.remove(field.mapKey);
                                    Core.app.post(() -> fieldList.remove(field));

                                    //once the field is invalidated, make sure that all the requests that have it stored in their 'old' field, so units don't stutter during recalculations
                                    for(var otherRequest : threadPathRequests){
                                        if(otherRequest.destination == request.destination){
                                            otherRequest.oldCache = field;

                                            if(otherRequest != request){
                                                queue.post(() -> recalculatePath(otherRequest));
                                            }
                                        }
                                    }

                                    //the recalculation is done next update, so multiple path requests in the same batch don't end up removing and recalculating the field multiple times.
                                    queue.post(() -> recalculatePath(request));
                                    //it has been processed.
                                    it.remove();
                                }
                            }else{ //there's no field, presumably because a previous request already invalidated it.
                                queue.post(() -> recalculatePath(request));
                                it.remove();
                            }
                        }
                    }

                    //each update time (not total!) no longer than maxUpdate
                    fields.eachValue(cache -> {
                        if(cache != null){
                            updateFields(cache, maxUpdate);
                        }
                    });
                }

                try{
                    Thread.sleep(updateInterval);
                }catch(InterruptedException e){
                    //stop looping when interrupted externally
                    return;
                }
            }catch(Throwable e){
                if(!invalidated){
                    Log.err(e);
                }else{
                    //This pathfinder is done, don't bother doing any tasks
                    return;
                }
            }
        }
    }

    @Struct
    static class FieldIndexStruct{
        int pos;
        @StructField(8)
        int costId;
        @StructField(8)
        int team;
    }

    @Struct
    static class IntraEdgeStruct{
        @StructField(8)
        int dir;
        @StructField(8)
        int portal;

        float cost;
    }

    @Struct
    static class NodeIndexStruct{
        @StructField(22)
        int cluster;
        @StructField(2)
        int dir;
        @StructField(8)
        int portal;
    }
}
