mirror of
https://github.com/jcwimer/wrestlingApp
synced 2026-04-12 16:25:41 +00:00
New stats page, scoreboard, and live scores pages.
This commit is contained in:
344
app/assets/javascripts/lib/match_state/config.js
Normal file
344
app/assets/javascripts/lib/match_state/config.js
Normal 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
|
||||
}
|
||||
}
|
||||
567
app/assets/javascripts/lib/match_state/engine.js
Normal file
567
app/assets/javascripts/lib/match_state/engine.js
Normal 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_")
|
||||
)
|
||||
}
|
||||
94
app/assets/javascripts/lib/match_state/presenters.js
Normal file
94
app/assets/javascripts/lib/match_state/presenters.js
Normal 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
|
||||
}
|
||||
288
app/assets/javascripts/lib/match_state/scoreboard_presenters.js
Normal file
288
app/assets/javascripts/lib/match_state/scoreboard_presenters.js
Normal 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
|
||||
}
|
||||
}
|
||||
158
app/assets/javascripts/lib/match_state/scoreboard_state.js
Normal file
158
app/assets/javascripts/lib/match_state/scoreboard_state.js
Normal 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
|
||||
}
|
||||
66
app/assets/javascripts/lib/match_state/serializers.js
Normal file
66
app/assets/javascripts/lib/match_state/serializers.js
Normal 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] || {})
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
}
|
||||
39
app/assets/javascripts/lib/match_state/transport.js
Normal file
39
app/assets/javascripts/lib/match_state/transport.js
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user