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:
parent
e3b70c1e3a
commit
3716df7493
6 changed files with 358 additions and 91 deletions
|
|
@ -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
34
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)")
|
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:
|
||||||
|
|
|
||||||
216
game.py
216
game.py
|
|
@ -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":
|
||||||
|
player["gold"] -= qty
|
||||||
|
else:
|
||||||
|
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
|
city["treasury"] -= qty
|
||||||
else:
|
else:
|
||||||
left = city["stockpile"][item] - qty
|
take_stock(city, item, qty)
|
||||||
if left:
|
building = city["buildings"][key]
|
||||||
city["stockpile"][item] = left
|
building["fund"][item] = building["fund"].get(item, 0) + qty
|
||||||
else:
|
label = f"{qty:,} gold" if item == "gold" else amount(item, qty)
|
||||||
del city["stockpile"][item]
|
where = "" if source == "bank" else " from the city's stores"
|
||||||
hours = BUILD_HOURS * (building["level"] + 1) * (0.25 if repair else 1)
|
log(world, now, "fund", f"{player['name']} put {label}{where} towards the {BUILDINGS[key]['name'].lower()}", player["id"])
|
||||||
city["construction"] = {"building": key, "repair": repair, "start": now,
|
_queue_if_funded(world, key, now)
|
||||||
"done_at": now + hours * HOUR / speed(world)}
|
_start_next(world, now)
|
||||||
verb = "repairs to" if repair else "work on"
|
|
||||||
log(world, now, "build", f"{player['name']} started {verb} the {BUILDINGS[key]['name'].lower()}", player["id"])
|
|
||||||
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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue