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/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",
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'); ?>
=$hourlyIncome?>
=$producing1?>
=$income?>
=$producing2?>
| =$v['label']?> | +=($v['amount'] > 0 && $i > 0 ? '+' : '') . $v['amount']?> | +|
| + | =$this->getText ('totalIncome')?> | +=$total?> | +
=$advice?>
+ + + +=$explanation?>
+ 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']); + } +}