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 00000000..050bc57a Binary files /dev/null and b/public/static/images/sprites/armies/thief.png differ diff --git a/resources/language/de/map.lng b/resources/language/de/map.lng index d3da6326..afc6e53a 100644 --- a/resources/language/de/map.lng +++ b/resources/language/de/map.lng @@ -3,6 +3,7 @@ attack = "Angreifende Armee" waiting = "Armee vor den Toren" returning = "Zurückkehrende Armee" squad = "Regiment unterwegs" +thief = "Dieb unterwegs" neutral = "Eine Armee" open = "Öffnen" close = "Schließen" diff --git a/resources/language/en/map.lng b/resources/language/en/map.lng index 64484531..26b2b69b 100644 --- a/resources/language/en/map.lng +++ b/resources/language/en/map.lng @@ -3,6 +3,7 @@ attack = "Attacking army" waiting = "Army at the gates" returning = "Returning army" squad = "Regiment on the move" +thief = "Thief on the move" neutral = "An army" open = "Open" close = "Close" diff --git a/resources/language/fr/map.lng b/resources/language/fr/map.lng index 4b2351e5..afdc5e1a 100644 --- a/resources/language/fr/map.lng +++ b/resources/language/fr/map.lng @@ -3,6 +3,7 @@ attack = "Armée en marche" waiting = "Armée aux portes" returning = "Armée de retour" squad = "Régiment en déplacement" +thief = "Voleur en route" neutral = "Une armée" open = "Ouvrir" close = "Fermer" diff --git a/resources/templates/default/gameserver/index/header.phpt b/resources/templates/default/gameserver/index/header.phpt index e6ff8910..ce292823 100755 --- a/resources/templates/default/gameserver/index/header.phpt +++ b/resources/templates/default/gameserver/index/header.phpt @@ -4,7 +4,7 @@ // Kind texts for the engine map-objects layer (javascript/mapobjects.js reads CONFIG_MAPOBJECT_TEXT) $armyText = Neuron_Core_Text::__getInstance (); $armyTexts = array (); - foreach (array ('attack', 'waiting', 'returning', 'squad', 'neutral', 'open', 'close') as $armyKind) + foreach (array ('attack', 'waiting', 'returning', 'squad', 'thief', 'neutral', 'open', 'close') as $armyKind) { $armyTexts[$armyKind] = $armyText->get ($armyKind, 'armies', 'map'); } 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..8a614af4 --- /dev/null +++ b/src/Dolumar/Map/LandRoute.php @@ -0,0 +1,510 @@ + 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)); + } + + // 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])) + { + 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) + { + // 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]; + } + + /* + 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/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 6a8b2038..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 @@ -76,7 +82,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 +156,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 @@ -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 @@ -223,9 +267,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 +280,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 +345,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 +361,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/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 7d233f66..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. @@ -185,18 +216,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..8c51e854 --- /dev/null +++ b/tests/Map/LandRouteTest.php @@ -0,0 +1,156 @@ +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 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); + $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; + } +} 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();