Spend favour on the city: builders, informants, militia
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
34dc85284d
commit
67a5db86ff
5 changed files with 142 additions and 3 deletions
3
app.py
3
app.py
|
|
@ -327,6 +327,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
return "That isn't food"
|
return "That isn't food"
|
||||||
player["food"] = food
|
player["food"] = food
|
||||||
return None
|
return None
|
||||||
|
if path == "/api/favour":
|
||||||
|
return game.spend_favour(world, player, str(body.get("kind")), now)
|
||||||
if path == "/api/perk":
|
if path == "/api/perk":
|
||||||
return game.buy_perk(player, str(body.get("perk")))
|
return game.buy_perk(player, str(body.get("perk")))
|
||||||
if path == "/api/settings":
|
if path == "/api/settings":
|
||||||
|
|
@ -411,6 +413,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"city": city_view(world, now),
|
"city": city_view(world, now),
|
||||||
"upcoming": game.event_view(world, upcoming, now) if upcoming else None,
|
"upcoming": game.event_view(world, upcoming, now) if upcoming else None,
|
||||||
|
"favours": game.favours(world, now),
|
||||||
"history": history,
|
"history": history,
|
||||||
"players": roster(world, now),
|
"players": roster(world, now),
|
||||||
"ledger": world["ledger"][-80:],
|
"ledger": world["ledger"][-80:],
|
||||||
|
|
|
||||||
60
game.py
60
game.py
|
|
@ -903,6 +903,7 @@ def defence(world: dict) -> dict:
|
||||||
"weapons": round(3 * iron + 1.5 * (swords - iron), 1),
|
"weapons": round(3 * iron + 1.5 * (swords - iron), 1),
|
||||||
"shields": 2 * min(armed, stock.get("iron_shield", 0)),
|
"shields": 2 * min(armed, stock.get("iron_shield", 0)),
|
||||||
"arrows": min(100, stock.get("arrows", 0) // 10),
|
"arrows": min(100, stock.get("arrows", 0) // 10),
|
||||||
|
"rallied": city.get("rally", 0),
|
||||||
"garrison": 0,
|
"garrison": 0,
|
||||||
}
|
}
|
||||||
garrison = []
|
garrison = []
|
||||||
|
|
@ -1192,6 +1193,7 @@ def _resolve_raid(world: dict, event: dict, now: float, rng: random.Random) -> N
|
||||||
event["report"] = report
|
event["report"] = report
|
||||||
for player in world["players"].values():
|
for player in world["players"].values():
|
||||||
player["garrison"] = False
|
player["garrison"] = False
|
||||||
|
city["rally"] = city["rallies"] = 0
|
||||||
|
|
||||||
|
|
||||||
def _use_up(stock: dict, item: str, share: float) -> None:
|
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 --------------------------------------------------------------
|
# --- 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:
|
def perk_cost(player: dict, perk: str) -> int:
|
||||||
if perk == "bank_slot":
|
if perk == "bank_slot":
|
||||||
return 500 * 2 ** player["perks"]["bank_slot"]
|
return 500 * 2 ** player["perks"]["bank_slot"]
|
||||||
|
|
|
||||||
|
|
@ -469,7 +469,7 @@ function favourPanel() {
|
||||||
return `<div class="panel"><h2>Favour with the council</h2>
|
return `<div class="panel"><h2>Favour with the council</h2>
|
||||||
<p class="favour">✨ ${fmt(me.favour)} favour · ${fmt(me.donated)} given in total</p>
|
<p class="favour">✨ ${fmt(me.favour)} favour · ${fmt(me.donated)} given in total</p>
|
||||||
<div class="btns"><button class="btn" data-perk="bank_slot" ${me.favour < me.perk_costs.bank_slot ? "disabled" : ""}>+1 bank slot (${fmt(me.perk_costs.bank_slot)} favour)</button></div>
|
<div class="btns"><button class="btn" data-perk="bank_slot" ${me.favour < me.perk_costs.bank_slot ? "disabled" : ""}>+1 bank slot (${fmt(me.perk_costs.bank_slot)} favour)</button></div>
|
||||||
<p class="small muted">More perks to come. The council remembers who helped.</p></div>`;
|
<p class="small muted">Favour can also hire builders, pay informants and rally the militia: see <button class="linkish" data-nav="council">Council & threats</button>.</p></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- town -----------------------------------------------------------------
|
// --- town -----------------------------------------------------------------
|
||||||
|
|
@ -494,7 +494,9 @@ function town() {
|
||||||
if (work) {
|
if (work) {
|
||||||
const spec = C.buildings[work.building];
|
const spec = C.buildings[work.building];
|
||||||
const queued = city.queue.map((key) => esc(C.buildings[key].name)).join(", ");
|
const queued = city.queue.map((key) => esc(C.buildings[key].name)).join(", ");
|
||||||
|
const hire = S.favours.builders;
|
||||||
html += `<div class="banner">🏗️<div class="grow"><b>${work.repair ? "Repairing" : "Building"} ${esc(spec.name)}</b> · done in ${countdown(work.done_at)}
|
html += `<div class="banner">🏗️<div class="grow"><b>${work.repair ? "Repairing" : "Building"} ${esc(spec.name)}</b> · done in ${countdown(work.done_at)}
|
||||||
|
${hire.why ? "" : `<button class="btn sm gold" data-favour="builders" ${S.me.favour < hire.cost ? "disabled" : ""} title="Halve the time left">Hire builders ✨ ${fmt(hire.cost)}</button>`}
|
||||||
<div class="bar gold" style="margin-top:6px"><span data-span="${work.start},${work.done_at}"></span></div>
|
<div class="bar gold" style="margin-top:6px"><span data-span="${work.start},${work.done_at}"></span></div>
|
||||||
${queued ? `<div class="small muted" style="margin-top:6px">Next: ${queued}</div>` : ""}</div></div>`;
|
${queued ? `<div class="small muted" style="margin-top:6px">Next: ${queued}</div>` : ""}</div></div>`;
|
||||||
}
|
}
|
||||||
|
|
@ -566,8 +568,9 @@ function council() {
|
||||||
html += `<div class="vs"><div class="side"><span class="muted small">Our defence</span><b>${fmt(city.defence.total)}</b></div><span class="muted">vs</span><div class="side"><span class="muted small">Raiders</span><b>${strength}</b></div></div>`;
|
html += `<div class="vs"><div class="side"><span class="muted small">Our defence</span><b>${fmt(city.defence.total)}</b></div><span class="muted">vs</span><div class="side"><span class="muted small">Raiders</span><b>${strength}</b></div></div>`;
|
||||||
}
|
}
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
if (ev.kind === "raid") html += raidPrep(ev);
|
|
||||||
}
|
}
|
||||||
|
html += favourSpends();
|
||||||
|
if (ev?.kind === "raid") html += raidPrep(ev);
|
||||||
html += `<div class="section-title">Past events</div>`;
|
html += `<div class="section-title">Past events</div>`;
|
||||||
if (!S.history.length) html += `<p class="muted">Nothing has happened yet.</p>`;
|
if (!S.history.length) html += `<p class="muted">Nothing has happened yet.</p>`;
|
||||||
for (const h of S.history) {
|
for (const h of S.history) {
|
||||||
|
|
@ -582,9 +585,28 @@ function council() {
|
||||||
return html;
|
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 `<div class="favour-option">
|
||||||
|
<span class="ico">${icon}</span>
|
||||||
|
<span class="grow"><b>${title}</b><span class="small muted">${text}</span></span>
|
||||||
|
<span class="btns"><button class="btn sm gold" data-favour="${kind}" ${o.why || short ? "disabled" : ""}
|
||||||
|
title="${esc(o.why || (short ? "Not enough favour" : ""))}">✨ ${fmt(o.cost)}</button></span>
|
||||||
|
</div>`;
|
||||||
|
};
|
||||||
|
return `<div class="panel"><h2>Spend favour on the city · you have ✨ ${fmt(me.favour)}</h2>
|
||||||
|
${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.`)}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function raidPrep(ev) {
|
function raidPrep(ev) {
|
||||||
const city = S.city, me = S.me, d = city.defence;
|
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 reqs = (ev.requests || []).map((r) => {
|
||||||
const mine = me.bank[r.item] || 0;
|
const mine = me.bank[r.item] || 0;
|
||||||
return `<div class="req"><span>${item(r.item).icon}</span><span>${r.item === "bread" ? "Food (any)" : esc(item(r.item).name)} <span class="muted small">${fmt(r.have)} / ${fmt(r.want)}</span></span>
|
return `<div class="req"><span>${item(r.item).icon}</span><span>${r.item === "bread" ? "Food (any)" : esc(item(r.item).name)} <span class="muted small">${fmt(r.have)} / ${fmt(r.want)}</span></span>
|
||||||
|
|
@ -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.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.garrison) return act("/api/garrison", { join: d.garrison === "1" });
|
||||||
if (d.perk) return act("/api/perk", { perk: d.perk });
|
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.food) return act("/api/food", { item: d.food });
|
||||||
if (d.overflow) return act("/api/settings", { overflow: d.overflow });
|
if (d.overflow) return act("/api/settings", { overflow: d.overflow });
|
||||||
if (d.class) { pickedClass = d.class; render(); return; }
|
if (d.class) { pickedClass = d.class; render(); return; }
|
||||||
|
|
|
||||||
|
|
@ -366,3 +366,7 @@ button.sheet-skill:hover { background: var(--panel-2); }
|
||||||
.btn.sm { padding: 6px 11px; }
|
.btn.sm { padding: 6px 11px; }
|
||||||
}
|
}
|
||||||
.linkish { background: none; border: 0; padding: 0; color: var(--xp); text-decoration: underline; font: inherit; }
|
.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; }
|
||||||
|
|
|
||||||
|
|
@ -464,5 +464,54 @@ class NewSkillsTest(unittest.TestCase):
|
||||||
self.assertEqual(p["xp"]["fishing"], 0)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue