Combat engine: swing-by-swing fights, Frontier and expeditions, trophies
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2ca90c43c0
commit
19ccab4ac3
7 changed files with 395 additions and 115 deletions
13
README.md
13
README.md
|
|
@ -13,9 +13,16 @@ Alpha. One season, bandit raids plus two friendly events, six buildings.
|
||||||
construction (with masonry), cooking, leatherworking, herblore, combat,
|
construction (with masonry), cooking, leatherworking, herblore, combat,
|
||||||
prayer and reconnaissance. Smiths also make hardware for builders, one kind
|
prayer and reconnaissance. Smiths also make hardware for builders, one kind
|
||||||
per metal (copper rivets to rune locks), which fittings and buildings need.
|
per metal (copper rivets to rune locks), which fittings and buildings need.
|
||||||
- **Combat:** fights cost HP (less when out-levelled or armoured); below half
|
- **Combat:** every fight is played out swing by swing: you and the enemy
|
||||||
health you eat food from your bank, and out of food you retreat, losing
|
each have HP, a swing timer, accuracy and a max hit, and swings hit, miss
|
||||||
nothing but the fight. HP recovers 25% an hour.
|
or crit (power from level and gear aims and hits; armour dodges and soaks).
|
||||||
|
Below half health you eat from your bank; out of food you retreat, losing
|
||||||
|
nothing but the fight. HP recovers 25% an hour. The Combat page replays
|
||||||
|
the fight live. Fights are split into the Frontier (bandits, sabotage,
|
||||||
|
drilling, the city's troubles) and expeditions: Greenwood to start, then
|
||||||
|
one area per tier (Old Watchtower, Sunken Barrows, Iron Hills, Frostpeak,
|
||||||
|
Dragon's Spine), each with a golem, a troll, a spirit and a flyer that drop
|
||||||
|
trophies for stronger weapons, and a boss (the dragons at the top).
|
||||||
- **Tiers:** six RuneScape-style tiers (copper, iron, steel, mithril,
|
- **Tiers:** six RuneScape-style tiers (copper, iron, steel, mithril,
|
||||||
adamant, rune at levels 1–75) of ore, trees (up to enchanted) and hides (deer,
|
adamant, rune at levels 1–75) of ore, trees (up to enchanted) and hides (deer,
|
||||||
boar and bear from hunting; green, blue and red dragonhide from fighting
|
boar and bear from hunting; green, blue and red dragonhide from fighting
|
||||||
|
|
|
||||||
13
app.py
13
app.py
|
|
@ -100,9 +100,7 @@ def player_view(world: dict, player: dict, now: float) -> dict:
|
||||||
view["reduction"] = round(game.damage_reduction(player), 3)
|
view["reduction"] = round(game.damage_reduction(player), 3)
|
||||||
view["edible"] = game.edible(player)
|
view["edible"] = game.edible(player)
|
||||||
view["blessing"] = game.blessing(player)
|
view["blessing"] = game.blessing(player)
|
||||||
blessed = game.with_potion(view["blessing"], game.potion_effect(player, "combat"))
|
view["fight_odds"] = {a["id"]: game.fight_odds(player, a) for a in game.ACTIONS if "enemy" in a}
|
||||||
view["fight_damage"] = {a["id"]: round(game.fight_damage(player, a, blessed), 1)
|
|
||||||
for a in game.ACTIONS if "difficulty" in a}
|
|
||||||
view["pp"] = int(player["pp"])
|
view["pp"] = int(player["pp"])
|
||||||
view["labour_per_action"] = game.labour_per_action(player)
|
view["labour_per_action"] = game.labour_per_action(player)
|
||||||
view["levels"] = {skill: game.skill_level(player, skill) for skill in game.SKILL_IDS}
|
view["levels"] = {skill: game.skill_level(player, skill) for skill in game.SKILL_IDS}
|
||||||
|
|
@ -112,9 +110,12 @@ def player_view(world: dict, player: dict, now: float) -> dict:
|
||||||
view["power"] = game.combat_power(player)
|
view["power"] = game.combat_power(player)
|
||||||
view["perk_costs"] = {perk: game.perk_cost(player, perk) for perk in player["perks"]}
|
view["perk_costs"] = {perk: game.perk_cost(player, perk) for perk in player["perks"]}
|
||||||
if player["action"]:
|
if player["action"]:
|
||||||
action = game.growing(player)
|
action, fight = game.growing(player), player["action"].get("fight")
|
||||||
view["action"] = {**player["action"], "growing": action["id"], "duration": game.action_time(world, player, action)}
|
duration = fight["duration"] if fight else game.action_time(world, player, action)
|
||||||
view["times"] = {action["id"]: game.action_time(world, player, action) for action in game.ACTIONS}
|
view["action"] = {**player["action"], "growing": action["id"], "duration": duration}
|
||||||
|
# A fight takes as long as it takes: show the average time to a kill.
|
||||||
|
view["times"] = {action["id"]: (view["fight_odds"][action["id"]]["kill_time"] + game.RESPAWN) / game.speed(world)
|
||||||
|
if "enemy" in action else 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["blocked"] = {action["id"]: game.can_start(world, player, action) for action in game.ACTIONS}
|
||||||
view["drill_per_action"] = game.drill_per_action(player)
|
view["drill_per_action"] = game.drill_per_action(player)
|
||||||
view["recon_gain"] = {action["id"]: game.recon_gain(world, player, action) for action in game.ACTIONS
|
view["recon_gain"] = {action["id"]: game.recon_gain(world, player, action) for action in game.ACTIONS
|
||||||
|
|
|
||||||
306
game.py
306
game.py
|
|
@ -397,7 +397,118 @@ def _tiered_actions() -> list[dict]:
|
||||||
return actions
|
return actions
|
||||||
|
|
||||||
|
|
||||||
|
# --- combat -----------------------------------------------------------------
|
||||||
|
# A fight is played out swing by swing: you and the enemy each have HP, a swing
|
||||||
|
# timer, accuracy and a max hit, and every swing hits, misses or crits. Combat
|
||||||
|
# is split into the Frontier (bandits and the city's troubles) and expeditions:
|
||||||
|
# one area per tier, each with a golem, a troll, a spirit and a flyer that drop
|
||||||
|
# the trophies higher weapons need, and a boss.
|
||||||
|
CRIT_CHANCE = 0.05
|
||||||
|
CRIT_MULT = 1.5
|
||||||
|
RESPAWN = 3 # seconds before the next enemy turns up
|
||||||
|
PLAYER_INTERVAL = 2.4 # seconds between your swings
|
||||||
|
MAX_FIGHT = 600 # a fight that drags on longer is broken off
|
||||||
|
FLEE_BELOW = 0.2 # out of food and under this share of HP: get out
|
||||||
|
|
||||||
|
# kind: HP, seconds between swings, accuracy, evasion, max hit (multipliers), damage it shrugs off
|
||||||
|
MONSTER_KINDS = {
|
||||||
|
"human": (1.0, 2.6, 1.0, 1.0, 1.0, 0.0),
|
||||||
|
"beast": (0.9, 2.2, 1.0, 1.0, 0.9, 0.0),
|
||||||
|
"golem": (1.5, 3.2, 1.0, 0.7, 1.25, 0.2),
|
||||||
|
"troll": (1.4, 3.0, 0.9, 1.0, 1.4, 0.0),
|
||||||
|
"spirit": (0.8, 2.4, 1.0, 1.35, 0.9, 0.0),
|
||||||
|
"flyer": (0.8, 2.0, 1.0, 1.2, 0.8, 0.0),
|
||||||
|
"dragon": (2.5, 3.0, 1.1, 1.0, 1.5, 0.15),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def monster(name: str, kind: str, level: int, boss: bool = False) -> dict:
|
||||||
|
hp, interval, accuracy, evasion, hit, shrug = MONSTER_KINDS[kind]
|
||||||
|
return {"name": name, "kind": kind, "level": level, "boss": boss,
|
||||||
|
"hp": round((20 + 6 * level) * hp * (2.5 if boss else 1)), "interval": interval,
|
||||||
|
"accuracy": round((12 + 2.2 * level) * accuracy), "evasion": round((10 + 2 * level) * evasion),
|
||||||
|
"max_hit": max(1, round((2 + level / 3) * hit * (1.2 if boss else 1))), "reduction": shrug}
|
||||||
|
|
||||||
|
|
||||||
|
def _fight(id: str, enemy: dict, group: str, gold: int = 0, drops=(), name: str | None = None, **extra) -> dict:
|
||||||
|
"""A combat action: XP per kill, needs the enemy's level in combat."""
|
||||||
|
xp = round(enemy["hp"] * 0.4 + enemy["level"] * 1.5)
|
||||||
|
return _action(id, "combat", name or enemy["name"], enemy["level"], RESPAWN, xp, gold=gold,
|
||||||
|
drops=[list(drop) for drop in drops], enemy=enemy, group=group, **extra)
|
||||||
|
|
||||||
|
|
||||||
|
# Expedition areas, tiers II to VI: name, level, and the trophy each kind of monster drops there.
|
||||||
|
AREAS = ["Old Watchtower", "Sunken Barrows", "Iron Hills", "Frostpeak", "Dragon's Spine"]
|
||||||
|
AREA_LEVELS = [10, 20, 30, 40, 50]
|
||||||
|
AREA_MONSTERS = { # kind: (level offset, monsters, trophies)
|
||||||
|
"golem": (0, ["Clay golem", "Stone golem", "Iron golem", "Obsidian golem", "Runic golem"],
|
||||||
|
["Clay core", "Stone core", "Iron core", "Obsidian core", "Runic core"]),
|
||||||
|
"troll": (2, ["Cave troll", "Hill troll", "Rock troll", "Frost troll", "Elder troll"],
|
||||||
|
["Cave troll hide", "Hill troll hide", "Rock troll hide", "Frost troll hide", "Elder troll hide"]),
|
||||||
|
"spirit": (1, ["Will-o'-wisp", "Bog wraith", "Mine ghost", "Banshee", "Shade"],
|
||||||
|
["Wisp essence", "Wraith essence", "Ghost essence", "Banshee essence", "Shade essence"]),
|
||||||
|
"flyer": (0, ["Giant bat", "Harpy", "Wyvern", "Griffin", "Roc"],
|
||||||
|
["Bat sinew", "Harpy sinew", "Wyvern sinew", "Griffin sinew", "Roc sinew"]),
|
||||||
|
}
|
||||||
|
TROPHY_ODDS = 0.33
|
||||||
|
AREA_BONES = ["bones", "big_bones", "big_bones", "dragon_bones", "dragon_bones"]
|
||||||
|
|
||||||
|
|
||||||
|
def _slug(name: str) -> str:
|
||||||
|
return "".join(c if c.isalnum() else "_" for c in name.lower().replace("'", "")).strip("_")
|
||||||
|
|
||||||
|
|
||||||
|
TROPHIES = {} # id: (name, kind, tier)
|
||||||
|
for _kind, (_offset, _monsters, _trophies) in AREA_MONSTERS.items():
|
||||||
|
for _tier, _trophy in enumerate(_trophies, start=1):
|
||||||
|
TROPHIES[_slug(_trophy)] = (_trophy, _kind, _tier)
|
||||||
|
ITEMS[_slug(_trophy)] = {"name": _trophy, "icon": "🏆", "value": 15 * _tier, "trophy": _kind, "tier": _tier}
|
||||||
|
|
||||||
|
|
||||||
|
def _area_fights() -> list[dict]:
|
||||||
|
fights = []
|
||||||
|
for index, (area, level) in enumerate(zip(AREAS, AREA_LEVELS)):
|
||||||
|
for kind, (offset, monsters, trophies) in AREA_MONSTERS.items():
|
||||||
|
enemy = monster(monsters[index], kind, level + offset)
|
||||||
|
fights.append(_fight(f"fight_{_slug(monsters[index])}", enemy, area, gold=level + offset,
|
||||||
|
drops=[(AREA_BONES[index], 1, 1.0), (_slug(trophies[index]), 1, TROPHY_ODDS)]))
|
||||||
|
return fights
|
||||||
|
|
||||||
|
|
||||||
|
def _boss_trophies(tier: int) -> list[tuple]:
|
||||||
|
return [(_slug(trophies[tier - 1]), 1, 0.25) for _offset, _monsters, trophies in AREA_MONSTERS.values()]
|
||||||
|
|
||||||
|
|
||||||
|
COMBAT_ACTIONS = [
|
||||||
|
# The Frontier: the city's own troubles.
|
||||||
|
_fight("fight_scouts", monster("Bandit scouts", "human", 10), "Frontier", gold=10,
|
||||||
|
drops=[("copper_bar", 1, 0.3), ("arrows", 5, 0.4), ("bones", 1, 1.0)]),
|
||||||
|
_fight("sabotage", monster("Camp guards", "human", 12), "Frontier", name="Sabotage the bandit camp",
|
||||||
|
special="sabotage"),
|
||||||
|
_fight("fight_stronghold", monster("Bandit stronghold", "human", 40, boss=True), "Frontier", gold=60,
|
||||||
|
drops=[("steel_bar", 1, 0.4), ("coal", 3, 0.5), ("iron_shield", 1, 0.04), ("big_bones", 1, 1.0)]),
|
||||||
|
# Greenwood: where everyone starts.
|
||||||
|
_fight("fight_wolves", monster("Wolves", "beast", 1), "Greenwood", gold=2, name="Wolves in the woods",
|
||||||
|
drops=[("raw_meat", 1, 0.6), ("hide", 1, 0.5), ("bones", 1, 1.0)]),
|
||||||
|
_fight("fight_goblins", monster("Cave goblins", "human", 5), "Greenwood", gold=4,
|
||||||
|
drops=[("bones", 1, 1.0), ("copper_ore", 2, 0.4), ("feathers", 5, 0.4)]),
|
||||||
|
*_area_fights(),
|
||||||
|
_fight("fight_ruins", monster("Watchtower ogre", "troll", 18, boss=True), "Old Watchtower", gold=25,
|
||||||
|
drops=[("iron_bar", 2, 0.5), ("iron_sword", 1, 0.05), ("big_bones", 1, 1.0), *_boss_trophies(1)]),
|
||||||
|
_fight("fight_barrow_king", monster("Barrow king", "spirit", 28, boss=True), "Sunken Barrows", gold=40,
|
||||||
|
drops=[("steel_bar", 2, 0.5), ("coal", 4, 0.5), ("big_bones", 2, 1.0), *_boss_trophies(2)]),
|
||||||
|
_fight("fight_green_dragon", monster("Green dragon", "dragon", 38), "Iron Hills", gold=80,
|
||||||
|
drops=[("green_dragonhide", 1, 0.9), ("mithril_ore", 2, 0.3), ("dragon_bones", 1, 1.0), *_boss_trophies(3)]),
|
||||||
|
_fight("fight_blue_dragon", monster("Blue dragon", "dragon", 48), "Frostpeak", gold=110,
|
||||||
|
drops=[("blue_dragonhide", 1, 0.9), ("adamantite_ore", 2, 0.3), ("dragon_bones", 1, 1.0), *_boss_trophies(4)]),
|
||||||
|
_fight("fight_red_dragon", monster("Red dragon", "dragon", 60), "Dragon's Spine", gold=150,
|
||||||
|
drops=[("red_dragonhide", 1, 0.9), ("runite_ore", 1, 0.25), ("dragon_bones", 1, 1.0), *_boss_trophies(5)]),
|
||||||
|
_fight("fight_black_dragon", monster("Black dragon", "dragon", 75, boss=True), "Dragon's Spine", gold=220,
|
||||||
|
drops=[("red_dragonhide", 2, 0.9), ("rune_bar", 1, 0.1), ("dragon_bones", 2, 1.0), *_boss_trophies(5)]),
|
||||||
|
]
|
||||||
|
|
||||||
ACTIONS = [
|
ACTIONS = [
|
||||||
|
*COMBAT_ACTIONS,
|
||||||
_action("mine_stone", "mining", "Quarry stone", 1, 20, 5, outputs={"stone": 1}),
|
_action("mine_stone", "mining", "Quarry stone", 1, 20, 5, outputs={"stone": 1}),
|
||||||
_action("farm_wheat", "farming", "Grow wheat", 1, 25, 6, outputs={"wheat": 2}, group="Crops"),
|
_action("farm_wheat", "farming", "Grow wheat", 1, 25, 6, outputs={"wheat": 2}, group="Crops"),
|
||||||
_action("farm_veg", "farming", "Grow vegetables", 12, 35, 13, outputs={"vegetables": 2}, group="Crops"),
|
_action("farm_veg", "farming", "Grow vegetables", 12, 35, 13, outputs={"vegetables": 2}, group="Crops"),
|
||||||
|
|
@ -435,25 +546,7 @@ ACTIONS = [
|
||||||
_action("pack_bread", "cooking", "Pack bread rations", 1, 20, 6, {"bread": 2}, {"provisions": 1}, group="Rations"),
|
_action("pack_bread", "cooking", "Pack bread rations", 1, 20, 6, {"bread": 2}, {"provisions": 1}, group="Rations"),
|
||||||
_action("pack_meat", "cooking", "Pack meat rations", 5, 20, 7, {"cooked_meat": 2}, {"provisions": 1}, group="Rations"),
|
_action("pack_meat", "cooking", "Pack meat rations", 5, 20, 7, {"cooked_meat": 2}, {"provisions": 1}, group="Rations"),
|
||||||
_action("pack_fish", "cooking", "Pack fish rations", 1, 20, 6, {"cooked_shrimp": 2}, {"provisions": 1}, group="Rations"),
|
_action("pack_fish", "cooking", "Pack fish rations", 1, 20, 6, {"cooked_shrimp": 2}, {"provisions": 1}, group="Rations"),
|
||||||
_action("drill_militia", "combat", "Drill the militia", 1, 60, 12, special="drill"),
|
_action("drill_militia", "combat", "Drill the militia", 1, 60, 12, special="drill", group="Frontier"),
|
||||||
_action("fight_wolves", "combat", "Wolves in the woods", 1, 60, 14, difficulty=2, damage=2, gold=2,
|
|
||||||
drops=[["raw_meat", 1, 0.6], ["hide", 1, 0.5], ["bones", 1, 1.0]]),
|
|
||||||
_action("fight_scouts", "combat", "Bandit scouts", 10, 75, 30, difficulty=14, damage=6, gold=10,
|
|
||||||
drops=[["copper_bar", 1, 0.3], ["arrows", 5, 0.4], ["bones", 1, 1.0]]),
|
|
||||||
_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], ["big_bones", 1, 0.8]]),
|
|
||||||
_action("fight_stronghold", "combat", "Bandit stronghold", 40, 105, 95, difficulty=50, damage=25, gold=60,
|
|
||||||
drops=[["steel_bar", 1, 0.4], ["coal", 3, 0.5], ["iron_shield", 1, 0.04], ["big_bones", 1, 1.0]]),
|
|
||||||
_action("fight_green_dragon", "combat", "Green dragon", 50, 120, 135, difficulty=90, damage=30, gold=80,
|
|
||||||
drops=[["green_dragonhide", 1, 0.9], ["mithril_ore", 2, 0.3], ["dragon_bones", 1, 1.0]]),
|
|
||||||
_action("fight_blue_dragon", "combat", "Blue dragon", 62, 130, 175, difficulty=115, damage=40, gold=110,
|
|
||||||
drops=[["blue_dragonhide", 1, 0.9], ["adamantite_ore", 2, 0.3], ["dragon_bones", 1, 1.0]]),
|
|
||||||
_action("fight_red_dragon", "combat", "Red dragon", 75, 140, 220, difficulty=140, damage=52, gold=150,
|
|
||||||
drops=[["red_dragonhide", 1, 0.9], ["runite_ore", 1, 0.25], ["dragon_bones", 1, 1.0]]),
|
|
||||||
_action("fight_black_dragon", "combat", "Black dragon", 85, 150, 280, difficulty=165, damage=65, gold=220,
|
|
||||||
drops=[["red_dragonhide", 2, 0.9], ["rune_bar", 1, 0.1], ["dragon_bones", 2, 1.0]]),
|
|
||||||
_action("sabotage", "combat", "Sabotage the bandit camp", 10, 120, 35, difficulty=12, damage=10,
|
|
||||||
special="sabotage"),
|
|
||||||
_action("bury_bones", "prayer", "Bury bones", 1, 10, 4, {"bones": 1}, special="bury", pp=2, group="Bury"),
|
_action("bury_bones", "prayer", "Bury bones", 1, 10, 4, {"bones": 1}, special="bury", pp=2, group="Bury"),
|
||||||
_action("bury_big_bones", "prayer", "Bury big bones", 15, 10, 15, {"big_bones": 1}, special="bury", pp=6, group="Bury"),
|
_action("bury_big_bones", "prayer", "Bury big bones", 15, 10, 15, {"big_bones": 1}, special="bury", pp=6, group="Bury"),
|
||||||
_action("bury_dragon_bones", "prayer", "Bury dragon bones", 40, 12, 72, {"dragon_bones": 1}, special="bury", pp=20, group="Bury"),
|
_action("bury_dragon_bones", "prayer", "Bury dragon bones", 40, 12, 72, {"dragon_bones": 1}, special="bury", pp=20, group="Bury"),
|
||||||
|
|
@ -980,12 +1073,87 @@ def regen(world: dict, player: dict, now: float, boost: float = 1) -> None:
|
||||||
player["hp_at"] = now
|
player["hp_at"] = now
|
||||||
|
|
||||||
|
|
||||||
def fight_damage(player: dict, action: dict, blessing: dict | None = None) -> float:
|
def fighter(player: dict, blessed: dict, brew: dict) -> dict:
|
||||||
"""Average damage one fight deals: less when out-levelling the enemy, armoured or praying."""
|
"""Your side of a fight: power from level and gear (plus prayers and potions) aims and hits; armour dodges and soaks."""
|
||||||
blessing = blessing or NO_BLESSING
|
power = combat_power(player) + blessed["power"] + brew.get("power", 0)
|
||||||
gap = combat_power(player) + blessing["power"] - action["difficulty"]
|
return {"interval": PLAYER_INTERVAL, "accuracy": 10 + 2 * power, "max_hit": max(2, round(2 + power / 3)),
|
||||||
scale = min(1.5, max(0.3, 1 - gap * 0.03))
|
"evasion": 10 + 2 * (skill_level(player, "combat") + armour(player) // 2),
|
||||||
return action["damage"] * scale * (1 - damage_reduction(player)) * blessing["taken"]
|
"taken": (1 - damage_reduction(player)) * blessed["taken"] * (1 - brew.get("reduce", 0))}
|
||||||
|
|
||||||
|
|
||||||
|
def hit_chance(accuracy: float, evasion: float) -> float:
|
||||||
|
return min(0.95, max(0.05, accuracy / (accuracy + evasion) + 0.15))
|
||||||
|
|
||||||
|
|
||||||
|
def _average_hit(max_hit: float) -> float:
|
||||||
|
return (1 - CRIT_CHANCE) * (max_hit + 1) / 2 + CRIT_CHANCE * max_hit * CRIT_MULT
|
||||||
|
|
||||||
|
|
||||||
|
def fight_odds(player: dict, action: dict) -> dict:
|
||||||
|
"""What a fight against this enemy looks like on average, for the cards."""
|
||||||
|
me, foe = fighter(player, blessing(player), potion_effect(player, "combat")), action["enemy"]
|
||||||
|
hit, their_hit = hit_chance(me["accuracy"], foe["evasion"]), hit_chance(foe["accuracy"], me["evasion"])
|
||||||
|
dps = hit * _average_hit(me["max_hit"]) * (1 - foe["reduction"]) / me["interval"]
|
||||||
|
kill = foe["hp"] / dps
|
||||||
|
hurt = their_hit * _average_hit(foe["max_hit"]) * me["taken"] / foe["interval"] * kill
|
||||||
|
return {"hit": round(hit, 2), "their_hit": round(their_hit, 2), "max_hit": me["max_hit"],
|
||||||
|
"their_max": max(1, round(foe["max_hit"] * me["taken"])), "kill_time": round(kill, 1), "damage": round(hurt, 1)}
|
||||||
|
|
||||||
|
|
||||||
|
def simulate_fight(world: dict, player: dict, action: dict, rng: random.Random) -> dict:
|
||||||
|
"""Play out one fight swing by swing. Nothing changes until it's over: the result is applied then."""
|
||||||
|
foe, blessed, brew = action["enemy"], blessing(player), potion_effect(player, "combat")
|
||||||
|
me = fighter(player, blessed, brew)
|
||||||
|
hit, their_hit = hit_chance(me["accuracy"], foe["evasion"]), hit_chance(foe["accuracy"], me["evasion"])
|
||||||
|
top, hp, foe_hp = max_hp(player), player["hp"], foe["hp"]
|
||||||
|
pantry = {item: player["bank"][item] for item in edible(player)}
|
||||||
|
eaten, events, hurt = {}, [], 0
|
||||||
|
wind = blessed["wind"] and not (player["action"] or {}).get("wind_used")
|
||||||
|
pace = speed(world)
|
||||||
|
mine, theirs, result, t = me["interval"], foe["interval"], None, 0.0
|
||||||
|
while result is None:
|
||||||
|
t = min(mine, theirs)
|
||||||
|
if t > MAX_FIGHT:
|
||||||
|
result = "fled"
|
||||||
|
break
|
||||||
|
crit = rng.random() < CRIT_CHANCE
|
||||||
|
if mine <= theirs:
|
||||||
|
mine += me["interval"]
|
||||||
|
landed = rng.random() < hit
|
||||||
|
dealt = max(1, round((me["max_hit"] * CRIT_MULT if crit else rng.randint(1, me["max_hit"]))
|
||||||
|
* (1 - foe["reduction"]))) if landed else 0
|
||||||
|
foe_hp = max(0, foe_hp - dealt)
|
||||||
|
events.append([round(t / pace, 2), "you", ("crit" if crit else "hit") if landed else "miss", dealt, round(hp), foe_hp])
|
||||||
|
if not foe_hp:
|
||||||
|
result = "won"
|
||||||
|
continue
|
||||||
|
theirs += foe["interval"]
|
||||||
|
landed = rng.random() < their_hit
|
||||||
|
dealt = max(1, round((foe["max_hit"] * CRIT_MULT if crit else rng.randint(1, foe["max_hit"])) * me["taken"])) if landed else 0
|
||||||
|
hp -= dealt
|
||||||
|
hurt += dealt
|
||||||
|
events.append([round(t / pace, 2), "foe", ("crit" if crit else "hit") if landed else "miss", dealt, max(0, round(hp)), foe_hp])
|
||||||
|
while hp < top * EAT_BELOW and any(pantry.values()):
|
||||||
|
food = next(item for item, left in pantry.items() if left)
|
||||||
|
pantry[food] -= 1
|
||||||
|
eaten[food] = eaten.get(food, 0) + 1
|
||||||
|
hp = min(top, hp + ITEMS[food]["heal"])
|
||||||
|
events.append([round(t / pace, 2), "you", "eat", ITEMS[food]["heal"], round(hp), foe_hp, food])
|
||||||
|
beaten, starving = hp <= 0, hp < top * FLEE_BELOW and not any(pantry.values())
|
||||||
|
if (beaten or starving) and wind:
|
||||||
|
# Second Wind: the retreat that was coming turns into half health, once per outing.
|
||||||
|
wind = False
|
||||||
|
hp = top / 2
|
||||||
|
events.append([round(t / pace, 2), "you", "wind", 0, round(hp), foe_hp])
|
||||||
|
elif beaten:
|
||||||
|
hp, result = 1, "lost"
|
||||||
|
elif starving:
|
||||||
|
result = "fled"
|
||||||
|
return {"enemy": foe["name"], "enemy_hp": foe["hp"], "top": top, "start_hp": round(player["hp"]),
|
||||||
|
"hp": hp, "result": result, "end": round(t / pace, 2), "duration": (t + RESPAWN) / pace,
|
||||||
|
"interval": me["interval"] / pace, "enemy_interval": foe["interval"] / pace, "events": events,
|
||||||
|
"eaten": eaten, "damage": hurt, "pp": blessed["cost"],
|
||||||
|
"wind_used": blessed["wind"] and not wind and not (player["action"] or {}).get("wind_used")}
|
||||||
|
|
||||||
|
|
||||||
# --- prayer -------------------------------------------------------------------
|
# --- prayer -------------------------------------------------------------------
|
||||||
|
|
@ -1067,17 +1235,6 @@ def edible(player: dict) -> list[str]:
|
||||||
return foods
|
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:
|
def action_time(world: dict, player: dict, action: dict) -> float:
|
||||||
seconds = action["time"]
|
seconds = action["time"]
|
||||||
if action["skill"] in CLASSES[player["cls"]]["fast"]:
|
if action["skill"] in CLASSES[player["cls"]]["fast"]:
|
||||||
|
|
@ -1140,12 +1297,6 @@ def plant_second_plot(world: dict, player: dict, action_id: str | None) -> str |
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def with_potion(blessed: dict, brew: dict) -> dict:
|
|
||||||
"""A fight's prayer boosts with a combat potion's added on top."""
|
|
||||||
return {**blessed, "power": blessed["power"] + brew.get("power", 0),
|
|
||||||
"taken": blessed["taken"] * (1 - brew.get("reduce", 0))}
|
|
||||||
|
|
||||||
|
|
||||||
def potion_effect(player: dict, skill: str) -> dict:
|
def potion_effect(player: dict, skill: str) -> dict:
|
||||||
active = player.get("potions", {}).get(skill)
|
active = player.get("potions", {}).get(skill)
|
||||||
return ITEMS[active["item"]]["potion"] if active else {}
|
return ITEMS[active["item"]]["potion"] if active else {}
|
||||||
|
|
@ -1227,7 +1378,7 @@ def can_start(world: dict, player: dict, action: dict) -> str | None:
|
||||||
return "No raid to prepare for"
|
return "No raid to prepare for"
|
||||||
if threat.get("drilled", 0) >= drill_needed(threat):
|
if threat.get("drilled", 0) >= drill_needed(threat):
|
||||||
return "The militia is as drilled as it can be"
|
return "The militia is as drilled as it can be"
|
||||||
if "difficulty" in action and player["hp"] < max_hp(player) * TOO_HURT and not edible(player):
|
if "enemy" in action and player["hp"] < max_hp(player) * TOO_HURT and not edible(player):
|
||||||
return "Too hurt to fight. Rest, or bring food"
|
return "Too hurt to fight. Rest, or bring food"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -1258,38 +1409,34 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
|
||||||
outputs[item] = int(exact) + (1 if rng.random() < exact - int(exact) else 0)
|
outputs[item] = int(exact) + (1 if rng.random() < exact - int(exact) else 0)
|
||||||
xp *= mods["xp"]
|
xp *= mods["xp"]
|
||||||
|
|
||||||
if "difficulty" in action:
|
if "enemy" in action:
|
||||||
blessed = blessing(player)
|
|
||||||
if player["prayers"] and not blessed["cost"]:
|
|
||||||
summary["prayer_out"] = True
|
|
||||||
if blessed["cost"]:
|
|
||||||
player["pp"] -= blessed["cost"]
|
|
||||||
summary["pp_spent"] += blessed["cost"]
|
|
||||||
gain_xp(world, player, "prayer", blessed["cost"] * PRAYER_XP_PER_POINT, now, summary)
|
|
||||||
regen(world, player, now, blessed["regen"])
|
|
||||||
blessed = with_potion(blessed, brew)
|
|
||||||
auto_eat(player, summary)
|
|
||||||
chance = 0.5 + (combat_power(player) + blessed["power"] - action["difficulty"]) * 0.04
|
|
||||||
won = rng.random() < min(0.97, max(0.15, chance))
|
|
||||||
hurt = fight_damage(player, action, blessed) * rng.uniform(0.6, 1.4) * (1 if won else 1.3)
|
|
||||||
player["hp"] = min(max_hp(player), player["hp"] - hurt + brew.get("heal", 0))
|
|
||||||
summary["damage"] += hurt
|
|
||||||
auto_eat(player, summary)
|
|
||||||
outing = player["action"]
|
outing = player["action"]
|
||||||
if player["hp"] <= 0 and blessed["wind"] and outing and not outing.get("wind_used"):
|
fight = outing.pop("fight", None) or simulate_fight(world, player, action, rng)
|
||||||
|
if player["prayers"] and not fight["pp"]:
|
||||||
|
summary["prayer_out"] = True
|
||||||
|
paid = min(player["pp"], fight["pp"])
|
||||||
|
if paid:
|
||||||
|
player["pp"] -= paid
|
||||||
|
summary["pp_spent"] += paid
|
||||||
|
gain_xp(world, player, "prayer", paid * PRAYER_XP_PER_POINT, now, summary)
|
||||||
|
for food, count in fight["eaten"].items():
|
||||||
|
count = min(count, player["bank"].get(food, 0))
|
||||||
|
if count:
|
||||||
|
bank_take(player, food, count)
|
||||||
|
summary["eaten"][food] = summary["eaten"].get(food, 0) + count
|
||||||
|
player["hp"], player["hp_at"] = fight["hp"], now
|
||||||
|
summary["damage"] += fight["damage"]
|
||||||
|
if fight["wind_used"]:
|
||||||
outing["wind_used"] = True
|
outing["wind_used"] = True
|
||||||
player["hp"] = max_hp(player) / 2
|
|
||||||
summary["second_wind"] += 1
|
summary["second_wind"] += 1
|
||||||
if player["hp"] <= 0:
|
if fight["result"] != "won":
|
||||||
# Out of food and out of luck: fall back with nothing but your life.
|
# Beaten or out of food: fall back with nothing but your life.
|
||||||
player["hp"] = 1
|
|
||||||
summary["losses"] += 1
|
summary["losses"] += 1
|
||||||
reason = "Retreated: ran out of food" if not edible(player) else "Retreated"
|
reason = "Retreated: ran out of food" if not edible(player) else "Retreated"
|
||||||
stop(player, reason)
|
stop(player, reason)
|
||||||
summary["stopped"] = reason
|
summary["stopped"] = reason
|
||||||
gain_xp(world, player, "combat", xp // 3, now, summary)
|
gain_xp(world, player, "combat", xp // 3, now, summary)
|
||||||
return
|
return
|
||||||
if won:
|
|
||||||
summary["wins"] += 1
|
summary["wins"] += 1
|
||||||
player["gold"] += action.get("gold", 0)
|
player["gold"] += action.get("gold", 0)
|
||||||
summary["gold"] += action.get("gold", 0)
|
summary["gold"] += action.get("gold", 0)
|
||||||
|
|
@ -1301,9 +1448,6 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
|
||||||
raid["sabotage"] = min(0.3, raid["sabotage"] + 0.03)
|
raid["sabotage"] = min(0.3, raid["sabotage"] + 0.03)
|
||||||
if action.get("special") == "problem":
|
if action.get("special") == "problem":
|
||||||
_work_problem(world, player, action, now)
|
_work_problem(world, player, action, now)
|
||||||
else:
|
|
||||||
summary["losses"] += 1
|
|
||||||
xp = xp // 3
|
|
||||||
if action.get("special") == "recon":
|
if action.get("special") == "recon":
|
||||||
event = upcoming_raid(world)
|
event = upcoming_raid(world)
|
||||||
# Once a threat is fully scouted, scouting only trains the skill (and keeps the informant's leads).
|
# Once a threat is fully scouted, scouting only trains the skill (and keeps the informant's leads).
|
||||||
|
|
@ -1391,6 +1535,12 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N
|
||||||
# The second crop can't grow right now (a problem, too low a level): tend the first only.
|
# The second crop can't grow right now (a problem, too low a level): tend the first only.
|
||||||
current["turn"] = 0
|
current["turn"] = 0
|
||||||
action = growing(player)
|
action = growing(player)
|
||||||
|
if "enemy" in action and not can_start(world, player, action):
|
||||||
|
if not current.get("fight"):
|
||||||
|
regen(world, player, current["start"], blessing(player)["regen"])
|
||||||
|
current["fight"] = simulate_fight(world, player, action, rng)
|
||||||
|
duration = current["fight"]["duration"]
|
||||||
|
else:
|
||||||
duration = action_time(world, player, action)
|
duration = action_time(world, player, action)
|
||||||
if current["start"] + duration > limit:
|
if current["start"] + duration > limit:
|
||||||
break
|
break
|
||||||
|
|
@ -2266,9 +2416,9 @@ PROBLEMS = {
|
||||||
}
|
}
|
||||||
MINOR_THREATS = ("collapse", "wolves", "bear", "goblins", "rockslide", "flood", "boars")
|
MINOR_THREATS = ("collapse", "wolves", "bear", "goblins", "rockslide", "flood", "boars")
|
||||||
PROBLEM_ACTIONS = [
|
PROBLEM_ACTIONS = [
|
||||||
_action("clear_wolves", "combat", "Drive off the wolves", 1, 60, 16, difficulty=6, damage=3,
|
_fight("clear_wolves", monster("Wolf pack", "beast", 4), "Frontier", name="Drive off the wolves",
|
||||||
special="problem", problem="wolves"),
|
special="problem", problem="wolves"),
|
||||||
_action("catch_goblins", "combat", "Catch the goblin thieves", 1, 60, 16, difficulty=8, damage=3,
|
_fight("catch_goblins", monster("Goblin thieves", "human", 5), "Frontier", name="Catch the goblin thieves",
|
||||||
special="problem", problem="goblins"),
|
special="problem", problem="goblins"),
|
||||||
_action("drive_bear", "hunting", "Drive off the bear", 1, 45, 14, special="problem", problem="bear"),
|
_action("drive_bear", "hunting", "Drive off the bear", 1, 45, 14, special="problem", problem="bear"),
|
||||||
_action("cull_boars", "hunting", "Cull the boar herd", 1, 45, 14, special="problem", problem="boars"),
|
_action("cull_boars", "hunting", "Cull the boar herd", 1, 45, 14, special="problem", problem="boars"),
|
||||||
|
|
@ -2935,6 +3085,18 @@ PRAYER_GLYPHS = {"thick_skin": ("shield", "#a8a29e"), "burst_of_strength": ("bic
|
||||||
"rapid_heal": ("heart-plus", "#e06a8a"), "steel_skin": ("armor-upgrade", "#d4dae2"),
|
"rapid_heal": ("heart-plus", "#e06a8a"), "steel_skin": ("armor-upgrade", "#d4dae2"),
|
||||||
"superhuman_strength": ("biceps", "#ff8a3a"), "second_wind": ("wind-hole", "#8fd0e0"),
|
"superhuman_strength": ("biceps", "#ff8a3a"), "second_wind": ("wind-hole", "#8fd0e0"),
|
||||||
"protection": ("aura", "#f0d060"), "piety": ("angel-wings", "#f0e6c0")}
|
"protection": ("aura", "#f0d060"), "piety": ("angel-wings", "#f0e6c0")}
|
||||||
|
TROPHY_GLYPHS = {"golem": "crystal-growth", "troll": "animal-hide", "spirit": "magic-swirl", "flyer": "sewing-string"}
|
||||||
|
TIER_TINTS = ["#9aa1ab", "#d4dae2", "#6f8fec", "#4cb878", "#3fd0e0"] # trophies of areas II to VI
|
||||||
|
MONSTER_GLYPHS = {"golem": ["rock-golem"] * 5, "troll": ["troll"] * 5,
|
||||||
|
"spirit": ["fairy", "spectre", "ghost", "spectre", "ghost"],
|
||||||
|
"flyer": ["bat", "harpy", "wyvern", "griffin-symbol", "eagle-head"]}
|
||||||
|
ACTION_GLYPHS.update({
|
||||||
|
"fight_goblins": ("goblin-head", "#8fbf6a"), "fight_ruins": ("ogre", "#a08060"),
|
||||||
|
"fight_barrow_king": ("crowned-skull", "#c8d0e0"), "drill_militia": ACTION_GLYPHS["drill_militia"],
|
||||||
|
})
|
||||||
|
for _kind, (_offset, _monsters, _trophies) in AREA_MONSTERS.items():
|
||||||
|
for _index, _name in enumerate(_monsters):
|
||||||
|
ACTION_GLYPHS[f"fight_{_slug(_name)}"] = (MONSTER_GLYPHS[_kind][_index], TIER_TINTS[_index])
|
||||||
UI_GLYPHS = ["two-coins", "sparkles", "hearts", "prayer", "checked-shield"]
|
UI_GLYPHS = ["two-coins", "sparkles", "hearts", "prayer", "checked-shield"]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2959,6 +3121,8 @@ def _assign_glyphs() -> None:
|
||||||
ITEMS[leather[0]].update(glyph="rolled-cloth", tint=tint)
|
ITEMS[leather[0]].update(glyph="rolled-cloth", tint=tint)
|
||||||
for suffix, glyph in LEATHER_GLYPHS.items():
|
for suffix, glyph in LEATHER_GLYPHS.items():
|
||||||
ITEMS[f"{prefix}_{suffix}"].update(glyph=glyph, tint=tint)
|
ITEMS[f"{prefix}_{suffix}"].update(glyph=glyph, tint=tint)
|
||||||
|
for trophy, (_name, kind, tier) in TROPHIES.items():
|
||||||
|
ITEMS[trophy].update(glyph=TROPHY_GLYPHS[kind], tint=TIER_TINTS[tier - 1])
|
||||||
for herb, tint in HERB_TINTS.items():
|
for herb, tint in HERB_TINTS.items():
|
||||||
ITEMS[herb].update(glyph="herbs-bundle", tint=tint)
|
ITEMS[herb].update(glyph="herbs-bundle", tint=tint)
|
||||||
flasks = ["round-bottom-flask", "round-bottom-flask", "standing-potion", "standing-potion",
|
flasks = ["round-bottom-flask", "round-bottom-flask", "standing-potion", "standing-potion",
|
||||||
|
|
|
||||||
|
|
@ -229,7 +229,7 @@ function sidebar() {
|
||||||
<div class="brand"><img src="/static/icon.svg" alt="">Guildhall <small>alpha</small></div>
|
<div class="brand"><img src="/static/icon.svg" alt="">Guildhall <small>alpha</small></div>
|
||||||
<div class="me">
|
<div class="me">
|
||||||
<div class="who">${esc(me.name)}</div>
|
<div class="who">${esc(me.name)}</div>
|
||||||
<div class="cls">${cls.icon} ${cls.name} · ${HEART} ${fmt(me.hp)}/${me.max_hp}</div>
|
<div class="cls">${cls.icon} ${cls.name} · ${HEART} <span data-live-hp>${fmt(me.hp)}</span>/${me.max_hp}</div>
|
||||||
<div class="purse"><span class="gold" title="Gold: from fights and selling. Fund buildings with it or give it to the treasury">${COIN} ${fmt(me.gold)}</span><span class="favour" title="Favour: earned by giving to the city. Spend it on the Council page, or on extra bank slots">${FAVOUR} ${fmt(me.favour)}</span></div>
|
<div class="purse"><span class="gold" title="Gold: from fights and selling. Fund buildings with it or give it to the treasury">${COIN} ${fmt(me.gold)}</span><span class="favour" title="Favour: earned by giving to the city. Spend it on the Council page, or on extra bank slots">${FAVOUR} ${fmt(me.favour)}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="navhead">City</div>
|
<div class="navhead">City</div>
|
||||||
|
|
@ -339,7 +339,7 @@ function skillPage(id) {
|
||||||
</div>
|
</div>
|
||||||
${fast ? `<span class="tag gold">${cls.icon} ${cls.name} bonus</span>` : ""}
|
${fast ? `<span class="tag gold">${cls.icon} ${cls.name} bonus</span>` : ""}
|
||||||
</div></div>`;
|
</div></div>`;
|
||||||
if (id === "combat") html += combatPanel();
|
if (id === "combat") html += fightPanel() + combatPanel();
|
||||||
if (id === "prayer") html += prayerPanel();
|
if (id === "prayer") html += prayerPanel();
|
||||||
if (id === "construction") html += S.city.construction ? siteBanner()
|
if (id === "construction") html += S.city.construction ? siteBanner()
|
||||||
: `<div class="panel muted">Nothing is being built right now. Fund a building on the Town square and its site opens here.</div>`;
|
: `<div class="panel muted">Nothing is being built right now. Fund a building on the Town square and its site opens here.</div>`;
|
||||||
|
|
@ -358,7 +358,7 @@ function skillPage(id) {
|
||||||
const open = (g) => actions.some((a) => a.group === g && !isLocked(a));
|
const open = (g) => actions.some((a) => a.group === g && !isLocked(a));
|
||||||
let current = tabs[id] || remember(`tab.${id}`);
|
let current = tabs[id] || remember(`tab.${id}`);
|
||||||
// Tier ladders open on the highest tier you can work; categories open on the first.
|
// Tier ladders open on the highest tier you can work; categories open on the first.
|
||||||
const ladder = ["smithing", "leatherworking", "herblore"].includes(id);
|
const ladder = ["smithing", "leatherworking", "herblore", "combat"].includes(id);
|
||||||
if (!groups.includes(current)) current = (ladder && [...groups].reverse().find(open)) || groups[0];
|
if (!groups.includes(current)) current = (ladder && [...groups].reverse().find(open)) || groups[0];
|
||||||
const bar = groups.map((g) => `<button class="tab ${g === current ? "on" : ""} ${open(g) ? "" : "dim"}" data-tab="${id}" data-group="${esc(g)}">${esc(g)}</button>`).join("");
|
const bar = groups.map((g) => `<button class="tab ${g === current ? "on" : ""} ${open(g) ? "" : "dim"}" data-tab="${id}" data-group="${esc(g)}">${esc(g)}</button>`).join("");
|
||||||
// Ungrouped actions (the building site) stay above the tabs.
|
// Ungrouped actions (the building site) stay above the tabs.
|
||||||
|
|
@ -441,6 +441,63 @@ function toolPanel(skill) {
|
||||||
: `<span class="muted">No tool. A smith can make you one that speeds this up.</span>`}</div>`;
|
: `<span class="muted">No tool. A smith can make you one that speeds this up.</span>`}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The fight in progress, replayed from the server's swing-by-swing record.
|
||||||
|
function fightPanel() {
|
||||||
|
const f = S.me.action?.fight;
|
||||||
|
if (!f) return "";
|
||||||
|
const side = (who, name) => `<div class="fighter">
|
||||||
|
<div class="fighter-name"><b>${esc(name)}</b><span class="small muted" data-f="${who}-hp"></span></div>
|
||||||
|
<div class="bar hp ${who}"><span data-f="${who}-bar"></span></div>
|
||||||
|
<div class="bar swing"><span data-f="${who}-swing"></span></div>
|
||||||
|
</div>`;
|
||||||
|
return `<div class="panel fight" data-fight="${S.me.action.start}">
|
||||||
|
<div class="fighters">${side("you", S.me.name)}<span class="vs">⚔️</span>${side("foe", f.enemy)}</div>
|
||||||
|
<ul class="fight-log" data-f="log"></ul>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fightLine(e, enemy) {
|
||||||
|
const [, who, kind, amount, , , food] = e;
|
||||||
|
const foe = esc(enemy);
|
||||||
|
if (kind === "eat") return `<li class="good">You eat ${esc(item(food).name.toLowerCase())} (+${amount})</li>`;
|
||||||
|
if (kind === "wind") return `<li class="good">Second wind! Back on your feet</li>`;
|
||||||
|
if (who === "you") return kind === "miss" ? `<li class="muted">You miss</li>` : `<li>You ${kind === "crit" ? "<b>crit</b>" : "hit"} for <b>${amount}</b></li>`;
|
||||||
|
return kind === "miss" ? `<li class="muted">${foe} misses</li>` : `<li class="bad">${foe} ${kind === "crit" ? "<b>crits</b>" : "hits"} you for <b>${amount}</b></li>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function animateFight(now) {
|
||||||
|
const panel = $("[data-fight]"), a = S.me?.action, f = a?.fight;
|
||||||
|
if (!panel || !f) return;
|
||||||
|
const t = now - a.start;
|
||||||
|
let shown = -1;
|
||||||
|
while (shown + 1 < f.events.length && f.events[shown + 1][0] <= t) shown++;
|
||||||
|
const last = f.events[shown];
|
||||||
|
const hp = last ? last[4] : f.start_hp, foeHp = last ? last[5] : f.enemy_hp;
|
||||||
|
// Your HP everywhere follows the fight as it plays out.
|
||||||
|
for (const el of document.querySelectorAll("[data-live-hp]")) el.textContent = fmt(hp);
|
||||||
|
for (const el of document.querySelectorAll("[data-live-hpbar]")) el.style.width = `${Math.max(0, hp / f.top * 100)}%`;
|
||||||
|
const set = (key, fn) => { const el = panel.querySelector(`[data-f="${key}"]`); if (el) fn(el); };
|
||||||
|
set("you-bar", (el) => { el.style.width = `${Math.max(0, hp / f.top * 100)}%`; });
|
||||||
|
set("foe-bar", (el) => { el.style.width = `${Math.max(0, foeHp / f.enemy_hp * 100)}%`; });
|
||||||
|
set("you-hp", (el) => { el.textContent = `${fmt(hp)} / ${fmt(f.top)}`; });
|
||||||
|
set("foe-hp", (el) => { el.textContent = `${fmt(foeHp)} / ${fmt(f.enemy_hp)}`; });
|
||||||
|
const over = t >= f.end;
|
||||||
|
for (const [who, every] of [["you", f.interval], ["foe", f.enemy_interval]]) {
|
||||||
|
let swung = 0;
|
||||||
|
for (let i = shown; i >= 0; i--) if (f.events[i][1] === who && f.events[i][2] !== "eat" && f.events[i][2] !== "wind") { swung = f.events[i][0]; break; }
|
||||||
|
set(`${who}-swing`, (el) => { el.style.width = over ? "0%" : `${Math.min(100, (t - swung) / every * 100)}%`; });
|
||||||
|
}
|
||||||
|
set("log", (el) => {
|
||||||
|
const key = `${a.start}:${shown}:${over}`;
|
||||||
|
if (el.dataset.key === key) return;
|
||||||
|
el.dataset.key = key;
|
||||||
|
const lines = f.events.slice(Math.max(0, shown - 5), shown + 1).reverse().map((e) => fightLine(e, f.enemy));
|
||||||
|
if (over) lines.unshift(f.result === "won" ? `<li class="good"><b>${esc(f.enemy)} is beaten.</b> The next one is coming…</li>`
|
||||||
|
: `<li class="bad"><b>You fall back.</b></li>`);
|
||||||
|
el.innerHTML = lines.join("") || `<li class="muted">Squaring up…</li>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function combatPanel() {
|
function combatPanel() {
|
||||||
const me = S.me;
|
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 stat = (id) => [item(id).power && `+${item(id).power} power`, item(id).armour && `+${item(id).armour} armour`].filter(Boolean).join(", ");
|
||||||
|
|
@ -454,8 +511,8 @@ function combatPanel() {
|
||||||
const hpPct = me.hp / me.max_hp * 100;
|
const hpPct = me.hp / me.max_hp * 100;
|
||||||
return `<div class="cols">
|
return `<div class="cols">
|
||||||
<div class="panel"><h2>Health</h2>
|
<div class="panel"><h2>Health</h2>
|
||||||
<div class="hp-line"><b>${HEART} ${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="hp-line"><b>${HEART} <span data-live-hp>${fmt(me.hp)}</span> / ${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="bar thick hp"><span data-live-hpbar style="width:${hpPct}%"></span></div>
|
||||||
<div class="row" style="margin-top:12px"><label class="small muted">Food in fights
|
<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>
|
<select data-food-pick>${`<option value="">Cheapest first</option>` + foods}</select></label></div>
|
||||||
<p class="small">${skillOf("prayer").icon} ${me.prayers.length ? `${me.prayers.map((id) => esc(C.prayers.find((p) => p.id === id).name)).join(" + ")} · ${fmt(me.pp)} points${me.blessing.cost ? "" : ` <b class="warn">(not enough points: prayers are off)</b>`}` : `<span class="muted">No prayers active.</span>`}
|
<p class="small">${skillOf("prayer").icon} ${me.prayers.length ? `${me.prayers.map((id) => esc(C.prayers.find((p) => p.id === id).name)).join(" + ")} · ${fmt(me.pp)} points${me.blessing.cost ? "" : ` <b class="warn">(not enough points: prayers are off)</b>`}` : `<span class="muted">No prayers active.</span>`}
|
||||||
|
|
@ -481,6 +538,7 @@ function itemStats(id) {
|
||||||
if (it.tool) parts.push(`${Math.round(it.speed * 100)}% faster ${skillOf(it.tool).name.toLowerCase()}`);
|
if (it.tool) parts.push(`${Math.round(it.speed * 100)}% faster ${skillOf(it.tool).name.toLowerCase()}`);
|
||||||
if (it.req && it.req[1] > 1) parts.push(`needs ${skillOf(it.req[0]).name.toLowerCase()} ${it.req[1]}`);
|
if (it.req && it.req[1] > 1) parts.push(`needs ${skillOf(it.req[0]).name.toLowerCase()} ${it.req[1]}`);
|
||||||
if (it.potion) parts.push(`${it.potion.text}, ${C.potion_charges} uses`);
|
if (it.potion) parts.push(`${it.potion.text}, ${C.potion_charges} uses`);
|
||||||
|
if (it.trophy) parts.push(`An expedition trophy: stronger weapons will need these`);
|
||||||
if (it.fitting) {
|
if (it.fitting) {
|
||||||
const f = C.fittings.find((x) => x.id === id), done = S.city.buildings[f.building].fittings.includes(id);
|
const f = C.fittings.find((x) => x.id === id), done = S.city.buildings[f.building].fittings.includes(id);
|
||||||
parts.push(`${C.buildings[f.building].icon} For the ${C.buildings[f.building].name.toLowerCase()} (Lv ${f.level}): ${f.effect}${done ? " · already fitted" : ""}`);
|
parts.push(`${C.buildings[f.building].icon} For the ${C.buildings[f.building].name.toLowerCase()} (Lv ${f.level}): ${f.effect}${done ? " · already fitted" : ""}`);
|
||||||
|
|
@ -507,9 +565,11 @@ function actionCard(a) {
|
||||||
}
|
}
|
||||||
io += Object.entries(a.outputs).map(([id, q]) => itemChip(id, q)).join("");
|
io += Object.entries(a.outputs).map(([id, q]) => itemChip(id, q)).join("");
|
||||||
let extra = "";
|
let extra = "";
|
||||||
if ("difficulty" in a) {
|
if (a.enemy) {
|
||||||
const chance = Math.min(0.97, Math.max(0.15, 0.5 + (me.power - a.difficulty) * 0.04));
|
const o = me.fight_odds[a.id], e = a.enemy, pct = (x) => `${Math.round(x * 100)}%`;
|
||||||
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>`;
|
extra = `<div class="small">${HEART} ${fmt(e.hp)} HP · hits up to <b>${o.their_max}</b> every ${e.interval}s${e.boss ? ` · <span class="tag warn">Boss</span>` : ""}</div>
|
||||||
|
<div class="small">You land <b>${pct(o.hit)}</b> of swings (up to ${o.max_hit}) · it lands <b>${pct(o.their_hit)}</b></div>
|
||||||
|
<div class="small">About <b>${dur(o.kill_time)}</b> a kill, <b>${Math.round(o.damage)}</b> damage taken${a.gold ? ` · ${a.gold} gold` : ""}</div>`;
|
||||||
if (a.drops) io += a.drops.map(([id, q, odds]) => `<span class="item-chip" title="${esc(item(id).name)}: ${Math.round(odds * 100)}% chance">${item(id).icon} ${q} <span class="have">${Math.round(odds * 100)}%</span></span>`).join("");
|
if (a.drops) io += a.drops.map(([id, q, odds]) => `<span class="item-chip" title="${esc(item(id).name)}: ${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>`;
|
if (a.special === "sabotage") extra += `<div class="small muted">Each win weakens the coming raid by 3%, up to 30%.</div>`;
|
||||||
}
|
}
|
||||||
|
|
@ -549,7 +609,7 @@ function actionCard(a) {
|
||||||
<span class="why">${!active && !second && why ? esc(why) : ""}</span>
|
<span class="why">${!active && !second && why ? esc(why) : ""}</span>
|
||||||
${second ? `<button class="btn" data-plot="">Clear plot 2</button>`
|
${second ? `<button class="btn" data-plot="">Clear plot 2</button>`
|
||||||
: active ? `<button class="btn danger" data-stop>Stop</button>`
|
: active ? `<button class="btn danger" data-stop>Stop</button>`
|
||||||
: `<span class="btns"><button class="btn" data-once="${a.id}" ${why ? "disabled" : ""} title="Do this once, then go back to what you were doing">${"difficulty" in a ? "Fight once" : Object.keys(a.outputs).length ? "Make one" : "Once"}</button>
|
: `<span class="btns"><button class="btn" data-once="${a.id}" ${why ? "disabled" : ""} title="Do this once, then go back to what you were doing">${a.enemy ? "Fight once" : Object.keys(a.outputs).length ? "Make one" : "Once"}</button>
|
||||||
${canPlot ? `<button class="btn" data-plot="${a.id}" ${why ? "disabled" : ""} title="Grow this in your second plot as well">Plot 2</button>` : ""}
|
${canPlot ? `<button class="btn" data-plot="${a.id}" ${why ? "disabled" : ""} title="Grow this in your second plot as well">Plot 2</button>` : ""}
|
||||||
<button class="btn primary" data-start="${a.id}" ${why ? "disabled" : ""}>Start</button></span>`}
|
<button class="btn primary" data-start="${a.id}" ${why ? "disabled" : ""}>Start</button></span>`}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1063,6 +1123,7 @@ function register() {
|
||||||
function animate() {
|
function animate() {
|
||||||
const now = serverNow();
|
const now = serverNow();
|
||||||
const a = S?.me?.action;
|
const a = S?.me?.action;
|
||||||
|
animateFight(now);
|
||||||
if (a) {
|
if (a) {
|
||||||
const pct = Math.min(1, (now - a.start) / a.duration);
|
const pct = Math.min(1, (now - a.start) / a.duration);
|
||||||
for (const el of document.querySelectorAll("[data-progress]")) el.style.width = (pct * 100) + "%";
|
for (const el of document.querySelectorAll("[data-progress]")) el.style.width = (pct * 100) + "%";
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 211 KiB |
|
|
@ -229,6 +229,21 @@ select { background: var(--bg); border: 1px solid var(--line); border-radius: 8p
|
||||||
.building.damaged { border-color: rgba(226, 100, 92, .6); }
|
.building.damaged { border-color: rgba(226, 100, 92, .6); }
|
||||||
.cost { display: flex; flex-wrap: wrap; gap: 6px; }
|
.cost { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
.fund { display: flex; flex-direction: column; gap: 6px; }
|
.fund { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.fight .fighters { display: grid; grid-template-columns: 1fr auto 1fr; gap: 16px; align-items: center; }
|
||||||
|
.fight .vs { font-size: 22px; }
|
||||||
|
.fighter { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||||
|
.fighter-name { display: flex; justify-content: space-between; gap: 8px; }
|
||||||
|
.fighter-name b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.fighter-name span { white-space: nowrap; }
|
||||||
|
@media (max-width: 500px) { .fight .fighters { gap: 8px; } .fighter-name { flex-direction: column; gap: 0; } }
|
||||||
|
.fight .bar.hp { height: 12px; }
|
||||||
|
.fight .bar.hp.you > span { background: var(--good); }
|
||||||
|
.fight .bar.hp.foe > span { background: var(--bad); }
|
||||||
|
.fight .bar.swing { height: 4px; }
|
||||||
|
.fight .bar.swing > span { background: var(--gold); transition: none; }
|
||||||
|
.fight-log { list-style: none; margin: 12px 0 0; padding: 0; font-size: 13px; display: flex; flex-direction: column; gap: 2px; min-height: 7.5em; }
|
||||||
|
.fight-log .good { color: var(--good); }
|
||||||
|
.fight-log .bad { color: var(--bad); }
|
||||||
.fund-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
|
.fund-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
|
||||||
.fittings { display: flex; flex-direction: column; gap: 6px; padding-top: 8px; border-top: 1px solid var(--line); }
|
.fittings { display: flex; flex-direction: column; gap: 6px; padding-top: 8px; border-top: 1px solid var(--line); }
|
||||||
.fitting-row { display: flex; align-items: center; gap: 8px; }
|
.fitting-row { display: flex; align-items: center; gap: 8px; }
|
||||||
|
|
|
||||||
|
|
@ -160,9 +160,9 @@ class ActionTest(unittest.TestCase):
|
||||||
p = world["players"]["wil"]
|
p = world["players"]["wil"]
|
||||||
p["bank"] = {"bread": 500}
|
p["bank"] = {"bread": 500}
|
||||||
game.start_action(world, p, "fight_wolves", T0)
|
game.start_action(world, p, "fight_wolves", T0)
|
||||||
summary = game.process_player(world, p, T0 + 60 * 100, random.Random(5))
|
summary = game.process_player(world, p, T0 + 3600, random.Random(5))
|
||||||
self.assertEqual(summary["done"], 100)
|
self.assertGreater(summary["done"], 50) # a fight lasts as long as it takes
|
||||||
self.assertEqual(summary["wins"] + summary["losses"], 100)
|
self.assertEqual(summary["wins"] + summary["losses"], summary["done"])
|
||||||
self.assertEqual(p["gold"], summary["wins"] * 2)
|
self.assertEqual(p["gold"], summary["wins"] * 2)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -490,7 +490,7 @@ class CombatTest(unittest.TestCase):
|
||||||
summary = game.process_player(world, p, T0 + 60 * 60, random.Random(4))
|
summary = game.process_player(world, p, T0 + 60 * 60, random.Random(4))
|
||||||
self.assertGreater(summary["damage"], 0)
|
self.assertGreater(summary["damage"], 0)
|
||||||
self.assertGreater(summary["eaten"].get("bread", 0), 0)
|
self.assertGreater(summary["eaten"].get("bread", 0), 0)
|
||||||
self.assertEqual(p["bank"]["bread"], 100 - summary["eaten"]["bread"])
|
self.assertEqual(p["bank"].get("bread", 0), 100 - summary["eaten"]["bread"])
|
||||||
self.assertGreaterEqual(p["hp"], 1)
|
self.assertGreaterEqual(p["hp"], 1)
|
||||||
|
|
||||||
def test_out_of_food_means_retreat_and_nothing_lost(self):
|
def test_out_of_food_means_retreat_and_nothing_lost(self):
|
||||||
|
|
@ -515,9 +515,25 @@ class CombatTest(unittest.TestCase):
|
||||||
world, bare = self.fighter(level=10)
|
world, bare = self.fighter(level=10)
|
||||||
_, armoured = self.fighter(level=10, helmet="iron_helmet", body="iron_body", legs="iron_legs")
|
_, armoured = self.fighter(level=10, helmet="iron_helmet", body="iron_body", legs="iron_legs")
|
||||||
_, veteran = self.fighter(level=60)
|
_, veteran = self.fighter(level=60)
|
||||||
wolves = game.ACTION_BY_ID["fight_scouts"]
|
scouts = game.ACTION_BY_ID["fight_scouts"]
|
||||||
self.assertLess(game.fight_damage(armoured, wolves), game.fight_damage(bare, wolves) * 0.7)
|
hurt = lambda p: game.fight_odds(p, scouts)["damage"]
|
||||||
self.assertLess(game.fight_damage(veteran, wolves), game.fight_damage(bare, wolves))
|
self.assertLess(hurt(armoured), hurt(bare) * 0.7)
|
||||||
|
self.assertLess(hurt(veteran), hurt(bare))
|
||||||
|
self.assertGreater(game.fight_odds(veteran, scouts)["hit"], game.fight_odds(bare, scouts)["hit"])
|
||||||
|
|
||||||
|
def test_a_fight_is_played_out_swing_by_swing(self):
|
||||||
|
world, p = self.fighter(level=10, weapon="iron_sword")
|
||||||
|
p["bank"] = {"bread": 50}
|
||||||
|
game.start_action(world, p, "fight_scouts", T0)
|
||||||
|
game.process_player(world, p, T0 + 1, random.Random(2))
|
||||||
|
fight = p["action"]["fight"] # simulated, waiting to finish, for the page to replay
|
||||||
|
self.assertEqual(fight["result"], "won")
|
||||||
|
self.assertTrue(all(e[1] in ("you", "foe") and e[2] in ("hit", "crit", "miss", "eat", "wind") for e in fight["events"]))
|
||||||
|
self.assertEqual(fight["events"][-1][5], 0) # the last swing finished it
|
||||||
|
self.assertAlmostEqual(fight["duration"], fight["end"] + game.RESPAWN, places=1)
|
||||||
|
summary = game.process_player(world, p, T0 + fight["duration"] + 0.5, random.Random(2))
|
||||||
|
self.assertEqual(summary["wins"], 1)
|
||||||
|
self.assertEqual(p["hp"], fight["hp"])
|
||||||
|
|
||||||
def test_preferred_food_is_eaten_first(self):
|
def test_preferred_food_is_eaten_first(self):
|
||||||
world, p = self.fighter()
|
world, p = self.fighter()
|
||||||
|
|
@ -764,12 +780,12 @@ class PrayerTest(unittest.TestCase):
|
||||||
def test_prayers_cost_points_cut_damage_and_train_prayer(self):
|
def test_prayers_cost_points_cut_damage_and_train_prayer(self):
|
||||||
world, p = self.priest(level=10)
|
world, p = self.priest(level=10)
|
||||||
scouts = game.ACTION_BY_ID["fight_scouts"]
|
scouts = game.ACTION_BY_ID["fight_scouts"]
|
||||||
plain = game.fight_damage(p, scouts)
|
plain = game.fight_odds(p, scouts)["damage"]
|
||||||
game.set_prayers(p, ["thick_skin", "burst_of_strength"])
|
game.set_prayers(p, ["thick_skin", "burst_of_strength"])
|
||||||
p["pp"] = 4
|
p["pp"] = 4
|
||||||
blessed = game.blessing(p)
|
blessed = game.blessing(p)
|
||||||
self.assertEqual(blessed["cost"], 2)
|
self.assertEqual(blessed["cost"], 2)
|
||||||
self.assertLess(game.fight_damage(p, scouts, blessed), plain * 0.9)
|
self.assertLess(game.fight_odds(p, scouts)["damage"], plain * 0.9)
|
||||||
p["bank"] = {"bread": 200}
|
p["bank"] = {"bread": 200}
|
||||||
game.start_action(world, p, "fight_scouts", T0)
|
game.start_action(world, p, "fight_scouts", T0)
|
||||||
summary = game.process_player(world, p, T0 + 600, random.Random(1))
|
summary = game.process_player(world, p, T0 + 600, random.Random(1))
|
||||||
|
|
@ -884,8 +900,10 @@ class HerbloreTest(unittest.TestCase):
|
||||||
game.use_potion(p, "defence_potion_6")
|
game.use_potion(p, "defence_potion_6")
|
||||||
game.start_action(world, p, "fight_scouts", T0)
|
game.start_action(world, p, "fight_scouts", T0)
|
||||||
scouts = game.ACTION_BY_ID["fight_scouts"]
|
scouts = game.ACTION_BY_ID["fight_scouts"]
|
||||||
brewed = game.with_potion(game.NO_BLESSING, game.potion_effect(p, "combat"))
|
brewed = game.fight_odds(p, scouts)["damage"]
|
||||||
self.assertAlmostEqual(game.fight_damage(p, scouts, brewed), game.fight_damage(p, scouts) * 0.70)
|
active = p["potions"].pop("combat")
|
||||||
|
self.assertAlmostEqual(brewed, game.fight_odds(p, scouts)["damage"] * 0.70, delta=0.2)
|
||||||
|
p["potions"]["combat"] = active
|
||||||
summary = game.process_player(world, p, T0 + 75 * 10, random.Random(1))
|
summary = game.process_player(world, p, T0 + 75 * 10, random.Random(1))
|
||||||
self.assertEqual(p["potions"]["combat"]["charges"], game.POTION_CHARGES - summary["done"])
|
self.assertEqual(p["potions"]["combat"]["charges"], game.POTION_CHARGES - summary["done"])
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue