diff --git a/README.md b/README.md index dfd8202..1ecb3dc 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,8 @@ Beta. The core game is in place; changes from here are mostly balance and tuning of the XP of every craft the fire pays for, shared evenly by everyone whose pile is hot enough for that craft on its own. Because the fires are shared, the server runs everyone's actions as time passes, not only when they next look. -- **Events:** raids are announced a day or two ahead. Reconnaissance from +- **Events:** a threat lands every two days, give or take two hours, and the + next is rumoured as soon as the last one lands. 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. Fighters can also drill the militia before any raid: a full drill takes as much work as @@ -161,8 +162,8 @@ Beta. The core game is in place; changes from here are mostly balance and tuning informants and drill sergeants double what players put in for a while, XP included. 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 + levels 2 and 3 only open about a day and a half and exactly a day before + the raid arrives, so the second day is for fighting at the camp. 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 @@ -171,11 +172,11 @@ Beta. The core game is in place; changes from here are mostly balance and tuning five minutes out, and everyone watches the battle play out once, live or on their next visit, before the report. - **Happenings:** three more tracks run on their own cooldowns. Minor threats - (wolves, a mine collapse, goblin thieves…) slow or block a skill until + (wolves, a mine collapse, goblin thieves, smugglers…) slow or block a skill until players deal with them; hardships (sickness, locusts, rot, storms, fire, - harsh winters, drought) hit the stores and buildings, softened by a granary, + harsh winters, drought, blight, the tax collector…) hit the stores and buildings, softened by a granary, lumber shed, well and water barrels; fortunes (visiting masters, harvests, - festivals, nobles, mercenaries…) give windows of plenty. Weather changes + festivals, nobles, mercenaries…) give short windows of plenty, several a day. Weather changes every few hours, and a game season lasts a week. ## Running diff --git a/game.py b/game.py index 439ad69..bb6e419 100644 --- a/game.py +++ b/game.py @@ -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."), diff --git a/static/app.js b/static/app.js index c48f1f1..913dee5 100644 --- a/static/app.js +++ b/static/app.js @@ -1186,7 +1186,7 @@ function actionCard(a) { extra = `${e.boss || own ? `