(() => {
const data = window.VALLEY_DATA;
const sync = window.ValleySync;
const availability = window.ValleyAvailability;
const calendar = window.ValleyCalendar;
const weekdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
const seasonNames = { spring: "Spring", summer: "Summer", fall: "Fall", winter: "Winter" };
const state = {
year: Number(localStorage.getItem("valley-year")) || 1,
season: localStorage.getItem("valley-season") || "spring",
day: Number(localStorage.getItem("valley-day")) || 1,
filter: "all",
checks: {},
revision: -1,
saving: new Set()
};
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
const escapeHtml = (value) => String(value).replace(/[&<>'"]/g, char => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ }[char]));
const itemKey = crop => crop.sharedId || crop.id;
function saveDate() {
localStorage.setItem("valley-year", state.year);
localStorage.setItem("valley-season", state.season);
localStorage.setItem("valley-day", state.day);
}
function weekday(day = state.day) { return weekdays[(day - 1) % 7]; }
function setDate({ year = state.year, season = state.season, day = state.day }) {
state.year = Math.max(1, Math.min(2, year));
state.season = data.seasons.includes(season) ? season : "spring";
state.day = Math.max(1, Math.min(28, day));
saveDate();
renderDate();
renderToday();
renderCrops();
renderBundles();
}
function moveDay(delta) {
let index = data.seasons.indexOf(state.season);
let day = state.day + delta;
let year = state.year;
if (day > 28) { day = 1; index += 1; }
if (day < 1) { day = 28; index -= 1; }
if (index > 3) { index = 0; year += 1; }
if (index < 0) { index = 3; year -= 1; }
if (year < 1 || year > 2) return;
setDate({ year, season: data.seasons[index], day });
}
function renderDate() {
document.body.dataset.season = state.season;
$("#season-title").textContent = seasonNames[state.season];
$("#day-title").textContent = state.day;
$("#weekday-title").textContent = `${weekday()} · Year ${state.year}`;
$$("[data-year]").forEach(button => button.setAttribute("aria-pressed", String(Number(button.dataset.year) === state.year)));
$$("[data-season]").forEach(button => button.setAttribute("aria-selected", String(button.dataset.season === state.season)));
$("#day-grid").innerHTML = Array.from({ length: 28 }, (_, index) => {
const day = index + 1;
const birthday = birthdayOn(day);
const label = `${seasonNames[state.season]} ${day}, ${weekday(day)}${birthday ? `, ${birthday}'s birthday` : ""}`;
const marker = birthday ? `🎂 ` : "";
return `${weekdays[index % 7].slice(0, 2)} ${day} ${marker} `;
}).join("");
}
function birthdayOn(day) {
return calendar.birthdayOn(data.birthdays, state.season, day, state.year);
}
function currentBirthday() {
return birthdayOn(state.day);
}
function currentEvent() {
const entry = data.events[state.season][state.day];
if (!entry) return null;
if (entry[2] && entry[2] !== state.year) return null;
return entry;
}
function reminders() {
const name = weekday();
const result = [];
if (name === "Friday" || name === "Sunday") result.push(["Traveling Cart", "Cindersap Forest, 6am–8pm. Check for off-season bundle items.", "cart"]);
if (name === "Sunday") result.push(["The Queen of Sauce", "Watch TV for a new recipe during Years 1–2.", "tv"]);
if (name === "Wednesday") result.push(["Recipe rerun", "The Queen of Sauce repeats a recipe you may have missed.", "tv"]);
if (name === "Monday" && (state.year > 1 || state.season !== "spring" || state.day >= 2)) result.push(["New week", "Special Order boards refresh on Monday once unlocked.", "board"]);
return result;
}
function cropDeadlineCards() {
return data.crops.filter(crop => crop.season === state.season && crop.last === state.day && (!crop.year || crop.year === state.year));
}
function dailyId(kind, title) {
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 36);
return `daily-${state.year}-${state.season}-${state.day}-${kind}-${slug}`;
}
function giftGuide(name) {
const profile = data.giftProfiles[name];
if (!profile) return "";
const giftList = entries => entries.map(entry => `
${escapeHtml(entry)} `).join("");
return `
♥ Loves${giftList(profile.loves)}
× Avoid${giftList(profile.avoid)}
`;
}
function card(kind, label, title, copy, itemId = null, extra = "") {
const checked = itemId ? Boolean(state.checks[itemId]) : false;
const content = `${itemId ? ` ` : ""} ${escapeHtml(label)}
${escapeHtml(title)} ${escapeHtml(copy)}
${extra}`;
return itemId
? `${content} `
: `${content} `;
}
function renderToday() {
const cards = [];
const birthday = currentBirthday();
const event = currentEvent();
if (birthday) cards.push(card("birthday", "Birthday", birthday, "A birthday gift gives 8× the usual friendship effect.", dailyId("birthday", birthday), giftGuide(birthday)));
if (event) cards.push(card("event", "Event", event[0], event[1], dailyId("event", event[0])));
reminders().forEach(([title, copy]) => cards.push(card("reminder", "Reminder", title, copy, dailyId("reminder", title))));
cropDeadlineCards().forEach(crop => cards.push(card("deadline", "Plant by today", crop.name, crop.purpose, itemKey(crop))));
if (!cards.length) cards.push(card("quiet", "Open day", "Make your own luck", "No fixed birthdays, festivals, or last planting dates today."));
$("#today-cards").innerHTML = cards.join("");
const noun = cards.length === 1 ? "thing" : "things";
$("#today-summary").textContent = `${cards.length} ${noun} on the calendar · tick cards when done`;
}
function renderCrops() {
const crops = data.crops.filter(crop => crop.season === state.season && (!crop.year || crop.year === state.year));
const rows = crops.map(crop => {
const key = itemKey(crop);
const checked = Boolean(state.checks[key]);
const overdue = state.day > crop.last && !checked;
const today = state.day === crop.last && !checked;
return `
${escapeHtml(crop.name)} ${escapeHtml(crop.purpose)}
${crop.grow} daysto grow
${overdue ? "Passed" : `${seasonNames[crop.season]} ${crop.last}`}${overdue ? "for this season" : "plant by"}
`;
});
$("#crop-list").innerHTML = rows.length ? rows.join("") : `❄ No mandatory field crops Use Winter for the foraging bundle, mines, fishing, and any unfinished animal or artisan goods.
`;
}
function groupProgress(group) {
const done = group.items.filter(entry => state.checks[entry.id]).length;
return { done, required: group.required, complete: done >= group.required };
}
function bundleTotals() {
return data.bundles.reduce((totals, room) => room.groups.reduce((inner, group) => {
const { done, required } = groupProgress(group);
inner.done += Math.min(done, required);
inner.required += required;
return inner;
}, totals), { done: 0, required: 0 });
}
function entryStatus(entry, group) {
const progress = groupProgress(group);
return availability.classify(entry, {
checked: Boolean(state.checks[entry.id]),
chooseGroupComplete: Boolean(group.choose && progress.complete),
season: state.season
});
}
function allEntryStatuses() {
return data.bundles.flatMap(room => room.groups.flatMap(group =>
group.items.map(entry => ({ entry, group, status: entryStatus(entry, group) }))
));
}
function renderPrioritySummary() {
const entries = allEntryStatuses();
const count = filter => entries.filter(({ status }) => availability.matchesFilter(status, filter)).length;
$("#needed-now-count").textContent = count("now");
$("#exclusive-count").textContent = count("exclusive");
$("#weather-count").textContent = count("weather");
$("#needed-now-detail").textContent = `Available in ${seasonNames[state.season]}`;
$("#exclusive-detail").textContent = `Only in ${seasonNames[state.season]}`;
}
function seasonList(seasons) {
return seasons.map(season => seasonNames[season]).join(" + ");
}
function statusBadges(status) {
if (status.checked) return "";
const badges = [];
if (!status.needed) badges.push(`Bundle ready `);
else if (status.seasonExclusive) badges.push(`Only ${seasonNames[state.season]} `);
else if (status.inSeason) badges.push(`In season `);
else if (status.seasonal) badges.push(`${seasonList(status.seasons)} `);
if (status.weather) badges.push(`${status.weather === "Rain" ? "Rain needed" : "Sunny only"} `);
return badges.length ? `${badges.join("")} ` : "";
}
function renderBundles() {
const html = data.bundles.map(room => {
const groups = room.groups.map(group => {
const progress = groupProgress(group);
let items = group.items;
if (state.filter !== "all") items = items.filter(entry => availability.matchesFilter(entryStatus(entry, group), state.filter));
if (!items.length) return "";
return `
${items.map(entry => {
const checked = Boolean(state.checks[entry.id]);
const status = entryStatus(entry, group);
const classes = [checked ? "complete" : "", status.needed ? "needed" : "optional", status.needed && status.availableNow ? "available-now" : "", status.seasonExclusive ? "season-exclusive" : "", status.weather ? "weather-specific" : ""].filter(Boolean).join(" ");
return `${escapeHtml(entry.label)} ${escapeHtml(entry.meta)} ${statusBadges(status)} `;
}).join("")}
`;
}).filter(Boolean).join("");
if (!groups) return "";
const roomGroups = room.groups.map(groupProgress);
const roomDone = roomGroups.filter(progress => progress.complete).length;
return `${room.icon} ${escapeHtml(room.room)} ${roomDone} of ${room.groups.length} bundles ready
${groups}
`;
}).filter(Boolean).join("");
const emptyMessages = {
now: ["Nothing needed right now", `No unfinished bundle items are available from normal sources in ${seasonNames[state.season]}.`],
exclusive: ["No season-only items left", `You have covered every unfinished item exclusive to ${seasonNames[state.season]}.`],
weather: ["No weather windows left", `No unfinished fish in ${seasonNames[state.season]} require rain or sun.`],
open: ["Community Center covered", "No required bundle items remain."],
all: ["Everything in this view is done", "Switch filters to review the whole Community Center."]
};
const empty = emptyMessages[state.filter] || emptyMessages.all;
$("#bundle-list").innerHTML = html || ``;
renderPrioritySummary();
const totals = bundleTotals();
const percent = totals.required ? Math.round(totals.done / totals.required * 100) : 0;
$("#progress-label").textContent = `${totals.done} of ${totals.required} essentials`;
$("#progress-bar").style.width = `${percent}%`;
}
function setSyncStatus(mode, text) {
const node = $("#sync-status");
node.dataset.mode = mode;
node.lastElementChild.textContent = text;
}
function toast(message) {
const node = $("#toast");
node.textContent = message;
node.classList.add("show");
clearTimeout(toast.timer);
toast.timer = setTimeout(() => node.classList.remove("show"), 2600);
}
async function loadState({ quiet = false } = {}) {
if (!quiet) setSyncStatus("loading", "Connecting…");
try {
const response = await fetch("/api/state", { cache: "no-store" });
if (!response.ok) throw new Error(`State request failed (${response.status})`);
const snapshot = await response.json();
const merged = sync.mergeSnapshot(state.checks, state.saving, state.revision, snapshot);
if (merged.applied) {
state.checks = merged.checks;
state.revision = merged.revision;
renderToday();
renderCrops();
renderBundles();
}
setSyncStatus("ready", "Shared progress synced");
} catch (error) {
console.error(error);
setSyncStatus("error", "Sync unavailable");
}
}
async function updateCheck(itemId, checked) {
const previous = Boolean(state.checks[itemId]);
state.checks[itemId] = checked;
state.saving.add(itemId);
renderToday();
renderCrops();
renderBundles();
setSyncStatus("loading", "Saving…");
try {
const response = await fetch("/api/checks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ itemId, checked })
});
if (!response.ok) throw new Error(`Save failed (${response.status})`);
const result = await response.json();
const confirmed = sync.confirmWrite(state.checks, state.revision, result);
state.checks = confirmed.checks;
state.revision = confirmed.revision;
setSyncStatus("ready", "Shared progress synced");
} catch (error) {
console.error(error);
state.checks[itemId] = previous;
setSyncStatus("error", "Couldn’t save");
toast("That change wasn’t saved. Your previous shared state is restored.");
void loadState({ quiet: true });
} finally {
state.saving.delete(itemId);
renderToday();
renderCrops();
renderBundles();
}
}
function installWebMcp() {
const context = document.modelContext;
if (!context?.registerTool) return;
const knownIds = new Set(data.bundles.flatMap(room => room.groups.flatMap(group => group.items.map(entry => entry.id))));
void Promise.resolve(context.registerTool({
name: "get_valley_progress",
title: "Get Valley progress",
description: "Read the currently selected game date and shared Community Center checklist progress.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
annotations: { readOnlyHint: true, untrustedContentHint: false },
execute: () => ({ year: state.year, season: state.season, day: state.day, progress: bundleTotals(), completedItemIds: Object.keys(state.checks).filter(id => state.checks[id]) })
})).catch(console.error);
void Promise.resolve(context.registerTool({
name: "set_valley_checklist_item",
title: "Update Valley checklist",
description: "Check or uncheck one known Community Center item in the shared household plan.",
inputSchema: { type: "object", properties: { itemId: { type: "string" }, checked: { type: "boolean" } }, required: ["itemId", "checked"], additionalProperties: false },
annotations: { readOnlyHint: false, untrustedContentHint: false },
async execute(input) {
if (!input || !knownIds.has(input.itemId) || typeof input.checked !== "boolean") throw new Error("Unknown checklist item or invalid checked value");
await updateCheck(input.itemId, input.checked);
return { itemId: input.itemId, checked: Boolean(state.checks[input.itemId]), revision: state.revision };
}
})).catch(console.error);
}
document.addEventListener("click", event => {
const yearButton = event.target.closest("[data-year]");
if (yearButton) setDate({ year: Number(yearButton.dataset.year) });
const seasonButton = event.target.closest("[data-season]");
if (seasonButton) setDate({ season: seasonButton.dataset.season });
const dayButton = event.target.closest("[data-day]");
if (dayButton) setDate({ day: Number(dayButton.dataset.day) });
const filterButton = event.target.closest("[data-filter]");
if (filterButton) {
state.filter = filterButton.dataset.filter;
$$("[data-filter]").forEach(button => button.setAttribute("aria-pressed", String(button.dataset.filter === state.filter)));
renderBundles();
}
const navLink = event.target.closest(".section-nav a");
if (navLink) {
$$(".section-nav a").forEach(link => link.classList.toggle("active", link === navLink));
}
});
document.addEventListener("change", event => {
const input = event.target.closest("[data-check-id]");
if (input) updateCheck(input.dataset.checkId, input.checked);
});
$("#previous-day").addEventListener("click", () => moveDay(-1));
$("#next-day").addEventListener("click", () => moveDay(1));
renderDate();
renderToday();
renderCrops();
renderBundles();
loadState();
setInterval(() => loadState({ quiet: true }), 4000);
installWebMcp();
})();