From 68cee86c5c0cf47fb123475cadd89f774a595f43 Mon Sep 17 00:00:00 2001 From: Endaris Date: Sun, 13 Jul 2025 06:35:08 +0200 Subject: [PATCH 01/12] fix crash when trying to load new v049 replays lacking the startTimersWithSwapCount field --- common/data/ReplayV3.lua | 2 +- common/engine/checkMatches.lua | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/common/data/ReplayV3.lua b/common/data/ReplayV3.lua index 7244cfd1..fb9d5c0e 100644 --- a/common/data/ReplayV3.lua +++ b/common/data/ReplayV3.lua @@ -302,7 +302,7 @@ function ReplayV3.createFromV3Data(replayData) -- the startTimersWithSwapCount got retired in favor of delaySimulationUntil -- as there were no use cases in which it was set to a different value than 1, there should be no problems with a straight up replacement ---@diagnostic disable-next-line: undefined-field - if stack.stackBehaviours.startTimersWithSwapCount > 0 then + if stack.stackBehaviours.startTimersWithSwapCount and stack.stackBehaviours.startTimersWithSwapCount > 0 then stack.stackBehaviours.delaySimulationUntil = "firstSwap" end end diff --git a/common/engine/checkMatches.lua b/common/engine/checkMatches.lua index 18f9e069..8760649f 100644 --- a/common/engine/checkMatches.lua +++ b/common/engine/checkMatches.lua @@ -468,7 +468,7 @@ function Stack:getConnectedGarbagePanels2(matchingPanels) for row = 1, #self.panels do for col = 1, self.width do - panel = self.panels[row][col] + local panel = self.panels[row][col] if panel.isGarbage and panel.state == "normal" and not idGarbage[panel.garbageId] -- we only want to match garbage that is either fully or partially on-screen OR has been on-screen before -- example: chain garbage several rows high lands in row 12; by visuals/shake it is clear that it is more than 1 row high From 6ce70edc466f232545f8a602592a3be2c440ef12 Mon Sep 17 00:00:00 2001 From: Endaris Date: Tue, 15 Jul 2025 23:46:38 +0200 Subject: [PATCH 02/12] annotate PUZZLE_TYPES enum and use it instead of string literals outside of validity tests --- client/src/PuzzleSet.lua | 2 +- common/engine/Puzzle.lua | 21 ++++++++++----------- common/tests/engine/StackReplayTests.lua | 2 +- common/tests/engine/StackTests.lua | 6 +++--- 4 files changed, 15 insertions(+), 16 deletions(-) diff --git a/client/src/PuzzleSet.lua b/client/src/PuzzleSet.lua index 937caa17..8b49cdf7 100644 --- a/client/src/PuzzleSet.lua +++ b/client/src/PuzzleSet.lua @@ -117,7 +117,7 @@ function PuzzleSet.loadV3(puzzleSetData) for _, puzzleData in pairs(puzzleSetData["Puzzles"] or {}) do local args = { - puzzleType = puzzleData["Puzzle Type"], + puzzleType = string.lower(puzzleData["Puzzle Type"]), startTiming = puzzleData["StartTiming"], moves = puzzleData["Moves"], stack = puzzleData["Stack"], diff --git a/common/engine/Puzzle.lua b/common/engine/Puzzle.lua index 15dbd9f6..6e7c5fed 100644 --- a/common/engine/Puzzle.lua +++ b/common/engine/Puzzle.lua @@ -8,8 +8,6 @@ local MatchRules = require("common.data.MatchRules") ---@field row integer ---@field column integer ----@alias PuzzleType ("moves" | "chain" | "clear") - ---@class PuzzleArgs ---@field puzzleType PuzzleType ---@field stack string representation of the panel colors, the last character is the bottom right panel @@ -41,11 +39,11 @@ Puzzle = class( ---@param self Puzzle ---@param puzzleArgs GarbagePuzzleArgs function(self, puzzleArgs) - self.puzzleType = puzzleArgs.puzzleType or "moves" + self.puzzleType = puzzleArgs.puzzleType or Puzzle.PUZZLE_TYPES.moves if puzzleArgs.startTiming then self.startTiming = puzzleArgs.startTiming else - if self.puzzleType == "clear" or self.puzzleType == "chain" then + if self.puzzleType == Puzzle.PUZZLE_TYPES.clear or self.puzzleType == Puzzle.PUZZLE_TYPES.chain then if self.cursorStartLeft then self.startTiming = Puzzle.START_TIMINGS.firstInput else @@ -80,7 +78,8 @@ end ---@enum PuzzleStartTiming Puzzle.START_TIMINGS = { countdown = "countdown", immediately = "immediately", firstInput = "firstInput", firstSwap = "firstSwap" } -Puzzle.PUZZLE_TYPES = { "moves", "chain", "clear" } +---@enum PuzzleType +Puzzle.PUZZLE_TYPES = { moves = "moves", chain = "chain", clear = "clear" } Puzzle.LEGAL_CHARACTERS = { "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "[", "]", "{", "}", "=" } ---@param width integer @@ -89,7 +88,7 @@ Puzzle.LEGAL_CHARACTERS = { "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "[ function Puzzle:fillMissingPanelsInPuzzleString(width, height) local puzzleString = self.stack local boardSizeInPanels = width * height - if self.puzzleType == "clear" then + if self.puzzleType == Puzzle.PUZZLE_TYPES.clear then -- first fill up the currently started row local fillUpLength = (puzzleString:len() % width) if fillUpLength > 0 then @@ -215,12 +214,12 @@ function Puzzle:validate() errMessage = errMessage .. "\nPuzzlestring contains invalid characters: " .. table.concat(illegalCharacters, ", ") end - if not tableUtils.contains(Puzzle.PUZZLE_TYPES, self.puzzleType) then + if not Puzzle.PUZZLE_TYPES[self.puzzleType] then errMessage = errMessage .. "\nInvalid puzzle type detected, available puzzle types are: " .. table.concat(Puzzle.PUZZLE_TYPES, ", ") end - if string.lower(self.puzzleType) == "moves" and (not tonumber(self.moves) or tonumber(self.moves) < 1 ) then + if self.puzzleType == Puzzle.PUZZLE_TYPES.moves and (not tonumber(self.moves) or tonumber(self.moves) < 1 ) then errMessage = errMessage .. "\nInvalid number of moves detected, expecting a number greater than zero but instead got " .. self.moves end @@ -295,7 +294,7 @@ function Puzzle:toGameMode() mode.matchRules.stackOverConditions[MatchRules.StackOverConditions.SWAPS] = self.moves end - if self.puzzleType == "clear" then + if self.puzzleType == Puzzle.PUZZLE_TYPES.clear then mode.matchRules.stackOverConditions[MatchRules.StackOverConditions.HEALTH] = 0 mode.matchRules.stackWinConditions[MatchRules.StackWinConditions.MATCHABLE_GARBAGE_PANELS] = 0 mode.matchRules.stackSetupModifications.stopTime = self.stopTime @@ -306,10 +305,10 @@ function Puzzle:toGameMode() allowManualRaise = false, passiveRaise = false, } - if self.puzzleType == "chain" then + if self.puzzleType == Puzzle.PUZZLE_TYPES.chain then mode.matchRules.stackOverConditions[MatchRules.StackOverConditions.CHAIN] = false mode.matchRules.stackWinConditions[MatchRules.StackWinConditions.MATCHABLE_PANELS] = 0 - elseif self.puzzleType == "moves" then + elseif self.puzzleType == Puzzle.PUZZLE_TYPES.moves then mode.matchRules.stackWinConditions[MatchRules.StackWinConditions.MATCHABLE_PANELS] = 0 end end diff --git a/common/tests/engine/StackReplayTests.lua b/common/tests/engine/StackReplayTests.lua index cbedbafc..2d0d480d 100644 --- a/common/tests/engine/StackReplayTests.lua +++ b/common/tests/engine/StackReplayTests.lua @@ -373,7 +373,7 @@ local function fallingWhileHoverBeginsDoesNotChain() end local function platformTest(waitFrames, useMatchSide) - local puzzle = Puzzle({puzzleType = "chain", stack = "3000994339949999994999999999999999999999999999999999", stopTime = 60}) + local puzzle = Puzzle({puzzleType = Puzzle.PUZZLE_TYPES.chain, stack = "3000994339949999994999999999999999999999999999999999", stopTime = 60}) local match = StackReplayTestingUtils.createSinglePlayerMatch(puzzle:toGameMode(), puzzle:toPanelSource(), "controller", LevelPresets.getModern(10)) local stack = match.stacks[1] ---@cast stack Stack diff --git a/common/tests/engine/StackTests.lua b/common/tests/engine/StackTests.lua index 5ac00db4..891f2b57 100644 --- a/common/tests/engine/StackTests.lua +++ b/common/tests/engine/StackTests.lua @@ -6,7 +6,7 @@ local KeyDataEncoding = require("common.data.KeyDataEncoding") local function puzzleTest() -- to stop rising - local puzzle = Puzzle({puzzleType = "moves", moves = 1, stack = "011010"}) + local puzzle = Puzzle({puzzleType = Puzzle.PUZZLE_TYPES.moves, moves = 1, stack = "011010"}) local match = StackReplayTestingUtils.createSinglePlayerMatch(puzzle:toGameMode(), puzzle:toPanelSource()) local stack = match.stacks[1] ---@cast stack Stack @@ -26,7 +26,7 @@ end puzzleTest() local function clearPuzzleTest() - local puzzle = Puzzle({puzzleType = "clear", stack = "[============================][====]246260[====]600016514213466313451511124242", stopTime = 60}) + local puzzle = Puzzle({puzzleType = Puzzle.PUZZLE_TYPES.clear, stack = "[============================][====]246260[====]600016514213466313451511124242", stopTime = 60}) local match = StackReplayTestingUtils.createSinglePlayerMatch(puzzle:toGameMode(), puzzle:toPanelSource()) local stack = match.stacks[1] ---@cast stack Stack @@ -133,7 +133,7 @@ testShakeFrames() local function swapStalling1Test1() - local puzzle = Puzzle({puzzleType = "clear", stack = "[======================][====]246260[====]600016514213461336451511124242"}) + local puzzle = Puzzle({puzzleType = Puzzle.PUZZLE_TYPES.clear, stack = "[======================][====]246260[====]600016514213461336451511124242"}) local match = StackReplayTestingUtils.createSinglePlayerMatch(puzzle:toGameMode(), puzzle:toPanelSource(), "controller", LevelPresets.getModern(10)) local stack = match.stacks[1] ---@cast stack Stack From 51c1df02894d0252553a8e303fe36b8177bc039a Mon Sep 17 00:00:00 2001 From: Endaris Date: Tue, 15 Jul 2025 23:51:39 +0200 Subject: [PATCH 03/12] fix some challengemodeplayer annotations --- client/src/ChallengeModePlayer.lua | 1 - client/src/MatchParticipant.lua | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/ChallengeModePlayer.lua b/client/src/ChallengeModePlayer.lua index 40159cd7..c31abfca 100644 --- a/client/src/ChallengeModePlayer.lua +++ b/client/src/ChallengeModePlayer.lua @@ -12,7 +12,6 @@ local ChallengeModePlayerStack = require("client.src.ChallengeModePlayerStack") ---@class ChallengeModePlayer : MatchParticipant ---@field usedCharacterIds string[] array of character ids that have already been used during the life time of the player ---@field settings ChallengeModePlayerSettings - local ChallengeModePlayer = class( function(self, playerNumber) self.name = "Challenger" diff --git a/client/src/MatchParticipant.lua b/client/src/MatchParticipant.lua index 43c14615..305d3acf 100644 --- a/client/src/MatchParticipant.lua +++ b/client/src/MatchParticipant.lua @@ -27,6 +27,7 @@ local ModController = require("client.src.mods.ModController") ---@field ready boolean if the participant is ready to start the game (wants to and actually is) ---@field human boolean if the participant is a human ---@field isLocal boolean if the participant is controlled by a local player +---@field playerNumber integer the (external) id for the player within the room; used to assign server messages to the correct player when spectating ---@field stack ClientStack? -- a match participant represents the minimum spec for a what constitutes a "player" in a battleRoom / match From dc245d45a4d7b891cb92db071b3a9b8c72ee5066 Mon Sep 17 00:00:00 2001 From: Endaris Date: Wed, 16 Jul 2025 00:07:06 +0200 Subject: [PATCH 04/12] rename game_stopwatch to stopWatch --- client/src/ChallengeMode.lua | 4 +- client/src/ClientMatch.lua | 4 +- client/src/PlayerStack.lua | 1 - .../ChallengeModeTimeSplitsUIElement.lua | 2 +- client/src/scenes/PortraitGame.lua | 4 +- common/engine/AttackEngine.lua | 24 ++++++------ common/engine/BaseStack.lua | 6 +-- common/engine/Match.lua | 10 ++--- common/engine/SimulatedStack.lua | 20 +++++----- common/engine/Stack.lua | 39 +++++++++---------- common/engine/checkMatches.lua | 8 ++-- .../tests/engine/GarbageQueueTestingUtils.lua | 9 ++++- common/tests/engine/StackReplayTests.lua | 2 +- 13 files changed, 69 insertions(+), 64 deletions(-) diff --git a/client/src/ChallengeMode.lua b/client/src/ChallengeMode.lua index b2220918..885f7b32 100644 --- a/client/src/ChallengeMode.lua +++ b/client/src/ChallengeMode.lua @@ -196,8 +196,8 @@ function ChallengeMode:onMatchEnded(match) -- so always record the result, even if it may have been an abort local gameTime = 0 local stackEngine = match.stacks[1].engine - if stackEngine ~= nil and stackEngine.game_stopwatch then - gameTime = stackEngine.game_stopwatch + if stackEngine ~= nil and stackEngine.stopWatch then + gameTime = stackEngine.stopWatch end self:recordStageResult(winners, gameTime) diff --git a/client/src/ClientMatch.lua b/client/src/ClientMatch.lua index d8325e3c..ded69b73 100644 --- a/client/src/ClientMatch.lua +++ b/client/src/ClientMatch.lua @@ -531,8 +531,8 @@ function ClientMatch:drawTimer() -- Draw the timer for time attack local frames = 0 local stack = self.stacks[1] - if stack ~= nil and stack.engine.game_stopwatch ~= nil and tonumber(stack.engine.game_stopwatch) ~= nil then - frames = stack.engine.game_stopwatch + if stack ~= nil and stack.engine.stopWatch ~= nil and tonumber(stack.engine.stopWatch) ~= nil then + frames = stack.engine.stopWatch end if self.engine.timeLimit then diff --git a/client/src/PlayerStack.lua b/client/src/PlayerStack.lua index c1830f2f..7cd523d9 100644 --- a/client/src/PlayerStack.lua +++ b/client/src/PlayerStack.lua @@ -252,7 +252,6 @@ function PlayerStack:onRollback(engine) -- to fool Match without having to wrap everything into getters self.clock = engine.clock - self.game_stopwatch = engine.game_stopwatch --prof.push("rollback copy analytics") self.analytic:rollbackToFrame(self.clock) diff --git a/client/src/graphics/ChallengeModeTimeSplitsUIElement.lua b/client/src/graphics/ChallengeModeTimeSplitsUIElement.lua index ce1b68ab..577fed4a 100644 --- a/client/src/graphics/ChallengeModeTimeSplitsUIElement.lua +++ b/client/src/graphics/ChallengeModeTimeSplitsUIElement.lua @@ -25,7 +25,7 @@ function ChallengeModeTimeSplitsUIElement:drawTimeSplits() local currentStageTime = time local isCurrentStage = (self.currentStageIndex and i == self.currentStageIndex) if isCurrentStage and self.challengeMode.match and not self.challengeMode.match.ended then - currentStageTime = currentStageTime + (self.challengeMode.match.stacks[1].engine.game_stopwatch or 0) + currentStageTime = currentStageTime + (self.challengeMode.match.stacks[1].engine.stopWatch or 0) end totalTime = totalTime + currentStageTime diff --git a/client/src/scenes/PortraitGame.lua b/client/src/scenes/PortraitGame.lua index 4e39eafc..0225f115 100644 --- a/client/src/scenes/PortraitGame.lua +++ b/client/src/scenes/PortraitGame.lua @@ -17,8 +17,8 @@ PortraitGame.name = "PortraitGame" local function getTimer(match) local frames = 0 local stack = match.stacks[1] - if stack ~= nil and stack.engine.game_stopwatch ~= nil and tonumber(stack.engine.game_stopwatch) ~= nil then - frames = stack.engine.game_stopwatch + if stack ~= nil and stack.engine.stopWatch ~= nil and tonumber(stack.engine.stopWatch) ~= nil then + frames = stack.engine.stopWatch end if match.engine.timeLimit then diff --git a/common/engine/AttackEngine.lua b/common/engine/AttackEngine.lua index 35f740aa..a004dab8 100644 --- a/common/engine/AttackEngine.lua +++ b/common/engine/AttackEngine.lua @@ -30,7 +30,7 @@ AttackPattern = ---@field treatMetalAsCombo boolean whether the metal garbage is treated the same as combo garbage (aka they can mix) ---@field attackPatterns AttackPattern[] The array of AttackPattern objects this engine will run through. ---@field attackSettings table The format for serializing AttackPattern information ----@field stopwatch integer The stopwatch to control the continuity of the sending process +---@field stopWatch integer The stopWatch to control the continuity of the sending process ---@field outgoingGarbage GarbageQueue The garbage queue attacks are added to local AttackEngine = class( function(self, attackSettings, garbageQueue) @@ -50,7 +50,7 @@ local AttackEngine = class( self:addAttackPatternsFromTable(attackSettings.attackPatterns) self.attackSettings = attackSettings - self.stopwatch = 0 + self.stopWatch = 0 self.outgoingGarbage = garbageQueue -- to ensure correct behaviour according to the pattern definition @@ -85,7 +85,7 @@ end -- Adds an attack pattern that happens repeatedly on a timer. ---@param width integer? the width of the garbage block in columns ---@param height integer? the height of the garbage block in rows ----@param start integer the stopwatch frame these attacks should start being sent +---@param start integer the stopWatch frame these attacks should start being sent ---@param metal boolean? if this is a metal block ---@param chain boolean? if this is a chain attack function AttackEngine.addAttackPattern(self, width, height, start, metal, chain) @@ -94,7 +94,7 @@ function AttackEngine.addAttackPattern(self, width, height, start, metal, chain) self.attackPatterns[#self.attackPatterns + 1] = attackPattern end ----@param chainEnd integer the stopwatch frame the ongoing chain is being finalized +---@param chainEnd integer the stopWatch frame the ongoing chain is being finalized function AttackEngine.addEndChainPattern(self, chainEnd) local attackPattern = AttackPattern(0, 0, self.delayBeforeStart + chainEnd, false, false, true) self.attackPatterns[#self.attackPatterns + 1] = attackPattern @@ -117,21 +117,21 @@ function AttackEngine.run(self) -- that the recipient is stalling acceptance so we shouldn't push more inside if self.disableQueueLimit or self.outgoingGarbage.transitTimers:len() <= 6 then for i = 1, #self.attackPatterns do - if self.stopwatch >= self.attackPatterns[i].startTime then - local difference = self.stopwatch - self.attackPatterns[i].startTime + if self.stopWatch >= self.attackPatterns[i].startTime then + local difference = self.stopWatch - self.attackPatterns[i].startTime local remainder = difference % totalAttackTimeBeforeRepeat if remainder == 0 then if self.attackPatterns[i].endsChain then if not self.outgoingGarbage.currentChain then break end - self.outgoingGarbage:finalizeCurrentChain(self.stopwatch) + self.outgoingGarbage:finalizeCurrentChain(self.stopWatch) else local garbage = self.attackPatterns[i].garbage if garbage.isChain then - self.outgoingGarbage:addChainLink(self.stopwatch, math.random(1, 11), math.random(1, 6)) + self.outgoingGarbage:addChainLink(self.stopWatch, math.random(1, 11), math.random(1, 6)) else - garbage.frameEarned = self.stopwatch + garbage.frameEarned = self.stopWatch -- we need a coordinate for the origin of the attack animation garbage.rowEarned = math.random(1, 11) garbage.colEarned = math.random(1, 6) @@ -147,7 +147,7 @@ function AttackEngine.run(self) end end - self.stopwatch = self.stopwatch + 1 + self.stopWatch = self.stopWatch + 1 end function AttackEngine:saveForRollback(frame) @@ -156,12 +156,12 @@ end function AttackEngine:rollbackToFrame(frame) self.outgoingGarbage:rollbackToFrame(frame) - self.stopwatch = frame + self.stopWatch = frame end function AttackEngine:rewindToFrame(frame) self.outgoingGarbage:rewindToFrame(frame) - self.stopwatch = frame + self.stopWatch = frame end return AttackEngine \ No newline at end of file diff --git a/common/engine/BaseStack.lua b/common/engine/BaseStack.lua index a1fb5648..0b278efd 100644 --- a/common/engine/BaseStack.lua +++ b/common/engine/BaseStack.lua @@ -9,8 +9,8 @@ local MatchRules = require("common.data.MatchRules") ---@field is_local boolean effectively if the Stack is receiving its inputs via local input ---@field framesBehindArray integer[] Records how far behind the stack was at each match clock time ---@field framesBehind integer How far behind the stack is at the current Match clock time ----@field clock integer how many times run has been called ----@field game_stopwatch integer how many times the simulation has run +---@field clock integer how many times run has been called; this is equivalent to how many inputs have been processed +---@field stopWatch integer how many times the simulation has run; unlike a clock and just like a stopWatch this frame timer only runs when the simulation is running ---@field game_stopwatch_running boolean if the stack is simulating during runs ---@field game_over_clock integer What the clock time was when the Stack went game over ---@field do_countdown boolean if the stack is performing a countdown at the start of the match @@ -61,7 +61,7 @@ function(self, args) self.framesBehindArray = {} self.framesBehind = 0 self.clock = 0 - self.game_stopwatch = 0 + self.stopWatch = 0 self.game_stopwatch_running = true self.game_over_clock = -1 -- the exact clock frame the stack lost, -1 while alive Signal.turnIntoEmitter(self) diff --git a/common/engine/Match.lua b/common/engine/Match.lua index 06a9c1b9..2f02d277 100644 --- a/common/engine/Match.lua +++ b/common/engine/Match.lua @@ -282,7 +282,7 @@ function Match:pushGarbageTo(stack) for _, st in ipairs(self.garbageSources[stack]) do local oldestTransitTime = st:getOldestFinishedGarbageTransitTime() if oldestTransitTime and ((not st.outgoingGarbage.illegalStuffIsAllowed) or (#stack.incomingGarbage.stagedGarbage < 72)) then - if stack.game_stopwatch > oldestTransitTime then + if stack.stopWatch > oldestTransitTime then -- recipient went past the frame it was supposed to receive the garbage -> rollback to that frame -- hypothetically, IF the receiving stack's garbage target was different than the sender forcing the rollback here -- it may be necessary to perform extra steps to ensure the recipient of the stack getting rolled back is getting correct garbage @@ -293,7 +293,7 @@ function Match:pushGarbageTo(stack) self:abort() end end - local garbageDelivery = st:getReadyGarbageAt(stack.game_stopwatch) + local garbageDelivery = st:getReadyGarbageAt(stack.stopWatch) if garbageDelivery then --logger.debug("Pushing garbage delivery to incoming garbage queue: " .. table_to_string(garbageDelivery)) stack:receiveGarbage(garbageDelivery) @@ -312,7 +312,7 @@ function Match:shouldSaveRollback(stack) for senderIndex, targetList in ipairs(self.garbageTargets) do for _, target in ipairs(targetList) do if target == stack then - if self.stacks[senderIndex].game_stopwatch + GARBAGE_DELAY_LAND_TIME <= stack.game_stopwatch then + if self.stacks[senderIndex].stopWatch + GARBAGE_DELAY_LAND_TIME <= stack.stopWatch then return true end end @@ -535,7 +535,7 @@ function Match:hasEnded() end if self.timeLimit then - if tableUtils.trueForAll(self.stacks, function(stack) return stack.game_stopwatch and stack.game_stopwatch >= self.timeLimit end) then + if tableUtils.trueForAll(self.stacks, function(stack) return stack.stopWatch and stack.stopWatch >= self.timeLimit end) then self.ended = true return true end @@ -588,7 +588,7 @@ function Match:shouldRun(stack, runsSoFar) if not stack:game_ended() then if self.timeLimit then -- timeLimit will malfunction with SimulatedStack - if stack.game_stopwatch and stack.game_stopwatch >= self.timeLimit then + if stack.stopWatch and stack.stopWatch >= self.timeLimit then -- the stack should only run 1 frame beyond the time limit (excluding countdown) return false end diff --git a/common/engine/SimulatedStack.lua b/common/engine/SimulatedStack.lua index bcb1215f..03bb1cf3 100644 --- a/common/engine/SimulatedStack.lua +++ b/common/engine/SimulatedStack.lua @@ -51,7 +51,7 @@ function SimulatedStack:run() self.attackEngine:run() end - self.outgoingGarbage:processStagedGarbageForClock(self.game_stopwatch) + self.outgoingGarbage:processStagedGarbageForClock(self.stopWatch) if self.healthEngine then -- perform the equivalent of queued garbage being dropped @@ -67,7 +67,7 @@ function SimulatedStack:run() self:setGameOver() end - self.game_stopwatch = self.game_stopwatch + 1 + self.stopWatch = self.stopWatch + 1 end self.clock = self.clock + 1 @@ -115,18 +115,18 @@ function SimulatedStack:saveForRollback() copy = {} end - self.incomingGarbage:saveForRollback(self.game_stopwatch) + self.incomingGarbage:saveForRollback(self.stopWatch) if self.healthEngine then self.healthEngine:saveRollbackCopy() end if self.attackEngine then - self.attackEngine:saveForRollback(self.game_stopwatch) + self.attackEngine:saveForRollback(self.stopWatch) end copy.health = self.health - copy.game_stopwatch = self.game_stopwatch + copy.stopWatch = self.stopWatch copy.game_over_clock = self.game_over_clock self.rollbackCopies[self.clock] = copy @@ -156,7 +156,7 @@ local function internalRollbackToFrame(stack, frame) stack.health = copy.health end - stack.game_stopwatch = copy.game_stopwatch + stack.stopWatch = copy.stopWatch stack.game_over_clock = copy.game_over_clock return true @@ -167,10 +167,10 @@ end function SimulatedStack:rollbackToFrame(frame) if internalRollbackToFrame(self, frame) then - self.incomingGarbage:rollbackToFrame(self.game_stopwatch) + self.incomingGarbage:rollbackToFrame(self.stopWatch) if self.attackEngine then - self.attackEngine:rollbackToFrame(self.game_stopwatch) + self.attackEngine:rollbackToFrame(self.stopWatch) end self.lastRollbackFrame = self.clock @@ -183,10 +183,10 @@ end function SimulatedStack:rewindToFrame(frame) if internalRollbackToFrame(self, frame) then - self.incomingGarbage:rewindToFrame(self.game_stopwatch) + self.incomingGarbage:rewindToFrame(self.stopWatch) if self.attackEngine then - self.attackEngine:rewindToFrame(self.game_stopwatch) + self.attackEngine:rewindToFrame(self.stopWatch) end self.clock = frame diff --git a/common/engine/Stack.lua b/common/engine/Stack.lua index c37a26b0..f00a4f8a 100644 --- a/common/engine/Stack.lua +++ b/common/engine/Stack.lua @@ -149,7 +149,6 @@ local DIRECTION_ROW = {up = 1, down = -1, left = 0, right = 0} ---@field peak_shake_time integer Records the maximum shake time obtained for the current stretch of uninterrupted shake time. \n --- Any additional shake time gained before shake depletes to 0 will reset shake_time back to this value. Set to 0 when shake_time reaches 0. ---@field warningsTriggered table ancient ancient, probably remove ----@field game_stopwatch integer? Clock time minus time that swaps were blocked ---@field rollbackBuffer RollbackBuffer A specialized class to manage memory for rollback data ---@field panelTemplate (Panel | fun(row: integer, column: integer, id: integer?): Panel) A template class based on Panel enriched by tailor made closures containing references to the Stack ---@field swapStallingBackLog table tracks swaps that will incur a health cost for stalling if not swapping would have resulted in health loss @@ -396,7 +395,7 @@ function Stack:rollbackCopy() copy.health = self.health copy.countdown_timer = self.countdown_timer copy.clock = self.clock - copy.game_stopwatch = self.game_stopwatch + copy.stopWatch = self.stopWatch copy.game_stopwatch_running = self.game_stopwatch_running copy.rise_lock = self.rise_lock copy.top_cur_row = self.top_cur_row @@ -452,7 +451,7 @@ local function internalRollbackToFrame(stack, frame) stack.countdown_timer = copy.countdown_timer stack.clock = copy.clock - stack.game_stopwatch = copy.game_stopwatch + stack.stopWatch = copy.stopWatch stack.game_stopwatch_running = copy.game_stopwatch_running stack.rise_lock = copy.rise_lock stack.top_cur_row = copy.top_cur_row @@ -538,8 +537,8 @@ function Stack:rollbackToFrame(frame) local currentFrame = self.clock if internalRollbackToFrame(self, frame) then - self.incomingGarbage:rollbackToFrame(self.game_stopwatch) - self.outgoingGarbage:rollbackToFrame(self.game_stopwatch) + self.incomingGarbage:rollbackToFrame(self.stopWatch) + self.outgoingGarbage:rollbackToFrame(self.stopWatch) self.panelSource:rollbackToFrame(frame) self.rollbackCount = self.rollbackCount + 1 @@ -556,8 +555,8 @@ end ---@return boolean success if rewinding succeeded function Stack:rewindToFrame(frame) if internalRollbackToFrame(self, frame) then - self.incomingGarbage:rewindToFrame(self.game_stopwatch) - self.outgoingGarbage:rewindToFrame(self.game_stopwatch) + self.incomingGarbage:rewindToFrame(self.stopWatch) + self.outgoingGarbage:rewindToFrame(self.stopWatch) self.panelSource:rewindToFrame(frame) self:emitSignal("rollbackPerformed", self) @@ -576,11 +575,11 @@ function Stack:saveForRollback() self:rollbackCopy() prof.pop("Stack.rollbackCopy") prof.push("incomingGarbage:saveForRollback") - self.incomingGarbage:saveForRollback(self.game_stopwatch) + self.incomingGarbage:saveForRollback(self.stopWatch) prof.pop("incomingGarbage:saveForRollback") prof.push("outgoingGarbage:saveForRollback") if self.outgoingGarbage then - self.outgoingGarbage:saveForRollback(self.game_stopwatch) + self.outgoingGarbage:saveForRollback(self.stopWatch) end prof.pop("outgoingGarbage:saveForRollback") self.panelSource:saveForRollback(self.clock) @@ -785,13 +784,13 @@ function Stack:run() if self.behaviours.delaySimulationUntil == "firstInput" then if self.input_state ~= self:idleInput() then self.game_stopwatch_running = true - -- need to compensate the fact that we increment stopwatch at the end of the frame without having simulated - self.game_stopwatch = -1 + -- need to compensate the fact that we increment stopWatch at the end of the frame without having simulated + self.stopWatch = -1 end elseif self.behaviours.delaySimulationUntil == "firstSwap" then if self.swapThisFrame then self.game_stopwatch_running = true - self.game_stopwatch = -1 + self.stopWatch = -1 end end end @@ -819,7 +818,7 @@ function Stack:run() self:tryDropGarbage() end prof.pop("pop from incoming garbage q") - self.game_stopwatch = self.game_stopwatch + 1 + self.stopWatch = self.stopWatch + 1 end self.clock = self.clock + 1 @@ -980,14 +979,14 @@ function Stack:simulate() self.chain_counter = 0 if self.outgoingGarbage then - logger.debug("Player " .. self.which .. " chain ended at " .. self.game_stopwatch) - self.outgoingGarbage:finalizeCurrentChain(self.game_stopwatch) + logger.debug("Player " .. self.which .. " chain ended at " .. self.stopWatch) + self.outgoingGarbage:finalizeCurrentChain(self.stopWatch) end end --prof.pop("chain update") --prof.push("process staged garbage") - self.outgoingGarbage:processStagedGarbageForClock(self.game_stopwatch) + self.outgoingGarbage:processStagedGarbageForClock(self.stopWatch) --prof.pop("process staged garbage") self:removeExtraRows() @@ -1352,10 +1351,10 @@ end -- tries to drop a width x height garbage. -- returns true if garbage was dropped, false otherwise function Stack:tryDropGarbage() - logger.debug("trying to drop garbage at frame " .. self.game_stopwatch) + logger.debug("trying to drop garbage at frame " .. self.stopWatch) local garbage = self.incomingGarbage:pop() - logger.debug(string.format("%d Dropping garbage on stack %d - height %d width %d %s", self.game_stopwatch, self.which, garbage.height, garbage.width, garbage.isMetal and "Metal" or "")) + logger.debug(string.format("%d Dropping garbage on stack %d - height %d width %d %s", self.stopWatch, self.which, garbage.height, garbage.width, garbage.isMetal and "Metal" or "")) self:dropGarbage(garbage.width, garbage.height, garbage.isMetal) @@ -1453,8 +1452,8 @@ function Stack:getAttackPatternData() data.attackPatterns = {} data.extraInfo = {} data.extraInfo.matchLength = " " - if self.game_stopwatch > 0 then - data.extraInfo.matchLength = frames_to_time_string(self.game_stopwatch) + if self.stopWatch > 0 then + data.extraInfo.matchLength = frames_to_time_string(self.stopWatch) else -- there is nothing to export! return diff --git a/common/engine/checkMatches.lua b/common/engine/checkMatches.lua index 8760649f..6dc79608 100644 --- a/common/engine/checkMatches.lua +++ b/common/engine/checkMatches.lua @@ -758,14 +758,14 @@ function Stack:convertGarbagePanels(isChain) end function Stack:pushGarbage(coordinate, isChain, comboSize, metalCount) - logger.debug("P" .. self.which .. "@" .. self.game_stopwatch .. ": Pushing garbage for " .. (isChain and "chain" or "combo") .. " with " .. comboSize .. " panels") + logger.debug("P" .. self.which .. "@" .. self.stopWatch .. ": Pushing garbage for " .. (isChain and "chain" or "combo") .. " with " .. comboSize .. " panels") for i = 3, metalCount do self.outgoingGarbage:push({ width = 6, height = 1, isMetal = true, isChain = false, - frameEarned = self.game_stopwatch, + frameEarned = self.stopWatch, rowEarned = coordinate.row, colEarned = coordinate.column }) @@ -779,7 +779,7 @@ function Stack:pushGarbage(coordinate, isChain, comboSize, metalCount) height = 1, isMetal = false, isChain = false, - frameEarned = self.game_stopwatch, + frameEarned = self.stopWatch, rowEarned = coordinate.row, colEarned = coordinate.column }) @@ -791,7 +791,7 @@ function Stack:pushGarbage(coordinate, isChain, comboSize, metalCount) -- If we did a combo also, we need to enqueue the attack graphic one row higher cause thats where the chain card will be. rowOffset = 1 end - self.outgoingGarbage:addChainLink(self.game_stopwatch, coordinate.column, coordinate.row + rowOffset) + self.outgoingGarbage:addChainLink(self.stopWatch, coordinate.column, coordinate.row + rowOffset) end end diff --git a/common/tests/engine/GarbageQueueTestingUtils.lua b/common/tests/engine/GarbageQueueTestingUtils.lua index c0bca785..2998d011 100644 --- a/common/tests/engine/GarbageQueueTestingUtils.lua +++ b/common/tests/engine/GarbageQueueTestingUtils.lua @@ -44,11 +44,13 @@ function GarbageQueueTestingUtils.createMatch(stackHealth, attackFile) match:start() -- make some space for garbage to fall +---@diagnostic disable-next-line: param-type-mismatch GarbageQueueTestingUtils.reduceRowsTo(match.stacks[1], 0) return match end +---@param match Match function GarbageQueueTestingUtils.runToFrame(match, frame) local stack = match.stacks[1] while stack.clock < frame do @@ -61,6 +63,7 @@ function GarbageQueueTestingUtils.runToFrame(match, frame) end -- clears panels until only "count" rows are left +---@param stack Stack function GarbageQueueTestingUtils.reduceRowsTo(stack, count) for row = #stack.panels, count + 1 do for col = 1, stack.width do @@ -70,6 +73,7 @@ function GarbageQueueTestingUtils.reduceRowsTo(stack, count) end -- fill up panels with non-matching panels until "count" rows are filled +---@param stack Stack function GarbageQueueTestingUtils.fillRowsTo(stack, count) for row = 1, count do if not stack.panels[row] then @@ -84,17 +88,20 @@ function GarbageQueueTestingUtils.fillRowsTo(stack, count) end end +---@param stack Stack function GarbageQueueTestingUtils.simulateActivity(stack) stack.hasActivePanels = function() return true end end +---@param stack Stack function GarbageQueueTestingUtils.simulateInactivity(stack) stack.hasActivePanels = function() return false end end +---@param stack Stack function GarbageQueueTestingUtils.sendGarbage(stack, width, height, chain, metal, time) -- -1 cause this will get called after the frame ended instead of during the frame - local frameEarned = time or stack.game_stopwatch + local frameEarned = time or stack.stopWatch local isChain = chain or false local isMetal = metal or false diff --git a/common/tests/engine/StackReplayTests.lua b/common/tests/engine/StackReplayTests.lua index 2d0d480d..9dcb158a 100644 --- a/common/tests/engine/StackReplayTests.lua +++ b/common/tests/engine/StackReplayTests.lua @@ -139,7 +139,7 @@ local function basicTimeAttackTest() assert(match.engineVersion == consts.ENGINE_VERSIONS.TELEGRAPH_COMPATIBLE) assert(match.timeLimit ~= nil) assert(match.panelSource.seed == 3490465) - assert(match.stacks[1].game_stopwatch == 7200) + assert(match.stacks[1].stopWatch == 7200) assert(match.stacks[1].levelData.maxHealth == 1) assert(match.stacks[1].score == 10353) assert(tableUtils.count(match.stacks[1].outgoingGarbage.history, function(g) return g.isChain end) == 8) From 879704e905a71a61f89667c10e3b3ac526810ab1 Mon Sep 17 00:00:00 2001 From: Endaris Date: Wed, 16 Jul 2025 00:15:05 +0200 Subject: [PATCH 05/12] rename game_stopwatch_running to stopWatchIsRunning --- common/engine/BaseStack.lua | 6 +++--- common/engine/SimulatedStack.lua | 1 + common/engine/Stack.lua | 23 +++++++++++------------ 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/common/engine/BaseStack.lua b/common/engine/BaseStack.lua index 0b278efd..709cb196 100644 --- a/common/engine/BaseStack.lua +++ b/common/engine/BaseStack.lua @@ -11,7 +11,7 @@ local MatchRules = require("common.data.MatchRules") ---@field framesBehind integer How far behind the stack is at the current Match clock time ---@field clock integer how many times run has been called; this is equivalent to how many inputs have been processed ---@field stopWatch integer how many times the simulation has run; unlike a clock and just like a stopWatch this frame timer only runs when the simulation is running ----@field game_stopwatch_running boolean if the stack is simulating during runs +---@field stopWatchIsRunning boolean if the stack is running the simulation during runs ---@field game_over_clock integer What the clock time was when the Stack went game over ---@field do_countdown boolean if the stack is performing a countdown at the start of the match ---@field countdown_timer boolean? ephemeral timer used for tracking countdown progress at the start of the game @@ -62,7 +62,7 @@ function(self, args) self.framesBehind = 0 self.clock = 0 self.stopWatch = 0 - self.game_stopwatch_running = true + self.stopWatchIsRunning = true self.game_over_clock = -1 -- the exact clock frame the stack lost, -1 while alive Signal.turnIntoEmitter(self) self:createSignal("gameOver") @@ -116,7 +116,7 @@ end ---@param doCountdown boolean function BaseStack:setCountdown(doCountdown) self.do_countdown = doCountdown - self.game_stopwatch_running = not self.do_countdown + self.stopWatchIsRunning = not self.do_countdown end ---@param maxRunsPerFrame integer diff --git a/common/engine/SimulatedStack.lua b/common/engine/SimulatedStack.lua index 03bb1cf3..e9df6ea1 100644 --- a/common/engine/SimulatedStack.lua +++ b/common/engine/SimulatedStack.lua @@ -39,6 +39,7 @@ function SimulatedStack:addHealth(healthSettings) end function SimulatedStack:run() + -- TODO: integrate this with stopWatchIsRunning instead of relying on the do_countdown field if self.do_countdown and self.countdown_timer > 0 then if self.healthEngine then self.healthEngine.clock = self.clock diff --git a/common/engine/Stack.lua b/common/engine/Stack.lua index f00a4f8a..a61076a7 100644 --- a/common/engine/Stack.lua +++ b/common/engine/Stack.lua @@ -110,7 +110,6 @@ local DIRECTION_ROW = {up = 1, down = -1, left = 0, right = 0} --- panel[i] gets the row where i is the index of the row with 1 being the bottommost row in play (not dimmed) \n --- panel[i][j] gets the panel at row i where j is the column index counting from left to right starting from 1 \n --- the update order for panels is bottom to top and left to right as well ----@field game_stopwatch_running boolean set to false if countdown starts ---@field displacement integer This variable indicates how far below the top of the play area the top row of panels actually is. \n --- This variable being decremented causes the stack to rise. \n --- During the automatic rising routine, if this variable is 0, it's reset to 15, all the panels are moved up one row, and a new row is generated at the bottom. \n @@ -183,7 +182,7 @@ local Stack = class( s.inputMethod = args.inputMethod if s.behaviours.delaySimulationUntil then - s.game_stopwatch_running = false + s.stopWatchIsRunning = false end s.swapStallingBackLog = {} @@ -396,7 +395,7 @@ function Stack:rollbackCopy() copy.countdown_timer = self.countdown_timer copy.clock = self.clock copy.stopWatch = self.stopWatch - copy.game_stopwatch_running = self.game_stopwatch_running + copy.stopWatchIsRunning = self.stopWatchIsRunning copy.rise_lock = self.rise_lock copy.top_cur_row = self.top_cur_row copy.displacement = self.displacement @@ -452,7 +451,7 @@ local function internalRollbackToFrame(stack, frame) stack.countdown_timer = copy.countdown_timer stack.clock = copy.clock stack.stopWatch = copy.stopWatch - stack.game_stopwatch_running = copy.game_stopwatch_running + stack.stopWatchIsRunning = copy.stopWatchIsRunning stack.rise_lock = copy.rise_lock stack.top_cur_row = copy.top_cur_row stack.displacement = copy.displacement @@ -771,25 +770,25 @@ function Stack:run() if self.behaviours.delaySimulationUntil == "countdownEnded" and self.clock <= (consts.COUNTDOWN_START + consts.COUNTDOWN_LENGTH) then self:runCountdown() if self.clock == (consts.COUNTDOWN_START + consts.COUNTDOWN_LENGTH) then - self.game_stopwatch_running = true + self.stopWatchIsRunning = true end end --prof.push("Stack:simulate") - if self.game_stopwatch_running then + if self.stopWatchIsRunning then self:simulate() else -- these behaviours need to run "half a frame" on their first one to give the first swap the chance to queue to prevent instant game over on the next one -- otherwise, if health is 1 and no stop/shake is given and the stack is topped out, passive raise will instakill if self.behaviours.delaySimulationUntil == "firstInput" then if self.input_state ~= self:idleInput() then - self.game_stopwatch_running = true + self.stopWatchIsRunning = true -- need to compensate the fact that we increment stopWatch at the end of the frame without having simulated self.stopWatch = -1 end elseif self.behaviours.delaySimulationUntil == "firstSwap" then if self.swapThisFrame then - self.game_stopwatch_running = true + self.stopWatchIsRunning = true self.stopWatch = -1 end end @@ -812,7 +811,7 @@ function Stack:run() self:handleManualRaise() - if self.game_stopwatch_running then + if self.stopWatchIsRunning then prof.push("pop from incoming garbage q") if self:shouldDropGarbage() then self:tryDropGarbage() @@ -1645,7 +1644,7 @@ function Stack:checkGameOver() -- but also as a negative (accidently killing yourself in non-threatening circumstances) return true end - elseif not self:hasActivePanels() and not self:swapQueued() and self.game_stopwatch_running then + elseif not self:hasActivePanels() and not self:swapQueued() and self.stopWatchIsRunning then if stackOverCondition == MatchRules.StackOverConditions.SWAPS then if self.swapCount >= value then return true @@ -1758,12 +1757,12 @@ function Stack:setCountdown(doCountdown) self.do_countdown = doCountdown if doCountdown then self.behaviours.delaySimulationUntil = "countdownEnded" - self.game_stopwatch_running = false + self.stopWatchIsRunning = false else if self.behaviours.delaySimulationUntil == "countdownEnded" then self.behaviours.delaySimulationUntil = nil end - self.game_stopwatch_running = not self.behaviours.delaySimulationUntil + self.stopWatchIsRunning = not self.behaviours.delaySimulationUntil end end From 645aea024cd02e17cbe00330b4d59dcd3d474ea6 Mon Sep 17 00:00:00 2001 From: Endaris Date: Wed, 16 Jul 2025 00:38:21 +0200 Subject: [PATCH 06/12] rename simulate to runPhysics adapt stopWatchIsRunning for SimulatedStack remove obsolete check for countdown in checkMatches --- common/engine/BaseStack.lua | 8 +++--- common/engine/SimulatedStack.lua | 47 +++++++++++++++++++------------- common/engine/Stack.lua | 4 +-- common/engine/checkMatches.lua | 4 --- 4 files changed, 34 insertions(+), 29 deletions(-) diff --git a/common/engine/BaseStack.lua b/common/engine/BaseStack.lua index 709cb196..5409cc39 100644 --- a/common/engine/BaseStack.lua +++ b/common/engine/BaseStack.lua @@ -9,11 +9,11 @@ local MatchRules = require("common.data.MatchRules") ---@field is_local boolean effectively if the Stack is receiving its inputs via local input ---@field framesBehindArray integer[] Records how far behind the stack was at each match clock time ---@field framesBehind integer How far behind the stack is at the current Match clock time ----@field clock integer how many times run has been called; this is equivalent to how many inputs have been processed ----@field stopWatch integer how many times the simulation has run; unlike a clock and just like a stopWatch this frame timer only runs when the simulation is running ----@field stopWatchIsRunning boolean if the stack is running the simulation during runs +---@field clock integer how many times run has been called; this is equivalent to how many inputs have been processed;
This is the chief timer to measure synchronicity and the driver of rollback and inputs +---@field stopWatch integer how many times the game physics have run; unlike a clock and just like a stopWatch this frame timer only runs when the simulation is running +---@field stopWatchIsRunning boolean if the stack is running the game physics during runs ---@field game_over_clock integer What the clock time was when the Stack went game over ----@field do_countdown boolean if the stack is performing a countdown at the start of the match +---@field do_countdown boolean if the stack is currently performing a countdown / will perform a countdown at the start of the match;
this is state, the value will change at the end of countdown ---@field countdown_timer boolean? ephemeral timer used for tracking countdown progress at the start of the game ---@field outgoingGarbage GarbageQueue ---@field incomingGarbage GarbageQueue diff --git a/common/engine/SimulatedStack.lua b/common/engine/SimulatedStack.lua index e9df6ea1..78657a04 100644 --- a/common/engine/SimulatedStack.lua +++ b/common/engine/SimulatedStack.lua @@ -39,41 +39,50 @@ function SimulatedStack:addHealth(healthSettings) end function SimulatedStack:run() - -- TODO: integrate this with stopWatchIsRunning instead of relying on the do_countdown field - if self.do_countdown and self.countdown_timer > 0 then + if self.stopWatchIsRunning then + self:runPhysics() + elseif self.do_countdown and self.countdown_timer > 0 then if self.healthEngine then self.healthEngine.clock = self.clock end if self.clock >= consts.COUNTDOWN_START then self.countdown_timer = self.countdown_timer - 1 end - else - if self.attackEngine then - self.attackEngine:run() + if self.countdown_timer == 0 then + self.do_countdown = nil + self.stopWatchIsRunning = true end + else + error("stopWatch of SimulatedStack is not running but neither is the countdown") + end - self.outgoingGarbage:processStagedGarbageForClock(self.stopWatch) + self.clock = self.clock + 1 - if self.healthEngine then - -- perform the equivalent of queued garbage being dropped - -- except a little quicker than on real stacks - for i = #self.incomingGarbage.stagedGarbage, 1, -1 do - self.healthEngine:receiveGarbage(self.clock, self.incomingGarbage:pop()) - end + self:emitSignal("finishedRun") +end - self.health = self.healthEngine:run() - end +function SimulatedStack:runPhysics() + if self.attackEngine then + self.attackEngine:run() + end + + self.outgoingGarbage:processStagedGarbageForClock(self.stopWatch) - if self.health <= 0 then - self:setGameOver() + if self.healthEngine then + -- perform the equivalent of queued garbage being dropped + -- except a little quicker than on real stacks + for i = #self.incomingGarbage.stagedGarbage, 1, -1 do + self.healthEngine:receiveGarbage(self.clock, self.incomingGarbage:pop()) end - self.stopWatch = self.stopWatch + 1 + self.health = self.healthEngine:run() end - self.clock = self.clock + 1 + if self.health <= 0 then + self:setGameOver() + end - self:emitSignal("finishedRun") + self.stopWatch = self.stopWatch + 1 end function SimulatedStack:setGameOver() diff --git a/common/engine/Stack.lua b/common/engine/Stack.lua index a61076a7..fd1889af 100644 --- a/common/engine/Stack.lua +++ b/common/engine/Stack.lua @@ -776,7 +776,7 @@ function Stack:run() --prof.push("Stack:simulate") if self.stopWatchIsRunning then - self:simulate() + self:runPhysics() else -- these behaviours need to run "half a frame" on their first one to give the first swap the chance to queue to prevent instant game over on the next one -- otherwise, if health is 1 and no stop/shake is given and the stack is topped out, passive raise will instakill @@ -927,7 +927,7 @@ function Stack:shouldDropGarbage() end -- One run of the engine routine. -function Stack:simulate() +function Stack:runPhysics() table.clear(self.garbageLandedThisFrame) self.wasToppedOut = self:isToppedOut() diff --git a/common/engine/checkMatches.lua b/common/engine/checkMatches.lua index 6dc79608..248b9560 100644 --- a/common/engine/checkMatches.lua +++ b/common/engine/checkMatches.lua @@ -109,10 +109,6 @@ local function canMatch(panel) end function Stack:checkMatches() - if self.do_countdown then - return - end - prof.push("Stack:checkMatches") --local reference = self:getMatchingPanels2() local matchingPanels = self:getMatchingPanels() From 63dd41932a65aa48161bb879d4c39da061ae3059 Mon Sep 17 00:00:00 2001 From: Endaris Date: Wed, 16 Jul 2025 17:01:04 +0200 Subject: [PATCH 07/12] fix a bug with live rollback (introduced on this branch) and add a test that simulates live desync change signatures of rollback related functions to clearly indicate whether they are operating on clock or stopWatch enable forced desync in matches with a dedicated function and remove its reference to the client's global config --- client/src/ClientMatch.lua | 8 +++ common/compatibility/LegacyPanelSource.lua | 12 ++--- common/engine/AttackEngine.lua | 16 +++--- common/engine/BaseStack.lua | 8 +-- common/engine/GarbageQueue.lua | 22 ++++----- common/engine/GeneratorSource.lua | 12 ++--- common/engine/Health.lua | 8 +-- common/engine/Match.lua | 49 ++++++++++++++----- common/engine/PuzzleSource.lua | 12 ++--- common/engine/SimulatedStack.lua | 22 ++++----- common/engine/Stack.lua | 24 ++++----- .../tests/engine/StackRollbackReplayTests.lua | 17 ++++++- 12 files changed, 128 insertions(+), 82 deletions(-) diff --git a/client/src/ClientMatch.lua b/client/src/ClientMatch.lua index ded69b73..294cab8e 100644 --- a/client/src/ClientMatch.lua +++ b/client/src/ClientMatch.lua @@ -96,6 +96,10 @@ end function ClientMatch.createFromReplay(replay, players) local engine = Match.createFromReplay(replay) + if config.debug_vsFramesBehind and config.debug_vsFramesBehind > 0 then + engine:enableDebugDesync(true, config.debug_vsFramesBehind) + end + -- we only need to reconstruct the players from the metadata -- unless we already got them passed in players = players or {} @@ -147,6 +151,10 @@ end function ClientMatch:setup() self.engine = Match(self.panelSource, self.matchRules) + if config.debug_vsFramesBehind and config.debug_vsFramesBehind > 0 then + self.engine:enableDebugDesync(true, config.debug_vsFramesBehind) + end + self.stacks = {} for i, player in ipairs(self.players) do diff --git a/common/compatibility/LegacyPanelSource.lua b/common/compatibility/LegacyPanelSource.lua index 10e12454..91b34cc5 100644 --- a/common/compatibility/LegacyPanelSource.lua +++ b/common/compatibility/LegacyPanelSource.lua @@ -197,7 +197,7 @@ function LegacyPanelSource:clone(stack) return source end -function LegacyPanelSource:saveForRollback(frame) +function LegacyPanelSource:saveForRollback(clock) local copy = self.rollbackBuffer:getOldest() if not copy then @@ -209,11 +209,11 @@ function LegacyPanelSource:saveForRollback(frame) copy.panelGenCount = self.panelGenCount copy.garbageGenCount = self.garbageGenCount - self.rollbackBuffer:saveCopy(frame, copy) + self.rollbackBuffer:saveCopy(clock, copy) end -function LegacyPanelSource:rollbackToFrame(frame) - local copy = self.rollbackBuffer:rollbackToFrame(frame) +function LegacyPanelSource:rollbackToFrame(clock) + local copy = self.rollbackBuffer:rollbackToFrame(clock) if not copy then error("Could not rollback LegacyPanelSource") @@ -225,8 +225,8 @@ function LegacyPanelSource:rollbackToFrame(frame) self.garbageGenCount = copy.garbageGenCount end -function LegacyPanelSource:rewindToFrame(frame) - self:rollbackToFrame(frame) +function LegacyPanelSource:rewindToFrame(clock) + self:rollbackToFrame(clock) end return LegacyPanelSource \ No newline at end of file diff --git a/common/engine/AttackEngine.lua b/common/engine/AttackEngine.lua index a004dab8..778acc06 100644 --- a/common/engine/AttackEngine.lua +++ b/common/engine/AttackEngine.lua @@ -150,18 +150,18 @@ function AttackEngine.run(self) self.stopWatch = self.stopWatch + 1 end -function AttackEngine:saveForRollback(frame) - self.outgoingGarbage:saveForRollback(frame) +function AttackEngine:saveForRollback(stopWatch) + self.outgoingGarbage:saveForRollback(stopWatch) end -function AttackEngine:rollbackToFrame(frame) - self.outgoingGarbage:rollbackToFrame(frame) - self.stopWatch = frame +function AttackEngine:rollbackToFrame(stopWatch) + self.outgoingGarbage:rollbackToFrame(stopWatch) + self.stopWatch = stopWatch end -function AttackEngine:rewindToFrame(frame) - self.outgoingGarbage:rewindToFrame(frame) - self.stopWatch = frame +function AttackEngine:rewindToFrame(stopWatch) + self.outgoingGarbage:rewindToFrame(stopWatch) + self.stopWatch = stopWatch end return AttackEngine \ No newline at end of file diff --git a/common/engine/BaseStack.lua b/common/engine/BaseStack.lua index 5409cc39..23f5627d 100644 --- a/common/engine/BaseStack.lua +++ b/common/engine/BaseStack.lua @@ -161,15 +161,15 @@ function BaseStack:saveForRollback() error("did not implement saveForRollback") end ----@param frame integer the frame to rollback to if possible +---@param clock integer the frame to rollback to if possible ---@return boolean success if rolling back succeeded -function BaseStack:rollbackToFrame(frame) +function BaseStack:rollbackToFrame(clock) error("did not implement rollbackToFrame") end ----@param frame integer the frame to rewind to if possible +---@param clock integer the frame to rewind to if possible ---@return boolean success if rewinding succeeded -function BaseStack:rewindToFrame(frame) +function BaseStack:rewindToFrame(clock) error("did not implement rewindToFrame") end diff --git a/common/engine/GarbageQueue.lua b/common/engine/GarbageQueue.lua index d5f4e037..97522cd1 100644 --- a/common/engine/GarbageQueue.lua +++ b/common/engine/GarbageQueue.lua @@ -202,13 +202,13 @@ function GarbageQueue:saveForRollback(frame) self.rollbackBuffer:saveCopy(frame, copy) end ----@param frame integer -function GarbageQueue:rollbackToFrame(frame) - assert(self.rollbackBuffer, "Attempted to rollback garbage queue to frame " .. frame .. " but no rollback buffer has been kept") +---@param stopWatch integer +function GarbageQueue:rollbackToFrame(stopWatch) + assert(self.rollbackBuffer, "Attempted to rollback garbage queue to frame " .. stopWatch .. " but no rollback buffer has been kept") - local copy = self.rollbackBuffer:rollbackToFrame(frame) + local copy = self.rollbackBuffer:rollbackToFrame(stopWatch) - assert(copy, "Attempted to rollback garbage queue to frame " .. frame .. " but no rollback copy was available") + assert(copy, "Attempted to rollback garbage queue to frame " .. stopWatch .. " but no rollback copy was available") self.stagedGarbage = copy.stagedGarbage self.currentChain = copy.currentChain @@ -221,20 +221,20 @@ function GarbageQueue:rollbackToFrame(frame) -- this may not universally work for multiplayer with more than 2 players for i = self.transitTimers.last, self.transitTimers.first, -1 do local transitFrame = self.transitTimers[i] - if transitFrame >= frame + GARBAGE_DELAY_LAND_TIME then + if transitFrame >= stopWatch + GARBAGE_DELAY_LAND_TIME then self.garbageInTransit[transitFrame] = nil self.transitTimers.last = self.transitTimers.last - 1 end end end ----@param frame integer -function GarbageQueue:rewindToFrame(frame) - assert(self.rollbackBuffer, "Attempted to rewind garbage queue to frame " .. frame .. " but no rollback buffer has been kept") +---@param stopWatch integer +function GarbageQueue:rewindToFrame(stopWatch) + assert(self.rollbackBuffer, "Attempted to rewind garbage queue to frame " .. stopWatch .. " but no rollback buffer has been kept") - local copy = self.rollbackBuffer:rollbackToFrame(frame) + local copy = self.rollbackBuffer:rollbackToFrame(stopWatch) - assert(copy, "Attempted to rewind garbage queue to frame " .. frame .. " but no rollback copy was available") + assert(copy, "Attempted to rewind garbage queue to frame " .. stopWatch .. " but no rollback copy was available") self.stagedGarbage = copy.stagedGarbage self.currentChain = copy.currentChain diff --git a/common/engine/GeneratorSource.lua b/common/engine/GeneratorSource.lua index 060eef55..e88b3604 100644 --- a/common/engine/GeneratorSource.lua +++ b/common/engine/GeneratorSource.lua @@ -222,7 +222,7 @@ function GeneratorSource:clone(stack) return source end -function GeneratorSource:saveForRollback(frame) +function GeneratorSource:saveForRollback(clock) local copy = self.rollbackBuffer:getOldest() if not copy then @@ -236,11 +236,11 @@ function GeneratorSource:saveForRollback(frame) copy.adjacentAccepted = self.panelGenerator.adjacentAccepted copy.adjacentDenied = self.panelGenerator.adjacentDenied - self.rollbackBuffer:saveCopy(frame, copy) + self.rollbackBuffer:saveCopy(clock, copy) end -function GeneratorSource:rollbackToFrame(frame) - local copy = self.rollbackBuffer:rollbackToFrame(frame) +function GeneratorSource:rollbackToFrame(clock) + local copy = self.rollbackBuffer:rollbackToFrame(clock) if not copy then error("Could not rollback GeneratorSource") @@ -254,8 +254,8 @@ function GeneratorSource:rollbackToFrame(frame) self.panelGenerator.adjacentDenied = copy.adjacentDenied end -function GeneratorSource:rewindToFrame(frame) - self:rollbackToFrame(frame) +function GeneratorSource:rewindToFrame(clock) + self:rollbackToFrame(clock) end return GeneratorSource \ No newline at end of file diff --git a/common/engine/Health.lua b/common/engine/Health.lua index 0d0ce446..bb97b6e8 100644 --- a/common/engine/Health.lua +++ b/common/engine/Health.lua @@ -120,10 +120,10 @@ function Health:saveRollbackCopy() end end -function Health:rollbackToFrame(frame) - local copy = self.rollbackCopies[frame] +function Health:rollbackToFrame(clock) + local copy = self.rollbackCopies[clock] - for i = frame + 1, self.clock do + for i = clock + 1, self.clock do self.rollbackCopyPool:push(self.rollbackCopies[i]) self.rollbackCopies[i] = nil end @@ -132,7 +132,7 @@ function Health:rollbackToFrame(frame) self.currentLines = copy.currentLines self.framesToppedOutToLose = copy.framesToppedOutToLose self.lastWasFourCombo = copy.lastWasFourCombo - self.clock = frame + self.clock = clock end ---@return HealthSettings diff --git a/common/engine/Match.lua b/common/engine/Match.lua index 2f02d277..384422c1 100644 --- a/common/engine/Match.lua +++ b/common/engine/Match.lua @@ -30,6 +30,8 @@ local MatchRules = require("common.data.MatchRules") ---@field clock integer ---@field ended boolean ---@field gameOverClock integer? +---@field debugDesync boolean? if the Match will purposely let the second stack fall behind for the purpose of debugging and testing rollback and related features +---@field debugDesyncValue integer? by how many frames the second stack will fall behind if desyncDebug is on -- A match is a particular instance of the game, for example 1 time attack round, or 1 vs match ---@class Match @@ -287,7 +289,7 @@ function Match:pushGarbageTo(stack) -- hypothetically, IF the receiving stack's garbage target was different than the sender forcing the rollback here -- it may be necessary to perform extra steps to ensure the recipient of the stack getting rolled back is getting correct garbage -- which may even include another rollback - if not self:rollbackToFrame(stack, oldestTransitTime) and not stack.incomingGarbage.illegalStuffIsAllowed then + if not self:rollbackToStopWatch(stack, oldestTransitTime) and not stack.incomingGarbage.illegalStuffIsAllowed then -- if we can't rollback, it's a desync self.desyncError = true self:abort() @@ -323,12 +325,20 @@ function Match:shouldSaveRollback(stack) end end +-- attempt to rollback the specified stack to the specified stopWatch +---@param stack BaseStack +---@param stopWatch integer +---@return boolean success +function Match:rollbackToStopWatch(stack, stopWatch) + return self:rollbackToFrame(stack, stopWatch + (stack.clock - stack.stopWatch)) +end + -- attempt to rollback the specified stack to the specified frame ---@param stack BaseStack ----@param frame integer +---@param clock integer ---@return boolean success -function Match:rollbackToFrame(stack, frame) - if stack:rollbackToFrame(frame) then +function Match:rollbackToFrame(stack, clock) + if stack:rollbackToFrame(clock) then return true end @@ -337,17 +347,17 @@ end -- rewind is ONLY to be used for replay playback as it relies on all stacks being at the same clock time -- and also uses slightly different data required only in a both-sides rollback scenario that would never occur for online rollback ----@param frame integer -function Match:rewindToFrame(frame) +---@param clock integer +function Match:rewindToFrame(clock) local failed = false for i, stack in ipairs(self.stacks) do - if not stack:rewindToFrame(frame) then + if not stack:rewindToFrame(clock) then failed = true break end end if not failed then - self.clock = frame + self.clock = clock self.ended = false end end @@ -600,11 +610,11 @@ function Match:shouldRun(stack, runsSoFar) end end - -- In debug mode allow non-local player 2 to fall a certain number of frames behind - if config and config.debug_mode and not stack.is_local and config.debug_vsFramesBehind and config.debug_vsFramesBehind > 0 and tableUtils.indexOf(self.stacks, stack) == 2 then - -- Only stay behind if the game isn't over for the local player (=garbageTarget) yet - if self.garbageTargets[2][1] and self.garbageTargets[2][1].game_ended and self.garbageTargets[2][1]:game_ended() == false then - if stack.clock + config.debug_vsFramesBehind >= self.garbageTargets[2][1].clock then + if self.debugDesync and not stack.is_local and tableUtils.indexOf(self.stacks, stack) == 2 then + -- force non-local player 2 to fall behind a certain number of frames to force rollback + if self.garbageTargets[2][1] and self.garbageTargets[2][1]:game_ended() == false then + -- but only stay behind if the game isn't over for the local player (=garbageTarget) yet as the second stack has to run to game over clock for the match to end + if stack.clock + self.debugDesyncValue >= self.garbageTargets[2][1].clock then return false end end @@ -614,6 +624,19 @@ function Match:shouldRun(stack, runsSoFar) return stack:shouldRun(runsSoFar) end +---@param enable boolean? true if the second stack should fall behind, false if they should stay in sync as much as possible +---@param value integer? by how many frames the second stack should be behind; defaults to 120 if enabled
+--- careful with setting this too high when using this with replays: if stack 1 is the losing one it can happen that they never receive the garbage that topped them out +--- because the second stack does not simulate far enough to send it, causing the replay to get stuck +function Match:enableDebugDesync(enable, value) + self.debugDesync = enable + if not self.debugDesync then + self.debugDesyncValue = nil + else + self.debugDesyncValue = value or 120 + end +end + function Match:setCountdown(doCountdown) self.doCountdown = doCountdown self.rules.doCountdown = doCountdown diff --git a/common/engine/PuzzleSource.lua b/common/engine/PuzzleSource.lua index ccada70f..53895d26 100644 --- a/common/engine/PuzzleSource.lua +++ b/common/engine/PuzzleSource.lua @@ -223,7 +223,7 @@ function PuzzleSource:clone(stack) return source end -function PuzzleSource:saveForRollback(frame) +function PuzzleSource:saveForRollback(clock) local copy = self.rollbackBuffer:getOldest() if not copy then @@ -236,11 +236,11 @@ function PuzzleSource:saveForRollback(frame) copy.garbageGenCount = self.garbageGenCount -- self.panels is not stored under the assumption that panels always get fully consumed on the frame they got created - self.rollbackBuffer:saveCopy(frame, copy) + self.rollbackBuffer:saveCopy(clock, copy) end -function PuzzleSource:rollbackToFrame(frame) - local copy = self.rollbackBuffer:rollbackToFrame(frame) +function PuzzleSource:rollbackToFrame(clock) + local copy = self.rollbackBuffer:rollbackToFrame(clock) if not copy then error("Could not rollback PuzzleSource") @@ -252,8 +252,8 @@ function PuzzleSource:rollbackToFrame(frame) self.garbageGenCount = copy.garbageGenCount end -function PuzzleSource:rewindToFrame(frame) - self:rollbackToFrame(frame) +function PuzzleSource:rewindToFrame(clock) + self:rollbackToFrame(clock) end return PuzzleSource \ No newline at end of file diff --git a/common/engine/SimulatedStack.lua b/common/engine/SimulatedStack.lua index 78657a04..1e2d23f8 100644 --- a/common/engine/SimulatedStack.lua +++ b/common/engine/SimulatedStack.lua @@ -148,11 +148,11 @@ function SimulatedStack:saveForRollback() end end -local function internalRollbackToFrame(stack, frame) - local copy = stack.rollbackCopies[frame] +local function internalRollbackToFrame(stack, clock) + local copy = stack.rollbackCopies[clock] - if copy and frame < stack.clock then - for f = frame, stack.clock do + if copy and clock < stack.clock then + for f = clock, stack.clock do if stack.rollbackCopies[f] then stack.rollbackCopyPool:push(stack.rollbackCopies[f]) stack.rollbackCopies[f] = nil @@ -160,7 +160,7 @@ local function internalRollbackToFrame(stack, frame) end if stack.healthEngine then - stack.healthEngine:rollbackToFrame(frame) + stack.healthEngine:rollbackToFrame(clock) stack.health = stack.healthEngine.framesToppedOutToLose else stack.health = copy.health @@ -175,8 +175,8 @@ local function internalRollbackToFrame(stack, frame) return false end -function SimulatedStack:rollbackToFrame(frame) - if internalRollbackToFrame(self, frame) then +function SimulatedStack:rollbackToFrame(clock) + if internalRollbackToFrame(self, clock) then self.incomingGarbage:rollbackToFrame(self.stopWatch) if self.attackEngine then @@ -184,22 +184,22 @@ function SimulatedStack:rollbackToFrame(frame) end self.lastRollbackFrame = self.clock - self.clock = frame + self.clock = clock return true end return false end -function SimulatedStack:rewindToFrame(frame) - if internalRollbackToFrame(self, frame) then +function SimulatedStack:rewindToFrame(clock) + if internalRollbackToFrame(self, clock) then self.incomingGarbage:rewindToFrame(self.stopWatch) if self.attackEngine then self.attackEngine:rewindToFrame(self.stopWatch) end - self.clock = frame + self.clock = clock return true end diff --git a/common/engine/Stack.lua b/common/engine/Stack.lua index fd1889af..5e1b5d93 100644 --- a/common/engine/Stack.lua +++ b/common/engine/Stack.lua @@ -440,9 +440,9 @@ function Stack:rollbackCopy() end ---@param stack Stack ----@param frame integer -local function internalRollbackToFrame(stack, frame) - local copy = stack.rollbackBuffer:rollbackToFrame(frame) +---@param clock integer +local function internalRollbackToFrame(stack, clock) + local copy = stack.rollbackBuffer:rollbackToFrame(clock) if not copy then return false @@ -518,7 +518,7 @@ local function internalRollbackToFrame(stack, frame) -- this is for the interpolation of the shake animation only (not a physics relevant field) local previousData = stack.rollbackBuffer:peekPrevious() - if previousData and previousData.clock == frame - 1 then + if previousData and previousData.clock == clock - 1 then stack.prev_shake_time = previousData.shake_time else -- if this is the oldest rollback frame we don't need to interpolate with previous values @@ -530,15 +530,15 @@ local function internalRollbackToFrame(stack, frame) return true end ----@param frame integer the frame to rollback to if possible +---@param clock integer the frame to rollback to if possible ---@return boolean success if rolling back succeeded -function Stack:rollbackToFrame(frame) +function Stack:rollbackToFrame(clock) local currentFrame = self.clock - if internalRollbackToFrame(self, frame) then + if internalRollbackToFrame(self, clock) then self.incomingGarbage:rollbackToFrame(self.stopWatch) self.outgoingGarbage:rollbackToFrame(self.stopWatch) - self.panelSource:rollbackToFrame(frame) + self.panelSource:rollbackToFrame(clock) self.rollbackCount = self.rollbackCount + 1 -- match will try to fast forward this stack to that frame @@ -550,13 +550,13 @@ function Stack:rollbackToFrame(frame) return false end ----@param frame integer the frame to rewind to if possible +---@param clock integer the frame to rewind to if possible ---@return boolean success if rewinding succeeded -function Stack:rewindToFrame(frame) - if internalRollbackToFrame(self, frame) then +function Stack:rewindToFrame(clock) + if internalRollbackToFrame(self, clock) then self.incomingGarbage:rewindToFrame(self.stopWatch) self.outgoingGarbage:rewindToFrame(self.stopWatch) - self.panelSource:rewindToFrame(frame) + self.panelSource:rewindToFrame(clock) self:emitSignal("rollbackPerformed", self) return true diff --git a/common/tests/engine/StackRollbackReplayTests.lua b/common/tests/engine/StackRollbackReplayTests.lua index 27068050..357f3e2b 100644 --- a/common/tests/engine/StackRollbackReplayTests.lua +++ b/common/tests/engine/StackRollbackReplayTests.lua @@ -196,6 +196,18 @@ local function rollbackFromDeath() assert(stack.game_over_clock == 652) end +local function liveDesync() + local match = StackReplayTestingUtils:setupReplayWithPath(testReplayFolder .. "v046-2023-01-28-02-39-32-JamBox-L10-vs-Galadic97-L10-Casual-P1wins.txt") + match:enableDebugDesync(true, 120) + + StackReplayTestingUtils:fullySimulateMatch(match) + + assert(match.ended and not match.aborted) + assert(not match:isIrrecoverablyDesynced()) + assert(match.stacks[1].rollbackCount == 5) + assert(match.gameOverClock == 2039) +end + logger.info("running rollbackFromDeath") rollbackFromDeath() @@ -206,4 +218,7 @@ logger.info("running rollbackNotPastAttackTest") rollbackNotPastAttackTest() logger.info("running rollbackFullyPastAttack") -rollbackFullyPastAttack() \ No newline at end of file +rollbackFullyPastAttack() + +logger.info("running liveDesync1") +liveDesync() \ No newline at end of file From fc9c99d2ceb6380fdff21dae517a220d622cf4fd Mon Sep 17 00:00:00 2001 From: Endaris Date: Wed, 16 Jul 2025 18:15:52 +0200 Subject: [PATCH 08/12] change the new fields in the file format to use title caps with spaces --- client/src/PuzzleSet.lua | 28 ++++++++++++++++++--------- docs/puzzles.txt | 42 +++++++++++++++++++++------------------- 2 files changed, 41 insertions(+), 29 deletions(-) diff --git a/client/src/PuzzleSet.lua b/client/src/PuzzleSet.lua index 8b49cdf7..8277fff2 100644 --- a/client/src/PuzzleSet.lua +++ b/client/src/PuzzleSet.lua @@ -71,8 +71,8 @@ function PuzzleSet.loadV1(setName, puzzleSetData) for _, puzzleData in pairs(puzzleSetData) do if type(puzzleData) == "table" and #puzzleData >= 2 and type(puzzleData[1]) == "string" and type(puzzleData[2]) == "number" then local args = { - puzzleType = "moves", - startTiming = "countdown", + puzzleType = Puzzle.PUZZLE_TYPES.moves, + startTiming = Puzzle.START_TIMINGS.countdown, moves = puzzleData[2], stack = puzzleData[1] } @@ -94,8 +94,8 @@ function PuzzleSet.loadV2(puzzleSetData) local puzzles = {} for _, puzzleData in pairs(puzzleSetData["Puzzles"]) do local args = { - puzzleType = puzzleData["Puzzle Type"], - startTiming = puzzleData["Do Countdown"] and "countdown" or "immediately", + puzzleType = string.lower(puzzleData["Puzzle Type"]), + startTiming = puzzleData["Do Countdown"] and Puzzle.START_TIMINGS.countdown or Puzzle.START_TIMINGS.immediately, moves = puzzleData["Moves"], stack = puzzleData["Stack"], stopTime = puzzleData["Stop"], @@ -118,16 +118,26 @@ function PuzzleSet.loadV3(puzzleSetData) for _, puzzleData in pairs(puzzleSetData["Puzzles"] or {}) do local args = { puzzleType = string.lower(puzzleData["Puzzle Type"]), - startTiming = puzzleData["StartTiming"], moves = puzzleData["Moves"], stack = puzzleData["Stack"], stopTime = puzzleData["Stop"], shakeTime = puzzleData["Shake"], - panelBuffer = puzzleData["PanelBuffer"], - garbagePanelBuffer = puzzleData["GarbagePanelBuffer"] + panelBuffer = puzzleData["Panel Buffer"], + garbagePanelBuffer = puzzleData["Garbage Panel Buffer"] } - if puzzleData["CursorStartLeft"] then - args.cursorStartLeft = {row = puzzleData["CursorStartLeft"].Row, column = puzzleData["CursorStartLeft"].Column} + if puzzleData["Cursor Start Left"] then + args.cursorStartLeft = {row = puzzleData["Cursor Start Left"].Row, column = puzzleData["Cursor Start Left"].Column} + end + if puzzleData["Start Timing"] then + if puzzleData["Start Timing"] == "First Swap" then + args.startTiming = Puzzle.START_TIMINGS.firstSwap + elseif puzzleData["Start Timing"] == "First Input" then + args.startTiming = Puzzle.START_TIMINGS.firstInput + elseif puzzleData["Start Timing"] == "Countdown" then + args.startTiming = Puzzle.START_TIMINGS.countdown + elseif puzzleData["Start Timing"] == "Immediately" then + args.startTiming = Puzzle.START_TIMINGS.immediately + end end local puzzle = Puzzle(args) diff --git a/docs/puzzles.txt b/docs/puzzles.txt index adf2ba3a..a813da42 100644 --- a/docs/puzzles.txt +++ b/docs/puzzles.txt @@ -16,20 +16,20 @@ The contents of each puzzle file should be formatted something like this: "Set Name": "Name of Puzzle Set", "Puzzles": [ { - "Puzzle Type": "chain", - "StartTiming": "countdown", + "Puzzle Type": "Chain", + "Start Timing": "Countdown", "Moves": 0, "Stack": "040000 111440", - "CursorStartLeft": + "Cursor Start Left": { "Row": 2, "Column": 2 } }, { - "Puzzle Type": "clear", + "Puzzle Type": "Clear", "Do Countdown": false, "Moves": 0, "Stack": @@ -46,7 +46,7 @@ The contents of each puzzle file should be formatted something like this: 122[=] 245156 325363", - "StartTiming": "firstSwap", + "Start Timing": "First Swap", "Stop": 60, "Shake": 0 }, @@ -55,6 +55,7 @@ The contents of each puzzle file should be formatted something like this: ] } +Unless mentioned otherwise, all fields and names are case sensitive. Version 3 is the current version it allows "Puzzle Sets" to have recursive "Puzzle Sets" for organization purposes. "Puzzle Sets" contains a list of all the puzzle sets @@ -63,36 +64,37 @@ Version 3 is the current version it allows "Puzzle Sets" to have recursive "Puzz "Puzzles" is the list of all the puzzles "Puzzle Type" should be one of the following - "moves" all panels need to be cleared in the set number of moves - "chain" all panels need to be cleared onces the first chain ends, and there must be a chain - "clear" all garbage on the field needs to be cleared before health runs out + "Moves": all panels need to be cleared in the set number of moves + "Chain": all panels need to be cleared onces the first chain ends, and there must be a chain + "Clear": all garbage on the field needs to be cleared before health runs out +the puzzle type may also be written in lower case "Moves" the number of moves, can be zero to not have a limit "Stack" the starting arrangement of the panels, see below -"StartTiming" specifies when the simulation of the Stack starts: -- "firstSwap": Game physics are on hold until the first swap. The player can move normally. -- "firstInput": All game physics are on hold until the first player input. -- "countdown": The game starts after a 3 second countdown -- "immediately": Full simulation starts with no delay +"Start Timing" specifies when the simulation of the Stack starts: +- "First Swap": Game physics are on hold until the first swap. The player can move normally. +- "First Input": All game physics are on hold until the first player input. +- "Countdown": The game starts after a 3 second countdown +- "Immediately": Full simulation starts with no delay If no start timing is given, a suitable start timing is selected based on puzzle type: -- "clear" and "chain" puzzles will use "firstInput" if a cursor start position is given and "firstSwap" if not -- "moves" puzzles will start immediately +- "Clear" and "Chain" puzzles will use "First Input" if a cursor start position is given (see further down) and "First Swap" if not +- "Moves" puzzles will start immediately "Stop" specifies how many frames of stop time are initially granted to the player "Shake" specifies how many frames of shake time are initially granted to the player -"CursorStartLeft" specifies where the left part of the cursor should start the puzzle +"Cursor Start Left" specifies where the left part of the cursor should start the puzzle the format is { "Row": 1, "Column": 1 } -"PanelBuffer" specifies the panels that should appear if the player is raising the stack. +"Panel Buffer" specifies the panels that should appear if the player is raising the stack. If not specified these will be unmatchable grey panels. Outside of clear puzzles, raising is currently disabled. -"GarbagePanelBuffer" specifies the panel that should appear from cleared garbage. +"Garbage Panel Buffer" specifies the panel that should appear from cleared garbage. For each cleared row of garbage, the first 6 colors are taken from the string and assigned to the positions in the row accordingly. @@ -103,9 +105,9 @@ If a piece of garbage did not fill out an entire row only the colors in the spot 91[==] 919999 -If the garbagePanelBuffer is "123456123456", the bottom garbage will transform into 3456 and the top garbage will transform into 1234. +If the garbage panel buffer is "123456123456", the bottom garbage will transform into 3456 and the top garbage will transform into 1234. -Afterwards the garbagePanelBuffer will be empty. An empty buffer means that only grey unmatchable panels will appear from garbage. +Afterwards the garbage panel buffer will be empty. An empty buffer means that only grey unmatchable panels will appear from garbage. Note: carriage returns and spaces in your file are very helpful for readability, but are not necessary. From 54ac58af7c189408124e2098095dd7c4ed08423f Mon Sep 17 00:00:00 2001 From: Endaris Date: Sat, 22 Nov 2025 00:28:48 +0100 Subject: [PATCH 09/12] removed duplicate function and fields to steer Match debug behaviour --- client/src/ClientMatch.lua | 8 -------- client/src/debug/DebugSettings.lua | 4 +++- common/engine/Match.lua | 13 ------------- common/tests/engine/StackRollbackReplayTests.lua | 2 +- 4 files changed, 4 insertions(+), 23 deletions(-) diff --git a/client/src/ClientMatch.lua b/client/src/ClientMatch.lua index 408998b0..8da9f2f0 100644 --- a/client/src/ClientMatch.lua +++ b/client/src/ClientMatch.lua @@ -96,10 +96,6 @@ end function ClientMatch.createFromReplay(replay, players) local engine = Match.createFromReplay(replay) - if config.debug_vsFramesBehind and config.debug_vsFramesBehind > 0 then - engine:enableDebugDesync(true, config.debug_vsFramesBehind) - end - -- we only need to reconstruct the players from the metadata -- unless we already got them passed in players = players or {} @@ -153,10 +149,6 @@ end function ClientMatch:setupFromGameMode() self.engine = Match(self.panelSource, self.matchRules) - if config.debug_vsFramesBehind and config.debug_vsFramesBehind > 0 then - self.engine:enableDebugDesync(true, config.debug_vsFramesBehind) - end - self.stacks = {} for i, player in ipairs(self.players) do diff --git a/client/src/debug/DebugSettings.lua b/client/src/debug/DebugSettings.lua index 43b02a2d..b2254b46 100644 --- a/client/src/debug/DebugSettings.lua +++ b/client/src/debug/DebugSettings.lua @@ -356,7 +356,9 @@ function DebugSettings.setShowRuntimeGraph(value) DebugSettings.set("showRuntimeGraph", value) end --- Sets the VS frames behind value +--- Sets the VS frames behind value
+--- Careful with setting this too high when using this with replays: if stack 1 is the losing one it can happen that they never receive the garbage that topped them out +--- because the second stack does not simulate far enough to send it, causing the replay to get stuck ---@param value number function DebugSettings.setVSFramesBehind(value) DebugSettings.set("vsFramesBehind", value) diff --git a/common/engine/Match.lua b/common/engine/Match.lua index 510171b0..1a7d6acb 100644 --- a/common/engine/Match.lua +++ b/common/engine/Match.lua @@ -644,19 +644,6 @@ function Match:shouldRun(stack, runsSoFar) return stack:shouldRun(runsSoFar) end ----@param enable boolean? true if the second stack should fall behind, false if they should stay in sync as much as possible ----@param value integer? by how many frames the second stack should be behind; defaults to 120 if enabled
---- careful with setting this too high when using this with replays: if stack 1 is the losing one it can happen that they never receive the garbage that topped them out ---- because the second stack does not simulate far enough to send it, causing the replay to get stuck -function Match:enableDebugDesync(enable, value) - self.debugDesync = enable - if not self.debugDesync then - self.debugDesyncValue = nil - else - self.debugDesyncValue = value or 120 - end -end - function Match:setCountdown(doCountdown) self.doCountdown = doCountdown self.rules.doCountdown = doCountdown diff --git a/common/tests/engine/StackRollbackReplayTests.lua b/common/tests/engine/StackRollbackReplayTests.lua index 357f3e2b..a26012a6 100644 --- a/common/tests/engine/StackRollbackReplayTests.lua +++ b/common/tests/engine/StackRollbackReplayTests.lua @@ -198,7 +198,7 @@ end local function liveDesync() local match = StackReplayTestingUtils:setupReplayWithPath(testReplayFolder .. "v046-2023-01-28-02-39-32-JamBox-L10-vs-Galadic97-L10-Casual-P1wins.txt") - match:enableDebugDesync(true, 120) + match.debug.vsFramesBehind = 120 StackReplayTestingUtils:fullySimulateMatch(match) From 2128d05b0713dae548acf7cff783bcbcf93009d1 Mon Sep 17 00:00:00 2001 From: JamBox <8935453+JamesVanBoxtel@users.noreply.github.com> Date: Fri, 21 Nov 2025 21:37:55 -0800 Subject: [PATCH 10/12] Fix crash in puzzle const name change --- client/src/PuzzleSet.lua | 2 +- client/tests/PuzzleSetTests.lua | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/client/src/PuzzleSet.lua b/client/src/PuzzleSet.lua index 97ce11a1..e316a82c 100644 --- a/client/src/PuzzleSet.lua +++ b/client/src/PuzzleSet.lua @@ -264,7 +264,7 @@ function PuzzleSet.loadV3(puzzleSetData) for _, puzzleData in pairs(puzzleSetData[PuzzleSet.PUZZLE_SET_PROPERTY.PUZZLES] or {}) do local args = { - puzzleType = string.lower(puzzleData[Puzzle.PUZZLE_PROPERTY.START_TIMING]), + puzzleType = string.lower(puzzleData[Puzzle.PUZZLE_PROPERTY.TYPE]), moves = puzzleData[Puzzle.PUZZLE_PROPERTY.MOVES], stack = puzzleData[Puzzle.PUZZLE_PROPERTY.STACK], stopTime = puzzleData[Puzzle.PUZZLE_PROPERTY.STOP], diff --git a/client/tests/PuzzleSetTests.lua b/client/tests/PuzzleSetTests.lua index 0ecd35e6..3dce3e90 100644 --- a/client/tests/PuzzleSetTests.lua +++ b/client/tests/PuzzleSetTests.lua @@ -200,6 +200,25 @@ function PuzzleSetTests.testExactJSONFormatting() assert(prettified == expected, "JSON formatting should match exactly") end +function PuzzleSetTests.testLoadV3WithoutStartTiming() + -- Test that loading a v3 puzzle without StartTiming works + local puzzleSetData = { + ["Set Name"] = "Test Set", + ["Description"] = "Test description", + ["Puzzles"] = { + { + ["Puzzle Type"] = "moves", + ["Moves"] = 5, + ["Stack"] = "1254216999999952" + } + } + } + + local result = PuzzleSet.loadV3(puzzleSetData) + assert(result ~= nil, "Should have loaded puzzle set") + assert(#result.puzzles == 1, "Should have one puzzle") +end + -- Run the tests PuzzleSetTests.updatePuzzleValid() PuzzleSetTests.generateSaveDataValid() @@ -208,5 +227,6 @@ PuzzleSetTests.generateSaveDataWithOptionalFields() PuzzleSetTests.testJSONValidityRequirement1() PuzzleSetTests.testUnchangedPuzzlePreservationRequirement3() PuzzleSetTests.testExactJSONFormatting() +PuzzleSetTests.testLoadV3WithoutStartTiming() return PuzzleSetTests \ No newline at end of file From 0ff262daca45e5d040afbb56d7e7088d82032b79 Mon Sep 17 00:00:00 2001 From: JamBox <8935453+JamesVanBoxtel@users.noreply.github.com> Date: Fri, 21 Nov 2025 21:55:15 -0800 Subject: [PATCH 11/12] Fix warnings --- client/src/PuzzleSet.lua | 4 +++- common/engine/Puzzle.lua | 1 + common/tests/engine/GarbageQueueTestingUtils.lua | 14 +++++++------- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/client/src/PuzzleSet.lua b/client/src/PuzzleSet.lua index e316a82c..fea69e13 100644 --- a/client/src/PuzzleSet.lua +++ b/client/src/PuzzleSet.lua @@ -263,8 +263,10 @@ function PuzzleSet.loadV3(puzzleSetData) local puzzleSet = PuzzleSet(puzzleSetName, puzzleSetDescription, {}, {}) for _, puzzleData in pairs(puzzleSetData[PuzzleSet.PUZZLE_SET_PROPERTY.PUZZLES] or {}) do + ---@type string + local puzzleTypeValue = puzzleData[Puzzle.PUZZLE_PROPERTY.TYPE] local args = { - puzzleType = string.lower(puzzleData[Puzzle.PUZZLE_PROPERTY.TYPE]), + puzzleType = string.lower(puzzleTypeValue), moves = puzzleData[Puzzle.PUZZLE_PROPERTY.MOVES], stack = puzzleData[Puzzle.PUZZLE_PROPERTY.STACK], stopTime = puzzleData[Puzzle.PUZZLE_PROPERTY.STOP], diff --git a/common/engine/Puzzle.lua b/common/engine/Puzzle.lua index a9417abe..65236b97 100644 --- a/common/engine/Puzzle.lua +++ b/common/engine/Puzzle.lua @@ -324,6 +324,7 @@ end -- Helper function to convert a single puzzle to save data format ---@return table function Puzzle:getSaveData() + ---@type table local puzzleData = { [Puzzle.PUZZLE_PROPERTY.TYPE] = self.puzzleType, [Puzzle.PUZZLE_PROPERTY.START_TIMING] = self.startTiming, diff --git a/common/tests/engine/GarbageQueueTestingUtils.lua b/common/tests/engine/GarbageQueueTestingUtils.lua index 2998d011..058c0ad9 100644 --- a/common/tests/engine/GarbageQueueTestingUtils.lua +++ b/common/tests/engine/GarbageQueueTestingUtils.lua @@ -44,7 +44,6 @@ function GarbageQueueTestingUtils.createMatch(stackHealth, attackFile) match:start() -- make some space for garbage to fall ----@diagnostic disable-next-line: param-type-mismatch GarbageQueueTestingUtils.reduceRowsTo(match.stacks[1], 0) return match @@ -63,7 +62,7 @@ function GarbageQueueTestingUtils.runToFrame(match, frame) end -- clears panels until only "count" rows are left ----@param stack Stack +---@param stack Stack|SimulatedStack function GarbageQueueTestingUtils.reduceRowsTo(stack, count) for row = #stack.panels, count + 1 do for col = 1, stack.width do @@ -73,13 +72,14 @@ function GarbageQueueTestingUtils.reduceRowsTo(stack, count) end -- fill up panels with non-matching panels until "count" rows are filled ----@param stack Stack +---@param stack Stack|SimulatedStack function GarbageQueueTestingUtils.fillRowsTo(stack, count) + ---@cast stack Stack for row = 1, count do if not stack.panels[row] then stack.panels[row] = {} for col = 1, stack.width do - stack.createPanelAt(row, col) + stack:createPanelAt(row, col) end end for col = 1, stack.width do @@ -88,17 +88,17 @@ function GarbageQueueTestingUtils.fillRowsTo(stack, count) end end ----@param stack Stack +---@param stack Stack|SimulatedStack function GarbageQueueTestingUtils.simulateActivity(stack) stack.hasActivePanels = function() return true end end ----@param stack Stack +---@param stack Stack|SimulatedStack function GarbageQueueTestingUtils.simulateInactivity(stack) stack.hasActivePanels = function() return false end end ----@param stack Stack +---@param stack Stack|SimulatedStack function GarbageQueueTestingUtils.sendGarbage(stack, width, height, chain, metal, time) -- -1 cause this will get called after the frame ended instead of during the frame local frameEarned = time or stack.stopWatch From f266f03dedace977eafbb4cd444d3486485d7d6e Mon Sep 17 00:00:00 2001 From: JamBox <8935453+JamesVanBoxtel@users.noreply.github.com> Date: Fri, 21 Nov 2025 22:00:02 -0800 Subject: [PATCH 12/12] fix one last warning --- common/tests/engine/GarbageQueueTestingUtils.lua | 2 ++ 1 file changed, 2 insertions(+) diff --git a/common/tests/engine/GarbageQueueTestingUtils.lua b/common/tests/engine/GarbageQueueTestingUtils.lua index 058c0ad9..343ccfc1 100644 --- a/common/tests/engine/GarbageQueueTestingUtils.lua +++ b/common/tests/engine/GarbageQueueTestingUtils.lua @@ -90,11 +90,13 @@ end ---@param stack Stack|SimulatedStack function GarbageQueueTestingUtils.simulateActivity(stack) + ---@diagnostic disable-next-line: duplicate-set-field stack.hasActivePanels = function() return true end end ---@param stack Stack|SimulatedStack function GarbageQueueTestingUtils.simulateInactivity(stack) + ---@diagnostic disable-next-line: duplicate-set-field stack.hasActivePanels = function() return false end end