From 22b5efb48dda5be6a69c4b0d77252814ad25921f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 13:02:53 +0000 Subject: [PATCH 1/3] Route marching armies around lakes Dolumar_Map_LandRoute walks the straight line and, only where it crosses water, searches a way around that lake (grid A* in a box around the crossing), then smooths the result to straight legs. Open land stays the exact straight line; no way around falls back to the straight line. Results are cached in memcache without expiry (water never changes). Dolumar_Map_Distance uses it for every walking edge (to the destination and to portals), so travel time, the Battle window, market transfers and thieves all follow the land route. Routes are now stored as legs (a list of point lists, a portal jump between legs); the map still reads the old flat routes of armies already on the move. Ranking and profile distance lists keep the cheap straight line. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WpWbf9LAeFevtaMRw9drZH --- src/Dolumar/Map/Distance.php | 91 ++-- src/Dolumar/Map/LandRoute.php | 498 ++++++++++++++++++++++ src/Dolumar/Map/Map.php | 16 +- src/Dolumar/NewMap/ArmyFactory.php | 87 +++- src/Dolumar/NewMap/MovingRows.php | 26 +- src/Dolumar/SpecialUnits/SpecialUnits.php | 12 +- src/Dolumar/Windows/PlayerProfile.php | 2 +- src/Dolumar/Windows/Ranking.php | 2 +- tests/Map/DistanceRouteTest.php | 70 ++- tests/Map/LandRouteTest.php | 147 +++++++ 10 files changed, 891 insertions(+), 60 deletions(-) create mode 100644 src/Dolumar/Map/LandRoute.php create mode 100644 tests/Map/LandRouteTest.php diff --git a/src/Dolumar/Map/Distance.php b/src/Dolumar/Map/Distance.php index 53a3d7eb..b753403a 100755 --- a/src/Dolumar/Map/Distance.php +++ b/src/Dolumar/Map/Distance.php @@ -28,9 +28,9 @@ class Dolumar_Map_Distance /* Distance only; see getDistanceAndRoute for the waypoints. */ - public static function getDistance ($loc1, $loc2, $portals, $ignoreImpassable = true) + public static function getDistance ($loc1, $loc2, $portals, $ignoreImpassable = true, $overLand = true) { - $result = self::getDistanceAndRoute ($loc1, $loc2, $portals, $ignoreImpassable); + $result = self::getDistanceAndRoute ($loc1, $loc2, $portals, $ignoreImpassable, $overLand); if ($result === false) { return false; @@ -39,15 +39,17 @@ public static function getDistance ($loc1, $loc2, $portals, $ignoreImpassable = } /* - Returns array ('distance' => float, 'route' => array of array (x, y)) - from $loc1 to $loc2 inclusive, with portal jumps as consecutive - waypoints, or false when no route exists. + Returns array ('distance' => float, 'route' => legs) or false when no + route exists. The route is a list of legs from $loc1 to $loc2, each leg a + list of array (x, y) walked point to point; between two legs is a portal + jump. With $overLand, walking goes around lakes (Dolumar_Map_LandRoute); + without, every walk is a straight line (cheap, for lists of distances). */ - public static function getDistanceAndRoute ($loc1, $loc2, $portals, $ignoreImpassable = true) + public static function getDistanceAndRoute ($loc1, $loc2, $portals, $ignoreImpassable = true, $overLand = true) { if ($loc1[0] == $loc2[0] && $loc1[1] == $loc2[1]) { - return array ('distance' => 0, 'route' => array (array ($loc1[0], $loc1[1]))); + return array ('distance' => 0, 'route' => array (array (array ($loc1[0], $loc1[1])))); } // Show the route @@ -75,7 +77,7 @@ public static function getDistanceAndRoute ($loc1, $loc2, $portals, $ignoreImpas } $profiler->stop (); - $distance = self::getShortestDistance ($loc1, $loc2, $portals, $ignoreImpassable); + $distance = self::getShortestDistance ($loc1, $loc2, $portals, $ignoreImpassable, $overLand); if ($distance === false) { @@ -93,11 +95,30 @@ public static function getDistanceAndRoute ($loc1, $loc2, $portals, $ignoreImpas $profiler->stop (); - // getRoute() builds the route destination-first with cost keys; flip it. - $route = array (); - foreach (array_reverse ($distance[1]) as $v) + // getRoute() builds the route destination-first; flip it and fill in + // the walked legs. 'jump' marks a node reached through a portal. + $nodes = array_reverse ($distance[1]); + $route = array (array (array ($nodes[0][0], $nodes[0][1]))); + for ($i = 1; $i < count ($nodes); $i ++) { - $route[] = array ($v[0], $v[1]); + $here = array ($nodes[$i][0], $nodes[$i][1]); + + if (!empty ($nodes[$i]['jump'])) + { + $route[] = array ($here); + continue; + } + + $walk = $overLand + ? Dolumar_Map_LandRoute::walk (array ($nodes[$i - 1][0], $nodes[$i - 1][1]), $here) + : array ('points' => array (array ($nodes[$i - 1][0], $nodes[$i - 1][1]), $here)); + + $leg = count ($route) - 1; + $points = $walk['points']; + for ($j = 1; $j < count ($points); $j ++) + { + $route[$leg][] = array ($points[$j][0], $points[$j][1]); + } } return array ('distance' => $distance[0], 'route' => $route); @@ -108,9 +129,10 @@ public static function getDistanceAndRoute ($loc1, $loc2, $portals, $ignoreImpas When max_distance is reached, skip any other calculations. We are only interested in the shortest route. */ - private static function getShortestDistance ($start, $destination, $portals, $ignoreIslands) + private static function getShortestDistance ($start, $destination, $portals, $ignoreIslands, $overLand) { $came_from = array (); + $jump = array (); $g = array (); $h = array (); @@ -159,8 +181,9 @@ private static function getShortestDistance ($start, $destination, $portals, $ig { $route = array ($destination); $route[0]['d'] = 0; + $route[0]['jump'] = !empty ($jump[$fk]); - self::getRoute ($came_from, $fk, $route); + self::getRoute ($came_from, $jump, $fk, $route); return array ($g[$fk], $route); } @@ -171,7 +194,7 @@ private static function getShortestDistance ($start, $destination, $portals, $ig unset ($open[$fk]); $closed[$fk] = $x; - $neighbours = self::getNeighbourNodes ($x, $destination, $portals, $ignoreIslands); + $neighbours = self::getNeighbourNodes ($x, $destination, $portals, $ignoreIslands, $overLand); foreach ($neighbours as $k => $v) { @@ -196,6 +219,7 @@ private static function getShortestDistance ($start, $destination, $portals, $ig $came_from[$k] = $x; $came_from[$k]['d'] = $v['cost']; + $jump[$k] = !empty ($v['jump']); } } } @@ -219,12 +243,15 @@ private static function getShortestDistance ($start, $destination, $portals, $ig return false; } - private static function getRoute ($came_from, $k, &$array) + private static function getRoute ($came_from, $jump, $k, &$array) { if (isset ($came_from[$k])) { - $array[] = $came_from[$k]; - self::getRoute ($came_from, self::getKeyName ($came_from[$k]), $array); + $node = $came_from[$k]; + $nk = self::getKeyName ($node); + $node['jump'] = !empty ($jump[$nk]); + $array[] = $node; + self::getRoute ($came_from, $jump, $nk, $array); } return; } @@ -238,7 +265,7 @@ private static function getKeyName ($loc) Return an array with all the neighbour locations (output = array (array (x, y), distance)) */ - private static function getNeighbourNodes ($loc, $des, $portals, $ignoreIslands) + private static function getNeighbourNodes ($loc, $des, $portals, $ignoreIslands, $overLand) { $out = array (); @@ -248,7 +275,7 @@ private static function getNeighbourNodes ($loc, $des, $portals, $ignoreIslands) $out[self::getKeyName ($des)] = array ( 'key' => $des, - 'cost' => self::calculateDistance ($loc[0], $loc[1], $des[0], $des[1]), + 'cost' => self::walkingDistance ($loc, $des, $overLand), /* self::calculateDistance ($loc[0], $loc[1], $des[0], $des[1]) */ 'heuristics' => self::calculateDistance ($loc[0], $loc[1], $des[0], $des[1]) ); @@ -256,7 +283,7 @@ private static function getNeighbourNodes ($loc, $des, $portals, $ignoreIslands) foreach ($portals as $v) { - self::addPortalNodes ($out, $v, $loc, $des); + self::addPortalNodes ($out, $v, $loc, $des, $overLand); } return $out; @@ -274,7 +301,7 @@ private static function getHeuristics ($loc1, $loc2, $isPortal = false) return $h; } - private static function addPortalNodes (&$out, $portal, $loc, $des) + private static function addPortalNodes (&$out, $portal, $loc, $des, $overLand) { $loc1 = $portal->getCasterLocation (); $loc2 = $portal->getTargetLocation (); @@ -295,7 +322,8 @@ private static function addPortalNodes (&$out, $portal, $loc, $des) ( 'key' => $loc2, 'cost' => $portal->getDistancePenalty (), - 'heuristics' => self::getHeuristics ($loc2, $des, true) + 'heuristics' => self::getHeuristics ($loc2, $des, true), + 'jump' => true ); } @@ -305,7 +333,7 @@ private static function addPortalNodes (&$out, $portal, $loc, $des) $out[self::getKeyName ($loc1)] = array ( 'key' => $loc1, - 'cost' => self::calculateDistance ($loc1[0], $loc1[1], $loc[0], $loc[1]), + 'cost' => self::walkingDistance ($loc, $loc1, $overLand), 'heuristics' => self::getHeuristics ($loc1, $des, true) ); } @@ -317,7 +345,8 @@ private static function addPortalNodes (&$out, $portal, $loc, $des) ( 'key' => $loc1, 'cost' => $portal->getDistancePenalty (), - 'heuristics' => self::getHeuristics ($loc1, $des, true) + 'heuristics' => self::getHeuristics ($loc1, $des, true), + 'jump' => true ); } @@ -327,12 +356,22 @@ private static function addPortalNodes (&$out, $portal, $loc, $des) $out[self::getKeyName ($loc2)] = array ( 'key' => $loc2, - 'cost' => self::calculateDistance ($loc2[0], $loc2[1], $loc[0], $loc[1]), + 'cost' => self::walkingDistance ($loc, $loc2, $overLand), 'heuristics' => self::getHeuristics ($loc2, $des, true) ); } } + private static function walkingDistance ($from, $to, $overLand) + { + if (!$overLand) + { + return self::calculateDistance ($from[0], $from[1], $to[0], $to[1]); + } + $walk = Dolumar_Map_LandRoute::walk (array ($from[0], $from[1]), array ($to[0], $to[1])); + return $walk['distance']; + } + public static function calculateDistance ($x1, $y1, $x2, $y2) { return sqrt (pow ($x1 - $x2, 2) + pow ($y1 - $y2, 2) ); diff --git a/src/Dolumar/Map/LandRoute.php b/src/Dolumar/Map/LandRoute.php new file mode 100644 index 00000000..98e81485 --- /dev/null +++ b/src/Dolumar/Map/LandRoute.php @@ -0,0 +1,498 @@ + float, 'points' => array of array (x, y)) + * from $from to $to inclusive. + */ + public static function walk (array $from, array $to) + { + $a = array ($from[0], $from[1]); + $b = array ($to[0], $to[1]); + + $straight = Dolumar_Map_Distance::calculateDistance ($a[0], $a[1], $b[0], $b[1]); + if ($straight == 0) + { + return array ('distance' => 0, 'points' => array ($a, $b)); + } + + $key = self::cacheKey ($a, $b); + if (isset (self::$memo[$key])) + { + return self::$memo[$key]; + } + + $cache = self::getCache (); + if ($cache) + { + $cached = $cache->getCache ($key); + if (is_string ($cached)) + { + $points = json_decode ($cached, true); + if (is_array ($points) && count ($points) >= 2) + { + return self::remember ($key, self::result ($points)); + } + } + } + + $points = self::findPoints ($a, $b); + $result = self::result ($points); + + if ($cache) + { + $cache->setCache ($key, json_encode ($points)); + } + + return self::remember ($key, $result); + } + + /** + * @param callable|null $test fn (int $x, int $y): bool; null = the real map + */ + public static function setWaterTest ($test) + { + self::$waterTest = $test; + self::reset (); + } + + /** + * @param object|false|null $cache false disables caching, null = memcache + */ + public static function setCache ($cache) + { + self::$cache = $cache; + self::reset (); + } + + public static function reset () + { + self::$memo = array (); + self::$waterMemo = array (); + } + + private static function remember ($key, array $result) + { + if (count (self::$memo) > 1000) + { + self::$memo = array (); + } + self::$memo[$key] = $result; + return $result; + } + + private static function result (array $points) + { + $distance = 0; + for ($i = 1; $i < count ($points); $i ++) + { + $distance += Dolumar_Map_Distance::calculateDistance + ( + $points[$i - 1][0], $points[$i - 1][1], + $points[$i][0], $points[$i][1] + ); + } + return array ('distance' => $distance, 'points' => $points); + } + + private static function getCache () + { + if (self::$cache === null) + { + self::$cache = class_exists ('Neuron_Core_Memcache') && defined ('RANDMAPFACTOR') + ? Neuron_Core_Memcache::getInstance () : false; + } + return self::$cache; + } + + private static function cacheKey (array $a, array $b) + { + $seed = isset ($GLOBALS['MAP_CONFIG_RANDMAPFACTOR']) ? $GLOBALS['MAP_CONFIG_RANDMAPFACTOR'] : ''; + return 'landroute_' . self::CACHE_VERSION . '_' . md5 ($seed) . '_' . + $a[0] . '_' . $a[1] . '_' . $b[0] . '_' . $b[1]; + } + + /* + The route itself, uncached. + */ + private static function findPoints (array $a, array $b) + { + $straight = array ($a, $b); + + // A town on a water tile (should not happen): keep the old behaviour. + if (self::isWater (self::tile ($a)) || self::isWater (self::tile ($b))) + { + return $straight; + } + + $crossings = self::crossings ($a, $b); + if (count ($crossings) == 0) + { + return $straight; + } + + $points = array ($a); + foreach ($crossings as $crossing) + { + list ($shoreIn, $shoreOut) = $crossing; + + $detour = self::searchAround ($shoreIn, $shoreOut); + if ($detour === false) + { + return $straight; + } + + foreach (self::smooth ($detour) as $p) + { + $points[] = $p; + } + } + $points[] = $b; + + return self::shortcut (self::withoutRepeats ($points)); + } + + /** + * The stretches of the straight line from $a to $b that are under water, + * as array (last land tile before, first land tile after). + */ + private static function crossings (array $a, array $b) + { + $out = array (); + + $length = Dolumar_Map_Distance::calculateDistance ($a[0], $a[1], $b[0], $b[1]); + $n = max (1, (int) ceil ($length * self::SAMPLES_PER_TILE)); + + $lastLand = self::tile ($a); + $inWater = false; + + for ($i = 0; $i <= $n; $i ++) + { + $t = $i / $n; + $tile = self::tile (array ($a[0] + ($b[0] - $a[0]) * $t, $a[1] + ($b[1] - $a[1]) * $t)); + + if (self::isWater ($tile)) + { + $inWater = true; + } + else + { + if ($inWater) + { + $out[] = array ($lastLand, $tile); + $inWater = false; + } + $lastLand = $tile; + } + } + + return $out; + } + + /** + * Grid A* from $start to $goal over land tiles inside a box around both, + * growing the box when no way is found. Returns the tiles walked, or false. + */ + private static function searchAround (array $start, array $goal) + { + $margin = self::MARGIN + (int) ceil (Dolumar_Map_Distance::calculateDistance ($start[0], $start[1], $goal[0], $goal[1]) / 2); + + for ($try = 0; $try < self::RETRIES; $try ++) + { + $box = array + ( + min ($start[0], $goal[0]) - $margin, + min ($start[1], $goal[1]) - $margin, + max ($start[0], $goal[0]) + $margin, + max ($start[1], $goal[1]) + $margin + ); + + $path = self::astar ($start, $goal, $box); + if ($path === null) + { + // Out of budget: a bigger box will not help. + return false; + } + if ($path !== false) + { + return $path; + } + + $margin *= 2; + } + + return false; + } + + /** + * @return array|false|null tiles from $start to $goal; false when the box + * holds no way; null when the search budget ran out. + */ + private static function astar (array $start, array $goal, array $box) + { + static $steps = array + ( + array (1, 0), array (-1, 0), array (0, 1), array (0, -1), + array (1, 1), array (1, -1), array (-1, 1), array (-1, -1) + ); + + $open = new SplPriorityQueue (); + $open->setExtractFlags (SplPriorityQueue::EXTR_DATA); + + $startKey = $start[0] . '|' . $start[1]; + $g = array ($startKey => 0.0); + $from = array (); + $closed = array (); + + // Ties broken by insertion order, so the result does not depend on the + // queue's internal order. + $serial = PHP_INT_MAX; + $open->insert ($start, array (-self::octile ($start, $goal), $serial --)); + $expanded = 0; + + while (!$open->isEmpty ()) + { + $c = $open->extract (); + $ck = $c[0] . '|' . $c[1]; + if (isset ($closed[$ck])) + { + continue; + } + $closed[$ck] = true; + + if ($c[0] == $goal[0] && $c[1] == $goal[1]) + { + $path = array ($c); + while (isset ($from[$ck])) + { + $c = $from[$ck]; + $ck = $c[0] . '|' . $c[1]; + $path[] = $c; + } + return array_reverse ($path); + } + + if (++ $expanded > self::MAX_NODES) + { + return null; + } + + foreach ($steps as $d) + { + $n = array ($c[0] + $d[0], $c[1] + $d[1]); + $nk = $n[0] . '|' . $n[1]; + + if (isset ($closed[$nk])) + { + continue; + } + if ($n[0] < $box[0] || $n[1] < $box[1] || $n[0] > $box[2] || $n[1] > $box[3]) + { + continue; + } + if (self::isWater ($n)) + { + continue; + } + + $diagonal = $d[0] != 0 && $d[1] != 0; + + // No cutting a corner of a lake. + if ($diagonal && (self::isWater (array ($c[0] + $d[0], $c[1])) || self::isWater (array ($c[0], $c[1] + $d[1])))) + { + continue; + } + + $cost = $g[$ck] + ($diagonal ? M_SQRT2 : 1); + if (!isset ($g[$nk]) || $cost < $g[$nk]) + { + $g[$nk] = $cost; + $from[$nk] = $c; + $open->insert ($n, array (-($cost + self::octile ($n, $goal)), $serial --)); + } + } + } + + return false; + } + + private static function octile (array $a, array $b) + { + $dx = abs ($a[0] - $b[0]); + $dy = abs ($a[1] - $b[1]); + return max ($dx, $dy) + (M_SQRT2 - 1) * min ($dx, $dy); + } + + /** + * Keep only the points needed to walk the same way in straight lines + * over land (from each kept point, the farthest point still in sight). + */ + private static function smooth (array $points) + { + $count = count ($points); + if ($count <= 2) + { + return $points; + } + + $out = array ($points[0]); + $anchor = 0; + $i = 1; + + while ($i < $count - 1) + { + if (!self::inSight ($points[$anchor], $points[$i + 1])) + { + $out[] = $points[$i]; + $anchor = $i; + } + $i ++; + } + + $out[] = $points[$count - 1]; + return $out; + } + + /** + * Like smooth (), but from each kept point jump to the farthest point in + * sight, not the last one of an unbroken run. Quadratic, so only used on + * the few points left after smooth (). + */ + private static function shortcut (array $points) + { + $count = count ($points); + if ($count <= 2) + { + return $points; + } + + $out = array ($points[0]); + $i = 0; + while ($i < $count - 1) + { + $next = $i + 1; + for ($j = $count - 1; $j > $i + 1; $j --) + { + if (self::inSight ($points[$i], $points[$j])) + { + $next = $j; + break; + } + } + $out[] = $points[$next]; + $i = $next; + } + + return $out; + } + + private static function inSight (array $a, array $b) + { + $length = Dolumar_Map_Distance::calculateDistance ($a[0], $a[1], $b[0], $b[1]); + $n = max (1, (int) ceil ($length * self::SAMPLES_PER_TILE)); + + for ($i = 0; $i <= $n; $i ++) + { + $t = $i / $n; + if (self::isWater (self::tile (array ($a[0] + ($b[0] - $a[0]) * $t, $a[1] + ($b[1] - $a[1]) * $t)))) + { + return false; + } + } + return true; + } + + private static function withoutRepeats (array $points) + { + $out = array (); + foreach ($points as $p) + { + $last = end ($out); + if ($last === false || $last[0] != $p[0] || $last[1] != $p[1]) + { + $out[] = $p; + } + } + return $out; + } + + private static function tile (array $p) + { + return array ((int) round ($p[0]), (int) round ($p[1])); + } + + private static function isWater (array $tile) + { + $k = $tile[0] . '|' . $tile[1]; + if (!isset (self::$waterMemo[$k])) + { + if (count (self::$waterMemo) > 500000) + { + self::$waterMemo = array (); + } + + self::$waterMemo[$k] = self::$waterTest + ? (bool) call_user_func (self::$waterTest, $tile[0], $tile[1]) + : Dolumar_Map_Map::isWater ($tile[0], $tile[1]); + } + return self::$waterMemo[$k]; + } +} diff --git a/src/Dolumar/Map/Map.php b/src/Dolumar/Map/Map.php index c6911b2e..5b388cc8 100644 --- a/src/Dolumar/Map/Map.php +++ b/src/Dolumar/Map/Map.php @@ -691,7 +691,12 @@ public static function calculateDistance ($x1, $y1, $x2, $y2, $ignoreIslands = t return Dolumar_Map_Distance::calculateDistance ($x1, $y1, $x2, $y2); } - public static function getDistanceBetweenVillages ($village1, $village2, $ignoreImpassable = true) + /* + Travel distance between two town centres (through portals). With + $overLand (the default) the walk goes around lakes; pass false for a + quick straight-line estimate, e.g. in lists of many villages. + */ + public static function getDistanceBetweenVillages ($village1, $village2, $ignoreImpassable = true, $overLand = true) { $tc1 = $village1->buildings->getTownCenter (); if (!$tc1) @@ -706,7 +711,7 @@ public static function getDistanceBetweenVillages ($village1, $village2, $ignore } //return self::getDistanceBetweenVillages_simple ($village1, $village2); - return self::getDistanceBetweenVillages_pathbased ($village1, $village2, $ignoreImpassable); + return self::getDistanceBetweenVillages_pathbased ($village1, $village2, $ignoreImpassable, $overLand); } private static function getDistanceBetweenVillages_simple ($village1, $village2, $ignoreImpassable = true) @@ -744,7 +749,8 @@ private static function getDistanceBetweenVillages_simple ($village1, $village2, /* Like getDistanceBetweenVillages, but returns - array ('distance' => float, 'route' => array of array (x, y)) or false. + array ('distance' => float, 'route' => legs) or false; see + Dolumar_Map_Distance::getDistanceAndRoute (). */ public static function getRouteBetweenVillages ($village1, $village2, $ignoreImpassable = true) { @@ -762,7 +768,7 @@ public static function getRouteBetweenVillages ($village1, $village2, $ignoreImp return Dolumar_Map_Distance::getDistanceAndRoute ($loc1, $loc2, $portals, $ignoreImpassable); } - private static function getDistanceBetweenVillages_pathbased ($village1, $village2, $ignoreImpassable = true) + private static function getDistanceBetweenVillages_pathbased ($village1, $village2, $ignoreImpassable = true, $overLand = true) { $tc1 = $village1->buildings->getTownCenter (); $tc2 = $village2->buildings->getTownCenter (); @@ -771,7 +777,7 @@ private static function getDistanceBetweenVillages_pathbased ($village1, $villag // Fetch portals $portals = Dolumar_Map_Portal::getFromVillages (array ($village1, $village2)); - return Dolumar_Map_Distance::getDistance ($loc1, $loc2, $portals, $ignoreImpassable); + return Dolumar_Map_Distance::getDistance ($loc1, $loc2, $portals, $ignoreImpassable, $overLand); } /* diff --git a/src/Dolumar/NewMap/ArmyFactory.php b/src/Dolumar/NewMap/ArmyFactory.php index 6a8b2038..ba0e3764 100644 --- a/src/Dolumar/NewMap/ArmyFactory.php +++ b/src/Dolumar/NewMap/ArmyFactory.php @@ -76,7 +76,7 @@ public static function fromBattleRow (array $row, $now, $poll = 30) $now = intval ($now); $from = array ((float) $row['fromX'], (float) $row['fromY']); $to = array ((float) $row['toX'], (float) $row['toY']); - $segments = self::segments (self::routeFromJson ($row['route'] ?? null, $from, $to)); + $segments = self::routeSegments ($row['route'] ?? null, $from, $to); $startDate = intval ($row['startDate']); $arriveDate = intval ($row['arriveDate']); @@ -150,7 +150,7 @@ public static function fromSquadRow (array $row, $now) $from = array ((float) $row['fromX'], (float) $row['fromY']); $to = array ((float) $row['toX'], (float) $row['toY']); - $segments = self::segments (self::routeFromJson ($row['sc_route'] ?? null, $from, $to)); + $segments = self::routeSegments ($row['sc_route'] ?? null, $from, $to); $homeVid = intval ($row['homeVid']); $army = new Dolumar_NewMap_Army @@ -223,9 +223,6 @@ private static function hasCoordinates (array $row) return true; } - /** - * Decode a stored route; fall back to the straight line when missing or broken. - */ /** * Route of a squad move recalled at $now: from where it is on its way * (the move from $from to $to along $json, between $start and $end) @@ -239,7 +236,7 @@ public static function recallRoute ($json, array $from, array $to, $start, $end, { $from = array ((float) $from[0], (float) $from[1]); $to = array ((float) $to[0], (float) $to[1]); - $segments = self::segments (self::routeFromJson ($json, $from, $to)); + $segments = self::routeSegments ($json, $from, $to); $start = intval ($start); $end = intval ($end); @@ -304,9 +301,15 @@ public static function recallRoute ($json, array $from, array $to, $start, $end, return $out; } - private static function routeFromJson ($json, array $from, array $to) + /** + * Walking segments of a stored route (see segments () and segmentsOfLegs ()); + * the straight line from $from to $to when it is missing or broken. + * + * @return array list of array (array (x, y), array (x, y)) + */ + public static function routeSegments ($json, array $from, array $to) { - $fallback = array ($from, $to); + $fallback = array (array ($from, $to)); if ($json === null || $json === '') { @@ -314,21 +317,81 @@ private static function routeFromJson ($json, array $from, array $to) } $route = json_decode ($json, true); - if (!is_array ($route) || count ($route) < 2) + if (!is_array ($route) || count ($route) == 0) + { + return $fallback; + } + + // Legs: list of (list of points), a portal jump between two legs. + if (isset ($route[0][0]) && is_array ($route[0][0])) + { + $legs = array (); + foreach ($route as $leg) + { + $points = is_array ($leg) ? self::points ($leg) : false; + if ($points === false || count ($points) == 0) + { + return $fallback; + } + $legs[] = $points; + } + return self::segmentsOfLegs ($legs); + } + + // Older flat list of points. + $points = self::points ($route); + if ($points === false || count ($points) < 2) { return $fallback; } + return self::segments ($points); + } + + /** + * Walking segments of a route stored as legs: each leg is walked point to + * point; the gap between the end of a leg and the start of the next is a + * portal jump. + * + * @param array $legs list of (list of array (x, y)) + * @return array list of array (array (x, y), array (x, y)) + */ + public static function segmentsOfLegs (array $legs) + { + $out = array (); + foreach ($legs as $leg) + { + $count = count ($leg); + if ($count == 1) + { + $out[] = array ($leg[0], $leg[0]); + } + for ($i = 0; $i < $count - 1; $i ++) + { + $out[] = array ($leg[$i], $leg[$i + 1]); + } + } + if (count ($out) == 0) + { + return array (array (array (0, 0), array (0, 0))); + } + return $out; + } + + /** + * @return array|false list of array (x, y) as floats, false when malformed + */ + private static function points (array $list) + { $out = array (); - foreach ($route as $p) + foreach ($list as $p) { if (!is_array ($p) || !isset ($p[0], $p[1]) || !is_numeric ($p[0]) || !is_numeric ($p[1])) { - return $fallback; + return false; } $out[] = array ((float) $p[0], (float) $p[1]); } - return $out; } diff --git a/src/Dolumar/NewMap/MovingRows.php b/src/Dolumar/NewMap/MovingRows.php index 7d233f66..dc433121 100644 --- a/src/Dolumar/NewMap/MovingRows.php +++ b/src/Dolumar/NewMap/MovingRows.php @@ -185,18 +185,30 @@ public static function pointsOfRow (array $row, $routeKey) $route = isset ($row[$routeKey]) ? json_decode ($row[$routeKey], true) : null; if (is_array ($route)) { - foreach ($route as $p) - { - if (is_array ($p) && isset ($p[0], $p[1]) && is_numeric ($p[0]) && is_numeric ($p[1])) - { - $points[] = array ($p[0], $p[1]); - } - } + self::collectPoints ($route, $points); } return $points; } + /* + Points of a stored route: a flat list of (x, y), or a list of legs (lists of points). + */ + private static function collectPoints (array $list, array &$points) + { + foreach ($list as $p) + { + if (is_array ($p) && isset ($p[0], $p[1]) && is_numeric ($p[0]) && is_numeric ($p[1])) + { + $points[] = array ($p[0], $p[1]); + } + elseif (is_array ($p)) + { + self::collectPoints ($p, $points); + } + } + } + /** * Serve $loader's result from $cache for $ttl seconds. The cache stores * the load time itself because Neuron_Core_Memcache has no expiry. diff --git a/src/Dolumar/SpecialUnits/SpecialUnits.php b/src/Dolumar/SpecialUnits/SpecialUnits.php index 4c66cce2..c611e25b 100755 --- a/src/Dolumar/SpecialUnits/SpecialUnits.php +++ b/src/Dolumar/SpecialUnits/SpecialUnits.php @@ -299,8 +299,10 @@ public function getLocation () */ public function moveUnit ($target) { - // Calculate distance - $distance = Dolumar_Map_Map::getDistanceBetweenVillages ($this->getLocation (), $target); + // Calculate distance (around lakes) and the route for the map + $from = $this->getLocation (); + $routeData = Dolumar_Map_Map::getRouteBetweenVillages ($from, $target); + $distance = $routeData === false ? false : $routeData['distance']; $duration = $this->getTravelDuration ($distance); // Update @@ -313,10 +315,14 @@ public function moveUnit ($target) ( 'vsu_moveStart' => Neuron_Core_Tools::timestampToMysqlDatetime (time ()), 'vsu_moveEnd' => Neuron_Core_Tools::timestampToMysqlDatetime (time () + $duration), - 'vsu_location' => $target->getId () + 'vsu_location' => $target->getId (), + 'vsu_from' => $from->getId (), + 'vsu_route' => $routeData === false ? '' : json_encode ($routeData['route']) ), "vsu_id = ".$this->getId () ); + + Dolumar_NewMap_MapObjectManager::armiesChanged (); } /* diff --git a/src/Dolumar/Windows/PlayerProfile.php b/src/Dolumar/Windows/PlayerProfile.php index cfbc4568..f4c71032 100644 --- a/src/Dolumar/Windows/PlayerProfile.php +++ b/src/Dolumar/Windows/PlayerProfile.php @@ -277,7 +277,7 @@ public function getVillageProfile ($objVillage) ) ); - $distance = Dolumar_Map_Map::getDistanceBetweenVillages ($v, $objVillage); + $distance = Dolumar_Map_Map::getDistanceBetweenVillages ($v, $objVillage, true, false); $page->addListValue ( diff --git a/src/Dolumar/Windows/Ranking.php b/src/Dolumar/Windows/Ranking.php index 78cfe17b..a7fed891 100644 --- a/src/Dolumar/Windows/Ranking.php +++ b/src/Dolumar/Windows/Ranking.php @@ -284,7 +284,7 @@ public function getVillageRanking () { $w_distances[$k] = Neuron_Core_Tools::output_distance ( - Dolumar_Map_Map::getDistanceBetweenVillages ($vv, $v), + Dolumar_Map_Map::getDistanceBetweenVillages ($vv, $v, true, false), true, true ); diff --git a/tests/Map/DistanceRouteTest.php b/tests/Map/DistanceRouteTest.php index 297d725b..6f1bab7c 100644 --- a/tests/Map/DistanceRouteTest.php +++ b/tests/Map/DistanceRouteTest.php @@ -4,20 +4,80 @@ class DistanceRouteTest extends TestCase { - public function testStraightRouteHasTwoPointsAndEuclideanDistance(): void + protected function setUp(): void { - $r = Dolumar_Map_Distance::getDistanceAndRoute([0, 0], [3, 4], [], true); + // A lake from x = 5 to 15, y = -10 to 10; everything else is land. + Dolumar_Map_LandRoute::setCache(false); + Dolumar_Map_LandRoute::setWaterTest(fn ($x, $y) => $x >= 5 && $x <= 15 && $y >= -10 && $y <= 10); + } + + protected function tearDown(): void + { + Dolumar_Map_LandRoute::setWaterTest(null); + Dolumar_Map_LandRoute::setCache(null); + } + + public function testStraightRouteIsOneLegWithEuclideanDistance(): void + { + $r = Dolumar_Map_Distance::getDistanceAndRoute([0, 20], [3, 24], [], true); $this->assertEqualsWithDelta(5.0, $r['distance'], 0.001); - $this->assertSame([[0, 0], [3, 4]], $r['route']); + $this->assertSame([[[0, 20], [3, 24]]], $r['route']); } public function testSamePointIsZeroWithOnePoint(): void { - $this->assertSame(['distance' => 0, 'route' => [[1, 1]]], Dolumar_Map_Distance::getDistanceAndRoute([1, 1], [1, 1], [], true)); + $this->assertSame(['distance' => 0, 'route' => [[[1, 1]]]], Dolumar_Map_Distance::getDistanceAndRoute([1, 1], [1, 1], [], true)); } public function testGetDistanceStillReturnsNumber(): void { - $this->assertEqualsWithDelta(5.0, Dolumar_Map_Distance::getDistance([0, 0], [3, 4], [], true), 0.001); + $this->assertEqualsWithDelta(5.0, Dolumar_Map_Distance::getDistance([0, 20], [3, 24], [], true), 0.001); + } + + public function testRouteGoesAroundTheLake(): void + { + $r = Dolumar_Map_Distance::getDistanceAndRoute([0, 0], [20, 0], [], true); + $this->assertGreaterThan(30, $r['distance']); + $this->assertCount(1, $r['route']); + $leg = $r['route'][0]; + $this->assertSame([0, 0], $leg[0]); + $this->assertSame([20, 0], end($leg)); + $this->assertGreaterThan(2, count($leg)); + } + + public function testStraightLineWhenNotOverLand(): void + { + $r = Dolumar_Map_Distance::getDistanceAndRoute([0, 0], [20, 0], [], true, false); + $this->assertEqualsWithDelta(20.0, $r['distance'], 0.001); + $this->assertSame([[[0, 0], [20, 0]]], $r['route']); + } + + public function testPortalJumpStartsANewLeg(): void + { + $portal = new class { + public function getId() { return 1; } + public function getCasterLocation() { return [1, 30]; } + public function getTargetLocation() { return [100, 30]; } + public function getDistancePenalty() { return 2; } + }; + + $r = Dolumar_Map_Distance::getDistanceAndRoute([0, 30], [101, 30], [$portal], true); + $this->assertEqualsWithDelta(4.0, $r['distance'], 0.001); + $this->assertSame([[[0, 30], [1, 30]], [[100, 30], [101, 30]]], $r['route']); + } + + public function testPortalApproachGoesAroundTheLake(): void + { + $portal = new class { + public function getId() { return 1; } + public function getCasterLocation() { return [20, 0]; } + public function getTargetLocation() { return [500, 0]; } + public function getDistancePenalty() { return 1; } + }; + + $r = Dolumar_Map_Distance::getDistanceAndRoute([0, 0], [501, 0], [$portal], true); + $this->assertCount(2, $r['route']); + $this->assertGreaterThan(2, count($r['route'][0])); + $this->assertGreaterThan(30 + 1 + 1, $r['distance']); } } diff --git a/tests/Map/LandRouteTest.php b/tests/Map/LandRouteTest.php new file mode 100644 index 00000000..b08c5128 --- /dev/null +++ b/tests/Map/LandRouteTest.php @@ -0,0 +1,147 @@ +assertFalse($water($x, $y), "leg $i passes water at $x,$y"); + } + } + } + + public function testOpenLandIsTheStraightLine(): void + { + $this->lake(fn ($x, $y) => false); + $r = Dolumar_Map_LandRoute::walk([0, 0], [30, 40]); + $this->assertSame(50.0, (float) $r['distance']); + $this->assertSame([[0, 0], [30, 40]], $r['points']); + } + + public function testGoesAroundALake(): void + { + $water = fn ($x, $y) => $x >= 5 && $x <= 15 && $y >= -10 && $y <= 10; + $this->lake($water); + + $r = Dolumar_Map_LandRoute::walk([0, 0], [20, 0]); + + $this->assertSame([0, 0], $r['points'][0]); + $this->assertSame([20, 0], end($r['points'])); + $this->assertStaysOnLand($r['points'], $water); + // Around the corner (4,11)/(16,11) or below: a bit more than 2 * hypot(4, 11) + 12 + $this->assertGreaterThan(35, $r['distance']); + $this->assertLessThan(38, $r['distance']); + // Smoothed: a handful of corners, not every tile of the shore + $this->assertLessThanOrEqual(6, count($r['points'])); + } + + public function testCrossesTwoLakes(): void + { + $water = fn ($x, $y) => ($x >= 5 && $x <= 8 && abs($y) <= 6) || ($x >= 20 && $x <= 25 && $y >= -3 && $y <= 9); + $this->lake($water); + + $r = Dolumar_Map_LandRoute::walk([0, 0], [40, 0]); + $this->assertStaysOnLand($r['points'], $water); + $this->assertGreaterThan(40, $r['distance']); + } + + public function testNoWayAroundFallsBackToTheStraightLine(): void + { + // A wall of water across the whole map. + $this->lake(fn ($x, $y) => $x >= 5 && $x <= 8); + $r = Dolumar_Map_LandRoute::walk([0, 0], [20, 0]); + $this->assertSame([[0, 0], [20, 0]], $r['points']); + $this->assertEqualsWithDelta(20.0, $r['distance'], 0.0001); + } + + public function testResultIsCached(): void + { + $cache = new class { + public $store = []; + public function getCache($k) { return $this->store[$k] ?? false; } + public function setCache($k, $v) { $this->store[$k] = $v; return true; } + }; + Dolumar_Map_LandRoute::setWaterTest(fn ($x, $y) => $x >= 5 && $x <= 15 && abs($y) <= 10); + Dolumar_Map_LandRoute::setCache($cache); + + $first = Dolumar_Map_LandRoute::walk([0, 0], [20, 0]); + $this->assertCount(1, $cache->store); + + // A fresh process (memo cleared) with all land would give a straight line; the cache wins. + Dolumar_Map_LandRoute::setWaterTest(fn ($x, $y) => false); + Dolumar_Map_LandRoute::setCache($cache); + $this->assertEqualsWithDelta($first['distance'], Dolumar_Map_LandRoute::walk([0, 0], [20, 0])['distance'], 0.0001); + } + + public static function realMapRoutes(): array + { + return [ + 'short, across a lake' => [[431, -430], [491, -448]], + 'long, several lakes' => [[-88, 336], [-741, 291]], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('realMapRoutes')] + public function testRealMapRouteStaysOnLand(array $from, array $to): void + { + if (!defined('MAXMAPSTRAAL')) { + define('MAXMAPSTRAAL', 15000); + } + if (!defined('RANDMAPFACTOR')) { + define('RANDMAPFACTOR', 'Funky Dolumar Game 2008'); + } + class_exists('Dolumar_Map_Map'); + $previous = $GLOBALS['MAP_CONFIG_RANDMAPFACTOR']; + $GLOBALS['MAP_CONFIG_RANDMAPFACTOR'] = 'Funky Dolumar Game 2008'; + + try { + $water = fn ($x, $y) => Dolumar_Map_Map::isWater($x, $y); + $this->lake($water); + Dolumar_Map_LandRoute::setWaterTest(null); + + $this->assertTrue($this->crossesWater($from, $to, $water), 'the straight line should cross water'); + + $started = microtime(true); + $r = Dolumar_Map_LandRoute::walk($from, $to); + $this->assertLessThan(2.0, microtime(true) - $started, 'route search too slow'); + + $straight = hypot($to[0] - $from[0], $to[1] - $from[1]); + $this->assertGreaterThan($straight, $r['distance']); + $this->assertLessThan($straight * 1.25, $r['distance']); + $this->assertStaysOnLand($r['points'], $water); + } finally { + $GLOBALS['MAP_CONFIG_RANDMAPFACTOR'] = $previous; + } + } + + private function crossesWater(array $a, array $b, callable $water): bool + { + $n = (int) ceil(hypot($b[0] - $a[0], $b[1] - $a[1]) * 3); + for ($j = 0; $j <= $n; $j ++) { + if ($water((int) round($a[0] + ($b[0] - $a[0]) * $j / $n), (int) round($a[1] + ($b[1] - $a[1]) * $j / $n))) { + return true; + } + } + return false; + } +} From ef243c42ea984b29334e5ba775246ade6a00ae8b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 13:05:06 +0000 Subject: [PATCH 2/3] Show moving thieves on the map with their own flag A thief sent to another village now stores where it left from and its route around lakes (villages_specialunits.vsu_from / vsu_route, new upgrade file). The map draws it with a dark pennant with a silver crescent. Thieves are secret: only the owner and their clan get them, not the target and not unrelated viewers. Thieves sent before this change have no vsu_from and are not drawn. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WpWbf9LAeFevtaMRw9drZH --- .ai/architecture.md | 9 +++- dump/dump.sql | 2 + dump/upgrades/20261105-thief-routes.sql | 17 ++++++ public/static/images/sprites/armies/thief.png | Bin 0 -> 2727 bytes resources/language/de/map.lng | 1 + resources/language/en/map.lng | 1 + resources/language/fr/map.lng | 1 + .../default/gameserver/index/header.phpt | 2 +- src/Dolumar/NewMap/Army.php | 21 ++++++-- src/Dolumar/NewMap/ArmyFactory.php | 44 +++++++++++++++ src/Dolumar/NewMap/MapObjectManager.php | 25 ++++++++- src/Dolumar/NewMap/MovingRows.php | 31 +++++++++++ tests/NewMap/ArmyFactoryTest.php | 50 ++++++++++++++++++ tests/NewMap/MovingRowsTest.php | 16 ++++++ 14 files changed, 212 insertions(+), 8 deletions(-) create mode 100644 dump/upgrades/20261105-thief-routes.sql create mode 100644 public/static/images/sprites/armies/thief.png diff --git a/.ai/architecture.md b/.ai/architecture.md index baee63e5..4437b7f8 100644 --- a/.ai/architecture.md +++ b/.ai/architecture.md @@ -106,9 +106,14 @@ Under `src/Dolumar/`: 20 s memcache of the rows), `ArmyFactory` (turns a row into a `Dolumar_NewMap_Army` with one engine `Movement` per phase: march out, wait at the target, march home; portal routes jump), and `MapObjectManager::getMovingObjects()`, which the engine's `map/objects` endpoint calls. Routes are stored at - dispatch time in `battle.route` and `squad_commands.sc_route` (JSON waypoints from `Dolumar_Map_Distance`). + dispatch time in `battle.route`, `squad_commands.sc_route` and `villages_specialunits.vsu_route` (JSON legs from + `Dolumar_Map_Distance`: a list of point lists, a portal jump between legs; older rows hold a flat point list and + are still read). Walking goes around lakes: `Map_LandRoute` keeps the straight line and only searches a way + around each stretch that crosses water (grid A* in a box around it), smooths the result, caches it in memcache + and falls back to the straight line when there is no way around. Ranking and profile lists pass + `$overLand = false` for a cheap straight-line distance. Related viewers (own or clan villages involved) get the full path and a tap action; everyone else sees a neutral - sprite with only the next 30 s of movement. The kind texts come from `resources/language/*/map.lng` through + sprite with only the next 30 s of movement. Thieves are secret: only their owner and clan see them at all. The kind texts come from `resources/language/*/map.lng` through `CONFIG_MAPOBJECT_TEXT` in `gameserver/index/header.phpt`; the engine layer itself is generic (`Game.mapObjects`, `mapobjects.js`). - `Underworld/`: a separate, turn-based mini-game with its own models, mappers and windows. - `Mappers/` and `Underworld/Mappers/`: the few classes that follow a mapper pattern. Most models query the database diff --git a/dump/dump.sql b/dump/dump.sql index 341f34ff..2f0b2434 100644 --- a/dump/dump.sql +++ b/dump/dump.sql @@ -1548,6 +1548,8 @@ CREATE TABLE `villages_specialunits` ( `vsu_location` int(11) DEFAULT NULL, `vsu_moveStart` datetime DEFAULT NULL, `vsu_moveEnd` datetime DEFAULT NULL, + `vsu_from` int(11) DEFAULT NULL, + `vsu_route` text DEFAULT NULL, PRIMARY KEY (`vsu_id`), KEY `v_id` (`v_id`) ) ENGINE=InnoDB AUTO_INCREMENT=1638 DEFAULT CHARSET=utf8; diff --git a/dump/upgrades/20261105-thief-routes.sql b/dump/upgrades/20261105-thief-routes.sql new file mode 100644 index 00000000..5eb99e81 --- /dev/null +++ b/dump/upgrades/20261105-thief-routes.sql @@ -0,0 +1,17 @@ +-- Thieves on the map: where a moving thief left from and its route around lakes (JSON legs, like battle.route), +-- stored when it is sent so the map can draw it. Thieves sent before this have no vsu_from and are not drawn. +-- Runs on every deploy (tools/setup.php), so each ALTER only runs when the column is still missing. + +SET @dolumar_sql = (SELECT IF (COUNT(*) = 0, 'ALTER TABLE `villages_specialunits` ADD COLUMN `vsu_from` int(11) DEFAULT NULL', 'DO 0') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'villages_specialunits' AND COLUMN_NAME = 'vsu_from'); +PREPARE dolumar_stmt FROM @dolumar_sql; +EXECUTE dolumar_stmt; +DEALLOCATE PREPARE dolumar_stmt; + +SET @dolumar_sql = (SELECT IF (COUNT(*) = 0, 'ALTER TABLE `villages_specialunits` ADD COLUMN `vsu_route` text DEFAULT NULL', 'DO 0') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'villages_specialunits' AND COLUMN_NAME = 'vsu_route'); +PREPARE dolumar_stmt FROM @dolumar_sql; +EXECUTE dolumar_stmt; +DEALLOCATE PREPARE dolumar_stmt; diff --git a/public/static/images/sprites/armies/thief.png b/public/static/images/sprites/armies/thief.png new file mode 100644 index 0000000000000000000000000000000000000000..050bc57ad12284a43163e1a79a5c1df0213c99b1 GIT binary patch literal 2727 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 ($armyKind, 'armies', 'map'); } diff --git a/src/Dolumar/NewMap/Army.php b/src/Dolumar/NewMap/Army.php index 8b7cf2b3..0ab77648 100644 --- a/src/Dolumar/NewMap/Army.php +++ b/src/Dolumar/NewMap/Army.php @@ -22,7 +22,7 @@ */ /** -* A marching army (attack, return trip or squad move) as a map object. +* A marching army (attack, return trip, squad move or thief) as a map object. * Built by Dolumar_NewMap_ArmyFactory; the movements describe where it is * at any moment. Only related viewers (the villages involved, or their * clan mates) get the details, see isRelatedTo. @@ -34,10 +34,11 @@ class Dolumar_NewMap_Army extends Neuron_GameServer_Map_MapObject private $relatedVillageIds; private $name; private $spriteUrl; + private $secret = false; /** - * @param string $uoid e.g. "b:12" (battle) or "s:7" (squad command) - * @param string $kind attack | waiting | returning | squad + * @param string $uoid e.g. "b:12" (battle), "s:7" (squad command) or "t:3" (thief) + * @param string $kind attack | waiting | returning | squad | thief * @param int[] $relatedVillageIds villages whose owners (and clan mates) may see details * @param string|null $name * @param string|null $click javascript to run on tap @@ -90,6 +91,20 @@ public function getNeutralDisplayObject () return new Neuron_GameServer_Map_Display_Sprite (Dolumar_NewMap_ArmyFactory::spriteUrl ('neutral')); } + /** + * A secret object (a thief) is never sent to unrelated viewers, not even + * as a neutral sprite. + */ + public function setSecret ($secret) + { + $this->secret = (bool) $secret; + } + + public function isSecret () + { + return $this->secret; + } + public function getRelatedVillageIds () { return $this->relatedVillageIds; diff --git a/src/Dolumar/NewMap/ArmyFactory.php b/src/Dolumar/NewMap/ArmyFactory.php index ba0e3764..08ac0086 100644 --- a/src/Dolumar/NewMap/ArmyFactory.php +++ b/src/Dolumar/NewMap/ArmyFactory.php @@ -32,6 +32,7 @@ class Dolumar_NewMap_ArmyFactory const KIND_WAITING = 'waiting'; const KIND_RETURNING = 'returning'; const KIND_SQUAD = 'squad'; + const KIND_THIEF = 'thief'; /** Map object id of a battle (attack, wait, return) */ public static function battleId ($battleId) @@ -45,6 +46,11 @@ public static function squadId ($scId) return 's:' . intval ($scId); } + public static function thiefId ($vsuId) + { + return 't:' . intval ($vsuId); + } + /** * Onclick script that centres the map on an army. $fallback (x, y) is where * to look when the army is not loaded on the map yet (a town centre it @@ -169,6 +175,44 @@ public static function fromSquadRow (array $row, $now) return $army; } + /** + * A thief walking to another village. Only its owner and their clan see + * it: the target must not learn that a thief is on its way. + * + * @param array $row villages_specialunits columns (moveStart, moveEnd as + * unix timestamps) plus fromX, fromY, toX, toY, fromName, toName, homeVid + * @param int $now + * @return Dolumar_NewMap_Army|null + */ + public static function fromThiefRow (array $row, $now) + { + if (!self::hasCoordinates ($row)) + { + return null; + } + + $from = array ((float) $row['fromX'], (float) $row['fromY']); + $to = array ((float) $row['toX'], (float) $row['toY']); + $segments = self::routeSegments ($row['vsu_route'] ?? null, $from, $to); + + $homeVid = intval ($row['homeVid']); + $army = new Dolumar_NewMap_Army + ( + self::thiefId ($row['vsu_id']), + self::KIND_THIEF, + array ($homeVid), + $row['fromName'] . ' → ' . $row['toName'], + "openWindow ('Thievery', {'village': " . $homeVid . ", 'building': " . intval ($row['vsu_bid']) . "});", + self::spriteUrl (self::KIND_THIEF) + ); + $army->setSecret (true); + $army->setLocation (new Neuron_GameServer_Map_Location ($from[0], $from[1])); + + self::addPhase ($army, $segments, intval ($row['moveStart']), intval ($row['moveEnd'])); + + return $army; + } + /** * Walking segments of a route. A route is start, [portal in, portal out, ...] end: * an even number of points is walked in pairs (the gap between a pair and the diff --git a/src/Dolumar/NewMap/MapObjectManager.php b/src/Dolumar/NewMap/MapObjectManager.php index cf97f6d8..739abd57 100755 --- a/src/Dolumar/NewMap/MapObjectManager.php +++ b/src/Dolumar/NewMap/MapObjectManager.php @@ -106,7 +106,7 @@ public function getDisplayObjects (Neuron_GameServer_Map_Area $area) const POLL_SECONDS = 30; /** - * Armies (attacks, returns, squad moves) touching any of $areas, for + * Armies (attacks, returns, squad moves, thieves) touching any of $areas, for * the map/objects endpoint. $viewer is the player looking, or null. * * @param Neuron_GameServer_Map_Area[] $areas @@ -147,6 +147,21 @@ public function getMovingObjects (array $areas, $viewer = null) } } + $thieves = isset ($rows['thieves']) ? $rows['thieves'] : array (); + foreach ($thieves as $row) + { + if (!Dolumar_NewMap_MovingRows::touchesAreas (Dolumar_NewMap_MovingRows::pointsOfRow ($row, 'vsu_route'), $areas)) + { + continue; + } + + $army = Dolumar_NewMap_ArmyFactory::fromThiefRow ($row, $now); + if ($army) + { + $out[] = $army; + } + } + $related = is_object ($viewer) ? self::relatedVillageIds ($viewer) : array (); return self::filterForViewer ($out, $related, $areas, $now); @@ -170,6 +185,11 @@ public static function filterForViewer (array $armies, array $relatedVillageIds, { if (!$army->isRelatedTo ($relatedVillageIds)) { + if ($army->isSecret ()) + { + continue; + } + $location = $army->getLocation ($now); if (!Dolumar_NewMap_MovingRows::touchesAreas (array (array ($location->x (), $location->y ())), $areas)) { @@ -201,7 +221,8 @@ function () use ($now) return array ( 'battles' => Dolumar_NewMap_MovingRows::battles ($db, $now), - 'squads' => Dolumar_NewMap_MovingRows::squads ($db, $now) + 'squads' => Dolumar_NewMap_MovingRows::squads ($db, $now), + 'thieves' => Dolumar_NewMap_MovingRows::thieves ($db, $now) ); } ); diff --git a/src/Dolumar/NewMap/MovingRows.php b/src/Dolumar/NewMap/MovingRows.php index dc433121..58f3bc82 100644 --- a/src/Dolumar/NewMap/MovingRows.php +++ b/src/Dolumar/NewMap/MovingRows.php @@ -85,6 +85,37 @@ public static function squads (Neuron_DB_Database $db, $now) ")); } + /** + * Thieves (special units) on their way to another village. Units sent + * before routes were stored (no vsu_from) are left out. + * Row keys match ArmyFactory::fromThiefRow(). + */ + public static function thieves (Neuron_DB_Database $db, $now) + { + $now = intval ($now); + + return self::rows ($db->query + (" + SELECT + vsu.vsu_id, vsu.vsu_bid, vsu.v_id AS homeVid, + vsu.vsu_from, vsu.vsu_location, vsu.vsu_route, + UNIX_TIMESTAMP(vsu.vsu_moveStart) AS moveStart, + UNIX_TIMESTAMP(vsu.vsu_moveEnd) AS moveEnd, + tc1.xas AS fromX, tc1.yas AS fromY, + tc2.xas AS toX, tc2.yas AS toY, + v1.vname AS fromName, v2.vname AS toName + FROM + villages_specialunits vsu + JOIN villages v1 ON v1.vid = vsu.vsu_from + JOIN villages v2 ON v2.vid = vsu.vsu_location + LEFT JOIN map_buildings tc1 ON tc1.village = vsu.vsu_from AND tc1.buildingType IN (1,3) AND tc1.destroyDate = 0 + LEFT JOIN map_buildings tc2 ON tc2.village = vsu.vsu_location AND tc2.buildingType IN (1,3) AND tc2.destroyDate = 0 + WHERE + vsu.vsu_from IS NOT NULL + AND vsu.vsu_moveEnd > FROM_UNIXTIME({$now}) + ")); + } + /** * Villages the player may see army details of: their own, plus every * village of every member of every clan they are in. diff --git a/tests/NewMap/ArmyFactoryTest.php b/tests/NewMap/ArmyFactoryTest.php index cf8bd518..bf8fbef2 100644 --- a/tests/NewMap/ArmyFactoryTest.php +++ b/tests/NewMap/ArmyFactoryTest.php @@ -139,4 +139,54 @@ public function testRecallRouteBeforeLeavingStaysHome(): void { $this->assertEqualsWithDelta([[0, 0], [0, 0]], Dolumar_NewMap_ArmyFactory::recallRoute(null, [0, 0], [10, 0], 1000, 1100, 1000), 0.0001); } + + public function testRouteStoredAsLegsWalksAroundAndJumpsBetweenLegs(): void + { + // leg one walks (0,0) -> (5,5) -> (10,0), then a portal jump to (100,0) -> (110,0) + $segments = Dolumar_NewMap_ArmyFactory::routeSegments('[[[0,0],[5,5],[10,0]],[[100,0],[110,0]]]', [0, 0], [110, 0]); + $this->assertSame([[[0.0, 0.0], [5.0, 5.0]], [[5.0, 5.0], [10.0, 0.0]], [[100.0, 0.0], [110.0, 0.0]]], $segments); + } + + public function testOldFlatRoutesStillRead(): void + { + $this->assertSame([[[0.0, 0.0], [10.0, 0.0]], [[100.0, 0.0], [110.0, 0.0]]], + Dolumar_NewMap_ArmyFactory::routeSegments('[[0,0],[10,0],[100,0],[110,0]]', [0, 0], [110, 0])); + $this->assertSame([[[0, 0], [10, 0]]], Dolumar_NewMap_ArmyFactory::routeSegments('[[[0,0]],"x"]', [0, 0], [10, 0])); + } + + public function testBattleAroundALakeFollowsTheShore(): void + { + // 20 + 20 tiles via (10,10): at the halfway time the army is at the corner, not on the straight line. + $a = Dolumar_NewMap_ArmyFactory::fromBattleRow($this->row(['route' => '[[[0,0],[10,10],[20,0]]]', 'toX' => 20]), 1050); + $this->assertEqualsWithDelta(10.0, $a->getLocation(1050)->x(), 0.01); + $this->assertEqualsWithDelta(10.0, $a->getLocation(1050)->y(), 0.01); + } + + private function thiefRow(array $o = []): array + { + return $o + ['vsu_id' => 4, 'vsu_bid' => 55, 'homeVid' => 1, 'vsu_from' => 1, 'vsu_location' => 2, 'vsu_route' => null, + 'moveStart' => 1000, 'moveEnd' => 1100, 'fromX' => 0, 'fromY' => 0, 'toX' => 10, 'toY' => 0, 'fromName' => 'A', 'toName' => 'B']; + } + + public function testThiefOnTheMove(): void + { + $a = Dolumar_NewMap_ArmyFactory::fromThiefRow($this->thiefRow(), 1050); + $this->assertSame('t:4', $a->getUOID()); + $this->assertSame('thief', $a->getKind()); + $this->assertTrue($a->isSecret()); + $this->assertSame([1], $a->getRelatedVillageIds()); + $this->assertEqualsWithDelta(5.0, $a->getLocation(1050)->x(), 0.001); + $this->assertSame("openWindow ('Thievery', {'village': 1, 'building': 55});", implode(' ', $a->getEvents('click'))); + $this->assertStringEndsWith('images/sprites/armies/thief.png', $a->getDisplayObject()->getURI()); + } + + public function testThiefIsOnlySentToItsOwnerAndClan(): void + { + $a = Dolumar_NewMap_ArmyFactory::fromThiefRow($this->thiefRow(), 1050); + $areas = Neuron_GameServer_Map_Regions::toAreas([[0, 0]], 10); + $this->assertCount(1, Dolumar_NewMap_MapObjectManager::filterForViewer([$a], [1], $areas, 1050)); + // the target (village 2) and strangers never see it, even when it walks through what they look at + $this->assertCount(0, Dolumar_NewMap_MapObjectManager::filterForViewer([$a], [2], $areas, 1050)); + $this->assertCount(0, Dolumar_NewMap_MapObjectManager::filterForViewer([$a], [], $areas, 1050)); + } } diff --git a/tests/NewMap/MovingRowsTest.php b/tests/NewMap/MovingRowsTest.php index eb1441a0..a14bfb27 100644 --- a/tests/NewMap/MovingRowsTest.php +++ b/tests/NewMap/MovingRowsTest.php @@ -89,6 +89,22 @@ public function testSquadRowsSelectTheFactoryAliases(): void } } + public function testThiefRowsSelectTheFactoryAliases(): void + { + $db = new FakeDatabase(['villages_specialunits vsu' => [['vsu_id' => 1]]]); + Dolumar_NewMap_MovingRows::thieves($db, 1000); + $sql = $db->queries[0]; + foreach (['vsu_id', 'vsu_bid', 'homeVid', 'vsu_route', 'AS moveStart', 'AS moveEnd', 'fromX', 'toY', 'fromName', 'toName', 'vsu_from IS NOT NULL', 'FROM_UNIXTIME(1000)'] as $needle) { + $this->assertStringContainsString($needle, $sql); + } + } + + public function testPointsOfRowReadLegs(): void + { + $row = ['fromX' => 0, 'fromY' => 0, 'toX' => 9, 'toY' => 0, 'route' => '[[[0,0],[4,6]],[[50,50],[9,0]]]']; + $this->assertSame([[0, 0], [9, 0], [0, 0], [4, 6], [50, 50], [9, 0]], Dolumar_NewMap_MovingRows::pointsOfRow($row, 'route')); + } + public function testCacheMissWithoutMemcacheStillAnswers(): void { $cache = new FakeCache(); From d16b28bfeeba97e35f390a70ebe8389b2d853bd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 13:17:24 +0000 Subject: [PATCH 3/3] Land routes: same route both ways, seed loaded before the cache key From review: walk (b, a) could pick a different equal-cost detour than walk (a, b), so a regiment's or thief's trip home took a few seconds longer or shorter than the trip out. Search one canonical direction and reverse it. Also make sure the map seed is loaded before building the cache key. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WpWbf9LAeFevtaMRw9drZH --- src/Dolumar/Map/LandRoute.php | 12 ++++++++++++ tests/Map/LandRouteTest.php | 9 +++++++++ 2 files changed, 21 insertions(+) diff --git a/src/Dolumar/Map/LandRoute.php b/src/Dolumar/Map/LandRoute.php index 98e81485..8a614af4 100644 --- a/src/Dolumar/Map/LandRoute.php +++ b/src/Dolumar/Map/LandRoute.php @@ -74,6 +74,13 @@ public static function walk (array $from, array $to) return array ('distance' => 0, 'points' => array ($a, $b)); } + // Search one direction only, so a trip back takes exactly as long as the trip out. + if ($b[0] < $a[0] || ($b[0] == $a[0] && $b[1] < $a[1])) + { + $back = self::walk ($b, $a); + return array ('distance' => $back['distance'], 'points' => array_reverse ($back['points'])); + } + $key = self::cacheKey ($a, $b); if (isset (self::$memo[$key])) { @@ -165,6 +172,11 @@ private static function getCache () private static function cacheKey (array $a, array $b) { + // Loading Dolumar_Map_Map sets the seed global from RANDMAPFACTOR (tests change it). + if (defined ('RANDMAPFACTOR')) + { + class_exists ('Dolumar_Map_Map'); + } $seed = isset ($GLOBALS['MAP_CONFIG_RANDMAPFACTOR']) ? $GLOBALS['MAP_CONFIG_RANDMAPFACTOR'] : ''; return 'landroute_' . self::CACHE_VERSION . '_' . md5 ($seed) . '_' . $a[0] . '_' . $a[1] . '_' . $b[0] . '_' . $b[1]; diff --git a/tests/Map/LandRouteTest.php b/tests/Map/LandRouteTest.php index b08c5128..8c51e854 100644 --- a/tests/Map/LandRouteTest.php +++ b/tests/Map/LandRouteTest.php @@ -55,6 +55,15 @@ public function testGoesAroundALake(): void $this->assertLessThanOrEqual(6, count($r['points'])); } + public function testWayBackIsTheSameRoute(): void + { + $this->lake(fn ($x, $y) => $x >= 5 && $x <= 15 && $y >= -10 && $y <= 7); + $out = Dolumar_Map_LandRoute::walk([0, 0], [20, 3]); + $back = Dolumar_Map_LandRoute::walk([20, 3], [0, 0]); + $this->assertSame($out['distance'], $back['distance']); + $this->assertSame(array_reverse($out['points']), $back['points']); + } + public function testCrossesTwoLakes(): void { $water = fn ($x, $y) => ($x >= 5 && $x <= 8 && abs($y) <= 6) || ($x >= 20 && $x <= 25 && $y >= -3 && $y <= 9);