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 - **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). 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. Raids never touch your bank. Players can give items to each other.
- **City:** donations fill the stockpile and treasury; anyone can start a - **City:** each building collects its own fund, from players' banks or the
building once they cover its cost. Population eats food and pays taxes. 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 - **Events:** raids are announced a day or two ahead. Intel (spymaster's
guild + scout reconnaissance) narrows down when, how strong, and finally guild + scout reconnaissance) narrows down when, how strong, and finally
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

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)") 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() row = db.execute("SELECT data FROM world WHERE id = 1").fetchone()
if row: if row:
self.world = json.loads(row[0]) self.world = game.migrate(json.loads(row[0]))
self.world["config"]["speed"] = speed self.world["config"]["speed"] = speed
else: else:
self.world = game.new_world(time.time(), speed) self.world = game.new_world(time.time(), speed)
@ -109,20 +109,25 @@ def city_view(world: dict, now: float) -> dict:
city = world["city"] city = world["city"]
buildings = {} buildings = {}
for key, building in city["buildings"].items(): for key, building in city["buildings"].items():
spec = game.BUILDINGS[key] working = city["construction"] and city["construction"]["building"] == key
if building["damaged"]: target = game.fund_target(world, key)
cost = game.repair_cost(key, building["level"]) missing = game.fund_missing(world, key)
elif building["level"] < spec["max"]: if working:
cost = game.build_cost(key, building["level"] + 1) 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: else:
cost = None status = "funding"
buildings[key] = {**building, "cost": cost, "missing": game.missing_for(city, cost) if cost else []} buildings[key] = {**building, "target": target, "missing": missing, "status": status,
"cap": game.level_cap(world, key)}
return { return {
"population": int(city["population"]), **game.population_view(world),
"housing": game.housing(world), "buildings": buildings,
"queue": city["queue"],
"treasury": int(city["treasury"]), "treasury": int(city["treasury"]),
"stockpile": city["stockpile"], "stockpile": city["stockpile"],
"buildings": buildings,
"construction": city["construction"], "construction": city["construction"],
"starving": city["starving"], "starving": city["starving"],
"merchant_until": city.get("merchant_until", 0), "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) return "Gold can't be sold" if item == "gold" else game.sell(world, player, item, qty, now)
if path == "/api/equip": if path == "/api/equip":
return self.equip(player, item, body.get("slot")) return self.equip(player, item, body.get("slot"))
if path == "/api/build": if path == "/api/fund":
return game.commission(world, player, str(body.get("building")), now) 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": if path == "/api/garrison":
raid = game.upcoming_raid(world) raid = game.upcoming_raid(world)
if body.get("join") and not raid: 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 = { BUILDINGS = {
"houses": { "houses": {
"name": "Houses", "icon": "🏠", "max": 5, "gold": 200, "name": "Houses", "icon": "🏠", "max": 8, "gold": 200,
"base": {"logs": 600, "bricks": 300}, "advanced": {"oak_logs": 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": { "walls": {
"name": "Walls", "icon": "🏰", "max": 5, "gold": 300, "name": "Walls", "icon": "🏰", "max": 5, "gold": 300,
@ -193,6 +193,18 @@ BUILDINGS = {
BUILD_HOURS = 3 BUILD_HOURS = 3
REPAIR_SHARE = 0.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", 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"]
RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows", 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 ---------------------------------------------------------------
WORLD_VERSION = 2
def new_world(now: float, speed: float = 1.0) -> dict: def new_world(now: float, speed: float = 1.0) -> dict:
return { return {
"version": 1, "version": WORLD_VERSION,
"created": now, "created": now,
"config": {"speed": speed, "clock_offset": 0}, "config": {"speed": speed, "clock_offset": 0},
"players": {}, "players": {},
@ -212,8 +227,10 @@ def new_world(now: float, speed: float = 1.0) -> dict:
"population": 20.0, "population": 20.0,
"treasury": 0.0, "treasury": 0.0,
"stockpile": {"bread": 40}, "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, "construction": None,
"queue": [],
"tier": 0,
"last_tick": now, "last_tick": now,
"food_due": 0.0, "food_due": 0.0,
"starving": False, "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: def speed(world: dict) -> float:
return world["config"]["speed"] return world["config"]["speed"]
@ -268,7 +297,29 @@ def repair_cost(key: str, level: int) -> dict:
def housing(world: dict) -> int: 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 ------------------------------------------------------------- # --- players -------------------------------------------------------------
@ -292,7 +343,8 @@ def skill_level(player: dict, skill: str) -> int:
def bank_slots(world: dict, player: dict) -> 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: def stack_size(world: dict) -> int:
@ -549,45 +601,120 @@ def sell_price(world: dict, item: str, now: float) -> float:
return price 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"] city = world["city"]
if key not in BUILDINGS: if key not in BUILDINGS:
return "No such building" return "No such building"
if city["construction"]: if item != "gold" and item not in ITEMS:
return "The builders are busy" return "No such item"
building = city["buildings"][key] need = fund_missing(world, key).get(item, 0)
repair = building["damaged"] if not need:
if not repair and building["level"] >= BUILDINGS[key]["max"]: return "The fund doesn't need that"
return "Already at its highest level" if source == "bank":
cost = repair_cost(key, building["level"]) if repair else build_cost(key, building["level"] + 1) have = player["gold"] if item == "gold" else player["bank"].get(item, 0)
missing = missing_for(city, cost) elif source == "city":
if missing: have = int(city["treasury"]) if item == "gold" else city["stockpile"].get(item, 0)
return "The stockpile is short: " + ", ".join(missing) else:
for item, qty in cost.items(): 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": if item == "gold":
city["treasury"] -= qty player["gold"] -= qty
else: else:
left = city["stockpile"][item] - qty bank_take(player, item, qty)
if left: gain = qty * (1 if item == "gold" else ITEMS[item]["value"])
city["stockpile"][item] = left player["favour"] += gain
else: player["donated"] += gain
del city["stockpile"][item] elif item == "gold":
hours = BUILD_HOURS * (building["level"] + 1) * (0.25 if repair else 1) city["treasury"] -= qty
city["construction"] = {"building": key, "repair": repair, "start": now, else:
"done_at": now + hours * HOUR / speed(world)} take_stock(city, item, qty)
verb = "repairs to" if repair else "work on" building = city["buildings"][key]
log(world, now, "build", f"{player['name']} started {verb} the {BUILDINGS[key]['name'].lower()}", player["id"]) 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 return None
def missing_for(city: dict, cost: dict) -> list[str]: def take_stock(city: dict, item: str, qty: int) -> None:
missing = [] left = city["stockpile"][item] - qty
for item, qty in cost.items(): if left > 0:
have = city["treasury"] if item == "gold" else city["stockpile"].get(item, 0) city["stockpile"][item] = left
if have < qty: else:
name = "gold" if item == "gold" else ITEMS[item]["name"].lower() del city["stockpile"][item]
missing.append(f"{qty - int(have):,} {name}")
return missing
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: def defence(world: dict) -> dict:
@ -634,6 +761,16 @@ def tick(world: dict, now: float, rng: random.Random | None = None) -> bool:
construction = city["construction"] construction = city["construction"]
if construction and construction["done_at"] <= now: if construction and construction["done_at"] <= now:
_finish_construction(world, construction["done_at"]) _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 changed = True
if world["next_event_at"] and world["next_event_at"] <= now and not upcoming_event(world): if world["next_event_at"] and world["next_event_at"] <= now and not upcoming_event(world):
_announce(world, now, rng) _announce(world, now, rng)
@ -669,7 +806,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.25, pop * 0.06) / 24) city["population"] = min(room, pop + max(0.5, pop * GROWTH_PER_DAY) / 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
@ -688,6 +825,8 @@ def _finish_construction(world: dict, now: float) -> None:
building["level"] += 1 building["level"] += 1
log(world, now, "build", f"{name} reached level {building['level']}") log(world, now, "build", f"{name} reached level {building['level']}")
city["construction"] = None city["construction"] = None
# Leftover materials may already cover the next step.
_queue_if_funded(world, work["building"], now)
# --- events -------------------------------------------------------------- # --- events --------------------------------------------------------------
@ -870,6 +1009,7 @@ def _resolve_raid(world: dict, event: dict, now: float, rng: random.Random) -> N
if severity > 0.35 and built: if severity > 0.35 and built:
key = rng.choice(built) key = rng.choice(built)
city["buildings"][key]["damaged"] = True 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." lines.append(f"The {BUILDINGS[key]['name'].lower()} were damaged and won't work until repaired."
if BUILDINGS[key]["name"].endswith("s") else if BUILDINGS[key]["name"].endswith("s") else
f"The {BUILDINGS[key]['name'].lower()} was damaged and won't work until repaired.") f"The {BUILDINGS[key]['name'].lower()} was damaged and won't work until repaired.")
@ -917,5 +1057,5 @@ def content() -> dict:
} }
return { return {
"items": ITEMS, "skills": SKILLS, "classes": CLASSES, "actions": ACTIONS, "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() { function town() {
const city = S.city; 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"> 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">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> <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">Treasury</div><div class="v gold">${fmt(city.treasury)}</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">Defence</div><div class="v">${fmt(city.defence.total)}</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">Raids repelled</div><div class="v">${city.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>
</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.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>`;
const work = city.construction; const work = city.construction;
if (work) { if (work) {
const spec = C.buildings[work.building]; 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)} 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">`; html += `<div class="section-title">Buildings</div>
for (const [key, b] of Object.entries(city.buildings)) { <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>
const spec = C.buildings[key]; <div class="grid wide">${Object.entries(city.buildings).map(([key, b]) => buildingCard(key, b)).join("")}</div>`;
const cost = b.cost ? Object.entries(b.cost).map(([id, q]) => id === "gold" html += `<div class="section-title">City stockpile</div><div class="panel">`;
? 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">`;
const stock = Object.entries(city.stockpile); 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 += stock.length ? `<div class="stock">${stock.map(([id, q]) => itemChip(id, q)).join("")}</div>` : `<p class="muted">Empty.</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 += `<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; 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 -------------------------------------------------------------- // --- council --------------------------------------------------------------
function pips(level) { 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.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.equip) return act("/api/equip", { item: d.equip });
if (d.unequip) return act("/api/equip", { slot: d.unequip }); 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.garrison) return act("/api/garrison", { join: d.garrison === "1" });
if (d.perk) return act("/api/perk", { perk: d.perk }); if (d.perk) return act("/api/perk", { perk: d.perk });
if (d.overflow) return act("/api/settings", { overflow: d.overflow }); 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 .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 { font-size: 22px; font-weight: 650; font-variant-numeric: tabular-nums; }
.stat .v small { font-size: 13px; color: var(--muted); font-weight: 400; } .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 { height: 8px; background: var(--bg); border-radius: 99px; overflow: hidden; }
.bar > span { display: block; height: 100%; background: var(--good); border-radius: 99px; width: 0; } .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 .lv { font-variant-numeric: tabular-nums; }
.building.damaged { border-color: rgba(226, 100, 92, .6); } .building.damaged { border-color: rgba(226, 100, 92, .6); }
.cost { display: flex; flex-wrap: wrap; gap: 6px; } .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); } .cost .item-chip .have { color: var(--muted); }
.banner { .banner {
display: flex; gap: 14px; align-items: center; padding: 12px 16px; margin-bottom: 18px; 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): 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")) world = world_with(("fred", "warrior"))
p = world["players"]["fred"] p = world["players"]["fred"]
self.assertIn("short", game.commission(world, p, "walls", T0)) city = world["city"]
world["city"]["stockpile"].update({"stone": 1200, "bricks": 500}) p["bank"] = {"stone": 1500}
world["city"]["treasury"] = 300 city["stockpile"] = {"bricks": 600}
self.assertIsNone(game.commission(world, p, "walls", T0)) city["treasury"] = 300
self.assertEqual(world["city"]["treasury"], 0) self.assertIsNone(game.fund(world, p, "walls", "stone", "all", "bank", T0))
self.assertNotIn("stone", world["city"]["stockpile"]) 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)) 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) 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): def test_people_eat_and_go_hungry(self):
world = world_with() world = world_with()
city = world["city"] city = world["city"]
city["stockpile"] = {"bread": 5} 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.assertTrue(city["starving"])
self.assertNotIn("bread", city["stockpile"]) self.assertNotIn("bread", city["stockpile"])
self.assertLess(city["population"], 20) self.assertLess(city["population"], 20)