Work orders: crafting orders claimed by one crafter, open materials requests, escrow, refunds
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
89fd9b3c40
commit
d03f0035eb
7 changed files with 538 additions and 8 deletions
186
static/app.js
186
static/app.js
|
|
@ -12,6 +12,10 @@ let skew = 0; // server clock minus client clock, seconds
|
|||
let page = decodeURIComponent(location.hash.slice(1)) || remember("page") || "town";
|
||||
let selected = null; // bank item
|
||||
let dollPick = null; // equipment slot shown on the character sheet
|
||||
let orderTab = "take"; // work orders: for you, all open, yours
|
||||
let draft = null; // a work order being written
|
||||
let seenOrders = null; // order ids already known, so only new ones toast
|
||||
let deliveredSeen = {}; // how much of each order had arrived
|
||||
let pickedClass = null; // registration
|
||||
const tabs = {}; // skill -> selected recipe tab
|
||||
let refreshing = false;
|
||||
|
|
@ -93,6 +97,7 @@ function accept(state) {
|
|||
S = state;
|
||||
skew = state.now - Date.now() / 1000;
|
||||
if (state.summary) showSummary(state.summary);
|
||||
orderNews(state);
|
||||
const ev = state.upcoming;
|
||||
if (ev?.at_gates && warned !== ev.id) {
|
||||
warned = ev.id;
|
||||
|
|
@ -307,6 +312,7 @@ function sidebar() {
|
|||
const me = S.me, cls = C.classes[me.cls];
|
||||
const doing = me.action && C.actions.find((a) => a.id === me.action.id);
|
||||
const raid = S.upcoming?.kind === "raid";
|
||||
const takeable = (S.orders || []).filter(canTake).length;
|
||||
let html = `
|
||||
<div class="brand"><img src="/static/icon.svg" alt="">Guildhall <small>alpha</small></div>
|
||||
<div class="me">
|
||||
|
|
@ -317,6 +323,7 @@ function sidebar() {
|
|||
<div class="navhead">City</div>
|
||||
${nav("town", "🏘️", "Town square", `${S.city.population}`, "", `Population ${S.city.population}`)}
|
||||
${nav("council", "📜", "Council & threats", "", raid ? `<span class="alert"></span>` : "", raid ? "A threat is coming" : "")}
|
||||
${nav("orders", ORDER_ICON(), "Work orders", "", takeable ? `<span class="badge">${takeable}</span>` : "", takeable ? `${takeable} for you` : "")}
|
||||
${nav("board", "💬", "Town board")}
|
||||
${nav("roster", "👥", "Guild roster", `${S.players.length}`, "", `${S.players.length} ${S.players.length === 1 ? "adventurer" : "adventurers"}`)}
|
||||
<div class="navhead">You</div>
|
||||
|
|
@ -350,7 +357,7 @@ function devbar() {
|
|||
</div></div>`;
|
||||
}
|
||||
|
||||
const TITLES = { town: "Town square", council: "Council & threats", board: "Town board", roster: "Guild roster", bank: "Bank", character: "Character" };
|
||||
const TITLES = { town: "Town square", council: "Council & threats", orders: "Work orders", board: "Town board", roster: "Guild roster", bank: "Bank", character: "Character" };
|
||||
|
||||
function topbar() {
|
||||
let title = TITLES[page] || "Guildhall";
|
||||
|
|
@ -390,7 +397,7 @@ function pageHtml() {
|
|||
}
|
||||
if (page.startsWith("character:")) return characterSheet(page.slice(10));
|
||||
if (page === "character") return characterSheet(S.uid);
|
||||
return ({ town, council, board, roster, bank }[page] || town)();
|
||||
return ({ town, council, orders, board, roster, bank }[page] || town)();
|
||||
}
|
||||
|
||||
function itemChip(id, qty, have) {
|
||||
|
|
@ -429,6 +436,11 @@ function skillPage(id) {
|
|||
html += skillGear(id);
|
||||
if (id === "reconnaissance") html += `<div class="panel small muted">Raises intel on the coming threat. Higher levels open as it nears.</div>`;
|
||||
html += skillHappenings(id);
|
||||
const wanted = (S.orders || []).filter((o) => o.skill === id && canTake(o));
|
||||
if (wanted.length) html += `<div class="banner good happening-banner"><span class="banner-ico">${ORDER_ICON()}</span>
|
||||
<div class="grow"><b>${wanted.length} work order${wanted.length > 1 ? "s" : ""}</b>
|
||||
<div class="small">${wanted.map((o) => esc(item(o.item).name)).join(" · ")}</div></div>
|
||||
<button class="btn" data-nav="orders">Work orders</button></div>`;
|
||||
const live = new Set(S.happenings.problems.map((p) => p.kind));
|
||||
const listed = C.actions.filter((a) => a.skill === id && (a.special !== "problem" || live.has(a.problem)));
|
||||
// Tabs and sections keep their designed order; what's inside each is sorted by the level it needs.
|
||||
|
|
@ -1148,6 +1160,140 @@ function raidPrep(ev) {
|
|||
</div></div>`;
|
||||
}
|
||||
|
||||
// --- work orders ------------------------------------------------------------
|
||||
|
||||
const ORDER_ICON = () => glyph("scroll-unfurled", "#e3c060");
|
||||
|
||||
// An order you could pick up: open, not yours, not claimed by someone else, and your level is high enough.
|
||||
function canTake(o) {
|
||||
if (o.status !== "open" || o.by === S.uid) return false;
|
||||
if (o.kind === "craft" && o.claimed && o.claimed !== S.uid) return false;
|
||||
return S.me.levels[o.skill] >= o.level;
|
||||
}
|
||||
|
||||
// Toasts for new orders you can take, and for things delivered to you. Nothing on the first load.
|
||||
function orderNews(state) {
|
||||
const orders = state.orders || [], uid = state.uid;
|
||||
if (seenOrders) {
|
||||
for (const o of orders) {
|
||||
if (!seenOrders.has(o.id) && o.by !== uid && state.me && state.me.levels[o.skill] >= o.level && o.status === "open") {
|
||||
toast(`📜 ${item(o.item).name}${o.qty > 1 ? ` ×${fmt(o.qty)}` : ""} · ${fmt(o.reward)} gold`, "level");
|
||||
}
|
||||
const was = deliveredSeen[o.id];
|
||||
if (o.by === uid && was !== undefined && o.delivered > was) toast(`📦 ${item(o.item).name} ×${fmt(o.delivered - was)}`, "good");
|
||||
}
|
||||
}
|
||||
seenOrders = new Set(orders.map((o) => o.id));
|
||||
deliveredSeen = Object.fromEntries(orders.map((o) => [o.id, o.delivered]));
|
||||
}
|
||||
|
||||
function orderSlots(o) {
|
||||
const a = C.actions.find((x) => x.id === o.action), out = a.outputs[o.item];
|
||||
const left = Math.ceil((o.qty - o.delivered) / out), all = Math.ceil(o.qty / out);
|
||||
return Object.entries(a.inputs).map(([id, per]) => {
|
||||
const name = item(id).name;
|
||||
if (id in o.provided) {
|
||||
return `<div class="ro-slot filled" title="${esc(`${name}: provided`)}"><span class="doll-slot">${item(id).icon}
|
||||
<span class="swap-qty">${fmt(per * all)}</span><span class="tick">✓</span></span></div>`;
|
||||
}
|
||||
const need = per * (o.status === "open" ? left : all), have = S.me.bank[id] || 0;
|
||||
return `<div class="ro-slot open" title="${esc(`${name}: crafter brings`)}"><span class="doll-slot empty-req">${item(id).icon}</span>
|
||||
<span class="ro-have ${o.by === S.uid ? "muted" : have < need ? "bad" : "good"}">${o.by === S.uid ? `×${fmt(need)}` : `${fmt(have)} / ${fmt(need)}`}</span></div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function orderCard(o) {
|
||||
const it = item(o.item), mine = o.by === S.uid, skill = skillOf(o.skill);
|
||||
const short = S.me.levels[o.skill] < o.level, have = S.me.bank[o.item] || 0, left = o.qty - o.delivered;
|
||||
const progress = o.qty > 1 || o.kind === "gather"
|
||||
? `<div class="small muted">${fmt(o.delivered)} / ${fmt(o.qty)}${o.kind === "gather" && Object.keys(o.from).length
|
||||
? ` · ${Object.entries(o.from).map(([uid, n]) => `${esc(playerName(uid))} ${fmt(n)}`).join(", ")}` : ""}</div>
|
||||
<div class="bar"><span style="width:${o.delivered / o.qty * 100}%"></span></div>` : "";
|
||||
let buttons = "", status = "";
|
||||
if (o.status !== "open") {
|
||||
status = `<span class="tag ${o.status === "done" ? "good" : ""}">${{ done: "Done", cancelled: "Cancelled", expired: "Expired" }[o.status]}</span>`;
|
||||
} else if (mine) {
|
||||
buttons = `<button class="btn danger" data-order-cancel="${o.id}">Cancel</button>`;
|
||||
if (o.claimed) status = `<span class="tag">${esc(playerName(o.claimed))}</span>`;
|
||||
} else if (o.kind === "gather") {
|
||||
buttons = `<button class="btn primary" data-order-deliver="${o.id}" ${have ? "" : "disabled"}>Deliver ${fmt(Math.min(have, left))}</button>`;
|
||||
} else if (o.claimed && o.claimed !== S.uid) {
|
||||
status = `<span class="tag">${esc(playerName(o.claimed))}</span>`;
|
||||
} else {
|
||||
const working = S.me.action?.order === o.id;
|
||||
const why = short ? `Needs ${skill.name.toLowerCase()} ${o.level}` : S.me.blocked[o.action] && !working
|
||||
&& !S.me.blocked[o.action].startsWith("Out of") ? S.me.blocked[o.action] : "";
|
||||
buttons = `${o.claimed === S.uid ? `<button class="btn" data-order-drop="${o.id}">Drop</button>` : ""}
|
||||
${have ? `<button class="btn" data-order-deliver="${o.id}">Deliver ${fmt(Math.min(have, left))}</button>` : ""}
|
||||
${working ? `<span class="tag gold">Crafting</span>` : `<button class="btn primary" data-order-work="${o.id}" ${why ? `disabled title="${esc(why)}"` : ""}>Craft it</button>`}`;
|
||||
}
|
||||
const reward = o.kind === "gather" ? `${fmt(Math.round(o.reward / o.qty))} each` : fmt(o.reward);
|
||||
return `<div class="action order">
|
||||
<div class="top"><div class="ico">${it.icon}</div><div>
|
||||
<div class="name">${o.kind === "gather" ? "Wanted: " : ""}${esc(it.name)}${o.qty > 1 ? ` ×${fmt(o.qty)}` : ""}</div>
|
||||
<div class="sub">${mine ? "Yours" : esc(playerName(o.by))} · ${ago(o.at)}${o.held ? ` · ${fmt(o.held)} waiting for bank space` : ""}</div></div>
|
||||
<span class="ro-req ${short && !mine ? "bad" : ""}" title="${esc(`${skill.name} ${o.level}`)}">${skill.icon} ${o.level}</span></div>
|
||||
${o.kind === "craft" ? `<div class="ro-slots">${orderSlots(o)}</div>` : ""}
|
||||
${progress}
|
||||
<div class="foot"><span class="item-chip ro-reward">${COIN} ${reward}</span>${status}<span class="btns">${buttons}</span></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function orderComposer() {
|
||||
const d = draft, list = d.kind === "craft" ? C.order_recipes : C.order_raw;
|
||||
const bySkill = {};
|
||||
for (const [id, action] of Object.entries(list)) {
|
||||
const a = C.actions.find((x) => x.id === action);
|
||||
(bySkill[a.skill] ||= []).push([id, a.level]);
|
||||
}
|
||||
const options = Object.entries(bySkill).map(([skill, items]) => `<optgroup label="${esc(skillOf(skill).name)}">${items
|
||||
.sort((x, y) => x[1] - y[1]).map(([id, level]) => `<option value="${id}" ${d.item === id ? "selected" : ""}>${esc(item(id).name)} (${level})</option>`).join("")}</optgroup>`).join("");
|
||||
let slots = "", maker = "";
|
||||
const a = d.item && C.actions.find((x) => x.id === list[d.item]);
|
||||
if (a) {
|
||||
const smiths = S.players.filter((p) => p.id !== S.uid && p.levels[a.skill] >= a.level).length;
|
||||
maker = `<div class="small muted" title="${esc(`${skillOf(a.skill).name} ${a.level} · ${smiths} can make it`)}">${skillOf(a.skill).icon} ${a.level} · 👥 ${smiths}</div>`;
|
||||
if (d.kind === "craft") {
|
||||
const runs = Math.ceil(d.qty / a.outputs[d.item]);
|
||||
slots = Object.entries(a.inputs).map(([id, per]) => {
|
||||
const need = per * runs, have = S.me.bank[id] || 0, on = d.provide.includes(id);
|
||||
return `<div class="ro-slot ${on ? "filled" : "open"}"><button class="doll-slot ${on ? "on" : "empty-req"}" data-provide="${id}"
|
||||
${!on && have < need ? `disabled title="${esc(`${item(id).name}: ${fmt(have)} / ${fmt(need)}`)}"` : `title="${esc(item(id).name)}"`}>
|
||||
${item(id).icon}<span class="swap-qty">${fmt(need)}</span>${on ? `<span class="tick">✓</span>` : ""}</button>
|
||||
<span class="ro-have ${have < need ? "bad" : "muted"}">${fmt(have)}</span></div>`;
|
||||
}).join("");
|
||||
}
|
||||
}
|
||||
const gold = d.kind === "craft" ? d.reward : d.reward * d.qty;
|
||||
return `<div class="panel compose">
|
||||
<div class="tabs" style="margin:0 0 14px"><button class="tab ${d.kind === "craft" ? "on" : ""}" data-draft-kind="craft">Craft</button>
|
||||
<button class="tab ${d.kind === "gather" ? "on" : ""}" data-draft-kind="gather">Materials</button></div>
|
||||
<div class="compose-grid">
|
||||
<div><select data-draft="item"><option value="">Item…</option>${options}</select>
|
||||
${a ? `<div class="ro-pick"><span class="doll-slot on">${item(d.item).icon}</span><div><b>${esc(item(d.item).name)}</b>${maker}</div></div>` : ""}
|
||||
<div class="stepper">×<input type="number" min="1" value="${d.qty}" data-draft="qty"></div></div>
|
||||
<div>${slots ? `<div class="ro-slots big">${slots}</div>` : ""}</div>
|
||||
<div><div class="reward-input">${COIN}<input type="number" min="0" value="${d.reward}" data-draft="reward">
|
||||
${d.kind === "gather" ? `<span class="muted small">each · ${fmt(gold)}</span>` : ""}</div>
|
||||
<button class="btn primary" data-order-post ${a && gold <= S.me.gold ? "" : "disabled"}>Post</button></div>
|
||||
</div></div>`;
|
||||
}
|
||||
|
||||
function orders() {
|
||||
const all = S.orders || [], open = all.filter((o) => o.status === "open");
|
||||
const takeable = open.filter(canTake), yours = all.filter((o) => o.by === S.uid);
|
||||
const lists = { take: takeable, open, yours };
|
||||
if (!lists[orderTab]) orderTab = "take";
|
||||
const shown = lists[orderTab];
|
||||
const tab = (key, label, n) => `<button class="tab ${orderTab === key ? "on" : ""}" data-otab="${key}">${label}${n ? ` <b class="badge">${n}</b>` : ""}</button>`;
|
||||
return `<div class="panel"><div class="skill-head"><div class="big">${ORDER_ICON()}</div>
|
||||
<div class="info"><h2>Work orders</h2></div>
|
||||
<button class="btn ${draft ? "" : "primary"}" data-order-new>${draft ? "Close" : "New order"}</button></div></div>
|
||||
${draft ? orderComposer() : ""}
|
||||
<div class="tabs">${tab("take", "For you", takeable.length)}${tab("open", "All open", 0)}${tab("yours", "Yours", 0)}</div>
|
||||
${shown.length ? `<div class="grid wide">${shown.map(orderCard).join("")}</div>` : `<p class="muted">None.</p>`}`;
|
||||
}
|
||||
|
||||
// --- board & roster -------------------------------------------------------
|
||||
|
||||
function board() {
|
||||
|
|
@ -1446,6 +1592,24 @@ document.addEventListener("click", (e) => {
|
|||
if (d.start) return act("/api/action", { id: d.start });
|
||||
if (d.once) return act("/api/action", { id: d.once, once: true });
|
||||
if ("stop" in d) return act("/api/stop", {});
|
||||
if ("orderNew" in d) {
|
||||
draft = draft ? null : { kind: "craft", item: "", qty: 1, provide: [], reward: 0 };
|
||||
return render();
|
||||
}
|
||||
if (d.draftKind) { draft = { kind: d.draftKind, item: "", qty: 1, provide: [], reward: 0 }; return render(); }
|
||||
if (d.provide) {
|
||||
draft.provide = draft.provide.includes(d.provide) ? draft.provide.filter((x) => x !== d.provide) : [...draft.provide, d.provide];
|
||||
return render();
|
||||
}
|
||||
if ("orderPost" in d) {
|
||||
const body = { kind: draft.kind, item: draft.item, qty: draft.qty, provide: draft.provide, reward: draft.reward };
|
||||
return api("/api/order", body).then((state) => { draft = null; orderTab = "yours"; accept(state); }).catch((err) => toast(err.message, "bad"));
|
||||
}
|
||||
if (d.otab) { orderTab = d.otab; return render(); }
|
||||
if (d.orderWork) return act("/api/order/work", { id: Number(d.orderWork) });
|
||||
if (d.orderDrop) return act("/api/order/drop", { id: Number(d.orderDrop) });
|
||||
if (d.orderDeliver) return act("/api/order/deliver", { id: Number(d.orderDeliver) });
|
||||
if (d.orderCancel) return act("/api/order/cancel", { id: Number(d.orderCancel) });
|
||||
if (d.doll) {
|
||||
dollPick = dollPick === d.doll ? null : d.doll;
|
||||
return render();
|
||||
|
|
@ -1492,6 +1656,24 @@ document.addEventListener("click", (e) => {
|
|||
});
|
||||
|
||||
document.addEventListener("change", (e) => {
|
||||
const key = e.target.dataset.draft;
|
||||
if (!key || !draft) return;
|
||||
if (key === "item") {
|
||||
const list = draft.kind === "craft" ? C.order_recipes : C.order_raw;
|
||||
draft.item = e.target.value;
|
||||
draft.provide = [];
|
||||
// A starting price: what the item's worth, each.
|
||||
draft.reward = draft.item ? item(draft.item).value * (draft.kind === "craft" ? draft.qty : 1) : 0;
|
||||
if (!list[draft.item]) draft.item = "";
|
||||
} else {
|
||||
draft[key] = Math.max(key === "qty" ? 1 : 0, Math.floor(Number(e.target.value) || 0));
|
||||
if (key === "qty" && draft.kind === "craft" && draft.item) {
|
||||
// Drop provided reagents the bank can no longer cover at the new quantity.
|
||||
const a = C.actions.find((x) => x.id === C.order_recipes[draft.item]), runs = Math.ceil(draft.qty / a.outputs[draft.item]);
|
||||
draft.provide = draft.provide.filter((id) => (S.me.bank[id] || 0) >= a.inputs[id] * runs);
|
||||
}
|
||||
}
|
||||
render();
|
||||
});
|
||||
|
||||
$("#modal").addEventListener("click", (e) => { if (e.target.id === "modal") e.target.hidden = true; });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue