diff --git a/README.md b/README.md index 665a22a..5b89d68 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/game.py b/game.py index 9618f2b..3b9ce94 100644 --- a/game.py +++ b/game.py @@ -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 diff --git a/static/app.js b/static/app.js index 4033dae..38bad07 100644 --- a/static/app.js +++ b/static/app.js @@ -495,8 +495,9 @@ function actionCard(a) { } 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.
`; + extra = !s.needed ? `
Fighters drill the townsfolk while a raid is coming. A fully drilled militia adds one defence per townsperson.
` + : `
${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 ? ` ×2 drill and XP for the next ${fmt(boost)}` : ""}
+
A bigger band needs more drilling. A fully drilled militia adds one defence per townsperson.
`; } if (a.special === "recon") { const boost = S.favours.informant.boost; @@ -862,7 +863,7 @@ 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("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.`)}
`; } diff --git a/tests/test_game.py b/tests/test_game.py index bb219aa..41d3686 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -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):