(() => { 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: {}, plantings: {}, revision: -1, saving: new Set(), savingPlantings: 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; const cropFor = key => data.crops.find(crop => itemKey(crop) === key); // Plantings are stored apart from checks so "on the way" never counts toward // bundle completion. Crops planted before 1.5 have no recorded day. const plantingFor = key => (!state.checks[key] && state.plantings[key]) || null; const selectedDate = () => ({ year: state.year, season: state.season, day: state.day }); const formatDate = date => `${seasonNames[date.season]} ${date.day}${date.year !== state.year ? ` (Y${date.year})` : ""}`; function harvestInfo(key, grow) { const planted = plantingFor(key); if (!planted?.day) return null; const date = calendar.harvestDate(planted, grow); const survives = date.season === planted.season || data.crops.some(crop => itemKey(crop) === key && crop.season === date.season); return { planted, date, survives, daysLeft: calendar.daysBetween(selectedDate(), date) }; } 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 ``; }).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
    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}
    `; } 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)))); const harvestKeys = new Set(); data.crops.forEach(crop => { const key = itemKey(crop); const harvest = harvestKeys.has(key) ? null : harvestInfo(key, crop.grow); harvestKeys.add(key); if (harvest?.survives && harvest.daysLeft === 0) cards.push(card("harvest", "Harvest today", crop.name, `Planted ${formatDate(harvest.planted)} · ${crop.purpose}`)); }); cropDeadlineCards().forEach(crop => { const planted = Boolean(plantingFor(itemKey(crop))); cards.push(card(planted ? "deadline planted" : "deadline", planted ? "Planted · on the way" : "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 planted = Boolean(plantingFor(key)); const harvest = harvestInfo(key, crop.grow); const overdue = state.day > crop.last && !checked && !planted; const today = state.day === crop.last && !checked && !planted; const plantButton = checked ? "" : ``; let growTime = `${crop.grow} daysto grow`; let date = `${overdue ? "Passed" : `${seasonNames[crop.season]} ${crop.last}`}${overdue ? "for this season" : "plant by"}`; let dateClass = today ? "today" : ""; if (planted && !harvest) date = `On the wayplanting day unknown`; else if (harvest && !harvest.survives) { date = `Won’t ripenseason ends first`; dateClass = "wilts"; } else if (harvest) { const ready = harvest.daysLeft <= 0; growTime = ready ? `Readyto harvest` : `${harvest.daysLeft} ${harvest.daysLeft === 1 ? "day" : "days"}to harvest`; date = `${harvest.daysLeft === 0 ? "Today" : ready ? "Ready" : formatDate(harvest.date)}${harvest.daysLeft < 0 ? `since ${formatDate(harvest.date)}` : "harvest"}`; if (ready) dateClass = "ready"; } const purpose = harvest ? `Planted ${formatDate(harvest.planted)} · ${crop.purpose}` : crop.purpose; return `
    ${escapeHtml(crop.name)}${escapeHtml(purpose)} ${plantButton} ${growTime} ${date}
    `; }); $("#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, entry) { if (status.checked) return ""; const badges = []; if (plantingFor(entry.id)) { const crop = cropFor(entry.id); const harvest = crop ? harvestInfo(entry.id, crop.grow) : null; const label = !harvest?.survives ? "Planted" : harvest.daysLeft <= 0 ? "Ready to harvest" : `Ready ${formatDate(harvest.date)}`; badges.push(`🌱 ${label}`); } 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 `

    ${escapeHtml(group.name)}

    ${group.choose ? `Choose ${group.required}` : ""}
    ${Math.min(progress.done, progress.required)}/${progress.required}
    ${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" : "", plantingFor(entry.id) ? "planted" : ""].filter(Boolean).join(" "); return ``; }).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 || `
    ✓

    ${empty[0]}

    ${empty[1]}

    `; 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, state.plantings, state.savingPlantings); if (merged.applied) { state.checks = merged.checks; state.plantings = merged.plantings; 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(); } } async function updatePlanting(itemId, planted) { const previous = state.plantings[itemId]; if (planted) state.plantings[itemId] = planted; else delete state.plantings[itemId]; state.savingPlantings.add(itemId); renderToday(); renderCrops(); renderBundles(); setSyncStatus("loading", "Saving…"); try { const response = await fetch("/api/plantings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ itemId, planted }) }); if (!response.ok) throw new Error(`Save failed (${response.status})`); const result = await response.json(); const confirmed = sync.confirmPlanting(state.plantings, state.revision, result); state.plantings = confirmed.plantings; state.revision = confirmed.revision; setSyncStatus("ready", "Shared progress synced"); } catch (error) { console.error(error); if (previous) state.plantings[itemId] = previous; else delete state.plantings[itemId]; setSyncStatus("error", "Couldn’t save"); toast("That change wasn’t saved. Your previous shared state is restored."); void loadState({ quiet: true }); } finally { state.savingPlantings.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]), plantings: state.plantings }) })).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("button[data-year]"); if (yearButton) setDate({ year: Number(yearButton.dataset.year) }); const seasonButton = event.target.closest("button[data-season]"); if (seasonButton) setDate({ season: seasonButton.dataset.season }); const dayButton = event.target.closest("button[data-day]"); if (dayButton) setDate({ day: Number(dayButton.dataset.day) }); const filterButton = event.target.closest("button[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 plantButton = event.target.closest("button[data-plant-id]"); if (plantButton && !plantButton.disabled) updatePlanting(plantButton.dataset.plantId, plantingFor(plantButton.dataset.plantId) ? null : selectedDate()); 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(); })();