diff --git a/Pages/AdminMassPlants.php b/Pages/AdminMassPlants.php
index 01d866e..40bde6d 100644
--- a/Pages/AdminMassPlants.php
+++ b/Pages/AdminMassPlants.php
@@ -211,6 +211,7 @@ $PageSection = "Admin";
+
diff --git a/Pages/AdminPlants.php b/Pages/AdminPlants.php
index 7e4cff7..761df61 100644
--- a/Pages/AdminPlants.php
+++ b/Pages/AdminPlants.php
@@ -443,6 +443,7 @@ $PageSection = "Admin";
+
diff --git a/Pages/Garden.php b/Pages/Garden.php
index 1bb4d18..49b23c5 100644
--- a/Pages/Garden.php
+++ b/Pages/Garden.php
@@ -290,6 +290,7 @@ $PageSection = "Garden";
+
diff --git a/Scripts/Garden/AdminValidation.js b/Scripts/Garden/AdminValidation.js
index b6944e2..6511570 100644
--- a/Scripts/Garden/AdminValidation.js
+++ b/Scripts/Garden/AdminValidation.js
@@ -58,6 +58,153 @@ function ValidateAdminKey(
}
+function ValidateAdminFunctionalEffects(
+ Effects
+) {
+ const Functional =
+ Effects?.Functional;
+
+ if (Functional === undefined) {
+ return true;
+ }
+
+ if (!Array.isArray(Functional)) {
+ throw new Error(
+ "Effects.Functional must be an array."
+ );
+ }
+
+ if (Functional.length > 32) {
+ throw new Error(
+ "Effects.Functional cannot contain more than 32 effects."
+ );
+ }
+
+ const SeenKeys = new Set();
+
+ for (
+ let EffectIndex = 0;
+ EffectIndex < Functional.length;
+ EffectIndex++
+ ) {
+ const Effect =
+ Functional[EffectIndex];
+
+ if (!IsAdminPlainObject(Effect)) {
+ throw new Error(
+ "Functional effect " +
+ (EffectIndex + 1) +
+ " must be an object."
+ );
+ }
+
+ ValidateAdminKey(
+ String(
+ Effect.Type ?? ""
+ ).trim(),
+ "Functional effect type"
+ );
+
+ if (Effect.Key !== undefined) {
+ const EffectKey = String(
+ Effect.Key ?? ""
+ ).trim();
+
+ ValidateAdminKey(
+ EffectKey,
+ "Functional effect key"
+ );
+
+ if (SeenKeys.has(EffectKey)) {
+ throw new Error(
+ "Functional effect keys must be unique within a plant."
+ );
+ }
+
+ SeenKeys.add(EffectKey);
+ }
+
+ for (const Field of [
+ "Area",
+ "Source",
+ "Target"
+ ]) {
+ if (Effect[Field] === undefined) {
+ continue;
+ }
+
+ const Area = String(
+ Effect[Field]
+ );
+
+ if (
+ typeof FunctionalEffectAreas !==
+ "undefined" &&
+ !FunctionalEffectAreas.includes(
+ Area
+ )
+ ) {
+ throw new Error(
+ "Functional effect " +
+ Field.toLowerCase() +
+ " must be Front, Behind, Cardinal, Adjacent or Farm."
+ );
+ }
+ }
+
+ if (Effect.Cooldown !== undefined) {
+ ValidateAdminInteger(
+ Number(
+ Effect.Cooldown
+ ),
+ "Functional effect cooldown",
+ typeof FunctionalEffectMinimumCooldown !==
+ "undefined"
+ ? FunctionalEffectMinimumCooldown
+ : 1000
+ );
+ }
+
+ if (Effect.Amount !== undefined) {
+ const Amount = Number(
+ Effect.Amount
+ );
+
+ if (!Number.isFinite(Amount)) {
+ throw new Error(
+ "Functional effect amount must be a number."
+ );
+ }
+ }
+
+ if (
+ Effect.RequireMature !== undefined &&
+ typeof Effect.RequireMature !==
+ "boolean"
+ ) {
+ throw new Error(
+ "Functional effect RequireMature must be On or Off."
+ );
+ }
+
+ if (
+ typeof IsFunctionalEffectActive ===
+ "function" &&
+ IsFunctionalEffectActive(
+ Effect
+ ) &&
+ Effect.Cooldown === undefined
+ ) {
+ throw new Error(
+ "Active functional effects need a cooldown."
+ );
+ }
+ }
+
+ return true;
+}
+
+
function ValidateAdminPlant(
Plant,
Catalogue,
@@ -205,6 +352,10 @@ function ValidateAdminPlant(
);
}
+ ValidateAdminFunctionalEffects(
+ Plant.Effects
+ );
+
if (
Plant.DirectionalSprites !== undefined &&
typeof Plant.DirectionalSprites !== "boolean"
diff --git a/Scripts/Garden/FunctionalEffects.js b/Scripts/Garden/FunctionalEffects.js
new file mode 100644
index 0000000..44b0c63
--- /dev/null
+++ b/Scripts/Garden/FunctionalEffects.js
@@ -0,0 +1,1339 @@
+const FunctionalEffectAreas = [
+ "Front",
+ "Behind",
+ "Cardinal",
+ "Adjacent",
+ "Farm"
+];
+
+const FunctionalEffectActiveTypes = [
+ "Harvest",
+ "Duplicate",
+ "Plant",
+ "Replant"
+];
+
+const FunctionalEffectPassiveTypes = [
+ "GrowthAdvance",
+ "DisableMutation",
+ "DisableHarvest",
+ "MutationChanceBonus",
+ "CooldownReduction",
+ "HarvestYieldBonus"
+];
+
+const FunctionalEffectTypes = [
+ ...FunctionalEffectActiveTypes,
+ ...FunctionalEffectPassiveTypes
+];
+
+const FunctionalEffectMinimumCooldown = 1000;
+
+const FunctionalEffectHandlers = new Map();
+
+const FunctionalDirectionOffsets = {
+ North: [0, -1],
+ East: [1, 0],
+ South: [0, 1],
+ West: [-1, 0]
+};
+
+const FunctionalCardinalOffsets = [
+ [0, -1],
+ [1, 0],
+ [0, 1],
+ [-1, 0]
+];
+
+const FunctionalAdjacentOffsets = [
+ [0, -1],
+ [1, -1],
+ [1, 0],
+ [1, 1],
+ [0, 1],
+ [-1, 1],
+ [-1, 0],
+ [-1, -1]
+];
+
+
+function IsFunctionalPlainObject(
+ Value
+) {
+ return (
+ Value !== null &&
+ typeof Value === "object" &&
+ !Array.isArray(Value)
+ );
+}
+
+
+function GetPlantFunctionalEffects(
+ Plant
+) {
+ const Functional =
+ Plant?.Effects?.Functional;
+
+ if (!Array.isArray(Functional)) {
+ return [];
+ }
+
+ return Functional.filter(
+ Effect =>
+ IsFunctionalPlainObject(
+ Effect
+ ) &&
+ typeof Effect.Type ===
+ "string" &&
+ Effect.Type.trim().length > 0
+ );
+}
+
+
+function IsFunctionalPlant(
+ Plant
+) {
+ return (
+ GetPlantFunctionalEffects(
+ Plant
+ ).length > 0
+ );
+}
+
+
+function IsKnownFunctionalEffectType(
+ Type
+) {
+ return FunctionalEffectTypes.includes(
+ String(Type ?? "")
+ );
+}
+
+
+function IsFunctionalEffectActive(
+ Effect
+) {
+ return FunctionalEffectActiveTypes.includes(
+ String(Effect?.Type ?? "")
+ );
+}
+
+
+function IsFunctionalEffectPassive(
+ Effect
+) {
+ return FunctionalEffectPassiveTypes.includes(
+ String(Effect?.Type ?? "")
+ );
+}
+
+
+function GetFunctionalEffectArea(
+ Effect,
+ Field = "Area",
+ Fallback = "Front"
+) {
+ const Area = String(
+ Effect?.[Field] ?? Fallback
+ );
+
+ return FunctionalEffectAreas.includes(
+ Area
+ )
+ ? Area
+ : Fallback;
+}
+
+
+function DoesFunctionalEffectRequireMaturity(
+ Effect
+) {
+ return Effect?.RequireMature !== false;
+}
+
+
+function GetFunctionalEffectCooldown(
+ Effect
+) {
+ const Cooldown = Number(
+ Effect?.Cooldown ?? 0
+ );
+
+ if (
+ !Number.isFinite(Cooldown) ||
+ Cooldown <= 0
+ ) {
+ return 0;
+ }
+
+ return Math.max(
+ FunctionalEffectMinimumCooldown,
+ Math.floor(Cooldown)
+ );
+}
+
+
+function GetResolvedFunctionalEffectCooldown(
+ SaveData,
+ GardenIndex,
+ PlotIndex,
+ Effect,
+ AtTime
+) {
+ return GetFunctionalEffectCooldown(
+ Effect
+ );
+}
+
+
+function GetFunctionalEffectAmount(
+ Effect,
+ Fallback = 0
+) {
+ const Amount = Number(
+ Effect?.Amount
+ );
+
+ return Number.isFinite(Amount)
+ ? Amount
+ : Fallback;
+}
+
+
+function GetFunctionalEffectKey(
+ Effect,
+ EffectIndex
+) {
+ const ExplicitKey =
+ typeof Effect?.Key === "string"
+ ? Effect.Key.trim()
+ : "";
+
+ if (ExplicitKey.length > 0) {
+ return ExplicitKey;
+ }
+
+ return (
+ String(
+ Effect?.Type ?? "Effect"
+ ) +
+ ":" +
+ String(EffectIndex)
+ );
+}
+
+
+function RegisterFunctionalEffectHandler(
+ Type,
+ Handler
+) {
+ const NormalizedType =
+ String(Type ?? "").trim();
+
+ if (
+ NormalizedType.length === 0 ||
+ typeof Handler !== "function"
+ ) {
+ return false;
+ }
+
+ FunctionalEffectHandlers.set(
+ NormalizedType,
+ Handler
+ );
+
+ return true;
+}
+
+
+function UnregisterFunctionalEffectHandler(
+ Type
+) {
+ return FunctionalEffectHandlers.delete(
+ String(Type ?? "")
+ );
+}
+
+
+function GetFunctionalEffectHandler(
+ Type
+) {
+ return FunctionalEffectHandlers.get(
+ String(Type ?? "")
+ ) ?? null;
+}
+
+
+function HasFunctionalEffectHandler(
+ Type
+) {
+ return (
+ GetFunctionalEffectHandler(
+ Type
+ ) !== null
+ );
+}
+
+
+function GetFunctionalGarden(
+ SaveData,
+ GardenIndex
+) {
+ if (
+ !Array.isArray(
+ SaveData?.Gardens
+ )
+ ) {
+ return null;
+ }
+
+ return SaveData.Gardens[
+ GardenIndex
+ ] ?? null;
+}
+
+
+function GetFunctionalPlotCoordinates(
+ Garden,
+ PlotIndex
+) {
+ const Width = Math.max(
+ 1,
+ Math.floor(
+ Number(Garden?.Width) || 1
+ )
+ );
+
+ return {
+ X: PlotIndex % Width,
+ Y: Math.floor(
+ PlotIndex / Width
+ )
+ };
+}
+
+
+function GetFunctionalPlotIndex(
+ Garden,
+ X,
+ Y
+) {
+ const Width = Math.max(
+ 1,
+ Math.floor(
+ Number(Garden?.Width) || 1
+ )
+ );
+
+ const Height = Math.max(
+ 1,
+ Math.floor(
+ Number(Garden?.Height) || 1
+ )
+ );
+
+ if (
+ X < 0 ||
+ Y < 0 ||
+ X >= Width ||
+ Y >= Height
+ ) {
+ return null;
+ }
+
+ return Y * Width + X;
+}
+
+
+function GetFunctionalPlotRotation(
+ Plot
+) {
+ const Rotation = String(
+ Plot?.Rotation ?? "East"
+ );
+
+ return FunctionalDirectionOffsets[
+ Rotation
+ ] !== undefined
+ ? Rotation
+ : "East";
+}
+
+
+function GetFunctionalOffsetIndexes(
+ Garden,
+ PlotIndex,
+ Offsets
+) {
+ const Coordinates =
+ GetFunctionalPlotCoordinates(
+ Garden,
+ PlotIndex
+ );
+
+ const Results = [];
+
+ for (const [OffsetX, OffsetY] of Offsets) {
+ const TargetIndex =
+ GetFunctionalPlotIndex(
+ Garden,
+ Coordinates.X + OffsetX,
+ Coordinates.Y + OffsetY
+ );
+
+ if (TargetIndex !== null) {
+ Results.push(TargetIndex);
+ }
+ }
+
+ return Results;
+}
+
+
+function GetFunctionalAreaPlotIndexes(
+ SaveData,
+ GardenIndex,
+ OriginPlotIndex,
+ Area,
+ Rotation = null
+) {
+ const Garden =
+ GetFunctionalGarden(
+ SaveData,
+ GardenIndex
+ );
+
+ if (Garden === null) {
+ return [];
+ }
+
+ const NormalizedArea =
+ FunctionalEffectAreas.includes(
+ Area
+ )
+ ? Area
+ : "Front";
+
+ if (NormalizedArea === "Farm") {
+ return Array.from(
+ {
+ length:
+ Garden.Plots?.length ?? 0
+ },
+ (_, PlotIndex) => PlotIndex
+ );
+ }
+
+ if (NormalizedArea === "Cardinal") {
+ return GetFunctionalOffsetIndexes(
+ Garden,
+ OriginPlotIndex,
+ FunctionalCardinalOffsets
+ );
+ }
+
+ if (NormalizedArea === "Adjacent") {
+ return GetFunctionalOffsetIndexes(
+ Garden,
+ OriginPlotIndex,
+ FunctionalAdjacentOffsets
+ );
+ }
+
+ const Direction =
+ Rotation === null
+ ? GetFunctionalPlotRotation(
+ Garden.Plots?.[
+ OriginPlotIndex
+ ]
+ )
+ : GetFunctionalPlotRotation({
+ Rotation
+ });
+
+ const [DirectionX, DirectionY] =
+ FunctionalDirectionOffsets[
+ Direction
+ ];
+
+ const Multiplier =
+ NormalizedArea === "Behind"
+ ? -1
+ : 1;
+
+ return GetFunctionalOffsetIndexes(
+ Garden,
+ OriginPlotIndex,
+ [[
+ DirectionX * Multiplier,
+ DirectionY * Multiplier
+ ]]
+ );
+}
+
+
+function GetFunctionalAreaTargets(
+ SaveData,
+ GardenIndex,
+ OriginPlotIndex,
+ Area,
+ Rotation = null
+) {
+ const Garden =
+ GetFunctionalGarden(
+ SaveData,
+ GardenIndex
+ );
+
+ if (Garden === null) {
+ return [];
+ }
+
+ return GetFunctionalAreaPlotIndexes(
+ SaveData,
+ GardenIndex,
+ OriginPlotIndex,
+ Area,
+ Rotation
+ ).map(
+ PlotIndex => ({
+ GardenIndex,
+ PlotIndex,
+ Plot:
+ Garden.Plots[
+ PlotIndex
+ ] ?? null
+ })
+ );
+}
+
+
+function IsFunctionalPlantMatureAtTime(
+ Plot,
+ Plant,
+ AtTime
+) {
+ if (
+ Plot === null ||
+ Plant === null ||
+ Plant === undefined
+ ) {
+ return false;
+ }
+
+ const GrowthTime = Math.max(
+ 0,
+ Number(
+ Plant.GrowthTime ?? 0
+ ) || 0
+ );
+
+ return (
+ Number(AtTime) -
+ Number(
+ Plot.PlantedAt ?? 0
+ ) >= GrowthTime
+ );
+}
+
+
+function EnsureFunctionalCooldowns(
+ Plot
+) {
+ if (
+ Plot === null ||
+ typeof Plot !== "object" ||
+ Array.isArray(Plot)
+ ) {
+ return null;
+ }
+
+ if (
+ !IsFunctionalPlainObject(
+ Plot.FunctionalCooldowns
+ )
+ ) {
+ Plot.FunctionalCooldowns = {};
+ }
+
+ return Plot.FunctionalCooldowns;
+}
+
+
+function NormalizeFunctionalCooldownEntry(
+ Value
+) {
+ if (
+ Number.isFinite(
+ Number(Value)
+ )
+ ) {
+ return {
+ StartedAt: 0,
+ Until: Math.max(
+ 0,
+ Math.floor(
+ Number(Value)
+ )
+ )
+ };
+ }
+
+ if (!IsFunctionalPlainObject(Value)) {
+ return null;
+ }
+
+ const StartedAt = Math.max(
+ 0,
+ Math.floor(
+ Number(
+ Value.StartedAt ?? 0
+ ) || 0
+ )
+ );
+
+ const Until = Math.max(
+ StartedAt,
+ Math.floor(
+ Number(
+ Value.Until ?? 0
+ ) || 0
+ )
+ );
+
+ if (Until <= 0) {
+ return null;
+ }
+
+ return {
+ StartedAt,
+ Until
+ };
+}
+
+
+function GetFunctionalEffectCooldownState(
+ Plot,
+ Effect,
+ EffectIndex,
+ AtTime
+) {
+ const Cooldowns =
+ EnsureFunctionalCooldowns(
+ Plot
+ );
+
+ if (Cooldowns === null) {
+ return null;
+ }
+
+ const EffectKey =
+ GetFunctionalEffectKey(
+ Effect,
+ EffectIndex
+ );
+
+ const Entry =
+ NormalizeFunctionalCooldownEntry(
+ Cooldowns[EffectKey]
+ );
+
+ if (Entry === null) {
+ delete Cooldowns[EffectKey];
+ return null;
+ }
+
+ Cooldowns[EffectKey] = Entry;
+
+ const Duration = Math.max(
+ 1,
+ Entry.Until -
+ Entry.StartedAt
+ );
+
+ const Progress = Math.max(
+ 0,
+ Math.min(
+ (
+ Number(AtTime) -
+ Entry.StartedAt
+ ) / Duration,
+ 1
+ )
+ );
+
+ return {
+ Key: EffectKey,
+ StartedAt: Entry.StartedAt,
+ Until: Entry.Until,
+ Remaining: Math.max(
+ 0,
+ Entry.Until -
+ Number(AtTime)
+ ),
+ Progress,
+ Ready:
+ Number(AtTime) >=
+ Entry.Until
+ };
+}
+
+
+function StartFunctionalEffectCooldown(
+ Plot,
+ Effect,
+ EffectIndex,
+ AtTime,
+ ResolvedCooldown = null
+) {
+ const Cooldown =
+ ResolvedCooldown === null
+ ? GetFunctionalEffectCooldown(
+ Effect
+ )
+ : Math.max(
+ 0,
+ Math.floor(
+ Number(
+ ResolvedCooldown
+ ) || 0
+ )
+ );
+
+ if (Cooldown <= 0) {
+ return null;
+ }
+
+ const Cooldowns =
+ EnsureFunctionalCooldowns(
+ Plot
+ );
+
+ if (Cooldowns === null) {
+ return null;
+ }
+
+ const EffectKey =
+ GetFunctionalEffectKey(
+ Effect,
+ EffectIndex
+ );
+
+ const StartedAt = Math.max(
+ 0,
+ Math.floor(
+ Number(AtTime) || 0
+ )
+ );
+
+ Cooldowns[EffectKey] = {
+ StartedAt,
+ Until:
+ StartedAt + Cooldown
+ };
+
+ return GetFunctionalEffectCooldownState(
+ Plot,
+ Effect,
+ EffectIndex,
+ AtTime
+ );
+}
+
+
+function IsFunctionalEffectOnCooldown(
+ Plot,
+ Effect,
+ EffectIndex,
+ AtTime
+) {
+ const State =
+ GetFunctionalEffectCooldownState(
+ Plot,
+ Effect,
+ EffectIndex,
+ AtTime
+ );
+
+ return (
+ State !== null &&
+ !State.Ready
+ );
+}
+
+
+function GetFunctionalPlantCooldownDisplayState(
+ Plot,
+ Plant,
+ AtTime
+) {
+ if (
+ !IsFunctionalPlantMatureAtTime(
+ Plot,
+ Plant,
+ AtTime
+ )
+ ) {
+ return null;
+ }
+
+ let BestState = null;
+
+ const Effects =
+ GetPlantFunctionalEffects(
+ Plant
+ );
+
+ Effects.forEach(
+ (Effect, EffectIndex) => {
+ if (
+ !IsFunctionalEffectActive(
+ Effect
+ )
+ ) {
+ return;
+ }
+
+ const State =
+ GetFunctionalEffectCooldownState(
+ Plot,
+ Effect,
+ EffectIndex,
+ AtTime
+ );
+
+ if (
+ State === null ||
+ State.Ready
+ ) {
+ return;
+ }
+
+ if (
+ BestState === null ||
+ State.Until <
+ BestState.Until
+ ) {
+ BestState = {
+ ...State,
+ Effect,
+ EffectIndex
+ };
+ }
+ }
+ );
+
+ return BestState;
+}
+
+
+function GetFunctionalEffectsAffectingPlot(
+ SaveData,
+ GardenIndex,
+ TargetPlotIndex,
+ Type,
+ AtTime
+) {
+ const Garden =
+ GetFunctionalGarden(
+ SaveData,
+ GardenIndex
+ );
+
+ if (Garden === null) {
+ return [];
+ }
+
+ const Results = [];
+
+ Garden.Plots.forEach(
+ (SourcePlot, SourcePlotIndex) => {
+ if (SourcePlot === null) {
+ return;
+ }
+
+ const SourcePlant =
+ Plants?.[
+ SourcePlot.Plant
+ ];
+
+ if (SourcePlant === undefined) {
+ return;
+ }
+
+ const Effects =
+ GetPlantFunctionalEffects(
+ SourcePlant
+ );
+
+ Effects.forEach(
+ (Effect, EffectIndex) => {
+ if (
+ String(
+ Effect.Type
+ ) !== String(Type)
+ ) {
+ return;
+ }
+
+ if (
+ DoesFunctionalEffectRequireMaturity(
+ Effect
+ ) &&
+ !IsFunctionalPlantMatureAtTime(
+ SourcePlot,
+ SourcePlant,
+ AtTime
+ )
+ ) {
+ return;
+ }
+
+ const Area =
+ GetFunctionalEffectArea(
+ Effect
+ );
+
+ const Targets =
+ GetFunctionalAreaPlotIndexes(
+ SaveData,
+ GardenIndex,
+ SourcePlotIndex,
+ Area
+ );
+
+ if (
+ !Targets.includes(
+ TargetPlotIndex
+ )
+ ) {
+ return;
+ }
+
+ Results.push({
+ GardenIndex,
+ SourcePlotIndex,
+ SourcePlot,
+ SourcePlant,
+ Effect,
+ EffectIndex
+ });
+ }
+ );
+ }
+ );
+
+ return Results;
+}
+
+
+function GetStrongestFunctionalEffectAffectingPlot(
+ SaveData,
+ GardenIndex,
+ TargetPlotIndex,
+ Type,
+ AtTime
+) {
+ const Effects =
+ GetFunctionalEffectsAffectingPlot(
+ SaveData,
+ GardenIndex,
+ TargetPlotIndex,
+ Type,
+ AtTime
+ );
+
+ if (Effects.length === 0) {
+ return null;
+ }
+
+ return Effects.reduce(
+ (Best, Candidate) =>
+ Best === null ||
+ GetFunctionalEffectAmount(
+ Candidate.Effect
+ ) >
+ GetFunctionalEffectAmount(
+ Best.Effect
+ )
+ ? Candidate
+ : Best,
+ null
+ );
+}
+
+
+function GetStrongestFunctionalEffectAffectingAnyPlot(
+ SaveData,
+ GardenIndex,
+ TargetPlotIndexes,
+ Type,
+ AtTime
+) {
+ let Best = null;
+
+ for (const PlotIndex of TargetPlotIndexes) {
+ const Candidate =
+ GetStrongestFunctionalEffectAffectingPlot(
+ SaveData,
+ GardenIndex,
+ PlotIndex,
+ Type,
+ AtTime
+ );
+
+ if (
+ Candidate !== null &&
+ (
+ Best === null ||
+ GetFunctionalEffectAmount(
+ Candidate.Effect
+ ) >
+ GetFunctionalEffectAmount(
+ Best.Effect
+ )
+ )
+ ) {
+ Best = Candidate;
+ }
+ }
+
+ return Best;
+}
+
+
+function CreateFunctionalProcessingResult() {
+ return {
+ Attempted: false,
+ Changed: false,
+ Activations: []
+ };
+}
+
+
+function MergeFunctionalHandlerResult(
+ Result,
+ HandlerResult
+) {
+ if (HandlerResult === true) {
+ Result.Changed = true;
+ return;
+ }
+
+ if (!IsFunctionalPlainObject(HandlerResult)) {
+ return;
+ }
+
+ Result.Changed =
+ Result.Changed ||
+ HandlerResult.Changed === true;
+}
+
+
+function ProcessFunctionalEffectsAtTime(
+ SaveData,
+ AtTime
+) {
+ const Result =
+ CreateFunctionalProcessingResult();
+
+ if (
+ !Array.isArray(
+ SaveData?.Gardens
+ )
+ ) {
+ return Result;
+ }
+
+ for (
+ let GardenIndex = 0;
+ GardenIndex < SaveData.Gardens.length;
+ GardenIndex++
+ ) {
+ const Garden =
+ SaveData.Gardens[
+ GardenIndex
+ ];
+
+ for (
+ let PlotIndex = 0;
+ PlotIndex < Garden.Plots.length;
+ PlotIndex++
+ ) {
+ const Plot =
+ Garden.Plots[
+ PlotIndex
+ ];
+
+ if (Plot === null) {
+ continue;
+ }
+
+ const Plant =
+ Plants?.[Plot.Plant];
+
+ if (Plant === undefined) {
+ continue;
+ }
+
+ const Effects =
+ GetPlantFunctionalEffects(
+ Plant
+ );
+
+ for (
+ let EffectIndex = 0;
+ EffectIndex < Effects.length;
+ EffectIndex++
+ ) {
+ const Effect =
+ Effects[EffectIndex];
+
+ if (
+ !IsFunctionalEffectActive(
+ Effect
+ )
+ ) {
+ continue;
+ }
+
+ if (
+ DoesFunctionalEffectRequireMaturity(
+ Effect
+ ) &&
+ !IsFunctionalPlantMatureAtTime(
+ Plot,
+ Plant,
+ AtTime
+ )
+ ) {
+ continue;
+ }
+
+ const Handler =
+ GetFunctionalEffectHandler(
+ Effect.Type
+ );
+
+ if (Handler === null) {
+ continue;
+ }
+
+ if (
+ IsFunctionalEffectOnCooldown(
+ Plot,
+ Effect,
+ EffectIndex,
+ AtTime
+ )
+ ) {
+ continue;
+ }
+
+ Result.Attempted = true;
+
+ const ResolvedCooldown =
+ GetResolvedFunctionalEffectCooldown(
+ SaveData,
+ GardenIndex,
+ PlotIndex,
+ Effect,
+ AtTime
+ );
+
+ StartFunctionalEffectCooldown(
+ Plot,
+ Effect,
+ EffectIndex,
+ AtTime,
+ ResolvedCooldown
+ );
+
+ const HandlerResult =
+ Handler({
+ SaveData,
+ GardenIndex,
+ Garden,
+ PlotIndex,
+ Plot,
+ Plant,
+ Effect,
+ EffectIndex,
+ AtTime,
+ GetTargets: (
+ Area = null
+ ) =>
+ GetFunctionalAreaTargets(
+ SaveData,
+ GardenIndex,
+ PlotIndex,
+ Area ??
+ GetFunctionalEffectArea(
+ Effect
+ )
+ )
+ });
+
+ MergeFunctionalHandlerResult(
+ Result,
+ HandlerResult
+ );
+
+ Result.Activations.push({
+ GardenIndex,
+ PlotIndex,
+ PlantKey:
+ Plot.Plant,
+ Type:
+ Effect.Type,
+ EffectIndex,
+ AtTime
+ });
+
+ if (
+ Garden.Plots[PlotIndex] !==
+ Plot
+ ) {
+ break;
+ }
+ }
+ }
+ }
+
+ return Result;
+}
+
+
+function GetNextFunctionalEffectEventTime(
+ SaveData,
+ CurrentTime,
+ TargetTime
+) {
+ if (
+ !Array.isArray(
+ SaveData?.Gardens
+ )
+ ) {
+ return null;
+ }
+
+ let NextTime = null;
+
+ for (
+ let GardenIndex = 0;
+ GardenIndex < SaveData.Gardens.length;
+ GardenIndex++
+ ) {
+ const Garden =
+ SaveData.Gardens[
+ GardenIndex
+ ];
+
+ for (
+ let PlotIndex = 0;
+ PlotIndex < Garden.Plots.length;
+ PlotIndex++
+ ) {
+ const Plot =
+ Garden.Plots[
+ PlotIndex
+ ];
+
+ if (Plot === null) {
+ continue;
+ }
+
+ const Plant =
+ Plants?.[Plot.Plant];
+
+ if (Plant === undefined) {
+ continue;
+ }
+
+ const Effects =
+ GetPlantFunctionalEffects(
+ Plant
+ );
+
+ if (Effects.length === 0) {
+ continue;
+ }
+
+ const MaturityTime =
+ Number(
+ Plot.PlantedAt ?? 0
+ ) +
+ Math.max(
+ 0,
+ Number(
+ Plant.GrowthTime ?? 0
+ ) || 0
+ );
+
+ for (
+ let EffectIndex = 0;
+ EffectIndex < Effects.length;
+ EffectIndex++
+ ) {
+ const Effect =
+ Effects[EffectIndex];
+
+ if (
+ !IsFunctionalEffectActive(
+ Effect
+ ) ||
+ !HasFunctionalEffectHandler(
+ Effect.Type
+ )
+ ) {
+ continue;
+ }
+
+ let EventTime;
+
+ if (
+ DoesFunctionalEffectRequireMaturity(
+ Effect
+ ) &&
+ MaturityTime > CurrentTime
+ ) {
+ EventTime = MaturityTime;
+ } else {
+ const CooldownState =
+ GetFunctionalEffectCooldownState(
+ Plot,
+ Effect,
+ EffectIndex,
+ CurrentTime
+ );
+
+ if (
+ CooldownState === null ||
+ CooldownState.Ready
+ ) {
+ EventTime = CurrentTime;
+ } else {
+ EventTime =
+ CooldownState.Until;
+ }
+ }
+
+ if (
+ EventTime <= CurrentTime ||
+ EventTime > TargetTime
+ ) {
+ continue;
+ }
+
+ if (
+ NextTime === null ||
+ EventTime < NextTime
+ ) {
+ NextTime = EventTime;
+ }
+ }
+ }
+ }
+
+ return NextTime;
+}
diff --git a/Scripts/Garden/Garden.js b/Scripts/Garden/Garden.js
index 3dddc34..b0ec523 100644
--- a/Scripts/Garden/Garden.js
+++ b/Scripts/Garden/Garden.js
@@ -1669,11 +1669,35 @@ function CreatePlotElement(
Plant
);
+ const CooldownState =
+ Progress >= 1 &&
+ typeof GetFunctionalPlantCooldownDisplayState ===
+ "function"
+ ? GetFunctionalPlantCooldownDisplayState(
+ Plot,
+ Plant,
+ GetSimulationTime(
+ GameSave
+ )
+ )
+ : null;
+
+ const BorderProgress =
+ CooldownState === null
+ ? Progress
+ : CooldownState.Progress;
+
Tile.style.setProperty(
"--GrowthProgress",
- `${Progress * 100}%`
+ `${BorderProgress * 100}%`
);
+ if (CooldownState !== null) {
+ Tile.classList.add(
+ "GardenPlotFunctionalCooldown"
+ );
+ }
+
const ImagePath =
GetPlantImage(
@@ -2004,6 +2028,31 @@ function CreateGardenPlotTimer(
}
+ if (
+ Progress >= 1 &&
+ typeof GetFunctionalPlantCooldownDisplayState ===
+ "function"
+ ) {
+ const CooldownState =
+ GetFunctionalPlantCooldownDisplayState(
+ Plot,
+ Plant,
+ GetSimulationTime(
+ GameSave
+ )
+ );
+
+ if (CooldownState !== null) {
+ Timer.textContent =
+ FormatGardenRemainingTime(
+ CooldownState.Remaining
+ );
+
+ return Timer;
+ }
+ }
+
+
if (Progress >= 1) {
Timer.textContent =
"Ready";
diff --git a/Scripts/Garden/Save.js b/Scripts/Garden/Save.js
index 1f62223..6760195 100644
--- a/Scripts/Garden/Save.js
+++ b/Scripts/Garden/Save.js
@@ -15,7 +15,7 @@ const MaximumGardenNameLength = 32;
const StartingGardenWidth = 4;
const StartingGardenHeight = 4;
-const CurrentSaveVersion = 7;
+const CurrentSaveVersion = 8;
function CreateSimulationRandomState() {
@@ -288,6 +288,77 @@ function NormalizeGardenData(
? Plot.Rotation
: "East";
+ if (
+ Plot.FunctionalCooldowns === null ||
+ typeof Plot.FunctionalCooldowns !==
+ "object" ||
+ Array.isArray(
+ Plot.FunctionalCooldowns
+ )
+ ) {
+ Plot.FunctionalCooldowns = {};
+ }
+
+ for (
+ const [CooldownKey, CooldownValue]
+ of Object.entries(
+ Plot.FunctionalCooldowns
+ )
+ ) {
+ let StartedAt = 0;
+ let Until = 0;
+
+ if (
+ CooldownValue !== null &&
+ typeof CooldownValue ===
+ "object" &&
+ !Array.isArray(
+ CooldownValue
+ )
+ ) {
+ StartedAt = Math.max(
+ 0,
+ Math.floor(
+ Number(
+ CooldownValue.StartedAt ?? 0
+ ) || 0
+ )
+ );
+
+ Until = Math.max(
+ StartedAt,
+ Math.floor(
+ Number(
+ CooldownValue.Until ?? 0
+ ) || 0
+ )
+ );
+ } else {
+ Until = Math.max(
+ 0,
+ Math.floor(
+ Number(
+ CooldownValue
+ ) || 0
+ )
+ );
+ }
+
+ if (Until <= 0) {
+ delete Plot.FunctionalCooldowns[
+ CooldownKey
+ ];
+ continue;
+ }
+
+ Plot.FunctionalCooldowns[
+ CooldownKey
+ ] = {
+ StartedAt,
+ Until
+ };
+ }
+
delete Plot.VisualVariant;
delete Plot.AddedTags;
}
diff --git a/Scripts/Garden/Simulation.js b/Scripts/Garden/Simulation.js
index 5f20a1e..59909a2 100644
--- a/Scripts/Garden/Simulation.js
+++ b/Scripts/Garden/Simulation.js
@@ -91,6 +91,28 @@ function MergeSimulationMutationResult(
}
+function MergeSimulationFunctionalResult(
+ Target,
+ Source
+) {
+ if (Source === null || Source === undefined) {
+ return;
+ }
+
+ Target.FunctionalAttempted =
+ Target.FunctionalAttempted ||
+ Source.Attempted === true;
+
+ Target.Changed =
+ Target.Changed ||
+ Source.Changed === true;
+
+ Target.FunctionalActivations.push(
+ ...(Source.Activations ?? [])
+ );
+}
+
+
function CreateSimulationResult(
StartTime,
TargetTime
@@ -100,6 +122,7 @@ function CreateSimulationResult(
TargetTime,
Advanced: TargetTime > StartTime,
Attempted: false,
+ FunctionalAttempted: false,
Changed: false,
Dirty: false,
CaughtUp: StartTime >= TargetTime,
@@ -107,7 +130,8 @@ function CreateSimulationResult(
Steps: 0,
SuccessfulMutations: [],
NewlyDiscoveredMutations: [],
- NewlyDiscoveredPlants: []
+ NewlyDiscoveredPlants: [],
+ FunctionalActivations: []
};
}
@@ -135,6 +159,7 @@ function FinalizeSimulationResult(
Result.Dirty =
Result.Attempted ||
+ Result.FunctionalAttempted ||
Result.Changed;
return Result;
@@ -324,18 +349,27 @@ function GetNextSimulationEventTime(
TargetTime
);
- if (MaturityTime === null) {
- return MutationTime;
- }
+ const FunctionalTime =
+ typeof GetNextFunctionalEffectEventTime ===
+ "function"
+ ? GetNextFunctionalEffectEventTime(
+ SaveData,
+ CurrentTime,
+ TargetTime
+ )
+ : null;
- if (MutationTime === null) {
- return MaturityTime;
- }
-
- return Math.min(
+ const EventTimes = [
MaturityTime,
- MutationTime
+ MutationTime,
+ FunctionalTime
+ ].filter(
+ Time => Time !== null
);
+
+ return EventTimes.length === 0
+ ? null
+ : Math.min(...EventTimes);
}
@@ -387,6 +421,22 @@ function AdvanceGameSimulation(
MutationResult
);
+ if (
+ typeof ProcessFunctionalEffectsAtTime ===
+ "function"
+ ) {
+ const FunctionalResult =
+ ProcessFunctionalEffectsAtTime(
+ SaveData,
+ CurrentTime
+ );
+
+ MergeSimulationFunctionalResult(
+ Result,
+ FunctionalResult
+ );
+ }
+
if (CurrentTime >= SafeTargetTime) {
Result.CaughtUp = true;
break;
diff --git a/Styles/Style.css b/Styles/Style.css
index d1d978d..97f2929 100644
--- a/Styles/Style.css
+++ b/Styles/Style.css
@@ -754,6 +754,15 @@ input, button, textarea, select {
--GrowthColour: var(--Yellow);
}
+.GardenPlotFunctionalCooldown,
+.GardenPlotFunctionalCooldown:hover {
+ --GrowthColour: var(--Blue);
+}
+
+.GardenPlotFunctionalCooldown .GardenPlotTimer {
+ color: var(--Blue);
+}
+
.SeedList {
display: flex;
gap: 0.5rem;