From 83a9cb6dcc0755a60536e4932a49c122dcfb737b Mon Sep 17 00:00:00 2001 From: rope Date: Tue, 6 Oct 2026 10:07:21 +0100 Subject: [PATCH] Record planting day and show harvest dates Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + app.py | 93 +++++++++++++++++++++++++++++++--- package.nix | 2 +- static/app.js | 111 ++++++++++++++++++++++++++++++++++------- static/calendar.js | 33 +++++++++--- static/index.html | 12 ++--- static/styles.css | 3 ++ static/sync.js | 18 +++++-- tests/test_app.py | 28 +++++++++++ tests/test_calendar.js | 10 ++++ tests/test_sync.js | 15 ++++++ 11 files changed, 282 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index d02fe15..323322e 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ A small, shared tracker for the first two years of Stardew Valley. It combines: - the standard Community Center bundles; - crop growth times and last safe planting dates; +- planted crops with their planting day and expected harvest date; - birthday previews and gift tips, festivals, seasonal events, and weekly reminders; and - one server-side checklist that stays in sync across phones and computers. diff --git a/app.py b/app.py index 5f9095d..1805b0b 100644 --- a/app.py +++ b/app.py @@ -21,12 +21,25 @@ APP_DIR = Path(__file__).resolve().parent STATIC_DIR = APP_DIR / "static" ITEM_ID = re.compile(r"^[a-z0-9][a-z0-9-]{0,79}$") MAX_BODY = 2048 +SEASONS = ("spring", "summer", "fall", "winter") def utc_now() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds") +def valid_game_date(value: object) -> bool: + def whole(number: object, low: int, high: int) -> bool: + return isinstance(number, int) and not isinstance(number, bool) and low <= number <= high + + return ( + isinstance(value, dict) + and whole(value.get("year"), 1, 2) + and value.get("season") in SEASONS + and whole(value.get("day"), 1, 28) + ) + + class Store: def __init__(self, path: str | Path): self.path = str(path) @@ -53,8 +66,32 @@ class Store: value TEXT NOT NULL ); INSERT OR IGNORE INTO metadata (key, value) VALUES ('revision', '0'); + -- A NULL date means the crop was planted before dates were recorded. + CREATE TABLE IF NOT EXISTS plantings ( + item_id TEXT PRIMARY KEY, + year INTEGER, + season TEXT, + day INTEGER, + updated_at TEXT NOT NULL, + updated_by TEXT NOT NULL + ); """ ) + # Version 1.4 stored planting as an undated "planted-" check. + db.execute( + """ + INSERT OR IGNORE INTO plantings (item_id, updated_at, updated_by) + SELECT substr(item_id, 9), updated_at, updated_by FROM checks + WHERE item_id LIKE 'planted-%' AND checked = 1 + """ + ) + if db.execute("DELETE FROM checks WHERE item_id LIKE 'planted-%'").rowcount: + self._bump_revision(db) + + @staticmethod + def _bump_revision(db: sqlite3.Connection) -> int: + db.execute("UPDATE metadata SET value = CAST(value AS INTEGER) + 1 WHERE key = 'revision'") + return int(db.execute("SELECT value FROM metadata WHERE key = 'revision'").fetchone()[0]) def snapshot(self) -> dict: with closing(self.connect()) as db, db: @@ -67,11 +104,47 @@ class Store: latest_row = db.execute( "SELECT updated_at, updated_by FROM checks ORDER BY updated_at DESC, rowid DESC LIMIT 1" ).fetchone() + plantings = db.execute( + "SELECT item_id, year, season, day FROM plantings ORDER BY item_id" + ).fetchall() return { "revision": revision, "updatedAt": latest_row["updated_at"] if latest_row else None, "updatedBy": latest_row["updated_by"] if latest_row else None, "checks": {row["item_id"]: bool(row["checked"]) for row in rows}, + "plantings": { + row["item_id"]: {"year": row["year"], "season": row["season"], "day": row["day"]} + for row in plantings + }, + } + + def set_planting(self, item_id: str, planted: dict | None, user: str) -> dict: + changed_at = utc_now() + with closing(self.connect()) as db, db: + db.execute("BEGIN IMMEDIATE") + if planted is None: + db.execute("DELETE FROM plantings WHERE item_id = ?", (item_id,)) + else: + db.execute( + """ + INSERT INTO plantings (item_id, year, season, day, updated_at, updated_by) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(item_id) DO UPDATE SET + year = excluded.year, + season = excluded.season, + day = excluded.day, + updated_at = excluded.updated_at, + updated_by = excluded.updated_by + """, + (item_id, planted["year"], planted["season"], planted["day"], changed_at, user), + ) + revision = self._bump_revision(db) + return { + "itemId": item_id, + "planted": planted, + "revision": revision, + "updatedAt": changed_at, + "updatedBy": user, } def set_check(self, item_id: str, checked: bool, user: str) -> dict: @@ -89,12 +162,7 @@ class Store: """, (item_id, int(checked), changed_at, user), ) - db.execute( - "UPDATE metadata SET value = CAST(value AS INTEGER) + 1 WHERE key = 'revision'" - ) - revision = int( - db.execute("SELECT value FROM metadata WHERE key = 'revision'").fetchone()[0] - ) + revision = self._bump_revision(db) return { "itemId": item_id, "checked": checked, @@ -153,7 +221,7 @@ class Handler(BaseHTTPRequestHandler): def do_POST(self) -> None: path = urlparse(self.path).path - if path != "/api/checks": + if path not in ("/api/checks", "/api/plantings"): self.error_json("Not found", HTTPStatus.NOT_FOUND) return if self.headers.get_content_type() != "application/json": @@ -173,10 +241,19 @@ class Handler(BaseHTTPRequestHandler): self.error_json("Invalid JSON", HTTPStatus.BAD_REQUEST) return item_id = payload.get("itemId") if isinstance(payload, dict) else None - checked = payload.get("checked") if isinstance(payload, dict) else None if not isinstance(item_id, str) or not ITEM_ID.fullmatch(item_id): self.error_json("Invalid itemId", HTTPStatus.BAD_REQUEST) return + if path == "/api/plantings": + planted = payload.get("planted") + if planted is not None and not valid_game_date(planted): + self.error_json("planted must be null or a {year, season, day} date", HTTPStatus.BAD_REQUEST) + return + if planted is not None: + planted = {key: planted[key] for key in ("year", "season", "day")} + self.json_response(self.server.store.set_planting(item_id, planted, self.authenticated_user())) + return + checked = payload.get("checked") if not isinstance(checked, bool): self.error_json("checked must be boolean", HTTPStatus.BAD_REQUEST) return diff --git a/package.nix b/package.nix index 24c84fb..5466fb9 100644 --- a/package.nix +++ b/package.nix @@ -2,7 +2,7 @@ stdenvNoCC.mkDerivation { pname = "stardew-tracker"; - version = "1.4.0"; + version = "1.5.0"; src = ./.; nativeBuildInputs = [ makeWrapper ]; diff --git a/static/app.js b/static/app.js index bae6cae..af4b09d 100644 --- a/static/app.js +++ b/static/app.js @@ -11,18 +11,30 @@ day: Number(localStorage.getItem("valley-day")) || 1, filter: "all", checks: {}, + plantings: {}, revision: -1, - saving: new Set() + 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; - // Planting is tracked as its own shared flag so "on the way" never counts - // toward bundle completion. - const plantedId = key => `planted-${key}`; - const isPlanted = key => !state.checks[key] && Boolean(state.checks[plantedId(key)]); + 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); @@ -130,8 +142,15 @@ 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 = isPlanted(itemKey(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.")); @@ -145,20 +164,32 @@ const rows = crops.map(crop => { const key = itemKey(crop); const checked = Boolean(state.checks[key]); - const planted = isPlanted(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 ? "" : ``; - const date = planted - ? `On the wayplanted` - : `${overdue ? "Passed" : `${seasonNames[crop.season]} ${crop.last}`}${overdue ? "for this season" : "plant by"}`; + 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(crop.purpose)} + ${escapeHtml(crop.name)}${escapeHtml(purpose)} ${plantButton} - ${crop.grow} daysto grow - ${date} + ${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.

`; @@ -210,7 +241,12 @@ function statusBadges(status, entry) { if (status.checked) return ""; const badges = []; - if (isPlanted(entry.id)) badges.push(`🌱 Planted`); + 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`); @@ -231,7 +267,7 @@
${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" : "", isPlanted(entry.id) ? "planted" : ""].filter(Boolean).join(" "); + 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("")}
`; @@ -277,9 +313,10 @@ 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); + 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(); @@ -326,6 +363,42 @@ } } + 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; @@ -336,7 +409,7 @@ 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]) }) + 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", @@ -366,7 +439,7 @@ renderBundles(); } const plantButton = event.target.closest("button[data-plant-id]"); - if (plantButton && !plantButton.disabled) updateCheck(plantButton.dataset.plantId, !state.checks[plantButton.dataset.plantId]); + 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)); diff --git a/static/calendar.js b/static/calendar.js index 79fdcbb..605b2b0 100644 --- a/static/calendar.js +++ b/static/calendar.js @@ -2,10 +2,29 @@ const api = factory(); if (typeof module === "object" && module.exports) module.exports = api; if (root) root.ValleyCalendar = api; -})(typeof window === "undefined" ? globalThis : window, () => ({ - birthdayOn(birthdays, season, day, year) { - const entry = birthdays?.[season]?.[day]; - if (!entry || (entry[1] && entry[1] !== year)) return null; - return entry[0]; - } -})); +})(typeof window === "undefined" ? globalThis : window, () => { + const seasons = ["spring", "summer", "fall", "winter"]; + // Days since Spring 1, Year 1 (which is day 1). + const dayNumber = ({ year, season, day }) => (year - 1) * 112 + seasons.indexOf(season) * 28 + day; + const fromDayNumber = number => { + const offset = number - 1; + return { year: Math.floor(offset / 112) + 1, season: seasons[Math.floor(offset % 112 / 28)], day: offset % 28 + 1 }; + }; + + return { + birthdayOn(birthdays, season, day, year) { + const entry = birthdays?.[season]?.[day]; + if (!entry || (entry[1] && entry[1] !== year)) return null; + return entry[0]; + }, + + // Crops are harvestable `growDays` after planting when watered daily. + harvestDate(planted, growDays) { + return fromDayNumber(dayNumber(planted) + growDays); + }, + + daysBetween(from, to) { + return dayNumber(to) - dayNumber(from); + } + }; +}); diff --git a/static/index.html b/static/index.html index 6d522d9..084d578 100644 --- a/static/index.html +++ b/static/index.html @@ -6,7 +6,7 @@ - + Valley Yearbook @@ -115,10 +115,10 @@ - - - - - + + + + + diff --git a/static/styles.css b/static/styles.css index da9c5ad..fccd903 100644 --- a/static/styles.css +++ b/static/styles.css @@ -139,6 +139,9 @@ label.today-card:hover { border-color: color-mix(in srgb, var(--season) 55%, tra .plant-toggle:disabled { cursor: wait; opacity: .6; } .crop-row.planted { box-shadow: inset 3px 0 0 #79b56b; } .crop-row.planted .plant-date { color: #a9dfa4; } +.crop-row.planted .plant-date.ready { color: var(--accent); } +.crop-row.planted .plant-date.wilts { color: var(--danger); } +.today-card.harvest { --season: #79b56b; } .today-card.deadline.planted { --season: #79b56b; } .status-badge.planted { color: #a9dfa4; background: rgba(121,181,107,.18); } diff --git a/static/sync.js b/static/sync.js index eb1e268..edd4703 100644 --- a/static/sync.js +++ b/static/sync.js @@ -3,9 +3,9 @@ if (typeof module === "object" && module.exports) module.exports = api; if (root) root.ValleySync = api; })(typeof window === "undefined" ? globalThis : window, () => ({ - mergeSnapshot(localChecks, savingIds, currentRevision, snapshot) { + mergeSnapshot(localChecks, savingIds, currentRevision, snapshot, localPlantings = {}, savingPlantings = new Set()) { if (!snapshot || !Number.isInteger(snapshot.revision) || snapshot.revision <= currentRevision) { - return { applied: false, revision: currentRevision, checks: localChecks }; + return { applied: false, revision: currentRevision, checks: localChecks, plantings: localPlantings }; } const checks = { ...(snapshot.checks || {}) }; @@ -14,7 +14,12 @@ checks[itemId] = Boolean(localChecks[itemId]); } } - return { applied: true, revision: snapshot.revision, checks }; + const plantings = { ...(snapshot.plantings || {}) }; + for (const itemId of savingPlantings) { + if (Object.prototype.hasOwnProperty.call(localPlantings, itemId)) plantings[itemId] = localPlantings[itemId]; + else delete plantings[itemId]; + } + return { applied: true, revision: snapshot.revision, checks, plantings }; }, confirmWrite(localChecks, currentRevision, result) { @@ -22,5 +27,12 @@ checks: { ...localChecks, [result.itemId]: Boolean(result.checked) }, revision: Math.max(currentRevision, result.revision) }; + }, + + confirmPlanting(localPlantings, currentRevision, result) { + const plantings = { ...localPlantings }; + if (result.planted) plantings[result.itemId] = result.planted; + else delete plantings[result.itemId]; + return { plantings, revision: Math.max(currentRevision, result.revision) }; } })); diff --git a/tests/test_app.py b/tests/test_app.py index e065a5e..3bb1725 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -46,6 +46,34 @@ class StoreTests(unittest.TestCase): self.assertEqual(len(snapshot["checks"]), 20) self.assertTrue(all(snapshot["checks"].values())) + def test_planting_round_trip(self): + spring_three = {"year": 1, "season": "spring", "day": 3} + result = self.store.set_planting("crop-parsnip", spring_three, "fred") + self.assertEqual(result["revision"], 1) + self.assertEqual(self.store.snapshot()["plantings"], {"crop-parsnip": spring_three}) + + self.store.set_planting("crop-parsnip", None, "fred") + snapshot = self.store.snapshot() + self.assertEqual(snapshot["plantings"], {}) + self.assertEqual(snapshot["revision"], 2) + + def test_undated_planted_checks_migrate_to_plantings(self): + self.store.set_check("planted-crop-parsnip", True, "fred") + self.store.set_check("planted-crop-potato", False, "fred") + self.store.initialize() + snapshot = self.store.snapshot() + self.assertEqual(snapshot["checks"], {}) + self.assertEqual( + snapshot["plantings"], {"crop-parsnip": {"year": None, "season": None, "day": None}} + ) + self.assertEqual(snapshot["revision"], 3) + + def test_game_date_validation(self): + self.assertTrue(app.valid_game_date({"year": 2, "season": "winter", "day": 28})) + self.assertFalse(app.valid_game_date({"year": 1, "season": "spring", "day": 29})) + self.assertFalse(app.valid_game_date({"year": True, "season": "spring", "day": 1})) + self.assertFalse(app.valid_game_date({"year": 1, "season": "monsoon", "day": 1})) + class StaticInteractionTests(unittest.TestCase): def test_native_checkboxes_cover_each_tickable_row(self): diff --git a/tests/test_calendar.js b/tests/test_calendar.js index 01e7c53..91be9fe 100644 --- a/tests/test_calendar.js +++ b/tests/test_calendar.js @@ -12,3 +12,13 @@ assert.equal(calendar.birthdayOn(birthdays, "spring", 7, 2), "Lewis"); assert.equal(calendar.birthdayOn(birthdays, "spring", 8, 1), null); console.log("calendar tests passed"); + +// Parsnip (4 days) planted on Spring 1 is ready on Spring 5. +assert.deepEqual(calendar.harvestDate({ year: 1, season: "spring", day: 1 }, 4), { year: 1, season: "spring", day: 5 }); +// Corn planted late in Summer finishes in Fall; Winter rolls over into the next year. +assert.deepEqual(calendar.harvestDate({ year: 1, season: "summer", day: 20 }, 14), { year: 1, season: "fall", day: 6 }); +assert.deepEqual(calendar.harvestDate({ year: 1, season: "winter", day: 28 }, 1), { year: 2, season: "spring", day: 1 }); +assert.equal(calendar.daysBetween({ year: 1, season: "spring", day: 3 }, { year: 1, season: "spring", day: 5 }), 2); +assert.equal(calendar.daysBetween({ year: 1, season: "fall", day: 2 }, { year: 1, season: "summer", day: 28 }), -2); + +console.log("harvest date tests passed"); diff --git a/tests/test_sync.js b/tests/test_sync.js index ac7f238..88897a0 100644 --- a/tests/test_sync.js +++ b/tests/test_sync.js @@ -28,3 +28,18 @@ assert.equal(confirmed.checks["crop-parsnip"], true); assert.equal(confirmed.revision, 7); console.log("sync race tests passed"); + +// Plantings follow the same rules: an in-flight local planting (or removal) wins. +const springFive = { year: 1, season: "spring", day: 5 }; +const plantMerge = sync.mergeSnapshot({}, new Set(), 7, { + revision: 8, + checks: {}, + plantings: { "crop-parsnip": { year: 1, season: "spring", day: 1 }, "crop-potato": springFive } +}, { "crop-parsnip": springFive }, new Set(["crop-parsnip", "crop-potato"])); +assert.deepEqual(plantMerge.plantings, { "crop-parsnip": springFive }); + +const unplanted = sync.confirmPlanting(plantMerge.plantings, 8, { itemId: "crop-parsnip", planted: null, revision: 9 }); +assert.deepEqual(unplanted.plantings, {}); +assert.equal(unplanted.revision, 9); + +console.log("planting sync tests passed");