const MODULE_ID = "sla-shift-ledger"; const STATE_KEY = "campaignState"; const CONFIG_KEY = "billingConfig"; const ACTOR_FLAG = "actorState"; const SHIFTS = [ { key: "morning", label: "Morning Shift", short: "Morning" }, { key: "afternoon", label: "Afternoon Shift", short: "Afternoon" }, { key: "evening", label: "Evening Shift", short: "Evening" }, { key: "night", label: "Night Shift", short: "Night" } ]; const DEFAULT_ACTIVITIES = [ "Investigation", "Resting", "Normal Social Time", "Transit", "Fieldwork", "Surveillance", "Paperwork", "Medical Recovery", "Training", "Downtime" ]; function defaultCampaignState() { return { day: 1000, shift: 0 }; } function defaultConfig() { return { activityOptions: [...DEFAULT_ACTIVITIES], housingLabels: [ "Squalid", "Bare Minimum", "Cheap Cubicle", "Basic", "Steady", "Comfortable", "Professional", "High Grade", "Luxury", "Executive" ], housingCosts: Array.from({ length: 10 }, (_value, index) => (index + 1) * 50), livingMultiplier: 0.5, autoChargeWeekly: true }; } function defaultActorState() { return { housingTier: 1, schedule: {}, ledger: [], lastBilledWeek: 0 }; } function getState() { return foundry.utils.mergeObject(defaultCampaignState(), game.settings.get(MODULE_ID, STATE_KEY) ?? {}); } function getConfig() { const cfg = foundry.utils.mergeObject(defaultConfig(), game.settings.get(MODULE_ID, CONFIG_KEY) ?? {}); cfg.activityOptions = normalizeActivityOptions(cfg.activityOptions); cfg.housingLabels = Array.from({ length: 10 }, (_value, index) => String(cfg.housingLabels?.[index] ?? defaultConfig().housingLabels[index]).trim() || defaultConfig().housingLabels[index]); cfg.housingCosts = Array.from({ length: 10 }, (_value, index) => Math.max(0, Number(cfg.housingCosts?.[index] ?? ((index + 1) * 50)) || 0)); cfg.livingMultiplier = Math.max(0, Number(cfg.livingMultiplier ?? 0.5) || 0); cfg.autoChargeWeekly = cfg.autoChargeWeekly !== false; return cfg; } async function setState(nextState) { await game.settings.set(MODULE_ID, STATE_KEY, foundry.utils.mergeObject(defaultCampaignState(), nextState ?? {})); } async function setConfig(nextConfig) { await game.settings.set(MODULE_ID, CONFIG_KEY, foundry.utils.mergeObject(defaultConfig(), nextConfig ?? {})); } function normalizeActivityOptions(value) { if (Array.isArray(value)) { const cleaned = value.map((entry) => String(entry ?? "").trim()).filter(Boolean); return cleaned.length ? cleaned : [...DEFAULT_ACTIVITIES]; } const cleaned = String(value ?? "") .split(/\n+/) .map((entry) => entry.trim()) .filter(Boolean); return cleaned.length ? cleaned : [...DEFAULT_ACTIVITIES]; } function getWeekFromDay(day) { const numericDay = Math.max(0, Number(day ?? 0) || 0); return Math.floor(numericDay / 7) + 1; } function getShift(index) { return SHIFTS[Math.max(0, Math.min(SHIFTS.length - 1, Number(index ?? 0) || 0))] ?? SHIFTS[0]; } function buildCampaignStamp(state = getState()) { return `Day ${state.day} / ${getShift(state.shift).label}`; } function getActorState(actor) { return foundry.utils.mergeObject(defaultActorState(), actor.getFlag(MODULE_ID, ACTOR_FLAG) ?? {}); } async function setActorState(actor, nextState) { await actor.setFlag(MODULE_ID, ACTOR_FLAG, foundry.utils.mergeObject(defaultActorState(), nextState ?? {})); } function normalizeDaySchedule(scheduleMap, day) { const next = foundry.utils.deepClone(scheduleMap ?? {}); const key = String(day); const existing = Array.isArray(next[key]) ? next[key] : []; next[key] = Array.from({ length: SHIFTS.length }, (_value, index) => String(existing[index] ?? DEFAULT_ACTIVITIES[0] ?? "Investigation")); return next; } function calculateWeeklyCosts(housingTier, config = getConfig()) { const index = Math.max(0, Math.min(9, Number(housingTier ?? 1) - 1)); const housing = Number(config.housingCosts?.[index] ?? 0) || 0; const living = Math.round(housing * (Number(config.livingMultiplier ?? 0.5) || 0) * 100) / 100; return { label: String(config.housingLabels?.[index] ?? `Tier ${index + 1}`), housing, living, total: Math.round((housing + living) * 100) / 100 }; } function getViewedDay(app) { const currentDay = Number(getState().day ?? 1000) || 1000; const stored = Number(app?._slaShiftLedgerViewedDay ?? currentDay) || currentDay; return Math.max(0, stored); } function getHtmlRoot(html) { return html instanceof HTMLElement ? html : html?.[0] ?? null; } function getCredits(actor) { return Number(actor.system?.credits?.value ?? 0) || 0; } async function applyWeeklyBilling(targetWeek = getWeekFromDay(getState().day), { force = false } = {}) { const config = getConfig(); const state = getState(); const actors = game.actors.filter((actor) => actor.type === "character"); for (const actor of actors) { const actorState = getActorState(actor); if (!force && Number(actorState.lastBilledWeek ?? 0) >= Number(targetWeek)) continue; const costs = calculateWeeklyCosts(actorState.housingTier, config); const currentCredits = getCredits(actor); const newCredits = Math.round((currentCredits - costs.total) * 100) / 100; const entry = { week: targetWeek, day: state.day, stamp: buildCampaignStamp(state), housingTier: actorState.housingTier, housingLabel: costs.label, housing: costs.housing, living: costs.living, total: costs.total, balance: newCredits }; const ledger = [entry, ...(Array.isArray(actorState.ledger) ? actorState.ledger : [])].slice(0, 24); await setActorState(actor, { ...actorState, ledger, lastBilledWeek: Number(targetWeek) }); await actor.update({ "system.credits.value": newCredits }); } } async function advanceShift(delta = 1) { const state = getState(); const previousDay = Number(state.day ?? 1000) || 1000; const previousWeek = getWeekFromDay(previousDay); let nextShift = Number(state.shift ?? 0) + Number(delta ?? 0); let nextDay = previousDay; while (nextShift < 0) { nextShift += SHIFTS.length; nextDay = Math.max(0, nextDay - 1); } while (nextShift >= SHIFTS.length) { nextShift -= SHIFTS.length; nextDay += 1; } const nextState = { day: nextDay, shift: nextShift }; await setState(nextState); const nextWeek = getWeekFromDay(nextDay); if (delta > 0 && nextWeek > previousWeek && getConfig().autoChargeWeekly) { await applyWeeklyBilling(nextWeek); } renderCampaignBanner(); ui.notifications.info(`Campaign time advanced to ${buildCampaignStamp(nextState)}.`); } class SlaCampaignClockApp extends Application { static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: "sla-campaign-clock-app", title: "SLA Campaign Clock", classes: ["sla-campaign-clock-window", "mosh"], width: 580, height: "auto", resizable: true, minimizable: true, popOut: true }); } async _renderInner(data) { const state = getState(); const weekNumber = getWeekFromDay(state.day); const currentShift = getShift(state.shift); const html = `
SLA Campaign Clock
Day ${state.day} · Week ${weekNumber}
${currentShift.label}
${SHIFTS.map((shift, index) => `
${shift.short}
`).join("")}
${game.user?.isGM ? ` ` : ""}
`; return $(html); } activateListeners(html) { super.activateListeners(html); if (game.user?.isGM) { html.find("[data-action='prev']").on("click", () => advanceShift(-1)); html.find("[data-action='next']").on("click", () => advanceShift(1)); html.find("[data-action='open']").on("click", () => SlaShiftLedgerApp.open()); } } static get activeApp() { return Object.values(ui.windows).find(app => app.constructor === SlaCampaignClockApp) ?? null; } static open() { const active = SlaCampaignClockApp.activeApp; if (active) { active.render(true); active.maximize(); return active; } const app = new SlaCampaignClockApp(); app.render(true); return app; } static toggle() { const active = SlaCampaignClockApp.activeApp; if (active) { active.close(); } else { SlaCampaignClockApp.open(); } } } function renderCampaignBanner() { const active = SlaCampaignClockApp.activeApp; if (active) { active.render(false); } } class SlaShiftLedgerApp extends FormApplication { static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: "sla-shift-ledger-app", title: "SLA Shift Ledger", template: "modules/sla-shift-ledger/templates/timekeeper.hbs", classes: ["sla-shift-ledger", "mosh", "sheet"], width: 760, height: "auto", submitOnChange: false, closeOnSubmit: false, resizable: true }); } async getData() { const state = getState(); const config = getConfig(); return { state, config, weekNumber: getWeekFromDay(state.day), currentShiftLabel: getShift(state.shift).label, activityOptionsText: config.activityOptions.join("\n"), housingLevels: config.housingCosts.map((cost, index) => ({ tier: index + 1, cost, label: config.housingLabels[index] })), shifts: SHIFTS.map((shift, index) => ({ ...shift, index, selected: index === Number(state.shift), active: index === Number(state.shift) })) }; } activateListeners(html) { super.activateListeners(html); html.find("[data-action='prev']").on("click", () => advanceShift(-1)); html.find("[data-action='next']").on("click", () => advanceShift(1)); html.find("[data-action='bill']").on("click", async () => { await applyWeeklyBilling(getWeekFromDay(getState().day), { force: true }); ui.notifications.info("Weekly billing applied to all character actors."); }); } async _updateObject(_event, formData) { const housingCosts = Array.from({ length: 10 }, (_value, index) => Math.max(0, Number(formData[`housingCost${index + 1}`] ?? 0) || 0)); const housingLabels = Array.from({ length: 10 }, (_value, index) => String(formData[`housingLabel${index + 1}`] ?? "").trim() || defaultConfig().housingLabels[index]); await setState({ day: Math.max(0, Number(formData.day ?? 1000) || 1000), shift: Math.max(0, Math.min(3, Number(formData.shift ?? 0) || 0)) }); await setConfig({ activityOptions: normalizeActivityOptions(formData.activityOptions), housingLabels, housingCosts, livingMultiplier: Math.max(0, Number(formData.livingMultiplier ?? 0.5) || 0.5), autoChargeWeekly: String(formData.autoChargeWeekly) !== "false" }); renderCampaignBanner(); ui.notifications.info("SLA Shift Ledger saved."); } static open() { return new SlaShiftLedgerApp().render(true); } } function installSceneButton(_app, html) { const root = getHtmlRoot(html); const directoryHeader = root?.querySelector(".directory-header"); if (!directoryHeader) return; let header = directoryHeader.querySelector(".header-actions, .action-buttons"); if (!header) { header = document.createElement("div"); header.className = "header-actions sla-shift-ledger-fallback-actions"; directoryHeader.appendChild(header); } if (!header.querySelector(".sla-campaign-clock-open")) { const clockBtn = document.createElement("button"); clockBtn.type = "button"; clockBtn.className = "sla-campaign-clock-open"; clockBtn.style.marginRight = "4px"; clockBtn.innerHTML = ` Campaign Clock`; clockBtn.addEventListener("click", () => SlaCampaignClockApp.toggle()); header.appendChild(clockBtn); } if (game.user?.isGM && !header.querySelector(".sla-shift-ledger-open")) { const button = document.createElement("button"); button.type = "button"; button.className = "sla-shift-ledger-open"; button.innerHTML = ` Shift Ledger`; button.addEventListener("click", () => SlaShiftLedgerApp.open()); header.appendChild(button); } } function installSceneButtonFromSidebarTab(app, html) { const root = getHtmlRoot(html); const tabName = app?.tabName ?? root?.dataset?.tab ?? ""; if (tabName !== "scenes") return; installSceneButton(app, html); } function buildLedgerHtml(actorState) { const entries = Array.isArray(actorState.ledger) ? actorState.ledger : []; if (!entries.length) { return `
No weekly charges have been logged yet.
`; } return entries.map((entry) => `
Week ${entry.week} · ${entry.stamp}
Housing Tier ${entry.housingTier}${entry.housingLabel ? ` · ${entry.housingLabel}` : ""}: -${entry.housing} cR · Living: -${entry.living} cR · Total -${entry.total} cR
Balance after charge: ${entry.balance} cR
`).join(""); } function renderActorTimePanel(app, html) { if (app.actor?.type !== "character") return; const root = getHtmlRoot(html); const notesTab = root?.querySelector(".tab[data-tab='notes']"); if (!notesTab || notesTab.querySelector(".sla-shift-ledger-actor-panel")) return; const actor = app.actor; const actorState = getActorState(actor); const state = getState(); const config = getConfig(); const viewedDay = getViewedDay(app); const schedule = normalizeDaySchedule(actorState.schedule, viewedDay); const currentDaySchedule = schedule[String(viewedDay)]; const weeklyCosts = calculateWeeklyCosts(actorState.housingTier, config); const currentShiftIndex = Number(state.shift ?? 0) || 0; const panel = document.createElement("section"); panel.className = "sla-shift-ledger-actor-panel sla-card"; panel.innerHTML = `
Shift Ledger
Track what this operative is doing across the current day and let weekly housing and living costs feed a running ledger.
Day ${viewedDay} Week ${getWeekFromDay(viewedDay)} ${getShift(state.shift).label} Current Duty: ${currentDaySchedule[currentShiftIndex] ?? config.activityOptions[0]}
Daily Schedule
Viewing Day ${viewedDay}
${SHIFTS.map((shift, index) => `
`).join("")}
Housing & Cost of Living
${weeklyCosts.label} · Housing ${weeklyCosts.housing} cR · Living ${weeklyCosts.living} cR · Total ${weeklyCosts.total} cR
${game.user?.isGM ? `` : ""}
Living Cost Ledger
${buildLedgerHtml(actorState)}
`; notesTab.prepend(panel); if (!app.isEditable) return; panel.querySelectorAll("[data-sla-day-nav]").forEach((button) => { button.addEventListener("click", () => { const dir = button.dataset.slaDayNav; if (dir === "today") { app._slaShiftLedgerViewedDay = Number(state.day ?? viewedDay) || viewedDay; } else { app._slaShiftLedgerViewedDay = Math.max(0, viewedDay + (Number(dir) || 0)); } app.render(false); }); }); panel.querySelectorAll("[data-sla-shift-index]").forEach((select) => { select.addEventListener("change", async (event) => { const shiftIndex = Number(event.currentTarget.dataset.slaShiftIndex ?? 0) || 0; const nextActorState = getActorState(actor); const nextSchedule = normalizeDaySchedule(nextActorState.schedule, viewedDay); nextSchedule[String(viewedDay)][shiftIndex] = String(event.currentTarget.value ?? config.activityOptions[0]); await setActorState(actor, { ...nextActorState, schedule: nextSchedule }); app.render(false); }); }); panel.querySelector("[data-sla-housing-tier]")?.addEventListener("change", async (event) => { const nextActorState = getActorState(actor); await setActorState(actor, { ...nextActorState, housingTier: Math.max(1, Math.min(10, Number(event.currentTarget.value ?? 1) || 1)) }); app.render(false); }); panel.querySelector("[data-sla-bill-now]")?.addEventListener("click", async () => { const nextActorState = getActorState(actor); const costs = calculateWeeklyCosts(nextActorState.housingTier, getConfig()); const stateNow = getState(); const newCredits = Math.round((getCredits(actor) - costs.total) * 100) / 100; const entry = { week: getWeekFromDay(stateNow.day), day: stateNow.day, stamp: buildCampaignStamp(stateNow), housingTier: nextActorState.housingTier, housingLabel: costs.label, housing: costs.housing, living: costs.living, total: costs.total, balance: newCredits }; await setActorState(actor, { ...nextActorState, ledger: [entry, ...(Array.isArray(nextActorState.ledger) ? nextActorState.ledger : [])].slice(0, 24), lastBilledWeek: Math.max(Number(nextActorState.lastBilledWeek ?? 0), getWeekFromDay(stateNow.day)) }); await actor.update({ "system.credits.value": newCredits }); app.render(false); }); } function renderActorFrontSummary(app, html) { if (app.actor?.type !== "character") return; const root = getHtmlRoot(html); const strip = root?.querySelector(".sla-front-strip"); if (!strip || strip.querySelector(".sla-shift-ledger-front-panel")) return; const actorState = getActorState(app.actor); const state = getState(); const config = getConfig(); const schedule = normalizeDaySchedule(actorState.schedule, state.day); const currentSchedule = schedule[String(state.day)]; const currentDuty = currentSchedule[Number(state.shift ?? 0) || 0] ?? config.activityOptions[0]; const weeklyCosts = calculateWeeklyCosts(actorState.housingTier, config); const panel = document.createElement("div"); panel.className = "sla-shift-ledger-front-panel"; panel.innerHTML = `
Day ${state.day} ${getShift(state.shift).label} Duty: ${currentDuty} Housing: Tier ${actorState.housingTier} · ${weeklyCosts.label} Weekly: ${weeklyCosts.total} cR
`; strip.appendChild(panel); } Hooks.once("init", () => { game.settings.register(MODULE_ID, STATE_KEY, { scope: "world", config: false, type: Object, default: defaultCampaignState() }); game.settings.register(MODULE_ID, CONFIG_KEY, { scope: "world", config: false, type: Object, default: defaultConfig() }); game.settings.registerMenu(MODULE_ID, "shiftLedgerMenu", { name: "SLA Shift Ledger", label: "Open Shift Ledger", hint: "Track campaign day, shift cadence, schedule options, and living costs.", icon: "fas fa-calendar-days", type: SlaShiftLedgerApp, restricted: true }); }); Hooks.once("ready", () => { game.slaShiftLedger = { open: () => SlaShiftLedgerApp.open(), getState, getConfig, advanceShift, applyWeeklyBilling, openClock: () => SlaCampaignClockApp.open() }; SlaCampaignClockApp.open(); }); Hooks.on("renderSceneDirectory", installSceneButton); Hooks.on("renderSidebarTab", installSceneButtonFromSidebarTab); Hooks.on("renderActorSheet", renderActorTimePanel); Hooks.on("renderActorSheet", renderActorFrontSummary); Hooks.on("renderMothershipActorSheet", renderActorTimePanel); Hooks.on("renderMothershipActorSheet", renderActorFrontSummary); Hooks.on("renderMothershipActorSheetDossier", renderActorTimePanel); Hooks.on("renderMothershipActorSheetDossier", renderActorFrontSummary); Hooks.on("updateSetting", (setting) => { if (setting?.namespace === MODULE_ID && [STATE_KEY, CONFIG_KEY].includes(setting?.key)) { renderCampaignBanner(); } });