From fb4b8bf7c893c1e265ac7255601dca55e48d709d Mon Sep 17 00:00:00 2001 From: rope Date: Mon, 19 Oct 2026 11:01:29 +0100 Subject: [PATCH] Master builder: favour buys double labour and XP for players, not finished work Co-Authored-By: Claude Opus 5.5 --- game.py | 30 ++++++++++++++++++++---------- static/app.js | 11 +++++++---- static/styles.css | 1 + tests/test_game.py | 32 +++++++++++++++++++++++++------- 4 files changed, 53 insertions(+), 21 deletions(-) diff --git a/game.py b/game.py index 088e4e4..f095051 100644 --- a/game.py +++ b/game.py @@ -1084,9 +1084,15 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R if action.get("special") == "problem" and action["skill"] != "combat": _work_problem(world, player, action, now) if action.get("special") == "labour": - site = world["city"]["construction"] - site["labour"] += labour_per_action(player) - summary["labour"] += labour_per_action(player) + city = world["city"] + site = city["construction"] + gain = labour_per_action(player) + # A master builder doubles the labour (and the XP) of whoever works while the boost lasts. + boost = min(city.get("site_boost", 0), gain) + city["site_boost"] = city.get("site_boost", 0) - boost + xp *= 1 + boost / gain + site["labour"] += gain + boost + summary["labour"] += gain + boost if site["labour"] >= site["needed"]: _finish_construction(world, now) _start_next(world, now) @@ -1348,6 +1354,7 @@ def _start_next(world: dict, now: float) -> None: else: del building["fund"][item] repair = building["damaged"] + city["boosts_bought"] = 0 # master builders cost the base price again on a new site city["construction"] = {"building": key, "repair": repair, "start": now, "labour": 0.0, "needed": labour_needed(building["level"], repair)} verb = "Repairs to" if repair else "Work on" @@ -2295,7 +2302,8 @@ def happenings_view(world: dict, now: float) -> dict: # --- favour -------------------------------------------------------------- -BUILDER_FAVOUR_PER_LABOUR = 0.67 +MASTER_BUILDER_LABOUR = 50 # boosted labour per hire: doubled labour and XP for whoever works it +MASTER_BUILDER_FAVOUR = 150 # +50% for every further hire on the same site INFORMANT_FAVOUR = 250 RALLY_FAVOUR = 150 RALLY_DEFENCE = 10 @@ -2305,14 +2313,14 @@ def favours(world: dict, now: float) -> dict: """What favour can buy for the city right now: cost, and why not if it can't.""" city = world["city"] work = city["construction"] - remaining = max(0.0, work["needed"] - work["labour"]) if work else 0 event = upcoming_event(world) raid = upcoming_raid(world) return { "builders": { - "cost": max(25, math.ceil(remaining / 2 * BUILDER_FAVOUR_PER_LABOUR)), - "why": None if work and remaining > 1 else "Nothing is being built", - "labour": round(remaining / 2), + "cost": round(MASTER_BUILDER_FAVOUR * (1 + 0.5 * city.get("boosts_bought", 0))), + "why": None if work else "Nothing is being built", + "labour": MASTER_BUILDER_LABOUR, + "boost": round(city.get("site_boost", 0)), }, "informant": { "cost": INFORMANT_FAVOUR, @@ -2340,9 +2348,11 @@ def spend_favour(world: dict, player: dict, kind: str, now: float) -> str | None city = world["city"] if kind == "builders": work = city["construction"] - work["labour"] += (work["needed"] - work["labour"]) / 2 + city["site_boost"] = city.get("site_boost", 0) + MASTER_BUILDER_LABOUR + city["boosts_bought"] = city.get("boosts_bought", 0) + 1 name = BUILDINGS[work["building"]]["name"].lower() - log(world, now, "favour", f"{player['name']} hired extra builders for the {name}", player["id"]) + log(world, now, "favour", f"{player['name']} hired a master builder for the {name}: " + f"the next {MASTER_BUILDER_LABOUR} labour counts double", player["id"]) elif kind == "informant": upcoming_event(world)["recon"] += RECON_PER_INTEL log(world, now, "favour", f"{player['name']} paid an informant for news from the roads", player["id"]) diff --git a/static/app.js b/static/app.js index ef5c5c8..1d75fba 100644 --- a/static/app.js +++ b/static/app.js @@ -488,7 +488,10 @@ function actionCard(a) { } if (a.special === "recon") extra = `
+${a.recon} recon on the next event
`; const temple = S.city.buildings.temple?.level || 0; - if (a.special === "labour") extra = `
${skillOf("construction").icon} +${me.labour_per_action} labour per action
`; + if (a.special === "labour") { + const boost = S.favours.builders.boost; + extra = `
${skillOf("construction").icon} +${me.labour_per_action} labour per action${boost ? ` ร—2 labour and XP for the next ${fmt(boost)}` : ""}
`; + } if (a.special === "bury") extra = `
${skillOf("prayer").icon} +${a.pp + temple} prayer points
`; if (a.special === "offer") extra = `
${C.buildings.temple.icon} +${+(a.devotion * (1 + 0.25 * temple)).toFixed(1)} devotion for the city's ward
`; const made = Object.keys(a.outputs).map(itemStats).filter(Boolean); @@ -651,10 +654,10 @@ function siteBanner() { ${fmt(work.labour)} / ${fmt(work.needed)} labour ${working ? `Working` : ``} - ${hire.why ? "" : ``} + ${hire.why ? "" : ``}
-
${builders.length ? `On site: ${builders.join(", ")}. ` : ""}The townsfolk add about ${fmt(city.townsfolk_labour)} labour an hour.${queued ? ` Next: ${queued}.` : ""}
+
${builders.length ? `On site: ${builders.join(", ")}. ` : ""}${hire.boost ? `Master builder on site: the next ${fmt(hire.boost)} labour counts double, with double XP. ` : ""}The townsfolk add about ${fmt(city.townsfolk_labour)} labour an hour.${queued ? ` Next: ${queued}.` : ""}
`; } @@ -804,7 +807,7 @@ function favourSpends() { `; }; return `

Spend favour on the city ยท you have ${FAVOUR} ${fmt(me.favour)}

- ${option("builders", "๐Ÿ—๏ธ", "Hire extra builders", f.builders.why ? esc(f.builders.why) + "." : `Adds ${fmt(f.builders.labour)} labour to the current building, half of what's left.`)} + ${option("builders", "๐Ÿ—๏ธ", "Hire a master builder", f.builders.why ? esc(f.builders.why) + "." : `The next ${f.builders.labour} labour anyone puts into the site counts double, with double Construction XP${f.builders.boost ? ` (${fmt(f.builders.boost)} still to use)` : ""}. Costs more each time on the same site.`)} ${option("informant", "๐Ÿ•ต๏ธ", "Pay an informant", f.informant.why ? esc(f.informant.why) + "." : "One more level of intel on what's coming.")} ${option("militia", "๐Ÿ“ฏ", "Rally the militia", f.militia.why ? esc(f.militia.why) + "." : `+10 defence for the coming raid${f.militia.rallied ? ` (+${f.militia.rallied} so far)` : ""}. Costs more each time.`)}
`; diff --git a/static/styles.css b/static/styles.css index b8cf834..11c804d 100644 --- a/static/styles.css +++ b/static/styles.css @@ -406,3 +406,4 @@ button.sheet-skill:hover { background: var(--panel-2); } .credit a { color: var(--muted); } .btn.working { color: var(--good); border-color: rgba(92, 191, 106, .5); background: rgba(92, 191, 106, .1); cursor: default; } .banner-ico { font-size: 22px; display: flex; } +.boosted { color: var(--gold); font-weight: 600; } diff --git a/tests/test_game.py b/tests/test_game.py index 7a12c4a..11c44c8 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -519,15 +519,33 @@ class FavourTest(unittest.TestCase): self.p = self.world["players"]["fred"] self.p["favour"] = 5000 - def test_builders_do_half_the_work_left(self): + def test_master_builder_doubles_labour_and_xp_for_a_while(self): world, p = self.world, self.p self.assertEqual(game.spend_favour(world, p, "builders", T0), "Nothing is being built") - world["city"]["construction"] = {"building": "walls", "repair": False, "start": T0, "labour": 60.0, "needed": 360} - cost = game.favours(world, T0)["builders"]["cost"] - self.assertEqual(cost, 101) # 150 labour at 0.67 favour each + world["city"]["construction"] = {"building": "walls", "repair": False, "start": T0, "labour": 0.0, "needed": 360} + self.assertEqual(game.favours(world, T0)["builders"]["cost"], 150) self.assertIsNone(game.spend_favour(world, p, "builders", T0)) - self.assertEqual(world["city"]["construction"]["labour"], 210) - self.assertEqual(p["favour"], 5000 - 101) + self.assertEqual(game.favours(world, T0)["builders"]["cost"], 225) + self.assertEqual(world["city"]["construction"]["labour"], 0) # nothing is built for you + game.start_action(world, p, "labour", T0) + summary = game.process_player(world, p, T0 + 24 * 60, random.Random(1)) # pioneer: 24s an action + self.assertEqual(summary["done"], 60) + self.assertEqual(summary["labour"], 60 + 50) # the first 50 labour counted double + self.assertEqual(world["city"]["site_boost"], 0) + self.assertEqual(summary["xp"]["construction"], 10 * 60 + 10 * 50) + + def test_master_builder_price_resets_on_a_new_site(self): + world, p = self.world, self.p + city = world["city"] + city["construction"] = {"building": "walls", "repair": False, "start": T0, "labour": 359.0, "needed": 360} + game.spend_favour(world, p, "builders", T0) + city["buildings"]["houses"]["fund"] = {k: v for k, v in game.build_cost("houses", 1).items()} + city["queue"] = ["houses"] + city["construction"]["labour"] = 360 + game.tick(world, T0 + 1, random.Random(1)) + self.assertEqual(city["construction"]["building"], "houses") + self.assertEqual(game.favours(world, T0)["builders"]["cost"], 150) + self.assertEqual(city["site_boost"], 50) # unused boost carries over def test_informant_raises_intel(self): world, p = self.world, self.p @@ -558,7 +576,7 @@ class FavourTest(unittest.TestCase): def test_not_enough_favour(self): self.p["favour"] = 10 self.world["city"]["construction"] = {"building": "walls", "repair": False, "start": T0, "labour": 0.0, "needed": 360} - self.assertEqual(game.spend_favour(self.world, self.p, "builders", T0), "Needs 121 favour") + self.assertEqual(game.spend_favour(self.world, self.p, "builders", T0), "Needs 150 favour")