diff --git a/README.md b/README.md index 1ccf66c..4a1c1d5 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,12 @@ Alpha. One season, bandit raids plus two friendly events, six buildings. time: faster gathering, a chance to keep materials or double ore, more prayer from bones, or combat power, armour and healing. An empty potion is replaced from the bank automatically. -- **Events:** raids are announced a day or two ahead. Intel (spymaster's - guild + scout reconnaissance) narrows down when, how strong, and finally - where their camp is, so fighters can sabotage it. Defeat kills or drives +- **Events:** raids are announced a day or two ahead. Reconnaissance from + scouts, informants and the spymaster's guild narrows down when, how strong, + and finally where their camp is, so fighters can sabotage it. Each intel + level takes more scouting than the last and more for a bigger band, and + levels 2 and 3 only open about a day and eight hours before the raid + arrives. Defeat kills or drives off people, loots the stockpile and treasury, and may damage a building. Damaged buildings stop working until repaired but keep their level. Every fight uses up arrows and breaks some of the swords and shields diff --git a/app.py b/app.py index 8631744..64bb279 100644 --- a/app.py +++ b/app.py @@ -116,6 +116,8 @@ def player_view(world: dict, player: dict, now: float) -> dict: view["action"] = {**player["action"], "duration": game.action_time(world, player, action)} view["times"] = {action["id"]: game.action_time(world, player, action) for action in game.ACTIONS} view["blocked"] = {action["id"]: game.can_start(world, player, action) for action in game.ACTIONS} + view["recon_gain"] = {action["id"]: game.recon_gain(world, action) for action in game.ACTIONS + if action.get("special") == "recon"} return view diff --git a/game.py b/game.py index 8c883bb..5dad9b3 100644 --- a/game.py +++ b/game.py @@ -20,7 +20,6 @@ BASE_STACK = 1000 CLASS_SPEED = 0.8 LUCKY_CHANCE = 0.1 OVERFLOW_FAVOUR = 0.5 # favour for items a full bank spills to the city, vs. giving on purpose -RECON_PER_INTEL = 8 MAX_LOG = 200 REGEN_PER_HOUR = 0.25 # share of max HP recovered per game hour EAT_BELOW = 0.5 # auto-eat when HP drops under this share of max @@ -457,7 +456,7 @@ BUILDINGS = { "name": "Spymaster's guild", "icon": "🕵️", "max": 3, "gold": 400, "base": {"logs": 500, "bricks": 300, "hide": 100, "feathers": 300}, "advanced": {"cut_stone": 150}, "late": {"bear_leather": 40, "oak_plank": 40}, - "effect": "One more level of intel on every event per level", + "effect": "+25% reconnaissance per level, and its agents gather some every hour on their own", }, "storehouse": { "name": "Storehouse", "icon": "📦", "max": 5, "gold": 200, @@ -1095,9 +1094,9 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R summary["losses"] += 1 xp = xp // 3 if action.get("special") == "recon": - event = upcoming_event(world) + event = upcoming_raid(world) if event: - event["recon"] += action["recon"] + fitted(world, "map_table") + event["recon"] += recon_gain(world, action) if action.get("special") == "problem" and action["skill"] != "combat": _work_problem(world, player, action, now) if action.get("special") == "labour": @@ -1572,6 +1571,9 @@ def _city_hour(world: dict, now: float) -> None: pop = city["population"] if city["construction"]: city["construction"]["labour"] += pop * TOWNSFOLK_LABOUR + threat = upcoming_raid(world) + if threat and building_level(world, "spymaster"): + threat["recon"] += SPYMASTER_AGENTS * building_level(world, "spymaster") city["food_due"] += (pop * 0.5 / 24 * city_rate(world, "food") * (1.5 if city.get("cold") else 1) * (0.9 if fitted(world, "grain_bins") else 1)) stock = city["stockpile"] @@ -1663,8 +1665,45 @@ def _announce(world: dict, now: float, rng: random.Random) -> None: del world["events"][:-30] -def intel_level(world: dict, event: dict) -> int: - return min(3, building_level(world, "spymaster") + event["recon"] // RECON_PER_INTEL) +# Intel: each level takes more reconnaissance than the last, a bigger band is +# harder to read (a dragon is hard to miss), and the sharper levels only become +# knowable as the threat draws near, however much scouting is done. +INTEL_POINTS = [15, 30, 45] # recon for intel levels 1, 2 and 3 against a small band +INTEL_SIZE = [1.0, 1.33, 1.67, 2.0] # small, medium, large, huge +INTEL_OPENS = [None, 24, 8] # game hours before arrival that levels 2 and 3 open, ±25% per threat +SPYMASTER_RECON = 0.25 # more recon per guild level +SPYMASTER_AGENTS = 0.5 # recon per guild level per game hour, with nobody scouting +INFORMANT_RECON = 15 + + +def recon_gain(world: dict, action: dict) -> float: + return (action["recon"] + fitted(world, "map_table")) * (1 + SPYMASTER_RECON * building_level(world, "spymaster")) + + +def intel_costs(event: dict) -> list[int]: + """Total recon needed for each intel level.""" + size = next(i for i, (limit, _label) in enumerate(SIZE_LABELS) if event["strength"] < limit) + scale = INTEL_SIZE[size] * (0.5 if event["kind"] == "dragon" else 1) + return [round(sum(INTEL_POINTS[:level]) * scale) for level in (1, 2, 3)] + + +def intel_opens(world: dict, event: dict, level: int) -> float: + """When an intel level becomes knowable. The ±25% is fixed per threat, so it can't be timed.""" + hours = INTEL_OPENS[level - 1] + if hours is None: + return 0 + wobble = random.Random(f"{event['id']}-{level}").uniform(0.75, 1.25) + return event["fire_at"] - hours * wobble * HOUR / speed(world) + + +def intel_level(world: dict, event: dict, now: float | None = None) -> int: + now = world["city"]["last_tick"] if now is None else now + level = 0 + for next_level, need in enumerate(intel_costs(event), 1): + if event["recon"] < need or now < intel_opens(world, event, next_level): + break + level = next_level + return level SIZE_LABELS = [(30, "small"), (60, "medium"), (100, "large"), (math.inf, "huge")] @@ -1673,14 +1712,16 @@ WINDOW_HOURS = [24, 8, 2, 0] def event_view(world: dict, event: dict, now: float) -> dict: """What the city knows about an upcoming event, filtered by intel.""" - level = intel_level(world, event) + level = intel_level(world, event, now) half = WINDOW_HOURS[level] * HOUR / speed(world) + costs = intel_costs(event) centre = event["fire_at"] + event["jitter"] * half * 0.6 view = { "id": event["id"], "kind": event["kind"], "intel": level, "earliest": max(now, centre - half), "latest": max(now, centre + half), "exact": event["fire_at"] if level >= 3 else None, - "recon": event["recon"], "recon_next": RECON_PER_INTEL - event["recon"] % RECON_PER_INTEL, + "recon": int(event["recon"]), "recon_full": costs[-1], + "recon_next": max(0, round(costs[level] - event["recon"])) if level < 3 else 0, } if event["kind"] == "dragon": strength = event["strength"] @@ -2453,7 +2494,6 @@ def favours(world: dict, now: float) -> dict: """What favour can buy for the city right now: cost, and why not if it can't.""" city = world["city"] work = city["construction"] - event = upcoming_event(world) raid = upcoming_raid(world) return { "builders": { @@ -2464,8 +2504,8 @@ def favours(world: dict, now: float) -> dict: }, "informant": { "cost": INFORMANT_FAVOUR, - "why": "No news to dig into" if not event else - "Nothing more to learn" if intel_level(world, event) >= 3 else None, + "why": "No news to dig into" if not raid else + "Nothing more to learn" if raid["recon"] >= intel_costs(raid)[-1] else None, }, "militia": { "cost": RALLY_FAVOUR * (city.get("rallies", 0) + 1), @@ -2494,7 +2534,7 @@ def spend_favour(world: dict, player: dict, kind: str, now: float) -> str | None log(world, now, "favour", f"{player['name']} hired a master builder for the {name}: " f"the next {MASTER_BUILDER_LABOUR} labour counts double", player["id"]) elif kind == "informant": - upcoming_event(world)["recon"] += RECON_PER_INTEL + upcoming_raid(world)["recon"] += INFORMANT_RECON log(world, now, "favour", f"{player['name']} paid an informant for news from the roads", player["id"]) else: city["rally"] = city.get("rally", 0) + RALLY_DEFENCE @@ -2656,5 +2696,6 @@ def content() -> dict: "city_uses": {item: city_uses(item) for item in ITEMS}, "prayers": [{**prayer, "text": describe_prayer(prayer)} for prayer in PRAYERS], "max_prayers": MAX_PRAYERS, "potion_charges": POTION_CHARGES, "problems": PROBLEMS, "fittings": list(FITTING_BY_ID.values()), - "equip_slots": EQUIP_SLOTS, "eat_below": EAT_BELOW, "class_speed": CLASS_SPEED, "regen_per_hour": REGEN_PER_HOUR, + "equip_slots": EQUIP_SLOTS, "eat_below": EAT_BELOW, "class_speed": CLASS_SPEED, + "informant_recon": INFORMANT_RECON, "regen_per_hour": REGEN_PER_HOUR, } diff --git a/static/app.js b/static/app.js index 069bae3..96e7f89 100644 --- a/static/app.js +++ b/static/app.js @@ -344,7 +344,7 @@ function skillPage(id) { : `
Better intel comes from the spymaster's guild and scouts' reconnaissance.
` : ""}`; + ${ev.intel < 3 ? `Better intel comes from the spymaster's guild and scouts' reconnaissance, and the sharpest only as the threat draws near.
` : ""}`; if (threat) { const strength = ev.strength ?? (ev.range ? `${ev.range[0]}–${ev.range[1]}` : "?"); html += `