Intel: scaled recon costs, time-gated levels, spymaster multiplies recon
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
366a195c6d
commit
27bdb6a154
5 changed files with 113 additions and 33 deletions
|
|
@ -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
|
||||
|
|
|
|||
2
app.py
2
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
|
||||
|
||||
|
||||
|
|
|
|||
67
game.py
67
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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -344,7 +344,7 @@ function skillPage(id) {
|
|||
: `<div class="panel muted">Nothing is being built right now. Fund a building on the Town square and its site opens here.</div>`;
|
||||
html += potionPanel(id);
|
||||
if (`tool_${id}` in me.equip) html += toolPanel(id);
|
||||
if (id === "reconnaissance") html += `<div class="panel small muted">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.</div>`;
|
||||
if (id === "reconnaissance") html += `<div class="panel small muted">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.</div>`;
|
||||
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]) => `<span class="item-chip" title="${esc(item(id).name)}: ${Math.round(odds * 100)}% chance">${item(id).icon} ${q} <span class="have">${Math.round(odds * 100)}%</span></span>`).join("");
|
||||
if (a.special === "sabotage") extra += `<div class="small muted">Each win weakens the coming raid by 3%, up to 30%.</div>`;
|
||||
}
|
||||
if (a.special === "recon") extra = `<div class="small muted">+${a.recon} recon on the next event</div>`;
|
||||
if (a.special === "recon") extra = `<div class="small muted">+${+me.recon_gain[a.id].toFixed(2)} recon on the coming threat</div>`;
|
||||
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 `<span class="pips" title="Intel level ${level} of 3">${[1, 2, 3].map((i) => `<i class="${i <= level ? "on" : ""}"></i>`).join("")}</span>`;
|
||||
}
|
||||
|
||||
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 ? `<span class="k">Strength</span><span>${ev.strength ?? `${ev.range[0]}–${ev.range[1]}`}</span>` : ""}
|
||||
${ev.trait ? `<span class="k">Scouts say</span><span>${esc(ev.trait)}</span>` : ""}
|
||||
${ev.camp ? `<span class="k">Their camp</span><span>Located. Fighters can sabotage it from the combat page${ev.sabotage ? ` (weakened ${ev.sabotage}% so far)` : ""}.</span>` : ""}
|
||||
${me.cls === "scout" || ev.recon ? `<span class="k">Reconnaissance</span><span>${ev.intel < 3 ? `${ev.recon} points · ${ev.recon_next} more for the next intel level` : `${ev.recon} points · nothing more to learn`}</span>` : ""}
|
||||
${me.cls === "scout" || ev.recon ? `<span class="k">Reconnaissance</span><span>${reconText(ev)}</span>` : ""}
|
||||
</div>
|
||||
${ev.intel < 3 ? `<p class="small muted">Better intel comes from the spymaster's guild and scouts' reconnaissance.</p>` : ""}`;
|
||||
${ev.intel < 3 ? `<p class="small muted">Better intel comes from the spymaster's guild and scouts' reconnaissance, and the sharpest only as the threat draws near.</p>` : ""}`;
|
||||
if (threat) {
|
||||
const strength = ev.strength ?? (ev.range ? `${ev.range[0]}–${ev.range[1]}` : "?");
|
||||
html += `<div class="vs"><div class="side"><span class="muted small">Our defence</span><b>${fmt(city.defence.total)}</b></div><span class="muted">vs</span><div class="side"><span class="muted small">Raiders</span><b>${strength}</b></div></div>`;
|
||||
|
|
@ -845,7 +852,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) + "." : "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.`)}
|
||||
</div>`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue