diff --git a/README.md b/README.md index 5a9a398..4905550 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app.py b/app.py index 36eb88f..0cc5800 100644 --- a/app.py +++ b/app.py @@ -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"], diff --git a/game.py b/game.py index 5103da5..adfbc64 100644 --- a/game.py +++ b/game.py @@ -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": diff --git a/static/app.js b/static/app.js index 8e2599b..45e4a6d 100644 --- a/static/app.js +++ b/static/app.js @@ -323,6 +323,8 @@ function skillPage(id) { `; if (id === "combat") html += combatPanel(); if (id === "prayer") html += prayerPanel(); + if (id === "construction") html += S.city.construction ? siteBanner() + : `
Nothing is being built right now. Fund a building on the Town square and its site opens here.
`; html += potionPanel(id); if (`tool_${id}` in me.equip) html += toolPanel(id); if (id === "reconnaissance") html += `
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.
`; @@ -467,6 +469,7 @@ function actionCard(a) { } if (a.special === "recon") extra = `
+${a.recon} recon on the next event
`; const temple = S.city.buildings.temple?.level || 0; + if (a.special === "labour") extra = `
๐Ÿ—๏ธ +${me.labour_per_action} labour per action
`; if (a.special === "bury") extra = `
๐Ÿ™ +${a.pp + temple} prayer points
`; if (a.special === "offer") extra = `
โ›ช +${+(a.devotion * (1 + 0.25 * temple)).toFixed(1)} devotion for the city's ward
`; const made = Object.keys(a.outputs).map(itemStats).filter(Boolean); @@ -597,16 +600,7 @@ function town() { `; if (city.starving) html += ``; if (city.merchant_until > serverNow()) html += ``; - 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 += ``; - } + if (city.construction) html += siteBanner(); html += `
Buildings

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.

${Object.entries(city.buildings).map(([key, b]) => buildingCard(key, b)).join("")}
`; @@ -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 ``; +} + function buildingCard(key, b) { const spec = C.buildings[key], city = S.city, me = S.me; const tags = { @@ -705,7 +719,7 @@ function favourSpends() { `; }; return `

Spend favour on the city ยท you have โœจ ${fmt(me.favour)}

- ${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.`)}
`; diff --git a/static/styles.css b/static/styles.css index af4e78c..f42ba65 100644 --- a/static/styles.css +++ b/static/styles.css @@ -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; } diff --git a/tests/test_game.py b/tests/test_game.py index 1804097..4ac10df 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -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()