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.
- **City:** each building collects its own fund, from players' banks or the
city's stockpile and treasury. Funds are safe from raiders, and a funded
building joins the construction queue by itself. Population eats food,
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
(hamlet, village at 40, town at 90, city at 150), which raises building
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)
for a in game.ACTIONS if a["skill"] == "combat"}
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["slots"] = game.bank_slots(world, player)
view["stack"] = game.stack_size(world)
@ -139,6 +140,7 @@ def city_view(world: dict, now: float) -> dict:
"buildings": buildings,
"queue": city["queue"],
"devotion": round(city.get("devotion", 0), 1),
"townsfolk_labour": round(city["population"] * game.TOWNSFOLK_LABOUR, 1),
"ward": game.ward(world),
"treasury": int(city["treasury"]),
"stockpile": city["stockpile"],

97
game.py
View file

@ -98,7 +98,7 @@ PIECE_ICONS = {"sword": "⚔️", "shield": "🛡️", "helmet": "🪖", "legs":
COPPER_ICONS = {"sword": "🗡️", "helmet": "⛑️", "legs": "🦵", "body": "🥋"}
# Gathering tools: one slot per skill, `speed` cuts the action time.
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]
# 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,
"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[_plank] = {"name": _plank_name, "icon": "🪚", "value": _value * 2 + 1}
for _tier, (_raw, _leather, _prefix, _label, _level, _armour) in enumerate(LEATHERS):
ITEMS[_raw[0]] = {"name": _raw[1], "icon": _raw[2], "value": _raw[3]}
ITEMS[_leather[0]] = {"name": _leather[1], "icon": "🪢", "value": _leather[2]}
@ -247,6 +250,7 @@ SKILLS = [
{"id": "fishing", "name": "Fishing", "icon": "🎣", "group": "Gathering"},
{"id": "smithing", "name": "Smithing", "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": "leatherworking", "name": "Leatherworking", "icon": "🪡", "group": "Crafting"},
{"id": "herblore", "name": "Herblore", "icon": "🧪", "group": "Crafting"},
@ -276,8 +280,8 @@ CLASSES = {
"signature": "Masterwork: 1 in 10 crafts makes double",
},
"pioneer": {
"name": "Pioneer", "icon": "🧭", "fast": ["mining", "woodcutting", "farming"],
"bonus": "Mining, woodcutting and farming 20% faster",
"name": "Pioneer", "icon": "🧭", "fast": ["mining", "woodcutting", "farming", "construction"],
"bonus": "Mining, woodcutting, farming and construction 20% faster",
"signature": "Surveying: 1 in 10 gathers yields double",
},
}
@ -355,6 +359,10 @@ ACTIONS = [
{"copper_bar": 1, "logs": 1, "feathers": 2}, {"arrows": 15}, group="Bars"),
_action("lw_studded", "leatherworking", "Studded jerkin", 25, 50, 40,
{"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_cut_stone", "masonry", "Cut stone", 10, 30, 12, {"stone": 3}, {"cut_stone": 1}),
_action("mason_beam", "masonry", "Reinforced beam", 20, 45, 30,
@ -399,47 +407,50 @@ BUILDINGS = {
"houses": {
"name": "Houses", "icon": "🏠", "max": 8, "gold": 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",
},
"walls": {
"name": "Walls", "icon": "🏰", "max": 5, "gold": 300,
"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",
},
"barracks": {
"name": "Barracks", "icon": "⛺", "max": 5, "gold": 300,
"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",
},
"smithy": {
"name": "Smithy", "icon": "⚒️", "max": 5, "gold": 250,
"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",
},
"temple": {
"name": "Temple", "icon": "⛪", "max": 3, "gold": 300,
"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",
},
"spymaster": {
"name": "Spymaster's guild", "icon": "🕵️", "max": 3, "gold": 400,
"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",
},
"storehouse": {
"name": "Storehouse", "icon": "📦", "max": 5, "gold": 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",
},
}
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
# 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_VERSION = 6
WORLD_VERSION = 7
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.setdefault("potions", {})
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
@ -910,6 +934,8 @@ def can_start(world: dict, player: dict, action: dict) -> str | None:
return "No bandit camp has been located"
if raid["sabotage"] >= 0.3:
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):
return "Too hurt to fight. Rest, or bring food"
return None
@ -983,6 +1009,13 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
event = upcoming_event(world)
if event:
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)
if action.get("special") == "bury":
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,
"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,
"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:
@ -1241,9 +1274,8 @@ def _start_next(world: dict, now: float) -> None:
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)}
city["construction"] = {"building": key, "repair": repair, "start": now, "labour": 0.0,
"needed": labour_needed(building["level"], repair)}
verb = "Repairs to" if repair else "Work on"
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"])
changed = True
construction = city["construction"]
if construction and construction["done_at"] <= now:
_finish_construction(world, construction["done_at"])
_start_next(world, construction["done_at"])
if construction and construction["labour"] >= construction["needed"]:
_finish_construction(world, now)
_start_next(world, now)
changed = True
while city["tier"] + 1 < len(TIERS) and city["population"] >= TIERS[city["tier"] + 1]["pop"]:
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:
city = world["city"]
pop = city["population"]
if city["construction"]:
city["construction"]["labour"] += pop * TOWNSFOLK_LABOUR
city["food_due"] += pop * 0.5 / 24
stock = city["stockpile"]
for item in FOOD_ORDER:
@ -1392,6 +1426,17 @@ def _city_hour(world: dict, now: float) -> None:
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:
city = world["city"]
work = city["construction"]
@ -1616,7 +1661,7 @@ def _use_up(stock: dict, item: str, share: float) -> None:
# --- favour --------------------------------------------------------------
BUILDER_FAVOUR_PER_HOUR = 40
BUILDER_FAVOUR_PER_LABOUR = 0.67
INFORMANT_FAVOUR = 250
RALLY_FAVOUR = 150
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."""
city = world["city"]
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)
raid = upcoming_raid(world)
return {
"builders": {
"cost": max(25, math.ceil(hours * BUILDER_FAVOUR_PER_HOUR)),
"why": None if work and hours > 0.05 else "Nothing is being built",
"saves": hours / 2,
"cost": max(25, math.ceil(remaining / 2 * BUILDER_FAVOUR_PER_LABOUR)),
"why": None if work and remaining > 1 else "Nothing is being built",
"labour": round(remaining / 2),
},
"informant": {
"cost": INFORMANT_FAVOUR,
@ -1661,7 +1706,7 @@ def spend_favour(world: dict, player: dict, kind: str, now: float) -> str | None
city = world["city"]
if kind == "builders":
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()
log(world, now, "favour", f"{player['name']} hired extra builders for the {name}", player["id"])
elif kind == "informant":

View file

@ -323,6 +323,8 @@ function skillPage(id) {
</div></div>`;
if (id === "combat") html += combatPanel();
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);
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>`;
@ -467,6 +469,7 @@ function actionCard(a) {
}
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;
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 === "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);
@ -597,16 +600,7 @@ function town() {
</div>`;
if (city.starving) html += `<div class="banner bad">🍞<div class="grow"><b>The granary is empty.</b> People are leaving and the city defends worse. Donate bread, cooked meat or stew.</div></div>`;
if (city.merchant_until > serverNow()) html += `<div class="banner">🐪<div class="grow"><b>A merchant caravan is in town.</b> Everything sells for 50% more for another ${countdown(city.merchant_until)}.</div></div>`;
const work = city.construction;
if (work) {
const spec = C.buildings[work.building];
const queued = city.queue.map((key) => esc(C.buildings[key].name)).join(", ");
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>`;
}
if (city.construction) html += siteBanner();
html += `<div class="section-title">Buildings</div>
<p class="small muted">Each building collects its own fund. Materials in a fund are safe from raiders, and building starts on its own once the fund is complete.</p>
<div class="grid wide">${Object.entries(city.buildings).map(([key, b]) => buildingCard(key, b)).join("")}</div>`;
@ -617,6 +611,26 @@ function town() {
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) {
const spec = C.buildings[key], city = S.city, me = S.me;
const tags = {
@ -705,7 +719,7 @@ function favourSpends() {
</div>`;
};
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("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>`;

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.locked { opacity: .45; cursor: not-allowed; }
.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.assertEqual(city["construction"]["building"], "walls")
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(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.assertEqual(city["construction"]["building"], "walls")
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["queue"], [])
@ -505,15 +508,15 @@ class FavourTest(unittest.TestCase):
self.p = self.world["players"]["fred"]
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
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"]
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.assertEqual(world["city"]["construction"]["done_at"], T0 + 2 * 3600)
self.assertEqual(p["favour"], 5000 - 160)
self.assertEqual(world["city"]["construction"]["labour"], 210)
self.assertEqual(p["favour"], 5000 - 101)
def test_informant_raises_intel(self):
world, p = self.world, self.p
@ -543,8 +546,8 @@ class FavourTest(unittest.TestCase):
def test_not_enough_favour(self):
self.p["favour"] = 10
self.world["city"]["construction"] = {"building": "walls", "repair": False, "start": T0, "done_at": T0 + 3600}
self.assertEqual(game.spend_favour(self.world, self.p, "builders", T0), "Needs 40 favour")
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 121 favour")
@ -761,5 +764,69 @@ class HerbloreTest(unittest.TestCase):
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__":
unittest.main()