From 8a57c5cbf92b45041e82e946c648940ca94113e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 13:06:46 +0000 Subject: [PATCH] Battle window: a way back, and an easier target picker Every step of Attack and Support now links back to the battle list and to "Choose another target"; the window no longer stays stuck on a picked target. The target picker gets tabs: Suggested (strike back, avenge a clan mate, and for premium the closest tribe and a nearby player that costs no honour), Nearby (the 20 closest villages, premium only with an upgrade box), Recent (who attacked you, who you attacked, profiles you looked at) and Search. Rows show owner, race, networth, distance in leagues and, for premium, the honour cost of an attack. Support lists your own and clan villages first. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WpWbfBL3zdGwhQNxBeeRqz --- .../specs/2026-11-05-battle-target-picker.md | 33 + public/dolumar/css/battlereport.css | 16 + resources/language/de/battle.lng | 51 ++ resources/language/en/battle.lng | 51 ++ resources/language/fr/battle.lng | 51 ++ resources/language/nl/battle.lng | 51 ++ resources/language/pl/battle.lng | 51 ++ .../templates/default/account/benefits.phpt | 1 + .../templates/default/battle/backLinks.phpt | 9 + .../default/battle/chooseTarget.phpt | 125 ++++ .../templates/default/battle/chooseUnits.tpl | 1 + .../templates/default/battle/sendSupport.phpt | 1 + .../default/battle/specialUnits.phpt | 1 + src/Dolumar/Battle/TargetList.php | 644 ++++++++++++++++++ src/Dolumar/Windows/Battle.php | 137 +++- tests/Battle/TargetListTest.php | 17 + tests/Integration/BattleTargetListTest.php | 129 ++++ 17 files changed, 1354 insertions(+), 15 deletions(-) create mode 100644 .ai/superpowers/specs/2026-11-05-battle-target-picker.md create mode 100644 resources/templates/default/battle/backLinks.phpt create mode 100644 resources/templates/default/battle/chooseTarget.phpt mode change 100755 => 100644 resources/templates/default/battle/chooseUnits.tpl mode change 100755 => 100644 resources/templates/default/battle/specialUnits.phpt create mode 100644 src/Dolumar/Battle/TargetList.php create mode 100644 tests/Battle/TargetListTest.php create mode 100644 tests/Integration/BattleTargetListTest.php 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 00000000..e63ad1c0 --- /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 fd4b6f93..92019f1e 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 86bfda60..04cc08dc 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 ef11d825..e63e788f 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 b18fa8ab..6a23b5cf 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 13556dd6..10a96a3a 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 be4cba5a..8d2225a4 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 2afefc8f..da07db1b 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 00000000..ee9f93c8 --- /dev/null +++ b/resources/templates/default/battle/backLinks.phpt @@ -0,0 +1,9 @@ + $action, 'command' => $action)), ENT_QUOTES); +?> + diff --git a/resources/templates/default/battle/chooseTarget.phpt b/resources/templates/default/battle/chooseTarget.phpt new file mode 100644 index 00000000..83392cbd --- /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)?>

    + + + +
    + + +

    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 63fd6b72..54d25a8a --- 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 6fda0316..63c1810d 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 43d3933f..d6a0770f --- 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 00000000..a41d4c44 --- /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 03eb394c..a896a934 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 00000000..1e39908c --- /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 00000000..9f8c248b --- /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 (' ')); + } +}