Recon caps at the threat's total; leads kept once fully scouted

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-19 15:39:02 +01:00
parent b248a030ea
commit 43b7a3a193
3 changed files with 23 additions and 6 deletions

11
game.py
View file

@ -1102,14 +1102,15 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
xp = xp // 3 xp = xp // 3
if action.get("special") == "recon": if action.get("special") == "recon":
event = upcoming_raid(world) event = upcoming_raid(world)
if event: # Once a threat is fully scouted, scouting only trains the skill (and keeps the informant's leads).
if event and event["recon"] < intel_costs(event)[-1]:
city = world["city"] city = world["city"]
gain = recon_gain(world, action) gain = recon_gain(world, action)
# An informant's leads double the reconnaissance (and the XP) of whoever follows them up. # An informant's leads double the reconnaissance (and the XP) of whoever follows them up.
boost = min(city.get("recon_boost", 0), gain) boost = min(city.get("recon_boost", 0), gain)
city["recon_boost"] = city.get("recon_boost", 0) - boost city["recon_boost"] = city.get("recon_boost", 0) - boost
xp *= 1 + boost / gain xp *= 1 + boost / gain
event["recon"] += gain + boost event["recon"] = min(intel_costs(event)[-1], event["recon"] + gain + boost)
if action.get("special") == "problem" and action["skill"] != "combat": if action.get("special") == "problem" and action["skill"] != "combat":
_work_problem(world, player, action, now) _work_problem(world, player, action, now)
threat = upcoming_raid(world) if action.get("special") == "drill" else None threat = upcoming_raid(world) if action.get("special") == "drill" else None
@ -1597,7 +1598,7 @@ def _city_hour(world: dict, now: float) -> None:
city["construction"]["labour"] += pop * TOWNSFOLK_LABOUR city["construction"]["labour"] += pop * TOWNSFOLK_LABOUR
threat = upcoming_raid(world) threat = upcoming_raid(world)
if threat and building_level(world, "spymaster"): if threat and building_level(world, "spymaster"):
threat["recon"] += SPYMASTER_AGENTS * building_level(world, "spymaster") threat["recon"] = min(intel_costs(threat)[-1], threat["recon"] + SPYMASTER_AGENTS * building_level(world, "spymaster"))
city["food_due"] += (pop * 0.5 / 24 * city_rate(world, "food") * (1.5 if city.get("cold") else 1) city["food_due"] += (pop * 0.5 / 24 * city_rate(world, "food") * (1.5 if city.get("cold") else 1)
* (0.9 if fitted(world, "grain_bins") else 1)) * (0.9 if fitted(world, "grain_bins") else 1))
stock = city["stockpile"] stock = city["stockpile"]
@ -1738,13 +1739,13 @@ def event_view(world: dict, event: dict, now: float) -> dict:
"""What the city knows about an upcoming event, filtered by intel.""" """What the city knows about an upcoming event, filtered by intel."""
level = intel_level(world, event, now) level = intel_level(world, event, now)
half = WINDOW_HOURS[level] * HOUR / speed(world) half = WINDOW_HOURS[level] * HOUR / speed(world)
costs = intel_costs(event) costs = intel_costs(event) if "strength" in event else [0, 0, 0]
centre = event["fire_at"] + event["jitter"] * half * 0.6 centre = event["fire_at"] + event["jitter"] * half * 0.6
view = { view = {
"id": event["id"], "kind": event["kind"], "intel": level, "id": event["id"], "kind": event["kind"], "intel": level,
"earliest": max(now, centre - half), "latest": max(now, centre + half), "earliest": max(now, centre - half), "latest": max(now, centre + half),
"exact": event["fire_at"] if level >= 3 else None, "exact": event["fire_at"] if level >= 3 else None,
"recon": int(event["recon"]), "recon_full": costs[-1], "recon": int(min(event["recon"], costs[-1])), "recon_full": costs[-1],
"recon_next": max(0, round(costs[level] - event["recon"])) if level < 3 else 0, "recon_next": max(0, round(costs[level] - event["recon"])) if level < 3 else 0,
} }
if event["kind"] == "dragon": if event["kind"] == "dragon":

View file

@ -501,7 +501,9 @@ function actionCard(a) {
} }
if (a.special === "recon") { if (a.special === "recon") {
const boost = S.favours.informant.boost; const boost = S.favours.informant.boost;
extra = `<div class="small makes">${skillOf("reconnaissance").icon} +${+me.recon_gain[a.id].toFixed(2)} recon on the coming threat${boost ? ` <b class="boosted">×2 recon and XP for the next ${fmt(boost)}</b>` : ""}</div>`; const ev = S.upcoming, full = ev && ev.recon >= ev.recon_full;
extra = full ? `<div class="small muted">The coming threat is fully scouted: this only trains the skill for now.</div>`
: `<div class="small makes">${skillOf("reconnaissance").icon} +${+me.recon_gain[a.id].toFixed(2)} recon on the coming threat${boost ? ` <b class="boosted">×2 recon and XP for the next ${fmt(boost)}</b>` : ""}</div>`;
} }
const temple = S.city.buildings.temple?.level || 0; const temple = S.city.buildings.temple?.level || 0;
if (a.special === "labour") { if (a.special === "labour") {

View file

@ -364,6 +364,20 @@ class EventTest(unittest.TestCase):
self.assertEqual(view["exact"], raid["fire_at"]) self.assertEqual(view["exact"], raid["fire_at"])
self.assertTrue(view["camp"]) self.assertTrue(view["camp"])
def test_scouting_stops_counting_once_the_threat_is_fully_scouted(self):
world = world_with(("sam", "scout"))
raid = self.announce(world)
full = game.intel_costs(raid)[-1]
raid["recon"] = full - 1
world["city"]["recon_boost"] = 30
p = world["players"]["sam"]
game.start_action(world, p, "recon_roads", T0)
game.process_player(world, p, T0 + 48 * 3 + 1, random.Random(1))
self.assertEqual(raid["recon"], full)
self.assertEqual(world["city"]["recon_boost"], 29) # leads only spent while there's something to learn
self.assertIsNotNone(p["action"]) # still trains the skill
self.assertEqual(game.event_view(world, raid, T0)["recon"], full)
def test_bigger_bands_take_more_scouting(self): def test_bigger_bands_take_more_scouting(self):
small = {"kind": "raid", "strength": 20} small = {"kind": "raid", "strength": 20}
huge = {"kind": "raid", "strength": 150} huge = {"kind": "raid", "strength": 150}