Building funds safe from raids; settlement tiers; population and food on the town square

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-18 19:20:52 +01:00
parent e3b70c1e3a
commit 3716df7493
6 changed files with 358 additions and 91 deletions

View file

@ -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

34
app.py
View file

@ -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:

218
game.py
View file

@ -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,
}

View file

@ -425,46 +425,68 @@ function favourPanel() {
function town() {
const city = S.city;
const growth = city.starving ? `<span class="bad">Hungry: ${city.growth} a day</span>`
: city.growth > 0 ? `+${city.growth} a day`
: city.growth < 0 ? `<span class="bad">Overcrowded: ${city.growth} a day</span>`
: `<span class="warn">Full. Build more houses to grow</span>`;
const next = city.next_tier ? `${city.next_tier.name} at ${city.next_tier.pop} people` : "The largest a settlement can be";
let html = `<div class="stats">
<div class="stat"><div class="k">Day</div><div class="v">${city.day}</div></div>
<div class="stat"><div class="k">Population</div><div class="v">${city.population} <small>/ ${city.housing} homes</small></div></div>
<div class="stat"><div class="k">Treasury</div><div class="v gold">${fmt(city.treasury)}</div></div>
<div class="stat"><div class="k">Defence</div><div class="v">${fmt(city.defence.total)}</div></div>
<div class="stat"><div class="k">Raids repelled</div><div class="v">${city.repelled}</div></div>
<div class="stat"><div class="k">Settlement</div><div class="v">${city.tier_name}</div><div class="note">${next}</div></div>
<div class="stat"><div class="k">Population</div><div class="v">${city.population} <small>/ ${city.housing} homes</small></div><div class="note">${growth}</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">Defence</div><div class="v">${fmt(city.defence.total)}</div><div class="note">${city.repelled} raid${city.repelled === 1 ? "" : "s"} repelled</div></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>`;
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 += `<div class="banner">🏗️<div class="grow"><b>${work.repair ? "Repairing" : "Building"} ${esc(spec.name)}</b> · done in ${countdown(work.done_at)}
<div class="bar gold" style="margin-top:6px"><span data-span="${work.start},${work.done_at}"></span></div></div></div>`;
<div class="bar gold" style="margin-top:6px"><span data-span="${work.start},${work.done_at}"></span></div>
${queued ? `<div class="small muted" style="margin-top:6px">Next: ${queued}</div>` : ""}</div></div>`;
}
html += `<div class="section-title">Buildings</div><div class="grid wide">`;
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 += `<div class="action building ${b.damaged ? "damaged" : ""}">
<div class="top"><div class="ico">${spec.icon}</div><div>
<div class="name">${esc(spec.name)} <span class="lv muted">${b.level} / ${spec.max}</span> ${b.damaged ? `<span class="tag bad">Damaged</span>` : ""}</div>
<div class="sub">${esc(spec.effect)}</div>
</div></div>
${b.cost ? `<div class="cost">${cost}</div>
<div class="foot"><span class="why">${work ? "Builders busy" : ""}</span>
<button class="btn ${b.damaged ? "danger" : "primary"}" data-build="${key}" ${b.missing.length || work ? "disabled" : ""}>${label}</button></div>`
: `<div class="tag good">Fully built</div>`}
</div>`;
}
html += `</div><div class="section-title">City stockpile</div><div class="panel">`;
html += `<div class="section-title">Buildings</div>
<p class="small muted">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.</p>
<div class="grid wide">${Object.entries(city.buildings).map(([key, b]) => buildingCard(key, b)).join("")}</div>`;
html += `<div class="section-title">City stockpile</div><div class="panel">`;
const stock = Object.entries(city.stockpile);
html += stock.length ? `<div class="stock">${stock.map(([id, q]) => itemChip(id, q)).join("")}</div>` : `<p class="muted">Empty. Donate from your bank.</p>`;
html += `<p class="small muted">Anyone can start a building once the stockpile and treasury cover it. Until then, the stockpile is what raiders come for.</p></div>`;
html += stock.length ? `<div class="stock">${stock.map(([id, q]) => itemChip(id, q)).join("")}</div>` : `<p class="muted">Empty.</p>`;
html += `<p class="small muted">Food, weapons and anything not yet in a fund. The granary and armoury draw from here, and so do raiders.</p></div>`;
return html;
}
function buildingCard(key, b) {
const spec = C.buildings[key], city = S.city, me = S.me;
const tags = {
building: `<span class="tag gold">Under construction</span>`,
queued: `<span class="tag good">Funded · waiting for builders</span>`,
max: `<span class="tag good">Fully built</span>`,
capped: `<span class="tag">Next level when the settlement grows</span>`,
};
let body = tags[b.status] ? `<div>${tags[b.status]}</div>` : "";
if (b.status === "funding" || b.status === "queued") {
body += `<div class="fund">${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 `<div class="fund-row">${chip}<span class="btns">
${mine ? `<button class="btn sm gold" data-fund="${key}" data-item="${id}" data-source="bank" title="From your ${gold ? "purse" : "bank"}, earns favour">Give ${fmt(mine)}</button>` : ""}
${stores ? `<button class="btn sm" data-fund="${key}" data-item="${id}" data-source="city" title="From the city's ${gold ? "treasury" : "stockpile"}">${gold ? "Treasury" : "Stockpile"} ${fmt(stores)}</button>` : ""}
</span></div>`;
}).join("")}</div>`;
}
const levelText = b.cap < spec.max ? `Lv ${b.level} · max ${b.cap} for a ${city.tier_name.toLowerCase()}` : `Lv ${b.level} / ${spec.max}`;
return `<div class="action building ${b.damaged ? "damaged" : ""}">
<div class="top"><div class="ico">${spec.icon}</div><div>
<div class="name">${esc(spec.name)} <span class="lv muted">${levelText}</span> ${b.damaged ? `<span class="tag bad">Damaged: repairs needed</span>` : ""}</div>
<div class="sub">${esc(spec.effect)}</div>
</div></div>${body}</div>`;
}
// --- 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 });

View file

@ -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;

View file

@ -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)