From 1c80bd80f731dd0957cc2d9e0b49e9abff00501d Mon Sep 17 00:00:00 2001 From: rope Date: Mon, 19 Oct 2026 15:21:49 +0100 Subject: [PATCH] Drill the militia; drill sergeant replaces rallying Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++- app.py | 3 +- game.py | 69 ++++++++++++++++++++++++++++++++++++---------- static/app.js | 17 ++++++++---- static/icons.svg | 1 + tests/test_game.py | 37 +++++++++++++++++++------ 6 files changed, 102 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 4a1c1d5..665a22a 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,11 @@ Alpha. One season, bandit raids plus two friendly events, six buildings. replaced from the bank automatically. - **Events:** raids are announced a day or two ahead. Reconnaissance from scouts, informants and the spymaster's guild narrows down when, how strong, - and finally where their camp is, so fighters can sabotage it. Each intel + and finally where their camp is, so fighters can sabotage it. Fighters can + also drill the militia before any raid, up to half a defence per + townsperson. Favour never buys results directly: master builders, + informants and drill sergeants double what players put in for a while, + XP included. Each intel level takes more scouting than the last and more for a bigger band, and levels 2 and 3 only open about a day and eight hours before the raid arrives. Defeat kills or drives diff --git a/app.py b/app.py index 64bb279..38e094b 100644 --- a/app.py +++ b/app.py @@ -102,7 +102,7 @@ def player_view(world: dict, player: dict, now: float) -> dict: view["blessing"] = game.blessing(player) blessed = game.with_potion(view["blessing"], game.potion_effect(player, "combat")) view["fight_damage"] = {a["id"]: round(game.fight_damage(player, a, blessed), 1) - for a in game.ACTIONS if a["skill"] == "combat"} + for a in game.ACTIONS if "difficulty" in a} view["pp"] = int(player["pp"]) view["labour_per_action"] = game.labour_per_action(player) view["levels"] = {skill: game.skill_level(player, skill) for skill in game.SKILL_IDS} @@ -116,6 +116,7 @@ def player_view(world: dict, player: dict, now: float) -> dict: view["action"] = {**player["action"], "duration": game.action_time(world, player, action)} view["times"] = {action["id"]: game.action_time(world, player, action) for action in game.ACTIONS} view["blocked"] = {action["id"]: game.can_start(world, player, action) for action in game.ACTIONS} + view["drill_per_action"] = game.drill_per_action(player) view["recon_gain"] = {action["id"]: game.recon_gain(world, action) for action in game.ACTIONS if action.get("special") == "recon"} return view diff --git a/game.py b/game.py index 6091021..9618f2b 100644 --- a/game.py +++ b/game.py @@ -371,6 +371,7 @@ ACTIONS = [ _action("cook_meat", "cooking", "Cooked meat", 5, 20, 7, {"raw_meat": 1, "logs": 1}, {"cooked_meat": 1}), _action("cook_stew", "cooking", "Stew", 15, 35, 16, {"raw_meat": 1, "vegetables": 2, "logs": 1}, {"stew": 2}), + _action("drill_militia", "combat", "Drill the militia", 1, 50, 10, special="drill"), _action("fight_wolves", "combat", "Wolves in the woods", 1, 60, 14, difficulty=2, damage=2, gold=2, drops=[["raw_meat", 1, 0.6], ["hide", 1, 0.5], ["bones", 1, 1.0]]), _action("fight_scouts", "combat", "Bandit scouts", 10, 75, 30, difficulty=14, damage=6, gold=10, @@ -1016,7 +1017,13 @@ def can_start(world: dict, player: dict, action: dict) -> str | None: blocked = skill_mods(world, action)["blocked"] if blocked: return blocked - if action["skill"] == "combat" and player["hp"] < max_hp(player) * TOO_HURT and not edible(player): + if action.get("special") == "drill": + threat = upcoming_raid(world) + if not threat: + return "No raid to prepare for" + if threat.get("drilled", 0) >= drill_cap(world): + return "The militia is as drilled as it can be" + if "difficulty" in action and player["hp"] < max_hp(player) * TOO_HURT and not edible(player): return "Too hurt to fight. Rest, or bring food" return None @@ -1047,7 +1054,7 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R outputs[item] = int(exact) + (1 if rng.random() < exact - int(exact) else 0) xp *= mods["xp"] - if action["skill"] == "combat": + if "difficulty" in action: blessed = blessing(player) if player["prayers"] and not blessed["cost"]: summary["prayer_out"] = True @@ -1105,6 +1112,15 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R event["recon"] += gain + boost if action.get("special") == "problem" and action["skill"] != "combat": _work_problem(world, player, action, now) + threat = upcoming_raid(world) if action.get("special") == "drill" else None + if threat: + city = world["city"] + gain = drill_per_action(player) + # A drill sergeant doubles the drilling (and the XP) of whoever does it while the boost lasts. + boost = min(city.get("drill_boost", 0), gain) + city["drill_boost"] = city.get("drill_boost", 0) - boost + xp = DRILL_XP * (gain + boost) * mods["xp"] + threat["drilled"] = min(drill_cap(world), threat.get("drilled", 0) + gain + boost) if action.get("special") == "labour": city = world["city"] site = city["construction"] @@ -1125,7 +1141,8 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R if action.get("special") == "offer": city = world["city"] city["devotion"] = city.get("devotion", 0) + action["devotion"] * (1 + 0.25 * building_level(world, "temple")) * bonus - _use_charge(player, action["skill"], summary) + if action.get("special") != "drill": + _use_charge(player, action["skill"], summary) for item, qty in outputs.items(): kept = bank_add(world, player, item, qty, now, summary) @@ -1514,7 +1531,8 @@ def defence(world: dict) -> dict: "weapons": round(0.35 * best_kit(stock, "weapon", "power", armed), 1), "shields": round(0.5 * best_kit(stock, "shield", "armour", armed), 1), "arrows": min(100, stock.get("arrows", 0) // 10), - "rallied": city.get("rally", 0), + "drilled": round(drilled(world), 1), + "rallied": city.get("rally", 0), # favour bought before drilling existed; gone after the next raid "ward": ward(world), "mercenaries": city.get("hired", 0), "garrison": 0, @@ -2492,8 +2510,26 @@ def happenings_view(world: dict, now: float) -> dict: 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 +# Drilling: fighters train the townsfolk before a raid, a little defence per +# session (more for a seasoned fighter), up to half the population. +DRILL_PER_ACTION = 0.5 # plus as much again every 20 combat levels +DRILL_XP = 20 # combat XP per point of defence drilled +DRILL_SHARE = 0.5 # most drilled defence: this much per townsperson +DRILL_SERGEANT_POINTS = 15 # boosted drilling per hire: doubled defence and XP +DRILL_SERGEANT_FAVOUR = 150 # +50% for every further hire against the same threat + + +def drill_per_action(player: dict) -> float: + return DRILL_PER_ACTION * (1 + skill_level(player, "combat") // 20) + + +def drill_cap(world: dict) -> float: + return round(DRILL_SHARE * world["city"]["population"], 1) + + +def drilled(world: dict) -> float: + threat = upcoming_raid(world) + return min(drill_cap(world), threat.get("drilled", 0)) if threat else 0 def favours(world: dict, now: float) -> dict: @@ -2517,10 +2553,13 @@ def favours(world: dict, now: float) -> dict: "recon": INFORMANT_RECON, "boost": round(city.get("recon_boost", 0)), }, - "militia": { - "cost": RALLY_FAVOUR * (city.get("rallies", 0) + 1), - "why": None if raid else "No raid to prepare for", - "rallied": city.get("rally", 0), + "sergeant": { + "cost": round(DRILL_SERGEANT_FAVOUR * (1 + 0.5 * (raid or {}).get("sergeants", 0))), + "why": "No raid to prepare for" if not raid else + "The militia is as drilled as it can be" if drilled(world) >= drill_cap(world) else None, + "drill": DRILL_SERGEANT_POINTS, + "boost": round(city.get("drill_boost", 0), 1), + "drilled": round(drilled(world), 1), "cap": drill_cap(world), }, } @@ -2550,9 +2589,11 @@ def spend_favour(world: dict, player: dict, kind: str, now: float) -> str | None log(world, now, "favour", f"{player['name']} paid an informant: the next {INFORMANT_RECON} " f"reconnaissance counts double", 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"]) + raid = upcoming_raid(world) + raid["sergeants"] = raid.get("sergeants", 0) + 1 + city["drill_boost"] = city.get("drill_boost", 0) + DRILL_SERGEANT_POINTS + log(world, now, "favour", f"{player['name']} hired a drill sergeant: the next {DRILL_SERGEANT_POINTS} " + f"drilled defence counts double", player["id"]) return None @@ -2625,7 +2666,7 @@ ACTION_GLYPHS = { "fight_ruins": ("castle-ruins", "#a8a29e"), "fight_stronghold": ("castle", "#8a8f98"), "fight_green_dragon": ("dragon-head", "#5fae5a"), "fight_blue_dragon": ("dragon-head", "#4f86d6"), "fight_red_dragon": ("dragon-head", "#d0503c"), "fight_black_dragon": ("dragon-head", "#8a8f98"), - "sabotage": ("fire-bomb", "#e0703a"), "recon_roads": ("spyglass", "#6f8fec"), "recon_camps": ("spyglass", "#8a7fd0"), + "sabotage": ("fire-bomb", "#e0703a"), "drill_militia": ("sword-brandish", "#e0a050"), "recon_roads": ("spyglass", "#6f8fec"), "recon_camps": ("spyglass", "#8a7fd0"), "labour": ("hammer-nails", "#d1a46a"), "clear_wolves": ("wolf-head", "#9aa1ab"), "catch_goblins": ("goblin-head", "#8fbf6a"), "drive_bear": ("bear-head", "#8a5a32"), "cull_boars": ("boar", "#a06a4a"), "catch_rats": ("rat", "#9aa1ab"), "clear_rockslide": ("falling-rocks", "#a8a29e"), diff --git a/static/app.js b/static/app.js index 14b848c..4033dae 100644 --- a/static/app.js +++ b/static/app.js @@ -487,12 +487,17 @@ function actionCard(a) { } io += Object.entries(a.outputs).map(([id, q]) => itemChip(id, q)).join(""); let extra = ""; - if (a.skill === "combat") { + if ("difficulty" in a) { const chance = Math.min(0.97, Math.max(0.15, 0.5 + (me.power - a.difficulty) * 0.04)); extra = `
Win chance ${Math.round(chance * 100)}% · about ${me.fight_damage[a.id]} damage a fight${a.gold ? ` · ${a.gold} gold` : ""}
`; if (a.drops) io += a.drops.map(([id, q, odds]) => `${item(id).icon} ${q} ${Math.round(odds * 100)}%`).join(""); if (a.special === "sabotage") extra += `
Each win weakens the coming raid by 3%, up to 30%.
`; } + if (a.special === "drill") { + const s = S.favours.sergeant, boost = s.boost; + extra = `
${skillOf("combat").icon} +${me.drill_per_action} defence for the coming raid · ${fmt(s.drilled)} / ${fmt(s.cap)} drilled${boost ? ` ×2 defence and XP for the next ${fmt(boost)}` : ""}
+
More per session every 20 combat levels. The militia can take up to half a defence per townsperson.
`; + } if (a.special === "recon") { const boost = S.favours.informant.boost; extra = `
${skillOf("reconnaissance").icon} +${+me.recon_gain[a.id].toFixed(2)} recon on the coming threat${boost ? ` ×2 recon and XP for the next ${fmt(boost)}` : ""}
`; @@ -519,7 +524,7 @@ function actionCard(a) {
${!active && why ? esc(why) : ""} ${active ? `` - : ` + : ` `}
`; @@ -617,7 +622,7 @@ function favourPanel() { return `

Favour with the council

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

-

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

`; +

Favour can also hire master builders, informants and drill sergeants: see .

`; } // --- town ----------------------------------------------------------------- @@ -857,13 +862,13 @@ function favourSpends() { return `

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

${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) + "." : `Leads for the scouts: the next ${f.informant.recon} reconnaissance on the coming threat counts double, with double XP${f.informant.boost ? ` (${fmt(f.informant.boost)} still to use)` : ""}. Costs more each time for the same threat.`)} - ${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.`)} + ${option("sergeant", "📯", "Hire a drill sergeant", f.sergeant.why ? esc(f.sergeant.why) + "." : `The next ${f.sergeant.drill} defence fighters drill into the militia counts double, with double combat XP${f.sergeant.boost ? ` (${fmt(f.sergeant.boost)} still to use)` : ""}. Costs more each time for the same threat.`)}
`; } function raidPrep(ev) { const city = S.city, me = S.me, d = city.defence; - const names = { mercenaries: "Hired sellswords", ward: "Divine ward (temple)", 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 names = { mercenaries: "Hired sellswords", ward: "Divine ward (temple)", rallied: "Rallied militia (favour)", drilled: "Drilled militia", 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}${esc(r.label)} ${fmt(r.have)} / ${fmt(r.want)} @@ -874,7 +879,7 @@ function raidPrep(ev) {

Council requests

${reqs}

Arrows, swords and shields in the stockpile arm the defenders: one sword and shield each, best first. Every fight uses up arrows and breaks some of the gear, more when it's close. Food keeps them fighting.

Defence

- ${Object.entries(d.parts).map(([k, v]) => ``).join("")} +
${names[k]}${fmt(v)}
${Object.entries(d.parts).filter(([k, v]) => k !== "rallied" || v).map(([k, v]) => ``).join("")} ${d.starving ? `` : ""}
${names[k]}${fmt(v)}
Hungry (×0.8)

Garrison

${d.garrison.length ? d.garrison.map((g) => `${esc(g.name)} (${g.power})`).join(", ") : `Nobody has signed up yet.`}

diff --git a/static/icons.svg b/static/icons.svg index ccccf1e..0e9b35b 100644 --- a/static/icons.svg +++ b/static/icons.svg @@ -91,6 +91,7 @@ + diff --git a/tests/test_game.py b/tests/test_game.py index a04920f..bb219aa 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -592,20 +592,39 @@ class FavourTest(unittest.TestCase): event["recon"] = game.intel_costs(event)[-1] 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): + def test_drilling_adds_defence_up_to_half_the_population(self): world, p = self.world, self.p - self.assertEqual(game.spend_favour(world, p, "militia", T0), "No raid to prepare for") + self.assertEqual(game.start_action(world, p, "drill_militia", T0), "No raid to prepare for") world["next_event_at"] = T0 game.tick(world, T0, random.Random(2)) + world["city"]["population"] = 20.0 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) + self.assertIsNone(game.start_action(world, p, "drill_militia", T0)) + game.process_player(world, p, T0 + 50 * 4 + 1, random.Random(1)) + self.assertEqual(game.defence(world)["parts"]["drilled"], 2.0) + self.assertEqual(game.defence(world)["total"], before + 2) + self.assertEqual(p["xp"]["combat"], 40) + self.assertEqual(p["hp"], 44) # drilling is not a fight + game.process_player(world, p, T0 + 50 * 40, random.Random(1)) + self.assertEqual(game.defence(world)["parts"]["drilled"], 10.0) + self.assertIsNone(p["action"]) # stops once the militia is fully drilled 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) + self.assertEqual(game.defence(world)["parts"]["drilled"], 0) + + def test_drill_sergeant_doubles_drilling_for_a_while(self): + world, p = self.world, self.p + self.assertEqual(game.spend_favour(world, p, "sergeant", T0), "No raid to prepare for") + world["next_event_at"] = T0 + game.tick(world, T0, random.Random(2)) + self.assertIsNone(game.spend_favour(world, p, "sergeant", T0)) + self.assertEqual(game.defence(world)["parts"]["drilled"], 0) # a sergeant drills nobody by himself + self.assertEqual(game.favours(world, T0)["sergeant"]["cost"], 225) + game.start_action(world, p, "drill_militia", T0) + game.process_player(world, p, T0 + 50 * 4 + 1, random.Random(1)) + self.assertEqual(game.upcoming_raid(world)["drilled"], 4.0) + self.assertEqual(p["xp"]["combat"], 80) + self.assertEqual(world["city"]["drill_boost"], 13) + self.assertEqual(p["favour"], 5000 - 150) def test_not_enough_favour(self): self.p["favour"] = 10