diff --git a/Pages/Admin.php b/Pages/Admin.php index bbd2c21..d1ad375 100644 --- a/Pages/Admin.php +++ b/Pages/Admin.php @@ -54,8 +54,61 @@ $PageSection = "Admin"; Build mutation recipes with a visual grid. + + + Mass plant editor + + Apply selected plant fields to several plants at once. + + + + + Mass mutation editor + + Apply selected mutation fields to several mutations at once. + + + + + + + diff --git a/Pages/AdminMassMutations.php b/Pages/AdminMassMutations.php new file mode 100644 index 0000000..d01f68f --- /dev/null +++ b/Pages/AdminMassMutations.php @@ -0,0 +1,269 @@ + + + + + + + + + + +
+

Mass mutation editor

+ +

+ Mark the mutations you want to edit, enable the fields to + change, then save. Fields left disabled stay untouched. +

+ +

+ Checking admin access... +

+ + +
+ + + + + + diff --git a/Pages/AdminMassPlants.php b/Pages/AdminMassPlants.php new file mode 100644 index 0000000..01d866e --- /dev/null +++ b/Pages/AdminMassPlants.php @@ -0,0 +1,218 @@ + + + + + + + + + + +
+

Mass plant editor

+ +

+ Mark the plants you want to edit, enable the fields to + change, then save. Fields left disabled stay untouched. +

+ +

+ Checking admin access... +

+ + +
+ + + + + + diff --git a/Pages/AdminMutations.php b/Pages/AdminMutations.php index b35e24e..e07d399 100644 --- a/Pages/AdminMutations.php +++ b/Pages/AdminMutations.php @@ -64,6 +64,31 @@ $PageSection = "Admin"; +
+ + + + + +
+
+ diff --git a/Pages/AdminPlants.php b/Pages/AdminPlants.php index 2ffd345..2377a9b 100644 --- a/Pages/AdminPlants.php +++ b/Pages/AdminPlants.php @@ -64,6 +64,31 @@ $PageSection = "Admin"; +
+ + + + + +
+ + diff --git a/Scripts/Garden/AdminMassMutationEditor.js b/Scripts/Garden/AdminMassMutationEditor.js new file mode 100644 index 0000000..649cf1e --- /dev/null +++ b/Scripts/Garden/AdminMassMutationEditor.js @@ -0,0 +1,787 @@ +let AdminMassMutationCatalogue = {}; +let AdminMassMutationPlantCatalogue = {}; +let AdminMassMutationSavePending = false; + + +async function AdminMassMutationRequest( + Endpoint, + Action, + Data = {} +) { + const SaveKey = + localStorage.getItem( + SaveKeyName + ); + + if ( + typeof SaveKey !== "string" || + !/^[0-9A-Fa-f]{64}$/.test( + SaveKey + ) + ) { + throw new Error( + "A valid Account Key is required." + ); + } + + const Response = await fetch( + ApiUrl + "/" + Endpoint, + { + method: "POST", + headers: { + "Content-Type": + "application/json" + }, + body: JSON.stringify({ + SaveKey: SaveKey, + Action: Action, + ...Data + }) + } + ); + + const Result = + await Response.json(); + + if ( + !Response.ok || + Result.Success !== true + ) { + throw new Error( + Result.Error ?? + "Mutation admin request failed." + ); + } + + return Result; +} + + +function GetSortedAdminMassMutationKeys() { + return Object.keys( + AdminMassMutationCatalogue + ).sort( + (A, B) => + Number( + AdminMassMutationCatalogue[A] + ?.Id ?? 0 + ) - + Number( + AdminMassMutationCatalogue[B] + ?.Id ?? 0 + ) || + A.localeCompare(B) + ); +} + + +function RenderAdminMassMutationSelection() { + const Container = + document.getElementById( + "AdminMassMutationSelection" + ); + + Container.replaceChildren(); + + for ( + const MutationKey + of GetSortedAdminMassMutationKeys() + ) { + const Mutation = + AdminMassMutationCatalogue[ + MutationKey + ]; + + const Label = + document.createElement("label"); + Label.className = + "AdminPlantChecklistItem"; + + const Input = + document.createElement("input"); + Input.type = "checkbox"; + Input.value = MutationKey; + Input.dataset.adminMassTarget = + "mutation"; + + const Text = + document.createElement("span"); + Text.textContent = + String(Mutation.Id).padStart( + 3, + "0" + ) + + " — " + + (Mutation.Name ?? MutationKey) + + ( + Mutation.Archived === true + ? " (archived)" + : "" + ); + + Label.append(Input, Text); + Container.appendChild(Label); + } +} + + +function SetAdminMassMutationSelection( + Checked +) { + for ( + const Input + of document.querySelectorAll( + '[data-admin-mass-target="mutation"]' + ) + ) { + Input.checked = Checked; + } +} + + +function UpdateAdminMassMutationFieldStates() { + const FieldMap = { + Name: ["AdminMassMutationName"], + Description: [ + "AdminMassMutationDescription" + ], + Hint: ["AdminMassMutationHint"], + Priority: [ + "AdminMassMutationPriority" + ], + Chance: ["AdminMassMutationChance"], + Cooldown: [ + "AdminMassMutationCooldownHours", + "AdminMassMutationCooldownMinutes", + "AdminMassMutationCooldownSeconds" + ], + Rotation: [ + "AdminMassMutationRotation" + ], + AllowImmature: [ + "AdminMassMutationAllowImmature" + ], + Pattern: [ + "AdminMassMutationPattern" + ], + Success: [ + "AdminMassMutationSuccess" + ], + Failure: [ + "AdminMassMutationFailure" + ], + PlantsUsed: [ + "AdminMassMutationPlantsUsed" + ], + PlantsCreated: [ + "AdminMassMutationPlantsCreated" + ] + }; + + for ( + const Apply + of document.querySelectorAll( + "[data-admin-mass-apply]" + ) + ) { + const FieldName = + Apply.dataset.adminMassApply; + + for ( + const ElementId + of FieldMap[FieldName] ?? [] + ) { + document.getElementById( + ElementId + ).disabled = !Apply.checked; + } + } +} + + +function AdminMassMutationFieldEnabled( + FieldName +) { + return document.querySelector( + '[data-admin-mass-apply="' + + FieldName + + '"]' + )?.checked === true; +} + + +function GetAdminMassMutationSelectedKeys() { + return [ + ...document.querySelectorAll( + '[data-admin-mass-target="mutation"]:checked' + ) + ].map(Input => Input.value); +} + + +function GetAdminMassMutationCooldown() { + const Hours = Number( + document.getElementById( + "AdminMassMutationCooldownHours" + ).value + ); + const Minutes = Number( + document.getElementById( + "AdminMassMutationCooldownMinutes" + ).value + ); + const Seconds = Number( + document.getElementById( + "AdminMassMutationCooldownSeconds" + ).value + ); + + if ( + !Number.isInteger(Hours) || + !Number.isInteger(Minutes) || + !Number.isInteger(Seconds) || + Hours < 0 || + Minutes < 0 || + Minutes > 59 || + Seconds < 0 || + Seconds > 59 + ) { + throw new Error( + "Cooldown is invalid." + ); + } + + return ( + Hours * 3600000 + + Minutes * 60000 + + Seconds * 1000 + ); +} + + +function ParseAdminMassMutationJson( + ElementId, + Name +) { + const Raw = + document.getElementById( + ElementId + ).value.trim(); + + try { + return JSON.parse(Raw); + } catch (Error) { + throw new Error( + Name + " is not valid JSON." + ); + } +} + + +function ParseAdminMassMutationPlantKeys( + ElementId, + Name +) { + const Keys = [ + ...new Set( + document.getElementById( + ElementId + ).value + .split(",") + .map(Value => Value.trim()) + .filter(Value => Value !== "") + ) + ]; + + return Keys.map( + PlantKey => { + const Plant = + AdminMassMutationPlantCatalogue[ + PlantKey + ]; + + if (Plant === undefined) { + throw new Error( + Name + + " references unknown Plant Key: " + + PlantKey + ); + } + + return Number(Plant.Id); + } + ).sort((A, B) => A - B); +} + + +function GetAdminMassMutationPatch() { + const Patch = {}; + + if (AdminMassMutationFieldEnabled("Name")) { + Patch.Name = + document.getElementById( + "AdminMassMutationName" + ).value.trim(); + } + + if ( + AdminMassMutationFieldEnabled( + "Description" + ) + ) { + Patch.Description = + document.getElementById( + "AdminMassMutationDescription" + ).value.trim(); + } + + if (AdminMassMutationFieldEnabled("Hint")) { + Patch.Hint = + document.getElementById( + "AdminMassMutationHint" + ).value.trim(); + } + + if ( + AdminMassMutationFieldEnabled( + "Priority" + ) + ) { + Patch.Priority = + Number( + document.getElementById( + "AdminMassMutationPriority" + ).value + ); + } + + if ( + AdminMassMutationFieldEnabled( + "Chance" + ) + ) { + Patch.Chance = + Number( + document.getElementById( + "AdminMassMutationChance" + ).value + ) / 100; + } + + if ( + AdminMassMutationFieldEnabled( + "Cooldown" + ) + ) { + Patch.Cooldown = + GetAdminMassMutationCooldown(); + } + + if ( + AdminMassMutationFieldEnabled( + "Rotation" + ) + ) { + Patch.Rotation = + document.getElementById( + "AdminMassMutationRotation" + ).value; + } + + if ( + AdminMassMutationFieldEnabled( + "AllowImmature" + ) + ) { + Patch.AllowImmature = + document.getElementById( + "AdminMassMutationAllowImmature" + ).value === "true"; + } + + if ( + AdminMassMutationFieldEnabled( + "Pattern" + ) + ) { + Patch.Pattern = + ParseAdminMassMutationJson( + "AdminMassMutationPattern", + "Pattern JSON" + ); + } + + if ( + AdminMassMutationFieldEnabled( + "Success" + ) + ) { + Patch.Success = + ParseAdminMassMutationJson( + "AdminMassMutationSuccess", + "Success result JSON" + ); + } + + if ( + AdminMassMutationFieldEnabled( + "Failure" + ) + ) { + Patch.Failure = + ParseAdminMassMutationJson( + "AdminMassMutationFailure", + "Failure result JSON" + ); + } + + if ( + AdminMassMutationFieldEnabled( + "PlantsUsed" + ) + ) { + Patch.PlantsUsed = + ParseAdminMassMutationPlantKeys( + "AdminMassMutationPlantsUsed", + "Plants used" + ); + } + + if ( + AdminMassMutationFieldEnabled( + "PlantsCreated" + ) + ) { + Patch.PlantsCreated = + ParseAdminMassMutationPlantKeys( + "AdminMassMutationPlantsCreated", + "Plants created" + ); + } + + return Patch; +} + + +function BuildAdminMassMutationPayload( + MutationKey, + Patch +) { + const Source = + AdminMassMutationCatalogue[ + MutationKey + ]; + + const Mutation = { + Id: Number(Source.Id), + MutationKey: MutationKey, + Name: Source.Name ?? "", + Description: + Source.Description ?? "", + Hint: Source.Hint ?? "", + Priority: + Number(Source.Priority ?? 0), + Chance: + Number(Source.Chance ?? 0), + Cooldown: + Number(Source.Cooldown ?? 0), + Rotation: + Source.Rotation ?? "None", + AllowImmature: + Source.AllowImmature === true, + Pattern: + JSON.parse( + JSON.stringify( + Source.Pattern ?? [] + ) + ), + Success: + JSON.parse( + JSON.stringify( + Source.Success ?? [] + ) + ), + Failure: + JSON.parse( + JSON.stringify( + Source.Failure ?? "Keep" + ) + ), + Relations: { + PlantsUsed: [ + ...(Source.Relations + ?.PlantsUsed ?? []) + ], + PlantsCreated: [ + ...(Source.Relations + ?.PlantsCreated ?? []) + ] + }, + IsNew: false + }; + + for ( + const [Key, Value] + of Object.entries(Patch) + ) { + if ( + Key === "PlantsUsed" || + Key === "PlantsCreated" + ) { + Mutation.Relations[Key] = [ + ...Value + ]; + continue; + } + + Mutation[Key] = + Value !== null && + typeof Value === "object" + ? JSON.parse( + JSON.stringify(Value) + ) + : Value; + } + + return Mutation; +} + + +async function SaveAdminMassMutations( + Event +) { + Event.preventDefault(); + + if (AdminMassMutationSavePending) { + return; + } + + const SelectedKeys = + GetAdminMassMutationSelectedKeys(); + + if (SelectedKeys.length === 0) { + SetAdminMassMutationMessage( + "Mark at least one mutation first." + ); + return; + } + + let Patch; + + try { + Patch = + GetAdminMassMutationPatch(); + } catch (Error) { + SetAdminMassMutationMessage( + Error.message + ); + return; + } + + if (Object.keys(Patch).length === 0) { + SetAdminMassMutationMessage( + "Enable at least one field to update." + ); + return; + } + + const Payloads = []; + + try { + for ( + const MutationKey + of SelectedKeys + ) { + const Mutation = + BuildAdminMassMutationPayload( + MutationKey, + Patch + ); + + ValidateAdminMutation( + Mutation, + AdminMassMutationCatalogue, + AdminMassMutationPlantCatalogue, + MutationKey + ); + + Payloads.push(Mutation); + } + } catch (Error) { + SetAdminMassMutationMessage( + Error.message + ); + return; + } + + AdminMassMutationSavePending = true; + + const SaveButton = + document.getElementById( + "AdminMassMutationSaveButton" + ); + + SaveButton.disabled = true; + + let SavedCount = 0; + + try { + for (const Mutation of Payloads) { + SetAdminMassMutationMessage( + "Saving " + + (SavedCount + 1) + + " of " + + Payloads.length + + "..." + ); + + await AdminMassMutationRequest( + "AdminMutations.php", + "Save", + {Mutation: Mutation} + ); + + SavedCount++; + } + + localStorage.removeItem( + "SarahtoninGardenContent" + ); + + await LoadAdminMassMutationData(); + + SetAdminMassMutationMessage( + SavedCount === 1 + ? "1 mutation updated." + : SavedCount + + " mutations updated." + ); + } catch (Error) { + SetAdminMassMutationMessage( + "Saved " + + SavedCount + + " before stopping: " + + (Error.message ?? + "Couldn't save mutations.") + ); + } finally { + AdminMassMutationSavePending = false; + SaveButton.disabled = false; + } +} + + +function BindAdminMassMutationEditor() { + document.getElementById( + "AdminMassMutationSelectAll" + ).addEventListener( + "click", + () => + SetAdminMassMutationSelection( + true + ) + ); + + document.getElementById( + "AdminMassMutationClear" + ).addEventListener( + "click", + () => + SetAdminMassMutationSelection( + false + ) + ); + + document.getElementById( + "AdminMassMutationForm" + ).addEventListener( + "submit", + SaveAdminMassMutations + ); + + for ( + const Apply + of document.querySelectorAll( + "[data-admin-mass-apply]" + ) + ) { + Apply.addEventListener( + "change", + UpdateAdminMassMutationFieldStates + ); + } + + UpdateAdminMassMutationFieldStates(); +} + + +async function LoadAdminMassMutationData() { + const [MutationResult, PlantResult] = + await Promise.all([ + AdminMassMutationRequest( + "AdminMutations.php", + "List" + ), + AdminMassMutationRequest( + "AdminPlants.php", + "List" + ) + ]); + + AdminMassMutationCatalogue = + MutationResult.Mutations ?? {}; + AdminMassMutationPlantCatalogue = + PlantResult.Plants ?? {}; + + RenderAdminMassMutationSelection(); +} + + +function SetAdminMassMutationMessage( + Message +) { + document.getElementById( + "AdminMassMutationMessage" + ).textContent = Message; +} + + +async function StartAdminMassMutationEditor() { + try { + const Status = + await GetAdminStatus(); + + if ( + Status.Success !== true || + Status.IsAdmin !== true + ) { + SetAdminMassMutationMessage( + "Admin access required." + ); + return; + } + + BindAdminMassMutationEditor(); + await LoadAdminMassMutationData(); + + SetAdminMassMutationMessage( + "Mark mutations and enable the fields you want to change." + ); + } catch (Error) { + console.error( + "Couldn't start Mass mutation editor:", + Error + ); + + SetAdminMassMutationMessage( + Error.message ?? + "Couldn't load the Mass mutation editor." + ); + } +} + + +document.addEventListener( + "DOMContentLoaded", + StartAdminMassMutationEditor +); diff --git a/Scripts/Garden/AdminMassPlantEditor.js b/Scripts/Garden/AdminMassPlantEditor.js new file mode 100644 index 0000000..6f70d80 --- /dev/null +++ b/Scripts/Garden/AdminMassPlantEditor.js @@ -0,0 +1,645 @@ +let AdminMassPlantCatalogue = {}; +let AdminMassPlantSavePending = false; + + +async function AdminMassPlantRequest( + Action, + Data = {} +) { + const SaveKey = + localStorage.getItem( + SaveKeyName + ); + + if ( + typeof SaveKey !== "string" || + !/^[0-9A-Fa-f]{64}$/.test( + SaveKey + ) + ) { + throw new Error( + "A valid Account Key is required." + ); + } + + const Response = await fetch( + ApiUrl + "/AdminPlants.php", + { + method: "POST", + headers: { + "Content-Type": + "application/json" + }, + body: JSON.stringify({ + SaveKey: SaveKey, + Action: Action, + ...Data + }) + } + ); + + const Result = + await Response.json(); + + if ( + !Response.ok || + Result.Success !== true + ) { + throw new Error( + Result.Error ?? + "Plant admin request failed." + ); + } + + return Result; +} + + +function GetSortedAdminMassPlantKeys() { + return Object.keys( + AdminMassPlantCatalogue + ).sort( + (A, B) => + Number( + AdminMassPlantCatalogue[A] + ?.Id ?? 0 + ) - + Number( + AdminMassPlantCatalogue[B] + ?.Id ?? 0 + ) || + A.localeCompare(B) + ); +} + + +function RenderAdminMassPlantSelection() { + const Container = + document.getElementById( + "AdminMassPlantSelection" + ); + + Container.replaceChildren(); + + for ( + const PlantKey + of GetSortedAdminMassPlantKeys() + ) { + const Plant = + AdminMassPlantCatalogue[ + PlantKey + ]; + + const Label = + document.createElement("label"); + Label.className = + "AdminPlantChecklistItem"; + + const Input = + document.createElement("input"); + Input.type = "checkbox"; + Input.value = PlantKey; + Input.dataset.adminMassTarget = + "plant"; + + const Text = + document.createElement("span"); + Text.textContent = + String(Plant.Id).padStart( + 3, + "0" + ) + + " — " + + (Plant.Name ?? PlantKey) + + ( + Plant.Archived === true + ? " (archived)" + : "" + ); + + Label.append( + Input, + Text + ); + Container.appendChild(Label); + } +} + + +function SetAdminMassPlantSelection( + Checked +) { + for ( + const Input + of document.querySelectorAll( + '[data-admin-mass-target="plant"]' + ) + ) { + Input.checked = Checked; + } +} + + +function UpdateAdminMassPlantFieldStates() { + const FieldMap = { + Name: [ + "AdminMassPlantName" + ], + Description: [ + "AdminMassPlantDescription" + ], + Tags: [ + "AdminMassPlantTags" + ], + GrowthTime: [ + "AdminMassPlantGrowthHours", + "AdminMassPlantGrowthMinutes", + "AdminMassPlantGrowthSeconds" + ], + HarvestMultiplier: [ + "AdminMassPlantHarvestMultiplier" + ], + ShopPlant: [ + "AdminMassPlantShopPlant" + ], + BaseCost: [ + "AdminMassPlantBaseCost" + ], + Effects: [ + "AdminMassPlantEffects" + ] + }; + + for ( + const Apply + of document.querySelectorAll( + "[data-admin-mass-apply]" + ) + ) { + const FieldName = + Apply.dataset.adminMassApply; + + for ( + const ElementId + of FieldMap[FieldName] ?? [] + ) { + document.getElementById( + ElementId + ).disabled = !Apply.checked; + } + } +} + + +function GetAdminMassPlantSelectedKeys() { + return [ + ...document.querySelectorAll( + '[data-admin-mass-target="plant"]:checked' + ) + ].map(Input => Input.value); +} + + +function AdminMassPlantFieldEnabled( + FieldName +) { + return document.querySelector( + '[data-admin-mass-apply="' + + FieldName + + '"]' + )?.checked === true; +} + + +function ParseAdminMassPlantTags() { + return [ + ...new Set( + document.getElementById( + "AdminMassPlantTags" + ).value + .split(",") + .map(Tag => Tag.trim()) + .filter(Tag => Tag !== "") + ) + ]; +} + + +function GetAdminMassPlantGrowthTime() { + const Hours = Number( + document.getElementById( + "AdminMassPlantGrowthHours" + ).value + ); + const Minutes = Number( + document.getElementById( + "AdminMassPlantGrowthMinutes" + ).value + ); + const Seconds = Number( + document.getElementById( + "AdminMassPlantGrowthSeconds" + ).value + ); + + if ( + !Number.isInteger(Hours) || + !Number.isInteger(Minutes) || + !Number.isInteger(Seconds) || + Hours < 0 || + Minutes < 0 || + Minutes > 59 || + Seconds < 0 || + Seconds > 59 + ) { + throw new Error( + "Growth time is invalid." + ); + } + + return ( + Hours * 3600000 + + Minutes * 60000 + + Seconds * 1000 + ); +} + + +function GetAdminMassPlantEffects() { + const Raw = + document.getElementById( + "AdminMassPlantEffects" + ).value.trim(); + + const Effects = JSON.parse( + Raw === "" ? "{}" : Raw + ); + + if (!IsAdminPlainObject(Effects)) { + throw new Error( + "Effects JSON must be an object." + ); + } + + return Effects; +} + + +function GetAdminMassPlantPatch() { + const Patch = {}; + + if (AdminMassPlantFieldEnabled("Name")) { + Patch.Name = + document.getElementById( + "AdminMassPlantName" + ).value.trim(); + } + + if ( + AdminMassPlantFieldEnabled( + "Description" + ) + ) { + Patch.Description = + document.getElementById( + "AdminMassPlantDescription" + ).value.trim(); + } + + if (AdminMassPlantFieldEnabled("Tags")) { + Patch.Tags = + ParseAdminMassPlantTags(); + } + + if ( + AdminMassPlantFieldEnabled( + "GrowthTime" + ) + ) { + Patch.GrowthTime = + GetAdminMassPlantGrowthTime(); + } + + if ( + AdminMassPlantFieldEnabled( + "HarvestMultiplier" + ) + ) { + Patch.HarvestMultiplier = + Number( + document.getElementById( + "AdminMassPlantHarvestMultiplier" + ).value + ); + } + + if ( + AdminMassPlantFieldEnabled( + "ShopPlant" + ) + ) { + Patch.ShopPlant = + document.getElementById( + "AdminMassPlantShopPlant" + ).value === "true"; + } + + if ( + AdminMassPlantFieldEnabled( + "BaseCost" + ) + ) { + Patch.BaseCost = + Number( + document.getElementById( + "AdminMassPlantBaseCost" + ).value + ); + } + + if ( + AdminMassPlantFieldEnabled( + "Effects" + ) + ) { + Patch.Effects = + GetAdminMassPlantEffects(); + } + + return Patch; +} + + +function BuildAdminMassPlantPayload( + PlantKey, + Patch +) { + const Source = + AdminMassPlantCatalogue[ + PlantKey + ]; + + const Plant = { + Id: Number(Source.Id), + PlantKey: PlantKey, + Name: Source.Name ?? "", + Description: + Source.Description ?? "", + Tags: + Array.isArray(Source.Tags) + ? [...Source.Tags] + : [], + GrowthTime: + Number(Source.GrowthTime ?? 0), + HarvestMultiplier: + Number( + Source.HarvestMultiplier ?? + 1.5 + ), + Effects: + JSON.parse( + JSON.stringify( + Source.Effects ?? {} + ) + ), + ShopPlant: + Source.Shop?.ShopPlant === true, + BaseCost: + Source.Shop?.BaseCost ?? null, + IsNew: false + }; + + Object.assign( + Plant, + JSON.parse( + JSON.stringify(Patch) + ) + ); + + return Plant; +} + + +async function SaveAdminMassPlants( + Event +) { + Event.preventDefault(); + + if (AdminMassPlantSavePending) { + return; + } + + const SelectedKeys = + GetAdminMassPlantSelectedKeys(); + + if (SelectedKeys.length === 0) { + SetAdminMassPlantMessage( + "Mark at least one plant first." + ); + return; + } + + let Patch; + + try { + Patch = GetAdminMassPlantPatch(); + } catch (Error) { + SetAdminMassPlantMessage( + Error.message + ); + return; + } + + if (Object.keys(Patch).length === 0) { + SetAdminMassPlantMessage( + "Enable at least one field to update." + ); + return; + } + + const Payloads = []; + + try { + for (const PlantKey of SelectedKeys) { + const Plant = + BuildAdminMassPlantPayload( + PlantKey, + Patch + ); + + ValidateAdminPlant( + Plant, + AdminMassPlantCatalogue, + PlantKey + ); + + Payloads.push(Plant); + } + } catch (Error) { + SetAdminMassPlantMessage( + Error.message + ); + return; + } + + AdminMassPlantSavePending = true; + + const SaveButton = + document.getElementById( + "AdminMassPlantSaveButton" + ); + + SaveButton.disabled = true; + + let SavedCount = 0; + + try { + for (const Plant of Payloads) { + SetAdminMassPlantMessage( + "Saving " + + (SavedCount + 1) + + " of " + + Payloads.length + + "..." + ); + + await AdminMassPlantRequest( + "Save", + {Plant: Plant} + ); + + SavedCount++; + } + + localStorage.removeItem( + "SarahtoninGardenContent" + ); + + await LoadAdminMassPlants(); + + SetAdminMassPlantMessage( + SavedCount === 1 + ? "1 plant updated." + : SavedCount + + " plants updated." + ); + } catch (Error) { + SetAdminMassPlantMessage( + "Saved " + + SavedCount + + " before stopping: " + + (Error.message ?? + "Couldn't save plants.") + ); + } finally { + AdminMassPlantSavePending = false; + SaveButton.disabled = false; + } +} + + +function BindAdminMassPlantEditor() { + document.getElementById( + "AdminMassPlantSelectAll" + ).addEventListener( + "click", + () => + SetAdminMassPlantSelection( + true + ) + ); + + document.getElementById( + "AdminMassPlantClear" + ).addEventListener( + "click", + () => + SetAdminMassPlantSelection( + false + ) + ); + + document.getElementById( + "AdminMassPlantForm" + ).addEventListener( + "submit", + SaveAdminMassPlants + ); + + for ( + const Apply + of document.querySelectorAll( + "[data-admin-mass-apply]" + ) + ) { + Apply.addEventListener( + "change", + UpdateAdminMassPlantFieldStates + ); + } + + UpdateAdminMassPlantFieldStates(); +} + + +async function LoadAdminMassPlants() { + const Result = + await AdminMassPlantRequest( + "List" + ); + + AdminMassPlantCatalogue = + Result.Plants ?? {}; + + RenderAdminMassPlantSelection(); +} + + +function SetAdminMassPlantMessage( + Message +) { + document.getElementById( + "AdminMassPlantMessage" + ).textContent = Message; +} + + +async function StartAdminMassPlantEditor() { + try { + const Status = + await GetAdminStatus(); + + if ( + Status.Success !== true || + Status.IsAdmin !== true + ) { + SetAdminMassPlantMessage( + "Admin access required." + ); + return; + } + + BindAdminMassPlantEditor(); + await LoadAdminMassPlants(); + + SetAdminMassPlantMessage( + "Mark plants and enable the fields you want to change." + ); + } catch (Error) { + console.error( + "Couldn't start Mass plant editor:", + Error + ); + + SetAdminMassPlantMessage( + Error.message ?? + "Couldn't load the Mass plant editor." + ); + } +} + + +document.addEventListener( + "DOMContentLoaded", + StartAdminMassPlantEditor +); diff --git a/Scripts/Garden/AdminMutationEditor.js b/Scripts/Garden/AdminMutationEditor.js index 08e4157..1492298 100644 --- a/Scripts/Garden/AdminMutationEditor.js +++ b/Scripts/Garden/AdminMutationEditor.js @@ -80,6 +80,31 @@ function BindAdminMutationEditor() { DuplicateAdminMutation ); + document.getElementById( + "AdminMutationImportButton" + ).addEventListener( + "click", + () => { + document.getElementById( + "AdminMutationImportFile" + ).click(); + } + ); + + document.getElementById( + "AdminMutationImportFile" + ).addEventListener( + "change", + ImportAdminMutationJson + ); + + document.getElementById( + "AdminMutationExportButton" + ).addEventListener( + "click", + ExportAdminMutationJson + ); + document.getElementById( "AdminMutationResizeButton" ).addEventListener( @@ -2339,6 +2364,21 @@ async function SaveAdminMutation( return; } + try { + ValidateAdminMutation( + Mutation, + AdminMutationCatalogue, + AdminMutationPlantCatalogue, + AdminMutationEditingKey + ); + } catch (Error) { + SetAdminMutationMessage( + Error.message + ); + + return; + } + AdminMutationSavePending = true; const SaveButton = @@ -2419,6 +2459,458 @@ function RenderAdminMutationJsonPreview() { } +function GetAdminMutationPortableRelations( + Relations +) { + const Result = { + PlantsUsed: [], + PlantsCreated: [] + }; + + for ( + const RelationName + of [ + "PlantsUsed", + "PlantsCreated" + ] + ) { + for ( + const PlantId + of Relations?.[ + RelationName + ] ?? [] + ) { + const PlantKey = + GetAdminPlantKeyById( + PlantId, + AdminMutationPlantCatalogue + ); + + if (PlantKey !== null) { + Result[RelationName].push( + PlantKey + ); + } + } + } + + return Result; +} + + +function GetAdminMutationPortableData( + Mutation +) { + return { + MutationKey: + Mutation.MutationKey, + Name: Mutation.Name, + Description: + Mutation.Description, + Hint: + Mutation.Hint === "" + ? null + : Mutation.Hint, + Priority: Mutation.Priority, + Chance: Mutation.Chance, + Cooldown: Mutation.Cooldown, + Rotation: Mutation.Rotation, + AllowImmature: + Mutation.AllowImmature === true, + Pattern: + CloneAdminMutationValue( + Mutation.Pattern + ), + Success: + CloneAdminMutationValue( + Mutation.Success + ), + Failure: + CloneAdminMutationValue( + Mutation.Failure + ), + Relations: + GetAdminMutationPortableRelations( + Mutation.Relations + ) + }; +} + + +function ExportAdminMutationJson() { + const Form = + document.getElementById( + "AdminMutationForm" + ); + + if (!Form.reportValidity()) { + return; + } + + try { + const Mutation = + GetAdminMutationFormData(); + + ValidateAdminMutation( + Mutation, + AdminMutationCatalogue, + AdminMutationPlantCatalogue, + AdminMutationEditingKey + ); + + DownloadAdminJson( + "Mutation-" + + Mutation.MutationKey + + ".json", + { + Type: "Mutation", + Version: 1, + Mutation: + GetAdminMutationPortableData( + Mutation + ) + } + ); + + SetAdminMutationMessage( + "Mutation JSON exported. Numeric IDs are not included." + ); + } catch (Error) { + SetAdminMutationMessage( + Error.message ?? + "Couldn't export mutation JSON." + ); + } +} + + +function ConvertAdminMutationPortableRelations( + Relations +) { + if ( + Relations === undefined || + Relations === null + ) { + return { + PlantsUsed: [], + PlantsCreated: [] + }; + } + + if (!IsAdminPlainObject(Relations)) { + throw new Error( + "Relations must be an object." + ); + } + + const Result = { + PlantsUsed: [], + PlantsCreated: [] + }; + + for ( + const RelationName + of [ + "PlantsUsed", + "PlantsCreated" + ] + ) { + const Values = + Relations[RelationName] ?? []; + + if (!Array.isArray(Values)) { + throw new Error( + RelationName + + " must be an array of Plant Keys." + ); + } + + for (const Value of Values) { + if (typeof Value !== "string") { + throw new Error( + RelationName + + " must use Plant Keys, not numeric IDs." + ); + } + + const Plant = + AdminMutationPlantCatalogue[ + Value + ]; + + if (Plant === undefined) { + throw new Error( + RelationName + + " references unknown Plant Key: " + + Value + ); + } + + Result[RelationName].push( + Number(Plant.Id) + ); + } + + Result[RelationName] = [ + ...new Set( + Result[RelationName] + ) + ].sort( + (A, B) => A - B + ); + } + + return Result; +} + + +async function ImportAdminMutationJson( + Event +) { + const Input = Event.target; + const File = Input.files?.[0]; + + Input.value = ""; + + if (File === undefined) { + return; + } + + try { + const DocumentData = + await ReadAdminJsonFile( + File + ); + + if ( + IsAdminPlainObject( + DocumentData + ) && + DocumentData.Type !== + undefined && + DocumentData.Type !== "Mutation" + ) { + throw new Error( + "That JSON file is not a mutation export." + ); + } + + if ( + IsAdminPlainObject( + DocumentData + ) && + DocumentData.Version !== + undefined && + Number(DocumentData.Version) !== 1 + ) { + throw new Error( + "Unsupported mutation JSON version." + ); + } + + const Imported = + IsAdminPlainObject( + DocumentData?.Mutation + ) + ? DocumentData.Mutation + : DocumentData; + + if (!IsAdminPlainObject(Imported)) { + throw new Error( + "Mutation JSON must contain a mutation object." + ); + } + + const MutationKey = + String( + Imported.MutationKey ?? "" + ).trim(); + + const Existing = + AdminMutationCatalogue[ + MutationKey + ]; + + const Mutation = { + Id: + Existing?.Id ?? + GetNextAdminMutationId(), + MutationKey: MutationKey, + Name: + String( + Imported.Name ?? "" + ).trim(), + Description: + typeof Imported.Description === + "string" + ? Imported.Description.trim() + : "", + Hint: + typeof Imported.Hint === + "string" + ? Imported.Hint.trim() + : "", + Priority: + Number( + Imported.Priority ?? 0 + ), + Chance: + Number( + Imported.Chance + ), + Cooldown: + Number( + Imported.Cooldown + ), + Rotation: + Imported.Rotation ?? + "None", + AllowImmature: + Imported.AllowImmature === + true, + Pattern: + CloneAdminMutationValue( + Imported.Pattern + ), + Success: + CloneAdminMutationValue( + Imported.Success + ), + Failure: + CloneAdminMutationValue( + Imported.Failure ?? + "Keep" + ), + Relations: + ConvertAdminMutationPortableRelations( + Imported.Relations + ), + IsNew: + Existing === undefined + }; + + ValidateAdminMutation( + Mutation, + AdminMutationCatalogue, + AdminMutationPlantCatalogue, + Existing === undefined + ? null + : MutationKey + ); + + if (Existing === undefined) { + StartNewAdminMutation(); + AdminMutationEditingKey = null; + } else { + LoadAdminMutationIntoForm( + MutationKey + ); + } + + SetAdminMutationField( + "AdminMutationId", + Mutation.Id + ); + SetAdminMutationField( + "AdminMutationKey", + Mutation.MutationKey + ); + SetAdminMutationField( + "AdminMutationName", + Mutation.Name + ); + SetAdminMutationField( + "AdminMutationDescription", + Mutation.Description + ); + SetAdminMutationField( + "AdminMutationHint", + Mutation.Hint + ); + SetAdminMutationField( + "AdminMutationPriority", + Mutation.Priority + ); + SetAdminMutationField( + "AdminMutationChance", + Mutation.Chance * 100 + ); + SetAdminMutationDurationFields( + Mutation.Cooldown + ); + SetAdminMutationField( + "AdminMutationRotation", + Mutation.Rotation + ); + + document.getElementById( + "AdminMutationAllowImmature" + ).checked = + Mutation.AllowImmature; + + AdminMutationPattern = + CloneAdminMutationMatrix( + Mutation.Pattern, + "Any" + ); + AdminMutationResult = + NormalizeAdminMutationMatrix( + Mutation.Success, + AdminMutationPattern[0] + .length, + AdminMutationPattern.length, + "Keep" + ); + + SetAdminMutationField( + "AdminMutationWidth", + AdminMutationPattern[0] + .length + ); + SetAdminMutationField( + "AdminMutationHeight", + AdminMutationPattern.length + ); + + LoadAdminMutationFailure( + Mutation.Failure + ); + SetAdminMutationRelations( + Mutation.Relations + ); + + document.getElementById( + "AdminMutationId" + ).readOnly = + Existing !== undefined; + document.getElementById( + "AdminMutationKey" + ).readOnly = + Existing !== undefined; + + AdminMutationSelectedCell = null; + HideAdminMutationCellEditor(); + RenderAdminMutationGrids(); + UpdateAdminMutationFailureVisibility(); + UpdateAdminMutationCooldownHint(); + RenderAdminMutationJsonPreview(); + + SetAdminMutationMessage( + Existing === undefined + ? "Mutation JSON imported as a new mutation. Review it, then save when ready." + : "Mutation JSON imported over " + + MutationKey + + ". Review it, then save when ready." + ); + } catch (Error) { + SetAdminMutationMessage( + Error.message ?? + "Couldn't import mutation JSON." + ); + } +} + + function SetAdminMutationDurationFields( Milliseconds ) { diff --git a/Scripts/Garden/AdminOverview.js b/Scripts/Garden/AdminOverview.js new file mode 100644 index 0000000..f2ef816 --- /dev/null +++ b/Scripts/Garden/AdminOverview.js @@ -0,0 +1,485 @@ +let AdminOverviewPlants = {}; +let AdminOverviewMutations = {}; + + +async function AdminOverviewRequest( + Endpoint, + Action = "List" +) { + const SaveKey = + GetStoredAdminSaveKey(); + + if (SaveKey === null) { + throw new Error( + "A valid Account Key is required." + ); + } + + const Response = await fetch( + AdminApiUrl + "/" + Endpoint, + { + method: "POST", + headers: { + "Content-Type": + "application/json" + }, + body: JSON.stringify({ + SaveKey: SaveKey, + Action: Action + }) + } + ); + + const Result = + await Response.json(); + + if ( + !Response.ok || + Result.Success !== true + ) { + throw new Error( + Result.Error ?? + "Admin overview request failed." + ); + } + + return Result; +} + + +function GetAdminOverviewPlantKeyById( + PlantId +) { + return Object.entries( + AdminOverviewPlants + ).find( + ([, Plant]) => + Number(Plant.Id) === + Number(PlantId) + )?.[0] ?? null; +} + + +function GetAdminOverviewMissingSprites( + PlantKey, + Plant +) { + const StaticImages = + Array.isArray( + PlantImages?.[PlantKey] + ) + ? PlantImages[PlantKey] + : []; + + const ApiStages = + new Set( + ( + Array.isArray( + Plant.ImageStages + ) + ? Plant.ImageStages + : [] + ) + .map( + Stage => + Number( + Stage?.Stage ?? + Stage + ) + ) + .filter( + Stage => + Number.isInteger(Stage) && + Stage > 0 + ) + ); + + const HighestApiStage = + Math.max( + 0, + ...ApiStages + ); + + const HighestStage = + Math.max( + 3, + StaticImages.length, + HighestApiStage + ); + + const Missing = []; + + for ( + let Stage = 1; + Stage <= HighestStage; + Stage++ + ) { + if ( + ApiStages.has(Stage) || + typeof StaticImages[ + Stage - 1 + ] === "string" + ) { + continue; + } + + Missing.push( + Stage + ".png" + ); + } + + return Missing; +} + + +function CollectAdminOverviewCreatedPlants() { + const Created = new Set(); + + function AddResultValue(Value) { + if (typeof Value === "string") { + if ( + Value !== "Keep" && + Value !== "Empty" && + !Value.startsWith("$") && + AdminOverviewPlants[ + Value + ] !== undefined + ) { + Created.add(Value); + } + + return; + } + + if ( + Value !== null && + typeof Value === "object" && + !Array.isArray(Value) && + typeof Value.Plant === "string" + ) { + AddResultValue( + Value.Plant + ); + } + } + + for ( + const Mutation + of Object.values( + AdminOverviewMutations + ) + ) { + if (Mutation.Archived === true) { + continue; + } + + for ( + const PlantId + of Mutation.Relations + ?.PlantsCreated ?? [] + ) { + const PlantKey = + GetAdminOverviewPlantKeyById( + PlantId + ); + + if (PlantKey !== null) { + Created.add(PlantKey); + } + } + + for ( + const Row + of Mutation.Success ?? [] + ) { + if (!Array.isArray(Row)) { + continue; + } + + for (const Cell of Row) { + AddResultValue(Cell); + } + } + } + + return Created; +} + + +function CreateAdminOverviewIssueList( + Items, + NestedValues = null +) { + const List = + document.createElement("ul"); + + List.className = + "AdminOverviewList"; + + for (const Item of Items) { + const ListItem = + document.createElement("li"); + + const Label = + document.createElement("span"); + + Label.textContent = Item.Label; + ListItem.appendChild(Label); + + const Nested = + NestedValues?.(Item) ?? []; + + if (Nested.length > 0) { + const NestedList = + document.createElement("ul"); + + for (const Value of Nested) { + const NestedItem = + document.createElement("li"); + + NestedItem.textContent = + Value; + NestedList.appendChild( + NestedItem + ); + } + + ListItem.appendChild( + NestedList + ); + } + + List.appendChild(ListItem); + } + + return List; +} + + +function RenderAdminOverviewSection( + ContainerId, + Title, + Items, + NestedValues = null +) { + const Container = + document.getElementById( + ContainerId + ); + + Container.replaceChildren(); + + const Heading = + document.createElement("h3"); + + Heading.textContent = Title; + Container.appendChild(Heading); + + if (Items.length === 0) { + const Empty = + document.createElement("p"); + + Empty.className = + "AdminOverviewEmpty"; + Empty.textContent = "None"; + Container.appendChild(Empty); + return; + } + + Container.appendChild( + CreateAdminOverviewIssueList( + Items, + NestedValues + ) + ); +} + + +function RenderAdminOverview() { + const CreatedPlants = + CollectAdminOverviewCreatedPlants(); + + const PlantEntries = + Object.entries( + AdminOverviewPlants + ) + .filter( + ([, Plant]) => + Plant.Archived !== true + ) + .sort( + ([KeyA, PlantA], [KeyB, PlantB]) => + Number(PlantA.Id) - + Number(PlantB.Id) || + KeyA.localeCompare(KeyB) + ); + + const MissingSprites = []; + const NoWaysToObtain = []; + + for ( + const [PlantKey, Plant] + of PlantEntries + ) { + const Missing = + GetAdminOverviewMissingSprites( + PlantKey, + Plant + ); + + if (Missing.length > 0) { + MissingSprites.push({ + Label: + Plant.Name ?? PlantKey, + Missing: Missing + }); + } + + if ( + Plant.Shop?.ShopPlant !== true && + !CreatedPlants.has(PlantKey) + ) { + NoWaysToObtain.push({ + Label: + Plant.Name ?? PlantKey + }); + } + } + + const MutationEntries = + Object.entries( + AdminOverviewMutations + ) + .filter( + ([, Mutation]) => + Mutation.Archived !== true + ) + .sort( + ([KeyA, MutationA], [KeyB, MutationB]) => + Number(MutationA.Id) - + Number(MutationB.Id) || + KeyA.localeCompare(KeyB) + ); + + const NoDescription = []; + const NoHint = []; + + for ( + const [MutationKey, Mutation] + of MutationEntries + ) { + const Label = + Mutation.Name?.trim() || + MutationKey; + + if ( + typeof Mutation.Description !== + "string" || + Mutation.Description.trim() === "" + ) { + NoDescription.push({ + Label: Label + }); + } + + if ( + typeof Mutation.Hint !== + "string" || + Mutation.Hint.trim() === "" + ) { + NoHint.push({ + Label: Label + }); + } + } + + RenderAdminOverviewSection( + "AdminOverviewMissingSprites", + "Missing sprites", + MissingSprites, + Item => Item.Missing + ); + + RenderAdminOverviewSection( + "AdminOverviewNoObtain", + "No ways to obtain", + NoWaysToObtain + ); + + RenderAdminOverviewSection( + "AdminOverviewNoDescription", + "No description", + NoDescription + ); + + RenderAdminOverviewSection( + "AdminOverviewNoHint", + "No hint", + NoHint + ); +} + + +async function StartAdminOverview() { + const Overview = + document.getElementById( + "AdminOverview" + ); + + if (Overview === null) { + return; + } + + try { + const Status = + await GetAdminStatus(); + + if ( + Status.Success !== true || + Status.IsAdmin !== true + ) { + return; + } + + const [ + PlantResult, + MutationResult + ] = await Promise.all([ + AdminOverviewRequest( + "AdminPlants.php" + ), + AdminOverviewRequest( + "AdminMutations.php" + ) + ]); + + AdminOverviewPlants = + PlantResult.Plants ?? {}; + AdminOverviewMutations = + MutationResult.Mutations ?? {}; + + RenderAdminOverview(); + Overview.hidden = false; + } catch (Error) { + console.error( + "Couldn't load admin overview:", + Error + ); + + const Message = + document.getElementById( + "AdminOverviewMessage" + ); + + Message.textContent = + Error.message ?? + "Couldn't load admin overview."; + Overview.hidden = false; + } +} + + +document.addEventListener( + "DOMContentLoaded", + StartAdminOverview +); diff --git a/Scripts/Garden/AdminPlantEditor.js b/Scripts/Garden/AdminPlantEditor.js index 8489604..478eb65 100644 --- a/Scripts/Garden/AdminPlantEditor.js +++ b/Scripts/Garden/AdminPlantEditor.js @@ -71,6 +71,31 @@ function BindAdminPlantEditor() { DuplicateAdminPlant ); + document.getElementById( + "AdminPlantImportButton" + ).addEventListener( + "click", + () => { + document.getElementById( + "AdminPlantImportFile" + ).click(); + } + ); + + document.getElementById( + "AdminPlantImportFile" + ).addEventListener( + "change", + ImportAdminPlantJson + ); + + document.getElementById( + "AdminPlantExportButton" + ).addEventListener( + "click", + ExportAdminPlantJson + ); + document.getElementById( "AdminPlantForm" ).addEventListener( @@ -930,6 +955,20 @@ async function SaveAdminPlant( return; } + try { + ValidateAdminPlant( + Plant, + AdminPlantCatalogue, + AdminPlantEditingKey + ); + } catch (Error) { + SetAdminPlantMessage( + Error.message + ); + + return; + } + AdminPlantSavePending = true; const SaveButton = @@ -1833,6 +1872,297 @@ async function DeleteAdminPlantImage( } +function GetAdminPlantPortableData( + Plant +) { + return { + PlantKey: Plant.PlantKey, + Name: Plant.Name, + Description: Plant.Description, + Tags: [...Plant.Tags], + GrowthTime: Plant.GrowthTime, + HarvestMultiplier: + Plant.HarvestMultiplier, + Effects: JSON.parse( + JSON.stringify( + Plant.Effects ?? {} + ) + ), + Shop: { + ShopPlant: + Plant.ShopPlant === true, + BaseCost: + Plant.ShopPlant === true + ? Plant.BaseCost + : null + } + }; +} + + +function ExportAdminPlantJson() { + const Form = + document.getElementById( + "AdminPlantForm" + ); + + if (!Form.reportValidity()) { + return; + } + + try { + const Plant = + GetAdminPlantFormData(); + + ValidateAdminPlant( + Plant, + AdminPlantCatalogue, + AdminPlantEditingKey + ); + + DownloadAdminJson( + "Plant-" + + Plant.PlantKey + + ".json", + { + Type: "Plant", + Version: 1, + Plant: + GetAdminPlantPortableData( + Plant + ) + } + ); + + SetAdminPlantMessage( + "Plant JSON exported. Sprites and numeric ID are not included." + ); + } catch (Error) { + SetAdminPlantMessage( + Error.message ?? + "Couldn't export plant JSON." + ); + } +} + + +async function ImportAdminPlantJson( + Event +) { + const Input = Event.target; + const File = Input.files?.[0]; + + Input.value = ""; + + if (File === undefined) { + return; + } + + try { + const DocumentData = + await ReadAdminJsonFile( + File + ); + + if ( + IsAdminPlainObject( + DocumentData + ) && + DocumentData.Type !== + undefined && + DocumentData.Type !== "Plant" + ) { + throw new Error( + "That JSON file is not a plant export." + ); + } + + if ( + IsAdminPlainObject( + DocumentData + ) && + DocumentData.Version !== + undefined && + Number(DocumentData.Version) !== 1 + ) { + throw new Error( + "Unsupported plant JSON version." + ); + } + + const Imported = + IsAdminPlainObject( + DocumentData?.Plant + ) + ? DocumentData.Plant + : DocumentData; + + if (!IsAdminPlainObject(Imported)) { + throw new Error( + "Plant JSON must contain a plant object." + ); + } + + const PlantKey = + String( + Imported.PlantKey ?? "" + ).trim(); + + const Existing = + AdminPlantCatalogue[ + PlantKey + ]; + + const Shop = + IsAdminPlainObject( + Imported.Shop + ) + ? Imported.Shop + : {}; + + const Plant = { + Id: + Existing?.Id ?? + GetNextAdminPlantId(), + PlantKey: PlantKey, + Name: + String( + Imported.Name ?? "" + ).trim(), + Description: + typeof Imported.Description === + "string" + ? Imported.Description.trim() + : "", + Tags: + Array.isArray(Imported.Tags) + ? Imported.Tags.map( + Tag => + String(Tag).trim() + ) + : [], + GrowthTime: + Number( + Imported.GrowthTime + ), + HarvestMultiplier: + Number( + Imported.HarvestMultiplier ?? + 1.5 + ), + Effects: + IsAdminPlainObject( + Imported.Effects + ) + ? JSON.parse( + JSON.stringify( + Imported.Effects + ) + ) + : {}, + ShopPlant: + Shop.ShopPlant === true || + Imported.ShopPlant === true, + BaseCost: + Shop.BaseCost ?? + Imported.BaseCost ?? + null, + IsNew: + Existing === undefined + }; + + ValidateAdminPlant( + Plant, + AdminPlantCatalogue, + Existing === undefined + ? null + : PlantKey + ); + + if (Existing === undefined) { + StartNewAdminPlant(); + AdminPlantEditingKey = null; + } else { + LoadAdminPlantIntoForm( + PlantKey + ); + } + + SetAdminPlantField( + "AdminPlantId", + Plant.Id + ); + SetAdminPlantField( + "AdminPlantKey", + Plant.PlantKey + ); + SetAdminPlantField( + "AdminPlantName", + Plant.Name + ); + SetAdminPlantField( + "AdminPlantDescription", + Plant.Description + ); + SetAdminPlantField( + "AdminPlantTags", + Plant.Tags.join(", ") + ); + SetAdminPlantDurationFields( + Plant.GrowthTime + ); + SetAdminPlantField( + "AdminPlantHarvestMultiplier", + Plant.HarvestMultiplier + ); + + document.getElementById( + "AdminPlantShopPlant" + ).checked = Plant.ShopPlant; + + SetAdminPlantField( + "AdminPlantBaseCost", + Plant.BaseCost ?? "" + ); + SetAdminPlantField( + "AdminPlantEffects", + JSON.stringify( + Plant.Effects, + null, + 4 + ) + ); + + document.getElementById( + "AdminPlantId" + ).readOnly = + Existing !== undefined; + + document.getElementById( + "AdminPlantKey" + ).readOnly = + Existing !== undefined; + + UpdateAdminPlantShopFields(); + RenderAdminPlantPreview(); + RenderAdminPlantImageManager(); + + SetAdminPlantMessage( + Existing === undefined + ? "Plant JSON imported as a new plant. Review it, then save when ready." + : "Plant JSON imported over " + + PlantKey + + ". Review it, then save when ready." + ); + } catch (Error) { + SetAdminPlantMessage( + Error.message ?? + "Couldn't import plant JSON." + ); + } +} + + function FormatAdminDuration( Milliseconds ) { diff --git a/Scripts/Garden/AdminValidation.js b/Scripts/Garden/AdminValidation.js new file mode 100644 index 0000000..88b512f --- /dev/null +++ b/Scripts/Garden/AdminValidation.js @@ -0,0 +1,820 @@ +function IsAdminPlainObject(Value) { + return ( + Value !== null && + typeof Value === "object" && + !Array.isArray(Value) + ); +} + + +function ValidateAdminInteger( + Value, + Name, + Minimum = null +) { + if ( + !Number.isInteger(Value) || + ( + Minimum !== null && + Value < Minimum + ) + ) { + throw new Error( + Name + " must be " + + ( + Minimum === null + ? "an integer." + : "an integer of at least " + + Minimum + "." + ) + ); + } +} + + +function ValidateAdminKey( + Value, + Name, + MaximumLength = 64 +) { + if ( + typeof Value !== "string" || + Value.length === 0 + ) { + throw new Error( + Name + " cannot be empty." + ); + } + + if ( + Value.length > MaximumLength || + !/^[A-Za-z0-9_-]+$/.test(Value) + ) { + throw new Error( + Name + + " may only contain letters, numbers, underscores and hyphens." + ); + } +} + + +function ValidateAdminPlant( + Plant, + Catalogue, + EditingKey = null +) { + if (!IsAdminPlainObject(Plant)) { + throw new Error( + "Plant data is invalid." + ); + } + + ValidateAdminInteger( + Number(Plant.Id), + "ID", + 1 + ); + + const PlantKey = + String( + Plant.PlantKey ?? "" + ).trim(); + + ValidateAdminKey( + PlantKey, + "Plant Key" + ); + + const ExistingAtId = + Object.entries( + Catalogue ?? {} + ).find( + ([, Candidate]) => + Number(Candidate?.Id) === + Number(Plant.Id) + ); + + if ( + ExistingAtId !== undefined && + ExistingAtId[0] !== PlantKey + ) { + throw new Error( + "ID " + Plant.Id + + " already belongs to " + + ExistingAtId[0] + "." + ); + } + + if ( + EditingKey !== null && + EditingKey !== PlantKey + ) { + throw new Error( + "An existing Plant Key cannot be changed." + ); + } + + if ( + EditingKey !== null && + Number( + Catalogue?.[EditingKey]?.Id + ) !== Number(Plant.Id) + ) { + throw new Error( + "An existing plant ID cannot be changed." + ); + } + + if ( + EditingKey === null && + Catalogue?.[PlantKey] !== undefined + ) { + throw new Error( + "Plant Key " + PlantKey + + " already exists." + ); + } + + const Name = + String( + Plant.Name ?? "" + ).trim(); + + if ( + Name.length === 0 || + Name.length > 64 + ) { + throw new Error( + "Name must contain between 1 and 64 characters." + ); + } + + if ( + typeof Plant.Description !== + "string" + ) { + throw new Error( + "Description must be text." + ); + } + + if (!Array.isArray(Plant.Tags)) { + throw new Error( + "Tags must be an array." + ); + } + + for (const Tag of Plant.Tags) { + if ( + typeof Tag !== "string" || + Tag.trim().length === 0 || + Tag.trim().length > 64 + ) { + throw new Error( + "Tags contains an invalid tag." + ); + } + } + + ValidateAdminInteger( + Number(Plant.GrowthTime), + "Growth time", + 0 + ); + + const HarvestMultiplier = + Number( + Plant.HarvestMultiplier + ); + + if ( + !Number.isFinite( + HarvestMultiplier + ) || + HarvestMultiplier < 0 || + HarvestMultiplier > 9999.9999 + ) { + throw new Error( + "Harvest multiplier must be between 0 and 9999.9999." + ); + } + + if (!IsAdminPlainObject(Plant.Effects)) { + throw new Error( + "Effects JSON must be an object." + ); + } + + if (Plant.ShopPlant === true) { + ValidateAdminInteger( + Number(Plant.BaseCost), + "Base seed cost", + 0 + ); + } + + return true; +} + + +function GetAdminPlantKeyById( + PlantId, + PlantCatalogue +) { + return Object.entries( + PlantCatalogue ?? {} + ).find( + ([, Plant]) => + Number(Plant?.Id) === + Number(PlantId) + )?.[0] ?? null; +} + + +function ValidateAdminMutationMatrix( + Matrix, + Name, + Width = null, + Height = null +) { + if ( + !Array.isArray(Matrix) || + Matrix.length < 1 || + Matrix.length > 7 + ) { + throw new Error( + Name + + " must contain between 1 and 7 rows." + ); + } + + const MatrixWidth = + Array.isArray(Matrix[0]) + ? Matrix[0].length + : 0; + + if ( + MatrixWidth < 1 || + MatrixWidth > 7 + ) { + throw new Error( + Name + + " rows must contain between 1 and 7 cells." + ); + } + + for (const Row of Matrix) { + if ( + !Array.isArray(Row) || + Row.length !== MatrixWidth + ) { + throw new Error( + Name + + " must be rectangular." + ); + } + } + + if ( + Width !== null && + MatrixWidth !== Width + ) { + throw new Error( + Name + + " width must match the pattern." + ); + } + + if ( + Height !== null && + Matrix.length !== Height + ) { + throw new Error( + Name + + " height must match the pattern." + ); + } + + return { + Width: MatrixWidth, + Height: Matrix.length + }; +} + + +function ValidateAdminMutationTagList( + Value, + Name +) { + if (!Array.isArray(Value)) { + throw new Error( + Name + " must be an array." + ); + } + + for (const Tag of Value) { + if ( + typeof Tag !== "string" || + Tag.trim().length === 0 || + Tag.trim().length > 64 + ) { + throw new Error( + Name + + " contains an invalid tag." + ); + } + } +} + + +function ValidateAdminCaptureName( + Capture +) { + if ( + typeof Capture !== "string" || + !/^[A-Za-z][A-Za-z0-9_]*$/.test( + Capture + ) + ) { + throw new Error( + "Capture names must start with a letter and contain only letters, numbers and underscores." + ); + } +} + + +function ValidateAdminMutationPatternCell( + Cell, + PlantCatalogue, + Captures +) { + if ( + Cell === null || + Cell === "Any" || + Cell === "Empty" + ) { + return; + } + + if (typeof Cell === "string") { + if ( + PlantCatalogue?.[Cell] === + undefined + ) { + throw new Error( + "Pattern references unknown plant: " + + Cell + ); + } + + return; + } + + if (!IsAdminPlainObject(Cell)) { + throw new Error( + "Pattern contains an invalid matcher." + ); + } + + if ( + Cell.Plant !== undefined && + ( + typeof Cell.Plant !== + "string" || + PlantCatalogue?.[ + Cell.Plant + ] === undefined + ) + ) { + throw new Error( + "Pattern matcher references an unknown plant." + ); + } + + for ( + const TagField + of [ + "Tags", + "TagsAny", + "TagsNot" + ] + ) { + if (Cell[TagField] !== undefined) { + ValidateAdminMutationTagList( + Cell[TagField], + TagField + ); + } + } + + if (Cell.Capture !== undefined) { + ValidateAdminCaptureName( + Cell.Capture + ); + + Captures.add( + Cell.Capture + ); + } +} + + +function ValidateAdminMutationResultCell( + Cell, + PlantCatalogue, + Captures +) { + if ( + Cell === null || + Cell === "Keep" || + Cell === "Empty" + ) { + return; + } + + let PlantValue = null; + + if (typeof Cell === "string") { + PlantValue = Cell; + } else if ( + IsAdminPlainObject(Cell) && + typeof Cell.Plant === "string" + ) { + PlantValue = Cell.Plant; + } + + if (PlantValue === null) { + throw new Error( + "Result contains an invalid value." + ); + } + + if (PlantValue.startsWith("$")) { + const Capture = + PlantValue.slice(1); + + if (!Captures.has(Capture)) { + throw new Error( + "Result references unknown capture: " + + Capture + ); + } + + return; + } + + if ( + PlantCatalogue?.[ + PlantValue + ] === undefined + ) { + throw new Error( + "Result references unknown plant: " + + PlantValue + ); + } +} + + +function ValidateAdminMutationRelations( + Relations, + PlantCatalogue +) { + if (!IsAdminPlainObject(Relations)) { + throw new Error( + "Relations must be an object." + ); + } + + const ValidIds = + new Set( + Object.values( + PlantCatalogue ?? {} + ).map( + Plant => + Number(Plant.Id) + ) + ); + + for ( + const Name + of [ + "PlantsUsed", + "PlantsCreated" + ] + ) { + const Values = + Relations[Name] ?? []; + + if (!Array.isArray(Values)) { + throw new Error( + Name + " must be an array." + ); + } + + for (const PlantId of Values) { + if ( + !Number.isInteger( + Number(PlantId) + ) || + !ValidIds.has( + Number(PlantId) + ) + ) { + throw new Error( + Name + + " references a plant that does not exist." + ); + } + } + } +} + + +function ValidateAdminMutation( + Mutation, + MutationCatalogue, + PlantCatalogue, + EditingKey = null +) { + if (!IsAdminPlainObject(Mutation)) { + throw new Error( + "Mutation data is invalid." + ); + } + + ValidateAdminInteger( + Number(Mutation.Id), + "ID", + 1 + ); + + const MutationKey = + String( + Mutation.MutationKey ?? "" + ).trim(); + + ValidateAdminKey( + MutationKey, + "Mutation Key" + ); + + const ExistingAtId = + Object.entries( + MutationCatalogue ?? {} + ).find( + ([, Candidate]) => + Number(Candidate?.Id) === + Number(Mutation.Id) + ); + + if ( + ExistingAtId !== undefined && + ExistingAtId[0] !== MutationKey + ) { + throw new Error( + "ID " + Mutation.Id + + " already belongs to " + + ExistingAtId[0] + "." + ); + } + + if ( + EditingKey !== null && + EditingKey !== MutationKey + ) { + throw new Error( + "An existing Mutation Key cannot be changed." + ); + } + + if ( + EditingKey !== null && + Number( + MutationCatalogue + ?.[EditingKey]?.Id + ) !== Number(Mutation.Id) + ) { + throw new Error( + "An existing mutation ID cannot be changed." + ); + } + + if ( + EditingKey === null && + MutationCatalogue?.[ + MutationKey + ] !== undefined + ) { + throw new Error( + "Mutation Key " + + MutationKey + + " already exists." + ); + } + + const Name = + String( + Mutation.Name ?? "" + ).trim(); + + if ( + Name.length === 0 || + Name.length > 128 + ) { + throw new Error( + "Name must contain between 1 and 128 characters." + ); + } + + if ( + typeof Mutation.Description !== + "string" + ) { + throw new Error( + "Description must be text." + ); + } + + if ( + Mutation.Hint !== null && + Mutation.Hint !== undefined && + typeof Mutation.Hint !== "string" + ) { + throw new Error( + "Hint must be text." + ); + } + + ValidateAdminInteger( + Number(Mutation.Priority), + "Priority" + ); + + const Chance = + Number( + Mutation.Chance + ); + + if ( + !Number.isFinite(Chance) || + Chance < 0 || + Chance > 1 + ) { + throw new Error( + "Chance must be between 0 and 100%." + ); + } + + ValidateAdminInteger( + Number(Mutation.Cooldown), + "Cooldown", + 0 + ); + + if ( + Mutation.Rotation !== "None" && + Mutation.Rotation !== "Any" + ) { + throw new Error( + "Rotation must be None or Any." + ); + } + + const PatternSize = + ValidateAdminMutationMatrix( + Mutation.Pattern, + "Pattern" + ); + + const Captures = + new Set(); + + for (const Row of Mutation.Pattern) { + for (const Cell of Row) { + ValidateAdminMutationPatternCell( + Cell, + PlantCatalogue, + Captures + ); + } + } + + ValidateAdminMutationMatrix( + Mutation.Success, + "Success result", + PatternSize.Width, + PatternSize.Height + ); + + for (const Row of Mutation.Success) { + for (const Cell of Row) { + ValidateAdminMutationResultCell( + Cell, + PlantCatalogue, + Captures + ); + } + } + + if ( + Mutation.Failure !== "Keep" && + Mutation.Failure !== "Clear" + ) { + ValidateAdminMutationMatrix( + Mutation.Failure, + "Failure result", + PatternSize.Width, + PatternSize.Height + ); + + for (const Row of Mutation.Failure) { + for (const Cell of Row) { + ValidateAdminMutationResultCell( + Cell, + PlantCatalogue, + Captures + ); + } + } + } + + ValidateAdminMutationRelations( + Mutation.Relations ?? {}, + PlantCatalogue + ); + + return true; +} + + +function DownloadAdminJson( + FileName, + Data +) { + const BlobData = + new Blob( + [ + JSON.stringify( + Data, + null, + 4 + ) + "\n" + ], + { + type: "application/json" + } + ); + + const Url = + URL.createObjectURL( + BlobData + ); + + const Link = + document.createElement("a"); + + Link.href = Url; + Link.download = FileName; + Link.click(); + + URL.revokeObjectURL(Url); +} + + +function ReadAdminJsonFile(File) { + return new Promise( + (Resolve, Reject) => { + const Reader = + new FileReader(); + + Reader.onload = () => { + try { + Resolve( + JSON.parse( + String( + Reader.result ?? "" + ) + ) + ); + } catch (Error) { + Reject( + new Error( + "The selected file is not valid JSON." + ) + ); + } + }; + + Reader.onerror = () => { + Reject( + new Error( + "Couldn't read the selected JSON file." + ) + ); + }; + + Reader.readAsText(File); + } + ); +} diff --git a/Styles/Style.css b/Styles/Style.css index f4497d4..3ee8ebc 100644 --- a/Styles/Style.css +++ b/Styles/Style.css @@ -2343,3 +2343,88 @@ input, button, textarea, select { width: 100%; } } + +.AdminEditorTransferActions { + margin: 0 0 1rem; +} + +.AdminOverview, +.AdminMassSelectionPanel, +.AdminMassEditorForm { + margin-top: 1.5rem; +} + +.AdminOverviewGroups { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + padding: 1rem; +} + +.AdminOverviewGroups > section > h2 { + margin-bottom: 0.75rem; + font-size: 1.4rem; +} + +.AdminOverviewGroups h3 { + margin: 1rem 0 0.35rem; + font-size: 1.1rem; +} + +.AdminOverviewGroups h3:first-child { + margin-top: 0; +} + +.AdminOverviewList, +.AdminOverviewList ul { + margin: 0; + padding-left: 1.25rem; +} + +.AdminOverviewList ul { + color: var(--Subtext1); +} + +.AdminOverviewEmpty { + margin: 0; + color: var(--Overlay2); +} + +.AdminOverview > .PageMessage:empty { + display: none; +} + +.AdminMassSelectionBody { + padding: 1rem; +} + +.AdminMassChecklist { + max-height: 20rem; +} + +.AdminMassField { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.75rem; + align-items: start; +} + +.AdminMassApplyField { + align-self: start; + margin-top: 1.55rem; +} + +.AdminMassField :disabled { + opacity: 0.55; +} + +@media (max-width: 700px) { + .AdminOverviewGroups, + .AdminMassField { + grid-template-columns: 1fr; + } + + .AdminMassApplyField { + margin-top: 0; + } +}