1
0
mirror of https://github.com/jcwimer/wrestlingApp synced 2026-04-24 23:01:19 +00:00

68 Commits

Author SHA1 Message Date
a031cfb446 Updating Gems. 2026-04-18 13:52:28 -04:00
c210b70c95 New stats page, scoreboard, and live scores pages. 2026-04-13 18:11:21 -04:00
7526148ba5 Better printing for brackets 2026-03-05 18:10:22 -05:00
e8e0fa291b Added clarifying documentation for BYE points as well as fixed logic. 2026-03-04 18:17:26 -05:00
679fc2fcb9 Ensuring good caching for the most popular pages. Added tests. 2026-03-02 18:34:12 -05:00
18d39c6c8f Using eager loading in GenerateTournamentMatches and AdvanceWrestler, generating/manipulating in-memory, and doing a single bulk insert or update at the end. 2026-02-24 20:58:36 -05:00
ca4d5ce0db Use websockets on stats page to determine which match to stat. 2026-02-23 17:56:40 -05:00
654cb84827 Use turbo streams for the bout board instead of auto refreshing every 30 seconds. 2026-02-20 19:20:33 -05:00
dc50efe8fc Removed the use of datatables and added pagination and search on all_matches. 2026-02-19 17:53:40 -05:00
8670ce38c3 Fixed a number of N+1 issues on low traffic pages. I also added relevant html tests for these pages. 2026-02-17 22:27:11 -05:00
d359be3ea1 Fixed deprecations 2026-02-13 18:02:04 -05:00
e97aa0d680 Fixed N+1 on up_matches and added html cache. 2026-02-13 18:02:04 -05:00
ae8d995b2c Added a QR code page that generates a QR code for tournament directors to print out. 2026-02-11 18:23:14 -05:00
d57aaac09d Hide ads on schools#show, wrestlers#new, wrestlers#edit, and mats#show 2026-02-11 07:55:49 -05:00
fcc8a9b9a9 Updated to ruby 4.0.1 2026-02-10 17:58:22 -05:00
b51866e9d8 Added tests for hiding ads on lineup submission. 2026-02-09 18:36:56 -05:00
07d43e7720 Hide ads for coaches when submitting lineups 2026-02-08 18:59:42 -05:00
d8b6cfa8ac Added bundle audit to pipeline. 2026-02-05 18:40:38 -05:00
5d674f894f Added round number and bracket position under the bout number 2026-02-04 18:16:19 -05:00
25df2a7280 Updated to rails 8.1.2. 2026-02-04 18:16:19 -05:00
2767274066 Added queues for mats and provided a way for tournament directors to move matches to a mat. 2026-02-03 17:50:52 -05:00
a2f8c7bced Stats page should auto push stats when it reconnects to the websocket. Spectate page should auto pull when it reconnects to the websocket. 2026-01-29 17:28:14 -05:00
9c2a9d62ad Fixed random seeding for double elimination. Since bracket positions are already evenly distributed on top half and bottom half of the bracket, I only need to pick odd or even bracket line numbers. 2026-01-23 17:35:16 -05:00
556090c16b Fixed double elimination generate loser names for a 6 man bracket when we're placing top 8 2026-01-23 17:35:16 -05:00
86f9c03991 Fixed double elim match generation errors and added tests 2026-01-23 17:35:16 -05:00
c8764c149b Added back tournament import text for the development environment 2026-01-23 17:35:16 -05:00
fe9a9c628c Fix arguements for the tournament backup and import jobs 2026-01-22 16:59:44 -05:00
7e4b6d8fc8 Fix round 1 bracket name when the first round of the bracket is not the first round of the tournament 2026-01-19 23:25:15 -05:00
940f7b1d00 Job concurrency per tournament is 1 so we don't have to scale too much on active queue. Pages no longer refresh automatically after navigating away from the bout board. Tournament backups are no longer deleted when restoring from a backup. Cloudflare is blocking manual backup imports so I have deleted that form on the backups page. 2026-01-16 18:21:17 -05:00
52df73d14f Fixed random double elimination seeding to avoid double byes in round 1 and evenly distribute the number of round 1 matches from the top and bottom half of the bracket 2026-01-14 19:00:35 -05:00
8b03a74b1e Fixed the save seeds button on weights#show to work on mobile. Fixed the trashcan and edit icons on tournaments#show schools#show and weights#show to work on mobile. Destroy all tournament backups on tournament cleanup. Added bracket position to bout board. 2026-01-13 17:02:59 -05:00
b4bca8f10a Fixed calculate team scores button, fixed import button, fixed deleting a mat causing match deletes 2026-01-10 23:39:23 -05:00
af1f8df4b6 Fix print views 2026-01-09 23:06:24 -05:00
3576445a1c Added a turbo stream for the current and next match on mat stats page. 2026-01-09 18:37:01 -05:00
8c2ddf55ed Increased solid queue arguments limit to support tournament backups 2026-01-09 00:49:32 -05:00
cfd3e7aecd Fixed create new backup link syntax for turbo_method and made the assign_next_match button a turbo_method 2026-01-08 23:59:33 -05:00
608999cb51 Fixed create new backup link as a turbo_method and hid the baumspage importer 2026-01-08 23:51:16 -05:00
6b5308360e Fixed a bug where logged in users could not access a school with a school permission key 2026-01-06 17:24:45 -05:00
9ca6572d9b Need to bring services down before bringing them back up on deploy test 2025-12-11 14:17:27 -05:00
61dc5e3cdd Added mission control for solid queue ui. 2025-11-21 15:43:05 +05:30
af2fc3feba Fixed a test after changing links to turbo 2025-11-11 21:55:36 -05:00
793a9e3ecc All links with a confirm now use turbo 2025-11-11 21:09:24 -05:00
f73e9bfc4e Fix the reset bout board link 2025-11-11 20:55:34 -05:00
92bd06fe3c No longer using memcached. Replication settings for standalong mariadb. Use --single-transaction in mariadb replica-watcher so mysqldump does not lock tables. Added horizontal pod autoscaler to the app statefulset 2025-10-30 08:50:31 -04:00
6e9554be55 Fixed the JSON 3 deprecation in the backup and import service 2025-10-08 13:54:38 -04:00
34f1783031 Upgraded to rails 8.0.2 2025-10-08 11:35:44 -04:00
bbd2bd9b44 Fixed ads.txt 2025-10-07 15:31:04 -04:00
6ecebba70d Updated gems 2025-10-07 15:30:47 -04:00
e64751e471 Fixed name of db to replicate 2025-10-03 08:50:34 -04:00
d0f19e855f Added a mariadb replica watcher to fix replication issues 2025-09-30 16:31:43 -04:00
3e1ae22b6b Added pagination for the tournaments index page 2025-09-26 12:31:37 -04:00
15f85e439c Fixed finish_tournament_204.rake 2025-09-15 18:57:29 -04:00
c5b9783853 Fixed logout link 2025-09-15 18:32:55 -04:00
cd77268070 Trying to make the finish_tournament_204 rake job reliable 2025-09-15 18:28:35 -04:00
d61ed80287 Reload last_match and wrestler in advanced_wrestler. Moved calculate team score to the end of advance_wrestler. 2025-09-15 18:22:42 -04:00
dd5ce9bd60 Fixed my ads.txt to contain my publisher id for adsense 2025-09-15 17:31:04 -04:00
9a4e6f6597 Dynamic double elim match generation and loser name generation 2025-09-02 22:10:55 -04:00
782baedcfe Added support for 64 man brackets 2025-08-28 10:32:35 -04:00
Your Name
53e16952bf Added Stimulus and moved the matstats vanilla js to stimulus controllers. Same with the spectate page. 2025-05-20 17:22:48 -04:00
Your Name
0326d87261 Migrated from Sprockets to Propshaft. 2025-05-16 17:14:05 -04:00
5296b71bb9 Downloaded fontawesome locally instead of using CDN 2025-05-15 17:09:24 -04:00
58be6b8074 gitignore .DS_Store and update Gemfile 2025-05-15 13:58:02 -04:00
4accedbb43 Added a daily recurring job to cleanup tournaments. Fixed final score fields not loading without page refresh on mat stats page and added a cypress test for it. 2025-05-07 16:01:48 -04:00
2856060b11 Added cypress tests for mat stats javascript. 2025-05-05 19:57:03 -04:00
68a7b214c9 Fixed match stats when localstorage is empty but server data is not. 2025-04-28 16:35:24 -04:00
1fcaec876f Refactored match winner and wrestlers matches and fixed slow tests. 2025-04-25 15:59:35 -04:00
3e4317dbc5 Using action cable to send stats updates to the client and made a spectate page. 2025-04-21 17:12:54 -04:00
44fb5388b4 Made the all results page grouping better and fixed the advance wrestler job. 2025-04-17 17:34:34 -04:00
293 changed files with 41750 additions and 3277 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -2,3 +2,10 @@
- If the docker image doesn't exist, use the build command: docker build -t wrestlingdev-dev -f deploy/rails-dev-Dockerfile . - 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. - 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. - Do not add unnecessary comments to the code where you remove things.
- 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
- Rails tests can be run with docker: docker run -it -v $(pwd):/rails wrestlingdev-dev rake test
- 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.
- use context7

16
.gitignore vendored
View File

@@ -21,4 +21,20 @@ tmp
.rvmrc .rvmrc
deploy/prod.env deploy/prod.env
frontend/node_modules frontend/node_modules
node_modules
.aider* .aider*
# Ignore cypress test results
cypress-tests/cypress/screenshots
cypress-tests/cypress/videos
.DS_Store
# generated with npx repomix
# repomix-output.xml
# generated by cine mcp settings
~/
/.ruby-lsp
.codex

View File

@@ -1 +1 @@
ruby-3.2.0 ruby-4.0.1

11
AGENTS.md Normal file
View File

@@ -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 <rails command>`
- 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.

37
Gemfile
View File

@@ -1,14 +1,15 @@
source 'https://rubygems.org' source 'https://rubygems.org'
ruby '3.2.0' ruby '4.0.1'
# Bundle edge Rails instead: gem 'rails', github: 'rails/rails' # Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
gem 'rails', '8.0.2' gem 'rails', '8.1.2'
# Added in rails 7.1 # Added in rails 7.1
gem 'rails-html-sanitizer' gem 'rails-html-sanitizer'
# The original asset pipeline for Rails [https://github.com/rails/sprockets-rails] # Asset Management: Propshaft for serving, Importmap for JavaScript
gem "sprockets-rails" gem "propshaft"
gem "importmap-rails"
# Reduces boot times through caching; required in config/boot.rb # Reduces boot times through caching; required in config/boot.rb
gem "bootsnap", require: false gem "bootsnap", require: false
@@ -17,17 +18,19 @@ gem "bootsnap", require: false
# Use sqlite3 version compatible with Rails 8 # Use sqlite3 version compatible with Rails 8
gem 'sqlite3', ">= 2.1", :group => :development gem 'sqlite3', ">= 2.1", :group => :development
# Use Uglifier as compressor for JavaScript assets # JavaScript and CSS related gems
gem 'uglifier' # Uglifier is not used with Propshaft by default
# Use CoffeeScript for .js.coffee assets and views # CoffeeScript (.js.coffee) files need to be converted to .js as Propshaft doesn't compile them
gem 'coffee-rails'
# See https://github.com/sstephenson/execjs#readme for more supported runtimes # See https://github.com/sstephenson/execjs#readme for more supported runtimes
# gem 'therubyracer', platforms: :ruby # gem 'therubyracer', platforms: :ruby
# Use jquery as the JavaScript library # Use jquery as the JavaScript library
gem 'jquery-rails' gem 'jquery-rails'
# Turbolinks makes following links in your web application faster. Read more: https://github.com/rails/turbolinks # Turbo for modern page interactions
gem 'turbolinks' gem 'turbo-rails'
# Stimulus for JavaScript behaviors
gem 'stimulus-rails'
# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
gem 'jbuilder' gem 'jbuilder'
# bundle exec rake doc:rails generates the API under doc/api. # bundle exec rake doc:rails generates the API under doc/api.
@@ -64,25 +67,24 @@ gem 'influxdb-rails'
gem 'cancancan' gem 'cancancan'
gem 'round_robin_tournament' gem 'round_robin_tournament'
gem 'rb-readline' gem 'rb-readline'
gem 'rqrcode'
# Replacing Delayed Job with Solid Queue # Replacing Delayed Job with Solid Queue
# gem 'delayed_job_active_record' # gem 'delayed_job_active_record'
gem 'solid_queue' gem 'solid_queue'
gem 'solid_cable' gem 'solid_cable'
gem 'puma' gem 'puma'
gem 'passenger'
gem 'tzinfo-data' gem 'tzinfo-data'
gem 'daemons' gem 'daemons'
# Interface for viewing and managing background jobs # Solid Queue UI
# gem 'delayed_job_web' gem "mission_control-jobs"
# Note: solid_queue-ui is not compatible with Rails 8.0 yet
# We'll create a custom UI or wait for compatibility updates
# gem 'solid_queue_ui', '~> 0.1.1'
group :development do group :development do
# gem 'rubocop' # gem 'rubocop'
gem 'bullet' gem 'bullet'
gem 'brakeman' gem 'brakeman'
gem 'bundler-audit' gem 'bundler-audit'
gem 'rubocop'
end end
group :development, :test do group :development, :test do
@@ -90,6 +92,3 @@ group :development, :test do
# rails-controller-testing is needed for assert_template # rails-controller-testing is needed for assert_template
gem 'rails-controller-testing' gem 'rails-controller-testing'
end end
gem 'font-awesome-sass'

View File

@@ -1,29 +1,31 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (8.0.2) action_text-trix (2.1.18)
actionpack (= 8.0.2) railties
activesupport (= 8.0.2) actioncable (8.1.2)
actionpack (= 8.1.2)
activesupport (= 8.1.2)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
actionmailbox (8.0.2) actionmailbox (8.1.2)
actionpack (= 8.0.2) actionpack (= 8.1.2)
activejob (= 8.0.2) activejob (= 8.1.2)
activerecord (= 8.0.2) activerecord (= 8.1.2)
activestorage (= 8.0.2) activestorage (= 8.1.2)
activesupport (= 8.0.2) activesupport (= 8.1.2)
mail (>= 2.8.0) mail (>= 2.8.0)
actionmailer (8.0.2) actionmailer (8.1.2)
actionpack (= 8.0.2) actionpack (= 8.1.2)
actionview (= 8.0.2) actionview (= 8.1.2)
activejob (= 8.0.2) activejob (= 8.1.2)
activesupport (= 8.0.2) activesupport (= 8.1.2)
mail (>= 2.8.0) mail (>= 2.8.0)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
actionpack (8.0.2) actionpack (8.1.2)
actionview (= 8.0.2) actionview (= 8.1.2)
activesupport (= 8.0.2) activesupport (= 8.1.2)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
rack (>= 2.2.4) rack (>= 2.2.4)
rack-session (>= 1.0.1) rack-session (>= 1.0.1)
@@ -31,130 +33,137 @@ GEM
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
useragent (~> 0.16) useragent (~> 0.16)
actiontext (8.0.2) actiontext (8.1.2)
actionpack (= 8.0.2) action_text-trix (~> 2.1.15)
activerecord (= 8.0.2) actionpack (= 8.1.2)
activestorage (= 8.0.2) activerecord (= 8.1.2)
activesupport (= 8.0.2) activestorage (= 8.1.2)
activesupport (= 8.1.2)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (8.0.2) actionview (8.1.2)
activesupport (= 8.0.2) activesupport (= 8.1.2)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.11) erubi (~> 1.11)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
activejob (8.0.2) activejob (8.1.2)
activesupport (= 8.0.2) activesupport (= 8.1.2)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (8.0.2) activemodel (8.1.2)
activesupport (= 8.0.2) activesupport (= 8.1.2)
activerecord (8.0.2) activerecord (8.1.2)
activemodel (= 8.0.2) activemodel (= 8.1.2)
activesupport (= 8.0.2) activesupport (= 8.1.2)
timeout (>= 0.4.0) timeout (>= 0.4.0)
activestorage (8.0.2) activestorage (8.1.2)
actionpack (= 8.0.2) actionpack (= 8.1.2)
activejob (= 8.0.2) activejob (= 8.1.2)
activerecord (= 8.0.2) activerecord (= 8.1.2)
activesupport (= 8.0.2) activesupport (= 8.1.2)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (8.0.2) activesupport (8.1.2)
base64 base64
benchmark (>= 0.3)
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1) concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5) connection_pool (>= 2.2.5)
drb drb
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
json
logger (>= 1.4.2) logger (>= 1.4.2)
minitest (>= 5.1) minitest (>= 5.1)
securerandom (>= 0.3) securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5) tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1) uri (>= 0.13.1)
base64 (0.2.0) ast (2.4.3)
bcrypt (3.1.20) base64 (0.3.0)
benchmark (0.4.0) bcrypt (3.1.22)
bigdecimal (3.1.9) bigdecimal (4.1.1)
bootsnap (1.18.4) bootsnap (1.23.0)
msgpack (~> 1.2) msgpack (~> 1.2)
brakeman (7.0.2) brakeman (8.0.4)
racc racc
builder (3.3.0) builder (3.3.0)
bullet (8.0.3) bullet (8.1.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
uniform_notifier (~> 1.11) uniform_notifier (~> 1.11)
bundler-audit (0.9.2) bundler-audit (0.9.3)
bundler (>= 1.2.0, < 3) bundler (>= 1.2.0)
thor (~> 1.0) thor (~> 1.0)
cancancan (3.6.1) cancancan (3.6.1)
coffee-rails (5.0.0) chunky_png (1.4.0)
coffee-script (>= 2.2.0) concurrent-ruby (1.3.6)
railties (>= 5.2.0) connection_pool (3.0.2)
coffee-script (2.4.1)
coffee-script-source
execjs
coffee-script-source (1.12.2)
concurrent-ruby (1.3.5)
connection_pool (2.5.0)
crass (1.0.6) crass (1.0.6)
daemons (1.4.1) daemons (1.4.1)
date (3.4.1) date (3.5.1)
drb (2.2.1) drb (2.2.3)
erb (6.0.2)
erubi (1.13.1) erubi (1.13.1)
et-orbi (1.2.11) et-orbi (1.4.0)
tzinfo tzinfo
execjs (2.10.0) fugit (1.12.1)
ffi (1.17.1-aarch64-linux-gnu) et-orbi (~> 1.4)
ffi (1.17.1-aarch64-linux-musl)
ffi (1.17.1-arm-linux-gnu)
ffi (1.17.1-arm-linux-musl)
ffi (1.17.1-arm64-darwin)
ffi (1.17.1-x86_64-darwin)
ffi (1.17.1-x86_64-linux-gnu)
ffi (1.17.1-x86_64-linux-musl)
font-awesome-sass (6.7.2)
sassc (~> 2.0)
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4) raabro (~> 1.4)
globalid (1.2.1) globalid (1.3.0)
activesupport (>= 6.1) activesupport (>= 6.1)
i18n (1.14.7) i18n (1.14.8)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
importmap-rails (2.2.3)
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
railties (>= 6.0.0)
influxdb (0.8.1) influxdb (0.8.1)
influxdb-rails (1.0.3) influxdb-rails (1.0.3)
influxdb (~> 0.6, >= 0.6.4) influxdb (~> 0.6, >= 0.6.4)
railties (>= 5.0) railties (>= 5.0)
io-console (0.8.0) io-console (0.8.2)
irb (1.15.2) irb (1.17.0)
pp (>= 0.6.0) pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jbuilder (2.13.0) jbuilder (2.14.1)
actionview (>= 5.0.0) actionview (>= 7.0.0)
activesupport (>= 5.0.0) activesupport (>= 7.0.0)
jquery-rails (4.6.0) jquery-rails (4.6.1)
rails-dom-testing (>= 1, < 3) rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0) railties (>= 4.2.0)
thor (>= 0.14, < 2.0) thor (>= 0.14, < 2.0)
json (2.19.3)
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
logger (1.7.0) logger (1.7.0)
loofah (2.24.0) loofah (2.25.1)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.8.1) mail (2.9.0)
logger
mini_mime (>= 0.1.1) mini_mime (>= 0.1.1)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
marcel (1.0.4) marcel (1.1.0)
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (5.25.5) minitest (6.0.3)
mocha (2.7.1) drb (~> 2.0)
prism (~> 1.5)
mission_control-jobs (1.1.0)
actioncable (>= 7.1)
actionpack (>= 7.1)
activejob (>= 7.1)
activerecord (>= 7.1)
importmap-rails (>= 1.2.1)
irb (~> 1.13)
railties (>= 7.1)
stimulus-rails
turbo-rails
mocha (3.1.0)
ruby2_keywords (>= 0.0.5) ruby2_keywords (>= 0.0.5)
msgpack (1.8.0) msgpack (1.8.0)
mysql2 (0.5.6) mysql2 (0.5.7)
net-imap (0.5.6) bigdecimal
net-imap (0.6.3)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
@@ -163,148 +172,172 @@ GEM
timeout timeout
net-smtp (0.5.1) net-smtp (0.5.1)
net-protocol net-protocol
nio4r (2.7.4) nio4r (2.7.5)
nokogiri (1.18.7-aarch64-linux-gnu) nokogiri (1.19.2-aarch64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.7-aarch64-linux-musl) nokogiri (1.19.2-aarch64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.7-arm-linux-gnu) nokogiri (1.19.2-arm-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.7-arm-linux-musl) nokogiri (1.19.2-arm-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.7-arm64-darwin) nokogiri (1.19.2-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.7-x86_64-darwin) nokogiri (1.19.2-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.7-x86_64-linux-gnu) nokogiri (1.19.2-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.7-x86_64-linux-musl) nokogiri (1.19.2-x86_64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
passenger (6.0.27) parallel (2.0.1)
rack (>= 1.6.13) parser (3.3.11.1)
rackup (>= 1.0.1) ast (~> 2.4.1)
rake (>= 12.3.3) racc
pp (0.6.2) pp (0.6.3)
prettyprint prettyprint
prettyprint (0.2.0) prettyprint (0.2.0)
psych (5.2.3) prism (1.9.0)
propshaft (1.3.1)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
psych (5.3.1)
date date
stringio stringio
puma (6.6.0) puma (8.0.0)
nio4r (~> 2.0) nio4r (~> 2.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.8.1) racc (1.8.1)
rack (3.1.12) rack (3.2.6)
rack-session (2.1.0) rack-session (2.1.2)
base64 (>= 0.1.0) base64 (>= 0.1.0)
rack (>= 3.0.0) rack (>= 3.0.0)
rack-test (2.2.0) rack-test (2.2.0)
rack (>= 1.3) rack (>= 1.3)
rackup (2.2.1) rackup (2.3.1)
rack (>= 3) rack (>= 3)
rails (8.0.2) rails (8.1.2)
actioncable (= 8.0.2) actioncable (= 8.1.2)
actionmailbox (= 8.0.2) actionmailbox (= 8.1.2)
actionmailer (= 8.0.2) actionmailer (= 8.1.2)
actionpack (= 8.0.2) actionpack (= 8.1.2)
actiontext (= 8.0.2) actiontext (= 8.1.2)
actionview (= 8.0.2) actionview (= 8.1.2)
activejob (= 8.0.2) activejob (= 8.1.2)
activemodel (= 8.0.2) activemodel (= 8.1.2)
activerecord (= 8.0.2) activerecord (= 8.1.2)
activestorage (= 8.0.2) activestorage (= 8.1.2)
activesupport (= 8.0.2) activesupport (= 8.1.2)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 8.0.2) railties (= 8.1.2)
rails-controller-testing (1.0.5) rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1) actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1)
activesupport (>= 5.0.1.rc1) activesupport (>= 5.0.1.rc1)
rails-dom-testing (2.2.0) rails-dom-testing (2.3.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2) rails-html-sanitizer (1.7.0)
loofah (~> 2.21) loofah (~> 2.25)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails_12factor (0.0.3) rails_12factor (0.0.3)
rails_serve_static_assets rails_serve_static_assets
rails_stdout_logging rails_stdout_logging
rails_serve_static_assets (0.0.5) rails_serve_static_assets (0.0.5)
rails_stdout_logging (0.0.5) rails_stdout_logging (0.0.5)
railties (8.0.2) railties (8.1.2)
actionpack (= 8.0.2) actionpack (= 8.1.2)
activesupport (= 8.0.2) activesupport (= 8.1.2)
irb (~> 1.13) irb (~> 1.13)
rackup (>= 1.0.0) rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0, >= 1.2.2) thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rake (13.2.1) rainbow (3.1.1)
rake (13.4.1)
rb-readline (0.5.5) rb-readline (0.5.5)
rdoc (6.13.1) rdoc (7.2.0)
erb
psych (>= 4.0.0) psych (>= 4.0.0)
reline (0.6.1) tsort
regexp_parser (2.12.0)
reline (0.6.3)
io-console (~> 0.5) io-console (~> 0.5)
round_robin_tournament (0.1.2) round_robin_tournament (0.1.2)
rqrcode (3.2.0)
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
rqrcode_core (2.1.0)
rubocop (1.86.1)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (>= 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.49.1)
parser (>= 3.3.7.2)
prism (~> 1.7)
ruby-progressbar (1.13.0)
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
sassc (2.4.0) sdoc (2.6.5)
ffi (~> 1.9)
sdoc (2.6.1)
rdoc (>= 5.0) rdoc (>= 5.0)
securerandom (0.4.1) securerandom (0.4.1)
solid_cable (3.0.7) solid_cable (3.0.12)
actioncable (>= 7.2) actioncable (>= 7.2)
activejob (>= 7.2) activejob (>= 7.2)
activerecord (>= 7.2) activerecord (>= 7.2)
railties (>= 7.2) railties (>= 7.2)
solid_cache (1.0.7) solid_cache (1.0.10)
activejob (>= 7.2) activejob (>= 7.2)
activerecord (>= 7.2) activerecord (>= 7.2)
railties (>= 7.2) railties (>= 7.2)
solid_queue (1.1.4) solid_queue (1.4.0)
activejob (>= 7.1) activejob (>= 7.1)
activerecord (>= 7.1) activerecord (>= 7.1)
concurrent-ruby (>= 1.3.1) concurrent-ruby (>= 1.3.1)
fugit (~> 1.11.0) fugit (~> 1.11)
railties (>= 7.1) railties (>= 7.1)
thor (~> 1.3.1) thor (>= 1.3.1)
spring (4.3.0) spring (4.4.2)
sprockets (4.2.1) sqlite3 (2.9.2-aarch64-linux-gnu)
concurrent-ruby (~> 1.0) sqlite3 (2.9.2-aarch64-linux-musl)
rack (>= 2.2.4, < 4) sqlite3 (2.9.2-arm-linux-gnu)
sprockets-rails (3.5.2) sqlite3 (2.9.2-arm-linux-musl)
actionpack (>= 6.1) sqlite3 (2.9.2-arm64-darwin)
activesupport (>= 6.1) sqlite3 (2.9.2-x86_64-darwin)
sprockets (>= 3.0.0) sqlite3 (2.9.2-x86_64-linux-gnu)
sqlite3 (2.6.0-aarch64-linux-gnu) sqlite3 (2.9.2-x86_64-linux-musl)
sqlite3 (2.6.0-aarch64-linux-musl) stimulus-rails (1.3.4)
sqlite3 (2.6.0-arm-linux-gnu) railties (>= 6.0.0)
sqlite3 (2.6.0-arm-linux-musl) stringio (3.2.0)
sqlite3 (2.6.0-arm64-darwin) thor (1.5.0)
sqlite3 (2.6.0-x86_64-darwin) timeout (0.6.1)
sqlite3 (2.6.0-x86_64-linux-gnu) tsort (0.2.0)
sqlite3 (2.6.0-x86_64-linux-musl) turbo-rails (2.0.23)
stringio (3.1.6) actionpack (>= 7.1.0)
thor (1.3.2) railties (>= 7.1.0)
timeout (0.4.3)
turbolinks (5.2.1)
turbolinks-source (~> 5.2)
turbolinks-source (5.2.0)
tzinfo (2.0.6) tzinfo (2.0.6)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
tzinfo-data (1.2025.2) tzinfo-data (1.2026.1)
tzinfo (>= 1.0.0) tzinfo (>= 1.0.0)
uglifier (4.2.1) unicode-display_width (3.2.0)
execjs (>= 0.3.0, < 3) unicode-emoji (~> 4.1)
uniform_notifier (1.16.0) unicode-emoji (4.2.0)
uri (1.0.3) uniform_notifier (1.18.0)
uri (1.1.1)
useragent (0.16.11) useragent (0.16.11)
websocket-driver (0.7.7) websocket-driver (0.8.0)
base64 base64
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
zeitwerk (2.7.2) zeitwerk (2.7.5)
PLATFORMS PLATFORMS
aarch64-linux-gnu aarch64-linux-gnu
@@ -323,35 +356,36 @@ DEPENDENCIES
bullet bullet
bundler-audit bundler-audit
cancancan cancancan
coffee-rails
daemons daemons
font-awesome-sass importmap-rails
influxdb-rails influxdb-rails
jbuilder jbuilder
jquery-rails jquery-rails
mission_control-jobs
mocha mocha
mysql2 mysql2
passenger propshaft
puma puma
rails (= 8.0.2) rails (= 8.1.2)
rails-controller-testing rails-controller-testing
rails-html-sanitizer rails-html-sanitizer
rails_12factor rails_12factor
rb-readline rb-readline
round_robin_tournament round_robin_tournament
rqrcode
rubocop
sdoc sdoc
solid_cable solid_cable
solid_cache solid_cache
solid_queue solid_queue
spring spring
sprockets-rails
sqlite3 (>= 2.1) sqlite3 (>= 2.1)
turbolinks stimulus-rails
turbo-rails
tzinfo-data tzinfo-data
uglifier
RUBY VERSION RUBY VERSION
ruby 3.2.0p0 ruby 4.0.1p0
BUNDLED WITH BUNDLED WITH
2.6.7 4.0.3

111
README.md
View File

@@ -7,11 +7,13 @@ This application is being created to run a wrestling tournament.
**Public Production Url:** [https://wrestlingdev.com](https://wrestlingdev.com) **Public Production Url:** [https://wrestlingdev.com](https://wrestlingdev.com)
**App Info** **App Info**
* Ruby 3.2.0 * Ruby 4.0.1
* Rails 8.0.0 * Rails 8.1.2
* DB MySQL/MariaDB * DB MySQL/MariaDB
* Memcached * Solid Cache -> MySQL/MariaDB for html partial caching
* Solid Queue for background job processing * Solid Queue -> MySQL/MariaDB for background job processing
* Solid Cable -> MySQL/MariaDB for websocket channels
* Hotwired Stimulus for client-side JavaScript
# Development # Development
@@ -32,11 +34,54 @@ In development environments, background jobs run inline (synchronously) by defau
To run a single test file: To run a single test file:
1. Get a shell with ruby and rails: `bash bin/rails-dev-run.sh wrestlingdev-development` 1. Get a shell with ruby and rails: `bash bin/rails-dev-run.sh wrestlingdev-development`
2. `rake test TEST=test/models/match_test.rb` 2. `rake test TEST=test/models/match_test.rb` OR `rails test test/models/match_test.rb`
To run a single test inside a file: To run a single test inside a file:
1. Get a shell with ruby and rails: `bash bin/rails-dev-run.sh wrestlingdev-development` 1. Get a shell with ruby and rails: `bash bin/rails-dev-run.sh wrestlingdev-development`
2. `rake test TEST=test/models/match_test.rb TESTOPTS="--name='/test_Match_should_not_be_valid_if_an_incorrect_win_type_is_given/'"` 2. `rake test TEST=test/models/match_test.rb TESTOPTS="--name='/test_Match_should_not_be_valid_if_an_incorrect_win_type_is_given/'"` OR `rails test test/models/match_test.rb --name=/test_Match_should_not_be_valid_if_an_incorrect_win_type_is_given/`
To run tests in verbose mode (outputs the time for each test file and the test file name)
`rails test -v`
## JavaScript tests with Vitest
Stimulus controllers and match-state JavaScript helpers are tested with Vitest. These tests live in `test/javascript`.
Run all JavaScript tests:
```bash
npm install
npm run test:js
```
Run one JavaScript test file:
```bash
npm run test:js -- test/javascript/match_state/engine.test.js
```
Run JavaScript tests in watch mode:
```bash
npm run test:js:watch
```
The full test runner also runs Vitest before Rails tests:
```bash
bash bin/run-all-tests.sh
```
Vitest currently covers client-side logic that is hard to test well with Minitest alone:
* The match-state rules engine: scoring, control changes, period choices, event replay, deletion, swapping, timers, accumulated match time, result defaults, and scoreboard payload generation.
* Stimulus controller behavior for the state page, legacy stat page, match result form, mat state page, scoreboard, spectate page, and live score updates.
* LocalStorage behavior for state/stat persistence, tournament-scoped keys, expiration timestamps, and cleanup of expired app-owned keys.
* Websocket payload handling at the JavaScript boundary, including deduped outbound state/stat messages and inbound scoreboard/spectate updates.
Minitest still owns the Rails side: controllers, permissions, models, channels, redirects, rendered ERB, and database behavior. Vitest fills the gap for logic that runs entirely in the browser without needing Cypress or a full browser session.
Cypress tests are deprecated for this project. Use Vitest for JavaScript unit coverage and Minitest for Rails behavior.
## Develop with rvm ## Develop with rvm
With rvm installed, run `rvm install ruby-3.2.0` With rvm installed, run `rvm install ruby-3.2.0`
@@ -78,6 +123,7 @@ Whether you have a shell from docker or are using rvm you can now run normal rai
* etc. * etc.
* `rake finish_seed_tournaments` will complete all matches from the seed data. This command takes about 5 minutes to execute * `rake finish_seed_tournaments` will complete all matches from the seed data. This command takes about 5 minutes to execute
* `rake assets:clobber` - removes previously compiled assets stored in `public/assets` forcing Rails to recompile them from scratch the next time they are requested. * `rake assets:clobber` - removes previously compiled assets stored in `public/assets` forcing Rails to recompile them from scratch the next time they are requested.
* `bundle-audit check --update` - check for vulnerabilities in Gemfile.lock
## Testing Job Status ## Testing Job Status
@@ -106,9 +152,23 @@ See `SOLID_QUEUE.md` for more details about the job system.
Note: If updating rails, do not change the version in `Gemfile` until after you run `bash bin/rails-dev-run.sh wrestlingdev-dev`. Creating the container will fail due to a mismatch in Gemfile and Gemfile.lock. Note: If updating rails, do not change the version in `Gemfile` until after you run `bash bin/rails-dev-run.sh wrestlingdev-dev`. Creating the container will fail due to a mismatch in Gemfile and Gemfile.lock.
Then run `rails app:update` to update rails. Then run `rails app:update` to update rails.
## Stimulus Controllers
The application uses Hotwired Stimulus for client-side JavaScript interactivity. Controllers can be found in `app/asssets/javascripts/controllers`
### Testing Stimulus Controllers
Stimulus controllers are tested with Vitest:
```bash
npm run test:js
```
# Deployment # Deployment
The production version of this is currently deployed in Kubernetes. See [Deploying with Kubernetes](deploy/kubernetes/README.md) The production version of this is currently deployed in Kubernetes (via K3s). See [Deploying with Kubernetes](deploy/kubernetes/README.md)
I'm using a Hetzner dedicated server with an i7-8700, 500GB NVME (RAID1), and 64GB ECC RAM. I have a hot standby (SQL read only replication) in my homelab.
## Server Configuration ## Server Configuration
@@ -122,11 +182,6 @@ The application uses an intelligent auto-scaling configuration for Puma (the web
- **SolidQueue Integration**: When `SOLID_QUEUE_IN_PUMA=true`, background jobs run within the Puma process. - **SolidQueue Integration**: When `SOLID_QUEUE_IN_PUMA=true`, background jobs run within the Puma process.
- **Database Connection Pool**: Automatically sized based on the maximum number of threads across all workers. - **Database Connection Pool**: Automatically sized based on the maximum number of threads across all workers.
The configuration is designed to adapt to different environments:
- Small servers: Uses fewer workers to avoid memory exhaustion
- Large servers: Scales up to utilize available CPU cores
- Development: Uses a single worker for simplicity
All of these settings can be overridden with environment variables if needed. All of these settings can be overridden with environment variables if needed.
To see the current configuration in the logs, look for these lines on startup: To see the current configuration in the logs, look for these lines on startup:
@@ -136,6 +191,9 @@ Available system resources: X CPU(s), YMMMB RAM
SolidQueue plugin enabled in Puma SolidQueue plugin enabled in Puma
``` ```
I have deployed Mission Control as a UI for SolidQueue. The uri for mission control is `/jobs`.
For the development environment, the user/password is dev/secret. For the production environment, it is defined by environment variables WRESTLINGDEV_MISSION_CONTROL_USER/WRESTLINGDEV_MISSION_CONTROL_PASSWORD. You can see this in `config/environments/production.rb` and `config/environments/development.rb`.
## Environment Variables ## Environment Variables
### Required Environment Variables ### Required Environment Variables
@@ -148,6 +206,8 @@ SolidQueue plugin enabled in Puma
* `WRESTLINGDEV_SECRET_KEY_BASE` - Rails application secret key (can be generated with `rake secret`) * `WRESTLINGDEV_SECRET_KEY_BASE` - Rails application secret key (can be generated with `rake secret`)
* `WRESTLINGDEV_EMAIL` - Email address (currently must be a Gmail account) * `WRESTLINGDEV_EMAIL` - Email address (currently must be a Gmail account)
* `WRESTLINGDEV_EMAIL_PWD` - Email password * `WRESTLINGDEV_EMAIL_PWD` - Email password
* `WRESTLINGDEV_MISSION_CONTROL_USER` - mission control username
* `WRESTLINGDEV_MISSION_CONTROL_PASSWORD` - mission control password
### Optional Environment Variables ### Optional Environment Variables
* `SOLID_QUEUE_IN_PUMA` - Set to "true" to run Solid Queue workers inside Puma (default in development) * `SOLID_QUEUE_IN_PUMA` - Set to "true" to run Solid Queue workers inside Puma (default in development)
@@ -172,6 +232,29 @@ SolidQueue plugin enabled in Puma
* `WRESTLINGDEV_INFLUXDB_USERNAME` - InfluxDB username (optional) * `WRESTLINGDEV_INFLUXDB_USERNAME` - InfluxDB username (optional)
* `WRESTLINGDEV_INFLUXDB_PASSWORD` - InfluxDB password (optional) * `WRESTLINGDEV_INFLUXDB_PASSWORD` - InfluxDB password (optional)
See `SOLID_QUEUE.md` for details about the job system configuration.
This project provides multiple ways to develop and deploy, with Docker being the primary method. This project provides multiple ways to develop and deploy, with Docker being the primary method.
# Frontend Assets
## Sprockets to Propshaft Migration
- Propshaft will automatically include in its search paths the folders vendor/assets, lib/assets and app/assets of your project and of all the gems in your Gemfile. You can see all included files by using the reveal rake task: `rake assets:reveal`. When importing you'll use the relative path from this command.
- All css files are imported via `app/assets/stylesheets/application.css`. This is imported on `app/views/layouts/application.html.erb`.
- Bootstrap and fontawesome have been downloaded locally to `vendor/`
- All js files are imported with a combination of "pinning" with `config/importmaps.rb` and `app/assets/javascript/application.js` and imported to `app/views/layouts/application.html.erb`
- Jquery, bootstrap, datatables have been downloaded locally to `vendor/`
- Turbo and action cable are gems and get pathed properly by propshaft.
- development is "nobuild" with `config.assets.build_assets = false` in `config/environments/development.rb`
- production needs to run rake assets:precompile. This is done in the `deploy/rails-prod-Dockerfile`.
## Stimulus Implementation
The application has been migrated from using vanilla JavaScript to Hotwired Stimulus. The Stimulus controllers are organized in:
- `app/assets/javascripts/controllers/` - Contains all Stimulus controllers
- `app/assets/javascripts/application.js` - Registers and loads all controllers
The importmap configuration in `config/importmap.rb` handles the loading of all JavaScript dependencies including Stimulus controllers.
# Using Repomix with LLMs
`npx repomix app test`

View File

@@ -1,288 +0,0 @@
# SolidQueue, SolidCache, and SolidCable Setup
This application uses Rails 8's built-in background job processing, caching, and ActionCable features with separate dedicated databases.
## Database Configuration
We use separate databases for the main application, SolidQueue, SolidCache, and SolidCable. This ensures complete separation and avoids any conflicts or performance issues.
In `config/database.yml`, we have the following setup:
```yaml
development:
primary:
database: db/development.sqlite3
queue:
database: db/development-queue.sqlite3
cache:
database: db/development-cache.sqlite3
cable:
database: db/development-cable.sqlite3
test:
primary:
database: db/test.sqlite3
queue:
database: db/test-queue.sqlite3
cache:
database: db/test-cache.sqlite3
cable:
database: db/test-cable.sqlite3
production:
primary:
database: <%= ENV['WRESTLINGDEV_DB_NAME'] %>
queue:
database: <%= ENV['WRESTLINGDEV_DB_NAME'] %>-queue
cache:
database: <%= ENV['WRESTLINGDEV_DB_NAME'] %>-cache
cable:
database: <%= ENV['WRESTLINGDEV_DB_NAME'] %>-cable
```
## Migration Structure
Migrations for each database are stored in their respective directories:
- Main application migrations: `db/migrate/`
- SolidQueue migrations: `db/queue/migrate/`
- SolidCache migrations: `db/cache/migrate/`
- SolidCable migrations: `db/cable/migrate/`
## Running Migrations
When deploying the application, you need to run migrations for each database separately:
```bash
# Run main application migrations
rails db:migrate
# Run SolidQueue migrations
rails db:migrate:queue
# Run SolidCache migrations
rails db:migrate:cache
# Run SolidCable migrations
rails db:migrate:cable
```
## Environment Configuration
In the environment configuration files (`config/environments/*.rb`), we've configured the paths for migrations and set up the appropriate adapters:
```ruby
# SolidCache configuration
config.cache_store = :solid_cache_store
config.paths["db/migrate"] << "db/cache/migrate"
# SolidQueue configuration
config.active_job.queue_adapter = :solid_queue
config.paths["db/migrate"] << "db/queue/migrate"
# ActionCable configuration
config.paths["db/migrate"] << "db/cable/migrate"
```
The database connections are configured in their respective YAML files:
### config/cache.yml
```yaml
production:
database: cache
# other options...
```
### config/queue.yml
```yaml
production:
database: queue
# other options...
```
### config/cable.yml
```yaml
production:
adapter: solid_cable
database: cable
# other options...
```
## SolidQueue Configuration
SolidQueue is used for background job processing in all environments except test. The application is configured to run jobs as follows:
### Development and Production
In both development and production environments, SolidQueue is configured to process jobs asynchronously. This provides consistent behavior across environments while maintaining performance.
### Test
In the test environment only, jobs are executed synchronously using the inline adapter. This makes testing more predictable and avoids the need for separate worker processes during tests.
Configuration is in `config/initializers/solid_queue.rb`:
```ruby
# Configure ActiveJob queue adapter based on environment
if Rails.env.test?
# In test, use inline adapter for simplicity and predictability
Rails.application.config.active_job.queue_adapter = :inline
else
# In development and production, use solid_queue with async execution
Rails.application.config.active_job.queue_adapter = :solid_queue
# Configure for regular async processing
Rails.application.config.active_job.queue_adapter_options = {
execution_mode: :async,
logger: Rails.logger
}
end
```
## Running with Puma
By default, the application is configured to run SolidQueue workers within the Puma processes. This is done by setting the `SOLID_QUEUE_IN_PUMA` environment variable to `true` in the production Dockerfile, which enables the Puma plugin for SolidQueue.
This means you don't need to run separate worker processes in production - the same Puma processes that handle web requests also handle background jobs. This simplifies deployment and reduces resource requirements.
The application uses an intelligent auto-scaling configuration for SolidQueue when running in Puma:
1. **Auto Detection**: The Puma configuration automatically detects available CPU cores and memory
2. **Worker Scaling**: Puma workers are calculated based on available memory and CPU cores
3. **SolidQueue Integration**: When enabled, SolidQueue simply runs within the Puma process
You can enable SolidQueue in Puma by setting:
```bash
SOLID_QUEUE_IN_PUMA=true
```
In `config/puma.rb`:
```ruby
# Run the Solid Queue supervisor inside of Puma for single-server deployments
if ENV["SOLID_QUEUE_IN_PUMA"] == "true" && !Rails.env.test?
# Simply load the SolidQueue plugin with default settings
plugin :solid_queue
# Log that SolidQueue is enabled
puts "SolidQueue plugin enabled in Puma"
end
```
On startup, you'll see a log message confirming that SolidQueue is enabled in Puma.
## Job Owner Tracking
Jobs in this application include metadata about their owner (typically a tournament) to allow tracking and displaying job status to tournament directors. Each job includes:
- `job_owner_id`: Usually the tournament ID
- `job_owner_type`: A description of the job (e.g., "Create a backup")
This information is serialized with the job and can be used to filter and display jobs on tournament pages.
## Job Pattern: Simplified Job Enqueuing
Our job classes follow a simplified pattern that works consistently across all environments:
1. Service classes always use `perform_later` to enqueue jobs
2. The execution mode is determined centrally by the ActiveJob adapter configuration
3. Each job finds the needed records and calls the appropriate method on the service class or model
Example service class method:
```ruby
def create_backup
# Set up job owner information for tracking
job_owner_id = @tournament.id
job_owner_type = "Create a backup"
# Use perform_later which will execute based on centralized adapter config
TournamentBackupJob.perform_later(@tournament.id, @reason, job_owner_id, job_owner_type)
end
```
Example job class:
```ruby
class TournamentBackupJob < ApplicationJob
queue_as :default
# For storing job owner metadata
attr_accessor :job_owner_id, :job_owner_type
# For execution via job queue with IDs
def perform(tournament_id, reason, job_owner_id = nil, job_owner_type = nil)
# Store job owner metadata
self.job_owner_id = job_owner_id
self.job_owner_type = job_owner_type
# Find the record
tournament = Tournament.find_by(id: tournament_id)
return unless tournament
# Create the service class and call the raw method
TournamentBackupService.new(tournament, reason).create_backup_raw
end
end
```
## Job Classes
The following job classes are available:
- `AdvanceWrestlerJob`: For advancing wrestlers in brackets
- `TournamentBackupJob`: For creating tournament backups
- `WrestlingdevImportJob`: For importing from wrestlingdev
- `GenerateTournamentMatchesJob`: For generating tournament matches
- `CalculateSchoolScoreJob`: For calculating school scores
## Job Status
Jobs in this application can have the following statuses:
1. **Running**: Job is currently being executed. This is determined by checking if a record exists in the `solid_queue_claimed_executions` table for the job.
2. **Scheduled**: Job is scheduled to run at a future time. This is determined by checking if `scheduled_at` is in the future.
3. **Error**: Job has failed. This is determined by:
- Checking if a record exists in the `solid_queue_failed_executions` table for the job
- Checking if `failed_at` is present
4. **Completed**: Job has finished successfully. This is determined by checking if `finished_at` is present and no error records exist.
5. **Pending**: Job is waiting to be picked up by a worker. This is the default status when none of the above conditions are met.
## Testing Job Status
To help with testing the job status display in the UI, several rake tasks are provided:
```bash
# Create a test "Running" job for the first tournament
rails jobs:create_running
# Create a test "Completed" job for the first tournament
rails jobs:create_completed
# Create a test "Error" job for the first tournament
rails jobs:create_failed
```
## Troubleshooting
If you encounter issues with SolidQueue or the separate databases:
1. Make sure all databases exist:
```sql
CREATE DATABASE IF NOT EXISTS wrestlingtourney;
CREATE DATABASE IF NOT EXISTS wrestlingtourney-queue;
CREATE DATABASE IF NOT EXISTS wrestlingtourney-cache;
CREATE DATABASE IF NOT EXISTS wrestlingtourney-cable;
```
2. Ensure all migrations have been run for each database.
3. Check that environment configurations properly connect to the right databases.
4. Verify that the database user has appropriate permissions for all databases.
5. If jobs aren't processing in production, check that `SOLID_QUEUE_IN_PUMA` is set to `true` in your environment.
## References
- [SolidQueue README](https://github.com/rails/solid_queue)
- [Rails Multiple Database Configuration](https://guides.rubyonrails.org/active_record_multiple_databases.html)

View File

@@ -1,3 +1,11 @@
//= link_tree ../images //= link_tree ../images
//= link_directory ../javascripts .js
//= link_directory ../stylesheets .css // Link all .js files from app/javascript for importmap, and vendor/javascript if needed directly
//= link_tree ../../javascript .js
//= link_tree ../../../vendor/assets/javascripts .js
// Explicitly link all .css files from app/assets/stylesheets and vendor/assets/stylesheets
//= link_tree ../stylesheets .css
//= link_tree ../../../vendor/assets/stylesheets .css
//= link_tree ../../../vendor/assets/webfonts

View File

@@ -1,3 +0,0 @@
# Place all the behaviors and hooks related to the matching controller here.
# All this logic will automatically be available in application.js.
# You can use CoffeeScript in this file: http://coffeescript.org/

View File

@@ -1,3 +0,0 @@
# Place all the behaviors and hooks related to the matching controller here.
# All this logic will automatically be available in application.js.
# You can use CoffeeScript in this file: http://coffeescript.org/

View File

@@ -1,22 +1,62 @@
// This is a manifest file that'll be compiled into application.js, which will include all the files // Entry point for your JavaScript application
// listed below.
//
// Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts,
// or vendor/assets/javascripts of plugins, if any, can be referenced here using a relative path.
//
// It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the
// compiled file.
//
// Read Sprockets README (https://github.com/sstephenson/sprockets#sprockets-directives) for details
// about supported directives.
//
//= require jquery
//= require jquery_ujs
// Bootstrap 3.3.6 in vendor/assets/javascripts
//= require bootstrap.min.js
// Data Tables 1.10.6 in vendor/assets/javascripts
//= require jquery.dataTables.min.js
//= require turbolinks
//
//= require_tree .
// These are pinned in config/importmap.rb
import "@hotwired/turbo-rails";
import { createConsumer } from "@rails/actioncable"; // Import createConsumer directly
import "jquery";
import "bootstrap";
// Stimulus setup
import { Application } from "@hotwired/stimulus";
import { cleanupExpiredLocalStorage } from "match-state-transport";
// Initialize Stimulus application
const application = Application.start();
window.Stimulus = application;
// Load all controllers from app/assets/javascripts/controllers
// Import controllers manually
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";
// Register controllers
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);
function cleanupWrestlingAppLocalStorage() {
cleanupExpiredLocalStorage(window.localStorage);
}
document.addEventListener("turbo:load", cleanupWrestlingAppLocalStorage);
cleanupWrestlingAppLocalStorage();
// Your existing Action Cable consumer setup
(function() {
try {
window.App || (window.App = {});
window.App.cable = createConsumer(); // Use the imported createConsumer
console.log('Action Cable Consumer Created via app/assets/javascripts/application.js');
} catch (e) {
console.error('Error creating ActionCable consumer:', e);
console.error('ActionCable not loaded or createConsumer failed, App.cable not created.');
}
}).call(this);
console.log("Propshaft/Importmap application.js initialized with jQuery, Bootstrap, and Stimulus.");
// If you have custom JavaScript files in app/javascript/ that were previously
// handled by Sprockets `require_tree`, you'll need to import them here explicitly.
// For example:
// import "./my_custom_logic";

View File

@@ -0,0 +1,169 @@
import { Controller } from "@hotwired/stimulus"
import {
loadJson,
removeKey,
saveJson,
SHORT_LIVED_TTL_MS
} from "match-state-transport"
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 {
saveJson(window.localStorage, this.storageKey(), {
boutNumber: this.boutNumberValue,
matchId: this.matchIdValue,
updatedAt: Date.now()
}, { ttlMs: SHORT_LIVED_TTL_MS })
} 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) {
saveJson(window.localStorage, this.lastMatchResultStorageKey(), text, { ttlMs: SHORT_LIVED_TTL_MS })
} else {
removeKey(window.localStorage, this.lastMatchResultStorageKey())
}
} catch (_error) {
}
}
loadLastMatchResult() {
try {
return loadJson(window.localStorage, 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
}
}

View File

@@ -0,0 +1,424 @@
import { Controller } from "@hotwired/stimulus"
import {
loadJson,
saveJson,
MATCH_DATA_TTL_MS
} from "match-state-transport"
export default class extends Controller {
static targets = [
"w1Stat", "w2Stat", "statusIndicator"
]
static values = {
tournamentId: Number,
boutNumber: Number,
matchId: Number
}
connect() {
console.log("Match data controller connected")
this.isConnected = false
this.pendingLocalSync = { w1: false, w2: false }
this.w1 = {
name: "w1",
stats: "",
updated_at: null,
timers: {
"injury": { time: 0, startTime: null, interval: null },
"blood": { time: 0, startTime: null, interval: null }
}
}
this.w2 = {
name: "w2",
stats: "",
updated_at: null,
timers: {
"injury": { time: 0, startTime: null, interval: null },
"blood": { time: 0, startTime: null, interval: null }
}
}
// Initial values
this.updateJsValues()
// Set up debounced handlers for text areas
this.debouncedW1Handler = this.debounce((el) => this.handleTextAreaInput(el, this.w1), 400)
this.debouncedW2Handler = this.debounce((el) => this.handleTextAreaInput(el, this.w2), 400)
// Set up text area event listeners
this.w1StatTarget.addEventListener('input', (event) => this.debouncedW1Handler(event.target))
this.w2StatTarget.addEventListener('input', (event) => this.debouncedW2Handler(event.target))
// Initialize from local storage
this.initializeFromLocalStorage()
// Setup ActionCable
if (this.matchIdValue) {
this.setupSubscription(this.matchIdValue)
}
}
disconnect() {
this.cleanupSubscription()
}
// Match stats core functionality
updateStats(wrestler, text) {
if (!wrestler) {
console.error("updateStats called with undefined wrestler")
return
}
wrestler.stats += text + " "
wrestler.updated_at = new Date().toISOString()
this.updateHtmlValues()
this.saveToLocalStorage(wrestler)
if (!this.isConnected) this.pendingLocalSync[wrestler.name] = true
// Send the update via Action Cable if subscribed
if (this.matchSubscription) {
let payload = {}
if (wrestler.name === 'w1') payload.new_w1_stat = wrestler.stats
else if (wrestler.name === 'w2') payload.new_w2_stat = wrestler.stats
if (Object.keys(payload).length > 0) {
console.log('[ActionCable] updateStats performing send_stat:', payload)
this.matchSubscription.perform('send_stat', payload)
}
} else {
console.warn('[ActionCable] updateStats called but matchSubscription is null.')
}
}
// Specific methods for updating each wrestler
updateW1Stats(event) {
const text = event.currentTarget.dataset.matchDataText || ''
this.updateStats(this.w1, text)
}
updateW2Stats(event) {
const text = event.currentTarget.dataset.matchDataText || ''
this.updateStats(this.w2, text)
}
// End period action
endPeriod() {
this.updateStats(this.w1, '|End Period|')
this.updateStats(this.w2, '|End Period|')
}
handleTextAreaInput(textAreaElement, wrestler) {
const newValue = textAreaElement.value
console.log(`Text area input detected for ${wrestler.name}:`, newValue.substring(0, 50) + "...")
// Update the internal JS object
wrestler.stats = newValue
wrestler.updated_at = new Date().toISOString()
if (!this.isConnected) this.pendingLocalSync[wrestler.name] = true
// Save to localStorage
this.saveToLocalStorage(wrestler)
// Send the update via Action Cable if subscribed
if (this.matchSubscription) {
let payload = {}
if (wrestler.name === 'w1') {
payload.new_w1_stat = wrestler.stats
} else if (wrestler.name === 'w2') {
payload.new_w2_stat = wrestler.stats
}
if (Object.keys(payload).length > 0) {
console.log('[ActionCable] Performing send_stat from textarea with payload:', payload)
this.matchSubscription.perform('send_stat', payload)
}
}
}
// Timer functions
startTimer(wrestler, timerKey) {
const timer = wrestler.timers[timerKey]
if (timer.interval) return // Prevent multiple intervals
timer.startTime = Date.now()
timer.interval = setInterval(() => {
const elapsedSeconds = Math.floor((Date.now() - timer.startTime) / 1000)
this.updateTimerDisplay(wrestler, timerKey, timer.time + elapsedSeconds)
}, 1000)
}
stopTimer(wrestler, timerKey) {
const timer = wrestler.timers[timerKey]
if (!timer.interval || !timer.startTime) return
clearInterval(timer.interval)
const elapsedSeconds = Math.floor((Date.now() - timer.startTime) / 1000)
timer.time += elapsedSeconds
timer.interval = null
timer.startTime = null
this.saveToLocalStorage(wrestler)
this.updateTimerDisplay(wrestler, timerKey, timer.time)
this.updateStatsBox(wrestler, timerKey, elapsedSeconds)
}
resetTimer(wrestler, timerKey) {
const timer = wrestler.timers[timerKey]
this.stopTimer(wrestler, timerKey)
timer.time = 0
this.updateTimerDisplay(wrestler, timerKey, 0)
this.saveToLocalStorage(wrestler)
}
// Timer control methods for W1
startW1InjuryTimer() {
this.startTimer(this.w1, 'injury')
}
stopW1InjuryTimer() {
this.stopTimer(this.w1, 'injury')
}
resetW1InjuryTimer() {
this.resetTimer(this.w1, 'injury')
}
startW1BloodTimer() {
this.startTimer(this.w1, 'blood')
}
stopW1BloodTimer() {
this.stopTimer(this.w1, 'blood')
}
resetW1BloodTimer() {
this.resetTimer(this.w1, 'blood')
}
// Timer control methods for W2
startW2InjuryTimer() {
this.startTimer(this.w2, 'injury')
}
stopW2InjuryTimer() {
this.stopTimer(this.w2, 'injury')
}
resetW2InjuryTimer() {
this.resetTimer(this.w2, 'injury')
}
startW2BloodTimer() {
this.startTimer(this.w2, 'blood')
}
stopW2BloodTimer() {
this.stopTimer(this.w2, 'blood')
}
resetW2BloodTimer() {
this.resetTimer(this.w2, 'blood')
}
updateTimerDisplay(wrestler, timerKey, totalTime) {
const elementId = `${wrestler.name}-${timerKey}-time`
const element = document.getElementById(elementId)
if (element) {
element.innerText = `${Math.floor(totalTime / 60)}m ${totalTime % 60}s`
}
}
updateStatsBox(wrestler, timerKey, elapsedSeconds) {
const timerType = timerKey.includes("injury") ? "Injury Time" : "Blood Time"
const formattedTime = `${Math.floor(elapsedSeconds / 60)}m ${elapsedSeconds % 60}s`
this.updateStats(wrestler, `${timerType}: ${formattedTime}`)
}
// Utility functions
generateKey(wrestler_name) {
return `${wrestler_name}-${this.tournamentIdValue}-${this.boutNumberValue}`
}
loadFromLocalStorage(wrestler_name) {
const key = this.generateKey(wrestler_name)
return loadJson(localStorage, key)
}
saveToLocalStorage(person) {
const key = this.generateKey(person.name)
const data = {
stats: person.stats,
updated_at: person.updated_at,
timers: person.timers
}
saveJson(localStorage, key, data, { ttlMs: MATCH_DATA_TTL_MS })
}
updateHtmlValues() {
if (this.w1StatTarget) this.w1StatTarget.value = this.w1.stats
if (this.w2StatTarget) this.w2StatTarget.value = this.w2.stats
}
updateJsValues() {
if (this.w1StatTarget) this.w1.stats = this.w1StatTarget.value
if (this.w2StatTarget) this.w2.stats = this.w2StatTarget.value
}
debounce(func, wait) {
let timeout
return (...args) => {
clearTimeout(timeout)
timeout = setTimeout(() => func(...args), wait)
}
}
initializeTimers(wrestler) {
for (const timerKey in wrestler.timers) {
this.updateTimerDisplay(wrestler, timerKey, wrestler.timers[timerKey].time)
}
}
initializeFromLocalStorage() {
const w1Data = this.loadFromLocalStorage('w1')
if (w1Data) {
this.w1.stats = w1Data.stats || ''
this.w1.updated_at = w1Data.updated_at
if (w1Data.timers) this.w1.timers = w1Data.timers
this.initializeTimers(this.w1)
}
const w2Data = this.loadFromLocalStorage('w2')
if (w2Data) {
this.w2.stats = w2Data.stats || ''
this.w2.updated_at = w2Data.updated_at
if (w2Data.timers) this.w2.timers = w2Data.timers
this.initializeTimers(this.w2)
}
this.updateHtmlValues()
}
cleanupSubscription() {
if (this.matchSubscription) {
console.log(`[Stats AC Cleanup] Unsubscribing from match channel.`)
try {
this.matchSubscription.unsubscribe()
} catch (e) {
console.error(`[Stats AC Cleanup] Error during unsubscribe:`, e)
}
this.matchSubscription = null
}
}
setupSubscription(matchId) {
this.cleanupSubscription()
console.log(`[Stats AC Setup] Attempting subscription for match ID: ${matchId}`)
// Update status indicator
if (this.statusIndicatorTarget) {
this.statusIndicatorTarget.innerText = "Connecting to server for real-time stat updates..."
this.statusIndicatorTarget.classList.remove('alert-success', 'alert-warning', 'alert-danger')
this.statusIndicatorTarget.classList.add('alert-info')
}
// Exit if we don't have App.cable
if (!window.App || !window.App.cable) {
console.error(`[Stats AC Setup] Error: App.cable is not available.`)
if (this.statusIndicatorTarget) {
this.statusIndicatorTarget.innerText = "Error: WebSockets unavailable. Stats won't update in real-time."
this.statusIndicatorTarget.classList.remove('alert-info', 'alert-success', 'alert-warning')
this.statusIndicatorTarget.classList.add('alert-danger')
}
return
}
this.matchSubscription = App.cable.subscriptions.create(
{
channel: "MatchChannel",
match_id: matchId
},
{
connected: () => {
console.log(`[Stats AC] Connected to MatchStatsChannel for match ID: ${matchId}`)
this.isConnected = true
if (this.statusIndicatorTarget) {
this.statusIndicatorTarget.innerText = "Connected: Stats will update in real-time."
this.statusIndicatorTarget.classList.remove('alert-info', 'alert-warning', 'alert-danger')
this.statusIndicatorTarget.classList.add('alert-success')
}
this.sendCurrentStatsOnReconnect()
},
disconnected: () => {
console.log(`[Stats AC] Disconnected from MatchStatsChannel`)
this.isConnected = false
if (this.statusIndicatorTarget) {
this.statusIndicatorTarget.innerText = "Disconnected: Stats updates paused."
this.statusIndicatorTarget.classList.remove('alert-info', 'alert-success', 'alert-danger')
this.statusIndicatorTarget.classList.add('alert-warning')
}
},
received: (data) => {
console.log(`[Stats AC] Received data:`, data)
// Update w1 stats
if (data.w1_stat !== undefined && this.w1StatTarget) {
console.log(`[Stats AC] Updating w1_stat: ${data.w1_stat.substring(0, 30)}...`)
if (!this.pendingLocalSync.w1 || data.w1_stat === this.w1.stats) {
this.w1.stats = data.w1_stat
this.w1StatTarget.value = data.w1_stat
this.pendingLocalSync.w1 = false
} else {
console.log('[Stats AC] Skipping w1_stat overwrite due to pending local changes.')
}
}
// Update w2 stats
if (data.w2_stat !== undefined && this.w2StatTarget) {
console.log(`[Stats AC] Updating w2_stat: ${data.w2_stat.substring(0, 30)}...`)
if (!this.pendingLocalSync.w2 || data.w2_stat === this.w2.stats) {
this.w2.stats = data.w2_stat
this.w2StatTarget.value = data.w2_stat
this.pendingLocalSync.w2 = false
} else {
console.log('[Stats AC] Skipping w2_stat overwrite due to pending local changes.')
}
}
},
receive_error: (error) => {
console.error(`[Stats AC] Error:`, error)
this.matchSubscription = null
if (this.statusIndicatorTarget) {
this.statusIndicatorTarget.innerText = "Error: Connection issue. Stats won't update in real-time."
this.statusIndicatorTarget.classList.remove('alert-info', 'alert-success', 'alert-warning')
this.statusIndicatorTarget.classList.add('alert-danger')
}
}
}
)
}
sendCurrentStatsOnReconnect() {
if (!this.matchSubscription) return
const payload = {}
if (typeof this.w1?.stats === 'string' && this.w1.stats.length > 0) {
payload.new_w1_stat = this.w1.stats
this.pendingLocalSync.w1 = true
}
if (typeof this.w2?.stats === 'string' && this.w2.stats.length > 0) {
payload.new_w2_stat = this.w2.stats
this.pendingLocalSync.w2 = true
}
if (Object.keys(payload).length > 0) {
console.log('[ActionCable] Reconnect sync: sending current stats payload:', payload)
this.matchSubscription.perform('send_stat', payload)
} else {
console.log('[ActionCable] Reconnect sync: no local stats to send.')
}
}
}

View File

@@ -0,0 +1,297 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [
"winType", "overtimeSelect", "winnerSelect", "submitButton", "dynamicScoreInput",
"finalScoreField", "validationAlerts", "pinTimeTip"
]
static values = {
winnerScore: { 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")
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()
}
winnerChanged() {
this.validateForm()
}
updateScoreInput() {
if (this.finishedValue) return
const winType = this.winTypeTarget.value
this.dynamicScoreInputTarget.innerHTML = ""
// Add section header
const header = document.createElement("h5")
header.innerText = `Score Input for ${winType}`
header.classList.add("mt-2", "mb-3")
this.dynamicScoreInputTarget.appendChild(header)
if (winType === "Pin") {
this.pinTimeTipTarget.style.display = "block"
const minuteInput = this.createTextInput("minutes", "Minutes (MM)", "Pin Time Minutes")
const secondInput = this.createTextInput("seconds", "Seconds (SS)", "Pin Time Seconds")
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")
inputs.forEach(input => {
input.addEventListener("input", () => {
this.updatePinTimeScore()
this.validateForm()
})
})
this.updatePinTimeScore()
} else if (["Decision", "Major", "Tech Fall"].includes(winType)) {
this.pinTimeTipTarget.style.display = "none"
const winnerScoreInput = this.createTextInput(
"winner-score",
"Winner's Score",
"Enter the winner's score"
)
const loserScoreInput = this.createTextInput(
"loser-score",
"Loser's Score",
"Enter the loser's score"
)
this.dynamicScoreInputTarget.appendChild(winnerScoreInput)
this.dynamicScoreInputTarget.appendChild(loserScoreInput)
// Restore stored values
const winnerInput = winnerScoreInput.querySelector("input")
const loserInput = loserScoreInput.querySelector("input")
winnerInput.value = this.winnerScoreValue
loserInput.value = this.loserScoreValue
// Add event listeners to the new inputs
winnerInput.addEventListener("input", (event) => {
this.winnerScoreValue = event.target.value || "0"
this.updatePointScore()
this.validateForm()
})
loserInput.addEventListener("input", (event) => {
this.loserScoreValue = event.target.value || "0"
this.updatePointScore()
this.validateForm()
})
this.updatePointScore()
} else {
// For other types (forfeit, etc.), clear the input and hide pin time tip
this.pinTimeTipTarget.style.display = "none"
this.finalScoreFieldTarget.value = ""
// Show message for non-score win types
const message = document.createElement("p")
message.innerText = `No score required for ${winType} win type.`
message.classList.add("text-muted")
this.dynamicScoreInputTarget.appendChild(message)
}
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")
if (minuteInput && secondInput) {
const minutes = (minuteInput.value || "0").padStart(2, "0")
const seconds = (secondInput.value || "0").padStart(2, "0")
this.finalScoreFieldTarget.value = `${minutes}:${seconds}`
// Validate after updating pin time
this.validateForm()
}
}
updatePointScore() {
const winnerScore = this.winnerScoreValue || "0"
const loserScore = this.loserScoreValue || "0"
this.finalScoreFieldTarget.value = `${winnerScore}-${loserScore}`
// Validate immediately after updating scores
this.validateForm()
}
validateForm() {
const winType = this.winTypeTarget.value
const winner = this.winnerSelectTarget?.value
let isValid = true
let alertMessage = ""
let winTypeShouldBe = "Decision"
// Clear previous validation messages
this.validationAlertsTarget.innerHTML = ""
this.validationAlertsTarget.style.display = "none"
this.validationAlertsTarget.classList.remove("alert", "alert-danger", "p-3")
if (["Decision", "Major", "Tech Fall"].includes(winType)) {
// Get scores and ensure they're valid numbers
const winnerScore = parseInt(this.winnerScoreValue || "0", 10)
const loserScore = parseInt(this.loserScoreValue || "0", 10)
console.log(`Validating scores: winner=${winnerScore}, loser=${loserScore}, type=${winType}`)
// Check if winner score > loser score
if (winnerScore <= loserScore) {
isValid = false
alertMessage += "<strong>Error:</strong> Winner's score must be higher than loser's score.<br>"
} else {
// Calculate score difference and determine correct win type
const scoreDifference = winnerScore - loserScore
if (scoreDifference < 8) {
winTypeShouldBe = "Decision"
} else if (scoreDifference >= 8 && scoreDifference < 15) {
winTypeShouldBe = "Major"
} else if (scoreDifference >= 15) {
winTypeShouldBe = "Tech Fall"
}
// Check if selected win type matches the correct one based on score difference
if (winTypeShouldBe !== winType) {
isValid = false
alertMessage += `
<strong>Win Type Error:</strong> Win type should be <strong>${winTypeShouldBe}</strong>.<br>
<ul>
<li>Decisions are wins with a score difference less than 8.</li>
<li>Majors are wins with a score difference between 8 and 14.</li>
<li>Tech Falls are wins with a score difference of 15 or more.</li>
</ul>
`
}
}
}
// Check if a winner is selected
if (!winner) {
isValid = false
alertMessage += "<strong>Error:</strong> Please select a winner.<br>"
}
// Display validation messages if any
if (alertMessage) {
this.validationAlertsTarget.innerHTML = alertMessage
this.validationAlertsTarget.style.display = "block"
this.validationAlertsTarget.classList.add("alert", "alert-danger", "p-3")
}
// Enable/disable submit button based on validation result
this.submitButtonTarget.disabled = !isValid
// Return validation result for potential use elsewhere
return isValid
}
createTextInput(id, placeholder, label) {
const container = document.createElement("div")
container.classList.add("form-group", "mb-2")
const inputLabel = document.createElement("label")
inputLabel.innerText = label
inputLabel.classList.add("form-label")
inputLabel.setAttribute("for", id)
const input = document.createElement("input")
input.type = "text"
input.id = id
input.placeholder = placeholder
input.classList.add("form-control")
input.style.width = "100%"
input.style.maxWidth = "400px"
container.appendChild(inputLabel)
container.appendChild(input)
return container
}
confirmWinner(event) {
const winnerSelect = this.winnerSelectTarget;
const selectedOption = winnerSelect.options[winnerSelect.selectedIndex];
if (!confirm('Is the name of the winner ' + selectedOption.text + '?')) {
event.preventDefault();
}
}
}

View File

@@ -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 `<span class="label label-default">${label}</span>`
}
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")}`
}
}

View File

@@ -0,0 +1,142 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [
"w1Stats", "w2Stats", "winner", "winType",
"score", "finished", "statusIndicator", "scoreboardContainer"
]
static values = {
matchId: Number
}
connect() {
console.log("Match spectate controller connected")
// Setup ActionCable connection if match ID is available
if (this.matchIdValue) {
this.setupSubscription(this.matchIdValue)
} else {
console.warn("No match ID provided for spectate controller")
}
}
disconnect() {
this.cleanupSubscription()
}
// Clean up the existing subscription
cleanupSubscription() {
if (this.matchSubscription) {
console.log('[Spectator AC Cleanup] Unsubscribing...')
this.matchSubscription.unsubscribe()
this.matchSubscription = null
}
}
// Set up the Action Cable subscription for a given matchId
setupSubscription(matchId) {
this.cleanupSubscription() // Ensure clean state
console.log(`[Spectator AC Setup] Attempting subscription for match ID: ${matchId}`)
if (typeof App === 'undefined' || typeof App.cable === 'undefined') {
console.error("[Spectator AC Setup] Action Cable consumer not found.")
if (this.hasStatusIndicatorTarget) {
this.statusIndicatorTarget.textContent = "Error: AC Not Loaded"
this.statusIndicatorTarget.classList.remove('text-dark', 'text-success')
this.statusIndicatorTarget.classList.add('alert-danger', 'text-danger')
}
return
}
// Set initial connecting state for indicator
if (this.hasStatusIndicatorTarget) {
this.statusIndicatorTarget.textContent = "Connecting to backend for live updates..."
this.statusIndicatorTarget.classList.remove('alert-danger', 'alert-success', 'text-danger', 'text-success')
this.statusIndicatorTarget.classList.add('alert-secondary', 'text-dark')
}
// Assign to the instance property
this.matchSubscription = App.cable.subscriptions.create(
{ channel: "MatchChannel", match_id: matchId },
{
initialized: () => {
console.log(`[Spectator AC Callback] Initialized: ${matchId}`)
// Set connecting state again in case of retry
if (this.hasStatusIndicatorTarget) {
this.statusIndicatorTarget.textContent = "Connecting to backend for live updates..."
this.statusIndicatorTarget.classList.remove('alert-danger', 'alert-success', 'text-danger', 'text-success')
this.statusIndicatorTarget.classList.add('alert-secondary', 'text-dark')
}
},
connected: () => {
console.log(`[Spectator AC Callback] CONNECTED: ${matchId}`)
if (this.hasStatusIndicatorTarget) {
this.statusIndicatorTarget.textContent = "Connected to backend for live updates..."
this.statusIndicatorTarget.classList.remove('alert-danger', 'alert-secondary', 'text-danger', 'text-dark')
this.statusIndicatorTarget.classList.add('alert-success')
}
try {
this.matchSubscription.perform('request_sync')
} catch (e) {
console.error('[Spectator AC] request_sync perform failed:', e)
}
},
disconnected: () => {
console.log(`[Spectator AC Callback] Disconnected: ${matchId}`)
if (this.hasStatusIndicatorTarget) {
this.statusIndicatorTarget.textContent = "Disconnected from backend for live updates. Retrying..."
this.statusIndicatorTarget.classList.remove('alert-success', 'alert-secondary', 'text-success', 'text-dark')
this.statusIndicatorTarget.classList.add('alert-danger')
}
},
rejected: () => {
console.error(`[Spectator AC Callback] REJECTED: ${matchId}`)
if (this.hasStatusIndicatorTarget) {
this.statusIndicatorTarget.textContent = "Connection to backend rejected"
this.statusIndicatorTarget.classList.remove('alert-success', 'alert-secondary', 'text-success', 'text-dark')
this.statusIndicatorTarget.classList.add('alert-danger')
}
this.matchSubscription = null
},
received: (data) => {
console.log("[Spectator AC Callback] Received:", data)
this.updateDisplayElements(data)
}
}
)
}
// Update UI elements with received data
updateDisplayElements(data) {
// Update display elements if they exist and data is provided
if (data.w1_stat !== undefined && this.hasW1StatsTarget) {
this.w1StatsTarget.textContent = data.w1_stat
}
if (data.w2_stat !== undefined && this.hasW2StatsTarget) {
this.w2StatsTarget.textContent = data.w2_stat
}
if (data.score !== undefined && this.hasScoreTarget) {
this.scoreTarget.textContent = data.score || '-'
}
if (data.win_type !== undefined && this.hasWinTypeTarget) {
this.winTypeTarget.textContent = data.win_type || '-'
}
if (data.winner_name !== undefined && this.hasWinnerTarget) {
this.winnerTarget.textContent = data.winner_name || (data.winner_id ? `ID: ${data.winner_id}` : '-')
} else if (data.winner_id !== undefined && this.hasWinnerTarget) {
this.winnerTarget.textContent = data.winner_id ? `ID: ${data.winner_id}` : '-'
}
if (data.finished !== undefined && this.hasFinishedTarget) {
this.finishedTarget.textContent = data.finished ? 'Yes' : 'No'
if (this.hasScoreboardContainerTarget) {
this.scoreboardContainerTarget.style.display = data.finished ? 'none' : 'block'
}
}
}
}

View File

@@ -0,0 +1,804 @@
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,
MATCH_DATA_TTL_MS
} 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 `
<div style="margin-top: 12px;">
<strong>${section.title}</strong>
<div class="text-muted" style="margin: 4px 0 8px;">${section.description}</div>
<div>${content}</div>
</div>
`
}).join('<hr>')
}
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 `<button type="button" class="btn ${buttonClass} btn-sm" data-match-state-button="score-action" data-participant-key="${participantKey}" data-action-key="${actionKey}">${action.label}</button>`
}).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 `
<div style="margin-bottom: 12px;">
<strong>${timerConfig.label}</strong>: <span data-match-state-timer-display="${participantKey}-${timerKey}">${this.formatClock(this.currentAuxiliaryTimerSeconds(participantKey, timerKey))}</span>
<div class="btn-group btn-group-xs" style="margin-left: 8px;">
<button type="button" class="btn btn-default" data-match-state-button="timer-action" data-participant-key="${participantKey}" data-timer-key="${timerKey}" data-timer-command="start">Start</button>
<button type="button" class="btn btn-default" data-match-state-button="timer-action" data-participant-key="${participantKey}" data-timer-key="${timerKey}" data-timer-command="stop">Stop</button>
<button type="button" class="btn btn-default" data-match-state-button="timer-action" data-participant-key="${participantKey}" data-timer-key="${timerKey}" data-timer-command="reset">Reset</button>
</div>
<div class="text-muted" data-match-state-timer-status="${participantKey}-${timerKey}">Max ${this.formatClock(timerConfig.maxSeconds)}</div>
</div>
`
}).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 '<p class="text-muted">No events yet.</p>'
}
return eventLogSections(this.config, this.state, (seconds) => this.formatClock(seconds)).map((section) => {
const items = section.items.map((eventRecord) => {
return `
<div class="well well-sm" style="margin-bottom: 8px;">
<div style="display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px;">
<div style="flex: 1 1 260px; min-width: 0; overflow-wrap: anywhere;">
<strong>${eventRecord.colorLabel}</strong> ${eventRecord.actionLabel}
<span class="text-muted">(${eventRecord.clockLabel})</span>
</div>
<div style="display: flex; flex-wrap: wrap; gap: 8px; flex: 0 0 auto;">
<button type="button" class="btn btn-xs btn-link" data-match-state-button="swap-event" data-event-id="${eventRecord.id}">Swap</button>
<button type="button" class="btn btn-xs btn-link" data-match-state-button="delete-event" data-event-id="${eventRecord.id}">Delete</button>
</div>
</div>
</div>
`
}).join("")
return `
<div style="margin-bottom: 16px;">
<div style="display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px;">
<h5 style="margin: 0;">${section.label}</h5>
<button type="button" class="btn btn-xs btn-link" data-match-state-button="swap-phase" data-phase-key="${section.key}">Swap Entire Period</button>
</div>
${items}
</div>
`
}).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 `
<div class="well well-sm">
<div><strong>${viewModel.label}</strong></div>
<div class="text-muted" style="margin: 6px 0;">${viewModel.selectionText}</div>
<div>${viewModel.buttons.map((button) => `
<button
type="button"
class="btn ${button.buttonClass} btn-sm"
data-match-state-button="choice-action"
data-participant-key="${button.participantKey}"
data-choice-key="${button.choiceKey}">
${button.text}
</button>
`).join(" ")}</div>
</div>
`
}
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, { ttlMs: MATCH_DATA_TTL_MS })
}
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
})
}
}

View File

@@ -0,0 +1,70 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["stream", "statusIndicator"]
connect() {
this.setupSubscription()
}
disconnect() {
this.cleanupSubscription()
}
setupSubscription() {
this.cleanupSubscription()
this.setStatus("Connecting to server for real-time bout board updates...", "info")
if (!this.hasStreamTarget) {
this.setStatus("Error: Stream source not found.", "danger")
return
}
const signedStreamName = this.streamTarget.getAttribute("signed-stream-name")
if (!signedStreamName) {
this.setStatus("Error: Invalid stream source.", "danger")
return
}
if (!window.App || !window.App.cable) {
this.setStatus("Error: WebSockets unavailable. Bout board won't update in real-time. Refresh the page to update.", "danger")
return
}
this.subscription = App.cable.subscriptions.create(
{
channel: "Turbo::StreamsChannel",
signed_stream_name: signedStreamName
},
{
connected: () => {
this.setStatus("Connected: Bout board updating in real-time.", "success")
},
disconnected: () => {
this.setStatus("Disconnected: Live bout board updates paused.", "warning")
},
rejected: () => {
this.setStatus("Error: Live bout board connection rejected.", "danger")
}
}
)
}
cleanupSubscription() {
if (!this.subscription) return
this.subscription.unsubscribe()
this.subscription = null
}
setStatus(message, type) {
if (!this.hasStatusIndicatorTarget) return
this.statusIndicatorTarget.innerText = message
this.statusIndicatorTarget.classList.remove("alert-secondary", "alert-info", "alert-success", "alert-warning", "alert-danger")
if (type === "success") this.statusIndicatorTarget.classList.add("alert-success")
else if (type === "warning") this.statusIndicatorTarget.classList.add("alert-warning")
else if (type === "danger") this.statusIndicatorTarget.classList.add("alert-danger")
else this.statusIndicatorTarget.classList.add("alert-info")
}
}

View File

@@ -0,0 +1,68 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [
"w1Takedown", "w1Escape", "w1Reversal", "w1Nf2", "w1Nf3", "w1Nf4", "w1Nf5", "w1Penalty", "w1Penalty2",
"w1Top", "w1Bottom", "w1Neutral", "w1Defer", "w1Stalling", "w1Caution", "w1ColorSelect",
"w2Takedown", "w2Escape", "w2Reversal", "w2Nf2", "w2Nf3", "w2Nf4", "w2Nf5", "w2Penalty", "w2Penalty2",
"w2Top", "w2Bottom", "w2Neutral", "w2Defer", "w2Stalling", "w2Caution", "w2ColorSelect"
]
connect() {
console.log("Wrestler color controller connected")
this.initializeColors()
}
initializeColors() {
// Set initial colors based on select values
this.changeW1Color({ preventRecursion: true })
}
changeW1Color(options = {}) {
const color = this.w1ColorSelectTarget.value
this.setElementsColor("w1", color)
// Update w2 color to the opposite color unless we're already in a recursive call
if (!options.preventRecursion) {
const oppositeColor = color === "green" ? "red" : "green"
this.w2ColorSelectTarget.value = oppositeColor
this.setElementsColor("w2", oppositeColor)
}
}
changeW2Color(options = {}) {
const color = this.w2ColorSelectTarget.value
this.setElementsColor("w2", color)
// Update w1 color to the opposite color unless we're already in a recursive call
if (!options.preventRecursion) {
const oppositeColor = color === "green" ? "red" : "green"
this.w1ColorSelectTarget.value = oppositeColor
this.setElementsColor("w1", oppositeColor)
}
}
setElementsColor(wrestler, color) {
// Define which targets to update for each wrestler
const targetSuffixes = [
"Takedown", "Escape", "Reversal", "Nf2", "Nf3", "Nf4", "Nf5", "Penalty", "Penalty2",
"Top", "Bottom", "Neutral", "Defer", "Stalling", "Caution"
]
// For each target type, update the class
targetSuffixes.forEach(suffix => {
const targetName = `${wrestler}${suffix}Target`
if (this[targetName]) {
// Remove existing color classes
this[targetName].classList.remove("btn-success", "btn-danger")
// Add new color class
if (color === "green") {
this[targetName].classList.add("btn-success")
} else if (color === "red") {
this[targetName].classList.add("btn-danger")
}
}
})
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,116 @@
export const MATCH_DATA_TTL_MS = 48 * 60 * 60 * 1000
export const SHORT_LIVED_TTL_MS = 4 * 60 * 60 * 1000
const STORAGE_MARKER = "__wrestlingAppStorage"
export function loadJson(storage, key) {
try {
const rawValue = storage.getItem(key)
if (!rawValue) return null
const parsed = JSON.parse(rawValue)
if (!isExpiringStorageValue(parsed)) return parsed
if (isExpired(parsed)) {
storage.removeItem(key)
return null
}
return parsed.value
} catch (_error) {
return null
}
}
export function saveJson(storage, key, value, options = {}) {
try {
const valueToStore = options.ttlMs
? expiringStorageValue(value, options.ttlMs)
: value
storage.setItem(key, JSON.stringify(valueToStore))
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
}
export function cleanupExpiredLocalStorage(storage, now = Date.now()) {
try {
const keys = []
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index)
if (key && ttlForStorageKey(key)) keys.push(key)
}
keys.forEach((key) => cleanupStorageKey(storage, key, now))
} catch (_error) {
}
}
function cleanupStorageKey(storage, key, now) {
const ttlMs = ttlForStorageKey(key)
if (!ttlMs) return
const rawValue = storage.getItem(key)
if (!rawValue) return
try {
const parsed = JSON.parse(rawValue)
if (isExpiringStorageValue(parsed)) {
if (isExpired(parsed, now)) storage.removeItem(key)
return
}
const legacyUpdatedAt = Date.parse(parsed?.updated_at)
if (legacyUpdatedAt && now - legacyUpdatedAt > ttlMs) {
storage.removeItem(key)
return
}
storage.setItem(key, JSON.stringify(expiringStorageValue(parsed, ttlMs, legacyUpdatedAt || now)))
} catch (_error) {
storage.removeItem(key)
}
}
function expiringStorageValue(value, ttlMs, storedAt = Date.now()) {
return {
[STORAGE_MARKER]: true,
expiresAt: storedAt + ttlMs,
value
}
}
function isExpiringStorageValue(value) {
return value && typeof value === "object" && value[STORAGE_MARKER] === true
}
function isExpired(value, now = Date.now()) {
return Number(value.expiresAt) <= now
}
function ttlForStorageKey(key) {
if (key.startsWith("match-state:")) return MATCH_DATA_TTL_MS
if (/^w[12]-\d+-\d+$/.test(key)) return MATCH_DATA_TTL_MS
if (key.startsWith("mat-selected-bout:")) return SHORT_LIVED_TTL_MS
if (key.startsWith("mat-last-match-result:")) return SHORT_LIVED_TTL_MS
return null
}

View File

@@ -1,4 +0,0 @@
# Place all the behaviors and hooks related to the matching controller here.
# All this logic will automatically be available in application.js.
# You can use CoffeeScript in this file: http://coffeescript.org/

View File

@@ -1,3 +0,0 @@
# Place all the behaviors and hooks related to the matching controller here.
# All this logic will automatically be available in application.js.
# You can use CoffeeScript in this file: http://coffeescript.org/

View File

@@ -1,3 +0,0 @@
# Place all the behaviors and hooks related to the matching controller here.
# All this logic will automatically be available in application.js.
# You can use CoffeeScript in this file: http://coffeescript.org/

View File

@@ -1,3 +0,0 @@
# Place all the behaviors and hooks related to the matching controller here.
# All this logic will automatically be available in application.js.
# You can use CoffeeScript in this file: http://coffeescript.org/

View File

@@ -1,3 +0,0 @@
# Place all the behaviors and hooks related to the matching controller here.
# All this logic will automatically be available in application.js.
# You can use CoffeeScript in this file: http://coffeescript.org/

View File

@@ -1,3 +0,0 @@
# Place all the behaviors and hooks related to the matching controller here.
# All this logic will automatically be available in application.js.
# You can use CoffeeScript in this file: http://coffeescript.org/

View File

@@ -1,3 +0,0 @@
# Place all the behaviors and hooks related to the matching controller here.
# All this logic will automatically be available in application.js.
# You can use CoffeeScript in this file: http://coffeescript.org/

View File

@@ -1,3 +0,0 @@
// Place all the styles related to the Admin controller here.
// They will automatically be included in application.css.
// You can use Sass (SCSS) here: http://sass-lang.com/

View File

@@ -1,4 +0,0 @@
/*
Place all the styles related to the matching controller here.
They will automatically be included in application.css.
*/

View File

@@ -1,22 +1,17 @@
/* /* relative pathing from /vender/assets/stylesheets = / */
* This is a manifest file that'll be compiled into application.css, which will include all the files @import url("/bootstrap.min.css");
* listed below. @import url("/bootstrap-theme.min.css");
* @import url("/fontawesome/all.css");
* Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, @import url("/custom.css");
* or vendor/assets/stylesheets of plugins, if any, can be referenced here using a relative path. @import url("/scaffolds.css");
*
* You're free to add application-wide styles to this file and they'll appear at the bottom of the
* compiled file so the styles you add here take precedence over styles defined in any styles
* defined in the other CSS/SCSS files in this directory. It is generally better to create a new
* file per style scope.
*
* For some reason this needs to be above bootstrap for the zindex of the main navbar to work.
* With it lower, bootstraps css overrides it.
*= require custom
* Bootstrap 3.3.6 in vendor/assets/stylesheets
*= require bootstrap.min.css
*= require bootstrap-theme.min.css
*= require_tree .
*= require_self
*/
@font-face {
font-family: 'Font Awesome 5 Brands';
/* relative pathing from /vender/assets/stylesheets = / */
src: url("/webfonts/fa-brands-400.eot");
src: url("/webfonts/fa-brands-400.eot?#iefix") format("embedded-opentype"),
url("/webfonts/fa-brands-400.woff2") format("woff2"),
url("/webfonts/fa-brands-400.woff") format("woff"),
url("/webfonts/fa-brands-400.ttf") format("truetype"),
url("/webfonts/fa-brands-400.svg#fontawesome") format("svg");
}

View File

@@ -1,3 +0,0 @@
// Place all the styles related to the Matches controller here.
// They will automatically be included in application.css.
// You can use Sass (SCSS) here: http://sass-lang.com/

View File

@@ -1,3 +0,0 @@
// Place all the styles related to the Mats controller here.
// They will automatically be included in application.css.
// You can use Sass (SCSS) here: http://sass-lang.com/

View File

@@ -1,3 +0,0 @@
// Place all the styles related to the Schools controller here.
// They will automatically be included in application.css.
// You can use Sass (SCSS) here: http://sass-lang.com/

View File

@@ -1,3 +0,0 @@
// Place all the styles related to the StaticPages controller here.
// They will automatically be included in application.css.
// You can use Sass (SCSS) here: http://sass-lang.com/

View File

@@ -1,3 +0,0 @@
// Place all the styles related to the tournaments controller here.
// They will automatically be included in application.css.
// You can use Sass (SCSS) here: http://sass-lang.com/

View File

@@ -1,3 +0,0 @@
// Place all the styles related to the Weights controller here.
// They will automatically be included in application.css.
// You can use Sass (SCSS) here: http://sass-lang.com/

View File

@@ -1,3 +0,0 @@
// Place all the styles related to the Wrestlers controller here.
// They will automatically be included in application.css.
// You can use Sass (SCSS) here: http://sass-lang.com/

View File

@@ -0,0 +1,4 @@
module ApplicationCable
class Channel < ActionCable::Channel::Base
end
end

View File

@@ -0,0 +1,4 @@
module ApplicationCable
class Connection < ActionCable::Connection::Base
end
end

View File

@@ -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

View File

@@ -0,0 +1,110 @@
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?}"
if @match
stream_for @match
else
Rails.logger.warn "[MatchChannel] Match not found for ID: #{params[:match_id]}. Subscription may fail."
# You might want to reject the subscription if the match isn't found
# reject
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
# Called when client sends data with action: 'send_stat'
def send_stat(data)
# Explicit check for @match at the start
unless @match
Rails.logger.error "[MatchChannel] Error: send_stat called but @match is nil. Client params on sub: #{params[:match_id]}"
return # Stop if no match context
end
Rails.logger.info "[MatchChannel] Received send_stat for match #{@match.id} with data: #{data.inspect}"
# Prepare attributes to update
attributes_to_update = {}
attributes_to_update[:w1_stat] = data['new_w1_stat'] if data.key?('new_w1_stat')
attributes_to_update[:w2_stat] = data['new_w2_stat'] if data.key?('new_w2_stat')
if attributes_to_update.present?
# Persist the changes to the database
# Note: Consider background job or throttling for very high frequency updates
begin
if @match.update(attributes_to_update)
Rails.logger.info "[MatchChannel] Updated match #{@match.id} stats in DB: #{attributes_to_update.keys.join(', ')}"
# Prepare payload for broadcast (using potentially updated values from @match)
payload = {
w1_stat: @match.w1_stat,
w2_stat: @match.w2_stat
}.compact
if payload.present?
Rails.logger.info "[MatchChannel] Broadcasting DB-persisted stats to match #{@match.id} with payload: #{payload.inspect}"
MatchChannel.broadcast_to(@match, payload)
else
Rails.logger.info "[MatchChannel] Payload empty after DB update for match #{@match.id}, not broadcasting."
end
else
Rails.logger.error "[MatchChannel] Failed to update match #{@match.id} stats in DB: #{@match.errors.full_messages.join(', ')}"
end
rescue => e
Rails.logger.error "[MatchChannel] Exception during match update for #{@match.id}: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
end
else
Rails.logger.info "[MatchChannel] No new stat data provided in send_stat for match #{@match.id}, not updating DB or broadcasting."
end
end
# Called when client wants the latest stats immediately after reconnect
def request_sync
unless @match
Rails.logger.error "[MatchChannel] Error: request_sync called but @match is nil. Client params on sub: #{params[:match_id]}"
return
end
payload = {
w1_stat: @match.w1_stat,
w2_stat: @match.w2_stat,
score: @match.score,
win_type: @match.win_type,
winner_name: @match.winner&.name,
winner_id: @match.winner_id,
finished: @match.finished,
scoreboard_state: Rails.cache.read(scoreboard_cache_key)
}.compact
if payload.present?
Rails.logger.info "[MatchChannel] request_sync transmit for match #{@match.id} with payload: #{payload.inspect}"
transmit(payload)
else
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

View File

@@ -14,9 +14,9 @@ class ApiController < ApplicationController
end end
def tournament def tournament
@tournament = Tournament.where(:id => params[:tournament]).includes(:schools,:weights,:mats,:matches,:user,:wrestlers).first @tournament = Tournament.where(:id => params[:tournament]).includes(:user, :mats, :schools, :weights, :matches, wrestlers: [:school, :weight, :matches_as_w1, :matches_as_w2]).first
@schools = @tournament.schools.includes(:wrestlers) @schools = @tournament.schools.includes(wrestlers: [:weight, :matches_as_w1, :matches_as_w2])
@weights = @tournament.weights.includes(:wrestlers) @weights = @tournament.weights.includes(wrestlers: [:school, :matches_as_w1, :matches_as_w2])
@matches = @tournament.matches.includes(:wrestlers,:schools) @matches = @tournament.matches.includes(:wrestlers,:schools)
@mats = @tournament.mats.includes(:matches) @mats = @tournament.mats.includes(:matches)
end end

View File

@@ -4,7 +4,7 @@ class MatAssignmentRulesController < ApplicationController
before_action :set_mat_assignment_rule, only: [:edit, :update, :destroy] before_action :set_mat_assignment_rule, only: [:edit, :update, :destroy]
def index def index
@mat_assignment_rules = @tournament.mat_assignment_rules @mat_assignment_rules = @tournament.mat_assignment_rules.includes(:mat)
@weights_by_id = @tournament.weights.index_by(&:id) # For quick lookup @weights_by_id = @tournament.weights.index_by(&:id) # For quick lookup
end end

View File

@@ -1,6 +1,7 @@
class MatchesController < ApplicationController class MatchesController < ApplicationController
before_action :set_match, only: [:show, :edit, :update, :stat] before_action :set_match, only: [:show, :edit, :update, :stat, :state, :spectate, :edit_assignment, :update_assignment]
before_action :check_access, only: [:edit,:update, :stat] 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
# GET /matches/1.json # GET /matches/1.json
@@ -21,61 +22,103 @@ class MatchesController < ApplicationController
session[:return_path] = "/tournaments/#{@match.tournament.id}/matches" session[:return_path] = "/tournaments/#{@match.tournament.id}/matches"
end end
def stat def stat
# @show_next_bout_button = false 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
session[:return_path] = "/tournaments/#{@tournament.id}/matches"
session[:error_return_path] = "/matches/#{@match.id}/stat"
end end
def state
load_match_stat_context
@match_state_ruleset = "folkstyle_usa"
end
# GET /matches/:id/spectate
def spectate
# Similar to stat, but potentially simplified for read-only view
# We mainly need @match for the view to get the ID
# and maybe initial wrestler names/schools
if @match
@wrestler1_name = @match.w1 ? @match.wrestler1.name : "Not assigned"
@wrestler1_school_name = @match.w1 ? @match.wrestler1.school.name : "N/A"
@wrestler2_name = @match.w2 ? @match.wrestler2.name : "Not assigned"
@wrestler2_school_name = @match.w2 ? @match.wrestler2.school.name : "N/A"
@tournament = @match.tournament
else
# Handle case where match isn't found, perhaps redirect or render error
redirect_to root_path, alert: "Match not found."
end
end
# GET /matches/1/edit_assignment
def edit_assignment
@tournament = @match.tournament
@mats = @tournament.mats.sort_by(&:name)
@current_mat = @match.mat
@current_queue_position = @current_mat&.queue_position_for_match(@match)
session[:return_path] = "/tournaments/#{@tournament.id}/matches"
end
# PATCH /matches/1/update_assignment
def update_assignment
@tournament = @match.tournament
mat_id = params.dig(:match, :mat_id)
queue_position = params.dig(:match, :queue_position)
if mat_id.blank?
Mat.where("queue1 = :match_id OR queue2 = :match_id OR queue3 = :match_id OR queue4 = :match_id", match_id: @match.id)
.find_each { |mat| mat.remove_match_from_queue_and_collapse!(@match.id) }
@match.update(mat_id: nil)
redirect_to session.delete(:return_path) || "/tournaments/#{@tournament.id}/matches", notice: "Match assignment cleared."
return
end
if queue_position.blank?
redirect_to edit_assignment_match_path(@match), alert: "Queue position is required when selecting a mat."
return
end
unless %w[1 2 3 4].include?(queue_position.to_s)
redirect_to edit_assignment_match_path(@match), alert: "Queue position must be between 1 and 4."
return
end
mat = @tournament.mats.find_by(id: mat_id)
unless mat
redirect_to edit_assignment_match_path(@match), alert: "Selected mat was not found."
return
end
mat.assign_match_to_queue!(@match, queue_position)
redirect_to session.delete(:return_path) || "/tournaments/#{@tournament.id}/matches", notice: "Match assignment updated."
end
# PATCH/PUT /matches/1 # PATCH/PUT /matches/1
# PATCH/PUT /matches/1.json # PATCH/PUT /matches/1.json
def update def update
respond_to do |format| respond_to do |format|
if @match.update(match_params) if @match.update(match_params)
if session[:return_path] # Broadcast the update
sanitized_return_path = sanitize_return_path(session[:return_path]) MatchChannel.broadcast_to(
format.html { redirect_to sanitized_return_path, notice: 'Match was successfully updated.' } @match,
session.delete(:return_path) # Remove the session variable {
else w1_stat: @match.w1_stat,
format.html { redirect_to "/tournaments/#{@match.tournament.id}", notice: 'Match was successfully updated.' } w2_stat: @match.w2_stat,
end score: @match.score,
win_type: @match.win_type,
winner_id: @match.winner_id,
winner_name: @match.winner&.name,
finished: @match.finished,
scoreboard_state: Rails.cache.read("tournament:#{@match.tournament_id}:match:#{@match.id}:scoreboard_state")
}
)
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 } format.json { head :no_content }
else else
if session[:error_return_path] error_path = resolve_match_redirect_path(session[:error_return_path]) || "/tournaments/#{@match.tournament.id}"
format.html { redirect_to session.delete(:error_return_path), alert: "Match did not save because: #{@match.errors.full_messages.to_s}" } 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 } 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
end end
end end
end end
@@ -96,11 +139,66 @@ class MatchesController < ApplicationController
authorize! :manage, @match.tournament authorize! :manage, @match.tournament
end 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) uri = URI.parse(path)
params = Rack::Utils.parse_nested_query(uri.query) return nil if uri.scheme.present? || uri.host.present?
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
uri.to_s # Return the full path as a string 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
end end

View File

@@ -1,22 +1,21 @@
class MatsController < ApplicationController class MatsController < ApplicationController
before_action :set_mat, only: [:show, :edit, :update, :destroy, :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, :assign_next_match] before_action :check_access, only: [:new,:create,:update,:destroy,:edit,:show, :state, :scoreboard, :assign_next_match, :select_match]
before_action :check_for_matches, only: [:show]
# GET /mats/1 # GET /mats/1
# GET /mats/1.json # GET /mats/1.json
def show def show
bout_number_param = params[:bout_number] # Read the bout_number from the URL params bout_number_param = params[:bout_number]
@queue_matches = @mat.queue_matches
if bout_number_param @match = if bout_number_param
@show_next_bout_button = false @queue_matches.compact.find { |m| m.bout_number == bout_number_param.to_i }
@match = @mat.unfinished_matches.find { |m| m.bout_number == bout_number_param.to_i }
else else
@show_next_bout_button = true @queue_matches[0]
@match = @mat.unfinished_matches.first
end end
# If a requested bout is no longer queued, fall back to queue1.
@next_match = @mat.unfinished_matches.second # Second unfinished match on the mat @match ||= @queue_matches[0]
@next_match = @queue_matches[1]
@show_next_bout_button = false
@wrestlers = [] @wrestlers = []
if @match if @match
@@ -45,10 +44,33 @@ class MatsController < ApplicationController
@tournament = @match.tournament @tournament = @match.tournament
end 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 session[:error_return_path] = request.original_fullpath
end 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 # GET /mats/new
def new def new
@mat = Mat.new @mat = Mat.new
@@ -82,8 +104,8 @@ class MatsController < ApplicationController
def assign_next_match def assign_next_match
@tournament = @mat.tournament_id @tournament = @mat.tournament_id
respond_to do |format| respond_to do |format|
if @mat.assign_next_match if @mat.advance_queue!
format.html { redirect_to "/tournaments/#{@mat.tournament.id}", notice: "Next Match on Mat #{@mat.name} successfully completed." } format.html { redirect_to "/tournaments/#{@mat.tournament.id}", notice: "Mat #{@mat.name} queue advanced." }
format.json { head :no_content } format.json { head :no_content }
else else
format.html { redirect_to "/tournaments/#{@mat.tournament.id}", alert: "There was an error." } format.html { redirect_to "/tournaments/#{@mat.tournament.id}", alert: "There was an error." }
@@ -141,12 +163,65 @@ class MatsController < ApplicationController
authorize! :manage, @tournament authorize! :manage, @tournament
end end
def sanitize_mat_redirect_path(path)
return nil if path.blank?
def check_for_matches uri = URI.parse(path)
if @mat return nil if uri.scheme.present? || uri.host.present?
if @mat.tournament.matches.empty?
redirect_to "/tournaments/#{@tournament.id}/no_matches" params = Rack::Utils.parse_nested_query(uri.query)
end params.delete("bout_number")
uri.query = params.to_query.presence
uri.to_s
rescue URI::InvalidURIError
nil
end end
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 end

View File

@@ -12,7 +12,7 @@ class SchoolsController < ApplicationController
# GET /schools/1.json # GET /schools/1.json
def show def show
session.delete(:return_path) session.delete(:return_path)
@wrestlers = @school.wrestlers.includes(:deductedPoints,:matches,:weight,:school) @wrestlers = @school.wrestlers.includes(:deductedPoints, :weight, :school, :matches_as_w1, :matches_as_w2)
@tournament = @school.tournament @tournament = @school.tournament
end end
@@ -84,7 +84,7 @@ class SchoolsController < ApplicationController
private private
# Use callbacks to share common setup or constraints between actions. # Use callbacks to share common setup or constraints between actions.
def set_school def set_school
@school = School.where(:id => params[:id]).includes(:tournament,:wrestlers,:deductedPoints,:delegates).first @school = School.includes(:tournament, :delegates, :deductedPoints, wrestlers: [:weight, :deductedPoints, :matches_as_w1, :matches_as_w2]).find_by(id: params[:id])
end end
# Never trust parameters from the scary internet, only allow the white list through. # Never trust parameters from the scary internet, only allow the white list through.

View File

@@ -1,11 +1,11 @@
class StaticPagesController < ApplicationController class StaticPagesController < ApplicationController
def my_tournaments def my_tournaments
tournaments_created = current_user.tournaments tournaments_created = current_user.tournaments.to_a
tournaments_delegated = current_user.delegated_tournaments tournaments_delegated = current_user.delegated_tournaments.to_a
all_tournaments = tournaments_created + tournaments_delegated all_tournaments = tournaments_created + tournaments_delegated
@tournaments = all_tournaments.sort_by{|t| t.days_until_start} @tournaments = all_tournaments.sort_by{|t| t.days_until_start}
@schools = current_user.delegated_schools @schools = current_user.delegated_schools.includes(:tournament)
end end
def not_allowed def not_allowed

View File

@@ -1,13 +1,13 @@
class TournamentsController < ApplicationController 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] 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] 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_access_destroy, only: [:destroy,:delegate,:remove_delegate]
before_action :check_tournament_errors, only: [:generate_matches] before_action :check_tournament_errors, only: [:generate_matches]
before_action :check_for_matches, only: [:all_results,:up_matches,:bracket,:all_brackets] 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 def weigh_in_sheet
@schools = @tournament.schools.includes(wrestlers: :weight)
end end
def calculate_team_scores def calculate_team_scores
@@ -92,12 +92,9 @@ class TournamentsController < ApplicationController
end end
end end
end end
@users_delegates = [] @users_delegates = SchoolDelegate.includes(:user, :school)
@tournament.schools.each do |s| .joins(:school)
s.delegates.each do |d| .where(schools: { tournament_id: @tournament.id })
@users_delegates << d
end
end
end end
def delegate def delegate
@@ -115,11 +112,63 @@ class TournamentsController < ApplicationController
end end
end end
end end
@users_delegates = @tournament.delegates @users_delegates = @tournament.delegates.includes(:user)
end end
def matches def matches
@matches = @tournament.matches.includes(:wrestlers,:schools).sort_by{|m| m.bout_number} per_page = 50
@page = params[:page].to_i > 0 ? params[:page].to_i : 1
offset = (@page - 1) * per_page
matches_table = Match.arel_table
matches_scope = @tournament.matches.order(:bout_number)
if params[:search].present?
wrestlers_table = Wrestler.arel_table
schools_table = School.arel_table
search_terms = params[:search].downcase.split
search_terms.each do |term|
escaped_term = ActiveRecord::Base.sanitize_sql_like(term)
pattern = "%#{escaped_term}%"
matching_wrestler_ids = Wrestler
.joins(:weight)
.left_outer_joins(:school)
.where(weights: { tournament_id: @tournament.id })
.where(
wrestlers_table[:name].matches(pattern)
.or(schools_table[:name].matches(pattern))
)
.distinct
.select(:id)
term_scope = @tournament.matches.where(w1: matching_wrestler_ids)
.or(@tournament.matches.where(w2: matching_wrestler_ids))
if term.match?(/\A\d+\z/)
term_scope = term_scope.or(@tournament.matches.where(bout_number: term.to_i))
end
matches_scope = matches_scope.where(id: term_scope.select(:id))
end
end
@total_count = matches_scope.count
@total_pages = (@total_count / per_page.to_f).ceil
@per_page = per_page
loser1_not_bye = matches_table[:loser1_name].not_eq("BYE").or(matches_table[:loser1_name].eq(nil))
loser2_not_bye = matches_table[:loser2_name].not_eq("BYE").or(matches_table[:loser2_name].eq(nil))
non_bye_scope = matches_scope.where(loser1_not_bye).where(loser2_not_bye)
@matches_without_byes_count = non_bye_scope.count
@unfinished_matches_without_byes_count = non_bye_scope.where(finished: [nil, 0]).count
@matches = matches_scope
.includes({ wrestler1: :school }, { wrestler2: :school }, { weight: :matches })
.offset(offset)
.limit(per_page)
if @match if @match
@w1 = @match.wrestler1 @w1 = @match.wrestler1
@w2 = @match.wrestler2 @w2 = @match.wrestler2
@@ -129,10 +178,18 @@ class TournamentsController < ApplicationController
def weigh_in_weight def weigh_in_weight
if params[:wrestler] if params[:wrestler]
Wrestler.update(params[:wrestler].keys, params[:wrestler].values) sanitized_wrestlers = params.require(:wrestler).to_unsafe_h.each_with_object({}) do |(wrestler_id, attributes), result|
permitted = ActionController::Parameters.new(attributes).permit(:offical_weight)
result[wrestler_id] = permitted
end
Wrestler.update(sanitized_wrestlers.keys, sanitized_wrestlers.values) if sanitized_wrestlers.present?
redirect_to "/tournaments/#{@tournament.id}/weigh_in/#{params[:weight]}", notice: "Weights were successfully recorded."
return
end end
if params[:weight] if params[:weight]
@weight = Weight.where(:id => params[:weight]).includes(:wrestlers).first @weight = Weight.where(id: params[:weight])
.includes(wrestlers: [:school, :weight])
.first
@tournament_id = @tournament.id @tournament_id = @tournament.id
@tournament_name = @tournament.name @tournament_name = @tournament.name
@weights = @tournament.weights @weights = @tournament.weights
@@ -159,20 +216,24 @@ class TournamentsController < ApplicationController
def all_brackets def all_brackets
@schools = @tournament.schools @schools = @tournament.schools
@schools = @schools.sort_by{|s| s.page_score_string}.reverse! @schools = @schools.sort_by{|s| s.page_score_string}.reverse!
@matches = @tournament.matches.includes(:wrestlers,:schools) @weights = @tournament.weights.includes(:matches, wrestlers: :school)
@weights = @tournament.weights.includes(:matches,:wrestlers) all_matches = @tournament.matches.includes(:weight, { wrestler1: :school }, { wrestler2: :school })
all_wrestlers = @tournament.wrestlers.includes(:school, :weight)
@matches_by_weight_id = all_matches.group_by(&:weight_id)
@wrestlers_by_weight_id = all_wrestlers.group_by(&:weight_id)
end end
def bracket def bracket
if params[:weight] if params[:weight]
@weight = Weight.where(:id => params[:weight]).includes(:matches,:wrestlers).first @weight = Weight.includes(:matches, wrestlers: [:school, :matches_as_w1, :matches_as_w2]).find_by(id: params[:weight])
@matches = @weight.matches.includes(:schools,:wrestlers) @matches = @weight.matches
@wrestlers = @weight.wrestlers.includes(:school) @wrestlers = @weight.wrestlers
if @tournament.tournament_type == "Pool to bracket"
@pools = @weight.pool_rounds(@matches) if @tournament.tournament_type == "Pool to bracket"
@bracketType = @weight.pool_bracket_type @pools = @weight.pool_rounds(@matches)
end @bracketType = @weight.pool_bracket_type
end end
end
end end
def all_results def all_results
@@ -181,6 +242,10 @@ class TournamentsController < ApplicationController
@bracket_position = nil @bracket_position = nil
end end
def live_scores
@mats = @tournament.mats.sort_by(&:name)
end
def generate_matches def generate_matches
GenerateTournamentMatches.new(@tournament).generate GenerateTournamentMatches.new(@tournament).generate
end end
@@ -195,37 +260,61 @@ class TournamentsController < ApplicationController
end end
def qrcode
@tournament_url = tournament_url(@tournament)
@qrcode = RQRCode::QRCode.new(@tournament_url)
end
def up_matches def up_matches
# .where.not(loser1_name: 'BYE') won't return matches with NULL loser1_name @matches = @tournament.up_matches_unassigned_matches
# so I was only getting back matches with Loser of BOUT_NUMBER @mats = @tournament.up_matches_mats
@matches = @tournament.matches
.where("mat_id is NULL and (finished != 1 or finished is NULL)")
.where("loser1_name != ? OR loser1_name IS NULL", "BYE")
.where("loser2_name != ? OR loser2_name IS NULL", "BYE")
.order('bout_number ASC')
.limit(10).includes(:wrestlers)
@mats = @tournament.mats.includes(:matches)
end end
def bout_sheets def bout_sheets
matches_scope = @tournament.matches
.where("loser1_name != ? OR loser1_name IS NULL", "BYE")
.where("loser2_name != ? OR loser2_name IS NULL", "BYE")
if params[:round] if params[:round]
round = params[:round] round = params[:round]
if round != "All" if round != "All"
@matches = @tournament.matches.where("round = ?",round).sort_by{|match| match.bout_number} @matches = matches_scope
.where(round: round)
.includes(:weight)
.order(:bout_number)
else else
@matches = @tournament.matches.sort_by{|match| match.bout_number} @matches = matches_scope
.includes(:weight)
.order(:bout_number)
end end
wrestler_ids = @matches.flat_map { |match| [match.w1, match.w2] }.compact.uniq
@wrestlers_by_id = Wrestler.includes(:school).where(id: wrestler_ids).index_by(&:id)
end end
end end
def index def index
if params[:search] # Simple manual pagination to avoid introducing a gem.
# @tournaments = Tournament.limit(200).search(params[:search]).order("date DESC") per_page = 20
@tournaments = Tournament.limit(200).search_date_name(params[:search]).order("date DESC") page = params[:page].to_i > 0 ? params[:page].to_i : 1
offset = (page - 1) * per_page
if params[:search].present?
tournaments = Tournament.search_date_name(params[:search]).to_a
else else
@tournaments = Tournament.all.sort_by{|t| t.days_until_start}.first(20) tournaments = Tournament.all.to_a
end end
# Sort by distance from today (closest first)
today = Date.today
tournaments.sort_by! { |t| (t.date - today).abs }
@total_count = tournaments.size
@total_pages = (@total_count / per_page.to_f).ceil
@page = page
@per_page = per_page
@tournaments = tournaments.slice(offset, per_page) || []
end end
def show def show
@@ -285,7 +374,7 @@ class TournamentsController < ApplicationController
def reset_bout_board def reset_bout_board
@tournament.reset_and_fill_bout_board @tournament.reset_and_fill_bout_board
redirect_to tournament_path(@tournament), notice: "Successfully reset the bout board." redirect_to tournament_path(@tournament), notice: "Successfully reset the bout board. Please have all mat table workers refresh their page."
end end
def generate_school_keys def generate_school_keys
@@ -303,7 +392,7 @@ class TournamentsController < ApplicationController
private private
# Use callbacks to share common setup or constraints between actions. # Use callbacks to share common setup or constraints between actions.
def set_tournament def set_tournament
@tournament = Tournament.where(:id => params[:id]).includes(:schools,:weights,:mats,:matches,:user,:wrestlers).first @tournament = Tournament.includes(:user, :mats, :schools, :weights, :matches, wrestlers: [:school, :weight, :matches_as_w1, :matches_as_w2]).find_by(id: params[:id])
end end
# Never trust parameters from the scary internet, only allow the white list through. # Never trust parameters from the scary internet, only allow the white list through.

View File

@@ -98,7 +98,8 @@ class WeightsController < ApplicationController
private private
# Use callbacks to share common setup or constraints between actions. # Use callbacks to share common setup or constraints between actions.
def set_weight def set_weight
@weight = Weight.where(:id => params[:id]).includes(:tournament,:wrestlers).first # Add nested includes for wrestlers
@weight = Weight.includes(:tournament, wrestlers: [:school, :matches_as_w1, :matches_as_w2]).find_by(id: params[:id])
end end
# Never trust parameters from the scary internet, only allow the white list through. # Never trust parameters from the scary internet, only allow the white list through.

View File

@@ -86,7 +86,11 @@ class WrestlersController < ApplicationController
private private
def set_wrestler def set_wrestler
@wrestler = Wrestler.includes(:school, :weight, :tournament, :matches).find_by(id: params[:id]) @wrestler = Wrestler.includes(:school, :weight, :tournament, :matches_as_w1, :matches_as_w2).find_by(id: params[:id])
if @wrestler.nil?
redirect_to root_path, alert: "Wrestler not found"
end
end end
def wrestler_params def wrestler_params

View File

@@ -1,4 +1,20 @@
module ApplicationHelper module ApplicationHelper
def hide_ads?
case controller_name
when "schools"
action_name == "show" && (user_signed_in? || school_permission_key_present?)
when "wrestlers"
%w[new edit].include?(action_name) && (user_signed_in? || school_permission_key_present?)
when "mats"
action_name == "show" && user_signed_in?
else
false
end
end
def school_permission_key_present?
@school_permission_key.present? ||
params[:school_permission_key].present? ||
params.dig(:school, :school_permission_key).present?
end
end end

View File

@@ -1,14 +1,9 @@
class AdvanceWrestlerJob < ApplicationJob class AdvanceWrestlerJob < ApplicationJob
queue_as :default queue_as :default
# associations are not available here so we had to pass tournament_id when creating the job
limits_concurrency to: 1, key: ->(_wrestler, _match, tournament_id) { "tournament:#{tournament_id}" }
# Class method for direct execution in test environment def perform(wrestler, match, tournament_id)
def self.perform_sync(wrestler, match)
# Execute directly on provided objects
service = AdvanceWrestler.new(wrestler, match)
service.advance_raw
end
def perform(wrestler, match)
# Get tournament from wrestler # Get tournament from wrestler
tournament = wrestler.tournament tournament = wrestler.tournament
@@ -18,7 +13,7 @@ class AdvanceWrestlerJob < ApplicationJob
tournament: tournament, tournament: tournament,
job_name: job_name, job_name: job_name,
status: "Running", status: "Running",
details: "Match ID: #{match&.bout_number || 'No match'}" details: "Match ID: #{match&.bout_number || 'No match'} Wrestler Name #{wrestler&.name || 'No Wrestler'}"
) )
begin begin

View File

@@ -1,7 +1,8 @@
class CalculateSchoolScoreJob < ApplicationJob class CalculateSchoolScoreJob < ApplicationJob
queue_as :default queue_as :default
limits_concurrency to: 1, key: ->(school) { "tournament:#{school.tournament_id}" }
# Class method for direct execution in test environment # Need for TournamentJobStatusIntegrationTest
def self.perform_sync(school) def self.perform_sync(school)
# Execute directly on provided objects # Execute directly on provided objects
school.calculate_score_raw school.calculate_score_raw

View File

@@ -1,12 +1,6 @@
class GenerateTournamentMatchesJob < ApplicationJob class GenerateTournamentMatchesJob < ApplicationJob
queue_as :default queue_as :default
limits_concurrency to: 1, key: ->(tournament) { "tournament:#{tournament.id}" }
# Class method for direct execution in test environment
def self.perform_sync(tournament)
# Execute directly on provided objects
generator = GenerateTournamentMatches.new(tournament)
generator.generate_raw
end
def perform(tournament) def perform(tournament)
# Log information about the job # Log information about the job

View File

@@ -1,12 +1,6 @@
class TournamentBackupJob < ApplicationJob class TournamentBackupJob < ApplicationJob
queue_as :default queue_as :default
limits_concurrency to: 1, key: ->(tournament, *) { "tournament:#{tournament.id}" }
# Class method for direct execution in test environment
def self.perform_sync(tournament, reason = nil)
# Execute directly on provided objects
service = TournamentBackupService.new(tournament, reason)
service.create_backup_raw
end
def perform(tournament, reason = nil) def perform(tournament, reason = nil)
# Log information about the job # Log information about the job

View File

@@ -0,0 +1,37 @@
class TournamentCleanupJob < ApplicationJob
queue_as :default
def perform
# Remove or clean up tournaments based on age and match status
process_old_tournaments
end
private
def process_old_tournaments
# Get all tournaments older than 1 week that have a user_id
old_tournaments = Tournament.where('date < ? AND user_id IS NOT NULL', 1.week.ago.to_date)
old_tournaments.each do |tournament|
# Check if it has any non-BYE finished matches
has_real_matches = tournament.matches.where(finished: 1).where.not(win_type: 'BYE').exists?
if has_real_matches
tournament.tournament_backups.destroy_all
# 1. Remove all school delegates
tournament.schools.each do |school|
school.delegates.destroy_all
end
# 2. Remove all tournament delegates
tournament.delegates.destroy_all
# 3. Set user_id to null
tournament.update(user_id: nil)
else
tournament.destroy
end
end
end
end

View File

@@ -1,13 +1,6 @@
class WrestlingdevImportJob < ApplicationJob class WrestlingdevImportJob < ApplicationJob
queue_as :default queue_as :default
limits_concurrency to: 1, key: ->(tournament, *) { "tournament:#{tournament.id}" }
# Class method for direct execution in test environment
def self.perform_sync(tournament, import_data = nil)
# Execute directly on provided objects
importer = WrestlingdevImporter.new(tournament)
importer.import_data = import_data if import_data
importer.import_raw
end
def perform(tournament, import_data = nil) def perform(tournament, import_data = nil)
# Log information about the job # Log information about the job

View File

@@ -1,6 +1,20 @@
class Ability class Ability
include CanCan::Ability include CanCan::Ability
def school_permission_key_check(school_permission_key)
# Can read school if tournament is public or a valid school permission key is provided
can :read, School do |school|
school.tournament.is_public ||
(school_permission_key.present? && school.permission_key == school_permission_key)
end
# Can manage school if a valid school permission key is provided
# school_permission_key comes from app/controllers/application_controller.rb
can :manage, School do |school|
(school_permission_key.present? && school.permission_key == school_permission_key)
end
end
def initialize(user, school_permission_key = nil) def initialize(user, school_permission_key = nil)
if user if user
# LOGGED IN USER PERMISSIONS # LOGGED IN USER PERMISSIONS
@@ -46,6 +60,8 @@ class Ability
school.tournament.delegates.map(&:user_id).include?(user.id) || school.tournament.delegates.map(&:user_id).include?(user.id) ||
school.tournament.user_id == user.id school.tournament.user_id == user.id
end end
school_permission_key_check(school_permission_key)
else else
# NON LOGGED IN USER PERMISSIONS # NON LOGGED IN USER PERMISSIONS
@@ -58,18 +74,7 @@ class Ability
# SCHOOL PERMISSIONS # SCHOOL PERMISSIONS
# wrestler permissions are included with school permissions # wrestler permissions are included with school permissions
school_permission_key_check(school_permission_key)
# Can read school if tournament is public or a valid school permission key is provided
can :read, School do |school|
school.tournament.is_public ||
(school_permission_key.present? && school.permission_key == school_permission_key)
end
# Can read school if a valid school permission key is provided
# school_permission_key comes from app/controllers/application_controller.rb
can :manage, School do |school|
(school_permission_key.present? && school.permission_key == school_permission_key)
end
end end
end end
end end

View File

@@ -1,53 +1,55 @@
class Mat < ApplicationRecord class Mat < ApplicationRecord
include ActionView::RecordIdentifier
belongs_to :tournament belongs_to :tournament
has_many :matches, dependent: :destroy has_many :matches, dependent: :nullify
has_many :mat_assignment_rules, dependent: :destroy has_many :mat_assignment_rules, dependent: :destroy
validates :name, presence: true validates :name, presence: true
before_destroy do QUEUE_SLOTS = %w[queue1 queue2 queue3 queue4].freeze
if tournament.matches.size > 0 SCOREBOARD_SELECTION_CACHE_TTL = 1.hours
tournament.reset_mats LAST_MATCH_RESULT_CACHE_TTL = 1.hours
matsToAssign = tournament.mats.select{|m| m.id != self.id}
tournament.assign_mats(matsToAssign)
end
end
after_create do after_save :clear_queue_matches_cache
if tournament.matches.size > 0 after_commit :broadcast_up_matches_board, on: :update, if: :up_matches_queue_changed?
tournament.reset_mats
matsToAssign = tournament.mats
tournament.assign_mats(matsToAssign)
end
end
def assign_next_match def assign_next_match
slot = first_empty_queue_slot
return true unless slot
match = next_eligible_match match = next_eligible_match
self.matches.reload return false unless match
if match and self.unfinished_matches.size < 4
match.mat_id = self.id place_match_in_empty_slot!(match, slot)
if match.save true
# Invalidate any wrestler caches end
if match.w1
match.wrestler1.touch def advance_queue!(finished_match = nil)
match.wrestler1.school.touch self.class.transaction do
if finished_match
position = queue_position_for_match(finished_match)
if position == 1
shift_queue_forward!
fill_queue_slots!
elsif position
remove_match_from_queue_and_collapse!(finished_match.id)
else
fill_queue_slots!
end end
if match.w2
match.wrestler2.touch
match.wrestler2.school.touch
end
return true
else else
return false if queue1_match&.finished == 1
shift_queue_forward!
end
fill_queue_slots!
end end
else
return true
end end
broadcast_current_match
true
end end
def next_eligible_match def next_eligible_match
# Start with all matches that are either unfinished (nil or 0), have a bout number, and are ordered by bout_number # Start with all matches that are either unfinished (nil or 0), have a bout number, and are ordered by bout_number
filtered_matches = tournament.matches filtered_matches = Match.where(tournament_id: tournament_id)
.where(finished: [nil, 0]) # finished is nil or 0 .where(finished: [nil, 0]) # finished is nil or 0
.where(mat_id: nil) # mat_id is nil .where(mat_id: nil) # mat_id is nil
.where.not(bout_number: nil) # bout_number is not nil .where.not(bout_number: nil) # bout_number is not nil
@@ -58,6 +60,11 @@ class Mat < ApplicationRecord
.where("loser1_name != ? OR loser1_name IS NULL", "BYE") .where("loser1_name != ? OR loser1_name IS NULL", "BYE")
.where("loser2_name != ? OR loser2_name IS NULL", "BYE") .where("loser2_name != ? OR loser2_name IS NULL", "BYE")
# Filter out matches without a wrestlers
filtered_matches = filtered_matches
.where("w1 IS NOT NULL")
.where("w2 IS NOT NULL")
# Apply mat assignment rules # Apply mat assignment rules
mat_assignment_rules.each do |rule| mat_assignment_rules.each do |rule|
if rule.weight_classes.any? if rule.weight_classes.any?
@@ -80,9 +87,264 @@ class Mat < ApplicationRecord
filtered_matches.first filtered_matches.first
end end
def queue_match_ids
QUEUE_SLOTS.map { |slot| public_send(slot) }
end
# used to prevent N+1 query on each mat
def queue_matches
slot_ids = queue_match_ids
if @queue_matches.nil? || @queue_match_slot_ids != slot_ids
ids = slot_ids.compact
@queue_matches = if ids.empty?
[nil, nil, nil, nil]
else
matches_by_id = Match.where(id: ids)
.includes({ wrestler1: :school }, { wrestler2: :school }, { weight: :matches })
.index_by(&:id)
slot_ids.map { |match_id| match_id ? matches_by_id[match_id] : nil }
end
@queue_match_slot_ids = slot_ids
end
@queue_matches
end
def queue1_match
queue_match_at(1)
end
def queue2_match
queue_match_at(2)
end
def queue3_match
queue_match_at(3)
end
def queue4_match
queue_match_at(4)
end
def queue_position_for_match(match)
return nil unless match
return 1 if queue1 == match.id
return 2 if queue2 == match.id
return 3 if queue3 == match.id
return 4 if queue4 == match.id
nil
end
def remove_match_from_queue_and_collapse!(match_id)
queue_ids = queue_match_ids
return if queue_ids.none? { |id| id == match_id }
queue_ids.map! { |id| id == match_id ? nil : id }
queue_ids = queue_ids.compact
queue_ids += [nil] * (4 - queue_ids.size)
update!(
queue1: queue_ids[0],
queue2: queue_ids[1],
queue3: queue_ids[2],
queue4: queue_ids[3]
)
fill_queue_slots!
broadcast_current_match
end
def assign_match_to_queue!(match, position)
position = position.to_i
raise ArgumentError, "Queue position must be 1-4" unless (1..4).cover?(position)
self.class.transaction do
match.update!(mat_id: id)
remove_match_from_other_mats!(match.id)
queue_ids = queue_match_ids.map { |id| id == match.id ? nil : id }
queue_ids = queue_ids.compact
queue_ids.insert(position - 1, match.id)
bumped_match_id = queue_ids.length > 4 ? queue_ids.pop : nil
queue_ids += [nil] * (4 - queue_ids.length)
update!(
queue1: queue_ids[0],
queue2: queue_ids[1],
queue3: queue_ids[2],
queue4: queue_ids[3]
)
bumped_match = Match.find_by(id: bumped_match_id)
if bumped_match && bumped_match.finished != 1
bumped_match.update!(mat_id: nil)
end
end
broadcast_current_match
end
def clear_queue!
update!(queue1: nil, queue2: nil, queue3: nil, queue4: nil)
broadcast_current_match
end
def unfinished_matches def unfinished_matches
matches.select{|m| m.finished != 1}.sort_by{|m| m.bout_number} matches.select{|m| m.finished != 1}.sort_by{|m| m.bout_number}
end 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
@queue_matches = nil
@queue_match_slot_ids = nil
end
def queue_match_at(position)
queue_matches[position - 1]
end
def first_empty_queue_slot
QUEUE_SLOTS.each_with_index do |slot, index|
return index + 1 if public_send(slot).nil?
end
nil
end
def shift_queue_forward!
update!(
queue1: queue2,
queue2: queue3,
queue3: queue4,
queue4: nil
)
end
def fill_queue_slots!
queue_ids = queue_match_ids
updated = false
QUEUE_SLOTS.each_with_index do |_slot, index|
next if queue_ids[index].present?
match = next_eligible_match
break unless match
queue_ids[index] = match.id
match.update!(mat_id: id)
updated = true
end
if updated
update!(
queue1: queue_ids[0],
queue2: queue_ids[1],
queue3: queue_ids[2],
queue4: queue_ids[3]
)
end
end
def remove_match_from_other_mats!(match_id)
self.class.where.not(id: id)
.where("queue1 = :match_id OR queue2 = :match_id OR queue3 = :match_id OR queue4 = :match_id", match_id: match_id)
.find_each do |mat|
mat.remove_match_from_queue_and_collapse!(match_id)
end
end
def place_match_in_empty_slot!(match, slot)
self.class.transaction do
match.update!(mat_id: id)
remove_match_from_other_mats!(match.id)
update!(slot_key(slot) => match.id)
end
broadcast_current_match
end
def slot_key(slot)
"queue#{slot}"
end
def broadcast_current_match
Turbo::StreamsChannel.broadcast_update_to(
self,
target: dom_id(self, :current_match),
partial: "mats/current_match",
locals: {
mat: self,
match: queue1_match,
next_match: queue2_match,
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
Tournament.broadcast_up_matches_board(tournament_id)
end
def up_matches_queue_changed?
saved_change_to_queue1? || saved_change_to_queue2? || saved_change_to_queue3? || saved_change_to_queue4?
end
end end

View File

@@ -1,7 +1,12 @@
class Match < ApplicationRecord class Match < ApplicationRecord
include ActionView::RecordIdentifier
belongs_to :tournament, touch: true belongs_to :tournament, touch: true
belongs_to :weight, touch: true belongs_to :weight, touch: true
belongs_to :mat, touch: true, optional: true belongs_to :mat, touch: true, optional: true
belongs_to :winner, class_name: 'Wrestler', foreign_key: 'winner_id', optional: true
belongs_to :wrestler1, class_name: 'Wrestler', foreign_key: 'w1', optional: true
belongs_to :wrestler2, class_name: 'Wrestler', foreign_key: 'w2', optional: true
has_many :wrestlers, :through => :weight has_many :wrestlers, :through => :weight
has_many :schools, :through => :wrestlers has_many :schools, :through => :wrestlers
validate :score_validation, :win_type_validation, :bracket_position_validation, :overtime_type_validation validate :score_validation, :win_type_validation, :bracket_position_validation, :overtime_type_validation
@@ -9,12 +14,22 @@ class Match < ApplicationRecord
# Callback to update finished_at when a match is finished # Callback to update finished_at when a match is finished
before_save :update_finished_at before_save :update_finished_at
after_update :after_finished_actions, if: -> { # update mat show with correct match if bout board is reset
saved_change_to_finished? || # this is done with a turbo stream
saved_change_to_winner_id? || after_commit :broadcast_mat_assignment_change, if: :saved_change_to_mat_id?, on: [:create, :update]
saved_change_to_win_type? || after_commit :broadcast_up_matches_board, on: :update, if: :saved_change_to_mat_id?
saved_change_to_score? ||
saved_change_to_overtime_type? # Enqueue advancement and related actions after the DB transaction has committed.
# Using after_commit ensures any background jobs enqueued inside these callbacks
# will see the committed state of the match (e.g. finished == 1). Enqueuing
# jobs from after_update can cause jobs to run before the transaction commits,
# which leads to jobs observing stale data and not performing advancement.
after_commit :after_finished_actions, on: :update, if: -> {
saved_change_to_finished? ||
saved_change_to_winner_id? ||
saved_change_to_win_type? ||
saved_change_to_score? ||
saved_change_to_overtime_type?
} }
def after_finished_actions def after_finished_actions
@@ -25,11 +40,14 @@ class Match < ApplicationRecord
wrestler2.touch wrestler2.touch
end end
if self.finished == 1 && self.winner_id != nil if self.finished == 1 && self.winner_id != nil
if self.mat
self.mat.assign_next_match
end
advance_wrestlers advance_wrestlers
calculate_school_points if self.mat
self.mat.advance_queue!(self)
end
self.tournament.refill_open_bout_board_queues
# School point calculation has move to the end of advance wrestler
# calculate_school_points
self.update(mat_id: nil)
end end
end end
@@ -43,7 +61,7 @@ class Match < ApplicationRecord
errors.add(:winner_id, "cannot be blank") errors.add(:winner_id, "cannot be blank")
end end
if win_type == "Pin" and ! score.match(/^[0-5]?[0-9]:[0-5][0-9]/) if win_type == "Pin" and ! score.match(/^[0-5]?[0-9]:[0-5][0-9]/)
errors.add(:score, "needs to be in time format MM:SS when win type is Pin example: 1:23 or 10:03") errors.add(:score, "needs to be in time format MM:SS when win type is Pin example: 2:23, 0:25, 10:03")
end end
if win_type == "Decision" or win_type == "Tech Fall" or win_type == "Major" and ! score.match(/^[0-9]?[0-9]-[0-9]?[0-9]/) if win_type == "Decision" or win_type == "Tech Fall" or win_type == "Major" and ! score.match(/^[0-9]?[0-9]-[0-9]?[0-9]/)
errors.add(:score, "needs to be in Number-Number format when win type is Decision, Tech Fall, and Major example: 10-2") errors.add(:score, "needs to be in Number-Number format when win type is Decision, Tech Fall, and Major example: 10-2")
@@ -163,14 +181,6 @@ class Match < ApplicationRecord
end end
end end
def wrestler1
wrestlers.select{|w| w.id == self.w1}.first
end
def wrestler2
wrestlers.select{|w| w.id == self.w2}.first
end
def w1_name def w1_name
if self.w1 != nil if self.w1 != nil
wrestler1.name wrestler1.name
@@ -188,6 +198,7 @@ class Match < ApplicationRecord
end end
def w1_bracket_name def w1_bracket_name
first_round = first_round_for_weight
return_string = "" return_string = ""
return_string_ending = "" return_string_ending = ""
if self.w1 and self.winner_id == self.w1 if self.w1 and self.winner_id == self.w1
@@ -195,7 +206,7 @@ class Match < ApplicationRecord
return_string_ending = return_string_ending + "</strong>" return_string_ending = return_string_ending + "</strong>"
end end
if self.w1 != nil if self.w1 != nil
if self.round == 1 if self.round == first_round
return_string = return_string + "#{wrestler1.long_bracket_name}" return_string = return_string + "#{wrestler1.long_bracket_name}"
else else
return_string = return_string + "#{wrestler1.short_bracket_name}" return_string = return_string + "#{wrestler1.short_bracket_name}"
@@ -207,6 +218,7 @@ class Match < ApplicationRecord
end end
def w2_bracket_name def w2_bracket_name
first_round = first_round_for_weight
return_string = "" return_string = ""
return_string_ending = "" return_string_ending = ""
if self.w2 and self.winner_id == self.w2 if self.w2 and self.winner_id == self.w2
@@ -214,7 +226,7 @@ class Match < ApplicationRecord
return_string_ending = return_string_ending + "</strong>" return_string_ending = return_string_ending + "</strong>"
end end
if self.w2 != nil if self.w2 != nil
if self.round == 1 if self.round == first_round
return_string = return_string + "#{wrestler2.long_bracket_name}" return_string = return_string + "#{wrestler2.long_bracket_name}"
else else
return_string = return_string + "#{wrestler2.short_bracket_name}" return_string = return_string + "#{wrestler2.short_bracket_name}"
@@ -229,10 +241,10 @@ class Match < ApplicationRecord
if self.finished != 1 if self.finished != 1
return "" return ""
end end
if self.winner_id == self.w1 if self.winner == self.wrestler1
return self.w1_name return self.w1_name
end end
if self.winner_id == self.w2 if self.winner == self.wrestler2
return self.w2_name return self.w2_name
end end
end end
@@ -241,20 +253,28 @@ class Match < ApplicationRecord
if self.finished != 1 if self.finished != 1
return "" return ""
end end
if self.winner_id == self.w1 winning_wrestler = self.winner
winning_wrestler = self.wrestler1 if winning_wrestler == self.wrestler1
losing_wrestler = self.wrestler2 losing_wrestler = self.wrestler2
end elsif winning_wrestler == self.wrestler2
if self.winner_id == self.w2
winning_wrestler = self.wrestler2
losing_wrestler = self.wrestler1 losing_wrestler = self.wrestler1
else
# Handle cases where winner is not w1 or w2 (e.g., BYE, DQ where opponent might be nil)
# Or maybe the match hasn't been fully populated yet after a win?
# Returning an empty string for now, but this might need review based on expected scenarios.
return ""
end end
return "#{self.weight.max} lbs - #{winning_wrestler.name} (#{winning_wrestler.school.name}) #{self.win_type} #{losing_wrestler.name} (#{losing_wrestler.school.name}) #{self.score}" # Ensure losing_wrestler is not nil before accessing its properties
losing_wrestler_name = losing_wrestler ? losing_wrestler.name : "Unknown"
losing_wrestler_school = losing_wrestler ? losing_wrestler.school.name : "Unknown"
return "#{self.weight.max} lbs - #{winning_wrestler.name} (#{winning_wrestler.school.name}) #{self.win_type} #{losing_wrestler_name} (#{losing_wrestler_school}) #{self.score}"
end end
def bracket_winner_name def bracket_winner_name
if winner_name != "" # Use the winner association directly
return "#{winner_name} (#{Wrestler.find(winner_id).school.abbreviation})" if self.winner
return "#{self.winner.name} (#{self.winner.school.abbreviation})"
else else
"" ""
end end
@@ -264,6 +284,17 @@ class Match < ApplicationRecord
self.weight.max self.weight.max
end end
def first_round_for_weight
return @first_round_for_weight if defined?(@first_round_for_weight)
@first_round_for_weight =
if association(:weight).loaded? && self.weight&.association(:matches)&.loaded?
self.weight.matches.map(&:round).compact.min
else
Match.where(weight_id: self.weight_id).minimum(:round)
end
end
def replace_loser_name_with_wrestler(w,loser_name) def replace_loser_name_with_wrestler(w,loser_name)
if self.loser1_name == loser_name if self.loser1_name == loser_name
self.w1 = w.id self.w1 = w.id
@@ -319,4 +350,30 @@ class Match < ApplicationRecord
self.finished_at = Time.current.utc self.finished_at = Time.current.utc
end end
end end
def broadcast_mat_assignment_change
old_mat_id, new_mat_id = saved_change_to_mat_id || previous_changes["mat_id"]
return unless old_mat_id || new_mat_id
[old_mat_id, new_mat_id].compact.uniq.each do |mat_id|
mat = Mat.find_by(id: mat_id)
next unless mat
Turbo::StreamsChannel.broadcast_update_to(
mat,
target: dom_id(mat, :current_match),
partial: "mats/current_match",
locals: {
mat: mat,
match: mat.queue1_match,
next_match: mat.queue2_match,
show_next_bout_button: true
}
)
end
end
def broadcast_up_matches_board
Tournament.broadcast_up_matches_board(tournament_id)
end
end end

View File

@@ -69,8 +69,35 @@ class Tournament < ApplicationRecord
end end
end end
def up_matches_unassigned_matches
matches
.where("mat_id is NULL and (finished != 1 or finished is NULL)")
.where("loser1_name != ? OR loser1_name IS NULL", "BYE")
.where("loser2_name != ? OR loser2_name IS NULL", "BYE")
.order("bout_number ASC")
.limit(10)
.includes({ wrestler1: :school }, { wrestler2: :school }, { weight: :matches })
end
def up_matches_mats
mats.includes(:matches)
end
def self.broadcast_up_matches_board(tournament_id)
tournament = find_by(id: tournament_id)
return unless tournament
Turbo::StreamsChannel.broadcast_replace_to(
tournament,
target: "up_matches_board",
partial: "tournaments/up_matches_board",
locals: { tournament: tournament }
)
end
def destroy_all_matches def destroy_all_matches
matches.destroy_all matches.destroy_all
mats.each(&:clear_queue!)
end end
def matches_by_round(round) def matches_by_round(round)
@@ -82,38 +109,26 @@ class Tournament < ApplicationRecord
matches.maximum(:round) || 0 # Return 0 if no matches or max round is nil matches.maximum(:round) || 0 # Return 0 if no matches or max round is nil
end end
def assign_mats(mats_to_assign)
if mats_to_assign.count > 0
until mats_to_assign.sort_by{|m| m.id}.last.matches.count == 4
mats_to_assign.sort_by{|m| m.id}.each do |m|
m.assign_next_match
end
end
end
end
def reset_mats def reset_mats
matches.reload
mats.reload
matches_to_reset = matches.select{|m| m.mat_id != nil} matches_to_reset = matches.select{|m| m.mat_id != nil}
# matches_to_reset.update_all( {:mat_id => nil } ) # matches_to_reset.update_all( {:mat_id => nil } )
matches_to_reset.each do |m| matches_to_reset.each do |m|
m.mat_id = nil m.mat_id = nil
m.save m.save
end end
mats.each do |mat|
mat.clear_queue!
end
end end
def pointAdjustments def pointAdjustments
point_adjustments = [] school_scope = Teampointadjust.where(school_id: schools.select(:id))
self.schools.each do |s| wrestler_scope = Teampointadjust.where(wrestler_id: wrestlers.select(:id))
s.deductedPoints.each do |d|
point_adjustments << d Teampointadjust.includes(:school, :wrestler)
end .merge(school_scope.or(wrestler_scope))
end
self.wrestlers.each do |w|
w.deductedPoints.each do |d|
point_adjustments << d
end
end
point_adjustments
end end
def remove_school_delegations def remove_school_delegations
@@ -156,14 +171,14 @@ class Tournament < ApplicationRecord
def double_elim_number_of_wrestlers_error def double_elim_number_of_wrestlers_error
error_string = "" error_string = ""
if self.tournament_type == "Double Elimination 1-6" or self.tournament_type == "Double Elimination 1-8" if self.tournament_type == "Regular Double Elimination 1-6" or self.tournament_type == "Regular Double Elimination 1-8"
weights_with_too_many_wrestlers = weights.select{|w| w.wrestlers.size > 32} weights_with_too_many_wrestlers = weights.select{|w| w.wrestlers.size > 64}
weight_with_too_few_wrestlers = weights.select{|w| w.wrestlers.size < 4} weight_with_too_few_wrestlers = weights.select{|w| w.wrestlers.size < 2}
weights_with_too_many_wrestlers.each do |weight| weights_with_too_many_wrestlers.each do |weight|
error_string = error_string + " The weight class #{weight.max} has more than 16 wrestlers." error_string = error_string + " The weight class #{weight.max} has more than 64 wrestlers."
end end
weight_with_too_few_wrestlers.each do |weight| weight_with_too_few_wrestlers.each do |weight|
error_string = error_string + " The weight class #{weight.max} has less than 4 wrestlers." error_string = error_string + " The weight class #{weight.max} has less than 2 wrestlers."
end end
end end
return error_string return error_string
@@ -228,19 +243,24 @@ class Tournament < ApplicationRecord
def reset_and_fill_bout_board def reset_and_fill_bout_board
reset_mats reset_mats
matches.reload
refill_open_bout_board_queues
end
if mats.any? def refill_open_bout_board_queues
4.times do return unless mats.any?
# Iterate over each mat and assign the next available match
mats.each do |mat| loop do
match_assigned = mat.assign_next_match assigned_any = false
# If no more matches are available, exit early # Fill in round-robin order by queue depth:
unless match_assigned # all mats queue1 first, then queue2, then queue3, then queue4.
puts "No more eligible matches to assign." (1..4).each do |slot|
return mats.reload.each do |mat|
end next unless mat.public_send("queue#{slot}").nil?
assigned_any ||= mat.assign_next_match
end
end end
end break unless assigned_any
end end
end end

View File

@@ -53,19 +53,16 @@ class User < ApplicationRecord
end end
def delegated_tournaments def delegated_tournaments
tournaments_delegated = [] Tournament.joins(:delegates)
delegated_tournament_permissions.each do |t| .where(tournament_delegates: { user_id: id })
tournaments_delegated << t.tournament .distinct
end
tournaments_delegated
end end
def delegated_schools def delegated_schools
schools_delegated = [] School.joins(:delegates)
delegated_school_permissions.each do |t| .where(school_delegates: { user_id: id })
schools_delegated << t.school .includes(:tournament)
end .distinct
schools_delegated
end end
def self.search(search) def self.search(search)

View File

@@ -156,7 +156,7 @@ class Weight < ApplicationRecord
end end
def calculate_bracket_size def calculate_bracket_size
num_wrestlers = wrestlers.reload.size num_wrestlers = wrestlers.size
return nil if num_wrestlers <= 0 # Handle invalid input return nil if num_wrestlers <= 0 # Handle invalid input
# Find the smallest power of 2 greater than or equal to num_wrestlers # Find the smallest power of 2 greater than or equal to num_wrestlers

View File

@@ -2,8 +2,13 @@ class Wrestler < ApplicationRecord
belongs_to :school, touch: true belongs_to :school, touch: true
belongs_to :weight, touch: true belongs_to :weight, touch: true
has_one :tournament, through: :weight has_one :tournament, through: :weight
has_many :matches, through: :weight
has_many :deductedPoints, class_name: "Teampointadjust", dependent: :destroy has_many :deductedPoints, class_name: "Teampointadjust", dependent: :destroy
## Matches association
# Rails associations expect only a single column so we cannot do a w1 OR w2
# So we have to create two associations and combine them with the all_matches method
has_many :matches_as_w1, ->(wrestler){ where(weight_id: wrestler.weight_id) }, class_name: 'Match', foreign_key: 'w1'
has_many :matches_as_w2, ->(wrestler){ where(weight_id: wrestler.weight_id) }, class_name: 'Match', foreign_key: 'w2'
##
attr_accessor :poolAdvancePoints, :originalId, :swapId attr_accessor :poolAdvancePoints, :originalId, :swapId
validates :name, :weight_id, :school_id, presence: true validates :name, :weight_id, :school_id, presence: true
@@ -59,7 +64,7 @@ class Wrestler < ApplicationRecord
end end
def winner_of_last_match? def winner_of_last_match?
if last_match.winner_id == self.id if last_match && last_match.winner == self # Keep winner association change
return true return true
else else
return false return false
@@ -87,28 +92,28 @@ class Wrestler < ApplicationRecord
end end
def result_by_bout(bout) def result_by_bout(bout)
bout_match = all_matches.select{|m| m.bout_number == bout and m.finished == 1} bout_match_results = all_matches.select{|m| m.bout_number == bout and m.finished == 1}
if bout_match.size == 0 if bout_match_results.empty?
return "" return ""
end end
if bout_match.first.winner_id == self.id bout_match = bout_match_results.first
return "W #{bout_match.first.bracket_score_string}" if bout_match.winner == self # Keep winner association change
end return "W #{bout_match.bracket_score_string}"
if bout_match.first.winner_id != self.id else
return "L #{bout_match.first.bracket_score_string}" return "L #{bout_match.bracket_score_string}"
end end
end end
def result_by_id(id) def result_by_id(id)
bout_match = all_matches.select{|m| m.id == id and m.finished == 1} bout_match_results = all_matches.select{|m| m.id == id and m.finished == 1}
if bout_match.size == 0 if bout_match_results.empty?
return "" return ""
end end
if bout_match.first.winner_id == self.id bout_match = bout_match_results.first
return "W #{bout_match.first.bracket_score_string}" if bout_match.winner == self # Keep winner association change
end return "W #{bout_match.bracket_score_string}"
if bout_match.first.winner_id != self.id else
return "L #{bout_match.first.bracket_score_string}" return "L #{bout_match.bracket_score_string}"
end end
end end
@@ -120,6 +125,7 @@ class Wrestler < ApplicationRecord
if all_matches.blank? if all_matches.blank?
return false return false
else else
# Original logic checked blank?, not specific round. Reverting to that.
return true return true
end end
end end
@@ -142,8 +148,12 @@ class Wrestler < ApplicationRecord
end end
end end
# Restore all_matches method
def all_matches def all_matches
return matches.select{|m| m.w1 == self.id or m.w2 == self.id} # Combine the two specific associations.
# This returns an Array, similar to the previous select method.
# Add .uniq for safety and sort for consistent order.
(matches_as_w1 + matches_as_w2).uniq.sort_by(&:bout_number)
end end
def pool_matches def pool_matches
@@ -152,7 +162,9 @@ class Wrestler < ApplicationRecord
end end
def has_a_pool_bye def has_a_pool_bye
if weight.pool_rounds(matches) > pool_matches.size # Revert back to using all_matches here too? Seems complex.
# Sticking with original: uses `matches` (all weight) and `pool_matches` (derived from all_matches)
if weight.pool_rounds(all_matches) > pool_matches.size
return true return true
else else
return false return false
@@ -188,7 +200,8 @@ class Wrestler < ApplicationRecord
end end
def matches_won def matches_won
all_matches.select{|m| m.winner_id == self.id} # Revert, but keep using winner association check
all_matches.select{|m| m.winner == self}
end end
def pool_wins def pool_wins
@@ -268,11 +281,17 @@ class Wrestler < ApplicationRecord
def season_win_percentage def season_win_percentage
win = self.season_win.to_f win = self.season_win.to_f
loss = self.season_loss.to_f loss = self.season_loss.to_f
# Revert to original logic
if win > 0 and loss != nil if win > 0 and loss != nil
match_total = win + loss match_total = win + loss
percentage_dec = win / match_total if match_total > 0
percentage = percentage_dec * 100 percentage_dec = win / match_total
return percentage.to_i percentage = percentage_dec * 100
return percentage.to_i
else
# Avoid division by zero if somehow win > 0 but total <= 0
return 0
end
elsif self.season_win == 0 elsif self.season_win == 0
return 0 return 0
elsif self.season_win == nil or self.season_loss == nil elsif self.season_win == nil or self.season_loss == nil
@@ -281,6 +300,7 @@ class Wrestler < ApplicationRecord
end end
def long_bracket_name def long_bracket_name
# Revert to original logic
return_string = "" return_string = ""
if self.original_seed if self.original_seed
return_string = return_string + "[#{self.original_seed}] " return_string = return_string + "[#{self.original_seed}] "
@@ -293,10 +313,12 @@ class Wrestler < ApplicationRecord
end end
def short_bracket_name def short_bracket_name
# Revert to original logic
return "#{self.name} (#{self.school.abbreviation})" return "#{self.name} (#{self.school.abbreviation})"
end end
def name_with_school def name_with_school
# Revert to original logic
return "#{self.name} - #{self.school.name}" return "#{self.name} - #{self.school.name}"
end end
end end

View File

@@ -8,23 +8,101 @@ class AdvanceWrestler
def advance def advance
# Use perform_later which will execute based on centralized adapter config # Use perform_later which will execute based on centralized adapter config
# This will be converted to inline execution in test environment by ActiveJob # This will be converted to inline execution in test environment by ActiveJob
AdvanceWrestlerJob.perform_later(@wrestler, @last_match) AdvanceWrestlerJob.perform_later(@wrestler, @last_match, @tournament.id)
end end
def advance_raw def advance_raw
@last_match = Match.find_by(id: @last_match&.id)
@wrestler = Wrestler.includes(:school, :weight).find_by(id: @wrestler.id)
return unless @last_match && @wrestler && @last_match.finished?
if @last_match && @last_match.finished? context = preload_advancement_context
pool_to_bracket_advancement if @tournament.tournament_type == "Pool to bracket" matches_to_advance = []
ModifiedDoubleEliminationAdvance.new(@wrestler, @last_match).bracket_advancement if @tournament.tournament_type.include? "Modified 16 Man Double Elimination"
DoubleEliminationAdvance.new(@wrestler, @last_match).bracket_advancement if @tournament.tournament_type.include? "Regular Double Elimination" if @tournament.tournament_type == "Pool to bracket"
matches_to_advance.concat(pool_to_bracket_advancement(context))
elsif @tournament.tournament_type.include?("Modified 16 Man Double Elimination")
service = ModifiedDoubleEliminationAdvance.new(@wrestler, @last_match, matches: context[:matches])
service.bracket_advancement
matches_to_advance.concat(service.matches_to_advance)
elsif @tournament.tournament_type.include?("Regular Double Elimination")
service = DoubleEliminationAdvance.new(@wrestler, @last_match, matches: context[:matches])
service.bracket_advancement
matches_to_advance.concat(service.matches_to_advance)
end
persist_advancement_changes(context)
advance_pending_matches(matches_to_advance)
@wrestler.school.calculate_score
end
def preload_advancement_context
weight = Weight.includes(:matches, :wrestlers).find(@wrestler.weight_id)
{
weight: weight,
matches: weight.matches.to_a,
wrestlers: weight.wrestlers.to_a
}
end
def persist_advancement_changes(context)
persist_matches(context[:matches])
persist_wrestlers(context[:wrestlers])
end
def persist_matches(matches)
timestamp = Time.current
updates = matches.filter_map do |m|
next unless m.changed?
{
id: m.id,
w1: m.w1,
w2: m.w2,
winner_id: m.winner_id,
win_type: m.win_type,
score: m.score,
finished: m.finished,
loser1_name: m.loser1_name,
loser2_name: m.loser2_name,
finished_at: m.finished_at,
updated_at: timestamp
}
end
Match.upsert_all(updates) if updates.any?
end
def persist_wrestlers(wrestlers)
timestamp = Time.current
updates = wrestlers.filter_map do |w|
next unless w.changed?
{
id: w.id,
pool_placement: w.pool_placement,
pool_placement_tiebreaker: w.pool_placement_tiebreaker,
updated_at: timestamp
}
end
Wrestler.upsert_all(updates) if updates.any?
end
def advance_pending_matches(matches_to_advance)
matches_to_advance.uniq(&:id).each do |match|
match.advance_wrestlers
end end
end end
def pool_to_bracket_advancement def pool_to_bracket_advancement(context)
if @wrestler.weight.all_pool_matches_finished(@wrestler.pool) and (@wrestler.finished_bracket_matches.size < 1) matches_to_advance = []
PoolOrder.new(@wrestler.weight.wrestlers_in_pool(@wrestler.pool)).getPoolOrder wrestlers_in_pool = context[:wrestlers].select { |w| w.pool == @wrestler.pool }
if @wrestler.weight.all_pool_matches_finished(@wrestler.pool) && (@wrestler.finished_bracket_matches.size < 1)
PoolOrder.new(wrestlers_in_pool).getPoolOrder
end end
PoolAdvance.new(@wrestler).advanceWrestler service = PoolAdvance.new(@wrestler, @last_match, matches: context[:matches], wrestlers: context[:wrestlers])
service.advanceWrestler
matches_to_advance.concat(service.matches_to_advance)
matches_to_advance
end end
end end

View File

@@ -1,23 +1,32 @@
class DoubleEliminationAdvance class DoubleEliminationAdvance
def initialize(wrestler,last_match) attr_reader :matches_to_advance
def initialize(wrestler,last_match, matches: nil)
@wrestler = wrestler @wrestler = wrestler
@last_match = last_match @last_match = last_match
@matches = matches || @wrestler.weight.matches.to_a
@matches_to_advance = []
@next_match_position_number = (@last_match.bracket_position_number / 2.0) @next_match_position_number = (@last_match.bracket_position_number / 2.0)
end end
def bracket_advancement def bracket_advancement
if @last_match.winner_id == @wrestler.id advance_wrestler
winner_advance
end
if @last_match.winner_id != @wrestler.id
loser_advance
end
advance_double_byes advance_double_byes
set_bye_for_placement set_bye_for_placement
end end
def winner_advance def advance_wrestler
# Advance winner
if @last_match.winner == @wrestler
winners_bracket_advancement
# Advance loser
elsif @last_match.winner != @wrestler
losers_bracket_advancement
end
end
def winners_bracket_advancement
if (@last_match.loser1_name == "BYE" or @last_match.loser2_name == "BYE") and @last_match.is_championship_match if (@last_match.loser1_name == "BYE" or @last_match.loser2_name == "BYE") and @last_match.is_championship_match
update_consolation_bye update_consolation_bye
end end
@@ -43,7 +52,7 @@ class DoubleEliminationAdvance
end end
if next_match_bracket_position if next_match_bracket_position
next_match = Match.where("bracket_position = ? AND bracket_position_number = ? AND weight_id = ?",next_match_bracket_position,next_match_position_number.ceil,@wrestler.weight_id).first next_match = @matches.find { |m| m.bracket_position == next_match_bracket_position && m.bracket_position_number == next_match_position_number.ceil && m.weight_id == @wrestler.weight_id }
end end
if next_match if next_match
@@ -54,18 +63,16 @@ class DoubleEliminationAdvance
def update_new_match(match, wrestler_number) def update_new_match(match, wrestler_number)
if wrestler_number == 2 or (match.loser1_name and match.loser1_name.include? "Loser of") if wrestler_number == 2 or (match.loser1_name and match.loser1_name.include? "Loser of")
match.w2 = @wrestler.id match.w2 = @wrestler.id
match.save
elsif wrestler_number == 1 elsif wrestler_number == 1
match.w1 = @wrestler.id match.w1 = @wrestler.id
match.save
end end
end end
def update_consolation_bye def update_consolation_bye
bout = @wrestler.last_match.bout_number bout = @wrestler.last_match.bout_number
next_match = Match.where("(loser1_name = ? OR loser2_name = ?) AND weight_id = ?","Loser of #{bout}","Loser of #{bout}",@wrestler.weight_id) next_match = @matches.find { |m| m.weight_id == @wrestler.weight_id && (m.loser1_name == "Loser of #{bout}" || m.loser2_name == "Loser of #{bout}") }
if next_match.size > 0 if next_match
next_match.first.replace_loser_name_with_bye("Loser of #{bout}") replace_loser_name_with_bye(next_match, "Loser of #{bout}")
end end
end end
@@ -77,29 +84,20 @@ class DoubleEliminationAdvance
end end
end end
def loser_advance def losers_bracket_advancement
bout = @last_match.bout_number bout = @last_match.bout_number
next_match = Match.where("(loser1_name = ? OR loser2_name = ?) AND weight_id = ?", "Loser of #{bout}", "Loser of #{bout}", @wrestler.weight_id).first next_match = @matches.find { |m| m.weight_id == @wrestler.weight_id && (m.loser1_name == "Loser of #{bout}" || m.loser2_name == "Loser of #{bout}") }
if next_match.present? if next_match.present?
next_match.replace_loser_name_with_wrestler(@wrestler, "Loser of #{bout}") replace_loser_name_with_wrestler(next_match, @wrestler, "Loser of #{bout}")
next_match.reload
if next_match.loser1_name == "BYE" || next_match.loser2_name == "BYE" if next_match.loser1_name == "BYE" || next_match.loser2_name == "BYE"
next_match.winner_id = @wrestler.id next_match.winner_id = @wrestler.id
next_match.win_type = "BYE" next_match.win_type = "BYE"
next_match.score = "" next_match.score = ""
next_match.finished = 1 next_match.finished = 1
# puts "Before save: winner_id=#{next_match.winner_id}" next_match.finished_at = Time.current
@matches_to_advance << next_match
# if next_match.save
# puts "Save successful: winner_id=#{next_match.reload.winner_id}"
# else
# puts "Save failed: #{next_match.errors.full_messages}"
# end
next_match.save
next_match.advance_wrestlers
end end
end end
end end
@@ -107,51 +105,69 @@ class DoubleEliminationAdvance
def advance_double_byes def advance_double_byes
weight = @wrestler.weight weight = @wrestler.weight
weight.matches.select{|m| m.loser1_name == "BYE" and m.loser2_name == "BYE" and m.finished != 1}.each do |match| @matches.select{|m| m.weight_id == weight.id && m.loser1_name == "BYE" and m.loser2_name == "BYE" and m.finished != 1}.each do |match|
match.finished = 1 match.finished = 1
match.finished_at = Time.current
match.score = "" match.score = ""
match.win_type = "BYE" match.win_type = "BYE"
next_match_position_number = (match.bracket_position_number / 2.0).ceil next_match_position_number = (match.bracket_position_number / 2.0).ceil
after_matches = weight.matches.select{|m| m.round > match.round and m.is_consolation_match == match.is_consolation_match }.sort_by{|m| m.round} after_matches = @matches.select{|m| m.weight_id == weight.id && m.round > match.round and m.is_consolation_match == match.is_consolation_match }.sort_by{|m| m.round}
next_matches = weight.matches.select{|m| m.round == after_matches.first.round and m.is_consolation_match == match.is_consolation_match } next if after_matches.empty?
this_round_matches = weight.matches.select{|m| m.round == match.round and m.is_consolation_match == match.is_consolation_match } next_matches = @matches.select{|m| m.weight_id == weight.id && m.round == after_matches.first.round and m.is_consolation_match == match.is_consolation_match }
this_round_matches = @matches.select{|m| m.weight_id == weight.id && m.round == match.round and m.is_consolation_match == match.is_consolation_match }
next_match = nil
if next_matches.size == this_round_matches.size if next_matches.size == this_round_matches.size
next_match = next_matches.select{|m| m.bracket_position_number == match.bracket_position_number}.first next_match = next_matches.select{|m| m.bracket_position_number == match.bracket_position_number}.first
next_match.loser2_name = "BYE" next_match.loser2_name = "BYE" if next_match
next_match.save
elsif next_matches.size < this_round_matches.size and next_matches.size > 0 elsif next_matches.size < this_round_matches.size and next_matches.size > 0
next_match = next_matches.select{|m| m.bracket_position_number == next_match_position_number}.first next_match = next_matches.select{|m| m.bracket_position_number == next_match_position_number}.first
if next_match.bracket_position_number == next_match_position_number if next_match && next_match.bracket_position_number == next_match_position_number
next_match.loser2_name = "BYE" next_match.loser2_name = "BYE"
else elsif next_match
next_match.loser1_name = "BYE" next_match.loser1_name = "BYE"
end end
end end
next_match.save
match.save
end end
end end
def set_bye_for_placement def set_bye_for_placement
weight = @wrestler.weight weight = @wrestler.weight
fifth_finals = weight.matches.select{|match| match.bracket_position == '5/6'}.first fifth_finals = @matches.select{|match| match.weight_id == weight.id && match.bracket_position == '5/6'}.first
seventh_finals = weight.matches.select{|match| match.bracket_position == '7/8'}.first seventh_finals = @matches.select{|match| match.weight_id == weight.id && match.bracket_position == '7/8'}.first
if seventh_finals if seventh_finals
conso_quarter = weight.matches.select{|match| match.bracket_position == 'Conso Quarter'} conso_quarter = @matches.select{|match| match.weight_id == weight.id && match.bracket_position == 'Conso Quarter'}
conso_quarter.each do |match| conso_quarter.each do |match|
if match.loser1_name == "BYE" or match.loser2_name == "BYE" if match.loser1_name == "BYE" or match.loser2_name == "BYE"
seventh_finals.replace_loser_name_with_bye("Loser of #{match.bout_number}") replace_loser_name_with_bye(seventh_finals, "Loser of #{match.bout_number}")
end end
end end
end end
if fifth_finals if fifth_finals
conso_semis = weight.matches.select{|match| match.bracket_position == 'Conso Semis'} conso_semis = @matches.select{|match| match.weight_id == weight.id && match.bracket_position == 'Conso Semis'}
conso_semis.each do |match| conso_semis.each do |match|
if match.loser1_name == "BYE" or match.loser2_name == "BYE" if match.loser1_name == "BYE" or match.loser2_name == "BYE"
fifth_finals.replace_loser_name_with_bye("Loser of #{match.bout_number}") replace_loser_name_with_bye(fifth_finals, "Loser of #{match.bout_number}")
end end
end end
end end
end end
def replace_loser_name_with_wrestler(match, wrestler, loser_name)
if match.loser1_name == loser_name
match.w1 = wrestler.id
end
if match.loser2_name == loser_name
match.w2 = wrestler.id
end
end
def replace_loser_name_with_bye(match, loser_name)
if match.loser1_name == loser_name
match.loser1_name = "BYE"
end
if match.loser2_name == loser_name
match.loser2_name = "BYE"
end
end
end end

View File

@@ -1,63 +1,69 @@
class ModifiedDoubleEliminationAdvance class ModifiedDoubleEliminationAdvance
def initialize(wrestler,last_match) attr_reader :matches_to_advance
def initialize(wrestler,last_match, matches: nil)
@wrestler = wrestler @wrestler = wrestler
@last_match = last_match @last_match = last_match
@matches = matches || @wrestler.weight.matches.to_a
@matches_to_advance = []
@next_match_position_number = (@last_match.bracket_position_number / 2.0) @next_match_position_number = (@last_match.bracket_position_number / 2.0)
end end
def bracket_advancement def bracket_advancement
if @last_match.winner_id == @wrestler.id advance_wrestler
winner_advance
end
if @last_match.winner_id != @wrestler.id
loser_advance
end
advance_double_byes advance_double_byes
set_bye_for_placement set_bye_for_placement
end end
def winner_advance def advance_wrestler
if @last_match.winner == @wrestler
winners_bracket_advancement
elsif @last_match.winner != @wrestler
losers_bracket_advancement
end
end
def winners_bracket_advancement
if (@last_match.loser1_name == "BYE" or @last_match.loser2_name == "BYE") and @last_match.is_championship_match if (@last_match.loser1_name == "BYE" or @last_match.loser2_name == "BYE") and @last_match.is_championship_match
update_consolation_bye update_consolation_bye
end end
if @last_match.bracket_position == "Quarter" if @last_match.bracket_position == "Quarter"
new_match = Match.where("bracket_position = ? AND bracket_position_number = ? AND weight_id = ?","Semis",@next_match_position_number.ceil,@wrestler.weight_id).first new_match = @matches.find { |m| m.bracket_position == "Semis" && m.bracket_position_number == @next_match_position_number.ceil && m.weight_id == @wrestler.weight_id }
update_new_match(new_match, get_wrestler_number) update_new_match(new_match, get_wrestler_number)
elsif @last_match.bracket_position == "Semis" elsif @last_match.bracket_position == "Semis"
new_match = Match.where("bracket_position = ? AND bracket_position_number = ? AND weight_id = ?","1/2",@next_match_position_number.ceil,@wrestler.weight_id).first new_match = @matches.find { |m| m.bracket_position == "1/2" && m.bracket_position_number == @next_match_position_number.ceil && m.weight_id == @wrestler.weight_id }
update_new_match(new_match, get_wrestler_number) update_new_match(new_match, get_wrestler_number)
elsif @last_match.bracket_position == "Conso Semis" elsif @last_match.bracket_position == "Conso Semis"
new_match = Match.where("bracket_position = ? AND bracket_position_number = ? AND weight_id = ?","5/6",@next_match_position_number.ceil,@wrestler.weight_id).first new_match = @matches.find { |m| m.bracket_position == "5/6" && m.bracket_position_number == @next_match_position_number.ceil && m.weight_id == @wrestler.weight_id }
update_new_match(new_match, get_wrestler_number) update_new_match(new_match, get_wrestler_number)
elsif @last_match.bracket_position == "Conso Quarter" elsif @last_match.bracket_position == "Conso Quarter"
# it's a special bracket where a semi loser is not dropping down # it's a special bracket where a semi loser is not dropping down
new_match = Match.where("bracket_position = ? AND bracket_position_number = ? AND weight_id = ?","Conso Semis",@next_match_position_number.ceil,@wrestler.weight_id).first new_match = @matches.find { |m| m.bracket_position == "Conso Semis" && m.bracket_position_number == @next_match_position_number.ceil && m.weight_id == @wrestler.weight_id }
update_new_match(new_match, get_wrestler_number) update_new_match(new_match, get_wrestler_number)
elsif @last_match.bracket_position == "Bracket Round of 16" elsif @last_match.bracket_position == "Bracket Round of 16"
new_match = Match.where("bracket_position_number = ? and weight_id = ? and round > ? and bracket_position = ?", @next_match_position_number.ceil,@wrestler.weight_id, @last_match.round , "Quarter").sort_by{|m| m.round}.first new_match = @matches.select { |m| m.bracket_position_number == @next_match_position_number.ceil && m.weight_id == @wrestler.weight_id && m.round > @last_match.round && m.bracket_position == "Quarter" }.sort_by(&:round).first
update_new_match(new_match, get_wrestler_number) update_new_match(new_match, get_wrestler_number)
elsif @last_match.bracket_position == "Conso Round of 8" elsif @last_match.bracket_position == "Conso Round of 8"
new_match = Match.where("bracket_position_number = ? and weight_id = ? and round > ? and bracket_position = ?", @last_match.bracket_position_number,@wrestler.weight_id, @last_match.round, "Conso Quarter").sort_by{|m| m.round}.first new_match = @matches.select { |m| m.bracket_position_number == @last_match.bracket_position_number && m.weight_id == @wrestler.weight_id && m.round > @last_match.round && m.bracket_position == "Conso Quarter" }.sort_by(&:round).first
update_new_match(new_match, get_wrestler_number) update_new_match(new_match, get_wrestler_number)
end end
end end
def update_new_match(match, wrestler_number) def update_new_match(match, wrestler_number)
return unless match
if wrestler_number == 2 or (match.loser1_name and match.loser1_name.include? "Loser of") if wrestler_number == 2 or (match.loser1_name and match.loser1_name.include? "Loser of")
match.w2 = @wrestler.id match.w2 = @wrestler.id
match.save
elsif wrestler_number == 1 elsif wrestler_number == 1
match.w1 = @wrestler.id match.w1 = @wrestler.id
match.save
end end
end end
def update_consolation_bye def update_consolation_bye
bout = @wrestler.last_match.bout_number bout = @wrestler.last_match.bout_number
next_match = Match.where("(loser1_name = ? OR loser2_name = ?) AND weight_id = ?","Loser of #{bout}","Loser of #{bout}",@wrestler.weight_id) next_match = @matches.find { |m| m.weight_id == @wrestler.weight_id && (m.loser1_name == "Loser of #{bout}" || m.loser2_name == "Loser of #{bout}") }
if next_match.size > 0 if next_match
next_match.first.replace_loser_name_with_bye("Loser of #{bout}") replace_loser_name_with_bye(next_match, "Loser of #{bout}")
end end
end end
@@ -69,29 +75,20 @@ class ModifiedDoubleEliminationAdvance
end end
end end
def loser_advance def losers_bracket_advancement
bout = @last_match.bout_number bout = @last_match.bout_number
next_match = Match.where("(loser1_name = ? OR loser2_name = ?) AND weight_id = ?", "Loser of #{bout}", "Loser of #{bout}", @wrestler.weight_id).first next_match = @matches.find { |m| m.weight_id == @wrestler.weight_id && (m.loser1_name == "Loser of #{bout}" || m.loser2_name == "Loser of #{bout}") }
if next_match.present? if next_match.present?
next_match.replace_loser_name_with_wrestler(@wrestler, "Loser of #{bout}") replace_loser_name_with_wrestler(next_match, @wrestler, "Loser of #{bout}")
next_match.reload
if next_match.loser1_name == "BYE" || next_match.loser2_name == "BYE" if next_match.loser1_name == "BYE" || next_match.loser2_name == "BYE"
next_match.winner_id = @wrestler.id next_match.winner_id = @wrestler.id
next_match.win_type = "BYE" next_match.win_type = "BYE"
next_match.score = "" next_match.score = ""
next_match.finished = 1 next_match.finished = 1
# puts "Before save: winner_id=#{next_match.winner_id}" next_match.finished_at = Time.current
@matches_to_advance << next_match
# if next_match.save
# puts "Save successful: winner_id=#{next_match.reload.winner_id}"
# else
# puts "Save failed: #{next_match.errors.full_messages}"
# end
next_match.save
next_match.advance_wrestlers
end end
end end
end end
@@ -99,43 +96,53 @@ class ModifiedDoubleEliminationAdvance
def advance_double_byes def advance_double_byes
weight = @wrestler.weight weight = @wrestler.weight
weight.matches.select{|m| m.loser1_name == "BYE" and m.loser2_name == "BYE" and m.finished != 1}.each do |match| @matches.select{|m| m.weight_id == weight.id && m.loser1_name == "BYE" and m.loser2_name == "BYE" and m.finished != 1}.each do |match|
match.finished = 1 match.finished = 1
match.finished_at = Time.current
match.score = "" match.score = ""
match.win_type = "BYE" match.win_type = "BYE"
next_match_position_number = (match.bracket_position_number / 2.0).ceil next_match_position_number = (match.bracket_position_number / 2.0).ceil
after_matches = weight.matches.select{|m| m.round > match.round and m.is_consolation_match == match.is_consolation_match }.sort_by{|m| m.round} after_matches = @matches.select{|m| m.weight_id == weight.id && m.round > match.round and m.is_consolation_match == match.is_consolation_match }.sort_by{|m| m.round}
next_matches = weight.matches.select{|m| m.round == after_matches.first.round and m.is_consolation_match == match.is_consolation_match } next if after_matches.empty?
this_round_matches = weight.matches.select{|m| m.round == match.round and m.is_consolation_match == match.is_consolation_match } next_matches = @matches.select{|m| m.weight_id == weight.id && m.round == after_matches.first.round and m.is_consolation_match == match.is_consolation_match }
this_round_matches = @matches.select{|m| m.weight_id == weight.id && m.round == match.round and m.is_consolation_match == match.is_consolation_match }
next_match = nil
if next_matches.size == this_round_matches.size if next_matches.size == this_round_matches.size
next_match = next_matches.select{|m| m.bracket_position_number == match.bracket_position_number}.first next_match = next_matches.select{|m| m.bracket_position_number == match.bracket_position_number}.first
next_match.loser2_name = "BYE" next_match.loser2_name = "BYE" if next_match
next_match.save
elsif next_matches.size < this_round_matches.size and next_matches.size > 0 elsif next_matches.size < this_round_matches.size and next_matches.size > 0
next_match = next_matches.select{|m| m.bracket_position_number == next_match_position_number}.first next_match = next_matches.select{|m| m.bracket_position_number == next_match_position_number}.first
if next_match.bracket_position_number == next_match_position_number if next_match && next_match.bracket_position_number == next_match_position_number
next_match.loser2_name = "BYE" next_match.loser2_name = "BYE"
else elsif next_match
next_match.loser1_name = "BYE" next_match.loser1_name = "BYE"
end end
end end
next_match.save
match.save
end end
end end
def set_bye_for_placement def set_bye_for_placement
weight = @wrestler.weight weight = @wrestler.weight
seventh_finals = weight.matches.select{|match| match.bracket_position == '7/8'}.first seventh_finals = @matches.select{|match| match.weight_id == weight.id && match.bracket_position == '7/8'}.first
if seventh_finals if seventh_finals
conso_quarter = weight.matches.select{|match| match.bracket_position == 'Conso Semis'} conso_quarter = @matches.select{|match| match.weight_id == weight.id && match.bracket_position == 'Conso Semis'}
conso_quarter.each do |match| conso_quarter.each do |match|
if match.loser1_name == "BYE" or match.loser2_name == "BYE" if match.loser1_name == "BYE" or match.loser2_name == "BYE"
seventh_finals.replace_loser_name_with_bye("Loser of #{match.bout_number}") replace_loser_name_with_bye(seventh_finals, "Loser of #{match.bout_number}")
end end
end end
end end
end end
def replace_loser_name_with_wrestler(match, wrestler, loser_name)
match.w1 = wrestler.id if match.loser1_name == loser_name
match.w2 = wrestler.id if match.loser2_name == loser_name
end
def replace_loser_name_with_bye(match, loser_name)
match.loser1_name = "BYE" if match.loser1_name == loser_name
match.loser2_name = "BYE" if match.loser2_name == loser_name
end
end end

View File

@@ -1,8 +1,13 @@
class PoolAdvance class PoolAdvance
def initialize(wrestler) attr_reader :matches_to_advance
def initialize(wrestler, last_match, matches: nil, wrestlers: nil)
@wrestler = wrestler @wrestler = wrestler
@last_match = @wrestler.last_match @last_match = last_match
@matches = matches || @wrestler.weight.matches.to_a
@wrestlers = wrestlers || @wrestler.weight.wrestlers.to_a
@matches_to_advance = []
end end
def advanceWrestler def advanceWrestler
@@ -17,59 +22,68 @@ class PoolAdvance
def poolToBracketAdvancment def poolToBracketAdvancment
pool = @wrestler.pool pool = @wrestler.pool
# This has to always run because the last match in a pool might not be a pool winner or runner up # This has to always run because the last match in a pool might not be a pool winner or runner up
winner = Wrestler.where("weight_id = ? and pool_placement = 1 and pool = ?",@wrestler.weight.id, pool).first winner = @wrestlers.find { |w| w.weight_id == @wrestler.weight.id && w.pool_placement == 1 && w.pool == pool }
runner_up = Wrestler.where("weight_id = ? and pool_placement = 2 and pool = ?",@wrestler.weight.id, pool).first runner_up = @wrestlers.find { |w| w.weight_id == @wrestler.weight.id && w.pool_placement == 2 && w.pool == pool }
if runner_up if runner_up
runner_up_match = Match.where("weight_id = ? and (loser1_name = ? or loser2_name = ?)",@wrestler.weight.id, "Runner Up Pool #{pool}", "Runner Up Pool #{pool}").first runner_up_match = @matches.find { |m| m.weight_id == @wrestler.weight.id && (m.loser1_name == "Runner Up Pool #{pool}" || m.loser2_name == "Runner Up Pool #{pool}") }
runner_up_match.replace_loser_name_with_wrestler(runner_up,"Runner Up Pool #{pool}") replace_loser_name_with_wrestler(runner_up_match, runner_up, "Runner Up Pool #{pool}") if runner_up_match
end end
if winner if winner
winner_match = Match.where("weight_id = ? and (loser1_name = ? or loser2_name = ?)",@wrestler.weight.id, "Winner Pool #{pool}", "Winner Pool #{pool}").first winner_match = @matches.find { |m| m.weight_id == @wrestler.weight.id && (m.loser1_name == "Winner Pool #{pool}" || m.loser2_name == "Winner Pool #{pool}") }
winner_match.replace_loser_name_with_wrestler(winner,"Winner Pool #{pool}") replace_loser_name_with_wrestler(winner_match, winner, "Winner Pool #{pool}") if winner_match
end end
end end
def bracketAdvancment def bracketAdvancment
if @last_match.winner_id == @wrestler.id advance_wrestlers
winnerAdvance
end
if @last_match.winner_id != @wrestler.id
loserAdvance
end
end end
def winnerAdvance def advance_wrestlers
# Advance winner
if @last_match.winner == @wrestler
winner_advance
# Advance loser
elsif @last_match.winner != @wrestler
loser_advance
end
end
def winner_advance
if @wrestler.last_match.bracket_position == "Quarter" if @wrestler.last_match.bracket_position == "Quarter"
new_match = Match.where("bracket_position = ? AND bracket_position_number = ? AND weight_id = ?","Semis",@wrestler.next_match_position_number.ceil,@wrestler.weight_id).first new_match = @matches.find { |m| m.bracket_position == "Semis" && m.bracket_position_number == @wrestler.next_match_position_number.ceil && m.weight_id == @wrestler.weight_id }
updateNewMatch(new_match) updateNewMatch(new_match)
end end
if @wrestler.last_match.bracket_position == "Semis" if @wrestler.last_match.bracket_position == "Semis"
new_match = Match.where("bracket_position = ? AND bracket_position_number = ? AND weight_id = ?","1/2",@wrestler.next_match_position_number.ceil,@wrestler.weight_id).first new_match = @matches.find { |m| m.bracket_position == "1/2" && m.bracket_position_number == @wrestler.next_match_position_number.ceil && m.weight_id == @wrestler.weight_id }
updateNewMatch(new_match) updateNewMatch(new_match)
end end
if @wrestler.last_match.bracket_position == "Conso Semis" if @wrestler.last_match.bracket_position == "Conso Semis"
new_match = Match.where("bracket_position = ? AND bracket_position_number = ? AND weight_id = ?","5/6",@wrestler.next_match_position_number.ceil,@wrestler.weight_id).first new_match = @matches.find { |m| m.bracket_position == "5/6" && m.bracket_position_number == @wrestler.next_match_position_number.ceil && m.weight_id == @wrestler.weight_id }
updateNewMatch(new_match) updateNewMatch(new_match)
end end
end end
def updateNewMatch(match) def updateNewMatch(match)
return unless match
if @wrestler.next_match_position_number == @wrestler.next_match_position_number.ceil if @wrestler.next_match_position_number == @wrestler.next_match_position_number.ceil
match.w2 = @wrestler.id match.w2 = @wrestler.id
match.save
end end
if @wrestler.next_match_position_number != @wrestler.next_match_position_number.ceil if @wrestler.next_match_position_number != @wrestler.next_match_position_number.ceil
match.w1 = @wrestler.id match.w1 = @wrestler.id
match.save
end end
end end
def loserAdvance def loser_advance
bout = @wrestler.last_match.bout_number bout = @wrestler.last_match.bout_number
next_match = Match.where("(loser1_name = ? OR loser2_name = ?) AND weight_id = ?","Loser of #{bout}","Loser of #{bout}",@wrestler.weight_id) next_match = @matches.find { |m| m.weight_id == @wrestler.weight_id && (m.loser1_name == "Loser of #{bout}" || m.loser2_name == "Loser of #{bout}") }
if next_match.size > 0 if next_match
next_match.first.replace_loser_name_with_wrestler(@wrestler,"Loser of #{bout}") replace_loser_name_with_wrestler(next_match, @wrestler, "Loser of #{bout}")
end end
end end
def replace_loser_name_with_wrestler(match, wrestler, loser_name)
match.w1 = wrestler.id if match.loser1_name == loser_name
match.w2 = wrestler.id if match.loser2_name == loser_name
end
end end

View File

@@ -4,8 +4,6 @@ class PoolOrder
end end
def getPoolOrder def getPoolOrder
# clear caching for weight for bracket page
@wrestlers.first.weight.touch
setOriginalPoints setOriginalPoints
while checkForTies(@wrestlers) == true while checkForTies(@wrestlers) == true
getWrestlersOrderByPoolAdvancePoints.each do |wrestler| getWrestlersOrderByPoolAdvancePoints.each do |wrestler|
@@ -18,7 +16,6 @@ class PoolOrder
getWrestlersOrderByPoolAdvancePoints.each_with_index do |wrestler, index| getWrestlersOrderByPoolAdvancePoints.each_with_index do |wrestler, index|
placement = index + 1 placement = index + 1
wrestler.pool_placement = placement wrestler.pool_placement = placement
wrestler.save
end end
@wrestlers.sort_by{|w| w.poolAdvancePoints}.reverse! @wrestlers.sort_by{|w| w.poolAdvancePoints}.reverse!
end end
@@ -29,7 +26,6 @@ class PoolOrder
def setOriginalPoints def setOriginalPoints
@wrestlers.each do |w| @wrestlers.each do |w|
matches = w.matches.reload
w.pool_placement_tiebreaker = nil w.pool_placement_tiebreaker = nil
w.pool_placement = nil w.pool_placement = nil
w.poolAdvancePoints = w.pool_wins.size w.poolAdvancePoints = w.pool_wins.size
@@ -80,10 +76,13 @@ class PoolOrder
def headToHead(wrestlers_with_same_points) def headToHead(wrestlers_with_same_points)
wrestlers_with_same_points.each do |wr| wrestlers_with_same_points.each do |wr|
otherWrestler = wrestlers_with_same_points.select{|w| w.id != wr.id}.first otherWrestler = wrestlers_with_same_points.select{|w| w.id != wr.id}.first
if otherWrestler and wr.match_against(otherWrestler).select{|match| match.bracket_position == "Pool"}.first.winner_id == wr.id if otherWrestler
addPointsToWrestlersAhead(wr) matches = wr.match_against(otherWrestler).select { |match| match.bracket_position == "Pool" }
wr.pool_placement_tiebreaker = "Head to Head" if matches.any? && matches.first.winner == wr
addPointsToWrestlersAhead(wr)
wr.pool_placement_tiebreaker = "Head to Head"
addPoints(wr) addPoints(wr)
end
end end
end end
end end

View File

@@ -0,0 +1,55 @@
class CalculateSchoolScore
def initialize(school)
@school = school
end
def calculate
school = preload_school_context
score = calculate_score_value(school)
persist_school_score(school.id, score)
score
end
def calculate_value
school = preload_school_context
calculate_score_value(school)
end
private
def preload_school_context
School
.includes(
:deductedPoints,
wrestlers: [
:deductedPoints,
{ matches_as_w1: :winner },
{ matches_as_w2: :winner },
{ weight: [:matches, { tournament: { weights: :wrestlers } }] }
]
)
.find(@school.id)
end
def calculate_score_value(school)
total_points_scored_by_wrestlers(school) - total_points_deducted(school)
end
def total_points_scored_by_wrestlers(school)
school.wrestlers.sum { |wrestler| CalculateWrestlerTeamScore.new(wrestler).totalScore }
end
def total_points_deducted(school)
school.deductedPoints.sum(&:points)
end
def persist_school_score(school_id, score)
School.upsert_all([
{
id: school_id,
score: score,
updated_at: Time.current
}
])
end
end

View File

@@ -3,130 +3,184 @@ class DoubleEliminationGenerateLoserNames
@tournament = tournament @tournament = tournament
end end
def assign_loser_names # Compatibility wrapper. Returns transformed rows and does not persist.
def assign_loser_names(match_rows = nil)
rows = match_rows || @tournament.matches.where(tournament_id: @tournament.id).map { |m| m.attributes.symbolize_keys }
@tournament.weights.each do |weight| @tournament.weights.each do |weight|
assign_loser_names_for_weight(weight) next unless weight.calculate_bracket_size > 2
advance_bye_matches_championship(weight.matches.reload)
assign_loser_names_in_memory(weight, rows)
assign_bye_outcomes_in_memory(weight, rows)
end end
rows
end end
def define_losername_championship_mappings(bracket_size) def assign_loser_names_in_memory(weight, match_rows)
# Use hashes instead of arrays for mappings
case bracket_size
when 4
[
{ conso_bracket_position: "3/4", championship_bracket_position: "Semis", cross_bracket: false, both_wrestlers: true }
]
when 8
[
{ conso_bracket_position: "Conso Quarter", championship_bracket_position: "Quarter", cross_bracket: false, both_wrestlers: true },
{ conso_bracket_position: "Conso Semis", championship_bracket_position: "Semis", cross_bracket: true, both_wrestlers: false }
]
when 16
[
{ conso_bracket_position: "Conso Round of 8.1", championship_bracket_position: "Bracket Round of 16", cross_bracket: false, both_wrestlers: true },
{ conso_bracket_position: "Conso Round of 8.2", championship_bracket_position: "Quarter", cross_bracket: true, both_wrestlers: false },
{ conso_bracket_position: "Conso Semis", championship_bracket_position: "Semis", cross_bracket: false, both_wrestlers: false }
]
when 32
[
{ conso_bracket_position: "Conso Round of 16.1", championship_bracket_position: "Bracket Round of 32", cross_bracket: false, both_wrestlers: true },
{ conso_bracket_position: "Conso Round of 16.2", championship_bracket_position: "Bracket Round of 16", cross_bracket: true, both_wrestlers: false },
{ conso_bracket_position: "Conso Round of 8.2", championship_bracket_position: "Quarter", cross_bracket: false, both_wrestlers: false },
{ conso_bracket_position: "Conso Semis", championship_bracket_position: "Semis", cross_bracket: true, both_wrestlers: false },
]
else
nil
end
end
def assign_loser_names_for_weight(weight)
number_of_placers = @tournament.number_of_placers
bracket_size = weight.calculate_bracket_size bracket_size = weight.calculate_bracket_size
matches_by_weight = weight.matches.reload return if bracket_size <= 2
loser_name_championship_mappings = define_losername_championship_mappings(bracket_size) rows = match_rows.select { |row| row[:weight_id] == weight.id }
num_placers = @tournament.number_of_placers
loser_name_championship_mappings.each do |mapping| champ_rounds = dynamic_championship_rounds(bracket_size)
conso_bracket_position = mapping[:conso_bracket_position] conso_rounds = dynamic_consolation_rounds(bracket_size)
championship_bracket_position = mapping[:championship_bracket_position] first_round = { bracket_position: first_round_label(bracket_size) }
cross_bracket = mapping[:cross_bracket] champ_full = [first_round] + champ_rounds
both_wrestlers = mapping[:both_wrestlers]
conso_matches = matches_by_weight.select do |match| mappings = []
match.bracket_position == conso_bracket_position && match.bracket_position == conso_bracket_position champ_full[0...-1].each_with_index do |champ_info, i|
end.sort_by(&:bracket_position_number) map_idx = i.zero? ? 0 : (2 * i - 1)
next if map_idx < 0 || map_idx >= conso_rounds.size
championship_matches = matches_by_weight.select do |match| mappings << {
match.bracket_position == championship_bracket_position && match.bracket_position == championship_bracket_position championship_bracket_position: champ_info[:bracket_position],
end.sort_by(&:bracket_position_number) consolation_bracket_position: conso_rounds[map_idx][:bracket_position],
both_wrestlers: i.zero?,
champ_round_index: i
}
end
conso_matches.reverse! if cross_bracket mappings.each do |map|
champ = rows.select { |r| r[:bracket_position] == map[:championship_bracket_position] }
.sort_by { |r| r[:bracket_position_number] }
conso = rows.select { |r| r[:bracket_position] == map[:consolation_bracket_position] }
.sort_by { |r| r[:bracket_position_number] }
conso.reverse! if map[:champ_round_index].odd?
championship_bracket_position_number = 1 idx = 0
conso_matches.each do |match| is_first_feed = map[:champ_round_index].zero?
bout_number1 = championship_matches.find do |bout_match| conso.each do |cm|
bout_match.bracket_position_number == championship_bracket_position_number champ_match1 = champ[idx]
end.bout_number if champ_match1
if is_first_feed && single_competitor_match_row?(champ_match1)
match.loser1_name = "Loser of #{bout_number1}" cm[:loser1_name] = "BYE"
if both_wrestlers else
championship_bracket_position_number += 1 cm[:loser1_name] = "Loser of #{champ_match1[:bout_number]}"
bout_number2 = championship_matches.find do |bout_match| end
bout_match.bracket_position_number == championship_bracket_position_number else
end.bout_number cm[:loser1_name] = nil
match.loser2_name = "Loser of #{bout_number2}"
end end
championship_bracket_position_number += 1
if map[:both_wrestlers]
idx += 1
champ_match2 = champ[idx]
if champ_match2
if is_first_feed && single_competitor_match_row?(champ_match2)
cm[:loser2_name] = "BYE"
else
cm[:loser2_name] = "Loser of #{champ_match2[:bout_number]}"
end
else
cm[:loser2_name] = nil
end
end
idx += 1
end end
end end
conso_semi_matches = matches_by_weight.select { |match| match.bracket_position == "Conso Semis" } if bracket_size >= 5 && num_placers >= 6 && weight.wrestlers.size > 4
conso_quarter_matches = matches_by_weight.select { |match| match.bracket_position == "Conso Quarter" } conso_semis = rows.select { |r| r[:bracket_position] == "Conso Semis" }.sort_by { |r| r[:bracket_position_number] }
m56 = rows.find { |r| r[:bracket_position] == "5/6" }
if number_of_placers >= 6 && weight.wrestlers.size >= 5 if conso_semis.size >= 2 && m56
five_six_match = matches_by_weight.find { |match| match.bracket_position == "5/6" } m56[:loser1_name] = "Loser of #{conso_semis[0][:bout_number]}"
bout_number1 = conso_semi_matches.find { |match| match.bracket_position_number == 1 }.bout_number m56[:loser2_name] = "Loser of #{conso_semis[1][:bout_number]}"
bout_number2 = conso_semi_matches.find { |match| match.bracket_position_number == 2 }.bout_number
five_six_match.loser1_name = "Loser of #{bout_number1}"
five_six_match.loser2_name = "Loser of #{bout_number2}"
end
if number_of_placers >= 8 && weight.wrestlers.size >= 7
seven_eight_match = matches_by_weight.find { |match| match.bracket_position == "7/8" }
bout_number1 = conso_quarter_matches.find { |match| match.bracket_position_number == 1 }.bout_number
bout_number2 = conso_quarter_matches.find { |match| match.bracket_position_number == 2 }.bout_number
seven_eight_match.loser1_name = "Loser of #{bout_number1}"
seven_eight_match.loser2_name = "Loser of #{bout_number2}"
end
save_matches(matches_by_weight)
end
def save_matches(matches)
matches.each(&:save!)
end
def advance_bye_matches_championship(matches)
first_round = matches.sort_by{|m| m.round}.first.round
matches.select do |m|
m.round == first_round
end.sort_by(&:bracket_position_number).each do |match|
next unless match.w1.nil? || match.w2.nil?
match.finished = 1
match.win_type = "BYE"
if match.w1
match.winner_id = match.w1
match.loser2_name = "BYE"
elsif match.w2
match.winner_id = match.w2
match.loser1_name = "BYE"
end end
match.score = "" end
match.save
match.advance_wrestlers if bracket_size >= 7 && num_placers >= 8 && weight.wrestlers.size > 6
conso_quarters = rows.select { |r| r[:bracket_position] == "Conso Quarter" }.sort_by { |r| r[:bracket_position_number] }
m78 = rows.find { |r| r[:bracket_position] == "7/8" }
if conso_quarters.size >= 2 && m78
m78[:loser1_name] = "Loser of #{conso_quarters[0][:bout_number]}"
m78[:loser2_name] = "Loser of #{conso_quarters[1][:bout_number]}"
end
end
end
def assign_bye_outcomes_in_memory(weight, match_rows)
bracket_size = weight.calculate_bracket_size
return if bracket_size <= 2
rows = match_rows.select { |r| r[:weight_id] == weight.id }
first_round = rows.map { |r| r[:round] }.compact.min
rows.select { |r| r[:round] == first_round }.each { |row| apply_bye_to_row(row) }
first_conso = dynamic_consolation_rounds(bracket_size).first
if first_conso
rows.select { |r| r[:round] == first_conso[:round] && r[:bracket_position] == first_conso[:bracket_position] }
.each { |row| apply_bye_to_row(row) }
end
end
def apply_bye_to_row(row)
return unless single_competitor_match_row?(row)
row[:finished] = 1
row[:win_type] = "BYE"
if row[:w1]
row[:winner_id] = row[:w1]
row[:loser2_name] = "BYE"
else
row[:winner_id] = row[:w2]
row[:loser1_name] = "BYE"
end
row[:score] = ""
end
def single_competitor_match_row?(row)
[row[:w1], row[:w2]].compact.size == 1
end
def first_round_label(size)
case size
when 2 then "Final"
when 4 then "Semis"
when 8 then "Quarter"
else "Bracket Round of #{size}"
end
end
def dynamic_championship_rounds(size)
total = Math.log2(size).to_i
(1...total).map do |i|
participants = size / (2**i)
{ bracket_position: bracket_label(participants), round: i + 1 }
end
end
def dynamic_consolation_rounds(size)
total_log2 = Math.log2(size).to_i
return [] if total_log2 <= 1
max_j_val = (2 * (total_log2 - 1) - 1)
(1..max_j_val).map do |j|
current_participants = size / (2**((j.to_f / 2).ceil))
{
bracket_position: consolation_label(current_participants, j, size),
round: j
}
end
end
def bracket_label(participants)
case participants
when 2 then "1/2"
when 4 then "Semis"
when 8 then "Quarter"
else "Bracket Round of #{participants}"
end
end
def consolation_label(participants, j, bracket_size)
max_j_for_bracket = (2 * (Math.log2(bracket_size).to_i - 1) - 1)
if participants == 2 && j == max_j_for_bracket
"3/4"
elsif participants == 4
j.odd? ? "Conso Quarter" : "Conso Semis"
else
suffix = j.odd? ? ".1" : ".2"
"Conso Round of #{participants}#{suffix}"
end end
end end
end end

View File

@@ -1,38 +1,42 @@
class DoubleEliminationMatchGeneration class DoubleEliminationMatchGeneration
def initialize(tournament) def initialize(tournament, weights: nil)
@tournament = tournament @tournament = tournament
@weights = weights
end end
def generate_matches def generate_matches
# build_match_rows
# PHASE 1: Generate matches (with local round definitions). end
#
@tournament.weights.each do |weight| def build_match_rows
generate_matches_for_weight(weight) rows_by_weight_id = {}
generation_weights.each do |weight|
rows_by_weight_id[weight.id] = generate_match_rows_for_weight(weight)
end end
# align_rows_to_largest_bracket(rows_by_weight_id)
# PHASE 2: Align all rounds to match the largest brackets definitions. rows_by_weight_id.values.flatten
#
align_all_rounds_to_largest_bracket
end end
########################################################################### ###########################################################################
# PHASE 1: Generate all matches for each bracket, using a single definition. # PHASE 1: Generate all matches for each bracket, using a single definition.
########################################################################### ###########################################################################
def generate_matches_for_weight(weight) def generate_match_rows_for_weight(weight)
bracket_size = weight.calculate_bracket_size bracket_size = weight.calculate_bracket_size
bracket_info = define_bracket_matches(bracket_size) bracket_info = define_bracket_matches(bracket_size)
return unless bracket_info return [] unless bracket_info
rows = []
# 1) Round one matchups # 1) Round one matchups
bracket_info[:round_one_matchups].each_with_index do |matchup, idx| bracket_info[:round_one_matchups].each_with_index do |matchup, idx|
seed1, seed2 = matchup[:seeds] seed1, seed2 = matchup[:seeds]
bracket_position = matchup[:bracket_position] bracket_position = matchup[:bracket_position]
bracket_pos_number = idx + 1 bracket_pos_number = idx + 1
round_number = matchup[:round] # Use the round from our definition round_number = matchup[:round]
create_matchup_from_seed( rows << create_matchup_from_seed(
seed1, seed1,
seed2, seed2,
bracket_position, bracket_position,
@@ -49,7 +53,7 @@ class DoubleEliminationMatchGeneration
round_number = round_info[:round] round_number = round_info[:round]
matches_this_round.times do |i| matches_this_round.times do |i|
create_matchup( rows << create_matchup(
nil, nil,
nil, nil,
bracket_position, bracket_position,
@@ -67,7 +71,7 @@ class DoubleEliminationMatchGeneration
round_number = round_info[:round] round_number = round_info[:round]
matches_this_round.times do |i| matches_this_round.times do |i|
create_matchup( rows << create_matchup(
nil, nil,
nil, nil,
bracket_position, bracket_position,
@@ -77,169 +81,116 @@ class DoubleEliminationMatchGeneration
) )
end end
#
# 5/6, 7/8 placing logic # 5/6, 7/8 placing logic
# if weight.wrestlers.size >= 5 && @tournament.number_of_placers >= 6 && matches_this_round == 1
if weight.wrestlers.size >= 5 rows << create_matchup(nil, nil, "5/6", 1, round_number, weight)
if @tournament.number_of_placers >= 6 && matches_this_round == 1
create_matchup(nil, nil, "5/6", 1, round_number, weight)
end
end end
if weight.wrestlers.size >= 7 if weight.wrestlers.size >= 7 && @tournament.number_of_placers >= 8 && matches_this_round == 1
if @tournament.number_of_placers >= 8 && matches_this_round == 1 rows << create_matchup(nil, nil, "7/8", 1, round_number, weight)
create_matchup(nil, nil, "7/8", 1, round_number, weight)
end
end end
end end
rows
end
# Single bracket definition dynamically generated for any power-of-two bracket size.
# Returns a hash with :round_one_matchups, :championship_rounds, and :consolation_rounds.
def define_bracket_matches(bracket_size)
# Only support brackets that are powers of two
return nil unless (bracket_size & (bracket_size - 1)).zero?
# 1) Generate the seed sequence (e.g., [1,8,5,4,...] for size=8)
seeds = generate_seed_sequence(bracket_size)
# 2) Pair seeds into first-round matchups, sorting so lower seed is w1
round_one = seeds.each_slice(2).map.with_index do |(s1, s2), idx|
a, b = [s1, s2].sort
{
seeds: [a, b],
bracket_position: first_round_label(bracket_size),
round: 1
}
end
# 3) Build full structure, including dynamic championship & consolation rounds
{
round_one_matchups: round_one,
championship_rounds: dynamic_championship_rounds(bracket_size),
consolation_rounds: dynamic_consolation_rounds(bracket_size)
}
end
# Returns a human-readable label for the first round based on bracket size.
def first_round_label(bracket_size)
case bracket_size
when 2 then "1/2"
when 4 then "Semis"
when 8 then "Quarter"
else "Bracket Round of #{bracket_size}"
end
end end
# # Dynamically generate championship rounds for any power-of-two bracket size.
# Single bracket definition that includes both bracket_position and round. def dynamic_championship_rounds(bracket_size)
# If you later decide to tweak round numbering, you do it in ONE place. rounds = []
# num_rounds = Math.log2(bracket_size).to_i
def define_bracket_matches(bracket_size) # i: 1 -> first post-initial round, up to num_rounds-1 (final)
case bracket_size (1...num_rounds).each do |i|
when 4 participants = bracket_size / (2**i)
{ number_of_matches = participants / 2
round_one_matchups: [ bracket_position = case participants
# First round is Semis => round=1 when 2 then "1/2"
{ seeds: [1, 4], bracket_position: "Semis", round: 1 }, when 4 then "Semis"
{ seeds: [2, 3], bracket_position: "Semis", round: 1 } when 8 then "Quarter"
], else "Bracket Round of #{participants}"
championship_rounds: [ end
# Final => round=2 round_number = i * 2
{ bracket_position: "1/2", number_of_matches: 1, round: 2 } rounds << { bracket_position: bracket_position,
], number_of_matches: number_of_matches,
consolation_rounds: [ round: round_number }
# 3rd place => round=2
{ bracket_position: "3/4", number_of_matches: 1, round: 2 }
]
}
when 8
{
round_one_matchups: [
# Quarter => round=1
{ seeds: [1, 8], bracket_position: "Quarter", round: 1 },
{ seeds: [4, 5], bracket_position: "Quarter", round: 1 },
{ seeds: [3, 6], bracket_position: "Quarter", round: 1 },
{ seeds: [2, 7], bracket_position: "Quarter", round: 1 }
],
championship_rounds: [
# Semis => round=2, Final => round=4
{ bracket_position: "Semis", number_of_matches: 2, round: 2 },
{ bracket_position: "1/2", number_of_matches: 1, round: 4 }
],
consolation_rounds: [
# Conso Quarter => round=2, Conso Semis => round=3, 3/4 => round=4
{ bracket_position: "Conso Quarter", number_of_matches: 2, round: 2 },
{ bracket_position: "Conso Semis", number_of_matches: 2, round: 3 },
{ bracket_position: "3/4", number_of_matches: 1, round: 4 }
]
}
when 16
{
round_one_matchups: [
{ seeds: [1,16], bracket_position: "Bracket Round of 16", round: 1 },
{ seeds: [8,9], bracket_position: "Bracket Round of 16", round: 1 },
{ seeds: [5,12], bracket_position: "Bracket Round of 16", round: 1 },
{ seeds: [4,13], bracket_position: "Bracket Round of 16", round: 1 },
{ seeds: [3,14], bracket_position: "Bracket Round of 16", round: 1 },
{ seeds: [6,11], bracket_position: "Bracket Round of 16", round: 1 },
{ seeds: [7,10], bracket_position: "Bracket Round of 16", round: 1 },
{ seeds: [2,15], bracket_position: "Bracket Round of 16", round: 1 }
],
championship_rounds: [
# Quarter => round=2, Semis => round=4, Final => round=6
{ bracket_position: "Quarter", number_of_matches: 4, round: 2 },
{ bracket_position: "Semis", number_of_matches: 2, round: 4 },
{ bracket_position: "1/2", number_of_matches: 1, round: 6 }
],
consolation_rounds: [
# Just carry over your standard numbering
{ bracket_position: "Conso Round of 8.1", number_of_matches: 4, round: 2 },
{ bracket_position: "Conso Round of 8.2", number_of_matches: 4, round: 3 },
{ bracket_position: "Conso Quarter", number_of_matches: 2, round: 4 },
{ bracket_position: "Conso Semis", number_of_matches: 2, round: 5 },
{ bracket_position: "3/4", number_of_matches: 1, round: 6 }
]
}
when 32
{
round_one_matchups: [
{ seeds: [1,32], bracket_position: "Bracket Round of 32", round: 1 },
{ seeds: [16,17], bracket_position: "Bracket Round of 32", round: 1 },
{ seeds: [9,24], bracket_position: "Bracket Round of 32", round: 1 },
{ seeds: [8,25], bracket_position: "Bracket Round of 32", round: 1 },
{ seeds: [5,28], bracket_position: "Bracket Round of 32", round: 1 },
{ seeds: [12,21], bracket_position: "Bracket Round of 32", round: 1 },
{ seeds: [13,20], bracket_position: "Bracket Round of 32", round: 1 },
{ seeds: [4,29], bracket_position: "Bracket Round of 32", round: 1 },
{ seeds: [3,30], bracket_position: "Bracket Round of 32", round: 1 },
{ seeds: [14,19], bracket_position: "Bracket Round of 32", round: 1 },
{ seeds: [11,22], bracket_position: "Bracket Round of 32", round: 1 },
{ seeds: [6,27], bracket_position: "Bracket Round of 32", round: 1 },
{ seeds: [7,26], bracket_position: "Bracket Round of 32", round: 1 },
{ seeds: [10,23], bracket_position: "Bracket Round of 32", round: 1 },
{ seeds: [15,18], bracket_position: "Bracket Round of 32", round: 1 },
{ seeds: [2,31], bracket_position: "Bracket Round of 32", round: 1 }
],
championship_rounds: [
{ bracket_position: "Bracket Round of 16", number_of_matches: 8, round: 2 },
{ bracket_position: "Quarter", number_of_matches: 4, round: 4 },
{ bracket_position: "Semis", number_of_matches: 2, round: 6 },
{ bracket_position: "1/2", number_of_matches: 1, round: 8 }
],
consolation_rounds: [
{ bracket_position: "Conso Round of 16.1", number_of_matches: 8, round: 2 },
{ bracket_position: "Conso Round of 16.2", number_of_matches: 8, round: 3 },
{ bracket_position: "Conso Round of 8.1", number_of_matches: 4, round: 4 },
{ bracket_position: "Conso Round of 8.2", number_of_matches: 4, round: 5 },
{ bracket_position: "Conso Quarter", number_of_matches: 2, round: 6 },
{ bracket_position: "Conso Semis", number_of_matches: 2, round: 7 },
{ bracket_position: "3/4", number_of_matches: 1, round: 8 }
]
}
else
nil
end end
rounds
end
# Dynamically generate consolation rounds for any power-of-two bracket size.
def dynamic_consolation_rounds(bracket_size)
rounds = []
num_rounds = Math.log2(bracket_size).to_i
total_conso = 2 * (num_rounds - 1) - 1
(1..total_conso).each do |j|
participants = bracket_size / (2**((j.to_f / 2).ceil))
number_of_matches = participants / 2
bracket_position = case participants
when 2 then "3/4"
when 4
j.odd? ? "Conso Quarter" : "Conso Semis"
else
suffix = j.odd? ? ".1" : ".2"
"Conso Round of #{participants}#{suffix}"
end
round_number = j + 1
rounds << { bracket_position: bracket_position,
number_of_matches: number_of_matches,
round: round_number }
end
rounds
end end
########################################################################### ###########################################################################
# PHASE 2: Overwrite rounds in all smaller brackets to match the largest one. # PHASE 2: Overwrite rounds in all smaller brackets to match the largest one.
########################################################################### ###########################################################################
def align_all_rounds_to_largest_bracket def align_rows_to_largest_bracket(rows_by_weight_id)
# largest_weight = generation_weights.max_by { |w| w.calculate_bracket_size }
# 1) Find the bracket size that is largest
#
largest_weight = @tournament.weights.max_by { |w| w.calculate_bracket_size }
return unless largest_weight return unless largest_weight
#
# 2) Gather all matches for that bracket. Build a map from bracket_position => round
#
# We assume "largest bracket" is the single weight with the largest bracket_size.
#
largest_bracket_size = largest_weight.calculate_bracket_size
largest_matches = largest_weight.tournament.matches.where(weight_id: largest_weight.id)
position_to_round = {} position_to_round = {}
largest_matches.each do |m| rows_by_weight_id.fetch(largest_weight.id, []).each do |row|
# In case multiple matches have the same bracket_position but different rounds position_to_round[row[:bracket_position]] ||= row[:round]
# (like "3/4" might appear more than once), you can pick the first or max.
position_to_round[m.bracket_position] ||= m.round
end end
# rows_by_weight_id.each_value do |rows|
# 3) For every other match in the entire tournament (including possibly the largest bracket, if you want), rows.each do |row|
# overwrite the round to match this map. row[:round] = position_to_round[row[:bracket_position]] if position_to_round.key?(row[:bracket_position])
#
@tournament.matches.find_each do |match|
# If there's a known round for this bracket_position, use it
if position_to_round.key?(match.bracket_position)
match.update(round: position_to_round[match.bracket_position])
end end
end end
end end
@@ -247,8 +198,12 @@ class DoubleEliminationMatchGeneration
########################################################################### ###########################################################################
# Helper methods # Helper methods
########################################################################### ###########################################################################
def generation_weights
@weights || @tournament.weights.to_a
end
def wrestler_with_seed(seed, weight) def wrestler_with_seed(seed, weight)
Wrestler.where("weight_id = ? AND bracket_line = ?", weight.id, seed).first&.id weight.wrestlers.find { |w| w.bracket_line == seed }&.id
end end
def create_matchup_from_seed(w1_seed, w2_seed, bracket_position, bracket_position_number, round, weight) def create_matchup_from_seed(w1_seed, w2_seed, bracket_position, bracket_position_number, round, weight)
@@ -263,13 +218,33 @@ class DoubleEliminationMatchGeneration
end end
def create_matchup(w1, w2, bracket_position, bracket_position_number, round, weight) def create_matchup(w1, w2, bracket_position, bracket_position_number, round, weight)
weight.tournament.matches.create!( {
w1: w1, w1: w1,
w2: w2, w2: w2,
tournament_id: weight.tournament_id,
weight_id: weight.id, weight_id: weight.id,
round: round, round: round,
bracket_position: bracket_position, bracket_position: bracket_position,
bracket_position_number: bracket_position_number bracket_position_number: bracket_position_number
) }
end
# Calculates the sequence of seeds for the first round of a power-of-two bracket.
def generate_seed_sequence(n)
raise ArgumentError, "Bracket size must be a power of two" unless (n & (n - 1)).zero?
return [1, 2] if n == 2
half = n / 2
prev = generate_seed_sequence(half)
comp = prev.map { |s| n + 1 - s }
result = []
(0...prev.size).step(2) do |k|
result << prev[k]
result << comp[k]
result << comp[k + 1]
result << prev[k + 1]
end
result
end end
end end

View File

@@ -10,62 +10,183 @@ class GenerateTournamentMatches
def generate_raw def generate_raw
standardStartingActions standardStartingActions
PoolToBracketMatchGeneration.new(@tournament).generatePoolToBracketMatches if @tournament.tournament_type == "Pool to bracket" generation_context = preload_generation_context
ModifiedSixteenManMatchGeneration.new(@tournament).generate_matches if @tournament.tournament_type.include? "Modified 16 Man Double Elimination" seed_wrestlers_in_memory(generation_context)
DoubleEliminationMatchGeneration.new(@tournament).generate_matches if @tournament.tournament_type.include? "Regular Double Elimination" match_rows = build_match_rows(generation_context)
post_process_match_rows_in_memory(generation_context, match_rows)
persist_generation_rows(generation_context, match_rows)
postMatchCreationActions postMatchCreationActions
PoolToBracketMatchGeneration.new(@tournament).assignLoserNames if @tournament.tournament_type == "Pool to bracket" advance_bye_matches_after_insert
ModifiedSixteenManGenerateLoserNames.new(@tournament).assign_loser_names if @tournament.tournament_type.include? "Modified 16 Man Double Elimination"
DoubleEliminationGenerateLoserNames.new(@tournament).assign_loser_names if @tournament.tournament_type.include? "Regular Double Elimination"
end end
def standardStartingActions def standardStartingActions
@tournament.curently_generating_matches = 1 @tournament.curently_generating_matches = 1
@tournament.save @tournament.save
WipeTournamentMatches.new(@tournament).setUpMatchGeneration WipeTournamentMatches.new(@tournament).setUpMatchGeneration
TournamentSeeding.new(@tournament).set_seeds end
def preload_generation_context
weights = @tournament.weights.includes(:wrestlers).order(:max).to_a
wrestlers = weights.flat_map(&:wrestlers)
{
weights: weights,
wrestlers: wrestlers,
wrestlers_by_weight_id: wrestlers.group_by(&:weight_id)
}
end
def seed_wrestlers_in_memory(generation_context)
TournamentSeeding.new(@tournament).set_seeds(weights: generation_context[:weights], persist: false)
end
def build_match_rows(generation_context)
return PoolToBracketMatchGeneration.new(
@tournament,
weights: generation_context[:weights],
wrestlers_by_weight_id: generation_context[:wrestlers_by_weight_id]
).generatePoolToBracketMatches if @tournament.tournament_type == "Pool to bracket"
return ModifiedSixteenManMatchGeneration.new(@tournament, weights: generation_context[:weights]).generate_matches if @tournament.tournament_type.include? "Modified 16 Man Double Elimination"
return DoubleEliminationMatchGeneration.new(@tournament, weights: generation_context[:weights]).generate_matches if @tournament.tournament_type.include? "Regular Double Elimination"
[]
end
def persist_generation_rows(generation_context, match_rows)
persist_wrestlers(generation_context[:wrestlers])
persist_matches(match_rows)
end
def post_process_match_rows_in_memory(generation_context, match_rows)
move_finals_rows_to_last_round(match_rows) unless @tournament.tournament_type.include?("Regular Double Elimination")
assign_bouts_in_memory(match_rows, generation_context[:weights])
assign_loser_names_in_memory(generation_context, match_rows)
assign_bye_outcomes_in_memory(generation_context, match_rows)
end
def persist_wrestlers(wrestlers)
return if wrestlers.blank?
timestamp = Time.current
rows = wrestlers.map do |w|
{
id: w.id,
bracket_line: w.bracket_line,
pool: w.pool,
updated_at: timestamp
}
end
Wrestler.upsert_all(rows)
end
def persist_matches(match_rows)
return if match_rows.blank?
timestamp = Time.current
rows_with_timestamps = match_rows.map do |row|
row.to_h.symbolize_keys.merge(created_at: timestamp, updated_at: timestamp)
end
all_keys = rows_with_timestamps.flat_map(&:keys).uniq
normalized_rows = rows_with_timestamps.map do |row|
all_keys.each_with_object({}) { |key, normalized| normalized[key] = row[key] }
end
Match.insert_all!(normalized_rows)
end end
def postMatchCreationActions def postMatchCreationActions
moveFinalsMatchesToLastRound if ! @tournament.tournament_type.include? "Regular Double Elimination"
assignBouts
@tournament.reset_and_fill_bout_board @tournament.reset_and_fill_bout_board
@tournament.curently_generating_matches = nil @tournament.curently_generating_matches = nil
@tournament.save! @tournament.save!
Tournament.broadcast_up_matches_board(@tournament.id)
end
def move_finals_rows_to_last_round(match_rows)
finals_round = match_rows.map { |row| row[:round] }.compact.max
return unless finals_round
match_rows.each do |row|
row[:round] = finals_round if ["1/2", "3/4", "5/6", "7/8"].include?(row[:bracket_position])
end
end
def assign_bouts_in_memory(match_rows, weights)
bout_counts = Hash.new(0)
weight_max_by_id = weights.each_with_object({}) { |w, memo| memo[w.id] = w.max }
match_rows
.sort_by { |row| [row[:round].to_i, weight_max_by_id[row[:weight_id]].to_f, row[:weight_id].to_i, row[:bracket_position_number].to_i] }
.each do |row|
round = row[:round].to_i
row[:bout_number] = round * 1000 + bout_counts[round]
bout_counts[round] += 1
end
end
def assign_loser_names_in_memory(generation_context, match_rows)
if @tournament.tournament_type == "Pool to bracket"
service = PoolToBracketGenerateLoserNames.new(@tournament)
generation_context[:weights].each { |weight| service.assign_loser_names_in_memory(weight, match_rows) }
elsif @tournament.tournament_type.include?("Modified 16 Man Double Elimination")
service = ModifiedSixteenManGenerateLoserNames.new(@tournament)
generation_context[:weights].each { |weight| service.assign_loser_names_in_memory(weight, match_rows) }
elsif @tournament.tournament_type.include?("Regular Double Elimination")
service = DoubleEliminationGenerateLoserNames.new(@tournament)
generation_context[:weights].each { |weight| service.assign_loser_names_in_memory(weight, match_rows) }
end
end
def assign_bye_outcomes_in_memory(generation_context, match_rows)
if @tournament.tournament_type.include?("Modified 16 Man Double Elimination")
service = ModifiedSixteenManGenerateLoserNames.new(@tournament)
generation_context[:weights].each { |weight| service.assign_bye_outcomes_in_memory(weight, match_rows) }
elsif @tournament.tournament_type.include?("Regular Double Elimination")
service = DoubleEliminationGenerateLoserNames.new(@tournament)
generation_context[:weights].each { |weight| service.assign_bye_outcomes_in_memory(weight, match_rows) }
end
end
def advance_bye_matches_after_insert
Match.where(tournament_id: @tournament.id, finished: 1, win_type: "BYE")
.where.not(winner_id: nil)
.find_each(&:advance_wrestlers)
end end
def assignBouts def assignBouts
bout_counts = Hash.new(0) bout_counts = Hash.new(0)
@tournament.matches.reload timestamp = Time.current
@tournament.matches.sort_by{|m| [m.round, m.weight_max]}.each do |m| ordered_matches = Match.joins(:weight)
m.bout_number = m.round * 1000 + bout_counts[m.round] .where(tournament_id: @tournament.id)
bout_counts[m.round] += 1 .order("matches.round ASC, weights.max ASC, matches.id ASC")
m.save! .pluck("matches.id", "matches.round")
updates = []
ordered_matches.each do |match_id, round|
updates << {
id: match_id,
bout_number: round * 1000 + bout_counts[round],
updated_at: timestamp
}
bout_counts[round] += 1
end end
Match.upsert_all(updates) if updates.any?
end end
def moveFinalsMatchesToLastRound def moveFinalsMatchesToLastRound
finalsRound = @tournament.reload.total_rounds finalsRound = @tournament.reload.total_rounds
finalsMatches = @tournament.matches.reload.select{|m| m.bracket_position == "1/2" || m.bracket_position == "3/4" || m.bracket_position == "5/6" || m.bracket_position == "7/8"} @tournament.matches
finalsMatches. each do |m| .where(bracket_position: ["1/2", "3/4", "5/6", "7/8"])
m.round = finalsRound .update_all(round: finalsRound, updated_at: Time.current)
m.save
end
end end
def unAssignMats def unAssignMats
matches = @tournament.matches.reload @tournament.matches.update_all(mat_id: nil, updated_at: Time.current)
matches.each do |m|
m.mat_id = nil
m.save!
end
end end
def unAssignBouts def unAssignBouts
bout_counts = Hash.new(0) @tournament.matches.update_all(bout_number: nil, updated_at: Time.current)
@tournament.matches.each do |m|
m.bout_number = nil
m.save!
end
end end
end end

View File

@@ -1,95 +1,91 @@
class ModifiedSixteenManGenerateLoserNames class ModifiedSixteenManGenerateLoserNames
def initialize( tournament ) def initialize(tournament)
@tournament = tournament @tournament = tournament
end
def assign_loser_names
matches_by_weight = nil
@tournament.weights.each do |w|
matches_by_weight = @tournament.matches.where(weight_id: w.id)
conso_round_2(matches_by_weight)
conso_round_3(matches_by_weight)
third_fourth(matches_by_weight)
seventh_eighth(matches_by_weight)
save_matches(matches_by_weight)
matches_by_weight = @tournament.matches.where(weight_id: w.id).reload
advance_bye_matches_championship(matches_by_weight)
save_matches(matches_by_weight)
end
end
def conso_round_2(matches)
matches.select{|m| m.bracket_position == "Conso Round of 8"}.sort_by{|m| m.bracket_position_number}.each do |match|
if match.bracket_position_number == 1
match.loser1_name = "Loser of #{matches.select{|m| m.bracket_position_number == 1 and m.bracket_position == "Bracket Round of 16"}.first.bout_number}"
match.loser2_name = "Loser of #{matches.select{|m| m.bracket_position_number == 2 and m.bracket_position == "Bracket Round of 16"}.first.bout_number}"
elsif match.bracket_position_number == 2
match.loser1_name = "Loser of #{matches.select{|m| m.bracket_position_number == 3 and m.bracket_position == "Bracket Round of 16"}.first.bout_number}"
match.loser2_name = "Loser of #{matches.select{|m| m.bracket_position_number == 4 and m.bracket_position == "Bracket Round of 16"}.first.bout_number}"
elsif match.bracket_position_number == 3
match.loser1_name = "Loser of #{matches.select{|m| m.bracket_position_number == 5 and m.bracket_position == "Bracket Round of 16"}.first.bout_number}"
match.loser2_name = "Loser of #{matches.select{|m| m.bracket_position_number == 6 and m.bracket_position == "Bracket Round of 16"}.first.bout_number}"
elsif match.bracket_position_number == 4
match.loser1_name = "Loser of #{matches.select{|m| m.bracket_position_number == 7 and m.bracket_position == "Bracket Round of 16"}.first.bout_number}"
match.loser2_name = "Loser of #{matches.select{|m| m.bracket_position_number == 8 and m.bracket_position == "Bracket Round of 16"}.first.bout_number}"
end
end
end
def conso_round_3(matches)
matches.select{|m| m.bracket_position == "Conso Quarter"}.sort_by{|m| m.bracket_position_number}.each do |match|
if match.bracket_position_number == 1
match.loser1_name = "Loser of #{matches.select{|m| m.bracket_position_number == 4 and m.bracket_position == "Quarter"}.first.bout_number}"
elsif match.bracket_position_number == 2
match.loser1_name = "Loser of #{matches.select{|m| m.bracket_position_number == 3 and m.bracket_position == "Quarter"}.first.bout_number}"
elsif match.bracket_position_number == 3
match.loser1_name = "Loser of #{matches.select{|m| m.bracket_position_number == 2 and m.bracket_position == "Quarter"}.first.bout_number}"
elsif match.bracket_position_number == 4
match.loser1_name = "Loser of #{matches.select{|m| m.bracket_position_number == 1 and m.bracket_position == "Quarter"}.first.bout_number}"
end
end
end
def third_fourth(matches)
matches.select{|m| m.bracket_position == "3/4"}.sort_by{|m| m.bracket_position_number}.each do |match|
match.loser1_name = "Loser of #{matches.select{|m| m.bracket_position == "Semis"}.first.bout_number}"
match.loser2_name = "Loser of #{matches.select{|m| m.bracket_position == "Semis"}.second.bout_number}"
end
end
def seventh_eighth(matches)
matches.select{|m| m.bracket_position == "7/8"}.sort_by{|m| m.bracket_position_number}.each do |match|
match.loser1_name = "Loser of #{matches.select{|m| m.bracket_position == "Conso Semis"}.first.bout_number}"
match.loser2_name = "Loser of #{matches.select{|m| m.bracket_position == "Conso Semis"}.second.bout_number}"
end
end
def advance_bye_matches_championship(matches)
matches.select{|m| m.bracket_position == "Bracket Round of 16"}.sort_by{|m| m.bracket_position_number}.each do |match|
if match.w1 == nil or match.w2 == nil
match.finished = 1
match.win_type = "BYE"
if match.w1 != nil
match.winner_id = match.w1
match.loser2_name = "BYE"
match.score = ""
match.save
match.advance_wrestlers
elsif match.w2 != nil
match.winner_id = match.w2
match.loser1_name = "BYE"
match.score = ""
match.save
match.advance_wrestlers
end
end
end
end
def save_matches(matches)
matches.each do |m|
m.save!
end
end end
# Compatibility wrapper. Returns transformed rows and does not persist.
def assign_loser_names(match_rows = nil)
rows = match_rows || @tournament.matches.where(tournament_id: @tournament.id).map { |m| m.attributes.symbolize_keys }
@tournament.weights.each do |weight|
assign_loser_names_in_memory(weight, rows)
assign_bye_outcomes_in_memory(weight, rows)
end
rows
end
def assign_loser_names_in_memory(weight, match_rows)
rows = match_rows.select { |row| row[:weight_id] == weight.id }
round_16 = rows.select { |r| r[:bracket_position] == "Bracket Round of 16" }
conso_8 = rows.select { |r| r[:bracket_position] == "Conso Round of 8" }.sort_by { |r| r[:bracket_position_number] }
conso_8.each do |row|
if row[:bracket_position_number] == 1
m1 = round_16.find { |m| m[:bracket_position_number] == 1 }
m2 = round_16.find { |m| m[:bracket_position_number] == 2 }
row[:loser1_name] = "Loser of #{m1[:bout_number]}" if m1
row[:loser2_name] = "Loser of #{m2[:bout_number]}" if m2
elsif row[:bracket_position_number] == 2
m3 = round_16.find { |m| m[:bracket_position_number] == 3 }
m4 = round_16.find { |m| m[:bracket_position_number] == 4 }
row[:loser1_name] = "Loser of #{m3[:bout_number]}" if m3
row[:loser2_name] = "Loser of #{m4[:bout_number]}" if m4
elsif row[:bracket_position_number] == 3
m5 = round_16.find { |m| m[:bracket_position_number] == 5 }
m6 = round_16.find { |m| m[:bracket_position_number] == 6 }
row[:loser1_name] = "Loser of #{m5[:bout_number]}" if m5
row[:loser2_name] = "Loser of #{m6[:bout_number]}" if m6
elsif row[:bracket_position_number] == 4
m7 = round_16.find { |m| m[:bracket_position_number] == 7 }
m8 = round_16.find { |m| m[:bracket_position_number] == 8 }
row[:loser1_name] = "Loser of #{m7[:bout_number]}" if m7
row[:loser2_name] = "Loser of #{m8[:bout_number]}" if m8
end
end
quarters = rows.select { |r| r[:bracket_position] == "Quarter" }
conso_quarters = rows.select { |r| r[:bracket_position] == "Conso Quarter" }.sort_by { |r| r[:bracket_position_number] }
conso_quarters.each do |row|
source = case row[:bracket_position_number]
when 1 then quarters.find { |q| q[:bracket_position_number] == 4 }
when 2 then quarters.find { |q| q[:bracket_position_number] == 3 }
when 3 then quarters.find { |q| q[:bracket_position_number] == 2 }
when 4 then quarters.find { |q| q[:bracket_position_number] == 1 }
end
row[:loser1_name] = "Loser of #{source[:bout_number]}" if source
end
semis = rows.select { |r| r[:bracket_position] == "Semis" }
third_fourth = rows.find { |r| r[:bracket_position] == "3/4" }
if third_fourth
third_fourth[:loser1_name] = "Loser of #{semis.first[:bout_number]}" if semis.first
third_fourth[:loser2_name] = "Loser of #{semis.second[:bout_number]}" if semis.second
end
conso_semis = rows.select { |r| r[:bracket_position] == "Conso Semis" }
seventh_eighth = rows.find { |r| r[:bracket_position] == "7/8" }
if seventh_eighth
seventh_eighth[:loser1_name] = "Loser of #{conso_semis.first[:bout_number]}" if conso_semis.first
seventh_eighth[:loser2_name] = "Loser of #{conso_semis.second[:bout_number]}" if conso_semis.second
end
end
def assign_bye_outcomes_in_memory(weight, match_rows)
rows = match_rows.select { |r| r[:weight_id] == weight.id && r[:bracket_position] == "Bracket Round of 16" }
rows.each { |row| apply_bye_to_row(row) }
end
def apply_bye_to_row(row)
return unless [row[:w1], row[:w2]].compact.size == 1
row[:finished] = 1
row[:win_type] = "BYE"
if row[:w1]
row[:winner_id] = row[:w1]
row[:loser2_name] = "BYE"
else
row[:winner_id] = row[:w2]
row[:loser1_name] = "BYE"
end
row[:score] = ""
end
end end

View File

@@ -1,70 +1,75 @@
class ModifiedSixteenManMatchGeneration class ModifiedSixteenManMatchGeneration
def initialize( tournament ) def initialize( tournament, weights: nil )
@tournament = tournament @tournament = tournament
@number_of_placers = @tournament.number_of_placers @number_of_placers = @tournament.number_of_placers
@weights = weights
end end
def generate_matches def generate_matches
@tournament.weights.each do |weight| rows = []
generate_matches_for_weight(weight) generation_weights.each do |weight|
rows.concat(generate_match_rows_for_weight(weight))
end end
rows
end end
def generate_matches_for_weight(weight) def generate_match_rows_for_weight(weight)
round_one(weight) rows = []
round_two(weight) round_one(weight, rows)
round_three(weight) round_two(weight, rows)
round_four(weight) round_three(weight, rows)
round_five(weight) round_four(weight, rows)
round_five(weight, rows)
rows
end end
def round_one(weight) def round_one(weight, rows)
create_matchup_from_seed(1,16, "Bracket Round of 16", 1, 1,weight) rows << create_matchup_from_seed(1,16, "Bracket Round of 16", 1, 1,weight)
create_matchup_from_seed(8,9, "Bracket Round of 16", 2, 1,weight) rows << create_matchup_from_seed(8,9, "Bracket Round of 16", 2, 1,weight)
create_matchup_from_seed(5,12, "Bracket Round of 16", 3, 1,weight) rows << create_matchup_from_seed(5,12, "Bracket Round of 16", 3, 1,weight)
create_matchup_from_seed(4,14, "Bracket Round of 16", 4, 1,weight) rows << create_matchup_from_seed(4,14, "Bracket Round of 16", 4, 1,weight)
create_matchup_from_seed(3,13, "Bracket Round of 16", 5, 1,weight) rows << create_matchup_from_seed(3,13, "Bracket Round of 16", 5, 1,weight)
create_matchup_from_seed(6,11, "Bracket Round of 16", 6, 1,weight) rows << create_matchup_from_seed(6,11, "Bracket Round of 16", 6, 1,weight)
create_matchup_from_seed(7,10, "Bracket Round of 16", 7, 1,weight) rows << create_matchup_from_seed(7,10, "Bracket Round of 16", 7, 1,weight)
create_matchup_from_seed(2,15, "Bracket Round of 16", 8, 1,weight) rows << create_matchup_from_seed(2,15, "Bracket Round of 16", 8, 1,weight)
end end
def round_two(weight) def round_two(weight, rows)
create_matchup(nil,nil,"Quarter",1,2,weight) rows << create_matchup(nil,nil,"Quarter",1,2,weight)
create_matchup(nil,nil,"Quarter",2,2,weight) rows << create_matchup(nil,nil,"Quarter",2,2,weight)
create_matchup(nil,nil,"Quarter",3,2,weight) rows << create_matchup(nil,nil,"Quarter",3,2,weight)
create_matchup(nil,nil,"Quarter",4,2,weight) rows << create_matchup(nil,nil,"Quarter",4,2,weight)
create_matchup(nil,nil,"Conso Round of 8",1,2,weight) rows << create_matchup(nil,nil,"Conso Round of 8",1,2,weight)
create_matchup(nil,nil,"Conso Round of 8",2,2,weight) rows << create_matchup(nil,nil,"Conso Round of 8",2,2,weight)
create_matchup(nil,nil,"Conso Round of 8",3,2,weight) rows << create_matchup(nil,nil,"Conso Round of 8",3,2,weight)
create_matchup(nil,nil,"Conso Round of 8",4,2,weight) rows << create_matchup(nil,nil,"Conso Round of 8",4,2,weight)
end end
def round_three(weight) def round_three(weight, rows)
create_matchup(nil,nil,"Semis",1,3,weight) rows << create_matchup(nil,nil,"Semis",1,3,weight)
create_matchup(nil,nil,"Semis",2,3,weight) rows << create_matchup(nil,nil,"Semis",2,3,weight)
create_matchup(nil,nil,"Conso Quarter",1,3,weight) rows << create_matchup(nil,nil,"Conso Quarter",1,3,weight)
create_matchup(nil,nil,"Conso Quarter",2,3,weight) rows << create_matchup(nil,nil,"Conso Quarter",2,3,weight)
create_matchup(nil,nil,"Conso Quarter",3,3,weight) rows << create_matchup(nil,nil,"Conso Quarter",3,3,weight)
create_matchup(nil,nil,"Conso Quarter",4,3,weight) rows << create_matchup(nil,nil,"Conso Quarter",4,3,weight)
end end
def round_four(weight) def round_four(weight, rows)
create_matchup(nil,nil,"Conso Semis",1,4,weight) rows << create_matchup(nil,nil,"Conso Semis",1,4,weight)
create_matchup(nil,nil,"Conso Semis",2,4,weight) rows << create_matchup(nil,nil,"Conso Semis",2,4,weight)
end end
def round_five(weight) def round_five(weight, rows)
create_matchup(nil,nil,"1/2",1,5,weight) rows << create_matchup(nil,nil,"1/2",1,5,weight)
create_matchup(nil,nil,"3/4",1,5,weight) rows << create_matchup(nil,nil,"3/4",1,5,weight)
create_matchup(nil,nil,"5/6",1,5,weight) rows << create_matchup(nil,nil,"5/6",1,5,weight)
if @number_of_placers >= 8 if @number_of_placers >= 8
create_matchup(nil,nil,"7/8",1,5,weight) rows << create_matchup(nil,nil,"7/8",1,5,weight)
end end
end end
def wrestler_with_seed(seed,weight) def wrestler_with_seed(seed,weight)
wrestler = Wrestler.where("weight_id = ? and bracket_line = ?", weight.id, seed).first wrestler = weight.wrestlers.find { |w| w.bracket_line == seed }
if wrestler if wrestler
return wrestler.id return wrestler.id
else else
@@ -79,13 +84,18 @@ class ModifiedSixteenManMatchGeneration
end end
def create_matchup(w1, w2, bracket_position, bracket_position_number,round,weight) def create_matchup(w1, w2, bracket_position, bracket_position_number,round,weight)
@tournament.matches.create( {
w1: w1, w1: w1,
w2: w2, w2: w2,
tournament_id: @tournament.id,
weight_id: weight.id, weight_id: weight.id,
round: round, round: round,
bracket_position: bracket_position, bracket_position: bracket_position,
bracket_position_number: bracket_position_number bracket_position_number: bracket_position_number
) }
end
def generation_weights
@weights || @tournament.weights.to_a
end end
end end

View File

@@ -12,18 +12,19 @@ class PoolBracketGeneration
end end
def generateBracketMatches() def generateBracketMatches()
@rows = []
if @pool_bracket_type == "twoPoolsToSemi" if @pool_bracket_type == "twoPoolsToSemi"
return twoPoolsToSemi() twoPoolsToSemi()
elsif @pool_bracket_type == "twoPoolsToFinal" elsif @pool_bracket_type == "twoPoolsToFinal"
return twoPoolsToFinal() twoPoolsToFinal()
elsif @pool_bracket_type == "fourPoolsToQuarter" elsif @pool_bracket_type == "fourPoolsToQuarter"
return fourPoolsToQuarter() fourPoolsToQuarter()
elsif @pool_bracket_type == "fourPoolsToSemi" elsif @pool_bracket_type == "fourPoolsToSemi"
return fourPoolsToSemi() fourPoolsToSemi()
elsif @pool_bracket_type == "eightPoolsToQuarter" elsif @pool_bracket_type == "eightPoolsToQuarter"
return eightPoolsToQuarter() eightPoolsToQuarter()
end end
return [] @rows
end end
def twoPoolsToSemi() def twoPoolsToSemi()
@@ -86,14 +87,15 @@ class PoolBracketGeneration
end end
def createMatchup(w1_name, w2_name, bracket_position, bracket_position_number) def createMatchup(w1_name, w2_name, bracket_position, bracket_position_number)
@tournament.matches.create( @rows << {
loser1_name: w1_name, loser1_name: w1_name,
loser2_name: w2_name, loser2_name: w2_name,
tournament_id: @tournament.id,
weight_id: @weight.id, weight_id: @weight.id,
round: @round, round: @round,
bracket_position: bracket_position, bracket_position: bracket_position,
bracket_position_number: bracket_position_number bracket_position_number: bracket_position_number
) }
end end
end end

View File

@@ -1,35 +1,46 @@
class PoolGeneration class PoolGeneration
def initialize(weight) def initialize(weight, wrestlers: nil)
@weight = weight @weight = weight
@tournament = @weight.tournament @tournament = @weight.tournament
@pool = 1 @pool = 1
@wrestlers = wrestlers
end end
def generatePools def generatePools
GeneratePoolNumbers.new(@weight).savePoolNumbers GeneratePoolNumbers.new(@weight).savePoolNumbers(wrestlers: wrestlers_for_weight, persist: false)
rows = []
pools = @weight.pools pools = @weight.pools
while @pool <= pools while @pool <= pools
roundRobin rows.concat(roundRobin)
@pool += 1 @pool += 1
end end
rows
end end
def roundRobin def roundRobin
wrestlers = @weight.wrestlers_in_pool(@pool) rows = []
wrestlers = wrestlers_for_weight.select { |w| w.pool == @pool }
pool_matches = RoundRobinTournament.schedule(wrestlers).reverse pool_matches = RoundRobinTournament.schedule(wrestlers).reverse
pool_matches.each_with_index do |b, index| pool_matches.each_with_index do |b, index|
round = index + 1 round = index + 1
bouts = b.map bouts = b.map
bouts.each do |bout| bouts.each do |bout|
if bout[0] != nil and bout[1] != nil if bout[0] != nil and bout[1] != nil
@tournament.matches.create( rows << {
w1: bout[0].id, w1: bout[0].id,
w2: bout[1].id, w2: bout[1].id,
tournament_id: @tournament.id,
weight_id: @weight.id, weight_id: @weight.id,
bracket_position: "Pool", bracket_position: "Pool",
round: round) round: round
}
end end
end end
end end
rows
end end
def wrestlers_for_weight
@wrestlers || @weight.wrestlers.to_a
end
end end

View File

@@ -1,80 +1,97 @@
class PoolToBracketGenerateLoserNames class PoolToBracketGenerateLoserNames
def initialize( tournament ) def initialize(tournament)
@tournament = tournament @tournament = tournament
end
# Compatibility wrapper. Returns transformed rows and does not persist.
def assignLoserNamesWeight(weight, match_rows = nil)
rows = match_rows || @tournament.matches.where(weight_id: weight.id).map { |m| m.attributes.symbolize_keys }
assign_loser_names_in_memory(weight, rows)
rows
end
# Compatibility wrapper. Returns transformed rows and does not persist.
def assignLoserNames
@tournament.weights.each_with_object([]) do |weight, all_rows|
all_rows.concat(assignLoserNamesWeight(weight))
end
end
def assign_loser_names_in_memory(weight, match_rows)
rows = match_rows.select { |row| row[:weight_id] == weight.id }
if weight.pool_bracket_type == "twoPoolsToSemi"
two_pools_to_semi_loser_rows(rows)
elsif (weight.pool_bracket_type == "fourPoolsToQuarter") || (weight.pool_bracket_type == "eightPoolsToQuarter")
four_pools_to_quarter_loser_rows(rows)
elsif weight.pool_bracket_type == "fourPoolsToSemi"
four_pools_to_semi_loser_rows(rows)
end
end
def two_pools_to_semi_loser_rows(rows)
match1 = rows.find { |m| m[:loser1_name] == "Winner Pool 1" }
match2 = rows.find { |m| m[:loser1_name] == "Winner Pool 2" }
match_change = rows.find { |m| m[:bracket_position] == "3/4" }
return unless match1 && match2 && match_change
match_change[:loser1_name] = "Loser of #{match1[:bout_number]}"
match_change[:loser2_name] = "Loser of #{match2[:bout_number]}"
end
def four_pools_to_quarter_loser_rows(rows)
quarters = rows.select { |m| m[:bracket_position] == "Quarter" }
conso_semis = rows.select { |m| m[:bracket_position] == "Conso Semis" }
semis = rows.select { |m| m[:bracket_position] == "Semis" }
third_fourth = rows.find { |m| m[:bracket_position] == "3/4" }
seventh_eighth = rows.find { |m| m[:bracket_position] == "7/8" }
conso_semis.each do |m|
if m[:bracket_position_number] == 1
q1 = quarters.find { |q| q[:bracket_position_number] == 1 }
q2 = quarters.find { |q| q[:bracket_position_number] == 2 }
m[:loser1_name] = "Loser of #{q1[:bout_number]}" if q1
m[:loser2_name] = "Loser of #{q2[:bout_number]}" if q2
elsif m[:bracket_position_number] == 2
q3 = quarters.find { |q| q[:bracket_position_number] == 3 }
q4 = quarters.find { |q| q[:bracket_position_number] == 4 }
m[:loser1_name] = "Loser of #{q3[:bout_number]}" if q3
m[:loser2_name] = "Loser of #{q4[:bout_number]}" if q4
end
end end
def assignLoserNamesWeight(weight) if third_fourth
matches_by_weight = @tournament.matches.where(weight_id: weight.id) s1 = semis.find { |s| s[:bracket_position_number] == 1 }
if weight.pool_bracket_type == "twoPoolsToSemi" s2 = semis.find { |s| s[:bracket_position_number] == 2 }
twoPoolsToSemiLoser(matches_by_weight) third_fourth[:loser1_name] = "Loser of #{s1[:bout_number]}" if s1
elsif (weight.pool_bracket_type == "fourPoolsToQuarter") or (weight.pool_bracket_type == "eightPoolsToQuarter") third_fourth[:loser2_name] = "Loser of #{s2[:bout_number]}" if s2
fourPoolsToQuarterLoser(matches_by_weight)
elsif weight.pool_bracket_type == "fourPoolsToSemi"
fourPoolsToSemiLoser(matches_by_weight)
end
saveMatches(matches_by_weight)
end
def assignLoserNames
matches_by_weight = nil
@tournament.weights.each do |w|
matches_by_weight = @tournament.matches.where(weight_id: w.id)
if w.pool_bracket_type == "twoPoolsToSemi"
twoPoolsToSemiLoser(matches_by_weight)
elsif (w.pool_bracket_type == "fourPoolsToQuarter") or (w.pool_bracket_type == "eightPoolsToQuarter")
fourPoolsToQuarterLoser(matches_by_weight)
elsif w.pool_bracket_type == "fourPoolsToSemi"
fourPoolsToSemiLoser(matches_by_weight)
end
saveMatches(matches_by_weight)
end end
end
def twoPoolsToSemiLoser(matches_by_weight) if seventh_eighth
match1 = matches_by_weight.select{|m| m.loser1_name == "Winner Pool 1"}.first c1 = conso_semis.find { |c| c[:bracket_position_number] == 1 }
match2 = matches_by_weight.select{|m| m.loser1_name == "Winner Pool 2"}.first c2 = conso_semis.find { |c| c[:bracket_position_number] == 2 }
matchChange = matches_by_weight.select{|m| m.bracket_position == "3/4"}.first seventh_eighth[:loser1_name] = "Loser of #{c1[:bout_number]}" if c1
matchChange.loser1_name = "Loser of #{match1.bout_number}" seventh_eighth[:loser2_name] = "Loser of #{c2[:bout_number]}" if c2
matchChange.loser2_name = "Loser of #{match2.bout_number}"
end
def fourPoolsToQuarterLoser(matches_by_weight)
quarters = matches_by_weight.select{|m| m.bracket_position == "Quarter"}
consoSemis = matches_by_weight.select{|m| m.bracket_position == "Conso Semis"}
semis = matches_by_weight.select{|m| m.bracket_position == "Semis"}
thirdFourth = matches_by_weight.select{|m| m.bracket_position == "3/4"}.first
seventhEighth = matches_by_weight.select{|m| m.bracket_position == "7/8"}.first
consoSemis.each do |m|
if m.bracket_position_number == 1
m.loser1_name = "Loser of #{quarters.select{|m| m.bracket_position_number == 1}.first.bout_number}"
m.loser2_name = "Loser of #{quarters.select{|m| m.bracket_position_number == 2}.first.bout_number}"
elsif m.bracket_position_number == 2
m.loser1_name = "Loser of #{quarters.select{|m| m.bracket_position_number == 3}.first.bout_number}"
m.loser2_name = "Loser of #{quarters.select{|m| m.bracket_position_number == 4}.first.bout_number}"
end
end end
thirdFourth.loser1_name = "Loser of #{semis.select{|m| m.bracket_position_number == 1}.first.bout_number}"
thirdFourth.loser2_name = "Loser of #{semis.select{|m| m.bracket_position_number == 2}.first.bout_number}"
consoSemis = matches_by_weight.select{|m| m.bracket_position == "Conso Semis"}
seventhEighth.loser1_name = "Loser of #{consoSemis.select{|m| m.bracket_position_number == 1}.first.bout_number}"
seventhEighth.loser2_name = "Loser of #{consoSemis.select{|m| m.bracket_position_number == 2}.first.bout_number}"
end end
def fourPoolsToSemiLoser(matches_by_weight) def four_pools_to_semi_loser_rows(rows)
semis = matches_by_weight.select{|m| m.bracket_position == "Semis"} semis = rows.select { |m| m[:bracket_position] == "Semis" }
thirdFourth = matches_by_weight.select{|m| m.bracket_position == "3/4"}.first conso_semis = rows.select { |m| m[:bracket_position] == "Conso Semis" }
consoSemis = matches_by_weight.select{|m| m.bracket_position == "Conso Semis"} third_fourth = rows.find { |m| m[:bracket_position] == "3/4" }
seventhEighth = matches_by_weight.select{|m| m.bracket_position == "7/8"}.first seventh_eighth = rows.find { |m| m[:bracket_position] == "7/8" }
thirdFourth.loser1_name = "Loser of #{semis.select{|m| m.bracket_position_number == 1}.first.bout_number}"
thirdFourth.loser2_name = "Loser of #{semis.select{|m| m.bracket_position_number == 2}.first.bout_number}"
seventhEighth.loser1_name = "Loser of #{consoSemis.select{|m| m.bracket_position_number == 1}.first.bout_number}"
seventhEighth.loser2_name = "Loser of #{consoSemis.select{|m| m.bracket_position_number == 2}.first.bout_number}"
end
def saveMatches(matches) if third_fourth
matches.each do |m| s1 = semis.find { |s| s[:bracket_position_number] == 1 }
m.save! s2 = semis.find { |s| s[:bracket_position_number] == 2 }
end third_fourth[:loser1_name] = "Loser of #{s1[:bout_number]}" if s1
end third_fourth[:loser2_name] = "Loser of #{s2[:bout_number]}" if s2
end
if seventh_eighth
c1 = conso_semis.find { |c| c[:bracket_position_number] == 1 }
c2 = conso_semis.find { |c| c[:bracket_position_number] == 2 }
seventh_eighth[:loser1_name] = "Loser of #{c1[:bout_number]}" if c1
seventh_eighth[:loser2_name] = "Loser of #{c2[:bout_number]}" if c2
end
end
end end

View File

@@ -1,41 +1,89 @@
class PoolToBracketMatchGeneration class PoolToBracketMatchGeneration
def initialize( tournament ) def initialize(tournament, weights: nil, wrestlers_by_weight_id: nil)
@tournament = tournament @tournament = tournament
@weights = weights
@wrestlers_by_weight_id = wrestlers_by_weight_id
end end
def generatePoolToBracketMatches def generatePoolToBracketMatches
@tournament.weights.order(:max).each do |weight| rows = []
PoolGeneration.new(weight).generatePools() generation_weights.each do |weight|
last_match = @tournament.matches.where(weight: weight).order(round: :desc).limit(1).first wrestlers = wrestlers_for_weight(weight)
highest_round = last_match.round pool_rows = PoolGeneration.new(weight, wrestlers: wrestlers).generatePools
PoolBracketGeneration.new(weight, highest_round).generateBracketMatches() rows.concat(pool_rows)
highest_round = pool_rows.map { |row| row[:round] }.max || 0
bracket_rows = PoolBracketGeneration.new(weight, highest_round).generateBracketMatches
rows.concat(bracket_rows)
end end
movePoolSeedsToFinalPoolRound
movePoolSeedsToFinalPoolRound(rows)
rows
end end
def movePoolSeedsToFinalPoolRound def movePoolSeedsToFinalPoolRound(match_rows)
@tournament.weights.each do |w| generation_weights.each do |w|
setOriginalSeedsToWrestleLastPoolRound(w) setOriginalSeedsToWrestleLastPoolRound(w, match_rows)
end end
end end
def setOriginalSeedsToWrestleLastPoolRound(weight) def setOriginalSeedsToWrestleLastPoolRound(weight, match_rows)
pool = 1 pool = 1
until pool > weight.pools wrestlers = wrestlers_for_weight(weight)
wrestler1 = weight.pool_wrestlers_sorted_by_bracket_line(pool).first weight_pools = weight.pools
wrestler2 = weight.pool_wrestlers_sorted_by_bracket_line(pool).second until pool > weight_pools
match = wrestler1.pool_matches.sort_by{|m| m.round}.last pool_wrestlers = wrestlers.select { |w| w.pool == pool }.sort_by(&:bracket_line)
if match.w1 != wrestler2.id or match.w2 != wrestler2.id wrestler1 = pool_wrestlers.first
if match.w1 == wrestler1.id wrestler2 = pool_wrestlers.second
SwapWrestlers.new.swap_wrestlers_bracket_lines(match.w2,wrestler2.id) if wrestler1 && wrestler2
elsif match.w2 == wrestler1.id pool_matches = match_rows.select { |row| row[:weight_id] == weight.id && row[:bracket_position] == "Pool" && (row[:w1] == wrestler1.id || row[:w2] == wrestler1.id) }
SwapWrestlers.new.swap_wrestlers_bracket_lines(match.w1,wrestler2.id) match = pool_matches.max_by { |row| row[:round] }
end if match && (match[:w1] != wrestler2.id || match[:w2] != wrestler2.id)
if match[:w1] == wrestler1.id
swap_wrestlers_in_memory(match_rows, wrestlers, match[:w2], wrestler2.id)
elsif match[:w2] == wrestler1.id
swap_wrestlers_in_memory(match_rows, wrestlers, match[:w1], wrestler2.id)
end
end
end end
pool += 1 pool += 1
end end
end end
def swap_wrestlers_in_memory(match_rows, wrestlers, wrestler1_id, wrestler2_id)
w1 = wrestlers.find { |w| w.id == wrestler1_id }
w2 = wrestlers.find { |w| w.id == wrestler2_id }
return unless w1 && w2
w1_bracket_line, w1_pool = w1.bracket_line, w1.pool
w1.bracket_line, w1.pool = w2.bracket_line, w2.pool
w2.bracket_line, w2.pool = w1_bracket_line, w1_pool
swap_match_rows(match_rows, wrestler1_id, wrestler2_id)
end
def swap_match_rows(match_rows, wrestler1_id, wrestler2_id)
match_rows.each do |row|
row[:w1] = swap_id(row[:w1], wrestler1_id, wrestler2_id)
row[:w2] = swap_id(row[:w2], wrestler1_id, wrestler2_id)
row[:winner_id] = swap_id(row[:winner_id], wrestler1_id, wrestler2_id)
end
end
def swap_id(value, wrestler1_id, wrestler2_id)
return wrestler2_id if value == wrestler1_id
return wrestler1_id if value == wrestler2_id
value
end
def generation_weights
@weights || @tournament.weights.order(:max).to_a
end
def wrestlers_for_weight(weight)
@wrestlers_by_weight_id&.fetch(weight.id, nil) || weight.wrestlers.to_a
end
def assignLoserNames def assignLoserNames
PoolToBracketGenerateLoserNames.new(@tournament).assignLoserNames PoolToBracketGenerateLoserNames.new(@tournament).assignLoserNames

View File

@@ -37,11 +37,19 @@ class TournamentBackupService
attributes: @tournament.attributes, attributes: @tournament.attributes,
schools: @tournament.schools.map(&:attributes), schools: @tournament.schools.map(&:attributes),
weights: @tournament.weights.map(&:attributes), weights: @tournament.weights.map(&:attributes),
mats: @tournament.mats.map(&:attributes), mats: @tournament.mats.map do |mat|
mat.attributes.merge(
"queue_bout_numbers" => mat.queue_matches.map { |match| match&.bout_number }
)
end,
mat_assignment_rules: @tournament.mat_assignment_rules.map do |rule| mat_assignment_rules: @tournament.mat_assignment_rules.map do |rule|
rule.attributes.merge( rule.attributes.merge(
mat: Mat.find_by(id: rule.mat_id)&.attributes.slice("name"), mat: Mat.find_by(id: rule.mat_id)&.attributes.slice("name"),
weight_classes: rule.weight_classes.map do |weight_id| # Emit the human-readable max values under a distinct key to avoid
# colliding with the raw DB-backed "weight_classes" attribute (which
# is stored as a comma-separated string). Using a different key
# prevents duplicate JSON keys when symbols and strings are both present.
"weight_class_maxes" => rule.weight_classes.map do |weight_id|
Weight.find_by(id: weight_id)&.max Weight.find_by(id: weight_id)&.max
end end
) )
@@ -54,11 +62,11 @@ class TournamentBackupService
end, end,
matches: @tournament.matches.sort_by(&:bout_number).map do |match| matches: @tournament.matches.sort_by(&:bout_number).map do |match|
match.attributes.merge( match.attributes.merge(
w1_name: Wrestler.find_by(id: match.w1)&.name, w1_name: match.wrestler1&.name,
w2_name: Wrestler.find_by(id: match.w2)&.name, w2_name: match.wrestler2&.name,
winner_name: Wrestler.find_by(id: match.winner_id)&.name, winner_name: match.winner&.name,
weight: Weight.find_by(id: match.weight_id)&.attributes, weight: match.weight&.attributes,
mat: Mat.find_by(id: match.mat_id)&.attributes mat: match.mat&.attributes
) )
end end
} }

View File

@@ -3,30 +3,36 @@ class TournamentSeeding
@tournament = tournament @tournament = tournament
end end
def set_seeds def set_seeds(weights: nil, persist: true)
@tournament.weights.each do |weight| weights_to_seed = weights || @tournament.weights.includes(:wrestlers)
updated_wrestlers = []
weights_to_seed.each do |weight|
wrestlers = weight.wrestlers wrestlers = weight.wrestlers
bracket_size = weight.calculate_bracket_size bracket_size = weight.calculate_bracket_size
wrestlers = reset_bracket_line_for_lines_higher_than_bracket_size(wrestlers, bracket_size) wrestlers = reset_bracket_line_for_lines_higher_than_bracket_size(wrestlers, bracket_size)
wrestlers = set_original_seed_to_bracket_line(wrestlers) wrestlers = set_original_seed_to_bracket_line(wrestlers)
wrestlers = random_seeding(wrestlers, bracket_size) wrestlers = random_seeding(wrestlers, bracket_size)
wrestlers.each(&:save) updated_wrestlers.concat(wrestlers)
end end
persist_bracket_lines(updated_wrestlers) if persist
updated_wrestlers
end end
def random_seeding(wrestlers, bracket_size) def random_seeding(wrestlers, bracket_size)
half_of_bracket = bracket_size / 2 half_of_bracket = bracket_size / 2
available_bracket_lines = (1..bracket_size).to_a available_bracket_lines = (1..bracket_size).to_a
first_half_available_bracket_lines = (1..half_of_bracket).to_a
# remove bracket lines that are taken from available_bracket_lines # remove bracket lines that are taken from available_bracket_lines
wrestlers_with_bracket_lines = wrestlers.select{|w| w.bracket_line != nil } wrestlers_with_bracket_lines = wrestlers.select{|w| w.bracket_line != nil }
wrestlers_with_bracket_lines.each do |wrestler| wrestlers_with_bracket_lines.each do |wrestler|
available_bracket_lines.delete(wrestler.bracket_line) available_bracket_lines.delete(wrestler.bracket_line)
first_half_available_bracket_lines.delete(wrestler.bracket_line)
end end
available_bracket_lines_to_use = set_random_seeding_bracket_line_order(available_bracket_lines)
wrestlers_without_bracket_lines = wrestlers.select{|w| w.bracket_line == nil } wrestlers_without_bracket_lines = wrestlers.select{|w| w.bracket_line == nil }
if @tournament.tournament_type == "Pool to bracket" if @tournament.tournament_type == "Pool to bracket"
wrestlers_without_bracket_lines.shuffle.each do |wrestler| wrestlers_without_bracket_lines.shuffle.each do |wrestler|
@@ -38,15 +44,10 @@ class TournamentSeeding
else else
# Iterrate over the list randomly # Iterrate over the list randomly
wrestlers_without_bracket_lines.shuffle.each do |wrestler| wrestlers_without_bracket_lines.shuffle.each do |wrestler|
if first_half_available_bracket_lines.size > 0 if available_bracket_lines_to_use.size > 0
random_available_bracket_line = first_half_available_bracket_lines.sample bracket_line_to_use = available_bracket_lines_to_use.first
wrestler.bracket_line = random_available_bracket_line wrestler.bracket_line = bracket_line_to_use
available_bracket_lines.delete(random_available_bracket_line) available_bracket_lines_to_use.delete(bracket_line_to_use)
first_half_available_bracket_lines.delete(random_available_bracket_line)
else
random_available_bracket_line = available_bracket_lines.sample
wrestler.bracket_line = random_available_bracket_line
available_bracket_lines.delete(random_available_bracket_line)
end end
end end
end end
@@ -81,4 +82,39 @@ class TournamentSeeding
end end
return wrestlers return wrestlers
end end
private
def set_random_seeding_bracket_line_order(available_bracket_lines)
# This method prevents double BYEs in round 1
# It also evenly distributes matches from the top half of the bracket to the bottom half
# It does both of these while keeping the randomness of the line assignment
odd_or_even = [0, 1]
odd_or_even_sample = odd_or_even.sample
# sort by odd or even based on the sample above
if odd_or_even_sample == 1
# odd numbers first
result = available_bracket_lines.sort_by { |n| n.even? ? 1 : 0 }
else
# even numbers first
result = available_bracket_lines.sort_by { |n| n.odd? ? 1 : 0 }
end
result
end
def persist_bracket_lines(wrestlers)
return if wrestlers.blank?
timestamp = Time.current
updates = wrestlers.map do |wrestler|
{
id: wrestler.id,
bracket_line: wrestler.bracket_line,
updated_at: timestamp
}
end
Wrestler.upsert_all(updates)
end
end end

View File

@@ -14,7 +14,7 @@ class WipeTournamentMatches
end end
def wipeMatches def wipeMatches
@tournament.matches.destroy_all @tournament.destroy_all_matches
end end
def resetSchoolScores def resetSchoolScores

View File

@@ -41,7 +41,6 @@ class WrestlingdevImporter
@tournament.matches.destroy_all @tournament.matches.destroy_all
@tournament.mat_assignment_rules.destroy_all # Explicitly destroy rules (might be redundant if Mat cascades) @tournament.mat_assignment_rules.destroy_all # Explicitly destroy rules (might be redundant if Mat cascades)
@tournament.delegates.destroy_all @tournament.delegates.destroy_all
@tournament.tournament_backups.destroy_all
@tournament.tournament_job_statuses.destroy_all @tournament.tournament_job_statuses.destroy_all
# Note: Teampointadjusts are deleted via School/Wrestler cascade # Note: Teampointadjusts are deleted via School/Wrestler cascade
end end
@@ -53,6 +52,7 @@ class WrestlingdevImporter
parse_mats(@import_data["tournament"]["mats"]) parse_mats(@import_data["tournament"]["mats"])
parse_wrestlers(@import_data["tournament"]["wrestlers"]) parse_wrestlers(@import_data["tournament"]["wrestlers"])
parse_matches(@import_data["tournament"]["matches"]) parse_matches(@import_data["tournament"]["matches"])
apply_mat_queues
parse_mat_assignment_rules(@import_data["tournament"]["mat_assignment_rules"]) parse_mat_assignment_rules(@import_data["tournament"]["mat_assignment_rules"])
end end
@@ -76,9 +76,15 @@ class WrestlingdevImporter
end end
def parse_mats(mats) def parse_mats(mats)
@mat_queue_bout_numbers = {}
mats.each do |mat_attributes| mats.each do |mat_attributes|
mat_attributes.except!("id") mat_name = mat_attributes["name"]
queue_bout_numbers = mat_attributes["queue_bout_numbers"]
mat_attributes.except!("id", "queue1", "queue2", "queue3", "queue4", "queue_bout_numbers", "tournament_id")
Mat.create(mat_attributes.merge(tournament_id: @tournament.id)) Mat.create(mat_attributes.merge(tournament_id: @tournament.id))
if mat_name && queue_bout_numbers
@mat_queue_bout_numbers[mat_name] = queue_bout_numbers
end
end end
end end
@@ -87,16 +93,29 @@ class WrestlingdevImporter
mat_name = rule_attributes.dig("mat", "name") mat_name = rule_attributes.dig("mat", "name")
mat = Mat.find_by(name: mat_name, tournament_id: @tournament.id) mat = Mat.find_by(name: mat_name, tournament_id: @tournament.id)
# Map max values of weight_classes to their new IDs # Prefer the new "weight_class_maxes" key emitted by backups (human-readable
new_weight_classes = rule_attributes["weight_classes"].map do |max_value| # max values). If not present, fall back to the legacy "weight_classes"
Weight.find_by(max: max_value, tournament_id: @tournament.id)&.id # value which may be a comma-separated string or an array of IDs.
end.compact if rule_attributes.key?("weight_class_maxes") && rule_attributes["weight_class_maxes"].respond_to?(:map)
new_weight_classes = rule_attributes["weight_class_maxes"].map do |max_value|
Weight.find_by(max: max_value, tournament_id: @tournament.id)&.id
end.compact
elsif rule_attributes["weight_classes"].is_a?(Array)
# Already an array of IDs
new_weight_classes = rule_attributes["weight_classes"].map(&:to_i)
elsif rule_attributes["weight_classes"].is_a?(String)
# Comma-separated IDs stored in the DB column; split into integers.
new_weight_classes = rule_attributes["weight_classes"].to_s.split(",").map(&:strip).reject(&:empty?).map(&:to_i)
else
new_weight_classes = []
end
# Extract bracket_positions and rounds # Extract bracket_positions and rounds (leave as-is; model will coerce if needed)
bracket_positions = rule_attributes["bracket_positions"] bracket_positions = rule_attributes["bracket_positions"]
rounds = rule_attributes["rounds"] rounds = rule_attributes["rounds"]
rule_attributes.except!("id", "mat", "tournament_id", "weight_classes") # Remove any keys we don't want to mass-assign (including both old/new weight keys)
rule_attributes.except!("id", "mat", "tournament_id", "weight_classes", "weight_class_maxes")
MatAssignmentRule.create( MatAssignmentRule.create(
rule_attributes.merge( rule_attributes.merge(
@@ -145,4 +164,51 @@ class WrestlingdevImporter
)) ))
end end
end end
def apply_mat_queues
if @mat_queue_bout_numbers.blank?
Mat.where(tournament_id: @tournament.id).find_each do |mat|
match_ids = mat.matches.where(finished: [nil, 0]).order(:bout_number).limit(4).pluck(:id)
mat.update(
queue1: match_ids[0],
queue2: match_ids[1],
queue3: match_ids[2],
queue4: match_ids[3]
)
end
return
end
@mat_queue_bout_numbers.each do |mat_name, bout_numbers|
mat = Mat.find_by(name: mat_name, tournament_id: @tournament.id)
next unless mat
matches = Array(bout_numbers).map do |bout_number|
Match.find_by(bout_number: bout_number, tournament_id: @tournament.id)
end
mat.update(
queue1: matches[0]&.id,
queue2: matches[1]&.id,
queue3: matches[2]&.id,
queue4: matches[3]&.id
)
matches.compact.each do |match|
match.update(mat_id: mat.id)
end
end
Mat.where(tournament_id: @tournament.id)
.where(queue1: nil, queue2: nil, queue3: nil, queue4: nil)
.find_each do |mat|
match_ids = mat.matches.where(finished: [nil, 0]).order(:bout_number).limit(4).pluck(:id)
mat.update(
queue1: match_ids[0],
queue2: match_ids[1],
queue3: match_ids[2],
queue4: match_ids[3]
)
end
end
end end

View File

@@ -3,11 +3,13 @@ class GeneratePoolNumbers
@weight = weight @weight = weight
end end
def savePoolNumbers def savePoolNumbers(wrestlers: nil, persist: true)
@weight.wrestlers.each do |wrestler| wrestlers_to_update = wrestlers || @weight.wrestlers.to_a
wrestlers_to_update.each do |wrestler|
wrestler.pool = get_wrestler_pool_number(@weight.pools, wrestler.bracket_line) wrestler.pool = get_wrestler_pool_number(@weight.pools, wrestler.bracket_line)
wrestler.save
end end
persist_pool_numbers(wrestlers_to_update) if persist
wrestlers_to_update
end end
def get_wrestler_pool_number(number_of_pools, wrestler_seed) def get_wrestler_pool_number(number_of_pools, wrestler_seed)
@@ -36,4 +38,20 @@ class GeneratePoolNumbers
pool pool
end end
private
def persist_pool_numbers(wrestlers)
return if wrestlers.blank?
timestamp = Time.current
rows = wrestlers.map do |w|
{
id: w.id,
pool: w.pool,
updated_at: timestamp
}
end
Wrestler.upsert_all(rows)
end
end end

View File

@@ -5,7 +5,7 @@ class CalculateWrestlerTeamScore
end end
def totalScore def totalScore
if @wrestler.extra or @wrestler.matches.count == 0 if @wrestler.extra or @wrestler.all_matches.count == 0
return 0 return 0
else else
earnedPoints - deductedPoints earnedPoints - deductedPoints
@@ -54,29 +54,20 @@ class CalculateWrestlerTeamScore
def byePoints def byePoints
points = 0 points = 0
if @tournament.tournament_type == "Pool to bracket" if @tournament.tournament_type == "Pool to bracket"
if @wrestler.pool_wins.size >= 1 and @wrestler.has_a_pool_bye == true if pool_bye_points_eligible?
points += 2 points += 2
end end
end end
if @tournament.tournament_type.include? "Regular Double Elimination" if @tournament.tournament_type.include? "Double Elimination"
if @wrestler.championship_advancement_wins.size > 0 or @wrestler.matches_won.select{|m| m.bracket_position == "1/2" and m.win_type != "BYE"}.size > 0 if @wrestler.championship_advancement_wins.any? &&
# if they have a win in the championship round or if they got a bye all the way to finals and won the finals @wrestler.championship_byes.any? &&
points += @wrestler.championship_byes.size * 2 any_bye_round_had_wrestled_match?(@wrestler.championship_byes)
points += 2
end end
if @wrestler.consolation_advancement_wins.size > 0 or @wrestler.matches_won.select{|m| m.bracket_position == "3/4" and m.win_type != "BYE"}.size > 0 if @wrestler.consolation_advancement_wins.any? &&
# if they have a win in the consolation round or if they got a bye all the way to 3rd/4th match and won @wrestler.consolation_byes.any? &&
points += @wrestler.consolation_byes.size * 1 any_bye_round_had_wrestled_match?(@wrestler.consolation_byes)
end points += 1
end
if @tournament.tournament_type.include? "Modified 16 Man Double Elimination"
if @wrestler.championship_advancement_wins.size > 0 or @wrestler.matches_won.select{|m| m.bracket_position == "1/2" and m.win_type != "BYE"}.size > 0
# if they have a win in the championship round or if they got a bye all the way to finals and won the finals
points += @wrestler.championship_byes.size * 2
end
if @wrestler.consolation_advancement_wins.size > 0 or @wrestler.matches_won.select{|m| m.bracket_position == "5/6" and m.win_type != "BYE"}.size > 0
# if they have a win in the consolation round or if they got a bye all the way to 5th/6th match and won
# since the consolation bracket goes to 5/6 in a modified tournament
points += @wrestler.consolation_byes.size * 1
end end
end end
return points return points
@@ -86,4 +77,30 @@ class CalculateWrestlerTeamScore
(@wrestler.pin_wins.size * 2) + (@wrestler.tech_wins.size * 1.5) + (@wrestler.major_wins.size * 1) (@wrestler.pin_wins.size * 2) + (@wrestler.tech_wins.size * 1.5) + (@wrestler.major_wins.size * 1)
end end
private
def pool_bye_points_eligible?
return false unless @wrestler.pool_wins.size >= 1
return false unless @wrestler.weight.pools.to_i > 1
wrestler_pool_size = @wrestler.weight.wrestlers_in_pool(@wrestler.pool).size
largest_pool_size = (1..@wrestler.weight.pools).map { |pool_number| @wrestler.weight.wrestlers_in_pool(pool_number).size }.max
wrestler_pool_size < largest_pool_size
end
def any_bye_round_had_wrestled_match?(bye_matches)
bye_matches.any? do |bye_match|
next false if bye_match.round.nil?
@wrestler.weight.matches.any? do |match|
next false if match.id == bye_match.id
next false if match.round != bye_match.round
next false if match.is_consolation_match != bye_match.is_consolation_match
match.finished == 1 && match.win_type.present? && match.win_type != "BYE"
end
end
end
end end

View File

@@ -27,10 +27,22 @@ class DoubleEliminationPlacementPoints
end end
def bracket_position_size(bracket_position_name) def bracket_position_size(bracket_position_name)
@wrestler.all_matches.select{|m| m.bracket_position == bracket_position_name}.size @wrestler.all_matches.select{|m| m.bracket_position == bracket_position_name}.size
end end
def won_bracket_position_size(bracket_position_name) def won_bracket_position_size(bracket_position_name)
@wrestler.matches_won.select{|m| m.bracket_position == bracket_position_name}.size @wrestler.matches_won.select{|m| m.bracket_position == bracket_position_name}.size
end end
def bracket_placement_points(bracket_position_name)
if bracket_position_name == "Did not place"
return 0
end
if @wrestler.participating_matches.where(bracket_position: bracket_position_name).count > 0
points = Teampointadjust.find_by(tournament_id: @wrestler.tournament.id, points_for_placement: bracket_position_name)
if points
# ... existing code ...
end
end
end
end end

View File

@@ -28,7 +28,7 @@ json.cache! ["api_tournament", @tournament] do
json.mats @mats do |mat| json.mats @mats do |mat|
json.name mat.name json.name mat.name
json.unfinished_matches mat.unfinished_matches do |match| json.unfinished_matches mat.queue_matches.compact do |match|
json.bout_number match.bout_number json.bout_number match.bout_number
json.w1_name match.w1_name json.w1_name match.w1_name
json.w2_name match.w2_name json.w2_name match.w2_name

View File

@@ -1,6 +1,3 @@
<!-- Fontawesome CDN -->
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.7.1/css/all.css" integrity="sha384-fnmOCqbTlWIlj8LyTjo7mOUStjsKC4pOpQbqyi7RrhN7udi9RwhKkMHpvLbHG9Sr" crossorigin="anonymous">
<!-- Fontawesome Modifications --> <!-- Fontawesome Modifications -->
<style> <style>
.fa { .fa {

View File

@@ -19,7 +19,7 @@
<a class="dropdown-toggle" data-toggle="dropdown" href="#"><%= current_user.email %> <a class="dropdown-toggle" data-toggle="dropdown" href="#"><%= current_user.email %>
<span class="caret"></span></a> <span class="caret"></span></a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li><%= link_to "Log out", logout_path, method: :delete %></li> <li><%= button_to "Log out", logout_path, method: :delete, class: 'btn btn-link', form: { class: 'navbar-logout-form' } %></li>
<li><%= link_to "Edit profile", edit_user_path(current_user) %></li> <li><%= link_to "Edit profile", edit_user_path(current_user) %></li>
<li><%= link_to "My tournaments and schools", "/static_pages/my_tournaments" %></li> <li><%= link_to "My tournaments and schools", "/static_pages/my_tournaments" %></li>
</ul> </ul>

View File

@@ -29,6 +29,7 @@
</ul> </ul>
</li> </li>
<li><%= link_to " Bout Board" , "/tournaments/#{@tournament.id}/up_matches", class: "fas fa-list-alt" %></li> <li><%= link_to " Bout Board" , "/tournaments/#{@tournament.id}/up_matches", class: "fas fa-list-alt" %></li>
<li><%= link_to " Live Scores" , "/tournaments/#{@tournament.id}/live_scores", class: "fas fa-tv" %></li>
<% end %> <% end %>
<% if can? :manage, @tournament %> <% if can? :manage, @tournament %>
<li class="dropdown"> <li class="dropdown">
@@ -40,10 +41,11 @@
<li><%= link_to "Weigh In Page" , "/tournaments/#{@tournament.id}/weigh_in" %></li> <li><%= link_to "Weigh In Page" , "/tournaments/#{@tournament.id}/weigh_in" %></li>
<li><%= link_to "All Matches" , "/tournaments/#{@tournament.id}/matches" %></li> <li><%= link_to "All Matches" , "/tournaments/#{@tournament.id}/matches" %></li>
<li><%= link_to "Full Screen Bout Board" , "/tournaments/#{@tournament.id}/up_matches?print=true" , target: :_blank %></li> <li><%= link_to "Full Screen Bout Board" , "/tournaments/#{@tournament.id}/up_matches?print=true" , target: :_blank %></li>
<li><%= link_to "QR Code (Full Screen)" , "/tournaments/#{@tournament.id}/qrcode?print=true" , target: :_blank %></li>
<li><%= link_to "Deduct Team Points" , "/tournaments/#{@tournament.id}/teampointadjust" %></li> <li><%= link_to "Deduct Team Points" , "/tournaments/#{@tournament.id}/teampointadjust" %></li>
<li><%= link_to "View All Mat Assignment Rules", tournament_mat_assignment_rules_path(@tournament) %></li> <li><%= link_to "View All Mat Assignment Rules", tournament_mat_assignment_rules_path(@tournament) %></li>
<li><%= link_to 'Manage Backups', tournament_tournament_backups_path(@tournament) %></li> <li><%= link_to 'Manage Backups', tournament_tournament_backups_path(@tournament) %></li>
<li><%= link_to "Reset Bout Board", reset_bout_board_tournament_path(@tournament), method: :post, data: { confirm: "Are you sure you want to reset the bout board?" } %></li> <li><%= link_to "Reset Bout Board", reset_bout_board_tournament_path(@tournament), data: { turbo_method: :post, turbo_confirm: "Are you sure you want to reset the bout board?" } %></li>
<% if can? :destroy, @tournament %> <% if can? :destroy, @tournament %>
<li><%= link_to "Tournament Delegation" , "/tournaments/#{@tournament.id}/delegate" %></li> <li><%= link_to "Tournament Delegation" , "/tournaments/#{@tournament.id}/delegate" %></li>
<% end %> <% end %>
@@ -55,13 +57,13 @@
<% end %> <% end %>
<% end %> <% end %>
<li><strong>Time Savers</strong></li> <li><strong>Time Savers</strong></li>
<li><%= link_to "Create Boys High School Weights (106-285)" , "/tournaments/#{@tournament.id}/create_custom_weights?customValue=#{Weight::HS_WEIGHT_CLASSES}",data: { confirm: 'Are you sure? This will delete all current weights.' } %></li> <li><%= link_to "Create Boys High School Weights (106-285)" , "/tournaments/#{@tournament.id}/create_custom_weights?customValue=#{Weight::HS_WEIGHT_CLASSES}",data: { turbo_method: :get, turbo_confirm: 'Are you sure? This will delete all current weights.' } %></li>
<li><%= link_to "Create Girls High School Weights (100-235)" , "/tournaments/#{@tournament.id}/create_custom_weights?customValue=#{Weight::HS_GIRLS_WEIGHT_CLASSES}",data: { confirm: 'Are you sure? This will delete all current weights.' } %></li> <li><%= link_to "Create Girls High School Weights (100-235)" , "/tournaments/#{@tournament.id}/create_custom_weights?customValue=#{Weight::HS_GIRLS_WEIGHT_CLASSES}",data: { turbo_method: :get, turbo_confirm: 'Are you sure? This will delete all current weights.' } %></li>
<li><%= link_to "Create Boys Middle School Weights (80-245)" , "/tournaments/#{@tournament.id}/create_custom_weights?customValue=#{Weight::MS_WEIGHT_CLASSES}",data: { confirm: 'Are you sure? This will delete all current weights.' } %></li> <li><%= link_to "Create Boys Middle School Weights (80-245)" , "/tournaments/#{@tournament.id}/create_custom_weights?customValue=#{Weight::MS_WEIGHT_CLASSES}",data: { turbo_method: :get, turbo_confirm: 'Are you sure? This will delete all current weights.' } %></li>
<li><%= link_to "Create Girls Middle School Weights (72-235)" , "/tournaments/#{@tournament.id}/create_custom_weights?customValue=#{Weight::MS_GIRLS_WEIGHT_CLASSES}",data: { confirm: 'Are you sure? This will delete all current weights.' } %></li> <li><%= link_to "Create Girls Middle School Weights (72-235)" , "/tournaments/#{@tournament.id}/create_custom_weights?customValue=#{Weight::MS_GIRLS_WEIGHT_CLASSES}",data: { turbo_method: :get, turbo_confirm: 'Are you sure? This will delete all current weights.' } %></li>
<li><strong>Tournament Actions</strong></li> <li><strong>Tournament Actions</strong></li>
<li><%= link_to "Calculate Team Scores" , "/tournaments/#{@tournament.id}/calculate_team_scores", :method => :put %></li> <li><%= link_to "Calculate Team Scores" , "/tournaments/#{@tournament.id}/calculate_team_scores", data: { turbo_method: :put } %></li>
<li><%= link_to "Generate Brackets" , "/tournaments/#{@tournament.id}/generate_matches", data: { confirm: 'Are you sure? This will delete all current matches.' } %></li> <li><%= link_to "Generate Brackets" , "/tournaments/#{@tournament.id}/generate_matches", data: { turbo_method: :get, turbo_confirm: 'Are you sure? This will delete all current matches.' } %></li>
<li><%= link_to "Export Data" , "/tournaments/#{@tournament.id}/export?print=true", target: :_blank %></li> <li><%= link_to "Export Data" , "/tournaments/#{@tournament.id}/export?print=true", target: :_blank %></li>
</ul> </ul>
<% end %> <% end %>

View File

@@ -3,10 +3,10 @@
<% if params[:print] %> <% if params[:print] %>
<head> <head>
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<%= action_cable_meta_tag %>
<title>WrestlingDev</title> <title>WrestlingDev</title>
<%= stylesheet_link_tag "application", media: "all", <%= stylesheet_link_tag "application", media: "all", "data-turbo-track": "reload" %>
"data-turbolinks-track" => true %> <%= javascript_importmap_tags %>
<%= javascript_include_tag "application", "data-turbolinks-track" => true %>
<%= render 'layouts/cdn' %> <%= render 'layouts/cdn' %>
<%= render 'layouts/shim' %> <%= render 'layouts/shim' %>
</head> </head>
@@ -16,12 +16,13 @@
<% else %> <% else %>
<head> <head>
<title>WrestlingDev</title> <title>WrestlingDev</title>
<%= action_cable_meta_tag %>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<% if Rails.env.production? %> <% if Rails.env.production? %>
<%= render 'layouts/analytics' %> <%= render 'layouts/analytics' %>
<% end %> <% end %>
<%= stylesheet_link_tag "application" %> <%= stylesheet_link_tag "application", media: "all", "data-turbo-track": "reload" %>
<%= javascript_include_tag "application" %> <%= javascript_importmap_tags %>
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<%= render 'layouts/cdn' %> <%= render 'layouts/cdn' %>
<%= render 'layouts/shim' %> <%= render 'layouts/shim' %>
@@ -35,7 +36,11 @@
<div id="page-content"> <div id="page-content">
<div class="row"> <div class="row">
<div class="col-md-12"><%= render 'layouts/underheader' %></div> <div class="col-md-12">
<% unless hide_ads? %>
<%= render 'layouts/underheader' %>
<% end %>
</div>
</div> </div>
<div class="row no-margin"> <div class="row no-margin">
<div class="col-md-12" style="padding-left: 2%;"> <div class="col-md-12" style="padding-left: 2%;">
@@ -46,6 +51,7 @@
<p id="alert" class="alert alert-danger alert-dismissible"><a href="#" class="close" data-dismiss="alert" aria-label="close">&times;</a><%= alert %></p> <p id="alert" class="alert alert-danger alert-dismissible"><a href="#" class="close" data-dismiss="alert" aria-label="close">&times;</a><%= alert %></p>
<% end %> <% end %>
<div id="view" style="overflow-x: auto; overflow-y: hidden;"> <%# Horizontal scroll only %> <div id="view" style="overflow-x: auto; overflow-y: hidden;"> <%# Horizontal scroll only %>
<br><br>
<%= yield %> <%= yield %>
</div> </div>
</div> </div>
@@ -56,4 +62,3 @@
</body> </body>
<% end %> <% end %>
</html> </html>

View File

@@ -23,7 +23,7 @@
<td><%= Array(rule.rounds).join(", ") %></td> <td><%= Array(rule.rounds).join(", ") %></td>
<td> <td>
<%= link_to '', edit_tournament_mat_assignment_rule_path(@tournament, rule), class: "fas fa-edit" %> <%= link_to '', edit_tournament_mat_assignment_rule_path(@tournament, rule), class: "fas fa-edit" %>
<%= link_to '', tournament_mat_assignment_rule_path(@tournament, rule), method: :delete, data: { confirm: "Are you sure?" }, class: "fas fa-trash-alt" %> <%= link_to '', tournament_mat_assignment_rule_path(@tournament, rule), data: { turbo_method: :delete, turbo_confirm: "Are you sure?" }, class: "fas fa-trash-alt" %>
</td> </td>
</tr> </tr>
<% end %> <% end %>

Some files were not shown because too many files have changed in this diff Show More