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() + : `
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.