Raid replay plays like a fight; fight-once can be joined

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-25 20:54:37 +00:00
parent 4f2d113abd
commit 387f4c201b
4 changed files with 513 additions and 112 deletions

50
game.py
View file

@ -3090,16 +3090,9 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N
current["turn"] = 1 - current.get("turn", 0) current["turn"] = 1 - current.get("turn", 0)
if player["action"] is current and "left" in current: if player["action"] is current and "left" in current:
current["left"] -= 1 current["left"] -= 1
if current["left"] <= 0: if current["left"] <= 0 and finish_once(player, current["start"]):
back = current.get("resume") current = player["action"]
if back: summary["resumed"] = current["id"]
plot2 = current.get("resume_plot2")
player["action"] = current = {"id": back, "start": current["start"]}
if plot2:
current.update(plot2=plot2, turn=0)
summary["resumed"] = back
else:
stop(player)
if player["action"] is current and "enemy" not in action and not current.get("plot2"): if player["action"] is current and "enemy" not in action and not current.get("plot2"):
# Out of materials: stop now, not once the next bar has filled. # Out of materials: stop now, not once the next bar has filled.
reason = can_start(world, player, growing(player)) reason = can_start(world, player, growing(player))
@ -3117,6 +3110,19 @@ def stop(player: dict, reason: str | None = None) -> None:
player["stopped"] = reason player["stopped"] = reason
def finish_once(player: dict, at: float, reason: str | None = None) -> bool:
"""A one-off is done: back to what you were doing before (True), or stop."""
current = player["action"]
back = current.get("resume")
if not back:
stop(player, reason)
return False
player["action"] = {"id": back, "start": at}
if current.get("resume_plot2"):
player["action"].update(plot2=current["resume_plot2"], turn=0)
return True
def drop_out(world: dict, player: dict, now: float, reason: str | None = None) -> None: 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.""" """Stop, stepping out of a shared fight if you're in one."""
current = player["action"] current = player["action"]
@ -3180,7 +3186,7 @@ def offline_limit(world: dict, player: dict) -> float:
def joinable(world: dict, player: dict) -> bool: def joinable(world: dict, player: dict) -> bool:
"""Whether someone's fight is one others can tag into.""" """Whether someone's fight is one others can tag into."""
current = player["action"] current = player["action"]
if not current or "left" in current or "order" in current: if not current or "order" in current:
return False return False
action = ACTION_BY_ID[current["id"]] action = ACTION_BY_ID[current["id"]]
if action.get("dungeon"): if action.get("dungeon"):
@ -3309,9 +3315,9 @@ def process_party(world: dict, pid: str, now: float, rng: random.Random) -> None
if world["parties"].get(pid) is not party: if world["parties"].get(pid) is not party:
break # the last of them carries on alone, and picks up when they're back break # the last of them carries on alone, and picks up when they're back
at = max(party["start"], offline_limit(world, player)) at = max(party["start"], offline_limit(world, player))
wind = {"wind_used": True} if player["action"].get("wind_used") else {} keep = {k: v for k, v in player["action"].items() if k in ("wind_used", "left", "resume", "resume_plot2")}
leave_party(world, player, pid, at) leave_party(world, player, pid, at)
player["action"] = {"id": action["id"], "start": at, **wind} player["action"] = {"id": action["id"], "start": at, **keep}
continue continue
if end > now: if end > now:
break break
@ -3336,6 +3342,15 @@ def process_party(world: dict, pid: str, now: float, rng: random.Random) -> None
for other in members: for other in members:
if other is not player: if other is not player:
together[other["name"]] = together.get(other["name"], 0) + 1 together[other["name"]] = together.get(other["name"], 0) + 1
current = player["action"]
if not action.get("dungeon") and "left" in current:
# Fought once: step out, and back to what you were doing. The rest carry on.
current["left"] -= 1
if current["left"] <= 0:
if world["parties"].get(current.get("party")) is party:
leave_party(world, player, pid, end)
if finish_once(player, end):
summary["resumed"] = player["action"]["id"]
if action.get("dungeon") and world["parties"].get(pid) is party: if action.get("dungeon") and world["parties"].get(pid) is party:
_advance_run(world, party, action, fight, members, end, rng) _advance_run(world, party, action, fight, members, end, rng)
@ -3382,8 +3397,11 @@ def _advance_run(world: dict, party: dict, action: dict, fight: dict, members: l
log(world, now, "party", f"{names}: {reason.lower()} in {spec['name']}") log(world, now, "party", f"{names}: {reason.lower()} in {spec['name']}")
del world["parties"][party["id"]] del world["parties"][party["id"]]
for player in members: for player in members:
stop(player, reason) summary = player.setdefault("away", empty_summary())
player.setdefault("away", empty_summary())["stopped"] = reason if finish_once(player, now, reason):
summary["resumed"] = player["action"]["id"]
else:
summary["stopped"] = reason
def _clear_bonus(world: dict, player: dict, spec: dict, now: float, rng: random.Random) -> None: def _clear_bonus(world: dict, player: dict, spec: dict, now: float, rng: random.Random) -> None:
@ -6074,6 +6092,8 @@ PATCH_NOTES = [
("fix", "Resting in a group: everyone heals at their own pace, so good food tops you up first. The pull still " ("fix", "Resting in a group: everyone heals at their own pace, so good food tops you up first. The pull still "
"waits for the slowest, and Pull now sends each in as healed as they really are."), "waits for the slowest, and Pull now sends each in as healed as they really are."),
("new", "DPS meter under the combat log: damage per second and total for each fighter, live. Shows the last fight while resting."), ("new", "DPS meter under the combat log: damage per second and total for each fighter, live. Shows the last fight while resting."),
("new", "Improved raid UI"),
("fix", "Bugfix: Combats started using \"Fight once\" can now be joined."),
]}, ]},
{"date": "2026-10-24", "title": "Dungeon loot", "notes": [ {"date": "2026-10-24", "title": "Dungeon loot", "notes": [
("new", "Masterwork patterns: every dungeon boss can drop one of 7 (15% from a mini-boss, 35% from the last boss, " ("new", "Masterwork patterns: every dungeon boss can drop one of 7 (15% from a mini-boss, 35% from the last boss, "

View file

@ -218,71 +218,413 @@ function showThreat() {
const t = S.threat_report; const t = S.threat_report;
if (!t || watching === t.id || !$("#modal").hidden) return; if (!t || watching === t.id || !$("#modal").hidden) return;
watching = t.id; watching = t.id;
openBattle(t, `data-raid-seen="${t.id}"`);
}
// A past fight, watched again from the council's history.
function replayBattle(id) {
const h = S.history.find((x) => x.id === id);
if (h?.report) openBattle(h, "data-battle-close");
}
// Who stands on each side. Ours: the garrison by name, then the city's defence in squads, each as
// strong as its share of the total. Theirs: the raiders' strength split into bands, or the one dragon.
// `every` is how often each swings, in seconds of the replay; walls and the ram only take blows.
const SQUADS = [
{ id: "guards", parts: ["militia", "weapons", "shields"], name: "Guards", glyph: "swordman", dmg: "slash", every: 2 },
{ id: "archers", parts: ["arrows"], name: "Archers", glyph: "bow-arrow", dmg: "pierce", every: 1.8 },
{ id: "walls", parts: ["walls"], name: "Walls", glyph: "stone-wall", dmg: "blunt", still: true },
{ id: "folk", parts: ["townsfolk", "drilled", "rallied"], name: "Townsfolk", glyph: "farmer", dmg: "blunt", every: 2.8 },
{ id: "swords", parts: ["mercenaries"], name: "Sellswords", glyph: "hooded-assassin", dmg: "slash", every: 2.2 },
{ id: "ward", parts: ["ward"], name: "Temple ward", glyph: "prayer", dmg: "magic", every: 3 },
];
const RAID_BANDS = [
{ id: "cut", name: "Cutthroats", glyph: "bowie-knife", dmg: "slash", every: 1.8 },
{ id: "bow", name: "Bowmen", glyph: "pocket-bow", dmg: "pierce", every: 2 },
{ id: "brute", name: "Brutes", glyph: "fist", dmg: "blunt", every: 3 },
];
const STOPPERS = ["shield_bash", "pinning_shot", "concuss"]; // abilities that cut a cast short
const OURS = "#d4dae2", THEIRS = "#e08a7a";
const BATTLE_STEPS = 32, BATTLE_DT = 0.65, BATTLE_LEAD = 1.2; // ~22s a fight
// An ability's cooldown in the replay: as on auto, but twice as quick, since the fight is short.
const battleCd = (id) => (C.abilities[id]?.cd || 8) * C.auto_cd / 2;
// The big moves: a cast bar, then a burst. Players' abilities come from C.abilities.
function battleMove(label) {
return { ram: { name: "Ram", icon: glyph("wood-beam", THEIRS) },
charge: { name: "Charge", icon: glyph("crossed-swords", OURS) },
war_cry: C.enemy_abilities.war_cry, fire_breath: C.enemy_abilities.fire_breath }[label] || C.abilities[label] || C.enemy_abilities[label];
}
function battleSides(t, rand) {
const r = t.report, parts = r.parts || {}, us = [], them = [];
const sum = Object.values(parts).reduce((a, b) => a + b, 0);
const scale = sum ? r.defence / sum : 1; // hunger takes a fifth off the whole line
r.garrison.forEach((g, i) => {
const p = S.players.find((x) => x.id === g.id);
const style = item(p?.equip?.weapon).style || "sword";
const known = C.weapon_kits[style].filter((id) => (p?.levels.combat ?? 1) >= C.abilities[id].level
&& (C.abilities[id].needs !== "shield" || p?.equip?.shield));
const hits = (id, kinds) => C.abilities[id].effects.some((e) => kinds.includes(e.t));
const kit = known.filter((id) => hits(id, ["dmg", "aoe", "dot"]));
const burst = known.find((id) => hits(id, ["aoe"])) || known.find((id) => hits(id, ["dmg"]));
const stop = known.find((id) => STOPPERS.includes(id));
us.push({ id: `g${i}`, uid: g.id, name: g.id === S.uid ? "You" : esc(g.name), label: `${crest(p?.look, 18)} ${esc(g.name)}`,
top: g.power * scale, dmg: C.styles[style].damage, known, kit, burst, stop, every: C.styles[style].interval });
});
const squad = (sq, top) => top >= 1 && us.push({ ...sq, top, squad: true,
label: `${glyph(sq.glyph, OURS)} ${sq.id === "guards" && r.guards ? `Guards ×${r.guards}` : sq.name}` });
if (sum) for (const sq of SQUADS) squad(sq, sq.parts.reduce((a, k) => a + (parts[k] || 0), 0) * scale);
else squad(SQUADS[3], r.defence - r.garrison.reduce((a, g) => a + g.power, 0));
const band = (b, top) => them.push({ ...b, top, side: "them", label: `${glyph(b.glyph, THEIRS)} ${b.name}` });
if (t.kind === "dragon") {
const name = (r.group || "the dragon").replace(/^\w/, (c) => c.toUpperCase());
band({ id: "d", name: "Dragon", glyph: "dragon-head", dmg: "magic", every: 3, kit: ["tail_swipe"] }, r.attack);
them[0].name = esc(name);
them[0].label = `${glyph("dragon-head", THEIRS)} ${esc(name)}`;
} else {
const chief = r.attack * 0.2, ram = parts.walls ? r.attack * 0.15 : 0;
band({ id: "chief", name: "Warlord", glyph: "visored-helm", dmg: "slash", every: 2.4 }, chief);
if (ram) band({ id: "ram", name: "Battering ram", glyph: "wood-beam", dmg: "blunt", still: true }, ram);
const weights = RAID_BANDS.map(() => 0.6 + rand() * 0.8), total = weights.reduce((a, b) => a + b, 0);
RAID_BANDS.forEach((b, i) => band(b, (r.attack - chief - ram) * weights[i] / total));
}
for (const u of us) u.side = "us";
return { us, them };
}
// The whole fight, worked out up front and seeded by the event so everyone sees the same one. Pure
// theatre: the outcome is already decided. The loser's side runs out; the winner keeps as much as the
// gap between the two. In a close fight the loser surges in the middle and the winner turns it late.
function battleScript(t) {
let seed = t.id * 9301 + 49297;
const rand = () => (seed = (seed * 9301 + 49297) % 233280) / 233280;
const won = t.report.title.startsWith("Victory"), dragon = t.kind === "dragon";
const { us, them } = battleSides(t, rand);
const units = [...us, ...them], byId = Object.fromEntries(units.map((u) => [u.id, u]));
for (const u of units) u.hp = u.top;
const usTop = us.reduce((a, u) => a + u.top, 0), themTop = them.reduce((a, u) => a + u.top, 0);
const close = Math.min(usTop, themTop) / Math.max(usTop, themTop, 1), k = close * close;
const keep = Math.max(0.1, 1 - close);
const p1 = 0.3 + (rand() - 0.5) * 0.1, p2 = 0.72 + (rand() - 0.5) * 0.1;
const lerp = (x, y) => x + (y - x) * k;
// How much of its losses each side has taken by each point of the fight.
const winner = [[0, 0], [p1, lerp(p1, 0.2)], [1, 1]];
const loser = [[0, 0], [p1, lerp(p1, 0.35)], [p2, lerp(p2, 0.55)], [1, 1]];
const at = (pts, x) => {
for (let i = 1; i < pts.length; i++) {
const [x0, y0] = pts[i - 1], [x1, y1] = pts[i];
if (x <= x1) return y0 + (y1 - y0) * (x - x0) / (x1 - x0);
}
return 1;
};
const losses = (pts, loss) => {
const steps = Array.from({ length: BATTLE_STEPS }, (_, i) =>
(at(pts, (i + 1) / BATTLE_STEPS) - at(pts, i / BATTLE_STEPS)) * (0.6 + rand() * 0.8));
const total = steps.reduce((a, b) => a + b, 0);
return steps.map((s) => s / total * loss);
};
const toUs = losses(won ? winner : loser, won ? usTop * (1 - keep) : usTop);
const toThem = losses(won ? loser : winner, won ? themTop : themTop * (1 - keep));
const sideOf = { us: toUs, them: toThem };
const time = (step) => BATTLE_LEAD + step * BATTLE_DT;
const live = (side) => (side === "us" ? us : them).filter((u) => u.hp > 0.01);
const pick = (list, weight) => {
let x = rand() * list.reduce((a, u) => a + weight(u), 0);
for (const u of list) if ((x -= weight(u)) <= 0) return u;
return list[list.length - 1];
};
// The big moves. Each side's surge piles the next two steps' damage into one burst; a foiled cast
// is the raiders' war cry, cut short.
const plan = {};
const add = (step, move) => { step = Math.min(BATTLE_STEPS - 1, step); (plan[step] ||= []).push(move); };
const b1 = Math.round(p1 * BATTLE_STEPS), b2 = Math.round(p2 * BATTLE_STEPS);
const best = us.find((u) => u.burst);
const ourMove = byId.archers ? { src: "archers", label: "volley", aoe: true }
: best ? { src: best.id, label: best.burst, aoe: true }
: { src: us.find((u) => !u.still)?.id ?? us[0].id, label: "charge", aoe: true };
const theirMove = dragon ? { src: "d", label: "fire_breath", aoe: true }
: byId.ram ? { src: "ram", label: "ram", tgt: "walls" } : { src: "chief", label: "war_cry", aoe: true };
const late = b2 + 2, mid = b1 + 3;
add(won ? late : mid, { ...ourMove, to: "them" });
add(won ? mid : late, { ...theirMove, to: "us" });
if (!dragon) add(won ? b2 : b1 + 7, { src: "chief", label: "war_cry", foiled: true });
const events = [];
const push = (e) => { e[0] = Math.max(e[0], (events.at(-1)?.[0] ?? 0) + 0.01); events.push(e); };
const focus = {};
// Damage dealt to one side, as hits from its foes; overflow spills onto the next. The losing side
// is picked off unit by unit; the winning side wears down evenly.
const strike = (when, src, to, amount, label, kind = "hit", only = null) => {
while (amount > 0.01) {
const pool = only ? [only].filter((u) => u.hp > 0.01) : live(to);
if (!pool.length) return;
const tgt = (to === "us") === won ? pick(pool, (u) => u.top * (u.hp / u.top) ** 4)
: pool.includes(focus[to]) && rand() < 0.5 ? focus[to] : pick(pool, (u) => u.hp);
focus[to] = tgt;
const n = Math.min(amount, tgt.hp);
tgt.hp = Math.max(0, tgt.hp - n);
amount -= n;
push([when, src.id, tgt.id, kind, n, tgt.hp, label]);
if (tgt.hp <= 0.01) { tgt.hp = 0; push([when + 0.05, src.id, tgt.id, "die"]); }
when += 0.08;
if (only) only = null;
}
};
const attacker = (side) => {
const all = live(side), fighting = all.filter((u) => !u.still);
return (fighting.length ? fighting : all).length ? pick(fighting.length ? fighting : all, (u) => u.top) : null;
};
// Everyone swings on their own clock. Each step's damage to a side is shared among the swings that
// land on it then, by how strong the swinger is; players use their abilities as they come off cooldown.
const next = {}, used = {}, carry = { us: 0, them: 0 };
for (const u of units) if (u.every && !u.still) { next[u.id] = BATTLE_LEAD + rand() * u.every * 0.7; u.from = next[u.id] - u.every; used[u.id] = {}; }
for (let step = 0; step < BATTLE_STEPS; step++) {
const base = time(step);
// Casts start three steps ahead of their burst; a foiled one is stopped a step short.
for (const move of plan[step + 3] || []) {
const src = byId[move.src];
if (src?.hp > 0.01) push([base, move.src, null, "cast", 0, null, move.label, time(step + 3)]);
}
for (const move of plan[step + 1] || []) {
if (!move.foiled || !(byId[move.src]?.hp > 0.01)) continue;
const stoppers = us.filter((u) => u.hp > 0.01 && u.stop);
const stopper = stoppers[Math.floor(rand() * stoppers.length)]
|| live("us").find((u) => u.id === "archers") || attacker("us");
if (stopper) push([base + 0.05, stopper.id, move.src, "stop", 0, null,
stopper.stop || "", move.label]);
if (stopper?.stop) used[stopper.id][stopper.stop] = base;
}
// A burst takes this step's damage and most of the next two's.
for (const move of plan[step] || []) {
const src = byId[move.src], dealt = sideOf[move.to];
if (move.foiled || !(src?.hp > 0.01)) continue;
for (let j = 1; j <= 2 && step + j < BATTLE_STEPS; j++) { dealt[step] += dealt[step + j] * 0.8; dealt[step + j] *= 0.2; }
const amount = dealt[step];
dealt[step] = 0;
if (used[src.id]) used[src.id][move.label] = base;
if (move.aoe) {
const pool = live(move.to), sum = pool.reduce((a, u) => a + u.hp, 0);
let when = base;
for (const u of pool) { strike(when, src, move.to, amount * u.hp / sum, move.label, "crit", u); when += 0.04; }
} else strike(base, src, move.to, amount, move.label, "crit", byId[move.tgt]);
}
// The swings that land this step. A side no one swings at keeps its share for the next step.
const swings = [];
for (const u of units) {
while (next[u.id] < base + BATTLE_DT) { swings.push([next[u.id], u]); next[u.id] += u.every * (0.85 + rand() * 0.3); }
}
swings.sort((x, y) => x[0] - y[0]);
const budget = { us: toUs[step] + carry.us, them: toThem[step] + carry.them }, weight = { us: 0, them: 0 };
const blows = swings.map(([when, u]) => {
const to = u.side === "us" ? "them" : "us";
const ready = (u.kit || []).filter((id) => !(used[u.id][id] > when - battleCd(id)));
const label = ready.length && rand() < 0.5 ? ready[Math.floor(rand() * ready.length)] : u.dmg;
const crit = rand() < 0.1, w = u.top * (0.7 + rand() * 0.6) * (crit ? 1.8 : 1) * (label === u.dmg ? 1 : 1.5);
weight[to] += w;
return { when, u, to, label, crit, w };
});
for (const side of ["us", "them"]) carry[side] = weight[side] ? 0 : budget[side];
for (const { when, u, to, label, crit, w } of blows) {
const amount = budget[to] * w / weight[to];
if (!(u.hp > 0.01)) { carry[to] += amount; continue; } // fell earlier this step
if (label !== u.dmg) used[u.id][label] = when;
if (amount >= 0.5) { strike(when, u, to, amount, label, crit ? "crit" : "hit"); continue; }
carry[to] += amount;
const tgt = live(to)[0];
if (tgt) push([when, u.id, tgt.id, "miss", 0, null, u.dmg]);
}
}
// Whatever's left of the losing side breaks at the last blow.
const end = time(BATTLE_STEPS);
for (const u of won ? them : us) if (u.hp > 0) { u.hp = 0; push([end - 0.1, u.id, u.id, "hit", 0, 0, ""]); push([end - 0.05, u.id, u.id, "die"]); }
return { units, byId, events, end: end + 0.4, won };
}
// Where everyone stands `t` seconds in: HP, last swing, cast under way, out or not.
function battleState(b, t) {
const st = {};
for (const u of b.units) st[u.id] = { hp: u.top, top: u.top, swung: null, cast: null, stopped: null, hit: null, out: false, cds: {} };
let shown = -1;
while (shown + 1 < b.events.length && b.events[shown + 1][0] <= t) {
const [time, src, tgt, kind, n, hp, label, until] = b.events[++shown];
const who = st[src], them = st[tgt];
if (them && hp != null) them.hp = hp;
if (who && (kind === "miss" || (kind === "hit" || kind === "crit") && n)) who.swung = time;
if (who && C.abilities[label] && (kind === "hit" || kind === "crit" || kind === "stop") && who.cds[label] !== time) who.cds[label] = time;
if (who && kind === "cast") who.cast = { label, from: time, until };
if (them && kind === "stop" && them.cast) { them.stopped = { label: them.cast.label, at: time }; them.cast = null; }
if (them && (kind === "hit" || kind === "crit") && n) them.hit = { at: time, n, kind };
if (them && kind === "die") { them.out = true; them.cast = null; }
}
return { st, shown };
}
// A line of the fight, terse: who, to whom, how much, with what.
function battleLine(e, b) {
const [, src, tgt, kind, n, , label, extra] = e;
const u = (id) => b.byId[id], name = (id) => u(id)?.name ?? "";
const move = battleMove(label);
const what = move ? ` ${move.icon || ""} ${esc(move.name)}` : C.damage_types[label] ? ` ${dmgIcon(label)}` : "";
const theirs = u(src)?.side === "them";
switch (kind) {
case "hit": case "crit":
return n ? `<li class="${theirs ? "bad" : u(src)?.uid === S.uid ? "" : "muted"}">${name(src)} → ${name(tgt)} <b>${Math.max(1, Math.round(n))}</b>${kind === "crit" ? " crit" : ""}${what}</li>` : "";
case "miss": return `<li class="muted">${name(src)} → ${name(tgt)} miss</li>`;
case "cast": return `<li class="warn">${name(src)}${what}…</li>`;
case "stop": {
const cut = battleMove(extra);
return `<li class="good">${name(src)}${what} ✋ ${name(tgt)} <s>${cut?.icon || ""} ${esc(cut?.name)}</s></li>`;
}
case "die": {
const v = u(tgt);
if (v.side === "them") return `<li class="good">${v.id === "d" ? `${v.name} flees` : v.id === "ram" ? "The ram is burning" : `${v.name} break and run`}</li>`;
return `<li class="bad"><b>${v.uid ? (v.uid === S.uid ? "You're knocked out" : `${v.name} is knocked out`)
: v.id === "walls" ? "The walls are breached" : `${v.name} fall back`}</b></li>`;
}
}
return "";
}
// Laid out like the combat page: you and your abilities, then the rest of the garrison and the city's
// squads; across from you the raiders, the fight log and the damage meter.
function openBattle(t, closeAttr) {
const r = t.report, dragon = t.kind === "dragon", won = r.title.startsWith("Victory"); const r = t.report, dragon = t.kind === "dragon", won = r.title.startsWith("Victory");
const foe = dragon ? "The dragon" : "Raiders"; const b = battleScript(t);
const unit = (u) => `<div class="fighter ${u.side === "them" ? "foe-unit" : u.uid === S.uid ? "b-me" : "ally"} ${u.squad ? "squad" : ""}" data-b="${u.id}">
<div class="fighter-name"><b>${u.label}<span class="traits-inline">${dmgIcon(u.dmg, "attack")}</span></b>
<span class="small muted"><span class="hitnum" data-u-hitnum></span><span data-u-hp></span></span></div>
<div class="bar hp ${u.side === "them" ? "foe" : "you"}"><span class="lag" data-u-lag></span><span data-u-bar style="width:100%"></span></div>
${u.every ? `<div class="bar swing"><span data-u-swing></span></div>` : ""}
${casters.has(u.id) ? `<div class="cast small" data-u-cast></div>` : ""}
</div>`;
const casters = new Set(b.events.filter((e) => e[3] === "cast").map((e) => e[1])); // room for a cast bar only where one comes
const us = b.units.filter((u) => u.side === "us"), them = b.units.filter((u) => u.side === "them");
const me = us.find((u) => u.uid === S.uid);
const kit = me ? `<div class="ability-bar">${me.known.map((id) => `<span class="ab auto" data-bab="${id}" title="${esc(C.abilities[id].name)}">
<span class="ab-press">${C.abilities[id].icon}<span class="ab-cd" data-ab-cd></span></span></span>`).join("")}</div>` : "";
const group = (r.group || (dragon ? "the dragon" : "the raiders")).replace(/^\w/, (c) => c.toUpperCase());
openModal(` openModal(`
<div class="battle"> <div class="battle fight">
<h2>${dragon ? "🐉 The dragon attacks!" : "⚔️ The raiders attack!"}</h2> <h2>${dragon ? `🐉 ${esc(group)} attacks!` : `⚔️ ${esc(group)} attack!`}</h2>
<div class="battle-side"><span><b>Our defence</b> <span class="muted">${fmt(r.defence)}</span></span> <div class="tug"><b data-b-us>${fmt(r.defence)}</b>
<div class="bar thick hp you"><span data-battle="us" style="width:100%"></span></div></div> <div class="bar thick tug-bar"><span data-b-tug style="width:${r.defence / Math.max(r.defence + r.attack, 1) * 100}%"></span></div>
<div class="battle-side"><span><b>${foe}</b> <span class="muted">${fmt(r.attack)}</span></span> <b data-b-them>${fmt(r.attack)}</b></div>
<div class="bar thick hp"><span data-battle="them" style="width:100%"></span></div></div> <div class="battle-frame">
<ul class="fight-log battle-log" data-battle="log"><li class="muted">${dragon ? "A shadow falls over the walls…" : "Horns sound from the walls…"}</li></ul> <div class="b-side">
${me ? unit(me) + kit : ""}
<div class="pack squads b-allies">${us.filter((u) => u.uid && u !== me).map(unit).join("")}</div>
<div class="pack squads b-city">${us.filter((u) => !u.uid).map(unit).join("")}</div>
</div>
<div class="b-side">
<div class="pack b-foes">${them.map(unit).join("")}</div>
<ul class="fight-log" data-b-log><li class="muted">${dragon ? "A shadow falls over the walls…" : "Horns sound from the walls…"}</li></ul>
<div class="dps" data-b-dps></div>
<div data-battle="report" hidden> <div data-battle="report" hidden>
<div class="battle-result ${won ? "win" : "loss"}">${esc(r.title)}</div> <div class="battle-result ${won ? "win" : "loss"}">${esc(r.title)}</div>
${r.garrison.length ? `<p class="small muted">Garrison: ${r.garrison.map((g) => esc(g.name)).join(", ")}</p>` : ""}
${r.lines.map((l) => `<p class="small">${esc(l)}</p>`).join("")} ${r.lines.map((l) => `<p class="small">${esc(l)}</p>`).join("")}
</div> </div>
<p class="btns"><button class="btn" data-battle-skip>Skip</button> <p class="btns"><button class="btn" data-battle-skip>Skip</button>
<button class="btn primary" data-raid-seen="${t.id}" hidden>Close</button></p> <button class="btn primary" ${closeAttr} hidden>Close</button></p>
</div>
</div>
</div>`); </div>`);
playBattle(t.id, r.defence, r.attack, won, dragon); playBattle(b, $(".battle"));
} }
// Pure theatre: the outcome is already decided. The loser's bar runs out; the winner's ends function playBattle(b, box) {
// as high as the gap between the two sides. const start = performance.now() / 1000;
function playBattle(id, us, them, won, dragon) { let skipped = false;
const steps = 10, every = 900; const frame = () => {
let seed = id * 9301 + 49297; if (!box.isConnected) return;
const rand = () => (seed = (seed * 9301 + 49297) % 233280) / 233280; const t = skipped ? b.end : performance.now() / 1000 - start;
const weights = Array.from({ length: steps }, () => 0.5 + rand()); drawBattle(b, box, t);
const total = weights.reduce((a, b) => a + b, 0); if (t < b.end) return requestAnimationFrame(frame);
const loserEnd = 0, winnerEnd = Math.max(0.1, 1 - Math.min(us, them) / Math.max(us, them, 1));
const ours = won ? winnerEnd : loserEnd, theirs = won ? loserEnd : winnerEnd;
const lines = {
good: dragon ? ["Arrows find the dragon's wing", "The ballista bolt strikes home", "Spears drive it back from the walls"]
: ["Arrows rain down on the raiders", "The walls hold", "The militia drives them back", "A charge breaks their line"],
bad: dragon ? ["Fire sweeps the rooftops", "Its claws tear at the gate", "The defenders scatter from its breath"]
: ["Raiders scale the walls", "The gate shudders under the ram", "A volley cuts down defenders"],
};
let step = 0, done = 0, said = "";
const finish = () => {
clearInterval(timer);
const box = $(".battle");
if (!box) return;
$('[data-battle="us"]', box).style.width = `${ours * 100}%`;
$('[data-battle="them"]', box).style.width = `${theirs * 100}%`;
$('[data-battle="report"]', box).hidden = false; $('[data-battle="report"]', box).hidden = false;
$("[data-battle-skip]", box).hidden = true; $("[data-battle-skip]", box).hidden = true;
$("[data-raid-seen]", box).hidden = false; const close = $("[data-raid-seen], [data-battle-close]", box);
close.hidden = false;
close.scrollIntoView({ block: "nearest", behavior: "smooth" });
}; };
const timer = setInterval(() => { playBattle.skip = () => { skipped = true; };
const box = $(".battle"); requestAnimationFrame(frame);
if (!box) return clearInterval(timer); }
if (step >= steps) return finish();
done += weights[step] / total; function drawBattle(b, box, t) {
step++; const { st, shown } = battleState(b, t), before = t < b.end ? battleState(b, t - LAG).st : st;
$('[data-battle="us"]', box).style.width = `${(1 - (1 - ours) * done) * 100}%`; let ours = 0, theirs = 0;
$('[data-battle="them"]', box).style.width = `${(1 - (1 - theirs) * done) * 100}%`; for (const u of b.units) if (u.side === "us") ours += st[u.id].hp; else theirs += st[u.id].hp;
// Each clash goes the way of the side that ends up ahead, now and then the other. $("[data-b-tug]", box).style.width = `${ours / (ours + theirs || 1) * 100}%`;
const ahead = rand() < 0.7 ? won : !won; $("[data-b-us]", box).textContent = fmt(Math.ceil(ours));
const pool = (ahead ? lines.good : lines.bad).filter((l) => l !== said); $("[data-b-them]", box).textContent = fmt(Math.ceil(theirs));
const log = $('[data-battle="log"]', box); for (const el of box.querySelectorAll("[data-b]")) {
said = pool[Math.floor(rand() * pool.length)]; const u = st[el.dataset.b], was = before[el.dataset.b], spec = b.byId[el.dataset.b];
log.insertAdjacentHTML("afterbegin", `<li class="${ahead ? "good" : "bad"}">${said}</li>`); el.classList.toggle("out", u.out);
while (log.children.length > 4) log.lastElementChild.remove(); el.querySelector("[data-u-bar]").style.width = `${u.hp / u.top * 100}%`;
}, every); el.querySelector("[data-u-lag]").style.width = `${Math.max(u.hp, was.hp) / u.top * 100}%`;
playBattle.finish = finish; el.querySelector("[data-u-hp]").textContent = `${fmt(Math.ceil(u.hp))} / ${fmt(Math.ceil(u.top))}`;
const hit = u.hit && t - u.hit.at < 1 ? u.hit : null, num = el.querySelector("[data-u-hitnum]");
num.textContent = hit ? `−${Math.max(1, Math.round(hit.n))}` : "";
num.className = `hitnum bad ${hit?.kind === "crit" ? "crit" : ""}`;
num.style.opacity = hit ? `${1 - (t - hit.at)}` : "0";
const swing = el.querySelector("[data-u-swing]");
if (swing) swing.style.width = u.out || t >= b.end ? "0%" : `${Math.max(0, Math.min(100, (t - (u.swung ?? spec.from)) / spec.every * 100))}%`;
const cast = u.cast && t < u.cast.until ? u.cast : null;
const stopped = !cast && u.stopped && t - u.stopped.at < STOPPED ? u.stopped : null;
const move = battleMove(cast?.label || stopped?.label);
const castEl = el.querySelector("[data-u-cast]");
const key = cast ? `${cast.label}:${cast.from}` : stopped ? `stopped:${stopped.at}` : "";
if (castEl && castEl.dataset.key !== key) {
castEl.dataset.key = key;
castEl.classList.toggle("stopped", !!stopped);
castEl.innerHTML = cast ? `<span>${move.icon || ""} ${esc(move.name)}</span><span class="bar"><span></span></span>`
: stopped ? `<span>✋ <s>${move.icon || ""} ${esc(move.name)}</s></span><span class="small">interrupted</span>` : "";
}
if (cast && castEl) castEl.querySelector(".bar > span").style.width = `${Math.min(100, (t - cast.from) / (cast.until - cast.from) * 100)}%`;
}
// Your abilities: the shade is the cooldown left, a flash when one's just been used.
const mine = b.units.find((u) => u.uid === S.uid);
for (const el of box.querySelectorAll("[data-bab]")) {
const used = st[mine.id].cds[el.dataset.bab], cd = battleCd(el.dataset.bab);
const left = used == null || t >= b.end ? 0 : Math.max(0, used + cd - t);
el.querySelector("[data-ab-cd]").style.height = `${left / cd * 100}%`;
el.classList.toggle("fired", used != null && t - used < 0.6);
}
// The log keeps to what you'd follow: your own blows and the ones on you, abilities, crits, the big
// moves and who falls. Everyone's swings are on the bars.
const log = $("[data-b-log]", box);
b.told ||= b.events.filter((e) => battleTold(e, b));
let told = 0;
while (told < b.told.length && b.told[told][0] <= t) told++;
if (told && log.dataset.key !== `${told}:${t >= b.end}`) {
log.dataset.key = `${told}:${t >= b.end}`;
const lines = b.told.slice(Math.max(0, told - 7), told).reverse().map((e) => battleLine(e, b)).filter(Boolean);
if (t >= b.end) lines.unshift(b.won ? `<li class="good"><b>Victory!</b></li>` : `<li class="bad"><b>The line breaks.</b></li>`);
log.innerHTML = lines.slice(0, 7).join("");
}
// The damage meter: what each of ours has dealt so far, best first.
const meter = $("[data-b-dps]", box), secs = Math.max(1, Math.min(t, b.end) - BATTLE_LEAD);
const key = `${shown}:${Math.floor(Math.min(t, b.end))}`;
if (meter.dataset.key !== key) {
meter.dataset.key = key;
const dealt = {};
for (const e of b.events.slice(0, shown + 1)) if (e[3] === "hit" || e[3] === "crit") dealt[e[1]] = (dealt[e[1]] || 0) + e[4];
const rows = b.units.filter((u) => u.side === "us" && dealt[u.id]).sort((x, y) => dealt[y.id] - dealt[x.id]).slice(0, 5);
const top = Math.max(...rows.map((u) => dealt[u.id]), 1);
meter.innerHTML = rows.length ? `<div class="dps-head small muted"><span>Damage</span><span title="Damage per second · total">DPS · total</span></div>
<ul>${rows.map((u) => `<li class="${u === mine ? "mine" : ""}"><span class="dps-fill" style="width:${dealt[u.id] / top * 100}%"></span>
<b>${u.uid ? esc(playerName(u.uid)) : esc(u.name)}</b><span>${(dealt[u.id] / secs).toFixed(1)} <span class="muted">· ${fmt(Math.round(dealt[u.id]))}</span></span></li>`).join("")}</ul>` : "";
}
}
// Whether a line of the replay makes the log (see drawBattle).
function battleTold(e, b) {
const [, src, tgt, kind, n, , label] = e;
const mine = (id) => b.byId[id]?.uid === S.uid;
if (kind === "cast" || kind === "stop" || kind === "die") return true;
if (kind === "miss") return mine(src);
return !!n && (kind === "crit" || mine(src) || mine(tgt) || !!C.abilities[label] && !!b.byId[src]?.uid);
} }
function openModal(html) { function openModal(html) {
@ -1799,6 +2141,7 @@ function council() {
: { fortune: "win", hardship: "loss", minor: "loss" }[h.track] || ""; : { fortune: "win", hardship: "loss", minor: "loss" }[h.track] || "";
html += `<div class="report ${tone}"> html += `<div class="report ${tone}">
<b>${h.report.icon || eventIcon(h.kind)} ${esc(h.report.title)}</b> <span class="muted small">${ago(h.at)}</span> <b>${h.report.icon || eventIcon(h.kind)} ${esc(h.report.title)}</b> <span class="muted small">${ago(h.at)}</span>
${fight ? `<button class="btn sm" data-replay="${h.id}">▶ Watch</button>` : ""}
${fight ? `<p class="muted">Defence ${fmt(h.report.defence)} vs ${h.kind === "dragon" ? "the dragon" : "raiders"} ${fmt(h.report.attack)}${h.report.garrison.length ? ` · garrison: ${h.report.garrison.map((g) => esc(g.name)).join(", ")}` : ""}</p>` : ""} ${fight ? `<p class="muted">Defence ${fmt(h.report.defence)} vs ${h.kind === "dragon" ? "the dragon" : "raiders"} ${fmt(h.report.attack)}${h.report.garrison.length ? ` · garrison: ${h.report.garrison.map((g) => esc(g.name)).join(", ")}` : ""}</p>` : ""}
${h.report.lines.map((l) => `<p>${esc(l)}</p>`).join("")} ${h.report.lines.map((l) => `<p>${esc(l)}</p>`).join("")}
</div>`; </div>`;
@ -2092,6 +2435,40 @@ function notes() {
}).join("")}</ul></div>`).join(""); }).join("")}</ul></div>`).join("");
} }
// --- achievements ----------------------------------------------------------
// A record of what's left to unlock, read off what you already have. Tap a chip for what it is.
const tally = (entries) => [entries.filter((e) => e.done).length, entries.length];
function achievementsNav(me) {
const [have, need] = tally(me.achievements.flatMap((s) => s.cards.flatMap((c) => c.entries)));
return nav("achievements", "🏆", "Achievements", `${have}/${need}`, "", `${have} of ${need} unlocked`);
}
function achievements() {
const sections = S.me.achievements, all = sections.flatMap((s) => s.cards.flatMap((c) => c.entries));
const [have, need] = tally(all);
return `<div class="panel"><h2>Achievements <span class="ach-count">${have} / ${need}</span></h2>
<div class="bar gold thick"><span style="width:${have / need * 100}%"></span></div></div>`
+ sections.map((s, si) => {
const [sh, sn] = tally(s.cards.flatMap((c) => c.entries));
return `<h3 class="section-title">${esc(s.name)} <span class="ach-count">${sh} / ${sn}</span></h3>
<div class="grid">${s.cards.map((c, ci) => achCard(c, `${si}.${ci}`)).join("")}</div>`;
}).join("");
}
function achCard(card, key) {
const [have, need] = tally(card.entries);
const pick = achPick?.startsWith(key + ".") ? card.entries[Number(achPick.split(".")[2])] : null;
const icon = (e) => e.colour ? `<span class="swatch" style="background:${e.colour}"></span>` : glyph(e.glyph, e.tint);
const chips = card.entries.map((e, i) => `<button class="ach ${e.done ? "done" : ""} ${e.held ? "held" : ""} ${pick === e ? "on" : ""}" data-ach="${key}.${i}"
title="${esc(`${e.name}${e.tip ? ` · ${e.tip}` : ""}`)}" aria-label="${esc(e.name)}">${icon(e)}</button>`).join("");
return `<div class="panel ach-card ${have === need ? "full" : ""}"><h2>${esc(card.name)} <span class="ach-count">${have} / ${need}</span></h2>
<div class="bar gold"><span style="width:${have / need * 100}%"></span></div>
<div class="ach-chips">${chips}</div>
${pick ? `<p class="small ach-line">${pick.done ? "✓" : "🔒"} <b>${esc(pick.name)}</b>${pick.tip ? ` <span class="muted">· ${esc(pick.tip)}</span>` : ""}</p>` : ""}</div>`;
}
// --- character sheet ----------------------------------------------------- // --- character sheet -----------------------------------------------------
const SLOT_NAMES = { weapon: "Weapon", shield: "Shield", helmet: "Helmet", body: "Body", legs: "Legs" }; const SLOT_NAMES = { weapon: "Weapon", shield: "Shield", helmet: "Helmet", body: "Body", legs: "Legs" };
@ -2367,52 +2744,20 @@ function loop() {
requestAnimationFrame(loop); requestAnimationFrame(loop);
} }
// --- achievements ----------------------------------------------------------
// A record of what's left to unlock, read off what you already have. Tap a chip for what it is.
const tally = (entries) => [entries.filter((e) => e.done).length, entries.length];
function achievementsNav(me) {
const [have, need] = tally(me.achievements.flatMap((s) => s.cards.flatMap((c) => c.entries)));
return nav("achievements", "🏆", "Achievements", `${have}/${need}`, "", `${have} of ${need} unlocked`);
}
function achievements() {
const sections = S.me.achievements, all = sections.flatMap((s) => s.cards.flatMap((c) => c.entries));
const [have, need] = tally(all);
return `<div class="panel"><h2>Achievements <span class="ach-count">${have} / ${need}</span></h2>
<div class="bar gold thick"><span style="width:${have / need * 100}%"></span></div></div>`
+ sections.map((s, si) => {
const [sh, sn] = tally(s.cards.flatMap((c) => c.entries));
return `<h3 class="section-title">${esc(s.name)} <span class="ach-count">${sh} / ${sn}</span></h3>
<div class="grid">${s.cards.map((c, ci) => achCard(c, `${si}.${ci}`)).join("")}</div>`;
}).join("");
}
function achCard(card, key) {
const [have, need] = tally(card.entries);
const pick = achPick?.startsWith(key + ".") ? card.entries[Number(achPick.split(".")[2])] : null;
const icon = (e) => e.colour ? `<span class="swatch" style="background:${e.colour}"></span>` : glyph(e.glyph, e.tint);
const chips = card.entries.map((e, i) => `<button class="ach ${e.done ? "done" : ""} ${e.held ? "held" : ""} ${pick === e ? "on" : ""}" data-ach="${key}.${i}"
title="${esc(`${e.name}${e.tip ? ` · ${e.tip}` : ""}`)}" aria-label="${esc(e.name)}">${icon(e)}</button>`).join("");
return `<div class="panel ach-card ${have === need ? "full" : ""}"><h2>${esc(card.name)} <span class="ach-count">${have} / ${need}</span></h2>
<div class="bar gold"><span style="width:${have / need * 100}%"></span></div>
<div class="ach-chips">${chips}</div>
${pick ? `<p class="small ach-line">${pick.done ? "✓" : "🔒"} <b>${esc(pick.name)}</b>${pick.tip ? ` <span class="muted">· ${esc(pick.tip)}</span>` : ""}</p>` : ""}</div>`;
}
// --- events --------------------------------------------------------------- // --- events ---------------------------------------------------------------
document.addEventListener("click", (e) => { document.addEventListener("click", (e) => {
const t = e.target.closest("button, [data-menu], [data-close], [data-aim]"); const t = e.target.closest("button, [data-menu], [data-close], [data-aim]");
if (!t) return; if (!t) return;
const d = t.dataset; const d = t.dataset;
if (t.matches("[data-battle-skip]")) return playBattle.finish?.(); if (t.matches("[data-battle-skip]")) return playBattle.skip?.();
if (t.matches("[data-battle-close]")) { $("#modal").hidden = true; showThreat(); return; }
if (d.raidSeen) { if (d.raidSeen) {
$("#modal").hidden = true; $("#modal").hidden = true;
return act("/api/seen", { id: Number(d.raidSeen) }); return act("/api/seen", { id: Number(d.raidSeen) });
} }
if (!$("#modal").hidden && $(".battle")) return; // the raid report closes with its own button, once watched if (!$("#modal").hidden && $(".battle")) return; // the raid report closes with its own button, once watched
if (d.replay) return replayBattle(Number(d.replay));
if (t.matches("[data-close]") || e.target === $("#modal")) { $("#modal").hidden = true; showThreat(); return; } if (t.matches("[data-close]") || e.target === $("#modal")) { $("#modal").hidden = true; showThreat(); return; }
if ("menu" in d) { setMenu(!$("#app").classList.contains("open")); return; } if ("menu" in d) { setMenu(!$("#app").classList.contains("open")); return; }
if (d.nav) { if (d.nav) {

View file

@ -451,10 +451,30 @@ button.sheet-skill:hover { background: var(--panel-2); }
.banner-ico { font-size: 22px; display: flex; } .banner-ico { font-size: 22px; display: flex; }
/* Raid replay */ /* Raid replay */
.battle-side { display: grid; gap: 6px; margin: 12px 0; } .modal-box:has(.battle) { max-width: 980px; max-height: 92vh; }
.battle-side .bar.hp.you > span { background: var(--good); } .tug { display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center; margin: 4px 0 18px; font-variant-numeric: tabular-nums; }
.battle-side .bar > span { transition: width .8s ease-out; } .tug > b:first-child { color: var(--good); } .tug > b:last-child { color: var(--bad); }
.modal-box .battle-log { min-height: 96px; margin: 14px 0 10px; padding-left: 0; } .tug-bar { position: relative; background: var(--bad); }
.tug-bar > span { background: var(--good); }
.tug-bar::after { content: ""; position: absolute; left: 50%; top: -3px; bottom: -3px; width: 2px; background: var(--panel); }
.battle-frame { display: grid; grid-template-columns: 1fr 1fr; gap: 26px; }
.b-side { display: grid; gap: 12px; align-content: start; min-width: 0; }
.battle .squads { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px 14px; }
.battle .fighter-name .gi { width: 16px; height: 16px; vertical-align: -3px; }
.battle .fighter-name .crest { vertical-align: -4px; }
.battle .fighter.squad .bar.hp { height: 6px; }
.fight.battle .bar.hp > span.lag { background: rgba(255, 236, 200, .55); transition: none; }
.battle .fight-log { margin: 0; padding: 0; line-height: 1.35; height: calc(7 * 1.35em + 12px); min-height: 0; overflow: hidden; }
.battle .btns { margin: 0; }
/* Narrow: you, then the raiders and the log, then the rest of the city */
@media (max-width: 700px) {
.battle-frame { display: flex; flex-direction: column; gap: 14px; }
.b-side { display: contents; }
.battle .b-me { order: 1; } .battle .ability-bar { order: 2; } .b-foes { order: 3; } .battle .fight-log { order: 4; }
.b-allies { order: 5; } .b-city { order: 6; } .battle .dps { order: 7; } .battle [data-battle] { order: 8; } .battle .btns { order: 9; }
.battle .squads { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); }
.battle .fighter-name { flex-direction: row; }
}
.battle-result { font-weight: 700; font-size: 17px; margin: 6px 0 8px; } .battle-result { font-weight: 700; font-size: 17px; margin: 6px 0 8px; }
.battle-result.win { color: var(--good); } .battle-result.win { color: var(--good); }
.battle-result.loss { color: var(--bad); } .battle-result.loss { color: var(--bad); }

View file

@ -2868,6 +2868,22 @@ class PartyTest(unittest.TestCase):
self.assertIsNone(game.join_fight(world, kay, "wil", T0)) self.assertIsNone(game.join_fight(world, kay, "wil", T0))
self.assertEqual(game.join_fight(world, wil, "kay", T0), "Already fighting together") self.assertEqual(game.join_fight(world, wil, "kay", T0), "Already fighting together")
def test_a_boss_fought_once_can_be_joined(self):
world, wil, kay = self.pair(level=30)
for p in (wil, kay):
p["equip"].update(weapon="steel_sword", shield="steel_shield", helmet="steel_helmet", body="steel_body",
legs="steel_legs")
p["bank"] = {"cooked_salmon": 5000}
game.start_action(world, wil, "mine_stone", T0)
self.assertIsNone(game.start_action(world, wil, "fight_ruins", T0, once=True))
self.assertTrue(game.joinable(world, wil))
self.assertIsNone(game.join_fight(world, kay, "wil", T0))
self.online(world, T0 + 3600, random.Random(6))
self.assertEqual(wil["away"]["with"], {"Kay": 1})
self.assertEqual(wil["action"]["id"], "mine_stone") # one kill, then back to mining
self.assertEqual(kay["action"]["id"], "fight_ruins") # waiting for the next group
self.assertEqual(world["parties"], {})
def test_bosses_give_everyone_their_own_loot(self): def test_bosses_give_everyone_their_own_loot(self):
world, wil, kay = self.pair(level=30) world, wil, kay = self.pair(level=30)
for p in (wil, kay): for p in (wil, kay):