Dungeons wait for the leader to start

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-25 19:21:37 +00:00
parent e43611e618
commit cf91cbce3c
4 changed files with 60 additions and 15 deletions

2
app.py
View file

@ -369,6 +369,8 @@ class Handler(BaseHTTPRequestHandler):
return game.learn_pattern(player, str(body.get("item")))
if path == "/api/pull":
return game.pull_now(world, player, now)
if path == "/api/run":
return game.start_run(world, player, now)
if path == "/api/manual":
return game.set_manual(world, player, str(body.get("id")), bool(body.get("on")), now)
if path == "/api/order":

33
game.py
View file

@ -3177,7 +3177,7 @@ def joinable(world: dict, player: dict) -> bool:
action = ACTION_BY_ID[current["id"]]
if action.get("dungeon"):
party = world["parties"].get(current.get("party"))
return not party or (not party.get("run", {}).get("pull") and len(party["members"]) < DUNGEON_SIZE)
return not party or ("run" not in party and len(party["members"]) < DUNGEON_SIZE)
return "enemy" in action and not action.get("special")
@ -3198,7 +3198,7 @@ def join_fight(world: dict, player: dict, uid: str, now: float) -> str | None:
return "No one to join"
run = party_of(world, host)
if run and ACTION_BY_ID[run["action"]].get("dungeon") and run is not party_of(world, player):
if run.get("run", {}).get("pull") or (run["fight"] and now >= run["start"] + run["fight"]["rest"] / run["fight"]["pace"]):
if "run" in run:
return "The run has started"
if len(run["members"]) >= DUNGEON_SIZE:
return "The group is full"
@ -3282,14 +3282,14 @@ def process_party(world: dict, pid: str, now: float, rng: random.Random) -> None
break
members = [world["players"][uid] for uid in party["members"]]
if action.get("dungeon"):
# A run waits for anyone who's looked away: it's played together.
# A run waits for its leader to start it, then for anyone who's looked away: it's played together.
away = [p for p in members if p["last_active"] < party["start"] - DUNGEON_AWAY]
if away:
if "run" not in party or away:
party["start"] = max(party["start"], now)
for player in members:
player["action"]["start"] = party["start"]
break
party["fight"] = draw_pull(world, members, action, party.setdefault("run", {"pull": 0, "wipes": 0}),
party["fight"] = draw_pull(world, members, action, party["run"],
rng, party["start"])
else:
party["fight"] = draw_fight(world, members, action, rng, party["start"])
@ -3402,7 +3402,27 @@ def run_of(world: dict, player: dict) -> dict | None:
if world["players"][uid]["last_active"] < party["start"] - DUNGEON_AWAY]
return {"dungeon": spec["name"], "pull": run["pull"] + 1, "of": len(spec["pulls"]), "wipes": run["wipes"],
"next": spec["pulls"][min(run["pull"], len(spec["pulls"]) - 1)][0], "away": away,
"leader": party["members"][0], "size": len(party["members"])}
"leader": party["members"][0], "size": len(party["members"]), "started": "run" in party}
def start_run(world: dict, player: dict, now: float) -> str | None:
"""The leader takes the group in: no one else can join from here on."""
party = party_of(world, player)
if not party or not ACTION_BY_ID[party["action"]].get("dungeon"):
return "Not in a dungeon"
if party["members"][0] != player["id"]:
return "Only the leader starts"
if "run" in party:
return "Already started"
if len(party["members"]) < GROUP_SIZE:
return f"Needs {GROUP_SIZE} or more"
party["run"] = {"pull": 0, "wipes": 0}
party["start"] = now
for uid in party["members"]:
world["players"][uid]["action"]["start"] = now
spec = DUNGEONS[ACTION_BY_ID[party["action"]]["dungeon"]]
log(world, now, "party", f"{player['name']} led {len(party['members'])} into {spec['name']}", player["id"])
return None
def pull_now(world: dict, player: dict, now: float) -> str | None:
@ -5978,6 +5998,7 @@ PATCH_NOTES = [
{"date": "2026-10-25", "title": "Titles", "notes": [
("new", "Fishing 50 also earns a second title: Master baiter."),
("balance", "Boiling hide glue now uses the kitchen fire: while it's lit, glue costs no logs."),
("fix", "Dungeons wait for the leader to press Start, so groups of 3 and 4 can form. No one can join once it starts."),
]},
{"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, "

View file

@ -916,11 +916,15 @@ function combatFrame() {
</div>`;
const you = unit(S.uid, esc(me.name)) + others.map((m) => unit(m.id, esc(m.name))).join("");
const run = a?.run;
// A dungeon run: where you are in it, and (for the leader, while resting) the button to go in early.
// A dungeon run: where you are in it, and (for the leader) the button to start it, or to go in early while resting.
const lead = run?.leader === S.uid;
const stage = run ? `<div class="run-head"><b>${esc(run.dungeon)}</b>
<span class="muted small">${run.pull} / ${run.of} · ${esc(run.next)}${run.wipes ? ` · wipes ${run.wipes} / ${C.dungeon_wipes}` : ""}</span>
${f && run.leader === S.uid ? `<button class="btn sm" data-pull data-pull-btn>Pull now</button>` : ""}</div>` : "";
let foe = `<div class="foe-idle muted">${run?.away?.length ? `Waiting for ${run.away.map(esc).join(", ")} to come back`
${!run.started && lead ? `<button class="btn sm primary" data-start-run title="No one can join once you start">Start</button>`
: f && lead ? `<button class="btn sm" data-pull data-pull-btn>Pull now</button>` : ""}</div>` : "";
const group = run && [S.uid, ...(a.with || [])].map((uid) => uid === S.uid ? "You" : esc(playerName(uid))).join(", ");
let foe = `<div class="foe-idle muted">${run && !run.started ? `👥 ${run.size} / ${C.dungeon_size} · ${group}<br>${lead ? "Start when everyone's in" : `${esc(playerName(run.leader))} starts the run`}`
: run?.away?.length ? `Waiting for ${run.away.map(esc).join(", ")} to come back`
: run ? `Ready: ${esc(run.next)}` : waiting ? `👥 Waiting for a group · 1 / ${C.group_size}` : "Not fighting."}</div>`;
if (f) {
const mine = C.styles[me.fighter.style || "sword"]?.damage || me.fighter.damage;
@ -2341,6 +2345,7 @@ document.addEventListener("click", (e) => {
}
if (d.mode) return act("/api/manual", { id: d.mode, on: !S.me.manual.includes(d.mode) });
if ("pull" in d) return act("/api/pull", {});
if ("startRun" in d) return act("/api/run", {});
if (d.learn) return act("/api/learn", { item: d.learn });
if (d.eat !== undefined) return act("/api/eat", { below: Number(d.eat) });
if (d.aim) {
@ -2603,7 +2608,7 @@ document.addEventListener("visibilitychange", () => { if (!document.hidden) refr
return;
}
setInterval(() => {
if (!document.hidden && Date.now() - polledAt >= (S?.me?.action?.fight ? FIGHT_POLL_MS : POLL_MS)) refresh();
if (!document.hidden && Date.now() - polledAt >= (S?.me?.action?.fight || S?.me?.action?.run ? FIGHT_POLL_MS : POLL_MS)) refresh();
}, 500);
requestAnimationFrame(loop);
})();

View file

@ -884,6 +884,7 @@ class DungeonTest(unittest.TestCase):
self.online(T0 + 600)
self.assertNotIn("fight", self.wil["action"]) # alone, it waits
self.assertIsNone(game.join_fight(self.world, self.kay, "wil", T0 + 600))
self.assertIsNone(game.start_run(self.world, self.wil, T0 + 600))
names = []
now = T0 + 600
while game.party_of(self.world, self.wil):
@ -901,21 +902,36 @@ class DungeonTest(unittest.TestCase):
self.assertGreaterEqual(p["gold"], game.DUNGEONS["dungeon_watchtower"]["bonus_gold"])
self.assertIn("cleared Watchtower Depths", self.world["ledger"][-1]["text"])
def test_joining_only_before_the_first_pull_and_up_to_four(self):
def test_a_group_waits_for_its_leader_and_holds_four(self):
game.start_action(self.world, self.wil, "dungeon_watchtower", T0)
self.assertIsNone(game.join_fight(self.world, self.kay, "wil", T0))
self.online(T0 + 1)
self.assertIsNone(game.join_fight(self.world, self.ann, "wil", T0 + 1)) # still resting: in time
self.party()["run"]["pull"] = 1
self.online(T0 + 600)
self.assertIsNone(self.party()["fight"]) # two in: still waiting for more
self.assertFalse(game.run_of(self.world, self.kay)["started"])
self.assertIsNone(game.join_fight(self.world, self.ann, "wil", T0 + 600))
extra = game.new_player("bo", "Bo", "ranger", T0)
extra["xp"]["combat"] = game.XP_TABLE[60]
self.world["players"]["bo"] = extra
self.assertEqual(game.join_fight(self.world, extra, "wil", T0 + 2), "The run has started")
self.assertIsNone(game.join_fight(self.world, extra, "wil", T0 + 600))
self.assertEqual(len(self.party()["members"]), 4)
self.assertFalse(game.joinable(self.world, self.wil))
self.assertEqual(game.start_run(self.world, self.kay, T0 + 601), "Only the leader starts")
self.assertIsNone(game.start_run(self.world, self.wil, T0 + 601))
self.online(T0 + 602)
self.assertEqual(len(self.party()["fight"]["members"]), 4)
game.leave_party(self.world, extra, self.party()["id"], T0 + 603)
extra["action"] = None
self.assertFalse(game.joinable(self.world, self.wil))
self.assertEqual(game.join_fight(self.world, extra, "wil", T0 + 604), "The run has started")
def test_no_run_alone(self):
game.start_action(self.world, self.wil, "dungeon_watchtower", T0)
self.assertEqual(game.start_run(self.world, self.wil, T0), "Not in a dungeon")
def test_a_run_waits_for_whoever_looked_away(self):
game.start_action(self.world, self.wil, "dungeon_watchtower", T0)
game.join_fight(self.world, self.kay, "wil", T0)
game.start_run(self.world, self.wil, T0)
self.online(T0 + 300, [self.wil]) # kay went to make tea
self.online(T0 + 1200, [self.wil])
self.assertIsNone(self.party()["fight"])
@ -940,6 +956,7 @@ class DungeonTest(unittest.TestCase):
def test_the_leader_can_pull_early(self):
game.start_action(self.world, self.wil, "dungeon_watchtower", T0)
game.join_fight(self.world, self.kay, "wil", T0)
game.start_run(self.world, self.wil, T0)
self.kay["hp"] = 1
self.online(T0 + 0.5)
fight = self.party()["fight"]