diff --git a/.gitignore b/.gitignore index 792994d..dc4029b 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ tmp .rvmrc deploy/prod.env frontend/node_modules +node_modules .aider* # Ignore cypress test results @@ -33,4 +34,7 @@ cypress-tests/cypress/videos # repomix-output.xml # generated by cine mcp settings -~/ \ No newline at end of file +~/ + +/.ruby-lsp +.codex diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5c2debb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,11 @@ +- I have two ways to run rails commands in the repo. Either use rvm with `rvm use 4.0.1; rvm gemset use wrestlingdev;` or use docker with `docker run -it -v $(pwd):/rails wrestlingdev-dev ` + - If the docker image doesn't exist, use the build command: `docker build -t wrestlingdev-dev -f deploy/rails-dev-Dockerfile .` + - If the Gemfile changes, you need to rebuild the docker image: `docker build -t wrestlingdev-dev -f deploy/rails-dev-Dockerfile .` +- Do not add unnecessary comments to the code where you remove things. +- Write as little code as possible. I do not want crazy non standard rails implementations. +- This project is using propshaft and importmap. +- Stimulus is used for javascript. +- Cypress tests are created for js tests. They can be found in cypress-tests/cypress +- Cypress tests can be run with docker: bash cypress-tests/run-cypress-tests.sh +- javascript tests are through vitest. See `vitest.config.js`. Run `npm run test:js` +- importmap pins in `importmap.rb` and aliases in `vitest.config.js` need to match. diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 91bd060..4c512b8 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -18,6 +18,9 @@ window.Stimulus = application; import WrestlerColorController from "controllers/wrestler_color_controller"; import MatchScoreController from "controllers/match_score_controller"; import MatchDataController from "controllers/match_data_controller"; +import MatchStateController from "controllers/match_state_controller"; +import MatchScoreboardController from "controllers/match_scoreboard_controller"; +import MatStateController from "controllers/mat_state_controller"; import MatchSpectateController from "controllers/match_spectate_controller"; import UpMatchesConnectionController from "controllers/up_matches_connection_controller"; @@ -25,6 +28,9 @@ import UpMatchesConnectionController from "controllers/up_matches_connection_con application.register("wrestler-color", WrestlerColorController); application.register("match-score", MatchScoreController); application.register("match-data", MatchDataController); +application.register("match-state", MatchStateController); +application.register("match-scoreboard", MatchScoreboardController); +application.register("mat-state", MatStateController); application.register("match-spectate", MatchSpectateController); application.register("up-matches-connection", UpMatchesConnectionController); diff --git a/app/assets/javascripts/controllers/mat_state_controller.js b/app/assets/javascripts/controllers/mat_state_controller.js new file mode 100644 index 0000000..80691b9 --- /dev/null +++ b/app/assets/javascripts/controllers/mat_state_controller.js @@ -0,0 +1,163 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static values = { + tournamentId: Number, + matId: Number, + boutNumber: Number, + matchId: Number, + selectMatchUrl: String, + weightLabel: String, + w1Id: Number, + w2Id: Number, + w1Name: String, + w2Name: String, + w1School: String, + w2School: String + } + + connect() { + this.boundHandleSubmit = this.handleSubmit.bind(this) + this.saveSelectedBout() + this.broadcastSelectedBout() + this.element.addEventListener("submit", this.boundHandleSubmit) + } + + disconnect() { + this.element.removeEventListener("submit", this.boundHandleSubmit) + } + + storageKey() { + return `mat-selected-bout:${this.tournamentIdValue}:${this.matIdValue}` + } + + saveSelectedBout() { + if (!this.matIdValue || this.matIdValue <= 0) return + + try { + window.localStorage.setItem(this.storageKey(), JSON.stringify({ + boutNumber: this.boutNumberValue, + matchId: this.matchIdValue, + updatedAt: Date.now() + })) + } catch (_error) { + } + } + + broadcastSelectedBout() { + if (!this.hasSelectMatchUrlValue || !this.selectMatchUrlValue) return + + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content + const body = new URLSearchParams() + if (this.matchIdValue) body.set("match_id", this.matchIdValue.toString()) + if (this.boutNumberValue) body.set("bout_number", this.boutNumberValue.toString()) + + const lastMatchResult = this.loadLastMatchResult() + if (lastMatchResult) body.set("last_match_result", lastMatchResult) + + fetch(this.selectMatchUrlValue, { + method: "POST", + headers: { + "X-CSRF-Token": csrfToken || "", + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "Accept": "text/vnd.turbo-stream.html, text/html, application/xhtml+xml" + }, + body, + credentials: "same-origin" + }).catch(() => {}) + } + + lastMatchResultStorageKey() { + return `mat-last-match-result:${this.tournamentIdValue}:${this.matIdValue}` + } + + saveLastMatchResult(text) { + if (!this.matIdValue || this.matIdValue <= 0) return + + try { + if (text) { + window.localStorage.setItem(this.lastMatchResultStorageKey(), text) + } else { + window.localStorage.removeItem(this.lastMatchResultStorageKey()) + } + } catch (_error) { + } + } + + loadLastMatchResult() { + try { + return window.localStorage.getItem(this.lastMatchResultStorageKey()) || "" + } catch (_error) { + return "" + } + } + + handleSubmit(event) { + const form = event.target + if (!(form instanceof HTMLFormElement)) return + + const resultText = this.buildLastMatchResult(form) + if (!resultText) return + + this.saveLastMatchResult(resultText) + this.broadcastCurrentState(resultText) + } + + broadcastCurrentState(lastMatchResult) { + if (!this.hasSelectMatchUrlValue || !this.selectMatchUrlValue) return + + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content + const body = new URLSearchParams() + if (this.matchIdValue) body.set("match_id", this.matchIdValue.toString()) + if (this.boutNumberValue) body.set("bout_number", this.boutNumberValue.toString()) + if (lastMatchResult) body.set("last_match_result", lastMatchResult) + + fetch(this.selectMatchUrlValue, { + method: "POST", + headers: { + "X-CSRF-Token": csrfToken || "", + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "Accept": "text/vnd.turbo-stream.html, text/html, application/xhtml+xml" + }, + body, + credentials: "same-origin", + keepalive: true + }).catch(() => {}) + } + + buildLastMatchResult(form) { + const winnerId = form.querySelector("#match_winner_id")?.value + const winType = form.querySelector("#match_win_type")?.value + const score = form.querySelector("#final-score-field")?.value + if (!winnerId || !winType) return "" + + const winner = this.participantDataForId(winnerId) + const loser = this.loserParticipantData(winnerId) + if (!winner || !loser) return "" + + const weightLabel = this.hasWeightLabelValue ? this.weightLabelValue : "" + return `${weightLabel} lbs - ${winner.name} (${winner.school}) ${winType} ${loser.name} (${loser.school}) ${score || ""}`.trim() + } + + participantDataForId(participantId) { + const normalizedId = String(participantId) + if (normalizedId === String(this.w1IdValue)) { + return { name: this.w1NameValue, school: this.w1SchoolValue } + } + if (normalizedId === String(this.w2IdValue)) { + return { name: this.w2NameValue, school: this.w2SchoolValue } + } + return null + } + + loserParticipantData(winnerId) { + const normalizedId = String(winnerId) + if (normalizedId === String(this.w1IdValue)) { + return { name: this.w2NameValue, school: this.w2SchoolValue } + } + if (normalizedId === String(this.w2IdValue)) { + return { name: this.w1NameValue, school: this.w1SchoolValue } + } + return null + } +} diff --git a/app/assets/javascripts/controllers/match_score_controller.js b/app/assets/javascripts/controllers/match_score_controller.js index 34d431c..145bc85 100644 --- a/app/assets/javascripts/controllers/match_score_controller.js +++ b/app/assets/javascripts/controllers/match_score_controller.js @@ -2,25 +2,44 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = [ - "winType", "winnerSelect", "submitButton", "dynamicScoreInput", + "winType", "overtimeSelect", "winnerSelect", "submitButton", "dynamicScoreInput", "finalScoreField", "validationAlerts", "pinTimeTip" ] static values = { winnerScore: { type: String, default: "0" }, - loserScore: { type: String, default: "0" } + loserScore: { type: String, default: "0" }, + pinMinutes: { type: String, default: "0" }, + pinSeconds: { type: String, default: "00" }, + manualOverride: { type: Boolean, default: false }, + finished: { type: Boolean, default: false } } connect() { console.log("Match score controller connected") - // Use setTimeout to ensure the DOM is fully loaded + this.boundMarkManualOverride = this.markManualOverride.bind(this) + this.element.addEventListener("input", this.boundMarkManualOverride) + this.element.addEventListener("change", this.boundMarkManualOverride) + if (this.finishedValue) { + this.validateForm() + return + } setTimeout(() => { this.updateScoreInput() this.validateForm() }, 50) } + disconnect() { + this.element.removeEventListener("input", this.boundMarkManualOverride) + this.element.removeEventListener("change", this.boundMarkManualOverride) + } + winTypeChanged() { + if (this.finishedValue) { + this.validateForm() + return + } this.updateScoreInput() this.validateForm() } @@ -30,6 +49,7 @@ export default class extends Controller { } updateScoreInput() { + if (this.finishedValue) return const winType = this.winTypeTarget.value this.dynamicScoreInputTarget.innerHTML = "" @@ -47,6 +67,9 @@ export default class extends Controller { this.dynamicScoreInputTarget.appendChild(minuteInput) this.dynamicScoreInputTarget.appendChild(secondInput) + + minuteInput.querySelector("input").value = this.pinMinutesValue || "0" + secondInput.querySelector("input").value = this.pinSecondsValue || "00" // Add event listeners to the new inputs const inputs = this.dynamicScoreInputTarget.querySelectorAll("input") @@ -111,6 +134,43 @@ export default class extends Controller { this.validateForm() } + applyDefaultResults(defaults = {}) { + if (this.manualOverrideValue || this.finishedValue) return + + if (Object.prototype.hasOwnProperty.call(defaults, "winnerId") && this.hasWinnerSelectTarget) { + this.winnerSelectTarget.value = defaults.winnerId ? String(defaults.winnerId) : "" + } + + if (Object.prototype.hasOwnProperty.call(defaults, "overtimeType") && this.hasOvertimeSelectTarget) { + const allowedValues = Array.from(this.overtimeSelectTarget.options).map((option) => option.value) + this.overtimeSelectTarget.value = allowedValues.includes(defaults.overtimeType) ? defaults.overtimeType : "" + } + + if (Object.prototype.hasOwnProperty.call(defaults, "winnerScore")) { + this.winnerScoreValue = String(defaults.winnerScore) + } + + if (Object.prototype.hasOwnProperty.call(defaults, "loserScore")) { + this.loserScoreValue = String(defaults.loserScore) + } + + if (Object.prototype.hasOwnProperty.call(defaults, "pinMinutes")) { + this.pinMinutesValue = String(defaults.pinMinutes) + } + + if (Object.prototype.hasOwnProperty.call(defaults, "pinSeconds")) { + this.pinSecondsValue = String(defaults.pinSeconds).padStart(2, "0") + } + + this.updateScoreInput() + this.validateForm() + } + + markManualOverride(event) { + if (!event.isTrusted) return + this.manualOverrideValue = true + } + updatePinTimeScore() { const minuteInput = this.dynamicScoreInputTarget.querySelector("#minutes") const secondInput = this.dynamicScoreInputTarget.querySelector("#seconds") @@ -234,4 +294,4 @@ export default class extends Controller { event.preventDefault(); } } -} \ No newline at end of file +} diff --git a/app/assets/javascripts/controllers/match_scoreboard_controller.js b/app/assets/javascripts/controllers/match_scoreboard_controller.js new file mode 100644 index 0000000..c1a0ed5 --- /dev/null +++ b/app/assets/javascripts/controllers/match_scoreboard_controller.js @@ -0,0 +1,364 @@ +import { Controller } from "@hotwired/stimulus" +import { getMatchStateConfig } from "match-state-config" +import { loadJson } from "match-state-transport" +import { + buildScoreboardContext, + connectionPlan, + applyMatchPayloadContext, + applyMatPayloadContext, + applyStatePayloadContext, + matchStorageKey, + selectedBoutNumber, + selectedBoutStorageKey as selectedBoutStorageKeyFromState, + storageChangePlan +} from "match-state-scoreboard-state" +import { + boardColors, + emptyBoardViewModel, + mainClockRunning as mainClockRunningFromPresenters, + nextTimerBannerState, + populatedBoardViewModel, + timerBannerRenderState +} from "match-state-scoreboard-presenters" + +export default class extends Controller { + static targets = [ + "redSection", + "centerSection", + "greenSection", + "emptyState", + "redName", + "redSchool", + "redScore", + "redTimerIndicator", + "greenName", + "greenSchool", + "greenScore", + "greenTimerIndicator", + "clock", + "periodLabel", + "weightLabel", + "boutLabel", + "timerBanner", + "timerBannerLabel", + "timerBannerClock", + "redStats", + "greenStats", + "lastMatchResult" + ] + + static values = { + sourceMode: { type: String, default: "localstorage" }, + displayMode: { type: String, default: "fullscreen" }, + matchId: Number, + matId: Number, + tournamentId: Number, + initialBoutNumber: Number + } + + connect() { + this.applyControllerContext(buildScoreboardContext({ + initialBoutNumber: this.initialBoutNumberValue, + matchId: this.matchIdValue + })) + + const plan = connectionPlan(this.sourceModeValue, this.currentMatchId) + if (plan.useStorageListener) { + this.storageListener = this.handleStorageChange.bind(this) + window.addEventListener("storage", this.storageListener) + } + if (plan.loadSelectedBout) { + this.loadSelectedBoutNumber() + } + if (plan.subscribeMat) { + this.setupMatSubscription() + } + if (plan.loadLocalState) { + this.loadStateFromLocalStorage() + } + if (plan.subscribeMatch) { + this.setupMatchSubscription(plan.matchId) + } + + this.startTicking() + this.render() + } + + disconnect() { + if (this.storageListener) { + window.removeEventListener("storage", this.storageListener) + this.storageListener = null + } + this.unsubscribeMatSubscription() + this.unsubscribeMatchSubscription() + if (this.tickInterval) { + window.clearInterval(this.tickInterval) + this.tickInterval = null + } + } + + setupMatSubscription() { + if (!window.App || !window.App.cable || !this.matIdValue) return + if (this.matSubscription) return + + this.matSubscription = App.cable.subscriptions.create( + { + channel: "MatScoreboardChannel", + mat_id: this.matIdValue + }, + { + received: (data) => this.handleMatPayload(data) + } + ) + } + + unsubscribeMatSubscription() { + if (this.matSubscription) { + this.matSubscription.unsubscribe() + this.matSubscription = null + } + } + + setupMatchSubscription(matchId) { + this.unsubscribeMatchSubscription() + if (!window.App || !window.App.cable || !matchId) return + + this.matchSubscription = App.cable.subscriptions.create( + { + channel: "MatchChannel", + match_id: matchId + }, + { + connected: () => { + this.matchSubscription.perform("request_sync") + }, + received: (data) => { + this.handleMatchPayload(data) + this.render() + } + } + ) + } + + unsubscribeMatchSubscription() { + if (this.matchSubscription) { + this.matchSubscription.unsubscribe() + this.matchSubscription = null + } + } + + handleMatPayload(data) { + const nextContext = applyMatPayloadContext(this.currentContext(), data) + this.applyControllerContext(nextContext) + + if (nextContext.loadSelectedBout) { + this.loadSelectedBoutNumber() + } + if (nextContext.loadLocalState) { + this.loadStateFromLocalStorage() + } + if (nextContext.resetTimerBanner) { + this.resetTimerBannerState() + } + if (nextContext.unsubscribeMatch) { + this.unsubscribeMatchSubscription() + } + if (nextContext.subscribeMatchId) { + this.setupMatchSubscription(nextContext.subscribeMatchId) + } + if (nextContext.renderNow) { + this.render() + } + } + + handleMatchPayload(data) { + this.applyControllerContext(applyMatchPayloadContext(this.currentContext(), data)) + } + + storageKey() { + return matchStorageKey(this.tournamentIdValue, this.currentBoutNumber) + } + + selectedBoutStorageKey() { + return selectedBoutStorageKeyFromState(this.tournamentIdValue, this.matIdValue) + } + + handleStorageChange(event) { + const plan = storageChangePlan(this.currentContext(), event.key, this.tournamentIdValue, this.matIdValue) + if (plan.loadSelectedBout) this.loadSelectedBoutNumber() + if (plan.loadLocalState) this.loadStateFromLocalStorage() + if (plan.renderNow) this.render() + } + + loadSelectedBoutNumber() { + const parsedSelection = loadJson(window.localStorage, this.selectedBoutStorageKey()) + this.currentBoutNumber = selectedBoutNumber(parsedSelection, this.currentQueueBoutNumber) + } + + loadStateFromLocalStorage() { + const storageKey = this.storageKey() + if (!storageKey) { + this.state = null + this.resetTimerBannerState() + return + } + + const parsed = loadJson(window.localStorage, storageKey) + this.applyStatePayload(parsed) + } + + applyStatePayload(payload) { + this.applyControllerContext(applyStatePayloadContext(this.currentContext(), payload)) + this.updateTimerBannerState() + } + + applyControllerContext(context) { + this.currentQueueBoutNumber = context.currentQueueBoutNumber + this.currentBoutNumber = context.currentBoutNumber + this.currentMatchId = context.currentMatchId + this.liveMatchData = context.liveMatchData + this.lastMatchResult = context.lastMatchResult + this.state = context.state + this.finished = context.finished + this.timerBannerState = context.timerBannerState || null + this.previousTimerSnapshot = context.previousTimerSnapshot || {} + } + + currentContext() { + return { + sourceMode: this.sourceModeValue, + currentQueueBoutNumber: this.currentQueueBoutNumber, + currentBoutNumber: this.currentBoutNumber, + currentMatchId: this.currentMatchId, + liveMatchData: this.liveMatchData, + lastMatchResult: this.lastMatchResult, + state: this.state, + finished: this.finished, + timerBannerState: this.timerBannerState, + previousTimerSnapshot: this.previousTimerSnapshot || {} + } + } + + startTicking() { + if (this.tickInterval) return + this.tickInterval = window.setInterval(() => this.render(), 250) + } + + render() { + if (!this.state || !this.state.metadata) { + this.renderEmptyState() + return + } + + this.config = getMatchStateConfig(this.state.metadata.ruleset, this.state.metadata.bracketPosition) + const viewModel = populatedBoardViewModel( + this.config, + this.state, + this.liveMatchData, + this.currentBoutNumber, + (seconds) => this.formatClock(seconds) + ) + + this.applyLiveBoardColors() + if (this.hasEmptyStateTarget) this.emptyStateTarget.style.display = "none" + this.applyBoardViewModel(viewModel) + this.renderTimerBanner() + this.renderLastMatchResult() + } + + renderEmptyState() { + const viewModel = emptyBoardViewModel(this.currentBoutNumber, this.lastMatchResult) + this.applyEmptyBoardColors() + if (this.hasEmptyStateTarget) this.emptyStateTarget.style.display = "block" + this.applyBoardViewModel(viewModel) + this.hideTimerBanner() + this.renderLastMatchResult() + } + + applyBoardViewModel(viewModel) { + if (this.hasRedNameTarget) this.redNameTarget.textContent = viewModel.redName + if (this.hasRedSchoolTarget) this.redSchoolTarget.textContent = viewModel.redSchool + if (this.hasRedScoreTarget) this.redScoreTarget.textContent = viewModel.redScore + if (this.hasRedTimerIndicatorTarget) this.redTimerIndicatorTarget.innerHTML = this.renderTimerIndicator(viewModel.redTimerIndicator) + if (this.hasGreenNameTarget) this.greenNameTarget.textContent = viewModel.greenName + if (this.hasGreenSchoolTarget) this.greenSchoolTarget.textContent = viewModel.greenSchool + if (this.hasGreenScoreTarget) this.greenScoreTarget.textContent = viewModel.greenScore + if (this.hasGreenTimerIndicatorTarget) this.greenTimerIndicatorTarget.innerHTML = this.renderTimerIndicator(viewModel.greenTimerIndicator) + if (this.hasClockTarget) this.clockTarget.textContent = viewModel.clockText + if (this.hasPeriodLabelTarget) this.periodLabelTarget.textContent = viewModel.phaseLabel + if (this.hasWeightLabelTarget) this.weightLabelTarget.textContent = viewModel.weightLabel + if (this.hasBoutLabelTarget) this.boutLabelTarget.textContent = viewModel.boutLabel + if (this.hasRedStatsTarget) this.redStatsTarget.textContent = viewModel.redStats + if (this.hasGreenStatsTarget) this.greenStatsTarget.textContent = viewModel.greenStats + } + + renderLastMatchResult() { + if (this.hasLastMatchResultTarget) this.lastMatchResultTarget.textContent = this.lastMatchResult || "-" + } + + renderTimerIndicator(label) { + if (!label) return "" + return `${label}` + } + + applyLiveBoardColors() { + this.applyBoardColors(boardColors(false)) + } + + applyEmptyBoardColors() { + this.applyBoardColors(boardColors(true)) + } + + applyBoardColors(colors) { + if (this.hasRedSectionTarget) this.redSectionTarget.style.background = colors.red + if (this.hasCenterSectionTarget) this.centerSectionTarget.style.background = colors.center + if (this.hasGreenSectionTarget) this.greenSectionTarget.style.background = colors.green + } + + updateTimerBannerState() { + const nextState = nextTimerBannerState(this.state, this.previousTimerSnapshot) + this.timerBannerState = nextState.timerBannerState + this.previousTimerSnapshot = nextState.previousTimerSnapshot + } + + renderTimerBanner() { + if (!this.hasTimerBannerTarget) return + const renderState = timerBannerRenderState( + this.config, + this.state, + this.timerBannerState, + (seconds) => this.formatClock(seconds) + ) + this.timerBannerState = renderState.timerBannerState + + if (!renderState.visible) { + this.hideTimerBanner() + return + } + + const viewModel = renderState.viewModel + this.timerBannerTarget.style.display = "block" + this.timerBannerTarget.style.borderColor = viewModel.color === "green" ? "#1cab2d" : "#c91f1f" + if (this.hasTimerBannerLabelTarget) this.timerBannerLabelTarget.textContent = viewModel.label + if (this.hasTimerBannerClockTarget) this.timerBannerClockTarget.textContent = viewModel.clockText + } + + hideTimerBanner() { + if (this.hasTimerBannerTarget) this.timerBannerTarget.style.display = "none" + } + + resetTimerBannerState() { + this.timerBannerState = null + this.previousTimerSnapshot = {} + } + + mainClockRunning() { + return mainClockRunningFromPresenters(this.config, this.state) + } + + formatClock(totalSeconds) { + const minutes = Math.floor(totalSeconds / 60) + const seconds = totalSeconds % 60 + return `${minutes}:${seconds.toString().padStart(2, "0")}` + } +} diff --git a/app/assets/javascripts/controllers/match_spectate_controller.js b/app/assets/javascripts/controllers/match_spectate_controller.js index 229d4df..82ee905 100644 --- a/app/assets/javascripts/controllers/match_spectate_controller.js +++ b/app/assets/javascripts/controllers/match_spectate_controller.js @@ -3,7 +3,7 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = [ "w1Stats", "w2Stats", "winner", "winType", - "score", "finished", "statusIndicator" + "score", "finished", "statusIndicator", "scoreboardContainer" ] static values = { @@ -134,6 +134,9 @@ export default class extends Controller { if (data.finished !== undefined && this.hasFinishedTarget) { this.finishedTarget.textContent = data.finished ? 'Yes' : 'No' + if (this.hasScoreboardContainerTarget) { + this.scoreboardContainerTarget.style.display = data.finished ? 'none' : 'block' + } } } } diff --git a/app/assets/javascripts/controllers/match_state_controller.js b/app/assets/javascripts/controllers/match_state_controller.js new file mode 100644 index 0000000..2823df6 --- /dev/null +++ b/app/assets/javascripts/controllers/match_state_controller.js @@ -0,0 +1,803 @@ +import { Controller } from "@hotwired/stimulus" +import { getMatchStateConfig } from "match-state-config" +import { + accumulatedMatchSeconds as accumulatedMatchSecondsFromEngine, + activeClockForPhase, + adjustClockState, + applyChoiceAction, + applyMatchAction, + baseControlForPhase, + buildEvent as buildEventFromEngine, + buildClockState, + buildInitialState, + buildTimerState, + controlForSelectedPhase, + controlFromChoice, + currentAuxiliaryTimerSeconds as currentAuxiliaryTimerSecondsFromEngine, + currentClockSeconds as currentClockSecondsFromEngine, + deleteEventFromState, + derivedStats as derivedStatsFromEngine, + hasRunningClockOrTimer as hasRunningClockOrTimerFromEngine, + matchResultDefaults as matchResultDefaultsFromEngine, + moveToNextPhase, + moveToPreviousPhase, + orderedEvents as orderedEventsFromEngine, + opponentParticipant as opponentParticipantFromEngine, + phaseIndexForKey as phaseIndexForKeyFromEngine, + recomputeDerivedState as recomputeDerivedStateFromEngine, + scoreboardStatePayload as scoreboardStatePayloadFromEngine, + startAuxiliaryTimerState, + startClockState, + stopAuxiliaryTimerState, + stopClockState, + stopAllAuxiliaryTimers as stopAllAuxiliaryTimersFromEngine, + swapEventParticipants, + swapPhaseParticipants, + syncClockSnapshot +} from "match-state-engine" +import { + buildMatchMetadata, + buildPersistedState, + buildStorageKey, + restorePersistedState +} from "match-state-serializers" +import { + loadJson, + performIfChanged, + removeKey, + saveJson +} from "match-state-transport" +import { + choiceViewModel, + eventLogSections +} from "match-state-presenters" + +export default class extends Controller { + static targets = [ + "greenLabel", + "redLabel", + "greenPanel", + "redPanel", + "greenName", + "redName", + "greenSchool", + "redSchool", + "greenScore", + "redScore", + "periodLabel", + "clock", + "clockStatus", + "accumulationClock", + "matchPosition", + "formatName", + "choiceActions", + "eventLog", + "greenControls", + "redControls", + "matchResultsPanel", + "w1StatField", + "w2StatField" + ] + + static values = { + matchId: Number, + tournamentId: Number, + boutNumber: Number, + weightLabel: String, + bracketPosition: String, + ruleset: String, + w1Id: Number, + w2Id: Number, + w1Name: String, + w2Name: String, + w1School: String, + w2School: String + } + + connect() { + this.config = getMatchStateConfig(this.rulesetValue, this.bracketPositionValue) + this.boundHandleClick = this.handleDelegatedClick.bind(this) + this.element.addEventListener("click", this.boundHandleClick) + this.initializeState() + this.loadPersistedState() + this.syncClockFromActivePhase() + if (this.hasRunningClockOrTimer()) { + this.startTicking() + } + this.render({ rebuildControls: true }) + this.setupSubscription() + } + + disconnect() { + this.element.removeEventListener("click", this.boundHandleClick) + window.clearTimeout(this.matchResultsDefaultsTimeout) + this.cleanupSubscription() + this.saveState() + this.stopTicking() + this.stopAllAuxiliaryTimers() + } + + initializeState() { + this.state = this.buildInitialState() + } + + buildInitialState() { + return buildInitialState(this.config) + } + + render(options = {}) { + const rebuildControls = options.rebuildControls === true + if (this.hasGreenLabelTarget) this.greenLabelTarget.textContent = this.displayLabelForParticipant("w1") + if (this.hasRedLabelTarget) this.redLabelTarget.textContent = this.displayLabelForParticipant("w2") + if (this.hasGreenPanelTarget) this.applyPanelColor(this.greenPanelTarget, this.colorForParticipant("w1")) + if (this.hasRedPanelTarget) this.applyPanelColor(this.redPanelTarget, this.colorForParticipant("w2")) + if (this.hasGreenNameTarget) this.greenNameTarget.textContent = this.w1NameValue + if (this.hasRedNameTarget) this.redNameTarget.textContent = this.w2NameValue + if (this.hasGreenSchoolTarget) this.greenSchoolTarget.textContent = this.w1SchoolValue + if (this.hasRedSchoolTarget) this.redSchoolTarget.textContent = this.w2SchoolValue + if (this.hasGreenScoreTarget) this.greenScoreTarget.textContent = this.state.participantScores.w1.toString() + if (this.hasRedScoreTarget) this.redScoreTarget.textContent = this.state.participantScores.w2.toString() + if (this.hasPeriodLabelTarget) this.periodLabelTarget.textContent = this.currentPhase().label + this.updateLiveDisplays() + if (this.hasMatchPositionTarget) this.matchPositionTarget.textContent = this.humanizePosition(this.state.displayControl) + if (this.hasFormatNameTarget) this.formatNameTarget.textContent = this.config.matchFormat.label + if (rebuildControls) { + if (this.hasGreenControlsTarget) this.greenControlsTarget.innerHTML = this.renderWrestlerControls("w1") + if (this.hasRedControlsTarget) this.redControlsTarget.innerHTML = this.renderWrestlerControls("w2") + } + if (this.hasChoiceActionsTarget) this.choiceActionsTarget.innerHTML = this.renderChoiceActions() + if (this.hasEventLogTarget) this.eventLogTarget.innerHTML = this.renderEventLog() + this.updateTimerDisplays() + this.updateStatFieldsAndBroadcast() + this.scheduleApplyMatchResultDefaults() + this.saveState() + } + + renderWrestlerControls(participantKey) { + return Object.values(this.config.wrestler_actions).map((section) => { + const content = this.renderWrestlerSection(participantKey, section) + if (!content) return "" + + return ` +
+ ${section.title} +
${section.description}
+
${content}
+
+ ` + }).join('
') + } + + renderWrestlerSection(participantKey, section) { + if (!section) return "" + + if (section === this.config.wrestler_actions.timers) { + return this.renderTimerSection(participantKey, section) + } + + const actionKeys = this.actionKeysForSection(participantKey, section) + return this.renderActionButtons(participantKey, actionKeys) + } + + renderActionButtons(participantKey, actionKeys) { + return actionKeys.map((actionKey) => { + const action = this.config.actionsByKey[actionKey] + if (!action) return "" + + const buttonClass = this.colorForParticipant(participantKey) === "green" ? "btn-success" : "btn-danger" + return `` + }).join(" ") + } + + actionKeysForSection(participantKey, section) { + if (!section?.items) return [] + + return section.items.flatMap((itemKey) => { + if (itemKey === "global") { + return this.availableActionKeysForAvailability(participantKey, "global") + } + + if (itemKey === "position") { + const position = this.positionForParticipant(participantKey) + return this.availableActionKeysForAvailability(participantKey, position) + } + + return itemKey + }) + } + + availableActionKeysForAvailability(participantKey, availability) { + if (this.currentPhase().type !== "period") return [] + + return Object.entries(this.config.actionsByKey) + .filter(([, action]) => action.availability === availability) + .map(([actionKey]) => actionKey) + } + + renderTimerSection(participantKey, section) { + return (section.items || []).map((timerKey) => { + const timerConfig = this.config.timers[timerKey] + if (!timerConfig) return "" + + return ` +
+ ${timerConfig.label}: ${this.formatClock(this.currentAuxiliaryTimerSeconds(participantKey, timerKey))} +
+ + + +
+
Max ${this.formatClock(timerConfig.maxSeconds)}
+
+ ` + }).join("") + } + + handleDelegatedClick(event) { + const button = event.target.closest("button") + if (!button) return + + // Buttons with direct Stimulus actions are handled separately. + if (button.dataset.action && button.dataset.action.includes("match-state#")) return + + const buttonType = button.dataset.matchStateButton + if (buttonType === "score-action") { + this.applyAction(button) + } else if (buttonType === "choice-action") { + this.applyChoice(button) + } else if (buttonType === "timer-action") { + this.handleTimerCommand(button) + } else if (buttonType === "swap-phase") { + this.swapPhase(button) + } else if (buttonType === "swap-event") { + this.swapEvent(button) + } else if (buttonType === "delete-event") { + this.deleteEvent(button) + } + } + + applyAction(button) { + const participantKey = button.dataset.participantKey + const actionKey = button.dataset.actionKey + if (!applyMatchAction(this.config, this.state, this.currentPhase(), this.currentClockSeconds(), participantKey, actionKey)) return + + this.recomputeDerivedState() + this.render({ rebuildControls: true }) + } + + applyChoice(button) { + const phase = this.currentPhase() + if (phase.type !== "choice") return + + const participantKey = button.dataset.participantKey + const choiceKey = button.dataset.choiceKey + + const result = applyChoiceAction(this.state, phase, this.currentClockSeconds(), participantKey, choiceKey) + if (!result.applied) return + + if (result.deferred) { + this.recomputeDerivedState() + this.render({ rebuildControls: true }) + return + } + + this.advancePhase() + } + + swapColors() { + this.state.assignment.w1 = this.state.assignment.w1 === "green" ? "red" : "green" + this.state.assignment.w2 = this.state.assignment.w2 === "green" ? "red" : "green" + this.render({ rebuildControls: true }) + } + + buildEvent(participantKey, actionKey, options = {}) { + return buildEventFromEngine(this.state, this.currentPhase(), this.currentClockSeconds(), participantKey, actionKey, options) + } + + startClock() { + if (this.currentPhase().type !== "period") return + const activeClock = this.activeClock() + if (!startClockState(activeClock)) return + this.syncClockFromActivePhase() + this.startTicking() + this.render() + } + + stopClock() { + const activeClock = this.activeClock() + if (!stopClockState(activeClock)) return + this.syncClockFromActivePhase() + this.stopTicking() + this.render() + } + + resetClock() { + this.stopClock() + const activeClock = this.activeClock() + if (!activeClock) return + activeClock.remainingSeconds = activeClock.durationSeconds + this.syncClockFromActivePhase() + this.render() + } + + addMinute() { + this.adjustClock(60) + } + + subtractMinute() { + this.adjustClock(-60) + } + + addSecond() { + this.adjustClock(1) + } + + subtractSecond() { + this.adjustClock(-1) + } + + previousPhase() { + this.stopClock() + if (!moveToPreviousPhase(this.config, this.state)) return + this.applyPhaseDefaults() + this.recomputeDerivedState() + this.render({ rebuildControls: true }) + } + + nextPhase() { + this.advancePhase() + } + + resetMatch() { + const confirmed = window.confirm("Are you sure you want to reset the match? This will wipe the score, reset all timers, and wipe all stats") + if (!confirmed) return + + this.stopTicking() + this.initializeState() + this.syncClockFromActivePhase() + this.clearPersistedState() + this.render({ rebuildControls: true }) + } + + advancePhase() { + this.stopClock() + if (!moveToNextPhase(this.config, this.state)) return + this.applyPhaseDefaults() + this.recomputeDerivedState() + this.render({ rebuildControls: true }) + } + + deleteEvent(button) { + const eventId = Number(button.dataset.eventId) + if (!deleteEventFromState(this.config, this.state, eventId)) return + this.recomputeDerivedState() + this.render({ rebuildControls: true }) + } + + swapEvent(button) { + const eventId = Number(button.dataset.eventId) + if (!swapEventParticipants(this.config, this.state, eventId)) return + this.recomputeDerivedState() + this.render({ rebuildControls: true }) + } + + swapPhase(button) { + const phaseKey = button.dataset.phaseKey + if (!swapPhaseParticipants(this.config, this.state, phaseKey)) return + this.recomputeDerivedState() + this.render({ rebuildControls: true }) + } + + handleTimerCommand(button) { + const participantKey = button.dataset.participantKey + const timerKey = button.dataset.timerKey + const command = button.dataset.timerCommand + + if (command === "start") this.startAuxiliaryTimer(participantKey, timerKey) + if (command === "stop") this.stopAuxiliaryTimer(participantKey, timerKey) + if (command === "reset") this.resetAuxiliaryTimer(participantKey, timerKey) + } + + startAuxiliaryTimer(participantKey, timerKey) { + const timer = this.state.timers[participantKey][timerKey] + if (!startAuxiliaryTimerState(timer)) return + this.startTicking() + this.render() + } + + stopAuxiliaryTimer(participantKey, timerKey) { + const timer = this.state.timers[participantKey][timerKey] + const { stopped, elapsedSeconds } = stopAuxiliaryTimerState(timer) + if (!stopped) return + + if (elapsedSeconds > 0) { + this.state.events.push({ + ...this.buildEvent(participantKey, `timer_used_${timerKey}`), + elapsedSeconds: elapsedSeconds + }) + } + + this.render() + } + + resetAuxiliaryTimer(participantKey, timerKey) { + this.stopAuxiliaryTimer(participantKey, timerKey) + const timer = this.state.timers[participantKey][timerKey] + timer.remainingSeconds = this.config.timers[timerKey].maxSeconds + this.render() + } + + buildTimerState() { + return buildTimerState(this.config) + } + + buildClockState() { + return buildClockState(this.config) + } + + currentClockSeconds() { + return currentClockSecondsFromEngine(this.activeClock()) + } + + currentAuxiliaryTimerSeconds(participantKey, timerKey) { + return currentAuxiliaryTimerSecondsFromEngine(this.state.timers[participantKey][timerKey]) + } + + hasRunningClockOrTimer() { + return hasRunningClockOrTimerFromEngine(this.state) + } + + startTicking() { + if (this.tickInterval) return + this.tickInterval = window.setInterval(() => { + if (this.activeClock()?.running && this.currentClockSeconds() === 0) { + this.stopClock() + return + } + + for (const participantKey of ["w1", "w2"]) { + for (const timerKey of Object.keys(this.state.timers[participantKey])) { + if (this.state.timers[participantKey][timerKey].running && this.currentAuxiliaryTimerSeconds(participantKey, timerKey) === 0) { + this.stopAuxiliaryTimer(participantKey, timerKey) + } + } + } + + this.updateLiveDisplays() + this.updateTimerDisplays() + }, 250) + } + + stopTicking() { + if (!this.tickInterval) return + window.clearInterval(this.tickInterval) + this.tickInterval = null + } + + stopAllAuxiliaryTimers() { + stopAllAuxiliaryTimersFromEngine(this.state) + } + + positionForParticipant(participantKey) { + if (this.state.displayControl === "neutral") return "neutral" + if (this.state.displayControl === `${participantKey}_control`) return "top" + return "bottom" + } + + opponentParticipant(participantKey) { + return opponentParticipantFromEngine(participantKey) + } + + humanizePosition(position) { + if (position === "neutral") return "Neutral" + if (position === "green_control") return "Green In Control" + if (position === "red_control") return "Red In Control" + return position + } + + recomputeDerivedState() { + recomputeDerivedStateFromEngine(this.config, this.state) + } + + renderEventLog() { + if (this.state.events.length === 0) { + return '

No events yet.

' + } + + return eventLogSections(this.config, this.state, (seconds) => this.formatClock(seconds)).map((section) => { + const items = section.items.map((eventRecord) => { + return ` +
+
+
+ ${eventRecord.colorLabel} ${eventRecord.actionLabel} + (${eventRecord.clockLabel}) +
+
+ + +
+
+
+ ` + }).join("") + + return ` +
+
+
${section.label}
+ +
+ ${items} +
+ ` + }).join("") + } + + updateLiveDisplays() { + if (this.hasClockTarget) { + this.clockTarget.textContent = this.currentPhase().type === "period" ? this.formatClock(this.currentClockSeconds()) : "-" + } + if (this.hasClockStatusTarget) { + this.clockStatusTarget.textContent = this.currentPhase().type === "period" + ? (this.activeClock()?.running ? "Running" : "Stopped") + : "Choice" + } + if (this.hasAccumulationClockTarget) { + this.accumulationClockTarget.textContent = this.formatClock(this.accumulatedMatchSeconds()) + } + } + + updateTimerDisplays() { + for (const participantKey of ["w1", "w2"]) { + for (const [timerKey, timerConfig] of Object.entries(this.config.timers)) { + const display = this.element.querySelector(`[data-match-state-timer-display="${participantKey}-${timerKey}"]`) + const status = this.element.querySelector(`[data-match-state-timer-status="${participantKey}-${timerKey}"]`) + if (display) { + display.textContent = this.formatClock(this.currentAuxiliaryTimerSeconds(participantKey, timerKey)) + } + if (status) { + const running = this.state.timers[participantKey][timerKey].running + status.textContent = `Max ${this.formatClock(timerConfig.maxSeconds)}${running ? " • running" : ""}` + } + } + } + } + + renderChoiceActions() { + const phase = this.currentPhase() + const viewModel = choiceViewModel(this.config, this.state, phase, { + w1: { name: this.w1NameValue }, + w2: { name: this.w2NameValue } + }) + if (!viewModel) return "" + + return ` +
+
${viewModel.label}
+
${viewModel.selectionText}
+
${viewModel.buttons.map((button) => ` + + `).join(" ")}
+
+ ` + } + + currentPhase() { + return this.config.phaseSequence[this.state.phaseIndex] + } + + applyPhaseDefaults() { + this.syncClockFromActivePhase() + this.state.control = this.baseControlForCurrentPhase() + } + + baseControlForCurrentPhase() { + return baseControlForPhase(this.currentPhase(), this.state.selections, this.state.control) + } + + controlFromChoice(selection) { + return controlFromChoice(selection) + } + + colorForParticipant(participantKey) { + return this.state.assignment[participantKey] + } + + displayLabelForParticipant(participantKey) { + return this.colorForParticipant(participantKey) === "green" ? "Green" : "Red" + } + + applyPanelColor(panelElement, color) { + panelElement.classList.remove("panel-success", "panel-danger") + panelElement.classList.add(color === "green" ? "panel-success" : "panel-danger") + } + + controlForSelectedPhase() { + return controlForSelectedPhase(this.config, this.state) + } + + baseControlForPhase(phase) { + return baseControlForPhase(phase, this.state.selections, this.state.control) + } + + orderedEvents() { + return orderedEventsFromEngine(this.config, this.state.events) + } + + phaseIndexForKey(phaseKey) { + return phaseIndexForKeyFromEngine(this.config, phaseKey) + } + + activeClock() { + return activeClockForPhase(this.state, this.currentPhase()) + } + + setupSubscription() { + this.cleanupSubscription() + if (!this.matchIdValue || !window.App || !window.App.cable) return + + this.matchSubscription = App.cable.subscriptions.create( + { + channel: "MatchChannel", + match_id: this.matchIdValue + }, + { + connected: () => { + this.isConnected = true + this.pushDerivedStatsToChannel() + this.pushScoreboardStateToChannel() + }, + disconnected: () => { + this.isConnected = false + } + } + ) + } + + cleanupSubscription() { + if (!this.matchSubscription) return + try { + this.matchSubscription.unsubscribe() + } catch (_error) { + } + this.matchSubscription = null + this.isConnected = false + } + + updateStatFieldsAndBroadcast() { + const derivedStats = this.derivedStats() + + if (this.hasW1StatFieldTarget) this.w1StatFieldTarget.value = derivedStats.w1 + if (this.hasW2StatFieldTarget) this.w2StatFieldTarget.value = derivedStats.w2 + + this.lastDerivedStats = derivedStats + this.pushDerivedStatsToChannel() + this.pushScoreboardStateToChannel() + } + + pushDerivedStatsToChannel() { + if (!this.matchSubscription || !this.lastDerivedStats) return + this.lastBroadcastStats = performIfChanged(this.matchSubscription, "send_stat", { + new_w1_stat: this.lastDerivedStats.w1, + new_w2_stat: this.lastDerivedStats.w2 + }, this.lastBroadcastStats) + } + + pushScoreboardStateToChannel() { + if (!this.matchSubscription) return + + this.lastBroadcastScoreboardState = performIfChanged(this.matchSubscription, "send_scoreboard", { + scoreboard_state: this.scoreboardStatePayload() + }, this.lastBroadcastScoreboardState) + } + + applyMatchResultDefaults() { + const controllerElement = this.matchResultsPanelTarget?.querySelector('[data-controller~="match-score"]') + if (!controllerElement) return + + const scoreController = this.application.getControllerForElementAndIdentifier(controllerElement, "match-score") + if (!scoreController || typeof scoreController.applyDefaultResults !== "function") return + + scoreController.applyDefaultResults( + matchResultDefaultsFromEngine(this.state, { + w1Id: this.w1IdValue, + w2Id: this.w2IdValue, + currentPhase: this.currentPhase(), + accumulationSeconds: this.accumulatedMatchSeconds() + }) + ) + } + + scheduleApplyMatchResultDefaults() { + if (!this.hasMatchResultsPanelTarget) return + + window.clearTimeout(this.matchResultsDefaultsTimeout) + this.matchResultsDefaultsTimeout = window.setTimeout(() => { + this.applyMatchResultDefaults() + }, 0) + } + + storageKey() { + return buildStorageKey(this.tournamentIdValue, this.boutNumberValue) + } + + loadPersistedState() { + const parsedState = loadJson(window.localStorage, this.storageKey()) + if (!parsedState) { + if (window.localStorage.getItem(this.storageKey())) { + this.clearPersistedState() + this.state = this.buildInitialState() + } + return + } + + try { + this.state = restorePersistedState(this.config, parsedState) + } catch (_error) { + this.clearPersistedState() + this.state = this.buildInitialState() + } + } + + saveState() { + const persistedState = buildPersistedState(this.state, this.matchMetadata()) + saveJson(window.localStorage, this.storageKey(), persistedState) + } + + clearPersistedState() { + removeKey(window.localStorage, this.storageKey()) + } + + accumulatedMatchSeconds() { + return accumulatedMatchSecondsFromEngine(this.config, this.state, this.currentPhase().key) + } + + syncClockFromActivePhase() { + this.state.clock = syncClockSnapshot(this.activeClock()) + } + + adjustClock(deltaSeconds) { + if (this.currentPhase().type !== "period") return + + const activeClock = this.activeClock() + if (!adjustClockState(activeClock, deltaSeconds)) return + this.syncClockFromActivePhase() + this.render() + } + + formatClock(totalSeconds) { + const minutes = Math.floor(totalSeconds / 60) + const seconds = totalSeconds % 60 + return `${minutes}:${seconds.toString().padStart(2, "0")}` + } + + derivedStats() { + return derivedStatsFromEngine(this.config, this.state.events) + } + + scoreboardStatePayload() { + return scoreboardStatePayloadFromEngine(this.config, this.state, this.matchMetadata()) + } + + matchMetadata() { + return buildMatchMetadata({ + tournamentId: this.tournamentIdValue, + boutNumber: this.boutNumberValue, + weightLabel: this.weightLabelValue, + ruleset: this.rulesetValue, + bracketPosition: this.bracketPositionValue, + w1Name: this.w1NameValue, + w2Name: this.w2NameValue, + w1School: this.w1SchoolValue, + w2School: this.w2SchoolValue + }) + } +} diff --git a/app/assets/javascripts/lib/match_state/config.js b/app/assets/javascripts/lib/match_state/config.js new file mode 100644 index 0000000..3b8dde3 --- /dev/null +++ b/app/assets/javascripts/lib/match_state/config.js @@ -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 + } +} diff --git a/app/assets/javascripts/lib/match_state/engine.js b/app/assets/javascripts/lib/match_state/engine.js new file mode 100644 index 0000000..65e730f --- /dev/null +++ b/app/assets/javascripts/lib/match_state/engine.js @@ -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_") + ) +} diff --git a/app/assets/javascripts/lib/match_state/presenters.js b/app/assets/javascripts/lib/match_state/presenters.js new file mode 100644 index 0000000..4277e63 --- /dev/null +++ b/app/assets/javascripts/lib/match_state/presenters.js @@ -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 +} diff --git a/app/assets/javascripts/lib/match_state/scoreboard_presenters.js b/app/assets/javascripts/lib/match_state/scoreboard_presenters.js new file mode 100644 index 0000000..84f067f --- /dev/null +++ b/app/assets/javascripts/lib/match_state/scoreboard_presenters.js @@ -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 + } +} diff --git a/app/assets/javascripts/lib/match_state/scoreboard_state.js b/app/assets/javascripts/lib/match_state/scoreboard_state.js new file mode 100644 index 0000000..3e9f39d --- /dev/null +++ b/app/assets/javascripts/lib/match_state/scoreboard_state.js @@ -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 +} diff --git a/app/assets/javascripts/lib/match_state/serializers.js b/app/assets/javascripts/lib/match_state/serializers.js new file mode 100644 index 0000000..5dbace0 --- /dev/null +++ b/app/assets/javascripts/lib/match_state/serializers.js @@ -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] || {}) + } + ]) + ) + } +} diff --git a/app/assets/javascripts/lib/match_state/transport.js b/app/assets/javascripts/lib/match_state/transport.js new file mode 100644 index 0000000..ae45b22 --- /dev/null +++ b/app/assets/javascripts/lib/match_state/transport.js @@ -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 +} diff --git a/app/channels/mat_scoreboard_channel.rb b/app/channels/mat_scoreboard_channel.rb new file mode 100644 index 0000000..e6f5cb4 --- /dev/null +++ b/app/channels/mat_scoreboard_channel.rb @@ -0,0 +1,15 @@ +class MatScoreboardChannel < ApplicationCable::Channel + def subscribed + @mat = Mat.find_by(id: params[:mat_id]) + return reject unless @mat + + stream_for @mat + transmit(scoreboard_payload(@mat)) + end + + private + + def scoreboard_payload(mat) + mat.scoreboard_payload + end +end diff --git a/app/channels/match_channel.rb b/app/channels/match_channel.rb index db5c596..dc69ad1 100644 --- a/app/channels/match_channel.rb +++ b/app/channels/match_channel.rb @@ -1,4 +1,6 @@ class MatchChannel < ApplicationCable::Channel + SCOREBOARD_CACHE_TTL = 1.hours + def subscribed @match = Match.find_by(id: params[:match_id]) Rails.logger.info "[MatchChannel] Client subscribed with match_id: #{params[:match_id]}. Match found: #{@match.present?}" @@ -11,6 +13,19 @@ class MatchChannel < ApplicationCable::Channel end end + def send_scoreboard(data) + unless @match + Rails.logger.error "[MatchChannel] Error: send_scoreboard called but @match is nil. Client params on sub: #{params[:match_id]}" + return + end + + scoreboard_state = data["scoreboard_state"] + return if scoreboard_state.blank? + + Rails.cache.write(scoreboard_cache_key, scoreboard_state, expires_in: SCOREBOARD_CACHE_TTL) + MatchChannel.broadcast_to(@match, { scoreboard_state: scoreboard_state }) + end + def unsubscribed Rails.logger.info "[MatchChannel] Client unsubscribed for match #{@match&.id}" end @@ -75,7 +90,8 @@ class MatchChannel < ApplicationCable::Channel win_type: @match.win_type, winner_name: @match.winner&.name, winner_id: @match.winner_id, - finished: @match.finished + finished: @match.finished, + scoreboard_state: Rails.cache.read(scoreboard_cache_key) }.compact if payload.present? @@ -85,4 +101,10 @@ class MatchChannel < ApplicationCable::Channel Rails.logger.info "[MatchChannel] request_sync payload empty for match #{@match.id}, not transmitting." end end + + private + + def scoreboard_cache_key + "tournament:#{@match.tournament_id}:match:#{@match.id}:scoreboard_state" + end end diff --git a/app/controllers/matches_controller.rb b/app/controllers/matches_controller.rb index 46cbfa2..2cb8321 100644 --- a/app/controllers/matches_controller.rb +++ b/app/controllers/matches_controller.rb @@ -1,6 +1,7 @@ class MatchesController < ApplicationController - before_action :set_match, only: [:show, :edit, :update, :stat, :spectate, :edit_assignment, :update_assignment] - before_action :check_access, only: [:edit, :update, :stat, :edit_assignment, :update_assignment] + before_action :set_match, only: [:show, :edit, :update, :stat, :state, :spectate, :edit_assignment, :update_assignment] + before_action :check_access, only: [:edit, :update, :stat, :state, :edit_assignment, :update_assignment] + before_action :check_read_access, only: [:spectate] # GET /matches/1 # GET /matches/1.json @@ -22,49 +23,12 @@ class MatchesController < ApplicationController end def stat - # @show_next_bout_button = false - if params[:match] - @match = Match.where(:id => params[:match]).includes(:wrestlers).first - end - @wrestlers = [] - if @match - if @match.w1 - @wrestler1_name = @match.wrestler1.name - @wrestler1_school_name = @match.wrestler1.school.name - @wrestler1_last_match = @match.wrestler1.last_match - @wrestlers.push(@match.wrestler1) - else - @wrestler1_name = "Not assigned" - @wrestler1_school_name = "N/A" - @wrestler1_last_match = nil - end - if @match.w2 - @wrestler2_name = @match.wrestler2.name - @wrestler2_school_name = @match.wrestler2.school.name - @wrestler2_last_match = @match.wrestler2.last_match - @wrestlers.push(@match.wrestler2) - else - @wrestler2_name = "Not assigned" - @wrestler2_school_name = "N/A" - @wrestler2_last_match = nil - end - @tournament = @match.tournament - end - if @match&.mat - @mat = @match.mat - queue_position = @mat.queue_position_for_match(@match) - @next_match = queue_position == 1 ? @mat.queue2_match : nil - @show_next_bout_button = queue_position == 1 - if request.referer&.include?("/tournaments/#{@tournament.id}/matches") - session[:return_path] = "/tournaments/#{@tournament.id}/matches" - else - session[:return_path] = mat_path(@mat) - end - session[:error_return_path] = "/matches/#{@match.id}/stat" - else - session[:return_path] = "/tournaments/#{@tournament.id}/matches" - session[:error_return_path] = "/matches/#{@match.id}/stat" - end + load_match_stat_context + end + + def state + load_match_stat_context + @match_state_ruleset = "folkstyle_usa" end # GET /matches/:id/spectate @@ -142,26 +106,19 @@ class MatchesController < ApplicationController win_type: @match.win_type, winner_id: @match.winner_id, winner_name: @match.winner&.name, - finished: @match.finished + finished: @match.finished, + scoreboard_state: Rails.cache.read("tournament:#{@match.tournament_id}:match:#{@match.id}:scoreboard_state") } ) - if session[:return_path] - sanitized_return_path = sanitize_return_path(session[:return_path]) - format.html { redirect_to sanitized_return_path, notice: 'Match was successfully updated.' } - session.delete(:return_path) # Remove the session variable - else - format.html { redirect_to "/tournaments/#{@match.tournament.id}", notice: 'Match was successfully updated.' } - end + redirect_path = resolve_match_redirect_path(session[:return_path]) || "/tournaments/#{@match.tournament.id}" + format.html { redirect_to redirect_path, notice: 'Match was successfully updated.' } + session.delete(:return_path) format.json { head :no_content } else - if session[:error_return_path] - format.html { redirect_to session.delete(:error_return_path), alert: "Match did not save because: #{@match.errors.full_messages.to_s}" } - format.json { render json: @match.errors, status: :unprocessable_entity } - else - format.html { redirect_to "/tournaments/#{@match.tournament.id}", alert: "Match did not save because: #{@match.errors.full_messages.to_s}" } - format.json { render json: @match.errors, status: :unprocessable_entity } - end + error_path = resolve_match_redirect_path(session[:error_return_path]) || "/tournaments/#{@match.tournament.id}" + format.html { redirect_to error_path, alert: "Match did not save because: #{@match.errors.full_messages.to_s}" } + format.json { render json: @match.errors, status: :unprocessable_entity } end end end @@ -182,11 +139,66 @@ class MatchesController < ApplicationController authorize! :manage, @match.tournament end - def sanitize_return_path(path) + def check_read_access + authorize! :read, @match.tournament + end + + def sanitize_redirect_path(path) + return nil if path.blank? + uri = URI.parse(path) - params = Rack::Utils.parse_nested_query(uri.query) - params.delete("bout_number") # Remove the bout_number param - uri.query = params.to_query.presence # Rebuild the query string or set it to nil if empty - uri.to_s # Return the full path as a string - end + return nil if uri.scheme.present? || uri.host.present? + + uri.to_s + rescue URI::InvalidURIError + nil + end + + def resolve_match_redirect_path(fallback_path) + sanitize_redirect_path(params[:redirect_to].presence) || sanitize_redirect_path(fallback_path) + end + + def load_match_stat_context + if params[:match] + @match = Match.where(:id => params[:match]).includes(:wrestlers).first + end + + @wrestlers = [] + if @match + if @match.w1 + @wrestler1_name = @match.wrestler1.name + @wrestler1_school_name = @match.wrestler1.school.name + @wrestler1_last_match = @match.wrestler1.last_match + @wrestlers.push(@match.wrestler1) + else + @wrestler1_name = "Not assigned" + @wrestler1_school_name = "N/A" + @wrestler1_last_match = nil + end + + if @match.w2 + @wrestler2_name = @match.wrestler2.name + @wrestler2_school_name = @match.wrestler2.school.name + @wrestler2_last_match = @match.wrestler2.last_match + @wrestlers.push(@match.wrestler2) + else + @wrestler2_name = "Not assigned" + @wrestler2_school_name = "N/A" + @wrestler2_last_match = nil + end + + @tournament = @match.tournament + end + + if @match&.mat + @mat = @match.mat + queue_position = @mat.queue_position_for_match(@match) + @next_match = queue_position == 1 ? @mat.queue2_match : nil + @show_next_bout_button = queue_position == 1 + end + + @match_results_redirect_path = sanitize_redirect_path(params[:redirect_to].presence) || "/tournaments/#{@tournament.id}/matches" + session[:return_path] = @match_results_redirect_path + session[:error_return_path] = request.original_fullpath + end end diff --git a/app/controllers/mats_controller.rb b/app/controllers/mats_controller.rb index 15b0603..08cacb4 100644 --- a/app/controllers/mats_controller.rb +++ b/app/controllers/mats_controller.rb @@ -1,6 +1,6 @@ class MatsController < ApplicationController - before_action :set_mat, only: [:show, :edit, :update, :destroy, :assign_next_match] - before_action :check_access, only: [:new,:create,:update,:destroy,:edit,:show, :assign_next_match] + before_action :set_mat, only: [:show, :state, :scoreboard, :edit, :update, :destroy, :assign_next_match, :select_match] + before_action :check_access, only: [:new,:create,:update,:destroy,:edit,:show, :state, :scoreboard, :assign_next_match, :select_match] # GET /mats/1 # GET /mats/1.json @@ -44,10 +44,33 @@ class MatsController < ApplicationController @tournament = @match.tournament end - session[:return_path] = request.original_fullpath + @match_results_redirect_path = sanitize_mat_redirect_path(params[:redirect_to].presence || request.original_fullpath) + session[:return_path] = @match_results_redirect_path session[:error_return_path] = request.original_fullpath end + def scoreboard + @match = @mat.selected_scoreboard_match || @mat.queue1_match + @tournament = @mat.tournament + end + + def state + load_mat_match_context + @match_state_ruleset = "folkstyle_usa" + end + + def select_match + selected_match = @mat.queue_matches.compact.find do |match| + match.id == params[:match_id].to_i || match.bout_number == params[:bout_number].to_i + end + + return head :unprocessable_entity unless selected_match || params[:last_match_result].present? + + @mat.set_selected_scoreboard_match!(selected_match) if selected_match + @mat.set_last_match_result!(params[:last_match_result]) if params.key?(:last_match_result) + head :no_content + end + # GET /mats/new def new @mat = Mat.new @@ -139,6 +162,66 @@ class MatsController < ApplicationController end authorize! :manage, @tournament end + + def sanitize_mat_redirect_path(path) + return nil if path.blank? + + uri = URI.parse(path) + return nil if uri.scheme.present? || uri.host.present? + + params = Rack::Utils.parse_nested_query(uri.query) + params.delete("bout_number") + uri.query = params.to_query.presence + uri.to_s + rescue URI::InvalidURIError + nil + end + + def load_mat_match_context + bout_number_param = params[:bout_number] + @queue_matches = @mat.queue_matches + @match = if bout_number_param + @queue_matches.compact.find { |match| match.bout_number == bout_number_param.to_i } + else + @queue_matches[0] + end + @match ||= @queue_matches[0] + @next_match = @queue_matches[1] + @show_next_bout_button = false + + @wrestlers = [] + if @match + if @match.w1 + @wrestler1_name = @match.wrestler1.name + @wrestler1_school_name = @match.wrestler1.school.name + @wrestler1_last_match = @match.wrestler1.last_match + @wrestlers.push(@match.wrestler1) + else + @wrestler1_name = "Not assigned" + @wrestler1_school_name = "N/A" + @wrestler1_last_match = nil + end + + if @match.w2 + @wrestler2_name = @match.wrestler2.name + @wrestler2_school_name = @match.wrestler2.school.name + @wrestler2_last_match = @match.wrestler2.last_match + @wrestlers.push(@match.wrestler2) + else + @wrestler2_name = "Not assigned" + @wrestler2_school_name = "N/A" + @wrestler2_last_match = nil + end + + @tournament = @match.tournament + else + @tournament = @mat.tournament + end + + @match_results_redirect_path = sanitize_mat_redirect_path(params[:redirect_to].presence || request.original_fullpath) + session[:return_path] = @match_results_redirect_path + session[:error_return_path] = request.original_fullpath + end end diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index 17bfa9f..942070e 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -1,10 +1,10 @@ class TournamentsController < ApplicationController - before_action :set_tournament, only: [:all_results, :delete_school_keys, :generate_school_keys,:reset_bout_board,:calculate_team_scores,:bout_sheets,:swap,:weigh_in_sheet,:error,:teampointadjust,:remove_teampointadjust,:remove_school_delegate,:remove_delegate,:school_delegate,:delegate,:matches,:weigh_in,:weigh_in_weight,:create_custom_weights,:show,:edit,:update,:destroy,:up_matches,:no_matches,:team_scores,:generate_matches,:bracket,:all_brackets,:qrcode] + before_action :set_tournament, only: [:all_results, :delete_school_keys, :generate_school_keys,:reset_bout_board,:calculate_team_scores,:bout_sheets,:swap,:weigh_in_sheet,:error,:teampointadjust,:remove_teampointadjust,:remove_school_delegate,:remove_delegate,:school_delegate,:delegate,:matches,:weigh_in,:weigh_in_weight,:create_custom_weights,:show,:edit,:update,:destroy,:up_matches,:no_matches,:team_scores,:generate_matches,:bracket,:all_brackets,:qrcode,:live_scores] before_action :check_access_manage, only: [:delete_school_keys, :generate_school_keys,:reset_bout_board,:calculate_team_scores,:swap,:weigh_in_sheet,:teampointadjust,:remove_teampointadjust,:remove_school_delegate,:school_delegate,:weigh_in,:weigh_in_weight,:create_custom_weights,:update,:edit,:generate_matches,:matches,:qrcode] before_action :check_access_destroy, only: [:destroy,:delegate,:remove_delegate] before_action :check_tournament_errors, only: [:generate_matches] before_action :check_for_matches, only: [:all_results,:bracket,:all_brackets] - before_action :check_access_read, only: [:all_results,:up_matches,:bracket,:all_brackets] + before_action :check_access_read, only: [:all_results,:up_matches,:bracket,:all_brackets,:live_scores] def weigh_in_sheet @schools = @tournament.schools.includes(wrestlers: :weight) @@ -242,6 +242,10 @@ class TournamentsController < ApplicationController @bracket_position = nil end + def live_scores + @mats = @tournament.mats.sort_by(&:name) + end + def generate_matches GenerateTournamentMatches.new(@tournament).generate end diff --git a/app/models/mat.rb b/app/models/mat.rb index 9843e6f..67ebc54 100644 --- a/app/models/mat.rb +++ b/app/models/mat.rb @@ -7,6 +7,8 @@ class Mat < ApplicationRecord validates :name, presence: true QUEUE_SLOTS = %w[queue1 queue2 queue3 queue4].freeze + SCOREBOARD_SELECTION_CACHE_TTL = 1.hours + LAST_MATCH_RESULT_CACHE_TTL = 1.hours after_save :clear_queue_matches_cache after_commit :broadcast_up_matches_board, on: :update, if: :up_matches_queue_changed? @@ -191,6 +193,56 @@ class Mat < ApplicationRecord matches.select{|m| m.finished != 1}.sort_by{|m| m.bout_number} end + def scoreboard_payload + selected_match = selected_scoreboard_match + { + mat_id: id, + queue1_bout_number: queue1_match&.bout_number, + queue1_match_id: queue1_match&.id, + selected_bout_number: selected_match&.bout_number, + selected_match_id: selected_match&.id, + last_match_result: last_match_result_text + } + end + + def set_selected_scoreboard_match!(match) + if match + Rails.cache.write( + scoreboard_selection_cache_key, + { match_id: match.id, bout_number: match.bout_number }, + expires_in: SCOREBOARD_SELECTION_CACHE_TTL + ) + else + Rails.cache.delete(scoreboard_selection_cache_key) + end + broadcast_current_match + end + + def selected_scoreboard_match + selection = Rails.cache.read(scoreboard_selection_cache_key) + return nil unless selection + + match_id = selection[:match_id] || selection["match_id"] + selected_match = queue_matches.compact.find { |match| match.id == match_id } + return selected_match if selected_match + + Rails.cache.delete(scoreboard_selection_cache_key) + nil + end + + def set_last_match_result!(text) + if text.present? + Rails.cache.write(last_match_result_cache_key, text, expires_in: LAST_MATCH_RESULT_CACHE_TTL) + else + Rails.cache.delete(last_match_result_cache_key) + end + broadcast_current_match + end + + def last_match_result_text + Rails.cache.read(last_match_result_cache_key) + end + private def clear_queue_matches_cache @@ -276,6 +328,15 @@ class Mat < ApplicationRecord show_next_bout_button: true } ) + MatScoreboardChannel.broadcast_to(self, scoreboard_payload) + end + + def scoreboard_selection_cache_key + "tournament:#{tournament_id}:mat:#{id}:scoreboard_selection" + end + + def last_match_result_cache_key + "tournament:#{tournament_id}:mat:#{id}:last_match_result" end def broadcast_up_matches_board diff --git a/app/views/layouts/_tournament-navbar.html.erb b/app/views/layouts/_tournament-navbar.html.erb index 57f3c9b..5ab835d 100644 --- a/app/views/layouts/_tournament-navbar.html.erb +++ b/app/views/layouts/_tournament-navbar.html.erb @@ -27,9 +27,10 @@ <% end %>
  • <%= link_to "All Brackets (Printable)", "/tournaments/#{@tournament.id}/all_brackets?print=true", target: :_blank %>
  • - -
  • <%= link_to " Bout Board" , "/tournaments/#{@tournament.id}/up_matches", class: "fas fa-list-alt" %>
  • - <% end %> + +
  • <%= link_to " Bout Board" , "/tournaments/#{@tournament.id}/up_matches", class: "fas fa-list-alt" %>
  • +
  • <%= link_to " Live Scores" , "/tournaments/#{@tournament.id}/live_scores", class: "fas fa-tv" %>
  • + <% end %> <% if can? :manage, @tournament %>