Drilling scales with the threat, like scouting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-19 15:35:35 +01:00
parent 1c80bd80f7
commit b248a030ea
4 changed files with 50 additions and 31 deletions

View file

@ -51,8 +51,8 @@ Alpha. One season, bandit raids plus two friendly events, six buildings.
- **Events:** raids are announced a day or two ahead. Reconnaissance from - **Events:** raids are announced a day or two ahead. Reconnaissance from
scouts, informants and the spymaster's guild narrows down when, how strong, scouts, informants and the spymaster's guild narrows down when, how strong,
and finally where their camp is, so fighters can sabotage it. Fighters can 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 also drill the militia before any raid: a full drill takes as much work as
townsperson. Favour never buys results directly: master builders, full intel on that threat and adds one defence per townsperson. Favour never buys results directly: master builders,
informants and drill sergeants double what players put in for a while, informants and drill sergeants double what players put in for a while,
XP included. Each intel XP included. Each intel
level takes more scouting than the last and more for a bigger band, and level takes more scouting than the last and more for a bigger band, and

37
game.py
View file

@ -371,7 +371,7 @@ ACTIONS = [
_action("cook_meat", "cooking", "Cooked meat", 5, 20, 7, {"raw_meat": 1, "logs": 1}, {"cooked_meat": 1}), _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, _action("cook_stew", "cooking", "Stew", 15, 35, 16,
{"raw_meat": 1, "vegetables": 2, "logs": 1}, {"stew": 2}), {"raw_meat": 1, "vegetables": 2, "logs": 1}, {"stew": 2}),
_action("drill_militia", "combat", "Drill the militia", 1, 50, 10, special="drill"), _action("drill_militia", "combat", "Drill the militia", 1, 60, 12, special="drill"),
_action("fight_wolves", "combat", "Wolves in the woods", 1, 60, 14, difficulty=2, damage=2, gold=2, _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]]), 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, _action("fight_scouts", "combat", "Bandit scouts", 10, 75, 30, difficulty=14, damage=6, gold=10,
@ -1021,7 +1021,7 @@ def can_start(world: dict, player: dict, action: dict) -> str | None:
threat = upcoming_raid(world) threat = upcoming_raid(world)
if not threat: if not threat:
return "No raid to prepare for" return "No raid to prepare for"
if threat.get("drilled", 0) >= drill_cap(world): if threat.get("drilled", 0) >= drill_needed(threat):
return "The militia is as drilled as it can be" 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): 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 "Too hurt to fight. Rest, or bring food"
@ -1120,7 +1120,7 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
boost = min(city.get("drill_boost", 0), gain) boost = min(city.get("drill_boost", 0), gain)
city["drill_boost"] = city.get("drill_boost", 0) - boost city["drill_boost"] = city.get("drill_boost", 0) - boost
xp = DRILL_XP * (gain + boost) * mods["xp"] xp = DRILL_XP * (gain + boost) * mods["xp"]
threat["drilled"] = min(drill_cap(world), threat.get("drilled", 0) + gain + boost) threat["drilled"] = min(drill_needed(threat), threat.get("drilled", 0) + gain + boost)
if action.get("special") == "labour": if action.get("special") == "labour":
city = world["city"] city = world["city"]
site = city["construction"] site = city["construction"]
@ -2510,17 +2510,22 @@ 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_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 MASTER_BUILDER_FAVOUR = 150 # +50% for every further hire on the same site
INFORMANT_FAVOUR = 250 INFORMANT_FAVOUR = 250
# Drilling: fighters train the townsfolk before a raid, a little defence per # Drilling: fighters train the townsfolk before a raid. A full drill takes as
# session (more for a seasoned fighter), up to half the population. # many points as full intel on the same threat, so fighters and scouts put in
DRILL_PER_ACTION = 0.5 # plus as much again every 20 combat levels # about the same time; the defence it's worth grows with the population.
DRILL_XP = 20 # combat XP per point of defence drilled DRILL_PER_ACTION = 1.0 # drill points per session, +25% every 20 combat levels
DRILL_SHARE = 0.5 # most drilled defence: this much per townsperson DRILL_XP = 12 # combat XP per drill point
DRILL_SERGEANT_POINTS = 15 # boosted drilling per hire: doubled defence and XP DRILL_SHARE = 1.0 # defence of a fully drilled militia, per townsperson
DRILL_SERGEANT_POINTS = 30 # boosted drilling per hire: doubled points and XP
DRILL_SERGEANT_FAVOUR = 150 # +50% for every further hire against the same threat DRILL_SERGEANT_FAVOUR = 150 # +50% for every further hire against the same threat
def drill_per_action(player: dict) -> float: def drill_per_action(player: dict) -> float:
return DRILL_PER_ACTION * (1 + skill_level(player, "combat") // 20) return DRILL_PER_ACTION * (1 + 0.25 * (skill_level(player, "combat") // 20))
def drill_needed(threat: dict) -> int:
return intel_costs(threat)[-1]
def drill_cap(world: dict) -> float: def drill_cap(world: dict) -> float:
@ -2528,8 +2533,11 @@ def drill_cap(world: dict) -> float:
def drilled(world: dict) -> float: def drilled(world: dict) -> float:
"""Defence the drilling is worth so far: a share of the cap, as far as the drill has got."""
threat = upcoming_raid(world) threat = upcoming_raid(world)
return min(drill_cap(world), threat.get("drilled", 0)) if threat else 0 if not threat:
return 0
return drill_cap(world) * min(1.0, threat.get("drilled", 0) / drill_needed(threat))
def favours(world: dict, now: float) -> dict: def favours(world: dict, now: float) -> dict:
@ -2556,10 +2564,11 @@ def favours(world: dict, now: float) -> dict:
"sergeant": { "sergeant": {
"cost": round(DRILL_SERGEANT_FAVOUR * (1 + 0.5 * (raid or {}).get("sergeants", 0))), "cost": round(DRILL_SERGEANT_FAVOUR * (1 + 0.5 * (raid or {}).get("sergeants", 0))),
"why": "No raid to prepare for" if not raid else "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, "The militia is as drilled as it can be" if raid.get("drilled", 0) >= drill_needed(raid) else None,
"drill": DRILL_SERGEANT_POINTS, "drill": DRILL_SERGEANT_POINTS,
"boost": round(city.get("drill_boost", 0), 1), "boost": round(city.get("drill_boost", 0), 1),
"drilled": round(drilled(world), 1), "cap": drill_cap(world), "points": int((raid or {}).get("drilled", 0)), "needed": drill_needed(raid) if raid else 0,
"defence": round(drilled(world), 1), "cap": drill_cap(world),
}, },
} }
@ -2593,7 +2602,7 @@ def spend_favour(world: dict, player: dict, kind: str, now: float) -> str | None
raid["sergeants"] = raid.get("sergeants", 0) + 1 raid["sergeants"] = raid.get("sergeants", 0) + 1
city["drill_boost"] = city.get("drill_boost", 0) + DRILL_SERGEANT_POINTS 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} " log(world, now, "favour", f"{player['name']} hired a drill sergeant: the next {DRILL_SERGEANT_POINTS} "
f"drilled defence counts double", player["id"]) f"drill points count double", player["id"])
return None return None

View file

@ -495,8 +495,9 @@ function actionCard(a) {
} }
if (a.special === "drill") { if (a.special === "drill") {
const s = S.favours.sergeant, boost = s.boost; const s = S.favours.sergeant, boost = s.boost;
extra = `<div class="small makes">${skillOf("combat").icon} +${me.drill_per_action} defence for the coming raid · ${fmt(s.drilled)} / ${fmt(s.cap)} drilled${boost ? ` <b class="boosted">×2 defence and XP for the next ${fmt(boost)}</b>` : ""}</div> extra = !s.needed ? `<div class="small muted">Fighters drill the townsfolk while a raid is coming. A fully drilled militia adds one defence per townsperson.</div>`
<div class="small muted">More per session every 20 combat levels. The militia can take up to half a defence per townsperson.</div>`; : `<div class="small makes">${skillOf("combat").icon} +${+me.drill_per_action.toFixed(2)} drill · ${fmt(s.points)} / ${fmt(s.needed)} points, worth ${fmt(s.defence)} of ${fmt(s.cap)} defence${boost ? ` <b class="boosted">×2 drill and XP for the next ${fmt(boost)}</b>` : ""}</div>
<div class="small muted">A bigger band needs more drilling. A fully drilled militia adds one defence per townsperson.</div>`;
} }
if (a.special === "recon") { if (a.special === "recon") {
const boost = S.favours.informant.boost; const boost = S.favours.informant.boost;
@ -862,7 +863,7 @@ function favourSpends() {
return `<div class="panel"><h2>Spend favour on the city · you have ${FAVOUR} ${fmt(me.favour)}</h2> return `<div class="panel"><h2>Spend favour on the city · you have ${FAVOUR} ${fmt(me.favour)}</h2>
${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("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("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("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.`)} ${option("sergeant", "📯", "Hire a drill sergeant", f.sergeant.why ? esc(f.sergeant.why) + "." : `The next ${f.sergeant.drill} drill points fighters put into the militia count double, with double combat XP${f.sergeant.boost ? ` (${fmt(f.sergeant.boost)} still to use)` : ""}. Costs more each time for the same threat.`)}
</div>`; </div>`;
} }

View file

@ -592,25 +592,34 @@ class FavourTest(unittest.TestCase):
event["recon"] = game.intel_costs(event)[-1] event["recon"] = game.intel_costs(event)[-1]
self.assertEqual(game.spend_favour(world, p, "informant", T0), "Nothing more to learn") self.assertEqual(game.spend_favour(world, p, "informant", T0), "Nothing more to learn")
def test_drilling_adds_defence_up_to_half_the_population(self): def test_drilling_takes_as_long_as_the_scouting(self):
world, p = self.world, self.p world, p = self.world, self.p
self.assertEqual(game.start_action(world, p, "drill_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 world["next_event_at"] = T0
game.tick(world, T0, random.Random(2)) game.tick(world, T0, random.Random(2))
raid = game.upcoming_raid(world)
world["city"]["population"] = 20.0 world["city"]["population"] = 20.0
needed = game.drill_needed(raid)
self.assertEqual(needed, game.intel_costs(raid)[-1])
before = game.defence(world)["total"] before = game.defence(world)["total"]
self.assertIsNone(game.start_action(world, p, "drill_militia", T0)) self.assertIsNone(game.start_action(world, p, "drill_militia", T0))
game.process_player(world, p, T0 + 50 * 4 + 1, random.Random(1)) game.process_player(world, p, T0 + 60 * 10 + 1, random.Random(1))
self.assertEqual(game.defence(world)["parts"]["drilled"], 2.0) self.assertEqual(raid["drilled"], 10)
self.assertEqual(game.defence(world)["total"], before + 2) self.assertAlmostEqual(game.defence(world)["parts"]["drilled"], round(20 * 10 / needed, 1))
self.assertEqual(p["xp"]["combat"], 40) self.assertAlmostEqual(game.defence(world)["total"], before + 20 * 10 / needed, places=0)
self.assertEqual(p["xp"]["combat"], 120)
self.assertEqual(p["hp"], 44) # drilling is not a fight self.assertEqual(p["hp"], 44) # drilling is not a fight
game.process_player(world, p, T0 + 50 * 40, random.Random(1)) game.process_player(world, p, T0 + 60 * (needed + 5), random.Random(1))
self.assertEqual(game.defence(world)["parts"]["drilled"], 10.0) self.assertEqual(game.defence(world)["parts"]["drilled"], 20.0)
self.assertIsNone(p["action"]) # stops once the militia is fully drilled self.assertIsNone(p["action"]) # stops once the militia is fully drilled
game.tick(world, game.upcoming_raid(world)["fire_at"], random.Random(1)) game.tick(world, raid["fire_at"], random.Random(1))
self.assertEqual(game.defence(world)["parts"]["drilled"], 0) self.assertEqual(game.defence(world)["parts"]["drilled"], 0)
def test_seasoned_fighters_drill_a_little_faster(self):
self.assertEqual(game.drill_per_action(self.p), 1.0)
self.p["xp"]["combat"] = game.XP_TABLE[40]
self.assertEqual(game.drill_per_action(self.p), 1.5)
def test_drill_sergeant_doubles_drilling_for_a_while(self): def test_drill_sergeant_doubles_drilling_for_a_while(self):
world, p = self.world, self.p world, p = self.world, self.p
self.assertEqual(game.spend_favour(world, p, "sergeant", T0), "No raid to prepare for") self.assertEqual(game.spend_favour(world, p, "sergeant", T0), "No raid to prepare for")
@ -620,10 +629,10 @@ class FavourTest(unittest.TestCase):
self.assertEqual(game.defence(world)["parts"]["drilled"], 0) # a sergeant drills nobody by himself self.assertEqual(game.defence(world)["parts"]["drilled"], 0) # a sergeant drills nobody by himself
self.assertEqual(game.favours(world, T0)["sergeant"]["cost"], 225) self.assertEqual(game.favours(world, T0)["sergeant"]["cost"], 225)
game.start_action(world, p, "drill_militia", T0) game.start_action(world, p, "drill_militia", T0)
game.process_player(world, p, T0 + 50 * 4 + 1, random.Random(1)) game.process_player(world, p, T0 + 60 * 4 + 1, random.Random(1))
self.assertEqual(game.upcoming_raid(world)["drilled"], 4.0) self.assertEqual(game.upcoming_raid(world)["drilled"], 8)
self.assertEqual(p["xp"]["combat"], 80) self.assertEqual(p["xp"]["combat"], 96)
self.assertEqual(world["city"]["drill_boost"], 13) self.assertEqual(world["city"]["drill_boost"], 26)
self.assertEqual(p["favour"], 5000 - 150) self.assertEqual(p["favour"], 5000 - 150)
def test_not_enough_favour(self): def test_not_enough_favour(self):