mirror of
https://github.com/jcwimer/wrestlingApp
synced 2026-04-24 14:53:18 +00:00
Compare commits
68 Commits
ed7186e5ce
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
| a031cfb446 | |||
| c210b70c95 | |||
| 7526148ba5 | |||
| e8e0fa291b | |||
| 679fc2fcb9 | |||
| 18d39c6c8f | |||
| ca4d5ce0db | |||
| 654cb84827 | |||
| dc50efe8fc | |||
| 8670ce38c3 | |||
| d359be3ea1 | |||
| e97aa0d680 | |||
| ae8d995b2c | |||
| d57aaac09d | |||
| fcc8a9b9a9 | |||
| b51866e9d8 | |||
| 07d43e7720 | |||
| d8b6cfa8ac | |||
| 5d674f894f | |||
| 25df2a7280 | |||
| 2767274066 | |||
| a2f8c7bced | |||
| 9c2a9d62ad | |||
| 556090c16b | |||
| 86f9c03991 | |||
| c8764c149b | |||
| fe9a9c628c | |||
| 7e4b6d8fc8 | |||
| 940f7b1d00 | |||
| 52df73d14f | |||
| 8b03a74b1e | |||
| b4bca8f10a | |||
| af1f8df4b6 | |||
| 3576445a1c | |||
| 8c2ddf55ed | |||
| cfd3e7aecd | |||
| 608999cb51 | |||
| 6b5308360e | |||
| 9ca6572d9b | |||
| 61dc5e3cdd | |||
| af2fc3feba | |||
| 793a9e3ecc | |||
| f73e9bfc4e | |||
| 92bd06fe3c | |||
| 6e9554be55 | |||
| 34f1783031 | |||
| bbd2bd9b44 | |||
| 6ecebba70d | |||
| e64751e471 | |||
| d0f19e855f | |||
| 3e1ae22b6b | |||
| 15f85e439c | |||
| c5b9783853 | |||
| cd77268070 | |||
| d61ed80287 | |||
| dd5ce9bd60 | |||
| 9a4e6f6597 | |||
| 782baedcfe | |||
|
|
53e16952bf | ||
|
|
0326d87261 | ||
| 5296b71bb9 | |||
| 58be6b8074 | |||
| 4accedbb43 | |||
| 2856060b11 | |||
| 68a7b214c9 | |||
| 1fcaec876f | |||
| 3e4317dbc5 | |||
| 44fb5388b4 |
@@ -1,4 +1,11 @@
|
||||
- If rails isn't installed use docker: 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.
|
||||
- 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
16
.gitignore
vendored
@@ -21,4 +21,20 @@ tmp
|
||||
.rvmrc
|
||||
deploy/prod.env
|
||||
frontend/node_modules
|
||||
node_modules
|
||||
.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
|
||||
|
||||
@@ -1 +1 @@
|
||||
wrestlingdev
|
||||
wrestlingdev
|
||||
@@ -1 +1 @@
|
||||
ruby-3.2.0
|
||||
ruby-4.0.1
|
||||
11
AGENTS.md
Normal file
11
AGENTS.md
Normal 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
37
Gemfile
@@ -1,14 +1,15 @@
|
||||
source 'https://rubygems.org'
|
||||
|
||||
ruby '3.2.0'
|
||||
ruby '4.0.1'
|
||||
# Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
|
||||
gem 'rails', '8.0.2'
|
||||
gem 'rails', '8.1.2'
|
||||
|
||||
# Added in rails 7.1
|
||||
gem 'rails-html-sanitizer'
|
||||
|
||||
# The original asset pipeline for Rails [https://github.com/rails/sprockets-rails]
|
||||
gem "sprockets-rails"
|
||||
# Asset Management: Propshaft for serving, Importmap for JavaScript
|
||||
gem "propshaft"
|
||||
gem "importmap-rails"
|
||||
|
||||
# Reduces boot times through caching; required in config/boot.rb
|
||||
gem "bootsnap", require: false
|
||||
@@ -17,17 +18,19 @@ gem "bootsnap", require: false
|
||||
# Use sqlite3 version compatible with Rails 8
|
||||
gem 'sqlite3', ">= 2.1", :group => :development
|
||||
|
||||
# Use Uglifier as compressor for JavaScript assets
|
||||
gem 'uglifier'
|
||||
# Use CoffeeScript for .js.coffee assets and views
|
||||
gem 'coffee-rails'
|
||||
# JavaScript and CSS related gems
|
||||
# Uglifier is not used with Propshaft by default
|
||||
# CoffeeScript (.js.coffee) files need to be converted to .js as Propshaft doesn't compile them
|
||||
|
||||
# See https://github.com/sstephenson/execjs#readme for more supported runtimes
|
||||
# gem 'therubyracer', platforms: :ruby
|
||||
|
||||
# Use jquery as the JavaScript library
|
||||
gem 'jquery-rails'
|
||||
# Turbolinks makes following links in your web application faster. Read more: https://github.com/rails/turbolinks
|
||||
gem 'turbolinks'
|
||||
# Turbo for modern page interactions
|
||||
gem 'turbo-rails'
|
||||
# Stimulus for JavaScript behaviors
|
||||
gem 'stimulus-rails'
|
||||
# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
|
||||
gem 'jbuilder'
|
||||
# bundle exec rake doc:rails generates the API under doc/api.
|
||||
@@ -64,25 +67,24 @@ gem 'influxdb-rails'
|
||||
gem 'cancancan'
|
||||
gem 'round_robin_tournament'
|
||||
gem 'rb-readline'
|
||||
gem 'rqrcode'
|
||||
# Replacing Delayed Job with Solid Queue
|
||||
# gem 'delayed_job_active_record'
|
||||
gem 'solid_queue'
|
||||
gem 'solid_cable'
|
||||
gem 'puma'
|
||||
gem 'passenger'
|
||||
gem 'tzinfo-data'
|
||||
gem 'daemons'
|
||||
# Interface for viewing and managing background jobs
|
||||
# gem 'delayed_job_web'
|
||||
# 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'
|
||||
# Solid Queue UI
|
||||
gem "mission_control-jobs"
|
||||
|
||||
|
||||
group :development do
|
||||
# gem 'rubocop'
|
||||
gem 'bullet'
|
||||
gem 'brakeman'
|
||||
gem 'bundler-audit'
|
||||
gem 'rubocop'
|
||||
end
|
||||
|
||||
group :development, :test do
|
||||
@@ -90,6 +92,3 @@ group :development, :test do
|
||||
# rails-controller-testing is needed for assert_template
|
||||
gem 'rails-controller-testing'
|
||||
end
|
||||
|
||||
gem 'font-awesome-sass'
|
||||
|
||||
|
||||
382
Gemfile.lock
382
Gemfile.lock
@@ -1,29 +1,31 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
action_text-trix (2.1.18)
|
||||
railties
|
||||
actioncable (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activestorage (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionmailbox (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activestorage (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
actionview (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionmailer (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
actionview (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (8.0.2)
|
||||
actionview (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionpack (8.1.2)
|
||||
actionview (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
nokogiri (>= 1.8.5)
|
||||
rack (>= 2.2.4)
|
||||
rack-session (>= 1.0.1)
|
||||
@@ -31,130 +33,137 @@ GEM
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activestorage (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actiontext (8.1.2)
|
||||
action_text-trix (~> 2.1.15)
|
||||
actionpack (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activestorage (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionview (8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activejob (8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activerecord (8.0.2)
|
||||
activemodel (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activemodel (8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
activerecord (8.1.2)
|
||||
activemodel (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activestorage (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
marcel (~> 1.0)
|
||||
activesupport (8.0.2)
|
||||
activesupport (8.1.2)
|
||||
base64
|
||||
benchmark (>= 0.3)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
connection_pool (>= 2.2.5)
|
||||
drb
|
||||
i18n (>= 1.6, < 2)
|
||||
json
|
||||
logger (>= 1.4.2)
|
||||
minitest (>= 5.1)
|
||||
securerandom (>= 0.3)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
uri (>= 0.13.1)
|
||||
base64 (0.2.0)
|
||||
bcrypt (3.1.20)
|
||||
benchmark (0.4.0)
|
||||
bigdecimal (3.1.9)
|
||||
bootsnap (1.18.4)
|
||||
ast (2.4.3)
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.22)
|
||||
bigdecimal (4.1.1)
|
||||
bootsnap (1.23.0)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (7.0.2)
|
||||
brakeman (8.0.4)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
bullet (8.0.3)
|
||||
bullet (8.1.0)
|
||||
activesupport (>= 3.0.0)
|
||||
uniform_notifier (~> 1.11)
|
||||
bundler-audit (0.9.2)
|
||||
bundler (>= 1.2.0, < 3)
|
||||
bundler-audit (0.9.3)
|
||||
bundler (>= 1.2.0)
|
||||
thor (~> 1.0)
|
||||
cancancan (3.6.1)
|
||||
coffee-rails (5.0.0)
|
||||
coffee-script (>= 2.2.0)
|
||||
railties (>= 5.2.0)
|
||||
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)
|
||||
chunky_png (1.4.0)
|
||||
concurrent-ruby (1.3.6)
|
||||
connection_pool (3.0.2)
|
||||
crass (1.0.6)
|
||||
daemons (1.4.1)
|
||||
date (3.4.1)
|
||||
drb (2.2.1)
|
||||
date (3.5.1)
|
||||
drb (2.2.3)
|
||||
erb (6.0.2)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.2.11)
|
||||
et-orbi (1.4.0)
|
||||
tzinfo
|
||||
execjs (2.10.0)
|
||||
ffi (1.17.1-aarch64-linux-gnu)
|
||||
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)
|
||||
fugit (1.12.1)
|
||||
et-orbi (~> 1.4)
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
globalid (1.3.0)
|
||||
activesupport (>= 6.1)
|
||||
i18n (1.14.7)
|
||||
i18n (1.14.8)
|
||||
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-rails (1.0.3)
|
||||
influxdb (~> 0.6, >= 0.6.4)
|
||||
railties (>= 5.0)
|
||||
io-console (0.8.0)
|
||||
irb (1.15.2)
|
||||
io-console (0.8.2)
|
||||
irb (1.17.0)
|
||||
pp (>= 0.6.0)
|
||||
prism (>= 1.3.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jbuilder (2.13.0)
|
||||
actionview (>= 5.0.0)
|
||||
activesupport (>= 5.0.0)
|
||||
jquery-rails (4.6.0)
|
||||
jbuilder (2.14.1)
|
||||
actionview (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
jquery-rails (4.6.1)
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.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)
|
||||
loofah (2.24.0)
|
||||
loofah (2.25.1)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.8.1)
|
||||
mail (2.9.0)
|
||||
logger
|
||||
mini_mime (>= 0.1.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
marcel (1.0.4)
|
||||
marcel (1.1.0)
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.25.5)
|
||||
mocha (2.7.1)
|
||||
minitest (6.0.3)
|
||||
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)
|
||||
msgpack (1.8.0)
|
||||
mysql2 (0.5.6)
|
||||
net-imap (0.5.6)
|
||||
mysql2 (0.5.7)
|
||||
bigdecimal
|
||||
net-imap (0.6.3)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -163,148 +172,172 @@ GEM
|
||||
timeout
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.18.7-aarch64-linux-gnu)
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.2-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.7-aarch64-linux-musl)
|
||||
nokogiri (1.19.2-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.7-arm-linux-gnu)
|
||||
nokogiri (1.19.2-arm-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.7-arm-linux-musl)
|
||||
nokogiri (1.19.2-arm-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.7-arm64-darwin)
|
||||
nokogiri (1.19.2-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.7-x86_64-darwin)
|
||||
nokogiri (1.19.2-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.7-x86_64-linux-gnu)
|
||||
nokogiri (1.19.2-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.7-x86_64-linux-musl)
|
||||
nokogiri (1.19.2-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
passenger (6.0.27)
|
||||
rack (>= 1.6.13)
|
||||
rackup (>= 1.0.1)
|
||||
rake (>= 12.3.3)
|
||||
pp (0.6.2)
|
||||
parallel (2.0.1)
|
||||
parser (3.3.11.1)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pp (0.6.3)
|
||||
prettyprint
|
||||
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
|
||||
stringio
|
||||
puma (6.6.0)
|
||||
puma (8.0.0)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.12)
|
||||
rack-session (2.1.0)
|
||||
rack (3.2.6)
|
||||
rack-session (2.1.2)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.2.0)
|
||||
rack (>= 1.3)
|
||||
rackup (2.2.1)
|
||||
rackup (2.3.1)
|
||||
rack (>= 3)
|
||||
rails (8.0.2)
|
||||
actioncable (= 8.0.2)
|
||||
actionmailbox (= 8.0.2)
|
||||
actionmailer (= 8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
actiontext (= 8.0.2)
|
||||
actionview (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activemodel (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activestorage (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
rails (8.1.2)
|
||||
actioncable (= 8.1.2)
|
||||
actionmailbox (= 8.1.2)
|
||||
actionmailer (= 8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
actiontext (= 8.1.2)
|
||||
actionview (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activemodel (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activestorage (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.0.2)
|
||||
railties (= 8.1.2)
|
||||
rails-controller-testing (1.0.5)
|
||||
actionpack (>= 5.0.1.rc1)
|
||||
actionview (>= 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)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.6.2)
|
||||
loofah (~> 2.21)
|
||||
rails-html-sanitizer (1.7.0)
|
||||
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)
|
||||
rails_12factor (0.0.3)
|
||||
rails_serve_static_assets
|
||||
rails_stdout_logging
|
||||
rails_serve_static_assets (0.0.5)
|
||||
rails_stdout_logging (0.0.5)
|
||||
railties (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
railties (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0, >= 1.2.2)
|
||||
tsort (>= 0.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rake (13.2.1)
|
||||
rainbow (3.1.1)
|
||||
rake (13.4.1)
|
||||
rb-readline (0.5.5)
|
||||
rdoc (6.13.1)
|
||||
rdoc (7.2.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
reline (0.6.1)
|
||||
tsort
|
||||
regexp_parser (2.12.0)
|
||||
reline (0.6.3)
|
||||
io-console (~> 0.5)
|
||||
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)
|
||||
sassc (2.4.0)
|
||||
ffi (~> 1.9)
|
||||
sdoc (2.6.1)
|
||||
sdoc (2.6.5)
|
||||
rdoc (>= 5.0)
|
||||
securerandom (0.4.1)
|
||||
solid_cable (3.0.7)
|
||||
solid_cable (3.0.12)
|
||||
actioncable (>= 7.2)
|
||||
activejob (>= 7.2)
|
||||
activerecord (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
solid_cache (1.0.7)
|
||||
solid_cache (1.0.10)
|
||||
activejob (>= 7.2)
|
||||
activerecord (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
solid_queue (1.1.4)
|
||||
solid_queue (1.4.0)
|
||||
activejob (>= 7.1)
|
||||
activerecord (>= 7.1)
|
||||
concurrent-ruby (>= 1.3.1)
|
||||
fugit (~> 1.11.0)
|
||||
fugit (~> 1.11)
|
||||
railties (>= 7.1)
|
||||
thor (~> 1.3.1)
|
||||
spring (4.3.0)
|
||||
sprockets (4.2.1)
|
||||
concurrent-ruby (~> 1.0)
|
||||
rack (>= 2.2.4, < 4)
|
||||
sprockets-rails (3.5.2)
|
||||
actionpack (>= 6.1)
|
||||
activesupport (>= 6.1)
|
||||
sprockets (>= 3.0.0)
|
||||
sqlite3 (2.6.0-aarch64-linux-gnu)
|
||||
sqlite3 (2.6.0-aarch64-linux-musl)
|
||||
sqlite3 (2.6.0-arm-linux-gnu)
|
||||
sqlite3 (2.6.0-arm-linux-musl)
|
||||
sqlite3 (2.6.0-arm64-darwin)
|
||||
sqlite3 (2.6.0-x86_64-darwin)
|
||||
sqlite3 (2.6.0-x86_64-linux-gnu)
|
||||
sqlite3 (2.6.0-x86_64-linux-musl)
|
||||
stringio (3.1.6)
|
||||
thor (1.3.2)
|
||||
timeout (0.4.3)
|
||||
turbolinks (5.2.1)
|
||||
turbolinks-source (~> 5.2)
|
||||
turbolinks-source (5.2.0)
|
||||
thor (>= 1.3.1)
|
||||
spring (4.4.2)
|
||||
sqlite3 (2.9.2-aarch64-linux-gnu)
|
||||
sqlite3 (2.9.2-aarch64-linux-musl)
|
||||
sqlite3 (2.9.2-arm-linux-gnu)
|
||||
sqlite3 (2.9.2-arm-linux-musl)
|
||||
sqlite3 (2.9.2-arm64-darwin)
|
||||
sqlite3 (2.9.2-x86_64-darwin)
|
||||
sqlite3 (2.9.2-x86_64-linux-gnu)
|
||||
sqlite3 (2.9.2-x86_64-linux-musl)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.2.0)
|
||||
thor (1.5.0)
|
||||
timeout (0.6.1)
|
||||
tsort (0.2.0)
|
||||
turbo-rails (2.0.23)
|
||||
actionpack (>= 7.1.0)
|
||||
railties (>= 7.1.0)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
tzinfo-data (1.2025.2)
|
||||
tzinfo-data (1.2026.1)
|
||||
tzinfo (>= 1.0.0)
|
||||
uglifier (4.2.1)
|
||||
execjs (>= 0.3.0, < 3)
|
||||
uniform_notifier (1.16.0)
|
||||
uri (1.0.3)
|
||||
unicode-display_width (3.2.0)
|
||||
unicode-emoji (~> 4.1)
|
||||
unicode-emoji (4.2.0)
|
||||
uniform_notifier (1.18.0)
|
||||
uri (1.1.1)
|
||||
useragent (0.16.11)
|
||||
websocket-driver (0.7.7)
|
||||
websocket-driver (0.8.0)
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
zeitwerk (2.7.2)
|
||||
zeitwerk (2.7.5)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux-gnu
|
||||
@@ -323,35 +356,36 @@ DEPENDENCIES
|
||||
bullet
|
||||
bundler-audit
|
||||
cancancan
|
||||
coffee-rails
|
||||
daemons
|
||||
font-awesome-sass
|
||||
importmap-rails
|
||||
influxdb-rails
|
||||
jbuilder
|
||||
jquery-rails
|
||||
mission_control-jobs
|
||||
mocha
|
||||
mysql2
|
||||
passenger
|
||||
propshaft
|
||||
puma
|
||||
rails (= 8.0.2)
|
||||
rails (= 8.1.2)
|
||||
rails-controller-testing
|
||||
rails-html-sanitizer
|
||||
rails_12factor
|
||||
rb-readline
|
||||
round_robin_tournament
|
||||
rqrcode
|
||||
rubocop
|
||||
sdoc
|
||||
solid_cable
|
||||
solid_cache
|
||||
solid_queue
|
||||
spring
|
||||
sprockets-rails
|
||||
sqlite3 (>= 2.1)
|
||||
turbolinks
|
||||
stimulus-rails
|
||||
turbo-rails
|
||||
tzinfo-data
|
||||
uglifier
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.2.0p0
|
||||
ruby 4.0.1p0
|
||||
|
||||
BUNDLED WITH
|
||||
2.6.7
|
||||
4.0.3
|
||||
|
||||
111
README.md
111
README.md
@@ -7,11 +7,13 @@ This application is being created to run a wrestling tournament.
|
||||
**Public Production Url:** [https://wrestlingdev.com](https://wrestlingdev.com)
|
||||
|
||||
**App Info**
|
||||
* Ruby 3.2.0
|
||||
* Rails 8.0.0
|
||||
* Ruby 4.0.1
|
||||
* Rails 8.1.2
|
||||
* DB MySQL/MariaDB
|
||||
* Memcached
|
||||
* Solid Queue for background job processing
|
||||
* Solid Cache -> MySQL/MariaDB for html partial caching
|
||||
* Solid Queue -> MySQL/MariaDB for background job processing
|
||||
* Solid Cable -> MySQL/MariaDB for websocket channels
|
||||
* Hotwired Stimulus for client-side JavaScript
|
||||
|
||||
# Development
|
||||
|
||||
@@ -32,11 +34,54 @@ In development environments, background jobs run inline (synchronously) by defau
|
||||
|
||||
To run a single test file:
|
||||
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:
|
||||
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
|
||||
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.
|
||||
* `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.
|
||||
* `bundle-audit check --update` - check for vulnerabilities in Gemfile.lock
|
||||
|
||||
## 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.
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
- **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.
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
### 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_EMAIL` - Email address (currently must be a Gmail account)
|
||||
* `WRESTLINGDEV_EMAIL_PWD` - Email password
|
||||
* `WRESTLINGDEV_MISSION_CONTROL_USER` - mission control username
|
||||
* `WRESTLINGDEV_MISSION_CONTROL_PASSWORD` - mission control password
|
||||
|
||||
### Optional Environment Variables
|
||||
* `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_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`
|
||||
|
||||
288
SOLID_QUEUE.md
288
SOLID_QUEUE.md
@@ -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)
|
||||
@@ -1,3 +1,11 @@
|
||||
//= 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
|
||||
|
||||
@@ -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/
|
||||
@@ -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/
|
||||
@@ -1,22 +1,62 @@
|
||||
// This is a manifest file that'll be compiled into application.js, which will include all the files
|
||||
// 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 .
|
||||
// Entry point for your JavaScript application
|
||||
|
||||
// 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";
|
||||
|
||||
169
app/assets/javascripts/controllers/mat_state_controller.js
Normal file
169
app/assets/javascripts/controllers/mat_state_controller.js
Normal 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
|
||||
}
|
||||
}
|
||||
424
app/assets/javascripts/controllers/match_data_controller.js
Normal file
424
app/assets/javascripts/controllers/match_data_controller.js
Normal 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.')
|
||||
}
|
||||
}
|
||||
}
|
||||
297
app/assets/javascripts/controllers/match_score_controller.js
Normal file
297
app/assets/javascripts/controllers/match_score_controller.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")}`
|
||||
}
|
||||
}
|
||||
142
app/assets/javascripts/controllers/match_spectate_controller.js
Normal file
142
app/assets/javascripts/controllers/match_spectate_controller.js
Normal 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
804
app/assets/javascripts/controllers/match_state_controller.js
Normal file
804
app/assets/javascripts/controllers/match_state_controller.js
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
344
app/assets/javascripts/lib/match_state/config.js
Normal file
344
app/assets/javascripts/lib/match_state/config.js
Normal file
@@ -0,0 +1,344 @@
|
||||
/*
|
||||
State page config contract
|
||||
==========================
|
||||
|
||||
The state page responds to these top-level config objects:
|
||||
|
||||
1. `wrestler_actions`
|
||||
Drives the wrestler-side UI from top to bottom inside each wrestler panel.
|
||||
The controller renders these sections in order, so the order in this object
|
||||
controls the visual order underneath each wrestler's name, school, and score.
|
||||
Supported sections:
|
||||
- `match_actions`
|
||||
- `timers`
|
||||
- `extra_actions`
|
||||
|
||||
Each section may define:
|
||||
- `title`
|
||||
- `description`
|
||||
- `items`
|
||||
|
||||
How the state page uses it:
|
||||
- `match_actions.items`
|
||||
Each item is either:
|
||||
- a literal action key, or
|
||||
- a special alias such as `global` or `position`
|
||||
The state page expands those aliases into the currently legal actions for
|
||||
that wrestler and renders them as buttons.
|
||||
- `timers.items`
|
||||
Each item is a timer key. The state page renders the timer display plus
|
||||
start/stop/reset buttons for each listed timer.
|
||||
- `extra_actions.items`
|
||||
Each item is a literal action key rendered as an always-visible button
|
||||
underneath the timer section.
|
||||
|
||||
2. `actionsByKey`
|
||||
Canonical definitions for match actions and history actions.
|
||||
This is the source of truth for how a button behaves and how an action
|
||||
should appear in the event log.
|
||||
Each action may define:
|
||||
- `label`
|
||||
- `availability`
|
||||
- `statCode`
|
||||
- `effect`
|
||||
- `progression`
|
||||
|
||||
How the state page uses it:
|
||||
- `label`
|
||||
Used for button text and event log text.
|
||||
- `availability`
|
||||
Used when `wrestler_actions.match_actions.items` includes aliases like
|
||||
`global` or `position`.
|
||||
- `effect`
|
||||
Used by the rules engine to update score and match position when replaying
|
||||
the event list.
|
||||
- `statCode`
|
||||
Used when rewriting the hidden `w1_stat` / `w2_stat` fields from the
|
||||
structured event log for websocket sync and match submission.
|
||||
- `progression`
|
||||
Used for progressive actions like stalling, caution, and penalty to decide
|
||||
if the opponent should automatically receive a linked point-scoring event.
|
||||
|
||||
Supported `availability` values used by the wrestler-side UI:
|
||||
- `global`
|
||||
- `neutral`
|
||||
- `top`
|
||||
- `bottom`
|
||||
- `extra`
|
||||
|
||||
3. `timers`
|
||||
Canonical timer definitions keyed by timer name.
|
||||
This controls both the timer controls in the wrestler panel and how timer
|
||||
usage is labeled in the event log.
|
||||
|
||||
How the state page uses it:
|
||||
- `label`
|
||||
Displayed next to the running timer value in the wrestler panel.
|
||||
- `maxSeconds`
|
||||
Used to initialize, reset, clamp, and render the timer.
|
||||
- `historyLabel`
|
||||
Used when a timer stop event is recorded in history.
|
||||
- `statCode`
|
||||
Used when rewriting the hidden `w1_stat` / `w2_stat` fields for timer-used
|
||||
events.
|
||||
|
||||
4. `phases`
|
||||
Defines the period / choice sequence for this wrestling style.
|
||||
The active phase drives:
|
||||
- the main match clock
|
||||
- phase labels
|
||||
- start-of-period position behavior
|
||||
- choice button behavior
|
||||
- event grouping in the history list
|
||||
|
||||
How the state page uses it:
|
||||
- chooses which phase sequence to use from bracket position
|
||||
- builds the main match clock state for timed phases
|
||||
- determines whether the current phase is a period or a choice phase
|
||||
- determines how a period starts (`neutral` or from a prior choice)
|
||||
*/
|
||||
|
||||
const RULESETS = {
|
||||
folkstyle_usa: {
|
||||
id: "folkstyle_usa",
|
||||
|
||||
wrestler_actions: {
|
||||
match_actions: {
|
||||
title: "Match Actions",
|
||||
description: "Scoring and match-state actions available based on current position.",
|
||||
items: ["global", "position"]
|
||||
},
|
||||
timers: {
|
||||
title: "Wrestler Timers",
|
||||
description: "Track blood, injury, recovery, and head/neck time for this wrestler.",
|
||||
items: ["blood", "injury", "recovery", "head_neck"]
|
||||
},
|
||||
extra_actions: {
|
||||
title: "Extra Actions",
|
||||
description: "Force the match into a specific position and record it in history.",
|
||||
items: ["position_neutral", "position_top", "position_bottom"]
|
||||
}
|
||||
},
|
||||
|
||||
actionsByKey: {
|
||||
stalling: {
|
||||
label: "Stalling",
|
||||
availability: "global",
|
||||
statCode: "S",
|
||||
effect: { points: 0 },
|
||||
progression: [0, 1, 1, 2]
|
||||
},
|
||||
caution: {
|
||||
label: "Caution",
|
||||
availability: "global",
|
||||
statCode: "C",
|
||||
effect: { points: 0 },
|
||||
progression: [0, 0, 1]
|
||||
},
|
||||
penalty: {
|
||||
label: "Penalty",
|
||||
availability: "global",
|
||||
statCode: "P",
|
||||
effect: { points: 0 },
|
||||
progression: [1, 1, 2]
|
||||
},
|
||||
minus_1: {
|
||||
label: "-1 Point",
|
||||
availability: "global",
|
||||
statCode: "-1",
|
||||
effect: { points: -1 }
|
||||
},
|
||||
plus_1: {
|
||||
label: "+1 Point",
|
||||
availability: "global",
|
||||
statCode: "+1",
|
||||
effect: { points: 1 }
|
||||
},
|
||||
plus_2: {
|
||||
label: "+2 Points",
|
||||
statCode: "+2",
|
||||
effect: { points: 2 }
|
||||
},
|
||||
takedown_3: {
|
||||
label: "Takedown +3",
|
||||
availability: "neutral",
|
||||
statCode: "T3",
|
||||
effect: { points: 3, nextPosition: "top" }
|
||||
},
|
||||
nearfall_2: {
|
||||
label: "Nearfall +2",
|
||||
availability: "top",
|
||||
statCode: "N2",
|
||||
effect: { points: 2 }
|
||||
},
|
||||
nearfall_3: {
|
||||
label: "Nearfall +3",
|
||||
availability: "top",
|
||||
statCode: "N3",
|
||||
effect: { points: 3 }
|
||||
},
|
||||
nearfall_4: {
|
||||
label: "Nearfall +4",
|
||||
availability: "top",
|
||||
statCode: "N4",
|
||||
effect: { points: 4 }
|
||||
},
|
||||
nearfall_5: {
|
||||
label: "Nearfall +5",
|
||||
availability: "top",
|
||||
statCode: "N5",
|
||||
effect: { points: 5 }
|
||||
},
|
||||
escape_1: {
|
||||
label: "Escape +1",
|
||||
availability: "bottom",
|
||||
statCode: "E1",
|
||||
effect: { points: 1, nextPosition: "neutral" }
|
||||
},
|
||||
reversal_2: {
|
||||
label: "Reversal +2",
|
||||
availability: "bottom",
|
||||
statCode: "R2",
|
||||
effect: { points: 2, nextPosition: "top" }
|
||||
},
|
||||
position_neutral: {
|
||||
label: "Neutral",
|
||||
availability: "extra",
|
||||
statCode: "|Neutral|",
|
||||
effect: { points: 0, nextPosition: "neutral" }
|
||||
},
|
||||
position_top: {
|
||||
label: "Top",
|
||||
availability: "extra",
|
||||
statCode: "|Top|",
|
||||
effect: { points: 0, nextPosition: "top" }
|
||||
},
|
||||
position_bottom: {
|
||||
label: "Bottom",
|
||||
availability: "extra",
|
||||
statCode: "|Bottom|",
|
||||
effect: { points: 0, nextPosition: "bottom" }
|
||||
},
|
||||
choice_top: {
|
||||
label: "Choice: Top",
|
||||
statCode: "|Chose Top|"
|
||||
},
|
||||
choice_bottom: {
|
||||
label: "Choice: Bottom",
|
||||
statCode: "|Chose Bottom|"
|
||||
},
|
||||
choice_neutral: {
|
||||
label: "Choice: Neutral",
|
||||
statCode: "|Chose Neutral|"
|
||||
},
|
||||
choice_defer: {
|
||||
label: "Choice: Defer",
|
||||
statCode: "|Deferred|"
|
||||
}
|
||||
},
|
||||
|
||||
timers: {
|
||||
blood: { maxSeconds: 300, label: "Blood", historyLabel: "Blood Time Used", statCode: "Blood Time" },
|
||||
injury: { maxSeconds: 90, label: "Injury", historyLabel: "Injury Time Used", statCode: "Injury Time" },
|
||||
recovery: { maxSeconds: 120, label: "Recovery", historyLabel: "Recovery Time Used", statCode: "Recovery Time" },
|
||||
head_neck: { maxSeconds: 300, label: "Head/Neck", historyLabel: "Head/Neck Time Used", statCode: "Head/Neck Time" }
|
||||
},
|
||||
|
||||
phases: {
|
||||
championship: {
|
||||
label: "Championship Format",
|
||||
sequence: [
|
||||
{ key: "period_1", label: "Period 1", type: "period", startsIn: "neutral", clockSeconds: 120 },
|
||||
{ key: "choice_1", label: "Choice 1", type: "choice", chooser: "either", options: ["top", "bottom", "neutral", "defer"] },
|
||||
{ key: "period_2", label: "Period 2", type: "period", startsFromChoice: "choice_1", clockSeconds: 120 },
|
||||
{ key: "choice_2", label: "Choice 2", type: "choice", chooser: "other", options: ["top", "bottom", "neutral"] },
|
||||
{ key: "period_3", label: "Period 3", type: "period", startsFromChoice: "choice_2", clockSeconds: 120 },
|
||||
{ key: "sv_1", label: "SV-1", type: "period", startsIn: "neutral", clockSeconds: 60, overtimeType: "SV-1" },
|
||||
{ key: "choice_3", label: "Choice 3", type: "choice", chooser: "either", options: ["top", "bottom", "defer"] },
|
||||
{ key: "tb_1a", label: "TB-1A", type: "period", startsFromChoice: "choice_3", clockSeconds: 30, overtimeType: "TB-1" },
|
||||
{ key: "choice_4", label: "Choice 4", type: "choice", chooser: "other", options: ["top", "bottom"] },
|
||||
{ key: "tb_1b", label: "TB-1B", type: "period", startsFromChoice: "choice_4", clockSeconds: 30, overtimeType: "TB-1" },
|
||||
{ key: "choice_5", label: "Choice 5", type: "choice", chooser: "either", options: ["top", "bottom"] },
|
||||
{ key: "utb", label: "UTB", type: "period", startsFromChoice: "choice_5", clockSeconds: 30, overtimeType: "UTB" }
|
||||
]
|
||||
},
|
||||
consolation: {
|
||||
label: "Consolation Format",
|
||||
sequence: [
|
||||
{ key: "period_1", label: "Period 1", type: "period", startsIn: "neutral", clockSeconds: 60 },
|
||||
{ key: "choice_1", label: "Choice 1", type: "choice", chooser: "either", options: ["top", "bottom", "neutral", "defer"] },
|
||||
{ key: "period_2", label: "Period 2", type: "period", startsFromChoice: "choice_1", clockSeconds: 120 },
|
||||
{ key: "choice_2", label: "Choice 2", type: "choice", chooser: "other", options: ["top", "bottom", "neutral"] },
|
||||
{ key: "period_3", label: "Period 3", type: "period", startsFromChoice: "choice_2", clockSeconds: 120 },
|
||||
{ key: "sv_1", label: "SV-1", type: "period", startsIn: "neutral", clockSeconds: 60, overtimeType: "SV-1" },
|
||||
{ key: "choice_3", label: "Choice 3", type: "choice", chooser: "either", options: ["top", "bottom", "defer"] },
|
||||
{ key: "tb_1a", label: "TB-1A", type: "period", startsFromChoice: "choice_3", clockSeconds: 30, overtimeType: "TB-1" },
|
||||
{ key: "choice_4", label: "Choice 4", type: "choice", chooser: "other", options: ["top", "bottom"] },
|
||||
{ key: "tb_1b", label: "TB-1B", type: "period", startsFromChoice: "choice_4", clockSeconds: 30, overtimeType: "TB-1" },
|
||||
{ key: "choice_5", label: "Choice 5", type: "choice", chooser: "either", options: ["top", "bottom"] },
|
||||
{ key: "utb", label: "UTB", type: "period", startsFromChoice: "choice_5", clockSeconds: 30, overtimeType: "UTB" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function phaseStyleKeyForBracketPosition(bracketPosition) {
|
||||
if (!bracketPosition) return "championship"
|
||||
|
||||
if (
|
||||
bracketPosition.includes("Conso") ||
|
||||
["3/4", "5/6", "7/8"].includes(bracketPosition)
|
||||
) {
|
||||
return "consolation"
|
||||
}
|
||||
|
||||
return "championship"
|
||||
}
|
||||
|
||||
function buildActionEffects(actionsByKey) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(actionsByKey)
|
||||
.filter(([, action]) => action.effect)
|
||||
.map(([key, action]) => [key, action.effect])
|
||||
)
|
||||
}
|
||||
|
||||
function buildActionLabels(actionsByKey, timers) {
|
||||
const actionLabels = Object.fromEntries(
|
||||
Object.entries(actionsByKey)
|
||||
.filter(([, action]) => action.label)
|
||||
.map(([key, action]) => [key, action.label])
|
||||
)
|
||||
|
||||
Object.entries(timers || {}).forEach(([timerKey, timer]) => {
|
||||
if (timer.historyLabel) {
|
||||
actionLabels[`timer_used_${timerKey}`] = timer.historyLabel
|
||||
}
|
||||
})
|
||||
|
||||
return actionLabels
|
||||
}
|
||||
|
||||
function buildProgressionRules(actionsByKey) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(actionsByKey)
|
||||
.filter(([, action]) => Array.isArray(action.progression))
|
||||
.map(([key, action]) => [key, action.progression])
|
||||
)
|
||||
}
|
||||
|
||||
export function getMatchStateConfig(rulesetId, bracketPosition) {
|
||||
const ruleset = RULESETS[rulesetId] || RULESETS.folkstyle_usa
|
||||
const phaseStyleKey = phaseStyleKeyForBracketPosition(bracketPosition)
|
||||
const phaseStyle = ruleset.phases[phaseStyleKey]
|
||||
|
||||
return {
|
||||
...ruleset,
|
||||
actionEffects: buildActionEffects(ruleset.actionsByKey),
|
||||
actionLabels: buildActionLabels(ruleset.actionsByKey, ruleset.timers),
|
||||
progressionRules: buildProgressionRules(ruleset.actionsByKey),
|
||||
matchFormat: { id: phaseStyleKey, label: phaseStyle.label },
|
||||
phaseSequence: phaseStyle.sequence
|
||||
}
|
||||
}
|
||||
567
app/assets/javascripts/lib/match_state/engine.js
Normal file
567
app/assets/javascripts/lib/match_state/engine.js
Normal file
@@ -0,0 +1,567 @@
|
||||
export function buildTimerState(config) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(config.timers).map(([timerKey, timerConfig]) => [
|
||||
timerKey,
|
||||
{
|
||||
remainingSeconds: timerConfig.maxSeconds,
|
||||
running: false,
|
||||
startedAt: null
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
export function buildClockState(config) {
|
||||
return Object.fromEntries(
|
||||
config.phaseSequence
|
||||
.filter((phase) => phase.type === "period")
|
||||
.map((phase) => [
|
||||
phase.key,
|
||||
{
|
||||
durationSeconds: phase.clockSeconds,
|
||||
remainingSeconds: phase.clockSeconds,
|
||||
running: false,
|
||||
startedAt: null
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
export function buildInitialState(config) {
|
||||
const openingPhase = config.phaseSequence[0]
|
||||
|
||||
return {
|
||||
participantScores: {
|
||||
w1: 0,
|
||||
w2: 0
|
||||
},
|
||||
control: "neutral",
|
||||
displayControl: "neutral",
|
||||
phaseIndex: 0,
|
||||
selections: {},
|
||||
assignment: {
|
||||
w1: "green",
|
||||
w2: "red"
|
||||
},
|
||||
nextEventId: 1,
|
||||
nextEventGroupId: 1,
|
||||
events: [],
|
||||
clocksByPhase: buildClockState(config),
|
||||
clock: {
|
||||
durationSeconds: openingPhase.clockSeconds,
|
||||
remainingSeconds: openingPhase.clockSeconds,
|
||||
running: false,
|
||||
startedAt: null
|
||||
},
|
||||
timers: {
|
||||
w1: buildTimerState(config),
|
||||
w2: buildTimerState(config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildEvent(state, phase, clockSeconds, participantKey, actionKey, options = {}) {
|
||||
return {
|
||||
id: state.nextEventId++,
|
||||
phaseKey: phase.key,
|
||||
phaseLabel: phase.label,
|
||||
clockSeconds,
|
||||
participantKey,
|
||||
actionKey,
|
||||
actionGroupId: options.actionGroupId
|
||||
}
|
||||
}
|
||||
|
||||
export function opponentParticipant(participantKey) {
|
||||
return participantKey === "w1" ? "w2" : "w1"
|
||||
}
|
||||
|
||||
export function isProgressiveAction(config, actionKey) {
|
||||
return Object.prototype.hasOwnProperty.call(config.progressionRules || {}, actionKey)
|
||||
}
|
||||
|
||||
export function progressiveActionCountForParticipant(events, participantKey, actionKey) {
|
||||
return events.filter((eventRecord) =>
|
||||
eventRecord.participantKey === participantKey && eventRecord.actionKey === actionKey
|
||||
).length
|
||||
}
|
||||
|
||||
export function progressiveActionPointsForOffense(config, actionKey, offenseNumber) {
|
||||
const progression = config.progressionRules?.[actionKey] || []
|
||||
return progression[Math.min(offenseNumber - 1, progression.length - 1)] || 0
|
||||
}
|
||||
|
||||
export function recordProgressiveAction(config, state, participantKey, actionKey, buildEvent) {
|
||||
const offenseNumber = progressiveActionCountForParticipant(state.events, participantKey, actionKey) + 1
|
||||
const actionGroupId = state.nextEventGroupId++
|
||||
state.events.push(buildEvent(participantKey, actionKey, { actionGroupId }))
|
||||
|
||||
const awardedPoints = progressiveActionPointsForOffense(config, actionKey, offenseNumber)
|
||||
if (awardedPoints > 0) {
|
||||
state.events.push(buildEvent(opponentParticipant(participantKey), `plus_${awardedPoints}`, { actionGroupId }))
|
||||
}
|
||||
}
|
||||
|
||||
export function applyMatchAction(config, state, phase, clockSeconds, participantKey, actionKey) {
|
||||
const effect = config.actionEffects[actionKey]
|
||||
if (!effect) return false
|
||||
|
||||
if (isProgressiveAction(config, actionKey)) {
|
||||
recordProgressiveAction(
|
||||
config,
|
||||
state,
|
||||
participantKey,
|
||||
actionKey,
|
||||
(eventParticipantKey, eventActionKey, options = {}) =>
|
||||
buildEvent(state, phase, clockSeconds, eventParticipantKey, eventActionKey, options)
|
||||
)
|
||||
} else {
|
||||
state.events.push(buildEvent(state, phase, clockSeconds, participantKey, actionKey))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function applyChoiceAction(state, phase, clockSeconds, participantKey, choiceKey) {
|
||||
if (phase.type !== "choice") return { applied: false, deferred: false }
|
||||
|
||||
state.events.push(buildEvent(state, phase, clockSeconds, participantKey, `choice_${choiceKey}`))
|
||||
|
||||
if (choiceKey === "defer") {
|
||||
return { applied: true, deferred: true }
|
||||
}
|
||||
|
||||
state.selections[phase.key] = {
|
||||
participantKey,
|
||||
choiceKey
|
||||
}
|
||||
|
||||
return { applied: true, deferred: false }
|
||||
}
|
||||
|
||||
export function deleteEventFromState(config, state, eventId) {
|
||||
const eventRecord = state.events.find((eventItem) => eventItem.id === eventId)
|
||||
if (!eventRecord) return false
|
||||
|
||||
let eventIdsToDelete = [eventId]
|
||||
|
||||
if (eventRecord.actionKey.startsWith("timer_used_") && typeof eventRecord.elapsedSeconds === "number") {
|
||||
const timerKey = eventRecord.actionKey.replace("timer_used_", "")
|
||||
const timer = state.timers[eventRecord.participantKey]?.[timerKey]
|
||||
const maxSeconds = config.timers[timerKey]?.maxSeconds || 0
|
||||
if (timer) {
|
||||
timer.remainingSeconds = Math.min(maxSeconds, timer.remainingSeconds + eventRecord.elapsedSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
if (isProgressiveAction(config, eventRecord.actionKey)) {
|
||||
const linkedAward = findLinkedProgressiveAward(state.events, eventRecord)
|
||||
if (linkedAward) {
|
||||
eventIdsToDelete.push(linkedAward.id)
|
||||
}
|
||||
}
|
||||
|
||||
state.events = state.events.filter((eventItem) => !eventIdsToDelete.includes(eventItem.id))
|
||||
return true
|
||||
}
|
||||
|
||||
export function swapEventParticipants(config, state, eventId) {
|
||||
const eventRecord = state.events.find((eventItem) => eventItem.id === eventId)
|
||||
if (!eventRecord) return false
|
||||
|
||||
const originalParticipant = eventRecord.participantKey
|
||||
const swappedParticipant = opponentParticipant(originalParticipant)
|
||||
|
||||
if (eventRecord.actionKey.startsWith("timer_used_") && typeof eventRecord.elapsedSeconds === "number") {
|
||||
reassignTimerUsage(config, state, eventRecord, swappedParticipant)
|
||||
}
|
||||
|
||||
eventRecord.participantKey = swappedParticipant
|
||||
|
||||
if (isProgressiveAction(config, eventRecord.actionKey)) {
|
||||
swapLinkedProgressiveAward(state.events, eventRecord, swappedParticipant)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function swapPhaseParticipants(config, state, phaseKey) {
|
||||
const phaseEvents = state.events.filter((eventRecord) => eventRecord.phaseKey === phaseKey)
|
||||
if (phaseEvents.length === 0) return false
|
||||
|
||||
phaseEvents.forEach((eventRecord) => {
|
||||
if (eventRecord.actionKey.startsWith("timer_used_") && typeof eventRecord.elapsedSeconds === "number") {
|
||||
reassignTimerUsage(config, state, eventRecord, opponentParticipant(eventRecord.participantKey))
|
||||
}
|
||||
|
||||
eventRecord.participantKey = opponentParticipant(eventRecord.participantKey)
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function phaseIndexForKey(config, phaseKey) {
|
||||
const phaseIndex = config.phaseSequence.findIndex((phase) => phase.key === phaseKey)
|
||||
return phaseIndex === -1 ? Number.MAX_SAFE_INTEGER : phaseIndex
|
||||
}
|
||||
|
||||
export function activeClockForPhase(state, phase) {
|
||||
if (!phase || phase.type !== "period") return null
|
||||
return state.clocksByPhase[phase.key] || null
|
||||
}
|
||||
|
||||
export function hasRunningClockOrTimer(state) {
|
||||
const anyTimerRunning = ["w1", "w2"].some((participantKey) =>
|
||||
Object.values(state.timers[participantKey] || {}).some((timer) => timer.running)
|
||||
)
|
||||
const anyClockRunning = Object.values(state.clocksByPhase || {}).some((clock) => clock.running)
|
||||
return anyTimerRunning || anyClockRunning
|
||||
}
|
||||
|
||||
export function stopAllAuxiliaryTimers(state, now = Date.now()) {
|
||||
for (const participantKey of ["w1", "w2"]) {
|
||||
for (const timerKey of Object.keys(state.timers[participantKey] || {})) {
|
||||
const timer = state.timers[participantKey][timerKey]
|
||||
if (!timer.running) continue
|
||||
|
||||
const elapsedSeconds = Math.floor((now - timer.startedAt) / 1000)
|
||||
timer.remainingSeconds = Math.max(0, timer.remainingSeconds - elapsedSeconds)
|
||||
timer.running = false
|
||||
timer.startedAt = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function moveToPreviousPhase(config, state) {
|
||||
if (state.phaseIndex === 0) return false
|
||||
state.phaseIndex -= 1
|
||||
state.control = baseControlForPhase(config.phaseSequence[state.phaseIndex], state.selections, state.control)
|
||||
return true
|
||||
}
|
||||
|
||||
export function moveToNextPhase(config, state) {
|
||||
if (state.phaseIndex >= config.phaseSequence.length - 1) return false
|
||||
state.phaseIndex += 1
|
||||
state.control = baseControlForPhase(config.phaseSequence[state.phaseIndex], state.selections, state.control)
|
||||
return true
|
||||
}
|
||||
|
||||
export function orderedEvents(config, events) {
|
||||
return [...events].sort((leftEvent, rightEvent) => {
|
||||
const leftPhaseIndex = phaseIndexForKey(config, leftEvent.phaseKey)
|
||||
const rightPhaseIndex = phaseIndexForKey(config, rightEvent.phaseKey)
|
||||
if (leftPhaseIndex !== rightPhaseIndex) {
|
||||
return leftPhaseIndex - rightPhaseIndex
|
||||
}
|
||||
return leftEvent.id - rightEvent.id
|
||||
})
|
||||
}
|
||||
|
||||
export function controlFromChoice(selection) {
|
||||
if (!selection) return "neutral"
|
||||
if (selection.choiceKey === "neutral" || selection.choiceKey === "defer") return "neutral"
|
||||
if (selection.choiceKey === "top") return `${selection.participantKey}_control`
|
||||
if (selection.choiceKey === "bottom") return `${opponentParticipant(selection.participantKey)}_control`
|
||||
return "neutral"
|
||||
}
|
||||
|
||||
export function baseControlForPhase(phase, selections, fallbackControl) {
|
||||
if (phase.type !== "period") return fallbackControl
|
||||
if (phase.startsIn === "neutral") return "neutral"
|
||||
if (phase.startsFromChoice) {
|
||||
return controlFromChoice(selections[phase.startsFromChoice])
|
||||
}
|
||||
return "neutral"
|
||||
}
|
||||
|
||||
export function recomputeDerivedState(config, state) {
|
||||
state.participantScores = { w1: 0, w2: 0 }
|
||||
state.selections = {}
|
||||
state.control = baseControlForPhase(config.phaseSequence[state.phaseIndex], state.selections, state.control)
|
||||
|
||||
orderedEvents(config, state.events).forEach((eventRecord) => {
|
||||
if (eventRecord.actionKey.startsWith("choice_")) {
|
||||
const choiceKey = eventRecord.actionKey.replace("choice_", "")
|
||||
if (choiceKey === "defer") return
|
||||
|
||||
state.selections[eventRecord.phaseKey] = {
|
||||
participantKey: eventRecord.participantKey,
|
||||
choiceKey: choiceKey
|
||||
}
|
||||
state.control = baseControlForPhase(config.phaseSequence[state.phaseIndex], state.selections, state.control)
|
||||
return
|
||||
}
|
||||
|
||||
const effect = config.actionEffects[eventRecord.actionKey]
|
||||
if (!effect) return
|
||||
|
||||
const scoringParticipant = effect.target === "opponent"
|
||||
? opponentParticipant(eventRecord.participantKey)
|
||||
: eventRecord.participantKey
|
||||
const nextScore = state.participantScores[scoringParticipant] + effect.points
|
||||
state.participantScores[scoringParticipant] = Math.max(0, nextScore)
|
||||
|
||||
if (effect.nextPosition === "neutral") {
|
||||
state.control = "neutral"
|
||||
} else if (effect.nextPosition === "top") {
|
||||
state.control = `${eventRecord.participantKey}_control`
|
||||
} else if (effect.nextPosition === "bottom") {
|
||||
state.control = `${opponentParticipant(eventRecord.participantKey)}_control`
|
||||
}
|
||||
})
|
||||
|
||||
state.displayControl = controlForSelectedPhase(config, state)
|
||||
}
|
||||
|
||||
export function controlForSelectedPhase(config, state) {
|
||||
const selectedPhase = config.phaseSequence[state.phaseIndex]
|
||||
let control = baseControlForPhase(selectedPhase, state.selections, state.control)
|
||||
const selectedPhaseIndex = phaseIndexForKey(config, selectedPhase.key)
|
||||
|
||||
orderedEvents(config, state.events).forEach((eventRecord) => {
|
||||
if (phaseIndexForKey(config, eventRecord.phaseKey) > selectedPhaseIndex) return
|
||||
if (eventRecord.phaseKey !== selectedPhase.key) return
|
||||
|
||||
const effect = config.actionEffects[eventRecord.actionKey]
|
||||
if (!effect) return
|
||||
|
||||
if (effect.nextPosition === "neutral") {
|
||||
control = "neutral"
|
||||
} else if (effect.nextPosition === "top") {
|
||||
control = `${eventRecord.participantKey}_control`
|
||||
} else if (effect.nextPosition === "bottom") {
|
||||
control = `${opponentParticipant(eventRecord.participantKey)}_control`
|
||||
}
|
||||
})
|
||||
|
||||
return control
|
||||
}
|
||||
|
||||
export function currentClockSeconds(clockState, now = Date.now()) {
|
||||
if (!clockState) return 0
|
||||
if (!clockState.running || !clockState.startedAt) {
|
||||
return clockState.remainingSeconds
|
||||
}
|
||||
|
||||
const elapsedSeconds = Math.floor((now - clockState.startedAt) / 1000)
|
||||
return Math.max(0, clockState.remainingSeconds - elapsedSeconds)
|
||||
}
|
||||
|
||||
export function currentAuxiliaryTimerSeconds(timerState, now = Date.now()) {
|
||||
if (!timerState) return 0
|
||||
if (!timerState.running || !timerState.startedAt) {
|
||||
return timerState.remainingSeconds
|
||||
}
|
||||
|
||||
const elapsedSeconds = Math.floor((now - timerState.startedAt) / 1000)
|
||||
return Math.max(0, timerState.remainingSeconds - elapsedSeconds)
|
||||
}
|
||||
|
||||
export function syncClockSnapshot(activeClock) {
|
||||
if (!activeClock) {
|
||||
return {
|
||||
durationSeconds: 0,
|
||||
remainingSeconds: 0,
|
||||
running: false,
|
||||
startedAt: null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
durationSeconds: activeClock.durationSeconds,
|
||||
remainingSeconds: activeClock.remainingSeconds,
|
||||
running: activeClock.running,
|
||||
startedAt: activeClock.startedAt
|
||||
}
|
||||
}
|
||||
|
||||
export function startClockState(activeClock, now = Date.now()) {
|
||||
if (!activeClock || activeClock.running) return false
|
||||
activeClock.running = true
|
||||
activeClock.startedAt = now
|
||||
return true
|
||||
}
|
||||
|
||||
export function stopClockState(activeClock, now = Date.now()) {
|
||||
if (!activeClock || !activeClock.running) return false
|
||||
activeClock.remainingSeconds = currentClockSeconds(activeClock, now)
|
||||
activeClock.running = false
|
||||
activeClock.startedAt = null
|
||||
return true
|
||||
}
|
||||
|
||||
export function adjustClockState(activeClock, deltaSeconds, now = Date.now()) {
|
||||
if (!activeClock) return false
|
||||
|
||||
const currentSeconds = currentClockSeconds(activeClock, now)
|
||||
activeClock.remainingSeconds = Math.max(0, currentSeconds + deltaSeconds)
|
||||
if (activeClock.running) {
|
||||
activeClock.startedAt = now
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function startAuxiliaryTimerState(timerState, now = Date.now()) {
|
||||
if (!timerState || timerState.running) return false
|
||||
timerState.running = true
|
||||
timerState.startedAt = now
|
||||
return true
|
||||
}
|
||||
|
||||
export function stopAuxiliaryTimerState(timerState, now = Date.now()) {
|
||||
if (!timerState || !timerState.running) return { stopped: false, elapsedSeconds: 0 }
|
||||
|
||||
const elapsedSeconds = Math.floor((now - timerState.startedAt) / 1000)
|
||||
timerState.remainingSeconds = currentAuxiliaryTimerSeconds(timerState, now)
|
||||
timerState.running = false
|
||||
timerState.startedAt = null
|
||||
|
||||
return { stopped: true, elapsedSeconds }
|
||||
}
|
||||
|
||||
export function accumulatedMatchSeconds(config, state, activePhaseKey, now = Date.now()) {
|
||||
return config.phaseSequence
|
||||
.filter((phase) => phase.type === "period")
|
||||
.reduce((totalElapsed, phase) => {
|
||||
const clockState = state.clocksByPhase[phase.key]
|
||||
if (!clockState) return totalElapsed
|
||||
|
||||
const remainingSeconds = phase.key === activePhaseKey
|
||||
? currentClockSeconds(clockState, now)
|
||||
: clockState.remainingSeconds
|
||||
|
||||
const elapsedSeconds = Math.max(0, clockState.durationSeconds - remainingSeconds)
|
||||
return totalElapsed + elapsedSeconds
|
||||
}, 0)
|
||||
}
|
||||
|
||||
export function derivedStats(config, events) {
|
||||
const grouped = config.phaseSequence.map((phase) => {
|
||||
const phaseEvents = orderedEvents(config, events).filter((eventRecord) => eventRecord.phaseKey === phase.key)
|
||||
if (phaseEvents.length === 0) return null
|
||||
|
||||
return {
|
||||
label: phase.label,
|
||||
w1: phaseEvents
|
||||
.filter((eventRecord) => eventRecord.participantKey === "w1")
|
||||
.map((eventRecord) => statTextForEvent(config, eventRecord))
|
||||
.filter(Boolean),
|
||||
w2: phaseEvents
|
||||
.filter((eventRecord) => eventRecord.participantKey === "w2")
|
||||
.map((eventRecord) => statTextForEvent(config, eventRecord))
|
||||
.filter(Boolean)
|
||||
}
|
||||
}).filter(Boolean)
|
||||
|
||||
return {
|
||||
w1: formatStatsByPhase(grouped, "w1"),
|
||||
w2: formatStatsByPhase(grouped, "w2")
|
||||
}
|
||||
}
|
||||
|
||||
export function scoreboardStatePayload(config, state, metadata) {
|
||||
return {
|
||||
participantScores: state.participantScores,
|
||||
assignment: state.assignment,
|
||||
phaseIndex: state.phaseIndex,
|
||||
clocksByPhase: state.clocksByPhase,
|
||||
timers: state.timers,
|
||||
metadata: metadata,
|
||||
matchResult: {
|
||||
finished: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function matchResultDefaults(state, options = {}) {
|
||||
const {
|
||||
w1Id = "",
|
||||
w2Id = "",
|
||||
currentPhase = {},
|
||||
accumulationSeconds = 0
|
||||
} = options
|
||||
|
||||
const w1Score = state.participantScores.w1
|
||||
const w2Score = state.participantScores.w2
|
||||
let winnerId = ""
|
||||
let winnerScore = w1Score
|
||||
let loserScore = w2Score
|
||||
|
||||
if (w1Score > w2Score) {
|
||||
winnerId = w1Id || ""
|
||||
winnerScore = w1Score
|
||||
loserScore = w2Score
|
||||
} else if (w2Score > w1Score) {
|
||||
winnerId = w2Id || ""
|
||||
winnerScore = w2Score
|
||||
loserScore = w1Score
|
||||
}
|
||||
|
||||
return {
|
||||
winnerId,
|
||||
overtimeType: currentPhase.overtimeType || "",
|
||||
winnerScore,
|
||||
loserScore,
|
||||
pinMinutes: Math.floor(accumulationSeconds / 60),
|
||||
pinSeconds: accumulationSeconds % 60
|
||||
}
|
||||
}
|
||||
|
||||
function statTextForEvent(config, eventRecord) {
|
||||
if (eventRecord.actionKey.startsWith("timer_used_")) {
|
||||
const timerKey = eventRecord.actionKey.replace("timer_used_", "")
|
||||
const timerConfig = config.timers[timerKey]
|
||||
if (!timerConfig || typeof eventRecord.elapsedSeconds !== "number") return null
|
||||
return `${timerConfig.statCode || timerConfig.label}: ${formatClock(eventRecord.elapsedSeconds)}`
|
||||
}
|
||||
|
||||
const action = config.actionsByKey[eventRecord.actionKey]
|
||||
return action?.statCode || null
|
||||
}
|
||||
|
||||
function formatStatsByPhase(groupedPhases, participantKey) {
|
||||
return groupedPhases
|
||||
.map((phase) => {
|
||||
const items = phase[participantKey]
|
||||
if (!items || items.length === 0) return null
|
||||
return `${phase.label}: ${items.join(" ")}`
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
function formatClock(totalSeconds) {
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
function reassignTimerUsage(config, state, eventRecord, newParticipantKey) {
|
||||
const timerKey = eventRecord.actionKey.replace("timer_used_", "")
|
||||
const originalParticipant = eventRecord.participantKey
|
||||
const originalTimer = state.timers[originalParticipant]?.[timerKey]
|
||||
const newTimer = state.timers[newParticipantKey]?.[timerKey]
|
||||
const maxSeconds = config.timers[timerKey]?.maxSeconds || 0
|
||||
|
||||
if (!originalTimer || !newTimer || typeof eventRecord.elapsedSeconds !== "number") return
|
||||
|
||||
originalTimer.remainingSeconds = Math.min(maxSeconds, originalTimer.remainingSeconds + eventRecord.elapsedSeconds)
|
||||
newTimer.remainingSeconds = Math.max(0, newTimer.remainingSeconds - eventRecord.elapsedSeconds)
|
||||
}
|
||||
|
||||
function swapLinkedProgressiveAward(events, eventRecord, offendingParticipant) {
|
||||
const linkedAward = findLinkedProgressiveAward(events, eventRecord)
|
||||
if (linkedAward) {
|
||||
linkedAward.participantKey = opponentParticipant(offendingParticipant)
|
||||
}
|
||||
}
|
||||
|
||||
function findLinkedProgressiveAward(events, eventRecord) {
|
||||
return events.find((candidateEvent) =>
|
||||
candidateEvent.id !== eventRecord.id &&
|
||||
candidateEvent.actionGroupId &&
|
||||
candidateEvent.actionGroupId === eventRecord.actionGroupId &&
|
||||
candidateEvent.actionKey.startsWith("plus_")
|
||||
)
|
||||
}
|
||||
94
app/assets/javascripts/lib/match_state/presenters.js
Normal file
94
app/assets/javascripts/lib/match_state/presenters.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import { orderedEvents } from "match-state-engine"
|
||||
|
||||
export function displayLabelForParticipant(assignment, participantKey) {
|
||||
return assignment[participantKey] === "green" ? "Green" : "Red"
|
||||
}
|
||||
|
||||
export function buttonClassForParticipant(assignment, participantKey) {
|
||||
return assignment[participantKey] === "green" ? "btn-success" : "btn-danger"
|
||||
}
|
||||
|
||||
export function humanizeChoice(choiceKey) {
|
||||
if (choiceKey === "top") return "Top"
|
||||
if (choiceKey === "bottom") return "Bottom"
|
||||
if (choiceKey === "neutral") return "Neutral"
|
||||
if (choiceKey === "defer") return "Defer"
|
||||
return choiceKey
|
||||
}
|
||||
|
||||
export function choiceLabelForPhase(phase) {
|
||||
if (phase.chooser === "other") return "Other wrestler chooses"
|
||||
return "Choose wrestler and position"
|
||||
}
|
||||
|
||||
export function eventLogSections(config, state, formatClock) {
|
||||
const eventsByPhase = orderedEvents(config, state.events).reduce((accumulator, eventRecord) => {
|
||||
if (!accumulator[eventRecord.phaseKey]) {
|
||||
accumulator[eventRecord.phaseKey] = []
|
||||
}
|
||||
accumulator[eventRecord.phaseKey].push(eventRecord)
|
||||
return accumulator
|
||||
}, {})
|
||||
|
||||
return config.phaseSequence.map((phase) => {
|
||||
const phaseEvents = eventsByPhase[phase.key]
|
||||
if (!phaseEvents || phaseEvents.length === 0) return null
|
||||
|
||||
return {
|
||||
key: phase.key,
|
||||
label: phase.label,
|
||||
items: [...phaseEvents].reverse().map((eventRecord) => ({
|
||||
id: eventRecord.id,
|
||||
participantKey: eventRecord.participantKey,
|
||||
colorLabel: displayLabelForParticipant(state.assignment, eventRecord.participantKey),
|
||||
actionLabel: eventActionLabel(config, eventRecord, formatClock),
|
||||
clockLabel: formatClock(eventRecord.clockSeconds)
|
||||
}))
|
||||
}
|
||||
}).filter(Boolean)
|
||||
}
|
||||
|
||||
export function choiceViewModel(config, state, phase, participantMeta) {
|
||||
if (phase.type !== "choice") return null
|
||||
|
||||
const phaseEvents = state.events.filter((eventRecord) => eventRecord.phaseKey === phase.key)
|
||||
const deferredParticipants = phaseEvents
|
||||
.filter((eventRecord) => eventRecord.actionKey === "choice_defer")
|
||||
.map((eventRecord) => eventRecord.participantKey)
|
||||
const selection = state.selections[phase.key]
|
||||
|
||||
const selectionText = selection
|
||||
? `Selected: ${displayLabelForParticipant(state.assignment, selection.participantKey)} ${humanizeChoice(selection.choiceKey)}`
|
||||
: deferredParticipants.length > 0
|
||||
? `${deferredParticipants.map((participantKey) => displayLabelForParticipant(state.assignment, participantKey)).join(", ")} deferred. Waiting for the other wrestler to choose.`
|
||||
: "No choice selected."
|
||||
|
||||
const availableParticipants = deferredParticipants.length > 0
|
||||
? ["w1", "w2"].filter((participantKey) => !deferredParticipants.includes(participantKey))
|
||||
: ["w1", "w2"]
|
||||
|
||||
const buttons = availableParticipants.flatMap((participantKey) =>
|
||||
phase.options
|
||||
.filter((choiceKey) => !(deferredParticipants.length > 0 && choiceKey === "defer"))
|
||||
.map((choiceKey) => ({
|
||||
participantKey,
|
||||
choiceKey,
|
||||
buttonClass: buttonClassForParticipant(state.assignment, participantKey),
|
||||
text: `${participantMeta[participantKey].name} (${displayLabelForParticipant(state.assignment, participantKey)}) ${humanizeChoice(choiceKey)}`
|
||||
}))
|
||||
)
|
||||
|
||||
return {
|
||||
label: choiceLabelForPhase(phase),
|
||||
selectionText,
|
||||
buttons
|
||||
}
|
||||
}
|
||||
|
||||
function eventActionLabel(config, eventRecord, formatClock) {
|
||||
let actionLabel = config.actionLabels[eventRecord.actionKey] || eventRecord.actionKey
|
||||
if (eventRecord.actionKey.startsWith("timer_used_") && typeof eventRecord.elapsedSeconds === "number") {
|
||||
actionLabel = `${actionLabel}: ${formatClock(eventRecord.elapsedSeconds)}`
|
||||
}
|
||||
return actionLabel
|
||||
}
|
||||
288
app/assets/javascripts/lib/match_state/scoreboard_presenters.js
Normal file
288
app/assets/javascripts/lib/match_state/scoreboard_presenters.js
Normal file
@@ -0,0 +1,288 @@
|
||||
export function participantForColor(state, color) {
|
||||
if (!state?.assignment) {
|
||||
return color === "red" ? "w2" : "w1"
|
||||
}
|
||||
|
||||
const match = Object.entries(state.assignment).find(([, assignedColor]) => assignedColor === color)
|
||||
return match ? match[0] : (color === "red" ? "w2" : "w1")
|
||||
}
|
||||
|
||||
export function participantColor(state, participantKey) {
|
||||
return state?.assignment?.[participantKey] || (participantKey === "w1" ? "green" : "red")
|
||||
}
|
||||
|
||||
export function participantName(state, participantKey) {
|
||||
return participantKey === "w1" ? state?.metadata?.w1Name : state?.metadata?.w2Name
|
||||
}
|
||||
|
||||
export function participantSchool(state, participantKey) {
|
||||
return participantKey === "w1" ? state?.metadata?.w1School : state?.metadata?.w2School
|
||||
}
|
||||
|
||||
export function participantScore(state, participantKey) {
|
||||
return state?.participantScores?.[participantKey] || 0
|
||||
}
|
||||
|
||||
export function currentPhaseLabel(config, state) {
|
||||
const phaseIndex = state?.phaseIndex || 0
|
||||
return config?.phaseSequence?.[phaseIndex]?.label || "Period 1"
|
||||
}
|
||||
|
||||
export function currentClockText(config, state, formatClock, now = Date.now()) {
|
||||
const phaseIndex = state?.phaseIndex || 0
|
||||
const phase = config?.phaseSequence?.[phaseIndex]
|
||||
if (!phase || phase.type !== "period") return "-"
|
||||
|
||||
const clockState = state?.clocksByPhase?.[phase.key]
|
||||
if (!clockState) return formatClock(phase.clockSeconds)
|
||||
|
||||
let remainingSeconds = clockState.remainingSeconds
|
||||
if (clockState.running && clockState.startedAt) {
|
||||
const elapsedSeconds = Math.floor((now - clockState.startedAt) / 1000)
|
||||
remainingSeconds = Math.max(0, clockState.remainingSeconds - elapsedSeconds)
|
||||
}
|
||||
|
||||
return formatClock(remainingSeconds)
|
||||
}
|
||||
|
||||
export function currentAuxiliaryTimerSeconds(state, participantKey, timerKey, now = Date.now()) {
|
||||
const timer = state?.timers?.[participantKey]?.[timerKey]
|
||||
if (!timer) return 0
|
||||
if (!timer.running || !timer.startedAt) {
|
||||
return timer.remainingSeconds
|
||||
}
|
||||
|
||||
const elapsedSeconds = Math.floor((now - timer.startedAt) / 1000)
|
||||
return Math.max(0, timer.remainingSeconds - elapsedSeconds)
|
||||
}
|
||||
|
||||
export function runningTimerForParticipant(state, participantKey) {
|
||||
for (const timerKey of Object.keys(state?.timers?.[participantKey] || {})) {
|
||||
if (state.timers[participantKey][timerKey]?.running) {
|
||||
return timerKey
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function participantDisplayLabel(state, participantKey) {
|
||||
return `${participantForColor(state, "red") === participantKey ? "Red" : "Green"} ${participantName(state, participantKey)}`
|
||||
}
|
||||
|
||||
export function timerIndicatorLabel(config, state, participantKey, formatClock, now = Date.now()) {
|
||||
const runningTimer = runningTimerForParticipant(state, participantKey)
|
||||
if (!runningTimer) return ""
|
||||
|
||||
const timerConfig = config?.timers?.[runningTimer]
|
||||
if (!timerConfig) return ""
|
||||
|
||||
const remainingSeconds = currentAuxiliaryTimerSeconds(state, participantKey, runningTimer, now)
|
||||
const usedSeconds = Math.max(0, timerConfig.maxSeconds - remainingSeconds)
|
||||
return `${timerConfig.label}: ${formatClock(usedSeconds)}`
|
||||
}
|
||||
|
||||
export function buildRunningTimerSnapshot(state) {
|
||||
const snapshot = {}
|
||||
for (const participantKey of ["w1", "w2"]) {
|
||||
for (const timerKey of Object.keys(state?.timers?.[participantKey] || {})) {
|
||||
const timer = state.timers[participantKey][timerKey]
|
||||
snapshot[`${participantKey}:${timerKey}`] = Boolean(timer?.running)
|
||||
}
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
export function detectRecentlyStoppedTimer(state, previousTimerSnapshot) {
|
||||
previousTimerSnapshot ||= {}
|
||||
|
||||
for (const participantKey of ["w1", "w2"]) {
|
||||
for (const timerKey of Object.keys(state?.timers?.[participantKey] || {})) {
|
||||
const snapshotKey = `${participantKey}:${timerKey}`
|
||||
const wasRunning = previousTimerSnapshot[snapshotKey]
|
||||
const isRunning = Boolean(state.timers[participantKey][timerKey]?.running)
|
||||
if (wasRunning && !isRunning) {
|
||||
return { participantKey, timerKey }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function runningAuxiliaryTimer(state) {
|
||||
for (const participantKey of ["w1", "w2"]) {
|
||||
for (const timerKey of Object.keys(state?.timers?.[participantKey] || {})) {
|
||||
const timer = state.timers[participantKey][timerKey]
|
||||
if (timer?.running) {
|
||||
return { participantKey, timerKey }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function mainClockRunning(config, state) {
|
||||
const phaseIndex = state?.phaseIndex || 0
|
||||
const phase = config?.phaseSequence?.[phaseIndex]
|
||||
if (!phase || phase.type !== "period") return false
|
||||
return Boolean(state?.clocksByPhase?.[phase.key]?.running)
|
||||
}
|
||||
|
||||
export function timerBannerViewModel(config, state, timerBannerState, formatClock, now = Date.now()) {
|
||||
if (!timerBannerState) return null
|
||||
|
||||
const { participantKey, timerKey, expiresAt } = timerBannerState
|
||||
if (expiresAt && now > expiresAt) return null
|
||||
|
||||
const timer = state?.timers?.[participantKey]?.[timerKey]
|
||||
const timerConfig = config?.timers?.[timerKey]
|
||||
if (!timer || !timerConfig) return null
|
||||
|
||||
const runningSeconds = currentAuxiliaryTimerSeconds(state, participantKey, timerKey, now)
|
||||
const usedSeconds = Math.max(0, timerConfig.maxSeconds - runningSeconds)
|
||||
const color = participantColor(state, participantKey)
|
||||
const label = `${participantDisplayLabel(state, participantKey)} ${timerConfig.label}`
|
||||
|
||||
return {
|
||||
color,
|
||||
label: timer.running ? `${label} Running` : `${label} Used`,
|
||||
clockText: formatClock(usedSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
export function populatedBoardViewModel(config, state, liveMatchData, currentBoutNumber, formatClock, now = Date.now()) {
|
||||
const redParticipant = participantForColor(state, "red")
|
||||
const greenParticipant = participantForColor(state, "green")
|
||||
|
||||
return {
|
||||
isEmpty: false,
|
||||
redName: participantName(state, redParticipant),
|
||||
redSchool: participantSchool(state, redParticipant),
|
||||
redScore: participantScore(state, redParticipant).toString(),
|
||||
redTimerIndicator: timerIndicatorLabel(config, state, redParticipant, formatClock, now),
|
||||
greenName: participantName(state, greenParticipant),
|
||||
greenSchool: participantSchool(state, greenParticipant),
|
||||
greenScore: participantScore(state, greenParticipant).toString(),
|
||||
greenTimerIndicator: timerIndicatorLabel(config, state, greenParticipant, formatClock, now),
|
||||
clockText: currentClockText(config, state, formatClock, now),
|
||||
phaseLabel: currentPhaseLabel(config, state),
|
||||
weightLabel: state?.metadata?.weightLabel ? `Weight ${state.metadata.weightLabel}` : "Weight -",
|
||||
boutLabel: currentBoutNumber ? `Bout ${currentBoutNumber}` : "No Bout",
|
||||
redStats: redParticipant === "w1" ? (liveMatchData?.w1_stat || "") : (liveMatchData?.w2_stat || ""),
|
||||
greenStats: greenParticipant === "w1" ? (liveMatchData?.w1_stat || "") : (liveMatchData?.w2_stat || "")
|
||||
}
|
||||
}
|
||||
|
||||
export function emptyBoardViewModel(currentBoutNumber, lastMatchResult) {
|
||||
return {
|
||||
isEmpty: true,
|
||||
redName: "NO MATCH",
|
||||
redSchool: "",
|
||||
redScore: "0",
|
||||
redTimerIndicator: "",
|
||||
greenName: "NO MATCH",
|
||||
greenSchool: "",
|
||||
greenScore: "0",
|
||||
greenTimerIndicator: "",
|
||||
clockText: "-",
|
||||
phaseLabel: "No Match",
|
||||
weightLabel: "Weight -",
|
||||
boutLabel: currentBoutNumber ? `Bout ${currentBoutNumber}` : "No Bout",
|
||||
redStats: "",
|
||||
greenStats: "",
|
||||
lastMatchResult: lastMatchResult || "-"
|
||||
}
|
||||
}
|
||||
|
||||
export function nextTimerBannerState(state, previousTimerSnapshot, now = Date.now()) {
|
||||
if (!state?.timers) {
|
||||
return { timerBannerState: null, previousTimerSnapshot: {} }
|
||||
}
|
||||
|
||||
const activeTimer = runningAuxiliaryTimer(state)
|
||||
const nextSnapshot = buildRunningTimerSnapshot(state)
|
||||
|
||||
if (activeTimer) {
|
||||
return {
|
||||
timerBannerState: {
|
||||
participantKey: activeTimer.participantKey,
|
||||
timerKey: activeTimer.timerKey,
|
||||
expiresAt: null
|
||||
},
|
||||
previousTimerSnapshot: nextSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
const stoppedTimer = detectRecentlyStoppedTimer(state, previousTimerSnapshot)
|
||||
if (stoppedTimer) {
|
||||
return {
|
||||
timerBannerState: {
|
||||
participantKey: stoppedTimer.participantKey,
|
||||
timerKey: stoppedTimer.timerKey,
|
||||
expiresAt: now + 10000
|
||||
},
|
||||
previousTimerSnapshot: nextSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
timerBannerState: null,
|
||||
previousTimerSnapshot: nextSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
export function boardColors(isEmpty) {
|
||||
if (isEmpty) {
|
||||
return {
|
||||
red: "#000",
|
||||
center: "#000",
|
||||
green: "#000"
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
red: "#c91f1f",
|
||||
center: "#050505",
|
||||
green: "#1cab2d"
|
||||
}
|
||||
}
|
||||
|
||||
export function timerBannerRenderState(config, state, timerBannerState, formatClock, now = Date.now()) {
|
||||
if (mainClockRunning(config, state)) {
|
||||
return {
|
||||
timerBannerState: timerBannerState?.expiresAt ? null : timerBannerState,
|
||||
visible: false,
|
||||
viewModel: null
|
||||
}
|
||||
}
|
||||
|
||||
if (!timerBannerState) {
|
||||
return {
|
||||
timerBannerState: null,
|
||||
visible: false,
|
||||
viewModel: null
|
||||
}
|
||||
}
|
||||
|
||||
if (timerBannerState.expiresAt && now > timerBannerState.expiresAt) {
|
||||
return {
|
||||
timerBannerState: null,
|
||||
visible: false,
|
||||
viewModel: null
|
||||
}
|
||||
}
|
||||
|
||||
const viewModel = timerBannerViewModel(config, state, timerBannerState, formatClock, now)
|
||||
if (!viewModel) {
|
||||
return {
|
||||
timerBannerState,
|
||||
visible: false,
|
||||
viewModel: null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
timerBannerState,
|
||||
visible: true,
|
||||
viewModel
|
||||
}
|
||||
}
|
||||
158
app/assets/javascripts/lib/match_state/scoreboard_state.js
Normal file
158
app/assets/javascripts/lib/match_state/scoreboard_state.js
Normal file
@@ -0,0 +1,158 @@
|
||||
import { buildStorageKey } from "match-state-serializers"
|
||||
|
||||
export function buildScoreboardContext({ initialBoutNumber, matchId }) {
|
||||
const currentQueueBoutNumber = initialBoutNumber > 0 ? initialBoutNumber : null
|
||||
|
||||
return {
|
||||
currentQueueBoutNumber,
|
||||
currentBoutNumber: currentQueueBoutNumber,
|
||||
currentMatchId: matchId || null,
|
||||
liveMatchData: {},
|
||||
lastMatchResult: "",
|
||||
state: null,
|
||||
finished: false,
|
||||
timerBannerState: null,
|
||||
previousTimerSnapshot: {}
|
||||
}
|
||||
}
|
||||
|
||||
export function selectedBoutStorageKey(tournamentId, matId) {
|
||||
return `mat-selected-bout:${tournamentId}:${matId}`
|
||||
}
|
||||
|
||||
export function matchStorageKey(tournamentId, boutNumber) {
|
||||
if (!boutNumber) return null
|
||||
return buildStorageKey(tournamentId, boutNumber)
|
||||
}
|
||||
|
||||
export function extractLiveMatchData(data) {
|
||||
const extracted = {}
|
||||
if (data.w1_stat !== undefined) extracted.w1_stat = data.w1_stat
|
||||
if (data.w2_stat !== undefined) extracted.w2_stat = data.w2_stat
|
||||
if (data.score !== undefined) extracted.score = data.score
|
||||
if (data.win_type !== undefined) extracted.win_type = data.win_type
|
||||
if (data.winner_name !== undefined) extracted.winner_name = data.winner_name
|
||||
if (data.finished !== undefined) extracted.finished = data.finished
|
||||
return extracted
|
||||
}
|
||||
|
||||
export function applyStatePayloadContext(currentContext, payload) {
|
||||
return {
|
||||
...currentContext,
|
||||
state: payload,
|
||||
finished: Boolean(payload?.matchResult?.finished),
|
||||
currentBoutNumber: payload?.metadata?.boutNumber || currentContext.currentBoutNumber
|
||||
}
|
||||
}
|
||||
|
||||
export function applyMatchPayloadContext(currentContext, data) {
|
||||
const nextContext = { ...currentContext }
|
||||
|
||||
if (data.scoreboard_state) {
|
||||
Object.assign(nextContext, applyStatePayloadContext(nextContext, data.scoreboard_state))
|
||||
}
|
||||
|
||||
nextContext.liveMatchData = {
|
||||
...currentContext.liveMatchData,
|
||||
...extractLiveMatchData(data)
|
||||
}
|
||||
|
||||
if (data.finished !== undefined) {
|
||||
nextContext.finished = Boolean(data.finished)
|
||||
}
|
||||
|
||||
return nextContext
|
||||
}
|
||||
|
||||
export function applyMatPayloadContext(currentContext, data) {
|
||||
const currentQueueBoutNumber = data.queue1_bout_number || null
|
||||
const lastMatchResult = data.last_match_result || ""
|
||||
|
||||
if (currentContext.sourceMode === "localstorage") {
|
||||
return {
|
||||
...currentContext,
|
||||
currentQueueBoutNumber: data.selected_bout_number || currentQueueBoutNumber,
|
||||
lastMatchResult,
|
||||
loadSelectedBout: true,
|
||||
loadLocalState: true,
|
||||
unsubscribeMatch: false,
|
||||
subscribeMatchId: null,
|
||||
renderNow: true
|
||||
}
|
||||
}
|
||||
|
||||
const nextMatchId = data.selected_match_id || data.queue1_match_id || null
|
||||
const nextBoutNumber = data.selected_bout_number || data.queue1_bout_number || null
|
||||
const matchChanged = nextMatchId !== currentContext.currentMatchId
|
||||
|
||||
if (!nextMatchId) {
|
||||
return {
|
||||
...currentContext,
|
||||
currentQueueBoutNumber,
|
||||
lastMatchResult,
|
||||
currentMatchId: null,
|
||||
currentBoutNumber: nextBoutNumber,
|
||||
state: null,
|
||||
liveMatchData: {},
|
||||
resetTimerBanner: true,
|
||||
unsubscribeMatch: true,
|
||||
subscribeMatchId: null,
|
||||
renderNow: true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...currentContext,
|
||||
currentQueueBoutNumber,
|
||||
lastMatchResult,
|
||||
currentMatchId: nextMatchId,
|
||||
currentBoutNumber: nextBoutNumber,
|
||||
state: matchChanged ? null : currentContext.state,
|
||||
liveMatchData: matchChanged ? {} : currentContext.liveMatchData,
|
||||
resetTimerBanner: matchChanged,
|
||||
unsubscribeMatch: false,
|
||||
subscribeMatchId: matchChanged ? nextMatchId : null,
|
||||
renderNow: matchChanged
|
||||
}
|
||||
}
|
||||
|
||||
export function connectionPlan(sourceMode, currentMatchId) {
|
||||
return {
|
||||
useStorageListener: sourceMode === "localstorage",
|
||||
subscribeMat: sourceMode === "localstorage" || sourceMode === "mat_websocket",
|
||||
subscribeMatch: sourceMode === "mat_websocket" || sourceMode === "websocket",
|
||||
matchId: sourceMode === "mat_websocket" || sourceMode === "websocket" ? currentMatchId : null,
|
||||
loadSelectedBout: sourceMode === "localstorage",
|
||||
loadLocalState: sourceMode === "localstorage"
|
||||
}
|
||||
}
|
||||
|
||||
export function storageChangePlan(currentContext, eventKey, tournamentId, matId) {
|
||||
const selectedKey = selectedBoutStorageKey(tournamentId, matId)
|
||||
if (eventKey === selectedKey) {
|
||||
return {
|
||||
loadSelectedBout: true,
|
||||
loadLocalState: true,
|
||||
renderNow: true
|
||||
}
|
||||
}
|
||||
|
||||
const storageKey = matchStorageKey(tournamentId, currentContext.currentBoutNumber)
|
||||
if (!storageKey || eventKey !== storageKey) {
|
||||
return {
|
||||
loadSelectedBout: false,
|
||||
loadLocalState: false,
|
||||
renderNow: false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loadSelectedBout: false,
|
||||
loadLocalState: true,
|
||||
renderNow: true
|
||||
}
|
||||
}
|
||||
|
||||
export function selectedBoutNumber(selection, currentQueueBoutNumber) {
|
||||
return selection?.boutNumber || currentQueueBoutNumber
|
||||
}
|
||||
66
app/assets/javascripts/lib/match_state/serializers.js
Normal file
66
app/assets/javascripts/lib/match_state/serializers.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import { buildInitialState } from "match-state-engine"
|
||||
|
||||
export function buildMatchMetadata(values) {
|
||||
return {
|
||||
tournamentId: values.tournamentId,
|
||||
boutNumber: values.boutNumber,
|
||||
weightLabel: values.weightLabel,
|
||||
ruleset: values.ruleset,
|
||||
bracketPosition: values.bracketPosition,
|
||||
w1Name: values.w1Name,
|
||||
w2Name: values.w2Name,
|
||||
w1School: values.w1School,
|
||||
w2School: values.w2School
|
||||
}
|
||||
}
|
||||
|
||||
export function buildStorageKey(tournamentId, boutNumber) {
|
||||
return `match-state:${tournamentId}:${boutNumber}`
|
||||
}
|
||||
|
||||
export function buildPersistedState(state, metadata) {
|
||||
return {
|
||||
...state,
|
||||
metadata
|
||||
}
|
||||
}
|
||||
|
||||
export function restorePersistedState(config, parsedState) {
|
||||
const initialState = buildInitialState(config)
|
||||
|
||||
return {
|
||||
...initialState,
|
||||
...parsedState,
|
||||
participantScores: {
|
||||
...initialState.participantScores,
|
||||
...(parsedState.participantScores || {})
|
||||
},
|
||||
assignment: {
|
||||
...initialState.assignment,
|
||||
...(parsedState.assignment || {})
|
||||
},
|
||||
clock: {
|
||||
...initialState.clock,
|
||||
...(parsedState.clock || {})
|
||||
},
|
||||
timers: {
|
||||
w1: {
|
||||
...initialState.timers.w1,
|
||||
...(parsedState.timers?.w1 || {})
|
||||
},
|
||||
w2: {
|
||||
...initialState.timers.w2,
|
||||
...(parsedState.timers?.w2 || {})
|
||||
}
|
||||
},
|
||||
clocksByPhase: Object.fromEntries(
|
||||
Object.entries(initialState.clocksByPhase).map(([phaseKey, defaultClock]) => [
|
||||
phaseKey,
|
||||
{
|
||||
...defaultClock,
|
||||
...(parsedState.clocksByPhase?.[phaseKey] || {})
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
}
|
||||
116
app/assets/javascripts/lib/match_state/transport.js
Normal file
116
app/assets/javascripts/lib/match_state/transport.js
Normal 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
|
||||
}
|
||||
@@ -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/
|
||||
|
||||
@@ -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/
|
||||
@@ -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/
|
||||
@@ -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/
|
||||
@@ -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/
|
||||
@@ -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/
|
||||
@@ -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/
|
||||
@@ -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/
|
||||
@@ -1,4 +0,0 @@
|
||||
/*
|
||||
Place all the styles related to the matching controller here.
|
||||
They will automatically be included in application.css.
|
||||
*/
|
||||
@@ -1,22 +1,17 @@
|
||||
/*
|
||||
* This is a manifest file that'll be compiled into application.css, which will include all the files
|
||||
* listed below.
|
||||
*
|
||||
* Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
|
||||
* or vendor/assets/stylesheets of plugins, if any, can be referenced here using a relative path.
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
/* relative pathing from /vender/assets/stylesheets = / */
|
||||
@import url("/bootstrap.min.css");
|
||||
@import url("/bootstrap-theme.min.css");
|
||||
@import url("/fontawesome/all.css");
|
||||
@import url("/custom.css");
|
||||
@import url("/scaffolds.css");
|
||||
|
||||
@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");
|
||||
}
|
||||
|
||||
@@ -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/
|
||||
@@ -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/
|
||||
@@ -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/
|
||||
@@ -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/
|
||||
@@ -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/
|
||||
@@ -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/
|
||||
@@ -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/
|
||||
4
app/channels/application_cable/channel.rb
Normal file
4
app/channels/application_cable/channel.rb
Normal file
@@ -0,0 +1,4 @@
|
||||
module ApplicationCable
|
||||
class Channel < ActionCable::Channel::Base
|
||||
end
|
||||
end
|
||||
4
app/channels/application_cable/connection.rb
Normal file
4
app/channels/application_cable/connection.rb
Normal file
@@ -0,0 +1,4 @@
|
||||
module ApplicationCable
|
||||
class Connection < ActionCable::Connection::Base
|
||||
end
|
||||
end
|
||||
15
app/channels/mat_scoreboard_channel.rb
Normal file
15
app/channels/mat_scoreboard_channel.rb
Normal 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
|
||||
110
app/channels/match_channel.rb
Normal file
110
app/channels/match_channel.rb
Normal 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
|
||||
@@ -14,9 +14,9 @@ class ApiController < ApplicationController
|
||||
end
|
||||
|
||||
def tournament
|
||||
@tournament = Tournament.where(:id => params[:tournament]).includes(:schools,:weights,:mats,:matches,:user,:wrestlers).first
|
||||
@schools = @tournament.schools.includes(:wrestlers)
|
||||
@weights = @tournament.weights.includes(:wrestlers)
|
||||
@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: [:weight, :matches_as_w1, :matches_as_w2])
|
||||
@weights = @tournament.weights.includes(wrestlers: [:school, :matches_as_w1, :matches_as_w2])
|
||||
@matches = @tournament.matches.includes(:wrestlers,:schools)
|
||||
@mats = @tournament.mats.includes(:matches)
|
||||
end
|
||||
|
||||
@@ -4,7 +4,7 @@ class MatAssignmentRulesController < ApplicationController
|
||||
before_action :set_mat_assignment_rule, only: [:edit, :update, :destroy]
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class MatchesController < ApplicationController
|
||||
before_action :set_match, only: [:show, :edit, :update, :stat]
|
||||
before_action :check_access, only: [: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, :state, :edit_assignment, :update_assignment]
|
||||
before_action :check_read_access, only: [:spectate]
|
||||
|
||||
# GET /matches/1
|
||||
# GET /matches/1.json
|
||||
@@ -21,61 +22,103 @@ class MatchesController < ApplicationController
|
||||
session[:return_path] = "/tournaments/#{@match.tournament.id}/matches"
|
||||
end
|
||||
|
||||
def stat
|
||||
# @show_next_bout_button = false
|
||||
if params[:match]
|
||||
@match = Match.where(:id => params[:match]).includes(:wrestlers).first
|
||||
end
|
||||
@wrestlers = []
|
||||
if @match
|
||||
if @match.w1
|
||||
@wrestler1_name = @match.wrestler1.name
|
||||
@wrestler1_school_name = @match.wrestler1.school.name
|
||||
@wrestler1_last_match = @match.wrestler1.last_match
|
||||
@wrestlers.push(@match.wrestler1)
|
||||
else
|
||||
@wrestler1_name = "Not assigned"
|
||||
@wrestler1_school_name = "N/A"
|
||||
@wrestler1_last_match = nil
|
||||
end
|
||||
if @match.w2
|
||||
@wrestler2_name = @match.wrestler2.name
|
||||
@wrestler2_school_name = @match.wrestler2.school.name
|
||||
@wrestler2_last_match = @match.wrestler2.last_match
|
||||
@wrestlers.push(@match.wrestler2)
|
||||
else
|
||||
@wrestler2_name = "Not assigned"
|
||||
@wrestler2_school_name = "N/A"
|
||||
@wrestler2_last_match = nil
|
||||
end
|
||||
@tournament = @match.tournament
|
||||
end
|
||||
session[:return_path] = "/tournaments/#{@tournament.id}/matches"
|
||||
session[:error_return_path] = "/matches/#{@match.id}/stat"
|
||||
def stat
|
||||
load_match_stat_context
|
||||
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.json
|
||||
def update
|
||||
respond_to do |format|
|
||||
if @match.update(match_params)
|
||||
if session[:return_path]
|
||||
sanitized_return_path = sanitize_return_path(session[:return_path])
|
||||
format.html { redirect_to sanitized_return_path, notice: 'Match was successfully updated.' }
|
||||
session.delete(:return_path) # Remove the session variable
|
||||
else
|
||||
format.html { redirect_to "/tournaments/#{@match.tournament.id}", notice: 'Match was successfully updated.' }
|
||||
end
|
||||
# Broadcast the update
|
||||
MatchChannel.broadcast_to(
|
||||
@match,
|
||||
{
|
||||
w1_stat: @match.w1_stat,
|
||||
w2_stat: @match.w2_stat,
|
||||
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 }
|
||||
else
|
||||
if session[:error_return_path]
|
||||
format.html { redirect_to session.delete(:error_return_path), alert: "Match did not save because: #{@match.errors.full_messages.to_s}" }
|
||||
format.json { render json: @match.errors, status: :unprocessable_entity }
|
||||
else
|
||||
format.html { redirect_to "/tournaments/#{@match.tournament.id}", alert: "Match did not save because: #{@match.errors.full_messages.to_s}" }
|
||||
format.json { render json: @match.errors, status: :unprocessable_entity }
|
||||
end
|
||||
error_path = resolve_match_redirect_path(session[:error_return_path]) || "/tournaments/#{@match.tournament.id}"
|
||||
format.html { redirect_to error_path, alert: "Match did not save because: #{@match.errors.full_messages.to_s}" }
|
||||
format.json { render json: @match.errors, status: :unprocessable_entity }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -96,11 +139,66 @@ class MatchesController < ApplicationController
|
||||
authorize! :manage, @match.tournament
|
||||
end
|
||||
|
||||
def sanitize_return_path(path)
|
||||
def check_read_access
|
||||
authorize! :read, @match.tournament
|
||||
end
|
||||
|
||||
def sanitize_redirect_path(path)
|
||||
return nil if path.blank?
|
||||
|
||||
uri = URI.parse(path)
|
||||
params = Rack::Utils.parse_nested_query(uri.query)
|
||||
params.delete("bout_number") # Remove the bout_number param
|
||||
uri.query = params.to_query.presence # Rebuild the query string or set it to nil if empty
|
||||
uri.to_s # Return the full path as a string
|
||||
end
|
||||
return nil if uri.scheme.present? || uri.host.present?
|
||||
|
||||
uri.to_s
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
|
||||
def resolve_match_redirect_path(fallback_path)
|
||||
sanitize_redirect_path(params[:redirect_to].presence) || sanitize_redirect_path(fallback_path)
|
||||
end
|
||||
|
||||
def load_match_stat_context
|
||||
if params[:match]
|
||||
@match = Match.where(:id => params[:match]).includes(:wrestlers).first
|
||||
end
|
||||
|
||||
@wrestlers = []
|
||||
if @match
|
||||
if @match.w1
|
||||
@wrestler1_name = @match.wrestler1.name
|
||||
@wrestler1_school_name = @match.wrestler1.school.name
|
||||
@wrestler1_last_match = @match.wrestler1.last_match
|
||||
@wrestlers.push(@match.wrestler1)
|
||||
else
|
||||
@wrestler1_name = "Not assigned"
|
||||
@wrestler1_school_name = "N/A"
|
||||
@wrestler1_last_match = nil
|
||||
end
|
||||
|
||||
if @match.w2
|
||||
@wrestler2_name = @match.wrestler2.name
|
||||
@wrestler2_school_name = @match.wrestler2.school.name
|
||||
@wrestler2_last_match = @match.wrestler2.last_match
|
||||
@wrestlers.push(@match.wrestler2)
|
||||
else
|
||||
@wrestler2_name = "Not assigned"
|
||||
@wrestler2_school_name = "N/A"
|
||||
@wrestler2_last_match = nil
|
||||
end
|
||||
|
||||
@tournament = @match.tournament
|
||||
end
|
||||
|
||||
if @match&.mat
|
||||
@mat = @match.mat
|
||||
queue_position = @mat.queue_position_for_match(@match)
|
||||
@next_match = queue_position == 1 ? @mat.queue2_match : nil
|
||||
@show_next_bout_button = queue_position == 1
|
||||
end
|
||||
|
||||
@match_results_redirect_path = sanitize_redirect_path(params[:redirect_to].presence) || "/tournaments/#{@tournament.id}/matches"
|
||||
session[:return_path] = @match_results_redirect_path
|
||||
session[:error_return_path] = request.original_fullpath
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
class MatsController < ApplicationController
|
||||
before_action :set_mat, only: [:show, :edit, :update, :destroy, :assign_next_match]
|
||||
before_action :check_access, only: [:new,:create,:update,:destroy,:edit,:show, :assign_next_match]
|
||||
before_action :check_for_matches, only: [:show]
|
||||
before_action :set_mat, only: [:show, :state, :scoreboard, :edit, :update, :destroy, :assign_next_match, :select_match]
|
||||
before_action :check_access, only: [:new,:create,:update,:destroy,:edit,:show, :state, :scoreboard, :assign_next_match, :select_match]
|
||||
|
||||
# GET /mats/1
|
||||
# GET /mats/1.json
|
||||
def show
|
||||
bout_number_param = params[:bout_number] # Read the bout_number from the URL params
|
||||
|
||||
if bout_number_param
|
||||
@show_next_bout_button = false
|
||||
@match = @mat.unfinished_matches.find { |m| m.bout_number == bout_number_param.to_i }
|
||||
bout_number_param = params[:bout_number]
|
||||
@queue_matches = @mat.queue_matches
|
||||
@match = if bout_number_param
|
||||
@queue_matches.compact.find { |m| m.bout_number == bout_number_param.to_i }
|
||||
else
|
||||
@show_next_bout_button = true
|
||||
@match = @mat.unfinished_matches.first
|
||||
@queue_matches[0]
|
||||
end
|
||||
|
||||
@next_match = @mat.unfinished_matches.second # Second unfinished match on the mat
|
||||
# If a requested bout is no longer queued, fall back to queue1.
|
||||
@match ||= @queue_matches[0]
|
||||
@next_match = @queue_matches[1]
|
||||
@show_next_bout_button = false
|
||||
|
||||
@wrestlers = []
|
||||
if @match
|
||||
@@ -45,10 +44,33 @@ class MatsController < ApplicationController
|
||||
@tournament = @match.tournament
|
||||
end
|
||||
|
||||
session[:return_path] = request.original_fullpath
|
||||
@match_results_redirect_path = sanitize_mat_redirect_path(params[:redirect_to].presence || request.original_fullpath)
|
||||
session[:return_path] = @match_results_redirect_path
|
||||
session[:error_return_path] = request.original_fullpath
|
||||
end
|
||||
|
||||
def scoreboard
|
||||
@match = @mat.selected_scoreboard_match || @mat.queue1_match
|
||||
@tournament = @mat.tournament
|
||||
end
|
||||
|
||||
def state
|
||||
load_mat_match_context
|
||||
@match_state_ruleset = "folkstyle_usa"
|
||||
end
|
||||
|
||||
def select_match
|
||||
selected_match = @mat.queue_matches.compact.find do |match|
|
||||
match.id == params[:match_id].to_i || match.bout_number == params[:bout_number].to_i
|
||||
end
|
||||
|
||||
return head :unprocessable_entity unless selected_match || params[:last_match_result].present?
|
||||
|
||||
@mat.set_selected_scoreboard_match!(selected_match) if selected_match
|
||||
@mat.set_last_match_result!(params[:last_match_result]) if params.key?(:last_match_result)
|
||||
head :no_content
|
||||
end
|
||||
|
||||
# GET /mats/new
|
||||
def new
|
||||
@mat = Mat.new
|
||||
@@ -82,8 +104,8 @@ class MatsController < ApplicationController
|
||||
def assign_next_match
|
||||
@tournament = @mat.tournament_id
|
||||
respond_to do |format|
|
||||
if @mat.assign_next_match
|
||||
format.html { redirect_to "/tournaments/#{@mat.tournament.id}", notice: "Next Match on Mat #{@mat.name} successfully completed." }
|
||||
if @mat.advance_queue!
|
||||
format.html { redirect_to "/tournaments/#{@mat.tournament.id}", notice: "Mat #{@mat.name} queue advanced." }
|
||||
format.json { head :no_content }
|
||||
else
|
||||
format.html { redirect_to "/tournaments/#{@mat.tournament.id}", alert: "There was an error." }
|
||||
@@ -140,13 +162,66 @@ class MatsController < ApplicationController
|
||||
end
|
||||
authorize! :manage, @tournament
|
||||
end
|
||||
|
||||
|
||||
def check_for_matches
|
||||
if @mat
|
||||
if @mat.tournament.matches.empty?
|
||||
redirect_to "/tournaments/#{@tournament.id}/no_matches"
|
||||
end
|
||||
|
||||
def sanitize_mat_redirect_path(path)
|
||||
return nil if path.blank?
|
||||
|
||||
uri = URI.parse(path)
|
||||
return nil if uri.scheme.present? || uri.host.present?
|
||||
|
||||
params = Rack::Utils.parse_nested_query(uri.query)
|
||||
params.delete("bout_number")
|
||||
uri.query = params.to_query.presence
|
||||
uri.to_s
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
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
|
||||
|
||||
@@ -12,7 +12,7 @@ class SchoolsController < ApplicationController
|
||||
# GET /schools/1.json
|
||||
def show
|
||||
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
|
||||
end
|
||||
|
||||
@@ -84,7 +84,7 @@ class SchoolsController < ApplicationController
|
||||
private
|
||||
# Use callbacks to share common setup or constraints between actions.
|
||||
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
|
||||
|
||||
# Never trust parameters from the scary internet, only allow the white list through.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
class StaticPagesController < ApplicationController
|
||||
|
||||
def my_tournaments
|
||||
tournaments_created = current_user.tournaments
|
||||
tournaments_delegated = current_user.delegated_tournaments
|
||||
tournaments_created = current_user.tournaments.to_a
|
||||
tournaments_delegated = current_user.delegated_tournaments.to_a
|
||||
all_tournaments = tournaments_created + tournaments_delegated
|
||||
@tournaments = all_tournaments.sort_by{|t| t.days_until_start}
|
||||
@schools = current_user.delegated_schools
|
||||
@schools = current_user.delegated_schools.includes(:tournament)
|
||||
end
|
||||
|
||||
def not_allowed
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
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 :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 :set_tournament, only: [:all_results, :delete_school_keys, :generate_school_keys,:reset_bout_board,:calculate_team_scores,:bout_sheets,:swap,:weigh_in_sheet,:error,:teampointadjust,:remove_teampointadjust,:remove_school_delegate,:remove_delegate,:school_delegate,:delegate,:matches,:weigh_in,:weigh_in_weight,:create_custom_weights,:show,:edit,:update,:destroy,:up_matches,:no_matches,:team_scores,:generate_matches,:bracket,:all_brackets,:qrcode,:live_scores]
|
||||
before_action :check_access_manage, only: [:delete_school_keys, :generate_school_keys,:reset_bout_board,:calculate_team_scores,:swap,:weigh_in_sheet,:teampointadjust,:remove_teampointadjust,:remove_school_delegate,:school_delegate,:weigh_in,:weigh_in_weight,:create_custom_weights,:update,:edit,:generate_matches,:matches,:qrcode]
|
||||
before_action :check_access_destroy, only: [:destroy,:delegate,:remove_delegate]
|
||||
before_action :check_tournament_errors, only: [:generate_matches]
|
||||
before_action :check_for_matches, only: [:all_results,:up_matches,:bracket,:all_brackets]
|
||||
before_action :check_access_read, 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,:live_scores]
|
||||
|
||||
def weigh_in_sheet
|
||||
|
||||
@schools = @tournament.schools.includes(wrestlers: :weight)
|
||||
end
|
||||
|
||||
def calculate_team_scores
|
||||
@@ -92,12 +92,9 @@ class TournamentsController < ApplicationController
|
||||
end
|
||||
end
|
||||
end
|
||||
@users_delegates = []
|
||||
@tournament.schools.each do |s|
|
||||
s.delegates.each do |d|
|
||||
@users_delegates << d
|
||||
end
|
||||
end
|
||||
@users_delegates = SchoolDelegate.includes(:user, :school)
|
||||
.joins(:school)
|
||||
.where(schools: { tournament_id: @tournament.id })
|
||||
end
|
||||
|
||||
def delegate
|
||||
@@ -115,11 +112,63 @@ class TournamentsController < ApplicationController
|
||||
end
|
||||
end
|
||||
end
|
||||
@users_delegates = @tournament.delegates
|
||||
@users_delegates = @tournament.delegates.includes(:user)
|
||||
end
|
||||
|
||||
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
|
||||
@w1 = @match.wrestler1
|
||||
@w2 = @match.wrestler2
|
||||
@@ -129,10 +178,18 @@ class TournamentsController < ApplicationController
|
||||
|
||||
def weigh_in_weight
|
||||
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
|
||||
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_name = @tournament.name
|
||||
@weights = @tournament.weights
|
||||
@@ -159,20 +216,24 @@ class TournamentsController < ApplicationController
|
||||
def all_brackets
|
||||
@schools = @tournament.schools
|
||||
@schools = @schools.sort_by{|s| s.page_score_string}.reverse!
|
||||
@matches = @tournament.matches.includes(:wrestlers,:schools)
|
||||
@weights = @tournament.weights.includes(:matches,:wrestlers)
|
||||
@weights = @tournament.weights.includes(:matches, wrestlers: :school)
|
||||
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
|
||||
|
||||
def bracket
|
||||
if params[:weight]
|
||||
@weight = Weight.where(:id => params[:weight]).includes(:matches,:wrestlers).first
|
||||
@matches = @weight.matches.includes(:schools,:wrestlers)
|
||||
@wrestlers = @weight.wrestlers.includes(:school)
|
||||
if @tournament.tournament_type == "Pool to bracket"
|
||||
@pools = @weight.pool_rounds(@matches)
|
||||
@bracketType = @weight.pool_bracket_type
|
||||
end
|
||||
if params[:weight]
|
||||
@weight = Weight.includes(:matches, wrestlers: [:school, :matches_as_w1, :matches_as_w2]).find_by(id: params[:weight])
|
||||
@matches = @weight.matches
|
||||
@wrestlers = @weight.wrestlers
|
||||
|
||||
if @tournament.tournament_type == "Pool to bracket"
|
||||
@pools = @weight.pool_rounds(@matches)
|
||||
@bracketType = @weight.pool_bracket_type
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def all_results
|
||||
@@ -181,6 +242,10 @@ class TournamentsController < ApplicationController
|
||||
@bracket_position = nil
|
||||
end
|
||||
|
||||
def live_scores
|
||||
@mats = @tournament.mats.sort_by(&:name)
|
||||
end
|
||||
|
||||
def generate_matches
|
||||
GenerateTournamentMatches.new(@tournament).generate
|
||||
end
|
||||
@@ -195,37 +260,61 @@ class TournamentsController < ApplicationController
|
||||
|
||||
end
|
||||
|
||||
def qrcode
|
||||
@tournament_url = tournament_url(@tournament)
|
||||
@qrcode = RQRCode::QRCode.new(@tournament_url)
|
||||
end
|
||||
|
||||
|
||||
def up_matches
|
||||
# .where.not(loser1_name: 'BYE') won't return matches with NULL loser1_name
|
||||
# so I was only getting back matches with Loser of BOUT_NUMBER
|
||||
@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)
|
||||
@matches = @tournament.up_matches_unassigned_matches
|
||||
@mats = @tournament.up_matches_mats
|
||||
end
|
||||
|
||||
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]
|
||||
round = params[:round]
|
||||
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
|
||||
@matches = @tournament.matches.sort_by{|match| match.bout_number}
|
||||
@matches = matches_scope
|
||||
.includes(:weight)
|
||||
.order(:bout_number)
|
||||
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
|
||||
|
||||
def index
|
||||
if params[:search]
|
||||
# @tournaments = Tournament.limit(200).search(params[:search]).order("date DESC")
|
||||
@tournaments = Tournament.limit(200).search_date_name(params[:search]).order("date DESC")
|
||||
# Simple manual pagination to avoid introducing a gem.
|
||||
per_page = 20
|
||||
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
|
||||
@tournaments = Tournament.all.sort_by{|t| t.days_until_start}.first(20)
|
||||
tournaments = Tournament.all.to_a
|
||||
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
|
||||
|
||||
def show
|
||||
@@ -285,7 +374,7 @@ class TournamentsController < ApplicationController
|
||||
|
||||
def reset_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
|
||||
|
||||
def generate_school_keys
|
||||
@@ -303,7 +392,7 @@ class TournamentsController < ApplicationController
|
||||
private
|
||||
# Use callbacks to share common setup or constraints between actions.
|
||||
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
|
||||
|
||||
# Never trust parameters from the scary internet, only allow the white list through.
|
||||
|
||||
@@ -98,7 +98,8 @@ class WeightsController < ApplicationController
|
||||
private
|
||||
# Use callbacks to share common setup or constraints between actions.
|
||||
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
|
||||
|
||||
# Never trust parameters from the scary internet, only allow the white list through.
|
||||
|
||||
@@ -86,7 +86,11 @@ class WrestlersController < ApplicationController
|
||||
private
|
||||
|
||||
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
|
||||
|
||||
def wrestler_params
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
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
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
class AdvanceWrestlerJob < ApplicationJob
|
||||
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 self.perform_sync(wrestler, match)
|
||||
# Execute directly on provided objects
|
||||
service = AdvanceWrestler.new(wrestler, match)
|
||||
service.advance_raw
|
||||
end
|
||||
|
||||
def perform(wrestler, match)
|
||||
def perform(wrestler, match, tournament_id)
|
||||
# Get tournament from wrestler
|
||||
tournament = wrestler.tournament
|
||||
|
||||
@@ -18,7 +13,7 @@ class AdvanceWrestlerJob < ApplicationJob
|
||||
tournament: tournament,
|
||||
job_name: job_name,
|
||||
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
|
||||
@@ -36,4 +31,4 @@ class AdvanceWrestlerJob < ApplicationJob
|
||||
raise e
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
class CalculateSchoolScoreJob < ApplicationJob
|
||||
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)
|
||||
# Execute directly on provided objects
|
||||
school.calculate_score_raw
|
||||
@@ -35,4 +36,4 @@ class CalculateSchoolScoreJob < ApplicationJob
|
||||
raise e
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
class GenerateTournamentMatchesJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
# 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
|
||||
limits_concurrency to: 1, key: ->(tournament) { "tournament:#{tournament.id}" }
|
||||
|
||||
def perform(tournament)
|
||||
# Log information about the job
|
||||
@@ -24,4 +18,4 @@ class GenerateTournamentMatchesJob < ApplicationJob
|
||||
raise # Re-raise the error so it's properly recorded
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
class TournamentBackupJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
# 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
|
||||
limits_concurrency to: 1, key: ->(tournament, *) { "tournament:#{tournament.id}" }
|
||||
|
||||
def perform(tournament, reason = nil)
|
||||
# Log information about the job
|
||||
@@ -36,4 +30,4 @@ class TournamentBackupJob < ApplicationJob
|
||||
raise e
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
37
app/jobs/tournament_cleanup_job.rb
Normal file
37
app/jobs/tournament_cleanup_job.rb
Normal 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
|
||||
@@ -1,13 +1,6 @@
|
||||
class WrestlingdevImportJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
# 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
|
||||
limits_concurrency to: 1, key: ->(tournament, *) { "tournament:#{tournament.id}" }
|
||||
|
||||
def perform(tournament, import_data = nil)
|
||||
# Log information about the job
|
||||
@@ -38,4 +31,4 @@ class WrestlingdevImportJob < ApplicationJob
|
||||
raise e
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
class 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)
|
||||
if user
|
||||
# LOGGED IN USER PERMISSIONS
|
||||
@@ -46,6 +60,8 @@ class Ability
|
||||
school.tournament.delegates.map(&:user_id).include?(user.id) ||
|
||||
school.tournament.user_id == user.id
|
||||
end
|
||||
|
||||
school_permission_key_check(school_permission_key)
|
||||
else
|
||||
# NON LOGGED IN USER PERMISSIONS
|
||||
|
||||
@@ -58,18 +74,7 @@ class Ability
|
||||
|
||||
# SCHOOL PERMISSIONS
|
||||
# wrestler permissions are included with school permissions
|
||||
|
||||
# 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
|
||||
school_permission_key_check(school_permission_key)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,53 +1,55 @@
|
||||
class Mat < ApplicationRecord
|
||||
include ActionView::RecordIdentifier
|
||||
belongs_to :tournament
|
||||
has_many :matches, dependent: :destroy
|
||||
has_many :matches, dependent: :nullify
|
||||
has_many :mat_assignment_rules, dependent: :destroy
|
||||
|
||||
validates :name, presence: true
|
||||
|
||||
before_destroy do
|
||||
if tournament.matches.size > 0
|
||||
tournament.reset_mats
|
||||
matsToAssign = tournament.mats.select{|m| m.id != self.id}
|
||||
tournament.assign_mats(matsToAssign)
|
||||
end
|
||||
end
|
||||
QUEUE_SLOTS = %w[queue1 queue2 queue3 queue4].freeze
|
||||
SCOREBOARD_SELECTION_CACHE_TTL = 1.hours
|
||||
LAST_MATCH_RESULT_CACHE_TTL = 1.hours
|
||||
|
||||
after_create do
|
||||
if tournament.matches.size > 0
|
||||
tournament.reset_mats
|
||||
matsToAssign = tournament.mats
|
||||
tournament.assign_mats(matsToAssign)
|
||||
end
|
||||
end
|
||||
after_save :clear_queue_matches_cache
|
||||
after_commit :broadcast_up_matches_board, on: :update, if: :up_matches_queue_changed?
|
||||
|
||||
def assign_next_match
|
||||
slot = first_empty_queue_slot
|
||||
return true unless slot
|
||||
|
||||
match = next_eligible_match
|
||||
self.matches.reload
|
||||
if match and self.unfinished_matches.size < 4
|
||||
match.mat_id = self.id
|
||||
if match.save
|
||||
# Invalidate any wrestler caches
|
||||
if match.w1
|
||||
match.wrestler1.touch
|
||||
match.wrestler1.school.touch
|
||||
return false unless match
|
||||
|
||||
place_match_in_empty_slot!(match, slot)
|
||||
true
|
||||
end
|
||||
|
||||
def advance_queue!(finished_match = nil)
|
||||
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
|
||||
if match.w2
|
||||
match.wrestler2.touch
|
||||
match.wrestler2.school.touch
|
||||
end
|
||||
return true
|
||||
else
|
||||
return false
|
||||
if queue1_match&.finished == 1
|
||||
shift_queue_forward!
|
||||
end
|
||||
fill_queue_slots!
|
||||
end
|
||||
else
|
||||
return true
|
||||
end
|
||||
broadcast_current_match
|
||||
true
|
||||
end
|
||||
|
||||
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
|
||||
filtered_matches = tournament.matches
|
||||
filtered_matches = Match.where(tournament_id: tournament_id)
|
||||
.where(finished: [nil, 0]) # finished is nil or 0
|
||||
.where(mat_id: nil) # mat_id is nil
|
||||
.where.not(bout_number: nil) # bout_number is not nil
|
||||
@@ -57,6 +59,11 @@ class Mat < ApplicationRecord
|
||||
filtered_matches = filtered_matches
|
||||
.where("loser1_name != ? OR loser1_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
|
||||
mat_assignment_rules.each do |rule|
|
||||
@@ -80,9 +87,264 @@ class Mat < ApplicationRecord
|
||||
filtered_matches.first
|
||||
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
|
||||
matches.select{|m| m.finished != 1}.sort_by{|m| m.bout_number}
|
||||
end
|
||||
|
||||
def scoreboard_payload
|
||||
selected_match = selected_scoreboard_match
|
||||
{
|
||||
mat_id: id,
|
||||
queue1_bout_number: queue1_match&.bout_number,
|
||||
queue1_match_id: queue1_match&.id,
|
||||
selected_bout_number: selected_match&.bout_number,
|
||||
selected_match_id: selected_match&.id,
|
||||
last_match_result: last_match_result_text
|
||||
}
|
||||
end
|
||||
|
||||
def set_selected_scoreboard_match!(match)
|
||||
if match
|
||||
Rails.cache.write(
|
||||
scoreboard_selection_cache_key,
|
||||
{ match_id: match.id, bout_number: match.bout_number },
|
||||
expires_in: SCOREBOARD_SELECTION_CACHE_TTL
|
||||
)
|
||||
else
|
||||
Rails.cache.delete(scoreboard_selection_cache_key)
|
||||
end
|
||||
broadcast_current_match
|
||||
end
|
||||
|
||||
def selected_scoreboard_match
|
||||
selection = Rails.cache.read(scoreboard_selection_cache_key)
|
||||
return nil unless selection
|
||||
|
||||
match_id = selection[:match_id] || selection["match_id"]
|
||||
selected_match = queue_matches.compact.find { |match| match.id == match_id }
|
||||
return selected_match if selected_match
|
||||
|
||||
Rails.cache.delete(scoreboard_selection_cache_key)
|
||||
nil
|
||||
end
|
||||
|
||||
def set_last_match_result!(text)
|
||||
if text.present?
|
||||
Rails.cache.write(last_match_result_cache_key, text, expires_in: LAST_MATCH_RESULT_CACHE_TTL)
|
||||
else
|
||||
Rails.cache.delete(last_match_result_cache_key)
|
||||
end
|
||||
broadcast_current_match
|
||||
end
|
||||
|
||||
def last_match_result_text
|
||||
Rails.cache.read(last_match_result_cache_key)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def clear_queue_matches_cache
|
||||
@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
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
class Match < ApplicationRecord
|
||||
include ActionView::RecordIdentifier
|
||||
|
||||
belongs_to :tournament, touch: true
|
||||
belongs_to :weight, touch: 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 :schools, :through => :wrestlers
|
||||
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
|
||||
before_save :update_finished_at
|
||||
|
||||
after_update :after_finished_actions, if: -> {
|
||||
saved_change_to_finished? ||
|
||||
saved_change_to_winner_id? ||
|
||||
saved_change_to_win_type? ||
|
||||
saved_change_to_score? ||
|
||||
saved_change_to_overtime_type?
|
||||
# update mat show with correct match if bout board is reset
|
||||
# this is done with a turbo stream
|
||||
after_commit :broadcast_mat_assignment_change, if: :saved_change_to_mat_id?, on: [:create, :update]
|
||||
after_commit :broadcast_up_matches_board, on: :update, if: :saved_change_to_mat_id?
|
||||
|
||||
# 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
|
||||
@@ -25,11 +40,14 @@ class Match < ApplicationRecord
|
||||
wrestler2.touch
|
||||
end
|
||||
if self.finished == 1 && self.winner_id != nil
|
||||
if self.mat
|
||||
self.mat.assign_next_match
|
||||
end
|
||||
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
|
||||
|
||||
@@ -43,7 +61,7 @@ class Match < ApplicationRecord
|
||||
errors.add(:winner_id, "cannot be blank")
|
||||
end
|
||||
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
|
||||
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")
|
||||
@@ -163,14 +181,6 @@ class Match < ApplicationRecord
|
||||
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
|
||||
if self.w1 != nil
|
||||
wrestler1.name
|
||||
@@ -188,6 +198,7 @@ class Match < ApplicationRecord
|
||||
end
|
||||
|
||||
def w1_bracket_name
|
||||
first_round = first_round_for_weight
|
||||
return_string = ""
|
||||
return_string_ending = ""
|
||||
if self.w1 and self.winner_id == self.w1
|
||||
@@ -195,7 +206,7 @@ class Match < ApplicationRecord
|
||||
return_string_ending = return_string_ending + "</strong>"
|
||||
end
|
||||
if self.w1 != nil
|
||||
if self.round == 1
|
||||
if self.round == first_round
|
||||
return_string = return_string + "#{wrestler1.long_bracket_name}"
|
||||
else
|
||||
return_string = return_string + "#{wrestler1.short_bracket_name}"
|
||||
@@ -207,6 +218,7 @@ class Match < ApplicationRecord
|
||||
end
|
||||
|
||||
def w2_bracket_name
|
||||
first_round = first_round_for_weight
|
||||
return_string = ""
|
||||
return_string_ending = ""
|
||||
if self.w2 and self.winner_id == self.w2
|
||||
@@ -214,7 +226,7 @@ class Match < ApplicationRecord
|
||||
return_string_ending = return_string_ending + "</strong>"
|
||||
end
|
||||
if self.w2 != nil
|
||||
if self.round == 1
|
||||
if self.round == first_round
|
||||
return_string = return_string + "#{wrestler2.long_bracket_name}"
|
||||
else
|
||||
return_string = return_string + "#{wrestler2.short_bracket_name}"
|
||||
@@ -229,10 +241,10 @@ class Match < ApplicationRecord
|
||||
if self.finished != 1
|
||||
return ""
|
||||
end
|
||||
if self.winner_id == self.w1
|
||||
if self.winner == self.wrestler1
|
||||
return self.w1_name
|
||||
end
|
||||
if self.winner_id == self.w2
|
||||
if self.winner == self.wrestler2
|
||||
return self.w2_name
|
||||
end
|
||||
end
|
||||
@@ -241,20 +253,28 @@ class Match < ApplicationRecord
|
||||
if self.finished != 1
|
||||
return ""
|
||||
end
|
||||
if self.winner_id == self.w1
|
||||
winning_wrestler = self.wrestler1
|
||||
winning_wrestler = self.winner
|
||||
if winning_wrestler == self.wrestler1
|
||||
losing_wrestler = self.wrestler2
|
||||
end
|
||||
if self.winner_id == self.w2
|
||||
winning_wrestler = self.wrestler2
|
||||
elsif winning_wrestler == self.wrestler2
|
||||
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
|
||||
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
|
||||
|
||||
def bracket_winner_name
|
||||
if winner_name != ""
|
||||
return "#{winner_name} (#{Wrestler.find(winner_id).school.abbreviation})"
|
||||
# Use the winner association directly
|
||||
if self.winner
|
||||
return "#{self.winner.name} (#{self.winner.school.abbreviation})"
|
||||
else
|
||||
""
|
||||
end
|
||||
@@ -264,6 +284,17 @@ class Match < ApplicationRecord
|
||||
self.weight.max
|
||||
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)
|
||||
if self.loser1_name == loser_name
|
||||
self.w1 = w.id
|
||||
@@ -319,4 +350,30 @@ class Match < ApplicationRecord
|
||||
self.finished_at = Time.current.utc
|
||||
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
|
||||
|
||||
@@ -69,8 +69,35 @@ class Tournament < ApplicationRecord
|
||||
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
|
||||
matches.destroy_all
|
||||
mats.each(&:clear_queue!)
|
||||
end
|
||||
|
||||
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
|
||||
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
|
||||
matches.reload
|
||||
mats.reload
|
||||
matches_to_reset = matches.select{|m| m.mat_id != nil}
|
||||
# matches_to_reset.update_all( {:mat_id => nil } )
|
||||
matches_to_reset.each do |m|
|
||||
m.mat_id = nil
|
||||
m.save
|
||||
end
|
||||
mats.each do |mat|
|
||||
mat.clear_queue!
|
||||
end
|
||||
end
|
||||
|
||||
def pointAdjustments
|
||||
point_adjustments = []
|
||||
self.schools.each do |s|
|
||||
s.deductedPoints.each do |d|
|
||||
point_adjustments << d
|
||||
end
|
||||
end
|
||||
self.wrestlers.each do |w|
|
||||
w.deductedPoints.each do |d|
|
||||
point_adjustments << d
|
||||
end
|
||||
end
|
||||
point_adjustments
|
||||
school_scope = Teampointadjust.where(school_id: schools.select(:id))
|
||||
wrestler_scope = Teampointadjust.where(wrestler_id: wrestlers.select(:id))
|
||||
|
||||
Teampointadjust.includes(:school, :wrestler)
|
||||
.merge(school_scope.or(wrestler_scope))
|
||||
end
|
||||
|
||||
def remove_school_delegations
|
||||
@@ -156,14 +171,14 @@ class Tournament < ApplicationRecord
|
||||
|
||||
def double_elim_number_of_wrestlers_error
|
||||
error_string = ""
|
||||
if self.tournament_type == "Double Elimination 1-6" or self.tournament_type == "Double Elimination 1-8"
|
||||
weights_with_too_many_wrestlers = weights.select{|w| w.wrestlers.size > 32}
|
||||
weight_with_too_few_wrestlers = weights.select{|w| w.wrestlers.size < 4}
|
||||
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 > 64}
|
||||
weight_with_too_few_wrestlers = weights.select{|w| w.wrestlers.size < 2}
|
||||
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
|
||||
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
|
||||
return error_string
|
||||
@@ -228,19 +243,24 @@ class Tournament < ApplicationRecord
|
||||
|
||||
def reset_and_fill_bout_board
|
||||
reset_mats
|
||||
|
||||
if mats.any?
|
||||
4.times do
|
||||
# Iterate over each mat and assign the next available match
|
||||
mats.each do |mat|
|
||||
match_assigned = mat.assign_next_match
|
||||
# If no more matches are available, exit early
|
||||
unless match_assigned
|
||||
puts "No more eligible matches to assign."
|
||||
return
|
||||
end
|
||||
matches.reload
|
||||
refill_open_bout_board_queues
|
||||
end
|
||||
|
||||
def refill_open_bout_board_queues
|
||||
return unless mats.any?
|
||||
|
||||
loop do
|
||||
assigned_any = false
|
||||
# Fill in round-robin order by queue depth:
|
||||
# all mats queue1 first, then queue2, then queue3, then queue4.
|
||||
(1..4).each do |slot|
|
||||
mats.reload.each do |mat|
|
||||
next unless mat.public_send("queue#{slot}").nil?
|
||||
assigned_any ||= mat.assign_next_match
|
||||
end
|
||||
end
|
||||
end
|
||||
break unless assigned_any
|
||||
end
|
||||
end
|
||||
|
||||
@@ -279,4 +299,4 @@ class Tournament < ApplicationRecord
|
||||
def connection_adapter
|
||||
ActiveRecord::Base.connection.adapter_name
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -53,19 +53,16 @@ class User < ApplicationRecord
|
||||
end
|
||||
|
||||
def delegated_tournaments
|
||||
tournaments_delegated = []
|
||||
delegated_tournament_permissions.each do |t|
|
||||
tournaments_delegated << t.tournament
|
||||
end
|
||||
tournaments_delegated
|
||||
Tournament.joins(:delegates)
|
||||
.where(tournament_delegates: { user_id: id })
|
||||
.distinct
|
||||
end
|
||||
|
||||
def delegated_schools
|
||||
schools_delegated = []
|
||||
delegated_school_permissions.each do |t|
|
||||
schools_delegated << t.school
|
||||
end
|
||||
schools_delegated
|
||||
School.joins(:delegates)
|
||||
.where(school_delegates: { user_id: id })
|
||||
.includes(:tournament)
|
||||
.distinct
|
||||
end
|
||||
|
||||
def self.search(search)
|
||||
|
||||
@@ -156,7 +156,7 @@ class Weight < ApplicationRecord
|
||||
end
|
||||
|
||||
def calculate_bracket_size
|
||||
num_wrestlers = wrestlers.reload.size
|
||||
num_wrestlers = wrestlers.size
|
||||
return nil if num_wrestlers <= 0 # Handle invalid input
|
||||
|
||||
# Find the smallest power of 2 greater than or equal to num_wrestlers
|
||||
|
||||
@@ -2,8 +2,13 @@ class Wrestler < ApplicationRecord
|
||||
belongs_to :school, touch: true
|
||||
belongs_to :weight, touch: true
|
||||
has_one :tournament, through: :weight
|
||||
has_many :matches, through: :weight
|
||||
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
|
||||
|
||||
validates :name, :weight_id, :school_id, presence: true
|
||||
@@ -59,7 +64,7 @@ class Wrestler < ApplicationRecord
|
||||
end
|
||||
|
||||
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
|
||||
else
|
||||
return false
|
||||
@@ -87,28 +92,28 @@ class Wrestler < ApplicationRecord
|
||||
end
|
||||
|
||||
def result_by_bout(bout)
|
||||
bout_match = all_matches.select{|m| m.bout_number == bout and m.finished == 1}
|
||||
if bout_match.size == 0
|
||||
bout_match_results = all_matches.select{|m| m.bout_number == bout and m.finished == 1}
|
||||
if bout_match_results.empty?
|
||||
return ""
|
||||
end
|
||||
if bout_match.first.winner_id == self.id
|
||||
return "W #{bout_match.first.bracket_score_string}"
|
||||
end
|
||||
if bout_match.first.winner_id != self.id
|
||||
return "L #{bout_match.first.bracket_score_string}"
|
||||
bout_match = bout_match_results.first
|
||||
if bout_match.winner == self # Keep winner association change
|
||||
return "W #{bout_match.bracket_score_string}"
|
||||
else
|
||||
return "L #{bout_match.bracket_score_string}"
|
||||
end
|
||||
end
|
||||
|
||||
def result_by_id(id)
|
||||
bout_match = all_matches.select{|m| m.id == id and m.finished == 1}
|
||||
if bout_match.size == 0
|
||||
bout_match_results = all_matches.select{|m| m.id == id and m.finished == 1}
|
||||
if bout_match_results.empty?
|
||||
return ""
|
||||
end
|
||||
if bout_match.first.winner_id == self.id
|
||||
return "W #{bout_match.first.bracket_score_string}"
|
||||
end
|
||||
if bout_match.first.winner_id != self.id
|
||||
return "L #{bout_match.first.bracket_score_string}"
|
||||
bout_match = bout_match_results.first
|
||||
if bout_match.winner == self # Keep winner association change
|
||||
return "W #{bout_match.bracket_score_string}"
|
||||
else
|
||||
return "L #{bout_match.bracket_score_string}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -120,7 +125,8 @@ class Wrestler < ApplicationRecord
|
||||
if all_matches.blank?
|
||||
return false
|
||||
else
|
||||
return true
|
||||
# Original logic checked blank?, not specific round. Reverting to that.
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -142,8 +148,12 @@ class Wrestler < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
# Restore all_matches method
|
||||
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
|
||||
|
||||
def pool_matches
|
||||
@@ -152,7 +162,9 @@ class Wrestler < ApplicationRecord
|
||||
end
|
||||
|
||||
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
|
||||
else
|
||||
return false
|
||||
@@ -188,7 +200,8 @@ class Wrestler < ApplicationRecord
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def pool_wins
|
||||
@@ -268,11 +281,17 @@ class Wrestler < ApplicationRecord
|
||||
def season_win_percentage
|
||||
win = self.season_win.to_f
|
||||
loss = self.season_loss.to_f
|
||||
# Revert to original logic
|
||||
if win > 0 and loss != nil
|
||||
match_total = win + loss
|
||||
percentage_dec = win / match_total
|
||||
percentage = percentage_dec * 100
|
||||
return percentage.to_i
|
||||
if match_total > 0
|
||||
percentage_dec = win / match_total
|
||||
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
|
||||
return 0
|
||||
elsif self.season_win == nil or self.season_loss == nil
|
||||
@@ -281,6 +300,7 @@ class Wrestler < ApplicationRecord
|
||||
end
|
||||
|
||||
def long_bracket_name
|
||||
# Revert to original logic
|
||||
return_string = ""
|
||||
if self.original_seed
|
||||
return_string = return_string + "[#{self.original_seed}] "
|
||||
@@ -293,10 +313,12 @@ class Wrestler < ApplicationRecord
|
||||
end
|
||||
|
||||
def short_bracket_name
|
||||
# Revert to original logic
|
||||
return "#{self.name} (#{self.school.abbreviation})"
|
||||
end
|
||||
|
||||
def name_with_school
|
||||
# Revert to original logic
|
||||
return "#{self.name} - #{self.school.name}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,23 +8,101 @@ class AdvanceWrestler
|
||||
def advance
|
||||
# Use perform_later which will execute based on centralized adapter config
|
||||
# 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
|
||||
|
||||
def advance_raw
|
||||
|
||||
if @last_match && @last_match.finished?
|
||||
pool_to_bracket_advancement if @tournament.tournament_type == "Pool to bracket"
|
||||
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"
|
||||
@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?
|
||||
|
||||
context = preload_advancement_context
|
||||
matches_to_advance = []
|
||||
|
||||
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 pool_to_bracket_advancement
|
||||
if @wrestler.weight.all_pool_matches_finished(@wrestler.pool) and (@wrestler.finished_bracket_matches.size < 1)
|
||||
PoolOrder.new(@wrestler.weight.wrestlers_in_pool(@wrestler.pool)).getPoolOrder
|
||||
end
|
||||
PoolAdvance.new(@wrestler).advanceWrestler
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
def pool_to_bracket_advancement(context)
|
||||
matches_to_advance = []
|
||||
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
|
||||
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
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
class DoubleEliminationAdvance
|
||||
|
||||
def initialize(wrestler,last_match)
|
||||
attr_reader :matches_to_advance
|
||||
|
||||
def initialize(wrestler,last_match, matches: nil)
|
||||
@wrestler = wrestler
|
||||
@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)
|
||||
end
|
||||
|
||||
def bracket_advancement
|
||||
if @last_match.winner_id == @wrestler.id
|
||||
winner_advance
|
||||
end
|
||||
if @last_match.winner_id != @wrestler.id
|
||||
loser_advance
|
||||
end
|
||||
advance_wrestler
|
||||
advance_double_byes
|
||||
set_bye_for_placement
|
||||
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
|
||||
update_consolation_bye
|
||||
end
|
||||
@@ -43,7 +52,7 @@ class DoubleEliminationAdvance
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
if next_match
|
||||
@@ -54,18 +63,16 @@ class DoubleEliminationAdvance
|
||||
def update_new_match(match, wrestler_number)
|
||||
if wrestler_number == 2 or (match.loser1_name and match.loser1_name.include? "Loser of")
|
||||
match.w2 = @wrestler.id
|
||||
match.save
|
||||
elsif wrestler_number == 1
|
||||
match.w1 = @wrestler.id
|
||||
match.save
|
||||
end
|
||||
end
|
||||
|
||||
def update_consolation_bye
|
||||
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)
|
||||
if next_match.size > 0
|
||||
next_match.first.replace_loser_name_with_bye("Loser of #{bout}")
|
||||
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
|
||||
replace_loser_name_with_bye(next_match, "Loser of #{bout}")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -77,29 +84,20 @@ class DoubleEliminationAdvance
|
||||
end
|
||||
end
|
||||
|
||||
def loser_advance
|
||||
def losers_bracket_advancement
|
||||
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?
|
||||
next_match.replace_loser_name_with_wrestler(@wrestler, "Loser of #{bout}")
|
||||
next_match.reload
|
||||
replace_loser_name_with_wrestler(next_match, @wrestler, "Loser of #{bout}")
|
||||
|
||||
if next_match.loser1_name == "BYE" || next_match.loser2_name == "BYE"
|
||||
next_match.winner_id = @wrestler.id
|
||||
next_match.win_type = "BYE"
|
||||
next_match.score = ""
|
||||
next_match.finished = 1
|
||||
# puts "Before save: winner_id=#{next_match.winner_id}"
|
||||
|
||||
# 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
|
||||
next_match.finished_at = Time.current
|
||||
@matches_to_advance << next_match
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -107,51 +105,69 @@ class DoubleEliminationAdvance
|
||||
|
||||
def advance_double_byes
|
||||
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_at = Time.current
|
||||
match.score = ""
|
||||
match.win_type = "BYE"
|
||||
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}
|
||||
next_matches = weight.matches.select{|m| m.round == after_matches.first.round and m.is_consolation_match == match.is_consolation_match }
|
||||
this_round_matches = weight.matches.select{|m| m.round == match.round and m.is_consolation_match == match.is_consolation_match }
|
||||
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 if after_matches.empty?
|
||||
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
|
||||
next_match = next_matches.select{|m| m.bracket_position_number == match.bracket_position_number}.first
|
||||
next_match.loser2_name = "BYE"
|
||||
next_match.save
|
||||
next_match.loser2_name = "BYE" if next_match
|
||||
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
|
||||
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"
|
||||
else
|
||||
elsif next_match
|
||||
next_match.loser1_name = "BYE"
|
||||
end
|
||||
end
|
||||
next_match.save
|
||||
match.save
|
||||
end
|
||||
end
|
||||
|
||||
def set_bye_for_placement
|
||||
weight = @wrestler.weight
|
||||
fifth_finals = weight.matches.select{|match| match.bracket_position == '5/6'}.first
|
||||
seventh_finals = weight.matches.select{|match| match.bracket_position == '7/8'}.first
|
||||
fifth_finals = @matches.select{|match| match.weight_id == weight.id && match.bracket_position == '5/6'}.first
|
||||
seventh_finals = @matches.select{|match| match.weight_id == weight.id && match.bracket_position == '7/8'}.first
|
||||
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|
|
||||
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
|
||||
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|
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@@ -1,63 +1,69 @@
|
||||
class ModifiedDoubleEliminationAdvance
|
||||
|
||||
def initialize(wrestler,last_match)
|
||||
attr_reader :matches_to_advance
|
||||
|
||||
def initialize(wrestler,last_match, matches: nil)
|
||||
@wrestler = wrestler
|
||||
@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)
|
||||
end
|
||||
|
||||
def bracket_advancement
|
||||
if @last_match.winner_id == @wrestler.id
|
||||
winner_advance
|
||||
end
|
||||
if @last_match.winner_id != @wrestler.id
|
||||
loser_advance
|
||||
end
|
||||
advance_wrestler
|
||||
advance_double_byes
|
||||
set_bye_for_placement
|
||||
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
|
||||
update_consolation_bye
|
||||
end
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
elsif @last_match.bracket_position == "Conso Quarter"
|
||||
# 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)
|
||||
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)
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
||||
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")
|
||||
match.w2 = @wrestler.id
|
||||
match.save
|
||||
elsif wrestler_number == 1
|
||||
match.w1 = @wrestler.id
|
||||
match.save
|
||||
end
|
||||
end
|
||||
|
||||
def update_consolation_bye
|
||||
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)
|
||||
if next_match.size > 0
|
||||
next_match.first.replace_loser_name_with_bye("Loser of #{bout}")
|
||||
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
|
||||
replace_loser_name_with_bye(next_match, "Loser of #{bout}")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -69,29 +75,20 @@ class ModifiedDoubleEliminationAdvance
|
||||
end
|
||||
end
|
||||
|
||||
def loser_advance
|
||||
def losers_bracket_advancement
|
||||
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?
|
||||
next_match.replace_loser_name_with_wrestler(@wrestler, "Loser of #{bout}")
|
||||
next_match.reload
|
||||
replace_loser_name_with_wrestler(next_match, @wrestler, "Loser of #{bout}")
|
||||
|
||||
if next_match.loser1_name == "BYE" || next_match.loser2_name == "BYE"
|
||||
next_match.winner_id = @wrestler.id
|
||||
next_match.win_type = "BYE"
|
||||
next_match.score = ""
|
||||
next_match.finished = 1
|
||||
# puts "Before save: winner_id=#{next_match.winner_id}"
|
||||
|
||||
# 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
|
||||
next_match.finished_at = Time.current
|
||||
@matches_to_advance << next_match
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -99,43 +96,53 @@ class ModifiedDoubleEliminationAdvance
|
||||
|
||||
def advance_double_byes
|
||||
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_at = Time.current
|
||||
match.score = ""
|
||||
match.win_type = "BYE"
|
||||
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}
|
||||
next_matches = weight.matches.select{|m| m.round == after_matches.first.round and m.is_consolation_match == match.is_consolation_match }
|
||||
this_round_matches = weight.matches.select{|m| m.round == match.round and m.is_consolation_match == match.is_consolation_match }
|
||||
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 if after_matches.empty?
|
||||
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
|
||||
next_match = next_matches.select{|m| m.bracket_position_number == match.bracket_position_number}.first
|
||||
next_match.loser2_name = "BYE"
|
||||
next_match.save
|
||||
next_match.loser2_name = "BYE" if next_match
|
||||
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
|
||||
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"
|
||||
else
|
||||
elsif next_match
|
||||
next_match.loser1_name = "BYE"
|
||||
end
|
||||
end
|
||||
next_match.save
|
||||
match.save
|
||||
end
|
||||
end
|
||||
|
||||
def set_bye_for_placement
|
||||
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
|
||||
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|
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
class PoolAdvance
|
||||
|
||||
def initialize(wrestler)
|
||||
attr_reader :matches_to_advance
|
||||
|
||||
def initialize(wrestler, last_match, matches: nil, wrestlers: nil)
|
||||
@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
|
||||
|
||||
def advanceWrestler
|
||||
@@ -17,59 +22,68 @@ class PoolAdvance
|
||||
def poolToBracketAdvancment
|
||||
pool = @wrestler.pool
|
||||
# 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
|
||||
runner_up = Wrestler.where("weight_id = ? and pool_placement = 2 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 = @wrestlers.find { |w| w.weight_id == @wrestler.weight.id && w.pool_placement == 2 && w.pool == pool }
|
||||
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.replace_loser_name_with_wrestler(runner_up,"Runner Up Pool #{pool}")
|
||||
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}") }
|
||||
replace_loser_name_with_wrestler(runner_up_match, runner_up, "Runner Up Pool #{pool}") if runner_up_match
|
||||
end
|
||||
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.replace_loser_name_with_wrestler(winner,"Winner Pool #{pool}")
|
||||
winner_match = @matches.find { |m| m.weight_id == @wrestler.weight.id && (m.loser1_name == "Winner Pool #{pool}" || m.loser2_name == "Winner Pool #{pool}") }
|
||||
replace_loser_name_with_wrestler(winner_match, winner, "Winner Pool #{pool}") if winner_match
|
||||
end
|
||||
end
|
||||
|
||||
def bracketAdvancment
|
||||
if @last_match.winner_id == @wrestler.id
|
||||
winnerAdvance
|
||||
end
|
||||
if @last_match.winner_id != @wrestler.id
|
||||
loserAdvance
|
||||
end
|
||||
advance_wrestlers
|
||||
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"
|
||||
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)
|
||||
end
|
||||
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)
|
||||
end
|
||||
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)
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
def updateNewMatch(match)
|
||||
return unless match
|
||||
if @wrestler.next_match_position_number == @wrestler.next_match_position_number.ceil
|
||||
match.w2 = @wrestler.id
|
||||
match.save
|
||||
end
|
||||
if @wrestler.next_match_position_number != @wrestler.next_match_position_number.ceil
|
||||
match.w1 = @wrestler.id
|
||||
match.save
|
||||
end
|
||||
end
|
||||
|
||||
def loserAdvance
|
||||
def loser_advance
|
||||
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)
|
||||
if next_match.size > 0
|
||||
next_match.first.replace_loser_name_with_wrestler(@wrestler,"Loser of #{bout}")
|
||||
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
|
||||
replace_loser_name_with_wrestler(next_match, @wrestler, "Loser of #{bout}")
|
||||
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
|
||||
|
||||
@@ -4,8 +4,6 @@ class PoolOrder
|
||||
end
|
||||
|
||||
def getPoolOrder
|
||||
# clear caching for weight for bracket page
|
||||
@wrestlers.first.weight.touch
|
||||
setOriginalPoints
|
||||
while checkForTies(@wrestlers) == true
|
||||
getWrestlersOrderByPoolAdvancePoints.each do |wrestler|
|
||||
@@ -18,7 +16,6 @@ class PoolOrder
|
||||
getWrestlersOrderByPoolAdvancePoints.each_with_index do |wrestler, index|
|
||||
placement = index + 1
|
||||
wrestler.pool_placement = placement
|
||||
wrestler.save
|
||||
end
|
||||
@wrestlers.sort_by{|w| w.poolAdvancePoints}.reverse!
|
||||
end
|
||||
@@ -29,7 +26,6 @@ class PoolOrder
|
||||
|
||||
def setOriginalPoints
|
||||
@wrestlers.each do |w|
|
||||
matches = w.matches.reload
|
||||
w.pool_placement_tiebreaker = nil
|
||||
w.pool_placement = nil
|
||||
w.poolAdvancePoints = w.pool_wins.size
|
||||
@@ -80,10 +76,13 @@ class PoolOrder
|
||||
def headToHead(wrestlers_with_same_points)
|
||||
wrestlers_with_same_points.each do |wr|
|
||||
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
|
||||
addPointsToWrestlersAhead(wr)
|
||||
wr.pool_placement_tiebreaker = "Head to Head"
|
||||
if otherWrestler
|
||||
matches = wr.match_against(otherWrestler).select { |match| match.bracket_position == "Pool" }
|
||||
if matches.any? && matches.first.winner == wr
|
||||
addPointsToWrestlersAhead(wr)
|
||||
wr.pool_placement_tiebreaker = "Head to Head"
|
||||
addPoints(wr)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
55
app/services/school_services/calculate_school_score.rb
Normal file
55
app/services/school_services/calculate_school_score.rb
Normal 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
|
||||
@@ -3,130 +3,184 @@ class DoubleEliminationGenerateLoserNames
|
||||
@tournament = tournament
|
||||
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|
|
||||
assign_loser_names_for_weight(weight)
|
||||
advance_bye_matches_championship(weight.matches.reload)
|
||||
next unless weight.calculate_bracket_size > 2
|
||||
|
||||
assign_loser_names_in_memory(weight, rows)
|
||||
assign_bye_outcomes_in_memory(weight, rows)
|
||||
end
|
||||
rows
|
||||
end
|
||||
|
||||
def define_losername_championship_mappings(bracket_size)
|
||||
# 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
|
||||
def assign_loser_names_in_memory(weight, match_rows)
|
||||
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|
|
||||
conso_bracket_position = mapping[:conso_bracket_position]
|
||||
championship_bracket_position = mapping[:championship_bracket_position]
|
||||
cross_bracket = mapping[:cross_bracket]
|
||||
both_wrestlers = mapping[:both_wrestlers]
|
||||
champ_rounds = dynamic_championship_rounds(bracket_size)
|
||||
conso_rounds = dynamic_consolation_rounds(bracket_size)
|
||||
first_round = { bracket_position: first_round_label(bracket_size) }
|
||||
champ_full = [first_round] + champ_rounds
|
||||
|
||||
conso_matches = matches_by_weight.select do |match|
|
||||
match.bracket_position == conso_bracket_position && match.bracket_position == conso_bracket_position
|
||||
end.sort_by(&:bracket_position_number)
|
||||
mappings = []
|
||||
champ_full[0...-1].each_with_index do |champ_info, i|
|
||||
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|
|
||||
match.bracket_position == championship_bracket_position && match.bracket_position == championship_bracket_position
|
||||
end.sort_by(&:bracket_position_number)
|
||||
mappings << {
|
||||
championship_bracket_position: champ_info[:bracket_position],
|
||||
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
|
||||
conso_matches.each do |match|
|
||||
bout_number1 = championship_matches.find do |bout_match|
|
||||
bout_match.bracket_position_number == championship_bracket_position_number
|
||||
end.bout_number
|
||||
|
||||
match.loser1_name = "Loser of #{bout_number1}"
|
||||
if both_wrestlers
|
||||
championship_bracket_position_number += 1
|
||||
bout_number2 = championship_matches.find do |bout_match|
|
||||
bout_match.bracket_position_number == championship_bracket_position_number
|
||||
end.bout_number
|
||||
match.loser2_name = "Loser of #{bout_number2}"
|
||||
idx = 0
|
||||
is_first_feed = map[:champ_round_index].zero?
|
||||
conso.each do |cm|
|
||||
champ_match1 = champ[idx]
|
||||
if champ_match1
|
||||
if is_first_feed && single_competitor_match_row?(champ_match1)
|
||||
cm[:loser1_name] = "BYE"
|
||||
else
|
||||
cm[:loser1_name] = "Loser of #{champ_match1[:bout_number]}"
|
||||
end
|
||||
else
|
||||
cm[:loser1_name] = nil
|
||||
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
|
||||
|
||||
conso_semi_matches = matches_by_weight.select { |match| match.bracket_position == "Conso Semis" }
|
||||
conso_quarter_matches = matches_by_weight.select { |match| match.bracket_position == "Conso Quarter" }
|
||||
|
||||
if number_of_placers >= 6 && weight.wrestlers.size >= 5
|
||||
five_six_match = matches_by_weight.find { |match| match.bracket_position == "5/6" }
|
||||
bout_number1 = conso_semi_matches.find { |match| match.bracket_position_number == 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"
|
||||
if bracket_size >= 5 && num_placers >= 6 && weight.wrestlers.size > 4
|
||||
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 conso_semis.size >= 2 && m56
|
||||
m56[:loser1_name] = "Loser of #{conso_semis[0][:bout_number]}"
|
||||
m56[:loser2_name] = "Loser of #{conso_semis[1][:bout_number]}"
|
||||
end
|
||||
match.score = ""
|
||||
match.save
|
||||
match.advance_wrestlers
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
@@ -1,38 +1,42 @@
|
||||
class DoubleEliminationMatchGeneration
|
||||
def initialize(tournament)
|
||||
def initialize(tournament, weights: nil)
|
||||
@tournament = tournament
|
||||
@weights = weights
|
||||
end
|
||||
|
||||
def generate_matches
|
||||
#
|
||||
# PHASE 1: Generate matches (with local round definitions).
|
||||
#
|
||||
@tournament.weights.each do |weight|
|
||||
generate_matches_for_weight(weight)
|
||||
build_match_rows
|
||||
end
|
||||
|
||||
def build_match_rows
|
||||
rows_by_weight_id = {}
|
||||
|
||||
generation_weights.each do |weight|
|
||||
rows_by_weight_id[weight.id] = generate_match_rows_for_weight(weight)
|
||||
end
|
||||
|
||||
#
|
||||
# PHASE 2: Align all rounds to match the largest bracket’s definitions.
|
||||
#
|
||||
align_all_rounds_to_largest_bracket
|
||||
align_rows_to_largest_bracket(rows_by_weight_id)
|
||||
rows_by_weight_id.values.flatten
|
||||
end
|
||||
|
||||
###########################################################################
|
||||
# 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_info = define_bracket_matches(bracket_size)
|
||||
return unless bracket_info
|
||||
return [] unless bracket_info
|
||||
|
||||
rows = []
|
||||
|
||||
# 1) Round one matchups
|
||||
bracket_info[:round_one_matchups].each_with_index do |matchup, idx|
|
||||
seed1, seed2 = matchup[:seeds]
|
||||
bracket_position = matchup[:bracket_position]
|
||||
bracket_pos_number = idx + 1
|
||||
round_number = matchup[:round] # Use the round from our definition
|
||||
seed1, seed2 = matchup[:seeds]
|
||||
bracket_position = matchup[:bracket_position]
|
||||
bracket_pos_number = idx + 1
|
||||
round_number = matchup[:round]
|
||||
|
||||
create_matchup_from_seed(
|
||||
rows << create_matchup_from_seed(
|
||||
seed1,
|
||||
seed2,
|
||||
bracket_position,
|
||||
@@ -49,7 +53,7 @@ class DoubleEliminationMatchGeneration
|
||||
round_number = round_info[:round]
|
||||
|
||||
matches_this_round.times do |i|
|
||||
create_matchup(
|
||||
rows << create_matchup(
|
||||
nil,
|
||||
nil,
|
||||
bracket_position,
|
||||
@@ -67,7 +71,7 @@ class DoubleEliminationMatchGeneration
|
||||
round_number = round_info[:round]
|
||||
|
||||
matches_this_round.times do |i|
|
||||
create_matchup(
|
||||
rows << create_matchup(
|
||||
nil,
|
||||
nil,
|
||||
bracket_position,
|
||||
@@ -77,169 +81,116 @@ class DoubleEliminationMatchGeneration
|
||||
)
|
||||
end
|
||||
|
||||
#
|
||||
# 5/6, 7/8 placing logic
|
||||
#
|
||||
if weight.wrestlers.size >= 5
|
||||
if @tournament.number_of_placers >= 6 && matches_this_round == 1
|
||||
create_matchup(nil, nil, "5/6", 1, round_number, weight)
|
||||
end
|
||||
if weight.wrestlers.size >= 5 && @tournament.number_of_placers >= 6 && matches_this_round == 1
|
||||
rows << create_matchup(nil, nil, "5/6", 1, round_number, weight)
|
||||
end
|
||||
if weight.wrestlers.size >= 7
|
||||
if @tournament.number_of_placers >= 8 && matches_this_round == 1
|
||||
create_matchup(nil, nil, "7/8", 1, round_number, weight)
|
||||
end
|
||||
if weight.wrestlers.size >= 7 && @tournament.number_of_placers >= 8 && matches_this_round == 1
|
||||
rows << create_matchup(nil, nil, "7/8", 1, round_number, weight)
|
||||
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
|
||||
|
||||
#
|
||||
# Single bracket definition that includes both bracket_position and round.
|
||||
# If you later decide to tweak round numbering, you do it in ONE place.
|
||||
#
|
||||
def define_bracket_matches(bracket_size)
|
||||
case bracket_size
|
||||
when 4
|
||||
{
|
||||
round_one_matchups: [
|
||||
# First round is Semis => round=1
|
||||
{ seeds: [1, 4], bracket_position: "Semis", round: 1 },
|
||||
{ seeds: [2, 3], bracket_position: "Semis", round: 1 }
|
||||
],
|
||||
championship_rounds: [
|
||||
# Final => round=2
|
||||
{ bracket_position: "1/2", number_of_matches: 1, round: 2 }
|
||||
],
|
||||
consolation_rounds: [
|
||||
# 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
|
||||
# Dynamically generate championship rounds for any power-of-two bracket size.
|
||||
def dynamic_championship_rounds(bracket_size)
|
||||
rounds = []
|
||||
num_rounds = Math.log2(bracket_size).to_i
|
||||
# i: 1 -> first post-initial round, up to num_rounds-1 (final)
|
||||
(1...num_rounds).each do |i|
|
||||
participants = bracket_size / (2**i)
|
||||
number_of_matches = participants / 2
|
||||
bracket_position = case participants
|
||||
when 2 then "1/2"
|
||||
when 4 then "Semis"
|
||||
when 8 then "Quarter"
|
||||
else "Bracket Round of #{participants}"
|
||||
end
|
||||
round_number = i * 2
|
||||
rounds << { bracket_position: bracket_position,
|
||||
number_of_matches: number_of_matches,
|
||||
round: round_number }
|
||||
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
|
||||
|
||||
###########################################################################
|
||||
# PHASE 2: Overwrite rounds in all smaller brackets to match the largest one.
|
||||
###########################################################################
|
||||
def align_all_rounds_to_largest_bracket
|
||||
#
|
||||
# 1) Find the bracket size that is largest
|
||||
#
|
||||
largest_weight = @tournament.weights.max_by { |w| w.calculate_bracket_size }
|
||||
def align_rows_to_largest_bracket(rows_by_weight_id)
|
||||
largest_weight = generation_weights.max_by { |w| w.calculate_bracket_size }
|
||||
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 = {}
|
||||
largest_matches.each do |m|
|
||||
# In case multiple matches have the same bracket_position but different rounds
|
||||
# (like "3/4" might appear more than once), you can pick the first or max.
|
||||
position_to_round[m.bracket_position] ||= m.round
|
||||
rows_by_weight_id.fetch(largest_weight.id, []).each do |row|
|
||||
position_to_round[row[:bracket_position]] ||= row[:round]
|
||||
end
|
||||
|
||||
#
|
||||
# 3) For every other match in the entire tournament (including possibly the largest bracket, if you want),
|
||||
# overwrite the round to match this map.
|
||||
#
|
||||
@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])
|
||||
rows_by_weight_id.each_value do |rows|
|
||||
rows.each do |row|
|
||||
row[:round] = position_to_round[row[:bracket_position]] if position_to_round.key?(row[:bracket_position])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -247,8 +198,12 @@ class DoubleEliminationMatchGeneration
|
||||
###########################################################################
|
||||
# Helper methods
|
||||
###########################################################################
|
||||
def generation_weights
|
||||
@weights || @tournament.weights.to_a
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def create_matchup_from_seed(w1_seed, w2_seed, bracket_position, bracket_position_number, round, weight)
|
||||
@@ -263,13 +218,33 @@ class DoubleEliminationMatchGeneration
|
||||
end
|
||||
|
||||
def create_matchup(w1, w2, bracket_position, bracket_position_number, round, weight)
|
||||
weight.tournament.matches.create!(
|
||||
{
|
||||
w1: w1,
|
||||
w2: w2,
|
||||
tournament_id: weight.tournament_id,
|
||||
weight_id: weight.id,
|
||||
round: round,
|
||||
bracket_position: bracket_position,
|
||||
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
|
||||
|
||||
@@ -10,62 +10,183 @@ class GenerateTournamentMatches
|
||||
|
||||
def generate_raw
|
||||
standardStartingActions
|
||||
PoolToBracketMatchGeneration.new(@tournament).generatePoolToBracketMatches if @tournament.tournament_type == "Pool to bracket"
|
||||
ModifiedSixteenManMatchGeneration.new(@tournament).generate_matches if @tournament.tournament_type.include? "Modified 16 Man Double Elimination"
|
||||
DoubleEliminationMatchGeneration.new(@tournament).generate_matches if @tournament.tournament_type.include? "Regular Double Elimination"
|
||||
generation_context = preload_generation_context
|
||||
seed_wrestlers_in_memory(generation_context)
|
||||
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
|
||||
PoolToBracketMatchGeneration.new(@tournament).assignLoserNames if @tournament.tournament_type == "Pool to bracket"
|
||||
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"
|
||||
advance_bye_matches_after_insert
|
||||
end
|
||||
|
||||
def standardStartingActions
|
||||
@tournament.curently_generating_matches = 1
|
||||
@tournament.save
|
||||
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
|
||||
|
||||
def postMatchCreationActions
|
||||
moveFinalsMatchesToLastRound if ! @tournament.tournament_type.include? "Regular Double Elimination"
|
||||
assignBouts
|
||||
@tournament.reset_and_fill_bout_board
|
||||
@tournament.curently_generating_matches = nil
|
||||
@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
|
||||
|
||||
def assignBouts
|
||||
bout_counts = Hash.new(0)
|
||||
@tournament.matches.reload
|
||||
@tournament.matches.sort_by{|m| [m.round, m.weight_max]}.each do |m|
|
||||
m.bout_number = m.round * 1000 + bout_counts[m.round]
|
||||
bout_counts[m.round] += 1
|
||||
m.save!
|
||||
timestamp = Time.current
|
||||
ordered_matches = Match.joins(:weight)
|
||||
.where(tournament_id: @tournament.id)
|
||||
.order("matches.round ASC, weights.max ASC, matches.id ASC")
|
||||
.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
|
||||
|
||||
Match.upsert_all(updates) if updates.any?
|
||||
end
|
||||
|
||||
def moveFinalsMatchesToLastRound
|
||||
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"}
|
||||
finalsMatches. each do |m|
|
||||
m.round = finalsRound
|
||||
m.save
|
||||
end
|
||||
@tournament.matches
|
||||
.where(bracket_position: ["1/2", "3/4", "5/6", "7/8"])
|
||||
.update_all(round: finalsRound, updated_at: Time.current)
|
||||
end
|
||||
|
||||
def unAssignMats
|
||||
matches = @tournament.matches.reload
|
||||
matches.each do |m|
|
||||
m.mat_id = nil
|
||||
m.save!
|
||||
end
|
||||
@tournament.matches.update_all(mat_id: nil, updated_at: Time.current)
|
||||
end
|
||||
|
||||
def unAssignBouts
|
||||
bout_counts = Hash.new(0)
|
||||
@tournament.matches.each do |m|
|
||||
m.bout_number = nil
|
||||
m.save!
|
||||
end
|
||||
@tournament.matches.update_all(bout_number: nil, updated_at: Time.current)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,95 +1,91 @@
|
||||
class ModifiedSixteenManGenerateLoserNames
|
||||
def initialize( tournament )
|
||||
@tournament = tournament
|
||||
def initialize(tournament)
|
||||
@tournament = tournament
|
||||
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
|
||||
|
||||
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)
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
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 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 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 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
|
||||
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
|
||||
|
||||
@@ -1,70 +1,75 @@
|
||||
class ModifiedSixteenManMatchGeneration
|
||||
def initialize( tournament )
|
||||
def initialize( tournament, weights: nil )
|
||||
@tournament = tournament
|
||||
@number_of_placers = @tournament.number_of_placers
|
||||
@weights = weights
|
||||
end
|
||||
|
||||
def generate_matches
|
||||
@tournament.weights.each do |weight|
|
||||
generate_matches_for_weight(weight)
|
||||
rows = []
|
||||
generation_weights.each do |weight|
|
||||
rows.concat(generate_match_rows_for_weight(weight))
|
||||
end
|
||||
rows
|
||||
end
|
||||
|
||||
def generate_matches_for_weight(weight)
|
||||
round_one(weight)
|
||||
round_two(weight)
|
||||
round_three(weight)
|
||||
round_four(weight)
|
||||
round_five(weight)
|
||||
def generate_match_rows_for_weight(weight)
|
||||
rows = []
|
||||
round_one(weight, rows)
|
||||
round_two(weight, rows)
|
||||
round_three(weight, rows)
|
||||
round_four(weight, rows)
|
||||
round_five(weight, rows)
|
||||
rows
|
||||
end
|
||||
|
||||
def round_one(weight)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
def round_one(weight, rows)
|
||||
rows << create_matchup_from_seed(1,16, "Bracket Round of 16", 1, 1,weight)
|
||||
rows << create_matchup_from_seed(8,9, "Bracket Round of 16", 2, 1,weight)
|
||||
rows << create_matchup_from_seed(5,12, "Bracket Round of 16", 3, 1,weight)
|
||||
rows << create_matchup_from_seed(4,14, "Bracket Round of 16", 4, 1,weight)
|
||||
rows << create_matchup_from_seed(3,13, "Bracket Round of 16", 5, 1,weight)
|
||||
rows << create_matchup_from_seed(6,11, "Bracket Round of 16", 6, 1,weight)
|
||||
rows << create_matchup_from_seed(7,10, "Bracket Round of 16", 7, 1,weight)
|
||||
rows << create_matchup_from_seed(2,15, "Bracket Round of 16", 8, 1,weight)
|
||||
end
|
||||
|
||||
def round_two(weight)
|
||||
create_matchup(nil,nil,"Quarter",1,2,weight)
|
||||
create_matchup(nil,nil,"Quarter",2,2,weight)
|
||||
create_matchup(nil,nil,"Quarter",3,2,weight)
|
||||
create_matchup(nil,nil,"Quarter",4,2,weight)
|
||||
create_matchup(nil,nil,"Conso Round of 8",1,2,weight)
|
||||
create_matchup(nil,nil,"Conso Round of 8",2,2,weight)
|
||||
create_matchup(nil,nil,"Conso Round of 8",3,2,weight)
|
||||
create_matchup(nil,nil,"Conso Round of 8",4,2,weight)
|
||||
def round_two(weight, rows)
|
||||
rows << create_matchup(nil,nil,"Quarter",1,2,weight)
|
||||
rows << create_matchup(nil,nil,"Quarter",2,2,weight)
|
||||
rows << create_matchup(nil,nil,"Quarter",3,2,weight)
|
||||
rows << create_matchup(nil,nil,"Quarter",4,2,weight)
|
||||
rows << create_matchup(nil,nil,"Conso Round of 8",1,2,weight)
|
||||
rows << create_matchup(nil,nil,"Conso Round of 8",2,2,weight)
|
||||
rows << create_matchup(nil,nil,"Conso Round of 8",3,2,weight)
|
||||
rows << create_matchup(nil,nil,"Conso Round of 8",4,2,weight)
|
||||
end
|
||||
|
||||
def round_three(weight)
|
||||
create_matchup(nil,nil,"Semis",1,3,weight)
|
||||
create_matchup(nil,nil,"Semis",2,3,weight)
|
||||
create_matchup(nil,nil,"Conso Quarter",1,3,weight)
|
||||
create_matchup(nil,nil,"Conso Quarter",2,3,weight)
|
||||
create_matchup(nil,nil,"Conso Quarter",3,3,weight)
|
||||
create_matchup(nil,nil,"Conso Quarter",4,3,weight)
|
||||
def round_three(weight, rows)
|
||||
rows << create_matchup(nil,nil,"Semis",1,3,weight)
|
||||
rows << create_matchup(nil,nil,"Semis",2,3,weight)
|
||||
rows << create_matchup(nil,nil,"Conso Quarter",1,3,weight)
|
||||
rows << create_matchup(nil,nil,"Conso Quarter",2,3,weight)
|
||||
rows << create_matchup(nil,nil,"Conso Quarter",3,3,weight)
|
||||
rows << create_matchup(nil,nil,"Conso Quarter",4,3,weight)
|
||||
end
|
||||
|
||||
def round_four(weight)
|
||||
create_matchup(nil,nil,"Conso Semis",1,4,weight)
|
||||
create_matchup(nil,nil,"Conso Semis",2,4,weight)
|
||||
def round_four(weight, rows)
|
||||
rows << create_matchup(nil,nil,"Conso Semis",1,4,weight)
|
||||
rows << create_matchup(nil,nil,"Conso Semis",2,4,weight)
|
||||
end
|
||||
|
||||
def round_five(weight)
|
||||
create_matchup(nil,nil,"1/2",1,5,weight)
|
||||
create_matchup(nil,nil,"3/4",1,5,weight)
|
||||
create_matchup(nil,nil,"5/6",1,5,weight)
|
||||
def round_five(weight, rows)
|
||||
rows << create_matchup(nil,nil,"1/2",1,5,weight)
|
||||
rows << create_matchup(nil,nil,"3/4",1,5,weight)
|
||||
rows << create_matchup(nil,nil,"5/6",1,5,weight)
|
||||
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
|
||||
|
||||
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
|
||||
return wrestler.id
|
||||
else
|
||||
@@ -79,13 +84,18 @@ class ModifiedSixteenManMatchGeneration
|
||||
end
|
||||
|
||||
def create_matchup(w1, w2, bracket_position, bracket_position_number,round,weight)
|
||||
@tournament.matches.create(
|
||||
{
|
||||
w1: w1,
|
||||
w2: w2,
|
||||
tournament_id: @tournament.id,
|
||||
weight_id: weight.id,
|
||||
round: round,
|
||||
bracket_position: bracket_position,
|
||||
bracket_position_number: bracket_position_number
|
||||
)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def generation_weights
|
||||
@weights || @tournament.weights.to_a
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,18 +12,19 @@ class PoolBracketGeneration
|
||||
end
|
||||
|
||||
def generateBracketMatches()
|
||||
@rows = []
|
||||
if @pool_bracket_type == "twoPoolsToSemi"
|
||||
return twoPoolsToSemi()
|
||||
twoPoolsToSemi()
|
||||
elsif @pool_bracket_type == "twoPoolsToFinal"
|
||||
return twoPoolsToFinal()
|
||||
twoPoolsToFinal()
|
||||
elsif @pool_bracket_type == "fourPoolsToQuarter"
|
||||
return fourPoolsToQuarter()
|
||||
fourPoolsToQuarter()
|
||||
elsif @pool_bracket_type == "fourPoolsToSemi"
|
||||
return fourPoolsToSemi()
|
||||
fourPoolsToSemi()
|
||||
elsif @pool_bracket_type == "eightPoolsToQuarter"
|
||||
return eightPoolsToQuarter()
|
||||
eightPoolsToQuarter()
|
||||
end
|
||||
return []
|
||||
@rows
|
||||
end
|
||||
|
||||
def twoPoolsToSemi()
|
||||
@@ -86,14 +87,15 @@ class PoolBracketGeneration
|
||||
end
|
||||
|
||||
def createMatchup(w1_name, w2_name, bracket_position, bracket_position_number)
|
||||
@tournament.matches.create(
|
||||
@rows << {
|
||||
loser1_name: w1_name,
|
||||
loser2_name: w2_name,
|
||||
tournament_id: @tournament.id,
|
||||
weight_id: @weight.id,
|
||||
round: @round,
|
||||
bracket_position: bracket_position,
|
||||
bracket_position_number: bracket_position_number
|
||||
)
|
||||
}
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
@@ -1,35 +1,46 @@
|
||||
class PoolGeneration
|
||||
def initialize(weight)
|
||||
def initialize(weight, wrestlers: nil)
|
||||
@weight = weight
|
||||
@tournament = @weight.tournament
|
||||
@pool = 1
|
||||
@wrestlers = wrestlers
|
||||
end
|
||||
|
||||
def generatePools
|
||||
GeneratePoolNumbers.new(@weight).savePoolNumbers
|
||||
GeneratePoolNumbers.new(@weight).savePoolNumbers(wrestlers: wrestlers_for_weight, persist: false)
|
||||
rows = []
|
||||
pools = @weight.pools
|
||||
while @pool <= pools
|
||||
roundRobin
|
||||
rows.concat(roundRobin)
|
||||
@pool += 1
|
||||
end
|
||||
rows
|
||||
end
|
||||
|
||||
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.each_with_index do |b, index|
|
||||
round = index + 1
|
||||
bouts = b.map
|
||||
bouts.each do |bout|
|
||||
if bout[0] != nil and bout[1] != nil
|
||||
@tournament.matches.create(
|
||||
rows << {
|
||||
w1: bout[0].id,
|
||||
w2: bout[1].id,
|
||||
tournament_id: @tournament.id,
|
||||
weight_id: @weight.id,
|
||||
bracket_position: "Pool",
|
||||
round: round)
|
||||
round: round
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
rows
|
||||
end
|
||||
|
||||
def wrestlers_for_weight
|
||||
@wrestlers || @weight.wrestlers.to_a
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,80 +1,97 @@
|
||||
class PoolToBracketGenerateLoserNames
|
||||
def initialize( tournament )
|
||||
@tournament = tournament
|
||||
end
|
||||
|
||||
def assignLoserNamesWeight(weight)
|
||||
matches_by_weight = @tournament.matches.where(weight_id: weight.id)
|
||||
if weight.pool_bracket_type == "twoPoolsToSemi"
|
||||
twoPoolsToSemiLoser(matches_by_weight)
|
||||
elsif (weight.pool_bracket_type == "fourPoolsToQuarter") or (weight.pool_bracket_type == "eightPoolsToQuarter")
|
||||
fourPoolsToQuarterLoser(matches_by_weight)
|
||||
elsif weight.pool_bracket_type == "fourPoolsToSemi"
|
||||
fourPoolsToSemiLoser(matches_by_weight)
|
||||
end
|
||||
saveMatches(matches_by_weight)
|
||||
def initialize(tournament)
|
||||
@tournament = tournament
|
||||
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
|
||||
|
||||
def twoPoolsToSemiLoser(matches_by_weight)
|
||||
match1 = matches_by_weight.select{|m| m.loser1_name == "Winner Pool 1"}.first
|
||||
match2 = matches_by_weight.select{|m| m.loser1_name == "Winner Pool 2"}.first
|
||||
matchChange = matches_by_weight.select{|m| m.bracket_position == "3/4"}.first
|
||||
matchChange.loser1_name = "Loser of #{match1.bout_number}"
|
||||
matchChange.loser2_name = "Loser of #{match2.bout_number}"
|
||||
# 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
|
||||
|
||||
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}"
|
||||
# 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
|
||||
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}"
|
||||
|
||||
if third_fourth
|
||||
s1 = semis.find { |s| s[:bracket_position_number] == 1 }
|
||||
s2 = semis.find { |s| s[:bracket_position_number] == 2 }
|
||||
third_fourth[:loser1_name] = "Loser of #{s1[:bout_number]}" if s1
|
||||
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
|
||||
|
||||
def fourPoolsToSemiLoser(matches_by_weight)
|
||||
semis = matches_by_weight.select{|m| m.bracket_position == "Semis"}
|
||||
thirdFourth = matches_by_weight.select{|m| m.bracket_position == "3/4"}.first
|
||||
consoSemis = matches_by_weight.select{|m| m.bracket_position == "Conso Semis"}
|
||||
seventhEighth = matches_by_weight.select{|m| m.bracket_position == "7/8"}.first
|
||||
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}"
|
||||
def four_pools_to_semi_loser_rows(rows)
|
||||
semis = rows.select { |m| m[:bracket_position] == "Semis" }
|
||||
conso_semis = rows.select { |m| m[:bracket_position] == "Conso Semis" }
|
||||
third_fourth = rows.find { |m| m[:bracket_position] == "3/4" }
|
||||
seventh_eighth = rows.find { |m| m[:bracket_position] == "7/8" }
|
||||
|
||||
if third_fourth
|
||||
s1 = semis.find { |s| s[:bracket_position_number] == 1 }
|
||||
s2 = semis.find { |s| s[:bracket_position_number] == 2 }
|
||||
third_fourth[:loser1_name] = "Loser of #{s1[:bout_number]}" if s1
|
||||
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
|
||||
|
||||
def saveMatches(matches)
|
||||
matches.each do |m|
|
||||
m.save!
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,44 +1,92 @@
|
||||
class PoolToBracketMatchGeneration
|
||||
def initialize( tournament )
|
||||
def initialize(tournament, weights: nil, wrestlers_by_weight_id: nil)
|
||||
@tournament = tournament
|
||||
@weights = weights
|
||||
@wrestlers_by_weight_id = wrestlers_by_weight_id
|
||||
end
|
||||
|
||||
def generatePoolToBracketMatches
|
||||
@tournament.weights.order(:max).each do |weight|
|
||||
PoolGeneration.new(weight).generatePools()
|
||||
last_match = @tournament.matches.where(weight: weight).order(round: :desc).limit(1).first
|
||||
highest_round = last_match.round
|
||||
PoolBracketGeneration.new(weight, highest_round).generateBracketMatches()
|
||||
rows = []
|
||||
generation_weights.each do |weight|
|
||||
wrestlers = wrestlers_for_weight(weight)
|
||||
pool_rows = PoolGeneration.new(weight, wrestlers: wrestlers).generatePools
|
||||
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
|
||||
movePoolSeedsToFinalPoolRound
|
||||
|
||||
movePoolSeedsToFinalPoolRound(rows)
|
||||
rows
|
||||
end
|
||||
|
||||
def movePoolSeedsToFinalPoolRound
|
||||
@tournament.weights.each do |w|
|
||||
setOriginalSeedsToWrestleLastPoolRound(w)
|
||||
def movePoolSeedsToFinalPoolRound(match_rows)
|
||||
generation_weights.each do |w|
|
||||
setOriginalSeedsToWrestleLastPoolRound(w, match_rows)
|
||||
end
|
||||
end
|
||||
|
||||
def setOriginalSeedsToWrestleLastPoolRound(weight)
|
||||
def setOriginalSeedsToWrestleLastPoolRound(weight, match_rows)
|
||||
pool = 1
|
||||
until pool > weight.pools
|
||||
wrestler1 = weight.pool_wrestlers_sorted_by_bracket_line(pool).first
|
||||
wrestler2 = weight.pool_wrestlers_sorted_by_bracket_line(pool).second
|
||||
match = wrestler1.pool_matches.sort_by{|m| m.round}.last
|
||||
if match.w1 != wrestler2.id or match.w2 != wrestler2.id
|
||||
if match.w1 == wrestler1.id
|
||||
SwapWrestlers.new.swap_wrestlers_bracket_lines(match.w2,wrestler2.id)
|
||||
elsif match.w2 == wrestler1.id
|
||||
SwapWrestlers.new.swap_wrestlers_bracket_lines(match.w1,wrestler2.id)
|
||||
end
|
||||
wrestlers = wrestlers_for_weight(weight)
|
||||
weight_pools = weight.pools
|
||||
until pool > weight_pools
|
||||
pool_wrestlers = wrestlers.select { |w| w.pool == pool }.sort_by(&:bracket_line)
|
||||
wrestler1 = pool_wrestlers.first
|
||||
wrestler2 = pool_wrestlers.second
|
||||
if wrestler1 && wrestler2
|
||||
pool_matches = match_rows.select { |row| row[:weight_id] == weight.id && row[:bracket_position] == "Pool" && (row[:w1] == wrestler1.id || row[:w2] == wrestler1.id) }
|
||||
match = pool_matches.max_by { |row| row[:round] }
|
||||
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
|
||||
pool += 1
|
||||
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
|
||||
PoolToBracketGenerateLoserNames.new(@tournament).assignLoserNames
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
@@ -37,11 +37,19 @@ class TournamentBackupService
|
||||
attributes: @tournament.attributes,
|
||||
schools: @tournament.schools.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|
|
||||
rule.attributes.merge(
|
||||
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
|
||||
end
|
||||
)
|
||||
@@ -54,11 +62,11 @@ class TournamentBackupService
|
||||
end,
|
||||
matches: @tournament.matches.sort_by(&:bout_number).map do |match|
|
||||
match.attributes.merge(
|
||||
w1_name: Wrestler.find_by(id: match.w1)&.name,
|
||||
w2_name: Wrestler.find_by(id: match.w2)&.name,
|
||||
winner_name: Wrestler.find_by(id: match.winner_id)&.name,
|
||||
weight: Weight.find_by(id: match.weight_id)&.attributes,
|
||||
mat: Mat.find_by(id: match.mat_id)&.attributes
|
||||
w1_name: match.wrestler1&.name,
|
||||
w2_name: match.wrestler2&.name,
|
||||
winner_name: match.winner&.name,
|
||||
weight: match.weight&.attributes,
|
||||
mat: match.mat&.attributes
|
||||
)
|
||||
end
|
||||
}
|
||||
|
||||
@@ -3,30 +3,36 @@ class TournamentSeeding
|
||||
@tournament = tournament
|
||||
end
|
||||
|
||||
def set_seeds
|
||||
@tournament.weights.each do |weight|
|
||||
def set_seeds(weights: nil, persist: true)
|
||||
weights_to_seed = weights || @tournament.weights.includes(:wrestlers)
|
||||
updated_wrestlers = []
|
||||
|
||||
weights_to_seed.each do |weight|
|
||||
wrestlers = weight.wrestlers
|
||||
bracket_size = weight.calculate_bracket_size
|
||||
|
||||
wrestlers = reset_bracket_line_for_lines_higher_than_bracket_size(wrestlers, bracket_size)
|
||||
wrestlers = set_original_seed_to_bracket_line(wrestlers)
|
||||
wrestlers = random_seeding(wrestlers, bracket_size)
|
||||
wrestlers.each(&:save)
|
||||
updated_wrestlers.concat(wrestlers)
|
||||
end
|
||||
|
||||
persist_bracket_lines(updated_wrestlers) if persist
|
||||
updated_wrestlers
|
||||
end
|
||||
|
||||
def random_seeding(wrestlers, bracket_size)
|
||||
half_of_bracket = bracket_size / 2
|
||||
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
|
||||
wrestlers_with_bracket_lines = wrestlers.select{|w| w.bracket_line != nil }
|
||||
wrestlers_with_bracket_lines.each do |wrestler|
|
||||
available_bracket_lines.delete(wrestler.bracket_line)
|
||||
first_half_available_bracket_lines.delete(wrestler.bracket_line)
|
||||
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 }
|
||||
if @tournament.tournament_type == "Pool to bracket"
|
||||
wrestlers_without_bracket_lines.shuffle.each do |wrestler|
|
||||
@@ -38,15 +44,10 @@ class TournamentSeeding
|
||||
else
|
||||
# Iterrate over the list randomly
|
||||
wrestlers_without_bracket_lines.shuffle.each do |wrestler|
|
||||
if first_half_available_bracket_lines.size > 0
|
||||
random_available_bracket_line = first_half_available_bracket_lines.sample
|
||||
wrestler.bracket_line = random_available_bracket_line
|
||||
available_bracket_lines.delete(random_available_bracket_line)
|
||||
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)
|
||||
if available_bracket_lines_to_use.size > 0
|
||||
bracket_line_to_use = available_bracket_lines_to_use.first
|
||||
wrestler.bracket_line = bracket_line_to_use
|
||||
available_bracket_lines_to_use.delete(bracket_line_to_use)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -81,4 +82,39 @@ class TournamentSeeding
|
||||
end
|
||||
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
|
||||
|
||||
@@ -14,10 +14,10 @@ class WipeTournamentMatches
|
||||
end
|
||||
|
||||
def wipeMatches
|
||||
@tournament.matches.destroy_all
|
||||
@tournament.destroy_all_matches
|
||||
end
|
||||
|
||||
def resetSchoolScores
|
||||
@tournament.schools.update_all("score = 0.0")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -41,20 +41,20 @@ class WrestlingdevImporter
|
||||
@tournament.matches.destroy_all
|
||||
@tournament.mat_assignment_rules.destroy_all # Explicitly destroy rules (might be redundant if Mat cascades)
|
||||
@tournament.delegates.destroy_all
|
||||
@tournament.tournament_backups.destroy_all
|
||||
@tournament.tournament_job_statuses.destroy_all
|
||||
# Note: Teampointadjusts are deleted via School/Wrestler cascade
|
||||
end
|
||||
|
||||
def parse_data
|
||||
parse_tournament(@import_data["tournament"]["attributes"])
|
||||
parse_schools(@import_data["tournament"]["schools"])
|
||||
parse_weights(@import_data["tournament"]["weights"])
|
||||
parse_mats(@import_data["tournament"]["mats"])
|
||||
parse_wrestlers(@import_data["tournament"]["wrestlers"])
|
||||
parse_matches(@import_data["tournament"]["matches"])
|
||||
parse_mat_assignment_rules(@import_data["tournament"]["mat_assignment_rules"])
|
||||
end
|
||||
def parse_data
|
||||
parse_tournament(@import_data["tournament"]["attributes"])
|
||||
parse_schools(@import_data["tournament"]["schools"])
|
||||
parse_weights(@import_data["tournament"]["weights"])
|
||||
parse_mats(@import_data["tournament"]["mats"])
|
||||
parse_wrestlers(@import_data["tournament"]["wrestlers"])
|
||||
parse_matches(@import_data["tournament"]["matches"])
|
||||
apply_mat_queues
|
||||
parse_mat_assignment_rules(@import_data["tournament"]["mat_assignment_rules"])
|
||||
end
|
||||
|
||||
def parse_tournament(attributes)
|
||||
attributes.except!("id")
|
||||
@@ -75,28 +75,47 @@ class WrestlingdevImporter
|
||||
end
|
||||
end
|
||||
|
||||
def parse_mats(mats)
|
||||
mats.each do |mat_attributes|
|
||||
mat_attributes.except!("id")
|
||||
Mat.create(mat_attributes.merge(tournament_id: @tournament.id))
|
||||
end
|
||||
end
|
||||
def parse_mats(mats)
|
||||
@mat_queue_bout_numbers = {}
|
||||
mats.each do |mat_attributes|
|
||||
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))
|
||||
if mat_name && queue_bout_numbers
|
||||
@mat_queue_bout_numbers[mat_name] = queue_bout_numbers
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def parse_mat_assignment_rules(mat_assignment_rules)
|
||||
mat_assignment_rules.each do |rule_attributes|
|
||||
mat_name = rule_attributes.dig("mat", "name")
|
||||
mat = Mat.find_by(name: mat_name, tournament_id: @tournament.id)
|
||||
|
||||
# Map max values of weight_classes to their new IDs
|
||||
new_weight_classes = rule_attributes["weight_classes"].map do |max_value|
|
||||
Weight.find_by(max: max_value, tournament_id: @tournament.id)&.id
|
||||
end.compact
|
||||
|
||||
# Extract bracket_positions and rounds
|
||||
|
||||
# Prefer the new "weight_class_maxes" key emitted by backups (human-readable
|
||||
# max values). If not present, fall back to the legacy "weight_classes"
|
||||
# value which may be a comma-separated string or an array of IDs.
|
||||
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 (leave as-is; model will coerce if needed)
|
||||
bracket_positions = rule_attributes["bracket_positions"]
|
||||
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(
|
||||
rule_attributes.merge(
|
||||
@@ -122,9 +141,9 @@ class WrestlingdevImporter
|
||||
end
|
||||
end
|
||||
|
||||
def parse_matches(matches)
|
||||
matches.each do |match_attributes|
|
||||
next unless match_attributes # Skip if match_attributes is nil
|
||||
def parse_matches(matches)
|
||||
matches.each do |match_attributes|
|
||||
next unless match_attributes # Skip if match_attributes is nil
|
||||
|
||||
weight = Weight.find_by(max: match_attributes.dig("weight", "max"), tournament_id: @tournament.id)
|
||||
mat = Mat.find_by(name: match_attributes.dig("mat", "name"), tournament_id: @tournament.id)
|
||||
@@ -143,6 +162,53 @@ class WrestlingdevImporter
|
||||
w2: w2&.id,
|
||||
winner_id: winner&.id
|
||||
))
|
||||
end
|
||||
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
|
||||
|
||||
@@ -3,11 +3,13 @@ class GeneratePoolNumbers
|
||||
@weight = weight
|
||||
end
|
||||
|
||||
def savePoolNumbers
|
||||
@weight.wrestlers.each do |wrestler|
|
||||
def savePoolNumbers(wrestlers: nil, persist: true)
|
||||
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.save
|
||||
end
|
||||
persist_pool_numbers(wrestlers_to_update) if persist
|
||||
wrestlers_to_update
|
||||
end
|
||||
|
||||
def get_wrestler_pool_number(number_of_pools, wrestler_seed)
|
||||
@@ -36,4 +38,20 @@ class GeneratePoolNumbers
|
||||
|
||||
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
|
||||
|
||||
@@ -5,7 +5,7 @@ class CalculateWrestlerTeamScore
|
||||
end
|
||||
|
||||
def totalScore
|
||||
if @wrestler.extra or @wrestler.matches.count == 0
|
||||
if @wrestler.extra or @wrestler.all_matches.count == 0
|
||||
return 0
|
||||
else
|
||||
earnedPoints - deductedPoints
|
||||
@@ -54,29 +54,20 @@ class CalculateWrestlerTeamScore
|
||||
def byePoints
|
||||
points = 0
|
||||
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
|
||||
end
|
||||
end
|
||||
if @tournament.tournament_type.include? "Regular 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
|
||||
if @tournament.tournament_type.include? "Double Elimination"
|
||||
if @wrestler.championship_advancement_wins.any? &&
|
||||
@wrestler.championship_byes.any? &&
|
||||
any_bye_round_had_wrestled_match?(@wrestler.championship_byes)
|
||||
points += 2
|
||||
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 they have a win in the consolation round or if they got a bye all the way to 3rd/4th match and won
|
||||
points += @wrestler.consolation_byes.size * 1
|
||||
end
|
||||
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
|
||||
if @wrestler.consolation_advancement_wins.any? &&
|
||||
@wrestler.consolation_byes.any? &&
|
||||
any_bye_round_had_wrestled_match?(@wrestler.consolation_byes)
|
||||
points += 1
|
||||
end
|
||||
end
|
||||
return points
|
||||
@@ -86,4 +77,30 @@ class CalculateWrestlerTeamScore
|
||||
(@wrestler.pin_wins.size * 2) + (@wrestler.tech_wins.size * 1.5) + (@wrestler.major_wins.size * 1)
|
||||
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
|
||||
|
||||
@@ -27,10 +27,22 @@ class DoubleEliminationPlacementPoints
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def won_bracket_position_size(bracket_position_name)
|
||||
@wrestler.matches_won.select{|m| m.bracket_position == bracket_position_name}.size
|
||||
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
|
||||
@@ -28,7 +28,7 @@ json.cache! ["api_tournament", @tournament] do
|
||||
|
||||
json.mats @mats do |mat|
|
||||
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.w1_name match.w1_name
|
||||
json.w2_name match.w2_name
|
||||
|
||||
@@ -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 -->
|
||||
<style>
|
||||
.fa {
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
<a class="dropdown-toggle" data-toggle="dropdown" href="#"><%= current_user.email %>
|
||||
<span class="caret"></span></a>
|
||||
<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 "My tournaments and schools", "/static_pages/my_tournaments" %></li>
|
||||
<li><%= link_to "My tournaments and schools", "/static_pages/my_tournaments" %></li>
|
||||
</ul>
|
||||
</li>
|
||||
<% else %>
|
||||
|
||||
@@ -27,9 +27,10 @@
|
||||
<% end %>
|
||||
<li><%= link_to "All Brackets (Printable)", "/tournaments/#{@tournament.id}/all_brackets?print=true", target: :_blank %></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><%= link_to " Bout Board" , "/tournaments/#{@tournament.id}/up_matches", class: "fas fa-list-alt" %></li>
|
||||
<% end %>
|
||||
</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 %>
|
||||
<% if can? :manage, @tournament %>
|
||||
<li class="dropdown">
|
||||
<a class="dropdown-toggle" data-toggle="dropdown" href="#director"><i class="fas fa-tools"> Director Links</i>
|
||||
@@ -38,12 +39,13 @@
|
||||
<li><strong>Pages</strong></li>
|
||||
<li></span> <%= link_to "Edit Tournament Info", edit_tournament_path(@tournament) %></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 "Full Screen Bout Board" , "/tournaments/#{@tournament.id}/up_matches?print=true" , target: :_blank %></li>
|
||||
<li><%= link_to "Deduct Team Points" , "/tournaments/#{@tournament.id}/teampointadjust" %></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 "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 "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 "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 %>
|
||||
<li><%= link_to "Tournament Delegation" , "/tournaments/#{@tournament.id}/delegate" %></li>
|
||||
<% end %>
|
||||
@@ -55,13 +57,13 @@
|
||||
<% end %>
|
||||
<% end %>
|
||||
<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 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 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 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 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: { 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: { 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: { turbo_method: :get, turbo_confirm: 'Are you sure? This will delete all current weights.' } %></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 "Generate Brackets" , "/tournaments/#{@tournament.id}/generate_matches", data: { confirm: 'Are you sure? This will delete all current matches.' } %></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: { 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>
|
||||
</ul>
|
||||
<% end %>
|
||||
@@ -69,4 +71,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
<% if params[:print] %>
|
||||
<head>
|
||||
<%= csrf_meta_tags %>
|
||||
<%= action_cable_meta_tag %>
|
||||
<title>WrestlingDev</title>
|
||||
<%= stylesheet_link_tag "application", media: "all",
|
||||
"data-turbolinks-track" => true %>
|
||||
<%= javascript_include_tag "application", "data-turbolinks-track" => true %>
|
||||
<%= stylesheet_link_tag "application", media: "all", "data-turbo-track": "reload" %>
|
||||
<%= javascript_importmap_tags %>
|
||||
<%= render 'layouts/cdn' %>
|
||||
<%= render 'layouts/shim' %>
|
||||
</head>
|
||||
@@ -16,12 +16,13 @@
|
||||
<% else %>
|
||||
<head>
|
||||
<title>WrestlingDev</title>
|
||||
<%= action_cable_meta_tag %>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<% if Rails.env.production? %>
|
||||
<%= render 'layouts/analytics' %>
|
||||
<% end %>
|
||||
<%= stylesheet_link_tag "application" %>
|
||||
<%= javascript_include_tag "application" %>
|
||||
<%= stylesheet_link_tag "application", media: "all", "data-turbo-track": "reload" %>
|
||||
<%= javascript_importmap_tags %>
|
||||
<%= csrf_meta_tags %>
|
||||
<%= render 'layouts/cdn' %>
|
||||
<%= render 'layouts/shim' %>
|
||||
@@ -35,7 +36,11 @@
|
||||
|
||||
<div id="page-content">
|
||||
<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 class="row no-margin">
|
||||
<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">×</a><%= alert %></p>
|
||||
<% end %>
|
||||
<div id="view" style="overflow-x: auto; overflow-y: hidden;"> <%# Horizontal scroll only %>
|
||||
<br><br>
|
||||
<%= yield %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,4 +62,3 @@
|
||||
</body>
|
||||
<% end %>
|
||||
</html>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user