V1 Dialog and Dialog.confirm are deprecated in v13 and removed in v14. Migrate the three sites in macros.js (attendance grid, single-session confirm, multi-session pick) to foundry.applications.api.DialogV2, and drop the jQuery used in their callbacks in favor of querySelector + dataset/value on dialog.element. Use rejectClose: false to preserve the prior null-on-close behavior. Bump version to 0.2.0 and widen compatibility to verified 13, max 14 (min stays at 12 — DialogV2 has been available since v12). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
337 lines
11 KiB
JavaScript
337 lines
11 KiB
JavaScript
/**
|
|
* GM-facing orchestration: the End Session flow and the bulk
|
|
* snapshot push it depends on. Imported by main.js and exposed on
|
|
* `globalThis.seitimeBridge` for use from Foundry macros.
|
|
*
|
|
* Recommended macro body (Script type, no arguments):
|
|
* seitimeBridge.endSession();
|
|
*/
|
|
|
|
import { MODULE_ID } from "./constants.js";
|
|
import {
|
|
completeSession,
|
|
finalizeAttendance,
|
|
getSessionRoster,
|
|
listScheduledSessions,
|
|
pushModuleInventory,
|
|
pushSnapshot,
|
|
} from "./api.js";
|
|
|
|
function escapeHtml(s) {
|
|
return String(s ?? "").replace(
|
|
/[&<>"']/g,
|
|
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Activity check used by the attendance proposal: an actor counts as "active"
|
|
* for this session if its data was modified during the session window, or if
|
|
* a non-GM owner is currently online when End Session is run.
|
|
*/
|
|
function actorWasActive(actor, sessionStartMs) {
|
|
if (!actor) return false;
|
|
const mod = actor.system?._stats?.modifiedTime ?? actor._stats?.modifiedTime ?? 0;
|
|
if (sessionStartMs && mod >= sessionStartMs) return true;
|
|
const ownership = actor.ownership ?? {};
|
|
for (const [userId, level] of Object.entries(ownership)) {
|
|
if (userId === "default") continue;
|
|
if (level < CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER) continue;
|
|
const user = game.users.get(userId);
|
|
if (user && !user.isGM && user.active) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Build an attendance proposal from a Frappe roster payload + current
|
|
* Foundry state. Returns the same player rows with a `proposed_status`
|
|
* field set:
|
|
* - Players whose status is anything other than "Signed Up" keep it.
|
|
* - "Signed Up" players become "Present" if any of their linked
|
|
* characters' actors show activity this session, else "No Show".
|
|
*
|
|
* The proposal is a default-correct starting point for the GM-facing
|
|
* dialog; every row remains overridable.
|
|
*/
|
|
export function proposeAttendance(roster) {
|
|
const sessionStartMs = roster.session_start ? Date.parse(roster.session_start) : 0;
|
|
return (roster.players ?? []).map((p) => {
|
|
if (p.status !== "Signed Up") {
|
|
return { ...p, proposed_status: p.status };
|
|
}
|
|
const anyActive = (p.characters ?? []).some((c) =>
|
|
actorWasActive(game.actors.get(c.foundry_actor_id), sessionStartMs),
|
|
);
|
|
return { ...p, proposed_status: anyActive ? "Present" : "No Show" };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Show the attendance confirmation dialog. Resolves to the final attendance
|
|
* array `[{player, status}]` on Confirm, or null on Cancel. An empty roster
|
|
* resolves to `[]` immediately without showing a dialog.
|
|
*
|
|
* The dropdown is restricted to Present / Notice / No Show — every row must
|
|
* be finalized in this flow. The GM can still rerun End Session later if
|
|
* they want to revise (the Frappe handler accepts updates).
|
|
*/
|
|
async function showAttendanceDialog(proposal) {
|
|
if (!proposal.length) return [];
|
|
|
|
const STATUS_OPTIONS = ["Present", "Notice", "No Show"];
|
|
const rows = proposal
|
|
.map((p, i) => {
|
|
const characters =
|
|
(p.characters ?? []).map((c) => escapeHtml(c.character)).join(", ") ||
|
|
"<em style=\"color:#888\">—</em>";
|
|
const opts = STATUS_OPTIONS.map(
|
|
(s) =>
|
|
`<option value="${s}"${s === p.proposed_status ? " selected" : ""}>${s}</option>`,
|
|
).join("");
|
|
return `
|
|
<tr>
|
|
<td style="padding:0.25em;">${escapeHtml(p.label)}</td>
|
|
<td style="padding:0.25em; color:#666; font-size:0.9em;">${characters}</td>
|
|
<td style="padding:0.25em;">
|
|
<select data-player="${escapeHtml(p.player)}" data-row="${i}">${opts}</select>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
})
|
|
.join("");
|
|
|
|
const result = await foundry.applications.api.DialogV2.wait({
|
|
window: { title: "Finalize Attendance" },
|
|
content: `
|
|
<p>Confirm each player's attendance for this session.</p>
|
|
<table style="width:100%; border-collapse:collapse;">
|
|
<thead>
|
|
<tr style="border-bottom:1px solid #ccc;">
|
|
<th style="text-align:left; padding:0.25em;">Player</th>
|
|
<th style="text-align:left; padding:0.25em;">Character(s)</th>
|
|
<th style="text-align:left; padding:0.25em; width:9em;">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>${rows}</tbody>
|
|
</table>
|
|
`,
|
|
buttons: [
|
|
{ action: "cancel", label: "Cancel", callback: () => null },
|
|
{
|
|
action: "confirm",
|
|
label: "Confirm",
|
|
default: true,
|
|
callback: (_event, _button, dialog) => {
|
|
const out = [];
|
|
dialog.element.querySelectorAll("select[data-player]").forEach((select) => {
|
|
out.push({
|
|
player: select.dataset.player,
|
|
status: select.value,
|
|
});
|
|
});
|
|
return out;
|
|
},
|
|
},
|
|
],
|
|
position: { width: 560 },
|
|
rejectClose: false,
|
|
});
|
|
|
|
return result ?? null;
|
|
}
|
|
|
|
/**
|
|
* Push snapshots for every PC in the world, in parallel. Returns a summary
|
|
* with successful/failed counts and the names of any failures so the caller
|
|
* can surface them.
|
|
*/
|
|
export async function pushAllSnapshots() {
|
|
const pcs = game.actors.filter((a) => a.type === "character");
|
|
const results = await Promise.allSettled(pcs.map((a) => pushSnapshot(a)));
|
|
const failed = results
|
|
.map((r, i) => (r.status === "rejected" ? { name: pcs[i].name, error: r.reason?.message ?? String(r.reason) } : null))
|
|
.filter(Boolean);
|
|
return {
|
|
total: pcs.length,
|
|
successful: pcs.length - failed.length,
|
|
failed,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Show the appropriate dialog for the number of candidates and resolve to
|
|
* the chosen session_id, or null if the user cancels.
|
|
*/
|
|
async function pickSession(sessions) {
|
|
if (sessions.length === 1) {
|
|
const s = sessions[0];
|
|
const confirmed = await foundry.applications.api.DialogV2.confirm({
|
|
window: { title: "End Session" },
|
|
content: `
|
|
<p>End <b>${escapeHtml(s.session_title)}</b>?</p>
|
|
<p>This will mark it Completed, schedule the next session, and push snapshots for all PCs.</p>
|
|
`,
|
|
yes: { default: true },
|
|
rejectClose: false,
|
|
});
|
|
return confirmed ? s.session_id : null;
|
|
}
|
|
|
|
const options = sessions
|
|
.map(
|
|
(s) =>
|
|
`<option value="${escapeHtml(s.session_id)}">${escapeHtml(s.session_title)}${s.session_start ? ` — ${escapeHtml(s.session_start)}` : ""}</option>`,
|
|
)
|
|
.join("");
|
|
|
|
const value = await foundry.applications.api.DialogV2.wait({
|
|
window: { title: "End Session" },
|
|
content: `
|
|
<p>Multiple scheduled sessions match this Foundry world. Pick one to end:</p>
|
|
<select id="seitime-session-pick" style="width:100%; margin-bottom:0.5em;">
|
|
${options}
|
|
</select>
|
|
`,
|
|
buttons: [
|
|
{ action: "cancel", label: "Cancel", callback: () => null },
|
|
{
|
|
action: "end",
|
|
label: "End Session",
|
|
default: true,
|
|
callback: (_event, _button, dialog) =>
|
|
dialog.element.querySelector("#seitime-session-pick")?.value || null,
|
|
},
|
|
],
|
|
rejectClose: false,
|
|
});
|
|
return value ?? null;
|
|
}
|
|
|
|
function summarizeAttendance(attendance) {
|
|
const counts = { Present: 0, Notice: 0, "No Show": 0, "Signed Up": 0 };
|
|
for (const a of attendance) counts[a.status] = (counts[a.status] ?? 0) + 1;
|
|
const order = ["Present", "Notice", "No Show", "Signed Up"];
|
|
return order
|
|
.filter((k) => counts[k])
|
|
.map((k) => `${counts[k]} ${k}`)
|
|
.join(", ");
|
|
}
|
|
|
|
function buildSummaryHtml(completion, snapshotResult, moduleResult, attendance) {
|
|
const parts = [
|
|
`<h3>Session Complete</h3>`,
|
|
`<p><b>${escapeHtml(completion.completed_session)}</b> marked Completed.</p>`,
|
|
];
|
|
if (attendance?.length) {
|
|
parts.push(`<p>Attendance: ${escapeHtml(summarizeAttendance(attendance))}.</p>`);
|
|
}
|
|
if (completion.next_session) {
|
|
parts.push(
|
|
`<p>Next session scheduled: <b>${escapeHtml(completion.next_session)}</b> (#${escapeHtml(completion.next_session_number)}).</p>`,
|
|
);
|
|
}
|
|
parts.push(
|
|
`<p>Snapshots pushed: ${snapshotResult.successful} of ${snapshotResult.total}.</p>`,
|
|
);
|
|
if (snapshotResult.failed.length) {
|
|
const items = snapshotResult.failed
|
|
.map((f) => `<li>${escapeHtml(f.name)}: ${escapeHtml(f.error)}</li>`)
|
|
.join("");
|
|
parts.push(`<p style="color:#a00"><b>Failed pushes:</b></p><ul>${items}</ul>`);
|
|
}
|
|
if (moduleResult?.upserted) {
|
|
parts.push(
|
|
`<p>Tracked ${moduleResult.upserted} active module(s) for credits.</p>`,
|
|
);
|
|
} else if (moduleResult?.error) {
|
|
parts.push(
|
|
`<p style="color:#a00">Module inventory push failed: ${escapeHtml(moduleResult.error)}</p>`,
|
|
);
|
|
}
|
|
return parts.join("");
|
|
}
|
|
|
|
/**
|
|
* Full end-of-session flow. Invoked from a Foundry macro by the GM.
|
|
*/
|
|
export async function endSession() {
|
|
if (!game.user.isGM) {
|
|
ui.notifications.warn("End Session is GM-only.");
|
|
return;
|
|
}
|
|
|
|
let sessions;
|
|
try {
|
|
sessions = await listScheduledSessions();
|
|
} catch (err) {
|
|
console.error(`[${MODULE_ID}]`, err);
|
|
ui.notifications.error(`Could not fetch scheduled sessions: ${err.message}`);
|
|
return;
|
|
}
|
|
|
|
if (!sessions.length) {
|
|
ui.notifications.warn(
|
|
"No scheduled sessions found for this Foundry world. Check that the world ID matches a Game in Frappe.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
const sessionId = await pickSession(sessions);
|
|
if (!sessionId) return;
|
|
|
|
let roster;
|
|
try {
|
|
roster = await getSessionRoster(sessionId);
|
|
} catch (err) {
|
|
console.error(`[${MODULE_ID}]`, err);
|
|
ui.notifications.error(`Could not fetch session roster: ${err.message}`);
|
|
return;
|
|
}
|
|
|
|
const proposal = proposeAttendance(roster);
|
|
const attendance = await showAttendanceDialog(proposal);
|
|
if (attendance === null) return;
|
|
|
|
// Finalize attendance BEFORE marking the session Completed so that a
|
|
// failure here leaves the session Scheduled and re-runnable, rather
|
|
// than Completed with no attendance recorded.
|
|
try {
|
|
await finalizeAttendance(sessionId, attendance);
|
|
} catch (err) {
|
|
console.error(`[${MODULE_ID}]`, err);
|
|
ui.notifications.error(`Could not finalize attendance: ${err.message}`);
|
|
return;
|
|
}
|
|
|
|
let completion;
|
|
try {
|
|
completion = await completeSession(sessionId);
|
|
} catch (err) {
|
|
console.error(`[${MODULE_ID}]`, err);
|
|
ui.notifications.error(`Could not complete session: ${err.message}`);
|
|
return;
|
|
}
|
|
|
|
const snapshotResult = await pushAllSnapshots();
|
|
|
|
// Module inventory is non-critical — log and continue if the push fails
|
|
// so the session still gets marked Completed with a clean summary.
|
|
let moduleResult = null;
|
|
try {
|
|
const raw = await pushModuleInventory();
|
|
moduleResult = raw?.message ?? null;
|
|
} catch (err) {
|
|
console.warn(`[${MODULE_ID}] module inventory push failed:`, err);
|
|
moduleResult = { error: err.message };
|
|
}
|
|
|
|
await ChatMessage.create({
|
|
content: buildSummaryHtml(completion, snapshotResult, moduleResult, attendance),
|
|
speaker: { alias: "Seitime Bridge" },
|
|
whisper: ChatMessage.getWhisperRecipients("GM").map((u) => u.id),
|
|
});
|
|
|
|
const failureNote = snapshotResult.failed.length ? ` (${snapshotResult.failed.length} snapshot push(es) failed — see chat)` : "";
|
|
ui.notifications.info(`Session ended. ${snapshotResult.successful}/${snapshotResult.total} snapshots pushed${failureNote}.`);
|
|
}
|