Fight together: tag into anyone's fight
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e0e0a824a9
commit
2d2cb16aff
6 changed files with 574 additions and 102 deletions
|
|
@ -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
13
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)
|
||||
|
|
|
|||
415
game.py
415
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."),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue