feat: add session level transitions

This commit is contained in:
Nighthawk42
2026-08-24 08:29:43 -04:00
parent 99a3b1a372
commit ff993027a6
5 changed files with 214 additions and 11 deletions
+27 -4
View File
@@ -91,6 +91,8 @@ function Input:New(grid, gemPool, animations, options)
instance.selectedY = nil
instance.locked = false
instance.paused = false
instance.sessionLocked = false
instance.sessionLockReason = nil
instance.pendingMove = nil
instance.lastMove = nil
instance.moves = instance.scoringState and (instance.scoringState.moves or 0) or (options.moves or 0)
@@ -98,13 +100,32 @@ function Input:New(grid, gemPool, animations, options)
end
function Input:IsLocked()
return self.paused or self.locked or self.animations:IsPlaying()
return self.paused or self.sessionLocked or self.locked or self.animations:IsPlaying()
end
function Input:IsPaused()
return self.paused
end
function Input:SetSessionLocked(locked, reason)
assert(type(locked) == "boolean", "input session lock state must be Boolean")
if self.sessionLocked == locked then
return false
end
self.sessionLocked = locked
self.sessionLockReason = locked and (reason or "session") or nil
if locked then
self:ClearSelection("session-locked")
end
self.gemPool:SetInteractive(
not self.paused
and not self.sessionLocked
and not self.locked
and not self.animations:IsPlaying()
)
return true
end
function Input:SetPaused(paused)
assert(type(paused) == "boolean", "input pause state must be Boolean")
if self.paused == paused then
@@ -117,9 +138,11 @@ function Input:SetPaused(paused)
self.gemPool:SetInteractive(false)
else
self.animations:Resume()
if not self.locked and not self.animations:IsPlaying() then
self.gemPool:SetInteractive(true)
end
self.gemPool:SetInteractive(
not self.sessionLocked
and not self.locked
and not self.animations:IsPlaying()
)
end
return true
end
+106
View File
@@ -12,6 +12,8 @@ local CALLBACK_NAMES = {
"onPauseChanged",
"onSaved",
"onRestored",
"onLevelTransitionStarted",
"onLevelTransitionComplete",
}
local RESTORED_STATE_KEYS = {
@@ -35,6 +37,23 @@ local function CopyTable(source)
return copy
end
local function CopyRecords(records)
local copy = {}
for index = 1, #(records or {}) do
copy[index] = CopyTable(records[index])
end
return copy
end
local function CopyLevelTransition(record)
if not record then
return nil
end
local copy = CopyTable(record)
copy.skillEvents = CopyRecords(record.skillEvents)
return copy
end
local function ValidateCallbacks(options)
local callbacks = {}
for index = 1, #CALLBACK_NAMES do
@@ -70,9 +89,13 @@ function Session:New(grid, gemPool, animations, options)
active = options.active ~= false,
paused = false,
autoSave = options.autoSave ~= false,
deferLevelTransitions = options.deferLevelTransitions == true,
levelTransitionSequence = 0,
levelTransition = nil,
callbacks = ValidateCallbacks(options),
}, self)
assert(type(instance.timerElapsed) == "number" and instance.timerElapsed >= 0, "session elapsed time must be nonnegative")
assert(options.deferLevelTransitions == nil or type(options.deferLevelTransitions) == "boolean", "deferred level-transition state must be Boolean")
local inputOptions = CopyTable(options.inputOptions)
local userMoveComplete = inputOptions.onMoveComplete
@@ -116,6 +139,14 @@ function Session:IsLocked()
return self.input:IsLocked()
end
function Session:IsLevelTransitionPending()
return self.levelTransition ~= nil
end
function Session:GetLevelTransition()
return self.levelTransition and CopyLevelTransition(self.levelTransition.record) or nil
end
function Session:SetPaused(paused, reason)
assert(type(paused) == "boolean", "session pause state must be Boolean")
if self.paused == paused then
@@ -164,6 +195,7 @@ end
function Session:SaveClassicGame(reason)
assert(self.active and self.gameMode == Constants.GAME_MODE_CLASSIC, "only an active classic session can be saved")
assert(not self.levelTransition, "classic session level transition must complete before saving")
assert(not self.input.pendingMove and not self.animations:IsPlaying(), "classic session must be stable before saving")
local savedState = self.savedVariables:SaveClassicGame(
self.grid,
@@ -181,7 +213,80 @@ function Session:SaveClassicGame(reason)
return result
end
function Session:BeginLevelTransition(sourceMove)
assert(type(sourceMove) == "table", "level transition requires a source move")
assert(self.active and sourceMove.status == "complete", "level transition requires a completed active move")
assert(not self.levelTransition, "level transition is already active")
assert(not self.input.pendingMove and not self.animations:IsPlaying(), "level transition requires a stable board")
assert(self.scoringState.levelPending, "scoring state has no pending level")
self.input:SetSessionLocked(true, "level-transition")
self.levelTransitionSequence = self.levelTransitionSequence + 1
local record = {
status = "started",
transitionID = self.levelTransitionSequence,
kind = self.gameMode == Constants.GAME_MODE_CLASSIC and "level-up" or "multiplier-up",
gameMode = self.gameMode,
score = self.scoringState.score,
oldLevel = self.scoringState.level,
level = self.scoringState.level + 1,
oldPointMultiplier = self.scoringState.pointMultiplier,
oldPointsToLevelUp = self.scoringState.pointsToLevelUp,
sound = "LevelUp",
skillEvents = {},
}
self.levelTransition = {
record = record,
sourceMove = sourceMove,
}
sourceMove.levelTransition = CopyLevelTransition(record)
self.input:PlaySound(record.sound)
self:Notify("onLevelTransitionStarted", CopyLevelTransition(record))
if not self.deferLevelTransitions and self.levelTransition then
self:CompleteLevelTransition()
end
return CopyLevelTransition(record)
end
function Session:CompleteLevelTransition()
local activeTransition = self.levelTransition
assert(activeTransition, "no level transition is active")
local advanced = Scoring:AdvanceLevel(self.scoringState, self.profile, {
random = self.input.random,
skillLimit = self.input.skillLimit,
})
self.levelTransition = nil
self.input:SetSessionLocked(false)
local started = activeTransition.record
local result = {
status = "complete",
transitionID = started.transitionID,
kind = started.kind,
gameMode = started.gameMode,
score = started.score,
oldLevel = advanced.oldLevel,
level = advanced.level,
oldPointMultiplier = started.oldPointMultiplier,
pointMultiplier = advanced.pointMultiplier,
oldPointsToLevelUp = started.oldPointsToLevelUp,
pointsToLevelUp = advanced.pointsToLevelUp,
skillEvents = CopyRecords(advanced.skillEvents),
}
activeTransition.sourceMove.levelTransitionComplete = CopyLevelTransition(result)
if self.autoSave and self.active and self.gameMode == Constants.GAME_MODE_CLASSIC then
activeTransition.sourceMove.saveResult = self:SaveClassicGame("level-transition")
end
self:Notify("onLevelTransitionComplete", CopyLevelTransition(result))
return CopyLevelTransition(result)
end
function Session:HandleMoveComplete(result)
if self.active and result.status == "complete" and self.scoringState.levelPending then
self:BeginLevelTransition(result)
return
end
if self.autoSave
and self.active
and self.gameMode == Constants.GAME_MODE_CLASSIC
@@ -195,6 +300,7 @@ function Session:RestoreClassicGame(options)
assert(type(options) == "table", "classic restore options must be a table")
local pauseAfterRestore = options.paused
assert(pauseAfterRestore == nil or type(pauseAfterRestore) == "boolean", "restore pause state must be Boolean")
assert(not self.levelTransition, "cannot restore during a level transition")
assert(not self.input.pendingMove and not self.animations:IsPlaying(), "cannot restore during an active move")
local wasPaused = self.paused
local restored = self.savedVariables:RestoreClassicGame(self.grid, self.profile, self:ResolvePlayerName())
+3 -3
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 level/game-over session states, HUD, and menu integration still need 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 game-over session state, HUD, and menu integration still need 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 now includes wire-compatible SavedVariables initialization and authenticated Classic restoration, deterministic grid/match/cascade/scoring transitions, pause/resume and stable-move autosave coordination, click selection, validated swap/rollback sessions, skills, levels, legacy-compatible audio cue scheduling, BackdropTemplate-safe UI chrome, persistent grid-to-gem texture projection, cancellable cascade sequencing, power/hyper presentation, and pooled explosion/lightning playback.
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 and authenticated Classic restoration, deterministic grid/match/cascade/scoring transitions, pause/resume and stable-state autosave coordination, session-owned level-transition handoffs, click selection, validated swap/rollback sessions, skills, levels, legacy-compatible audio cue scheduling, BackdropTemplate-safe UI chrome, persistent grid-to-gem texture projection, cancellable cascade sequencing, power/hyper presentation, and pooled explosion/lightning playback.
## Goal
@@ -18,7 +18,7 @@ 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, supported audio playback, non-destructive SavedVariables defaulting, and legacy-authenticated Classic save encoding.
- `Bejeweled/Engine/` — deterministic gameplay state; currently the 8×8 grid, selection/swap and pause/resume sessions, authenticated restoration, legal moves, legacy cell encoding, stable cascade resolution, legacy score formulas, move accounting, statistics, skill gains, achievements, and level thresholds.
- `Bejeweled/Engine/` — deterministic gameplay state; currently the 8×8 grid, selection/swap and pause/resume sessions, authenticated restoration, session-locked level advancement, legal moves, legacy cell encoding, stable cascade resolution, legacy score formulas, move accounting, statistics, skill gains, achievements, and level thresholds.
- `Bejeweled/UI/` — Retail-safe frame/rendering boundaries; currently shared backdrop construction, board tiles, persistent gem-frame projection, and recorded cascade-transition playback.
- `docs/analysis/` — sequential batch reports, identifier ledger, and exact coverage schedule.
- `docs/architecture.md` — module ownership and authoritative runtime data flow.
+3 -3
View File
@@ -12,8 +12,8 @@ The analysis phase gate is satisfied. This ownership map now governs runtime imp
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` — transactional clears, matched power-gem expansion, target-color and double-hyper activation, spawned-special preservation, fixed-cell gravity, bounded refill, and repeated transitions to a stable board. It emits immutable award, lightning-link, movement, and refill records but owns no animation or scoring.
8. `Engine/Scoring.lua` — legacy score arithmetic, combo/mode/level multipliers, wire-compatible statistics, probabilistic skill gains, one-time achievements, rank advancement, and pending/explicit level transitions. It emits presentation events and owns no frames, text, sound, or chat publishing.
9. `Engine/Input.lua` — authoritative selection and move coordination, optimistic adjacent swaps, match validation, immediate engine rollback for invalid moves, legacy hyper activation, move accounting, cascade/scoring handoff, pause gating, and presentation locking.
10. `Engine/Session.lua` — Classic session lifetime, pause/resume state, elapsed-time gating, stable-move autosave, authenticated restore, and coordination between input, scoring, persistence, grid projection, and animation clocks. Level and game-over transitions remain follow-on session work.
9. `Engine/Input.lua` — authoritative selection and move coordination, optimistic adjacent swaps, match validation, immediate engine rollback for invalid moves, legacy hyper activation, move accounting, cascade/scoring handoff, pause gating, and session/presentation locking.
10. `Engine/Session.lua` — Classic session lifetime, pause/resume state, elapsed-time gating, stable-state autosave, authenticated restore, and coordination between input, scoring, persistence, grid projection, and animation clocks. It exclusively consumes pending scoring levels after a stable move, emits copied start/complete presentation records, retains the input lock while presentation is deferred, advances level arithmetic on completion, and only then autosaves. Game-over transitions remain follow-on session work.
11. `UI/Backdrops.lua` — backdrop-compatible frame construction and fresh-copy presets for tooltip, window, panel, slider, and level-border chrome. Every constructed frame explicitly inherits `BackdropTemplate`.
12. `UI/GemPool.lua` — fixed allocation and reuse of the 64 interactive gem frames, the sixteen board-art tiles, input-handler attachment, selection projection, and change-aware projection from authoritative grid cells into normal/hyper texture layers. Power-gem overlays remain `UI/Animations.lua` ownership.
13. `UI/Animations.lua` — deterministic swap/rollback and clear/gravity/refill plans, reusable animation groups, session-controlled pause/resume, interaction locking, cancellation, and final-grid normalization. It also owns the legacy-cadence 40-frame hyper atlas, counter-rotating/cross-faded power layers, pooled 16-frame explosion atlas, and pooled 15-tick lightning lines that gate settling. Shards, lightwaves, hint bounce, and floating text remain follow-on presentation work; current fall timings are explicit modernization defaults pending in-game tuning.
@@ -22,7 +22,7 @@ The analysis phase gate is satisfied. This ownership map now governs runtime imp
## Data flow
`SavedVariables initialization/restore → session-owned lifetime → engine-owned deterministic grid state → input/match/cascade/scoring transitions → UI rendering and animation → stable-state autosave and HUD/audio feedback`
`SavedVariables initialization/restore → session-owned lifetime → engine-owned deterministic grid state → input/match/cascade/scoring transitions → UI rendering and animation → session-owned level/game-over gates → stable-state autosave and HUD/audio feedback`
The engine will own authoritative game state. UI frames will render state and report input; they will not become the source of gameplay truth. Audio and HUD reactions consume completed transitions.
+75 -1
View File
@@ -994,14 +994,23 @@ local sessionState = addon.Scoring:NewState(addon.Constants.GAME_MODE_CLASSIC, {
local sessionPauseEvents = {}
local sessionSaveEvents = {}
local sessionRestoreEvents = {}
local sessionLevelStartedEvents = {}
local sessionLevelCompleteEvents = {}
local sessionSounds = {}
local session = addon.Session:New(sessionGrid, sessionPool, sessionAnimations, {
profile = sessionProfile,
playerName = "Nighthawk",
scoringState = sessionState,
timerElapsed = 42.75,
deferLevelTransitions = true,
inputOptions = {
random = MakeRandom(4810),
requireLegalMove = false,
audio = {
Play = function(_, soundName)
sessionSounds[#sessionSounds + 1] = soundName
end,
},
},
onPauseChanged = function(result)
sessionPauseEvents[#sessionPauseEvents + 1] = result
@@ -1012,6 +1021,12 @@ local session = addon.Session:New(sessionGrid, sessionPool, sessionAnimations, {
onRestored = function(result)
sessionRestoreEvents[#sessionRestoreEvents + 1] = result
end,
onLevelTransitionStarted = function(result)
sessionLevelStartedEvents[#sessionLevelStartedEvents + 1] = result
end,
onLevelTransitionComplete = function(result)
sessionLevelCompleteEvents[#sessionLevelCompleteEvents + 1] = result
end,
})
local manualSessionSave = session:SaveClassicGame("test-save")
local savedSessionState = manualSessionSave.savedState
@@ -1103,6 +1118,65 @@ AssertEqual(sessionMove.status, "complete", "resumed session move completion sta
assert(sessionMove.saveResult and sessionMove.saveResult.status == "saved", "stable session move was not auto-saved")
AssertEqual(#sessionSaveEvents, 2, "session save callback count")
AssertEqual(sessionProfile.settings.savedState[9][4], sessionState.moves, "auto-saved move count")
sessionState.score = 500
sessionState.pointsToLevelUp = 500
sessionState.level = 1
sessionState.pointMultiplier = 1
sessionState.levelPending = true
local levelSourceMove = { status = "complete" }
session:HandleMoveComplete(levelSourceMove)
assert(session:IsLevelTransitionPending(), "session did not retain the presentation handoff")
assert(session:IsLocked(), "pending level transition did not lock input")
assert(not sessionPool:GetFrame(1, 1).mouseEnabled, "pending level transition left gems interactive")
AssertEqual(sessionState.level, 1, "level advanced before presentation completion")
assert(sessionState.levelPending, "pending level flag was consumed before presentation completion")
AssertEqual(#sessionLevelStartedEvents, 1, "level-transition start callback count")
AssertEqual(sessionLevelStartedEvents[1].kind, "level-up", "classic level-transition kind")
AssertEqual(sessionLevelStartedEvents[1].oldLevel, 1, "level-transition starting level")
AssertEqual(sessionLevelStartedEvents[1].level, 2, "level-transition target level")
AssertEqual(sessionSounds[#sessionSounds], "LevelUp", "level-transition sound")
sessionLevelStartedEvents[1].level = 99
levelSourceMove.levelTransition.oldLevel = 99
AssertEqual(session:GetLevelTransition().level, 2, "callback mutated active level-transition record")
AssertEqual(session:GetLevelTransition().oldLevel, 1, "source move mutated active level-transition record")
local transitionSaveSucceeded = pcall(function()
session:SaveClassicGame("during-level-transition")
end)
assert(not transitionSaveSucceeded, "session saved an incomplete level transition")
session:Pause("level-transition")
session:Resume("level-transition")
assert(session:IsLocked(), "pause cycle released the level-transition lock")
assert(not sessionPool:GetFrame(1, 1).mouseEnabled, "pause cycle re-enabled input during level transition")
local completedLevelTransition = session:CompleteLevelTransition()
AssertEqual(completedLevelTransition.status, "complete", "level-transition completion status")
AssertEqual(completedLevelTransition.level, 2, "session-advanced level")
AssertEqual(completedLevelTransition.pointMultiplier, 1.5, "session-advanced point multiplier")
AssertEqual(completedLevelTransition.pointsToLevelUp, 1975, "session-advanced level threshold")
assert(not sessionState.levelPending, "completed transition retained the pending level flag")
assert(not session:IsLevelTransitionPending(), "completed level transition remained active")
assert(not session:IsLocked(), "completed level transition left input locked")
assert(sessionPool:GetFrame(1, 1).mouseEnabled, "completed level transition left gems disabled")
AssertEqual(#sessionLevelCompleteEvents, 1, "level-transition completion callback count")
AssertEqual(#sessionSaveEvents, 3, "level-transition autosave callback count")
assert(levelSourceMove.saveResult and levelSourceMove.saveResult.status == "saved", "level transition was not auto-saved")
AssertEqual(sessionProfile.settings.savedState[9][2], 1975, "auto-saved level threshold")
AssertEqual(sessionProfile.settings.savedState[9][3], 2, "auto-saved advanced level")
session.deferLevelTransitions = false
sessionState.score = 1975
sessionState.levelPending = true
local automaticLevelMove = { status = "complete" }
session:HandleMoveComplete(automaticLevelMove)
assert(not session:IsLevelTransitionPending(), "automatic level transition remained deferred")
AssertEqual(sessionState.level, 3, "automatic session-advanced level")
AssertEqual(sessionState.pointMultiplier, 2, "automatic session-advanced point multiplier")
AssertEqual(sessionState.pointsToLevelUp, 5550, "automatic session-advanced threshold")
AssertEqual(#sessionLevelStartedEvents, 2, "automatic level-transition start callback count")
AssertEqual(#sessionLevelCompleteEvents, 2, "automatic level-transition completion callback count")
AssertEqual(#sessionSaveEvents, 4, "automatic level-transition autosave callback count")
assert(automaticLevelMove.saveResult, "automatic level transition omitted stable-state autosave")
end
TestSessionRestore()
@@ -1480,4 +1554,4 @@ assert(addon.animationFactory == initializedAnimationFactory, "Animations initia
assert(addon.inputFactory == initializedInputFactory, "Input initialization is not idempotent")
assert(addon.sessionFactory == initializedSessionFactory, "Session initialization is not idempotent")
print("Runtime verification passed: pause/restore sessions, input, cascade animation, gem projection, UI backdrops, audio, SavedVariables, and deterministic gameplay engine.")
print("Runtime verification passed: pause/restore/level-transition sessions, input, cascade animation, gem projection, UI backdrops, audio, SavedVariables, and deterministic gameplay engine.")