Livelier frontier: 2-day raids, more and shorter happenings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-24 11:43:56 +01:00
parent bf013a1648
commit db1a00e8aa
5 changed files with 201 additions and 48 deletions

189
game.py
View file

@ -737,6 +737,12 @@ WOLVES = [("Grey wolf", "skirmisher", "slash", "pierce", "blunt"),
GOBLINS = [("Goblin brute", "brute", "blunt", "blunt", "slash"),
("Goblin rogue", "skirmisher", "pierce", "magic", "blunt"),
("Goblin warlock", "caster", "magic", "slash", "magic")]
SPIDERS = [("Cave spider", "skirmisher", "pierce", "blunt", "pierce"),
("Bloated spider", "brute", "magic", "slash", "blunt"),
("Spider matriarch", "elite", "pierce", "magic", "slash")]
SMUGGLERS = [("Smuggler", "skirmisher", "slash", "blunt", "slash"),
("Smuggler bruiser", "brute", "blunt", "pierce", "blunt"),
("Smuggler hexer", "caster", "magic", "slash", "pierce")]
# Expedition areas, tiers II to VI: name, level, the reagent prefix, and four races.
# Each race: (name, trophy, weapon line its trophy goes into, level offset, roster).
@ -1161,7 +1167,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows
# --- world ---------------------------------------------------------------
WORLD_VERSION = 26
WORLD_VERSION = 27
def new_world(now: float, speed: float = 1.0) -> dict:
@ -1366,6 +1372,16 @@ def migrate(world: dict, now: float | None = None) -> dict:
for party in world["parties"].values():
party["fight"] = None
world["version"] = 26
if world["version"] < 27:
# Livelier happenings: tracks waiting longer than their new cooldowns are brought forward,
# and a city with no threat coming hears rumours of the next one straight away.
for track, (_low, high) in TRACK_HOURS.items():
key = f"next_{track}_at"
if world.get(key):
world[key] = min(world[key], now + high * HOUR / speed(world))
if world["next_event_at"] and not upcoming_event(world):
world["next_event_at"] = min(world["next_event_at"], now)
world["version"] = 27
return world
@ -2397,7 +2413,7 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
outputs[item] = outputs.get(item, 0) + qty
if action.get("special") == "sabotage":
raid = upcoming_raid(world)
raid["sabotage"] = min(0.3, raid["sabotage"] + 0.03)
raid["sabotage"] = min(0.3, raid["sabotage"] + 0.01)
if action.get("special") == "problem":
_work_problem(world, player, action, now)
if action.get("special") == "recon":
@ -3457,16 +3473,17 @@ def tick(world: dict, now: float, rng: random.Random | None = None) -> bool:
_queue_if_funded(world, key, now)
_start_next(world, now)
changed = True
if world["next_event_at"] and world["next_event_at"] <= now and not upcoming_event(world):
_announce(world, now, rng)
changed = True
for event in world["events"]:
if event["status"] == "upcoming" and event["fire_at"] <= now:
for player in world["players"].values():
process_player(world, player, event["fire_at"], rng)
_fire(world, event, now, rng)
world["next_event_at"] = now + rng.uniform(8, 24) * HOUR / speed(world)
# The next threat is rumoured as soon as this one is over.
world["next_event_at"] = now
changed = True
if world["next_event_at"] and world["next_event_at"] <= now and not upcoming_event(world):
_announce(world, now, rng)
changed = True
return changed
@ -3534,16 +3551,33 @@ def _finish_construction(world: dict, now: float) -> None:
# --- events --------------------------------------------------------------
# Threats keep a two-day rhythm: one lands every THREAT_EVERY game hours, give or
# take THREAT_WOBBLE, and the next is rumoured the moment it's over. The first day
# is for scouting, drilling and building; on the second the camp can be found.
THREAT_EVERY = 48
THREAT_WOBBLE = 2
def threat_slot(world: dict, now: float) -> float:
"""When the next threat is due: two days after the last one's slot, never sooner than about two days off."""
hours = HOUR / speed(world)
last = next((e["slot"] for e in reversed(world["events"]) if e["kind"] in THREATS and "slot" in e), None)
soonest = now + (THREAT_EVERY - THREAT_WOBBLE) * hours
return max(last + THREAT_EVERY * hours, soonest) if last else now + THREAT_EVERY * hours
def _announce(world: dict, now: float, rng: random.Random) -> None:
city = world["city"]
hours = HOUR / speed(world)
first = not any(e["kind"] in ("raid", "dragon") for e in world["events"])
slot = threat_slot(world, now)
day = (now - world["created"]) * speed(world) / DAY
# The major-threat track: mostly bandits, sometimes (after the first days) a dragon.
kind = "dragon" if not first and day >= 5 and rng.random() < 0.15 else "raid"
event = {
"id": world["next_event_id"], "kind": kind, "status": "upcoming",
"announced": now, "recon": 0, "sabotage": 0.0, "jitter": rng.uniform(-1, 1),
"slot": slot, "fire_at": slot + rng.uniform(-THREAT_WOBBLE, THREAT_WOBBLE) * hours,
}
world["next_event_id"] += 1
# Bigger, richer and older cities draw bigger threats; every win draws a bolder one.
@ -3551,7 +3585,6 @@ def _announce(world: dict, now: float, rng: random.Random) -> None:
strength = (15 + 4 * day + 0.8 * city["population"] + 1.2 * math.sqrt(wealth)) * (1 + 0.08 * city["repelled"])
if kind == "dragon":
event.update({
"fire_at": now + rng.uniform(30, 54) * hours,
"strength": round(strength * 1.5 * rng.uniform(0.9, 1.15), 1),
"group": rng.choice(DRAGONS),
"trait": rng.choice(DRAGON_TRAITS),
@ -3559,7 +3592,6 @@ def _announce(world: dict, now: float, rng: random.Random) -> None:
log(world, now, "event", "Herders report scorched pastures in the hills")
elif kind == "raid":
event.update({
"fire_at": now + rng.uniform(30, 54) * hours,
"strength": round(strength * rng.uniform(0.85, 1.2), 1),
"group": rng.choice(RAID_GROUPS),
"trait": rng.choice(RAID_TRAITS),
@ -3574,7 +3606,7 @@ def _announce(world: dict, now: float, rng: random.Random) -> None:
# 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
INTEL_OPENS = [None, 36, 24] # game hours before arrival that levels 2 (±25% per threat) and 3 open
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 = 30 # boosted recon per informant: doubled recon and XP for the scout who works it
@ -3593,11 +3625,12 @@ def intel_costs(event: dict) -> list[int]:
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."""
"""When an intel level becomes knowable. Level 2's ±25% is fixed per threat, so it can't be timed;
the camp (level 3) can always be found a day before the threat lands."""
hours = INTEL_OPENS[level - 1]
if hours is None:
return 0
wobble = random.Random(f"{event['id']}-{level}").uniform(0.75, 1.25)
wobble = random.Random(f"{event['id']}-{level}").uniform(0.75, 1.25) if level < 3 else 1
return event["fire_at"] - hours * wobble * HOUR / speed(world)
@ -3612,7 +3645,7 @@ def intel_level(world: dict, event: dict, now: float | None = None) -> int:
SIZE_LABELS = [(30, "small"), (60, "medium"), (100, "large"), (math.inf, "huge")]
WINDOW_HOURS = [24, 8, 2, 0]
WINDOW_HOURS = [4, 3, 2, 0]
THREATS = ("raid", "dragon")
@ -3926,14 +3959,24 @@ PROBLEMS = {
"rockslide": {"name": "Rockslide on the quarry road", "icon": "🪨", "work": 25, "action": "clear_rockslide",
"block": {"mining": "Quarry road blocked"}, "only": ["mine_stone"],
"text": "No stone until builders clear it."},
"flood": {"name": "Flooded fishing grounds", "icon": "🌊", "speed": {"fishing": 1.5}, "need": {"plank": 20}, "lasts": 36,
"text": "Fishing slow for 36h, or until dammed with planks."},
"flood": {"name": "Flooded fishing grounds", "icon": "🌊", "speed": {"fishing": 1.5}, "need": {"plank": 20}, "lasts": 18,
"text": "Fishing slow for 18h, or until dammed with planks."},
"boars": {"name": "Boars trampling the crops", "icon": "🐗", "speed": {"farming": 1.5}, "work": 10, "action": "cull_boars",
"text": "Farming slower until hunters cull them."},
"rats": {"name": "Rats in the granary", "icon": "🐀", "steal_food": 0.03, "work": 12, "action": "catch_rats",
"text": "Eat city food hourly until hunters catch them."},
"spiders": {"name": "Spiders in the mine", "icon": "🕷️", "speed": {"mining": 1.5}, "work": 10, "action": "clear_spiders",
"text": "Mining much slower until fighters clear them out."},
"smugglers": {"name": "Smugglers in the caves", "icon": "🏴‍☠️", "steal_gold": 0.01, "work": 10, "action": "catch_smugglers",
"text": "Skim 1% of the treasury hourly until fighters catch them."},
"bridge": {"name": "Bridge washed out", "icon": "🌉", "need": {"plank": 25},
"block": {"hunting": "The bridge to the hunting grounds is out"},
"text": "No hunting until the bridge is rebuilt with planks."},
"hornets": {"name": "Hornets in the woods", "icon": "🐝", "speed": {"woodcutting": 1.4}, "work": 8, "action": "smoke_hornets",
"text": "Woodcutting slower until herbalists smoke them out."},
}
MINOR_THREATS = ("collapse", "wolves", "bear", "goblins", "rockslide", "flood", "boars")
MINOR_THREATS = ("collapse", "wolves", "bear", "goblins", "rockslide", "flood", "boars",
"spiders", "smugglers", "bridge", "hornets")
PROBLEM_ACTIONS = [
_encounter("clear_wolves", "Wolf pack", "Frontier", 4, WOLVES, label="Drive off the wolves",
special="problem", problem="wolves"),
@ -3943,6 +3986,11 @@ PROBLEM_ACTIONS = [
_action("cull_boars", "hunting", "Cull the boar herd", 1, 45, 14, special="problem", problem="boars"),
_action("catch_rats", "hunting", "Catch the rats", 1, 30, 8, special="problem", problem="rats"),
_action("clear_rockslide", "construction", "Clear the rockslide", 1, 30, 10, special="problem", problem="rockslide"),
_encounter("clear_spiders", "Mine spiders", "Frontier", 6, SPIDERS, label="Clear the spiders",
special="problem", problem="spiders"),
_encounter("catch_smugglers", "Smugglers", "Frontier", 8, SMUGGLERS, gold=6, label="Catch the smugglers",
special="problem", problem="smugglers"),
_action("smoke_hornets", "herblore", "Smoke out the hornets", 1, 40, 12, special="problem", problem="hornets"),
]
ACTIONS.extend(PROBLEM_ACTIONS)
ACTION_BY_ID.update({action["id"]: action for action in PROBLEM_ACTIONS})
@ -4002,11 +4050,16 @@ for _id, _name, _building, _blevel, _level, _inputs, _effect, _glyph, _tint in F
# (weights by season, spring to winter)
HARDSHIPS = {"sickness": [2, 1, 2, 3], "locusts": [1, 3, 2, 0], "rot": [3, 1, 3, 1], "storm": [1, 1, 3, 1],
"rats": [2, 2, 2, 2], "fire": [1, 2, 1, 2], "winter": [0, 0, 0, 4], "drought": [0, 4, 1, 0]}
"rats": [2, 2, 2, 2], "fire": [1, 2, 1, 2], "winter": [0, 0, 0, 4], "drought": [0, 4, 1, 0],
"blight": [2, 1, 3, 0], "tremors": [1, 1, 1, 1], "taxman": [1, 1, 2, 1]}
FORTUNES = {"merchant": [12] * 4, "refugees": [10] * 4, "master": [16] * 4, "harvest": [10, 10, 12, 0],
"rich_vein": [10] * 4, "salmon": [10, 8, 4, 0], "festival": [8] * 4, "noble": [8] * 4,
"pilgrims": [8] * 4, "mercenaries": [6] * 4, "cargo": [10] * 4}
TRACK_HOURS = {"minor": (48, 96), "hardship": (48, 96), "fortune": (36, 72)}
"pilgrims": [8] * 4, "mercenaries": [6] * 4, "cargo": [10] * 4,
"timber": [8, 6, 10, 8], "deer": [8, 10, 10, 4], "masons": [10, 10, 8, 6], "artisans": [8] * 4,
"trapper": [10] * 4}
TRACK_HOURS = {"minor": (16, 32), "hardship": (20, 40), "fortune": (10, 20)}
CRAFTS = ("smithing", "jewelcrafting", "cooking", "leatherworking", "tailoring", "herblore")
TRAPPER_RECON = 15 # boosted recon a trapper's leads are worth, like an informant's
def calendar_day(world: dict, now: float) -> int:
@ -4160,7 +4213,7 @@ def _hardship(world: dict, now: float, rng: random.Random, kind: str | None = No
kind = kind or _pick(weights, rng)
granary, shed, well = (building_level(world, k) for k in ("granary", "lumber_shed", "well"))
if kind == "sickness":
hours = 72 * (1 - 0.2 * building_level(world, "temple"))
hours = 36 * (1 - 0.2 * building_level(world, "temple"))
_add_effect(world, now, hours, kind="sickness", name="Sickness", icon="🤒", food=2.0,
text="City eats twice as much food")
_record(world, now, "hardship", kind, "🤒", "Sickness is spreading through the city",
@ -4208,28 +4261,46 @@ def _hardship(world: dict, now: float, rng: random.Random, kind: str | None = No
lines.append("Water barrels in the stockpile (five per fire) would have put it out.")
_record(world, now, "hardship", kind, "🔥", "Fire swept through part of the city", lines)
elif kind == "winter":
_add_effect(world, now, 48, kind="winter", name="Harsh winter", icon="🥶", firewood=0.1,
_add_effect(world, now, 24, kind="winter", name="Harsh winter", icon="🥶", firewood=0.1,
text="Burns stockpile logs hourly")
_record(world, now, "hardship", kind, "🥶", "A harsh cold snap has set in",
["For two days the city burns logs from the stockpile to keep warm.",
["For a day the city burns logs from the stockpile to keep warm.",
"Without firewood, the cold makes everyone eat 50% more food."])
elif kind == "blight":
_add_effect(world, now, 12, kind="blight", name="Blight", icon="🥀", **{"yield": {"farming": 0.5}},
text="Farming yield halved")
_record(world, now, "hardship", kind, "🥀", "Blight has struck the fields",
["For 12 hours, farming yields half."])
elif kind == "tremors":
_add_effect(world, now, 12, kind="tremors", name="Tremors", icon="🫨", speed={"mining": 1.3},
text="Mining 30% slower")
_record(world, now, "hardship", kind, "🫨", "The ground is shaking under the mines",
["For 12 hours, mining is 30% slower."])
elif kind == "taxman":
gold = int(city["treasury"] * 0.15)
city["treasury"] -= gold
_record(world, now, "hardship", kind, "📜", "The crown's tax collector came by",
[f"The collector took {gold:,} gold (15%) from the treasury." if gold else "The treasury was empty, so the collector left grumbling."])
else:
_add_effect(world, now, 24, kind="drought", name="Drought", icon="🏜️", speed={"farming": 1.3, "fishing": 1.2},
_add_effect(world, now, 12, kind="drought", name="Drought", icon="🏜️", speed={"farming": 1.3, "fishing": 1.2},
text="Farming 30% and fishing 20% slower")
_record(world, now, "hardship", kind, "🏜️", "Drought has dried the fields and streams",
["For a day, farming is 30% and fishing 20% slower."])
["For 12 hours, farming is 30% and fishing 20% slower."])
def _fortune(world: dict, now: float, rng: random.Random, kind: str | None = None) -> None:
city = world["city"]
weights = {kind: w[season(world, now)["index"]] for kind, w in FORTUNES.items()}
if not upcoming_raid(world):
threat = upcoming_raid(world)
if not threat:
weights["mercenaries"] = 0
if not threat or threat["recon"] >= intel_costs(threat)[-1]:
weights["trapper"] = 0
kind = kind or _pick(weights, rng)
if kind == "merchant":
city["merchant_until"] = now + 12 * HOUR / speed(world)
city["merchant_until"] = now + 6 * HOUR / speed(world)
_record(world, now, "fortune", kind, "🐪", "A merchant caravan has arrived",
["For the next 12 hours, everything sells for 50% more."])
["For the next 6 hours, everything sells for 50% more."])
elif kind == "refugees":
people = rng.randint(6, 12)
taken = min(max(0, housing(world) - int(city["population"])), people)
@ -4241,30 +4312,30 @@ def _fortune(world: dict, now: float, rng: random.Random, kind: str | None = Non
elif kind == "master":
skill = rng.choice([s for s in SKILL_IDS if s != "reconnaissance"])
name = next(s["name"] for s in SKILLS if s["id"] == skill)
_add_effect(world, now, 12, kind="master", name=f"A master of {name.lower()}", icon="🎓",
_add_effect(world, now, 6, kind="master", name=f"A master of {name.lower()}", icon="🎓",
xp={skill: 1.5}, text=f"{name} +50% XP")
_record(world, now, "fortune", kind, "🎓", f"A travelling master of {name.lower()} is visiting",
[f"For 12 hours, {name.lower()} earns 50% more XP. Learn while you can."])
[f"For 6 hours, {name.lower()} earns 50% more XP. Learn while you can."])
elif kind == "harvest":
_add_effect(world, now, 12, kind="harvest", name="Bountiful harvest", icon="🌾", **{"yield": {"farming": 2}},
_add_effect(world, now, 6, kind="harvest", name="Bountiful harvest", icon="🌾", **{"yield": {"farming": 2}},
text="Farming yield doubled")
_record(world, now, "fortune", kind, "🌾", "The fields are bursting: a bountiful harvest",
["Farming yields are doubled for 12 hours."])
["Farming yields are doubled for 6 hours."])
elif kind == "rich_vein":
_add_effect(world, now, 12, kind="rich_vein", name="Rich vein", icon="💎", **{"yield": {"mining": 1.5}},
_add_effect(world, now, 6, kind="rich_vein", name="Rich vein", icon="💎", **{"yield": {"mining": 1.5}},
text="Mining yield +50%")
_record(world, now, "fortune", kind, "💎", "Miners struck a rich vein",
["Mining yields 50% more for 12 hours."])
["Mining yields 50% more for 6 hours."])
elif kind == "salmon":
_add_effect(world, now, 12, kind="salmon", name="Salmon run", icon="🐟", **{"yield": {"fishing": 2}},
_add_effect(world, now, 6, kind="salmon", name="Salmon run", icon="🐟", **{"yield": {"fishing": 2}},
text="Fishing yield doubled")
_record(world, now, "fortune", kind, "🐟", "The salmon are running",
["Fishing yields are doubled for 12 hours."])
["Fishing yields are doubled for 6 hours."])
elif kind == "festival":
_add_effect(world, now, 24, kind="festival", name="Festival", icon="🎉", growth=2, favour=1.5,
_add_effect(world, now, 12, kind="festival", name="Festival", icon="🎉", growth=2, favour=1.5,
text="Growth doubled, +50% favour from donations")
_record(world, now, "fortune", kind, "🎉", "The city is holding a festival",
["For a day, the population grows twice as fast and donations earn 50% more favour."])
["For 12 hours, the population grows twice as fast and donations earn 50% more favour."])
elif kind == "noble":
levels = sum(b["level"] for b in city["buildings"].values())
gold = 100 + 40 * levels
@ -4283,10 +4354,34 @@ def _fortune(world: dict, now: float, rng: random.Random, kind: str | None = Non
_record(world, now, "fortune", kind, "🙏", "Pilgrims passed through the city", lines)
elif kind == "mercenaries":
cost = 150 + 30 * season(world, now)["index"] + int(city["population"]) * 5
_add_effect(world, now, 24, kind="mercenaries", name="Mercenaries for hire", icon="🗡️", offer=True,
_add_effect(world, now, 12, kind="mercenaries", name="Mercenaries for hire", icon="🗡️", offer=True,
cost=cost, defence=25, text="+25 defence for the coming threat")
_record(world, now, "fortune", kind, "🗡️", "A sellsword company is camped outside the walls",
[f"For a day they'll join the next defence for {cost:,} gold from the treasury."])
[f"For 12 hours they'll join the next defence for {cost:,} gold from the treasury."])
elif kind == "timber":
_add_effect(world, now, 6, kind="timber", name="Storm-felled timber", icon="🪵", **{"yield": {"woodcutting": 1.5}},
text="Woodcutting yield +50%")
_record(world, now, "fortune", kind, "🪵", "A storm felled trees all over the woods",
["Woodcutting yields 50% more for 6 hours."])
elif kind == "deer":
_add_effect(world, now, 6, kind="deer", name="Deer migration", icon="🦌", **{"yield": {"hunting": 2}},
text="Hunting yield doubled")
_record(world, now, "fortune", kind, "🦌", "Herds are migrating past the city",
["Hunting yields are doubled for 6 hours."])
elif kind == "masons":
_add_effect(world, now, 6, kind="masons", name="Masons' guild", icon="🧱", speed={"construction": 0.75},
text="Construction 25% faster")
_record(world, now, "fortune", kind, "🧱", "A masons' guild is passing through",
["For 6 hours, construction is 25% faster."])
elif kind == "artisans":
_add_effect(world, now, 6, kind="artisans", name="Artisans' fair", icon="🎨", speed={s: 0.85 for s in CRAFTS},
text="Smithing, jewelcrafting, cooking, leatherworking, tailoring and herblore 15% faster")
_record(world, now, "fortune", kind, "🎨", "Artisans from afar opened a fair",
["For 6 hours, every craft but construction and firekeeping is 15% faster."])
elif kind == "trapper":
city["recon_boost"] = city.get("recon_boost", 0) + TRAPPER_RECON
_record(world, now, "fortune", kind, "🗺️", "A trapper brought word from the hills",
[f"The next {TRAPPER_RECON} recon scouts gather counts double."])
else:
goods = rng.sample([("stone", 200), ("logs", 200), ("bricks", 80), ("bread", 60), ("arrows", 150), ("plank", 40)], 2)
for item, qty in goods:
@ -4390,6 +4485,8 @@ def _happenings_hour(world: dict, now: float, rng: random.Random) -> None:
_take_share(stock, [i for i in stock if i not in _food_items(stock)], spec["steal"])
if spec.get("steal_food"):
_take_share(stock, _food_items(stock), spec["steal_food"] * (1 - 0.25 * building_level(world, "granary")))
if spec.get("steal_gold"):
city["treasury"] *= 1 - spec["steal_gold"]
def _happenings_tracks(world: dict, now: float, rng: random.Random) -> bool:
@ -4637,6 +4734,8 @@ ACTION_GLYPHS = {
"labour": ("hammer-nails", "#d1a46a"), "clear_wolves": ("wolf-head", "#9aa1ab"),
"catch_goblins": ("goblin-head", "#8fbf6a"), "drive_bear": ("bear-head", "#8a5a32"), "cull_boars": ("boar", "#a06a4a"),
"catch_rats": ("rat", "#9aa1ab"), "clear_rockslide": ("falling-rocks", "#a8a29e"),
"clear_spiders": ("spider-alt", "#9a7fb0"), "catch_smugglers": ("pirate-hat", "#a08060"),
"smoke_hornets": ("wasp-sting", "#e3c060"),
"hunt_elk": ("deer", "#a07a50"), "hunt_sabrecat": ("saber-toothed-cat-head", "#e0a050"),
"hunt_mammoth": ("mammoth", "#8a6a50"),
}
@ -4783,6 +4882,20 @@ ORDER_RECIPES, ORDER_RAW = _order_sources()
# What changed, shown on the Patch notes page. Newest first; every shipped change adds a
# line here (new / balance / fix), and a new release a new entry on top.
PATCH_NOTES = [
{"date": "2026-10-24", "title": "Livelier frontier", "notes": [
("balance", "A threat lands every 2 days (±2h), and the next one is rumoured as soon as the last lands. "
"Day 1: scout, drill, build. Day 2: the camp is found (intel III opens 24h out), with the exact hour."),
("balance", "Sabotage: -1% raid strength per win (was -3%), still max 30%."),
("balance", "Happenings ~3× as often: fortunes every 10–20h (was 36–72h), hardships 20–40h (was 48–96h), "
"minor threats 16–32h (was 48–96h)."),
("balance", "Shorter happenings: 12h boosts → 6h; festival, sellswords, drought 24h → 12h; cold snap 48h → 24h; "
"sickness 72h → 36h; flood 36h → 18h."),
("new", "Fortunes: storm-felled timber (woodcutting yield +50%), deer migration (hunting yield ×2), masons' guild "
"(construction 25% faster), artisans' fair (crafts 15% faster), a trapper's leads (next 15 recon doubled)."),
("new", "Hardships: blight (farming yield ½), tremors (mining 30% slower), the tax collector (-15% treasury)."),
("new", "Minor threats: spiders in the mine and smugglers skimming 1% treasury/h (fighters), a washed-out "
"bridge that blocks hunting (25 planks), hornets slowing woodcutting (herbalists smoke them out)."),
]},
{"date": "2026-10-24", "title": "Combat rework", "notes": [
("new", "Fights are against packs: as many enemies as there are of you, often one more (each 35% weaker). "
"A rare elite still comes alone."),