From 3716df7493479da174a724531ae347966a3aa845 Mon Sep 17 00:00:00 2001 From: rope Date: Sun, 18 Oct 2026 19:20:52 +0100 Subject: [PATCH] Building funds safe from raids; settlement tiers; population and food on the town square Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +- app.py | 34 ++++--- game.py | 218 +++++++++++++++++++++++++++++++++++++-------- static/app.js | 78 ++++++++++------ static/styles.css | 5 ++ tests/test_game.py | 106 ++++++++++++++++++++-- 6 files changed, 358 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index 79db06d..58134fd 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,12 @@ Alpha. One season, bandit raids plus two friendly events, six buildings. - **Bank:** 12 slots of up to 1,000 each to start. When something doesn't fit it goes to the city stockpile (or the action stops, if you prefer). Raids never touch your bank. Players can give items to each other. -- **City:** donations fill the stockpile and treasury; anyone can start a - building once they cover its cost. Population eats food and pays taxes. +- **City:** each building collects its own fund, from players' banks or the + city's stockpile and treasury. Funds are safe from raiders, and a funded + building joins the construction queue by itself. Population eats food, + pays taxes, and grows up to the housing cap; it promotes the settlement + (hamlet, village at 40, town at 90, city at 150), which raises building + level caps and gives everyone bank slots. - **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 diff --git a/app.py b/app.py index 675ec0c..18347a2 100644 --- a/app.py +++ b/app.py @@ -56,7 +56,7 @@ class Store: db.execute("CREATE TABLE IF NOT EXISTS world (id INTEGER PRIMARY KEY CHECK (id = 1), data TEXT NOT NULL)") row = db.execute("SELECT data FROM world WHERE id = 1").fetchone() if row: - self.world = json.loads(row[0]) + self.world = game.migrate(json.loads(row[0])) self.world["config"]["speed"] = speed else: self.world = game.new_world(time.time(), speed) @@ -109,20 +109,25 @@ def city_view(world: dict, now: float) -> dict: city = world["city"] buildings = {} for key, building in city["buildings"].items(): - spec = game.BUILDINGS[key] - if building["damaged"]: - cost = game.repair_cost(key, building["level"]) - elif building["level"] < spec["max"]: - cost = game.build_cost(key, building["level"] + 1) + working = city["construction"] and city["construction"]["building"] == key + target = game.fund_target(world, key) + missing = game.fund_missing(world, key) + if working: + status = "building" + elif key in city["queue"]: + status = "queued" + elif target is None: + status = "max" if building["level"] >= game.BUILDINGS[key]["max"] else "capped" else: - cost = None - buildings[key] = {**building, "cost": cost, "missing": game.missing_for(city, cost) if cost else []} + status = "funding" + buildings[key] = {**building, "target": target, "missing": missing, "status": status, + "cap": game.level_cap(world, key)} return { - "population": int(city["population"]), - "housing": game.housing(world), + **game.population_view(world), + "buildings": buildings, + "queue": city["queue"], "treasury": int(city["treasury"]), "stockpile": city["stockpile"], - "buildings": buildings, "construction": city["construction"], "starving": city["starving"], "merchant_until": city.get("merchant_until", 0), @@ -290,8 +295,11 @@ class Handler(BaseHTTPRequestHandler): return "Gold can't be sold" if item == "gold" else game.sell(world, player, item, qty, now) if path == "/api/equip": return self.equip(player, item, body.get("slot")) - if path == "/api/build": - return game.commission(world, player, str(body.get("building")), now) + if path == "/api/fund": + qty = body.get("qty") + return game.fund(world, player, str(body.get("building")), str(body.get("item")), + qty if qty == "all" else (qty if isinstance(qty, int) else -1), + str(body.get("source")), now) if path == "/api/garrison": raid = game.upcoming_raid(world) if body.get("join") and not raid: diff --git a/game.py b/game.py index 3cace1f..2f4c77b 100644 --- a/game.py +++ b/game.py @@ -160,9 +160,9 @@ ACTION_BY_ID = {action["id"]: action for action in ACTIONS} BUILDINGS = { "houses": { - "name": "Houses", "icon": "๐Ÿ ", "max": 5, "gold": 200, + "name": "Houses", "icon": "๐Ÿ ", "max": 8, "gold": 200, "base": {"logs": 600, "bricks": 300}, "advanced": {"oak_logs": 200}, - "effect": "Room for 15 more people per level", + "effect": "Room for 20 more people per level", }, "walls": { "name": "Walls", "icon": "๐Ÿฐ", "max": 5, "gold": 300, @@ -193,6 +193,18 @@ BUILDINGS = { BUILD_HOURS = 3 REPAIR_SHARE = 0.3 +# Population promotes the settlement, permanently. Each tier lifts how high +# buildings may go (houses have their own ladder so the next tier is always +# reachable) and gives everyone more bank slots. +TIERS = [ + {"name": "Hamlet", "pop": 0, "cap": 1, "houses": 2}, + {"name": "Village", "pop": 40, "cap": 3, "houses": 4}, + {"name": "Town", "pop": 90, "cap": 4, "houses": 7}, + {"name": "City", "pop": 150, "cap": 99, "houses": 8}, +] +TIER_SLOTS = 2 +GROWTH_PER_DAY = 0.12 + RAID_GROUPS = ["the Red Hand", "the Hollow Crows", "the Blackwater gang", "the Ashen Wolves", "the Gallows Men", "the Thornback raiders"] RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows", @@ -202,9 +214,12 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows # --- world --------------------------------------------------------------- +WORLD_VERSION = 2 + + def new_world(now: float, speed: float = 1.0) -> dict: return { - "version": 1, + "version": WORLD_VERSION, "created": now, "config": {"speed": speed, "clock_offset": 0}, "players": {}, @@ -212,8 +227,10 @@ def new_world(now: float, speed: float = 1.0) -> dict: "population": 20.0, "treasury": 0.0, "stockpile": {"bread": 40}, - "buildings": {key: {"level": 0, "damaged": False} for key in BUILDINGS}, + "buildings": {key: {"level": 0, "damaged": False, "fund": {}} for key in BUILDINGS}, "construction": None, + "queue": [], + "tier": 0, "last_tick": now, "food_due": 0.0, "starving": False, @@ -227,6 +244,18 @@ def new_world(now: float, speed: float = 1.0) -> dict: } +def migrate(world: dict) -> dict: + """Bring a saved world up to the current shape. Never loses anything.""" + if world.get("version", 1) < 2: + city = world["city"] + for building in city["buildings"].values(): + building.setdefault("fund", {}) + city.setdefault("queue", []) + city.setdefault("tier", 0) + world["version"] = 2 + return world + + def speed(world: dict) -> float: return world["config"]["speed"] @@ -268,7 +297,29 @@ def repair_cost(key: str, level: int) -> dict: def housing(world: dict) -> int: - return 25 + 15 * building_level(world, "houses") + return 25 + 20 * building_level(world, "houses") + + +def level_cap(world: dict, key: str) -> int: + """The highest level the settlement's tier allows for this building.""" + tier = TIERS[world["city"]["tier"]] + return min(BUILDINGS[key]["max"], tier["houses"] if key == "houses" else tier["cap"]) + + +def fund_target(world: dict, key: str) -> dict | None: + """What the building's fund is collecting for: a repair, the next level, or nothing.""" + building = world["city"]["buildings"][key] + if building["damaged"]: + return repair_cost(key, building["level"]) + if building["level"] >= level_cap(world, key): + return None + return build_cost(key, building["level"] + 1) + + +def fund_missing(world: dict, key: str) -> dict: + target = fund_target(world, key) or {} + fund = world["city"]["buildings"][key]["fund"] + return {item: qty - fund.get(item, 0) for item, qty in target.items() if fund.get(item, 0) < qty} # --- players ------------------------------------------------------------- @@ -292,7 +343,8 @@ def skill_level(player: dict, skill: str) -> int: def bank_slots(world: dict, player: dict) -> int: - return BASE_SLOTS + 3 * building_level(world, "storehouse") + player["perks"]["bank_slot"] + return (BASE_SLOTS + 3 * building_level(world, "storehouse") + TIER_SLOTS * world["city"]["tier"] + + player["perks"]["bank_slot"]) def stack_size(world: dict) -> int: @@ -549,45 +601,120 @@ def sell_price(world: dict, item: str, now: float) -> float: return price -def commission(world: dict, player: dict, key: str, now: float) -> str | None: +def fund(world: dict, player: dict, key: str, item: str, qty: int | str, source: str, now: float) -> str | None: + """Put materials towards a building. Funds are safe from raiders. + + `source` is "bank" (the player's own, earns favour) or "city" (the + stockpile, or the treasury for gold). `qty` may be "all": as much as + the source has, up to what the fund still needs. + """ city = world["city"] if key not in BUILDINGS: return "No such building" - if city["construction"]: - return "The builders are busy" - building = city["buildings"][key] - repair = building["damaged"] - if not repair and building["level"] >= BUILDINGS[key]["max"]: - return "Already at its highest level" - cost = repair_cost(key, building["level"]) if repair else build_cost(key, building["level"] + 1) - missing = missing_for(city, cost) - if missing: - return "The stockpile is short: " + ", ".join(missing) - for item, qty in cost.items(): + if item != "gold" and item not in ITEMS: + return "No such item" + need = fund_missing(world, key).get(item, 0) + if not need: + return "The fund doesn't need that" + if source == "bank": + have = player["gold"] if item == "gold" else player["bank"].get(item, 0) + elif source == "city": + have = int(city["treasury"]) if item == "gold" else city["stockpile"].get(item, 0) + else: + return "Bad source" + if qty == "all": + qty = min(have, need) + if not isinstance(qty, int) or qty <= 0: + return "Nothing to give" + if qty > have: + return "Not enough to give" + qty = min(qty, need) + if source == "bank": if item == "gold": - city["treasury"] -= qty + player["gold"] -= qty else: - left = city["stockpile"][item] - qty - if left: - city["stockpile"][item] = left - else: - del city["stockpile"][item] - hours = BUILD_HOURS * (building["level"] + 1) * (0.25 if repair else 1) - city["construction"] = {"building": key, "repair": repair, "start": now, - "done_at": now + hours * HOUR / speed(world)} - verb = "repairs to" if repair else "work on" - log(world, now, "build", f"{player['name']} started {verb} the {BUILDINGS[key]['name'].lower()}", player["id"]) + bank_take(player, item, qty) + gain = qty * (1 if item == "gold" else ITEMS[item]["value"]) + player["favour"] += gain + player["donated"] += gain + elif item == "gold": + city["treasury"] -= qty + else: + take_stock(city, item, qty) + building = city["buildings"][key] + building["fund"][item] = building["fund"].get(item, 0) + qty + label = f"{qty:,} gold" if item == "gold" else amount(item, qty) + where = "" if source == "bank" else " from the city's stores" + log(world, now, "fund", f"{player['name']} put {label}{where} towards the {BUILDINGS[key]['name'].lower()}", player["id"]) + _queue_if_funded(world, key, now) + _start_next(world, now) return None -def missing_for(city: dict, cost: dict) -> list[str]: - missing = [] - for item, qty in cost.items(): - have = city["treasury"] if item == "gold" else city["stockpile"].get(item, 0) - if have < qty: - name = "gold" if item == "gold" else ITEMS[item]["name"].lower() - missing.append(f"{qty - int(have):,} {name}") - return missing +def take_stock(city: dict, item: str, qty: int) -> None: + left = city["stockpile"][item] - qty + if left > 0: + city["stockpile"][item] = left + else: + del city["stockpile"][item] + + +def _queue_if_funded(world: dict, key: str, now: float) -> None: + city = world["city"] + if fund_target(world, key) is None or fund_missing(world, key): + return + busy = city["construction"] and city["construction"]["building"] == key + if key not in city["queue"] and not busy: + city["queue"].append(key) + log(world, now, "build", f"The {BUILDINGS[key]['name'].lower()} are fully funded" + if BUILDINGS[key]["name"].endswith("s") else f"The {BUILDINGS[key]['name'].lower()} is fully funded") + + +def _start_next(world: dict, now: float) -> None: + """Start the next funded building, if the builders are free.""" + city = world["city"] + while not city["construction"] and city["queue"]: + key = city["queue"].pop(0) + target = fund_target(world, key) + if target is None or fund_missing(world, key): + continue # its target changed while it waited (e.g. damaged); keep funding + building = city["buildings"][key] + for item, qty in target.items(): + left = building["fund"][item] - qty + if left > 0: + building["fund"][item] = left + else: + del building["fund"][item] + repair = building["damaged"] + hours = BUILD_HOURS * (building["level"] + 1) * (0.25 if repair else 1) + city["construction"] = {"building": key, "repair": repair, "start": now, + "done_at": now + hours * HOUR / speed(world)} + verb = "Repairs to" if repair else "Work on" + log(world, now, "build", f"{verb} the {BUILDINGS[key]['name'].lower()} began") + + +def population_view(world: dict) -> dict: + """Population, food and tier, with the numbers that explain them.""" + city = world["city"] + pop = city["population"] + room = housing(world) + if city["starving"]: + growth = -pop * 0.04 + elif pop < room - 0.01: + growth = max(0.5, pop * GROWTH_PER_DAY) + elif pop > room: + growth = -pop * 0.05 + else: + growth = 0.0 + food = sum(city["stockpile"].get(item, 0) * ITEMS[item]["food"] for item in FOOD_ORDER) + tier = city["tier"] + following = TIERS[tier + 1] if tier + 1 < len(TIERS) else None + return { + "population": int(pop), "housing": room, "growth": round(growth, 1), + "food": food, "food_days": round(food / (pop * 0.5), 1) if pop else None, + "tier": tier, "tier_name": TIERS[tier]["name"], + "next_tier": following and {"name": following["name"], "pop": following["pop"]}, + } def defence(world: dict) -> dict: @@ -634,6 +761,16 @@ def tick(world: dict, now: float, rng: random.Random | None = None) -> bool: construction = city["construction"] if construction and construction["done_at"] <= now: _finish_construction(world, construction["done_at"]) + _start_next(world, construction["done_at"]) + changed = True + while city["tier"] + 1 < len(TIERS) and city["population"] >= TIERS[city["tier"] + 1]["pop"]: + city["tier"] += 1 + tier = TIERS[city["tier"]] + log(world, now, "tier", f"The settlement has grown into a {tier['name'].lower()}! " + f"Buildings can go higher, and everyone gets {TIER_SLOTS} more bank slots.") + for key in BUILDINGS: + _queue_if_funded(world, key, now) + _start_next(world, now) changed = True if world["next_event_at"] and world["next_event_at"] <= now and not upcoming_event(world): _announce(world, now, rng) @@ -669,7 +806,7 @@ def _city_hour(world: dict, now: float) -> None: else: room = housing(world) if pop < room: - city["population"] = min(room, pop + max(0.25, pop * 0.06) / 24) + city["population"] = min(room, pop + max(0.5, pop * GROWTH_PER_DAY) / 24) elif pop > room: city["population"] = max(room, pop - pop * 0.05 / 24) city["treasury"] += pop * 0.5 / 24 @@ -688,6 +825,8 @@ def _finish_construction(world: dict, now: float) -> None: building["level"] += 1 log(world, now, "build", f"{name} reached level {building['level']}") city["construction"] = None + # Leftover materials may already cover the next step. + _queue_if_funded(world, work["building"], now) # --- events -------------------------------------------------------------- @@ -870,6 +1009,7 @@ def _resolve_raid(world: dict, event: dict, now: float, rng: random.Random) -> N if severity > 0.35 and built: key = rng.choice(built) city["buildings"][key]["damaged"] = True + _queue_if_funded(world, key, now) lines.append(f"The {BUILDINGS[key]['name'].lower()} were damaged and won't work until repaired." if BUILDINGS[key]["name"].endswith("s") else f"The {BUILDINGS[key]['name'].lower()} was damaged and won't work until repaired.") @@ -917,5 +1057,5 @@ def content() -> dict: } return { "items": ITEMS, "skills": SKILLS, "classes": CLASSES, "actions": ACTIONS, - "buildings": buildings, "xp_table": XP_TABLE, "offline_cap": OFFLINE_CAP, + "buildings": buildings, "xp_table": XP_TABLE, "offline_cap": OFFLINE_CAP, "tiers": TIERS, } diff --git a/static/app.js b/static/app.js index 17ae8b2..e4c34b3 100644 --- a/static/app.js +++ b/static/app.js @@ -425,46 +425,68 @@ function favourPanel() { function town() { const city = S.city; + const growth = city.starving ? `Hungry: ${city.growth} a day` + : city.growth > 0 ? `+${city.growth} a day` + : city.growth < 0 ? `Overcrowded: ${city.growth} a day` + : `Full. Build more houses to grow`; + const next = city.next_tier ? `${city.next_tier.name} at ${city.next_tier.pop} people` : "The largest a settlement can be"; let html = `
-
Day
${city.day}
-
Population
${city.population} / ${city.housing} homes
-
Treasury
${fmt(city.treasury)}
-
Defence
${fmt(city.defence.total)}
-
Raids repelled
${city.repelled}
+
Settlement
${city.tier_name}
${next}
+
Population
${city.population} / ${city.housing} homes
${growth}
+
Food
${city.food_days ?? 0} days
${fmt(city.food)} meals in the granary
+
Treasury
${fmt(city.treasury)}
Day ${city.day}
+
Defence
${fmt(city.defence.total)}
${city.repelled} raid${city.repelled === 1 ? "" : "s"} repelled
`; if (city.starving) html += ``; if (city.merchant_until > serverNow()) html += ``; const work = city.construction; if (work) { const spec = C.buildings[work.building]; + const queued = city.queue.map((key) => esc(C.buildings[key].name)).join(", "); html += ``; +
+ ${queued ? `
Next: ${queued}
` : ""}`; } - html += `
Buildings
`; - for (const [key, b] of Object.entries(city.buildings)) { - const spec = C.buildings[key]; - const cost = b.cost ? Object.entries(b.cost).map(([id, q]) => id === "gold" - ? costChip("๐Ÿช™", "Gold", q, city.treasury) - : costChip(item(id).icon, item(id).name, q, city.stockpile[id] || 0)).join("") : ""; - const label = b.damaged ? "Repair" : `Build level ${b.level + 1}`; - html += `
-
${spec.icon}
-
${esc(spec.name)} ${b.level} / ${spec.max} ${b.damaged ? `Damaged` : ""}
-
${esc(spec.effect)}
-
- ${b.cost ? `
${cost}
-
${work ? "Builders busy" : ""} -
` - : `
Fully built
`} -
`; - } - html += `
City stockpile
`; + html += `
Buildings
+

Each building collects its own fund. Materials in a fund are safe from raiders, and building starts on its own once the fund is complete.

+
${Object.entries(city.buildings).map(([key, b]) => buildingCard(key, b)).join("")}
`; + html += `
City stockpile
`; const stock = Object.entries(city.stockpile); - html += stock.length ? `
${stock.map(([id, q]) => itemChip(id, q)).join("")}
` : `

Empty. Donate from your bank.

`; - html += `

Anyone can start a building once the stockpile and treasury cover it. Until then, the stockpile is what raiders come for.

`; + html += stock.length ? `
${stock.map(([id, q]) => itemChip(id, q)).join("")}
` : `

Empty.

`; + html += `

Food, weapons and anything not yet in a fund. The granary and armoury draw from here, and so do raiders.

`; return html; } +function buildingCard(key, b) { + const spec = C.buildings[key], city = S.city, me = S.me; + const tags = { + building: `Under construction`, + queued: `Funded ยท waiting for builders`, + max: `Fully built`, + capped: `Next level when the settlement grows`, + }; + let body = tags[b.status] ? `
${tags[b.status]}
` : ""; + if (b.status === "funding" || b.status === "queued") { + body += `
${Object.entries(b.target).map(([id, need]) => { + const missing = b.missing[id] || 0; + const gold = id === "gold"; + const mine = Math.min(missing, gold ? me.gold : me.bank[id] || 0); + const stores = Math.min(missing, gold ? city.treasury : city.stockpile[id] || 0); + const chip = costChip(gold ? "๐Ÿช™" : item(id).icon, gold ? "Gold" : item(id).name, need, need - missing); + return `
${chip} + ${mine ? `` : ""} + ${stores ? `` : ""} +
`; + }).join("")}
`; + } + const levelText = b.cap < spec.max ? `Lv ${b.level} ยท max ${b.cap} for a ${city.tier_name.toLowerCase()}` : `Lv ${b.level} / ${spec.max}`; + return `
+
${spec.icon}
+
${esc(spec.name)} ${levelText} ${b.damaged ? `Damaged: repairs needed` : ""}
+
${esc(spec.effect)}
+
${body}
`; +} + // --- council -------------------------------------------------------------- function pips(level) { @@ -633,7 +655,7 @@ document.addEventListener("click", (e) => { if (d.give) return act("/api/give", { item: d.give, qty, to: $("[data-keep=give-to]").value }); if (d.equip) return act("/api/equip", { item: d.equip }); if (d.unequip) return act("/api/equip", { slot: d.unequip }); - if (d.build) return act("/api/build", { building: d.build }); + if (d.fund) return act("/api/fund", { building: d.fund, item: d.item, source: d.source, qty: "all" }); if (d.garrison) return act("/api/garrison", { join: d.garrison === "1" }); if (d.perk) return act("/api/perk", { perk: d.perk }); if (d.overflow) return act("/api/settings", { overflow: d.overflow }); diff --git a/static/styles.css b/static/styles.css index 37468a8..3b61292 100644 --- a/static/styles.css +++ b/static/styles.css @@ -133,6 +133,9 @@ main { padding: 24px; max-width: 1200px; width: 100%; } .stat .k { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; } .stat .v { font-size: 22px; font-weight: 650; font-variant-numeric: tabular-nums; } .stat .v small { font-size: 13px; color: var(--muted); font-weight: 400; } +.stat .note { font-size: 12.5px; color: var(--muted); margin-top: 2px; } +.stat .bad, .note .bad { color: var(--bad); } +.note .warn { color: var(--warn); } .bar { height: 8px; background: var(--bg); border-radius: 99px; overflow: hidden; } .bar > span { display: block; height: 100%; background: var(--good); border-radius: 99px; width: 0; } @@ -217,6 +220,8 @@ textarea { width: 100%; resize: vertical; min-height: 60px; } .building .lv { font-variant-numeric: tabular-nums; } .building.damaged { border-color: rgba(226, 100, 92, .6); } .cost { display: flex; flex-wrap: wrap; gap: 6px; } +.fund { display: flex; flex-direction: column; gap: 6px; } +.fund-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; } .cost .item-chip .have { color: var(--muted); } .banner { display: flex; gap: 14px; align-items: center; padding: 12px 16px; margin-bottom: 18px; diff --git a/tests/test_game.py b/tests/test_game.py index d5a95e5..ac0a973 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -125,24 +125,112 @@ class GiftTest(unittest.TestCase): class CityTest(unittest.TestCase): - def test_build_needs_stockpile_then_takes_time(self): + def test_fund_from_bank_and_stores_then_builds(self): world = world_with(("fred", "warrior")) p = world["players"]["fred"] - self.assertIn("short", game.commission(world, p, "walls", T0)) - world["city"]["stockpile"].update({"stone": 1200, "bricks": 500}) - world["city"]["treasury"] = 300 - self.assertIsNone(game.commission(world, p, "walls", T0)) - self.assertEqual(world["city"]["treasury"], 0) - self.assertNotIn("stone", world["city"]["stockpile"]) + city = world["city"] + p["bank"] = {"stone": 1500} + city["stockpile"] = {"bricks": 600} + city["treasury"] = 300 + self.assertIsNone(game.fund(world, p, "walls", "stone", "all", "bank", T0)) + self.assertEqual(p["bank"], {"stone": 300}) # only what the fund needed + self.assertEqual(p["favour"], 1200) + self.assertIsNone(game.fund(world, p, "walls", "bricks", 500, "city", T0)) + self.assertEqual(city["stockpile"], {"bricks": 100}) + self.assertIsNone(city["construction"]) + self.assertEqual(game.fund(world, p, "walls", "stone", 1, "bank", T0), "The fund doesn't need that") + self.assertIsNone(game.fund(world, p, "walls", "gold", "all", "city", T0)) + self.assertEqual(city["construction"]["building"], "walls") + self.assertEqual(city["buildings"]["walls"]["fund"], {}) game.tick(world, T0 + 3 * game.HOUR, random.Random(1)) - self.assertEqual(world["city"]["buildings"]["walls"]["level"], 1) + self.assertEqual(city["buildings"]["walls"]["level"], 1) self.assertEqual(game.defence(world)["parts"]["walls"], 20) + def test_funded_buildings_queue_behind_the_builders(self): + world = world_with(("fred", "warrior")) + p = world["players"]["fred"] + city = world["city"] + for key in ("walls", "houses"): + city["buildings"][key]["fund"] = {k: v for k, v in game.build_cost(key, 1).items() if k != "gold"} + city["treasury"] += game.build_cost(key, 1)["gold"] + self.assertIsNone(game.fund(world, p, key, "gold", "all", "city", T0)) + self.assertEqual(city["construction"]["building"], "walls") + self.assertEqual(city["queue"], ["houses"]) + game.tick(world, T0 + 3 * game.HOUR, random.Random(1)) + self.assertEqual(city["construction"]["building"], "houses") + self.assertEqual(city["queue"], []) + + def test_raids_never_touch_funds(self): + world = world_with(("fred", "warrior")) + city = world["city"] + city["buildings"]["walls"]["fund"] = {"stone": 1000} + city["stockpile"] = {"stone": 1000} + world["next_event_at"] = T0 + game.tick(world, T0, random.Random(2)) + raid = game.upcoming_raid(world) + raid["strength"] = 500 + game.tick(world, raid["fire_at"], random.Random(1)) + self.assertLess(city["stockpile"]["stone"], 1000) + self.assertEqual(city["buildings"]["walls"]["fund"], {"stone": 1000}) + + def test_tier_caps_levels_and_promotes_for_good(self): + world = world_with(("fred", "warrior")) + city = world["city"] + city["buildings"]["walls"]["level"] = 1 + self.assertIsNone(game.fund_target(world, "walls")) # hamlet: level 1 only + self.assertIsNotNone(game.fund_target(world, "houses")) # houses go to 2 + slots = game.bank_slots(world, world["players"]["fred"]) + city["population"] = 41 + world["next_event_at"] = None + game.tick(world, T0 + 1, random.Random(1)) + self.assertEqual(city["tier"], 1) + self.assertIsNotNone(game.fund_target(world, "walls")) + self.assertEqual(game.bank_slots(world, world["players"]["fred"]), slots + game.TIER_SLOTS) + city["population"] = 10 + game.tick(world, T0 + 2, random.Random(1)) + self.assertEqual(city["tier"], 1) + + def test_damage_turns_the_fund_towards_repairs_and_keeps_the_rest(self): + world = world_with(("fred", "warrior")) + p = world["players"]["fred"] + city = world["city"] + city["buildings"]["walls"].update(level=1, damaged=True, fund={"stone": 2000}) + repair = game.repair_cost("walls", 1) + self.assertEqual(game.fund_target(world, "walls"), repair) + for item, qty in repair.items(): + if item == "gold": + city["treasury"] = qty + game.fund(world, p, "walls", "gold", "all", "city", T0) + elif item != "stone": + city["stockpile"][item] = qty + game.fund(world, p, "walls", item, "all", "city", T0) + self.assertTrue(city["construction"]["repair"]) + self.assertEqual(city["buildings"]["walls"]["fund"], {"stone": 2000 - repair["stone"]}) + + def test_old_worlds_migrate(self): + world = game.new_world(T0) + world["version"] = 1 + for building in world["city"]["buildings"].values(): + del building["fund"] + del world["city"]["queue"], world["city"]["tier"] + game.migrate(world) + self.assertEqual(world["version"], game.WORLD_VERSION) + self.assertEqual(world["city"]["queue"], []) + self.assertEqual(world["city"]["buildings"]["walls"]["fund"], {}) + + def test_population_view_explains_growth(self): + world = world_with() + view = game.population_view(world) + self.assertEqual(view["tier_name"], "Hamlet") + self.assertEqual(view["next_tier"], {"name": "Village", "pop": 40}) + self.assertGreater(view["growth"], 0) + self.assertEqual(view["food_days"], 4.0) # 40 bread for 20 people eating 0.5 a day + def test_people_eat_and_go_hungry(self): world = world_with() city = world["city"] city["stockpile"] = {"bread": 5} - game.tick(world, T0 + 2 * game.DAY, random.Random(1)) + game.tick(world, T0 + 4 * game.DAY, random.Random(1)) self.assertTrue(city["starving"]) self.assertNotIn("bread", city["stockpile"]) self.assertLess(city["population"], 20)