diff --git a/README.md b/README.md index a3eafa6..cb7f6c0 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,9 @@ Alpha. One season, bandit raids plus two friendly events, six buildings. 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, + Construction also saws planks, which higher building levels need, and + builds fittings from them: one-off upgrades a building takes once it's high + enough (shelving, a well pump, weapon racks, enchanted chests…). 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 323e83d..8631744 100644 --- a/app.py +++ b/app.py @@ -325,6 +325,8 @@ class Handler(BaseHTTPRequestHandler): return game.fund(world, player, str(body.get("building")), str(body.get("item")), qty if qty == "all" else (qty if isinstance(qty, int) else -1), str(body.get("source")), now) + if path == "/api/install": + return game.install(world, player, str(body.get("fitting")), str(body.get("source")), now) if path == "/api/garrison": raid = game.upcoming_raid(world) if body.get("join") and not raid: diff --git a/game.py b/game.py index f095051..41ec031 100644 --- a/game.py +++ b/game.py @@ -496,7 +496,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows # --- world --------------------------------------------------------------- -WORLD_VERSION = 9 +WORLD_VERSION = 10 def new_world(now: float, speed: float = 1.0) -> dict: @@ -509,7 +509,7 @@ def new_world(now: float, speed: float = 1.0) -> dict: "population": 20.0, "treasury": 0.0, "stockpile": {"bread": 40}, - "buildings": {key: {"level": 0, "damaged": False, "fund": {}} for key in BUILDINGS}, + "buildings": {key: {"level": 0, "damaged": False, "fund": {}, "fittings": []} for key in BUILDINGS}, "construction": None, "queue": [], "tier": 0, @@ -599,6 +599,10 @@ def migrate(world: dict, now: float | None = None) -> dict: message["id"] = number world["next_message_id"] = len(world["board"]) + 1 world["version"] = 9 + if world["version"] < 10: + for building in world["city"]["buildings"].values(): + building.setdefault("fittings", []) + world["version"] = 10 return world @@ -644,6 +648,12 @@ def building_level(world: dict, key: str) -> int: return 0 if building["damaged"] else building["level"] +def fitted(world: dict, fitting: str) -> bool: + """Whether a fitting is installed and working (its building isn't damaged).""" + key = FITTING_BY_ID[fitting]["building"] + return fitting in world["city"]["buildings"][key]["fittings"] and building_level(world, key) > 0 + + def build_cost(key: str, level: int) -> dict: spec = BUILDINGS[key] cost = {item: qty * level for item, qty in spec["base"].items()} @@ -711,11 +721,11 @@ def skill_level(player: dict, skill: str) -> int: def bank_slots(world: dict, player: dict) -> int: return (BASE_SLOTS + 3 * building_level(world, "storehouse") + TIER_SLOTS * world["city"]["tier"] - + player["perks"]["bank_slot"]) + + player["perks"]["bank_slot"] + fitted(world, "shelving")) def stack_size(world: dict) -> int: - return BASE_STACK + 250 * building_level(world, "storehouse") + return BASE_STACK + 250 * building_level(world, "storehouse") + 500 * fitted(world, "enchanted_chests") def slots_used(world: dict, player: dict) -> int: @@ -744,6 +754,8 @@ def city_uses(item: str) -> list[str]: uses.append("the armoury") if item == "water_barrel": uses.append("the fire brigade") + if "fitting" in spec: + uses.append(BUILDINGS[spec["fitting"]]["name"]) return uses @@ -802,6 +814,7 @@ def regen(world: dict, player: dict, now: float, boost: float = 1) -> None: """Heal for the time since HP was last settled.""" if now > player["hp_at"]: top = max_hp(player) + boost *= 2 if fitted(world, "enchanted_hearths") else 1 per_second = top * REGEN_PER_HOUR * boost * speed(world) / HOUR player["hp"] = min(top, player["hp"] + (now - player["hp_at"]) * per_second) player["hp_at"] = now @@ -876,12 +889,13 @@ def set_prayers(player: dict, ids: list) -> str | None: def bone_points(world: dict, action: dict) -> int: - return action["pp"] + building_level(world, "temple") + return action["pp"] + building_level(world, "temple") + fitted(world, "carved_altar") def ward(world: dict) -> float: """Defence the temple's devotion adds to the next raid.""" - return round(min(world["city"].get("devotion", 0) / 4, 15 * building_level(world, "temple")), 1) + cap = 15 * building_level(world, "temple") + 15 * fitted(world, "carved_altar") + return round(min(world["city"].get("devotion", 0) / 4, cap), 1) def edible(player: dict) -> list[str]: @@ -913,6 +927,9 @@ def action_time(world: dict, player: dict, action: dict) -> float: seconds *= 1 - ITEMS[tool]["speed"] if action["skill"] == "smithing": seconds *= 1 - 0.08 * building_level(world, "smithy") + seconds *= 0.9 if fitted(world, "bellows") else 1 + if action["id"].startswith("saw_") and fitted(world, "saw_benches"): + seconds *= 0.8 seconds *= 1 - potion_effect(player, action["skill"]).get("speed", 0) seconds *= skill_mods(world, action)["speed"] return seconds / speed(world) @@ -1080,7 +1097,7 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R if action.get("special") == "recon": event = upcoming_event(world) if event: - event["recon"] += action["recon"] + event["recon"] += action["recon"] + fitted(world, "map_table") if action.get("special") == "problem" and action["skill"] != "combat": _work_problem(world, player, action, now) if action.get("special") == "labour": @@ -1319,6 +1336,36 @@ def fund(world: dict, player: dict, key: str, item: str, qty: int | str, source: return None +def install(world: dict, player: dict, fitting: str, source: str, now: float) -> str | None: + """Fit a building with a fitting from the player's bank (earns favour) or the stockpile.""" + spec = FITTING_BY_ID.get(fitting) + if not spec: + return "No such fitting" + city = world["city"] + building = city["buildings"][spec["building"]] + name = BUILDINGS[spec["building"]]["name"].lower() + if fitting in building["fittings"]: + return f"The {name} already {'have' if name.endswith('s') else 'has'} that" + if building["level"] < spec["level"]: + return f"Needs {BUILDINGS[spec['building']]['name']} level {spec['level']}" + if source == "bank": + if not player["bank"].get(fitting): + return "You don't have one" + bank_take(player, fitting, 1) + gain = ITEMS[fitting]["value"] + player["favour"] += gain * city_rate(world, "favour") + player["donated"] += gain + elif source == "city": + if not city["stockpile"].get(fitting): + return "There isn't one in the stockpile" + take_stock(city, fitting, 1) + else: + return "Bad source" + building["fittings"].append(fitting) + log(world, now, "build", f"{player['name']} fitted the {name} with {spec['name'].lower()}", player["id"]) + return None + + def take_stock(city: dict, item: str, qty: int) -> None: left = city["stockpile"][item] - qty if left > 0: @@ -1418,8 +1465,8 @@ def defence(world: dict) -> dict: armed = int(pop // 2) parts = { "townsfolk": 10, - "walls": 20 * building_level(world, "walls"), - "militia": round(0.3 * building_level(world, "barracks") * pop, 1), + "walls": 20 * building_level(world, "walls") + 25 * fitted(world, "hoardings"), + "militia": round(0.3 * building_level(world, "barracks") * pop * (1.5 if fitted(world, "weapon_racks") else 1), 1), "weapons": round(0.35 * best_kit(stock, "weapon", "power", armed), 1), "shields": round(0.5 * best_kit(stock, "shield", "armour", armed), 1), "arrows": min(100, stock.get("arrows", 0) // 10), @@ -1486,7 +1533,8 @@ def _city_hour(world: dict, now: float) -> None: pop = city["population"] if city["construction"]: city["construction"]["labour"] += pop * TOWNSFOLK_LABOUR - city["food_due"] += pop * 0.5 / 24 * city_rate(world, "food") * (1.5 if city.get("cold") else 1) + city["food_due"] += (pop * 0.5 / 24 * city_rate(world, "food") * (1.5 if city.get("cold") else 1) + * (0.9 if fitted(world, "grain_bins") else 1)) stock = city["stockpile"] for item in FOOD_ORDER: while city["food_due"] >= ITEMS[item]["food"] and stock.get(item, 0) > 0: @@ -1504,7 +1552,8 @@ def _city_hour(world: dict, now: float) -> None: else: room = housing(world) if pop < room: - city["population"] = min(room, pop + max(0.5, pop * GROWTH_PER_DAY) * city_rate(world, "growth") / 24) + growth = max(0.5, pop * GROWTH_PER_DAY) * city_rate(world, "growth") * (1.25 if fitted(world, "furniture") else 1) + city["population"] = min(room, pop + growth / 24) elif pop > room: city["population"] = max(room, pop - pop * 0.05 / 24) city["treasury"] += pop * 0.5 / 24 @@ -1894,6 +1943,47 @@ _barrel = _action("cooper_barrel", "construction", "Water barrel", 10, 35, 18, ACTIONS.append(_barrel) ACTION_BY_ID[_barrel["id"]] = _barrel +# Fittings: Construction makes them, mostly from the better planks, and each +# building takes one of each once it's high enough. A fitting works while its +# building stands; damage pauses it like the rest of the building. +# id, name, building, building level, construction level, inputs, effect, glyph, tint +FITTINGS = [ + ("grain_bins", "Grain bins", "granary", 1, 15, {"oak_plank": 20, "iron_bar": 5}, + "The city eats 10% less food", "grain-bundle", "#e3c060"), + ("saw_benches", "Saw benches", "lumber_shed", 1, 15, {"oak_plank": 20, "iron_bar": 10}, + "Sawing planks is 20% faster", "hand-saw", "#8f5e30"), + ("shelving", "Shelving", "storehouse", 2, 20, {"oak_plank": 40, "iron_bar": 10}, + "+1 bank slot for everyone", "bookshelf", "#8f5e30"), + ("map_table", "Map table", "spymaster", 1, 30, {"willow_plank": 25, "hard_leather": 10}, + "Scouting gathers one more point of reconnaissance", "treasure-map", "#a3b86c"), + ("well_pump", "Well pump", "well", 2, 30, {"willow_plank": 25, "steel_bar": 10}, + "Half of all fires are put out before they spread", "water-tank", "#5aa0d8"), + ("furniture", "Furniture", "houses", 2, 35, {"willow_plank": 40, "leather": 30}, + "Population grows 25% faster", "sofa", "#a3b86c"), + ("weapon_racks", "Weapon racks", "barracks", 2, 40, {"willow_plank": 30, "steel_bar": 20}, + "Militia defence +50%", "spears", "#d4dae2"), + ("hoardings", "Hoardings", "walls", 3, 45, {"maple_plank": 40, "mithril_bar": 10}, + "+25 defence", "palisade", "#d0663a"), + ("bellows", "Great bellows", "smithy", 3, 50, {"maple_plank": 30, "bear_leather": 15}, + "Smithing another 10% faster", "bellows", "#d0663a"), + ("carved_altar", "Carved altar", "temple", 2, 60, {"yew_plank": 30, "cut_stone": 60}, + "+1 prayer point per bone buried, and offerings can ward 15 more", "star-altar", "#6f9a4a"), + ("enchanted_chests", "Enchanted chests", "storehouse", 4, 75, {"magic_plank": 40, "rune_bar": 5}, + "+500 per bank stack for everyone", "open-treasure-chest", "#a77be8"), + ("enchanted_hearths", "Enchanted hearths", "houses", 4, 80, {"magic_plank": 50, "red_dragon_leather": 5}, + "Everyone recovers HP twice as fast", "fireplace", "#a77be8"), +] +FITTING_BY_ID = {} +for _id, _name, _building, _blevel, _level, _inputs, _effect, _glyph, _tint in FITTINGS: + FITTING_BY_ID[_id] = {"id": _id, "name": _name, "building": _building, "level": _blevel, "effect": _effect} + ITEMS[_id] = {"name": _name, "plural": _name, "icon": "🪚", "value": sum(ITEMS[i]["value"] * q for i, q in _inputs.items()), + "fitting": _building} + _fitting = _action(f"make_{_id}", "construction", _name, _level, 120 + _level * 2, 40 + _level * 4, + _inputs, {_id: 1}, group="Fittings") + ACTIONS.append(_fitting) + ACTION_BY_ID[_fitting["id"]] = _fitting + ITEMS[_id].update(glyph=_glyph, tint=_tint) + # (weights by season, spring to winter) HARDSHIPS = {"sickness": [2, 1, 2, 3], "locusts": [1, 3, 2, 0], "rot": [3, 1, 3, 1], "storm": [1, 1, 3, 1], "rats": [2, 2, 2, 2], "fire": [1, 2, 1, 2], "winter": [0, 0, 0, 4], "drought": [0, 4, 1, 0]} @@ -2061,7 +2151,10 @@ def _hardship(world: dict, now: float, rng: random.Random, kind: str | None = No ["They'll eat into the food every hour until hunters catch them."]) elif kind == "fire": barrels = stock.get("water_barrel", 0) - if barrels >= 5: + if fitted(world, "well_pump") and rng.random() < 0.5: + _record(world, now, "hardship", kind, "🔥", "A fire broke out, and the well's pump put it out", + ["Nothing was lost."]) + elif barrels >= 5: take_stock_qty(stock, "water_barrel", 5) _record(world, now, "hardship", kind, "🔥", "A fire broke out, and the water barrels put it out", ["Five barrels were used up. Nothing else was lost."]) @@ -2515,6 +2608,6 @@ def content() -> dict: "buildings": buildings, "xp_table": XP_TABLE, "offline_cap": OFFLINE_CAP, "tiers": TIERS, "city_uses": {item: city_uses(item) for item in ITEMS}, "prayers": [{**prayer, "text": describe_prayer(prayer)} for prayer in PRAYERS], "max_prayers": MAX_PRAYERS, - "potion_charges": POTION_CHARGES, "problems": PROBLEMS, + "potion_charges": POTION_CHARGES, "problems": PROBLEMS, "fittings": list(FITTING_BY_ID.values()), "equip_slots": EQUIP_SLOTS, "eat_below": EAT_BELOW, "regen_per_hour": REGEN_PER_HOUR, } diff --git a/static/app.js b/static/app.js index 1d75fba..8774762 100644 --- a/static/app.js +++ b/static/app.js @@ -462,6 +462,10 @@ function itemStats(id) { if (it.tool) parts.push(`${Math.round(it.speed * 100)}% faster ${skillOf(it.tool).name.toLowerCase()}`); if (it.req && it.req[1] > 1) parts.push(`needs ${skillOf(it.req[0]).name.toLowerCase()} ${it.req[1]}`); if (it.potion) parts.push(`${it.potion.text}, ${C.potion_charges} uses`); + if (it.fitting) { + const f = C.fittings.find((x) => x.id === id), done = S.city.buildings[f.building].fittings.includes(id); + parts.push(`${C.buildings[f.building].icon} For the ${C.buildings[f.building].name.toLowerCase()} (Lv ${f.level}): ${f.effect}${done ? " · already fitted" : ""}`); + } return parts.join(", "); } @@ -738,6 +742,7 @@ function buildingCard(key, b) { `; }).join("")}`; } + body += fittingRows(key, b); const levelText = b.cap < spec.max ? `Lv ${b.level} · max ${b.cap} for a ${city.tier_name.toLowerCase()}` : `Lv ${b.level} / ${spec.max}`; return `