diff --git a/.ai/superpowers/specs/2026-11-05-battle-target-picker.md b/.ai/superpowers/specs/2026-11-05-battle-target-picker.md new file mode 100644 index 0000000..e63ad1c --- /dev/null +++ b/.ai/superpowers/specs/2026-11-05-battle-target-picker.md @@ -0,0 +1,33 @@ +# Battle window: choosing a target + +Date: 2026-11-05. Options write-up and mockups: project files `battle-targets/01-target-picking-options.md`. +Thijs picked A + C, with Nearby and the honour cost premium only (with a call to upgrade), the 20 closest villages, +networth instead of runes, and distances in leagues. + +## Way back + +- Every step after Attack / Support starts with "‹ Choose another target · All battles" (`battle/backLinks.phpt`), + including the error pages (unreachable, own village, holiday, not found) and the "attack sent" message. +- The window keeps the picked `target` in its request data. `Dolumar_Windows_Battle::getContent()` now checks + `overview` (back to the list) and `action` without `target` (choose again) before that, and resets the request data. + +## Picker (`Dolumar_Battle_TargetList`, `battle/chooseTarget.phpt`) + +Replaces `Neuron_Structure_ChooseTarget` in the Battle window only (Thieves and Market still use the engine one). + +| Tab | Content | Free | +|---|---|---| +| Suggested | Attacks only, shown when there is something: strike back (attacked you in the last 7 days), avenge a clan mate (latest attack on a clan member by an outsider); premium adds the closest tribe and the closest player that costs no honour and you did not attack this week. Never suggests a village that costs honour or cannot be attacked. Max 4. | Yes (premium adds two kinds) | +| Nearby | The 20 closest active villages by straight line between town centres, filter All / Players / Tribes. Support: your villages and your clan first. | No: upgrade box | +| Recent | They attacked you (7 days), you attacked (with result), profiles you looked at (`villages_visits`, the old "latest targets") | Yes | +| Search | Name search, 10 results, exact match first | Yes | + +Each row: name, owner (or "Tribe"), race, networth, distance in leagues (`Dolumar_Map_Map::tile2league`), and for +premium attacks the honour cost (`Dolumar_Battle_Battle::getHonourPenalty`). Clan members and players on holiday stay +in the list, greyed, with the reason. Free players land on Recent rather than on the locked tab; other tabs carry a +one-line "premium shows the honour cost" link. + +Premium here means `isPremium()` (which includes the free first week). When credits are not configured on a server +(`Dolumar_Premium_Prices::creditsEnabled()` false), nobody could buy premium, so the picker is open to everyone. + +The path distance and travel time stay on the next step: the path search is too heavy for 20 rows. diff --git a/public/dolumar/css/battlereport.css b/public/dolumar/css/battlereport.css index fd4b6f9..92019f1 100644 --- a/public/dolumar/css/battlereport.css +++ b/public/dolumar/css/battlereport.css @@ -110,8 +110,24 @@ div.battle .battle-report .br-tok img, .battle-report .br-dot { display: inline-block; width: 10px; height: 10px; border: 2px solid #f3e7c4; background: #40331f; vertical-align: -2px; } .battle-report .br-dot.us { border-color: #000097; } +/* Choose a target (battle/chooseTarget.phpt, battle/backLinks.phpt) */ +.battle-report .bt-target img { width: 40px; height: 40px; } +.battle-report .bt-rt { flex: 1; min-width: 0; } +.battle-report .bt-rt .ui-card-title { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.battle-report .bt-line { display: block; margin-top: 2px; } +.battle-report .bt-note { font-style: italic; } +.battle-report .bt-target.off { opacity: 0.6; } +.battle-report .bt-target.off .ui-card-title { color: #40331f; text-decoration: none; } +.battle-report .bt-intro { margin-top: 0; } +.battle-report .bt-search .ui-field input { width: 100%; } +.battle-report .bt-premium p { margin: 0; } +.battle-report .bt-premium p.bt-premium-go { margin-top: 8px; } +.battle-report .bt-honour-hint { margin: 12px 0 0; } +.battle-report .ui-tab-panel h3:first-child { margin-top: 0; } + /* Mobile: the kit handles sizes; the transport keeps its buttons on one line. */ body.layout-mobile .sheet-content .battle-report .br-transport .ui-btn { padding: 0 12px; } body.layout-mobile .sheet-content .battle-report .br-back a { text-decoration: none; } +body.layout-mobile .sheet-content .battle-report .bt-search .ui-field input { font-size: 16px; } body.layout-mobile .sheet-content .battle-report h2, body.layout-mobile .sheet-content .battle-report h3 { font-family: Verdana, sans-serif; } diff --git a/resources/language/de/battle.lng b/resources/language/de/battle.lng index 86bfda6..04cc08d 100755 --- a/resources/language/de/battle.lng +++ b/resources/language/de/battle.lng @@ -210,3 +210,54 @@ killed = "@@0 getötet" loot = "@@0 Rohst." runes = "@@0 Runen" rune = "1 Rune" + +[targets] +title_attack = "Angriff: wähle ein Ziel" +title_support = "Unterstützung: wähle eine Siedlung" +tab_suggested = "Vorschläge" +tab_nearby = "In der Nähe" +tab_recent = "Zuletzt" +tab_search = "Suche" +suggestedIntro = "Siedlungen, die einen Blick wert sind. Du entscheidest." +filter_all = "Alle" +filter_players = "Spieler" +filter_tribes = "Stämme" +closest = "die @@amount nächsten" +village = "Name der Siedlung" +search = "Suchen" +tribe = "Stamm" +networth = "Punktzahl @@networth" +leagues = "@@distance League" +honourLoss = "du verlierst @@honour% Ehre" +noHonourLoss = "kein Ehrverlust" +status_own = "Deine eigene Siedlung." +status_clan = "Clanmitglied: du kannst diese Siedlung nicht angreifen." +status_holiday = "Im Urlaub: diese Siedlung ist nicht erreichbar." +section_ownVillages = "Deine Siedlungen" +section_clanVillages = "Dein Clan" +section_otherVillages = "Andere Siedlungen in der Nähe" +section_attackedYou = "Sie haben dich angegriffen" +section_fought = "Du hast angegriffen" +section_visited = "Profile, die du angesehen hast" +empty_suggested = "Im Moment gibt es nichts vorzuschlagen." +empty_nearby = "Keine Siedlungen in der Nähe gefunden." +empty_search = "Ich habe keine Siedlung mit diesem Namen gefunden." +empty_ownVillages = "Du hast keine anderen Siedlungen." +empty_clanVillages = "Du bist in keinem Clan, oder dein Clan hat keine anderen Siedlungen." +empty_otherVillages = "Keine anderen Siedlungen in der Nähe gefunden." +empty_attackedYou = "Diese Woche hat dich niemand angegriffen." +empty_fought = "Du hast noch niemanden angegriffen." +empty_visited = "Du hast noch keine Siedlungsprofile angesehen." +attackedYou = "hat dich am @@when angegriffen" +youWon = "gewonnen, @@when" +youLost = "zurückgeschlagen, @@when" +whyStrikeBack = "Schlag zurück: sie haben dich am @@when angegriffen" +whyClan = "Sie haben dein Clanmitglied @@village angegriffen (@@when)" +whyTribe = "Nächster Stamm: Überfälle kosten keine Ehre" +whyPlayer = "In der Nähe, kein Ehrverlust, diese Woche nicht von dir angegriffen" +premiumNearby = "Mit Premium siehst du die 20 Siedlungen, die dir am nächsten liegen, mit ihrer Entfernung und dem Ehrverlust eines Angriffs." +premiumHonour = "Mit Premium siehst du, wie viel Ehre jeder Angriff kostet." +premiumBenefit = "Die 20 nächsten Siedlungen und die Ehrkosten jedes Angriffs im Kampffenster." +upgrade = "Premium holen" +upgradeShort = "Premium holen" +otherTarget = "Anderes Ziel wählen" diff --git a/resources/language/en/battle.lng b/resources/language/en/battle.lng index ef11d82..e63e788 100755 --- a/resources/language/en/battle.lng +++ b/resources/language/en/battle.lng @@ -215,3 +215,54 @@ killed = "killed @@0" loot = "@@0 res" runes = "@@0 runes" rune = "1 rune" + +[targets] +title_attack = "Attack: choose a target" +title_support = "Support: choose a village" +tab_suggested = "Suggested" +tab_nearby = "Nearby" +tab_recent = "Recent" +tab_search = "Search" +suggestedIntro = "Villages worth a look. You decide." +filter_all = "All" +filter_players = "Players" +filter_tribes = "Tribes" +closest = "@@amount closest" +village = "Village name" +search = "Search" +tribe = "Tribe" +networth = "networth @@networth" +leagues = "@@distance leagues" +honourLoss = "you lose @@honour% honour" +noHonourLoss = "no honour loss" +status_own = "Your own village." +status_clan = "Clan member: you cannot attack this village." +status_holiday = "On holiday: this village cannot be reached." +section_ownVillages = "Your villages" +section_clanVillages = "Your clan" +section_otherVillages = "Other villages nearby" +section_attackedYou = "They attacked you" +section_fought = "You attacked" +section_visited = "Profiles you looked at" +empty_suggested = "Nothing to suggest right now." +empty_nearby = "No villages found nearby." +empty_search = "I couldn't find any village with that name." +empty_ownVillages = "You have no other villages." +empty_clanVillages = "You are not in a clan, or your clan has no other villages." +empty_otherVillages = "No other villages found nearby." +empty_attackedYou = "Nobody attacked you this week." +empty_fought = "You have not attacked anyone yet." +empty_visited = "You have not looked at any village profiles yet." +attackedYou = "attacked you @@when" +youWon = "you won, @@when" +youLost = "you were repelled, @@when" +whyStrikeBack = "Strike back: they attacked you @@when" +whyClan = "They attacked your clan mate @@village (@@when)" +whyTribe = "Closest tribe: raids cost no honour" +whyPlayer = "Close by, no honour loss, not attacked by you this week" +premiumNearby = "Premium shows the 20 villages closest to you, with their distance and what an attack would cost you in honour." +premiumHonour = "Premium shows what each attack costs in honour." +premiumBenefit = "The 20 villages closest to you, and the honour cost of every attack, in the Battle window." +upgrade = "Get premium" +upgradeShort = "Get premium" +otherTarget = "Choose another target" diff --git a/resources/language/fr/battle.lng b/resources/language/fr/battle.lng index b18fa8a..6a23b5c 100644 --- a/resources/language/fr/battle.lng +++ b/resources/language/fr/battle.lng @@ -213,3 +213,54 @@ killed = "@@0 tués" loot = "@@0 ress." runes = "@@0 runes" rune = "1 rune" + +[targets] +title_attack = "Attaquer : choisissez une cible" +title_support = "Soutenir : choisissez un village" +tab_suggested = "Suggestions" +tab_nearby = "À proximité" +tab_recent = "Récents" +tab_search = "Recherche" +suggestedIntro = "Des villages qui valent le coup d'œil. À vous de décider." +filter_all = "Tous" +filter_players = "Joueurs" +filter_tribes = "Tribus" +closest = "les @@amount plus proches" +village = "Nom du village" +search = "Rechercher" +tribe = "Tribu" +networth = "points @@networth" +leagues = "@@distance lieues" +honourLoss = "vous perdez @@honour% d'honneur" +noHonourLoss = "aucune perte d'honneur" +status_own = "Votre propre village." +status_clan = "Membre du clan : vous ne pouvez pas attaquer ce village." +status_holiday = "En vacances : ce village est inaccessible." +section_ownVillages = "Vos villages" +section_clanVillages = "Votre clan" +section_otherVillages = "Autres villages à proximité" +section_attackedYou = "Ils vous ont attaqué" +section_fought = "Vous avez attaqué" +section_visited = "Profils consultés" +empty_suggested = "Rien à suggérer pour le moment." +empty_nearby = "Aucun village trouvé à proximité." +empty_search = "Je n'ai trouvé aucun village portant ce nom." +empty_ownVillages = "Vous n'avez pas d'autres villages." +empty_clanVillages = "Vous n'êtes pas dans un clan, ou votre clan n'a pas d'autres villages." +empty_otherVillages = "Aucun autre village trouvé à proximité." +empty_attackedYou = "Personne ne vous a attaqué cette semaine." +empty_fought = "Vous n'avez encore attaqué personne." +empty_visited = "Vous n'avez encore consulté aucun profil de village." +attackedYou = "vous a attaqué le @@when" +youWon = "victoire, @@when" +youLost = "repoussé, @@when" +whyStrikeBack = "Ripostez : ils vous ont attaqué le @@when" +whyClan = "Ils ont attaqué @@village, membre de votre clan (@@when)" +whyTribe = "Tribu la plus proche : les raids ne coûtent pas d'honneur" +whyPlayer = "Proche, sans perte d'honneur, pas attaqué par vous cette semaine" +premiumNearby = "Avec le premium, vous voyez les 20 villages les plus proches, avec leur distance et l'honneur qu'une attaque vous coûterait." +premiumHonour = "Avec le premium, vous voyez l'honneur que coûte chaque attaque." +premiumBenefit = "Les 20 villages les plus proches et le coût en honneur de chaque attaque, dans la fenêtre de bataille." +upgrade = "Passer au premium" +upgradeShort = "Passer au premium" +otherTarget = "Choisir une autre cible" diff --git a/resources/language/nl/battle.lng b/resources/language/nl/battle.lng index 13556dd..10a96a3 100755 --- a/resources/language/nl/battle.lng +++ b/resources/language/nl/battle.lng @@ -212,3 +212,54 @@ killed = "@@0 gedood" loot = "@@0 grondst." runes = "@@0 runen" rune = "1 rune" + +[targets] +title_attack = "Aanvallen: kies een doelwit" +title_support = "Steunen: kies een dorp" +tab_suggested = "Voorstellen" +tab_nearby = "In de buurt" +tab_recent = "Recent" +tab_search = "Zoeken" +suggestedIntro = "Dorpen die het bekijken waard zijn. Jij beslist." +filter_all = "Alle" +filter_players = "Spelers" +filter_tribes = "Stammen" +closest = "@@amount dichtstbij" +village = "Naam van het dorp" +search = "Zoeken" +tribe = "Stam" +networth = "score @@networth" +leagues = "@@distance league" +honourLoss = "je verliest @@honour% eer" +noHonourLoss = "geen eerverlies" +status_own = "Je eigen dorp." +status_clan = "Clanlid: je kan dit dorp niet aanvallen." +status_holiday = "Op vakantie: dit dorp is niet bereikbaar." +section_ownVillages = "Je dorpen" +section_clanVillages = "Je clan" +section_otherVillages = "Andere dorpen in de buurt" +section_attackedYou = "Zij vielen je aan" +section_fought = "Jij viel aan" +section_visited = "Profielen die je bekeek" +empty_suggested = "Op dit moment niets voor te stellen." +empty_nearby = "Geen dorpen in de buurt gevonden." +empty_search = "Ik vond geen dorp met die naam." +empty_ownVillages = "Je hebt geen andere dorpen." +empty_clanVillages = "Je zit niet in een clan, of je clan heeft geen andere dorpen." +empty_otherVillages = "Geen andere dorpen in de buurt gevonden." +empty_attackedYou = "Niemand viel je deze week aan." +empty_fought = "Je hebt nog niemand aangevallen." +empty_visited = "Je hebt nog geen dorpsprofielen bekeken." +attackedYou = "viel je aan op @@when" +youWon = "gewonnen, @@when" +youLost = "afgeslagen, @@when" +whyStrikeBack = "Sla terug: zij vielen je aan op @@when" +whyClan = "Zij vielen je clanlid @@village aan (@@when)" +whyTribe = "Dichtste stam: plunderen kost geen eer" +whyPlayer = "Dichtbij, geen eerverlies, deze week niet door jou aangevallen" +premiumNearby = "Met premium zie je de 20 dorpen die het dichtst bij je liggen, met hun afstand en wat een aanval je aan eer kost." +premiumHonour = "Met premium zie je wat elke aanval aan eer kost." +premiumBenefit = "De 20 dorpen het dichtst bij je en de eerkost van elke aanval, in het gevechtsvenster." +upgrade = "Neem premium" +upgradeShort = "Neem premium" +otherTarget = "Kies een ander doelwit" diff --git a/resources/language/pl/battle.lng b/resources/language/pl/battle.lng index be4cba5..8d2225a 100755 --- a/resources/language/pl/battle.lng +++ b/resources/language/pl/battle.lng @@ -211,3 +211,54 @@ killed = "zabici: @@0" loot = "surowce: @@0" runes = "runy: @@0" rune = "1 runa" + +[targets] +title_attack = "Atak: wybierz cel" +title_support = "Wsparcie: wybierz wioskę" +tab_suggested = "Propozycje" +tab_nearby = "W pobliżu" +tab_recent = "Ostatnie" +tab_search = "Szukaj" +suggestedIntro = "Wioski warte uwagi. Ty decydujesz." +filter_all = "Wszystkie" +filter_players = "Gracze" +filter_tribes = "Plemiona" +closest = "@@amount najbliższych" +village = "Nazwa wioski" +search = "Szukaj" +tribe = "Plemię" +networth = "punkty @@networth" +leagues = "@@distance mil" +honourLoss = "tracisz @@honour% honoru" +noHonourLoss = "bez utraty honoru" +status_own = "Twoja własna wioska." +status_clan = "Członek klanu: nie możesz zaatakować tej wioski." +status_holiday = "Na urlopie: tej wioski nie można osiągnąć." +section_ownVillages = "Twoje wioski" +section_clanVillages = "Twój klan" +section_otherVillages = "Inne wioski w pobliżu" +section_attackedYou = "Zaatakowali cię" +section_fought = "Zaatakowałeś" +section_visited = "Oglądane profile" +empty_suggested = "W tej chwili nie ma nic do zaproponowania." +empty_nearby = "Nie znaleziono wiosek w pobliżu." +empty_search = "Nie znalazłem wioski o tej nazwie." +empty_ownVillages = "Nie masz innych wiosek." +empty_clanVillages = "Nie jesteś w klanie albo twój klan nie ma innych wiosek." +empty_otherVillages = "Nie znaleziono innych wiosek w pobliżu." +empty_attackedYou = "Nikt cię nie zaatakował w tym tygodniu." +empty_fought = "Jeszcze nikogo nie zaatakowałeś." +empty_visited = "Nie oglądałeś jeszcze żadnych profili wiosek." +attackedYou = "zaatakował cię @@when" +youWon = "zwycięstwo, @@when" +youLost = "odparty, @@when" +whyStrikeBack = "Odwet: zaatakowali cię @@when" +whyClan = "Zaatakowali członka twojego klanu, @@village (@@when)" +whyTribe = "Najbliższe plemię: najazdy nie kosztują honoru" +whyPlayer = "Blisko, bez utraty honoru, nie atakowany przez ciebie w tym tygodniu" +premiumNearby = "Z kontem premium widzisz 20 najbliższych wiosek, ich odległość i ile honoru kosztowałby atak." +premiumHonour = "Z kontem premium widzisz, ile honoru kosztuje każdy atak." +premiumBenefit = "20 najbliższych wiosek i koszt honoru każdego ataku w oknie bitwy." +upgrade = "Kup premium" +upgradeShort = "Kup premium" +otherTarget = "Wybierz inny cel" diff --git a/resources/templates/default/account/benefits.phpt b/resources/templates/default/account/benefits.phpt index 2afefc8..da07db1 100755 --- a/resources/templates/default/account/benefits.phpt +++ b/resources/templates/default/account/benefits.phpt @@ -7,4 +7,5 @@
  • getText ('tqueue'); ?>
  • getText ('uinfo'); ?>
  • getText ('searchtool'); ?>
  • +
  • getText ('premiumBenefit', 'targets', 'battle'); ?>
  • diff --git a/resources/templates/default/battle/backLinks.phpt b/resources/templates/default/battle/backLinks.phpt new file mode 100644 index 0000000..ee9f93c --- /dev/null +++ b/resources/templates/default/battle/backLinks.phpt @@ -0,0 +1,9 @@ + $action, 'command' => $action)), ENT_QUOTES); +?> +

    + ‹ getText ('otherTarget')?> + · + getText ('allBattles', 'report2')?> +

    diff --git a/resources/templates/default/battle/chooseTarget.phpt b/resources/templates/default/battle/chooseTarget.phpt new file mode 100644 index 0000000..83392cb --- /dev/null +++ b/resources/templates/default/battle/chooseTarget.phpt @@ -0,0 +1,125 @@ + $mode, 'command' => $mode), $params); + return 'windowAction(this, ' . htmlspecialchars (json_encode ($params), ENT_QUOTES) . ');'; + }; + + $card = function ($r) use ($go, $mode, $premium) { + $lines = array (); + + // Who and how big. + $who = $r['owner']; + if ($r['race'] !== '') { + $who .= ' · ' . $r['race']; + } + $who .= ' · ' . $this->putIntoText ($this->getText ('networth'), array ('networth' => $r['networth'])); + $lines[] = '' . $who . ''; + + // How far, and what it costs. + $far = array (); + if ($r['leagues'] !== null) { + $far[] = $this->putIntoText ($this->getText ('leagues'), array ('distance' => $r['leagues'])); + } + if ($r['honour'] !== null) { + $far[] = $r['honour'] > 0 + ? '' . $this->putIntoText ($this->getText ('honourLoss'), array ('honour' => $r['honour'])) . '' + : '' . $this->getText ('noHonourLoss') . ''; + } + if (count ($far) > 0) { + $lines[] = '' . implode (' · ', $far) . ''; + } + + if ($r['note']) { + $lines[] = '' . $r['note'] . ''; + } + + if (!$r['canPick']) { + $lines[] = '' . $this->getText ('status_' . $r['status']) . ''; + } + + $img = ''; + $body = '' . $r['name'] . '' . implode ('', $lines) . ''; + + if ($r['canPick']) { + return '' + . $img . $body . '›'; + } + return '
    ' . $img . $body . '
    '; + }; + + $upgrade = function ($key) { + return '

    ' . $this->getText ($key) . '

    ' + . '

    ' + . $this->getText ('upgrade') . '

    '; + }; +?> +
    + +

    ‹ getText ('allBattles', 'report2')?>

    + +

    getText ('title_' . $mode)?>

    + +
    + + class="active" onclick=" $t))?>">getText ('tab_' . $t)?> + +
    + +
    + + +

    getText ('suggestedIntro')?>

    + + + + + + + + + +

    getText ('section_' . $s['title'])?>

    + + + +

    + + · + + getText ('filter_' . $f)?> + + getText ('filter_' . $f)?> + + + putIntoText ($this->getText ('closest'), array ('amount' => $nearbyCount))?> +

    + + + + + 0) { ?> + + +

    getText ('empty_' . ($s['title'] ? $s['title'] : $tab))?>

    + + + + + +

    getText ('premiumHonour')?> getText ('upgradeShort')?>

    + + +
    +
    diff --git a/resources/templates/default/battle/chooseUnits.tpl b/resources/templates/default/battle/chooseUnits.tpl old mode 100755 new mode 100644 index 63fd6b7..54d25a8 --- a/resources/templates/default/battle/chooseUnits.tpl +++ b/resources/templates/default/battle/chooseUnits.tpl @@ -1,3 +1,4 @@ +

    diff --git a/resources/templates/default/battle/sendSupport.phpt b/resources/templates/default/battle/sendSupport.phpt index 6fda031..63c1810 100755 --- a/resources/templates/default/battle/sendSupport.phpt +++ b/resources/templates/default/battle/sendSupport.phpt @@ -1,4 +1,5 @@ setTextSection ('support', 'battle');?> +

    getText ('title'), array ('target' => $target))?>

    diff --git a/resources/templates/default/battle/specialUnits.phpt b/resources/templates/default/battle/specialUnits.phpt old mode 100755 new mode 100644 index 43d3933..d6a0770 --- a/resources/templates/default/battle/specialUnits.phpt +++ b/resources/templates/default/battle/specialUnits.phpt @@ -1,3 +1,4 @@ +

    getText ('title')?>

    diff --git a/src/Dolumar/Battle/TargetList.php b/src/Dolumar/Battle/TargetList.php new file mode 100644 index 0000000..a41d4c4 --- /dev/null +++ b/src/Dolumar/Battle/TargetList.php @@ -0,0 +1,644 @@ +village = $village; + $this->mode = $mode === 'support' ? 'support' : 'attack'; + $this->premium = (bool) $premium; + $this->now = $now === null ? NOW : $now; + } + + /** + * Premium unlocks the nearby list and the honour cost. When premium cannot be bought on this server, nobody could + * unlock it, so it is open to everyone. + * @param Dolumar_Players_Player $player + * @return bool + */ + public static function canSeeNeighbourhood ($player) + { + if (!Dolumar_Premium_Prices::creditsEnabled ()) + { + return true; + } + return $player && $player->isPremium (); + } + + public function isPremium () + { + return $this->premium; + } + + public function getMode () + { + return $this->mode; + } + + /** + * The closest active villages (straight line between town centres), closest first. + * @param string $filter all, players or tribes + * @param int $limit + * @return array[] rows, empty without premium + */ + public function getNearby ($filter = 'all', $limit = self::NEARBY) + { + if (!$this->premium) + { + return array (); + } + + $exclude = array ($this->village->getId ()); + if ($this->mode === 'support') + { + // Own and clan villages have their own sections in support mode. + foreach ($this->getOwnVillageIds () as $id) + { + $exclude[] = $id; + } + } + + $where = ''; + if ($filter === 'players') + { + $where = ' AND v.plid > 0'; + } + elseif ($filter === 'tribes') + { + $where = ' AND v.plid = 0'; + } + + return $this->queryByDistance ($where . ' AND v.vid NOT IN (' . implode (', ', array_map ('intval', $exclude)) . ')', $limit); + } + + /** + * Support mode: my other villages, closest first. + * @return array[] + */ + public function getOwnVillages () + { + $ids = array_diff ($this->getOwnVillageIds (), array ($this->village->getId ())); + if (count ($ids) === 0) + { + return array (); + } + return $this->queryByDistance (' AND v.vid IN (' . implode (', ', array_map ('intval', $ids)) . ')', 50); + } + + /** + * Support mode: villages of my clan mates, closest first. + * @return array[] + */ + public function getClanVillages () + { + $players = $this->getClanPlayers (); + if (count ($players) === 0) + { + return array (); + } + return $this->queryByDistance (' AND v.plid IN (' . implode (', ', array_map ('intval', $players)) . ')', 50); + } + + /** + * Villages that attacked me in the last days, newest first, one row per village. + * @return array[] + */ + public function getAttackers () + { + $rows = $this->queryReports ('targetId', 'fromId', $this->village->getId (), $this->now - self::RECENT_DAYS * 86400); + + $out = array (); + foreach ($rows as $r) + { + $row = $this->getRow (Dolumar_Players_Village::getVillage ($r['vid'])); + if ($row) + { + $row['note'] = $this->text ('attackedYou', array ('when' => $this->formatWhen ($r['fightDate']))); + $row['lastAttack'] = (int) $r['fightDate']; + $out[] = $row; + } + } + return $out; + } + + /** + * Villages I attacked, newest first, one row per village. + * @return array[] + */ + public function getFought () + { + $rows = $this->queryReports ('fromId', 'targetId', $this->village->getId (), 0); + + $out = array (); + foreach ($rows as $r) + { + $row = $this->getRow (Dolumar_Players_Village::getVillage ($r['vid'])); + if ($row) + { + $key = $r['victory'] >= 0.01 ? 'youWon' : 'youLost'; + $row['note'] = $this->text ($key, array ('when' => $this->formatWhen ($r['fightDate']))); + $out[] = $row; + } + } + return $out; + } + + /** + * Village profiles opened from this village (what the old picker called "your latest targets"). + * @return array[] + */ + public function getVisited () + { + $out = array (); + $seen = array (); + foreach ($this->village->visits->getLastVisits (self::RECENT * 2) as $v) + { + if (isset ($seen[$v->getId ()])) + { + continue; + } + $seen[$v->getId ()] = true; + + $row = $this->getRow ($v); + if ($row) + { + $out[] = $row; + } + + if (count ($out) >= self::RECENT) + { + break; + } + } + return $out; + } + + /** + * Name search, exact match first. + * @param string $query + * @return array[] + */ + public function search ($query) + { + $query = trim ((string) $query); + if ($query === '') + { + return array (); + } + + $db = Neuron_DB_Database::getInstance (); + $q = $db->escape ($query); + $like = $db->escape (addcslashes ($query, '%_\\')); + + $rows = $db->query (" + SELECT vid + FROM villages + WHERE vname LIKE '%" . $like . "%' AND isActive = '1' AND vid != " . intval ($this->village->getId ()) . " + ORDER BY vname = '" . $q . "' DESC, vname ASC + LIMIT " . self::SEARCH + ); + + $out = array (); + foreach ($rows as $r) + { + $row = $this->getRow (Dolumar_Players_Village::getVillage ($r['vid'])); + if ($row) + { + $out[] = $row; + } + } + return $out; + } + + /** + * A few villages worth attacking, each with its reason. Free: villages that attacked me or my clan. Premium adds the + * closest tribe and the closest player whose attack costs no honour. Never suggests a village that costs honour or + * cannot be attacked. + * @return array[] + */ + public function getSuggestions () + { + if ($this->mode !== 'attack') + { + return array (); + } + + $out = array (); + $add = function ($row, $reason) use (&$out) { + if ($row && !isset ($out[$row['id']]) && $row['status'] === self::STATUS_OK && !$row['costsHonour'] + && count ($out) < self::SUGGESTIONS) + { + $row['note'] = $reason; + $out[$row['id']] = $row; + } + }; + + // Strike back. + foreach (array_slice ($this->getAttackers (), 0, 2) as $row) + { + $add ($row, $this->text ('whyStrikeBack', array ('when' => $this->formatWhen ($row['lastAttack'])))); + } + + // Avenge a clan mate. + $clanAttack = $this->getClanAttacker (); + if ($clanAttack) + { + $add ($clanAttack['row'], $this->text ('whyClan', array ( + 'village' => $clanAttack['victim'], + 'when' => $this->formatWhen ($clanAttack['fightDate']) + ))); + } + + if ($this->premium) + { + $nearby = $this->getNearby ('all', self::NEARBY); + + foreach ($nearby as $row) + { + if ($row['npc']) + { + $add ($row, $this->text ('whyTribe')); + break; + } + } + + $recent = array (); + foreach ($this->queryReports ('fromId', 'targetId', $this->village->getId (), $this->now - self::RECENT_DAYS * 86400) as $r) + { + $recent[$r['vid']] = true; + } + + foreach ($nearby as $row) + { + if (!$row['npc'] && !isset ($recent[$row['id']]) && $row['honour'] === 0) + { + $add ($row, $this->text ('whyPlayer')); + break; + } + } + } + + return array_values ($out); + } + + /** + * One row for the picker, or null when the village is gone. + * @param Dolumar_Players_Village $target + * @param float|null $distance tiles, computed when null + * @return array|null + */ + public function getRow ($target, $distance = null) + { + if (!$target || !$target->isFound () || !$target->isActive ()) + { + return null; + } + + $owner = $target->getOwner (); + $npc = $target->isNpc (); + $me = $this->village->getOwner (); + + if ($distance === null) + { + $distance = $this->getDistance ($target); + } + + if (!$npc && $owner->equals ($me)) + { + $status = self::STATUS_OWN; + } + elseif (!$npc && in_array ($owner->getId (), $this->getClanPlayers ())) + { + $status = self::STATUS_CLAN; + } + elseif (!$npc && $owner->inVacationMode ()) + { + $status = self::STATUS_HOLIDAY; + } + else + { + $status = self::STATUS_OK; + } + + // Honour cost of an attack that is possible. Only shown with premium, but suggestions always avoid it. + $cost = 0; + if ($this->mode === 'attack' && $status === self::STATUS_OK) + { + $cost = (int) Dolumar_Battle_Battle::getHonourPenalty ($this->village, $target); + } + $honour = $this->premium && $this->mode === 'attack' && $status === self::STATUS_OK ? $cost : null; + + $race = $target->getRace (); + + return array + ( + 'id' => (int) $target->getId (), + 'name' => Neuron_Core_Tools::output_varchar ($target->getName ()), + 'owner' => $npc ? Neuron_Core_Text::getInstance ()->get ('tribe', 'targets', 'battle') : Neuron_Core_Tools::output_varchar ($owner->getNickname ()), + 'race' => $race ? Neuron_Core_Tools::output_varchar ($race->getRaceName ()) : '', + 'npc' => $npc, + 'networth' => (int) $target->getNetworth (), + 'distance' => $distance, + 'leagues' => $distance === null ? null : $this->formatLeagues ($distance), + 'status' => $status, + 'canPick' => $this->mode === 'support' ? $status !== self::STATUS_HOLIDAY : $status === self::STATUS_OK, + 'honour' => $honour, + 'costsHonour' => $cost > 0, + 'image' => $this->getImage ($target, $npc), + 'note' => null, + 'lastAttack' => null + ); + } + + /** + * Straight-line distance in tiles between the two town centres. + * @param Dolumar_Players_Village $target + * @return float|null + */ + public function getDistance ($target) + { + $to = $target->buildings->getTownCenter (); + if (!$to) + { + return null; + } + list ($x, $y) = $this->getOrigin (); + list ($tx, $ty) = $to->getLocation (); + return sqrt (pow ($tx - $x, 2) + pow ($ty - $y, 2)); + } + + /** + * @param float $tiles + * @return string "1.4" + */ + public function formatLeagues ($tiles) + { + $l = Dolumar_Map_Map::tile2league ($tiles); + return number_format ($l, $l < 10 ? 1 : 0, '.', ''); + } + + private function getOrigin () + { + if ($this->origin === null) + { + $this->origin = $this->village->buildings->getTownCenterLocation (); + } + return $this->origin; + } + + private function getImage ($target, $npc) + { + if ($npc) + { + return 'sprites/goblins_towncenter.png'; + } + $race = $target->getRace (); + if ($race && strtolower ($race->getName ()) === 'darkelves') + { + return 'buildings/darkelves_towncenter.png'; + } + return 'buildings/towncenter.png'; + } + + /** + * Active villages joined with their town centre, closest first. + * @param string $where extra SQL conditions on v (villages) starting with AND + * @param int $limit + * @return array[] + */ + private function queryByDistance ($where, $limit) + { + list ($x, $y) = $this->getOrigin (); + $db = Neuron_DB_Database::getInstance (); + + $rows = $db->query (" + SELECT + v.vid, + SQRT(POW(b.xas - " . (float) $x . ", 2) + POW(b.yas - " . (float) $y . ", 2)) AS distance + FROM + map_buildings b + INNER JOIN + villages v ON v.vid = b.village + WHERE + b.buildingType = 1 + AND b.destroyDate = 0 + AND v.isActive = '1' + " . $where . " + ORDER BY + distance ASC + LIMIT " . intval ($limit) + ); + + $out = array (); + foreach ($rows as $r) + { + $row = $this->getRow (Dolumar_Players_Village::getVillage ($r['vid']), (float) $r['distance']); + if ($row) + { + $out[] = $row; + } + } + return $out; + } + + /** + * Latest fought report per opponent. + * @param string $mine column holding my village id + * @param string $theirs column holding the opponent + * @param int $vid + * @param int $since + * @return array[] vid, fightDate, victory + */ + private function queryReports ($mine, $theirs, $vid, $since) + { + $db = Neuron_DB_Database::getInstance (); + + $rows = $db->query (" + SELECT r.{$theirs} AS vid, r.fightDate, r.victory + FROM battle_report r + INNER JOIN ( + SELECT {$theirs} AS opp, MAX(fightDate) AS last + FROM battle_report + WHERE {$mine} = " . intval ($vid) . " AND fightDate <= " . intval ($this->now) . " AND fightDate >= " . intval ($since) . " + GROUP BY {$theirs} + ) l ON l.opp = r.{$theirs} AND l.last = r.fightDate + WHERE r.{$mine} = " . intval ($vid) . " + ORDER BY r.fightDate DESC + LIMIT " . self::RECENT + ); + + $out = array (); + $seen = array (); + foreach ($rows as $r) + { + if (!isset ($seen[$r['vid']])) + { + $seen[$r['vid']] = true; + $out[] = $r; + } + } + return $out; + } + + /** + * The latest attack on a clan mate (not on me) by someone outside the clan. + * @return array|null row, victim name, fightDate + */ + private function getClanAttacker () + { + $players = $this->getClanPlayers (); + if (count ($players) === 0) + { + return null; + } + + $db = Neuron_DB_Database::getInstance (); + $rows = $db->query (" + SELECT r.fromId, r.targetId, r.fightDate + FROM battle_report r + INNER JOIN villages t ON t.vid = r.targetId + INNER JOIN villages a ON a.vid = r.fromId + WHERE t.plid IN (" . implode (', ', array_map ('intval', $players)) . ") + AND a.plid NOT IN (" . implode (', ', array_map ('intval', array_merge ($players, array ($this->village->getOwner ()->getId ())))) . ") + AND r.fightDate <= " . intval ($this->now) . " + AND r.fightDate >= " . intval ($this->now - self::RECENT_DAYS * 86400) . " + ORDER BY r.fightDate DESC + LIMIT 1 + "); + + if (count ($rows) === 0) + { + return null; + } + + $row = $this->getRow (Dolumar_Players_Village::getVillage ($rows[0]['fromId'])); + $victim = Dolumar_Players_Village::getVillage ($rows[0]['targetId']); + if (!$row || !$victim->isFound ()) + { + return null; + } + + return array + ( + 'row' => $row, + 'victim' => Neuron_Core_Tools::output_varchar ($victim->getName ()), + 'fightDate' => (int) $rows[0]['fightDate'] + ); + } + + /** + * @return int[] + */ + private function getClanPlayers () + { + if ($this->clanPlayers === null) + { + $this->clanPlayers = array (); + $me = (int) $this->village->getOwner ()->getId (); + + $db = Neuron_DB_Database::getInstance (); + $rows = $db->query (" + SELECT DISTINCT m.plid + FROM clan_members m + INNER JOIN clan_members mine ON mine.c_id = m.c_id AND mine.plid = " . $me . " AND mine.cm_active = 1 + WHERE m.cm_active = 1 AND m.plid != " . $me + ); + + foreach ($rows as $r) + { + $this->clanPlayers[] = (int) $r['plid']; + } + } + return $this->clanPlayers; + } + + /** + * @return int[] + */ + private function getOwnVillageIds () + { + $out = array (); + foreach ($this->village->getOwner ()->getVillages () as $v) + { + $out[] = (int) $v->getId (); + } + return $out; + } + + private function formatWhen ($date) + { + $date = (int) $date; + return date ('Y-m-d', $date) === date ('Y-m-d', $this->now) ? date ('H:i', $date) : date ('d.m', $date); + } + + private function text ($key, $params = array ()) + { + $text = Neuron_Core_Text::getInstance (); + return Neuron_Core_Tools::putIntoText ($text->get ($key, 'targets', 'battle'), $params); + } +} diff --git a/src/Dolumar/Windows/Battle.php b/src/Dolumar/Windows/Battle.php index 03eb394..a896a93 100755 --- a/src/Dolumar/Windows/Battle.php +++ b/src/Dolumar/Windows/Battle.php @@ -76,19 +76,27 @@ public function getContent () return '

    '.$text->get ('vacationMode', 'main', 'main').'

    '; } - elseif (isset ($data['target'])) + // Back to the list, from any step: forget the target the window remembers. + elseif (isset ($input['overview']) && !isset ($input['report'])) { $this->setAjaxPollSeconds (0); - //$this->updateRequestData (array ('vid' => $this->village->getId ())); - return $this->getChallenge ($data['target']); + $this->updateRequestData (array ('vid' => $this->village->getId ())); + return $this->getBattleOverview (); } - - elseif (isset ($input['action'])) + + // Choose a (new) target. Picking one sends the target along, which goes to the next step. + elseif (isset ($input['action']) && !isset ($input['target'])) { $this->setAjaxPollSeconds (0); - //$this->updateRequestData (array ('vid' => $this->village->getId ())); + $this->updateRequestData (array ('vid' => $this->village->getId ())); return $this->getChooseTarget (); } + + elseif (isset ($data['target'])) + { + $this->setAjaxPollSeconds (0); + return $this->getChallenge ($data['target']); + } elseif (isset ($input['report'])) { @@ -121,12 +129,108 @@ public function getContent () } } + /* + Choose a target for an attack or support: tabs Suggested (attacks only), Nearby, Recent and Search. + Nearby and the honour cost are premium (Dolumar_Battle_TargetList::canSeeNeighbourhood). + */ private function getChooseTarget () { - $structure = new Neuron_Structure_ChooseTarget ($this->getInputData (), $this->village, false, true); - return $structure->getHTML (); + $input = $this->getInputData (); + $mode = isset ($input['command']) && $input['command'] == 'support' ? 'support' : 'attack'; + + $premium = Dolumar_Battle_TargetList::canSeeNeighbourhood ($this->village->getOwner ()); + $list = new Dolumar_Battle_TargetList ($this->village, $mode, $premium); + + $suggestions = $list->getSuggestions (); + + $tabs = array (); + if (count ($suggestions) > 0) + { + $tabs[] = 'suggested'; + } + $tabs[] = 'nearby'; + $tabs[] = 'recent'; + $tabs[] = 'search'; + + $query = isset ($input['sVillageName']) ? (string) $input['sVillageName'] : ''; + + // Free players start on Recent rather than on the locked Nearby tab. + $default = $tabs[0] === 'nearby' && !$premium ? 'recent' : $tabs[0]; + $tab = isset ($input['tab']) && in_array ($input['tab'], $tabs) ? $input['tab'] : $default; + if ($query !== '') + { + $tab = 'search'; + } + + $filter = isset ($input['filter']) && in_array ($input['filter'], array ('players', 'tribes')) ? $input['filter'] : 'all'; + + $page = new Neuron_Core_Template (); + $page->setTextSection ('targets', 'battle'); + + $page->set ('mode', $mode); + $page->set ('tabs', $tabs); + $page->set ('tab', $tab); + $page->set ('filter', $filter); + $page->set ('premium', $premium); + $page->set ('query', Neuron_Core_Tools::output_varchar ($query)); + $page->set ('nearbyCount', Dolumar_Battle_TargetList::NEARBY); + + $sections = array (); + switch ($tab) + { + case 'suggested': + $sections[] = array ('title' => null, 'rows' => $suggestions); + break; + + case 'nearby': + if ($mode === 'support') + { + $sections[] = array ('title' => 'ownVillages', 'rows' => $list->getOwnVillages ()); + $sections[] = array ('title' => 'clanVillages', 'rows' => $list->getClanVillages ()); + $sections[] = array ('title' => 'otherVillages', 'rows' => $list->getNearby ($filter), 'nearby' => true); + } + else + { + $sections[] = array ('title' => null, 'rows' => $list->getNearby ($filter), 'nearby' => true); + } + break; + + case 'recent': + if ($mode === 'attack') + { + $sections[] = array ('title' => 'attackedYou', 'rows' => $list->getAttackers ()); + } + $sections[] = array ('title' => 'fought', 'rows' => $list->getFought ()); + $sections[] = array ('title' => 'visited', 'rows' => $list->getVisited ()); + break; + + case 'search': + $sections[] = array ('title' => null, 'rows' => $list->search ($query), 'search' => $query !== ''); + break; + } + + $page->set ('sections', $sections); + + return $page->parse ('battle/chooseTarget.phpt'); } - + + /* + The way back from every step after a target was picked: choose another target, or the battle list. + */ + private function getBackLinks ($action = null) + { + if ($action === null) + { + $data = $this->getInputData (); + $action = isset ($data['command']) && $data['command'] == 'support' ? 'support' : 'attack'; + } + + $page = new Neuron_Core_Template (); + $page->setTextSection ('targets', 'battle'); + $page->set ('action', $action); + return $page->parse ('battle/backLinks.phpt'); + } + private function getChallenge ($targetId, $error = null) { $text = Neuron_Core_Text::__getInstance (); @@ -168,22 +272,22 @@ private function getChallenge ($targetId, $error = null) ) { // User is currently in vacation mode - return '

    '.$text->get ('vacationMode', 'challenge', 'battle').'

    '; + return $this->getBackLinks () . '

    '.$text->get ('vacationMode', 'challenge', 'battle').'

    '; } elseif ($target->isFound () && $target->isActive () && $distance !== false) { - return '

    '.$text->get ('ownVillage', 'challenge', 'battle').'

    '; + return $this->getBackLinks () . '

    '.$text->get ('ownVillage', 'challenge', 'battle').'

    '; } elseif ($distance === false) { - return '

    '.$text->get ('unreachable', 'challenge', 'battle').'

    '; + return $this->getBackLinks () . '

    '.$text->get ('unreachable', 'challenge', 'battle').'

    '; } else { - return '

    '.$text->get ('teamNotFound', 'challenge', 'battle').'

    '; + return $this->getBackLinks () . '

    '.$text->get ('teamNotFound', 'challenge', 'battle').'

    '; } } @@ -218,7 +322,7 @@ private function getAttackVillage ($target) if ($this->village->attackVillage ($target, $squads, $specials)) { reloadStatusCounters (); - return ('

    '.$text->get ('bdone', 'challenge', 'battle').'

    '); + return $this->getBackLinks ('attack') . '

    '.$text->get ('bdone', 'challenge', 'battle').'

    '; } else { @@ -264,6 +368,7 @@ private function getSendSupport ($target) $page = new Neuron_Core_Template (); $page->set ('target', Neuron_Core_Tools::output_varchar ($target->getName ())); $page->set ('action', 'support'); + $page->set ('backLinks', $this->getBackLinks ('support')); $input = $this->getInputData (); @@ -333,6 +438,7 @@ private function getChooseUnits ($target, $error = null) $page->set ('target', Neuron_Core_Tools::output_varchar ($target->getName ())); $page->set ('action', 'attack'); $page->set ('target_id', $target->getId ()); + $page->set ('backLinks', $this->getBackLinks ('attack')); $showForm = true; @@ -426,6 +532,7 @@ private function getChooseSpecialUnits ($target, $squads, $error = null) $page->setTextSection ('specialUnits', 'battle'); $page->set ('error', $error); + $page->set ('backLinks', $this->getBackLinks ('attack')); $page->set ('target', Neuron_Core_Tools::output_varchar ($target->getName ())); $page->set ('targetId', $target->getId ()); @@ -533,7 +640,7 @@ private function processChallengeInput ($target) } else { - $this->updateContent ('

    '.$text->get ('teamNotFound').'

    '); + $this->updateContent ($this->getBackLinks () . '

    '.$text->get ('teamNotFound').'

    '); } } diff --git a/tests/Battle/TargetListTest.php b/tests/Battle/TargetListTest.php new file mode 100644 index 0000000..1e39908 --- /dev/null +++ b/tests/Battle/TargetListTest.php @@ -0,0 +1,17 @@ +assertSame ('attack', $list->getMode ()); + $this->assertSame ('support', (new Dolumar_Battle_TargetList (null, 'support', false, 0))->getMode ()); + } +} diff --git a/tests/Integration/BattleTargetListTest.php b/tests/Integration/BattleTargetListTest.php new file mode 100644 index 0000000..9f8c248 --- /dev/null +++ b/tests/Integration/BattleTargetListTest.php @@ -0,0 +1,129 @@ +query (" + SELECT v.vid FROM villages v + INNER JOIN map_buildings b ON b.village = v.vid AND b.buildingType = 1 AND b.destroyDate = 0 + WHERE v.isActive = '1' AND v.plid > 0 + ORDER BY v.vid LIMIT 1 + "); + $this->assertNotEmpty ($rows, 'Needs an active player village'); + $this->me = Dolumar_Players_Village::getVillage ((int) $rows[0]['vid']); + + $rows = $db->query (" + SELECT v.vid FROM villages v + INNER JOIN map_buildings b ON b.village = v.vid AND b.buildingType = 1 AND b.destroyDate = 0 + WHERE v.isActive = '1' AND v.plid = 0 + ORDER BY v.vid LIMIT 1 + "); + if (count ($rows) === 0) + { + $this->markTestSkipped ('Needs an active tribe village'); + } + $this->tribe = Dolumar_Players_Village::getVillage ((int) $rows[0]['vid']); + } + + protected function tearDown (): void + { + if ($this->reportId) + { + Neuron_DB_Database::getInstance ()->query ("DELETE FROM battle_report WHERE reportId = " . (int) $this->reportId); + } + } + + public function testNearbyNeedsPremium (): void + { + $list = new Dolumar_Battle_TargetList ($this->me, 'attack', false); + $this->assertSame (array (), $list->getNearby ()); + } + + public function testNearbyIsClosestFirstAndAtMostTwenty (): void + { + $list = new Dolumar_Battle_TargetList ($this->me, 'attack', true); + $rows = $list->getNearby (); + + $this->assertNotEmpty ($rows); + $this->assertLessThanOrEqual (Dolumar_Battle_TargetList::NEARBY, count ($rows)); + + $last = -1; + foreach ($rows as $r) + { + $this->assertNotSame ((int) $this->me->getId (), $r['id']); + $this->assertGreaterThanOrEqual ($last, $r['distance']); + $last = $r['distance']; + } + + foreach ($list->getNearby ('tribes') as $r) + { + $this->assertTrue ($r['npc']); + $this->assertSame (0, $r['honour'], 'Raids on tribes cost no honour'); + } + } + + public function testHonourIsHiddenWithoutPremium (): void + { + $list = new Dolumar_Battle_TargetList ($this->me, 'attack', false); + $row = $list->getRow ($this->tribe); + $this->assertNull ($row['honour']); + $this->assertTrue ($row['canPick']); + } + + public function testAttackerIsListedAndSuggested (): void + { + $now = time (); + $this->reportId = Neuron_DB_Database::getInstance ()->query (" + INSERT INTO battle_report SET battleId = 0, fightDate = " . ($now - 3600) . ", fightDuration = 0, + fromId = " . (int) $this->tribe->getId () . ", targetId = " . (int) $this->me->getId () . ", + squads = '', slots = '', fightLog = '', battleLog = '', resultLog = '', victory = 0.5, execDate = NOW() + "); + + $list = new Dolumar_Battle_TargetList ($this->me, 'attack', false, $now); + + $ids = array_column ($list->getAttackers (), 'id'); + $this->assertContains ((int) $this->tribe->getId (), $ids); + + $suggestions = $list->getSuggestions (); + $this->assertNotEmpty ($suggestions); + $this->assertSame ((int) $this->tribe->getId (), $suggestions[0]['id']); + + // Support never suggests. + $support = new Dolumar_Battle_TargetList ($this->me, 'support', false, $now); + $this->assertSame (array (), $support->getSuggestions ()); + } + + public function testDistanceIsInLeagues (): void + { + $list = new Dolumar_Battle_TargetList ($this->me, 'attack', false); + $this->assertSame ('0.5', $list->formatLeagues (5)); + $this->assertSame ('8.9', $list->formatLeagues (88.6)); + $this->assertSame ('14', $list->formatLeagues (140.2)); + } + + public function testSearchFindsVillageByPartOfItsName (): void + { + $list = new Dolumar_Battle_TargetList ($this->me, 'attack', false); + $name = $this->tribe->getName (); + $ids = array_column ($list->search (substr ($name, 0, 6)), 'id'); + $this->assertContains ((int) $this->tribe->getId (), $ids); + $this->assertSame (array (), $list->search (' ')); + } +}