chore: initial deploy — SLA Shift Ledger Foundry VTT module

This commit is contained in:
2026-05-24 22:02:45 +01:00
commit 4c8da646da
6 changed files with 1018 additions and 0 deletions
+584
View File
@@ -0,0 +1,584 @@
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)}.`);
}
function renderCampaignBanner() {
let host = document.querySelector("#sla-shift-ledger-banner");
if (!host) {
host = document.createElement("section");
host.id = "sla-shift-ledger-banner";
host.className = "sla-shift-ledger-banner";
document.body.appendChild(host);
}
const state = getState();
const weekNumber = getWeekFromDay(state.day);
const currentShift = getShift(state.shift);
host.innerHTML = `
<div class="sla-shift-ledger-banner-inner">
<div class="sla-shift-ledger-banner-meta">
<div class="sla-shift-ledger-banner-kicker">SLA Campaign Clock</div>
<div class="sla-shift-ledger-banner-title">Day ${state.day} · Week ${weekNumber}</div>
<div class="sla-shift-ledger-banner-subtitle">${currentShift.label}</div>
</div>
<div class="sla-shift-ledger-banner-main">
<div class="sla-shift-ledger-shifts">
${SHIFTS.map((shift, index) => `
<div class="sla-shift-ledger-shift ${index === Number(state.shift) ? "is-active" : ""}">
${shift.short}
</div>
`).join("")}
</div>
<div class="sla-shift-ledger-banner-controls">
${game.user?.isGM ? `
<button type="button" class="sla-shift-ledger-button" data-action="prev">Prev</button>
<button type="button" class="sla-shift-ledger-button" data-action="next">Next</button>
<button type="button" class="sla-shift-ledger-button" data-action="open">Open</button>
` : ""}
</div>
</div>
</div>
`;
if (game.user?.isGM) {
host.querySelector("[data-action='prev']")?.addEventListener("click", () => advanceShift(-1));
host.querySelector("[data-action='next']")?.addEventListener("click", () => advanceShift(1));
host.querySelector("[data-action='open']")?.addEventListener("click", () => SlaShiftLedgerApp.open());
}
}
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) {
if (!game.user?.isGM) return;
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 || header.querySelector(".sla-shift-ledger-open")) return;
const button = document.createElement("button");
button.type = "button";
button.className = "sla-shift-ledger-open";
button.innerHTML = `<i class="fas fa-calendar-days"></i> 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 `<div class="sla-shift-ledger-ledger-entry">No weekly charges have been logged yet.</div>`;
}
return entries.map((entry) => `
<div class="sla-shift-ledger-ledger-entry">
<strong>Week ${entry.week}</strong> · ${entry.stamp}<br>
Housing Tier ${entry.housingTier}${entry.housingLabel ? ` · ${entry.housingLabel}` : ""}: -${entry.housing} cR · Living: -${entry.living} cR · <strong>Total -${entry.total} cR</strong><br>
Balance after charge: ${entry.balance} cR
</div>
`).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 = `
<div class="sla-card-label">Shift Ledger</div>
<div class="sla-card-copy">Track what this operative is doing across the current day and let weekly housing and living costs feed a running ledger.</div>
<div class="sla-shift-ledger-actor-summary">
<span class="sla-shift-ledger-tag">Day ${viewedDay}</span>
<span class="sla-shift-ledger-tag">Week ${getWeekFromDay(viewedDay)}</span>
<span class="sla-shift-ledger-tag">${getShift(state.shift).label}</span>
<span class="sla-shift-ledger-tag">Current Duty: ${currentDaySchedule[currentShiftIndex] ?? config.activityOptions[0]}</span>
</div>
<div class="sla-shift-ledger-actor-card">
<div class="sla-shift-ledger-kicker">Daily Schedule</div>
<div class="sla-shift-ledger-day-nav">
<button type="button" class="sla-shift-ledger-button" data-sla-day-nav="-1">Earlier Day</button>
<button type="button" class="sla-shift-ledger-button" data-sla-day-nav="today">Current Day</button>
<button type="button" class="sla-shift-ledger-button" data-sla-day-nav="1">Later Day</button>
<span class="sla-shift-ledger-day-readout">Viewing Day ${viewedDay}</span>
</div>
<div class="sla-shift-ledger-actor-grid">
${SHIFTS.map((shift, index) => `
<div class="sla-shift-ledger-field">
<label>${shift.label}</label>
<select data-sla-shift-index="${index}" ${app.isEditable ? "" : "disabled"}>
${config.activityOptions.map((option) => `
<option value="${foundry.utils.escapeHTML(option)}" ${option === currentDaySchedule[index] ? "selected" : ""}>${foundry.utils.escapeHTML(option)}</option>
`).join("")}
</select>
</div>
`).join("")}
</div>
</div>
<div class="sla-shift-ledger-actor-card">
<div class="sla-shift-ledger-kicker">Housing & Cost of Living</div>
<div class="sla-shift-ledger-grid">
<div class="sla-shift-ledger-field">
<label>Housing Tier</label>
<select data-sla-housing-tier ${app.isEditable ? "" : "disabled"}>
${Array.from({ length: 10 }, (_value, index) => {
const tier = index + 1;
return `<option value="${tier}" ${tier === Number(actorState.housingTier ?? 1) ? "selected" : ""}>Tier ${tier} · ${config.housingLabels[index]} · ${config.housingCosts[index]} cR/week</option>`;
}).join("")}
</select>
</div>
<div class="sla-shift-ledger-field">
<label>Weekly Total</label>
<div class="sla-shift-ledger-tag">${weeklyCosts.label} · Housing ${weeklyCosts.housing} cR · Living ${weeklyCosts.living} cR · Total ${weeklyCosts.total} cR</div>
</div>
</div>
<div class="sla-shift-ledger-inline-actions">
${game.user?.isGM ? `<button type="button" class="sla-shift-ledger-button" data-sla-bill-now>Charge Now</button>` : ""}
</div>
</div>
<div class="sla-shift-ledger-actor-card">
<div class="sla-shift-ledger-kicker">Living Cost Ledger</div>
<div class="sla-shift-ledger-ledger">
${buildLedgerHtml(actorState)}
</div>
</div>
`;
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 = `
<div class="sla-shift-ledger-front-summary">
<span class="sla-shift-ledger-tag">Day ${state.day}</span>
<span class="sla-shift-ledger-tag">${getShift(state.shift).label}</span>
<span class="sla-shift-ledger-tag">Duty: ${currentDuty}</span>
<span class="sla-shift-ledger-tag">Housing: Tier ${actorState.housingTier} · ${weeklyCosts.label}</span>
<span class="sla-shift-ledger-tag">Weekly: ${weeklyCosts.total} cR</span>
</div>
`;
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
};
renderCampaignBanner();
});
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();
}
});