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

415
game.py
View file

@ -1138,7 +1138,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows
# --- world ---------------------------------------------------------------
WORLD_VERSION = 24
WORLD_VERSION = 25
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": [],
"next_message_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["equip"].setdefault("tool_firekeeping", None)
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
@ -1800,76 +1809,145 @@ def fight_odds(player: dict, action: dict) -> dict:
"damage": round(row["damage"], 1)} for row in rows]}
def simulate_fight(world: dict, player: dict, action: dict, rng: random.Random) -> dict:
"""Play out one fight swing by swing, both swing timers starting empty. Nothing changes
until it's over: the result is applied then."""
variants = action["enemy"]["variants"]
foe = rng.choices(variants, [v["weight"] for v in variants])[0]
def fight_member(world: dict, player: dict, foe: dict, at: float = 0.0) -> dict:
"""One side of a fight as it squares up (or tags in `at` seconds of game time in): kept with the
fight, so playing it out again later goes the same way."""
blessed, brew = blessing(player), potion_effect(player, "combat")
me = fighter(player, blessed, brew, foe)
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")
return {"id": player["id"], "name": player["name"], "at": at, "left": None,
"start_hp": round(player["hp"]), "top": max_hp(player), "style": me["style"],
"interval": me["interval"] / speed(world), "fighter": me, "hp_in": player["hp"],
"pantry": {item: player["bank"][item] for item in edible(player)}, "mend": blessed["mend"],
"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)
# A bow looses its first arrow before the enemy closes in.
mine, theirs, result, t = (0.5 if me["first"] else me["interval"]), foe["interval"], None, 0.0
fight = {"seed": rng.random(), "variant": variant, "pace": pace, "enemy": foe["name"], "enemy_hp": foe["hp"],
"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
while result is None:
t = min(mine, theirs)
if t > MAX_FIGHT:
result = "fled"
while True:
# The next thing to happen: someone stepping out, someone's swing (in order of who came first), the enemy's.
moves = [(theirs, 2, -1)]
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
if mine <= theirs:
mine += me["interval"]
hp = min(top, hp + blessed["mend"] * top * me["interval"]) # Rapid Heal mends a little each swing
t, kind, i = min(moves)
if t > MAX_FIGHT:
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"]
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
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, me["damage"]])
side["dealt"] += dealt
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:
result = "won"
won = True
break
continue
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
crit = rng.random() < CRIT_CHANCE
landed = rng.random() < their_hit
landed = rng.random() < side["their_hit"]
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
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
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, kind])
while hp < top * EAT_BELOW and any(pantry.values()):
side["hp"] -= dealt
side["hurt"] += dealt
events.append([round(t / pace, 2), "foe", ("crit" if crit else "hit") if landed else "miss", dealt,
max(0, round(side["hp"])), foe_hp, attack, uid])
pantry = side["pantry"]
while side["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"])
mine += EAT_DELAY
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:
side["eaten"][food] = side["eaten"].get(food, 0) + 1
side["hp"] = min(top, side["hp"] + ITEMS[food]["heal"])
side["next"] += EAT_DELAY
events.append([round(t / pace, 2), uid, "eat", ITEMS[food]["heal"], round(side["hp"]), foe_hp, food])
beaten, starving = side["hp"] <= 0, side["hp"] < top * FLEE_BELOW and not any(pantry.values())
if (beaten or starving) and side["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])
side["wind"] = False
side["hp"] = top / 2
events.append([round(t / pace, 2), uid, "wind", 0, round(side["hp"]), foe_hp])
elif beaten:
hp, result = 1, "lost"
side["hp"], side["out"] = 1, "lost"
elif starving:
result = "fled"
return {"enemy": foe["name"], "enemy_hp": foe["hp"], "top": top, "start_hp": round(player["hp"]), "xp": foe["xp"],
"foe": {key: foe[key] for key in ("archetype", "attack", "vulnerable", "resists")},
"hp": hp, "result": result, "end": round(t / pace, 2), "duration": t / pace,
"interval": me["interval"] / pace, "enemy_interval": foe["interval"] / pace, "events": events, "style": me["style"],
"eaten": eaten, "damage": hurt, "pp": blessed["cost"],
"wind_used": blessed["wind"] and not wind and not (player["action"] or {}).get("wind_used")}
side["out"] = "fled"
for side in sides:
member = side["member"]
member.update(hp=side["hp"], eaten=side["eaten"], damage=side["hurt"], dealt=side["dealt"],
wind_used=member["wind"] and not side["wind"],
result=side["out"] or ("won" if won and member["at"] <= t else "left"))
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 -------------------------------------------------------------------
@ -2221,7 +2299,9 @@ def can_start(world: dict, player: dict, action: dict) -> str | 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"])
order = working_order(world, player, 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:
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
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
paid = min(player["pp"], fight["pp"])
paid = min(player["pp"], mine["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():
for food, count in mine["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"]:
player["hp"], player["hp_at"] = mine["hp"], now
summary["damage"] += mine["damage"]
if mine["wind_used"]:
outing["wind_used"] = True
summary["second_wind"] += 1
if fight["result"] != "won":
if mine["result"] != "won":
# Beaten or out of food: fall back with nothing but your life.
summary["losses"] += 1
reason = "Retreated: ran out of food" if not edible(player) else "Retreated"
stop(player, reason)
drop_out(world, player, now, reason)
summary["stopped"] = reason
gain_xp(world, player, "combat", xp // 3, now, summary)
return
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
summary["gold"] += gold
for item, qty, odds in action.get("drops", []):
if rng.random() < odds:
outputs[item] = outputs.get(item, 0) + qty
for item, qty in drops.items():
outputs[item] = outputs.get(item, 0) + qty
if action.get("special") == "sabotage":
raid = upcoming_raid(world)
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()
collect_orders(world, player)
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:
return summary
limit = min(now, player["last_active"] + OFFLINE_CAP / speed(world))
limit = min(now, offline_limit(world, player))
while player["action"]:
action = growing(player)
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
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:
"""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."""
changed = False
parties = lambda: {pid: party["start"] for pid, party in world["parties"].items()}
before = parties()
for player in world["players"].values():
summary = process_player(world, player, now, rng)
if summary["done"] or summary["stopped"]:
merge_summary(player.setdefault("away", empty_summary()), summary)
changed = True
return changed
return changed or parties() != before
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:
order["claimed"] = None # couldn't start: don't sit on it
return reason
if previous and previous.get("party") in world["parties"]:
leave_party(world, player, previous["party"], now)
player["stopped"] = 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":
# Replanting the first plot leaves the second one growing.
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["stopped"] = 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 "
"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", "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", "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."),