Fight together: tag into anyone's fight

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-21 22:06:49 +01:00
parent e0e0a824a9
commit 2d2cb16aff
6 changed files with 574 additions and 102 deletions

View file

@ -28,9 +28,10 @@ Beta. The core game is in place; changes from here are mostly balance and tuning
Spine), each with four races and a boss (the dragons at the top). Every Spine), each with four races and a boss (the dragons at the top). Every
kill is one of the race's variants (a brute, a skirmisher, a caster, now kill is one of the race's variants (a brute, a skirmisher, a caster, now
and then a rare elite), so each encounter mixes damage types: spirit races and then a rare elite), so each encounter mixes damage types: spirit races
lean on magic, golems and trolls on blows. Races drop rare trophies (3%, lean on magic, golems and trolls on blows. Races drop trophies (10%,
6% from bosses) for stronger weapons and the area's armour reagents (10%, 20% from bosses) for stronger weapons and the area's armour reagents (10%,
far more from dragons). Each kill is followed by a 2-second pause. far more from dragons). Each kill is followed by a 2-second pause.
- **Multiplayer Combat:** tag into anyone's fight from the encounter card or the guild roster.
- **Weapons:** four styles, one per trade, and anyone can use any of them. - **Weapons:** four styles, one per trade, and anyone can use any of them.
Sword and shield (slashing, 2.4s swings, the shield blocks 15% of hits; Sword and shield (slashing, 2.4s swings, the shield blocks 15% of hits;
swords from Smithing, shields from Construction), warhammer (blunt, swords from Smithing, shields from Construction), warhammer (blunt,

13
app.py
View file

@ -119,11 +119,15 @@ def player_view(world: dict, player: dict, now: float) -> dict:
"crit": me["crit"], "interval": me["interval"], "block": me["block"], "damage": me["damage"]} "crit": me["crit"], "interval": me["interval"], "block": me["block"], "damage": me["damage"]}
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, fight = game.growing(player), player["action"].get("fight") action, fight = game.growing(player), game.current_fight(world, player)
duration = fight["duration"] if fight else game.action_time(world, player, action) duration = fight["duration"] if fight else game.action_time(world, player, action)
view["action"] = {**player["action"], "growing": action["id"], "duration": duration} view["action"] = {**player["action"], "growing": action["id"], "duration": duration}
if fight: if fight:
view["action"]["fight"] = game.fight_view(fight)
view["action"]["respawn"] = game.RESPAWN / game.speed(world) view["action"]["respawn"] = game.RESPAWN / game.speed(world)
party = game.party_of(world, player)
if party:
view["action"]["with"] = [uid for uid in party["members"] if uid != player["id"]]
# A fight takes as long as it takes: show the average time to a kill. # 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) 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} if "enemy" in action else game.action_time(world, player, action) for action in game.ACTIONS}
@ -193,6 +197,9 @@ def roster(world: dict, now: float) -> list[dict]:
"levels": {skill: game.skill_level(player, skill) for skill in game.SKILL_IDS}, "levels": {skill: game.skill_level(player, skill) for skill in game.SKILL_IDS},
"doing": action["name"] if action else None, "doing": action["name"] if action else None,
"skill": action["skill"] if action else None, "skill": action["skill"] if action else None,
# A fight others can tag into, and who's in it together.
"fight": player["action"]["id"] if game.joinable(world, player) and game.offline_limit(world, player) > now else None,
"party": (player["action"] or {}).get("party"),
"garrison": player["garrison"], "donated": player["donated"], "garrison": player["garrison"], "donated": player["donated"],
}) })
return sorted(people, key=lambda p: -p["donated"]) return sorted(people, key=lambda p: -p["donated"])
@ -334,6 +341,8 @@ class Handler(BaseHTTPRequestHandler):
return "Bad quantity" return "Bad quantity"
if path == "/api/action": if path == "/api/action":
return game.start_action(world, player, str(body.get("id")), now, once=bool(body.get("once"))) return game.start_action(world, player, str(body.get("id")), now, once=bool(body.get("once")))
if path == "/api/join":
return game.join_fight(world, player, str(body.get("id")), now)
if path == "/api/order": if path == "/api/order":
return game.post_order(world, player, body.get("kind"), str(item), qty, body.get("provide"), return game.post_order(world, player, body.get("kind"), str(item), qty, body.get("provide"),
body.get("reward"), now) body.get("reward"), now)
@ -356,7 +365,7 @@ class Handler(BaseHTTPRequestHandler):
player["seen_threat"] = max(player.get("seen_threat", 0), event_id) player["seen_threat"] = max(player.get("seen_threat", 0), event_id)
return None return None
if path == "/api/stop": if path == "/api/stop":
game.stop(player) game.drop_out(world, player, now)
return None return None
if path == "/api/donate": if path == "/api/donate":
return game.donate(world, player, item, qty, now) return game.donate(world, player, item, qty, now)

415
game.py
View file

@ -1138,7 +1138,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows
# --- world --------------------------------------------------------------- # --- world ---------------------------------------------------------------
WORLD_VERSION = 24 WORLD_VERSION = 25
def new_world(now: float, speed: float = 1.0) -> dict: def new_world(now: float, speed: float = 1.0) -> dict:
@ -1173,6 +1173,7 @@ def new_world(now: float, speed: float = 1.0) -> dict:
"board": [], "board": [],
"next_message_id": 1, "next_message_id": 1,
"orders": [], "next_order_id": 1, "orders": [], "next_order_id": 1,
"parties": {}, "next_party_id": 1,
} }
@ -1326,6 +1327,14 @@ def migrate(world: dict, now: float | None = None) -> dict:
player["xp"].setdefault("firekeeping", 0) player["xp"].setdefault("firekeeping", 0)
player["equip"].setdefault("tool_firekeeping", None) player["equip"].setdefault("tool_firekeeping", None)
world["version"] = 24 world["version"] = 24
if world["version"] < 25:
# Fighting together: fights are kept per fighter now, so the one in progress is drawn up again.
world.setdefault("parties", {})
world.setdefault("next_party_id", 1)
for player in world["players"].values():
if player["action"]:
player["action"].pop("fight", None)
world["version"] = 25
return world return world
@ -1800,76 +1809,145 @@ def fight_odds(player: dict, action: dict) -> dict:
"damage": round(row["damage"], 1)} for row in rows]} "damage": round(row["damage"], 1)} for row in rows]}
def simulate_fight(world: dict, player: dict, action: dict, rng: random.Random) -> dict: def fight_member(world: dict, player: dict, foe: dict, at: float = 0.0) -> dict:
"""Play out one fight swing by swing, both swing timers starting empty. Nothing changes """One side of a fight as it squares up (or tags in `at` seconds of game time in): kept with the
until it's over: the result is applied then.""" fight, so playing it out again later goes the same way."""
variants = action["enemy"]["variants"]
foe = rng.choices(variants, [v["weight"] for v in variants])[0]
blessed, brew = blessing(player), potion_effect(player, "combat") blessed, brew = blessing(player), potion_effect(player, "combat")
me = fighter(player, blessed, brew, foe) me = fighter(player, blessed, brew, foe)
hit, their_hit = hit_chance(me["accuracy"], foe["evasion"]), hit_chance(foe["accuracy"], me["evasion"]) return {"id": player["id"], "name": player["name"], "at": at, "left": None,
top, hp, foe_hp = max_hp(player), player["hp"], foe["hp"] "start_hp": round(player["hp"]), "top": max_hp(player), "style": me["style"],
pantry = {item: player["bank"][item] for item in edible(player)} "interval": me["interval"] / speed(world), "fighter": me, "hp_in": player["hp"],
eaten, events, hurt = {}, [], 0 "pantry": {item: player["bank"][item] for item in edible(player)}, "mend": blessed["mend"],
wind = blessed["wind"] and not (player["action"] or {}).get("wind_used") "wind": blessed["wind"] and not (player["action"] or {}).get("wind_used"), "pp": blessed["cost"]}
FIGHT_PRIVATE = ("fighter", "hp_in", "pantry", "mend", "wind")
def fight_view(fight: dict) -> dict:
"""A fight as the page sees it: no one's bank or stats."""
return {**fight, "members": [{key: value for key, value in member.items() if key not in FIGHT_PRIVATE}
for member in fight["members"]]}
def member_of(fight: dict, uid: str) -> dict:
return next(member for member in fight["members"] if member["id"] == uid)
def draw_fight(world: dict, players: list, action: dict, rng: random.Random) -> dict:
"""Pick the enemy and play the fight out for everyone in it."""
variants = action["enemy"]["variants"]
variant = rng.choices(range(len(variants)), [v["weight"] for v in variants])[0]
foe = variants[variant]
pace = speed(world) pace = speed(world)
# A bow looses its first arrow before the enemy closes in. fight = {"seed": rng.random(), "variant": variant, "pace": pace, "enemy": foe["name"], "enemy_hp": foe["hp"],
mine, theirs, result, t = (0.5 if me["first"] else me["interval"]), foe["interval"], None, 0.0 "xp": foe["xp"], "enemy_interval": foe["interval"] / pace,
"foe": {key: foe[key] for key in ("archetype", "attack", "vulnerable", "resists")},
"members": [fight_member(world, player, foe) for player in players]}
return play_fight(action, fight)
def simulate_fight(world: dict, player: dict, action: dict, rng: random.Random) -> dict:
return draw_fight(world, [player], action, rng)
def play_fight(action: dict, fight: dict) -> dict:
"""Play out a fight swing by swing, every swing timer starting empty (a latecomer's from when they
tag in). Nothing changes until it's over: the result is applied then. The dice come from the fight's
own seed, so playing it again with someone tagging in or stepping out changes nothing before then."""
rng = random.Random(fight["seed"])
foe = action["enemy"]["variants"][fight["variant"]]
pace = fight["pace"]
sides = []
for member in fight["members"]:
me = member["fighter"]
# A bow looses its first arrow before the enemy closes in.
sides.append({"member": member, "me": me, "hp": member["hp_in"], "pantry": dict(member["pantry"]),
"next": member["at"] + (0.5 if me["first"] else me["interval"]), "eaten": {}, "hurt": 0,
"dealt": 0, "wind": member["wind"], "out": None,
"hit": hit_chance(me["accuracy"], foe["evasion"]), "their_hit": hit_chance(foe["accuracy"], me["evasion"])})
foe_hp, theirs, events, t, won = foe["hp"], foe["interval"], [], 0.0, False
swings = 0 # an enemy with more than one attack takes turns with them swings = 0 # an enemy with more than one attack takes turns with them
while result is None: while True:
t = min(mine, theirs) # The next thing to happen: someone stepping out, someone's swing (in order of who came first), the enemy's.
if t > MAX_FIGHT: moves = [(theirs, 2, -1)]
result = "fled" for i, side in enumerate(sides):
if not side["out"]:
if side["member"]["left"] is not None:
moves.append((side["member"]["left"], 0, i))
moves.append((side["next"], 1, i))
if len(moves) == 1:
break break
if mine <= theirs: t, kind, i = min(moves)
mine += me["interval"] if t > MAX_FIGHT:
hp = min(top, hp + blessed["mend"] * top * me["interval"]) # Rapid Heal mends a little each swing for side in sides:
side["out"] = side["out"] or "fled"
break
if kind == 0:
sides[i]["out"] = "left"
continue
if kind == 1:
side, me = sides[i], sides[i]["me"]
top = side["member"]["top"]
side["next"] += me["interval"]
side["hp"] = min(top, side["hp"] + side["member"]["mend"] * top * me["interval"]) # Rapid Heal mends a little each swing
crit = rng.random() < me["crit"] crit = rng.random() < me["crit"]
landed = rng.random() < hit landed = rng.random() < side["hit"]
dealt = max(1, round(me["max_hit"] * CRIT_MULT if crit else rng.randint(1, me["max_hit"]))) if landed else 0 dealt = max(1, round(me["max_hit"] * CRIT_MULT if crit else rng.randint(1, me["max_hit"]))) if landed else 0
foe_hp = max(0, foe_hp - dealt) 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), side["dealt"] += dealt
foe_hp, me["damage"]]) events.append([round(t / pace, 2), side["member"]["id"], ("crit" if crit else "hit") if landed else "miss",
dealt, round(side["hp"]), foe_hp, me["damage"]])
if not foe_hp: if not foe_hp:
result = "won" won = True
break
continue continue
theirs += foe["interval"] theirs += foe["interval"]
kind = foe["attack"][swings % len(foe["attack"])] targets = [side for side in sides if not side["out"] and side["member"]["at"] <= t]
if not targets:
continue
side = targets[0] if len(targets) == 1 else rng.choice(targets)
me, uid, top = side["me"], side["member"]["id"], side["member"]["top"]
attack = foe["attack"][swings % len(foe["attack"])]
swings += 1 swings += 1
crit = rng.random() < CRIT_CHANCE crit = rng.random() < CRIT_CHANCE
landed = rng.random() < their_hit landed = rng.random() < side["their_hit"]
if landed and rng.random() < me["block"]: if landed and rng.random() < me["block"]:
events.append([round(t / pace, 2), "foe", "block", 0, max(0, round(hp)), foe_hp]) events.append([round(t / pace, 2), "foe", "block", 0, max(0, round(side["hp"])), foe_hp, attack, uid])
continue continue
dealt = max(1, round((foe["max_hit"] * CRIT_MULT if crit else rng.randint(1, foe["max_hit"])) * _taken(me, kind))) \ dealt = max(1, round((foe["max_hit"] * CRIT_MULT if crit else rng.randint(1, foe["max_hit"])) * _taken(me, attack))) \
if landed else 0 if landed else 0
hp -= dealt side["hp"] -= dealt
hurt += dealt side["hurt"] += dealt
events.append([round(t / pace, 2), "foe", ("crit" if crit else "hit") if landed else "miss", dealt, max(0, round(hp)), events.append([round(t / pace, 2), "foe", ("crit" if crit else "hit") if landed else "miss", dealt,
foe_hp, kind]) max(0, round(side["hp"])), foe_hp, attack, uid])
while hp < top * EAT_BELOW and any(pantry.values()): pantry = side["pantry"]
while side["hp"] < top * EAT_BELOW and any(pantry.values()):
food = next(item for item, left in pantry.items() if left) food = next(item for item, left in pantry.items() if left)
pantry[food] -= 1 pantry[food] -= 1
eaten[food] = eaten.get(food, 0) + 1 side["eaten"][food] = side["eaten"].get(food, 0) + 1
hp = min(top, hp + ITEMS[food]["heal"]) side["hp"] = min(top, side["hp"] + ITEMS[food]["heal"])
mine += EAT_DELAY side["next"] += EAT_DELAY
events.append([round(t / pace, 2), "you", "eat", ITEMS[food]["heal"], round(hp), foe_hp, food]) events.append([round(t / pace, 2), uid, "eat", ITEMS[food]["heal"], round(side["hp"]), foe_hp, food])
beaten, starving = hp <= 0, hp < top * FLEE_BELOW and not any(pantry.values()) beaten, starving = side["hp"] <= 0, side["hp"] < top * FLEE_BELOW and not any(pantry.values())
if (beaten or starving) and wind: if (beaten or starving) and side["wind"]:
# Second Wind: the retreat that was coming turns into half health, once per outing. # Second Wind: the retreat that was coming turns into half health, once per outing.
wind = False side["wind"] = False
hp = top / 2 side["hp"] = top / 2
events.append([round(t / pace, 2), "you", "wind", 0, round(hp), foe_hp]) events.append([round(t / pace, 2), uid, "wind", 0, round(side["hp"]), foe_hp])
elif beaten: elif beaten:
hp, result = 1, "lost" side["hp"], side["out"] = 1, "lost"
elif starving: elif starving:
result = "fled" side["out"] = "fled"
return {"enemy": foe["name"], "enemy_hp": foe["hp"], "top": top, "start_hp": round(player["hp"]), "xp": foe["xp"], for side in sides:
"foe": {key: foe[key] for key in ("archetype", "attack", "vulnerable", "resists")}, member = side["member"]
"hp": hp, "result": result, "end": round(t / pace, 2), "duration": t / pace, member.update(hp=side["hp"], eaten=side["eaten"], damage=side["hurt"], dealt=side["dealt"],
"interval": me["interval"] / pace, "enemy_interval": foe["interval"] / pace, "events": events, "style": me["style"], wind_used=member["wind"] and not side["wind"],
"eaten": eaten, "damage": hurt, "pp": blessed["cost"], result=side["out"] or ("won" if won and member["at"] <= t else "left"))
"wind_used": blessed["wind"] and not wind and not (player["action"] or {}).get("wind_used")} standing = [side for side in sides if side["out"] not in ("left", None)]
fight.update(events=events, end=round(t / pace, 2), duration=t / pace,
result="won" if won else standing[-1]["out"] if standing else "left")
return fight
# --- prayer ------------------------------------------------------------------- # --- prayer -------------------------------------------------------------------
@ -2221,7 +2299,9 @@ def can_start(world: dict, player: dict, action: dict) -> str | None:
return None return None
def _complete(world: dict, player: dict, action: dict, now: float, rng: random.Random, summary: dict) -> None: def _complete(world: dict, player: dict, action: dict, now: float, rng: random.Random, summary: dict,
fight: dict | None = None, loot: tuple | None = None) -> None:
"""Finish one action. A shared fight comes in as `fight`, with this member's share of its `loot`."""
brew = potion_effect(player, action["skill"]) brew = potion_effect(player, action["skill"])
order = working_order(world, player, action) order = working_order(world, player, action)
inputs = fire_inputs(world, action) inputs = fire_inputs(world, action)
@ -2269,41 +2349,41 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
if "enemy" in action: if "enemy" in action:
outing = player["action"] outing = player["action"]
fight = outing.pop("fight", None) or simulate_fight(world, player, action, rng) fight = fight or outing.pop("fight", None) or simulate_fight(world, player, action, rng)
outing.pop("carry", None) # from a short-lived version that ran the swing timer on outing.pop("carry", None) # from a short-lived version that ran the swing timer on
xp = fight.get("xp", action["xp"]) * mods["xp"] # the variant you met xp = fight.get("xp", action["xp"]) * mods["xp"] # the variant you met
if player["prayers"] and not fight["pp"]: mine = member_of(fight, player["id"])
if player["prayers"] and not mine["pp"]:
summary["prayer_out"] = True summary["prayer_out"] = True
paid = min(player["pp"], fight["pp"]) paid = min(player["pp"], mine["pp"])
if paid: if paid:
player["pp"] -= paid player["pp"] -= paid
summary["pp_spent"] += paid summary["pp_spent"] += paid
gain_xp(world, player, "prayer", paid * PRAYER_XP_PER_POINT, now, summary) gain_xp(world, player, "prayer", paid * PRAYER_XP_PER_POINT, now, summary)
for food, count in fight["eaten"].items(): for food, count in mine["eaten"].items():
count = min(count, player["bank"].get(food, 0)) count = min(count, player["bank"].get(food, 0))
if count: if count:
bank_take(player, food, count) bank_take(player, food, count)
summary["eaten"][food] = summary["eaten"].get(food, 0) + count summary["eaten"][food] = summary["eaten"].get(food, 0) + count
player["hp"], player["hp_at"] = fight["hp"], now player["hp"], player["hp_at"] = mine["hp"], now
summary["damage"] += fight["damage"] summary["damage"] += mine["damage"]
if fight["wind_used"]: if mine["wind_used"]:
outing["wind_used"] = True outing["wind_used"] = True
summary["second_wind"] += 1 summary["second_wind"] += 1
if fight["result"] != "won": if mine["result"] != "won":
# Beaten or out of food: fall back with nothing but your life. # Beaten or out of food: fall back with nothing but your life.
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) drop_out(world, player, now, 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
summary["wins"] += 1 summary["wins"] += 1
gold = rng.randint(*action["gold"]) if action.get("gold") else 0 gold, drops = loot or roll_loot(action, rng)
player["gold"] += gold player["gold"] += gold
summary["gold"] += gold summary["gold"] += gold
for item, qty, odds in action.get("drops", []): for item, qty in drops.items():
if rng.random() < odds: outputs[item] = outputs.get(item, 0) + qty
outputs[item] = outputs.get(item, 0) + qty
if action.get("special") == "sabotage": if action.get("special") == "sabotage":
raid = upcoming_raid(world) raid = upcoming_raid(world)
raid["sabotage"] = min(0.3, raid["sabotage"] + 0.03) raid["sabotage"] = min(0.3, raid["sabotage"] + 0.03)
@ -2402,9 +2482,17 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N
summary = empty_summary() summary = empty_summary()
collect_orders(world, player) collect_orders(world, player)
current = player["action"] current = player["action"]
if current and current.get("party"):
if current["party"] in world["parties"]:
process_party(world, current["party"], now, rng)
else:
current.pop("party")
current = player["action"]
if current and current.get("party"):
return summary # a party's kills land in each member's away summary
if not current: if not current:
return summary return summary
limit = min(now, player["last_active"] + OFFLINE_CAP / speed(world)) limit = min(now, offline_limit(world, player))
while player["action"]: while player["action"]:
action = growing(player) action = growing(player)
if current.get("plot2") and can_start(world, player, action): if current.get("plot2") and can_start(world, player, action):
@ -2476,16 +2564,204 @@ def stop(player: dict, reason: str | None = None) -> None:
player["stopped"] = reason player["stopped"] = reason
def drop_out(world: dict, player: dict, now: float, reason: str | None = None) -> None:
"""Stop, stepping out of a shared fight if you're in one."""
current = player["action"]
if current and current.get("party") in world["parties"]:
leave_party(world, player, current["party"], now)
stop(player, reason)
def roll_loot(action: dict, rng: random.Random) -> tuple[int, dict]:
"""A kill's gold and drops."""
gold = rng.randint(*action["gold"]) if action.get("gold") else 0
drops = {}
for item, qty, odds in action.get("drops", []):
if rng.random() < odds:
drops[item] = drops.get(item, 0) + qty
return gold, drops
def share_loot(loot: tuple[int, dict], uids: list, rng: random.Random) -> dict:
"""Gold split evenly (the odd coins to whoever's lucky), each drop to someone at random."""
gold, drops = loot
each, odd = divmod(gold, len(uids))
lucky = set(rng.sample(uids, odd))
shares = {uid: (each + (uid in lucky), {}) for uid in uids}
for item, qty in drops.items():
for _ in range(qty):
got = shares[rng.choice(uids)][1]
got[item] = got.get(item, 0) + 1
return shares
# --- fighting together ------------------------------------------------------
# Anyone can tag into someone else's fight: from then on they're a party on that
# encounter, every enemy fought by all of them at once. Everyone gets the kill's full
# XP; its gold is split evenly and each drop goes to someone at random. The party
# lasts until all but one have left (stopped, retreated, or been away too long), and
# the last one carries on alone.
def offline_limit(world: dict, player: dict) -> float:
return player["last_active"] + OFFLINE_CAP / speed(world)
def joinable(world: dict, player: dict) -> bool:
"""Whether someone's fight is one others can tag into."""
current = player["action"]
if not current or "left" in current or "order" in current:
return False
action = ACTION_BY_ID[current["id"]]
return "enemy" in action and not action.get("special")
def current_fight(world: dict, player: dict) -> dict | None:
current = player["action"] or {}
party = world["parties"].get(current.get("party"))
return party["fight"] if party else current.get("fight")
def party_of(world: dict, player: dict) -> dict | None:
return world["parties"].get((player["action"] or {}).get("party"))
def join_fight(world: dict, player: dict, uid: str, now: float) -> str | None:
"""Tag into someone's fight, swinging at the enemy they're on right now."""
host = world["players"].get(uid)
if not host or host is player:
return "No one to join"
if not joinable(world, host):
return f"{host['name']} isn't fighting"
if offline_limit(world, host) <= now:
return f"{host['name']} is away"
party = party_of(world, host)
if party and party is party_of(world, player):
return "Already fighting together"
action = ACTION_BY_ID[host["action"]["id"]]
reason = can_start(world, player, action)
if reason:
return reason
if party_of(world, player):
leave_party(world, player, player["action"]["party"], now)
if not party:
# Someone fighting alone becomes a party of one, their fight as it stands.
pid = str(world["next_party_id"])
world["next_party_id"] += 1
solo = host["action"]
party = world["parties"][pid] = {"id": pid, "action": solo["id"], "members": [host["id"]],
"start": solo["start"], "fight": solo.pop("fight", None)}
solo["party"] = pid
party["members"].append(player["id"])
player["action"] = {"id": party["action"], "start": party["start"], "party": party["id"]}
player["stopped"] = None
fight = party["fight"]
if fight and now < party["start"] + fight["duration"]:
at = max(0.0, (now - party["start"]) * fight["pace"])
foe = action["enemy"]["variants"][fight["variant"]]
fight["members"].append(fight_member(world, player, foe, at))
play_fight(action, fight)
log(world, now, "party", f"{player['name']} joined {host['name']}: {action['name']}", player["id"])
return None
def leave_party(world: dict, player: dict, pid: str, now: float) -> None:
"""Step out of a party at `now`. The fight goes on without you (you get nothing from it); whoever's
left alone carries on by themselves. Setting your own action is up to the caller."""
party = world["parties"][pid]
party["members"].remove(player["id"])
fight = party["fight"]
if fight and now < party["start"] + fight["duration"]:
action = ACTION_BY_ID[party["action"]]
member = member_of(fight, player["id"])
if now <= party["start"] or len(fight["members"]) == 1:
fight["members"].remove(member)
else:
member["left"] = max(member["at"], (now - party["start"]) * fight["pace"])
party["fight"] = play_fight(action, fight) if party["members"] and fight["members"] else None
if len(party["members"]) <= 1:
del world["parties"][pid]
for uid in party["members"]:
last = world["players"][uid]["action"]
last.pop("party", None)
last.update(start=party["start"], **({"fight": party["fight"]} if party["fight"] else {}))
def process_party(world: dict, pid: str, now: float, rng: random.Random) -> None:
"""Run a party's fights forward to `now`: every kill lands for everyone in it at once. What
each member gets waits in their away summary."""
party = world["parties"][pid]
action = ACTION_BY_ID[party["action"]]
for uid in list(party["members"]):
if (world["players"][uid]["action"] or {}).get("party") != pid and world["parties"].get(pid) is party:
leave_party(world, world["players"][uid], pid, party["start"]) # moved on without saying
while world["parties"].get(pid) is party:
members = [world["players"][uid] for uid in party["members"]]
if not party["fight"]:
for player in members:
regen(world, player, party["start"])
reason = can_start(world, player, action)
if reason and reason.startswith("Too hurt"):
if blessing(player)["wind"] and not player["action"].get("wind_used"):
# Second Wind: the retreat that was coming turns into half health.
player["action"]["wind_used"] = True
player["hp"] = max_hp(player) / 2
player.setdefault("away", empty_summary())["second_wind"] += 1
reason = None
else:
reason = "Retreated: hurt and out of food"
if reason:
drop_out(world, player, party["start"], reason)
player.setdefault("away", empty_summary())["stopped"] = reason
if world["parties"].get(pid) is not party:
break
members = [world["players"][uid] for uid in party["members"]]
party["fight"] = draw_fight(world, members, action, rng)
end = party["start"] + party["fight"]["duration"]
late = [player for player in members if offline_limit(world, player) < min(end, now)]
if late:
# Away too long: they step out where their time ran out, and pick up alone when they're back.
for player in late:
if world["parties"].get(pid) is not party:
break # the last of them carries on alone, and picks up when they're back
at = max(party["start"], offline_limit(world, player))
wind = {"wind_used": True} if player["action"].get("wind_used") else {}
leave_party(world, player, pid, at)
player["action"] = {"id": action["id"], "start": at, **wind}
continue
if end > now:
break
burn_fires(world, end)
fight, party["fight"] = party["fight"], None
party["start"] = end + RESPAWN / speed(world) # a moment before the next enemy turns up
for player in members:
player["action"]["start"] = party["start"]
results = {m["id"]: m["result"] for m in fight["members"]}
winners = [uid for uid in party["members"] if results.get(uid) == "won"]
shares = share_loot(roll_loot(action, rng), winners, rng) if winners else {}
for player in members:
if results.get(player["id"], "left") == "left":
continue
summary = player.setdefault("away", empty_summary())
_complete(world, player, action, end, rng, summary, fight=fight, loot=shares.get(player["id"]))
summary["done"] += 1
together = summary.setdefault("with", {})
for other in members:
if other is not player:
together[other["name"]] = together.get(other["name"], 0) + 1
def catch_up(world: dict, now: float, rng: random.Random | None = None) -> bool: def catch_up(world: dict, now: float, rng: random.Random | None = None) -> bool:
"""Run everyone's actions up to now, so the shared fires see feeds and crafts as they """Run everyone's actions up to now, so the shared fires see feeds and crafts as they
happen. What each player missed waits in their `away` summary for their next visit.""" happen. What each player missed waits in their `away` summary for their next visit."""
changed = False changed = False
parties = lambda: {pid: party["start"] for pid, party in world["parties"].items()}
before = parties()
for player in world["players"].values(): for player in world["players"].values():
summary = process_player(world, player, now, rng) summary = process_player(world, player, now, rng)
if summary["done"] or summary["stopped"]: if summary["done"] or summary["stopped"]:
merge_summary(player.setdefault("away", empty_summary()), summary) merge_summary(player.setdefault("away", empty_summary()), summary)
changed = True changed = True
return changed return changed or parties() != before
def merge_summary(into: dict, more: dict) -> dict: def merge_summary(into: dict, more: dict) -> dict:
@ -2705,6 +2981,8 @@ def work_order(world: dict, player: dict, order_id, now: float) -> str | None:
if not had: if not had:
order["claimed"] = None # couldn't start: don't sit on it order["claimed"] = None # couldn't start: don't sit on it
return reason return reason
if previous and previous.get("party") in world["parties"]:
leave_party(world, player, previous["party"], now)
player["stopped"] = None player["stopped"] = None
return None return None
@ -2813,6 +3091,8 @@ def start_action(world: dict, player: dict, action_id: str, now: float, once: bo
elif current and current.get("plot2") and current["plot2"] != action_id and action["skill"] == "farming": elif current and current.get("plot2") and current["plot2"] != action_id and action["skill"] == "farming":
# Replanting the first plot leaves the second one growing. # Replanting the first plot leaves the second one growing.
entry.update(plot2=current["plot2"], turn=0) entry.update(plot2=current["plot2"], turn=0)
if current and current.get("party") in world["parties"]:
leave_party(world, player, current["party"], now)
player["action"] = entry player["action"] = entry
player["stopped"] = None player["stopped"] = None
return None return None
@ -4469,6 +4749,9 @@ PATCH_NOTES = [
"the fire is lit, the Firekeeper gains 10% of the XP the craft gave the crafter, shared between all players keeping a " "the fire is lit, the Firekeeper gains 10% of the XP the craft gave the crafter, shared between all players keeping a "
"fire of that tier. Each player keeps their own \"pile\", which contributes an equal amount to the fire."), "fire of that tier. Each player keeps their own \"pile\", which contributes an equal amount to the fire."),
("new", "Work orders: anyone can provide reagents towards an incomplete work order and gets paid based on the value of the items provided."), ("new", "Work orders: anyone can provide reagents towards an incomplete work order and gets paid based on the value of the items provided."),
("new", "Fight together: tag into anyone's fight from the encounter card or the guild roster. You all swing at the same enemy, "
"live on everyone's screen, so kills come faster. Everyone gets the full XP for each kill; the gold is split evenly and "
"each drop goes to one of you at random. Leave any time, and whoever's left fights on alone."),
("balance", "Gear: combat requirement adjusted to match the tier of crafting required to make it."), ("balance", "Gear: combat requirement adjusted to match the tier of crafting required to make it."),
("balance", "Combat XP reduced to keep it in line with professions. Eating now delays your next swing; eat higher tier food to reduce the impact."), ("balance", "Combat XP reduced to keep it in line with professions. Eating now delays your next swing; eat higher tier food to reduce the impact."),
("balance", "Construction workbench now also affects labour on building sites."), ("balance", "Construction workbench now also affects labour on building sites."),

View file

@ -4,6 +4,7 @@
// between polls. The server is the source of truth for everything. // between polls. The server is the source of truth for everything.
const POLL_MS = 10000; const POLL_MS = 10000;
const FIGHT_POLL_MS = 2000; // in a fight someone may tag in: keep everyone's view of it close
const SUMMARY_MODAL_AT = 25; const SUMMARY_MODAL_AT = 25;
let C = null; // static content tables let C = null; // static content tables
@ -21,6 +22,8 @@ let pickedClass = null; // registration
const tabs = {}; // skill -> selected recipe tab const tabs = {}; // skill -> selected recipe tab
let refreshing = false; let refreshing = false;
let refreshedFor = null; // action (start and length) a completion refresh was sent for let refreshedFor = null; // action (start and length) a completion refresh was sent for
let polledAt = 0;
let tagging = false; // this page just tagged into someone's fight
let retriedAt = 0; // last time a fight past its kill asked again for the next one let retriedAt = 0; // last time a fight past its kill asked again for the next one
let version = null; // server build this page was loaded from let version = null; // server build this page was loaded from
let failures = 0; // polls in a row that didn't reach the server let failures = 0; // polls in a row that didn't reach the server
@ -77,6 +80,7 @@ async function act(path, body) {
async function refresh() { async function refresh() {
if (refreshing) return; if (refreshing) return;
refreshing = true; refreshing = true;
polledAt = Date.now();
try { try {
accept(await api("/api/state")); accept(await api("/api/state"));
failures = 0; failures = 0;
@ -95,8 +99,14 @@ function accept(state) {
location.reload(); // a new build was deployed location.reload(); // a new build was deployed
return; return;
} }
// Someone tagging into your fight (not you tagging into theirs) gets a toast.
const fightingWith = S?.me && !tagging ? S.me.action?.with || [] : null;
tagging = false;
S = state; S = state;
skew = state.now - Date.now() / 1000; skew = state.now - Date.now() / 1000;
for (const uid of fightingWith ? state.me?.action?.with || [] : []) {
if (!fightingWith.includes(uid)) toast(`⚔️ ${playerName(uid)} joined the fight`, "good");
}
if (state.summary) showSummary(state.summary); if (state.summary) showSummary(state.summary);
orderNews(state); orderNews(state);
const ev = state.upcoming; const ev = state.upcoming;
@ -180,6 +190,7 @@ function awayModal(sum) {
openModal(` openModal(`
<h2>While you were away</h2> <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> <p class="muted">You completed ${fmt(sum.done)} actions${sum.wins || sum.losses ? ` (${sum.wins} fights won, ${sum.losses} lost)` : ""}.</p>
${Object.keys(sum.with || {}).length ? `<p class="muted small">⚔️ ${Object.entries(sum.with).map(([name, n]) => `${fmt(n)} with ${esc(name)}`).join(" · ")}</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>` : ""} ${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>` : ""} ${items ? `<ul>${items}</ul>` : ""}
${sum.gold ? `<p class="gold">+${fmt(sum.gold)} gold</p>` : ""} ${sum.gold ? `<p class="gold">+${fmt(sum.gold)} gold</p>` : ""}
@ -832,12 +843,16 @@ function variantRows(e, o) {
function combatFrame() { function combatFrame() {
const me = S.me, a = me.action, f = a?.fight; const me = S.me, a = me.action, f = a?.fight;
const encounter = f && C.actions.find((x) => x.id === a.id); const encounter = f && C.actions.find((x) => x.id === a.id);
const allies = f ? f.members.filter((m) => m.id !== S.uid) : [];
const bars = (who) => `<div class="bar hp ${who}"><span data-f="${who}-bar"${who === "you" ? ` data-live-hpbar style="width:${me.hp / me.max_hp * 100}%"` : ""}></span></div> const bars = (who) => `<div class="bar hp ${who}"><span data-f="${who}-bar"${who === "you" ? ` data-live-hpbar style="width:${me.hp / me.max_hp * 100}%"` : ""}></span></div>
<div class="bar swing"><span data-f="${who}-swing"></span></div>`; <div class="bar swing"><span data-f="${who}-swing"></span></div>`;
const you = `<div class="fighter"> const you = `<div class="fighter">
<div class="fighter-name"><b>${esc(me.name)}</b><span class="small muted">${HEART} <span data-f="you-hp">${fmt(me.hp)} / ${me.max_hp}</span></span></div> <div class="fighter-name"><b>${esc(me.name)}</b><span class="small muted">${HEART} <span data-f="you-hp">${fmt(me.hp)} / ${me.max_hp}</span></span></div>
${bars("you")} ${bars("you")}
</div>`; </div>${allies.map((m, i) => `<div class="fighter ally">
<div class="fighter-name"><b>${esc(m.name)}</b><span class="small muted">${HEART} <span data-f="ally${i}-hp"></span></span></div>
<div class="bar hp you"><span data-f="ally${i}-bar"></span></div><div class="bar swing"><span data-f="ally${i}-swing"></span></div>
</div>`).join("")}`;
let foe = `<div class="foe-idle muted">Not fighting.</div>`; let foe = `<div class="foe-idle muted">Not fighting.</div>`;
if (f) { if (f) {
const mine = C.styles[(me.equip.weapon && item(me.equip.weapon).style) || "sword"].damage; const mine = C.styles[(me.equip.weapon && item(me.equip.weapon).style) || "sword"].damage;
@ -864,15 +879,18 @@ function prayerBar(me) {
return `<div class="prayer-bar"><span class="pp ${me.prayers.length && !me.blessing.cost ? "warn" : ""}" title="Prayer points">${skillOf("prayer").icon} ${fmt(me.pp)}</span>${buttons}</div>`; return `<div class="prayer-bar"><span class="pp ${me.prayers.length && !me.blessing.cost ? "warn" : ""}" title="Prayer points">${skillOf("prayer").icon} ${fmt(me.pp)}</span>${buttons}</div>`;
} }
// A line of the fight: yours as "You", everyone else's by name.
function fightLine(e, enemy) { function fightLine(e, enemy) {
const [, who, kind, amount, , , food] = e; const [, who, kind, amount, , , food, target] = e;
const type = kind === "hit" || kind === "crit" ? (C.damage_types[food] ? ` ${dmgIcon(food)}` : "") : ""; const type = kind === "hit" || kind === "crit" ? (C.damage_types[food] ? ` ${dmgIcon(food)}` : "") : "";
const foe = esc(enemy); const foe = esc(enemy);
if (kind === "eat") return `<li class="good">You eat ${esc(item(food).name.toLowerCase())} (+${amount})</li>`; const subject = who === "foe" ? target : who, mine = subject === S.uid;
if (kind === "wind") return `<li class="good">Second wind! Back on your feet</li>`; const name = mine ? "You" : esc(playerName(subject)), s = mine ? "" : "s";
if (kind === "block") return `<li class="good">You block ${foe}'s blow</li>`; if (kind === "eat") return `<li class="${mine ? "good" : "muted"}">${name} eat${s} ${esc(item(food).name.toLowerCase())} (+${amount})</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>${type}</li>`; if (kind === "wind") return `<li class="good">Second wind! ${mine ? "Back on your feet" : `${name} is back up`}</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>${type}</li>`; if (kind === "block") return `<li class="${mine ? "good" : "muted"}">${name} block${s} ${foe}'s blow</li>`;
if (who !== "foe") return kind === "miss" ? `<li class="muted">${name} miss${mine ? "" : "es"}</li>` : `<li${mine ? "" : ` class="ally"`}>${name} ${kind === "crit" ? `<b>crit${s}</b>` : `hit${s}`} for <b>${amount}</b>${type}</li>`;
return kind === "miss" ? `<li class="muted">${foe} misses</li>` : `<li class="${mine ? "bad" : "muted"}">${foe} ${kind === "crit" ? "<b>crits</b>" : "hits"} ${mine ? "you" : name} for <b>${amount}</b>${type}</li>`;
} }
function animateFight(now) { function animateFight(now) {
@ -880,37 +898,50 @@ function animateFight(now) {
if (!f) return; if (!f) return;
const t = now - a.start; const t = now - a.start;
const over = t >= f.end; const over = t >= f.end;
const mine = f.members.find((m) => m.id === S.uid) || f.members[0];
// From the kill until the next enemy turns up (the next fight's start, once the server sends it), // From the kill until the next enemy turns up (the next fight's start, once the server sends it),
// the panel is the same quiet countdown: no enemy, both swing timers empty. // the panel is the same quiet countdown: no enemy, every swing timer empty.
const waiting = t < 0 || (over && f.result === "won"); const waiting = t < 0 || (over && f.result === "won" && mine.result === "won");
const respawnIn = t < 0 ? -t : f.end + (a.respawn || 1) - t; const respawnIn = t < 0 ? -t : f.end + (a.respawn || 1) - t;
let shown = -1; let shown = -1;
while (shown + 1 < f.events.length && f.events[shown + 1][0] <= t) shown++; while (shown + 1 < f.events.length && f.events[shown + 1][0] <= t) shown++;
const last = f.events[shown]; const last = f.events[shown];
const hp = last ? last[4] : f.start_hp, foeHp = waiting ? 0 : last ? last[5] : f.enemy_hp; // Each fighter's HP is in their own swings and bites, and in the enemy's blows at them.
const about = (e, m) => e[1] === m.id || (e[1] === "foe" && e[7] === m.id);
const hpOf = (m) => { for (let i = shown; i >= 0; i--) if (about(f.events[i], m)) return f.events[i][4]; return m.start_hp; };
const swungAt = (id) => { for (let i = shown; i >= 0; i--) if (f.events[i][1] === id && f.events[i][2] !== "eat" && f.events[i][2] !== "wind") return f.events[i][0]; return null; };
const hp = hpOf(mine), foeHp = waiting ? 0 : last ? last[5] : f.enemy_hp;
let fell = Infinity; // beaten or out of food while the others fight on
if (mine.result === "lost" || mine.result === "fled") for (let i = f.events.length - 1; i >= 0; i--) if (about(f.events[i], mine)) { fell = f.events[i][0]; break; }
for (const el of document.querySelectorAll("[data-progress]")) el.style.width = `${foeHp / f.enemy_hp * 100}%`; for (const el of document.querySelectorAll("[data-progress]")) el.style.width = `${foeHp / f.enemy_hp * 100}%`;
const panel = $("[data-fight]"); const panel = $("[data-fight]");
if (!panel) return; if (!panel) return;
panel.classList.toggle("waiting", waiting); panel.classList.toggle("waiting", waiting);
// Your HP everywhere follows the fight as it plays out. // 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-hp]")) el.textContent = fmt(hp);
for (const el of document.querySelectorAll("[data-live-hpbar]")) el.style.width = `${Math.max(0, hp / f.top * 100)}%`; for (const el of document.querySelectorAll("[data-live-hpbar]")) el.style.width = `${Math.max(0, hp / mine.top * 100)}%`;
const set = (key, fn) => { const el = panel.querySelector(`[data-f="${key}"]`); if (el) fn(el); }; 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("you-bar", (el) => { el.style.width = `${Math.max(0, hp / mine.top * 100)}%`; });
set("foe-bar", (el) => { el.style.width = `${Math.max(0, foeHp / f.enemy_hp * 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("you-hp", (el) => { el.textContent = `${fmt(hp)} / ${fmt(mine.top)}`; });
set("foe-hp", (el) => { el.textContent = waiting ? "" : `${fmt(foeHp)} / ${fmt(f.enemy_hp)}`; }); set("foe-hp", (el) => { el.textContent = waiting ? "" : `${fmt(foeHp)} / ${fmt(f.enemy_hp)}`; });
for (const [who, every] of [["you", f.interval], ["foe", f.enemy_interval]]) { const swing = (key, swung, every, from = 0, gone = false) => set(`${key}-swing`, (el) => {
let swung = 0; el.style.width = over || waiting || gone || t < from ? "0%" : `${Math.min(100, (t - (swung ?? from)) / every * 100)}%`;
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 || waiting ? "0%" : `${Math.min(100, (t - swung) / every * 100)}%`; }); swing("you", swungAt(mine.id), mine.interval, mine.at / f.pace, t >= fell);
} swing("foe", swungAt("foe"), f.enemy_interval);
f.members.filter((m) => m.id !== S.uid).forEach((m, i) => {
const ally = hpOf(m), left = m.left != null && t >= m.left / f.pace;
set(`ally${i}-bar`, (el) => { el.style.width = `${Math.max(0, ally / m.top * 100)}%`; });
set(`ally${i}-hp`, (el) => { el.textContent = t < m.at / f.pace ? "joining…" : left ? "left" : `${fmt(ally)} / ${fmt(m.top)}`; });
swing(`ally${i}`, swungAt(m.id), m.interval, m.at / f.pace, left);
});
set("log", (el) => { set("log", (el) => {
const key = `${a.start}:${shown}:${over}:${waiting ? Math.ceil(respawnIn * 10) : ""}`; const key = `${a.start}:${shown}:${over}:${waiting ? Math.ceil(respawnIn * 10) : ""}`;
if (el.dataset.key === key) return; if (el.dataset.key === key) return;
el.dataset.key = key; el.dataset.key = key;
const lines = f.events.slice(Math.max(0, shown - 5), shown + 1).reverse().map((e) => fightLine(e, f.enemy)); const lines = f.events.slice(Math.max(0, shown - 5), shown + 1).reverse().map((e) => fightLine(e, f.enemy));
if (over && !waiting) lines.unshift(`<li class="bad"><b>You fall back.</b></li>`); if ((over && !waiting) || t >= fell) lines.unshift(`<li class="bad"><b>You fall back.</b></li>`);
el.innerHTML = waiting ? `<li class="good"><b>Victory!</b> ${respawnIn > 0.05 ? `Next enemy in ${respawnIn.toFixed(1)}s` : "Next enemy coming…"}</li>` el.innerHTML = waiting ? `<li class="good"><b>Victory!</b> ${respawnIn > 0.05 ? `Next enemy in ${respawnIn.toFixed(1)}s` : "Next enemy coming…"}</li>`
: lines.join("") || `<li class="muted">Squaring up…</li>`; : lines.join("") || `<li class="muted">Squaring up…</li>`;
}); });
@ -1014,7 +1045,7 @@ function actionCard(a) {
let extra = ""; let extra = "";
if (a.enemy) { if (a.enemy) {
const o = me.fight_odds[a.id], e = a.enemy; const o = me.fight_odds[a.id], e = a.enemy;
extra = `${e.boss ? `<div><span class="tag warn">Boss</span></div>` : ""}${variantRows(e, o)}`; extra = `${e.boss ? `<div><span class="tag warn">Boss</span></div>` : ""}${variantRows(e, o)}${fightingHere(a, why)}`;
if (a.gold) io += `<span class="item-chip" title="Gold: ${a.gold[0]}–${a.gold[1]} a kill">${COIN} ${a.gold[0]}–${a.gold[1]}</span>`; if (a.gold) io += `<span class="item-chip" title="Gold: ${a.gold[0]}–${a.gold[1]} a kill">${COIN} ${a.gold[0]}–${a.gold[1]}</span>`;
if (a.drops) io += a.drops.map(chance).join(""); if (a.drops) io += a.drops.map(chance).join("");
if (a.special === "sabotage") extra += `<div class="small muted">-3% raid strength per win (max 30%)</div>`; if (a.special === "sabotage") extra += `<div class="small muted">-3% raid strength per win (max 30%)</div>`;
@ -1052,7 +1083,7 @@ function actionCard(a) {
${extra} ${extra}
${me.action?.plot2 && (active || second) ? `<div class="small muted">Plot ${active ? 1 : 2}${growingNow ? " · growing now" : " · next"}</div>` : ""} ${me.action?.plot2 && (active || second) ? `<div class="small muted">Plot ${active ? 1 : 2}${growingNow ? " · growing now" : " · next"}</div>` : ""}
${growingNow && !a.enemy ? `<div class="bar thick"><span data-progress></span></div>` : ""} ${growingNow && !a.enemy ? `<div class="bar thick"><span data-progress></span></div>` : ""}
${active && a.enemy ? `<div><span class="tag gold">⚔️ Fighting</span></div>` : ""} ${active && a.enemy ? `<div><span class="tag gold">⚔️ Fighting${me.action.with?.length ? ` with ${me.action.with.map((uid) => esc(playerName(uid))).join(", ")}` : ""}</span></div>` : ""}
${active && me.action.left ? `<div class="small muted">Just one${me.action.resume ? `, then back to ${esc(C.actions.find((x) => x.id === me.action.resume).name.toLowerCase())}` : ""}</div>` : ""} ${active && me.action.left ? `<div class="small muted">Just one${me.action.resume ? `, then back to ${esc(C.actions.find((x) => x.id === me.action.resume).name.toLowerCase())}` : ""}</div>` : ""}
<div class="foot"> <div class="foot">
<span class="why">${!active && !second && why ? esc(why) : ""}</span> <span class="why">${!active && !second && why ? esc(why) : ""}</span>
@ -1065,6 +1096,15 @@ function actionCard(a) {
</div>`; </div>`;
} }
// Who else is fighting this encounter, with a button to tag in: one per group fighting together.
function fightingHere(a, why) {
const mine = S.me.action?.party;
const here = S.players.filter((p) => p.id !== S.uid && p.fight === a.id && !(mine && p.party === mine));
const groups = [...new Map(here.map((p) => [p.party || p.id, here.filter((q) => (q.party || q.id) === (p.party || p.id))])).values()];
return groups.map((group) => `<div class="fighting-here"><span class="small">⚔️ ${group.map((p) => esc(p.name)).join(", ")}</span>
<button class="btn sm" data-join="${esc(group[0].id)}" ${why ? `disabled title="${esc(why)}"` : ""}>Join</button></div>`).join("");
}
// --- bank ----------------------------------------------------------------- // --- bank -----------------------------------------------------------------
// A strongbox's extra slots or a bag's bigger stack. // A strongbox's extra slots or a bag's bigger stack.
@ -1652,6 +1692,12 @@ function board() {
</div>`; </div>`;
} }
function joinButton(p) {
if (!p.fight || p.id === S.uid || (p.party && p.party === S.me?.action?.party)) return "";
const why = S.me?.blocked[p.fight];
return ` <button class="btn sm primary" data-join="${esc(p.id)}" ${why ? `disabled title="${esc(why)}"` : ""}>⚔️ Join</button>`;
}
function roster() { function roster() {
return `<div class="grid wide">${S.players.map((p) => { return `<div class="grid wide">${S.players.map((p) => {
const cls = C.classes[p.cls]; const cls = C.classes[p.cls];
@ -1661,7 +1707,7 @@ function roster() {
<div class="small">${p.doing ? `${skillOf(p.skill).icon} ${esc(p.doing)}` : `<span class="muted">Idle</span>`}${p.garrison ? ` · <span class="tag warn">On guard</span>` : ""}</div> <div class="small">${p.doing ? `${skillOf(p.skill).icon} ${esc(p.doing)}` : `<span class="muted">Idle</span>`}${p.garrison ? ` · <span class="tag warn">On guard</span>` : ""}</div>
<div class="small favour">${FAVOUR} ${fmt(p.donated)} given to the city</div></div></div> <div class="small favour">${FAVOUR} ${fmt(p.donated)} given to the city</div></div></div>
<div class="levels">${levels}</div> <div class="levels">${levels}</div>
<p><button class="btn sm" data-nav="character:${esc(p.id)}">Character sheet</button></p></div>`; <p><button class="btn sm" data-nav="character:${esc(p.id)}">Character sheet</button>${joinButton(p)}</p></div>`;
}).join("")}</div>`; }).join("")}</div>`;
} }
@ -1955,6 +2001,7 @@ document.addEventListener("click", (e) => {
if (d.start) return act("/api/action", { id: d.start }); if (d.start) return act("/api/action", { id: d.start });
if (d.once) return act("/api/action", { id: d.once, once: true }); if (d.once) return act("/api/action", { id: d.once, once: true });
if ("stop" in d) return act("/api/stop", {}); if ("stop" in d) return act("/api/stop", {});
if (d.join) { tagging = true; return act("/api/join", { id: d.join }); }
if ("orderNew" in d) { if ("orderNew" in d) {
draft = draft ? null : { kind: "craft", item: "", qty: 1, provide: [], reward: 0 }; draft = draft ? null : { kind: "craft", item: "", qty: 1, provide: [], reward: 0 };
return render(); return render();
@ -2173,6 +2220,8 @@ document.addEventListener("visibilitychange", () => { if (!document.hidden) refr
$("#main").innerHTML = `<p class="loading">Couldn't reach the city: ${esc(err.message)}</p>`; $("#main").innerHTML = `<p class="loading">Couldn't reach the city: ${esc(err.message)}</p>`;
return; return;
} }
setInterval(() => { if (!document.hidden) refresh(); }, POLL_MS); setInterval(() => {
if (!document.hidden && Date.now() - polledAt >= (S?.me?.action?.fight ? FIGHT_POLL_MS : POLL_MS)) refresh();
}, 500);
requestAnimationFrame(loop); requestAnimationFrame(loop);
})(); })();

View file

@ -257,6 +257,10 @@ select { background: var(--bg); border: 1px solid var(--line); border-radius: 8p
.fight .bar.hp { height: 12px; } .fight .bar.hp { height: 12px; }
.fight .bar.hp.you > span { background: var(--good); } .fight .bar.hp.you > span { background: var(--good); }
.fight .bar.hp.foe > span { background: var(--bad); } .fight .bar.hp.foe > span { background: var(--bad); }
.fighter.ally { opacity: 0.85; }
.fighter.ally .bar.hp { height: 6px; }
.fight-log .ally { color: var(--muted); }
.fighting-here { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 6px; }
.fight .bar.swing { height: 4px; } .fight .bar.swing { height: 4px; }
.fight .bar.swing > span { background: var(--gold); transition: none; } .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 { list-style: none; margin: 12px 0 0; padding: 0; font-size: 13px; display: flex; flex-direction: column; gap: 2px; min-height: 7.5em; }

View file

@ -1,3 +1,4 @@
import json
import random import random
import sys import sys
import unittest import unittest
@ -171,7 +172,7 @@ class ActionTest(unittest.TestCase):
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 + 3600, random.Random(5)) summary = game.process_player(world, p, T0 + 3600, random.Random(5))
self.assertGreater(summary["done"], 50) # a fight lasts as long as it takes self.assertGreater(summary["done"], 30) # a fight lasts as long as it takes
self.assertEqual(summary["wins"] + summary["losses"], summary["done"]) self.assertEqual(summary["wins"] + summary["losses"], summary["done"])
low, high = game.ACTION_BY_ID["fight_wolves"]["gold"] low, high = game.ACTION_BY_ID["fight_wolves"]["gold"]
self.assertTrue(summary["wins"] * low <= p["gold"] <= summary["wins"] * high) self.assertTrue(summary["wins"] * low <= p["gold"] <= summary["wins"] * high)
@ -521,7 +522,7 @@ class CombatTest(unittest.TestCase):
p["bank"] = {"cooked_shrimp": 100} p["bank"] = {"cooked_shrimp": 100}
p["hp"] = 1 p["hp"] = 1
fight = game.simulate_fight(world, p, game.ACTION_BY_ID["fight_clay_golems"], random.Random(3)) fight = game.simulate_fight(world, p, game.ACTION_BY_ID["fight_clay_golems"], random.Random(3))
mine = [event for event in fight["events"] if event[1] == "you"] mine = [event for event in fight["events"] if event[1] == p["id"]]
bite = next(i for i, event in enumerate(mine) if event[2] == "eat") bite = next(i for i, event in enumerate(mine) if event[2] == "eat")
swing = next(event for event in mine[bite:] if event[2] != "eat") swing = next(event for event in mine[bite:] if event[2] != "eat")
self.assertGreaterEqual(swing[0] - mine[bite][0], game.EAT_DELAY) self.assertGreaterEqual(swing[0] - mine[bite][0], game.EAT_DELAY)
@ -553,12 +554,12 @@ class CombatTest(unittest.TestCase):
game.process_player(world, p, T0 + 1, random.Random(2)) game.process_player(world, p, T0 + 1, random.Random(2))
fight = p["action"]["fight"] # simulated, waiting to finish, for the page to replay fight = p["action"]["fight"] # simulated, waiting to finish, for the page to replay
self.assertEqual(fight["result"], "won") 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.assertTrue(all(e[1] in (p["id"], "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.assertEqual(fight["events"][-1][5], 0) # the last swing finished it
self.assertAlmostEqual(fight["duration"], fight["end"], places=1) # rewards land on the kill self.assertAlmostEqual(fight["duration"], fight["end"], places=1) # rewards land on the kill
summary = game.process_player(world, p, T0 + fight["duration"] + 0.01, random.Random(2)) summary = game.process_player(world, p, T0 + fight["duration"] + 0.01, random.Random(2))
self.assertEqual(summary["wins"], 1) self.assertEqual(summary["wins"], 1)
self.assertAlmostEqual(p["hp"], fight["hp"], delta=0.1) self.assertAlmostEqual(p["hp"], game.member_of(fight, p["id"])["hp"], delta=0.1)
self.assertAlmostEqual(p["action"]["start"], T0 + fight["duration"] + game.RESPAWN) # then a short pause self.assertAlmostEqual(p["action"]["start"], T0 + fight["duration"] + game.RESPAWN) # then a short pause
def test_outclassing_an_enemy_kills_faster(self): def test_outclassing_an_enemy_kills_faster(self):
@ -577,7 +578,7 @@ class CombatTest(unittest.TestCase):
game.process_player(world, p, T0 + first["duration"] + 0.01, random.Random(1)) game.process_player(world, p, T0 + first["duration"] + 0.01, random.Random(1))
self.assertAlmostEqual(p["action"]["start"], T0 + first["duration"] + game.RESPAWN) # the pause starts at the kill self.assertAlmostEqual(p["action"]["start"], T0 + first["duration"] + game.RESPAWN) # the pause starts at the kill
second = p["action"]["fight"] second = p["action"]["fight"]
self.assertEqual(next(e[0] for e in second["events"] if e[1] == "you"), 2.4) # a full swing, from scratch self.assertEqual(next(e[0] for e in second["events"] if e[1] == p["id"]), 2.4) # a full swing, from scratch
def test_preferred_food_is_eaten_first(self): def test_preferred_food_is_eaten_first(self):
world, p = self.fighter() world, p = self.fighter()
p["bank"] = {"bread": 5, "cooked_salmon": 5} p["bank"] = {"bread": 5, "cooked_salmon": 5}
@ -855,11 +856,13 @@ class PrayerTest(unittest.TestCase):
world, p = self.priest(level=10, combat=10) world, p = self.priest(level=10, combat=10)
scouts = game.ACTION_BY_ID["fight_scouts"] scouts = game.ACTION_BY_ID["fight_scouts"]
p["pp"], p["hp"] = 1000, 50 p["pp"], p["hp"] = 1000, 50
plain = game.simulate_fight(world, p, scouts, random.Random(3)) plain = game.simulate_fight(world, p, scouts, random.Random(2))
game.set_prayers(p, ["rapid_heal"]) game.set_prayers(p, ["rapid_heal"])
mended = game.simulate_fight(world, p, scouts, random.Random(3)) mended = game.simulate_fight(world, p, scouts, random.Random(2))
swings = sum(1 for e in plain["events"] if e[1] == "you") self.assertEqual(plain["end"], mended["end"]) # the same fight, only the mending differs
swings = sum(1 for e in plain["events"] if e[1] == p["id"])
top = game.max_hp(p) top = game.max_hp(p)
plain, mended = game.member_of(plain, p["id"]), game.member_of(mended, p["id"])
self.assertAlmostEqual(mended["hp"] - plain["hp"], swings * game.MEND * top * plain["interval"], places=6) self.assertAlmostEqual(mended["hp"] - plain["hp"], swings * game.MEND * top * plain["interval"], places=6)
self.assertLess(game.fight_odds(p, scouts)["damage"], game.fight_odds({**p, "prayers": []}, scouts)["damage"]) self.assertLess(game.fight_odds(p, scouts)["damage"], game.fight_odds({**p, "prayers": []}, scouts)["damage"])
@ -1411,7 +1414,7 @@ class WeaponStyleTest(unittest.TestCase):
self.assertGreater(spec(smith)["max_hit"], spec(other)["max_hit"]) # the trade's own weapon hits harder self.assertGreater(spec(smith)["max_hit"], spec(other)["max_hit"]) # the trade's own weapon hits harder
world, archer = self.armed("ranger", "oak_bow") world, archer = self.armed("ranger", "oak_bow")
fight = game.simulate_fight(world, archer, game.ACTION_BY_ID["fight_harpies"], random.Random(1)) fight = game.simulate_fight(world, archer, game.ACTION_BY_ID["fight_harpies"], random.Random(1))
self.assertEqual(fight["events"][0][1], "you") # the bow strikes first self.assertEqual(fight["events"][0][1], archer["id"]) # the bow strikes first
def test_two_handed_weapons_leave_no_room_for_a_shield(self): def test_two_handed_weapons_leave_no_room_for_a_shield(self):
_w, p = self.armed("miner", "iron_warhammer") _w, p = self.armed("miner", "iron_warhammer")
@ -2050,5 +2053,128 @@ class PatchNotesTest(unittest.TestCase):
self.assertTrue(text) self.assertTrue(text)
class PartyTest(unittest.TestCase):
def pair(self, level=12):
world = world_with(("wil", "commoner"), ("kay", "commoner"))
for p in world["players"].values():
p["xp"]["combat"] = game.XP_TABLE[level]
p["hp"] = game.max_hp(p)
p["equip"]["weapon"] = "iron_sword"
p["bank"] = {"bread": 300}
return world, world["players"]["wil"], world["players"]["kay"]
def online(self, world, now, rng):
"""Everyone playing: catch up to `now` as their pages would."""
for p in world["players"].values():
p["last_active"] = now
game.catch_up(world, now, rng)
def test_tagging_in_keeps_what_already_happened(self):
world, wil, kay = self.pair()
rng = random.Random(1)
game.start_action(world, wil, "fight_scouts", T0)
self.online(world, T0 + 0.1, rng)
before = json.loads(json.dumps(wil["action"]["fight"]))
k = len(before["events"]) // 2
at = (before["events"][k - 1][0] + before["events"][k][0]) / 2
self.online(world, T0 + at, rng)
self.assertIsNone(game.join_fight(world, kay, "wil", T0 + at))
fight = game.current_fight(world, wil)
self.assertIs(fight, game.current_fight(world, kay)) # both watch the one fight
self.assertEqual(fight["events"][:k], before["events"][:k])
theirs = [e for e in fight["events"] if e[1] == "kay"]
self.assertTrue(theirs)
self.assertGreater(theirs[0][0], at)
self.assertEqual(kay["action"]["start"], wil["action"]["start"])
def test_kills_go_faster_and_everyone_gets_the_xp(self):
world, wil, kay = self.pair()
game.start_action(world, wil, "fight_scouts", T0)
self.assertIsNone(game.join_fight(world, kay, "wil", T0))
self.online(world, T0 + 3600, random.Random(2))
together = wil["away"]
self.assertEqual(together["done"], kay["away"]["done"])
self.assertEqual(together["with"], {"Kay": together["done"]})
self.assertEqual(wil["xp"]["combat"], kay["xp"]["combat"]) # the same kills, the full XP each
low, high = game.ACTION_BY_ID["fight_scouts"]["gold"]
self.assertTrue(together["wins"] * low <= wil["gold"] + kay["gold"] <= together["wins"] * high)
self.assertLessEqual(abs(wil["gold"] - kay["gold"]), together["wins"]) # split evenly, odd coins aside
self.assertEqual(wil["bank"].get("bones", 0) + kay["bank"].get("bones", 0), together["wins"]) # one each kill
solo, alone = self.pair()[0:2]
game.start_action(solo, alone, "fight_scouts", T0)
self.online(solo, T0 + 3600, random.Random(2))
self.assertGreater(together["done"], alone["away"]["done"] * 1.5)
def test_leaving_lets_the_other_carry_on(self):
world, wil, kay = self.pair()
rng = random.Random(3)
game.start_action(world, wil, "fight_scouts", T0)
game.join_fight(world, kay, "wil", T0)
self.online(world, T0 + 5, rng)
self.assertEqual(len(world["parties"]), 1)
self.assertIsNone(game.start_action(world, kay, "mine_stone", T0 + 5))
self.assertEqual(world["parties"], {})
self.assertNotIn("party", wil["action"])
left = game.member_of(wil["action"]["fight"], "kay") # the fight goes on without her
self.assertEqual(left["result"], "left")
self.online(world, T0 + 600, rng)
self.assertGreater(wil["away"]["wins"], 3)
def test_away_too_long_steps_out(self):
world, wil, kay = self.pair(level=40)
for p in (wil, kay):
p["bank"] = {"bread": 100_000}
game.start_action(world, wil, "fight_scouts", T0)
game.join_fight(world, kay, "wil", T0)
now = T0 + game.OFFLINE_CAP + 3600
wil["last_active"] = now # wil kept playing, kay went to bed
game.catch_up(world, now, random.Random(4))
self.assertEqual(world["parties"], {})
self.assertEqual(kay["action"]["id"], "fight_scouts")
self.assertNotIn("party", kay["action"])
self.assertGreater(wil["away"]["done"], kay["away"]["done"]) # wil carried on alone for the last hour
def test_a_retreat_leaves_the_rest_fighting(self):
world, wil, kay = self.pair()
kay["bank"] = {}
kay["hp"] = game.max_hp(kay) * 0.35
game.start_action(world, wil, "fight_scouts", T0)
self.assertIsNone(game.join_fight(world, kay, "wil", T0))
self.online(world, T0 + 600, random.Random(5))
self.assertIsNone(kay["action"])
self.assertTrue(kay["stopped"].startswith("Retreated"))
self.assertEqual(wil["action"]["id"], "fight_scouts")
self.assertEqual(world["parties"], {})
def test_who_can_join(self):
world, wil, kay = self.pair()
self.assertEqual(game.join_fight(world, kay, "wil", T0), "Wil isn't fighting")
game.start_action(world, wil, "mine_stone", T0)
self.assertEqual(game.join_fight(world, kay, "wil", T0), "Wil isn't fighting")
self.assertEqual(game.join_fight(world, kay, "kay", T0), "No one to join")
wil["xp"]["combat"] = game.XP_TABLE[60]
game.start_action(world, wil, "fight_ruins", T0)
self.assertEqual(game.join_fight(world, kay, "wil", T0), "Needs level 26")
game.start_action(world, wil, "fight_scouts", T0)
self.assertIsNone(game.join_fight(world, kay, "wil", T0))
self.assertEqual(game.join_fight(world, wil, "kay", T0), "Already fighting together")
def test_loot_is_shared_out(self):
shares = game.share_loot((7, {"bones": 1, "arrows": 5}), ["a", "b"], random.Random(1))
self.assertEqual(sorted(gold for gold, _ in shares.values()), [3, 4])
self.assertEqual(sum(got.get("arrows", 0) for _, got in shares.values()), 5)
self.assertEqual(sum(got.get("bones", 0) for _, got in shares.values()), 1)
def test_a_fight_in_progress_is_drawn_again_after_the_update(self):
world, wil, _ = self.pair()
game.start_action(world, wil, "fight_scouts", T0)
wil["action"]["fight"] = {"old": True}
world["version"] = 24
del world["parties"], world["next_party_id"]
game.migrate(world, T0)
self.assertNotIn("fight", wil["action"])
self.assertEqual(world["parties"], {})
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()