feat: add pure match discovery

This commit is contained in:
Nighthawk42
2026-08-24 01:19:38 -04:00
parent 64b715e218
commit de00d6ed91
6 changed files with 364 additions and 9 deletions
+1
View File
@@ -11,3 +11,4 @@ Core\Init.lua
Core\Constants.lua
Core\SavedVariables.lua
Engine\Grid.lua
Engine\Matches.lua
+264
View File
@@ -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
+4 -4
View File
@@ -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 18,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 18,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
+2 -2
View File
@@ -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.
+90 -1
View File
@@ -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.")
+3 -2
View File
@@ -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