diff --git a/README.md b/README.md index 4a1c1d5..665a22a 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,11 @@ Alpha. One season, bandit raids plus two friendly events, six buildings. replaced from the bank automatically. - **Events:** raids are announced a day or two ahead. Reconnaissance from scouts, informants and the spymaster's guild narrows down when, how strong, - and finally where their camp is, so fighters can sabotage it. Each intel + and finally where their camp is, so fighters can sabotage it. Fighters can + also drill the militia before any raid, up to half a defence per + townsperson. Favour never buys results directly: master builders, + informants and drill sergeants double what players put in for a while, + XP included. Each intel level takes more scouting than the last and more for a bigger band, and levels 2 and 3 only open about a day and eight hours before the raid arrives. Defeat kills or drives diff --git a/app.py b/app.py index 64bb279..38e094b 100644 --- a/app.py +++ b/app.py @@ -102,7 +102,7 @@ def player_view(world: dict, player: dict, now: float) -> dict: view["blessing"] = game.blessing(player) blessed = game.with_potion(view["blessing"], game.potion_effect(player, "combat")) view["fight_damage"] = {a["id"]: round(game.fight_damage(player, a, blessed), 1) - for a in game.ACTIONS if a["skill"] == "combat"} + for a in game.ACTIONS if "difficulty" in a} view["pp"] = int(player["pp"]) view["labour_per_action"] = game.labour_per_action(player) view["levels"] = {skill: game.skill_level(player, skill) for skill in game.SKILL_IDS} @@ -116,6 +116,7 @@ def player_view(world: dict, player: dict, now: float) -> dict: view["action"] = {**player["action"], "duration": game.action_time(world, player, action)} view["times"] = {action["id"]: game.action_time(world, player, action) for action in game.ACTIONS} view["blocked"] = {action["id"]: game.can_start(world, player, action) for action in game.ACTIONS} + view["drill_per_action"] = game.drill_per_action(player) view["recon_gain"] = {action["id"]: game.recon_gain(world, action) for action in game.ACTIONS if action.get("special") == "recon"} return view diff --git a/game.py b/game.py index 6091021..9618f2b 100644 --- a/game.py +++ b/game.py @@ -371,6 +371,7 @@ ACTIONS = [ _action("cook_meat", "cooking", "Cooked meat", 5, 20, 7, {"raw_meat": 1, "logs": 1}, {"cooked_meat": 1}), _action("cook_stew", "cooking", "Stew", 15, 35, 16, {"raw_meat": 1, "vegetables": 2, "logs": 1}, {"stew": 2}), + _action("drill_militia", "combat", "Drill the militia", 1, 50, 10, special="drill"), _action("fight_wolves", "combat", "Wolves in the woods", 1, 60, 14, difficulty=2, damage=2, gold=2, drops=[["raw_meat", 1, 0.6], ["hide", 1, 0.5], ["bones", 1, 1.0]]), _action("fight_scouts", "combat", "Bandit scouts", 10, 75, 30, difficulty=14, damage=6, gold=10, @@ -1016,7 +1017,13 @@ def can_start(world: dict, player: dict, action: dict) -> str | None: blocked = skill_mods(world, action)["blocked"] if blocked: return blocked - if action["skill"] == "combat" and player["hp"] < max_hp(player) * TOO_HURT and not edible(player): + if action.get("special") == "drill": + threat = upcoming_raid(world) + if not threat: + return "No raid to prepare for" + if threat.get("drilled", 0) >= drill_cap(world): + return "The militia is as drilled as it can be" + if "difficulty" in action and player["hp"] < max_hp(player) * TOO_HURT and not edible(player): return "Too hurt to fight. Rest, or bring food" return None @@ -1047,7 +1054,7 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R outputs[item] = int(exact) + (1 if rng.random() < exact - int(exact) else 0) xp *= mods["xp"] - if action["skill"] == "combat": + if "difficulty" in action: blessed = blessing(player) if player["prayers"] and not blessed["cost"]: summary["prayer_out"] = True @@ -1105,6 +1112,15 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R event["recon"] += gain + boost if action.get("special") == "problem" and action["skill"] != "combat": _work_problem(world, player, action, now) + threat = upcoming_raid(world) if action.get("special") == "drill" else None + if threat: + city = world["city"] + gain = drill_per_action(player) + # A drill sergeant doubles the drilling (and the XP) of whoever does it while the boost lasts. + boost = min(city.get("drill_boost", 0), gain) + city["drill_boost"] = city.get("drill_boost", 0) - boost + xp = DRILL_XP * (gain + boost) * mods["xp"] + threat["drilled"] = min(drill_cap(world), threat.get("drilled", 0) + gain + boost) if action.get("special") == "labour": city = world["city"] site = city["construction"] @@ -1125,7 +1141,8 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R if action.get("special") == "offer": city = world["city"] city["devotion"] = city.get("devotion", 0) + action["devotion"] * (1 + 0.25 * building_level(world, "temple")) * bonus - _use_charge(player, action["skill"], summary) + if action.get("special") != "drill": + _use_charge(player, action["skill"], summary) for item, qty in outputs.items(): kept = bank_add(world, player, item, qty, now, summary) @@ -1514,7 +1531,8 @@ def defence(world: dict) -> dict: "weapons": round(0.35 * best_kit(stock, "weapon", "power", armed), 1), "shields": round(0.5 * best_kit(stock, "shield", "armour", armed), 1), "arrows": min(100, stock.get("arrows", 0) // 10), - "rallied": city.get("rally", 0), + "drilled": round(drilled(world), 1), + "rallied": city.get("rally", 0), # favour bought before drilling existed; gone after the next raid "ward": ward(world), "mercenaries": city.get("hired", 0), "garrison": 0, @@ -2492,8 +2510,26 @@ def happenings_view(world: dict, now: float) -> dict: MASTER_BUILDER_LABOUR = 50 # boosted labour per hire: doubled labour and XP for whoever works it MASTER_BUILDER_FAVOUR = 150 # +50% for every further hire on the same site INFORMANT_FAVOUR = 250 -RALLY_FAVOUR = 150 -RALLY_DEFENCE = 10 +# Drilling: fighters train the townsfolk before a raid, a little defence per +# session (more for a seasoned fighter), up to half the population. +DRILL_PER_ACTION = 0.5 # plus as much again every 20 combat levels +DRILL_XP = 20 # combat XP per point of defence drilled +DRILL_SHARE = 0.5 # most drilled defence: this much per townsperson +DRILL_SERGEANT_POINTS = 15 # boosted drilling per hire: doubled defence and XP +DRILL_SERGEANT_FAVOUR = 150 # +50% for every further hire against the same threat + + +def drill_per_action(player: dict) -> float: + return DRILL_PER_ACTION * (1 + skill_level(player, "combat") // 20) + + +def drill_cap(world: dict) -> float: + return round(DRILL_SHARE * world["city"]["population"], 1) + + +def drilled(world: dict) -> float: + threat = upcoming_raid(world) + return min(drill_cap(world), threat.get("drilled", 0)) if threat else 0 def favours(world: dict, now: float) -> dict: @@ -2517,10 +2553,13 @@ def favours(world: dict, now: float) -> dict: "recon": INFORMANT_RECON, "boost": round(city.get("recon_boost", 0)), }, - "militia": { - "cost": RALLY_FAVOUR * (city.get("rallies", 0) + 1), - "why": None if raid else "No raid to prepare for", - "rallied": city.get("rally", 0), + "sergeant": { + "cost": round(DRILL_SERGEANT_FAVOUR * (1 + 0.5 * (raid or {}).get("sergeants", 0))), + "why": "No raid to prepare for" if not raid else + "The militia is as drilled as it can be" if drilled(world) >= drill_cap(world) else None, + "drill": DRILL_SERGEANT_POINTS, + "boost": round(city.get("drill_boost", 0), 1), + "drilled": round(drilled(world), 1), "cap": drill_cap(world), }, } @@ -2550,9 +2589,11 @@ def spend_favour(world: dict, player: dict, kind: str, now: float) -> str | None log(world, now, "favour", f"{player['name']} paid an informant: the next {INFORMANT_RECON} " f"reconnaissance counts double", player["id"]) else: - city["rally"] = city.get("rally", 0) + RALLY_DEFENCE - city["rallies"] = city.get("rallies", 0) + 1 - log(world, now, "favour", f"{player['name']} rallied the militia (+{RALLY_DEFENCE} defence for the coming raid)", player["id"]) + raid = upcoming_raid(world) + raid["sergeants"] = raid.get("sergeants", 0) + 1 + city["drill_boost"] = city.get("drill_boost", 0) + DRILL_SERGEANT_POINTS + log(world, now, "favour", f"{player['name']} hired a drill sergeant: the next {DRILL_SERGEANT_POINTS} " + f"drilled defence counts double", player["id"]) return None @@ -2625,7 +2666,7 @@ ACTION_GLYPHS = { "fight_ruins": ("castle-ruins", "#a8a29e"), "fight_stronghold": ("castle", "#8a8f98"), "fight_green_dragon": ("dragon-head", "#5fae5a"), "fight_blue_dragon": ("dragon-head", "#4f86d6"), "fight_red_dragon": ("dragon-head", "#d0503c"), "fight_black_dragon": ("dragon-head", "#8a8f98"), - "sabotage": ("fire-bomb", "#e0703a"), "recon_roads": ("spyglass", "#6f8fec"), "recon_camps": ("spyglass", "#8a7fd0"), + "sabotage": ("fire-bomb", "#e0703a"), "drill_militia": ("sword-brandish", "#e0a050"), "recon_roads": ("spyglass", "#6f8fec"), "recon_camps": ("spyglass", "#8a7fd0"), "labour": ("hammer-nails", "#d1a46a"), "clear_wolves": ("wolf-head", "#9aa1ab"), "catch_goblins": ("goblin-head", "#8fbf6a"), "drive_bear": ("bear-head", "#8a5a32"), "cull_boars": ("boar", "#a06a4a"), "catch_rats": ("rat", "#9aa1ab"), "clear_rockslide": ("falling-rocks", "#a8a29e"), diff --git a/static/app.js b/static/app.js index 14b848c..4033dae 100644 --- a/static/app.js +++ b/static/app.js @@ -487,12 +487,17 @@ function actionCard(a) { } io += Object.entries(a.outputs).map(([id, q]) => itemChip(id, q)).join(""); let extra = ""; - if (a.skill === "combat") { + if ("difficulty" in a) { const chance = Math.min(0.97, Math.max(0.15, 0.5 + (me.power - a.difficulty) * 0.04)); extra = `
${FAVOUR} ${fmt(me.favour)} favour · ${fmt(me.donated)} given in total
-Favour can also hire builders, pay informants and rally the militia: see .
Favour can also hire master builders, informants and drill sergeants: see .
`; } // --- town ----------------------------------------------------------------- @@ -857,13 +862,13 @@ function favourSpends() { return `Arrows, swords and shields in the stockpile arm the defenders: one sword and shield each, best first. Every fight uses up arrows and breaks some of the gear, more when it's close. Food keeps them fighting.
| ${names[k]} | ${fmt(v)} |
| ${names[k]} | ${fmt(v)} |
| Hungry (×0.8) |
${d.garrison.length ? d.garrison.map((g) => `${esc(g.name)} (${g.power})`).join(", ") : `Nobody has signed up yet.`}
diff --git a/static/icons.svg b/static/icons.svg index ccccf1e..0e9b35b 100644 --- a/static/icons.svg +++ b/static/icons.svg @@ -91,6 +91,7 @@