architecture timers - Drowbe/coffee-pub-blacksmith GitHub Wiki

Combat Timers — Architecture

Audience: Contributors to the Blacksmith codebase, and modules drawing a Blacksmith timer.

Three timer modules track different things during an encounter and are drawn by more than one surface. This describes what each measures, the contract every surface uses to draw one, and the traps in that contract.

Files:

File Role
scripts/timer-planning.js Planning timer — counts down at the top of a round
scripts/timer-combat.js Turn timer — counts down per combatant turn
scripts/timer-round.js Round and total combat duration — count up
templates/timer-planning.hbs, timer-combat.hbs, timer-round.hbs Tracker markup
styles/timer-planning.css, timer-combat.css Bar styling, including the shared band colours

Two kinds of timer

Countdowns have a configured limit, so they have a percentage and can be drawn as a bar. The planning timer and the turn timer are countdowns.

Elapsed counters count up with no maximum. RoundTimer.getCurrentRoundDuration() and its total-combat equivalent have no percentage and cannot be bars; they are text. getCombatData in manager-combatbar.js computes currentRoundDuration and totalCombatDuration and passes both to the combat bar template, which currently ignores them.

The turn timer's file and setting are named combatTimer*, but it measures a turn, not the combat. Nothing measures the combat as a countdown.

The display contract

PlanningTimer.getDisplayState() and CombatTimer.getDisplayState() each return:

{ percent, state: 'high' | 'medium' | 'low' | 'expired', text, isExpired }

This is the single source of truth for every surface drawing that timer — the tracker's render, the tracker's per-tick DOM update, and the combat bar's readout. Both timers previously computed the same thresholds and text in two or three places each; that is what these methods replaced.

Bands are identical across both timers: expired at zero or below, low at 25% or less, medium at 50% or less, high above. Colour is the timer's identity, not its state — planning is a blue ramp, the turn timer green through yellow to red. The values are custom properties declared beside the tracker's own bar rules (--blacksmith-planning-timer-*, --blacksmith-turn-timer-*) so a second surface consumes them rather than repeating hex codes.

Text precedence differs between the two timers and that is deliberate. The turn timer shows paused in preference to expired; the planning timer shows expired in preference to paused. This is existing behaviour, preserved rather than normalised.

Pause is a text state. Colour continues to track remaining time while paused; nothing desaturates.

Visibility

Whether a timer should be on screen is a separate question from its display state, and each module owns it:

  • CombatTimer.shouldDisplay() — combat started, round not zero, timer enabled, not GM-only-hidden, and every non-defeated combatant's initiative rolled.
  • PlanningTimer.verifyTimerConditions() — enabled, combat started, game.combat.turn === 0, not expired, not GM-only-hidden, turns array built, and every non-defeated combatant's initiative rolled.

state.isActive is not a visibility signal for either timer, despite the name. For CombatTimer it is assigned only in resumeTimer, so it is false for a timer that started normally and only becomes true after a pause and resume — while startTimer nonetheless announces isActive: true in the combatTimerStateChange payload it emits, so the hook and the state disagree. For PlanningTimer it is set on start but stays true after the turn advances past zero, so it does not express "the planning phase is running". Ask the module through the two methods above.

Ticking

All three timers run setInterval(..., 1000) and update by writing into a cached set of DOM nodes, never by re-rendering. Each keeps {bars, texts, progress}, refreshed when the cache is stale (any cached node disconnected) or empty, and refreshed outright by the tracker render. This is deliberate: the menubar fingerprint exists to avoid rebuilding on a cadence, and a per-second rebuild for the length of every combat is exactly what it is there to prevent.

Any new surface must follow the same rule. blacksmithTimerDisplay(itemId, display) is fired on every tick by both countdown timers for exactly this purpose — a subscriber writes to its own DOM. Re-render only on transitions, which the timers announce separately.

Hooks

Hook Fired by Meaning
blacksmithTimerDisplay both countdowns, per tick draw this state; do not re-render
combatTimerStateChange CombatTimer start, pause, resume; carries {isPaused, isActive, remaining}
planningTimerExpired PlanningTimer planning has ended; the turn timer takes over
endPlanningTimer CombatTimer ask the planning timer to stop
combatTimerCleanup PlanningTimer teardown

Interaction

Left click toggles pause; right click sets the remaining time from the click position. Both are GM-only and both are shared: CombatTimer.handleTimerClick / handleRightClick and PlanningTimer._onTimerClick / _onTimerRightClick.

Both right-click handlers measure event.currentTarget.getBoundingClientRect(), so they must be bound directly to the bar element, never through a delegated listener — delegation hands them the delegate root and the click scrubs to a wrong time.

Consumers

The combat tracker draws all three. The combat bar draws the two countdowns in one shared slot — see documentation/architecture/architecture-encounter.md.