diff --git a/README.md b/README.md index 2acf168..1cbe58a 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/app.py b/app.py index 7a48aa8..a3d2939 100644 --- a/app.py +++ b/app.py @@ -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) diff --git a/game.py b/game.py index 13ec34d..dd2f9d3 100644 --- a/game.py +++ b/game.py @@ -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."), diff --git a/static/app.js b/static/app.js index 96f5e3c..3f4a7b0 100644 --- a/static/app.js +++ b/static/app.js @@ -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(`

While you were away

You completed ${fmt(sum.done)} actions${sum.wins || sum.losses ? ` (${sum.wins} fights won, ${sum.losses} lost)` : ""}.

+ ${Object.keys(sum.with || {}).length ? `

⚔️ ${Object.entries(sum.with).map(([name, n]) => `${fmt(n)} with ${esc(name)}`).join(" · ")}

` : ""} ${Object.keys(sum.eaten || {}).length ? `

Took ${fmt(sum.damage)} damage and ate ${Object.entries(sum.eaten).map(([id, q]) => `${fmt(q)} ${esc(item(id).name.toLowerCase())}`).join(", ")}.

` : ""} ${items ? `` : ""} ${sum.gold ? `

+${fmt(sum.gold)} gold

` : ""} @@ -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) => `
`; const you = `
${esc(me.name)}${HEART} ${fmt(me.hp)} / ${me.max_hp}
${bars("you")} -
`; + ${allies.map((m, i) => `
+
${esc(m.name)}${HEART}
+
+
`).join("")}`; let foe = `
Not fighting.
`; if (f) { const mine = C.styles[(me.equip.weapon && item(me.equip.weapon).style) || "sword"].damage; @@ -864,15 +879,18 @@ function prayerBar(me) { return `
${skillOf("prayer").icon} ${fmt(me.pp)}${buttons}
`; } +// 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 `
  • You eat ${esc(item(food).name.toLowerCase())} (+${amount})
  • `; - if (kind === "wind") return `
  • Second wind! Back on your feet
  • `; - if (kind === "block") return `
  • You block ${foe}'s blow
  • `; - if (who === "you") return kind === "miss" ? `
  • You miss
  • ` : `
  • You ${kind === "crit" ? "crit" : "hit"} for ${amount}${type}
  • `; - return kind === "miss" ? `
  • ${foe} misses
  • ` : `
  • ${foe} ${kind === "crit" ? "crits" : "hits"} you for ${amount}${type}
  • `; + const subject = who === "foe" ? target : who, mine = subject === S.uid; + const name = mine ? "You" : esc(playerName(subject)), s = mine ? "" : "s"; + if (kind === "eat") return `
  • ${name} eat${s} ${esc(item(food).name.toLowerCase())} (+${amount})
  • `; + if (kind === "wind") return `
  • Second wind! ${mine ? "Back on your feet" : `${name} is back up`}
  • `; + if (kind === "block") return `
  • ${name} block${s} ${foe}'s blow
  • `; + if (who !== "foe") return kind === "miss" ? `
  • ${name} miss${mine ? "" : "es"}
  • ` : `${name} ${kind === "crit" ? `crit${s}` : `hit${s}`} for ${amount}${type}`; + return kind === "miss" ? `
  • ${foe} misses
  • ` : `
  • ${foe} ${kind === "crit" ? "crits" : "hits"} ${mine ? "you" : name} for ${amount}${type}
  • `; } 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(`
  • You fall back.
  • `); + if ((over && !waiting) || t >= fell) lines.unshift(`
  • You fall back.
  • `); el.innerHTML = waiting ? `
  • Victory! ${respawnIn > 0.05 ? `Next enemy in ${respawnIn.toFixed(1)}s` : "Next enemy coming…"}
  • ` : lines.join("") || `
  • Squaring up…
  • `; }); @@ -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 ? `
    Boss
    ` : ""}${variantRows(e, o)}`; + extra = `${e.boss ? `
    Boss
    ` : ""}${variantRows(e, o)}${fightingHere(a, why)}`; if (a.gold) io += `${COIN} ${a.gold[0]}–${a.gold[1]}`; if (a.drops) io += a.drops.map(chance).join(""); if (a.special === "sabotage") extra += `
    -3% raid strength per win (max 30%)
    `; @@ -1052,7 +1083,7 @@ function actionCard(a) { ${extra} ${me.action?.plot2 && (active || second) ? `
    Plot ${active ? 1 : 2}${growingNow ? " · growing now" : " · next"}
    ` : ""} ${growingNow && !a.enemy ? `
    ` : ""} - ${active && a.enemy ? `
    ⚔️ Fighting
    ` : ""} + ${active && a.enemy ? `
    ⚔️ Fighting${me.action.with?.length ? ` with ${me.action.with.map((uid) => esc(playerName(uid))).join(", ")}` : ""}
    ` : ""} ${active && me.action.left ? `
    Just one${me.action.resume ? `, then back to ${esc(C.actions.find((x) => x.id === me.action.resume).name.toLowerCase())}` : ""}
    ` : ""}
    ${!active && !second && why ? esc(why) : ""} @@ -1065,6 +1096,15 @@ function actionCard(a) {
    `; } +// 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) => `
    ⚔️ ${group.map((p) => esc(p.name)).join(", ")} +
    `).join(""); +} + // --- bank ----------------------------------------------------------------- // A strongbox's extra slots or a bag's bigger stack. @@ -1652,6 +1692,12 @@ function board() { `; } +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 ` `; +} + function roster() { return `
    ${S.players.map((p) => { const cls = C.classes[p.cls]; @@ -1661,7 +1707,7 @@ function roster() {
    ${p.doing ? `${skillOf(p.skill).icon} ${esc(p.doing)}` : `Idle`}${p.garrison ? ` · On guard` : ""}
    ${FAVOUR} ${fmt(p.donated)} given to the city
    ${levels}
    -

    `; +

    ${joinButton(p)}

    `; }).join("")}`; } @@ -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 = `

    Couldn't reach the city: ${esc(err.message)}

    `; 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); })(); diff --git a/static/styles.css b/static/styles.css index 6cb9ef2..4aec257 100644 --- a/static/styles.css +++ b/static/styles.css @@ -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; } diff --git a/tests/test_game.py b/tests/test_game.py index cb74be8..d637e2f 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -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()