Happenings: weather and seasons, minor threats, hardships, fortunes, dragons
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a4e4bd4385
commit
75327f30e0
6 changed files with 903 additions and 38 deletions
|
|
@ -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
|
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.
|
off people, loots the stockpile and treasury, and may damage a building.
|
||||||
Damaged buildings stop working until repaired but keep their level.
|
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
|
## Running
|
||||||
|
|
||||||
|
|
|
||||||
16
app.py
16
app.py
|
|
@ -330,6 +330,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
return "There's no raid to stand guard for"
|
return "There's no raid to stand guard for"
|
||||||
player["garrison"] = bool(body.get("join"))
|
player["garrison"] = bool(body.get("join"))
|
||||||
return None
|
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":
|
if path == "/api/potion":
|
||||||
return game.use_potion(player, str(body.get("item")))
|
return game.use_potion(player, str(body.get("item")))
|
||||||
if path == "/api/prayers":
|
if path == "/api/prayers":
|
||||||
|
|
@ -409,6 +413,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
world["config"]["clock_offset"] += hours * game.HOUR / game.speed(world)
|
world["config"]["clock_offset"] += hours * game.HOUR / game.speed(world)
|
||||||
game.tick(world, self.store.now(), self.store.rng)
|
game.tick(world, self.store.now(), self.store.rng)
|
||||||
return None
|
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":
|
if path == "/api/dev/event":
|
||||||
world["next_event_at"] = self.store.now()
|
world["next_event_at"] = self.store.now()
|
||||||
game.tick(world, self.store.now(), self.store.rng)
|
game.tick(world, self.store.now(), self.store.rng)
|
||||||
|
|
@ -421,9 +432,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
player = world["players"].get(uid)
|
player = world["players"].get(uid)
|
||||||
upcoming = game.upcoming_event(world)
|
upcoming = game.upcoming_event(world)
|
||||||
history = [
|
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"
|
for e in reversed(world["events"]) if e["status"] == "done"
|
||||||
][:10]
|
][:15]
|
||||||
return {
|
return {
|
||||||
"now": now, "dev": self.dev, "uid": uid, "version": VERSION,
|
"now": now, "dev": self.dev, "uid": uid, "version": VERSION,
|
||||||
"me": player_view(world, player, now) if player else None,
|
"me": player_view(world, player, now) if player else None,
|
||||||
|
|
@ -431,6 +442,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
"city": city_view(world, now),
|
"city": city_view(world, now),
|
||||||
"upcoming": game.event_view(world, upcoming, now) if upcoming else None,
|
"upcoming": game.event_view(world, upcoming, now) if upcoming else None,
|
||||||
"favours": game.favours(world, now),
|
"favours": game.favours(world, now),
|
||||||
|
"happenings": game.happenings_view(world, now),
|
||||||
"history": history,
|
"history": history,
|
||||||
"players": roster(world, now),
|
"players": roster(world, now),
|
||||||
"ledger": world["ledger"][-80:],
|
"ledger": world["ledger"][-80:],
|
||||||
|
|
|
||||||
655
game.py
655
game.py
|
|
@ -434,6 +434,24 @@ BUILDINGS = {
|
||||||
"late": {"willow_logs": 150, "oak_plank": 60},
|
"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",
|
"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": {
|
"spymaster": {
|
||||||
"name": "Spymaster's guild", "icon": "🕵️", "max": 3, "gold": 400,
|
"name": "Spymaster's guild", "icon": "🕵️", "max": 3, "gold": 400,
|
||||||
"base": {"logs": 500, "bricks": 300, "hide": 100, "feathers": 300}, "advanced": {"cut_stone": 150},
|
"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",
|
RAID_GROUPS = ["the Red Hand", "the Hollow Crows", "the Blackwater gang", "the Ashen Wolves",
|
||||||
"the Gallows Men", "the Thornback raiders"]
|
"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",
|
RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows",
|
||||||
"well armed and disciplined", "a ragged mob, many but poorly armed",
|
"well armed and disciplined", "a ragged mob, many but poorly armed",
|
||||||
"led by a deserter knight in plate"]
|
"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 ---------------------------------------------------------------
|
||||||
|
|
||||||
WORLD_VERSION = 7
|
WORLD_VERSION = 8
|
||||||
|
|
||||||
|
|
||||||
def new_world(now: float, speed: float = 1.0) -> dict:
|
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,
|
"construction": None,
|
||||||
"queue": [],
|
"queue": [],
|
||||||
"tier": 0,
|
"tier": 0,
|
||||||
|
"weather": {"kind": "clear", "until": now + 6 * HOUR / speed, "next": "rain"},
|
||||||
|
"effects": [], "problems": [], "hired": 0,
|
||||||
"last_tick": now,
|
"last_tick": now,
|
||||||
"food_due": 0.0,
|
"food_due": 0.0,
|
||||||
"starving": False,
|
"starving": False,
|
||||||
|
|
@ -498,6 +521,9 @@ def new_world(now: float, speed: float = 1.0) -> dict:
|
||||||
},
|
},
|
||||||
"events": [],
|
"events": [],
|
||||||
"next_event_at": now + 12 * HOUR / speed,
|
"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,
|
"next_event_id": 1,
|
||||||
"ledger": [],
|
"ledger": [],
|
||||||
"board": [],
|
"board": [],
|
||||||
|
|
@ -553,6 +579,18 @@ def migrate(world: dict, now: float | None = None) -> dict:
|
||||||
del work["done_at"]
|
del work["done_at"]
|
||||||
work.update(labour=needed * share, needed=needed)
|
work.update(labour=needed * share, needed=needed)
|
||||||
world["version"] = 7
|
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
|
return world
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -689,6 +727,8 @@ def city_uses(item: str) -> list[str]:
|
||||||
uses.append("the granary")
|
uses.append("the granary")
|
||||||
if spec.get("slot") in ("weapon", "shield") or item == "arrows":
|
if spec.get("slot") in ("weapon", "shield") or item == "arrows":
|
||||||
uses.append("the armoury")
|
uses.append("the armoury")
|
||||||
|
if item == "water_barrel":
|
||||||
|
uses.append("the fire brigade")
|
||||||
return uses
|
return uses
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -859,6 +899,7 @@ def action_time(world: dict, player: dict, action: dict) -> float:
|
||||||
if action["skill"] == "smithing":
|
if action["skill"] == "smithing":
|
||||||
seconds *= 1 - 0.08 * building_level(world, "smithy")
|
seconds *= 1 - 0.08 * building_level(world, "smithy")
|
||||||
seconds *= 1 - potion_effect(player, action["skill"]).get("speed", 0)
|
seconds *= 1 - potion_effect(player, action["skill"]).get("speed", 0)
|
||||||
|
seconds *= skill_mods(world, action)["speed"]
|
||||||
return seconds / speed(world)
|
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:
|
def upcoming_raid(world: dict) -> dict | None:
|
||||||
|
"""The coming major threat: a bandit raid or a dragon."""
|
||||||
for event in world["events"]:
|
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 event
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -932,10 +974,17 @@ def can_start(world: dict, player: dict, action: dict) -> str | None:
|
||||||
raid = upcoming_raid(world)
|
raid = upcoming_raid(world)
|
||||||
if not raid or intel_level(world, raid) < 3:
|
if not raid or intel_level(world, raid) < 3:
|
||||||
return "No bandit camp has been located"
|
return "No bandit camp has been located"
|
||||||
|
if raid["kind"] != "raid":
|
||||||
|
return "A dragon has no camp to sabotage"
|
||||||
if raid["sabotage"] >= 0.3:
|
if raid["sabotage"] >= 0.3:
|
||||||
return "The camp is already in ruins"
|
return "The camp is already in ruins"
|
||||||
if action.get("special") == "labour" and not world["city"]["construction"]:
|
if action.get("special") == "labour" and not world["city"]["construction"]:
|
||||||
return "Nothing is being built"
|
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):
|
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 "Too hurt to fight. Rest, or bring food"
|
||||||
return None
|
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():
|
for item, qty in action["outputs"].items():
|
||||||
outputs[item] += qty
|
outputs[item] += qty
|
||||||
summary["lucky"] += 1
|
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":
|
if action["skill"] == "combat":
|
||||||
blessed = blessing(player)
|
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":
|
if action.get("special") == "sabotage":
|
||||||
raid = upcoming_raid(world)
|
raid = upcoming_raid(world)
|
||||||
raid["sabotage"] = min(0.3, raid["sabotage"] + 0.03)
|
raid["sabotage"] = min(0.3, raid["sabotage"] + 0.03)
|
||||||
|
if action.get("special") == "problem":
|
||||||
|
_work_problem(world, player, action, now)
|
||||||
else:
|
else:
|
||||||
summary["losses"] += 1
|
summary["losses"] += 1
|
||||||
xp = xp // 3
|
xp = xp // 3
|
||||||
|
|
@ -1009,6 +1066,8 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
|
||||||
event = upcoming_event(world)
|
event = upcoming_event(world)
|
||||||
if event:
|
if event:
|
||||||
event["recon"] += action["recon"]
|
event["recon"] += action["recon"]
|
||||||
|
if action.get("special") == "problem" and action["skill"] != "combat":
|
||||||
|
_work_problem(world, player, action, now)
|
||||||
if action.get("special") == "labour":
|
if action.get("special") == "labour":
|
||||||
site = world["city"]["construction"]
|
site = world["city"]["construction"]
|
||||||
site["labour"] += labour_per_action(player)
|
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
|
city["stockpile"][item] = city["stockpile"].get(item, 0) + qty
|
||||||
gain = qty * ITEMS[item]["value"]
|
gain = qty * ITEMS[item]["value"]
|
||||||
label = amount(item, qty)
|
label = amount(item, qty)
|
||||||
player["favour"] += gain
|
player["favour"] += gain * city_rate(world, "favour")
|
||||||
player["donated"] += gain
|
player["donated"] += gain
|
||||||
log(world, now, "donation", f"{player['name']} donated {label}", player["id"])
|
log(world, now, "donation", f"{player['name']} donated {label}", player["id"])
|
||||||
return None
|
return None
|
||||||
|
|
@ -1223,7 +1282,7 @@ def fund(world: dict, player: dict, key: str, item: str, qty: int | str, source:
|
||||||
else:
|
else:
|
||||||
bank_take(player, item, qty)
|
bank_take(player, item, qty)
|
||||||
gain = qty * (1 if item == "gold" else ITEMS[item]["value"])
|
gain = qty * (1 if item == "gold" else ITEMS[item]["value"])
|
||||||
player["favour"] += gain
|
player["favour"] += gain * city_rate(world, "favour")
|
||||||
player["donated"] += gain
|
player["donated"] += gain
|
||||||
elif item == "gold":
|
elif item == "gold":
|
||||||
city["treasury"] -= qty
|
city["treasury"] -= qty
|
||||||
|
|
@ -1344,6 +1403,7 @@ def defence(world: dict) -> dict:
|
||||||
"arrows": min(100, stock.get("arrows", 0) // 10),
|
"arrows": min(100, stock.get("arrows", 0) // 10),
|
||||||
"rallied": city.get("rally", 0),
|
"rallied": city.get("rally", 0),
|
||||||
"ward": ward(world),
|
"ward": ward(world),
|
||||||
|
"mercenaries": city.get("hired", 0),
|
||||||
"garrison": 0,
|
"garrison": 0,
|
||||||
}
|
}
|
||||||
garrison = []
|
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))
|
city["last_tick"] = max(city["last_tick"], now - 14 * DAY / speed(world))
|
||||||
while city["last_tick"] + step <= now:
|
while city["last_tick"] + step <= now:
|
||||||
city["last_tick"] += step
|
city["last_tick"] += step
|
||||||
|
_happenings_hour(world, city["last_tick"], rng)
|
||||||
_city_hour(world, city["last_tick"])
|
_city_hour(world, city["last_tick"])
|
||||||
changed = True
|
changed = True
|
||||||
|
changed = _happenings_tracks(world, now, rng) or changed
|
||||||
construction = city["construction"]
|
construction = city["construction"]
|
||||||
if construction and construction["labour"] >= construction["needed"]:
|
if construction and construction["labour"] >= construction["needed"]:
|
||||||
_finish_construction(world, now)
|
_finish_construction(world, now)
|
||||||
|
|
@ -1402,7 +1464,7 @@ def _city_hour(world: dict, now: float) -> None:
|
||||||
pop = city["population"]
|
pop = city["population"]
|
||||||
if city["construction"]:
|
if city["construction"]:
|
||||||
city["construction"]["labour"] += pop * TOWNSFOLK_LABOUR
|
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"]
|
stock = city["stockpile"]
|
||||||
for item in FOOD_ORDER:
|
for item in FOOD_ORDER:
|
||||||
while city["food_due"] >= ITEMS[item]["food"] and stock.get(item, 0) > 0:
|
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:
|
else:
|
||||||
room = housing(world)
|
room = housing(world)
|
||||||
if pop < room:
|
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:
|
elif pop > room:
|
||||||
city["population"] = max(room, pop - pop * 0.05 / 24)
|
city["population"] = max(room, pop - pop * 0.05 / 24)
|
||||||
city["treasury"] += pop * 0.5 / 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:
|
def _announce(world: dict, now: float, rng: random.Random) -> None:
|
||||||
city = world["city"]
|
city = world["city"]
|
||||||
hours = HOUR / speed(world)
|
hours = HOUR / speed(world)
|
||||||
first = not world["events"]
|
first = not any(e["kind"] in ("raid", "dragon") for e in world["events"])
|
||||||
roll = rng.random()
|
day = (now - world["created"]) * speed(world) / DAY
|
||||||
kind = "raid" if first or roll < 0.7 else ("merchant" if roll < 0.85 else "refugees")
|
# 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 = {
|
event = {
|
||||||
"id": world["next_event_id"], "kind": kind, "status": "upcoming",
|
"id": world["next_event_id"], "kind": kind, "status": "upcoming",
|
||||||
"announced": now, "recon": 0, "sabotage": 0.0, "jitter": rng.uniform(-1, 1),
|
"announced": now, "recon": 0, "sabotage": 0.0, "jitter": rng.uniform(-1, 1),
|
||||||
}
|
}
|
||||||
world["next_event_id"] += 1
|
world["next_event_id"] += 1
|
||||||
if kind == "raid":
|
# Bigger, richer and older cities draw bigger threats; every win draws a bolder one.
|
||||||
# Bigger, richer and older cities draw bigger bands; every win draws a bolder one.
|
wealth = city["treasury"] + stockpile_value(city)
|
||||||
wealth = city["treasury"] + stockpile_value(city)
|
strength = (15 + 4 * day + 0.8 * city["population"] + 1.2 * math.sqrt(wealth)) * (1 + 0.08 * city["repelled"])
|
||||||
day = (now - world["created"]) * speed(world) / DAY
|
if kind == "dragon":
|
||||||
strength = (15 + 4 * day + 0.8 * city["population"] + 1.2 * math.sqrt(wealth)) * (1 + 0.08 * city["repelled"])
|
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({
|
event.update({
|
||||||
"fire_at": now + rng.uniform(30, 54) * hours,
|
"fire_at": now + rng.uniform(30, 54) * hours,
|
||||||
"strength": round(strength * rng.uniform(0.85, 1.2), 1),
|
"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),
|
"trait": rng.choice(RAID_TRAITS),
|
||||||
})
|
})
|
||||||
log(world, now, "event", "Rumours of bandits on the roads reach the council")
|
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)
|
world["events"].append(event)
|
||||||
del world["events"][:-30]
|
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,
|
"exact": event["fire_at"] if level >= 3 else None,
|
||||||
"recon": event["recon"], "recon_next": RECON_PER_INTEL - event["recon"] % RECON_PER_INTEL,
|
"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"])
|
strength = event["strength"] * (1 - event["sabotage"])
|
||||||
view["headline"] = "Travellers speak of trouble on the roads."
|
view["headline"] = "Travellers speak of trouble on the roads."
|
||||||
if level >= 1:
|
if level >= 1:
|
||||||
|
|
@ -1565,6 +1644,8 @@ def _fire(world: dict, event: dict, now: float, rng: random.Random) -> None:
|
||||||
event["resolved"] = now
|
event["resolved"] = now
|
||||||
if event["kind"] == "raid":
|
if event["kind"] == "raid":
|
||||||
_resolve_raid(world, event, now, rng)
|
_resolve_raid(world, event, now, rng)
|
||||||
|
elif event["kind"] == "dragon":
|
||||||
|
_resolve_dragon(world, event, now, rng)
|
||||||
elif event["kind"] == "merchant":
|
elif event["kind"] == "merchant":
|
||||||
world["city"]["merchant_until"] = now + 12 * HOUR / speed(world)
|
world["city"]["merchant_until"] = now + 12 * HOUR / speed(world)
|
||||||
event["report"] = {"title": "A merchant caravan has arrived",
|
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
|
player["garrison"] = False
|
||||||
city["rally"] = city["rallies"] = 0
|
city["rally"] = city["rallies"] = 0
|
||||||
city["devotion"] = 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:
|
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]
|
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 --------------------------------------------------------------
|
# --- favour --------------------------------------------------------------
|
||||||
|
|
||||||
BUILDER_FAVOUR_PER_LABOUR = 0.67
|
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,
|
"buildings": buildings, "xp_table": XP_TABLE, "offline_cap": OFFLINE_CAP, "tiers": TIERS,
|
||||||
"city_uses": {item: city_uses(item) for item in ITEMS},
|
"city_uses": {item: city_uses(item) for item in ITEMS},
|
||||||
"prayers": [{**prayer, "text": describe_prayer(prayer)} for prayer in PRAYERS], "max_prayers": MAX_PRAYERS,
|
"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,
|
"equip_slots": EQUIP_SLOTS, "eat_below": EAT_BELOW, "regen_per_hour": REGEN_PER_HOUR,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -249,7 +249,10 @@ function devbar() {
|
||||||
<button class="btn sm" data-dev-advance="1">+1h</button>
|
<button class="btn sm" data-dev-advance="1">+1h</button>
|
||||||
<button class="btn sm" data-dev-advance="8">+8h</button>
|
<button class="btn sm" data-dev-advance="8">+8h</button>
|
||||||
<button class="btn sm" data-dev-advance="24">+1d</button>
|
<button class="btn sm" data-dev-advance="24">+1d</button>
|
||||||
<button class="btn sm" data-dev-event>Event now</button>
|
<button class="btn sm" data-dev-event>Threat now</button>
|
||||||
|
<button class="btn sm" data-dev-happen="fortune">Fortune</button>
|
||||||
|
<button class="btn sm" data-dev-happen="hardship">Hardship</button>
|
||||||
|
<button class="btn sm" data-dev-happen="minor">Problem</button>
|
||||||
</div></div>`;
|
</div></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -267,12 +270,14 @@ function topbar() {
|
||||||
} else if (S.me) {
|
} else if (S.me) {
|
||||||
chips += `<span class="chip muted">Idle${S.me.stopped ? ` · ${esc(S.me.stopped)}` : ""}</span>`;
|
chips += `<span class="chip muted">Idle${S.me.stopped ? ` · ${esc(S.me.stopped)}` : ""}</span>`;
|
||||||
}
|
}
|
||||||
if (S.upcoming) chips += `<button class="chip ${S.upcoming.kind === "raid" ? "threat" : ""}" data-nav="council">${eventIcon(S.upcoming.kind)} ${eventWhen(S.upcoming, true)}</button>`;
|
const w = S.happenings?.weather;
|
||||||
|
if (w) chips += `<button class="chip" data-nav="town" title="${esc(w.name)}: ${esc(w.text)}">${w.icon}</button>`;
|
||||||
|
if (S.upcoming) chips += `<button class="chip ${["raid", "dragon"].includes(S.upcoming.kind) ? "threat" : ""}" data-nav="council">${eventIcon(S.upcoming.kind)} ${eventWhen(S.upcoming, true)}</button>`;
|
||||||
return `<button class="menu-btn" data-menu aria-label="Menu">☰</button><h1>${esc(title)}</h1>${chips}`;
|
return `<button class="menu-btn" data-menu aria-label="Menu">☰</button><h1>${esc(title)}</h1>${chips}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function eventIcon(kind) {
|
function eventIcon(kind) {
|
||||||
return { raid: "⚔️", merchant: "🐪", refugees: "🧳" }[kind] || "❗";
|
return { raid: "⚔️", dragon: "🐉", merchant: "🐪", refugees: "🧳" }[kind] || "❗";
|
||||||
}
|
}
|
||||||
|
|
||||||
function eventWhen(ev, short = false) {
|
function eventWhen(ev, short = false) {
|
||||||
|
|
@ -328,7 +333,9 @@ function skillPage(id) {
|
||||||
html += potionPanel(id);
|
html += potionPanel(id);
|
||||||
if (`tool_${id}` in me.equip) html += toolPanel(id);
|
if (`tool_${id}` in me.equip) html += toolPanel(id);
|
||||||
if (id === "reconnaissance") html += `<div class="panel small muted">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.</div>`;
|
if (id === "reconnaissance") html += `<div class="panel small muted">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.</div>`;
|
||||||
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))];
|
const groups = [...new Set(actions.map((a) => a.group).filter(Boolean))];
|
||||||
if (groups.length > 2) {
|
if (groups.length > 2) {
|
||||||
// Many tiers: one tab per tier, opening on the highest one you can work.
|
// Many tiers: one tab per tier, opening on the highest one you can work.
|
||||||
|
|
@ -597,7 +604,9 @@ function town() {
|
||||||
<div class="stat"><div class="k">Food</div><div class="v ${city.food_days < 1 ? "bad" : ""}">${city.food_days ?? 0} <small>days</small></div><div class="note">${fmt(city.food)} meals in the granary</div></div>
|
<div class="stat"><div class="k">Food</div><div class="v ${city.food_days < 1 ? "bad" : ""}">${city.food_days ?? 0} <small>days</small></div><div class="note">${fmt(city.food)} meals in the granary</div></div>
|
||||||
<div class="stat"><div class="k">Treasury</div><div class="v gold">${fmt(city.treasury)}</div><div class="note">Day ${city.day}</div></div>
|
<div class="stat"><div class="k">Treasury</div><div class="v gold">${fmt(city.treasury)}</div><div class="note">Day ${city.day}</div></div>
|
||||||
<div class="stat"><div class="k">Defence</div><div class="v">${fmt(city.defence.total)}</div><div class="note">${city.repelled} raid${city.repelled === 1 ? "" : "s"} repelled</div></div>
|
<div class="stat"><div class="k">Defence</div><div class="v">${fmt(city.defence.total)}</div><div class="note">${city.repelled} raid${city.repelled === 1 ? "" : "s"} repelled</div></div>
|
||||||
|
${weatherCard()}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
html += happeningsPanel();
|
||||||
if (city.starving) html += `<div class="banner bad">🍞<div class="grow"><b>The granary is empty.</b> People are leaving and the city defends worse. Donate bread, cooked meat or stew.</div></div>`;
|
if (city.starving) html += `<div class="banner bad">🍞<div class="grow"><b>The granary is empty.</b> People are leaving and the city defends worse. Donate bread, cooked meat or stew.</div></div>`;
|
||||||
if (city.merchant_until > serverNow()) html += `<div class="banner">🐪<div class="grow"><b>A merchant caravan is in town.</b> Everything sells for 50% more for another ${countdown(city.merchant_until)}.</div></div>`;
|
if (city.merchant_until > serverNow()) html += `<div class="banner">🐪<div class="grow"><b>A merchant caravan is in town.</b> Everything sells for 50% more for another ${countdown(city.merchant_until)}.</div></div>`;
|
||||||
if (city.construction) html += siteBanner();
|
if (city.construction) html += siteBanner();
|
||||||
|
|
@ -631,6 +640,61 @@ function siteBanner() {
|
||||||
</div></div>`;
|
</div></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function weatherCard() {
|
||||||
|
const h = S.happenings, w = h.weather;
|
||||||
|
return `<div class="stat" title="${esc(w.text)}"><div class="k">${h.season.icon} ${h.season.name}, day ${h.season.day}</div>
|
||||||
|
<div class="v">${w.icon} <small>${esc(w.name)}</small></div>
|
||||||
|
<div class="note">${w.next === w.kind ? `Staying ${esc(w.name.toLowerCase())}` : `${w.next_icon} ${esc(w.next_name)}`} in ${countdown(w.until)}</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(`<div class="happening"><span class="ico">${h.weather.icon}</span><span class="grow"><b>${esc(h.weather.name)}</b><span class="small muted">${esc(h.weather.text)}</span></span></div>`);
|
||||||
|
if (h.cold) rows.push(`<div class="happening bad"><span class="ico">🥶</span><span class="grow"><b>Out of firewood</b><span class="small muted">People are cold and eating half again as much. Donate logs.</span></span></div>`);
|
||||||
|
for (const e of h.effects) {
|
||||||
|
const hire = e.offer ? `<button class="btn sm gold" data-hire ${city.treasury < e.cost ? "disabled" : ""}>Hire for ${fmt(e.cost)} gold</button>` : "";
|
||||||
|
rows.push(`<div class="happening"><span class="ico">${e.icon}</span><span class="grow"><b>${esc(e.name)}</b>
|
||||||
|
<span class="small muted">${esc(e.text)} · ${countdown(e.until)} left</span></span>${hire}</div>`);
|
||||||
|
}
|
||||||
|
for (const p of h.problems) {
|
||||||
|
let help = "";
|
||||||
|
if (p.action) {
|
||||||
|
const a = C.actions.find((x) => x.id === p.action);
|
||||||
|
help = `<span class="small muted">${fmt(p.work)} / ${fmt(p.needed)} done</span>
|
||||||
|
<button class="btn sm primary" data-nav="skill:${a.skill}">${esc(a.name)}</button>`;
|
||||||
|
}
|
||||||
|
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 `<div class="fund-row">${costChip(item(id).icon, item(id).name, need, need - missing)}<span class="btns">
|
||||||
|
${mine ? `<button class="btn sm gold" data-aid="${p.kind}" data-item="${id}" data-source="bank">Give ${fmt(mine)}</button>` : ""}
|
||||||
|
${stores ? `<button class="btn sm" data-aid="${p.kind}" data-item="${id}" data-source="city">Stockpile ${fmt(stores)}</button>` : ""}
|
||||||
|
</span></div>`;
|
||||||
|
}).join("");
|
||||||
|
rows.push(`<div class="happening bad"><span class="ico">${p.icon}</span><span class="grow"><b>${esc(p.name)}</b>
|
||||||
|
<span class="small muted">${esc(p.text)}${p.until ? ` · ${countdown(p.until)} left` : ""}</span>${gifts}</span>${help}</div>`);
|
||||||
|
}
|
||||||
|
if (!rows.length) return "";
|
||||||
|
return `<div class="panel"><h2>Happenings</h2><div class="happenings">${rows.join("")}</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(`<span>${icon} <b>${esc(name)}</b> <span class="muted">${esc(text)}</span></span>`);
|
||||||
|
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 ? `<div class="panel tool-panel skill-happenings">${lines.join("")}</div>` : "";
|
||||||
|
}
|
||||||
|
|
||||||
function buildingCard(key, b) {
|
function buildingCard(key, b) {
|
||||||
const spec = C.buildings[key], city = S.city, me = S.me;
|
const spec = C.buildings[key], city = S.city, me = S.me;
|
||||||
const tags = {
|
const tags = {
|
||||||
|
|
@ -673,8 +737,9 @@ function council() {
|
||||||
if (!ev) {
|
if (!ev) {
|
||||||
html += `<div class="panel"><h2>All quiet</h2><p class="muted">No news on the roads. The spymasters will report as soon as something stirs.</p></div>`;
|
html += `<div class="panel"><h2>All quiet</h2><p class="muted">No news on the roads. The spymasters will report as soon as something stirs.</p></div>`;
|
||||||
} else {
|
} else {
|
||||||
html += `<div class="panel ${ev.kind === "raid" ? "threat-card" : ""}">
|
const threat = ["raid", "dragon"].includes(ev.kind);
|
||||||
<h2>${eventIcon(ev.kind)} ${ev.kind === "raid" ? "Threat" : "News"} · intel ${pips(ev.intel)}</h2>
|
html += `<div class="panel ${threat ? "threat-card" : ""}">
|
||||||
|
<h2>${eventIcon(ev.kind)} ${threat ? "Threat" : "News"} · intel ${pips(ev.intel)}</h2>
|
||||||
<div class="headline">${esc(ev.headline)}</div>
|
<div class="headline">${esc(ev.headline)}</div>
|
||||||
<div class="kv">
|
<div class="kv">
|
||||||
<span class="k">Expected</span><span>${eventWhen(ev)}</span>
|
<span class="k">Expected</span><span>${eventWhen(ev)}</span>
|
||||||
|
|
@ -684,22 +749,24 @@ function council() {
|
||||||
${me.cls === "scout" || ev.recon ? `<span class="k">Reconnaissance</span><span>${ev.intel < 3 ? `${ev.recon} points · ${ev.recon_next} more for the next intel level` : `${ev.recon} points · nothing more to learn`}</span>` : ""}
|
${me.cls === "scout" || ev.recon ? `<span class="k">Reconnaissance</span><span>${ev.intel < 3 ? `${ev.recon} points · ${ev.recon_next} more for the next intel level` : `${ev.recon} points · nothing more to learn`}</span>` : ""}
|
||||||
</div>
|
</div>
|
||||||
${ev.intel < 3 ? `<p class="small muted">Better intel comes from the spymaster's guild and scouts' reconnaissance.</p>` : ""}`;
|
${ev.intel < 3 ? `<p class="small muted">Better intel comes from the spymaster's guild and scouts' reconnaissance.</p>` : ""}`;
|
||||||
if (ev.kind === "raid") {
|
if (threat) {
|
||||||
const strength = ev.strength ?? (ev.range ? `${ev.range[0]}–${ev.range[1]}` : "?");
|
const strength = ev.strength ?? (ev.range ? `${ev.range[0]}–${ev.range[1]}` : "?");
|
||||||
html += `<div class="vs"><div class="side"><span class="muted small">Our defence</span><b>${fmt(city.defence.total)}</b></div><span class="muted">vs</span><div class="side"><span class="muted small">Raiders</span><b>${strength}</b></div></div>`;
|
html += `<div class="vs"><div class="side"><span class="muted small">Our defence</span><b>${fmt(city.defence.total)}</b></div><span class="muted">vs</span><div class="side"><span class="muted small">Raiders</span><b>${strength}</b></div></div>`;
|
||||||
}
|
}
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
}
|
}
|
||||||
html += favourSpends();
|
html += favourSpends();
|
||||||
if (ev?.kind === "raid") html += raidPrep(ev);
|
if (ev && ["raid", "dragon"].includes(ev.kind)) html += raidPrep(ev);
|
||||||
html += `<div class="section-title">Past events</div>`;
|
html += `<div class="section-title">Past events</div>`;
|
||||||
if (!S.history.length) html += `<p class="muted">Nothing has happened yet.</p>`;
|
if (!S.history.length) html += `<p class="muted">Nothing has happened yet.</p>`;
|
||||||
for (const h of S.history) {
|
for (const h of S.history) {
|
||||||
if (!h.report) continue;
|
if (!h.report) continue;
|
||||||
const win = h.kind !== "raid" || h.report.title.startsWith("Victory");
|
const fight = ["raid", "dragon"].includes(h.kind);
|
||||||
html += `<div class="report ${h.kind === "raid" ? (win ? "win" : "loss") : ""}">
|
const tone = fight ? (h.report.title.startsWith("Victory") ? "win" : "loss")
|
||||||
<b>${eventIcon(h.kind)} ${esc(h.report.title)}</b> <span class="muted small">${ago(h.at)}</span>
|
: { fortune: "win", hardship: "loss", minor: "loss" }[h.track] || "";
|
||||||
${h.kind === "raid" ? `<p class="muted">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(", ")}` : ""}</p>` : ""}
|
html += `<div class="report ${tone}">
|
||||||
|
<b>${h.report.icon || eventIcon(h.kind)} ${esc(h.report.title)}</b> <span class="muted small">${ago(h.at)}</span>
|
||||||
|
${fight ? `<p class="muted">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(", ")}` : ""}</p>` : ""}
|
||||||
${h.report.lines.map((l) => `<p>${esc(l)}</p>`).join("")}
|
${h.report.lines.map((l) => `<p>${esc(l)}</p>`).join("")}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
@ -727,7 +794,7 @@ function favourSpends() {
|
||||||
|
|
||||||
function raidPrep(ev) {
|
function raidPrep(ev) {
|
||||||
const city = S.city, me = S.me, d = city.defence;
|
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 reqs = (ev.requests || []).map((r) => {
|
||||||
const mine = me.bank[r.item] || 0;
|
const mine = me.bank[r.item] || 0;
|
||||||
return `<div class="req"><span>${item(r.item).icon}</span><span>${esc(r.label)} <span class="muted small">${fmt(r.have)} / ${fmt(r.want)}</span></span>
|
return `<div class="req"><span>${item(r.item).icon}</span><span>${esc(r.label)} <span class="muted small">${fmt(r.have)} / ${fmt(r.want)}</span></span>
|
||||||
|
|
@ -933,6 +1000,9 @@ document.addEventListener("click", (e) => {
|
||||||
if (d.favour) return act("/api/favour", { kind: d.favour });
|
if (d.favour) return act("/api/favour", { kind: d.favour });
|
||||||
if (d.food) return act("/api/food", { item: d.food });
|
if (d.food) return act("/api/food", { item: d.food });
|
||||||
if (d.potion) return act("/api/potion", { item: d.potion });
|
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) {
|
if (d.pray) {
|
||||||
const on = S.me.prayers.includes(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");
|
if (!on && S.me.prayers.length >= C.max_prayers) return toast(`Only ${C.max_prayers} prayers at once: turn one off first`, "bad");
|
||||||
|
|
|
||||||
|
|
@ -388,3 +388,10 @@ button.sheet-skill:hover { background: var(--panel-2); }
|
||||||
.prayer-points { font-size: 15px; }
|
.prayer-points { font-size: 15px; }
|
||||||
.site-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
.site-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
.site-head .btns { margin-left: auto; }
|
.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; }
|
||||||
|
|
|
||||||
|
|
@ -828,5 +828,164 @@ class ConstructionTest(unittest.TestCase):
|
||||||
self.assertIsNone(p["equip"]["tool_construction"])
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue