From cf91cbce3c736a6fc661a2f3d2e25e98711ed20c Mon Sep 17 00:00:00 2001 From: rope Date: Sun, 25 Oct 2026 19:21:37 +0000 Subject: [PATCH] Dungeons wait for the leader to start Co-Authored-By: Claude Opus 5.5 --- app.py | 2 ++ game.py | 33 +++++++++++++++++++++++++++------ static/app.js | 13 +++++++++---- tests/test_game.py | 27 ++++++++++++++++++++++----- 4 files changed, 60 insertions(+), 15 deletions(-) diff --git a/app.py b/app.py index e39aab4..85cc76d 100644 --- a/app.py +++ b/app.py @@ -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": diff --git a/game.py b/game.py index 617ea20..b7f8f98 100644 --- a/game.py +++ b/game.py @@ -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, " diff --git a/static/app.js b/static/app.js index 9bb50cd..17a7012 100644 --- a/static/app.js +++ b/static/app.js @@ -916,11 +916,15 @@ function combatFrame() { `; 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 ? `
${esc(run.dungeon)} ${run.pull} / ${run.of} · ${esc(run.next)}${run.wipes ? ` · wipes ${run.wipes} / ${C.dungeon_wipes}` : ""} - ${f && run.leader === S.uid ? `` : ""}
` : ""; - let foe = `
${run?.away?.length ? `Waiting for ${run.away.map(esc).join(", ")} to come back` + ${!run.started && lead ? `` + : f && lead ? `` : ""}
` : ""; + const group = run && [S.uid, ...(a.with || [])].map((uid) => uid === S.uid ? "You" : esc(playerName(uid))).join(", "); + let foe = `
${run && !run.started ? `👥 ${run.size} / ${C.dungeon_size} · ${group}
${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."}
`; 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); })(); diff --git a/tests/test_game.py b/tests/test_game.py index 32d6cd7..71aab81 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -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"]