diff --git a/README.md b/README.md
index 6ef6752..6e808df 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,8 @@ Alpha. One season, bandit raids plus two friendly events, six buildings.
## What's in it
- **Skills:** mining, woodcutting, farming, hunting, fishing, smithing,
- masonry, cooking, leatherworking, combat, plus the scout's reconnaissance.
+ masonry, construction, cooking, leatherworking, herblore, combat, prayer and
+ reconnaissance.
- **Combat:** fights cost HP (less when out-levelled or armoured); below half
health you eat food from your bank, and out of food you retreat, losing
nothing but the fight. HP recovers 25% an hour.
@@ -21,8 +22,14 @@ Alpha. One season, bandit raids plus two friendly events, six buildings.
five tools; every leather tier a cap, trousers and jerkin. Higher bars need
coal; each metal past copper needs a Smithy level (rune needs level 5, so a
city). Gear needs a combat level to wear, tools a skill level to use.
-- **Classes:** warrior, scout, artisan, pioneer. Every class can train every
- skill; a class makes some skills 20% faster and has one signature ability.
+- **Classes are trades,** from gathering to finished goods, so each player has
+ a quiet gathering side and a busier crafting side: miner (mining, smithing),
+ builder (woodcutting, construction, masonry), homesteader (farming, cooking,
+ herblore) and ranger (hunting, fishing, leatherworking, cooking). A trade's
+ skills are 20% faster, 1 in 10 of its crafts make double, and each has one
+ bonus of its own. Every class can train every skill, scout and drill.
+ Building costs draw on all four trades: thatch, flax for rope, hide glue,
+ leather and provisions as well as wood, stone and metal.
- **Bank:** 12 slots of 1,000 each to start; more than a stack of one item
spills into another slot. When something doesn't fit it goes to the city
stockpile (or the action stops, if you prefer).
diff --git a/app.py b/app.py
index 38e094b..e7bb4bc 100644
--- a/app.py
+++ b/app.py
@@ -117,7 +117,7 @@ def player_view(world: dict, player: dict, now: float) -> dict:
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
+ view["recon_gain"] = {action["id"]: game.recon_gain(world, player, action) for action in game.ACTIONS
if action.get("special") == "recon"}
return view
@@ -361,6 +361,15 @@ class Handler(BaseHTTPRequestHandler):
return None
if path == "/api/favour":
return game.spend_favour(world, player, str(body.get("kind")), now)
+ if path == "/api/class":
+ cls = body.get("cls")
+ if not player.get("repick"):
+ return "You've already chosen your trade"
+ if cls not in game.CLASSES:
+ return "Pick a class"
+ player["cls"], player["repick"] = cls, False
+ game.log(world, now, "join", f"{player['name']} took up the {game.CLASSES[cls]['name'].lower()}'s trade", player["id"])
+ return None
if path == "/api/perk":
return game.buy_perk(player, str(body.get("perk")))
if path == "/api/settings":
diff --git a/game.py b/game.py
index bdeb2f7..657aec2 100644
--- a/game.py
+++ b/game.py
@@ -66,6 +66,13 @@ ITEMS = {
"dragon_bones": {"name": "Dragon bones", "icon": "๐ฆด", "value": 15},
"studded_body": {"name": "Studded jerkin", "icon": "๐งฅ", "value": 40, "slot": "body", "armour": 6,
"req": ["combat", 10]},
+ # Building materials from every trade: thatch and flax from the fields, rope
+ # twisted from flax, glue boiled from hides, and provisions to feed the builders.
+ "thatch": {"name": "Thatch", "icon": "๐พ", "value": 2},
+ "flax": {"name": "Flax", "icon": "๐ฟ", "value": 2},
+ "rope": {"name": "Rope", "icon": "๐ชข", "value": 8},
+ "hide_glue": {"name": "Hide glue", "icon": "๐ฏ", "value": 7},
+ "provisions": {"name": "Provisions", "icon": "๐", "value": 10},
}
# --- tier ladders -----------------------------------------------------------
@@ -280,35 +287,46 @@ SKILLS = [
{"id": "herblore", "name": "Herblore", "icon": "๐งช", "group": "Crafting"},
{"id": "combat", "name": "Combat", "icon": "โ๏ธ", "group": "Combat"},
{"id": "prayer", "name": "Prayer", "icon": "๐", "group": "Combat"},
- {"id": "reconnaissance", "name": "Reconnaissance", "icon": "๐ญ", "group": "Signature", "class": "scout"},
+ {"id": "reconnaissance", "name": "Reconnaissance", "icon": "๐ญ", "group": "Combat"},
]
SKILL_IDS = [skill["id"] for skill in SKILLS]
GATHERING = {"mining", "woodcutting", "farming", "hunting", "fishing"}
-CRAFTING = {"smithing", "masonry", "cooking", "leatherworking", "herblore"}
-SURVEYED = {"mining", "woodcutting", "farming"} # what the pioneer's double yield applies to
+CRAFTING = {"smithing", "masonry", "construction", "cooking", "leatherworking", "herblore"}
+# A class is a whole trade, from gathering to the finished goods: its skills are
+# 20% faster, 1 in 10 of its crafts make double, and it has one bonus of its own.
+# Nothing is locked: every class can train every skill.
CLASSES = {
- "warrior": {
- "name": "Warrior", "icon": "๐ก๏ธ", "fast": ["combat"],
- "bonus": "Combat actions 20% faster",
- "signature": "Command: counts for half again as much in the garrison",
+ "miner": {
+ "name": "Miner", "icon": "โ๏ธ", "fast": ["mining", "smithing"], "craft": ["smithing"],
+ "bonus": "Mining and smithing 20% faster",
+ "masterwork": "Masterwork: 1 in 10 smithing crafts make double",
+ "signature": "Prospector: 1 in 10 mining trips bring back double",
},
- "scout": {
- "name": "Scout", "icon": "๐น", "fast": ["hunting", "fishing", "reconnaissance"],
- "bonus": "Hunting and fishing 20% faster",
- "signature": "Reconnaissance: scout the roads to sharpen intel on what's coming",
+ "builder": {
+ "name": "Builder", "icon": "๐๏ธ", "fast": ["woodcutting", "construction", "masonry"],
+ "craft": ["construction", "masonry"],
+ "bonus": "Woodcutting, construction and masonry 20% faster",
+ "masterwork": "Masterwork: 1 in 10 construction and masonry crafts make double",
+ "signature": "Foreman: +1 labour every time you work a building site",
},
- "artisan": {
- "name": "Artisan", "icon": "โ๏ธ", "fast": ["smithing", "masonry", "cooking", "leatherworking"],
- "bonus": "Smithing, masonry, cooking and leatherworking 20% faster",
- "signature": "Masterwork: 1 in 10 crafts makes double",
+ "homesteader": {
+ "name": "Homesteader", "icon": "๐พ", "fast": ["farming", "cooking", "herblore"],
+ "craft": ["cooking", "herblore"],
+ "bonus": "Farming, cooking and herblore 20% faster",
+ "masterwork": "Masterwork: 1 in 10 cooking and herblore crafts make double",
+ "signature": "Steady hand: potions you drink last 25% longer",
},
- "pioneer": {
- "name": "Pioneer", "icon": "๐งญ", "fast": ["mining", "woodcutting", "farming", "construction"],
- "bonus": "Mining, woodcutting, farming and construction 20% faster",
- "signature": "Surveying: 1 in 10 gathers yields double",
+ "ranger": {
+ "name": "Ranger", "icon": "๐น", "fast": ["hunting", "fishing", "leatherworking", "cooking"],
+ "craft": ["leatherworking", "cooking"],
+ "bonus": "Hunting, fishing, leatherworking and cooking 20% faster",
+ "masterwork": "Masterwork: 1 in 10 leatherworking and cooking crafts make double",
+ "signature": "Pathfinder: your scouting gathers 50% more reconnaissance",
},
}
+# Classes from before the trades, as a starting point until their player re-picks.
+OLD_CLASSES = {"pioneer": "miner", "artisan": "builder", "scout": "ranger", "warrior": "builder"}
def _action(id, skill, name, level, time, xp, inputs=None, outputs=None, **extra):
@@ -384,6 +402,14 @@ ACTIONS = [
_action("lw_studded", "leatherworking", "Studded jerkin", 25, 50, 40,
{"leather": 4, "iron_bar": 1}, {"studded_body": 1}, group="Leather"),
_action("labour", "construction", "Work on the building site", 1, 30, 10, special="labour"),
+ _action("twist_rope", "construction", "Twist rope", 5, 30, 10, {"flax": 3}, {"rope": 1}, group="Supplies"),
+ _action("grow_thatch", "farming", "Cut thatch reeds", 3, 30, 7, outputs={"thatch": 3}),
+ _action("grow_flax", "farming", "Grow flax", 10, 35, 11, outputs={"flax": 3}),
+ _action("boil_glue", "leatherworking", "Boil hide glue", 5, 30, 10, {"hide": 1, "logs": 1}, {"hide_glue": 2},
+ group="Tanning"),
+ _action("pack_bread", "cooking", "Pack bread rations", 1, 20, 6, {"bread": 2}, {"provisions": 1}),
+ _action("pack_meat", "cooking", "Pack meat rations", 5, 20, 7, {"cooked_meat": 2}, {"provisions": 1}),
+ _action("pack_fish", "cooking", "Pack fish rations", 1, 20, 6, {"cooked_shrimp": 2}, {"provisions": 1}),
*[_action(f"saw_{plank}", "construction", f"Saw {name.lower()}s", TIER_LEVELS[tier], 25 + 3 * tier,
[6, 14, 22, 32, 44, 58][tier], {TREES[tier][0]: 2}, {plank: 1}, group="Planks")
for tier, (plank, name) in enumerate(PLANKS)],
@@ -431,62 +457,77 @@ ACTION_BY_ID = {action["id"]: action for action in ACTIONS}
BUILDINGS = {
"houses": {
"name": "Houses", "icon": "๐ ", "max": 8, "gold": 200,
- "base": {"logs": 600, "bricks": 300}, "advanced": {"oak_logs": 200},
- "late": {"willow_logs": 150, "oak_plank": 60}, "final": {"maple_logs": 100, "willow_plank": 60},
+ "base": {"logs": 300, "bricks": 150, "thatch": 300, "provisions": 80},
+ "advanced": {"oak_logs": 100, "leather": 80, "rope": 40, "iron_bar": 30},
+ "late": {"willow_logs": 80, "oak_plank": 35, "hide_glue": 50},
+ "final": {"maple_logs": 50, "willow_plank": 30, "hard_leather": 60},
"effect": "Room for 20 more people per level",
},
"walls": {
"name": "Walls", "icon": "๐ฐ", "max": 5, "gold": 300,
- "base": {"stone": 1200, "bricks": 500}, "advanced": {"cut_stone": 300, "beam": 40},
- "late": {"steel_bar": 60, "oak_plank": 40}, "final": {"mithril_bar": 40, "maple_plank": 40},
+ "base": {"stone": 1000, "bricks": 250, "provisions": 90},
+ "advanced": {"cut_stone": 150, "beam": 20, "rope": 50, "iron_bar": 40},
+ "late": {"steel_bar": 80, "oak_plank": 25, "hide_glue": 50},
+ "final": {"mithril_bar": 50, "maple_plank": 20, "hide_glue": 60},
"effect": "+20 defence per level, and raiders carry off less",
},
"barracks": {
"name": "Barracks", "icon": "โบ", "max": 5, "gold": 300,
- "base": {"logs": 600, "bricks": 400, "copper_sword": 20}, "advanced": {"iron_sword": 15},
- "late": {"steel_sword": 10, "oak_plank": 50}, "final": {"mithril_sword": 6, "willow_plank": 50},
+ "base": {"logs": 300, "bricks": 200, "thatch": 200, "leather": 80, "copper_sword": 25, "provisions": 80},
+ "advanced": {"iron_sword": 20, "hard_leather": 40},
+ "late": {"steel_sword": 12, "oak_plank": 30, "hide_glue": 40},
+ "final": {"mithril_sword": 8, "willow_plank": 25, "bear_leather": 40},
"effect": "Trains townsfolk as militia: more defence per citizen",
},
"smithy": {
"name": "Smithy", "icon": "โ๏ธ", "max": 5, "gold": 250,
- "base": {"stone": 500, "logs": 600, "copper_bar": 150}, "advanced": {"iron_bar": 150},
- "late": {"steel_bar": 80, "oak_plank": 40}, "final": {"mithril_bar": 60, "maple_plank": 40},
+ "base": {"stone": 400, "logs": 250, "copper_bar": 180, "provisions": 60},
+ "advanced": {"iron_bar": 180, "leather": 70},
+ "late": {"steel_bar": 100, "oak_plank": 25, "hide_glue": 40},
+ "final": {"mithril_bar": 70, "maple_plank": 20, "bear_leather": 35},
"effect": "Smithing 8% faster per level; each level unlocks a metal: iron, steel, mithril, adamant, rune",
},
"temple": {
"name": "Temple", "icon": "โช", "max": 3, "gold": 300,
- "base": {"stone": 800, "bricks": 400, "cut_stone": 100}, "advanced": {"beam": 20},
- "late": {"willow_logs": 150, "oak_plank": 60},
+ "base": {"stone": 700, "bricks": 200, "cut_stone": 60, "provisions": 60},
+ "advanced": {"beam": 15, "rope": 40, "iron_bar": 30},
+ "late": {"willow_logs": 80, "oak_plank": 35, "hide_glue": 40},
"effect": "Offerings ward the city in raids; +1 prayer point per bone buried and +25% devotion per level",
},
"granary": {
"name": "Granary", "icon": "๐พ", "max": 3, "gold": 150,
- "base": {"logs": 400, "bricks": 200, "plank": 60}, "advanced": {"cut_stone": 80},
- "late": {"oak_plank": 60},
+ "base": {"logs": 200, "bricks": 100, "plank": 40, "thatch": 300, "provisions": 50},
+ "advanced": {"cut_stone": 40, "rope": 40, "iron_bar": 20},
+ "late": {"oak_plank": 35, "hide_glue": 35},
"effect": "Protects a quarter of the food from locusts and rats per level",
},
"lumber_shed": {
"name": "Lumber shed", "icon": "๐ชต", "max": 3, "gold": 120,
- "base": {"logs": 300, "stone": 200, "plank": 80}, "advanced": {"oak_plank": 60},
- "late": {"willow_plank": 40},
+ "base": {"logs": 200, "stone": 250, "plank": 50, "thatch": 180, "provisions": 40},
+ "advanced": {"oak_plank": 40, "rope": 40, "iron_bar": 20},
+ "late": {"willow_plank": 25, "hide_glue": 35},
"effect": "Keeps a quarter of the stored wood safe from rot per level",
},
"well": {
"name": "Well", "icon": "๐ชฃ", "max": 3, "gold": 120,
- "base": {"stone": 500, "bricks": 150}, "advanced": {"cut_stone": 80},
- "late": {"oak_plank": 40},
+ "base": {"stone": 500, "bricks": 80, "rope": 50, "provisions": 40},
+ "advanced": {"cut_stone": 40, "leather": 60, "iron_bar": 25},
+ "late": {"oak_plank": 25, "hide_glue": 35},
"effect": "Each level cuts fire losses by a quarter and may save the building",
},
"spymaster": {
"name": "Spymaster's guild", "icon": "๐ต๏ธ", "max": 3, "gold": 400,
- "base": {"logs": 500, "bricks": 300, "hide": 100, "feathers": 300}, "advanced": {"cut_stone": 150},
- "late": {"bear_leather": 40, "oak_plank": 40},
+ "base": {"logs": 200, "bricks": 150, "hide": 120, "feathers": 300, "provisions": 60},
+ "advanced": {"cut_stone": 80, "hard_leather": 40, "iron_bar": 20},
+ "late": {"bear_leather": 50, "oak_plank": 25},
"effect": "+25% reconnaissance per level, and its agents gather some every hour on their own",
},
"storehouse": {
"name": "Storehouse", "icon": "๐ฆ", "max": 5, "gold": 200,
- "base": {"logs": 800, "stone": 800}, "advanced": {"oak_logs": 300, "cut_stone": 200},
- "late": {"willow_logs": 200, "oak_plank": 80}, "final": {"yew_logs": 80, "yew_plank": 40},
+ "base": {"logs": 400, "stone": 800, "thatch": 200, "provisions": 60},
+ "advanced": {"oak_logs": 150, "cut_stone": 100, "rope": 50, "iron_bar": 30},
+ "late": {"willow_logs": 120, "oak_plank": 45, "hide_glue": 45},
+ "final": {"yew_logs": 40, "yew_plank": 20, "bear_leather": 40},
"effect": "+3 bank slots and +250 per stack for everyone, per level",
},
}
@@ -523,7 +564,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows
# --- world ---------------------------------------------------------------
-WORLD_VERSION = 11
+WORLD_VERSION = 12
def new_world(now: float, speed: float = 1.0) -> dict:
@@ -633,6 +674,13 @@ def migrate(world: dict, now: float | None = None) -> dict:
if world["version"] < 11:
_migrate_potions(world)
world["version"] = 11
+ if world["version"] < 12:
+ # Classes became trades: everyone gets a stand-in and one free pick.
+ for player in world["players"].values():
+ player["cls"] = OLD_CLASSES.get(player["cls"], player["cls"])
+ player["repick"] = True
+ _return_overfunding(world)
+ world["version"] = 12
return world
@@ -656,6 +704,24 @@ def _migrate_potions(world: dict) -> None:
action[key] = f"brew_{OLD_POTIONS[old]}"
+def _return_overfunding(world: dict) -> None:
+ """After building costs change, anything a fund holds beyond its new target goes back to the stockpile."""
+ city = world["city"]
+ for key, building in city["buildings"].items():
+ target = fund_target(world, key) or {}
+ for item, qty in list(building["fund"].items()):
+ extra = qty - target.get(item, 0)
+ if extra <= 0:
+ continue
+ building["fund"][item] = qty - extra
+ if not building["fund"][item]:
+ del building["fund"][item]
+ if item == "gold":
+ city["treasury"] += extra
+ else:
+ city["stockpile"][item] = city["stockpile"].get(item, 0) + extra
+
+
def _sell_useless_stock(world: dict, now: float) -> None:
"""The council sells what the city can't use, into the treasury."""
city = world["city"]
@@ -1006,7 +1072,8 @@ def use_potion(player: dict, item: str) -> str | None:
bank_take(player, item, 1)
current = player["potions"].get(spec["skill"])
charges = current["charges"] if current and current["item"] == item else 0
- player["potions"][spec["skill"]] = {"item": item, "charges": charges + POTION_CHARGES}
+ fresh = round(POTION_CHARGES * (1.25 if player["cls"] == "homesteader" else 1))
+ player["potions"][spec["skill"]] = {"item": item, "charges": charges + fresh}
return None
@@ -1042,8 +1109,6 @@ def upcoming_event(world: dict) -> dict | None:
def can_start(world: dict, player: dict, action: dict) -> str | None:
"""Why the action can't run right now, or None."""
- if action["skill"] == "reconnaissance" and player["cls"] != "scout":
- return "Only scouts can do that"
if skill_level(player, action["skill"]) < action["level"]:
return f"Needs level {action['level']}"
for key, level in action.get("city", {}).items():
@@ -1086,9 +1151,9 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
for item, qty in action["inputs"].items():
bank_take(player, item, qty)
xp = action["xp"]
- lucky = (
- (player["cls"] == "artisan" and action["skill"] in CRAFTING)
- or (player["cls"] == "pioneer" and action["skill"] in SURVEYED)
+ cls = CLASSES[player["cls"]]
+ lucky = bool(action["outputs"]) and (
+ action["skill"] in cls["craft"] or (player["cls"] == "miner" and action["skill"] == "mining")
) and rng.random() < LUCKY_CHANCE
outputs = {item: qty * (2 if lucky else 1) for item, qty in action["outputs"].items()}
if lucky:
@@ -1155,7 +1220,7 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
# Once a threat is fully scouted, scouting only trains the skill (and keeps the informant's leads).
if event and event["recon"] < intel_costs(event)[-1]:
city = world["city"]
- gain = recon_gain(world, action)
+ gain = recon_gain(world, player, action)
# An informant's leads double the reconnaissance (and the XP) of whoever follows them up.
boost = min(city.get("recon_boost", 0), gain)
city["recon_boost"] = city.get("recon_boost", 0) - boost
@@ -1598,7 +1663,7 @@ def defence(world: dict) -> dict:
garrison = []
for player in world["players"].values():
if player["garrison"]:
- power = combat_power(player) * (1.5 if player["cls"] == "warrior" else 1)
+ power = combat_power(player)
garrison.append({"id": player["id"], "name": player["name"], "power": round(power, 1)})
parts["garrison"] += power
total = sum(parts.values())
@@ -1690,7 +1755,8 @@ def labour_needed(level: int, repair: bool) -> float:
def labour_per_action(player: dict) -> float:
- return 1 + skill_level(player, "construction") // 20 # one more labour every 20 levels
+ # One more labour every 20 levels, and one more for a builder.
+ return 1 + skill_level(player, "construction") // 20 + (player["cls"] == "builder")
def _finish_construction(world: dict, now: float) -> None:
@@ -1758,8 +1824,9 @@ SPYMASTER_AGENTS = 0.5 # recon per guild level per game hour, wi
INFORMANT_RECON = 30 # boosted recon per informant: doubled recon and XP for the scout who works it
-def recon_gain(world: dict, action: dict) -> float:
- return (action["recon"] + fitted(world, "map_table")) * (1 + SPYMASTER_RECON * building_level(world, "spymaster"))
+def recon_gain(world: dict, player: dict, action: dict) -> float:
+ gain = (action["recon"] + fitted(world, "map_table")) * (1 + SPYMASTER_RECON * building_level(world, "spymaster"))
+ return gain * (1.5 if player["cls"] == "ranger" else 1)
def intel_costs(event: dict) -> list[int]:
@@ -2635,9 +2702,7 @@ def favours(world: dict, now: float) -> dict:
"informant": {
"cost": round(INFORMANT_FAVOUR * (1 + 0.5 * (raid or {}).get("informants", 0))),
"why": "No news to dig into" if not raid else
- "Nothing more to learn" if raid["recon"] >= intel_costs(raid)[-1] else
- "No scout in the guild to follow up the leads"
- if not any(p["cls"] == "scout" for p in world["players"].values()) else None,
+ "Nothing more to learn" if raid["recon"] >= intel_costs(raid)[-1] else None,
"recon": INFORMANT_RECON,
"boost": round(city.get("recon_boost", 0)),
},
@@ -2728,6 +2793,8 @@ GLYPHS = {
"bones": ("broken-bone", "#e9dfc6"), "big_bones": ("crossed-bones", "#e9dfc6"),
"dragon_bones": ("dinosaur-bones", "#a6d98f"), "studded_body": ("leather-armor", "#a3825a"),
"coal": ("ore", "#5d626b"), "water_barrel": ("barrel", "#5aa0d8"),
+ "thatch": ("high-grass", "#d8c070"), "flax": ("flax", "#8fb0d8"), "rope": ("rope-coil", "#c9a66b"),
+ "hide_glue": ("honey-jar", "#c08a40"), "provisions": ("knapsack", "#a08050"),
"raw_shrimp": ("shrimp", "#f39a82"), "raw_trout": ("flatfish", "#9fb5c8"), "raw_salmon": ("salmon", "#f08a6a"),
"raw_eel": ("eel", "#7f9a6a"), "raw_lobster": ("crab", "#e05a3a"), "raw_swordfish": ("angler-fish", "#7fa3c8"),
"cooked_shrimp": ("shrimp", "#f0b07a"), "cooked_trout": ("fish-cooked", "#d9a060"),
@@ -2742,8 +2809,8 @@ SKILL_GLYPHS = {
"herblore": ("cauldron", "#7fbf5a"), "combat": ("crossed-swords", "#e05a5a"), "prayer": ("prayer", "#e9dfc6"),
"reconnaissance": ("spyglass", "#6f8fec"),
}
-CLASS_GLYPHS = {"warrior": ("black-knight-helm", "#c0c6d0"), "scout": ("bow-arrow", "#8fbf6a"),
- "artisan": ("anvil-impact", "#e0a050"), "pioneer": ("compass", "#4fb3d9")}
+CLASS_GLYPHS = {"miner": ("miner", "#d07a3c"), "builder": ("crane", "#d1a46a"),
+ "homesteader": ("farmer", "#8fbf6a"), "ranger": ("bow-arrow", "#5fae8a")}
BUILDING_GLYPHS = {
"houses": ("house", "#d0a060"), "walls": ("stone-wall", "#a8a29e"), "barracks": ("barracks-tent", "#c06a4a"),
"smithy": ("anvil", "#d07a3c"), "temple": ("temple-gate", "#e9dfc6"), "granary": ("granary", "#e3c060"),
diff --git a/static/app.js b/static/app.js
index 3309aa1..8ee5644 100644
--- a/static/app.js
+++ b/static/app.js
@@ -299,6 +299,7 @@ function eventWhen(ev, short = false) {
}
function pageHtml() {
+ if (S.me.repick) return repickPage();
if (page.startsWith("skill:")) {
const id = page.slice(6);
const skill = skillOf(id);
@@ -450,7 +451,7 @@ function combatPanel() {
Equipment
${slot("weapon", "weapon")}${slot("shield", "shield")}${slot("helmet", "helmet")}${slot("body", "body armour")}${slot("legs", "leg armour")}
-
Power ${me.power} (level + weapon + shield + half your armour) ยท armour ${me.armour} takes ${Math.round(me.reduction * 100)}% off the damage you take${me.cls === "warrior" ? " ยท as a warrior you count ร1.5 in the garrison" : ""}. Equip gear from the bank.
+
Power ${me.power} (level + weapon + shield + half your armour) ยท armour ${me.armour} takes ${Math.round(me.reduction * 100)}% off the damage you take. Equip gear from the bank.
`;
}
@@ -822,9 +823,9 @@ function council() {
${ev.range ? `Strength ${ev.strength ?? `${ev.range[0]}โ${ev.range[1]}`} ` : ""}
${ev.trait ? `Scouts say ${esc(ev.trait)} ` : ""}
${ev.camp ? `Their camp Located. Fighters can sabotage it from the combat page${ev.sabotage ? ` (weakened ${ev.sabotage}% so far)` : ""}. ` : ""}
- ${me.cls === "scout" || ev.recon ? `Reconnaissance ${reconText(ev)} ` : ""}
+ Reconnaissance ${reconText(ev)}
- ${ev.intel < 3 ? `Better intel comes from the spymaster's guild and scouts' reconnaissance, and the sharpest only as the threat draws near.
` : ""}`;
+ ${ev.intel < 3 ? `Better intel comes from the spymaster's guild and anyone's scouting (rangers are best at it), and the sharpest only as the threat draws near.
` : ""}`;
if (threat) {
const strength = ev.strength ?? (ev.range ? `${ev.range[0]}โ${ev.range[1]}` : "?");
html += `Our defence ${fmt(city.defence.total)}
vs Raiders ${strength}
`;
@@ -863,7 +864,7 @@ function favourSpends() {
};
return `
Spend favour on the city ยท you have ${FAVOUR} ${fmt(me.favour)}
${option("builders", "๐๏ธ", "Hire a master builder", f.builders.why ? esc(f.builders.why) + "." : `The next ${f.builders.labour} labour anyone puts into the site counts double, with double Construction XP${f.builders.boost ? ` (${fmt(f.builders.boost)} still to use)` : ""}. Costs more each time on the same site.`)}
- ${option("informant", "๐ต๏ธ", "Pay an informant", f.informant.why ? esc(f.informant.why) + "." : `Leads for the scouts: the next ${f.informant.recon} reconnaissance on the coming threat counts double, with double XP${f.informant.boost ? ` (${fmt(f.informant.boost)} still to use)` : ""}. Costs more each time for the same threat.`)}
+ ${option("informant", "๐ต๏ธ", "Pay an informant", f.informant.why ? esc(f.informant.why) + "." : `Leads to follow up: the next ${f.informant.recon} reconnaissance on the coming threat counts double, with double XP${f.informant.boost ? ` (${fmt(f.informant.boost)} still to use)` : ""}. Costs more each time for the same threat.`)}
${option("sergeant", "๐ฏ", "Hire a drill sergeant", f.sergeant.why ? esc(f.sergeant.why) + "." : `The next ${f.sergeant.drill} drill points fighters put into the militia count double, with double combat XP${f.sergeant.boost ? ` (${fmt(f.sergeant.boost)} still to use)` : ""}. Costs more each time for the same threat.`)}
`;
}
@@ -886,7 +887,7 @@ function raidPrep(ev) {
Garrison
${d.garrison.length ? d.garrison.map((g) => `${esc(g.name)} (${g.power})`).join(", ") : `Nobody has signed up yet. `}
${me.garrison ? `Leave the garrison `
- : `Stand guard (+${me.cls === "warrior" ? me.power * 1.5 : me.power}) `}
+ : `Stand guard (+${me.power}) `}
You can keep working while you're signed up. Victory earns the garrison favour and combat XP.
`;
}
@@ -932,7 +933,7 @@ function characterSheet(uid) {
const skills = C.skills.filter((s) => !s.class || s.class === p.cls);
const total = skills.reduce((sum, s) => sum + p.levels[s.id], 0);
const days = Math.max(1, Math.ceil((serverNow() - p.joined) / 86400));
- const guard = Math.round(p.power * (p.cls === "warrior" ? 1.5 : 1));
+ const guard = Math.round(p.power);
const gearSlot = (slot, label, empty) => {
const id = p.equip[slot];
@@ -983,6 +984,7 @@ function characterSheet(uid) {
Class
${cls.icon} ${cls.name}
${esc(cls.bonus)}.
+
${esc(cls.masterwork)}.
${esc(cls.signature)}.
Equipment
@@ -998,11 +1000,28 @@ function characterSheet(uid) {
// --- registration ---------------------------------------------------------
-function register() {
- const cards = Object.entries(C.classes).map(([id, c]) => `
+function classCards() {
+ return Object.entries(C.classes).map(([id, c]) => `
- ${c.icon} ${c.name} ${esc(c.bonus)}.
${esc(c.signature)}.
+ ${c.icon} ${c.name} ${esc(c.bonus)}.
${esc(c.masterwork)}.
${esc(c.signature)}.
`).join("");
+}
+
+// After the classes became trades, everyone picks once more. Nothing is lost.
+function repickPage() {
+ return `
+
Pick your trade
+
Classes are now whole trades, from gathering through to the finished goods, so you can play
+ the quiet gathering side and the busier crafting side yourself. You keep every level and item. This choice
+ is free, once.
+
${classCards()}
+
Take up this trade
+
Every class can still train every skill, scout and drill. A trade makes its skills faster and better.
+
`;
+}
+
+function register() {
+ const cards = classCards();
return `
A new city on the frontier
You're one of a handful of adventurers settling a young city at the edge of the kingdom.
@@ -1013,7 +1032,7 @@ function register() {
Enter the city
-
Every class can train every skill. Your class makes some things faster and gives you one thing only you can do.
+
Every class can train every skill. Your class is a trade: its skills are faster, its crafts sometimes make double, and it has one bonus of its own.
`;
}
@@ -1093,6 +1112,7 @@ document.addEventListener("click", (e) => {
}
if (d.overflow) return act("/api/settings", { overflow: d.overflow });
if (d.class) { pickedClass = d.class; render(); return; }
+ if ("repick" in d) return act("/api/class", { cls: pickedClass });
if (d.tab) { tabs[d.tab] = d.group; remember(`tab.${d.tab}`, d.group); render(); return; }
if (d.devAdvance) return act("/api/dev/advance", { hours: Number(d.devAdvance) });
if ("devEvent" in d) return act("/api/dev/event", {});
diff --git a/static/icons.svg b/static/icons.svg
index 042873b..a4a68c6 100644
--- a/static/icons.svg
+++ b/static/icons.svg
@@ -4,7 +4,6 @@
-
@@ -13,7 +12,6 @@
-
@@ -31,9 +29,9 @@
-
+
@@ -41,12 +39,14 @@
+
+
@@ -56,17 +56,21 @@
+
+
+
+
@@ -76,6 +80,7 @@
+
diff --git a/tests/test_game.py b/tests/test_game.py
index 5244e5f..c07cf6a 100644
--- a/tests/test_game.py
+++ b/tests/test_game.py
@@ -8,6 +8,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import game # noqa: E402
T0 = 1_800_000_000.0
+# A test-only class with no bonuses, so tests of the rules aren't skewed by a trade.
+game.CLASSES.setdefault("commoner", {"name": "Commoner", "icon": "", "fast": [], "craft": [],
+ "bonus": "", "masterwork": "", "signature": "", "glyph": "farmer", "tint": ""})
def world_with(*players):
@@ -28,7 +31,7 @@ class XpTest(unittest.TestCase):
class ActionTest(unittest.TestCase):
def test_gathering_runs_while_away(self):
- world = world_with(("fred", "warrior"))
+ world = world_with(("fred", "commoner"))
p = world["players"]["fred"]
self.assertIsNone(game.start_action(world, p, "mine_stone", T0))
summary = game.process_player(world, p, T0 + 200, random.Random(1))
@@ -38,7 +41,7 @@ class ActionTest(unittest.TestCase):
self.assertEqual(p["action"]["start"], T0 + 200)
def test_make_one_then_back_to_work(self):
- world = world_with(("fred", "warrior"))
+ world = world_with(("fred", "commoner"))
p = world["players"]["fred"]
p["bank"] = {"copper_bar": 10, "logs": 10}
game.start_action(world, p, "mine_stone", T0)
@@ -52,7 +55,7 @@ class ActionTest(unittest.TestCase):
self.assertEqual(p["bank"]["stone"], 5 + 2) # mining carried on from when the pickaxe was done
def test_make_one_while_idle_just_stops(self):
- world = world_with(("fred", "warrior"))
+ world = world_with(("fred", "commoner"))
p = world["players"]["fred"]
game.start_action(world, p, "cut_logs", T0, once=True)
game.process_player(world, p, T0 + 500, random.Random(1))
@@ -61,7 +64,7 @@ class ActionTest(unittest.TestCase):
self.assertIsNone(p["stopped"])
def test_class_speed_and_lucky_yield(self):
- world = world_with(("pia", "pioneer"))
+ world = world_with(("pia", "miner"))
p = world["players"]["pia"]
game.start_action(world, p, "mine_stone", T0)
summary = game.process_player(world, p, T0 + 1600, random.Random(3))
@@ -70,7 +73,7 @@ class ActionTest(unittest.TestCase):
self.assertEqual(p["bank"]["stone"], 100 + summary["lucky"])
def test_offline_cap(self):
- world = world_with(("fred", "warrior"))
+ world = world_with(("fred", "commoner"))
p = world["players"]["fred"]
game.start_action(world, p, "cut_logs", T0)
summary = game.process_player(world, p, T0 + 3 * game.DAY, random.Random(1))
@@ -78,7 +81,7 @@ class ActionTest(unittest.TestCase):
self.assertEqual(p["action"]["start"], T0 + 3 * game.DAY)
def test_crafting_stops_when_inputs_run_out(self):
- world = world_with(("ann", "warrior"))
+ world = world_with(("ann", "commoner"))
p = world["players"]["ann"]
p["bank"] = {"copper_ore": 3, "logs": 10}
game.start_action(world, p, "smith_copper_bar", T0)
@@ -89,16 +92,16 @@ class ActionTest(unittest.TestCase):
self.assertEqual(summary["stopped"], "Out of copper ore")
def test_level_and_city_requirements(self):
- world = world_with(("ann", "warrior"))
+ world = world_with(("ann", "commoner"))
p = world["players"]["ann"]
self.assertEqual(game.start_action(world, p, "mine_iron", T0), "Needs level 15")
p["xp"]["smithing"] = game.XP_TABLE[30]
p["bank"] = {"iron_bar": 10}
self.assertEqual(game.start_action(world, p, "smith_iron_sword", T0), "Needs Smithy level 1")
- self.assertEqual(game.start_action(world, p, "recon_roads", T0), "Only scouts can do that")
+ self.assertIsNone(game.can_start(world, p, game.ACTION_BY_ID["recon_roads"])) # anyone can scout
def test_full_bank_overflows_to_city(self):
- world = world_with(("fred", "warrior"))
+ world = world_with(("fred", "commoner"))
p = world["players"]["fred"]
p["bank"] = {"stone": game.BASE_STACK * game.BASE_SLOTS - 2}
game.start_action(world, p, "mine_stone", T0)
@@ -110,7 +113,7 @@ class ActionTest(unittest.TestCase):
self.assertEqual(p["donated"], 3)
def test_full_bank_sells_what_the_city_cannot_use(self):
- world = world_with(("fred", "warrior"))
+ world = world_with(("fred", "commoner"))
p = world["players"]["fred"]
p["bank"] = {"copper_ore": game.BASE_STACK * game.BASE_SLOTS - 1}
game.start_action(world, p, "mine_copper", T0)
@@ -121,7 +124,7 @@ class ActionTest(unittest.TestCase):
self.assertEqual(p["favour"], 0)
def test_only_useful_things_can_be_donated(self):
- world = world_with(("fred", "warrior"))
+ world = world_with(("fred", "commoner"))
p = world["players"]["fred"]
p["bank"] = {"copper_ore": 10, "bricks": 10, "bread": 5, "iron_helmet": 1, "steel_sword": 1}
self.assertEqual(game.donate(world, p, "copper_ore", 10, T0), "The city has no use for that")
@@ -132,7 +135,7 @@ class ActionTest(unittest.TestCase):
self.assertEqual(game.city_uses("bread"), ["the granary"])
def test_a_full_stack_spills_into_another_slot(self):
- world = world_with(("fred", "warrior"))
+ world = world_with(("fred", "commoner"))
p = world["players"]["fred"]
p["bank"] = {"coal": game.BASE_STACK, "logs": 10}
self.assertEqual(game.slots_used(world, p), 2)
@@ -143,7 +146,7 @@ class ActionTest(unittest.TestCase):
self.assertEqual(game.bank_room(world, p, "coal"), 500 + (game.BASE_SLOTS - 4) * game.BASE_STACK)
def test_full_bank_can_stop_instead(self):
- world = world_with(("fred", "warrior"))
+ world = world_with(("fred", "commoner"))
p = world["players"]["fred"]
p["overflow"] = "stop"
p["bank"] = {f"x{i}": 1 for i in range(game.BASE_SLOTS)}
@@ -153,11 +156,11 @@ class ActionTest(unittest.TestCase):
self.assertNotIn("stone", p["bank"])
def test_combat_pays_and_loses(self):
- world = world_with(("wil", "warrior"))
+ world = world_with(("wil", "commoner"))
p = world["players"]["wil"]
p["bank"] = {"bread": 500}
game.start_action(world, p, "fight_wolves", T0)
- summary = game.process_player(world, p, T0 + 48 * 100, random.Random(5))
+ summary = game.process_player(world, p, T0 + 60 * 100, random.Random(5))
self.assertEqual(summary["done"], 100)
self.assertEqual(summary["wins"] + summary["losses"], 100)
self.assertEqual(p["gold"], summary["wins"] * 2)
@@ -165,7 +168,7 @@ class ActionTest(unittest.TestCase):
class GiftTest(unittest.TestCase):
def test_give_moves_items_within_bank_room(self):
- world = world_with(("fred", "pioneer"), ("kay", "artisan"))
+ world = world_with(("fred", "miner"), ("kay", "miner"))
fred, kay = world["players"]["fred"], world["players"]["kay"]
fred["bank"] = {"copper_ore": 300}
self.assertIsNone(game.give(world, fred, kay, "copper_ore", 200, T0))
@@ -184,17 +187,19 @@ class GiftTest(unittest.TestCase):
class CityTest(unittest.TestCase):
def test_fund_from_bank_and_stores_then_builds(self):
- world = world_with(("fred", "warrior"))
+ world = world_with(("fred", "commoner"))
p = world["players"]["fred"]
city = world["city"]
p["bank"] = {"stone": 1500}
- city["stockpile"] = {"bricks": 600}
+ city["stockpile"] = {"bricks": 600, "provisions": 90}
city["treasury"] = 300
self.assertIsNone(game.fund(world, p, "walls", "stone", "all", "bank", T0))
- self.assertEqual(p["bank"], {"stone": 300}) # only what the fund needed
- self.assertEqual(p["favour"], 1200)
+ self.assertEqual(p["bank"], {"stone": 500}) # only what the fund needed
+ self.assertEqual(p["favour"], 1000)
self.assertIsNone(game.fund(world, p, "walls", "bricks", 500, "city", T0))
- self.assertEqual(city["stockpile"], {"bricks": 100})
+ self.assertEqual(city["stockpile"], {"bricks": 350, "provisions": 90})
+ self.assertIsNone(game.fund(world, p, "walls", "provisions", "all", "city", T0))
+ self.assertEqual(city["stockpile"], {"bricks": 350})
self.assertIsNone(city["construction"])
self.assertEqual(game.fund(world, p, "walls", "stone", 1, "bank", T0), "The fund doesn't need that")
self.assertIsNone(game.fund(world, p, "walls", "gold", "all", "city", T0))
@@ -207,7 +212,7 @@ class CityTest(unittest.TestCase):
self.assertEqual(game.defence(world)["parts"]["walls"], 20)
def test_funded_buildings_queue_behind_the_builders(self):
- world = world_with(("fred", "warrior"))
+ world = world_with(("fred", "commoner"))
p = world["players"]["fred"]
city = world["city"]
for key in ("walls", "houses"):
@@ -222,7 +227,7 @@ class CityTest(unittest.TestCase):
self.assertEqual(city["queue"], [])
def test_raids_never_touch_funds(self):
- world = world_with(("fred", "warrior"))
+ world = world_with(("fred", "commoner"))
city = world["city"]
city["buildings"]["walls"]["fund"] = {"stone": 1000}
city["stockpile"] = {"stone": 1000}
@@ -235,7 +240,7 @@ class CityTest(unittest.TestCase):
self.assertEqual(city["buildings"]["walls"]["fund"], {"stone": 1000})
def test_tier_caps_levels_and_promotes_for_good(self):
- world = world_with(("fred", "warrior"))
+ world = world_with(("fred", "commoner"))
city = world["city"]
city["buildings"]["walls"]["level"] = 1
self.assertIsNone(game.fund_target(world, "walls")) # hamlet: level 1 only
@@ -252,7 +257,7 @@ class CityTest(unittest.TestCase):
self.assertEqual(city["tier"], 1)
def test_damage_turns_the_fund_towards_repairs_and_keeps_the_rest(self):
- world = world_with(("fred", "warrior"))
+ world = world_with(("fred", "commoner"))
p = world["players"]["fred"]
city = world["city"]
city["buildings"]["walls"].update(level=1, damaged=True, fund={"stone": 2000})
@@ -336,7 +341,7 @@ class CityTest(unittest.TestCase):
def test_speed_scales_time(self):
world = game.new_world(T0, speed=60)
- world["players"]["fred"] = game.new_player("fred", "Fred", "warrior", T0)
+ world["players"]["fred"] = game.new_player("fred", "Fred", "commoner", T0)
p = world["players"]["fred"]
game.start_action(world, p, "mine_stone", T0)
game.process_player(world, p, T0 + 10, random.Random(1))
@@ -361,7 +366,7 @@ class EventTest(unittest.TestCase):
self.assertGreaterEqual(view["latest"], raid["fire_at"])
def test_recon_sharpens_intel_as_the_raid_draws_near(self):
- world = world_with(("sam", "scout"))
+ world = world_with(("sam", "ranger"))
raid = self.announce(world)
p = world["players"]["sam"]
p["xp"]["reconnaissance"] = game.XP_TABLE[20]
@@ -382,14 +387,14 @@ class EventTest(unittest.TestCase):
self.assertTrue(view["camp"])
def test_scouting_stops_counting_once_the_threat_is_fully_scouted(self):
- world = world_with(("sam", "scout"))
+ world = world_with(("sam", "commoner"))
raid = self.announce(world)
full = game.intel_costs(raid)[-1]
raid["recon"] = full - 1
world["city"]["recon_boost"] = 30
p = world["players"]["sam"]
game.start_action(world, p, "recon_roads", T0)
- game.process_player(world, p, T0 + 48 * 3 + 1, random.Random(1))
+ game.process_player(world, p, T0 + 60 * 3 + 1, random.Random(1))
self.assertEqual(raid["recon"], full)
self.assertEqual(world["city"]["recon_boost"], 29) # leads only spent while there's something to learn
self.assertIsNotNone(p["action"]) # still trains the skill
@@ -404,17 +409,17 @@ class EventTest(unittest.TestCase):
self.assertEqual(game.intel_costs(dragon), [8, 22, 45])
def test_the_spymasters_guild_boosts_and_gathers_recon(self):
- world = world_with()
+ world = world_with(("sam", "commoner"))
raid = self.announce(world)
- action = game.ACTION_BY_ID["recon_roads"]
- self.assertEqual(game.recon_gain(world, action), 1)
+ action, p = game.ACTION_BY_ID["recon_roads"], world["players"]["sam"]
+ self.assertEqual(game.recon_gain(world, p, action), 1)
world["city"]["buildings"]["spymaster"]["level"] = 2
- self.assertEqual(game.recon_gain(world, action), 1.5)
+ self.assertEqual(game.recon_gain(world, p, action), 1.5)
game.tick(world, T0 + 2 * game.HOUR, random.Random(2))
self.assertAlmostEqual(raid["recon"], 2.0)
def test_victory_rewards_garrison(self):
- world = world_with(("wil", "warrior"))
+ world = world_with(("wil", "commoner"))
raid = self.announce(world)
p = world["players"]["wil"]
p["xp"]["combat"] = game.XP_TABLE[60]
@@ -427,7 +432,7 @@ class EventTest(unittest.TestCase):
self.assertFalse(p["garrison"])
def test_defeat_culls_and_loots_but_spares_banks(self):
- world = world_with(("fred", "warrior"))
+ world = world_with(("fred", "commoner"))
p = world["players"]["fred"]
p["bank"] = {"stone": 500}
city = world["city"]
@@ -453,7 +458,7 @@ class EventTest(unittest.TestCase):
self.assertLess(qty, full[item])
def test_sabotage_needs_a_located_camp(self):
- world = world_with(("wil", "warrior"))
+ world = world_with(("wil", "commoner"))
p = world["players"]["wil"]
p["xp"]["combat"] = game.XP_TABLE[40]
p["hp"] = game.max_hp(p)
@@ -471,7 +476,7 @@ class EventTest(unittest.TestCase):
class CombatTest(unittest.TestCase):
def fighter(self, level=10, **equip):
- world = world_with(("wil", "warrior"))
+ world = world_with(("wil", "commoner"))
p = world["players"]["wil"]
p["xp"]["combat"] = game.XP_TABLE[level]
p["hp"] = game.max_hp(p)
@@ -524,7 +529,7 @@ class CombatTest(unittest.TestCase):
class NewSkillsTest(unittest.TestCase):
def test_tools_speed_up_gathering(self):
- world = world_with(("pia", "warrior"))
+ world = world_with(("pia", "commoner"))
p = world["players"]["pia"]
plain = game.action_time(world, p, game.ACTION_BY_ID["mine_stone"])
p["equip"]["tool_mining"] = "iron_pickaxe"
@@ -532,19 +537,19 @@ class NewSkillsTest(unittest.TestCase):
self.assertEqual(game.action_time(world, p, game.ACTION_BY_ID["cut_logs"]), plain)
def test_fish_to_plate(self):
- world = world_with(("sam", "scout"))
+ world = world_with(("sam", "ranger"))
p = world["players"]["sam"]
game.start_action(world, p, "fish_shrimp", T0)
game.process_player(world, p, T0 + 200, random.Random(1))
self.assertEqual(p["bank"]["raw_shrimp"], 10) # 20s with the scout bonus
p["bank"]["logs"] = 10
game.start_action(world, p, "cook_shrimp", T0 + 200)
- game.process_player(world, p, T0 + 1000, random.Random(1))
- self.assertEqual(p["bank"]["cooked_shrimp"], 10)
+ summary = game.process_player(world, p, T0 + 1000, random.Random(1))
+ self.assertEqual(p["bank"]["cooked_shrimp"], 10 + summary["lucky"]) # rangers cook their catch, sometimes double
self.assertIn("cooked_shrimp", game.FOOD_ORDER)
def test_hides_to_armour(self):
- world = world_with(("kay", "artisan"))
+ world = world_with(("kay", "miner"))
p = world["players"]["kay"]
p["bank"] = {"hide": 4}
game.start_action(world, p, "tan_leather", T0)
@@ -554,7 +559,7 @@ class NewSkillsTest(unittest.TestCase):
self.assertIsNone(game.start_action(world, p, "lw_body", T0 + 1000))
def test_v2_worlds_gain_hp_and_slots(self):
- world = world_with(("fred", "pioneer"))
+ world = world_with(("fred", "miner"))
p = world["players"]["fred"]
world["version"] = 2
for key in ("hp", "hp_at", "food"):
@@ -572,7 +577,7 @@ class NewSkillsTest(unittest.TestCase):
class FavourTest(unittest.TestCase):
def setUp(self):
- self.world = world_with(("fred", "pioneer"))
+ self.world = world_with(("fred", "miner"))
self.p = self.world["players"]["fred"]
self.p["favour"] = 5000
@@ -585,7 +590,7 @@ class FavourTest(unittest.TestCase):
self.assertEqual(game.favours(world, T0)["builders"]["cost"], 225)
self.assertEqual(world["city"]["construction"]["labour"], 0) # nothing is built for you
game.start_action(world, p, "labour", T0)
- summary = game.process_player(world, p, T0 + 24 * 60, random.Random(1)) # pioneer: 24s an action
+ summary = game.process_player(world, p, T0 + 30 * 60, random.Random(1)) # 30s an action
self.assertEqual(summary["done"], 60)
self.assertEqual(summary["labour"], 60 + 50) # the first 50 labour counted double
self.assertEqual(world["city"]["site_boost"], 0)
@@ -610,13 +615,12 @@ class FavourTest(unittest.TestCase):
world["next_event_at"] = T0
game.tick(world, T0, random.Random(2))
event = game.upcoming_raid(world)
- self.assertEqual(game.spend_favour(world, p, "informant", T0), "No scout in the guild to follow up the leads")
- world["players"]["sam"] = scout = game.new_player("sam", "Sam", "scout", T0)
+ world["players"]["sam"] = scout = game.new_player("sam", "Sam", "commoner", T0)
self.assertIsNone(game.spend_favour(world, p, "informant", T0))
self.assertEqual(event["recon"], 0) # leads, not intel
self.assertEqual(game.favours(world, T0)["informant"]["cost"], 375)
game.start_action(world, scout, "recon_roads", T0)
- game.process_player(world, scout, T0 + 48 * 10 + 1, random.Random(1)) # ten scouting runs
+ game.process_player(world, scout, T0 + 60 * 10 + 1, random.Random(1)) # ten scouting runs
self.assertEqual(event["recon"], 20)
self.assertEqual(scout["xp"]["reconnaissance"], 240)
self.assertEqual(world["city"]["recon_boost"], 20)
@@ -675,7 +679,7 @@ class FavourTest(unittest.TestCase):
class TierTest(unittest.TestCase):
def test_steel_needs_coal_and_the_smithy(self):
- world = world_with(("kay", "artisan"))
+ world = world_with(("kay", "miner"))
p = world["players"]["kay"]
p["xp"]["smithing"] = game.XP_TABLE[30]
p["bank"] = {"iron_ore": 5, "coal": 10}
@@ -697,7 +701,7 @@ class TierTest(unittest.TestCase):
self.assertEqual(game.ITEMS["rune_body"]["plural"], "Rune cuirasses")
def test_gear_needs_levels_to_wear(self):
- world = world_with(("wil", "warrior"))
+ world = world_with(("wil", "commoner"))
p = world["players"]["wil"]
self.assertEqual(game.equip_problem(p, "rune_sword"), "Needs combat level 50")
self.assertEqual(game.equip_problem(p, "steel_pickaxe"), "Needs mining level 20")
@@ -715,12 +719,12 @@ class TierTest(unittest.TestCase):
def test_higher_building_levels_need_higher_tiers(self):
self.assertNotIn("steel_bar", game.build_cost("walls", 2))
- self.assertEqual(game.build_cost("walls", 3)["steel_bar"], 60)
- self.assertEqual(game.build_cost("walls", 5)["mithril_bar"], 40)
- self.assertEqual(game.build_cost("walls", 5)["steel_bar"], 180)
+ self.assertEqual(game.build_cost("walls", 3)["steel_bar"], 80)
+ self.assertEqual(game.build_cost("walls", 5)["mithril_bar"], 50)
+ self.assertEqual(game.build_cost("walls", 5)["steel_bar"], 240)
def test_dragons_drop_dragonhide(self):
- world = world_with(("wil", "warrior"))
+ world = world_with(("wil", "commoner"))
p = world["players"]["wil"]
p["xp"]["combat"] = game.XP_TABLE[80]
p["equip"].update(weapon="rune_sword", shield="rune_shield", helmet="rune_helmet", body="rune_body", legs="rune_legs")
@@ -734,7 +738,7 @@ class TierTest(unittest.TestCase):
class PrayerTest(unittest.TestCase):
def priest(self, level=1, combat=10):
- world = world_with(("pat", "warrior"))
+ world = world_with(("pat", "commoner"))
p = world["players"]["pat"]
p["xp"]["prayer"] = game.XP_TABLE[level]
p["xp"]["combat"] = game.XP_TABLE[combat]
@@ -802,7 +806,7 @@ class PrayerTest(unittest.TestCase):
self.assertEqual(game.start_action(world, p, "offer_bones", T0), "Needs Temple level 1")
def test_v4_worlds_get_prayer(self):
- world = world_with(("fred", "pioneer"))
+ world = world_with(("fred", "miner"))
world["version"] = 4
p = world["players"]["fred"]
del p["pp"], p["prayers"], p["xp"]["prayer"]
@@ -815,7 +819,7 @@ class PrayerTest(unittest.TestCase):
class HerbloreTest(unittest.TestCase):
def setUp(self):
- self.world = world_with(("hal", "warrior"))
+ self.world = world_with(("hal", "commoner"))
self.p = self.world["players"]["hal"]
def test_herbs_to_potion(self):
@@ -901,7 +905,7 @@ class HerbloreTest(unittest.TestCase):
self.assertIn(action, game.ACTION_BY_ID)
def test_v5_worlds_get_herblore(self):
- world = world_with(("fred", "pioneer"))
+ world = world_with(("fred", "miner"))
world["version"] = 5
p = world["players"]["fred"]
del p["potions"], p["xp"]["herblore"]
@@ -912,7 +916,7 @@ class HerbloreTest(unittest.TestCase):
class ConstructionTest(unittest.TestCase):
def site(self):
- world = world_with(("bob", "pioneer"), ("ann", "warrior"))
+ world = world_with(("bob", "miner"), ("ann", "commoner"))
world["city"]["construction"] = {"building": "walls", "repair": False, "start": T0, "labour": 0.0, "needed": 360}
world["next_event_at"] = None
return world
@@ -950,7 +954,7 @@ class ConstructionTest(unittest.TestCase):
self.assertAlmostEqual(game.action_time(world, p, game.ACTION_BY_ID["labour"]), plain * 0.7)
def test_no_site_no_work(self):
- world = world_with(("ann", "warrior"))
+ world = world_with(("ann", "commoner"))
self.assertEqual(game.start_action(world, world["players"]["ann"], "labour", T0), "Nothing is being built")
def test_planks_and_hammers_exist_for_every_tier(self):
@@ -961,7 +965,7 @@ class ConstructionTest(unittest.TestCase):
self.assertIn("oak_plank", game.build_cost("houses", 3))
def test_v6_timer_becomes_labour(self):
- world = world_with(("fred", "pioneer"))
+ world = world_with(("fred", "miner"))
world["version"] = 6
p = world["players"]["fred"]
del p["xp"]["construction"], p["equip"]["tool_construction"]
@@ -977,7 +981,7 @@ class ConstructionTest(unittest.TestCase):
class HappeningsTest(unittest.TestCase):
def setUp(self):
- self.world = world_with(("ann", "warrior"), ("sam", "scout"))
+ self.world = world_with(("ann", "commoner"), ("sam", "ranger"))
self.world["next_event_at"] = None
for track in ("minor", "hardship", "fortune"):
self.world[f"next_{track}_at"] = None
@@ -1156,7 +1160,7 @@ class HappeningsTest(unittest.TestCase):
class WearTest(unittest.TestCase):
def setUp(self):
- self.world = world_with(("fred", "warrior"))
+ self.world = world_with(("fred", "commoner"))
self.city = self.world["city"]
self.city["population"] = 40.0 # 20 defenders
@@ -1186,9 +1190,65 @@ class WearTest(unittest.TestCase):
self.assertIn("broken in the fighting", text)
+class ClassTest(unittest.TestCase):
+ def lucky_run(self, cls, action_id, bank):
+ world = world_with(("pat", cls))
+ p = world["players"]["pat"]
+ p["xp"] = {skill: game.XP_TABLE[50] for skill in p["xp"]}
+ p["bank"] = dict(bank)
+ game.start_action(world, p, action_id, T0)
+ return p, game.process_player(world, p, T0 + 3 * game.HOUR, random.Random(3))
+
+ def test_every_trade_sometimes_crafts_double(self):
+ for cls, action, bank in (("miner", "smith_copper_bar", {"copper_ore": 300, "logs": 300}),
+ ("builder", "saw_plank", {"logs": 600}),
+ ("homesteader", "cook_bread", {"wheat": 600, "logs": 300}),
+ ("ranger", "cook_shrimp", {"raw_shrimp": 300, "logs": 300})):
+ _p, summary = self.lucky_run(cls, action, bank)
+ self.assertGreater(summary["lucky"], 0, cls)
+ _p, summary = self.lucky_run("commoner", action, bank)
+ self.assertEqual(summary["lucky"], 0, cls)
+
+ def test_trade_bonuses(self):
+ _p, summary = self.lucky_run("miner", "mine_stone", {})
+ self.assertGreater(summary["lucky"], 0) # prospector
+ _p, summary = self.lucky_run("builder", "mine_stone", {})
+ self.assertEqual(summary["lucky"], 0)
+ builder = game.new_player("b", "B", "builder", T0)
+ self.assertEqual(game.labour_per_action(builder), 2) # foreman
+ home = game.new_player("h", "H", "homesteader", T0)
+ home["bank"] = {"mining_potion_1": 1}
+ game.use_potion(home, "mining_potion_1")
+ self.assertEqual(home["potions"]["mining"]["charges"], 62) # steady hand
+ world = world_with()
+ ranger = game.new_player("r", "R", "ranger", T0)
+ self.assertEqual(game.recon_gain(world, ranger, game.ACTION_BY_ID["recon_roads"]), 1.5) # pathfinder
+ self.assertEqual(game.action_time(world, ranger, game.ACTION_BY_ID["cook_shrimp"]),
+ game.ACTION_BY_ID["cook_shrimp"]["time"] * game.CLASS_SPEED)
+
+ def test_old_classes_become_trades_with_a_free_pick(self):
+ world = world_with()
+ world["version"] = 11
+ for uid, cls in (("a", "pioneer"), ("b", "artisan"), ("c", "scout"), ("d", "warrior")):
+ world["players"][uid] = game.new_player(uid, uid, "miner", T0)
+ world["players"][uid]["cls"] = cls
+ game.migrate(world, T0)
+ self.assertEqual([p["cls"] for p in world["players"].values()], ["miner", "builder", "ranger", "builder"])
+ self.assertTrue(all(p["repick"] for p in world["players"].values()))
+
+ def test_overfunded_buildings_return_the_extra(self):
+ world = world_with()
+ world["version"] = 11
+ world["city"]["buildings"]["houses"]["fund"] = {"bricks": 400, "logs": 100}
+ world["city"]["stockpile"] = {}
+ game.migrate(world, T0)
+ self.assertEqual(world["city"]["buildings"]["houses"]["fund"], {"bricks": 150, "logs": 100})
+ self.assertEqual(world["city"]["stockpile"], {"bricks": 250})
+
+
class FittingTest(unittest.TestCase):
def setUp(self):
- self.world = world_with(("fred", "artisan"))
+ self.world = world_with(("fred", "miner"))
self.p = self.world["players"]["fred"]
self.buildings = self.world["city"]["buildings"]