From 109fa8801c5c0a1fb872f2ea26fed856dc9e55e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 17:00:27 +0000 Subject: [PATCH 1/2] Make resource building bonuses visible Building window of a farm, lumber camp, mine or mill now shows where its production comes from: base, honour, each kind of bonus tile next to it, and each technology or spell (procBonusses can report its steps). The rows add up to the real income. The mill explains its share of the farms and what another mill or farm level would add. The Build window passes a placement preview (tiles near the village that raise the building's resource) to the engine's new Game.map.placementPreview. Text fixes: Mill description, rock tiles named as rocks, 20% stated. The mill upgrade preview counts the extra mill level; dead income_increase removed. Income rules are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WpWbfBTo645HCwsC28PTm7 --- .ai/style-guide.md | 1 + public/dolumar/css/ui.css | 64 +++++ resources/language/en/buildings.lng | 37 ++- resources/language/nl/buildings.lng | 37 ++- .../templates/default/buildings/producing.tpl | 27 ++ src/Dolumar/Buildings/Mill.php | 110 +++++++- src/Dolumar/Buildings/Producing.php | 255 ++++++++++++++++-- src/Dolumar/Map/Haystack.php | 5 + src/Dolumar/Map/Location.php | 9 + src/Dolumar/Map/Map.php | 42 +++ src/Dolumar/Map/Resource.php | 5 + src/Dolumar/Map/Trees.php | 5 + src/Dolumar/Players/Village.php | 15 +- src/Dolumar/View/SelectBuildLocation.php | 17 +- src/Dolumar/Windows/Build.php | 25 +- tests/Buildings/ResourceBonusTest.php | 142 ++++++++++ tests/Integration/IncomeBreakdownTest.php | 58 ++++ 17 files changed, 803 insertions(+), 51 deletions(-) create mode 100644 tests/Buildings/ResourceBonusTest.php create mode 100644 tests/Integration/IncomeBreakdownTest.php diff --git a/.ai/style-guide.md b/.ai/style-guide.md index 9fe4690c..6b277961 100644 --- a/.ai/style-guide.md +++ b/.ai/style-guide.md @@ -46,6 +46,7 @@ in the project's shared files, with the background in `01-style-guide-options.md | `.ui-box`, `.ui-columns` | Bordered group, two-column grid of groups | Same border and fill as a card | | `.ui-bar` with an `` | Health or progress | 6px, green part `#2f7a1f`, rest `#b3261e` (`.neutral` for a tan rest) | | `.ui-field` | Label above its input | Bold label, input with 1px `#8a7550` border and 3px corners | +| `table.ui-breakdown` | Where a total comes from (income per building): `td.tile` for a 32px map tile image, `td.number` for amounts, `tr.total` last | Full width, thin tan line under each row, bold total row with a dark line above | | `.ui-muted`, `.ui-good`, `.ui-bad` | Secondary text, good and bad numbers | `#7a6a4c`, `#2f7a1f`, `#b3261e` | | `p.true.ui-msg` etc. | Classic message box with room | 8px 10px padding, 3px corners | diff --git a/public/dolumar/css/ui.css b/public/dolumar/css/ui.css index 5828e200..cd4b23d7 100644 --- a/public/dolumar/css/ui.css +++ b/public/dolumar/css/ui.css @@ -254,6 +254,48 @@ a.ui-card:hover } /* Text helpers. */ +/* Number table: where a total comes from (income breakdown). Label left, amount right, total row at the bottom. */ +table.ui-breakdown +{ + width: 100%; + margin: 8px 0; + border-collapse: collapse; +} + +table.ui-breakdown td +{ + padding: 3px 4px; + border-bottom: 1px solid #c9b58a; + vertical-align: middle; + text-align: left; +} + +table.ui-breakdown td.number +{ + text-align: right; + white-space: nowrap; +} + +table.ui-breakdown td.tile +{ + width: 32px; + padding: 0 4px 0 0; +} + +table.ui-breakdown td.tile img +{ + display: block; + width: 32px; + height: auto; +} + +table.ui-breakdown tr.total td +{ + border-top: 1px solid #40331f; + border-bottom: 0; + font-weight: bold; +} + .ui-muted { color: #7a6a4c; } .ui-good { color: #2f7a1f; } .ui-bad { color: #b3261e; } @@ -350,3 +392,25 @@ body.layout-mobile .sheet-content .ui-card .ui-card-title color: #000097; text-decoration: underline; } + +/* The sheet's generic table rules (separate borders, centred numbers, top alignment) do not apply. */ +body.layout-mobile .sheet-content table.ui-breakdown +{ + border-collapse: collapse; +} + +body.layout-mobile .sheet-content table.ui-breakdown td +{ + padding: 6px 4px; + vertical-align: middle; +} + +body.layout-mobile .sheet-content table.ui-breakdown td.number +{ + text-align: right; +} + +body.layout-mobile .sheet-content table.ui-breakdown td.tile +{ + padding: 0 4px 0 0; +} diff --git a/resources/language/en/buildings.lng b/resources/language/en/buildings.lng index cef4db35..13252ab7 100644 --- a/resources/language/en/buildings.lng +++ b/resources/language/en/buildings.lng @@ -49,11 +49,12 @@ Clanportal = "Clan Portals" [desc] TownCenter = "The center of the Town. Here the elders of the town meet to discuss politics." Silo = "Your Silo is where your resources are stored. It determines the amount of resources you can store." -Farm = "Your farm produces grain. If you have a haystack in your village, put it next to that for extra income." -StoneMine = "Your stone mine produces stone. If you have a mine in your village, put it next to that for extra income." -Lumber = "Your lumber camp produces wood. If you have a forest in your village, put it next to that for extra income." -IronMine = "Your iron mine produces iron. If you have a mine in your village, put it next to that for extra income." -GemMine = "Your gem mine produces gems. Put it next to a gem mine for extra income." +Farm = "Your farm produces grain. Every haystack next to it adds 20% more grain." +StoneMine = "Your stone mine produces stone. Every stone rock next to it adds 20% more stone." +Lumber = "Your lumber camp produces wood. Every forest tile next to it adds 20% more wood." +IronMine = "Your iron mine produces iron. Every iron ore rock next to it adds 20% more iron." +GemMine = "Your gem mine produces gems. Every gem rock next to it adds 20% more gems." +Mill = "Your mill grinds the grain of all your farms. The more farm levels you have, the more grain your mills produce." Barrack = "Your infantry is trained in barracks. The more barracks you have, the larger your army will be. Your barrack level determines the training duration." Archery = "Archers are trained in your archery." Stable = "Stables hold horses. Horsemen are faster then soldiers on foot." @@ -99,6 +100,32 @@ producing1 = "This building produces" producing2 = "every hour." upgrade1 = "This building will produce @@next after upgrading." upgrade2 = "At the moment it's producing @@now." +baseIncome = "Base production (level @@level)" +honourIncome = "Honour @@honour%" +tileBonus = "@@tile next to it: +@@percentage%" +tileBonusMultiple = "@@tile next to it (@@amount tiles): +@@percentage%" +totalIncome = "Total" +millShare = "Share of your farms' grain (farm levels: @@farms, mill levels: @@mills)" +millAdvice = "Another mill level would make your mills produce @@mill more. Another farm level adds @@farm through your mills, on top of the farm's own grain." + +[tiles] +trees = "Forest" +haystack = "Haystack" +stone = "Stone rock" +iron = "Iron ore" +gems = "Gem rock" + +[placement] +bonustext = "+@@value% @@resource" +nobonus = "No bonus here" + +[bonusExplanation] +Farm = "Every haystack in the 3 by 3 square around a farm (its own tile included) adds 20% grain. Haystacks are rare: look for one before you place a farm." +Lumber = "Every forest tile in the 3 by 3 square around a lumber camp (its own tile included) adds 20% wood." +StoneMine = "Every stone rock in the 3 by 3 square around a stone mine adds 20% stone. Rocks are rare: look for one before you place a mine." +IronMine = "Every iron ore rock in the 3 by 3 square around an iron mine adds 20% iron. Rocks are rare: look for one before you place a mine." +GemMine = "Every gem rock in the 3 by 3 square around a gem mine adds 20% gems. Rocks are rare: look for one before you place a mine." +Mill = "A mill does not need to stand near anything: it grinds the grain of all your farms. Every farm level adds grain for your mills to share, and every mill level takes a share. Honour and map tiles do not change a mill's production." [silo] silo = "Silo Capacity" diff --git a/resources/language/nl/buildings.lng b/resources/language/nl/buildings.lng index bd7d2431..f090f97b 100755 --- a/resources/language/nl/buildings.lng +++ b/resources/language/nl/buildings.lng @@ -47,11 +47,12 @@ Portal="Magische poorten" [desc] TownCenter="Het centrum van het dorp. Hier komen de dorpswijzen samen om over politiek te praten." Silo="Je grondstoffen worden opgeslagen in opslagplaatsen." -Farm="Boederijen produceren voedsel, de belangrijkste grondstof voor het opbouwen van troepen. Plaats ze naast een hooiberg voor extra inkomsten." -StoneMine="Steenmijen produceren steen. Plaats ze naast steenmijnen voor extra inkomsten." -Lumber="Houthakkers zorgen voor hout. Plaats ze naast bossen voor extra inkomsten." -IronMine="Je ijzermijnen produceren ijzer, voornamelijk gebruikt voor wapens." -GemMine="Je diamantmijnen produceren diamanten, gebruikt voor magische spreuken. Plaats ze naast diamanten voor extra inkomsten." +Farm="Boerderijen produceren graan, de belangrijkste grondstof voor het opbouwen van troepen. Elke hooiberg ernaast geeft 20% meer graan." +StoneMine="Steenmijnen produceren steen. Elke steenrots ernaast geeft 20% meer steen." +Lumber="Houthakkers zorgen voor hout. Elke bostegel ernaast geeft 20% meer hout." +IronMine="Je ijzermijnen produceren ijzer, voornamelijk gebruikt voor wapens. Elke ijzerertsrots ernaast geeft 20% meer ijzer." +GemMine="Je diamantmijnen produceren diamanten, gebruikt voor magische spreuken. Elke diamantrots ernaast geeft 20% meer diamanten." +Mill="Je molen maalt het graan van al je boerderijen. Hoe meer boerderijniveaus je hebt, hoe meer graan je molens produceren." Barrack="Je infantrie wordt getraind in barakken. Hoe meer barakken je hebt, hoe meer troepen je kan bouwen." Archery="In het shuttershof worden scherpschutters getraind." Stable="De stallen huizen en trainen paarden en ruiters, ideaal om snel je vijand te bereiken." @@ -91,6 +92,32 @@ producing1="Dit gebouw produceert" producing2="elk uur." upgrade1="Dit gebouw zal @@next produceren na de uitbreiding." upgrade2="Momenteel produceert het @@now." +baseIncome="Basisproductie (niveau @@level)" +honourIncome="Eer @@honour%" +tileBonus="@@tile ernaast: +@@percentage%" +tileBonusMultiple="@@tile ernaast (@@amount tegels): +@@percentage%" +totalIncome="Totaal" +millShare="Deel van het graan van je boerderijen (boerderijniveaus: @@farms, molenniveaus: @@mills)" +millAdvice="Een extra molenniveau laat je molens samen @@mill meer produceren. Een extra boerderijniveau levert via je molens @@farm op, bovenop het graan van de boerderij zelf." + +[tiles] +trees="Bos" +haystack="Hooiberg" +stone="Steenrots" +iron="IJzererts" +gems="Diamantrots" + +[placement] +bonustext="+@@value% @@resource" +nobonus="Geen bonus hier" + +[bonusExplanation] +Farm="Elke hooiberg in het vierkant van 3 op 3 rond een boerderij (haar eigen tegel inbegrepen) geeft 20% meer graan. Hooibergen zijn zeldzaam: zoek er een voor je een boerderij plaatst." +Lumber="Elke bostegel in het vierkant van 3 op 3 rond een houthakker (zijn eigen tegel inbegrepen) geeft 20% meer hout." +StoneMine="Elke steenrots in het vierkant van 3 op 3 rond een steenmijn geeft 20% meer steen. Rotsen zijn zeldzaam: zoek er een voor je een mijn plaatst." +IronMine="Elke ijzerertsrots in het vierkant van 3 op 3 rond een ijzermijn geeft 20% meer ijzer. Rotsen zijn zeldzaam: zoek er een voor je een mijn plaatst." +GemMine="Elke diamantrots in het vierkant van 3 op 3 rond een diamantmijn geeft 20% meer diamanten. Rotsen zijn zeldzaam: zoek er een voor je een mijn plaatst." +Mill="Een molen hoeft nergens naast te staan: hij maalt het graan van al je boerderijen. Elk boerderijniveau levert graan om te delen, elk molenniveau neemt een deel. Eer en kaarttegels veranderen de productie van een molen niet." [silo] silo="Opslagplaats capaciteit" diff --git a/resources/templates/default/buildings/producing.tpl b/resources/templates/default/buildings/producing.tpl index e842b93a..bb6ead38 100644 --- a/resources/templates/default/buildings/producing.tpl +++ b/resources/templates/default/buildings/producing.tpl @@ -1,6 +1,33 @@ +setTextSection ('producing', 'buildings'); ?>



+ + 0) { ?> + 0; ?> + + $v) { ?> + + + + + + + + + + + +
0 && $i > 0 ? '+' : '') . $v['amount']?>
getText ('totalIncome')?>  
+ + + +

+ + + +

+ diff --git a/src/Dolumar/Buildings/Mill.php b/src/Dolumar/Buildings/Mill.php index 58bdf25e..148cf919 100755 --- a/src/Dolumar/Buildings/Mill.php +++ b/src/Dolumar/Buildings/Mill.php @@ -33,20 +33,110 @@ protected function initRequirements () public function calculateIncome ($level = null) { + $numbers = $this->getMillNumbers ($level); + return $numbers['income']; + } + + /* + A mill does not use map tiles or honour: all mills share the + grain of all farms. Every farm level adds 1.7 x 120 grain to share, + divided over the farm and mill levels together. + $level is this mill's level; another level than the current one + also changes the total of mill levels (for the upgrade preview). + */ + private function getMillNumbers ($level = null, $extraFarms = 0, $extraMills = 0) + { + $current = $this->getLevel (); if (!isset ($level)) { - $level = $this->getLevel (); + $level = $current; } - - $farms = $this->getVillage()->buildings->getBuildingLevelSum (10); - $mills = $this->getVillage()->buildings->getBuildingLevelSum (16); - + + $farms = $this->getVillage()->buildings->getBuildingLevelSum (10) + $extraFarms; + $mills = $this->getVillage()->buildings->getBuildingLevelSum (16) + ($level - $current) + $extraMills; + $income = $this->INCOME * GAME_SPEED_RESOURCES; - - $x = 1.7 * $income * $farms * $level; - $y = max (1, $farms + $mills); - - return floor ($x / $y); + + return array + ( + 'farms' => $farms, + 'mills' => $mills, + 'income' => floor ((1.7 * $income * $farms * $level) / max (1, $farms + $mills)), + + // What all mills of this village produce together. + 'allMills' => floor ((1.7 * $income * $farms * $mills) / max (1, $farms + $mills)) + ); + } + + /* + Mills do not use map tiles. + */ + public function getPlacementPreview (Dolumar_Players_Village $village) + { + return null; + } + + public function getIncomeBreakdown () + { + $text = Neuron_Core_Text::__getInstance (); + $text->setFile ('buildings'); + $text->setSection ('producing'); + + $numbers = $this->getMillNumbers (); + + $rows = array (); + $rows[] = array + ( + 'label' => Neuron_Core_Tools::putIntoText + ( + $text->get ('millShare'), + array + ( + 'level' => $this->getLevel (), + 'farms' => $numbers['farms'], + 'mills' => $numbers['mills'] + ) + ), + 'amount' => $numbers['income'] + ); + + $this->addEffectRows ($rows, $numbers['income'], $numbers['income']); + + return $rows; + } + + /* + What another mill level or another farm level would add through + the mills (before technologies and spells). + */ + protected function getIncomeAdvice () + { + $text = Neuron_Core_Text::__getInstance (); + + $now = $this->getMillNumbers (); + $moreMills = $this->getMillNumbers (null, 0, 1); + $moreFarms = $this->getMillNumbers (null, 1, 0); + + return Neuron_Core_Tools::putIntoText + ( + $text->get ('millAdvice', 'producing', 'buildings'), + array + ( + 'mill' => $this->grainToText ($moreMills['allMills'] - $now['allMills']), + 'farm' => $this->grainToText ($moreFarms['allMills'] - $now['allMills']) + ) + ); + } + + private function grainToText ($amount) + { + // resourceToText leaves out amounts of 0 or less. + if ($amount <= 0) + { + return '0'; + } + + return $this->resourceToText (array ('grain' => $amount), false, false); } } ?> diff --git a/src/Dolumar/Buildings/Producing.php b/src/Dolumar/Buildings/Producing.php index feb7139c..15f91c46 100644 --- a/src/Dolumar/Buildings/Producing.php +++ b/src/Dolumar/Buildings/Producing.php @@ -53,6 +53,17 @@ public function getIncome ($since = NOW, $now = NOW) } protected function calculateIncome ($level = null) + { + $steps = $this->calculateIncomeSteps ($level); + return $steps['total']; + } + + /* + The income of this building before technologies and spells, + step by step: base (level), honour, nearby bonus tiles. + calculateIncome () returns 'total'. + */ + protected function calculateIncomeSteps ($level = null) { $profiler = Neuron_Profiler_Profiler::getInstance (); @@ -61,32 +72,50 @@ protected function calculateIncome ($level = null) $level = $this->getLevel (); } + $out = array (); + // 40 for every level $income = $this->INCOME * GAME_SPEED_RESOURCES * 1.1 * $level; + $out['base'] = $income; // Manipulated by honour! $profiler->start ('Fetching honour'); - $income *= ($this->getVillage()->honour->getHonour() / 100); + $out['honourPercentage'] = $this->getVillage()->honour->getHonour(); + $income *= ($out['honourPercentage'] / 100); + $out['honour'] = $income; $profiler->stop (); // Bonusses (in resources) $profiler->start ('Fetching locations'); - $bonus = $this->loadNearebyLocation (); - if (isset ($bonus[$this->RESOURCE])) + $out['tiles'] = $this->getNearbyBonusTiles (); + $bonus = 0; + foreach ($out['tiles'] as $v) { - $income += ( ($income / 100) * $bonus[ $this->RESOURCE ] ); + $bonus += $v['percentage']; } + + if ($bonus != 0) + { + $income += ( ($income / 100) * $bonus ); + } + $out['tilesPercentage'] = $bonus; + $out['tilesIncome'] = $income; $profiler->stop (); //$income = (floor ($income * 4 * GAME_SPEED_RESOURCES) / 4); - $income = floor ($income); + $out['total'] = floor ($income); - return $income; + return $out; } - private function loadNearebyLocation () + /* + The map tiles in the 3x3 square around this building (its own tile + included) that raise this building's resource, grouped by kind: + key => array ('key', 'amount', 'percentage', 'image'). + */ + public function getNearbyBonusTiles () { - $resources = array (); + $out = array (); for ($i = -1; $i <= 1; $i ++) { for ($j = -1; $j <= 1; $j ++) @@ -94,21 +123,132 @@ private function loadNearebyLocation () $land = Dolumar_Map_Location::getLocation ($this->tileLocationX + $i, $this->tileLocationY + $j); $benefit = $land->getIncomeBonus (); - foreach ($benefit as $k => $v) + if (empty ($benefit[$this->RESOURCE])) + { + continue; + } + + $key = $land->getBonusKey (); + if (!isset ($out[$key])) { - if (!isset ($resources[$k])) - { - $resources[$k] = $v; - } - else - { - $resources[$k] += $v; - } + $image = $land->getImage (); + $out[$key] = array + ( + 'key' => $key, + 'amount' => 0, + 'percentage' => 0, + 'image' => IMAGE_URL . 'tiles/' . $image['image'] . '.gif' + ); } + + $out[$key]['amount'] ++; + $out[$key]['percentage'] += $benefit[$this->RESOURCE]; } } - return $resources; + return $out; + } + + /* + Where the income comes from, as rows for the building window: + array ('label', 'amount' (signed), 'image' (optional)). + The amounts add up to getIncome (). + */ + public function getIncomeBreakdown () + { + $text = Neuron_Core_Text::__getInstance (); + $text->setFile ('buildings'); + $text->setSection ('producing'); + + $steps = $this->calculateIncomeSteps (); + + $rows = array (); + + $rows[] = array + ( + 'label' => Neuron_Core_Tools::putIntoText ($text->get ('baseIncome'), array ('level' => $this->getLevel ())), + 'amount' => floor ($steps['base']) + ); + + if ($steps['honourPercentage'] != 100) + { + $rows[] = array + ( + 'label' => Neuron_Core_Tools::putIntoText ($text->get ('honourIncome'), array ('honour' => $steps['honourPercentage'])), + 'amount' => floor ($steps['honour']) - floor ($steps['base']) + ); + } + + // Show every bonus tile kind with its share of the bonus. + $previous = $steps['honour']; + foreach ($steps['tiles'] as $v) + { + $after = $previous + ($steps['honour'] / 100) * $v['percentage']; + $rows[] = array + ( + 'label' => Neuron_Core_Tools::putIntoText + ( + $text->get ($v['amount'] > 1 ? 'tileBonusMultiple' : 'tileBonus'), + array + ( + 'tile' => $text->get ($v['key'], 'tiles', 'buildings', $v['key']), + 'amount' => $v['amount'], + 'percentage' => $v['percentage'] + ) + ), + 'amount' => floor ($after) - floor ($previous), + 'image' => $v['image'] + ); + $previous = $after; + } + + $this->addEffectRows ($rows, $previous, $steps['total']); + + return $rows; + } + + /* + Add one row per technology or spell that changes the income. + $previous is the income before effects (unrounded), $total the + same value rounded the way calculateIncome () does. + */ + protected function addEffectRows (&$rows, $previous, $total) + { + $text = Neuron_Core_Text::__getInstance (); + + $effects = array (); + $this->getVillage ()->procBonusses + ( + 'procIncome', + array (array ($this->RESOURCE => $total), $this), + NOW, NOW, + $effects + ); + + // The rounding of the steps before goes into the first row after them. + $last = floor ($previous); + $lastRow = count ($rows) - 1; + if ($lastRow >= 0 && $last != $total) + { + $rows[$lastRow]['amount'] += $total - $last; + } + $last = $total; + + foreach ($effects as $effect) + { + list ($object, $resources) = $effect; + $value = isset ($resources[$this->RESOURCE]) ? floor ($resources[$this->RESOURCE]) : 0; + + if ($value != $last) + { + $rows[] = array + ( + 'label' => $object->getName (), + 'amount' => $value - $last + ); + $last = $value; + } + } } protected function getCustomContent ($input) @@ -124,10 +264,86 @@ protected function getCustomContent ($input) $page->set ('producing1', $text->get ('producing1')); $page->set ('producing2', $text->get ('producing2')); - $page->set ('income', $this->resourceToText($this->getIncome(), true, false)); + $income = $this->getIncome (); + $page->set ('income', $this->resourceToText($income, true, false)); + $page->set ('resource', $this->RESOURCE); + $page->set ('total', isset ($income[$this->RESOURCE]) ? $income[$this->RESOURCE] : 0); + + foreach ($this->getIncomeBreakdown () as $row) + { + $page->addListValue ('breakdown', $row); + } + + $page->set ('explanation', $this->getBonusExplanation ()); + $page->set ('advice', $this->getIncomeAdvice ()); return $page->parse ('buildings/producing.tpl'); } + + /* + What every spot is worth while this building is placed: the tiles + near the village that raise its resource (for the engine's + Game.map.placementPreview). Null when nothing can raise it. + */ + public function getPlacementPreview (Dolumar_Players_Village $village) + { + $townCenter = $village->buildings->getTownCenter (); + if (!$townCenter) + { + return null; + } + + $text = Neuron_Core_Text::__getInstance (); + + list ($cx, $cy) = $townCenter->getLocation (); + + // One tile further than the building radius: a tile just outside still counts. + $radius = $village->getBuildingRadius () + 1; + + $tiles = array (); + foreach (Dolumar_Map_Map::getBonusTilesAround ($cx, $cy, $radius) as $v) + { + list ($x, $y, $location) = $v; + + $bonus = $location->getIncomeBonus (); + if (empty ($bonus[$this->RESOURCE])) + { + continue; + } + + $key = $location->getBonusKey (); + $tiles[] = array ((int)$x, (int)$y, $bonus[$this->RESOURCE], $text->get ($key, 'tiles', 'buildings', $key)); + } + + $resource = $text->get ($this->RESOURCE, 'resources', 'main', $this->RESOURCE); + + return array + ( + 'area' => 1, + 'tiles' => $tiles, + + // The engine fills in @@value; putIntoText would remove it. + 'text' => str_replace ('@@resource', $resource, $text->get ('bonustext', 'placement', 'buildings')), + 'none' => $text->get ('nobonus', 'placement', 'buildings') + ); + } + + /* + Extra advice under the breakdown (the mill uses it); null for none. + */ + protected function getIncomeAdvice () + { + return null; + } + + /* + One line that tells the player how to raise this building's income. + */ + protected function getBonusExplanation () + { + $text = Neuron_Core_Text::__getInstance (); + return $text->get ($this->getClassName (), 'bonusExplanation', 'buildings', ''); + } /* Returns a small upgrade message @@ -141,7 +357,6 @@ protected function getUpgradeInformation () $page->set ('income_next_level', $this->resourceToText ($later, true, false)); $page->set ('income_this_level', $this->resourceToText ($now, true, false)); - $page->set ('income_increase', round ((($later[$this->RESOURCE] - $later[$this->RESOURCE]) / max (1, $later[$this->RESOURCE])) * 100)); return $page->parse ('buildings/producing_up.phpt'); } diff --git a/src/Dolumar/Map/Haystack.php b/src/Dolumar/Map/Haystack.php index 5ae76565..94fe0aea 100644 --- a/src/Dolumar/Map/Haystack.php +++ b/src/Dolumar/Map/Haystack.php @@ -48,6 +48,11 @@ public function getIncomeBonus () //return array (); return array ('grain' => 20); } + + public function getBonusKey () + { + return 'haystack'; + } } ?> diff --git a/src/Dolumar/Map/Location.php b/src/Dolumar/Map/Location.php index 2345287f..88ddcf4d 100644 --- a/src/Dolumar/Map/Location.php +++ b/src/Dolumar/Map/Location.php @@ -72,6 +72,15 @@ public function getIncomeBonus () return array (); } + /* + Name of the bonus tile ('trees', 'haystack', 'stone', 'iron', 'gems') + used for its label and image; null for tiles without an income bonus. + */ + public function getBonusKey () + { + return null; + } + public function isWater () { return false; diff --git a/src/Dolumar/Map/Map.php b/src/Dolumar/Map/Map.php index 5b388cc8..6cae5368 100644 --- a/src/Dolumar/Map/Map.php +++ b/src/Dolumar/Map/Map.php @@ -111,6 +111,48 @@ public static function getLocation ($x, $y, $hasBuilding = false, $useCache = tr } } + /* + All tiles with an income bonus within $radius of ($cx, $cy): + array of array (x, y, Dolumar_Map_Location). Tiles are generated + fresh: they never change and this is much faster than the cache + for a few thousand tiles. + */ + public static function getBonusTilesAround ($cx, $cy, $radius) + { + static $cache = array (); + + $cx = floor ($cx); + $cy = floor ($cy); + $radius = (int)$radius; + + $key = $cx . '_' . $cy . '_' . $radius; + if (isset ($cache[$key])) + { + return $cache[$key]; + } + + $out = array (); + for ($x = $cx - $radius; $x <= $cx + $radius; $x ++) + { + for ($y = $cy - $radius; $y <= $cy + $radius; $y ++) + { + if (pow ($x - $cx, 2) + pow ($y - $cy, 2) > $radius * $radius) + { + continue; + } + + $location = self::getLocation ($x, $y, false, false); + if ($location->getBonusKey () !== null) + { + $out[] = array ($x, $y, $location); + } + } + } + + $cache[$key] = $out; + return $out; + } + public static function isWater ($x, $y) { /* diff --git a/src/Dolumar/Map/Resource.php b/src/Dolumar/Map/Resource.php index e7442d0e..a8400a5a 100644 --- a/src/Dolumar/Map/Resource.php +++ b/src/Dolumar/Map/Resource.php @@ -64,6 +64,11 @@ public function getIncomeBonus () $this->getRes () => 20 ); } + + public function getBonusKey () + { + return $this->getRes (); + } } ?> diff --git a/src/Dolumar/Map/Trees.php b/src/Dolumar/Map/Trees.php index 897bcbc3..acb30e46 100644 --- a/src/Dolumar/Map/Trees.php +++ b/src/Dolumar/Map/Trees.php @@ -42,6 +42,11 @@ public function getIncomeBonus () { return array ('wood' => 20); } + + public function getBonusKey () + { + return 'trees'; + } } ?> diff --git a/src/Dolumar/Players/Village.php b/src/Dolumar/Players/Village.php index 22cf5a87..d0855c7e 100644 --- a/src/Dolumar/Players/Village.php +++ b/src/Dolumar/Players/Village.php @@ -1715,8 +1715,11 @@ public function stealRunes ($amount) The bonus returned should be the average bonus between that time. The income bonus returns the hourly income bonus! + + Pass an array as $steps to get every technology or effect that + changed the value, with the value after it: array (object, value). */ - public function procBonusses ($function, $arguments = array (), $now = NOW, $since = NOW) + public function procBonusses ($function, $arguments = array (), $now = NOW, $since = NOW, ?array &$steps = null) { $profiler = Neuron_Profiler_Profiler::getInstance (); @@ -1750,6 +1753,11 @@ public function procBonusses ($function, $arguments = array (), $now = NOW, $sin if (method_exists ($technology, $function)) { $arguments[0] = call_user_func_array (array ($technology, $function), $arguments); + + if (isset ($steps)) + { + $steps[] = array ($technology, $arguments[0]); + } } } $profiler->stop (); @@ -1770,6 +1778,11 @@ public function procBonusses ($function, $arguments = array (), $now = NOW, $sin if (method_exists ($v, $function)) { $arguments[0] = call_user_func_array (array ($v, $function), $arguments); + + if (isset ($steps)) + { + $steps[] = array ($v, $arguments[0]); + } } } $profiler->stop (); diff --git a/src/Dolumar/View/SelectBuildLocation.php b/src/Dolumar/View/SelectBuildLocation.php index 7f38cc5a..b5f98591 100755 --- a/src/Dolumar/View/SelectBuildLocation.php +++ b/src/Dolumar/View/SelectBuildLocation.php @@ -31,6 +31,7 @@ class Dolumar_View_SelectBuildLocation private $runeclassname; private $extradata = "''"; private $error; + private $preview = null; public function __construct ( @@ -53,6 +54,14 @@ public function __construct $this->error = $errormessage; } + /* + Show what each spot is worth while choosing (Game.map.placementPreview). + */ + public function setPreview (?array $preview) + { + $this->preview = $preview; + } + public function setSendFormData () { $this->sendformdata = true; @@ -70,7 +79,13 @@ public function getAction () $sendformdata = $this->sendformdata ? 'true' : 'false'; - return 'selectBuildLocation (this, '.$data.', '.$this->runeclassname.', '.$image.', '.$this->extradata.', \'' . $this->error . '\');'; + $preview = ''; + if (isset ($this->preview)) + { + $preview = ', ' . htmlentities (json_encode ($this->preview), ENT_COMPAT); + } + + return 'selectBuildLocation (this, '.$data.', '.$this->runeclassname.', '.$image.', '.$this->extradata.', \'' . $this->error . '\''.$preview.');'; } } ?> diff --git a/src/Dolumar/Windows/Build.php b/src/Dolumar/Windows/Build.php index 7632ac91..94114e83 100644 --- a/src/Dolumar/Windows/Build.php +++ b/src/Dolumar/Windows/Build.php @@ -159,6 +159,21 @@ public function getContent ($errorInput = null, $putIntoText = array ()) if ($building->canBuildBuilding ($this->village)) { + $action = new Dolumar_View_SelectBuildLocation + ( + $building->getBuildingId (), + 'build', + 'building_' . $building->getBuildingId (), + $building->getDisplayObject ($race), + null, + $selectrunewarning + ); + + if ($building instanceof Dolumar_Buildings_Producing) + { + $action->setPreview ($building->getPlacementPreview ($this->village)); + } + $duration = $building->getConstructionTime ($this->village); $dur = Neuron_Core_Tools::getDuration ($duration); @@ -186,15 +201,7 @@ public function getContent ($errorInput = null, $putIntoText = array ()) 'myBuildingsName' => $building->getName ($this->village->buildings->getBuildingAmount ($building) > 1), - 'action' => new Dolumar_View_SelectBuildLocation - ( - $building->getBuildingId (), - 'build', - 'building_' . $building->getBuildingId (), - $building->getDisplayObject ($race), - null, - $selectrunewarning - ) + 'action' => $action ); } } diff --git a/tests/Buildings/ResourceBonusTest.php b/tests/Buildings/ResourceBonusTest.php new file mode 100644 index 00000000..3f347c8e --- /dev/null +++ b/tests/Buildings/ResourceBonusTest.php @@ -0,0 +1,142 @@ +previousSeed = $GLOBALS['MAP_CONFIG_RANDMAPFACTOR']; + $GLOBALS['MAP_CONFIG_RANDMAPFACTOR'] = self::SEED; + } + + protected function tearDown (): void + { + if (isset ($this->previousSeed)) { + $GLOBALS['MAP_CONFIG_RANDMAPFACTOR'] = $this->previousSeed; + } + } + + /** + * A village with the given farm and mill level totals, honour 100, no technologies or spells. + */ + private function village (int $farms, int $mills): object + { + $buildings = new class ($farms, $mills) { + public function __construct (private int $farms, private int $mills) {} + + public function getBuildingLevelSum ($id) + { + return $id == 10 ? $this->farms : ($id == 16 ? $this->mills : 0); + } + }; + + return new class ($buildings) { + public function __construct (public object $buildings) {} + + public function procBonusses ($function, $arguments = array (), $now = NOW, $since = NOW, ?array &$steps = null) + { + return $arguments[0]; + } + }; + } + + private function mill (int $level, int $farms, int $mills): Dolumar_Buildings_Mill + { + $mill = Dolumar_Buildings_Building::getBuildingFromName ('Mill', Dolumar_Races_Race::getRace ('Humans')); + $mill->setVillage ($this->village ($farms, $mills)); + $mill->setData (1, array ('bLevel' => $level)); + return $mill; + } + + public function testMillIncomeIsItsShareOfTheFarms (): void + { + // 1.7 x 120 grain per farm level, shared by 10 farm levels and 3 mill levels; this mill has all 3. + $this->assertSame (floor (1.7 * 120 * 10 * 3 / 13), $this->mill (3, 10, 3)->calculateIncome ()); + } + + public function testMillUpgradePreviewCountsTheExtraMillLevel (): void + { + // After upgrading, the village has 4 mill levels, not 3. + $this->assertSame (floor (1.7 * 120 * 10 * 4 / 14), $this->mill (3, 10, 3)->calculateIncome (4)); + } + + public function testMillBreakdownAddsUpToItsIncome (): void + { + $mill = $this->mill (1, 5, 1); + $rows = $mill->getIncomeBreakdown (); + + $this->assertCount (1, $rows); + $this->assertSame (170.0, (float) $rows[0]['amount']); + $this->assertStringContainsString ('farm levels: 5', $rows[0]['label']); + } + + public function testMillHasNoPlacementPreview (): void + { + $village = (new ReflectionClass ('Dolumar_Players_Village'))->newInstanceWithoutConstructor (); + $this->assertNull ($this->mill (1, 5, 1)->getPlacementPreview ($village)); + } + + public function testBonusTilesAroundAreTheTilesWithAnIncomeBonus (): void + { + $cx = 230; + $cy = -310; + $radius = 12; + + $expected = array (); + for ($x = $cx - $radius; $x <= $cx + $radius; $x ++) { + for ($y = $cy - $radius; $y <= $cy + $radius; $y ++) { + if (pow ($x - $cx, 2) + pow ($y - $cy, 2) > $radius * $radius) { + continue; + } + + $data = Dolumar_Map_Map::getFreshTileData ($x, $y); + if (in_array ($data[0], array (Dolumar_Map_Map::TILE_TREE, Dolumar_Map_Map::TILE_HAYSTACK, Dolumar_Map_Map::TILE_RESOURCE))) { + $expected[] = $x . ',' . $y; + } + } + } + + $found = array (); + foreach (Dolumar_Map_Map::getBonusTilesAround ($cx, $cy, $radius) as [$x, $y, $location]) { + $found[] = $x . ',' . $y; + $this->assertNotEmpty ($location->getIncomeBonus ()); + $this->assertContains ($location->getBonusKey (), array ('trees', 'haystack', 'stone', 'iron', 'gems')); + } + + $this->assertNotEmpty ($expected); + $this->assertSame ($expected, $found); + } + + public function testOnlyBonusTilesHaveABonusKey (): void + { + $this->assertNull ((new Dolumar_Map_Location (1, 0, 0, 3))->getBonusKey ()); + $this->assertSame ('trees', (new Dolumar_Map_Trees (1, 0, 0, 3))->getBonusKey ()); + $this->assertSame ('haystack', (new Dolumar_Map_Haystack (1, 0, 0, 3))->getBonusKey ()); + $this->assertSame ('iron', (new Dolumar_Map_Resource (2, 0, 0, 3))->getBonusKey ()); + $this->assertSame (array ('iron' => 20), (new Dolumar_Map_Resource (2, 0, 0, 3))->getIncomeBonus ()); + } +} diff --git a/tests/Integration/IncomeBreakdownTest.php b/tests/Integration/IncomeBreakdownTest.php new file mode 100644 index 00000000..2190e8dd --- /dev/null +++ b/tests/Integration/IncomeBreakdownTest.php @@ -0,0 +1,58 @@ +query ("SELECT village FROM map_buildings WHERE buildingType = 1 AND destroyDate = 0 ORDER BY bid LIMIT 1"); + $this->assertNotEmpty ($rows, 'The database needs a village with a town centre'); + + $village = Dolumar_Players_Village::getVillage ($rows[0]['village']); + list ($cx, $cy) = $village->buildings->getTownCenter ()->getLocation (); + + // A spot on a forest tile: the forest itself counts too. + $forest = null; + foreach (Dolumar_Map_Map::getBonusTilesAround ($cx, $cy, $village->getBuildingRadius ()) as $v) + { + if ($v[2]->getBonusKey () === 'trees') + { + $forest = $v; + break; + } + } + $this->assertNotNull ($forest, 'No forest near the town centre'); + + $lumber = Dolumar_Buildings_Building::getBuilding (12, $village->getRace (), $forest[0], $forest[1]); + $lumber->setVillage ($village); + $lumber->setData (0, array ('bLevel' => 2)); + + $breakdown = $lumber->getIncomeBreakdown (); + $income = $lumber->getIncome (); + + $sum = 0; + $tiles = 0; + foreach ($breakdown as $row) + { + $sum += $row['amount']; + if (!empty ($row['image'])) + { + $tiles ++; + $this->assertStringContainsString ('Forest', $row['label']); + } + } + + $this->assertSame (1, $tiles); + $this->assertEquals ($income['wood'], $sum); + + // The placement preview lists that forest tile with its bonus. + $preview = $lumber->getPlacementPreview ($village); + $this->assertContains (array ((int) $forest[0], (int) $forest[1], 20, 'Forest'), $preview['tiles']); + } +} From eba0caf709cde74279e526c5af8cee0ee46ab187 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 09:29:58 +0000 Subject: [PATCH 2/2] Lock engine master b601d96 (placement preview, engine #33) Also brings engine #32 (hide 'Load older messages' when there are none). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WpWbfBTo645HCwsC28PTm7 --- composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index 65546941..3460becc 100644 --- a/composer.lock +++ b/composer.lock @@ -175,12 +175,12 @@ "source": { "type": "git", "url": "https://github.com/CatLabInteractive/dolumar-engine.git", - "reference": "3d7495304c09aafa8b98e7cf045c3c7dd17aee27" + "reference": "b601d969e5065c1e0823977aa840915f4f23a291" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CatLabInteractive/dolumar-engine/zipball/3d7495304c09aafa8b98e7cf045c3c7dd17aee27", - "reference": "3d7495304c09aafa8b98e7cf045c3c7dd17aee27", + "url": "https://api.github.com/repos/CatLabInteractive/dolumar-engine/zipball/b601d969e5065c1e0823977aa840915f4f23a291", + "reference": "b601d969e5065c1e0823977aa840915f4f23a291", "shasum": "" }, "require": { @@ -213,7 +213,7 @@ "source": "https://github.com/CatLabInteractive/dolumar-engine/tree/master", "issues": "https://github.com/CatLabInteractive/dolumar-engine/issues" }, - "time": "2026-11-05T09:35:16+00:00" + "time": "2026-11-06T09:28:51+00:00" }, { "name": "graham-campbell/result-type",