From 0b7e4a722f2bab578fe49ea752c9f6a09e435c3f Mon Sep 17 00:00:00 2001 From: rope Date: Mon, 19 Oct 2026 18:33:50 +0100 Subject: [PATCH] Weather changes on time; repeat rolls lengthen the spell Co-Authored-By: Claude Opus 5.5 --- game.py | 31 ++++++++++++++++++++++++++----- static/app.js | 5 ++--- tests/test_game.py | 13 +++++++++++++ 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/game.py b/game.py index 6dd13a5..6b8fd72 100644 --- a/game.py +++ b/game.py @@ -1563,6 +1563,7 @@ def tick(world: dict, now: float, rng: random.Random | None = None) -> bool: _happenings_hour(world, city["last_tick"], rng) _city_hour(world, city["last_tick"]) changed = True + changed = advance_weather(world, now, rng) or changed changed = _happenings_tracks(world, now, rng) or changed construction = city["construction"] if construction and construction["labour"] >= construction["needed"]: @@ -2134,6 +2135,30 @@ def _roll_weather(world: dict, now: float, rng: random.Random, current: str | No return _pick(SEASON_WEATHER[season(world, now)["index"]], rng) +def advance_weather(world: dict, now: float, rng: random.Random) -> bool: + """Change the weather when its spell ends. Returns True if it changed.""" + weather = world["city"]["weather"] + if now < weather["until"]: + return False + while now >= weather["until"]: + weather["kind"] = weather["next"] + _plan_spell(world, weather, rng) + return True + + +def _plan_spell(world: dict, weather: dict, rng: random.Random) -> None: + """Set how long the current weather lasts and what follows. Rolls that keep the + same weather lengthen the spell, so `until` is when it really changes.""" + for _ in range(3): + weather["until"] += rng.uniform(4, 8) * HOUR / speed(world) + weather["next"] = _roll_weather(world, weather["until"], rng, weather["kind"]) + if weather["next"] != weather["kind"]: + return + # Three spells of the same is long enough: something else comes next. + weights = SEASON_WEATHER[season(world, weather["until"])["index"]] + weather["next"] = _pick({kind: w for kind, w in weights.items() if kind != weather["kind"]}, rng) + + def _modifier_sources(world: dict) -> list[dict]: city = world["city"] sources = [WEATHER[city["weather"]["kind"]]] + city["effects"] @@ -2437,11 +2462,7 @@ def hire_mercenaries(world: dict, player: dict, now: float) -> str | 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"]) + advance_weather(world, now, rng) 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: diff --git a/static/app.js b/static/app.js index f650357..3309aa1 100644 --- a/static/app.js +++ b/static/app.js @@ -681,15 +681,14 @@ function siteBanner() { // "Rain: farming 10% faster. Clear skies in 3h 20m." function weatherTip(w) { - const next = w.next === w.kind ? `Staying ${w.name.toLowerCase()}` : w.next_name; - return `${w.name}: ${w.text.charAt(0).toLowerCase()}${w.text.slice(1)}. ${next} in ${dur(w.until - serverNow())}.`; + return `${w.name}: ${w.text.charAt(0).toLowerCase()}${w.text.slice(1)}. ${w.next_name} in ${dur(w.until - serverNow())}.`; } 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)}
`; +
${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. diff --git a/tests/test_game.py b/tests/test_game.py index 04b9d9d..f32e628 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -302,6 +302,7 @@ class CityTest(unittest.TestCase): world = world_with() city = world["city"] city["stockpile"] = {"bread": 5} + world["next_fortune_at"] = None # no lucky wagon of bread game.tick(world, T0 + 4 * game.DAY, random.Random(1)) self.assertTrue(city["starving"]) self.assertNotIn("bread", city["stockpile"]) @@ -953,6 +954,18 @@ class HappeningsTest(unittest.TestCase): 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_on_time_and_always_to_something_new(self): + weather = self.city["weather"] + weather.update(kind="rain", next="clear", until=T0 + 10 * 60) + self.city["last_tick"] = T0 + game.tick(self.world, T0 + 11 * 60, self.rng) # well inside the hour + self.assertEqual(weather["kind"], "clear") + for _ in range(50): + spell = weather["until"] + game.tick(self.world, spell, self.rng) + self.assertNotEqual(weather["next"], weather["kind"]) + self.assertGreater(weather["until"], spell) + def test_days_count_midnights_not_24_hour_stretches(self): from datetime import datetime late = datetime(2026, 10, 18, 23, 0).timestamp()