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
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
lean on magic, golems and trolls on blows. Races drop rare trophies (3%,
6% from bosses) for stronger weapons and the area's armour reagents (10%,
lean on magic, golems and trolls on blows. Races drop trophies (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.
- **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.
Sword and shield (slashing, 2.4s swings, the shield blocks 15% of hits;
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"]}
view["perk_costs"] = {perk: game.perk_cost(player, perk) for perk in player["perks"]}
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)
view["action"] = {**player["action"], "growing": action["id"], "duration": duration}
if fight:
view["action"]["fight"] = game.fight_view(fight)
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.
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}
@ -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},
"doing": action["name"] 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"],
})
return sorted(people, key=lambda p: -p["donated"])
@ -334,6 +341,8 @@ class Handler(BaseHTTPRequestHandler):
return "Bad quantity"
if path == "/api/action":
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":
return game.post_order(world, player, body.get("kind"), str(item), qty, body.get("provide"),
body.get("reward"), now)
@ -356,7 +365,7 @@ class Handler(BaseHTTPRequestHandler):
player["seen_threat"] = max(player.get("seen_threat", 0), event_id)
return None
if path == "/api/stop":
game.stop(player)
game.drop_out(world, player, now)
return None
if path == "/api/donate":
return game.donate(world, player, item, qty, now)

411
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)
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.
mine, theirs, result, t = (0.5 if me["first"] else me["interval"]), foe["interval"], None, 0.0
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,40 +2349,40 @@ 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:
for item, qty in drops.items():
outputs[item] = outputs.get(item, 0) + qty
if action.get("special") == "sabotage":
raid = upcoming_raid(world)
@ -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."),

View file

@ -4,6 +4,7 @@
// between polls. The server is the source of truth for everything.
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;
let C = null; // static content tables
@ -21,6 +22,8 @@ let pickedClass = null; // registration
const tabs = {}; // skill -> selected recipe tab
let refreshing = false;
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 version = null; // server build this page was loaded from
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() {
if (refreshing) return;
refreshing = true;
polledAt = Date.now();
try {
accept(await api("/api/state"));
failures = 0;
@ -95,8 +99,14 @@ function accept(state) {
location.reload(); // a new build was deployed
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;
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);
orderNews(state);
const ev = state.upcoming;
@ -180,6 +190,7 @@ function awayModal(sum) {
openModal(`
<h2>While you were away</h2>
<p class="muted">You completed ${fmt(sum.done)} actions${sum.wins || sum.losses ? ` (${sum.wins} fights won, ${sum.losses} lost)` : ""}.</p>
${Object.keys(sum.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>` : ""}
${items ? `<ul>${items}</ul>` : ""}
${sum.gold ? `<p class="gold">+${fmt(sum.gold)} gold</p>` : ""}
@ -832,12 +843,16 @@ function variantRows(e, o) {
function combatFrame() {
const me = S.me, a = me.action, f = a?.fight;
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>
<div class="bar swing"><span data-f="${who}-swing"></span></div>`;
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>
${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>`;
if (f) {
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>`;
}
// A line of the fight: yours as "You", everyone else's by name.
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 foe = esc(enemy);
if (kind === "eat") return `<li class="good">You eat ${esc(item(food).name.toLowerCase())} (+${amount})</li>`;
if (kind === "wind") return `<li class="good">Second wind! Back on your feet</li>`;
if (kind === "block") return `<li class="good">You block ${foe}'s blow</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>`;
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>`;
const subject = who === "foe" ? target : who, mine = subject === S.uid;
const name = mine ? "You" : esc(playerName(subject)), s = mine ? "" : "s";
if (kind === "eat") return `<li class="${mine ? "good" : "muted"}">${name} eat${s} ${esc(item(food).name.toLowerCase())} (+${amount})</li>`;
if (kind === "wind") return `<li class="good">Second wind! ${mine ? "Back on your feet" : `${name} is back up`}</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) {
@ -880,37 +898,50 @@ function animateFight(now) {
if (!f) return;
const t = now - a.start;
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),
// the panel is the same quiet countdown: no enemy, both swing timers empty.
const waiting = t < 0 || (over && f.result === "won");
// the panel is the same quiet countdown: no enemy, every swing timer empty.
const waiting = t < 0 || (over && f.result === "won" && mine.result === "won");
const respawnIn = t < 0 ? -t : f.end + (a.respawn || 1) - t;
let shown = -1;
while (shown + 1 < f.events.length && f.events[shown + 1][0] <= t) shown++;
const last = f.events[shown];
const hp = last ? last[4] : f.start_hp, foeHp = 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}%`;
const panel = $("[data-fight]");
if (!panel) return;
panel.classList.toggle("waiting", waiting);
// Your HP everywhere follows the fight as it plays out.
for (const el of document.querySelectorAll("[data-live-hp]")) el.textContent = fmt(hp);
for (const el of document.querySelectorAll("[data-live-hpbar]")) el.style.width = `${Math.max(0, hp / f.top * 100)}%`;
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); };
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("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)}`; });
for (const [who, every] of [["you", f.interval], ["foe", f.enemy_interval]]) {
let swung = 0;
for (let i = shown; i >= 0; i--) if (f.events[i][1] === who && f.events[i][2] !== "eat" && f.events[i][2] !== "wind") { swung = f.events[i][0]; break; }
set(`${who}-swing`, (el) => { el.style.width = over || waiting ? "0%" : `${Math.min(100, (t - swung) / every * 100)}%`; });
}
const swing = (key, swung, every, from = 0, gone = false) => set(`${key}-swing`, (el) => {
el.style.width = over || waiting || gone || t < from ? "0%" : `${Math.min(100, (t - (swung ?? from)) / 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) => {
const key = `${a.start}:${shown}:${over}:${waiting ? Math.ceil(respawnIn * 10) : ""}`;
if (el.dataset.key === key) return;
el.dataset.key = key;
const lines = f.events.slice(Math.max(0, shown - 5), shown + 1).reverse().map((e) => fightLine(e, f.enemy));
if (over && !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>`
: lines.join("") || `<li class="muted">Squaring up…</li>`;
});
@ -1014,7 +1045,7 @@ function actionCard(a) {
let extra = "";
if (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.drops) io += a.drops.map(chance).join("");
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}
${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>` : ""}
${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>` : ""}
<div class="foot">
<span class="why">${!active && !second && why ? esc(why) : ""}</span>
@ -1065,6 +1096,15 @@ function actionCard(a) {
</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 -----------------------------------------------------------------
// A strongbox's extra slots or a bag's bigger stack.
@ -1652,6 +1692,12 @@ function board() {
</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() {
return `<div class="grid wide">${S.players.map((p) => {
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 favour">${FAVOUR} ${fmt(p.donated)} given to the city</div></div></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>`;
}
@ -1955,6 +2001,7 @@ document.addEventListener("click", (e) => {
if (d.start) return act("/api/action", { id: d.start });
if (d.once) return act("/api/action", { id: d.once, once: true });
if ("stop" in d) return act("/api/stop", {});
if (d.join) { tagging = true; return act("/api/join", { id: d.join }); }
if ("orderNew" in d) {
draft = draft ? null : { kind: "craft", item: "", qty: 1, provide: [], reward: 0 };
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>`;
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);
})();

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.you > span { background: var(--good); }
.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 > 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; }

View file

@ -1,3 +1,4 @@
import json
import random
import sys
import unittest
@ -171,7 +172,7 @@ class ActionTest(unittest.TestCase):
p["bank"] = {"bread": 500}
game.start_action(world, p, "fight_wolves", T0)
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"])
low, high = game.ACTION_BY_ID["fight_wolves"]["gold"]
self.assertTrue(summary["wins"] * low <= p["gold"] <= summary["wins"] * high)
@ -521,7 +522,7 @@ class CombatTest(unittest.TestCase):
p["bank"] = {"cooked_shrimp": 100}
p["hp"] = 1
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")
swing = next(event for event in mine[bite:] if event[2] != "eat")
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))
fight = p["action"]["fight"] # simulated, waiting to finish, for the page to replay
self.assertEqual(fight["result"], "won")
self.assertTrue(all(e[1] in ("you", "foe") and e[2] in ("hit", "crit", "miss", "eat", "wind") for e in fight["events"]))
self.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.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))
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
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))
self.assertAlmostEqual(p["action"]["start"], T0 + first["duration"] + game.RESPAWN) # the pause starts at the kill
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):
world, p = self.fighter()
p["bank"] = {"bread": 5, "cooked_salmon": 5}
@ -855,11 +856,13 @@ class PrayerTest(unittest.TestCase):
world, p = self.priest(level=10, combat=10)
scouts = game.ACTION_BY_ID["fight_scouts"]
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"])
mended = game.simulate_fight(world, p, scouts, random.Random(3))
swings = sum(1 for e in plain["events"] if e[1] == "you")
mended = game.simulate_fight(world, p, scouts, random.Random(2))
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)
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.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
world, archer = self.armed("ranger", "oak_bow")
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):
_w, p = self.armed("miner", "iron_warhammer")
@ -2050,5 +2053,128 @@ class PatchNotesTest(unittest.TestCase):
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__":
unittest.main()