From 75327f30e045e78943980fb39e811ea3dc501675 Mon Sep 17 00:00:00 2001 From: rope Date: Mon, 19 Oct 2026 09:34:18 +0100 Subject: [PATCH] Happenings: weather and seasons, minor threats, hardships, fortunes, dragons Co-Authored-By: Claude Opus 5.5 --- README.md | 8 + app.py | 16 +- game.py | 655 +++++++++++++++++++++++++++++++++++++++++++-- static/app.js | 96 ++++++- static/styles.css | 7 + tests/test_game.py | 159 +++++++++++ 6 files changed, 903 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 4905550..ca75d4f 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,14 @@ Alpha. One season, bandit raids plus two friendly events, six buildings. where their camp is, so fighters can sabotage it. 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. + Now and then a dragon comes instead of bandits. +- **Happenings:** three more tracks run on their own cooldowns. Minor threats + (wolves, a mine collapse, goblin thieves…) 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, + lumber shed, well and water barrels; fortunes (visiting masters, harvests, + festivals, nobles, mercenaries…) give windows of plenty. Weather changes + every few hours, and a game season lasts a week. ## Running diff --git a/app.py b/app.py index 0cc5800..126c6f4 100644 --- a/app.py +++ b/app.py @@ -330,6 +330,10 @@ class Handler(BaseHTTPRequestHandler): return "There's no raid to stand guard for" player["garrison"] = bool(body.get("join")) return None + if path == "/api/aid": + return game.aid_problem(world, player, str(body.get("kind")), str(body.get("item")), str(body.get("source")), now) + if path == "/api/hire": + return game.hire_mercenaries(world, player, now) if path == "/api/potion": return game.use_potion(player, str(body.get("item"))) if path == "/api/prayers": @@ -409,6 +413,13 @@ class Handler(BaseHTTPRequestHandler): world["config"]["clock_offset"] += hours * game.HOUR / game.speed(world) game.tick(world, self.store.now(), self.store.rng) return None + if path == "/api/dev/happen": + track = body.get("track") + fire = {"minor": game._minor_threat, "hardship": game._hardship, "fortune": game._fortune}.get(track) + if not fire: + return "No such track" + fire(world, self.store.now(), self.store.rng, body.get("kind")) + return None if path == "/api/dev/event": world["next_event_at"] = self.store.now() game.tick(world, self.store.now(), self.store.rng) @@ -421,9 +432,9 @@ class Handler(BaseHTTPRequestHandler): player = world["players"].get(uid) upcoming = game.upcoming_event(world) history = [ - {"id": e["id"], "kind": e["kind"], "at": e["resolved"], "report": e.get("report")} + {"id": e["id"], "kind": e["kind"], "track": e.get("track", "major"), "at": e["resolved"], "report": e.get("report")} for e in reversed(world["events"]) if e["status"] == "done" - ][:10] + ][:15] return { "now": now, "dev": self.dev, "uid": uid, "version": VERSION, "me": player_view(world, player, now) if player else None, @@ -431,6 +442,7 @@ class Handler(BaseHTTPRequestHandler): "city": city_view(world, now), "upcoming": game.event_view(world, upcoming, now) if upcoming else None, "favours": game.favours(world, now), + "happenings": game.happenings_view(world, now), "history": history, "players": roster(world, now), "ledger": world["ledger"][-80:], diff --git a/game.py b/game.py index adfbc64..21b39ab 100644 --- a/game.py +++ b/game.py @@ -434,6 +434,24 @@ BUILDINGS = { "late": {"willow_logs": 150, "oak_plank": 60}, "effect": "Offerings ward the city in raids; +1 prayer point per bone buried and +25% devotion per level", }, + "granary": { + "name": "Granary", "icon": "🌾", "max": 3, "gold": 150, + "base": {"logs": 400, "bricks": 200, "plank": 60}, "advanced": {"cut_stone": 80}, + "late": {"oak_plank": 60}, + "effect": "Protects a quarter of the food from locusts and rats per level", + }, + "lumber_shed": { + "name": "Lumber shed", "icon": "πŸͺ΅", "max": 3, "gold": 120, + "base": {"logs": 300, "stone": 200, "plank": 80}, "advanced": {"oak_plank": 60}, + "late": {"willow_plank": 40}, + "effect": "Keeps a quarter of the stored wood safe from rot per level", + }, + "well": { + "name": "Well", "icon": "πŸͺ£", "max": 3, "gold": 120, + "base": {"stone": 500, "bricks": 150}, "advanced": {"cut_stone": 80}, + "late": {"oak_plank": 40}, + "effect": "Each level cuts fire losses by a quarter and may save the building", + }, "spymaster": { "name": "Spymaster's guild", "icon": "πŸ•΅οΈ", "max": 3, "gold": 400, "base": {"logs": 500, "bricks": 300, "hide": 100, "feathers": 300}, "advanced": {"cut_stone": 150}, @@ -467,6 +485,9 @@ GROWTH_PER_DAY = 0.12 RAID_GROUPS = ["the Red Hand", "the Hollow Crows", "the Blackwater gang", "the Ashen Wolves", "the Gallows Men", "the Thornback raiders"] +DRAGONS = ["a green dragon", "a blue dragon", "an old red dragon", "a young black dragon"] +DRAGON_TRAITS = ["it hunts at dusk", "one wing is scarred and weak", "it breathes fire in long sweeps", + "it circles twice before it strikes"] RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows", "well armed and disciplined", "a ragged mob, many but poorly armed", "led by a deserter knight in plate"] @@ -474,7 +495,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows # --- world --------------------------------------------------------------- -WORLD_VERSION = 7 +WORLD_VERSION = 8 def new_world(now: float, speed: float = 1.0) -> dict: @@ -491,6 +512,8 @@ def new_world(now: float, speed: float = 1.0) -> dict: "construction": None, "queue": [], "tier": 0, + "weather": {"kind": "clear", "until": now + 6 * HOUR / speed, "next": "rain"}, + "effects": [], "problems": [], "hired": 0, "last_tick": now, "food_due": 0.0, "starving": False, @@ -498,6 +521,9 @@ def new_world(now: float, speed: float = 1.0) -> dict: }, "events": [], "next_event_at": now + 12 * HOUR / speed, + "next_minor_at": now + 30 * HOUR / speed, + "next_hardship_at": now + 24 * HOUR / speed, + "next_fortune_at": now + 6 * HOUR / speed, "next_event_id": 1, "ledger": [], "board": [], @@ -553,6 +579,18 @@ def migrate(world: dict, now: float | None = None) -> dict: del work["done_at"] work.update(labour=needed * share, needed=needed) world["version"] = 7 + if world["version"] < 8: + city = world["city"] + for key in ("granary", "lumber_shed", "well"): + city["buildings"].setdefault(key, {"level": 0, "damaged": False, "fund": {}}) + city.setdefault("weather", {"kind": "clear", "until": now + 4 * HOUR / speed(world), "next": "rain"}) + city.setdefault("effects", []) + city.setdefault("problems", []) + city.setdefault("hired", 0) + world.setdefault("next_minor_at", now + 30 * HOUR / speed(world)) + world.setdefault("next_hardship_at", now + 24 * HOUR / speed(world)) + world.setdefault("next_fortune_at", now + 8 * HOUR / speed(world)) + world["version"] = 8 return world @@ -689,6 +727,8 @@ def city_uses(item: str) -> list[str]: uses.append("the granary") if spec.get("slot") in ("weapon", "shield") or item == "arrows": uses.append("the armoury") + if item == "water_barrel": + uses.append("the fire brigade") return uses @@ -859,6 +899,7 @@ def action_time(world: dict, player: dict, action: dict) -> float: if action["skill"] == "smithing": seconds *= 1 - 0.08 * building_level(world, "smithy") seconds *= 1 - potion_effect(player, action["skill"]).get("speed", 0) + seconds *= skill_mods(world, action)["speed"] return seconds / speed(world) @@ -903,8 +944,9 @@ def _use_charge(player: dict, skill: str, summary: dict) -> None: def upcoming_raid(world: dict) -> dict | None: + """The coming major threat: a bandit raid or a dragon.""" for event in world["events"]: - if event["status"] == "upcoming" and event["kind"] == "raid": + if event["status"] == "upcoming" and event["kind"] in ("raid", "dragon"): return event return None @@ -932,10 +974,17 @@ def can_start(world: dict, player: dict, action: dict) -> str | None: raid = upcoming_raid(world) if not raid or intel_level(world, raid) < 3: return "No bandit camp has been located" + if raid["kind"] != "raid": + return "A dragon has no camp to sabotage" if raid["sabotage"] >= 0.3: return "The camp is already in ruins" if action.get("special") == "labour" and not world["city"]["construction"]: return "Nothing is being built" + if action.get("special") == "problem" and not active_problem(world, action["problem"]): + return "That's been dealt with" + blocked = skill_mods(world, action)["blocked"] + if blocked: + return blocked if action["skill"] == "combat" and player["hp"] < max_hp(player) * TOO_HURT and not edible(player): return "Too hurt to fight. Rest, or bring food" return None @@ -960,6 +1009,12 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R for item, qty in action["outputs"].items(): outputs[item] += qty summary["lucky"] += 1 + mods = skill_mods(world, action) + if mods["yield"] != 1: + for item, qty in list(outputs.items()): + exact = qty * mods["yield"] + outputs[item] = int(exact) + (1 if rng.random() < exact - int(exact) else 0) + xp *= mods["xp"] if action["skill"] == "combat": blessed = blessing(player) @@ -1002,6 +1057,8 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R if action.get("special") == "sabotage": raid = upcoming_raid(world) raid["sabotage"] = min(0.3, raid["sabotage"] + 0.03) + if action.get("special") == "problem": + _work_problem(world, player, action, now) else: summary["losses"] += 1 xp = xp // 3 @@ -1009,6 +1066,8 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R event = upcoming_event(world) if event: event["recon"] += action["recon"] + if action.get("special") == "problem" and action["skill"] != "combat": + _work_problem(world, player, action, now) if action.get("special") == "labour": site = world["city"]["construction"] site["labour"] += labour_per_action(player) @@ -1154,7 +1213,7 @@ def donate(world: dict, player: dict, item: str, qty: int, now: float) -> str | city["stockpile"][item] = city["stockpile"].get(item, 0) + qty gain = qty * ITEMS[item]["value"] label = amount(item, qty) - player["favour"] += gain + player["favour"] += gain * city_rate(world, "favour") player["donated"] += gain log(world, now, "donation", f"{player['name']} donated {label}", player["id"]) return None @@ -1223,7 +1282,7 @@ def fund(world: dict, player: dict, key: str, item: str, qty: int | str, source: else: bank_take(player, item, qty) gain = qty * (1 if item == "gold" else ITEMS[item]["value"]) - player["favour"] += gain + player["favour"] += gain * city_rate(world, "favour") player["donated"] += gain elif item == "gold": city["treasury"] -= qty @@ -1344,6 +1403,7 @@ def defence(world: dict) -> dict: "arrows": min(100, stock.get("arrows", 0) // 10), "rallied": city.get("rally", 0), "ward": ward(world), + "mercenaries": city.get("hired", 0), "garrison": 0, } garrison = [] @@ -1368,8 +1428,10 @@ def tick(world: dict, now: float, rng: random.Random | None = None) -> bool: city["last_tick"] = max(city["last_tick"], now - 14 * DAY / speed(world)) while city["last_tick"] + step <= now: city["last_tick"] += step + _happenings_hour(world, city["last_tick"], rng) _city_hour(world, city["last_tick"]) changed = True + changed = _happenings_tracks(world, now, rng) or changed construction = city["construction"] if construction and construction["labour"] >= construction["needed"]: _finish_construction(world, now) @@ -1402,7 +1464,7 @@ def _city_hour(world: dict, now: float) -> None: pop = city["population"] if city["construction"]: city["construction"]["labour"] += pop * TOWNSFOLK_LABOUR - city["food_due"] += pop * 0.5 / 24 + city["food_due"] += pop * 0.5 / 24 * city_rate(world, "food") * (1.5 if city.get("cold") else 1) stock = city["stockpile"] for item in FOOD_ORDER: while city["food_due"] >= ITEMS[item]["food"] and stock.get(item, 0) > 0: @@ -1420,7 +1482,7 @@ def _city_hour(world: dict, now: float) -> None: else: room = housing(world) if pop < room: - city["population"] = min(room, pop + max(0.5, pop * GROWTH_PER_DAY) / 24) + city["population"] = min(room, pop + max(0.5, pop * GROWTH_PER_DAY) * city_rate(world, "growth") / 24) elif pop > room: city["population"] = max(room, pop - pop * 0.05 / 24) city["treasury"] += pop * 0.5 / 24 @@ -1459,19 +1521,27 @@ def _finish_construction(world: dict, now: float) -> None: def _announce(world: dict, now: float, rng: random.Random) -> None: city = world["city"] hours = HOUR / speed(world) - first = not world["events"] - roll = rng.random() - kind = "raid" if first or roll < 0.7 else ("merchant" if roll < 0.85 else "refugees") + first = not any(e["kind"] in ("raid", "dragon") for e in world["events"]) + 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), } world["next_event_id"] += 1 - if kind == "raid": - # Bigger, richer and older cities draw bigger bands; every win draws a bolder one. - wealth = city["treasury"] + stockpile_value(city) - day = (now - world["created"]) * speed(world) / DAY - strength = (15 + 4 * day + 0.8 * city["population"] + 1.2 * math.sqrt(wealth)) * (1 + 0.08 * city["repelled"]) + # Bigger, richer and older cities draw bigger threats; every win draws a bolder one. + wealth = city["treasury"] + stockpile_value(city) + 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), + }) + 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), @@ -1479,13 +1549,6 @@ def _announce(world: dict, now: float, rng: random.Random) -> None: "trait": rng.choice(RAID_TRAITS), }) log(world, now, "event", "Rumours of bandits on the roads reach the council") - elif kind == "merchant": - event["fire_at"] = now + rng.uniform(12, 30) * hours - log(world, now, "event", "Word arrives that a merchant caravan is headed our way") - else: - event["fire_at"] = now + rng.uniform(12, 36) * hours - event["people"] = rng.randint(6, 12) - log(world, now, "event", "Refugees from the borderlands are walking towards the city") world["events"].append(event) del world["events"][:-30] @@ -1509,7 +1572,23 @@ def event_view(world: dict, event: dict, now: float) -> dict: "exact": event["fire_at"] if level >= 3 else None, "recon": event["recon"], "recon_next": RECON_PER_INTEL - event["recon"] % RECON_PER_INTEL, } - if event["kind"] == "raid": + if event["kind"] == "dragon": + strength = event["strength"] + view["headline"] = "Herders whisper of scorched pastures in the hills." + if level >= 1: + view["headline"] = "A dragon has been seen over the hills, and it's coming closer." + view["size"] = next(label for limit, label in SIZE_LABELS if strength < limit) + if level >= 2: + spread = 0.25 * strength + low = strength - spread * (1 + event["jitter"]) / 2 + view["range"] = [round(low), round(low + spread)] + view["group"] = event["group"] + view["headline"] = f"{upper_first(event['group'])} is coming for the city." + if level >= 3: + view["strength"] = round(strength) + view["trait"] = event["trait"] + view["requests"] = council_requests(world, event) + elif event["kind"] == "raid": strength = event["strength"] * (1 - event["sabotage"]) view["headline"] = "Travellers speak of trouble on the roads." if level >= 1: @@ -1565,6 +1644,8 @@ def _fire(world: dict, event: dict, now: float, rng: random.Random) -> None: event["resolved"] = now if event["kind"] == "raid": _resolve_raid(world, event, now, rng) + elif event["kind"] == "dragon": + _resolve_dragon(world, event, now, rng) elif event["kind"] == "merchant": world["city"]["merchant_until"] = now + 12 * HOUR / speed(world) event["report"] = {"title": "A merchant caravan has arrived", @@ -1650,6 +1731,67 @@ def _resolve_raid(world: dict, event: dict, now: float, rng: random.Random) -> N player["garrison"] = False city["rally"] = city["rallies"] = 0 city["devotion"] = 0 + city["hired"] = 0 + + +def _resolve_dragon(world: dict, event: dict, now: float, rng: random.Random) -> None: + city = world["city"] + stock = city["stockpile"] + attack = event["strength"] + guard = defence(world) + garrison = guard["garrison"] + report = {"group": event["group"], "attack": round(attack), "defence": guard["total"], + "parts": guard["parts"], "garrison": garrison, "icon": "πŸ‰"} + lines = [] + if guard["total"] >= attack: + city["repelled"] += 1 + city["treasury"] += round(attack) + _use_up(stock, "arrows", 0.5) + report["title"] = f"Victory! {upper_first(event['group'])} was driven off" + lines.append(f"It fled wounded, dropping a hoard worth {round(attack):,} gold.") + for entry in garrison: + player = world["players"][entry["id"]] + player["favour"] += 40 + gain_xp(world, player, "combat", round(attack * 6), now) + bank_add(world, player, "dragon_bones", 2, now, empty_summary()) + bank_add(world, player, "green_dragonhide", 1, now, empty_summary()) + if garrison: + lines.append("Everyone in the garrison earned 40 favour, combat experience, dragon bones and a hide.") + log(world, now, "raid", report["title"]) + else: + severity = min(1.0, 1 - guard["total"] / attack) + lost = int(city["population"] * (0.05 + 0.15 * severity)) + city["population"] = max(5.0, city["population"] - lost) + share = min(0.8, 0.2 + 0.4 * severity) + burned = _take_share(stock, WOOD, share * (1 - 0.25 * building_level(world, "lumber_shed"))) + burned.update(_take_share(stock, _food_items(stock), share * (1 - 0.25 * building_level(world, "granary")))) + name = _damage_building(world, now, rng) + _use_up(stock, "arrows", 0.7) + report["title"] = f"{upper_first(event['group'])} set the city ablaze" + lines.append(f"{lost} townsfolk were lost in the flames.") + if burned: + lines.append(f"It burned {_listing(burned)}.") + if name: + lines.append(f"The {name.lower()} {'were' if name.endswith('s') else 'was'} damaged and needs repairs.") + for entry in garrison: + gain_xp(world, world["players"][entry["id"]], "combat", round(attack * 2), now) + log(world, now, "raid", report["title"]) + report["lines"] = lines + event["report"] = report + for player in world["players"].values(): + player["garrison"] = False + city["rally"] = city["rallies"] = 0 + city["devotion"] = 0 + city["hired"] = 0 + + +def _work_problem(world: dict, player: dict, action: dict, now: float) -> None: + problem = active_problem(world, action["problem"]) + if not problem: + return + problem["work"] += 1 + if problem["work"] >= PROBLEMS[problem["kind"]]["work"]: + _solve(world, problem, now, player["name"]) def _use_up(stock: dict, item: str, share: float) -> None: @@ -1659,6 +1801,473 @@ def _use_up(stock: dict, item: str, share: float) -> None: del stock[item] +# --- happenings: weather, seasons, hardships, fortunes, minor threats --------- +# Raids and dragons keep their own announced rhythm (the major-threat track +# above). Three more tracks run beside it on their own cooldowns, and the +# weather rolls every few hours, leaning towards whatever the season favours. + +SEASONS = [("Spring", "🌱"), ("Summer", "β˜€οΈ"), ("Autumn", "πŸ‚"), ("Winter", "❄️")] +SEASON_DAYS = 7 + +# `speed` multiplies action time (below 1 is faster), `yield` multiplies output, +# `food` multiplies what the townsfolk eat, `firewood` is logs per person per hour. +WEATHER = { + "clear": {"name": "Clear skies", "icon": "🌀️", "text": "Nothing out of the ordinary"}, + "rain": {"name": "Rain", "icon": "🌧️", "speed": {"farming": 0.9}, "text": "Farming 10% faster"}, + "heat": {"name": "Heat wave", "icon": "πŸ₯΅", "speed": {"farming": 0.85}, "food": 1.15, + "text": "Farming 15% faster, but food spoils faster"}, + "storm": {"name": "Storm", "icon": "β›ˆοΈ", "speed": {"fishing": 1.25}, "text": "Fishing 25% slower"}, + "fog": {"name": "Fog", "icon": "🌫️", "speed": {"reconnaissance": 1.3, "hunting": 1.1}, + "text": "Scouting 30% and hunting 10% slower"}, + "snow": {"name": "Snow", "icon": "🌨️", "speed": {"hunting": 1.2}, "firewood": 0.05, + "text": "Hunting 20% slower, and the city burns firewood"}, +} +SEASON_WEATHER = [ + {"clear": 35, "rain": 40, "storm": 10, "fog": 15}, + {"clear": 45, "heat": 30, "rain": 15, "storm": 10}, + {"clear": 30, "rain": 30, "storm": 25, "fog": 15}, + {"clear": 30, "snow": 45, "fog": 15, "storm": 10}, +] +FIREWOOD = ("logs", "oak_logs", "willow_logs", "maple_logs", "yew_logs", "magic_logs") +WOOD = FIREWOOD + tuple(plank for plank, _name in PLANKS) + +# Minor threats: they slow or block a skill until someone deals with them, +# either by working a special action (`work` completions) or by donating (`need`). +PROBLEMS = { + "collapse": {"name": "Mine collapse", "icon": "⛰️", "need": {"plank": 30}, + "block": {"mining": "The mine has collapsed: shore it up with planks"}, "allow": ["mine_stone"], + "text": "A shaft caved in. Ore can't be mined until the mine is shored up with planks."}, + "wolves": {"name": "Wolves at the farms", "icon": "🐺", "yield": {"farming": 0.5}, "work": 10, "action": "clear_wolves", + "text": "A pack is harrying the fields. Farming yields are halved until fighters drive them off."}, + "bear": {"name": "Bear in the woods", "icon": "🐻", "speed": {"woodcutting": 1.5}, "work": 8, "action": "drive_bear", + "text": "Woodcutters won't go near it. Woodcutting is much slower until hunters drive it off."}, + "goblins": {"name": "Goblin thieves", "icon": "πŸ‘Ί", "steal": 0.01, "work": 10, "action": "catch_goblins", + "text": "Goblins raid the stockpile every hour until fighters catch them."}, + "rockslide": {"name": "Rockslide on the quarry road", "icon": "πŸͺ¨", "work": 25, "action": "clear_rockslide", + "block": {"mining": "The quarry road is blocked by a rockslide"}, "only": ["mine_stone"], + "text": "No stone can be quarried until builders clear the road."}, + "flood": {"name": "Flooded fishing grounds", "icon": "🌊", "speed": {"fishing": 1.5}, "need": {"plank": 20}, "lasts": 36, + "text": "The river burst its banks. Fishing is slow until it drops in a day and a half, or a dam of planks holds it back."}, + "boars": {"name": "Boars trampling the crops", "icon": "πŸ—", "yield": {"farming": 0.6}, "work": 10, "action": "cull_boars", + "text": "A herd is trampling the fields. Farming yields are down until hunters cull them."}, + "rats": {"name": "Rats in the granary", "icon": "πŸ€", "steal_food": 0.03, "work": 12, "action": "catch_rats", + "text": "Rats eat into the city's food every hour until someone catches them."}, +} +MINOR_THREATS = ("collapse", "wolves", "bear", "goblins", "rockslide", "flood", "boars") +PROBLEM_ACTIONS = [ + _action("clear_wolves", "combat", "Drive off the wolves", 1, 60, 16, difficulty=6, damage=3, + special="problem", problem="wolves"), + _action("catch_goblins", "combat", "Catch the goblin thieves", 1, 60, 16, difficulty=8, damage=3, + special="problem", problem="goblins"), + _action("drive_bear", "hunting", "Drive off the bear", 1, 45, 14, special="problem", problem="bear"), + _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"), +] +ACTIONS.extend(PROBLEM_ACTIONS) +ACTION_BY_ID.update({action["id"]: action for action in PROBLEM_ACTIONS}) +ITEMS["water_barrel"] = {"name": "Water barrel", "icon": "πŸ›’οΈ", "value": 20} +_barrel = _action("cooper_barrel", "construction", "Water barrel", 10, 35, 18, + {"oak_plank": 2, "iron_bar": 1}, {"water_barrel": 1}, group="Supplies") +ACTIONS.append(_barrel) +ACTION_BY_ID[_barrel["id"]] = _barrel + +# (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]} +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)} + + +def season(world: dict, now: float) -> dict: + day = (now - world["created"]) * speed(world) / DAY + index = int(day // SEASON_DAYS) % len(SEASONS) + name, icon = SEASONS[index] + return {"index": index, "name": name, "icon": icon, "day": int(day % SEASON_DAYS) + 1} + + +def _pick(weights: dict, rng: random.Random) -> str: + keys = [key for key, weight in weights.items() if weight > 0] + return rng.choices(keys, [weights[key] for key in keys])[0] + + +def _roll_weather(world: dict, now: float, rng: random.Random, current: str | None = None) -> str: + if current and rng.random() < 0.4: + return current + return _pick(SEASON_WEATHER[season(world, now)["index"]], rng) + + +def _modifier_sources(world: dict) -> list[dict]: + city = world["city"] + sources = [WEATHER[city["weather"]["kind"]]] + city["effects"] + sources += [PROBLEMS[problem["kind"]] for problem in city["problems"]] + return sources + + +def skill_mods(world: dict, action: dict) -> dict: + """How weather, happenings and problems change this action right now.""" + skill = action["skill"] + mods = {"speed": 1.0, "yield": 1.0, "xp": 1.0, "blocked": None} + for source in _modifier_sources(world): + mods["speed"] *= source.get("speed", {}).get(skill, 1) + mods["yield"] *= source.get("yield", {}).get(skill, 1) + mods["xp"] *= source.get("xp", {}).get(skill, 1) + if action.get("special") != "problem": + for problem in world["city"]["problems"]: + spec = PROBLEMS[problem["kind"]] + reason = spec.get("block", {}).get(skill) + if reason and (action["id"] in spec.get("only", [action["id"]])) and action["id"] not in spec.get("allow", []): + mods["blocked"] = reason + return mods + + +def city_rate(world: dict, key: str) -> float: + """The product of a city-wide factor (food, growth, favour) across everything active.""" + rate = 1.0 + for source in _modifier_sources(world): + rate *= source.get(key, 1) + return rate + + +def active_problem(world: dict, kind: str) -> dict | None: + return next((p for p in world["city"]["problems"] if p["kind"] == kind), None) + + +def _record(world: dict, now: float, track: str, kind: str, icon: str, title: str, lines: list[str]) -> None: + """A happening for the history list and the ledger.""" + world["events"].append({"id": world["next_event_id"], "kind": kind, "track": track, "status": "done", + "announced": now, "fire_at": now, "resolved": now, + "report": {"title": title, "icon": icon, "lines": lines}}) + world["next_event_id"] += 1 + del world["events"][:-60] + log(world, now, track, title) + + +def _add_effect(world: dict, now: float, hours: float, **effect) -> None: + world["city"]["effects"].append({**effect, "until": now + hours * HOUR / speed(world)}) + + +def _take_share(stock: dict, items, share: float) -> dict: + taken = {} + for item in [i for i in items if stock.get(i)]: + gone = int(stock[item] * share) + if gone: + taken[item] = gone + take_stock_qty(stock, item, gone) + return taken + + +def take_stock_qty(stock: dict, item: str, qty: int) -> None: + stock[item] -= qty + if stock[item] <= 0: + del stock[item] + + +def _listing(taken: dict) -> str: + names = [amount(item, qty) for item, qty in taken.items()] + return ", ".join(names[:-1]) + " and " + names[-1] if len(names) > 1 else (names[0] if names else "nothing") + + +def _damage_building(world: dict, now: float, rng: random.Random) -> str | None: + city = world["city"] + built = [key for key, b in city["buildings"].items() if b["level"] and not b["damaged"]] + if not built: + return None + key = rng.choice(built) + city["buildings"][key]["damaged"] = True + _queue_if_funded(world, key, now) + return BUILDINGS[key]["name"] + + +def _food_items(stock: dict) -> list[str]: + return [item for item in stock if "food" in ITEMS[item] or item in ("wheat", "vegetables")] + + +def _hardship(world: dict, now: float, rng: random.Random, kind: str | None = None) -> None: + city = world["city"] + stock = city["stockpile"] + weights = {kind: w[season(world, now)["index"]] for kind, w in HARDSHIPS.items()} + if city["weather"]["kind"] == "storm": + weights["storm"] += 6 + if city["weather"]["kind"] == "rain": + weights["rot"] += 3 + if active_problem(world, "rats"): + weights["rats"] = 0 + 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")) + _add_effect(world, now, hours, kind="sickness", name="Sickness", icon="πŸ€’", food=1.5, + text="The sick need more food: the city eats half again as much") + _record(world, now, "hardship", kind, "πŸ€’", "Sickness is spreading through the city", + [f"For about {round(hours)} hours the townsfolk eat half again as much.", + "A bigger temple shortens it."]) + elif kind == "locusts": + share = 0.4 * (1 - 0.25 * granary) + taken = _take_share(stock, _food_items(stock), share) + _record(world, now, "hardship", kind, "πŸ¦—", "A swarm of locusts descended on the stores", + [f"They ate {_listing(taken)}." if taken else "There was little for them to eat.", + *(["The granary kept part of it safe."] if granary else ["A granary would protect some of it."])]) + elif kind == "rot": + taken = _take_share(stock, WOOD, 0.3 * (1 - 0.25 * shed)) + _record(world, now, "hardship", kind, "πŸ„", "Damp got into the wood stores", + [f"Rot ruined {_listing(taken)}." if taken else "There was no wood in the stockpile to rot.", + *(["The lumber shed kept part of it dry."] if shed else ["A lumber shed would keep it dry."])]) + elif kind == "storm": + name = _damage_building(world, now, rng) + taken = _take_share(stock, FIREWOOD, 0.1) + lines = [f"The {name.lower()} {'were' if name.endswith('s') else 'was'} damaged and needs repairs." if name + else "Luckily there was nothing standing tall enough to break."] + if taken: + lines.append(f"The wind scattered {_listing(taken)}.") + _record(world, now, "hardship", kind, "πŸŒͺ️", "A wild storm battered the city", lines) + elif kind == "rats": + city["problems"].append({"kind": "rats", "started": now, "progress": {}, "work": 0}) + _record(world, now, "hardship", kind, "πŸ€", "Rats have got into the granary", + ["They'll eat into the food every hour until hunters catch them."]) + elif kind == "fire": + barrels = stock.get("water_barrel", 0) + if barrels >= 5: + take_stock_qty(stock, "water_barrel", 5) + _record(world, now, "hardship", kind, "πŸ”₯", "A fire broke out, and the water barrels put it out", + ["Five barrels were used up. Nothing else was lost."]) + else: + share = 0.25 * (1 - 0.25 * well) + taken = _take_share(stock, WOOD + tuple(_food_items(stock)), share) + name = _damage_building(world, now, rng) if rng.random() > 0.25 * well else None + lines = [f"The flames took {_listing(taken)}." if taken else "Little was lost from the stores."] + if name: + lines.append(f"The {name.lower()} {'were' if name.endswith('s') else 'was'} damaged and needs repairs.") + 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, + text="The city burns firewood from the stockpile every hour; without it, people go cold and hungry") + _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.", + "Without firewood, the cold makes everyone eat half again as much."]) + else: + _add_effect(world, now, 24, 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."]) + + +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): + weights["mercenaries"] = 0 + kind = kind or _pick(weights, rng) + if kind == "merchant": + city["merchant_until"] = now + 12 * HOUR / speed(world) + _record(world, now, "fortune", kind, "πŸͺ", "A merchant caravan has arrived", + ["For the next 12 hours, everything sells for 50% more."]) + elif kind == "refugees": + people = rng.randint(6, 12) + taken = min(max(0, housing(world) - int(city["population"])), people) + city["population"] += taken + lines = [f"{taken} of {people} refugees found a home."] + if taken < people: + lines.append("The rest moved on. More houses would have kept them.") + _record(world, now, "fortune", kind, "🧳", "Refugees arrived", lines) + 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="πŸŽ“", + xp={skill: 1.5}, text=f"{name} earns 50% more 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."]) + elif kind == "harvest": + _add_effect(world, now, 12, kind="harvest", name="Bountiful harvest", icon="🌾", **{"yield": {"farming": 2}}, + text="Farming yields doubled") + _record(world, now, "fortune", kind, "🌾", "The fields are bursting: a bountiful harvest", + ["Farming yields are doubled for 12 hours."]) + elif kind == "rich_vein": + _add_effect(world, now, 12, kind="rich_vein", name="Rich vein", icon="πŸ’Ž", **{"yield": {"mining": 1.5}}, + text="Mining yields 50% more") + _record(world, now, "fortune", kind, "πŸ’Ž", "Miners struck a rich vein", + ["Mining yields 50% more for 12 hours."]) + elif kind == "salmon": + _add_effect(world, now, 12, kind="salmon", name="Salmon run", icon="🐟", **{"yield": {"fishing": 2}}, + text="Fishing yields doubled") + _record(world, now, "fortune", kind, "🐟", "The salmon are running", + ["Fishing yields are doubled for 12 hours."]) + elif kind == "festival": + _add_effect(world, now, 24, kind="festival", name="Festival", icon="πŸŽ‰", growth=2, favour=1.5, + text="The population grows twice as fast, and giving to the city earns 50% more favour") + _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."]) + elif kind == "noble": + levels = sum(b["level"] for b in city["buildings"].values()) + gold = 100 + 40 * levels + city["treasury"] += gold + _record(world, now, "fortune", kind, "πŸ‘‘", "A noble visited and admired the city", + [f"Impressed by its {levels} building levels, they left {gold:,} gold for the treasury."]) + elif kind == "pilgrims": + lines = [] + if building_level(world, "temple"): + gift = 20 * building_level(world, "temple") + city["devotion"] = city.get("devotion", 0) + gift + lines.append(f"They prayed at the temple: +{gift} devotion.") + for player in world["players"].values(): + bank_add(world, player, "bones", 10, now, empty_summary()) + lines.append("They left 10 bones with every adventurer.") + _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, + cost=cost, defence=25, text=f"A sellsword company offers +25 defence for the coming threat for {cost:,} gold from the treasury") + _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."]) + else: + goods = rng.sample([("stone", 200), ("logs", 200), ("bricks", 80), ("bread", 60), ("arrows", 150), ("plank", 40)], 2) + for item, qty in goods: + city["stockpile"][item] = city["stockpile"].get(item, 0) + qty + _record(world, now, "fortune", kind, "πŸ“¦", "Lost cargo was found on the road", + [f"{_listing(dict(goods))} went into the stockpile."]) + + +def _minor_threat(world: dict, now: float, rng: random.Random, kind: str | None = None) -> None: + city = world["city"] + options = [k for k in MINOR_THREATS if not active_problem(world, k)] + kind = kind or rng.choice(options) + problem = {"kind": kind, "started": now, "progress": {}, "work": 0} + if "lasts" in PROBLEMS[kind]: + problem["until"] = now + PROBLEMS[kind]["lasts"] * HOUR / speed(world) + city["problems"].append(problem) + spec = PROBLEMS[kind] + _record(world, now, "minor", kind, spec["icon"], spec["name"], [spec["text"]]) + + +def _solve(world: dict, problem: dict, now: float, who: str | None = None) -> None: + world["city"]["problems"].remove(problem) + spec = PROBLEMS[problem["kind"]] + _record(world, now, "minor", problem["kind"], "βœ…", f"Solved: {spec['name'].lower()}", + [f"Thanks to {who}." if who else "It's over."]) + + +def problem_missing(problem: dict) -> dict: + need = PROBLEMS[problem["kind"]].get("need", {}) + return {item: qty - problem["progress"].get(item, 0) for item, qty in need.items() if problem["progress"].get(item, 0) < qty} + + +def aid_problem(world: dict, player: dict, kind: str, item: str, source: str, now: float) -> str | None: + """Give planks (or whatever a problem needs) from the bank or the stockpile.""" + problem = active_problem(world, kind) + if not problem: + return "That's not a problem right now" + need = problem_missing(problem).get(item, 0) + if not need: + return "It doesn't need that" + stock = world["city"]["stockpile"] + have = player["bank"].get(item, 0) if source == "bank" else stock.get(item, 0) + qty = min(have, need) + if qty <= 0: + return "Nothing to give" + if source == "bank": + bank_take(player, item, qty) + gain = qty * ITEMS[item]["value"] * city_rate(world, "favour") + player["favour"] += gain + player["donated"] += qty * ITEMS[item]["value"] + else: + take_stock_qty(stock, item, qty) + problem["progress"][item] = problem["progress"].get(item, 0) + qty + if not problem_missing(problem): + _solve(world, problem, now, player["name"]) + return None + + +def hire_mercenaries(world: dict, player: dict, now: float) -> str | None: + city = world["city"] + offer = next((e for e in city["effects"] if e.get("offer")), None) + if not offer: + return "Nobody is offering" + if city["treasury"] < offer["cost"]: + return f"The treasury needs {offer['cost']:,} gold" + city["treasury"] -= offer["cost"] + city["hired"] = city.get("hired", 0) + offer["defence"] + city["effects"].remove(offer) + log(world, now, "favour", f"{player['name']} hired the sellswords for {offer['cost']:,} gold (+{offer['defence']} defence)", player["id"]) + return None + + +def _happenings_hour(world: dict, now: float, rng: random.Random) -> None: + """Weather, firewood, thieves and expiries, once per game hour.""" + city = world["city"] + weather = city["weather"] + if now >= weather["until"]: + weather["kind"] = weather["next"] + weather["until"] = now + rng.uniform(4, 8) * HOUR / speed(world) + weather["next"] = _roll_weather(world, weather["until"], rng, weather["kind"]) + city["effects"] = [e for e in city["effects"] if e["until"] > now] + for problem in list(city["problems"]): + if problem.get("until") and problem["until"] <= now: + _solve(world, problem, now) + stock = city["stockpile"] + burn = city["population"] * sum(source.get("firewood", 0) for source in _modifier_sources(world)) + city["firewood_due"] = city.get("firewood_due", 0) + burn + cold = False + while city["firewood_due"] >= 1: + log_item = next((item for item in FIREWOOD if stock.get(item)), None) + if not log_item: + cold = True + city["firewood_due"] = 0 + break + take_stock_qty(stock, log_item, 1) + city["firewood_due"] -= 1 + if cold and not city.get("cold"): + log(world, now, "city", "The firewood has run out. People are cold and eating more to stay warm.") + city["cold"] = cold + for problem in city["problems"]: + spec = PROBLEMS[problem["kind"]] + if spec.get("steal"): + _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"))) + + +def _happenings_tracks(world: dict, now: float, rng: random.Random) -> bool: + changed = False + for track, (low, high) in TRACK_HOURS.items(): + due = world.get(f"next_{track}_at") + if due is None or now < due: + continue + if track == "minor" and any(p["kind"] in MINOR_THREATS for p in world["city"]["problems"]): + world[f"next_{track}_at"] = now + 6 * HOUR / speed(world) + continue + {"minor": _minor_threat, "hardship": _hardship, "fortune": _fortune}[track](world, now, rng) + world[f"next_{track}_at"] = now + rng.uniform(low, high) * HOUR / speed(world) + changed = True + return changed + + +def happenings_view(world: dict, now: float) -> dict: + city = world["city"] + weather = city["weather"] + problems = [] + for problem in city["problems"]: + spec = PROBLEMS[problem["kind"]] + problems.append({ + "kind": problem["kind"], "name": spec["name"], "icon": spec["icon"], "text": spec["text"], + "until": problem.get("until"), "work": problem["work"], "needed": spec.get("work"), + "action": spec.get("action"), "need": spec.get("need"), "missing": problem_missing(problem), + }) + return { + "season": season(world, now), + "weather": {**{k: v for k, v in WEATHER[weather["kind"]].items()}, "kind": weather["kind"], "until": weather["until"], + "next": weather["next"], "next_name": WEATHER[weather["next"]]["name"], + "next_icon": WEATHER[weather["next"]]["icon"]}, + "effects": [{k: e[k] for k in ("kind", "name", "icon", "text", "until", "speed", "yield", "xp") if k in e} + | {"offer": e.get("offer", False), "cost": e.get("cost")} for e in city["effects"]], + "problems": problems, + "cold": city.get("cold", False), + "hired": city.get("hired", 0), + } + + # --- favour -------------------------------------------------------------- BUILDER_FAVOUR_PER_LABOUR = 0.67 @@ -1747,6 +2356,6 @@ def content() -> dict: "buildings": buildings, "xp_table": XP_TABLE, "offline_cap": OFFLINE_CAP, "tiers": TIERS, "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, + "potion_charges": POTION_CHARGES, "problems": PROBLEMS, "equip_slots": EQUIP_SLOTS, "eat_below": EAT_BELOW, "regen_per_hour": REGEN_PER_HOUR, } diff --git a/static/app.js b/static/app.js index 45e4a6d..c4ecd07 100644 --- a/static/app.js +++ b/static/app.js @@ -249,7 +249,10 @@ function devbar() { - + + + + `; } @@ -267,12 +270,14 @@ function topbar() { } else if (S.me) { chips += `Idle${S.me.stopped ? ` Β· ${esc(S.me.stopped)}` : ""}`; } - if (S.upcoming) chips += ``; + const w = S.happenings?.weather; + if (w) chips += ``; + if (S.upcoming) chips += ``; return `

${esc(title)}

${chips}`; } function eventIcon(kind) { - return { raid: "βš”οΈ", merchant: "πŸͺ", refugees: "🧳" }[kind] || "❗"; + return { raid: "βš”οΈ", dragon: "πŸ‰", merchant: "πŸͺ", refugees: "🧳" }[kind] || "❗"; } function eventWhen(ev, short = false) { @@ -328,7 +333,9 @@ function skillPage(id) { 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.
`; - const actions = C.actions.filter((a) => a.skill === id); + 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))); const groups = [...new Set(actions.map((a) => a.group).filter(Boolean))]; if (groups.length > 2) { // Many tiers: one tab per tier, opening on the highest one you can work. @@ -597,7 +604,9 @@ function town() {
Food
${city.food_days ?? 0} days
${fmt(city.food)} meals in the granary
Treasury
${fmt(city.treasury)}
Day ${city.day}
Defence
${fmt(city.defence.total)}
${city.repelled} raid${city.repelled === 1 ? "" : "s"} repelled
+ ${weatherCard()} `; + html += happeningsPanel(); if (city.starving) html += ``; if (city.merchant_until > serverNow()) html += ``; if (city.construction) html += siteBanner(); @@ -631,6 +640,61 @@ function siteBanner() { `; } +function weatherCard() { + const h = S.happenings, w = h.weather; + return `
${h.season.icon} ${h.season.name}, day ${h.season.day}
+
${w.icon} ${esc(w.name)}
+
${w.next === w.kind ? `Staying ${esc(w.name.toLowerCase())}` : `${w.next_icon} ${esc(w.next_name)}`} in ${countdown(w.until)}
`; +} + +// Everything going on in the city right now, and what anyone can do about it. +function happeningsPanel() { + const h = S.happenings, me = S.me, city = S.city; + const rows = []; + if (h.weather.kind !== "clear") rows.push(`
${h.weather.icon}${esc(h.weather.name)}${esc(h.weather.text)}
`); + if (h.cold) rows.push(`
πŸ₯ΆOut of firewoodPeople are cold and eating half again as much. Donate logs.
`); + for (const e of h.effects) { + const hire = e.offer ? `` : ""; + rows.push(`
${e.icon}${esc(e.name)} + ${esc(e.text)} Β· ${countdown(e.until)} left${hire}
`); + } + for (const p of h.problems) { + let help = ""; + if (p.action) { + const a = C.actions.find((x) => x.id === p.action); + help = `${fmt(p.work)} / ${fmt(p.needed)} done + `; + } + const gifts = Object.entries(p.need || {}).map(([id, need]) => { + const missing = p.missing[id] || 0; + const mine = Math.min(missing, me.bank[id] || 0), stores = Math.min(missing, city.stockpile[id] || 0); + return `
${costChip(item(id).icon, item(id).name, need, need - missing)} + ${mine ? `` : ""} + ${stores ? `` : ""} +
`; + }).join(""); + rows.push(`
${p.icon}${esc(p.name)} + ${esc(p.text)}${p.until ? ` Β· ${countdown(p.until)} left` : ""}${gifts}${help}
`); + } + if (!rows.length) return ""; + return `

Happenings

${rows.join("")}
`; +} + +// What's speeding up, slowing down or blocking this skill right now. +function skillHappenings(skill) { + const h = S.happenings, lines = []; + const note = (icon, name, text) => lines.push(`${icon} ${esc(name)} ${esc(text)}`); + const touches = (src) => ["speed", "yield", "xp"].some((k) => src[k] && skill in src[k]) || (src.block && skill in src.block); + if (touches(h.weather)) note(h.weather.icon, h.weather.name, h.weather.text); + for (const e of h.effects) if (touches(e)) note(e.icon, e.name, e.text); + for (const p of h.problems) { + if (!C.problems[p.kind] || !touches(C.problems[p.kind])) continue; + const fix = p.action && C.actions.find((x) => x.id === p.action); + note(p.icon, p.name, !fix ? p.text : fix.skill === skill ? "Help clear it below." : `${skillOf(fix.skill).name} can clear it: see the Town square.`); + } + return lines.length ? `
${lines.join("")}
` : ""; +} + function buildingCard(key, b) { const spec = C.buildings[key], city = S.city, me = S.me; const tags = { @@ -673,8 +737,9 @@ function council() { if (!ev) { html += `

All quiet

No news on the roads. The spymasters will report as soon as something stirs.

`; } else { - html += `
-

${eventIcon(ev.kind)} ${ev.kind === "raid" ? "Threat" : "News"} Β· intel ${pips(ev.intel)}

+ const threat = ["raid", "dragon"].includes(ev.kind); + html += `
+

${eventIcon(ev.kind)} ${threat ? "Threat" : "News"} Β· intel ${pips(ev.intel)}

${esc(ev.headline)}
Expected${eventWhen(ev)} @@ -684,22 +749,24 @@ function council() { ${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`}` : ""}
${ev.intel < 3 ? `

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

` : ""}`; - if (ev.kind === "raid") { + if (threat) { const strength = ev.strength ?? (ev.range ? `${ev.range[0]}–${ev.range[1]}` : "?"); html += `
Our defence${fmt(city.defence.total)}
vs
Raiders${strength}
`; } html += `
`; } html += favourSpends(); - if (ev?.kind === "raid") html += raidPrep(ev); + if (ev && ["raid", "dragon"].includes(ev.kind)) html += raidPrep(ev); html += `
Past events
`; if (!S.history.length) html += `

Nothing has happened yet.

`; for (const h of S.history) { if (!h.report) continue; - const win = h.kind !== "raid" || h.report.title.startsWith("Victory"); - html += `
- ${eventIcon(h.kind)} ${esc(h.report.title)} ${ago(h.at)} - ${h.kind === "raid" ? `

Defence ${fmt(h.report.defence)} vs raiders ${fmt(h.report.attack)}${h.report.garrison.length ? ` Β· garrison: ${h.report.garrison.map((g) => esc(g.name)).join(", ")}` : ""}

` : ""} + const fight = ["raid", "dragon"].includes(h.kind); + const tone = fight ? (h.report.title.startsWith("Victory") ? "win" : "loss") + : { fortune: "win", hardship: "loss", minor: "loss" }[h.track] || ""; + html += `
+ ${h.report.icon || eventIcon(h.kind)} ${esc(h.report.title)} ${ago(h.at)} + ${fight ? `

Defence ${fmt(h.report.defence)} vs ${h.kind === "dragon" ? "the dragon" : "raiders"} ${fmt(h.report.attack)}${h.report.garrison.length ? ` Β· garrison: ${h.report.garrison.map((g) => esc(g.name)).join(", ")}` : ""}

` : ""} ${h.report.lines.map((l) => `

${esc(l)}

`).join("")}
`; } @@ -727,7 +794,7 @@ function favourSpends() { function raidPrep(ev) { const city = S.city, me = S.me, d = city.defence; - const names = { ward: "Divine ward (temple)", rallied: "Rallied militia (favour)", townsfolk: "Townsfolk with pitchforks", walls: "Walls", militia: "Militia (barracks)", weapons: "Swords in the armoury", shields: "Shields in the armoury", arrows: "Arrows", garrison: "Garrison" }; + const names = { mercenaries: "Hired sellswords", ward: "Divine ward (temple)", rallied: "Rallied militia (favour)", townsfolk: "Townsfolk with pitchforks", walls: "Walls", militia: "Militia (barracks)", weapons: "Swords in the armoury", shields: "Shields in the armoury", arrows: "Arrows", garrison: "Garrison" }; const reqs = (ev.requests || []).map((r) => { const mine = me.bank[r.item] || 0; return `
${item(r.item).icon}${esc(r.label)} ${fmt(r.have)} / ${fmt(r.want)} @@ -933,6 +1000,9 @@ document.addEventListener("click", (e) => { if (d.favour) return act("/api/favour", { kind: d.favour }); if (d.food) return act("/api/food", { item: d.food }); if (d.potion) return act("/api/potion", { item: d.potion }); + if (d.aid) return act("/api/aid", { kind: d.aid, item: d.item, source: d.source }); + if ("hire" in d) return act("/api/hire", {}); + if (d.devHappen) return act("/api/dev/happen", { track: d.devHappen }); if (d.pray) { const on = S.me.prayers.includes(d.pray); if (!on && S.me.prayers.length >= C.max_prayers) return toast(`Only ${C.max_prayers} prayers at once: turn one off first`, "bad"); diff --git a/static/styles.css b/static/styles.css index f42ba65..5f50fa1 100644 --- a/static/styles.css +++ b/static/styles.css @@ -388,3 +388,10 @@ button.sheet-skill:hover { background: var(--panel-2); } .prayer-points { font-size: 15px; } .site-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } .site-head .btns { margin-left: auto; } +.happenings { display: flex; flex-direction: column; gap: 8px; } +.happening { display: flex; align-items: flex-start; gap: 12px; padding: 8px 10px; border-radius: 10px; background: var(--panel-2); border: 1px solid var(--line); } +.happening.bad { border-color: rgba(226, 100, 92, .4); } +.happening .ico { font-size: 22px; width: 28px; text-align: center; flex: none; } +.happening .grow { display: flex; flex-direction: column; gap: 4px; } +.happening > .btn, .happening > .small { align-self: center; } +.skill-happenings { flex-direction: column; align-items: flex-start; gap: 4px; } diff --git a/tests/test_game.py b/tests/test_game.py index 4ac10df..5b80b9a 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -828,5 +828,164 @@ class ConstructionTest(unittest.TestCase): self.assertIsNone(p["equip"]["tool_construction"]) + +class HappeningsTest(unittest.TestCase): + def setUp(self): + self.world = world_with(("ann", "warrior"), ("sam", "scout")) + self.world["next_event_at"] = None + for track in ("minor", "hardship", "fortune"): + self.world[f"next_{track}_at"] = None + self.city = self.world["city"] + self.rng = random.Random(4) + + def test_weather_rolls_and_seasons_turn(self): + seen = set() + for hour in range(1, 24 * 14): + game.tick(self.world, T0 + hour * game.HOUR, self.rng) + seen.add(self.city["weather"]["kind"]) + self.assertGreaterEqual(len(seen), 3) + self.assertEqual(game.season(self.world, T0)["name"], "Spring") + self.assertEqual(game.season(self.world, T0 + 8 * game.DAY)["name"], "Summer") + self.assertEqual(game.season(self.world, T0 + 22 * game.DAY)["name"], "Winter") + + def test_weather_changes_skill_speed(self): + p = self.world["players"]["ann"] + farm = game.ACTION_BY_ID["farm_wheat"] + plain = game.action_time(self.world, p, farm) + self.city["weather"]["kind"] = "heat" + self.assertAlmostEqual(game.action_time(self.world, p, farm), plain * 0.85) + self.assertAlmostEqual(game.city_rate(self.world, "food"), 1.15) + + def test_sickness_makes_the_city_eat_more(self): + game._hardship(self.world, T0, self.rng, "sickness") + self.assertAlmostEqual(game.city_rate(self.world, "food"), 1.5) + self.assertEqual(self.world["events"][-1]["report"]["icon"], "πŸ€’") + + def test_locusts_and_rot_take_from_stores_and_buildings_protect(self): + self.city["stockpile"] = {"bread": 100, "wheat": 100, "logs": 100, "stone": 100} + game._hardship(self.world, T0, self.rng, "locusts") + self.assertEqual(self.city["stockpile"]["bread"], 60) + self.assertEqual(self.city["stockpile"]["stone"], 100) + self.city["buildings"]["lumber_shed"]["level"] = 2 + game._hardship(self.world, T0, self.rng, "rot") + self.assertEqual(self.city["stockpile"]["logs"], 85) + + def test_storm_damages_a_building(self): + self.city["buildings"]["walls"]["level"] = 1 + game._hardship(self.world, T0, self.rng, "storm") + self.assertTrue(self.city["buildings"]["walls"]["damaged"]) + + def test_fire_is_put_out_by_water_barrels(self): + self.city["stockpile"] = {"water_barrel": 6, "logs": 100} + game._hardship(self.world, T0, self.rng, "fire") + self.assertEqual(self.city["stockpile"], {"water_barrel": 1, "logs": 100}) + self.assertIn("the fire brigade", game.city_uses("water_barrel")) + + def test_winter_burns_firewood_or_the_city_goes_cold(self): + game._hardship(self.world, T0, self.rng, "winter") + self.city["stockpile"] = {"logs": 3} + game.tick(self.world, T0 + 4 * game.HOUR, self.rng) + self.assertNotIn("logs", self.city["stockpile"]) + self.assertTrue(self.city["cold"]) + + def test_wolves_halve_farming_until_fighters_clear_them(self): + game._minor_threat(self.world, T0, self.rng, "wolves") + p = self.world["players"]["ann"] + self.assertEqual(game.skill_mods(self.world, game.ACTION_BY_ID["farm_wheat"])["yield"], 0.5) + p["xp"]["combat"] = game.XP_TABLE[30] + p["hp"] = game.max_hp(p) + self.assertIsNone(game.start_action(self.world, p, "clear_wolves", T0)) + summary = game.process_player(self.world, p, T0 + 3600, random.Random(1)) + self.assertIsNone(game.active_problem(self.world, "wolves")) + self.assertEqual(summary["stopped"], "That's been dealt with") + + def test_collapse_blocks_ore_until_planks_shore_it_up(self): + game._minor_threat(self.world, T0, self.rng, "collapse") + p = self.world["players"]["ann"] + self.assertIn("collapsed", game.start_action(self.world, p, "mine_copper", T0)) + self.assertIsNone(game.start_action(self.world, p, "mine_stone", T0)) + p["bank"] = {"plank": 50} + self.assertIsNone(game.aid_problem(self.world, p, "collapse", "plank", "bank", T0)) + self.assertEqual(p["bank"], {"plank": 20}) + self.assertIsNone(game.active_problem(self.world, "collapse")) + + def test_flood_recedes_on_its_own(self): + game._minor_threat(self.world, T0, self.rng, "flood") + game.tick(self.world, T0 + 37 * game.HOUR, self.rng) + self.assertIsNone(game.active_problem(self.world, "flood")) + + def test_goblins_steal_every_hour(self): + self.city["stockpile"] = {"stone": 1000} + game._minor_threat(self.world, T0, self.rng, "goblins") + game.tick(self.world, T0 + 5 * game.HOUR, self.rng) + self.assertLess(self.city["stockpile"]["stone"], 1000) + + def test_fortunes(self): + game._fortune(self.world, T0, self.rng, "harvest") + self.assertEqual(game.skill_mods(self.world, game.ACTION_BY_ID["farm_wheat"])["yield"], 2) + game._fortune(self.world, T0, self.rng, "master") + self.assertTrue(any(e["kind"] == "master" for e in self.city["effects"])) + before = self.city["treasury"] + game._fortune(self.world, T0, self.rng, "noble") + self.assertGreater(self.city["treasury"], before) + game._fortune(self.world, T0, self.rng, "pilgrims") + self.assertEqual(self.world["players"]["ann"]["bank"]["bones"], 10) + game.tick(self.world, T0 + 13 * game.HOUR, self.rng) + self.assertFalse(any(e["kind"] == "harvest" for e in self.city["effects"])) + + def test_festival_boosts_favour(self): + game._fortune(self.world, T0, self.rng, "festival") + p = self.world["players"]["ann"] + p["bank"] = {"stone": 100} + game.donate(self.world, p, "stone", 100, T0) + self.assertEqual(p["favour"], 150) + + def test_mercenaries_join_the_next_defence(self): + self.world["next_event_at"] = T0 + game.tick(self.world, T0, self.rng) + game._fortune(self.world, T0, self.rng, "mercenaries") + p = self.world["players"]["ann"] + self.city["treasury"] = 5000 + before = game.defence(self.world)["total"] + self.assertIsNone(game.hire_mercenaries(self.world, p, T0)) + self.assertEqual(game.defence(self.world)["total"], before + 25) + + def test_dragons_burn_when_they_win(self): + self.city["buildings"]["houses"]["level"] = 1 + self.city["stockpile"] = {"logs": 500, "bread": 200} + self.world["events"].append({"id": 99, "kind": "dragon", "status": "upcoming", "announced": T0, + "fire_at": T0 + 10, "recon": 0, "sabotage": 0.0, "jitter": 0, + "strength": 1000, "group": "a green dragon", "trait": "x"}) + self.assertEqual(game.upcoming_raid(self.world)["kind"], "dragon") + game.tick(self.world, T0 + 20, self.rng) + report = self.world["events"][-1]["report"] + self.assertIn("ablaze", report["title"]) + self.assertTrue(self.city["buildings"]["houses"]["damaged"]) + self.assertLess(self.city["stockpile"]["logs"], 500) + + def test_tracks_fire_on_their_own_cooldowns(self): + self.world["next_fortune_at"] = T0 + self.world["next_hardship_at"] = T0 + game.tick(self.world, T0 + 1, self.rng) + tracks = [e.get("track") for e in self.world["events"]] + self.assertIn("fortune", tracks) + self.assertIn("hardship", tracks) + self.assertGreater(self.world["next_fortune_at"], T0 + 30 * game.HOUR) + + def test_v7_worlds_get_weather_and_tracks(self): + world = world_with() + world["version"] = 7 + for key in ("weather", "effects", "problems", "hired"): + del world["city"][key] + for key in ("granary", "lumber_shed", "well"): + del world["city"]["buildings"][key] + for track in ("minor", "hardship", "fortune"): + del world[f"next_{track}_at"] + game.migrate(world, T0) + self.assertEqual(world["city"]["weather"]["kind"], "clear") + self.assertIn("granary", world["city"]["buildings"]) + self.assertIsNotNone(world["next_fortune_at"]) + + if __name__ == "__main__": unittest.main()