Townsfolk mood: needs, kindling, clothes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-28 10:46:43 +00:00
parent 002e990dc4
commit 58a3e66454
6 changed files with 385 additions and 30 deletions

1
app.py
View file

@ -192,6 +192,7 @@ def city_view(world: dict, now: float) -> dict:
"day": game.calendar_day(world, now) + 1, "day": game.calendar_day(world, now) + 1,
"fires": game.fires_view(world, now), "fires": game.fires_view(world, now),
"monuments": game.monuments_view(world), "monuments": game.monuments_view(world),
"happiness": game.happiness(world, now),
} }

220
game.py
View file

@ -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", 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} "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. # 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. # Each tier is the tier's herb and its cooked fish.
HEALTH_HEAL = [20, 40, 65, 90, 115, 140] 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, *[_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") {log: 1}, special="tend", tend=None, tier=tier, group="All fires")
for tier, log in enumerate(FIREWOOD)], 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 -------------------------------------------------------------- # --- dungeon loot --------------------------------------------------------------
@ -1586,7 +1624,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows
# --- world --------------------------------------------------------------- # --- world ---------------------------------------------------------------
WORLD_VERSION = 34 WORLD_VERSION = 35
def new_world(now: float, speed: float = 1.0) -> dict: 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, "repelled": 0,
"fires": {key: new_fire(now) for key in FIRES}, "fires": {key: new_fire(now) for key in FIRES},
"wages": 0, "monuments": {}, "built": [], "carving": None, "wages": 0, "monuments": {}, "built": [], "carving": None,
"happiness": 0.5, "clothes_due": 0.0, "unheated": False,
}, },
"events": [], "events": [],
"next_event_at": now + 12 * HOUR / speed, "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(): for player in world["players"].values():
player.setdefault("gave", {}) player.setdefault("gave", {})
world["version"] = 34 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 return world
@ -2155,6 +2198,10 @@ def city_uses(item: str) -> list[str]:
spec = ITEMS[item] spec = ITEMS[item]
if "food" in spec: if "food" in spec:
uses.append("the granary") 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": if spec.get("slot") in ("weapon", "shield") or "kit" in spec or item == "arrows":
uses.append("the armoury") uses.append("the armoury")
if item == "water_barrel": 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.""" """New townsfolk a day while there's room: settlers for the homes, plus families."""
pop = world["city"]["population"] pop = world["city"]["population"]
growth = SETTLERS_PER_EMPTY_HOME * max(0.0, housing(world) - pop) + FAMILY_GROWTH * pop 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: def population_view(world: dict) -> dict:
@ -4298,17 +4345,137 @@ def population_view(world: dict) -> dict:
growth = -pop * 0.05 growth = -pop * 0.05
else: else:
growth = 0.0 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) food = sum(city["stockpile"].get(item, 0) * ITEMS[item]["food"] for item in FOOD_ORDER)
tier = city["tier"] tier = city["tier"]
following = TIERS[tier + 1] if tier + 1 < len(TIERS) else None following = TIERS[tier + 1] if tier + 1 < len(TIERS) else None
return { 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, "food": food, "food_days": round(food / (pop * 0.5), 1) if pop else None,
"tier": tier, "tier_name": TIERS[tier]["name"], "tier": tier, "tier_name": TIERS[tier]["name"],
"next_tier": following and {"name": following["name"], "pop": following["pop"]}, "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: def kit_slot(item: str) -> str | None:
"""Where a defender carries it: a gear slot, or city-only kit like guard jerkins.""" """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") 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"] stock = city["stockpile"]
posts = guard_posts(world) posts = guard_posts(world)
armed = guards_on_duty(world) if guards is None else min(guards, posts) 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 = { parts = {
"townsfolk": 10, "townsfolk": 10,
"walls": 20 * building_level(world, "walls") + 25 * fitted(world, "hoardings"), "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: def _city_hour(world: dict, now: float) -> None:
city = world["city"] city = world["city"]
pop = city["population"] pop = city["population"]
band = mood(world)
if city["construction"]: 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) threat = upcoming_raid(world)
if threat and building_level(world, "spymaster"): if threat and building_level(world, "spymaster"):
threat["recon"] = min(intel_costs(threat)[-1], threat["recon"] + SPYMASTER_AGENTS * 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) 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)) * (0.9 if fitted(world, "grain_bins") else 1))
stock = city["stockpile"] 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: while city["food_due"] >= ITEMS[item]["food"] and stock.get(item, 0) > 0:
stock[item] -= 1 stock[item] -= 1
if not stock[item]: 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) city["population"] = min(room, pop + growth_per_day(world) / 24)
elif pop > room: elif pop > room:
city["population"] = max(room, pop - pop * 0.05 / 24) 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: 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: if garrison:
lines.append("Everyone in the garrison earned 25 favour and combat experience.") lines.append("Everyone in the garrison earned 25 favour and combat experience.")
log(world, now, "raid", f"{upper_first(event["group"])} were driven off") log(world, now, "raid", f"{upper_first(event["group"])} were driven off")
shock_mood(world, now, "raid_won")
else: else:
severity = min(1.0, 1 - ratio) severity = min(1.0, 1 - ratio)
broken = wear_kit(world, wear_share(ratio), rng) 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: for entry in garrison:
gain_xp(world, world["players"][entry["id"]], "combat", round(attack), now) gain_xp(world, world["players"][entry["id"]], "combat", round(attack), now)
log(world, now, "raid", f"{upper_first(event["group"])} broke into the city") log(world, now, "raid", f"{upper_first(event["group"])} broke into the city")
shock_mood(world, now, "raid_lost")
report["lines"] = lines report["lines"] = lines
event["report"] = report event["report"] = report
_pay_guards(world, report) _pay_guards(world, report)
@ -4945,6 +5119,7 @@ def _resolve_dragon(world: dict, event: dict, now: float, rng: random.Random) ->
if garrison: if garrison:
lines.append("Everyone in the garrison earned 40 favour, combat experience, dragon bones and Ironhill reagents.") lines.append("Everyone in the garrison earned 40 favour, combat experience, dragon bones and Ironhill reagents.")
log(world, now, "raid", report["title"]) log(world, now, "raid", report["title"])
shock_mood(world, now, "dragon_won")
else: else:
severity = min(1.0, 1 - guard["total"] / attack) severity = min(1.0, 1 - guard["total"] / attack)
broken = wear_kit(world, 0.5, rng) 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: for entry in garrison:
gain_xp(world, world["players"][entry["id"]], "combat", round(attack), now) gain_xp(world, world["players"][entry["id"]], "combat", round(attack), now)
log(world, now, "raid", report["title"]) log(world, now, "raid", report["title"])
shock_mood(world, now, "dragon_lost")
report["lines"] = lines report["lines"] = lines
event["report"] = report event["report"] = report
_pay_guards(world, report) _pay_guards(world, report)
@ -5324,6 +5500,7 @@ def _hardship(world: dict, now: float, rng: random.Random, kind: str | None = No
if taken: if taken:
lines.append(f"The wind scattered {_listing(taken)}.") lines.append(f"The wind scattered {_listing(taken)}.")
_record(world, now, "hardship", kind, "🌪️", "A wild storm battered the city", lines) _record(world, now, "hardship", kind, "🌪️", "A wild storm battered the city", lines)
shock_mood(world, now, "storm")
elif kind == "rats": elif kind == "rats":
city["problems"].append({"kind": "rats", "started": now, "progress": {}, "work": 0}) city["problems"].append({"kind": "rats", "started": now, "progress": {}, "work": 0})
_record(world, now, "hardship", kind, "🐀", "Rats have got into the granary", _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(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.") 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) _record(world, now, "hardship", kind, "🔥", "Fire swept through part of the city", lines)
shock_mood(world, now, "fire")
elif kind == "winter": elif kind == "winter":
_add_effect(world, now, 24, kind="winter", name="Harsh winter", icon="🥶", firewood=0.1, _add_effect(world, now, 24, kind="winter", name="Harsh winter", icon="🥶", firewood=0.1,
text="Burns stockpile logs hourly") 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", _record(world, now, "fortune", kind, "🐟", "The salmon are running",
["Fishing yields are doubled for 6 hours."]) ["Fishing yields are doubled for 6 hours."])
elif kind == "festival": elif kind == "festival":
_add_effect(world, now, 12, kind="festival", name="Festival", icon="🎉", growth=2, favour=1.5, _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") 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", _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."]) ["For 12 hours, the population grows twice as fast and donations earn 50% more favour."])
elif kind == "noble": 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: if problem.get("until") and problem["until"] <= now:
_solve(world, problem, now) _solve(world, problem, now)
stock = city["stockpile"] stock = city["stockpile"]
burn = city["population"] * sum(source.get("firewood", 0) for source in _modifier_sources(world)) homes, weather = hearth_burn(world, now)
city["firewood_due"] = city.get("firewood_due", 0) + burn city["firewood_due"] = city.get("firewood_due", 0) + homes + weather
cold = False short = False
while city["firewood_due"] >= 1: while city["firewood_due"] >= 1:
log_item = next((item for item in FIREWOOD if stock.get(item)), None) fuel = next((item for item in FUEL if stock.get(item)), None)
if not log_item: if not fuel:
cold = True short = True
city["firewood_due"] = 0 city["firewood_due"] = 0
break break
take_stock_qty(stock, log_item, 1) take_stock_qty(stock, fuel, 1)
city["firewood_due"] -= 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"): 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.") 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["cold"] = cold
city["unheated"] = unheated
for problem in city["problems"]: for problem in city["problems"]:
spec = PROBLEMS[problem["kind"]] spec = PROBLEMS[problem["kind"]]
if spec.get("steal"): 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"))) _take_share(stock, _food_items(stock), spec["steal_food"] * (1 - 0.25 * building_level(world, "granary")))
if spec.get("steal_gold"): if spec.get("steal_gold"):
city["treasury"] *= 1 - spec["steal_gold"] city["treasury"] *= 1 - spec["steal_gold"]
_townsfolk_hour(world, now, rng)
def _happenings_tracks(world: dict, now: float, rng: random.Random) -> bool: 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"} WEAPON_GLYPHS = {"hammer": "warhammer", "staff": "wizard-staff", "bow": "pocket-bow"}
HARDWARE_GLYPHS = ["screw", "nails", "door-handle", "metal-plate", "crossed-chains", "padlock"] HARDWARE_GLYPHS = ["screw", "nails", "door-handle", "metal-plate", "crossed-chains", "padlock"]
GLYPHS = { GLYPHS = {
"kindling": ("wood-pile", "#c9a06a"), "smock": ("shirt", "#d8d0b8"), "cloak": ("cloak", "#8a6fd0"),
"stone": ("stone-block", "#a8a29e"), "wheat": ("wheat", "#e3c060"), "vegetables": ("carrot", "#f08a3c"), "stone": ("stone-block", "#a8a29e"), "wheat": ("wheat", "#e3c060"), "vegetables": ("carrot", "#f08a3c"),
"feathers": ("feather", "#e8e8ee"), "raw_meat": ("meat", "#e06a6a"), "arrows": ("arrow-cluster", "#c9b28a"), "feathers": ("feather", "#e8e8ee"), "raw_meat": ("meat", "#e06a6a"), "arrows": ("arrow-cluster", "#c9b28a"),
"bricks": ("brick-pile", "#c66a45"), "cut_stone": ("stone-stack", "#b8b2aa"), "beam": ("wood-beam", "#9a6a3a"), "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": [ {"date": "2026-10-28", "title": "Queue", "notes": [
("new", "Queue: ▲ on #1 swaps it with the current make."), ("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", "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": [ {"date": "2026-10-27", "title": "Prayer", "notes": [
("new", "Gathering can be queued."), ("new", "Gathering can be queued."),

View file

@ -934,12 +934,16 @@ function cityNeed(id) {
const it = item(id), fund = fundNeeds()[id]; const it = item(id), fund = fundNeeds()[id];
let qty = fund?.qty || 0; let qty = fund?.qty || 0;
const why = [...(fund?.why || [])]; const why = [...(fund?.why || [])];
for (const r of S.upcoming?.requests || []) { 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 short = r.want - r.have;
const counts = r.any === "food" ? it.food : r.any ? (it.slot || it.kit) === r.any : id === r.item; 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; if (short <= 0 || !counts) continue;
qty += r.any === "food" ? Math.ceil(short / it.food) : short; qty += r.any === "food" ? Math.ceil(short / it.food) : short;
why.push(`Raid readiness: ${r.label} · ${fmt(r.have)} / ${fmt(r.want)}`); why.push(`${source}: ${r.label} · ${fmt(r.have)} / ${fmt(r.want)}`);
}
} }
return qty ? { qty, why } : null; return qty ? { qty, why } : null;
} }
@ -1140,7 +1144,9 @@ function firekeepingPage() {
<span class="tag" title="Most fuel you can bank in a fire">${glyph("campfire", "#98a1b4")} ${dur(fk.bank * 60 / speed)}</span> <span class="tag" title="Most fuel you can bank in a fire">${glyph("campfire", "#98a1b4")} ${dur(fk.bank * 60 / speed)}</span>
${tendingAll ? `<button class="btn danger" data-stop>Stop</button>` ${tendingAll ? `<button class="btn danger" data-stop>Stop</button>`
: `<button class="btn primary" data-start="${all}" ${allWhy ? "disabled" : ""} title="${esc(allWhy || "Each log goes on the lowest fire with room in your pile")}">🔥 Tend all</button>`}</div> : `<button class="btn primary" data-start="${all}" ${allWhy ? "disabled" : ""} title="${esc(allWhy || "Each log goes on the lowest fire with room in your pile")}">🔥 Tend all</button>`}</div>
<div class="fire-grid">${Object.keys(C.fires).map((id) => fireCard(id, pick, tendAllTarget())).join("")}</div>`; <div class="fire-grid">${Object.keys(C.fires).map((id) => fireCard(id, pick, tendAllTarget())).join("")}</div>
<div class="section-title">Kindling</div>
<div class="grid">${C.actions.filter((a) => a.skill === "firekeeping" && a.group === "Kindling").map(actionCard).join("")}</div>`;
} }
function fireCard(id, pick, target) { function fireCard(id, pick, target) {
@ -1972,18 +1978,22 @@ function town() {
const city = S.city; const city = S.city;
const growth = city.starving ? `<span class="bad">Hungry: ${city.growth} a day</span>` const growth = city.starving ? `<span class="bad">Hungry: ${city.growth} a day</span>`
: city.growth > 0 ? `+${city.growth} a day` : city.growth > 0 ? `+${city.growth} a day`
: city.growth < 0 && city.leaving ? `<span class="bad">Leaving: ${city.growth} a day</span>`
: city.growth < 0 ? `<span class="bad">Overcrowded: ${city.growth} a day</span>` : city.growth < 0 ? `<span class="bad">Overcrowded: ${city.growth} a day</span>`
: city.halted ? `<span class="warn">Halted: mood</span>`
: `<span class="warn">Full: build houses</span>`; : `<span class="warn">Full: build houses</span>`;
const next = city.next_tier ? `${city.next_tier.name} at ${city.next_tier.pop} people` : "Largest tier"; const next = city.next_tier ? `${city.next_tier.name} at ${city.next_tier.pop} people` : "Largest tier";
let html = `<div class="stats"> let html = `<div class="stats">
<div class="stat"><div class="k">Settlement</div><div class="v">${city.tier_name}</div><div class="note">${next}</div></div> <div class="stat"><div class="k">Settlement</div><div class="v">${city.tier_name}</div><div class="note">${next}</div></div>
<div class="stat"><div class="k">Population</div><div class="v">${city.population} <small>/ ${city.housing} homes</small></div><div class="note">${growth}</div></div> <div class="stat"><div class="k">Population</div><div class="v">${city.population} <small>/ ${city.housing} homes</small></div><div class="note">${growth}</div></div>
<div class="stat"><div class="k">Food</div><div class="v ${city.food_days < 1 ? "bad" : ""}">${city.food_days ?? 0} <small>days</small></div><div class="note">${fmt(city.food)} meals in the granary</div></div> <div class="stat"><div class="k">Food</div><div class="v ${city.food_days < 1 ? "bad" : ""}">${city.food_days ?? 0} <small>days</small></div><div class="note">${fmt(city.food)} meals in the granary</div></div>
${moodStat()}
<div class="stat"><div class="k">Treasury</div><div class="v gold">${fmt(city.treasury)}</div><div class="note">Day ${city.day}</div></div> <div class="stat"><div class="k">Treasury</div><div class="v gold">${fmt(city.treasury)}</div><div class="note">Day ${city.day}</div></div>
<div class="stat"><div class="k">Defence</div><div class="v">${fmt(city.defence.total)}</div><div class="note">${city.repelled} raid${city.repelled === 1 ? "" : "s"} repelled</div></div> <div class="stat"><div class="k">Defence</div><div class="v">${fmt(city.defence.total)}</div><div class="note">${city.repelled} raid${city.repelled === 1 ? "" : "s"} repelled</div></div>
${weatherCard()} ${weatherCard()}
</div>`; </div>`;
html += happeningsPanel(); html += happeningsPanel();
html += moodPanel();
html += caravanPanel(); html += caravanPanel();
if (city.starving) html += `<div class="banner bad">🍞<div class="grow"><b>Granary empty.</b> People are leaving, defence is down. Donate food.</div></div>`; if (city.starving) html += `<div class="banner bad">🍞<div class="grow"><b>Granary empty.</b> People are leaving, defence is down. Donate food.</div></div>`;
if (city.merchant_until > serverNow()) html += `<div class="banner">🐪<div class="grow"><b>Merchant in town.</b> Sell prices +50% for ${countdown(city.merchant_until)}.</div></div>`; if (city.merchant_until > serverNow()) html += `<div class="banner">🐪<div class="grow"><b>Merchant in town.</b> Sell prices +50% for ${countdown(city.merchant_until)}.</div></div>`;
@ -1999,6 +2009,35 @@ function town() {
return html; 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 `<div class="stat" title="${esc(`Mood ${pct(h.score)}, heading to ${pct(h.target)}`)}"><div class="k">Mood ${h.icon}</div>
<div class="v ${h.score < .4 ? "bad" : ""}">${h.name}</div><div class="note">${trend}</div></div>`;
}
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]) => `<div class="need">
<div class="need-head"><b>${NEED_NAMES[key][0]} ${NEED_NAMES[key][1]}</b><span class="muted small">${pct(n.score)}</span></div>
${n.rows.map(reqRow).join("")}</div>`).join("");
const locked = h.locked.map((key) => `<span class="tag" title="Needed from ${NEED_TIERS[key]}">🔒 ${NEED_NAMES[key][1]} · ${NEED_TIERS[key]}</span>`).join("");
const mods = h.mods.map((m) => `<span class="tag ${m.value < 0 ? "bad" : "good"}">${esc(m.name)} ${m.value > 0 ? "+" : "−"}${pct(Math.abs(m.value))}</span>`).join("");
const effects = [`🌱 Growth ${x(e.growth)}`, `🛡️ Guards ${x(e.militia)}`, `🔨 Labour ${x(e.work)}`, `${COIN} Taxes ${x(e.work)}`,
...(e.leave ? [`<span class="bad">🚶 ${pct(e.leave)} leave a day</span>`] : [])];
return `<div class="panel mood"><h2>${h.icon} Townsfolk · ${h.name}</h2>
<div class="mood-bar" title="${esc(`Now ${pct(h.score)} · heading to ${pct(h.target)}`)}"><span style="width:${h.score * 100}%;--c:var(--${h.score < .4 ? "bad" : h.score < .6 ? "warn" : "good"})"></span><i style="left:${h.target * 100}%"></i></div>
<div class="mood-effects small">${effects.join(" · ")}</div>
<div class="needs">${needs}</div>
${mods || locked ? `<div class="mood-tags">${mods}${locked}</div>` : ""}</div>`;
}
function lookChip(id) { function lookChip(id) {
const c = C.cosmetics[id]; const c = C.cosmetics[id];
if (c.kind === "colour") return `<span class="swatch" style="background:${c.value}"></span>`; if (c.kind === "colour") return `<span class="swatch" style="background:${c.value}"></span>`;
@ -2285,16 +2324,19 @@ function favourSpends() {
</div>`; </div>`;
} }
// 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 `<div class="req ${r.next ? "dim" : ""}"><span>${item(r.item).icon}</span><span>${esc(r.label)} <span class="muted small">${fmt(r.have)} / ${fmt(r.want)}</span></span>
${mine ? `<button class="btn sm gold" data-donate="${r.item}" data-qty="all">Give ${fmt(mine)}</button>` : "<span></span>"}
<div class="bar"><span style="width:${r.want ? Math.min(100, r.have / r.want * 100) : 100}%"></span></div></div>`;
}
function raidPrep(ev) { function raidPrep(ev) {
const city = S.city, me = S.me, d = city.defence; 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 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 full = d.posts * d.wage, owed = Math.max(0, full - d.wages);
const reqs = (ev.requests || []).map((r) => { const reqs = (ev.requests || []).map(reqRow).join("");
const mine = me.bank[r.item] || 0;
return `<div class="req"><span>${item(r.item).icon}</span><span>${esc(r.label)} <span class="muted small">${fmt(r.have)} / ${fmt(r.want)}</span></span>
${mine ? `<button class="btn sm gold" data-donate="${r.item}" data-qty="all">Give ${fmt(mine)}</button>` : "<span></span>"}
<div class="bar"><span style="width:${Math.min(100, r.have / r.want * 100)}%"></span></div></div>`;
}).join("");
return `<div class="cols"> return `<div class="cols">
<div class="panel"><h2>Raid readiness</h2>${reqs} <div class="panel"><h2>Raid readiness</h2>${reqs}
<p class="small muted">Stockpile gear arms the defenders, best first. Fights use up arrows and break some gear.</p></div> <p class="small muted">Stockpile gear arms the defenders, best first. Fights use up arrows and break some gear.</p></div>

View file

@ -49,6 +49,7 @@
<symbol id="checked-shield" viewBox="0 0 512 512"><path fill="currentColor" d="M48.906 19.656v10.782c0 103.173 10.53 206.07 41.313 289.53c30.78 83.463 82.763 148.094 164.53 170.563l2.188.626l2.25-.5c89.686-19.12 142.322-84.028 171.187-168.344S465.78 133.657 465.78 30.438v-10.78l-10.655 1.53C323.26 39.954 191.452 40 59.595 21.188l-10.69-1.53zM67.75 41.03c63.242 8.536 126.495 12.792 189.75 12.782v184.532h174.78c-4.905 27.572-11.31 53.747-19.592 77.937c-27.348 79.884-73.757 137.33-155.157 155.564c-.008-.003-.02.003-.03 0v-233.5H86.53c-12.87-60.99-18.277-128.81-18.78-197.313z"/></symbol> <symbol id="checked-shield" viewBox="0 0 512 512"><path fill="currentColor" d="M48.906 19.656v10.782c0 103.173 10.53 206.07 41.313 289.53c30.78 83.463 82.763 148.094 164.53 170.563l2.188.626l2.25-.5c89.686-19.12 142.322-84.028 171.187-168.344S465.78 133.657 465.78 30.438v-10.78l-10.655 1.53C323.26 39.954 191.452 40 59.595 21.188l-10.69-1.53zM67.75 41.03c63.242 8.536 126.495 12.792 189.75 12.782v184.532h174.78c-4.905 27.572-11.31 53.747-19.592 77.937c-27.348 79.884-73.757 137.33-155.157 155.564c-.008-.003-.02.003-.03 0v-233.5H86.53c-12.87-60.99-18.277-128.81-18.78-197.313z"/></symbol>
<symbol id="chisel" viewBox="0 0 512 512"><path fill="currentColor" d="M426.2 30.65L405 51.82l55.2 55.08l21.2-21.1zM173.6 55.12c-13.3 19.1-31.3 28.5-50 31.6c-12.3 2-24.91 1.5-37.41.4c10.5 10.6 19.91 22.18 24.21 36.18c36.7.2 69.2-9.9 98.6-36.28c-8.9-13.4-21.3-23.1-35.4-31.9m218.7 9.47L231.6 225.3c0 10.1-.1 20.4-1.5 30c-1.1 7.8-3.1 15.4-7 21.9l11.7 11.7c6.5-3.9 14.1-5.9 21.9-7c9.6-1.4 19.9-1.5 30-1.5l160.7-160.7zM188.6 268.2l-21.2 21.2l55.2 55.2l21.2-21.2zm-24.8 43c-28.8 11.1-48.7 30.5-59.4 41.2l-79.14 79.2c-.71.7-1.72 2.6-1.98 6.2c-.26 3.7.28 8.5 1.52 13.5c2.48 9.9 8.22 20.5 11.79 24.1c3.55 3.6 14.15 9.3 24.08 11.8c4.97 1.2 9.83 1.8 13.45 1.5c3.61-.2 5.59-1.3 6.3-2l79.18-79.2c10.7-10.6 30.1-30.5 41.2-59.3zM314 375.6c-17.2 13-25.1 26.3-27 37.2c-2.1 11.1 1.5 19.7 8.2 25.5l40.2 13.7c-3.9-9.7-6.8-19.9-5.8-30.8c.8-9.2 4.8-18.5 12.6-26.2c3.2-3.2 7-6 11.3-8.7zm85.4 40.5c-17.7 1.8-36.1 5-52 9.7c.2 7.8 3.6 17.2 7.9 27.1c22.7-9.5 54.8-17.6 87.9-19.6c-15.9-8.6-28.4-13.7-43.8-17.2"/></symbol> <symbol id="chisel" viewBox="0 0 512 512"><path fill="currentColor" d="M426.2 30.65L405 51.82l55.2 55.08l21.2-21.1zM173.6 55.12c-13.3 19.1-31.3 28.5-50 31.6c-12.3 2-24.91 1.5-37.41.4c10.5 10.6 19.91 22.18 24.21 36.18c36.7.2 69.2-9.9 98.6-36.28c-8.9-13.4-21.3-23.1-35.4-31.9m218.7 9.47L231.6 225.3c0 10.1-.1 20.4-1.5 30c-1.1 7.8-3.1 15.4-7 21.9l11.7 11.7c6.5-3.9 14.1-5.9 21.9-7c9.6-1.4 19.9-1.5 30-1.5l160.7-160.7zM188.6 268.2l-21.2 21.2l55.2 55.2l21.2-21.2zm-24.8 43c-28.8 11.1-48.7 30.5-59.4 41.2l-79.14 79.2c-.71.7-1.72 2.6-1.98 6.2c-.26 3.7.28 8.5 1.52 13.5c2.48 9.9 8.22 20.5 11.79 24.1c3.55 3.6 14.15 9.3 24.08 11.8c4.97 1.2 9.83 1.8 13.45 1.5c3.61-.2 5.59-1.3 6.3-2l79.18-79.2c10.7-10.6 30.1-30.5 41.2-59.3zM314 375.6c-17.2 13-25.1 26.3-27 37.2c-2.1 11.1 1.5 19.7 8.2 25.5l40.2 13.7c-3.9-9.7-6.8-19.9-5.8-30.8c.8-9.2 4.8-18.5 12.6-26.2c3.2-3.2 7-6 11.3-8.7zm85.4 40.5c-17.7 1.8-36.1 5-52 9.7c.2 7.8 3.6 17.2 7.9 27.1c22.7-9.5 54.8-17.6 87.9-19.6c-15.9-8.6-28.4-13.7-43.8-17.2"/></symbol>
<symbol id="claw-hammer" viewBox="0 0 512 512"><path fill="currentColor" d="M215.97 27.813c-2.218.04-4.35.177-6.407.375l-.532.062c-2.774.266-5.796 1.01-9.124 1.844c-4.418 1.11-8.096 2.607-11.125 4.406l77.782 59.313l7.375 5.656l-5.593 7.405l-13.72 18.156l67.595 51.626l14.217-18.812l9.47-12.53l6.5 14.342l19.656 43.47l60.593-80.095l-47.187-7.124l-15.533-2.312l9.47-12.563l14.28-18.843l-67.593-51.624L310.78 50.78l-5.03 6.657l-7.188-4.25c-28.635-16.917-58.02-25.157-80.343-25.374a77 77 0 0 0-2.25 0zM453 127.124l-71.47 94.438l34.564 26.187c44.24-20.22 64.01-52.594 71.312-94.625zm-193.688 25.03c-27.17 33.09-55.308 64.706-82.812 96.22l47.406 39.875c23.11-35.343 46.336-71.61 71.938-108.22l-36.53-27.874zM164.25 262.47c-11.355 13.115-22.564 26.254-33.5 39.5l48.375 52.717c11.707-16.6 23.107-33.515 34.438-50.687zm-45.438 54.124a1112 1112 0 0 0-23.093 29.72l33.468 73.842c13.586-16.22 26.434-32.893 38.843-49.937zm-35.75 47.03c-12.108 16.978-23.57 34.335-34.124 52.282L98.53 454.47a688 688 0 0 0 17.22-18.72zm-43.437 68.688a546 546 0 0 0-14.75 29l37.375 28.313a611 611 0 0 0 23.125-21.72z"/></symbol> <symbol id="claw-hammer" viewBox="0 0 512 512"><path fill="currentColor" d="M215.97 27.813c-2.218.04-4.35.177-6.407.375l-.532.062c-2.774.266-5.796 1.01-9.124 1.844c-4.418 1.11-8.096 2.607-11.125 4.406l77.782 59.313l7.375 5.656l-5.593 7.405l-13.72 18.156l67.595 51.626l14.217-18.812l9.47-12.53l6.5 14.342l19.656 43.47l60.593-80.095l-47.187-7.124l-15.533-2.312l9.47-12.563l14.28-18.843l-67.593-51.624L310.78 50.78l-5.03 6.657l-7.188-4.25c-28.635-16.917-58.02-25.157-80.343-25.374a77 77 0 0 0-2.25 0zM453 127.124l-71.47 94.438l34.564 26.187c44.24-20.22 64.01-52.594 71.312-94.625zm-193.688 25.03c-27.17 33.09-55.308 64.706-82.812 96.22l47.406 39.875c23.11-35.343 46.336-71.61 71.938-108.22l-36.53-27.874zM164.25 262.47c-11.355 13.115-22.564 26.254-33.5 39.5l48.375 52.717c11.707-16.6 23.107-33.515 34.438-50.687zm-45.438 54.124a1112 1112 0 0 0-23.093 29.72l33.468 73.842c13.586-16.22 26.434-32.893 38.843-49.937zm-35.75 47.03c-12.108 16.978-23.57 34.335-34.124 52.282L98.53 454.47a688 688 0 0 0 17.22-18.72zm-43.437 68.688a546 546 0 0 0-14.75 29l37.375 28.313a611 611 0 0 0 23.125-21.72z"/></symbol>
<symbol id="cloak" viewBox="0 0 512 512"><path fill="currentColor" d="M256 23.316c-27.177 0-58.578 5.75-79.525 17.967c-20.688 13.774-43.22 60.756-43.22 60.756a333 333 0 0 0-9.16 16.376c-36.372 69.627-61.496 175.763-62.4 317.686c46.593 26.853 97.436 44.53 142.05 52.582c-21.736-14.917-40.667-38.325-55.18-67.618c36.913-4.56 78.545-9.817 107.314-9.818c29.802 0 73.456 5.63 111.32 10.29c-14.484 29.072-33.326 52.308-54.946 67.144c44.615-8.052 91.458-25.727 138.05-52.58c-.903-141.923-26.027-248.06-62.4-317.686a331 331 0 0 0-9.16-16.375v-.003s-22.53-46.98-43.22-60.754c-23.52-11.95-52.347-17.967-79.524-17.967zm-.045 16.22c30.187 3.09 57.525 4.198 76.307 24.67c-18.46 21.81-45.975 49.5-76.29 49.503c-30.302.002-66.432-30.935-76.27-49.468c17.72-19.566 53.18-24.537 76.253-24.707zM170.57 80c5.43 12.588 52.522 49.167 85.375 49.25C289.09 129.333 336 91.16 342.32 80c0 0 12.28 18.68 18.594 31.057l4.55 10.906c20.518 45.148 32.614 92.753 32.614 158.1c0 45.65-8.408 88.144-22.873 123.724c-32.45-4.387-73.958-7.39-119.205-7.39c-43.513 0-83.54 2.78-115.408 6.896c-14.34-35.48-22.67-77.792-22.67-123.23c0-65.347 8.096-112.952 28.613-158.1l4.55-10.906C157.378 98.725 170.57 80 170.57 80"/></symbol>
<symbol id="clover" viewBox="0 0 512 512"><path fill="currentColor" d="M256 16c-24 0-72 24-72 48s48 24 72 96c24-72 72-72 72-96s-48-48-72-48M64 184c-24 0-48 48-48 72s24 72 48 72s24-48 96-72c-72-24-72-72-96-72m384 0c-24 0-24 48-96 72c72 24 72 72 96 72s48-48 48-72s-24-72-48-72m-189.906.03A72 72 0 0 0 184 256a72 72 0 0 0 144 0a72 72 0 0 0-69.906-71.97M256 352c-24 72-72 72-72 96s48 48 72 48s72-24 72-48s-48-24-72-96"/></symbol> <symbol id="clover" viewBox="0 0 512 512"><path fill="currentColor" d="M256 16c-24 0-72 24-72 48s48 24 72 96c24-72 72-72 72-96s-48-48-72-48M64 184c-24 0-48 48-48 72s24 72 48 72s24-48 96-72c-72-24-72-72-96-72m384 0c-24 0-24 48-96 72c72 24 72 72 96 72s48-48 48-72s-24-72-48-72m-189.906.03A72 72 0 0 0 184 256a72 72 0 0 0 144 0a72 72 0 0 0-69.906-71.97M256 352c-24 72-72 72-72 96s48 48 72 48s72-24 72-48s-48-24-72-96"/></symbol>
<symbol id="cooking-pot" viewBox="0 0 512 512"><path fill="currentColor" d="M256 100c-8 0-16 4-32 12l10.848 32.543C179.665 147.226 112.76 160.04 98.68 183h314.625c-7.012-11.422-27.093-20.334-52.305-26.738V135h-18v17.266c-21.203-4.107-44.4-6.68-65.848-7.723L288 112c-16-8-24-12-32-12M96 201v14H55v18h41v151c0 16 16 32 32 32h256c16 0 32-16 32-32V233h41v-18h-41v-14z"/></symbol> <symbol id="cooking-pot" viewBox="0 0 512 512"><path fill="currentColor" d="M256 100c-8 0-16 4-32 12l10.848 32.543C179.665 147.226 112.76 160.04 98.68 183h314.625c-7.012-11.422-27.093-20.334-52.305-26.738V135h-18v17.266c-21.203-4.107-44.4-6.68-65.848-7.723L288 112c-16-8-24-12-32-12M96 201v14H55v18h41v151c0 16 16 32 32 32h256c16 0 32-16 32-32V233h41v-18h-41v-14z"/></symbol>
<symbol id="cotton-flower" viewBox="0 0 512 512"><path fill="currentColor" d="M146.507 115.11c9.57 5.297 18.566 12.903 26.037 21.379c10.833 12.29 28.59 45.178 16.488 56.211c-4.728-13.9-21.103-34.226-29.992-44.311s-21.578-18.9-52.738-28.575c-31.16-9.674-69.148 19.903-76.52 51.505c-2.914 12.495.51 28.852 8.485 46.326s20.208 35.816 32.984 52.318a478 478 0 0 0 9.41 11.756c18.983 4.276 37.443 15.428 55.809 26.63c20.962 12.787 41.92 25.934 61.137 31.946c5.485-36.352 27.15-69.3 61.484-97.972l11.166-9.325l3.36 14.155c7.24 30.507 11.35 65.134-1.391 99.884c40.212-3.17 90.187-3.23 131.701 10.72c15.05-13.313 25.29-25.987 17.26-43.76c-51.41 12.81-96.615-21.713-110.274-43.09c35.856 22.168 58.87 35.061 96.428 27.488c29.382-7.439 56.674-9.419 65.94-39.385c16.521-53.932 14.432-125.682-21.018-166.254c-17.638-20.186-39.924-35.822-62.8-41.017c-22.878-5.196-46.394-.887-66.583 16.095c-20.188 16.982-29.956 33.382-33.648 48.041c-3.693 14.66 8.225 45.176 10.042 52.874c-19.041-15.644-32.472-37.512-27.495-57.27c4.468-17.742 15.862-34.596 30.91-47.748c-6.982-14.43-19.913-23.282-34.713-28.453c-61.606-16.768-134.47 5.879-121.469 79.832m112.725 151.232c-27.875 26.527-43.112 55.04-44.983 85.74l-.619 10.162l-10.01-1.86c-27.054-5.03-52.438-21.976-76.523-36.667c-16.28-9.93-31.842-18.507-45.717-22.818c10.695 15.797 22.65 33.306 38.1 49.05c21.19 21.595 46.663 37.596 79.838 35.805l14.314-.773c-13.651 31.451-22.935 66.172-22.822 94.023l15.404 2.96c6.81-36.11 13.404-62.107 29.902-86.911c52.267 7.413 107.26-4.614 147.467-24.219c-39.897-9.577-88.254-8.25-124.73-4.54l-16.412 1.667l7.48-14.703c14.8-29.092 14.524-58.461 9.31-86.916z"/></symbol> <symbol id="cotton-flower" viewBox="0 0 512 512"><path fill="currentColor" d="M146.507 115.11c9.57 5.297 18.566 12.903 26.037 21.379c10.833 12.29 28.59 45.178 16.488 56.211c-4.728-13.9-21.103-34.226-29.992-44.311s-21.578-18.9-52.738-28.575c-31.16-9.674-69.148 19.903-76.52 51.505c-2.914 12.495.51 28.852 8.485 46.326s20.208 35.816 32.984 52.318a478 478 0 0 0 9.41 11.756c18.983 4.276 37.443 15.428 55.809 26.63c20.962 12.787 41.92 25.934 61.137 31.946c5.485-36.352 27.15-69.3 61.484-97.972l11.166-9.325l3.36 14.155c7.24 30.507 11.35 65.134-1.391 99.884c40.212-3.17 90.187-3.23 131.701 10.72c15.05-13.313 25.29-25.987 17.26-43.76c-51.41 12.81-96.615-21.713-110.274-43.09c35.856 22.168 58.87 35.061 96.428 27.488c29.382-7.439 56.674-9.419 65.94-39.385c16.521-53.932 14.432-125.682-21.018-166.254c-17.638-20.186-39.924-35.822-62.8-41.017c-22.878-5.196-46.394-.887-66.583 16.095c-20.188 16.982-29.956 33.382-33.648 48.041c-3.693 14.66 8.225 45.176 10.042 52.874c-19.041-15.644-32.472-37.512-27.495-57.27c4.468-17.742 15.862-34.596 30.91-47.748c-6.982-14.43-19.913-23.282-34.713-28.453c-61.606-16.768-134.47 5.879-121.469 79.832m112.725 151.232c-27.875 26.527-43.112 55.04-44.983 85.74l-.619 10.162l-10.01-1.86c-27.054-5.03-52.438-21.976-76.523-36.667c-16.28-9.93-31.842-18.507-45.717-22.818c10.695 15.797 22.65 33.306 38.1 49.05c21.19 21.595 46.663 37.596 79.838 35.805l14.314-.773c-13.651 31.451-22.935 66.172-22.822 94.023l15.404 2.96c6.81-36.11 13.404-62.107 29.902-86.911c52.267 7.413 107.26-4.614 147.467-24.219c-39.897-9.577-88.254-8.25-124.73-4.54l-16.412 1.667l7.48-14.703c14.8-29.092 14.524-58.461 9.31-86.916z"/></symbol>
@ -216,6 +217,7 @@
<symbol id="shield" viewBox="0 0 512 512"><path fill="currentColor" d="M256 16c25 24 100 72 150 72v96c0 96-75 240-150 312c-75-72-150-216-150-312V88c50 0 125-48 150-72"/></symbol> <symbol id="shield" viewBox="0 0 512 512"><path fill="currentColor" d="M256 16c25 24 100 72 150 72v96c0 96-75 240-150 312c-75-72-150-216-150-312V88c50 0 125-48 150-72"/></symbol>
<symbol id="shield-bash" viewBox="0 0 512 512"><path fill="currentColor" d="M274.663 63.02L90.792 80.26l154.193 19.273c5.063-13.339 12.952-24.341 22.541-31.828a52 52 0 0 1 7.137-4.683zm19.832 12.803c-5.092.166-10.492 2.296-15.879 6.502c-7.835 6.118-15.009 16.575-18.83 29.688s-3.477 26.099-.289 35.927c3.188 9.829 8.73 16.071 15.633 18.395s14.766.596 22.601-5.522c7.835-6.117 15.01-16.574 18.83-29.687c3.822-13.113 3.48-26.1.292-35.928s-8.73-16.07-15.633-18.394a19 19 0 0 0-6.725-.98zm166.85 9.485c-24.113 13.949-46.193 20.298-87.233 17.252L340.48 228.452c-.675 2.682-.318 6 1.922 10.87c2.243 4.876 6.355 10.89 11.836 17.607c9.99 12.242 24.527 27.16 39.573 44.238c14.56-5.5 28.23-12.828 38.972-20.19c11.841-8.113 20.234-16.95 21.965-19.939l42.027-118.22c-16.748-14.613-29.471-33.974-35.43-57.51m-288.07 51.261L23.652 158.331l89.309 12.988l2.158-5.052zm286.265 2.325l16.941 6.078l-39.123 109.037l-37.212 19.181l-8.247-15.998l30.913-15.933zm-259.842 4.394l-70.586 36.043l-29.222 68.422l19.218 8.809l24.905-57.764l59.299-22.973l-14.702 75.955l-.963 1.477c-32.725 50.18-71.654 93.41-118.464 134.28L42.722 432.98l17.021 7.245l31.875-43.989l1.38-.906c45.476-29.872 75.93-62.333 112.255-94.492l4.533-4.012l5.426 2.686c23.365 11.571 42.934 24.117 62.107 37.705l8.924 6.324l-69.006 65.643l24.649 39.794l17.67-10.308l-20.078-28.477l8.224-5.004c29.884-18.186 49.986-39.43 71.938-66.039c-23.653-35.6-42.006-49.433-71.592-71.267l9.908-7.227c34.703-25.312 38.132-54.476 41.61-79.449c-9.203 4.441-19.498 5.772-29.473 2.414c-13.488-4.54-22.924-16.472-27.465-30.473c-.17-.522-.321-1.054-.479-1.584zm116.62 45.04c-1.355 7.027-3.324 14.17-6.092 21.349l14.056 9.666l5.938-22.223zm-174.243 97.476l-126.85 17.953l99.67 14.105a599 599 0 0 0 27.18-32.058m91.781 82.73l-95.892 21.432l59.406 13.277z"/></symbol> <symbol id="shield-bash" viewBox="0 0 512 512"><path fill="currentColor" d="M274.663 63.02L90.792 80.26l154.193 19.273c5.063-13.339 12.952-24.341 22.541-31.828a52 52 0 0 1 7.137-4.683zm19.832 12.803c-5.092.166-10.492 2.296-15.879 6.502c-7.835 6.118-15.009 16.575-18.83 29.688s-3.477 26.099-.289 35.927c3.188 9.829 8.73 16.071 15.633 18.395s14.766.596 22.601-5.522c7.835-6.117 15.01-16.574 18.83-29.687c3.822-13.113 3.48-26.1.292-35.928s-8.73-16.07-15.633-18.394a19 19 0 0 0-6.725-.98zm166.85 9.485c-24.113 13.949-46.193 20.298-87.233 17.252L340.48 228.452c-.675 2.682-.318 6 1.922 10.87c2.243 4.876 6.355 10.89 11.836 17.607c9.99 12.242 24.527 27.16 39.573 44.238c14.56-5.5 28.23-12.828 38.972-20.19c11.841-8.113 20.234-16.95 21.965-19.939l42.027-118.22c-16.748-14.613-29.471-33.974-35.43-57.51m-288.07 51.261L23.652 158.331l89.309 12.988l2.158-5.052zm286.265 2.325l16.941 6.078l-39.123 109.037l-37.212 19.181l-8.247-15.998l30.913-15.933zm-259.842 4.394l-70.586 36.043l-29.222 68.422l19.218 8.809l24.905-57.764l59.299-22.973l-14.702 75.955l-.963 1.477c-32.725 50.18-71.654 93.41-118.464 134.28L42.722 432.98l17.021 7.245l31.875-43.989l1.38-.906c45.476-29.872 75.93-62.333 112.255-94.492l4.533-4.012l5.426 2.686c23.365 11.571 42.934 24.117 62.107 37.705l8.924 6.324l-69.006 65.643l24.649 39.794l17.67-10.308l-20.078-28.477l8.224-5.004c29.884-18.186 49.986-39.43 71.938-66.039c-23.653-35.6-42.006-49.433-71.592-71.267l9.908-7.227c34.703-25.312 38.132-54.476 41.61-79.449c-9.203 4.441-19.498 5.772-29.473 2.414c-13.488-4.54-22.924-16.472-27.465-30.473c-.17-.522-.321-1.054-.479-1.584zm116.62 45.04c-1.355 7.027-3.324 14.17-6.092 21.349l14.056 9.666l5.938-22.223zm-174.243 97.476l-126.85 17.953l99.67 14.105a599 599 0 0 0 27.18-32.058m91.781 82.73l-95.892 21.432l59.406 13.277z"/></symbol>
<symbol id="shield-reflect" viewBox="0 0 512 512"><path fill="currentColor" d="m19.36 19.457l-.38 27.84l267.127 154.75l-58.703 19.916l57.602 19.36L142.12 383.546l-19.214-19.213l-51.47 114.324l123.125-43.482l-17.492-18.95l151.868-151.87l12.433 43.078l22.976-68.5l56.675-16.565l-57.475-15.492l-22.934-79.828l-14.145 48.266l-268.65-155.41l-38.454-.446zM370.614 81.94q-6.796.03-13.51.302c-3.275.134-6.528.32-9.76.547l31.25 108.786l104.05 28.045c6.204-39.085 8.902-80.275 9.18-120.075C456.37 88.82 415.098 82.257 375.15 81.95a404 404 0 0 0-4.537-.01zm-36.87 2.1c-31.99 3.653-61.013 12.153-83.014 25.87l64.493 37.31l18.52-63.18zm148.18 139.997L378.958 254.13l-38.93 116.054l-20.268-70.225l-39.07 39.067c18.814 35.364 46.1 62.287 86.175 77.604c47.473-17.834 78.566-62.795 98.014-121.6c7.323-22.14 12.905-46.11 17.042-70.993z"/></symbol> <symbol id="shield-reflect" viewBox="0 0 512 512"><path fill="currentColor" d="m19.36 19.457l-.38 27.84l267.127 154.75l-58.703 19.916l57.602 19.36L142.12 383.546l-19.214-19.213l-51.47 114.324l123.125-43.482l-17.492-18.95l151.868-151.87l12.433 43.078l22.976-68.5l56.675-16.565l-57.475-15.492l-22.934-79.828l-14.145 48.266l-268.65-155.41l-38.454-.446zM370.614 81.94q-6.796.03-13.51.302c-3.275.134-6.528.32-9.76.547l31.25 108.786l104.05 28.045c6.204-39.085 8.902-80.275 9.18-120.075C456.37 88.82 415.098 82.257 375.15 81.95a404 404 0 0 0-4.537-.01zm-36.87 2.1c-31.99 3.653-61.013 12.153-83.014 25.87l64.493 37.31l18.52-63.18zm148.18 139.997L378.958 254.13l-38.93 116.054l-20.268-70.225l-39.07 39.067c18.814 35.364 46.1 62.287 86.175 77.604c47.473-17.834 78.566-62.795 98.014-121.6c7.323-22.14 12.905-46.11 17.042-70.993z"/></symbol>
<symbol id="shirt" viewBox="0 0 512 512"><path fill="currentColor" d="M255.973 22.965c-43.313 0-95.117 14.71-126.66 30.12c-33.538 13.63-60.84 30.408-80.33 60.542c-26.22 40.536-38.948 103.153-30.278 202.797c24.886 8.635 47.288 6.054 70.272-1.73c-7.203-32.898-6.04-65.776 2.072-92.645c8.872-29.395 26.72-53.085 52.886-57.17l11.56-1.804l.305 11.697c.226 8.523.38 17.397.485 26.434l.113 26.338c-.155 36.707-1.593 76.298-5.328 116.182c23.522 7.64 61.78 12.326 99.99 12.597l19.18-.244c35.59-1.145 69.858-6.043 90.4-12.78c-4.562-61.128-4.866-122.812-6.34-172.464l-.407-13.7l13.267 3.44c26.03 6.746 44.022 30.776 53.246 60.528c8.425 27.166 9.814 59.957 2.668 92.833c17.938 5.68 35.678 7.15 53.602 4.926l18.355-2.453c4.31-78.49-5.98-156.066-26.157-193.04L450.48 96.767c-19.39-22.354-44.318-35.044-73.71-46.207c-28.146-14.427-76.6-27.595-120.797-27.595m0 12.724c27.095 0 55.783 8.103 80.42 18.845c-13.03 11.505-29.087 19.462-46.176 24.008l-32.764 72.228l-28.53-70.442c-19.852-3.88-38.642-12.34-53.34-25.76c25.763-11.032 54.585-18.88 80.39-18.88zm2.416 119.08l14.655 22.226l-14.59 21.084l-14.725-21.084zm0 49.603l14.655 22.23l-14.59 21.083l-14.725-21.084l14.66-22.23zm0 48.957l14.655 22.227l-14.59 21.086l-14.725-21.086zm0 49.553l14.655 22.228l-14.59 21.085l-14.725-21.084l14.66-22.227zm104.645 57.226c-27.583 8.572-66.045 12.127-105.467 12.314c-39.638.188-79.132-3.107-108.078-12.07a919 919 0 0 1-2.828 22.312c24.902 8.897 68.203 14.22 110.56 14.016c42.322-.205 84.93-6.02 108.116-14.32a1007 1007 0 0 1-2.303-22.253zm5.71 37.913c-28.3 9.63-68.984 13.61-110.698 13.81c-12.246.06-24.475-.22-36.402-.89l-17.637-1.314c-22.358-2.09-43.007-5.768-59.787-11.507c-3.742 22.658-8.426 44.944-14.243 66.354c8.833 4.422 18.403 8.212 28.52 11.404l16.564 4.575c66.132 15.875 149.713 8.995 206.963-14.658c-5.656-21.24-9.96-44.108-13.28-67.774"/></symbol>
<symbol id="shoulder-bag" viewBox="0 0 512 512"><path fill="currentColor" d="M256 18.75c14.4 0 27.4 5.89 38.2 15.27s19.7 22.17 27.5 37.08C337.2 101 348 139.6 355.8 178c11.6 56.7 16.5 113 18.3 138.8H356c-1.8-25.8-6.7-80.5-17.8-135.2c-7.7-37.6-18.4-75.1-32.5-102.2c-7.1-13.59-15-24.55-23.3-31.79c-8.4-7.25-16.8-10.86-26.4-10.86s-18 3.61-26.4 10.86c-8.3 7.24-16.2 18.2-23.3 31.79c-14.1 27.1-24.8 64.6-32.5 102.2c-11.1 54.7-16 109.4-17.8 135.2h-18.1c1.8-25.8 6.7-82.1 18.3-138.8c7.8-38.4 18.6-77 34.1-106.9c7.8-14.91 16.7-27.7 27.5-37.08s23.8-15.27 38.2-15.27M374.9 334.8q0 16.95-.3 32.1c-80.2 18.9-159.1 18.1-237.2-.1c-.2-10.2-.3-20.7-.3-32zm-1 50.6c-2.1 38.4-8.7 68.1-29.1 94.9v.1l-.1.1c.1-.1-2.7 2.1-7.8 4c-5.2 1.8-12.4 3.5-20.8 4.8c-16.8 2.6-38.5 4-60.1 4s-43.3-1.4-60.1-4c-8.4-1.3-15.6-3-20.8-4.8c-5.1-1.9-7.9-4.1-7.8-4l-.1-.1v-.1c-20.4-26.8-27-56.7-29.1-94.9c77.6 17.4 156.3 18.2 235.8 0"/></symbol> <symbol id="shoulder-bag" viewBox="0 0 512 512"><path fill="currentColor" d="M256 18.75c14.4 0 27.4 5.89 38.2 15.27s19.7 22.17 27.5 37.08C337.2 101 348 139.6 355.8 178c11.6 56.7 16.5 113 18.3 138.8H356c-1.8-25.8-6.7-80.5-17.8-135.2c-7.7-37.6-18.4-75.1-32.5-102.2c-7.1-13.59-15-24.55-23.3-31.79c-8.4-7.25-16.8-10.86-26.4-10.86s-18 3.61-26.4 10.86c-8.3 7.24-16.2 18.2-23.3 31.79c-14.1 27.1-24.8 64.6-32.5 102.2c-11.1 54.7-16 109.4-17.8 135.2h-18.1c1.8-25.8 6.7-82.1 18.3-138.8c7.8-38.4 18.6-77 34.1-106.9c7.8-14.91 16.7-27.7 27.5-37.08s23.8-15.27 38.2-15.27M374.9 334.8q0 16.95-.3 32.1c-80.2 18.9-159.1 18.1-237.2-.1c-.2-10.2-.3-20.7-.3-32zm-1 50.6c-2.1 38.4-8.7 68.1-29.1 94.9v.1l-.1.1c.1-.1-2.7 2.1-7.8 4c-5.2 1.8-12.4 3.5-20.8 4.8c-16.8 2.6-38.5 4-60.1 4s-43.3-1.4-60.1-4c-8.4-1.3-15.6-3-20.8-4.8c-5.1-1.9-7.9-4.1-7.8-4l-.1-.1v-.1c-20.4-26.8-27-56.7-29.1-94.9c77.6 17.4 156.3 18.2 235.8 0"/></symbol>
<symbol id="shouting" viewBox="0 0 512 512"><path fill="currentColor" d="M256.813 34.094c-32.67 0-62.826 19.624-85.375 53.125c-22.55 33.5-36.844 80.558-36.844 132.686s14.294 99.187 36.844 132.688s52.705 53.125 85.375 53.125s62.825-19.626 85.375-53.126s36.875-80.56 36.875-132.688s-14.326-99.187-36.875-132.687c-22.55-33.502-52.706-53.126-85.375-53.126zM64.405 127.47c-37.863 55.188-52.23 125.55-33.562 195.218c23.816 88.89 95.022 152.122 179.25 170.25C145.894 460.577 90.12 390.486 65.874 300c-16.337-60.975-15.608-121.328-1.468-172.53zm384.813 0c14.14 51.202 14.836 111.555-1.5 172.53c-24.246 90.486-79.988 160.578-144.19 192.938c84.23-18.128 155.403-81.36 179.22-170.25c18.668-69.668 4.332-140.03-33.53-195.22zM118.5 167.78c-27.588 40.212-38.04 91.49-24.438 142.25c17.355 64.767 69.225 110.823 130.594 124.033C177.88 410.485 137.26 359.43 119.594 293.5c-11.905-44.428-11.397-88.41-1.094-125.72m276.594 0c10.303 37.31 10.81 81.292-1.094 125.72c-17.666 65.93-58.285 116.985-105.063 140.563c61.37-13.21 113.24-59.266 130.594-124.032c13.603-50.76 3.152-102.038-24.436-142.25zm-197.813 2.095c20.065-.2 38.86 8.59 52 25.313l-14.718 11.53c-16.02-20.384-38.817-24.964-64.625-10.312l-9.218-16.25c12.02-6.824 24.523-10.16 36.56-10.28zm117.157.53c.625-.01 1.25-.005 1.875 0c12.04.122 24.543 3.49 36.563 10.314l-9.22 16.25c-25.806-14.653-48.603-10.103-64.624 10.28l-14.686-11.53c12.73-16.2 30.74-24.965 50.094-25.314zm-57.625 66.69c42.196 0 76.375 34.21 76.375 76.405c0 42.193-34.18 76.375-76.375 76.375s-76.406-34.18-76.406-76.375s34.21-76.406 76.406-76.406z"/></symbol> <symbol id="shouting" viewBox="0 0 512 512"><path fill="currentColor" d="M256.813 34.094c-32.67 0-62.826 19.624-85.375 53.125c-22.55 33.5-36.844 80.558-36.844 132.686s14.294 99.187 36.844 132.688s52.705 53.125 85.375 53.125s62.825-19.626 85.375-53.126s36.875-80.56 36.875-132.688s-14.326-99.187-36.875-132.687c-22.55-33.502-52.706-53.126-85.375-53.126zM64.405 127.47c-37.863 55.188-52.23 125.55-33.562 195.218c23.816 88.89 95.022 152.122 179.25 170.25C145.894 460.577 90.12 390.486 65.874 300c-16.337-60.975-15.608-121.328-1.468-172.53zm384.813 0c14.14 51.202 14.836 111.555-1.5 172.53c-24.246 90.486-79.988 160.578-144.19 192.938c84.23-18.128 155.403-81.36 179.22-170.25c18.668-69.668 4.332-140.03-33.53-195.22zM118.5 167.78c-27.588 40.212-38.04 91.49-24.438 142.25c17.355 64.767 69.225 110.823 130.594 124.033C177.88 410.485 137.26 359.43 119.594 293.5c-11.905-44.428-11.397-88.41-1.094-125.72m276.594 0c10.303 37.31 10.81 81.292-1.094 125.72c-17.666 65.93-58.285 116.985-105.063 140.563c61.37-13.21 113.24-59.266 130.594-124.032c13.603-50.76 3.152-102.038-24.436-142.25zm-197.813 2.095c20.065-.2 38.86 8.59 52 25.313l-14.718 11.53c-16.02-20.384-38.817-24.964-64.625-10.312l-9.218-16.25c12.02-6.824 24.523-10.16 36.56-10.28zm117.157.53c.625-.01 1.25-.005 1.875 0c12.04.122 24.543 3.49 36.563 10.314l-9.22 16.25c-25.806-14.653-48.603-10.103-64.624 10.28l-14.686-11.53c12.73-16.2 30.74-24.965 50.094-25.314zm-57.625 66.69c42.196 0 76.375 34.21 76.375 76.405c0 42.193-34.18 76.375-76.375 76.375s-76.406-34.18-76.406-76.375s34.21-76.406 76.406-76.406z"/></symbol>
<symbol id="shrimp" viewBox="0 0 512 512"><path fill="currentColor" d="M376.4 19.14c-24.4-.17-55.5 6.54-89 13.02c-33.4 6.48-68.9 12.8-100.2 12.56c-31.2-.24-57.4-6.91-74.6-24.83L99.67 32.35C128 59.39 166 63.49 200.2 62.49c-8.4 1.48-17.2 2.59-26.1 3.21c-36.7 2.54-75.23-2.61-92.58-13.15l-9.34 15.38C96.57 82.72 136.4 86.36 175.3 83.65c38.6-2.68 75.7-11.02 94.7-29.9c7.1-1.28 14.1-2.59 20.9-3.92c33.6-6.51 64.5-12.84 85.3-12.69c10.5.1 18 1.91 21.9 4.57c2.3 1.53 3.9 3.15 4.8 5.78h18.3c-1.7-8.79-6.3-16.19-13-20.74c-8.8-5.86-19.6-7.52-31.8-7.61m51.7 46.1c-44.9-.24-134 8.46-209.4 29.2c1.1-.11 2.2-.17 3.3-.17c18.1 0 33 14.93 33 33.03s-14.9 33-33 33s-33-14.9-33-33c0-13 7.7-24.3 18.7-29.71c-27.9 8.31-53.4 18.31-73.4 30.21c51.7 27.3 77.4 63 99.9 97.3c73.4-20.4 119.8-42.8 174.9-95.9c-24.1 5.9-35.3 6.5-56.2 5.7c28.1-10.5 97-43.23 118.7-55.9c-29.4 2.28-74.5 12.38-100.9 18.44c14.9-13.43 51.3-28.42 64.4-32.08c-2.2-.1-4.5-.1-7-.12M222 112.3c-8.4 0-15 6.6-15 15s6.6 15 15 15s15-6.6 15-15s-6.6-15-15-15m-120.5 20.2c-17.58 26.8-31.42 52.3-41.44 76.2c25.1 34.2 57.14 63.2 115.54 80.3c10-17.5 24.4-35.6 43-54.6c-25.3-38.9-51.8-75.5-117.1-101.9m-48.93 96c-12.03 35.4-15.18 67.4-9.37 96.1c32.35 23.5 59.1 38.1 115.5 25.9q-1.5-21.75 8.7-45.3c-54.9-16.7-89.29-44.9-114.83-76.7M231 247.2c-4.5 4.6-8.7 9.1-12.6 13.6c31.6 17.7 54.7 38.7 77.8 70.8l14.6-10.6c-23.4-32.4-48-55.2-79.8-73.8m-28.3 32.9c-3.8 5.2-7.2 10.3-10.1 15.3c8.6 3.6 21.7 10.7 33.8 19c15 10.4 28.9 23.4 33.6 32.6l16-8.4c-7.7-14.6-23.2-27.7-39.3-38.9c-11.9-8.3-23.8-15.1-34-19.6m-20.1 35.5c-2.5 6.2-4.1 12.2-5.1 17.9c4.7 1 12.9 4 20.8 9.1c10.7 7.1 21.5 17.3 27 26.1l15.2-9.6c-7.6-12-19.7-23.2-32.3-31.5c-8.6-5.6-17-10.4-25.6-12M52 352.1c15.4 34.5 46.57 64.6 95.2 89.5c11.6-1 20.9-2.1 28.7-5.9c6.9-3.4 13.3-9 19.9-20.9c-16.9-13.7-28.4-29.2-33.8-46.7c-49.2 10.4-81.56.9-110-16m370.1 72.6c-10.4-.1-43.1 4.2-75.8 11c-23.5 5-47.6 11.5-64.9 18c11.7-3.1 25.8-4.3 41.3-4.7c6.1-.2 12.5-.2 18.9-.1c19.2.4 39.2 2.2 57 4.8c12.3 1.9 23.4 3.9 32.9 6.5c6.9-8.3 12.4-18.2 12.9-24.1c.5-10.9-16.7-11.3-22.3-11.4m-211.7.7c-9.7 14.2-19.1 24.7-34.1 29.5c21.2 8.8 44.9 16.8 71.4 24c8.3-7.3 16-17.2 14.3-27.7c-19.8-7.8-37.1-16.3-51.6-25.8m63.3 50.9l-9.2 8.3c13.3 4 34.8 9 59.2 8.2c36.3-1.3 84.3 2.2 114.8-10.5c-48.8-12.3-126.4-27.6-164.8-6"/></symbol> <symbol id="shrimp" viewBox="0 0 512 512"><path fill="currentColor" d="M376.4 19.14c-24.4-.17-55.5 6.54-89 13.02c-33.4 6.48-68.9 12.8-100.2 12.56c-31.2-.24-57.4-6.91-74.6-24.83L99.67 32.35C128 59.39 166 63.49 200.2 62.49c-8.4 1.48-17.2 2.59-26.1 3.21c-36.7 2.54-75.23-2.61-92.58-13.15l-9.34 15.38C96.57 82.72 136.4 86.36 175.3 83.65c38.6-2.68 75.7-11.02 94.7-29.9c7.1-1.28 14.1-2.59 20.9-3.92c33.6-6.51 64.5-12.84 85.3-12.69c10.5.1 18 1.91 21.9 4.57c2.3 1.53 3.9 3.15 4.8 5.78h18.3c-1.7-8.79-6.3-16.19-13-20.74c-8.8-5.86-19.6-7.52-31.8-7.61m51.7 46.1c-44.9-.24-134 8.46-209.4 29.2c1.1-.11 2.2-.17 3.3-.17c18.1 0 33 14.93 33 33.03s-14.9 33-33 33s-33-14.9-33-33c0-13 7.7-24.3 18.7-29.71c-27.9 8.31-53.4 18.31-73.4 30.21c51.7 27.3 77.4 63 99.9 97.3c73.4-20.4 119.8-42.8 174.9-95.9c-24.1 5.9-35.3 6.5-56.2 5.7c28.1-10.5 97-43.23 118.7-55.9c-29.4 2.28-74.5 12.38-100.9 18.44c14.9-13.43 51.3-28.42 64.4-32.08c-2.2-.1-4.5-.1-7-.12M222 112.3c-8.4 0-15 6.6-15 15s6.6 15 15 15s15-6.6 15-15s-6.6-15-15-15m-120.5 20.2c-17.58 26.8-31.42 52.3-41.44 76.2c25.1 34.2 57.14 63.2 115.54 80.3c10-17.5 24.4-35.6 43-54.6c-25.3-38.9-51.8-75.5-117.1-101.9m-48.93 96c-12.03 35.4-15.18 67.4-9.37 96.1c32.35 23.5 59.1 38.1 115.5 25.9q-1.5-21.75 8.7-45.3c-54.9-16.7-89.29-44.9-114.83-76.7M231 247.2c-4.5 4.6-8.7 9.1-12.6 13.6c31.6 17.7 54.7 38.7 77.8 70.8l14.6-10.6c-23.4-32.4-48-55.2-79.8-73.8m-28.3 32.9c-3.8 5.2-7.2 10.3-10.1 15.3c8.6 3.6 21.7 10.7 33.8 19c15 10.4 28.9 23.4 33.6 32.6l16-8.4c-7.7-14.6-23.2-27.7-39.3-38.9c-11.9-8.3-23.8-15.1-34-19.6m-20.1 35.5c-2.5 6.2-4.1 12.2-5.1 17.9c4.7 1 12.9 4 20.8 9.1c10.7 7.1 21.5 17.3 27 26.1l15.2-9.6c-7.6-12-19.7-23.2-32.3-31.5c-8.6-5.6-17-10.4-25.6-12M52 352.1c15.4 34.5 46.57 64.6 95.2 89.5c11.6-1 20.9-2.1 28.7-5.9c6.9-3.4 13.3-9 19.9-20.9c-16.9-13.7-28.4-29.2-33.8-46.7c-49.2 10.4-81.56.9-110-16m370.1 72.6c-10.4-.1-43.1 4.2-75.8 11c-23.5 5-47.6 11.5-64.9 18c11.7-3.1 25.8-4.3 41.3-4.7c6.1-.2 12.5-.2 18.9-.1c19.2.4 39.2 2.2 57 4.8c12.3 1.9 23.4 3.9 32.9 6.5c6.9-8.3 12.4-18.2 12.9-24.1c.5-10.9-16.7-11.3-22.3-11.4m-211.7.7c-9.7 14.2-19.1 24.7-34.1 29.5c21.2 8.8 44.9 16.8 71.4 24c8.3-7.3 16-17.2 14.3-27.7c-19.8-7.8-37.1-16.3-51.6-25.8m63.3 50.9l-9.2 8.3c13.3 4 34.8 9 59.2 8.2c36.3-1.3 84.3 2.2 114.8-10.5c-48.8-12.3-126.4-27.6-164.8-6"/></symbol>

Before

Width:  |  Height:  |  Size: 445 KiB

After

Width:  |  Height:  |  Size: 448 KiB

Before After
Before After

View file

@ -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 { 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)); } .grid.wide { grid-template-columns: repeat(auto-fill, minmax(min(330px, 100%), 1fr)); }
.cols { display: grid; gap: 18px; grid-template-columns: 1fr 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 { 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 .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; } .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-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 { 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; } .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; }

View file

@ -3242,5 +3242,116 @@ class PartyTest(unittest.TestCase):
self.assertEqual(world["parties"], {}) 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__": if __name__ == "__main__":
unittest.main() unittest.main()