mirror of
https://github.com/jcwimer/wrestlingApp
synced 2026-04-22 06:30:23 +00:00
Compare commits
195 Commits
c0ad06cea7
...
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 | |||
| ed7186e5ce | |||
| 6e61a7245a | |||
| 4828d9b876 | |||
| 2d433b680a | |||
| 9c25a6cc39 | |||
| f32e711d2b | |||
| 010d9a5f6b | |||
| 91e1939e69 | |||
| 934b34d0b7 | |||
| 63b91f7ddd | |||
| dd0585ed55 | |||
| 735f7090b2 | |||
| 690e497654 | |||
| 54655a2ea9 | |||
| 5114ed7b08 | |||
| f6bc7aa1a4 | |||
| 3248fdf7ca | |||
| 60814fdd94 | |||
| cc62e1c2f1 | |||
| d57b2e6e6f | |||
| fd1a7c43ff | |||
| b241818c21 | |||
| 288cb6704e | |||
| c45ec8ab38 | |||
| e612a4b10e | |||
| 38785924ed | |||
| 275847befe | |||
| b5150a3a37 | |||
| 6adcc709e7 | |||
| bb3b05ee81 | |||
| 490bd762f6 | |||
| 210e763d4c | |||
| f3e0f5d4c5 | |||
| 50055fc278 | |||
| 49fbf6735d | |||
| 698576dac9 | |||
| e986ce225b | |||
| 15bf39014f | |||
| 13bb8067fb | |||
| 4f0f69223d | |||
| 7e6d7ddfbb | |||
| 6242100e01 | |||
| d34fd873c0 | |||
| a851436c0c | |||
| 5f049793c8 | |||
| fc43de71bb | |||
| 5219c2b1e3 | |||
| f18802a933 | |||
| f6ef471591 | |||
| f92faf4f5c | |||
| db440c0603 | |||
| 5d37e5e0d8 | |||
| ad8e486205 | |||
| bb548be81b | |||
| b1f8cc3532 | |||
| b974773289 | |||
| 1f18f338ff | |||
| 6cb523e350 | |||
| b108545034 | |||
| e46180e9ea | |||
| fc3623008b | |||
| 508dd493a1 | |||
| 92bd1ec3c9 | |||
| 87353da05e | |||
| 57baa3ea45 | |||
| 2eb38ce788 | |||
| e047383fe4 | |||
| 05b42dbf0e | |||
| f011dae730 | |||
| 1078dc9e97 | |||
| c133a8b051 | |||
| 1d0cff0e6f | |||
| 1ee886abd3 | |||
| 86bb0b8410 | |||
| c3480909a2 | |||
| bde2b6d8c4 | |||
| 288c144f53 | |||
| 371b44977f | |||
| eb56b9d16c | |||
| 6b57246080 | |||
| ffb7d8be5b | |||
| 22f5733160 | |||
| b5ab929270 | |||
| 1d646cb05d | |||
| c328bbd91c | |||
| d675337d7a | |||
| 2dccf8dd55 | |||
| 6eb71ef59e | |||
| 67bee921ab | |||
| 8672bdd73d | |||
| 9c1563febf | |||
| 994fc18365 | |||
| 645fb59e5b | |||
| 05acebbf5b | |||
| 9c5ac6c1aa | |||
| 85b0da6a30 | |||
| 907a2eadef | |||
| e6797fcce9 | |||
| add48b95c3 | |||
| 85701e194a | |||
| 4d3f93a109 | |||
| f9095d8c99 | |||
| a3724914aa | |||
| 6e712cd199 | |||
| a3391afe02 | |||
| cce2e5b5f8 | |||
| 2cd62bbbd5 | |||
| d6a273c964 | |||
| 78bd51cafb | |||
| ae70128479 | |||
| 9fec6c079f | |||
| 58d088907a | |||
| 7220ffe3c6 | |||
| 0c9349c871 | |||
| d42b683f67 | |||
| f0e8c99b9f | |||
| 6db1dacbb6 | |||
| eae2c6756c | |||
| 194fbca978 | |||
| 1c8e3af5f4 | |||
| b571bcc749 | |||
| a6cc05424c | |||
| efeea574ed | |||
| 54e755ba3a | |||
| 8430949a37 | |||
| 5ef27f19cc | |||
| 09651ff12e |
@@ -1,39 +0,0 @@
|
||||
version: 2
|
||||
workflows: # Define a Workflow running the build job, then the deploy job
|
||||
version: 2
|
||||
build-deploy:
|
||||
jobs:
|
||||
- build
|
||||
- deploy:
|
||||
requires:
|
||||
- build
|
||||
filters:
|
||||
branches:
|
||||
only: master # Only deploys when the commit is on the Master branch
|
||||
jobs:
|
||||
build:
|
||||
machine: true
|
||||
steps:
|
||||
- checkout
|
||||
- run: |
|
||||
bash bin/run-tests-with-docker.sh
|
||||
|
||||
# https://github.com/circleci/circleci-docs/blob/master/jekyll/_cci1/continuous-deployment-with-heroku.md
|
||||
deploy:
|
||||
machine: true
|
||||
steps:
|
||||
- checkout
|
||||
- run: |
|
||||
cat >~/.netrc <<EOF
|
||||
machine api.heroku.com
|
||||
login $HEROKU_EMAIL
|
||||
password $HEROKU_TOKEN
|
||||
machine git.heroku.com
|
||||
login $HEROKU_EMAIL
|
||||
password $HEROKU_TOKEN
|
||||
EOF
|
||||
chmod 600 ~/.netrc # Heroku cli complains about permissions without this
|
||||
heroku git:remote -a wrestlingapp
|
||||
git push heroku master
|
||||
sleep 20s
|
||||
heroku run rake db:migrate --app wrestlingapp
|
||||
11
.cursorrules
Normal file
11
.cursorrules
Normal file
@@ -0,0 +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.
|
||||
- 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
|
||||
20
.gitignore
vendored
20
.gitignore
vendored
@@ -11,10 +11,30 @@ vendor/bundle/
|
||||
# Ignore the default SQLite database.
|
||||
db/*.sqlite3
|
||||
db/*.sqlite3-journal
|
||||
db/*.sqlite3-shm
|
||||
db/*.sqlite3-wal
|
||||
|
||||
# Ignore all logfiles and tempfiles.
|
||||
log/*.log
|
||||
log/*.log*
|
||||
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
.ruby-gemset
Normal file
1
.ruby-gemset
Normal file
@@ -0,0 +1 @@
|
||||
wrestlingdev
|
||||
1
.ruby-version
Normal file
1
.ruby-version
Normal file
@@ -0,0 +1 @@
|
||||
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.
|
||||
66
Gemfile
66
Gemfile
@@ -1,23 +1,36 @@
|
||||
source 'https://rubygems.org'
|
||||
|
||||
ruby '3.0.0'
|
||||
ruby '4.0.1'
|
||||
# Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
|
||||
gem 'rails', '6.1.4.1'
|
||||
# Use sqlite3 as the database for Active Record
|
||||
#gem 'sqlite3', '~> 1.3', '< 1.4', :group => :development
|
||||
gem 'sqlite3', :group => :development
|
||||
gem 'rails', '8.1.2'
|
||||
|
||||
# Added in rails 7.1
|
||||
gem 'rails-html-sanitizer'
|
||||
|
||||
# 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
|
||||
|
||||
# Use sqlite3 as the database for Active Record
|
||||
# Use sqlite3 version compatible with Rails 8
|
||||
gem 'sqlite3', ">= 2.1", :group => :development
|
||||
|
||||
# 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
|
||||
|
||||
# Use Uglifier as compressor for JavaScript assets
|
||||
gem 'uglifier'
|
||||
# Use CoffeeScript for .js.coffee assets and views
|
||||
gem 'coffee-rails'
|
||||
# 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.
|
||||
@@ -27,7 +40,7 @@ gem 'sdoc', :group => :doc
|
||||
gem 'spring', :group => :development
|
||||
|
||||
# Use ActiveModel has_secure_password
|
||||
# gem 'bcrypt', '~> 3.1.7'
|
||||
gem 'bcrypt', '~> 3.1.7'
|
||||
|
||||
# Use unicorn as the app server
|
||||
# gem 'unicorn'
|
||||
@@ -42,27 +55,40 @@ gem 'spring', :group => :development
|
||||
group :production do
|
||||
gem 'rails_12factor'
|
||||
gem 'mysql2'
|
||||
gem 'therubyracer'
|
||||
gem 'newrelic_rpm'
|
||||
gem 'dalli'
|
||||
end
|
||||
|
||||
gem 'solid_cache'
|
||||
|
||||
gem 'influxdb-rails'
|
||||
gem 'devise'
|
||||
# Authentication
|
||||
# gem 'devise' # Removed - replaced with Rails built-in authentication
|
||||
|
||||
# Role Management
|
||||
gem 'cancancan'
|
||||
gem 'round_robin_tournament'
|
||||
gem 'rb-readline'
|
||||
gem 'delayed_job_active_record'
|
||||
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'
|
||||
gem 'delayed_job_web'
|
||||
# Solid Queue UI
|
||||
gem "mission_control-jobs"
|
||||
|
||||
|
||||
group :development do
|
||||
gem 'rubocop'
|
||||
# gem 'rubocop'
|
||||
gem 'bullet'
|
||||
gem 'brakeman'
|
||||
gem 'bundler-audit'
|
||||
gem 'rubocop'
|
||||
end
|
||||
|
||||
group :development, :test do
|
||||
gem 'mocha'
|
||||
# rails-controller-testing is needed for assert_template
|
||||
gem 'rails-controller-testing'
|
||||
end
|
||||
|
||||
534
Gemfile.lock
534
Gemfile.lock
@@ -1,281 +1,391 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (6.1.4.1)
|
||||
actionpack (= 6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
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)
|
||||
actionmailbox (6.1.4.1)
|
||||
actionpack (= 6.1.4.1)
|
||||
activejob (= 6.1.4.1)
|
||||
activerecord (= 6.1.4.1)
|
||||
activestorage (= 6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
mail (>= 2.7.1)
|
||||
actionmailer (6.1.4.1)
|
||||
actionpack (= 6.1.4.1)
|
||||
actionview (= 6.1.4.1)
|
||||
activejob (= 6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
actionpack (6.1.4.1)
|
||||
actionview (= 6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
rack (~> 2.0, >= 2.0.9)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
||||
actiontext (6.1.4.1)
|
||||
actionpack (= 6.1.4.1)
|
||||
activerecord (= 6.1.4.1)
|
||||
activestorage (= 6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
zeitwerk (~> 2.6)
|
||||
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.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.1.2)
|
||||
actionview (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
rack (>= 2.2.4)
|
||||
rack-session (>= 1.0.1)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
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.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.1, >= 1.2.0)
|
||||
activejob (6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
activerecord (6.1.4.1)
|
||||
activemodel (= 6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
activestorage (6.1.4.1)
|
||||
actionpack (= 6.1.4.1)
|
||||
activejob (= 6.1.4.1)
|
||||
activerecord (= 6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
marcel (~> 1.0.0)
|
||||
mini_mime (>= 1.1.0)
|
||||
activesupport (6.1.4.1)
|
||||
concurrent-ruby (~> 1.0, >= 1.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.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
marcel (~> 1.0)
|
||||
activesupport (8.1.2)
|
||||
base64
|
||||
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)
|
||||
tzinfo (~> 2.0)
|
||||
zeitwerk (~> 2.3)
|
||||
ast (2.4.2)
|
||||
bcrypt (3.1.16)
|
||||
brakeman (5.1.1)
|
||||
builder (3.2.4)
|
||||
bullet (6.1.5)
|
||||
securerandom (>= 0.3)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
uri (>= 0.13.1)
|
||||
ast (2.4.3)
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.22)
|
||||
bigdecimal (4.1.1)
|
||||
bootsnap (1.23.0)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (8.0.4)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
bullet (8.1.0)
|
||||
activesupport (>= 3.0.0)
|
||||
uniform_notifier (~> 1.11)
|
||||
bundler-audit (0.8.0)
|
||||
bundler (>= 1.2.0, < 3)
|
||||
bundler-audit (0.9.3)
|
||||
bundler (>= 1.2.0)
|
||||
thor (~> 1.0)
|
||||
cancancan (3.3.0)
|
||||
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.1.9)
|
||||
cancancan (3.6.1)
|
||||
chunky_png (1.4.0)
|
||||
concurrent-ruby (1.3.6)
|
||||
connection_pool (3.0.2)
|
||||
crass (1.0.6)
|
||||
daemons (1.4.1)
|
||||
dalli (2.7.11)
|
||||
delayed_job (4.1.9)
|
||||
activesupport (>= 3.0, < 6.2)
|
||||
delayed_job_active_record (4.1.6)
|
||||
activerecord (>= 3.0, < 6.2)
|
||||
delayed_job (>= 3.0, < 5)
|
||||
delayed_job_web (1.4.4)
|
||||
activerecord (> 3.0.0)
|
||||
delayed_job (> 2.0.3)
|
||||
rack-protection (>= 1.5.5)
|
||||
sinatra (>= 1.4.4)
|
||||
devise (4.8.0)
|
||||
bcrypt (~> 3.0)
|
||||
orm_adapter (~> 0.1)
|
||||
railties (>= 4.1.0)
|
||||
responders
|
||||
warden (~> 1.2.3)
|
||||
erubi (1.10.0)
|
||||
execjs (2.8.1)
|
||||
globalid (0.5.2)
|
||||
activesupport (>= 5.0)
|
||||
i18n (1.8.10)
|
||||
date (3.5.1)
|
||||
drb (2.2.3)
|
||||
erb (6.0.2)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.4.0)
|
||||
tzinfo
|
||||
fugit (1.12.1)
|
||||
et-orbi (~> 1.4)
|
||||
raabro (~> 1.4)
|
||||
globalid (1.3.0)
|
||||
activesupport (>= 6.1)
|
||||
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.2)
|
||||
influxdb-rails (1.0.3)
|
||||
influxdb (~> 0.6, >= 0.6.4)
|
||||
railties (>= 5.0)
|
||||
jbuilder (2.11.2)
|
||||
activesupport (>= 5.0.0)
|
||||
jquery-rails (4.4.0)
|
||||
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.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)
|
||||
libv8 (3.16.14.19-x86_64-linux)
|
||||
loofah (2.12.0)
|
||||
json (2.19.3)
|
||||
language_server-protocol (3.17.0.5)
|
||||
lint_roller (1.1.0)
|
||||
logger (1.7.0)
|
||||
loofah (2.25.1)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.5.9)
|
||||
mail (2.7.1)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.9.0)
|
||||
logger
|
||||
mini_mime (>= 0.1.1)
|
||||
marcel (1.0.1)
|
||||
method_source (1.0.0)
|
||||
mini_mime (1.1.1)
|
||||
minitest (5.14.4)
|
||||
mustermann (1.1.1)
|
||||
ruby2_keywords (~> 0.0.1)
|
||||
mysql2 (0.5.3)
|
||||
newrelic_rpm (7.2.0)
|
||||
nio4r (2.5.8)
|
||||
nokogiri (1.12.4-x86_64-linux)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
marcel (1.1.0)
|
||||
mini_mime (1.1.5)
|
||||
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.7)
|
||||
bigdecimal
|
||||
net-imap (0.6.3)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
net-protocol
|
||||
net-protocol (0.2.2)
|
||||
timeout
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.2-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
orm_adapter (0.5.0)
|
||||
parallel (1.20.1)
|
||||
parser (3.0.2.0)
|
||||
nokogiri (1.19.2-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.2-arm-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.2-arm-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.2-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.2-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.2-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.2-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
parallel (2.0.1)
|
||||
parser (3.3.11.1)
|
||||
ast (~> 2.4.1)
|
||||
passenger (6.0.10)
|
||||
racc
|
||||
pp (0.6.3)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.9.0)
|
||||
propshaft (1.3.1)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
rack
|
||||
rake (>= 0.8.1)
|
||||
puma (5.4.0)
|
||||
psych (5.3.1)
|
||||
date
|
||||
stringio
|
||||
puma (8.0.0)
|
||||
nio4r (~> 2.0)
|
||||
racc (1.5.2)
|
||||
rack (2.2.3)
|
||||
rack-protection (2.1.0)
|
||||
rack
|
||||
rack-test (1.1.0)
|
||||
rack (>= 1.0, < 3)
|
||||
rails (6.1.4.1)
|
||||
actioncable (= 6.1.4.1)
|
||||
actionmailbox (= 6.1.4.1)
|
||||
actionmailer (= 6.1.4.1)
|
||||
actionpack (= 6.1.4.1)
|
||||
actiontext (= 6.1.4.1)
|
||||
actionview (= 6.1.4.1)
|
||||
activejob (= 6.1.4.1)
|
||||
activemodel (= 6.1.4.1)
|
||||
activerecord (= 6.1.4.1)
|
||||
activestorage (= 6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
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.3.1)
|
||||
rack (>= 3)
|
||||
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 (= 6.1.4.1)
|
||||
sprockets-rails (>= 2.0.0)
|
||||
rails-dom-testing (2.0.3)
|
||||
activesupport (>= 4.2.0)
|
||||
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.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.4.2)
|
||||
loofah (~> 2.3)
|
||||
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 (6.1.4.1)
|
||||
actionpack (= 6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
method_source
|
||||
rake (>= 0.13)
|
||||
thor (~> 1.0)
|
||||
rainbow (3.0.0)
|
||||
rake (13.0.6)
|
||||
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)
|
||||
rainbow (3.1.1)
|
||||
rake (13.4.1)
|
||||
rb-readline (0.5.5)
|
||||
rdoc (6.3.2)
|
||||
ref (2.0.0)
|
||||
regexp_parser (2.1.1)
|
||||
responders (3.0.1)
|
||||
actionpack (>= 5.0)
|
||||
railties (>= 5.0)
|
||||
rexml (3.2.5)
|
||||
round_robin_tournament (0.1.1)
|
||||
rubocop (1.20.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.0.0.0)
|
||||
rdoc (7.2.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
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 (>= 1.8, < 3.0)
|
||||
rexml
|
||||
rubocop-ast (>= 1.9.1, < 2.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.49.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 1.4.0, < 3.0)
|
||||
rubocop-ast (1.11.0)
|
||||
parser (>= 3.0.1.1)
|
||||
ruby-progressbar (1.11.0)
|
||||
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)
|
||||
sdoc (2.2.0)
|
||||
sdoc (2.6.5)
|
||||
rdoc (>= 5.0)
|
||||
sinatra (2.1.0)
|
||||
mustermann (~> 1.0)
|
||||
rack (~> 2.2)
|
||||
rack-protection (= 2.1.0)
|
||||
tilt (~> 2.0)
|
||||
spring (2.1.1)
|
||||
sprockets (4.0.2)
|
||||
securerandom (0.4.1)
|
||||
solid_cable (3.0.12)
|
||||
actioncable (>= 7.2)
|
||||
activejob (>= 7.2)
|
||||
activerecord (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
solid_cache (1.0.10)
|
||||
activejob (>= 7.2)
|
||||
activerecord (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
solid_queue (1.4.0)
|
||||
activejob (>= 7.1)
|
||||
activerecord (>= 7.1)
|
||||
concurrent-ruby (>= 1.3.1)
|
||||
fugit (~> 1.11)
|
||||
railties (>= 7.1)
|
||||
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)
|
||||
rack (> 1, < 3)
|
||||
sprockets-rails (3.2.2)
|
||||
actionpack (>= 4.0)
|
||||
activesupport (>= 4.0)
|
||||
sprockets (>= 3.0.0)
|
||||
sqlite3 (1.4.2)
|
||||
therubyracer (0.12.3)
|
||||
libv8 (~> 3.16.14.15)
|
||||
ref
|
||||
thor (1.1.0)
|
||||
tilt (2.0.10)
|
||||
turbolinks (5.2.1)
|
||||
turbolinks-source (~> 5.2)
|
||||
turbolinks-source (5.2.0)
|
||||
tzinfo (2.0.4)
|
||||
concurrent-ruby (~> 1.0)
|
||||
tzinfo-data (1.2021.1)
|
||||
tzinfo-data (1.2026.1)
|
||||
tzinfo (>= 1.0.0)
|
||||
uglifier (4.2.0)
|
||||
execjs (>= 0.3.0, < 3)
|
||||
unicode-display_width (2.0.0)
|
||||
uniform_notifier (1.14.2)
|
||||
warden (1.2.9)
|
||||
rack (>= 2.0.9)
|
||||
websocket-driver (0.7.5)
|
||||
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.8.0)
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
zeitwerk (2.4.2)
|
||||
zeitwerk (2.7.5)
|
||||
|
||||
PLATFORMS
|
||||
x86_64-linux
|
||||
aarch64-linux-gnu
|
||||
aarch64-linux-musl
|
||||
arm-linux-gnu
|
||||
arm-linux-musl
|
||||
arm64-darwin
|
||||
x86_64-darwin
|
||||
x86_64-linux-gnu
|
||||
x86_64-linux-musl
|
||||
|
||||
DEPENDENCIES
|
||||
bcrypt (~> 3.1.7)
|
||||
bootsnap
|
||||
brakeman
|
||||
bullet
|
||||
bundler-audit
|
||||
cancancan
|
||||
coffee-rails
|
||||
daemons
|
||||
dalli
|
||||
delayed_job_active_record
|
||||
delayed_job_web
|
||||
devise
|
||||
importmap-rails
|
||||
influxdb-rails
|
||||
jbuilder
|
||||
jquery-rails
|
||||
mission_control-jobs
|
||||
mocha
|
||||
mysql2
|
||||
newrelic_rpm
|
||||
passenger
|
||||
propshaft
|
||||
puma
|
||||
rails (= 6.1.4.1)
|
||||
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
|
||||
sqlite3
|
||||
therubyracer
|
||||
turbolinks
|
||||
sqlite3 (>= 2.1)
|
||||
stimulus-rails
|
||||
turbo-rails
|
||||
tzinfo-data
|
||||
uglifier
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.0.0p0
|
||||
ruby 4.0.1p0
|
||||
|
||||
BUNDLED WITH
|
||||
2.2.26
|
||||
4.0.3
|
||||
|
||||
4
Procfile
4
Procfile
@@ -1,4 +0,0 @@
|
||||
worker: bundle exec bin/delayed_job -n 1 run
|
||||
#worker: bundle exec rake jobs:work
|
||||
#web: bundle exec puma -w 3 -t 5:5 -p ${PORT:-3000} -e ${RACK_ENV:-development}
|
||||
web: bundle exec passenger start -p $PORT --max-pool-size 2 --min-instances 2
|
||||
271
README.md
271
README.md
@@ -1,61 +1,260 @@
|
||||
# README
|
||||
This application is being created to run a wrestling tournament.
|
||||
|
||||
### Current master status
|
||||
[](https://circleci.com/gh/jcwimer/wrestlingApp/tree/master)
|
||||
|
||||
### Current development status
|
||||
[](https://circleci.com/gh/jcwimer/wrestlingApp/tree/development)
|
||||
|
||||
# Info
|
||||
**License:** MIT License
|
||||
|
||||
**Public Production Url:** [https://wrestlingdev.com](http://wrestlingdev.com)
|
||||
**Public Production Url:** [https://wrestlingdev.com](https://wrestlingdev.com)
|
||||
|
||||
**App Info**
|
||||
* Ruby 3.0.0
|
||||
* Rails 6.1.2
|
||||
* DB mysql or mariadb
|
||||
* Memcached
|
||||
* Delayed Jobs
|
||||
* Ruby 4.0.1
|
||||
* Rails 8.1.2
|
||||
* DB MySQL/MariaDB
|
||||
* 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
|
||||
|
||||
## Develop with docker
|
||||
All dependencies are wrapped in docker. Tests can be run with `bash bin/run-tests-with-docker.sh`. That is the same command used in CI.
|
||||
|
||||
If you want to run a full rails environment shell in docker run: `bash bin/rails-dev-run.sh wrestlingapp-dev`
|
||||
|
||||
If you want to run a full rails environment shell in docker run: `bash bin/rails-dev-run.sh wrestlingdev-dev`
|
||||
From here, you can run the normal rails commands.
|
||||
|
||||
Special rake tasks:
|
||||
* `tournament:assign_random_wins` will complete all matches for tournament 204 from seed data. This task takes a while since it waits for the worker to complete tasks. In my testing, it takes about 3.5 hours to complete.
|
||||
* `rake tournament:assign_random_wins` to run locally
|
||||
* `docker-compose -f deploy/docker-compose-test.yml exec -T app rails tournament:assign_random_wins` to run on the dev server
|
||||
|
||||
To deploy a full local version of the app `bash deploy/deploy-test.sh` (this requires docker-compose to be installed). This deploys a full version of the app including Rails app, Solid Queue background workers, Memcached, and MariaDB. Now, you can open [http://localhost](http://localhost).
|
||||
|
||||
In development environments, background jobs run inline (synchronously) by default. In production and staging environments, jobs are processed asynchronously by separate worker processes.
|
||||
|
||||
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` 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/'"` 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`
|
||||
Then, `cd ../; cd wrestlingApp`. This will load the gemset file in this repo.
|
||||
|
||||
## Quick Rails Commands Without Local Installation
|
||||
You can run one-off Rails commands without installing Rails locally by using the development Docker image:
|
||||
|
||||
```bash
|
||||
# Build the development image
|
||||
docker build -t wrestlingdev-dev -f deploy/rails-dev-Dockerfile .
|
||||
|
||||
# Run a specific Rails command
|
||||
docker run -it -v $(pwd):/rails wrestlingdev-dev rails db:migrate
|
||||
|
||||
# Run the Rails console
|
||||
docker run -it -v $(pwd):/rails wrestlingdev-dev rails console
|
||||
|
||||
# Run a custom Rake task
|
||||
docker run -it -v $(pwd):/rails wrestlingdev-dev rake jobs:create_running
|
||||
```
|
||||
|
||||
This approach is useful for quick commands without the need to set up a full development environment. The image contains all required dependencies for the application.
|
||||
|
||||
For a more convenient experience with a persistent shell, use the included wrapper script:
|
||||
|
||||
```bash
|
||||
bash bin/rails-dev-run.sh wrestlingdev-dev
|
||||
```
|
||||
|
||||
## Rails commands
|
||||
Whether you have a shell from docker or are using rvm you can now run normal rails commands:
|
||||
* `bundle config set --local without 'production'`
|
||||
* `bundle install`
|
||||
* `rake db:seed` Development login email from seed data: `test@test.com` password: `password`
|
||||
* `rake test`
|
||||
* `rails generate blah blah blah`
|
||||
* ` rails s -b 0.0.0.0` port 3000 is exposed. You can open [http://localhost:3000](http://localhost:3000) after running that command
|
||||
* 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
|
||||
|
||||
To deploy a a full local version of the app `bash deploy/deploy-test.sh` (this requires docker-compose to be installed). This deploys a full version of the app. App, delayed job, memcached, and mariadb. Now, you can open [http://localhost](http://localhost). Delayed jobs are turned off in dev and everything that is a delayed job in prod just runs in browser.
|
||||
## 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
|
||||
```
|
||||
|
||||
See `SOLID_QUEUE.md` for more details about the job system.
|
||||
|
||||
## Update gems
|
||||
|
||||
1. `bash bin/rails-dev-run.sh wrestlingdev-dev` to open a contianer with a rails shell available
|
||||
2. `bundle config --delete without` to remove the bundle config that ignores production gems
|
||||
3. `bundle update`
|
||||
4. `bundle config set --local without 'production'` to reset your without locally
|
||||
|
||||
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)
|
||||
|
||||
**Required environment variables for deployment**
|
||||
* `WRESTLINGDEV_DB_NAME=databasename`
|
||||
* `WRESTLINGDEV_DB_USER=databaseusername`
|
||||
* `WRESTLINGDEV_DB_PWD=databasepassword`
|
||||
* `WRESTLINGDEV_DB_HOST=database.homename`
|
||||
* `WRESTLINGDEV_DB_PORT=databaseport`
|
||||
* `WRESTLINGDEV_DEVISE_SECRET_KEY=devise_key` can be generated with `rake secret`
|
||||
* `WRESTLINGDEV_SECRET_KEY_BASE=secret_key` can be generated with `rake secret`
|
||||
* `WRESTLINGDEV_EMAIL_PWD=emailpwd` Email has to be a gmail account for now.
|
||||
* `WRESTLINGDEV_EMAIL=email address`
|
||||
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.
|
||||
|
||||
**Optional environment variables**
|
||||
* `MEMCACHIER_PASSWORD=memcachier_password` needed for caching password
|
||||
* `MEMCACHIER_SERVERS=memcachier_hostname:memcachier_port` needed for caching
|
||||
* `MEMCACHIER_USERNAME=memcachier_username` needed for caching
|
||||
* `WRESTLINGDEV_NEW_RELIC_LICENSE_KEY=new_relic_license_key` this is only needed to use new relic
|
||||
* `WRESTLINGDEV_INFLUXDB_DATABASE=influx_db_name` to send metrics to influxdb this is required
|
||||
* `WRESTLINGDEV_INFLUXDB_HOST=influx_db_ip_or_hostname` to send metrics to influxdb this is required
|
||||
* `WRESTLINGDEV_INFLUXDB_PORT=influx_db_port` to send metrics to influxdb this is required
|
||||
* `WRESTLINGDEV_INFLUXDB_USERNAME=influx_db_username` to send metrics to influxdb this is optional
|
||||
* `WRESTLINGDEV_INFLUXDB_PASSWORD=influx_db_password` to send metrics to influxdb this is optional
|
||||
## Server Configuration
|
||||
|
||||
### Puma and SolidQueue
|
||||
|
||||
The application uses an intelligent auto-scaling configuration for Puma (the web server) and SolidQueue (background job processing):
|
||||
|
||||
- **Auto Detection**: The server automatically detects available CPU cores and memory, and scales accordingly.
|
||||
- **Worker Scaling**: In production, the number of Puma workers is calculated based on available memory (assuming ~400MB per worker) and CPU cores.
|
||||
- **Thread Configuration**: Each Puma worker uses 5-12 threads by default, optimized for mixed I/O and CPU workloads.
|
||||
- **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.
|
||||
|
||||
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:
|
||||
```
|
||||
Puma starting with X worker(s), Y-Z threads per worker
|
||||
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
|
||||
* `WRESTLINGDEV_DB_NAME` - Database name for the main application
|
||||
* `WRESTLINGDEV_DB_USR` - Database username
|
||||
* `WRESTLINGDEV_DB_PWD` - Database password
|
||||
* `WRESTLINGDEV_DB_HOST` - Database hostname
|
||||
* `WRESTLINGDEV_DB_PORT` - Database port
|
||||
* `WRESTLINGDEV_DEVISE_SECRET_KEY` - Secret key for Devise (can be generated with `rake secret`)
|
||||
* `WRESTLINGDEV_SECRET_KEY_BASE` - Rails application secret key (can be generated with `rake secret`)
|
||||
* `WRESTLINGDEV_EMAIL` - Email address (currently must be a Gmail account)
|
||||
* `WRESTLINGDEV_EMAIL_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)
|
||||
* `WEB_CONCURRENCY` - Number of Puma workers (auto-detected based on CPU/memory if not specified)
|
||||
* `RAILS_MIN_THREADS` - Minimum number of threads per Puma worker (defaults to 5)
|
||||
* `RAILS_MAX_THREADS` - Maximum number of threads per Puma worker (defaults to 12)
|
||||
* `DATABASE_POOL_SIZE` - Database connection pool size (auto-calculated if not specified)
|
||||
* `SOLID_QUEUE_WORKERS` - Number of SolidQueue workers (auto-calculated if not specified)
|
||||
* `SOLID_QUEUE_THREADS` - Number of threads per SolidQueue worker (auto-calculated if not specified)
|
||||
* `PORT` - Port for Puma server to listen on (defaults to 3000)
|
||||
* `RAILS_LOG_LEVEL` - Log level for Rails in production (defaults to "info")
|
||||
* `PIDFILE` - PID file location for Puma
|
||||
* `RAILS_SSL_TERMINATION` - Set to "true" to enable force_ssl in production (HTTPS enforcement)
|
||||
* `REVERSE_PROXY_SSL_TERMINATION` - Set to "true" if the app is behind a SSL-terminating reverse proxy
|
||||
* `CI` - Set in CI environments to enable eager loading in test environment
|
||||
* `WRESTLINGDEV_NEW_RELIC_LICENSE_KEY` - New Relic license key for monitoring
|
||||
|
||||
### InfluxDB Configuration (all required if using InfluxDB)
|
||||
* `WRESTLINGDEV_INFLUXDB_DATABASE` - InfluxDB database name
|
||||
* `WRESTLINGDEV_INFLUXDB_HOST` - InfluxDB hostname
|
||||
* `WRESTLINGDEV_INFLUXDB_PORT` - InfluxDB port
|
||||
* `WRESTLINGDEV_INFLUXDB_USERNAME` - InfluxDB username (optional)
|
||||
* `WRESTLINGDEV_INFLUXDB_PASSWORD` - InfluxDB password (optional)
|
||||
|
||||
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`
|
||||
|
||||
@@ -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,16 +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_tree .
|
||||
//= require jquery
|
||||
//= require jquery_ujs
|
||||
// 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,15 +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.
|
||||
*
|
||||
*= 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");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
.navbar-inverse.navbar-fixed-top {
|
||||
z-index: 1040; /* Ensure main navbar is above tournament navbar */
|
||||
}
|
||||
|
||||
#tournament-navbar {
|
||||
top: 50px; /* Position below the first fixed navbar */
|
||||
z-index: 1030; /* Explicitly set standard fixed navbar z-index */
|
||||
}
|
||||
|
||||
/* Make desktop navbar dropdowns scrollable if they overflow */
|
||||
@media (min-width: 768px) {
|
||||
/* Target dropdowns in main nav and tournament nav specifically */
|
||||
.navbar-fixed-top .dropdown-menu {
|
||||
max-height: 70vh; /* Adjust as needed - 70% of viewport height */
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
@@ -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,7 +14,11 @@ class ApiController < ApplicationController
|
||||
end
|
||||
|
||||
def tournament
|
||||
@tournament = Tournament.where(:id => params[:tournament]).includes(:schools,:weights,:mats,:matches,:user,:wrestlers).first
|
||||
@tournament = Tournament.where(:id => params[:tournament]).includes(:user, :mats, :schools, :weights, :matches, wrestlers: [:school, :weight, :matches_as_w1, :matches_as_w2]).first
|
||||
@schools = @tournament.schools.includes(wrestlers: [: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
|
||||
|
||||
def newTournament
|
||||
|
||||
@@ -5,19 +5,39 @@ class ApplicationController < ActionController::Base
|
||||
|
||||
after_action :set_csrf_cookie_for_ng
|
||||
|
||||
# Add helpers for authentication (replacing Devise)
|
||||
helper_method :current_user, :user_signed_in?
|
||||
|
||||
def current_user
|
||||
@current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
|
||||
end
|
||||
|
||||
def user_signed_in?
|
||||
current_user.present?
|
||||
end
|
||||
|
||||
def authenticate_user!
|
||||
redirect_to login_path, alert: "Please log in to access this page" unless user_signed_in?
|
||||
end
|
||||
|
||||
def set_csrf_cookie_for_ng
|
||||
cookies['XSRF-TOKEN'] = form_authenticity_token if protect_against_forgery?
|
||||
end
|
||||
|
||||
rescue_from CanCan::AccessDenied do |exception|
|
||||
# flash[:error] = "Access denied!"
|
||||
redirect_to '/static_pages/not_allowed'
|
||||
end
|
||||
|
||||
|
||||
protected
|
||||
|
||||
# In Rails 4.2 and above
|
||||
def verified_request?
|
||||
super || valid_authenticity_token?(session, request.headers['X-XSRF-TOKEN'])
|
||||
end
|
||||
|
||||
# Override current_ability to pass school_permission_key
|
||||
# @school_permission_key needs to be defined on the controller
|
||||
def current_ability
|
||||
@current_ability ||= Ability.new(current_user, @school_permission_key)
|
||||
end
|
||||
end
|
||||
|
||||
77
app/controllers/mat_assignment_rules_controller.rb
Normal file
77
app/controllers/mat_assignment_rules_controller.rb
Normal file
@@ -0,0 +1,77 @@
|
||||
class MatAssignmentRulesController < ApplicationController
|
||||
before_action :set_tournament
|
||||
before_action :check_access_manage
|
||||
before_action :set_mat_assignment_rule, only: [:edit, :update, :destroy]
|
||||
|
||||
def index
|
||||
@mat_assignment_rules = @tournament.mat_assignment_rules.includes(:mat)
|
||||
@weights_by_id = @tournament.weights.index_by(&:id) # For quick lookup
|
||||
end
|
||||
|
||||
def new
|
||||
@mat_assignment_rule = @tournament.mat_assignment_rules.build
|
||||
load_form_data
|
||||
end
|
||||
|
||||
def create
|
||||
@mat_assignment_rule = @tournament.mat_assignment_rules.build(mat_assignment_rule_params)
|
||||
load_form_data
|
||||
|
||||
if @mat_assignment_rule.save
|
||||
redirect_to tournament_mat_assignment_rules_path(@tournament), notice: 'Mat assignment rule created successfully.'
|
||||
else
|
||||
render :new
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
load_form_data
|
||||
end
|
||||
|
||||
def update
|
||||
load_form_data
|
||||
|
||||
if @mat_assignment_rule.update(mat_assignment_rule_params)
|
||||
redirect_to tournament_mat_assignment_rules_path(@tournament), notice: 'Mat assignment rule updated successfully.'
|
||||
else
|
||||
render :edit
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@mat_assignment_rule.destroy
|
||||
redirect_to tournament_mat_assignment_rules_path(@tournament), notice: 'Mat assignment rule was successfully deleted.'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_tournament
|
||||
@tournament = Tournament.find(params[:tournament_id])
|
||||
end
|
||||
|
||||
def set_mat_assignment_rule
|
||||
@mat_assignment_rule = @tournament.mat_assignment_rules.find(params[:id])
|
||||
end
|
||||
|
||||
def check_access_manage
|
||||
authorize! :manage, @tournament
|
||||
end
|
||||
|
||||
def mat_assignment_rule_params
|
||||
params[:mat_assignment_rule][:weight_classes] ||= []
|
||||
params[:mat_assignment_rule][:bracket_positions] ||= []
|
||||
params[:mat_assignment_rule][:rounds] ||= []
|
||||
|
||||
params.require(:mat_assignment_rule).permit(:mat_id, weight_classes: [], bracket_positions: [], rounds: []).tap do |whitelisted|
|
||||
whitelisted[:weight_classes] = Array(whitelisted[:weight_classes]).map(&:to_i)
|
||||
whitelisted[:rounds] = Array(whitelisted[:rounds]).map(&:to_i)
|
||||
whitelisted[:bracket_positions] = Array(whitelisted[:bracket_positions])
|
||||
end
|
||||
end
|
||||
|
||||
def load_form_data
|
||||
@available_mats = @tournament.mats
|
||||
@unique_bracket_positions = @tournament.matches.select(:bracket_position).distinct.pluck(:bracket_position)
|
||||
@unique_rounds = @tournament.matches.select(:round).distinct.pluck(:round)
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,7 @@
|
||||
class MatchesController < ApplicationController
|
||||
before_action :set_match, only: [:show, :edit, :update, :destroy, :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
|
||||
@@ -16,41 +17,111 @@ class MatchesController < ApplicationController
|
||||
end
|
||||
if @match
|
||||
@wrestlers = @match.weight.wrestlers
|
||||
@tournament = @match.tournament
|
||||
end
|
||||
session[:return_path] = "/tournaments/#{@match.tournament.id}/matches"
|
||||
end
|
||||
|
||||
def stat
|
||||
if params[:match]
|
||||
@match = Match.where(:id => params[:match]).includes(:wrestlers).first
|
||||
end
|
||||
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
|
||||
@w1 = @match.wrestler1
|
||||
@w2 = @match.wrestler2
|
||||
@wrestlers = [@w1,@w2]
|
||||
@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]
|
||||
format.html { redirect_to session.delete(:return_path), notice: 'Match was successfully updated.' }
|
||||
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
|
||||
format.html { render action: 'edit' }
|
||||
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
|
||||
end
|
||||
|
||||
|
||||
private
|
||||
@@ -61,10 +132,73 @@ class MatchesController < ApplicationController
|
||||
|
||||
# Never trust parameters from the scary internet, only allow the white list through.
|
||||
def match_params
|
||||
params.require(:match).permit(:w1, :w2, :w1_stat, :w2_stat, :winner_id, :win_type, :score, :finished)
|
||||
params.require(:match).permit(:w1, :w2, :w1_stat, :w2_stat, :winner_id, :win_type, :score, :overtime_type, :finished, :round)
|
||||
end
|
||||
|
||||
def check_access
|
||||
authorize! :manage, @match.tournament
|
||||
end
|
||||
|
||||
def check_read_access
|
||||
authorize! :read, @match.tournament
|
||||
end
|
||||
|
||||
def sanitize_redirect_path(path)
|
||||
return nil if path.blank?
|
||||
|
||||
uri = URI.parse(path)
|
||||
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,18 +1,74 @@
|
||||
class MatsController < ApplicationController
|
||||
before_action :set_mat, only: [:show, :edit, :update, :destroy]
|
||||
before_action :check_access, only: [:new,:create,:update,:destroy,:edit,:show]
|
||||
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
|
||||
@match = @mat.unfinished_matches.first
|
||||
if @match
|
||||
@w1 = @match.wrestler1
|
||||
@w2 = @match.wrestler2
|
||||
@wrestlers = [@w1,@w2]
|
||||
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
|
||||
@queue_matches[0]
|
||||
end
|
||||
session[:return_path] = request.original_fullpath
|
||||
# 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
|
||||
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
|
||||
|
||||
@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
|
||||
@@ -44,6 +100,20 @@ class MatsController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
# POST /mats/1/assign_next_match
|
||||
def assign_next_match
|
||||
@tournament = @mat.tournament_id
|
||||
respond_to do |format|
|
||||
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." }
|
||||
format.json { head :no_content }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# PATCH/PUT /mats/1
|
||||
# PATCH/PUT /mats/1.json
|
||||
def update
|
||||
@@ -92,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
|
||||
|
||||
57
app/controllers/password_resets_controller.rb
Normal file
57
app/controllers/password_resets_controller.rb
Normal file
@@ -0,0 +1,57 @@
|
||||
class PasswordResetsController < ApplicationController
|
||||
before_action :get_user, only: [:edit, :update]
|
||||
before_action :valid_user, only: [:edit, :update]
|
||||
before_action :check_expiration, only: [:edit, :update]
|
||||
|
||||
def new
|
||||
end
|
||||
|
||||
def create
|
||||
@user = User.find_by(email: params[:password_reset][:email].downcase)
|
||||
if @user
|
||||
@user.create_reset_digest
|
||||
@user.send_password_reset_email
|
||||
redirect_to root_url, notice: "Email sent with password reset instructions"
|
||||
else
|
||||
flash.now[:alert] = "Email address not found"
|
||||
render 'new'
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
if params[:user][:password].empty?
|
||||
@user.errors.add(:password, "can't be empty")
|
||||
render 'edit'
|
||||
elsif @user.update(user_params)
|
||||
session[:user_id] = @user.id
|
||||
redirect_to root_url, notice: "Password has been reset"
|
||||
else
|
||||
render 'edit'
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def user_params
|
||||
params.require(:user).permit(:password, :password_confirmation)
|
||||
end
|
||||
|
||||
def get_user
|
||||
@user = User.find_by(email: params[:email])
|
||||
end
|
||||
|
||||
def valid_user
|
||||
unless @user && @user.authenticated?(:reset, params[:id])
|
||||
redirect_to root_url
|
||||
end
|
||||
end
|
||||
|
||||
def check_expiration
|
||||
if @user.password_reset_expired?
|
||||
redirect_to new_password_reset_url, alert: "Password reset has expired"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,7 +2,7 @@ class SchoolsController < ApplicationController
|
||||
before_action :set_school, only: [:import_baumspage_roster, :show, :edit, :update, :destroy, :stats]
|
||||
before_action :check_access_director, only: [:new,:create,:destroy]
|
||||
before_action :check_access_delegate, only: [:import_baumspage_roster, :update,:edit]
|
||||
|
||||
before_action :check_read_access, only: [:show, :stats]
|
||||
|
||||
def stats
|
||||
@tournament = @school.tournament
|
||||
@@ -12,7 +12,7 @@ class SchoolsController < ApplicationController
|
||||
# GET /schools/1.json
|
||||
def show
|
||||
session.delete(:return_path)
|
||||
@wrestlers = @school.wrestlers.includes(:deductedPoints,:matches,:weight)
|
||||
@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.
|
||||
@@ -93,20 +93,37 @@ class SchoolsController < ApplicationController
|
||||
end
|
||||
|
||||
def check_access_director
|
||||
if params[:tournament]
|
||||
if params[:tournament].present?
|
||||
@tournament = Tournament.find(params[:tournament])
|
||||
elsif params[:school]
|
||||
elsif params[:school].present?
|
||||
@tournament = Tournament.find(params[:school]["tournament_id"])
|
||||
elsif @school
|
||||
@tournament = @school.tournament
|
||||
elsif school_params
|
||||
@tournament = Tournament.find(school_params[:tournament_id])
|
||||
end
|
||||
|
||||
authorize! :manage, @tournament
|
||||
end
|
||||
|
||||
def check_access_delegate
|
||||
if params[:school].present?
|
||||
if school_params[:school_permission_key].present?
|
||||
@school_permission_key = params[:school_permission_key]
|
||||
end
|
||||
end
|
||||
|
||||
if params[:school_permission_key].present?
|
||||
@school_permission_key = params[:school_permission_key]
|
||||
end
|
||||
|
||||
authorize! :manage, @school
|
||||
end
|
||||
|
||||
def check_read_access
|
||||
# set @school_permission_key for use in ability
|
||||
if params[:school_permission_key].present?
|
||||
@school_permission_key = params[:school_permission_key]
|
||||
end
|
||||
|
||||
authorize! :read, @school
|
||||
end
|
||||
end
|
||||
|
||||
20
app/controllers/sessions_controller.rb
Normal file
20
app/controllers/sessions_controller.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
class SessionsController < ApplicationController
|
||||
def new
|
||||
end
|
||||
|
||||
def create
|
||||
user = User.find_by(email: params[:session][:email].downcase)
|
||||
if user && user.authenticate(params[:session][:password])
|
||||
session[:user_id] = user.id
|
||||
redirect_to root_path, notice: "Logged in successfully"
|
||||
else
|
||||
flash.now[:alert] = "Invalid email/password combination"
|
||||
render 'new'
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
session.delete(:user_id)
|
||||
redirect_to root_path, notice: "Logged out successfully"
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
78
app/controllers/tournament_backups_controller.rb
Normal file
78
app/controllers/tournament_backups_controller.rb
Normal file
@@ -0,0 +1,78 @@
|
||||
class TournamentBackupsController < ApplicationController
|
||||
before_action :set_tournament
|
||||
before_action :set_tournament_backup, only: [:show, :destroy, :restore]
|
||||
before_action :check_access_manage
|
||||
|
||||
# GET /tournament/:tournament_id/tournament_backups
|
||||
def index
|
||||
@tournament_backups = @tournament.tournament_backups.order(created_at: :desc)
|
||||
end
|
||||
|
||||
# GET /tournament/:tournament_id/tournament_backups/:id
|
||||
def show
|
||||
end
|
||||
|
||||
# DELETE /tournament/:tournament_id/tournament_backups/:id
|
||||
def destroy
|
||||
if @tournament_backup.destroy
|
||||
redirect_to tournament_tournament_backups_path(@tournament), notice: 'Backup was successfully deleted.'
|
||||
else
|
||||
redirect_to tournament_tournament_backups_path(@tournament), alert: 'Failed to delete the backup.'
|
||||
end
|
||||
end
|
||||
|
||||
# POST /tournament/:tournament_id/tournament_backups/create
|
||||
def create
|
||||
TournamentBackupService.new(@tournament, 'Manual backup').create_backup
|
||||
redirect_to tournament_tournament_backups_path(@tournament), notice: 'Backup was successfully created. It will show up soon, check your background jobs for status.'
|
||||
end
|
||||
|
||||
# POST /tournament/:tournament_id/tournament_backups/:id/restore
|
||||
def restore
|
||||
WrestlingdevImporter.new(@tournament, @tournament_backup).import
|
||||
redirect_to tournament_path(@tournament), notice: 'Restore has successfully been submitted, please check your background jobs to see if it has finished.'
|
||||
end
|
||||
|
||||
# POST /tournament/:tournament_id/tournament_backups/import_manual
|
||||
def import_manual
|
||||
import_text = params[:tournament][:import_text]
|
||||
if import_text.blank?
|
||||
redirect_to tournament_tournament_backups_path(@tournament), alert: 'Import text cannot be blank.'
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
|
||||
# Create a temporary backup object
|
||||
backup = TournamentBackup.new(
|
||||
tournament: @tournament,
|
||||
backup_data: Base64.encode64(import_text),
|
||||
backup_reason: 'Manual Import'
|
||||
)
|
||||
|
||||
# Pass the backup object to the importer
|
||||
WrestlingdevImporter.new(@tournament, backup).import
|
||||
|
||||
redirect_to tournament_path(@tournament), notice: 'Restore has successfully been submitted, please check your background jobs to see if it has finished.'
|
||||
rescue JSON::ParserError => e
|
||||
redirect_to tournament_tournament_backups_path(@tournament), alert: "Failed to parse JSON: #{e.message}"
|
||||
rescue StandardError => e
|
||||
redirect_to tournament_tournament_backups_path(@tournament), alert: "An error occurred: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_tournament
|
||||
@tournament = Tournament.find(params[:tournament_id])
|
||||
end
|
||||
|
||||
def set_tournament_backup
|
||||
@tournament_backup = @tournament.tournament_backups.find(params[:id])
|
||||
end
|
||||
|
||||
def check_access_manage
|
||||
authorize! :manage, @tournament
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,33 +1,20 @@
|
||||
class TournamentsController < ApplicationController
|
||||
before_action :set_tournament, only: [:calculate_team_scores, :import,:export,: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,:brackets,:generate_matches,:bracket,:all_brackets]
|
||||
before_action :check_access_manage, only: [:calculate_team_scores, :import,:export,: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: [: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
|
||||
|
||||
end
|
||||
|
||||
def export
|
||||
|
||||
@schools = @tournament.schools.includes(wrestlers: :weight)
|
||||
end
|
||||
|
||||
def calculate_team_scores
|
||||
@tournament.schools.each do |school|
|
||||
school.calculate_score
|
||||
end
|
||||
respond_to do |format|
|
||||
format.html { redirect_to "/tournaments/#{@tournament.id}", notice: 'Team scores are calcuating.' }
|
||||
end
|
||||
end
|
||||
|
||||
def import
|
||||
import_text = params[:tournament][:import_text]
|
||||
respond_to do |format|
|
||||
if WrestlingdevImporter.new(@tournament,import_text).import
|
||||
format.html { redirect_to "/tournaments/#{@tournament.id}", notice: 'Import is on-going. This will take 1-5 minutes.' }
|
||||
format.json { render action: 'show', status: :created, location: @tournament }
|
||||
if @tournament.calculate_all_team_scores
|
||||
format.html { redirect_to "/tournaments/#{@tournament.id}", notice: 'Team scores are calcuating.' }
|
||||
format.json { head :no_content }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -92,7 +79,7 @@ class TournamentsController < ApplicationController
|
||||
|
||||
def school_delegate
|
||||
if params[:search]
|
||||
@users = User.limit(200).search(params[:search])
|
||||
@user = User.where('email = ?', params[:search]).first
|
||||
elsif params[:school_delegate]
|
||||
@delegate = SchoolDelegate.new
|
||||
@delegate.user_id = params[:school_delegate]["user_id"]
|
||||
@@ -104,19 +91,15 @@ class TournamentsController < ApplicationController
|
||||
format.html { redirect_to "/tournaments/#{@tournament.id}/school_delegate", notice: 'There was an issue delegating permissions please try again' }
|
||||
end
|
||||
end
|
||||
else
|
||||
@users_delegates = []
|
||||
@tournament.schools.each do |s|
|
||||
s.delegates.each do |d|
|
||||
@users_delegates << d
|
||||
end
|
||||
end
|
||||
end
|
||||
@users_delegates = SchoolDelegate.includes(:user, :school)
|
||||
.joins(:school)
|
||||
.where(schools: { tournament_id: @tournament.id })
|
||||
end
|
||||
|
||||
def delegate
|
||||
if params[:search]
|
||||
@users = User.limit(200).search(params[:search])
|
||||
@user = User.where('email = ?', params[:search]).first
|
||||
elsif params[:tournament_delegate]
|
||||
@delegate = TournamentDelegate.new
|
||||
@delegate.user_id = params[:tournament_delegate]["user_id"]
|
||||
@@ -128,13 +111,64 @@ class TournamentsController < ApplicationController
|
||||
format.html { redirect_to "/tournaments/#{@tournament.id}/delegate", notice: 'There was an issue delegating permissions please try again' }
|
||||
end
|
||||
end
|
||||
else
|
||||
@users_delegates = @tournament.delegates
|
||||
end
|
||||
@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
|
||||
@@ -144,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
|
||||
@@ -174,18 +216,34 @@ class TournamentsController < ApplicationController
|
||||
def all_brackets
|
||||
@schools = @tournament.schools
|
||||
@schools = @schools.sort_by{|s| s.page_score_string}.reverse!
|
||||
@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
|
||||
@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
|
||||
@matches = @tournament.matches.includes(:schools,:wrestlers,:weight)
|
||||
@round = nil
|
||||
@bracket_position = nil
|
||||
end
|
||||
|
||||
def live_scores
|
||||
@mats = @tournament.mats.sort_by(&:name)
|
||||
end
|
||||
|
||||
def generate_matches
|
||||
@@ -202,35 +260,68 @@ class TournamentsController < ApplicationController
|
||||
|
||||
end
|
||||
|
||||
def qrcode
|
||||
@tournament_url = tournament_url(@tournament)
|
||||
@qrcode = RQRCode::QRCode.new(@tournament_url)
|
||||
end
|
||||
|
||||
|
||||
def up_matches
|
||||
@matches = @tournament.matches.where("mat_id is NULL and (finished <> ? or finished is NULL)",1).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("created_at 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
|
||||
@schools = @tournament.schools.includes(:delegates)
|
||||
@tournament = Tournament.find(params[:id])
|
||||
@schools = @tournament.schools.includes(:delegates).sort_by{|school|school.name}
|
||||
@weights = @tournament.weights.sort_by{|x|[x.max]}
|
||||
@mats = @tournament.mats
|
||||
@mats = @tournament.mats.sort_by{|mat|mat.name}
|
||||
end
|
||||
|
||||
def new
|
||||
@@ -246,6 +337,7 @@ class TournamentsController < ApplicationController
|
||||
redirect_to root_path
|
||||
end
|
||||
@tournament = Tournament.new(tournament_params)
|
||||
@tournament.user_id = current_user.id
|
||||
respond_to do |format|
|
||||
if @tournament.save
|
||||
format.html { redirect_to @tournament, notice: 'Tournament was successfully created.' }
|
||||
@@ -280,15 +372,32 @@ class TournamentsController < ApplicationController
|
||||
def error
|
||||
end
|
||||
|
||||
def reset_bout_board
|
||||
@tournament.reset_and_fill_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
|
||||
@tournament.schools.each do |school|
|
||||
school.update(permission_key: SecureRandom.uuid)
|
||||
end
|
||||
redirect_to school_delegate_path(@tournament), notice: "School permission keys generated successfully."
|
||||
end
|
||||
|
||||
def delete_school_keys
|
||||
@tournament.schools.update_all(permission_key: nil)
|
||||
redirect_to school_delegate_path(@tournament), notice: "All school permission keys have been deleted."
|
||||
end
|
||||
|
||||
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.
|
||||
def tournament_params
|
||||
params.require(:tournament).permit(:name, :address, :director, :director_email, :tournament_type, :weigh_in_ref, :user_id, :date, :originalId, :swapId)
|
||||
params.require(:tournament).permit(:name, :address, :director, :director_email, :tournament_type, :weigh_in_ref, :date, :originalId, :swapId, :is_public)
|
||||
end
|
||||
|
||||
#Check for tournament owner
|
||||
@@ -300,6 +409,10 @@ class TournamentsController < ApplicationController
|
||||
authorize! :manage, @tournament
|
||||
end
|
||||
|
||||
def check_access_read
|
||||
authorize! :read, @tournament
|
||||
end
|
||||
|
||||
def check_for_matches
|
||||
if @tournament
|
||||
if @tournament.matches.empty? or @tournament.curently_generating_matches == 1
|
||||
|
||||
48
app/controllers/users_controller.rb
Normal file
48
app/controllers/users_controller.rb
Normal file
@@ -0,0 +1,48 @@
|
||||
class UsersController < ApplicationController
|
||||
before_action :require_login, only: [:edit, :update]
|
||||
before_action :correct_user, only: [:edit, :update]
|
||||
|
||||
def new
|
||||
@user = User.new
|
||||
end
|
||||
|
||||
def create
|
||||
@user = User.new(user_params)
|
||||
if @user.save
|
||||
session[:user_id] = @user.id
|
||||
redirect_to root_path, notice: "Account created successfully"
|
||||
else
|
||||
render 'new'
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
@user = User.find(params[:id])
|
||||
end
|
||||
|
||||
def update
|
||||
@user = User.find(params[:id])
|
||||
if @user.update(user_params)
|
||||
redirect_to root_path, notice: "Account updated successfully"
|
||||
else
|
||||
render 'edit'
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def user_params
|
||||
params.require(:user).permit(:email, :password, :password_confirmation)
|
||||
end
|
||||
|
||||
def require_login
|
||||
unless current_user
|
||||
redirect_to login_path, alert: "Please log in to access this page"
|
||||
end
|
||||
end
|
||||
|
||||
def correct_user
|
||||
@user = User.find(params[:id])
|
||||
redirect_to root_path unless current_user == @user
|
||||
end
|
||||
end
|
||||
@@ -1,21 +1,28 @@
|
||||
class WeightsController < ApplicationController
|
||||
before_action :set_weight, only: [:pool_order, :show, :edit, :update, :destroy,:re_gen]
|
||||
before_action :check_access, only: [:pool_order, :new,:create,:update,:destroy,:edit, :re_gen]
|
||||
before_action :set_weight, only: [:pool_order, :show, :edit, :update, :destroy]
|
||||
before_action :check_access_manage, only: [:pool_order, :new,:create,:update,:destroy,:edit]
|
||||
before_action :check_access_read, only: [:show]
|
||||
|
||||
|
||||
# GET /weights/1
|
||||
# GET /weights/1.json
|
||||
def show
|
||||
if params[:wrestler]
|
||||
check_access_manage
|
||||
respond_to do |format|
|
||||
Wrestler.update(params[:wrestler].keys, params[:wrestler].values)
|
||||
# Sanitize the wrestler parameters
|
||||
sanitized_wrestlers = params.require(:wrestler).to_unsafe_h.transform_values do |attributes|
|
||||
ActionController::Parameters.new(attributes).permit(:original_seed)
|
||||
end
|
||||
|
||||
Wrestler.update(sanitized_wrestlers.keys, sanitized_wrestlers.values)
|
||||
format.html { redirect_to @weight, notice: 'Seeds were successfully updated.' }
|
||||
end
|
||||
end
|
||||
@wrestlers = @weight.wrestlers
|
||||
@tournament = @weight.tournament
|
||||
session[:return_path] = "/weights/#{@weight.id}"
|
||||
end
|
||||
end
|
||||
|
||||
# GET /weights/new
|
||||
def new
|
||||
@@ -72,11 +79,6 @@ class WeightsController < ApplicationController
|
||||
format.json { head :no_content }
|
||||
end
|
||||
end
|
||||
|
||||
def re_gen
|
||||
@tournament = @weight.tournament
|
||||
GenerateTournamentMatches.new(@tournament).generateWeight(@weight)
|
||||
end
|
||||
|
||||
def pool_order
|
||||
pool = params[:pool_to_order].to_i
|
||||
@@ -96,14 +98,15 @@ 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.
|
||||
def weight_params
|
||||
params.require(:weight).permit(:max, :tournament_id, :mat_id)
|
||||
end
|
||||
def check_access
|
||||
def check_access_manage
|
||||
if params[:tournament]
|
||||
@tournament = Tournament.find(params[:tournament])
|
||||
elsif params[:weight]
|
||||
@@ -114,5 +117,16 @@ class WeightsController < ApplicationController
|
||||
authorize! :manage, @tournament
|
||||
end
|
||||
|
||||
def check_access_read
|
||||
if params[:tournament]
|
||||
@tournament = Tournament.find(params[:tournament])
|
||||
elsif params[:weight]
|
||||
@tournament = Tournament.find(params[:weight]["tournament_id"])
|
||||
elsif @weight
|
||||
@tournament = @weight.tournament
|
||||
end
|
||||
authorize! :read, @tournament
|
||||
end
|
||||
|
||||
|
||||
end
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
class WrestlersController < ApplicationController
|
||||
before_action :set_wrestler, only: [:show, :edit, :update, :destroy, :update_pool]
|
||||
before_action :check_access, only: [:new,:create,:update,:destroy,:edit,:update_pool]
|
||||
|
||||
|
||||
|
||||
before_action :set_wrestler, only: [:show, :edit, :update, :destroy]
|
||||
before_action :check_access, only: [:new, :create, :update, :destroy, :edit]
|
||||
before_action :check_read_access, only: [:show]
|
||||
|
||||
# GET /wrestlers/1
|
||||
# GET /wrestlers/1.json
|
||||
@@ -16,132 +14,149 @@ class WrestlersController < ApplicationController
|
||||
# GET /wrestlers/new
|
||||
def new
|
||||
@wrestler = Wrestler.new
|
||||
if params[:school]
|
||||
@school = School.find(params[:school])
|
||||
end
|
||||
if @school
|
||||
@tournament = Tournament.find(@school.tournament_id)
|
||||
end
|
||||
if @tournament
|
||||
@weights = Weight.where(tournament_id: @tournament.id).sort_by{|w| w.max}
|
||||
end
|
||||
|
||||
@school = School.find_by(id: params[:school]) if params[:school]
|
||||
# Save the key into an instance variable so the view can use it.
|
||||
@school_permission_key = params[:school_permission_key].presence
|
||||
@tournament = @school.tournament if @school
|
||||
@weights = @tournament.weights.sort_by(&:max) if @tournament
|
||||
end
|
||||
|
||||
# GET /wrestlers/1/edit
|
||||
def edit
|
||||
@tournament = @wrestler.tournament
|
||||
@weight = @wrestler.weight
|
||||
@weights = @school.tournament.weights.sort_by{|w| w.max}
|
||||
@school = @wrestler.school
|
||||
@weights = @school.tournament.weights.sort_by(&:max)
|
||||
end
|
||||
|
||||
# POST /wrestlers
|
||||
# POST /wrestlers.json
|
||||
def create
|
||||
@wrestler = Wrestler.new(wrestler_params)
|
||||
@school = School.find(wrestler_params[:school_id])
|
||||
@weights = @school.tournament.weights
|
||||
@school = School.find_by(id: wrestler_params[:school_id])
|
||||
# IMPORTANT: Get the key from wrestler_params (not from params directly)
|
||||
@school_permission_key = wrestler_params[:school_permission_key].presence
|
||||
@weights = @school.tournament.weights if @school
|
||||
|
||||
# Remove the key from attributes so it isn't assigned to the model.
|
||||
@wrestler = Wrestler.new(wrestler_params.except(:school_permission_key))
|
||||
|
||||
respond_to do |format|
|
||||
if @wrestler.save
|
||||
if session[:return_path]
|
||||
format.html { redirect_to session.delete(:return_path), notice: 'Wrestler was successfully created.' }
|
||||
else
|
||||
format.html { redirect_to @school, notice: 'Wrestler was successfully created.' }
|
||||
format.json { render action: 'show', status: :created, location: @wrestler }
|
||||
end
|
||||
redirect_path = session[:return_path] || school_path(@school)
|
||||
format.html { redirect_to append_permission_key(redirect_path), notice: 'Wrestler was successfully created.' }
|
||||
format.json { render :show, status: :created, location: @wrestler }
|
||||
else
|
||||
format.html { render action: 'new' }
|
||||
format.html { render :new }
|
||||
format.json { render json: @wrestler.errors, status: :unprocessable_entity }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# PATCH/PUT /wrestlers/1
|
||||
# PATCH/PUT /wrestlers/1.json
|
||||
def update
|
||||
@tournament = @wrestler.tournament
|
||||
@weight = @wrestler.weight
|
||||
@weights = @tournament.weights.sort_by{|w| w.max}
|
||||
@school = @wrestler.school
|
||||
@weights = @tournament.weights.sort_by(&:max)
|
||||
|
||||
respond_to do |format|
|
||||
if @wrestler.update(wrestler_params)
|
||||
if session[:return_path]
|
||||
format.html { redirect_to session.delete(:return_path), notice: 'Wrestler was successfully updated.' }
|
||||
else
|
||||
format.html { redirect_to @school, notice: 'Wrestler was successfully updated.' }
|
||||
format.json { render action: 'show', status: :created, location: @wrestler }
|
||||
end
|
||||
if @wrestler.update(wrestler_params.except(:school_permission_key))
|
||||
redirect_path = session[:return_path] || school_path(@school)
|
||||
format.html { redirect_to append_permission_key(redirect_path), notice: 'Wrestler was successfully updated.' }
|
||||
format.json { render :show, status: :ok, location: @wrestler }
|
||||
else
|
||||
format.html { render action: 'edit' }
|
||||
format.html { render :edit }
|
||||
format.json { render json: @wrestler.errors, status: :unprocessable_entity }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def update_pool
|
||||
@tournament = @wrestler.tournament
|
||||
@weight = @wrestler.weight
|
||||
@weights = @tournament.weights.sort_by{|w| w.max}
|
||||
@school = @wrestler.school
|
||||
if params[:wrestler]['pool']
|
||||
@wrestler.pool = params[:wrestler]['pool']
|
||||
respond_to do |format|
|
||||
message = "Wrestler has successfully been switched to a new pool. Matches for that weight are now in a weird state. Please re-generate matches when you are done with all of your changes."
|
||||
if @wrestler.update(wrestler_params)
|
||||
format.html { redirect_to "/tournaments/#{@tournament.id}/brackets/#{@wrestler.weight.id}/", notice: message }
|
||||
format.json { head :no_content }
|
||||
else
|
||||
format.html { render action: 'edit' }
|
||||
format.json { render json: @wrestler.errors, status: :unprocessable_entity }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# DELETE /wrestlers/1
|
||||
# DELETE /wrestlers/1.json
|
||||
def destroy
|
||||
@school = @wrestler.school
|
||||
@wrestler.destroy
|
||||
message = "Wrestler was successfully deleted. This action has removed all matches. Please re-generate matches if you already had matches."
|
||||
|
||||
respond_to do |format|
|
||||
message = "Wrestler was successfully deleted. This action has removed all matches. Please re-generate matches if you already had matches."
|
||||
if session[:return_path]
|
||||
format.html { redirect_to session.delete(:return_path), notice: message }
|
||||
else
|
||||
format.html { redirect_to @school, notice: message }
|
||||
format.json { head :no_content }
|
||||
end
|
||||
redirect_path = session[:return_path] || school_path(@school)
|
||||
format.html { redirect_to append_permission_key(redirect_path), notice: message }
|
||||
format.json { head :no_content }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
# Use callbacks to share common setup or constraints between actions.
|
||||
def set_wrestler
|
||||
@wrestler = Wrestler.where(:id => params[:id]).includes(:school, :weight, :tournament, :matches).first
|
||||
|
||||
def set_wrestler
|
||||
@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
|
||||
params.require(:wrestler).permit(:name, :school_id, :weight_id, :seed, :original_seed, :season_win,
|
||||
:season_loss, :criteria, :extra, :offical_weight, :pool, :school_permission_key)
|
||||
end
|
||||
|
||||
def check_access
|
||||
if params[:school].present?
|
||||
@school = School.find(params[:school])
|
||||
#@tournament = Tournament.find(@school.tournament.id)
|
||||
elsif params[:wrestler].present?
|
||||
if params[:wrestler]["school_id"].present?
|
||||
@school = School.find(params[:wrestler]["school_id"])
|
||||
if wrestler_params[:school_permission_key].present?
|
||||
@school_permission_key = wrestler_params[:school_permission_key]
|
||||
end
|
||||
else
|
||||
@wrestler = Wrestler.find(params[:wrestler]["id"])
|
||||
@school = @wrestler.school
|
||||
end
|
||||
elsif @wrestler
|
||||
@school = @wrestler.school
|
||||
end
|
||||
|
||||
# Never trust parameters from the scary internet, only allow the white list through.
|
||||
def wrestler_params
|
||||
params.require(:wrestler).permit(:name, :school_id, :weight_id, :seed, :original_seed, :season_win, :season_loss,:criteria,:extra,:offical_weight,:pool)
|
||||
# set @school_permission_key for use in ability
|
||||
if params[:school_permission_key].present?
|
||||
@school_permission_key = params[:school_permission_key]
|
||||
end
|
||||
def check_access
|
||||
if params[:school]
|
||||
@school = School.find(params[:school])
|
||||
#@tournament = Tournament.find(@school.tournament.id)
|
||||
elsif params[:wrestler]
|
||||
if params[:wrestler]["school_id"]
|
||||
@school = School.find(params[:wrestler]["school_id"])
|
||||
else
|
||||
@wrestler = Wrestler.find(params[:wrestler]["id"])
|
||||
@school = @wrestler.school
|
||||
end
|
||||
#@tournament = Tournament.find(@school.tournament.id)
|
||||
elsif @wrestler
|
||||
@school = @wrestler.school
|
||||
#@tournament = @wrestler.tournament
|
||||
elsif wrestler_params
|
||||
@school = School.find(wrestler_params[:school_id])
|
||||
end
|
||||
authorize! :manage, @school
|
||||
authorize! :manage, @school
|
||||
end
|
||||
|
||||
def check_read_access
|
||||
if params[:school]
|
||||
@school = School.find(params[:school])
|
||||
elsif params[:wrestler].present?
|
||||
if params[:wrestler]["school_id"].present?
|
||||
@school = School.find(params[:wrestler]["school_id"])
|
||||
else
|
||||
@wrestler = Wrestler.find(params[:wrestler]["id"])
|
||||
@school = @wrestler.school
|
||||
end
|
||||
if wrestler_params[:school_permission_key].present?
|
||||
@school_permission_key = wrestler_params[:school_permission_key]
|
||||
end
|
||||
elsif @wrestler
|
||||
@school = @wrestler.school
|
||||
end
|
||||
|
||||
# set @school_permission_key for use in ability
|
||||
if params[:school_permission_key].present?
|
||||
@school_permission_key = params[:school_permission_key]
|
||||
end
|
||||
authorize! :read, @school
|
||||
end
|
||||
|
||||
# Helper method to append school_permission_key to redirects if it exists.
|
||||
def append_permission_key(path)
|
||||
return path unless @school_permission_key.present?
|
||||
|
||||
# If path is an ActiveRecord object, convert to URL.
|
||||
path = school_path(path) if path.is_a?(School)
|
||||
uri = URI.parse(path)
|
||||
query_params = Rack::Utils.parse_nested_query(uri.query || "")
|
||||
query_params["school_permission_key"] = @school_permission_key
|
||||
uri.query = query_params.to_query
|
||||
uri.to_s
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
34
app/jobs/advance_wrestler_job.rb
Normal file
34
app/jobs/advance_wrestler_job.rb
Normal file
@@ -0,0 +1,34 @@
|
||||
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}" }
|
||||
|
||||
def perform(wrestler, match, tournament_id)
|
||||
# Get tournament from wrestler
|
||||
tournament = wrestler.tournament
|
||||
|
||||
# Create job status record
|
||||
job_name = "Advancing wrestler #{wrestler.name}"
|
||||
job_status = TournamentJobStatus.create!(
|
||||
tournament: tournament,
|
||||
job_name: job_name,
|
||||
status: "Running",
|
||||
details: "Match ID: #{match&.bout_number || 'No match'} Wrestler Name #{wrestler&.name || 'No Wrestler'}"
|
||||
)
|
||||
|
||||
begin
|
||||
# Execute the job
|
||||
service = AdvanceWrestler.new(wrestler, match)
|
||||
service.advance_raw
|
||||
|
||||
# Remove the job status record on success
|
||||
TournamentJobStatus.complete_job(tournament.id, job_name)
|
||||
rescue => e
|
||||
# Update status to errored
|
||||
job_status.update(status: "Errored", details: "Error: #{e.message}")
|
||||
|
||||
# Re-raise the error for SolidQueue to handle
|
||||
raise e
|
||||
end
|
||||
end
|
||||
end
|
||||
7
app/jobs/application_job.rb
Normal file
7
app/jobs/application_job.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
class ApplicationJob < ActiveJob::Base
|
||||
# Automatically retry jobs that encountered a deadlock
|
||||
# retry_on ActiveRecord::Deadlocked
|
||||
|
||||
# Most jobs are safe to ignore if the underlying records are no longer available
|
||||
# discard_on ActiveJob::DeserializationError
|
||||
end
|
||||
39
app/jobs/calculate_school_score_job.rb
Normal file
39
app/jobs/calculate_school_score_job.rb
Normal file
@@ -0,0 +1,39 @@
|
||||
class CalculateSchoolScoreJob < ApplicationJob
|
||||
queue_as :default
|
||||
limits_concurrency to: 1, key: ->(school) { "tournament:#{school.tournament_id}" }
|
||||
|
||||
# Need for TournamentJobStatusIntegrationTest
|
||||
def self.perform_sync(school)
|
||||
# Execute directly on provided objects
|
||||
school.calculate_score_raw
|
||||
end
|
||||
|
||||
def perform(school)
|
||||
# Log information about the job
|
||||
Rails.logger.info("Calculating score for school ##{school.id} (#{school.name})")
|
||||
|
||||
# Create job status record
|
||||
tournament = school.tournament
|
||||
job_name = "Calculating team score for #{school.name}"
|
||||
job_status = TournamentJobStatus.create!(
|
||||
tournament: tournament,
|
||||
job_name: job_name,
|
||||
status: "Running",
|
||||
details: "School ID: #{school.id}"
|
||||
)
|
||||
|
||||
begin
|
||||
# Execute the calculation
|
||||
school.calculate_score_raw
|
||||
|
||||
# Remove the job status record on success
|
||||
TournamentJobStatus.complete_job(tournament.id, job_name)
|
||||
rescue => e
|
||||
# Update status to errored
|
||||
job_status.update(status: "Errored", details: "Error: #{e.message}")
|
||||
|
||||
# Re-raise the error for SolidQueue to handle
|
||||
raise e
|
||||
end
|
||||
end
|
||||
end
|
||||
21
app/jobs/generate_tournament_matches_job.rb
Normal file
21
app/jobs/generate_tournament_matches_job.rb
Normal file
@@ -0,0 +1,21 @@
|
||||
class GenerateTournamentMatchesJob < ApplicationJob
|
||||
queue_as :default
|
||||
limits_concurrency to: 1, key: ->(tournament) { "tournament:#{tournament.id}" }
|
||||
|
||||
def perform(tournament)
|
||||
# Log information about the job
|
||||
Rails.logger.info("Starting tournament match generation for tournament ##{tournament.id}")
|
||||
|
||||
begin
|
||||
# Execute the job
|
||||
generator = GenerateTournamentMatches.new(tournament)
|
||||
generator.generate_raw
|
||||
|
||||
Rails.logger.info("Completed tournament match generation for tournament ##{tournament.id}")
|
||||
rescue => e
|
||||
Rails.logger.error("Error generating tournament matches: #{e.message}")
|
||||
Rails.logger.error(e.backtrace.join("\n"))
|
||||
raise # Re-raise the error so it's properly recorded
|
||||
end
|
||||
end
|
||||
end
|
||||
33
app/jobs/tournament_backup_job.rb
Normal file
33
app/jobs/tournament_backup_job.rb
Normal file
@@ -0,0 +1,33 @@
|
||||
class TournamentBackupJob < ApplicationJob
|
||||
queue_as :default
|
||||
limits_concurrency to: 1, key: ->(tournament, *) { "tournament:#{tournament.id}" }
|
||||
|
||||
def perform(tournament, reason = nil)
|
||||
# Log information about the job
|
||||
Rails.logger.info("Creating backup for tournament ##{tournament.id} (#{tournament.name}), reason: #{reason || 'manual'}")
|
||||
|
||||
# Create job status record
|
||||
job_name = "Backing up tournament"
|
||||
job_status = TournamentJobStatus.create!(
|
||||
tournament: tournament,
|
||||
job_name: job_name,
|
||||
status: "Running",
|
||||
details: "Reason: #{reason || 'manual'}"
|
||||
)
|
||||
|
||||
begin
|
||||
# Execute the backup
|
||||
service = TournamentBackupService.new(tournament, reason)
|
||||
service.create_backup_raw
|
||||
|
||||
# Remove the job status record on success
|
||||
TournamentJobStatus.complete_job(tournament.id, job_name)
|
||||
rescue => e
|
||||
# Update status to errored
|
||||
job_status.update(status: "Errored", details: "Error: #{e.message}")
|
||||
|
||||
# Re-raise the error for SolidQueue to handle
|
||||
raise e
|
||||
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
|
||||
34
app/jobs/wrestlingdev_import_job.rb
Normal file
34
app/jobs/wrestlingdev_import_job.rb
Normal file
@@ -0,0 +1,34 @@
|
||||
class WrestlingdevImportJob < ApplicationJob
|
||||
queue_as :default
|
||||
limits_concurrency to: 1, key: ->(tournament, *) { "tournament:#{tournament.id}" }
|
||||
|
||||
def perform(tournament, import_data = nil)
|
||||
# Log information about the job
|
||||
Rails.logger.info("Starting import for tournament ##{tournament.id} (#{tournament.name})")
|
||||
|
||||
# Create job status record
|
||||
job_name = "Importing tournament"
|
||||
job_status = TournamentJobStatus.create!(
|
||||
tournament: tournament,
|
||||
job_name: job_name,
|
||||
status: "Running",
|
||||
details: "Processing backup data"
|
||||
)
|
||||
|
||||
begin
|
||||
# Execute the import
|
||||
importer = WrestlingdevImporter.new(tournament)
|
||||
importer.import_data = import_data if import_data
|
||||
importer.import_raw
|
||||
|
||||
# Remove the job status record on success
|
||||
TournamentJobStatus.complete_job(tournament.id, job_name)
|
||||
rescue => e
|
||||
# Update status to errored
|
||||
job_status.update(status: "Errored", details: "Error: #{e.message}")
|
||||
|
||||
# Re-raise the error for SolidQueue to handle
|
||||
raise e
|
||||
end
|
||||
end
|
||||
end
|
||||
4
app/mailers/application_mailer.rb
Normal file
4
app/mailers/application_mailer.rb
Normal file
@@ -0,0 +1,4 @@
|
||||
class ApplicationMailer < ActionMailer::Base
|
||||
default from: ENV["WRESTLINGDEV_EMAIL"] || 'noreply@wrestlingdev.com'
|
||||
layout 'mailer'
|
||||
end
|
||||
11
app/mailers/user_mailer.rb
Normal file
11
app/mailers/user_mailer.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
class UserMailer < ApplicationMailer
|
||||
# Subject can be set in your I18n file at config/locales/en.yml
|
||||
# with the following lookup:
|
||||
#
|
||||
# en.user_mailer.password_reset.subject
|
||||
#
|
||||
def password_reset(user)
|
||||
@user = user
|
||||
mail to: user.email, subject: "WrestlingDev - Password reset"
|
||||
end
|
||||
end
|
||||
@@ -1,58 +1,80 @@
|
||||
class Ability
|
||||
include CanCan::Ability
|
||||
|
||||
def initialize(user)
|
||||
# Define abilities for the passed in user here. For example:
|
||||
#
|
||||
# user ||= User.new # guest user (not logged in)
|
||||
# if user.admin?
|
||||
# can :manage, :all
|
||||
# else
|
||||
# can :read, :all
|
||||
# end
|
||||
#
|
||||
# The first argument to `can` is the action you are giving the user
|
||||
# permission to do.
|
||||
# If you pass :manage it will apply to every action. Other common actions
|
||||
# here are :read, :create, :update and :destroy.
|
||||
#
|
||||
# The second argument is the resource the user can perform the action on.
|
||||
# If you pass :all it will apply to every resource. Otherwise pass a Ruby
|
||||
# class of the resource.
|
||||
#
|
||||
# The third argument is an optional hash of conditions to further filter the
|
||||
# objects.
|
||||
# For example, here the user can only update published articles.
|
||||
#
|
||||
# can :update, Article, :published => true
|
||||
#
|
||||
# See the wiki for details:
|
||||
# https://github.com/CanCanCommunity/cancancan/wiki/Defining-Abilities
|
||||
if !user.nil?
|
||||
#Can manage tournament if tournament owner
|
||||
can :manage, Tournament, :user_id => user.id
|
||||
#Can manage but cannot destroy tournament if tournament delegate
|
||||
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
|
||||
|
||||
# TOURNAMENT PERMISSIONS
|
||||
|
||||
# Can manage but cannot destroy tournament if tournament delegate
|
||||
can :manage, Tournament do |tournament|
|
||||
tournament.delegates.map(&:user_id).include? user.id
|
||||
tournament.user_id == user.id ||
|
||||
tournament.delegates.map(&:user_id).include?(user.id)
|
||||
end
|
||||
|
||||
# can destroy tournament if tournament owner
|
||||
can :destroy, Tournament do |tournament|
|
||||
tournament.user_id == user.id
|
||||
end
|
||||
# tournament delegates cannot destroy - explicitly deny
|
||||
cannot :destroy, Tournament do |tournament|
|
||||
tournament.delegates.map(&:user_id).include? user.id
|
||||
tournament.delegates.map(&:user_id).include?(user.id)
|
||||
end
|
||||
#Can manage school if tournament owner
|
||||
|
||||
# Can read tournament if tournament.is_public, tournament owner, or tournament delegate
|
||||
can :read, Tournament do |tournament|
|
||||
tournament.is_public ||
|
||||
tournament.delegates.map(&:user_id).include?(user.id) ||
|
||||
tournament.user_id == user.id
|
||||
end
|
||||
|
||||
# SCHOOL PERMISSIONS
|
||||
# wrestler permissions are included with school permissions
|
||||
|
||||
# Can manage school if is school delegate, is tournament delegate, or is tournament director
|
||||
can :manage, School do |school|
|
||||
school.delegates.map(&:user_id).include?(user.id) ||
|
||||
school.tournament.delegates.map(&:user_id).include?(user.id) ||
|
||||
school.tournament.user_id == user.id
|
||||
end
|
||||
#Can manage school if tournament delegate
|
||||
can :manage, School do |school|
|
||||
school.tournament.delegates.map(&:user_id).include? user.id
|
||||
|
||||
# Can read school if tournament.is_public OR is school delegate, is tournament delegate, or is tournament director
|
||||
can :read, School do |school|
|
||||
school.tournament.is_public ||
|
||||
school.delegates.map(&:user_id).include?(user.id) ||
|
||||
school.tournament.delegates.map(&:user_id).include?(user.id) ||
|
||||
school.tournament.user_id == user.id
|
||||
end
|
||||
#Can manage but cannot destroy school if school delegate
|
||||
can :manage, School do |school|
|
||||
school.delegates.map(&:user_id).include? user.id
|
||||
end
|
||||
cannot :destroy, School do |school|
|
||||
school.delegates.map(&:user_id).include? user.id
|
||||
|
||||
school_permission_key_check(school_permission_key)
|
||||
else
|
||||
# NON LOGGED IN USER PERMISSIONS
|
||||
|
||||
# TOURNAMENT PERMISSIONS
|
||||
|
||||
# Can read tournament if tournament is public
|
||||
can :read, Tournament do |tournament|
|
||||
tournament.is_public
|
||||
end
|
||||
|
||||
# SCHOOL PERMISSIONS
|
||||
# wrestler permissions are included with school permissions
|
||||
school_permission_key_check(school_permission_key)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
3
app/models/application_record.rb
Normal file
3
app/models/application_record.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class ApplicationRecord < ActiveRecord::Base
|
||||
self.abstract_class = true
|
||||
end
|
||||
@@ -1,36 +1,350 @@
|
||||
class Mat < ActiveRecord::Base
|
||||
class Mat < ApplicationRecord
|
||||
include ActionView::RecordIdentifier
|
||||
belongs_to :tournament
|
||||
has_many :matches
|
||||
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
|
||||
t_matches = tournament.matches.select{|m| m.mat_id == nil && m.finished != 1 && m.bout_number != nil}
|
||||
if t_matches.size > 0
|
||||
match = t_matches.sort_by{|m| m.bout_number}.first
|
||||
match.mat_id = self.id
|
||||
match.save
|
||||
slot = first_empty_queue_slot
|
||||
return true unless slot
|
||||
|
||||
match = next_eligible_match
|
||||
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
|
||||
else
|
||||
if queue1_match&.finished == 1
|
||||
shift_queue_forward!
|
||||
end
|
||||
fill_queue_slots!
|
||||
end
|
||||
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 = 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
|
||||
.order(:bout_number)
|
||||
|
||||
# Filter out BYE matches
|
||||
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|
|
||||
if rule.weight_classes.any?
|
||||
# Ensure weight_classes is treated as an array
|
||||
filtered_matches = filtered_matches.where(weight_id: Array(rule.weight_classes).map(&:to_i))
|
||||
end
|
||||
|
||||
if rule.bracket_positions.any?
|
||||
# Ensure bracket_positions is treated as an array
|
||||
filtered_matches = filtered_matches.where(bracket_position: Array(rule.bracket_positions).map(&:to_s))
|
||||
end
|
||||
|
||||
if rule.rounds.any?
|
||||
# Ensure rounds is treated as an array
|
||||
filtered_matches = filtered_matches.where(round: Array(rule.rounds).map(&:to_i))
|
||||
end
|
||||
end
|
||||
|
||||
# Return the first match in filtered results, or nil if none are left
|
||||
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
|
||||
|
||||
29
app/models/mat_assignment_rule.rb
Normal file
29
app/models/mat_assignment_rule.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
class MatAssignmentRule < ApplicationRecord
|
||||
belongs_to :mat
|
||||
belongs_to :tournament
|
||||
|
||||
# Convert comma-separated values to arrays
|
||||
def weight_classes
|
||||
(super || "").split(",").map(&:to_i)
|
||||
end
|
||||
|
||||
def weight_classes=(value)
|
||||
super(value.is_a?(Array) ? value.join(",") : value)
|
||||
end
|
||||
|
||||
def bracket_positions
|
||||
(super || "").split(",")
|
||||
end
|
||||
|
||||
def bracket_positions=(value)
|
||||
super(value.is_a?(Array) ? value.join(",") : value)
|
||||
end
|
||||
|
||||
def rounds
|
||||
(super || "").split(",").map(&:to_i)
|
||||
end
|
||||
|
||||
def rounds=(value)
|
||||
super(value.is_a?(Array) ? value.join(",") : value)
|
||||
end
|
||||
end
|
||||
@@ -1,32 +1,106 @@
|
||||
class Match < ActiveRecord::Base
|
||||
class Match < ApplicationRecord
|
||||
include ActionView::RecordIdentifier
|
||||
|
||||
belongs_to :tournament, touch: true
|
||||
belongs_to :weight, touch: true
|
||||
belongs_to :mat, 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
|
||||
after_update :after_finished_actions, :if => :saved_change_to_finished? or :saved_change_to_winner_id? or :saved_change_to_win_type? or :saved_change_to_score?
|
||||
validate :score_validation, :win_type_validation, :bracket_position_validation, :overtime_type_validation
|
||||
|
||||
# Callback to update finished_at when a match is finished
|
||||
before_save :update_finished_at
|
||||
|
||||
# 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
|
||||
if self.w1
|
||||
wrestler1.touch
|
||||
end
|
||||
if self.w2
|
||||
wrestler2.touch
|
||||
end
|
||||
if self.finished == 1 && self.winner_id != nil
|
||||
if self.w1
|
||||
wrestler1.touch
|
||||
end
|
||||
if self.w2
|
||||
wrestler2.touch
|
||||
end
|
||||
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
|
||||
|
||||
BRACKET_POSITIONS = ["Pool","1/2","3/4","5/6","7/8","Quarter","Semis","Conso Semis","Bracket","Conso", "Conso Quarter"]
|
||||
WIN_TYPES = ["Decision", "Major", "Tech Fall", "Pin", "Forfeit", "Injury Default", "Default", "DQ", "BYE"]
|
||||
OVERTIME_TYPES = ["", "SV-1", "TB-1", "UTB", "SV-2", "TB-2", "OT"] # had to keep the blank here for validations
|
||||
|
||||
def score_validation
|
||||
if finished == 1
|
||||
if ! winner_id
|
||||
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: 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")
|
||||
end
|
||||
if (win_type == "Forfeit" or win_type == "Injury Default" or win_type == "Default" or win_type == "BYE" or win_type == "DQ") and (score != "")
|
||||
errors.add(:score, "needs to be blank when win type is Forfeit, Injury Default, Default, BYE, or DQ win_type")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def win_type_validation
|
||||
if finished == 1
|
||||
if ! WIN_TYPES.include? win_type
|
||||
errors.add(:win_type, "can only be one of the following #{WIN_TYPES.to_s}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def overtime_type_validation
|
||||
# overtime_type can be nil or of type OVERTIME_TYPES
|
||||
if overtime_type != nil and ! OVERTIME_TYPES.include? overtime_type
|
||||
errors.add(:overtime_type, "can only be one of the following #{OVERTIME_TYPES.to_s}")
|
||||
end
|
||||
end
|
||||
|
||||
def bracket_position_validation
|
||||
# Allow "Bracket Round of 16", "Bracket Round of 16.1",
|
||||
# "Conso Round of 8", "Conso Round of 8.2", etc.
|
||||
bracket_round_regex = /\A(Bracket|Conso) Round of \d+(\.\d+)?\z/
|
||||
|
||||
unless BRACKET_POSITIONS.include?(bracket_position) || bracket_position.match?(bracket_round_regex)
|
||||
errors.add(:bracket_position,
|
||||
"must be one of #{BRACKET_POSITIONS.to_s} " \
|
||||
"or match the pattern 'Bracket Round of X'/'Conso Round of X'")
|
||||
end
|
||||
end
|
||||
|
||||
def is_consolation_match
|
||||
if self.bracket_position == "Conso" or self.bracket_position == "Conso Quarter" or self.bracket_position == "Conso Semis" or self.bracket_position == "3/4" or self.bracket_position == "5/6" or self.bracket_position == "7/8"
|
||||
if self.bracket_position.include? "Conso" or self.bracket_position == "3/4" or self.bracket_position == "5/6" or self.bracket_position == "7/8"
|
||||
return true
|
||||
else
|
||||
return false
|
||||
@@ -34,7 +108,7 @@ class Match < ActiveRecord::Base
|
||||
end
|
||||
|
||||
def is_championship_match
|
||||
if self.bracket_position == "Pool" or self.bracket_position == "Quarter" or self.bracket_position == "Semis" or self.bracket_position == "Bracket" or self.bracket_position == "1/2"
|
||||
if self.bracket_position == "Pool" or self.bracket_position == "Quarter" or self.bracket_position == "Semis" or self.bracket_position.include? "Bracket" or self.bracket_position == "1/2"
|
||||
return true
|
||||
else
|
||||
return false
|
||||
@@ -71,7 +145,7 @@ class Match < ActiveRecord::Base
|
||||
sec = time.partition(':').last.to_i
|
||||
return minutes_in_seconds + sec
|
||||
else
|
||||
nil
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
@@ -90,9 +164,11 @@ class Match < ActiveRecord::Base
|
||||
return ""
|
||||
end
|
||||
if self.finished == 1
|
||||
if self.win_type == "Default"
|
||||
return "(Def)"
|
||||
elsif self.win_type == "Injury Default"
|
||||
overtime_type_abbreviation = ""
|
||||
if self.overtime_type != "" and self.overtime_type
|
||||
overtime_type_abbreviation = " #{self.overtime_type}"
|
||||
end
|
||||
if self.win_type == "Injury Default"
|
||||
return "(Inj)"
|
||||
elsif self.win_type == "DQ"
|
||||
return "(DQ)"
|
||||
@@ -100,19 +176,11 @@ class Match < ActiveRecord::Base
|
||||
return "(FF)"
|
||||
else
|
||||
win_type_abbreviation = "#{self.win_type.chars.to_a[0..2].join('')}"
|
||||
return "(#{win_type_abbreviation} #{self.score})"
|
||||
return "(#{win_type_abbreviation} #{self.score}#{overtime_type_abbreviation})"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def wrestler1
|
||||
wrestlers.select{|w| w.id == self.w1}.first
|
||||
end
|
||||
|
||||
def wrestler2
|
||||
wrestlers.select{|w| w.id == self.w2}.first
|
||||
end
|
||||
|
||||
def w1_name
|
||||
if self.w1 != nil
|
||||
wrestler1.name
|
||||
@@ -130,6 +198,7 @@ class Match < ActiveRecord::Base
|
||||
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
|
||||
@@ -137,57 +206,75 @@ class Match < ActiveRecord::Base
|
||||
return_string_ending = return_string_ending + "</strong>"
|
||||
end
|
||||
if self.w1 != nil
|
||||
if self.round == 1
|
||||
if self.wrestler1.original_seed
|
||||
return_string = return_string + "[#{wrestler1.original_seed}] "
|
||||
end
|
||||
return_string = return_string + "#{w1_name} - #{wrestler1.school.name} (#{wrestler1.season_win}-#{wrestler1.season_loss})"
|
||||
if self.round == first_round
|
||||
return_string = return_string + "#{wrestler1.long_bracket_name}"
|
||||
else
|
||||
return_string = return_string + "#{w1_name} (#{wrestler1.school.abbreviation})"
|
||||
return_string = return_string + "#{wrestler1.short_bracket_name}"
|
||||
end
|
||||
else
|
||||
return_string = return_string + "#{w1_name}"
|
||||
return_string = return_string + "#{self.loser1_name}"
|
||||
end
|
||||
return return_string + return_string_ending
|
||||
end
|
||||
|
||||
def w2_bracket_name
|
||||
return_string = ""
|
||||
return_string_ending = ""
|
||||
if self.w2 and self.winner_id == self.w2
|
||||
return_string = return_string + "<strong>"
|
||||
return_string_ending = return_string_ending + "</strong>"
|
||||
end
|
||||
if self.w2 != nil
|
||||
if self.round == 1
|
||||
if self.wrestler2.original_seed
|
||||
return_string = return_string + "#{wrestler2.original_seed} "
|
||||
end
|
||||
return_string = return_string + "#{w2_name} - #{wrestler2.school.name} (#{wrestler2.season_win}-#{wrestler2.season_loss})"
|
||||
else
|
||||
return_string = return_string + "#{w2_name} (#{wrestler2.school.abbreviation})"
|
||||
end
|
||||
else
|
||||
return_string = return_string + "#{w2_name}"
|
||||
end
|
||||
return return_string + return_string_ending
|
||||
first_round = first_round_for_weight
|
||||
return_string = ""
|
||||
return_string_ending = ""
|
||||
if self.w2 and self.winner_id == self.w2
|
||||
return_string = return_string + "<strong>"
|
||||
return_string_ending = return_string_ending + "</strong>"
|
||||
end
|
||||
if self.w2 != nil
|
||||
if self.round == first_round
|
||||
return_string = return_string + "#{wrestler2.long_bracket_name}"
|
||||
else
|
||||
return_string = return_string + "#{wrestler2.short_bracket_name}"
|
||||
end
|
||||
else
|
||||
return_string = return_string + "#{self.loser2_name}"
|
||||
end
|
||||
return return_string + return_string_ending
|
||||
end
|
||||
|
||||
def winner_name
|
||||
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
|
||||
|
||||
def all_results_text
|
||||
if self.finished != 1
|
||||
return ""
|
||||
end
|
||||
winning_wrestler = self.winner
|
||||
if winning_wrestler == self.wrestler1
|
||||
losing_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
|
||||
# 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
|
||||
@@ -197,6 +284,17 @@ class Match < ActiveRecord::Base
|
||||
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
|
||||
@@ -240,4 +338,42 @@ class Match < ActiveRecord::Base
|
||||
""
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_finished_at
|
||||
# Get the changes that will be persisted
|
||||
changes = changes_to_save
|
||||
|
||||
# Check if finished is changing from 0 to 1 or if it's already 1 but has no timestamp
|
||||
if (changes['finished'] && changes['finished'][1] == 1) || (finished == 1 && finished_at.nil?)
|
||||
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
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
class School < ActiveRecord::Base
|
||||
class School < ApplicationRecord
|
||||
belongs_to :tournament, touch: true
|
||||
has_many :wrestlers, dependent: :destroy
|
||||
has_many :deductedPoints, class_name: "Teampointadjust"
|
||||
has_many :delegates, class_name: "SchoolDelegate"
|
||||
has_many :deductedPoints, class_name: "Teampointadjust", dependent: :destroy
|
||||
has_many :delegates, class_name: "SchoolDelegate", dependent: :destroy
|
||||
|
||||
validates :name, presence: true
|
||||
|
||||
@@ -36,13 +36,9 @@ class School < ActiveRecord::Base
|
||||
end
|
||||
|
||||
def calculate_score
|
||||
if Rails.env.production?
|
||||
self.delay(:job_owner_id => self.tournament.id, :job_owner_type => "Calculate team score for #{self.name}").calculate_score_raw
|
||||
else
|
||||
calculate_score_raw
|
||||
end
|
||||
|
||||
end
|
||||
# Use perform_later which will execute based on centralized adapter config
|
||||
CalculateSchoolScoreJob.perform_later(self)
|
||||
end
|
||||
|
||||
def calculate_score_raw
|
||||
newScore = total_points_scored_by_wrestlers - total_points_deducted
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
class SchoolDelegate < ActiveRecord::Base
|
||||
class SchoolDelegate < ApplicationRecord
|
||||
belongs_to :school
|
||||
belongs_to :user
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class Teampointadjust < ActiveRecord::Base
|
||||
belongs_to :wrestler, touch: true
|
||||
belongs_to :school, touch: true
|
||||
class Teampointadjust < ApplicationRecord
|
||||
belongs_to :wrestler, touch: true, optional: true
|
||||
belongs_to :school, touch: true, optional: true
|
||||
|
||||
after_save do
|
||||
advance_wrestlers_and_calc_team_score
|
||||
@@ -14,7 +14,7 @@ class Teampointadjust < ActiveRecord::Base
|
||||
#Team score needs calculated
|
||||
if self.wrestler_id != nil
|
||||
#In case this affects pool order
|
||||
AdvanceWrestler.new(self.wrestler).advance
|
||||
AdvanceWrestler.new(self.wrestler,self.wrestler.last_match).advance
|
||||
self.wrestler.school.calculate_score
|
||||
elsif self.school_id != nil
|
||||
self.school.calculate_score
|
||||
|
||||
@@ -1,23 +1,39 @@
|
||||
class Tournament < ActiveRecord::Base
|
||||
class Tournament < ApplicationRecord
|
||||
|
||||
belongs_to :user
|
||||
belongs_to :user, optional: true
|
||||
has_many :schools, dependent: :destroy
|
||||
has_many :weights, dependent: :destroy
|
||||
has_many :mats, dependent: :destroy
|
||||
has_many :wrestlers, through: :weights
|
||||
has_many :matches, dependent: :destroy
|
||||
has_many :delegates, class_name: "TournamentDelegate"
|
||||
has_many :delegates, class_name: "TournamentDelegate", dependent: :destroy
|
||||
has_many :mat_assignment_rules, dependent: :destroy
|
||||
has_many :tournament_backups, dependent: :destroy
|
||||
has_many :tournament_job_statuses, dependent: :destroy
|
||||
|
||||
validates :date, :name, :tournament_type, :address, :director, :director_email , presence: true
|
||||
|
||||
attr_accessor :import_text
|
||||
|
||||
def deferred_jobs
|
||||
Delayed::Job.where(job_owner_id: self.id)
|
||||
end
|
||||
|
||||
def self.search(search)
|
||||
where("date LIKE ? or name LIKE ?", "%#{search}%", "%#{search}%")
|
||||
def self.search_date_name(pattern)
|
||||
if pattern.blank? # blank? covers both nil and empty string
|
||||
all
|
||||
else
|
||||
search_functions = []
|
||||
search_variables = []
|
||||
search_terms = pattern.split(' ').map{|word| "%#{word.downcase}%"}
|
||||
search_terms.each do |word|
|
||||
search_functions << '(LOWER(name) LIKE ? or LOWER(date) LIKE ?)'
|
||||
# add twice for both ?'s in the function above
|
||||
search_variables << word
|
||||
search_variables << word
|
||||
end
|
||||
like_patterns = search_functions.join(' and ')
|
||||
# puts "where(#{like_patterns})"
|
||||
# puts *search_variables
|
||||
# example: (LOWER(name LIKE ? or LOWER(date) LIKE ?) and (LOWER(name) LIKE ? or LOWER(date) LIKE ?), %test%, %test%, %2016%, %2016%
|
||||
where("#{like_patterns}", *search_variables)
|
||||
end
|
||||
end
|
||||
|
||||
def days_until_start
|
||||
@@ -40,6 +56,12 @@ class Tournament < ActiveRecord::Base
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_all_team_scores
|
||||
self.schools.each do |school|
|
||||
school.calculate_score
|
||||
end
|
||||
end
|
||||
|
||||
def create_pre_defined_weights(weight_classes)
|
||||
weights.destroy_all
|
||||
weight_classes.each do |w|
|
||||
@@ -47,8 +69,35 @@ class Tournament < ActiveRecord::Base
|
||||
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)
|
||||
@@ -56,45 +105,30 @@ class Tournament < ActiveRecord::Base
|
||||
end
|
||||
|
||||
def total_rounds
|
||||
if self.matches.count > 0
|
||||
self.matches.sort_by{|m| m.round}.last.round
|
||||
else
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
def assign_mats(mats_to_assign)
|
||||
if mats_to_assign.count > 0
|
||||
until mats_to_assign.sort_by{|m| m.id}.last.matches.count == 4
|
||||
mats_to_assign.sort_by{|m| m.id}.each do |m|
|
||||
m.assign_next_match
|
||||
end
|
||||
end
|
||||
end
|
||||
# Assuming this is line 147 that's causing the error
|
||||
matches.maximum(:round) || 0 # Return 0 if no matches or max round is nil
|
||||
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
|
||||
@@ -105,20 +139,22 @@ class Tournament < ActiveRecord::Base
|
||||
end
|
||||
end
|
||||
|
||||
def pool_to_bracket_weights_with_too_many_wrestlers
|
||||
if self.tournament_type == "Pool to bracket"
|
||||
weightsWithTooManyWrestlers = weights.select{|w| w.wrestlers.size > 24}
|
||||
if weightsWithTooManyWrestlers.size < 1
|
||||
return nil
|
||||
else
|
||||
return weightsWithTooManyWrestlers
|
||||
end
|
||||
else
|
||||
nil
|
||||
end
|
||||
def pool_to_bracket_number_of_wrestlers_error
|
||||
error_string = ""
|
||||
if self.tournament_type.include? "Pool to bracket"
|
||||
weights_with_too_many_wrestlers = weights.select{|w| w.wrestlers.size > 24}
|
||||
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 24 wrestlers."
|
||||
end
|
||||
weight_with_too_few_wrestlers.each do |weight|
|
||||
error_string = error_string + " The weight class #{weight.max} has less than 2 wrestlers."
|
||||
end
|
||||
end
|
||||
return error_string
|
||||
end
|
||||
|
||||
def modified_sixteen_man_number_of_wrestlers
|
||||
def modified_sixteen_man_number_of_wrestlers_error
|
||||
error_string = ""
|
||||
if self.tournament_type.include? "Modified 16 Man Double Elimination"
|
||||
weights_with_too_many_wrestlers = weights.select{|w| w.wrestlers.size > 16}
|
||||
@@ -133,37 +169,134 @@ class Tournament < ActiveRecord::Base
|
||||
return error_string
|
||||
end
|
||||
|
||||
def double_elim_number_of_wrestlers
|
||||
def double_elim_number_of_wrestlers_error
|
||||
error_string = ""
|
||||
if self.tournament_type == "Double Elimination 1-6"
|
||||
weights_with_too_many_wrestlers = weights.select{|w| w.wrestlers.size > 16}
|
||||
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
|
||||
end
|
||||
|
||||
def wrestlers_with_higher_seed_than_bracket_size_error
|
||||
error_string = ""
|
||||
weights.each do |weight|
|
||||
weight.wrestlers.each do |wrestler|
|
||||
if wrestler.original_seed != nil && wrestler.original_seed > weight.wrestlers.size
|
||||
error_string += "Wrestler: #{wrestler.name} has a seed of #{wrestler.original_seed} which is greater than the amount of wrestlers (#{weight.wrestlers.size}) in the weight class #{weight.max}."
|
||||
end
|
||||
end
|
||||
end
|
||||
return error_string
|
||||
end
|
||||
|
||||
def wrestlers_with_duplicate_original_seed_error
|
||||
error_string = ""
|
||||
weights.each do |weight|
|
||||
weight.wrestlers.select{|wr| wr.original_seed != nil}.each do |wrestler|
|
||||
if weight.wrestlers.select{|wr| wr.original_seed == wrestler.original_seed}.size > 1
|
||||
error_string += "More than 1 wrestler in the #{weight.max} weight class is seeded #{wrestler.original_seed}."
|
||||
end
|
||||
end
|
||||
end
|
||||
return error_string
|
||||
end
|
||||
|
||||
def match_generation_error
|
||||
error_string = "There is a tournament error."
|
||||
modified_sixteen_man_error = modified_sixteen_man_number_of_wrestlers
|
||||
double_elim_error = double_elim_number_of_wrestlers
|
||||
if pool_to_bracket_weights_with_too_many_wrestlers != nil
|
||||
error_string = error_string + " The following weights have too many wrestlers "
|
||||
pool_to_bracket_weights_with_too_many_wrestlers.each do |w|
|
||||
error_string = error_string + "#{w.max} "
|
||||
end
|
||||
return error_string
|
||||
elsif modified_sixteen_man_error.length > 0
|
||||
return error_string + modified_sixteen_man_error
|
||||
elsif double_elim_error.length > 0
|
||||
return error_string + double_elim_error
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
def wrestlers_with_out_of_order_seed_error
|
||||
error_string = ""
|
||||
weights.each do |weight|
|
||||
original_seeds = weight.wrestlers.map(&:original_seed).compact.sort
|
||||
if original_seeds.any? && original_seeds != (original_seeds.first..original_seeds.last).to_a
|
||||
error_string += "The weight class #{weight.max} has wrestlers with out-of-order seeds: #{original_seeds}. There is a gap in the sequence."
|
||||
end
|
||||
end
|
||||
return error_string
|
||||
end
|
||||
|
||||
def match_generation_error
|
||||
error_string = ""
|
||||
if pool_to_bracket_number_of_wrestlers_error.length > 0
|
||||
error_string += pool_to_bracket_number_of_wrestlers_error
|
||||
elsif modified_sixteen_man_number_of_wrestlers_error.length > 0
|
||||
error_string += modified_sixteen_man_number_of_wrestlers_error
|
||||
elsif double_elim_number_of_wrestlers_error.length > 0
|
||||
error_string += double_elim_number_of_wrestlers_error
|
||||
elsif wrestlers_with_higher_seed_than_bracket_size_error.length > 0
|
||||
error_string += wrestlers_with_higher_seed_than_bracket_size_error
|
||||
elsif wrestlers_with_duplicate_original_seed_error.length > 0
|
||||
error_string += wrestlers_with_duplicate_original_seed_error
|
||||
elsif wrestlers_with_out_of_order_seed_error.length > 0
|
||||
error_string += wrestlers_with_out_of_order_seed_error
|
||||
end
|
||||
if error_string.length > 0
|
||||
return "There is a tournament error. #{error_string}"
|
||||
else
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
def reset_and_fill_bout_board
|
||||
reset_mats
|
||||
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
|
||||
break unless assigned_any
|
||||
end
|
||||
end
|
||||
|
||||
def create_backup()
|
||||
TournamentBackupService.new(self, "Manual backup").create_backup
|
||||
end
|
||||
|
||||
def confirm_all_weights_have_original_seeds
|
||||
error_string = wrestlers_with_higher_seed_than_bracket_size_error
|
||||
error_string += wrestlers_with_duplicate_original_seed_error
|
||||
error_string += wrestlers_with_out_of_order_seed_error
|
||||
|
||||
return error_string.blank?
|
||||
end
|
||||
|
||||
def confirm_each_weight_class_has_correct_number_of_wrestlers
|
||||
error_string = pool_to_bracket_number_of_wrestlers_error
|
||||
error_string += modified_sixteen_man_number_of_wrestlers_error
|
||||
error_string += double_elim_number_of_wrestlers_error
|
||||
|
||||
return error_string.blank?
|
||||
end
|
||||
|
||||
# Check if there are any active jobs for this tournament
|
||||
def has_active_jobs?
|
||||
tournament_job_statuses.active.exists?
|
||||
end
|
||||
|
||||
# Get all active jobs for this tournament
|
||||
def active_jobs
|
||||
tournament_job_statuses.active
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def connection_adapter
|
||||
ActiveRecord::Base.connection.adapter_name
|
||||
end
|
||||
end
|
||||
|
||||
5
app/models/tournament_backup.rb
Normal file
5
app/models/tournament_backup.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class TournamentBackup < ApplicationRecord
|
||||
belongs_to :tournament
|
||||
|
||||
validates :backup_data, presence: true
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
class TournamentDelegate < ActiveRecord::Base
|
||||
class TournamentDelegate < ApplicationRecord
|
||||
belongs_to :tournament
|
||||
belongs_to :user
|
||||
end
|
||||
|
||||
22
app/models/tournament_job_status.rb
Normal file
22
app/models/tournament_job_status.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
class TournamentJobStatus < ApplicationRecord
|
||||
belongs_to :tournament, optional: false
|
||||
|
||||
# Validations
|
||||
validates :job_name, presence: true
|
||||
validates :status, presence: true
|
||||
validates_inclusion_of :status, in: ["Queued", "Running", "Errored"], allow_nil: false
|
||||
validates :tournament, presence: true
|
||||
|
||||
# Scopes
|
||||
scope :active, -> { where.not(status: "Errored") }
|
||||
|
||||
# Class methods to find jobs for a tournament
|
||||
def self.for_tournament(tournament)
|
||||
where(tournament_id: tournament.id)
|
||||
end
|
||||
|
||||
# Clean up completed jobs (should be called when job finishes successfully)
|
||||
def self.complete_job(tournament_id, job_name)
|
||||
where(tournament_id: tournament_id, job_name: job_name).destroy_all
|
||||
end
|
||||
end
|
||||
@@ -1,27 +1,68 @@
|
||||
class User < ActiveRecord::Base
|
||||
class User < ApplicationRecord
|
||||
attr_accessor :reset_token
|
||||
|
||||
# Include default devise modules. Others available are:
|
||||
# :confirmable, :lockable, :timeoutable and :omniauthable
|
||||
has_many :tournaments
|
||||
has_many :delegated_tournament_permissions, class_name: "TournamentDelegate"
|
||||
has_many :delegated_school_permissions, class_name: "SchoolDelegate"
|
||||
has_many :delegated_tournament_permissions, class_name: "TournamentDelegate", dependent: :destroy
|
||||
has_many :delegated_school_permissions, class_name: "SchoolDelegate", dependent: :destroy
|
||||
|
||||
devise :database_authenticatable, :registerable,
|
||||
:recoverable, :rememberable, :trackable, :validatable
|
||||
# Replace Devise with has_secure_password
|
||||
has_secure_password
|
||||
|
||||
# Add validations that were handled by Devise
|
||||
validates :email, presence: true, uniqueness: { case_sensitive: false }
|
||||
validates :password, length: { minimum: 6 }, allow_nil: true
|
||||
|
||||
# These are the Devise modules we were using:
|
||||
# devise :database_authenticatable, :registerable,
|
||||
# :recoverable, :rememberable, :trackable, :validatable
|
||||
|
||||
# Returns the hash digest of the given string
|
||||
def self.digest(string)
|
||||
cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost
|
||||
BCrypt::Password.create(string, cost: cost)
|
||||
end
|
||||
|
||||
# Returns a random token
|
||||
def self.new_token
|
||||
SecureRandom.urlsafe_base64
|
||||
end
|
||||
|
||||
# Sets the password reset attributes
|
||||
def create_reset_digest
|
||||
self.reset_token = User.new_token
|
||||
update_columns(reset_digest: User.digest(reset_token), reset_sent_at: Time.zone.now)
|
||||
end
|
||||
|
||||
# Sends password reset email
|
||||
def send_password_reset_email
|
||||
UserMailer.password_reset(self).deliver_now
|
||||
end
|
||||
|
||||
# Returns true if a password reset has expired
|
||||
def password_reset_expired?
|
||||
reset_sent_at < 2.hours.ago
|
||||
end
|
||||
|
||||
# Returns true if the given token matches the digest
|
||||
def authenticated?(attribute, token)
|
||||
digest = send("#{attribute}_digest")
|
||||
return false if digest.nil?
|
||||
BCrypt::Password.new(digest).is_password?(token)
|
||||
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)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Weight < ActiveRecord::Base
|
||||
belongs_to :tournament
|
||||
class Weight < ApplicationRecord
|
||||
belongs_to :tournament, touch: true
|
||||
has_many :wrestlers, dependent: :destroy
|
||||
has_many :matches, dependent: :destroy
|
||||
|
||||
@@ -10,8 +10,10 @@ class Weight < ActiveRecord::Base
|
||||
# passed via layouts/_tournament-navbar.html.erb
|
||||
# tournaments controller does a .split(',') on this string and creates an array via commas
|
||||
# tournament model runs the code via method create_pre_defined_weights
|
||||
HS_WEIGHT_CLASSES = "106,113,120,126,132,138,144,150,157,165,175,190,215,285."
|
||||
HS_WEIGHT_CLASSES = "106,113,120,126,132,138,144,150,157,165,175,190,215,285"
|
||||
HS_GIRLS_WEIGHT_CLASSES = "100,105,110,115,120,125,130,135,140,145,155,170,190,235"
|
||||
MS_WEIGHT_CLASSES = "80,86,92,98,104,110,116,122,128,134,142,150,160,172,205,245"
|
||||
MS_GIRLS_WEIGHT_CLASSES = "72,80,86,92,98,104,110,116,122,128,134,142,155,170,190,235"
|
||||
|
||||
before_destroy do
|
||||
self.tournament.destroy_all_matches
|
||||
@@ -152,5 +154,48 @@ class Weight < ActiveRecord::Base
|
||||
def wrestlers_without_pool_assignment
|
||||
wrestlers.select{|w| w.pool == nil}
|
||||
end
|
||||
|
||||
|
||||
def calculate_bracket_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**Math.log2(num_wrestlers).ceil
|
||||
end
|
||||
|
||||
def highest_bracket_round
|
||||
bracket_matches_sorted_by_round_descending = matches.select{|m| m.bracket_position.include? "Bracket"}.sort_by{|m| m.round}.reverse
|
||||
if bracket_matches_sorted_by_round_descending.size > 0
|
||||
return bracket_matches_sorted_by_round_descending.first.round
|
||||
else
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
def lowest_bracket_round
|
||||
bracket_matches_sorted_by_round_ascending = matches.select{|m| m.bracket_position.include? "Bracket"}.sort_by{|m| m.round}
|
||||
if bracket_matches_sorted_by_round_ascending.size > 0
|
||||
return bracket_matches_sorted_by_round_ascending.first.round
|
||||
else
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
def highest_conso_round
|
||||
conso_matches_sorted_by_round_descending = matches.select{|m| m.bracket_position.include? "Conso"}.sort_by{|m| m.round}.reverse
|
||||
if conso_matches_sorted_by_round_descending.size > 0
|
||||
return conso_matches_sorted_by_round_descending.first.round
|
||||
else
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
def lowest_conso_round
|
||||
conso_matches_sorted_by_round_ascending = matches.select{|m| m.bracket_position.include? "Conso"}.sort_by{|m| m.round}
|
||||
if conso_matches_sorted_by_round_ascending.size > 0
|
||||
return conso_matches_sorted_by_round_ascending.first.round
|
||||
else
|
||||
return nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
class Wrestler < ActiveRecord::Base
|
||||
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"
|
||||
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
|
||||
@@ -18,7 +23,7 @@ class Wrestler < ActiveRecord::Base
|
||||
|
||||
|
||||
def last_finished_match
|
||||
all_matches.select{|m| m.finished == 1}.sort_by{|m| m.bout_number}.last
|
||||
all_matches.select{|m| m.finished == 1}.sort_by{|m| m.finished_at}.last
|
||||
end
|
||||
|
||||
def total_team_points
|
||||
@@ -38,7 +43,7 @@ class Wrestler < ActiveRecord::Base
|
||||
end
|
||||
|
||||
def total_pool_points_for_pool_order
|
||||
CalculateWrestlerTeamScore.new(self).poolPoints + CalculateWrestlerTeamScore.new(self).bonusWinPoints
|
||||
CalculateWrestlerTeamScore.new(self).poolPoints + CalculateWrestlerTeamScore.new(self).pool_bonus_points
|
||||
end
|
||||
|
||||
def unfinished_pool_matches
|
||||
@@ -59,7 +64,7 @@ class Wrestler < ActiveRecord::Base
|
||||
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
|
||||
@@ -85,17 +90,30 @@ class Wrestler < ActiveRecord::Base
|
||||
def unfinished_matches
|
||||
all_matches.select{|m| m.finished != 1}.sort_by{|m| m.bout_number}
|
||||
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}"
|
||||
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
|
||||
if bout_match.first.winner_id != self.id
|
||||
return "L #{bout_match.first.bracket_score_string}"
|
||||
end
|
||||
|
||||
def result_by_id(id)
|
||||
bout_match_results = all_matches.select{|m| m.id == id and m.finished == 1}
|
||||
if bout_match_results.empty?
|
||||
return ""
|
||||
end
|
||||
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
|
||||
|
||||
@@ -107,7 +125,8 @@ class Wrestler < ActiveRecord::Base
|
||||
if all_matches.blank?
|
||||
return false
|
||||
else
|
||||
return true
|
||||
# Original logic checked blank?, not specific round. Reverting to that.
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -119,9 +138,22 @@ class Wrestler < ActiveRecord::Base
|
||||
return round_match.bout_number
|
||||
end
|
||||
end
|
||||
|
||||
def match_id_by_round(round)
|
||||
round_match = all_matches.select{|m| m.round == round}.first
|
||||
if round_match.blank?
|
||||
return "BYE"
|
||||
else
|
||||
return round_match.id
|
||||
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
|
||||
@@ -130,7 +162,9 @@ class Wrestler < ActiveRecord::Base
|
||||
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
|
||||
@@ -138,11 +172,19 @@ class Wrestler < ActiveRecord::Base
|
||||
end
|
||||
|
||||
def championship_advancement_wins
|
||||
matches_won.select{|m| (m.bracket_position == "Quarter" or m.bracket_position == "Semis" or m.bracket_position == "Bracket") and m.win_type != "BYE"}
|
||||
matches_won.select{|m| (m.bracket_position == "Quarter" or m.bracket_position == "Semis" or m.bracket_position.include? "Bracket") and m.win_type != "BYE"}
|
||||
end
|
||||
|
||||
def consolation_advancement_wins
|
||||
matches_won.select{|m| (m.bracket_position == "Conso Semis" or m.bracket_position == "Conso" or m.bracket_position == "Conso Quarter") and m.win_type != "BYE"}
|
||||
matches_won.select{|m| (m.bracket_position.include? "Conso") and m.win_type != "BYE"}
|
||||
end
|
||||
|
||||
def championship_byes
|
||||
matches_won.select{|m| (m.bracket_position == "Quarter" or m.bracket_position == "Semis" or m.bracket_position.include? "Bracket") and m.win_type == "BYE"}
|
||||
end
|
||||
|
||||
def consolation_byes
|
||||
matches_won.select{|m| (m.bracket_position.include? "Conso") and m.win_type == "BYE"}
|
||||
end
|
||||
|
||||
def finished_matches
|
||||
@@ -158,7 +200,8 @@ class Wrestler < ActiveRecord::Base
|
||||
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
|
||||
@@ -196,9 +239,28 @@ class Wrestler < ActiveRecord::Base
|
||||
points_scored
|
||||
end
|
||||
|
||||
def decision_points_scored_pool
|
||||
points_scored = 0
|
||||
decision_wins.select{|m| m.bracket_position == "Pool"}.each do |m|
|
||||
score_of_match = m.score.delete(" ")
|
||||
score_one = score_of_match.partition('-').first.to_i
|
||||
score_two = score_of_match.partition('-').last.to_i
|
||||
if score_one > score_two
|
||||
points_scored = points_scored + score_one
|
||||
elsif score_two > score_one
|
||||
points_scored = points_scored + score_two
|
||||
end
|
||||
end
|
||||
points_scored
|
||||
end
|
||||
|
||||
def fastest_pin
|
||||
pin_wins.sort_by{|m| m.pin_time_in_seconds}.first
|
||||
end
|
||||
|
||||
def fastest_pin_pool
|
||||
pin_wins.select{|m| m.bracket_position == "Pool"}.sort_by{|m| m.pin_time_in_seconds}.first
|
||||
end
|
||||
|
||||
def pin_time
|
||||
time = 0
|
||||
@@ -208,14 +270,28 @@ class Wrestler < ActiveRecord::Base
|
||||
time
|
||||
end
|
||||
|
||||
def pin_time_pool
|
||||
time = 0
|
||||
pin_wins.select{|m| m.bracket_position == "Pool"}.each do | m |
|
||||
time = time + m.pin_time_in_seconds
|
||||
end
|
||||
time
|
||||
end
|
||||
|
||||
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
|
||||
@@ -223,4 +299,26 @@ class Wrestler < ActiveRecord::Base
|
||||
end
|
||||
end
|
||||
|
||||
def long_bracket_name
|
||||
# Revert to original logic
|
||||
return_string = ""
|
||||
if self.original_seed
|
||||
return_string = return_string + "[#{self.original_seed}] "
|
||||
end
|
||||
return_string = return_string + "#{self.name} - #{self.school.name}"
|
||||
if self.season_win && self.season_loss
|
||||
return_string = return_string + " (#{self.season_win}-#{self.season_loss})"
|
||||
end
|
||||
return return_string
|
||||
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
|
||||
|
||||
@@ -1,29 +1,108 @@
|
||||
class AdvanceWrestler
|
||||
def initialize( wrestler, last_match )
|
||||
def initialize(wrestler, last_match)
|
||||
@wrestler = wrestler
|
||||
@tournament = @wrestler.tournament
|
||||
@last_match = last_match
|
||||
end
|
||||
|
||||
def advance
|
||||
if Rails.env.production?
|
||||
self.delay(:job_owner_id => @tournament.id, :job_owner_type => "Advance wrestler #{@wrestler.name} in the bracket").advance_raw
|
||||
else
|
||||
advance_raw
|
||||
end
|
||||
# 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, @tournament.id)
|
||||
end
|
||||
|
||||
def advance_raw
|
||||
pool_to_bracket_advancement if @tournament.tournament_type == "Pool to bracket"
|
||||
DoubleEliminationAdvance.new(@wrestler, @last_match).bracket_advancement if @tournament.tournament_type.include? "Modified 16 Man Double Elimination" or
|
||||
@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,87 +1,78 @@
|
||||
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
|
||||
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
|
||||
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
|
||||
update_new_match(new_match, get_wrestler_number)
|
||||
elsif @last_match.bracket_position == "Conso Semis"
|
||||
# if its a regular double elim
|
||||
if @wrestler.tournament.tournament_type.include? "Regular Double Elimination"
|
||||
new_match = Match.where("bracket_position = ? AND bracket_position_number = ? AND weight_id = ?","3/4",@next_match_position_number.ceil,@wrestler.weight_id).first
|
||||
update_new_match(new_match, get_wrestler_number)
|
||||
# if it's a special bracket where consolations wrestler for 5th
|
||||
elsif @wrestler.tournament.tournament_type.include? "Modified 16 Man Double Elimination"
|
||||
new_match = Match.where("bracket_position = ? AND bracket_position_number = ? AND weight_id = ?","5/6",@next_match_position_number.ceil,@wrestler.weight_id).first
|
||||
update_new_match(new_match, get_wrestler_number)
|
||||
end
|
||||
elsif @last_match.bracket_position == "Conso Quarter"
|
||||
next_round_matches = Match.where("weight_id = ? and bracket_position = ?", @wrestler.weight_id, "Conso Semis").sort_by{|m| m.round}
|
||||
this_round_matches = Match.where("weight_id = ? and round = ? and bracket_position = ?", @wrestler.weight_id, @last_match.round, "Conso Quarter")
|
||||
# bracket position is different depending on if a semi loser is dropping or if there are 4 conso quarter matches
|
||||
if next_round_matches.size == this_round_matches.size
|
||||
# if a semi loser is dropping down
|
||||
new_match = Match.where("bracket_position = ? AND bracket_position_number = ? AND weight_id = ?","Conso Semis",@last_match.bracket_position_number,@wrestler.weight_id).first
|
||||
update_new_match(new_match,2)
|
||||
else
|
||||
# if 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
|
||||
update_new_match(new_match, get_wrestler_number)
|
||||
end
|
||||
elsif @last_match.bracket_position == "Bracket"
|
||||
new_match = Match.where("bracket_position_number = ? and weight_id = ? and round > ? and (bracket_position = ? or bracket_position = ?)", @next_match_position_number.ceil,@wrestler.weight_id, @last_match.round , "Bracket", "Quarter").sort_by{|m| m.round}.first
|
||||
update_new_match(new_match, get_wrestler_number)
|
||||
elsif @last_match.bracket_position == "Conso"
|
||||
next_round = next_round_matches = Match.where("weight_id = ? and round > ? and (bracket_position = ? or bracket_position = ?)", @wrestler.weight_id, @last_match.round, "Conso", "Conso Quarter").sort_by{|m| m.round}.first.round
|
||||
next_round_matches = Match.where("weight_id = ? and round = ? and (bracket_position = ? or bracket_position = ?)", @wrestler.weight_id, next_round, "Conso", "Conso Quarter")
|
||||
this_round_matches = Match.where("weight_id = ? and round = ? and (bracket_position = ? or bracket_position = ?)", @wrestler.weight_id, @last_match.round, "Conso", "Conso Quarter")
|
||||
# if a loser is dropping down
|
||||
if next_round_matches.size == this_round_matches.size
|
||||
new_match = Match.where("bracket_position_number = ? and weight_id = ? and round > ? and (bracket_position = ? or bracket_position = ?)", @last_match.bracket_position_number,@wrestler.weight_id, @last_match.round, "Conso", "Conso Quarter").sort_by{|m| m.round}.first
|
||||
update_new_match(new_match, 2)
|
||||
else
|
||||
new_match = Match.where("bracket_position_number = ? and weight_id = ? and round > ? and (bracket_position = ? or bracket_position = ?)", @next_match_position_number.ceil,@wrestler.weight_id, @last_match.round, "Conso", "Conso Quarter").sort_by{|m| m.round}.first
|
||||
update_new_match(new_match, get_wrestler_number)
|
||||
end
|
||||
|
||||
future_round_matches = @last_match.weight.matches.select{|m| m.round > @last_match.round}
|
||||
next_match = nil
|
||||
next_match_bracket_position = nil
|
||||
next_match_position_number = @next_match_position_number.ceil
|
||||
|
||||
if @last_match.is_championship_match and future_round_matches.size > 0
|
||||
next_match_round = future_round_matches.select{|m| m.is_championship_match}.sort_by{|m| m.round}.first.round
|
||||
next_match_bracket_position = future_round_matches.select{|m| m.is_championship_match}.sort_by{|m| m.round}.first.bracket_position
|
||||
end
|
||||
|
||||
if @last_match.is_consolation_match and future_round_matches.size > 0
|
||||
next_match_round = future_round_matches.select{|m| m.is_consolation_match}.sort_by{|m| m.round}.first.round
|
||||
next_match_bracket_position = future_round_matches.select{|m| m.is_consolation_match}.sort_by{|m| m.round}.first.bracket_position
|
||||
next_match_loser1_name = future_round_matches.select{|m| m.is_consolation_match}.sort_by{|m| m.round}.first.loser1_name
|
||||
# If someone is falling down to them in this round, then their bracket_position_number stays the same
|
||||
if next_match_loser1_name
|
||||
next_match_position_number = @last_match.bracket_position_number
|
||||
end
|
||||
end
|
||||
|
||||
if next_match_bracket_position
|
||||
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
|
||||
update_new_match(next_match, get_wrestler_number)
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
@@ -93,46 +84,90 @@ class DoubleEliminationAdvance
|
||||
end
|
||||
end
|
||||
|
||||
def loser_advance
|
||||
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)
|
||||
if next_match.size > 0
|
||||
next_match = next_match.first
|
||||
next_match.replace_loser_name_with_wrestler(@wrestler,"Loser of #{bout}")
|
||||
if next_match.loser1_name == "BYE" or next_match.loser2_name == "BYE"
|
||||
next_match.winner_id = @wrestler.id
|
||||
next_match.win_type = "BYE"
|
||||
next_match.finished = 1
|
||||
next_match.save
|
||||
next_match.advance_wrestlers
|
||||
end
|
||||
def losers_bracket_advancement
|
||||
bout = @last_match.bout_number
|
||||
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?
|
||||
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
|
||||
next_match.finished_at = Time.current
|
||||
@matches_to_advance << next_match
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
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 = @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 = @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"
|
||||
replace_loser_name_with_bye(seventh_finals, "Loser of #{match.bout_number}")
|
||||
end
|
||||
end
|
||||
end
|
||||
if fifth_finals
|
||||
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"
|
||||
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
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
class ModifiedDoubleEliminationAdvance
|
||||
|
||||
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
|
||||
advance_wrestler
|
||||
advance_double_byes
|
||||
set_bye_for_placement
|
||||
end
|
||||
|
||||
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 = @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 = @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 = @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 = @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 = @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 = @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
|
||||
elsif wrestler_number == 1
|
||||
match.w1 = @wrestler.id
|
||||
end
|
||||
end
|
||||
|
||||
def update_consolation_bye
|
||||
bout = @wrestler.last_match.bout_number
|
||||
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
|
||||
|
||||
def get_wrestler_number
|
||||
if @next_match_position_number != @next_match_position_number.ceil
|
||||
return 1
|
||||
elsif @next_match_position_number == @next_match_position_number.ceil
|
||||
return 2
|
||||
end
|
||||
end
|
||||
|
||||
def losers_bracket_advancement
|
||||
bout = @last_match.bout_number
|
||||
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?
|
||||
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
|
||||
next_match.finished_at = Time.current
|
||||
@matches_to_advance << next_match
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
def advance_double_byes
|
||||
weight = @wrestler.weight
|
||||
@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 = @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" 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 && next_match.bracket_position_number == next_match_position_number
|
||||
next_match.loser2_name = "BYE"
|
||||
elsif next_match
|
||||
next_match.loser1_name = "BYE"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def set_bye_for_placement
|
||||
weight = @wrestler.weight
|
||||
seventh_finals = @matches.select{|match| match.weight_id == weight.id && match.bracket_position == '7/8'}.first
|
||||
if seventh_finals
|
||||
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"
|
||||
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
|
||||
|
||||
@@ -5,17 +5,25 @@ class PoolOrder
|
||||
|
||||
def getPoolOrder
|
||||
setOriginalPoints
|
||||
while checkForTieBreakers == true
|
||||
breakTie
|
||||
while checkForTies(@wrestlers) == true
|
||||
getWrestlersOrderByPoolAdvancePoints.each do |wrestler|
|
||||
wrestlers_with_same_points = getWrestlersOrderByPoolAdvancePoints.select{|w| w.poolAdvancePoints == wrestler.poolAdvancePoints}
|
||||
if wrestlers_with_same_points.size > 1
|
||||
breakTie(wrestlers_with_same_points)
|
||||
end
|
||||
end
|
||||
end
|
||||
@wrestlers.sort_by{|w| w.poolAdvancePoints}.reverse.each_with_index do |wrestler, index|
|
||||
placement = index + 1
|
||||
wrestler.pool_placement = placement
|
||||
wrestler.save
|
||||
getWrestlersOrderByPoolAdvancePoints.each_with_index do |wrestler, index|
|
||||
placement = index + 1
|
||||
wrestler.pool_placement = placement
|
||||
end
|
||||
@wrestlers.sort_by{|w| w.poolAdvancePoints}.reverse!
|
||||
end
|
||||
|
||||
def getWrestlersOrderByPoolAdvancePoints
|
||||
@wrestlers.sort_by{|w| w.poolAdvancePoints}.reverse
|
||||
end
|
||||
|
||||
def setOriginalPoints
|
||||
@wrestlers.each do |w|
|
||||
w.pool_placement_tiebreaker = nil
|
||||
@@ -24,16 +32,16 @@ class PoolOrder
|
||||
end
|
||||
end
|
||||
|
||||
def checkForTieBreakers
|
||||
if wrestlersWithSamePoints.size > 1
|
||||
def checkForTies(wrestlers_to_check)
|
||||
if wrestlersWithSamePoints(wrestlers_to_check).size > 1
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
def wrestlersWithSamePoints
|
||||
@wrestlers.each do |w|
|
||||
wrestlersWithSamePointsLocal = @wrestlers.select{|wr| wr.poolAdvancePoints == w.poolAdvancePoints}
|
||||
def wrestlersWithSamePoints(wrestlers_to_check)
|
||||
wrestlers_to_check.each do |w|
|
||||
wrestlersWithSamePointsLocal = wrestlers_to_check.select{|wr| wr.poolAdvancePoints == w.poolAdvancePoints}
|
||||
if wrestlersWithSamePointsLocal.size > 1
|
||||
return wrestlersWithSamePointsLocal
|
||||
end
|
||||
@@ -41,42 +49,47 @@ class PoolOrder
|
||||
return []
|
||||
end
|
||||
|
||||
def ifWrestlersWithSamePointsIsSameAsOriginal(originalTieSize)
|
||||
if wrestlersWithSamePoints.size == originalTieSize
|
||||
def ifWrestlersWithSamePointsIsSameAsOriginal(originalTieSize,wrestlers_to_check)
|
||||
if wrestlersWithSamePoints(wrestlers_to_check).size == originalTieSize
|
||||
return true
|
||||
else
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
def breakTie
|
||||
originalTieSize = wrestlersWithSamePoints.size
|
||||
deductedPoints(originalTieSize) if ifWrestlersWithSamePointsIsSameAsOriginal(originalTieSize)
|
||||
def breakTie(wrestlers_with_same_points)
|
||||
originalTieSize = wrestlers_with_same_points.size
|
||||
deductedPoints(originalTieSize,wrestlers_with_same_points) if ifWrestlersWithSamePointsIsSameAsOriginal(originalTieSize,wrestlers_with_same_points)
|
||||
if originalTieSize == 2
|
||||
headToHead if ifWrestlersWithSamePointsIsSameAsOriginal(originalTieSize)
|
||||
headToHead(wrestlers_with_same_points) if ifWrestlersWithSamePointsIsSameAsOriginal(originalTieSize,wrestlers_with_same_points)
|
||||
end
|
||||
teamPoints if ifWrestlersWithSamePointsIsSameAsOriginal(originalTieSize)
|
||||
mostFalls if ifWrestlersWithSamePointsIsSameAsOriginal(originalTieSize)
|
||||
mostTechs if ifWrestlersWithSamePointsIsSameAsOriginal(originalTieSize)
|
||||
mostMajors if ifWrestlersWithSamePointsIsSameAsOriginal(originalTieSize)
|
||||
mostDecisionPointsScored if ifWrestlersWithSamePointsIsSameAsOriginal(originalTieSize)
|
||||
fastest_pins if ifWrestlersWithSamePointsIsSameAsOriginal(originalTieSize)
|
||||
coinFlip if ifWrestlersWithSamePointsIsSameAsOriginal(originalTieSize)
|
||||
teamPoints(wrestlers_with_same_points) if ifWrestlersWithSamePointsIsSameAsOriginal(originalTieSize,wrestlers_with_same_points)
|
||||
mostFalls(wrestlers_with_same_points) if ifWrestlersWithSamePointsIsSameAsOriginal(originalTieSize,wrestlers_with_same_points)
|
||||
mostTechs(wrestlers_with_same_points) if ifWrestlersWithSamePointsIsSameAsOriginal(originalTieSize,wrestlers_with_same_points)
|
||||
mostMajors(wrestlers_with_same_points) if ifWrestlersWithSamePointsIsSameAsOriginal(originalTieSize,wrestlers_with_same_points)
|
||||
mostDecisionPointsScored(wrestlers_with_same_points) if ifWrestlersWithSamePointsIsSameAsOriginal(originalTieSize,wrestlers_with_same_points)
|
||||
fastest_pins(wrestlers_with_same_points) if ifWrestlersWithSamePointsIsSameAsOriginal(originalTieSize,wrestlers_with_same_points)
|
||||
coinFlip(wrestlers_with_same_points) if ifWrestlersWithSamePointsIsSameAsOriginal(originalTieSize,wrestlers_with_same_points)
|
||||
end
|
||||
|
||||
|
||||
def headToHead
|
||||
wrestlersWithSamePoints.each do |wr|
|
||||
otherWrestler = wrestlersWithSamePoints.select{|w| w.id != wr.id}.first
|
||||
if otherWrestler and wr.match_against(otherWrestler).first.winner_id == wr.id
|
||||
addPointsToWrestlersAhead(wr)
|
||||
wr.pool_placement_tiebreaker = "Head to Head"
|
||||
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
|
||||
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
|
||||
|
||||
def addPoints(wrestler)
|
||||
# addPointsToWrestlersAhead(wrestler)
|
||||
# Cannot go here because if team points are the same the first with points added will stay ahead
|
||||
wrestler.poolAdvancePoints = wrestler.poolAdvancePoints + 1
|
||||
end
|
||||
|
||||
@@ -87,13 +100,13 @@ class PoolOrder
|
||||
end
|
||||
end
|
||||
|
||||
def deductedPoints(originalTieSize)
|
||||
def deductedPoints(originalTieSize,wrestlers_with_same_points)
|
||||
pointsArray = []
|
||||
wrestlersWithSamePoints.each do |w|
|
||||
wrestlers_with_same_points.each do |w|
|
||||
pointsArray << w.total_points_deducted
|
||||
end
|
||||
leastPoints = pointsArray.min
|
||||
wrestlersWithLeastDeductedPoints = wrestlersWithSamePoints.select{|w| w.total_points_deducted == leastPoints}
|
||||
wrestlersWithLeastDeductedPoints = wrestlers_with_same_points.select{|w| w.total_points_deducted == leastPoints}
|
||||
addPointsToWrestlersAhead(wrestlersWithLeastDeductedPoints.first)
|
||||
if wrestlersWithLeastDeductedPoints.size != originalTieSize
|
||||
wrestlersWithLeastDeductedPoints.each do |wr|
|
||||
@@ -103,38 +116,37 @@ class PoolOrder
|
||||
end
|
||||
end
|
||||
|
||||
def mostDecisionPointsScored
|
||||
def mostDecisionPointsScored(wrestlers_with_same_points)
|
||||
pointsArray = []
|
||||
wrestlersWithSamePoints.each do |w|
|
||||
pointsArray << w.decision_points_scored
|
||||
wrestlers_with_same_points.each do |w|
|
||||
pointsArray << w.decision_points_scored_pool
|
||||
end
|
||||
mostPoints = pointsArray.max
|
||||
wrestlersWithMostPoints = wrestlersWithSamePoints.select{|w| w.decision_points_scored == mostPoints}
|
||||
wrestlersWithMostPoints = wrestlers_with_same_points.select{|w| w.decision_points_scored_pool == mostPoints}
|
||||
addPointsToWrestlersAhead(wrestlersWithMostPoints.first)
|
||||
wrestlersWithMostPoints.each do |wr|
|
||||
wr.pool_placement_tiebreaker = "Points Scored"
|
||||
wr.pool_placement_tiebreaker = "Decision Points Scored"
|
||||
addPoints(wr)
|
||||
end
|
||||
secondPoints = pointsArray.sort[-2]
|
||||
wrestlersWithSecondMostPoints = wrestlersWithSamePoints.select{|w| w.decision_points_scored == secondPoints}
|
||||
wrestlersWithSecondMostPoints = wrestlers_with_same_points.select{|w| w.decision_points_scored_pool == secondPoints}
|
||||
addPointsToWrestlersAhead(wrestlersWithSecondMostPoints.first)
|
||||
wrestlersWithSecondMostPoints.each do |wr|
|
||||
wr.pool_placement_tiebreaker = "Points Scored"
|
||||
wr.pool_placement_tiebreaker = "Decision Points Scored"
|
||||
addPoints(wr)
|
||||
end
|
||||
end
|
||||
|
||||
def fastest_pins
|
||||
def fastest_pins(wrestlers_with_same_points)
|
||||
wrestlersWithSamePointsWithPins = []
|
||||
wrestlersWithSamePoints.each do |wr|
|
||||
if wr.pin_wins.size > 0
|
||||
wrestlers_with_same_points.each do |wr|
|
||||
if wr.pin_wins.select{|m| m.bracket_position == "Pool"}.size > 0
|
||||
wrestlersWithSamePointsWithPins << wr
|
||||
end
|
||||
end
|
||||
if wrestlersWithSamePointsWithPins.size > 0
|
||||
fastest = wrestlersWithSamePointsWithPins.sort_by{|w| w.fastest_pin.pin_time_in_seconds}.first.fastest_pin
|
||||
secondFastest = wrestlersWithSamePointsWithPins.sort_by{|w| w.fastest_pin.pin_time_in_seconds}.second.fastest_pin
|
||||
wrestlersWithFastestPin = wrestlersWithSamePointsWithPins.select{|w| w.fastest_pin.pin_time_in_seconds == fastest.pin_time_in_seconds}
|
||||
fastest = wrestlersWithSamePointsWithPins.sort_by{|w| w.pin_time_pool}.first.pin_time_pool
|
||||
wrestlersWithFastestPin = wrestlersWithSamePointsWithPins.select{|w| w.pin_time_pool == fastest}
|
||||
addPointsToWrestlersAhead(wrestlersWithFastestPin.first)
|
||||
wrestlersWithFastestPin.each do |wr|
|
||||
wr.pool_placement_tiebreaker = "Pin Time"
|
||||
@@ -143,13 +155,13 @@ class PoolOrder
|
||||
end
|
||||
end
|
||||
|
||||
def teamPoints
|
||||
pointsArray = []
|
||||
wrestlersWithSamePoints.each do |w|
|
||||
pointsArray << w.total_pool_points_for_pool_order
|
||||
def teamPoints(wrestlers_with_same_points)
|
||||
teamPointsArray = []
|
||||
wrestlers_with_same_points.each do |w|
|
||||
teamPointsArray << w.total_pool_points_for_pool_order
|
||||
end
|
||||
mostPoints = pointsArray.max
|
||||
wrestlersSortedByTeamPoints = wrestlersWithSamePoints.select{|w| w.total_pool_points_for_pool_order == mostPoints}
|
||||
mostPoints = teamPointsArray.max
|
||||
wrestlersSortedByTeamPoints = wrestlers_with_same_points.select{|w| w.total_pool_points_for_pool_order == mostPoints}
|
||||
addPointsToWrestlersAhead(wrestlersSortedByTeamPoints.first)
|
||||
wrestlersSortedByTeamPoints.each do |wr|
|
||||
wr.pool_placement_tiebreaker = "Team Points"
|
||||
@@ -157,50 +169,56 @@ class PoolOrder
|
||||
end
|
||||
end
|
||||
|
||||
def mostFalls
|
||||
pointsArray = []
|
||||
wrestlersWithSamePoints.each do |w|
|
||||
pointsArray << w.pin_wins.size
|
||||
def mostFalls(wrestlers_with_same_points)
|
||||
mostPins = []
|
||||
wrestlers_with_same_points.each do |w|
|
||||
mostPins << w.pin_wins.select{|m| m.bracket_position == "Pool"}.size
|
||||
end
|
||||
pinsMax = mostPins.max
|
||||
wrestlersSortedByFallWins = wrestlers_with_same_points.select{|w| w.pin_wins.select{|m| m.bracket_position == "Pool"}.size == pinsMax}
|
||||
if pinsMax > 0
|
||||
addPointsToWrestlersAhead(wrestlersSortedByFallWins.first)
|
||||
wrestlersSortedByFallWins.each do |wr|
|
||||
wr.pool_placement_tiebreaker = "Most Pins"
|
||||
addPoints(wr)
|
||||
end
|
||||
end
|
||||
mostPoints = pointsArray.max
|
||||
wrestlersSortedByFallWins = wrestlersWithSamePoints.select{|w| w.pin_wins.size == mostPoints}
|
||||
addPointsToWrestlersAhead(wrestlersSortedByFallWins.first)
|
||||
wrestlersSortedByFallWins.each do |wr|
|
||||
wr.pool_placement_tiebreaker = "Most Pins"
|
||||
addPoints(wr)
|
||||
end
|
||||
end
|
||||
|
||||
def mostTechs
|
||||
pointsArray = []
|
||||
wrestlersWithSamePoints.each do |w|
|
||||
pointsArray << w.tech_wins.size
|
||||
def mostTechs(wrestlers_with_same_points)
|
||||
techsArray = []
|
||||
wrestlers_with_same_points.each do |w|
|
||||
techsArray << w.tech_wins.select{|m| m.bracket_position == "Pool"}.size
|
||||
end
|
||||
mostTechsWins = techsArray.max
|
||||
wrestlersSortedByTechWins = wrestlers_with_same_points.select{|w| w.tech_wins.select{|m| m.bracket_position == "Pool"}.size == mostTechsWins}
|
||||
if mostTechsWins > 0
|
||||
addPointsToWrestlersAhead(wrestlersSortedByTechWins.first)
|
||||
wrestlersSortedByTechWins.each do |wr|
|
||||
wr.pool_placement_tiebreaker = "Most Techs"
|
||||
addPoints(wr)
|
||||
end
|
||||
end
|
||||
mostPoints = pointsArray.max
|
||||
wrestlersSortedByTechWins = wrestlersWithSamePoints.select{|w| w.tech_wins.size == mostPoints}
|
||||
addPointsToWrestlersAhead(wrestlersSortedByTechWins.first)
|
||||
wrestlersSortedByTechWins.each do |wr|
|
||||
wr.pool_placement_tiebreaker = "Most Techs"
|
||||
addPoints(wr)
|
||||
end
|
||||
end
|
||||
|
||||
def mostMajors
|
||||
pointsArray = []
|
||||
wrestlersWithSamePoints.each do |w|
|
||||
pointsArray << w.major_wins.size
|
||||
def mostMajors(wrestlers_with_same_points)
|
||||
majorsArray = []
|
||||
wrestlers_with_same_points.each do |w|
|
||||
majorsArray << w.major_wins.select{|m| m.bracket_position == "Pool"}.size
|
||||
end
|
||||
mostMajorWins = majorsArray.max
|
||||
wrestlersSortedByMajorWins = wrestlers_with_same_points.select{|w| w.major_wins.select{|m| m.bracket_position == "Pool"}.size == mostMajorWins}
|
||||
if mostMajorWins > 0
|
||||
addPointsToWrestlersAhead(wrestlersSortedByMajorWins.first)
|
||||
wrestlersSortedByMajorWins.each do |wr|
|
||||
wr.pool_placement_tiebreaker = "Most Majors"
|
||||
addPoints(wr)
|
||||
end
|
||||
end
|
||||
mostPoints = pointsArray.max
|
||||
wrestlersSortedByMajorWins = wrestlersWithSamePoints.select{|w| w.major_wins.size == mostPoints}
|
||||
addPointsToWrestlersAhead(wrestlersSortedByMajorWins.first)
|
||||
wrestlersSortedByMajorWins.each do |wr|
|
||||
wr.pool_placement_tiebreaker = "Most Majors"
|
||||
addPoints(wr)
|
||||
end
|
||||
end
|
||||
|
||||
def coinFlip
|
||||
wrestler = wrestlersWithSamePoints.sample
|
||||
def coinFlip(wrestlers_with_same_points)
|
||||
wrestler = wrestlers_with_same_points.sample
|
||||
wrestler.pool_placement_tiebreaker = "Coin Flip"
|
||||
addPointsToWrestlersAhead(wrestler)
|
||||
addPoints(wrestler)
|
||||
|
||||
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
|
||||
@@ -1,13 +1,186 @@
|
||||
class DoubleEliminationGenerateLoserNames
|
||||
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|
|
||||
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 assign_loser_names_in_memory(weight, match_rows)
|
||||
bracket_size = weight.calculate_bracket_size
|
||||
return if bracket_size <= 2
|
||||
|
||||
rows = match_rows.select { |row| row[:weight_id] == weight.id }
|
||||
num_placers = @tournament.number_of_placers
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
def assign_loser_names
|
||||
@tournament.weights.each do |weight|
|
||||
SixteenManDoubleEliminationGenerateLoserNames.new(weight).assign_loser_names_for_weight if weight.wrestlers.size >= 9 and weight.wrestlers.size <= 16
|
||||
EightManDoubleEliminationGenerateLoserNames.new(weight).assign_loser_names_for_weight if weight.wrestlers.size >= 4 and weight.wrestlers.size <= 8
|
||||
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?
|
||||
|
||||
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
|
||||
|
||||
if map[:both_wrestlers]
|
||||
idx += 1
|
||||
champ_match2 = champ[idx]
|
||||
if champ_match2
|
||||
if is_first_feed && single_competitor_match_row?(champ_match2)
|
||||
cm[:loser2_name] = "BYE"
|
||||
else
|
||||
cm[:loser2_name] = "Loser of #{champ_match2[:bout_number]}"
|
||||
end
|
||||
else
|
||||
cm[:loser2_name] = nil
|
||||
end
|
||||
end
|
||||
idx += 1
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
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
|
||||
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,12 +1,250 @@
|
||||
class DoubleEliminationMatchGeneration
|
||||
def initialize( tournament )
|
||||
@tournament = tournament
|
||||
def initialize(tournament, weights: nil)
|
||||
@tournament = tournament
|
||||
@weights = weights
|
||||
end
|
||||
|
||||
def generate_matches
|
||||
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
|
||||
|
||||
def generate_matches
|
||||
@tournament.weights.each do |weight|
|
||||
SixteenManDoubleEliminationMatchGeneration.new(weight).generate_matches_for_weight if weight.wrestlers.size >= 9 and weight.wrestlers.size <= 16
|
||||
EightManDoubleEliminationMatchGeneration.new(weight).generate_matches_for_weight if weight.wrestlers.size >= 4 and weight.wrestlers.size <= 8
|
||||
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_match_rows_for_weight(weight)
|
||||
bracket_size = weight.calculate_bracket_size
|
||||
bracket_info = define_bracket_matches(bracket_size)
|
||||
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]
|
||||
|
||||
rows << create_matchup_from_seed(
|
||||
seed1,
|
||||
seed2,
|
||||
bracket_position,
|
||||
bracket_pos_number,
|
||||
round_number,
|
||||
weight
|
||||
)
|
||||
end
|
||||
|
||||
# 2) Championship rounds
|
||||
bracket_info[:championship_rounds].each do |round_info|
|
||||
bracket_position = round_info[:bracket_position]
|
||||
matches_this_round = round_info[:number_of_matches]
|
||||
round_number = round_info[:round]
|
||||
|
||||
matches_this_round.times do |i|
|
||||
rows << create_matchup(
|
||||
nil,
|
||||
nil,
|
||||
bracket_position,
|
||||
i + 1,
|
||||
round_number,
|
||||
weight
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# 3) Consolation rounds
|
||||
bracket_info[:consolation_rounds].each do |round_info|
|
||||
bracket_position = round_info[:bracket_position]
|
||||
matches_this_round = round_info[:number_of_matches]
|
||||
round_number = round_info[:round]
|
||||
|
||||
matches_this_round.times do |i|
|
||||
rows << create_matchup(
|
||||
nil,
|
||||
nil,
|
||||
bracket_position,
|
||||
i + 1,
|
||||
round_number,
|
||||
weight
|
||||
)
|
||||
end
|
||||
|
||||
# 5/6, 7/8 placing logic
|
||||
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 && @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
|
||||
|
||||
# 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_rows_to_largest_bracket(rows_by_weight_id)
|
||||
largest_weight = generation_weights.max_by { |w| w.calculate_bracket_size }
|
||||
return unless largest_weight
|
||||
|
||||
position_to_round = {}
|
||||
rows_by_weight_id.fetch(largest_weight.id, []).each do |row|
|
||||
position_to_round[row[:bracket_position]] ||= row[:round]
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
###########################################################################
|
||||
# Helper methods
|
||||
###########################################################################
|
||||
def generation_weights
|
||||
@weights || @tournament.weights.to_a
|
||||
end
|
||||
|
||||
def wrestler_with_seed(seed, weight)
|
||||
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)
|
||||
create_matchup(
|
||||
wrestler_with_seed(w1_seed, weight),
|
||||
wrestler_with_seed(w2_seed, weight),
|
||||
bracket_position,
|
||||
bracket_position_number,
|
||||
round,
|
||||
weight
|
||||
)
|
||||
end
|
||||
|
||||
def create_matchup(w1, w2, bracket_position, bracket_position_number, round, weight)
|
||||
{
|
||||
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
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
class EightManDoubleEliminationGenerateLoserNames
|
||||
def initialize( weight )
|
||||
@weight = weight
|
||||
end
|
||||
|
||||
def assign_loser_names_for_weight
|
||||
matches_by_weight = nil
|
||||
matches_by_weight = @weight.matches
|
||||
conso_round_2(matches_by_weight)
|
||||
conso_round_4(matches_by_weight)
|
||||
fifth_sixth(matches_by_weight)
|
||||
save_matches(matches_by_weight)
|
||||
matches_by_weight = @weight.matches.reload
|
||||
advance_bye_matches_championship(matches_by_weight)
|
||||
save_matches(matches_by_weight)
|
||||
end
|
||||
|
||||
def conso_round_2(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 == 1 and m.round == 1 and m.bracket_position == "Quarter"}.first.bout_number}"
|
||||
match.loser2_name = "Loser of #{matches.select{|m| m.bracket_position_number == 2 and m.round == 1 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.round == 1 and m.bracket_position == "Quarter"}.first.bout_number}"
|
||||
match.loser2_name = "Loser of #{matches.select{|m| m.bracket_position_number == 4 and m.round == 1 and m.bracket_position == "Quarter"}.first.bout_number}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def conso_round_4(matches)
|
||||
matches.select{|m| m.bracket_position == "Conso Semis"}.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 == 2 and m.bracket_position == "Semis"}.first.bout_number}"
|
||||
elsif match.bracket_position_number == 2
|
||||
match.loser1_name = "Loser of #{matches.select{|m| m.bracket_position_number == 1 and m.bracket_position == "Semis"}.first.bout_number}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def fifth_sixth(matches)
|
||||
matches.select{|m| m.bracket_position == "5/6"}.sort_by{|m| m.bracket_position_number}.each do |match|
|
||||
match.loser1_name = "Loser of #{matches.select{|m| m.bracket_position == "Conso Semis"}.first.bout_number}"
|
||||
match.loser2_name = "Loser of #{matches.select{|m| m.bracket_position == "Conso Semis"}.second.bout_number}"
|
||||
end
|
||||
end
|
||||
|
||||
def advance_bye_matches_championship(matches)
|
||||
matches.select{|m| m.round == 1 and m.bracket_position == "Quarter"}.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.save
|
||||
match.advance_wrestlers
|
||||
elsif match.w2 != nil
|
||||
match.winner_id = match.w2
|
||||
match.loser1_name = "BYE"
|
||||
match.save
|
||||
match.advance_wrestlers
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def save_matches(matches)
|
||||
matches.each do |m|
|
||||
m.save!
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user