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:
parent
4f2d113abd
commit
387f4c201b
4 changed files with 513 additions and 112 deletions
50
game.py
50
game.py
|
|
@ -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, "
|
||||||
|
|
|
||||||
519
static/app.js
519
static/app.js
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue