Construction: buildings rise by labour; planks and hammers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-19 09:24:45 +01:00
parent 4093245b8e
commit a4e4bd4385
6 changed files with 180 additions and 47 deletions

View file

@ -28,7 +28,10 @@ Alpha. One season, bandit raids plus two friendly events, six buildings.
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:** each building collects its own fund, from players' banks or the - **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 city's stockpile and treasury. Funds are safe from raiders, and a funded
building joins the construction queue by itself. Population eats food, building joins the construction queue by itself, then rises by labour:
players working the site with Construction (more labour per action every
20 levels, faster with a hammer), plus a slow trickle from the townsfolk.
Construction also saws planks, which higher building levels need. Population eats food,
pays taxes, and grows up to the housing cap; it promotes the settlement 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 (hamlet, village at 40, town at 90, city at 150), which raises building
level caps and gives everyone bank slots. level caps and gives everyone bank slots.

2
app.py
View file

@ -104,6 +104,7 @@ def player_view(world: dict, player: dict, now: float) -> dict:
view["fight_damage"] = {a["id"]: round(game.fight_damage(player, a, blessed), 1) view["fight_damage"] = {a["id"]: round(game.fight_damage(player, a, blessed), 1)
for a in game.ACTIONS if a["skill"] == "combat"} for a in game.ACTIONS if a["skill"] == "combat"}
view["pp"] = int(player["pp"]) view["pp"] = int(player["pp"])
view["labour_per_action"] = game.labour_per_action(player)
view["levels"] = {skill: game.skill_level(player, skill) for skill in game.SKILL_IDS} view["levels"] = {skill: game.skill_level(player, skill) for skill in game.SKILL_IDS}
view["slots"] = game.bank_slots(world, player) view["slots"] = game.bank_slots(world, player)
view["stack"] = game.stack_size(world) view["stack"] = game.stack_size(world)
@ -139,6 +140,7 @@ def city_view(world: dict, now: float) -> dict:
"buildings": buildings, "buildings": buildings,
"queue": city["queue"], "queue": city["queue"],
"devotion": round(city.get("devotion", 0), 1), "devotion": round(city.get("devotion", 0), 1),
"townsfolk_labour": round(city["population"] * game.TOWNSFOLK_LABOUR, 1),
"ward": game.ward(world), "ward": game.ward(world),
"treasury": int(city["treasury"]), "treasury": int(city["treasury"]),
"stockpile": city["stockpile"], "stockpile": city["stockpile"],

97
game.py
View file

@ -98,7 +98,7 @@ PIECE_ICONS = {"sword": "⚔️", "shield": "🛡️", "helmet": "🪖", "legs":
COPPER_ICONS = {"sword": "🗡️", "helmet": "⛑️", "legs": "🦵", "body": "🥋"} COPPER_ICONS = {"sword": "🗡️", "helmet": "⛑️", "legs": "🦵", "body": "🥋"}
# Gathering tools: one slot per skill, `speed` cuts the action time. # Gathering tools: one slot per skill, `speed` cuts the action time.
TOOLS = {"mining": ("pickaxe", "⛏️"), "woodcutting": ("axe", "🪓"), "farming": ("hoe", "🪏"), TOOLS = {"mining": ("pickaxe", "⛏️"), "woodcutting": ("axe", "🪓"), "farming": ("hoe", "🪏"),
"fishing": ("fishing rod", "🎣"), "hunting": ("hunting knife", "🔪")} "fishing": ("fishing rod", "🎣"), "hunting": ("hunting knife", "🔪"), "construction": ("hammer", "🔨")}
TOOL_SPEED = [0.15, 0.30, 0.38, 0.45, 0.52, 0.60] TOOL_SPEED = [0.15, 0.30, 0.38, 0.45, 0.52, 0.60]
# item id, name, icon, value, action id, verb # item id, name, icon, value, action id, verb
@ -160,8 +160,11 @@ for _tier, (_key, _name, _ore, _coal, _bar_icon, _bar_value, _smithy, _wood) in
"name": f"{_name} {_tool}", "icon": _icon, "value": round(2 * _bar_value * 1.4), "tier": _tier, "name": f"{_name} {_tool}", "icon": _icon, "value": round(2 * _bar_value * 1.4), "tier": _tier,
"slot": f"tool_{_skill}", "tool": _skill, "speed": TOOL_SPEED[_tier], "req": [_skill, EQUIP_LEVELS[_tier]], "slot": f"tool_{_skill}", "tool": _skill, "speed": TOOL_SPEED[_tier], "req": [_skill, EQUIP_LEVELS[_tier]],
} }
for _log, _name, _icon, _value, _action_id, _verb in TREES: PLANKS = [("plank", "Plank"), ("oak_plank", "Oak plank"), ("willow_plank", "Willow plank"),
("maple_plank", "Maple plank"), ("yew_plank", "Yew plank"), ("magic_plank", "Magic plank")]
for (_log, _name, _icon, _value, _action_id, _verb), (_plank, _plank_name) in zip(TREES, PLANKS):
ITEMS[_log] = {"name": _name, "icon": _icon, "value": _value} ITEMS[_log] = {"name": _name, "icon": _icon, "value": _value}
ITEMS[_plank] = {"name": _plank_name, "icon": "🪚", "value": _value * 2 + 1}
for _tier, (_raw, _leather, _prefix, _label, _level, _armour) in enumerate(LEATHERS): for _tier, (_raw, _leather, _prefix, _label, _level, _armour) in enumerate(LEATHERS):
ITEMS[_raw[0]] = {"name": _raw[1], "icon": _raw[2], "value": _raw[3]} ITEMS[_raw[0]] = {"name": _raw[1], "icon": _raw[2], "value": _raw[3]}
ITEMS[_leather[0]] = {"name": _leather[1], "icon": "🪢", "value": _leather[2]} ITEMS[_leather[0]] = {"name": _leather[1], "icon": "🪢", "value": _leather[2]}
@ -247,6 +250,7 @@ SKILLS = [
{"id": "fishing", "name": "Fishing", "icon": "🎣", "group": "Gathering"}, {"id": "fishing", "name": "Fishing", "icon": "🎣", "group": "Gathering"},
{"id": "smithing", "name": "Smithing", "icon": "🔨", "group": "Crafting"}, {"id": "smithing", "name": "Smithing", "icon": "🔨", "group": "Crafting"},
{"id": "masonry", "name": "Masonry", "icon": "🧱", "group": "Crafting"}, {"id": "masonry", "name": "Masonry", "icon": "🧱", "group": "Crafting"},
{"id": "construction", "name": "Construction", "icon": "🏗️", "group": "Crafting"},
{"id": "cooking", "name": "Cooking", "icon": "🍳", "group": "Crafting"}, {"id": "cooking", "name": "Cooking", "icon": "🍳", "group": "Crafting"},
{"id": "leatherworking", "name": "Leatherworking", "icon": "🪡", "group": "Crafting"}, {"id": "leatherworking", "name": "Leatherworking", "icon": "🪡", "group": "Crafting"},
{"id": "herblore", "name": "Herblore", "icon": "🧪", "group": "Crafting"}, {"id": "herblore", "name": "Herblore", "icon": "🧪", "group": "Crafting"},
@ -276,8 +280,8 @@ CLASSES = {
"signature": "Masterwork: 1 in 10 crafts makes double", "signature": "Masterwork: 1 in 10 crafts makes double",
}, },
"pioneer": { "pioneer": {
"name": "Pioneer", "icon": "🧭", "fast": ["mining", "woodcutting", "farming"], "name": "Pioneer", "icon": "🧭", "fast": ["mining", "woodcutting", "farming", "construction"],
"bonus": "Mining, woodcutting and farming 20% faster", "bonus": "Mining, woodcutting, farming and construction 20% faster",
"signature": "Surveying: 1 in 10 gathers yields double", "signature": "Surveying: 1 in 10 gathers yields double",
}, },
} }
@ -355,6 +359,10 @@ ACTIONS = [
{"copper_bar": 1, "logs": 1, "feathers": 2}, {"arrows": 15}, group="Bars"), {"copper_bar": 1, "logs": 1, "feathers": 2}, {"arrows": 15}, group="Bars"),
_action("lw_studded", "leatherworking", "Studded jerkin", 25, 50, 40, _action("lw_studded", "leatherworking", "Studded jerkin", 25, 50, 40,
{"leather": 4, "iron_bar": 1}, {"studded_body": 1}, group="Leather"), {"leather": 4, "iron_bar": 1}, {"studded_body": 1}, group="Leather"),
_action("labour", "construction", "Work on the building site", 1, 30, 10, special="labour"),
*[_action(f"saw_{plank}", "construction", f"Saw {name.lower()}s", TIER_LEVELS[tier], 25 + 3 * tier,
[6, 14, 22, 32, 44, 58][tier], {TREES[tier][0]: 2}, {plank: 1}, group="Planks")
for tier, (plank, name) in enumerate(PLANKS)],
_action("mason_bricks", "masonry", "Bricks", 1, 25, 6, {"stone": 2, "logs": 1}, {"bricks": 2}), _action("mason_bricks", "masonry", "Bricks", 1, 25, 6, {"stone": 2, "logs": 1}, {"bricks": 2}),
_action("mason_cut_stone", "masonry", "Cut stone", 10, 30, 12, {"stone": 3}, {"cut_stone": 1}), _action("mason_cut_stone", "masonry", "Cut stone", 10, 30, 12, {"stone": 3}, {"cut_stone": 1}),
_action("mason_beam", "masonry", "Reinforced beam", 20, 45, 30, _action("mason_beam", "masonry", "Reinforced beam", 20, 45, 30,
@ -399,47 +407,50 @@ BUILDINGS = {
"houses": { "houses": {
"name": "Houses", "icon": "🏠", "max": 8, "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},
"late": {"willow_logs": 150}, "final": {"maple_logs": 100}, "late": {"willow_logs": 150, "oak_plank": 60}, "final": {"maple_logs": 100, "willow_plank": 60},
"effect": "Room for 20 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,
"base": {"stone": 1200, "bricks": 500}, "advanced": {"cut_stone": 300, "beam": 40}, "base": {"stone": 1200, "bricks": 500}, "advanced": {"cut_stone": 300, "beam": 40},
"late": {"steel_bar": 60}, "final": {"mithril_bar": 40}, "late": {"steel_bar": 60, "oak_plank": 40}, "final": {"mithril_bar": 40, "maple_plank": 40},
"effect": "+20 defence per level, and raiders carry off less", "effect": "+20 defence per level, and raiders carry off less",
}, },
"barracks": { "barracks": {
"name": "Barracks", "icon": "⛺", "max": 5, "gold": 300, "name": "Barracks", "icon": "⛺", "max": 5, "gold": 300,
"base": {"logs": 600, "bricks": 400, "copper_sword": 20}, "advanced": {"iron_sword": 15}, "base": {"logs": 600, "bricks": 400, "copper_sword": 20}, "advanced": {"iron_sword": 15},
"late": {"steel_sword": 10}, "final": {"mithril_sword": 6}, "late": {"steel_sword": 10, "oak_plank": 50}, "final": {"mithril_sword": 6, "willow_plank": 50},
"effect": "Trains townsfolk as militia: more defence per citizen", "effect": "Trains townsfolk as militia: more defence per citizen",
}, },
"smithy": { "smithy": {
"name": "Smithy", "icon": "⚒️", "max": 5, "gold": 250, "name": "Smithy", "icon": "⚒️", "max": 5, "gold": 250,
"base": {"stone": 500, "logs": 600, "copper_bar": 150}, "advanced": {"iron_bar": 150}, "base": {"stone": 500, "logs": 600, "copper_bar": 150}, "advanced": {"iron_bar": 150},
"late": {"steel_bar": 80}, "final": {"mithril_bar": 60}, "late": {"steel_bar": 80, "oak_plank": 40}, "final": {"mithril_bar": 60, "maple_plank": 40},
"effect": "Smithing 8% faster per level; each level unlocks a metal: iron, steel, mithril, adamant, rune", "effect": "Smithing 8% faster per level; each level unlocks a metal: iron, steel, mithril, adamant, rune",
}, },
"temple": { "temple": {
"name": "Temple", "icon": "⛪", "max": 3, "gold": 300, "name": "Temple", "icon": "⛪", "max": 3, "gold": 300,
"base": {"stone": 800, "bricks": 400, "cut_stone": 100}, "advanced": {"beam": 20}, "base": {"stone": 800, "bricks": 400, "cut_stone": 100}, "advanced": {"beam": 20},
"late": {"willow_logs": 150}, "late": {"willow_logs": 150, "oak_plank": 60},
"effect": "Offerings ward the city in raids; +1 prayer point per bone buried and +25% devotion per level", "effect": "Offerings ward the city in raids; +1 prayer point per bone buried and +25% devotion per level",
}, },
"spymaster": { "spymaster": {
"name": "Spymaster's guild", "icon": "🕵️", "max": 3, "gold": 400, "name": "Spymaster's guild", "icon": "🕵️", "max": 3, "gold": 400,
"base": {"logs": 500, "bricks": 300, "hide": 100, "feathers": 300}, "advanced": {"cut_stone": 150}, "base": {"logs": 500, "bricks": 300, "hide": 100, "feathers": 300}, "advanced": {"cut_stone": 150},
"late": {"bear_leather": 40}, "late": {"bear_leather": 40, "oak_plank": 40},
"effect": "One more level of intel on every event per level", "effect": "One more level of intel on every event per level",
}, },
"storehouse": { "storehouse": {
"name": "Storehouse", "icon": "📦", "max": 5, "gold": 200, "name": "Storehouse", "icon": "📦", "max": 5, "gold": 200,
"base": {"logs": 800, "stone": 800}, "advanced": {"oak_logs": 300, "cut_stone": 200}, "base": {"logs": 800, "stone": 800}, "advanced": {"oak_logs": 300, "cut_stone": 200},
"late": {"willow_logs": 200}, "final": {"yew_logs": 80}, "late": {"willow_logs": 200, "oak_plank": 80}, "final": {"yew_logs": 80, "yew_plank": 40},
"effect": "+3 bank slots and +250 per stack for everyone, per level", "effect": "+3 bank slots and +250 per stack for everyone, per level",
}, },
} }
BUILD_HOURS = 3 # Buildings rise by labour: players working the site, and a slow trickle from
# the townsfolk so nothing stalls completely while everyone is away.
LABOUR_PER_LEVEL = 360 # a level-1 building: about three hours of one player's work
TOWNSFOLK_LABOUR = 0.5 # per person per game hour
REPAIR_SHARE = 0.3 REPAIR_SHARE = 0.3
# Population promotes the settlement, permanently. Each tier lifts how high # Population promotes the settlement, permanently. Each tier lifts how high
@ -463,7 +474,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows
# --- world --------------------------------------------------------------- # --- world ---------------------------------------------------------------
WORLD_VERSION = 6 WORLD_VERSION = 7
def new_world(now: float, speed: float = 1.0) -> dict: def new_world(now: float, speed: float = 1.0) -> dict:
@ -529,6 +540,19 @@ def migrate(world: dict, now: float | None = None) -> dict:
player["xp"].setdefault("herblore", 0) player["xp"].setdefault("herblore", 0)
player.setdefault("potions", {}) player.setdefault("potions", {})
world["version"] = 6 world["version"] = 6
if world["version"] < 7:
for player in world["players"].values():
player["xp"].setdefault("construction", 0)
player["equip"].setdefault("tool_construction", None)
work = world["city"]["construction"]
if work and "done_at" in work:
# The old timer becomes labour, credited for the time already spent.
level = world["city"]["buildings"][work["building"]]["level"]
needed = labour_needed(level, work["repair"])
share = min(1.0, max(0.0, (now - work["start"]) / max(1.0, work["done_at"] - work["start"])))
del work["done_at"]
work.update(labour=needed * share, needed=needed)
world["version"] = 7
return world return world
@ -910,6 +934,8 @@ def can_start(world: dict, player: dict, action: dict) -> str | None:
return "No bandit camp has been located" return "No bandit camp has been located"
if raid["sabotage"] >= 0.3: if raid["sabotage"] >= 0.3:
return "The camp is already in ruins" return "The camp is already in ruins"
if action.get("special") == "labour" and not world["city"]["construction"]:
return "Nothing is being built"
if action["skill"] == "combat" and player["hp"] < max_hp(player) * TOO_HURT and not edible(player): if action["skill"] == "combat" and player["hp"] < max_hp(player) * TOO_HURT and not edible(player):
return "Too hurt to fight. Rest, or bring food" return "Too hurt to fight. Rest, or bring food"
return None return None
@ -983,6 +1009,13 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
event = upcoming_event(world) event = upcoming_event(world)
if event: if event:
event["recon"] += action["recon"] event["recon"] += action["recon"]
if action.get("special") == "labour":
site = world["city"]["construction"]
site["labour"] += labour_per_action(player)
summary["labour"] += labour_per_action(player)
if site["labour"] >= site["needed"]:
_finish_construction(world, now)
_start_next(world, now)
bonus = 1 + brew.get("bones", 0) bonus = 1 + brew.get("bones", 0)
if action.get("special") == "bury": if action.get("special") == "bury":
player["pp"] += bone_points(world, action) * bonus player["pp"] += bone_points(world, action) * bonus
@ -1017,7 +1050,7 @@ def empty_summary() -> dict:
return {"done": 0, "items": {}, "overflow": {}, "xp": {}, "levels": [], "eaten": {}, "damage": 0, return {"done": 0, "items": {}, "overflow": {}, "xp": {}, "levels": [], "eaten": {}, "damage": 0,
"gold": 0, "wins": 0, "losses": 0, "lucky": 0, "stopped": None, "resumed": None, "gold": 0, "wins": 0, "losses": 0, "lucky": 0, "stopped": None, "resumed": None,
"sold": {}, "sold_gold": 0, "pp_spent": 0, "pp_gained": 0, "prayer_out": False, "second_wind": 0, "sold": {}, "sold_gold": 0, "pp_spent": 0, "pp_gained": 0, "prayer_out": False, "second_wind": 0,
"kept": 0, "potions_drunk": {}, "potion_out": []} "kept": 0, "potions_drunk": {}, "potion_out": [], "labour": 0}
def process_player(world: dict, player: dict, now: float, rng: random.Random | None = None) -> dict: def process_player(world: dict, player: dict, now: float, rng: random.Random | None = None) -> dict:
@ -1241,9 +1274,8 @@ def _start_next(world: dict, now: float) -> None:
else: else:
del building["fund"][item] del building["fund"][item]
repair = building["damaged"] repair = building["damaged"]
hours = BUILD_HOURS * (building["level"] + 1) * (0.25 if repair else 1) city["construction"] = {"building": key, "repair": repair, "start": now, "labour": 0.0,
city["construction"] = {"building": key, "repair": repair, "start": now, "needed": labour_needed(building["level"], repair)}
"done_at": now + hours * HOUR / speed(world)}
verb = "Repairs to" if repair else "Work on" verb = "Repairs to" if repair else "Work on"
log(world, now, "build", f"{verb} the {BUILDINGS[key]['name'].lower()} began") log(world, now, "build", f"{verb} the {BUILDINGS[key]['name'].lower()} began")
@ -1339,9 +1371,9 @@ def tick(world: dict, now: float, rng: random.Random | None = None) -> bool:
_city_hour(world, city["last_tick"]) _city_hour(world, city["last_tick"])
changed = True changed = True
construction = city["construction"] construction = city["construction"]
if construction and construction["done_at"] <= now: if construction and construction["labour"] >= construction["needed"]:
_finish_construction(world, construction["done_at"]) _finish_construction(world, now)
_start_next(world, construction["done_at"]) _start_next(world, now)
changed = True changed = True
while city["tier"] + 1 < len(TIERS) and city["population"] >= TIERS[city["tier"] + 1]["pop"]: while city["tier"] + 1 < len(TIERS) and city["population"] >= TIERS[city["tier"] + 1]["pop"]:
city["tier"] += 1 city["tier"] += 1
@ -1368,6 +1400,8 @@ def tick(world: dict, now: float, rng: random.Random | None = None) -> bool:
def _city_hour(world: dict, now: float) -> None: def _city_hour(world: dict, now: float) -> None:
city = world["city"] city = world["city"]
pop = city["population"] pop = city["population"]
if city["construction"]:
city["construction"]["labour"] += pop * TOWNSFOLK_LABOUR
city["food_due"] += pop * 0.5 / 24 city["food_due"] += pop * 0.5 / 24
stock = city["stockpile"] stock = city["stockpile"]
for item in FOOD_ORDER: for item in FOOD_ORDER:
@ -1392,6 +1426,17 @@ def _city_hour(world: dict, now: float) -> None:
city["treasury"] += pop * 0.5 / 24 city["treasury"] += pop * 0.5 / 24
def labour_needed(level: int, repair: bool) -> float:
"""Labour to raise a building from `level`, or to repair it at that level."""
if repair:
return LABOUR_PER_LEVEL * max(1, level) * 0.25
return LABOUR_PER_LEVEL * (level + 1)
def labour_per_action(player: dict) -> float:
return 1 + skill_level(player, "construction") // 20 # one more labour every 20 levels
def _finish_construction(world: dict, now: float) -> None: def _finish_construction(world: dict, now: float) -> None:
city = world["city"] city = world["city"]
work = city["construction"] work = city["construction"]
@ -1616,7 +1661,7 @@ def _use_up(stock: dict, item: str, share: float) -> None:
# --- favour -------------------------------------------------------------- # --- favour --------------------------------------------------------------
BUILDER_FAVOUR_PER_HOUR = 40 BUILDER_FAVOUR_PER_LABOUR = 0.67
INFORMANT_FAVOUR = 250 INFORMANT_FAVOUR = 250
RALLY_FAVOUR = 150 RALLY_FAVOUR = 150
RALLY_DEFENCE = 10 RALLY_DEFENCE = 10
@ -1626,14 +1671,14 @@ def favours(world: dict, now: float) -> dict:
"""What favour can buy for the city right now: cost, and why not if it can't.""" """What favour can buy for the city right now: cost, and why not if it can't."""
city = world["city"] city = world["city"]
work = city["construction"] work = city["construction"]
hours = (work["done_at"] - now) * speed(world) / HOUR if work else 0 remaining = max(0.0, work["needed"] - work["labour"]) if work else 0
event = upcoming_event(world) event = upcoming_event(world)
raid = upcoming_raid(world) raid = upcoming_raid(world)
return { return {
"builders": { "builders": {
"cost": max(25, math.ceil(hours * BUILDER_FAVOUR_PER_HOUR)), "cost": max(25, math.ceil(remaining / 2 * BUILDER_FAVOUR_PER_LABOUR)),
"why": None if work and hours > 0.05 else "Nothing is being built", "why": None if work and remaining > 1 else "Nothing is being built",
"saves": hours / 2, "labour": round(remaining / 2),
}, },
"informant": { "informant": {
"cost": INFORMANT_FAVOUR, "cost": INFORMANT_FAVOUR,
@ -1661,7 +1706,7 @@ def spend_favour(world: dict, player: dict, kind: str, now: float) -> str | None
city = world["city"] city = world["city"]
if kind == "builders": if kind == "builders":
work = city["construction"] work = city["construction"]
work["done_at"] = now + (work["done_at"] - now) / 2 work["labour"] += (work["needed"] - work["labour"]) / 2
name = BUILDINGS[work["building"]]["name"].lower() name = BUILDINGS[work["building"]]["name"].lower()
log(world, now, "favour", f"{player['name']} hired extra builders for the {name}", player["id"]) log(world, now, "favour", f"{player['name']} hired extra builders for the {name}", player["id"])
elif kind == "informant": elif kind == "informant":

View file

@ -323,6 +323,8 @@ function skillPage(id) {
</div></div>`; </div></div>`;
if (id === "combat") html += combatPanel(); if (id === "combat") html += combatPanel();
if (id === "prayer") html += prayerPanel(); if (id === "prayer") html += prayerPanel();
if (id === "construction") html += S.city.construction ? siteBanner()
: `<div class="panel muted">Nothing is being built right now. Fund a building on the Town square and its site opens here.</div>`;
html += potionPanel(id); html += potionPanel(id);
if (`tool_${id}` in me.equip) html += toolPanel(id); if (`tool_${id}` in me.equip) html += toolPanel(id);
if (id === "reconnaissance") html += `<div class="panel small muted">Every ${8} reconnaissance points on an upcoming event raises its intel by one level, on top of what the spymaster's guild provides. The council & threats page shows the progress.</div>`; if (id === "reconnaissance") html += `<div class="panel small muted">Every ${8} reconnaissance points on an upcoming event raises its intel by one level, on top of what the spymaster's guild provides. The council & threats page shows the progress.</div>`;
@ -467,6 +469,7 @@ function actionCard(a) {
} }
if (a.special === "recon") extra = `<div class="small muted">+${a.recon} recon on the next event</div>`; if (a.special === "recon") extra = `<div class="small muted">+${a.recon} recon on the next event</div>`;
const temple = S.city.buildings.temple?.level || 0; const temple = S.city.buildings.temple?.level || 0;
if (a.special === "labour") extra = `<div class="small makes">🏗️ +${me.labour_per_action} labour per action</div>`;
if (a.special === "bury") extra = `<div class="small makes">🙏 +${a.pp + temple} prayer points</div>`; if (a.special === "bury") extra = `<div class="small makes">🙏 +${a.pp + temple} prayer points</div>`;
if (a.special === "offer") extra = `<div class="small makes">⛪ +${+(a.devotion * (1 + 0.25 * temple)).toFixed(1)} devotion for the city's ward</div>`; if (a.special === "offer") extra = `<div class="small makes">⛪ +${+(a.devotion * (1 + 0.25 * temple)).toFixed(1)} devotion for the city's ward</div>`;
const made = Object.keys(a.outputs).map(itemStats).filter(Boolean); const made = Object.keys(a.outputs).map(itemStats).filter(Boolean);
@ -597,16 +600,7 @@ function town() {
</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; if (city.construction) html += siteBanner();
if (work) {
const spec = C.buildings[work.building];
const queued = city.queue.map((key) => esc(C.buildings[key].name)).join(", ");
const hire = S.favours.builders;
html += `<div class="banner">🏗️<div class="grow"><b>${work.repair ? "Repairing" : "Building"} ${esc(spec.name)}</b> · done in ${countdown(work.done_at)}
${hire.why ? "" : `<button class="btn sm gold" data-favour="builders" ${S.me.favour < hire.cost ? "disabled" : ""} title="Halve the time left">Hire builders ✨ ${fmt(hire.cost)}</button>`}
<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> 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> <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>`; <div class="grid wide">${Object.entries(city.buildings).map(([key, b]) => buildingCard(key, b)).join("")}</div>`;
@ -617,6 +611,26 @@ function town() {
return html; return html;
} }
// The building site: labour so far, who is helping, and ways to speed it up.
function siteBanner() {
const city = S.city, work = city.construction, me = S.me;
const spec = C.buildings[work.building];
const queued = city.queue.map((key) => esc(C.buildings[key].name)).join(", ");
const hire = S.favours.builders;
const working = me.action?.id === "labour";
const builders = S.players.filter((p) => p.skill === "construction" && p.doing === "Work on the building site").map((p) => esc(p.name));
return `<div class="banner">🏗️<div class="grow">
<div class="site-head"><b>${work.repair ? "Repairing" : "Building"} ${esc(spec.name)}</b>
<span class="muted small">${fmt(work.labour)} / ${fmt(work.needed)} labour</span>
<span class="btns">
${working ? `<span class="tag good">You're working on it</span>` : `<button class="btn sm primary" data-start="labour">Work on it</button>`}
${hire.why ? "" : `<button class="btn sm gold" data-favour="builders" ${me.favour < hire.cost ? "disabled" : ""} title="Adds half the labour that's left">Hire builders ✨ ${fmt(hire.cost)}</button>`}
</span></div>
<div class="bar gold" style="margin-top:6px"><span style="width:${Math.min(100, work.labour / work.needed * 100)}%"></span></div>
<div class="small muted" style="margin-top:6px">${builders.length ? `On site: ${builders.join(", ")}. ` : ""}The townsfolk add about ${fmt(city.townsfolk_labour)} labour an hour.${queued ? ` Next: ${queued}.` : ""}</div>
</div></div>`;
}
function buildingCard(key, b) { function buildingCard(key, b) {
const spec = C.buildings[key], city = S.city, me = S.me; const spec = C.buildings[key], city = S.city, me = S.me;
const tags = { const tags = {
@ -705,7 +719,7 @@ function favourSpends() {
</div>`; </div>`;
}; };
return `<div class="panel"><h2>Spend favour on the city · you have ✨ ${fmt(me.favour)}</h2> return `<div class="panel"><h2>Spend favour on the city · you have ✨ ${fmt(me.favour)}</h2>
${option("builders", "🏗️", "Hire extra builders", f.builders.why ? esc(f.builders.why) + "." : `Halves the time left on the current building (saves about ${dur(Math.round(f.builders.saves * 60) * 60)}).`)} ${option("builders", "🏗️", "Hire extra builders", f.builders.why ? esc(f.builders.why) + "." : `Adds ${fmt(f.builders.labour)} labour to the current building, half of what's left.`)}
${option("informant", "🕵️", "Pay an informant", f.informant.why ? esc(f.informant.why) + "." : "One more level of intel on what's coming.")} ${option("informant", "🕵️", "Pay an informant", f.informant.why ? esc(f.informant.why) + "." : "One more level of intel on what's coming.")}
${option("militia", "📯", "Rally the militia", f.militia.why ? esc(f.militia.why) + "." : `+10 defence for the coming raid${f.militia.rallied ? ` (+${f.militia.rallied} so far)` : ""}. Costs more each time.`)} ${option("militia", "📯", "Rally the militia", f.militia.why ? esc(f.militia.why) + "." : `+10 defence for the coming raid${f.militia.rallied ? ` (+${f.militia.rallied} so far)` : ""}. Costs more each time.`)}
</div>`; </div>`;

View file

@ -386,3 +386,5 @@ button.sheet-skill:hover { background: var(--panel-2); }
.prayer.on { border-color: var(--favour); box-shadow: 0 0 0 1px var(--favour) inset; background: rgba(185, 140, 232, .12); } .prayer.on { border-color: var(--favour); box-shadow: 0 0 0 1px var(--favour) inset; background: rgba(185, 140, 232, .12); }
.prayer.locked { opacity: .45; cursor: not-allowed; } .prayer.locked { opacity: .45; cursor: not-allowed; }
.prayer-points { font-size: 15px; } .prayer-points { font-size: 15px; }
.site-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.site-head .btns { margin-left: auto; }

View file

@ -189,7 +189,9 @@ class CityTest(unittest.TestCase):
self.assertIsNone(game.fund(world, p, "walls", "gold", "all", "city", T0)) self.assertIsNone(game.fund(world, p, "walls", "gold", "all", "city", T0))
self.assertEqual(city["construction"]["building"], "walls") self.assertEqual(city["construction"]["building"], "walls")
self.assertEqual(city["buildings"]["walls"]["fund"], {}) self.assertEqual(city["buildings"]["walls"]["fund"], {})
game.tick(world, T0 + 3 * game.HOUR, random.Random(1)) self.assertEqual(city["construction"]["needed"], game.LABOUR_PER_LEVEL)
city["construction"]["labour"] = game.LABOUR_PER_LEVEL
game.tick(world, T0 + 1, random.Random(1))
self.assertEqual(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)
@ -203,7 +205,8 @@ class CityTest(unittest.TestCase):
self.assertIsNone(game.fund(world, p, key, "gold", "all", "city", T0)) self.assertIsNone(game.fund(world, p, key, "gold", "all", "city", T0))
self.assertEqual(city["construction"]["building"], "walls") self.assertEqual(city["construction"]["building"], "walls")
self.assertEqual(city["queue"], ["houses"]) self.assertEqual(city["queue"], ["houses"])
game.tick(world, T0 + 3 * game.HOUR, random.Random(1)) city["construction"]["labour"] = city["construction"]["needed"]
game.tick(world, T0 + 1, random.Random(1))
self.assertEqual(city["construction"]["building"], "houses") self.assertEqual(city["construction"]["building"], "houses")
self.assertEqual(city["queue"], []) self.assertEqual(city["queue"], [])
@ -505,15 +508,15 @@ class FavourTest(unittest.TestCase):
self.p = self.world["players"]["fred"] self.p = self.world["players"]["fred"]
self.p["favour"] = 5000 self.p["favour"] = 5000
def test_builders_halve_the_time_left(self): def test_builders_do_half_the_work_left(self):
world, p = self.world, self.p world, p = self.world, self.p
self.assertEqual(game.spend_favour(world, p, "builders", T0), "Nothing is being built") self.assertEqual(game.spend_favour(world, p, "builders", T0), "Nothing is being built")
world["city"]["construction"] = {"building": "walls", "repair": False, "start": T0, "done_at": T0 + 4 * 3600} world["city"]["construction"] = {"building": "walls", "repair": False, "start": T0, "labour": 60.0, "needed": 360}
cost = game.favours(world, T0)["builders"]["cost"] cost = game.favours(world, T0)["builders"]["cost"]
self.assertEqual(cost, 160) self.assertEqual(cost, 101) # 150 labour at 0.67 favour each
self.assertIsNone(game.spend_favour(world, p, "builders", T0)) self.assertIsNone(game.spend_favour(world, p, "builders", T0))
self.assertEqual(world["city"]["construction"]["done_at"], T0 + 2 * 3600) self.assertEqual(world["city"]["construction"]["labour"], 210)
self.assertEqual(p["favour"], 5000 - 160) self.assertEqual(p["favour"], 5000 - 101)
def test_informant_raises_intel(self): def test_informant_raises_intel(self):
world, p = self.world, self.p world, p = self.world, self.p
@ -543,8 +546,8 @@ class FavourTest(unittest.TestCase):
def test_not_enough_favour(self): def test_not_enough_favour(self):
self.p["favour"] = 10 self.p["favour"] = 10
self.world["city"]["construction"] = {"building": "walls", "repair": False, "start": T0, "done_at": T0 + 3600} self.world["city"]["construction"] = {"building": "walls", "repair": False, "start": T0, "labour": 0.0, "needed": 360}
self.assertEqual(game.spend_favour(self.world, self.p, "builders", T0), "Needs 40 favour") self.assertEqual(game.spend_favour(self.world, self.p, "builders", T0), "Needs 121 favour")
@ -761,5 +764,69 @@ class HerbloreTest(unittest.TestCase):
self.assertEqual((p["potions"], p["xp"]["herblore"]), ({}, 0)) self.assertEqual((p["potions"], p["xp"]["herblore"]), ({}, 0))
class ConstructionTest(unittest.TestCase):
def site(self):
world = world_with(("bob", "pioneer"), ("ann", "warrior"))
world["city"]["construction"] = {"building": "walls", "repair": False, "start": T0, "labour": 0.0, "needed": 360}
world["next_event_at"] = None
return world
def test_players_build_with_their_hands(self):
world = self.site()
p = world["players"]["ann"]
game.start_action(world, p, "labour", T0)
summary = game.process_player(world, p, T0 + 30 * 100, random.Random(1))
self.assertEqual(summary["labour"], 100)
self.assertEqual(world["city"]["construction"]["labour"], 100)
self.assertGreater(p["xp"]["construction"], 0)
def test_finishing_the_site_raises_the_building_and_stops_the_work(self):
world = self.site()
world["city"]["construction"]["labour"] = 355
p = world["players"]["ann"]
game.start_action(world, p, "labour", T0)
summary = game.process_player(world, p, T0 + 30 * 10, random.Random(1))
self.assertEqual(world["city"]["buildings"]["walls"]["level"], 1)
self.assertIsNone(world["city"]["construction"])
self.assertEqual(summary["stopped"], "Nothing is being built")
def test_townsfolk_trickle_and_levels_help(self):
world = self.site()
game.tick(world, T0 + 10 * game.HOUR, random.Random(1))
self.assertAlmostEqual(world["city"]["construction"]["labour"], 10 * 20 * game.TOWNSFOLK_LABOUR, delta=5)
p = world["players"]["bob"]
self.assertEqual(game.labour_per_action(p), 1)
p["xp"]["construction"] = game.XP_TABLE[40]
self.assertEqual(game.labour_per_action(p), 3)
plain = game.action_time(world, p, game.ACTION_BY_ID["labour"])
p["equip"]["tool_construction"] = "iron_hammer"
self.assertAlmostEqual(game.action_time(world, p, game.ACTION_BY_ID["labour"]), plain * 0.7)
def test_no_site_no_work(self):
world = world_with(("ann", "warrior"))
self.assertEqual(game.start_action(world, world["players"]["ann"], "labour", T0), "Nothing is being built")
def test_planks_and_hammers_exist_for_every_tier(self):
for plank, _name in game.PLANKS:
self.assertIn(f"saw_{plank}", game.ACTION_BY_ID)
for key, *_ in game.METALS:
self.assertIn(f"smith_{key}_hammer", game.ACTION_BY_ID)
self.assertIn("oak_plank", game.build_cost("houses", 3))
def test_v6_timer_becomes_labour(self):
world = world_with(("fred", "pioneer"))
world["version"] = 6
p = world["players"]["fred"]
del p["xp"]["construction"], p["equip"]["tool_construction"]
world["city"]["construction"] = {"building": "houses", "repair": False, "start": T0, "done_at": T0 + 3 * 3600}
game.migrate(world, T0 + 3600)
work = world["city"]["construction"]
self.assertEqual(work["needed"], game.LABOUR_PER_LEVEL)
self.assertAlmostEqual(work["labour"], game.LABOUR_PER_LEVEL / 3)
self.assertNotIn("done_at", work)
self.assertIsNone(p["equip"]["tool_construction"])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()