From 27bdb6a154de02eb328d57475977a027984e611e Mon Sep 17 00:00:00 2001 From: rope Date: Mon, 19 Oct 2026 15:09:51 +0100 Subject: [PATCH] Intel: scaled recon costs, time-gated levels, spymaster multiplies recon Co-Authored-By: Claude Opus 5.5 --- README.md | 9 ++++--- app.py | 2 ++ game.py | 67 +++++++++++++++++++++++++++++++++++++--------- static/app.js | 17 ++++++++---- tests/test_game.py | 51 ++++++++++++++++++++++++++--------- 5 files changed, 113 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 1ccf66c..4a1c1d5 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,12 @@ Alpha. One season, bandit raids plus two friendly events, six buildings. time: faster gathering, a chance to keep materials or double ore, more prayer from bones, or combat power, armour and healing. An empty potion is replaced from the bank automatically. -- **Events:** raids are announced a day or two ahead. Intel (spymaster's - guild + scout reconnaissance) narrows down when, how strong, and finally - where their camp is, so fighters can sabotage it. Defeat kills or drives +- **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. Each intel + level takes more scouting than the last and more for a bigger band, and + levels 2 and 3 only open about a day and eight hours before the raid + arrives. Defeat kills or drives off people, loots the stockpile and treasury, and may damage a building. Damaged buildings stop working until repaired but keep their level. Every fight uses up arrows and breaks some of the swords and shields diff --git a/app.py b/app.py index 8631744..64bb279 100644 --- a/app.py +++ b/app.py @@ -116,6 +116,8 @@ def player_view(world: dict, player: dict, now: float) -> dict: view["action"] = {**player["action"], "duration": game.action_time(world, player, action)} view["times"] = {action["id"]: game.action_time(world, player, action) for action in game.ACTIONS} view["blocked"] = {action["id"]: game.can_start(world, player, action) for action in game.ACTIONS} + view["recon_gain"] = {action["id"]: game.recon_gain(world, action) for action in game.ACTIONS + if action.get("special") == "recon"} return view diff --git a/game.py b/game.py index 8c883bb..5dad9b3 100644 --- a/game.py +++ b/game.py @@ -20,7 +20,6 @@ BASE_STACK = 1000 CLASS_SPEED = 0.8 LUCKY_CHANCE = 0.1 OVERFLOW_FAVOUR = 0.5 # favour for items a full bank spills to the city, vs. giving on purpose -RECON_PER_INTEL = 8 MAX_LOG = 200 REGEN_PER_HOUR = 0.25 # share of max HP recovered per game hour EAT_BELOW = 0.5 # auto-eat when HP drops under this share of max @@ -457,7 +456,7 @@ BUILDINGS = { "name": "Spymaster's guild", "icon": "🕵️", "max": 3, "gold": 400, "base": {"logs": 500, "bricks": 300, "hide": 100, "feathers": 300}, "advanced": {"cut_stone": 150}, "late": {"bear_leather": 40, "oak_plank": 40}, - "effect": "One more level of intel on every event per level", + "effect": "+25% reconnaissance per level, and its agents gather some every hour on their own", }, "storehouse": { "name": "Storehouse", "icon": "📦", "max": 5, "gold": 200, @@ -1095,9 +1094,9 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R summary["losses"] += 1 xp = xp // 3 if action.get("special") == "recon": - event = upcoming_event(world) + event = upcoming_raid(world) if event: - event["recon"] += action["recon"] + fitted(world, "map_table") + event["recon"] += recon_gain(world, action) if action.get("special") == "problem" and action["skill"] != "combat": _work_problem(world, player, action, now) if action.get("special") == "labour": @@ -1572,6 +1571,9 @@ def _city_hour(world: dict, now: float) -> None: pop = city["population"] if city["construction"]: city["construction"]["labour"] += pop * TOWNSFOLK_LABOUR + threat = upcoming_raid(world) + if threat and building_level(world, "spymaster"): + 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) * (0.9 if fitted(world, "grain_bins") else 1)) stock = city["stockpile"] @@ -1663,8 +1665,45 @@ def _announce(world: dict, now: float, rng: random.Random) -> None: del world["events"][:-30] -def intel_level(world: dict, event: dict) -> int: - return min(3, building_level(world, "spymaster") + event["recon"] // RECON_PER_INTEL) +# Intel: each level takes more reconnaissance than the last, a bigger band is +# harder to read (a dragon is hard to miss), and the sharper levels only become +# knowable as the threat draws near, however much scouting is done. +INTEL_POINTS = [15, 30, 45] # recon for intel levels 1, 2 and 3 against a small band +INTEL_SIZE = [1.0, 1.33, 1.67, 2.0] # small, medium, large, huge +INTEL_OPENS = [None, 24, 8] # game hours before arrival that levels 2 and 3 open, ±25% per threat +SPYMASTER_RECON = 0.25 # more recon per guild level +SPYMASTER_AGENTS = 0.5 # recon per guild level per game hour, with nobody scouting +INFORMANT_RECON = 15 + + +def recon_gain(world: dict, action: dict) -> float: + return (action["recon"] + fitted(world, "map_table")) * (1 + SPYMASTER_RECON * building_level(world, "spymaster")) + + +def intel_costs(event: dict) -> list[int]: + """Total recon needed for each intel level.""" + size = next(i for i, (limit, _label) in enumerate(SIZE_LABELS) if event["strength"] < limit) + scale = INTEL_SIZE[size] * (0.5 if event["kind"] == "dragon" else 1) + return [round(sum(INTEL_POINTS[:level]) * scale) for level in (1, 2, 3)] + + +def intel_opens(world: dict, event: dict, level: int) -> float: + """When an intel level becomes knowable. The ±25% is fixed per threat, so it can't be timed.""" + hours = INTEL_OPENS[level - 1] + if hours is None: + return 0 + wobble = random.Random(f"{event['id']}-{level}").uniform(0.75, 1.25) + return event["fire_at"] - hours * wobble * HOUR / speed(world) + + +def intel_level(world: dict, event: dict, now: float | None = None) -> int: + now = world["city"]["last_tick"] if now is None else now + level = 0 + for next_level, need in enumerate(intel_costs(event), 1): + if event["recon"] < need or now < intel_opens(world, event, next_level): + break + level = next_level + return level SIZE_LABELS = [(30, "small"), (60, "medium"), (100, "large"), (math.inf, "huge")] @@ -1673,14 +1712,16 @@ WINDOW_HOURS = [24, 8, 2, 0] def event_view(world: dict, event: dict, now: float) -> dict: """What the city knows about an upcoming event, filtered by intel.""" - level = intel_level(world, event) + level = intel_level(world, event, now) half = WINDOW_HOURS[level] * HOUR / speed(world) + costs = intel_costs(event) centre = event["fire_at"] + event["jitter"] * half * 0.6 view = { "id": event["id"], "kind": event["kind"], "intel": level, "earliest": max(now, centre - half), "latest": max(now, centre + half), "exact": event["fire_at"] if level >= 3 else None, - "recon": event["recon"], "recon_next": RECON_PER_INTEL - event["recon"] % RECON_PER_INTEL, + "recon": int(event["recon"]), "recon_full": costs[-1], + "recon_next": max(0, round(costs[level] - event["recon"])) if level < 3 else 0, } if event["kind"] == "dragon": strength = event["strength"] @@ -2453,7 +2494,6 @@ def favours(world: dict, now: float) -> dict: """What favour can buy for the city right now: cost, and why not if it can't.""" city = world["city"] work = city["construction"] - event = upcoming_event(world) raid = upcoming_raid(world) return { "builders": { @@ -2464,8 +2504,8 @@ def favours(world: dict, now: float) -> dict: }, "informant": { "cost": INFORMANT_FAVOUR, - "why": "No news to dig into" if not event else - "Nothing more to learn" if intel_level(world, event) >= 3 else None, + "why": "No news to dig into" if not raid else + "Nothing more to learn" if raid["recon"] >= intel_costs(raid)[-1] else None, }, "militia": { "cost": RALLY_FAVOUR * (city.get("rallies", 0) + 1), @@ -2494,7 +2534,7 @@ def spend_favour(world: dict, player: dict, kind: str, now: float) -> str | None log(world, now, "favour", f"{player['name']} hired a master builder for the {name}: " f"the next {MASTER_BUILDER_LABOUR} labour counts double", player["id"]) elif kind == "informant": - upcoming_event(world)["recon"] += RECON_PER_INTEL + upcoming_raid(world)["recon"] += INFORMANT_RECON log(world, now, "favour", f"{player['name']} paid an informant for news from the roads", player["id"]) else: city["rally"] = city.get("rally", 0) + RALLY_DEFENCE @@ -2656,5 +2696,6 @@ def content() -> dict: "city_uses": {item: city_uses(item) for item in ITEMS}, "prayers": [{**prayer, "text": describe_prayer(prayer)} for prayer in PRAYERS], "max_prayers": MAX_PRAYERS, "potion_charges": POTION_CHARGES, "problems": PROBLEMS, "fittings": list(FITTING_BY_ID.values()), - "equip_slots": EQUIP_SLOTS, "eat_below": EAT_BELOW, "class_speed": CLASS_SPEED, "regen_per_hour": REGEN_PER_HOUR, + "equip_slots": EQUIP_SLOTS, "eat_below": EAT_BELOW, "class_speed": CLASS_SPEED, + "informant_recon": INFORMANT_RECON, "regen_per_hour": REGEN_PER_HOUR, } diff --git a/static/app.js b/static/app.js index 069bae3..96e7f89 100644 --- a/static/app.js +++ b/static/app.js @@ -344,7 +344,7 @@ function skillPage(id) { : `
Nothing is being built right now. Fund a building on the Town square and its site opens here.
`; html += potionPanel(id); if (`tool_${id}` in me.equip) html += toolPanel(id); - if (id === "reconnaissance") html += `
Every ${8} reconnaissance points on an upcoming event raises its intel by one level, on top of what the spymaster's guild provides. The council & threats page shows the progress.
`; + if (id === "reconnaissance") html += `
Reconnaissance on a coming threat raises its intel. Each level takes more than the last, a bigger band takes longer to read, and the sharpest news (the exact day and their camp) only comes in the last hours before they strike. The spymaster's guild adds to every point and gathers some on its own. The council & threats page shows the progress.
`; html += skillHappenings(id); const live = new Set(S.happenings.problems.map((p) => p.kind)); const actions = C.actions.filter((a) => a.skill === id && (a.special !== "problem" || live.has(a.problem))); @@ -493,7 +493,7 @@ function actionCard(a) { if (a.drops) io += a.drops.map(([id, q, odds]) => `${item(id).icon} ${q} ${Math.round(odds * 100)}%`).join(""); if (a.special === "sabotage") extra += `
Each win weakens the coming raid by 3%, up to 30%.
`; } - if (a.special === "recon") extra = `
+${a.recon} recon on the next event
`; + if (a.special === "recon") extra = `
+${+me.recon_gain[a.id].toFixed(2)} recon on the coming threat
`; const temple = S.city.buildings.temple?.level || 0; if (a.special === "labour") { const boost = S.favours.builders.boost; @@ -789,6 +789,13 @@ function pips(level) { return `${[1, 2, 3].map((i) => ``).join("")}`; } +function reconText(ev) { + const points = `${fmt(ev.recon)} / ${fmt(ev.recon_full)} points`; + if (ev.intel >= 3) return `${points} · nothing more to learn`; + if (ev.recon_next <= 0) return `${points} · enough for the next level, but they're too far off to read yet`; + return `${points} · ${fmt(ev.recon_next)} more for the next intel level`; +} + function council() { const ev = S.upcoming, city = S.city, me = S.me; let html = ""; @@ -804,9 +811,9 @@ function council() { ${ev.range ? `Strength${ev.strength ?? `${ev.range[0]}–${ev.range[1]}`}` : ""} ${ev.trait ? `Scouts say${esc(ev.trait)}` : ""} ${ev.camp ? `Their campLocated. Fighters can sabotage it from the combat page${ev.sabotage ? ` (weakened ${ev.sabotage}% so far)` : ""}.` : ""} - ${me.cls === "scout" || ev.recon ? `Reconnaissance${ev.intel < 3 ? `${ev.recon} points · ${ev.recon_next} more for the next intel level` : `${ev.recon} points · nothing more to learn`}` : ""} + ${me.cls === "scout" || ev.recon ? `Reconnaissance${reconText(ev)}` : ""} - ${ev.intel < 3 ? `

Better intel comes from the spymaster's guild and scouts' reconnaissance.

` : ""}`; + ${ev.intel < 3 ? `

Better intel comes from the spymaster's guild and scouts' reconnaissance, and the sharpest only as the threat draws near.

` : ""}`; if (threat) { const strength = ev.strength ?? (ev.range ? `${ev.range[0]}–${ev.range[1]}` : "?"); html += `
Our defence${fmt(city.defence.total)}
vs
Raiders${strength}
`; @@ -845,7 +852,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) + "." : "One more level of intel on what's coming.")} + ${option("informant", "🕵️", "Pay an informant", f.informant.why ? esc(f.informant.why) + "." : `${C.informant_recon} reconnaissance on what's coming.`)} ${option("militia", "📯", "Rally the militia", f.militia.why ? esc(f.militia.why) + "." : `+10 defence for the coming raid${f.militia.rallied ? ` (+${f.militia.rallied} so far)` : ""}. Costs more each time.`)}
`; } diff --git a/tests/test_game.py b/tests/test_game.py index 4566852..f603b99 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -343,21 +343,45 @@ class EventTest(unittest.TestCase): self.assertLessEqual(view["earliest"], raid["fire_at"]) self.assertGreaterEqual(view["latest"], raid["fire_at"]) - def test_spymaster_and_recon_sharpen_intel(self): + def test_recon_sharpens_intel_as_the_raid_draws_near(self): world = world_with(("sam", "scout")) raid = self.announce(world) - world["city"]["buildings"]["spymaster"]["level"] = 1 - self.assertIn("size", game.event_view(world, raid, T0)) p = world["players"]["sam"] p["xp"]["reconnaissance"] = game.XP_TABLE[20] game.start_action(world, p, "recon_camps", T0) - game.process_player(world, p, T0 + 8 * 72, random.Random(1)) - view = game.event_view(world, raid, T0) + game.process_player(world, p, T0 + 4 * game.HOUR, random.Random(1)) + self.assertGreaterEqual(raid["recon"], game.intel_costs(raid)[-1]) + # Plenty of scouting, but a day or more out only the first level is knowable. + view = game.event_view(world, raid, T0 + 4 * game.HOUR) + self.assertEqual(view["intel"], 1) + self.assertIn("size", view) + self.assertNotIn("strength", view) + self.assertEqual(view["recon_next"], 0) + late = raid["fire_at"] - 12 * game.HOUR + self.assertEqual(game.intel_level(world, raid, late), 2) + view = game.event_view(world, raid, raid["fire_at"] - game.HOUR) self.assertEqual(view["intel"], 3) self.assertEqual(view["exact"], raid["fire_at"]) - self.assertIn("strength", view) self.assertTrue(view["camp"]) + def test_bigger_bands_take_more_scouting(self): + small = {"kind": "raid", "strength": 20} + huge = {"kind": "raid", "strength": 150} + dragon = {"kind": "dragon", "strength": 20} + self.assertEqual(game.intel_costs(small), [15, 45, 90]) + self.assertEqual(game.intel_costs(huge), [30, 90, 180]) + self.assertEqual(game.intel_costs(dragon), [8, 22, 45]) + + def test_the_spymasters_guild_boosts_and_gathers_recon(self): + world = world_with() + raid = self.announce(world) + action = game.ACTION_BY_ID["recon_roads"] + self.assertEqual(game.recon_gain(world, action), 1) + world["city"]["buildings"]["spymaster"]["level"] = 2 + self.assertEqual(game.recon_gain(world, action), 1.5) + game.tick(world, T0 + 2 * game.HOUR, random.Random(2)) + self.assertAlmostEqual(raid["recon"], 2.0) + def test_victory_rewards_garrison(self): world = world_with(("wil", "warrior")) raid = self.announce(world) @@ -404,9 +428,11 @@ class EventTest(unittest.TestCase): p["hp"] = game.max_hp(p) raid = self.announce(world) self.assertEqual(game.start_action(world, p, "sabotage", T0), "No bandit camp has been located") - raid["recon"] = 3 * game.RECON_PER_INTEL - self.assertIsNone(game.start_action(world, p, "sabotage", T0)) - game.process_player(world, p, T0 + 96 * 20, random.Random(1)) + raid["recon"] = game.intel_costs(raid)[-1] + now = raid["fire_at"] - game.HOUR + world["city"]["last_tick"] = p["last_active"] = now + self.assertIsNone(game.start_action(world, p, "sabotage", now)) + game.process_player(world, p, now + 96 * 20, random.Random(1)) self.assertGreater(raid["sabotage"], 0) self.assertLessEqual(raid["sabotage"], 0.3) @@ -552,11 +578,12 @@ class FavourTest(unittest.TestCase): self.assertEqual(game.spend_favour(world, p, "informant", T0), "No news to dig into") world["next_event_at"] = T0 game.tick(world, T0, random.Random(2)) - event = game.upcoming_event(world) - for level in (1, 2, 3): + event = game.upcoming_raid(world) + while event["recon"] < game.intel_costs(event)[-1]: self.assertIsNone(game.spend_favour(world, p, "informant", T0)) - self.assertEqual(game.intel_level(world, event), level) + self.assertEqual(event["recon"] % game.INFORMANT_RECON, 0) self.assertEqual(game.spend_favour(world, p, "informant", T0), "Nothing more to learn") + self.assertEqual(game.intel_level(world, event, T0), 1) # the rest waits for the raid to draw near def test_rallies_cost_more_each_time_and_end_with_the_raid(self): world, p = self.world, self.p