Master builder: favour buys double labour and XP for players, not finished work

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-19 11:01:29 +01:00
parent e6b8c327e8
commit fb4b8bf7c8
4 changed files with 53 additions and 21 deletions

30
game.py
View file

@ -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"])

View file

@ -488,7 +488,10 @@ function actionCard(a) {
}
if (a.special === "recon") extra = `<div class="small muted">+${a.recon} recon on the next event</div>`;
const temple = S.city.buildings.temple?.level || 0;
if (a.special === "labour") extra = `<div class="small makes">${skillOf("construction").icon} +${me.labour_per_action} labour per action</div>`;
if (a.special === "labour") {
const boost = S.favours.builders.boost;
extra = `<div class="small makes">${skillOf("construction").icon} +${me.labour_per_action} labour per action${boost ? ` <b class="boosted">×2 labour and XP for the next ${fmt(boost)}</b>` : ""}</div>`;
}
if (a.special === "bury") extra = `<div class="small makes">${skillOf("prayer").icon} +${a.pp + temple} prayer points</div>`;
if (a.special === "offer") extra = `<div class="small makes">${C.buildings.temple.icon} +${+(a.devotion * (1 + 0.25 * temple)).toFixed(1)} devotion for the city's ward</div>`;
const made = Object.keys(a.outputs).map(itemStats).filter(Boolean);
@ -651,10 +654,10 @@ function siteBanner() {
<span class="muted small">${fmt(work.labour)} / ${fmt(work.needed)} labour</span>
<span class="btns">
${working ? `<span class="btn sm working">Working</span>` : `<button class="btn sm primary" data-start="labour">Work on it</button>`}
${hire.why ? "" : `<button class="btn sm gold" data-favour="builders" ${me.favour < hire.cost ? "disabled" : ""} title="Adds half the labour that's left">Hire builders ${FAVOUR} ${fmt(hire.cost)}</button>`}
${hire.why ? "" : `<button class="btn sm gold" data-favour="builders" ${me.favour < hire.cost ? "disabled" : ""} title="The next ${hire.labour} labour on the site counts double, with double XP">Master builder ${FAVOUR} ${fmt(hire.cost)}</button>`}
</span></div>
<div class="bar gold" style="margin-top:6px"><span style="width:${Math.min(100, work.labour / work.needed * 100)}%"></span></div>
<div class="small muted" style="margin-top:6px">${builders.length ? `On site: ${builders.join(", ")}. ` : ""}The townsfolk add about ${fmt(city.townsfolk_labour)} labour an hour.${queued ? ` Next: ${queued}.` : ""}</div>
<div class="small muted" style="margin-top:6px">${builders.length ? `On site: ${builders.join(", ")}. ` : ""}${hire.boost ? `<b class="boosted">Master builder on site: the next ${fmt(hire.boost)} labour counts double, with double XP.</b> ` : ""}The townsfolk add about ${fmt(city.townsfolk_labour)} labour an hour.${queued ? ` Next: ${queued}.` : ""}</div>
</div></div>`;
}
@ -804,7 +807,7 @@ function favourSpends() {
</div>`;
};
return `<div class="panel"><h2>Spend favour on the city · you have ${FAVOUR} ${fmt(me.favour)}</h2>
${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.`)}
</div>`;

View file

@ -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; }

View file

@ -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")