diff --git a/Pages/Garden.php b/Pages/Garden.php index d9201e4..300d25e 100644 --- a/Pages/Garden.php +++ b/Pages/Garden.php @@ -56,6 +56,20 @@ $SubNavbarCurrent = "Garden"; id="SeedList" class="SeedList" > + +

+ Tools +

+ +
+ +
@@ -134,6 +148,7 @@ $SubNavbarCurrent = "Garden"; + diff --git a/Scripts/Garden/Economy.js b/Scripts/Garden/Economy.js index 599076e..37a1bc5 100644 --- a/Scripts/Garden/Economy.js +++ b/Scripts/Garden/Economy.js @@ -356,7 +356,8 @@ function GetMutationIngredientCost( ) { if ( Matcher === null || - Matcher === "Empty" + Matcher === "Empty" || + Matcher === "Any" ) { continue; } diff --git a/Scripts/Garden/Garden.js b/Scripts/Garden/Garden.js index ce14d30..77c93a9 100644 --- a/Scripts/Garden/Garden.js +++ b/Scripts/Garden/Garden.js @@ -1,5 +1,8 @@ let GameSave; let SelectedSeedId = null; +let SelectedGardenTool = "Seed"; + +let MutationCheckPending = false; const GardenUpdateInterval = 1000; @@ -7,14 +10,24 @@ const GardenUpdateInterval = 1000; async function StartGame() { GameSave = await LoadGame(); + BindGardenTools(); EnsureSelectedSeed(); + + const InitialMutationResult = + CheckGardenMutations( + GameSave + ); + RenderGame(); + if (InitialMutationResult.Attempted) { + await SaveGame( + GameSave + ); + } + setInterval( - () => { - RenderGarden(); - RenderNextHarvest(); - }, + GardenTick, GardenUpdateInterval ); @@ -27,11 +40,62 @@ function RenderGame() { EnsureSelectedSeed(); RenderCurrency(); RenderSeeds(); + RenderTools(); RenderGarden(); RenderNextHarvest(); } +function BindGardenTools() { + const ShovelButton = + document.getElementById( + "ShovelToolButton" + ); + + if (ShovelButton === null) { + return; + } + + ShovelButton.addEventListener( + "click", + () => { + SelectedGardenTool = + "Shovel"; + + RenderSeeds(); + RenderTools(); + RenderGarden(); + } + ); +} + + +function RenderTools() { + const ShovelButton = + document.getElementById( + "ShovelToolButton" + ); + + if (ShovelButton === null) { + return; + } + + if ( + SelectedGardenTool === + "Shovel" + ) { + ShovelButton.setAttribute( + "aria-current", + "true" + ); + } else { + ShovelButton.removeAttribute( + "aria-current" + ); + } +} + + function RenderCurrency() { const DewAmount = document.getElementById( @@ -178,8 +242,10 @@ function RenderSeeds() { ); if ( + SelectedGardenTool === + "Seed" && PlantId === - SelectedSeedId + SelectedSeedId ) { Button.setAttribute( "aria-current", @@ -190,10 +256,15 @@ function RenderSeeds() { Button.addEventListener( "click", () => { + SelectedGardenTool = + "Seed"; + SelectedSeedId = PlantId; RenderSeeds(); + RenderTools(); + RenderGarden(); SetGardenMessage( "Selected " + @@ -262,14 +333,19 @@ function CreatePlotElement( Button.title = "Empty plot"; - Button.addEventListener( - "click", - () => { - PlantSeed( - PlotIndex - ); - } - ); + if ( + SelectedGardenTool === + "Seed" + ) { + Button.addEventListener( + "click", + () => { + PlantSeed( + PlotIndex + ); + } + ); + } return Button; } @@ -327,6 +403,31 @@ function CreatePlotElement( } + if ( + SelectedGardenTool === + "Shovel" + ) { + Button.classList.add( + "GardenPlotRemove" + ); + + Button.title = + "Remove " + + Plant.Name; + + Button.addEventListener( + "click", + () => { + RemovePlant( + PlotIndex + ); + } + ); + + return Button; + } + + if ( IsPlantMature( Plot, @@ -437,6 +538,10 @@ async function PlantSeed( "." ); + CheckGardenMutations( + GameSave + ); + RenderGame(); await SaveGame( @@ -506,6 +611,10 @@ async function HarvestPlant( " Dew." ); + CheckGardenMutations( + GameSave + ); + RenderGame(); await SaveGame( @@ -514,6 +623,68 @@ async function HarvestPlant( } +async function RemovePlant( + PlotIndex +) { + if ( + GameSave.Garden.Plots[ + PlotIndex + ] === null + ) { + return; + } + + + GameSave.Garden.Plots[ + PlotIndex + ] = null; + + + CheckGardenMutations( + GameSave + ); + + RenderGame(); + + await SaveGame( + GameSave + ); +} + + +async function GardenTick() { + RenderGarden(); + RenderNextHarvest(); + + + if (MutationCheckPending) { + return; + } + + + MutationCheckPending = true; + + + try { + const MutationResult = + CheckGardenMutations( + GameSave + ); + + + if (MutationResult.Attempted) { + RenderGame(); + + await SaveGame( + GameSave + ); + } + } finally { + MutationCheckPending = false; + } +} + + function GiveReward( Reward ) { diff --git a/Scripts/Garden/MutationEngine.js b/Scripts/Garden/MutationEngine.js new file mode 100644 index 0000000..47d65b2 --- /dev/null +++ b/Scripts/Garden/MutationEngine.js @@ -0,0 +1,1280 @@ +const MaxMutationPasses = 100; + + +function CheckGardenMutations( + SaveData +) { + SaveData.MutationCooldowns ??= {}; + + const Result = { + Attempted: false, + Changed: false, + + SuccessfulMutations: [], + NewlyDiscoveredMutations: [], + NewlyDiscoveredPlants: [] + }; + + + for ( + let Pass = 0; + Pass < MaxMutationPasses; + Pass++ + ) { + const Candidates = + FindGardenMutationCandidates( + SaveData + ) + .filter( + Candidate => + !IsMutationCandidateOnCooldown( + SaveData, + Candidate + ) + ) + .sort( + CompareMutationCandidates + ); + + + if (Candidates.length === 0) { + break; + } + + + const ReservedCells = + new Set(); + + const AcceptedCandidates = []; + + + for ( + const Candidate + of Candidates + ) { + if ( + Candidate.Cells.some( + Cell => + ReservedCells.has( + Cell + ) + ) + ) { + continue; + } + + + AcceptedCandidates.push( + Candidate + ); + + + for ( + const Cell + of Candidate.Cells + ) { + ReservedCells.add( + Cell + ); + } + } + + + if ( + AcceptedCandidates.length === + 0 + ) { + break; + } + + + let PassChanged = false; + + + for ( + const Candidate + of AcceptedCandidates + ) { + if ( + !DoesMutationCandidateStillMatch( + SaveData, + Candidate + ) + ) { + continue; + } + + + if ( + IsMutationCandidateOnCooldown( + SaveData, + Candidate + ) + ) { + continue; + } + + + Result.Attempted = true; + + StartMutationCandidateCooldown( + SaveData, + Candidate + ); + + + const Chance = Math.max( + 0, + Math.min( + Number( + Candidate.Mutation.Chance ?? + 1 + ), + 1 + ) + ); + + + const Succeeded = + Math.random() < Chance; + + + if (Succeeded) { + const NewlyDiscovered = + DiscoverMutationResult( + SaveData, + Candidate.Mutation + ); + + + const Changed = + ApplyMutationResult( + SaveData, + Candidate, + Candidate.Success + ); + + + Result.SuccessfulMutations.push( + Candidate.Mutation.Id + ); + + Result.NewlyDiscoveredMutations.push( + ...NewlyDiscovered.Mutations + ); + + Result.NewlyDiscoveredPlants.push( + ...NewlyDiscovered.Plants + ); + + + if (Changed) { + Result.Changed = true; + PassChanged = true; + } + } else { + const Changed = + ApplyMutationFailure( + SaveData, + Candidate + ); + + + if (Changed) { + Result.Changed = true; + PassChanged = true; + } + } + } + + + /* + * Only rescan immediately if the board + * actually changed. Failed attempts still + * receive their cooldown, but do not cause + * lower-priority overlapping recipes to be + * attempted immediately afterwards. + */ + + if (!PassChanged) { + break; + } + } + + + Result.SuccessfulMutations = [ + ...new Set( + Result.SuccessfulMutations + ) + ]; + + Result.NewlyDiscoveredMutations = [ + ...new Set( + Result.NewlyDiscoveredMutations + ) + ]; + + Result.NewlyDiscoveredPlants = [ + ...new Set( + Result.NewlyDiscoveredPlants + ) + ]; + + + return Result; +} + + +function FindGardenMutationCandidates( + SaveData +) { + const Candidates = []; + + + for ( + const Mutation + of Object.values( + MutationSets + ) + ) { + for ( + const Orientation + of GetMutationOrientations( + Mutation + ) + ) { + const PatternHeight = + Orientation.Pattern.length; + + const PatternWidth = + Orientation.Pattern[0] + ?.length ?? 0; + + + if ( + PatternWidth === 0 || + PatternHeight === 0 || + PatternWidth > + SaveData.Garden.Width || + PatternHeight > + SaveData.Garden.Height + ) { + continue; + } + + + for ( + let OriginY = 0; + OriginY <= + SaveData.Garden.Height - + PatternHeight; + OriginY++ + ) { + for ( + let OriginX = 0; + OriginX <= + SaveData.Garden.Width - + PatternWidth; + OriginX++ + ) { + const Candidate = + CreateMutationCandidate( + SaveData, + Mutation, + Orientation, + OriginX, + OriginY + ); + + + if (Candidate !== null) { + Candidates.push( + Candidate + ); + } + } + } + } + } + + + return Candidates; +} + + +function CreateMutationCandidate( + SaveData, + Mutation, + Orientation, + OriginX, + OriginY +) { + const Captures = {}; + const Cells = []; + + + for ( + let LocalY = 0; + LocalY < + Orientation.Pattern.length; + LocalY++ + ) { + for ( + let LocalX = 0; + LocalX < + Orientation.Pattern[ + LocalY + ].length; + LocalX++ + ) { + const PlotIndex = + GetGardenPlotIndex( + SaveData, + OriginX + LocalX, + OriginY + LocalY + ); + + const Matcher = + Orientation.Pattern[ + LocalY + ][ + LocalX + ]; + + const Plot = + SaveData.Garden.Plots[ + PlotIndex + ]; + + + Cells.push( + PlotIndex + ); + + + if ( + !DoesPlotMatchMutationMatcher( + Plot, + Matcher, + Mutation, + Captures + ) + ) { + return null; + } + } + } + + + return { + Mutation: Mutation, + + Pattern: + Orientation.Pattern, + + Success: + Orientation.Success, + + Rotation: + Orientation.Rotation, + + OriginX: OriginX, + OriginY: OriginY, + + Cells: Cells, + Captures: Captures, + + Priority: + Number( + Mutation.Priority ?? 0 + ), + + Specificity: + GetMutationPatternSpecificity( + Orientation.Pattern + ) + }; +} + + +function DoesMutationCandidateStillMatch( + SaveData, + Candidate +) { + const RefreshedCandidate = + CreateMutationCandidate( + SaveData, + Candidate.Mutation, + { + Pattern: + Candidate.Pattern, + + Success: + Candidate.Success, + + Rotation: + Candidate.Rotation + }, + Candidate.OriginX, + Candidate.OriginY + ); + + + if (RefreshedCandidate === null) { + return false; + } + + + Candidate.Captures = + RefreshedCandidate.Captures; + + + return true; +} + + +function DoesPlotMatchMutationMatcher( + Plot, + Matcher, + Mutation, + Captures +) { + if ( + Matcher === null || + Matcher === "Any" + ) { + return true; + } + + + if (Matcher === "Empty") { + return Plot === null; + } + + + if (Plot === null) { + return false; + } + + + const Plant = + Plants[Plot.Plant]; + + if (Plant === undefined) { + return false; + } + + + if ( + Mutation.AllowImmature !== + true && + !IsMutationPlantMature( + Plot, + Plant + ) + ) { + return false; + } + + + if ( + typeof Matcher === + "string" + ) { + return Plot.Plant === Matcher; + } + + + if ( + typeof Matcher !== + "object" + ) { + return false; + } + + + if ( + typeof Matcher.Plant === + "string" && + Plot.Plant !== + Matcher.Plant + ) { + return false; + } + + + const Tags = + Plant.Tags ?? []; + + + if ( + Array.isArray( + Matcher.Tags + ) && + !Matcher.Tags.every( + Tag => + Tags.includes(Tag) + ) + ) { + return false; + } + + + if ( + Array.isArray( + Matcher.TagsAny + ) && + Matcher.TagsAny.length > 0 && + !Matcher.TagsAny.some( + Tag => + Tags.includes(Tag) + ) + ) { + return false; + } + + + if ( + Array.isArray( + Matcher.TagsNot + ) && + Matcher.TagsNot.some( + Tag => + Tags.includes(Tag) + ) + ) { + return false; + } + + + if ( + typeof Matcher.Capture === + "string" && + Matcher.Capture.length > 0 + ) { + Captures[ + Matcher.Capture + ] = CloneMutationPlot( + Plot + ); + } + + + return true; +} + + +function IsMutationPlantMature( + Plot, + Plant +) { + if ( + Number( + Plant.GrowthTime ?? 0 + ) <= 0 + ) { + return true; + } + + + return ( + Date.now() - + Number( + Plot.PlantedAt ?? 0 + ) >= + Plant.GrowthTime + ); +} + + +function ApplyMutationFailure( + SaveData, + Candidate +) { + const Failure = + Candidate.Mutation.Failure ?? + "Keep"; + + + if (Failure === "Keep") { + return false; + } + + + if (Failure === "Clear") { + let Changed = false; + + + for ( + const PlotIndex + of Candidate.Cells + ) { + if ( + SaveData.Garden.Plots[ + PlotIndex + ] !== null + ) { + SaveData.Garden.Plots[ + PlotIndex + ] = null; + + Changed = true; + } + } + + + return Changed; + } + + + if (Array.isArray(Failure)) { + return ApplyMutationResult( + SaveData, + Candidate, + Failure + ); + } + + + return false; +} + + +function ApplyMutationResult( + SaveData, + Candidate, + ResultMatrix +) { + if (!Array.isArray(ResultMatrix)) { + return false; + } + + + const NormalizedResult = + NormalizeMutationMatrix( + ResultMatrix, + Candidate.Pattern[0] + ?.length ?? 0, + Candidate.Pattern.length, + "Keep" + ); + + + let Changed = false; + + + for ( + let LocalY = 0; + LocalY < + NormalizedResult.length; + LocalY++ + ) { + for ( + let LocalX = 0; + LocalX < + NormalizedResult[ + LocalY + ].length; + LocalX++ + ) { + const ResultValue = + NormalizedResult[ + LocalY + ][ + LocalX + ]; + + + if ( + ResultValue === null || + ResultValue === "Keep" + ) { + continue; + } + + + const PlotIndex = + GetGardenPlotIndex( + SaveData, + Candidate.OriginX + + LocalX, + Candidate.OriginY + + LocalY + ); + + + const NewPlot = + CreateMutationResultPlot( + ResultValue, + Candidate.Captures + ); + + + if ( + ResultValue === "Empty" + ) { + if ( + SaveData.Garden.Plots[ + PlotIndex + ] !== null + ) { + SaveData.Garden.Plots[ + PlotIndex + ] = null; + + Changed = true; + } + + continue; + } + + + if (NewPlot === undefined) { + continue; + } + + + SaveData.Garden.Plots[ + PlotIndex + ] = NewPlot; + + Changed = true; + } + } + + + return Changed; +} + + +function CreateMutationResultPlot( + ResultValue, + Captures +) { + if ( + typeof ResultValue === + "string" + ) { + if ( + ResultValue.startsWith( + "$" + ) + ) { + const CaptureName = + ResultValue.slice(1); + + return Captures[ + CaptureName + ] === undefined + ? undefined + : CloneMutationPlot( + Captures[ + CaptureName + ] + ); + } + + + if ( + Plants[ResultValue] === + undefined + ) { + return undefined; + } + + + return { + Plant: ResultValue, + PlantedAt: Date.now() + }; + } + + + if ( + typeof ResultValue !== + "object" || + ResultValue === null + ) { + return undefined; + } + + + if ( + typeof ResultValue.Plant !== + "string" + ) { + return undefined; + } + + + if ( + ResultValue.Plant.startsWith( + "$" + ) + ) { + const CaptureName = + ResultValue.Plant.slice(1); + + const CapturedPlot = + Captures[CaptureName]; + + + if (CapturedPlot === undefined) { + return undefined; + } + + + return CloneMutationPlot( + CapturedPlot + ); + } + + + if ( + Plants[ + ResultValue.Plant + ] === undefined + ) { + return undefined; + } + + + return { + Plant: ResultValue.Plant, + PlantedAt: Date.now() + }; +} + + +function DiscoverMutationResult( + SaveData, + Mutation +) { + const NewMutations = []; + const NewPlants = []; + + + if ( + !HasDiscoveredMutation( + SaveData, + Mutation.Id + ) + ) { + DiscoverMutation( + SaveData, + Mutation.Id + ); + + NewMutations.push( + Mutation.Id + ); + } + + + for ( + const PlantId + of Mutation.Relations + ?.PlantsCreated ?? [] + ) { + if ( + HasDiscoveredPlant( + SaveData, + PlantId + ) + ) { + continue; + } + + + DiscoverPlant( + SaveData, + PlantId + ); + + NewPlants.push( + PlantId + ); + } + + + SaveData.Discoveries.Plants.sort( + (A, B) => A - B + ); + + SaveData.Discoveries.Mutations.sort( + (A, B) => A - B + ); + + + return { + Mutations: NewMutations, + Plants: NewPlants + }; +} + + +function CompareMutationCandidates( + A, + B +) { + return ( + B.Priority - A.Priority || + B.Specificity - + A.Specificity || + A.Mutation.Id - + B.Mutation.Id || + A.OriginY - B.OriginY || + A.OriginX - B.OriginX || + A.Rotation - B.Rotation + ); +} + + +function GetMutationPatternSpecificity( + Pattern +) { + let Specificity = 0; + + + for ( + const Row + of Pattern + ) { + for ( + const Matcher + of Row + ) { + if ( + Matcher === null || + Matcher === "Any" + ) { + continue; + } + + + if (Matcher === "Empty") { + Specificity += 2; + continue; + } + + + if ( + typeof Matcher === + "string" + ) { + Specificity += 8; + continue; + } + + + if ( + typeof Matcher !== + "object" + ) { + continue; + } + + + if ( + typeof Matcher.Plant === + "string" + ) { + Specificity += 8; + } + + + Specificity += + ( + Matcher.Tags + ?.length ?? 0 + ) * 3; + + Specificity += + ( + Matcher.TagsAny + ?.length ?? 0 + ) * 2; + + Specificity += + Matcher.TagsNot + ?.length ?? 0; + } + } + + + return Specificity; +} + + +function IsMutationCandidateOnCooldown( + SaveData, + Candidate +) { + const CooldownUntil = + Number( + SaveData.MutationCooldowns[ + GetMutationCooldownKey( + Candidate + ) + ] ?? 0 + ); + + + return CooldownUntil > Date.now(); +} + + +function StartMutationCandidateCooldown( + SaveData, + Candidate +) { + const Cooldown = Math.max( + 0, + Number( + Candidate.Mutation.Cooldown ?? + 0 + ) + ); + + + SaveData.MutationCooldowns[ + GetMutationCooldownKey( + Candidate + ) + ] = Date.now() + Cooldown; +} + + +function GetMutationCooldownKey( + Candidate +) { + const Cells = [ + ...Candidate.Cells + ].sort( + (A, B) => A - B + ); + + + return ( + Candidate.Mutation.Id + + ":" + + Cells.join(",") + ); +} + + +function GetMutationOrientations( + Mutation +) { + const BasePattern = + NormalizeMutationMatrix( + Mutation.Pattern ?? [], + null, + null, + "Any" + ); + + + if (BasePattern.length === 0) { + return []; + } + + + const BaseSuccess = + NormalizeMutationMatrix( + Mutation.Success ?? [], + BasePattern[0].length, + BasePattern.length, + "Keep" + ); + + + const RotationCount = + Mutation.Rotation === "Any" + ? 4 + : 1; + + const Orientations = []; + const Seen = new Set(); + + let Pattern = BasePattern; + let Success = BaseSuccess; + + + for ( + let RotationIndex = 0; + RotationIndex < RotationCount; + RotationIndex++ + ) { + const Signature = + JSON.stringify(Pattern) + + "|" + + JSON.stringify(Success); + + + if (!Seen.has(Signature)) { + Seen.add(Signature); + + Orientations.push({ + Pattern: Pattern, + Success: Success, + Rotation: + RotationIndex * 90 + }); + } + + + Pattern = + RotateMutationMatrixClockwise( + Pattern + ); + + Success = + RotateMutationMatrixClockwise( + Success + ); + } + + + return Orientations; +} + + +function NormalizeMutationMatrix( + Matrix, + Width = null, + Height = null, + FillValue = "Any" +) { + if (!Array.isArray(Matrix)) { + return []; + } + + + const TargetHeight = + Height ?? Matrix.length; + + const TargetWidth = + Width ?? Math.max( + 0, + ...Matrix.map( + Row => + Array.isArray(Row) + ? Row.length + : 0 + ) + ); + + + const Normalized = []; + + + for ( + let Y = 0; + Y < TargetHeight; + Y++ + ) { + const SourceRow = + Array.isArray(Matrix[Y]) + ? Matrix[Y] + : []; + + const Row = []; + + + for ( + let X = 0; + X < TargetWidth; + X++ + ) { + Row.push( + X < SourceRow.length + ? SourceRow[X] + : FillValue + ); + } + + + Normalized.push( + Row + ); + } + + + return Normalized; +} + + +function RotateMutationMatrixClockwise( + Matrix +) { + if ( + Matrix.length === 0 || + Matrix[0].length === 0 + ) { + return []; + } + + + const Height = Matrix.length; + const Width = Matrix[0].length; + + const Rotated = []; + + + for ( + let X = 0; + X < Width; + X++ + ) { + const Row = []; + + + for ( + let Y = Height - 1; + Y >= 0; + Y-- + ) { + Row.push( + Matrix[Y][X] + ); + } + + + Rotated.push( + Row + ); + } + + + return Rotated; +} + + +function GetGardenPlotIndex( + SaveData, + X, + Y +) { + return ( + Y * + SaveData.Garden.Width + + X + ); +} + + +function CloneMutationPlot( + Plot +) { + if (Plot === null) { + return null; + } + + + return JSON.parse( + JSON.stringify( + Plot + ) + ); +} diff --git a/Scripts/Garden/Mutations.js b/Scripts/Garden/Mutations.js index fa60c37..e3279b0 100644 --- a/Scripts/Garden/Mutations.js +++ b/Scripts/Garden/Mutations.js @@ -6,6 +6,9 @@ const MutationSets = { Description: "A mutation that seems to corrupt roses.", + Priority: 10, + Rotation: "Any", + Chance: 0.1, Cooldown: 120000, @@ -59,6 +62,9 @@ const MutationSets = { Description: "A colourful mutation that makes a purple rose.", + Priority: 20, + Rotation: "Any", + Chance: 1.0, Cooldown: 100000, diff --git a/Styles/Style.css b/Styles/Style.css index 38ee956..3444b53 100644 --- a/Styles/Style.css +++ b/Styles/Style.css @@ -501,6 +501,66 @@ select { border-color: var(--Green); } +.GardenToolHeading { + margin-top: 1rem; + margin-bottom: 0.5rem; + + color: var(--Peach); +} + +.ToolList { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.ToolButton { + width: 100%; + padding: 0.6rem; + + cursor: pointer; + + color: var(--Text); + background-color: var(--Surface0); + + border: 3px solid var(--Surface2); + border-radius: 0; + + box-shadow: + 3px 3px 0 + var(--Crust); + + appearance: none; +} + +.ToolButton:hover { + color: var(--Sky); + background-color: var(--Surface1); + + border-color: var(--Sky); + + transform: + translate( + 1px, + 1px + ); + + box-shadow: + 2px 2px 0 + var(--Crust); +} + +.ToolButtonShovel[aria-current="true"] { + color: var(--Red); + background-color: var(--Surface1); + + border-color: var(--Red); +} + +.GardenPlotRemove:hover { + border-color: var(--Red); +} + #GardenMessage { margin-top: 1rem; margin-bottom: 0; @@ -1016,12 +1076,14 @@ select { grid-row: 1; } - .SeedList { + .SeedList, + .ToolList { flex-direction: row; flex-wrap: wrap; } - .SeedButton { + .SeedButton, + .ToolButton { width: auto; }