Attendance dialog in End Session flow
endSession() now pulls the session roster from Frappe, computes a default-correct attendance proposal from Foundry actor activity, and presents it as a dropdown grid the GM can override. Activity = actor.system._stats.modifiedTime since session_start, OR a non-GM owner currently online. Notice rows are preserved as-is so the player's stated intent is respected; the GM can still flip them manually in the dialog. finalize_attendance runs BEFORE complete_session so a failure there leaves the session Scheduled and re-runnable rather than Completed- with-no-attendance. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
ed853de6a2
commit
0df05e0d0b
2 changed files with 174 additions and 2 deletions
|
|
@ -147,6 +147,21 @@ export async function completeSession(sessionId) {
|
|||
return result?.message ?? {};
|
||||
}
|
||||
|
||||
export async function getSessionRoster(sessionId) {
|
||||
if (!sessionId) throw new Error("getSessionRoster requires a session_id.");
|
||||
const result = await bridgeFetch("get_session_roster", { session_id: sessionId });
|
||||
return result?.message ?? {};
|
||||
}
|
||||
|
||||
export async function finalizeAttendance(sessionId, attendance) {
|
||||
if (!sessionId) throw new Error("finalizeAttendance requires a session_id.");
|
||||
const result = await bridgeFetch("finalize_attendance", {
|
||||
session_id: sessionId,
|
||||
attendance: attendance ?? [],
|
||||
});
|
||||
return result?.message ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Round-trip an empty manifest push to verify URL, secret, world ID, and
|
||||
* Frappe-side enablement. Surfaces the result in the Foundry UI.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@
|
|||
import { MODULE_ID } from "./constants.js";
|
||||
import {
|
||||
completeSession,
|
||||
finalizeAttendance,
|
||||
getSessionRoster,
|
||||
listScheduledSessions,
|
||||
pushModuleInventory,
|
||||
pushSnapshot,
|
||||
|
|
@ -22,6 +24,124 @@ function escapeHtml(s) {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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("");
|
||||
|
||||
return new Promise((resolve) => {
|
||||
new Dialog(
|
||||
{
|
||||
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: {
|
||||
cancel: { label: "Cancel", callback: () => resolve(null) },
|
||||
confirm: {
|
||||
label: "Confirm",
|
||||
callback: (html) => {
|
||||
const result = [];
|
||||
html.find("select[data-player]").each(function () {
|
||||
result.push({
|
||||
player: $(this).data("player"),
|
||||
status: $(this).val(),
|
||||
});
|
||||
});
|
||||
resolve(result);
|
||||
},
|
||||
},
|
||||
},
|
||||
default: "confirm",
|
||||
close: () => resolve(null),
|
||||
},
|
||||
{ width: 560 },
|
||||
).render(true);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
|
@ -92,11 +212,24 @@ async function pickSession(sessions) {
|
|||
});
|
||||
}
|
||||
|
||||
function buildSummaryHtml(completion, snapshotResult, moduleResult) {
|
||||
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>`,
|
||||
|
|
@ -151,6 +284,30 @@ export async function endSession() {
|
|||
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);
|
||||
|
|
@ -174,7 +331,7 @@ export async function endSession() {
|
|||
}
|
||||
|
||||
await ChatMessage.create({
|
||||
content: buildSummaryHtml(completion, snapshotResult, moduleResult),
|
||||
content: buildSummaryHtml(completion, snapshotResult, moduleResult, attendance),
|
||||
speaker: { alias: "Seitime Bridge" },
|
||||
whisper: ChatMessage.getWhisperRecipients("GM").map((u) => u.id),
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue