This repository has been archived on 2026-10-08. You can view files and clone it, but cannot push or open issues or pull requests.
allotment/packages/server/services/patchGrowingState.js

721 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Derives currentGrowingPosition, currentWithering, currentLeaves, currentFlowers, currentFruiting, and currentGrowingPhase from lifecycle + time.
* Biennial: weeks since plantedAt vs phase offsets; each phase lerps growth and withering like position (first phase withering starts from 0).
* Perennial: fractional week in [0, 52) from UTC Jan 1; phases use [startWeek, endWeek) on that ring (wrap allowed). Within each segment, interpolate from the prior phase (t=0 at startWeek) to the current phase’s targets (t=1 at endWeek), snapping to p exactly near endWeek.
*/
import { Patch } from "../db/models/index.js";
import { getLogger } from "../logger.js";
import { formatClientPatches, sanitizeLifecycleForClient } from "./patches.js";
const logger = getLogger("PatchGrowingState");
const MS_PER_WEEK = 7 * 24 * 60 * 60 * 1000;
const MAX_CAL_WEEK = 52;
const DEFAULT_POSITION = 0;
/** Baseline withering at timeline start (matches first-phase withering lerp origin). */
const DEFAULT_WITHERING = 0;
const MIN_PHASE_GROWING = 0;
const MAX_PHASE_GROWING = 100;
const MIN_PHASE_LEAVES = 0;
const MAX_PHASE_LEAVES = 6;
const MIN_PHASE_FLOWERS = 0;
const MAX_PHASE_FLOWERS = 3;
const DEFAULT_LEAVES = 0;
const DEFAULT_FLOWERS = 0;
const DEFAULT_FRUITING = 0;
function growingPositionFromPhase(ph) {
const v = ph?.growingPosition ?? ph?.growing_position;
const n = Number(v);
if (!Number.isFinite(n)) return 0;
return Math.min(
MAX_PHASE_GROWING,
Math.max(MIN_PHASE_GROWING, Math.round(n)),
);
}
function witheringFromPhase(ph) {
const v = ph?.withering ?? ph?.withering_position;
const n = Number(v);
if (!Number.isFinite(n)) return DEFAULT_WITHERING;
return Math.min(
MAX_PHASE_GROWING,
Math.max(MIN_PHASE_GROWING, Math.round(n)),
);
}
function clampPosition(n) {
if (!Number.isFinite(n)) return DEFAULT_POSITION;
return Math.min(
MAX_PHASE_GROWING,
Math.max(MIN_PHASE_GROWING, Math.round(n)),
);
}
function clampWithering(n) {
if (!Number.isFinite(n)) return DEFAULT_WITHERING;
return Math.min(
MAX_PHASE_GROWING,
Math.max(MIN_PHASE_GROWING, Math.round(n)),
);
}
function leavesFromPhase(ph) {
const v = ph?.leaves ?? ph?.leaf_count;
const n = Number(v);
if (!Number.isFinite(n)) return DEFAULT_LEAVES;
return Math.min(
MAX_PHASE_LEAVES,
Math.max(MIN_PHASE_LEAVES, Math.round(n)),
);
}
function flowersFromPhase(ph) {
const v = ph?.flowers ?? ph?.flower_count;
const n = Number(v);
if (!Number.isFinite(n)) return DEFAULT_FLOWERS;
return Math.min(
MAX_PHASE_FLOWERS,
Math.max(MIN_PHASE_FLOWERS, Math.round(n)),
);
}
function fruitingFromPhase(ph) {
const v = ph?.fruiting;
const n = Number(v);
if (!Number.isFinite(n)) return DEFAULT_FRUITING;
return Math.min(
MAX_PHASE_GROWING,
Math.max(MIN_PHASE_GROWING, Math.round(n)),
);
}
function clampLeaves(n) {
if (!Number.isFinite(n)) return DEFAULT_LEAVES;
return Math.min(
MAX_PHASE_LEAVES,
Math.max(MIN_PHASE_LEAVES, Math.round(n)),
);
}
function clampFlowers(n) {
if (!Number.isFinite(n)) return DEFAULT_FLOWERS;
return Math.min(
MAX_PHASE_FLOWERS,
Math.max(MIN_PHASE_FLOWERS, Math.round(n)),
);
}
function clampFruiting(n) {
if (!Number.isFinite(n)) return DEFAULT_FRUITING;
return Math.min(
MAX_PHASE_GROWING,
Math.max(MIN_PHASE_GROWING, Math.round(n)),
);
}
/**
* Fractional week index on the annual ring [0, 52): 0 = UTC Jan 1 00:00:00,
* 52 ≡ 0 (so late December continues smoothly into cross-year phases like 48→8).
*/
function perennialFractionalWeekInYearRing(now) {
const y = now.getUTCFullYear();
const yearStartUtcMs = Date.UTC(y, 0, 1, 0, 0, 0, 0);
let wf = (now.getTime() - yearStartUtcMs) / MS_PER_WEEK;
wf %= MAX_CAL_WEEK;
if (wf < 0) wf += MAX_CAL_WEEK;
return wf;
}
/** Phase covers [start, end) on the 0..52 ring; end < start means wrap (e.g. 48→8). */
function phaseContainsFractionalWeek(wf, start, end) {
const s = Math.floor(Number(start)) || 0;
const e = Math.floor(Number(end)) || 0;
if (e >= s) return wf >= s && wf < e;
return wf >= s || wf < e;
}
function perennialPhaseSpanWeeks(start, end) {
const s = Math.floor(Number(start)) || 0;
const e = Math.floor(Number(end)) || 0;
if (e >= s) return Math.max(1, e - s);
return Math.max(1, MAX_CAL_WEEK - s + e);
}
/** Weeks (float) elapsed from segment start to `wf` within the phase window. */
function fractionalIntoPerennialPhase(wf, start, end) {
const s = Math.floor(Number(start)) || 0;
const e = Math.floor(Number(end)) || 0;
if (e >= s) return wf - s;
if (wf >= s) return wf - s;
return MAX_CAL_WEEK - s + wf;
}
/** Fractional weeks from `wf` to segment end (exclusive); →0 as wf → end⁻. */
function fractionalWeeksRemainingInPhase(wf, start, end) {
const s = Math.floor(Number(start)) || 0;
const e = Math.floor(Number(end)) || 0;
if (e >= s) return e - wf;
if (wf >= s) return MAX_CAL_WEEK - wf + e;
return e - wf;
}
/**
* @param {object} patchLean — Patch document (lean)
* @param {Date} [now]
* @returns {{ currentGrowingPosition: number, currentWithering: number, currentLeaves: number, currentFlowers: number, currentFruiting: number, currentGrowingPhase: { name: string, index: number } | null }}
*/
function patchLogLabel(patchLean) {
if (patchLean?.patchId != null && patchLean.patchId !== "") {
return String(patchLean.patchId);
}
if (patchLean?._id != null) return String(patchLean._id);
return "(unknown patch)";
}
export function computePatchGrowingState(patchLean, now = new Date()) {
const lcRaw = patchLean?.lifecycle;
const lc = sanitizeLifecycleForClient(lcRaw);
const label = patchLogLabel(patchLean);
logger.trace(
"computePatchGrowingState:",
label,
"now",
now.toISOString(),
"lifecycleType",
lc?.type ?? "(none)",
"phaseCount",
lc?.phases?.length ?? 0,
);
if (!lc?.phases?.length) {
logger.trace("computePatchGrowingState: no phases → default", label);
return {
currentGrowingPosition: DEFAULT_POSITION,
currentWithering: DEFAULT_WITHERING,
currentLeaves: DEFAULT_LEAVES,
currentFlowers: DEFAULT_FLOWERS,
currentFruiting: DEFAULT_FRUITING,
currentGrowingPhase: null,
};
}
const phases = lc.phases;
if (lc.type === "biennial") {
return computeBiennialGrowingState(
phases,
lc.durationWeeks,
patchLean?.plantedAt ? new Date(patchLean.plantedAt) : null,
now,
label,
);
}
if (lc.type === "perennial") {
return computePerennialGrowingState(phases, now, label);
}
logger.trace(
"computePatchGrowingState: unknown lifecycle type → default",
label,
lc.type,
);
return {
currentGrowingPosition: DEFAULT_POSITION,
currentWithering: DEFAULT_WITHERING,
currentLeaves: DEFAULT_LEAVES,
currentFlowers: DEFAULT_FLOWERS,
currentFruiting: DEFAULT_FRUITING,
currentGrowingPhase: null,
};
}
/**
* @param {object[]} phases
* @param {number} durationWeeks
* @param {Date | null} plantedAt
* @param {Date} now
* @param {string} label
*/
function computeBiennialGrowingState(phases, durationWeeks, plantedAt, now, label) {
if (!plantedAt || Number.isNaN(plantedAt.getTime())) {
const p = phases[0];
logger.trace(
"biennial: missing/invalid plantedAt → phase 0",
label,
"plantedAt",
plantedAt,
);
return {
currentGrowingPosition: DEFAULT_POSITION,
currentWithering: DEFAULT_WITHERING,
currentLeaves: DEFAULT_LEAVES,
currentFlowers: DEFAULT_FLOWERS,
currentFruiting: DEFAULT_FRUITING,
currentGrowingPhase: {
name: typeof p?.name === "string" ? p.name : "phase",
index: 0,
},
};
}
const elapsedMs = now.getTime() - plantedAt.getTime();
const elapsedWeeksFloat = elapsedMs >= 0 ? elapsedMs / MS_PER_WEEK : 0;
let elapsedWeeks = Math.floor(elapsedWeeksFloat);
if (elapsedWeeks < 0) elapsedWeeks = 0;
const dur = Math.max(1, Math.floor(Number(durationWeeks)) || 1);
logger.trace(
"biennial:",
label,
"plantedAt",
plantedAt.toISOString(),
"elapsedWeeks",
elapsedWeeks,
"elapsedWeeksFloat",
elapsedWeeksFloat.toFixed(4),
"durationWeeks",
dur,
);
if (elapsedWeeks >= dur) {
const last = phases[phases.length - 1];
logger.trace(
"biennial: cycle complete → last phase",
label,
"phaseIndex",
phases.length - 1,
);
return {
currentGrowingPosition: growingPositionFromPhase(last),
currentWithering: witheringFromPhase(last),
currentLeaves: leavesFromPhase(last),
currentFlowers: flowersFromPhase(last),
currentFruiting: fruitingFromPhase(last),
currentGrowingPhase: {
name: typeof last?.name === "string" ? last.name : "phase",
index: phases.length - 1,
},
};
}
for (let i = 0; i < phases.length; i++) {
const p = phases[i];
const start = p.startOffsetWeeks ?? 0;
const end = p.endOffsetWeeks ?? start;
if (elapsedWeeks >= start && elapsedWeeks < end) {
const span = Math.max(1, end - start);
const u = Math.min(1, Math.max(0, (elapsedWeeksFloat - start) / span));
const fromGp = i === 0 ? 0 : growingPositionFromPhase(phases[i - 1]);
const toGp = growingPositionFromPhase(p);
const position = clampPosition(fromGp + u * (toGp - fromGp));
const fromW =
i === 0 ? DEFAULT_WITHERING : witheringFromPhase(phases[i - 1]);
const toW = witheringFromPhase(p);
const withering = clampWithering(fromW + u * (toW - fromW));
const fromL = i === 0 ? DEFAULT_LEAVES : leavesFromPhase(phases[i - 1]);
const toL = leavesFromPhase(p);
const leaves = clampLeaves(fromL + u * (toL - fromL));
const fromF = i === 0 ? DEFAULT_FLOWERS : flowersFromPhase(phases[i - 1]);
const toF = flowersFromPhase(p);
const flowers = clampFlowers(fromF + u * (toF - fromF));
const fromFr =
i === 0 ? DEFAULT_FRUITING : fruitingFromPhase(phases[i - 1]);
const toFr = fruitingFromPhase(p);
const fruiting = clampFruiting(fromFr + u * (toFr - fromFr));
logger.trace(
"biennial: in-phase lerp",
label,
"phaseIndex",
i,
"weekRange",
`${start}–${end}`,
"u",
u.toFixed(4),
"position",
position,
"withering",
withering,
);
return {
currentGrowingPosition: position,
currentWithering: withering,
currentLeaves: leaves,
currentFlowers: flowers,
currentFruiting: fruiting,
currentGrowingPhase: {
name: typeof p?.name === "string" ? p.name : "phase",
index: i,
},
};
}
}
const firstStart = phases[0]?.startOffsetWeeks ?? 0;
if (elapsedWeeks < firstStart) {
const p = phases[0];
logger.trace(
"biennial: before first phase window → phase 0",
label,
"elapsedWeeks",
elapsedWeeks,
"firstStart",
firstStart,
);
return {
currentGrowingPosition: DEFAULT_POSITION,
currentWithering: DEFAULT_WITHERING,
currentLeaves: DEFAULT_LEAVES,
currentFlowers: DEFAULT_FLOWERS,
currentFruiting: DEFAULT_FRUITING,
currentGrowingPhase: {
name: typeof p?.name === "string" ? p.name : "phase",
index: 0,
},
};
}
const last = phases[phases.length - 1];
logger.trace(
"biennial: fallback → last phase",
label,
"elapsedWeeks",
elapsedWeeks,
);
return {
currentGrowingPosition: growingPositionFromPhase(last),
currentWithering: witheringFromPhase(last),
currentLeaves: leavesFromPhase(last),
currentFlowers: flowersFromPhase(last),
currentFruiting: fruitingFromPhase(last),
currentGrowingPhase: {
name: typeof last?.name === "string" ? last.name : "phase",
index: phases.length - 1,
},
};
}
/**
* @param {object[]} phases
* @param {Date} now
* @param {string} label
*/
function computePerennialGrowingState(phases, now, label) {
const wf = perennialFractionalWeekInYearRing(now);
logger.trace(
"perennial:",
label,
"yearWeekFloat",
wf.toFixed(4),
"now",
now.toISOString(),
);
for (let i = 0; i < phases.length; i++) {
const p = phases[i];
const start = p.startWeek ?? 0;
const end = p.endWeek ?? 0;
if (!phaseContainsFractionalWeek(wf, start, end)) continue;
const span = perennialPhaseSpanWeeks(start, end);
const into = fractionalIntoPerennialPhase(wf, start, end);
const remaining = fractionalWeeksRemainingInPhase(wf, start, end);
// [startWeek, endWeek): lerp from prior phase (t=0 at start) → this phase's targets (t=1 at end).
// Snap t=1 near endWeek so values match p exactly (open end otherwise never reaches t=1).
const EPS_WEEKS = 1e-4;
let t = span > 0 ? into / span : 1;
if (remaining <= EPS_WEEKS) t = 1;
t = Math.min(1, Math.max(0, t));
const prev = phases[(i - 1 + phases.length) % phases.length];
const fromGp = growingPositionFromPhase(prev);
const toGp = growingPositionFromPhase(p);
const position = clampPosition(fromGp + t * (toGp - fromGp));
const fromW = witheringFromPhase(prev);
const toW = witheringFromPhase(p);
const withering = clampWithering(fromW + t * (toW - fromW));
const fromL = leavesFromPhase(prev);
const toL = leavesFromPhase(p);
const leaves = clampLeaves(fromL + t * (toL - fromL));
const fromF = flowersFromPhase(prev);
const toF = flowersFromPhase(p);
const flowers = clampFlowers(fromF + t * (toF - fromF));
const fromFr = fruitingFromPhase(prev);
const toFr = fruitingFromPhase(p);
const fruiting = clampFruiting(fromFr + t * (toFr - fromFr));
logger.trace(
"perennial: matched phase",
label,
"phaseIndex",
i,
"weekRange",
`${start}–${end}`,
"into",
into.toFixed(4),
"span",
span,
"t",
t.toFixed(4),
"position",
position,
"withering",
withering,
);
return {
currentGrowingPosition: position,
currentWithering: withering,
currentLeaves: leaves,
currentFlowers: flowers,
currentFruiting: fruiting,
currentGrowingPhase: {
name: typeof p?.name === "string" ? p.name : "phase",
index: i,
},
};
}
const p = phases[0];
logger.trace(
"perennial: no week match → phase 0",
label,
"yearWeekFloat",
wf.toFixed(4),
);
return {
currentGrowingPosition: growingPositionFromPhase(p),
currentWithering: witheringFromPhase(p),
currentLeaves: leavesFromPhase(p),
currentFlowers: flowersFromPhase(p),
currentFruiting: fruitingFromPhase(p),
currentGrowingPhase: {
name: typeof p?.name === "string" ? p.name : "phase",
index: 0,
},
};
}
function growingStateChanged(prev, next) {
if (
(prev?.currentGrowingPhase == null) !==
(next.currentGrowingPhase == null)
) {
return true;
}
if (prev?.currentGrowingPosition !== next.currentGrowingPosition) return true;
if (prev?.currentWithering !== next.currentWithering) return true;
if (prev?.currentLeaves !== next.currentLeaves) return true;
if (prev?.currentFlowers !== next.currentFlowers) return true;
if (prev?.currentFruiting !== next.currentFruiting) return true;
if (next.currentGrowingPhase == null) return false;
const pp = prev?.currentGrowingPhase;
if (!pp) return true;
return (
pp.name !== next.currentGrowingPhase.name ||
pp.index !== next.currentGrowingPhase.index
);
}
const populateImages = {
path: "images",
select: "blurHash contentType",
};
/**
* Persists computed fields on one patch by Mongo _id.
* @param {import("mongoose").Types.ObjectId | string} patchMongoId
* @param {Date} [now]
* @returns {Promise<{ patch: object | null, changed: boolean }>}
*/
export async function recalculateAndPersistOnePatchByMongoId(
patchMongoId,
now = new Date(),
) {
const idStr = String(patchMongoId);
logger.debug("recalculateAndPersistOnePatchByMongoId:", idStr);
const doc = await Patch.findById(patchMongoId)
.populate(populateImages)
.lean();
if (!doc) {
logger.debug("recalculateAndPersistOnePatchByMongoId: not found", idStr);
return { patch: null, changed: false };
}
const label = patchLogLabel(doc);
logger.trace(
"recalculateAndPersistOnePatch: loaded",
label,
"mongoId",
idStr,
);
const next = computePatchGrowingState(doc, now);
const prev = {
currentGrowingPosition: doc.currentGrowingPosition,
currentWithering: doc.currentWithering,
currentLeaves: doc.currentLeaves,
currentFlowers: doc.currentFlowers,
currentFruiting: doc.currentFruiting,
currentGrowingPhase: doc.currentGrowingPhase ?? null,
};
logger.trace(
"recalculateAndPersistOnePatch: compare",
label,
"prevPosition",
prev.currentGrowingPosition,
"nextPosition",
next.currentGrowingPosition,
"prevWithering",
prev.currentWithering,
"nextWithering",
next.currentWithering,
"prevLeaves",
prev.currentLeaves,
"nextLeaves",
next.currentLeaves,
"prevFlowers",
prev.currentFlowers,
"nextFlowers",
next.currentFlowers,
"prevFruiting",
prev.currentFruiting,
"nextFruiting",
next.currentFruiting,
"prevPhase",
prev.currentGrowingPhase?.name,
prev.currentGrowingPhase?.index,
"nextPhase",
next.currentGrowingPhase?.name,
next.currentGrowingPhase?.index,
);
if (!growingStateChanged(prev, next)) {
logger.trace("recalculateAndPersistOnePatch: unchanged", label);
return { patch: formatClientPatches([doc])[0], changed: false };
}
const updated = await Patch.findByIdAndUpdate(
patchMongoId,
{
$set: {
currentGrowingPosition: next.currentGrowingPosition,
currentWithering: next.currentWithering,
currentLeaves: next.currentLeaves,
currentFlowers: next.currentFlowers,
currentFruiting: next.currentFruiting,
currentGrowingPhase: next.currentGrowingPhase,
},
},
{ new: true },
)
.populate(populateImages)
.lean();
if (!updated) {
logger.warn(
"recalculateAndPersistOnePatch: update returned null",
idStr,
label,
);
return { patch: null, changed: false };
}
logger.debug(
"recalculateAndPersistOnePatch: persisted",
label,
"position",
next.currentGrowingPosition,
"withering",
next.currentWithering,
"leaves",
next.currentLeaves,
"flowers",
next.currentFlowers,
"fruiting",
next.currentFruiting,
"phase",
next.currentGrowingPhase?.name,
"phaseIndex",
next.currentGrowingPhase?.index,
);
return { patch: formatClientPatches([updated])[0], changed: true };
}
/**
* @param {import("mongoose").Types.ObjectId} deviceId
* @param {string} patchId
* @param {Date} [now]
*/
export async function recalculateGrowingStateForLogicalPatch(
deviceId,
patchId,
now = new Date(),
) {
logger.debug(
"recalculateGrowingStateForLogicalPatch:",
String(deviceId),
patchId,
);
const doc = await Patch.findOne({ deviceId, patchId }).select("_id").lean();
if (!doc) {
logger.trace(
"recalculateGrowingStateForLogicalPatch: no document",
String(deviceId),
patchId,
);
return { patch: null, changed: false };
}
logger.trace(
"recalculateGrowingStateForLogicalPatch: resolved mongoId",
String(doc._id),
);
return recalculateAndPersistOnePatchByMongoId(doc._id, now);
}
/**
* Recalculates every patch. Returns formatted client patches that changed (for socket push).
* @param {Date} [now]
* @returns {Promise<object[]>}
*/
export async function recalculateAllPatchesGrowingState(now = new Date()) {
const ids = await Patch.find({}).select("_id").lean();
logger.debug(
"recalculateAllPatchesGrowingState: starting",
ids.length,
"patches",
"now",
now.toISOString(),
);
const changed = [];
for (const row of ids) {
try {
const { patch, changed: did } =
await recalculateAndPersistOnePatchByMongoId(row._id, now);
if (did && patch) changed.push(patch);
} catch (e) {
logger.error("recalculate patch", row._id, e.message ?? e);
}
}
logger.debug(
"recalculateAllPatchesGrowingState: done",
changed.length,
"updated",
);
return changed;
}