Drilling scales with the threat, like scouting
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1c80bd80f7
commit
b248a030ea
4 changed files with 50 additions and 31 deletions
|
|
@ -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
37
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_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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue