Zones instead of road timers: journeys, flights, meeting stone

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-24 08:24:50 +01:00
parent 7150b2f89c
commit 0e396ee5d4
7 changed files with 363 additions and 230 deletions

View file

@ -35,6 +35,7 @@ let failures = 0;
let selected = null; // bag item
let regCls = null;
let pickReward = {}; // quest id -> reward index
let openQuest = null; // quest offer shown in full
let seenEv = { id: null, t: -1 };
let barKey = "";
let seenChat = Number(remember("seenChat") || 0);
@ -289,12 +290,9 @@ function abilityRow(id, level, me) {
function adventurePage() {
const me = S.me;
const here = place(me.place);
const act = me.activity;
const parts = [statusCard()];
if (!act || act.kind !== "dungeon") {
parts.push(placeCard(here));
}
if (!act || act.kind !== "dungeon") parts.push(zoneCard(zoneOf(me.place)));
parts.push(logCard());
if (S.dev) parts.push(devCard());
return parts.join("");
@ -308,19 +306,21 @@ function statusCard() {
return `<div class="card"><b>💀 You died.</b> <span class="muted">Running back · <span data-until="${me.dead_until}"></span></span></div>`;
}
if (!act) return "";
if (act.kind === "travel") {
const waiting = !S.fight && me.next_pull > t ? `resting · <span data-until="${me.next_pull}"></span>` : "";
if (act.kind === "journey") {
const road = C.roads[act.road];
const stops = act.to === road.zones[1] ? road.stops : road.stops.slice().reverse();
return `<div class="card">
<div class="row"><b>🐎 On the road to ${esc(place(act.to).name)}</b><span class="right muted small" data-until="${act.end}"></span></div>
<div class="bar road" style="margin-top:6px" data-from="${act.start}" data-to="${act.end}"><i></i><span></span></div>
<div class="small muted" style="margin-top:4px">${act.path.map((p) => esc(place(p).name)).join(" → ")}</div>
<div class="row"><b>🛤️ On the road to ${esc(C.zones[act.to].name)}</b><span class="muted small">${waiting}</span>
<button class="btn small right" data-act="stop">Turn back</button></div>
<div style="margin-top:6px">${stops.map((stop, i) => `<div class="small ${i < act.leg ? "muted" : ""}">${i < act.leg ? "✅" : i === act.leg ? "➡️" : "▫️"} ${esc(stop.name)}</div>`).join("")}
<div class="small">${act.leg >= stops.length ? "✅" : "▫️"} ${esc(C.places[C.zones[act.to].hub].name)}</div></div>
</div>`;
}
if (act.kind === "hunt") {
const mob = C.mobs[act.hunt];
const waiting = !S.fight && me.next_pull > t;
return `<div class="card"><div class="row">
<b>🎯 Hunting ${esc(mob.name)}</b>
<span class="muted small">${waiting ? `resting · <span data-until="${me.next_pull}"></span>` : ""}</span>
<b>🎯 Hunting ${esc(C.mobs[act.hunt].name)}</b><span class="muted small">${esc(place(me.place).name)}</span>
<span class="muted small">${waiting}</span>
<button class="btn small right" data-act="stop">Stop</button></div>
</div>`;
}
@ -357,46 +357,59 @@ function dungeonCard() {
</div>`;
}
function placeCard(here) {
function zoneOf(placeId) { return C.places[placeId].zone; }
function zoneDungeon(zone) {
return Object.keys(C.dungeons).find((id) => zoneOf(C.dungeons[id].entrance) === zone);
}
function zoneCard(zone) {
const me = S.me;
const z = C.zones[zone];
const act = me.activity;
const travelling = act && act.kind === "travel";
const away = act && act.kind === "journey";
const hunting = act && act.kind === "hunt" ? act.hunt : null;
const hunts = Object.entries(here.hunts || {}).map(([id, pulls]) => {
const mob = C.mobs[id];
const lv = mob.lv[0] === mob.lv[1] ? mob.lv[0] : `${mob.lv[0]}-${mob.lv[1]}`;
const others = [...new Set(pulls.flat())].filter((m) => m !== id).map((m) => C.mobs[m].name);
const hot = Object.keys(me.quests).some((q) => C.quests[q].obj.some((o) => o.kill === id || (o.from || []).includes(id)));
return `<div class="item-row" style="cursor:default">
<div class="grow"><b class="${mob.rare ? "q-rare" : ""}">${esc(mob.name)}</b> <span class="small muted">lv ${lv}${others.length ? " · with " + esc(others.join(", ")) : ""}</span>
${hot ? '<span class="qmark" title="Quest target">!</span>' : ""}</div>
${hunting === id ? `<button class="btn small on" data-act="stop">Hunting</button>`
: `<button class="btn small" data-act="hunt" data-id="${id}" ${travelling ? "disabled" : ""}>Hunt</button>`}
</div>`;
const subzones = Object.entries(C.places).filter(([, p]) => p.zone === zone && p.hunts).map(([pid, p]) => {
const rows = Object.entries(p.hunts).map(([id, pulls]) => {
const mob = C.mobs[id];
const lv = mob.lv[0] === mob.lv[1] ? mob.lv[0] : `${mob.lv[0]}-${mob.lv[1]}`;
const others = [...new Set(pulls.flat())].filter((m) => m !== id).map((m) => C.mobs[m].name);
const hot = Object.keys(me.quests).some((q) => C.quests[q].obj.some((o) => o.kill === id || (o.from || []).includes(id)));
return `<div class="item-row" style="cursor:default">
<div class="grow"><b class="${mob.rare ? "q-rare" : ""}">${esc(mob.name)}</b> <span class="small muted">lv ${lv}${others.length ? " · with " + esc(others.join(", ")) : ""}</span>
${hot ? '<span class="qmark" title="Quest target">!</span>' : ""}</div>
${hunting === id ? `<button class="btn small on" data-act="stop">Hunting</button>`
: `<button class="btn small" data-act="hunt" data-id="${id}" ${away ? "disabled" : ""}>Hunt</button>`}
</div>`;
}).join("");
return `<div class="small muted" style="margin:8px 0 4px">${esc(p.name)} · ${p.lv[0]}-${p.lv[1]}</div><div class="list">${rows}</div>`;
}).join("");
const offers = me.available.map((q) => questOffer(q)).join("");
const turnIns = Object.keys(me.quests).filter((q) => (C.quests[q].ender || C.quests[q].giver) === me.place && questReady(q))
// Nearest first; quests far above you wait until they're within reach.
const offers = me.available.filter((q) => C.quests[q].lv <= me.level + 5)
.sort((a, b) => C.quests[a].lv - C.quests[b].lv).map((q) => questOffer(q)).join("");
const turnIns = Object.keys(me.quests).filter((q) => zoneOf(C.quests[q].ender || C.quests[q].giver) === zone && questReady(q))
.map((q) => questTurnIn(q)).join("");
const dungeon = here.dungeon ? dungeonEntrance(here.dungeon) : "";
const dungeon = zoneDungeon(zone);
const junk = me.bags.filter((b) => b.q === "poor");
const junkValue = junk.reduce((sum, b) => sum + b.value * b.qty, 0);
const vendor = here.hub ? `<div class="row" style="margin-top:8px"><span class="muted small">🪙 Vendor</span>
<button class="btn small right" data-act="sell-junk" ${junk.length ? "" : "disabled"}>Sell junk ${junk.length ? money(junkValue) : ""}</button></div>` : "";
const roads = Object.entries(here.links).map(([to, minutes]) =>
`<button class="btn small" data-act="travel" data-id="${to}" ${travelling ? "disabled" : ""}>${esc(place(to).name)} <span class="muted">${minutes}m</span></button>`).join("");
const far = Object.entries(C.places).filter(([id]) => id !== me.place && !here.links[id])
.map(([id, p]) => `<option value="${id}">${esc(p.name)} (${esc(p.zone)}, ${p.lv[0]}-${p.lv[1]})</option>`).join("");
const roads = Object.entries(C.roads).filter(([, r]) => r.zones.includes(zone)).map(([, r]) => {
const to = r.zones.find((x) => x !== zone);
return `<div class="item-row" style="cursor:default">
<div class="grow"><b>🛤️ ${esc(C.zones[to].name)}</b> <span class="small muted">lv ${C.zones[to].lv[0]}-${C.zones[to].lv[1]} · ${esc(r.name)}, ${r.stops.length} fights</span></div>
<button class="btn small" data-act="journey" data-id="${to}" ${away ? "disabled" : ""}>Set out</button>
</div>`;
}).join("");
const flights = me.zones.filter((x) => x !== zone).map((to) =>
`<button class="btn small" data-act="fly" data-id="${to}" ${away ? "disabled" : ""}>🦅 ${esc(C.zones[to].name)} ${money(C.flight_cost)}</button>`).join("");
return `<div class="card">
<div class="row"><h2 style="margin:0">${esc(here.name)}</h2><span class="muted small">${esc(here.zone)} · ${here.lv[0]}-${here.lv[1]}</span></div>
${hunts ? `<h3>Hunt</h3><div class="list">${hunts}</div>` : ""}
<div class="row"><h2 style="margin:0">${esc(z.name)}</h2><span class="muted small">lv ${z.lv[0]}-${z.lv[1]}</span>
<button class="btn small right" data-act="sell-junk" ${junk.length ? "" : "disabled"}>🪙 Sell junk${junk.length ? " " + money(junkValue) : ""}</button></div>
${turnIns ? `<h3>Turn in</h3><div class="list">${turnIns}</div>` : ""}
${offers ? `<h3>Quests</h3><div class="list">${offers}</div>` : ""}
${dungeon}
${vendor}
<h3>Roads</h3>
<div class="row wrap">${roads}</div>
<div class="row" style="margin-top:8px"><select id="far" class="grow" ${travelling ? "disabled" : ""}><option value="">Travel further…</option>${far}</select>
<button class="btn small" data-act="travel-far" ${travelling ? "disabled" : ""}>Go</button></div>
${subzones ? `<h3>Hunt</h3>${subzones}` : ""}
${dungeon ? dungeonEntrance(dungeon) : ""}
${roads ? `<h3>Roads</h3><div class="list">${roads}</div>` : ""}
${flights ? `<div class="row wrap" style="margin-top:8px"><span class="small muted">Flight master</span>${flights}</div>` : ""}
</div>`;
}
@ -405,22 +418,25 @@ function dungeonEntrance(id) {
const g = S.group;
let action;
if (!g) action = `<span class="small muted">Form a group (👥) to go in</span>`;
else if (g.leader !== S.uid) action = `<span class="small muted">Waiting for the leader</span>`;
else action = `<button class="btn primary small" data-act="enter">Enter</button>`;
else if (g.leader !== S.uid) action = `<span class="small muted">The leader summons you</span>`;
else action = `<button class="btn primary small" data-act="enter">Summon &amp; enter</button>`;
return `<h3>Dungeon</h3><div class="item-row" style="cursor:default">
<div class="grow"><b>🕳️ ${esc(spec.name)}</b> <span class="small muted">lv ${spec.lv[0]}-${spec.lv[1]} · ${spec.size} players · ${spec.pulls.filter((p) => p.boss).length} bosses</span></div>
<div class="grow"><b>🕳️ ${esc(spec.name)}</b> <span class="small muted">${esc(place(spec.entrance).name)} · lv ${spec.lv[0]}-${spec.lv[1]} · ${spec.size} players · ${spec.pulls.filter((p) => p.boss).length} bosses</span>
<div class="tip">The meeting stone summons your whole group, wherever they are.</div></div>
${action}</div>`;
}
function questOffer(qid) {
const q = C.quests[qid];
const tooHigh = q.lv > S.me.level + 3;
return `<div class="quest">
<div class="row"><span class="qmark">!</span><b class="grow">${esc(q.name)}</b><span class="small ${tooHigh ? "stat-down" : "muted"}">lv ${q.lv}</span></div>
<div class="small muted" style="margin:4px 0">${esc(q.npc)}: “${esc(q.text)}”</div>
const open = openQuest === qid;
return `<div class="quest" data-act="open-quest" data-id="${qid}" style="cursor:pointer">
<div class="row"><span class="qmark">!</span><b class="grow">${esc(q.name)}</b><span class="small ${tooHigh ? "stat-down" : "muted"}">lv ${q.lv}</span>
${open ? "" : `<button class="btn small primary" data-act="accept" data-id="${qid}">Accept</button>`}</div>
${open ? `<div class="small muted" style="margin:4px 0">${esc(q.npc)}, ${esc(place(q.giver).name)}: “${esc(q.text)}”</div>
${q.obj.map((o) => `<div class="obj">· ${objText(o)}</div>`).join("")}
<div class="row" style="margin-top:6px"><span class="small muted">${q.xp} xp · ${money(q.copper)}${q.choice ? " · a reward of your choice" : ""}</span>
<button class="btn small primary right" data-act="accept" data-id="${qid}">Accept</button></div>
<button class="btn small primary right" data-act="accept" data-id="${qid}">Accept</button></div>` : ""}
</div>`;
}
@ -447,7 +463,7 @@ function objText(o, have) {
const count = have === undefined ? "" : ` ${have} / ${need}`;
if (o.kill) return `Slay ${esc(C.mobs[o.kill].name)}${count || (need > 1 ? ` ×${need}` : "")}`;
if (o.loot) return `${esc(o.loot)}${count || ` ×${need}`} <span class="muted">from ${o.from.map((m) => esc(C.mobs[m].name)).join(", ")}</span>`;
if (o.visit) return `Go to ${esc(place(o.visit).name)}${count}`;
if (o.visit) return `Reach ${esc(C.zones[o.visit].name)}${count}`;
return "";
}
@ -502,8 +518,7 @@ function questsPage() {
return `<div class="quest ${ready ? "ready" : ""}">
<div class="row"><b class="grow">${ready ? '<span class="qmark">?</span> ' : ""}${esc(q.name)}</b><span class="small muted">lv ${q.lv}</span></div>
${q.obj.map((o, i) => `<div class="obj ${me.quests[qid][i] >= (o.n || 1) ? "done" : ""}">· ${objText(o, me.quests[qid][i])}</div>`).join("")}
<div class="row" style="margin-top:6px"><span class="small muted">${ready ? "Turn in" : "Ends"} at ${esc(place(ender).name)}${q.ender_npc ? ` (${esc(q.ender_npc)})` : ` (${esc(q.npc)})`}</span>
${ready && me.place !== ender ? `<button class="btn small" data-act="travel" data-id="${ender}">Go</button>` : ""}
<div class="row" style="margin-top:6px"><span class="small muted">${ready ? "Turn in" : "Ends"} with ${esc(q.ender_npc || q.npc)}, ${esc(place(ender).name)}${zoneOf(ender) !== zoneOf(me.place) ? `, ${esc(C.zones[zoneOf(ender)].name)}` : ""}</span>
<button class="btn small danger right" data-act="abandon" data-id="${qid}">Abandon</button></div>
</div>`;
}).join("")}
@ -552,14 +567,13 @@ function characterPage() {
}).join("");
const bags = me.bags.map((it) => {
const on = selected === it.id;
const atHub = place(me.place).hub;
return `<div class="item-row ${on ? "sel" : ""}" data-act="select" data-id="${esc(it.id)}">
<div class="grow"><span class="q-${it.q}">${esc(it.name)}</span>${it.qty > 1 ? ` <span class="muted">×${it.qty}</span>` : ""}
<div class="tip">${itemLine(it)}${it.req > me.level ? ` · <span class="stat-down">requires ${it.req}</span>` : ""}</div>
${on && it.slot ? `<div class="tip">${compare(it)}</div>` : ""}
${on ? `<div class="row" style="margin-top:6px">
${it.slot ? `<button class="btn small primary" data-act="equip" data-id="${esc(it.id)}" ${it.fit ? "disabled" : ""}>${it.fit ? esc(it.fit) : "Equip"}</button>` : ""}
<button class="btn small" data-act="sell" data-id="${esc(it.id)}" ${atHub ? "" : "disabled"}>${atHub ? `Sell ${money(it.value)}` : "Sell in town"}</button></div>` : ""}
<button class="btn small" data-act="sell" data-id="${esc(it.id)}">Sell ${money(it.value)}</button></div>` : ""}
</div></div>`;
}).join("");
return `<div class="card">
@ -586,7 +600,7 @@ function groupPage() {
const g = S.group;
let mine;
if (g) {
const here = place(S.me.place);
const dungeon = zoneDungeon(zoneOf(S.me.place));
mine = `<div class="card"><h2>Your group</h2><div class="list">
${g.members.map((m) => `<div class="item-row" style="cursor:default">
<b style="color:${clsColor(m.cls)}">${esc(m.name)}</b>${m.id === g.leader ? " 👑" : ""}
@ -594,21 +608,21 @@ function groupPage() {
<div style="width:80px">${bar(m.hp, m.max_hp, "thin")}</div></div>`).join("")}
</div>
<div class="row" style="margin-top:8px">
${!g.dungeon && here.dungeon && g.leader === S.uid ? `<button class="btn primary small" data-act="enter">Enter ${esc(C.dungeons[here.dungeon].name)}</button>` : ""}
${!g.dungeon && !here.dungeon ? `<span class="small muted">Meet at Moonbrook for the Deadmines</span>` : ""}
${!g.dungeon && dungeon && g.leader === S.uid ? `<button class="btn primary small" data-act="enter">Summon &amp; enter ${esc(C.dungeons[dungeon].name)}</button>` : ""}
${!g.dungeon && !dungeon ? `<span class="small muted">The leader summons everyone from the meeting stone in the dungeon's zone</span>` : ""}
<button class="btn small danger right" data-act="leave-group">Leave group</button></div></div>
${g.dungeon && !(S.me.activity && S.me.activity.kind === "dungeon") ? "" : ""}`;
} else {
const open = S.groups.filter((x) => !x.inside && x.size < 5);
mine = `<div class="card"><h2>Group</h2>
<p class="tip">Dungeons take a group. Meet at the entrance; the leader leads you in.</p>
<p class="tip">Dungeons take a group. The leader summons everyone from the dungeon's meeting stone.</p>
<div class="list">${open.map((x) => `<div class="item-row" style="cursor:default"><span class="grow">${esc(x.leader)}'s group <span class="muted small">${x.size} / 5</span></span>
<button class="btn small" data-act="join" data-id="${x.id}">Join</button></div>`).join("")}</div>
<div class="row" style="margin-top:8px"><button class="btn primary small" data-act="create-group">Start a group</button></div></div>`;
}
const people = S.players.map((p) => `<div class="item-row" style="cursor:default">
<span>${p.online ? "🟢" : "⚪"}</span><b style="color:${clsColor(p.cls)}">${esc(p.name)}</b>
<span class="small muted grow">${p.level} ${esc(C.classes[p.cls].name)} · ${esc(place(p.place).name)}${p.doing === "hunt" ? ` · hunting ${esc(C.mobs[p.hunt].name)}` : p.doing === "dungeon" ? " · in a dungeon" : p.doing === "travel" ? " · travelling" : ""}</span></div>`).join("");
<span class="small muted grow">${p.level} ${esc(C.classes[p.cls].name)} · ${esc(place(p.place).name)}${p.doing === "hunt" ? ` · hunting ${esc(C.mobs[p.hunt].name)}` : p.doing === "dungeon" ? " · in a dungeon" : p.doing === "journey" ? " · on the road" : ""}</span></div>`).join("");
return `${mine}<div class="card"><h2>Adventurers</h2><div class="list">${people}</div></div>`;
}
@ -762,7 +776,7 @@ function renderBar() {
animate();
}
// Moves cast bars, cooldowns and travel along between polls.
// Moves cast bars, cooldowns and countdowns along between polls.
function animate() {
if (!S) return;
const t = fightTime();
@ -770,10 +784,6 @@ function animate() {
const start = Number(el.dataset.start), end = Number(el.dataset.end);
el.firstElementChild.style.width = `${Math.min(1, Math.max(0, (t - start) / (end - start))) * 100}%`;
}
for (const el of document.querySelectorAll(".bar.road[data-from]")) {
const from = Number(el.dataset.from), to = Number(el.dataset.to);
el.firstElementChild.style.width = `${Math.min(1, (now() - from) / (to - from)) * 100}%`;
}
for (const el of document.querySelectorAll("[data-until]")) {
el.textContent = clock(Number(el.dataset.until) - now());
}
@ -863,9 +873,10 @@ document.addEventListener("click", async (ev) => {
break;
case "hunt": api("hunt", { id }); break;
case "stop": api("stop"); break;
case "travel": api("travel", { id }); break;
case "travel-far": if ($("#far").value) api("travel", { id: $("#far").value }); break;
case "journey": api("journey", { id }); break;
case "fly": api("fly", { id }); break;
case "accept": api("accept", { id }); break;
case "open-quest": openQuest = openQuest === id ? null : id; render(); break;
case "abandon": if (confirm("Abandon this quest?")) api("abandon", { id }); break;
case "pick-reward": pickReward[id] = Number(el.dataset.i); render(); break;
case "turn-in": api("turn_in", { id, choice: pickReward[id] }); break;

View file

@ -85,7 +85,6 @@ main { max-width: 760px; margin: 0 auto; padding: 8px 12px; }
.bar.low > i { background: var(--hp-low); }
.bar.cast > i { background: var(--cast); transition: none; }
.bar.cast.safe > i { background: #8b8b8b; }
.bar.road > i { background: #8a6a2a; transition: none; }
/* fight */
.fight-head { display: flex; align-items: baseline; gap: 8px; }