diff --git a/Bejeweled/Bejeweled_Mainline.toc b/Bejeweled/Bejeweled_Mainline.toc index 8dcfa8e..8abcfcd 100644 --- a/Bejeweled/Bejeweled_Mainline.toc +++ b/Bejeweled/Bejeweled_Mainline.toc @@ -11,3 +11,4 @@ Core\Init.lua Core\Constants.lua Core\SavedVariables.lua Engine\Grid.lua +Engine\Matches.lua diff --git a/Bejeweled/Engine/Matches.lua b/Bejeweled/Engine/Matches.lua new file mode 100644 index 0000000..57c6c04 --- /dev/null +++ b/Bejeweled/Engine/Matches.lua @@ -0,0 +1,264 @@ +local _, addon = ... + +local Constants = addon.Constants +local Matches = {} +addon.Matches = Matches + +local AXIS_HORIZONTAL = "horizontal" +local AXIS_VERTICAL = "vertical" + +local function NewMarkRows(grid) + local rows = {} + for y = 1, grid.height do + rows[y] = {} + for x = 1, grid.width do + rows[y][x] = {} + end + end + return rows +end + +local function AddUniqueCell(cells, seen, cell) + if not seen[cell] then + seen[cell] = true + cells[#cells + 1] = cell + end +end + +local function IsPreferred(cell, options) + if options.preferredCell == cell then + return true + end + local preferredCells = options.preferredCells + if preferredCells then + for index = 1, #preferredCells do + if preferredCells[index] == cell then + return true + end + end + end + return false +end + +local function SelectSpecialCell(primaryCells, intersection, options, random) + if intersection then + return intersection + end + for index = 1, #primaryCells do + if IsPreferred(primaryCells[index], options) then + return primaryCells[index] + end + end + return primaryCells[random(1, #primaryCells)] +end + +local function ClassifySpecial(primaryLength, crossLength) + if crossLength then + if primaryLength >= 5 or crossLength >= 5 then + return "hyper" + end + return "power" + end + if primaryLength >= 5 then + return "hyper" + end + if primaryLength == 4 then + return "power" + end + return nil +end + +local function BuildGroup(axis, contents, primaryCells, crossCells, intersection, options, random) + local cells = {} + local seen = {} + for index = 1, #primaryCells do + AddUniqueCell(cells, seen, primaryCells[index]) + end + if crossCells then + for index = 1, #crossCells do + AddUniqueCell(cells, seen, crossCells[index]) + end + end + + local specialKind = ClassifySpecial(#primaryCells, crossCells and #crossCells or nil) + local special + if specialKind then + special = { + kind = specialKind, + cell = SelectSpecialCell(primaryCells, intersection, options, random), + } + end + + return { + axis = axis, + contents = contents, + primaryCells = primaryCells, + crossCells = crossCells, + intersection = intersection, + cells = cells, + clearCount = #cells, + special = special, + } +end + +local function FindHorizontalCross(grid, x, y, contents) + local left = x - 1 + while left >= 1 and grid.rows[y][left].contents == contents do + left = left - 1 + end + local right = x + 1 + while right <= grid.width and grid.rows[y][right].contents == contents do + right = right + 1 + end + if (x - left - 1) + (right - x - 1) < 2 then + return nil + end + + local cells = {} + for scanX = left + 1, right - 1 do + cells[#cells + 1] = grid.rows[y][scanX] + end + return cells +end + +local function FindVerticalCross(grid, x, y, contents) + local top = y - 1 + while top >= 1 and grid.rows[top][x].contents == contents do + top = top - 1 + end + local bottom = y + 1 + while bottom <= grid.height and grid.rows[bottom][x].contents == contents do + bottom = bottom + 1 + end + if (y - top - 1) + (bottom - y - 1) < 2 then + return nil + end + + local cells = {} + for scanY = top + 1, bottom - 1 do + cells[#cells + 1] = grid.rows[scanY][x] + end + return cells +end + +local function AddGroup(result, group) + result.groups[#result.groups + 1] = group + for index = 1, #group.cells do + local cell = group.cells[index] + if not result.cellSet[cell] then + result.cellSet[cell] = true + result.cells[#result.cells + 1] = cell + end + end +end + +local function ScanVertical(grid, x, y, contents, marks, options, random) + local primaryCells = {} + local crossCells + local intersection + local scanY = y + while scanY <= grid.height do + local cell = grid.rows[scanY][x] + if cell.contents ~= contents or marks[scanY][x].vertical then + break + end + primaryCells[#primaryCells + 1] = cell + if not crossCells then + crossCells = FindHorizontalCross(grid, x, scanY, contents) + if crossCells then + intersection = cell + end + end + scanY = scanY + 1 + end + + if #primaryCells < 3 then + return nil + end + for index = 1, #primaryCells do + local cell = primaryCells[index] + marks[cell.gridY][cell.gridX].vertical = true + end + if crossCells then + for index = 1, #crossCells do + local cell = crossCells[index] + marks[cell.gridY][cell.gridX].horizontal = true + end + end + return BuildGroup(AXIS_VERTICAL, contents, primaryCells, crossCells, intersection, options, random) +end + +local function ScanHorizontal(grid, x, y, contents, marks, options, random) + local primaryCells = {} + local crossCells + local intersection + local scanX = x + while scanX <= grid.width do + local cell = grid.rows[y][scanX] + if cell.contents ~= contents or marks[y][scanX].horizontal then + break + end + primaryCells[#primaryCells + 1] = cell + if not crossCells then + crossCells = FindVerticalCross(grid, scanX, y, contents) + if crossCells then + intersection = cell + end + end + scanX = scanX + 1 + end + + if #primaryCells < 3 then + return nil + end + for index = 1, #primaryCells do + local cell = primaryCells[index] + marks[cell.gridY][cell.gridX].horizontal = true + end + if crossCells then + for index = 1, #crossCells do + local cell = crossCells[index] + marks[cell.gridY][cell.gridX].vertical = true + end + end + return BuildGroup(AXIS_HORIZONTAL, contents, primaryCells, crossCells, intersection, options, random) +end + +function Matches:Find(grid, options) + assert(type(grid) == "table" and type(grid.rows) == "table", "match discovery requires a grid") + options = options or {} + local random = options.random or grid.random or math.random + local marks = NewMarkRows(grid) + local result = { + hasMatches = false, + groups = {}, + cells = {}, + cellSet = {}, + marks = marks, + } + + for y = 1, grid.height do + for x = 1, grid.width do + local contents = grid.rows[y][x].contents + if contents ~= Constants.EMPTY_CONTENTS and contents ~= Constants.HYPER_CONTENTS then + if y < grid.height then + local vertical = ScanVertical(grid, x, y, contents, marks, options, random) + if vertical then + AddGroup(result, vertical) + end + end + if x < grid.width then + local horizontal = ScanHorizontal(grid, x, y, contents, marks, options, random) + if horizontal then + AddGroup(result, horizontal) + end + end + end + end + end + + result.hasMatches = #result.groups > 0 + result.cellCount = #result.cells + result.cellSet = nil + return result +end diff --git a/README.md b/README.md index c060a29..d5487d4 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # Bejeweled modernization -This branch contains the analysis-complete, Mainline-first modernization of the legacy World of Warcraft Bejeweled addon. Its Retail TOC and headless runtime foundation are installable for development, but the addon is not yet playable because UI, match/cascade/scoring, animation, and input slices remain to be restored. +This branch contains the analysis-complete, Mainline-first modernization of the legacy World of Warcraft Bejeweled addon. Its Retail TOC and headless runtime foundation are installable for development, but the addon is not yet playable because UI, cascade/scoring, animation, and input slices remain to be restored. ## Status and phase gate The preserved 8,401-line Mainline source must be analyzed sequentially, in evidence-backed batches, before runtime work begins. All behavior-critical shortened symbols must be resolved and all batches must be complete before any public Lua API, runtime module, modern TOC, packaging, or release work is added. -All 17 batches (lines 1–8,401) are documented, the cross-batch identifier audit has no remaining `working` or `unresolved` declarations, and the retained Retail API contracts are pinned in `docs/api-baseline.md`. The analysis phase gate is closed. Runtime implementation has begun with wire-compatible SavedVariables initialization and an engine-owned deterministic 8×8 grid. +All 17 batches (lines 1–8,401) are documented, the cross-batch identifier audit has no remaining `working` or `unresolved` declarations, and the retained Retail API contracts are pinned in `docs/api-baseline.md`. The analysis phase gate is closed. Runtime implementation now includes wire-compatible SavedVariables initialization, an engine-owned deterministic 8×8 grid, and pure legacy-order match discovery with power/hyper classification. ## Goal @@ -18,11 +18,11 @@ The eventual addon will target current Retail/Mainline World of Warcraft while p - `Bejeweled/images/` — immutable legacy images and bundled font. - `Bejeweled/sounds/` — immutable legacy sounds. - `Bejeweled/Core/` — private addon initialization, constants, and non-destructive SavedVariables defaulting. -- `Bejeweled/Engine/` — deterministic gameplay state; currently the 8×8 grid, swaps, legal moves, and legacy cell encoding. +- `Bejeweled/Engine/` — deterministic gameplay state; currently the 8×8 grid, swaps, legal moves, legacy cell encoding, and non-mutating match/special discovery. - `docs/analysis/` — sequential batch reports, identifier ledger, and exact coverage schedule. - `docs/architecture.md` — module ownership and authoritative runtime data flow. - `docs/api-baseline.md` — verified Retail API constraints for implementation. -- `tools/test-runtime.lua` and `tools/verify-runtime.ps1` — Lua 5.1-compatible foundation tests and TOC verification. +- `tools/test-runtime.lua` and `tools/verify-runtime.ps1` — Lua 5.1-compatible engine tests and TOC verification. - `plan.md` — the bootstrap specification executed by the root commit. ## Contributing diff --git a/docs/architecture.md b/docs/architecture.md index e042b07..e33c9ef 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Target architecture (post-analysis) -The analysis phase gate is satisfied. This ownership map now governs runtime implementation; currently implemented modules are `Core/Init.lua`, `Core/Constants.lua`, `Core/SavedVariables.lua`, and `Engine/Grid.lua`. +The analysis phase gate is satisfied. This ownership map now governs runtime implementation; currently implemented modules are `Core/Init.lua`, `Core/Constants.lua`, `Core/SavedVariables.lua`, `Engine/Grid.lua`, and `Engine/Matches.lua`. ## Load order and ownership @@ -9,7 +9,7 @@ The analysis phase gate is satisfied. This ownership map now governs runtime imp 3. `Core/Audio.lua` — supported sound playback and sound identifiers. 4. `Core/SavedVariables.lua` — defaulting, validation, and eventual proven migrations. 5. `Engine/Grid.lua` — deterministic grid representation, coordinates, swaps, and legal-move state. -6. `Engine/Matches.lua` — match detection and special-gem classification. +6. `Engine/Matches.lua` — pure legacy-order match detection, axis-overlap reporting, and power/hyper-gem classification; clearing and scoring remain downstream responsibilities. 7. `Engine/Cascade.lua` — clears, gravity, refill, and cascade transitions. 8. `Engine/Scoring.lua` — score, level, multiplier, skill, and achievement transitions. 9. `UI/Backdrops.lua` — backdrop-compatible frame construction and shared chrome. diff --git a/tools/test-runtime.lua b/tools/test-runtime.lua index b79c76c..2157fb3 100644 --- a/tools/test-runtime.lua +++ b/tools/test-runtime.lua @@ -43,6 +43,7 @@ LoadAddonFile("Bejeweled/Core/Init.lua", addon) LoadAddonFile("Bejeweled/Core/Constants.lua", addon) LoadAddonFile("Bejeweled/Core/SavedVariables.lua", addon) LoadAddonFile("Bejeweled/Engine/Grid.lua", addon) +LoadAddonFile("Bejeweled/Engine/Matches.lua", addon) AssertEqual(eventFrame.registeredEvent, "ADDON_LOADED", "initializer event registration") AssertEqual(addon.Constants.GRID_WIDTH, 8, "grid width") @@ -141,6 +142,94 @@ for y = 1, addon.Constants.GRID_HEIGHT do end end +local matchGrid = addon.Grid:New() +matchGrid:Set(2, 2, 3) +matchGrid:Set(3, 2, 3) +matchGrid:Set(4, 2, 3) +local threeMatch = addon.Matches:Find(matchGrid) +assert(threeMatch.hasMatches, "three-gem match was not found") +AssertEqual(#threeMatch.groups, 1, "three-gem group count") +AssertEqual(threeMatch.cellCount, 3, "three-gem matched cell count") +AssertEqual(threeMatch.groups[1].axis, "horizontal", "three-gem primary axis") +assert(not threeMatch.groups[1].special, "three-gem match created a special gem") +assert(threeMatch.marks[2][2].horizontal, "horizontal match mark was not reported") +assert(not matchGrid:Get(2, 2).markX, "match discovery mutated a grid cell") + +matchGrid:Reset() +for x = 2, 5 do + matchGrid:Set(x, 3, 4) +end +local preferredPowerCell = matchGrid:Get(4, 3) +local fourMatch = addon.Matches:Find(matchGrid, { preferredCell = preferredPowerCell }) +AssertEqual(fourMatch.groups[1].special.kind, "power", "four-gem classification") +assert(fourMatch.groups[1].special.cell == preferredPowerCell, "preferred power-gem position was ignored") + +matchGrid:Reset() +for y = 1, 5 do + matchGrid:Set(6, y, 5) +end +local fiveMatch = addon.Matches:Find(matchGrid, { random = function() return 2 end }) +AssertEqual(fiveMatch.groups[1].special.kind, "hyper", "five-gem classification") +assert(fiveMatch.groups[1].special.cell == matchGrid:Get(6, 2), "hyper-gem fallback position was not deterministic") + +matchGrid:Reset() +matchGrid:Set(3, 1, 6) +matchGrid:Set(3, 2, 6) +matchGrid:Set(3, 3, 6) +matchGrid:Set(2, 3, 6) +matchGrid:Set(4, 3, 6) +local tMatch = addon.Matches:Find(matchGrid) +AssertEqual(#tMatch.groups, 1, "T-match group count") +AssertEqual(tMatch.groups[1].clearCount, 5, "T-match clear count") +AssertEqual(tMatch.groups[1].special.kind, "power", "T-match classification") +assert(tMatch.groups[1].special.cell == matchGrid:Get(3, 3), "T-match special was not placed at the intersection") + +matchGrid:Set(1, 3, 6) +matchGrid:Set(5, 3, 6) +local longCrossMatch = addon.Matches:Find(matchGrid) +AssertEqual(longCrossMatch.groups[1].special.kind, "hyper", "five-wide cross classification") +assert(longCrossMatch.groups[1].special.cell == matchGrid:Get(3, 3), "cross hyper gem was not placed at the intersection") + +matchGrid:Reset() +for y = 1, 3 do + matchGrid:Set(2, y, 2) + matchGrid:Set(4, y, 2) +end +matchGrid:Set(3, 3, 2) +local overlappingMatches = addon.Matches:Find(matchGrid) +AssertEqual(#overlappingMatches.groups, 2, "overlapping match group count") +AssertEqual(overlappingMatches.groups[1].clearCount, 5, "first overlapping group count") +AssertEqual(overlappingMatches.groups[2].clearCount, 5, "second overlapping group count") +AssertEqual(overlappingMatches.cellCount, 7, "overlapping unique cell count") + +matchGrid:Reset() +matchGrid:Set(1, 8, addon.Constants.HYPER_CONTENTS) +matchGrid:Set(2, 8, addon.Constants.HYPER_CONTENTS) +matchGrid:Set(3, 8, addon.Constants.HYPER_CONTENTS) +local hyperOnly = addon.Matches:Find(matchGrid) +assert(not hyperOnly.hasMatches, "hyper gems were treated as an ordinary color match") + +for seed = 1, 100 do + local random = MakeRandom(seed * 97) + matchGrid:Reset() + for y = 1, addon.Constants.GRID_HEIGHT do + for x = 1, addon.Constants.GRID_WIDTH do + matchGrid:Set(x, y, random(1, addon.Constants.GEM_COLOR_COUNT)) + end + end + local discovered = addon.Matches:Find(matchGrid, { random = random }) + local discoveredCells = {} + for index = 1, #discovered.cells do + discoveredCells[discovered.cells[index]] = true + end + for y = 1, addon.Constants.GRID_HEIGHT do + for x = 1, addon.Constants.GRID_WIDTH do + local cell = matchGrid:Get(x, y) + AssertEqual(discoveredCells[cell] and true or false, matchGrid:HasMatchAt(x, y), "random-board match coverage") + end + end +end + eventFrame.scripts.OnEvent(eventFrame, "ADDON_LOADED", "AnotherAddon", false) assert(not addon.initialized, "foreign ADDON_LOADED initialized the addon") eventFrame.scripts.OnEvent(eventFrame, "ADDON_LOADED", "Bejeweled", false) @@ -151,4 +240,4 @@ local initializedGrid = addon.grid addon:Initialize({}, {}) assert(addon.grid == initializedGrid, "addon initialization is not idempotent") -print("Runtime verification passed: SavedVariables and deterministic 8x8 grid.") +print("Runtime verification passed: SavedVariables, deterministic 8x8 grid, and pure match discovery.") diff --git a/tools/verify-runtime.ps1 b/tools/verify-runtime.ps1 index 97ad23f..4920021 100644 --- a/tools/verify-runtime.ps1 +++ b/tools/verify-runtime.ps1 @@ -22,7 +22,8 @@ try { "Core\Init.lua", "Core\Constants.lua", "Core\SavedVariables.lua", - "Engine\Grid.lua" + "Engine\Grid.lua", + "Engine\Matches.lua" ) $actualFiles = @($toc | Where-Object { $_ -match "\.lua$" }) if (Compare-Object -ReferenceObject $expectedFiles -DifferenceObject $actualFiles -SyncWindow 0) { @@ -36,7 +37,7 @@ try { } } - Write-Output "Verified: Retail TOC order and Lua 5.1-compatible runtime foundation." + Write-Output "Verified: Retail TOC order and Lua 5.1-compatible grid and match engine." } finally { Pop-Location