1
0
mirror of https://github.com/jcwimer/wrestlingApp synced 2026-04-12 08:18:44 +00:00

New stats page, scoreboard, and live scores pages.

This commit is contained in:
2026-04-10 21:08:03 -04:00
parent 7526148ba5
commit 970f38ed14
60 changed files with 9650 additions and 148 deletions

View File

@@ -0,0 +1,344 @@
/*
State page config contract
==========================
The state page responds to these top-level config objects:
1. `wrestler_actions`
Drives the wrestler-side UI from top to bottom inside each wrestler panel.
The controller renders these sections in order, so the order in this object
controls the visual order underneath each wrestler's name, school, and score.
Supported sections:
- `match_actions`
- `timers`
- `extra_actions`
Each section may define:
- `title`
- `description`
- `items`
How the state page uses it:
- `match_actions.items`
Each item is either:
- a literal action key, or
- a special alias such as `global` or `position`
The state page expands those aliases into the currently legal actions for
that wrestler and renders them as buttons.
- `timers.items`
Each item is a timer key. The state page renders the timer display plus
start/stop/reset buttons for each listed timer.
- `extra_actions.items`
Each item is a literal action key rendered as an always-visible button
underneath the timer section.
2. `actionsByKey`
Canonical definitions for match actions and history actions.
This is the source of truth for how a button behaves and how an action
should appear in the event log.
Each action may define:
- `label`
- `availability`
- `statCode`
- `effect`
- `progression`
How the state page uses it:
- `label`
Used for button text and event log text.
- `availability`
Used when `wrestler_actions.match_actions.items` includes aliases like
`global` or `position`.
- `effect`
Used by the rules engine to update score and match position when replaying
the event list.
- `statCode`
Used when rewriting the hidden `w1_stat` / `w2_stat` fields from the
structured event log for websocket sync and match submission.
- `progression`
Used for progressive actions like stalling, caution, and penalty to decide
if the opponent should automatically receive a linked point-scoring event.
Supported `availability` values used by the wrestler-side UI:
- `global`
- `neutral`
- `top`
- `bottom`
- `extra`
3. `timers`
Canonical timer definitions keyed by timer name.
This controls both the timer controls in the wrestler panel and how timer
usage is labeled in the event log.
How the state page uses it:
- `label`
Displayed next to the running timer value in the wrestler panel.
- `maxSeconds`
Used to initialize, reset, clamp, and render the timer.
- `historyLabel`
Used when a timer stop event is recorded in history.
- `statCode`
Used when rewriting the hidden `w1_stat` / `w2_stat` fields for timer-used
events.
4. `phases`
Defines the period / choice sequence for this wrestling style.
The active phase drives:
- the main match clock
- phase labels
- start-of-period position behavior
- choice button behavior
- event grouping in the history list
How the state page uses it:
- chooses which phase sequence to use from bracket position
- builds the main match clock state for timed phases
- determines whether the current phase is a period or a choice phase
- determines how a period starts (`neutral` or from a prior choice)
*/
const RULESETS = {
folkstyle_usa: {
id: "folkstyle_usa",
wrestler_actions: {
match_actions: {
title: "Match Actions",
description: "Scoring and match-state actions available based on current position.",
items: ["global", "position"]
},
timers: {
title: "Wrestler Timers",
description: "Track blood, injury, recovery, and head/neck time for this wrestler.",
items: ["blood", "injury", "recovery", "head_neck"]
},
extra_actions: {
title: "Extra Actions",
description: "Force the match into a specific position and record it in history.",
items: ["position_neutral", "position_top", "position_bottom"]
}
},
actionsByKey: {
stalling: {
label: "Stalling",
availability: "global",
statCode: "S",
effect: { points: 0 },
progression: [0, 1, 1, 2]
},
caution: {
label: "Caution",
availability: "global",
statCode: "C",
effect: { points: 0 },
progression: [0, 0, 1]
},
penalty: {
label: "Penalty",
availability: "global",
statCode: "P",
effect: { points: 0 },
progression: [1, 1, 2]
},
minus_1: {
label: "-1 Point",
availability: "global",
statCode: "-1",
effect: { points: -1 }
},
plus_1: {
label: "+1 Point",
availability: "global",
statCode: "+1",
effect: { points: 1 }
},
plus_2: {
label: "+2 Points",
statCode: "+2",
effect: { points: 2 }
},
takedown_3: {
label: "Takedown +3",
availability: "neutral",
statCode: "T3",
effect: { points: 3, nextPosition: "top" }
},
nearfall_2: {
label: "Nearfall +2",
availability: "top",
statCode: "N2",
effect: { points: 2 }
},
nearfall_3: {
label: "Nearfall +3",
availability: "top",
statCode: "N3",
effect: { points: 3 }
},
nearfall_4: {
label: "Nearfall +4",
availability: "top",
statCode: "N4",
effect: { points: 4 }
},
nearfall_5: {
label: "Nearfall +5",
availability: "top",
statCode: "N5",
effect: { points: 5 }
},
escape_1: {
label: "Escape +1",
availability: "bottom",
statCode: "E1",
effect: { points: 1, nextPosition: "neutral" }
},
reversal_2: {
label: "Reversal +2",
availability: "bottom",
statCode: "R2",
effect: { points: 2, nextPosition: "top" }
},
position_neutral: {
label: "Neutral",
availability: "extra",
statCode: "|Neutral|",
effect: { points: 0, nextPosition: "neutral" }
},
position_top: {
label: "Top",
availability: "extra",
statCode: "|Top|",
effect: { points: 0, nextPosition: "top" }
},
position_bottom: {
label: "Bottom",
availability: "extra",
statCode: "|Bottom|",
effect: { points: 0, nextPosition: "bottom" }
},
choice_top: {
label: "Choice: Top",
statCode: "|Chose Top|"
},
choice_bottom: {
label: "Choice: Bottom",
statCode: "|Chose Bottom|"
},
choice_neutral: {
label: "Choice: Neutral",
statCode: "|Chose Neutral|"
},
choice_defer: {
label: "Choice: Defer",
statCode: "|Deferred|"
}
},
timers: {
blood: { maxSeconds: 300, label: "Blood", historyLabel: "Blood Time Used", statCode: "Blood Time" },
injury: { maxSeconds: 90, label: "Injury", historyLabel: "Injury Time Used", statCode: "Injury Time" },
recovery: { maxSeconds: 120, label: "Recovery", historyLabel: "Recovery Time Used", statCode: "Recovery Time" },
head_neck: { maxSeconds: 300, label: "Head/Neck", historyLabel: "Head/Neck Time Used", statCode: "Head/Neck Time" }
},
phases: {
championship: {
label: "Championship Format",
sequence: [
{ key: "period_1", label: "Period 1", type: "period", startsIn: "neutral", clockSeconds: 120 },
{ key: "choice_1", label: "Choice 1", type: "choice", chooser: "either", options: ["top", "bottom", "neutral", "defer"] },
{ key: "period_2", label: "Period 2", type: "period", startsFromChoice: "choice_1", clockSeconds: 120 },
{ key: "choice_2", label: "Choice 2", type: "choice", chooser: "other", options: ["top", "bottom", "neutral"] },
{ key: "period_3", label: "Period 3", type: "period", startsFromChoice: "choice_2", clockSeconds: 120 },
{ key: "sv_1", label: "SV-1", type: "period", startsIn: "neutral", clockSeconds: 60, overtimeType: "SV-1" },
{ key: "choice_3", label: "Choice 3", type: "choice", chooser: "either", options: ["top", "bottom", "defer"] },
{ key: "tb_1a", label: "TB-1A", type: "period", startsFromChoice: "choice_3", clockSeconds: 30, overtimeType: "TB-1" },
{ key: "choice_4", label: "Choice 4", type: "choice", chooser: "other", options: ["top", "bottom"] },
{ key: "tb_1b", label: "TB-1B", type: "period", startsFromChoice: "choice_4", clockSeconds: 30, overtimeType: "TB-1" },
{ key: "choice_5", label: "Choice 5", type: "choice", chooser: "either", options: ["top", "bottom"] },
{ key: "utb", label: "UTB", type: "period", startsFromChoice: "choice_5", clockSeconds: 30, overtimeType: "UTB" }
]
},
consolation: {
label: "Consolation Format",
sequence: [
{ key: "period_1", label: "Period 1", type: "period", startsIn: "neutral", clockSeconds: 60 },
{ key: "choice_1", label: "Choice 1", type: "choice", chooser: "either", options: ["top", "bottom", "neutral", "defer"] },
{ key: "period_2", label: "Period 2", type: "period", startsFromChoice: "choice_1", clockSeconds: 120 },
{ key: "choice_2", label: "Choice 2", type: "choice", chooser: "other", options: ["top", "bottom", "neutral"] },
{ key: "period_3", label: "Period 3", type: "period", startsFromChoice: "choice_2", clockSeconds: 120 },
{ key: "sv_1", label: "SV-1", type: "period", startsIn: "neutral", clockSeconds: 60, overtimeType: "SV-1" },
{ key: "choice_3", label: "Choice 3", type: "choice", chooser: "either", options: ["top", "bottom", "defer"] },
{ key: "tb_1a", label: "TB-1A", type: "period", startsFromChoice: "choice_3", clockSeconds: 30, overtimeType: "TB-1" },
{ key: "choice_4", label: "Choice 4", type: "choice", chooser: "other", options: ["top", "bottom"] },
{ key: "tb_1b", label: "TB-1B", type: "period", startsFromChoice: "choice_4", clockSeconds: 30, overtimeType: "TB-1" },
{ key: "choice_5", label: "Choice 5", type: "choice", chooser: "either", options: ["top", "bottom"] },
{ key: "utb", label: "UTB", type: "period", startsFromChoice: "choice_5", clockSeconds: 30, overtimeType: "UTB" }
]
}
}
}
}
function phaseStyleKeyForBracketPosition(bracketPosition) {
if (!bracketPosition) return "championship"
if (
bracketPosition.includes("Conso") ||
["3/4", "5/6", "7/8"].includes(bracketPosition)
) {
return "consolation"
}
return "championship"
}
function buildActionEffects(actionsByKey) {
return Object.fromEntries(
Object.entries(actionsByKey)
.filter(([, action]) => action.effect)
.map(([key, action]) => [key, action.effect])
)
}
function buildActionLabels(actionsByKey, timers) {
const actionLabels = Object.fromEntries(
Object.entries(actionsByKey)
.filter(([, action]) => action.label)
.map(([key, action]) => [key, action.label])
)
Object.entries(timers || {}).forEach(([timerKey, timer]) => {
if (timer.historyLabel) {
actionLabels[`timer_used_${timerKey}`] = timer.historyLabel
}
})
return actionLabels
}
function buildProgressionRules(actionsByKey) {
return Object.fromEntries(
Object.entries(actionsByKey)
.filter(([, action]) => Array.isArray(action.progression))
.map(([key, action]) => [key, action.progression])
)
}
export function getMatchStateConfig(rulesetId, bracketPosition) {
const ruleset = RULESETS[rulesetId] || RULESETS.folkstyle_usa
const phaseStyleKey = phaseStyleKeyForBracketPosition(bracketPosition)
const phaseStyle = ruleset.phases[phaseStyleKey]
return {
...ruleset,
actionEffects: buildActionEffects(ruleset.actionsByKey),
actionLabels: buildActionLabels(ruleset.actionsByKey, ruleset.timers),
progressionRules: buildProgressionRules(ruleset.actionsByKey),
matchFormat: { id: phaseStyleKey, label: phaseStyle.label },
phaseSequence: phaseStyle.sequence
}
}

View File

@@ -0,0 +1,567 @@
export function buildTimerState(config) {
return Object.fromEntries(
Object.entries(config.timers).map(([timerKey, timerConfig]) => [
timerKey,
{
remainingSeconds: timerConfig.maxSeconds,
running: false,
startedAt: null
}
])
)
}
export function buildClockState(config) {
return Object.fromEntries(
config.phaseSequence
.filter((phase) => phase.type === "period")
.map((phase) => [
phase.key,
{
durationSeconds: phase.clockSeconds,
remainingSeconds: phase.clockSeconds,
running: false,
startedAt: null
}
])
)
}
export function buildInitialState(config) {
const openingPhase = config.phaseSequence[0]
return {
participantScores: {
w1: 0,
w2: 0
},
control: "neutral",
displayControl: "neutral",
phaseIndex: 0,
selections: {},
assignment: {
w1: "green",
w2: "red"
},
nextEventId: 1,
nextEventGroupId: 1,
events: [],
clocksByPhase: buildClockState(config),
clock: {
durationSeconds: openingPhase.clockSeconds,
remainingSeconds: openingPhase.clockSeconds,
running: false,
startedAt: null
},
timers: {
w1: buildTimerState(config),
w2: buildTimerState(config)
}
}
}
export function buildEvent(state, phase, clockSeconds, participantKey, actionKey, options = {}) {
return {
id: state.nextEventId++,
phaseKey: phase.key,
phaseLabel: phase.label,
clockSeconds,
participantKey,
actionKey,
actionGroupId: options.actionGroupId
}
}
export function opponentParticipant(participantKey) {
return participantKey === "w1" ? "w2" : "w1"
}
export function isProgressiveAction(config, actionKey) {
return Object.prototype.hasOwnProperty.call(config.progressionRules || {}, actionKey)
}
export function progressiveActionCountForParticipant(events, participantKey, actionKey) {
return events.filter((eventRecord) =>
eventRecord.participantKey === participantKey && eventRecord.actionKey === actionKey
).length
}
export function progressiveActionPointsForOffense(config, actionKey, offenseNumber) {
const progression = config.progressionRules?.[actionKey] || []
return progression[Math.min(offenseNumber - 1, progression.length - 1)] || 0
}
export function recordProgressiveAction(config, state, participantKey, actionKey, buildEvent) {
const offenseNumber = progressiveActionCountForParticipant(state.events, participantKey, actionKey) + 1
const actionGroupId = state.nextEventGroupId++
state.events.push(buildEvent(participantKey, actionKey, { actionGroupId }))
const awardedPoints = progressiveActionPointsForOffense(config, actionKey, offenseNumber)
if (awardedPoints > 0) {
state.events.push(buildEvent(opponentParticipant(participantKey), `plus_${awardedPoints}`, { actionGroupId }))
}
}
export function applyMatchAction(config, state, phase, clockSeconds, participantKey, actionKey) {
const effect = config.actionEffects[actionKey]
if (!effect) return false
if (isProgressiveAction(config, actionKey)) {
recordProgressiveAction(
config,
state,
participantKey,
actionKey,
(eventParticipantKey, eventActionKey, options = {}) =>
buildEvent(state, phase, clockSeconds, eventParticipantKey, eventActionKey, options)
)
} else {
state.events.push(buildEvent(state, phase, clockSeconds, participantKey, actionKey))
}
return true
}
export function applyChoiceAction(state, phase, clockSeconds, participantKey, choiceKey) {
if (phase.type !== "choice") return { applied: false, deferred: false }
state.events.push(buildEvent(state, phase, clockSeconds, participantKey, `choice_${choiceKey}`))
if (choiceKey === "defer") {
return { applied: true, deferred: true }
}
state.selections[phase.key] = {
participantKey,
choiceKey
}
return { applied: true, deferred: false }
}
export function deleteEventFromState(config, state, eventId) {
const eventRecord = state.events.find((eventItem) => eventItem.id === eventId)
if (!eventRecord) return false
let eventIdsToDelete = [eventId]
if (eventRecord.actionKey.startsWith("timer_used_") && typeof eventRecord.elapsedSeconds === "number") {
const timerKey = eventRecord.actionKey.replace("timer_used_", "")
const timer = state.timers[eventRecord.participantKey]?.[timerKey]
const maxSeconds = config.timers[timerKey]?.maxSeconds || 0
if (timer) {
timer.remainingSeconds = Math.min(maxSeconds, timer.remainingSeconds + eventRecord.elapsedSeconds)
}
}
if (isProgressiveAction(config, eventRecord.actionKey)) {
const linkedAward = findLinkedProgressiveAward(state.events, eventRecord)
if (linkedAward) {
eventIdsToDelete.push(linkedAward.id)
}
}
state.events = state.events.filter((eventItem) => !eventIdsToDelete.includes(eventItem.id))
return true
}
export function swapEventParticipants(config, state, eventId) {
const eventRecord = state.events.find((eventItem) => eventItem.id === eventId)
if (!eventRecord) return false
const originalParticipant = eventRecord.participantKey
const swappedParticipant = opponentParticipant(originalParticipant)
if (eventRecord.actionKey.startsWith("timer_used_") && typeof eventRecord.elapsedSeconds === "number") {
reassignTimerUsage(config, state, eventRecord, swappedParticipant)
}
eventRecord.participantKey = swappedParticipant
if (isProgressiveAction(config, eventRecord.actionKey)) {
swapLinkedProgressiveAward(state.events, eventRecord, swappedParticipant)
}
return true
}
export function swapPhaseParticipants(config, state, phaseKey) {
const phaseEvents = state.events.filter((eventRecord) => eventRecord.phaseKey === phaseKey)
if (phaseEvents.length === 0) return false
phaseEvents.forEach((eventRecord) => {
if (eventRecord.actionKey.startsWith("timer_used_") && typeof eventRecord.elapsedSeconds === "number") {
reassignTimerUsage(config, state, eventRecord, opponentParticipant(eventRecord.participantKey))
}
eventRecord.participantKey = opponentParticipant(eventRecord.participantKey)
})
return true
}
export function phaseIndexForKey(config, phaseKey) {
const phaseIndex = config.phaseSequence.findIndex((phase) => phase.key === phaseKey)
return phaseIndex === -1 ? Number.MAX_SAFE_INTEGER : phaseIndex
}
export function activeClockForPhase(state, phase) {
if (!phase || phase.type !== "period") return null
return state.clocksByPhase[phase.key] || null
}
export function hasRunningClockOrTimer(state) {
const anyTimerRunning = ["w1", "w2"].some((participantKey) =>
Object.values(state.timers[participantKey] || {}).some((timer) => timer.running)
)
const anyClockRunning = Object.values(state.clocksByPhase || {}).some((clock) => clock.running)
return anyTimerRunning || anyClockRunning
}
export function stopAllAuxiliaryTimers(state, now = Date.now()) {
for (const participantKey of ["w1", "w2"]) {
for (const timerKey of Object.keys(state.timers[participantKey] || {})) {
const timer = state.timers[participantKey][timerKey]
if (!timer.running) continue
const elapsedSeconds = Math.floor((now - timer.startedAt) / 1000)
timer.remainingSeconds = Math.max(0, timer.remainingSeconds - elapsedSeconds)
timer.running = false
timer.startedAt = null
}
}
}
export function moveToPreviousPhase(config, state) {
if (state.phaseIndex === 0) return false
state.phaseIndex -= 1
state.control = baseControlForPhase(config.phaseSequence[state.phaseIndex], state.selections, state.control)
return true
}
export function moveToNextPhase(config, state) {
if (state.phaseIndex >= config.phaseSequence.length - 1) return false
state.phaseIndex += 1
state.control = baseControlForPhase(config.phaseSequence[state.phaseIndex], state.selections, state.control)
return true
}
export function orderedEvents(config, events) {
return [...events].sort((leftEvent, rightEvent) => {
const leftPhaseIndex = phaseIndexForKey(config, leftEvent.phaseKey)
const rightPhaseIndex = phaseIndexForKey(config, rightEvent.phaseKey)
if (leftPhaseIndex !== rightPhaseIndex) {
return leftPhaseIndex - rightPhaseIndex
}
return leftEvent.id - rightEvent.id
})
}
export function controlFromChoice(selection) {
if (!selection) return "neutral"
if (selection.choiceKey === "neutral" || selection.choiceKey === "defer") return "neutral"
if (selection.choiceKey === "top") return `${selection.participantKey}_control`
if (selection.choiceKey === "bottom") return `${opponentParticipant(selection.participantKey)}_control`
return "neutral"
}
export function baseControlForPhase(phase, selections, fallbackControl) {
if (phase.type !== "period") return fallbackControl
if (phase.startsIn === "neutral") return "neutral"
if (phase.startsFromChoice) {
return controlFromChoice(selections[phase.startsFromChoice])
}
return "neutral"
}
export function recomputeDerivedState(config, state) {
state.participantScores = { w1: 0, w2: 0 }
state.selections = {}
state.control = baseControlForPhase(config.phaseSequence[state.phaseIndex], state.selections, state.control)
orderedEvents(config, state.events).forEach((eventRecord) => {
if (eventRecord.actionKey.startsWith("choice_")) {
const choiceKey = eventRecord.actionKey.replace("choice_", "")
if (choiceKey === "defer") return
state.selections[eventRecord.phaseKey] = {
participantKey: eventRecord.participantKey,
choiceKey: choiceKey
}
state.control = baseControlForPhase(config.phaseSequence[state.phaseIndex], state.selections, state.control)
return
}
const effect = config.actionEffects[eventRecord.actionKey]
if (!effect) return
const scoringParticipant = effect.target === "opponent"
? opponentParticipant(eventRecord.participantKey)
: eventRecord.participantKey
const nextScore = state.participantScores[scoringParticipant] + effect.points
state.participantScores[scoringParticipant] = Math.max(0, nextScore)
if (effect.nextPosition === "neutral") {
state.control = "neutral"
} else if (effect.nextPosition === "top") {
state.control = `${eventRecord.participantKey}_control`
} else if (effect.nextPosition === "bottom") {
state.control = `${opponentParticipant(eventRecord.participantKey)}_control`
}
})
state.displayControl = controlForSelectedPhase(config, state)
}
export function controlForSelectedPhase(config, state) {
const selectedPhase = config.phaseSequence[state.phaseIndex]
let control = baseControlForPhase(selectedPhase, state.selections, state.control)
const selectedPhaseIndex = phaseIndexForKey(config, selectedPhase.key)
orderedEvents(config, state.events).forEach((eventRecord) => {
if (phaseIndexForKey(config, eventRecord.phaseKey) > selectedPhaseIndex) return
if (eventRecord.phaseKey !== selectedPhase.key) return
const effect = config.actionEffects[eventRecord.actionKey]
if (!effect) return
if (effect.nextPosition === "neutral") {
control = "neutral"
} else if (effect.nextPosition === "top") {
control = `${eventRecord.participantKey}_control`
} else if (effect.nextPosition === "bottom") {
control = `${opponentParticipant(eventRecord.participantKey)}_control`
}
})
return control
}
export function currentClockSeconds(clockState, now = Date.now()) {
if (!clockState) return 0
if (!clockState.running || !clockState.startedAt) {
return clockState.remainingSeconds
}
const elapsedSeconds = Math.floor((now - clockState.startedAt) / 1000)
return Math.max(0, clockState.remainingSeconds - elapsedSeconds)
}
export function currentAuxiliaryTimerSeconds(timerState, now = Date.now()) {
if (!timerState) return 0
if (!timerState.running || !timerState.startedAt) {
return timerState.remainingSeconds
}
const elapsedSeconds = Math.floor((now - timerState.startedAt) / 1000)
return Math.max(0, timerState.remainingSeconds - elapsedSeconds)
}
export function syncClockSnapshot(activeClock) {
if (!activeClock) {
return {
durationSeconds: 0,
remainingSeconds: 0,
running: false,
startedAt: null
}
}
return {
durationSeconds: activeClock.durationSeconds,
remainingSeconds: activeClock.remainingSeconds,
running: activeClock.running,
startedAt: activeClock.startedAt
}
}
export function startClockState(activeClock, now = Date.now()) {
if (!activeClock || activeClock.running) return false
activeClock.running = true
activeClock.startedAt = now
return true
}
export function stopClockState(activeClock, now = Date.now()) {
if (!activeClock || !activeClock.running) return false
activeClock.remainingSeconds = currentClockSeconds(activeClock, now)
activeClock.running = false
activeClock.startedAt = null
return true
}
export function adjustClockState(activeClock, deltaSeconds, now = Date.now()) {
if (!activeClock) return false
const currentSeconds = currentClockSeconds(activeClock, now)
activeClock.remainingSeconds = Math.max(0, currentSeconds + deltaSeconds)
if (activeClock.running) {
activeClock.startedAt = now
}
return true
}
export function startAuxiliaryTimerState(timerState, now = Date.now()) {
if (!timerState || timerState.running) return false
timerState.running = true
timerState.startedAt = now
return true
}
export function stopAuxiliaryTimerState(timerState, now = Date.now()) {
if (!timerState || !timerState.running) return { stopped: false, elapsedSeconds: 0 }
const elapsedSeconds = Math.floor((now - timerState.startedAt) / 1000)
timerState.remainingSeconds = currentAuxiliaryTimerSeconds(timerState, now)
timerState.running = false
timerState.startedAt = null
return { stopped: true, elapsedSeconds }
}
export function accumulatedMatchSeconds(config, state, activePhaseKey, now = Date.now()) {
return config.phaseSequence
.filter((phase) => phase.type === "period")
.reduce((totalElapsed, phase) => {
const clockState = state.clocksByPhase[phase.key]
if (!clockState) return totalElapsed
const remainingSeconds = phase.key === activePhaseKey
? currentClockSeconds(clockState, now)
: clockState.remainingSeconds
const elapsedSeconds = Math.max(0, clockState.durationSeconds - remainingSeconds)
return totalElapsed + elapsedSeconds
}, 0)
}
export function derivedStats(config, events) {
const grouped = config.phaseSequence.map((phase) => {
const phaseEvents = orderedEvents(config, events).filter((eventRecord) => eventRecord.phaseKey === phase.key)
if (phaseEvents.length === 0) return null
return {
label: phase.label,
w1: phaseEvents
.filter((eventRecord) => eventRecord.participantKey === "w1")
.map((eventRecord) => statTextForEvent(config, eventRecord))
.filter(Boolean),
w2: phaseEvents
.filter((eventRecord) => eventRecord.participantKey === "w2")
.map((eventRecord) => statTextForEvent(config, eventRecord))
.filter(Boolean)
}
}).filter(Boolean)
return {
w1: formatStatsByPhase(grouped, "w1"),
w2: formatStatsByPhase(grouped, "w2")
}
}
export function scoreboardStatePayload(config, state, metadata) {
return {
participantScores: state.participantScores,
assignment: state.assignment,
phaseIndex: state.phaseIndex,
clocksByPhase: state.clocksByPhase,
timers: state.timers,
metadata: metadata,
matchResult: {
finished: false
}
}
}
export function matchResultDefaults(state, options = {}) {
const {
w1Id = "",
w2Id = "",
currentPhase = {},
accumulationSeconds = 0
} = options
const w1Score = state.participantScores.w1
const w2Score = state.participantScores.w2
let winnerId = ""
let winnerScore = w1Score
let loserScore = w2Score
if (w1Score > w2Score) {
winnerId = w1Id || ""
winnerScore = w1Score
loserScore = w2Score
} else if (w2Score > w1Score) {
winnerId = w2Id || ""
winnerScore = w2Score
loserScore = w1Score
}
return {
winnerId,
overtimeType: currentPhase.overtimeType || "",
winnerScore,
loserScore,
pinMinutes: Math.floor(accumulationSeconds / 60),
pinSeconds: accumulationSeconds % 60
}
}
function statTextForEvent(config, eventRecord) {
if (eventRecord.actionKey.startsWith("timer_used_")) {
const timerKey = eventRecord.actionKey.replace("timer_used_", "")
const timerConfig = config.timers[timerKey]
if (!timerConfig || typeof eventRecord.elapsedSeconds !== "number") return null
return `${timerConfig.statCode || timerConfig.label}: ${formatClock(eventRecord.elapsedSeconds)}`
}
const action = config.actionsByKey[eventRecord.actionKey]
return action?.statCode || null
}
function formatStatsByPhase(groupedPhases, participantKey) {
return groupedPhases
.map((phase) => {
const items = phase[participantKey]
if (!items || items.length === 0) return null
return `${phase.label}: ${items.join(" ")}`
})
.filter(Boolean)
.join("\n")
}
function formatClock(totalSeconds) {
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${minutes}:${seconds.toString().padStart(2, "0")}`
}
function reassignTimerUsage(config, state, eventRecord, newParticipantKey) {
const timerKey = eventRecord.actionKey.replace("timer_used_", "")
const originalParticipant = eventRecord.participantKey
const originalTimer = state.timers[originalParticipant]?.[timerKey]
const newTimer = state.timers[newParticipantKey]?.[timerKey]
const maxSeconds = config.timers[timerKey]?.maxSeconds || 0
if (!originalTimer || !newTimer || typeof eventRecord.elapsedSeconds !== "number") return
originalTimer.remainingSeconds = Math.min(maxSeconds, originalTimer.remainingSeconds + eventRecord.elapsedSeconds)
newTimer.remainingSeconds = Math.max(0, newTimer.remainingSeconds - eventRecord.elapsedSeconds)
}
function swapLinkedProgressiveAward(events, eventRecord, offendingParticipant) {
const linkedAward = findLinkedProgressiveAward(events, eventRecord)
if (linkedAward) {
linkedAward.participantKey = opponentParticipant(offendingParticipant)
}
}
function findLinkedProgressiveAward(events, eventRecord) {
return events.find((candidateEvent) =>
candidateEvent.id !== eventRecord.id &&
candidateEvent.actionGroupId &&
candidateEvent.actionGroupId === eventRecord.actionGroupId &&
candidateEvent.actionKey.startsWith("plus_")
)
}

View File

@@ -0,0 +1,94 @@
import { orderedEvents } from "match-state-engine"
export function displayLabelForParticipant(assignment, participantKey) {
return assignment[participantKey] === "green" ? "Green" : "Red"
}
export function buttonClassForParticipant(assignment, participantKey) {
return assignment[participantKey] === "green" ? "btn-success" : "btn-danger"
}
export function humanizeChoice(choiceKey) {
if (choiceKey === "top") return "Top"
if (choiceKey === "bottom") return "Bottom"
if (choiceKey === "neutral") return "Neutral"
if (choiceKey === "defer") return "Defer"
return choiceKey
}
export function choiceLabelForPhase(phase) {
if (phase.chooser === "other") return "Other wrestler chooses"
return "Choose wrestler and position"
}
export function eventLogSections(config, state, formatClock) {
const eventsByPhase = orderedEvents(config, state.events).reduce((accumulator, eventRecord) => {
if (!accumulator[eventRecord.phaseKey]) {
accumulator[eventRecord.phaseKey] = []
}
accumulator[eventRecord.phaseKey].push(eventRecord)
return accumulator
}, {})
return config.phaseSequence.map((phase) => {
const phaseEvents = eventsByPhase[phase.key]
if (!phaseEvents || phaseEvents.length === 0) return null
return {
key: phase.key,
label: phase.label,
items: [...phaseEvents].reverse().map((eventRecord) => ({
id: eventRecord.id,
participantKey: eventRecord.participantKey,
colorLabel: displayLabelForParticipant(state.assignment, eventRecord.participantKey),
actionLabel: eventActionLabel(config, eventRecord, formatClock),
clockLabel: formatClock(eventRecord.clockSeconds)
}))
}
}).filter(Boolean)
}
export function choiceViewModel(config, state, phase, participantMeta) {
if (phase.type !== "choice") return null
const phaseEvents = state.events.filter((eventRecord) => eventRecord.phaseKey === phase.key)
const deferredParticipants = phaseEvents
.filter((eventRecord) => eventRecord.actionKey === "choice_defer")
.map((eventRecord) => eventRecord.participantKey)
const selection = state.selections[phase.key]
const selectionText = selection
? `Selected: ${displayLabelForParticipant(state.assignment, selection.participantKey)} ${humanizeChoice(selection.choiceKey)}`
: deferredParticipants.length > 0
? `${deferredParticipants.map((participantKey) => displayLabelForParticipant(state.assignment, participantKey)).join(", ")} deferred. Waiting for the other wrestler to choose.`
: "No choice selected."
const availableParticipants = deferredParticipants.length > 0
? ["w1", "w2"].filter((participantKey) => !deferredParticipants.includes(participantKey))
: ["w1", "w2"]
const buttons = availableParticipants.flatMap((participantKey) =>
phase.options
.filter((choiceKey) => !(deferredParticipants.length > 0 && choiceKey === "defer"))
.map((choiceKey) => ({
participantKey,
choiceKey,
buttonClass: buttonClassForParticipant(state.assignment, participantKey),
text: `${participantMeta[participantKey].name} (${displayLabelForParticipant(state.assignment, participantKey)}) ${humanizeChoice(choiceKey)}`
}))
)
return {
label: choiceLabelForPhase(phase),
selectionText,
buttons
}
}
function eventActionLabel(config, eventRecord, formatClock) {
let actionLabel = config.actionLabels[eventRecord.actionKey] || eventRecord.actionKey
if (eventRecord.actionKey.startsWith("timer_used_") && typeof eventRecord.elapsedSeconds === "number") {
actionLabel = `${actionLabel}: ${formatClock(eventRecord.elapsedSeconds)}`
}
return actionLabel
}

View File

@@ -0,0 +1,288 @@
export function participantForColor(state, color) {
if (!state?.assignment) {
return color === "red" ? "w2" : "w1"
}
const match = Object.entries(state.assignment).find(([, assignedColor]) => assignedColor === color)
return match ? match[0] : (color === "red" ? "w2" : "w1")
}
export function participantColor(state, participantKey) {
return state?.assignment?.[participantKey] || (participantKey === "w1" ? "green" : "red")
}
export function participantName(state, participantKey) {
return participantKey === "w1" ? state?.metadata?.w1Name : state?.metadata?.w2Name
}
export function participantSchool(state, participantKey) {
return participantKey === "w1" ? state?.metadata?.w1School : state?.metadata?.w2School
}
export function participantScore(state, participantKey) {
return state?.participantScores?.[participantKey] || 0
}
export function currentPhaseLabel(config, state) {
const phaseIndex = state?.phaseIndex || 0
return config?.phaseSequence?.[phaseIndex]?.label || "Period 1"
}
export function currentClockText(config, state, formatClock, now = Date.now()) {
const phaseIndex = state?.phaseIndex || 0
const phase = config?.phaseSequence?.[phaseIndex]
if (!phase || phase.type !== "period") return "-"
const clockState = state?.clocksByPhase?.[phase.key]
if (!clockState) return formatClock(phase.clockSeconds)
let remainingSeconds = clockState.remainingSeconds
if (clockState.running && clockState.startedAt) {
const elapsedSeconds = Math.floor((now - clockState.startedAt) / 1000)
remainingSeconds = Math.max(0, clockState.remainingSeconds - elapsedSeconds)
}
return formatClock(remainingSeconds)
}
export function currentAuxiliaryTimerSeconds(state, participantKey, timerKey, now = Date.now()) {
const timer = state?.timers?.[participantKey]?.[timerKey]
if (!timer) return 0
if (!timer.running || !timer.startedAt) {
return timer.remainingSeconds
}
const elapsedSeconds = Math.floor((now - timer.startedAt) / 1000)
return Math.max(0, timer.remainingSeconds - elapsedSeconds)
}
export function runningTimerForParticipant(state, participantKey) {
for (const timerKey of Object.keys(state?.timers?.[participantKey] || {})) {
if (state.timers[participantKey][timerKey]?.running) {
return timerKey
}
}
return null
}
export function participantDisplayLabel(state, participantKey) {
return `${participantForColor(state, "red") === participantKey ? "Red" : "Green"} ${participantName(state, participantKey)}`
}
export function timerIndicatorLabel(config, state, participantKey, formatClock, now = Date.now()) {
const runningTimer = runningTimerForParticipant(state, participantKey)
if (!runningTimer) return ""
const timerConfig = config?.timers?.[runningTimer]
if (!timerConfig) return ""
const remainingSeconds = currentAuxiliaryTimerSeconds(state, participantKey, runningTimer, now)
const usedSeconds = Math.max(0, timerConfig.maxSeconds - remainingSeconds)
return `${timerConfig.label}: ${formatClock(usedSeconds)}`
}
export function buildRunningTimerSnapshot(state) {
const snapshot = {}
for (const participantKey of ["w1", "w2"]) {
for (const timerKey of Object.keys(state?.timers?.[participantKey] || {})) {
const timer = state.timers[participantKey][timerKey]
snapshot[`${participantKey}:${timerKey}`] = Boolean(timer?.running)
}
}
return snapshot
}
export function detectRecentlyStoppedTimer(state, previousTimerSnapshot) {
previousTimerSnapshot ||= {}
for (const participantKey of ["w1", "w2"]) {
for (const timerKey of Object.keys(state?.timers?.[participantKey] || {})) {
const snapshotKey = `${participantKey}:${timerKey}`
const wasRunning = previousTimerSnapshot[snapshotKey]
const isRunning = Boolean(state.timers[participantKey][timerKey]?.running)
if (wasRunning && !isRunning) {
return { participantKey, timerKey }
}
}
}
return null
}
export function runningAuxiliaryTimer(state) {
for (const participantKey of ["w1", "w2"]) {
for (const timerKey of Object.keys(state?.timers?.[participantKey] || {})) {
const timer = state.timers[participantKey][timerKey]
if (timer?.running) {
return { participantKey, timerKey }
}
}
}
return null
}
export function mainClockRunning(config, state) {
const phaseIndex = state?.phaseIndex || 0
const phase = config?.phaseSequence?.[phaseIndex]
if (!phase || phase.type !== "period") return false
return Boolean(state?.clocksByPhase?.[phase.key]?.running)
}
export function timerBannerViewModel(config, state, timerBannerState, formatClock, now = Date.now()) {
if (!timerBannerState) return null
const { participantKey, timerKey, expiresAt } = timerBannerState
if (expiresAt && now > expiresAt) return null
const timer = state?.timers?.[participantKey]?.[timerKey]
const timerConfig = config?.timers?.[timerKey]
if (!timer || !timerConfig) return null
const runningSeconds = currentAuxiliaryTimerSeconds(state, participantKey, timerKey, now)
const usedSeconds = Math.max(0, timerConfig.maxSeconds - runningSeconds)
const color = participantColor(state, participantKey)
const label = `${participantDisplayLabel(state, participantKey)} ${timerConfig.label}`
return {
color,
label: timer.running ? `${label} Running` : `${label} Used`,
clockText: formatClock(usedSeconds)
}
}
export function populatedBoardViewModel(config, state, liveMatchData, currentBoutNumber, formatClock, now = Date.now()) {
const redParticipant = participantForColor(state, "red")
const greenParticipant = participantForColor(state, "green")
return {
isEmpty: false,
redName: participantName(state, redParticipant),
redSchool: participantSchool(state, redParticipant),
redScore: participantScore(state, redParticipant).toString(),
redTimerIndicator: timerIndicatorLabel(config, state, redParticipant, formatClock, now),
greenName: participantName(state, greenParticipant),
greenSchool: participantSchool(state, greenParticipant),
greenScore: participantScore(state, greenParticipant).toString(),
greenTimerIndicator: timerIndicatorLabel(config, state, greenParticipant, formatClock, now),
clockText: currentClockText(config, state, formatClock, now),
phaseLabel: currentPhaseLabel(config, state),
weightLabel: state?.metadata?.weightLabel ? `Weight ${state.metadata.weightLabel}` : "Weight -",
boutLabel: currentBoutNumber ? `Bout ${currentBoutNumber}` : "No Bout",
redStats: redParticipant === "w1" ? (liveMatchData?.w1_stat || "") : (liveMatchData?.w2_stat || ""),
greenStats: greenParticipant === "w1" ? (liveMatchData?.w1_stat || "") : (liveMatchData?.w2_stat || "")
}
}
export function emptyBoardViewModel(currentBoutNumber, lastMatchResult) {
return {
isEmpty: true,
redName: "NO MATCH",
redSchool: "",
redScore: "0",
redTimerIndicator: "",
greenName: "NO MATCH",
greenSchool: "",
greenScore: "0",
greenTimerIndicator: "",
clockText: "-",
phaseLabel: "No Match",
weightLabel: "Weight -",
boutLabel: currentBoutNumber ? `Bout ${currentBoutNumber}` : "No Bout",
redStats: "",
greenStats: "",
lastMatchResult: lastMatchResult || "-"
}
}
export function nextTimerBannerState(state, previousTimerSnapshot, now = Date.now()) {
if (!state?.timers) {
return { timerBannerState: null, previousTimerSnapshot: {} }
}
const activeTimer = runningAuxiliaryTimer(state)
const nextSnapshot = buildRunningTimerSnapshot(state)
if (activeTimer) {
return {
timerBannerState: {
participantKey: activeTimer.participantKey,
timerKey: activeTimer.timerKey,
expiresAt: null
},
previousTimerSnapshot: nextSnapshot
}
}
const stoppedTimer = detectRecentlyStoppedTimer(state, previousTimerSnapshot)
if (stoppedTimer) {
return {
timerBannerState: {
participantKey: stoppedTimer.participantKey,
timerKey: stoppedTimer.timerKey,
expiresAt: now + 10000
},
previousTimerSnapshot: nextSnapshot
}
}
return {
timerBannerState: null,
previousTimerSnapshot: nextSnapshot
}
}
export function boardColors(isEmpty) {
if (isEmpty) {
return {
red: "#000",
center: "#000",
green: "#000"
}
}
return {
red: "#c91f1f",
center: "#050505",
green: "#1cab2d"
}
}
export function timerBannerRenderState(config, state, timerBannerState, formatClock, now = Date.now()) {
if (mainClockRunning(config, state)) {
return {
timerBannerState: timerBannerState?.expiresAt ? null : timerBannerState,
visible: false,
viewModel: null
}
}
if (!timerBannerState) {
return {
timerBannerState: null,
visible: false,
viewModel: null
}
}
if (timerBannerState.expiresAt && now > timerBannerState.expiresAt) {
return {
timerBannerState: null,
visible: false,
viewModel: null
}
}
const viewModel = timerBannerViewModel(config, state, timerBannerState, formatClock, now)
if (!viewModel) {
return {
timerBannerState,
visible: false,
viewModel: null
}
}
return {
timerBannerState,
visible: true,
viewModel
}
}

View File

@@ -0,0 +1,158 @@
import { buildStorageKey } from "match-state-serializers"
export function buildScoreboardContext({ initialBoutNumber, matchId }) {
const currentQueueBoutNumber = initialBoutNumber > 0 ? initialBoutNumber : null
return {
currentQueueBoutNumber,
currentBoutNumber: currentQueueBoutNumber,
currentMatchId: matchId || null,
liveMatchData: {},
lastMatchResult: "",
state: null,
finished: false,
timerBannerState: null,
previousTimerSnapshot: {}
}
}
export function selectedBoutStorageKey(tournamentId, matId) {
return `mat-selected-bout:${tournamentId}:${matId}`
}
export function matchStorageKey(tournamentId, boutNumber) {
if (!boutNumber) return null
return buildStorageKey(tournamentId, boutNumber)
}
export function extractLiveMatchData(data) {
const extracted = {}
if (data.w1_stat !== undefined) extracted.w1_stat = data.w1_stat
if (data.w2_stat !== undefined) extracted.w2_stat = data.w2_stat
if (data.score !== undefined) extracted.score = data.score
if (data.win_type !== undefined) extracted.win_type = data.win_type
if (data.winner_name !== undefined) extracted.winner_name = data.winner_name
if (data.finished !== undefined) extracted.finished = data.finished
return extracted
}
export function applyStatePayloadContext(currentContext, payload) {
return {
...currentContext,
state: payload,
finished: Boolean(payload?.matchResult?.finished),
currentBoutNumber: payload?.metadata?.boutNumber || currentContext.currentBoutNumber
}
}
export function applyMatchPayloadContext(currentContext, data) {
const nextContext = { ...currentContext }
if (data.scoreboard_state) {
Object.assign(nextContext, applyStatePayloadContext(nextContext, data.scoreboard_state))
}
nextContext.liveMatchData = {
...currentContext.liveMatchData,
...extractLiveMatchData(data)
}
if (data.finished !== undefined) {
nextContext.finished = Boolean(data.finished)
}
return nextContext
}
export function applyMatPayloadContext(currentContext, data) {
const currentQueueBoutNumber = data.queue1_bout_number || null
const lastMatchResult = data.last_match_result || ""
if (currentContext.sourceMode === "localstorage") {
return {
...currentContext,
currentQueueBoutNumber: data.selected_bout_number || currentQueueBoutNumber,
lastMatchResult,
loadSelectedBout: true,
loadLocalState: true,
unsubscribeMatch: false,
subscribeMatchId: null,
renderNow: true
}
}
const nextMatchId = data.selected_match_id || data.queue1_match_id || null
const nextBoutNumber = data.selected_bout_number || data.queue1_bout_number || null
const matchChanged = nextMatchId !== currentContext.currentMatchId
if (!nextMatchId) {
return {
...currentContext,
currentQueueBoutNumber,
lastMatchResult,
currentMatchId: null,
currentBoutNumber: nextBoutNumber,
state: null,
liveMatchData: {},
resetTimerBanner: true,
unsubscribeMatch: true,
subscribeMatchId: null,
renderNow: true
}
}
return {
...currentContext,
currentQueueBoutNumber,
lastMatchResult,
currentMatchId: nextMatchId,
currentBoutNumber: nextBoutNumber,
state: matchChanged ? null : currentContext.state,
liveMatchData: matchChanged ? {} : currentContext.liveMatchData,
resetTimerBanner: matchChanged,
unsubscribeMatch: false,
subscribeMatchId: matchChanged ? nextMatchId : null,
renderNow: matchChanged
}
}
export function connectionPlan(sourceMode, currentMatchId) {
return {
useStorageListener: sourceMode === "localstorage",
subscribeMat: sourceMode === "localstorage" || sourceMode === "mat_websocket",
subscribeMatch: sourceMode === "mat_websocket" || sourceMode === "websocket",
matchId: sourceMode === "mat_websocket" || sourceMode === "websocket" ? currentMatchId : null,
loadSelectedBout: sourceMode === "localstorage",
loadLocalState: sourceMode === "localstorage"
}
}
export function storageChangePlan(currentContext, eventKey, tournamentId, matId) {
const selectedKey = selectedBoutStorageKey(tournamentId, matId)
if (eventKey === selectedKey) {
return {
loadSelectedBout: true,
loadLocalState: true,
renderNow: true
}
}
const storageKey = matchStorageKey(tournamentId, currentContext.currentBoutNumber)
if (!storageKey || eventKey !== storageKey) {
return {
loadSelectedBout: false,
loadLocalState: false,
renderNow: false
}
}
return {
loadSelectedBout: false,
loadLocalState: true,
renderNow: true
}
}
export function selectedBoutNumber(selection, currentQueueBoutNumber) {
return selection?.boutNumber || currentQueueBoutNumber
}

View File

@@ -0,0 +1,66 @@
import { buildInitialState } from "match-state-engine"
export function buildMatchMetadata(values) {
return {
tournamentId: values.tournamentId,
boutNumber: values.boutNumber,
weightLabel: values.weightLabel,
ruleset: values.ruleset,
bracketPosition: values.bracketPosition,
w1Name: values.w1Name,
w2Name: values.w2Name,
w1School: values.w1School,
w2School: values.w2School
}
}
export function buildStorageKey(tournamentId, boutNumber) {
return `match-state:${tournamentId}:${boutNumber}`
}
export function buildPersistedState(state, metadata) {
return {
...state,
metadata
}
}
export function restorePersistedState(config, parsedState) {
const initialState = buildInitialState(config)
return {
...initialState,
...parsedState,
participantScores: {
...initialState.participantScores,
...(parsedState.participantScores || {})
},
assignment: {
...initialState.assignment,
...(parsedState.assignment || {})
},
clock: {
...initialState.clock,
...(parsedState.clock || {})
},
timers: {
w1: {
...initialState.timers.w1,
...(parsedState.timers?.w1 || {})
},
w2: {
...initialState.timers.w2,
...(parsedState.timers?.w2 || {})
}
},
clocksByPhase: Object.fromEntries(
Object.entries(initialState.clocksByPhase).map(([phaseKey, defaultClock]) => [
phaseKey,
{
...defaultClock,
...(parsedState.clocksByPhase?.[phaseKey] || {})
}
])
)
}
}

View File

@@ -0,0 +1,39 @@
export function loadJson(storage, key) {
try {
const rawValue = storage.getItem(key)
if (!rawValue) return null
return JSON.parse(rawValue)
} catch (_error) {
return null
}
}
export function saveJson(storage, key, value) {
try {
storage.setItem(key, JSON.stringify(value))
return true
} catch (_error) {
return false
}
}
export function removeKey(storage, key) {
try {
storage.removeItem(key)
return true
} catch (_error) {
return false
}
}
export function performIfChanged(subscription, action, payload, lastSerializedPayload) {
if (!subscription) return lastSerializedPayload
const serializedPayload = JSON.stringify(payload)
if (serializedPayload === lastSerializedPayload) {
return lastSerializedPayload
}
subscription.perform(action, payload)
return serializedPayload
}