Combat HP, food and retreat; armour, leatherworking, fishing, tools
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3716df7493
commit
d2991ebf7a
6 changed files with 381 additions and 44 deletions
10
README.md
10
README.md
|
|
@ -9,8 +9,14 @@ Alpha. One season, bandit raids plus two friendly events, six buildings.
|
|||
|
||||
## What's in it
|
||||
|
||||
- **Skills:** mining, woodcutting, farming, hunting, smithing, masonry,
|
||||
cooking, combat, plus the scout's reconnaissance.
|
||||
- **Skills:** mining, woodcutting, farming, hunting, fishing, smithing,
|
||||
masonry, cooking, leatherworking, combat, plus the scout's 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.
|
||||
- **Gear and tools:** smiths make weapons, copper and iron armour, and tools
|
||||
(pickaxe, axe, hoe, fishing rod, hunting knife) that make gathering 15–30%
|
||||
faster; leatherworkers turn hides into leather armour.
|
||||
- **Classes:** warrior, scout, artisan, pioneer. Every class can train every
|
||||
skill; a class makes some skills 20% faster and has one signature ability.
|
||||
- **Bank:** 12 slots of up to 1,000 each to start. When something doesn't
|
||||
|
|
|
|||
20
app.py
20
app.py
|
|
@ -91,7 +91,14 @@ class Store:
|
|||
|
||||
|
||||
def player_view(world: dict, player: dict, now: float) -> dict:
|
||||
view = {key: value for key, value in player.items() if key != "last_active"}
|
||||
game.regen(world, player, now)
|
||||
view = {key: value for key, value in player.items() if key not in ("last_active", "hp_at")}
|
||||
view["hp"] = int(player["hp"])
|
||||
view["max_hp"] = game.max_hp(player)
|
||||
view["armour"] = game.armour(player)
|
||||
view["reduction"] = round(game.damage_reduction(player), 3)
|
||||
view["edible"] = game.edible(player)
|
||||
view["fight_damage"] = {a["id"]: round(game.fight_damage(player, a), 1) for a in game.ACTIONS if a["skill"] == "combat"}
|
||||
view["levels"] = {skill: game.skill_level(player, skill) for skill in game.SKILL_IDS}
|
||||
view["slots"] = game.bank_slots(world, player)
|
||||
view["stack"] = game.stack_size(world)
|
||||
|
|
@ -224,6 +231,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||
summary = None
|
||||
if player:
|
||||
summary = game.process_player(world, player, now, self.store.rng)
|
||||
game.regen(world, player, now)
|
||||
player["last_active"] = now
|
||||
self.store.save()
|
||||
self.send_json(self.state(uid, summary))
|
||||
|
|
@ -253,6 +261,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||
return self.fail("Create your adventurer first", HTTPStatus.CONFLICT)
|
||||
else:
|
||||
summary = game.process_player(world, player, now, self.store.rng)
|
||||
game.regen(world, player, now)
|
||||
player["last_active"] = now
|
||||
error = self.player_route(path, player, body, now)
|
||||
if error is None:
|
||||
|
|
@ -306,6 +315,12 @@ class Handler(BaseHTTPRequestHandler):
|
|||
return "There's no raid to stand guard for"
|
||||
player["garrison"] = bool(body.get("join"))
|
||||
return None
|
||||
if path == "/api/food":
|
||||
food = body.get("item")
|
||||
if food is not None and "heal" not in game.ITEMS.get(str(food), {}):
|
||||
return "That isn't food"
|
||||
player["food"] = food
|
||||
return None
|
||||
if path == "/api/perk":
|
||||
return game.buy_perk(player, str(body.get("perk")))
|
||||
if path == "/api/settings":
|
||||
|
|
@ -341,6 +356,9 @@ class Handler(BaseHTTPRequestHandler):
|
|||
game.bank_take(player, item, 1)
|
||||
current = player["equip"][spec["slot"]]
|
||||
if current:
|
||||
if game.bank_room(self.store.world, player, current) < 1:
|
||||
player["bank"][item] = player["bank"].get(item, 0) + 1
|
||||
return "No room in the bank for what you're wearing"
|
||||
player["bank"][current] = player["bank"].get(current, 0) + 1
|
||||
player["equip"][spec["slot"]] = item
|
||||
return None
|
||||
|
|
|
|||
218
game.py
218
game.py
|
|
@ -20,6 +20,9 @@ CLASS_SPEED = 0.8
|
|||
LUCKY_CHANCE = 0.1
|
||||
RECON_PER_INTEL = 8
|
||||
MAX_LOG = 200
|
||||
REGEN_PER_HOUR = 0.25 # share of max HP recovered per game hour
|
||||
EAT_BELOW = 0.5 # auto-eat when HP drops under this share of max
|
||||
TOO_HURT = 0.3 # won't start a fight below this share without food
|
||||
|
||||
|
||||
def _xp_table(max_level: int = 99) -> list[int]:
|
||||
|
|
@ -63,26 +66,62 @@ ITEMS = {
|
|||
"bricks": {"name": "Bricks", "icon": "🧱", "value": 3},
|
||||
"cut_stone": {"name": "Cut stone", "icon": "🔷", "value": 5},
|
||||
"beam": {"name": "Reinforced beam", "plural": "Reinforced beams", "icon": "🪜", "value": 25},
|
||||
"bread": {"name": "Bread", "icon": "🍞", "value": 4, "food": 1},
|
||||
"cooked_meat": {"name": "Cooked meat", "icon": "🍖", "value": 5, "food": 1},
|
||||
"stew": {"name": "Stew", "icon": "🍲", "value": 10, "food": 2},
|
||||
# `food` feeds that many townsfolk for two days; `heal` restores HP in a fight.
|
||||
"bread": {"name": "Bread", "icon": "🍞", "value": 4, "food": 1, "heal": 8},
|
||||
"cooked_meat": {"name": "Cooked meat", "icon": "🍖", "value": 5, "food": 1, "heal": 12},
|
||||
"stew": {"name": "Stew", "icon": "🍲", "value": 10, "food": 2, "heal": 20},
|
||||
"raw_shrimp": {"name": "Raw shrimp", "icon": "🦐", "value": 1},
|
||||
"raw_trout": {"name": "Raw trout", "icon": "🐟", "value": 3},
|
||||
"raw_salmon": {"name": "Raw salmon", "icon": "🐠", "value": 6},
|
||||
"raw_eel": {"name": "Raw eel", "icon": "🐍", "value": 10},
|
||||
"cooked_shrimp": {"name": "Cooked shrimp", "icon": "🍤", "value": 4, "food": 1, "heal": 10},
|
||||
"cooked_trout": {"name": "Grilled trout", "icon": "🍢", "value": 8, "food": 1, "heal": 18},
|
||||
"cooked_salmon": {"name": "Baked salmon", "icon": "🍣", "value": 14, "food": 2, "heal": 28},
|
||||
"smoked_eel": {"name": "Smoked eel", "icon": "🥓", "value": 22, "food": 2, "heal": 40},
|
||||
"leather": {"name": "Leather", "icon": "🪢", "value": 5},
|
||||
# `armour` lowers the damage a fight deals; `power` adds to fighting strength.
|
||||
"leather_cap": {"name": "Leather cap", "icon": "🧢", "value": 12, "slot": "helmet", "armour": 1},
|
||||
"leather_legs": {"name": "Leather trousers", "plural": "Leather trousers", "icon": "👖", "value": 18, "slot": "legs", "armour": 2},
|
||||
"leather_body": {"name": "Leather jerkin", "icon": "🦺", "value": 24, "slot": "body", "armour": 3},
|
||||
"studded_body": {"name": "Studded jerkin", "icon": "🧥", "value": 40, "slot": "body", "armour": 6},
|
||||
"copper_helmet": {"name": "Copper helmet", "icon": "⛑️", "value": 12, "slot": "helmet", "armour": 2},
|
||||
"copper_legs": {"name": "Copper greaves", "plural": "Copper greaves", "icon": "🦵", "value": 22, "slot": "legs", "armour": 3},
|
||||
"copper_body": {"name": "Copper cuirass", "icon": "🥋", "value": 28, "slot": "body", "armour": 4},
|
||||
"iron_helmet": {"name": "Iron helmet", "icon": "🪖", "value": 25, "slot": "helmet", "armour": 4},
|
||||
"iron_legs": {"name": "Iron greaves", "plural": "Iron greaves", "icon": "🦿", "value": 45, "slot": "legs", "armour": 6},
|
||||
"iron_body": {"name": "Iron cuirass", "icon": "🦾", "value": 55, "slot": "body", "armour": 8},
|
||||
}
|
||||
FOOD_ORDER = ("bread", "cooked_meat", "stew")
|
||||
# Gathering tools: one slot per skill, `speed` cuts the action time.
|
||||
TOOLS = {"mining": ("pickaxe", "⛏️"), "woodcutting": ("axe", "🪓"), "farming": ("hoe", ""),
|
||||
"fishing": ("fishing rod", "🎣"), "hunting": ("hunting knife", "🔪")}
|
||||
for _skill, (_tool, _icon) in TOOLS.items():
|
||||
for _metal, _value, _speed in (("copper", 14, 0.15), ("iron", 30, 0.3)):
|
||||
ITEMS[f"{_metal}_{_tool.replace(' ', '_')}"] = {
|
||||
"name": f"{_metal.title()} {_tool}", "icon": _icon, "value": _value,
|
||||
"slot": f"tool_{_skill}", "tool": _skill, "speed": _speed,
|
||||
}
|
||||
ITEMS["iron_shield"]["armour"] = 4
|
||||
# What the city eats first: plain food before the good stuff.
|
||||
FOOD_ORDER = ("bread", "cooked_meat", "cooked_shrimp", "cooked_trout", "stew", "cooked_salmon", "smoked_eel")
|
||||
EQUIP_SLOTS = ["weapon", "shield", "helmet", "body", "legs"] + [f"tool_{skill}" for skill in TOOLS]
|
||||
|
||||
SKILLS = [
|
||||
{"id": "mining", "name": "Mining", "icon": "⛏️", "group": "Gathering"},
|
||||
{"id": "woodcutting", "name": "Woodcutting", "icon": "🪓", "group": "Gathering"},
|
||||
{"id": "farming", "name": "Farming", "icon": "🌱", "group": "Gathering"},
|
||||
{"id": "hunting", "name": "Hunting", "icon": "🦌", "group": "Gathering"},
|
||||
{"id": "fishing", "name": "Fishing", "icon": "🎣", "group": "Gathering"},
|
||||
{"id": "smithing", "name": "Smithing", "icon": "🔨", "group": "Crafting"},
|
||||
{"id": "masonry", "name": "Masonry", "icon": "🧱", "group": "Crafting"},
|
||||
{"id": "cooking", "name": "Cooking", "icon": "🍳", "group": "Crafting"},
|
||||
{"id": "leatherworking", "name": "Leatherworking", "icon": "🪡", "group": "Crafting"},
|
||||
{"id": "combat", "name": "Combat", "icon": "⚔️", "group": "Combat"},
|
||||
{"id": "reconnaissance", "name": "Reconnaissance", "icon": "🔭", "group": "Signature", "class": "scout"},
|
||||
]
|
||||
SKILL_IDS = [skill["id"] for skill in SKILLS]
|
||||
GATHERING = {"mining", "woodcutting", "farming", "hunting"}
|
||||
CRAFTING = {"smithing", "masonry", "cooking"}
|
||||
GATHERING = {"mining", "woodcutting", "farming", "hunting", "fishing"}
|
||||
CRAFTING = {"smithing", "masonry", "cooking", "leatherworking"}
|
||||
SURVEYED = {"mining", "woodcutting", "farming"} # what the pioneer's double yield applies to
|
||||
|
||||
CLASSES = {
|
||||
"warrior": {
|
||||
|
|
@ -91,13 +130,13 @@ CLASSES = {
|
|||
"signature": "Command: counts for half again as much in the garrison",
|
||||
},
|
||||
"scout": {
|
||||
"name": "Scout", "icon": "🏹", "fast": ["hunting", "reconnaissance"],
|
||||
"bonus": "Hunting 20% faster",
|
||||
"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",
|
||||
},
|
||||
"artisan": {
|
||||
"name": "Artisan", "icon": "⚒️", "fast": ["smithing", "masonry", "cooking"],
|
||||
"bonus": "Smithing, masonry and cooking 20% faster",
|
||||
"name": "Artisan", "icon": "⚒️", "fast": ["smithing", "masonry", "cooking", "leatherworking"],
|
||||
"bonus": "Smithing, masonry, cooking and leatherworking 20% faster",
|
||||
"signature": "Masterwork: 1 in 10 crafts makes double",
|
||||
},
|
||||
"pioneer": {
|
||||
|
|
@ -123,18 +162,45 @@ ACTIONS = [
|
|||
_action("farm_veg", "farming", "Grow vegetables", 12, 35, 13, outputs={"vegetables": 2}),
|
||||
_action("hunt_birds", "hunting", "Snare birds", 1, 25, 6, outputs={"feathers": 3}),
|
||||
_action("hunt_deer", "hunting", "Hunt deer", 8, 40, 13, outputs={"raw_meat": 2, "hide": 1}),
|
||||
_action("fish_shrimp", "fishing", "Net shrimp", 1, 25, 6, outputs={"raw_shrimp": 1}),
|
||||
_action("fish_trout", "fishing", "Fly-fish trout", 15, 35, 15, outputs={"raw_trout": 1}),
|
||||
_action("fish_salmon", "fishing", "Catch salmon", 30, 45, 28, outputs={"raw_salmon": 1}),
|
||||
_action("fish_eel", "fishing", "Trap eels", 45, 55, 45, outputs={"raw_eel": 1}),
|
||||
_action("smith_copper_bar", "smithing", "Copper bar", 1, 20, 6,
|
||||
{"copper_ore": 1, "logs": 1}, {"copper_bar": 1}),
|
||||
_action("smith_arrows", "smithing", "Arrows", 5, 30, 10,
|
||||
{"copper_bar": 1, "logs": 1, "feathers": 2}, {"arrows": 15}),
|
||||
_action("smith_copper_sword", "smithing", "Copper sword", 8, 40, 18,
|
||||
{"copper_bar": 3}, {"copper_sword": 1}),
|
||||
{"copper_ore": 1, "logs": 1}, {"copper_bar": 1}, group="Bars"),
|
||||
_action("smith_iron_bar", "smithing", "Iron bar", 15, 30, 14,
|
||||
{"iron_ore": 1, "logs": 1}, {"iron_bar": 1}),
|
||||
{"iron_ore": 1, "logs": 1}, {"iron_bar": 1}, group="Bars"),
|
||||
_action("smith_copper_sword", "smithing", "Copper sword", 8, 40, 18,
|
||||
{"copper_bar": 3}, {"copper_sword": 1}, group="Weapons"),
|
||||
_action("smith_iron_sword", "smithing", "Iron sword", 20, 45, 32,
|
||||
{"iron_bar": 2}, {"iron_sword": 1}, city={"smithy": 1}),
|
||||
{"iron_bar": 2}, {"iron_sword": 1}, city={"smithy": 1}, group="Weapons"),
|
||||
_action("smith_iron_shield", "smithing", "Iron shield", 25, 50, 40,
|
||||
{"iron_bar": 3}, {"iron_shield": 1}, city={"smithy": 2}),
|
||||
{"iron_bar": 3}, {"iron_shield": 1}, city={"smithy": 2}, group="Weapons"),
|
||||
_action("smith_copper_helmet", "smithing", "Copper helmet", 6, 35, 14,
|
||||
{"copper_bar": 2}, {"copper_helmet": 1}, group="Armour"),
|
||||
_action("smith_copper_legs", "smithing", "Copper greaves", 10, 40, 20,
|
||||
{"copper_bar": 4}, {"copper_legs": 1}, group="Armour"),
|
||||
_action("smith_copper_body", "smithing", "Copper cuirass", 12, 45, 26,
|
||||
{"copper_bar": 5}, {"copper_body": 1}, group="Armour"),
|
||||
_action("smith_iron_helmet", "smithing", "Iron helmet", 22, 40, 34,
|
||||
{"iron_bar": 2}, {"iron_helmet": 1}, city={"smithy": 1}, group="Armour"),
|
||||
_action("smith_iron_legs", "smithing", "Iron greaves", 27, 45, 46,
|
||||
{"iron_bar": 4}, {"iron_legs": 1}, city={"smithy": 1}, group="Armour"),
|
||||
_action("smith_iron_body", "smithing", "Iron cuirass", 30, 50, 56,
|
||||
{"iron_bar": 5}, {"iron_body": 1}, city={"smithy": 2}, group="Armour"),
|
||||
*[_action(f"smith_{metal}_{tool.replace(' ', '_')}", "smithing", f"{metal.title()} {tool}", level, time, xp,
|
||||
{f"{metal}_bar": 2, wood: 1}, {f"{metal}_{tool.replace(' ', '_')}": 1}, group="Tools",
|
||||
**({"city": {"smithy": 1}} if metal == "iron" else {}))
|
||||
for metal, level, time, xp, wood in (("copper", 4, 30, 12, "logs"), ("iron", 20, 40, 30, "oak_logs"))
|
||||
for tool, _icon in TOOLS.values()],
|
||||
_action("smith_arrows", "smithing", "Arrows", 5, 30, 10,
|
||||
{"copper_bar": 1, "logs": 1, "feathers": 2}, {"arrows": 15}, group="Supplies"),
|
||||
_action("tan_leather", "leatherworking", "Tan leather", 1, 25, 6, {"hide": 1}, {"leather": 1}),
|
||||
_action("lw_cap", "leatherworking", "Leather cap", 3, 30, 10, {"leather": 2}, {"leather_cap": 1}),
|
||||
_action("lw_legs", "leatherworking", "Leather trousers", 8, 35, 16, {"leather": 3}, {"leather_legs": 1}),
|
||||
_action("lw_body", "leatherworking", "Leather jerkin", 12, 40, 22, {"leather": 4}, {"leather_body": 1}),
|
||||
_action("lw_studded", "leatherworking", "Studded jerkin", 25, 50, 40,
|
||||
{"leather": 4, "iron_bar": 1}, {"studded_body": 1}),
|
||||
_action("mason_bricks", "masonry", "Bricks", 1, 25, 6, {"stone": 2, "logs": 1}, {"bricks": 2}),
|
||||
_action("mason_cut_stone", "masonry", "Cut stone", 10, 30, 12, {"stone": 3}, {"cut_stone": 1}),
|
||||
_action("mason_beam", "masonry", "Reinforced beam", 20, 45, 30,
|
||||
|
|
@ -143,15 +209,19 @@ 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("fight_wolves", "combat", "Wolves in the woods", 1, 60, 14, difficulty=2, gold=2,
|
||||
_action("cook_shrimp", "cooking", "Cooked shrimp", 1, 20, 6, {"raw_shrimp": 1, "logs": 1}, {"cooked_shrimp": 1}),
|
||||
_action("cook_trout", "cooking", "Grilled trout", 15, 30, 15, {"raw_trout": 1, "logs": 1}, {"cooked_trout": 1}),
|
||||
_action("cook_salmon", "cooking", "Baked salmon", 30, 35, 27, {"raw_salmon": 1, "logs": 1}, {"cooked_salmon": 1}),
|
||||
_action("cook_eel", "cooking", "Smoked eel", 45, 40, 42, {"raw_eel": 1, "logs": 1}, {"smoked_eel": 1}),
|
||||
_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]]),
|
||||
_action("fight_scouts", "combat", "Bandit scouts", 10, 75, 30, difficulty=14, gold=10,
|
||||
_action("fight_scouts", "combat", "Bandit scouts", 10, 75, 30, difficulty=14, damage=6, gold=10,
|
||||
drops=[["copper_bar", 1, 0.3], ["arrows", 5, 0.4]]),
|
||||
_action("fight_ruins", "combat", "Watchtower ruins", 25, 90, 60, difficulty=30, gold=25,
|
||||
_action("fight_ruins", "combat", "Watchtower ruins", 25, 90, 60, difficulty=30, damage=14, gold=25,
|
||||
drops=[["iron_bar", 1, 0.4], ["iron_sword", 1, 0.03]]),
|
||||
_action("fight_stronghold", "combat", "Bandit stronghold", 40, 105, 95, difficulty=50, gold=60,
|
||||
_action("fight_stronghold", "combat", "Bandit stronghold", 40, 105, 95, difficulty=50, damage=25, gold=60,
|
||||
drops=[["iron_bar", 2, 0.5], ["iron_shield", 1, 0.04]]),
|
||||
_action("sabotage", "combat", "Sabotage the bandit camp", 10, 120, 35, difficulty=12,
|
||||
_action("sabotage", "combat", "Sabotage the bandit camp", 10, 120, 35, difficulty=12, damage=10,
|
||||
special="sabotage"),
|
||||
_action("recon_roads", "reconnaissance", "Scout the roads", 1, 60, 12, special="recon", recon=1),
|
||||
_action("recon_camps", "reconnaissance", "Shadow the war bands", 15, 90, 28, special="recon", recon=2),
|
||||
|
|
@ -214,7 +284,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows
|
|||
|
||||
# --- world ---------------------------------------------------------------
|
||||
|
||||
WORLD_VERSION = 2
|
||||
WORLD_VERSION = 3
|
||||
|
||||
|
||||
def new_world(now: float, speed: float = 1.0) -> dict:
|
||||
|
|
@ -253,6 +323,17 @@ def migrate(world: dict) -> dict:
|
|||
city.setdefault("queue", [])
|
||||
city.setdefault("tier", 0)
|
||||
world["version"] = 2
|
||||
if world["version"] < 3:
|
||||
now = world["city"]["last_tick"]
|
||||
for player in world["players"].values():
|
||||
for skill in SKILL_IDS:
|
||||
player["xp"].setdefault(skill, 0)
|
||||
for slot in EQUIP_SLOTS:
|
||||
player["equip"].setdefault(slot, None)
|
||||
player.setdefault("food", None)
|
||||
player.setdefault("hp", max_hp(player))
|
||||
player.setdefault("hp_at", now)
|
||||
world["version"] = 3
|
||||
return world
|
||||
|
||||
|
||||
|
|
@ -329,7 +410,9 @@ def new_player(uid: str, name: str, cls: str, now: float) -> dict:
|
|||
"id": uid, "name": name, "cls": cls, "joined": now,
|
||||
"xp": {skill: 0 for skill in SKILL_IDS},
|
||||
"bank": {}, "gold": 0, "favour": 0,
|
||||
"equip": {"weapon": None, "shield": None},
|
||||
"equip": {slot: None for slot in EQUIP_SLOTS},
|
||||
"food": None, # preferred food in fights; None eats the cheapest first
|
||||
"hp": 44, "hp_at": now,
|
||||
"action": None, "stopped": None,
|
||||
"last_active": now, "overflow": "city",
|
||||
"perks": {"bank_slot": 0},
|
||||
|
|
@ -383,19 +466,69 @@ def bank_take(player: dict, item: str, qty: int) -> None:
|
|||
del player["bank"][item]
|
||||
|
||||
|
||||
def armour(player: dict) -> int:
|
||||
return sum(ITEMS[item].get("armour", 0) for item in player["equip"].values() if item)
|
||||
|
||||
|
||||
def damage_reduction(player: dict) -> float:
|
||||
points = armour(player)
|
||||
return points / (points + 25)
|
||||
|
||||
|
||||
def combat_power(player: dict) -> int:
|
||||
power = skill_level(player, "combat")
|
||||
for slot in ("weapon", "shield"):
|
||||
item = player["equip"][slot]
|
||||
power = skill_level(player, "combat") + armour(player) // 2
|
||||
for item in player["equip"].values():
|
||||
if item:
|
||||
power += ITEMS[item]["power"]
|
||||
power += ITEMS[item].get("power", 0)
|
||||
return power
|
||||
|
||||
|
||||
def max_hp(player: dict) -> int:
|
||||
return 40 + 4 * skill_level(player, "combat")
|
||||
|
||||
|
||||
def regen(world: dict, player: dict, now: float) -> None:
|
||||
"""Heal for the time since HP was last settled."""
|
||||
if now > player["hp_at"]:
|
||||
top = max_hp(player)
|
||||
per_second = top * REGEN_PER_HOUR * speed(world) / HOUR
|
||||
player["hp"] = min(top, player["hp"] + (now - player["hp_at"]) * per_second)
|
||||
player["hp_at"] = now
|
||||
|
||||
|
||||
def fight_damage(player: dict, action: dict) -> float:
|
||||
"""Average damage one fight deals: less when out-levelling the enemy or armoured."""
|
||||
gap = combat_power(player) - action["difficulty"]
|
||||
return action["damage"] * min(1.5, max(0.3, 1 - gap * 0.03)) * (1 - damage_reduction(player))
|
||||
|
||||
|
||||
def edible(player: dict) -> list[str]:
|
||||
"""Food in the bank, in the order it gets eaten."""
|
||||
foods = sorted((item for item in player["bank"] if "heal" in ITEMS[item]), key=lambda i: ITEMS[i]["heal"])
|
||||
if player["food"] in foods:
|
||||
foods.remove(player["food"])
|
||||
foods.insert(0, player["food"])
|
||||
return foods
|
||||
|
||||
|
||||
def auto_eat(player: dict, summary: dict) -> None:
|
||||
top = max_hp(player)
|
||||
while player["hp"] < top * EAT_BELOW:
|
||||
foods = edible(player)
|
||||
if not foods:
|
||||
return
|
||||
bank_take(player, foods[0], 1)
|
||||
player["hp"] = min(top, player["hp"] + ITEMS[foods[0]]["heal"])
|
||||
summary["eaten"][foods[0]] = summary["eaten"].get(foods[0], 0) + 1
|
||||
|
||||
|
||||
def action_time(world: dict, player: dict, action: dict) -> float:
|
||||
seconds = action["time"]
|
||||
if action["skill"] in CLASSES[player["cls"]]["fast"]:
|
||||
seconds *= CLASS_SPEED
|
||||
tool = player["equip"].get(f"tool_{action['skill']}")
|
||||
if tool:
|
||||
seconds *= 1 - ITEMS[tool]["speed"]
|
||||
if action["skill"] == "smithing":
|
||||
seconds *= 1 - 0.08 * building_level(world, "smithy")
|
||||
return seconds / speed(world)
|
||||
|
|
@ -433,6 +566,8 @@ def can_start(world: dict, player: dict, action: dict) -> str | None:
|
|||
return "No bandit camp has been located"
|
||||
if raid["sabotage"] >= 0.3:
|
||||
return "The camp is already in ruins"
|
||||
if action["skill"] == "combat" and player["hp"] < max_hp(player) * TOO_HURT and not edible(player):
|
||||
return "Too hurt to fight. Rest, or bring food"
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -442,15 +577,31 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
|
|||
xp = action["xp"]
|
||||
lucky = (
|
||||
(player["cls"] == "artisan" and action["skill"] in CRAFTING)
|
||||
or (player["cls"] == "pioneer" and action["skill"] in GATHERING - {"hunting"})
|
||||
or (player["cls"] == "pioneer" and action["skill"] in SURVEYED)
|
||||
) and rng.random() < LUCKY_CHANCE
|
||||
outputs = {item: qty * (2 if lucky else 1) for item, qty in action["outputs"].items()}
|
||||
if lucky:
|
||||
summary["lucky"] += 1
|
||||
|
||||
if action["skill"] == "combat":
|
||||
regen(world, player, now)
|
||||
auto_eat(player, summary)
|
||||
chance = 0.5 + (combat_power(player) - action["difficulty"]) * 0.04
|
||||
if rng.random() < min(0.97, max(0.15, chance)):
|
||||
won = rng.random() < min(0.97, max(0.15, chance))
|
||||
hurt = fight_damage(player, action) * rng.uniform(0.6, 1.4) * (1 if won else 1.3)
|
||||
player["hp"] -= hurt
|
||||
summary["damage"] += hurt
|
||||
auto_eat(player, summary)
|
||||
if player["hp"] <= 0:
|
||||
# Out of food and out of luck: fall back with nothing but your life.
|
||||
player["hp"] = 1
|
||||
summary["losses"] += 1
|
||||
reason = "Retreated: ran out of food" if not edible(player) else "Retreated"
|
||||
stop(player, reason)
|
||||
summary["stopped"] = reason
|
||||
gain_xp(world, player, "combat", xp // 3, now, summary)
|
||||
return
|
||||
if won:
|
||||
summary["wins"] += 1
|
||||
player["gold"] += action.get("gold", 0)
|
||||
summary["gold"] += action.get("gold", 0)
|
||||
|
|
@ -490,7 +641,7 @@ def gain_xp(world: dict, player: dict, skill: str, xp: float, now: float, summar
|
|||
|
||||
|
||||
def empty_summary() -> dict:
|
||||
return {"done": 0, "items": {}, "overflow": {}, "xp": {}, "levels": [],
|
||||
return {"done": 0, "items": {}, "overflow": {}, "xp": {}, "levels": [], "eaten": {}, "damage": 0,
|
||||
"gold": 0, "wins": 0, "losses": 0, "lucky": 0, "stopped": None}
|
||||
|
||||
|
||||
|
|
@ -508,6 +659,8 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N
|
|||
if current["start"] + duration > limit:
|
||||
break
|
||||
reason = can_start(world, player, action)
|
||||
if reason and reason.startswith("Too hurt"):
|
||||
reason = "Retreated: hurt and out of food"
|
||||
if reason:
|
||||
stop(player, reason)
|
||||
summary["stopped"] = reason
|
||||
|
|
@ -1058,4 +1211,5 @@ def content() -> dict:
|
|||
return {
|
||||
"items": ITEMS, "skills": SKILLS, "classes": CLASSES, "actions": ACTIONS,
|
||||
"buildings": buildings, "xp_table": XP_TABLE, "offline_cap": OFFLINE_CAP, "tiers": TIERS,
|
||||
"equip_slots": EQUIP_SLOTS, "eat_below": EAT_BELOW, "regen_per_hour": REGEN_PER_HOUR,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,6 +127,8 @@ function showSummary(sum) {
|
|||
const got = Object.entries(sum.items).map(([id, q]) => `+${fmt(q)} ${item(id).icon} ${item(id).name}`);
|
||||
if (sum.gold) got.push(`+${fmt(sum.gold)} gold`);
|
||||
if (got.length) toast(got.join(" · "), "good");
|
||||
const eaten = Object.entries(sum.eaten || {});
|
||||
if (eaten.length) toast(`Ate ${eaten.map(([id, q]) => `${fmt(q)} ${item(id).name.toLowerCase()}`).join(", ")}`);
|
||||
if (sum.lucky) toast(sum.lucky === 1 ? "✨ Double yield!" : `✨ ${sum.lucky} double yields!`, "level");
|
||||
const spilled = Object.entries(sum.overflow);
|
||||
if (spilled.length) toast(`📦 Bank full: ${spilled.map(([id, q]) => `${fmt(q)} ${item(id).name.toLowerCase()}`).join(", ")} went to the city`);
|
||||
|
|
@ -142,6 +144,7 @@ function awayModal(sum) {
|
|||
openModal(`
|
||||
<h2>While you were away</h2>
|
||||
<p class="muted">You completed ${fmt(sum.done)} actions${sum.wins || sum.losses ? ` (${sum.wins} fights won, ${sum.losses} lost)` : ""}.</p>
|
||||
${Object.keys(sum.eaten || {}).length ? `<p class="muted small">Took ${fmt(sum.damage)} damage and ate ${Object.entries(sum.eaten).map(([id, q]) => `${fmt(q)} ${esc(item(id).name.toLowerCase())}`).join(", ")}.</p>` : ""}
|
||||
${items ? `<ul>${items}</ul>` : ""}
|
||||
${sum.gold ? `<p class="gold">+${fmt(sum.gold)} gold</p>` : ""}
|
||||
${xp ? `<ul>${xp}</ul>` : ""}
|
||||
|
|
@ -205,7 +208,7 @@ function sidebar() {
|
|||
<div class="brand"><img src="/static/icon.svg" alt="">Guildhall <small>alpha</small></div>
|
||||
<div class="me">
|
||||
<div class="who">${esc(me.name)}</div>
|
||||
<div class="cls">${cls.icon} ${cls.name}</div>
|
||||
<div class="cls">${cls.icon} ${cls.name} · ❤️ ${fmt(me.hp)}/${me.max_hp}</div>
|
||||
<div class="purse"><span class="gold" title="Gold">🪙 ${fmt(me.gold)}</span><span class="favour" title="Favour">✨ ${fmt(me.favour)}</span></div>
|
||||
</div>
|
||||
<div class="navhead">City</div>
|
||||
|
|
@ -305,20 +308,52 @@ function skillPage(id) {
|
|||
${fast ? `<span class="tag gold">${cls.icon} ${cls.name} bonus</span>` : ""}
|
||||
</div></div>`;
|
||||
if (id === "combat") html += combatPanel();
|
||||
if (`tool_${id}` in me.equip) html += toolPanel(id);
|
||||
if (id === "reconnaissance") html += `<div class="panel small muted">Every ${8} reconnaissance points on an upcoming event raises its intel by one level, on top of what the spymaster's guild provides. The council & threats page shows the progress.</div>`;
|
||||
html += `<div class="grid">${C.actions.filter((a) => a.skill === id).map(actionCard).join("")}</div>`;
|
||||
return html;
|
||||
let group = null, cards = "";
|
||||
for (const a of C.actions.filter((a) => a.skill === id)) {
|
||||
if (a.group && a.group !== group) {
|
||||
group = a.group;
|
||||
cards += `<div class="section-title grid-head">${esc(group)}</div>`;
|
||||
}
|
||||
cards += actionCard(a);
|
||||
}
|
||||
return html + `<div class="grid">${cards}</div>`;
|
||||
}
|
||||
|
||||
function toolPanel(skill) {
|
||||
const tool = S.me.equip[`tool_${skill}`];
|
||||
return `<div class="panel tool-panel">${tool
|
||||
? `${item(tool).icon} <b>${esc(item(tool).name)}</b> <span class="muted">${Math.round(item(tool).speed * 100)}% faster</span>
|
||||
<button class="btn sm" data-unequip="tool_${skill}">Unequip</button>`
|
||||
: `<span class="muted">No tool. A smith can make you one that speeds this up.</span>`}</div>`;
|
||||
}
|
||||
|
||||
function combatPanel() {
|
||||
const me = S.me;
|
||||
const stat = (id) => [item(id).power && `+${item(id).power} power`, item(id).armour && `+${item(id).armour} armour`].filter(Boolean).join(", ");
|
||||
const slot = (name, label) => {
|
||||
const id = me.equip[name];
|
||||
return `<div class="item-chip">${id ? `${item(id).icon} ${esc(item(id).name)} (+${item(id).power}) <button class="btn sm" data-unequip="${name}">Unequip</button>` : `<span class="muted">No ${label}</span>`}</div>`;
|
||||
return `<div class="gear-slot">${id
|
||||
? `<span>${item(id).icon} ${esc(item(id).name)} <span class="muted small">${stat(id)}</span></span><button class="btn sm" data-unequip="${name}">Unequip</button>`
|
||||
: `<span class="muted">No ${label}</span>`}</div>`;
|
||||
};
|
||||
return `<div class="panel"><h2>Equipment</h2>
|
||||
<div class="btns">${slot("weapon", "weapon")} ${slot("shield", "shield")}</div>
|
||||
<p class="small muted">Combat power ${me.power} = combat level + gear${me.cls === "warrior" ? ". As a warrior you count for ×1.5 in the garrison" : ""}. Equip swords and shields from the bank.</p></div>`;
|
||||
const foods = me.edible.map((id) => `<option value="${id}" ${me.food === id ? "selected" : ""}>${esc(item(id).name)} (heals ${item(id).heal}, ${fmt(me.bank[id])} left)</option>`).join("");
|
||||
const hpPct = me.hp / me.max_hp * 100;
|
||||
return `<div class="cols">
|
||||
<div class="panel"><h2>Health</h2>
|
||||
<div class="hp-line"><b>❤️ ${fmt(me.hp)} / ${me.max_hp}</b><span class="muted small">recovers ${Math.round(C.regen_per_hour * 100)}% an hour</span></div>
|
||||
<div class="bar thick hp"><span style="width:${hpPct}%"></span></div>
|
||||
<div class="row" style="margin-top:12px"><label class="small muted">Food in fights
|
||||
<select data-food-pick>${`<option value="">Cheapest first</option>` + foods}</select></label></div>
|
||||
<p class="small muted">Below half health you eat from your bank automatically.
|
||||
${me.edible.length ? "" : `<b class="warn">You have no food in your bank.</b>`}
|
||||
Out of food and badly hurt, you retreat: the fight is lost, but nothing else is.</p>
|
||||
</div>
|
||||
<div class="panel"><h2>Equipment</h2>
|
||||
<div class="gear">${slot("weapon", "weapon")}${slot("shield", "shield")}${slot("helmet", "helmet")}${slot("body", "body armour")}${slot("legs", "leg armour")}</div>
|
||||
<p class="small muted">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.</p>
|
||||
</div></div>`;
|
||||
}
|
||||
|
||||
function actionCard(a) {
|
||||
|
|
@ -337,7 +372,7 @@ function actionCard(a) {
|
|||
let extra = "";
|
||||
if (a.skill === "combat") {
|
||||
const chance = Math.min(0.97, Math.max(0.15, 0.5 + (me.power - a.difficulty) * 0.04));
|
||||
extra = `<div class="small">Win chance <b>${Math.round(chance * 100)}%</b>${a.gold ? ` · ${a.gold} gold` : ""}</div>`;
|
||||
extra = `<div class="small">Win chance <b>${Math.round(chance * 100)}%</b> · about <b>${me.fight_damage[a.id]}</b> damage a fight${a.gold ? ` · ${a.gold} gold` : ""}</div>`;
|
||||
if (a.drops) io += a.drops.map(([id, q, odds]) => `<span class="item-chip" title="${Math.round(odds * 100)}% chance">${item(id).icon} ${q} <span class="have">${Math.round(odds * 100)}%</span></span>`).join("");
|
||||
if (a.special === "sabotage") extra += `<div class="small muted">Each win weakens the coming raid by 3%, up to 30%.</div>`;
|
||||
}
|
||||
|
|
@ -397,7 +432,9 @@ function itemPanel(id) {
|
|||
<div class="ico">${it.icon}</div>
|
||||
<h3>${esc(it.name)}</h3>
|
||||
<div class="muted small">You have ${fmt(have)} · worth ${price % 1 ? price.toFixed(1) : price} gold each${merchant ? " (merchant in town!)" : ""}</div>
|
||||
${it.slot ? `<div class="row"><button class="btn gold" data-equip="${id}">Equip ${it.slot} (+${it.power} power)</button></div>` : ""}
|
||||
${it.slot ? `<div class="row"><button class="btn gold" data-equip="${id}">${equipLabel(it)}</button></div>` : ""}
|
||||
${it.heal ? `<div class="row"><div class="small muted">Heals ${it.heal} in fights.</div>
|
||||
${S.me.food === id ? `<span class="tag good">Your fighting food</span>` : `<button class="btn sm" data-food="${id}">Eat this first in fights</button>`}</div>` : ""}
|
||||
${it.food ? `<div class="row small muted">Feeds ${it.food} townsfolk for two days in the city granary.</div>` : ""}
|
||||
<div class="row"><div class="k">Donate to the city · +${it.value} favour each</div><div class="btns">${qtyBtns("data-donate")}</div></div>
|
||||
${others.length ? `<div class="row"><div class="k">Give to <select data-keep="give-to">${others.map((p) => `<option value="${esc(p.id)}">${esc(p.name)}</option>`).join("")}</select></div><div class="btns">${qtyBtns("data-give")}</div></div>` : ""}
|
||||
|
|
@ -406,6 +443,12 @@ function itemPanel(id) {
|
|||
</div>${goldPanel()}`;
|
||||
}
|
||||
|
||||
function equipLabel(it) {
|
||||
if (it.tool) return `Use as ${skillOf(it.tool).name.toLowerCase()} tool (${Math.round(it.speed * 100)}% faster)`;
|
||||
const stats = [it.power && `+${it.power} power`, it.armour && `+${it.armour} armour`].filter(Boolean).join(", ");
|
||||
return `Equip ${it.slot === "body" ? "body armour" : it.slot === "legs" ? "leg armour" : it.slot} (${stats})`;
|
||||
}
|
||||
|
||||
function goldPanel() {
|
||||
return `<div class="panel"><h2>Gold</h2>
|
||||
<p class="gold">🪙 ${fmt(S.me.gold)}</p>
|
||||
|
|
@ -658,12 +701,17 @@ document.addEventListener("click", (e) => {
|
|||
if (d.fund) return act("/api/fund", { building: d.fund, item: d.item, source: d.source, qty: "all" });
|
||||
if (d.garrison) return act("/api/garrison", { join: d.garrison === "1" });
|
||||
if (d.perk) return act("/api/perk", { perk: d.perk });
|
||||
if (d.food) return act("/api/food", { item: d.food });
|
||||
if (d.overflow) return act("/api/settings", { overflow: d.overflow });
|
||||
if (d.class) { pickedClass = d.class; render(); return; }
|
||||
if (d.devAdvance) return act("/api/dev/advance", { hours: Number(d.devAdvance) });
|
||||
if ("devEvent" in d) return act("/api/dev/event", {});
|
||||
});
|
||||
|
||||
document.addEventListener("change", (e) => {
|
||||
if (e.target.matches("[data-food-pick]")) act("/api/food", { item: e.target.value || null });
|
||||
});
|
||||
|
||||
$("#modal").addEventListener("click", (e) => { if (e.target.id === "modal") e.target.hidden = true; });
|
||||
|
||||
document.addEventListener("submit", async (e) => {
|
||||
|
|
|
|||
|
|
@ -194,6 +194,14 @@ textarea { width: 100%; resize: vertical; min-height: 60px; }
|
|||
.item-chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 6px; background: var(--panel-2); }
|
||||
.item-chip.short { color: var(--bad); }
|
||||
.action .why { font-size: 13px; color: var(--warn); }
|
||||
.grid-head { grid-column: 1 / -1; margin: 10px 0 -4px; }
|
||||
.tool-panel { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.gear { display: flex; flex-direction: column; gap: 6px; }
|
||||
.gear-slot { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 10px; border-radius: 8px; background: var(--panel-2); }
|
||||
.hp-line { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; margin-bottom: 6px; }
|
||||
.bar.hp > span { background: var(--bad); }
|
||||
.warn { color: var(--warn); }
|
||||
select { background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: 5px 8px; margin-left: 6px; }
|
||||
.action .foot { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: auto; }
|
||||
|
||||
/* --- bank ------------------------------------------------------------- */
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ class ActionTest(unittest.TestCase):
|
|||
def test_combat_pays_and_loses(self):
|
||||
world = world_with(("wil", "warrior"))
|
||||
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))
|
||||
self.assertEqual(summary["done"], 100)
|
||||
|
|
@ -329,6 +330,7 @@ class EventTest(unittest.TestCase):
|
|||
world = world_with(("wil", "warrior"))
|
||||
p = world["players"]["wil"]
|
||||
p["xp"]["combat"] = game.XP_TABLE[40]
|
||||
p["hp"] = game.max_hp(p)
|
||||
raid = self.announce(world)
|
||||
self.assertEqual(game.start_action(world, p, "sabotage", T0), "No bandit camp has been located")
|
||||
raid["recon"] = 3 * game.RECON_PER_INTEL
|
||||
|
|
@ -338,5 +340,106 @@ class EventTest(unittest.TestCase):
|
|||
self.assertLessEqual(raid["sabotage"], 0.3)
|
||||
|
||||
|
||||
|
||||
class CombatTest(unittest.TestCase):
|
||||
def fighter(self, level=10, **equip):
|
||||
world = world_with(("wil", "warrior"))
|
||||
p = world["players"]["wil"]
|
||||
p["xp"]["combat"] = game.XP_TABLE[level]
|
||||
p["hp"] = game.max_hp(p)
|
||||
p["equip"].update(equip)
|
||||
return world, p
|
||||
|
||||
def test_fights_hurt_and_food_heals(self):
|
||||
world, p = self.fighter()
|
||||
p["bank"] = {"bread": 50}
|
||||
game.start_action(world, p, "fight_scouts", T0)
|
||||
summary = game.process_player(world, p, T0 + 60 * 60, random.Random(4))
|
||||
self.assertGreater(summary["damage"], 0)
|
||||
self.assertGreater(summary["eaten"].get("bread", 0), 0)
|
||||
self.assertEqual(p["bank"]["bread"], 50 - summary["eaten"]["bread"])
|
||||
self.assertGreaterEqual(p["hp"], 1)
|
||||
|
||||
def test_out_of_food_means_retreat_and_nothing_lost(self):
|
||||
world, p = self.fighter(level=10, weapon="copper_sword")
|
||||
p["bank"] = {"stone": 10}
|
||||
game.start_action(world, p, "fight_scouts", T0)
|
||||
summary = game.process_player(world, p, T0 + 6 * 3600, random.Random(1))
|
||||
self.assertIsNone(p["action"])
|
||||
self.assertTrue(summary["stopped"].startswith("Retreated"))
|
||||
self.assertEqual(p["equip"]["weapon"], "copper_sword")
|
||||
self.assertEqual(p["bank"]["stone"], 10)
|
||||
self.assertLess(p["hp"], game.max_hp(p) * game.TOO_HURT)
|
||||
self.assertEqual(game.start_action(world, p, "fight_scouts", T0 + 6 * 3600), "Too hurt to fight. Rest, or bring food")
|
||||
|
||||
def test_rest_heals(self):
|
||||
world, p = self.fighter()
|
||||
p["hp"] = 1
|
||||
game.regen(world, p, T0 + 4 * 3600)
|
||||
self.assertEqual(p["hp"], game.max_hp(p))
|
||||
|
||||
def test_armour_and_levels_cut_damage(self):
|
||||
world, bare = self.fighter(level=10)
|
||||
_, armoured = self.fighter(level=10, helmet="iron_helmet", body="iron_body", legs="iron_legs")
|
||||
_, veteran = self.fighter(level=60)
|
||||
wolves = game.ACTION_BY_ID["fight_scouts"]
|
||||
self.assertLess(game.fight_damage(armoured, wolves), game.fight_damage(bare, wolves) * 0.7)
|
||||
self.assertLess(game.fight_damage(veteran, wolves), game.fight_damage(bare, wolves))
|
||||
|
||||
def test_preferred_food_is_eaten_first(self):
|
||||
world, p = self.fighter()
|
||||
p["bank"] = {"bread": 5, "cooked_salmon": 5}
|
||||
self.assertEqual(game.edible(p), ["bread", "cooked_salmon"])
|
||||
p["food"] = "cooked_salmon"
|
||||
self.assertEqual(game.edible(p), ["cooked_salmon", "bread"])
|
||||
|
||||
|
||||
class NewSkillsTest(unittest.TestCase):
|
||||
def test_tools_speed_up_gathering(self):
|
||||
world = world_with(("pia", "warrior"))
|
||||
p = world["players"]["pia"]
|
||||
plain = game.action_time(world, p, game.ACTION_BY_ID["mine_stone"])
|
||||
p["equip"]["tool_mining"] = "iron_pickaxe"
|
||||
self.assertAlmostEqual(game.action_time(world, p, game.ACTION_BY_ID["mine_stone"]), plain * 0.7)
|
||||
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"))
|
||||
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)
|
||||
self.assertIn("cooked_shrimp", game.FOOD_ORDER)
|
||||
|
||||
def test_hides_to_armour(self):
|
||||
world = world_with(("kay", "artisan"))
|
||||
p = world["players"]["kay"]
|
||||
p["bank"] = {"hide": 4}
|
||||
game.start_action(world, p, "tan_leather", T0)
|
||||
game.process_player(world, p, T0 + 1000, random.Random(1))
|
||||
self.assertGreaterEqual(p["bank"]["leather"], 4)
|
||||
p["xp"]["leatherworking"] = game.XP_TABLE[12]
|
||||
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"))
|
||||
p = world["players"]["fred"]
|
||||
world["version"] = 2
|
||||
for key in ("hp", "hp_at", "food"):
|
||||
del p[key]
|
||||
p["equip"] = {"weapon": "copper_sword", "shield": None}
|
||||
del p["xp"]["fishing"], p["xp"]["leatherworking"]
|
||||
game.migrate(world)
|
||||
self.assertEqual(world["version"], game.WORLD_VERSION)
|
||||
self.assertEqual(p["hp"], game.max_hp(p))
|
||||
self.assertEqual(p["equip"]["weapon"], "copper_sword")
|
||||
self.assertIsNone(p["equip"]["tool_fishing"])
|
||||
self.assertEqual(p["xp"]["fishing"], 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue