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
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
also drill the militia before any raid, up to half a defence per
townsperson. Favour never buys results directly: master builders,
also drill the militia before any raid: a full drill takes as much work as
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,
XP included. Each intel
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_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("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,
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,
@ -1021,7 +1021,7 @@ def can_start(world: dict, player: dict, action: dict) -> str | None:
threat = upcoming_raid(world)
if not threat:
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"
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"
@ -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)
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)
threat["drilled"] = min(drill_needed(threat), threat.get("drilled", 0) + gain + boost)
if action.get("special") == "labour":
city = world["city"]
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_FAVOUR = 150 # +50% for every further hire on the same site
INFORMANT_FAVOUR = 250
# 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
# Drilling: fighters train the townsfolk before a raid. A full drill takes as
# many points as full intel on the same threat, so fighters and scouts put in
# about the same time; the defence it's worth grows with the population.
DRILL_PER_ACTION = 1.0 # drill points per session, +25% every 20 combat levels
DRILL_XP = 12 # combat XP per drill point
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
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:
@ -2528,8 +2533,11 @@ def drill_cap(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)
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:
@ -2556,10 +2564,11 @@ def favours(world: dict, now: float) -> dict:
"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,
"The militia is as drilled as it can be" if raid.get("drilled", 0) >= drill_needed(raid) else None,
"drill": DRILL_SERGEANT_POINTS,
"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
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"])
f"drill points count double", player["id"])
return None

View file

@ -495,8 +495,9 @@ function actionCard(a) {
}
if (a.special === "drill") {
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>
<div class="small muted">More per session every 20 combat levels. The militia can take up to half a defence per townsperson.</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 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") {
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>
${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("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>`;
}

View file

@ -592,25 +592,34 @@ 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_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
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))
raid = game.upcoming_raid(world)
world["city"]["population"] = 20.0
needed = game.drill_needed(raid)
self.assertEqual(needed, game.intel_costs(raid)[-1])
before = game.defence(world)["total"]
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)
game.process_player(world, p, T0 + 60 * 10 + 1, random.Random(1))
self.assertEqual(raid["drilled"], 10)
self.assertAlmostEqual(game.defence(world)["parts"]["drilled"], round(20 * 10 / needed, 1))
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
game.process_player(world, p, T0 + 50 * 40, random.Random(1))
self.assertEqual(game.defence(world)["parts"]["drilled"], 10.0)
game.process_player(world, p, T0 + 60 * (needed + 5), random.Random(1))
self.assertEqual(game.defence(world)["parts"]["drilled"], 20.0)
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)
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):
world, p = self.world, self.p
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.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)
game.process_player(world, p, T0 + 60 * 4 + 1, random.Random(1))
self.assertEqual(game.upcoming_raid(world)["drilled"], 8)
self.assertEqual(p["xp"]["combat"], 96)
self.assertEqual(world["city"]["drill_boost"], 26)
self.assertEqual(p["favour"], 5000 - 150)
def test_not_enough_favour(self):