commit f25fd6f8022dc35b6983586f5ac540422e56c291 Author: kontei Date: Sun Feb 15 14:57:17 2026 +0900 フロントエンドプレイアブル diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..325bfc0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,51 @@ +# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files. + +# Ignore git directory. +/.git/ +/.gitignore + +# Ignore bundler config. +/.bundle + +# Ignore all environment files. +/.env* + +# Ignore all default key files. +/config/master.key +/config/credentials/*.key + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/.keep + +# Ignore assets. +/node_modules/ +/app/assets/builds/* +!/app/assets/builds/.keep +/public/assets + +# Ignore CI service files. +/.github + +# Ignore Kamal files. +/config/deploy*.yml +/.kamal + +# Ignore development files +/.devcontainer + +# Ignore Docker-related files +/.dockerignore +/Dockerfile* 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/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..83610cf --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: +- package-ecosystem: bundler + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 +- package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6824190 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,124 @@ +name: CI + +on: + pull_request: + push: + branches: [ main ] + +jobs: + scan_ruby: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Scan for common Rails security vulnerabilities using static analysis + run: bin/brakeman --no-pager + + - name: Scan for known security vulnerabilities in gems used + run: bin/bundler-audit + + scan_js: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Scan for security vulnerabilities in JavaScript dependencies + run: bin/importmap audit + + lint: + runs-on: ubuntu-latest + env: + RUBOCOP_CACHE_ROOT: tmp/rubocop + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Prepare RuboCop cache + uses: actions/cache@v4 + env: + DEPENDENCIES_HASH: ${{ hashFiles('.ruby-version', '**/.rubocop.yml', '**/.rubocop_todo.yml', 'Gemfile.lock') }} + with: + path: ${{ env.RUBOCOP_CACHE_ROOT }} + key: rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-${{ github.ref_name == github.event.repository.default_branch && github.run_id || 'default' }} + restore-keys: | + rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}- + + - name: Lint code for consistent style + run: bin/rubocop -f github + + test: + runs-on: ubuntu-latest + + # services: + # redis: + # image: valkey/valkey:8 + # ports: + # - 6379:6379 + # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Run tests + env: + RAILS_ENV: test + # RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} + # REDIS_URL: redis://localhost:6379/0 + run: bin/rails db:test:prepare test + + system-test: + runs-on: ubuntu-latest + + # services: + # redis: + # image: valkey/valkey:8 + # ports: + # - 6379:6379 + # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Run System Tests + env: + RAILS_ENV: test + # RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} + # REDIS_URL: redis://localhost:6379/0 + run: bin/rails db:test:prepare test:system + + - name: Keep screenshots from failed system tests + uses: actions/upload-artifact@v4 + if: failure() + with: + name: screenshots + path: ${{ github.workspace }}/tmp/screenshots + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e953825 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# See https://help.github.com/articles/ignoring-files for more about ignoring files. +# +# Temporary files generated by your text editor or operating system +# belong in git's global ignore instead: +# `$XDG_CONFIG_HOME/git/ignore` or `~/.config/git/ignore` + +# Ignore bundler config. +/.bundle + +# Ignore all environment files. +/.env* + +# 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 key files for decrypting credentials and more. +/config/*.key + + +/app/assets/builds/* +!/app/assets/builds/.keep diff --git a/.kamal/hooks/docker-setup.sample b/.kamal/hooks/docker-setup.sample new file mode 100755 index 0000000..2fb07d7 --- /dev/null +++ b/.kamal/hooks/docker-setup.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Docker set up on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/post-app-boot.sample b/.kamal/hooks/post-app-boot.sample new file mode 100755 index 0000000..70f9c4b --- /dev/null +++ b/.kamal/hooks/post-app-boot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Booted app version $KAMAL_VERSION on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/post-deploy.sample b/.kamal/hooks/post-deploy.sample new file mode 100755 index 0000000..fd364c2 --- /dev/null +++ b/.kamal/hooks/post-deploy.sample @@ -0,0 +1,14 @@ +#!/bin/sh + +# A sample post-deploy hook +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) +# KAMAL_RUNTIME + +echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds" diff --git a/.kamal/hooks/post-proxy-reboot.sample b/.kamal/hooks/post-proxy-reboot.sample new file mode 100755 index 0000000..1435a67 --- /dev/null +++ b/.kamal/hooks/post-proxy-reboot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Rebooted kamal-proxy on $KAMAL_HOSTS" diff --git a/.kamal/hooks/pre-app-boot.sample b/.kamal/hooks/pre-app-boot.sample new file mode 100755 index 0000000..45f7355 --- /dev/null +++ b/.kamal/hooks/pre-app-boot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Booting app version $KAMAL_VERSION on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/pre-build.sample b/.kamal/hooks/pre-build.sample new file mode 100755 index 0000000..c5a5567 --- /dev/null +++ b/.kamal/hooks/pre-build.sample @@ -0,0 +1,51 @@ +#!/bin/sh + +# A sample pre-build hook +# +# Checks: +# 1. We have a clean checkout +# 2. A remote is configured +# 3. The branch has been pushed to the remote +# 4. The version we are deploying matches the remote +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) + +if [ -n "$(git status --porcelain)" ]; then + echo "Git checkout is not clean, aborting..." >&2 + git status --porcelain >&2 + exit 1 +fi + +first_remote=$(git remote) + +if [ -z "$first_remote" ]; then + echo "No git remote set, aborting..." >&2 + exit 1 +fi + +current_branch=$(git branch --show-current) + +if [ -z "$current_branch" ]; then + echo "Not on a git branch, aborting..." >&2 + exit 1 +fi + +remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1) + +if [ -z "$remote_head" ]; then + echo "Branch not pushed to remote, aborting..." >&2 + exit 1 +fi + +if [ "$KAMAL_VERSION" != "$remote_head" ]; then + echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2 + exit 1 +fi + +exit 0 diff --git a/.kamal/hooks/pre-connect.sample b/.kamal/hooks/pre-connect.sample new file mode 100755 index 0000000..77744bd --- /dev/null +++ b/.kamal/hooks/pre-connect.sample @@ -0,0 +1,47 @@ +#!/usr/bin/env ruby + +# A sample pre-connect check +# +# Warms DNS before connecting to hosts in parallel +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) +# KAMAL_RUNTIME + +hosts = ENV["KAMAL_HOSTS"].split(",") +results = nil +max = 3 + +elapsed = Benchmark.realtime do + results = hosts.map do |host| + Thread.new do + tries = 1 + + begin + Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME) + rescue SocketError + if tries < max + puts "Retrying DNS warmup: #{host}" + tries += 1 + sleep rand + retry + else + puts "DNS warmup failed: #{host}" + host + end + end + + tries + end + end.map(&:value) +end + +retries = results.sum - hosts.size +nopes = results.count { |r| r == max } + +puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ] diff --git a/.kamal/hooks/pre-deploy.sample b/.kamal/hooks/pre-deploy.sample new file mode 100755 index 0000000..05b3055 --- /dev/null +++ b/.kamal/hooks/pre-deploy.sample @@ -0,0 +1,122 @@ +#!/usr/bin/env ruby + +# A sample pre-deploy hook +# +# Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds. +# +# Fails unless the combined status is "success" +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_COMMAND +# KAMAL_SUBCOMMAND +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) + +# Only check the build status for production deployments +if ENV["KAMAL_COMMAND"] == "rollback" || ENV["KAMAL_DESTINATION"] != "production" + exit 0 +end + +require "bundler/inline" + +# true = install gems so this is fast on repeat invocations +gemfile(true, quiet: true) do + source "https://rubygems.org" + + gem "octokit" + gem "faraday-retry" +end + +MAX_ATTEMPTS = 72 +ATTEMPTS_GAP = 10 + +def exit_with_error(message) + $stderr.puts message + exit 1 +end + +class GithubStatusChecks + attr_reader :remote_url, :git_sha, :github_client, :combined_status + + def initialize + @remote_url = github_repo_from_remote_url + @git_sha = `git rev-parse HEAD`.strip + @github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"]) + refresh! + end + + def refresh! + @combined_status = github_client.combined_status(remote_url, git_sha) + end + + def state + combined_status[:state] + end + + def first_status_url + first_status = combined_status[:statuses].find { |status| status[:state] == state } + first_status && first_status[:target_url] + end + + def complete_count + combined_status[:statuses].count { |status| status[:state] != "pending"} + end + + def total_count + combined_status[:statuses].count + end + + def current_status + if total_count > 0 + "Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ..." + else + "Build not started..." + end + end + + private + def github_repo_from_remote_url + url = `git config --get remote.origin.url`.strip.delete_suffix(".git") + if url.start_with?("https://github.com/") + url.delete_prefix("https://github.com/") + elsif url.start_with?("git@github.com:") + url.delete_prefix("git@github.com:") + else + url + end + end +end + + +$stdout.sync = true + +begin + puts "Checking build status..." + + attempts = 0 + checks = GithubStatusChecks.new + + loop do + case checks.state + when "success" + puts "Checks passed, see #{checks.first_status_url}" + exit 0 + when "failure" + exit_with_error "Checks failed, see #{checks.first_status_url}" + when "pending" + attempts += 1 + end + + exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS + + puts checks.current_status + sleep(ATTEMPTS_GAP) + checks.refresh! + end +rescue Octokit::NotFound + exit_with_error "Build status could not be found" +end diff --git a/.kamal/hooks/pre-proxy-reboot.sample b/.kamal/hooks/pre-proxy-reboot.sample new file mode 100755 index 0000000..061f805 --- /dev/null +++ b/.kamal/hooks/pre-proxy-reboot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Rebooting kamal-proxy on $KAMAL_HOSTS..." diff --git a/.kamal/secrets b/.kamal/secrets new file mode 100644 index 0000000..b3089d6 --- /dev/null +++ b/.kamal/secrets @@ -0,0 +1,20 @@ +# Secrets defined here are available for reference under registry/password, env/secret, builder/secrets, +# and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either +# password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git. + +# Example of extracting secrets from 1password (or another compatible pw manager) +# SECRETS=$(kamal secrets fetch --adapter 1password --account your-account --from Vault/Item KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY) +# KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD ${SECRETS}) +# RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY ${SECRETS}) + +# Example of extracting secrets from Rails credentials +# KAMAL_REGISTRY_PASSWORD=$(rails credentials:fetch kamal.registry_password) + +# Use a GITHUB_TOKEN if private repositories are needed for the image +# GITHUB_TOKEN=$(gh config get -h github.com oauth_token) + +# Grab the registry password from ENV +# KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD + +# Improve security by using a password manager. Never check config/master.key into git! +RAILS_MASTER_KEY=$(cat config/master.key) diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..f9d86d4 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,8 @@ +# Omakase Ruby styling for Rails +inherit_gem: { rubocop-rails-omakase: rubocop.yml } + +# Overwrite or add rules to create your own house style +# +# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` +# Layout/SpaceInsideArrayLiteralBrackets: +# Enabled: false diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..1454f6e --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +4.0.1 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..75028e4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,77 @@ +# syntax=docker/dockerfile:1 +# check=error=true + +# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand: +# docker build -t dip_front . +# docker run -d -p 80:80 -e RAILS_MASTER_KEY= --name dip_front dip_front + +# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html + +# Make sure RUBY_VERSION matches the Ruby version in .ruby-version +ARG RUBY_VERSION=4.0.1 +FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base + +# Rails app lives here +WORKDIR /rails + +# Install base packages +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \ + ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Set production environment variables and enable jemalloc for reduced memory usage and latency. +ENV RAILS_ENV="production" \ + BUNDLE_DEPLOYMENT="1" \ + BUNDLE_PATH="/usr/local/bundle" \ + BUNDLE_WITHOUT="development" \ + LD_PRELOAD="/usr/local/lib/libjemalloc.so" + +# Throw-away build stage to reduce size of final image +FROM base AS build + +# Install packages needed to build gems +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Install application gems +COPY vendor/* ./vendor/ +COPY Gemfile Gemfile.lock ./ + +RUN bundle install && \ + rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ + # -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495 + bundle exec bootsnap precompile -j 1 --gemfile + +# Copy application code +COPY . . + +# Precompile bootsnap code for faster boot times. +# -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495 +RUN bundle exec bootsnap precompile -j 1 app/ lib/ + +# Precompiling assets for production without requiring secret RAILS_MASTER_KEY +RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile + + + + +# Final stage for app image +FROM base + +# Run and own only the runtime files as a non-root user for security +RUN groupadd --system --gid 1000 rails && \ + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash +USER 1000:1000 + +# Copy built artifacts: gems, application +COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" +COPY --chown=rails:rails --from=build /rails /rails + +# Entrypoint prepares the database. +ENTRYPOINT ["/rails/bin/docker-entrypoint"] + +# Start server via Thruster by default, this can be overwritten at runtime +EXPOSE 80 +CMD ["./bin/thrust", "./bin/rails", "server"] diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..435c654 --- /dev/null +++ b/Gemfile @@ -0,0 +1,70 @@ +source "https://rubygems.org" + +# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" +gem "rails", "~> 8.1.2" +# The modern asset pipeline for Rails [https://github.com/rails/propshaft] +gem "propshaft" +# Use sqlite3 as the database for Active Record +gem "sqlite3", ">= 2.1" +# 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" + + +# Windows does not include zoneinfo files, so bundle the tzinfo-data gem +gem "tzinfo-data", platforms: %i[ windows jruby ] + +# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable +gem "solid_cache" +gem "solid_queue" +gem "solid_cable" + +# Reduces boot times through caching; required in config/boot.rb +gem "bootsnap", require: false + +# Deploy this application anywhere as a Docker container [https://kamal-deploy.org] +gem "kamal", require: false + +# Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/] +gem "thruster", require: false + +# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] +gem "image_processing", "~> 1.2" + +group :development, :test do + # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem + gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" + + # Audits gems for known security defects (use config/bundler-audit.yml to ignore issues) + gem "bundler-audit", require: false + + # Static analysis for security vulnerabilities [https://brakemanscanner.org/] + gem "brakeman", require: false + + # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] + gem "rubocop-rails-omakase", require: false +end + +group :development do + # Use console on exceptions pages [https://github.com/rails/web-console] + gem "web-console" + gem "ruby-lsp", require: false + gem "foreman" +end + +group :test do + # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] + gem "capybara" + gem "selenium-webdriver" +end + +gem "tailwindcss-rails" +gem "faraday" +gem "bcrypt", "~> 3.1.7" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..83a39d0 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,597 @@ +GEM + remote: https://rubygems.org/ + specs: + action_text-trix (2.1.16) + railties + actioncable (8.1.2) + actionpack (= 8.1.2) + activesupport (= 8.1.2) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (8.1.2) + actionpack (= 8.1.2) + activejob (= 8.1.2) + activerecord (= 8.1.2) + activestorage (= 8.1.2) + activesupport (= 8.1.2) + mail (>= 2.8.0) + actionmailer (8.1.2) + actionpack (= 8.1.2) + actionview (= 8.1.2) + activejob (= 8.1.2) + activesupport (= 8.1.2) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.1.2) + actionview (= 8.1.2) + activesupport (= 8.1.2) + nokogiri (>= 1.8.5) + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (8.1.2) + action_text-trix (~> 2.1.15) + actionpack (= 8.1.2) + activerecord (= 8.1.2) + activestorage (= 8.1.2) + activesupport (= 8.1.2) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (8.1.2) + activesupport (= 8.1.2) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (8.1.2) + activesupport (= 8.1.2) + globalid (>= 0.3.6) + activemodel (8.1.2) + activesupport (= 8.1.2) + activerecord (8.1.2) + activemodel (= 8.1.2) + activesupport (= 8.1.2) + timeout (>= 0.4.0) + activestorage (8.1.2) + actionpack (= 8.1.2) + activejob (= 8.1.2) + activerecord (= 8.1.2) + activesupport (= 8.1.2) + marcel (~> 1.0) + activesupport (8.1.2) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + json + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) + ast (2.4.3) + base64 (0.3.0) + bcrypt (3.1.21) + bcrypt_pbkdf (1.1.2) + bigdecimal (4.0.1) + bindex (0.8.1) + bootsnap (1.22.0) + msgpack (~> 1.2) + brakeman (8.0.2) + racc + builder (3.3.0) + bundler-audit (0.9.3) + bundler (>= 1.2.0) + thor (~> 1.0) + capybara (3.40.0) + addressable + matrix + mini_mime (>= 0.1.3) + nokogiri (~> 1.11) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + regexp_parser (>= 1.5, < 3.0) + xpath (~> 3.2) + concurrent-ruby (1.3.6) + connection_pool (3.0.2) + crass (1.0.6) + date (3.5.1) + debug (1.11.1) + irb (~> 1.10) + reline (>= 0.3.8) + dotenv (3.2.0) + drb (2.2.3) + ed25519 (1.4.0) + erb (6.0.1) + erubi (1.13.1) + et-orbi (1.4.0) + tzinfo + faraday (2.14.0) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-net_http (3.4.2) + net-http (~> 0.5) + ffi (1.17.3-aarch64-linux-gnu) + ffi (1.17.3-aarch64-linux-musl) + ffi (1.17.3-arm-linux-gnu) + ffi (1.17.3-arm-linux-musl) + ffi (1.17.3-x86_64-linux-gnu) + ffi (1.17.3-x86_64-linux-musl) + foreman (0.90.0) + thor (~> 1.4) + fugit (1.12.1) + et-orbi (~> 1.4) + raabro (~> 1.4) + globalid (1.3.0) + activesupport (>= 6.1) + i18n (1.14.8) + concurrent-ruby (~> 1.0) + image_processing (1.14.0) + mini_magick (>= 4.9.5, < 6) + ruby-vips (>= 2.0.17, < 3) + importmap-rails (2.2.3) + actionpack (>= 6.0.0) + activesupport (>= 6.0.0) + railties (>= 6.0.0) + io-console (0.8.2) + irb (1.16.0) + pp (>= 0.6.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + jbuilder (2.14.1) + actionview (>= 7.0.0) + activesupport (>= 7.0.0) + json (2.18.1) + kamal (2.10.1) + activesupport (>= 7.0) + base64 (~> 0.2) + bcrypt_pbkdf (~> 1.0) + concurrent-ruby (~> 1.2) + dotenv (~> 3.1) + ed25519 (~> 1.4) + net-ssh (~> 7.3) + sshkit (>= 1.23.0, < 2.0) + thor (~> 1.3) + zeitwerk (>= 2.6.18, < 3.0) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + logger (1.7.0) + loofah (2.25.0) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.9.0) + logger + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.1.0) + matrix (0.4.3) + mini_magick (5.3.1) + logger + mini_mime (1.1.5) + minitest (6.0.1) + prism (~> 1.5) + msgpack (1.8.0) + net-http (0.9.1) + uri (>= 0.11.1) + net-imap (0.6.2) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-scp (4.1.0) + net-ssh (>= 2.6.5, < 8.0.0) + net-sftp (4.0.0) + net-ssh (>= 5.0.0, < 8.0.0) + net-smtp (0.5.1) + net-protocol + net-ssh (7.3.0) + nio4r (2.7.5) + nokogiri (1.19.0-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.0-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.19.0-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.0-arm-linux-musl) + racc (~> 1.4) + nokogiri (1.19.0-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.0-x86_64-linux-musl) + racc (~> 1.4) + ostruct (0.6.3) + parallel (1.27.0) + parser (3.3.10.1) + ast (~> 2.4.1) + racc + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + propshaft (1.3.1) + actionpack (>= 7.0.0) + activesupport (>= 7.0.0) + rack + psych (5.3.1) + date + stringio + public_suffix (7.0.2) + puma (7.2.0) + nio4r (~> 2.0) + raabro (1.4.0) + racc (1.8.1) + rack (3.2.4) + rack-session (2.1.1) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.3.1) + rack (>= 3) + rails (8.1.2) + actioncable (= 8.1.2) + actionmailbox (= 8.1.2) + actionmailer (= 8.1.2) + actionpack (= 8.1.2) + actiontext (= 8.1.2) + actionview (= 8.1.2) + activejob (= 8.1.2) + activemodel (= 8.1.2) + activerecord (= 8.1.2) + activestorage (= 8.1.2) + activesupport (= 8.1.2) + bundler (>= 1.15.0) + railties (= 8.1.2) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.6.2) + loofah (~> 2.21) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + railties (8.1.2) + actionpack (= 8.1.2) + activesupport (= 8.1.2) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) + zeitwerk (~> 2.6) + rainbow (3.1.1) + rake (13.3.1) + rbs (3.10.3) + logger + tsort + rdoc (7.1.0) + erb + psych (>= 4.0.0) + tsort + regexp_parser (2.11.3) + reline (0.6.3) + io-console (~> 0.5) + rexml (3.4.4) + rubocop (1.84.1) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.0) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.34.3) + activesupport (>= 4.2.0) + lint_roller (~> 1.1) + rack (>= 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rails-omakase (1.1.0) + rubocop (>= 1.72) + rubocop-performance (>= 1.24) + rubocop-rails (>= 2.30) + ruby-lsp (0.26.5) + language_server-protocol (~> 3.17.0) + prism (>= 1.2, < 2.0) + rbs (>= 3, < 5) + ruby-progressbar (1.13.0) + ruby-vips (2.3.0) + ffi (~> 1.12) + logger + rubyzip (3.2.2) + securerandom (0.4.1) + selenium-webdriver (4.40.0) + base64 (~> 0.2) + logger (~> 1.4) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 4.0) + websocket (~> 1.0) + solid_cable (3.0.12) + actioncable (>= 7.2) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_cache (1.0.10) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_queue (1.3.1) + activejob (>= 7.1) + activerecord (>= 7.1) + concurrent-ruby (>= 1.3.1) + fugit (~> 1.11) + railties (>= 7.1) + thor (>= 1.3.1) + sqlite3 (2.9.0-aarch64-linux-gnu) + sqlite3 (2.9.0-aarch64-linux-musl) + sqlite3 (2.9.0-arm-linux-gnu) + sqlite3 (2.9.0-arm-linux-musl) + sqlite3 (2.9.0-x86_64-linux-gnu) + sqlite3 (2.9.0-x86_64-linux-musl) + sshkit (1.25.0) + base64 + logger + net-scp (>= 1.1.2) + net-sftp (>= 2.1.2) + net-ssh (>= 2.8.0) + ostruct + stimulus-rails (1.3.4) + railties (>= 6.0.0) + stringio (3.2.0) + tailwindcss-rails (4.4.0) + railties (>= 7.0.0) + tailwindcss-ruby (~> 4.0) + tailwindcss-ruby (4.1.18) + tailwindcss-ruby (4.1.18-aarch64-linux-gnu) + tailwindcss-ruby (4.1.18-aarch64-linux-musl) + tailwindcss-ruby (4.1.18-x86_64-linux-gnu) + tailwindcss-ruby (4.1.18-x86_64-linux-musl) + thor (1.5.0) + thruster (0.1.18) + thruster (0.1.18-aarch64-linux) + thruster (0.1.18-x86_64-linux) + timeout (0.6.0) + tsort (0.2.0) + turbo-rails (2.0.23) + actionpack (>= 7.1.0) + railties (>= 7.1.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) + useragent (0.16.11) + web-console (4.2.1) + actionview (>= 6.0.0) + activemodel (>= 6.0.0) + bindex (>= 0.4.0) + railties (>= 6.0.0) + websocket (1.2.11) + websocket-driver (0.8.0) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + xpath (3.2.0) + nokogiri (~> 1.8) + zeitwerk (2.7.4) + +PLATFORMS + aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + bcrypt (~> 3.1.7) + bootsnap + brakeman + bundler-audit + capybara + debug + faraday + foreman + image_processing (~> 1.2) + importmap-rails + jbuilder + kamal + propshaft + puma (>= 5.0) + rails (~> 8.1.2) + rubocop-rails-omakase + ruby-lsp + selenium-webdriver + solid_cable + solid_cache + solid_queue + sqlite3 (>= 2.1) + stimulus-rails + tailwindcss-rails + thruster + turbo-rails + tzinfo-data + web-console + +CHECKSUMS + action_text-trix (2.1.16) sha256=f645a2c21821b8449fd1d6770708f4031c91a2eedf9ef476e9be93c64e703a8a + actioncable (8.1.2) sha256=dc31efc34cca9cdefc5c691ddb8b4b214c0ea5cd1372108cbc1377767fb91969 + actionmailbox (8.1.2) sha256=058b2fb1980e5d5a894f675475fcfa45c62631103d5a2596d9610ec81581889b + actionmailer (8.1.2) sha256=f4c1d2060f653bfe908aa7fdc5a61c0e5279670de992146582f2e36f8b9175e9 + actionpack (8.1.2) sha256=ced74147a1f0daafaa4bab7f677513fd4d3add574c7839958f7b4f1de44f8423 + actiontext (8.1.2) sha256=0bf57da22a9c19d970779c3ce24a56be31b51c7640f2763ec64aa72e358d2d2d + actionview (8.1.2) sha256=80455b2588911c9b72cec22d240edacb7c150e800ef2234821269b2b2c3e2e5b + activejob (8.1.2) sha256=908dab3713b101859536375819f4156b07bdf4c232cc645e7538adb9e302f825 + activemodel (8.1.2) sha256=e21358c11ce68aed3f9838b7e464977bc007b4446c6e4059781e1d5c03bcf33e + activerecord (8.1.2) sha256=acfbe0cadfcc50fa208011fe6f4eb01cae682ebae0ef57145ba45380c74bcc44 + activestorage (8.1.2) sha256=8a63a48c3999caeee26a59441f813f94681fc35cc41aba7ce1f836add04fba76 + activesupport (8.1.2) sha256=88842578ccd0d40f658289b0e8c842acfe9af751afee2e0744a7873f50b6fdae + addressable (2.8.8) sha256=7c13b8f9536cf6364c03b9d417c19986019e28f7c00ac8132da4eb0fe393b057 + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bcrypt (3.1.21) sha256=5964613d750a42c7ee5dc61f7b9336fb6caca429ba4ac9f2011609946e4a2dcf + bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 + bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7 + bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e + bootsnap (1.22.0) sha256=5820c9d42c2efef095bee6565484bdc511f1223bf950140449c9385ae775793e + brakeman (8.0.2) sha256=7b02065ce8b1de93949cefd3f2ad78e8eb370e644b95c8556a32a912a782426a + builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f + bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9 + capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef + concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab + connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a + crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d + date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 + debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 + dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d + drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506 + erb (6.0.1) sha256=28ecdd99c5472aebd5674d6061e3c6b0a45c049578b071e5a52c2a7f13c197e5 + erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 + et-orbi (1.4.0) sha256=6c7e3c90779821f9e3b324c5e96fda9767f72995d6ae435b96678a4f3e2de8bc + faraday (2.14.0) sha256=8699cfe5d97e55268f2596f9a9d5a43736808a943714e3d9a53e6110593941cd + faraday-net_http (3.4.2) sha256=f147758260d3526939bf57ecf911682f94926a3666502e24c69992765875906c + ffi (1.17.3-aarch64-linux-gnu) sha256=28ad573df26560f0aedd8a90c3371279a0b2bd0b4e834b16a2baa10bd7a97068 + ffi (1.17.3-aarch64-linux-musl) sha256=020b33b76775b1abacc3b7d86b287cef3251f66d747092deec592c7f5df764b2 + ffi (1.17.3-arm-linux-gnu) sha256=5bd4cea83b68b5ec0037f99c57d5ce2dd5aa438f35decc5ef68a7d085c785668 + ffi (1.17.3-arm-linux-musl) sha256=0d7626bb96265f9af78afa33e267d71cfef9d9a8eb8f5525344f8da6c7d76053 + ffi (1.17.3-x86_64-linux-gnu) sha256=3746b01f677aae7b16dc1acb7cb3cc17b3e35bdae7676a3f568153fb0e2c887f + ffi (1.17.3-x86_64-linux-musl) sha256=086b221c3a68320b7564066f46fed23449a44f7a1935f1fe5a245bd89d9aea56 + foreman (0.90.0) sha256=ff675e2d47b607ac58714a6d4ac3e1ee8f06f41d8db084531c31961e2c3f117c + fugit (1.12.1) sha256=5898f478ede9b415f0804e42b8f3fd53f814bd85eebffceebdbc34e1107aaf68 + globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11 + i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 + image_processing (1.14.0) sha256=754cc169c9c262980889bec6bfd325ed1dafad34f85242b5a07b60af004742fb + importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a + io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc + irb (1.16.0) sha256=2abe56c9ac947cdcb2f150572904ba798c1e93c890c256f8429981a7675b0806 + jbuilder (2.14.1) sha256=4eb26376ff60ef100cb4fd6fd7533cd271f9998327e86adf20fd8c0e69fabb42 + json (2.18.1) sha256=fe112755501b8d0466b5ada6cf50c8c3f41e897fa128ac5d263ec09eedc9f986 + kamal (2.10.1) sha256=53b7ecb4c33dd83b1aedfc7aacd1c059f835993258a552d70d584c6ce32b6340 + language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc + lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + loofah (2.25.0) sha256=df5ed7ac3bac6a4ec802df3877ee5cc86d027299f8952e6243b3dac446b060e6 + mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941 + marcel (1.1.0) sha256=fdcfcfa33cc52e93c4308d40e4090a5d4ea279e160a7f6af988260fa970e0bee + matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b + mini_magick (5.3.1) sha256=29395dfd76badcabb6403ee5aff6f681e867074f8f28ce08d78661e9e4a351c4 + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb + msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732 + net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996 + net-imap (0.6.2) sha256=08caacad486853c61676cca0c0c47df93db02abc4a8239a8b67eb0981428acc6 + net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 + net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 + net-scp (4.1.0) sha256=a99b0b92a1e5d360b0de4ffbf2dc0c91531502d3d4f56c28b0139a7c093d1a5d + net-sftp (4.0.0) sha256=65bb91c859c2f93b09826757af11b69af931a3a9155050f50d1b06d384526364 + net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 + net-ssh (7.3.0) sha256=172076c4b30ce56fb25a03961b0c4da14e1246426401b0f89cba1a3b54bf3ef0 + nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 + nokogiri (1.19.0-aarch64-linux-gnu) sha256=11a97ecc3c0e7e5edcf395720b10860ef493b768f6aa80c539573530bc933767 + nokogiri (1.19.0-aarch64-linux-musl) sha256=eb70507f5e01bc23dad9b8dbec2b36ad0e61d227b42d292835020ff754fb7ba9 + nokogiri (1.19.0-arm-linux-gnu) sha256=572a259026b2c8b7c161fdb6469fa2d0edd2b61cd599db4bbda93289abefbfe5 + nokogiri (1.19.0-arm-linux-musl) sha256=23ed90922f1a38aed555d3de4d058e90850c731c5b756d191b3dc8055948e73c + nokogiri (1.19.0-x86_64-linux-gnu) sha256=f482b95c713d60031d48c44ce14562f8d2ce31e3a9e8dd0ccb131e9e5a68b58c + nokogiri (1.19.0-x86_64-linux-musl) sha256=1c4ca6b381622420073ce6043443af1d321e8ed93cc18b08e2666e5bd02ffae4 + ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 + parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 + parser (3.3.10.1) sha256=06f6a725d2cd91e5e7f2b7c32ba143631e1f7c8ae2fb918fc4cebec187e6a688 + pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + propshaft (1.3.1) sha256=9acc664ef67e819ffa3d95bd7ad4c3623ea799110c5f4dee67fa7e583e74c392 + psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974 + public_suffix (7.0.2) sha256=9114090c8e4e7135c1fd0e7acfea33afaab38101884320c65aaa0ffb8e26a857 + puma (7.2.0) sha256=bf8ef4ab514a4e6d4554cb4326b2004eba5036ae05cf765cfe51aba9706a72a8 + raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882 + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rack (3.2.4) sha256=5d74b6f75082a643f43c1e76b419c40f0e5527fcfee1e669ac1e6b73c0ccb6f6 + rack-session (2.1.1) sha256=0b6dc07dea7e4b583f58a48e8b806d4c9f1c6c9214ebc202ec94562cbea2e4e9 + rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 + rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 + rails (8.1.2) sha256=5069061b23dfa8706b9f0159ae8b9d35727359103178a26962b868a680ba7d95 + rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d + rails-html-sanitizer (1.6.2) sha256=35fce2ca8242da8775c83b6ba9c1bcaad6751d9eb73c1abaa8403475ab89a560 + railties (8.1.2) sha256=1289ece76b4f7668fc46d07e55cc992b5b8751f2ad85548b7da351b8c59f8055 + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c + rbs (3.10.3) sha256=70627f3919016134d554e6c99195552ae3ef6020fe034c8e983facc9c192daa6 + rdoc (7.1.0) sha256=494899df0706c178596ca6e1d50f1b7eb285a9b2aae715be5abd742734f17363 + regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4 + reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + rubocop (1.84.1) sha256=14cc626f355141f5a2ef53c10a68d66b13bb30639b26370a76559096cc6bcc1a + rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd + rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 + rubocop-rails (2.34.3) sha256=10d37989024865ecda8199f311f3faca990143fbac967de943f88aca11eb9ad2 + rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d + ruby-lsp (0.26.5) sha256=19272659139b292a81a700d78e1b4d8988c4812e96b54fd6c30a21ce5e82b189 + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + ruby-vips (2.3.0) sha256=e685ec02c13969912debbd98019e50492e12989282da5f37d05f5471442f5374 + rubyzip (3.2.2) sha256=c0ed99385f0625415c8f05bcae33fe649ed2952894a95ff8b08f26ca57ea5b3c + securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + selenium-webdriver (4.40.0) sha256=16ef7aa9853c1d4b9d52eac45aafa916e3934c5c83cb4facb03f250adfd15e5b + solid_cable (3.0.12) sha256=a168a54731a455d5627af48d8441ea3b554b8c1f6e6cd6074109de493e6b0460 + solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 + solid_queue (1.3.1) sha256=d9580111180c339804ff1a810a7768f69f5dc694d31e86cf1535ff2cd7a87428 + sqlite3 (2.9.0-aarch64-linux-gnu) sha256=cfe1e0216f46d7483839719bf827129151e6c680317b99d7b8fc1597a3e13473 + sqlite3 (2.9.0-aarch64-linux-musl) sha256=56a35cb2d70779afc2ac191baf2c2148242285ecfed72f9b021218c5c4917913 + sqlite3 (2.9.0-arm-linux-gnu) sha256=a19a21504b0d7c8c825fbbf37b358ae316b6bd0d0134c619874060b2eef05435 + sqlite3 (2.9.0-arm-linux-musl) sha256=fca5b26197c70e3363115d3faaea34d7b2ad9c7f5fa8d8312e31b64e7556ee07 + sqlite3 (2.9.0-x86_64-linux-gnu) sha256=72fff9bd750070ba3af695511ba5f0e0a2d8a9206f84869640b3e99dfaf3d5a5 + sqlite3 (2.9.0-x86_64-linux-musl) sha256=ef716ba7a66d7deb1ccc402ac3a6d7343da17fac862793b7f0be3d2917253c90 + sshkit (1.25.0) sha256=c8c6543cdb60f91f1d277306d585dd11b6a064cb44eab0972827e4311ff96744 + stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 + stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 + tailwindcss-rails (4.4.0) sha256=efa2961351a52acebe616e645a81a30bb4f27fde46cc06ce7688d1cd1131e916 + tailwindcss-ruby (4.1.18) sha256=b62fad5b00494e92987ee319dfb5c5ad272f0ed93649963d62f08d2ba0f03fa7 + tailwindcss-ruby (4.1.18-aarch64-linux-gnu) sha256=e10f9560bccddbb4955fd535b3bcc8c7071a7df07404dd473a23fa791ec4e46b + tailwindcss-ruby (4.1.18-aarch64-linux-musl) sha256=3c8426674718a2c98a0649c825ac0b3286ff52acd0b4052d7d19126cd74904f3 + tailwindcss-ruby (4.1.18-x86_64-linux-gnu) sha256=e0a2220163246fe0126c5c5bafb95bc6206e7d21fce2a2878fd9c9a359137534 + tailwindcss-ruby (4.1.18-x86_64-linux-musl) sha256=d957cf545b09d2db7eb6267450cc1fc589e126524066537a0c4d5b99d701f4b2 + thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 + thruster (0.1.18) sha256=f025103bc7c8e6747436bb9de058c366840d2871560574ea7070a9bc8608a889 + thruster (0.1.18-aarch64-linux) sha256=16f3d49468d76a9a5de86b7bdedf535b7b80da7c16495ca8ec96cfdc256870e2 + thruster (0.1.18-x86_64-linux) sha256=0ec1ff5f12289c1ac10cf8e28ce6b5266f4e73416b34a664b79d037c7d955c40 + timeout (0.6.0) sha256=6d722ad619f96ee383a0c557ec6eb8c4ecb08af3af62098a0be5057bf00de1af + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f + tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 + useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 + web-console (4.2.1) sha256=e7bcf37a10ea2b4ec4281649d1cee461b32232d0a447e82c786e6841fd22fe20 + websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737 + websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962 + websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 + xpath (3.2.0) sha256=6dfda79d91bb3b949b947ecc5919f042ef2f399b904013eb3ef6d20dd3a4082e + zeitwerk (2.7.4) sha256=2bef90f356bdafe9a6c2bd32bcd804f83a4f9b8bc27f3600fff051eb3edcec8b + +BUNDLED WITH + 4.0.6 diff --git a/Procfile.dev b/Procfile.dev new file mode 100644 index 0000000..0b0a4cd --- /dev/null +++ b/Procfile.dev @@ -0,0 +1,3 @@ +web: bin/rails server +css: bin/rails tailwindcss:watch +worker: bin/rails solid_queue:start diff --git a/README.md b/README.md new file mode 100644 index 0000000..d9c12a1 --- /dev/null +++ b/README.md @@ -0,0 +1,149 @@ +# KonDiplo Front + +外交ゲーム(Diplomacy)のWebフロントエンドアプリケーション。 +モダンなUIで、ゲームの作成・参加・命令入力・ターン処理・自動進行管理までをブラウザ上で完結できます。 + +## 主な機能 + +### 🎮 ゲーム管理 + +- **ゲーム作成**: タイトル、メモ、パスワード保護、参加人数などの設定。 +- **モード**: + - **ソロモード**: 1人で全7カ国を操作(テストや練習用)。 + - **マルチプレイヤーモード**: 複数のユーザーで国を担当。 +- **国割り当て**: 管理者による手動選択、またはランダム割り当て。 + +### ⏱️ ターン進行管理 + +- **手動進行**: 全員が命令完了後、管理者がボタンでターンを進める方式。 +- **自動進行 (Auto Turn)**: + - **スケジュール設定**: 毎日決まった時間(例: 0時, 12時, 18時)に自動でターンを進めることが可能。 + - **デッドライン管理**: 次の更新時間をカウントダウン表示。 + - **NPC自動処理**: 期限までに命令未提出の国や、プレイヤー不在の国(NPC)は、自動保持(HOLD)またはランダム命令が適用されます。 + +### 🗺️ マップ・UI + +- **動的SVGマップ**: ターンごとの戦況を可視化。 +- **命令入力**: プルダウン形式で直感的に命令を作成(支援・輸送も対応)。日本語訳付き。 +- **視認性向上**: + - **国別カラー**: 地図上の色に近いバッジ(Austria=赤, England=紫, etc.)で国名を表示。 + - **NPCバッジ**: 人間が操作していない国に🤖アイコンを表示。 + - **フェーズ表示**: ゲーム一覧で現在の時期(例: 1901年 春)を確認可能。 + +### ⚙️ ハウスルール + +- **年数制限**: 指定年数でゲーム終了。 +- **目標SC数**: 勝利条件となるSC数(デフォルト: 18)を変更可能。 +- **スコアリング**: SC数ベース、DSS、SoSなど複数の評価方式に対応。 +- **引き分け投票**: 参加者の過半数の同意で引き分け終了。 + +--- + +## 技術スタック + +| カテゴリ | 技術 | 解説 | +|---|---|---| +| **言語** | Ruby 3.x / 4.x | | +| **フレームワーク** | Rails 8.1 | 最新のRails機能を活用 | +| **フロントエンド** | Hotwire | Turbo + Stimulus によるSPA風体験 | +| **CSS** | Tailwind CSS | ユーティリティファーストなスタイリング | +| **DB** | SQLite3 / PostgreSQL | 開発はSQLite、本番はPostgreSQL推奨 | +| **非同期処理** | **Solid Queue** | データベースベースのジョブキュー(自動ターン処理に使用) | +| **API連携** | Faraday | 外部のDiplomacy判定ロジックサーバーと通信 | +| **アセット** | Propshaft | 前提アセットパイプライン | + +## アーキテクチャ + +``` +[ ブラウザ ] ⟷ [ Rails App (KonDiplo Front) ] ⟷ [ Diplomacy API (Python/FastAPI) ] + ↕ port 8000 + [ DB (SQLite/Postgres) ] + ↕ + [ Solid Queue Worker ] + (自動ターン処理ジョブを定期実行) +``` + +- **Rails App**: フロントエンド表示、ユーザー管理、ゲーム進行管理、自動ターンのスケジューリング。 +- **Diplomacy API**: ゲームのコアロジック(移動判定、支援カット、撤退判定など)とマップ描画を担当。 + +--- + +## セットアップ + +### 1. 前提条件 + +- Ruby 3.x 以上 +- Node.js (Tailwind CSS ビルド用) +- **Diplomacy API サーバー**: 別途起動が必要です(デフォルト: `http://0.0.0.0:8000`)。 + +### 2. インストール + +```bash +# リポジトリのクローン +git clone +cd kondiplo_front + +# 依存関係のインストール +bundle install + +# データベースのセットアップ +# (Solid Queue 用のテーブルもここで作成されます) +bin/rails db:create db:migrate + +# 初期データの投入 +# ※ db/seeds.rb で管理者ユーザーのメール・パスワードを確認・変更してください +bin/rails db:seed +``` + +### 3. 起動 + +開発環境では `bin/dev` を使用します。これにより、Webサーバー、CSSビルド、バックグラウンドワーカーが一括で起動します。 + +```bash +bin/dev +``` + +- アクセス: `http://localhost:3000` +- **注意**: 自動ターン処理を機能させるには、`bin/dev` で起動し、Solid Queueのワーカーが動いている必要があります。 + +--- + +## 使い方ガイド + +### 自動ターン進行の設定 + +1. ゲーム作成または編集画面の「ターン進行方式」セクションへ移動。 +2. プリセットから選択するか、「カスタム」を選んで時間を入力(例: `0,12,18` = 毎日0時・12時・18時に更新)。 +3. ゲームが開始されると、設定された時刻に自動で判定処理が走ります。 + +### NPC(非参加国)の挙動 + +- 「自動処理モード」で設定可能です。 + - **HOLD**: 何もしない(全ユニット維持)。 + - **Random**: ランダムに移動・支援・輸送命令を生成して実行。 + +--- + +## トラブルシューティング + +### サーバーが起動しない / Solid Queue エラー + +- `relation "solid_queue_jobs" does not exist` などのエラーが出る場合は、マイグレーションが不足しています。 + + ```bash + bin/rails db:migrate + ``` + + を実行してテーブルを作成してください。 + +### 自動ターンが進まない + +- `bin/dev` コンソールのログを確認してください。 +- `AutoTurnProcessJob` が定期的に(1分毎)実行されているか確認してください。 +- Solid Queue のワーカープロセスが立ち上がっているか確認してください(`bin/dev` で `worker` プロセスが表示されているはずです)。 + +--- + +## ライセンス + +Private diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..9a5ea73 --- /dev/null +++ b/Rakefile @@ -0,0 +1,6 @@ +# 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/api_process_response.json b/api_process_response.json new file mode 100644 index 0000000..df9f537 --- /dev/null +++ b/api_process_response.json @@ -0,0 +1,561 @@ +{ + "game_state": { + "controlled_powers": null, + "daide_port": null, + "deadline": 0, + "error": [], + "game_id": "Dncix-fiIf9-jtlQ", + "map_name": "standard", + "message_history": { + "S1901M": [] + }, + "messages": [], + "meta_rules": [], + "n_controls": 0, + "no_rules": [], + "note": "", + "observer_level": null, + "order_history": { + "S1901M": { + "AUSTRIA": [ + "A BUD - VIE", + "F TRI - ADR", + "A VIE - TRI" + ], + "ENGLAND": [ + "F EDI - NTH", + "F LON - ENG", + "A LVP - EDI" + ], + "FRANCE": [ + "F BRE - PIC", + "A MAR - PIE", + "A PAR - BUR" + ], + "GERMANY": [], + "ITALY": [], + "RUSSIA": [], + "TURKEY": [] + } + }, + "outcome": [], + "phase": "FALL 1901 MOVEMENT", + "phase_abbr": "", + "powers": { + "AUSTRIA": { + "abbrev": "A", + "adjust": [], + "centers": [ + "BUD", + "TRI", + "VIE" + ], + "civil_disorder": 0, + "controller": { + "1770644503197279": "dummy" + }, + "homes": [ + "BUD", + "TRI", + "VIE" + ], + "influence": [ + "BUD", + "VIE", + "TRI", + "ADR" + ], + "name": "AUSTRIA", + "order_is_set": 0, + "orders": {}, + "retreats": {}, + "role": "server_type", + "tokens": [], + "units": [ + "A VIE", + "A TRI", + "F ADR" + ], + "vote": "neutral", + "wait": true + }, + "ENGLAND": { + "abbrev": "E", + "adjust": [], + "centers": [ + "EDI", + "LON", + "LVP" + ], + "civil_disorder": 0, + "controller": { + "1770644503197318": "dummy" + }, + "homes": [ + "EDI", + "LON", + "LVP" + ], + "influence": [ + "LON", + "LVP", + "NTH", + "ENG", + "EDI" + ], + "name": "ENGLAND", + "order_is_set": 0, + "orders": {}, + "retreats": {}, + "role": "server_type", + "tokens": [], + "units": [ + "F NTH", + "F ENG", + "A EDI" + ], + "vote": "neutral", + "wait": true + }, + "FRANCE": { + "abbrev": "F", + "adjust": [], + "centers": [ + "BRE", + "MAR", + "PAR" + ], + "civil_disorder": 0, + "controller": { + "1770644503197354": "dummy" + }, + "homes": [ + "BRE", + "MAR", + "PAR" + ], + "influence": [ + "BRE", + "MAR", + "PAR", + "PIC", + "PIE", + "BUR" + ], + "name": "FRANCE", + "order_is_set": 0, + "orders": {}, + "retreats": {}, + "role": "server_type", + "tokens": [], + "units": [ + "F PIC", + "A PIE", + "A BUR" + ], + "vote": "neutral", + "wait": true + }, + "GERMANY": { + "abbrev": "G", + "adjust": [], + "centers": [ + "BER", + "KIE", + "MUN" + ], + "civil_disorder": 0, + "controller": { + "1770644503197391": "dummy" + }, + "homes": [ + "BER", + "KIE", + "MUN" + ], + "influence": [ + "KIE", + "BER", + "MUN" + ], + "name": "GERMANY", + "order_is_set": 0, + "orders": {}, + "retreats": {}, + "role": "server_type", + "tokens": [], + "units": [ + "F KIE", + "A BER", + "A MUN" + ], + "vote": "neutral", + "wait": true + }, + "ITALY": { + "abbrev": "I", + "adjust": [], + "centers": [ + "NAP", + "ROM", + "VEN" + ], + "civil_disorder": 0, + "controller": { + "1770644503197427": "dummy" + }, + "homes": [ + "NAP", + "ROM", + "VEN" + ], + "influence": [ + "NAP", + "ROM", + "VEN" + ], + "name": "ITALY", + "order_is_set": 0, + "orders": {}, + "retreats": {}, + "role": "server_type", + "tokens": [], + "units": [ + "F NAP", + "A ROM", + "A VEN" + ], + "vote": "neutral", + "wait": true + }, + "RUSSIA": { + "abbrev": "R", + "adjust": [], + "centers": [ + "MOS", + "SEV", + "STP", + "WAR" + ], + "civil_disorder": 0, + "controller": { + "1770644503197465": "dummy" + }, + "homes": [ + "MOS", + "SEV", + "STP", + "WAR" + ], + "influence": [ + "WAR", + "MOS", + "SEV", + "STP" + ], + "name": "RUSSIA", + "order_is_set": 0, + "orders": {}, + "retreats": {}, + "role": "server_type", + "tokens": [], + "units": [ + "A WAR", + "A MOS", + "F SEV", + "F STP/SC" + ], + "vote": "neutral", + "wait": true + }, + "TURKEY": { + "abbrev": "T", + "adjust": [], + "centers": [ + "ANK", + "CON", + "SMY" + ], + "civil_disorder": 0, + "controller": { + "1770644503197503": "dummy" + }, + "homes": [ + "ANK", + "CON", + "SMY" + ], + "influence": [ + "ANK", + "CON", + "SMY" + ], + "name": "TURKEY", + "order_is_set": 0, + "orders": {}, + "retreats": {}, + "role": "server_type", + "tokens": [], + "units": [ + "F ANK", + "A CON", + "A SMY" + ], + "vote": "neutral", + "wait": true + } + }, + "registration_password": null, + "result_history": { + "S1901M": { + "A BUD": [], + "A VIE": [], + "F TRI": [], + "F EDI": [], + "F LON": [], + "A LVP": [], + "F BRE": [], + "A MAR": [], + "A PAR": [], + "F KIE": [], + "A BER": [], + "A MUN": [], + "F NAP": [], + "A ROM": [], + "A VEN": [], + "A WAR": [], + "A MOS": [], + "F SEV": [], + "F STP/SC": [], + "F ANK": [], + "A CON": [], + "A SMY": [] + } + }, + "role": "server_type", + "rules": [ + "NO_DEADLINE", + "CD_DUMMIES", + "ALWAYS_WAIT", + "SOLITAIRE", + "NO_PRESS", + "IGNORE_ERRORS", + "POWER_CHOICE" + ], + "state_history": { + "S1901M": { + "timestamp": 1770644503199152, + "zobrist_hash": "1919110489198082658", + "note": "", + "name": "S1901M", + "units": { + "AUSTRIA": [ + "A BUD", + "A VIE", + "F TRI" + ], + "ENGLAND": [ + "F EDI", + "F LON", + "A LVP" + ], + "FRANCE": [ + "F BRE", + "A MAR", + "A PAR" + ], + "GERMANY": [ + "F KIE", + "A BER", + "A MUN" + ], + "ITALY": [ + "F NAP", + "A ROM", + "A VEN" + ], + "RUSSIA": [ + "A WAR", + "A MOS", + "F SEV", + "F STP/SC" + ], + "TURKEY": [ + "F ANK", + "A CON", + "A SMY" + ] + }, + "retreats": { + "AUSTRIA": {}, + "ENGLAND": {}, + "FRANCE": {}, + "GERMANY": {}, + "ITALY": {}, + "RUSSIA": {}, + "TURKEY": {} + }, + "centers": { + "AUSTRIA": [ + "BUD", + "TRI", + "VIE" + ], + "ENGLAND": [ + "EDI", + "LON", + "LVP" + ], + "FRANCE": [ + "BRE", + "MAR", + "PAR" + ], + "GERMANY": [ + "BER", + "KIE", + "MUN" + ], + "ITALY": [ + "NAP", + "ROM", + "VEN" + ], + "RUSSIA": [ + "MOS", + "SEV", + "STP", + "WAR" + ], + "TURKEY": [ + "ANK", + "CON", + "SMY" + ] + }, + "homes": { + "AUSTRIA": [ + "BUD", + "TRI", + "VIE" + ], + "ENGLAND": [ + "EDI", + "LON", + "LVP" + ], + "FRANCE": [ + "BRE", + "MAR", + "PAR" + ], + "GERMANY": [ + "BER", + "KIE", + "MUN" + ], + "ITALY": [ + "NAP", + "ROM", + "VEN" + ], + "RUSSIA": [ + "MOS", + "SEV", + "STP", + "WAR" + ], + "TURKEY": [ + "ANK", + "CON", + "SMY" + ] + }, + "influence": { + "AUSTRIA": [ + "BUD", + "VIE", + "TRI" + ], + "ENGLAND": [ + "EDI", + "LON", + "LVP" + ], + "FRANCE": [ + "BRE", + "MAR", + "PAR" + ], + "GERMANY": [ + "KIE", + "BER", + "MUN" + ], + "ITALY": [ + "NAP", + "ROM", + "VEN" + ], + "RUSSIA": [ + "WAR", + "MOS", + "SEV", + "STP" + ], + "TURKEY": [ + "ANK", + "CON", + "SMY" + ] + }, + "civil_disorder": { + "AUSTRIA": 0, + "ENGLAND": 0, + "FRANCE": 0, + "GERMANY": 0, + "ITALY": 0, + "RUSSIA": 0, + "TURKEY": 0 + }, + "builds": { + "AUSTRIA": { + "count": 0, + "homes": [] + }, + "ENGLAND": { + "count": 0, + "homes": [] + }, + "FRANCE": { + "count": 0, + "homes": [] + }, + "GERMANY": { + "count": 0, + "homes": [] + }, + "ITALY": { + "count": 0, + "homes": [] + }, + "RUSSIA": { + "count": 0, + "homes": [] + }, + "TURKEY": { + "count": 0, + "homes": [] + } + } + } + }, + "status": "forming", + "timestamp_created": 1770644503197200, + "victory": [ + 18 + ], + "win": 18, + "zobrist_hash": 2619432138363037717 + } +} \ No newline at end of file diff --git a/app/assets/builds/.keep b/app/assets/builds/.keep new file mode 100644 index 0000000..e69de29 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/background.png b/app/assets/images/background.png new file mode 100644 index 0000000..5c902b9 Binary files /dev/null and b/app/assets/images/background.png differ diff --git a/app/assets/images/background_2.jpg b/app/assets/images/background_2.jpg new file mode 100644 index 0000000..1607257 Binary files /dev/null and b/app/assets/images/background_2.jpg differ diff --git a/app/assets/images/header-logo.png b/app/assets/images/header-logo.png new file mode 100644 index 0000000..777e327 Binary files /dev/null and b/app/assets/images/header-logo.png differ diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css new file mode 100644 index 0000000..fe93333 --- /dev/null +++ b/app/assets/stylesheets/application.css @@ -0,0 +1,10 @@ +/* + * This is a manifest file that'll be compiled into application.css. + * + * With Propshaft, assets are served efficiently without preprocessing steps. You can still include + * application-wide styles in this file, but keep in mind that CSS precedence will follow the standard + * cascading order, meaning styles declared later in the document or manifest will override earlier ones, + * depending on specificity. + * + * Consider organizing styles into separate files for maintainability. + */ diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css new file mode 100644 index 0000000..f09e7d8 --- /dev/null +++ b/app/assets/tailwind/application.css @@ -0,0 +1,18 @@ +@import "tailwindcss"; + +:root { + --background-image-diplomacy: url('background.png'); +} + +body { + background-image: var(--background-image-diplomacy); + background-attachment: fixed; + background-size: 600px; + background-repeat: repeat; +} + +.game-map svg { + width: 100%; + height: auto; + max-height: 70vh; +} \ No newline at end of file diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb new file mode 100644 index 0000000..3777879 --- /dev/null +++ b/app/controllers/application_controller.rb @@ -0,0 +1,33 @@ +class ApplicationController < ActionController::Base + # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. + allow_browser versions: :modern + + # Changes to the importmap will invalidate the etag for HTML responses + stale_when_importmap_changes + + helper_method :current_user, :logged_in? + + private + + def current_user + @current_user ||= User.find_by(id: session[:user_id]) if session[:user_id] + end + + def logged_in? + current_user.present? + end + + def require_login + unless logged_in? + flash[:alert] = "ログインが必要です" + redirect_to login_path + end + end + + def require_admin + unless logged_in? && current_user.admin? + flash[:alert] = "管理者権限が必要です" + redirect_to root_path + end + end +end diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/controllers/game_participants_controller.rb b/app/controllers/game_participants_controller.rb new file mode 100644 index 0000000..07819d6 --- /dev/null +++ b/app/controllers/game_participants_controller.rb @@ -0,0 +1,62 @@ +class GameParticipantsController < ApplicationController + before_action :set_game + before_action :require_login + before_action :require_participant, only: [:select_power] + + # POST /games/1/game_participants/select_power + def select_power + power_name = params[:power_name] + + unless power_name.present? + redirect_to @game, alert: "国を選択してください" + return + end + + # 利用可能な国のリストを取得 + available_powers = get_available_powers + + unless available_powers.include?(power_name) + redirect_to @game, alert: "無効な国です" + return + end + + # 既に選択されているかチェック + if @game.game_participants.exists?(power: power_name) + redirect_to @game, alert: "その国は既に選択されています" + return + end + + # 国を選択 + @current_participant.select_power!(power_name) + + # 全員が選択したかチェック + if @game.can_start_order_input? + redirect_to @game, notice: "国を選択しました。全員の選択が完了しました!" + else + redirect_to @game, notice: "国を選択しました。他のプレイヤーの選択を待っています..." + end + end + + private + + def set_game + @game = Game.find(params[:game_id]) + @current_participant = current_user && @game.game_participants.find_by(user: current_user) + end + + def require_participant + unless @current_participant + redirect_to @game, alert: "このゲームに参加していません" + end + end + + def get_available_powers + # ディプロマシーの標準的な国 + standard_powers = %w[Austria England France Germany Italy Russia Turkey] + + # 既に選択されている国を除外 + selected_powers = @game.game_participants.where.not(power: nil).pluck(:power) + + standard_powers - selected_powers + end +end diff --git a/app/controllers/games_controller.rb b/app/controllers/games_controller.rb new file mode 100644 index 0000000..405d816 --- /dev/null +++ b/app/controllers/games_controller.rb @@ -0,0 +1,421 @@ +class GamesController < ApplicationController + include GamesHelper + before_action :set_game, only: %i[ show edit update destroy join_game start_power_selection start_order_input turn_data vote_draw force_draw ] + before_action :require_login, only: %i[ new create join_game ] + before_action :require_game_admin, only: %i[ edit update destroy start_power_selection start_order_input ] + + helper_method :get_available_powers_for_select + + # GET /games or /games.json + def index + @recruiting_games = Game.where(status: "recruiting").includes(:participants) + @my_games = current_user ? Game.joins(:participants).where(participants: { user_id: current_user.id }).includes(:participants, :turns) : [] + @games = Game.all.includes(:participants, :turns) + end + + # GET /games/1 or /games/1.json + def show + @latest_turn = @game.turns.last + + # ゲーム終了判定 + @game_finished = @game.status == "finished" + + # 表示するターンの決定 + if @game_finished + # 終了済みの場合: + # params[:turn_number] があればそのターン + # なければ 最初のターン (Turn 1) を表示 + if params[:turn_number].present? + @display_turn = @game.turns.find_by(number: params[:turn_number].to_i) + end + + # 指定がない、または見つからない場合は初期ターン(number=1)を表示 + # もし存在しなければ最新(というかあるやつ) + @display_turn ||= @game.turns.find_by(number: 1) || @latest_turn + + # 最終結果の取得 (最後のターン情報から) + if @latest_turn.game_state + centers = @latest_turn.game_state["centers"] || {} + alive_powers = centers.keys + + # ソロ勝利判定 + solo_winner = @game.solo_victory_power(@latest_turn.game_state) + if solo_winner + result_type = "Solo Victory" + winners = [ solo_winner ] + else + result_type = "Draw" + winners = alive_powers + end + + @winner_info = { + type: result_type, + winners: winners, + scores: @game.calculate_scores(@latest_turn.game_state) + } + end + else + # 進行中の場合: params[:turn_number] があればそのターン、なければ最新ターンを表示 + if params[:turn_number].present? + @display_turn = @game.turns.find_by(number: params[:turn_number].to_i) + end + @display_turn ||= @latest_turn + end + + if @display_turn + @game_state = @display_turn.game_state + + # フェーズ名のパース + @current_season_year = parse_phase(@display_turn.phase) + + # 国別情報の集計 (表示対象ターンのデータ) + centers = @game_state["centers"] || {} + units = @game_state["units"] || {} + + # 全7カ国(固定) + powers = %w[AUSTRIA ENGLAND FRANCE GERMANY ITALY RUSSIA TURKEY] + + @country_statuses = powers.map do |power| + participant = @game.participants.find_by(power: power) + # 終了済みなら全員完了扱い、そうでなければターンごとの提出状況 + submitted = @game_finished ? true : @display_turn.orders_submitted_for?(power) + + { + power: power, + sc_count: centers[power]&.size || 0, + unit_count: units[power]&.size || 0, + submitted: submitted, + participant: participant, + is_user: current_user && participant&.user_id == current_user.id + } + end + + # 自国を先頭に移動 + if current_user + user_power_index = @country_statuses.find_index { |s| s[:is_user] } + if user_power_index + user_status = @country_statuses.delete_at(user_power_index) + @country_statuses.unshift(user_status) + end + end + end + end + + # GET /games/new + def new + @game = Game.new + @game.is_solo_mode = current_user&.admin? ? false : false + end + + # GET /games/1/edit + def edit + end + + # POST /games or /games.json + def create + @game = Game.new(game_params) + + # ソロモードかどうかを判定(管理者のみ選択可能) + if current_user&.admin? && params.dig(:game, :game_mode) == "admin_mode" + @game.is_solo_mode = true + else + @game.is_solo_mode = false + end + + respond_to do |format| + if @game.save + # ゲーム作成者は自動的に参加者として登録(管理者として) + Participant.create!( + game: @game, + user: current_user, + is_administrator: true + ) + + # ソロモードの場合、即座に最初のターンを作成 + if @game.solo_mode? + service = GameSetupService.new(@game) + result = service.setup_initial_turn + if result[:success] + @game.update!(status: "in_progress") + else + # 失敗したらロールバックしたいところだが・・・ + # 現状はsave後なので、エラー表示だけにするかdestroyするか。 + # 今回は簡易的にログに残す + logger.error result[:message] + end + end + + format.html { redirect_to @game, notice: "ゲームが正常に作成されました。" } + format.json { render :show, status: :created, location: @game } + else + format.html { render :new, status: :unprocessable_entity } + format.json { render json: @game.errors, status: :unprocessable_entity } + end + end + end + + # PATCH/PUT /games/1 or /games/1.json + def update + respond_to do |format| + if @game.update(game_params) + # スケジュール変更時にデッドラインを再計算 + if @game.status == "in_progress" + @game.update_column(:next_deadline_at, @game.auto_turn? ? @game.calculate_next_deadline : nil) + end + format.html { redirect_to @game, notice: "ゲームが正常に更新されました。", status: :see_other } + format.json { render :show, status: :ok, location: @game } + else + format.html { render :edit, status: :unprocessable_entity } + format.json { render json: @game.errors, status: :unprocessable_entity } + end + end + end + + # DELETE /games/1 or /games/1.json + def destroy + @game.destroy! + + respond_to do |format| + format.html { redirect_to games_path, notice: "ゲームが正常に削除されました。", status: :see_other } + format.json { head :no_content } + end + end + + # POST /games/1/join + def join_game + unless current_user + redirect_to login_path, alert: "ゲームに参加するにはログインしてください" + return + end + + # パスワードチェック + if @game.password_protected? + password = params.dig(:participant, :password) + unless @game.authenticate_password(password) + redirect_to @game, alert: "パスワードが正しくありません" + return + end + end + + # 既に参加しているかチェック + if @game.participants.exists?(user: current_user) + redirect_to @game, alert: "既にこのゲームに参加しています" + return + end + + # 満員チェック + if @game.participants.count >= @game.participants_count + redirect_to @game, alert: "このゲームは満員です" + return + end + + # 参加者を作成 + Participant.create!( + game: @game, + user: current_user + ) + + # 定員到達チェック + if @game.participants.count == @game.participants_count + @game.update(status: "power_selection") + end + + redirect_to @game, notice: "ゲームに正常に参加しました!" + end + + # POST /games/1/start_power_selection + def start_power_selection + unless @game.participants.count >= 2 + redirect_to @game, alert: "最低2人の参加者が必要です" + return + end + + @game.update!(status: "power_selection") + redirect_to @game, notice: "国選択フェーズを開始しました!" + end + + # POST /games/1/start_order_input + def start_order_input + unless @game.all_powers_assigned? + redirect_to @game, alert: "全員が国を選択する必要があります" + return + end + + # 最初のターンを作成 + service = GameSetupService.new(@game) + result = service.setup_initial_turn + + if result[:success] + update_attrs = { status: "in_progress" } + update_attrs[:next_deadline_at] = @game.calculate_next_deadline if @game.auto_turn? + @game.update!(update_attrs) + redirect_to @game, notice: "命令入力フェーズを開始しました!" + else + redirect_to @game, alert: result[:message] + end + end + + # GET /games/1/turn_data.json + def turn_data + if params[:turn_number].present? + target_turn = @game.turns.find_by(number: params[:turn_number]) + end + target_turn ||= @game.turns.last + + return head :not_found unless target_turn + + # 国別ステータス情報の再計算(JSON用) + # showアクションと同様のロジック + game_state = target_turn.game_state + centers = game_state["centers"] || {} + units = game_state["units"] || {} + powers = %w[AUSTRIA ENGLAND FRANCE GERMANY ITALY RUSSIA TURKEY] + + country_statuses = powers.map do |power| + # 終了済みなら全員完了扱い + submitted = @game.status == "finished" ? true : target_turn.orders_submitted_for?(power) + { + power: power, + sc_count: centers[power]&.size || 0, + unit_count: units[power]&.size || 0, + submitted: submitted + } + end + + render json: { + turn_number: target_turn.number, + phase: target_turn.phase, + possible_orders: target_turn.possible_orders, + decided_orders: target_turn.orders || {}, + svg_orders: target_turn.svg_orders || {}, + # svg_date カラムにデフォルトSVGが入っていると仮定(または svg_orders["NONE"]) + # ここでは svg_date が単一の画像パスかSVG文字列かによるが、 + # 既存の show.html.erb 実装を見ると svg_orders["NONE"] を使っている可能性が高い + # 既存実装: default_svg: last_turn.svg_date となっていたのでそのまま変更なしでいくが + # target_turn.svg_date を返すようにする + default_svg: target_turn.svg_date, + + # 完了状況 + all_orders_submitted: @game.status == "finished" || @game.all_orders_submitted?, + missing_orders_powers: @game.status == "finished" ? [] : @game.participants.where(orders_submitted: false).pluck(:power).compact, + country_statuses: country_statuses + } + end + + def vote_draw + return unless @game.status == "in_progress" + + turn = @game.turns.last + participant = @game.participants.find_by(user: current_user) + + unless participant && participant.power + redirect_to @game, alert: "権限がありません。" + return + end + + power = participant.power + if turn.draw_voted?(power) + turn.revoke_draw_vote(power) + flash[:notice] = "引き分け投票を取り消しました。" + else + turn.vote_draw(power) + flash[:notice] = "引き分けに投票しました。" + end + + if turn.unanimous_draw? + if execute_draw(turn) + flash[:notice] = "全会一致により、ゲームは引き分けとなりました。" + else + # エラーメッセージは execute_draw 内で flash[:alert] に設定される + end + end + + redirect_to @game + end + + def force_draw + return unless current_user.admin? + + turn = @game.turns.last + if execute_draw(turn) + redirect_to @game, notice: "ゲームを強制的に引き分けにしました。" + else + redirect_to @game + end + end + + private + + def execute_draw(turn) + client = GameApiClient.new + # 生存国を勝者として扱う(引き分け) + winners = turn.powers + begin + response = client.api_game_draw(turn.game_state, winners: winners) + + if response + # 新しいターン(完了状態)を作成 + Turn.create!( + game: @game, + number: turn.number + 1, + # year, season カラムは存在しないため削除 + # phase を COMPLETED に設定 + game_state: response, + orders: {}, + possible_orders: {}, + phase: "COMPLETED", + svg_date: turn.svg_date, + svg_orders: turn.svg_orders + ) + @game.update(status: "finished") + true + else + flash[:alert] = "ゲームサーバーからの応答が不正です。" + false + end + rescue Faraday::ConnectionFailed => e + flash[:alert] = "ゲームサーバーへの接続に失敗しました。管理者へ連絡してください。" + Rails.logger.error "API Connection Failed: #{e.message}" + false + rescue Faraday::TimeoutError => e + flash[:alert] = "ゲームサーバーとの通信がタイムアウトしました。" + Rails.logger.error "API Timeout: #{e.message}" + false + rescue StandardError => e + flash[:alert] = "予期せぬエラーが発生しました: #{e.message}" + Rails.logger.error "Execute Draw Error: #{e.message}" + false + end + end + + + + def set_game + @game = Game.find(params.expect(:id)) + end + + def require_game_admin + unless current_user&.admin? || @game.administrator == current_user + redirect_to @game, alert: "ゲーム管理者のみこの機能にアクセスできます" + end + end + + def game_params + house_rules = [ :year_limit, :victory_sc_count, :scoring_system, :turn_schedule ] + + if action_name == "update" + params.expect(game: [ :title, :memo, :auto_order_mode ] + house_rules) + else + permitted = [ :title, :memo ] + house_rules + + if current_user&.admin? + permitted += [ :participants_count, :password, :auto_order_mode ] + else + # 一般ユーザーはマルチプレイヤーモードのみ + permitted += [ :participants_count, :password, :auto_order_mode ] + end + + params.expect(game: permitted) + end + end +end diff --git a/app/controllers/participants_controller.rb b/app/controllers/participants_controller.rb new file mode 100644 index 0000000..9042bad --- /dev/null +++ b/app/controllers/participants_controller.rb @@ -0,0 +1,87 @@ +class ParticipantsController < ApplicationController + before_action :set_game + + # POST /games/:game_id/participants + def create + # パスワード確認 + if @game.password_protected? + unless @game.authenticate_password(params[:password]) + redirect_to @game, alert: "パスワードが正しくありません" + return + end + end + + # 参加処理 + @participant = @game.participants.build( + user: current_user, + is_administrator: false + ) + + if @participant.save + # 定員到達チェック + if @game.participants.count == @game.participants_count + @game.update(status: "power_selection") + end + + redirect_to @game, notice: "ゲームに参加しました" + else + redirect_to @game, alert: @participant.errors.full_messages.join(", ") + end + end + + # PATCH /games/:game_id/participants/:id/select_power + def select_power + @participant = @game.participants.find(params[:id]) + + unless @participant.user == current_user + redirect_to @game, alert: "権限がありません" + return + end + + if @participant.update(power: params[:power]) + # 全員が国を選択したかチェック + if @game.all_powers_assigned? + service = GameSetupService.new(@game) + result = service.setup_initial_turn + + if result[:success] + @game.update(status: "in_progress") + flash[:notice] = "国を選択し、ゲームが開始されました!" + else + flash[:alert] = "ゲーム開始に失敗しました: #{result[:message]}" + end + else + flash[:notice] = "国を選択しました" + end + + redirect_to @game + else + redirect_to @game, alert: @participant.errors.full_messages.join(", ") + end + end + + # DELETE /games/:game_id/participants/:id + def destroy + @participant = @game.participants.find(params[:id]) + + unless @participant.user == current_user || current_user&.admin? + redirect_to @game, alert: "権限がありません" + return + end + + @participant.destroy + + # ゲーム状態を更新 + if @game.status == "power_selection" + @game.update(status: "recruiting") + end + + redirect_to games_path, notice: "ゲームから退出しました" + end + + private + + def set_game + @game = Game.find(params[:game_id]) + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 0000000..06cee1d --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,22 @@ +class SessionsController < ApplicationController + def new + end + + def create + user = User.find_by(email: params[:email]&.downcase) + if user&.authenticate(params[:password]) + session[:user_id] = user.id + flash[:notice] = "ログインしました" + redirect_to root_path + else + flash.now[:alert] = "メールアドレスまたはパスワードが正しくありません" + render :new, status: :unprocessable_entity + end + end + + def destroy + session[:user_id] = nil + flash[:notice] = "ログアウトしました" + redirect_to root_path + end +end diff --git a/app/controllers/turns_controller.rb b/app/controllers/turns_controller.rb new file mode 100644 index 0000000..86c584f --- /dev/null +++ b/app/controllers/turns_controller.rb @@ -0,0 +1,108 @@ +class TurnsController < ApplicationController + before_action :require_login + before_action :set_turn, only: %i[ show edit update destroy submit_orders process_turn ] + + # GET /turns or /turns.json + def index + @turns = Turn.all + end + + # GET /turns/1 or /turns/1.json + def show + end + + # GET /turns/new + def new + @turn = Turn.new + end + + # GET /turns/1/edit + def edit + end + + # POST /turns or /turns.json + def create + @turn = Turn.new(turn_params) + + respond_to do |format| + if @turn.save + format.html { redirect_to @turn, notice: "Turn was successfully created." } + format.json { render :show, status: :created, location: @turn } + else + format.html { render :new, status: :unprocessable_entity } + format.json { render json: @turn.errors, status: :unprocessable_entity } + end + end + end + + # PATCH/PUT /turns/1 or /turns/1.json + def update + respond_to do |format| + if @turn.update(turn_params) + format.html { redirect_to @turn, notice: "Turn was successfully updated.", status: :see_other } + format.json { render :show, status: :ok, location: @turn } + else + format.html { render :edit, status: :unprocessable_entity } + format.json { render json: @turn.errors, status: :unprocessable_entity } + end + end + end + + # DELETE /turns/1 or /turns/1.json + def destroy + @turn.destroy! + + respond_to do |format| + format.html { redirect_to turns_path, notice: "Turn was successfully destroyed.", status: :see_other } + format.json { head :no_content } + end + end + + # PATCH /turns/1/submit_orders + def submit_orders + @turn = Turn.find(params[:id]) + power = params[:power] + orders = params[:orders]&.permit!&.to_h || {} + + service = OrderSubmissionService.new(@turn, current_user) + result = service.submit(power: power, orders: orders) + + if result[:success] + redirect_to game_path(@turn.game), notice: result[:message] + else + redirect_to game_path(@turn.game), alert: result[:message] + end + end + + # POST /turns/1/process_turn + def process_turn + @turn = Turn.find(params[:id]) + @game = @turn.game + + # Check admin/turn ending permissions + unless @game.solo_mode? || current_user&.admin? || @game.administrator == current_user + redirect_to game_path(@game), alert: "ゲーム管理者のみターンを終了できます" + return + end + + service = TurnProcessingService.new(@turn) + result = service.process(force: params[:force]) + + if result[:success] + redirect_to game_path(@game), notice: result[:message] + else + redirect_to game_path(@game), alert: result[:message] + end + end + + private + # Use callbacks to share common setup or constraints between actions. + def set_turn + @turn = Turn.find(params.expect(:id)) + end + + # Only allow a list of trusted parameters through. + def turn_params + params.expect(turn: [ :number, :phase, :game_state, :svg_date, :game_id, :possible_orders, :orders ]) + end +end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb new file mode 100644 index 0000000..6ba1e36 --- /dev/null +++ b/app/controllers/users_controller.rb @@ -0,0 +1,88 @@ +class UsersController < ApplicationController + before_action :require_admin, only: [:index, :destroy, :toggle_admin] + before_action :set_user, only: [:show, :edit, :update, :destroy, :toggle_admin] + before_action :require_admin_or_owner, only: [:show, :edit, :update] + + def index + @users = User.all.order(created_at: :desc) + end + + def show + end + + def new + @user = User.new + end + + def create + @user = User.new(user_params) + if @user.save + session[:user_id] = @user.id + flash[:notice] = "アカウントを作成しました" + redirect_to root_path + else + render :new, status: :unprocessable_entity + end + end + + def edit + end + + def update + # パスワードが空の場合は更新しない + if user_update_params[:password].blank? + user_update_params.delete(:password) + user_update_params.delete(:password_confirmation) + end + + if @user.update(user_update_params) + flash[:notice] = "ユーザー情報を更新しました" + redirect_to user_path(@user) + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + if @user == current_user + flash[:alert] = "自分自身を削除することはできません" + redirect_to users_path + else + @user.destroy + flash[:notice] = "ユーザーを削除しました" + redirect_to users_path + end + end + + def toggle_admin + if @user == current_user + flash[:alert] = "自分自身の管理者権限は変更できません" + else + @user.update(admin: !@user.admin) + flash[:notice] = "管理者権限を#{@user.admin? ? '付与' : '削除'}しました" + end + redirect_to users_path + end + + private + + def set_user + @user = User.find(params[:id]) + end + + def require_admin_or_owner + unless current_user&.admin? || current_user == @user + flash[:alert] = "アクセス権限がありません" + redirect_to root_path + end + end + + def user_params + params.require(:user).permit(:username, :email, :password, :password_confirmation) + end + + def user_update_params + # メールアドレスは変更不可 + params.require(:user).permit(:username, :password, :password_confirmation) + 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/games_helper.rb b/app/helpers/games_helper.rb new file mode 100644 index 0000000..5ac0a38 --- /dev/null +++ b/app/helpers/games_helper.rb @@ -0,0 +1,51 @@ +module GamesHelper + def power_color_class(power) + case power + when "AUSTRIA" + "bg-red-100 text-red-800" + when "ENGLAND" + "bg-purple-100 text-purple-800" + when "FRANCE" + "bg-sky-100 text-sky-800" + when "GERMANY" + "bg-amber-100 text-amber-900" + when "ITALY" + "bg-green-100 text-green-800" + when "RUSSIA" + "bg-gray-100 text-gray-800" + when "TURKEY" + "bg-yellow-100 text-yellow-800" + else + "bg-gray-100 text-gray-800" + end + end + + def parse_phase(phase_string) + # 例: "S1901M" -> "1901年 春 (移動)" + # 例: "F1901R" -> "1901年 秋 (撤退)" + # 例: "W1901A" -> "1901年 冬 (調整)" + + return phase_string if phase_string.blank? + return phase_string unless phase_string.match?(/^[SFW]\d{4}[MRA]$/) + + season_code = phase_string[0] + year = phase_string[1..4] + type_code = phase_string[5] + + season = case season_code + when "S" then "春" + when "F" then "秋" + when "W" then "冬" + else season_code + end + + type = case type_code + when "M" then "移動" + when "R" then "撤退" + when "A" then "調整" + else type_code + end + + "#{year}年 #{season} (#{type})" + end +end diff --git a/app/helpers/turns_helper.rb b/app/helpers/turns_helper.rb new file mode 100644 index 0000000..395e6cd --- /dev/null +++ b/app/helpers/turns_helper.rb @@ -0,0 +1,2 @@ +module TurnsHelper +end diff --git a/app/javascript/application.js b/app/javascript/application.js new file mode 100644 index 0000000..0d7b494 --- /dev/null +++ b/app/javascript/application.js @@ -0,0 +1,3 @@ +// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails +import "@hotwired/turbo-rails" +import "controllers" diff --git a/app/javascript/controllers/application.js b/app/javascript/controllers/application.js new file mode 100644 index 0000000..1213e85 --- /dev/null +++ b/app/javascript/controllers/application.js @@ -0,0 +1,9 @@ +import { Application } from "@hotwired/stimulus" + +const application = Application.start() + +// Configure Stimulus development experience +application.debug = false +window.Stimulus = application + +export { application } diff --git a/app/javascript/controllers/hello_controller.js b/app/javascript/controllers/hello_controller.js new file mode 100644 index 0000000..5975c07 --- /dev/null +++ b/app/javascript/controllers/hello_controller.js @@ -0,0 +1,7 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + connect() { + this.element.textContent = "Hello World!" + } +} diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js new file mode 100644 index 0000000..1156bf8 --- /dev/null +++ b/app/javascript/controllers/index.js @@ -0,0 +1,4 @@ +// Import and register all your controllers from the importmap via controllers/**/*_controller +import { application } from "controllers/application" +import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" +eagerLoadControllersFrom("controllers", application) 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/auto_turn_process_job.rb b/app/jobs/auto_turn_process_job.rb new file mode 100644 index 0000000..dfe1a95 --- /dev/null +++ b/app/jobs/auto_turn_process_job.rb @@ -0,0 +1,66 @@ +class AutoTurnProcessJob < ApplicationJob + queue_as :default + + def perform + Game.where(status: "in_progress") + .where.not(next_deadline_at: nil) + .where("next_deadline_at <= ?", Time.current) + .find_each do |game| + process_game(game) + rescue StandardError => e + Rails.logger.error "AutoTurnProcessJob: Game #{game.id} failed: #{e.message}" + Rails.logger.error e.backtrace.first(5).join("\n") + end + end + + private + + def process_game(game) + latest_turn = game.turns.where.not(phase: "COMPLETED").last + return unless latest_turn + + client = GameApiClient.new + current_orders = latest_turn.orders || {} + + # 人間が担当していない国のAutoOrderを生成 + # 人間プレイヤーの未提出分はそのまま(空のまま)処理する + all_powers = latest_turn.game_state&.dig("units")&.keys || [] + human_powers = game.participants.where.not(power: nil).pluck(:power).map(&:upcase) + submitted_powers = current_orders.keys.map(&:upcase) + + all_powers.each do |power| + next if submitted_powers.include?(power.upcase) + + # 人間プレイヤーが担当している国は命令未提出のまま進行 + next if human_powers.include?(power.upcase) + + # 人間が担当していない国のみAutoOrderを適用 + if game.auto_order_mode == "random" + auto_orders_response = client.api_calculate_auto_orders(latest_turn.game_state, power) + if auto_orders_response && auto_orders_response["orders"] + current_orders[power] = auto_orders_response["orders"] + end + end + # auto_order_mode == "hold" の場合はAPIがデフォルトでHOLDを適用するため何もしない + end + + # 自動生成した命令を保存 + latest_turn.update_columns(orders: current_orders) if current_orders.present? + + # ターン処理を実行(force: true で未提出でも強制進行) + service = TurnProcessingService.new(latest_turn, client: client) + result = service.process(force: "true") + + if result[:success] + game.reload + if game.status == "in_progress" + game.update!(next_deadline_at: game.calculate_next_deadline) + else + game.update!(next_deadline_at: nil) + end + Rails.logger.info "AutoTurnProcess: Game #{game.id} processed successfully: #{result[:message]}" + else + Rails.logger.error "AutoTurnProcess: Game #{game.id} failed: #{result[:message]}" + end + 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/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/concerns/.keep b/app/models/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/models/game.rb b/app/models/game.rb new file mode 100644 index 0000000..a48820f --- /dev/null +++ b/app/models/game.rb @@ -0,0 +1,165 @@ +class Game < ApplicationRecord + has_many :turns, dependent: :destroy + has_many :participants, dependent: :destroy + has_many :users, through: :participants + + # パスワード保護 + has_secure_password :password, validations: false + + # バリデーション + validates :status, inclusion: { + in: %w[recruiting power_selection in_progress finished cancelled] + } + validates :auto_order_mode, inclusion: { in: %w[hold random] } + validates :participants_count, + numericality: { greater_than_or_equal_to: 2, less_than_or_equal_to: 7 }, + unless: :is_solo_mode? + + # ハウスルールバリデーション + validates :year_limit, + numericality: { only_integer: true, greater_than_or_equal_to: 1901, less_than_or_equal_to: 1999 }, + allow_nil: true + validates :victory_sc_count, + numericality: { only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: 34 } + validates :scoring_system, inclusion: { in: %w[none sc_count sc_ratio dss sos] } + + # ターンスケジュールバリデーション + validate :validate_turn_schedule + + # ヘルパーメソッド + def password_protected? + password_digest.present? + end + + def solo_mode? + is_solo_mode + end + + def administrator + participants.find_by(is_administrator: true)&.user + end + + def available_powers + assigned_powers = participants.where.not(power: nil).pluck(:power) + %w[AUSTRIA ENGLAND FRANCE GERMANY ITALY RUSSIA TURKEY] - assigned_powers + end + + def all_powers_assigned? + participants.where(power: nil).empty? && + participants.count == participants_count + end + + def all_orders_submitted? + participants.where(power: nil).empty? && + participants.all?(&:orders_submitted) + end + + def unassigned_powers + all_powers = %w[AUSTRIA ENGLAND FRANCE GERMANY ITALY RUSSIA TURKEY] + assigned_powers = participants.where.not(power: nil).pluck(:power) + all_powers - assigned_powers + end + + # ターンスケジュール関連メソッド + + def auto_turn? + turn_schedule.present? + end + + # 次のデッドラインを計算(日本時間基準) + def calculate_next_deadline + return nil unless turn_schedule.present? + + hours = turn_schedule.split(",").map(&:strip).map(&:to_i).sort + now = Time.current.in_time_zone("Asia/Tokyo") + + # 今日の残りの時間枠を探す(JST基準) + next_time = hours.map { |h| now.beginning_of_day + h.hours } + .find { |t| t > now } + + # 今日の枠がなければ翌日の最初の枠 + next_time || (now.beginning_of_day + 1.day + hours.first.hours) + end + + # スケジュール表示用 + def schedule_display + return "手動" unless turn_schedule.present? + hours = turn_schedule.split(",").map(&:strip) + "毎日 " + hours.map { |h| "#{h}時" }.join("・") + end + + # ハウスルール関連メソッド + + # 年数制限チェック: フェーズ名から年を抽出し、year_limitを超えているか判定 + def year_limit_reached?(phase_name) + return false unless year_limit.present? && phase_name.present? + + # フェーズ名の例: "S1901M", "F1910R", "W1901A" + year_match = phase_name.match(/[SFW](\d{4})[MRA]/) + return false unless year_match + + year_match[1].to_i > year_limit + end + + # ソロ勝利判定: いずれかの国のSC数がvictory_sc_count以上か + def solo_victory?(game_state) + centers = game_state&.dig("centers") || {} + centers.any? { |_power, scs| scs.size >= victory_sc_count } + end + + # ソロ勝利した国を返す + def solo_victory_power(game_state) + centers = game_state&.dig("centers") || {} + centers.find { |_power, scs| scs.size >= victory_sc_count }&.first + end + + # スコア計算 + def calculate_scores(game_state) + return {} if scoring_system == "none" + + centers = game_state&.dig("centers") || {} + total_scs = centers.values.flatten.size + alive_count = centers.count { |_power, scs| scs.any? } + + case scoring_system + when "sc_count" + # 単純SC数 + centers.transform_values { |scs| scs.size } + when "sc_ratio" + # SC比率(%) + centers.transform_values { |scs| total_scs > 0 ? (scs.size.to_f / total_scs * 100).round(1) : 0 } + when "dss" + # Draw Size Scoring: 生存国で均等分割 + centers.transform_values { |scs| scs.any? ? (100.0 / alive_count).round(1) : 0 } + when "sos" + # Sum of Squares + sum_of_squares = centers.values.sum { |scs| scs.size ** 2 }.to_f + centers.transform_values { |scs| sum_of_squares > 0 ? ((scs.size ** 2) / sum_of_squares * 100).round(1) : 0 } + else + {} + end + end + + # スコアリング方式の日本語名 + def scoring_system_name + case scoring_system + when "none" then "なし" + when "sc_count" then "SC数" + when "sc_ratio" then "SC比率" + when "dss" then "DSS(均等分割)" + when "sos" then "SoS(二乗和)" + else scoring_system + end + end + + private + + def validate_turn_schedule + return if turn_schedule.blank? + + hours = turn_schedule.split(",").map(&:strip) + unless hours.all? { |h| h.match?(/\A\d{1,2}\z/) && h.to_i.between?(0, 23) } + errors.add(:turn_schedule, "は0〜23の数値をカンマ区切りで入力してください(例: 0,18)") + end + end +end diff --git a/app/models/game_participant.rb b/app/models/game_participant.rb new file mode 100644 index 0000000..85d66d0 --- /dev/null +++ b/app/models/game_participant.rb @@ -0,0 +1,52 @@ +class GameParticipant < ApplicationRecord + belongs_to :game + belongs_to :user + + validates :status, inclusion: { in: %w[joined ready finished] } + validates :user_id, uniqueness: { scope: :game_id } + validates :power, uniqueness: { scope: :game_id }, allow_nil: true + + # ステータス管理メソッド + def joined? + status == 'joined' + end + + def ready? + status == 'ready' + end + + def finished? + status == 'finished' + end + + # パワー選択関連 + def has_selected_power? + power.present? + end + + def select_power!(power_name) + update!(power: power_name, status: 'ready') + end + + # ゲーム参加メソッド + class << self + def join_game!(game, user, password = nil) + # パスワードチェック(プレイヤーモードの場合) + if game.player_mode? && game.password.present? + raise "Invalid password" unless game.password == password + end + + # 既存参加チェック + if game.game_participants.exists?(user_id: user.id) + raise "Already joined this game" + end + + # 定員チェック + if game.player_mode? && game.full? + raise "Game is full" + end + + create!(game: game, user: user, joined_at: Time.current, status: 'joined') + end + end +end diff --git a/app/models/participant.rb b/app/models/participant.rb new file mode 100644 index 0000000..53013d4 --- /dev/null +++ b/app/models/participant.rb @@ -0,0 +1,20 @@ +class Participant < ApplicationRecord + belongs_to :user + belongs_to :game + + # バリデーション + validates :user_id, uniqueness: { + scope: :game_id, + message: "既にこのゲームに参加しています" + } + + validates :power, uniqueness: { + scope: :game_id, + message: "この国は既に選択されています" + }, allow_nil: true + + validates :power, inclusion: { + in: %w[AUSTRIA ENGLAND FRANCE GERMANY ITALY RUSSIA TURKEY], + message: "無効な国です" + }, allow_nil: true +end diff --git a/app/models/turn.rb b/app/models/turn.rb new file mode 100644 index 0000000..4c168bd --- /dev/null +++ b/app/models/turn.rb @@ -0,0 +1,124 @@ +class Turn < ApplicationRecord + belongs_to :game + + # 特定の国の実行可能な命令を取得 + # 例: turn.possible_orders_for("FRANCE") + def possible_orders_for(power_name) + possible_orders&.dig("possible_orders", power_name.to_s.upcase) + end + + # 特定の国の決定済み命令を取得 + # 例: turn.orders_for("FRANCE") + def orders_for(power_name) + # orders は直接ハッシュを保存する場合を想定(必要に応じてネストを調整) + orders&.dig(power_name.to_s.upcase) + end + + # 命令が存在するすべての国名リストを取得 + def powers + possible_orders&.dig("possible_orders")&.keys || [] + end + + # 特定の国の命令が提出済みかチェック + def orders_submitted_for?(power_name) + orders&.key?(power_name.to_s.upcase) + end + + # 未提出の国のリストを取得 + def pending_powers + game.participants.where.not(power: nil).map(&:power).reject do |power| + orders_submitted_for?(power) + end + end + + # 自動命令を生成(参加者がいない国用) + def generate_auto_orders_for_unassigned_powers + unassigned = game.unassigned_powers + return if unassigned.empty? + + client = GameApiClient.new + current_orders = orders || {} + + unassigned.each do |power| + if game.auto_order_mode == "hold" + # HOLD命令を生成 + current_orders[power] = generate_hold_orders(power) + else + # ランダム命令を生成 + auto_orders_response = client.api_calculate_auto_orders(game_state, power) + if auto_orders_response + # APIレスポンスから命令を抽出 + # レスポンスが {"orders": [...]} の形式の場合 + if auto_orders_response.is_a?(Hash) && auto_orders_response["orders"] + current_orders[power] = auto_orders_response["orders"] + elsif auto_orders_response.is_a?(Array) + current_orders[power] = auto_orders_response + else + # フォールバック: HOLD命令を使用 + current_orders[power] = generate_hold_orders(power) + end + end + end + end + + update(orders: current_orders) + update(orders: current_orders) + end + + # 引き分け投票 + def vote_draw(power_name) + current_votes = draw_votes || [] + power = power_name.to_s.upcase + unless current_votes.include?(power) + current_votes << power + update(draw_votes: current_votes) + end + end + + # 引き分け投票取り消し + def revoke_draw_vote(power_name) + current_votes = draw_votes || [] + power = power_name.to_s.upcase + if current_votes.include?(power) + current_votes.delete(power) + update(draw_votes: current_votes) + end + end + + # 投票済みかチェック + def draw_voted?(power_name) + (draw_votes || []).include?(power_name.to_s.upcase) + end + + # 全会一致で引き分けかチェック + def unanimous_draw? + # 生存している国 + active_powers = powers + return false if active_powers.empty? + + # プレイヤーが割り当てられている国のみを対象とする + # (未割り当ての国は投票できないため、除外する) + assigned_powers = game.participants.where.not(power: nil).pluck(:power).map(&:upcase) + + # 投票権を持つ国 = 生存している かつ プレイヤーがいる + eligible_powers = active_powers.map(&:upcase) & assigned_powers + + return false if eligible_powers.empty? + + current_votes = draw_votes || [] + eligible_powers.all? { |power| current_votes.include?(power) } + end + + private + + def generate_hold_orders(power) + possible = possible_orders_for(power) + return {} unless possible + + hold_orders = {} + possible.each do |unit, moves| + hold_orders[unit] = [ "H" ] if moves.is_a?(Array) + end + hold_orders + end +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000..550103f --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,15 @@ +class User < ApplicationRecord + has_secure_password + + validates :username, presence: true, length: { minimum: 3, maximum: 50 } + validates :email, presence: true, + uniqueness: { case_sensitive: false }, + format: { with: URI::MailTo::EMAIL_REGEXP } + validates :password, length: { minimum: 6 }, if: -> { new_record? || !password.nil? } + + before_save { self.email = email.downcase } + + def admin? + admin + end +end diff --git a/app/services/game_api_client.rb b/app/services/game_api_client.rb new file mode 100644 index 0000000..bee2872 --- /dev/null +++ b/app/services/game_api_client.rb @@ -0,0 +1,126 @@ +require "faraday" +require "json" + +class GameApiClient + BASE_URL = "http://0.0.0.0:8000" + + def initialize + @connection = Faraday.new(url: BASE_URL) do |f| + f.request :json # リクエストをJSON形式にする + f.response :json # レスポンスをJSONとしてパースする + f.adapter Faraday.default_adapter + end + end + + # GET リクエスト: 初期状態の取得 + def api_game_initial_state(map_name = "standard") + response = @connection.get("/game/initial-state", { map_name: map_name }) + handle_response(response, "game/initial-state") + end + + # POST リクエスト: ゲームのみを強制終了 (Draw) にする + def api_game_draw(game_state, winners: nil) + response = @connection.post("/game/draw") do |req| + body = { game_state: game_state } + body[:winners] = winners if winners + req.body = body + end + handle_response(response, "game/draw") + end + + # GET リクエスト: 利用可能なマップの取得 + def api_maps + response = @connection.get("/maps") + handle_response(response, "maps") + end + + # GET /maps/{map_name} + def api_maps_data(map_name) + response = @connection.get("/maps/#{map_name}") + handle_response(response, "maps/#{map_name}") + end + + # POST リクエスト: 可能な命令の計算 + def api_calculate_possible_orders(game_state, power_name: "", by_power: false) + response = @connection.post("/calculate/possible-orders") do |req| + req.params["power_name"] = power_name + req.params["by_power"] = by_power + req.body = { game_state: game_state } + end + handle_response(response, "calculate/possible-orders") + end + + # POST リクエスト: 命令を処理して次のフェーズへ進める + def api_calculate_process(game_state, orders) + normalized_orders = normalize_orders(orders) + response = @connection.post("/calculate/process") do |req| + req.body = { game_state: game_state, orders: normalized_orders } + end + handle_response(response, "calculate/process") + end + + # POST リクエスト: 命令の妥当性を検証する + def api_calculate_validate(game_state, orders) + normalized_orders = normalize_orders(orders) + response = @connection.post("/calculate/validate") do |req| + req.body = { game_state: game_state, orders: normalized_orders } + end + handle_response(response, "calculate/validate") + end + + # POST リクエスト: 特定勢力の命令を自動生成する + def api_calculate_auto_orders(game_state, power_name) + response = @connection.post("/calculate/auto-orders") do |req| + req.params["power_name"] = power_name + req.body = { game_state: game_state } + end + handle_response(response, "calculate/auto-orders") + end + + # POST リクエスト: マップをSVGとしてレンダリングする + def api_render(game_state, orders: nil, incl_orders: true, incl_abbrev: true) + # ordersのキーを文字列に変換し、値を配列に変換して正規化 + normalized_orders = normalize_orders(orders) + + body = { + game_state: game_state, + orders: normalized_orders, + incl_orders: incl_orders, + incl_abbrev: incl_abbrev + } + Rails.logger.debug "API Render Request Body: #{body.inspect}" + + response = @connection.post("/render") do |req| + req.body = body + end + handle_response(response, "render") + end + + private + + def normalize_orders(orders) + return nil unless orders + + normalized_orders = {} + orders.each do |power, power_orders| + # power_ordersがハッシュの場合、値の配列に変換 + if power_orders.is_a?(Hash) + normalized_orders[power.to_s] = power_orders.values + elsif power_orders.is_a?(Array) + normalized_orders[power.to_s] = power_orders + else + normalized_orders[power.to_s] = power_orders + end + end + normalized_orders + end + + def handle_response(response, endpoint) + if response.success? + response.body + else + Rails.logger.error "API Error (#{endpoint}): #{response.status} - #{response.body}" + nil + end + end +end diff --git a/app/services/game_setup_service.rb b/app/services/game_setup_service.rb new file mode 100644 index 0000000..489bcde --- /dev/null +++ b/app/services/game_setup_service.rb @@ -0,0 +1,37 @@ +class GameSetupService + def initialize(game, client: GameApiClient.new) + @game = game + @client = client + end + + def setup_initial_turn + initial_response = @client.api_game_initial_state + + unless initial_response && initial_response["game_state"] + return { success: false, message: "初期状態の取得に失敗しました。" } + end + + initial_state = initial_response["game_state"] + svg_render = @client.api_render(initial_state) + possible_orders = @client.api_calculate_possible_orders(initial_state, by_power: true) + + @game.turns.build( + number: 1, + game_state: initial_state, + possible_orders: possible_orders, + phase: initial_state&.dig("name"), + svg_date: svg_render, + # Initialize svg_orders with NONE (and standard rendering as default) + svg_orders: { "NONE" => svg_render } + ) + + if @game.save + { success: true, message: "ゲームが初期化されました。" } + else + { success: false, message: "初期ターンの作成に失敗しました: #{@game.errors.full_messages.join(', ')}" } + end + rescue StandardError => e + Rails.logger.error("Game setup failed: #{e.message}") + { success: false, message: "予期せぬエラーが発生しました。" } + end +end diff --git a/app/services/order_submission_service.rb b/app/services/order_submission_service.rb new file mode 100644 index 0000000..818fb26 --- /dev/null +++ b/app/services/order_submission_service.rb @@ -0,0 +1,87 @@ +class OrderSubmissionService + def initialize(turn, user, client: GameApiClient.new) + @turn = turn + @game = turn.game + @user = user + @client = client + end + + def submit(power:, orders:) + # Check permissions + unless valid_permission?(power) + return { success: false, message: "権限がありません。" } + end + + # Handle multiplayer updates (orders_submitted flag) + if !@game.solo_mode? + participant = @game.participants.find_by(user: @user) + participant.update(orders_submitted: true) if participant + end + + # Update orders tentatively for validation + current_orders = @turn.orders || {} + tentative_orders = current_orders.merge(power => orders) + + # Validate + validation_result = @client.api_calculate_validate(@turn.game_state, tentative_orders) + + if validation_result.nil? + return { success: false, message: "バリデーションサーバーとの通信に失敗しました。" } + end + + # Assuming validation_result is an array of error messages or empty if valid + # Adjust this based on actual API response structure if known differently. + # Common pattern: ["Order A is invalid", "Order B is impossible"] + if validation_result.is_a?(Array) && validation_result.any? + return { success: false, message: "命令に誤りがあります: #{validation_result.join(', ')}" } + end + + # Check if it returns hash with "errors" key? + if validation_result.is_a?(Hash) && validation_result["errors"]&.any? + return { success: false, message: "命令に誤りがあります: #{validation_result['errors'].join(', ')}" } + end + + # If passed validation, update current_orders + current_orders[power] = orders + + # Generate SVGs + svg_orders_data = @turn.svg_orders || {} + + # None SVG (first time only) + unless svg_orders_data["NONE"] + none_svg = @client.api_render(@turn.game_state, orders: nil) + svg_orders_data["NONE"] = none_svg if none_svg + end + + # Power specific SVG + power_orders = { power => orders } + power_svg = @client.api_render(@turn.game_state, orders: power_orders) + svg_orders_data[power] = power_svg if power_svg + + # All SVG + all_svg = @client.api_render(@turn.game_state, orders: current_orders) + svg_orders_data["ALL"] = all_svg if all_svg + + # Save + if @turn.update(orders: current_orders, svg_orders: svg_orders_data) + { success: true, message: "命令を送信しました" } + else + { success: false, message: "命令の送信に失敗しました" } + end + rescue StandardError => e + Rails.logger.error("Order submission failed: #{e.message}") + { success: false, message: "予期せぬエラーが発生しました。" } + end + + private + + def valid_permission?(power) + return true if @game.solo_mode? + + participant = @game.participants.find_by(user: @user) + return false unless participant + + # Compare case-insensitive + participant.power&.upcase == power&.upcase + end +end diff --git a/app/services/turn_processing_service.rb b/app/services/turn_processing_service.rb new file mode 100644 index 0000000..0bf417e --- /dev/null +++ b/app/services/turn_processing_service.rb @@ -0,0 +1,135 @@ +class TurnProcessingService + def initialize(turn, client: GameApiClient.new) + @turn = turn + @game = turn.game + @client = client + end + + def process(force: false) + # Check for unsubmitted orders in multiplayer + if !@game.solo_mode? && !@game.all_orders_submitted? + unless force == "true" + return { success: false, message: "全プレイヤーの命令入力が完了していません。強制ターン終了ボタンを使用してください。" } + end + end + + current_orders = @turn.orders || {} + + # 非人間プレイヤーのランダム命令を事前に生成(ターン処理の前に) + if @game.auto_order_mode == "random" && !@game.solo_mode? + # ゲーム状態から全国のリストを取得(参加者だけでなく全国にランダム命令を生成) + all_powers = @turn.game_state&.dig("units")&.keys || [] + submitted_powers = current_orders.keys.map(&:upcase) + + all_powers.each do |power| + # 既に命令が提出済みならスキップ + next if submitted_powers.include?(power.upcase) + + auto_orders_response = @client.api_calculate_auto_orders(@turn.game_state, power) + + if auto_orders_response && auto_orders_response["orders"] + current_orders[power] = auto_orders_response["orders"] + end + end + + # 自動生成した命令を現在のターンに保存 + @turn.update_columns(orders: current_orders) if current_orders.present? + end + + # 現在のターンにALL SVG(全プレイヤーの命令を含む画像)を保存 + # 履歴モードで「このターンでどんな命令が出されたか」を表示するため + if current_orders.present? + current_svg_orders = @turn.svg_orders || {} + all_svg_current = @client.api_render(@turn.game_state, orders: current_orders) + if all_svg_current + current_svg_orders["ALL"] = all_svg_current + @turn.update_columns(svg_orders: current_svg_orders) + end + end + + # Calculate next state + process_response = @client.api_calculate_process(@turn.game_state, current_orders) + + unless process_response + return { success: false, message: "ターン処理に失敗しました。" } + end + + new_game_state = process_response["game_state"] + + # Transaction to ensure data consistency + Game.transaction do + # Create next turn foundation + possible_orders = @client.api_calculate_possible_orders(new_game_state, by_power: true) + svg = @client.api_render(new_game_state, orders: nil) + + new_turn = @game.turns.build( + number: @turn.number + 1, + game_state: new_game_state, + orders: {}, + phase: new_game_state&.dig("name"), + possible_orders: possible_orders, + svg_date: svg, + svg_orders: { "NONE" => svg } + ) + + # Save new turn + if new_turn.save + # Reset orders_submitted flag + @game.participants.update_all(orders_submitted: false) unless @game.solo_mode? + + # ハウスルール: 勝利条件判定 + result = check_victory_conditions(new_turn, new_game_state) + return result if result + + { success: true, message: "ターンを終了し、次のフェーズへ進みました。" } + else + { success: false, message: "次のターンの作成に失敗しました: #{new_turn.errors.full_messages.join(', ')}" } + end + end + rescue StandardError => e + Rails.logger.error("Turn processing failed: #{e.message}") + Rails.logger.error(e.backtrace.join("\n")) + { success: false, message: "予期せぬエラーが発生しました。" } + end + + private + + def check_victory_conditions(new_turn, new_game_state) + centers = new_game_state&.dig("centers") || {} + + # 1. ソロ勝利判定 + if @game.solo_victory?(new_game_state) + winner = @game.solo_victory_power(new_game_state) + finish_game(new_turn, "solo", [ winner ]) + return { success: true, message: "#{winner} が #{@game.victory_sc_count} SC を獲得し、ソロ勝利しました!" } + end + + # 2. 年数制限判定 + if @game.year_limit_reached?(new_turn.phase) + # SC数最多の国が勝者、同数なら引き分け + max_sc = centers.values.map(&:size).max + winners = centers.select { |_power, scs| scs.size == max_sc }.keys + finish_game(new_turn, winners.size == 1 ? "year_limit_solo" : "year_limit_draw", winners) + result_type = winners.size == 1 ? "#{winners.first} の勝利" : "#{winners.join(', ')} の引き分け" + return { success: true, message: "年数制限(#{@game.year_limit}年)に達しました。#{result_type}です。" } + end + + nil + end + + def finish_game(last_turn, result_type, winners) + # 完了ターンを作成 + Turn.create!( + game: @game, + number: last_turn.number + 1, + game_state: last_turn.game_state, + orders: {}, + possible_orders: {}, + phase: "COMPLETED", + svg_date: last_turn.svg_date, + svg_orders: last_turn.svg_orders + ) + @game.update!(status: "finished") + Rails.logger.info("Game #{@game.id} finished: #{result_type}, winners: #{winners.join(', ')}") + end +end diff --git a/app/views/games/_form.html.erb b/app/views/games/_form.html.erb new file mode 100644 index 0000000..adbe890 --- /dev/null +++ b/app/views/games/_form.html.erb @@ -0,0 +1,211 @@ +<%= form_with(model: game, class: "space-y-6") do |form| %> + <% if game.errors.any? %> +
+
+
+ +
+
+

+ <%= pluralize(game.errors.count, "error") %> prohibited this game from being saved: +

+
+
    + <% game.errors.each do |error| %> +
  • <%= error.full_message %>
  • + <% end %> +
+
+
+
+
+ <% end %> + +
+ <%= form.label :title, "ゲームタイトル", class: "block text-sm font-medium text-gray-700" %> +
+ <%= form.text_field :title, class: "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md", placeholder: "例: 定例ディプロマシー会 #1" %> +
+
+ + <% unless game.persisted? %> + <% if current_user&.admin? %> +
+ <%= form.label :game_mode, "ゲームモード", class: "block text-sm font-medium text-gray-700" %> +
+ <%= form.select :game_mode, + options_for_select([ + ['ソロモード (単独プレイ)', 'admin_mode'], + ['マルチプレイヤーモード (複数プレイ)', 'player_mode'] + ], game.is_solo_mode ? 'admin_mode' : 'player_mode'), + {}, + { id: 'game-mode-select', class: 'shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md' } %> +
+
+ <% end %> + +
+ <%= form.label :participants_count, "参加人数", class: "block text-sm font-medium text-gray-700" %> +
+ <%= form.number_field :participants_count, min: 1, max: 7, step: 1, class: "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md" %> +
+

1人から7人まで設定可能です。

+
+ +
+ <%= form.label :password, "パスワード (任意)", class: "block text-sm font-medium text-gray-700" %> +
+ <%= form.password_field :password, class: "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md" %> +
+

知人同士でプレイする場合など、アクセス制限をかけたい場合に設定してください。

+
+ <% end %> + +
+ <%= form.label :auto_order_mode, "自動処理モード", class: "block text-sm font-medium text-gray-700" %> +
+ <%= form.select :auto_order_mode, + options_for_select([ + ['HOLD (待機)', 'hold'], + ['ランダム動作', 'random'] + ], game.auto_order_mode || 'hold'), + {}, + { class: 'shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md' } %> +
+

注文未入力の国や、プレイヤー不在の国の動作を設定します。

+
+ +
+ <%= form.label :turn_schedule, "ターン進行方式", class: "block text-sm font-medium text-gray-700" %> +
+ <% current_schedule = game.turn_schedule.presence %> + <% preset_value = case current_schedule + when nil then "manual" + when "0" then "daily_0" + else "custom" + end %> + +
+
+ +
+ <%= form.hidden_field :turn_schedule, id: "turn_schedule_value", value: current_schedule %> +

+ 自動の場合、締切時刻を過ぎると未入力国はAutoOrderで処理され、ターンが自動進行します。 +

+
+ + + +
+ <%= form.label :memo, "メモ", class: "block text-sm font-medium text-gray-700" %> +
+ <%= form.textarea :memo, rows: 3, class: "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md" %> +
+

ゲームに関するメモや注意事項があれば記載してください。

+
+ + +
+

ハウスルール

+ +
+
+ <%= form.label :year_limit, "年数制限", class: "block text-sm font-medium text-gray-700" %> +
+ <%= form.number_field :year_limit, min: 1901, max: 1999, step: 1, class: "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md", placeholder: "空欄 = 無制限" %> +
+

指定した年を超えるとゲームが自動終了します(例: 1910)。空欄で無制限。

+
+ +
+ <%= form.label :victory_sc_count, "目標SC数", class: "block text-sm font-medium text-gray-700" %> +
+ <%= form.number_field :victory_sc_count, min: 1, max: 34, step: 1, value: game.victory_sc_count || 18, class: "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md" %> +
+

ソロ勝利に必要なSC数(デフォルト: 18)。

+
+ +
+ <%= form.label :scoring_system, "スコアリング方式", class: "block text-sm font-medium text-gray-700" %> +
+ <%= form.select :scoring_system, + options_for_select([ + ['なし', 'none'], + ['SC数(獲得SC数がスコア)', 'sc_count'], + ['SC比率(全SC中の割合%)', 'sc_ratio'], + ['DSS(生存国で均等分割)', 'dss'], + ['SoS(二乗和比率)', 'sos'] + ], game.scoring_system || 'none'), + {}, + { class: 'shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md' } %> +
+

ゲーム終了時のスコア計算方式を選択します。

+
+
+
+ +
+
+ <%= form.submit "保存する", class: "ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %> +
+
+ + +<% end %> diff --git a/app/views/games/_game.html.erb b/app/views/games/_game.html.erb new file mode 100644 index 0000000..717a52a --- /dev/null +++ b/app/views/games/_game.html.erb @@ -0,0 +1,18 @@ +
+

+ <%= game.title %> +

+ +
+ + + + <%= game.participants_count %> Players +
+ + <% if game.memo.present? %> +

+ <%= game.memo %> +

+ <% end %> +
diff --git a/app/views/games/_game.json.jbuilder b/app/views/games/_game.json.jbuilder new file mode 100644 index 0000000..a4fe609 --- /dev/null +++ b/app/views/games/_game.json.jbuilder @@ -0,0 +1,2 @@ +json.extract! game, :id, :title, :participants_count, :memo, :created_at, :updated_at +json.url game_url(game, format: :json) diff --git a/app/views/games/edit.html.erb b/app/views/games/edit.html.erb new file mode 100644 index 0000000..fa87d7d --- /dev/null +++ b/app/views/games/edit.html.erb @@ -0,0 +1,25 @@ +<% content_for :title, "ゲーム編集" %> + +
+
+
+

+ ゲーム設定の編集 +

+
+
+ <%= link_to @game, class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" do %> + + + + ゲームに戻る + <% end %> +
+
+ +
+
+ <%= render "form", game: @game %> +
+
+
diff --git a/app/views/games/index.html.erb b/app/views/games/index.html.erb new file mode 100644 index 0000000..9ee7114 --- /dev/null +++ b/app/views/games/index.html.erb @@ -0,0 +1,117 @@ +

<%= notice %>

+ +<% content_for :title, "Games" %> + +<% content_for :top_content do %> +
+ <%= image_tag "header-logo.png", width: 768, alt: "DipFront Logo" %> +
+<% end %> + +
+

Games

+ <%= link_to "New game", new_game_path, class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %> +
+ +<% if current_user && @my_games.any? %> +
+

参加中のゲーム

+
+ <% @my_games.each do |game| %> +
+
+

<%= game.title %>

+
+
+
+ <% if game.status == 'finished' %> +

状態: 履歴モード

+ <% else %> +

状態: <%= game.status %>

+ <% end %> + <% if game.turns.present? %> +

時期: <%= parse_phase(game.turns.sort_by(&:number).last&.phase) %>

+ <% end %> + <% participant = game.participants.find_by(user: current_user) %> + <% if participant %> +

国: <%= participant.power || '未選択' %>

+

+ 命令: + +

+ <% end %> +
+
+
+ <%= link_to game, class: "inline-flex items-center text-sm font-bold text-green-900 hover:text-[#c5a059] transition-colors" do %> + プレイする + <% end %> +
+
+ <% end %> +
+
+<% end %> + +<% if @recruiting_games.any? %> +
+

募集中のゲーム

+
+ <% @recruiting_games.each do |game| %> +
+
+

<%= game.title %>

+
+
+
+

参加者: <%= game.participants.count %> / <%= game.participants_count %>

+ <% if game.password_protected? %> +

パスワード保護

+ <% else %> +

公開ゲーム

+ <% end %> +
+
+
+ <%= link_to game, class: "inline-flex items-center text-sm font-bold text-green-900 hover:text-[#c5a059] transition-colors" do %> + 詳細を見る + <% end %> +
+
+ <% end %> +
+
+<% end %> + +
+

すべてのゲーム

+
+ <% @games.each do |game| %> +
+
+

<%= game.title %>

+
+
+
+ <% if game.status == 'finished' %> +

履歴モード

+ <% else %> +

<%= game.status %>

+ <% end %> + <% if game.turns.present? %> +

時期: <%= parse_phase(game.turns.sort_by(&:number).last&.phase) %>

+ <% end %> +

参加人数: <%= game.participants.size %> / <%= game.participants_count %>

+
+
+
+ <%= link_to game, class: "text-sm font-medium text-gray-600 hover:text-gray-900" do %> + Show + <% end %> +
+
+ <% end %> +
+
diff --git a/app/views/games/index.json.jbuilder b/app/views/games/index.json.jbuilder new file mode 100644 index 0000000..e6305a0 --- /dev/null +++ b/app/views/games/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @games, partial: "games/game", as: :game diff --git a/app/views/games/new.html.erb b/app/views/games/new.html.erb new file mode 100644 index 0000000..871e6ec --- /dev/null +++ b/app/views/games/new.html.erb @@ -0,0 +1,25 @@ +<% content_for :title, "ゲーム作成" %> + +
+
+
+

+ 新規ゲーム作成 +

+
+
+ <%= link_to games_path, class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" do %> + + + + 一覧に戻る + <% end %> +
+
+ +
+
+ <%= render "form", game: @game %> +
+
+
diff --git a/app/views/games/show.html.erb b/app/views/games/show.html.erb new file mode 100644 index 0000000..524b0e8 --- /dev/null +++ b/app/views/games/show.html.erb @@ -0,0 +1,1153 @@ +<% content_for :title, @game.title %> + +
+
+

<%= h(@game.title) %>

+ <% if @game.memo.present? %> +

<%= h(@game.memo) %>

+ <% end %> +
+ + <%= @game.solo_mode? ? 'ソロモード' : 'マルチプレイヤーモード' %> + + <% if @game.administrator %> + 管理者: <%= h(@game.administrator.username) %> + <% end %> + 状態: <%= @game.status %> +
+ <% if @game.year_limit.present? || @game.victory_sc_count != 18 || @game.scoring_system != "none" || @game.auto_turn? %> +
+ ハウスルール: + <% if @game.auto_turn? %> + + <%= @game.schedule_display %> + + <% end %> + <% if @game.year_limit.present? %> + + <%= @game.year_limit %>年制限 + + <% end %> + <% if @game.victory_sc_count != 18 %> + + 目標SC: <%= @game.victory_sc_count %> + + <% end %> + <% if @game.scoring_system != "none" %> + + <%= @game.scoring_system_name %> + + <% end %> +
+ <% end %> + <% if @game.auto_turn? && @game.next_deadline_at.present? && @game.status == "in_progress" %> +
+ + 次のターン締切: <%= @game.next_deadline_at.in_time_zone("Asia/Tokyo").strftime("%Y-%m-%d %H:%M") %> + +
+ + <% end %> +
+
+ <% if current_user&.admin? || @game.administrator == current_user %> + <%= link_to "設定", edit_game_path(@game), class: "inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %> + <%= button_to "削除", @game, method: :delete, class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500", data: { turbo_confirm: "本当に削除しますか?" } %> + <% end %> +
+
+ + +<% if !@game.solo_mode? && @game.status == 'recruiting' %> +
+
+

ゲーム参加

+
+
+
+

+ 現在の参加者: <%= @game.participants.count %> / + <%= @game.participants_count %>人 +

+ <% if @game.password_protected? %> +

※このゲームはパスワード保護されています

+ <% end %> +
+ + +
+

参加者一覧:

+
+ <% @game.participants.includes(:user).each do |participant| %> +
+ <%= participant.user.username %> + + <%= participant.power.present? ? "(#{participant.power})" : "(未選択)" %> + +
+ <% end %> +
+
+ + + <% current_participant = @game.participants.find_by(user: current_user) %> + <% if current_user && !current_participant && @game.participants.count < @game.participants_count %> + <%= form_with url: join_game_game_path(@game), method: :post, class: "space-y-3" do |f| %> + <% if @game.password_protected? %> +
+ <%= f.label :password, "参加パスワード", class: "block text-sm font-medium text-gray-700" %> + <%= f.password_field :password, class: "mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md" %> +
+ <% end %> +
+ <%= f.submit "ゲームに参加", class: "inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %> +
+ <% end %> + <% elsif current_participant %> +

あなたはこのゲームに参加しています

+ <% elsif @game.participants.count >= @game.participants_count %> +

このゲームは満員です

+ <% elsif !current_user %> +

+ <%= link_to "ログイン", login_path %>してゲームに参加 +

+ <% end %> + + + <% if current_user&.admin? || @game.administrator == current_user %> +
+

ゲーム管理者コントロール

+
+ <% if @game.participants.count >= 2 && @game.status == 'recruiting' %> + <%= button_to "国選択フェーズ開始", start_power_selection_game_path(@game), + method: :post, + class: "inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500" %> + <% end %> +
+
+ <% end %> +
+
+<% end %> + + +<% if @game.status == 'power_selection' %> +
+
+

国選択フェーズ

+

全参加者が国を選択してください

+
+
+ +
+

参加者の国選択状況:

+
+ <% @game.participants.includes(:user).each do |participant| %> +
+
+ <%= participant.user.username %> + <% if participant.power.present? %> + + <%= participant.power %> + + <% else %> + + 未選択 + + <% end %> +
+ <% if participant.user == current_user && !participant.power.present? %> + <%= form_with url: select_power_game_participant_path(@game, participant), method: :patch, class: "flex items-center space-x-2" do |f| %> + <%= f.select :power, + options_for_select(@game.available_powers.map { |p| [p, p] }), + { prompt: "国を選択" }, + { class: "block pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md" } %> + <%= f.submit "選択", class: "inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %> + <% end %> + <% end %> +
+ <% end %> +
+
+ + + <% if (current_user&.admin? || @game.administrator == current_user) && @game.all_powers_assigned? %> +
+
+

全員の国選択が完了しました!

+ <%= button_to "命令入力フェーズ開始", start_order_input_game_path(@game), + method: :post, + class: "inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500" %> +
+
+ <% end %> +
+
+<% end %> + + +<% if @display_turn && (@game.solo_mode? || @game.status == 'in_progress' || @game_finished) %> + + <% if @game_finished && @winner_info %> + +
+
+
+ +
+
+

+ Game Over - Final Result +

+
+

+ 結果: <%= @winner_info[:type] == "Solo Victory" ? "ソロ勝利" : "引き分け (Draw)" %> +

+

+ <%= @winner_info[:type] == "Solo Victory" ? "勝者:" : "生存国:" %> + <% @winner_info[:winners].each do |winner| %> + + <%= winner %> + + <% end %> +

+ <% if @winner_info[:scores].present? %> +
+

スコア(<%= @game.scoring_system_name %>):

+
+ <% @winner_info[:scores].sort_by { |_, v| -v }.each do |power, score| %> +
+ <%= power %> + <%= score %> +
+ <% end %> +
+
+ <% end %> +
+
+
+
+ <% end %> + + +
+
+
+ <% if @game_finished %> +
+ + 履歴モード + +

<%= @current_season_year %>

+
+ <% else %> +

<%= @current_season_year %>

+ <% end %> + +
+ ターン: <%= @display_turn.number %> + | + フェーズ: <%= @display_turn.phase %> +
+
+ + <% if @game_finished %> + +
+ <% if @display_turn.number > 1 %> + <%= link_to game_path(@game, turn_number: @display_turn.number - 1, anchor: "game-turn-section"), class: "inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", data: { turbo: false } do %> + 前のターン + <% end %> + <% else %> + + <% end %> + + <% if @display_turn.number < @latest_turn.number %> + <%= link_to game_path(@game, turn_number: @display_turn.number + 1, anchor: "game-turn-section"), class: "inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", data: { turbo: false } do %> + 次のターン + <% end %> + <% else %> + + <% end %> +
+ <% else %> + +
+ <% current_participant = @game.participants.find_by(user: current_user) %> + <% if current_participant&.power.present? %> +
+ あなたの担当国: + <%= current_participant.power %> + <%= hidden_field_tag :current_user_power, current_participant.power, id: "current-user-power" %> +
+ <% else %> +
+ + <% powers = %w[AUSTRIA ENGLAND FRANCE GERMANY ITALY RUSSIA TURKEY] %> + <% powers << "ALL" if current_user&.admin? || @game.administrator == current_user %> + <%= select_tag :power, options_for_select(powers), id: "power-select", prompt: "国を選択", class: "block w-40 pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md" %> +
+ <% end %> + + <% if @latest_turn.number > 1 %> +
+ <% if @display_turn.number > 1 %> + <%= link_to game_path(@game, turn_number: @display_turn.number - 1), class: "inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", data: { turbo: false } do %> + 前 + <% end %> + <% else %> + + <% end %> + + <% if @display_turn.number < @latest_turn.number %> + <%= link_to game_path(@game, turn_number: @display_turn.number + 1), class: "inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", data: { turbo: false } do %> + 次 + <% end %> + <% else %> + + <% end %> +
+ <% end %> +
+ <% end %> +
+
+ +
+ +
+
+
+
+ <% if @display_turn %> + <% if @game_finished %> + <% initial_svg = @display_turn.svg_orders&.dig("ALL") || @display_turn.svg_orders&.dig("NONE") || @display_turn.svg_date %> + <% else %> + <% initial_svg = @display_turn.svg_orders&.dig("NONE") || @display_turn.svg_date %> + <% end %> + <% if initial_svg.present? %> + <%= raw initial_svg %> + <% else %> +

マップデータがありません

+ <% end %> + <% end %> +
+
+
+ + + <% unless @game_finished || (@display_turn && @display_turn.number < @latest_turn.number) %> +
+

命令入力

+ <%= form_with url: submit_orders_turn_path(@latest_turn), method: :patch, id: "orders-form", class: "space-y-4" do |f| %> + <%= f.hidden_field :power, id: "hidden-power-field" %> + +
+

国を選択(または担当国でログイン)してください

+
+ + + <% end %> +
+ <% end %> + + + <% unless @game_finished || (@display_turn && @display_turn.number < @latest_turn.number) %> + <% current_participant = @game.participants.find_by(user: current_user) %> + <% if current_participant&.power.present? %> +
+ <% if @latest_turn.draw_voted?(current_participant.power) %> +
+ 引き分けに投票済みです +
+ + +
+
+ <% else %> +
+ ゲームを引き分けで終了しますか? +
+ + +
+
+ <% end %> +
+ <% end %> + <% end %> +
+ + +
+ +
+
+

国別情報

+
+
+ + + + + + + + + + + <% @country_statuses.each do |status| %> + + + + + + + <% end %> + +
国名SCUnit命令
+ + <%= status[:power] %> + + <% if status[:is_user] %> + YOU + <% elsif status[:participant].nil? %> + + NPC + + <% end %> + <% if @latest_turn.draw_voted?(status[:power]) %> + + + + <% end %> + <%= status[:sc_count] %><%= status[:unit_count] %> + <% if status[:submitted] %> + + 完了 + + <% else %> + + 未完了 + + <% end %> +
+
+
+ + +
+
+

確定した命令

+
+
+
+

-

+
+
+
+ + + <% unless @game_finished %> + <% if current_user&.admin? || @game.administrator == current_user %> +
+
+

管理者メニュー

+
+
+ <% if !@game.solo_mode? && !@game.all_orders_submitted? %> +
+

+ ⚠️ 全プレイヤーの命令入力が完了していません。
+ 未完了: <%= @game.participants.where(orders_submitted: false).pluck(:power).compact.join(', ') %> +

+
+
+ <%= button_to "ターン終了(全員完了待ち)", process_turn_turn_path(@display_turn), method: :post, form_class: "w-full", class: "w-full inline-flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-gray-600 hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500", data: { turbo_confirm: "全員の命令入力が完了していません。本当に実行しますか?" } %> + <%= button_to "強制ターン終了", process_turn_turn_path(@display_turn, force: true), method: :post, form_class: "w-full", class: "w-full inline-flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500", data: { turbo_confirm: "⚠️ 未完了のプレイヤーがいますが、強制的にターンを終了します。よろしいですか?" } %> + +
+ <%= button_to "強制引き分け (Draw)", force_draw_game_path(@game), method: :post, form_class: "w-full", class: "w-full inline-flex justify-center items-center px-4 py-2 border border-purple-300 text-sm font-medium rounded-md text-purple-700 bg-purple-50 hover:bg-purple-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500", data: { turbo_confirm: "⚠️ ゲームを強制的に引き分けとして終了させます。よろしいですか?" } %> +
+
+ <% else %> + <%= button_to "ターン終了", process_turn_turn_path(@display_turn), method: :post, form_class: "w-full", class: "w-full inline-flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500", data: { turbo_confirm: "本当に実行しますか?これにより結果が計算され、次のフェーズに進みます。" } %> + <% end %> +
+
+ <% end %> + <% end %> + +
+
+ + +<% else %> +
+
+
+

+ <% if @game.solo_mode? %> + ソロモードのゲーム準備中... + <% elsif @game.status == 'recruiting' %> + プレイヤーの参加を待っています... + <% else %> + ゲーム準備中... + <% end %> +

+
+
+
+<% end %> + + diff --git a/app/views/games/show.json.jbuilder b/app/views/games/show.json.jbuilder new file mode 100644 index 0000000..c04bffe --- /dev/null +++ b/app/views/games/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "games/game", game: @game diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb new file mode 100644 index 0000000..ee20209 --- /dev/null +++ b/app/views/layouts/application.html.erb @@ -0,0 +1,110 @@ + + + + <%= content_for(:title) || "Dip Front" %> + + + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= yield :head %> + + + + + + + + + + + <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + + + + +
+ <%= yield :top_content %> +
+ <% if notice %> + + <% end %> + + <% if alert %> + + <% end %> + +
+ <%= 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/pwa/manifest.json.erb b/app/views/pwa/manifest.json.erb new file mode 100644 index 0000000..9432f08 --- /dev/null +++ b/app/views/pwa/manifest.json.erb @@ -0,0 +1,22 @@ +{ + "name": "DipFront", + "icons": [ + { + "src": "/icon.png", + "type": "image/png", + "sizes": "512x512" + }, + { + "src": "/icon.png", + "type": "image/png", + "sizes": "512x512", + "purpose": "maskable" + } + ], + "start_url": "/", + "display": "standalone", + "scope": "/", + "description": "DipFront.", + "theme_color": "red", + "background_color": "red" +} diff --git a/app/views/pwa/service-worker.js b/app/views/pwa/service-worker.js new file mode 100644 index 0000000..b3a13fb --- /dev/null +++ b/app/views/pwa/service-worker.js @@ -0,0 +1,26 @@ +// Add a service worker for processing Web Push notifications: +// +// self.addEventListener("push", async (event) => { +// const { title, options } = await event.data.json() +// event.waitUntil(self.registration.showNotification(title, options)) +// }) +// +// self.addEventListener("notificationclick", function(event) { +// event.notification.close() +// event.waitUntil( +// clients.matchAll({ type: "window" }).then((clientList) => { +// for (let i = 0; i < clientList.length; i++) { +// let client = clientList[i] +// let clientPath = (new URL(client.url)).pathname +// +// if (clientPath == event.notification.data.path && "focus" in client) { +// return client.focus() +// } +// } +// +// if (clients.openWindow) { +// return clients.openWindow(event.notification.data.path) +// } +// }) +// ) +// }) diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb new file mode 100644 index 0000000..f45497e --- /dev/null +++ b/app/views/sessions/new.html.erb @@ -0,0 +1,28 @@ +<% content_for :title, "ログイン" %> + +
+

ログイン

+ + <%= form_with url: login_path, class: "space-y-6" do |f| %> +
+ <%= label_tag :email, "メールアドレス", class: "block text-sm font-medium text-gray-700" %> + <%= email_field_tag :email, params[:email], class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500", autofocus: true %> +
+ +
+ <%= label_tag :password, "パスワード", class: "block text-sm font-medium text-gray-700" %> + <%= password_field_tag :password, nil, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" %> +
+ +
+ <%= submit_tag "ログイン", class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %> +
+ <% end %> + +
+

+ アカウントをお持ちでないですか? + <%= link_to "新規登録", signup_path, class: "font-medium text-indigo-600 hover:text-indigo-500" %> +

+
+
diff --git a/app/views/turns/_form.html.erb b/app/views/turns/_form.html.erb new file mode 100644 index 0000000..6f25642 --- /dev/null +++ b/app/views/turns/_form.html.erb @@ -0,0 +1,42 @@ +<%= form_with(model: turn) do |form| %> + <% if turn.errors.any? %> +
+

<%= pluralize(turn.errors.count, "error") %> prohibited this turn from being saved:

+ +
    + <% turn.errors.each do |error| %> +
  • <%= error.full_message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form.label :number, style: "display: block" %> + <%= form.number_field :number %> +
+ +
+ <%= form.label :phase, style: "display: block" %> + <%= form.text_field :phase %> +
+ +
+ <%= form.label :game_state, style: "display: block" %> + <%= form.text_field :game_state %> +
+ +
+ <%= form.label :svg_date, style: "display: block" %> + <%= form.textarea :svg_date %> +
+ +
+ <%= form.label :game_id, style: "display: block" %> + <%= form.text_field :game_id %> +
+ +
+ <%= form.submit %> +
+<% end %> diff --git a/app/views/turns/_turn.html.erb b/app/views/turns/_turn.html.erb new file mode 100644 index 0000000..7ddb9aa --- /dev/null +++ b/app/views/turns/_turn.html.erb @@ -0,0 +1,39 @@ +
+
+ Number: + <%= turn.number %> +
+ +
+ Phase: + <%= turn.phase %> +
+ +
+ Game state: +
<%= JSON.pretty_generate(turn.game_state) if turn.game_state %>
+
+ +
+ Possible orders: +
<%= JSON.pretty_generate(turn.possible_orders) if turn.possible_orders %>
+
+ +
+ Orders: +
<%= JSON.pretty_generate(turn.orders) if turn.orders %>
+
+ +
+ Map View: +
+ <%= turn.svg_date.html_safe if turn.svg_date %> +
+
+ +
+ Game ID: + <%= turn.game_id %> +
+ +
diff --git a/app/views/turns/_turn.json.jbuilder b/app/views/turns/_turn.json.jbuilder new file mode 100644 index 0000000..7e107c4 --- /dev/null +++ b/app/views/turns/_turn.json.jbuilder @@ -0,0 +1,2 @@ +json.extract! turn, :id, :number, :phase, :game_state, :possible_orders, :orders, :svg_date, :game_id, :created_at, :updated_at +json.url turn_url(turn, format: :json) diff --git a/app/views/turns/edit.html.erb b/app/views/turns/edit.html.erb new file mode 100644 index 0000000..d78d1b9 --- /dev/null +++ b/app/views/turns/edit.html.erb @@ -0,0 +1,12 @@ +<% content_for :title, "Editing turn" %> + +

Editing turn

+ +<%= render "form", turn: @turn %> + +
+ +
+ <%= link_to "Show this turn", @turn %> | + <%= link_to "Back to turns", turns_path %> +
diff --git a/app/views/turns/index.html.erb b/app/views/turns/index.html.erb new file mode 100644 index 0000000..904996a --- /dev/null +++ b/app/views/turns/index.html.erb @@ -0,0 +1,19 @@ +

<%= notice %>

+ +<% content_for :title, "Turns" %> + +

Turns

+ +
+ <% @turns.each do |turn| %> + <%= turn.id %> + <%= turn.game_id %> + <%= turn.number %> + <%= turn.phase %> +

+ <%= link_to "Show this turn", turn %> +

+ <% end %> +
+ +<%= link_to "New turn", new_turn_path %> diff --git a/app/views/turns/index.json.jbuilder b/app/views/turns/index.json.jbuilder new file mode 100644 index 0000000..90bcdea --- /dev/null +++ b/app/views/turns/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @turns, partial: "turns/turn", as: :turn diff --git a/app/views/turns/new.html.erb b/app/views/turns/new.html.erb new file mode 100644 index 0000000..72c4003 --- /dev/null +++ b/app/views/turns/new.html.erb @@ -0,0 +1,11 @@ +<% content_for :title, "New turn" %> + +

New turn

+ +<%= render "form", turn: @turn %> + +
+ +
+ <%= link_to "Back to turns", turns_path %> +
diff --git a/app/views/turns/show.html.erb b/app/views/turns/show.html.erb new file mode 100644 index 0000000..86c0bb8 --- /dev/null +++ b/app/views/turns/show.html.erb @@ -0,0 +1,42 @@ +
+
+
+

Turn <%= @turn.number %> Details

+ <%= link_to 'Back to Game', game_path(@turn.game), class: "mt-2 inline-block text-indigo-600 hover:text-indigo-900" %> +
+
+ <%= link_to 'Edit', edit_turn_path(@turn), class: "inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %> + <%= button_to "Destroy this turn", @turn, method: :delete, class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500", data: { turbo_confirm: "Are you sure?" } %> +
+
+ +
+
+

SVG Orders Data

+

Stored SVG images for each power.

+
+
+
+ <% if @turn.svg_orders.present? %> + <% @turn.svg_orders.each do |key, svg| %> + <%= key %> + <%= svg.html_safe %> + <% end %> + <% else %> +
+

No SVG orders data found.

+
+ <% end %> +
+
+
+ +
+
+

Debug Info

+
+
+
<%= JSON.pretty_generate(@turn.attributes.except("svg_orders", "svg_date")) %>
+
+
+
diff --git a/app/views/turns/show.json.jbuilder b/app/views/turns/show.json.jbuilder new file mode 100644 index 0000000..2c21a6d --- /dev/null +++ b/app/views/turns/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "turns/turn", turn: @turn diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb new file mode 100644 index 0000000..7e4f21e --- /dev/null +++ b/app/views/users/edit.html.erb @@ -0,0 +1,55 @@ +<% content_for :title, "ユーザー編集" %> + +
+
+

ユーザー編集

+

<%= @user.username %> の情報を編集

+
+ + <%= form_with model: @user, class: "space-y-6" do |f| %> + <% if @user.errors.any? %> + + <% end %> + +
+ <%= f.label :username, "ユーザー名", class: "block text-sm font-medium text-gray-700" %> + <%= f.text_field :username, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" %> +
+ +
+ <%= f.label :email, "メールアドレス", class: "block text-sm font-medium text-gray-700" %> + <%= f.email_field :email, class: "mt-1 block w-full rounded-md border-gray-300 bg-gray-100 shadow-sm", disabled: true %> +

メールアドレスは変更できません

+
+ +
+

パスワード変更

+

パスワードを変更する場合のみ入力してください。空欄の場合は変更されません。

+ +
+
+ <%= f.label :password, "新しいパスワード", class: "block text-sm font-medium text-gray-700" %> + <%= f.password_field :password, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500", autocomplete: "new-password" %> +

6文字以上

+
+ +
+ <%= f.label :password_confirmation, "新しいパスワード(確認)", class: "block text-sm font-medium text-gray-700" %> + <%= f.password_field :password_confirmation, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500", autocomplete: "new-password" %> +
+
+
+ +
+ <%= link_to "キャンセル", user_path(@user), class: "inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50" %> + <%= f.submit "更新", class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %> +
+ <% end %> +
diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb new file mode 100644 index 0000000..82cb034 --- /dev/null +++ b/app/views/users/index.html.erb @@ -0,0 +1,82 @@ +<% content_for :title, "ユーザー管理" %> + +
+

ユーザー管理

+

登録されているユーザーの一覧と管理

+
+ +
+ + + + + + + + + + + + <% @users.each do |user| %> + + + + + + + + <% end %> + +
+ ユーザー名 + + メールアドレス + + 権限 + + 登録日 + + 操作 +
+
+
+ <%= user.username %> +
+
+
+
<%= user.email %>
+
+ <% if user.admin? %> + + 管理者 + + <% else %> + + 一般ユーザー + + <% end %> + + <%= user.created_at.strftime("%Y年%m月%d日") %> + + <%= link_to "詳細", user_path(user), class: "text-indigo-600 hover:text-indigo-900" %> + <%= link_to "編集", edit_user_path(user), class: "text-blue-600 hover:text-blue-900" %> + <% if user != current_user %> + <%= button_to "#{user.admin? ? '管理者解除' : '管理者に昇格'}", + toggle_admin_user_path(user), + method: :patch, + class: "inline text-yellow-600 hover:text-yellow-900", + data: { confirm: "#{user.admin? ? '管理者権限を削除' : '管理者権限を付与'}しますか?" } %> + <%= button_to "削除", + user_path(user), + method: :delete, + class: "inline text-red-600 hover:text-red-900", + data: { confirm: "#{user.username}を削除しますか?" } %> + <% end %> +
+
+ +
+

+ 合計: <%= @users.count %>ユーザー +

+
diff --git a/app/views/users/new.html.erb b/app/views/users/new.html.erb new file mode 100644 index 0000000..c413ffc --- /dev/null +++ b/app/views/users/new.html.erb @@ -0,0 +1,50 @@ +<% content_for :title, "新規登録" %> + +
+

新規登録

+ + <%= form_with model: @user, url: signup_path, class: "space-y-6" do |f| %> + <% if @user.errors.any? %> + + <% end %> + +
+ <%= f.label :username, "ユーザー名", class: "block text-sm font-medium text-gray-700" %> + <%= f.text_field :username, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500", autofocus: true %> +
+ +
+ <%= f.label :email, "メールアドレス", class: "block text-sm font-medium text-gray-700" %> + <%= f.email_field :email, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" %> +
+ +
+ <%= f.label :password, "パスワード", class: "block text-sm font-medium text-gray-700" %> + <%= f.password_field :password, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" %> +

6文字以上

+
+ +
+ <%= f.label :password_confirmation, "パスワード(確認)", class: "block text-sm font-medium text-gray-700" %> + <%= f.password_field :password_confirmation, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" %> +
+ +
+ <%= f.submit "登録", class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %> +
+ <% end %> + +
+

+ 既にアカウントをお持ちですか? + <%= link_to "ログイン", login_path, class: "font-medium text-indigo-600 hover:text-indigo-500" %> +

+
+
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb new file mode 100644 index 0000000..e266674 --- /dev/null +++ b/app/views/users/show.html.erb @@ -0,0 +1,83 @@ +<% content_for :title, @user.username %> + +
+
+

<%= @user.username %>

+ <% if @user.admin? %> + + 管理者 + + <% end %> +
+
+ <%= link_to "編集", edit_user_path(@user), class: "inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50" %> + <%= link_to "一覧に戻る", users_path, class: "inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50" %> +
+
+ +
+
+

+ ユーザー情報 +

+
+
+
+
+
+ ユーザー名 +
+
+ <%= @user.username %> +
+
+
+
+ メールアドレス +
+
+ <%= @user.email %> +
+
+
+
+ 権限 +
+
+ <%= @user.admin? ? "管理者" : "一般ユーザー" %> +
+
+
+
+ 登録日 +
+
+ <%= @user.created_at.strftime("%Y年%m月%d日 %H:%M") %> +
+
+
+
+ 最終更新日 +
+
+ <%= @user.updated_at.strftime("%Y年%m月%d日 %H:%M") %> +
+
+
+
+
+ +<% if @user != current_user %> +
+ <%= button_to "#{@user.admin? ? '管理者権限を削除' : '管理者権限を付与'}", + toggle_admin_user_path(@user), + method: :patch, + class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-yellow-600 hover:bg-yellow-700", + data: { confirm: "#{@user.admin? ? '管理者権限を削除' : '管理者権限を付与'}しますか?" } %> + <%= button_to "ユーザーを削除", + user_path(@user), + method: :delete, + class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700", + data: { confirm: "#{@user.username}を削除しますか?" } %> +
+<% end %> diff --git a/bin/brakeman b/bin/brakeman new file mode 100755 index 0000000..ace1c9b --- /dev/null +++ b/bin/brakeman @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +ARGV.unshift("--ensure-latest") + +load Gem.bin_path("brakeman", "brakeman") diff --git a/bin/bundler-audit b/bin/bundler-audit new file mode 100755 index 0000000..e2ef226 --- /dev/null +++ b/bin/bundler-audit @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "bundler/audit/cli" + +ARGV.concat %w[ --config config/bundler-audit.yml ] if ARGV.empty? || ARGV.include?("check") +Bundler::Audit::CLI.start diff --git a/bin/ci b/bin/ci new file mode 100755 index 0000000..4137ad5 --- /dev/null +++ b/bin/ci @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "active_support/continuous_integration" + +CI = ActiveSupport::ContinuousIntegration +require_relative "../config/ci.rb" diff --git a/bin/dev b/bin/dev new file mode 100755 index 0000000..b8063eb --- /dev/null +++ b/bin/dev @@ -0,0 +1,16 @@ +#!/usr/bin/env sh + +if ! gem list foreman -i --silent; then + echo "Installing foreman..." + gem install foreman +fi + +# Default to port 3000 if not specified +export PORT="${PORT:-3000}" + +# Let the debug gem allow remote connections, +# but avoid loading until `debugger` is called +export RUBY_DEBUG_OPEN="true" +export RUBY_DEBUG_LAZY="true" + +exec bundle exec foreman start -f Procfile.dev "$@" diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint new file mode 100755 index 0000000..ed31659 --- /dev/null +++ b/bin/docker-entrypoint @@ -0,0 +1,8 @@ +#!/bin/bash -e + +# If running the rails server then create or migrate existing database +if [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then + ./bin/rails db:prepare +fi + +exec "${@}" diff --git a/bin/importmap b/bin/importmap new file mode 100755 index 0000000..36502ab --- /dev/null +++ b/bin/importmap @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby + +require_relative "../config/application" +require "importmap/commands" diff --git a/bin/jobs b/bin/jobs new file mode 100755 index 0000000..dcf59f3 --- /dev/null +++ b/bin/jobs @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby + +require_relative "../config/environment" +require "solid_queue/cli" + +SolidQueue::Cli.start(ARGV) diff --git a/bin/kamal b/bin/kamal new file mode 100755 index 0000000..d9ba276 --- /dev/null +++ b/bin/kamal @@ -0,0 +1,16 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'kamal' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("kamal", "kamal") diff --git a/bin/process_logo.rb b/bin/process_logo.rb new file mode 100644 index 0000000..3977f0d --- /dev/null +++ b/bin/process_logo.rb @@ -0,0 +1,12 @@ + +require "mini_magick" + +input_path = Rails.root.join("app/assets/images/header-logo-original.png") +output_path = Rails.root.join("app/assets/images/header-logo.png") + +image = MiniMagick::Image.open(input_path) +image.format "png" +image.transparent "white" +image.write output_path + +puts "Created transparent logo at #{output_path}" diff --git a/bin/rails b/bin/rails new file mode 100755 index 0000000..efc0377 --- /dev/null +++ b/bin/rails @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +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/rubocop b/bin/rubocop new file mode 100755 index 0000000..5a20504 --- /dev/null +++ b/bin/rubocop @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +# Explicit RuboCop config increases performance slightly while avoiding config confusion. +ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) + +load Gem.bin_path("rubocop", "rubocop") diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..81be011 --- /dev/null +++ b/bin/setup @@ -0,0 +1,35 @@ +#!/usr/bin/env ruby +require "fileutils" + +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("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" + system! "bin/rails db:reset" if ARGV.include?("--reset") + + puts "\n== Removing old logs and tempfiles ==" + system! "bin/rails log:clear tmp:clear" + + unless ARGV.include?("--skip-server") + puts "\n== Starting development server ==" + STDOUT.flush # flush the output before exec(2) so that it displays + exec "bin/dev" + end +end diff --git a/bin/test b/bin/test new file mode 100755 index 0000000..59ecbd0 --- /dev/null +++ b/bin/test @@ -0,0 +1,16 @@ +#!/usr/bin/env sh + +if ! gem list foreman -i --silent; then + echo "Installing foreman..." + gem install foreman +fi + +# Default to port 3000 if not specified +export PORT="${PORT:-3000}" + +# Let the debug gem allow remote connections, +# but avoid loading until `debugger` is called +#export RUBY_DEBUG_OPEN="true" +#export RUBY_DEBUG_LAZY="true" + +exec bundle exec foreman start -f Procfile.dev "$@" diff --git a/bin/thrust b/bin/thrust new file mode 100755 index 0000000..36bde2d --- /dev/null +++ b/bin/thrust @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("thruster", "thrust") diff --git a/check_all_turns.rb b/check_all_turns.rb new file mode 100644 index 0000000..ac7ddf5 --- /dev/null +++ b/check_all_turns.rb @@ -0,0 +1,15 @@ +Turn.order(:number).each do |t| + gs = t.game_state + puts "Turn ##{t.number} (ID: #{t.id}): Class=#{gs.class}, Sample=#{gs.inspect[0..100]}..." +end + +puts "\n--- Fresh API Call (Initial State) ---" +client = GameApiClient.new +initial = client.api_game_initial_state +if initial + puts "Initial State Class: #{initial.class}" + puts "Initial State Keys: #{initial.keys}" + gs_inner = initial['game_state'] + puts "Inner game_state Class: #{gs_inner.class}" + puts "Inner game_state Sample: #{gs_inner.inspect[0..100]}" if gs_inner +end diff --git a/check_svg.rb b/check_svg.rb new file mode 100644 index 0000000..13bf16c --- /dev/null +++ b/check_svg.rb @@ -0,0 +1,20 @@ +turn = Turn.last +puts "Turn ID: #{turn.id}" +puts "Possible Orders Keys: #{turn.possible_orders&.keys}" +if turn.possible_orders && turn.possible_orders["possible_orders"] + puts "Possible Orders Content Keys: #{turn.possible_orders["possible_orders"].keys}" + puts "First Order Sample: #{turn.possible_orders["possible_orders"].values.first}" +else + puts "Possible Orders Content: #{turn.possible_orders}" +end +puts "SVG Orders Keys: #{turn.svg_orders&.keys}" +puts "SVG Orders NONE Size: #{turn.svg_orders['NONE']&.size}" if turn.svg_orders +puts "SVG Orders ALL Size: #{turn.svg_orders['ALL']&.size}" if turn.svg_orders + +puts "--- API Client Test ---" +client = GameApiClient.new +orders = { "AUSTRIA" => { "BUD" => "A BUD - TRI" } } +puts "Testing api_render with orders: #{orders}" +svg = client.api_render(turn.game_state, orders: orders) +puts "SVG Result Size: #{svg&.size}" +puts "SVG Result (First 100 chars): #{svg&.slice(0, 100)}" diff --git a/check_units.rb b/check_units.rb new file mode 100644 index 0000000..10ceb46 --- /dev/null +++ b/check_units.rb @@ -0,0 +1,36 @@ +def get_units_from_gs(gs) + return {} if gs.nil? + # powers[power_name]['units'] に入っていると想定 + units = {} + if gs['powers'].is_a?(Hash) + gs['powers'].each do |power, data| + units[power] = data['units'] + end + end + units +end + +t1 = Turn.find_by(number: 1) +t2 = Turn.find_by(number: 2) + +puts "--- Turn 1 (SPRING 1901 MOVEMENT) Units ---" +u1 = get_units_from_gs(t1&.game_state) +puts u1.inspect + +puts "\n--- Turn 2 (FALL 1901 MOVEMENT) Units ---" +if t2 + u2 = get_units_from_gs(t2.game_state) + puts u2.inspect + + puts "\nDifference detected?" + if u1 == u2 + puts "NO CHANGE in unit positions!" + else + puts "Unit positions HAVE CHANGED." + end + + puts "\nTurn 1 Orders stored in DB:" + puts t1.orders.inspect +else + puts "Turn 2 not found." +end diff --git a/cleanup_data.rb b/cleanup_data.rb new file mode 100644 index 0000000..23ee5e0 --- /dev/null +++ b/cleanup_data.rb @@ -0,0 +1,10 @@ +turn1 = Turn.find_by(number: 1) +if turn1 && turn1.game_state.is_a?(Hash) && turn1.game_state.has_key?('game_state') + puts "Normalizing Turn 1 (ID: #{turn1.id})..." + turn1.update!(game_state: turn1.game_state['game_state']) + puts "Turn 1 optimized." +end + +# 不整合のある後続ターンを削除 +Turn.where('number > 1').destroy_all +puts "Cleared subsequent turns." diff --git a/compare_turns.rb b/compare_turns.rb new file mode 100644 index 0000000..b498f69 --- /dev/null +++ b/compare_turns.rb @@ -0,0 +1,28 @@ +t1 = Turn.find_by(number: 1) +t2 = Turn.find_by(number: 2) + +def summarize_gs(gs) + return "nil" if gs.nil? + gs = JSON.parse(gs) if gs.is_a?(String) + # APIレスポンス全体の可能性も考慮 + gs = gs['game_state'] if gs.is_a?(Hash) && gs.has_key?('game_state') + + { + phase: gs['phase'], + units_count: gs['units']&.values&.flatten&.size, + units_sample: gs['units']&.values&.first(2), + centers_count: gs['centers']&.values&.flatten&.size + } +end + +puts "--- Turn 1 Summary ---" +puts summarize_gs(t1&.game_state) + +puts "\n--- Turn 2 Summary ---" +if t2 + puts summarize_gs(t2.game_state) + puts "\nPossible Orders for AUSTRIA in Turn 2:" + puts (t2.possible_orders&.dig("possible_orders", "AUSTRIA") || "Not found").inspect[0..500] +else + puts "Turn 2 not found." +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..948fbfd --- /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 DipFront + class Application < Rails::Application + # Initialize configuration defaults for originally generated Rails version. + config.load_defaults 8.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/bundler-audit.yml b/config/bundler-audit.yml new file mode 100644 index 0000000..e74b3af --- /dev/null +++ b/config/bundler-audit.yml @@ -0,0 +1,5 @@ +# Audit all gems listed in the Gemfile for known security problems by running bin/bundler-audit. +# CVEs that are not relevant to the application can be enumerated on the ignore list below. + +ignore: + - CVE-THAT-DOES-NOT-APPLY diff --git a/config/cable.yml b/config/cable.yml new file mode 100644 index 0000000..b9adc5a --- /dev/null +++ b/config/cable.yml @@ -0,0 +1,17 @@ +# Async adapter only works within the same process, so for manually triggering cable updates from a console, +# and seeing results in the browser, you must do so from the web console (running inside the dev process), +# not a terminal started via bin/rails console! Add "console" to any action or any ERB template view +# to make the web console appear. +development: + adapter: async + +test: + adapter: test + +production: + adapter: solid_cable + connects_to: + database: + writing: cable + polling_interval: 0.1.seconds + message_retention: 1.day diff --git a/config/cache.yml b/config/cache.yml new file mode 100644 index 0000000..19d4908 --- /dev/null +++ b/config/cache.yml @@ -0,0 +1,16 @@ +default: &default + store_options: + # Cap age of oldest cache entry to fulfill retention policies + # max_age: <%= 60.days.to_i %> + max_size: <%= 256.megabytes %> + namespace: <%= Rails.env %> + +development: + <<: *default + +test: + <<: *default + +production: + database: cache + <<: *default diff --git a/config/ci.rb b/config/ci.rb new file mode 100644 index 0000000..1712cc1 --- /dev/null +++ b/config/ci.rb @@ -0,0 +1,24 @@ +# Run using bin/ci + +CI.run do + step "Setup", "bin/setup --skip-server" + + step "Style: Ruby", "bin/rubocop" + + step "Security: Gem audit", "bin/bundler-audit" + step "Security: Importmap vulnerability audit", "bin/importmap audit" + step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error" + step "Tests: Rails", "bin/rails test" + step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant" + + # Optional: Run system tests + # step "Tests: System", "bin/rails test:system" + + # Optional: set a green GitHub commit status to unblock PR merge. + # Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`. + # if success? + # step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff" + # else + # failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again." + # end +end diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc new file mode 100644 index 0000000..a4a3fcc --- /dev/null +++ b/config/credentials.yml.enc @@ -0,0 +1 @@ +7mDalwYZdLi7w0m1MvGVhygx/O0YiNIiiVA+tj/LBukGJInb/cy6jHV7xg1oGIhzzQ1nl0sUbuMHtitmdkb6QWOUiSPAjkpiqfx5sbnE+5Q7U5CPjg0szp7CrNbBhL4ojibKYRPI5Js78x6eBSr9L1vmoWirFZS/ar3B2TwGQ0yNPxghxN7kQ0pEVoiFZUmuXmErih/nTUy5patG1zPPGsXUiAkMGYXztn6n+cIahX6lgFpV79HzI/c01VeMcOV/pZRs/RrQrzfTlnSX2UQlsHqLTQyXx4O6yDUNF8Mii7g6N57jyd26Osi5OIUAsBkrZOhfSSXmd3ceBlt+oSlMXtKaT+qfg+vywqI026eDlsKyEYbCyIaXp9kn+8VMaJcCI+qSGlewfxmxFKVgq7MyNDBrL1VM51UVO00s6cxXWUZ2W96t1SnLyVwcq/NIuhN530imvLvE6cAOAJJKgCfY1gEmUCZ3kuEMO64OSL5Ynm2wxyMF48cCJrxP--rN5KnyzLPaDv/sop--ouscdd1e738zVM6LolPIQA== \ No newline at end of file diff --git a/config/database.yml b/config/database.yml new file mode 100644 index 0000000..302d638 --- /dev/null +++ b/config/database.yml @@ -0,0 +1,40 @@ +# 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 + max_connections: <%= 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 + +# Store production database in the storage/ directory, which by default +# is mounted as a persistent Docker volume in config/deploy.yml. +production: + primary: + <<: *default + database: storage/production.sqlite3 + cache: + <<: *default + database: storage/production_cache.sqlite3 + migrations_paths: db/cache_migrate + queue: + <<: *default + database: storage/production_queue.sqlite3 + migrations_paths: db/queue_migrate + cable: + <<: *default + database: storage/production_cable.sqlite3 + migrations_paths: db/cable_migrate diff --git a/config/deploy.yml b/config/deploy.yml new file mode 100644 index 0000000..b08be3f --- /dev/null +++ b/config/deploy.yml @@ -0,0 +1,119 @@ +# Name of your application. Used to uniquely configure containers. +service: dip_front + +# Name of the container image (use your-user/app-name on external registries). +image: dip_front + +# Deploy to these servers. +servers: + web: + - 192.168.0.1 + # job: + # hosts: + # - 192.168.0.1 + # cmd: bin/jobs + +# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server. +# If used with Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption. +# +# Using an SSL proxy like this requires turning on config.assume_ssl and config.force_ssl in production.rb! +# +# Don't use this when deploying to multiple web servers (then you have to terminate SSL at your load balancer). +# +# proxy: +# ssl: true +# host: app.example.com + +# Where you keep your container images. +registry: + # Alternatives: hub.docker.com / registry.digitalocean.com / ghcr.io / ... + server: localhost:5555 + + # Needed for authenticated registries. + # username: your-user + + # Always use an access token rather than real password when possible. + # password: + # - KAMAL_REGISTRY_PASSWORD + +# Inject ENV variables into containers (secrets come from .kamal/secrets). +env: + secret: + - RAILS_MASTER_KEY + clear: + # Run the Solid Queue Supervisor inside the web server's Puma process to do jobs. + # When you start using multiple servers, you should split out job processing to a dedicated machine. + SOLID_QUEUE_IN_PUMA: true + + # Set number of processes dedicated to Solid Queue (default: 1) + # JOB_CONCURRENCY: 3 + + # Set number of cores available to the application on each server (default: 1). + # WEB_CONCURRENCY: 2 + + # Match this to any external database server to configure Active Record correctly + # Use dip_front-db for a db accessory server on same machine via local kamal docker network. + # DB_HOST: 192.168.0.2 + + # Log everything from Rails + # RAILS_LOG_LEVEL: debug + +# Aliases are triggered with "bin/kamal ". You can overwrite arguments on invocation: +# "bin/kamal logs -r job" will tail logs from the first server in the job section. +aliases: + console: app exec --interactive --reuse "bin/rails console" + shell: app exec --interactive --reuse "bash" + logs: app logs -f + dbc: app exec --interactive --reuse "bin/rails dbconsole --include-password" + +# Use a persistent storage volume for sqlite database files and local Active Storage files. +# Recommended to change this to a mounted volume path that is backed up off server. +volumes: + - "dip_front_storage:/rails/storage" + +# Bridge fingerprinted assets, like JS and CSS, between versions to avoid +# hitting 404 on in-flight requests. Combines all files from new and old +# version inside the asset_path. +asset_path: /rails/public/assets + +# Configure the image builder. +builder: + arch: amd64 + + # # Build image via remote server (useful for faster amd64 builds on arm64 computers) + # remote: ssh://docker@docker-builder-server + # + # # Pass arguments and secrets to the Docker build process + # args: + # RUBY_VERSION: 4.0.1 + # secrets: + # - GITHUB_TOKEN + # - RAILS_MASTER_KEY + +# Use a different ssh user than root +# ssh: +# user: app + +# Use accessory services (secrets come from .kamal/secrets). +# accessories: +# db: +# image: mysql:8.0 +# host: 192.168.0.2 +# # Change to 3306 to expose port to the world instead of just local network. +# port: "127.0.0.1:3306:3306" +# env: +# clear: +# MYSQL_ROOT_HOST: '%' +# secret: +# - MYSQL_ROOT_PASSWORD +# files: +# - config/mysql/production.cnf:/etc/mysql/my.cnf +# - db/production.sql:/docker-entrypoint-initdb.d/setup.sql +# directories: +# - data:/var/lib/mysql +# redis: +# image: valkey/valkey:8 +# host: 192.168.0.2 +# port: 6379 +# directories: +# - data:/data 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..75243c3 --- /dev/null +++ b/config/environments/development.rb @@ -0,0 +1,78 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Make code changes take effect immediately without server restart. + 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 Action Controller caching. By default Action Controller caching is disabled. + # Run rails dev:cache to toggle Action Controller 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.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" } + else + config.action_controller.perform_caching = false + end + + # Change to :null_store to avoid any caching. + config.cache_store = :memory_store + + # 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 + + # Make template changes take effect immediately. + config.action_mailer.perform_caching = false + + # Set localhost to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "localhost", port: 3000 } + + # Print deprecation notices to the Rails logger. + config.active_support.deprecation = :log + + # 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 + + # Append comments with runtime information tags to SQL queries in logs. + config.active_record.query_log_tags_enabled = true + + # Highlight code that enqueued background job in logs. + config.active_job.verbose_enqueue_logs = true + + # Highlight code that triggered redirect in logs. + config.action_dispatch.verbose_redirect_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 + + # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. + # config.generators.apply_rubocop_autocorrect_after_generate! +end diff --git a/config/environments/production.rb b/config/environments/production.rb new file mode 100644 index 0000000..f5763e0 --- /dev/null +++ b/config/environments/production.rb @@ -0,0 +1,90 @@ +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 for better performance and memory savings (ignored by Rake tasks). + config.eager_load = true + + # Full error reports are disabled. + config.consider_all_requests_local = false + + # Turn on fragment caching in view templates. + config.action_controller.perform_caching = true + + # Cache assets for far-future expiry since they are all digest stamped. + config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } + + # Enable serving of images, stylesheets, and JavaScripts from an asset server. + # config.asset_host = "http://assets.example.com" + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Assume all access to the app is happening through a SSL-terminating reverse proxy. + # config.assume_ssl = true + + # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. + # config.force_ssl = true + + # Skip http-to-https redirect for the default health check endpoint. + # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } + + # Log to STDOUT with the current request id as a default log tag. + config.log_tags = [ :request_id ] + config.logger = ActiveSupport::TaggedLogging.logger(STDOUT) + + # Change to "debug" to log everything (including potentially personally-identifiable information!). + config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") + + # Prevent health checks from clogging up the logs. + config.silence_healthcheck_path = "/up" + + # Don't log any deprecations. + config.active_support.report_deprecations = false + + # Replace the default in-process memory cache store with a durable alternative. + config.cache_store = :solid_cache_store + + # Replace the default in-process and non-durable queuing backend for Active Job. + config.active_job.queue_adapter = :solid_queue + config.solid_queue.connects_to = { database: { writing: :queue } } + + # 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 + + # Set host to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "example.com" } + + # Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit. + # config.action_mailer.smtp_settings = { + # user_name: Rails.application.credentials.dig(:smtp, :user_name), + # password: Rails.application.credentials.dig(:smtp, :password), + # address: "smtp.example.com", + # port: 587, + # authentication: :plain + # } + + # 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 + + # Do not dump schema after migrations. + config.active_record.dump_schema_after_migration = false + + # Only use :id for inspections in production. + config.active_record.attributes_for_inspect = [ :id ] + + # 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..c2095b1 --- /dev/null +++ b/config/environments/test.rb @@ -0,0 +1,53 @@ +# 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.headers = { "cache-control" => "public, max-age=3600" } + + # Show full error reports. + config.consider_all_requests_local = true + 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 + + # 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 + + # Set host to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "example.com" } + + # Print deprecation notices to the stderr. + config.active_support.deprecation = :stderr + + # 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/importmap.rb b/config/importmap.rb new file mode 100644 index 0000000..909dfc5 --- /dev/null +++ b/config/importmap.rb @@ -0,0 +1,7 @@ +# Pin npm packages by running ./bin/importmap + +pin "application" +pin "@hotwired/turbo-rails", to: "turbo.min.js" +pin "@hotwired/stimulus", to: "stimulus.min.js" +pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" +pin_all_from "app/javascript/controllers", under: "controllers" diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb new file mode 100644 index 0000000..4873244 --- /dev/null +++ b/config/initializers/assets.rb @@ -0,0 +1,7 @@ +# 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 diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb new file mode 100644 index 0000000..d51d713 --- /dev/null +++ b/config/initializers/content_security_policy.rb @@ -0,0 +1,29 @@ +# 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) +# +# # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag` +# # if the corresponding directives are specified in `content_security_policy_nonce_directives`. +# # config.content_security_policy_nonce_auto = true +# +# # 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..c0b717f --- /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, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc +] 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/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..38c4b86 --- /dev/null +++ b/config/puma.rb @@ -0,0 +1,42 @@ +# 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 starts a configurable number of processes (workers) and each process +# serves each request in a thread from an internal thread pool. +# +# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You +# should only set this value when you want to run 2 or more workers. The +# default is already 1. You can set it to `auto` to automatically start a worker +# for each available processor. +# +# The ideal number of threads per worker depends both on how much time the +# application spends waiting for IO operations and on how much you wish to +# prioritize throughput over latency. +# +# As a rule of thumb, increasing the number of threads will increase how much +# traffic a given process can handle (throughput), but due to CRuby's +# Global VM Lock (GVL) it has diminishing returns and will degrade the +# response time (latency) of the application. +# +# The default is set to 3 threads as it's deemed a decent compromise between +# throughput and latency for the average Rails application. +# +# Any libraries that use a connection pool or another resource pool should +# be configured to provide at least as many connections as the number of +# threads. This includes Active Record's `pool` parameter in `database.yml`. +threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) +threads threads_count, threads_count + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +port ENV.fetch("PORT", 3000) + +# Allow puma to be restarted by `bin/rails restart` command. +plugin :tmp_restart + +# Run the Solid Queue supervisor inside of Puma for single-server deployments. +plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"] + +# Specify the PID file. Defaults to tmp/pids/server.pid in development. +# In other environments, only set the PID file if requested. +pidfile ENV["PIDFILE"] if ENV["PIDFILE"] diff --git a/config/queue.yml b/config/queue.yml new file mode 100644 index 0000000..9eace59 --- /dev/null +++ b/config/queue.yml @@ -0,0 +1,18 @@ +default: &default + dispatchers: + - polling_interval: 1 + batch_size: 500 + workers: + - queues: "*" + threads: 3 + processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %> + polling_interval: 0.1 + +development: + <<: *default + +test: + <<: *default + +production: + <<: *default diff --git a/config/recurring.yml b/config/recurring.yml new file mode 100644 index 0000000..5cfa5d3 --- /dev/null +++ b/config/recurring.yml @@ -0,0 +1,11 @@ +# Solid Queue Recurring Tasks + +# 自動ターン進行: 1分ごとにデッドラインを過ぎたゲームを確認 +auto_turn_process: + class: AutoTurnProcessJob + schedule: every minute + +production: + clear_solid_queue_finished_jobs: + command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)" + schedule: every hour at minute 12 diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 0000000..2819e17 --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,52 @@ +Rails.application.routes.draw do + resources :turns do + member do + patch :submit_orders + post :process_turn + end + end + + resources :games do + member do + post :join_game + post :start_power_selection + post :start_order_input + post :vote_draw + post :force_draw + get :turn_data + end + + resources :participants, only: [] do + member do + patch :select_power + end + end + end + + # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html + + # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. + # Can be used by load balancers and uptime monitors to verify that the app is live. + get "up" => "rails/health#show", as: :rails_health_check + + # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) + # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest + # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker + + # Defines the root path route ("/") + root "games#index" + + # Authentication routes + get "/signup", to: "users#new" + post "/signup", to: "users#create" + get "/login", to: "sessions#new" + post "/login", to: "sessions#create" + delete "/logout", to: "sessions#destroy" + + # User management routes (admin only) + resources :users, only: [ :index, :show, :edit, :update, :destroy ] do + member do + patch :toggle_admin + end + end +end diff --git a/config/storage.yml b/config/storage.yml new file mode 100644 index 0000000..927dc53 --- /dev/null +++ b/config/storage.yml @@ -0,0 +1,27 @@ +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 %> + +# mirror: +# service: Mirror +# primary: local +# mirrors: [ amazon, google, microsoft ] diff --git a/db/cable_schema.rb b/db/cable_schema.rb new file mode 100644 index 0000000..2366660 --- /dev/null +++ b/db/cable_schema.rb @@ -0,0 +1,11 @@ +ActiveRecord::Schema[7.1].define(version: 1) do + create_table "solid_cable_messages", force: :cascade do |t| + t.binary "channel", limit: 1024, null: false + t.binary "payload", limit: 536870912, null: false + t.datetime "created_at", null: false + t.integer "channel_hash", limit: 8, null: false + t.index ["channel"], name: "index_solid_cable_messages_on_channel" + t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash" + t.index ["created_at"], name: "index_solid_cable_messages_on_created_at" + end +end diff --git a/db/cache_schema.rb b/db/cache_schema.rb new file mode 100644 index 0000000..81a410d --- /dev/null +++ b/db/cache_schema.rb @@ -0,0 +1,12 @@ +ActiveRecord::Schema[7.2].define(version: 1) do + create_table "solid_cache_entries", force: :cascade do |t| + t.binary "key", limit: 1024, null: false + t.binary "value", limit: 536870912, null: false + t.datetime "created_at", null: false + t.integer "key_hash", limit: 8, null: false + t.integer "byte_size", limit: 4, null: false + t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size" + t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size" + t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true + end +end diff --git a/db/migrate/20260206132054_create_games.rb b/db/migrate/20260206132054_create_games.rb new file mode 100644 index 0000000..ddabc05 --- /dev/null +++ b/db/migrate/20260206132054_create_games.rb @@ -0,0 +1,11 @@ +class CreateGames < ActiveRecord::Migration[8.1] + def change + create_table :games do |t| + t.string :title + t.integer :participants_count + t.text :memo + + t.timestamps + end + end +end diff --git a/db/migrate/20260206132822_create_turns.rb b/db/migrate/20260206132822_create_turns.rb new file mode 100644 index 0000000..57d934f --- /dev/null +++ b/db/migrate/20260206132822_create_turns.rb @@ -0,0 +1,13 @@ +class CreateTurns < ActiveRecord::Migration[8.1] + def change + create_table :turns do |t| + t.integer :number + t.string :phase + t.json :game_stat + t.text :svg_date + t.references :game, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/migrate/20260207140954_rename_game_stat_to_game_state_in_turns.rb b/db/migrate/20260207140954_rename_game_stat_to_game_state_in_turns.rb new file mode 100644 index 0000000..3458a14 --- /dev/null +++ b/db/migrate/20260207140954_rename_game_stat_to_game_state_in_turns.rb @@ -0,0 +1,5 @@ +class RenameGameStatToGameStateInTurns < ActiveRecord::Migration[8.1] + def change + rename_column :turns, :game_stat, :game_state + end +end diff --git a/db/migrate/20260207142532_add_orders_to_turns.rb b/db/migrate/20260207142532_add_orders_to_turns.rb new file mode 100644 index 0000000..8827512 --- /dev/null +++ b/db/migrate/20260207142532_add_orders_to_turns.rb @@ -0,0 +1,6 @@ +class AddOrdersToTurns < ActiveRecord::Migration[8.1] + def change + add_column :turns, :possible_orders, :json + add_column :turns, :orders, :json + end +end diff --git a/db/migrate/20260209003749_create_users.rb b/db/migrate/20260209003749_create_users.rb new file mode 100644 index 0000000..636324f --- /dev/null +++ b/db/migrate/20260209003749_create_users.rb @@ -0,0 +1,13 @@ +class CreateUsers < ActiveRecord::Migration[8.1] + def change + create_table :users do |t| + t.string :username, null: false + t.string :email, null: false + t.string :password_digest, null: false + t.boolean :admin, default: false, null: false + + t.timestamps + end + add_index :users, :email, unique: true + end +end diff --git a/db/migrate/20260209014947_add_svg_orders_to_turns.rb b/db/migrate/20260209014947_add_svg_orders_to_turns.rb new file mode 100644 index 0000000..9ef6bce --- /dev/null +++ b/db/migrate/20260209014947_add_svg_orders_to_turns.rb @@ -0,0 +1,5 @@ +class AddSvgOrdersToTurns < ActiveRecord::Migration[8.1] + def change + add_column :turns, :svg_orders, :json + end +end diff --git a/db/migrate/20260211013256_create_participants.rb b/db/migrate/20260211013256_create_participants.rb new file mode 100644 index 0000000..4026943 --- /dev/null +++ b/db/migrate/20260211013256_create_participants.rb @@ -0,0 +1,16 @@ +class CreateParticipants < ActiveRecord::Migration[8.1] + def change + create_table :participants do |t| + t.references :user, null: false, foreign_key: true + t.references :game, null: false, foreign_key: true + t.string :power, null: true + t.boolean :orders_submitted, default: false, null: false + t.boolean :is_administrator, default: false, null: false + + t.timestamps + end + + add_index :participants, [:user_id, :game_id], unique: true + add_index :participants, [:game_id, :power], unique: true, where: "power IS NOT NULL" + end +end diff --git a/db/migrate/20260211013321_add_multiplayer_fields_to_games.rb b/db/migrate/20260211013321_add_multiplayer_fields_to_games.rb new file mode 100644 index 0000000..44554f3 --- /dev/null +++ b/db/migrate/20260211013321_add_multiplayer_fields_to_games.rb @@ -0,0 +1,10 @@ +class AddMultiplayerFieldsToGames < ActiveRecord::Migration[8.1] + def change + add_column :games, :status, :string, default: 'recruiting', null: false + add_column :games, :password_digest, :string, null: true + add_column :games, :auto_order_mode, :string, default: 'hold', null: false + add_column :games, :is_solo_mode, :boolean, default: false, null: false + + add_index :games, :status + end +end diff --git a/db/migrate/20260212061627_add_draw_votes_to_turns.rb b/db/migrate/20260212061627_add_draw_votes_to_turns.rb new file mode 100644 index 0000000..b31603f --- /dev/null +++ b/db/migrate/20260212061627_add_draw_votes_to_turns.rb @@ -0,0 +1,5 @@ +class AddDrawVotesToTurns < ActiveRecord::Migration[8.1] + def change + add_column :turns, :draw_votes, :json + end +end diff --git a/db/migrate/20260213052612_add_house_rules_to_games.rb b/db/migrate/20260213052612_add_house_rules_to_games.rb new file mode 100644 index 0000000..4d2af92 --- /dev/null +++ b/db/migrate/20260213052612_add_house_rules_to_games.rb @@ -0,0 +1,7 @@ +class AddHouseRulesToGames < ActiveRecord::Migration[8.1] + def change + add_column :games, :year_limit, :integer, null: true + add_column :games, :victory_sc_count, :integer, default: 18, null: false + add_column :games, :scoring_system, :string, default: 'none', null: false + end +end diff --git a/db/migrate/20260213071204_add_turn_schedule_to_games.rb b/db/migrate/20260213071204_add_turn_schedule_to_games.rb new file mode 100644 index 0000000..f1383a6 --- /dev/null +++ b/db/migrate/20260213071204_add_turn_schedule_to_games.rb @@ -0,0 +1,6 @@ +class AddTurnScheduleToGames < ActiveRecord::Migration[8.1] + def change + add_column :games, :turn_schedule, :string + add_column :games, :next_deadline_at, :datetime + end +end diff --git a/db/migrate/20260213122531_create_solid_queue_tables.rb b/db/migrate/20260213122531_create_solid_queue_tables.rb new file mode 100644 index 0000000..60c8b80 --- /dev/null +++ b/db/migrate/20260213122531_create_solid_queue_tables.rb @@ -0,0 +1,131 @@ +class CreateSolidQueueTables < ActiveRecord::Migration[7.1] + def change + create_table "solid_queue_blocked_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.string "concurrency_key", null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release" + t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance" + t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true + end + + create_table "solid_queue_claimed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.bigint "process_id" + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true + t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" + end + + create_table "solid_queue_failed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.text "error" + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true + end + + create_table "solid_queue_jobs", force: :cascade do |t| + t.string "queue_name", null: false + t.string "class_name", null: false + t.text "arguments" + t.integer "priority", default: 0, null: false + t.string "active_job_id" + t.datetime "scheduled_at" + t.datetime "finished_at" + t.string "concurrency_key" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id" + t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name" + t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at" + t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering" + t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting" + end + + create_table "solid_queue_pauses", force: :cascade do |t| + t.string "queue_name", null: false + t.datetime "created_at", null: false + t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true + end + + create_table "solid_queue_processes", force: :cascade do |t| + t.string "kind", null: false + t.datetime "last_heartbeat_at", null: false + t.bigint "supervisor_id" + t.integer "pid", null: false + t.string "hostname" + t.text "metadata" + t.datetime "created_at", null: false + t.string "name", null: false + t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at" + t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true + t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id" + end + + create_table "solid_queue_ready_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true + t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all" + t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue" + end + + create_table "solid_queue_recurring_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "task_key", null: false + t.datetime "run_at", null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true + t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true + end + + create_table "solid_queue_recurring_tasks", force: :cascade do |t| + t.string "key", null: false + t.string "schedule", null: false + t.string "command", limit: 2048 + t.string "class_name" + t.text "arguments" + t.string "queue_name" + t.integer "priority", default: 0 + t.boolean "static", default: true, null: false + t.text "description" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true + t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static" + end + + create_table "solid_queue_scheduled_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "scheduled_at", null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true + t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all" + end + + create_table "solid_queue_semaphores", force: :cascade do |t| + t.string "key", null: false + t.integer "value", default: 1, null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at" + t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value" + t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true + end + + add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + end +end diff --git a/db/queue_schema.rb b/db/queue_schema.rb new file mode 100644 index 0000000..85194b6 --- /dev/null +++ b/db/queue_schema.rb @@ -0,0 +1,129 @@ +ActiveRecord::Schema[7.1].define(version: 1) do + create_table "solid_queue_blocked_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.string "concurrency_key", null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release" + t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance" + t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true + end + + create_table "solid_queue_claimed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.bigint "process_id" + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true + t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" + end + + create_table "solid_queue_failed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.text "error" + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true + end + + create_table "solid_queue_jobs", force: :cascade do |t| + t.string "queue_name", null: false + t.string "class_name", null: false + t.text "arguments" + t.integer "priority", default: 0, null: false + t.string "active_job_id" + t.datetime "scheduled_at" + t.datetime "finished_at" + t.string "concurrency_key" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id" + t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name" + t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at" + t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering" + t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting" + end + + create_table "solid_queue_pauses", force: :cascade do |t| + t.string "queue_name", null: false + t.datetime "created_at", null: false + t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true + end + + create_table "solid_queue_processes", force: :cascade do |t| + t.string "kind", null: false + t.datetime "last_heartbeat_at", null: false + t.bigint "supervisor_id" + t.integer "pid", null: false + t.string "hostname" + t.text "metadata" + t.datetime "created_at", null: false + t.string "name", null: false + t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at" + t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true + t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id" + end + + create_table "solid_queue_ready_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true + t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all" + t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue" + end + + create_table "solid_queue_recurring_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "task_key", null: false + t.datetime "run_at", null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true + t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true + end + + create_table "solid_queue_recurring_tasks", force: :cascade do |t| + t.string "key", null: false + t.string "schedule", null: false + t.string "command", limit: 2048 + t.string "class_name" + t.text "arguments" + t.string "queue_name" + t.integer "priority", default: 0 + t.boolean "static", default: true, null: false + t.text "description" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true + t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static" + end + + create_table "solid_queue_scheduled_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "scheduled_at", null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true + t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all" + end + + create_table "solid_queue_semaphores", force: :cascade do |t| + t.string "key", null: false + t.integer "value", default: 1, null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at" + t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value" + t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true + end + + add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..413988c --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,201 @@ +# 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[8.1].define(version: 2026_02_13_122531) do + create_table "games", force: :cascade do |t| + t.string "auto_order_mode", default: "hold", null: false + t.datetime "created_at", null: false + t.boolean "is_solo_mode", default: false, null: false + t.text "memo" + t.datetime "next_deadline_at" + t.integer "participants_count" + t.string "password_digest" + t.string "scoring_system" + t.string "status", default: "recruiting", null: false + t.string "title" + t.string "turn_schedule" + t.datetime "updated_at", null: false + t.integer "victory_sc_count" + t.integer "year_limit" + t.index ["status"], name: "index_games_on_status" + end + + create_table "participants", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "game_id", null: false + t.boolean "is_administrator", default: false, null: false + t.boolean "orders_submitted", default: false, null: false + t.string "power" + t.datetime "updated_at", null: false + t.integer "user_id", null: false + t.index ["game_id", "power"], name: "index_participants_on_game_id_and_power", unique: true, where: "power IS NOT NULL" + t.index ["game_id"], name: "index_participants_on_game_id" + t.index ["user_id", "game_id"], name: "index_participants_on_user_id_and_game_id", unique: true + t.index ["user_id"], name: "index_participants_on_user_id" + end + + create_table "solid_queue_blocked_executions", force: :cascade do |t| + t.string "concurrency_key", null: false + t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.bigint "job_id", null: false + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.index ["concurrency_key", "priority", "job_id"], name: "index_solid_queue_blocked_executions_for_release" + t.index ["expires_at", "concurrency_key"], name: "index_solid_queue_blocked_executions_for_maintenance" + t.index ["job_id"], name: "index_solid_queue_blocked_executions_on_job_id", unique: true + end + + create_table "solid_queue_claimed_executions", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "job_id", null: false + t.bigint "process_id" + t.index ["job_id"], name: "index_solid_queue_claimed_executions_on_job_id", unique: true + t.index ["process_id", "job_id"], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" + end + + create_table "solid_queue_failed_executions", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "error" + t.bigint "job_id", null: false + t.index ["job_id"], name: "index_solid_queue_failed_executions_on_job_id", unique: true + end + + create_table "solid_queue_jobs", force: :cascade do |t| + t.string "active_job_id" + t.text "arguments" + t.string "class_name", null: false + t.string "concurrency_key" + t.datetime "created_at", null: false + t.datetime "finished_at" + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.datetime "scheduled_at" + t.datetime "updated_at", null: false + t.index ["active_job_id"], name: "index_solid_queue_jobs_on_active_job_id" + t.index ["class_name"], name: "index_solid_queue_jobs_on_class_name" + t.index ["finished_at"], name: "index_solid_queue_jobs_on_finished_at" + t.index ["queue_name", "finished_at"], name: "index_solid_queue_jobs_for_filtering" + t.index ["scheduled_at", "finished_at"], name: "index_solid_queue_jobs_for_alerting" + end + + create_table "solid_queue_pauses", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "queue_name", null: false + t.index ["queue_name"], name: "index_solid_queue_pauses_on_queue_name", unique: true + end + + create_table "solid_queue_processes", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "hostname" + t.string "kind", null: false + t.datetime "last_heartbeat_at", null: false + t.text "metadata" + t.string "name", null: false + t.integer "pid", null: false + t.bigint "supervisor_id" + t.index ["last_heartbeat_at"], name: "index_solid_queue_processes_on_last_heartbeat_at" + t.index ["name", "supervisor_id"], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true + t.index ["supervisor_id"], name: "index_solid_queue_processes_on_supervisor_id" + end + + create_table "solid_queue_ready_executions", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "job_id", null: false + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.index ["job_id"], name: "index_solid_queue_ready_executions_on_job_id", unique: true + t.index ["priority", "job_id"], name: "index_solid_queue_poll_all" + t.index ["queue_name", "priority", "job_id"], name: "index_solid_queue_poll_by_queue" + end + + create_table "solid_queue_recurring_executions", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "job_id", null: false + t.datetime "run_at", null: false + t.string "task_key", null: false + t.index ["job_id"], name: "index_solid_queue_recurring_executions_on_job_id", unique: true + t.index ["task_key", "run_at"], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true + end + + create_table "solid_queue_recurring_tasks", force: :cascade do |t| + t.text "arguments" + t.string "class_name" + t.string "command", limit: 2048 + t.datetime "created_at", null: false + t.text "description" + t.string "key", null: false + t.integer "priority", default: 0 + t.string "queue_name" + t.string "schedule", null: false + t.boolean "static", default: true, null: false + t.datetime "updated_at", null: false + t.index ["key"], name: "index_solid_queue_recurring_tasks_on_key", unique: true + t.index ["static"], name: "index_solid_queue_recurring_tasks_on_static" + end + + create_table "solid_queue_scheduled_executions", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "job_id", null: false + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.datetime "scheduled_at", null: false + t.index ["job_id"], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true + t.index ["scheduled_at", "priority", "job_id"], name: "index_solid_queue_dispatch_all" + end + + create_table "solid_queue_semaphores", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.string "key", null: false + t.datetime "updated_at", null: false + t.integer "value", default: 1, null: false + t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at" + t.index ["key", "value"], name: "index_solid_queue_semaphores_on_key_and_value" + t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true + end + + create_table "turns", force: :cascade do |t| + t.datetime "created_at", null: false + t.json "draw_votes" + t.integer "game_id", null: false + t.json "game_state" + t.integer "number" + t.json "orders" + t.string "phase" + t.json "possible_orders" + t.text "svg_date" + t.json "svg_orders" + t.datetime "updated_at", null: false + t.index ["game_id"], name: "index_turns_on_game_id" + end + + create_table "users", force: :cascade do |t| + t.boolean "admin", default: false, null: false + t.datetime "created_at", null: false + t.string "email", null: false + t.string "password_digest", null: false + t.datetime "updated_at", null: false + t.string "username", null: false + t.index ["email"], name: "index_users_on_email", unique: true + end + + add_foreign_key "participants", "games" + add_foreign_key "participants", "users" + add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "turns", "games" +end diff --git a/db/seeds.rb b/db/seeds.rb new file mode 100644 index 0000000..0b65a02 --- /dev/null +++ b/db/seeds.rb @@ -0,0 +1,15 @@ +# 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 +admin = User.find_or_create_by(email: "kontei2000@gmail.com") do |u| + u.username = "kontei2000" + u.password = "Type3TPX" + u.password_confirmation = "Type3TPX" + u.admin = true +end \ No newline at end of file diff --git a/db_turn2_game_state.json b/db_turn2_game_state.json new file mode 100644 index 0000000..33ebed6 --- /dev/null +++ b/db_turn2_game_state.json @@ -0,0 +1,559 @@ +{ + "controlled_powers": null, + "daide_port": null, + "deadline": 0, + "error": [], + "game_id": "7C1wAzXuixF1rAjX", + "map_name": "standard", + "message_history": { + "S1901M": [] + }, + "messages": [], + "meta_rules": [], + "n_controls": 0, + "no_rules": [], + "note": "", + "observer_level": null, + "order_history": { + "S1901M": { + "AUSTRIA": [ + "A BUD - VIE", + "F TRI - ADR", + "A VIE - TRI" + ], + "ENGLAND": [ + "F EDI - NTH", + "F LON - ENG", + "A LVP - EDI" + ], + "FRANCE": [ + "F BRE - PIC", + "A MAR - PIE", + "A PAR - BUR" + ], + "GERMANY": [], + "ITALY": [], + "RUSSIA": [], + "TURKEY": [] + } + }, + "outcome": [], + "phase": "FALL 1901 MOVEMENT", + "phase_abbr": "", + "powers": { + "AUSTRIA": { + "abbrev": "A", + "adjust": [], + "centers": [ + "BUD", + "TRI", + "VIE" + ], + "civil_disorder": 0, + "controller": { + "1770643888550880": "dummy" + }, + "homes": [ + "BUD", + "TRI", + "VIE" + ], + "influence": [ + "BUD", + "VIE", + "TRI", + "ADR" + ], + "name": "AUSTRIA", + "order_is_set": 0, + "orders": {}, + "retreats": {}, + "role": "server_type", + "tokens": [], + "units": [ + "A VIE", + "A TRI", + "F ADR" + ], + "vote": "neutral", + "wait": true + }, + "ENGLAND": { + "abbrev": "E", + "adjust": [], + "centers": [ + "EDI", + "LON", + "LVP" + ], + "civil_disorder": 0, + "controller": { + "1770643888550920": "dummy" + }, + "homes": [ + "EDI", + "LON", + "LVP" + ], + "influence": [ + "LON", + "LVP", + "NTH", + "ENG", + "EDI" + ], + "name": "ENGLAND", + "order_is_set": 0, + "orders": {}, + "retreats": {}, + "role": "server_type", + "tokens": [], + "units": [ + "F NTH", + "F ENG", + "A EDI" + ], + "vote": "neutral", + "wait": true + }, + "FRANCE": { + "abbrev": "F", + "adjust": [], + "centers": [ + "BRE", + "MAR", + "PAR" + ], + "civil_disorder": 0, + "controller": { + "1770643888550958": "dummy" + }, + "homes": [ + "BRE", + "MAR", + "PAR" + ], + "influence": [ + "BRE", + "MAR", + "PAR", + "PIC", + "PIE", + "BUR" + ], + "name": "FRANCE", + "order_is_set": 0, + "orders": {}, + "retreats": {}, + "role": "server_type", + "tokens": [], + "units": [ + "F PIC", + "A PIE", + "A BUR" + ], + "vote": "neutral", + "wait": true + }, + "GERMANY": { + "abbrev": "G", + "adjust": [], + "centers": [ + "BER", + "KIE", + "MUN" + ], + "civil_disorder": 0, + "controller": { + "1770643888550994": "dummy" + }, + "homes": [ + "BER", + "KIE", + "MUN" + ], + "influence": [ + "KIE", + "BER", + "MUN" + ], + "name": "GERMANY", + "order_is_set": 0, + "orders": {}, + "retreats": {}, + "role": "server_type", + "tokens": [], + "units": [ + "F KIE", + "A BER", + "A MUN" + ], + "vote": "neutral", + "wait": true + }, + "ITALY": { + "abbrev": "I", + "adjust": [], + "centers": [ + "NAP", + "ROM", + "VEN" + ], + "civil_disorder": 0, + "controller": { + "1770643888551030": "dummy" + }, + "homes": [ + "NAP", + "ROM", + "VEN" + ], + "influence": [ + "NAP", + "ROM", + "VEN" + ], + "name": "ITALY", + "order_is_set": 0, + "orders": {}, + "retreats": {}, + "role": "server_type", + "tokens": [], + "units": [ + "F NAP", + "A ROM", + "A VEN" + ], + "vote": "neutral", + "wait": true + }, + "RUSSIA": { + "abbrev": "R", + "adjust": [], + "centers": [ + "MOS", + "SEV", + "STP", + "WAR" + ], + "civil_disorder": 0, + "controller": { + "1770643888551067": "dummy" + }, + "homes": [ + "MOS", + "SEV", + "STP", + "WAR" + ], + "influence": [ + "WAR", + "MOS", + "SEV", + "STP" + ], + "name": "RUSSIA", + "order_is_set": 0, + "orders": {}, + "retreats": {}, + "role": "server_type", + "tokens": [], + "units": [ + "A WAR", + "A MOS", + "F SEV", + "F STP/SC" + ], + "vote": "neutral", + "wait": true + }, + "TURKEY": { + "abbrev": "T", + "adjust": [], + "centers": [ + "ANK", + "CON", + "SMY" + ], + "civil_disorder": 0, + "controller": { + "1770643888551105": "dummy" + }, + "homes": [ + "ANK", + "CON", + "SMY" + ], + "influence": [ + "ANK", + "CON", + "SMY" + ], + "name": "TURKEY", + "order_is_set": 0, + "orders": {}, + "retreats": {}, + "role": "server_type", + "tokens": [], + "units": [ + "F ANK", + "A CON", + "A SMY" + ], + "vote": "neutral", + "wait": true + } + }, + "registration_password": null, + "result_history": { + "S1901M": { + "A BUD": [], + "A VIE": [], + "F TRI": [], + "F EDI": [], + "F LON": [], + "A LVP": [], + "F BRE": [], + "A MAR": [], + "A PAR": [], + "F KIE": [], + "A BER": [], + "A MUN": [], + "F NAP": [], + "A ROM": [], + "A VEN": [], + "A WAR": [], + "A MOS": [], + "F SEV": [], + "F STP/SC": [], + "F ANK": [], + "A CON": [], + "A SMY": [] + } + }, + "role": "server_type", + "rules": [ + "NO_DEADLINE", + "CD_DUMMIES", + "ALWAYS_WAIT", + "SOLITAIRE", + "NO_PRESS", + "IGNORE_ERRORS", + "POWER_CHOICE" + ], + "state_history": { + "S1901M": { + "timestamp": 1770643888552821, + "zobrist_hash": "1919110489198082658", + "note": "", + "name": "S1901M", + "units": { + "AUSTRIA": [ + "A BUD", + "A VIE", + "F TRI" + ], + "ENGLAND": [ + "F EDI", + "F LON", + "A LVP" + ], + "FRANCE": [ + "F BRE", + "A MAR", + "A PAR" + ], + "GERMANY": [ + "F KIE", + "A BER", + "A MUN" + ], + "ITALY": [ + "F NAP", + "A ROM", + "A VEN" + ], + "RUSSIA": [ + "A WAR", + "A MOS", + "F SEV", + "F STP/SC" + ], + "TURKEY": [ + "F ANK", + "A CON", + "A SMY" + ] + }, + "retreats": { + "AUSTRIA": {}, + "ENGLAND": {}, + "FRANCE": {}, + "GERMANY": {}, + "ITALY": {}, + "RUSSIA": {}, + "TURKEY": {} + }, + "centers": { + "AUSTRIA": [ + "BUD", + "TRI", + "VIE" + ], + "ENGLAND": [ + "EDI", + "LON", + "LVP" + ], + "FRANCE": [ + "BRE", + "MAR", + "PAR" + ], + "GERMANY": [ + "BER", + "KIE", + "MUN" + ], + "ITALY": [ + "NAP", + "ROM", + "VEN" + ], + "RUSSIA": [ + "MOS", + "SEV", + "STP", + "WAR" + ], + "TURKEY": [ + "ANK", + "CON", + "SMY" + ] + }, + "homes": { + "AUSTRIA": [ + "BUD", + "TRI", + "VIE" + ], + "ENGLAND": [ + "EDI", + "LON", + "LVP" + ], + "FRANCE": [ + "BRE", + "MAR", + "PAR" + ], + "GERMANY": [ + "BER", + "KIE", + "MUN" + ], + "ITALY": [ + "NAP", + "ROM", + "VEN" + ], + "RUSSIA": [ + "MOS", + "SEV", + "STP", + "WAR" + ], + "TURKEY": [ + "ANK", + "CON", + "SMY" + ] + }, + "influence": { + "AUSTRIA": [ + "BUD", + "VIE", + "TRI" + ], + "ENGLAND": [ + "EDI", + "LON", + "LVP" + ], + "FRANCE": [ + "BRE", + "MAR", + "PAR" + ], + "GERMANY": [ + "KIE", + "BER", + "MUN" + ], + "ITALY": [ + "NAP", + "ROM", + "VEN" + ], + "RUSSIA": [ + "WAR", + "MOS", + "SEV", + "STP" + ], + "TURKEY": [ + "ANK", + "CON", + "SMY" + ] + }, + "civil_disorder": { + "AUSTRIA": 0, + "ENGLAND": 0, + "FRANCE": 0, + "GERMANY": 0, + "ITALY": 0, + "RUSSIA": 0, + "TURKEY": 0 + }, + "builds": { + "AUSTRIA": { + "count": 0, + "homes": [] + }, + "ENGLAND": { + "count": 0, + "homes": [] + }, + "FRANCE": { + "count": 0, + "homes": [] + }, + "GERMANY": { + "count": 0, + "homes": [] + }, + "ITALY": { + "count": 0, + "homes": [] + }, + "RUSSIA": { + "count": 0, + "homes": [] + }, + "TURKEY": { + "count": 0, + "homes": [] + } + } + } + }, + "status": "forming", + "timestamp_created": 1770643888550799, + "victory": [ + 18 + ], + "win": 18, + "zobrist_hash": 2619432138363037717 +} \ No newline at end of file diff --git a/debug_turn.rb b/debug_turn.rb new file mode 100644 index 0000000..043478a --- /dev/null +++ b/debug_turn.rb @@ -0,0 +1,7 @@ +turn = Turn.last +puts "Turn ID: #{turn.id}" +puts "Possible Orders Keys: #{turn.possible_orders&.keys}" +puts "Possible Orders['possible_orders'] Keys: #{turn.possible_orders&.dig('possible_orders')&.keys}" +puts "Turn#powers: #{turn.powers}" +puts "Turn#draw_votes: #{turn.draw_votes}" +puts "Turn#unanimous_draw?: #{turn.unanimous_draw?}" diff --git a/dev_output.log b/dev_output.log new file mode 100644 index 0000000..a2a1259 --- /dev/null +++ b/dev_output.log @@ -0,0 +1,27 @@ +23:24:13 web.1 | started with pid 81314 +23:24:13 css.1 | started with pid 81315 +23:24:13 web.1 | => Booting Puma +23:24:13 web.1 | => Rails 8.1.2 application starting in development +23:24:13 web.1 | => Run `bin/rails server --help` for more startup options +23:24:13 web.1 | A server is already running (pid: 80439, file: /home/kontei/Data/Dev/ruby/dip_front/tmp/pids/server.pid). +23:24:13 web.1 | Exiting +23:24:13 css.1 | ≈ tailwindcss v4.1.18 +23:24:13 css.1 | +23:24:13 css.1 | sh: 行 1: watchman: command not found +23:24:13 css.1 | Done in 67ms +23:24:13 css.1 | Done in 2ms +23:24:13 css.1 | Done in 2ms +23:24:13 css.1 | Done in 2ms +23:24:14 css.1 | Done in 2ms +23:24:14 css.1 | Done in 2ms +23:24:14 web.1 | exited with code 1 +23:24:14 system | sending SIGTERM to all processes +23:24:14 css.1 | Done in 2ms +23:24:14 css.1 | bin/rails aborted! +23:24:14 css.1 | SignalException: SIGTERM (SignalException) +23:24:14 css.1 | +23:24:14 css.1 | Tasks: TOP => tailwindcss:watch +23:24:14 css.1 | (See full trace by running task with --trace) +23:24:14 css.1 | Done in 2ms +23:24:14 css.1 | exited with code 1 +23:24:14 | Done in 2ms diff --git a/dump_api_res.rb b/dump_api_res.rb new file mode 100644 index 0000000..bc7fc2e --- /dev/null +++ b/dump_api_res.rb @@ -0,0 +1,21 @@ +client = GameApiClient.new +turn1 = Turn.find_by(number: 1) + +if turn1 + puts "Calling api_calculate_process..." + res = client.api_calculate_process(turn1.game_state, turn1.orders || {}) + + if res + File.write('api_process_response.json', JSON.pretty_generate(res)) + puts "Saved full response to api_process_response.json" + + # また、現在のTurn 2のgame_stateの中身も確認 + t2 = Turn.find_by(number: 2) + if t2 + File.write('db_turn2_game_state.json', JSON.pretty_generate(t2.game_state)) + puts "Saved Turn 2 game_state from DB to db_turn2_game_state.json" + end + else + puts "Process failed." + end +end diff --git a/e b/e new file mode 100644 index 0000000..e69de29 diff --git a/game_state_only.json b/game_state_only.json new file mode 100644 index 0000000..60584cd --- /dev/null +++ b/game_state_only.json @@ -0,0 +1,297 @@ +{ + "controlled_powers": null, + "daide_port": null, + "deadline": 0, + "error": [], + "game_id": "HkpwyOnXcvYeIvfl", + "map_name": "standard", + "message_history": {}, + "messages": [], + "meta_rules": [], + "n_controls": 0, + "no_rules": [], + "note": "", + "observer_level": null, + "order_history": {}, + "outcome": [], + "phase": "SPRING 1901 MOVEMENT", + "phase_abbr": "", + "powers": { + "AUSTRIA": { + "abbrev": "A", + "adjust": [], + "centers": [ + "BUD", + "TRI", + "VIE" + ], + "civil_disorder": 0, + "controller": { + "1770421548023314": "dummy" + }, + "homes": [ + "BUD", + "TRI", + "VIE" + ], + "influence": [ + "BUD", + "VIE", + "TRI" + ], + "name": "AUSTRIA", + "order_is_set": 0, + "orders": {}, + "retreats": {}, + "role": "server_type", + "tokens": [], + "units": [ + "A BUD", + "A VIE", + "F TRI" + ], + "vote": "neutral", + "wait": true + }, + "ENGLAND": { + "abbrev": "E", + "adjust": [], + "centers": [ + "EDI", + "LON", + "LVP" + ], + "civil_disorder": 0, + "controller": { + "1770421548023354": "dummy" + }, + "homes": [ + "EDI", + "LON", + "LVP" + ], + "influence": [ + "EDI", + "LON", + "LVP" + ], + "name": "ENGLAND", + "order_is_set": 0, + "orders": {}, + "retreats": {}, + "role": "server_type", + "tokens": [], + "units": [ + "F EDI", + "F LON", + "A LVP" + ], + "vote": "neutral", + "wait": true + }, + "FRANCE": { + "abbrev": "F", + "adjust": [], + "centers": [ + "BRE", + "MAR", + "PAR" + ], + "civil_disorder": 0, + "controller": { + "1770421548023391": "dummy" + }, + "homes": [ + "BRE", + "MAR", + "PAR" + ], + "influence": [ + "BRE", + "MAR", + "PAR" + ], + "name": "FRANCE", + "order_is_set": 0, + "orders": {}, + "retreats": {}, + "role": "server_type", + "tokens": [], + "units": [ + "F BRE", + "A MAR", + "A PAR" + ], + "vote": "neutral", + "wait": true + }, + "GERMANY": { + "abbrev": "G", + "adjust": [], + "centers": [ + "BER", + "KIE", + "MUN" + ], + "civil_disorder": 0, + "controller": { + "1770421548023427": "dummy" + }, + "homes": [ + "BER", + "KIE", + "MUN" + ], + "influence": [ + "KIE", + "BER", + "MUN" + ], + "name": "GERMANY", + "order_is_set": 0, + "orders": {}, + "retreats": {}, + "role": "server_type", + "tokens": [], + "units": [ + "F KIE", + "A BER", + "A MUN" + ], + "vote": "neutral", + "wait": true + }, + "ITALY": { + "abbrev": "I", + "adjust": [], + "centers": [ + "NAP", + "ROM", + "VEN" + ], + "civil_disorder": 0, + "controller": { + "1770421548023462": "dummy" + }, + "homes": [ + "NAP", + "ROM", + "VEN" + ], + "influence": [ + "NAP", + "ROM", + "VEN" + ], + "name": "ITALY", + "order_is_set": 0, + "orders": {}, + "retreats": {}, + "role": "server_type", + "tokens": [], + "units": [ + "F NAP", + "A ROM", + "A VEN" + ], + "vote": "neutral", + "wait": true + }, + "RUSSIA": { + "abbrev": "R", + "adjust": [], + "centers": [ + "MOS", + "SEV", + "STP", + "WAR" + ], + "civil_disorder": 0, + "controller": { + "1770421548023497": "dummy" + }, + "homes": [ + "MOS", + "SEV", + "STP", + "WAR" + ], + "influence": [ + "WAR", + "MOS", + "SEV", + "STP" + ], + "name": "RUSSIA", + "order_is_set": 0, + "orders": {}, + "retreats": {}, + "role": "server_type", + "tokens": [], + "units": [ + "A WAR", + "A MOS", + "F SEV", + "F STP/SC" + ], + "vote": "neutral", + "wait": true + }, + "TURKEY": { + "abbrev": "T", + "adjust": [], + "centers": [ + "ANK", + "CON", + "SMY" + ], + "civil_disorder": 0, + "controller": { + "1770421548023532": "dummy" + }, + "homes": [ + "ANK", + "CON", + "SMY" + ], + "influence": [ + "ANK", + "CON", + "SMY" + ], + "name": "TURKEY", + "order_is_set": 0, + "orders": {}, + "retreats": {}, + "role": "server_type", + "tokens": [], + "units": [ + "F ANK", + "A CON", + "A SMY" + ], + "vote": "neutral", + "wait": true + } + }, + "registration_password": null, + "result_history": {}, + "role": "server_type", + "rules": [ + "NO_DEADLINE", + "CD_DUMMIES", + "ALWAYS_WAIT", + "SOLITAIRE", + "NO_PRESS", + "IGNORE_ERRORS", + "POWER_CHOICE" + ], + "state_history": {}, + "status": "forming", + "timestamp_created": 1770421548023232, + "victory": [ + 18 + ], + "win": 18, + "zobrist_hash": 1919110489198082658 +} diff --git a/initial_state.json b/initial_state.json new file mode 100644 index 0000000..7390829 --- /dev/null +++ b/initial_state.json @@ -0,0 +1 @@ +{"game_state":{"controlled_powers":null,"daide_port":null,"deadline":0,"error":[],"game_id":"HkpwyOnXcvYeIvfl","map_name":"standard","message_history":{},"messages":[],"meta_rules":[],"n_controls":0,"no_rules":[],"note":"","observer_level":null,"order_history":{},"outcome":[],"phase":"SPRING 1901 MOVEMENT","phase_abbr":"","powers":{"AUSTRIA":{"abbrev":"A","adjust":[],"centers":["BUD","TRI","VIE"],"civil_disorder":0,"controller":{"1770421548023314":"dummy"},"homes":["BUD","TRI","VIE"],"influence":["BUD","VIE","TRI"],"name":"AUSTRIA","order_is_set":0,"orders":{},"retreats":{},"role":"server_type","tokens":[],"units":["A BUD","A VIE","F TRI"],"vote":"neutral","wait":true},"ENGLAND":{"abbrev":"E","adjust":[],"centers":["EDI","LON","LVP"],"civil_disorder":0,"controller":{"1770421548023354":"dummy"},"homes":["EDI","LON","LVP"],"influence":["EDI","LON","LVP"],"name":"ENGLAND","order_is_set":0,"orders":{},"retreats":{},"role":"server_type","tokens":[],"units":["F EDI","F LON","A LVP"],"vote":"neutral","wait":true},"FRANCE":{"abbrev":"F","adjust":[],"centers":["BRE","MAR","PAR"],"civil_disorder":0,"controller":{"1770421548023391":"dummy"},"homes":["BRE","MAR","PAR"],"influence":["BRE","MAR","PAR"],"name":"FRANCE","order_is_set":0,"orders":{},"retreats":{},"role":"server_type","tokens":[],"units":["F BRE","A MAR","A PAR"],"vote":"neutral","wait":true},"GERMANY":{"abbrev":"G","adjust":[],"centers":["BER","KIE","MUN"],"civil_disorder":0,"controller":{"1770421548023427":"dummy"},"homes":["BER","KIE","MUN"],"influence":["KIE","BER","MUN"],"name":"GERMANY","order_is_set":0,"orders":{},"retreats":{},"role":"server_type","tokens":[],"units":["F KIE","A BER","A MUN"],"vote":"neutral","wait":true},"ITALY":{"abbrev":"I","adjust":[],"centers":["NAP","ROM","VEN"],"civil_disorder":0,"controller":{"1770421548023462":"dummy"},"homes":["NAP","ROM","VEN"],"influence":["NAP","ROM","VEN"],"name":"ITALY","order_is_set":0,"orders":{},"retreats":{},"role":"server_type","tokens":[],"units":["F NAP","A ROM","A VEN"],"vote":"neutral","wait":true},"RUSSIA":{"abbrev":"R","adjust":[],"centers":["MOS","SEV","STP","WAR"],"civil_disorder":0,"controller":{"1770421548023497":"dummy"},"homes":["MOS","SEV","STP","WAR"],"influence":["WAR","MOS","SEV","STP"],"name":"RUSSIA","order_is_set":0,"orders":{},"retreats":{},"role":"server_type","tokens":[],"units":["A WAR","A MOS","F SEV","F STP/SC"],"vote":"neutral","wait":true},"TURKEY":{"abbrev":"T","adjust":[],"centers":["ANK","CON","SMY"],"civil_disorder":0,"controller":{"1770421548023532":"dummy"},"homes":["ANK","CON","SMY"],"influence":["ANK","CON","SMY"],"name":"TURKEY","order_is_set":0,"orders":{},"retreats":{},"role":"server_type","tokens":[],"units":["F ANK","A CON","A SMY"],"vote":"neutral","wait":true}},"registration_password":null,"result_history":{},"role":"server_type","rules":["NO_DEADLINE","CD_DUMMIES","ALWAYS_WAIT","SOLITAIRE","NO_PRESS","IGNORE_ERRORS","POWER_CHOICE"],"state_history":{},"status":"forming","timestamp_created":1770421548023232,"victory":[18],"win":18,"zobrist_hash":1919110489198082658}} \ No newline at end of file diff --git a/lib/tasks/.keep b/lib/tasks/.keep new file mode 100644 index 0000000..e69de29 diff --git a/log/.keep b/log/.keep new file mode 100644 index 0000000..e69de29 diff --git a/public/400.html b/public/400.html new file mode 100644 index 0000000..640de03 --- /dev/null +++ b/public/400.html @@ -0,0 +1,135 @@ + + + + + + + The server cannot process the request due to a client error (400 Bad Request) + + + + + + + + + + + + + +
+
+ +
+
+

The server cannot process the request due to a client error. Please check the request and try again. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/404.html b/public/404.html new file mode 100644 index 0000000..d7f0f14 --- /dev/null +++ b/public/404.html @@ -0,0 +1,135 @@ + + + + + + + The page you were looking for doesn't exist (404 Not found) + + + + + + + + + + + + + +
+
+ +
+
+

The page you were looking for doesn't exist. You may have mistyped the address or the page may have moved. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/406-unsupported-browser.html b/public/406-unsupported-browser.html new file mode 100644 index 0000000..43d2811 --- /dev/null +++ b/public/406-unsupported-browser.html @@ -0,0 +1,135 @@ + + + + + + + Your browser is not supported (406 Not Acceptable) + + + + + + + + + + + + + +
+
+ +
+
+

Your browser is not supported.
Please upgrade your browser to continue.

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

The change you wanted was rejected. Maybe you tried to change something you didn't have access to. If you're 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..e4eb18a --- /dev/null +++ b/public/500.html @@ -0,0 +1,135 @@ + + + + + + + We're sorry, but something went wrong (500 Internal Server Error) + + + + + + + + + + + + + +
+
+ +
+
+

We're sorry, but something went wrong.
If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 0000000..c4c9dbf Binary files /dev/null and b/public/icon.png differ diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..04b34bf --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,3 @@ + + + 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/reproduce_issue.rb b/reproduce_issue.rb new file mode 100644 index 0000000..ef197d9 --- /dev/null +++ b/reproduce_issue.rb @@ -0,0 +1,38 @@ +require_relative 'app/services/game_api_client' +require 'pp' + +client = GameApiClient.new +# Get initial state +puts "Fetching initial state..." +initial_state_full = client.api_game_initial_state +if initial_state_full.nil? + puts "Failed to fetch initial state" + exit +end + +game_state = initial_state_full["game_state"] +puts "Game state fetched. Game ID: #{game_state['game_id']}" + +# Test possible orders +puts "\nTesting api_calculate_possible_orders(game_state)..." +begin + orders = client.api_calculate_possible_orders(game_state) + if orders + puts "Success! Number of locations with orders: #{orders['possible_orders']&.keys&.size}" + else + puts "Failed! Returned nil." + end +rescue ArgumentError => e + puts "Caught ArgumentError: #{e.message}" +rescue => e + puts "Caught unexpected error: #{e.class} - #{e.message}" +end + +# Test with 2 arguments +puts "\nTesting api_calculate_possible_orders(game_state, 'FRANCE')..." +orders = client.api_calculate_possible_orders(game_state, 'FRANCE') +if orders + puts "Success! Power: #{orders['power']}" +else + puts "Failed! Returned nil." +end diff --git a/request.json b/request.json new file mode 100644 index 0000000..08086b5 --- /dev/null +++ b/request.json @@ -0,0 +1,299 @@ +{"game_state": +{ + "controlled_powers": null, + "daide_port": null, + "deadline": 0, + "error": [], + "game_id": "HkpwyOnXcvYeIvfl", + "map_name": "standard", + "message_history": {}, + "messages": [], + "meta_rules": [], + "n_controls": 0, + "no_rules": [], + "note": "", + "observer_level": null, + "order_history": {}, + "outcome": [], + "phase": "SPRING 1901 MOVEMENT", + "phase_abbr": "", + "powers": { + "AUSTRIA": { + "abbrev": "A", + "adjust": [], + "centers": [ + "BUD", + "TRI", + "VIE" + ], + "civil_disorder": 0, + "controller": { + "1770421548023314": "dummy" + }, + "homes": [ + "BUD", + "TRI", + "VIE" + ], + "influence": [ + "BUD", + "VIE", + "TRI" + ], + "name": "AUSTRIA", + "order_is_set": 0, + "orders": {}, + "retreats": {}, + "role": "server_type", + "tokens": [], + "units": [ + "A BUD", + "A VIE", + "F TRI" + ], + "vote": "neutral", + "wait": true + }, + "ENGLAND": { + "abbrev": "E", + "adjust": [], + "centers": [ + "EDI", + "LON", + "LVP" + ], + "civil_disorder": 0, + "controller": { + "1770421548023354": "dummy" + }, + "homes": [ + "EDI", + "LON", + "LVP" + ], + "influence": [ + "EDI", + "LON", + "LVP" + ], + "name": "ENGLAND", + "order_is_set": 0, + "orders": {}, + "retreats": {}, + "role": "server_type", + "tokens": [], + "units": [ + "F EDI", + "F LON", + "A LVP" + ], + "vote": "neutral", + "wait": true + }, + "FRANCE": { + "abbrev": "F", + "adjust": [], + "centers": [ + "BRE", + "MAR", + "PAR" + ], + "civil_disorder": 0, + "controller": { + "1770421548023391": "dummy" + }, + "homes": [ + "BRE", + "MAR", + "PAR" + ], + "influence": [ + "BRE", + "MAR", + "PAR" + ], + "name": "FRANCE", + "order_is_set": 0, + "orders": {}, + "retreats": {}, + "role": "server_type", + "tokens": [], + "units": [ + "F BRE", + "A MAR", + "A PAR" + ], + "vote": "neutral", + "wait": true + }, + "GERMANY": { + "abbrev": "G", + "adjust": [], + "centers": [ + "BER", + "KIE", + "MUN" + ], + "civil_disorder": 0, + "controller": { + "1770421548023427": "dummy" + }, + "homes": [ + "BER", + "KIE", + "MUN" + ], + "influence": [ + "KIE", + "BER", + "MUN" + ], + "name": "GERMANY", + "order_is_set": 0, + "orders": {}, + "retreats": {}, + "role": "server_type", + "tokens": [], + "units": [ + "F KIE", + "A BER", + "A MUN" + ], + "vote": "neutral", + "wait": true + }, + "ITALY": { + "abbrev": "I", + "adjust": [], + "centers": [ + "NAP", + "ROM", + "VEN" + ], + "civil_disorder": 0, + "controller": { + "1770421548023462": "dummy" + }, + "homes": [ + "NAP", + "ROM", + "VEN" + ], + "influence": [ + "NAP", + "ROM", + "VEN" + ], + "name": "ITALY", + "order_is_set": 0, + "orders": {}, + "retreats": {}, + "role": "server_type", + "tokens": [], + "units": [ + "F NAP", + "A ROM", + "A VEN" + ], + "vote": "neutral", + "wait": true + }, + "RUSSIA": { + "abbrev": "R", + "adjust": [], + "centers": [ + "MOS", + "SEV", + "STP", + "WAR" + ], + "civil_disorder": 0, + "controller": { + "1770421548023497": "dummy" + }, + "homes": [ + "MOS", + "SEV", + "STP", + "WAR" + ], + "influence": [ + "WAR", + "MOS", + "SEV", + "STP" + ], + "name": "RUSSIA", + "order_is_set": 0, + "orders": {}, + "retreats": {}, + "role": "server_type", + "tokens": [], + "units": [ + "A WAR", + "A MOS", + "F SEV", + "F STP/SC" + ], + "vote": "neutral", + "wait": true + }, + "TURKEY": { + "abbrev": "T", + "adjust": [], + "centers": [ + "ANK", + "CON", + "SMY" + ], + "civil_disorder": 0, + "controller": { + "1770421548023532": "dummy" + }, + "homes": [ + "ANK", + "CON", + "SMY" + ], + "influence": [ + "ANK", + "CON", + "SMY" + ], + "name": "TURKEY", + "order_is_set": 0, + "orders": {}, + "retreats": {}, + "role": "server_type", + "tokens": [], + "units": [ + "F ANK", + "A CON", + "A SMY" + ], + "vote": "neutral", + "wait": true + } + }, + "registration_password": null, + "result_history": {}, + "role": "server_type", + "rules": [ + "NO_DEADLINE", + "CD_DUMMIES", + "ALWAYS_WAIT", + "SOLITAIRE", + "NO_PRESS", + "IGNORE_ERRORS", + "POWER_CHOICE" + ], + "state_history": {}, + "status": "forming", + "timestamp_created": 1770421548023232, + "victory": [ + 18 + ], + "win": 18, + "zobrist_hash": 1919110489198082658 +} +} diff --git a/script/.keep b/script/.keep new file mode 100644 index 0000000..e69de29 diff --git a/storage/.keep b/storage/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/controllers/.keep b/test/controllers/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/controllers/games_controller_test.rb b/test/controllers/games_controller_test.rb new file mode 100644 index 0000000..7f0f04a --- /dev/null +++ b/test/controllers/games_controller_test.rb @@ -0,0 +1,48 @@ +require "test_helper" + +class GamesControllerTest < ActionDispatch::IntegrationTest + setup do + @game = games(:one) + end + + test "should get index" do + get games_url + assert_response :success + end + + test "should get new" do + get new_game_url + assert_response :success + end + + test "should create game" do + assert_difference("Game.count") do + post games_url, params: { game: { memo: @game.memo, participants_count: @game.participants_count, title: @game.title } } + end + + assert_redirected_to game_url(Game.last) + end + + test "should show game" do + get game_url(@game) + assert_response :success + end + + test "should get edit" do + get edit_game_url(@game) + assert_response :success + end + + test "should update game" do + patch game_url(@game), params: { game: { memo: @game.memo, participants_count: @game.participants_count, title: @game.title } } + assert_redirected_to game_url(@game) + end + + test "should destroy game" do + assert_difference("Game.count", -1) do + delete game_url(@game) + end + + assert_redirected_to games_url + end +end diff --git a/test/controllers/turns_controller_test.rb b/test/controllers/turns_controller_test.rb new file mode 100644 index 0000000..f520476 --- /dev/null +++ b/test/controllers/turns_controller_test.rb @@ -0,0 +1,48 @@ +require "test_helper" + +class TurnsControllerTest < ActionDispatch::IntegrationTest + setup do + @turn = turns(:one) + end + + test "should get index" do + get turns_url + assert_response :success + end + + test "should get new" do + get new_turn_url + assert_response :success + end + + test "should create turn" do + assert_difference("Turn.count") do + post turns_url, params: { turn: { game_id: @turn.game_id, game_stat: @turn.game_stat, number: @turn.number, phase: @turn.phase, svg_date: @turn.svg_date } } + end + + assert_redirected_to turn_url(Turn.last) + end + + test "should show turn" do + get turn_url(@turn) + assert_response :success + end + + test "should get edit" do + get edit_turn_url(@turn) + assert_response :success + end + + test "should update turn" do + patch turn_url(@turn), params: { turn: { game_id: @turn.game_id, game_stat: @turn.game_stat, number: @turn.number, phase: @turn.phase, svg_date: @turn.svg_date } } + assert_redirected_to turn_url(@turn) + end + + test "should destroy turn" do + assert_difference("Turn.count", -1) do + delete turn_url(@turn) + end + + assert_redirected_to turns_url + end +end diff --git a/test/fixtures/files/.keep b/test/fixtures/files/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/games.yml b/test/fixtures/games.yml new file mode 100644 index 0000000..7f69ebe --- /dev/null +++ b/test/fixtures/games.yml @@ -0,0 +1,11 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + title: MyString + participants_count: 1 + memo: MyText + +two: + title: MyString + participants_count: 1 + memo: MyText diff --git a/test/fixtures/turns.yml b/test/fixtures/turns.yml new file mode 100644 index 0000000..0716a07 --- /dev/null +++ b/test/fixtures/turns.yml @@ -0,0 +1,19 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + number: 1 + phase: MyString + game_state: + possible_orders: + orders: + svg_date: MyText + game: one + +two: + number: 1 + phase: MyString + game_state: + possible_orders: + orders: + svg_date: MyText + game: two diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml new file mode 100644 index 0000000..2dc9d23 --- /dev/null +++ b/test/fixtures/users.yml @@ -0,0 +1,11 @@ +one: + username: UserOne + email: one@example.com + password_digest: <%= BCrypt::Password.create('password') %> + admin: false + +two: + username: UserTwo + email: two@example.com + password_digest: <%= BCrypt::Password.create('password') %> + admin: false diff --git a/test/helpers/.keep b/test/helpers/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/integration/.keep b/test/integration/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/mailers/.keep b/test/mailers/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/models/.keep b/test/models/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/models/game_test.rb b/test/models/game_test.rb new file mode 100644 index 0000000..6628fae --- /dev/null +++ b/test/models/game_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class GameTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/turn_test.rb b/test/models/turn_test.rb new file mode 100644 index 0000000..d01f67e --- /dev/null +++ b/test/models/turn_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class TurnTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/user_test.rb b/test/models/user_test.rb new file mode 100644 index 0000000..5c07f49 --- /dev/null +++ b/test/models/user_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class UserTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/services/game_services_test.rb b/test/services/game_services_test.rb new file mode 100644 index 0000000..4c26d28 --- /dev/null +++ b/test/services/game_services_test.rb @@ -0,0 +1,85 @@ +ENV["RAILS_ENV"] ||= "test" +require_relative "../test/test_helper" + +class RefactoringVerificationTest < ActiveSupport::TestCase + def setup + @game = Game.create!(title: "Refactor Test #{Time.now.to_r}", status: "recruiting", participants_count: 7, is_solo_mode: true) + end + + # Mock Client Class + class MockClient + def initialize(initial_state: {}, possible_orders: {}, render_result: "...", process_result: nil, auto_orders: nil) + @initial_state = initial_state + @possible_orders = possible_orders + @render_result = render_result + @process_result = process_result + @auto_orders = auto_orders + end + + def api_game_initial_state(map_name = "standard") + @initial_state + end + + def api_render(game_state, orders: nil, incl_orders: true, incl_abbrev: true) + @render_result + end + + def api_calculate_possible_orders(game_state, power_name: "", by_power: false) + @possible_orders + end + + def api_calculate_process(game_state, orders) + @process_result + end + + def api_calculate_auto_orders(game_state, power_name) + @auto_orders + end + end + + test "GameSetupService creates initial turn" do + mock_client = MockClient.new( + initial_state: { "game_state" => { "name" => "Spring 1901" } }, + possible_orders: { "FRANCE" => [] } + ) + + service = GameSetupService.new(@game, client: mock_client) + result = service.setup_initial_turn + + assert result[:success], "Service failed: #{result[:message]}" + assert_equal 1, @game.turns.count + assert_equal "Spring 1901", @game.turns.first.phase + end + + test "OrderSubmissionService submits orders" do + turn = @game.turns.create!(number: 1, game_state: { "name" => "S1901" }) + user = User.create!(username: "TestUser#{Time.now.to_r}", email: "test#{Time.now.to_r}@example.com", password: "password") + + mock_client = MockClient.new + + service = OrderSubmissionService.new(turn, user, client: mock_client) + orders = { "A PAR" => "H" } + + result = service.submit(power: "FRANCE", orders: orders) + + assert result[:success] + turn.reload + assert_equal "H", turn.orders["FRANCE"]["A PAR"] + end + + test "TurnProcessingService processes turn" do + turn = @game.turns.create!(number: 1, game_state: { "name" => "S1901" }) + + mock_client = MockClient.new( + process_result: { "game_state" => { "name" => "Fall 1901" } }, + possible_orders: {} + ) + + service = TurnProcessingService.new(turn, client: mock_client) + result = service.process(force: "true") + + assert result[:success], result[:message] + assert_equal 2, @game.turns.count + assert_equal "Fall 1901", @game.turns.last.phase + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..0c22470 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,15 @@ +ENV["RAILS_ENV"] ||= "test" +require_relative "../config/environment" +require "rails/test_help" + +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_api_v2.rb b/test_api_v2.rb new file mode 100644 index 0000000..796cd52 --- /dev/null +++ b/test_api_v2.rb @@ -0,0 +1,20 @@ +client = GameApiClient.new +turn1 = Turn.find_by(number: 1) + +if turn1 + puts "Testing with Turn 1 (ID: #{turn1.id})" + # 1. 現状の渡し方 (Double Nesting?): + puts "Test 1: Passing whole game_state hash" + res1 = client.api_calculate_process(turn1.game_state, turn1.orders || {}) + puts "Response 1 Keys: #{res1&.keys}" + puts "Response 1 Inner Class: #{res1['game_state'].class}" if res1 + + # 2. 内部データのみを渡す場合: + puts "\nTest 2: Passing inner game_state data" + inner_state = turn1.game_state['game_state'] + res2 = client.api_calculate_process(inner_state, turn1.orders || {}) + puts "Response 2 Keys: #{res2&.keys}" + puts "Response 2 Inner Class: #{res2['game_state'].class}" if res2 +else + puts "Turn 1 not found." +end diff --git a/test_api_v3.rb b/test_api_v3.rb new file mode 100644 index 0000000..88dc125 --- /dev/null +++ b/test_api_v3.rb @@ -0,0 +1,35 @@ +client = GameApiClient.new +turn1 = Turn.find_by(number: 1) + +def get_units(gs) + return {} unless gs.is_a?(Hash) + gs = gs['game_state'] if gs.has_key?('game_state') + units = {} + if gs['powers'].is_a?(Hash) + gs['powers'].each do |power, data| + units[power] = data['units'] + end + end + units +end + +if turn1 + puts "Sending process request for Turn 1..." + res = client.api_calculate_process(turn1.game_state, turn1.orders || {}) + + if res + new_gs = res['game_state'] + puts "Response Phase: #{new_gs&.dig('phase')}" + + puts "Units in Response game_state:" + units = get_units(new_gs) + puts units.inspect + + puts "\nSending possible_orders request using this response..." + pos = client.api_calculate_possible_orders(new_gs, by_power: true) + puts "Possible Orders for AUSTRIA:" + puts (pos&.dig('possible_orders', 'AUSTRIA') || "None").inspect[0..500] + else + puts "Process failed." + end +end diff --git a/test_render.rb b/test_render.rb new file mode 100644 index 0000000..4f42468 --- /dev/null +++ b/test_render.rb @@ -0,0 +1,25 @@ +client = GameApiClient.new +turn1 = Turn.find_by(number: 1) + +if turn1 + puts "Processing Turn 1..." + res = client.api_calculate_process(turn1.game_state, turn1.orders || {}) + + if res + new_gs = res['game_state'] + puts "Phase: #{new_gs&.dig('phase')}" + + puts "Rendering new state..." + svg = client.api_render(new_gs, orders: nil) + + if svg + File.write('test_render_turn2.svg', svg) + puts "Saved SVG to test_render_turn2.svg" + puts "SVG Sample: #{svg[0..100]}..." + else + puts "Render failed." + end + else + puts "Process failed." + end +end diff --git a/test_render_turn2.svg b/test_render_turn2.svg new file mode 100644 index 0000000..11d6e3f --- /dev/null +++ b/test_render_turn2.svg @@ -0,0 +1,467 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SWI + ADR + AEG + ALB + ANK + APU + ARM + BAL + BAR + BEL + BER + BLA + BOH + BRE + BUD + BUL + BUR + CLY + CON + DEN + EAS + EDI + ENG + FIN + GAL + GAS + GRE + BOT + LYO + HEL + HOL + ION + IRI + KIE + LON + LVN + LVP + MAR + MAO + MOS + MUN + NAF + NAP + NAO + NTH + NWY + NWG + PAR + PIC + PIE + POR + PRU + ROM + RUH + RUM + SER + SEV + SIL + SKA + SMY + SPA + STP + SWE + SYR + TRI + TUN + TUS + TYR + TYS + UKR + VEN + VIE + WAL + WAR + WES + YOR + + + + + RUS: 4 AUS: 3 ENG: 3 FRA: 3 GER: 3 ITA: 3 TUR: 3 + + S1901M + + + + \ No newline at end of file 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/verify_all_methods.rb b/verify_all_methods.rb new file mode 100644 index 0000000..31928cb --- /dev/null +++ b/verify_all_methods.rb @@ -0,0 +1,67 @@ +require_relative 'app/services/game_api_client' +require 'pp' + +# Mock Rails.logger for standalone script +class Rails + def self.logger + @logger ||= Logger.new(STDOUT) + end +end +require 'logger' + +client = GameApiClient.new + +puts "--- 1. api_game_initial_state ---" +initial_state_full = client.api_game_initial_state +if initial_state_full + game_state = initial_state_full["game_state"] + puts "Success: Game ID #{game_state['game_id']}" +else + puts "Failed" + exit +end + +puts "\n--- 2. api_calculate_possible_orders ---" +orders_poss = client.api_calculate_possible_orders(game_state, "FRANCE") +puts "Success: Power #{orders_poss['power']}" if orders_poss + +puts "\n--- 3. api_calculate_auto_orders ---" +auto_orders = client.api_calculate_auto_orders(game_state, "FRANCE") +if auto_orders + puts "Success: Generated #{auto_orders['orders'].size} orders" + orders_for_process = { "FRANCE" => auto_orders['orders'] } +else + puts "Failed" +end + +puts "\n--- 4. api_calculate_validate ---" +validation = client.api_calculate_validate(game_state, orders_for_process) +puts "Success: Validated orders for #{validation['validated_orders'].keys.join(', ')}" if validation + +puts "\n--- 5. api_calculate_process ---" +processed = client.api_calculate_process(game_state, orders_for_process) +if processed + puts "Success: New phase #{processed['game_state']['phase']}" +else + puts "Failed" +end + +puts "\n--- 6. api_render ---" +svg = client.api_render(game_state, orders: orders_for_process) +if svg + if svg.is_a?(String) && (svg.include?("