From 58a3e66454bfdb936ae79c9cf86c62e8139be98d Mon Sep 17 00:00:00 2001 From: rope Date: Wed, 28 Oct 2026 10:46:43 +0000 Subject: [PATCH] Townsfolk mood: needs, kindling, clothes Co-Authored-By: Claude Opus 5.5 --- app.py | 1 + game.py | 220 +++++++++++++++++++++++++++++++++++++++++---- static/app.js | 68 +++++++++++--- static/icons.svg | 2 + static/styles.css | 13 ++- tests/test_game.py | 111 +++++++++++++++++++++++ 6 files changed, 385 insertions(+), 30 deletions(-) diff --git a/app.py b/app.py index c9b8904..c32643f 100644 --- a/app.py +++ b/app.py @@ -192,6 +192,7 @@ def city_view(world: dict, now: float) -> dict: "day": game.calendar_day(world, now) + 1, "fires": game.fires_view(world, now), "monuments": game.monuments_view(world), + "happiness": game.happiness(world, now), } diff --git a/game.py b/game.py index 035354b..86f8ad0 100644 --- a/game.py +++ b/game.py @@ -514,6 +514,38 @@ for _tier, (_id, _name, *_rest) in enumerate(HERBS): ITEMS[f"{_id}_salve"] = {"name": f"{_name.removesuffix(' leaf')} salve", "plural": f"{_name.removesuffix(' leaf')} salves", "icon": "๐Ÿฉน", "kit": "salve", "tier": _tier, "mend": _tier + 1, "value": 0} +# --- townsfolk needs ----------------------------------------------------------- +# The townsfolk's mood (0 to 1) follows what the city gives them, a little each hour. +# Each settlement tier adds a need: a hamlet only wants food, a village firewood too, +# a town clothes for the season as well. +NEEDS = {"food": 0.4, "warmth": 0.3, "clothes": 0.3} # weight in the mood +NEEDS_BY_TIER = [("food",), ("food", "warmth"), ("food", "warmth", "clothes"), ("food", "warmth", "clothes")] +FOOD_DAYS = [2, 4, 7, 10] # days of food stored for full marks, by tier +FOOD_KINDS = 4 # kinds of food, each enough for a day, for full marks +FUEL_DAYS = 2 # days of fuel stored for full marks +HEARTH_BURN = [0.01, 0.005, 0.015, 0.025] # fuel per person per game hour, spring to winter +KINDLING_YIELD = [2, 3, 4, 5, 6, 8] # from one log of each tier +KINDLING_XP = [3, 7, 15, 23, 33, 45] +SEASON_CLOTHES = ["smock", "smock", "cloak", "cloak"] # each person wears out one a season +MOOD_DRIFT = 0.5 / 48 # per game hour: Content to either end in two days +PROBLEM_MOOD = 0.05 # off the mood for each minor threat left unsolved +# Sudden news moves the mood at once, then it drifts back. +MOOD_SHOCKS = {"raid_won": 0.05, "raid_lost": -0.2, "dragon_won": 0.05, "dragon_lost": -0.25, + "fire": -0.05, "storm": -0.05, "festival": 0.1, "trouble": -0.03} +# Up to `below`: name, icon, growth, guards (militia), townsfolk labour and taxes, leaving a day +MOODS = [ + {"below": 0.2, "name": "Miserable", "icon": "๐Ÿ˜ ", "growth": 0, "militia": 0.8, "work": 0.5, "leave": 0.03}, + {"below": 0.4, "name": "Unhappy", "icon": "๐Ÿ˜Ÿ", "growth": 0, "militia": 0.9, "work": 0.8, "leave": 0}, + {"below": 0.6, "name": "Content", "icon": "๐Ÿ˜", "growth": 1.0, "militia": 1.0, "work": 1.0, "leave": 0}, + {"below": 0.8, "name": "Happy", "icon": "๐Ÿ™‚", "growth": 1.1, "militia": 1.05, "work": 1.1, "leave": 0}, + {"below": 9.0, "name": "Joyful", "icon": "๐Ÿ˜„", "growth": 1.25, "militia": 1.1, "work": 1.25, "leave": 0}, +] +TROUBLE_ODDS = 1 / 24 # an hour, while Miserable: about once a day +FUEL = ("kindling", *FIREWOOD) # what the hearths burn, kindling first; one each an hour's warmth +ITEMS["kindling"] = {"name": "Kindling", "plural": "Kindling", "icon": "๐Ÿชต", "value": 1} +ITEMS["smock"] = {"name": "Summer smock", "plural": "Summer smocks", "icon": "๐Ÿ‘•", "value": 12} +ITEMS["cloak"] = {"name": "Winter cloak", "plural": "Winter cloaks", "icon": "๐Ÿงฅ", "value": 24} + # Health potions: the one heal you take into a fight, drunk once a fight when you're low. # Each tier is the tier's herb and its cooked fish. HEALTH_HEAL = [20, 40, 65, 90, 115, 140] @@ -1367,6 +1399,12 @@ ACTIONS = [ *[_action(f"tend_all_{log}", "firekeeping", "Tend all fires", TIER_LEVELS[tier], 30, 0, {log: 1}, special="tend", tend=None, tier=tier, group="All fires") for tier, log in enumerate(FIREWOOD)], + # Kindling for the townsfolk's hearths: better logs split into more. + *[_action(f"split_{log}", "firekeeping", f"Split {ITEMS[log]['name'].lower()}", TIER_LEVELS[tier], 25, + KINDLING_XP[tier], {log: 1}, {"kindling": KINDLING_YIELD[tier]}, group="Kindling") + for tier, log in enumerate(FIREWOOD)], + _action("sew_smock", "tailoring", "Summer smock", 3, 30, 12, {"linen": 2}, {"smock": 1}, group="Townsfolk"), + _action("sew_cloak", "tailoring", "Winter cloak", 8, 40, 24, {"linen": 4}, {"cloak": 1}, group="Townsfolk"), ] # --- dungeon loot -------------------------------------------------------------- @@ -1586,7 +1624,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows # --- world --------------------------------------------------------------- -WORLD_VERSION = 34 +WORLD_VERSION = 35 def new_world(now: float, speed: float = 1.0) -> dict: @@ -1611,6 +1649,7 @@ def new_world(now: float, speed: float = 1.0) -> dict: "repelled": 0, "fires": {key: new_fire(now) for key in FIRES}, "wages": 0, "monuments": {}, "built": [], "carving": None, + "happiness": 0.5, "clothes_due": 0.0, "unheated": False, }, "events": [], "next_event_at": now + 12 * HOUR / speed, @@ -1847,6 +1886,10 @@ def migrate(world: dict, now: float | None = None) -> dict: for player in world["players"].values(): player.setdefault("gave", {}) world["version"] = 34 + if world["version"] < 35: + # Townsfolk needs: everyone starts Content and drifts to what the city gives them. + world["city"].update(happiness=0.5, clothes_due=0.0, unheated=False) + world["version"] = 35 return world @@ -2155,6 +2198,10 @@ def city_uses(item: str) -> list[str]: spec = ITEMS[item] if "food" in spec: uses.append("the granary") + if item in FUEL: + uses.append("the hearths") + if item in SEASON_CLOTHES: + uses.append("the townsfolk") if spec.get("slot") in ("weapon", "shield") or "kit" in spec or item == "arrows": uses.append("the armoury") if item == "water_barrel": @@ -4282,7 +4329,7 @@ def growth_per_day(world: dict) -> float: """New townsfolk a day while there's room: settlers for the homes, plus families.""" pop = world["city"]["population"] growth = SETTLERS_PER_EMPTY_HOME * max(0.0, housing(world) - pop) + FAMILY_GROWTH * pop - return growth * city_rate(world, "growth") * (1.25 if fitted(world, "furniture") else 1) + return growth * city_rate(world, "growth") * (1.25 if fitted(world, "furniture") else 1) * mood(world)["growth"] def population_view(world: dict) -> dict: @@ -4298,17 +4345,137 @@ def population_view(world: dict) -> dict: growth = -pop * 0.05 else: growth = 0.0 + leaving = 0.0 if city["starving"] else pop * mood(world)["leave"] food = sum(city["stockpile"].get(item, 0) * ITEMS[item]["food"] for item in FOOD_ORDER) tier = city["tier"] following = TIERS[tier + 1] if tier + 1 < len(TIERS) else None return { - "population": int(pop), "housing": room, "growth": round(growth, 1), + "population": int(pop), "housing": room, "growth": round(growth - leaving, 1), "leaving": round(leaving, 1), + "halted": not mood(world)["growth"], "food": food, "food_days": round(food / (pop * 0.5), 1) if pop else None, "tier": tier, "tier_name": TIERS[tier]["name"], "next_tier": following and {"name": following["name"], "pop": following["pop"]}, } +def mood(world: dict) -> dict: + """The townsfolk's mood band right now, with what it does.""" + score = world["city"]["happiness"] + return next(band for band in MOODS if score < band["below"]) + + +def _meals(stock: dict, item: str) -> float: + return stock.get(item, 0) * ITEMS[item]["food"] + + +def hearth_burn(world: dict, now: float) -> tuple[float, float]: + """Fuel the city burns an hour: (homes, weather and hardships). Hamlets don't keep hearths.""" + pop = world["city"]["population"] + homes = pop * HEARTH_BURN[season(world, now)["index"]] if "warmth" in NEEDS_BY_TIER[world["city"]["tier"]] else 0.0 + return homes, pop * sum(source.get("firewood", 0) for source in _modifier_sources(world)) + + +def happiness(world: dict, now: float) -> dict: + """Each need scored 0..1 with its have / need rows, the modifiers, and the mood they pull towards.""" + city = world["city"] + pop = max(1.0, city["population"]) + stock = city["stockpile"] + tier = city["tier"] + day = pop * 0.5 # meals a day + meals = sum(_meals(stock, item) for item in FOOD_ORDER) + kinds = sum(1 for item in FOOD_ORDER if _meals(stock, item) >= day) + want_meals = math.ceil(FOOD_DAYS[tier] * day) + supply = min(1.0, meals / want_meals) + variety = min(1.0, kinds / FOOD_KINDS) + needs = {"food": {"score": 0.0 if city["starving"] else 0.5 * supply + 0.5 * variety, "rows": [ + {"item": "bread", "label": "Food (any)", "have": int(meals), "want": want_meals, "any": "food"}, + {"item": "stew", "label": "Kinds of food", "have": kinds, "want": FOOD_KINDS, "kinds": True}, + ]}} + season_now = season(world, now)["index"] + burn = 24 * sum(hearth_burn(world, now)) + fuel = sum(stock.get(item, 0) for item in FUEL) + want_fuel = math.ceil(FUEL_DAYS * burn) + needs["warmth"] = {"score": 0.0 if city.get("unheated") else min(1.0, fuel / want_fuel) if want_fuel else 1.0, + "rows": [{"item": "kindling", "label": "Firewood (any)", "have": fuel, "want": want_fuel, + "any": "fuel"}]} + garment, later = SEASON_CLOTHES[season_now], SEASON_CLOTHES[(season_now + 1) % 4] + rows = [{"item": garment, "label": ITEMS[garment]["plural"], "have": stock.get(garment, 0), "want": math.ceil(pop)}] + if later != garment: + rows.append({"item": later, "label": f"{ITEMS[later]['plural']} (next season)", "have": stock.get(later, 0), + "want": math.ceil(pop), "next": True}) + needs["clothes"] = {"score": min(1.0, stock.get(garment, 0) / pop), "rows": rows} + active = NEEDS_BY_TIER[tier] + base = sum(NEEDS[key] * needs[key]["score"] for key in active) / sum(NEEDS[key] for key in active) + problems = sum(1 for problem in city["problems"] if problem["kind"] in MINOR_THREATS) + mods = [{"name": "Unsolved problems", "value": -PROBLEM_MOOD * problems}] if problems else [] + mods += [{"name": effect["name"], "value": effect["mood"]} for effect in city["effects"] if effect.get("mood")] + target = max(0.0, min(1.0, base + sum(m["value"] for m in mods))) + band = mood(world) + return { + "score": round(city["happiness"], 3), "target": round(target, 3), "name": band["name"], "icon": band["icon"], + "heading": next(b["name"] for b in MOODS if target < b["below"]), + "effects": {key: band[key] for key in ("growth", "militia", "work", "leave")}, + "needs": {key: {**needs[key], "score": round(needs[key]["score"], 3), "weight": NEEDS[key]} for key in active}, + "locked": [key for key in NEEDS if key not in active], + "mods": mods, + } + + +def shock_mood(world: dict, now: float, kind: str) -> None: + """News that moves the townsfolk at once: a raid, a fire, a festival.""" + city = world["city"] + before = mood(world)["name"] + city["happiness"] = max(0.0, min(1.0, city["happiness"] + MOOD_SHOCKS[kind])) + _mood_news(world, now, before) + + +def _mood_news(world: dict, now: float, before: str) -> None: + band = mood(world) + if band["name"] != before: + log(world, now, "city", f"{band['icon']} The townsfolk are {band['name'].lower()}.") + + +def _townsfolk_hour(world: dict, now: float, rng: random.Random) -> None: + """Clothes wear out, the mood drifts towards what the city gives, and misery makes trouble.""" + city = world["city"] + stock = city["stockpile"] + pop = city["population"] + if "clothes" in NEEDS_BY_TIER[city["tier"]]: + garment = SEASON_CLOTHES[season(world, now)["index"]] + city["clothes_due"] = city.get("clothes_due", 0) + pop / (SEASON_DAYS * 24) + while city["clothes_due"] >= 1 and stock.get(garment): + take_stock_qty(stock, garment, 1) + city["clothes_due"] -= 1 + city["clothes_due"] = min(city["clothes_due"], 1) # nothing to wear out: no debt piles up + before = mood(world)["name"] + gap = happiness(world, now)["target"] - city["happiness"] + city["happiness"] += max(-MOOD_DRIFT, min(MOOD_DRIFT, gap)) + _mood_news(world, now, before) + if mood(world)["name"] == "Miserable" and rng.random() < TROUBLE_ODDS: + _trouble(world, now, rng) + + +def _trouble(world: dict, now: float, rng: random.Random) -> None: + """What miserable townsfolk do: down tools, help themselves to the stockpile, or riot.""" + city = world["city"] + kind = rng.choice(["strike", "theft", "riot"]) + if kind == "strike": + _add_effect(world, now, 12, kind="strike", name="Strike", icon="โœŠ", labour=0, text="No townsfolk labour") + _record(world, now, "hardship", "strike", "โœŠ", "The townsfolk have downed tools", + ["For 12 hours they won't work on the building site."]) + elif kind == "theft": + stock = city["stockpile"] + taken = _take_share(stock, [item for item in stock if item not in _food_items(stock)], 0.05) + _record(world, now, "hardship", "theft", "๐Ÿซณ", "Townsfolk helped themselves to the stockpile", + [f"They took {_listing(taken)}." if taken else "There was little worth taking."]) + else: + name = _damage_building(world, now, rng) + _record(world, now, "hardship", "riot", "๐Ÿ”ฅ", "A riot broke out in the streets", + [f"The {name.lower()} {'were' if name.endswith('s') else 'was'} damaged and needs repairs." if name + else "Nothing was badly damaged."]) + shock_mood(world, now, "trouble") + + def kit_slot(item: str) -> str | None: """Where a defender carries it: a gear slot, or city-only kit like guard jerkins.""" return ITEMS[item].get("slot") or ITEMS[item].get("kit") @@ -4423,7 +4590,8 @@ def defence(world: dict, guards: int | None = None) -> dict: stock = city["stockpile"] posts = guard_posts(world) armed = guards_on_duty(world) if guards is None else min(guards, posts) - militia = 0.3 * building_level(world, "barracks") * pop * (1.5 if fitted(world, "weapon_racks") else 1) + militia = (0.3 * building_level(world, "barracks") * pop * (1.5 if fitted(world, "weapon_racks") else 1) + * mood(world)["militia"]) parts = { "townsfolk": 10, "walls": 20 * building_level(world, "walls") + 25 * fitted(world, "hoardings"), @@ -4541,15 +4709,17 @@ 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"] + band = mood(world) if city["construction"]: - city["construction"]["labour"] += pop * TOWNSFOLK_LABOUR + city["construction"]["labour"] += pop * TOWNSFOLK_LABOUR * band["work"] * city_rate(world, "labour") threat = upcoming_raid(world) if threat and building_level(world, "spymaster"): threat["recon"] = min(intel_costs(threat)[-1], threat["recon"] + SPYMASTER_AGENTS * building_level(world, "spymaster")) 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: + # The biggest pile goes first, so the smaller kinds last and the table stays varied. + for item in sorted(FOOD_ORDER, key=lambda i: -_meals(stock, i)): while city["food_due"] >= ITEMS[item]["food"] and stock.get(item, 0) > 0: stock[item] -= 1 if not stock[item]: @@ -4568,7 +4738,9 @@ def _city_hour(world: dict, now: float) -> None: city["population"] = min(room, pop + growth_per_day(world) / 24) elif pop > room: city["population"] = max(room, pop - pop * 0.05 / 24) - city["treasury"] += pop * 0.5 / 24 + if band["leave"]: + city["population"] = max(5.0, city["population"] - pop * band["leave"] / 24) + city["treasury"] += pop * 0.5 / 24 * band["work"] def labour_needed(level: int, repair: bool) -> float: @@ -4869,6 +5041,7 @@ def _resolve_raid(world: dict, event: dict, now: float, rng: random.Random) -> N if garrison: lines.append("Everyone in the garrison earned 25 favour and combat experience.") log(world, now, "raid", f"{upper_first(event["group"])} were driven off") + shock_mood(world, now, "raid_won") else: severity = min(1.0, 1 - ratio) broken = wear_kit(world, wear_share(ratio), rng) @@ -4907,6 +5080,7 @@ def _resolve_raid(world: dict, event: dict, now: float, rng: random.Random) -> N for entry in garrison: gain_xp(world, world["players"][entry["id"]], "combat", round(attack), now) log(world, now, "raid", f"{upper_first(event["group"])} broke into the city") + shock_mood(world, now, "raid_lost") report["lines"] = lines event["report"] = report _pay_guards(world, report) @@ -4945,6 +5119,7 @@ def _resolve_dragon(world: dict, event: dict, now: float, rng: random.Random) -> if garrison: lines.append("Everyone in the garrison earned 40 favour, combat experience, dragon bones and Ironhill reagents.") log(world, now, "raid", report["title"]) + shock_mood(world, now, "dragon_won") else: severity = min(1.0, 1 - guard["total"] / attack) broken = wear_kit(world, 0.5, rng) @@ -4966,6 +5141,7 @@ def _resolve_dragon(world: dict, event: dict, now: float, rng: random.Random) -> for entry in garrison: gain_xp(world, world["players"][entry["id"]], "combat", round(attack), now) log(world, now, "raid", report["title"]) + shock_mood(world, now, "dragon_lost") report["lines"] = lines event["report"] = report _pay_guards(world, report) @@ -5324,6 +5500,7 @@ def _hardship(world: dict, now: float, rng: random.Random, kind: str | None = No if taken: lines.append(f"The wind scattered {_listing(taken)}.") _record(world, now, "hardship", kind, "๐ŸŒช๏ธ", "A wild storm battered the city", lines) + shock_mood(world, now, "storm") elif kind == "rats": city["problems"].append({"kind": "rats", "started": now, "progress": {}, "work": 0}) _record(world, now, "hardship", kind, "๐Ÿ€", "Rats have got into the granary", @@ -5346,6 +5523,7 @@ def _hardship(world: dict, now: float, rng: random.Random, kind: str | None = No lines.append(f"The {name.lower()} {'were' if name.endswith('s') else 'was'} damaged and needs repairs.") lines.append("Water barrels in the stockpile (five per fire) would have put it out.") _record(world, now, "hardship", kind, "๐Ÿ”ฅ", "Fire swept through part of the city", lines) + shock_mood(world, now, "fire") elif kind == "winter": _add_effect(world, now, 24, kind="winter", name="Harsh winter", icon="๐Ÿฅถ", firewood=0.1, text="Burns stockpile logs hourly") @@ -5418,8 +5596,9 @@ def _fortune(world: dict, now: float, rng: random.Random, kind: str | None = Non _record(world, now, "fortune", kind, "๐ŸŸ", "The salmon are running", ["Fishing yields are doubled for 6 hours."]) elif kind == "festival": - _add_effect(world, now, 12, kind="festival", name="Festival", icon="๐ŸŽ‰", growth=2, favour=1.5, - text="Growth doubled, +50% favour from donations") + _add_effect(world, now, 12, kind="festival", name="Festival", icon="๐ŸŽ‰", growth=2, favour=1.5, mood=0.1, + text="Growth doubled, +50% favour from donations, +10% mood") + shock_mood(world, now, "festival") _record(world, now, "fortune", kind, "๐ŸŽ‰", "The city is holding a festival", ["For 12 hours, the population grows twice as fast and donations earn 50% more favour."]) elif kind == "noble": @@ -5549,20 +5728,26 @@ def _happenings_hour(world: dict, now: float, rng: random.Random) -> None: if problem.get("until") and problem["until"] <= now: _solve(world, problem, now) stock = city["stockpile"] - burn = city["population"] * sum(source.get("firewood", 0) for source in _modifier_sources(world)) - city["firewood_due"] = city.get("firewood_due", 0) + burn - cold = False + homes, weather = hearth_burn(world, now) + city["firewood_due"] = city.get("firewood_due", 0) + homes + weather + short = False while city["firewood_due"] >= 1: - log_item = next((item for item in FIREWOOD if stock.get(item)), None) - if not log_item: - cold = True + fuel = next((item for item in FUEL if stock.get(item)), None) + if not fuel: + short = True city["firewood_due"] = 0 break - take_stock_qty(stock, log_item, 1) + take_stock_qty(stock, fuel, 1) city["firewood_due"] -= 1 + # Cold homes make for unhappy townsfolk; in snow or a cold snap they also eat more to keep warm. + cold = short and weather > 0 + unheated = homes + weather > 0 and not any(stock.get(item) for item in FUEL) if cold and not city.get("cold"): log(world, now, "city", "The firewood has run out. People are cold and eating more to stay warm.") + elif unheated and not city.get("unheated"): + log(world, now, "city", "The firewood has run out. Homes are cold.") city["cold"] = cold + city["unheated"] = unheated for problem in city["problems"]: spec = PROBLEMS[problem["kind"]] if spec.get("steal"): @@ -5571,6 +5756,7 @@ def _happenings_hour(world: dict, now: float, rng: random.Random) -> None: _take_share(stock, _food_items(stock), spec["steal_food"] * (1 - 0.25 * building_level(world, "granary"))) if spec.get("steal_gold"): city["treasury"] *= 1 - spec["steal_gold"] + _townsfolk_hour(world, now, rng) def _happenings_tracks(world: dict, now: float, rng: random.Random) -> bool: @@ -6160,6 +6346,7 @@ REAGENT_GLYPHS = {"heavy": "chain-mail", "medium": "scale-mail", "light": "yarn" WEAPON_GLYPHS = {"hammer": "warhammer", "staff": "wizard-staff", "bow": "pocket-bow"} HARDWARE_GLYPHS = ["screw", "nails", "door-handle", "metal-plate", "crossed-chains", "padlock"] GLYPHS = { + "kindling": ("wood-pile", "#c9a06a"), "smock": ("shirt", "#d8d0b8"), "cloak": ("cloak", "#8a6fd0"), "stone": ("stone-block", "#a8a29e"), "wheat": ("wheat", "#e3c060"), "vegetables": ("carrot", "#f08a3c"), "feathers": ("feather", "#e8e8ee"), "raw_meat": ("meat", "#e06a6a"), "arrows": ("arrow-cluster", "#c9b28a"), "bricks": ("brick-pile", "#c66a45"), "cut_stone": ("stone-stack", "#b8b2aa"), "beam": ("wood-beam", "#9a6a3a"), @@ -6390,6 +6577,7 @@ PATCH_NOTES = [ {"date": "2026-10-28", "title": "Queue", "notes": [ ("new", "Queue: โ–ฒ on #1 swaps it with the current make."), ("new", "Updated Guard kit, added recipes for crafting professions to contribute to defence of town without needing combat-related loot."), + ("new", "Townsfolk mood: food, firewood and seasonal clothes. Kindling (Firekeeping), smocks and cloaks (Tailoring)."), ]}, {"date": "2026-10-27", "title": "Prayer", "notes": [ ("new", "Gathering can be queued."), diff --git a/static/app.js b/static/app.js index 2c6c05a..f8e89c1 100644 --- a/static/app.js +++ b/static/app.js @@ -934,12 +934,16 @@ function cityNeed(id) { const it = item(id), fund = fundNeeds()[id]; let qty = fund?.qty || 0; const why = [...(fund?.why || [])]; - for (const r of S.upcoming?.requests || []) { - const short = r.want - r.have; - const counts = r.any === "food" ? it.food : r.any ? (it.slot || it.kit) === r.any : id === r.item; - if (short <= 0 || !counts) continue; - qty += r.any === "food" ? Math.ceil(short / it.food) : short; - why.push(`Raid readiness: ${r.label} ยท ${fmt(r.have)} / ${fmt(r.want)}`); + const townsfolk = Object.values(S.city.happiness.needs).flatMap((n) => n.rows).filter((r) => !r.kinds); + for (const [source, rows] of [["Raid readiness", S.upcoming?.requests || []], ["Townsfolk", townsfolk]]) { + for (const r of rows) { + const short = r.want - r.have; + const counts = r.any === "food" ? it.food : r.any === "fuel" ? id === "kindling" || C.firewood.includes(id) + : r.any ? (it.slot || it.kit) === r.any : id === r.item; + if (short <= 0 || !counts) continue; + qty += r.any === "food" ? Math.ceil(short / it.food) : short; + why.push(`${source}: ${r.label} ยท ${fmt(r.have)} / ${fmt(r.want)}`); + } } return qty ? { qty, why } : null; } @@ -1140,7 +1144,9 @@ function firekeepingPage() { ${glyph("campfire", "#98a1b4")} ${dur(fk.bank * 60 / speed)} ${tendingAll ? `` : ``} -
${Object.keys(C.fires).map((id) => fireCard(id, pick, tendAllTarget())).join("")}
`; +
${Object.keys(C.fires).map((id) => fireCard(id, pick, tendAllTarget())).join("")}
+
Kindling
+
${C.actions.filter((a) => a.skill === "firekeeping" && a.group === "Kindling").map(actionCard).join("")}
`; } function fireCard(id, pick, target) { @@ -1972,18 +1978,22 @@ function town() { const city = S.city; const growth = city.starving ? `Hungry: ${city.growth} a day` : city.growth > 0 ? `+${city.growth} a day` + : city.growth < 0 && city.leaving ? `Leaving: ${city.growth} a day` : city.growth < 0 ? `Overcrowded: ${city.growth} a day` + : city.halted ? `Halted: mood` : `Full: build houses`; const next = city.next_tier ? `${city.next_tier.name} at ${city.next_tier.pop} people` : "Largest tier"; let html = `
Settlement
${city.tier_name}
${next}
Population
${city.population} / ${city.housing} homes
${growth}
Food
${city.food_days ?? 0} days
${fmt(city.food)} meals in the granary
+ ${moodStat()}
Treasury
${fmt(city.treasury)}
Day ${city.day}
Defence
${fmt(city.defence.total)}
${city.repelled} raid${city.repelled === 1 ? "" : "s"} repelled
${weatherCard()}
`; html += happeningsPanel(); + html += moodPanel(); html += caravanPanel(); if (city.starving) html += ``; if (city.merchant_until > serverNow()) html += ``; @@ -1999,6 +2009,35 @@ function town() { return html; } +// --- townsfolk --------------------------------------------------------------- +const NEED_NAMES = { food: ["๐Ÿฒ", "Food"], warmth: ["๐Ÿ”ฅ", "Firewood"], clothes: ["๐Ÿ‘•", "Clothes"] }; +const NEED_TIERS = { warmth: "Village", clothes: "Town" }; +const pct = (x) => `${Math.round(x * 100)}%`; + +function moodStat() { + const h = S.city.happiness; + const trend = Math.abs(h.target - h.score) < .005 ? "Steady" : `${h.target > h.score ? "โ†—" : "โ†˜"} ${h.heading}`; + return `
Mood ${h.icon}
+
${h.name}
${trend}
`; +} + +function moodPanel() { + const h = S.city.happiness, e = h.effects; + const x = (v) => `ร—${+v.toFixed(2)}`; + const needs = Object.entries(h.needs).map(([key, n]) => `
+
${NEED_NAMES[key][0]} ${NEED_NAMES[key][1]}${pct(n.score)}
+ ${n.rows.map(reqRow).join("")}
`).join(""); + const locked = h.locked.map((key) => `๐Ÿ”’ ${NEED_NAMES[key][1]} ยท ${NEED_TIERS[key]}`).join(""); + const mods = h.mods.map((m) => `${esc(m.name)} ${m.value > 0 ? "+" : "โˆ’"}${pct(Math.abs(m.value))}`).join(""); + const effects = [`๐ŸŒฑ Growth ${x(e.growth)}`, `๐Ÿ›ก๏ธ Guards ${x(e.militia)}`, `๐Ÿ”จ Labour ${x(e.work)}`, `${COIN} Taxes ${x(e.work)}`, + ...(e.leave ? [`๐Ÿšถ ${pct(e.leave)} leave a day`] : [])]; + return `

${h.icon} Townsfolk ยท ${h.name}

+
+
${effects.join(" ยท ")}
+
${needs}
+ ${mods || locked ? `
${mods}${locked}
` : ""}
`; +} + function lookChip(id) { const c = C.cosmetics[id]; if (c.kind === "colour") return ``; @@ -2285,16 +2324,19 @@ function favourSpends() { `; } +// A have / need row with its bar, and a Give button when you hold the item. +function reqRow(r) { + const mine = !r.kinds && S.me.bank[r.item] || 0; + return `
${item(r.item).icon}${esc(r.label)} ${fmt(r.have)} / ${fmt(r.want)} + ${mine ? `` : ""} +
`; +} + function raidPrep(ev) { const city = S.city, me = S.me, d = city.defence; const names = { mercenaries: "Hired sellswords", ward: "Divine ward (temple)", rallied: "Rallied militia (favour)", drilled: "Drilled militia", townsfolk: "Townsfolk with pitchforks", walls: "Walls", militia: "Paid guards (barracks)", weapons: "Weapons the guards hold", shields: "Shields the guards hold", jerkins: "Body armour the guards wear", salves: "Salves", arrows: "Arrows", garrison: "Garrison" }; const full = d.posts * d.wage, owed = Math.max(0, full - d.wages); - const reqs = (ev.requests || []).map((r) => { - const mine = me.bank[r.item] || 0; - return `
${item(r.item).icon}${esc(r.label)} ${fmt(r.have)} / ${fmt(r.want)} - ${mine ? `` : ""} -
`; - }).join(""); + const reqs = (ev.requests || []).map(reqRow).join(""); return `

Raid readiness

${reqs}

Stockpile gear arms the defenders, best first. Fights use up arrows and break some gear.

diff --git a/static/icons.svg b/static/icons.svg index bb89106..23c3718 100644 --- a/static/icons.svg +++ b/static/icons.svg @@ -49,6 +49,7 @@ + @@ -216,6 +217,7 @@ + diff --git a/static/styles.css b/static/styles.css index b90c0bb..9ca0131 100644 --- a/static/styles.css +++ b/static/styles.css @@ -139,7 +139,7 @@ main { padding: 24px; max-width: 1200px; width: 100%; } .grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(min(250px, 100%), 1fr)); } .grid.wide { grid-template-columns: repeat(auto-fill, minmax(min(330px, 100%), 1fr)); } .cols { display: grid; gap: 18px; grid-template-columns: 1fr 1fr; } -.stats { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); margin-bottom: 18px; } +.stats { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(128px, 1fr)); margin-bottom: 18px; } .stat { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 12px 14px; } .stat .k { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; } .stat .v { font-size: 22px; font-weight: 650; font-variant-numeric: tabular-nums; } @@ -871,3 +871,14 @@ button.swatch { width: 36px; height: 36px; } .qp-row input::-webkit-inner-spin-button, .qp-row input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } .qp-then { display: inline-flex; align-items: center; gap: 4px; } .qp-then .x { background: none; border: 0; color: var(--muted); cursor: pointer; font-size: 11px; padding: 0 2px; } + +/* Townsfolk mood */ +.mood-bar { position: relative; height: 12px; border-radius: 99px; margin: 4px 0 8px; + background: linear-gradient(90deg, #e2645c33, #e3a04333 40%, #5cbf6a33); } +.mood-bar > span { display: block; height: 100%; border-radius: 99px; background: var(--c); } +.mood-bar > i { position: absolute; top: -3px; bottom: -3px; width: 2px; margin-left: -1px; background: var(--text); border-radius: 2px; } +.mood-effects { color: var(--muted); margin-bottom: 12px; } +.needs { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); } +.need-head { display: flex; justify-content: space-between; margin-bottom: 8px; } +.req.dim { opacity: .6; } +.mood-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; } diff --git a/tests/test_game.py b/tests/test_game.py index 5190269..b1ed066 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -3242,5 +3242,116 @@ class PartyTest(unittest.TestCase): self.assertEqual(world["parties"], {}) + +class TownsfolkTest(unittest.TestCase): + def setUp(self): + self.world = world_with() + self.city = self.world["city"] + self.city["population"] = 100.0 + self.city["stockpile"] = {} + self.now = self.city["last_tick"] + + def test_a_new_city_starts_content(self): + self.assertEqual(self.city["happiness"], 0.5) + self.assertEqual(game.mood(self.world)["name"], "Content") + + def test_each_tier_adds_a_need(self): + for tier, keys in enumerate([["food"], ["food", "warmth"], ["food", "warmth", "clothes"]]): + self.city["tier"] = tier + self.assertEqual(list(game.happiness(self.world, self.now)["needs"]), keys) + + def test_food_counts_supply_and_kinds(self): + # 100 people eat 50 meals a day; a hamlet wants 2 days stored and 4 kinds. + self.city["stockpile"] = {"bread": 100, "cooked_meat": 49} + food = game.happiness(self.world, self.now)["needs"]["food"] + self.assertAlmostEqual(food["score"], 0.5 * 1 + 0.5 * 1 / 4) # meat is short of a day's worth + self.city["starving"] = True + self.assertEqual(game.happiness(self.world, self.now)["needs"]["food"]["score"], 0) + + def test_the_biggest_pile_is_eaten_first(self): + self.city["stockpile"] = {"bread": 10, "cooked_meat": 500} + game._city_hour(self.world, self.now) + self.assertEqual(self.city["stockpile"]["bread"], 10) + self.assertLess(self.city["stockpile"]["cooked_meat"], 500) + + def test_hearths_burn_kindling_first_and_only_from_a_village(self): + self.city["stockpile"] = {"kindling": 10, "logs": 10} + self.city["weather"]["kind"] = "clear" + game._happenings_hour(self.world, self.now, random.Random(1)) + self.assertEqual(self.city["stockpile"], {"kindling": 10, "logs": 10}) # a hamlet has no hearths + self.city["tier"] = 1 + game._happenings_hour(self.world, self.now, random.Random(1)) + self.assertLess(self.city["stockpile"]["kindling"], 10) + self.assertEqual(self.city["stockpile"]["logs"], 10) + + def test_no_fuel_means_cold_homes(self): + self.city["tier"] = 1 + self.city["weather"]["kind"] = "clear" + game._happenings_hour(self.world, self.now, random.Random(1)) + self.assertTrue(self.city["unheated"]) + self.assertFalse(self.city["cold"]) # no extra food unless it's snowing + self.assertEqual(game.happiness(self.world, self.now)["needs"]["warmth"]["score"], 0) + + def test_clothes_for_the_season_wear_out(self): + self.city["tier"] = 2 + garment = game.SEASON_CLOTHES[game.season(self.world, self.now)["index"]] + self.city["stockpile"] = {garment: 100} + self.assertEqual(game.happiness(self.world, self.now)["needs"]["clothes"]["score"], 1) + for _hour in range(24): + game._townsfolk_hour(self.world, self.now, random.Random(1)) + # 100 people wear out 100 in a 7-day season: about 14 a day. + self.assertEqual(self.city["stockpile"][garment], 100 - int(100 / game.SEASON_DAYS)) + + def test_mood_drifts_slowly(self): + self.assertEqual(game.happiness(self.world, self.now)["target"], 0) # nothing to eat + game._townsfolk_hour(self.world, self.now, random.Random(1)) + self.assertAlmostEqual(self.city["happiness"], 0.5 - game.MOOD_DRIFT) + + def test_unsolved_problems_weigh_on_the_mood(self): + self.city["stockpile"] = {"bread": 1000, "cooked_meat": 100, "stew": 100, "cooked_shrimp": 100} + self.assertEqual(game.happiness(self.world, self.now)["target"], 1) + self.city["problems"].append({"kind": "goblins", "started": self.now, "progress": {}, "work": 0}) + self.assertAlmostEqual(game.happiness(self.world, self.now)["target"], 1 - game.PROBLEM_MOOD) + + def test_a_lost_raid_shocks_the_mood(self): + event = {"strength": 10_000, "sabotage": 0, "group": "the Red Hand"} + game._resolve_raid(self.world, event, self.now, random.Random(1)) + self.assertAlmostEqual(self.city["happiness"], 0.5 + game.MOOD_SHOCKS["raid_lost"]) + + def test_misery_drives_people_away_and_weakens_the_guards(self): + self.city["buildings"]["barracks"]["level"] = 1 + self.city["wages"] = 10_000 + self.city["stockpile"] = {"bread": 1000} + content = game.defence(self.world)["parts"]["militia"] + self.city["happiness"] = 0.1 + self.assertAlmostEqual(game.defence(self.world)["parts"]["militia"], round(content * 0.8, 1)) + self.city["population"] = game.housing(self.world) # full: no growth to hide the leaving + before = self.city["population"] + game._city_hour(self.world, self.now) + self.assertAlmostEqual(self.city["population"], before * (1 - 0.03 / 24)) + + def test_unhappy_halts_growth(self): + self.city["stockpile"] = {"bread": 1000} + self.city["happiness"] = 0.3 + self.city["population"] = game.housing(self.world) - 5.0 # room to grow + before = self.city["population"] + game._city_hour(self.world, self.now) + self.assertEqual(self.city["population"], before) + self.assertTrue(game.population_view(self.world)["halted"]) + + def test_misery_makes_trouble(self): + self.city["happiness"] = 0.05 + rng = random.Random(1) + rng.random = lambda: 0.0 + game._townsfolk_hour(self.world, self.now, rng) + self.assertIn(self.world["events"][-1]["kind"], ("strike", "theft", "riot")) + + def test_kindling_is_split_under_firekeeping(self): + split = game.ACTION_BY_ID["split_willow_logs"] + self.assertEqual((split["skill"], split["inputs"], split["outputs"]), + ("firekeeping", {"willow_logs": 1}, {"kindling": 4})) + self.assertIn("the hearths", game.city_uses("kindling")) + + if __name__ == "__main__": unittest.main()