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 ? `