From 67a5db86ffd5d02a5725a0a2ab5c07782c687c10 Mon Sep 17 00:00:00 2001 From: rope Date: Sun, 18 Oct 2026 19:48:22 +0100 Subject: [PATCH] Spend favour on the city: builders, informants, militia Co-Authored-By: Claude Opus 5.5 --- app.py | 3 +++ game.py | 60 ++++++++++++++++++++++++++++++++++++++++++++++ static/app.js | 29 +++++++++++++++++++--- static/styles.css | 4 ++++ tests/test_game.py | 49 +++++++++++++++++++++++++++++++++++++ 5 files changed, 142 insertions(+), 3 deletions(-) diff --git a/app.py b/app.py index a9700de..6d749a0 100644 --- a/app.py +++ b/app.py @@ -327,6 +327,8 @@ class Handler(BaseHTTPRequestHandler): return "That isn't food" player["food"] = food return None + if path == "/api/favour": + return game.spend_favour(world, player, str(body.get("kind")), now) if path == "/api/perk": return game.buy_perk(player, str(body.get("perk"))) if path == "/api/settings": @@ -411,6 +413,7 @@ class Handler(BaseHTTPRequestHandler): "summary": summary, "city": city_view(world, now), "upcoming": game.event_view(world, upcoming, now) if upcoming else None, + "favours": game.favours(world, now), "history": history, "players": roster(world, now), "ledger": world["ledger"][-80:], diff --git a/game.py b/game.py index 7989606..56d8dac 100644 --- a/game.py +++ b/game.py @@ -903,6 +903,7 @@ def defence(world: dict) -> dict: "weapons": round(3 * iron + 1.5 * (swords - iron), 1), "shields": 2 * min(armed, stock.get("iron_shield", 0)), "arrows": min(100, stock.get("arrows", 0) // 10), + "rallied": city.get("rally", 0), "garrison": 0, } garrison = [] @@ -1192,6 +1193,7 @@ def _resolve_raid(world: dict, event: dict, now: float, rng: random.Random) -> N event["report"] = report for player in world["players"].values(): player["garrison"] = False + city["rally"] = city["rallies"] = 0 def _use_up(stock: dict, item: str, share: float) -> None: @@ -1203,6 +1205,64 @@ def _use_up(stock: dict, item: str, share: float) -> None: # --- favour -------------------------------------------------------------- +BUILDER_FAVOUR_PER_HOUR = 40 +INFORMANT_FAVOUR = 250 +RALLY_FAVOUR = 150 +RALLY_DEFENCE = 10 + + +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"] + hours = (work["done_at"] - now) * speed(world) / HOUR if work else 0 + event = upcoming_event(world) + raid = upcoming_raid(world) + return { + "builders": { + "cost": max(25, math.ceil(hours * BUILDER_FAVOUR_PER_HOUR)), + "why": None if work and hours > 0.05 else "Nothing is being built", + "saves": hours / 2, + }, + "informant": { + "cost": INFORMANT_FAVOUR, + "why": "No news to dig into" if not event else + "Nothing more to learn" if intel_level(world, event) >= 3 else None, + }, + "militia": { + "cost": RALLY_FAVOUR * (city.get("rallies", 0) + 1), + "why": None if raid else "No raid to prepare for", + "rallied": city.get("rally", 0), + }, + } + + +def spend_favour(world: dict, player: dict, kind: str, now: float) -> str | None: + """Turn a player's favour into help for the whole city.""" + option = favours(world, now).get(kind) + if not option: + return "No such favour" + if option["why"]: + return option["why"] + if player["favour"] < option["cost"]: + return f"Needs {option['cost']:,} favour" + player["favour"] -= option["cost"] + city = world["city"] + if kind == "builders": + work = city["construction"] + work["done_at"] = now + (work["done_at"] - now) / 2 + name = BUILDINGS[work["building"]]["name"].lower() + log(world, now, "favour", f"{player['name']} hired extra builders for the {name}", 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"]) + else: + city["rally"] = city.get("rally", 0) + RALLY_DEFENCE + city["rallies"] = city.get("rallies", 0) + 1 + log(world, now, "favour", f"{player['name']} rallied the militia (+{RALLY_DEFENCE} defence for the coming raid)", player["id"]) + return None + + def perk_cost(player: dict, perk: str) -> int: if perk == "bank_slot": return 500 * 2 ** player["perks"]["bank_slot"] diff --git a/static/app.js b/static/app.js index 9d9ecf6..e7c6a71 100644 --- a/static/app.js +++ b/static/app.js @@ -469,7 +469,7 @@ function favourPanel() { return `

Favour with the council

✨ ${fmt(me.favour)} favour · ${fmt(me.donated)} given in total

-

More perks to come. The council remembers who helped.

`; +

Favour can also hire builders, pay informants and rally the militia: see .

`; } // --- town ----------------------------------------------------------------- @@ -494,7 +494,9 @@ function town() { if (work) { const spec = C.buildings[work.building]; const queued = city.queue.map((key) => esc(C.buildings[key].name)).join(", "); + const hire = S.favours.builders; html += ``; } @@ -566,8 +568,9 @@ function council() { html += `
Our defence${fmt(city.defence.total)}
vs
Raiders${strength}
`; } html += ``; - if (ev.kind === "raid") html += raidPrep(ev); } + html += favourSpends(); + if (ev?.kind === "raid") html += raidPrep(ev); html += `
Past events
`; if (!S.history.length) html += `

Nothing has happened yet.

`; for (const h of S.history) { @@ -582,9 +585,28 @@ function council() { return html; } +function favourSpends() { + const f = S.favours, me = S.me; + const option = (kind, icon, title, text) => { + const o = f[kind]; + const short = !o.why && me.favour < o.cost; + return `
+ ${icon} + ${title}${text} + +
`; + }; + return `

Spend favour on the city · you have ✨ ${fmt(me.favour)}

+ ${option("builders", "🏗️", "Hire extra builders", f.builders.why ? esc(f.builders.why) + "." : `Halves the time left on the current building (saves about ${dur(Math.round(f.builders.saves * 60) * 60)}).`)} + ${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.`)} +
`; +} + function raidPrep(ev) { const city = S.city, me = S.me, d = city.defence; - const names = { townsfolk: "Townsfolk with pitchforks", walls: "Walls", militia: "Militia (barracks)", weapons: "Swords in the armoury", shields: "Shields in the armoury", arrows: "Arrows", garrison: "Garrison" }; + const names = { rallied: "Rallied militia (favour)", townsfolk: "Townsfolk with pitchforks", walls: "Walls", militia: "Militia (barracks)", weapons: "Swords in the armoury", shields: "Shields in the armoury", arrows: "Arrows", garrison: "Garrison" }; const reqs = (ev.requests || []).map((r) => { const mine = me.bank[r.item] || 0; return `
${item(r.item).icon}${r.item === "bread" ? "Food (any)" : esc(item(r.item).name)} ${fmt(r.have)} / ${fmt(r.want)} @@ -786,6 +808,7 @@ document.addEventListener("click", (e) => { if (d.fund) return act("/api/fund", { building: d.fund, item: d.item, source: d.source, qty: "all" }); if (d.garrison) return act("/api/garrison", { join: d.garrison === "1" }); if (d.perk) return act("/api/perk", { perk: d.perk }); + if (d.favour) return act("/api/favour", { kind: d.favour }); if (d.food) return act("/api/food", { item: d.food }); if (d.overflow) return act("/api/settings", { overflow: d.overflow }); if (d.class) { pickedClass = d.class; render(); return; } diff --git a/static/styles.css b/static/styles.css index 15170af..0fb9ede 100644 --- a/static/styles.css +++ b/static/styles.css @@ -366,3 +366,7 @@ button.sheet-skill:hover { background: var(--panel-2); } .btn.sm { padding: 6px 11px; } } .linkish { background: none; border: 0; padding: 0; color: var(--xp); text-decoration: underline; font: inherit; } +.favour-option { display: flex; align-items: center; gap: 12px; padding: 8px 0; border-top: 1px solid var(--line); } +.favour-option:first-of-type { border-top: 0; } +.favour-option .ico { font-size: 24px; width: 30px; text-align: center; } +.favour-option .grow { display: flex; flex-direction: column; } diff --git a/tests/test_game.py b/tests/test_game.py index a7d7d5f..4bddc9e 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -464,5 +464,54 @@ class NewSkillsTest(unittest.TestCase): self.assertEqual(p["xp"]["fishing"], 0) + +class FavourTest(unittest.TestCase): + def setUp(self): + self.world = world_with(("fred", "pioneer")) + self.p = self.world["players"]["fred"] + self.p["favour"] = 5000 + + def test_builders_halve_the_time_left(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, "done_at": T0 + 4 * 3600} + cost = game.favours(world, T0)["builders"]["cost"] + self.assertEqual(cost, 160) + self.assertIsNone(game.spend_favour(world, p, "builders", T0)) + self.assertEqual(world["city"]["construction"]["done_at"], T0 + 2 * 3600) + self.assertEqual(p["favour"], 5000 - 160) + + def test_informant_raises_intel(self): + world, p = self.world, self.p + self.assertEqual(game.spend_favour(world, p, "informant", T0), "No news to dig into") + world["next_event_at"] = T0 + game.tick(world, T0, random.Random(2)) + event = game.upcoming_event(world) + for level in (1, 2, 3): + self.assertIsNone(game.spend_favour(world, p, "informant", T0)) + self.assertEqual(game.intel_level(world, event), level) + self.assertEqual(game.spend_favour(world, p, "informant", T0), "Nothing more to learn") + + def test_rallies_cost_more_each_time_and_end_with_the_raid(self): + world, p = self.world, self.p + self.assertEqual(game.spend_favour(world, p, "militia", T0), "No raid to prepare for") + world["next_event_at"] = T0 + game.tick(world, T0, random.Random(2)) + before = game.defence(world)["total"] + game.spend_favour(world, p, "militia", T0) + self.assertEqual(game.favours(world, T0)["militia"]["cost"], 300) + game.spend_favour(world, p, "militia", T0) + self.assertEqual(game.defence(world)["total"], before + 20) + self.assertEqual(p["favour"], 5000 - 150 - 300) + game.tick(world, game.upcoming_raid(world)["fire_at"], random.Random(1)) + self.assertEqual(game.defence(world)["parts"]["rallied"], 0) + self.assertEqual(game.favours(world, T0)["militia"]["cost"], 150) + + def test_not_enough_favour(self): + self.p["favour"] = 10 + self.world["city"]["construction"] = {"building": "walls", "repair": False, "start": T0, "done_at": T0 + 3600} + self.assertEqual(game.spend_favour(self.world, self.p, "builders", T0), "Needs 40 favour") + + if __name__ == "__main__": unittest.main()