From 0929719a845897cc8567cf972e07a69a71f0fa6f Mon Sep 17 00:00:00 2001 From: Star Rauchenberger Date: Thu, 30 Nov 2023 13:29:08 -0500 Subject: Migrate to a full rails app --- .gitattributes | 9 + .gitignore | 46 +- .ruby-version | 1 + Gemfile | 82 +- Gemfile.lock | 224 +++-- README.md | 2 +- Rakefile | 12 +- app/assets/audio/panel_abort_tracing.aac | Bin 0 -> 5606 bytes app/assets/audio/panel_failure.aac | Bin 0 -> 8543 bytes app/assets/audio/panel_start_tracing.aac | Bin 0 -> 12082 bytes app/assets/audio/panel_success.aac | Bin 0 -> 46061 bytes app/assets/audio/wittle/.keep | 0 app/assets/audio/wittle/panel_abort_tracing.aac | Bin 5606 -> 0 bytes app/assets/audio/wittle/panel_failure.aac | Bin 8543 -> 0 bytes app/assets/audio/wittle/panel_start_tracing.aac | Bin 12082 -> 0 bytes app/assets/audio/wittle/panel_success.aac | Bin 46061 -> 0 bytes app/assets/config/manifest.js | 6 + app/assets/config/wittle_manifest.js | 4 - app/assets/images/.keep | 0 app/assets/images/slider.png | Bin 0 -> 20100 bytes app/assets/images/wittle/slider.png | Bin 20100 -> 0 bytes app/assets/images/wittle/wittle_expert.png | Bin 155050 -> 0 bytes app/assets/images/wittle/wittle_hard.png | Bin 92194 -> 0 bytes app/assets/images/wittle/wittle_header.png | Bin 82868 -> 0 bytes app/assets/images/wittle/wittle_normal.png | Bin 81341 -> 0 bytes app/assets/images/wittle_expert.png | Bin 0 -> 155050 bytes app/assets/images/wittle_hard.png | Bin 0 -> 92194 bytes app/assets/images/wittle_header.png | Bin 0 -> 82868 bytes app/assets/images/wittle_normal.png | Bin 0 -> 81341 bytes app/assets/javascripts/application.js | 2 + app/assets/javascripts/custom_mechanics.js | 201 ++++ app/assets/javascripts/display2.js | 316 ++++++ app/assets/javascripts/polyominos.js | 331 ++++++ app/assets/javascripts/puzzle.js | 538 ++++++++++ app/assets/javascripts/serializer.js | 365 +++++++ app/assets/javascripts/solve.js | 531 ++++++++++ app/assets/javascripts/svg.js | 422 ++++++++ app/assets/javascripts/trace2.js | 1055 ++++++++++++++++++++ app/assets/javascripts/utilities.js.erb | 498 +++++++++ app/assets/javascripts/validate.js | 391 ++++++++ app/assets/javascripts/wittle.js | 5 + app/assets/javascripts/wittle/application.js | 14 - app/assets/javascripts/wittle/custom_mechanics.js | 201 ---- app/assets/javascripts/wittle/display2.js | 316 ------ app/assets/javascripts/wittle/polyominos.js | 331 ------ app/assets/javascripts/wittle/puzzle.js | 538 ---------- app/assets/javascripts/wittle/serializer.js | 365 ------- app/assets/javascripts/wittle/solve.js | 531 ---------- app/assets/javascripts/wittle/svg.js | 422 -------- app/assets/javascripts/wittle/trace2.js | 1055 -------------------- app/assets/javascripts/wittle/utilities.js.erb | 498 --------- app/assets/javascripts/wittle/validate.js | 391 -------- app/assets/javascripts/wittle/wittle.js | 5 - app/assets/stylesheets/application.css | 15 + app/assets/stylesheets/general.css.scss | 263 +++++ app/assets/stylesheets/wittle/application.css | 15 - app/assets/stylesheets/wittle/general.css.scss | 263 ----- app/channels/application_cable/channel.rb | 4 + app/channels/application_cable/connection.rb | 4 + app/controllers/application_controller.rb | 2 + app/controllers/puzzles_controller.rb | 109 ++ app/controllers/wittle/application_controller.rb | 4 - app/controllers/wittle/puzzles_controller.rb | 120 --- app/helpers/application_helper.rb | 2 + app/helpers/puzzles_helper.rb | 7 + app/helpers/wittle/application_helper.rb | 4 - app/helpers/wittle/puzzles_helper.rb | 9 - app/jobs/application_job.rb | 7 + app/jobs/wittle/application_job.rb | 4 - app/mailers/application_mailer.rb | 4 + app/mailers/wittle/application_mailer.rb | 6 - app/models/application_record.rb | 3 + app/models/puzzle.rb | 14 + app/models/score.rb | 7 + app/models/wittle/application_record.rb | 5 - app/models/wittle/puzzle.rb | 16 - app/models/wittle/score.rb | 9 - app/views/layouts/application.html.haml | 13 + app/views/layouts/mailer.html.erb | 13 + app/views/layouts/mailer.text.erb | 1 + app/views/layouts/wittle/application.html.haml | 13 - app/views/puzzles/_handle_puzzle.html.erb | 76 ++ app/views/puzzles/_submission.html.haml | 9 + app/views/puzzles/about.html.haml | 53 + app/views/puzzles/index.html.haml | 26 + app/views/puzzles/show.html.haml | 41 + app/views/puzzles/solve.js.erb | 5 + app/views/wittle/puzzles/_handle_puzzle.html.erb | 76 -- app/views/wittle/puzzles/_submission.html.haml | 9 - app/views/wittle/puzzles/about.html.haml | 53 - app/views/wittle/puzzles/index.html.haml | 26 - app/views/wittle/puzzles/show.html.haml | 41 - app/views/wittle/puzzles/solve.js.erb | 5 - bin/bundle | 109 ++ bin/rails | 16 +- bin/rake | 4 + bin/setup | 33 + config.ru | 6 + config/application.rb | 27 + config/boot.rb | 4 + config/cable.yml | 10 + config/credentials.yml.enc | 1 + config/database.yml | 25 + config/environment.rb | 5 + config/environments/development.rb | 76 ++ config/environments/production.rb | 98 ++ config/environments/test.rb | 64 ++ config/initializers/assets.rb | 12 + config/initializers/content_security_policy.rb | 25 + config/initializers/filter_parameter_logging.rb | 8 + config/initializers/inflections.rb | 16 + config/initializers/permissions_policy.rb | 13 + config/locales/en.yml | 31 + config/puma.rb | 35 + config/routes.rb | 2 +- config/storage.yml | 34 + db/migrate/20231028205751_create_wittle_puzzles.rb | 11 - db/migrate/20231028210722_create_wittle_scores.rb | 12 - db/migrate/20231130173455_create_puzzles.rb | 11 + db/migrate/20231130173513_create_scores.rb | 12 + db/schema.rb | 32 + db/seeds.rb | 9 + lib/assets/.keep | 0 lib/keep | 0 lib/tasks/.keep | 0 lib/tasks/wittle_tasks.rake | 8 +- lib/wittle.rb | 6 - lib/wittle/engine.rb | 11 - lib/wittle/version.rb | 3 - log/.keep | 0 public/404.html | 67 ++ public/422.html | 67 ++ public/500.html | 66 ++ public/apple-touch-icon-precomposed.png | 0 public/apple-touch-icon.png | 0 public/favicon.ico | 0 public/robots.txt | 1 + storage/.keep | 0 test/application_system_test_case.rb | 5 + test/channels/application_cable/connection_test.rb | 13 + test/controllers/puzzles_controller_test.rb | 7 + test/controllers/wittle/puzzles_controller_test.rb | 12 - test/dummy/Rakefile | 6 - test/dummy/app/assets/config/manifest.js | 3 - test/dummy/app/assets/images/.keep | 0 test/dummy/app/assets/stylesheets/application.css | 15 - .../app/channels/application_cable/channel.rb | 4 - .../app/channels/application_cable/connection.rb | 4 - .../app/controllers/application_controller.rb | 2 - test/dummy/app/controllers/concerns/.keep | 0 test/dummy/app/helpers/application_helper.rb | 2 - test/dummy/app/jobs/application_job.rb | 7 - test/dummy/app/mailers/application_mailer.rb | 4 - test/dummy/app/models/application_record.rb | 3 - test/dummy/app/models/concerns/.keep | 0 test/dummy/app/views/layouts/application.html.erb | 15 - test/dummy/app/views/layouts/mailer.html.erb | 13 - test/dummy/app/views/layouts/mailer.text.erb | 1 - test/dummy/bin/rails | 4 - test/dummy/bin/rake | 4 - test/dummy/bin/setup | 33 - test/dummy/config.ru | 6 - test/dummy/config/application.rb | 29 - test/dummy/config/boot.rb | 5 - test/dummy/config/cable.yml | 10 - test/dummy/config/database.yml | 25 - test/dummy/config/environment.rb | 5 - test/dummy/config/environments/development.rb | 76 -- test/dummy/config/environments/production.rb | 97 -- test/dummy/config/environments/test.rb | 64 -- test/dummy/config/initializers/assets.rb | 12 - .../config/initializers/content_security_policy.rb | 25 - .../initializers/filter_parameter_logging.rb | 8 - test/dummy/config/initializers/inflections.rb | 16 - .../config/initializers/permissions_policy.rb | 13 - test/dummy/config/locales/en.yml | 31 - test/dummy/config/puma.rb | 35 - test/dummy/config/routes.rb | 3 - test/dummy/config/storage.yml | 34 - test/dummy/db/schema.rb | 32 - test/dummy/lib/assets/.keep | 0 test/dummy/public/404.html | 67 -- test/dummy/public/422.html | 67 -- test/dummy/public/500.html | 66 -- test/dummy/public/apple-touch-icon-precomposed.png | 0 test/dummy/public/apple-touch-icon.png | 0 test/dummy/public/favicon.ico | 0 test/fixtures/puzzles.yml | 11 + test/fixtures/scores.yml | 13 + test/fixtures/wittle/puzzles.yml | 11 - test/fixtures/wittle/scores.yml | 13 - test/integration/navigation_test.rb | 7 - test/models/puzzle_test.rb | 7 + test/models/score_test.rb | 7 + test/models/wittle/puzzle_test.rb | 9 - test/models/wittle/score_test.rb | 9 - test/system/.keep | 0 test/test_helper.rb | 24 +- test/wittle_test.rb | 7 - tmp/.keep | 0 tmp/pids/.keep | 0 tmp/storage/.keep | 0 vendor/.keep | 0 vendor/javascript/.keep | 0 wittle.gemspec | 24 - 205 files changed, 6589 insertions(+), 6470 deletions(-) create mode 100644 .gitattributes create mode 100644 .ruby-version create mode 100644 app/assets/audio/panel_abort_tracing.aac create mode 100644 app/assets/audio/panel_failure.aac create mode 100644 app/assets/audio/panel_start_tracing.aac create mode 100644 app/assets/audio/panel_success.aac delete mode 100644 app/assets/audio/wittle/.keep delete mode 100644 app/assets/audio/wittle/panel_abort_tracing.aac delete mode 100644 app/assets/audio/wittle/panel_failure.aac delete mode 100644 app/assets/audio/wittle/panel_start_tracing.aac delete mode 100644 app/assets/audio/wittle/panel_success.aac create mode 100644 app/assets/config/manifest.js delete mode 100644 app/assets/config/wittle_manifest.js create mode 100644 app/assets/images/.keep create mode 100644 app/assets/images/slider.png delete mode 100644 app/assets/images/wittle/slider.png delete mode 100644 app/assets/images/wittle/wittle_expert.png delete mode 100644 app/assets/images/wittle/wittle_hard.png delete mode 100644 app/assets/images/wittle/wittle_header.png delete mode 100644 app/assets/images/wittle/wittle_normal.png create mode 100644 app/assets/images/wittle_expert.png create mode 100644 app/assets/images/wittle_hard.png create mode 100644 app/assets/images/wittle_header.png create mode 100644 app/assets/images/wittle_normal.png create mode 100644 app/assets/javascripts/application.js create mode 100644 app/assets/javascripts/custom_mechanics.js create mode 100644 app/assets/javascripts/display2.js create mode 100644 app/assets/javascripts/polyominos.js create mode 100644 app/assets/javascripts/puzzle.js create mode 100644 app/assets/javascripts/serializer.js create mode 100644 app/assets/javascripts/solve.js create mode 100644 app/assets/javascripts/svg.js create mode 100644 app/assets/javascripts/trace2.js create mode 100644 app/assets/javascripts/utilities.js.erb create mode 100644 app/assets/javascripts/validate.js create mode 100644 app/assets/javascripts/wittle.js delete mode 100644 app/assets/javascripts/wittle/application.js delete mode 100644 app/assets/javascripts/wittle/custom_mechanics.js delete mode 100644 app/assets/javascripts/wittle/display2.js delete mode 100644 app/assets/javascripts/wittle/polyominos.js delete mode 100644 app/assets/javascripts/wittle/puzzle.js delete mode 100644 app/assets/javascripts/wittle/serializer.js delete mode 100644 app/assets/javascripts/wittle/solve.js delete mode 100644 app/assets/javascripts/wittle/svg.js delete mode 100644 app/assets/javascripts/wittle/trace2.js delete mode 100644 app/assets/javascripts/wittle/utilities.js.erb delete mode 100644 app/assets/javascripts/wittle/validate.js delete mode 100644 app/assets/javascripts/wittle/wittle.js create mode 100644 app/assets/stylesheets/application.css create mode 100644 app/assets/stylesheets/general.css.scss delete mode 100644 app/assets/stylesheets/wittle/application.css delete mode 100644 app/assets/stylesheets/wittle/general.css.scss create mode 100644 app/channels/application_cable/channel.rb create mode 100644 app/channels/application_cable/connection.rb create mode 100644 app/controllers/application_controller.rb create mode 100644 app/controllers/puzzles_controller.rb delete mode 100644 app/controllers/wittle/application_controller.rb delete mode 100644 app/controllers/wittle/puzzles_controller.rb create mode 100644 app/helpers/application_helper.rb create mode 100644 app/helpers/puzzles_helper.rb delete mode 100644 app/helpers/wittle/application_helper.rb delete mode 100644 app/helpers/wittle/puzzles_helper.rb create mode 100644 app/jobs/application_job.rb delete mode 100644 app/jobs/wittle/application_job.rb create mode 100644 app/mailers/application_mailer.rb delete mode 100644 app/mailers/wittle/application_mailer.rb create mode 100644 app/models/application_record.rb create mode 100644 app/models/puzzle.rb create mode 100644 app/models/score.rb delete mode 100644 app/models/wittle/application_record.rb delete mode 100644 app/models/wittle/puzzle.rb delete mode 100644 app/models/wittle/score.rb create mode 100644 app/views/layouts/application.html.haml create mode 100644 app/views/layouts/mailer.html.erb create mode 100644 app/views/layouts/mailer.text.erb delete mode 100644 app/views/layouts/wittle/application.html.haml create mode 100644 app/views/puzzles/_handle_puzzle.html.erb create mode 100644 app/views/puzzles/_submission.html.haml create mode 100644 app/views/puzzles/about.html.haml create mode 100644 app/views/puzzles/index.html.haml create mode 100644 app/views/puzzles/show.html.haml create mode 100644 app/views/puzzles/solve.js.erb delete mode 100644 app/views/wittle/puzzles/_handle_puzzle.html.erb delete mode 100644 app/views/wittle/puzzles/_submission.html.haml delete mode 100644 app/views/wittle/puzzles/about.html.haml delete mode 100644 app/views/wittle/puzzles/index.html.haml delete mode 100644 app/views/wittle/puzzles/show.html.haml delete mode 100644 app/views/wittle/puzzles/solve.js.erb create mode 100755 bin/bundle create mode 100755 bin/rake create mode 100755 bin/setup create mode 100644 config.ru create mode 100644 config/application.rb create mode 100644 config/boot.rb create mode 100644 config/cable.yml create mode 100644 config/credentials.yml.enc create mode 100644 config/database.yml create mode 100644 config/environment.rb create mode 100644 config/environments/development.rb create mode 100644 config/environments/production.rb create mode 100644 config/environments/test.rb create mode 100644 config/initializers/assets.rb create mode 100644 config/initializers/content_security_policy.rb create mode 100644 config/initializers/filter_parameter_logging.rb create mode 100644 config/initializers/inflections.rb create mode 100644 config/initializers/permissions_policy.rb create mode 100644 config/locales/en.yml create mode 100644 config/puma.rb create mode 100644 config/storage.yml delete mode 100644 db/migrate/20231028205751_create_wittle_puzzles.rb delete mode 100644 db/migrate/20231028210722_create_wittle_scores.rb create mode 100644 db/migrate/20231130173455_create_puzzles.rb create mode 100644 db/migrate/20231130173513_create_scores.rb create mode 100644 db/schema.rb create mode 100644 db/seeds.rb create mode 100644 lib/assets/.keep delete mode 100644 lib/keep create mode 100644 lib/tasks/.keep delete mode 100644 lib/wittle.rb delete mode 100644 lib/wittle/engine.rb delete mode 100644 lib/wittle/version.rb create mode 100644 log/.keep create mode 100644 public/404.html create mode 100644 public/422.html create mode 100644 public/500.html create mode 100644 public/apple-touch-icon-precomposed.png create mode 100644 public/apple-touch-icon.png create mode 100644 public/favicon.ico create mode 100644 public/robots.txt create mode 100644 storage/.keep create mode 100644 test/application_system_test_case.rb create mode 100644 test/channels/application_cable/connection_test.rb create mode 100644 test/controllers/puzzles_controller_test.rb delete mode 100644 test/controllers/wittle/puzzles_controller_test.rb delete mode 100644 test/dummy/Rakefile delete mode 100644 test/dummy/app/assets/config/manifest.js delete mode 100644 test/dummy/app/assets/images/.keep delete mode 100644 test/dummy/app/assets/stylesheets/application.css delete mode 100644 test/dummy/app/channels/application_cable/channel.rb delete mode 100644 test/dummy/app/channels/application_cable/connection.rb delete mode 100644 test/dummy/app/controllers/application_controller.rb delete mode 100644 test/dummy/app/controllers/concerns/.keep delete mode 100644 test/dummy/app/helpers/application_helper.rb delete mode 100644 test/dummy/app/jobs/application_job.rb delete mode 100644 test/dummy/app/mailers/application_mailer.rb delete mode 100644 test/dummy/app/models/application_record.rb delete mode 100644 test/dummy/app/models/concerns/.keep delete mode 100644 test/dummy/app/views/layouts/application.html.erb delete mode 100644 test/dummy/app/views/layouts/mailer.html.erb delete mode 100644 test/dummy/app/views/layouts/mailer.text.erb delete mode 100755 test/dummy/bin/rails delete mode 100755 test/dummy/bin/rake delete mode 100755 test/dummy/bin/setup delete mode 100644 test/dummy/config.ru delete mode 100644 test/dummy/config/application.rb delete mode 100644 test/dummy/config/boot.rb delete mode 100644 test/dummy/config/cable.yml delete mode 100644 test/dummy/config/database.yml delete mode 100644 test/dummy/config/environment.rb delete mode 100644 test/dummy/config/environments/development.rb delete mode 100644 test/dummy/config/environments/production.rb delete mode 100644 test/dummy/config/environments/test.rb delete mode 100644 test/dummy/config/initializers/assets.rb delete mode 100644 test/dummy/config/initializers/content_security_policy.rb delete mode 100644 test/dummy/config/initializers/filter_parameter_logging.rb delete mode 100644 test/dummy/config/initializers/inflections.rb delete mode 100644 test/dummy/config/initializers/permissions_policy.rb delete mode 100644 test/dummy/config/locales/en.yml delete mode 100644 test/dummy/config/puma.rb delete mode 100644 test/dummy/config/routes.rb delete mode 100644 test/dummy/config/storage.yml delete mode 100644 test/dummy/db/schema.rb delete mode 100644 test/dummy/lib/assets/.keep delete mode 100644 test/dummy/public/404.html delete mode 100644 test/dummy/public/422.html delete mode 100644 test/dummy/public/500.html delete mode 100644 test/dummy/public/apple-touch-icon-precomposed.png delete mode 100644 test/dummy/public/apple-touch-icon.png delete mode 100644 test/dummy/public/favicon.ico create mode 100644 test/fixtures/puzzles.yml create mode 100644 test/fixtures/scores.yml delete mode 100644 test/fixtures/wittle/puzzles.yml delete mode 100644 test/fixtures/wittle/scores.yml delete mode 100644 test/integration/navigation_test.rb create mode 100644 test/models/puzzle_test.rb create mode 100644 test/models/score_test.rb delete mode 100644 test/models/wittle/puzzle_test.rb delete mode 100644 test/models/wittle/score_test.rb create mode 100644 test/system/.keep delete mode 100644 test/wittle_test.rb create mode 100644 tmp/.keep create mode 100644 tmp/pids/.keep create mode 100644 tmp/storage/.keep create mode 100644 vendor/.keep create mode 100644 vendor/javascript/.keep delete mode 100644 wittle.gemspec diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8dc4323 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# See https://git-scm.com/docs/gitattributes for more about git attribute files. + +# Mark the database schema as having been generated. +db/schema.rb linguist-generated + +# Mark any vendored files as having been vendored. +vendor/* linguist-vendored +config/credentials/*.yml.enc diff=rails_credentials +config/credentials.yml.enc diff=rails_credentials diff --git a/.gitignore b/.gitignore index 162e77e..b03f305 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,40 @@ +# See https://help.github.com/articles/ignoring-files for more about ignoring files. +# +# If you find yourself ignoring temporary files generated by your text editor +# or operating system, you probably want to add a global ignore instead: +# git config --global core.excludesfile '~/.gitignore_global' + +# Ignore bundler config. +/.bundle + +# Ignore all environment files (except templates). +/.env* +!/.env*.erb + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/ +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/ +!/tmp/storage/.keep + +/public/assets + +# Ignore master key for decrypting credentials and more. +/config/master.key + ext/wittle_generator/build *.o mkmf.log wittle_generator.bundle -/.bundle/ -/doc/ -/log/*.log -/pkg/ -/tmp/ -/test/dummy/db/*.sqlite3 -/test/dummy/db/*.sqlite3-* -/test/dummy/log/*.log -/test/dummy/storage/ -/test/dummy/tmp/ diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..9e79f6c --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +ruby-3.2.2 diff --git a/Gemfile b/Gemfile index a4414ee..0baa41c 100644 --- a/Gemfile +++ b/Gemfile @@ -1,15 +1,79 @@ source "https://rubygems.org" -git_source(:github) { |repo| "https://github.com/#{repo}.git" } -# Specify your gem's dependencies in wittle.gemspec. -gemspec +ruby "3.2.2" -gem "puma" - -gem "sqlite3" +# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" +gem "rails", "~> 7.1.2" +# The original asset pipeline for Rails [https://github.com/rails/sprockets-rails] gem "sprockets-rails" -gem "rake-compiler" -# Start debugger with binding.b [https://github.com/ruby/debug] -# gem "debug", ">= 1.0.0" +# Use sqlite3 as the database for Active Record +gem "sqlite3", "~> 1.4" + +# Use the Puma web server [https://github.com/puma/puma] +gem "puma", ">= 5.0" + +# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails] +#gem "importmap-rails" + +# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev] +#gem "turbo-rails" + +# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev] +# gem "stimulus-rails" + +# Build JSON APIs with ease [https://github.com/rails/jbuilder] +gem "jbuilder" + +# Use Redis adapter to run Action Cable in production +# gem "redis", ">= 4.0.1" + +# Use Kredis to get higher-level data types in Redis [https://github.com/rails/kredis] +# gem "kredis" + +# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] +# gem "bcrypt", "~> 3.1.7" + +# Windows does not include zoneinfo files, so bundle the tzinfo-data gem +gem "tzinfo-data", platforms: %i[ windows jruby ] + +# Reduces boot times through caching; required in config/boot.rb +gem "bootsnap", require: false + +# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] +# gem "image_processing", "~> 1.2" + +# Use Uglifier as compressor for JavaScript assets +gem 'terser', '~> 1.1.19' +# See https://github.com/rails/execjs#readme for more supported runtimes +gem 'mini_racer' + +group :development, :test do + # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem + gem "debug", platforms: %i[ mri windows ] +end + +group :development do + # Use console on exceptions pages [https://github.com/rails/web-console] + gem "web-console" + + # Add speed badges [https://github.com/MiniProfiler/rack-mini-profiler] + # gem "rack-mini-profiler" + + # Speed up commands on slow machines / big apps [https://github.com/rails/spring] + # gem "spring" +end + +group :test do + # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] + gem "capybara" + gem "selenium-webdriver" +end + +gem "rice" +gem "haml" +gem "enumerize" +gem "sassc-rails" +gem "jquery-rails" +gem "rake-compiler" diff --git a/Gemfile.lock b/Gemfile.lock index 11ce5d5..86a8335 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,81 +1,71 @@ -PATH - remote: . - specs: - wittle (0.1.0) - enumerize - haml - jquery-rails - rails (>= 7.1.1) - rice - sassc-rails - GEM remote: https://rubygems.org/ specs: - actioncable (7.1.1) - actionpack (= 7.1.1) - activesupport (= 7.1.1) + actioncable (7.1.2) + actionpack (= 7.1.2) + activesupport (= 7.1.2) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.1.1) - actionpack (= 7.1.1) - activejob (= 7.1.1) - activerecord (= 7.1.1) - activestorage (= 7.1.1) - activesupport (= 7.1.1) + actionmailbox (7.1.2) + actionpack (= 7.1.2) + activejob (= 7.1.2) + activerecord (= 7.1.2) + activestorage (= 7.1.2) + activesupport (= 7.1.2) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.1.1) - actionpack (= 7.1.1) - actionview (= 7.1.1) - activejob (= 7.1.1) - activesupport (= 7.1.1) + actionmailer (7.1.2) + actionpack (= 7.1.2) + actionview (= 7.1.2) + activejob (= 7.1.2) + activesupport (= 7.1.2) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp rails-dom-testing (~> 2.2) - actionpack (7.1.1) - actionview (= 7.1.1) - activesupport (= 7.1.1) + actionpack (7.1.2) + actionview (= 7.1.2) + activesupport (= 7.1.2) nokogiri (>= 1.8.5) + racc rack (>= 2.2.4) rack-session (>= 1.0.1) rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - actiontext (7.1.1) - actionpack (= 7.1.1) - activerecord (= 7.1.1) - activestorage (= 7.1.1) - activesupport (= 7.1.1) + actiontext (7.1.2) + actionpack (= 7.1.2) + activerecord (= 7.1.2) + activestorage (= 7.1.2) + activesupport (= 7.1.2) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.1.1) - activesupport (= 7.1.1) + actionview (7.1.2) + activesupport (= 7.1.2) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (7.1.1) - activesupport (= 7.1.1) + activejob (7.1.2) + activesupport (= 7.1.2) globalid (>= 0.3.6) - activemodel (7.1.1) - activesupport (= 7.1.1) - activerecord (7.1.1) - activemodel (= 7.1.1) - activesupport (= 7.1.1) + activemodel (7.1.2) + activesupport (= 7.1.2) + activerecord (7.1.2) + activemodel (= 7.1.2) + activesupport (= 7.1.2) timeout (>= 0.4.0) - activestorage (7.1.1) - actionpack (= 7.1.1) - activejob (= 7.1.1) - activerecord (= 7.1.1) - activesupport (= 7.1.1) + activestorage (7.1.2) + actionpack (= 7.1.2) + activejob (= 7.1.2) + activerecord (= 7.1.2) + activesupport (= 7.1.2) marcel (~> 1.0) - activesupport (7.1.1) + activesupport (7.1.2) base64 bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) @@ -85,18 +75,36 @@ GEM minitest (>= 5.1) mutex_m tzinfo (~> 2.0) - base64 (0.1.1) + addressable (2.8.5) + public_suffix (>= 2.0.2, < 6.0) + base64 (0.2.0) bigdecimal (3.1.4) + bindex (0.8.1) + bootsnap (1.17.0) + msgpack (~> 1.2) builder (3.2.4) + capybara (3.39.2) + addressable + matrix + mini_mime (>= 0.1.3) + nokogiri (~> 1.8) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + regexp_parser (>= 1.5, < 3.0) + xpath (~> 3.2) concurrent-ruby (1.2.2) connection_pool (2.4.1) crass (1.0.6) - date (3.3.3) - drb (2.1.1) + date (3.3.4) + debug (1.8.0) + irb (>= 1.5.0) + reline (>= 0.3.1) + drb (2.2.0) ruby2_keywords enumerize (2.7.0) activesupport (>= 3.2) erubi (1.12.0) + execjs (2.9.1) ffi (1.16.3) globalid (1.2.1) activesupport (>= 6.1) @@ -107,14 +115,19 @@ GEM i18n (1.14.1) concurrent-ruby (~> 1.0) io-console (0.6.0) - irb (1.8.3) + irb (1.9.1) rdoc reline (>= 0.3.8) + jbuilder (2.11.5) + actionview (>= 5.0.0) + activesupport (>= 5.0.0) jquery-rails (4.6.0) rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) - loofah (2.21.4) + libv8-node (18.16.0.0-x86_64-darwin) + libv8-node (18.16.0.0-x86_64-linux) + loofah (2.22.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -123,26 +136,33 @@ GEM net-pop net-smtp marcel (1.0.2) + matrix (0.4.2) mini_mime (1.1.5) + mini_racer (0.8.0) + libv8-node (~> 18.16.0.0) minitest (5.20.0) - mutex_m (0.1.2) - net-imap (0.4.2) + msgpack (1.7.2) + mutex_m (0.2.0) + net-imap (0.4.7) date net-protocol net-pop (0.1.2) net-protocol - net-protocol (0.2.1) + net-protocol (0.2.2) timeout net-smtp (0.4.0) net-protocol - nio4r (2.5.9) - nokogiri (1.15.4-x86_64-darwin) + nio4r (2.6.1) + nokogiri (1.15.5-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.15.5-x86_64-linux) racc (~> 1.4) psych (5.1.1.1) stringio + public_suffix (5.0.4) puma (6.4.0) nio4r (~> 2.0) - racc (1.7.1) + racc (1.7.3) rack (3.0.8) rack-session (2.0.0) rack (>= 3.0.0) @@ -151,20 +171,20 @@ GEM rackup (2.1.0) rack (>= 3) webrick (~> 1.8) - rails (7.1.1) - actioncable (= 7.1.1) - actionmailbox (= 7.1.1) - actionmailer (= 7.1.1) - actionpack (= 7.1.1) - actiontext (= 7.1.1) - actionview (= 7.1.1) - activejob (= 7.1.1) - activemodel (= 7.1.1) - activerecord (= 7.1.1) - activestorage (= 7.1.1) - activesupport (= 7.1.1) + rails (7.1.2) + actioncable (= 7.1.2) + actionmailbox (= 7.1.2) + actionmailer (= 7.1.2) + actionpack (= 7.1.2) + actiontext (= 7.1.2) + actionview (= 7.1.2) + activejob (= 7.1.2) + activemodel (= 7.1.2) + activerecord (= 7.1.2) + activestorage (= 7.1.2) + activesupport (= 7.1.2) bundler (>= 1.15.0) - railties (= 7.1.1) + railties (= 7.1.2) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest @@ -172,9 +192,9 @@ GEM rails-html-sanitizer (1.6.0) loofah (~> 2.21) nokogiri (~> 1.14) - railties (7.1.1) - actionpack (= 7.1.1) - activesupport (= 7.1.1) + railties (7.1.2) + actionpack (= 7.1.2) + activesupport (= 7.1.2) irb rackup (>= 1.0.0) rake (>= 12.2) @@ -183,12 +203,15 @@ GEM rake (13.1.0) rake-compiler (1.2.5) rake - rdoc (6.5.0) + rdoc (6.6.0) psych (>= 4.0.0) - reline (0.3.9) + regexp_parser (2.8.2) + reline (0.4.1) io-console (~> 0.5) + rexml (3.2.6) rice (4.1.0) ruby2_keywords (0.0.5) + rubyzip (2.3.2) sassc (2.4.0) ffi (~> 1.9) sassc-rails (2.1.2) @@ -197,6 +220,10 @@ GEM sprockets (> 3.0) sprockets-rails tilt + selenium-webdriver (4.15.0) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 3.0) + websocket (~> 1.0) sprockets (4.2.1) concurrent-ruby (~> 1.0) rack (>= 2.2.4, < 4) @@ -204,29 +231,58 @@ GEM actionpack (>= 5.2) activesupport (>= 5.2) sprockets (>= 3.0.0) - sqlite3 (1.6.7-x86_64-darwin) - stringio (3.0.8) + sqlite3 (1.6.9-x86_64-darwin) + sqlite3 (1.6.9-x86_64-linux) + stringio (3.1.0) temple (0.10.3) + terser (1.1.20) + execjs (>= 0.3.0, < 3) thor (1.3.0) tilt (2.3.0) - timeout (0.4.0) + timeout (0.4.1) tzinfo (2.0.6) concurrent-ruby (~> 1.0) + web-console (4.2.1) + actionview (>= 6.0.0) + activemodel (>= 6.0.0) + bindex (>= 0.4.0) + railties (>= 6.0.0) webrick (1.8.1) + websocket (1.2.10) websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) + xpath (3.2.0) + nokogiri (~> 1.8) zeitwerk (2.6.12) PLATFORMS x86_64-darwin-22 + x86_64-linux DEPENDENCIES - puma + bootsnap + capybara + debug + enumerize + haml + jbuilder + jquery-rails + mini_racer + puma (>= 5.0) + rails (~> 7.1.2) rake-compiler + rice + sassc-rails + selenium-webdriver sprockets-rails - sqlite3 - wittle! + sqlite3 (~> 1.4) + terser (~> 1.1.19) + tzinfo-data + web-console + +RUBY VERSION + ruby 3.2.2p53 BUNDLED WITH - 2.4.10 + 2.4.21 diff --git a/README.md b/README.md index f42a9fe..96ee4dd 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ # wittle -Rails engine that generates Witness puzzles daily and presents them on a website. Adapted from [Sigma144's Witness Random Puzzle Generator](https://github.com/sigma144/witness-randomizer), which is itself based on [darkid's Witness Randomizer](https://github.com/darkid/witness-randomizer). The tracing UI is adapted from [darkid's witness-puzzles website](https://github.com/jbzdarkid/jbzdarkid.github.io). +Rails app that generates Witness puzzles daily and presents them on a website. Adapted from [Sigma144's Witness Random Puzzle Generator](https://github.com/sigma144/witness-randomizer), which is itself based on [darkid's Witness Randomizer](https://github.com/darkid/witness-randomizer). The tracing UI is adapted from [darkid's witness-puzzles website](https://github.com/jbzdarkid/jbzdarkid.github.io). diff --git a/Rakefile b/Rakefile index 41a438f..e63c405 100644 --- a/Rakefile +++ b/Rakefile @@ -1,12 +1,10 @@ -require "bundler/setup" -require "rake/extensiontask" - -APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__) -load "rails/tasks/engine.rake" +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. -load "rails/tasks/statistics.rake" +require "rake/extensiontask" +require_relative "config/application" -require "bundler/gem_tasks" +Rails.application.load_tasks Rake::ExtensionTask.new "wittle_generator" do |ext| ext.lib_dir = "lib" diff --git a/app/assets/audio/panel_abort_tracing.aac b/app/assets/audio/panel_abort_tracing.aac new file mode 100644 index 0000000..1871586 Binary files /dev/null and b/app/assets/audio/panel_abort_tracing.aac differ diff --git a/app/assets/audio/panel_failure.aac b/app/assets/audio/panel_failure.aac new file mode 100644 index 0000000..c61fe94 Binary files /dev/null and b/app/assets/audio/panel_failure.aac differ diff --git a/app/assets/audio/panel_start_tracing.aac b/app/assets/audio/panel_start_tracing.aac new file mode 100644 index 0000000..9b828f1 Binary files /dev/null and b/app/assets/audio/panel_start_tracing.aac differ diff --git a/app/assets/audio/panel_success.aac b/app/assets/audio/panel_success.aac new file mode 100644 index 0000000..d6e75fe Binary files /dev/null and b/app/assets/audio/panel_success.aac differ diff --git a/app/assets/audio/wittle/.keep b/app/assets/audio/wittle/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/app/assets/audio/wittle/panel_abort_tracing.aac b/app/assets/audio/wittle/panel_abort_tracing.aac deleted file mode 100644 index 1871586..0000000 Binary files a/app/assets/audio/wittle/panel_abort_tracing.aac and /dev/null differ diff --git a/app/assets/audio/wittle/panel_failure.aac b/app/assets/audio/wittle/panel_failure.aac deleted file mode 100644 index c61fe94..0000000 Binary files a/app/assets/audio/wittle/panel_failure.aac and /dev/null differ diff --git a/app/assets/audio/wittle/panel_start_tracing.aac b/app/assets/audio/wittle/panel_start_tracing.aac deleted file mode 100644 index 9b828f1..0000000 Binary files a/app/assets/audio/wittle/panel_start_tracing.aac and /dev/null differ diff --git a/app/assets/audio/wittle/panel_success.aac b/app/assets/audio/wittle/panel_success.aac deleted file mode 100644 index d6e75fe..0000000 Binary files a/app/assets/audio/wittle/panel_success.aac and /dev/null differ diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js new file mode 100644 index 0000000..70ff62e --- /dev/null +++ b/app/assets/config/manifest.js @@ -0,0 +1,6 @@ +//= link_tree ../images +//= link_directory ../stylesheets .css +//= link_directory ../javascripts .js +//= link_tree ../../../vendor/javascript .js +//= link_directory ../audio .aac +//= link jquery.min.js diff --git a/app/assets/config/wittle_manifest.js b/app/assets/config/wittle_manifest.js deleted file mode 100644 index 46aea2a..0000000 --- a/app/assets/config/wittle_manifest.js +++ /dev/null @@ -1,4 +0,0 @@ -//= link_directory ../stylesheets/wittle .css -//= link_directory ../javascripts/wittle .js -//= link_directory ../audio/wittle .aac -//= link_directory ../images/wittle .png diff --git a/app/assets/images/.keep b/app/assets/images/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/assets/images/slider.png b/app/assets/images/slider.png new file mode 100644 index 0000000..f093f89 Binary files /dev/null and b/app/assets/images/slider.png differ diff --git a/app/assets/images/wittle/slider.png b/app/assets/images/wittle/slider.png deleted file mode 100644 index f093f89..0000000 Binary files a/app/assets/images/wittle/slider.png and /dev/null differ diff --git a/app/assets/images/wittle/wittle_expert.png b/app/assets/images/wittle/wittle_expert.png deleted file mode 100644 index b806b57..0000000 Binary files a/app/assets/images/wittle/wittle_expert.png and /dev/null differ diff --git a/app/assets/images/wittle/wittle_hard.png b/app/assets/images/wittle/wittle_hard.png deleted file mode 100644 index e993e9b..0000000 Binary files a/app/assets/images/wittle/wittle_hard.png and /dev/null differ diff --git a/app/assets/images/wittle/wittle_header.png b/app/assets/images/wittle/wittle_header.png deleted file mode 100644 index a082f8e..0000000 Binary files a/app/assets/images/wittle/wittle_header.png and /dev/null differ diff --git a/app/assets/images/wittle/wittle_normal.png b/app/assets/images/wittle/wittle_normal.png deleted file mode 100644 index adc0943..0000000 Binary files a/app/assets/images/wittle/wittle_normal.png and /dev/null differ diff --git a/app/assets/images/wittle_expert.png b/app/assets/images/wittle_expert.png new file mode 100644 index 0000000..b806b57 Binary files /dev/null and b/app/assets/images/wittle_expert.png differ diff --git a/app/assets/images/wittle_hard.png b/app/assets/images/wittle_hard.png new file mode 100644 index 0000000..e993e9b Binary files /dev/null and b/app/assets/images/wittle_hard.png differ diff --git a/app/assets/images/wittle_header.png b/app/assets/images/wittle_header.png new file mode 100644 index 0000000..a082f8e Binary files /dev/null and b/app/assets/images/wittle_header.png differ diff --git a/app/assets/images/wittle_normal.png b/app/assets/images/wittle_normal.png new file mode 100644 index 0000000..adc0943 Binary files /dev/null and b/app/assets/images/wittle_normal.png differ diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js new file mode 100644 index 0000000..e614ed0 --- /dev/null +++ b/app/assets/javascripts/application.js @@ -0,0 +1,2 @@ +//= require jquery3 +//= require_tree . diff --git a/app/assets/javascripts/custom_mechanics.js b/app/assets/javascripts/custom_mechanics.js new file mode 100644 index 0000000..d4733db --- /dev/null +++ b/app/assets/javascripts/custom_mechanics.js @@ -0,0 +1,201 @@ +namespace(function() { + +function isCellBridgePathFriendly(puzzle, color, pos) { + if (pos.x%2 === 0 && pos.y%2 === 0) return false + var cell = puzzle.getCell(pos.x, pos.y) + return cell == null || cell.color == null || cell.color === color +} + +function makeMinimalTree(graph, root, required) { + var seen = Array(graph.length).fill(false) + var result = Array(graph.length).fill(false) + result[root] = true + function dfs(node) { + seen[node] = true + result[node] = required[node] + for (var child of graph[node]) { + if (!seen[child]) { + dfs(child) + result[node] = result[node] || result[child] + } + } + } + dfs(root) + return result +} + +function isTreeUnique(graph, isInTree) { + var seen = isInTree.slice() + function dfs(node) { + seen[node] = true + var reachableTreeNode = null + for (var child of graph[node]) { + var candidate = null + if (isInTree[child]) { + candidate = child + } else if (!seen[child]) { + candidate = dfs(child) + } + if (candidate != null && candidate !== reachableTreeNode) { + if (reachableTreeNode == null) { + reachableTreeNode = candidate + } else { + return -1 + } + } + } + return reachableTreeNode + } + for (var i = 0; i < graph.length; i++) { + if (!seen[i]) { + if (dfs(i) === -1) return false + } + } + return true +} + +function puzzleCellsAdjacent(first, second, pillar) { + if (pillar && first.y == second.y && Math.abs(second.x - first.x) === puzzle.width - 1) + return true + return Math.abs(second.x - first.x) + Math.abs(second.y - first.y) === 1 +} + +function bridgeTest(region, puzzle, color, bridges) { + var nodes = region.cells.filter(pos => isCellBridgePathFriendly(puzzle, color, pos)) + var graph = Array.from(Array(nodes.length), () => []) + for (var ir = 1; ir < nodes.length; ir++) { + var right = nodes[ir] + for (var il = 0; il < ir; il++) { + var left = nodes[il] + if (puzzleCellsAdjacent(left, right, puzzle.pillar)) { + graph[il].push(ir) + graph[ir].push(il) + } + } + } + var isBridge = nodes.map(node => bridges.some(bridge => node.x === bridge.x && node.y === bridge.y)) + var isInTree = makeMinimalTree(graph, isBridge.indexOf(true), isBridge) + for (var i = 0; i < nodes.length; i++) { + if (isBridge[i] && !isInTree[i]) return false + } + return isTreeUnique(graph, isInTree) +} + +window.validateBridges = function(puzzle, region, regionData) { + var bridges = {} + for (var pos of region) { + var cell = puzzle.getCell(pos.x, pos.y) + if (cell == null) continue + + // Count color-based elements + if (cell.color != null) { + if (cell.type === 'bridge') { + if (bridges[cell.color] == null) { + bridges[cell.color] = [] + } + bridges[cell.color].push(pos) + } + } + } + + for (var color in bridges) { + var total = 0 + var discardable = 0 + for (var x=1; x < puzzle.width; x+=2) { + for (var y=1; y < puzzle.height; y+=2) { + var cell = puzzle.getCell(x, y) + if (cell != null) { + if (cell.type === 'bridge' && cell.color === color) total++ + if (cell.type === 'nega') discardable++ + } + } + } + + if (bridges[color].length != total) { + if (bridges[color].length >= total - discardable) { + // TODO: Negations in other regions can validate the solution + for (var bridge of bridges[color]) { + regionData.addInvalid(bridge) + } + } else { + for (var bridge of bridges[color]) { + regionData.addVeryInvalid(bridge) + } + } + } else if (!window.bridgeTest(region, puzzle, color, bridges[color])) { + for (var bridge of bridges[color]) { + regionData.addInvalid(bridge) + } + } + } +} + +var DIRECTIONS = [ + {'x': 0, 'y':-1}, + {'x': 1, 'y':-1}, + {'x': 1, 'y': 0}, + {'x': 1, 'y': 1}, + {'x': 0, 'y': 1}, + {'x':-1, 'y': 1}, + {'x':-1, 'y': 0}, + {'x':-1, 'y':-1}, +] + +window.validateArrows = function(puzzle, region, regionData) { + for (var pos of region) { + var cell = puzzle.getCell(pos.x, pos.y) + if (cell == null) continue + if (cell.type != 'arrow') continue + dir = DIRECTIONS[cell.rot] + + var count = 0 + var x = pos.x + dir.x + var y = pos.y + dir.y + for (var i=0; i<100; i++) { // 100 is arbitrary, it's just here to avoid infinite loops. + var line = puzzle.getLine(x, y) + console.spam('Testing', x, y, 'for arrow at', pos.x, pos.y, 'found', line) + if (line == null && (x%2 !== 1 || y%2 !== 1)) break + if (line > window.LINE_NONE) count++ + if (count > cell.count) break + x += dir.x * 2 + y += dir.y * 2 + if (puzzle.matchesSymmetricalPos(x, y, pos.x + dir.x, pos.y + dir.y)) break // Pillar exit condition (in case of looping) + } + if (count !== cell.count) { + console.log('Arrow at', pos.x, pos.y, 'crosses', count, 'lines, but should cross', cell.count) + regionData.addInvalid(pos) + } + } +} + +window.validateSizers = function(puzzle, region, regionData) { + var sizers = [] + var regionSize = 0 + for (var pos of region) { + if (pos.x%2 === 1 && pos.y%2 === 1) regionSize++ // Only count cells for the region + var cell = puzzle.getCell(pos.x, pos.y) + if (cell == null) continue + if (cell.type == 'sizer') sizers.push(pos) + } + console.debug('Found', sizers.length, 'sizers') + if (sizers.length == 0) return // No sizers -- no impact on sizer validity + + var sizerCount = regionSize / sizers.length + if (sizerCount % 1 != 0) { + console.log('Region size', regionSize, 'is not a multiple of # sizers', sizers.length) + for (var sizer of sizers) { + regionData.addInvalid(sizer) + } + return + } + + if (puzzle.sizerCount == null) puzzle.sizerCount = sizerCount // No other sizes have been defined + if (puzzle.sizerCount != sizerCount) { + console.log('sizerCount', sizerCount, 'does not match puzzle sizerCount', puzzle.sizerCount) + for (var sizer of sizers) { + regionData.addInvalid(sizer) + } + } +} + +}) diff --git a/app/assets/javascripts/display2.js b/app/assets/javascripts/display2.js new file mode 100644 index 0000000..ddf3968 --- /dev/null +++ b/app/assets/javascripts/display2.js @@ -0,0 +1,316 @@ +var SYM_TYPE_NONE = 0 +var SYM_TYPE_HORIZONTAL = 1 +var SYM_TYPE_VERTICAL = 2 +var SYM_TYPE_ROTATIONAL = 3 +var SYM_TYPE_ROTATE_LEFT = 4 +var SYM_TYPE_ROTATE_RIGHT = 5 +var SYM_TYPE_FLIP_XY = 6 +var SYM_TYPE_FLIP_NEG_XY = 7 +var SYM_TYPE_PARALLEL_H = 8 +var SYM_TYPE_PARALLEL_V = 9 +var SYM_TYPE_PARALLEL_H_FLIP = 10 +var SYM_TYPE_PARALLEL_V_FLIP = 11 +var SYM_TYPE_PILLAR_PARALLEL = 12 +var SYM_TYPE_PILLAR_HORIZONTAL = 13 +var SYM_TYPE_PILLAR_VERTICAL = 14 +var SYM_TYPE_PILLAR_ROTATIONAL = 15 + +namespace(function() { + +window.draw = function(puzzle, target='puzzle') { + if (puzzle == null) return + var svg = document.getElementById(target) + console.info('Drawing', puzzle, 'into', svg) + while (svg.firstChild) svg.removeChild(svg.firstChild) + + // Prevent context menu popups within the puzzle + svg.oncontextmenu = function(event) { + event.preventDefault() + } + + if (puzzle.pillar === true) { + // 41*width + 30*2 (padding) + 10*2 (border) + var pixelWidth = 41 * puzzle.width + 80 + } else { + // 41*(width-1) + 24 (extra edge) + 30*2 (padding) + 10*2 (border) + var pixelWidth = 41 * puzzle.width + 63 + } + var pixelHeight = 41 * puzzle.height + 63 + svg.setAttribute('viewbox', '0 0 ' + pixelWidth + ' ' + pixelHeight) + svg.setAttribute('width', pixelWidth) + svg.setAttribute('height', pixelHeight) + + var rect = createElement('rect') + svg.appendChild(rect) + rect.setAttribute('stroke-width', 10) + rect.setAttribute('stroke', window.BORDER) + rect.setAttribute('fill', window.OUTER_BACKGROUND) + // Accounting for the border thickness + rect.setAttribute('x', 5) + rect.setAttribute('y', 5) + rect.setAttribute('width', pixelWidth - 10) // Removing border + rect.setAttribute('height', pixelHeight - 10) // Removing border + + drawCenters(puzzle, svg) + drawGrid(puzzle, svg, target) + drawStartAndEnd(puzzle, svg) + // Draw cell symbols after so they overlap the lines, if necessary + drawSymbols(puzzle, svg, target) + + // For pillar puzzles, add faders for the left and right sides + if (puzzle.pillar === true) { + var defs = window.createElement('defs') + defs.id = 'cursorPos' + defs.innerHTML = '' + + '\n' + + ' \n' + + ' \n' + + ' \n' + + '\n' + + '\n' + + ' \n' + + ' \n' + + '\n' + svg.appendChild(defs) + + var leftBox = window.createElement('rect') + leftBox.setAttribute('x', 16) + leftBox.setAttribute('y', 10) + leftBox.setAttribute('width', 48) + leftBox.setAttribute('height', 41 * puzzle.height + 43) + leftBox.setAttribute('fill', 'url(#fadeInLeft)') + leftBox.setAttribute('style', 'pointer-events: none') + svg.appendChild(leftBox) + + var rightBox = window.createElement('rect') + rightBox.setAttribute('x', 41 * puzzle.width + 22) + rightBox.setAttribute('y', 10) + rightBox.setAttribute('width', 30) + rightBox.setAttribute('height', 41 * puzzle.height + 43) + rightBox.setAttribute('fill', 'url(#fadeOutRight)') + rightBox.setAttribute('style', 'pointer-events: none') + svg.appendChild(rightBox) + } +} + +function drawCenters(puzzle, svg) { + // @Hack that I am not fixing. This switches the puzzle's grid to a floodfilled grid + // where null represents cells which are part of the outside + var savedGrid = puzzle.switchToMaskedGrid() + if (puzzle.pillar === true) { + for (var y=1; y 1) { + // Add rounding for other intersections (handling gap-only corners) + var circ = createElement('circle') + circ.setAttribute('cx', x*41 + 52) + circ.setAttribute('cy', y*41 + 52) + circ.setAttribute('r', 12) + circ.setAttribute('fill', window.FOREGROUND) + svg.appendChild(circ) + } + } + } + } + // Determine if left-side needs a 'wrap indicator' + if (puzzle.pillar === true) { + var x = 0; + for (var y=0; y window.DOT_NONE) { + params.type = 'dot' + if (cell.dot === window.DOT_BLACK) params.color = 'black' + else if (cell.dot === window.DOT_BLUE) params.color = window.LINE_PRIMARY + else if (cell.dot === window.DOT_YELLOW) params.color = window.LINE_SECONDARY + else if (cell.dot === window.DOT_INVISIBLE) { + params.color = window.FOREGROUND + // This makes the invisible dots visible, but only while we're in the editor. + if (document.getElementById('metaButtons') != null) { + params.stroke = 'black' + params.strokeWidth = '2px' + } + } + drawSymbolWithSvg(svg, params) + } else if (cell.gap === window.GAP_BREAK) { + // Gaps were handled above, while drawing the grid. + } else if (x%2 === 1 && y%2 === 1) { + // Generic draw for all other elements + Object.assign(params, cell) + window.drawSymbolWithSvg(svg, params, puzzle.settings.CUSTOM_MECHANICS) + } + } + } +} + +function drawStartAndEnd(puzzle, svg) { + for (var x=0; x= 4 || y >= 4) return false + return (polyshape & mask(x, y)) !== 0 +} + +// This is 2^20, whereas all the other bits fall into 2^(0-15) +window.ROTATION_BIT = (1 << 20) + +window.isRotated = function(polyshape) { + return (polyshape & ROTATION_BIT) !== 0 +} + +function getRotations(polyshape) { + if (!isRotated(polyshape)) return [polyshape] + + var rotations = [0, 0, 0, 0] + for (var x=0; x<4; x++) { + for (var y=0; y<4; y++) { + if (isSet(polyshape, x, y)) { + rotations[0] ^= mask(x, y) + rotations[1] ^= mask(y, 3-x) + rotations[2] ^= mask(3-x, 3-y) + rotations[3] ^= mask(3-y, x) + } + } + } + + return rotations +} + +// 90 degree rotations of the polyomino +window.rotatePolyshape = function(polyshape, count=1) { + var rotations = getRotations(polyshape | window.ROTATION_BIT) + return rotations[count % 4] +} + +// IMPORTANT NOTE: When formulating these, the top row must contain (0, 0) +// That means there will never be any negative y values. +// (0, 0) must also be a cell in the shape, so that +// placing the shape at (x, y) will fill (x, y) +// Ylops will have -1s on all adjacent cells, to break "overlaps" for polyominos. +window.polyominoFromPolyshape = function(polyshape, ylop=false, precise=true) { + var topLeft = null + for (var y=0; y<4; y++) { + for (var x=0; x<4; x++) { + if (isSet(polyshape, x, y)) { + topLeft = {'x':x, 'y':y} + break + } + } + if (topLeft != null) break + } + if (topLeft == null) return [] // Empty polyomino + + var polyomino = [] + for (var x=0; x<4; x++) { + for (var y=0; y<4; y++) { + if (!isSet(polyshape, x, y)) continue + polyomino.push({'x':2*(x - topLeft.x), 'y':2*(y - topLeft.y)}) + + // "Precise" polyominos adds cells in between the apparent squares in the polyomino. + // This prevents the solution line from going through polyominos in the solution. + if (precise) { + if (ylop) { + // Ylops fill up/left if no adjacent cell, and always fill bottom/right + if (!isSet(polyshape, x - 1, y)) { + polyomino.push({'x':2*(x - topLeft.x) - 1, 'y':2*(y - topLeft.y)}) + } + if (!isSet(polyshape, x, y - 1)) { + polyomino.push({'x':2*(x - topLeft.x), 'y':2*(y - topLeft.y) - 1}) + } + polyomino.push({'x':2*(x - topLeft.x) + 1, 'y':2*(y - topLeft.y)}) + polyomino.push({'x':2*(x - topLeft.x), 'y':2*(y - topLeft.y) + 1}) + } else { + // Normal polys only fill bottom/right if there is an adjacent cell. + if (isSet(polyshape, x + 1, y)) { + polyomino.push({'x':2*(x - topLeft.x) + 1, 'y':2*(y - topLeft.y)}) + } + if (isSet(polyshape, x, y + 1)) { + polyomino.push({'x':2*(x - topLeft.x), 'y':2*(y - topLeft.y) + 1}) + } + } + } + } + } + return polyomino +} + +window.polyshapeFromPolyomino = function(polyomino) { + var topLeft = {'x': 9999, 'y': 9999} + for (var pos of polyomino) { + if (pos.x%2 != 1 || pos.y%2 != 1) continue // We only care about cells, not edges or intersections + + // Unlike when we're making a polyomino, we just want to top and left flush the shape, + // we don't actually need (0, 0) to be filled. + if (pos.x < topLeft.x) topLeft.x = pos.x + if (pos.y < topLeft.y) topLeft.y = pos.y + } + if (topLeft == null) return 0 // Empty polyomino + + var polyshape = 0 + for (var pos of polyomino) { + if (pos.x%2 != 1 || pos.y%2 != 1) continue // We only care about cells, not edges or intersections + var x = (pos.x - topLeft.x) / 2 // 0.5x to convert from puzzle coordinates to polyshape coordinates + var y = (pos.y - topLeft.y) / 2 // 0.5x to convert from puzzle coordinates to polyshape coordinates + polyshape |= mask(x, y) + } + + return polyshape +} + +// In some cases, polyominos and onimoylops will fully cancel each other out. +// However, even if they are the same size, that doesn't guarantee that they fit together. +// As an optimization, we save the results for known combinations of shapes, since there are likely many +// fewer pairings of shapes than paths through the grid. +var knownCancellations = {} + +// Attempt to fit polyominos in a region into the puzzle. +// This function checks for early exits, then simplifies the grid to a numerical representation: +// * 1 represents a square that has been double-covered (by two polyominos) +// * Or, in the cancellation case, it represents a square that was covered by a polyomino and not by an onimoylop +// * 0 represents a square that is satisfied, either because: +// * it is outside the region +// * (In the normal case) it was inside the region, and has been covered by a polyomino +// * (In the cancellation case) it was covered by an equal number of polyominos and onimoylops +// * -1 represents a square that needs to be covered once (inside the region, or outside but covered by an onimoylop) +// * -2 represents a square that needs to be covered twice (inside the region & covered by an onimoylop) +// * And etc, for additional layers of polyominos/onimoylops. +window.polyFit = function(region, puzzle) { + var polys = [] + var ylops = [] + var polyCount = 0 + var regionSize = 0 + for (var pos of region) { + if (pos.x%2 === 1 && pos.y%2 === 1) regionSize++ + var cell = puzzle.grid[pos.x][pos.y] + if (cell == null) continue + if (cell.polyshape === 0) continue + if (cell.type === 'poly') { + polys.push(cell) + polyCount += getPolySize(cell.polyshape) + } else if (cell.type === 'ylop') { + ylops.push(cell) + polyCount -= getPolySize(cell.polyshape) + } + } + if (polys.length + ylops.length === 0) { + console.log('No polyominos or onimoylops inside the region, vacuously true') + return true + } + if (polyCount > 0 && polyCount !== regionSize) { + console.log('Combined size of polyominos and onimoylops', polyCount, 'does not match region size', regionSize) + return false + } + if (polyCount < 0) { + console.log('Combined size of onimoylops is greater than polyominos by', -polyCount) + return false + } + var key = null + if (polyCount === 0) { + if (puzzle.settings.SHAPELESS_ZERO_POLY) { + console.log('Combined size of polyominos and onimoylops is zero') + return true + } + // These will be ordered by the order of cells in the region, which isn't exactly consistent. + // In practice, it seems to be good enough. + key = '' + for (var ylop of ylops) key += ' ' + ylop.polyshape + key += '|' + for (var poly of polys) key += ' ' + poly.polyshape + var ret = knownCancellations[key] + if (ret != null) return ret + } + + // For polyominos, we clear the grid to mark it up again: + var savedGrid = puzzle.grid + puzzle.newGrid() + // First, we mark all cells as 0: Cells outside the target region should be unaffected. + for (var x=0; x 0) { + for (var pos of region) puzzle.grid[pos.x][pos.y] = -1 + } + // In the exact match case, we leave every cell marked 0: Polys and ylops need to cancel. + + var ret = placeYlops(ylops, 0, polys, puzzle) + if (polyCount === 0) knownCancellations[key] = ret + puzzle.grid = savedGrid + return ret +} + +// If false, poly doesn't fit and grid is unmodified +// If true, poly fits and grid is modified (with the placement) +function tryPlacePolyshape(cells, x, y, puzzle, sign) { + console.spam('Placing at', x, y, 'with sign', sign) + var numCells = cells.length + for (var i=0; i 0) { + console.log('Cell', x, y, 'has been overfilled and no ylops left to place') + return false + } + if (allPolysPlaced && cell < 0 && x%2 === 1 && y%2 === 1) { + // Normal, center cell with a negative value & no polys remaining. + console.log('All polys placed, but grid not full') + return false + } + } + } + if (allPolysPlaced) { + console.log('All polys placed, and grid full') + return true + } + + // The top-left (first open cell) must be filled by a polyomino. + // However in the case of pillars, there is no top-left, so we try all open cells in the + // top-most open row + var openCells = [] + for (var y=1; y= 0) continue + openCells.push({'x':x, 'y':y}) + if (puzzle.pillar === false) break + } + if (openCells.length > 0) break + } + + if (openCells.length === 0) { + console.log('Polys remaining but grid full') + return false + } + + for (var openCell of openCells) { + var attemptedPolyshapes = [] + for (var i=0; i0 polys, but no valid recursion.') + return false +} + +}) diff --git a/app/assets/javascripts/puzzle.js b/app/assets/javascripts/puzzle.js new file mode 100644 index 0000000..4889e96 --- /dev/null +++ b/app/assets/javascripts/puzzle.js @@ -0,0 +1,538 @@ +namespace(function() { + +// A 2x2 grid is internally a 5x5: +// corner, edge, corner, edge, corner +// edge, cell, edge, cell, edge +// corner, edge, corner, edge, corner +// edge, cell, edge, cell, edge +// corner, edge, corner, edge, corner +// +// Corners and edges will have a value of true if the line passes through them +// Cells will contain an object if there is an element in them +window.Puzzle = class { + constructor(width, height, pillar=false) { + if (pillar === true) { + this.newGrid(2 * width, 2 * height + 1) + } else { + this.newGrid(2 * width + 1, 2 * height + 1) + } + this.pillar = pillar + this.settings = { + // If true, negation symbols are allowed to cancel other negation symbols. + NEGATIONS_CANCEL_NEGATIONS: true, + + // If true, and the count of polyominos and onimoylops is zero, they cancel regardless of shape. + SHAPELESS_ZERO_POLY: false, + + // If true, the traced line cannot go through the placement of a polyomino. + PRECISE_POLYOMINOS: true, + + // If false, incorrect elements will not flash when failing the puzzle. + FLASH_FOR_ERRORS: true, + + // If true, mid-segment startpoints will constitute solid lines, and form boundaries for the region. + FAT_STARTPOINTS: false, + + // If true, custom mechanics are displayed (and validated) in this puzzle. + CUSTOM_MECHANICS: false, + + // If true, polyominos may be placed partially off of the grid as an intermediate solution step. + // OUT_OF_BOUNDS_POLY: false, + + // If true, the symmetry line will be invisible. + INVISIBLE_SYMMETRY: false, + } + } + + static deserialize(json) { + var parsed = JSON.parse(json) + // Claim that it's not a pillar (for consistent grid sizing), then double-check ourselves later. + var puzzle = new Puzzle((parsed.grid.length - 1)/2, (parsed.grid[0].length - 1)/2) + puzzle.name = parsed.name + puzzle.autoSolved = parsed.autoSolved + puzzle.grid = parsed.grid + // Legacy: Grid squares used to use 'false' to indicate emptiness. + // Legacy: Cells may use {} to represent emptiness + // Now, we use: + // Cells default to null + // During onTraceStart, empty cells that are still inbounds are changed to {'type': 'nonce'} for tracing purposes. + // Lines default to {'type':'line', 'line':0} + for (var x=0; x= this.width) return false + if (y < 0 || y >= this.height) return false + return true + } + + getCell(x, y) { + x = this._mod(x) + if (!this._safeCell(x, y)) return null + return this.grid[x][y] + } + + setCell(x, y, value) { + x = this._mod(x) + if (!this._safeCell(x, y)) return + this.grid[x][y] = value + } + + getSymmetricalDir(dir) { + if (this.symType == SYM_TYPE_VERTICAL || this.symType == SYM_TYPE_ROTATIONAL || this.symType == SYM_TYPE_PARALLEL_H_FLIP) { + if (dir === 'left') return 'right' + if (dir === 'right') return 'left' + } + if (this.symType == SYM_TYPE_HORIZONTAL || this.symType == SYM_TYPE_ROTATIONAL || this.symType == SYM_TYPE_PARALLEL_V_FLIP) { + if (dir === 'top') return 'bottom' + if (dir === 'bottom') return 'top' + } + if (this.symType == SYM_TYPE_ROTATE_LEFT || this.symType == SYM_TYPE_FLIP_NEG_XY) { + if (dir === 'left') return 'bottom' + if (dir === 'right') return 'top' + } + if (this.symType == SYM_TYPE_ROTATE_RIGHT || this.symType == SYM_TYPE_FLIP_NEG_XY) { + if (dir === 'top') return 'right' + if (dir === 'bottom') return 'left' + } + if (this.symType == SYM_TYPE_ROTATE_LEFT || this.symType == SYM_TYPE_FLIP_XY) { + if (dir === 'top') return 'left' + if (dir === 'bottom') return 'right' + } + if (this.symType == SYM_TYPE_ROTATE_RIGHT || this.symType == SYM_TYPE_FLIP_XY) { + if (dir === 'right') return 'bottom' + if (dir === 'left') return 'top' + } + return dir + } + + // The resulting position is guaranteed to be gridsafe. + getSymmetricalPos(x, y) { + var origx = x + var origy = y + + if (this.symType == SYM_TYPE_VERTICAL || this.symType == SYM_TYPE_ROTATIONAL || this.symType == SYM_TYPE_PARALLEL_H_FLIP) { + x = (this.width - 1) - origx + } + if (this.symType == SYM_TYPE_HORIZONTAL || this.symType == SYM_TYPE_ROTATIONAL || this.symType == SYM_TYPE_PARALLEL_V_FLIP) { + y = (this.height - 1) - origy + } + if (this.symType == SYM_TYPE_ROTATE_LEFT || this.symType == SYM_TYPE_FLIP_XY) { + x = origy + } + if (this.symType == SYM_TYPE_ROTATE_RIGHT || this.symType == SYM_TYPE_FLIP_XY) { + y = origx + } + if (this.symType == SYM_TYPE_ROTATE_LEFT || this.symType == SYM_TYPE_FLIP_NEG_XY) { + y = (this.width - 1) - origx + } + if (this.symType == SYM_TYPE_ROTATE_RIGHT || this.symType == SYM_TYPE_FLIP_NEG_XY) { + x = (this.height - 1) - origy + } + if (this.symType == SYM_TYPE_PARALLEL_H || this.symType == SYM_TYPE_PARALLEL_H_FLIP) { + y = (origy == this.height / 2) ? (this.height / 2) : ((origy + (this.height + 1) / 2) % (this.height + 1)) + } + if (this.symType == SYM_TYPE_PARALLEL_V || this.symType == SYM_TYPE_PARALLEL_V_FLIP) { + x = (origx == this.width / 2) ? (this.width / 2) : ((origx + (this.width + 1) / 2) % (this.width + 1)) + } + + return {'x':this._mod(x), 'y':y} + } + + getSymmetricalCell(x, y) { + var pos = this.getSymmetricalPos(x, y) + return this.getCell(pos.x, pos.y) + } + + matchesSymmetricalPos(x1, y1, x2, y2) { + return (y1 === y2 && this._mod(x1) === x2) + } + + // A variant of getCell which specifically returns line values, + // and treats objects as being out-of-bounds + getLine(x, y) { + var cell = this.getCell(x, y) + if (cell == null) return null + if (cell.type !== 'line') return null + return cell.line + } + + updateCell2(x, y, key, value) { + x = this._mod(x) + if (!this._safeCell(x, y)) return + var cell = this.grid[x][y] + if (cell == null) return + cell[key] = value + } + + getValidEndDirs(x, y) { + x = this._mod(x) + if (!this._safeCell(x, y)) return [] + + var dirs = [] + var leftCell = this.getCell(x - 1, y) + if (leftCell == null || leftCell.gap === window.GAP_FULL) dirs.push('left') + var topCell = this.getCell(x, y - 1) + if (topCell == null || topCell.gap === window.GAP_FULL) dirs.push('top') + var rightCell = this.getCell(x + 1, y) + if (rightCell == null || rightCell.gap === window.GAP_FULL) dirs.push('right') + var bottomCell = this.getCell(x, y + 1) + if (bottomCell == null || bottomCell.gap === window.GAP_FULL) dirs.push('bottom') + return dirs + } + + // Note: Does not use this.width/this.height, so that it may be used to ask about resizing. + getSizeError(width, height) { + if (this.pillar && width < 4) return 'Pillars may not have a width of 1' + if (width * height < 25) return 'Puzzles may not be smaller than 2x2 or 1x4' + if (width > 21 || height > 21) return 'Puzzles may not be larger than 10 in either dimension' + if (this.symmetry != null) { + if (this.symmetry.x && width <= 2) return 'Symmetrical puzzles must be sufficiently wide for both lines' + if (this.symmetry.y && height <= 2) return 'Symmetrical puzzles must be sufficiently wide for both lines' + if (this.pillar && this.symmetry.x && width % 4 !== 0) return 'X + Pillar symmetry must be an even number of rows, to keep both startpoints at the same parity' + } + + return null + } + + + // Called on a solution. Computes a list of gaps to show as hints which *do not* + // break the path. + loadHints() { + this.hints = [] + for (var x=0; x window.LINE_NONE) { + this.hints.push({'x':x, 'y':y}) + } + } + } + } + + // Show a hint on the grid. + // If no hint is provided, will select the best one it can find, + // prioritizing breaking current lines on the grid. + // Returns the shown hint. + showHint(hint) { + if (hint != null) { + this.grid[hint.x][hint.y].gap = window.GAP_BREAK + return + } + + var goodHints = [] + var badHints = [] + + for (var hint of this.hints) { + if (this.getLine(hint.x, hint.y) > window.LINE_NONE) { + // Solution will be broken by this hint + goodHints.push(hint) + } else { + badHints.push(hint) + } + } + if (goodHints.length > 0) { + var hint = goodHints.splice(window.randInt(goodHints.length), 1)[0] + } else if (badHints.length > 0) { + var hint = badHints.splice(window.randInt(badHints.length), 1)[0] + } else { + return + } + this.grid[hint.x][hint.y].gap = window.GAP_BREAK + this.hints = badHints.concat(goodHints) + return hint + } + + clearLines() { + for (var x=0; x 0) this._floodFill(x, y - 1, region, col) + if (x < this.width - 1) this._floodFill(x + 1, y, region, this.grid[x+1]) + else if (this.pillar !== false) this._floodFill(0, y, region, this.grid[0]) + if (x > 0) this._floodFill(x - 1, y, region, this.grid[x-1]) + else if (this.pillar !== false) this._floodFill(this.width-1, y, region, this.grid[this.width-1]) + } + + // Re-uses the same grid, but only called on edges which border the outside + // Called first to mark cells that are connected to the outside, i.e. should not be part of any region. + _floodFillOutside(x, y, col) { + var cell = col[y] + if (cell === MASKED_PROCESSED) return + if (x%2 !== y%2 && cell !== MASKED_GAP2) return // Only flood-fill through gap-2 + if (x%2 === 0 && y%2 === 0 && cell === MASKED_DOT) return // Don't flood-fill through dots + col[y] = MASKED_PROCESSED + + if (x%2 === 0 && y%2 === 0) return // Don't flood fill through corners (what? Clarify.) + + if (y < this.height - 1) this._floodFillOutside(x, y + 1, col) + if (y > 0) this._floodFillOutside(x, y - 1, col) + if (x < this.width - 1) this._floodFillOutside(x + 1, y, this.grid[x+1]) + else if (this.pillar !== false) this._floodFillOutside(0, y, this.grid[0]) + if (x > 0) this._floodFillOutside(x - 1, y, this.grid[x-1]) + else if (this.pillar !== false) this._floodFillOutside(this.width-1, y, this.grid[this.width-1]) + } + + // Returns the original grid (pre-masking). You will need to switch back once you are done flood filling. + switchToMaskedGrid() { + // Make a copy of the grid -- we will be overwriting it + var savedGrid = this.grid + this.grid = new Array(this.width) + // Override all elements with empty lines -- this means that flood fill is just + // looking for lines with line=0. + for (var x=0; x window.LINE_NONE) { + row[y] = MASKED_PROCESSED // Traced lines should not be a part of the region + } else if (cell.gap === window.GAP_FULL) { + row[y] = MASKED_GAP2 + } else if (cell.dot > window.DOT_NONE) { + row[y] = MASKED_DOT + } else { + row[y] = MASKED_INB_COUNT + } + } + this.grid[x] = row + } + + // Starting at a mid-segment startpoint + if (this.startPoint != null && this.startPoint.x%2 !== this.startPoint.y%2) { + if (this.settings.FAT_STARTPOINTS) { + // This segment is not in any region (acts as a barrier) + this.grid[this.startPoint.x][this.startPoint.y] = MASKED_OOB + } else { + // This segment is part of this region (acts as an empty cell) + this.grid[this.startPoint.x][this.startPoint.y] = MASKED_INB_NONCOUNT + } + } + + // Ending at a mid-segment endpoint + if (this.endPoint != null && this.endPoint.x%2 !== this.endPoint.y%2) { + // This segment is part of this region (acts as an empty cell) + this.grid[this.endPoint.x][this.endPoint.y] = MASKED_INB_NONCOUNT + } + + // Mark all outside cells as 'not in any region' (aka null) + + // Top and bottom edges + for (var x=1; x 0) row[x] = ' ' + if (cell.dot > 0) row[x] = 'X' + if (cell.line === 0) row[x] = '.' + if (cell.line === 1) row[x] = '#' + if (cell.line === 2) row[x] = '#' + if (cell.line === 3) row[x] = 'o' + } else row[x] = '?' + } + output += row.join('') + '\n' + } + console.info(output) + } +} + +// The grid contains 5 colors: +// null: Out of bounds or already processed +var MASKED_OOB = null +var MASKED_PROCESSED = null +// 0: In bounds, awaiting processing, but should not be part of the final region. +var MASKED_INB_NONCOUNT = 0 +// 1: In bounds, awaiting processing +var MASKED_INB_COUNT = 1 +// 2: Gap-2. After _floodFillOutside, this means "treat normally" (it will be null if oob) +var MASKED_GAP2 = 2 +// 3: Dot (of any kind), otherwise identical to 1. Should not be flood-filled through (why the f do we need this) +var MASKED_DOT = 3 + +}) diff --git a/app/assets/javascripts/serializer.js b/app/assets/javascripts/serializer.js new file mode 100644 index 0000000..70c7f0f --- /dev/null +++ b/app/assets/javascripts/serializer.js @@ -0,0 +1,365 @@ +namespace(function() { + +window.serializePuzzle = function(puzzle) { + var s = new Serializer('w') + var version = 0 + + s.writeInt(version) + s.writeByte(puzzle.width) + s.writeByte(puzzle.height) + s.writeString(puzzle.name) + + var genericFlags = 0 + if (puzzle.autoSolved) genericFlags |= GENERIC_FLAG_AUTOSOLVED + if (puzzle.symmetry) { + genericFlags |= GENERIC_FLAG_SYMMETRICAL + if (puzzle.symmetry.x) genericFlags |= GENERIC_FLAG_SYMMETRY_X + if (puzzle.symmetry.y) genericFlags |= GENERIC_FLAG_SYMMETRY_Y + } + if (puzzle.pillar) genericFlags |= GENERIC_FLAG_PILLAR + s.writeByte(genericFlags) + for (var x=0; x 0) { + s.writeInt(puzzle.path.length) + s.writeByte(startPos.x) + s.writeByte(startPos.y) + for (var dir of puzzle.path) s.writeByte(dir) + } + } else { + s.writeInt(0) + } + + var settingsFlags = 0 + if (puzzle.settings.NEGATIONS_CANCEL_NEGATIONS) settingsFlags |= SETTINGS_FLAG_NCN + if (puzzle.settings.SHAPELESS_ZERO_POLY) settingsFlags |= SETTINGS_FLAG_SZP + if (puzzle.settings.PRECISE_POLYOMINOS) settingsFlags |= SETTINGS_FLAG_PP + if (puzzle.settings.FLASH_FOR_ERRORS) settingsFlags |= SETTINGS_FLAG_FFE + if (puzzle.settings.FAT_STARTPOINTS) settingsFlags |= SETTINGS_FLAG_FS + if (puzzle.settings.CUSTOM_MECHANICS) settingsFlags |= SETTINGS_FLAG_CM + if (puzzle.settings.INVISIBLE_SYMMETRY) settingsFlags |= SETTINGS_FLAG_IS + s.writeByte(settingsFlags) + + s.writeByte(puzzle.symType) + + return s.str() +} + +window.deserializePuzzle = function(data) { + // Data is JSON, so decode it with the old deserializer + if (data[0] == '{') return Puzzle.deserialize(data) + + var s = new Serializer('r', data) + var version = s.readInt() + if (version > 0) throw Error('Cannot read data from unknown version: ' + version) + + var width = s.readByte() + var height = s.readByte() + var puzzle = new Puzzle(Math.floor(width / 2), Math.floor(height / 2)) + puzzle.name = s.readString() + + var genericFlags = s.readByte() + puzzle.autoSolved = genericFlags & GENERIC_FLAG_AUTOSOLVED + puzzle.symType = SYM_TYPE_NONE + if ((genericFlags & GENERIC_FLAG_SYMMETRICAL) != 0) { + if ((genericFlags & GENERIC_FLAG_SYMMETRY_X) != 0) { + if ((genericFlags & GENERIC_FLAG_SYMMETRY_Y) != 0) { + puzzle.symType = SYM_TYPE_ROTATIONAL + } else { + puzzle.symType = SYM_TYPE_VERTICAL + } + } else if ((genericFlags & GENERIC_FLAG_SYMMETRY_Y) != 0) { + puzzle.symType = SYM_TYPE_HORIZONTAL + } + } + puzzle.pillar = (genericFlags & GENERIC_FLAG_PILLAR) != 0 + for (var x=0; x 0) { + var path = [{ + 'x': s.readByte(), + 'y': s.readByte(), + }] + for (var i=0; i= numBytes) + } + + readByte() { + this._checkRead() + return this.data.charCodeAt(this.index++) + } + + writeByte(b) { + if (b < 0 || b > 0xFF) throw Error('Cannot write out-of-range byte ' + b) + this.data += String.fromCharCode(b) + } + + readInt() { + var b1 = this.readByte() << 0 + var b2 = this.readByte() << 8 + var b3 = this.readByte() << 16 + var b4 = this.readByte() << 24 + return b1 | b2 | b3 | b4 + } + + writeInt(i) { + if (i < 0 || i > 0xFFFFFFFF) throw Error('Cannot write out-of-range int ' + i) + var b1 = (i & 0x000000FF) >> 0 + var b2 = (i & 0x0000FF00) >> 8 + var b3 = (i & 0x00FF0000) >> 16 + var b4 = (i & 0xFF000000) >> 24 + this.writeByte(b1) + this.writeByte(b2) + this.writeByte(b3) + this.writeByte(b4) + } + + readLong() { + var i1 = this.readInt() << 32 + var i2 = this.readInt() + return i1 | i2 + } + + writeLong(l) { + if (l < 0 || l > 0xFFFFFFFFFFFFFFFF) throw Error('Cannot write out-of-range long ' + l) + var i1 = l & 0xFFFFFFFF + var i2 = (l - i1) / 0x100000000 + this.writeInt(i1) + this.writeInt(i2) + } + + readString() { + var len = this.readInt() + this._checkRead(len) + var str = this.data.substr(this.index, len) + this.index += len + return str + } + + writeString(s) { + if (s == null) { + this.writeInt(0) + return + } + this.writeInt(s.length) + this.data += s + } + + readColor() { + var r = this.readByte().toString() + var g = this.readByte().toString() + var b = this.readByte().toString() + var a = this.readByte().toString() + return 'rgba(' + r + ', ' + g + ', ' + b + ', ' + a + ')' + } + + writeColor(c) { + // Adapted from https://gist.github.com/njvack/02ad8efcb0d552b0230d + this.colorConverter.fillStyle = 'rgba(0, 0, 0, 0)' // Load a default in case we are passed garbage + this.colorConverter.clearRect(0, 0, 1, 1) + this.colorConverter.fillStyle = c + this.colorConverter.fillRect(0, 0, 1, 1) + var rgba = this.colorConverter.getImageData(0, 0, 1, 1).data + this.writeByte(rgba[0]) + this.writeByte(rgba[1]) + this.writeByte(rgba[2]) + this.writeByte(rgba[3]) + } + + readCell() { + var cellType = this.readByte() + if (cellType === CELL_TYPE_NULL) return null + + var cell = {} + cell.dir = null + cell.line = 0 + if (cellType === CELL_TYPE_LINE) { + cell.type = 'line' + cell.line = this.readByte() + var dot = this.readByte() + if (dot != 0) cell.dot = dot + var gap = this.readByte() + if (gap != 0) cell.gap = gap + } else if (cellType === CELL_TYPE_SQUARE) { + cell.type = 'square' + cell.color = this.readColor() + } else if (cellType === CELL_TYPE_STAR) { + cell.type = 'star' + cell.color = this.readColor() + } else if (cellType === CELL_TYPE_NEGA) { + cell.type = 'nega' + cell.color = this.readColor() + } else if (cellType === CELL_TYPE_TRIANGLE) { + cell.type = 'triangle' + cell.color = this.readColor() + cell.count = this.readByte() + } else if (cellType === CELL_TYPE_POLY) { + cell.type = 'poly' + cell.color = this.readColor() + cell.polyshape = this.readLong() + } else if (cellType === CELL_TYPE_YLOP) { + cell.type = 'ylop' + cell.color = this.readColor() + cell.polyshape = this.readLong() + } else if (cellType == CELL_TYPE_NONCE) { + cell.type = 'nonce' + } + + var startEnd = this.readByte() + if (startEnd & CELL_START) cell.start = true + if (startEnd & CELL_END_LEFT) cell.end = 'left' + if (startEnd & CELL_END_RIGHT) cell.end = 'right' + if (startEnd & CELL_END_TOP) cell.end = 'top' + if (startEnd & CELL_END_BOTTOM) cell.end = 'bottom' + + return cell + } + + + writeCell(cell) { + if (cell == null) { + this.writeByte(CELL_TYPE_NULL) + return + } + + // Write cell type, then cell data, then generic data. + // Note that cell type starts at 1, since 0 is the "null type". + if (cell.type == 'line') { + this.writeByte(CELL_TYPE_LINE) + this.writeByte(cell.line) + this.writeByte(cell.dot) + this.writeByte(cell.gap) + } else if (cell.type == 'square') { + this.writeByte(CELL_TYPE_SQUARE) + this.writeColor(cell.color) + } else if (cell.type == 'star') { + this.writeByte(CELL_TYPE_STAR) + this.writeColor(cell.color) + } else if (cell.type == 'nega') { + this.writeByte(CELL_TYPE_NEGA) + this.writeColor(cell.color) + } else if (cell.type == 'triangle') { + this.writeByte(CELL_TYPE_TRIANGLE) + this.writeColor(cell.color) + this.writeByte(cell.count) + } else if (cell.type == 'poly') { + this.writeByte(CELL_TYPE_POLY) + this.writeColor(cell.color) + this.writeLong(cell.polyshape) + } else if (cell.type == 'ylop') { + this.writeByte(CELL_TYPE_YLOP) + this.writeColor(cell.color) + this.writeLong(cell.polyshape) + } + + var startEnd = 0 + if (cell.start === true) startEnd |= CELL_START + if (cell.end == 'left') startEnd |= CELL_END_LEFT + if (cell.end == 'right') startEnd |= CELL_END_RIGHT + if (cell.end == 'top') startEnd |= CELL_END_TOP + if (cell.end == 'bottom') startEnd |= CELL_END_BOTTOM + this.writeByte(startEnd) + } +} + +var CELL_TYPE_NULL = 0 +var CELL_TYPE_LINE = 1 +var CELL_TYPE_SQUARE = 2 +var CELL_TYPE_STAR = 3 +var CELL_TYPE_NEGA = 4 +var CELL_TYPE_TRIANGLE = 5 +var CELL_TYPE_POLY = 6 +var CELL_TYPE_YLOP = 7 +var CELL_TYPE_NONCE = 8 + +var CELL_START = 1 +var CELL_END_LEFT = 2 +var CELL_END_RIGHT = 4 +var CELL_END_TOP = 8 +var CELL_END_BOTTOM = 16 + +var GENERIC_FLAG_AUTOSOLVED = 1 +var GENERIC_FLAG_SYMMETRICAL = 2 +var GENERIC_FLAG_SYMMETRY_X = 4 +var GENERIC_FLAG_SYMMETRY_Y = 8 +var GENERIC_FLAG_PILLAR = 16 + +var SETTINGS_FLAG_NCN = 1 +var SETTINGS_FLAG_SZP = 2 +var SETTINGS_FLAG_PP = 4 +var SETTINGS_FLAG_FFE = 8 +var SETTINGS_FLAG_FS = 16 +var SETTINGS_FLAG_CM = 32 +var SETTINGS_FLAG_IS = 64 + +}) diff --git a/app/assets/javascripts/solve.js b/app/assets/javascripts/solve.js new file mode 100644 index 0000000..8695291 --- /dev/null +++ b/app/assets/javascripts/solve.js @@ -0,0 +1,531 @@ +namespace(function() { + +// @Volatile -- must match order of MOVE_* in trace2 +// Move these, dummy. +var PATH_NONE = 0 +var PATH_LEFT = 1 +var PATH_RIGHT = 2 +var PATH_TOP = 3 +var PATH_BOTTOM = 4 + +window.MAX_SOLUTIONS = 0 +var solutionPaths = [] +var asyncTimer = 0 +var task = null +var puzzle = null +var path = [] +var SOLVE_SYNC = false +var SYNC_THRESHOLD = 9 // Depth at which we switch to a synchronous solver (for perf) +var doPruning = false + +var percentages = [] +var NODE_DEPTH = 9 +var nodes = 0 +function countNodes(x, y, depth) { + // Check for collisions (outside, gap, self, other) + var cell = puzzle.getCell(x, y) + if (cell == null) return + if (cell.gap > window.GAP_NONE) return + if (cell.line !== window.LINE_NONE) return + + if (puzzle.symType == SYM_TYPE_NONE) { + puzzle.updateCell2(x, y, 'line', window.LINE_BLACK) + } else { + var sym = puzzle.getSymmetricalPos(x, y) + if (puzzle.matchesSymmetricalPos(x, y, sym.x, sym.y)) return // Would collide with our reflection + + var symCell = puzzle.getCell(sym.x, sym.y) + if (symCell.gap > window.GAP_NONE) return + + puzzle.updateCell2(x, y, 'line', window.LINE_BLUE) + puzzle.updateCell2(sym.x, sym.y, 'line', window.LINE_YELLOW) + } + + if (depth < NODE_DEPTH) { + nodes++ + + if (y%2 === 0) { + countNodes(x - 1, y, depth + 1) + countNodes(x + 1, y, depth + 1) + } + + if (x%2 === 0) { + countNodes(x, y - 1, depth + 1) + countNodes(x, y + 1, depth + 1) + } + } + + tailRecurse(x, y) +} + +// Generates a solution via DFS recursive backtracking +window.solve = function(p, partialCallback, finalCallback) { + if (task != null) throw Error('Cannot start another solve() while one is already in progress') + var start = (new Date()).getTime() + + puzzle = p + var startPoints = [] + var numEndpoints = 0 + puzzle.hasNegations = false + puzzle.hasPolyominos = false + for (var x=0; x 0) { + // Tasks are pushed in order. To do DFS, we need to enqueue them in reverse order. + for (var i=newTasks.length - 1; i >= 0; i--) { + task = { + 'code': newTasks[i], + 'nextTask': task, + } + } + } + + // Asynchronizing is expensive. As such, we don't want to do it too often. + // However, we would like 'cancel solving' to be responsive. So, we call setTimeout every so often. + var doAsync = false + if (!SOLVE_SYNC) { + doAsync = (asyncTimer++ % 100 === 0) + while (nodes >= percentages[0]) { + if (partialCallback) partialCallback(100 - percentages.length) + percentages.shift() + doAsync = true + } + } + + if (doAsync) { + setTimeout(function() { + taskLoop(partialCallback, finalCallback) + }, 0) + } else { + taskLoop(partialCallback, finalCallback) + } +} + +function tailRecurse(x, y) { + // Tail recursion: Back out of this cell + puzzle.updateCell2(x, y, 'line', window.LINE_NONE) + if (puzzle.symType != SYM_TYPE_NONE) { + var sym = puzzle.getSymmetricalPos(x, y) + puzzle.updateCell2(sym.x, sym.y, 'line', window.LINE_NONE) + } +} + +// @Performance: This is the most central loop in this code. +// Any performance efforts should be focused here. +// Note: Most mechanics are NP (or harder), so don't feel bad about solving them by brute force. +// https://arxiv.org/pdf/1804.10193.pdf +function solveLoop(x, y, numEndpoints, earlyExitData) { + // Stop trying to solve once we reach our goal + if (solutionPaths.length >= window.MAX_SOLUTIONS) return + + // Check for collisions (outside, gap, self, other) + var cell = puzzle.getCell(x, y) + if (cell == null) return + if (cell.gap > window.GAP_NONE) return + if (cell.line !== window.LINE_NONE) return + + if (puzzle.symType == SYM_TYPE_NONE) { + puzzle.updateCell2(x, y, 'line', window.LINE_BLACK) + } else { + var sym = puzzle.getSymmetricalPos(x, y) + if (puzzle.matchesSymmetricalPos(x, y, sym.x, sym.y)) return // Would collide with our reflection + + var symCell = puzzle.getCell(sym.x, sym.y) + if (symCell.gap > window.GAP_NONE) return + + puzzle.updateCell2(x, y, 'line', window.LINE_BLUE) + puzzle.updateCell2(sym.x, sym.y, 'line', window.LINE_YELLOW) + } + + if (path.length < NODE_DEPTH) nodes++ + + if (cell.end != null) { + path.push(PATH_NONE) + puzzle.endPoint = {'x': x, 'y': y} + var puzzleData = window.validate(puzzle, true) + if (puzzleData.valid()) solutionPaths.push(path.slice()) + path.pop() + + // If there are no further endpoints, tail recurse. + // Otherwise, keep going -- we might be able to reach another endpoint. + numEndpoints-- + if (numEndpoints === 0) return tailRecurse(x, y) + } + + var newEarlyExitData = null + if (doPruning) { + var isEdge = x <= 0 || y <= 0 || x >= puzzle.width - 1 || y >= puzzle.height - 1 + newEarlyExitData = [ + earlyExitData[0] || (!isEdge && earlyExitData[2].isEdge), // Have we ever left an edge? + earlyExitData[2], // The position before our current one + {'x':x, 'y':y, 'isEdge':isEdge} // Our current position. + ] + if (earlyExitData[0] && !earlyExitData[1].isEdge && earlyExitData[2].isEdge && isEdge) { + // See the above comment for an explanation of this math. + var floodX = earlyExitData[2].x + (earlyExitData[1].x - x) + var floodY = earlyExitData[2].y + (earlyExitData[1].y - y) + var region = puzzle.getRegion(floodX, floodY) + if (region != null) { + var regionData = window.validateRegion(puzzle, region, true) + if (!regionData.valid()) return tailRecurse(x, y) + + // Additionally, we might have left an endpoint in the enclosed region. + // If so, we should decrement the number of remaining endpoints (and possibly tail recurse). + for (var pos of region) { + var endCell = puzzle.grid[pos.x][pos.y] + if (endCell != null && endCell.end != null) numEndpoints-- + } + + if (numEndpoints === 0) return tailRecurse(x, y) + } + } + } + + if (SOLVE_SYNC || path.length > SYNC_THRESHOLD) { + path.push(PATH_NONE) + + // Recursion order (LRUD) is optimized for BL->TR and mid-start puzzles + if (y%2 === 0) { + path[path.length-1] = PATH_LEFT + solveLoop(x - 1, y, numEndpoints, newEarlyExitData) + + path[path.length-1] = PATH_RIGHT + solveLoop(x + 1, y, numEndpoints, newEarlyExitData) + } + + if (x%2 === 0) { + path[path.length-1] = PATH_TOP + solveLoop(x, y - 1, numEndpoints, newEarlyExitData) + + path[path.length-1] = PATH_BOTTOM + solveLoop(x, y + 1, numEndpoints, newEarlyExitData) + } + + path.pop() + tailRecurse(x, y) + + } else { + // Push a dummy element on the end of the path, so that we can fill it correctly as we DFS. + // This element is popped when we tail recurse (which always happens *after* all of our DFS!) + path.push(PATH_NONE) + + // Recursion order (LRUD) is optimized for BL->TR and mid-start puzzles + var newTasks = [] + if (y%2 === 0) { + newTasks.push(function() { + path[path.length-1] = PATH_LEFT + return solveLoop(x - 1, y, numEndpoints, newEarlyExitData) + }) + newTasks.push(function() { + path[path.length-1] = PATH_RIGHT + return solveLoop(x + 1, y, numEndpoints, newEarlyExitData) + }) + } + + if (x%2 === 0) { + newTasks.push(function() { + path[path.length-1] = PATH_TOP + return solveLoop(x, y - 1, numEndpoints, newEarlyExitData) + }) + newTasks.push(function() { + path[path.length-1] = PATH_BOTTOM + return solveLoop(x, y + 1, numEndpoints, newEarlyExitData) + }) + } + + newTasks.push(function() { + path.pop() + tailRecurse(x, y) + }) + + return newTasks + } +} + +window.cancelSolving = function() { + console.info('Cancelled solving') + window.MAX_SOLUTIONS = 0 // Causes all new solveLoop calls to exit immediately. + tasks = [] +} + +// Only modifies the puzzle object (does not do any graphics updates). Used by metapuzzle.js to determine subpuzzle polyshapes. +window.drawPathNoUI = function(puzzle, path) { + puzzle.clearLines() + + // Extract the start data from the first path element + var x = path[0].x + var y = path[0].y + var cell = puzzle.getCell(x, y) + if (cell == null || cell.start !== true) throw Error('Path does not begin with a startpoint: ' + JSON.stringify(cell)) + + for (var i=1; i max ? max : value +} + +class BoundingBox { + constructor(x1, x2, y1, y2, sym=false) { + this.raw = {'x1':x1, 'x2':x2, 'y1':y1, 'y2':y2} + this.sym = sym + if (BBOX_DEBUG === true) { + this.debug = createElement('rect') + data.svg.appendChild(this.debug) + this.debug.setAttribute('opacity', 0.5) + this.debug.setAttribute('style', 'pointer-events: none;') + if (data.puzzle.symType == SYM_TYPE_NONE) { + this.debug.setAttribute('fill', 'white') + } else { + if (this.sym !== true) { + this.debug.setAttribute('fill', 'blue') + } else { + this.debug.setAttribute('fill', 'orange') + } + } + } + this._update() + } + + shift(dir, pixels) { + if (dir === 'left') { + this.raw.x2 = this.raw.x1 + this.raw.x1 -= pixels + } else if (dir === 'right') { + this.raw.x1 = this.raw.x2 + this.raw.x2 += pixels + } else if (dir === 'top') { + this.raw.y2 = this.raw.y1 + this.raw.y1 -= pixels + } else if (dir === 'bottom') { + this.raw.y1 = this.raw.y2 + this.raw.y2 += pixels + } + this._update() + } + + inMain(x, y) { + var inMainBox = + (this.x1 < x && x < this.x2) && + (this.y1 < y && y < this.y2) + var inRawBox = + (this.raw.x1 < x && x < this.raw.x2) && + (this.raw.y1 < y && y < this.raw.y2) + + return inMainBox && !inRawBox + } + + _update() { + this.x1 = this.raw.x1 + this.x2 = this.raw.x2 + this.y1 = this.raw.y1 + this.y2 = this.raw.y2 + + // Check for endpoint adjustment. + // Pretend it's not an endpoint if the sym cell isn't an endpoint. + if (data.puzzle.symType != SYM_TYPE_NONE) { + var cell1 = data.puzzle.getCell(data.pos.x, data.pos.y) + var cell2 = data.puzzle.getSymmetricalCell(data.pos.x, data.pos.y) + + if ((cell1.end == null) != (cell2.end == null)) { + var cell = {'end': 'none'} + } else if (this.sym !== true) { + var cell = cell1 + } else { + var cell = cell2 + } + } else { + var cell = data.puzzle.getCell(data.pos.x, data.pos.y) + } + if (cell.end === 'left') { + this.x1 -= 24 + } else if (cell.end === 'right') { + this.x2 += 24 + } else if (cell.end === 'top') { + this.y1 -= 24 + } else if (cell.end === 'bottom') { + this.y2 += 24 + } + + this.middle = { // Note: Middle of the raw object + 'x':(this.raw.x1 + this.raw.x2)/2, + 'y':(this.raw.y1 + this.raw.y2)/2 + } + + if (this.debug != null) { + this.debug.setAttribute('x', this.x1) + this.debug.setAttribute('y', this.y1) + this.debug.setAttribute('width', this.x2 - this.x1) + this.debug.setAttribute('height', this.y2 - this.y1) + } + } +} + +class PathSegment { + constructor(dir) { + this.poly1 = createElement('polygon') + this.circ = createElement('circle') + this.poly2 = createElement('polygon') + this.pillarCirc = createElement('circle') + this.dir = dir + data.svg.insertBefore(this.circ, data.cursor) + data.svg.insertBefore(this.poly2, data.cursor) + data.svg.insertBefore(this.pillarCirc, data.cursor) + this.circ.setAttribute('cx', data.bbox.middle.x) + this.circ.setAttribute('cy', data.bbox.middle.y) + + if (data.puzzle.pillar === true) { + // cx/cy are updated in redraw(), since pillarCirc tracks the cursor + this.pillarCirc.setAttribute('cy', data.bbox.middle.y) + this.pillarCirc.setAttribute('r', 12) + if (data.pos.x === 0 && this.dir === MOVE_RIGHT) { + this.pillarCirc.setAttribute('cx', data.bbox.x1) + this.pillarCirc.setAttribute('static', true) + } else if (data.pos.x === data.puzzle.width - 1 && this.dir === MOVE_LEFT) { + this.pillarCirc.setAttribute('cx', data.bbox.x2) + this.pillarCirc.setAttribute('static', true) + } else { + this.pillarCirc.setAttribute('cx', data.bbox.middle.x) + } + } + + if (data.puzzle.symType == SYM_TYPE_NONE) { + this.poly1.setAttribute('class', 'line-1 ' + data.svg.id) + this.circ.setAttribute('class', 'line-1 ' + data.svg.id) + this.poly2.setAttribute('class', 'line-1 ' + data.svg.id) + this.pillarCirc.setAttribute('class', 'line-1 ' + data.svg.id) + } else { + this.poly1.setAttribute('class', 'line-2 ' + data.svg.id) + this.circ.setAttribute('class', 'line-2 ' + data.svg.id) + this.poly2.setAttribute('class', 'line-2 ' + data.svg.id) + this.pillarCirc.setAttribute('class', 'line-2 ' + data.svg.id) + + this.symPoly1 = createElement('polygon') + this.symCirc = createElement('circle') + this.symPoly2 = createElement('polygon') + this.symPillarCirc = createElement('circle') + data.svg.insertBefore(this.symCirc, data.cursor) + data.svg.insertBefore(this.symPoly2, data.cursor) + data.svg.insertBefore(this.symPillarCirc, data.cursor) + + if (data.puzzle.settings.INVISIBLE_SYMMETRY) { + this.symPoly1.setAttribute('class', 'line-4 ' + data.svg.id) + this.symCirc.setAttribute('class', 'line-4 ' + data.svg.id) + this.symPoly2.setAttribute('class', 'line-4 ' + data.svg.id) + this.symPillarCirc.setAttribute('class', 'line-4 ' + data.svg.id) + } else { + this.symPoly1.setAttribute('class', 'line-3 ' + data.svg.id) + this.symCirc.setAttribute('class', 'line-3 ' + data.svg.id) + this.symPoly2.setAttribute('class', 'line-3 ' + data.svg.id) + this.symPillarCirc.setAttribute('class', 'line-3 ' + data.svg.id) + } + + this.symCirc.setAttribute('cx', data.symbbox.middle.x) + this.symCirc.setAttribute('cy', data.symbbox.middle.y) + + if (data.puzzle.pillar === true) { + // cx/cy are updated in redraw(), since symPillarCirc tracks the cursor + this.symPillarCirc.setAttribute('cy', data.symbbox.middle.y) + this.symPillarCirc.setAttribute('r', 12) + var symmetricalDir = getSymmetricalDir(data.puzzle, this.dir) + if (data.sym.x === 0 && symmetricalDir === MOVE_RIGHT) { + this.symPillarCirc.setAttribute('cx', data.symbbox.x1) + this.symPillarCirc.setAttribute('static', true) + } else if (data.sym.x === data.puzzle.width - 1 && symmetricalDir === MOVE_LEFT) { + this.symPillarCirc.setAttribute('cx', data.symbbox.x2) + this.symPillarCirc.setAttribute('static', true) + } else { + this.symPillarCirc.setAttribute('cx', data.symbbox.middle.x) + } + } + } + + if (this.dir === MOVE_NONE) { // Start point + this.circ.setAttribute('r', 24) + this.circ.setAttribute('class', this.circ.getAttribute('class') + ' start') + if (data.puzzle.symType != SYM_TYPE_NONE) { + this.symCirc.setAttribute('r', 24) + this.symCirc.setAttribute('class', this.symCirc.getAttribute('class') + ' start') + } + } else { + // Only insert poly1 in non-startpoints + data.svg.insertBefore(this.poly1, data.cursor) + this.circ.setAttribute('r', 12) + if (data.puzzle.symType != SYM_TYPE_NONE) { + data.svg.insertBefore(this.symPoly1, data.cursor) + this.symCirc.setAttribute('r', 12) + } + } + } + + destroy() { + data.svg.removeChild(this.poly1) + data.svg.removeChild(this.circ) + data.svg.removeChild(this.poly2) + data.svg.removeChild(this.pillarCirc) + if (data.puzzle.symType != SYM_TYPE_NONE) { + data.svg.removeChild(this.symPoly1) + data.svg.removeChild(this.symCirc) + data.svg.removeChild(this.symPoly2) + data.svg.removeChild(this.symPillarCirc) + } + } + + redraw() { // Uses raw bbox because of endpoints + // Move the cursor and related objects + var x = clamp(data.x, data.bbox.x1, data.bbox.x2) + var y = clamp(data.y, data.bbox.y1, data.bbox.y2) + data.cursor.setAttribute('cx', x) + data.cursor.setAttribute('cy', y) + if (data.puzzle.symType != SYM_TYPE_NONE) { + data.symcursor.setAttribute('cx', this._reflX(x,y)) + data.symcursor.setAttribute('cy', this._reflY(x,y)) + } + if (data.puzzle.pillar === true) { + if (this.pillarCirc.getAttribute('static') == null) { + this.pillarCirc.setAttribute('cx', x) + this.pillarCirc.setAttribute('cy', y) + } + if (data.puzzle.symType != SYM_TYPE_NONE) { + if (this.symPillarCirc.getAttribute('static') == null) { + this.symPillarCirc.setAttribute('cx', this._reflX(x,y)) + this.symPillarCirc.setAttribute('cy', this._reflY(x,y)) + } + } + } + + // Draw the first-half box + var points1 = JSON.parse(JSON.stringify(data.bbox.raw)) + if (this.dir === MOVE_LEFT) { + points1.x1 = clamp(data.x, data.bbox.middle.x, data.bbox.x2) + } else if (this.dir === MOVE_RIGHT) { + points1.x2 = clamp(data.x, data.bbox.x1, data.bbox.middle.x) + } else if (this.dir === MOVE_TOP) { + points1.y1 = clamp(data.y, data.bbox.middle.y, data.bbox.y2) + } else if (this.dir === MOVE_BOTTOM) { + points1.y2 = clamp(data.y, data.bbox.y1, data.bbox.middle.y) + } + this.poly1.setAttribute('points', + points1.x1 + ' ' + points1.y1 + ',' + + points1.x1 + ' ' + points1.y2 + ',' + + points1.x2 + ' ' + points1.y2 + ',' + + points1.x2 + ' ' + points1.y1 + ) + + var firstHalf = false + var isEnd = (data.puzzle.grid[data.pos.x][data.pos.y].end != null) + // The second half of the line uses the raw so that it can enter the endpoint properly. + var points2 = JSON.parse(JSON.stringify(data.bbox.raw)) + if (data.x < data.bbox.middle.x && this.dir !== MOVE_RIGHT) { + points2.x1 = clamp(data.x, data.bbox.x1, data.bbox.middle.x) + points2.x2 = data.bbox.middle.x + if (isEnd && data.pos.x%2 === 0 && data.pos.y%2 === 1) { + points2.y1 += 17 + points2.y2 -= 17 + } + } else if (data.x > data.bbox.middle.x && this.dir !== MOVE_LEFT) { + points2.x1 = data.bbox.middle.x + points2.x2 = clamp(data.x, data.bbox.middle.x, data.bbox.x2) + if (isEnd && data.pos.x%2 === 0 && data.pos.y%2 === 1) { + points2.y1 += 17 + points2.y2 -= 17 + } + } else if (data.y < data.bbox.middle.y && this.dir !== MOVE_BOTTOM) { + points2.y1 = clamp(data.y, data.bbox.y1, data.bbox.middle.y) + points2.y2 = data.bbox.middle.y + if (isEnd && data.pos.x%2 === 1 && data.pos.y%2 === 0) { + points2.x1 += 17 + points2.x2 -= 17 + } + } else if (data.y > data.bbox.middle.y && this.dir !== MOVE_TOP) { + points2.y1 = data.bbox.middle.y + points2.y2 = clamp(data.y, data.bbox.middle.y, data.bbox.y2) + if (isEnd && data.pos.x%2 === 1 && data.pos.y%2 === 0) { + points2.x1 += 17 + points2.x2 -= 17 + } + } else { + firstHalf = true + } + + this.poly2.setAttribute('points', + points2.x1 + ' ' + points2.y1 + ',' + + points2.x1 + ' ' + points2.y2 + ',' + + points2.x2 + ' ' + points2.y2 + ',' + + points2.x2 + ' ' + points2.y1 + ) + + // Show the second poly only in the second half of the cell + this.poly2.setAttribute('opacity', (firstHalf ? 0 : 1)) + // Show the circle in the second half of the cell AND in the start + if (firstHalf && this.dir !== MOVE_NONE) { + this.circ.setAttribute('opacity', 0) + } else { + this.circ.setAttribute('opacity', 1) + } + + // Draw the symmetrical path based on the original one + if (data.puzzle.symType != SYM_TYPE_NONE) { + this.symPoly1.setAttribute('points', + this._reflX(points1.x2, points1.y2) + ' ' + this._reflY(points1.x2, points1.y2) + ',' + + this._reflX(points1.x2, points1.y2) + ' ' + this._reflY(points1.x1, points1.y1) + ',' + + this._reflX(points1.x1, points1.y1) + ' ' + this._reflY(points1.x1, points1.y1) + ',' + + this._reflX(points1.x1, points1.y1) + ' ' + this._reflY(points1.x2, points1.y2) + ) + + this.symPoly2.setAttribute('points', + this._reflX(points2.x2, points2.y2) + ' ' + this._reflY(points2.x2, points2.y2) + ',' + + this._reflX(points2.x2, points2.y2) + ' ' + this._reflY(points2.x1, points2.y1) + ',' + + this._reflX(points2.x1, points2.y1) + ' ' + this._reflY(points2.x1, points2.y1) + ',' + + this._reflX(points2.x1, points2.y1) + ' ' + this._reflY(points2.x2, points2.y2) + ) + + this.symCirc.setAttribute('opacity', this.circ.getAttribute('opacity')) + this.symPoly2.setAttribute('opacity', this.poly2.getAttribute('opacity')) + } + } + + _reflX(x,y) { + if (data.puzzle.symType == SYM_TYPE_NONE) return x + + if (data.puzzle.symType == SYM_TYPE_VERTICAL || data.puzzle.symType == SYM_TYPE_ROTATIONAL || data.puzzle.symType == SYM_TYPE_PARALLEL_H_FLIP) { + // Mirror position inside the bounding box + return (data.bbox.middle.x - x) + data.symbbox.middle.x + } + if (data.puzzle.symType == SYM_TYPE_HORIZONTAL || data.puzzle.symType == SYM_TYPE_PARALLEL_H || data.puzzle.symType == SYM_TYPE_PARALLEL_V || data.puzzle.symType == SYM_TYPE_PARALLEL_V_FLIP) { + // Copy position inside the bounding box + return (x - data.bbox.middle.x) + data.symbbox.middle.x + } + if (data.puzzle.symType == SYM_TYPE_ROTATE_LEFT || data.puzzle.symType == SYM_TYPE_FLIP_XY) { + // Rotate position left inside the bounding box + return (y - data.bbox.middle.y) + data.symbbox.middle.x + } + if (data.puzzle.symType == SYM_TYPE_ROTATE_RIGHT || data.puzzle.symType == SYM_TYPE_FLIP_NEG_XY) { + // Rotate position right inside the bounding box + return (data.bbox.middle.y - y) + data.symbbox.middle.x + } + } + + _reflY(x,y) { + if (data.puzzle.symType == SYM_TYPE_NONE) return y + + if (data.puzzle.symType == SYM_TYPE_HORIZONTAL || data.puzzle.symType == SYM_TYPE_ROTATIONAL || data.puzzle.symType == SYM_TYPE_PARALLEL_V_FLIP) { + // Mirror position inside the bounding box + return (data.bbox.middle.y - y) + data.symbbox.middle.y + } + if (data.puzzle.symType == SYM_TYPE_VERTICAL || data.puzzle.symType == SYM_TYPE_PARALLEL_V || data.puzzle.symType == SYM_TYPE_PARALLEL_H || data.puzzle.symType == SYM_TYPE_PARALLEL_H_FLIP) { + // Copy position inside the bounding box + return (y - data.bbox.middle.y) + data.symbbox.middle.y + } + if (data.puzzle.symType == SYM_TYPE_ROTATE_RIGHT || data.puzzle.symType == SYM_TYPE_FLIP_XY) { + // Rotate position left inside the bounding box + return (x - data.bbox.middle.x) + data.symbbox.middle.y + } + if (data.puzzle.symType == SYM_TYPE_ROTATE_LEFT || data.puzzle.symType == SYM_TYPE_FLIP_NEG_XY) { + // Rotate position right inside the bounding box + return (data.bbox.middle.x - x) + data.symbbox.middle.y + } + } +} + +var data = {} + +function clearGrid(svg, puzzle) { + if (data.bbox != null && data.bbox.debug != null) { + data.svg.removeChild(data.bbox.debug) + data.bbox = null + } + if (data.symbbox != null && data.symbbox.debug != null) { + data.svg.removeChild(data.symbbox.debug) + data.symbbox = null + } + + window.deleteElementsByClassName(svg, 'cursor') + window.deleteElementsByClassName(svg, 'line-1') + window.deleteElementsByClassName(svg, 'line-2') + window.deleteElementsByClassName(svg, 'line-3') + window.deleteElementsByClassName(svg, 'line-4') + puzzle.clearLines() +} + +// This copy is an exact copy of puzzle.getSymmetricalDir, except that it uses MOVE_* values instead of strings +function getSymmetricalDir(puzzle, dir) { + if (puzzle.symType == SYM_TYPE_VERTICAL || puzzle.symType == SYM_TYPE_ROTATIONAL || puzzle.symType == SYM_TYPE_PARALLEL_H_FLIP) { + if (dir === MOVE_LEFT) return MOVE_RIGHT + if (dir === MOVE_RIGHT) return MOVE_LEFT + } + if (puzzle.symType == SYM_TYPE_HORIZONTAL || puzzle.symType == SYM_TYPE_ROTATIONAL || puzzle.symType == SYM_TYPE_PARALLEL_V_FLIP) { + if (dir === MOVE_TOP) return MOVE_BOTTOM + if (dir === MOVE_BOTTOM) return MOVE_TOP + } + if (puzzle.symType == SYM_TYPE_ROTATE_LEFT || puzzle.symType == SYM_TYPE_FLIP_NEG_XY) { + if (dir === MOVE_LEFT) return MOVE_BOTTOM + if (dir === MOVE_RIGHT) return MOVE_TOP + } + if (puzzle.symType == SYM_TYPE_ROTATE_RIGHT || puzzle.symType == SYM_TYPE_FLIP_NEG_XY) { + if (dir === MOVE_TOP) return MOVE_RIGHT + if (dir === MOVE_BOTTOM) return MOVE_LEFT + } + if (puzzle.symType == SYM_TYPE_ROTATE_LEFT || puzzle.symType == SYM_TYPE_FLIP_XY) { + if (dir === MOVE_TOP) return MOVE_LEFT + if (dir === MOVE_BOTTOM) return MOVE_RIGHT + } + if (puzzle.symType == SYM_TYPE_ROTATE_RIGHT || puzzle.symType == SYM_TYPE_FLIP_XY) { + if (dir === MOVE_RIGHT) return MOVE_BOTTOM + if (dir === MOVE_LEFT) return MOVE_TOP + } + return dir +} + +window.trace = function(event, puzzle, pos, start, symStart=null) { + /*if (data.start == null) {*/ + if (data.tracing !== true) { // could be undefined or false + var svg = start.parentElement + data.tracing = true + window.PLAY_SOUND('start') + // Cleans drawn lines & puzzle state + clearGrid(svg, puzzle) + onTraceStart(puzzle, pos, svg, start, symStart) + data.animations.insertRule('.' + svg.id + '.start {animation: 150ms 1 forwards start-grow}\n') + + hookMovementEvents(start) + } else { + event.stopPropagation() + // Signal the onMouseMove to stop accepting input (race condition) + data.tracing = false + + // At endpoint and in main box + var cell = puzzle.getCell(data.pos.x, data.pos.y) + if (cell.end != null && data.bbox.inMain(data.x, data.y)) { + data.cursor.onpointerdown = null + setTimeout(function() { // Run validation asynchronously so we can free the pointer immediately. + puzzle.endPoint = data.pos + var puzzleData = window.validate(puzzle, false) // We want all invalid elements so we can show the user. + + for (var negation of puzzleData.negations) { + console.debug('Rendering negation', negation) + data.animations.insertRule('.' + data.svg.id + '_' + negation.source.x + '_' + negation.source.y + ' {animation: 0.75s 1 forwards fade}\n') + data.animations.insertRule('.' + data.svg.id + '_' + negation.target.x + '_' + negation.target.y + ' {animation: 0.75s 1 forwards fade}\n') + } + + if (puzzleData.valid()) { + window.PLAY_SOUND('success') + // !important to override the child animation + data.animations.insertRule('.' + data.svg.id + ' {animation: 1s 1 forwards line-success !important}\n') + + // Convert the traced path into something suitable for solve.drawPath (for publishing purposes) + var rawPath = [puzzle.startPoint] + for (var i=1; i 1) { + // Stop tracing for two+ finger touches (the equivalent of a right click on desktop) + window.trace(event, data.puzzle, null, null, null) + return + } + data.lastTouchPos = event.position + } + document.ontouchmove = function(event) { + if (data.tracing !== true) return + + var eventIsWithinPuzzle = false + for (var node = event.target; node != null; node = node.parentElement) { + if (node == data.svg) { + eventIsWithinPuzzle = true + break + } + } + if (!eventIsWithinPuzzle) return // Ignore drag events that aren't within the puzzle + event.preventDefault() // Prevent accidental scrolling if the touch event is within the puzzle. + + var newPos = event.position + onMove(newPos.x - data.lastTouchPos.x, newPos.y - data.lastTouchPos.y) + data.lastTouchPos = newPos + } + document.ontouchend = function(event) { + data.lastTouchPos = null + // Only call window.trace (to stop tracing) if we're really in an endpoint. + var cell = data.puzzle.getCell(data.pos.x, data.pos.y) + if (cell.end != null && data.bbox.inMain(data.x, data.y)) { + window.trace(event, data.puzzle, null, null, null) + } + } +} + +// @Volatile -- must match order of PATH_* in solve +var MOVE_NONE = 0 +var MOVE_LEFT = 1 +var MOVE_RIGHT = 2 +var MOVE_TOP = 3 +var MOVE_BOTTOM = 4 + +window.onMove = function(dx, dy) { + { + // Also handles some collision + var collidedWith = pushCursor(dx, dy) + console.spam('Collided with', collidedWith) + } + + while (true) { + hardCollision() + + // Potentially move the location to a new cell, and make absolute boundary checks + var moveDir = move() + data.path[data.path.length - 1].redraw() + if (moveDir === MOVE_NONE) break + console.debug('Moved', ['none', 'left', 'right', 'top', 'bottom'][moveDir]) + + // Potentially adjust data.x/data.y if our position went around a pillar + if (data.puzzle.pillar === true) pillarWrap(moveDir) + + var lastDir = data.path[data.path.length - 1].dir + var backedUp = ((moveDir === MOVE_LEFT && lastDir === MOVE_RIGHT) + || (moveDir === MOVE_RIGHT && lastDir === MOVE_LEFT) + || (moveDir === MOVE_TOP && lastDir === MOVE_BOTTOM) + || (moveDir === MOVE_BOTTOM && lastDir === MOVE_TOP)) + + if (data.puzzle.symType != SYM_TYPE_NONE) { + var symMoveDir = getSymmetricalDir(data.puzzle, moveDir) + } + + // If we backed up, remove a path segment and mark the old cell as unvisited + if (backedUp) { + data.path.pop().destroy() + data.puzzle.updateCell2(data.pos.x, data.pos.y, 'line', window.LINE_NONE) + if (data.puzzle.symType != SYM_TYPE_NONE) { + if (data.puzzle.getLine(data.sym.x, data.sym.y) == window.LINE_OVERLAP) { + data.puzzle.updateCell2(data.sym.x, data.sym.y, 'line', window.LINE_BLUE) + } else { + data.puzzle.updateCell2(data.sym.x, data.sym.y, 'line', window.LINE_NONE) + } + } + } + + // Move to the next cell + changePos(data.bbox, data.pos, moveDir) + if (data.puzzle.symType != SYM_TYPE_NONE) { + changePos(data.symbbox, data.sym, symMoveDir) + } + + // If we didn't back up, add a path segment and mark the new cell as visited + if (!backedUp) { + data.path.push(new PathSegment(moveDir)) + if (data.puzzle.symType == SYM_TYPE_NONE) { + data.puzzle.updateCell2(data.pos.x, data.pos.y, 'line', window.LINE_BLACK) + } else { + data.puzzle.updateCell2(data.pos.x, data.pos.y, 'line', window.LINE_BLUE) + if (data.puzzle.getLine(data.sym.x, data.sym.y) == window.LINE_BLUE) { + data.puzzle.updateCell2(data.sym.x, data.sym.y, 'line', window.LINE_OVERLAP) + } else { + data.puzzle.updateCell2(data.sym.x, data.sym.y, 'line', window.LINE_YELLOW) + } + } + } + } +} + +// Helper function for pushCursor. Used to determine the direction and magnitude of redirection. +function push(dx, dy, dir, targetDir) { + // Fraction of movement to redirect in the other direction + var movementRatio = null + if (targetDir === 'left' || targetDir === 'top') { + movementRatio = -3 + } else if (targetDir === 'right' || targetDir === 'bottom') { + movementRatio = 3 + } + if (window.settings.disablePushing === true) movementRatio *= 1000 + + if (dir === 'left') { + var overshoot = data.bbox.x1 - (data.x + dx) + 12 + if (overshoot > 0) { + data.y += dy + overshoot / movementRatio + data.x = data.bbox.x1 + 12 + return true + } + } else if (dir === 'right') { + var overshoot = (data.x + dx) - data.bbox.x2 + 12 + if (overshoot > 0) { + data.y += dy + overshoot / movementRatio + data.x = data.bbox.x2 - 12 + return true + } + } else if (dir === 'leftright') { + data.y += dy + Math.abs(dx) / movementRatio + return true + } else if (dir === 'top') { + var overshoot = data.bbox.y1 - (data.y + dy) + 12 + if (overshoot > 0) { + data.x += dx + overshoot / movementRatio + data.y = data.bbox.y1 + 12 + return true + } + } else if (dir === 'bottom') { + var overshoot = (data.y + dy) - data.bbox.y2 + 12 + if (overshoot > 0) { + data.x += dx + overshoot / movementRatio + data.y = data.bbox.y2 - 12 + return true + } + } else if (dir === 'topbottom') { + data.x += dx + Math.abs(dy) / movementRatio + return true + } + return false +} + +// Redirect momentum from pushing against walls, so that all further moment steps +// will be strictly linear. Returns a string for logging purposes only. +function pushCursor(dx, dy) { + // Outer wall collision + var cell = data.puzzle.getCell(data.pos.x, data.pos.y) + if (cell == null) return 'nothing' + + // Only consider non-endpoints or endpoints which are parallel + if ([undefined, 'top', 'bottom'].includes(cell.end)) { + var leftCell = data.puzzle.getCell(data.pos.x - 1, data.pos.y) + if (leftCell == null || leftCell.gap === window.GAP_FULL) { + if (push(dx, dy, 'left', 'top')) return 'left outer wall' + } + var rightCell = data.puzzle.getCell(data.pos.x + 1, data.pos.y) + if (rightCell == null || rightCell.gap === window.GAP_FULL) { + if (push(dx, dy, 'right', 'top')) return 'right outer wall' + } + } + // Only consider non-endpoints or endpoints which are parallel + if ([undefined, 'left', 'right'].includes(cell.end)) { + var topCell = data.puzzle.getCell(data.pos.x, data.pos.y - 1) + if (topCell == null || topCell.gap === window.GAP_FULL) { + if (push(dx, dy, 'top', 'right')) return 'top outer wall' + } + var bottomCell = data.puzzle.getCell(data.pos.x, data.pos.y + 1) + if (bottomCell == null || bottomCell.gap === window.GAP_FULL) { + if (push(dx, dy, 'bottom', 'right')) return 'bottom outer wall' + } + } + + // Inner wall collision + if (cell.end == null) { + if (data.pos.x%2 === 1 && data.pos.y%2 === 0) { // Horizontal cell + if (data.x < data.bbox.middle.x) { + push(dx, dy, 'topbottom', 'left') + return 'topbottom inner wall, moved left' + } else { + push(dx, dy, 'topbottom', 'right') + return 'topbottom inner wall, moved right' + } + } else if (data.pos.x%2 === 0 && data.pos.y%2 === 1) { // Vertical cell + if (data.y < data.bbox.middle.y) { + push(dx, dy, 'leftright', 'top') + return 'leftright inner wall, moved up' + } else { + push(dx, dy, 'leftright', 'bottom') + return 'leftright inner wall, moved down' + } + } + } + + // Intersection & endpoint collision + // Ratio of movement to be considered turning at an intersection + var turnMod = 2 + if ((data.pos.x%2 === 0 && data.pos.y%2 === 0) || cell.end != null) { + if (data.x < data.bbox.middle.x) { + push(dx, dy, 'topbottom', 'right') + // Overshot the intersection and appears to be trying to turn + if (data.x > data.bbox.middle.x && Math.abs(dy) * turnMod > Math.abs(dx)) { + data.y += Math.sign(dy) * (data.x - data.bbox.middle.x) + data.x = data.bbox.middle.x + return 'overshot moving right' + } + return 'intersection moving right' + } else if (data.x > data.bbox.middle.x) { + push(dx, dy, 'topbottom', 'left') + // Overshot the intersection and appears to be trying to turn + if (data.x < data.bbox.middle.x && Math.abs(dy) * turnMod > Math.abs(dx)) { + data.y += Math.sign(dy) * (data.bbox.middle.x - data.x) + data.x = data.bbox.middle.x + return 'overshot moving left' + } + return 'intersection moving left' + } + if (data.y < data.bbox.middle.y) { + push(dx, dy, 'leftright', 'bottom') + // Overshot the intersection and appears to be trying to turn + if (data.y > data.bbox.middle.y && Math.abs(dx) * turnMod > Math.abs(dy)) { + data.x += Math.sign(dx) * (data.y - data.bbox.middle.y) + data.y = data.bbox.middle.y + return 'overshot moving down' + } + return 'intersection moving down' + } else if (data.y > data.bbox.middle.y) { + push(dx, dy, 'leftright', 'top') + // Overshot the intersection and appears to be trying to turn + if (data.y < data.bbox.middle.y && Math.abs(dx) * turnMod > Math.abs(dy)) { + data.x += Math.sign(dx) * (data.bbox.middle.y - data.y) + data.y = data.bbox.middle.y + return 'overshot moving up' + } + return 'intersection moving up' + } + } + + // No collision, limit movement to X or Y only to prevent out-of-bounds + if (Math.abs(dx) > Math.abs(dy)) { + data.x += dx + return 'nothing, x' + } else { + data.y += dy + return 'nothing, y' + } +} + +// Check to see if we collided with any gaps, or with a symmetrical line, or a startpoint. +// In any case, abruptly zero momentum. +function hardCollision() { + var lastDir = data.path[data.path.length - 1].dir + var cell = data.puzzle.getCell(data.pos.x, data.pos.y) + if (cell == null) return + + var gapSize = 0 + if (cell.gap === window.GAP_BREAK) { + console.spam('Collided with a gap') + gapSize = 21 + } else { + var nextCell = null + if (lastDir === MOVE_LEFT) nextCell = data.puzzle.getCell(data.pos.x - 1, data.pos.y) + if (lastDir === MOVE_RIGHT) nextCell = data.puzzle.getCell(data.pos.x + 1, data.pos.y) + if (lastDir === MOVE_TOP) nextCell = data.puzzle.getCell(data.pos.x, data.pos.y - 1) + if (lastDir === MOVE_BOTTOM) nextCell = data.puzzle.getCell(data.pos.x, data.pos.y + 1) + if (nextCell != null && nextCell.start === true && nextCell.line > window.LINE_NONE) { + gapSize = -5 + } + } + + if (data.puzzle.symType != SYM_TYPE_NONE) { + if (data.sym.x === data.pos.x && data.sym.y === data.pos.y) { + console.spam('Collided with our symmetrical line') + gapSize = 13 + } else if (data.puzzle.getCell(data.sym.x, data.sym.y).gap === window.GAP_BREAK) { + console.spam('Symmetrical line hit a gap') + gapSize = 21 + } + } + if (gapSize === 0) return // Didn't collide with anything + + if (lastDir === MOVE_LEFT) { + data.x = Math.max(data.bbox.middle.x + gapSize, data.x) + } else if (lastDir === MOVE_RIGHT) { + data.x = Math.min(data.x, data.bbox.middle.x - gapSize) + } else if (lastDir === MOVE_TOP) { + data.y = Math.max(data.bbox.middle.y + gapSize, data.y) + } else if (lastDir === MOVE_BOTTOM) { + data.y = Math.min(data.y, data.bbox.middle.y - gapSize) + } +} + +// Check to see if we've gone beyond the edge of puzzle cell, and if the next cell is safe, +// i.e. not out of bounds. Reports the direction we are going to move (or none), +// but does not actually change data.pos +function move() { + var lastDir = data.path[data.path.length - 1].dir + + if (data.x < data.bbox.x1 + 12) { // Moving left + var cell = data.puzzle.getCell(data.pos.x - 1, data.pos.y) + if (cell == null || cell.type !== 'line' || cell.gap === window.GAP_FULL) { + console.spam('Collided with outside / gap-2', cell) + data.x = data.bbox.x1 + 12 + } else if (cell.line > window.LINE_NONE && lastDir !== MOVE_RIGHT) { + console.spam('Collided with other line', cell.line) + data.x = data.bbox.x1 + 12 + } else if (data.puzzle.symType != SYM_TYPE_NONE) { + var symCell = data.puzzle.getSymmetricalCell(data.pos.x - 1, data.pos.y) + if (symCell == null || symCell.type !== 'line' || symCell.gap === window.GAP_FULL) { + console.spam('Collided with symmetrical outside / gap-2', cell) + data.x = data.bbox.x1 + 12 + } + } + if (data.x < data.bbox.x1) { + return MOVE_LEFT + } + } else if (data.x > data.bbox.x2 - 12) { // Moving right + var cell = data.puzzle.getCell(data.pos.x + 1, data.pos.y) + if (cell == null || cell.type !== 'line' || cell.gap === window.GAP_FULL) { + console.spam('Collided with outside / gap-2', cell) + data.x = data.bbox.x2 - 12 + } else if (cell.line > window.LINE_NONE && lastDir !== MOVE_LEFT) { + console.spam('Collided with other line', cell.line) + data.x = data.bbox.x2 - 12 + } else if (data.puzzle.symType != SYM_TYPE_NONE) { + var symCell = data.puzzle.getSymmetricalCell(data.pos.x + 1, data.pos.y) + if (symCell == null || symCell.type !== 'line' || symCell.gap === window.GAP_FULL) { + console.spam('Collided with symmetrical outside / gap-2', cell) + data.x = data.bbox.x2 - 12 + } + } + if (data.x > data.bbox.x2) { + return MOVE_RIGHT + } + } else if (data.y < data.bbox.y1 + 12) { // Moving up + var cell = data.puzzle.getCell(data.pos.x, data.pos.y - 1) + if (cell == null || cell.type !== 'line' || cell.gap === window.GAP_FULL) { + console.spam('Collided with outside / gap-2', cell) + data.y = data.bbox.y1 + 12 + } else if (cell.line > window.LINE_NONE && lastDir !== MOVE_BOTTOM) { + console.spam('Collided with other line', cell.line) + data.y = data.bbox.y1 + 12 + } else if (data.puzzle.symType != SYM_TYPE_NONE) { + var symCell = data.puzzle.getSymmetricalCell(data.pos.x, data.pos.y - 1) + if (symCell == null || symCell.type !== 'line' || symCell.gap === window.GAP_FULL) { + console.spam('Collided with symmetrical outside / gap-2', cell) + data.y = data.bbox.y1 + 12 + } + } + if (data.y < data.bbox.y1) { + return MOVE_TOP + } + } else if (data.y > data.bbox.y2 - 12) { // Moving down + var cell = data.puzzle.getCell(data.pos.x, data.pos.y + 1) + if (cell == null || cell.type !== 'line' || cell.gap === window.GAP_FULL) { + console.spam('Collided with outside / gap-2') + data.y = data.bbox.y2 - 12 + } else if (cell.line > window.LINE_NONE && lastDir !== MOVE_TOP) { + console.spam('Collided with other line', cell.line) + data.y = data.bbox.y2 - 12 + } else if (data.puzzle.symType != SYM_TYPE_NONE) { + var symCell = data.puzzle.getSymmetricalCell(data.pos.x, data.pos.y + 1) + if (symCell == null || symCell.type !== 'line' || symCell.gap === window.GAP_FULL) { + console.spam('Collided with symmetrical outside / gap-2', cell) + data.y = data.bbox.y2 - 12 + } + } + if (data.y > data.bbox.y2) { + return MOVE_BOTTOM + } + } + return MOVE_NONE +} + +// Check to see if you moved beyond the edge of a pillar. +// If so, wrap the cursor x to preserve momentum. +// Note that this still does not change the position. +function pillarWrap(moveDir) { + if (moveDir === MOVE_LEFT && data.pos.x === 0) { + data.x += data.puzzle.width * 41 + } + if (moveDir === MOVE_RIGHT && data.pos.x === data.puzzle.width - 1) { + data.x -= data.puzzle.width * 41 + } +} + +// Actually change the data position. (Note that this takes in pos to allow easier symmetry). +// Note that this doesn't zero the momentum, so that we can adjust appropriately on further loops. +// This function also shifts the bounding box that we use to determine the bounds of the cell. +function changePos(bbox, pos, moveDir) { + if (moveDir === MOVE_LEFT) { + pos.x-- + // Wrap around the left + if (data.puzzle.pillar === true && pos.x < 0) { + pos.x += data.puzzle.width + bbox.shift('right', data.puzzle.width * 41 - 82) + bbox.shift('right', 58) + } else { + bbox.shift('left', (pos.x%2 === 0 ? 24 : 58)) + } + } else if (moveDir === MOVE_RIGHT) { + pos.x++ + // Wrap around to the right + if (data.puzzle.pillar === true && pos.x >= data.puzzle.width) { + pos.x -= data.puzzle.width + bbox.shift('left', data.puzzle.width * 41 - 82) + bbox.shift('left', 24) + } else { + bbox.shift('right', (pos.x%2 === 0 ? 24 : 58)) + } + } else if (moveDir === MOVE_TOP) { + pos.y-- + bbox.shift('top', (pos.y%2 === 0 ? 24 : 58)) + } else if (moveDir === MOVE_BOTTOM) { + pos.y++ + bbox.shift('bottom', (pos.y%2 === 0 ? 24 : 58)) + } +} + +}) diff --git a/app/assets/javascripts/utilities.js.erb b/app/assets/javascripts/utilities.js.erb new file mode 100644 index 0000000..0414ce8 --- /dev/null +++ b/app/assets/javascripts/utilities.js.erb @@ -0,0 +1,498 @@ +function namespace(code) { + code() +} + +namespace(function() { + +/*** Start cross-compatibility ***/ +// Used to detect if IDs include a direction, e.g. resize-top-left +if (!String.prototype.includes) { + String.prototype.includes = function() { + return String.prototype.indexOf.apply(this, arguments) !== -1 + } +} +Event.prototype.movementX = Event.prototype.movementX || Event.prototype.mozMovementX +Event.prototype.movementY = Event.prototype.movementY || Event.prototype.mozMovementY +Event.prototype.isRightClick = function() { + return this.which === 3 || (this.touches && this.touches.length > 1) +} +Element.prototype.disable = function() { + this.disabled = true + this.style.pointerEvents = 'none' + this.className = 'noselect' +} +Element.prototype.enable = function() { + this.disabled = false + this.style.pointerEvents = null + this.className = null +} +Object.defineProperty(Event.prototype, 'position', { + 'get': function() { + return { + 'x': event.pageX || event.clientX || (event.touches && event.touches[0].pageX) || null, + 'y': event.pageY || event.clientY || (event.touches && event.touches[0].pageY) || null, + } + } +}) +/*** End cross-compatibility ***/ + +var proxy = { + 'get': function(_, key) { + try { + return this._map[key] + } catch (e) { + return null + } + }, + 'set': function(_, key, value) { + if (value == null) { + delete this._map[key] + } else { + this._map[key] = value.toString() + window.localStorage.setItem('settings', JSON.stringify(this._map)) + } + }, + 'init': function() { + this._map = {} + try { + var j = window.localStorage.getItem('settings') + if (j != null) this._map = JSON.parse(j) + } catch (e) {/* Do nothing */} + + function setIfNull(map, key, value) { + if (map[key] == null) map[key] = value + } + + // Set any values which are not defined + setIfNull(this._map, 'theme', 'light') + setIfNull(this._map, 'volume', '0.12') + setIfNull(this._map, 'sensitivity', '0.7') + setIfNull(this._map, 'expanded', 'false') + setIfNull(this._map, 'customMechanics', 'false') + return this + }, +} +window.settings = new Proxy({}, proxy.init()) + +var tracks = { + 'start': new Audio(src = '<%= asset_url("panel_start_tracing.aac") %>'), + 'success': new Audio(src = '<%= asset_url("panel_success.aac") %>'), + 'fail': new Audio(src = '<%= asset_url("panel_failure.aac") %>'), + 'abort': new Audio(src = '<%= asset_url("panel_abort_tracing.aac") %>'), +} + +var currentAudio = null +window.PLAY_SOUND = function(name) { + if (currentAudio) currentAudio.pause() + var audio = tracks[name] + audio.load() + audio.volume = parseFloat(window.settings.volume) + audio.play().then(function() { + currentAudio = audio + }).catch(function() { + // Do nothing. + }) +} + +window.LINE_PRIMARY = '#8FF' +window.LINE_SECONDARY = '#FF2' + +if (window.settings.theme == 'night') { + window.BACKGROUND = '#221' + window.OUTER_BACKGROUND = '#070704' + window.FOREGROUND = '#751' + window.BORDER = '#666' + window.LINE_DEFAULT = '#888' + window.LINE_SUCCESS = '#BBB' + window.LINE_FAIL = '#000' + window.CURSOR = '#FFF' + window.TEXT_COLOR = '#AAA' + window.PAGE_BACKGROUND = '#000' + window.ALT_BACKGROUND = '#333' // An off-black. Good for mild contrast. + window.ACTIVE_COLOR = '#555' // Color for 'while the element is being pressed' +} else if (window.settings.theme == 'light') { + window.BACKGROUND = '#0A8' + window.OUTER_BACKGROUND = '#113833' + window.FOREGROUND = '#344' + window.BORDER = '#000' + window.LINE_DEFAULT = '#AAA' + window.LINE_SUCCESS = '#FFF' + window.LINE_FAIL = '#000' + window.CURSOR = '#FFF' + window.TEXT_COLOR = '#000' + window.PAGE_BACKGROUND = '#FFF' + window.ALT_BACKGROUND = '#EEE' // An off-white. Good for mild contrast. + window.ACTIVE_COLOR = '#DDD' // Color for 'while the element is being pressed' +} + +window.LINE_NONE = 0 +window.LINE_BLACK = 1 +window.LINE_BLUE = 2 +window.LINE_YELLOW = 3 +window.LINE_OVERLAP = 4 +window.DOT_NONE = 0 +window.DOT_BLACK = 1 +window.DOT_BLUE = 2 +window.DOT_YELLOW = 3 +window.DOT_INVISIBLE = 4 +window.GAP_NONE = 0 +window.GAP_BREAK = 1 +window.GAP_FULL = 2 + +var animations = '' +var l = function(line) {animations += line + '\n'} +// pointer-events: none; allows for events to bubble up (so that editor hooks still work) +l('.line-1 {') +l(' fill: ' + window.LINE_DEFAULT + ';') +l(' pointer-events: none;') +l('}') +l('.line-2 {') +l(' fill: ' + window.LINE_PRIMARY + ';') +l(' pointer-events: none;') +l('}') +l('.line-3 {') +l(' fill: ' + window.LINE_SECONDARY + ';') +l(' pointer-events: none;') +l('}') +l('.line-4 {') +l(' display: none;') +l(' pointer-events: none;') +l('}') +l('@keyframes line-success {to {fill: ' + window.LINE_SUCCESS + ';}}') +l('@keyframes line-fail {to {fill: ' + window.LINE_FAIL + ';}}') +l('@keyframes error {to {fill: red;}}') +l('@keyframes fade {to {opacity: 0.35;}}') +l('@keyframes start-grow {from {r:12;} to {r:24;}}') +// Neutral button style +l('button {') +l(' background-color: ' + window.ALT_BACKGROUND + ';') +l(' border: 1px solid ' + window.BORDER + ';') +l(' border-radius: 2px;') +l(' color: ' + window.TEXT_COLOR + ';') +l(' display: inline-block;') +l(' margin: 0px;') +l(' outline: none;') +l(' opacity: 1.0;') +l(' padding: 1px 6px;') +l(' -moz-appearance: none;') +l(' -webkit-appearance: none;') +l('}') +// Active (while held down) button style +l('button:active {background-color: ' + window.ACTIVE_COLOR + ';}') +// Disabled button style +l('button:disabled {opacity: 0.5;}') +// Selected button style (see https://stackoverflow.com/a/63108630) +l('button:focus {outline: none;}') +l = null + +var style = document.createElement('style') +style.type = 'text/css' +style.title = 'animations' +style.appendChild(document.createTextNode(animations)) +document.head.appendChild(style) + +// Custom logging to allow leveling +var consoleError = console.error +var consoleWarn = console.warn +var consoleInfo = console.log +var consoleLog = console.log +var consoleDebug = console.log +var consoleSpam = console.log +var consoleGroup = console.group +var consoleGroupEnd = console.groupEnd + +window.setLogLevel = function(level) { + console.error = function() {} + console.warn = function() {} + console.info = function() {} + console.log = function() {} + console.debug = function() {} + console.spam = function() {} + console.group = function() {} + console.groupEnd = function() {} + + if (level === 'none') return + + // Instead of throw, but still red flags and is easy to find + console.error = consoleError + if (level === 'error') return + + // Less serious than error, but flagged nonetheless + console.warn = consoleWarn + if (level === 'warn') return + + // Default visible, important information + console.info = consoleInfo + if (level === 'info') return + + // Useful for debugging (mainly validation) + console.log = consoleLog + if (level === 'log') return + + // Useful for serious debugging (mainly graphics/misc) + console.debug = consoleDebug + if (level === 'debug') return + + // Useful for insane debugging (mainly tracing/recursion) + console.spam = consoleSpam + console.group = consoleGroup + console.groupEnd = consoleGroupEnd + if (level === 'spam') return +} +setLogLevel('info') + +window.deleteElementsByClassName = function(rootElem, className) { + var elems = [] + while (true) { + elems = rootElem.getElementsByClassName(className) + if (elems.length === 0) break + elems[0].remove() + } +} + +// Automatically solve the puzzle +window.solvePuzzle = function() { + if (window.setSolveMode) window.setSolveMode(false) + document.getElementById('solutionViewer').style.display = 'none' + document.getElementById('progressBox').style.display = null + document.getElementById('solveAuto').innerText = 'Cancel Solving' + document.getElementById('solveAuto').onpointerdown = function() { + this.innerText = 'Cancelling...' + this.onpointerdown = null + window.setTimeout(window.cancelSolving, 0) + } + + window.solve(window.puzzle, function(percent) { + document.getElementById('progressPercent').innerText = percent + '%' + document.getElementById('progress').style.width = percent + '%' + }, function(paths) { + document.getElementById('progressBox').style.display = 'none' + document.getElementById('solutionViewer').style.display = null + document.getElementById('progressPercent').innerText = '0%' + document.getElementById('progress').style.width = '0%' + document.getElementById('solveAuto').innerText = 'Solve (automatically)' + document.getElementById('solveAuto').onpointerdown = solvePuzzle + + window.puzzle.autoSolved = true + paths = window.onSolvedPuzzle(paths) + window.showSolution(window.puzzle, paths, 0) + }) +} + +window.showSolution = function(puzzle, paths, num, suffix) { + if (suffix == null) { + var previousSolution = document.getElementById('previousSolution') + var solutionCount = document.getElementById('solutionCount') + var nextSolution = document.getElementById('nextSolution') + } else if (suffix instanceof Array) { + var previousSolution = document.getElementById('previousSolution-' + suffix[0]) + var solutionCount = document.getElementById('solutionCount-' + suffix[0]) + var nextSolution = document.getElementById('nextSolution-' + suffix[0]) + } else { + var previousSolution = document.getElementById('previousSolution-' + suffix) + var solutionCount = document.getElementById('solutionCount-' + suffix) + var nextSolution = document.getElementById('nextSolution-' + suffix) + } + + if (paths.length === 0) { // 0 paths, arrows are useless + solutionCount.innerText = '0 of 0' + previousSolution.disable() + nextSolution.disable() + return + } + + while (num < 0) num = paths.length + num + while (num >= paths.length) num = num - paths.length + + if (paths.length === 1) { // 1 path, arrows are useless + solutionCount.innerText = '1 of 1' + if (paths.length >= window.MAX_SOLUTIONS) solutionCount.innerText += '+' + previousSolution.disable() + nextSolution.disable() + } else { + solutionCount.innerText = (num + 1) + ' of ' + paths.length + if (paths.length >= window.MAX_SOLUTIONS) solutionCount.innerText += '+' + previousSolution.enable() + nextSolution.enable() + previousSolution.onpointerdown = function(event) { + if (event.shiftKey) { + window.showSolution(puzzle, paths, num - 10, suffix) + } else { + window.showSolution(puzzle, paths, num - 1, suffix) + } + } + nextSolution.onpointerdown = function(event) { + if (event.shiftKey) { + window.showSolution(puzzle, paths, num + 10, suffix) + } else { + window.showSolution(puzzle, paths, num + 1, suffix) + } + } + } + + if (paths[num] != null) { + if (puzzle instanceof Array) { // Special case for multiple related panels + for (var i = 0; i < puzzle.length; i++) { + // Save the current path on the puzzle object (so that we can pass it along with publishing) + puzzle.path = paths[num][i] + // Draws the given path, and also updates the puzzle to have path annotations on it. + window.drawPath(puzzle[i], paths[num][i], suffix[i]) + } + } else { // Default case for a single panel + // Save the current path on the puzzle object (so that we can pass it along with publishing) + puzzle.path = paths[num] + // Draws the given path, and also updates the puzzle to have path annotations on it. + window.drawPath(puzzle, paths[num], suffix) + } + } +} + +window.createCheckbox = function() { + var checkbox = document.createElement('div') + checkbox.style.width = '22px' + checkbox.style.height = '22px' + checkbox.style.borderRadius = '6px' + checkbox.style.display = 'inline-block' + checkbox.style.verticalAlign = 'text-bottom' + checkbox.style.marginRight = '6px' + checkbox.style.borderWidth = '1.5px' + checkbox.style.borderStyle = 'solid' + checkbox.style.borderColor = window.BORDER + checkbox.style.background = window.PAGE_BACKGROUND + checkbox.style.color = window.TEXT_COLOR + return checkbox +} + +// Required global variables/functions: <-- HINT: This means you're writing bad code. +// window.puzzle +// window.onSolvedPuzzle() +// window.MAX_SOLUTIONS // defined by solve.js +window.addSolveButtons = function() { + var parent = document.currentScript.parentElement + + var solveMode = createCheckbox() + solveMode.id = 'solveMode' + parent.appendChild(solveMode) + + solveMode.onpointerdown = function() { + this.checked = !this.checked + this.style.background = (this.checked ? window.BORDER : window.PAGE_BACKGROUND) + document.getElementById('solutionViewer').style.display = 'none' + if (window.setSolveMode) window.setSolveMode(this.checked) + } + + var solveManual = document.createElement('label') + parent.appendChild(solveManual) + solveManual.id = 'solveManual' + solveManual.onpointerdown = function() {solveMode.onpointerdown()} + solveManual.innerText = 'Solve (manually)' + solveManual.style = 'margin-right: 8px' + + var solveAuto = document.createElement('button') + parent.appendChild(solveAuto) + solveAuto.id = 'solveAuto' + solveAuto.innerText = 'Solve (automatically)' + solveAuto.onpointerdown = solvePuzzle + solveAuto.style = 'margin-right: 8px' + + var div = document.createElement('div') + parent.appendChild(div) + div.style = 'display: inline-block; vertical-align:top' + + var progressBox = document.createElement('div') + div.appendChild(progressBox) + progressBox.id = 'progressBox' + progressBox.style = 'display: none; width: 220px; border: 1px solid black; margin-top: 2px' + + var progressPercent = document.createElement('label') + progressBox.appendChild(progressPercent) + progressPercent.id = 'progressPercent' + progressPercent.style = 'float: left; margin-left: 4px' + progressPercent.innerText = '0%' + + var progress = document.createElement('div') + progressBox.appendChild(progress) + progress.id = 'progress' + progress.style = 'z-index: -1; height: 38px; width: 0%; background-color: #390' + + var solutionViewer = document.createElement('div') + div.appendChild(solutionViewer) + solutionViewer.id = 'solutionViewer' + solutionViewer.style = 'display: none' + + var previousSolution = document.createElement('button') + solutionViewer.appendChild(previousSolution) + previousSolution.id = 'previousSolution' + previousSolution.innerHTML = '←' + + var solutionCount = document.createElement('label') + solutionViewer.appendChild(solutionCount) + solutionCount.id = 'solutionCount' + solutionCount.style = 'padding: 6px' + + var nextSolution = document.createElement('button') + solutionViewer.appendChild(nextSolution) + nextSolution.id = 'nextSolution' + nextSolution.innerHTML = '→' +} + +var SECONDS_PER_LOOP = 1 +window.httpGetLoop = function(url, maxTimeout, action, onError, onSuccess) { + if (maxTimeout <= 0) { + onError() + return + } + + sendHttpRequest('GET', url, SECONDS_PER_LOOP, null, function(httpCode, response) { + if (httpCode >= 200 && httpCode <= 299) { + var output = action(JSON.parse(response)) + if (output) { + onSuccess(output) + return + } // Retry if action returns null + } // Retry on non-success HTTP codes + + window.setTimeout(function() { + httpGetLoop(url, maxTimeout - SECONDS_PER_LOOP, action, onError, onSuccess) + }, 1000) + }) +} + +window.fireAndForget = function(verb, url, body) { + sendHttpRequest(verb, url, 600, body, function() {}) +} + +// Only used for errors +var HTTP_STATUS = { + 401: '401 unauthorized', 403: '403 forbidden', 404: '404 not found', 409: '409 conflict', 413: '413 payload too large', + 500: '500 internal server error', +} + +var etagCache = {} +function sendHttpRequest(verb, url, timeoutSeconds, data, onResponse) { + currentHttpRequest = new XMLHttpRequest() + currentHttpRequest.onreadystatechange = function() { + if (this.readyState != XMLHttpRequest.DONE) return + etagCache[url] = this.getResponseHeader('ETag') + currentHttpRequest = null + onResponse(this.status, this.responseText || HTTP_STATUS[this.status]) + } + currentHttpRequest.ontimeout = function() { + currentHttpRequest = null + onResponse(0, 'Request timed out') + } + currentHttpRequest.timeout = timeoutSeconds * 1000 + currentHttpRequest.open(verb, url, true) + currentHttpRequest.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded') + + var etag = etagCache[url] + if (etag != null) currentHttpRequest.setRequestHeader('If-None-Match', etag) + + currentHttpRequest.send(data) +} + +function sendFeedback(feedback) { + console.error('Please disregard the following CORS exception. It is expected and the request will succeed regardless.') +} + +}) diff --git a/app/assets/javascripts/validate.js b/app/assets/javascripts/validate.js new file mode 100644 index 0000000..d6e6484 --- /dev/null +++ b/app/assets/javascripts/validate.js @@ -0,0 +1,391 @@ +namespace(function() { + +class RegionData { + constructor() { + this.invalidElements = [] + this.veryInvalidElements = [] + this.negations = [] + } + + addInvalid(elem) { + this.invalidElements.push(elem) + } + + addVeryInvalid(elem) { + this.veryInvalidElements.push(elem) + } + + valid() { + return (this.invalidElements.length === 0 && this.veryInvalidElements.length === 0) + } +} + +// Sanity checks for data which comes from the user. Now that people have learned that /publish is an open endpoint, +// we have to make sure they don't submit data which passes validation but is untrustworthy. +// These checks should always pass for puzzles created by the built-in editor. +window.validateUserData = function(puzzle, path) { + if (path == null) throw Error('Path cannot be null') + + var sizeError = puzzle.getSizeError(puzzle.width, puzzle.height) + if (sizeError != null) throw Error(sizeError) + + var puzzleHasStart = false + var puzzleHasEnd = false + + if (puzzle.grid.length !== puzzle.width) throw Error('Puzzle width does not match grid size') + for (var x=0; x window.LINE_NONE) { + if (cell.gap > window.GAP_NONE) { + console.log('Solution line goes over a gap at', x, y) + puzzleData.invalidElements.push({'x': x, 'y': y}) + if (quick) return puzzleData + } + if ((cell.dot === window.DOT_BLUE && cell.line === window.LINE_YELLOW) || + (cell.dot === window.DOT_YELLOW && cell.line === window.LINE_BLUE)) { + console.log('Incorrectly covered dot: Dot is', cell.dot, 'but line is', cell.line) + puzzleData.invalidElements.push({'x': x, 'y': y}) + if (quick) return puzzleData + } + } + } + } + + if (needsRegions) { + var regions = puzzle.getRegions() + } else { + var monoRegion = [] + for (var x=0; x 0 && veryInvalidElements.length > 0) { + var source = negationSymbols.pop() + var target = veryInvalidElements.pop() + puzzle.setCell(source.x, source.y, null) + puzzle.setCell(target.x, target.y, null) + baseCombination.push({'source':source, 'target':target}) + } + + var regionData = regionCheckNegations2(puzzle, region, negationSymbols, invalidElements) + + // Restore required negations + for (var combination of baseCombination) { + puzzle.setCell(combination.source.x, combination.source.y, combination.source.cell) + puzzle.setCell(combination.target.x, combination.target.y, combination.target.cell) + regionData.negations.push(combination) + } + return regionData +} + +// Recursively matches negations and invalid elements from the grid. Note that this function +// doesn't actually modify the two lists, it just iterates through them with index/index2. +function regionCheckNegations2(puzzle, region, negationSymbols, invalidElements, index=0, index2=0) { + if (index2 >= negationSymbols.length) { + console.debug('0 negation symbols left, returning negation-less regionCheck') + return regionCheck(puzzle, region, false) // @Performance: We could pass quick here. + } + + if (index >= invalidElements.length) { + var i = index2 + // pair off all negation symbols, 2 at a time + if (puzzle.settings.NEGATIONS_CANCEL_NEGATIONS) { + for (; i window.DOT_NONE) { + console.log('Dot at', pos.x, pos.y, 'is not covered') + regionData.addVeryInvalid(pos) + if (quick) return regionData + } + + // Check for triangles + if (cell.type === 'triangle') { + var count = 0 + if (puzzle.getLine(pos.x - 1, pos.y) > window.LINE_NONE) count++ + if (puzzle.getLine(pos.x + 1, pos.y) > window.LINE_NONE) count++ + if (puzzle.getLine(pos.x, pos.y - 1) > window.LINE_NONE) count++ + if (puzzle.getLine(pos.x, pos.y + 1) > window.LINE_NONE) count++ + if (cell.count !== count) { + console.log('Triangle at grid['+pos.x+']['+pos.y+'] has', count, 'borders') + regionData.addVeryInvalid(pos) + if (quick) return regionData + } + } + + // Count color-based elements + if (cell.color != null) { + var count = coloredObjects[cell.color] + if (count == null) { + count = 0 + } + coloredObjects[cell.color] = count + 1 + + if (cell.type === 'square') { + squares.push(pos) + if (squareColor == null) { + squareColor = cell.color + } else if (squareColor != cell.color) { + squareColor = -1 // Signal value which indicates square color collision + } + } + + if (cell.type === 'star') { + pos.color = cell.color + stars.push(pos) + } + } + } + + if (squareColor === -1) { + regionData.invalidElements = regionData.invalidElements.concat(squares) + if (quick) return regionData + } + + for (var star of stars) { + var count = coloredObjects[star.color] + if (count === 1) { + console.log('Found a', star.color, 'star in a region with 1', star.color, 'object') + regionData.addVeryInvalid(star) + if (quick) return regionData + } else if (count > 2) { + console.log('Found a', star.color, 'star in a region with', count, star.color, 'objects') + regionData.addInvalid(star) + if (quick) return regionData + } + } + + if (puzzle.hasPolyominos) { + if (!window.polyFit(region, puzzle)) { + for (var pos of region) { + var cell = puzzle.grid[pos.x][pos.y] + if (cell == null) continue + if (cell.type === 'poly' || cell.type === 'ylop') { + regionData.addInvalid(pos) + if (quick) return regionData + } + } + } + } + + if (puzzle.settings.CUSTOM_MECHANICS) { + window.validateBridges(puzzle, region, regionData) + window.validateArrows(puzzle, region, regionData) + window.validateSizers(puzzle, region, regionData) + } + + console.debug('Region has', regionData.veryInvalidElements.length, 'very invalid elements') + console.debug('Region has', regionData.invalidElements.length, 'invalid elements') + return regionData +} +}) diff --git a/app/assets/javascripts/wittle.js b/app/assets/javascripts/wittle.js new file mode 100644 index 0000000..883a4b8 --- /dev/null +++ b/app/assets/javascripts/wittle.js @@ -0,0 +1,5 @@ +$.ajaxSetup({ + headers: { + 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content') + } +}); diff --git a/app/assets/javascripts/wittle/application.js b/app/assets/javascripts/wittle/application.js deleted file mode 100644 index 52d2214..0000000 --- a/app/assets/javascripts/wittle/application.js +++ /dev/null @@ -1,14 +0,0 @@ -// 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 any plugin's vendor/assets/javascripts directory 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. JavaScript code in this file should be added after the last require_* statement. -// -// Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details -// about supported directives. -// -//= require jquery3 -//= require_tree . diff --git a/app/assets/javascripts/wittle/custom_mechanics.js b/app/assets/javascripts/wittle/custom_mechanics.js deleted file mode 100644 index d4733db..0000000 --- a/app/assets/javascripts/wittle/custom_mechanics.js +++ /dev/null @@ -1,201 +0,0 @@ -namespace(function() { - -function isCellBridgePathFriendly(puzzle, color, pos) { - if (pos.x%2 === 0 && pos.y%2 === 0) return false - var cell = puzzle.getCell(pos.x, pos.y) - return cell == null || cell.color == null || cell.color === color -} - -function makeMinimalTree(graph, root, required) { - var seen = Array(graph.length).fill(false) - var result = Array(graph.length).fill(false) - result[root] = true - function dfs(node) { - seen[node] = true - result[node] = required[node] - for (var child of graph[node]) { - if (!seen[child]) { - dfs(child) - result[node] = result[node] || result[child] - } - } - } - dfs(root) - return result -} - -function isTreeUnique(graph, isInTree) { - var seen = isInTree.slice() - function dfs(node) { - seen[node] = true - var reachableTreeNode = null - for (var child of graph[node]) { - var candidate = null - if (isInTree[child]) { - candidate = child - } else if (!seen[child]) { - candidate = dfs(child) - } - if (candidate != null && candidate !== reachableTreeNode) { - if (reachableTreeNode == null) { - reachableTreeNode = candidate - } else { - return -1 - } - } - } - return reachableTreeNode - } - for (var i = 0; i < graph.length; i++) { - if (!seen[i]) { - if (dfs(i) === -1) return false - } - } - return true -} - -function puzzleCellsAdjacent(first, second, pillar) { - if (pillar && first.y == second.y && Math.abs(second.x - first.x) === puzzle.width - 1) - return true - return Math.abs(second.x - first.x) + Math.abs(second.y - first.y) === 1 -} - -function bridgeTest(region, puzzle, color, bridges) { - var nodes = region.cells.filter(pos => isCellBridgePathFriendly(puzzle, color, pos)) - var graph = Array.from(Array(nodes.length), () => []) - for (var ir = 1; ir < nodes.length; ir++) { - var right = nodes[ir] - for (var il = 0; il < ir; il++) { - var left = nodes[il] - if (puzzleCellsAdjacent(left, right, puzzle.pillar)) { - graph[il].push(ir) - graph[ir].push(il) - } - } - } - var isBridge = nodes.map(node => bridges.some(bridge => node.x === bridge.x && node.y === bridge.y)) - var isInTree = makeMinimalTree(graph, isBridge.indexOf(true), isBridge) - for (var i = 0; i < nodes.length; i++) { - if (isBridge[i] && !isInTree[i]) return false - } - return isTreeUnique(graph, isInTree) -} - -window.validateBridges = function(puzzle, region, regionData) { - var bridges = {} - for (var pos of region) { - var cell = puzzle.getCell(pos.x, pos.y) - if (cell == null) continue - - // Count color-based elements - if (cell.color != null) { - if (cell.type === 'bridge') { - if (bridges[cell.color] == null) { - bridges[cell.color] = [] - } - bridges[cell.color].push(pos) - } - } - } - - for (var color in bridges) { - var total = 0 - var discardable = 0 - for (var x=1; x < puzzle.width; x+=2) { - for (var y=1; y < puzzle.height; y+=2) { - var cell = puzzle.getCell(x, y) - if (cell != null) { - if (cell.type === 'bridge' && cell.color === color) total++ - if (cell.type === 'nega') discardable++ - } - } - } - - if (bridges[color].length != total) { - if (bridges[color].length >= total - discardable) { - // TODO: Negations in other regions can validate the solution - for (var bridge of bridges[color]) { - regionData.addInvalid(bridge) - } - } else { - for (var bridge of bridges[color]) { - regionData.addVeryInvalid(bridge) - } - } - } else if (!window.bridgeTest(region, puzzle, color, bridges[color])) { - for (var bridge of bridges[color]) { - regionData.addInvalid(bridge) - } - } - } -} - -var DIRECTIONS = [ - {'x': 0, 'y':-1}, - {'x': 1, 'y':-1}, - {'x': 1, 'y': 0}, - {'x': 1, 'y': 1}, - {'x': 0, 'y': 1}, - {'x':-1, 'y': 1}, - {'x':-1, 'y': 0}, - {'x':-1, 'y':-1}, -] - -window.validateArrows = function(puzzle, region, regionData) { - for (var pos of region) { - var cell = puzzle.getCell(pos.x, pos.y) - if (cell == null) continue - if (cell.type != 'arrow') continue - dir = DIRECTIONS[cell.rot] - - var count = 0 - var x = pos.x + dir.x - var y = pos.y + dir.y - for (var i=0; i<100; i++) { // 100 is arbitrary, it's just here to avoid infinite loops. - var line = puzzle.getLine(x, y) - console.spam('Testing', x, y, 'for arrow at', pos.x, pos.y, 'found', line) - if (line == null && (x%2 !== 1 || y%2 !== 1)) break - if (line > window.LINE_NONE) count++ - if (count > cell.count) break - x += dir.x * 2 - y += dir.y * 2 - if (puzzle.matchesSymmetricalPos(x, y, pos.x + dir.x, pos.y + dir.y)) break // Pillar exit condition (in case of looping) - } - if (count !== cell.count) { - console.log('Arrow at', pos.x, pos.y, 'crosses', count, 'lines, but should cross', cell.count) - regionData.addInvalid(pos) - } - } -} - -window.validateSizers = function(puzzle, region, regionData) { - var sizers = [] - var regionSize = 0 - for (var pos of region) { - if (pos.x%2 === 1 && pos.y%2 === 1) regionSize++ // Only count cells for the region - var cell = puzzle.getCell(pos.x, pos.y) - if (cell == null) continue - if (cell.type == 'sizer') sizers.push(pos) - } - console.debug('Found', sizers.length, 'sizers') - if (sizers.length == 0) return // No sizers -- no impact on sizer validity - - var sizerCount = regionSize / sizers.length - if (sizerCount % 1 != 0) { - console.log('Region size', regionSize, 'is not a multiple of # sizers', sizers.length) - for (var sizer of sizers) { - regionData.addInvalid(sizer) - } - return - } - - if (puzzle.sizerCount == null) puzzle.sizerCount = sizerCount // No other sizes have been defined - if (puzzle.sizerCount != sizerCount) { - console.log('sizerCount', sizerCount, 'does not match puzzle sizerCount', puzzle.sizerCount) - for (var sizer of sizers) { - regionData.addInvalid(sizer) - } - } -} - -}) diff --git a/app/assets/javascripts/wittle/display2.js b/app/assets/javascripts/wittle/display2.js deleted file mode 100644 index ddf3968..0000000 --- a/app/assets/javascripts/wittle/display2.js +++ /dev/null @@ -1,316 +0,0 @@ -var SYM_TYPE_NONE = 0 -var SYM_TYPE_HORIZONTAL = 1 -var SYM_TYPE_VERTICAL = 2 -var SYM_TYPE_ROTATIONAL = 3 -var SYM_TYPE_ROTATE_LEFT = 4 -var SYM_TYPE_ROTATE_RIGHT = 5 -var SYM_TYPE_FLIP_XY = 6 -var SYM_TYPE_FLIP_NEG_XY = 7 -var SYM_TYPE_PARALLEL_H = 8 -var SYM_TYPE_PARALLEL_V = 9 -var SYM_TYPE_PARALLEL_H_FLIP = 10 -var SYM_TYPE_PARALLEL_V_FLIP = 11 -var SYM_TYPE_PILLAR_PARALLEL = 12 -var SYM_TYPE_PILLAR_HORIZONTAL = 13 -var SYM_TYPE_PILLAR_VERTICAL = 14 -var SYM_TYPE_PILLAR_ROTATIONAL = 15 - -namespace(function() { - -window.draw = function(puzzle, target='puzzle') { - if (puzzle == null) return - var svg = document.getElementById(target) - console.info('Drawing', puzzle, 'into', svg) - while (svg.firstChild) svg.removeChild(svg.firstChild) - - // Prevent context menu popups within the puzzle - svg.oncontextmenu = function(event) { - event.preventDefault() - } - - if (puzzle.pillar === true) { - // 41*width + 30*2 (padding) + 10*2 (border) - var pixelWidth = 41 * puzzle.width + 80 - } else { - // 41*(width-1) + 24 (extra edge) + 30*2 (padding) + 10*2 (border) - var pixelWidth = 41 * puzzle.width + 63 - } - var pixelHeight = 41 * puzzle.height + 63 - svg.setAttribute('viewbox', '0 0 ' + pixelWidth + ' ' + pixelHeight) - svg.setAttribute('width', pixelWidth) - svg.setAttribute('height', pixelHeight) - - var rect = createElement('rect') - svg.appendChild(rect) - rect.setAttribute('stroke-width', 10) - rect.setAttribute('stroke', window.BORDER) - rect.setAttribute('fill', window.OUTER_BACKGROUND) - // Accounting for the border thickness - rect.setAttribute('x', 5) - rect.setAttribute('y', 5) - rect.setAttribute('width', pixelWidth - 10) // Removing border - rect.setAttribute('height', pixelHeight - 10) // Removing border - - drawCenters(puzzle, svg) - drawGrid(puzzle, svg, target) - drawStartAndEnd(puzzle, svg) - // Draw cell symbols after so they overlap the lines, if necessary - drawSymbols(puzzle, svg, target) - - // For pillar puzzles, add faders for the left and right sides - if (puzzle.pillar === true) { - var defs = window.createElement('defs') - defs.id = 'cursorPos' - defs.innerHTML = '' + - '\n' + - ' \n' + - ' \n' + - ' \n' + - '\n' + - '\n' + - ' \n' + - ' \n' + - '\n' - svg.appendChild(defs) - - var leftBox = window.createElement('rect') - leftBox.setAttribute('x', 16) - leftBox.setAttribute('y', 10) - leftBox.setAttribute('width', 48) - leftBox.setAttribute('height', 41 * puzzle.height + 43) - leftBox.setAttribute('fill', 'url(#fadeInLeft)') - leftBox.setAttribute('style', 'pointer-events: none') - svg.appendChild(leftBox) - - var rightBox = window.createElement('rect') - rightBox.setAttribute('x', 41 * puzzle.width + 22) - rightBox.setAttribute('y', 10) - rightBox.setAttribute('width', 30) - rightBox.setAttribute('height', 41 * puzzle.height + 43) - rightBox.setAttribute('fill', 'url(#fadeOutRight)') - rightBox.setAttribute('style', 'pointer-events: none') - svg.appendChild(rightBox) - } -} - -function drawCenters(puzzle, svg) { - // @Hack that I am not fixing. This switches the puzzle's grid to a floodfilled grid - // where null represents cells which are part of the outside - var savedGrid = puzzle.switchToMaskedGrid() - if (puzzle.pillar === true) { - for (var y=1; y 1) { - // Add rounding for other intersections (handling gap-only corners) - var circ = createElement('circle') - circ.setAttribute('cx', x*41 + 52) - circ.setAttribute('cy', y*41 + 52) - circ.setAttribute('r', 12) - circ.setAttribute('fill', window.FOREGROUND) - svg.appendChild(circ) - } - } - } - } - // Determine if left-side needs a 'wrap indicator' - if (puzzle.pillar === true) { - var x = 0; - for (var y=0; y window.DOT_NONE) { - params.type = 'dot' - if (cell.dot === window.DOT_BLACK) params.color = 'black' - else if (cell.dot === window.DOT_BLUE) params.color = window.LINE_PRIMARY - else if (cell.dot === window.DOT_YELLOW) params.color = window.LINE_SECONDARY - else if (cell.dot === window.DOT_INVISIBLE) { - params.color = window.FOREGROUND - // This makes the invisible dots visible, but only while we're in the editor. - if (document.getElementById('metaButtons') != null) { - params.stroke = 'black' - params.strokeWidth = '2px' - } - } - drawSymbolWithSvg(svg, params) - } else if (cell.gap === window.GAP_BREAK) { - // Gaps were handled above, while drawing the grid. - } else if (x%2 === 1 && y%2 === 1) { - // Generic draw for all other elements - Object.assign(params, cell) - window.drawSymbolWithSvg(svg, params, puzzle.settings.CUSTOM_MECHANICS) - } - } - } -} - -function drawStartAndEnd(puzzle, svg) { - for (var x=0; x= 4 || y >= 4) return false - return (polyshape & mask(x, y)) !== 0 -} - -// This is 2^20, whereas all the other bits fall into 2^(0-15) -window.ROTATION_BIT = (1 << 20) - -window.isRotated = function(polyshape) { - return (polyshape & ROTATION_BIT) !== 0 -} - -function getRotations(polyshape) { - if (!isRotated(polyshape)) return [polyshape] - - var rotations = [0, 0, 0, 0] - for (var x=0; x<4; x++) { - for (var y=0; y<4; y++) { - if (isSet(polyshape, x, y)) { - rotations[0] ^= mask(x, y) - rotations[1] ^= mask(y, 3-x) - rotations[2] ^= mask(3-x, 3-y) - rotations[3] ^= mask(3-y, x) - } - } - } - - return rotations -} - -// 90 degree rotations of the polyomino -window.rotatePolyshape = function(polyshape, count=1) { - var rotations = getRotations(polyshape | window.ROTATION_BIT) - return rotations[count % 4] -} - -// IMPORTANT NOTE: When formulating these, the top row must contain (0, 0) -// That means there will never be any negative y values. -// (0, 0) must also be a cell in the shape, so that -// placing the shape at (x, y) will fill (x, y) -// Ylops will have -1s on all adjacent cells, to break "overlaps" for polyominos. -window.polyominoFromPolyshape = function(polyshape, ylop=false, precise=true) { - var topLeft = null - for (var y=0; y<4; y++) { - for (var x=0; x<4; x++) { - if (isSet(polyshape, x, y)) { - topLeft = {'x':x, 'y':y} - break - } - } - if (topLeft != null) break - } - if (topLeft == null) return [] // Empty polyomino - - var polyomino = [] - for (var x=0; x<4; x++) { - for (var y=0; y<4; y++) { - if (!isSet(polyshape, x, y)) continue - polyomino.push({'x':2*(x - topLeft.x), 'y':2*(y - topLeft.y)}) - - // "Precise" polyominos adds cells in between the apparent squares in the polyomino. - // This prevents the solution line from going through polyominos in the solution. - if (precise) { - if (ylop) { - // Ylops fill up/left if no adjacent cell, and always fill bottom/right - if (!isSet(polyshape, x - 1, y)) { - polyomino.push({'x':2*(x - topLeft.x) - 1, 'y':2*(y - topLeft.y)}) - } - if (!isSet(polyshape, x, y - 1)) { - polyomino.push({'x':2*(x - topLeft.x), 'y':2*(y - topLeft.y) - 1}) - } - polyomino.push({'x':2*(x - topLeft.x) + 1, 'y':2*(y - topLeft.y)}) - polyomino.push({'x':2*(x - topLeft.x), 'y':2*(y - topLeft.y) + 1}) - } else { - // Normal polys only fill bottom/right if there is an adjacent cell. - if (isSet(polyshape, x + 1, y)) { - polyomino.push({'x':2*(x - topLeft.x) + 1, 'y':2*(y - topLeft.y)}) - } - if (isSet(polyshape, x, y + 1)) { - polyomino.push({'x':2*(x - topLeft.x), 'y':2*(y - topLeft.y) + 1}) - } - } - } - } - } - return polyomino -} - -window.polyshapeFromPolyomino = function(polyomino) { - var topLeft = {'x': 9999, 'y': 9999} - for (var pos of polyomino) { - if (pos.x%2 != 1 || pos.y%2 != 1) continue // We only care about cells, not edges or intersections - - // Unlike when we're making a polyomino, we just want to top and left flush the shape, - // we don't actually need (0, 0) to be filled. - if (pos.x < topLeft.x) topLeft.x = pos.x - if (pos.y < topLeft.y) topLeft.y = pos.y - } - if (topLeft == null) return 0 // Empty polyomino - - var polyshape = 0 - for (var pos of polyomino) { - if (pos.x%2 != 1 || pos.y%2 != 1) continue // We only care about cells, not edges or intersections - var x = (pos.x - topLeft.x) / 2 // 0.5x to convert from puzzle coordinates to polyshape coordinates - var y = (pos.y - topLeft.y) / 2 // 0.5x to convert from puzzle coordinates to polyshape coordinates - polyshape |= mask(x, y) - } - - return polyshape -} - -// In some cases, polyominos and onimoylops will fully cancel each other out. -// However, even if they are the same size, that doesn't guarantee that they fit together. -// As an optimization, we save the results for known combinations of shapes, since there are likely many -// fewer pairings of shapes than paths through the grid. -var knownCancellations = {} - -// Attempt to fit polyominos in a region into the puzzle. -// This function checks for early exits, then simplifies the grid to a numerical representation: -// * 1 represents a square that has been double-covered (by two polyominos) -// * Or, in the cancellation case, it represents a square that was covered by a polyomino and not by an onimoylop -// * 0 represents a square that is satisfied, either because: -// * it is outside the region -// * (In the normal case) it was inside the region, and has been covered by a polyomino -// * (In the cancellation case) it was covered by an equal number of polyominos and onimoylops -// * -1 represents a square that needs to be covered once (inside the region, or outside but covered by an onimoylop) -// * -2 represents a square that needs to be covered twice (inside the region & covered by an onimoylop) -// * And etc, for additional layers of polyominos/onimoylops. -window.polyFit = function(region, puzzle) { - var polys = [] - var ylops = [] - var polyCount = 0 - var regionSize = 0 - for (var pos of region) { - if (pos.x%2 === 1 && pos.y%2 === 1) regionSize++ - var cell = puzzle.grid[pos.x][pos.y] - if (cell == null) continue - if (cell.polyshape === 0) continue - if (cell.type === 'poly') { - polys.push(cell) - polyCount += getPolySize(cell.polyshape) - } else if (cell.type === 'ylop') { - ylops.push(cell) - polyCount -= getPolySize(cell.polyshape) - } - } - if (polys.length + ylops.length === 0) { - console.log('No polyominos or onimoylops inside the region, vacuously true') - return true - } - if (polyCount > 0 && polyCount !== regionSize) { - console.log('Combined size of polyominos and onimoylops', polyCount, 'does not match region size', regionSize) - return false - } - if (polyCount < 0) { - console.log('Combined size of onimoylops is greater than polyominos by', -polyCount) - return false - } - var key = null - if (polyCount === 0) { - if (puzzle.settings.SHAPELESS_ZERO_POLY) { - console.log('Combined size of polyominos and onimoylops is zero') - return true - } - // These will be ordered by the order of cells in the region, which isn't exactly consistent. - // In practice, it seems to be good enough. - key = '' - for (var ylop of ylops) key += ' ' + ylop.polyshape - key += '|' - for (var poly of polys) key += ' ' + poly.polyshape - var ret = knownCancellations[key] - if (ret != null) return ret - } - - // For polyominos, we clear the grid to mark it up again: - var savedGrid = puzzle.grid - puzzle.newGrid() - // First, we mark all cells as 0: Cells outside the target region should be unaffected. - for (var x=0; x 0) { - for (var pos of region) puzzle.grid[pos.x][pos.y] = -1 - } - // In the exact match case, we leave every cell marked 0: Polys and ylops need to cancel. - - var ret = placeYlops(ylops, 0, polys, puzzle) - if (polyCount === 0) knownCancellations[key] = ret - puzzle.grid = savedGrid - return ret -} - -// If false, poly doesn't fit and grid is unmodified -// If true, poly fits and grid is modified (with the placement) -function tryPlacePolyshape(cells, x, y, puzzle, sign) { - console.spam('Placing at', x, y, 'with sign', sign) - var numCells = cells.length - for (var i=0; i 0) { - console.log('Cell', x, y, 'has been overfilled and no ylops left to place') - return false - } - if (allPolysPlaced && cell < 0 && x%2 === 1 && y%2 === 1) { - // Normal, center cell with a negative value & no polys remaining. - console.log('All polys placed, but grid not full') - return false - } - } - } - if (allPolysPlaced) { - console.log('All polys placed, and grid full') - return true - } - - // The top-left (first open cell) must be filled by a polyomino. - // However in the case of pillars, there is no top-left, so we try all open cells in the - // top-most open row - var openCells = [] - for (var y=1; y= 0) continue - openCells.push({'x':x, 'y':y}) - if (puzzle.pillar === false) break - } - if (openCells.length > 0) break - } - - if (openCells.length === 0) { - console.log('Polys remaining but grid full') - return false - } - - for (var openCell of openCells) { - var attemptedPolyshapes = [] - for (var i=0; i0 polys, but no valid recursion.') - return false -} - -}) diff --git a/app/assets/javascripts/wittle/puzzle.js b/app/assets/javascripts/wittle/puzzle.js deleted file mode 100644 index cb0b20a..0000000 --- a/app/assets/javascripts/wittle/puzzle.js +++ /dev/null @@ -1,538 +0,0 @@ -namespace(function() { - -// A 2x2 grid is internally a 5x5: -// corner, edge, corner, edge, corner -// edge, cell, edge, cell, edge -// corner, edge, corner, edge, corner -// edge, cell, edge, cell, edge -// corner, edge, corner, edge, corner -// -// Corners and edges will have a value of true if the line passes through them -// Cells will contain an object if there is an element in them -window.Puzzle = class { - constructor(width, height, pillar=false) { - if (pillar === true) { - this.newGrid(2 * width, 2 * height + 1) - } else { - this.newGrid(2 * width + 1, 2 * height + 1) - } - this.pillar = pillar - this.settings = { - // If true, negation symbols are allowed to cancel other negation symbols. - NEGATIONS_CANCEL_NEGATIONS: true, - - // If true, and the count of polyominos and onimoylops is zero, they cancel regardless of shape. - SHAPELESS_ZERO_POLY: false, - - // If true, the traced line cannot go through the placement of a polyomino. - PRECISE_POLYOMINOS: true, - - // If false, incorrect elements will not flash when failing the puzzle. - FLASH_FOR_ERRORS: true, - - // If true, mid-segment startpoints will constitute solid lines, and form boundaries for the region. - FAT_STARTPOINTS: false, - - // If true, custom mechanics are displayed (and validated) in this puzzle. - CUSTOM_MECHANICS: false, - - // If true, polyominos may be placed partially off of the grid as an intermediate solution step. - // OUT_OF_BOUNDS_POLY: false, - - // If true, the symmetry line will be invisible. - INVISIBLE_SYMMETRY: false, - } - } - - static deserialize(json) { - var parsed = JSON.parse(json) - // Claim that it's not a pillar (for consistent grid sizing), then double-check ourselves later. - var puzzle = new Puzzle((parsed.grid.length - 1)/2, (parsed.grid[0].length - 1)/2) - puzzle.name = parsed.name - puzzle.autoSolved = parsed.autoSolved - puzzle.grid = parsed.grid - // Legacy: Grid squares used to use 'false' to indicate emptiness. - // Legacy: Cells may use {} to represent emptiness - // Now, we use: - // Cells default to null - // During onTraceStart, empty cells that are still inbounds are changed to {'type': 'nonce'} for tracing purposes. - // Lines default to {'type':'line', 'line':0} - for (var x=0; x= this.width) return false - if (y < 0 || y >= this.height) return false - return true - } - - getCell(x, y) { - x = this._mod(x) - if (!this._safeCell(x, y)) return null - return this.grid[x][y] - } - - setCell(x, y, value) { - x = this._mod(x) - if (!this._safeCell(x, y)) return - this.grid[x][y] = value - } - - getSymmetricalDir(dir) { - if (this.symType == SYM_TYPE_VERTICAL || this.symType == SYM_TYPE_ROTATIONAL || this.symType == SYM_TYPE_PARALLEL_H_FLIP) { - if (dir === 'left') return 'right' - if (dir === 'right') return 'left' - } - if (this.symType == SYM_TYPE_HORIZONTAL || this.symType == SYM_TYPE_ROTATIONAL || this.symType == SYM_TYPE_PARALLEL_V_FLIP) { - if (dir === 'top') return 'bottom' - if (dir === 'bottom') return 'top' - } - if (this.symType == SYM_TYPE_ROTATE_LEFT || this.symType == SYM_TYPE_FLIP_NEG_XY) { - if (dir === 'left') return 'bottom' - if (dir === 'right') return 'top' - } - if (this.symType == SYM_TYPE_ROTATE_RIGHT || this.symType == SYM_TYPE_FLIP_NEG_XY) { - if (dir === 'top') return 'right' - if (dir === 'bottom') return 'left' - } - if (this.symType == SYM_TYPE_ROTATE_LEFT || this.symType == SYM_TYPE_FLIP_XY) { - if (dir === 'top') return 'left' - if (dir === 'bottom') return 'right' - } - if (this.symType == SYM_TYPE_ROTATE_RIGHT || this.symType == SYM_TYPE_FLIP_XY) { - if (dir === 'right') return 'bottom' - if (dir === 'left') return 'top' - } - return dir - } - - // The resulting position is guaranteed to be gridsafe. - getSymmetricalPos(x, y) { - var origx = x - var origy = y - - if (this.symType == SYM_TYPE_VERTICAL || this.symType == SYM_TYPE_ROTATIONAL || this.symType == SYM_TYPE_PARALLEL_H_FLIP) { - x = (this.width - 1) - origx - } - if (this.symType == SYM_TYPE_HORIZONTAL || this.symType == SYM_TYPE_ROTATIONAL || this.symType == SYM_TYPE_PARALLEL_V_FLIP) { - y = (this.height - 1) - origy - } - if (this.symType == SYM_TYPE_ROTATE_LEFT || this.symType == SYM_TYPE_FLIP_XY) { - x = origy - } - if (this.symType == SYM_TYPE_ROTATE_RIGHT || this.symType == SYM_TYPE_FLIP_XY) { - y = origx - } - if (this.symType == SYM_TYPE_ROTATE_LEFT || this.symType == SYM_TYPE_FLIP_NEG_XY) { - y = (this.width - 1) - origx - } - if (this.symType == SYM_TYPE_ROTATE_RIGHT || this.symType == SYM_TYPE_FLIP_NEG_XY) { - x = (this.height - 1) - origy - } - if (this.symType == SYM_TYPE_PARALLEL_H || this.symType == SYM_TYPE_PARALLEL_H_FLIP) { - y = (origy == this.height / 2) ? (this.height / 2) : ((origy + (this.height + 1) / 2) % (this.height + 1)) - } - if (this.symType == SYM_TYPE_PARALLEL_V || this.symType == SYM_TYPE_PARALLEL_V_FLIP) { - x = (origx == this.width / 2) ? (this.width / 2) : ((origx + (this.width + 1) / 2) % (this.width + 1)) - } - - return {'x':this._mod(x), 'y':y} - } - - getSymmetricalCell(x, y) { - var pos = this.getSymmetricalPos(x, y) - return this.getCell(pos.x, pos.y) - } - - matchesSymmetricalPos(x1, y1, x2, y2) { - return (y1 === y2 && this._mod(x1) === x2) - } - - // A variant of getCell which specifically returns line values, - // and treats objects as being out-of-bounds - getLine(x, y) { - var cell = this.getCell(x, y) - if (cell == null) return null - if (cell.type !== 'line') return null - return cell.line - } - - updateCell2(x, y, key, value) { - x = this._mod(x) - if (!this._safeCell(x, y)) return - var cell = this.grid[x][y] - if (cell == null) return - cell[key] = value - } - - getValidEndDirs(x, y) { - x = this._mod(x) - if (!this._safeCell(x, y)) return [] - - var dirs = [] - var leftCell = this.getCell(x - 1, y) - if (leftCell == null || leftCell.gap === window.GAP_FULL) dirs.push('left') - var topCell = this.getCell(x, y - 1) - if (topCell == null || topCell.gap === window.GAP_FULL) dirs.push('top') - var rightCell = this.getCell(x + 1, y) - if (rightCell == null || rightCell.gap === window.GAP_FULL) dirs.push('right') - var bottomCell = this.getCell(x, y + 1) - if (bottomCell == null || bottomCell.gap === window.GAP_FULL) dirs.push('bottom') - return dirs - } - - // Note: Does not use this.width/this.height, so that it may be used to ask about resizing. - getSizeError(width, height) { - if (this.pillar && width < 4) return 'Pillars may not have a width of 1' - if (width * height < 25) return 'Puzzles may not be smaller than 2x2 or 1x4' - if (width > 21 || height > 21) return 'Puzzles may not be larger than 10 in either dimension' - if (this.symmetry != null) { - if (this.symmetry.x && width <= 2) return 'Symmetrical puzzles must be sufficiently wide for both lines' - if (this.symmetry.y && height <= 2) return 'Symmetrical puzzles must be sufficiently wide for both lines' - if (this.pillar && this.symmetry.x && width % 4 !== 0) return 'X + Pillar symmetry must be an even number of rows, to keep both startpoints at the same parity' - } - - return null - } - - - // Called on a solution. Computes a list of gaps to show as hints which *do not* - // break the path. - loadHints() { - this.hints = [] - for (var x=0; x window.LINE_NONE) { - this.hints.push({'x':x, 'y':y}) - } - } - } - } - - // Show a hint on the grid. - // If no hint is provided, will select the best one it can find, - // prioritizing breaking current lines on the grid. - // Returns the shown hint. - showHint(hint) { - if (hint != null) { - this.grid[hint.x][hint.y].gap = window.GAP_BREAK - return - } - - var goodHints = [] - var badHints = [] - - for (var hint of this.hints) { - if (this.getLine(hint.x, hint.y) > window.LINE_NONE) { - // Solution will be broken by this hint - goodHints.push(hint) - } else { - badHints.push(hint) - } - } - if (goodHints.length > 0) { - var hint = goodHints.splice(window.randInt(goodHints.length), 1)[0] - } else if (badHints.length > 0) { - var hint = badHints.splice(window.randInt(badHints.length), 1)[0] - } else { - return - } - this.grid[hint.x][hint.y].gap = window.GAP_BREAK - this.hints = badHints.concat(goodHints) - return hint - } - - clearLines() { - for (var x=0; x 0) this._floodFill(x, y - 1, region, col) - if (x < this.width - 1) this._floodFill(x + 1, y, region, this.grid[x+1]) - else if (this.pillar !== false) this._floodFill(0, y, region, this.grid[0]) - if (x > 0) this._floodFill(x - 1, y, region, this.grid[x-1]) - else if (this.pillar !== false) this._floodFill(this.width-1, y, region, this.grid[this.width-1]) - } - - // Re-uses the same grid, but only called on edges which border the outside - // Called first to mark cells that are connected to the outside, i.e. should not be part of any region. - _floodFillOutside(x, y, col) { - var cell = col[y] - if (cell === MASKED_PROCESSED) return - if (x%2 !== y%2 && cell !== MASKED_GAP2) return // Only flood-fill through gap-2 - if (x%2 === 0 && y%2 === 0 && cell === MASKED_DOT) return // Don't flood-fill through dots - col[y] = MASKED_PROCESSED - - if (x%2 === 0 && y%2 === 0) return // Don't flood fill through corners (what? Clarify.) - - if (y < this.height - 1) this._floodFillOutside(x, y + 1, col) - if (y > 0) this._floodFillOutside(x, y - 1, col) - if (x < this.width - 1) this._floodFillOutside(x + 1, y, this.grid[x+1]) - else if (this.pillar !== false) this._floodFillOutside(0, y, this.grid[0]) - if (x > 0) this._floodFillOutside(x - 1, y, this.grid[x-1]) - else if (this.pillar !== false) this._floodFillOutside(this.width-1, y, this.grid[this.width-1]) - } - - // Returns the original grid (pre-masking). You will need to switch back once you are done flood filling. - switchToMaskedGrid() { - // Make a copy of the grid -- we will be overwriting it - var savedGrid = this.grid - this.grid = new Array(this.width) - // Override all elements with empty lines -- this means that flood fill is just - // looking for lines with line=0. - for (var x=0; x window.LINE_NONE) { - row[y] = MASKED_PROCESSED // Traced lines should not be a part of the region - } else if (cell.gap === window.GAP_FULL) { - row[y] = MASKED_GAP2 - } else if (cell.dot > window.DOT_NONE) { - row[y] = MASKED_DOT - } else { - row[y] = MASKED_INB_COUNT - } - } - this.grid[x] = row - } - - // Starting at a mid-segment startpoint - if (this.startPoint != null && this.startPoint.x%2 !== this.startPoint.y%2) { - if (this.settings.FAT_STARTPOINTS) { - // This segment is not in any region (acts as a barrier) - this.grid[this.startPoint.x][this.startPoint.y] = MASKED_OOB - } else { - // This segment is part of this region (acts as an empty cell) - this.grid[this.startPoint.x][this.startPoint.y] = MASKED_INB_NONCOUNT - } - } - - // Ending at a mid-segment endpoint - if (this.endPoint != null && this.endPoint.x%2 !== this.endPoint.y%2) { - // This segment is part of this region (acts as an empty cell) - this.grid[this.endPoint.x][this.endPoint.y] = MASKED_INB_NONCOUNT - } - - // Mark all outside cells as 'not in any region' (aka null) - - // Top and bottom edges - for (var x=1; x 0) row[x] = ' ' - if (cell.dot > 0) row[x] = 'X' - if (cell.line === 0) row[x] = '.' - if (cell.line === 1) row[x] = '#' - if (cell.line === 2) row[x] = '#' - if (cell.line === 3) row[x] = 'o' - } else row[x] = '?' - } - output += row.join('') + '\n' - } - console.info(output) - } -} - -// The grid contains 5 colors: -// null: Out of bounds or already processed -var MASKED_OOB = null -var MASKED_PROCESSED = null -// 0: In bounds, awaiting processing, but should not be part of the final region. -var MASKED_INB_NONCOUNT = 0 -// 1: In bounds, awaiting processing -var MASKED_INB_COUNT = 1 -// 2: Gap-2. After _floodFillOutside, this means "treat normally" (it will be null if oob) -var MASKED_GAP2 = 2 -// 3: Dot (of any kind), otherwise identical to 1. Should not be flood-filled through (why the f do we need this) -var MASKED_DOT = 3 - -}) diff --git a/app/assets/javascripts/wittle/serializer.js b/app/assets/javascripts/wittle/serializer.js deleted file mode 100644 index 70c7f0f..0000000 --- a/app/assets/javascripts/wittle/serializer.js +++ /dev/null @@ -1,365 +0,0 @@ -namespace(function() { - -window.serializePuzzle = function(puzzle) { - var s = new Serializer('w') - var version = 0 - - s.writeInt(version) - s.writeByte(puzzle.width) - s.writeByte(puzzle.height) - s.writeString(puzzle.name) - - var genericFlags = 0 - if (puzzle.autoSolved) genericFlags |= GENERIC_FLAG_AUTOSOLVED - if (puzzle.symmetry) { - genericFlags |= GENERIC_FLAG_SYMMETRICAL - if (puzzle.symmetry.x) genericFlags |= GENERIC_FLAG_SYMMETRY_X - if (puzzle.symmetry.y) genericFlags |= GENERIC_FLAG_SYMMETRY_Y - } - if (puzzle.pillar) genericFlags |= GENERIC_FLAG_PILLAR - s.writeByte(genericFlags) - for (var x=0; x 0) { - s.writeInt(puzzle.path.length) - s.writeByte(startPos.x) - s.writeByte(startPos.y) - for (var dir of puzzle.path) s.writeByte(dir) - } - } else { - s.writeInt(0) - } - - var settingsFlags = 0 - if (puzzle.settings.NEGATIONS_CANCEL_NEGATIONS) settingsFlags |= SETTINGS_FLAG_NCN - if (puzzle.settings.SHAPELESS_ZERO_POLY) settingsFlags |= SETTINGS_FLAG_SZP - if (puzzle.settings.PRECISE_POLYOMINOS) settingsFlags |= SETTINGS_FLAG_PP - if (puzzle.settings.FLASH_FOR_ERRORS) settingsFlags |= SETTINGS_FLAG_FFE - if (puzzle.settings.FAT_STARTPOINTS) settingsFlags |= SETTINGS_FLAG_FS - if (puzzle.settings.CUSTOM_MECHANICS) settingsFlags |= SETTINGS_FLAG_CM - if (puzzle.settings.INVISIBLE_SYMMETRY) settingsFlags |= SETTINGS_FLAG_IS - s.writeByte(settingsFlags) - - s.writeByte(puzzle.symType) - - return s.str() -} - -window.deserializePuzzle = function(data) { - // Data is JSON, so decode it with the old deserializer - if (data[0] == '{') return Puzzle.deserialize(data) - - var s = new Serializer('r', data) - var version = s.readInt() - if (version > 0) throw Error('Cannot read data from unknown version: ' + version) - - var width = s.readByte() - var height = s.readByte() - var puzzle = new Puzzle(Math.floor(width / 2), Math.floor(height / 2)) - puzzle.name = s.readString() - - var genericFlags = s.readByte() - puzzle.autoSolved = genericFlags & GENERIC_FLAG_AUTOSOLVED - puzzle.symType = SYM_TYPE_NONE - if ((genericFlags & GENERIC_FLAG_SYMMETRICAL) != 0) { - if ((genericFlags & GENERIC_FLAG_SYMMETRY_X) != 0) { - if ((genericFlags & GENERIC_FLAG_SYMMETRY_Y) != 0) { - puzzle.symType = SYM_TYPE_ROTATIONAL - } else { - puzzle.symType = SYM_TYPE_VERTICAL - } - } else if ((genericFlags & GENERIC_FLAG_SYMMETRY_Y) != 0) { - puzzle.symType = SYM_TYPE_HORIZONTAL - } - } - puzzle.pillar = (genericFlags & GENERIC_FLAG_PILLAR) != 0 - for (var x=0; x 0) { - var path = [{ - 'x': s.readByte(), - 'y': s.readByte(), - }] - for (var i=0; i= numBytes) - } - - readByte() { - this._checkRead() - return this.data.charCodeAt(this.index++) - } - - writeByte(b) { - if (b < 0 || b > 0xFF) throw Error('Cannot write out-of-range byte ' + b) - this.data += String.fromCharCode(b) - } - - readInt() { - var b1 = this.readByte() << 0 - var b2 = this.readByte() << 8 - var b3 = this.readByte() << 16 - var b4 = this.readByte() << 24 - return b1 | b2 | b3 | b4 - } - - writeInt(i) { - if (i < 0 || i > 0xFFFFFFFF) throw Error('Cannot write out-of-range int ' + i) - var b1 = (i & 0x000000FF) >> 0 - var b2 = (i & 0x0000FF00) >> 8 - var b3 = (i & 0x00FF0000) >> 16 - var b4 = (i & 0xFF000000) >> 24 - this.writeByte(b1) - this.writeByte(b2) - this.writeByte(b3) - this.writeByte(b4) - } - - readLong() { - var i1 = this.readInt() << 32 - var i2 = this.readInt() - return i1 | i2 - } - - writeLong(l) { - if (l < 0 || l > 0xFFFFFFFFFFFFFFFF) throw Error('Cannot write out-of-range long ' + l) - var i1 = l & 0xFFFFFFFF - var i2 = (l - i1) / 0x100000000 - this.writeInt(i1) - this.writeInt(i2) - } - - readString() { - var len = this.readInt() - this._checkRead(len) - var str = this.data.substr(this.index, len) - this.index += len - return str - } - - writeString(s) { - if (s == null) { - this.writeInt(0) - return - } - this.writeInt(s.length) - this.data += s - } - - readColor() { - var r = this.readByte().toString() - var g = this.readByte().toString() - var b = this.readByte().toString() - var a = this.readByte().toString() - return 'rgba(' + r + ', ' + g + ', ' + b + ', ' + a + ')' - } - - writeColor(c) { - // Adapted from https://gist.github.com/njvack/02ad8efcb0d552b0230d - this.colorConverter.fillStyle = 'rgba(0, 0, 0, 0)' // Load a default in case we are passed garbage - this.colorConverter.clearRect(0, 0, 1, 1) - this.colorConverter.fillStyle = c - this.colorConverter.fillRect(0, 0, 1, 1) - var rgba = this.colorConverter.getImageData(0, 0, 1, 1).data - this.writeByte(rgba[0]) - this.writeByte(rgba[1]) - this.writeByte(rgba[2]) - this.writeByte(rgba[3]) - } - - readCell() { - var cellType = this.readByte() - if (cellType === CELL_TYPE_NULL) return null - - var cell = {} - cell.dir = null - cell.line = 0 - if (cellType === CELL_TYPE_LINE) { - cell.type = 'line' - cell.line = this.readByte() - var dot = this.readByte() - if (dot != 0) cell.dot = dot - var gap = this.readByte() - if (gap != 0) cell.gap = gap - } else if (cellType === CELL_TYPE_SQUARE) { - cell.type = 'square' - cell.color = this.readColor() - } else if (cellType === CELL_TYPE_STAR) { - cell.type = 'star' - cell.color = this.readColor() - } else if (cellType === CELL_TYPE_NEGA) { - cell.type = 'nega' - cell.color = this.readColor() - } else if (cellType === CELL_TYPE_TRIANGLE) { - cell.type = 'triangle' - cell.color = this.readColor() - cell.count = this.readByte() - } else if (cellType === CELL_TYPE_POLY) { - cell.type = 'poly' - cell.color = this.readColor() - cell.polyshape = this.readLong() - } else if (cellType === CELL_TYPE_YLOP) { - cell.type = 'ylop' - cell.color = this.readColor() - cell.polyshape = this.readLong() - } else if (cellType == CELL_TYPE_NONCE) { - cell.type = 'nonce' - } - - var startEnd = this.readByte() - if (startEnd & CELL_START) cell.start = true - if (startEnd & CELL_END_LEFT) cell.end = 'left' - if (startEnd & CELL_END_RIGHT) cell.end = 'right' - if (startEnd & CELL_END_TOP) cell.end = 'top' - if (startEnd & CELL_END_BOTTOM) cell.end = 'bottom' - - return cell - } - - - writeCell(cell) { - if (cell == null) { - this.writeByte(CELL_TYPE_NULL) - return - } - - // Write cell type, then cell data, then generic data. - // Note that cell type starts at 1, since 0 is the "null type". - if (cell.type == 'line') { - this.writeByte(CELL_TYPE_LINE) - this.writeByte(cell.line) - this.writeByte(cell.dot) - this.writeByte(cell.gap) - } else if (cell.type == 'square') { - this.writeByte(CELL_TYPE_SQUARE) - this.writeColor(cell.color) - } else if (cell.type == 'star') { - this.writeByte(CELL_TYPE_STAR) - this.writeColor(cell.color) - } else if (cell.type == 'nega') { - this.writeByte(CELL_TYPE_NEGA) - this.writeColor(cell.color) - } else if (cell.type == 'triangle') { - this.writeByte(CELL_TYPE_TRIANGLE) - this.writeColor(cell.color) - this.writeByte(cell.count) - } else if (cell.type == 'poly') { - this.writeByte(CELL_TYPE_POLY) - this.writeColor(cell.color) - this.writeLong(cell.polyshape) - } else if (cell.type == 'ylop') { - this.writeByte(CELL_TYPE_YLOP) - this.writeColor(cell.color) - this.writeLong(cell.polyshape) - } - - var startEnd = 0 - if (cell.start === true) startEnd |= CELL_START - if (cell.end == 'left') startEnd |= CELL_END_LEFT - if (cell.end == 'right') startEnd |= CELL_END_RIGHT - if (cell.end == 'top') startEnd |= CELL_END_TOP - if (cell.end == 'bottom') startEnd |= CELL_END_BOTTOM - this.writeByte(startEnd) - } -} - -var CELL_TYPE_NULL = 0 -var CELL_TYPE_LINE = 1 -var CELL_TYPE_SQUARE = 2 -var CELL_TYPE_STAR = 3 -var CELL_TYPE_NEGA = 4 -var CELL_TYPE_TRIANGLE = 5 -var CELL_TYPE_POLY = 6 -var CELL_TYPE_YLOP = 7 -var CELL_TYPE_NONCE = 8 - -var CELL_START = 1 -var CELL_END_LEFT = 2 -var CELL_END_RIGHT = 4 -var CELL_END_TOP = 8 -var CELL_END_BOTTOM = 16 - -var GENERIC_FLAG_AUTOSOLVED = 1 -var GENERIC_FLAG_SYMMETRICAL = 2 -var GENERIC_FLAG_SYMMETRY_X = 4 -var GENERIC_FLAG_SYMMETRY_Y = 8 -var GENERIC_FLAG_PILLAR = 16 - -var SETTINGS_FLAG_NCN = 1 -var SETTINGS_FLAG_SZP = 2 -var SETTINGS_FLAG_PP = 4 -var SETTINGS_FLAG_FFE = 8 -var SETTINGS_FLAG_FS = 16 -var SETTINGS_FLAG_CM = 32 -var SETTINGS_FLAG_IS = 64 - -}) diff --git a/app/assets/javascripts/wittle/solve.js b/app/assets/javascripts/wittle/solve.js deleted file mode 100644 index 8695291..0000000 --- a/app/assets/javascripts/wittle/solve.js +++ /dev/null @@ -1,531 +0,0 @@ -namespace(function() { - -// @Volatile -- must match order of MOVE_* in trace2 -// Move these, dummy. -var PATH_NONE = 0 -var PATH_LEFT = 1 -var PATH_RIGHT = 2 -var PATH_TOP = 3 -var PATH_BOTTOM = 4 - -window.MAX_SOLUTIONS = 0 -var solutionPaths = [] -var asyncTimer = 0 -var task = null -var puzzle = null -var path = [] -var SOLVE_SYNC = false -var SYNC_THRESHOLD = 9 // Depth at which we switch to a synchronous solver (for perf) -var doPruning = false - -var percentages = [] -var NODE_DEPTH = 9 -var nodes = 0 -function countNodes(x, y, depth) { - // Check for collisions (outside, gap, self, other) - var cell = puzzle.getCell(x, y) - if (cell == null) return - if (cell.gap > window.GAP_NONE) return - if (cell.line !== window.LINE_NONE) return - - if (puzzle.symType == SYM_TYPE_NONE) { - puzzle.updateCell2(x, y, 'line', window.LINE_BLACK) - } else { - var sym = puzzle.getSymmetricalPos(x, y) - if (puzzle.matchesSymmetricalPos(x, y, sym.x, sym.y)) return // Would collide with our reflection - - var symCell = puzzle.getCell(sym.x, sym.y) - if (symCell.gap > window.GAP_NONE) return - - puzzle.updateCell2(x, y, 'line', window.LINE_BLUE) - puzzle.updateCell2(sym.x, sym.y, 'line', window.LINE_YELLOW) - } - - if (depth < NODE_DEPTH) { - nodes++ - - if (y%2 === 0) { - countNodes(x - 1, y, depth + 1) - countNodes(x + 1, y, depth + 1) - } - - if (x%2 === 0) { - countNodes(x, y - 1, depth + 1) - countNodes(x, y + 1, depth + 1) - } - } - - tailRecurse(x, y) -} - -// Generates a solution via DFS recursive backtracking -window.solve = function(p, partialCallback, finalCallback) { - if (task != null) throw Error('Cannot start another solve() while one is already in progress') - var start = (new Date()).getTime() - - puzzle = p - var startPoints = [] - var numEndpoints = 0 - puzzle.hasNegations = false - puzzle.hasPolyominos = false - for (var x=0; x 0) { - // Tasks are pushed in order. To do DFS, we need to enqueue them in reverse order. - for (var i=newTasks.length - 1; i >= 0; i--) { - task = { - 'code': newTasks[i], - 'nextTask': task, - } - } - } - - // Asynchronizing is expensive. As such, we don't want to do it too often. - // However, we would like 'cancel solving' to be responsive. So, we call setTimeout every so often. - var doAsync = false - if (!SOLVE_SYNC) { - doAsync = (asyncTimer++ % 100 === 0) - while (nodes >= percentages[0]) { - if (partialCallback) partialCallback(100 - percentages.length) - percentages.shift() - doAsync = true - } - } - - if (doAsync) { - setTimeout(function() { - taskLoop(partialCallback, finalCallback) - }, 0) - } else { - taskLoop(partialCallback, finalCallback) - } -} - -function tailRecurse(x, y) { - // Tail recursion: Back out of this cell - puzzle.updateCell2(x, y, 'line', window.LINE_NONE) - if (puzzle.symType != SYM_TYPE_NONE) { - var sym = puzzle.getSymmetricalPos(x, y) - puzzle.updateCell2(sym.x, sym.y, 'line', window.LINE_NONE) - } -} - -// @Performance: This is the most central loop in this code. -// Any performance efforts should be focused here. -// Note: Most mechanics are NP (or harder), so don't feel bad about solving them by brute force. -// https://arxiv.org/pdf/1804.10193.pdf -function solveLoop(x, y, numEndpoints, earlyExitData) { - // Stop trying to solve once we reach our goal - if (solutionPaths.length >= window.MAX_SOLUTIONS) return - - // Check for collisions (outside, gap, self, other) - var cell = puzzle.getCell(x, y) - if (cell == null) return - if (cell.gap > window.GAP_NONE) return - if (cell.line !== window.LINE_NONE) return - - if (puzzle.symType == SYM_TYPE_NONE) { - puzzle.updateCell2(x, y, 'line', window.LINE_BLACK) - } else { - var sym = puzzle.getSymmetricalPos(x, y) - if (puzzle.matchesSymmetricalPos(x, y, sym.x, sym.y)) return // Would collide with our reflection - - var symCell = puzzle.getCell(sym.x, sym.y) - if (symCell.gap > window.GAP_NONE) return - - puzzle.updateCell2(x, y, 'line', window.LINE_BLUE) - puzzle.updateCell2(sym.x, sym.y, 'line', window.LINE_YELLOW) - } - - if (path.length < NODE_DEPTH) nodes++ - - if (cell.end != null) { - path.push(PATH_NONE) - puzzle.endPoint = {'x': x, 'y': y} - var puzzleData = window.validate(puzzle, true) - if (puzzleData.valid()) solutionPaths.push(path.slice()) - path.pop() - - // If there are no further endpoints, tail recurse. - // Otherwise, keep going -- we might be able to reach another endpoint. - numEndpoints-- - if (numEndpoints === 0) return tailRecurse(x, y) - } - - var newEarlyExitData = null - if (doPruning) { - var isEdge = x <= 0 || y <= 0 || x >= puzzle.width - 1 || y >= puzzle.height - 1 - newEarlyExitData = [ - earlyExitData[0] || (!isEdge && earlyExitData[2].isEdge), // Have we ever left an edge? - earlyExitData[2], // The position before our current one - {'x':x, 'y':y, 'isEdge':isEdge} // Our current position. - ] - if (earlyExitData[0] && !earlyExitData[1].isEdge && earlyExitData[2].isEdge && isEdge) { - // See the above comment for an explanation of this math. - var floodX = earlyExitData[2].x + (earlyExitData[1].x - x) - var floodY = earlyExitData[2].y + (earlyExitData[1].y - y) - var region = puzzle.getRegion(floodX, floodY) - if (region != null) { - var regionData = window.validateRegion(puzzle, region, true) - if (!regionData.valid()) return tailRecurse(x, y) - - // Additionally, we might have left an endpoint in the enclosed region. - // If so, we should decrement the number of remaining endpoints (and possibly tail recurse). - for (var pos of region) { - var endCell = puzzle.grid[pos.x][pos.y] - if (endCell != null && endCell.end != null) numEndpoints-- - } - - if (numEndpoints === 0) return tailRecurse(x, y) - } - } - } - - if (SOLVE_SYNC || path.length > SYNC_THRESHOLD) { - path.push(PATH_NONE) - - // Recursion order (LRUD) is optimized for BL->TR and mid-start puzzles - if (y%2 === 0) { - path[path.length-1] = PATH_LEFT - solveLoop(x - 1, y, numEndpoints, newEarlyExitData) - - path[path.length-1] = PATH_RIGHT - solveLoop(x + 1, y, numEndpoints, newEarlyExitData) - } - - if (x%2 === 0) { - path[path.length-1] = PATH_TOP - solveLoop(x, y - 1, numEndpoints, newEarlyExitData) - - path[path.length-1] = PATH_BOTTOM - solveLoop(x, y + 1, numEndpoints, newEarlyExitData) - } - - path.pop() - tailRecurse(x, y) - - } else { - // Push a dummy element on the end of the path, so that we can fill it correctly as we DFS. - // This element is popped when we tail recurse (which always happens *after* all of our DFS!) - path.push(PATH_NONE) - - // Recursion order (LRUD) is optimized for BL->TR and mid-start puzzles - var newTasks = [] - if (y%2 === 0) { - newTasks.push(function() { - path[path.length-1] = PATH_LEFT - return solveLoop(x - 1, y, numEndpoints, newEarlyExitData) - }) - newTasks.push(function() { - path[path.length-1] = PATH_RIGHT - return solveLoop(x + 1, y, numEndpoints, newEarlyExitData) - }) - } - - if (x%2 === 0) { - newTasks.push(function() { - path[path.length-1] = PATH_TOP - return solveLoop(x, y - 1, numEndpoints, newEarlyExitData) - }) - newTasks.push(function() { - path[path.length-1] = PATH_BOTTOM - return solveLoop(x, y + 1, numEndpoints, newEarlyExitData) - }) - } - - newTasks.push(function() { - path.pop() - tailRecurse(x, y) - }) - - return newTasks - } -} - -window.cancelSolving = function() { - console.info('Cancelled solving') - window.MAX_SOLUTIONS = 0 // Causes all new solveLoop calls to exit immediately. - tasks = [] -} - -// Only modifies the puzzle object (does not do any graphics updates). Used by metapuzzle.js to determine subpuzzle polyshapes. -window.drawPathNoUI = function(puzzle, path) { - puzzle.clearLines() - - // Extract the start data from the first path element - var x = path[0].x - var y = path[0].y - var cell = puzzle.getCell(x, y) - if (cell == null || cell.start !== true) throw Error('Path does not begin with a startpoint: ' + JSON.stringify(cell)) - - for (var i=1; i max ? max : value -} - -class BoundingBox { - constructor(x1, x2, y1, y2, sym=false) { - this.raw = {'x1':x1, 'x2':x2, 'y1':y1, 'y2':y2} - this.sym = sym - if (BBOX_DEBUG === true) { - this.debug = createElement('rect') - data.svg.appendChild(this.debug) - this.debug.setAttribute('opacity', 0.5) - this.debug.setAttribute('style', 'pointer-events: none;') - if (data.puzzle.symType == SYM_TYPE_NONE) { - this.debug.setAttribute('fill', 'white') - } else { - if (this.sym !== true) { - this.debug.setAttribute('fill', 'blue') - } else { - this.debug.setAttribute('fill', 'orange') - } - } - } - this._update() - } - - shift(dir, pixels) { - if (dir === 'left') { - this.raw.x2 = this.raw.x1 - this.raw.x1 -= pixels - } else if (dir === 'right') { - this.raw.x1 = this.raw.x2 - this.raw.x2 += pixels - } else if (dir === 'top') { - this.raw.y2 = this.raw.y1 - this.raw.y1 -= pixels - } else if (dir === 'bottom') { - this.raw.y1 = this.raw.y2 - this.raw.y2 += pixels - } - this._update() - } - - inMain(x, y) { - var inMainBox = - (this.x1 < x && x < this.x2) && - (this.y1 < y && y < this.y2) - var inRawBox = - (this.raw.x1 < x && x < this.raw.x2) && - (this.raw.y1 < y && y < this.raw.y2) - - return inMainBox && !inRawBox - } - - _update() { - this.x1 = this.raw.x1 - this.x2 = this.raw.x2 - this.y1 = this.raw.y1 - this.y2 = this.raw.y2 - - // Check for endpoint adjustment. - // Pretend it's not an endpoint if the sym cell isn't an endpoint. - if (data.puzzle.symType != SYM_TYPE_NONE) { - var cell1 = data.puzzle.getCell(data.pos.x, data.pos.y) - var cell2 = data.puzzle.getSymmetricalCell(data.pos.x, data.pos.y) - - if ((cell1.end == null) != (cell2.end == null)) { - var cell = {'end': 'none'} - } else if (this.sym !== true) { - var cell = cell1 - } else { - var cell = cell2 - } - } else { - var cell = data.puzzle.getCell(data.pos.x, data.pos.y) - } - if (cell.end === 'left') { - this.x1 -= 24 - } else if (cell.end === 'right') { - this.x2 += 24 - } else if (cell.end === 'top') { - this.y1 -= 24 - } else if (cell.end === 'bottom') { - this.y2 += 24 - } - - this.middle = { // Note: Middle of the raw object - 'x':(this.raw.x1 + this.raw.x2)/2, - 'y':(this.raw.y1 + this.raw.y2)/2 - } - - if (this.debug != null) { - this.debug.setAttribute('x', this.x1) - this.debug.setAttribute('y', this.y1) - this.debug.setAttribute('width', this.x2 - this.x1) - this.debug.setAttribute('height', this.y2 - this.y1) - } - } -} - -class PathSegment { - constructor(dir) { - this.poly1 = createElement('polygon') - this.circ = createElement('circle') - this.poly2 = createElement('polygon') - this.pillarCirc = createElement('circle') - this.dir = dir - data.svg.insertBefore(this.circ, data.cursor) - data.svg.insertBefore(this.poly2, data.cursor) - data.svg.insertBefore(this.pillarCirc, data.cursor) - this.circ.setAttribute('cx', data.bbox.middle.x) - this.circ.setAttribute('cy', data.bbox.middle.y) - - if (data.puzzle.pillar === true) { - // cx/cy are updated in redraw(), since pillarCirc tracks the cursor - this.pillarCirc.setAttribute('cy', data.bbox.middle.y) - this.pillarCirc.setAttribute('r', 12) - if (data.pos.x === 0 && this.dir === MOVE_RIGHT) { - this.pillarCirc.setAttribute('cx', data.bbox.x1) - this.pillarCirc.setAttribute('static', true) - } else if (data.pos.x === data.puzzle.width - 1 && this.dir === MOVE_LEFT) { - this.pillarCirc.setAttribute('cx', data.bbox.x2) - this.pillarCirc.setAttribute('static', true) - } else { - this.pillarCirc.setAttribute('cx', data.bbox.middle.x) - } - } - - if (data.puzzle.symType == SYM_TYPE_NONE) { - this.poly1.setAttribute('class', 'line-1 ' + data.svg.id) - this.circ.setAttribute('class', 'line-1 ' + data.svg.id) - this.poly2.setAttribute('class', 'line-1 ' + data.svg.id) - this.pillarCirc.setAttribute('class', 'line-1 ' + data.svg.id) - } else { - this.poly1.setAttribute('class', 'line-2 ' + data.svg.id) - this.circ.setAttribute('class', 'line-2 ' + data.svg.id) - this.poly2.setAttribute('class', 'line-2 ' + data.svg.id) - this.pillarCirc.setAttribute('class', 'line-2 ' + data.svg.id) - - this.symPoly1 = createElement('polygon') - this.symCirc = createElement('circle') - this.symPoly2 = createElement('polygon') - this.symPillarCirc = createElement('circle') - data.svg.insertBefore(this.symCirc, data.cursor) - data.svg.insertBefore(this.symPoly2, data.cursor) - data.svg.insertBefore(this.symPillarCirc, data.cursor) - - if (data.puzzle.settings.INVISIBLE_SYMMETRY) { - this.symPoly1.setAttribute('class', 'line-4 ' + data.svg.id) - this.symCirc.setAttribute('class', 'line-4 ' + data.svg.id) - this.symPoly2.setAttribute('class', 'line-4 ' + data.svg.id) - this.symPillarCirc.setAttribute('class', 'line-4 ' + data.svg.id) - } else { - this.symPoly1.setAttribute('class', 'line-3 ' + data.svg.id) - this.symCirc.setAttribute('class', 'line-3 ' + data.svg.id) - this.symPoly2.setAttribute('class', 'line-3 ' + data.svg.id) - this.symPillarCirc.setAttribute('class', 'line-3 ' + data.svg.id) - } - - this.symCirc.setAttribute('cx', data.symbbox.middle.x) - this.symCirc.setAttribute('cy', data.symbbox.middle.y) - - if (data.puzzle.pillar === true) { - // cx/cy are updated in redraw(), since symPillarCirc tracks the cursor - this.symPillarCirc.setAttribute('cy', data.symbbox.middle.y) - this.symPillarCirc.setAttribute('r', 12) - var symmetricalDir = getSymmetricalDir(data.puzzle, this.dir) - if (data.sym.x === 0 && symmetricalDir === MOVE_RIGHT) { - this.symPillarCirc.setAttribute('cx', data.symbbox.x1) - this.symPillarCirc.setAttribute('static', true) - } else if (data.sym.x === data.puzzle.width - 1 && symmetricalDir === MOVE_LEFT) { - this.symPillarCirc.setAttribute('cx', data.symbbox.x2) - this.symPillarCirc.setAttribute('static', true) - } else { - this.symPillarCirc.setAttribute('cx', data.symbbox.middle.x) - } - } - } - - if (this.dir === MOVE_NONE) { // Start point - this.circ.setAttribute('r', 24) - this.circ.setAttribute('class', this.circ.getAttribute('class') + ' start') - if (data.puzzle.symType != SYM_TYPE_NONE) { - this.symCirc.setAttribute('r', 24) - this.symCirc.setAttribute('class', this.symCirc.getAttribute('class') + ' start') - } - } else { - // Only insert poly1 in non-startpoints - data.svg.insertBefore(this.poly1, data.cursor) - this.circ.setAttribute('r', 12) - if (data.puzzle.symType != SYM_TYPE_NONE) { - data.svg.insertBefore(this.symPoly1, data.cursor) - this.symCirc.setAttribute('r', 12) - } - } - } - - destroy() { - data.svg.removeChild(this.poly1) - data.svg.removeChild(this.circ) - data.svg.removeChild(this.poly2) - data.svg.removeChild(this.pillarCirc) - if (data.puzzle.symType != SYM_TYPE_NONE) { - data.svg.removeChild(this.symPoly1) - data.svg.removeChild(this.symCirc) - data.svg.removeChild(this.symPoly2) - data.svg.removeChild(this.symPillarCirc) - } - } - - redraw() { // Uses raw bbox because of endpoints - // Move the cursor and related objects - var x = clamp(data.x, data.bbox.x1, data.bbox.x2) - var y = clamp(data.y, data.bbox.y1, data.bbox.y2) - data.cursor.setAttribute('cx', x) - data.cursor.setAttribute('cy', y) - if (data.puzzle.symType != SYM_TYPE_NONE) { - data.symcursor.setAttribute('cx', this._reflX(x,y)) - data.symcursor.setAttribute('cy', this._reflY(x,y)) - } - if (data.puzzle.pillar === true) { - if (this.pillarCirc.getAttribute('static') == null) { - this.pillarCirc.setAttribute('cx', x) - this.pillarCirc.setAttribute('cy', y) - } - if (data.puzzle.symType != SYM_TYPE_NONE) { - if (this.symPillarCirc.getAttribute('static') == null) { - this.symPillarCirc.setAttribute('cx', this._reflX(x,y)) - this.symPillarCirc.setAttribute('cy', this._reflY(x,y)) - } - } - } - - // Draw the first-half box - var points1 = JSON.parse(JSON.stringify(data.bbox.raw)) - if (this.dir === MOVE_LEFT) { - points1.x1 = clamp(data.x, data.bbox.middle.x, data.bbox.x2) - } else if (this.dir === MOVE_RIGHT) { - points1.x2 = clamp(data.x, data.bbox.x1, data.bbox.middle.x) - } else if (this.dir === MOVE_TOP) { - points1.y1 = clamp(data.y, data.bbox.middle.y, data.bbox.y2) - } else if (this.dir === MOVE_BOTTOM) { - points1.y2 = clamp(data.y, data.bbox.y1, data.bbox.middle.y) - } - this.poly1.setAttribute('points', - points1.x1 + ' ' + points1.y1 + ',' + - points1.x1 + ' ' + points1.y2 + ',' + - points1.x2 + ' ' + points1.y2 + ',' + - points1.x2 + ' ' + points1.y1 - ) - - var firstHalf = false - var isEnd = (data.puzzle.grid[data.pos.x][data.pos.y].end != null) - // The second half of the line uses the raw so that it can enter the endpoint properly. - var points2 = JSON.parse(JSON.stringify(data.bbox.raw)) - if (data.x < data.bbox.middle.x && this.dir !== MOVE_RIGHT) { - points2.x1 = clamp(data.x, data.bbox.x1, data.bbox.middle.x) - points2.x2 = data.bbox.middle.x - if (isEnd && data.pos.x%2 === 0 && data.pos.y%2 === 1) { - points2.y1 += 17 - points2.y2 -= 17 - } - } else if (data.x > data.bbox.middle.x && this.dir !== MOVE_LEFT) { - points2.x1 = data.bbox.middle.x - points2.x2 = clamp(data.x, data.bbox.middle.x, data.bbox.x2) - if (isEnd && data.pos.x%2 === 0 && data.pos.y%2 === 1) { - points2.y1 += 17 - points2.y2 -= 17 - } - } else if (data.y < data.bbox.middle.y && this.dir !== MOVE_BOTTOM) { - points2.y1 = clamp(data.y, data.bbox.y1, data.bbox.middle.y) - points2.y2 = data.bbox.middle.y - if (isEnd && data.pos.x%2 === 1 && data.pos.y%2 === 0) { - points2.x1 += 17 - points2.x2 -= 17 - } - } else if (data.y > data.bbox.middle.y && this.dir !== MOVE_TOP) { - points2.y1 = data.bbox.middle.y - points2.y2 = clamp(data.y, data.bbox.middle.y, data.bbox.y2) - if (isEnd && data.pos.x%2 === 1 && data.pos.y%2 === 0) { - points2.x1 += 17 - points2.x2 -= 17 - } - } else { - firstHalf = true - } - - this.poly2.setAttribute('points', - points2.x1 + ' ' + points2.y1 + ',' + - points2.x1 + ' ' + points2.y2 + ',' + - points2.x2 + ' ' + points2.y2 + ',' + - points2.x2 + ' ' + points2.y1 - ) - - // Show the second poly only in the second half of the cell - this.poly2.setAttribute('opacity', (firstHalf ? 0 : 1)) - // Show the circle in the second half of the cell AND in the start - if (firstHalf && this.dir !== MOVE_NONE) { - this.circ.setAttribute('opacity', 0) - } else { - this.circ.setAttribute('opacity', 1) - } - - // Draw the symmetrical path based on the original one - if (data.puzzle.symType != SYM_TYPE_NONE) { - this.symPoly1.setAttribute('points', - this._reflX(points1.x2, points1.y2) + ' ' + this._reflY(points1.x2, points1.y2) + ',' + - this._reflX(points1.x2, points1.y2) + ' ' + this._reflY(points1.x1, points1.y1) + ',' + - this._reflX(points1.x1, points1.y1) + ' ' + this._reflY(points1.x1, points1.y1) + ',' + - this._reflX(points1.x1, points1.y1) + ' ' + this._reflY(points1.x2, points1.y2) - ) - - this.symPoly2.setAttribute('points', - this._reflX(points2.x2, points2.y2) + ' ' + this._reflY(points2.x2, points2.y2) + ',' + - this._reflX(points2.x2, points2.y2) + ' ' + this._reflY(points2.x1, points2.y1) + ',' + - this._reflX(points2.x1, points2.y1) + ' ' + this._reflY(points2.x1, points2.y1) + ',' + - this._reflX(points2.x1, points2.y1) + ' ' + this._reflY(points2.x2, points2.y2) - ) - - this.symCirc.setAttribute('opacity', this.circ.getAttribute('opacity')) - this.symPoly2.setAttribute('opacity', this.poly2.getAttribute('opacity')) - } - } - - _reflX(x,y) { - if (data.puzzle.symType == SYM_TYPE_NONE) return x - - if (data.puzzle.symType == SYM_TYPE_VERTICAL || data.puzzle.symType == SYM_TYPE_ROTATIONAL || data.puzzle.symType == SYM_TYPE_PARALLEL_H_FLIP) { - // Mirror position inside the bounding box - return (data.bbox.middle.x - x) + data.symbbox.middle.x - } - if (data.puzzle.symType == SYM_TYPE_HORIZONTAL || data.puzzle.symType == SYM_TYPE_PARALLEL_H || data.puzzle.symType == SYM_TYPE_PARALLEL_V || data.puzzle.symType == SYM_TYPE_PARALLEL_V_FLIP) { - // Copy position inside the bounding box - return (x - data.bbox.middle.x) + data.symbbox.middle.x - } - if (data.puzzle.symType == SYM_TYPE_ROTATE_LEFT || data.puzzle.symType == SYM_TYPE_FLIP_XY) { - // Rotate position left inside the bounding box - return (y - data.bbox.middle.y) + data.symbbox.middle.x - } - if (data.puzzle.symType == SYM_TYPE_ROTATE_RIGHT || data.puzzle.symType == SYM_TYPE_FLIP_NEG_XY) { - // Rotate position right inside the bounding box - return (data.bbox.middle.y - y) + data.symbbox.middle.x - } - } - - _reflY(x,y) { - if (data.puzzle.symType == SYM_TYPE_NONE) return y - - if (data.puzzle.symType == SYM_TYPE_HORIZONTAL || data.puzzle.symType == SYM_TYPE_ROTATIONAL || data.puzzle.symType == SYM_TYPE_PARALLEL_V_FLIP) { - // Mirror position inside the bounding box - return (data.bbox.middle.y - y) + data.symbbox.middle.y - } - if (data.puzzle.symType == SYM_TYPE_VERTICAL || data.puzzle.symType == SYM_TYPE_PARALLEL_V || data.puzzle.symType == SYM_TYPE_PARALLEL_H || data.puzzle.symType == SYM_TYPE_PARALLEL_H_FLIP) { - // Copy position inside the bounding box - return (y - data.bbox.middle.y) + data.symbbox.middle.y - } - if (data.puzzle.symType == SYM_TYPE_ROTATE_RIGHT || data.puzzle.symType == SYM_TYPE_FLIP_XY) { - // Rotate position left inside the bounding box - return (x - data.bbox.middle.x) + data.symbbox.middle.y - } - if (data.puzzle.symType == SYM_TYPE_ROTATE_LEFT || data.puzzle.symType == SYM_TYPE_FLIP_NEG_XY) { - // Rotate position right inside the bounding box - return (data.bbox.middle.x - x) + data.symbbox.middle.y - } - } -} - -var data = {} - -function clearGrid(svg, puzzle) { - if (data.bbox != null && data.bbox.debug != null) { - data.svg.removeChild(data.bbox.debug) - data.bbox = null - } - if (data.symbbox != null && data.symbbox.debug != null) { - data.svg.removeChild(data.symbbox.debug) - data.symbbox = null - } - - window.deleteElementsByClassName(svg, 'cursor') - window.deleteElementsByClassName(svg, 'line-1') - window.deleteElementsByClassName(svg, 'line-2') - window.deleteElementsByClassName(svg, 'line-3') - window.deleteElementsByClassName(svg, 'line-4') - puzzle.clearLines() -} - -// This copy is an exact copy of puzzle.getSymmetricalDir, except that it uses MOVE_* values instead of strings -function getSymmetricalDir(puzzle, dir) { - if (puzzle.symType == SYM_TYPE_VERTICAL || puzzle.symType == SYM_TYPE_ROTATIONAL || puzzle.symType == SYM_TYPE_PARALLEL_H_FLIP) { - if (dir === MOVE_LEFT) return MOVE_RIGHT - if (dir === MOVE_RIGHT) return MOVE_LEFT - } - if (puzzle.symType == SYM_TYPE_HORIZONTAL || puzzle.symType == SYM_TYPE_ROTATIONAL || puzzle.symType == SYM_TYPE_PARALLEL_V_FLIP) { - if (dir === MOVE_TOP) return MOVE_BOTTOM - if (dir === MOVE_BOTTOM) return MOVE_TOP - } - if (puzzle.symType == SYM_TYPE_ROTATE_LEFT || puzzle.symType == SYM_TYPE_FLIP_NEG_XY) { - if (dir === MOVE_LEFT) return MOVE_BOTTOM - if (dir === MOVE_RIGHT) return MOVE_TOP - } - if (puzzle.symType == SYM_TYPE_ROTATE_RIGHT || puzzle.symType == SYM_TYPE_FLIP_NEG_XY) { - if (dir === MOVE_TOP) return MOVE_RIGHT - if (dir === MOVE_BOTTOM) return MOVE_LEFT - } - if (puzzle.symType == SYM_TYPE_ROTATE_LEFT || puzzle.symType == SYM_TYPE_FLIP_XY) { - if (dir === MOVE_TOP) return MOVE_LEFT - if (dir === MOVE_BOTTOM) return MOVE_RIGHT - } - if (puzzle.symType == SYM_TYPE_ROTATE_RIGHT || puzzle.symType == SYM_TYPE_FLIP_XY) { - if (dir === MOVE_RIGHT) return MOVE_BOTTOM - if (dir === MOVE_LEFT) return MOVE_TOP - } - return dir -} - -window.trace = function(event, puzzle, pos, start, symStart=null) { - /*if (data.start == null) {*/ - if (data.tracing !== true) { // could be undefined or false - var svg = start.parentElement - data.tracing = true - window.PLAY_SOUND('start') - // Cleans drawn lines & puzzle state - clearGrid(svg, puzzle) - onTraceStart(puzzle, pos, svg, start, symStart) - data.animations.insertRule('.' + svg.id + '.start {animation: 150ms 1 forwards start-grow}\n') - - hookMovementEvents(start) - } else { - event.stopPropagation() - // Signal the onMouseMove to stop accepting input (race condition) - data.tracing = false - - // At endpoint and in main box - var cell = puzzle.getCell(data.pos.x, data.pos.y) - if (cell.end != null && data.bbox.inMain(data.x, data.y)) { - data.cursor.onpointerdown = null - setTimeout(function() { // Run validation asynchronously so we can free the pointer immediately. - puzzle.endPoint = data.pos - var puzzleData = window.validate(puzzle, false) // We want all invalid elements so we can show the user. - - for (var negation of puzzleData.negations) { - console.debug('Rendering negation', negation) - data.animations.insertRule('.' + data.svg.id + '_' + negation.source.x + '_' + negation.source.y + ' {animation: 0.75s 1 forwards fade}\n') - data.animations.insertRule('.' + data.svg.id + '_' + negation.target.x + '_' + negation.target.y + ' {animation: 0.75s 1 forwards fade}\n') - } - - if (puzzleData.valid()) { - window.PLAY_SOUND('success') - // !important to override the child animation - data.animations.insertRule('.' + data.svg.id + ' {animation: 1s 1 forwards line-success !important}\n') - - // Convert the traced path into something suitable for solve.drawPath (for publishing purposes) - var rawPath = [puzzle.startPoint] - for (var i=1; i 1) { - // Stop tracing for two+ finger touches (the equivalent of a right click on desktop) - window.trace(event, data.puzzle, null, null, null) - return - } - data.lastTouchPos = event.position - } - document.ontouchmove = function(event) { - if (data.tracing !== true) return - - var eventIsWithinPuzzle = false - for (var node = event.target; node != null; node = node.parentElement) { - if (node == data.svg) { - eventIsWithinPuzzle = true - break - } - } - if (!eventIsWithinPuzzle) return // Ignore drag events that aren't within the puzzle - event.preventDefault() // Prevent accidental scrolling if the touch event is within the puzzle. - - var newPos = event.position - onMove(newPos.x - data.lastTouchPos.x, newPos.y - data.lastTouchPos.y) - data.lastTouchPos = newPos - } - document.ontouchend = function(event) { - data.lastTouchPos = null - // Only call window.trace (to stop tracing) if we're really in an endpoint. - var cell = data.puzzle.getCell(data.pos.x, data.pos.y) - if (cell.end != null && data.bbox.inMain(data.x, data.y)) { - window.trace(event, data.puzzle, null, null, null) - } - } -} - -// @Volatile -- must match order of PATH_* in solve -var MOVE_NONE = 0 -var MOVE_LEFT = 1 -var MOVE_RIGHT = 2 -var MOVE_TOP = 3 -var MOVE_BOTTOM = 4 - -window.onMove = function(dx, dy) { - { - // Also handles some collision - var collidedWith = pushCursor(dx, dy) - console.spam('Collided with', collidedWith) - } - - while (true) { - hardCollision() - - // Potentially move the location to a new cell, and make absolute boundary checks - var moveDir = move() - data.path[data.path.length - 1].redraw() - if (moveDir === MOVE_NONE) break - console.debug('Moved', ['none', 'left', 'right', 'top', 'bottom'][moveDir]) - - // Potentially adjust data.x/data.y if our position went around a pillar - if (data.puzzle.pillar === true) pillarWrap(moveDir) - - var lastDir = data.path[data.path.length - 1].dir - var backedUp = ((moveDir === MOVE_LEFT && lastDir === MOVE_RIGHT) - || (moveDir === MOVE_RIGHT && lastDir === MOVE_LEFT) - || (moveDir === MOVE_TOP && lastDir === MOVE_BOTTOM) - || (moveDir === MOVE_BOTTOM && lastDir === MOVE_TOP)) - - if (data.puzzle.symType != SYM_TYPE_NONE) { - var symMoveDir = getSymmetricalDir(data.puzzle, moveDir) - } - - // If we backed up, remove a path segment and mark the old cell as unvisited - if (backedUp) { - data.path.pop().destroy() - data.puzzle.updateCell2(data.pos.x, data.pos.y, 'line', window.LINE_NONE) - if (data.puzzle.symType != SYM_TYPE_NONE) { - if (data.puzzle.getLine(data.sym.x, data.sym.y) == window.LINE_OVERLAP) { - data.puzzle.updateCell2(data.sym.x, data.sym.y, 'line', window.LINE_BLUE) - } else { - data.puzzle.updateCell2(data.sym.x, data.sym.y, 'line', window.LINE_NONE) - } - } - } - - // Move to the next cell - changePos(data.bbox, data.pos, moveDir) - if (data.puzzle.symType != SYM_TYPE_NONE) { - changePos(data.symbbox, data.sym, symMoveDir) - } - - // If we didn't back up, add a path segment and mark the new cell as visited - if (!backedUp) { - data.path.push(new PathSegment(moveDir)) - if (data.puzzle.symType == SYM_TYPE_NONE) { - data.puzzle.updateCell2(data.pos.x, data.pos.y, 'line', window.LINE_BLACK) - } else { - data.puzzle.updateCell2(data.pos.x, data.pos.y, 'line', window.LINE_BLUE) - if (data.puzzle.getLine(data.sym.x, data.sym.y) == window.LINE_BLUE) { - data.puzzle.updateCell2(data.sym.x, data.sym.y, 'line', window.LINE_OVERLAP) - } else { - data.puzzle.updateCell2(data.sym.x, data.sym.y, 'line', window.LINE_YELLOW) - } - } - } - } -} - -// Helper function for pushCursor. Used to determine the direction and magnitude of redirection. -function push(dx, dy, dir, targetDir) { - // Fraction of movement to redirect in the other direction - var movementRatio = null - if (targetDir === 'left' || targetDir === 'top') { - movementRatio = -3 - } else if (targetDir === 'right' || targetDir === 'bottom') { - movementRatio = 3 - } - if (window.settings.disablePushing === true) movementRatio *= 1000 - - if (dir === 'left') { - var overshoot = data.bbox.x1 - (data.x + dx) + 12 - if (overshoot > 0) { - data.y += dy + overshoot / movementRatio - data.x = data.bbox.x1 + 12 - return true - } - } else if (dir === 'right') { - var overshoot = (data.x + dx) - data.bbox.x2 + 12 - if (overshoot > 0) { - data.y += dy + overshoot / movementRatio - data.x = data.bbox.x2 - 12 - return true - } - } else if (dir === 'leftright') { - data.y += dy + Math.abs(dx) / movementRatio - return true - } else if (dir === 'top') { - var overshoot = data.bbox.y1 - (data.y + dy) + 12 - if (overshoot > 0) { - data.x += dx + overshoot / movementRatio - data.y = data.bbox.y1 + 12 - return true - } - } else if (dir === 'bottom') { - var overshoot = (data.y + dy) - data.bbox.y2 + 12 - if (overshoot > 0) { - data.x += dx + overshoot / movementRatio - data.y = data.bbox.y2 - 12 - return true - } - } else if (dir === 'topbottom') { - data.x += dx + Math.abs(dy) / movementRatio - return true - } - return false -} - -// Redirect momentum from pushing against walls, so that all further moment steps -// will be strictly linear. Returns a string for logging purposes only. -function pushCursor(dx, dy) { - // Outer wall collision - var cell = data.puzzle.getCell(data.pos.x, data.pos.y) - if (cell == null) return 'nothing' - - // Only consider non-endpoints or endpoints which are parallel - if ([undefined, 'top', 'bottom'].includes(cell.end)) { - var leftCell = data.puzzle.getCell(data.pos.x - 1, data.pos.y) - if (leftCell == null || leftCell.gap === window.GAP_FULL) { - if (push(dx, dy, 'left', 'top')) return 'left outer wall' - } - var rightCell = data.puzzle.getCell(data.pos.x + 1, data.pos.y) - if (rightCell == null || rightCell.gap === window.GAP_FULL) { - if (push(dx, dy, 'right', 'top')) return 'right outer wall' - } - } - // Only consider non-endpoints or endpoints which are parallel - if ([undefined, 'left', 'right'].includes(cell.end)) { - var topCell = data.puzzle.getCell(data.pos.x, data.pos.y - 1) - if (topCell == null || topCell.gap === window.GAP_FULL) { - if (push(dx, dy, 'top', 'right')) return 'top outer wall' - } - var bottomCell = data.puzzle.getCell(data.pos.x, data.pos.y + 1) - if (bottomCell == null || bottomCell.gap === window.GAP_FULL) { - if (push(dx, dy, 'bottom', 'right')) return 'bottom outer wall' - } - } - - // Inner wall collision - if (cell.end == null) { - if (data.pos.x%2 === 1 && data.pos.y%2 === 0) { // Horizontal cell - if (data.x < data.bbox.middle.x) { - push(dx, dy, 'topbottom', 'left') - return 'topbottom inner wall, moved left' - } else { - push(dx, dy, 'topbottom', 'right') - return 'topbottom inner wall, moved right' - } - } else if (data.pos.x%2 === 0 && data.pos.y%2 === 1) { // Vertical cell - if (data.y < data.bbox.middle.y) { - push(dx, dy, 'leftright', 'top') - return 'leftright inner wall, moved up' - } else { - push(dx, dy, 'leftright', 'bottom') - return 'leftright inner wall, moved down' - } - } - } - - // Intersection & endpoint collision - // Ratio of movement to be considered turning at an intersection - var turnMod = 2 - if ((data.pos.x%2 === 0 && data.pos.y%2 === 0) || cell.end != null) { - if (data.x < data.bbox.middle.x) { - push(dx, dy, 'topbottom', 'right') - // Overshot the intersection and appears to be trying to turn - if (data.x > data.bbox.middle.x && Math.abs(dy) * turnMod > Math.abs(dx)) { - data.y += Math.sign(dy) * (data.x - data.bbox.middle.x) - data.x = data.bbox.middle.x - return 'overshot moving right' - } - return 'intersection moving right' - } else if (data.x > data.bbox.middle.x) { - push(dx, dy, 'topbottom', 'left') - // Overshot the intersection and appears to be trying to turn - if (data.x < data.bbox.middle.x && Math.abs(dy) * turnMod > Math.abs(dx)) { - data.y += Math.sign(dy) * (data.bbox.middle.x - data.x) - data.x = data.bbox.middle.x - return 'overshot moving left' - } - return 'intersection moving left' - } - if (data.y < data.bbox.middle.y) { - push(dx, dy, 'leftright', 'bottom') - // Overshot the intersection and appears to be trying to turn - if (data.y > data.bbox.middle.y && Math.abs(dx) * turnMod > Math.abs(dy)) { - data.x += Math.sign(dx) * (data.y - data.bbox.middle.y) - data.y = data.bbox.middle.y - return 'overshot moving down' - } - return 'intersection moving down' - } else if (data.y > data.bbox.middle.y) { - push(dx, dy, 'leftright', 'top') - // Overshot the intersection and appears to be trying to turn - if (data.y < data.bbox.middle.y && Math.abs(dx) * turnMod > Math.abs(dy)) { - data.x += Math.sign(dx) * (data.bbox.middle.y - data.y) - data.y = data.bbox.middle.y - return 'overshot moving up' - } - return 'intersection moving up' - } - } - - // No collision, limit movement to X or Y only to prevent out-of-bounds - if (Math.abs(dx) > Math.abs(dy)) { - data.x += dx - return 'nothing, x' - } else { - data.y += dy - return 'nothing, y' - } -} - -// Check to see if we collided with any gaps, or with a symmetrical line, or a startpoint. -// In any case, abruptly zero momentum. -function hardCollision() { - var lastDir = data.path[data.path.length - 1].dir - var cell = data.puzzle.getCell(data.pos.x, data.pos.y) - if (cell == null) return - - var gapSize = 0 - if (cell.gap === window.GAP_BREAK) { - console.spam('Collided with a gap') - gapSize = 21 - } else { - var nextCell = null - if (lastDir === MOVE_LEFT) nextCell = data.puzzle.getCell(data.pos.x - 1, data.pos.y) - if (lastDir === MOVE_RIGHT) nextCell = data.puzzle.getCell(data.pos.x + 1, data.pos.y) - if (lastDir === MOVE_TOP) nextCell = data.puzzle.getCell(data.pos.x, data.pos.y - 1) - if (lastDir === MOVE_BOTTOM) nextCell = data.puzzle.getCell(data.pos.x, data.pos.y + 1) - if (nextCell != null && nextCell.start === true && nextCell.line > window.LINE_NONE) { - gapSize = -5 - } - } - - if (data.puzzle.symType != SYM_TYPE_NONE) { - if (data.sym.x === data.pos.x && data.sym.y === data.pos.y) { - console.spam('Collided with our symmetrical line') - gapSize = 13 - } else if (data.puzzle.getCell(data.sym.x, data.sym.y).gap === window.GAP_BREAK) { - console.spam('Symmetrical line hit a gap') - gapSize = 21 - } - } - if (gapSize === 0) return // Didn't collide with anything - - if (lastDir === MOVE_LEFT) { - data.x = Math.max(data.bbox.middle.x + gapSize, data.x) - } else if (lastDir === MOVE_RIGHT) { - data.x = Math.min(data.x, data.bbox.middle.x - gapSize) - } else if (lastDir === MOVE_TOP) { - data.y = Math.max(data.bbox.middle.y + gapSize, data.y) - } else if (lastDir === MOVE_BOTTOM) { - data.y = Math.min(data.y, data.bbox.middle.y - gapSize) - } -} - -// Check to see if we've gone beyond the edge of puzzle cell, and if the next cell is safe, -// i.e. not out of bounds. Reports the direction we are going to move (or none), -// but does not actually change data.pos -function move() { - var lastDir = data.path[data.path.length - 1].dir - - if (data.x < data.bbox.x1 + 12) { // Moving left - var cell = data.puzzle.getCell(data.pos.x - 1, data.pos.y) - if (cell == null || cell.type !== 'line' || cell.gap === window.GAP_FULL) { - console.spam('Collided with outside / gap-2', cell) - data.x = data.bbox.x1 + 12 - } else if (cell.line > window.LINE_NONE && lastDir !== MOVE_RIGHT) { - console.spam('Collided with other line', cell.line) - data.x = data.bbox.x1 + 12 - } else if (data.puzzle.symType != SYM_TYPE_NONE) { - var symCell = data.puzzle.getSymmetricalCell(data.pos.x - 1, data.pos.y) - if (symCell == null || symCell.type !== 'line' || symCell.gap === window.GAP_FULL) { - console.spam('Collided with symmetrical outside / gap-2', cell) - data.x = data.bbox.x1 + 12 - } - } - if (data.x < data.bbox.x1) { - return MOVE_LEFT - } - } else if (data.x > data.bbox.x2 - 12) { // Moving right - var cell = data.puzzle.getCell(data.pos.x + 1, data.pos.y) - if (cell == null || cell.type !== 'line' || cell.gap === window.GAP_FULL) { - console.spam('Collided with outside / gap-2', cell) - data.x = data.bbox.x2 - 12 - } else if (cell.line > window.LINE_NONE && lastDir !== MOVE_LEFT) { - console.spam('Collided with other line', cell.line) - data.x = data.bbox.x2 - 12 - } else if (data.puzzle.symType != SYM_TYPE_NONE) { - var symCell = data.puzzle.getSymmetricalCell(data.pos.x + 1, data.pos.y) - if (symCell == null || symCell.type !== 'line' || symCell.gap === window.GAP_FULL) { - console.spam('Collided with symmetrical outside / gap-2', cell) - data.x = data.bbox.x2 - 12 - } - } - if (data.x > data.bbox.x2) { - return MOVE_RIGHT - } - } else if (data.y < data.bbox.y1 + 12) { // Moving up - var cell = data.puzzle.getCell(data.pos.x, data.pos.y - 1) - if (cell == null || cell.type !== 'line' || cell.gap === window.GAP_FULL) { - console.spam('Collided with outside / gap-2', cell) - data.y = data.bbox.y1 + 12 - } else if (cell.line > window.LINE_NONE && lastDir !== MOVE_BOTTOM) { - console.spam('Collided with other line', cell.line) - data.y = data.bbox.y1 + 12 - } else if (data.puzzle.symType != SYM_TYPE_NONE) { - var symCell = data.puzzle.getSymmetricalCell(data.pos.x, data.pos.y - 1) - if (symCell == null || symCell.type !== 'line' || symCell.gap === window.GAP_FULL) { - console.spam('Collided with symmetrical outside / gap-2', cell) - data.y = data.bbox.y1 + 12 - } - } - if (data.y < data.bbox.y1) { - return MOVE_TOP - } - } else if (data.y > data.bbox.y2 - 12) { // Moving down - var cell = data.puzzle.getCell(data.pos.x, data.pos.y + 1) - if (cell == null || cell.type !== 'line' || cell.gap === window.GAP_FULL) { - console.spam('Collided with outside / gap-2') - data.y = data.bbox.y2 - 12 - } else if (cell.line > window.LINE_NONE && lastDir !== MOVE_TOP) { - console.spam('Collided with other line', cell.line) - data.y = data.bbox.y2 - 12 - } else if (data.puzzle.symType != SYM_TYPE_NONE) { - var symCell = data.puzzle.getSymmetricalCell(data.pos.x, data.pos.y + 1) - if (symCell == null || symCell.type !== 'line' || symCell.gap === window.GAP_FULL) { - console.spam('Collided with symmetrical outside / gap-2', cell) - data.y = data.bbox.y2 - 12 - } - } - if (data.y > data.bbox.y2) { - return MOVE_BOTTOM - } - } - return MOVE_NONE -} - -// Check to see if you moved beyond the edge of a pillar. -// If so, wrap the cursor x to preserve momentum. -// Note that this still does not change the position. -function pillarWrap(moveDir) { - if (moveDir === MOVE_LEFT && data.pos.x === 0) { - data.x += data.puzzle.width * 41 - } - if (moveDir === MOVE_RIGHT && data.pos.x === data.puzzle.width - 1) { - data.x -= data.puzzle.width * 41 - } -} - -// Actually change the data position. (Note that this takes in pos to allow easier symmetry). -// Note that this doesn't zero the momentum, so that we can adjust appropriately on further loops. -// This function also shifts the bounding box that we use to determine the bounds of the cell. -function changePos(bbox, pos, moveDir) { - if (moveDir === MOVE_LEFT) { - pos.x-- - // Wrap around the left - if (data.puzzle.pillar === true && pos.x < 0) { - pos.x += data.puzzle.width - bbox.shift('right', data.puzzle.width * 41 - 82) - bbox.shift('right', 58) - } else { - bbox.shift('left', (pos.x%2 === 0 ? 24 : 58)) - } - } else if (moveDir === MOVE_RIGHT) { - pos.x++ - // Wrap around to the right - if (data.puzzle.pillar === true && pos.x >= data.puzzle.width) { - pos.x -= data.puzzle.width - bbox.shift('left', data.puzzle.width * 41 - 82) - bbox.shift('left', 24) - } else { - bbox.shift('right', (pos.x%2 === 0 ? 24 : 58)) - } - } else if (moveDir === MOVE_TOP) { - pos.y-- - bbox.shift('top', (pos.y%2 === 0 ? 24 : 58)) - } else if (moveDir === MOVE_BOTTOM) { - pos.y++ - bbox.shift('bottom', (pos.y%2 === 0 ? 24 : 58)) - } -} - -}) diff --git a/app/assets/javascripts/wittle/utilities.js.erb b/app/assets/javascripts/wittle/utilities.js.erb deleted file mode 100644 index b621003..0000000 --- a/app/assets/javascripts/wittle/utilities.js.erb +++ /dev/null @@ -1,498 +0,0 @@ -function namespace(code) { - code() -} - -namespace(function() { - -/*** Start cross-compatibility ***/ -// Used to detect if IDs include a direction, e.g. resize-top-left -if (!String.prototype.includes) { - String.prototype.includes = function() { - return String.prototype.indexOf.apply(this, arguments) !== -1 - } -} -Event.prototype.movementX = Event.prototype.movementX || Event.prototype.mozMovementX -Event.prototype.movementY = Event.prototype.movementY || Event.prototype.mozMovementY -Event.prototype.isRightClick = function() { - return this.which === 3 || (this.touches && this.touches.length > 1) -} -Element.prototype.disable = function() { - this.disabled = true - this.style.pointerEvents = 'none' - this.className = 'noselect' -} -Element.prototype.enable = function() { - this.disabled = false - this.style.pointerEvents = null - this.className = null -} -Object.defineProperty(Event.prototype, 'position', { - 'get': function() { - return { - 'x': event.pageX || event.clientX || (event.touches && event.touches[0].pageX) || null, - 'y': event.pageY || event.clientY || (event.touches && event.touches[0].pageY) || null, - } - } -}) -/*** End cross-compatibility ***/ - -var proxy = { - 'get': function(_, key) { - try { - return this._map[key] - } catch (e) { - return null - } - }, - 'set': function(_, key, value) { - if (value == null) { - delete this._map[key] - } else { - this._map[key] = value.toString() - window.localStorage.setItem('settings', JSON.stringify(this._map)) - } - }, - 'init': function() { - this._map = {} - try { - var j = window.localStorage.getItem('settings') - if (j != null) this._map = JSON.parse(j) - } catch (e) {/* Do nothing */} - - function setIfNull(map, key, value) { - if (map[key] == null) map[key] = value - } - - // Set any values which are not defined - setIfNull(this._map, 'theme', 'light') - setIfNull(this._map, 'volume', '0.12') - setIfNull(this._map, 'sensitivity', '0.7') - setIfNull(this._map, 'expanded', 'false') - setIfNull(this._map, 'customMechanics', 'false') - return this - }, -} -window.settings = new Proxy({}, proxy.init()) - -var tracks = { - 'start': new Audio(src = '<%= asset_url("wittle/panel_start_tracing.aac") %>'), - 'success': new Audio(src = '<%= asset_url("wittle/panel_success.aac") %>'), - 'fail': new Audio(src = '<%= asset_url("wittle/panel_failure.aac") %>'), - 'abort': new Audio(src = '<%= asset_url("wittle/panel_abort_tracing.aac") %>'), -} - -var currentAudio = null -window.PLAY_SOUND = function(name) { - if (currentAudio) currentAudio.pause() - var audio = tracks[name] - audio.load() - audio.volume = parseFloat(window.settings.volume) - audio.play().then(function() { - currentAudio = audio - }).catch(function() { - // Do nothing. - }) -} - -window.LINE_PRIMARY = '#8FF' -window.LINE_SECONDARY = '#FF2' - -if (window.settings.theme == 'night') { - window.BACKGROUND = '#221' - window.OUTER_BACKGROUND = '#070704' - window.FOREGROUND = '#751' - window.BORDER = '#666' - window.LINE_DEFAULT = '#888' - window.LINE_SUCCESS = '#BBB' - window.LINE_FAIL = '#000' - window.CURSOR = '#FFF' - window.TEXT_COLOR = '#AAA' - window.PAGE_BACKGROUND = '#000' - window.ALT_BACKGROUND = '#333' // An off-black. Good for mild contrast. - window.ACTIVE_COLOR = '#555' // Color for 'while the element is being pressed' -} else if (window.settings.theme == 'light') { - window.BACKGROUND = '#0A8' - window.OUTER_BACKGROUND = '#113833' - window.FOREGROUND = '#344' - window.BORDER = '#000' - window.LINE_DEFAULT = '#AAA' - window.LINE_SUCCESS = '#FFF' - window.LINE_FAIL = '#000' - window.CURSOR = '#FFF' - window.TEXT_COLOR = '#000' - window.PAGE_BACKGROUND = '#FFF' - window.ALT_BACKGROUND = '#EEE' // An off-white. Good for mild contrast. - window.ACTIVE_COLOR = '#DDD' // Color for 'while the element is being pressed' -} - -window.LINE_NONE = 0 -window.LINE_BLACK = 1 -window.LINE_BLUE = 2 -window.LINE_YELLOW = 3 -window.LINE_OVERLAP = 4 -window.DOT_NONE = 0 -window.DOT_BLACK = 1 -window.DOT_BLUE = 2 -window.DOT_YELLOW = 3 -window.DOT_INVISIBLE = 4 -window.GAP_NONE = 0 -window.GAP_BREAK = 1 -window.GAP_FULL = 2 - -var animations = '' -var l = function(line) {animations += line + '\n'} -// pointer-events: none; allows for events to bubble up (so that editor hooks still work) -l('.line-1 {') -l(' fill: ' + window.LINE_DEFAULT + ';') -l(' pointer-events: none;') -l('}') -l('.line-2 {') -l(' fill: ' + window.LINE_PRIMARY + ';') -l(' pointer-events: none;') -l('}') -l('.line-3 {') -l(' fill: ' + window.LINE_SECONDARY + ';') -l(' pointer-events: none;') -l('}') -l('.line-4 {') -l(' display: none;') -l(' pointer-events: none;') -l('}') -l('@keyframes line-success {to {fill: ' + window.LINE_SUCCESS + ';}}') -l('@keyframes line-fail {to {fill: ' + window.LINE_FAIL + ';}}') -l('@keyframes error {to {fill: red;}}') -l('@keyframes fade {to {opacity: 0.35;}}') -l('@keyframes start-grow {from {r:12;} to {r:24;}}') -// Neutral button style -l('button {') -l(' background-color: ' + window.ALT_BACKGROUND + ';') -l(' border: 1px solid ' + window.BORDER + ';') -l(' border-radius: 2px;') -l(' color: ' + window.TEXT_COLOR + ';') -l(' display: inline-block;') -l(' margin: 0px;') -l(' outline: none;') -l(' opacity: 1.0;') -l(' padding: 1px 6px;') -l(' -moz-appearance: none;') -l(' -webkit-appearance: none;') -l('}') -// Active (while held down) button style -l('button:active {background-color: ' + window.ACTIVE_COLOR + ';}') -// Disabled button style -l('button:disabled {opacity: 0.5;}') -// Selected button style (see https://stackoverflow.com/a/63108630) -l('button:focus {outline: none;}') -l = null - -var style = document.createElement('style') -style.type = 'text/css' -style.title = 'animations' -style.appendChild(document.createTextNode(animations)) -document.head.appendChild(style) - -// Custom logging to allow leveling -var consoleError = console.error -var consoleWarn = console.warn -var consoleInfo = console.log -var consoleLog = console.log -var consoleDebug = console.log -var consoleSpam = console.log -var consoleGroup = console.group -var consoleGroupEnd = console.groupEnd - -window.setLogLevel = function(level) { - console.error = function() {} - console.warn = function() {} - console.info = function() {} - console.log = function() {} - console.debug = function() {} - console.spam = function() {} - console.group = function() {} - console.groupEnd = function() {} - - if (level === 'none') return - - // Instead of throw, but still red flags and is easy to find - console.error = consoleError - if (level === 'error') return - - // Less serious than error, but flagged nonetheless - console.warn = consoleWarn - if (level === 'warn') return - - // Default visible, important information - console.info = consoleInfo - if (level === 'info') return - - // Useful for debugging (mainly validation) - console.log = consoleLog - if (level === 'log') return - - // Useful for serious debugging (mainly graphics/misc) - console.debug = consoleDebug - if (level === 'debug') return - - // Useful for insane debugging (mainly tracing/recursion) - console.spam = consoleSpam - console.group = consoleGroup - console.groupEnd = consoleGroupEnd - if (level === 'spam') return -} -setLogLevel('info') - -window.deleteElementsByClassName = function(rootElem, className) { - var elems = [] - while (true) { - elems = rootElem.getElementsByClassName(className) - if (elems.length === 0) break - elems[0].remove() - } -} - -// Automatically solve the puzzle -window.solvePuzzle = function() { - if (window.setSolveMode) window.setSolveMode(false) - document.getElementById('solutionViewer').style.display = 'none' - document.getElementById('progressBox').style.display = null - document.getElementById('solveAuto').innerText = 'Cancel Solving' - document.getElementById('solveAuto').onpointerdown = function() { - this.innerText = 'Cancelling...' - this.onpointerdown = null - window.setTimeout(window.cancelSolving, 0) - } - - window.solve(window.puzzle, function(percent) { - document.getElementById('progressPercent').innerText = percent + '%' - document.getElementById('progress').style.width = percent + '%' - }, function(paths) { - document.getElementById('progressBox').style.display = 'none' - document.getElementById('solutionViewer').style.display = null - document.getElementById('progressPercent').innerText = '0%' - document.getElementById('progress').style.width = '0%' - document.getElementById('solveAuto').innerText = 'Solve (automatically)' - document.getElementById('solveAuto').onpointerdown = solvePuzzle - - window.puzzle.autoSolved = true - paths = window.onSolvedPuzzle(paths) - window.showSolution(window.puzzle, paths, 0) - }) -} - -window.showSolution = function(puzzle, paths, num, suffix) { - if (suffix == null) { - var previousSolution = document.getElementById('previousSolution') - var solutionCount = document.getElementById('solutionCount') - var nextSolution = document.getElementById('nextSolution') - } else if (suffix instanceof Array) { - var previousSolution = document.getElementById('previousSolution-' + suffix[0]) - var solutionCount = document.getElementById('solutionCount-' + suffix[0]) - var nextSolution = document.getElementById('nextSolution-' + suffix[0]) - } else { - var previousSolution = document.getElementById('previousSolution-' + suffix) - var solutionCount = document.getElementById('solutionCount-' + suffix) - var nextSolution = document.getElementById('nextSolution-' + suffix) - } - - if (paths.length === 0) { // 0 paths, arrows are useless - solutionCount.innerText = '0 of 0' - previousSolution.disable() - nextSolution.disable() - return - } - - while (num < 0) num = paths.length + num - while (num >= paths.length) num = num - paths.length - - if (paths.length === 1) { // 1 path, arrows are useless - solutionCount.innerText = '1 of 1' - if (paths.length >= window.MAX_SOLUTIONS) solutionCount.innerText += '+' - previousSolution.disable() - nextSolution.disable() - } else { - solutionCount.innerText = (num + 1) + ' of ' + paths.length - if (paths.length >= window.MAX_SOLUTIONS) solutionCount.innerText += '+' - previousSolution.enable() - nextSolution.enable() - previousSolution.onpointerdown = function(event) { - if (event.shiftKey) { - window.showSolution(puzzle, paths, num - 10, suffix) - } else { - window.showSolution(puzzle, paths, num - 1, suffix) - } - } - nextSolution.onpointerdown = function(event) { - if (event.shiftKey) { - window.showSolution(puzzle, paths, num + 10, suffix) - } else { - window.showSolution(puzzle, paths, num + 1, suffix) - } - } - } - - if (paths[num] != null) { - if (puzzle instanceof Array) { // Special case for multiple related panels - for (var i = 0; i < puzzle.length; i++) { - // Save the current path on the puzzle object (so that we can pass it along with publishing) - puzzle.path = paths[num][i] - // Draws the given path, and also updates the puzzle to have path annotations on it. - window.drawPath(puzzle[i], paths[num][i], suffix[i]) - } - } else { // Default case for a single panel - // Save the current path on the puzzle object (so that we can pass it along with publishing) - puzzle.path = paths[num] - // Draws the given path, and also updates the puzzle to have path annotations on it. - window.drawPath(puzzle, paths[num], suffix) - } - } -} - -window.createCheckbox = function() { - var checkbox = document.createElement('div') - checkbox.style.width = '22px' - checkbox.style.height = '22px' - checkbox.style.borderRadius = '6px' - checkbox.style.display = 'inline-block' - checkbox.style.verticalAlign = 'text-bottom' - checkbox.style.marginRight = '6px' - checkbox.style.borderWidth = '1.5px' - checkbox.style.borderStyle = 'solid' - checkbox.style.borderColor = window.BORDER - checkbox.style.background = window.PAGE_BACKGROUND - checkbox.style.color = window.TEXT_COLOR - return checkbox -} - -// Required global variables/functions: <-- HINT: This means you're writing bad code. -// window.puzzle -// window.onSolvedPuzzle() -// window.MAX_SOLUTIONS // defined by solve.js -window.addSolveButtons = function() { - var parent = document.currentScript.parentElement - - var solveMode = createCheckbox() - solveMode.id = 'solveMode' - parent.appendChild(solveMode) - - solveMode.onpointerdown = function() { - this.checked = !this.checked - this.style.background = (this.checked ? window.BORDER : window.PAGE_BACKGROUND) - document.getElementById('solutionViewer').style.display = 'none' - if (window.setSolveMode) window.setSolveMode(this.checked) - } - - var solveManual = document.createElement('label') - parent.appendChild(solveManual) - solveManual.id = 'solveManual' - solveManual.onpointerdown = function() {solveMode.onpointerdown()} - solveManual.innerText = 'Solve (manually)' - solveManual.style = 'margin-right: 8px' - - var solveAuto = document.createElement('button') - parent.appendChild(solveAuto) - solveAuto.id = 'solveAuto' - solveAuto.innerText = 'Solve (automatically)' - solveAuto.onpointerdown = solvePuzzle - solveAuto.style = 'margin-right: 8px' - - var div = document.createElement('div') - parent.appendChild(div) - div.style = 'display: inline-block; vertical-align:top' - - var progressBox = document.createElement('div') - div.appendChild(progressBox) - progressBox.id = 'progressBox' - progressBox.style = 'display: none; width: 220px; border: 1px solid black; margin-top: 2px' - - var progressPercent = document.createElement('label') - progressBox.appendChild(progressPercent) - progressPercent.id = 'progressPercent' - progressPercent.style = 'float: left; margin-left: 4px' - progressPercent.innerText = '0%' - - var progress = document.createElement('div') - progressBox.appendChild(progress) - progress.id = 'progress' - progress.style = 'z-index: -1; height: 38px; width: 0%; background-color: #390' - - var solutionViewer = document.createElement('div') - div.appendChild(solutionViewer) - solutionViewer.id = 'solutionViewer' - solutionViewer.style = 'display: none' - - var previousSolution = document.createElement('button') - solutionViewer.appendChild(previousSolution) - previousSolution.id = 'previousSolution' - previousSolution.innerHTML = '←' - - var solutionCount = document.createElement('label') - solutionViewer.appendChild(solutionCount) - solutionCount.id = 'solutionCount' - solutionCount.style = 'padding: 6px' - - var nextSolution = document.createElement('button') - solutionViewer.appendChild(nextSolution) - nextSolution.id = 'nextSolution' - nextSolution.innerHTML = '→' -} - -var SECONDS_PER_LOOP = 1 -window.httpGetLoop = function(url, maxTimeout, action, onError, onSuccess) { - if (maxTimeout <= 0) { - onError() - return - } - - sendHttpRequest('GET', url, SECONDS_PER_LOOP, null, function(httpCode, response) { - if (httpCode >= 200 && httpCode <= 299) { - var output = action(JSON.parse(response)) - if (output) { - onSuccess(output) - return - } // Retry if action returns null - } // Retry on non-success HTTP codes - - window.setTimeout(function() { - httpGetLoop(url, maxTimeout - SECONDS_PER_LOOP, action, onError, onSuccess) - }, 1000) - }) -} - -window.fireAndForget = function(verb, url, body) { - sendHttpRequest(verb, url, 600, body, function() {}) -} - -// Only used for errors -var HTTP_STATUS = { - 401: '401 unauthorized', 403: '403 forbidden', 404: '404 not found', 409: '409 conflict', 413: '413 payload too large', - 500: '500 internal server error', -} - -var etagCache = {} -function sendHttpRequest(verb, url, timeoutSeconds, data, onResponse) { - currentHttpRequest = new XMLHttpRequest() - currentHttpRequest.onreadystatechange = function() { - if (this.readyState != XMLHttpRequest.DONE) return - etagCache[url] = this.getResponseHeader('ETag') - currentHttpRequest = null - onResponse(this.status, this.responseText || HTTP_STATUS[this.status]) - } - currentHttpRequest.ontimeout = function() { - currentHttpRequest = null - onResponse(0, 'Request timed out') - } - currentHttpRequest.timeout = timeoutSeconds * 1000 - currentHttpRequest.open(verb, url, true) - currentHttpRequest.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded') - - var etag = etagCache[url] - if (etag != null) currentHttpRequest.setRequestHeader('If-None-Match', etag) - - currentHttpRequest.send(data) -} - -function sendFeedback(feedback) { - console.error('Please disregard the following CORS exception. It is expected and the request will succeed regardless.') -} - -}) diff --git a/app/assets/javascripts/wittle/validate.js b/app/assets/javascripts/wittle/validate.js deleted file mode 100644 index d6e6484..0000000 --- a/app/assets/javascripts/wittle/validate.js +++ /dev/null @@ -1,391 +0,0 @@ -namespace(function() { - -class RegionData { - constructor() { - this.invalidElements = [] - this.veryInvalidElements = [] - this.negations = [] - } - - addInvalid(elem) { - this.invalidElements.push(elem) - } - - addVeryInvalid(elem) { - this.veryInvalidElements.push(elem) - } - - valid() { - return (this.invalidElements.length === 0 && this.veryInvalidElements.length === 0) - } -} - -// Sanity checks for data which comes from the user. Now that people have learned that /publish is an open endpoint, -// we have to make sure they don't submit data which passes validation but is untrustworthy. -// These checks should always pass for puzzles created by the built-in editor. -window.validateUserData = function(puzzle, path) { - if (path == null) throw Error('Path cannot be null') - - var sizeError = puzzle.getSizeError(puzzle.width, puzzle.height) - if (sizeError != null) throw Error(sizeError) - - var puzzleHasStart = false - var puzzleHasEnd = false - - if (puzzle.grid.length !== puzzle.width) throw Error('Puzzle width does not match grid size') - for (var x=0; x window.LINE_NONE) { - if (cell.gap > window.GAP_NONE) { - console.log('Solution line goes over a gap at', x, y) - puzzleData.invalidElements.push({'x': x, 'y': y}) - if (quick) return puzzleData - } - if ((cell.dot === window.DOT_BLUE && cell.line === window.LINE_YELLOW) || - (cell.dot === window.DOT_YELLOW && cell.line === window.LINE_BLUE)) { - console.log('Incorrectly covered dot: Dot is', cell.dot, 'but line is', cell.line) - puzzleData.invalidElements.push({'x': x, 'y': y}) - if (quick) return puzzleData - } - } - } - } - - if (needsRegions) { - var regions = puzzle.getRegions() - } else { - var monoRegion = [] - for (var x=0; x 0 && veryInvalidElements.length > 0) { - var source = negationSymbols.pop() - var target = veryInvalidElements.pop() - puzzle.setCell(source.x, source.y, null) - puzzle.setCell(target.x, target.y, null) - baseCombination.push({'source':source, 'target':target}) - } - - var regionData = regionCheckNegations2(puzzle, region, negationSymbols, invalidElements) - - // Restore required negations - for (var combination of baseCombination) { - puzzle.setCell(combination.source.x, combination.source.y, combination.source.cell) - puzzle.setCell(combination.target.x, combination.target.y, combination.target.cell) - regionData.negations.push(combination) - } - return regionData -} - -// Recursively matches negations and invalid elements from the grid. Note that this function -// doesn't actually modify the two lists, it just iterates through them with index/index2. -function regionCheckNegations2(puzzle, region, negationSymbols, invalidElements, index=0, index2=0) { - if (index2 >= negationSymbols.length) { - console.debug('0 negation symbols left, returning negation-less regionCheck') - return regionCheck(puzzle, region, false) // @Performance: We could pass quick here. - } - - if (index >= invalidElements.length) { - var i = index2 - // pair off all negation symbols, 2 at a time - if (puzzle.settings.NEGATIONS_CANCEL_NEGATIONS) { - for (; i window.DOT_NONE) { - console.log('Dot at', pos.x, pos.y, 'is not covered') - regionData.addVeryInvalid(pos) - if (quick) return regionData - } - - // Check for triangles - if (cell.type === 'triangle') { - var count = 0 - if (puzzle.getLine(pos.x - 1, pos.y) > window.LINE_NONE) count++ - if (puzzle.getLine(pos.x + 1, pos.y) > window.LINE_NONE) count++ - if (puzzle.getLine(pos.x, pos.y - 1) > window.LINE_NONE) count++ - if (puzzle.getLine(pos.x, pos.y + 1) > window.LINE_NONE) count++ - if (cell.count !== count) { - console.log('Triangle at grid['+pos.x+']['+pos.y+'] has', count, 'borders') - regionData.addVeryInvalid(pos) - if (quick) return regionData - } - } - - // Count color-based elements - if (cell.color != null) { - var count = coloredObjects[cell.color] - if (count == null) { - count = 0 - } - coloredObjects[cell.color] = count + 1 - - if (cell.type === 'square') { - squares.push(pos) - if (squareColor == null) { - squareColor = cell.color - } else if (squareColor != cell.color) { - squareColor = -1 // Signal value which indicates square color collision - } - } - - if (cell.type === 'star') { - pos.color = cell.color - stars.push(pos) - } - } - } - - if (squareColor === -1) { - regionData.invalidElements = regionData.invalidElements.concat(squares) - if (quick) return regionData - } - - for (var star of stars) { - var count = coloredObjects[star.color] - if (count === 1) { - console.log('Found a', star.color, 'star in a region with 1', star.color, 'object') - regionData.addVeryInvalid(star) - if (quick) return regionData - } else if (count > 2) { - console.log('Found a', star.color, 'star in a region with', count, star.color, 'objects') - regionData.addInvalid(star) - if (quick) return regionData - } - } - - if (puzzle.hasPolyominos) { - if (!window.polyFit(region, puzzle)) { - for (var pos of region) { - var cell = puzzle.grid[pos.x][pos.y] - if (cell == null) continue - if (cell.type === 'poly' || cell.type === 'ylop') { - regionData.addInvalid(pos) - if (quick) return regionData - } - } - } - } - - if (puzzle.settings.CUSTOM_MECHANICS) { - window.validateBridges(puzzle, region, regionData) - window.validateArrows(puzzle, region, regionData) - window.validateSizers(puzzle, region, regionData) - } - - console.debug('Region has', regionData.veryInvalidElements.length, 'very invalid elements') - console.debug('Region has', regionData.invalidElements.length, 'invalid elements') - return regionData -} -}) diff --git a/app/assets/javascripts/wittle/wittle.js b/app/assets/javascripts/wittle/wittle.js deleted file mode 100644 index 883a4b8..0000000 --- a/app/assets/javascripts/wittle/wittle.js +++ /dev/null @@ -1,5 +0,0 @@ -$.ajaxSetup({ - headers: { - 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content') - } -}); diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css new file mode 100644 index 0000000..288b9ab --- /dev/null +++ b/app/assets/stylesheets/application.css @@ -0,0 +1,15 @@ +/* + * This is a manifest file that'll be compiled into application.css, which will include all the files + * listed below. + * + * Any CSS (and SCSS, if configured) file within this directory, lib/assets/stylesheets, or any plugin's + * vendor/assets/stylesheets directory 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 other CSS + * files in this directory. Styles in this file should be added after the last require_* statement. + * It is generally better to create a new file per style scope. + * + *= require_tree . + *= require_self + */ diff --git a/app/assets/stylesheets/general.css.scss b/app/assets/stylesheets/general.css.scss new file mode 100644 index 0000000..7ee3169 --- /dev/null +++ b/app/assets/stylesheets/general.css.scss @@ -0,0 +1,263 @@ +#puzzle { + touch-action: none; +} + +#banner { + background-image: image-url("wittle_header.png"); + background-size: cover; + width: 600px; + height: 245px; + margin: 0 auto; + + h1 { + margin: 0; + + a { + display: block; + width: 600px; + height: 245px; + text-indent: -5000px; + text-decoration: none; + margin: 0; + } + } +} + +h1 { + text-align: center; +} + +.puzzle-description { + text-align: center; + display: block; + margin-top: -1em; + margin-bottom: 2em; + font-size: 1.25em; +} + +#trace-settings { + margin: 1em auto; + width: 540px; + + summary { + text-align: center; + } +} + +#sens, #volume { + background-image: linear-gradient(to right, #EEE, #555); +} + +/* Slider control main bar */ +input[type="range"] { + appearance: none; + -moz-appearance: none; + -webkit-appearance: none; + border-radius: 3px; + height: 15px; + margin-bottom: 5px; + margin-top: 5px; + outline: none; + width: 100%; +} +/* Slider control icon */ +input[type="range"]::-webkit-slider-thumb { + appearance: none; + -moz-appearance: none; + -webkit-appearance: none; + background: image-url('slider.png'); + background-size: 17px 33px; + height: 33px; + width: 17px; +} +input[type="range"]::-moz-range-thumb { + appearance: none; + -moz-appearance: none; + -webkit-appearance: none; + background: image-url('slider.png'); + background-size: 17px 33px; + height: 33px; + width: 17px; +} +input[type="range"]::-ms-thumb { + appearance: none; + -moz-appearance: none; + -webkit-appearance: none; + background: image-url('slider.png'); + background-size: 17px 33px; + height: 33px; + width: 17px; +} + +#submission-form { + margin: 1em auto; + width: 540px; + text-align: center; + + form { + border: 1px solid black; + width: max-content; + padding: 0 2em; + margin: 0 auto; + } +} + +#scores { + display: flex; + + div { + flex: 0 0 48%; + + h2 { + text-align: center; + } + + table { + width: max-content; + margin: 0 auto; + } + } +} + +#activation-button { + button { + display: block; + margin: 0 auto; + width: 25%; + background-color: #04AA6D; + padding: 14px 28px; + font-size: 16px; + cursor: pointer; + text-align: center; + color: white; + } +} + +#timer { + width: max-content; + margin: 0.5em auto 0; + font-size: 3em; + + %label { + padding: 0; + } +} + +.score-field { + padding-left: 1em; + text-align: right; +} + +#archive { + width: 50%; + margin: 0 auto; + border-spacing: 0; + text-align: center; + + tr { + &.odd { + background-color: #fff; + } + + &.even { + background-color: #edf; + } + } + + th, td { + padding: 0.5em; + } + + ul { + list-style-type: none; + padding: 0; + } +} + +#current-date { + margin-top: 1em; + text-align: center; +} + +#choose-difficulty { + margin: 1em auto 2em; + width: max-content; + display: flex; +} + +#normal-link { + width: 215px; + margin-right: 1em; + + a { + background-image: image-url("wittle_normal.png"); + background-size: cover; + display: block; + width: 215px; + height: 215px; + text-indent: -5000px; + text-decoration: none; + margin: 0; + } +} + +#hard-link { + width: 215px; + margin-right: 1em; + + a { + background-image: image-url("wittle_hard.png"); + background-size: cover; + display: block; + width: 215px; + height: 215px; + text-indent: -5000px; + text-decoration: none; + margin: 0; + } +} + +#expert-link { + width: 215px; + + a { + background-image: image-url("wittle_expert.png"); + background-size: cover; + display: block; + width: 215px; + height: 215px; + text-indent: -5000px; + text-decoration: none; + margin: 0; + } +} + +.summary { + width: 50%; + min-width: 600px; + margin: 0 auto 1em; +} + +.puzzle-status { + text-align: center; + margin-bottom: 0; +} + +.breadcrumb { + text-align: center; + + a, a:visited { + color: #555d66; + text-decoration: none; + } + + a:hover { + text-decoration: underline; + } +} + +#new-puzzles { + text-align: center; + font-size: 1.25em; + margin-bottom: 1em; +} diff --git a/app/assets/stylesheets/wittle/application.css b/app/assets/stylesheets/wittle/application.css deleted file mode 100644 index 0ebd7fe..0000000 --- a/app/assets/stylesheets/wittle/application.css +++ /dev/null @@ -1,15 +0,0 @@ -/* - * 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 any plugin's vendor/assets/stylesheets directory 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 other CSS/SCSS - * files in this directory. Styles in this file should be added after the last require_* statement. - * It is generally better to create a new file per style scope. - * - *= require_tree . - *= require_self - */ diff --git a/app/assets/stylesheets/wittle/general.css.scss b/app/assets/stylesheets/wittle/general.css.scss deleted file mode 100644 index 12e5486..0000000 --- a/app/assets/stylesheets/wittle/general.css.scss +++ /dev/null @@ -1,263 +0,0 @@ -#puzzle { - touch-action: none; -} - -#banner { - background-image: image-url("wittle/wittle_header.png"); - background-size: cover; - width: 600px; - height: 245px; - margin: 0 auto; - - h1 { - margin: 0; - - a { - display: block; - width: 600px; - height: 245px; - text-indent: -5000px; - text-decoration: none; - margin: 0; - } - } -} - -h1 { - text-align: center; -} - -.puzzle-description { - text-align: center; - display: block; - margin-top: -1em; - margin-bottom: 2em; - font-size: 1.25em; -} - -#trace-settings { - margin: 1em auto; - width: 540px; - - summary { - text-align: center; - } -} - -#sens, #volume { - background-image: linear-gradient(to right, #EEE, #555); -} - -/* Slider control main bar */ -input[type="range"] { - appearance: none; - -moz-appearance: none; - -webkit-appearance: none; - border-radius: 3px; - height: 15px; - margin-bottom: 5px; - margin-top: 5px; - outline: none; - width: 100%; -} -/* Slider control icon */ -input[type="range"]::-webkit-slider-thumb { - appearance: none; - -moz-appearance: none; - -webkit-appearance: none; - background: image-url('wittle/slider.png'); - background-size: 17px 33px; - height: 33px; - width: 17px; -} -input[type="range"]::-moz-range-thumb { - appearance: none; - -moz-appearance: none; - -webkit-appearance: none; - background: image-url('wittle/slider.png'); - background-size: 17px 33px; - height: 33px; - width: 17px; -} -input[type="range"]::-ms-thumb { - appearance: none; - -moz-appearance: none; - -webkit-appearance: none; - background: image-url('wittle/slider.png'); - background-size: 17px 33px; - height: 33px; - width: 17px; -} - -#submission-form { - margin: 1em auto; - width: 540px; - text-align: center; - - form { - border: 1px solid black; - width: max-content; - padding: 0 2em; - margin: 0 auto; - } -} - -#scores { - display: flex; - - div { - flex: 0 0 48%; - - h2 { - text-align: center; - } - - table { - width: max-content; - margin: 0 auto; - } - } -} - -#activation-button { - button { - display: block; - margin: 0 auto; - width: 25%; - background-color: #04AA6D; - padding: 14px 28px; - font-size: 16px; - cursor: pointer; - text-align: center; - color: white; - } -} - -#timer { - width: max-content; - margin: 0.5em auto 0; - font-size: 3em; - - %label { - padding: 0; - } -} - -.score-field { - padding-left: 1em; - text-align: right; -} - -#archive { - width: 50%; - margin: 0 auto; - border-spacing: 0; - text-align: center; - - tr { - &.odd { - background-color: #fff; - } - - &.even { - background-color: #edf; - } - } - - th, td { - padding: 0.5em; - } - - ul { - list-style-type: none; - padding: 0; - } -} - -#current-date { - margin-top: 1em; - text-align: center; -} - -#choose-difficulty { - margin: 1em auto 2em; - width: max-content; - display: flex; -} - -#normal-link { - width: 215px; - margin-right: 1em; - - a { - background-image: image-url("wittle/wittle_normal.png"); - background-size: cover; - display: block; - width: 215px; - height: 215px; - text-indent: -5000px; - text-decoration: none; - margin: 0; - } -} - -#hard-link { - width: 215px; - margin-right: 1em; - - a { - background-image: image-url("wittle/wittle_hard.png"); - background-size: cover; - display: block; - width: 215px; - height: 215px; - text-indent: -5000px; - text-decoration: none; - margin: 0; - } -} - -#expert-link { - width: 215px; - - a { - background-image: image-url("wittle/wittle_expert.png"); - background-size: cover; - display: block; - width: 215px; - height: 215px; - text-indent: -5000px; - text-decoration: none; - margin: 0; - } -} - -.summary { - width: 50%; - min-width: 600px; - margin: 0 auto 1em; -} - -.puzzle-status { - text-align: center; - margin-bottom: 0; -} - -.breadcrumb { - text-align: center; - - a, a:visited { - color: #555d66; - text-decoration: none; - } - - a:hover { - text-decoration: underline; - } -} - -#new-puzzles { - text-align: center; - font-size: 1.25em; - margin-bottom: 1em; -} diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb new file mode 100644 index 0000000..d672697 --- /dev/null +++ b/app/channels/application_cable/channel.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Channel < ActionCable::Channel::Base + end +end diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb new file mode 100644 index 0000000..0ff5442 --- /dev/null +++ b/app/channels/application_cable/connection.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Connection < ActionCable::Connection::Base + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb new file mode 100644 index 0000000..09705d1 --- /dev/null +++ b/app/controllers/application_controller.rb @@ -0,0 +1,2 @@ +class ApplicationController < ActionController::Base +end diff --git a/app/controllers/puzzles_controller.rb b/app/controllers/puzzles_controller.rb new file mode 100644 index 0000000..b996022 --- /dev/null +++ b/app/controllers/puzzles_controller.rb @@ -0,0 +1,109 @@ +class PuzzlesController < ApplicationController + before_action :prepare_session + + def about + @normal_puzzle = Puzzle.normal.order(created_at: :desc).first + if session[:puzzles]["normal"]["id"] == @normal_puzzle.id + @normal_started = session[:puzzles]["normal"]["started"] + @normal_solved = session[:puzzles]["normal"]["solved"] + else + @normal_started = false + @normal_solved = false + end + + @hard_puzzle = Puzzle.hard.order(created_at: :desc).first + if session[:puzzles]["hard"]["id"] == @hard_puzzle.id + @hard_started = session[:puzzles]["hard"]["started"] + @hard_solved = session[:puzzles]["hard"]["solved"] + else + @hard_started = false + @hard_solved = false + end + + @expert_puzzle = Puzzle.expert.order(created_at: :desc).first + if session[:puzzles]["expert"]["id"] == @expert_puzzle.id + @expert_started = session[:puzzles]["expert"]["started"] + @expert_solved = session[:puzzles]["expert"]["solved"] + else + @expert_started = false + @expert_solved = false + end + end + + def index + @puzzles = Puzzle.select(:id, :created_at, :category).order(created_at: :asc).all.chunk { |puzzle| puzzle.created_at.localtime.to_date }.to_h.transform_values { |by_date| by_date.sort_by(&:category).chunk { |puzzle| puzzle.category }.to_h } + end + + def show + @puzzle = Puzzle.find(params["id"]) + + if @puzzle.latest? + if session[:puzzles][@puzzle.category]["id"] == @puzzle.id + @playable = !(session[:puzzles][@puzzle.category]["solved"]) + @already_started = session[:puzzles][@puzzle.category]["started"] + + unless @playable + @solution = session[:puzzles][@puzzle.category]["path"] + end + else + @playable = true + @already_started = false + end + else + @playable = false + @already_started = false + @solution = @puzzle.solved_data + end + end + + def start + @puzzle = Puzzle.find(params["id"]) + + if session[:puzzles][@puzzle.category]["id"] != @puzzle.id + session[:puzzles][@puzzle.category] = { "id" => @puzzle.id } + end + + session[:puzzles][@puzzle.category]["started"] = true + end + + def solve + @puzzle = Puzzle.find(params["id"]) + + raise ActiveRecord::RecordNotFound unless @puzzle.latest? + + if @puzzle.solved_data.nil? + @puzzle.solved_data = params["solved"] + @puzzle.save! + end + + @time = (params.include? :time) ? params[:time] : nil + + if session[:puzzles][@puzzle.category]["id"] != @puzzle.id + session[:puzzles][@puzzle.category] = { "id" => @puzzle.id } + end + + session[:puzzles][@puzzle.category]["solved"] = true + session[:puzzles][@puzzle.category]["path"] = params["solved"] + end + + def submit + @puzzle = Puzzle.find(params["id"]) + + raise ActiveRecord::RecordNotFound unless @puzzle.latest? + + @puzzle.scores.create(name: params[:name], ip: request.ip, seconds_taken: (params.include? :time) ? params[:time] : nil) + + redirect_to @puzzle + end + + private + + def prepare_session + session[:puzzles] ||= { + normal: { "id" => 0 }, + hard: { "id" => 0 }, + expert: { "id" => 0 }, + } + end + +end diff --git a/app/controllers/wittle/application_controller.rb b/app/controllers/wittle/application_controller.rb deleted file mode 100644 index 252a28e..0000000 --- a/app/controllers/wittle/application_controller.rb +++ /dev/null @@ -1,4 +0,0 @@ -module Wittle - class ApplicationController < ActionController::Base - end -end diff --git a/app/controllers/wittle/puzzles_controller.rb b/app/controllers/wittle/puzzles_controller.rb deleted file mode 100644 index a937aa1..0000000 --- a/app/controllers/wittle/puzzles_controller.rb +++ /dev/null @@ -1,120 +0,0 @@ -module Wittle - class PuzzlesController < ApplicationController - before_action :prepare_session - after_action :commit_session - - def about - @normal_puzzle = Puzzle.normal.order(created_at: :desc).first - if @session_puzzles["normal"]["id"] == @normal_puzzle.id - @normal_started = @session_puzzles["normal"]["started"] - @normal_solved = @session_puzzles["normal"]["solved"] - else - @normal_started = false - @normal_solved = false - end - - @hard_puzzle = Puzzle.hard.order(created_at: :desc).first - if @session_puzzles["hard"]["id"] == @hard_puzzle.id - @hard_started = @session_puzzles["hard"]["started"] - @hard_solved = @session_puzzles["hard"]["solved"] - else - @hard_started = false - @hard_solved = false - end - - @expert_puzzle = Puzzle.expert.order(created_at: :desc).first - if @session_puzzles["expert"]["id"] == @expert_puzzle.id - @expert_started = @session_puzzles["expert"]["started"] - @expert_solved = @session_puzzles["expert"]["solved"] - else - @expert_started = false - @expert_solved = false - end - end - - def index - @puzzles = Puzzle.select(:id, :created_at, :category).order(created_at: :asc).all.chunk { |puzzle| puzzle.created_at.localtime.to_date }.to_h.transform_values { |by_date| by_date.sort_by(&:category).chunk { |puzzle| puzzle.category }.to_h } - end - - def show - @puzzle = Puzzle.find(params[:id]) - - if @puzzle.latest? - if @session_puzzles[@puzzle.category]["id"] == @puzzle.id - @playable = !(@session_puzzles[@puzzle.category]["solved"]) - @already_started = @session_puzzles[@puzzle.category]["started"] - - unless @playable - @solution = @session_puzzles[@puzzle.category]["path"] - end - else - @playable = true - @already_started = false - end - else - @playable = false - @already_started = false - @solution = @puzzle.solved_data - end - end - - def start - @puzzle = Puzzle.find(params[:id]) - - if @session_puzzles[@puzzle.category]["id"] != @puzzle.id - @session_puzzles[@puzzle.category] = { "id" => @puzzle.id } - end - - @session_puzzles[@puzzle.category]["started"] = true - end - - def solve - @puzzle = Puzzle.find(params[:id]) - - raise ActiveRecord::RecordNotFound unless @puzzle.latest? - - if @puzzle.solved_data.nil? - @puzzle.solved_data = params[:solved] - @puzzle.save! - end - - @time = (params.include? :time) ? params[:time] : nil - - if @session_puzzles[@puzzle.category]["id"] != @puzzle.id - @session_puzzles[@puzzle.category] = { "id" => @puzzle.id } - end - - @session_puzzles[@puzzle.category]["solved"] = true - @session_puzzles[@puzzle.category]["path"] = params[:solved] - end - - def submit - @puzzle = Puzzle.find(params[:id]) - - raise ActiveRecord::RecordNotFound unless @puzzle.latest? - - @puzzle.scores.create(name: params[:name], ip: request.ip, seconds_taken: (params.include? :time) ? params[:time] : nil) - - redirect_to @puzzle - end - - private - - def prepare_session - if cookies.encrypted[:puzzles].nil? - @session_puzzles = { "normal" => { id: nil }, "hard" => { id: nil }, "expert" => { id: nil } } - else - @session_puzzles = JSON.parse(cookies.encrypted[:puzzles]) - end - - # Temporary hack - if session.has_key? :puzzle_solutions - session[:puzzle_solutions].clear() - end - end - - def commit_session - cookies.encrypted[:puzzles] = JSON.generate(@session_puzzles) - end - end -end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb new file mode 100644 index 0000000..de6be79 --- /dev/null +++ b/app/helpers/application_helper.rb @@ -0,0 +1,2 @@ +module ApplicationHelper +end diff --git a/app/helpers/puzzles_helper.rb b/app/helpers/puzzles_helper.rb new file mode 100644 index 0000000..ded1e8d --- /dev/null +++ b/app/helpers/puzzles_helper.rb @@ -0,0 +1,7 @@ +module PuzzlesHelper + + def humanize_interval(seconds) + "#{(seconds / 60).to_s.rjust(2, '0')}:#{(seconds % 60).to_s.rjust(2, '0')}" + end + +end diff --git a/app/helpers/wittle/application_helper.rb b/app/helpers/wittle/application_helper.rb deleted file mode 100644 index 8fcb2c9..0000000 --- a/app/helpers/wittle/application_helper.rb +++ /dev/null @@ -1,4 +0,0 @@ -module Wittle - module ApplicationHelper - end -end diff --git a/app/helpers/wittle/puzzles_helper.rb b/app/helpers/wittle/puzzles_helper.rb deleted file mode 100644 index 64364c6..0000000 --- a/app/helpers/wittle/puzzles_helper.rb +++ /dev/null @@ -1,9 +0,0 @@ -module Wittle - module PuzzlesHelper - - def humanize_interval(seconds) - "#{(seconds / 60).to_s.rjust(2, '0')}:#{(seconds % 60).to_s.rjust(2, '0')}" - end - - end -end diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb new file mode 100644 index 0000000..d394c3d --- /dev/null +++ b/app/jobs/application_job.rb @@ -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 diff --git a/app/jobs/wittle/application_job.rb b/app/jobs/wittle/application_job.rb deleted file mode 100644 index b0ab02e..0000000 --- a/app/jobs/wittle/application_job.rb +++ /dev/null @@ -1,4 +0,0 @@ -module Wittle - class ApplicationJob < ActiveJob::Base - end -end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb new file mode 100644 index 0000000..3c34c81 --- /dev/null +++ b/app/mailers/application_mailer.rb @@ -0,0 +1,4 @@ +class ApplicationMailer < ActionMailer::Base + default from: "from@example.com" + layout "mailer" +end diff --git a/app/mailers/wittle/application_mailer.rb b/app/mailers/wittle/application_mailer.rb deleted file mode 100644 index 5a563c4..0000000 --- a/app/mailers/wittle/application_mailer.rb +++ /dev/null @@ -1,6 +0,0 @@ -module Wittle - class ApplicationMailer < ActionMailer::Base - default from: "from@example.com" - layout "mailer" - end -end diff --git a/app/models/application_record.rb b/app/models/application_record.rb new file mode 100644 index 0000000..b63caeb --- /dev/null +++ b/app/models/application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class +end diff --git a/app/models/puzzle.rb b/app/models/puzzle.rb new file mode 100644 index 0000000..4f57d9c --- /dev/null +++ b/app/models/puzzle.rb @@ -0,0 +1,14 @@ +class Puzzle < ApplicationRecord + extend Enumerize + + has_many :scores + + validates :data, presence: true + + validates :category, presence: true + enumerize :category, in: [:normal, :hard, :expert], scope: :shallow + + def latest? + Puzzle.where(category: category).order(created_at: :desc).first.id == id + end +end diff --git a/app/models/score.rb b/app/models/score.rb new file mode 100644 index 0000000..1958389 --- /dev/null +++ b/app/models/score.rb @@ -0,0 +1,7 @@ +class Score < ApplicationRecord + belongs_to :puzzle + + validates :name, presence: true + validates :ip, presence: true + validates_uniqueness_of :name, scope: :puzzle_id +end diff --git a/app/models/wittle/application_record.rb b/app/models/wittle/application_record.rb deleted file mode 100644 index be1dfe5..0000000 --- a/app/models/wittle/application_record.rb +++ /dev/null @@ -1,5 +0,0 @@ -module Wittle - class ApplicationRecord < ActiveRecord::Base - self.abstract_class = true - end -end diff --git a/app/models/wittle/puzzle.rb b/app/models/wittle/puzzle.rb deleted file mode 100644 index f9009bc..0000000 --- a/app/models/wittle/puzzle.rb +++ /dev/null @@ -1,16 +0,0 @@ -module Wittle - class Puzzle < ApplicationRecord - extend Enumerize - - has_many :scores - - validates :data, presence: true - - validates :category, presence: true - enumerize :category, in: [:normal, :hard, :expert], scope: :shallow - - def latest? - Puzzle.where(category: category).order(created_at: :desc).first.id == id - end - end -end diff --git a/app/models/wittle/score.rb b/app/models/wittle/score.rb deleted file mode 100644 index 54d2b89..0000000 --- a/app/models/wittle/score.rb +++ /dev/null @@ -1,9 +0,0 @@ -module Wittle - class Score < ApplicationRecord - belongs_to :puzzle - - validates :name, presence: true - validates :ip, presence: true - validates_uniqueness_of :name, scope: :puzzle_id - end -end diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml new file mode 100644 index 0000000..cae8de9 --- /dev/null +++ b/app/views/layouts/application.html.haml @@ -0,0 +1,13 @@ +!!! 5 +%html + %head + %title Wittle + = csrf_meta_tags + = csp_meta_tag + = stylesheet_link_tag "application", "data-turbo-track": "reload" + = javascript_include_tag "application" + %body + #wrap + %header#banner + %h1= link_to "Wittle", root_url + = yield diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb new file mode 100644 index 0000000..3aac900 --- /dev/null +++ b/app/views/layouts/mailer.html.erb @@ -0,0 +1,13 @@ + + + + + + + + + <%= yield %> + + diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb new file mode 100644 index 0000000..37f0bdd --- /dev/null +++ b/app/views/layouts/mailer.text.erb @@ -0,0 +1 @@ +<%= yield %> diff --git a/app/views/layouts/wittle/application.html.haml b/app/views/layouts/wittle/application.html.haml deleted file mode 100644 index 1226d79..0000000 --- a/app/views/layouts/wittle/application.html.haml +++ /dev/null @@ -1,13 +0,0 @@ -!!! 5 -%html - %head - %title Wittle - = csrf_meta_tags - = csp_meta_tag - = stylesheet_link_tag "wittle/application", media: "all" - = javascript_include_tag "wittle/application" - %body - #wrap - %header#banner - %h1= link_to "Wittle", root_url - = yield diff --git a/app/views/puzzles/_handle_puzzle.html.erb b/app/views/puzzles/_handle_puzzle.html.erb new file mode 100644 index 0000000..f0e3227 --- /dev/null +++ b/app/views/puzzles/_handle_puzzle.html.erb @@ -0,0 +1,76 @@ + diff --git a/app/views/puzzles/_submission.html.haml b/app/views/puzzles/_submission.html.haml new file mode 100644 index 0000000..a3d0740 --- /dev/null +++ b/app/views/puzzles/_submission.html.haml @@ -0,0 +1,9 @@ +%h3 Congrats! +%p Would you like to submit your time? += form_with url: submit_puzzle_path(@puzzle) do |form| + - unless @time.nil? + = form.hidden_field :time, value: @time + %p + = form.label :name, "Name:" + = form.text_field :name + %p= form.submit "Submit" diff --git a/app/views/puzzles/about.html.haml b/app/views/puzzles/about.html.haml new file mode 100644 index 0000000..f1f7aa0 --- /dev/null +++ b/app/views/puzzles/about.html.haml @@ -0,0 +1,53 @@ +%p.summary Wittle gives you daily randomly-generated puzzles in the style of those from the 2016 indie game, The Witness. There are three difficulties of puzzles to choose from: +%h2#current-date= @normal_puzzle.created_at.localtime.strftime("%B %-d, %Y") +%nav#choose-difficulty + #normal-link + = link_to "Normal", @normal_puzzle + - if @normal_solved + %p.puzzle-status Solved! + - elsif @normal_started + %p.puzzle-status Started + #hard-link + = link_to "Hard", @hard_puzzle + - if @hard_solved + %p.puzzle-status Solved! + - elsif @hard_started + %p.puzzle-status Started + #expert-link + = link_to "Expert", @expert_puzzle + - if @expert_solved + %p.puzzle-status Solved! + - elsif @expert_started + %p.puzzle-status Started +#new-puzzles + - if @normal_puzzle.created_at.localtime.to_date != DateTime.now.localtime.to_date + New puzzles are being generated... + - else + Time until new puzzles: 00:00:00 + :javascript + function pad(val) { + var valString = val + "" + if (valString.length < 2) + { + return "0" + valString + } else { + return valString + } + } + function setTime() { + --totalSeconds + if (totalSeconds == 0) + { + $("#new-puzzles").text("Refresh the page for today's puzzles!") + clearInterval(timerInterval) + } else { + $("#seconds").text(pad(totalSeconds%60)) + $("#minutes").text(pad(parseInt(totalSeconds/60)%60)) + $("#hours").text(pad(parseInt(parseInt(totalSeconds/60)/60))) + } + } + var totalSeconds = #{(Time.now.tomorrow.beginning_of_day - Time.now).to_i + 1} + setTime() + var timerInterval = setInterval(setTime, 1000); +%p.summary Wittle was created by Hatkirby, with major help from Sigma144 (who wrote the puzzle generation code) and jbzdarkid (who wrote the puzzle solving web interface). The source code is available here. If you encounter any bugs, or have feedback regarding puzzle difficulty level, feel free to contact me! I am hatkirby on Discord, and you can also find me in the Witness Speedrunning server. +%p.summary There is an archive of past puzzles for those interested. diff --git a/app/views/puzzles/index.html.haml b/app/views/puzzles/index.html.haml new file mode 100644 index 0000000..fe497e4 --- /dev/null +++ b/app/views/puzzles/index.html.haml @@ -0,0 +1,26 @@ +.breadcrumb= link_to "← Back to home page", root_path +%h1 Archive +%table#archive + %tr + %th + %th Normal + %th Hard + %th Expert + - @puzzles.each do |date, dps| + %tr{ class: cycle("even", "odd") } + %td= date.strftime("%B %-d, %Y") + %td + - if dps.has_key? "normal" + %ul + - dps["normal"].each do |puzzle| + %li= link_to "\##{puzzle.id}", puzzle + %td + - if dps.has_key? "hard" + %ul + - dps["hard"].each do |puzzle| + %li= link_to "\##{puzzle.id}", puzzle + %td + - if dps.has_key? "expert" + %ul + - dps["expert"].each do |puzzle| + %li= link_to "\##{puzzle.id}", puzzle diff --git a/app/views/puzzles/show.html.haml b/app/views/puzzles/show.html.haml new file mode 100644 index 0000000..47db8f2 --- /dev/null +++ b/app/views/puzzles/show.html.haml @@ -0,0 +1,41 @@ +.breadcrumb= link_to "← Back to home page", root_path +%h1 Wittle ##{@puzzle.id} +.puzzle-description #{@puzzle.category.capitalize} - #{@puzzle.created_at.localtime.strftime("%B %-d, %Y")} +#puzzle-container{ style: "display: flex; justify-content: center; align-items: center" } + %svg#puzzle{ style: "pointer-events: auto"} +#submission-form +- if @playable + - unless @already_started + #activation-button + %button{ type: "button" } Reveal Puzzle + #timer + %label#minutes 00 + %label#colon : + %label#seconds 00 +- if @playable or @puzzle.latest? + %details#trace-settings + %summary Settings + .things + %label{ for: "sens" } Mouse Speed 2D + %input#sens{ type: "range", min: "0.1", max: "1.3", step: "0.1" } + %label{ for: "volume" } Volume + %input#volume{ type: "range", min: "0", max: "0.24", step: "0.02" } +- unless @playable + #scores + #by-time + %h2 Fastest Solves + %table + - @puzzle.scores.where("seconds_taken IS NOT NULL").order(seconds_taken: :asc, created_at: :asc).limit(100).each_with_index do |score, i| + %tr + %td #{(i + 1)}. + %td= score.name + %td.score-field= humanize_interval(score.seconds_taken) + #by-when + %h2 Completion Order + %table + - @puzzle.scores.order(created_at: :asc).limit(100).each_with_index do |score, i| + %tr + %td #{(i + 1)}. + %td= score.name + %td.score-field= score.created_at.getlocal().strftime("%-I:%M:%S%P") += render partial: "handle_puzzle" diff --git a/app/views/puzzles/solve.js.erb b/app/views/puzzles/solve.js.erb new file mode 100644 index 0000000..2aa22e9 --- /dev/null +++ b/app/views/puzzles/solve.js.erb @@ -0,0 +1,5 @@ +$("#submission-form").html('<%= escape_javascript(render partial: "submission") %>'); +$("#submission-form #name").val(window.settings.player_name) +$("#submission-form #name").on("change", function() { + window.settings.player_name = this.value +}) diff --git a/app/views/wittle/puzzles/_handle_puzzle.html.erb b/app/views/wittle/puzzles/_handle_puzzle.html.erb deleted file mode 100644 index f0e3227..0000000 --- a/app/views/wittle/puzzles/_handle_puzzle.html.erb +++ /dev/null @@ -1,76 +0,0 @@ - diff --git a/app/views/wittle/puzzles/_submission.html.haml b/app/views/wittle/puzzles/_submission.html.haml deleted file mode 100644 index a3d0740..0000000 --- a/app/views/wittle/puzzles/_submission.html.haml +++ /dev/null @@ -1,9 +0,0 @@ -%h3 Congrats! -%p Would you like to submit your time? -= form_with url: submit_puzzle_path(@puzzle) do |form| - - unless @time.nil? - = form.hidden_field :time, value: @time - %p - = form.label :name, "Name:" - = form.text_field :name - %p= form.submit "Submit" diff --git a/app/views/wittle/puzzles/about.html.haml b/app/views/wittle/puzzles/about.html.haml deleted file mode 100644 index f1f7aa0..0000000 --- a/app/views/wittle/puzzles/about.html.haml +++ /dev/null @@ -1,53 +0,0 @@ -%p.summary Wittle gives you daily randomly-generated puzzles in the style of those from the 2016 indie game, The Witness. There are three difficulties of puzzles to choose from: -%h2#current-date= @normal_puzzle.created_at.localtime.strftime("%B %-d, %Y") -%nav#choose-difficulty - #normal-link - = link_to "Normal", @normal_puzzle - - if @normal_solved - %p.puzzle-status Solved! - - elsif @normal_started - %p.puzzle-status Started - #hard-link - = link_to "Hard", @hard_puzzle - - if @hard_solved - %p.puzzle-status Solved! - - elsif @hard_started - %p.puzzle-status Started - #expert-link - = link_to "Expert", @expert_puzzle - - if @expert_solved - %p.puzzle-status Solved! - - elsif @expert_started - %p.puzzle-status Started -#new-puzzles - - if @normal_puzzle.created_at.localtime.to_date != DateTime.now.localtime.to_date - New puzzles are being generated... - - else - Time until new puzzles: 00:00:00 - :javascript - function pad(val) { - var valString = val + "" - if (valString.length < 2) - { - return "0" + valString - } else { - return valString - } - } - function setTime() { - --totalSeconds - if (totalSeconds == 0) - { - $("#new-puzzles").text("Refresh the page for today's puzzles!") - clearInterval(timerInterval) - } else { - $("#seconds").text(pad(totalSeconds%60)) - $("#minutes").text(pad(parseInt(totalSeconds/60)%60)) - $("#hours").text(pad(parseInt(parseInt(totalSeconds/60)/60))) - } - } - var totalSeconds = #{(Time.now.tomorrow.beginning_of_day - Time.now).to_i + 1} - setTime() - var timerInterval = setInterval(setTime, 1000); -%p.summary Wittle was created by Hatkirby, with major help from Sigma144 (who wrote the puzzle generation code) and jbzdarkid (who wrote the puzzle solving web interface). The source code is available here. If you encounter any bugs, or have feedback regarding puzzle difficulty level, feel free to contact me! I am hatkirby on Discord, and you can also find me in the Witness Speedrunning server. -%p.summary There is an archive of past puzzles for those interested. diff --git a/app/views/wittle/puzzles/index.html.haml b/app/views/wittle/puzzles/index.html.haml deleted file mode 100644 index fe497e4..0000000 --- a/app/views/wittle/puzzles/index.html.haml +++ /dev/null @@ -1,26 +0,0 @@ -.breadcrumb= link_to "← Back to home page", root_path -%h1 Archive -%table#archive - %tr - %th - %th Normal - %th Hard - %th Expert - - @puzzles.each do |date, dps| - %tr{ class: cycle("even", "odd") } - %td= date.strftime("%B %-d, %Y") - %td - - if dps.has_key? "normal" - %ul - - dps["normal"].each do |puzzle| - %li= link_to "\##{puzzle.id}", puzzle - %td - - if dps.has_key? "hard" - %ul - - dps["hard"].each do |puzzle| - %li= link_to "\##{puzzle.id}", puzzle - %td - - if dps.has_key? "expert" - %ul - - dps["expert"].each do |puzzle| - %li= link_to "\##{puzzle.id}", puzzle diff --git a/app/views/wittle/puzzles/show.html.haml b/app/views/wittle/puzzles/show.html.haml deleted file mode 100644 index 47db8f2..0000000 --- a/app/views/wittle/puzzles/show.html.haml +++ /dev/null @@ -1,41 +0,0 @@ -.breadcrumb= link_to "← Back to home page", root_path -%h1 Wittle ##{@puzzle.id} -.puzzle-description #{@puzzle.category.capitalize} - #{@puzzle.created_at.localtime.strftime("%B %-d, %Y")} -#puzzle-container{ style: "display: flex; justify-content: center; align-items: center" } - %svg#puzzle{ style: "pointer-events: auto"} -#submission-form -- if @playable - - unless @already_started - #activation-button - %button{ type: "button" } Reveal Puzzle - #timer - %label#minutes 00 - %label#colon : - %label#seconds 00 -- if @playable or @puzzle.latest? - %details#trace-settings - %summary Settings - .things - %label{ for: "sens" } Mouse Speed 2D - %input#sens{ type: "range", min: "0.1", max: "1.3", step: "0.1" } - %label{ for: "volume" } Volume - %input#volume{ type: "range", min: "0", max: "0.24", step: "0.02" } -- unless @playable - #scores - #by-time - %h2 Fastest Solves - %table - - @puzzle.scores.where("seconds_taken IS NOT NULL").order(seconds_taken: :asc, created_at: :asc).limit(100).each_with_index do |score, i| - %tr - %td #{(i + 1)}. - %td= score.name - %td.score-field= humanize_interval(score.seconds_taken) - #by-when - %h2 Completion Order - %table - - @puzzle.scores.order(created_at: :asc).limit(100).each_with_index do |score, i| - %tr - %td #{(i + 1)}. - %td= score.name - %td.score-field= score.created_at.getlocal().strftime("%-I:%M:%S%P") -= render partial: "handle_puzzle" diff --git a/app/views/wittle/puzzles/solve.js.erb b/app/views/wittle/puzzles/solve.js.erb deleted file mode 100644 index 2aa22e9..0000000 --- a/app/views/wittle/puzzles/solve.js.erb +++ /dev/null @@ -1,5 +0,0 @@ -$("#submission-form").html('<%= escape_javascript(render partial: "submission") %>'); -$("#submission-form #name").val(window.settings.player_name) -$("#submission-form #name").on("change", function() { - window.settings.player_name = this.value -}) diff --git a/bin/bundle b/bin/bundle new file mode 100755 index 0000000..42c7fd7 --- /dev/null +++ b/bin/bundle @@ -0,0 +1,109 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'bundle' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +require "rubygems" + +m = Module.new do + module_function + + def invoked_as_script? + File.expand_path($0) == File.expand_path(__FILE__) + end + + def env_var_version + ENV["BUNDLER_VERSION"] + end + + def cli_arg_version + return unless invoked_as_script? # don't want to hijack other binstubs + return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update` + bundler_version = nil + update_index = nil + ARGV.each_with_index do |a, i| + if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN + bundler_version = a + end + next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ + bundler_version = $1 + update_index = i + end + bundler_version + end + + def gemfile + gemfile = ENV["BUNDLE_GEMFILE"] + return gemfile if gemfile && !gemfile.empty? + + File.expand_path("../Gemfile", __dir__) + end + + def lockfile + lockfile = + case File.basename(gemfile) + when "gems.rb" then gemfile.sub(/\.rb$/, ".locked") + else "#{gemfile}.lock" + end + File.expand_path(lockfile) + end + + def lockfile_version + return unless File.file?(lockfile) + lockfile_contents = File.read(lockfile) + return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ + Regexp.last_match(1) + end + + def bundler_requirement + @bundler_requirement ||= + env_var_version || + cli_arg_version || + bundler_requirement_for(lockfile_version) + end + + def bundler_requirement_for(version) + return "#{Gem::Requirement.default}.a" unless version + + bundler_gem_version = Gem::Version.new(version) + + bundler_gem_version.approximate_recommendation + end + + def load_bundler! + ENV["BUNDLE_GEMFILE"] ||= gemfile + + activate_bundler + end + + def activate_bundler + gem_error = activation_error_handling do + gem "bundler", bundler_requirement + end + return if gem_error.nil? + require_error = activation_error_handling do + require "bundler/version" + end + return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION)) + warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`" + exit 42 + end + + def activation_error_handling + yield + nil + rescue StandardError, LoadError => e + e + end +end + +m.load_bundler! + +if m.invoked_as_script? + load Gem.bin_path("bundler", "bundle") +end diff --git a/bin/rails b/bin/rails index dc13199..efc0377 100755 --- a/bin/rails +++ b/bin/rails @@ -1,14 +1,4 @@ #!/usr/bin/env ruby -# This command will automatically be run when you run "rails" with Rails gems -# installed from the root of your application. - -ENGINE_ROOT = File.expand_path("..", __dir__) -ENGINE_PATH = File.expand_path("../lib/wittle/engine", __dir__) -APP_PATH = File.expand_path("../test/dummy/config/application", __dir__) - -# Set up gems listed in the Gemfile. -ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) -require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) - -require "rails/all" -require "rails/engine/commands" +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/bin/rake b/bin/rake new file mode 100755 index 0000000..4fbf10b --- /dev/null +++ b/bin/rake @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "rake" +Rake.application.run diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..3cd5a9d --- /dev/null +++ b/bin/setup @@ -0,0 +1,33 @@ +#!/usr/bin/env ruby +require "fileutils" + +# path to your application root. +APP_ROOT = File.expand_path("..", __dir__) + +def system!(*args) + system(*args, exception: true) +end + +FileUtils.chdir APP_ROOT do + # This script is a way to set up or update your development environment automatically. + # This script is idempotent, so that you can run it at any time and get an expectable outcome. + # Add necessary setup steps to this file. + + puts "== Installing dependencies ==" + system! "gem install bundler --conservative" + system("bundle check") || system!("bundle install") + + # puts "\n== Copying sample files ==" + # unless File.exist?("config/database.yml") + # FileUtils.cp "config/database.yml.sample", "config/database.yml" + # end + + puts "\n== Preparing database ==" + system! "bin/rails db:prepare" + + puts "\n== Removing old logs and tempfiles ==" + system! "bin/rails log:clear tmp:clear" + + puts "\n== Restarting application server ==" + system! "bin/rails restart" +end diff --git a/config.ru b/config.ru new file mode 100644 index 0000000..4a3c09a --- /dev/null +++ b/config.ru @@ -0,0 +1,6 @@ +# This file is used by Rack-based servers to start the application. + +require_relative "config/environment" + +run Rails.application +Rails.application.load_server diff --git a/config/application.rb b/config/application.rb new file mode 100644 index 0000000..095566e --- /dev/null +++ b/config/application.rb @@ -0,0 +1,27 @@ +require_relative "boot" + +require "rails/all" + +# Require the gems listed in Gemfile, including any gems +# you've limited to :test, :development, or :production. +Bundler.require(*Rails.groups) + +module Wittle + class Application < Rails::Application + # Initialize configuration defaults for originally generated Rails version. + config.load_defaults 7.1 + + # Please, add to the `ignore` list any other `lib` subdirectories that do + # not contain `.rb` files, or that should not be reloaded or eager loaded. + # Common ones are `templates`, `generators`, or `middleware`, for example. + config.autoload_lib(ignore: %w(assets tasks)) + + # Configuration for the application, engines, and railties goes here. + # + # These settings can be overridden in specific environments using the files + # in config/environments, which are processed later. + # + # config.time_zone = "Central Time (US & Canada)" + # config.eager_load_paths << Rails.root.join("extras") + end +end diff --git a/config/boot.rb b/config/boot.rb new file mode 100644 index 0000000..988a5dd --- /dev/null +++ b/config/boot.rb @@ -0,0 +1,4 @@ +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "bundler/setup" # Set up gems listed in the Gemfile. +require "bootsnap/setup" # Speed up boot time by caching expensive operations. diff --git a/config/cable.yml b/config/cable.yml new file mode 100644 index 0000000..46d6557 --- /dev/null +++ b/config/cable.yml @@ -0,0 +1,10 @@ +development: + adapter: async + +test: + adapter: test + +production: + adapter: redis + url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> + channel_prefix: wittle_production diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc new file mode 100644 index 0000000..494d15d --- /dev/null +++ b/config/credentials.yml.enc @@ -0,0 +1 @@ +80HLuAOBpfETFq58hHZxQQI9DcvkqZzwx5TDwXZ5JPvjIzrj4lOuhFCnd/QfqHsKOyearjd1Ot0+VzmN8pi3dr3sgRaVrqE37F6fBlUW5XxDE02GWmKQk8MTIkVJyLjI+AfiGAEn+W9LoKK1drqsJFnQDYVRIXpKjUTyYYiA8i3M+CqSNJCb41tOsXgVbfORUAEJGncJVGCkHey6alRGWoJioByUe5jPIVnSFNUAz7kOajbB2l7laqxvTVRFXzOOryh44pXeNiJRVmLAlJGfdfTZcB1cD+OjEd0QOfyIrchyGHcGexmV4qrirN+5gVqeJ75D1M9SWVPms/w2RFSlq+miEjnUIYq6LyrLEwQxYSG8ontWMuVpdfdgJrdb6aaPYyVzFxk968BUpjWU9asXsE1sVqK7--q8zHjMUiCIKcGKVK--s/ilfRd7l7AUvTXruie3LA== \ No newline at end of file diff --git a/config/database.yml b/config/database.yml new file mode 100644 index 0000000..796466b --- /dev/null +++ b/config/database.yml @@ -0,0 +1,25 @@ +# SQLite. Versions 3.8.0 and up are supported. +# gem install sqlite3 +# +# Ensure the SQLite 3 gem is defined in your Gemfile +# gem "sqlite3" +# +default: &default + adapter: sqlite3 + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + timeout: 5000 + +development: + <<: *default + database: storage/development.sqlite3 + +# Warning: The database defined as "test" will be erased and +# re-generated from your development database when you run "rake". +# Do not set this db to the same as development or production. +test: + <<: *default + database: storage/test.sqlite3 + +production: + <<: *default + database: storage/production.sqlite3 diff --git a/config/environment.rb b/config/environment.rb new file mode 100644 index 0000000..cac5315 --- /dev/null +++ b/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/config/environments/development.rb b/config/environments/development.rb new file mode 100644 index 0000000..2e7fb48 --- /dev/null +++ b/config/environments/development.rb @@ -0,0 +1,76 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # In the development environment your application's code is reloaded any time + # it changes. This slows down response time but is perfect for development + # since you don't have to restart the web server when you make code changes. + config.enable_reloading = true + + # Do not eager load code on boot. + config.eager_load = false + + # Show full error reports. + config.consider_all_requests_local = true + + # Enable server timing + config.server_timing = true + + # Enable/disable caching. By default caching is disabled. + # Run rails dev:cache to toggle caching. + if Rails.root.join("tmp/caching-dev.txt").exist? + config.action_controller.perform_caching = true + config.action_controller.enable_fragment_cache_logging = true + + config.cache_store = :memory_store + config.public_file_server.headers = { + "Cache-Control" => "public, max-age=#{2.days.to_i}" + } + else + config.action_controller.perform_caching = false + + config.cache_store = :null_store + end + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Don't care if the mailer can't send. + config.action_mailer.raise_delivery_errors = false + + config.action_mailer.perform_caching = false + + # Print deprecation notices to the Rails logger. + config.active_support.deprecation = :log + + # Raise exceptions for disallowed deprecations. + config.active_support.disallowed_deprecation = :raise + + # Tell Active Support which deprecation messages to disallow. + config.active_support.disallowed_deprecation_warnings = [] + + # Raise an error on page load if there are pending migrations. + config.active_record.migration_error = :page_load + + # Highlight code that triggered database queries in logs. + config.active_record.verbose_query_logs = true + + # Highlight code that enqueued background job in logs. + config.active_job.verbose_enqueue_logs = true + + # Suppress logger output for asset requests. + config.assets.quiet = true + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Uncomment if you wish to allow Action Cable access from any origin. + # config.action_cable.disable_request_forgery_protection = true + + # Raise error when a before_action's only/except options reference missing actions + config.action_controller.raise_on_missing_callback_actions = true +end diff --git a/config/environments/production.rb b/config/environments/production.rb new file mode 100644 index 0000000..77a44b1 --- /dev/null +++ b/config/environments/production.rb @@ -0,0 +1,98 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Code is not reloaded between requests. + config.enable_reloading = false + + # Eager load code on boot. This eager loads most of Rails and + # your application in memory, allowing both threaded web servers + # and those relying on copy on write to perform better. + # Rake tasks automatically ignore this option for performance. + config.eager_load = true + + # Full error reports are disabled and caching is turned on. + config.consider_all_requests_local = false + config.action_controller.perform_caching = true + + # Ensures that a master key has been made available in ENV["RAILS_MASTER_KEY"], config/master.key, or an environment + # key such as config/credentials/production.key. This key is used to decrypt credentials (and other encrypted files). + # config.require_master_key = true + + # Disable serving static files from `public/`, relying on NGINX/Apache to do so instead. + # config.public_file_server.enabled = false + + # Compress CSS using a preprocessor. + config.assets.js_compressor = :terser + # config.assets.css_compressor = :sass + + # Do not fallback to assets pipeline if a precompiled asset is missed. + config.assets.compile = false + + # Enable serving of images, stylesheets, and JavaScripts from an asset server. + # config.asset_host = "http://assets.example.com" + + # Specifies the header that your server uses for sending files. + # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache + # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Mount Action Cable outside main process or domain. + # config.action_cable.mount_path = nil + # config.action_cable.url = "wss://example.com/cable" + # config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ] + + # Assume all access to the app is happening through a SSL-terminating reverse proxy. + # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies. + # config.assume_ssl = true + + # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. + config.force_ssl = true + + # Log to STDOUT by default + config.logger = ActiveSupport::Logger.new(STDOUT) + .tap { |logger| logger.formatter = ::Logger::Formatter.new } + .then { |logger| ActiveSupport::TaggedLogging.new(logger) } + + # Prepend all log lines with the following tags. + config.log_tags = [ :request_id ] + + # Info include generic and useful information about system operation, but avoids logging too much + # information to avoid inadvertent exposure of personally identifiable information (PII). If you + # want to log everything, set the level to "debug". + config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") + + # Use a different cache store in production. + # config.cache_store = :mem_cache_store + + # Use a real queuing backend for Active Job (and separate queues per environment). + # config.active_job.queue_adapter = :resque + # config.active_job.queue_name_prefix = "wittle_production" + + config.action_mailer.perform_caching = false + + # Ignore bad email addresses and do not raise email delivery errors. + # Set this to true and configure the email server for immediate delivery to raise delivery errors. + # config.action_mailer.raise_delivery_errors = false + + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to + # the I18n.default_locale when a translation cannot be found). + config.i18n.fallbacks = true + + # Don't log any deprecations. + config.active_support.report_deprecations = false + + # Do not dump schema after migrations. + config.active_record.dump_schema_after_migration = false + + # Enable DNS rebinding protection and other `Host` header attacks. + # config.hosts = [ + # "example.com", # Allow requests from example.com + # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` + # ] + # Skip DNS rebinding protection for the default health check endpoint. + # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } +end diff --git a/config/environments/test.rb b/config/environments/test.rb new file mode 100644 index 0000000..adbb4a6 --- /dev/null +++ b/config/environments/test.rb @@ -0,0 +1,64 @@ +require "active_support/core_ext/integer/time" + +# The test environment is used exclusively to run your application's +# test suite. You never need to work with it otherwise. Remember that +# your test database is "scratch space" for the test suite and is wiped +# and recreated between test runs. Don't rely on the data there! + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # While tests run files are not watched, reloading is not necessary. + config.enable_reloading = false + + # Eager loading loads your entire application. When running a single test locally, + # this is usually not necessary, and can slow down your test suite. However, it's + # recommended that you enable it in continuous integration systems to ensure eager + # loading is working properly before deploying your code. + config.eager_load = ENV["CI"].present? + + # Configure public file server for tests with Cache-Control for performance. + config.public_file_server.enabled = true + config.public_file_server.headers = { + "Cache-Control" => "public, max-age=#{1.hour.to_i}" + } + + # Show full error reports and disable caching. + config.consider_all_requests_local = true + config.action_controller.perform_caching = false + config.cache_store = :null_store + + # Render exception templates for rescuable exceptions and raise for other exceptions. + config.action_dispatch.show_exceptions = :rescuable + + # Disable request forgery protection in test environment. + config.action_controller.allow_forgery_protection = false + + # Store uploaded files on the local file system in a temporary directory. + config.active_storage.service = :test + + config.action_mailer.perform_caching = false + + # Tell Action Mailer not to deliver emails to the real world. + # The :test delivery method accumulates sent emails in the + # ActionMailer::Base.deliveries array. + config.action_mailer.delivery_method = :test + + # Print deprecation notices to the stderr. + config.active_support.deprecation = :stderr + + # Raise exceptions for disallowed deprecations. + config.active_support.disallowed_deprecation = :raise + + # Tell Active Support which deprecation messages to disallow. + config.active_support.disallowed_deprecation_warnings = [] + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Raise error when a before_action's only/except options reference missing actions + config.action_controller.raise_on_missing_callback_actions = true +end diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb new file mode 100644 index 0000000..2eeef96 --- /dev/null +++ b/config/initializers/assets.rb @@ -0,0 +1,12 @@ +# Be sure to restart your server when you modify this file. + +# Version of your assets, change this if you want to expire all your assets. +Rails.application.config.assets.version = "1.0" + +# Add additional assets to the asset load path. +# Rails.application.config.assets.paths << Emoji.images_path + +# Precompile additional assets. +# application.js, application.css, and all non-JS/CSS in the app/assets +# folder are already added. +# Rails.application.config.assets.precompile += %w( admin.js admin.css ) diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb new file mode 100644 index 0000000..b3076b3 --- /dev/null +++ b/config/initializers/content_security_policy.rb @@ -0,0 +1,25 @@ +# Be sure to restart your server when you modify this file. + +# Define an application-wide content security policy. +# See the Securing Rails Applications Guide for more information: +# https://guides.rubyonrails.org/security.html#content-security-policy-header + +# Rails.application.configure do +# config.content_security_policy do |policy| +# policy.default_src :self, :https +# policy.font_src :self, :https, :data +# policy.img_src :self, :https, :data +# policy.object_src :none +# policy.script_src :self, :https +# policy.style_src :self, :https +# # Specify URI for violation reports +# # policy.report_uri "/csp-violation-report-endpoint" +# end +# +# # Generate session nonces for permitted importmap, inline scripts, and inline styles. +# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } +# config.content_security_policy_nonce_directives = %w(script-src style-src) +# +# # Report violations without enforcing the policy. +# # config.content_security_policy_report_only = true +# end diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb new file mode 100644 index 0000000..c2d89e2 --- /dev/null +++ b/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,8 @@ +# Be sure to restart your server when you modify this file. + +# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. +# Use this to limit dissemination of sensitive information. +# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. +Rails.application.config.filter_parameters += [ + :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn +] diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb new file mode 100644 index 0000000..3860f65 --- /dev/null +++ b/config/initializers/inflections.rb @@ -0,0 +1,16 @@ +# Be sure to restart your server when you modify this file. + +# Add new inflection rules using the following format. Inflections +# are locale specific, and you may define rules for as many different +# locales as you wish. All of these examples are active by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.plural /^(ox)$/i, "\\1en" +# inflect.singular /^(ox)en/i, "\\1" +# inflect.irregular "person", "people" +# inflect.uncountable %w( fish sheep ) +# end + +# These inflection rules are supported but not enabled by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.acronym "RESTful" +# end diff --git a/config/initializers/permissions_policy.rb b/config/initializers/permissions_policy.rb new file mode 100644 index 0000000..7db3b95 --- /dev/null +++ b/config/initializers/permissions_policy.rb @@ -0,0 +1,13 @@ +# Be sure to restart your server when you modify this file. + +# Define an application-wide HTTP permissions policy. For further +# information see: https://developers.google.com/web/updates/2018/06/feature-policy + +# Rails.application.config.permissions_policy do |policy| +# policy.camera :none +# policy.gyroscope :none +# policy.microphone :none +# policy.usb :none +# policy.fullscreen :self +# policy.payment :self, "https://secure.example.com" +# end diff --git a/config/locales/en.yml b/config/locales/en.yml new file mode 100644 index 0000000..6c349ae --- /dev/null +++ b/config/locales/en.yml @@ -0,0 +1,31 @@ +# Files in the config/locales directory are used for internationalization and +# are automatically loaded by Rails. If you want to use locales other than +# English, add the necessary files in this directory. +# +# To use the locales, use `I18n.t`: +# +# I18n.t "hello" +# +# In views, this is aliased to just `t`: +# +# <%= t("hello") %> +# +# To use a different locale, set it with `I18n.locale`: +# +# I18n.locale = :es +# +# This would use the information in config/locales/es.yml. +# +# To learn more about the API, please read the Rails Internationalization guide +# at https://guides.rubyonrails.org/i18n.html. +# +# Be aware that YAML interprets the following case-insensitive strings as +# booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings +# must be quoted to be interpreted as strings. For example: +# +# en: +# "yes": yup +# enabled: "ON" + +en: + hello: "Hello world" diff --git a/config/puma.rb b/config/puma.rb new file mode 100644 index 0000000..afa809b --- /dev/null +++ b/config/puma.rb @@ -0,0 +1,35 @@ +# This configuration file will be evaluated by Puma. The top-level methods that +# are invoked here are part of Puma's configuration DSL. For more information +# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. + +# Puma can serve each request in a thread from an internal thread pool. +# The `threads` method setting takes two numbers: a minimum and maximum. +# Any libraries that use thread pools should be configured to match +# the maximum value specified for Puma. Default is set to 5 threads for minimum +# and maximum; this matches the default thread size of Active Record. +max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } +min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } +threads min_threads_count, max_threads_count + +# Specifies that the worker count should equal the number of processors in production. +if ENV["RAILS_ENV"] == "production" + require "concurrent-ruby" + worker_count = Integer(ENV.fetch("WEB_CONCURRENCY") { Concurrent.physical_processor_count }) + workers worker_count if worker_count > 1 +end + +# Specifies the `worker_timeout` threshold that Puma will use to wait before +# terminating a worker in development environments. +worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +port ENV.fetch("PORT") { 3000 } + +# Specifies the `environment` that Puma will run in. +environment ENV.fetch("RAILS_ENV") { "development" } + +# Specifies the `pidfile` that Puma will use. +pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } + +# Allow puma to be restarted by `bin/rails restart` command. +plugin :tmp_restart diff --git a/config/routes.rb b/config/routes.rb index 346315e..3a93d2a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,4 @@ -Wittle::Engine.routes.draw do +Rails.application.routes.draw do root to: 'puzzles#about' get 'archive' => 'puzzles#index' get ':id' => 'puzzles#show', as: 'puzzle' diff --git a/config/storage.yml b/config/storage.yml new file mode 100644 index 0000000..4942ab6 --- /dev/null +++ b/config/storage.yml @@ -0,0 +1,34 @@ +test: + service: Disk + root: <%= Rails.root.join("tmp/storage") %> + +local: + service: Disk + root: <%= Rails.root.join("storage") %> + +# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) +# amazon: +# service: S3 +# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> +# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> +# region: us-east-1 +# bucket: your_own_bucket-<%= Rails.env %> + +# Remember not to checkin your GCS keyfile to a repository +# google: +# service: GCS +# project: your_project +# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> +# bucket: your_own_bucket-<%= Rails.env %> + +# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) +# microsoft: +# service: AzureStorage +# storage_account_name: your_account_name +# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> +# container: your_container_name-<%= Rails.env %> + +# mirror: +# service: Mirror +# primary: local +# mirrors: [ amazon, google, microsoft ] diff --git a/db/migrate/20231028205751_create_wittle_puzzles.rb b/db/migrate/20231028205751_create_wittle_puzzles.rb deleted file mode 100644 index ebe0bf7..0000000 --- a/db/migrate/20231028205751_create_wittle_puzzles.rb +++ /dev/null @@ -1,11 +0,0 @@ -class CreateWittlePuzzles < ActiveRecord::Migration[7.1] - def change - create_table :wittle_puzzles do |t| - t.text :data - t.text :solved_data - t.string :category - - t.timestamps - end - end -end diff --git a/db/migrate/20231028210722_create_wittle_scores.rb b/db/migrate/20231028210722_create_wittle_scores.rb deleted file mode 100644 index aa49a13..0000000 --- a/db/migrate/20231028210722_create_wittle_scores.rb +++ /dev/null @@ -1,12 +0,0 @@ -class CreateWittleScores < ActiveRecord::Migration[7.1] - def change - create_table :wittle_scores do |t| - t.references :puzzle, null: false - t.string :name - t.string :ip - t.integer :seconds_taken - - t.timestamps - end - end -end diff --git a/db/migrate/20231130173455_create_puzzles.rb b/db/migrate/20231130173455_create_puzzles.rb new file mode 100644 index 0000000..548473a --- /dev/null +++ b/db/migrate/20231130173455_create_puzzles.rb @@ -0,0 +1,11 @@ +class CreatePuzzles < ActiveRecord::Migration[7.1] + def change + create_table :puzzles do |t| + t.text :data + t.text :solved_data + t.string :category + + t.timestamps + end + end +end diff --git a/db/migrate/20231130173513_create_scores.rb b/db/migrate/20231130173513_create_scores.rb new file mode 100644 index 0000000..953ba8d --- /dev/null +++ b/db/migrate/20231130173513_create_scores.rb @@ -0,0 +1,12 @@ +class CreateScores < ActiveRecord::Migration[7.1] + def change + create_table :scores do |t| + t.references :puzzle, null: false + t.string :name + t.string :ip + t.integer :seconds_taken + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..73725c5 --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,32 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[7.1].define(version: 2023_11_30_173513) do + create_table "puzzles", force: :cascade do |t| + t.text "data" + t.text "solved_data" + t.string "category" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "scores", force: :cascade do |t| + t.integer "puzzle_id", null: false + t.string "name" + t.string "ip" + t.integer "seconds_taken" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["puzzle_id"], name: "index_scores_on_puzzle_id" + end + +end diff --git a/db/seeds.rb b/db/seeds.rb new file mode 100644 index 0000000..4fbd6ed --- /dev/null +++ b/db/seeds.rb @@ -0,0 +1,9 @@ +# This file should ensure the existence of records required to run the application in every environment (production, +# development, test). The code here should be idempotent so that it can be executed at any point in every environment. +# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). +# +# Example: +# +# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| +# MovieGenre.find_or_create_by!(name: genre_name) +# end diff --git a/lib/assets/.keep b/lib/assets/.keep new file mode 100644 index 0000000..e69de29 diff --git a/lib/keep b/lib/keep deleted file mode 100644 index e69de29..0000000 diff --git a/lib/tasks/.keep b/lib/tasks/.keep new file mode 100644 index 0000000..e69de29 diff --git a/lib/tasks/wittle_tasks.rake b/lib/tasks/wittle_tasks.rake index b9300c7..1be45e2 100644 --- a/lib/tasks/wittle_tasks.rake +++ b/lib/tasks/wittle_tasks.rake @@ -1,8 +1,10 @@ namespace :wittle do desc "Generate new puzzles for the day" task :generate_puzzles => :environment do - Wittle::Puzzle.create(data: WittleGenerator.new.generate_easy, category: :normal) - Wittle::Puzzle.create(data: WittleGenerator.new.generate_medium, category: :hard) - Wittle::Puzzle.create(data: WittleGenerator.new.generate_expert, category: :expert) + require "wittle_generator" + + Puzzle.create(data: WittleGenerator.new.generate_easy, category: :normal) + Puzzle.create(data: WittleGenerator.new.generate_medium, category: :hard) + Puzzle.create(data: WittleGenerator.new.generate_expert, category: :expert) end end diff --git a/lib/wittle.rb b/lib/wittle.rb deleted file mode 100644 index 44bfc3f..0000000 --- a/lib/wittle.rb +++ /dev/null @@ -1,6 +0,0 @@ -require "wittle/version" -require "wittle/engine" - -module Wittle - # Your code goes here... -end diff --git a/lib/wittle/engine.rb b/lib/wittle/engine.rb deleted file mode 100644 index 13b8d2b..0000000 --- a/lib/wittle/engine.rb +++ /dev/null @@ -1,11 +0,0 @@ -require "enumerize" -require "haml" -require "jquery-rails" -require "sassc-rails" -require "wittle_generator" - -module Wittle - class Engine < ::Rails::Engine - isolate_namespace Wittle - end -end diff --git a/lib/wittle/version.rb b/lib/wittle/version.rb deleted file mode 100644 index a1538e2..0000000 --- a/lib/wittle/version.rb +++ /dev/null @@ -1,3 +0,0 @@ -module Wittle - VERSION = "0.1.0" -end diff --git a/log/.keep b/log/.keep new file mode 100644 index 0000000..e69de29 diff --git a/public/404.html b/public/404.html new file mode 100644 index 0000000..2be3af2 --- /dev/null +++ b/public/404.html @@ -0,0 +1,67 @@ + + + + The page you were looking for doesn't exist (404) + + + + + + +
+
+

The page you were looking for doesn't exist.

+

You may have mistyped the address or the page may have moved.

+
+

If you are the application owner check the logs for more information.

+
+ + diff --git a/public/422.html b/public/422.html new file mode 100644 index 0000000..c08eac0 --- /dev/null +++ b/public/422.html @@ -0,0 +1,67 @@ + + + + The change you wanted was rejected (422) + + + + + + +
+
+

The change you wanted was rejected.

+

Maybe you tried to change something you didn't have access to.

+
+

If you are the application owner check the logs for more information.

+
+ + diff --git a/public/500.html b/public/500.html new file mode 100644 index 0000000..78a030a --- /dev/null +++ b/public/500.html @@ -0,0 +1,66 @@ + + + + We're sorry, but something went wrong (500) + + + + + + +
+
+

We're sorry, but something went wrong.

+
+

If you are the application owner check the logs for more information.

+
+ + diff --git a/public/apple-touch-icon-precomposed.png b/public/apple-touch-icon-precomposed.png new file mode 100644 index 0000000..e69de29 diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..e69de29 diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..c19f78a --- /dev/null +++ b/public/robots.txt @@ -0,0 +1 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file diff --git a/storage/.keep b/storage/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb new file mode 100644 index 0000000..d19212a --- /dev/null +++ b/test/application_system_test_case.rb @@ -0,0 +1,5 @@ +require "test_helper" + +class ApplicationSystemTestCase < ActionDispatch::SystemTestCase + driven_by :selenium, using: :chrome, screen_size: [1400, 1400] +end diff --git a/test/channels/application_cable/connection_test.rb b/test/channels/application_cable/connection_test.rb new file mode 100644 index 0000000..6340bf9 --- /dev/null +++ b/test/channels/application_cable/connection_test.rb @@ -0,0 +1,13 @@ +require "test_helper" + +module ApplicationCable + class ConnectionTest < ActionCable::Connection::TestCase + # test "connects with cookies" do + # cookies.signed[:user_id] = 42 + # + # connect + # + # assert_equal connection.user_id, "42" + # end + end +end diff --git a/test/controllers/puzzles_controller_test.rb b/test/controllers/puzzles_controller_test.rb new file mode 100644 index 0000000..1d6b8ea --- /dev/null +++ b/test/controllers/puzzles_controller_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class PuzzlesControllerTest < ActionDispatch::IntegrationTest + # test "the truth" do + # assert true + # end +end diff --git a/test/controllers/wittle/puzzles_controller_test.rb b/test/controllers/wittle/puzzles_controller_test.rb deleted file mode 100644 index d1a3ca7..0000000 --- a/test/controllers/wittle/puzzles_controller_test.rb +++ /dev/null @@ -1,12 +0,0 @@ -require "test_helper" - -module Wittle - class PuzzlesControllerTest < ActionDispatch::IntegrationTest - include Engine.routes.url_helpers - - test "should get index" do - get puzzles_index_url - assert_response :success - end - end -end diff --git a/test/dummy/Rakefile b/test/dummy/Rakefile deleted file mode 100644 index 9a5ea73..0000000 --- a/test/dummy/Rakefile +++ /dev/null @@ -1,6 +0,0 @@ -# Add your own tasks in files placed in lib/tasks ending in .rake, -# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. - -require_relative "config/application" - -Rails.application.load_tasks diff --git a/test/dummy/app/assets/config/manifest.js b/test/dummy/app/assets/config/manifest.js deleted file mode 100644 index f482c48..0000000 --- a/test/dummy/app/assets/config/manifest.js +++ /dev/null @@ -1,3 +0,0 @@ -//= link_tree ../images -//= link_directory ../stylesheets .css -//= link wittle_manifest.js diff --git a/test/dummy/app/assets/images/.keep b/test/dummy/app/assets/images/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/test/dummy/app/assets/stylesheets/application.css b/test/dummy/app/assets/stylesheets/application.css deleted file mode 100644 index 0ebd7fe..0000000 --- a/test/dummy/app/assets/stylesheets/application.css +++ /dev/null @@ -1,15 +0,0 @@ -/* - * 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 any plugin's vendor/assets/stylesheets directory 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 other CSS/SCSS - * files in this directory. Styles in this file should be added after the last require_* statement. - * It is generally better to create a new file per style scope. - * - *= require_tree . - *= require_self - */ diff --git a/test/dummy/app/channels/application_cable/channel.rb b/test/dummy/app/channels/application_cable/channel.rb deleted file mode 100644 index d672697..0000000 --- a/test/dummy/app/channels/application_cable/channel.rb +++ /dev/null @@ -1,4 +0,0 @@ -module ApplicationCable - class Channel < ActionCable::Channel::Base - end -end diff --git a/test/dummy/app/channels/application_cable/connection.rb b/test/dummy/app/channels/application_cable/connection.rb deleted file mode 100644 index 0ff5442..0000000 --- a/test/dummy/app/channels/application_cable/connection.rb +++ /dev/null @@ -1,4 +0,0 @@ -module ApplicationCable - class Connection < ActionCable::Connection::Base - end -end diff --git a/test/dummy/app/controllers/application_controller.rb b/test/dummy/app/controllers/application_controller.rb deleted file mode 100644 index 09705d1..0000000 --- a/test/dummy/app/controllers/application_controller.rb +++ /dev/null @@ -1,2 +0,0 @@ -class ApplicationController < ActionController::Base -end diff --git a/test/dummy/app/controllers/concerns/.keep b/test/dummy/app/controllers/concerns/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/test/dummy/app/helpers/application_helper.rb b/test/dummy/app/helpers/application_helper.rb deleted file mode 100644 index de6be79..0000000 --- a/test/dummy/app/helpers/application_helper.rb +++ /dev/null @@ -1,2 +0,0 @@ -module ApplicationHelper -end diff --git a/test/dummy/app/jobs/application_job.rb b/test/dummy/app/jobs/application_job.rb deleted file mode 100644 index d394c3d..0000000 --- a/test/dummy/app/jobs/application_job.rb +++ /dev/null @@ -1,7 +0,0 @@ -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 diff --git a/test/dummy/app/mailers/application_mailer.rb b/test/dummy/app/mailers/application_mailer.rb deleted file mode 100644 index 3c34c81..0000000 --- a/test/dummy/app/mailers/application_mailer.rb +++ /dev/null @@ -1,4 +0,0 @@ -class ApplicationMailer < ActionMailer::Base - default from: "from@example.com" - layout "mailer" -end diff --git a/test/dummy/app/models/application_record.rb b/test/dummy/app/models/application_record.rb deleted file mode 100644 index b63caeb..0000000 --- a/test/dummy/app/models/application_record.rb +++ /dev/null @@ -1,3 +0,0 @@ -class ApplicationRecord < ActiveRecord::Base - primary_abstract_class -end diff --git a/test/dummy/app/models/concerns/.keep b/test/dummy/app/models/concerns/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/test/dummy/app/views/layouts/application.html.erb b/test/dummy/app/views/layouts/application.html.erb deleted file mode 100644 index f72b4ef..0000000 --- a/test/dummy/app/views/layouts/application.html.erb +++ /dev/null @@ -1,15 +0,0 @@ - - - - Dummy - - <%= csrf_meta_tags %> - <%= csp_meta_tag %> - - <%= stylesheet_link_tag "application" %> - - - - <%= yield %> - - diff --git a/test/dummy/app/views/layouts/mailer.html.erb b/test/dummy/app/views/layouts/mailer.html.erb deleted file mode 100644 index 3aac900..0000000 --- a/test/dummy/app/views/layouts/mailer.html.erb +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - <%= yield %> - - diff --git a/test/dummy/app/views/layouts/mailer.text.erb b/test/dummy/app/views/layouts/mailer.text.erb deleted file mode 100644 index 37f0bdd..0000000 --- a/test/dummy/app/views/layouts/mailer.text.erb +++ /dev/null @@ -1 +0,0 @@ -<%= yield %> diff --git a/test/dummy/bin/rails b/test/dummy/bin/rails deleted file mode 100755 index efc0377..0000000 --- a/test/dummy/bin/rails +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env ruby -APP_PATH = File.expand_path("../config/application", __dir__) -require_relative "../config/boot" -require "rails/commands" diff --git a/test/dummy/bin/rake b/test/dummy/bin/rake deleted file mode 100755 index 4fbf10b..0000000 --- a/test/dummy/bin/rake +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env ruby -require_relative "../config/boot" -require "rake" -Rake.application.run diff --git a/test/dummy/bin/setup b/test/dummy/bin/setup deleted file mode 100755 index 3cd5a9d..0000000 --- a/test/dummy/bin/setup +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env ruby -require "fileutils" - -# path to your application root. -APP_ROOT = File.expand_path("..", __dir__) - -def system!(*args) - system(*args, exception: true) -end - -FileUtils.chdir APP_ROOT do - # This script is a way to set up or update your development environment automatically. - # This script is idempotent, so that you can run it at any time and get an expectable outcome. - # Add necessary setup steps to this file. - - puts "== Installing dependencies ==" - system! "gem install bundler --conservative" - system("bundle check") || system!("bundle install") - - # puts "\n== Copying sample files ==" - # unless File.exist?("config/database.yml") - # FileUtils.cp "config/database.yml.sample", "config/database.yml" - # end - - puts "\n== Preparing database ==" - system! "bin/rails db:prepare" - - puts "\n== Removing old logs and tempfiles ==" - system! "bin/rails log:clear tmp:clear" - - puts "\n== Restarting application server ==" - system! "bin/rails restart" -end diff --git a/test/dummy/config.ru b/test/dummy/config.ru deleted file mode 100644 index 4a3c09a..0000000 --- a/test/dummy/config.ru +++ /dev/null @@ -1,6 +0,0 @@ -# This file is used by Rack-based servers to start the application. - -require_relative "config/environment" - -run Rails.application -Rails.application.load_server diff --git a/test/dummy/config/application.rb b/test/dummy/config/application.rb deleted file mode 100644 index 67cc140..0000000 --- a/test/dummy/config/application.rb +++ /dev/null @@ -1,29 +0,0 @@ -require_relative "boot" - -require "rails/all" - -# Require the gems listed in Gemfile, including any gems -# you've limited to :test, :development, or :production. -Bundler.require(*Rails.groups) - -module Dummy - class Application < Rails::Application - config.load_defaults Rails::VERSION::STRING.to_f - - # For compatibility with applications that use this config - config.action_controller.include_all_helpers = false - - # Please, add to the `ignore` list any other `lib` subdirectories that do - # not contain `.rb` files, or that should not be reloaded or eager loaded. - # Common ones are `templates`, `generators`, or `middleware`, for example. - config.autoload_lib(ignore: %w(assets tasks)) - - # Configuration for the application, engines, and railties goes here. - # - # These settings can be overridden in specific environments using the files - # in config/environments, which are processed later. - # - # config.time_zone = "Central Time (US & Canada)" - # config.eager_load_paths << Rails.root.join("extras") - end -end diff --git a/test/dummy/config/boot.rb b/test/dummy/config/boot.rb deleted file mode 100644 index 116591a..0000000 --- a/test/dummy/config/boot.rb +++ /dev/null @@ -1,5 +0,0 @@ -# Set up gems listed in the Gemfile. -ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__) - -require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) -$LOAD_PATH.unshift File.expand_path("../../../lib", __dir__) diff --git a/test/dummy/config/cable.yml b/test/dummy/config/cable.yml deleted file mode 100644 index 98367f8..0000000 --- a/test/dummy/config/cable.yml +++ /dev/null @@ -1,10 +0,0 @@ -development: - adapter: async - -test: - adapter: test - -production: - adapter: redis - url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> - channel_prefix: dummy_production diff --git a/test/dummy/config/database.yml b/test/dummy/config/database.yml deleted file mode 100644 index 796466b..0000000 --- a/test/dummy/config/database.yml +++ /dev/null @@ -1,25 +0,0 @@ -# SQLite. Versions 3.8.0 and up are supported. -# gem install sqlite3 -# -# Ensure the SQLite 3 gem is defined in your Gemfile -# gem "sqlite3" -# -default: &default - adapter: sqlite3 - pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> - timeout: 5000 - -development: - <<: *default - database: storage/development.sqlite3 - -# Warning: The database defined as "test" will be erased and -# re-generated from your development database when you run "rake". -# Do not set this db to the same as development or production. -test: - <<: *default - database: storage/test.sqlite3 - -production: - <<: *default - database: storage/production.sqlite3 diff --git a/test/dummy/config/environment.rb b/test/dummy/config/environment.rb deleted file mode 100644 index cac5315..0000000 --- a/test/dummy/config/environment.rb +++ /dev/null @@ -1,5 +0,0 @@ -# Load the Rails application. -require_relative "application" - -# Initialize the Rails application. -Rails.application.initialize! diff --git a/test/dummy/config/environments/development.rb b/test/dummy/config/environments/development.rb deleted file mode 100644 index 2e7fb48..0000000 --- a/test/dummy/config/environments/development.rb +++ /dev/null @@ -1,76 +0,0 @@ -require "active_support/core_ext/integer/time" - -Rails.application.configure do - # Settings specified here will take precedence over those in config/application.rb. - - # In the development environment your application's code is reloaded any time - # it changes. This slows down response time but is perfect for development - # since you don't have to restart the web server when you make code changes. - config.enable_reloading = true - - # Do not eager load code on boot. - config.eager_load = false - - # Show full error reports. - config.consider_all_requests_local = true - - # Enable server timing - config.server_timing = true - - # Enable/disable caching. By default caching is disabled. - # Run rails dev:cache to toggle caching. - if Rails.root.join("tmp/caching-dev.txt").exist? - config.action_controller.perform_caching = true - config.action_controller.enable_fragment_cache_logging = true - - config.cache_store = :memory_store - config.public_file_server.headers = { - "Cache-Control" => "public, max-age=#{2.days.to_i}" - } - else - config.action_controller.perform_caching = false - - config.cache_store = :null_store - end - - # Store uploaded files on the local file system (see config/storage.yml for options). - config.active_storage.service = :local - - # Don't care if the mailer can't send. - config.action_mailer.raise_delivery_errors = false - - config.action_mailer.perform_caching = false - - # Print deprecation notices to the Rails logger. - config.active_support.deprecation = :log - - # Raise exceptions for disallowed deprecations. - config.active_support.disallowed_deprecation = :raise - - # Tell Active Support which deprecation messages to disallow. - config.active_support.disallowed_deprecation_warnings = [] - - # Raise an error on page load if there are pending migrations. - config.active_record.migration_error = :page_load - - # Highlight code that triggered database queries in logs. - config.active_record.verbose_query_logs = true - - # Highlight code that enqueued background job in logs. - config.active_job.verbose_enqueue_logs = true - - # Suppress logger output for asset requests. - config.assets.quiet = true - - # Raises error for missing translations. - # config.i18n.raise_on_missing_translations = true - - # Annotate rendered view with file names. - # config.action_view.annotate_rendered_view_with_filenames = true - - # Uncomment if you wish to allow Action Cable access from any origin. - # config.action_cable.disable_request_forgery_protection = true - - # Raise error when a before_action's only/except options reference missing actions - config.action_controller.raise_on_missing_callback_actions = true -end diff --git a/test/dummy/config/environments/production.rb b/test/dummy/config/environments/production.rb deleted file mode 100644 index 714394b..0000000 --- a/test/dummy/config/environments/production.rb +++ /dev/null @@ -1,97 +0,0 @@ -require "active_support/core_ext/integer/time" - -Rails.application.configure do - # Settings specified here will take precedence over those in config/application.rb. - - # Code is not reloaded between requests. - config.enable_reloading = false - - # Eager load code on boot. This eager loads most of Rails and - # your application in memory, allowing both threaded web servers - # and those relying on copy on write to perform better. - # Rake tasks automatically ignore this option for performance. - config.eager_load = true - - # Full error reports are disabled and caching is turned on. - config.consider_all_requests_local = false - config.action_controller.perform_caching = true - - # Ensures that a master key has been made available in ENV["RAILS_MASTER_KEY"], config/master.key, or an environment - # key such as config/credentials/production.key. This key is used to decrypt credentials (and other encrypted files). - # config.require_master_key = true - - # Enable static file serving from the `/public` folder (turn off if using NGINX/Apache for it). - config.public_file_server.enabled = true - - # Compress CSS using a preprocessor. - # config.assets.css_compressor = :sass - - # Do not fallback to assets pipeline if a precompiled asset is missed. - config.assets.compile = false - - # Enable serving of images, stylesheets, and JavaScripts from an asset server. - # config.asset_host = "http://assets.example.com" - - # Specifies the header that your server uses for sending files. - # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache - # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX - - # Store uploaded files on the local file system (see config/storage.yml for options). - config.active_storage.service = :local - - # Mount Action Cable outside main process or domain. - # config.action_cable.mount_path = nil - # config.action_cable.url = "wss://example.com/cable" - # config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ] - - # Assume all access to the app is happening through a SSL-terminating reverse proxy. - # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies. - # config.assume_ssl = true - - # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. - config.force_ssl = true - - # Log to STDOUT by default - config.logger = ActiveSupport::Logger.new(STDOUT) - .tap { |logger| logger.formatter = ::Logger::Formatter.new } - .then { |logger| ActiveSupport::TaggedLogging.new(logger) } - - # Prepend all log lines with the following tags. - config.log_tags = [ :request_id ] - - # Info include generic and useful information about system operation, but avoids logging too much - # information to avoid inadvertent exposure of personally identifiable information (PII). If you - # want to log everything, set the level to "debug". - config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") - - # Use a different cache store in production. - # config.cache_store = :mem_cache_store - - # Use a real queuing backend for Active Job (and separate queues per environment). - # config.active_job.queue_adapter = :resque - # config.active_job.queue_name_prefix = "dummy_production" - - config.action_mailer.perform_caching = false - - # Ignore bad email addresses and do not raise email delivery errors. - # Set this to true and configure the email server for immediate delivery to raise delivery errors. - # config.action_mailer.raise_delivery_errors = false - - # Enable locale fallbacks for I18n (makes lookups for any locale fall back to - # the I18n.default_locale when a translation cannot be found). - config.i18n.fallbacks = true - - # Don't log any deprecations. - config.active_support.report_deprecations = false - - # Do not dump schema after migrations. - config.active_record.dump_schema_after_migration = false - - # Enable DNS rebinding protection and other `Host` header attacks. - # config.hosts = [ - # "example.com", # Allow requests from example.com - # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` - # ] - # Skip DNS rebinding protection for the default health check endpoint. - # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } -end diff --git a/test/dummy/config/environments/test.rb b/test/dummy/config/environments/test.rb deleted file mode 100644 index 0dda9f9..0000000 --- a/test/dummy/config/environments/test.rb +++ /dev/null @@ -1,64 +0,0 @@ -require "active_support/core_ext/integer/time" - -# The test environment is used exclusively to run your application's -# test suite. You never need to work with it otherwise. Remember that -# your test database is "scratch space" for the test suite and is wiped -# and recreated between test runs. Don't rely on the data there! - -Rails.application.configure do - # Settings specified here will take precedence over those in config/application.rb. - - # While tests run files are not watched, reloading is not necessary. - config.enable_reloading = false - - # Eager loading loads your entire application. When running a single test locally, - # this is usually not necessary, and can slow down your test suite. However, it's - # recommended that you enable it in continuous integration systems to ensure eager - # loading is working properly before deploying your code. - config.eager_load = ENV["CI"].present? - - # Configure public file server for tests with Cache-Control for performance. - config.public_file_server.enabled = true - config.public_file_server.headers = { - "Cache-Control" => "public, max-age=#{1.hour.to_i}" - } - - # Show full error reports and disable caching. - config.consider_all_requests_local = true - config.action_controller.perform_caching = false - config.cache_store = :null_store - - # Raise exceptions instead of rendering exception templates. - config.action_dispatch.show_exceptions = :rescuable - - # Disable request forgery protection in test environment. - config.action_controller.allow_forgery_protection = false - - # Store uploaded files on the local file system in a temporary directory. - config.active_storage.service = :test - - config.action_mailer.perform_caching = false - - # Tell Action Mailer not to deliver emails to the real world. - # The :test delivery method accumulates sent emails in the - # ActionMailer::Base.deliveries array. - config.action_mailer.delivery_method = :test - - # Print deprecation notices to the stderr. - config.active_support.deprecation = :stderr - - # Raise exceptions for disallowed deprecations. - config.active_support.disallowed_deprecation = :raise - - # Tell Active Support which deprecation messages to disallow. - config.active_support.disallowed_deprecation_warnings = [] - - # Raises error for missing translations. - # config.i18n.raise_on_missing_translations = true - - # Annotate rendered view with file names. - # config.action_view.annotate_rendered_view_with_filenames = true - - # Raise error when a before_action's only/except options reference missing actions - config.action_controller.raise_on_missing_callback_actions = true -end diff --git a/test/dummy/config/initializers/assets.rb b/test/dummy/config/initializers/assets.rb deleted file mode 100644 index 2eeef96..0000000 --- a/test/dummy/config/initializers/assets.rb +++ /dev/null @@ -1,12 +0,0 @@ -# Be sure to restart your server when you modify this file. - -# Version of your assets, change this if you want to expire all your assets. -Rails.application.config.assets.version = "1.0" - -# Add additional assets to the asset load path. -# Rails.application.config.assets.paths << Emoji.images_path - -# Precompile additional assets. -# application.js, application.css, and all non-JS/CSS in the app/assets -# folder are already added. -# Rails.application.config.assets.precompile += %w( admin.js admin.css ) diff --git a/test/dummy/config/initializers/content_security_policy.rb b/test/dummy/config/initializers/content_security_policy.rb deleted file mode 100644 index b3076b3..0000000 --- a/test/dummy/config/initializers/content_security_policy.rb +++ /dev/null @@ -1,25 +0,0 @@ -# Be sure to restart your server when you modify this file. - -# Define an application-wide content security policy. -# See the Securing Rails Applications Guide for more information: -# https://guides.rubyonrails.org/security.html#content-security-policy-header - -# Rails.application.configure do -# config.content_security_policy do |policy| -# policy.default_src :self, :https -# policy.font_src :self, :https, :data -# policy.img_src :self, :https, :data -# policy.object_src :none -# policy.script_src :self, :https -# policy.style_src :self, :https -# # Specify URI for violation reports -# # policy.report_uri "/csp-violation-report-endpoint" -# end -# -# # Generate session nonces for permitted importmap, inline scripts, and inline styles. -# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } -# config.content_security_policy_nonce_directives = %w(script-src style-src) -# -# # Report violations without enforcing the policy. -# # config.content_security_policy_report_only = true -# end diff --git a/test/dummy/config/initializers/filter_parameter_logging.rb b/test/dummy/config/initializers/filter_parameter_logging.rb deleted file mode 100644 index c2d89e2..0000000 --- a/test/dummy/config/initializers/filter_parameter_logging.rb +++ /dev/null @@ -1,8 +0,0 @@ -# Be sure to restart your server when you modify this file. - -# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. -# Use this to limit dissemination of sensitive information. -# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. -Rails.application.config.filter_parameters += [ - :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn -] diff --git a/test/dummy/config/initializers/inflections.rb b/test/dummy/config/initializers/inflections.rb deleted file mode 100644 index 3860f65..0000000 --- a/test/dummy/config/initializers/inflections.rb +++ /dev/null @@ -1,16 +0,0 @@ -# Be sure to restart your server when you modify this file. - -# Add new inflection rules using the following format. Inflections -# are locale specific, and you may define rules for as many different -# locales as you wish. All of these examples are active by default: -# ActiveSupport::Inflector.inflections(:en) do |inflect| -# inflect.plural /^(ox)$/i, "\\1en" -# inflect.singular /^(ox)en/i, "\\1" -# inflect.irregular "person", "people" -# inflect.uncountable %w( fish sheep ) -# end - -# These inflection rules are supported but not enabled by default: -# ActiveSupport::Inflector.inflections(:en) do |inflect| -# inflect.acronym "RESTful" -# end diff --git a/test/dummy/config/initializers/permissions_policy.rb b/test/dummy/config/initializers/permissions_policy.rb deleted file mode 100644 index 7db3b95..0000000 --- a/test/dummy/config/initializers/permissions_policy.rb +++ /dev/null @@ -1,13 +0,0 @@ -# Be sure to restart your server when you modify this file. - -# Define an application-wide HTTP permissions policy. For further -# information see: https://developers.google.com/web/updates/2018/06/feature-policy - -# Rails.application.config.permissions_policy do |policy| -# policy.camera :none -# policy.gyroscope :none -# policy.microphone :none -# policy.usb :none -# policy.fullscreen :self -# policy.payment :self, "https://secure.example.com" -# end diff --git a/test/dummy/config/locales/en.yml b/test/dummy/config/locales/en.yml deleted file mode 100644 index 6c349ae..0000000 --- a/test/dummy/config/locales/en.yml +++ /dev/null @@ -1,31 +0,0 @@ -# Files in the config/locales directory are used for internationalization and -# are automatically loaded by Rails. If you want to use locales other than -# English, add the necessary files in this directory. -# -# To use the locales, use `I18n.t`: -# -# I18n.t "hello" -# -# In views, this is aliased to just `t`: -# -# <%= t("hello") %> -# -# To use a different locale, set it with `I18n.locale`: -# -# I18n.locale = :es -# -# This would use the information in config/locales/es.yml. -# -# To learn more about the API, please read the Rails Internationalization guide -# at https://guides.rubyonrails.org/i18n.html. -# -# Be aware that YAML interprets the following case-insensitive strings as -# booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings -# must be quoted to be interpreted as strings. For example: -# -# en: -# "yes": yup -# enabled: "ON" - -en: - hello: "Hello world" diff --git a/test/dummy/config/puma.rb b/test/dummy/config/puma.rb deleted file mode 100644 index afa809b..0000000 --- a/test/dummy/config/puma.rb +++ /dev/null @@ -1,35 +0,0 @@ -# This configuration file will be evaluated by Puma. The top-level methods that -# are invoked here are part of Puma's configuration DSL. For more information -# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. - -# Puma can serve each request in a thread from an internal thread pool. -# The `threads` method setting takes two numbers: a minimum and maximum. -# Any libraries that use thread pools should be configured to match -# the maximum value specified for Puma. Default is set to 5 threads for minimum -# and maximum; this matches the default thread size of Active Record. -max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } -min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } -threads min_threads_count, max_threads_count - -# Specifies that the worker count should equal the number of processors in production. -if ENV["RAILS_ENV"] == "production" - require "concurrent-ruby" - worker_count = Integer(ENV.fetch("WEB_CONCURRENCY") { Concurrent.physical_processor_count }) - workers worker_count if worker_count > 1 -end - -# Specifies the `worker_timeout` threshold that Puma will use to wait before -# terminating a worker in development environments. -worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" - -# Specifies the `port` that Puma will listen on to receive requests; default is 3000. -port ENV.fetch("PORT") { 3000 } - -# Specifies the `environment` that Puma will run in. -environment ENV.fetch("RAILS_ENV") { "development" } - -# Specifies the `pidfile` that Puma will use. -pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } - -# Allow puma to be restarted by `bin/rails restart` command. -plugin :tmp_restart diff --git a/test/dummy/config/routes.rb b/test/dummy/config/routes.rb deleted file mode 100644 index 2e69726..0000000 --- a/test/dummy/config/routes.rb +++ /dev/null @@ -1,3 +0,0 @@ -Rails.application.routes.draw do - mount Wittle::Engine => "/wittle" -end diff --git a/test/dummy/config/storage.yml b/test/dummy/config/storage.yml deleted file mode 100644 index 4942ab6..0000000 --- a/test/dummy/config/storage.yml +++ /dev/null @@ -1,34 +0,0 @@ -test: - service: Disk - root: <%= Rails.root.join("tmp/storage") %> - -local: - service: Disk - root: <%= Rails.root.join("storage") %> - -# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) -# amazon: -# service: S3 -# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> -# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> -# region: us-east-1 -# bucket: your_own_bucket-<%= Rails.env %> - -# Remember not to checkin your GCS keyfile to a repository -# google: -# service: GCS -# project: your_project -# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> -# bucket: your_own_bucket-<%= Rails.env %> - -# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) -# microsoft: -# service: AzureStorage -# storage_account_name: your_account_name -# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> -# container: your_container_name-<%= Rails.env %> - -# mirror: -# service: Mirror -# primary: local -# mirrors: [ amazon, google, microsoft ] diff --git a/test/dummy/db/schema.rb b/test/dummy/db/schema.rb deleted file mode 100644 index 3b8bd7f..0000000 --- a/test/dummy/db/schema.rb +++ /dev/null @@ -1,32 +0,0 @@ -# This file is auto-generated from the current state of the database. Instead -# of editing this file, please use the migrations feature of Active Record to -# incrementally modify your database, and then regenerate this schema definition. -# -# This file is the source Rails uses to define your schema when running `bin/rails -# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to -# be faster and is potentially less error prone than running all of your -# migrations from scratch. Old migrations may fail to apply correctly if those -# migrations use external dependencies or application code. -# -# It's strongly recommended that you check this file into your version control system. - -ActiveRecord::Schema[7.1].define(version: 2023_10_28_210722) do - create_table "wittle_puzzles", force: :cascade do |t| - t.text "data" - t.text "solved_data" - t.string "category" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - end - - create_table "wittle_scores", force: :cascade do |t| - t.integer "puzzle_id", null: false - t.string "name" - t.string "ip" - t.integer "seconds_taken" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["puzzle_id"], name: "index_wittle_scores_on_puzzle_id" - end - -end diff --git a/test/dummy/lib/assets/.keep b/test/dummy/lib/assets/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/test/dummy/public/404.html b/test/dummy/public/404.html deleted file mode 100644 index 2be3af2..0000000 --- a/test/dummy/public/404.html +++ /dev/null @@ -1,67 +0,0 @@ - - - - The page you were looking for doesn't exist (404) - - - - - - -
-
-

The page you were looking for doesn't exist.

-

You may have mistyped the address or the page may have moved.

-
-

If you are the application owner check the logs for more information.

-
- - diff --git a/test/dummy/public/422.html b/test/dummy/public/422.html deleted file mode 100644 index c08eac0..0000000 --- a/test/dummy/public/422.html +++ /dev/null @@ -1,67 +0,0 @@ - - - - The change you wanted was rejected (422) - - - - - - -
-
-

The change you wanted was rejected.

-

Maybe you tried to change something you didn't have access to.

-
-

If you are the application owner check the logs for more information.

-
- - diff --git a/test/dummy/public/500.html b/test/dummy/public/500.html deleted file mode 100644 index 78a030a..0000000 --- a/test/dummy/public/500.html +++ /dev/null @@ -1,66 +0,0 @@ - - - - We're sorry, but something went wrong (500) - - - - - - -
-
-

We're sorry, but something went wrong.

-
-

If you are the application owner check the logs for more information.

-
- - diff --git a/test/dummy/public/apple-touch-icon-precomposed.png b/test/dummy/public/apple-touch-icon-precomposed.png deleted file mode 100644 index e69de29..0000000 diff --git a/test/dummy/public/apple-touch-icon.png b/test/dummy/public/apple-touch-icon.png deleted file mode 100644 index e69de29..0000000 diff --git a/test/dummy/public/favicon.ico b/test/dummy/public/favicon.ico deleted file mode 100644 index e69de29..0000000 diff --git a/test/fixtures/puzzles.yml b/test/fixtures/puzzles.yml new file mode 100644 index 0000000..75f9eab --- /dev/null +++ b/test/fixtures/puzzles.yml @@ -0,0 +1,11 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + data: MyText + solved_data: MyText + category: MyString + +two: + data: MyText + solved_data: MyText + category: MyString diff --git a/test/fixtures/scores.yml b/test/fixtures/scores.yml new file mode 100644 index 0000000..1f21c4c --- /dev/null +++ b/test/fixtures/scores.yml @@ -0,0 +1,13 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + puzzle: one + name: MyString + ip: MyString + seconds_taken: 1 + +two: + puzzle: two + name: MyString + ip: MyString + seconds_taken: 1 diff --git a/test/fixtures/wittle/puzzles.yml b/test/fixtures/wittle/puzzles.yml deleted file mode 100644 index 75f9eab..0000000 --- a/test/fixtures/wittle/puzzles.yml +++ /dev/null @@ -1,11 +0,0 @@ -# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html - -one: - data: MyText - solved_data: MyText - category: MyString - -two: - data: MyText - solved_data: MyText - category: MyString diff --git a/test/fixtures/wittle/scores.yml b/test/fixtures/wittle/scores.yml deleted file mode 100644 index 1f21c4c..0000000 --- a/test/fixtures/wittle/scores.yml +++ /dev/null @@ -1,13 +0,0 @@ -# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html - -one: - puzzle: one - name: MyString - ip: MyString - seconds_taken: 1 - -two: - puzzle: two - name: MyString - ip: MyString - seconds_taken: 1 diff --git a/test/integration/navigation_test.rb b/test/integration/navigation_test.rb deleted file mode 100644 index ebbc098..0000000 --- a/test/integration/navigation_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "test_helper" - -class NavigationTest < ActionDispatch::IntegrationTest - # test "the truth" do - # assert true - # end -end diff --git a/test/models/puzzle_test.rb b/test/models/puzzle_test.rb new file mode 100644 index 0000000..6736bf6 --- /dev/null +++ b/test/models/puzzle_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class PuzzleTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/score_test.rb b/test/models/score_test.rb new file mode 100644 index 0000000..eeb5563 --- /dev/null +++ b/test/models/score_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class ScoreTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/wittle/puzzle_test.rb b/test/models/wittle/puzzle_test.rb deleted file mode 100644 index f826aa3..0000000 --- a/test/models/wittle/puzzle_test.rb +++ /dev/null @@ -1,9 +0,0 @@ -require "test_helper" - -module Wittle - class PuzzleTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end - end -end diff --git a/test/models/wittle/score_test.rb b/test/models/wittle/score_test.rb deleted file mode 100644 index 0d5e4a3..0000000 --- a/test/models/wittle/score_test.rb +++ /dev/null @@ -1,9 +0,0 @@ -require "test_helper" - -module Wittle - class ScoreTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end - end -end diff --git a/test/system/.keep b/test/system/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/test_helper.rb b/test/test_helper.rb index 9d47a1b..0c22470 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,15 +1,15 @@ -# Configure Rails Environment -ENV["RAILS_ENV"] = "test" - -require_relative "../test/dummy/config/environment" -ActiveRecord::Migrator.migrations_paths = [File.expand_path("../test/dummy/db/migrate", __dir__)] -ActiveRecord::Migrator.migrations_paths << File.expand_path("../db/migrate", __dir__) +ENV["RAILS_ENV"] ||= "test" +require_relative "../config/environment" require "rails/test_help" -# Load fixtures from the engine -if ActiveSupport::TestCase.respond_to?(:fixture_paths=) - ActiveSupport::TestCase.fixture_paths = [File.expand_path("fixtures", __dir__)] - ActionDispatch::IntegrationTest.fixture_paths = ActiveSupport::TestCase.fixture_paths - ActiveSupport::TestCase.file_fixture_path = File.expand_path("fixtures", __dir__) + "/files" - ActiveSupport::TestCase.fixtures :all +module ActiveSupport + class TestCase + # Run tests in parallel with specified workers + parallelize(workers: :number_of_processors) + + # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. + fixtures :all + + # Add more helper methods to be used by all tests here... + end end diff --git a/test/wittle_test.rb b/test/wittle_test.rb deleted file mode 100644 index e28a047..0000000 --- a/test/wittle_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "test_helper" - -class WittleTest < ActiveSupport::TestCase - test "it has a version number" do - assert Wittle::VERSION - end -end diff --git a/tmp/.keep b/tmp/.keep new file mode 100644 index 0000000..e69de29 diff --git a/tmp/pids/.keep b/tmp/pids/.keep new file mode 100644 index 0000000..e69de29 diff --git a/tmp/storage/.keep b/tmp/storage/.keep new file mode 100644 index 0000000..e69de29 diff --git a/vendor/.keep b/vendor/.keep new file mode 100644 index 0000000..e69de29 diff --git a/vendor/javascript/.keep b/vendor/javascript/.keep new file mode 100644 index 0000000..e69de29 diff --git a/wittle.gemspec b/wittle.gemspec deleted file mode 100644 index 38741ba..0000000 --- a/wittle.gemspec +++ /dev/null @@ -1,24 +0,0 @@ -require_relative "lib/wittle/version" - -Gem::Specification.new do |s| - s.name = 'wittle' - s.version = Wittle::VERSION - s.date = '2023-10-27' - s.summary = 'Wittle puzzles generator' - s.authors = ['hatkirby', 'Sigma144', 'jbzdarkid'] - s.email = ['fefferburbia@gmail.com'] - s.licenses = ['MIT'] - s.homepage = 'https://code.fourisland.com/wittle-generator' - s.extensions = ['ext/wittle_generator/extconf.rb'] - s.require_paths = ['lib'] - s.files = Dir.chdir(File.expand_path(__dir__)) do - Dir["{app,config,db,lib,ext}/**/*", "MIT-LICENSE", "Rakefile", "README.md"] - end - - s.add_dependency "rails", ">= 7.1.1" - s.add_dependency "rice" - s.add_dependency "haml" - s.add_dependency "enumerize" - s.add_dependency 'sassc-rails' - s.add_dependency 'jquery-rails' -end -- cgit 1.4.1