Compare commits
1 commit
main
...
jedmund/ho
| Author | SHA1 | Date | |
|---|---|---|---|
| 41a090b74c |
273 changed files with 2624 additions and 21667 deletions
|
|
@ -1,26 +0,0 @@
|
|||
.*
|
||||
|
||||
app/assets
|
||||
bin/
|
||||
coverage/
|
||||
download/
|
||||
export/
|
||||
log/
|
||||
postgres/
|
||||
public/
|
||||
storage/
|
||||
tmp/
|
||||
vendor/
|
||||
lib/
|
||||
sig/
|
||||
test/
|
||||
|
||||
LICENSE
|
||||
logfile
|
||||
config.ru
|
||||
codebase.md
|
||||
Rakefile
|
||||
|
||||
db/migrate/*
|
||||
db/data/*
|
||||
db/seed/updates/*
|
||||
1
.env
1
.env
|
|
@ -1 +0,0 @@
|
|||
RAILS_LOG_TO_STDOUT=true
|
||||
5
.github/PULL_REQUEST_TEMPLATE.md
vendored
5
.github/PULL_REQUEST_TEMPLATE.md
vendored
|
|
@ -1,5 +0,0 @@
|
|||
Choose a PR template:
|
||||
|
||||
* [General](?expand=1&template=.github/PULL_REQUEST_TEMPLATE/default.md): For general changes like feature additions
|
||||
and issue resolution
|
||||
* [Data Update](?expand=1&template=.github/PULL_REQUEST_TEMPLATE/data_update.md): For game data updates
|
||||
46
.github/PULL_REQUEST_TEMPLATE/data_update.md
vendored
46
.github/PULL_REQUEST_TEMPLATE/data_update.md
vendored
|
|
@ -1,46 +0,0 @@
|
|||
# Data Update
|
||||
|
||||
## Summary
|
||||
|
||||
<!-- Describe what this data update includes -->
|
||||
<!-- Example: Adding new Valentines 2024 characters -->
|
||||
|
||||
## New Additions
|
||||
|
||||
<!-- Copy the list of newly added items from the Import Summary -->
|
||||
|
||||
##### Characters
|
||||
-
|
||||
|
||||
##### Weapons
|
||||
-
|
||||
|
||||
##### Summons
|
||||
-
|
||||
|
||||
## Modifications
|
||||
|
||||
<!-- Copy the list of modified items from the Import Summary -->
|
||||
|
||||
##### Characters
|
||||
-
|
||||
|
||||
##### Weapons
|
||||
-
|
||||
|
||||
##### Summons
|
||||
-
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] CSV files use the correct naming format (`YYYYMMDD-{type}-XXX.csv`)
|
||||
- [ ] CSV files are in the correct location (`db/seed/updates/`)
|
||||
- [ ] All required fields are filled out
|
||||
- [ ] Dates use the correct format (`YYYY-MM-DD`)
|
||||
- [ ] Boolean values are either `true` or `false`
|
||||
- [ ] Arrays use the correct format (e.g., `{value1,value2}`)
|
||||
- [ ] Ran import in test mode (`bin/rails granblue:import_data TEST=true`)
|
||||
|
||||
## Test Results
|
||||
|
||||
<!-- Paste the completion section from running the import in test mode -->
|
||||
21
.github/PULL_REQUEST_TEMPLATE/default.md
vendored
21
.github/PULL_REQUEST_TEMPLATE/default.md
vendored
|
|
@ -1,21 +0,0 @@
|
|||
# Pull Request
|
||||
|
||||
## Description
|
||||
|
||||
Please provide a clear description of your changes.
|
||||
|
||||
## Changes Made
|
||||
|
||||
<!-- List the main changes in this PR -->
|
||||
<!-- Example:
|
||||
- Added feature X
|
||||
- Fixed bug Y
|
||||
- Updated documentation for Z
|
||||
-->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I have tested my changes
|
||||
- [ ] I have updated relevant documentation
|
||||
- [ ] My changes generate no new warnings
|
||||
- [ ] I have added tests if applicable
|
||||
15
.gitignore
vendored
15
.gitignore
vendored
|
|
@ -7,9 +7,6 @@
|
|||
# Ignore bundler config.
|
||||
/.bundle
|
||||
|
||||
# Ignore mystery Postgres folder
|
||||
/postgres
|
||||
|
||||
# Ignore the default SQLite database.
|
||||
/db/*.sqlite3
|
||||
/db/*.sqlite3-journal
|
||||
|
|
@ -21,9 +18,6 @@
|
|||
!/log/.keep
|
||||
!/tmp/.keep
|
||||
|
||||
# Ignore simplecov directory
|
||||
/coverage/*
|
||||
|
||||
# Ignore pidfiles, but keep the directory.
|
||||
/tmp/pids/*
|
||||
!/tmp/pids/
|
||||
|
|
@ -37,12 +31,9 @@
|
|||
# Ignore master key for decrypting credentials and more.
|
||||
/config/master.key
|
||||
|
||||
# Ignore specific directories
|
||||
/.local
|
||||
# Ignore exported and downloaded files
|
||||
/export
|
||||
/download
|
||||
/backups
|
||||
/logs
|
||||
|
||||
.DS_Store
|
||||
|
||||
|
|
@ -55,7 +46,3 @@ config/application.yml
|
|||
.vscode/*
|
||||
|
||||
/config/credentials/production.key
|
||||
|
||||
# Ignore AI Codebase-generated files
|
||||
codebase.md
|
||||
mise.toml
|
||||
|
|
|
|||
1
.rspec
1
.rspec
|
|
@ -1 +0,0 @@
|
|||
--require spec_helper
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
Layout/MultilineOperationIndentation:
|
||||
EnforcedStyle: aligned
|
||||
|
|
@ -1 +1 @@
|
|||
ruby-3.3.7
|
||||
ruby-3.0.0
|
||||
|
|
|
|||
206
ENDPOINTS.md
206
ENDPOINTS.md
|
|
@ -1,206 +0,0 @@
|
|||
# Hensei API Documentation
|
||||
|
||||
## Authentication
|
||||
|
||||
All API endpoints require authentication using OAuth2 via Doorkeeper. You must include a valid access token in the Authorization header:
|
||||
|
||||
```
|
||||
Authorization: Bearer YOUR_ACCESS_TOKEN
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Authentication Endpoints
|
||||
|
||||
#### Token Generation
|
||||
- **POST** `/oauth/token`
|
||||
- Generate access token for user authentication
|
||||
|
||||
### User Endpoints
|
||||
|
||||
#### Create User
|
||||
- **POST** `/api/v1/users`
|
||||
- Create a new user account
|
||||
|
||||
#### Update User
|
||||
- **PUT** `/api/v1/users/:id`
|
||||
- Update user information
|
||||
|
||||
#### Get User Info
|
||||
- **GET** `/api/v1/users/info/:id`
|
||||
- Retrieve user information
|
||||
|
||||
#### Email & Username Availability Check
|
||||
- **POST** `/api/v1/check/email`
|
||||
- Check if email is available for registration
|
||||
- **POST** `/api/v1/check/username`
|
||||
- Check if username is available
|
||||
|
||||
### Party Endpoints
|
||||
|
||||
#### List Parties
|
||||
- **GET** `/api/v1/parties`
|
||||
- Retrieve list of parties
|
||||
|
||||
#### Create Party
|
||||
- **POST** `/api/v1/parties`
|
||||
- Create a new party
|
||||
|
||||
#### Update Party
|
||||
- **PUT** `/api/v1/parties/:id`
|
||||
- Update an existing party
|
||||
|
||||
#### Delete Party
|
||||
- **DELETE** `/api/v1/parties/:id`
|
||||
- Delete a party
|
||||
|
||||
#### Get Party Details
|
||||
- **GET** `/api/v1/parties/:id`
|
||||
- Retrieve detailed information about a specific party
|
||||
|
||||
#### Remix Party
|
||||
- **POST** `/api/v1/parties/:id/remix`
|
||||
- Create a remix (copy) of an existing party
|
||||
|
||||
#### Favorites
|
||||
- **GET** `/api/v1/parties/favorites`
|
||||
- Retrieve user's favorite parties
|
||||
- **POST** `/api/v1/favorites`
|
||||
- Add a party to favorites
|
||||
- **DELETE** `/api/v1/favorites`
|
||||
- Remove a party from favorites
|
||||
|
||||
### Job Management
|
||||
|
||||
#### Update Job
|
||||
- **PUT** `/api/v1/parties/:id/jobs`
|
||||
- Update job for a party
|
||||
|
||||
#### Update Job Skills
|
||||
- **PUT** `/api/v1/parties/:id/job_skills`
|
||||
- Update job skills for a party
|
||||
- **DELETE** `/api/v1/parties/:id/job_skills`
|
||||
- Remove a job skill from a party
|
||||
|
||||
#### Job Endpoints
|
||||
- **GET** `/api/v1/jobs`
|
||||
- List all jobs
|
||||
- **GET** `/api/v1/jobs/:id`
|
||||
- Get details of a specific job
|
||||
- **GET** `/api/v1/jobs/:id/skills`
|
||||
- Get skills for a specific job
|
||||
- **GET** `/api/v1/jobs/:id/accessories`
|
||||
- Get accessories for a specific job
|
||||
|
||||
### Grid Management
|
||||
|
||||
#### Characters
|
||||
- **POST** `/api/v1/characters`
|
||||
- Add a character to a party
|
||||
- **POST** `/api/v1/characters/resolve`
|
||||
- Resolve character conflicts
|
||||
- **POST** `/api/v1/characters/update_uncap`
|
||||
- Update character uncap level
|
||||
- **DELETE** `/api/v1/characters`
|
||||
- Remove a character from a party
|
||||
|
||||
#### Weapons
|
||||
- **POST** `/api/v1/weapons`
|
||||
- Add a weapon to a party
|
||||
- **POST** `/api/v1/weapons/resolve`
|
||||
- Resolve weapon conflicts
|
||||
- **POST** `/api/v1/weapons/update_uncap`
|
||||
- Update weapon uncap level
|
||||
- **DELETE** `/api/v1/weapons`
|
||||
- Remove a weapon from a party
|
||||
|
||||
#### Summons
|
||||
- **POST** `/api/v1/summons`
|
||||
- Add a summon to a party
|
||||
- **POST** `/api/v1/summons/update_uncap`
|
||||
- Update summon uncap level
|
||||
- **POST** `/api/v1/summons/update_quick_summon`
|
||||
- Update quick summon status
|
||||
- **DELETE** `/api/v1/summons`
|
||||
- Remove a summon from a party
|
||||
|
||||
### Search Endpoints
|
||||
|
||||
#### Global Search
|
||||
- **POST** `/api/v1/search`
|
||||
- Perform a global search across all object types
|
||||
|
||||
#### Specific Object Searches
|
||||
- **POST** `/api/v1/search/characters`
|
||||
- Search characters
|
||||
- **POST** `/api/v1/search/weapons`
|
||||
- Search weapons
|
||||
- **POST** `/api/v1/search/summons`
|
||||
- Search summons
|
||||
- **POST** `/api/v1/search/job_skills`
|
||||
- Search job skills
|
||||
- **POST** `/api/v1/search/guidebooks`
|
||||
- Search guidebooks
|
||||
|
||||
### Reference Endpoints
|
||||
|
||||
#### Guidebooks
|
||||
- **GET** `/api/v1/guidebooks`
|
||||
- List all guidebooks
|
||||
|
||||
#### Raids
|
||||
- **GET** `/api/v1/raids`
|
||||
- List all raids
|
||||
- **GET** `/api/v1/raids/groups`
|
||||
- List raid groups
|
||||
- **GET** `/api/v1/raids/:id`
|
||||
- Get details of a specific raid
|
||||
|
||||
#### Weapon Keys
|
||||
- **GET** `/api/v1/weapon_keys`
|
||||
- List all weapon keys
|
||||
|
||||
### Object Detail Endpoints
|
||||
|
||||
#### Get Object Details
|
||||
- **GET** `/api/v1/weapons/:granblue_id`
|
||||
- Get weapon details
|
||||
- **GET** `/api/v1/characters/:granblue_id`
|
||||
- Get character details
|
||||
- **GET** `/api/v1/summons/:granblue_id`
|
||||
- Get summon details
|
||||
|
||||
### Utility Endpoints
|
||||
|
||||
#### Version
|
||||
- **GET** `/api/v1/version`
|
||||
- Get current API version
|
||||
|
||||
#### Import
|
||||
- **POST** `/api/v1/import`
|
||||
- Import party data from game
|
||||
|
||||
## Request and Response Formats
|
||||
|
||||
- All endpoints return JSON
|
||||
- Requests should include the `Content-Type: application/json` header
|
||||
- Authentication is required via Bearer token
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Successful requests return HTTP 200 (OK) or 201 (Created)
|
||||
- Failed requests return appropriate HTTP status codes with error details in the response body
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
[Specify your rate limiting policy]
|
||||
|
||||
## Pagination
|
||||
|
||||
[Specify your pagination approach for list endpoints]
|
||||
|
||||
## Notes
|
||||
|
||||
- All endpoints are versioned under `/api/v1/`
|
||||
- Timestamps are typically returned in ISO 8601 format
|
||||
- Dates are typically returned in YYYY-MM-DD format
|
||||
39
Gemfile
39
Gemfile
|
|
@ -1,7 +1,8 @@
|
|||
source 'https://rubygems.org'
|
||||
ruby '3.3.7'
|
||||
ruby '3.0.0'
|
||||
|
||||
gem 'bootsnap'
|
||||
gem 'pg'
|
||||
gem 'rack-cors'
|
||||
gem 'rails'
|
||||
gem 'sprockets-rails'
|
||||
|
|
@ -9,9 +10,6 @@ gem 'sprockets-rails'
|
|||
# A Ruby Web Server Built For Concurrency
|
||||
gem 'puma'
|
||||
|
||||
# Pg is the Ruby interface to the PostgreSQL RDBMS
|
||||
gem 'pg'
|
||||
|
||||
# A sophisticated and secure hash algorithm for
|
||||
# hashing passwords.
|
||||
gem 'bcrypt'
|
||||
|
|
@ -34,30 +32,15 @@ gem 'responders'
|
|||
# Parse emoji to strings
|
||||
gem 'gemoji-parser'
|
||||
|
||||
# Mini replacement for RMagick
|
||||
gem 'mini_magick'
|
||||
|
||||
# An awesome replacement for acts_as_nested_set and better_nested_set.
|
||||
gem 'awesome_nested_set'
|
||||
|
||||
# Official AWS Ruby gem for Amazon Simple Storage Service (Amazon S3)
|
||||
gem 'aws-sdk-s3'
|
||||
|
||||
# An email validator for Rails
|
||||
gem 'email_validator'
|
||||
|
||||
# pg_search builds ActiveRecord named scopes that take advantage of PostgreSQL’s full text search
|
||||
gem 'pg_search'
|
||||
|
||||
# A Ruby client library for Redis
|
||||
gem 'redis'
|
||||
|
||||
# Simple, efficient background processing for Ruby
|
||||
gem 'sidekiq'
|
||||
|
||||
# scheduler for Ruby (at, in, cron and every jobs)
|
||||
gem 'rufus-scheduler'
|
||||
|
||||
# Pagination library
|
||||
gem 'will_paginate', '~> 3.3'
|
||||
|
||||
|
|
@ -73,17 +56,6 @@ gem 'httparty'
|
|||
# StringScanner provides for lexical scanning operations on a String.
|
||||
gem 'strscan'
|
||||
|
||||
# New Relic Ruby Agent
|
||||
gem 'newrelic_rpm'
|
||||
|
||||
# Parallel processing made simple and fast
|
||||
gem 'parallel'
|
||||
|
||||
# The Sentry SDK for Rails
|
||||
gem 'sentry-rails'
|
||||
gem 'sentry-ruby'
|
||||
gem 'stackprof'
|
||||
|
||||
group :doc do
|
||||
gem 'apipie-rails'
|
||||
gem 'sdoc'
|
||||
|
|
@ -92,7 +64,8 @@ end
|
|||
group :development, :test do
|
||||
gem 'amazing_print'
|
||||
gem 'dotenv-rails'
|
||||
gem 'prosopite'
|
||||
gem 'factory_bot_rails'
|
||||
gem 'faker'
|
||||
gem 'pry'
|
||||
gem 'rspec_junit_formatter'
|
||||
gem 'rspec-rails'
|
||||
|
|
@ -100,7 +73,6 @@ end
|
|||
|
||||
group :development do
|
||||
gem 'listen'
|
||||
gem 'pg_query'
|
||||
gem 'solargraph'
|
||||
gem 'spring'
|
||||
gem 'spring-commands-rspec'
|
||||
|
|
@ -115,9 +87,6 @@ group :test do
|
|||
gem 'api_matchers'
|
||||
gem 'byebug'
|
||||
gem 'database_cleaner'
|
||||
gem 'factory_bot_rails'
|
||||
gem 'faker'
|
||||
gem 'rspec'
|
||||
gem 'shoulda-matchers'
|
||||
gem 'simplecov', require: false
|
||||
end
|
||||
|
|
|
|||
591
Gemfile.lock
591
Gemfile.lock
|
|
@ -1,461 +1,329 @@
|
|||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (8.0.1)
|
||||
actionpack (= 8.0.1)
|
||||
activesupport (= 8.0.1)
|
||||
actioncable (7.0.4.1)
|
||||
actionpack (= 7.0.4.1)
|
||||
activesupport (= 7.0.4.1)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (8.0.1)
|
||||
actionpack (= 8.0.1)
|
||||
activejob (= 8.0.1)
|
||||
activerecord (= 8.0.1)
|
||||
activestorage (= 8.0.1)
|
||||
activesupport (= 8.0.1)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (8.0.1)
|
||||
actionpack (= 8.0.1)
|
||||
actionview (= 8.0.1)
|
||||
activejob (= 8.0.1)
|
||||
activesupport (= 8.0.1)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (8.0.1)
|
||||
actionview (= 8.0.1)
|
||||
activesupport (= 8.0.1)
|
||||
nokogiri (>= 1.8.5)
|
||||
rack (>= 2.2.4)
|
||||
rack-session (>= 1.0.1)
|
||||
actionmailbox (7.0.4.1)
|
||||
actionpack (= 7.0.4.1)
|
||||
activejob (= 7.0.4.1)
|
||||
activerecord (= 7.0.4.1)
|
||||
activestorage (= 7.0.4.1)
|
||||
activesupport (= 7.0.4.1)
|
||||
mail (>= 2.7.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
actionmailer (7.0.4.1)
|
||||
actionpack (= 7.0.4.1)
|
||||
actionview (= 7.0.4.1)
|
||||
activejob (= 7.0.4.1)
|
||||
activesupport (= 7.0.4.1)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
rails-dom-testing (~> 2.0)
|
||||
actionpack (7.0.4.1)
|
||||
actionview (= 7.0.4.1)
|
||||
activesupport (= 7.0.4.1)
|
||||
rack (~> 2.0, >= 2.2.0)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (8.0.1)
|
||||
actionpack (= 8.0.1)
|
||||
activerecord (= 8.0.1)
|
||||
activestorage (= 8.0.1)
|
||||
activesupport (= 8.0.1)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
||||
actiontext (7.0.4.1)
|
||||
actionpack (= 7.0.4.1)
|
||||
activerecord (= 7.0.4.1)
|
||||
activestorage (= 7.0.4.1)
|
||||
activesupport (= 7.0.4.1)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (8.0.1)
|
||||
activesupport (= 8.0.1)
|
||||
actionview (7.0.4.1)
|
||||
activesupport (= 7.0.4.1)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (8.0.1)
|
||||
activesupport (= 8.0.1)
|
||||
erubi (~> 1.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.1, >= 1.2.0)
|
||||
activejob (7.0.4.1)
|
||||
activesupport (= 7.0.4.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (8.0.1)
|
||||
activesupport (= 8.0.1)
|
||||
activerecord (8.0.1)
|
||||
activemodel (= 8.0.1)
|
||||
activesupport (= 8.0.1)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (8.0.1)
|
||||
actionpack (= 8.0.1)
|
||||
activejob (= 8.0.1)
|
||||
activerecord (= 8.0.1)
|
||||
activesupport (= 8.0.1)
|
||||
activemodel (7.0.4.1)
|
||||
activesupport (= 7.0.4.1)
|
||||
activerecord (7.0.4.1)
|
||||
activemodel (= 7.0.4.1)
|
||||
activesupport (= 7.0.4.1)
|
||||
activestorage (7.0.4.1)
|
||||
actionpack (= 7.0.4.1)
|
||||
activejob (= 7.0.4.1)
|
||||
activerecord (= 7.0.4.1)
|
||||
activesupport (= 7.0.4.1)
|
||||
marcel (~> 1.0)
|
||||
activesupport (8.0.1)
|
||||
base64
|
||||
benchmark (>= 0.3)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
connection_pool (>= 2.2.5)
|
||||
drb
|
||||
mini_mime (>= 1.1.0)
|
||||
activesupport (7.0.4.1)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
i18n (>= 1.6, < 2)
|
||||
logger (>= 1.4.2)
|
||||
minitest (>= 5.1)
|
||||
securerandom (>= 0.3)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
uri (>= 0.13.1)
|
||||
amazing_print (1.7.2)
|
||||
amoeba (3.3.0)
|
||||
activerecord (>= 5.2.0)
|
||||
tzinfo (~> 2.0)
|
||||
amazing_print (1.4.0)
|
||||
amoeba (3.2.0)
|
||||
activerecord (>= 4.2.0)
|
||||
api_matchers (0.6.2)
|
||||
activesupport (>= 3.2.5)
|
||||
nokogiri (>= 1.5.2)
|
||||
rspec (>= 3.1)
|
||||
apipie-rails (1.4.2)
|
||||
apipie-rails (0.9.1)
|
||||
actionpack (>= 5.0)
|
||||
activesupport (>= 5.0)
|
||||
ast (2.4.2)
|
||||
awesome_nested_set (3.8.0)
|
||||
activerecord (>= 4.0.0, < 8.1)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.1044.0)
|
||||
aws-sdk-core (3.217.1)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.97.0)
|
||||
aws-sdk-core (~> 3, >= 3.216.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.179.0)
|
||||
aws-sdk-core (~> 3, >= 3.216.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.11.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
awesome_nested_set (3.5.0)
|
||||
activerecord (>= 4.0.0, < 7.1)
|
||||
backport (1.2.0)
|
||||
base64 (0.2.0)
|
||||
bcrypt (3.1.20)
|
||||
benchmark (0.4.0)
|
||||
bigdecimal (3.1.9)
|
||||
blueprinter (1.1.2)
|
||||
bootsnap (1.18.4)
|
||||
bcrypt (3.1.18)
|
||||
benchmark (0.2.1)
|
||||
blueprinter (0.25.3)
|
||||
bootsnap (1.15.0)
|
||||
msgpack (~> 1.2)
|
||||
builder (3.3.0)
|
||||
builder (3.2.4)
|
||||
byebug (11.1.3)
|
||||
coderay (1.1.3)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.0)
|
||||
concurrent-ruby (1.1.10)
|
||||
crass (1.0.6)
|
||||
csv (3.3.2)
|
||||
data_migrate (11.2.0)
|
||||
activerecord (>= 6.1)
|
||||
railties (>= 6.1)
|
||||
database_cleaner (2.1.0)
|
||||
database_cleaner-active_record (>= 2, < 3)
|
||||
database_cleaner-active_record (2.2.0)
|
||||
data_migrate (8.5.0)
|
||||
activerecord (>= 5.0)
|
||||
railties (>= 5.0)
|
||||
database_cleaner (2.0.1)
|
||||
database_cleaner-active_record (~> 2.0.0)
|
||||
database_cleaner-active_record (2.0.1)
|
||||
activerecord (>= 5.a)
|
||||
database_cleaner-core (~> 2.0.0)
|
||||
database_cleaner-core (2.0.1)
|
||||
date (3.4.1)
|
||||
diff-lcs (1.5.1)
|
||||
docile (1.4.1)
|
||||
doorkeeper (5.8.1)
|
||||
date (3.3.3)
|
||||
diff-lcs (1.5.0)
|
||||
docile (1.4.0)
|
||||
doorkeeper (5.6.2)
|
||||
railties (>= 5)
|
||||
dotenv (3.1.7)
|
||||
dotenv-rails (3.1.7)
|
||||
dotenv (= 3.1.7)
|
||||
railties (>= 6.1)
|
||||
drb (2.2.1)
|
||||
dotenv (2.8.1)
|
||||
dotenv-rails (2.8.1)
|
||||
dotenv (= 2.8.1)
|
||||
railties (>= 3.2)
|
||||
e2mmap (0.1.0)
|
||||
email_validator (2.2.4)
|
||||
activemodel
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
factory_bot (6.5.1)
|
||||
activesupport (>= 6.1.0)
|
||||
factory_bot_rails (6.4.4)
|
||||
factory_bot (~> 6.5)
|
||||
erubi (1.12.0)
|
||||
factory_bot (6.2.1)
|
||||
activesupport (>= 5.0.0)
|
||||
factory_bot_rails (6.2.0)
|
||||
factory_bot (~> 6.2.0)
|
||||
railties (>= 5.0.0)
|
||||
faker (3.5.1)
|
||||
faker (3.1.0)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
ffi (1.17.1-aarch64-linux-gnu)
|
||||
ffi (1.17.1-aarch64-linux-musl)
|
||||
ffi (1.17.1-arm-linux-gnu)
|
||||
ffi (1.17.1-arm-linux-musl)
|
||||
ffi (1.17.1-arm64-darwin)
|
||||
ffi (1.17.1-x86_64-darwin)
|
||||
ffi (1.17.1-x86_64-linux-gnu)
|
||||
ffi (1.17.1-x86_64-linux-musl)
|
||||
ffi (1.15.5)
|
||||
figaro (1.2.0)
|
||||
thor (>= 0.14.0, < 2)
|
||||
fugit (1.11.1)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
raabro (~> 1.4)
|
||||
gemoji (4.1.0)
|
||||
gemoji (4.0.1)
|
||||
gemoji-parser (1.3.1)
|
||||
gemoji (>= 2.1.0)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
google-protobuf (4.29.3)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
google-protobuf (4.29.3-aarch64-linux)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
google-protobuf (4.29.3-arm64-darwin)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
google-protobuf (4.29.3-x86_64-darwin)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
google-protobuf (4.29.3-x86_64-linux)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
httparty (0.22.0)
|
||||
csv
|
||||
mini_mime (>= 1.0.0)
|
||||
globalid (1.0.1)
|
||||
activesupport (>= 5.0)
|
||||
httparty (0.20.0)
|
||||
mime-types (~> 3.0)
|
||||
multi_xml (>= 0.5.2)
|
||||
i18n (1.14.7)
|
||||
i18n (1.12.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
io-console (0.8.0)
|
||||
irb (1.15.1)
|
||||
pp (>= 0.6.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jaro_winkler (1.6.0)
|
||||
jmespath (1.6.2)
|
||||
json (2.9.1)
|
||||
kramdown (2.5.1)
|
||||
rexml (>= 3.3.9)
|
||||
jaro_winkler (1.5.4)
|
||||
json (2.6.3)
|
||||
kramdown (2.4.0)
|
||||
rexml
|
||||
kramdown-parser-gfm (1.1.0)
|
||||
kramdown (~> 2.0)
|
||||
language_server-protocol (3.17.0.4)
|
||||
listen (3.9.0)
|
||||
listen (3.8.0)
|
||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||
rb-inotify (~> 0.9, >= 0.9.10)
|
||||
logger (1.6.5)
|
||||
loofah (2.24.0)
|
||||
loofah (2.19.1)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.8.1)
|
||||
nokogiri (>= 1.5.9)
|
||||
mail (2.8.0.1)
|
||||
mini_mime (>= 0.1.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
marcel (1.0.4)
|
||||
method_source (1.1.0)
|
||||
mini_magick (5.1.0)
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.25.4)
|
||||
msgpack (1.7.5)
|
||||
multi_xml (0.7.1)
|
||||
bigdecimal (~> 3.1)
|
||||
net-imap (0.5.5)
|
||||
marcel (1.0.2)
|
||||
method_source (1.0.0)
|
||||
mime-types (3.4.1)
|
||||
mime-types-data (~> 3.2015)
|
||||
mime-types-data (3.2022.0105)
|
||||
mini_mime (1.1.2)
|
||||
mini_portile2 (2.8.1)
|
||||
minitest (5.17.0)
|
||||
msgpack (1.6.0)
|
||||
multi_xml (0.6.0)
|
||||
net-imap (0.3.4)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
net-protocol
|
||||
net-protocol (0.2.2)
|
||||
net-protocol (0.2.1)
|
||||
timeout
|
||||
net-smtp (0.5.0)
|
||||
net-smtp (0.3.3)
|
||||
net-protocol
|
||||
newrelic_rpm (9.17.0)
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.18.2-aarch64-linux-gnu)
|
||||
nio4r (2.5.8)
|
||||
nokogiri (1.14.0)
|
||||
mini_portile2 (~> 2.8.0)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-arm-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-arm-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
observer (0.1.2)
|
||||
oj (3.16.9)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
ostruct (0.6.1)
|
||||
parallel (1.26.3)
|
||||
parser (3.3.7.0)
|
||||
oj (3.13.23)
|
||||
parallel (1.22.1)
|
||||
parser (3.2.0.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pg (1.5.9)
|
||||
pg_query (6.0.0)
|
||||
google-protobuf (>= 3.25.3)
|
||||
pg_search (2.3.7)
|
||||
activerecord (>= 6.1)
|
||||
activesupport (>= 6.1)
|
||||
pp (0.6.2)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prosopite (1.4.2)
|
||||
pry (0.15.2)
|
||||
pg (1.4.5)
|
||||
pg_search (2.3.6)
|
||||
activerecord (>= 5.2)
|
||||
activesupport (>= 5.2)
|
||||
pry (0.14.1)
|
||||
coderay (~> 1.1)
|
||||
method_source (~> 1.0)
|
||||
psych (5.2.3)
|
||||
date
|
||||
psych (5.0.2)
|
||||
stringio
|
||||
puma (6.6.0)
|
||||
puma (6.0.2)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.9)
|
||||
rack-cors (2.0.2)
|
||||
racc (1.6.2)
|
||||
rack (2.2.6.2)
|
||||
rack-cors (1.1.1)
|
||||
rack (>= 2.0.0)
|
||||
rack-session (2.1.0)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.2.0)
|
||||
rack-test (2.0.2)
|
||||
rack (>= 1.3)
|
||||
rackup (2.2.1)
|
||||
rack (>= 3)
|
||||
rails (8.0.1)
|
||||
actioncable (= 8.0.1)
|
||||
actionmailbox (= 8.0.1)
|
||||
actionmailer (= 8.0.1)
|
||||
actionpack (= 8.0.1)
|
||||
actiontext (= 8.0.1)
|
||||
actionview (= 8.0.1)
|
||||
activejob (= 8.0.1)
|
||||
activemodel (= 8.0.1)
|
||||
activerecord (= 8.0.1)
|
||||
activestorage (= 8.0.1)
|
||||
activesupport (= 8.0.1)
|
||||
rails (7.0.4.1)
|
||||
actioncable (= 7.0.4.1)
|
||||
actionmailbox (= 7.0.4.1)
|
||||
actionmailer (= 7.0.4.1)
|
||||
actionpack (= 7.0.4.1)
|
||||
actiontext (= 7.0.4.1)
|
||||
actionview (= 7.0.4.1)
|
||||
activejob (= 7.0.4.1)
|
||||
activemodel (= 7.0.4.1)
|
||||
activerecord (= 7.0.4.1)
|
||||
activestorage (= 7.0.4.1)
|
||||
activesupport (= 7.0.4.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.0.1)
|
||||
rails-dom-testing (2.2.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
railties (= 7.0.4.1)
|
||||
rails-dom-testing (2.0.3)
|
||||
activesupport (>= 4.2.0)
|
||||
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.0.1)
|
||||
actionpack (= 8.0.1)
|
||||
activesupport (= 8.0.1)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rails-html-sanitizer (1.5.0)
|
||||
loofah (~> 2.19, >= 2.19.1)
|
||||
railties (7.0.4.1)
|
||||
actionpack (= 7.0.4.1)
|
||||
activesupport (= 7.0.4.1)
|
||||
method_source
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0, >= 1.2.2)
|
||||
zeitwerk (~> 2.6)
|
||||
thor (~> 1.0)
|
||||
zeitwerk (~> 2.5)
|
||||
rainbow (3.1.1)
|
||||
rake (13.2.1)
|
||||
rake (13.0.6)
|
||||
rb-fsevent (0.11.2)
|
||||
rb-inotify (0.11.1)
|
||||
rb-inotify (0.10.1)
|
||||
ffi (~> 1.0)
|
||||
rbs (3.8.1)
|
||||
logger
|
||||
rdoc (6.11.0)
|
||||
rdoc (6.5.0)
|
||||
psych (>= 4.0.0)
|
||||
redis (5.3.0)
|
||||
redis-client (>= 0.22.0)
|
||||
redis-client (0.23.2)
|
||||
connection_pool
|
||||
regexp_parser (2.10.0)
|
||||
reline (0.6.0)
|
||||
io-console (~> 0.5)
|
||||
responders (3.1.1)
|
||||
actionpack (>= 5.2)
|
||||
railties (>= 5.2)
|
||||
reverse_markdown (3.0.0)
|
||||
regexp_parser (2.6.2)
|
||||
responders (3.0.1)
|
||||
actionpack (>= 5.0)
|
||||
railties (>= 5.0)
|
||||
reverse_markdown (2.1.1)
|
||||
nokogiri
|
||||
rexml (3.4.0)
|
||||
rspec (3.13.0)
|
||||
rspec-core (~> 3.13.0)
|
||||
rspec-expectations (~> 3.13.0)
|
||||
rspec-mocks (~> 3.13.0)
|
||||
rspec-core (3.13.2)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.3)
|
||||
rexml (3.2.5)
|
||||
rspec (3.12.0)
|
||||
rspec-core (~> 3.12.0)
|
||||
rspec-expectations (~> 3.12.0)
|
||||
rspec-mocks (~> 3.12.0)
|
||||
rspec-core (3.12.0)
|
||||
rspec-support (~> 3.12.0)
|
||||
rspec-expectations (3.12.2)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-mocks (3.13.2)
|
||||
rspec-support (~> 3.12.0)
|
||||
rspec-mocks (3.12.3)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-rails (7.1.0)
|
||||
actionpack (>= 7.0)
|
||||
activesupport (>= 7.0)
|
||||
railties (>= 7.0)
|
||||
rspec-core (~> 3.13)
|
||||
rspec-expectations (~> 3.13)
|
||||
rspec-mocks (~> 3.13)
|
||||
rspec-support (~> 3.13)
|
||||
rspec-support (3.13.2)
|
||||
rspec-support (~> 3.12.0)
|
||||
rspec-rails (6.0.1)
|
||||
actionpack (>= 6.1)
|
||||
activesupport (>= 6.1)
|
||||
railties (>= 6.1)
|
||||
rspec-core (~> 3.11)
|
||||
rspec-expectations (~> 3.11)
|
||||
rspec-mocks (~> 3.11)
|
||||
rspec-support (~> 3.11)
|
||||
rspec-support (3.12.0)
|
||||
rspec_junit_formatter (0.6.0)
|
||||
rspec-core (>= 2, < 4, != 2.12.0)
|
||||
rubocop (1.71.1)
|
||||
rubocop (1.43.0)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
parser (>= 3.2.0.0)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
regexp_parser (>= 1.8, < 3.0)
|
||||
rexml (>= 3.2.5, < 4.0)
|
||||
rubocop-ast (>= 1.24.1, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.38.0)
|
||||
parser (>= 3.3.1.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
rufus-scheduler (3.9.2)
|
||||
fugit (~> 1.1, >= 1.11.1)
|
||||
sdoc (2.6.1)
|
||||
unicode-display_width (>= 2.4.0, < 3.0)
|
||||
rubocop-ast (1.24.1)
|
||||
parser (>= 3.1.1.0)
|
||||
ruby-progressbar (1.11.0)
|
||||
sdoc (2.6.0)
|
||||
rdoc (>= 5.0)
|
||||
securerandom (0.4.1)
|
||||
sentry-rails (5.22.4)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.22.4)
|
||||
sentry-ruby (5.22.4)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
shoulda-matchers (6.4.0)
|
||||
shoulda-matchers (5.3.0)
|
||||
activesupport (>= 5.2.0)
|
||||
sidekiq (7.3.8)
|
||||
base64
|
||||
connection_pool (>= 2.3.0)
|
||||
logger
|
||||
rack (>= 2.2.4)
|
||||
redis-client (>= 0.22.2)
|
||||
simplecov (0.22.0)
|
||||
docile (~> 1.1)
|
||||
simplecov-html (~> 0.11)
|
||||
simplecov_json_formatter (~> 0.1)
|
||||
simplecov-html (0.13.1)
|
||||
simplecov-html (0.12.3)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
solargraph (0.51.1)
|
||||
solargraph (0.48.0)
|
||||
backport (~> 1.2)
|
||||
benchmark
|
||||
bundler (~> 2.0)
|
||||
bundler (>= 1.17.2)
|
||||
diff-lcs (~> 1.4)
|
||||
jaro_winkler (~> 1.6)
|
||||
e2mmap
|
||||
jaro_winkler (~> 1.5)
|
||||
kramdown (~> 2.3)
|
||||
kramdown-parser-gfm (~> 1.1)
|
||||
logger (~> 1.6)
|
||||
observer (~> 0.1)
|
||||
ostruct (~> 0.6)
|
||||
parser (~> 3.0)
|
||||
rbs (~> 3.0)
|
||||
reverse_markdown (>= 2.0, < 4)
|
||||
rubocop (~> 1.38)
|
||||
reverse_markdown (>= 1.0.5, < 3)
|
||||
rubocop (>= 0.52)
|
||||
thor (~> 1.0)
|
||||
tilt (~> 2.0)
|
||||
yard (~> 0.9, >= 0.9.24)
|
||||
spring (4.2.1)
|
||||
spring (4.1.1)
|
||||
spring-commands-rspec (1.0.4)
|
||||
spring (>= 0.9.1)
|
||||
sprockets (4.2.1)
|
||||
sprockets (4.2.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
rack (>= 2.2.4, < 4)
|
||||
sprockets-rails (3.5.2)
|
||||
actionpack (>= 6.1)
|
||||
activesupport (>= 6.1)
|
||||
sprockets-rails (3.4.2)
|
||||
actionpack (>= 5.2)
|
||||
activesupport (>= 5.2)
|
||||
sprockets (>= 3.0.0)
|
||||
squasher (0.8.0)
|
||||
stackprof (0.2.27)
|
||||
stringio (3.1.2)
|
||||
strscan (3.1.2)
|
||||
thor (1.3.2)
|
||||
tilt (2.6.0)
|
||||
timeout (0.4.3)
|
||||
tzinfo (2.0.6)
|
||||
squasher (0.7.2)
|
||||
stringio (3.0.4)
|
||||
strscan (3.0.0)
|
||||
thor (1.2.1)
|
||||
tilt (2.0.11)
|
||||
timeout (0.3.1)
|
||||
tzinfo (2.0.5)
|
||||
concurrent-ruby (~> 1.0)
|
||||
unicode-display_width (3.1.4)
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
uri (1.0.2)
|
||||
useragent (0.16.11)
|
||||
websocket-driver (0.7.7)
|
||||
base64
|
||||
unicode-display_width (2.4.2)
|
||||
webrick (1.7.0)
|
||||
websocket-driver (0.7.5)
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
will_paginate (3.3.1)
|
||||
yard (0.9.37)
|
||||
zeitwerk (2.7.1)
|
||||
yard (0.9.28)
|
||||
webrick (~> 1.7.0)
|
||||
zeitwerk (2.6.6)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux-gnu
|
||||
aarch64-linux-musl
|
||||
arm-linux-gnu
|
||||
arm-linux-musl
|
||||
arm64-darwin
|
||||
x86_64-darwin
|
||||
x86_64-linux-gnu
|
||||
x86_64-linux-musl
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
amazing_print
|
||||
|
|
@ -463,7 +331,6 @@ DEPENDENCIES
|
|||
api_matchers
|
||||
apipie-rails
|
||||
awesome_nested_set
|
||||
aws-sdk-s3
|
||||
bcrypt
|
||||
blueprinter
|
||||
bootsnap
|
||||
|
|
@ -479,42 +346,30 @@ DEPENDENCIES
|
|||
gemoji-parser
|
||||
httparty
|
||||
listen
|
||||
mini_magick
|
||||
newrelic_rpm
|
||||
oj
|
||||
parallel
|
||||
pg
|
||||
pg_query
|
||||
pg_search
|
||||
prosopite
|
||||
pry
|
||||
puma
|
||||
rack-cors
|
||||
rails
|
||||
redis
|
||||
responders
|
||||
rspec
|
||||
rspec-rails
|
||||
rspec_junit_formatter
|
||||
rubocop
|
||||
rufus-scheduler
|
||||
sdoc
|
||||
sentry-rails
|
||||
sentry-ruby
|
||||
shoulda-matchers
|
||||
sidekiq
|
||||
simplecov
|
||||
solargraph
|
||||
spring
|
||||
spring-commands-rspec
|
||||
sprockets-rails
|
||||
squasher (>= 0.6.0)
|
||||
stackprof
|
||||
strscan
|
||||
will_paginate (~> 3.3)
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.3.7p123
|
||||
ruby 3.0.0p0
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.22
|
||||
2.4.2
|
||||
|
|
|
|||
674
LICENSE
674
LICENSE
|
|
@ -1,674 +0,0 @@
|
|||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
142
README.md
142
README.md
|
|
@ -1,140 +1,24 @@
|
|||
# Hensei API
|
||||
# README
|
||||
|
||||
## Project Overview
|
||||
This README would normally document whatever steps are necessary to get the
|
||||
application up and running.
|
||||
|
||||
Hensei is a Ruby on Rails API for managing Granblue Fantasy party configurations, providing comprehensive tools for team building, character management, and game data tracking.
|
||||
Things you may want to cover:
|
||||
|
||||
## Prerequisites
|
||||
* Ruby version
|
||||
|
||||
- Ruby 3.3.7
|
||||
- Rails 8.0.1
|
||||
- PostgreSQL
|
||||
- AWS S3 Account (for image storage)
|
||||
* System dependencies
|
||||
|
||||
## System Dependencies
|
||||
* Configuration
|
||||
|
||||
- Ruby version manager (rbenv or RVM recommended)
|
||||
- Bundler
|
||||
- PostgreSQL
|
||||
- Redis (for background jobs)
|
||||
- ImageMagick (for image processing)
|
||||
* Database creation
|
||||
|
||||
## Initial Setup
|
||||
* Database initialization
|
||||
|
||||
### 1. Clone the Repository
|
||||
* How to run the test suite
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-organization/hensei-api.git
|
||||
cd hensei-api
|
||||
```
|
||||
* Services (job queues, cache servers, search engines, etc.)
|
||||
|
||||
### 2. Install Ruby
|
||||
* Deployment instructions
|
||||
|
||||
Ensure you have Ruby 3.3.7 installed. If using rbenv:
|
||||
|
||||
```bash
|
||||
rbenv install 3.3.7
|
||||
rbenv local 3.3.7
|
||||
```
|
||||
|
||||
### 3. Install Dependencies
|
||||
|
||||
```bash
|
||||
gem install bundler
|
||||
bundle install
|
||||
```
|
||||
|
||||
### 4. Database Configuration
|
||||
|
||||
1. Ensure PostgreSQL is running
|
||||
2. Create the database configuration:
|
||||
|
||||
```bash
|
||||
rails db:create
|
||||
rails db:migrate
|
||||
```
|
||||
|
||||
### 5. AWS S3 Configuration
|
||||
|
||||
Hensei requires an AWS S3 bucket for storing images. Configure your credentials:
|
||||
|
||||
```bash
|
||||
EDITOR=vim rails credentials:edit
|
||||
```
|
||||
|
||||
Add the following structure to your credentials:
|
||||
|
||||
```yaml
|
||||
aws:
|
||||
s3:
|
||||
bucket: your-bucket-name
|
||||
access_key_id: your-access-key
|
||||
secret_access_key: your-secret-key
|
||||
region: your-aws-region
|
||||
```
|
||||
|
||||
### 6. Run Initial Data Import (Optional)
|
||||
|
||||
```bash
|
||||
rails data:import
|
||||
```
|
||||
|
||||
### 7. Start the Server
|
||||
|
||||
```bash
|
||||
rails server
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
While most configurations use Rails credentials, you may need to set:
|
||||
|
||||
- `DATABASE_URL`
|
||||
- `RAILS_MASTER_KEY`
|
||||
- `REDIS_URL`
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- Use Redis for caching
|
||||
- Background jobs managed by Sidekiq
|
||||
- Ensure PostgreSQL is optimized for full-text search
|
||||
|
||||
## Security
|
||||
|
||||
- Always use `rails credentials:edit` for sensitive information
|
||||
- Keep your `master.key` secure and out of version control
|
||||
- Regularly update dependencies
|
||||
|
||||
## Deployment
|
||||
|
||||
Recommended platforms:
|
||||
- Railway.app (We use this)i98-i
|
||||
- Heroku
|
||||
- DigitalOcean App Platform
|
||||
|
||||
Deployment steps:
|
||||
1. Precompile assets: `rails assets:precompile`
|
||||
2. Run migrations: `rails db:migrate`
|
||||
3. Start the server with a production-ready web server like Puma
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Ensure all credentials are correctly set
|
||||
- Check PostgreSQL and Redis connections
|
||||
- Verify AWS S3 bucket permissions
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the GNU General Public License v3.0 (GPL-3.0-only) with additional non-commercial restrictions.
|
||||
|
||||
Key points:
|
||||
- You are free to use and modify the software for non-commercial purposes
|
||||
- Any modifications must be shared under the same license
|
||||
- You must provide attribution to the original authors
|
||||
- No warranty is provided
|
||||
|
||||
See the LICENSE file for full details.
|
||||
|
||||
## Contact
|
||||
|
||||
For support, please open an issue on the GitHub repository.
|
||||
* ...
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -3,77 +3,72 @@
|
|||
module Api
|
||||
module V1
|
||||
class CharacterBlueprint < ApiBlueprint
|
||||
field :name do |c|
|
||||
field :name do |w|
|
||||
{
|
||||
en: c.name_en,
|
||||
ja: c.name_jp
|
||||
en: w.name_en,
|
||||
ja: w.name_jp
|
||||
}
|
||||
end
|
||||
|
||||
fields :granblue_id, :character_id, :rarity,
|
||||
:element, :gender, :special
|
||||
|
||||
field :uncap do |c|
|
||||
field :uncap do |w|
|
||||
{
|
||||
flb: c.flb,
|
||||
ulb: c.ulb
|
||||
flb: w.flb,
|
||||
ulb: w.ulb
|
||||
}
|
||||
end
|
||||
|
||||
field :race do |c|
|
||||
[c.race1, c.race2].compact
|
||||
field :hp do |w|
|
||||
{
|
||||
min_hp: w.min_hp,
|
||||
max_hp: w.max_hp,
|
||||
max_hp_flb: w.max_hp_flb
|
||||
}
|
||||
end
|
||||
|
||||
field :proficiency do |c|
|
||||
[c.proficiency1, c.proficiency2].compact
|
||||
field :atk do |w|
|
||||
{
|
||||
min_atk: w.min_atk,
|
||||
max_atk: w.max_atk,
|
||||
max_atk_flb: w.max_atk_flb
|
||||
}
|
||||
end
|
||||
|
||||
view :full do
|
||||
include_view :stats
|
||||
include_view :rates
|
||||
include_view :dates
|
||||
field :race do |w|
|
||||
[
|
||||
w.race1,
|
||||
w.race2
|
||||
]
|
||||
end
|
||||
|
||||
field :awakenings do
|
||||
Character::AWAKENINGS.map do |awakening|
|
||||
AwakeningBlueprint.render_as_hash(OpenStruct.new(awakening))
|
||||
end
|
||||
field :proficiency do |w|
|
||||
[
|
||||
w.proficiency1,
|
||||
w.proficiency2
|
||||
]
|
||||
end
|
||||
|
||||
field :data do |w|
|
||||
{
|
||||
base_da: w.base_da,
|
||||
base_ta: w.base_ta
|
||||
}
|
||||
end
|
||||
|
||||
field :ougi_ratio do |w|
|
||||
{
|
||||
ougi_ratio: w.ougi_ratio,
|
||||
ougi_ratio_flb: w.ougi_ratio_flb
|
||||
}
|
||||
end
|
||||
|
||||
field :awakenings do
|
||||
Awakening.where(object_type: 'Character').map do |a|
|
||||
AwakeningBlueprint.render_as_hash(a)
|
||||
end
|
||||
end
|
||||
|
||||
view :stats do
|
||||
field :hp do |c|
|
||||
{
|
||||
min_hp: c.min_hp,
|
||||
max_hp: c.max_hp,
|
||||
max_hp_flb: c.max_hp_flb
|
||||
}
|
||||
end
|
||||
|
||||
field :atk do |c|
|
||||
{
|
||||
min_atk: c.min_atk,
|
||||
max_atk: c.max_atk,
|
||||
max_atk_flb: c.max_atk_flb
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
view :rates do
|
||||
fields :base_da, :base_ta
|
||||
|
||||
field :ougi_ratio do |c|
|
||||
{
|
||||
ougi_ratio: c.ougi_ratio,
|
||||
ougi_ratio_flb: c.ougi_ratio_flb
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
view :dates do
|
||||
field :release_date
|
||||
field :flb_date
|
||||
field :ulb_date
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,64 +3,57 @@
|
|||
module Api
|
||||
module V1
|
||||
class GridCharacterBlueprint < ApiBlueprint
|
||||
fields :position, :uncap_level, :perpetuity
|
||||
|
||||
field :transcendence_step, if: ->(_field, gc, _options) { gc.character&.ulb } do |gc|
|
||||
gc.transcendence_step
|
||||
end
|
||||
|
||||
view :preview do
|
||||
association :character, name: :object, blueprint: CharacterBlueprint
|
||||
view :uncap do
|
||||
association :party, blueprint: PartyBlueprint, view: :minimal
|
||||
fields :position, :uncap_level
|
||||
end
|
||||
|
||||
view :nested do
|
||||
include_view :mastery_bonuses
|
||||
association :character, name: :object, blueprint: CharacterBlueprint, view: :full
|
||||
fields :position, :uncap_level, :perpetuity
|
||||
|
||||
field :transcendence_step, if: lambda { |_fn, obj, _opt|
|
||||
obj.character.ulb
|
||||
} do |c|
|
||||
c.transcendence_step
|
||||
end
|
||||
|
||||
field :awakening do |c|
|
||||
{
|
||||
type: AwakeningBlueprint.render_as_hash(c.awakening),
|
||||
level: c.awakening_level
|
||||
}
|
||||
end
|
||||
|
||||
field :over_mastery, if: lambda { |_fn, obj, _opt|
|
||||
!obj.ring1['modifier'].nil? && !obj.ring2['modifier'].nil?
|
||||
} do |c|
|
||||
rings = []
|
||||
|
||||
rings.push(c.ring1) unless c.ring1['modifier'].nil?
|
||||
rings.push(c.ring2) unless c.ring2['modifier'].nil?
|
||||
rings.push(c.ring3) unless c.ring3['modifier'].nil?
|
||||
rings.push(c.ring4) unless c.ring4['modifier'].nil?
|
||||
|
||||
rings
|
||||
end
|
||||
|
||||
field :aetherial_mastery, if: lambda { |_fn, obj, _opt|
|
||||
!obj.earring['modifier'].nil?
|
||||
} do |c|
|
||||
c.earring
|
||||
end
|
||||
|
||||
association :character, name: :object, blueprint: CharacterBlueprint
|
||||
end
|
||||
|
||||
view :uncap do
|
||||
association :party, blueprint: PartyBlueprint
|
||||
fields :position, :uncap_level
|
||||
view :full do
|
||||
include_view :nested
|
||||
association :party, blueprint: PartyBlueprint, view: :minimal
|
||||
end
|
||||
|
||||
view :destroyed do
|
||||
fields :position, :created_at, :updated_at
|
||||
end
|
||||
|
||||
view :mastery_bonuses do
|
||||
field :awakening, if: ->(_field_name, gc, _options) { gc.association(:awakening).loaded? } do |gc|
|
||||
{
|
||||
type: AwakeningBlueprint.render_as_hash(gc.awakening),
|
||||
level: gc.awakening_level.to_i
|
||||
}
|
||||
end
|
||||
|
||||
field :over_mastery, if: lambda { |_fn, obj, _opt|
|
||||
obj.ring1.present? && obj.ring2.present? && !obj.ring1['modifier'].nil? && !obj.ring2['modifier'].nil?
|
||||
} do |c|
|
||||
mapped_rings = [c.ring1, c.ring2, c.ring3, c.ring4].each_with_object([]) do |ring, arr|
|
||||
# Skip if the ring is nil or its modifier is blank.
|
||||
next if ring.blank? || ring['modifier'].blank?
|
||||
|
||||
# Convert the string values to numbers.
|
||||
mod = ring['modifier'].to_i
|
||||
|
||||
# Only include if modifier is non-zero.
|
||||
next if mod.zero?
|
||||
|
||||
arr << { modifier: mod, strength: ring['strength'].to_i }
|
||||
end
|
||||
|
||||
mapped_rings
|
||||
end
|
||||
|
||||
field :aetherial_mastery, if: ->(_fn, obj, _opt) { obj.earring.present? && !obj.earring['modifier'].nil? } do |gc, _options|
|
||||
{
|
||||
modifier: gc.earring['modifier'].to_i,
|
||||
strength: gc.earring['strength'].to_i
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,24 +3,19 @@
|
|||
module Api
|
||||
module V1
|
||||
class GridSummonBlueprint < ApiBlueprint
|
||||
fields :main, :friend, :position, :quick_summon, :uncap_level, :transcendence_step
|
||||
|
||||
view :preview do
|
||||
association :summon, name: :object, blueprint: SummonBlueprint
|
||||
view :uncap do
|
||||
association :party, blueprint: PartyBlueprint, view: :minimal
|
||||
fields :position, :uncap_level, :transcendence_step
|
||||
end
|
||||
|
||||
view :nested do
|
||||
association :summon, name: :object, blueprint: SummonBlueprint, view: :full
|
||||
fields :main, :friend, :position, :quick_summon, :uncap_level, :transcendence_step
|
||||
association :summon, name: :object, blueprint: SummonBlueprint
|
||||
end
|
||||
|
||||
view :full do
|
||||
include_view :nested
|
||||
association :party, blueprint: PartyBlueprint
|
||||
end
|
||||
|
||||
view :uncap do
|
||||
association :party, blueprint: PartyBlueprint
|
||||
fields :position, :uncap_level, :transcendence_step
|
||||
association :party, blueprint: PartyBlueprint, view: :minimal
|
||||
end
|
||||
|
||||
view :destroyed do
|
||||
|
|
|
|||
|
|
@ -3,47 +3,45 @@
|
|||
module Api
|
||||
module V1
|
||||
class GridWeaponBlueprint < ApiBlueprint
|
||||
fields :mainhand, :position, :uncap_level, :transcendence_step, :element
|
||||
|
||||
view :preview do
|
||||
association :weapon, name: :object, blueprint: WeaponBlueprint
|
||||
view :uncap do
|
||||
association :party, blueprint: PartyBlueprint, view: :minimal
|
||||
fields :position, :uncap_level
|
||||
end
|
||||
|
||||
view :nested do
|
||||
field :ax, if: ->(_field_name, w, _options) { w.weapon.present? && w.weapon.ax } do |w|
|
||||
[
|
||||
{ modifier: w.ax_modifier1, strength: w.ax_strength1 },
|
||||
{ modifier: w.ax_modifier2, strength: w.ax_strength2 }
|
||||
]
|
||||
end
|
||||
|
||||
field :awakening, if: ->(_field_name, w, _options) { w.awakening.present? } do |w|
|
||||
{
|
||||
type: AwakeningBlueprint.render_as_hash(w.awakening),
|
||||
level: w.awakening_level
|
||||
}
|
||||
end
|
||||
|
||||
association :weapon, name: :object, blueprint: WeaponBlueprint, view: :full,
|
||||
if: ->(_field_name, w, _options) { w.weapon.present? }
|
||||
fields :mainhand, :position, :uncap_level, :element
|
||||
association :weapon, name: :object, blueprint: WeaponBlueprint
|
||||
|
||||
association :weapon_keys,
|
||||
blueprint: WeaponKeyBlueprint,
|
||||
if: ->(_field_name, w, _options) {
|
||||
w.weapon.present? &&
|
||||
w.weapon.series.present? &&
|
||||
[2, 3, 17, 24, 34].include?(w.weapon.series)
|
||||
if: lambda { |_field_name, w, _options|
|
||||
[2, 3, 17, 24, 34].include?(w.weapon.series)
|
||||
}
|
||||
|
||||
field :ax, if: ->(_field_name, w, _options) { w.weapon.ax } do |w|
|
||||
[
|
||||
{
|
||||
modifier: w.ax_modifier1,
|
||||
strength: w.ax_strength1
|
||||
},
|
||||
{
|
||||
modifier: w.ax_modifier2,
|
||||
strength: w.ax_strength2
|
||||
}
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
field :awakening, if: ->(_field_name, w, _options) { w.awakening_id } do |w|
|
||||
{
|
||||
type: AwakeningBlueprint.render_as_hash(w.awakening),
|
||||
level: w.awakening_level
|
||||
}
|
||||
end
|
||||
|
||||
view :full do
|
||||
include_view :nested
|
||||
association :party, blueprint: PartyBlueprint
|
||||
end
|
||||
|
||||
view :uncap do
|
||||
association :party, blueprint: PartyBlueprint
|
||||
fields :position, :uncap_level
|
||||
association :party, blueprint: PartyBlueprint, view: :minimal
|
||||
end
|
||||
|
||||
view :destroyed do
|
||||
|
|
|
|||
|
|
@ -3,141 +3,105 @@
|
|||
module Api
|
||||
module V1
|
||||
class PartyBlueprint < ApiBlueprint
|
||||
# Base fields that are always needed
|
||||
fields :local_id, :description, :shortcode, :visibility,
|
||||
:name, :element, :extra, :charge_attack,
|
||||
:button_count, :turn_count, :chain_count, :clear_time,
|
||||
:full_auto, :auto_guard, :auto_summon,
|
||||
:created_at, :updated_at
|
||||
|
||||
fields :local_id, :description, :charge_attack,
|
||||
:button_count, :turn_count, :chain_count,
|
||||
:master_level, :ultimate_mastery
|
||||
|
||||
# Party associations
|
||||
association :user,
|
||||
blueprint: UserBlueprint,
|
||||
view: :minimal
|
||||
|
||||
association :job,
|
||||
blueprint: JobBlueprint
|
||||
|
||||
association :raid,
|
||||
blueprint: RaidBlueprint,
|
||||
view: :nested
|
||||
|
||||
# Metadata associations
|
||||
field :favorited do |party, options|
|
||||
party.favorited?(options[:current_user])
|
||||
view :weapons do
|
||||
association :weapons,
|
||||
blueprint: GridWeaponBlueprint,
|
||||
view: :nested
|
||||
end
|
||||
|
||||
view :summons do
|
||||
association :summons,
|
||||
blueprint: GridSummonBlueprint,
|
||||
view: :nested
|
||||
end
|
||||
|
||||
view :characters do
|
||||
association :characters,
|
||||
blueprint: GridCharacterBlueprint,
|
||||
view: :nested
|
||||
end
|
||||
|
||||
view :job_skills do
|
||||
field :job_skills do |job|
|
||||
{
|
||||
'0' => !job.skill0.nil? ? JobSkillBlueprint.render_as_hash(job.skill0) : nil,
|
||||
'1' => !job.skill1.nil? ? JobSkillBlueprint.render_as_hash(job.skill1) : nil,
|
||||
'2' => !job.skill2.nil? ? JobSkillBlueprint.render_as_hash(job.skill2) : nil,
|
||||
'3' => !job.skill3.nil? ? JobSkillBlueprint.render_as_hash(job.skill3) : nil
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
view :minimal do
|
||||
fields :name, :element, :shortcode, :favorited, :remix,
|
||||
:extra, :full_auto, :clear_time, :auto_guard, :auto_summon,
|
||||
:visibility, :created_at, :updated_at
|
||||
|
||||
field :guidebooks do |p|
|
||||
{
|
||||
'1' => !p.guidebook1.nil? ? GuidebookBlueprint.render_as_hash(p.guidebook1) : nil,
|
||||
'2' => !p.guidebook2.nil? ? GuidebookBlueprint.render_as_hash(p.guidebook2) : nil,
|
||||
'3' => !p.guidebook3.nil? ? GuidebookBlueprint.render_as_hash(p.guidebook3) : nil
|
||||
}
|
||||
end
|
||||
|
||||
association :raid,
|
||||
blueprint: RaidBlueprint,
|
||||
view: :full
|
||||
|
||||
association :job,
|
||||
blueprint: JobBlueprint
|
||||
|
||||
association :user,
|
||||
blueprint: UserBlueprint,
|
||||
view: :minimal
|
||||
end
|
||||
|
||||
view :jobs do
|
||||
association :job,
|
||||
blueprint: JobBlueprint
|
||||
include_view :job_skills
|
||||
end
|
||||
|
||||
# For collection views
|
||||
view :preview do
|
||||
include_view :preview_objects # Characters, Weapons, Summons
|
||||
include_view :preview_metadata # Object counts
|
||||
include_view :minimal
|
||||
include_view :characters
|
||||
include_view :weapons
|
||||
include_view :summons
|
||||
end
|
||||
|
||||
# For object views
|
||||
view :full do
|
||||
# Primary object associations
|
||||
include_view :nested_objects # Characters, Weapons, Summons
|
||||
include_view :remix_metadata # Remixes, Source party
|
||||
include_view :job_metadata # Accessory, Skills, Guidebooks
|
||||
end
|
||||
include_view :preview
|
||||
include_view :summons
|
||||
include_view :characters
|
||||
include_view :job_skills
|
||||
|
||||
# Primary object associations
|
||||
view :preview_objects do
|
||||
association :characters,
|
||||
blueprint: GridCharacterBlueprint,
|
||||
view: :preview
|
||||
|
||||
association :weapons,
|
||||
blueprint: GridWeaponBlueprint,
|
||||
view: :preview
|
||||
|
||||
association :summons,
|
||||
blueprint: GridSummonBlueprint,
|
||||
view: :preview
|
||||
end
|
||||
|
||||
view :nested_objects do
|
||||
association :characters,
|
||||
blueprint: GridCharacterBlueprint,
|
||||
view: :nested
|
||||
|
||||
association :weapons,
|
||||
blueprint: GridWeaponBlueprint,
|
||||
view: :nested
|
||||
|
||||
association :summons,
|
||||
blueprint: GridSummonBlueprint,
|
||||
view: :nested
|
||||
end
|
||||
|
||||
# Metadata views
|
||||
view :preview_metadata do
|
||||
field :counts do |party|
|
||||
{
|
||||
weapons: party.weapons_count,
|
||||
characters: party.characters_count,
|
||||
summons: party.summons_count
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
view :source_party do
|
||||
association :source_party,
|
||||
blueprint: PartyBlueprint,
|
||||
view: :preview,
|
||||
if: ->(_field_name, party, _options) { party.source_party_id.present? }
|
||||
end
|
||||
|
||||
view :remix_metadata do
|
||||
include_view :source_party
|
||||
|
||||
# Re-added remixes association
|
||||
association :remixes,
|
||||
blueprint: PartyBlueprint,
|
||||
view: :preview
|
||||
end
|
||||
|
||||
# Job-related views
|
||||
view :job_metadata do
|
||||
field :job_skills, cache: true do |party|
|
||||
{
|
||||
'0' => party.skill0 ? JobSkillBlueprint.render_as_hash(party.skill0) : nil,
|
||||
'1' => party.skill1 ? JobSkillBlueprint.render_as_hash(party.skill1) : nil,
|
||||
'2' => party.skill2 ? JobSkillBlueprint.render_as_hash(party.skill2) : nil,
|
||||
'3' => party.skill3 ? JobSkillBlueprint.render_as_hash(party.skill3) : nil
|
||||
}
|
||||
end
|
||||
|
||||
field :guidebooks, cache: true do |party|
|
||||
{
|
||||
'1' => party.guidebook1 ? GuidebookBlueprint.render_as_hash(party.guidebook1) : nil,
|
||||
'2' => party.guidebook2 ? GuidebookBlueprint.render_as_hash(party.guidebook2) : nil,
|
||||
'3' => party.guidebook3 ? GuidebookBlueprint.render_as_hash(party.guidebook3) : nil
|
||||
}
|
||||
end
|
||||
fields :local_id, :description, :charge_attack,
|
||||
:button_count, :turn_count, :chain_count,
|
||||
:master_level, :ultimate_mastery
|
||||
|
||||
association :accessory,
|
||||
blueprint: JobAccessoryBlueprint,
|
||||
if: ->(_field_name, party, _options) { party.accessory_id.present? }
|
||||
blueprint: JobAccessoryBlueprint
|
||||
|
||||
association :source_party,
|
||||
blueprint: PartyBlueprint,
|
||||
view: :minimal
|
||||
|
||||
# TODO: This should probably be paginated
|
||||
association :remixes,
|
||||
blueprint: PartyBlueprint,
|
||||
view: :collection
|
||||
end
|
||||
|
||||
view :collection do
|
||||
include_view :preview
|
||||
end
|
||||
|
||||
# Created view
|
||||
view :created do
|
||||
include_view :full
|
||||
fields :edit_key
|
||||
end
|
||||
|
||||
# Remixed view
|
||||
view :remixed do
|
||||
include_view :created
|
||||
include_view :source_party
|
||||
end
|
||||
|
||||
# Destroyed view
|
||||
view :destroyed do
|
||||
fields :name, :description, :created_at, :updated_at
|
||||
end
|
||||
|
|
|
|||
|
|
@ -12,8 +12,6 @@ module Api
|
|||
end
|
||||
|
||||
fields :slug, :level, :element
|
||||
|
||||
association :group, blueprint: RaidGroupBlueprint, view: :flat
|
||||
end
|
||||
|
||||
view :full do
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ module Api
|
|||
|
||||
view :full do
|
||||
include_view :flat
|
||||
association :raids, blueprint: RaidBlueprint, view: :nested
|
||||
association :raids, blueprint: RaidBlueprint, view: :full
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,55 +3,41 @@
|
|||
module Api
|
||||
module V1
|
||||
class SummonBlueprint < ApiBlueprint
|
||||
field :name do |s|
|
||||
field :name do |w|
|
||||
{
|
||||
en: s.name_en,
|
||||
ja: s.name_jp
|
||||
en: w.name_en,
|
||||
ja: w.name_jp
|
||||
}
|
||||
end
|
||||
|
||||
fields :granblue_id, :element, :rarity, :max_level
|
||||
|
||||
field :uncap do |s|
|
||||
field :uncap do |w|
|
||||
{
|
||||
flb: s.flb,
|
||||
ulb: s.ulb,
|
||||
transcendence: s.transcendence
|
||||
flb: w.flb,
|
||||
ulb: w.ulb,
|
||||
xlb: w.xlb
|
||||
}
|
||||
end
|
||||
|
||||
view :stats do
|
||||
field :hp do |s|
|
||||
{
|
||||
min_hp: s.min_hp,
|
||||
max_hp: s.max_hp,
|
||||
max_hp_flb: s.max_hp_flb,
|
||||
max_hp_ulb: s.max_hp_ulb,
|
||||
max_hp_xlb: s.max_hp_xlb
|
||||
}
|
||||
end
|
||||
|
||||
field :atk do |s|
|
||||
{
|
||||
min_atk: s.min_atk,
|
||||
max_atk: s.max_atk,
|
||||
max_atk_flb: s.max_atk_flb,
|
||||
max_atk_ulb: s.max_atk_ulb,
|
||||
max_atk_xlb: s.max_atk_xlb
|
||||
}
|
||||
end
|
||||
field :hp do |w|
|
||||
{
|
||||
min_hp: w.min_hp,
|
||||
max_hp: w.max_hp,
|
||||
max_hp_flb: w.max_hp_flb,
|
||||
max_hp_ulb: w.max_hp_ulb,
|
||||
max_hp_xlb: w.max_hp_xlb
|
||||
}
|
||||
end
|
||||
|
||||
view :dates do
|
||||
field :release_date
|
||||
field :flb_date
|
||||
field :ulb_date
|
||||
field :transcendence_date
|
||||
end
|
||||
|
||||
view :full do
|
||||
include_view :stats
|
||||
include_view :dates
|
||||
field :atk do |w|
|
||||
{
|
||||
min_atk: w.min_atk,
|
||||
max_atk: w.max_atk,
|
||||
max_atk_flb: w.max_atk_flb,
|
||||
max_atk_ulb: w.max_atk_ulb,
|
||||
max_atk_xlb: w.max_atk_xlb
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -10,53 +10,39 @@ module Api
|
|||
}
|
||||
end
|
||||
|
||||
# Primary information
|
||||
fields :granblue_id, :element, :proficiency,
|
||||
:max_level, :max_skill_level, :max_awakening_level, :limit, :rarity,
|
||||
:series, :ax, :ax_type
|
||||
|
||||
# Uncap information
|
||||
field :uncap do |w|
|
||||
{
|
||||
flb: w.flb,
|
||||
ulb: w.ulb,
|
||||
transcendence: w.transcendence
|
||||
ulb: w.ulb
|
||||
}
|
||||
end
|
||||
|
||||
view :stats do
|
||||
field :hp do |w|
|
||||
{
|
||||
min_hp: w.min_hp,
|
||||
max_hp: w.max_hp,
|
||||
max_hp_flb: w.max_hp_flb,
|
||||
max_hp_ulb: w.max_hp_ulb
|
||||
}
|
||||
end
|
||||
|
||||
field :atk do |w|
|
||||
{
|
||||
min_atk: w.min_atk,
|
||||
max_atk: w.max_atk,
|
||||
max_atk_flb: w.max_atk_flb,
|
||||
max_atk_ulb: w.max_atk_ulb
|
||||
}
|
||||
end
|
||||
field :hp do |w|
|
||||
{
|
||||
min_hp: w.min_hp,
|
||||
max_hp: w.max_hp,
|
||||
max_hp_flb: w.max_hp_flb,
|
||||
max_hp_ulb: w.max_hp_ulb
|
||||
}
|
||||
end
|
||||
|
||||
view :dates do
|
||||
field :release_date
|
||||
field :flb_date
|
||||
field :ulb_date
|
||||
field :transcendence_date
|
||||
field :atk do |w|
|
||||
{
|
||||
min_atk: w.min_atk,
|
||||
max_atk: w.max_atk,
|
||||
max_atk_flb: w.max_atk_flb,
|
||||
max_atk_ulb: w.max_atk_ulb
|
||||
}
|
||||
end
|
||||
|
||||
view :full do
|
||||
include_view :stats
|
||||
include_view :dates
|
||||
association :awakenings,
|
||||
blueprint: AwakeningBlueprint,
|
||||
if: ->(_field_name, weapon, _options) { weapon.awakenings.any? }
|
||||
field :awakenings, if: lambda { |_fn, obj, _opt| obj.awakenings.length.positive? } do |w|
|
||||
w.awakenings.map do |a|
|
||||
AwakeningBlueprint.render_as_hash(a)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ module Api
|
|||
##### Hooks
|
||||
before_action :current_user
|
||||
before_action :default_content_type
|
||||
around_action :n_plus_one_detection, unless: -> { Rails.env.production? }
|
||||
|
||||
##### Responders
|
||||
respond_to :json
|
||||
|
|
@ -86,7 +85,7 @@ module Api
|
|||
end
|
||||
|
||||
def render_unprocessable_entity_response(exception)
|
||||
render json: ErrorBlueprint.render_as_json(nil, errors: exception.to_hash),
|
||||
render json: ErrorBlueprint.render_as_json(nil, exception: exception),
|
||||
status: :unprocessable_entity
|
||||
end
|
||||
|
||||
|
|
@ -105,9 +104,9 @@ module Api
|
|||
|
||||
def render_not_found_response(object)
|
||||
render json: ErrorBlueprint.render(nil, error: {
|
||||
message: "#{object.capitalize} could not be found",
|
||||
code: 'not_found'
|
||||
}), status: :not_found
|
||||
message: "#{object.capitalize} could not be found",
|
||||
code: 'not_found'
|
||||
}), status: :not_found
|
||||
end
|
||||
|
||||
def render_unauthorized_response
|
||||
|
|
@ -120,13 +119,6 @@ module Api
|
|||
def restrict_access
|
||||
raise UnauthorizedError unless current_user
|
||||
end
|
||||
|
||||
def n_plus_one_detection
|
||||
Prosopite.scan
|
||||
yield
|
||||
ensure
|
||||
Prosopite.finish
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,115 +2,68 @@
|
|||
|
||||
module Api
|
||||
module V1
|
||||
##
|
||||
# Controller handling API requests related to grid characters within a party.
|
||||
#
|
||||
# This controller provides endpoints for creating, updating, resolving conflicts,
|
||||
# updating uncap levels, and deleting grid characters. It follows the structure of
|
||||
# GridSummonsController and GridWeaponsController by using the new authorization method
|
||||
# `authorize_party_edit!` and deprecating legacy methods such as `set` in favor of
|
||||
# `find_party`, `find_grid_character`, and `find_incoming_character`.
|
||||
#
|
||||
# @see Api::V1::ApiController for shared API behavior.
|
||||
class GridCharactersController < Api::V1::ApiController
|
||||
before_action :find_grid_character, only: %i[update update_uncap_level destroy resolve]
|
||||
before_action :find_party, only: %i[create resolve update update_uncap_level destroy]
|
||||
attr_reader :party, :incoming_character, :current_characters
|
||||
|
||||
before_action :find_party, only: :create
|
||||
before_action :set, only: %i[update destroy]
|
||||
before_action :authorize, only: %i[create update destroy]
|
||||
before_action :find_incoming_character, only: :create
|
||||
before_action :authorize_party_edit!, only: %i[create resolve update update_uncap_level destroy]
|
||||
before_action :find_current_characters, only: :create
|
||||
|
||||
##
|
||||
# Creates a new grid character.
|
||||
#
|
||||
# If a conflicting grid character is found (i.e. one with the same character_id already exists
|
||||
# in the party), a conflict view is rendered so the user can decide on removal. Otherwise,
|
||||
# any grid character occupying the desired position is removed and a new one is created.
|
||||
#
|
||||
# @return [void]
|
||||
def create
|
||||
processed_params = transform_character_params(character_params)
|
||||
if !conflict_characters.nil? && conflict_characters.length.positive?
|
||||
# Render a template with the conflicting and incoming characters,
|
||||
# as well as the selected position, so the user can be presented with
|
||||
# a decision.
|
||||
|
||||
if conflict_characters.present?
|
||||
render json: render_conflict_view(conflict_characters, @incoming_character, character_params[:position])
|
||||
# Up to 3 characters can be removed at the same time
|
||||
conflict_view = render_conflict_view(conflict_characters, incoming_character, character_params[:position])
|
||||
render json: conflict_view
|
||||
else
|
||||
# Remove any existing grid character occupying the specified position.
|
||||
if (existing = GridCharacter.find_by(party_id: @party.id, position: character_params[:position]))
|
||||
existing.destroy
|
||||
# Destroy the grid character in the position if it is already filled
|
||||
if GridCharacter.where(party_id: party.id, position: character_params[:position]).exists?
|
||||
character = GridCharacter.where(party_id: party.id, position: character_params[:position]).limit(1)[0]
|
||||
character.destroy
|
||||
end
|
||||
|
||||
# Build the new grid character
|
||||
grid_character = build_new_grid_character(processed_params)
|
||||
# Then, create a new grid character
|
||||
character = GridCharacter.create!(character_params.merge(party_id: party.id,
|
||||
character_id: incoming_character.id))
|
||||
|
||||
if grid_character.save
|
||||
render json: GridCharacterBlueprint.render(grid_character,
|
||||
root: :grid_character,
|
||||
view: :nested), status: :created
|
||||
else
|
||||
render_validation_error_response(grid_character)
|
||||
if character.save!
|
||||
grid_character_view = render_grid_character_view(character)
|
||||
render json: grid_character_view, status: :created
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Updates an existing grid character.
|
||||
#
|
||||
# Assigns new rings and awakening data to their respective virtual attributes and updates other
|
||||
# permitted attributes. On success, the updated grid character view is rendered.
|
||||
#
|
||||
# @return [void]
|
||||
def update
|
||||
processed_params = transform_character_params(character_params)
|
||||
assign_raw_attributes(@grid_character)
|
||||
assign_transformed_attributes(@grid_character, processed_params)
|
||||
|
||||
if @grid_character.save
|
||||
render json: GridCharacterBlueprint.render(@grid_character,
|
||||
root: :grid_character,
|
||||
view: :nested)
|
||||
else
|
||||
render_validation_error_response(@grid_character)
|
||||
mastery = {}
|
||||
%i[ring1 ring2 ring3 ring4 earring awakening].each do |key|
|
||||
value = character_params.to_h[key]
|
||||
mastery[key] = value unless value.nil?
|
||||
end
|
||||
|
||||
@character.attributes = character_params.merge(mastery)
|
||||
|
||||
return render json: GridCharacterBlueprint.render(@character, view: :full) if @character.save
|
||||
|
||||
render_validation_error_response(@character)
|
||||
end
|
||||
|
||||
##
|
||||
# Updates the uncap level and transcendence step of a grid character.
|
||||
#
|
||||
# The grid character's uncap level and transcendence step are updated based on the provided parameters.
|
||||
# This action requires that the current user is authorized to modify the party.
|
||||
#
|
||||
# @return [void]
|
||||
def update_uncap_level
|
||||
@grid_character.uncap_level = character_params[:uncap_level]
|
||||
@grid_character.transcendence_step = character_params[:transcendence_step]
|
||||
|
||||
if @grid_character.save
|
||||
render json: GridCharacterBlueprint.render(@grid_character,
|
||||
root: :grid_character,
|
||||
view: :nested)
|
||||
else
|
||||
render_validation_error_response(@grid_character)
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Resolves conflicts for grid characters.
|
||||
#
|
||||
# This action destroys any conflicting grid characters as well as any grid character occupying
|
||||
# the target position, then creates a new grid character using a computed default uncap level.
|
||||
# The default uncap level is determined by the incoming character's attributes.
|
||||
#
|
||||
# @return [void]
|
||||
def resolve
|
||||
incoming = Character.find_by(id: resolve_params[:incoming])
|
||||
render_not_found_response('character') and return unless incoming
|
||||
incoming = Character.find(resolve_params[:incoming])
|
||||
conflicting = resolve_params[:conflicting].map { |id| GridCharacter.find(id) }
|
||||
party = conflicting.first.party
|
||||
|
||||
conflicting = resolve_params[:conflicting].map { |id| GridCharacter.find_by(id: id) }.compact
|
||||
conflicting.each(&:destroy)
|
||||
# Destroy each conflicting character
|
||||
conflicting.each { |character| GridCharacter.destroy(character.id) }
|
||||
|
||||
if (existing = GridCharacter.find_by(party_id: @party.id, position: resolve_params[:position]))
|
||||
existing.destroy
|
||||
end
|
||||
# Destroy the character at the desired position if it exists
|
||||
existing_character = GridCharacter.where(party: party.id, position: resolve_params[:position]).first
|
||||
GridCharacter.destroy(existing_character.id) if existing_character
|
||||
|
||||
# Compute the default uncap level based on the incoming character's flags.
|
||||
if incoming.special
|
||||
uncap_level = 3
|
||||
uncap_level = 5 if incoming.ulb
|
||||
|
|
@ -121,146 +74,33 @@ module Api
|
|||
uncap_level = 5 if incoming.flb
|
||||
end
|
||||
|
||||
grid_character = GridCharacter.create!(
|
||||
party_id: @party.id,
|
||||
character_id: incoming.id,
|
||||
position: resolve_params[:position],
|
||||
uncap_level: uncap_level
|
||||
)
|
||||
render json: GridCharacterBlueprint.render(grid_character,
|
||||
root: :grid_character,
|
||||
view: :nested), status: :created
|
||||
character = GridCharacter.create!(party_id: party.id, character_id: incoming.id,
|
||||
position: resolve_params[:position], uncap_level: uncap_level)
|
||||
render json: GridCharacterBlueprint.render(character, view: :nested), status: :created if character.save!
|
||||
end
|
||||
|
||||
##
|
||||
# Destroys a grid character.
|
||||
#
|
||||
# If the current user is not the owner of the party, an unauthorized response is rendered.
|
||||
# On successful destruction, the destroyed grid character view is rendered.
|
||||
#
|
||||
# @return [void]
|
||||
def update_uncap_level
|
||||
character = GridCharacter.find(character_params[:id])
|
||||
|
||||
render_unauthorized_response if current_user && (character.party.user != current_user)
|
||||
|
||||
character.uncap_level = character_params[:uncap_level]
|
||||
character.transcendence_step = character_params[:transcendence_step]
|
||||
return unless character.save!
|
||||
|
||||
render json: GridCharacterBlueprint.render(character, view: :nested, root: :grid_character)
|
||||
end
|
||||
|
||||
# TODO: Implement removing characters
|
||||
def destroy
|
||||
grid_character = GridCharacter.find_by('id = ?', params[:id])
|
||||
|
||||
return render_not_found_response('grid_character') if grid_character.nil?
|
||||
|
||||
render json: GridCharacterBlueprint.render(grid_character, view: :destroyed) if grid_character.destroy
|
||||
render_unauthorized_response if @character.party.user != current_user
|
||||
return render json: GridCharacterBlueprint.render(@character, view: :destroyed) if @character.destroy
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
##
|
||||
# Builds a new grid character using the transformed parameters.
|
||||
#
|
||||
# @param processed_params [Hash] the transformed parameters.
|
||||
# @return [GridCharacter] the newly built grid character.
|
||||
def build_new_grid_character(processed_params)
|
||||
grid_character = GridCharacter.new(
|
||||
character_params.except(:rings, :awakening).merge(
|
||||
party_id: @party.id,
|
||||
character_id: @incoming_character.id
|
||||
)
|
||||
)
|
||||
assign_transformed_attributes(grid_character, processed_params)
|
||||
assign_raw_attributes(grid_character)
|
||||
grid_character
|
||||
end
|
||||
|
||||
##
|
||||
# Assigns raw attributes from the original parameters to the grid character.
|
||||
#
|
||||
# These attributes (like new_rings and new_awakening) are used by model callbacks.
|
||||
#
|
||||
# @param grid_character [GridCharacter] the grid character instance.
|
||||
# @return [void]
|
||||
def assign_raw_attributes(grid_character)
|
||||
grid_character.new_rings = character_params[:rings] if character_params[:rings].present?
|
||||
grid_character.new_awakening = character_params[:awakening] if character_params[:awakening].present?
|
||||
grid_character.assign_attributes(character_params.except(:rings, :awakening))
|
||||
end
|
||||
|
||||
##
|
||||
# Assigns transformed attributes (such as uncap_level, transcendence_step, etc.) to the grid character.
|
||||
#
|
||||
# @param grid_character [GridCharacter] the grid character instance.
|
||||
# @param processed_params [Hash] the transformed parameters.
|
||||
# @return [void]
|
||||
def assign_transformed_attributes(grid_character, processed_params)
|
||||
grid_character.uncap_level = processed_params[:uncap_level] if processed_params[:uncap_level]
|
||||
grid_character.transcendence_step = processed_params[:transcendence_step] if processed_params[:transcendence_step]
|
||||
grid_character.perpetuity = processed_params[:perpetuity] if processed_params.key?(:perpetuity)
|
||||
grid_character.earring = processed_params[:earring] if processed_params[:earring]
|
||||
|
||||
return unless processed_params[:awakening_id]
|
||||
|
||||
grid_character.awakening_id = processed_params[:awakening_id]
|
||||
grid_character.awakening_level = processed_params[:awakening_level]
|
||||
end
|
||||
|
||||
##
|
||||
# Transforms the incoming character parameters to the required format.
|
||||
#
|
||||
# The frontend sends parameters in a raw format that need to be processed (e.g., converting string
|
||||
# values to integers, handling nested attributes for rings and awakening). This method extracts and
|
||||
# converts only the keys that were provided.
|
||||
#
|
||||
# @param raw_params [ActionController::Parameters] the raw permitted parameters.
|
||||
# @return [Hash] the transformed parameters.
|
||||
def transform_character_params(raw_params)
|
||||
# Convert to a symbolized hash for convenience.
|
||||
raw = raw_params.to_h.deep_symbolize_keys
|
||||
|
||||
# Only update keys that were provided.
|
||||
transformed = raw.slice(:uncap_level, :transcendence_step, :perpetuity)
|
||||
transformed[:uncap_level] = raw[:uncap_level] if raw[:uncap_level].present?
|
||||
transformed[:transcendence_step] = raw[:transcendence_step] if raw[:transcendence_step].present?
|
||||
|
||||
# Process rings if provided.
|
||||
transformed.merge!(transform_rings(raw[:rings])) if raw[:rings].present?
|
||||
|
||||
# Process earring if provided.
|
||||
transformed[:earring] = raw[:earring] if raw[:earring].present?
|
||||
|
||||
# Process awakening if provided.
|
||||
if raw[:awakening].present?
|
||||
transformed[:awakening_id] = raw[:awakening][:id]
|
||||
# Default to 1 if level is missing (to satisfy validations)
|
||||
transformed[:awakening_level] = raw[:awakening][:level].present? ? raw[:awakening][:level] : 1
|
||||
end
|
||||
|
||||
transformed
|
||||
end
|
||||
|
||||
##
|
||||
# Transforms the rings data to ensure exactly four rings are present.
|
||||
#
|
||||
# Pads the array with a default ring hash if necessary.
|
||||
#
|
||||
# @param rings [Array, Hash] the rings data from the frontend.
|
||||
# @return [Hash] a hash with keys :ring1, :ring2, :ring3, :ring4.
|
||||
def transform_rings(rings)
|
||||
default_ring = { modifier: nil, strength: nil }
|
||||
# Ensure rings is an array of hashes.
|
||||
rings_array = Array(rings).map(&:to_h)
|
||||
# Pad the array to exactly four rings if needed.
|
||||
rings_array.fill(default_ring, rings_array.size...4)
|
||||
{
|
||||
ring1: rings_array[0],
|
||||
ring2: rings_array[1],
|
||||
ring3: rings_array[2],
|
||||
ring4: rings_array[3]
|
||||
}
|
||||
end
|
||||
|
||||
##
|
||||
# Returns any grid characters in the party that conflict with the incoming character.
|
||||
#
|
||||
# Conflict is defined as any grid character already in the party with the same character_id as the
|
||||
# incoming character. This method is used to prompt the user for conflict resolution.
|
||||
#
|
||||
# @return [Array<GridCharacter>]
|
||||
def conflict_characters
|
||||
@party.characters.where(character_id: @incoming_character.id).to_a
|
||||
@conflict_characters ||= find_conflict_characters(incoming_character)
|
||||
end
|
||||
|
||||
def find_conflict_characters(incoming_character)
|
||||
|
|
@ -282,124 +122,52 @@ module Api
|
|||
end.flatten
|
||||
end
|
||||
|
||||
##
|
||||
# Finds and sets the party based on parameters.
|
||||
#
|
||||
# Checks for the party id in params[:character][:party_id], params[:party_id], or falls back to the party
|
||||
# associated with the current grid character. Renders a not found response if the party is missing.
|
||||
#
|
||||
# @return [void]
|
||||
def find_party
|
||||
@party = Party.find_by(id: params.dig(:character, :party_id)) ||
|
||||
Party.find_by(id: params[:party_id]) ||
|
||||
@grid_character&.party
|
||||
render_not_found_response('party') unless @party
|
||||
def set
|
||||
@character = GridCharacter.find(params[:id])
|
||||
end
|
||||
|
||||
##
|
||||
# Finds and sets the grid character based on the provided parameters.
|
||||
#
|
||||
# Searches for a grid character by its ID and renders a not found response if it is absent.
|
||||
#
|
||||
# @return [void]
|
||||
def find_grid_character
|
||||
grid_character_id = params[:id] || params.dig(:character, :id) || params.dig(:resolve, :conflicting)
|
||||
@grid_character = GridCharacter.includes(:awakening).find_by(id: grid_character_id)
|
||||
render_not_found_response('grid_character') unless @grid_character
|
||||
end
|
||||
|
||||
##
|
||||
# Finds and sets the incoming character based on the provided parameters.
|
||||
#
|
||||
# Searches for a character using the :character_id parameter and renders a not found response if it is absent.
|
||||
#
|
||||
# @return [void]
|
||||
def find_incoming_character
|
||||
@incoming_character = Character.find_by(id: character_params[:character_id])
|
||||
render_unprocessable_entity_response(Api::V1::NoCharacterProvidedError.new) unless @incoming_character
|
||||
@incoming_character = Character.find(character_params[:character_id])
|
||||
end
|
||||
|
||||
##
|
||||
# Authorizes the current action by ensuring that the current user or provided edit key
|
||||
# matches the party's owner.
|
||||
#
|
||||
# For parties associated with a user, it verifies that the current user is the owner.
|
||||
# For anonymous parties, it compares the provided edit key with the party's edit key.
|
||||
#
|
||||
# @return [void]
|
||||
def authorize_party_edit!
|
||||
if @party.user.present?
|
||||
authorize_user_party
|
||||
else
|
||||
authorize_anonymous_party
|
||||
end
|
||||
def find_party
|
||||
@party = Party.find(character_params[:party_id])
|
||||
render_unauthorized_response if current_user && (party.user != current_user)
|
||||
end
|
||||
|
||||
##
|
||||
# Authorizes an action for a party that belongs to a user.
|
||||
#
|
||||
# Renders an unauthorized response unless the current user is present and matches the party's user.
|
||||
#
|
||||
# @return [void]
|
||||
def authorize_user_party
|
||||
return if current_user.present? && @party.user == current_user
|
||||
def authorize
|
||||
# Create
|
||||
unauthorized_create = @party && (@party.user != current_user || @party.edit_key != edit_key)
|
||||
unauthorized_update = @character && @character.party && (@character.party.user != current_user || @character.party.edit_key != edit_key)
|
||||
|
||||
render_unauthorized_response
|
||||
render_unauthorized_response if unauthorized_create || unauthorized_update
|
||||
end
|
||||
|
||||
##
|
||||
# Authorizes an action for an anonymous party using an edit key.
|
||||
#
|
||||
# Compares the provided edit key with the party's edit key and renders an unauthorized response
|
||||
# if they do not match.
|
||||
#
|
||||
# @return [void]
|
||||
def authorize_anonymous_party
|
||||
provided_edit_key = edit_key.to_s.strip.force_encoding('UTF-8')
|
||||
party_edit_key = @party.edit_key.to_s.strip.force_encoding('UTF-8')
|
||||
return if valid_edit_key?(provided_edit_key, party_edit_key)
|
||||
|
||||
render_unauthorized_response
|
||||
end
|
||||
|
||||
##
|
||||
# Validates that the provided edit key matches the party's edit key.
|
||||
#
|
||||
# @param provided_edit_key [String] the edit key provided in the request.
|
||||
# @param party_edit_key [String] the edit key associated with the party.
|
||||
# @return [Boolean] true if the keys match; false otherwise.
|
||||
def valid_edit_key?(provided_edit_key, party_edit_key)
|
||||
provided_edit_key.present? &&
|
||||
provided_edit_key.bytesize == party_edit_key.bytesize &&
|
||||
ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key)
|
||||
end
|
||||
|
||||
##
|
||||
# Specifies and permits the allowed character parameters.
|
||||
#
|
||||
# @return [ActionController::Parameters] the permitted parameters.
|
||||
# Specify whitelisted properties that can be modified.
|
||||
def character_params
|
||||
params.require(:character).permit(
|
||||
:id,
|
||||
:party_id,
|
||||
:character_id,
|
||||
:position,
|
||||
:uncap_level,
|
||||
:transcendence_step,
|
||||
:perpetuity,
|
||||
awakening: %i[id level],
|
||||
rings: %i[modifier strength],
|
||||
earring: %i[modifier strength]
|
||||
)
|
||||
params.require(:character).permit(:id, :party_id, :character_id, :position,
|
||||
:uncap_level, :transcendence_step, :perpetuity,
|
||||
:awakening_id, :awakening_level,
|
||||
ring1: %i[modifier strength], ring2: %i[modifier strength],
|
||||
ring3: %i[modifier strength], ring4: %i[modifier strength],
|
||||
earring: %i[modifier strength])
|
||||
end
|
||||
|
||||
##
|
||||
# Specifies and permits the allowed resolve parameters.
|
||||
#
|
||||
# @return [ActionController::Parameters] the permitted parameters.
|
||||
def resolve_params
|
||||
params.require(:resolve).permit(:position, :incoming, conflicting: [])
|
||||
end
|
||||
|
||||
def render_conflict_view(conflict_characters, incoming_character, incoming_position)
|
||||
ConflictBlueprint.render(nil,
|
||||
view: :characters,
|
||||
conflict_characters: conflict_characters,
|
||||
incoming_character: incoming_character,
|
||||
incoming_position: incoming_position)
|
||||
end
|
||||
|
||||
def render_grid_character_view(grid_character)
|
||||
GridCharacterBlueprint.render(grid_character, view: :nested)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,142 +2,82 @@
|
|||
|
||||
module Api
|
||||
module V1
|
||||
##
|
||||
# Controller handling API requests related to grid summons within a party.
|
||||
#
|
||||
# This controller provides endpoints for creating, updating, resolving conflicts, and deleting grid summons.
|
||||
# It ensures that the correct party and summons are found and that the current user (or edit key) is authorized.
|
||||
#
|
||||
# @see Api::V1::ApiController for shared API behavior.
|
||||
class GridSummonsController < Api::V1::ApiController
|
||||
attr_reader :party, :incoming_summon
|
||||
|
||||
before_action :find_grid_summon, only: %i[update update_uncap_level update_quick_summon resolve destroy]
|
||||
before_action :find_party, only: %i[create update update_uncap_level update_quick_summon resolve destroy]
|
||||
before_action :set, only: %w[update update_uncap_level update_quick_summon]
|
||||
before_action :find_party, only: :create
|
||||
before_action :find_incoming_summon, only: :create
|
||||
before_action :authorize_party_edit!, only: %i[create update update_uncap_level update_quick_summon destroy]
|
||||
before_action :authorize, only: %i[create update update_uncap_level update_quick_summon destroy]
|
||||
|
||||
##
|
||||
# Creates a new grid summon.
|
||||
#
|
||||
# This method builds a new grid summon using the permitted parameters merged
|
||||
# with the party and summon IDs. It ensures that the `uncap_level` is set to the
|
||||
# maximum allowed level if not provided. Depending on validation, it will either save
|
||||
# the summon, handle conflict resolution, or render a validation error response.
|
||||
#
|
||||
# @return [void]
|
||||
def create
|
||||
# Build a new grid summon using permitted parameters merged with party and summon IDs.
|
||||
# Then, using `tap`, ensure that the uncap_level is set by using the max_uncap_level helper
|
||||
# if it hasn't already been provided.
|
||||
grid_summon = build_grid_summon.tap do |gs|
|
||||
gs.uncap_level ||= max_uncap_level(gs)
|
||||
end
|
||||
# Create the GridSummon with the desired parameters
|
||||
summon = GridSummon.new
|
||||
summon.attributes = summon_params.merge(party_id: party.id, summon_id: incoming_summon.id)
|
||||
summon.uncap_level = max_uncap_level(summon) if summon.uncap_level.nil?
|
||||
|
||||
# If the grid summon is valid (i.e. it passes all validations), then save it normally.
|
||||
if grid_summon.valid?
|
||||
save_summon(grid_summon)
|
||||
# If it is invalid due to a conflict error, handle the conflict resolution flow.
|
||||
elsif conflict_error?(grid_summon)
|
||||
handle_conflict(grid_summon)
|
||||
# If there's some other kind of validation error, render the validation error response back to the client.
|
||||
if summon.validate
|
||||
save_summon(summon)
|
||||
else
|
||||
render_validation_error_response(grid_summon)
|
||||
handle_conflict(summon)
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Updates an existing grid summon.
|
||||
#
|
||||
# Updates the grid summon attributes using permitted parameters. If the update is successful,
|
||||
# it renders the updated grid summon view; otherwise, it renders a validation error response.
|
||||
#
|
||||
# @return [void]
|
||||
def update
|
||||
@grid_summon.attributes = summon_params
|
||||
@summon.attributes = summon_params
|
||||
|
||||
return render json: GridSummonBlueprint.render(@grid_summon, view: :nested, root: :grid_summon) if @grid_summon.save
|
||||
return render json: GridSummonBlueprint.render(@summon, view: :nested, root: :grid_summon) if @summon.save
|
||||
|
||||
render_validation_error_response(@grid_summon)
|
||||
render_validation_error_response(@character)
|
||||
end
|
||||
|
||||
##
|
||||
# Updates the uncap level and transcendence step of a grid summon.
|
||||
#
|
||||
# This action recalculates the maximum allowed uncap level based on the summon attributes
|
||||
# and applies business logic to adjust the uncap level and transcendence step accordingly.
|
||||
# On success, it renders the updated grid summon view; otherwise, it renders a validation error response.
|
||||
#
|
||||
# @return [void]
|
||||
def update_uncap_level
|
||||
summon = @grid_summon.summon
|
||||
max_level = max_uncap_level(summon)
|
||||
summon = @summon.summon
|
||||
max_uncap_level = max_uncap_level(summon)
|
||||
|
||||
greater_than_max_uncap = summon_params[:uncap_level].to_i > max_level
|
||||
can_be_transcended = summon.transcendence &&
|
||||
summon_params[:transcendence_step].present? &&
|
||||
summon_params[:transcendence_step].to_i.positive?
|
||||
greater_than_max_uncap = summon_params[:uncap_level].to_i > max_uncap_level
|
||||
can_be_transcended = summon.xlb && summon_params[:transcendence_step] && summon_params[:transcendence_step]&.to_i&.positive?
|
||||
|
||||
new_uncap_level = greater_than_max_uncap || can_be_transcended ? max_level : summon_params[:uncap_level]
|
||||
new_transcendence_step = summon.transcendence && summon_params[:transcendence_step].present? ? summon_params[:transcendence_step] : 0
|
||||
uncap_level = if greater_than_max_uncap || can_be_transcended
|
||||
max_uncap_level
|
||||
else
|
||||
summon_params[:uncap_level]
|
||||
end
|
||||
|
||||
if @grid_summon.update(uncap_level: new_uncap_level, transcendence_step: new_transcendence_step)
|
||||
render json: GridSummonBlueprint.render(@grid_summon, view: :nested, root: :grid_summon)
|
||||
else
|
||||
render_validation_error_response(@grid_summon)
|
||||
end
|
||||
transcendence_step = if summon.xlb && summon_params[:transcendence_step]
|
||||
summon_params[:transcendence_step]
|
||||
else
|
||||
0
|
||||
end
|
||||
|
||||
@summon.update!(
|
||||
uncap_level: uncap_level,
|
||||
transcendence_step: transcendence_step
|
||||
)
|
||||
|
||||
return unless @summon.persisted?
|
||||
|
||||
render json: GridSummonBlueprint.render(@summon, view: :nested, root: :grid_summon)
|
||||
end
|
||||
|
||||
##
|
||||
# Updates the quick summon status for a grid summon.
|
||||
#
|
||||
# If the grid summon is in positions 4, 5, or 6, no update is performed.
|
||||
# Otherwise, it disables quick summon for all other summons in the party,
|
||||
# updates the current summon, and renders the updated list of summons.
|
||||
#
|
||||
# @return [void]
|
||||
def update_quick_summon
|
||||
return if [4, 5, 6].include?(@grid_summon.position)
|
||||
return if [4, 5, 6].include?(@summon.position)
|
||||
|
||||
quick_summons = @grid_summon.party.summons.select(&:quick_summon)
|
||||
quick_summons = @summon.party.summons.select(&:quick_summon)
|
||||
|
||||
quick_summons.each do |summon|
|
||||
summon.update!(quick_summon: false)
|
||||
end
|
||||
|
||||
@grid_summon.update!(quick_summon: summon_params[:quick_summon])
|
||||
return unless @grid_summon.persisted?
|
||||
@summon.update!(quick_summon: summon_params[:quick_summon])
|
||||
return unless @summon.persisted?
|
||||
|
||||
quick_summons -= [@grid_summon]
|
||||
summons = [@grid_summon] + quick_summons
|
||||
quick_summons -= [@summon]
|
||||
summons = [@summon] + quick_summons
|
||||
|
||||
render json: GridSummonBlueprint.render(summons, view: :nested, root: :summons)
|
||||
end
|
||||
|
||||
#
|
||||
# Destroys a grid summon.
|
||||
#
|
||||
# Finds the grid summon by ID. If not found, renders a not-found response.
|
||||
# If the current user is not authorized to perform the deletion, renders an unauthorized response.
|
||||
# On successful destruction, renders the destroyed grid summon view.
|
||||
#
|
||||
# @return [void]
|
||||
def destroy
|
||||
grid_summon = GridSummon.find_by('id = ?', params[:id])
|
||||
|
||||
return render_not_found_response('grid_summon') if grid_summon.nil?
|
||||
|
||||
render json: GridSummonBlueprint.render(grid_summon, view: :destroyed), status: :ok if grid_summon.destroy
|
||||
end
|
||||
|
||||
##
|
||||
# Saves the provided grid summon.
|
||||
#
|
||||
# If an existing grid summon is found at the specified position for the party, it is replaced.
|
||||
# On successful save, renders the grid summon view with a created status.
|
||||
#
|
||||
# @param summon [GridSummon] The grid summon instance to be saved.
|
||||
# @return [void]
|
||||
def save_summon(summon)
|
||||
if (grid_summon = GridSummon.where(
|
||||
party_id: party.id,
|
||||
|
|
@ -152,15 +92,6 @@ module Api
|
|||
render json: output, status: :created
|
||||
end
|
||||
|
||||
##
|
||||
# Handles conflict resolution for a grid summon.
|
||||
#
|
||||
# If a conflict is detected and the conflicting summon matches the incoming summon,
|
||||
# the method updates the conflicting summon’s position with the new position.
|
||||
# On a successful update, renders the updated grid summon view.
|
||||
#
|
||||
# @param summon [GridSummon] The grid summon instance that encountered a conflict.
|
||||
# @return [void]
|
||||
def handle_conflict(summon)
|
||||
conflict_summon = summon.conflicts(party)
|
||||
return unless conflict_summon.summon.id == incoming_summon.id
|
||||
|
|
@ -174,167 +105,55 @@ module Api
|
|||
render json: output
|
||||
end
|
||||
|
||||
def destroy
|
||||
summon = GridSummon.find_by('id = ?', params[:id])
|
||||
render_unauthorized_response if summon.party.user != current_user
|
||||
return render json: GridSummonBlueprint.render(summon, view: :destroyed) if summon.destroy
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
##
|
||||
# Finds the party based on the provided party_id parameter.
|
||||
#
|
||||
# Sets the @party instance variable and renders an unauthorized response if the current
|
||||
# user is not the owner of the party.
|
||||
#
|
||||
# @return [void]
|
||||
|
||||
##
|
||||
# Finds and sets the party based on parameters.
|
||||
#
|
||||
# Renders an unauthorized response if the current user is not the owner.
|
||||
#
|
||||
# @return [void]
|
||||
def find_party
|
||||
@party = Party.find_by(id: params.dig(:summon, :party_id)) || Party.find_by(id: params[:party_id]) || @grid_summon&.party
|
||||
render_not_found_response('party') unless @party
|
||||
end
|
||||
|
||||
##
|
||||
# Finds and sets the GridSummon based on the provided parameters.
|
||||
#
|
||||
# Searches for a grid summon using various parameter keys and renders a not found response if it is absent.
|
||||
#
|
||||
# @return [void]
|
||||
def find_grid_summon
|
||||
grid_summon_id = params[:id] || params.dig(:summon, :id) || params.dig(:resolve, :conflicting)
|
||||
@grid_summon = GridSummon.find_by(id: grid_summon_id)
|
||||
render_not_found_response('grid_summon') unless @grid_summon
|
||||
end
|
||||
|
||||
##
|
||||
# Finds the incoming summon based on the provided parameters.
|
||||
#
|
||||
# Sets the @incoming_summon instance variable.
|
||||
#
|
||||
# @return [void]
|
||||
def find_incoming_summon
|
||||
@incoming_summon = Summon.find_by(id: summon_params[:summon_id])
|
||||
end
|
||||
|
||||
##
|
||||
# Builds a new GridSummon instance using permitted parameters.
|
||||
#
|
||||
# Merges the party id and the incoming summon id into the parameters.
|
||||
#
|
||||
# @return [GridSummon] A new grid summon instance.
|
||||
def build_grid_summon
|
||||
GridSummon.new(summon_params.merge(party_id: party.id, summon_id: incoming_summon.id))
|
||||
end
|
||||
|
||||
##
|
||||
# Checks whether the grid summon error is solely due to a conflict.
|
||||
#
|
||||
# Verifies if the errors on the :series attribute include the specific conflict message
|
||||
# and confirms that a conflict exists for the current party.
|
||||
#
|
||||
# @param grid_summon [GridSummon] The grid summon instance to check.
|
||||
# @return [Boolean] True if the error is due solely to a conflict, false otherwise.
|
||||
def conflict_error?(grid_summon)
|
||||
grid_summon.errors[:series].include?('must not conflict with existing summons') &&
|
||||
grid_summon.conflicts(party).present?
|
||||
end
|
||||
|
||||
##
|
||||
# Renders the grid summon view with additional metadata.
|
||||
#
|
||||
# @param grid_summon [GridSummon] The grid summon instance to render.
|
||||
# @param conflict_position [Integer, nil] The position of a conflicting summon, if applicable.
|
||||
# @return [String] The rendered grid summon view as JSON.
|
||||
def render_grid_summon_view(grid_summon, conflict_position = nil)
|
||||
GridSummonBlueprint.render(grid_summon,
|
||||
view: :nested,
|
||||
root: :grid_summon,
|
||||
meta: { replaced: conflict_position })
|
||||
end
|
||||
|
||||
##
|
||||
# Determines the maximum uncap level for a given summon.
|
||||
#
|
||||
# The maximum uncap level is determined based on the attributes of the summon:
|
||||
# - Returns 4 if the summon has FLB but not ULB and is not transcended.
|
||||
# - Returns 5 if the summon has ULB and is not transcended.
|
||||
# - Returns 6 if the summon has transcendence.
|
||||
# - Otherwise, returns 3.
|
||||
#
|
||||
# @param summon [Summon] The summon for which to determine the maximum uncap level.
|
||||
# @return [Integer] The maximum uncap level.
|
||||
def max_uncap_level(summon)
|
||||
if summon.flb && !summon.ulb && !summon.transcendence
|
||||
if summon.flb && !summon.ulb && !summon.xlb
|
||||
4
|
||||
elsif summon.ulb && !summon.transcendence
|
||||
elsif summon.ulb && !summon.xlb
|
||||
5
|
||||
elsif summon.transcendence
|
||||
elsif summon.xlb
|
||||
6
|
||||
else
|
||||
3
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Authorizes the current action by ensuring that the current user or provided edit key matches the party's owner.
|
||||
#
|
||||
# For parties associated with a user, it verifies that the current_user is the owner.
|
||||
# For anonymous parties, it checks that the provided edit key matches the party's edit key.
|
||||
#
|
||||
# @return [void]
|
||||
def authorize_party_edit!
|
||||
if @party.user.present?
|
||||
authorize_user_party
|
||||
else
|
||||
authorize_anonymous_party
|
||||
end
|
||||
def find_incoming_summon
|
||||
@incoming_summon = Summon.find_by(id: summon_params[:summon_id])
|
||||
end
|
||||
|
||||
##
|
||||
# Authorizes an action for a party that belongs to a user.
|
||||
#
|
||||
# Renders an unauthorized response unless the current user is present and
|
||||
# matches the party's user.
|
||||
#
|
||||
# @return [void]
|
||||
def authorize_user_party
|
||||
return if current_user.present? && @party.user == current_user
|
||||
|
||||
render_unauthorized_response
|
||||
def find_party
|
||||
# BUG: I can create grid weapons even when I'm not logged in on an authenticated party
|
||||
@party = Party.find(summon_params[:party_id])
|
||||
render_unauthorized_response if current_user && (party.user != current_user)
|
||||
end
|
||||
|
||||
##
|
||||
# Authorizes an action for an anonymous party using an edit key.
|
||||
#
|
||||
# Retrieves and normalizes the provided edit key and compares it with the party's edit key.
|
||||
# Renders an unauthorized response unless the keys are valid.
|
||||
#
|
||||
# @return [void]
|
||||
def authorize_anonymous_party
|
||||
provided_edit_key = edit_key.to_s.strip.force_encoding('UTF-8')
|
||||
party_edit_key = @party.edit_key.to_s.strip.force_encoding('UTF-8')
|
||||
return if valid_edit_key?(provided_edit_key, party_edit_key)
|
||||
|
||||
render_unauthorized_response
|
||||
def render_grid_summon_view(grid_summon, conflict_position = nil)
|
||||
GridSummonBlueprint.render(grid_summon, view: :nested,
|
||||
root: :grid_summon,
|
||||
meta: { replaced: conflict_position })
|
||||
end
|
||||
|
||||
##
|
||||
# Validates that the provided edit key matches the party's edit key.
|
||||
#
|
||||
# @param provided_edit_key [String] the edit key provided in the request.
|
||||
# @param party_edit_key [String] the edit key associated with the party.
|
||||
# @return [Boolean] true if the edit keys match; false otherwise.
|
||||
def valid_edit_key?(provided_edit_key, party_edit_key)
|
||||
provided_edit_key.present? &&
|
||||
provided_edit_key.bytesize == party_edit_key.bytesize &&
|
||||
ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key)
|
||||
def authorize
|
||||
# Create
|
||||
unauthorized_create = @party && (@party.user != current_user || @party.edit_key != edit_key)
|
||||
unauthorized_update = @summon && @summon.party && (@summon.party.user != current_user || @summon.party.edit_key != edit_key)
|
||||
|
||||
render_unauthorized_response if unauthorized_create || unauthorized_update
|
||||
end
|
||||
|
||||
##
|
||||
# Defines and permits the whitelisted parameters for a grid summon.
|
||||
#
|
||||
# @return [ActionController::Parameters] The permitted parameters.
|
||||
def set
|
||||
@summon = GridSummon.find_by('id = ?', summon_params[:id])
|
||||
end
|
||||
|
||||
# Specify whitelisted properties that can be modified.
|
||||
def summon_params
|
||||
params.require(:summon).permit(:id, :party_id, :summon_id, :position, :main, :friend,
|
||||
:quick_summon, :uncap_level, :transcendence_step)
|
||||
|
|
|
|||
|
|
@ -2,375 +2,215 @@
|
|||
|
||||
module Api
|
||||
module V1
|
||||
##
|
||||
# Controller handling API requests related to grid weapons within a party.
|
||||
#
|
||||
# This controller provides endpoints for creating, updating, resolving conflicts, and deleting grid weapons.
|
||||
# It ensures that the correct party and weapon are found and that the current user (or edit key) is authorized.
|
||||
#
|
||||
# @see Api::V1::ApiController for shared API behavior.
|
||||
class GridWeaponsController < Api::V1::ApiController
|
||||
before_action :find_grid_weapon, only: %i[update update_uncap_level resolve destroy]
|
||||
before_action :find_party, only: %i[create update update_uncap_level resolve destroy]
|
||||
before_action :find_incoming_weapon, only: %i[create resolve]
|
||||
before_action :authorize_party_edit!, only: %i[create update update_uncap_level resolve destroy]
|
||||
attr_reader :party, :incoming_weapon
|
||||
|
||||
before_action :set, except: %w[create update_uncap_level]
|
||||
before_action :find_party, only: :create
|
||||
before_action :find_incoming_weapon, only: :create
|
||||
before_action :authorize, only: %i[create update destroy]
|
||||
|
||||
##
|
||||
# Creates a new GridWeapon.
|
||||
#
|
||||
# Builds a new GridWeapon using parameters merged with the party and weapon IDs.
|
||||
# If the model validations (including compatibility and conflict validations)
|
||||
# pass, the weapon is saved; otherwise, conflict resolution is attempted.
|
||||
#
|
||||
# @return [void]
|
||||
def create
|
||||
return render_unprocessable_entity_response(Api::V1::NoWeaponProvidedError.new) if @incoming_weapon.nil?
|
||||
# Create the GridWeapon with the desired parameters
|
||||
weapon = GridWeapon.new
|
||||
weapon.attributes = weapon_params.merge(party_id: party.id, weapon_id: incoming_weapon.id)
|
||||
|
||||
grid_weapon = GridWeapon.new(
|
||||
weapon_params.merge(
|
||||
party_id: @party.id,
|
||||
weapon_id: @incoming_weapon.id
|
||||
)
|
||||
)
|
||||
|
||||
if grid_weapon.valid?
|
||||
save_weapon(grid_weapon)
|
||||
if weapon.validate
|
||||
save_weapon(weapon)
|
||||
else
|
||||
if grid_weapon.errors[:series].include?('must not conflict with existing weapons')
|
||||
handle_conflict(grid_weapon)
|
||||
else
|
||||
render_validation_error_response(grid_weapon)
|
||||
end
|
||||
handle_conflict(weapon)
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Updates an existing GridWeapon.
|
||||
#
|
||||
# After checking authorization, assigns new attributes to the weapon.
|
||||
# Also normalizes modifier and strength fields, then renders the updated view on success.
|
||||
#
|
||||
# @return [void]
|
||||
def update
|
||||
normalize_ax_fields!
|
||||
if @grid_weapon.update(weapon_params)
|
||||
render json: GridWeaponBlueprint.render(@grid_weapon, view: :full, root: :grid_weapon), status: :ok
|
||||
else
|
||||
render_validation_error_response(@grid_weapon)
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Updates the uncap level and transcendence step of a GridWeapon.
|
||||
#
|
||||
# Finds the weapon to update, computes the maximum allowed uncap level based on its associated
|
||||
# weapon’s flags, and then updates the fields accordingly.
|
||||
#
|
||||
# @return [void]
|
||||
def update_uncap_level
|
||||
max_uncap = compute_max_uncap_level(@grid_weapon.weapon)
|
||||
requested_uncap = weapon_params[:uncap_level].to_i
|
||||
new_uncap = requested_uncap > max_uncap ? max_uncap : requested_uncap
|
||||
|
||||
if @grid_weapon.update(uncap_level: new_uncap, transcendence_step: weapon_params[:transcendence_step].to_i)
|
||||
render json: GridWeaponBlueprint.render(@grid_weapon, view: :full, root: :grid_weapon), status: :ok
|
||||
else
|
||||
render_validation_error_response(@grid_weapon)
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Resolves conflicts by removing conflicting grid weapons and creating a new one.
|
||||
#
|
||||
# Expects resolve parameters that include the desired position, the incoming weapon ID,
|
||||
# and a list of conflicting GridWeapon IDs. After deleting conflicting records and any existing
|
||||
# grid weapon at that position, creates a new GridWeapon with computed uncap_level.
|
||||
#
|
||||
# @return [void]
|
||||
def resolve
|
||||
incoming = Weapon.find_by(id: resolve_params[:incoming])
|
||||
conflicting_ids = resolve_params[:conflicting]
|
||||
conflicting_weapons = GridWeapon.where(id: conflicting_ids)
|
||||
incoming = Weapon.find(resolve_params[:incoming])
|
||||
conflicting = resolve_params[:conflicting].map { |id| GridWeapon.find(id) }
|
||||
party = conflicting.first.party
|
||||
|
||||
# Destroy each conflicting weapon
|
||||
conflicting_weapons.each(&:destroy)
|
||||
conflicting.each { |weapon| GridWeapon.destroy(weapon.id) }
|
||||
|
||||
# Destroy the weapon at the desired position if it exists
|
||||
if (existing_weapon = GridWeapon.find_by(party_id: @party.id, position: resolve_params[:position]))
|
||||
existing_weapon.destroy
|
||||
end
|
||||
existing_weapon = GridWeapon.where(party: party.id, position: resolve_params[:position]).first
|
||||
GridWeapon.destroy(existing_weapon.id) if existing_weapon
|
||||
|
||||
# Compute the default uncap level based on incoming weapon flags, maxing out at ULB.
|
||||
new_uncap = compute_default_uncap(incoming)
|
||||
grid_weapon = GridWeapon.create!(
|
||||
party_id: @party.id,
|
||||
weapon_id: incoming.id,
|
||||
position: resolve_params[:position],
|
||||
uncap_level: new_uncap,
|
||||
transcendence_step: 0
|
||||
)
|
||||
uncap_level = 3
|
||||
uncap_level = 4 if incoming.flb
|
||||
uncap_level = 5 if incoming.ulb
|
||||
|
||||
if grid_weapon.persisted?
|
||||
render json: GridWeaponBlueprint.render(grid_weapon, view: :full, root: :grid_weapon, meta: { replaced: resolve_params[:position] }), status: :created
|
||||
else
|
||||
render_validation_error_response(grid_weapon)
|
||||
end
|
||||
weapon = GridWeapon.create!(party_id: party.id, weapon_id: incoming.id,
|
||||
position: resolve_params[:position], uncap_level: uncap_level)
|
||||
|
||||
return unless weapon.save
|
||||
|
||||
view = render_grid_weapon_view(weapon, resolve_params[:position])
|
||||
render json: view, status: :created
|
||||
end
|
||||
|
||||
def update
|
||||
render_unauthorized_response if current_user && (@weapon.party.user != current_user)
|
||||
|
||||
# TODO: Server-side validation of weapon mods
|
||||
# We don't want someone modifying the JSON and adding
|
||||
# keys to weapons that cannot have them
|
||||
|
||||
# Maybe we make methods on the model to validate for us somehow
|
||||
|
||||
@weapon.assign_attributes(weapon_params)
|
||||
|
||||
@weapon.ax_modifier1 = nil if weapon_params[:ax_modifier1] == -1
|
||||
@weapon.ax_modifier2 = nil if weapon_params[:ax_modifier2] == -1
|
||||
@weapon.ax_strength1 = nil if weapon_params[:ax_strength1]&.zero?
|
||||
@weapon.ax_strength2 = nil if weapon_params[:ax_strength2]&.zero?
|
||||
|
||||
render json: GridWeaponBlueprint.render(@weapon, view: :nested) if @weapon.save
|
||||
end
|
||||
|
||||
##
|
||||
# Destroys a GridWeapon.
|
||||
#
|
||||
# Checks authorization and, if allowed, destroys the weapon and renders the destroyed view.
|
||||
#
|
||||
# @return [void]
|
||||
def destroy
|
||||
grid_weapon = GridWeapon.find_by('id = ?', params[:id])
|
||||
render_unauthorized_response if @weapon.party.user != current_user
|
||||
return render json: GridCharacterBlueprint.render(@weapon, view: :destroyed) if @weapon.destroy
|
||||
end
|
||||
|
||||
return render_not_found_response('grid_weapon') if grid_weapon.nil?
|
||||
def update_uncap_level
|
||||
weapon = GridWeapon.find(weapon_params[:id])
|
||||
|
||||
render json: GridWeaponBlueprint.render(grid_weapon, view: :destroyed), status: :ok if grid_weapon.destroy
|
||||
render_unauthorized_response if current_user && (weapon.party.user != current_user)
|
||||
|
||||
weapon.uncap_level = weapon_params[:uncap_level]
|
||||
return unless weapon.save!
|
||||
|
||||
render json: GridWeaponBlueprint.render(weapon, view: :nested, root: :grid_weapon),
|
||||
status: :created
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
##
|
||||
# Computes the maximum uncap level for a given weapon based on its flags.
|
||||
#
|
||||
# @param weapon [Weapon] the associated weapon.
|
||||
# @return [Integer] the maximum allowed uncap level.
|
||||
def compute_max_uncap_level(weapon)
|
||||
if weapon.flb && !weapon.ulb && !weapon.transcendence
|
||||
4
|
||||
elsif weapon.ulb && !weapon.transcendence
|
||||
5
|
||||
elsif weapon.transcendence
|
||||
6
|
||||
else
|
||||
3
|
||||
def check_weapon_compatibility
|
||||
return if compatible_with_position?(incoming_weapon, weapon_params[:position])
|
||||
|
||||
raise Api::V1::IncompatibleWeaponForPositionError.new(weapon: incoming_weapon)
|
||||
end
|
||||
|
||||
# Check if the incoming weapon is compatible with the specified position
|
||||
def compatible_with_position?(incoming_weapon, position)
|
||||
false if [9, 10, 11].include?(position.to_i) && ![11, 16, 17, 28, 29, 34].include?(incoming_weapon.series)
|
||||
true
|
||||
end
|
||||
|
||||
def conflict_weapon
|
||||
@conflict_weapon ||= find_conflict_weapon(party, incoming_weapon)
|
||||
end
|
||||
|
||||
# Find a conflict weapon if one exists
|
||||
def find_conflict_weapon(party, incoming_weapon)
|
||||
return unless incoming_weapon.limit
|
||||
|
||||
party.weapons.find do |weapon|
|
||||
series_match = incoming_weapon.series == weapon.weapon.series
|
||||
weapon if series_match || opus_or_draconic?(weapon.weapon) && opus_or_draconic?(incoming_weapon)
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Computes the default uncap level for an incoming weapon.
|
||||
#
|
||||
# This method calculates the default uncap level by computing the maximum uncap level based on the weapon's flags.
|
||||
#
|
||||
# @param incoming [Weapon] the incoming weapon.
|
||||
# @return [Integer] the default uncap level.
|
||||
def compute_default_uncap(incoming)
|
||||
compute_max_uncap_level(incoming)
|
||||
def find_incoming_weapon
|
||||
@incoming_weapon = Weapon.find_by(id: weapon_params[:weapon_id])
|
||||
end
|
||||
|
||||
##
|
||||
# Normalizes the AX modifier fields for the weapon parameters.
|
||||
#
|
||||
# Sets ax_modifier1 and ax_modifier2 to nil if their integer values equal -1.
|
||||
#
|
||||
# @return [void]
|
||||
def normalize_ax_fields!
|
||||
params[:weapon][:ax_modifier1] = nil if weapon_params[:ax_modifier1].to_i == -1
|
||||
|
||||
params[:weapon][:ax_modifier2] = nil if weapon_params[:ax_modifier2].to_i == -1
|
||||
def find_party
|
||||
# BUG: I can create grid weapons even when I'm not logged in on an authenticated party
|
||||
@party = Party.find(weapon_params[:party_id])
|
||||
render_unauthorized_response if current_user && (party.user != current_user)
|
||||
end
|
||||
|
||||
def opus_or_draconic?(weapon)
|
||||
[2, 3].include?(weapon.series)
|
||||
end
|
||||
|
||||
# Render the conflict view as a string
|
||||
def render_conflict_view(conflict_weapons, incoming_weapon, incoming_position)
|
||||
ConflictBlueprint.render(nil, view: :weapons,
|
||||
conflict_weapons: conflict_weapons,
|
||||
incoming_weapon: incoming_weapon,
|
||||
incoming_position: incoming_position)
|
||||
end
|
||||
|
||||
##
|
||||
# Renders the grid weapon view.
|
||||
#
|
||||
# @param grid_weapon [GridWeapon] the grid weapon to render.
|
||||
# @param conflict_position [Integer] the position that was replaced.
|
||||
# @return [String] the rendered view.
|
||||
def render_grid_weapon_view(grid_weapon, conflict_position)
|
||||
GridWeaponBlueprint.render(grid_weapon,
|
||||
view: :full,
|
||||
root: :grid_weapon,
|
||||
meta: { replaced: conflict_position })
|
||||
GridWeaponBlueprint.render(grid_weapon, view: :full,
|
||||
root: :grid_weapon,
|
||||
meta: { replaced: conflict_position })
|
||||
end
|
||||
|
||||
##
|
||||
# Saves the GridWeapon.
|
||||
#
|
||||
# Deletes any existing grid weapon at the same position,
|
||||
# adjusts party attributes based on the weapon's position,
|
||||
# and renders the full view upon successful save.
|
||||
#
|
||||
# @param weapon [GridWeapon] the grid weapon to save.
|
||||
# @return [void]
|
||||
def save_weapon(weapon)
|
||||
# Check weapon validation and delete existing grid weapon if one already exists at position
|
||||
if (existing = GridWeapon.find_by(party_id: @party.id, position: weapon.position))
|
||||
existing.destroy
|
||||
# Check weapon validation and delete existing grid weapon
|
||||
# if one already exists at position
|
||||
if (grid_weapon = GridWeapon.where(
|
||||
party_id: party.id,
|
||||
position: weapon_params[:position]
|
||||
).first)
|
||||
GridWeapon.destroy(grid_weapon.id)
|
||||
end
|
||||
|
||||
# Set the party's element if the grid weapon is being set as mainhand
|
||||
if weapon.position.to_i == -1
|
||||
@party.element = weapon.weapon.element
|
||||
@party.save!
|
||||
elsif GridWeapon::EXTRA_POSITIONS.include?(weapon.position.to_i)
|
||||
@party.extra = true
|
||||
@party.save!
|
||||
if weapon.position == -1
|
||||
party.element = weapon.weapon.element
|
||||
party.save!
|
||||
elsif [9, 10, 11].include?(weapon.position)
|
||||
party.extra = true
|
||||
party.save!
|
||||
end
|
||||
|
||||
if weapon.save
|
||||
output = GridWeaponBlueprint.render(weapon, view: :full, root: :grid_weapon)
|
||||
render json: output, status: :created
|
||||
else
|
||||
render_validation_error_response(weapon)
|
||||
end
|
||||
# Render the weapon if it can be saved
|
||||
return unless weapon.save
|
||||
|
||||
output = GridWeaponBlueprint.render(weapon, view: :full, root: :grid_weapon)
|
||||
render json: output, status: :created
|
||||
end
|
||||
|
||||
##
|
||||
# Handles conflicts when a new GridWeapon fails validation.
|
||||
#
|
||||
# Retrieves the array of conflicting grid weapons (via the model’s conflicts method)
|
||||
# and either renders a conflict view (if the canonical weapons differ) or updates the
|
||||
# conflicting grid weapon's position.
|
||||
#
|
||||
# @param weapon [GridWeapon] the weapon that failed validation.
|
||||
# @return [void]
|
||||
def handle_conflict(weapon)
|
||||
conflict_weapons = weapon.conflicts(party)
|
||||
# Find if one of the conflicting grid weapons is associated with the incoming weapon.
|
||||
conflict_weapon = conflict_weapons.find { |gw| gw.weapon.id == incoming_weapon.id }
|
||||
|
||||
if conflict_weapon.nil?
|
||||
# Map conflict weapon IDs into an array
|
||||
conflict_weapon_ids = conflict_weapons.map(&:id)
|
||||
if !conflict_weapon_ids.include?(incoming_weapon.id)
|
||||
# Render conflict view if the underlying canonical weapons
|
||||
# are not identical
|
||||
output = render_conflict_view(conflict_weapons, incoming_weapon, weapon_params[:position])
|
||||
render json: output
|
||||
else
|
||||
# Move the original grid weapon to the new position
|
||||
# to preserve keys and other modifications
|
||||
old_position = conflict_weapon.position
|
||||
conflict_weapon.position = weapon_params[:position]
|
||||
|
||||
if conflict_weapon.save
|
||||
output = render_grid_weapon_view(conflict_weapon, old_position)
|
||||
render json: output
|
||||
else
|
||||
render_validation_error_response(conflict_weapon)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Renders the conflict view.
|
||||
#
|
||||
# @param conflict_weapons [Array<GridWeapon>] an array of conflicting grid weapons.
|
||||
# @param incoming_weapon [Weapon] the incoming weapon.
|
||||
# @param incoming_position [Integer] the desired position.
|
||||
# @return [String] the rendered conflict view.
|
||||
def render_conflict_view(conflict_weapons, incoming_weapon, incoming_position)
|
||||
ConflictBlueprint.render(nil,
|
||||
view: :weapons,
|
||||
conflict_weapons: conflict_weapons,
|
||||
incoming_weapon: incoming_weapon,
|
||||
incoming_position: incoming_position)
|
||||
def set
|
||||
@weapon = GridWeapon.where('id = ?', params[:id]).first
|
||||
end
|
||||
|
||||
##
|
||||
# Finds and sets the GridWeapon based on the provided parameters.
|
||||
#
|
||||
# Searches for a grid weapon using various parameter keys and renders a not found response if it is absent.
|
||||
#
|
||||
# @return [void]
|
||||
def find_grid_weapon
|
||||
grid_weapon_id = params[:id] || params.dig(:weapon, :id) || params.dig(:resolve, :conflicting)
|
||||
@grid_weapon = GridWeapon.find_by(id: grid_weapon_id)
|
||||
render_not_found_response('grid_weapon') unless @grid_weapon
|
||||
def authorize
|
||||
# Create
|
||||
unauthorized_create = @party && (@party.user != current_user || @party.edit_key != edit_key)
|
||||
unauthorized_update = @weapon && @weapon.party && (@weapon.party.user != current_user || @weapon.party.edit_key != edit_key)
|
||||
|
||||
render_unauthorized_response if unauthorized_create || unauthorized_update
|
||||
end
|
||||
|
||||
##
|
||||
# Finds and sets the incoming weapon.
|
||||
#
|
||||
# @return [void]
|
||||
def find_incoming_weapon
|
||||
if params.dig(:weapon, :weapon_id).present?
|
||||
@incoming_weapon = Weapon.find_by(id: params.dig(:weapon, :weapon_id))
|
||||
render_not_found_response('weapon') unless @incoming_weapon
|
||||
else
|
||||
@incoming_weapon = nil
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Finds and sets the party based on parameters.
|
||||
#
|
||||
# Renders an unauthorized response if the current user is not the owner.
|
||||
#
|
||||
# @return [void]
|
||||
def find_party
|
||||
@party = Party.find_by(id: params.dig(:weapon, :party_id)) || Party.find_by(id: params[:party_id]) || @grid_weapon&.party
|
||||
render_not_found_response('party') unless @party
|
||||
end
|
||||
|
||||
##
|
||||
# Authorizes the current action by ensuring that the current user or provided edit key matches the party's owner.
|
||||
#
|
||||
# For parties associated with a user, it verifies that the current_user is the owner.
|
||||
# For anonymous parties, it checks that the provided edit key matches the party's edit key.
|
||||
#
|
||||
# @return [void]
|
||||
def authorize_party_edit!
|
||||
if @party.user.present?
|
||||
authorize_user_party
|
||||
else
|
||||
authorize_anonymous_party
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Authorizes an action for a party that belongs to a user.
|
||||
#
|
||||
# Renders an unauthorized response unless the current user is present and
|
||||
# matches the party's user.
|
||||
#
|
||||
# @return [void]
|
||||
def authorize_user_party
|
||||
return if current_user.present? && @party.user == current_user
|
||||
|
||||
return render_unauthorized_response
|
||||
end
|
||||
|
||||
##
|
||||
# Authorizes an action for an anonymous party using an edit key.
|
||||
#
|
||||
# Retrieves and normalizes the provided edit key and compares it with the party's edit key.
|
||||
# Renders an unauthorized response unless the keys are valid.
|
||||
#
|
||||
# @return [void]
|
||||
def authorize_anonymous_party
|
||||
provided_edit_key = edit_key.to_s.strip.force_encoding('UTF-8')
|
||||
party_edit_key = @party.edit_key.to_s.strip.force_encoding('UTF-8')
|
||||
return if valid_edit_key?(provided_edit_key, party_edit_key)
|
||||
|
||||
return render_unauthorized_response
|
||||
end
|
||||
|
||||
##
|
||||
# Validates that the provided edit key matches the party's edit key.
|
||||
#
|
||||
# @param provided_edit_key [String] the edit key provided in the request.
|
||||
# @param party_edit_key [String] the edit key associated with the party.
|
||||
# @return [Boolean] true if the edit keys match; false otherwise.
|
||||
def valid_edit_key?(provided_edit_key, party_edit_key)
|
||||
provided_edit_key.present? &&
|
||||
provided_edit_key.bytesize == party_edit_key.bytesize &&
|
||||
ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key)
|
||||
end
|
||||
|
||||
##
|
||||
# Specifies and permits the allowed weapon parameters.
|
||||
#
|
||||
# @return [ActionController::Parameters] the permitted parameters.
|
||||
# Specify whitelisted properties that can be modified.
|
||||
def weapon_params
|
||||
params.require(:weapon).permit(
|
||||
:id, :party_id, :weapon_id,
|
||||
:position, :mainhand, :uncap_level, :transcendence_step, :element,
|
||||
:position, :mainhand, :uncap_level, :element,
|
||||
:weapon_key1_id, :weapon_key2_id, :weapon_key3_id,
|
||||
:ax_modifier1, :ax_modifier2, :ax_strength1, :ax_strength2,
|
||||
:awakening_id, :awakening_level
|
||||
)
|
||||
end
|
||||
|
||||
##
|
||||
# Specifies and permits the resolve parameters.
|
||||
#
|
||||
# @return [ActionController::Parameters] the permitted parameters.
|
||||
def resolve_params
|
||||
params.require(:resolve).permit(:position, :incoming, conflicting: [])
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,216 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
module V1
|
||||
##
|
||||
# ImportController is responsible for importing game data (e.g. deck data)
|
||||
# and creating a new Party along with associated records (job, characters, weapons, summons, etc.).
|
||||
#
|
||||
# The controller expects a JSON payload whose top-level key is "import". If not wrapped,
|
||||
# the controller will wrap the raw data automatically.
|
||||
#
|
||||
# @example Valid payload structure
|
||||
# {
|
||||
# "import": {
|
||||
# "deck": { "name": "My Party", ... },
|
||||
# "pc": { "job": { "master": { "name": "Warrior" } }, ... }
|
||||
# }
|
||||
# }
|
||||
class ImportController < Api::V1::ApiController
|
||||
ELEMENT_MAPPING = {
|
||||
0 => nil,
|
||||
1 => 4,
|
||||
2 => 2,
|
||||
3 => 3,
|
||||
4 => 1,
|
||||
5 => 6,
|
||||
6 => 5
|
||||
}.freeze
|
||||
|
||||
before_action :ensure_admin_role, only: %i[weapons summons characters]
|
||||
|
||||
##
|
||||
# Processes an import request.
|
||||
#
|
||||
# It reads and parses the raw JSON, wraps the data under the "import" key if necessary,
|
||||
# transforms the deck data using BaseDeckTransformer, validates that the transformed data
|
||||
# contains required fields, and then creates a new Party record (and its associated objects)
|
||||
# inside a transaction.
|
||||
#
|
||||
# @return [void] Renders JSON response with a party shortcode or an error message.
|
||||
def create
|
||||
Rails.logger.info '[IMPORT] Checking input...'
|
||||
|
||||
body = parse_request_body
|
||||
return unless body
|
||||
|
||||
raw_params = body['import']
|
||||
unless raw_params.is_a?(Hash)
|
||||
Rails.logger.error "[IMPORT] 'import' key is missing or not a hash."
|
||||
return render json: { error: 'Invalid JSON data' }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
unless raw_params['deck'].is_a?(Hash) &&
|
||||
raw_params['deck'].key?('pc') &&
|
||||
raw_params['deck'].key?('npc')
|
||||
Rails.logger.error '[IMPORT] Deck data incomplete or missing.'
|
||||
return render json: { error: 'Invalid deck data' }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
Rails.logger.info '[IMPORT] Starting import...'
|
||||
|
||||
return if performed? # Rendered an error response already
|
||||
|
||||
party = Party.create(user: current_user)
|
||||
deck_data = raw_params['import']
|
||||
process_data(party, deck_data)
|
||||
|
||||
render json: { shortcode: party.shortcode }, status: :created
|
||||
rescue StandardError => e
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
def weapons
|
||||
Rails.logger.info '[IMPORT] Checking weapon gamedata input...'
|
||||
|
||||
body = parse_request_body
|
||||
return unless body
|
||||
|
||||
weapon = Weapon.find_by(granblue_id: body['id'])
|
||||
unless weapon
|
||||
Rails.logger.error "[IMPORT] Weapon not found with ID: #{body['id']}"
|
||||
return render json: { error: 'Weapon not found' }, status: :not_found
|
||||
end
|
||||
|
||||
lang = params[:lang]
|
||||
unless %w[en jp].include?(lang)
|
||||
Rails.logger.error "[IMPORT] Invalid language: #{lang}"
|
||||
return render json: { error: 'Invalid language' }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
begin
|
||||
weapon.update!(
|
||||
"game_raw_#{lang}" => body.to_json
|
||||
)
|
||||
render json: { message: 'Weapon gamedata updated successfully' }, status: :ok
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[IMPORT] Failed to update weapon gamedata: #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
def summons
|
||||
Rails.logger.info '[IMPORT] Checking summon gamedata input...'
|
||||
|
||||
body = parse_request_body
|
||||
return unless body
|
||||
|
||||
summon = Summon.find_by(granblue_id: body['id'])
|
||||
unless summon
|
||||
Rails.logger.error "[IMPORT] Summon not found with ID: #{body['id']}"
|
||||
return render json: { error: 'Summon not found' }, status: :not_found
|
||||
end
|
||||
|
||||
lang = params[:lang]
|
||||
unless %w[en jp].include?(lang)
|
||||
Rails.logger.error "[IMPORT] Invalid language: #{lang}"
|
||||
return render json: { error: 'Invalid language' }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
begin
|
||||
summon.update!(
|
||||
"game_raw_#{lang}" => body.to_json
|
||||
)
|
||||
render json: { message: 'Summon gamedata updated successfully' }, status: :ok
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[IMPORT] Failed to update summon gamedata: #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Updates character gamedata from JSON blob.
|
||||
#
|
||||
# @return [void] Renders JSON response with success or error message.
|
||||
def characters
|
||||
Rails.logger.info '[IMPORT] Checking character gamedata input...'
|
||||
|
||||
body = parse_request_body
|
||||
return unless body
|
||||
|
||||
character = Character.find_by(granblue_id: body['id'])
|
||||
unless character
|
||||
Rails.logger.error "[IMPORT] Character not found with ID: #{body['id']}"
|
||||
return render json: { error: 'Character not found' }, status: :not_found
|
||||
end
|
||||
|
||||
lang = params[:lang]
|
||||
unless %w[en jp].include?(lang)
|
||||
Rails.logger.error "[IMPORT] Invalid language: #{lang}"
|
||||
return render json: { error: 'Invalid language' }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
begin
|
||||
character.update!(
|
||||
"game_raw_#{lang}" => body.to_json
|
||||
)
|
||||
render json: { message: 'Character gamedata updated successfully' }, status: :ok
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[IMPORT] Failed to update character gamedata: #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
##
|
||||
# Ensures the current user has admin role (role 9).
|
||||
# Renders an error if the user is not an admin.
|
||||
#
|
||||
# @return [void]
|
||||
def ensure_admin_role
|
||||
return if current_user&.role == 9
|
||||
|
||||
Rails.logger.error "[IMPORT] Unauthorized access attempt by user #{current_user&.id}"
|
||||
render json: { error: 'Unauthorized' }, status: :unauthorized
|
||||
end
|
||||
|
||||
##
|
||||
# Reads and parses the raw JSON request body.
|
||||
#
|
||||
# @return [Hash] Parsed JSON data.
|
||||
# @raise [JSON::ParserError] If the JSON is invalid.
|
||||
def parse_request_body
|
||||
raw_body = request.raw_post
|
||||
JSON.parse(raw_body)
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error "[IMPORT] Invalid JSON: #{e.message}"
|
||||
render json: { error: 'Invalid JSON data' }, status: :bad_request and return
|
||||
end
|
||||
|
||||
##
|
||||
# Ensures that the provided data is wrapped under an "import" key.
|
||||
#
|
||||
# @param data [Hash] The parsed JSON data.
|
||||
# @return [Hash] Data wrapped under the "import" key.
|
||||
def wrap_import_data(data)
|
||||
data.key?('import') ? data : { 'import' => data }
|
||||
end
|
||||
|
||||
##
|
||||
# Processes the deck data using processors.
|
||||
#
|
||||
# @param party [Party] The party to insert data into
|
||||
# @param data [Hash] The wrapped data.
|
||||
# @return [Hash] The transformed deck data.
|
||||
def process_data(party, data)
|
||||
Rails.logger.info '[IMPORT] Transforming deck data'
|
||||
|
||||
Processors::JobProcessor.new(party, data).process
|
||||
Processors::CharacterProcessor.new(party, data).process
|
||||
Processors::SummonProcessor.new(party, data).process
|
||||
Processors::WeaponProcessor.new(party, data).process
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -4,13 +4,11 @@ module Api
|
|||
module V1
|
||||
class JobSkillsController < Api::V1::ApiController
|
||||
def all
|
||||
render json: JobSkillBlueprint.render(JobSkill.includes(:job).all)
|
||||
render json: JobSkillBlueprint.render(JobSkill.all)
|
||||
end
|
||||
|
||||
def job
|
||||
@skills = JobSkill.includes(:job)
|
||||
.where.not(job_id: params[:id])
|
||||
.where(emp: true)
|
||||
@skills = JobSkill.where('job_id != ? AND emp = ?', params[:id], true)
|
||||
render json: JobSkillBlueprint.render(@skills)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -10,10 +10,6 @@ module Api
|
|||
render json: JobBlueprint.render(Job.all)
|
||||
end
|
||||
|
||||
def show
|
||||
render json: JobBlueprint.render(Job.find_by(granblue_id: params[:id]))
|
||||
end
|
||||
|
||||
def update_job
|
||||
if job_params[:job_id] != -1
|
||||
# Extract job and find its main skills
|
||||
|
|
@ -47,7 +43,7 @@ module Api
|
|||
end
|
||||
end
|
||||
|
||||
render json: PartyBlueprint.render(@party, view: :job_metadata) if @party.save!
|
||||
render json: PartyBlueprint.render(@party, view: :jobs) if @party.save!
|
||||
end
|
||||
|
||||
def update_job_skills
|
||||
|
|
@ -68,8 +64,7 @@ module Api
|
|||
new_skill_ids = new_skill_keys.map { |key| job_params[key] }
|
||||
new_skill_ids.map do |id|
|
||||
skill = JobSkill.find(id)
|
||||
raise Api::V1::IncompatibleSkillError.new(job: @party.job, skill: skill) if mismatched_skill(@party.job,
|
||||
skill)
|
||||
raise Api::V1::IncompatibleSkillError.new(job: @party.job, skill: skill) if mismatched_skill(@party.job, skill)
|
||||
end
|
||||
|
||||
positions = extract_positions_from_keys(new_skill_keys)
|
||||
|
|
@ -167,8 +162,8 @@ module Api
|
|||
if %w[4 5 ex2].include?(job.row)
|
||||
if skill.base && !mismatched_base
|
||||
false
|
||||
elsif mismatched_emp || mismatched_main
|
||||
true
|
||||
else
|
||||
true if mismatched_emp || mismatched_main
|
||||
end
|
||||
elsif mismatched_emp || mismatched_main
|
||||
true
|
||||
|
|
|
|||
|
|
@ -2,197 +2,400 @@
|
|||
|
||||
module Api
|
||||
module V1
|
||||
# Controller for managing party-related operations in the API
|
||||
# @api public
|
||||
class PartiesController < Api::V1::ApiController
|
||||
include PartyAuthorizationConcern
|
||||
include PartyQueryingConcern
|
||||
include PartyPreviewConcern
|
||||
before_action :set_from_slug,
|
||||
except: %w[create destroy update index favorites]
|
||||
before_action :set, only: %w[update destroy]
|
||||
before_action :authorize, only: %w[update destroy]
|
||||
|
||||
# Constants used for filtering validations.
|
||||
|
||||
# Maximum number of characters allowed in a party
|
||||
MAX_CHARACTERS = 5
|
||||
|
||||
# Maximum number of summons allowed in a party
|
||||
MAX_SUMMONS = 8
|
||||
|
||||
# Maximum number of weapons allowed in a party
|
||||
MAX_WEAPONS = 13
|
||||
|
||||
# Default minimum number of characters required for filtering
|
||||
DEFAULT_MIN_CHARACTERS = 3
|
||||
|
||||
# Default minimum number of summons required for filtering
|
||||
DEFAULT_MIN_SUMMONS = 2
|
||||
|
||||
# Default minimum number of weapons required for filtering
|
||||
DEFAULT_MIN_WEAPONS = 5
|
||||
|
||||
# Default maximum clear time in seconds
|
||||
DEFAULT_MAX_CLEAR_TIME = 5400
|
||||
|
||||
before_action :set_from_slug, except: %w[create destroy update index favorites]
|
||||
before_action :set, only: %w[update destroy]
|
||||
before_action :authorize_party!, only: %w[update destroy]
|
||||
|
||||
# Primary CRUD Actions
|
||||
|
||||
# Creates a new party with optional user association
|
||||
# @return [void]
|
||||
# Creates a new party.
|
||||
def create
|
||||
party = Party.new(party_params)
|
||||
party = Party.new
|
||||
party.user = current_user if current_user
|
||||
if party_params && party_params[:raid_id].present?
|
||||
if (raid = Raid.find_by(id: party_params[:raid_id]))
|
||||
party.extra = raid.group.extra
|
||||
end
|
||||
party.attributes = party_params if party_params
|
||||
|
||||
if party_params && party_params[:raid_id]
|
||||
raid = Raid.find_by(id: party_params[:raid_id])
|
||||
party.extra = raid.group.extra
|
||||
end
|
||||
if party.save
|
||||
party.schedule_preview_generation if party.ready_for_preview?
|
||||
render json: PartyBlueprint.render(party, view: :created, root: :party), status: :created
|
||||
else
|
||||
render_validation_error_response(party)
|
||||
|
||||
if party.save!
|
||||
return render json: PartyBlueprint.render(party, view: :created, root: :party),
|
||||
status: :created
|
||||
end
|
||||
|
||||
render_validation_error_response(@party)
|
||||
end
|
||||
|
||||
# Shows a specific party.
|
||||
def show
|
||||
return render_unauthorized_response if @party.private? && (!current_user || not_owner?)
|
||||
|
||||
if @party
|
||||
render json: PartyBlueprint.render(@party, view: :full, root: :party)
|
||||
else
|
||||
render_not_found_response('project')
|
||||
# If a party is private, check that the user is the owner or an admin
|
||||
if (@party.private? && !current_user) || (@party.private? && not_owner && !admin_mode)
|
||||
return render_unauthorized_response
|
||||
end
|
||||
|
||||
return render json: PartyBlueprint.render(@party, view: :full, root: :party) if @party
|
||||
|
||||
render_not_found_response('project')
|
||||
end
|
||||
|
||||
# Updates an existing party.
|
||||
def update
|
||||
@party.attributes = party_params.except(:skill1_id, :skill2_id, :skill3_id)
|
||||
|
||||
if party_params && party_params[:raid_id]
|
||||
if (raid = Raid.find_by(id: party_params[:raid_id]))
|
||||
@party.extra = raid.group.extra
|
||||
end
|
||||
end
|
||||
if @party.save
|
||||
render json: PartyBlueprint.render(@party, view: :full, root: :party)
|
||||
else
|
||||
render_validation_error_response(@party)
|
||||
raid = Raid.find_by(id: party_params[:raid_id])
|
||||
@party.extra = raid.group.extra
|
||||
end
|
||||
|
||||
# TODO: Validate accessory with job
|
||||
|
||||
return render json: PartyBlueprint.render(@party, view: :full, root: :party) if @party.save
|
||||
|
||||
render_validation_error_response(@party)
|
||||
end
|
||||
|
||||
# Deletes a party.
|
||||
def destroy
|
||||
render json: PartyBlueprint.render(@party, view: :destroyed, root: :checkin) if @party.destroy
|
||||
return render json: PartyBlueprint.render(@party, view: :destroyed, root: :checkin) if @party.destroy
|
||||
end
|
||||
|
||||
# Extended Party Actions
|
||||
|
||||
# Creates a remixed copy of an existing party.
|
||||
def remix
|
||||
new_party = @party.amoeba_dup
|
||||
new_party.attributes = { user: current_user, name: remixed_name(@party.name), source_party: @party, remix: true }
|
||||
new_party.local_id = party_params[:local_id] if party_params
|
||||
new_party.attributes = {
|
||||
user: current_user,
|
||||
name: remixed_name(@party.name),
|
||||
source_party: @party,
|
||||
remix: true
|
||||
}
|
||||
|
||||
new_party.local_id = party_params[:local_id] unless party_params.nil?
|
||||
|
||||
if new_party.save
|
||||
new_party.schedule_preview_generation
|
||||
render json: PartyBlueprint.render(new_party, view: :remixed, root: :party), status: :created
|
||||
render json: PartyBlueprint.render(new_party, view: :created, root: :party),
|
||||
status: :created
|
||||
else
|
||||
render_validation_error_response(new_party)
|
||||
end
|
||||
end
|
||||
|
||||
# Lists parties based on query parameters.
|
||||
def index
|
||||
query = build_filtered_query(build_common_base_query)
|
||||
@parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE)
|
||||
render_paginated_parties(@parties)
|
||||
conditions = build_filters
|
||||
|
||||
query = build_query(conditions)
|
||||
query = apply_includes(query, params[:includes]) if params[:includes].present?
|
||||
query = apply_excludes(query, params[:excludes]) if params[:excludes].present?
|
||||
|
||||
@parties = fetch_parties(query)
|
||||
count = calculate_count(query)
|
||||
total_pages = calculate_total_pages(count)
|
||||
|
||||
render_party_json(@parties, count, total_pages)
|
||||
end
|
||||
|
||||
# GET /api/v1/parties/favorites
|
||||
def favorites
|
||||
raise Api::V1::UnauthorizedError unless current_user
|
||||
|
||||
base_query = build_common_base_query
|
||||
.joins(:favorites)
|
||||
.where(favorites: { user_id: current_user.id })
|
||||
.distinct
|
||||
query = build_filtered_query(base_query)
|
||||
@parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE)
|
||||
render_paginated_parties(@parties)
|
||||
end
|
||||
conditions = build_filters
|
||||
conditions[:favorites] = { user_id: current_user.id }
|
||||
|
||||
# Preview Management
|
||||
query = build_query(conditions, favorites: true)
|
||||
query = apply_includes(query, params[:includes]) if params[:includes].present?
|
||||
query = apply_excludes(query, params[:excludes]) if params[:excludes].present?
|
||||
|
||||
# Serves the party's preview image
|
||||
# @return [void]
|
||||
# Serves the party's preview image.
|
||||
def preview
|
||||
party_preview(@party)
|
||||
end
|
||||
@parties = fetch_parties(query)
|
||||
count = calculate_count(query)
|
||||
total_pages = calculate_total_pages(count)
|
||||
|
||||
# Returns the current preview status of a party.
|
||||
def preview_status
|
||||
party = Party.find_by!(shortcode: params[:id])
|
||||
render json: { state: party.preview_state, generated_at: party.preview_generated_at, ready_for_preview: party.ready_for_preview? }
|
||||
end
|
||||
|
||||
# Forces regeneration of the party preview.
|
||||
def regenerate_preview
|
||||
party = Party.find_by!(shortcode: params[:id])
|
||||
return render_unauthorized_response unless current_user && party.user_id == current_user.id
|
||||
|
||||
preview_service = PreviewService::Coordinator.new(party)
|
||||
if preview_service.force_regenerate
|
||||
render json: { status: 'Preview regeneration started' }
|
||||
else
|
||||
render json: { error: 'Preview regeneration failed' }, status: :unprocessable_entity
|
||||
end
|
||||
render_party_json(@parties, count, total_pages)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Loads the party by its shortcode.
|
||||
def set_from_slug
|
||||
@party = Party.includes(
|
||||
:user, :job, { raid: :group },
|
||||
{ characters: %i[character awakening] },
|
||||
{ weapons: {
|
||||
weapon: [:awakenings],
|
||||
awakening: {},
|
||||
weapon_key1: {},
|
||||
weapon_key2: {},
|
||||
weapon_key3: {}
|
||||
}
|
||||
},
|
||||
{ summons: :summon },
|
||||
:guidebook1, :guidebook2, :guidebook3,
|
||||
:source_party, :remixes, :skill0, :skill1, :skill2, :skill3, :accessory
|
||||
).find_by(shortcode: params[:id])
|
||||
render_not_found_response('party') unless @party
|
||||
def authorize
|
||||
return unless not_owner && !admin_mode
|
||||
|
||||
render_unauthorized_response
|
||||
end
|
||||
|
||||
def not_owner
|
||||
if @party.user
|
||||
# party has a user and current_user does not match
|
||||
return true if current_user != @party.user
|
||||
|
||||
# party has a user, there's no current_user, but edit_key is provided
|
||||
return true if current_user.nil? && edit_key
|
||||
else
|
||||
# party has no user, there's no current_user and there's no edit_key provided
|
||||
return true if current_user.nil? && edit_key.nil?
|
||||
|
||||
# party has no user, there's no current_user, and the party's edit_key doesn't match the provided edit_key
|
||||
return true if current_user.nil? && @party.edit_key != edit_key
|
||||
end
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def build_filters
|
||||
params = request.params
|
||||
|
||||
start_time = build_start_time(params['recency'])
|
||||
|
||||
min_characters_count = build_count(params['characters_count'], DEFAULT_MIN_CHARACTERS)
|
||||
min_summons_count = build_count(params['summons_count'], DEFAULT_MIN_SUMMONS)
|
||||
min_weapons_count = build_count(params['weapons_count'], DEFAULT_MIN_WEAPONS)
|
||||
max_clear_time = build_max_clear_time(params['max_clear_time'])
|
||||
|
||||
{
|
||||
element: build_element(params['element']),
|
||||
raid: params['raid'],
|
||||
created_at: params['recency'].present? ? start_time..DateTime.current : nil,
|
||||
full_auto: build_option(params['full_auto']),
|
||||
auto_guard: build_option(params['auto_guard']),
|
||||
charge_attack: build_option(params['charge_attack']),
|
||||
characters_count: min_characters_count..MAX_CHARACTERS,
|
||||
summons_count: min_summons_count..MAX_SUMMONS,
|
||||
weapons_count: min_weapons_count..MAX_WEAPONS
|
||||
}.delete_if { |_k, v| v.nil? }
|
||||
end
|
||||
|
||||
def build_start_time(recency)
|
||||
return unless recency.present?
|
||||
|
||||
(DateTime.current - recency.to_i.seconds).to_datetime.beginning_of_day
|
||||
end
|
||||
|
||||
def build_count(value, default)
|
||||
value.blank? ? default : value.to_i
|
||||
end
|
||||
|
||||
def build_max_clear_time(value)
|
||||
value.blank? ? DEFAULT_MAX_CLEAR_TIME : value.to_i
|
||||
end
|
||||
|
||||
def build_element(element)
|
||||
element.to_i unless element.blank?
|
||||
end
|
||||
|
||||
def build_option(value)
|
||||
value.to_i unless value.blank? || value.to_i == -1
|
||||
end
|
||||
|
||||
def build_query(conditions, favorites: false)
|
||||
query = Party.distinct
|
||||
.joins(weapons: [:object], summons: [:object], characters: [:object])
|
||||
.group('parties.id')
|
||||
.where(conditions)
|
||||
.where(privacy(favorites: favorites))
|
||||
.where(name_quality)
|
||||
.where(user_quality)
|
||||
.where(original)
|
||||
|
||||
query = query.joins(:favorites) if favorites
|
||||
|
||||
query
|
||||
end
|
||||
|
||||
def includes(id)
|
||||
"(\"#{id_to_table(id)}\".\"granblue_id\" = '#{id}')"
|
||||
end
|
||||
|
||||
def excludes(id)
|
||||
"(\"#{id_to_table(id)}\".\"granblue_id\" != '#{id}')"
|
||||
end
|
||||
|
||||
def apply_includes(query, includes)
|
||||
included = includes.split(',')
|
||||
includes_condition = included.map { |id| includes(id) }.join(' AND ')
|
||||
query.where(includes_condition)
|
||||
end
|
||||
|
||||
def apply_excludes(query, _excludes)
|
||||
characters_subquery = excluded_characters.select(1).arel
|
||||
summons_subquery = excluded_summons.select(1).arel
|
||||
weapons_subquery = excluded_weapons.select(1).arel
|
||||
|
||||
query.where(characters_subquery.exists.not)
|
||||
.where(weapons_subquery.exists.not)
|
||||
.where(summons_subquery.exists.not)
|
||||
end
|
||||
|
||||
def excluded_characters
|
||||
return unless params[:excludes]
|
||||
|
||||
excluded = params[:excludes].split(',').filter { |id| id[0] == '3' }
|
||||
GridCharacter.joins(:object)
|
||||
.where(characters: { granblue_id: excluded })
|
||||
.where('grid_characters.party_id = parties.id')
|
||||
end
|
||||
|
||||
def excluded_summons
|
||||
return unless params[:excludes]
|
||||
|
||||
excluded = params[:excludes].split(',').filter { |id| id[0] == '2' }
|
||||
GridSummon.joins(:object)
|
||||
.where(summons: { granblue_id: excluded })
|
||||
.where('grid_summons.party_id = parties.id')
|
||||
end
|
||||
|
||||
def excluded_weapons
|
||||
return unless params[:excludes]
|
||||
|
||||
excluded = params[:excludes].split(',').filter { |id| id[0] == '1' }
|
||||
GridWeapon.joins(:object)
|
||||
.where(weapons: { granblue_id: excluded })
|
||||
.where('grid_weapons.party_id = parties.id')
|
||||
end
|
||||
|
||||
def fetch_parties(query)
|
||||
query.order(created_at: :desc)
|
||||
.paginate(page: request.params[:page], per_page: COLLECTION_PER_PAGE)
|
||||
.each { |party| party.favorited = current_user ? party.is_favorited(current_user) : false }
|
||||
end
|
||||
|
||||
def calculate_count(query)
|
||||
query.count.values.sum
|
||||
end
|
||||
|
||||
def calculate_total_pages(count)
|
||||
count.to_f / COLLECTION_PER_PAGE > 1 ? (count.to_f / COLLECTION_PER_PAGE).ceil : 1
|
||||
end
|
||||
|
||||
def render_party_json(parties, count, total_pages)
|
||||
render json: PartyBlueprint.render(parties,
|
||||
view: :collection,
|
||||
root: :results,
|
||||
meta: {
|
||||
count: count,
|
||||
total_pages: total_pages,
|
||||
per_page: COLLECTION_PER_PAGE
|
||||
})
|
||||
end
|
||||
|
||||
def privacy(favorites: false)
|
||||
return if admin_mode
|
||||
|
||||
if favorites
|
||||
'visibility < 3'
|
||||
else
|
||||
'visibility = 1'
|
||||
end
|
||||
end
|
||||
|
||||
def user_quality
|
||||
return if request.params[:user_quality].blank? || request.params[:user_quality] == 'false'
|
||||
|
||||
'user_id IS NOT NULL'
|
||||
end
|
||||
|
||||
def name_quality
|
||||
low_quality = [
|
||||
'Untitled',
|
||||
'Remix of Untitled',
|
||||
'Remix of Remix of Untitled',
|
||||
'Remix of Remix of Remix of Untitled',
|
||||
'Remix of Remix of Remix of Remix of Untitled',
|
||||
'Remix of Remix of Remix of Remix of Remix of Untitled',
|
||||
'無題',
|
||||
'無題のリミックス',
|
||||
'無題のリミックスのリミックス',
|
||||
'無題のリミックスのリミックスのリミックス',
|
||||
'無題のリミックスのリミックスのリミックスのリミックス',
|
||||
'無題のリミックスのリミックスのリミックスのリミックスのリミックス'
|
||||
]
|
||||
|
||||
joined_names = low_quality.map { |name| "'#{name}'" }.join(',')
|
||||
|
||||
return if request.params[:name_quality].blank? || request.params[:name_quality] == 'false'
|
||||
|
||||
"name NOT IN (#{joined_names})"
|
||||
end
|
||||
|
||||
def original
|
||||
return if request.params['original'].blank? || request.params['original'] == 'false'
|
||||
|
||||
'source_party_id IS NULL'
|
||||
end
|
||||
|
||||
def id_to_table(id)
|
||||
case id[0]
|
||||
when '3'
|
||||
table = 'characters'
|
||||
when '2'
|
||||
table = 'summons'
|
||||
when '1'
|
||||
table = 'weapons'
|
||||
end
|
||||
|
||||
table
|
||||
end
|
||||
|
||||
def remixed_name(name)
|
||||
blanked_name = {
|
||||
en: name.blank? ? 'Untitled team' : name,
|
||||
ja: name.blank? ? '無名の編成' : name
|
||||
}
|
||||
|
||||
if current_user
|
||||
case current_user.language
|
||||
when 'en'
|
||||
"Remix of #{blanked_name[:en]}"
|
||||
when 'ja'
|
||||
"#{blanked_name[:ja]}のリミックス"
|
||||
end
|
||||
else
|
||||
"Remix of #{blanked_name[:en]}"
|
||||
end
|
||||
end
|
||||
|
||||
def set_from_slug
|
||||
@party = Party.where('shortcode = ?', params[:id]).first
|
||||
if @party
|
||||
@party.favorited = current_user && @party ? @party.is_favorited(current_user) : false
|
||||
else
|
||||
render_not_found_response('party')
|
||||
end
|
||||
end
|
||||
|
||||
# Loads the party by its id.
|
||||
def set
|
||||
@party = Party.where('id = ?', params[:id]).first
|
||||
end
|
||||
|
||||
# Sanitizes and permits party parameters.
|
||||
def party_params
|
||||
return unless params[:party].present?
|
||||
|
||||
params.require(:party).permit(
|
||||
:user_id, :local_id, :edit_key, :extra, :name, :description, :raid_id, :job_id, :visibility,
|
||||
:accessory_id, :skill0_id, :skill1_id, :skill2_id, :skill3_id,
|
||||
:full_auto, :auto_guard, :auto_summon, :charge_attack, :clear_time, :button_count,
|
||||
:turn_count, :chain_count, :guidebook1_id, :guidebook2_id, :guidebook3_id,
|
||||
characters_attributes: [:id, :party_id, :character_id, :position, :uncap_level,
|
||||
:transcendence_step, :perpetuity, :awakening_id, :awakening_level,
|
||||
{ ring1: %i[modifier strength], ring2: %i[modifier strength], ring3: %i[modifier strength], ring4: %i[modifier strength],
|
||||
earring: %i[modifier strength] }],
|
||||
summons_attributes: %i[id party_id summon_id position main friend quick_summon uncap_level transcendence_step],
|
||||
weapons_attributes: %i[id party_id weapon_id position mainhand uncap_level transcendence_step element weapon_key1_id weapon_key2_id weapon_key3_id ax_modifier1 ax_modifier2 ax_strength1 ax_strength2 awakening_id awakening_level]
|
||||
:user_id,
|
||||
:local_id,
|
||||
:edit_key,
|
||||
:extra,
|
||||
:name,
|
||||
:description,
|
||||
:raid_id,
|
||||
:job_id,
|
||||
:visibility,
|
||||
:accessory_id,
|
||||
:skill0_id,
|
||||
:skill1_id,
|
||||
:skill2_id,
|
||||
:skill3_id,
|
||||
:full_auto,
|
||||
:auto_guard,
|
||||
:auto_summon,
|
||||
:charge_attack,
|
||||
:clear_time,
|
||||
:button_count,
|
||||
:turn_count,
|
||||
:chain_count,
|
||||
:guidebook1_id,
|
||||
:guidebook2_id,
|
||||
:guidebook3_id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -4,16 +4,11 @@ module Api
|
|||
module V1
|
||||
class RaidsController < Api::V1::ApiController
|
||||
def all
|
||||
render json: RaidBlueprint.render(Raid.includes(:group).all, view: :nested)
|
||||
end
|
||||
|
||||
def show
|
||||
raid = Raid.find_by(slug: params[:id])
|
||||
render json: RaidBlueprint.render(Raid.find_by(slug: params[:id]), view: :full) if raid
|
||||
render json: RaidBlueprint.render(Raid.all, view: :full)
|
||||
end
|
||||
|
||||
def groups
|
||||
render json: RaidGroupBlueprint.render(RaidGroup.includes(raids: :group).all, view: :full)
|
||||
render json: RaidGroupBlueprint.render(RaidGroup.all, view: :full)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ module Api
|
|||
Weapon.en_search(search_params[:query]).where(conditions)
|
||||
end
|
||||
else
|
||||
Weapon.where(conditions).order(Arel.sql('greatest(release_date, flb_date, ulb_date, transcendence_date) desc'))
|
||||
Weapon.where(conditions).order(Arel.sql('greatest(release_date, flb_date, ulb_date) desc'))
|
||||
end
|
||||
|
||||
count = weapons.length
|
||||
|
|
@ -149,7 +149,7 @@ module Api
|
|||
Summon.en_search(search_params[:query]).where(conditions)
|
||||
end
|
||||
else
|
||||
Summon.where(conditions).order(release_date: :desc).order(Arel.sql('greatest(release_date, flb_date, ulb_date, transcendence_date) desc'))
|
||||
Summon.where(conditions).order(release_date: :desc).order(Arel.sql('greatest(release_date, flb_date, ulb_date, xlb_date) desc'))
|
||||
end
|
||||
|
||||
count = summons.length
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ module Api
|
|||
class ForbiddenError < StandardError; end
|
||||
|
||||
before_action :set, except: %w[create check_email check_username]
|
||||
before_action :set_by_id, only: %w[update]
|
||||
before_action :set_by_id, only: %w[info update]
|
||||
|
||||
MAX_CHARACTERS = 5
|
||||
MAX_SUMMONS = 8
|
||||
|
|
@ -55,39 +55,32 @@ module Api
|
|||
if @user.nil?
|
||||
render_not_found_response('user')
|
||||
else
|
||||
base_query = Party.includes(
|
||||
{ raid: :group },
|
||||
:job,
|
||||
:user,
|
||||
:skill0,
|
||||
:skill1,
|
||||
:skill2,
|
||||
:skill3,
|
||||
:guidebook1,
|
||||
:guidebook2,
|
||||
:guidebook3,
|
||||
{ characters: :character },
|
||||
{ weapons: :weapon },
|
||||
{ summons: :summon }
|
||||
)
|
||||
# Restrict to parties belonging to the profile owner
|
||||
base_query = base_query.where(user_id: @user.id)
|
||||
skip_privacy = (current_user&.id == @user.id)
|
||||
query = PartyQueryBuilder.new(
|
||||
base_query,
|
||||
params: params,
|
||||
current_user: current_user,
|
||||
options: { skip_privacy: skip_privacy }
|
||||
).build
|
||||
parties = query.paginate(page: params[:page], per_page: PartyConstants::COLLECTION_PER_PAGE)
|
||||
count = query.count
|
||||
conditions = build_conditions
|
||||
conditions[:user_id] = @user.id
|
||||
|
||||
parties = Party
|
||||
.where(conditions)
|
||||
.where(name_quality)
|
||||
.where(user_quality)
|
||||
.where(original)
|
||||
.where(privacy)
|
||||
.order(created_at: :desc)
|
||||
.paginate(page: request.params[:page], per_page: COLLECTION_PER_PAGE)
|
||||
.each do |party|
|
||||
party.favorited = current_user ? party.is_favorited(current_user) : false
|
||||
end
|
||||
|
||||
count = Party.where(conditions).count
|
||||
|
||||
render json: UserBlueprint.render(@user,
|
||||
view: :profile,
|
||||
root: 'profile',
|
||||
parties: parties,
|
||||
meta: { count: count, total_pages: (count.to_f / PartyConstants::COLLECTION_PER_PAGE).ceil, per_page: PartyConstants::COLLECTION_PER_PAGE },
|
||||
current_user: current_user
|
||||
)
|
||||
meta: {
|
||||
count: count,
|
||||
total_pages: count.to_f / COLLECTION_PER_PAGE > 1 ? (count.to_f / COLLECTION_PER_PAGE).ceil : 1,
|
||||
per_page: COLLECTION_PER_PAGE
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -103,42 +96,12 @@ module Api
|
|||
|
||||
private
|
||||
|
||||
def build_profile_query(profile_user)
|
||||
query = Party.includes(
|
||||
{ raid: :group },
|
||||
:job,
|
||||
:user,
|
||||
:skill0,
|
||||
:skill1,
|
||||
:skill2,
|
||||
:skill3,
|
||||
:guidebook1,
|
||||
:guidebook2,
|
||||
:guidebook3,
|
||||
{ characters: :character },
|
||||
{ weapons: :weapon },
|
||||
{ summons: :summon }
|
||||
)
|
||||
# Restrict to parties belonging to the profile’s owner.
|
||||
query = query.where(user_id: profile_user.id)
|
||||
# Then apply the additional filters that we normally use:
|
||||
query = query.where(name_quality)
|
||||
.where(user_quality)
|
||||
.where(original)
|
||||
.where(privacy)
|
||||
# And if there are any request-supplied filters, includes, or excludes:
|
||||
query = apply_filters(query) if params[:filters].present?
|
||||
query = apply_includes(query, params[:includes]) if params[:includes].present?
|
||||
query = apply_excludes(query, params[:excludes]) if params[:excludes].present?
|
||||
query.order(created_at: :desc)
|
||||
end
|
||||
|
||||
def build_conditions
|
||||
params = request.params
|
||||
|
||||
unless params['recency'].blank?
|
||||
start_time = (DateTime.current - params['recency'].to_i.seconds)
|
||||
.to_datetime.beginning_of_day
|
||||
.to_datetime.beginning_of_day
|
||||
end
|
||||
|
||||
min_characters_count = params['characters_count'].blank? ? DEFAULT_MIN_CHARACTERS : params['characters_count'].to_i
|
||||
|
|
|
|||
|
|
@ -5,18 +5,14 @@ module Api
|
|||
class WeaponKeysController < Api::V1::ApiController
|
||||
def all
|
||||
conditions = {}.tap do |hash|
|
||||
hash[:series] = request.params['series'].to_i unless request.params['series'].blank?
|
||||
hash[:slot] = request.params['slot'].to_i unless request.params['slot'].blank?
|
||||
hash[:group] = request.params['group'].to_i unless request.params['group'].blank?
|
||||
hash[:series] = request.params['series'] unless request.params['series'].blank?
|
||||
hash[:slot] = request.params['slot'] unless request.params['slot'].blank?
|
||||
hash[:group] = request.params['group'] unless request.params['group'].blank?
|
||||
end
|
||||
|
||||
# Build the query based on the conditions
|
||||
weapon_keys = WeaponKey.all
|
||||
weapon_keys = weapon_keys.where('? = ANY(series)', conditions[:series]) if conditions.key?(:series)
|
||||
weapon_keys = weapon_keys.where(slot: conditions[:slot]) if conditions.key?(:slot)
|
||||
weapon_keys = weapon_keys.where(group: conditions[:group]) if conditions.key?(:group)
|
||||
|
||||
render json: WeaponKeyBlueprint.render(weapon_keys)
|
||||
render json: WeaponKeyBlueprint.render(
|
||||
WeaponKey.where(conditions)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module PartyAuthorizationConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# Checks whether the current user (or provided edit key) is authorized to modify @party.
|
||||
def authorize_party!
|
||||
if @party.user.present?
|
||||
render_unauthorized_response unless current_user.present? && @party.user == current_user
|
||||
else
|
||||
provided_edit_key = edit_key.to_s.strip.force_encoding('UTF-8')
|
||||
party_edit_key = @party.edit_key.to_s.strip.force_encoding('UTF-8')
|
||||
render_unauthorized_response unless valid_edit_key?(provided_edit_key, party_edit_key)
|
||||
end
|
||||
end
|
||||
|
||||
# Returns true if the party does not belong to the current user.
|
||||
def not_owner?
|
||||
if @party.user
|
||||
return true if current_user && @party.user != current_user
|
||||
return true if current_user.nil? && edit_key.present?
|
||||
else
|
||||
return true if current_user.present?
|
||||
return true if current_user.nil? && (@party.edit_key != edit_key)
|
||||
end
|
||||
false
|
||||
end
|
||||
|
||||
# Verifies that the provided edit key matches the party's edit key.
|
||||
def valid_edit_key?(provided_edit_key, party_edit_key)
|
||||
provided_edit_key.present? &&
|
||||
provided_edit_key.bytesize == party_edit_key.bytesize &&
|
||||
ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key)
|
||||
end
|
||||
end
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module PartyPreviewConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# Schedules preview generation for this party.
|
||||
def schedule_preview_generation
|
||||
GeneratePartyPreviewJob.perform_later(id)
|
||||
end
|
||||
|
||||
# Handles serving the party preview image.
|
||||
def party_preview(party)
|
||||
coordinator = PreviewService::Coordinator.new(party)
|
||||
if coordinator.generation_in_progress?
|
||||
response.headers['Retry-After'] = '2'
|
||||
default_path = Rails.root.join('public', 'default-previews', "#{party.element || 'default'}.png")
|
||||
send_file default_path, type: 'image/png', disposition: 'inline'
|
||||
return
|
||||
end
|
||||
begin
|
||||
if Rails.env.production?
|
||||
s3_object = coordinator.get_s3_object
|
||||
send_data s3_object.body.read, filename: "#{party.shortcode}.png", type: 'image/png', disposition: 'inline'
|
||||
else
|
||||
send_file coordinator.local_preview_path, type: 'image/png', disposition: 'inline'
|
||||
end
|
||||
rescue Aws::S3::Errors::NoSuchKey
|
||||
coordinator.schedule_generation unless coordinator.generation_in_progress?
|
||||
send_file Rails.root.join('public', 'default-previews', "#{party.element || 'default'}.png"), type: 'image/png', disposition: 'inline'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module PartyQueryingConcern
|
||||
extend ActiveSupport::Concern
|
||||
include PartyConstants
|
||||
|
||||
# Returns the common base query for Parties including all necessary associations.
|
||||
def build_common_base_query
|
||||
Party.includes(
|
||||
{ raid: :group },
|
||||
:job,
|
||||
:user,
|
||||
:skill0,
|
||||
:skill1,
|
||||
:skill2,
|
||||
:skill3,
|
||||
:guidebook1,
|
||||
:guidebook2,
|
||||
:guidebook3,
|
||||
{ characters: :character },
|
||||
{ weapons: :weapon },
|
||||
{ summons: :summon }
|
||||
)
|
||||
end
|
||||
|
||||
# Uses PartyQueryBuilder to apply additional filters (includes, excludes, date ranges, etc.)
|
||||
def build_filtered_query(base_query)
|
||||
PartyQueryBuilder.new(base_query,
|
||||
params: params,
|
||||
current_user: current_user,
|
||||
options: { apply_defaults: true }).build
|
||||
end
|
||||
|
||||
# Renders paginated parties using PartyBlueprint.
|
||||
def render_paginated_parties(parties)
|
||||
render json: Api::V1::PartyBlueprint.render(
|
||||
parties,
|
||||
view: :preview,
|
||||
root: :results,
|
||||
meta: {
|
||||
count: parties.total_entries,
|
||||
total_pages: parties.total_pages,
|
||||
per_page: COLLECTION_PER_PAGE
|
||||
},
|
||||
current_user: current_user
|
||||
)
|
||||
end
|
||||
|
||||
# Returns a remixed party name based on the current party name and current_user language.
|
||||
def remixed_name(name)
|
||||
blanked_name = { en: name.blank? ? 'Untitled team' : name, ja: name.blank? ? '無名の編成' : name }
|
||||
if current_user
|
||||
case current_user.language
|
||||
when 'en' then "Remix of #{blanked_name[:en]}"
|
||||
when 'ja' then "#{blanked_name[:ja]}のリミックス"
|
||||
else "Remix of #{blanked_name[:en]}"
|
||||
end
|
||||
else
|
||||
"Remix of #{blanked_name[:en]}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
module V1
|
||||
class NoCharacterProvidedError < GranblueError
|
||||
def http_status
|
||||
422
|
||||
end
|
||||
|
||||
def code
|
||||
'no_character_provided'
|
||||
end
|
||||
|
||||
def message
|
||||
'A valid character must be provided'
|
||||
end
|
||||
|
||||
def to_hash
|
||||
{
|
||||
message: message,
|
||||
code: code
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
module V1
|
||||
class NoSummonProvidedError < GranblueError
|
||||
def http_status
|
||||
422
|
||||
end
|
||||
|
||||
def code
|
||||
'no_summon_provided'
|
||||
end
|
||||
|
||||
def message
|
||||
'A valid summon must be provided'
|
||||
end
|
||||
|
||||
def to_hash
|
||||
{
|
||||
message: message,
|
||||
code: code
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
module V1
|
||||
class NoWeaponProvidedError < GranblueError
|
||||
def http_status
|
||||
422
|
||||
end
|
||||
|
||||
def code
|
||||
'no_weapon_provided'
|
||||
end
|
||||
|
||||
def message
|
||||
'A valid weapon must be provided'
|
||||
end
|
||||
|
||||
def to_hash
|
||||
{
|
||||
message: message,
|
||||
code: code
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
278
app/helpers/character_parser.rb
Normal file
278
app/helpers/character_parser.rb
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'pry'
|
||||
|
||||
# CharacterParser parses character data from gbf.wiki
|
||||
class CharacterParser
|
||||
attr_reader :granblue_id
|
||||
|
||||
def initialize(granblue_id: String, debug: false)
|
||||
@character = Character.find_by(granblue_id: granblue_id)
|
||||
@wiki = GranblueWiki.new
|
||||
@debug = debug || false
|
||||
end
|
||||
|
||||
# Fetches using @wiki and then processes the response
|
||||
# Returns true if successful, false if not
|
||||
# Raises an exception if something went wrong
|
||||
def fetch(save: false)
|
||||
response = fetch_wiki_info
|
||||
return false if response.nil?
|
||||
|
||||
redirect = handle_redirected_string(response)
|
||||
return fetch(save: save) unless redirect.nil?
|
||||
|
||||
handle_fetch_success(response, save)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Determines whether or not the response is a redirect
|
||||
# If it is, it will update the character's wiki_en value
|
||||
def handle_redirected_string(response)
|
||||
redirect = extract_redirected_string(response)
|
||||
return unless redirect
|
||||
|
||||
@character.wiki_en = redirect
|
||||
if @character.save!
|
||||
ap "Saved new wiki_en value for #{@character.granblue_id}: #{redirect}" if @debug
|
||||
redirect
|
||||
else
|
||||
ap "Unable to save new wiki_en value for #{@character.granblue_id}: #{redirect}" if @debug
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Handle the response from the wiki if the response is successful
|
||||
# If the save flag is set, it will persist the data to the database
|
||||
def handle_fetch_success(response, save)
|
||||
ap "#{@character.granblue_id}: Successfully fetched info for #{@character.wiki_en}" if @debug
|
||||
extracted = parse_string(response)
|
||||
info = parse(extracted)
|
||||
persist(info) if save
|
||||
true
|
||||
end
|
||||
|
||||
# Determines whether the response string
|
||||
# should be treated as a redirect
|
||||
def extract_redirected_string(string)
|
||||
string.match(/#REDIRECT \[\[(.*?)\]\]/)&.captures&.first
|
||||
end
|
||||
|
||||
# Parses the response string into a hash
|
||||
def parse_string(string)
|
||||
lines = string.split("\n")
|
||||
data = {}
|
||||
stop_loop = false
|
||||
|
||||
lines.each do |line|
|
||||
next if stop_loop
|
||||
|
||||
if line.include?('Gameplay Notes')
|
||||
stop_loop = true
|
||||
next
|
||||
end
|
||||
|
||||
next unless line[0] == '|' && line.size > 2
|
||||
|
||||
key, value = line[1..].split('=', 2).map(&:strip)
|
||||
data[key] = value if value
|
||||
end
|
||||
|
||||
data
|
||||
end
|
||||
|
||||
# Fetches data from the GranblueWiki object
|
||||
def fetch_wiki_info
|
||||
@wiki.fetch(@character.wiki_en)
|
||||
rescue WikiError => e
|
||||
ap "There was an error fetching #{e.page}: #{e.message}" if @debug
|
||||
nil
|
||||
end
|
||||
|
||||
# Iterates over all characters in the database and fetches their data
|
||||
# If the save flag is set, data is saved to the database
|
||||
# If the overwrite flag is set, data is fetched even if it already exists
|
||||
# If the debug flag is set, additional information is printed to the console
|
||||
def self.fetch_all(save: false, overwrite: false, debug: false)
|
||||
errors = []
|
||||
|
||||
count = Character.count
|
||||
Character.all.each_with_index do |c, i|
|
||||
percentage = ((i + 1) / count.to_f * 100).round(2)
|
||||
ap "#{percentage}%: Fetching #{c.name_en}... (#{i + 1}/#{count})" if debug
|
||||
next unless c.release_date.nil? || overwrite
|
||||
|
||||
begin
|
||||
CharacterParser.new(granblue_id: c.granblue_id,
|
||||
debug: debug).fetch(save: save)
|
||||
rescue WikiError => e
|
||||
errors.push(e.page)
|
||||
end
|
||||
end
|
||||
|
||||
ap 'The following pages were unable to be fetched:'
|
||||
ap errors
|
||||
end
|
||||
|
||||
def self.fetch_list(list: [], save: false, overwrite: false, debug: false, start: nil)
|
||||
errors = []
|
||||
|
||||
start_index = start.nil? ? 0 : list.index { |id| id == start }
|
||||
count = list.drop(start_index).count
|
||||
|
||||
# ap "Start index: #{start_index}"
|
||||
|
||||
list.drop(start_index).each_with_index do |id, i|
|
||||
chara = Character.find_by(granblue_id: id)
|
||||
percentage = ((i + 1) / count.to_f * 100).round(2)
|
||||
ap "#{percentage}%: Fetching #{chara.wiki_en}... (#{i + 1}/#{count})" if debug
|
||||
next unless chara.release_date.nil? || overwrite
|
||||
|
||||
begin
|
||||
WeaponParser.new(granblue_id: chara.granblue_id,
|
||||
debug: debug).fetch(save: save)
|
||||
rescue WikiError => e
|
||||
errors.push(e.page)
|
||||
end
|
||||
end
|
||||
|
||||
ap 'The following pages were unable to be fetched:'
|
||||
ap errors
|
||||
end
|
||||
|
||||
# Parses the hash into a format that can be saved to the database
|
||||
def parse(hash)
|
||||
info = {}
|
||||
|
||||
info[:name] = { en: hash['name'], ja: hash['jpname'] }
|
||||
info[:id] = hash['id']
|
||||
info[:charid] = hash['charid'].scan(/\b\d{4}\b/)
|
||||
|
||||
info[:flb] = GranblueWiki.boolean.fetch(hash['5star'], false)
|
||||
info[:ulb] = hash['max_evo'].to_i == 6
|
||||
|
||||
info[:rarity] = GranblueWiki.rarities.fetch(hash['rarity'], 0)
|
||||
info[:element] = GranblueWiki.elements.fetch(hash['element'], 0)
|
||||
info[:gender] = GranblueWiki.genders.fetch(hash['gender'], 0)
|
||||
|
||||
info[:proficiencies] = proficiencies_from_hash(hash['weapon'])
|
||||
info[:races] = races_from_hash(hash['race'])
|
||||
|
||||
info[:hp] = {
|
||||
min_hp: hash['min_hp'].to_i,
|
||||
max_hp: hash['max_hp'].to_i,
|
||||
max_hp_flb: hash['flb_hp'].to_i
|
||||
}
|
||||
|
||||
info[:atk] = {
|
||||
min_atk: hash['min_atk'].to_i,
|
||||
max_atk: hash['max_atk'].to_i,
|
||||
max_atk_flb: hash['flb_atk'].to_i
|
||||
}
|
||||
|
||||
info[:dates] = {
|
||||
release_date: parse_date(hash['release_date']),
|
||||
flb_date: parse_date(hash['5star_date']),
|
||||
ulb_date: parse_date(hash['6star_date'])
|
||||
}
|
||||
|
||||
info[:links] = {
|
||||
wiki: { en: hash['name'], ja: hash['link_jpwiki'] },
|
||||
gamewith: hash['link_gamewith'],
|
||||
kamigame: hash['link_kamigame']
|
||||
}
|
||||
|
||||
info.compact
|
||||
end
|
||||
|
||||
# Saves select fields to the database
|
||||
def persist(hash)
|
||||
@character.release_date = hash[:dates][:release_date]
|
||||
@character.flb_date = hash[:dates][:flb_date] if hash[:dates].key?(:flb_date)
|
||||
@character.ulb_date = hash[:dates][:ulb_date] if hash[:dates].key?(:ulb_date)
|
||||
|
||||
@character.wiki_ja = hash[:links][:wiki][:ja] if hash[:links].key?(:wiki) && hash[:links][:wiki].key?(:ja)
|
||||
@character.gamewith = hash[:links][:gamewith] if hash[:links].key?(:gamewith)
|
||||
@character.kamigame = hash[:links][:kamigame] if hash[:links].key?(:kamigame)
|
||||
|
||||
if @character.save
|
||||
ap "#{@character.granblue_id}: Successfully saved info for #{@character.name_en}" if @debug
|
||||
puts
|
||||
true
|
||||
end
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
# Converts proficiencies from a string to a hash
|
||||
def proficiencies_from_hash(character)
|
||||
character.to_s.split(',').map.with_index do |prof, i|
|
||||
{ "proficiency#{i + 1}" => GranblueWiki.proficiencies[prof] }
|
||||
end.reduce({}, :merge)
|
||||
end
|
||||
|
||||
# Converts races from a string to a hash
|
||||
def races_from_hash(race)
|
||||
race.to_s.split(',').map.with_index do |r, i|
|
||||
{ "race#{i + 1}" => GranblueWiki.races[r] }
|
||||
end.reduce({}, :merge)
|
||||
end
|
||||
|
||||
# Parses a date string into a Date object
|
||||
def parse_date(date_str)
|
||||
Date.parse(date_str) unless date_str.blank?
|
||||
end
|
||||
|
||||
# Unused methods for now
|
||||
def extract_abilities(hash)
|
||||
abilities = []
|
||||
hash.each do |key, value|
|
||||
next unless key =~ /^a(\d+)_/
|
||||
|
||||
ability_number = Regexp.last_match(1).to_i
|
||||
abilities[ability_number] ||= {}
|
||||
|
||||
case key.gsub(/^a\d+_/, '')
|
||||
when 'cd'
|
||||
cooldown = parse_substring(value)
|
||||
abilities[ability_number]['cooldown'] = cooldown
|
||||
when 'dur'
|
||||
duration = parse_substring(value)
|
||||
abilities[ability_number]['duration'] = duration
|
||||
when 'oblevel'
|
||||
obtained = parse_substring(value)
|
||||
abilities[ability_number]['obtained'] = obtained
|
||||
else
|
||||
abilities[ability_number][key.gsub(/^a\d+_/, '')] = value
|
||||
end
|
||||
end
|
||||
|
||||
{ 'abilities' => abilities.compact }
|
||||
end
|
||||
|
||||
def parse_substring(string)
|
||||
hash = {}
|
||||
|
||||
string.scan(/\|([^|=]+?)=([^|]+)/) do |key, value|
|
||||
value.gsub!(/\}\}$/, '') if value.include?('}}')
|
||||
hash[key] = value
|
||||
end
|
||||
|
||||
hash
|
||||
end
|
||||
|
||||
def extract_ougis(hash)
|
||||
ougi = []
|
||||
hash.each do |key, value|
|
||||
next unless key =~ /^ougi(\d*)_(.*)/
|
||||
|
||||
ougi_number = Regexp.last_match(1)
|
||||
ougi_key = Regexp.last_match(2)
|
||||
ougi[ougi_number.to_i] ||= {}
|
||||
ougi[ougi_number.to_i][ougi_key] = value
|
||||
end
|
||||
|
||||
{ 'ougis' => ougi.compact }
|
||||
end
|
||||
end
|
||||
118
app/helpers/granblue_wiki.rb
Normal file
118
app/helpers/granblue_wiki.rb
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'httparty'
|
||||
|
||||
# GranblueWiki fetches and parses data from gbf.wiki
|
||||
class GranblueWiki
|
||||
class_attribute :base_uri
|
||||
|
||||
class_attribute :proficiencies
|
||||
class_attribute :elements
|
||||
class_attribute :rarities
|
||||
class_attribute :genders
|
||||
class_attribute :races
|
||||
class_attribute :bullets
|
||||
class_attribute :boolean
|
||||
|
||||
self.base_uri = 'https://gbf.wiki/api.php'
|
||||
|
||||
self.proficiencies = {
|
||||
'Sabre' => 1,
|
||||
'Dagger' => 2,
|
||||
'Axe' => 3,
|
||||
'Spear' => 4,
|
||||
'Bow' => 5,
|
||||
'Staff' => 6,
|
||||
'Melee' => 7,
|
||||
'Harp' => 8,
|
||||
'Gun' => 9,
|
||||
'Katana' => 10
|
||||
}.freeze
|
||||
|
||||
self.elements = {
|
||||
'Wind' => 1,
|
||||
'Fire' => 2,
|
||||
'Water' => 3,
|
||||
'Earth' => 4,
|
||||
'Dark' => 5,
|
||||
'Light' => 6
|
||||
}.freeze
|
||||
|
||||
self.rarities = {
|
||||
'R' => 1,
|
||||
'SR' => 2,
|
||||
'SSR' => 3
|
||||
}.freeze
|
||||
|
||||
self.races = {
|
||||
'Other' => 0,
|
||||
'Human' => 1,
|
||||
'Erune' => 2,
|
||||
'Draph' => 3,
|
||||
'Harvin' => 4,
|
||||
'Primal' => 5
|
||||
}.freeze
|
||||
|
||||
self.genders = {
|
||||
'o' => 0,
|
||||
'm' => 1,
|
||||
'f' => 2,
|
||||
'mf' => 3
|
||||
}.freeze
|
||||
|
||||
self.bullets = {
|
||||
'cartridge' => 1,
|
||||
'rifle' => 2,
|
||||
'parabellum' => 3,
|
||||
'aetherial' => 4
|
||||
}.freeze
|
||||
|
||||
self.boolean = {
|
||||
'yes' => true,
|
||||
'no' => false
|
||||
}.freeze
|
||||
|
||||
def initialize(props: ['wikitext'], debug: false)
|
||||
@debug = debug
|
||||
@props = props.join('|')
|
||||
end
|
||||
|
||||
def fetch(page)
|
||||
query_params = params(page).map do |key, value|
|
||||
"#{key}=#{value}"
|
||||
end.join('&')
|
||||
|
||||
destination = "#{base_uri}?#{query_params}"
|
||||
ap "--> Fetching #{destination}" if @debug
|
||||
|
||||
response = HTTParty.get(destination)
|
||||
|
||||
handle_response(response, page)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def handle_response(response, page)
|
||||
case response.code
|
||||
when 200
|
||||
if response.key?('error')
|
||||
raise WikiError.new(code: response['error']['code'],
|
||||
message: response['error']['info'],
|
||||
page: page)
|
||||
end
|
||||
|
||||
response['parse']['wikitext']['*']
|
||||
when 404 then puts "Page #{page} not found"
|
||||
when 500...600 then puts "Server error: #{response.code}"
|
||||
end
|
||||
end
|
||||
|
||||
def params(page)
|
||||
{
|
||||
action: 'parse',
|
||||
format: 'json',
|
||||
page: page,
|
||||
prop: @props
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
#
|
||||
# This module contains shared constants used for querying and filtering Party resources.
|
||||
# It is included by controllers and concerns that require these configuration values.
|
||||
#
|
||||
module PartyConstants
|
||||
COLLECTION_PER_PAGE = 15
|
||||
DEFAULT_MIN_CHARACTERS = 3
|
||||
DEFAULT_MIN_SUMMONS = 2
|
||||
DEFAULT_MIN_WEAPONS = 5
|
||||
MAX_CHARACTERS = 5
|
||||
MAX_SUMMONS = 8
|
||||
MAX_WEAPONS = 13
|
||||
DEFAULT_MAX_CLEAR_TIME = 5400
|
||||
end
|
||||
251
app/helpers/summon_parser.rb
Normal file
251
app/helpers/summon_parser.rb
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'pry'
|
||||
|
||||
# SummonParser parses summon data from gbf.wiki
|
||||
class SummonParser
|
||||
attr_reader :granblue_id
|
||||
|
||||
def initialize(granblue_id: String, debug: false)
|
||||
@summon = Summon.find_by(granblue_id: granblue_id)
|
||||
@wiki = GranblueWiki.new(debug: debug)
|
||||
@debug = debug || false
|
||||
end
|
||||
|
||||
# Fetches using @wiki and then processes the response
|
||||
# Returns true if successful, false if not
|
||||
# Raises an exception if something went wrong
|
||||
def fetch(name = nil, save: false)
|
||||
response = fetch_wiki_info(name)
|
||||
return false if response.nil?
|
||||
|
||||
if response.starts_with?('#REDIRECT')
|
||||
# Fetch the string inside of [[]]
|
||||
redirect = response[/\[\[(.*?)\]\]/m, 1]
|
||||
fetch(redirect, save: save)
|
||||
else
|
||||
# return response if response[:error]
|
||||
handle_fetch_success(response, save)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Handle the response from the wiki if the response is successful
|
||||
# If the save flag is set, it will persist the data to the database
|
||||
def handle_fetch_success(response, save)
|
||||
ap "#{@summon.granblue_id}: Successfully fetched info for #{@summon.wiki_en}" if @debug
|
||||
|
||||
extracted = parse_string(response)
|
||||
|
||||
unless extracted[:template].nil?
|
||||
template = @wiki.fetch("Template:#{extracted[:template]}")
|
||||
extracted.merge!(parse_string(template))
|
||||
end
|
||||
|
||||
info, skills = parse(extracted)
|
||||
|
||||
# ap info
|
||||
# ap skills
|
||||
|
||||
persist(info[:info]) if save
|
||||
true
|
||||
end
|
||||
|
||||
# Fetches the wiki info from the wiki
|
||||
# Returns the response body
|
||||
# Raises an exception if something went wrong
|
||||
def fetch_wiki_info(name = nil)
|
||||
@wiki.fetch(name || @summon.wiki_en)
|
||||
rescue WikiError => e
|
||||
ap e
|
||||
# ap "There was an error fetching #{e.page}: #{e.message}" if @debug
|
||||
{
|
||||
error: {
|
||||
name: @summon.wiki_en,
|
||||
granblue_id: @summon.granblue_id
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
# Iterates over all summons in the database and fetches their data
|
||||
# If the save flag is set, data is saved to the database
|
||||
# If the overwrite flag is set, data is fetched even if it already exists
|
||||
# If the debug flag is set, additional information is printed to the console
|
||||
def self.fetch_all(save: false, overwrite: false, debug: false, start: nil)
|
||||
errors = []
|
||||
|
||||
summons = Summon.all.order(:granblue_id)
|
||||
|
||||
start_index = start.nil? ? 0 : summons.index { |w| w.granblue_id == start }
|
||||
count = summons.drop(start_index).count
|
||||
|
||||
# ap "Start index: #{start_index}"
|
||||
|
||||
summons.drop(start_index).each_with_index do |w, i|
|
||||
percentage = ((i + 1) / count.to_f * 100).round(2)
|
||||
ap "#{percentage}%: Fetching #{w.wiki_en}... (#{i + 1}/#{count})" if debug
|
||||
next unless w.release_date.nil? || overwrite
|
||||
|
||||
begin
|
||||
SummonParser.new(granblue_id: w.granblue_id,
|
||||
debug: debug).fetch(save: save)
|
||||
rescue WikiError => e
|
||||
errors.push(e.page)
|
||||
end
|
||||
end
|
||||
|
||||
ap 'The following pages were unable to be fetched:'
|
||||
ap errors
|
||||
end
|
||||
|
||||
def self.fetch_list(list: [], save: false, overwrite: false, debug: false, start: nil)
|
||||
errors = []
|
||||
|
||||
start_index = start.nil? ? 0 : list.index { |id| id == start }
|
||||
count = list.drop(start_index).count
|
||||
|
||||
# ap "Start index: #{start_index}"
|
||||
|
||||
list.drop(start_index).each_with_index do |id, i|
|
||||
summon = Summon.find_by(granblue_id: id)
|
||||
percentage = ((i + 1) / count.to_f * 100).round(2)
|
||||
ap "#{percentage}%: Fetching #{summon.wiki_en}... (#{i + 1}/#{count})" if debug
|
||||
next unless summon.release_date.nil? || overwrite
|
||||
|
||||
begin
|
||||
SummonParser.new(granblue_id: summon.granblue_id,
|
||||
debug: debug).fetch(save: save)
|
||||
rescue WikiError => e
|
||||
errors.push(e.page)
|
||||
end
|
||||
end
|
||||
|
||||
ap 'The following pages were unable to be fetched:'
|
||||
ap errors
|
||||
end
|
||||
|
||||
# Parses the response string into a hash
|
||||
def parse_string(string)
|
||||
data = {}
|
||||
lines = string.split("\n")
|
||||
stop_loop = false
|
||||
|
||||
lines.each do |line|
|
||||
next if stop_loop
|
||||
|
||||
if line.include?('Gameplay Notes')
|
||||
stop_loop = true
|
||||
next
|
||||
end
|
||||
|
||||
if line.starts_with?('{{')
|
||||
substr = line[2..].strip! || line[2..]
|
||||
|
||||
# All template tags start with {{ so we can skip the first two characters
|
||||
disallowed = %w[#vardefine #lsth About]
|
||||
next if substr.start_with?(*disallowed)
|
||||
|
||||
if substr.start_with?('Summon')
|
||||
ap "--> Found template: #{substr}" if @debug
|
||||
|
||||
substr = substr.split('|').first
|
||||
data[:template] = substr if substr != 'Summon'
|
||||
next
|
||||
end
|
||||
end
|
||||
|
||||
next unless line[0] == '|' && line.size > 2
|
||||
|
||||
key, value = line[1..].split('=', 2).map(&:strip)
|
||||
|
||||
regex = /\A\{\{\{.*\|\}\}\}\z/
|
||||
next if value =~ regex
|
||||
|
||||
data[key] = value if value
|
||||
end
|
||||
|
||||
data
|
||||
end
|
||||
|
||||
# Parses the hash into a format that can be saved to the database
|
||||
def parse(hash)
|
||||
info = {}
|
||||
skills = {}
|
||||
|
||||
info[:name] = { en: hash['name'], ja: hash['jpname'] }
|
||||
info[:flavor] = { en: hash['flavor'], ja: hash['jpflavor'] }
|
||||
info[:id] = hash['id']
|
||||
|
||||
info[:flb] = hash['evo_max'].to_i >= 4
|
||||
info[:ulb] = hash['evo_max'].to_i >= 5
|
||||
info[:xlb] = hash['evo_max'].to_i == 6
|
||||
|
||||
info[:rarity] = rarity_from_hash(hash['rarity'])
|
||||
info[:series] = hash['series']
|
||||
info[:obtain] = hash['obtain']
|
||||
|
||||
info[:hp] = {
|
||||
min_hp: hash['hp1'].to_i,
|
||||
max_hp: hash['hp2'].to_i,
|
||||
max_hp_flb: hash['hp3'].to_i,
|
||||
max_hp_ulb: hash['hp4'].to_i.zero? ? nil : hash['hp4'].to_i,
|
||||
max_hp_xlb: hash['hp5'].to_i.zero? ? nil : hash['hp5'].to_i
|
||||
}
|
||||
|
||||
info[:atk] = {
|
||||
min_atk: hash['atk1'].to_i,
|
||||
max_atk: hash['atk2'].to_i,
|
||||
max_atk_flb: hash['atk3'].to_i,
|
||||
max_atk_ulb: hash['atk4'].to_i.zero? ? nil : hash['atk4'].to_i,
|
||||
max_atk_xlb: hash['atk5'].to_i.zero? ? nil : hash['atk5'].to_i
|
||||
}
|
||||
|
||||
info[:dates] = {
|
||||
release_date: parse_date(hash['release_date']),
|
||||
flb_date: parse_date(hash['4star_date']),
|
||||
ulb_date: parse_date(hash['5star_date']),
|
||||
xlb_date: parse_date(hash['6star_date'])
|
||||
}
|
||||
|
||||
info[:links] = {
|
||||
wiki: { en: hash['name'], ja: hash['link_jpwiki'] },
|
||||
gamewith: hash['link_gamewith'],
|
||||
kamigame: hash['link_kamigame']
|
||||
}
|
||||
|
||||
{
|
||||
info: info.compact
|
||||
# skills: skills.compact
|
||||
}
|
||||
end
|
||||
|
||||
# Saves select fields to the database
|
||||
def persist(hash)
|
||||
@summon.release_date = hash[:dates][:release_date]
|
||||
@summon.flb_date = hash[:dates][:flb_date] if hash[:dates].key?(:flb_date)
|
||||
@summon.ulb_date = hash[:dates][:ulb_date] if hash[:dates].key?(:ulb_date)
|
||||
|
||||
@summon.wiki_ja = hash[:links][:wiki][:ja] if hash[:links].key?(:wiki) && hash[:links][:wiki].key?(:ja)
|
||||
@summon.gamewith = hash[:links][:gamewith] if hash[:links].key?(:gamewith)
|
||||
@summon.kamigame = hash[:links][:kamigame] if hash[:links].key?(:kamigame)
|
||||
|
||||
if @summon.save
|
||||
ap "#{@summon.granblue_id}: Successfully saved info for #{@summon.wiki_en}" if @debug
|
||||
puts
|
||||
true
|
||||
end
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
# Converts rarities from a string to a hash
|
||||
def rarity_from_hash(string)
|
||||
string ? GranblueWiki.rarities[string.upcase] : nil
|
||||
end
|
||||
|
||||
# Parses a date string into a Date object
|
||||
def parse_date(date_str)
|
||||
Date.parse(date_str) unless date_str.blank?
|
||||
end
|
||||
end
|
||||
35
app/helpers/validation_error_serializer.rb
Normal file
35
app/helpers/validation_error_serializer.rb
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ValidationErrorSerializer
|
||||
def initialize(record, field, details)
|
||||
@record = record
|
||||
@field = field
|
||||
@details = details
|
||||
end
|
||||
|
||||
def serialize
|
||||
{
|
||||
resource: resource,
|
||||
field: field,
|
||||
code: code
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resource
|
||||
@record.class.to_s
|
||||
end
|
||||
|
||||
def field
|
||||
@field.to_s
|
||||
end
|
||||
|
||||
def code
|
||||
@details[:error].to_s
|
||||
end
|
||||
|
||||
def underscored_resource_name
|
||||
@record.class.to_s.gsub('::', '').underscore
|
||||
end
|
||||
end
|
||||
17
app/helpers/validation_errors_serializer.rb
Normal file
17
app/helpers/validation_errors_serializer.rb
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ValidationErrorsSerializer
|
||||
attr_reader :record
|
||||
|
||||
def initialize(record)
|
||||
@record = record
|
||||
end
|
||||
|
||||
def serialize
|
||||
record.errors.details.map do |field, details|
|
||||
details.map do |error_details|
|
||||
ValidationErrorSerializer.new(record, field, error_details).serialize
|
||||
end
|
||||
end.flatten
|
||||
end
|
||||
end
|
||||
296
app/helpers/weapon_parser.rb
Normal file
296
app/helpers/weapon_parser.rb
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'pry'
|
||||
|
||||
# WeaponParser parses weapon data from gbf.wiki
|
||||
class WeaponParser
|
||||
attr_reader :granblue_id
|
||||
|
||||
def initialize(granblue_id: String, debug: false)
|
||||
@weapon = Weapon.find_by(granblue_id: granblue_id)
|
||||
@wiki = GranblueWiki.new(debug: debug)
|
||||
@debug = debug || false
|
||||
end
|
||||
|
||||
# Fetches using @wiki and then processes the response
|
||||
# Returns true if successful, false if not
|
||||
# Raises an exception if something went wrong
|
||||
def fetch(save: false)
|
||||
response = fetch_wiki_info
|
||||
return false if response.nil?
|
||||
|
||||
# return response if response[:error]
|
||||
|
||||
handle_fetch_success(response, save)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Handle the response from the wiki if the response is successful
|
||||
# If the save flag is set, it will persist the data to the database
|
||||
def handle_fetch_success(response, save)
|
||||
ap "#{@weapon.granblue_id}: Successfully fetched info for #{@weapon.wiki_en}" if @debug
|
||||
extracted = parse_string(response)
|
||||
|
||||
unless extracted[:template].nil?
|
||||
template = @wiki.fetch("Template:#{extracted[:template]}")
|
||||
extracted.merge!(parse_string(template))
|
||||
end
|
||||
|
||||
info, skills = parse(extracted)
|
||||
|
||||
# ap info
|
||||
# ap skills
|
||||
|
||||
persist(info[:info]) if save
|
||||
true
|
||||
end
|
||||
|
||||
# Fetches the wiki info from the wiki
|
||||
# Returns the response body
|
||||
# Raises an exception if something went wrong
|
||||
def fetch_wiki_info
|
||||
@wiki.fetch(@weapon.wiki_en)
|
||||
rescue WikiError => e
|
||||
ap e
|
||||
# ap "There was an error fetching #{e.page}: #{e.message}" if @debug
|
||||
{
|
||||
error: {
|
||||
name: @weapon.wiki_en,
|
||||
granblue_id: @weapon.granblue_id
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
# Iterates over all weapons in the database and fetches their data
|
||||
# If the save flag is set, data is saved to the database
|
||||
# If the overwrite flag is set, data is fetched even if it already exists
|
||||
# If the debug flag is set, additional information is printed to the console
|
||||
def self.fetch_all(save: false, overwrite: false, debug: false, start: nil)
|
||||
errors = []
|
||||
|
||||
weapons = Weapon.all.order(:granblue_id)
|
||||
|
||||
start_index = start.nil? ? 0 : weapons.index { |w| w.granblue_id == start }
|
||||
count = weapons.drop(start_index).count
|
||||
|
||||
# ap "Start index: #{start_index}"
|
||||
|
||||
weapons.drop(start_index).each_with_index do |w, i|
|
||||
percentage = ((i + 1) / count.to_f * 100).round(2)
|
||||
ap "#{percentage}%: Fetching #{w.wiki_en}... (#{i + 1}/#{count})" if debug
|
||||
next if w.wiki_en.include?('Element Changed') || w.wiki_en.include?('Awakened')
|
||||
next unless w.release_date.nil? || overwrite
|
||||
|
||||
begin
|
||||
WeaponParser.new(granblue_id: w.granblue_id,
|
||||
debug: debug).fetch(save: save)
|
||||
rescue WikiError => e
|
||||
errors.push(e.page)
|
||||
end
|
||||
end
|
||||
|
||||
ap 'The following pages were unable to be fetched:'
|
||||
ap errors
|
||||
end
|
||||
|
||||
def self.fetch_list(list: [], save: false, overwrite: false, debug: false, start: nil)
|
||||
errors = []
|
||||
|
||||
start_index = start.nil? ? 0 : list.index { |id| id == start }
|
||||
count = list.drop(start_index).count
|
||||
|
||||
# ap "Start index: #{start_index}"
|
||||
|
||||
list.drop(start_index).each_with_index do |id, i|
|
||||
weapon = Weapon.find_by(granblue_id: id)
|
||||
percentage = ((i + 1) / count.to_f * 100).round(2)
|
||||
ap "#{percentage}%: Fetching #{weapon.wiki_en}... (#{i + 1}/#{count})" if debug
|
||||
next unless weapon.release_date.nil? || overwrite
|
||||
|
||||
begin
|
||||
WeaponParser.new(granblue_id: weapon.granblue_id,
|
||||
debug: debug).fetch(save: save)
|
||||
rescue WikiError => e
|
||||
errors.push(e.page)
|
||||
end
|
||||
end
|
||||
|
||||
ap 'The following pages were unable to be fetched:'
|
||||
ap errors
|
||||
end
|
||||
|
||||
# Parses the response string into a hash
|
||||
def parse_string(string)
|
||||
data = {}
|
||||
lines = string.split("\n")
|
||||
stop_loop = false
|
||||
|
||||
lines.each do |line|
|
||||
next if stop_loop
|
||||
|
||||
if line.include?('Gameplay Notes')
|
||||
stop_loop = true
|
||||
next
|
||||
end
|
||||
|
||||
if line.starts_with?('{{')
|
||||
substr = line[2..].strip! || line[2..]
|
||||
|
||||
# All template tags start with {{ so we can skip the first two characters
|
||||
disallowed = %w[#vardefine #lsth About]
|
||||
next if substr.start_with?(*disallowed)
|
||||
|
||||
if substr.start_with?('Weapon')
|
||||
ap "--> Found template: #{substr}" if @debug
|
||||
|
||||
substr = substr.split('|').first
|
||||
data[:template] = substr if substr != 'Weapon'
|
||||
next
|
||||
end
|
||||
end
|
||||
|
||||
next unless line[0] == '|' && line.size > 2
|
||||
|
||||
key, value = line[1..].split('=', 2).map(&:strip)
|
||||
|
||||
regex = /\A\{\{\{.*\|\}\}\}\z/
|
||||
next if value =~ regex
|
||||
|
||||
data[key] = value if value
|
||||
end
|
||||
|
||||
data
|
||||
end
|
||||
|
||||
# Parses the hash into a format that can be saved to the database
|
||||
def parse(hash)
|
||||
info = {}
|
||||
skills = {}
|
||||
|
||||
info[:name] = { en: hash['name'], ja: hash['jpname'] }
|
||||
info[:flavor] = { en: hash['flavor'], ja: hash['jpflavor'] }
|
||||
info[:id] = hash['id']
|
||||
|
||||
info[:flb] = hash['evo_max'].to_i >= 4
|
||||
info[:ulb] = hash['evo_max'].to_i == 5
|
||||
|
||||
info[:rarity] = rarity_from_hash(hash['rarity'])
|
||||
info[:proficiency] = proficiency_from_hash(hash['weapon'])
|
||||
info[:series] = hash['series']
|
||||
info[:obtain] = hash['obtain']
|
||||
|
||||
if hash.key?('bullets')
|
||||
info[:bullets] = {
|
||||
count: hash['bullets'].to_i,
|
||||
loadout: [
|
||||
bullet_from_hash(hash['bullet1']),
|
||||
bullet_from_hash(hash['bullet2']),
|
||||
bullet_from_hash(hash['bullet3']),
|
||||
bullet_from_hash(hash['bullet4']),
|
||||
bullet_from_hash(hash['bullet5']),
|
||||
bullet_from_hash(hash['bullet6'])
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
info[:hp] = {
|
||||
min_hp: hash['hp1'].to_i,
|
||||
max_hp: hash['hp2'].to_i,
|
||||
max_hp_flb: hash['hp3'].to_i,
|
||||
max_hp_ulb: hash['hp4'].to_i.zero? ? nil : hash['hp4'].to_i
|
||||
}
|
||||
|
||||
info[:atk] = {
|
||||
min_atk: hash['atk1'].to_i,
|
||||
max_atk: hash['atk2'].to_i,
|
||||
max_atk_flb: hash['atk3'].to_i,
|
||||
max_atk_ulb: hash['atk4'].to_i.zero? ? nil : hash['atk4'].to_i
|
||||
}
|
||||
|
||||
info[:dates] = {
|
||||
release_date: parse_date(hash['release_date']),
|
||||
flb_date: parse_date(hash['4star_date']),
|
||||
ulb_date: parse_date(hash['5star_date'])
|
||||
}
|
||||
|
||||
info[:links] = {
|
||||
wiki: { en: hash['name'], ja: hash['link_jpwiki'] },
|
||||
gamewith: hash['link_gamewith'],
|
||||
kamigame: hash['link_kamigame']
|
||||
}
|
||||
|
||||
skills[:charge_attack] = {
|
||||
name: { en: hash['ougi_name'], ja: hash['jpougi_name'] },
|
||||
description: {
|
||||
mlb: {
|
||||
en: hash['enougi'],
|
||||
ja: hash['jpougi']
|
||||
},
|
||||
flb: {
|
||||
en: hash['enougi_4s'],
|
||||
ja: hash['jpougi_4s']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
skills[:skills] = [
|
||||
{
|
||||
name: { en: hash['s1_name'], ja: nil },
|
||||
description: { en: hash['ens1_desc'] || hash['s1_desc'], ja: nil }
|
||||
},
|
||||
{
|
||||
name: { en: hash['s2_name'], ja: nil },
|
||||
description: { en: hash['ens2_desc'] || hash['s2_desc'], ja: nil }
|
||||
},
|
||||
{
|
||||
name: { en: hash['s3_name'], ja: nil },
|
||||
description: { en: hash['ens3_desc'] || hash['s3_desc'], ja: nil }
|
||||
}
|
||||
]
|
||||
|
||||
{
|
||||
info: info.compact,
|
||||
skills: skills.compact
|
||||
}
|
||||
end
|
||||
|
||||
# Saves select fields to the database
|
||||
def persist(hash)
|
||||
@weapon.release_date = hash[:dates][:release_date]
|
||||
@weapon.flb_date = hash[:dates][:flb_date] if hash[:dates].key?(:flb_date)
|
||||
@weapon.ulb_date = hash[:dates][:ulb_date] if hash[:dates].key?(:ulb_date)
|
||||
|
||||
@weapon.wiki_ja = hash[:links][:wiki][:ja] if hash[:links].key?(:wiki) && hash[:links][:wiki].key?(:ja)
|
||||
@weapon.gamewith = hash[:links][:gamewith] if hash[:links].key?(:gamewith)
|
||||
@weapon.kamigame = hash[:links][:kamigame] if hash[:links].key?(:kamigame)
|
||||
|
||||
if @weapon.save
|
||||
ap "#{@weapon.granblue_id}: Successfully saved info for #{@weapon.wiki_en}" if @debug
|
||||
puts
|
||||
true
|
||||
end
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
# Converts rarities from a string to a hash
|
||||
def rarity_from_hash(string)
|
||||
string ? GranblueWiki.rarities[string.upcase] : nil
|
||||
end
|
||||
|
||||
# Converts proficiencies from a string to a hash
|
||||
def proficiency_from_hash(string)
|
||||
GranblueWiki.proficiencies[string]
|
||||
end
|
||||
|
||||
# Converts a bullet type from a string to a hash
|
||||
def bullet_from_hash(string)
|
||||
string ? GranblueWiki.bullets[string] : nil
|
||||
end
|
||||
|
||||
# Parses a date string into a Date object
|
||||
def parse_date(date_str)
|
||||
Date.parse(date_str) unless date_str.blank?
|
||||
end
|
||||
end
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ApplicationJob < ActiveJob::Base
|
||||
end
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
class CleanupPartyPreviewsJob < ApplicationJob
|
||||
queue_as :maintenance
|
||||
|
||||
def perform
|
||||
Party.where(preview_state: :generated)
|
||||
.where('preview_generated_at < ?', PreviewService::Coordinator::PREVIEW_EXPIRY.ago)
|
||||
.find_each do |party|
|
||||
PreviewService::Coordinator.new(party).delete_preview
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
# app/jobs/generate_party_preview_job.rb
|
||||
class GeneratePartyPreviewJob < ApplicationJob
|
||||
queue_as :previews
|
||||
|
||||
# Configure retry behavior
|
||||
retry_on StandardError, wait: :exponentially_longer, attempts: 3
|
||||
|
||||
discard_on ActiveRecord::RecordNotFound do |job, error|
|
||||
Rails.logger.error("Party #{job.arguments.first} not found for preview generation")
|
||||
end
|
||||
|
||||
around_perform :track_timing
|
||||
|
||||
def perform(party_id)
|
||||
Rails.logger.info("Starting preview generation for party #{party_id}")
|
||||
Rails.logger.info("Debug: should_generate? check starting")
|
||||
|
||||
party = Party.find(party_id)
|
||||
Rails.logger.info("Party found: #{party.inspect}")
|
||||
|
||||
if party.preview_state == 'generated' &&
|
||||
party.preview_generated_at &&
|
||||
party.preview_generated_at > 1.hour.ago
|
||||
Rails.logger.info("Skipping preview generation - recent preview exists")
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
Rails.logger.info("Initializing PreviewService::Coordinator")
|
||||
service = PreviewService::Coordinator.new(party)
|
||||
Rails.logger.info("Coordinator initialized")
|
||||
|
||||
Rails.logger.info("Checking should_generate?")
|
||||
should_gen = service.send(:should_generate?)
|
||||
Rails.logger.info("should_generate? returned: #{should_gen}")
|
||||
|
||||
if !should_gen
|
||||
Rails.logger.info("Not generating preview because should_generate? returned false")
|
||||
Rails.logger.info("Preview state: #{party.preview_state}")
|
||||
Rails.logger.info("Generation in progress: #{service.send(:generation_in_progress?)}")
|
||||
return
|
||||
end
|
||||
|
||||
Rails.logger.info("Starting generate_preview")
|
||||
result = service.generate_preview
|
||||
Rails.logger.info("Generate preview result: #{result}")
|
||||
|
||||
if result
|
||||
Rails.logger.info("Successfully generated preview for party #{party_id}")
|
||||
else
|
||||
Rails.logger.error("Failed to generate preview for party #{party_id}")
|
||||
notify_failure(party)
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.error("Error generating preview for party #{party_id}: #{e.message}")
|
||||
Rails.logger.error("Full error details:")
|
||||
Rails.logger.error(e.full_message)
|
||||
notify_failure(party, e)
|
||||
raise
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def track_timing
|
||||
start_time = Time.current
|
||||
job_id = job_id
|
||||
|
||||
Rails.logger.info("Preview generation job #{job_id} starting")
|
||||
|
||||
yield
|
||||
|
||||
duration = Time.current - start_time
|
||||
Rails.logger.info("Preview generation job #{job_id} completed in #{duration.round(2)}s")
|
||||
|
||||
# Track metrics if you have a metrics service
|
||||
# StatsD.timing("preview_generation.duration", duration * 1000)
|
||||
end
|
||||
|
||||
def notify_failure(party, error = nil)
|
||||
# Log to error tracking service if you have one
|
||||
# Sentry.capture_exception(error) if error
|
||||
|
||||
# You could also notify admins through Slack/email for critical failures
|
||||
message = if error
|
||||
"Preview generation failed for party #{party.id} with error: #{error.message}"
|
||||
else
|
||||
"Preview generation failed for party #{party.id}"
|
||||
end
|
||||
|
||||
# SlackNotifier.notify(message) # If you have Slack integration
|
||||
end
|
||||
end
|
||||
|
|
@ -1,8 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Awakening < ApplicationRecord
|
||||
has_many :weapon_awakenings, foreign_key: :awakening_id
|
||||
has_many :weapons, through: :weapon_awakenings
|
||||
def weapon_awakenings
|
||||
WeaponAwakening.where(awakening_id: id)
|
||||
end
|
||||
|
||||
def weapons
|
||||
weapon_awakenings.map(&:weapon)
|
||||
end
|
||||
|
||||
def awakening
|
||||
AwakeningBlueprint
|
||||
|
|
|
|||
|
|
@ -34,13 +34,6 @@ class Character < ApplicationRecord
|
|||
}
|
||||
}
|
||||
|
||||
AWAKENINGS = [
|
||||
{ slug: 'character-balanced', name_en: 'Balanced', name_jp: 'バランス', order: 0 },
|
||||
{ slug: 'character-atk', name_en: 'Attack', name_jp: '攻撃', order: 1 },
|
||||
{ slug: 'character-def', name_en: 'Defense', name_jp: '防御', order: 2 },
|
||||
{ slug: 'character-multi', name_en: 'Multiattack', name_jp: '連続攻撃', order: 3 }
|
||||
].freeze
|
||||
|
||||
def blueprint
|
||||
CharacterBlueprint
|
||||
end
|
||||
|
|
@ -48,4 +41,73 @@ class Character < ApplicationRecord
|
|||
def display_resource(character)
|
||||
character.name_en
|
||||
end
|
||||
|
||||
# enum rarities: {
|
||||
# R: 1,
|
||||
# SR: 2,
|
||||
# SSR: 3
|
||||
# }
|
||||
|
||||
# enum elements: {
|
||||
# Null: 0,
|
||||
# Wind: 1,
|
||||
# Fire: 2,
|
||||
# Water: 3,
|
||||
# Earth: 4,
|
||||
# Dark: 5,
|
||||
# Light: 6
|
||||
# }
|
||||
|
||||
# enum proficiency1s: {
|
||||
# Sabre: 1,
|
||||
# Dagger: 2,
|
||||
# Axe: 3,
|
||||
# Spear: 4,
|
||||
# Bow: 5,
|
||||
# Staff: 6,
|
||||
# Melee: 7,
|
||||
# Harp: 8,
|
||||
# Gun: 9,
|
||||
# Katana: 10
|
||||
# }, _prefix: "proficiency1"
|
||||
|
||||
# enum proficiency2s: {
|
||||
# None: 0,
|
||||
# Sabre: 1,
|
||||
# Dagger: 2,
|
||||
# Axe: 3,
|
||||
# Spear: 4,
|
||||
# Bow: 5,
|
||||
# Staff: 6,
|
||||
# Melee: 7,
|
||||
# Harp: 8,
|
||||
# Gun: 9,
|
||||
# Katana: 10,
|
||||
# }, _default: :None, _prefix: "proficiency2"
|
||||
|
||||
# enum race1s: {
|
||||
# Unknown: 0,
|
||||
# Human: 1,
|
||||
# Erune: 2,
|
||||
# Draph: 3,
|
||||
# Harvin: 4,
|
||||
# Primal: 5
|
||||
# }, _prefix: "race1"
|
||||
|
||||
# enum race2s: {
|
||||
# Unknown: 0,
|
||||
# Human: 1,
|
||||
# Erune: 2,
|
||||
# Draph: 3,
|
||||
# Harvin: 4,
|
||||
# Primal: 5,
|
||||
# None: 6
|
||||
# }, _default: :None, _prefix: "race2"
|
||||
|
||||
# enum gender: {
|
||||
# Unknown: 0,
|
||||
# Male: 1,
|
||||
# Female: 2,
|
||||
# "Male/Female": 3
|
||||
# }
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
module GranblueEnums
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# Define constants for shared enum mappings.
|
||||
RARITIES = { R: 1, SR: 2, SSR: 3 }.freeze
|
||||
ELEMENTS = { Null: 0, Wind: 1, Fire: 2, Water: 3, Earth: 4, Dark: 5, Light: 6 }.freeze
|
||||
GENDERS = { Unknown: 0, Male: 1, Female: 2, "Male/Female": 3 }.freeze
|
||||
|
||||
# Single proficiency enum mapping used for both proficiency1 and proficiency2.
|
||||
PROFICIENCY = {
|
||||
None: 0,
|
||||
Sabre: 1,
|
||||
Dagger: 2,
|
||||
Axe: 3,
|
||||
Spear: 4,
|
||||
Bow: 5,
|
||||
Staff: 6,
|
||||
Melee: 7,
|
||||
Harp: 8,
|
||||
Gun: 9,
|
||||
Katana: 10
|
||||
}.freeze
|
||||
|
||||
# Single race enum mapping used for both race1 and race2.
|
||||
RACES = {
|
||||
Unknown: 0,
|
||||
Human: 1,
|
||||
Erune: 2,
|
||||
Draph: 3,
|
||||
Harvin: 4,
|
||||
Primal: 5,
|
||||
None: 6
|
||||
}.freeze
|
||||
end
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
class DataVersion < ActiveRecord::Base
|
||||
validates :filename, presence: true, uniqueness: true
|
||||
validates :imported_at, presence: true
|
||||
|
||||
def self.mark_as_imported(filename)
|
||||
create!(filename: filename, imported_at: Time.current)
|
||||
end
|
||||
|
||||
def self.imported?(filename)
|
||||
exists?(filename: filename)
|
||||
end
|
||||
end
|
||||
|
|
@ -1,44 +1,19 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
##
|
||||
# This file defines the GridCharacter model which represents a character's grid configuration within a party.
|
||||
# The GridCharacter model handles validations related to awakenings, rings, mastery values, and transcendence.
|
||||
# It includes virtual attributes for processing new rings and awakening data, and utilizes the amoeba gem
|
||||
# for duplicating records with specific attribute resets.
|
||||
#
|
||||
# @note This model belongs to a Character, an optional Awakening, and a Party. It maintains associations for
|
||||
# these relationships and includes counter caches for performance optimization.
|
||||
#
|
||||
# @!attribute [r] character
|
||||
# @return [Character] the associated character record.
|
||||
# @!attribute [r] awakening
|
||||
# @return [Awakening, nil] the associated awakening record (optional).
|
||||
# @!attribute [r] party
|
||||
# @return [Party] the associated party record.
|
||||
#
|
||||
class GridCharacter < ApplicationRecord
|
||||
# Associations
|
||||
belongs_to :character, foreign_key: :character_id, primary_key: :id
|
||||
has_one :object, class_name: 'Character', foreign_key: :id, primary_key: :character_id
|
||||
|
||||
belongs_to :awakening, optional: true
|
||||
belongs_to :party,
|
||||
counter_cache: :characters_count,
|
||||
inverse_of: :characters
|
||||
|
||||
# Validations
|
||||
validates_presence_of :party
|
||||
|
||||
# Validate that uncap_level and transcendence_step are present and numeric.
|
||||
validates :uncap_level, presence: true, numericality: { only_integer: true }
|
||||
validates :transcendence_step, presence: true, numericality: { only_integer: true }
|
||||
|
||||
validate :validate_awakening_level, on: :update
|
||||
validate :transcendence, on: :update
|
||||
validate :validate_over_mastery_values, on: :update
|
||||
validate :validate_aetherial_mastery_value, on: :update
|
||||
|
||||
# Virtual attributes
|
||||
attr_accessor :new_rings
|
||||
attr_accessor :new_awakening
|
||||
validate :over_mastery_attack_matches_hp, on: :update
|
||||
|
||||
##### Amoeba configuration
|
||||
amoeba do
|
||||
|
|
@ -50,121 +25,49 @@ class GridCharacter < ApplicationRecord
|
|||
set perpetuity: false
|
||||
end
|
||||
|
||||
# Hooks
|
||||
before_validation :apply_new_rings, if: -> { new_rings.present? }
|
||||
before_validation :apply_new_awakening, if: -> { new_awakening.present? }
|
||||
# Add awakening before the model saves
|
||||
before_save :add_awakening
|
||||
|
||||
##
|
||||
# Validates the awakening level to ensure it falls within the allowed range.
|
||||
#
|
||||
# @note Triggered on update.
|
||||
# @return [void]
|
||||
def validate_awakening_level
|
||||
errors.add(:awakening, 'awakening level too low') if awakening_level < 1
|
||||
errors.add(:awakening, 'awakening level too high') if awakening_level > 9
|
||||
end
|
||||
|
||||
##
|
||||
# Validates the transcendence step of the character.
|
||||
#
|
||||
# Ensures that the transcendence step is appropriate based on the character's ULB status.
|
||||
# Adds errors if:
|
||||
# - The character has a positive transcendence_step but no transcendence (ulb is false).
|
||||
# - The transcendence_step exceeds the allowed maximum.
|
||||
# - The transcendence_step is negative when character.ulb is true.
|
||||
#
|
||||
# @note Triggered on update.
|
||||
# @return [void]
|
||||
def transcendence
|
||||
errors.add(:transcendence_step, 'character has no transcendence') if transcendence_step.positive? && !character.ulb
|
||||
errors.add(:transcendence_step, 'transcendence step too high') if transcendence_step > 5 && character.ulb
|
||||
errors.add(:transcendence_step, 'transcendence step too low') if transcendence_step.negative? && character.ulb
|
||||
end
|
||||
|
||||
##
|
||||
# Validates the over mastery attack value for ring1.
|
||||
#
|
||||
# Checks that if ring1's modifier is set, the strength must be one of the allowed attack values.
|
||||
# Adds an error if the value is not valid.
|
||||
#
|
||||
# @return [void]
|
||||
def over_mastery_attack
|
||||
errors.add(:ring1, 'invalid value') unless ring1['modifier'].nil? || atk_values.include?(ring1['strength'])
|
||||
end
|
||||
|
||||
##
|
||||
# Validates the over mastery HP value for ring2.
|
||||
#
|
||||
# If ring2's modifier is present, ensures that the strength is within the allowed HP values.
|
||||
# Adds an error if the value is not valid.
|
||||
#
|
||||
# @return [void]
|
||||
def over_mastery_hp
|
||||
return if ring2['modifier'].nil?
|
||||
|
||||
errors.add(:ring2, 'invalid value') unless hp_values.include?(ring2['strength'])
|
||||
end
|
||||
|
||||
##
|
||||
# Validates over mastery values by invoking individual and cross-field validations.
|
||||
#
|
||||
# This method triggers:
|
||||
# - Validation for individual over mastery values for rings 1-4.
|
||||
# - Validation ensuring that ring1's attack and ring2's HP values are consistent.
|
||||
#
|
||||
# @return [void]
|
||||
def validate_over_mastery_values
|
||||
validate_individual_over_mastery_values
|
||||
validate_over_mastery_attack_matches_hp
|
||||
def over_mastery_attack_matches_hp
|
||||
return if ring1[:modifier].nil? && ring2[:modifier].nil?
|
||||
|
||||
return if ring2[:strength] == (ring1[:strength] / 2)
|
||||
|
||||
errors.add(:over_mastery,
|
||||
'over mastery attack and hp values do not match')
|
||||
end
|
||||
|
||||
##
|
||||
# Validates individual over mastery values for each ring (ring1 to ring4).
|
||||
#
|
||||
# Iterates over each ring and, if a modifier is present, uses a helper to verify that the associated strength
|
||||
# is within the permitted range based on over mastery rules.
|
||||
#
|
||||
# @return [void]
|
||||
def validate_individual_over_mastery_values
|
||||
# Iterate over rings 1-4 and check each ring’s value.
|
||||
def validate_over_mastery_values
|
||||
[ring1, ring2, ring3, ring4].each_with_index do |ring, index|
|
||||
next if ring['modifier'].nil?
|
||||
|
||||
modifier = over_mastery_modifiers[ring['modifier']]
|
||||
# Use a helper to add errors if the value is out-of-range.
|
||||
check_value({ "ring#{index}": { ring[modifier] => ring['strength'] } }, 'over_mastery')
|
||||
check_value({ "ring#{index}": { ring[modifier] => ring['strength'] } },
|
||||
'over_mastery')
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Validates that the over mastery attack value matches the HP value appropriately.
|
||||
#
|
||||
# Converts ring1 and ring2 hashes to use indifferent access, and if either ring has a modifier set,
|
||||
# checks that ring2's strength is exactly half of ring1's strength.
|
||||
# Adds an error if the values do not match.
|
||||
#
|
||||
# @return [void]
|
||||
def validate_over_mastery_attack_matches_hp
|
||||
# Convert ring1 and ring2 to use indifferent access so that keys (symbols or strings)
|
||||
# can be accessed uniformly.
|
||||
r1 = ring1.with_indifferent_access
|
||||
r2 = ring2.with_indifferent_access
|
||||
# Only check if either ring has a modifier set.
|
||||
if r1[:modifier].present? || r2[:modifier].present?
|
||||
# Ensure that ring2's strength equals exactly half of ring1's strength.
|
||||
unless r2[:strength].to_f == (r1[:strength].to_f / 2)
|
||||
errors.add(:over_mastery, 'over mastery attack and hp values do not match')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Validates the aetherial mastery value for the earring.
|
||||
#
|
||||
# If the earring's modifier is present and positive, it uses a helper method to check that the strength
|
||||
# falls within the allowed range for aetherial mastery.
|
||||
#
|
||||
# @return [void]
|
||||
def validate_aetherial_mastery_value
|
||||
return if earring['modifier'].nil?
|
||||
|
||||
|
|
@ -175,72 +78,22 @@ class GridCharacter < ApplicationRecord
|
|||
'aetherial_mastery')
|
||||
end
|
||||
|
||||
##
|
||||
# Returns the blueprint for rendering the grid character.
|
||||
#
|
||||
# @return [GridCharacterBlueprint] the blueprint class used for grid character representation.
|
||||
def character
|
||||
Character.find(character_id)
|
||||
end
|
||||
|
||||
def blueprint
|
||||
GridCharacterBlueprint
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
##
|
||||
# Adds a default awakening to the character before saving if none is set.
|
||||
#
|
||||
# Retrieves the Awakening record with slug 'character-balanced' and assigns it.
|
||||
#
|
||||
# @return [void]
|
||||
def add_awakening
|
||||
return unless awakening.nil?
|
||||
|
||||
self.awakening = Awakening.where(slug: 'character-balanced').sole
|
||||
end
|
||||
|
||||
##
|
||||
# Applies new ring configurations from the virtual attribute +new_rings+.
|
||||
#
|
||||
# Expects +new_rings+ to be an array of hashes with keys "modifier" and "strength".
|
||||
# Pads the array with default ring hashes to ensure there are exactly four rings, then assigns them to
|
||||
# ring1, ring2, ring3, and ring4.
|
||||
#
|
||||
# @return [void]
|
||||
def apply_new_rings
|
||||
# Expect new_rings to be an array of hashes, e.g.,
|
||||
# [{"modifier" => "1", "strength" => "1500"}, {"modifier" => "2", "strength" => "750"}]
|
||||
default_ring = { 'modifier' => nil, 'strength' => nil }
|
||||
rings_array = Array(new_rings).map(&:to_h)
|
||||
# Pad with defaults so there are exactly four rings
|
||||
rings_array.fill(default_ring, rings_array.size...4)
|
||||
self.ring1 = rings_array[0]
|
||||
self.ring2 = rings_array[1]
|
||||
self.ring3 = rings_array[2]
|
||||
self.ring4 = rings_array[3]
|
||||
end
|
||||
|
||||
##
|
||||
# Applies new awakening configuration from the virtual attribute +new_awakening+.
|
||||
#
|
||||
# Sets the +awakening_id+ and +awakening_level+ based on the provided hash.
|
||||
#
|
||||
# @return [void]
|
||||
def apply_new_awakening
|
||||
self.awakening_id = new_awakening[:id]
|
||||
self.awakening_level = new_awakening[:level].present? ? new_awakening[:level].to_i : 1
|
||||
end
|
||||
|
||||
##
|
||||
# Checks that a given property value falls within the allowed range based on the specified mastery type.
|
||||
#
|
||||
# The +property+ parameter is expected to be a hash in the following format:
|
||||
# { ring1: { atk: 300 } }
|
||||
#
|
||||
# Depending on the +type+, it validates against either over mastery or aetherial mastery values.
|
||||
# Adds an error to the record if the value is not within the permitted range.
|
||||
#
|
||||
# @param property [Hash] the property hash containing the attribute and its value.
|
||||
# @param type [String] the type of mastery validation to perform ('over_mastery' or 'aetherial_mastery').
|
||||
# @return [void]
|
||||
def check_value(property, type)
|
||||
# Input format
|
||||
# { ring1: { atk: 300 } }
|
||||
|
|
@ -259,10 +112,6 @@ class GridCharacter < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Returns a hash mapping over mastery modifier keys to their corresponding attribute names.
|
||||
#
|
||||
# @return [Hash{Integer => String}] mapping of modifier codes to attribute names.
|
||||
def over_mastery_modifiers
|
||||
{
|
||||
1 => 'atk',
|
||||
|
|
@ -283,10 +132,6 @@ class GridCharacter < ApplicationRecord
|
|||
}
|
||||
end
|
||||
|
||||
##
|
||||
# Returns a hash containing allowed values for over mastery attributes.
|
||||
#
|
||||
# @return [Hash{Symbol => Array<Integer>}] mapping of attribute names to their valid values.
|
||||
def over_mastery_values
|
||||
{
|
||||
atk: [300, 600, 900, 1200, 1500, 1800, 2100, 2400, 2700, 3000],
|
||||
|
|
@ -307,9 +152,6 @@ class GridCharacter < ApplicationRecord
|
|||
}
|
||||
end
|
||||
|
||||
# Returns a hash mapping aetherial mastery modifier keys to their corresponding attribute names.
|
||||
#
|
||||
# @return [Hash{Integer => String}] mapping of aetherial mastery modifier codes to attribute names.
|
||||
def aetherial_mastery_modifiers
|
||||
{
|
||||
1 => 'da',
|
||||
|
|
@ -325,10 +167,6 @@ class GridCharacter < ApplicationRecord
|
|||
}
|
||||
end
|
||||
|
||||
##
|
||||
# Returns a hash containing allowed values for aetherial mastery attributes.
|
||||
#
|
||||
# @return [Hash{Symbol => Hash{Symbol => Integer}}] mapping of attribute names to their minimum and maximum values.
|
||||
def aetherial_mastery_values
|
||||
{
|
||||
da: {
|
||||
|
|
@ -374,18 +212,10 @@ class GridCharacter < ApplicationRecord
|
|||
}
|
||||
end
|
||||
|
||||
##
|
||||
# Returns an array of valid attack values for over mastery validation.
|
||||
#
|
||||
# @return [Array<Integer>] list of allowed attack values.
|
||||
def atk_values
|
||||
[300, 600, 900, 1200, 1500, 1800, 2100, 2400, 2700, 3000]
|
||||
end
|
||||
|
||||
##
|
||||
# Returns an array of valid HP values for over mastery validation.
|
||||
#
|
||||
# @return [Array<Integer>] list of allowed HP values.
|
||||
def hp_values
|
||||
[150, 300, 450, 600, 750, 900, 1050, 1200, 1350, 1500]
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,52 +1,24 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
##
|
||||
# Model representing a grid summon within a party.
|
||||
#
|
||||
# A GridSummon is associated with a specific {Summon} and {Party} and is responsible for
|
||||
# enforcing rules on positions, uncap levels, and transcendence steps based on the associated summon’s flags.
|
||||
#
|
||||
# @!attribute [r] summon
|
||||
# @return [Summon] the associated summon.
|
||||
# @!attribute [r] party
|
||||
# @return [Party] the associated party.
|
||||
class GridSummon < ApplicationRecord
|
||||
belongs_to :summon, foreign_key: :summon_id, primary_key: :id
|
||||
|
||||
belongs_to :party,
|
||||
counter_cache: :summons_count,
|
||||
inverse_of: :summons
|
||||
validates_presence_of :party
|
||||
has_one :object, class_name: 'Summon', foreign_key: :id, primary_key: :summon_id
|
||||
|
||||
# Validate that position is provided.
|
||||
validates :position, presence: true
|
||||
validate :compatible_with_position, on: :create
|
||||
|
||||
# Validate that uncap_level and transcendence_step are present and numeric.
|
||||
validates :uncap_level, presence: true, numericality: { only_integer: true }
|
||||
validates :transcendence_step, presence: true, numericality: { only_integer: true }
|
||||
|
||||
# Custom validation to enforce maximum uncap_level based on the associated Summon’s flags.
|
||||
validate :validate_uncap_level_based_on_summon_flags
|
||||
|
||||
validate :no_conflicts, on: :create
|
||||
|
||||
##
|
||||
# Returns the blueprint for rendering the grid summon.
|
||||
#
|
||||
# @return [GridSummonBlueprint] the blueprint class for grid summons.
|
||||
def summon
|
||||
Summon.find(summon_id)
|
||||
end
|
||||
|
||||
def blueprint
|
||||
GridSummonBlueprint
|
||||
end
|
||||
|
||||
##
|
||||
# Returns any conflicting grid summon for the given party.
|
||||
#
|
||||
# If the associated summon has a limit, this method searches the party's grid summons to find
|
||||
# any that conflict based on the summon ID.
|
||||
#
|
||||
# @param party [Party] the party in which to check for conflicts.
|
||||
# @return [GridSummon, nil] the conflicting grid summon if found, otherwise nil.
|
||||
# Returns conflicting summons if they exist
|
||||
def conflicts(party)
|
||||
return unless summon.limit
|
||||
|
||||
|
|
@ -59,74 +31,13 @@ class GridSummon < ApplicationRecord
|
|||
|
||||
private
|
||||
|
||||
##
|
||||
# Validates the uncap_level based on the associated Summon’s flags.
|
||||
#
|
||||
# This method delegates to specific validation methods for FLB, ULB, and transcendence limits.
|
||||
#
|
||||
# @return [void]
|
||||
def validate_uncap_level_based_on_summon_flags
|
||||
return unless summon
|
||||
|
||||
validate_flb_limit
|
||||
validate_ulb_limit
|
||||
validate_transcendence_limits
|
||||
end
|
||||
|
||||
##
|
||||
# Validates that the uncap_level does not exceed 3 if the associated Summon does not have the FLB flag.
|
||||
#
|
||||
# @return [void]
|
||||
def validate_flb_limit
|
||||
return unless !summon.flb && uncap_level.to_i > 3
|
||||
|
||||
errors.add(:uncap_level, 'cannot be greater than 3 if summon does not have FLB')
|
||||
end
|
||||
|
||||
##
|
||||
# Validates that the uncap_level does not exceed 4 if the associated Summon does not have the ULB flag.
|
||||
#
|
||||
# @return [void]
|
||||
def validate_ulb_limit
|
||||
return unless !summon.ulb && uncap_level.to_i > 4
|
||||
|
||||
errors.add(:uncap_level, 'cannot be greater than 4 if summon does not have ULB')
|
||||
end
|
||||
|
||||
##
|
||||
# Validates the uncap_level and transcendence_step based on whether the associated Summon supports transcendence.
|
||||
#
|
||||
# If the summon does not support transcendence, the uncap_level must not exceed 5 and the transcendence_step must be 0.
|
||||
#
|
||||
# @return [void]
|
||||
def validate_transcendence_limits
|
||||
return if summon.transcendence
|
||||
|
||||
errors.add(:uncap_level, 'cannot be greater than 5 if summon does not have transcendence') if uncap_level.to_i > 5
|
||||
|
||||
return unless transcendence_step.to_i.positive?
|
||||
|
||||
errors.add(:transcendence_step, 'must be 0 if summon does not have transcendence')
|
||||
end
|
||||
|
||||
##
|
||||
# Validates that there are no conflicting grid summons in the party.
|
||||
#
|
||||
# If a conflict is found (i.e. another grid summon exists that conflicts with this one),
|
||||
# an error is added to the :series attribute.
|
||||
#
|
||||
# @return [void]
|
||||
# Validates whether there is a conflict with the party
|
||||
def no_conflicts
|
||||
# Check if the grid summon conflicts with any of the other grid summons in the party
|
||||
errors.add(:series, 'must not conflict with existing summons') unless conflicts(party).nil?
|
||||
end
|
||||
|
||||
##
|
||||
# Validates whether the grid summon can be added to the desired position.
|
||||
#
|
||||
# For positions 4 and 5, the associated summon must have subaura; otherwise, an error is added.
|
||||
#
|
||||
# @return [void]
|
||||
# Validates whether the summon can be added to the desired position
|
||||
def compatible_with_position
|
||||
return unless [4, 5].include?(position.to_i) && !summon.subaura
|
||||
|
||||
|
|
|
|||
|
|
@ -1,52 +1,23 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
##
|
||||
# Model representing a grid weapon within a party.
|
||||
#
|
||||
# This model associates a weapon with a party and manages validations for weapon compatibility,
|
||||
# conflict detection, and attribute adjustments such as determining if a weapon is mainhand.
|
||||
#
|
||||
# @!attribute [r] weapon
|
||||
# @return [Weapon] the associated weapon.
|
||||
# @!attribute [r] party
|
||||
# @return [Party] the party to which the grid weapon belongs.
|
||||
# @!attribute [r] weapon_key1
|
||||
# @return [WeaponKey, nil] the primary weapon key, if assigned.
|
||||
# @!attribute [r] weapon_key2
|
||||
# @return [WeaponKey, nil] the secondary weapon key, if assigned.
|
||||
# @!attribute [r] weapon_key3
|
||||
# @return [WeaponKey, nil] the tertiary weapon key, if assigned.
|
||||
# @!attribute [r] weapon_key4
|
||||
# @return [WeaponKey, nil] the quaternary weapon key, if assigned.
|
||||
# @!attribute [r] awakening
|
||||
# @return [Awakening, nil] the associated awakening, if any.
|
||||
class GridWeapon < ApplicationRecord
|
||||
# Allowed extra positions and allowed weapon series when in an extra position.
|
||||
EXTRA_POSITIONS = [9, 10, 11].freeze
|
||||
ALLOWED_EXTRA_SERIES = [11, 16, 17, 28, 29, 32, 34].freeze
|
||||
|
||||
belongs_to :weapon, foreign_key: :weapon_id, primary_key: :id
|
||||
|
||||
belongs_to :party,
|
||||
counter_cache: :weapons_count,
|
||||
inverse_of: :weapons
|
||||
validates_presence_of :party
|
||||
|
||||
has_one :object, class_name: 'Weapon', foreign_key: :id, primary_key: :weapon_id
|
||||
|
||||
belongs_to :weapon_key1, class_name: 'WeaponKey', foreign_key: :weapon_key1_id, optional: true
|
||||
belongs_to :weapon_key2, class_name: 'WeaponKey', foreign_key: :weapon_key2_id, optional: true
|
||||
belongs_to :weapon_key3, class_name: 'WeaponKey', foreign_key: :weapon_key3_id, optional: true
|
||||
belongs_to :weapon_key4, class_name: 'WeaponKey', foreign_key: :weapon_key4_id, optional: true
|
||||
|
||||
belongs_to :awakening, optional: true
|
||||
|
||||
# Validate that uncap_level and transcendence_step are present and numeric.
|
||||
validates :uncap_level, presence: true, numericality: { only_integer: true }
|
||||
validates :transcendence_step, presence: true, numericality: { only_integer: true }
|
||||
|
||||
validate :compatible_with_position, on: :create
|
||||
validate :no_conflicts, on: :create
|
||||
|
||||
before_save :assign_mainhand
|
||||
before_save :mainhand?
|
||||
|
||||
##### Amoeba configuration
|
||||
amoeba do
|
||||
|
|
@ -56,99 +27,73 @@ class GridWeapon < ApplicationRecord
|
|||
nullify :ax_strength2
|
||||
end
|
||||
|
||||
##
|
||||
# Returns the blueprint for rendering the grid weapon.
|
||||
#
|
||||
# @return [GridWeaponBlueprint] the blueprint class for grid weapons.
|
||||
# Helper methods
|
||||
def blueprint
|
||||
GridWeaponBlueprint
|
||||
end
|
||||
|
||||
##
|
||||
# Returns an array of assigned weapon keys.
|
||||
#
|
||||
# This method returns an array containing weapon_key1, weapon_key2, and weapon_key3,
|
||||
# omitting any nil values.
|
||||
#
|
||||
# @return [Array<WeaponKey>] the non-nil weapon keys.
|
||||
def weapon
|
||||
Weapon.find(weapon_id)
|
||||
end
|
||||
|
||||
def weapon_keys
|
||||
[weapon_key1, weapon_key2, weapon_key3].compact
|
||||
end
|
||||
|
||||
##
|
||||
# Returns conflicting grid weapons within a given party.
|
||||
#
|
||||
# Checks if the associated weapon is present, responds to a :limit method, and is limited.
|
||||
# It then iterates over the party's grid weapons and selects those that conflict with this one,
|
||||
# based on series matching or specific conditions related to opus or draconic status.
|
||||
#
|
||||
# @param party [Party] the party in which to check for conflicts.
|
||||
# @return [ActiveRecord::Relation<GridWeapon>] an array of conflicting grid weapons (empty if none are found).
|
||||
# Returns conflicting weapons if they exist
|
||||
def conflicts(party)
|
||||
return [] unless weapon.present? && weapon.respond_to?(:limit) && weapon.limit
|
||||
return unless weapon.limit
|
||||
|
||||
party.weapons.select do |party_weapon|
|
||||
# Skip if the record is not persisted.
|
||||
next false unless party_weapon.id.present?
|
||||
conflicting_weapons = []
|
||||
|
||||
party.weapons.each do |party_weapon|
|
||||
next unless party_weapon.id
|
||||
|
||||
id_match = weapon.id == party_weapon.id
|
||||
series_match = weapon.series == party_weapon.weapon.series
|
||||
both_opus_or_draconic = weapon.opus_or_draconic? && party_weapon.weapon.opus_or_draconic?
|
||||
both_draconic = weapon.draconic_or_providence? && party_weapon.weapon.draconic_or_providence?
|
||||
|
||||
(series_match || both_opus_or_draconic || both_draconic) && !id_match
|
||||
conflicting_weapons << party_weapon if (series_match || both_opus_or_draconic || both_draconic) && !id_match
|
||||
end
|
||||
|
||||
conflicting_weapons
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
##
|
||||
# Validates whether the grid weapon is compatible with the desired position.
|
||||
#
|
||||
# For positions 9, 10, or 11 (considered extra positions), the weapon's series must belong to the allowed set.
|
||||
# If the weapon is in an extra position but does not match an allowed series, an error is added.
|
||||
#
|
||||
# @return [void]
|
||||
def compatible_with_position
|
||||
return unless weapon.present?
|
||||
# Conflict management methods
|
||||
|
||||
if EXTRA_POSITIONS.include?(position.to_i) && !ALLOWED_EXTRA_SERIES.include?(weapon.series.to_i)
|
||||
errors.add(:series, 'must be compatible with position')
|
||||
end
|
||||
# Validates whether the weapon can be added to the desired position
|
||||
def compatible_with_position
|
||||
is_extra_position = [9, 10, 11].include?(position.to_i)
|
||||
is_extra_weapon = [11, 16, 17, 28, 29, 32, 34].include?(weapon.series.to_i)
|
||||
|
||||
return unless is_extra_position
|
||||
|
||||
return true if is_extra_weapon
|
||||
|
||||
errors.add(:series, 'must be compatible with position')
|
||||
false
|
||||
end
|
||||
|
||||
##
|
||||
# Validates that the assigned weapon keys are compatible with the weapon.
|
||||
#
|
||||
# Iterates over each non-nil weapon key and checks compatibility using the weapon's
|
||||
# `compatible_with_key?` method. An error is added for any key that is not compatible.
|
||||
#
|
||||
# @return [void]
|
||||
# Validates whether the desired weapon key can be added to the weapon
|
||||
def compatible_with_key
|
||||
weapon_keys.each do |key|
|
||||
errors.add(:weapon_keys, 'must be compatible with weapon') unless weapon.compatible_with_key?(key)
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Validates that there are no conflicting grid weapons in the party.
|
||||
#
|
||||
# Checks if the current grid weapon conflicts with any other grid weapons within the party.
|
||||
# If conflicting weapons are found, an error is added.
|
||||
#
|
||||
# @return [void]
|
||||
# Validates whether there is a conflict with the party
|
||||
def no_conflicts
|
||||
conflicting = conflicts(party)
|
||||
errors.add(:series, 'must not conflict with existing weapons') if conflicting.any?
|
||||
# Check if the grid weapon conflicts with any of the other grid weapons in the party
|
||||
return unless !conflicts(party).nil? && !conflicts(party).empty?
|
||||
|
||||
errors.add(:series, 'must not conflict with existing weapons')
|
||||
end
|
||||
|
||||
##
|
||||
# Determines if the grid weapon should be marked as mainhand based on its position.
|
||||
#
|
||||
# If the grid weapon's position is -1, sets the `mainhand` attribute to true.
|
||||
#
|
||||
# @return [void]
|
||||
def assign_mainhand
|
||||
self.mainhand = (position == -1)
|
||||
# Checks if the weapon should be a mainhand before saving the model
|
||||
def mainhand?
|
||||
self.mainhand = position == -1
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
class Job < ApplicationRecord
|
||||
include PgSearch::Model
|
||||
|
||||
belongs_to :party, optional: true
|
||||
belongs_to :party
|
||||
has_many :skills, class_name: 'JobSkill'
|
||||
|
||||
multisearchable against: %i[name_en name_jp],
|
||||
|
|
|
|||
|
|
@ -1,95 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
##
|
||||
# This file defines the Party model which represents a party in the application.
|
||||
# It encapsulates the logic for managing party records including associations with
|
||||
# characters, weapons, summons, and other related models. The Party model handles
|
||||
# validations, nested attributes, preview generation, and various business logic
|
||||
# to ensure consistency and integrity of party data.
|
||||
#
|
||||
# @note The model uses ActiveRecord associations, enums, and custom validations.
|
||||
#
|
||||
# @!attribute [rw] preview_state
|
||||
# @return [Integer] the current state of the preview, represented as an enum:
|
||||
# - 0: pending
|
||||
# - 1: queued
|
||||
# - 2: in_progress
|
||||
# - 3: generated
|
||||
# - 4: failed
|
||||
# @!attribute [rw] element
|
||||
# @return [Integer] the elemental type associated with the party.
|
||||
# @!attribute [rw] clear_time
|
||||
# @return [Integer] the clear time for the party.
|
||||
# @!attribute [rw] master_level
|
||||
# @return [Integer, nil] the master level of the party.
|
||||
# @!attribute [rw] button_count
|
||||
# @return [Integer, nil] the button count, if applicable.
|
||||
# @!attribute [rw] chain_count
|
||||
# @return [Integer, nil] the chain count, if applicable.
|
||||
# @!attribute [rw] turn_count
|
||||
# @return [Integer, nil] the turn count, if applicable.
|
||||
# @!attribute [rw] ultimate_mastery
|
||||
# @return [Integer, nil] the ultimate mastery level, if applicable.
|
||||
# @!attribute [rw] visibility
|
||||
# @return [Integer] the visibility of the party:
|
||||
# - 1: Public
|
||||
# - 2: Unlisted
|
||||
# - 3: Private
|
||||
# @!attribute [rw] shortcode
|
||||
# @return [String] a unique shortcode for the party.
|
||||
# @!attribute [rw] edit_key
|
||||
# @return [String] an edit key for parties without an associated user.
|
||||
#
|
||||
# @!attribute [r] source_party
|
||||
# @return [Party, nil] the original party if this is a remix.
|
||||
# @!attribute [r] remixes
|
||||
# @return [Array<Party>] a collection of parties remixed from this party.
|
||||
# @!attribute [r] user
|
||||
# @return [User, nil] the user who created the party.
|
||||
# @!attribute [r] raid
|
||||
# @return [Raid, nil] the associated raid.
|
||||
# @!attribute [r] job
|
||||
# @return [Job, nil] the associated job.
|
||||
# @!attribute [r] accessory
|
||||
# @return [JobAccessory, nil] the accessory used in the party.
|
||||
# @!attribute [r] skill0
|
||||
# @return [JobSkill, nil] the primary skill.
|
||||
# @!attribute [r] skill1
|
||||
# @return [JobSkill, nil] the secondary skill.
|
||||
# @!attribute [r] skill2
|
||||
# @return [JobSkill, nil] the tertiary skill.
|
||||
# @!attribute [r] skill3
|
||||
# @return [JobSkill, nil] the quaternary skill.
|
||||
# @!attribute [r] guidebook1
|
||||
# @return [Guidebook, nil] the first guidebook.
|
||||
# @!attribute [r] guidebook2
|
||||
# @return [Guidebook, nil] the second guidebook.
|
||||
# @!attribute [r] guidebook3
|
||||
# @return [Guidebook, nil] the third guidebook.
|
||||
# @!attribute [r] characters
|
||||
# @return [Array<GridCharacter>] the characters associated with this party.
|
||||
# @!attribute [r] weapons
|
||||
# @return [Array<GridWeapon>] the weapons associated with this party.
|
||||
# @!attribute [r] summons
|
||||
# @return [Array<GridSummon>] the summons associated with this party.
|
||||
# @!attribute [r] favorites
|
||||
# @return [Array<Favorite>] the favorites that include this party.
|
||||
class Party < ApplicationRecord
|
||||
include GranblueEnums
|
||||
|
||||
# Define preview_state as an enum.
|
||||
attribute :preview_state, :integer
|
||||
enum :preview_state, { pending: 0, queued: 1, in_progress: 2, generated: 3, failed: 4 }
|
||||
|
||||
# ActiveRecord Associations
|
||||
##### ActiveRecord Associations
|
||||
belongs_to :source_party,
|
||||
class_name: 'Party',
|
||||
foreign_key: :source_party_id,
|
||||
optional: true
|
||||
|
||||
has_many :remixes, -> { order(created_at: :desc) },
|
||||
has_many :derivative_parties,
|
||||
class_name: 'Party',
|
||||
foreign_key: 'source_party_id',
|
||||
foreign_key: :source_party_id,
|
||||
inverse_of: :source_party,
|
||||
dependent: :nullify
|
||||
|
||||
|
|
@ -140,37 +60,27 @@ class Party < ApplicationRecord
|
|||
has_many :characters,
|
||||
foreign_key: 'party_id',
|
||||
class_name: 'GridCharacter',
|
||||
counter_cache: true,
|
||||
dependent: :destroy,
|
||||
inverse_of: :party
|
||||
|
||||
has_many :weapons,
|
||||
foreign_key: 'party_id',
|
||||
class_name: 'GridWeapon',
|
||||
counter_cache: true,
|
||||
dependent: :destroy,
|
||||
inverse_of: :party
|
||||
|
||||
has_many :summons,
|
||||
foreign_key: 'party_id',
|
||||
class_name: 'GridSummon',
|
||||
counter_cache: true,
|
||||
dependent: :destroy,
|
||||
inverse_of: :party
|
||||
|
||||
has_many :favorites, dependent: :destroy
|
||||
|
||||
accepts_nested_attributes_for :characters
|
||||
accepts_nested_attributes_for :summons
|
||||
accepts_nested_attributes_for :weapons
|
||||
|
||||
before_create :set_shortcode
|
||||
before_create :set_edit_key
|
||||
|
||||
after_commit :update_element!, on: %i[create update]
|
||||
after_commit :update_extra!, on: %i[create update]
|
||||
|
||||
# Amoeba configuration
|
||||
##### Amoeba configuration
|
||||
amoeba do
|
||||
set weapons_count: 0
|
||||
set characters_count: 0
|
||||
|
|
@ -185,303 +95,82 @@ class Party < ApplicationRecord
|
|||
include_association :summons
|
||||
end
|
||||
|
||||
# ActiveRecord Validations
|
||||
##### ActiveRecord Validations
|
||||
validate :skills_are_unique
|
||||
validate :guidebooks_are_unique
|
||||
|
||||
# For element, validate numericality and inclusion using the allowed values from GranblueEnums.
|
||||
validates :element,
|
||||
numericality: { only_integer: true },
|
||||
inclusion: {
|
||||
in: GranblueEnums::ELEMENTS.values,
|
||||
message: "must be one of #{GranblueEnums::ELEMENTS.map { |name, value| "#{value} (#{name})" }.join(', ')}"
|
||||
},
|
||||
allow_nil: true
|
||||
attr_accessor :favorited
|
||||
|
||||
validates :clear_time, numericality: { only_integer: true }
|
||||
validates :master_level, numericality: { only_integer: true }, allow_nil: true
|
||||
validates :button_count, numericality: { only_integer: true }, allow_nil: true
|
||||
validates :chain_count, numericality: { only_integer: true }, allow_nil: true
|
||||
validates :turn_count, numericality: { only_integer: true }, allow_nil: true
|
||||
validates :ultimate_mastery, numericality: { only_integer: true }, allow_nil: true
|
||||
def is_favorited(user)
|
||||
user.favorite_parties.include? self if user
|
||||
end
|
||||
|
||||
# Validate visibility (allowed values: 1, 2, or 3).
|
||||
validates :visibility,
|
||||
numericality: { only_integer: true },
|
||||
inclusion: {
|
||||
in: [1, 2, 3],
|
||||
message: 'must be 1 (Public), 2 (Unlisted), or 3 (Private)'
|
||||
}
|
||||
|
||||
after_commit :schedule_preview_generation, if: :should_generate_preview?
|
||||
|
||||
#########################
|
||||
# Public API Methods
|
||||
#########################
|
||||
|
||||
##
|
||||
# Checks if the party is a remix of another party.
|
||||
#
|
||||
# @return [Boolean] true if the party is a remix; false otherwise.
|
||||
def remix?
|
||||
def is_remix
|
||||
!source_party.nil?
|
||||
end
|
||||
|
||||
##
|
||||
# Returns the blueprint class used for rendering the party.
|
||||
#
|
||||
# @return [Class] the PartyBlueprint class.
|
||||
def remixes
|
||||
Party.where(source_party_id: id)
|
||||
end
|
||||
|
||||
def blueprint
|
||||
PartyBlueprint
|
||||
end
|
||||
|
||||
##
|
||||
# Determines if the party is public.
|
||||
#
|
||||
# @return [Boolean] true if the party is public; false otherwise.
|
||||
def public?
|
||||
visibility == 1
|
||||
end
|
||||
|
||||
##
|
||||
# Determines if the party is unlisted.
|
||||
#
|
||||
# @return [Boolean] true if the party is unlisted; false otherwise.
|
||||
def unlisted?
|
||||
visibility == 2
|
||||
end
|
||||
|
||||
##
|
||||
# Determines if the party is private.
|
||||
#
|
||||
# @return [Boolean] true if the party is private; false otherwise.
|
||||
def private?
|
||||
visibility == 3
|
||||
end
|
||||
|
||||
##
|
||||
# Checks if the party is favorited by a given user.
|
||||
#
|
||||
# @param user [User, nil] the user to check for favoritism.
|
||||
# @return [Boolean] true if the party is favorited by the user; false otherwise.
|
||||
def favorited?(user)
|
||||
return false unless user
|
||||
|
||||
Rails.cache.fetch("party_#{id}_favorited_by_#{user.id}", expires_in: 1.hour) do
|
||||
user.favorite_parties.include?(self)
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Determines if the party meets the minimum requirements for preview generation.
|
||||
#
|
||||
# The party must have at least one weapon, one character, and one summon.
|
||||
#
|
||||
# @return [Boolean] true if the party is ready for preview; false otherwise.
|
||||
def ready_for_preview?
|
||||
return false if weapons_count < 1 # At least 1 weapon
|
||||
return false if characters_count < 1 # At least 1 character
|
||||
return false if summons_count < 1 # At least 1 summon
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
##
|
||||
# Determines whether a new preview should be generated for the party.
|
||||
#
|
||||
# The method checks various conditions such as preview state, expiration, and content changes.
|
||||
#
|
||||
# @return [Boolean] true if a preview generation should be triggered; false otherwise.
|
||||
def should_generate_preview?
|
||||
return false unless ready_for_preview?
|
||||
|
||||
return true if preview_pending?
|
||||
return true if preview_failed_and_stale?
|
||||
return true if preview_generated_and_expired?
|
||||
return true if preview_content_changed_and_stale?
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
##
|
||||
# Checks whether the current preview has expired based on a predefined expiry period.
|
||||
#
|
||||
# @return [Boolean] true if the preview is expired; false otherwise.
|
||||
def preview_expired?
|
||||
preview_generated_at.nil? ||
|
||||
preview_generated_at < PreviewService::Coordinator::PREVIEW_EXPIRY.ago
|
||||
end
|
||||
|
||||
##
|
||||
# Determines if the content relevant for preview generation has changed.
|
||||
#
|
||||
# @return [Boolean] true if any preview-relevant attributes have changed; false otherwise.
|
||||
def preview_content_changed?
|
||||
saved_changes.keys.any? { |attr| preview_relevant_attributes.include?(attr) }
|
||||
end
|
||||
|
||||
##
|
||||
# Schedules the generation of a party preview if applicable.
|
||||
#
|
||||
# This method updates the preview state to 'queued' and enqueues a background job
|
||||
# to generate the preview.
|
||||
#
|
||||
# @return [void]
|
||||
def schedule_preview_generation
|
||||
return if %w[queued in_progress].include?(preview_state.to_s)
|
||||
|
||||
update_column(:preview_state, self.class.preview_states[:queued])
|
||||
GeneratePartyPreviewJob.perform_later(id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
#########################
|
||||
# Preview Generation Helpers
|
||||
#########################
|
||||
|
||||
##
|
||||
# Checks if the preview is pending.
|
||||
#
|
||||
# @return [Boolean] true if preview_state is nil or 'pending'.
|
||||
def preview_pending?
|
||||
preview_state.nil? || preview_state == 'pending'
|
||||
end
|
||||
|
||||
##
|
||||
# Checks if the preview generation failed and the preview is stale.
|
||||
#
|
||||
# @return [Boolean] true if preview_state is 'failed' and preview_generated_at is older than 5 minutes.
|
||||
def preview_failed_and_stale?
|
||||
preview_state == 'failed' && preview_generated_at < 5.minutes.ago
|
||||
end
|
||||
|
||||
##
|
||||
# Checks if the generated preview is expired.
|
||||
#
|
||||
# @return [Boolean] true if preview_state is 'generated' and the preview is expired.
|
||||
def preview_generated_and_expired?
|
||||
preview_state == 'generated' && preview_expired?
|
||||
end
|
||||
|
||||
##
|
||||
# Checks if the preview content has changed and the preview is stale.
|
||||
#
|
||||
# @return [Boolean] true if the preview content has changed and preview_generated_at is nil or older than 5 minutes.
|
||||
def preview_content_changed_and_stale?
|
||||
preview_content_changed? && (preview_generated_at.nil? || preview_generated_at < 5.minutes.ago)
|
||||
end
|
||||
|
||||
#########################
|
||||
# Uniqueness Validation Helpers
|
||||
#########################
|
||||
|
||||
##
|
||||
# Validates uniqueness for a given set of associations.
|
||||
#
|
||||
# @param associations [Array<Object, nil>] an array of associated objects.
|
||||
# @param attribute_names [Array<Symbol>] the corresponding attribute names for each association.
|
||||
# @param error_key [Symbol] the key for a generic error.
|
||||
# @return [void]
|
||||
def validate_uniqueness_of_associations(associations, attribute_names, error_key)
|
||||
filtered = associations.compact
|
||||
return if filtered.uniq.length == filtered.length
|
||||
|
||||
associations.each_with_index do |assoc, index|
|
||||
next if assoc.nil?
|
||||
|
||||
errors.add(attribute_names[index], 'must be unique') if associations[0...index].include?(assoc)
|
||||
end
|
||||
errors.add(error_key, 'must be unique')
|
||||
end
|
||||
|
||||
##
|
||||
# Validates that the selected skills are unique.
|
||||
#
|
||||
# @return [void]
|
||||
def skills_are_unique
|
||||
validate_uniqueness_of_associations([skill0, skill1, skill2, skill3],
|
||||
%i[skill0 skill1 skill2 skill3],
|
||||
:job_skills)
|
||||
end
|
||||
|
||||
##
|
||||
# Validates that the selected guidebooks are unique.
|
||||
#
|
||||
# @return [void]
|
||||
def guidebooks_are_unique
|
||||
validate_uniqueness_of_associations([guidebook1, guidebook2, guidebook3],
|
||||
%i[guidebook1 guidebook2 guidebook3],
|
||||
:guidebooks)
|
||||
end
|
||||
|
||||
##
|
||||
# Provides a list of attributes that are relevant for determining if the preview content has changed.
|
||||
#
|
||||
# @return [Array<String>] an array of attribute names.
|
||||
def preview_relevant_attributes
|
||||
%w[
|
||||
name job_id element weapons_count characters_count summons_count
|
||||
full_auto auto_guard charge_attack clear_time
|
||||
]
|
||||
end
|
||||
|
||||
#########################
|
||||
# Miscellaneous Helpers
|
||||
#########################
|
||||
|
||||
##
|
||||
# Updates the party's element based on its main weapon.
|
||||
#
|
||||
# Finds the main weapon (position -1) and updates the party's element if it differs.
|
||||
#
|
||||
# @return [void]
|
||||
def update_element!
|
||||
main_weapon = weapons.detect { |gw| gw.position.to_i == -1 }
|
||||
new_element = main_weapon&.weapon&.element
|
||||
update_column(:element, new_element) if new_element.present? && element != new_element
|
||||
end
|
||||
|
||||
##
|
||||
# Updates the party's extra flag based on weapon positions.
|
||||
#
|
||||
# Sets the extra flag to true if any weapon is in an extra position, otherwise false.
|
||||
#
|
||||
# @return [void]
|
||||
def update_extra!
|
||||
new_extra = weapons.any? { |gw| GridWeapon::EXTRA_POSITIONS.include?(gw.position.to_i) }
|
||||
update_column(:extra, new_extra) if extra != new_extra
|
||||
end
|
||||
|
||||
##
|
||||
# Sets a unique shortcode for the party before creation.
|
||||
#
|
||||
# Generates a random string and assigns it to the shortcode attribute.
|
||||
#
|
||||
# @return [void]
|
||||
def set_shortcode
|
||||
self.shortcode = random_string
|
||||
end
|
||||
|
||||
##
|
||||
# Sets an edit key for the party before creation if no associated user is present.
|
||||
#
|
||||
# The edit key is generated using a SHA1 hash based on the current time and a random value.
|
||||
#
|
||||
# @return [void]
|
||||
def set_edit_key
|
||||
return if user
|
||||
|
||||
self.edit_key ||= Digest::SHA1.hexdigest([Time.now, rand].join)
|
||||
self.edit_key = Digest::SHA1.hexdigest([Time.now, rand].join)
|
||||
end
|
||||
|
||||
##
|
||||
# Generates a random alphanumeric string used for the party shortcode.
|
||||
#
|
||||
# @return [String] a random string of 6 characters.
|
||||
def random_string
|
||||
num_chars = 6
|
||||
o = [('a'..'z'), ('A'..'Z'), (0..9)].map(&:to_a).flatten
|
||||
(0...num_chars).map { o[rand(o.length)] }.join
|
||||
end
|
||||
|
||||
def skills_are_unique
|
||||
skills = [skill0, skill1, skill2, skill3].compact
|
||||
|
||||
return if skills.uniq.length == skills.length
|
||||
|
||||
skills.each_with_index do |skill, index|
|
||||
next if index.zero?
|
||||
|
||||
errors.add(:"skill#{index + 1}", 'must be unique') if skills[0...index].include?(skill)
|
||||
end
|
||||
|
||||
errors.add(:job_skills, 'must be unique')
|
||||
end
|
||||
|
||||
def guidebooks_are_unique
|
||||
guidebooks = [guidebook1, guidebook2, guidebook3].compact
|
||||
return if guidebooks.uniq.length == guidebooks.length
|
||||
|
||||
guidebooks.each_with_index do |book, index|
|
||||
next if index.zero?
|
||||
|
||||
errors.add(:"guidebook#{index + 1}", 'must be unique') if guidebooks[0...index].include?(book)
|
||||
end
|
||||
|
||||
errors.add(:guidebooks, 'must be unique')
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -37,54 +37,6 @@ class Weapon < ApplicationRecord
|
|||
has_many :weapon_awakenings
|
||||
has_many :awakenings, through: :weapon_awakenings
|
||||
|
||||
SERIES_SLUGS = {
|
||||
1 => 'seraphic',
|
||||
2 => 'grand',
|
||||
3 => 'dark-opus',
|
||||
4 => 'revenant',
|
||||
5 => 'primal',
|
||||
6 => 'beast',
|
||||
7 => 'regalia',
|
||||
8 => 'omega',
|
||||
9 => 'olden-primal',
|
||||
10 => 'hollowsky',
|
||||
11 => 'xeno',
|
||||
12 => 'rose',
|
||||
13 => 'ultima',
|
||||
14 => 'bahamut',
|
||||
15 => 'epic',
|
||||
16 => 'cosmos',
|
||||
17 => 'superlative',
|
||||
18 => 'vintage',
|
||||
19 => 'class-champion',
|
||||
20 => 'replica',
|
||||
21 => 'relic',
|
||||
22 => 'rusted',
|
||||
23 => 'sephira',
|
||||
24 => 'vyrmament',
|
||||
25 => 'upgrader',
|
||||
26 => 'astral',
|
||||
27 => 'draconic',
|
||||
28 => 'eternal-splendor',
|
||||
29 => 'ancestral',
|
||||
30 => 'new-world-foundation',
|
||||
31 => 'ennead',
|
||||
32 => 'militis',
|
||||
33 => 'malice',
|
||||
34 => 'menace',
|
||||
35 => 'illustrious',
|
||||
36 => 'proven',
|
||||
37 => 'revans',
|
||||
38 => 'world',
|
||||
39 => 'exo',
|
||||
40 => 'draconic-providence',
|
||||
41 => 'celestial',
|
||||
42 => 'omega-rebirth',
|
||||
43 => 'collab',
|
||||
98 => 'event',
|
||||
99 => 'gacha'
|
||||
}.freeze
|
||||
|
||||
def blueprint
|
||||
WeaponBlueprint
|
||||
end
|
||||
|
|
@ -99,23 +51,11 @@ class Weapon < ApplicationRecord
|
|||
|
||||
# Returns whether the weapon is included in the Draconic or Dark Opus series
|
||||
def opus_or_draconic?
|
||||
[3, 27].include?(series)
|
||||
[2, 3].include?(series)
|
||||
end
|
||||
|
||||
# Returns whether the weapon belongs to the Draconic Weapon series or the Draconic Weapon Providence series
|
||||
def draconic_or_providence?
|
||||
[27, 40].include?(series)
|
||||
end
|
||||
|
||||
def self.element_changeable?(series)
|
||||
[4, 13, 17, 19].include?(series.to_i)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def series_slug
|
||||
# Assuming series is an array, take the first value
|
||||
series_number = series.first
|
||||
SERIES_SLUGS[series_number]
|
||||
[3, 34].include?(series)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,4 +3,12 @@
|
|||
class WeaponAwakening < ApplicationRecord
|
||||
belongs_to :weapon
|
||||
belongs_to :awakening
|
||||
|
||||
def weapon
|
||||
Weapon.find(weapon_id)
|
||||
end
|
||||
|
||||
def awakening
|
||||
Awakening.find(awakening_id)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,98 +0,0 @@
|
|||
class AwsService
|
||||
attr_reader :s3_client, :bucket
|
||||
|
||||
class ConfigurationError < StandardError; end
|
||||
|
||||
def initialize
|
||||
Rails.logger.info "Environment: #{Rails.env}"
|
||||
|
||||
# Try different methods of getting credentials
|
||||
creds = get_credentials
|
||||
Rails.logger.info "Credentials source: #{creds[:source]}"
|
||||
|
||||
@s3_client = Aws::S3::Client.new(
|
||||
region: creds[:region],
|
||||
access_key_id: creds[:access_key_id],
|
||||
secret_access_key: creds[:secret_access_key]
|
||||
)
|
||||
@bucket = creds[:bucket_name]
|
||||
rescue KeyError => e
|
||||
raise ConfigurationError, "Missing AWS credential: #{e.message}"
|
||||
end
|
||||
|
||||
def upload_stream(io, key)
|
||||
@s3_client.put_object(
|
||||
bucket: @bucket,
|
||||
key: key,
|
||||
body: io
|
||||
)
|
||||
end
|
||||
|
||||
def file_exists?(key)
|
||||
@s3_client.head_object(
|
||||
bucket: @bucket,
|
||||
key: key
|
||||
)
|
||||
true
|
||||
rescue Aws::S3::Errors::NotFound
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def get_credentials
|
||||
# Try Rails credentials first
|
||||
rails_creds = Rails.application.credentials.dig(:aws)
|
||||
if rails_creds&.dig(:access_key_id)
|
||||
Rails.logger.info "Using Rails credentials"
|
||||
return rails_creds.merge(source: 'rails_credentials')
|
||||
end
|
||||
|
||||
# Try string keys
|
||||
rails_creds = Rails.application.credentials.dig('aws')
|
||||
if rails_creds&.dig('access_key_id')
|
||||
Rails.logger.info "Using Rails credentials (string keys)"
|
||||
return {
|
||||
region: rails_creds['region'],
|
||||
access_key_id: rails_creds['access_key_id'],
|
||||
secret_access_key: rails_creds['secret_access_key'],
|
||||
bucket_name: rails_creds['bucket_name'],
|
||||
source: 'rails_credentials_string'
|
||||
}
|
||||
end
|
||||
|
||||
# Try environment variables
|
||||
if ENV['AWS_ACCESS_KEY_ID']
|
||||
Rails.logger.info "Using environment variables"
|
||||
return {
|
||||
region: ENV['AWS_REGION'],
|
||||
access_key_id: ENV['AWS_ACCESS_KEY_ID'],
|
||||
secret_access_key: ENV['AWS_SECRET_ACCESS_KEY'],
|
||||
bucket_name: ENV['AWS_BUCKET_NAME'],
|
||||
source: 'environment'
|
||||
}
|
||||
end
|
||||
|
||||
# Try alternate environment variable names
|
||||
if ENV['RAILS_AWS_ACCESS_KEY_ID']
|
||||
Rails.logger.info "Using Rails-prefixed environment variables"
|
||||
return {
|
||||
region: ENV['RAILS_AWS_REGION'],
|
||||
access_key_id: ENV['RAILS_AWS_ACCESS_KEY_ID'],
|
||||
secret_access_key: ENV['RAILS_AWS_SECRET_ACCESS_KEY'],
|
||||
bucket_name: ENV['RAILS_AWS_BUCKET_NAME'],
|
||||
source: 'rails_environment'
|
||||
}
|
||||
end
|
||||
|
||||
validate_credentials = ->(creds, source) {
|
||||
missing = []
|
||||
%i[region access_key_id secret_access_key bucket_name].each do |key|
|
||||
missing << key unless creds[key].present?
|
||||
end
|
||||
raise ConfigurationError, "Missing AWS credentials from #{source}: #{missing.join(', ')}" if missing.any?
|
||||
}
|
||||
|
||||
raise ConfigurationError, "No AWS credentials found in any location"
|
||||
end
|
||||
end
|
||||
|
|
@ -1,251 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Dataminer
|
||||
include HTTParty
|
||||
|
||||
BOT_UID = '39094985'
|
||||
GAME_VERSION = '1741068713'
|
||||
|
||||
base_uri 'https://game.granbluefantasy.jp'
|
||||
format :json
|
||||
|
||||
HEADERS = {
|
||||
'Accept' => 'application/json, text/javascript, */*; q=0.01',
|
||||
'Accept-Language' => 'en-US,en;q=0.9',
|
||||
'Accept-Encoding' => 'gzip, deflate, br, zstd',
|
||||
'Content-Type' => 'application/json',
|
||||
'DNT' => '1',
|
||||
'Origin' => 'https://game.granbluefantasy.jp',
|
||||
'Referer' => 'https://game.granbluefantasy.jp/',
|
||||
'User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36',
|
||||
'X-Requested-With' => 'XMLHttpRequest'
|
||||
}.freeze
|
||||
|
||||
attr_reader :page, :cookies, :logger, :debug
|
||||
|
||||
def initialize(page:, access_token:, wing:, midship:, t: 'dummy', debug: false)
|
||||
@page = page
|
||||
@cookies = {
|
||||
access_gbtk: access_token,
|
||||
wing: wing,
|
||||
t: t,
|
||||
midship: midship
|
||||
}
|
||||
@debug = debug
|
||||
setup_logger
|
||||
end
|
||||
|
||||
def fetch
|
||||
timestamp = Time.now.to_i * 1000
|
||||
response = self.class.post(
|
||||
"/#{page}?_=#{timestamp}&t=#{timestamp}&uid=#{BOT_UID}",
|
||||
headers: HEADERS.merge(
|
||||
'Cookie' => format_cookies,
|
||||
'X-VERSION' => GAME_VERSION
|
||||
)
|
||||
)
|
||||
|
||||
raise AuthenticationError if auth_failed?(response)
|
||||
|
||||
response
|
||||
end
|
||||
|
||||
def fetch_character(granblue_id)
|
||||
timestamp = Time.now.to_i * 1000
|
||||
url = "/archive/npc_detail?_=#{timestamp}&t=#{timestamp}&uid=#{BOT_UID}"
|
||||
body = {
|
||||
special_token: nil,
|
||||
user_id: BOT_UID,
|
||||
kind_name: '0',
|
||||
attribute: '0',
|
||||
event_id: nil,
|
||||
story_id: nil,
|
||||
style: 1,
|
||||
character_id: granblue_id
|
||||
}
|
||||
|
||||
response = fetch_detail(url, body)
|
||||
update_game_data('Character', granblue_id, response) if response
|
||||
response
|
||||
end
|
||||
|
||||
def fetch_weapon(granblue_id)
|
||||
timestamp = Time.now.to_i * 1000
|
||||
url = "/archive/weapon_detail?_=#{timestamp}&t=#{timestamp}&uid=#{BOT_UID}"
|
||||
body = {
|
||||
special_token: nil,
|
||||
user_id: BOT_UID,
|
||||
kind_name: '0',
|
||||
attribute: '0',
|
||||
event_id: nil,
|
||||
story_id: nil,
|
||||
weapon_id: granblue_id
|
||||
}
|
||||
|
||||
response = fetch_detail(url, body)
|
||||
update_game_data('Weapon', granblue_id, response) if response
|
||||
response
|
||||
end
|
||||
|
||||
def fetch_summon(granblue_id)
|
||||
timestamp = Time.now.to_i * 1000
|
||||
url = "/archive/summon_detail?_=#{timestamp}&t=#{timestamp}&uid=#{BOT_UID}"
|
||||
body = {
|
||||
special_token: nil,
|
||||
user_id: BOT_UID,
|
||||
kind_name: '0',
|
||||
attribute: '0',
|
||||
event_id: nil,
|
||||
story_id: nil,
|
||||
summon_id: granblue_id
|
||||
}
|
||||
|
||||
response = fetch_detail(url, body)
|
||||
update_game_data('Summon', granblue_id, response) if response
|
||||
response
|
||||
end
|
||||
|
||||
# Public batch processing methods
|
||||
def fetch_all_characters(only_missing: false)
|
||||
process_all_records('Character', only_missing: only_missing)
|
||||
end
|
||||
|
||||
def fetch_all_weapons(only_missing: false)
|
||||
process_all_records('Weapon', only_missing: only_missing)
|
||||
end
|
||||
|
||||
def fetch_all_summons(only_missing: false)
|
||||
process_all_records('Summon', only_missing: only_missing)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def format_cookies
|
||||
cookies.map { |k, v| "#{k}=#{v}" }.join('; ')
|
||||
end
|
||||
|
||||
def auth_failed?(response)
|
||||
return true if response.code != 200
|
||||
|
||||
begin
|
||||
parsed = JSON.parse(response.body)
|
||||
parsed.is_a?(Hash) && parsed['auth_status'] == 'require_auth'
|
||||
rescue JSON::ParserError
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
def setup_logger
|
||||
@logger = ::Logger.new($stdout)
|
||||
@logger.level = debug ? ::Logger::DEBUG : ::Logger::INFO
|
||||
@logger.formatter = proc do |severity, _datetime, _progname, msg|
|
||||
case severity
|
||||
when 'DEBUG'
|
||||
debug ? "#{msg}\n" : ''
|
||||
else
|
||||
"#{msg}\n"
|
||||
end
|
||||
end
|
||||
|
||||
# Suppress SQL logs in non-debug mode
|
||||
return if debug
|
||||
|
||||
ActiveRecord::Base.logger.level = ::Logger::INFO if defined?(ActiveRecord::Base)
|
||||
end
|
||||
|
||||
def fetch_detail(url, body)
|
||||
logger.debug "\n=== Request Details ==="
|
||||
logger.debug "URL: #{url}"
|
||||
logger.debug 'Headers:'
|
||||
logger.debug HEADERS.merge(
|
||||
'Cookie' => format_cookies,
|
||||
'X-VERSION' => GAME_VERSION
|
||||
).inspect
|
||||
logger.debug 'Body:'
|
||||
logger.debug body.to_json
|
||||
logger.debug '===================='
|
||||
|
||||
response = self.class.post(
|
||||
url,
|
||||
headers: HEADERS.merge(
|
||||
'Cookie' => format_cookies,
|
||||
'X-VERSION' => GAME_VERSION
|
||||
),
|
||||
body: body.to_json
|
||||
)
|
||||
|
||||
logger.debug "\n=== Response Details ==="
|
||||
logger.debug "Response code: #{response.code}"
|
||||
logger.debug 'Response headers:'
|
||||
logger.debug response.headers.inspect
|
||||
logger.debug 'Raw response body:'
|
||||
logger.debug response.body.inspect
|
||||
begin
|
||||
logger.debug 'Parsed response body (if JSON):'
|
||||
logger.debug JSON.parse(response.body).inspect
|
||||
rescue JSON::ParserError => e
|
||||
logger.debug "Could not parse as JSON: #{e.message}"
|
||||
end
|
||||
logger.debug '======================'
|
||||
|
||||
raise AuthenticationError if auth_failed?(response)
|
||||
|
||||
JSON.parse(response.body)
|
||||
end
|
||||
|
||||
def update_game_data(model_name, granblue_id, response_data)
|
||||
return unless response_data.is_a?(Hash)
|
||||
|
||||
model = Object.const_get(model_name)
|
||||
record = model.find_by(granblue_id: granblue_id)
|
||||
|
||||
if record
|
||||
record.update(game_raw_en: response_data)
|
||||
logger.debug "Updated #{model_name} #{granblue_id}"
|
||||
else
|
||||
logger.warn "#{model_name} with granblue_id #{granblue_id} not found in database"
|
||||
end
|
||||
rescue StandardError => e
|
||||
logger.error "Error updating #{model_name} #{granblue_id}: #{e.message}"
|
||||
end
|
||||
|
||||
def process_all_records(model_name, only_missing: false)
|
||||
model = Object.const_get(model_name)
|
||||
scope = model
|
||||
scope = scope.where(game_raw_en: nil) if only_missing
|
||||
|
||||
total = scope.count
|
||||
success_count = 0
|
||||
error_count = 0
|
||||
|
||||
logger.info "Starting to fetch #{total} #{model_name.downcase}s#{' (missing data only)' if only_missing}..."
|
||||
|
||||
scope.find_each do |record|
|
||||
logger.info "\nProcessing #{model_name} #{record.granblue_id} (#{success_count + error_count + 1}/#{total})"
|
||||
|
||||
response = case model_name
|
||||
when 'Character'
|
||||
fetch_character(record.granblue_id)
|
||||
when 'Weapon'
|
||||
fetch_weapon(record.granblue_id)
|
||||
when 'Summon'
|
||||
fetch_summon(record.granblue_id)
|
||||
end
|
||||
|
||||
success_count += 1
|
||||
logger.debug "Successfully processed #{model_name} #{record.granblue_id}"
|
||||
|
||||
sleep(1)
|
||||
rescue StandardError => e
|
||||
error_count += 1
|
||||
logger.error "Error processing #{model_name} #{record.granblue_id}: #{e.message}"
|
||||
end
|
||||
|
||||
logger.info "\nProcessing complete!"
|
||||
logger.info "Total: #{total}"
|
||||
logger.info "Successful: #{success_count}"
|
||||
logger.info "Failed: #{error_count}"
|
||||
end
|
||||
|
||||
class AuthenticationError < StandardError; end
|
||||
end
|
||||
|
|
@ -1,274 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# PartyQueryBuilder is responsible for building an ActiveRecord query for parties
|
||||
# by applying a series of filters, includes, and excludes based on request parameters.
|
||||
# It is used to build both the general parties query and specialized queries (like
|
||||
# for a user’s profile) while keeping the filtering logic DRY.
|
||||
#
|
||||
# Usage:
|
||||
# base_query = Party.includes(:user, :job, ... ) # a starting query
|
||||
# query_builder = PartyQueryBuilder.new(base_query, params: params, current_user: current_user, options: { default_status: 'active' })
|
||||
# final_query = query_builder.build
|
||||
#
|
||||
class PartyQueryBuilder
|
||||
# Initialize with a base query, a params hash, and the current user.
|
||||
# Options may include default filters like :default_status, default counts, and max values.
|
||||
def initialize(base_query, params:, current_user:, options: {})
|
||||
@base_query = base_query
|
||||
@params = params
|
||||
@current_user = current_user
|
||||
@options = options
|
||||
end
|
||||
|
||||
# Builds the final ActiveRecord query by applying filters, includes, and excludes.
|
||||
#
|
||||
# Edge cases handled:
|
||||
# - If a parameter is missing or blank, default values are used.
|
||||
# - If no recency is provided, no date range is applied.
|
||||
# - If includes/excludes parameters are missing, those methods are skipped.
|
||||
#
|
||||
# Also applies a default status filter (if provided via options) using a dedicated callback.
|
||||
def build
|
||||
query = @base_query
|
||||
query = apply_filters(query)
|
||||
query = apply_default_status(query) if @options[:default_status]
|
||||
query = apply_privacy_settings(query)
|
||||
query = apply_includes(query, @params[:includes]) if @params[:includes].present?
|
||||
query = apply_excludes(query, @params[:excludes]) if @params[:excludes].present?
|
||||
query.order(created_at: :desc)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Applies filtering conditions to the given query.
|
||||
# Combines generic filters (like element, raid_id, created_at) with object count ranges.
|
||||
#
|
||||
# Example edge case: If the request does not specify 'characters_count',
|
||||
# then the default (e.g. 3) will be used, with the upper bound coming from a constant.
|
||||
def apply_filters(query)
|
||||
query = apply_base_filters(query)
|
||||
query = apply_name_quality_filter(query)
|
||||
query = apply_count_filters(query)
|
||||
|
||||
query
|
||||
end
|
||||
|
||||
# Example callback method: if no explicit status filter is provided, we may want
|
||||
# to force the query to include only records with a given default status.
|
||||
# This method encapsulates that behavior.
|
||||
def apply_default_status(query)
|
||||
query.where(status: @options[:default_status])
|
||||
end
|
||||
|
||||
# Applies privacy settings based on whether the current user is an admin.
|
||||
def apply_privacy_settings(query)
|
||||
# If the options say to skip privacy filtering (e.g. when viewing your own profile),
|
||||
# then return the query unchanged.
|
||||
return query if @options[:skip_privacy]
|
||||
|
||||
# Otherwise, if not admin, only show public parties.
|
||||
return query if @current_user&.admin?
|
||||
|
||||
query.where('visibility = ?', 1)
|
||||
end
|
||||
|
||||
# Builds a hash of filtering conditions from the params.
|
||||
#
|
||||
# Uses guard clauses to ignore keys when a parameter is missing.
|
||||
def build_filters
|
||||
{
|
||||
element: (@params[:element].present? ? @params[:element].to_i : nil),
|
||||
raid_id: @params[:raid],
|
||||
created_at: build_date_range,
|
||||
full_auto: build_option(@params[:full_auto]),
|
||||
auto_guard: build_option(@params[:auto_guard]),
|
||||
charge_attack: build_option(@params[:charge_attack])
|
||||
}.compact
|
||||
end
|
||||
|
||||
# Returns a date range based on the 'recency' parameter.
|
||||
# If recency is not provided, returns nil so no date filter is applied.
|
||||
def build_date_range
|
||||
return nil unless @params[:recency].present?
|
||||
start_time = DateTime.current - @params[:recency].to_i.seconds
|
||||
start_time.beginning_of_day..DateTime.current
|
||||
end
|
||||
|
||||
# Returns the count from the parameter or a default value if the parameter is blank.
|
||||
def build_count(value, default_value)
|
||||
value.blank? ? default_value : value.to_i
|
||||
end
|
||||
|
||||
# Processes an option parameter.
|
||||
# Returns the integer value unless the value is blank or equal to -1.
|
||||
def build_option(value)
|
||||
value.to_i unless value.blank? || value.to_i == -1
|
||||
end
|
||||
|
||||
# Applies "includes" filtering to the query based on a comma-separated string.
|
||||
# For each provided ID, it adds a condition using an EXISTS subquery.
|
||||
#
|
||||
# Edge case example: If an ID does not start with a known prefix,
|
||||
# grid_table_and_object_table returns [nil, nil] and the condition is skipped.
|
||||
def apply_includes(query, includes)
|
||||
includes.split(',').each do |id|
|
||||
grid_table, object_table = grid_table_and_object_table(id)
|
||||
next unless grid_table && object_table
|
||||
condition = <<-SQL.squish
|
||||
EXISTS (
|
||||
SELECT 1 FROM #{grid_table}
|
||||
JOIN #{object_table} ON #{grid_table}.#{object_table.singularize}_id = #{object_table}.id
|
||||
WHERE #{object_table}.granblue_id = ? AND #{grid_table}.party_id = parties.id
|
||||
)
|
||||
SQL
|
||||
query = query.where(condition, id)
|
||||
end
|
||||
query
|
||||
end
|
||||
|
||||
# Applies "excludes" filtering to the query based on a comma-separated string.
|
||||
# Works similarly to apply_includes, but with a NOT EXISTS clause.
|
||||
def apply_excludes(query, excludes)
|
||||
excludes.split(',').each do |id|
|
||||
grid_table, object_table = grid_table_and_object_table(id)
|
||||
next unless grid_table && object_table
|
||||
condition = <<-SQL.squish
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM #{grid_table}
|
||||
JOIN #{object_table} ON #{grid_table}.#{object_table.singularize}_id = #{object_table}.id
|
||||
WHERE #{object_table}.granblue_id = ? AND #{grid_table}.party_id = parties.id
|
||||
)
|
||||
SQL
|
||||
query = query.where(condition, id)
|
||||
end
|
||||
query
|
||||
end
|
||||
|
||||
# Applies base filtering conditions from build_filters to the query.
|
||||
# @param query [ActiveRecord::QueryMethods::WhereChain] The current query.
|
||||
# @return [ActiveRecord::Relation] The query with base filters applied.
|
||||
def apply_base_filters(query)
|
||||
query.where(build_filters)
|
||||
end
|
||||
|
||||
# Applies the name quality filter to the query if the parameter is present.
|
||||
# @param query [ActiveRecord::QueryMethods::WhereChain] The current query.
|
||||
# @return [ActiveRecord::Relation] The query with the name quality filter applied.
|
||||
def apply_name_quality_filter(query)
|
||||
@params[:name_quality].present? ? query.where(name_quality) : query
|
||||
end
|
||||
|
||||
# Applies count filters to the query based on provided parameters or default options.
|
||||
# If apply_defaults is set in options, default ranges are applied.
|
||||
# Otherwise, count ranges are built from provided parameters.
|
||||
# @param query [ | ActiveRecord::QueryMethods::WhereChain] The current query.
|
||||
# @return [ActiveRecord::Relation] The query with count filters applied.
|
||||
def apply_count_filters(query)
|
||||
if @options[:apply_defaults]
|
||||
query.where(
|
||||
weapons_count: default_weapons_count..max_weapons,
|
||||
characters_count: default_characters_count..max_characters,
|
||||
summons_count: default_summons_count..max_summons
|
||||
)
|
||||
elsif count_filter_provided?
|
||||
query.where(build_count_conditions)
|
||||
else
|
||||
query
|
||||
end
|
||||
end
|
||||
|
||||
# Determines if any count filter parameters have been provided.
|
||||
# @return [Boolean] True if any count filters are provided, false otherwise.
|
||||
def count_filter_provided?
|
||||
@params.key?(:weapons_count) || @params.key?(:characters_count) || @params.key?(:summons_count)
|
||||
end
|
||||
|
||||
# Builds a hash of count conditions based on the count filter parameters.
|
||||
# @return [Hash] A hash with keys :weapons_count, :characters_count, and :summons_count.
|
||||
def build_count_conditions
|
||||
{
|
||||
weapons_count: build_range(@params[:weapons_count], max_weapons),
|
||||
characters_count: build_range(@params[:characters_count], max_characters),
|
||||
summons_count: build_range(@params[:summons_count], max_summons)
|
||||
}
|
||||
end
|
||||
|
||||
# Constructs a range for a given count parameter.
|
||||
# @param param_value [String, nil] The count filter parameter value.
|
||||
# @param max_value [Integer] The maximum allowed value for the count.
|
||||
# @return [Range] A range from the provided count (or 0 if blank) to the max_value.
|
||||
def build_range(param_value, max_value)
|
||||
param_value.present? ? param_value.to_i..max_value : 0..max_value
|
||||
end
|
||||
|
||||
# Maps an ID’s first character to the corresponding grid table and object table names.
|
||||
#
|
||||
# For example:
|
||||
# '3...' => %w[grid_characters characters]
|
||||
# '2...' => %w[grid_summons summons]
|
||||
# '1...' => %w[grid_weapons weapons]
|
||||
# Returns [nil, nil] for unknown prefixes.
|
||||
def grid_table_and_object_table(id)
|
||||
case id[0]
|
||||
when '3'
|
||||
%w[grid_characters characters]
|
||||
when '2'
|
||||
%w[grid_summons summons]
|
||||
when '1'
|
||||
%w[grid_weapons weapons]
|
||||
else
|
||||
[nil, nil]
|
||||
end
|
||||
end
|
||||
|
||||
# Default values and maximum limits for counts.
|
||||
def default_weapons_count
|
||||
@options[:default_weapons_count] || 5
|
||||
end
|
||||
|
||||
def default_characters_count
|
||||
@options[:default_characters_count] || 3
|
||||
end
|
||||
|
||||
def default_summons_count
|
||||
@options[:default_summons_count] || 2
|
||||
end
|
||||
|
||||
def max_weapons
|
||||
@options[:max_weapons] || 13
|
||||
end
|
||||
|
||||
def max_characters
|
||||
@options[:max_characters] || 5
|
||||
end
|
||||
|
||||
def max_summons
|
||||
@options[:max_summons] || 8
|
||||
end
|
||||
|
||||
# Stub method for name quality filtering.
|
||||
# In your application, this might be defined in a helper or concern.
|
||||
def name_quality
|
||||
# Example: exclude parties with names like 'Untitled' (edge case)
|
||||
"name NOT LIKE 'Untitled%'"
|
||||
end
|
||||
|
||||
# Stub method for user quality filtering.
|
||||
# Adjust as needed for your actual implementation.
|
||||
def user_quality
|
||||
'user_id IS NOT NULL'
|
||||
end
|
||||
|
||||
# Stub method for original filtering.
|
||||
def original
|
||||
'source_party_id IS NULL'
|
||||
end
|
||||
|
||||
# Stub method for privacy filtering.
|
||||
# Here we assume that if the current user is not an admin, only public parties (visibility = 1) are returned.
|
||||
def privacy
|
||||
return nil if @current_user && @current_user.admin?
|
||||
|
||||
'visibility = 1'
|
||||
end
|
||||
end
|
||||
|
|
@ -1,217 +0,0 @@
|
|||
# app/services/canvas.rb
|
||||
module PreviewService
|
||||
class Canvas
|
||||
PREVIEW_WIDTH = 1200
|
||||
PREVIEW_HEIGHT = 630
|
||||
DEFAULT_BACKGROUND_COLOR = '#1a1b1e'
|
||||
|
||||
# Padding and spacing constants
|
||||
PADDING = 24
|
||||
TITLE_IMAGE_GAP = 24
|
||||
GRID_GAP = 4
|
||||
|
||||
def initialize(image_fetcher)
|
||||
@image_fetcher = image_fetcher
|
||||
end
|
||||
|
||||
def create_blank_canvas(width: PREVIEW_WIDTH, height: PREVIEW_HEIGHT, color: DEFAULT_BACKGROUND_COLOR)
|
||||
Rails.logger.info("Creating blank canvas #{width}x#{height}")
|
||||
temp_file = Tempfile.new(%w[canvas .png])
|
||||
Rails.logger.info("Temp file created at: #{temp_file.path}")
|
||||
|
||||
begin
|
||||
Rails.logger.info("Checking ImageMagick setup...")
|
||||
version = `which convert`
|
||||
Rails.logger.info("ImageMagick convert path: #{version}")
|
||||
|
||||
Rails.logger.info("Executing convert command...")
|
||||
MiniMagick::Tool::Convert.new do |convert|
|
||||
convert.size "#{width}x#{height}"
|
||||
convert << "xc:#{color}"
|
||||
convert << temp_file.path
|
||||
end
|
||||
Rails.logger.info("Convert command completed successfully")
|
||||
rescue => e
|
||||
Rails.logger.error("Failed to create canvas with convert: #{e.class} - #{e.message}")
|
||||
Rails.logger.error("PATH: #{ENV['PATH']}")
|
||||
Rails.logger.error("LD_LIBRARY_PATH: #{ENV['LD_LIBRARY_PATH']}")
|
||||
Rails.logger.error(e.backtrace.join("\n"))
|
||||
raise
|
||||
end
|
||||
|
||||
Rails.logger.info("Canvas created successfully at: #{temp_file.path}")
|
||||
temp_file
|
||||
end
|
||||
|
||||
def add_text(image, party_name, job_icon: nil, user: nil, **options)
|
||||
party_name = party_name.to_s.strip
|
||||
party_name = 'Untitled' if party_name.empty?
|
||||
|
||||
font_size = options.fetch(:size, '32')
|
||||
font_color = options.fetch(:color, 'white')
|
||||
|
||||
# Try multiple font locations
|
||||
font_locations = [
|
||||
Rails.root.join('app', 'assets', 'fonts', 'Gk-Bd.otf').to_s,
|
||||
Rails.root.join('public', 'assets', 'fonts', 'Gk-Bd.otf').to_s
|
||||
]
|
||||
|
||||
@font_path = font_locations.find { |path| File.exist?(path) }
|
||||
|
||||
unless @font_path
|
||||
Rails.logger.error("Font file not found in any location: #{font_locations.join(', ')}")
|
||||
raise "Font file not found"
|
||||
end
|
||||
|
||||
Rails.logger.info("Using font path: #{@font_path}")
|
||||
unless File.exist?(@font_path)
|
||||
Rails.logger.error("Font file not found at: #{@font_path}")
|
||||
raise "Font file not found"
|
||||
end
|
||||
|
||||
# Measure party name text size
|
||||
text_metrics = measure_text(party_name, font_size)
|
||||
|
||||
# Draw job icon if provided
|
||||
image = draw_job_icon(image, job_icon) if job_icon
|
||||
|
||||
# Draw party name text
|
||||
image = draw_party_name(image, party_name, text_metrics, job_icon, font_color, font_size)
|
||||
|
||||
# Compute vertical center of the party name text line
|
||||
party_text_center_y = PADDING + (text_metrics[:height] / 2.0)
|
||||
|
||||
# Draw user info if provided
|
||||
image = draw_user_info(image, user, party_text_center_y, font_color) if user
|
||||
|
||||
{
|
||||
image: image,
|
||||
text_bottom_y: PADDING + text_metrics[:height] + TITLE_IMAGE_GAP
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def draw_job_icon(image, job_icon)
|
||||
job_icon.format("png32")
|
||||
job_icon.alpha('set')
|
||||
job_icon.background('none')
|
||||
job_icon.combine_options do |c|
|
||||
c.filter "Lanczos" # High-quality filter
|
||||
c.resize "64x64"
|
||||
end
|
||||
image = image.composite(job_icon) do |c|
|
||||
c.compose "Over"
|
||||
c.geometry "+#{PADDING}+#{PADDING}"
|
||||
end
|
||||
image
|
||||
end
|
||||
|
||||
def draw_party_name(image, party_name, text_metrics, job_icon, font_color, font_size)
|
||||
text_x = job_icon ? PADDING + 64 + 16 : PADDING
|
||||
text_y = PADDING + text_metrics[:height]
|
||||
|
||||
image.combine_options do |c|
|
||||
c.gravity 'NorthWest'
|
||||
c.fill font_color
|
||||
c.font @font_path
|
||||
c.pointsize font_size
|
||||
# Escape quotes and use pango markup for better text handling
|
||||
c.annotate "0x0+#{text_x}+#{text_y}", party_name.gsub('"', '\"')
|
||||
end
|
||||
|
||||
image
|
||||
end
|
||||
|
||||
def draw_user_info(image, user, party_text_center_y, font_color)
|
||||
username_font_size = 24
|
||||
username_font_path = @font_path
|
||||
|
||||
# Fetch and prepare user picture
|
||||
user_picture = @image_fetcher.fetch_user_picture(user.picture)
|
||||
if user_picture
|
||||
user_picture.format("png32")
|
||||
user_picture.alpha('set')
|
||||
user_picture.background('none')
|
||||
user_picture.combine_options do |c|
|
||||
c.filter "Lanczos" # Use a high-quality filter
|
||||
c.resize "48x48"
|
||||
end
|
||||
end
|
||||
|
||||
# Measure username text size
|
||||
username_metrics = measure_text(user.username, username_font_size, font: username_font_path)
|
||||
|
||||
right_padding = PADDING
|
||||
total_user_width = 48 + 8 + username_metrics[:width]
|
||||
user_x = image.width - right_padding - total_user_width
|
||||
|
||||
# Center user picture vertically relative to party text line
|
||||
user_pic_y = (party_text_center_y - (48 / 2.0)).round
|
||||
|
||||
image = image.composite(user_picture) do |c|
|
||||
c.compose "Over"
|
||||
c.geometry "+#{user_x}+#{user_pic_y}"
|
||||
end if user_picture
|
||||
|
||||
# Adjust text y-coordinate to better align vertically with the picture
|
||||
# You may need to tweak the offset value based on visual inspection.
|
||||
vertical_offset = 6 # Adjust this value as needed
|
||||
user_text_y = (party_text_center_y + (username_metrics[:height] / 2.0) - vertical_offset).round
|
||||
|
||||
image.combine_options do |c|
|
||||
c.font username_font_path
|
||||
c.fill font_color
|
||||
c.pointsize username_font_size
|
||||
text_x = user_x + 48 + 12
|
||||
c.draw "text #{text_x},#{user_text_y} '#{user.username}'"
|
||||
end
|
||||
|
||||
image
|
||||
end
|
||||
|
||||
def measure_text(text, font_size, font: @font_path)
|
||||
# Ensure text is not empty and is properly escaped
|
||||
text = text.to_s.strip
|
||||
text = 'Untitled' if text.empty?
|
||||
|
||||
# Escape text for shell command
|
||||
escaped_text = text.gsub(/'/, "'\\\\''")
|
||||
|
||||
# Create a temporary file for the text measurement
|
||||
temp_file = Tempfile.new(['text_measure', '.png'])
|
||||
|
||||
begin
|
||||
# Use ImageMagick command to create an image with the text
|
||||
command = [
|
||||
'magick',
|
||||
'-background', 'transparent',
|
||||
'-fill', 'black',
|
||||
'-font', font,
|
||||
'-pointsize', font_size.to_s,
|
||||
"label:'#{escaped_text}'", # Quote the text
|
||||
temp_file.path
|
||||
]
|
||||
|
||||
# Execute the command
|
||||
system(*command)
|
||||
|
||||
# Use MiniMagick to read the image and get dimensions
|
||||
image = MiniMagick::Image.open(temp_file.path)
|
||||
|
||||
{
|
||||
height: image.height,
|
||||
width: image.width
|
||||
}
|
||||
rescue => e
|
||||
Rails.logger.error "Text measurement error: #{e.message}"
|
||||
# Fallback dimensions
|
||||
{ height: 50, width: 200 }
|
||||
ensure
|
||||
# Close and unlink the temporary file
|
||||
temp_file.close
|
||||
temp_file.unlink
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,532 +0,0 @@
|
|||
# app/services/preview/coordinator.rb
|
||||
module PreviewService
|
||||
class Coordinator
|
||||
PREVIEW_FOLDER = 'previews'
|
||||
PREVIEW_WIDTH = 1200
|
||||
PREVIEW_HEIGHT = 630
|
||||
PREVIEW_EXPIRY = 30.days
|
||||
GENERATION_TIMEOUT = 5.minutes
|
||||
LOCAL_STORAGE_PATH = Rails.root.join('storage', 'party-previews')
|
||||
PREVIEW_DEBOUNCE_PERIOD = 5.minutes
|
||||
|
||||
# Public Interface - Core Operations
|
||||
|
||||
# Initialize the party preview service
|
||||
#
|
||||
# @param party [Party] The party to generate a preview for
|
||||
def initialize(party)
|
||||
@party = party
|
||||
@image_fetcher = ImageFetcherService.new(AwsService.new)
|
||||
@grid_service = Grid.new
|
||||
@canvas_service = Canvas.new(@image_fetcher)
|
||||
setup_storage
|
||||
end
|
||||
|
||||
# Retrieves the URL for the party's preview image
|
||||
#
|
||||
# @return [String] A URL pointing to the party's preview image
|
||||
def preview_url
|
||||
if preview_exists?
|
||||
Rails.env.production? ? generate_s3_url : local_preview_url
|
||||
else
|
||||
schedule_generation unless generation_in_progress?
|
||||
default_preview_url
|
||||
end
|
||||
end
|
||||
|
||||
# Generates a preview image for the party
|
||||
#
|
||||
# @return [Boolean] True if preview generation was successful, false otherwise
|
||||
def generate_preview
|
||||
return false unless should_generate?
|
||||
|
||||
begin
|
||||
Rails.logger.info("🖼️ Starting preview generation for party #{@party.id}")
|
||||
|
||||
Rails.logger.info("🖼️ Updating party state to in_progress")
|
||||
@party.update!(preview_state: :in_progress)
|
||||
set_generation_in_progress
|
||||
|
||||
Rails.logger.info("🖼️ Checking ImageMagick installation...")
|
||||
begin
|
||||
version = `convert -version`
|
||||
Rails.logger.info("🖼️ ImageMagick version: #{version}")
|
||||
rescue => e
|
||||
Rails.logger.error("🖼️ Failed to get ImageMagick version: #{e.message}")
|
||||
end
|
||||
|
||||
Rails.logger.info("🖼️ Creating preview image...")
|
||||
begin
|
||||
image = create_preview_image
|
||||
Rails.logger.info("🖼️ Preview image created successfully")
|
||||
rescue => e
|
||||
Rails.logger.error("🖼️ Failed to create preview image: #{e.class} - #{e.message}")
|
||||
Rails.logger.error(e.backtrace.join("\n"))
|
||||
raise e
|
||||
end
|
||||
|
||||
Rails.logger.info("🖼️ Saving preview...")
|
||||
begin
|
||||
save_preview(image)
|
||||
Rails.logger.info("🖼️ Preview saved successfully")
|
||||
rescue => e
|
||||
Rails.logger.error("🖼️ Failed to save preview: #{e.class} - #{e.message}")
|
||||
Rails.logger.error(e.backtrace.join("\n"))
|
||||
raise e
|
||||
end
|
||||
|
||||
Rails.logger.info("🖼️ Updating party state...")
|
||||
@party.update!(
|
||||
preview_state: :generated,
|
||||
preview_generated_at: Time.current
|
||||
)
|
||||
Rails.logger.info("🖼️ Party state updated successfully")
|
||||
|
||||
true
|
||||
rescue => e
|
||||
Rails.logger.error("🖼️ Preview generation failed: #{e.class} - #{e.message}")
|
||||
Rails.logger.error("🖼️ Stack trace:")
|
||||
Rails.logger.error(e.backtrace.join("\n"))
|
||||
handle_preview_generation_error(e)
|
||||
false
|
||||
ensure
|
||||
Rails.logger.info("🖼️ Cleaning up resources...")
|
||||
@image_fetcher.cleanup
|
||||
clear_generation_in_progress
|
||||
Rails.logger.info("🖼️ Cleanup completed")
|
||||
end
|
||||
end
|
||||
|
||||
# Forces regeneration of the party's preview image
|
||||
#
|
||||
# @return [Boolean] Result of the preview generation attempt
|
||||
def force_regenerate
|
||||
delete_preview if preview_exists?
|
||||
generate_preview
|
||||
end
|
||||
|
||||
# Deletes the existing preview image for the party
|
||||
#
|
||||
# @return [void]
|
||||
def delete_preview
|
||||
if Rails.env.production?
|
||||
delete_s3_preview
|
||||
else
|
||||
delete_local_previews
|
||||
end
|
||||
|
||||
@party.update!(
|
||||
preview_state: :pending,
|
||||
preview_generated_at: nil
|
||||
)
|
||||
rescue => e
|
||||
Rails.logger.error("Failed to delete preview for party #{@party.id}: #{e.message}")
|
||||
end
|
||||
|
||||
# State Management - Public
|
||||
|
||||
# Determines if a new preview should be generated
|
||||
#
|
||||
# @return [Boolean] True if a new preview should be generated, false otherwise
|
||||
def should_generate?
|
||||
Rails.logger.info("🖼️ Checking should_generate? conditions")
|
||||
|
||||
unless @party.ready_for_preview?
|
||||
Rails.logger.info("🖼️ Party not ready for preview (insufficient content)")
|
||||
return false
|
||||
end
|
||||
|
||||
if generation_in_progress?
|
||||
Rails.logger.info("🖼️ Generation already in progress, returning false")
|
||||
return false
|
||||
end
|
||||
|
||||
Rails.logger.info("🖼️ Preview state: #{@party.preview_state}")
|
||||
|
||||
case @party.preview_state
|
||||
when 'pending', 'queued'
|
||||
Rails.logger.info("🖼️ State is #{@party.preview_state}, will generate")
|
||||
true
|
||||
when 'in_progress'
|
||||
Rails.logger.info("🖼️ State is in_progress, skipping generation")
|
||||
false
|
||||
when 'failed'
|
||||
should_retry = @party.preview_generated_at.nil? ||
|
||||
@party.preview_generated_at < PREVIEW_DEBOUNCE_PERIOD.ago
|
||||
Rails.logger.info("🖼️ Failed state, should retry: #{should_retry}")
|
||||
should_retry
|
||||
when 'generated'
|
||||
expired = @party.preview_expired?
|
||||
changed = @party.preview_content_changed?
|
||||
debounced = @party.preview_generated_at.nil? ||
|
||||
@party.preview_generated_at < PREVIEW_DEBOUNCE_PERIOD.ago
|
||||
|
||||
should_regenerate = expired || (changed && debounced)
|
||||
|
||||
Rails.logger.info("🖼️ Generated state check - expired: #{expired}, content changed: #{changed}, debounced: #{debounced}")
|
||||
Rails.logger.info("🖼️ Should regenerate: #{should_regenerate}")
|
||||
|
||||
should_regenerate
|
||||
else
|
||||
Rails.logger.info("🖼️ Unknown state, will generate")
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
# Checks if a preview generation is currently in progress
|
||||
#
|
||||
# @return [Boolean] True if a preview is being generated, false otherwise
|
||||
def generation_in_progress?
|
||||
in_progress = Rails.cache.exist?("party_preview_generating_#{@party.id}")
|
||||
Rails.logger.info("Cache key check for generation_in_progress: #{in_progress}")
|
||||
in_progress
|
||||
end
|
||||
|
||||
# Retrieves the S3 object for the party's preview image
|
||||
#
|
||||
# @return [Aws::S3::Types::GetObjectOutput] S3 object containing the preview image
|
||||
# @raise [Aws::S3::Errors::NoSuchKey] If the preview image doesn't exist in S3
|
||||
# @raise [Aws::S3::Errors::NoSuchBucket] If the configured bucket doesn't exist
|
||||
def get_s3_object
|
||||
@aws_service.s3_client.get_object(
|
||||
bucket: @aws_service.bucket,
|
||||
key: preview_key
|
||||
)
|
||||
end
|
||||
|
||||
# Schedules a background job to generate the preview
|
||||
#
|
||||
# @return [void]
|
||||
def schedule_generation
|
||||
GeneratePartyPreviewJob
|
||||
.set(wait: 30.seconds)
|
||||
.perform_later(@party.id)
|
||||
|
||||
@party.update!(preview_state: :queued)
|
||||
end
|
||||
|
||||
# Returns the full path for storing preview images locally
|
||||
#
|
||||
# @return [Pathname] Full path where the preview image should be stored
|
||||
def local_preview_path
|
||||
LOCAL_STORAGE_PATH.join(preview_filename)
|
||||
end
|
||||
|
||||
# Creates the preview image for the party
|
||||
#
|
||||
# @return [MiniMagick::Image] The generated preview image
|
||||
def create_preview_image
|
||||
Rails.logger.info("Creating blank canvas...")
|
||||
begin
|
||||
canvas = @canvas_service.create_blank_canvas
|
||||
Rails.logger.info("Canvas created at: #{canvas.path}")
|
||||
image = MiniMagick::Image.new(canvas.path)
|
||||
Rails.logger.info("MiniMagick image object created")
|
||||
rescue => e
|
||||
Rails.logger.error("Failed to create canvas: #{e.class} - #{e.message}")
|
||||
Rails.logger.error(e.backtrace.join("\n"))
|
||||
raise e
|
||||
end
|
||||
|
||||
# Add more detailed logging for job icon handling
|
||||
Rails.logger.info("Processing job icon...")
|
||||
job_icon = nil
|
||||
if @party.job.present?
|
||||
Rails.logger.info("Job present: #{@party.job.inspect}")
|
||||
Rails.logger.info("Fetching job icon for job ID: #{@party.job.granblue_id}")
|
||||
begin
|
||||
job_icon = @image_fetcher.fetch_job_icon(@party.job.granblue_id)
|
||||
Rails.logger.info("Job icon fetched successfully") if job_icon
|
||||
rescue => e
|
||||
Rails.logger.error("Failed to fetch job icon: #{e.class} - #{e.message}")
|
||||
Rails.logger.error(e.backtrace.join("\n"))
|
||||
# Don't raise this error, just log it and continue without the job icon
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
Rails.logger.info("Adding party name and job icon...")
|
||||
text_result = @canvas_service.add_text(image, @party.name, job_icon: job_icon, user: @party.user)
|
||||
image = text_result[:image]
|
||||
Rails.logger.info("Text and icon added successfully")
|
||||
rescue => e
|
||||
Rails.logger.error("Failed to add text/icon: #{e.class} - #{e.message}")
|
||||
Rails.logger.error(e.backtrace.join("\n"))
|
||||
raise e
|
||||
end
|
||||
|
||||
begin
|
||||
Rails.logger.info("Calculating grid layout...")
|
||||
grid_layout = @grid_service.calculate_layout(
|
||||
canvas_height: Canvas::PREVIEW_HEIGHT,
|
||||
title_bottom_y: text_result[:text_bottom_y]
|
||||
)
|
||||
Rails.logger.info("Grid layout calculated")
|
||||
|
||||
Rails.logger.info("Drawing weapons...")
|
||||
Rails.logger.info("Weapons count: #{@party.weapons.count}")
|
||||
image = organize_and_draw_weapons(image, grid_layout)
|
||||
Rails.logger.info("Weapons drawn successfully")
|
||||
rescue => e
|
||||
Rails.logger.error("Failed during weapons drawing: #{e.class} - #{e.message}")
|
||||
Rails.logger.error(e.backtrace.join("\n"))
|
||||
raise e
|
||||
end
|
||||
|
||||
image
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Sets up the appropriate storage system based on environment
|
||||
#
|
||||
# @return [void]
|
||||
def setup_storage
|
||||
# Always initialize AWS service for potential image fetching
|
||||
@aws_service = AwsService.new
|
||||
|
||||
# Create local storage paths in development
|
||||
FileUtils.mkdir_p(LOCAL_STORAGE_PATH) unless Dir.exist?(LOCAL_STORAGE_PATH.to_s)
|
||||
end
|
||||
|
||||
# Image Generation Pipeline
|
||||
|
||||
# Adds the job icon to the preview image
|
||||
#
|
||||
# @param image [MiniMagick::Image] The base image
|
||||
# @param job_icon [MiniMagick::Image] The job icon to add
|
||||
# @return [MiniMagick::Image] The updated image
|
||||
def add_job_icon(image, job_icon)
|
||||
job_icon.resize '200x200'
|
||||
image.composite(job_icon) do |comp|
|
||||
comp.compose "Over"
|
||||
comp.geometry "+40+120"
|
||||
end
|
||||
end
|
||||
|
||||
# Organizes and draws weapons on the preview image
|
||||
#
|
||||
# @param image [MiniMagick::Image] The base image
|
||||
# @param grid_layout [Hash] The layout configuration for the grid
|
||||
# @return [MiniMagick::Image] The updated image with weapons
|
||||
def organize_and_draw_weapons(image, grid_layout)
|
||||
mainhand_weapon = @party.weapons.find(&:mainhand)
|
||||
grid_weapons = @party.weapons.reject(&:mainhand)
|
||||
|
||||
# Draw mainhand weapon
|
||||
if mainhand_weapon
|
||||
weapon_image = @image_fetcher.fetch_weapon_image(mainhand_weapon.weapon, mainhand: true)
|
||||
image = @grid_service.draw_grid_item(image, weapon_image, 'mainhand', 0, grid_layout) if weapon_image
|
||||
end
|
||||
|
||||
# Draw grid weapons
|
||||
grid_weapons.each_with_index do |weapon, idx|
|
||||
weapon_image = @image_fetcher.fetch_weapon_image(weapon.weapon)
|
||||
image = @grid_service.draw_grid_item(image, weapon_image, 'weapon', idx, grid_layout) if weapon_image
|
||||
end
|
||||
|
||||
image
|
||||
end
|
||||
|
||||
# Draws the mainhand weapon on the preview image
|
||||
#
|
||||
# @param image [MiniMagick::Image] The base image
|
||||
# @param weapon_image [MiniMagick::Image] The weapon image to add
|
||||
# @return [MiniMagick::Image] The updated image
|
||||
def draw_mainhand_weapon(image, weapon_image)
|
||||
target_size = Grid::GRID_CELL_SIZE * 1.5
|
||||
weapon_image.resize "#{target_size}x#{target_size}"
|
||||
|
||||
image.composite(weapon_image) do |c|
|
||||
c.compose "Over"
|
||||
c.gravity "northwest"
|
||||
c.geometry "+150+150"
|
||||
end
|
||||
end
|
||||
|
||||
# Storage Operations
|
||||
|
||||
# Saves the preview image to the appropriate storage system
|
||||
#
|
||||
# @param image [MiniMagick::Image] The image to save
|
||||
# @return [void]
|
||||
def save_preview(image)
|
||||
if Rails.env.production?
|
||||
upload_to_s3(image)
|
||||
else
|
||||
save_to_local_storage(image)
|
||||
end
|
||||
end
|
||||
|
||||
# Uploads the preview image to S3
|
||||
#
|
||||
# @param image [MiniMagick::Image] The image to upload
|
||||
# @return [void]
|
||||
def upload_to_s3(image)
|
||||
temp_file = Tempfile.new(%w[preview .png])
|
||||
begin
|
||||
image.write(temp_file.path)
|
||||
|
||||
# Use fixed key without timestamp
|
||||
key = "#{PREVIEW_FOLDER}/#{@party.shortcode}.png"
|
||||
|
||||
File.open(temp_file.path, 'rb') do |file|
|
||||
@aws_service.s3_client.put_object(
|
||||
bucket: @aws_service.bucket,
|
||||
key: key,
|
||||
body: file,
|
||||
content_type: 'image/png',
|
||||
acl: 'private'
|
||||
)
|
||||
end
|
||||
|
||||
@party.update!(preview_s3_key: key)
|
||||
ensure
|
||||
temp_file.close
|
||||
temp_file.unlink
|
||||
end
|
||||
end
|
||||
|
||||
# Saves the preview image to local storage
|
||||
#
|
||||
# @param image [MiniMagick::Image] The image to save
|
||||
# @return [void]
|
||||
def save_to_local_storage(image)
|
||||
image.write(local_preview_path)
|
||||
end
|
||||
|
||||
# Path & URL Generation
|
||||
|
||||
# Generates a filename for the preview image
|
||||
#
|
||||
# @return [String] Filename for the preview image
|
||||
def preview_filename
|
||||
"#{@party.shortcode}.png"
|
||||
end
|
||||
|
||||
# Returns the URL for accessing locally stored preview images
|
||||
#
|
||||
# @return [String] URL path to access the preview image in development
|
||||
def local_preview_url
|
||||
latest_preview = Dir.glob(LOCAL_STORAGE_PATH.join("#{@party.shortcode}_*.png").to_s)
|
||||
.max_by { |f| File.mtime(f) }
|
||||
return default_preview_url unless latest_preview
|
||||
|
||||
"/party-previews/#{File.basename(latest_preview)}"
|
||||
end
|
||||
|
||||
# Generates the S3 key for the party's preview image
|
||||
#
|
||||
# @return [String] The S3 object key for the preview image
|
||||
def preview_key
|
||||
"#{PREVIEW_FOLDER}/#{@party.shortcode}.png"
|
||||
end
|
||||
|
||||
# Preview State Management
|
||||
|
||||
# Checks if a preview image exists for the party
|
||||
#
|
||||
# @return [Boolean] True if a preview exists, false otherwise
|
||||
def preview_exists?
|
||||
return false unless @party.preview_state == 'generated'
|
||||
|
||||
if Rails.env.production?
|
||||
@aws_service.file_exists?(preview_key)
|
||||
else
|
||||
!Dir.glob(LOCAL_STORAGE_PATH.join("#{@party.shortcode}.png").to_s).empty?
|
||||
end
|
||||
rescue Aws::S3::Errors::NotFound
|
||||
false
|
||||
end
|
||||
|
||||
# Generates a pre-signed S3 URL for the preview image
|
||||
#
|
||||
# @return [String] A pre-signed URL to access the preview image
|
||||
def generate_s3_url
|
||||
signer = Aws::S3::Presigner.new(client: @aws_service.s3_client)
|
||||
signer.presigned_url(
|
||||
:get_object,
|
||||
bucket: @aws_service.bucket,
|
||||
key: preview_key,
|
||||
expires_in: 1.hour.to_i
|
||||
)
|
||||
end
|
||||
|
||||
# Marks the preview generation as in progress
|
||||
#
|
||||
# @return [void]
|
||||
def set_generation_in_progress
|
||||
Rails.cache.write(
|
||||
"party_preview_generating_#{@party.id}",
|
||||
true,
|
||||
expires_in: GENERATION_TIMEOUT
|
||||
)
|
||||
end
|
||||
|
||||
# Clears the in-progress flag for preview generation
|
||||
#
|
||||
# @return [void]
|
||||
def clear_generation_in_progress
|
||||
Rails.cache.delete("party_preview_generating_#{@party.id}")
|
||||
end
|
||||
|
||||
# Job Scheduling
|
||||
|
||||
# URL Generation
|
||||
|
||||
# Provides a default preview URL based on party attributes
|
||||
#
|
||||
# @return [String] A URL to a default preview image
|
||||
def default_preview_url
|
||||
if @party.element.present?
|
||||
"/default-previews/#{@party.element}.png"
|
||||
else
|
||||
"/default-previews/default.png"
|
||||
end
|
||||
end
|
||||
|
||||
# Cleanup Operations
|
||||
|
||||
# Deletes the preview from S3
|
||||
#
|
||||
# @return [void]
|
||||
def delete_s3_preview
|
||||
@aws_service.s3_client.delete_object(
|
||||
bucket: @aws_service.bucket,
|
||||
key: preview_key
|
||||
)
|
||||
end
|
||||
|
||||
def self.cleanup_stalled_jobs
|
||||
Party.where(preview_state: :in_progress)
|
||||
.where('updated_at < ?', 10.minutes.ago)
|
||||
.update_all(preview_state: :pending)
|
||||
end
|
||||
|
||||
# Deletes local preview files
|
||||
#
|
||||
# @return [void]
|
||||
def delete_local_previews
|
||||
Dir.glob(LOCAL_STORAGE_PATH.join("#{@party.shortcode}_*.png").to_s).each do |file|
|
||||
File.delete(file)
|
||||
end
|
||||
end
|
||||
|
||||
# Error Handling
|
||||
|
||||
# Handles errors during preview generation
|
||||
#
|
||||
# @param error [Exception] The error that occurred
|
||||
# @return [void]
|
||||
def handle_preview_generation_error(error)
|
||||
Rails.logger.error("Preview generation failed for party #{@party.id}")
|
||||
Rails.logger.error("Error: #{error.class} - #{error.message}")
|
||||
Rails.logger.error(error.backtrace.join("\n"))
|
||||
|
||||
@party.update_columns(
|
||||
preview_state: 'failed',
|
||||
preview_generated_at: Time.current
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
require 'sidekiq/api'
|
||||
|
||||
module PreviewService
|
||||
class GenerationMonitor
|
||||
class << self
|
||||
def check_stalled_jobs
|
||||
Party.where(preview_state: :queued)
|
||||
.where('updated_at < ?', 10.minutes.ago)
|
||||
.find_each do |party|
|
||||
Rails.logger.warn("Found stalled preview generation for party #{party.id}")
|
||||
|
||||
# If no job is actually queued, reset the state
|
||||
unless job_exists?(party)
|
||||
party.update!(preview_state: :pending)
|
||||
Rails.logger.info("Reset stalled party #{party.id} to pending state")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def retry_failed
|
||||
Party.where(preview_state: :failed)
|
||||
.where('updated_at < ?', 1.hour.ago)
|
||||
.find_each do |party|
|
||||
Rails.logger.info("Retrying failed preview generation for party #{party.id}")
|
||||
GeneratePartyPreviewJob.perform_later(party.id)
|
||||
end
|
||||
end
|
||||
|
||||
def cleanup_old_previews
|
||||
Party.where(preview_state: :generated)
|
||||
.where('preview_generated_at < ?', 30.days.ago)
|
||||
.find_each do |party|
|
||||
PreviewService::Coordinator.new(party).delete_preview
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def job_exists?(party)
|
||||
# Implementation depends on your job backend
|
||||
# For Sidekiq:
|
||||
queue = Sidekiq::Queue.new('previews')
|
||||
scheduled = Sidekiq::ScheduledSet.new
|
||||
retrying = Sidekiq::RetrySet.new
|
||||
|
||||
[queue, scheduled, retrying].any? do |set|
|
||||
set.any? do |job|
|
||||
job.args.first == party.id &&
|
||||
job.klass == 'GeneratePartyPreviewJob'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
# app/services/grid.rb
|
||||
module PreviewService
|
||||
class Grid
|
||||
GRID_GAP = 8
|
||||
GRID_COLUMNS = 4
|
||||
GRID_ROWS = 3
|
||||
GRID_SCALE = 0.75 # Scale for grid images
|
||||
|
||||
# Natural dimensions
|
||||
MAINHAND_NATURAL_WIDTH = 200
|
||||
MAINHAND_NATURAL_HEIGHT = 420
|
||||
|
||||
GRID_NATURAL_WIDTH = 280
|
||||
GRID_NATURAL_HEIGHT = 160
|
||||
|
||||
# Scaled grid dimensions
|
||||
CELL_WIDTH = (GRID_NATURAL_WIDTH * GRID_SCALE).floor
|
||||
CELL_HEIGHT = (GRID_NATURAL_HEIGHT * GRID_SCALE).floor
|
||||
|
||||
def calculate_layout(canvas_height:, title_bottom_y:, padding: 24)
|
||||
# Use scaled dimensions for grid images
|
||||
cell_width = CELL_WIDTH
|
||||
cell_height = CELL_HEIGHT
|
||||
|
||||
grid_columns = GRID_COLUMNS - 1 # 3 columns for grid items
|
||||
grid_total_width = cell_width * grid_columns + GRID_GAP * (grid_columns - 1)
|
||||
grid_total_height = cell_height * GRID_ROWS + GRID_GAP * (GRID_ROWS - 1)
|
||||
|
||||
# Determine the scale factor for the mainhand to match grid height
|
||||
mainhand_scale = grid_total_height.to_f / MAINHAND_NATURAL_HEIGHT
|
||||
scaled_mainhand_width = (MAINHAND_NATURAL_WIDTH * mainhand_scale).floor
|
||||
scaled_mainhand_height = (MAINHAND_NATURAL_HEIGHT * mainhand_scale).floor
|
||||
|
||||
total_width = scaled_mainhand_width + GRID_GAP + grid_total_width
|
||||
|
||||
# Center the grid absolutely in the canvas
|
||||
grid_start_y = (canvas_height - grid_total_height) / 2
|
||||
|
||||
{
|
||||
cell_width: cell_width,
|
||||
cell_height: cell_height,
|
||||
grid_total_width: grid_total_width,
|
||||
grid_total_height: grid_total_height,
|
||||
total_width: total_width,
|
||||
grid_columns: grid_columns,
|
||||
grid_start_y: grid_start_y,
|
||||
mainhand_width: scaled_mainhand_width,
|
||||
mainhand_height: scaled_mainhand_height
|
||||
}
|
||||
end
|
||||
|
||||
def grid_position(type, idx, layout)
|
||||
case type
|
||||
when 'mainhand'
|
||||
{
|
||||
x: (Canvas::PREVIEW_WIDTH - layout[:total_width]) / 2,
|
||||
y: layout[:grid_start_y]
|
||||
# No explicit width/height here since resizing is handled in draw_grid_item
|
||||
}
|
||||
when 'weapon'
|
||||
row = idx / layout[:grid_columns]
|
||||
col = idx % layout[:grid_columns]
|
||||
{
|
||||
x: (Canvas::PREVIEW_WIDTH - layout[:total_width]) / 2 + layout[:mainhand_width] + GRID_GAP + col * (layout[:cell_width] + GRID_GAP),
|
||||
y: layout[:grid_start_y] + row * (layout[:cell_height] + GRID_GAP),
|
||||
width: layout[:cell_width],
|
||||
height: layout[:cell_height]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def draw_grid_item(image, item_image, type, idx, layout)
|
||||
coords = grid_position(type, idx, layout)
|
||||
|
||||
if type == 'mainhand'
|
||||
# Resize mainhand using scaled dimensions from layout
|
||||
item_image.resize "#{layout[:mainhand_width]}x#{layout[:mainhand_height]}"
|
||||
item_image = round_corners(item_image, 4)
|
||||
else
|
||||
# Resize grid items to fixed, scaled dimensions and round corners
|
||||
item_image.resize "#{coords[:width]}x#{coords[:height]}^"
|
||||
item_image = round_corners(item_image, 4)
|
||||
end
|
||||
|
||||
image.composite(item_image) do |c|
|
||||
c.compose "Over"
|
||||
c.geometry "+#{coords[:x]}+#{coords[:y]}"
|
||||
end
|
||||
end
|
||||
|
||||
def round_corners(image, radius = 8)
|
||||
# Create a round-corner mask for the image
|
||||
mask = MiniMagick::Image.open(image.path)
|
||||
mask.format "png"
|
||||
mask.combine_options do |m|
|
||||
m.alpha "transparent"
|
||||
m.background "none"
|
||||
m.fill "white"
|
||||
m.draw "roundRectangle 0,0,#{mask.width},#{mask.height},#{radius},#{radius}"
|
||||
end
|
||||
|
||||
image.composite(mask) do |c|
|
||||
c.compose "DstIn"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
# app/services/image_fetcher_service.rb
|
||||
module PreviewService
|
||||
class ImageFetcherService
|
||||
def initialize(aws_service)
|
||||
@aws_service = aws_service
|
||||
@tempfiles = []
|
||||
end
|
||||
|
||||
def fetch_s3_image(key, folder = nil)
|
||||
full_key = folder ? "#{folder}/#{key}" : key
|
||||
temp_file = create_temp_file
|
||||
|
||||
download_from_s3(full_key, temp_file)
|
||||
create_mini_magick_image(temp_file)
|
||||
rescue => e
|
||||
handle_fetch_error(e, full_key)
|
||||
end
|
||||
|
||||
def fetch_job_icon(job_name)
|
||||
fetch_s3_image("#{job_name.downcase}.png", 'job-icons')
|
||||
end
|
||||
|
||||
def fetch_weapon_image(weapon, mainhand: false)
|
||||
folder = mainhand ? 'weapon-main' : 'weapon-grid'
|
||||
fetch_s3_image("#{weapon.granblue_id}.jpg", folder)
|
||||
end
|
||||
|
||||
def fetch_user_picture(picture_identifier)
|
||||
# Assuming user pictures are stored as PNG in a folder called 'user-pictures'
|
||||
fetch_s3_image("#{picture_identifier}.png", 'profile')
|
||||
end
|
||||
|
||||
def cleanup
|
||||
@tempfiles.each do |tempfile|
|
||||
tempfile.close
|
||||
tempfile.unlink
|
||||
end
|
||||
@tempfiles.clear
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_temp_file
|
||||
temp_file = Tempfile.new(['image', '.jpg'])
|
||||
temp_file.binmode
|
||||
@tempfiles << temp_file
|
||||
temp_file
|
||||
end
|
||||
|
||||
def download_from_s3(key, temp_file)
|
||||
response = @aws_service.s3_client.get_object(
|
||||
bucket: @aws_service.bucket,
|
||||
key: key
|
||||
)
|
||||
temp_file.write(response.body.read)
|
||||
temp_file.rewind
|
||||
end
|
||||
|
||||
def create_mini_magick_image(temp_file)
|
||||
MiniMagick::Image.new(temp_file.path)
|
||||
end
|
||||
|
||||
def handle_fetch_error(error, key)
|
||||
Rails.logger.error "Error fetching image #{key}: #{error.message}"
|
||||
Rails.logger.error error.backtrace.join("\n")
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Processors
|
||||
##
|
||||
# BaseProcessor provides shared functionality for processing transformed deck data
|
||||
# into new party records. Subclasses must implement the +process+ method.
|
||||
#
|
||||
# @abstract
|
||||
class BaseProcessor
|
||||
##
|
||||
# Initializes the processor.
|
||||
#
|
||||
# @param party [Party] the Party record to which the component will be added.
|
||||
# @param data [Object] the transformed data for this component.
|
||||
# @param options [Hash] optional additional options.
|
||||
def initialize(party, data, options = {})
|
||||
@party = party
|
||||
@data = data
|
||||
@options = options
|
||||
end
|
||||
|
||||
##
|
||||
# Process the given data and create associated records.
|
||||
#
|
||||
# @abstract Subclasses must implement this method.
|
||||
# @return [void]
|
||||
def process
|
||||
raise NotImplementedError, "#{self.class} must implement the process method"
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
attr_reader :party, :data, :options
|
||||
|
||||
##
|
||||
# Logs a message to Rails.logger.
|
||||
#
|
||||
# @param message [String] the message to log.
|
||||
# @return [void]
|
||||
def log(message)
|
||||
Rails.logger.info "[PROCESSOR][#{self.class.name}] #{message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Processors
|
||||
##
|
||||
# CharacterProcessor processes an array of character data and creates GridCharacter records.
|
||||
#
|
||||
# @example
|
||||
# processor = Processors::CharacterProcessor.new(party, transformed_characters_array)
|
||||
# processor.process
|
||||
class CharacterProcessor < BaseProcessor
|
||||
def initialize(party, data, type = :normal, options = {})
|
||||
super(party, data, options)
|
||||
@party = party
|
||||
@data = data
|
||||
end
|
||||
|
||||
##
|
||||
# Processes character data.
|
||||
#
|
||||
# Iterates over each character hash in +data+ and creates a new GridCharacter record.
|
||||
# Expects each character hash to include keys such as :id, :position, :uncap, etc.
|
||||
#
|
||||
# @return [void]
|
||||
def process
|
||||
unless @data.is_a?(Hash)
|
||||
Rails.logger.error "[CHARACTER] Invalid data format: expected a Hash, got #{@data.class}"
|
||||
return
|
||||
end
|
||||
|
||||
unless @data.key?('deck') && @data['deck'].key?('npc')
|
||||
Rails.logger.error '[CHARACTER] Missing npc data in deck JSON'
|
||||
return
|
||||
end
|
||||
|
||||
@data = @data.with_indifferent_access
|
||||
characters_data = @data.dig('deck', 'npc')
|
||||
|
||||
grid_characters = process_characters(characters_data)
|
||||
grid_characters.each do |grid_character|
|
||||
begin
|
||||
grid_character.save!
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error "[CHARACTER] Failed to create GridCharacter: #{e.record.errors.full_messages.join(', ')}"
|
||||
end
|
||||
end
|
||||
|
||||
rescue StandardError => e
|
||||
raise e
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_characters(characters_data)
|
||||
characters_data.map do |key, raw_character|
|
||||
next if raw_character.nil? || raw_character['param'].nil? || raw_character['master'].nil?
|
||||
|
||||
position = key.to_i - 1
|
||||
|
||||
# Find the Character record by its granblue_id.
|
||||
character_id = raw_character.dig('master', 'id')
|
||||
character = Character.find_by(granblue_id: character_id)
|
||||
|
||||
unless character
|
||||
Rails.logger.error "[CHARACTER] Character not found with id #{character_id}"
|
||||
next
|
||||
end
|
||||
|
||||
# The deck doesn't have Awakening data, so use the default
|
||||
awakening = Awakening.where(slug: 'character-balanced').first
|
||||
grid_character = GridCharacter.create(
|
||||
party_id: @party.id,
|
||||
character_id: character.id,
|
||||
uncap_level: raw_character.dig('param', 'evolution').to_i,
|
||||
transcendence_step: raw_character.dig('param', 'phase').to_i,
|
||||
position: position,
|
||||
perpetuity: raw_character.dig('param', 'has_npcaugment_constant'),
|
||||
awakening: awakening
|
||||
)
|
||||
|
||||
grid_character
|
||||
end.compact
|
||||
end
|
||||
|
||||
# Converts a value to a boolean.
|
||||
def parse_boolean(val)
|
||||
val.to_s.downcase == 'true'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Processors
|
||||
##
|
||||
# JobProcessor is responsible for processing job data from the transformed deck data.
|
||||
# It finds a Job record by the master’s id and assigns it (and its job skills) to the Party.
|
||||
#
|
||||
# @example
|
||||
# raw_data = { 'job' => { "master": { "id": '130401', ... }, ... }, 'set_action': [ ... ] }
|
||||
# processor = Processors::JobProcessor.new(party, raw_data, language: 'en')
|
||||
# processor.process
|
||||
class JobProcessor < BaseProcessor
|
||||
##
|
||||
# Initializes a new JobProcessor.
|
||||
#
|
||||
# @param party [Party] the Party record.
|
||||
# @param data [Hash] the raw JSON data.
|
||||
# @param options [Hash] options hash; e.g. expects :language.
|
||||
def initialize(party, data, options = {})
|
||||
super(party, options)
|
||||
@party = party
|
||||
@data = data
|
||||
@language = options[:language] || 'en'
|
||||
end
|
||||
|
||||
##
|
||||
# Processes job data.
|
||||
#
|
||||
# Finds a Job record using a case‐insensitive search on +name_en+ or +name_jp+.
|
||||
# If found, it assigns the job to the party and (if provided) assigns subskills.
|
||||
#
|
||||
# @return [void]
|
||||
def process
|
||||
if @data.is_a?(Hash)
|
||||
@data = @data.with_indifferent_access
|
||||
else
|
||||
Rails.logger.error "[JOB] Invalid data format: expected a Hash, got #{@data.class}"
|
||||
return
|
||||
end
|
||||
|
||||
unless @data.key?('deck') && @data['deck'].key?('pc') && @data['deck']['pc'].key?('job')
|
||||
Rails.logger.error '[JOB] Missing job data in deck JSON'
|
||||
return
|
||||
end
|
||||
|
||||
# Extract job data
|
||||
job_data = @data.dig('deck', 'pc', 'job', 'master')
|
||||
job_skills = @data.dig('deck', 'pc', 'set_action')
|
||||
job_accessory_id = @data.dig('deck', 'pc', 'familiar_id') || @data.dig('deck', 'pc', 'shield_id')
|
||||
|
||||
# Look up and set the Job and its main skill
|
||||
process_core_job(job_data)
|
||||
|
||||
# Look up and set the job skills.
|
||||
if job_skills.present?
|
||||
skills = process_job_skills(job_skills)
|
||||
party.update(skill1: skills[0], skill2: skills[1], skill3: skills[2])
|
||||
end
|
||||
|
||||
# Look up and set the job accessory.
|
||||
accessory = process_job_accessory(job_accessory_id)
|
||||
party.update(accessory: accessory)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[JOB] Exception during job processing: #{e.message}"
|
||||
raise e
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
##
|
||||
# Updates the party with the corresponding job and its main skill.
|
||||
#
|
||||
# This method attempts to locate a Job using the provided job_data's 'id' (which represents
|
||||
# the granblue_id). If the job is found, it retrieves the job's main
|
||||
# skill (i.e. the JobSkill record where `main` is true) and updates the party with the job
|
||||
# and its main skill. If no job is found, the method returns without updating.
|
||||
#
|
||||
# @param [Hash] job_data A hash containing job information.
|
||||
# It must include the key 'id', which holds the granblue_id for the job.
|
||||
# @return [void]
|
||||
#
|
||||
# @example
|
||||
# job_data = { 'id' => 42 }
|
||||
# process_core_job(job_data)
|
||||
def process_core_job(job_data)
|
||||
# Look up the Job by granblue_id (the job master id).
|
||||
job = Job.find_by(granblue_id: job_data['id'])
|
||||
return unless job
|
||||
|
||||
main_skill = JobSkill.find_by(job_id: job.id, main: true)
|
||||
|
||||
party.update(job: job, skill0: main_skill)
|
||||
end
|
||||
|
||||
##
|
||||
# Processes and associates job skills with a given job.
|
||||
#
|
||||
# This method first removes any existing skills from the job. It then iterates over the provided
|
||||
# array of skill names, attempting to find a matching JobSkill record by comparing the provided
|
||||
# name against both the English and Japanese name fields. Any found JobSkill records are then
|
||||
# associated with the job. Finally, the method logs the processed job skill names.
|
||||
#
|
||||
# @param job_skills [Array<String>] an array of job skill names.
|
||||
# @return [Array<JobSkill>] an array of JobSkill records that were associated with the job.
|
||||
def process_job_skills(job_skills)
|
||||
job_skills.map do |skill|
|
||||
name = skill['name']
|
||||
JobSkill.find_by(name_en: name)
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Processes raw data to find the currently set job accessory
|
||||
#
|
||||
# Searches JobAccessories for the given `granblue_id`
|
||||
#
|
||||
# @param accessory_id [String] the granblue_id of the accessory
|
||||
def process_job_accessory(accessory_id)
|
||||
JobAccessory.find_by(granblue_id: accessory_id)
|
||||
end
|
||||
|
||||
# Converts a value (string or boolean) to a boolean.
|
||||
def to_boolean(val)
|
||||
val.to_s.downcase == 'true'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,201 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Processors
|
||||
##
|
||||
# SummonProcessor processes an array of summon data and creates GridSummon records.
|
||||
# It handles different summon types based on the +type+ parameter:
|
||||
# - :normal => standard summons
|
||||
# - :friend => friend summon (fixed position and uncap logic)
|
||||
# - :sub => sub summons (position based on order)
|
||||
#
|
||||
# @example
|
||||
# normal_processor = SummonProcessor.new(party, summons_array, :normal, quick_summon_id)
|
||||
# normal_processor.process
|
||||
#
|
||||
# friend_processor = SummonProcessor.new(party, [friend_summon_name], :friend)
|
||||
# friend_processor.process
|
||||
class SummonProcessor < BaseProcessor
|
||||
TRANSCENDENCE_LEVELS = [200, 210, 220, 230, 240, 250].freeze
|
||||
|
||||
##
|
||||
# Initializes a new SummonProcessor.
|
||||
#
|
||||
# @param party [Party] the Party record.
|
||||
# @param data [Hash] the deck hash.
|
||||
# @param type [Symbol] the type of summon (:normal, :friend, or :sub).
|
||||
# @param quick_summon_id [String, nil] (optional) the quick summon identifier.
|
||||
# @param options [Hash] additional options.
|
||||
def initialize(party, data, type = :normal, options = {})
|
||||
super(party, data, options)
|
||||
@party = party
|
||||
@data = data
|
||||
@type = type
|
||||
end
|
||||
|
||||
##
|
||||
# Processes summon data and creates GridSummon records.
|
||||
#
|
||||
# @return [void]
|
||||
def process
|
||||
unless @data.is_a?(Hash)
|
||||
Rails.logger.error "[SUMMON] Invalid data format: expected a Hash, got #{@data.class}"
|
||||
return
|
||||
end
|
||||
|
||||
unless @data.key?('deck') && @data['deck'].key?('pc')
|
||||
Rails.logger.error '[SUMMON] Missing npc data in deck JSON'
|
||||
return
|
||||
end
|
||||
|
||||
@data = @data.with_indifferent_access
|
||||
summons_data = @data.dig('deck', 'pc', 'summons')
|
||||
sub_summons_data = @data.dig('deck', 'pc', 'sub_summons')
|
||||
|
||||
grid_summons = process_summons(summons_data, sub: false)
|
||||
friend_summon = process_friend_summon
|
||||
sub_summons = process_summons(sub_summons_data, sub: true)
|
||||
|
||||
summons = [*grid_summons, friend_summon, *sub_summons]
|
||||
|
||||
summons.each do |summon|
|
||||
summon.save!
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error "[SUMMON] Failed to create GridSummon: #{e.record.errors.full_messages.join(', ')}"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :type
|
||||
|
||||
##
|
||||
# Processes a set of summon data and creates GridSummon records.
|
||||
#
|
||||
# @param summons [Hash] the summon data
|
||||
# @param sub [Boolean] true if we are polling sub summons
|
||||
# @return [Array<GridSummon>]
|
||||
def process_summons(summons, sub: false)
|
||||
internal_quick_summon_id = @data['quick_user_summon_id'].to_i if sub
|
||||
|
||||
summons.map do |key, raw_summon|
|
||||
summon_params = raw_summon['param']
|
||||
summon_id = raw_summon['master']['id']
|
||||
summon = Summon.find_by(granblue_id: transform_id(summon_id))
|
||||
|
||||
position = if sub
|
||||
key.to_i + 4
|
||||
else
|
||||
key.to_i == 1 ? -1 : key.to_i - 2
|
||||
end
|
||||
|
||||
GridSummon.new({
|
||||
party: @party,
|
||||
summon: summon,
|
||||
position: position,
|
||||
main: key.to_i == 1,
|
||||
friend: false,
|
||||
quick_summon: summon_params['id'].to_i == internal_quick_summon_id,
|
||||
uncap_level: summon_params['evolution'].to_i,
|
||||
transcendence_step: level_to_transcendence(summon_params['level'].to_i),
|
||||
created_at: Time.now,
|
||||
updated_at: Time.now
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Processes friend summon data and creates a GridSummon record.
|
||||
#
|
||||
# @return [GridSummon]
|
||||
def process_friend_summon
|
||||
summon_name = @data.dig('deck', 'pc', 'damage_info', 'summon_name')
|
||||
summon = Summon.find_by('name_en = ? OR name_jp = ?', summon_name, summon_name)
|
||||
|
||||
GridSummon.new({
|
||||
party: @party,
|
||||
summon: summon,
|
||||
position: 4,
|
||||
main: false,
|
||||
friend: true,
|
||||
quick_summon: false,
|
||||
uncap_level: determine_uncap_level(summon),
|
||||
transcendence_step: summon.transcendence ? 5 : 0,
|
||||
created_at: Time.now,
|
||||
updated_at: Time.now
|
||||
})
|
||||
end
|
||||
|
||||
##
|
||||
# Determines the numeric uncap level of a given Summon
|
||||
#
|
||||
# @param summon [Summon] the canonical summon
|
||||
# @return [Integer]
|
||||
def determine_uncap_level(summon)
|
||||
if summon.transcendence
|
||||
6
|
||||
elsif summon.ulb
|
||||
5
|
||||
elsif summon.flb
|
||||
4
|
||||
else
|
||||
3
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Determines the uncap level for a friend summon based on its ULb and FLb flags.
|
||||
#
|
||||
# @param summon_data [Hash] the summon data.
|
||||
# @return [Integer] the computed uncap level.
|
||||
def determine_friend_uncap(summon_data)
|
||||
if summon_data[:ulb]
|
||||
5
|
||||
elsif summon_data[:flb]
|
||||
4
|
||||
else
|
||||
3
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Converts a given level, rounded down to the nearest 10,
|
||||
# to its corresponding transcendence step.
|
||||
#
|
||||
# If level is 200, returns 0; if level is 250, returns 5.
|
||||
#
|
||||
# @param level [Integer] the summon's level
|
||||
# @return [Integer] the transcendence step
|
||||
def level_to_transcendence(level)
|
||||
return 0 if level < 200
|
||||
|
||||
floored_level = (level / 10).floor * 10
|
||||
TRANSCENDENCE_LEVELS.index(floored_level)
|
||||
end
|
||||
|
||||
##
|
||||
# Transforms 5★ Arcarum-series summon IDs into their 4★ variants,
|
||||
# as that's what is stored in the database.
|
||||
#
|
||||
# If an unrelated ID, or the 4★ ID is passed, then returns the input.
|
||||
#
|
||||
# @param id [String] the ID to match
|
||||
# @return [String] the resulting ID
|
||||
def transform_id(id)
|
||||
mapping = {
|
||||
'2040315000' => '2040238000',
|
||||
'2040316000' => '2040239000',
|
||||
'2040314000' => '2040237000',
|
||||
'2040313000' => '2040236000',
|
||||
'2040321000' => '2040244000',
|
||||
'2040319000' => '2040242000',
|
||||
'2040317000' => '2040240000',
|
||||
'2040322000' => '2040245000',
|
||||
'2040318000' => '2040241000',
|
||||
'2040320000' => '2040243000'
|
||||
}
|
||||
|
||||
# If the id is a key, return the mapped value; otherwise, return the id.
|
||||
mapping[id] || id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,395 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Processors
|
||||
##
|
||||
# WeaponProcessor processes weapon data from a deck JSON and creates GridWeapon records.
|
||||
# It follows a similar error‐handling and implementation strategy as SummonProcessor.
|
||||
#
|
||||
# Expected data format (excerpt):
|
||||
# {
|
||||
# "deck": {
|
||||
# "pc": {
|
||||
# "weapons": {
|
||||
# "1": {
|
||||
# "param": {
|
||||
# "uncap": 3,
|
||||
# "level": "150",
|
||||
# "augment_skill_info": [ [ { "skill_id": 1588, "effect_value": "3", "show_value": "3%" }, ... ] ],
|
||||
# "arousal": {
|
||||
# "is_arousal_weapon": true,
|
||||
# "level": 4,
|
||||
# "skill": [ { "skill_id": 1896, ... }, ... ]
|
||||
# },
|
||||
# ...
|
||||
# },
|
||||
# "master": {
|
||||
# "id": "1040215100",
|
||||
# "name": "Wamdus's Cnidocyte",
|
||||
# "attribute": "2",
|
||||
# ...
|
||||
# },
|
||||
# "keys": [ "..." ] // optional
|
||||
# },
|
||||
# "2": { ... },
|
||||
# ...
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# The processor also uses an AX_MAPPING to convert in‐game AX skill IDs to our stored values.
|
||||
class WeaponProcessor < BaseProcessor
|
||||
TRANSCENDENCE_LEVELS = [200, 210, 220, 230, 240, 250].freeze
|
||||
|
||||
# Mapping from in‑game AX skill IDs (as strings) to our internal modifier values.
|
||||
AX_MAPPING = {
|
||||
'1588' => 2,
|
||||
'1589' => 0,
|
||||
'1590' => 1,
|
||||
'1591' => 3,
|
||||
'1592' => 4,
|
||||
'1593' => 9,
|
||||
'1594' => 13,
|
||||
'1595' => 10,
|
||||
'1596' => 5,
|
||||
'1597' => 6,
|
||||
'1599' => 8,
|
||||
'1600' => 12,
|
||||
'1601' => 11,
|
||||
'1719' => 15,
|
||||
'1720' => 16,
|
||||
'1721' => 17,
|
||||
'1722' => 14
|
||||
}.freeze
|
||||
|
||||
# KEY_MAPPING maps the raw key value (as a string) to a canonical range or value.
|
||||
# For example, in our test we want a raw key "10001" to be interpreted as any key whose
|
||||
# canonical granblue_id is between 697 and 706.
|
||||
KEY_MAPPING = {
|
||||
'10001' => %w[697 698 699 700 701 702 703 704 705 706],
|
||||
'10002' => %w[707 708 709 710 711 712 713 714 715 716],
|
||||
'10003' => %w[717 718 719 720 721 722 723 724 725 726],
|
||||
'10004' => %w[727 728 729 730 731 732 733 734 735 736],
|
||||
'10005' => %w[737 738 739 740 741 742 743 744 745 746],
|
||||
'10006' => %w[747 748 749 750 751 752 753 754 755 756],
|
||||
'11001' => '758',
|
||||
'11002' => '759',
|
||||
'11003' => '760',
|
||||
'11004' => '760',
|
||||
'13001' => %w[1240 2204 2208], # α Pendulum
|
||||
'13002' => %w[1241 2205 2209], # β Pendulum
|
||||
'13003' => %w[1242 2206 2210], # γ Pendulum
|
||||
'13004' => %w[1243 2207 2211], # Δ Pendulum
|
||||
'14001' => %w[502 503 504 505 506 507 1213 1214 1215 1216 1217 1218], # Pendulum of Strength
|
||||
'14002' => %w[130 131 132 133 134 135 71 72 73 74 75 76], # Pendulum of Zeal
|
||||
'14003' => %w[1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271], # Pendulum of Strife
|
||||
'14004' => %w[1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210], # Pendulum of Prosperity
|
||||
'14005' => %w[2212 2213 2214 2215 2216 2217 2218 2219 2220 2221 2222 2223], # Pendulum of Extremity
|
||||
'14006' => %w[2224 2225 2226 2227 2228 2229 2230 2231 2232 2233 2234 2235], # Pendulum of Sagacity
|
||||
'14007' => %w[2236 2237 2238 2239 2240 2241 2242 2243 2244 2245 2246 2247], # Pendulum of Supremacy
|
||||
'14011' => %w[322 323 324 325 326 327 1310 1311 1312 1313 1314 1315], # Chain of Temperament
|
||||
'14012' => %w[764 765 766 767 768 769 1731 1732 1733 1734 1735 948], # Chain of Restoration
|
||||
'14013' => %w[1171 1172 1173 1174 1175 1176 1736 1737 1738 1739 1740 1741], # Chain of Glorification
|
||||
'14014' => '1723', # Chain of Temptation
|
||||
'14015' => '1724', # Chain of Forbiddance
|
||||
'14016' => '1725', # Chain of Depravity
|
||||
'14017' => '1726', # Chain of Falsehood
|
||||
'15001' => '1446',
|
||||
'15002' => '1447',
|
||||
'15003' => '1448', # Abyss Teluma
|
||||
'15004' => '1449', # Crag Teluma
|
||||
'15005' => '1450', # Tempest Teluma
|
||||
'15006' => '1451',
|
||||
'15007' => '1452', # Malice Teluma
|
||||
'15008' => %w[2043 2044 2045 2046 2047 2048],
|
||||
'15009' => %w[2049 2050 2051 2052 2053 2054], # Oblivion Teluma
|
||||
'16001' => %w[1228 1229 1230 1231 1232 1233], # Optimus Teluma
|
||||
'16002' => %w[1234 1235 1236 1237 1238 1239], # Omega Teluma
|
||||
'17001' => '1807',
|
||||
'17002' => '1808',
|
||||
'17003' => '1809',
|
||||
'17004' => '1810',
|
||||
# Emblems (series {24})
|
||||
'3' => '3',
|
||||
'2' => '2',
|
||||
'1' => '1'
|
||||
}.freeze
|
||||
|
||||
AWAKENING_MAPPING = {
|
||||
'1' => 'weapon-atk',
|
||||
'2' => 'weapon-def',
|
||||
'3' => 'weapon-special',
|
||||
'4' => 'weapon-ca',
|
||||
'5' => 'weapon-skill',
|
||||
'6' => 'weapon-heal',
|
||||
'7' => 'weapon-multi'
|
||||
}.freeze
|
||||
|
||||
ELEMENTAL_WEAPON_MAPPING = %w[1040914600 1040810100 1040506800 1040312000 1040513800 1040810900 1040910300
|
||||
1040114200 1040027000 1040807600 1040120300 1040318500 1040710000 1040608100
|
||||
1040812100 1040307200 1040410200 1040510600 1040018100 1040113400 1040017300
|
||||
1040011900 1040412200 1040508000 1040512600 1040609100 1040411600 1040208800
|
||||
1040906900 1040909300 1040509700 1040014400 1040308400 1040613100 1040013200
|
||||
1040011300 1040413400 1040607500 1040504400 1040703600 1040406000 1040601700
|
||||
1040904300 1040109700 1040900300 1040002000 1040807200 1040102900 1040203000
|
||||
1040402800 1040507400 1040200900 1040307800 1040501600 1040706900 1040604200
|
||||
1040103000 1040003500 1040300100 1040907500 1040105500 1040106600 1040503500
|
||||
1040801300 1040410800 1040702700 1040006200 1040302300 1040803700 1040900400
|
||||
1040406900 1040109100 1040111600 1040706300 1040806400 1040209700 1040707500
|
||||
1040208200 1040214000 1040021100 1040417200 1040012600 1040317500 1040402900].freeze
|
||||
ELEMENTAL_WEAPON_MAPPING_INT = ELEMENTAL_WEAPON_MAPPING.map(&:to_i).sort.freeze
|
||||
|
||||
ELEMENT_MAPPING = {
|
||||
0 => nil,
|
||||
1 => 4, # Wind -> Earth
|
||||
2 => 2, # Fire -> Fire
|
||||
3 => 3, # Water -> Water
|
||||
4 => 1, # Earth -> Wind
|
||||
5 => 6, # Dark -> Light
|
||||
6 => 5 # Light -> Dark
|
||||
}.freeze
|
||||
##
|
||||
# Initializes a new WeaponProcessor.
|
||||
#
|
||||
# @param party [Party] the Party record.
|
||||
# @param data [Hash] the full deck JSON.
|
||||
# @param type [Symbol] (optional) processing type.
|
||||
# @param options [Hash] additional options.
|
||||
def initialize(party, data, type = :normal, options = {})
|
||||
super(party, data, options)
|
||||
@party = party
|
||||
@data = data
|
||||
end
|
||||
|
||||
##
|
||||
# Processes the deck’s weapon data and creates GridWeapon records.
|
||||
#
|
||||
# It expects the incoming data to be a Hash that contains:
|
||||
# "deck" → "pc" → "weapons"
|
||||
#
|
||||
# @return [void]
|
||||
def process
|
||||
unless @data.is_a?(Hash)
|
||||
Rails.logger.error "[WEAPON] Invalid data format: expected a Hash, got #{@data.class}"
|
||||
return
|
||||
end
|
||||
|
||||
unless @data.key?('deck') && @data['deck'].key?('pc') && @data['deck']['pc'].key?('weapons')
|
||||
Rails.logger.error '[WEAPON] Missing weapons data in deck JSON'
|
||||
return
|
||||
end
|
||||
|
||||
@data = @data.with_indifferent_access
|
||||
weapons_data = @data['deck']['pc']['weapons']
|
||||
|
||||
grid_weapons = process_weapons(weapons_data)
|
||||
|
||||
grid_weapons.each do |grid_weapon|
|
||||
begin
|
||||
grid_weapon.save!
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error "[WEAPON] Failed to create GridWeapon: #{e.record.errors.full_messages.join(', ')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
##
|
||||
# Processes a hash of raw weapon data and returns an array of GridWeapon records.
|
||||
#
|
||||
# @param weapons_data [Hash] the raw weapons data (keyed by slot number).
|
||||
# @return [Array<GridWeapon>]
|
||||
def process_weapons(weapons_data)
|
||||
weapons_data.map do |key, raw_weapon|
|
||||
next if raw_weapon.nil? || raw_weapon['param'].nil? || raw_weapon['master'].nil?
|
||||
|
||||
position = key.to_i == 1 ? -1 : key.to_i - 2
|
||||
mainhand = (position == -1)
|
||||
|
||||
uncap_level = raw_weapon.dig('param', 'uncap').to_i
|
||||
level = raw_weapon.dig('param', 'level').to_i
|
||||
transcendence_step = level_to_transcendence(level)
|
||||
series = raw_weapon.dig('master', 'series_id')
|
||||
weapon_id = raw_weapon.dig('master', 'id')
|
||||
|
||||
processed_weapon_id = if Weapon.element_changeable?(series)
|
||||
process_elemental_weapon(weapon_id)
|
||||
else
|
||||
weapon_id
|
||||
end
|
||||
|
||||
processed_element = if Weapon.element_changeable?(series)
|
||||
ELEMENT_MAPPING[raw_weapon.dig('master', 'attribute')]
|
||||
end
|
||||
|
||||
weapon = Weapon.find_by(granblue_id: processed_weapon_id)
|
||||
|
||||
unless weapon
|
||||
Rails.logger.error "[WEAPON] Weapon not found with id #{processed_weapon_id}"
|
||||
next
|
||||
end
|
||||
|
||||
grid_weapon = GridWeapon.new(
|
||||
party: @party,
|
||||
weapon: weapon,
|
||||
position: position,
|
||||
mainhand: mainhand,
|
||||
uncap_level: uncap_level,
|
||||
transcendence_step: transcendence_step,
|
||||
element: processed_element
|
||||
)
|
||||
|
||||
arousal_data = raw_weapon.dig('param', 'arousal')
|
||||
if arousal_data && arousal_data['is_arousal_weapon']
|
||||
grid_weapon.awakening_id = map_arousal_to_awakening(arousal_data)
|
||||
grid_weapon.awakening_level = arousal_data['level'].to_i.positive? ? arousal_data['level'].to_i : 1
|
||||
end
|
||||
|
||||
# Extract skill IDs and convert into weapon keys
|
||||
skill_ids = [raw_weapon['skill1'], raw_weapon['skill2'], raw_weapon['skill3']].compact.map { |s| s['id'] }
|
||||
process_weapon_keys(grid_weapon, skill_ids) if skill_ids.length.positive?
|
||||
|
||||
if raw_weapon.dig('param', 'augment_skill_info').present?
|
||||
process_weapon_ax(grid_weapon, raw_weapon.dig('param', 'augment_skill_info'))
|
||||
end
|
||||
|
||||
grid_weapon
|
||||
end.compact
|
||||
end
|
||||
|
||||
##
|
||||
# Converts a given weapon level to a transcendence step.
|
||||
#
|
||||
# If the level is less than 200, returns 0; otherwise, floors the level
|
||||
# to the nearest 10 and returns its index in TRANSCENDENCE_LEVELS.
|
||||
#
|
||||
# @param level [Integer] the weapon’s level.
|
||||
# @return [Integer] the transcendence step.
|
||||
def level_to_transcendence(level)
|
||||
return 0 if level < 200
|
||||
|
||||
floored_level = (level / 10).floor * 10
|
||||
TRANSCENDENCE_LEVELS.index(floored_level) || 0
|
||||
end
|
||||
|
||||
##
|
||||
# Processes weapon key data and assigns them to the grid_weapon.
|
||||
#
|
||||
# @param grid_weapon [GridWeapon] the grid weapon record being built.
|
||||
# @param skill_ids [Array<String>] an array of key identifiers.
|
||||
# @return [void]
|
||||
def process_weapon_keys(grid_weapon, skill_ids)
|
||||
series = grid_weapon.weapon.series.to_i
|
||||
|
||||
skill_ids.each_with_index do |skill_id, idx|
|
||||
# Go to the next iteration unless the key under which `skill_id` exists
|
||||
mapping_pair = KEY_MAPPING.find { |key, value| Array(value).include?(skill_id) }
|
||||
next unless mapping_pair
|
||||
|
||||
# Fetch the key from the mapping_pair and find the weapon key based on the weapon series
|
||||
mapping_value = mapping_pair.first
|
||||
candidate = WeaponKey.where('granblue_id = ? AND ? = ANY(series)', mapping_value, series).first
|
||||
|
||||
if candidate
|
||||
grid_weapon["weapon_key#{idx + 1}_id"] = candidate.id
|
||||
else
|
||||
Rails.logger.warn "[WEAPON] No matching WeaponKey found for raw key #{skill_id} using mapping #{mapping_value}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Returns true if the candidate key (a string) matches the mapping entry.
|
||||
#
|
||||
# If mapping_entry includes a dash, it is interpreted as a range (e.g. "697-706").
|
||||
# Otherwise, it must match exactly.
|
||||
#
|
||||
# @param candidate_key [String] the candidate WeaponKey.granblue_id.
|
||||
# @param mapping_entry [String] the mapping entry.
|
||||
# @return [Boolean]
|
||||
def matches_key?(candidate_key, mapping_entry)
|
||||
if mapping_entry.include?('-')
|
||||
left, right = mapping_entry.split('-').map(&:to_i)
|
||||
candidate_key.to_i >= left && candidate_key.to_i <= right
|
||||
else
|
||||
candidate_key == mapping_entry
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Processes AX (augment) skill data.
|
||||
#
|
||||
# The deck stores AX skills in an array of arrays under "augment_skill_info".
|
||||
# This method flattens the data and assigns each skill’s modifier and strength.
|
||||
#
|
||||
# @param grid_weapon [GridWeapon] the grid weapon record being built.
|
||||
# @param ax_skill_info [Array] the raw AX skill info.
|
||||
# @return [void]
|
||||
def process_weapon_ax(grid_weapon, ax_skill_info)
|
||||
# Flatten the nested array structure.
|
||||
ax_skills = ax_skill_info.flatten
|
||||
ax_skills.each_with_index do |ax, idx|
|
||||
ax_id = ax['skill_id'].to_s
|
||||
ax_mod = AX_MAPPING[ax_id] || ax_id.to_i
|
||||
strength = ax['effect_value'].to_s.gsub(/[+%]/, '').to_i
|
||||
grid_weapon["ax_modifier#{idx + 1}"] = ax_mod
|
||||
grid_weapon["ax_strength#{idx + 1}"] = strength
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Maps the in‑game awakening data (stored under "arousal") to our Awakening record.
|
||||
#
|
||||
# This method looks at the "skill" array inside the arousal data and uses the first
|
||||
# awakening’s skill_id to find the corresponding Awakening record.
|
||||
#
|
||||
# @param arousal_data [Hash] the raw arousal (awakening) data.
|
||||
# @return [String, nil] the database awakening id or nil if not found.
|
||||
def map_arousal_to_awakening(arousal_data)
|
||||
raw_data = arousal_data.with_indifferent_access
|
||||
|
||||
return nil if raw_data.nil?
|
||||
return nil unless raw_data.is_a?(Hash)
|
||||
return nil unless raw_data.has_key?('form')
|
||||
|
||||
id = (raw_data['form']).to_s
|
||||
return unless AWAKENING_MAPPING.key?(id)
|
||||
|
||||
slug = AWAKENING_MAPPING[id]
|
||||
awakening = Awakening.find_by(slug: slug)
|
||||
|
||||
awakening&.id
|
||||
end
|
||||
|
||||
def process_elemental_weapon(granblue_id)
|
||||
granblue_int = granblue_id.to_i
|
||||
|
||||
# Find the index of the first element that is >= granblue_int.
|
||||
idx = ELEMENTAL_WEAPON_MAPPING_INT.bsearch_index { |x| x >= granblue_int }
|
||||
|
||||
# We'll check the candidate at idx and the one immediately before it.
|
||||
candidates = []
|
||||
if idx
|
||||
candidate = ELEMENTAL_WEAPON_MAPPING_INT[idx]
|
||||
candidates << candidate if (granblue_int - candidate).abs <= 500
|
||||
# Check the candidate just before, if it exists.
|
||||
if idx > 0
|
||||
candidate_prev = ELEMENTAL_WEAPON_MAPPING_INT[idx - 1]
|
||||
candidates << candidate_prev if (granblue_int - candidate_prev).abs <= 500
|
||||
end
|
||||
else
|
||||
# If idx is nil, then granblue_int is greater than all mapped values.
|
||||
candidate = ELEMENTAL_WEAPON_MAPPING_INT.last
|
||||
candidates << candidate if (granblue_int - candidate).abs <= 500
|
||||
end
|
||||
|
||||
# If no candidate is close enough, return the original input.
|
||||
return granblue_id if candidates.empty?
|
||||
|
||||
# Choose the candidate with the smallest difference.
|
||||
best_match = candidates.min_by { |x| (granblue_int - x).abs }
|
||||
best_match.to_s
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,42 +1,39 @@
|
|||
require_relative "boot"
|
||||
|
||||
require "rails"
|
||||
# Pick the frameworks you want:
|
||||
require "active_model/railtie"
|
||||
require "active_job/railtie"
|
||||
require "active_record/railtie"
|
||||
require "active_storage/engine"
|
||||
require "action_controller/railtie"
|
||||
# require "action_mailer/railtie"
|
||||
# require "action_mailbox/engine"
|
||||
require "action_text/engine"
|
||||
require "action_view/railtie"
|
||||
require "action_cable/engine"
|
||||
require "rails/test_unit/railtie"
|
||||
|
||||
# Include only the Rails frameworks we need
|
||||
require "active_model/railtie" # Basic model functionality
|
||||
require "active_job/railtie" # Background job processing
|
||||
require "active_record/railtie" # Database support
|
||||
require "active_storage/engine" # File upload and storage
|
||||
require "action_controller/railtie" # API controller support
|
||||
require "action_text/engine" # Rich text handling
|
||||
require "action_view/railtie" # View rendering (needed for some API responses)
|
||||
require "rails/test_unit/railtie" # Testing framework
|
||||
|
||||
# Load gems from Gemfile
|
||||
# Require the gems listed in Gemfile, including any gems
|
||||
# you've limited to :test, :development, or :production.
|
||||
Bundler.require(*Rails.groups)
|
||||
|
||||
module HenseiApi
|
||||
class Application < Rails::Application
|
||||
# Use Rails 7.0 defaults
|
||||
# Initialize configuration defaults for originally generated Rails version.
|
||||
config.load_defaults 7.0
|
||||
|
||||
# Configure autoloading
|
||||
config.autoload_paths << Rails.root.join("lib")
|
||||
config.eager_load_paths << Rails.root.join("lib")
|
||||
# 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")
|
||||
|
||||
# Configure asset handling for API mode
|
||||
config.paths["app/assets"] ||= []
|
||||
config.paths["app/assets"].unshift(Rails.root.join("app", "assets").to_s)
|
||||
config.assets.paths << Rails.root.join("app", "assets", "fonts")
|
||||
|
||||
# Enable query logging
|
||||
config.active_record.query_log_tags_enabled = true
|
||||
config.active_record.query_log_tags = [:application, :controller, :action, :job]
|
||||
config.active_record.cache_query_log_tags = true
|
||||
|
||||
config.active_support.to_time_preserves_timezone = :zone
|
||||
|
||||
# API-only application configuration
|
||||
# Only loads a smaller set of middleware suitable for API only apps.
|
||||
# Middleware like session, flash, cookies can be added back manually.
|
||||
# Skip views, helpers and assets when generating a new resource.
|
||||
config.api_only = true
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
7wcHmOJGd2lnyS5YYSo7OMJZlS+O/iNQqQJOHju+eZReCgKfV2PVji7MU6Bs0yymA3Z4SmTwsDRgPXfHnPb5ZiiLygGzsfWlPtcuwZA8U/9QFzerfz5/0ttgo2iAboJ8oY/NJzz73vVEzBwDv99CSvyMiy8Z9Y9QATnX9bE18pLll1A7/a+SpoH+JTO5zoDg/l/+RhLxaH/U+jc6u88sM1jjGbsA+5oH/RyNycjH2MA5suFvWMdrUUEu0fS90yv0IJaqHOB/XqpTxhkRd5aOjNbToNnVA5SHfBSdqQ9KpT4HCmOHhL2YSdGHhklkZP+Oo+Yh2je7Ve+siD0e5l9b/ckc9ojg8eb4D7A9NN8PwWtVtp6tEPGp7DovqpGVSK1MRtw1xtXhNuGr17aeRoz/fNVX19UjaTGYaiWngHGkbMt2s92jIP/XRvVrRNDgYlHiFRETwZepX83yyg1fkZRQ8rDwNBysowsfcnYyukh/C6ksAkV0wODT2FlZK7FA/OnmFG1c1cT+hRoMvddf+gxIO5MC--jogWWrqO3IqhQ8XD--4nqp+9AVerhwjXAn3xzs9w==
|
||||
Fxc8acnxWOFdt+zwWoACR/fskFH2+ZY5izq5cHf8pnGDKRSoI7QYm0h8RwevJtRUvUJQsJ+ja/xzbTYxNC4ABRSBe06lXwHJuCnt5YtR+4l+NiFnS76kGzSfhlfhmvPLtSdfTVRfhRib1vrz7E38jM1pcc2QBkzCxyaoZRu3X65U+gc7EqTjOsg8wpTjJwvfTXW9gkFNwFSen3nOSytewYDcivwUjr/3NUAONKHn4rNhBN3UJiNgOSCGj77Xx60E0Q95CidbkgExcyKAIMMsQgLKGhQRr9yUGxdshMuhA3JhVQSyvtd+jX8PmNX3FQusQIg7YUCh/WpiKo3aimZLQYY2n7lbfeSLpwuishjn138GAxe59Wgm1JhKN4xAkcAq54Q9d4AGFnu/IphMhv1TO03CqnwX1BbfY142--n8Fil7/q3W/SrENe--J31ORG+51iIo29fjiZU3Uw==
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
require 'active_support/core_ext/integer/time'
|
||||
require "active_support/core_ext/integer/time"
|
||||
|
||||
Rails.application.configure do
|
||||
# Settings specified here will take precedence over those in config/application.rb.
|
||||
config.hosts << 'staging-api.granblue.team'
|
||||
config.hosts << "staging-api.granblue.team"
|
||||
|
||||
# In the development environment your application's code is reloaded any time
|
||||
# it changes. This slows down response time but is perfect for development
|
||||
|
|
@ -20,10 +20,10 @@ Rails.application.configure do
|
|||
|
||||
# Enable/disable caching. By default caching is disabled.
|
||||
# Run rails dev:cache to toggle caching.
|
||||
if Rails.root.join('tmp/caching-dev.txt').exist?
|
||||
if Rails.root.join("tmp/caching-dev.txt").exist?
|
||||
config.cache_store = :memory_store
|
||||
config.public_file_server.headers = {
|
||||
'Cache-Control' => "public, max-age=#{2.days.to_i}"
|
||||
"Cache-Control" => "public, max-age=#{2.days.to_i}"
|
||||
}
|
||||
else
|
||||
config.action_controller.perform_caching = false
|
||||
|
|
@ -57,27 +57,4 @@ Rails.application.configure do
|
|||
|
||||
# Uncomment if you wish to allow Action Cable access from any origin.
|
||||
# config.action_cable.disable_request_forgery_protection = true
|
||||
#
|
||||
|
||||
logger = ActiveSupport::Logger.new(STDOUT)
|
||||
# To support a formatter, you must manually assign a formatter from the config.log_formatter value to the logger.
|
||||
logger.formatter = config.log_formatter
|
||||
# config.logger is the logger that will be used for Rails.logger and any
|
||||
# related Rails logging such as ActiveRecord::Base.logger.
|
||||
# It defaults to an instance of ActiveSupport::TaggedLogging that wraps an
|
||||
# instance of ActiveSupport::Logger which outputs a log to the log/ directory.
|
||||
config.logger = ActiveSupport::TaggedLogging.new(logger)
|
||||
# config.log_level defines the verbosity of the Rails logger.
|
||||
# This option defaults to :debug for all environments.
|
||||
# The available log levels are: :debug, :info, :warn, :error, :fatal, and :unknown
|
||||
# config.log_level = :debug
|
||||
# config.log_tags accepts a list of: methods that the request object responds to,
|
||||
# a Proc that accepts the request object, or something that responds to to_s.
|
||||
# This makes it easy to tag log lines with debug information like subdomain and request id -
|
||||
# both very helpful in debugging multi-user production applications.
|
||||
config.log_tags = [:request_id]
|
||||
|
||||
config.after_initialize do
|
||||
Prosopite.rails_logger = true
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@ require "active_support/core_ext/integer/time"
|
|||
|
||||
Rails.application.configure do
|
||||
# Settings specified here will take precedence over those in config/application.rb.
|
||||
config.require_master_key = true
|
||||
|
||||
|
||||
# Code is not reloaded between requests.
|
||||
config.cache_classes = true
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
require 'active_support/core_ext/integer/time'
|
||||
require "active_support/core_ext/integer/time"
|
||||
|
||||
# The test environment is used exclusively to run your application's
|
||||
# test suite. You never need to work with it otherwise. Remember that
|
||||
|
|
@ -9,21 +9,21 @@ Rails.application.configure do
|
|||
# Settings specified here will take precedence over those in config/application.rb.
|
||||
|
||||
# Turn false under Spring and add config.action_view.cache_template_loading = true.
|
||||
config.cache_classes = false
|
||||
config.cache_classes = true
|
||||
|
||||
# Eager loading loads your whole application. When running a single test locally,
|
||||
# this probably isn't necessary. It's a good idea to do in a continuous integration
|
||||
# system, or in some way before deploying your code.
|
||||
config.eager_load = ENV['CI'].present?
|
||||
config.eager_load = ENV["CI"].present?
|
||||
|
||||
# Configure public file server for tests with Cache-Control for performance.
|
||||
config.public_file_server.enabled = true
|
||||
config.public_file_server.headers = {
|
||||
'Cache-Control' => "public, max-age=#{1.hour.to_i}"
|
||||
"Cache-Control" => "public, max-age=#{1.hour.to_i}"
|
||||
}
|
||||
|
||||
# Show full error reports and disable caching.
|
||||
config.consider_all_requests_local = true
|
||||
config.consider_all_requests_local = true
|
||||
config.action_controller.perform_caching = false
|
||||
config.cache_store = :null_store
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# class CacheFreeLogger < ActiveSupport::Logger
|
||||
# def add(severity, message = nil, progname = nil, &block)
|
||||
# return true if progname&.include? 'CACHE'
|
||||
#
|
||||
# super
|
||||
# end
|
||||
# end
|
||||
#
|
||||
ActiveRecord::Base.logger = Logger.new(STDOUT)
|
||||
# ActiveRecord::Base.logger.level = 1
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
Rails.application.config.assets.precompile += %w( .otf )
|
||||
|
||||
# Ensure fonts directory exists in production
|
||||
fonts_dir = Rails.root.join('public', 'assets', 'fonts')
|
||||
FileUtils.mkdir_p(fonts_dir) unless File.directory?(fonts_dir)
|
||||
|
||||
# Copy fonts to public directory in production
|
||||
if Rails.env.production?
|
||||
Dir[Rails.root.join('app', 'assets', 'fonts', '*')].each do |font|
|
||||
FileUtils.cp(font, fonts_dir) if File.file?(font)
|
||||
end
|
||||
end
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
Rails.application.config.after_initialize do
|
||||
Rails.logger.info "Initializing AWS Service..."
|
||||
begin
|
||||
AwsService.new
|
||||
Rails.logger.info "AWS Service initialized successfully"
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to initialize AWS Service: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
end
|
||||
end
|
||||
|
|
@ -8,9 +8,9 @@
|
|||
Rails.application.config.middleware.insert_before 0, Rack::Cors do
|
||||
allow do
|
||||
if Rails.env.production?
|
||||
origins %w[granblue.team app.granblue.team hensei-web-production.up.railway.app game.granbluefantasy.jp chrome-extension://ahacbogimbikgiodaahmacboojcpdfpf]
|
||||
origins %w[granblue.team app.granblue.team hensei-web-production.up.railway.app]
|
||||
else
|
||||
origins %w[staging.granblue.team 127.0.0.1:1234 game.granbluefantasy.jp chrome-extension://ahacbogimbikgiodaahmacboojcpdfpf]
|
||||
origins %w[staging.granblue.team 127.0.0.1:1234]
|
||||
end
|
||||
|
||||
resource '*',
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Explicitly configure Oj to mimic JSON.
|
||||
Oj::Rails.mimic_JSON
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
# Fetch environment variables with defaults if not set
|
||||
redis_url = ENV.fetch('REDIS_URL', 'redis://localhost')
|
||||
redis_port = ENV.fetch('REDISPORT', '6379')
|
||||
|
||||
# Combine URL and port (adjust the path/DB as needed)
|
||||
full_redis_url = "#{redis_url}/0"
|
||||
|
||||
# Initialize Redis using the constructed URL
|
||||
$redis = Redis.new(url: full_redis_url)
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
require 'rufus-scheduler'
|
||||
|
||||
# Don't schedule jobs in test environment or when running rake tasks
|
||||
unless defined?(Rails::Console) || Rails.env.test? || File.split($0).last == 'rake'
|
||||
scheduler = Rufus::Scheduler.new
|
||||
|
||||
scheduler.every '5m' do
|
||||
PreviewService::GenerationMonitor.check_stalled_jobs
|
||||
end
|
||||
|
||||
scheduler.every '1h' do
|
||||
PreviewService::GenerationMonitor.retry_failed
|
||||
end
|
||||
|
||||
scheduler.every '1d' do
|
||||
PreviewService::GenerationMonitor.cleanup_old_previews
|
||||
end
|
||||
end
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Sentry.init do |config|
|
||||
config.breadcrumbs_logger = [:active_support_logger]
|
||||
config.dsn = ENV['SENTRY_DSN']
|
||||
config.enable_tracing = true
|
||||
end
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
redis_url = ENV.fetch('REDIS_URL', 'redis://localhost:6379/0')
|
||||
|
||||
Sidekiq.configure_server do |config|
|
||||
config.redis = { url: redis_url }
|
||||
config.death_handlers << ->(job, ex) do
|
||||
Rails.logger.error("Preview generation job #{job['jid']} failed with: #{ex.message}")
|
||||
end
|
||||
end
|
||||
|
||||
Sidekiq.configure_client do |config|
|
||||
config.redis = { url: redis_url }
|
||||
end
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
#
|
||||
# This file configures the New Relic Agent. New Relic monitors Ruby, Java,
|
||||
# .NET, PHP, Python, Node, and Go applications with deep visibility and low
|
||||
# overhead. For more information, visit www.newrelic.com.
|
||||
#
|
||||
# Generated October 28, 2022
|
||||
#
|
||||
# This configuration file is custom generated for NewRelic Administration
|
||||
#
|
||||
# For full documentation of agent configuration options, please refer to
|
||||
# https://docs.newrelic.com/docs/agents/ruby-agent/installation-configuration/ruby-agent-configuration
|
||||
|
||||
common: &default_settings
|
||||
# Required license key associated with your New Relic account.
|
||||
license_key: <%= ENV['NEW_RELIC_LICENSE_KEY'] %>
|
||||
|
||||
# Your application name. Renaming here affects where data displays in New
|
||||
# Relic. For more details, see https://docs.newrelic.com/docs/apm/new-relic-apm/maintenance/renaming-applications
|
||||
app_name: 'hensei-api'
|
||||
|
||||
distributed_tracing:
|
||||
enabled: true
|
||||
|
||||
# To disable the agent regardless of other settings, uncomment the following:
|
||||
|
||||
# agent_enabled: false
|
||||
|
||||
log_file_path: logs/
|
||||
|
||||
# Logging level for log/newrelic_agent.log
|
||||
log_level: info
|
||||
|
||||
application_logging:
|
||||
# If `true`, all logging-related features for the agent can be enabled or disabled
|
||||
# independently. If `false`, all logging-related features are disabled.
|
||||
enabled: true
|
||||
forwarding:
|
||||
# If `true`, the agent captures log records emitted by this application.
|
||||
enabled: true
|
||||
# Defines the maximum number of log records to buffer in memory at a time.
|
||||
max_samples_stored: 10000
|
||||
metrics:
|
||||
# If `true`, the agent captures metrics related to logging for this application.
|
||||
enabled: true
|
||||
local_decorating:
|
||||
# If `true`, the agent decorates logs with metadata to link to entities, hosts, traces, and spans.
|
||||
# This requires a log forwarder to send your log files to New Relic.
|
||||
# This should not be used when forwarding is enabled.
|
||||
enabled: false
|
||||
|
||||
# Environment-specific settings are in this section.
|
||||
# RAILS_ENV or RACK_ENV (as appropriate) is used to determine the environment.
|
||||
# If your application has other named environments, configure them here.
|
||||
development:
|
||||
<<: *default_settings
|
||||
app_name: 'hensei-api (Development)'
|
||||
|
||||
test:
|
||||
<<: *default_settings
|
||||
# It doesn't make sense to report to New Relic from automated test runs.
|
||||
monitor_mode: false
|
||||
|
||||
staging:
|
||||
<<: *default_settings
|
||||
app_name: 'hensei-api (Staging)'
|
||||
|
||||
production:
|
||||
<<: *default_settings
|
||||
|
|
@ -4,20 +4,20 @@
|
|||
# the maximum value specified for Puma. Default is set to 5 threads for minimum
|
||||
# and maximum; this matches the default thread size of Active Record.
|
||||
#
|
||||
max_threads_count = ENV.fetch('RAILS_MAX_THREADS') { 5 }
|
||||
min_threads_count = ENV.fetch('RAILS_MIN_THREADS') { max_threads_count }
|
||||
max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
|
||||
min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count }
|
||||
threads min_threads_count, max_threads_count
|
||||
|
||||
# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
|
||||
#
|
||||
port ENV.fetch('PORT', 3000)
|
||||
port ENV.fetch("PORT") { 3000 }
|
||||
|
||||
# Specifies the `environment` that Puma will run in.
|
||||
#
|
||||
environment ENV.fetch('RAILS_ENV') { 'development' }
|
||||
environment ENV.fetch("RAILS_ENV") { "development" }
|
||||
|
||||
# Specifies the `pidfile` that Puma will use.
|
||||
pidfile ENV.fetch('PIDFILE') { 'tmp/pids/server.pid' }
|
||||
pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" }
|
||||
|
||||
# Specifies the number of `workers` to boot in clustered mode.
|
||||
# Workers are forked web server processes. If using threads and workers together
|
||||
|
|
|
|||
124
config/routes.rb
124
config/routes.rb
|
|
@ -4,94 +4,68 @@ Rails.application.routes.draw do
|
|||
skip_controllers :applications, :authorized_applications
|
||||
end
|
||||
|
||||
path_prefix = Rails.env.production? ? '/v1' : '/api/v1'
|
||||
namespace :api, defaults: { format: :json } do
|
||||
namespace :v1 do
|
||||
resources :parties, only: %i[index create update destroy]
|
||||
resources :users, only: %i[create update show]
|
||||
resources :grid_weapons, only: %i[update destroy]
|
||||
resources :grid_characters, only: %i[update destroy]
|
||||
resources :grid_summons, only: %i[update destroy]
|
||||
resources :weapons, only: :show
|
||||
resources :characters, only: :show
|
||||
resources :summons, only: :show
|
||||
resources :favorites, only: [:create]
|
||||
|
||||
scope path: path_prefix, module: 'api/v1', defaults: { format: :json } do
|
||||
resources :parties, only: %i[index create update destroy]
|
||||
resources :users, only: %i[create update show]
|
||||
resources :grid_weapons, only: %i[update destroy]
|
||||
resources :grid_characters, only: %i[update destroy]
|
||||
resources :grid_summons, only: %i[update destroy]
|
||||
resources :weapons, only: :show
|
||||
resources :characters, only: :show
|
||||
resources :summons, only: :show
|
||||
resources :favorites, only: [:create]
|
||||
get 'version', to: 'api#version'
|
||||
|
||||
get 'version', to: 'api#version'
|
||||
get 'users/info/:id', to: 'users#info'
|
||||
|
||||
post 'import', to: 'import#create'
|
||||
post 'import/weapons', to: 'import#weapons'
|
||||
post 'import/summons', to: 'import#summons'
|
||||
post 'import/characters', to: 'import#characters'
|
||||
get 'parties/favorites', to: 'parties#favorites'
|
||||
get 'parties/:id', to: 'parties#show'
|
||||
post 'parties/:id/remix', to: 'parties#remix'
|
||||
|
||||
get 'users/info/:id', to: 'users#info'
|
||||
put 'parties/:id/jobs', to: 'jobs#update_job'
|
||||
put 'parties/:id/job_skills', to: 'jobs#update_job_skills'
|
||||
delete 'parties/:id/job_skills', to: 'jobs#destroy_job_skill'
|
||||
|
||||
get 'parties/favorites', to: 'parties#favorites'
|
||||
get 'parties/:id', to: 'parties#show'
|
||||
get 'parties/:id/preview', to: 'parties#preview'
|
||||
get 'parties/:id/preview_status', to: 'parties#preview_status'
|
||||
post 'parties/:id/regenerate_preview', to: 'parties#regenerate_preview'
|
||||
post 'parties/:id/remix', to: 'parties#remix'
|
||||
post 'check/email', to: 'users#check_email'
|
||||
post 'check/username', to: 'users#check_username'
|
||||
|
||||
put 'parties/:id/jobs', to: 'jobs#update_job'
|
||||
put 'parties/:id/job_skills', to: 'jobs#update_job_skills'
|
||||
delete 'parties/:id/job_skills', to: 'jobs#destroy_job_skill'
|
||||
post 'search', to: 'search#all'
|
||||
post 'search/characters', to: 'search#characters'
|
||||
post 'search/weapons', to: 'search#weapons'
|
||||
post 'search/summons', to: 'search#summons'
|
||||
post 'search/job_skills', to: 'search#job_skills'
|
||||
post 'search/guidebooks', to: 'search#guidebooks'
|
||||
|
||||
post 'check/email', to: 'users#check_email'
|
||||
post 'check/username', to: 'users#check_username'
|
||||
get 'jobs', to: 'jobs#all'
|
||||
|
||||
post 'search', to: 'search#all'
|
||||
post 'search/characters', to: 'search#characters'
|
||||
post 'search/weapons', to: 'search#weapons'
|
||||
post 'search/summons', to: 'search#summons'
|
||||
post 'search/job_skills', to: 'search#job_skills'
|
||||
post 'search/guidebooks', to: 'search#guidebooks'
|
||||
get 'jobs/skills', to: 'job_skills#all'
|
||||
get 'jobs/:id/skills', to: 'job_skills#job'
|
||||
get 'jobs/:id/accessories', to: 'job_accessories#job'
|
||||
|
||||
get 'jobs', to: 'jobs#all'
|
||||
get 'guidebooks', to: 'guidebooks#all'
|
||||
|
||||
get 'jobs/skills', to: 'job_skills#all'
|
||||
get 'jobs/:id', to: 'jobs#show'
|
||||
get 'jobs/:id/skills', to: 'job_skills#job'
|
||||
get 'jobs/:id/accessories', to: 'job_accessories#job'
|
||||
get 'raids', to: 'raids#all'
|
||||
get 'raids/groups', to: 'raids#groups'
|
||||
get 'weapon_keys', to: 'weapon_keys#all'
|
||||
|
||||
get 'guidebooks', to: 'guidebooks#all'
|
||||
post 'characters', to: 'grid_characters#create'
|
||||
post 'characters/resolve', to: 'grid_characters#resolve'
|
||||
post 'characters/update_uncap', to: 'grid_characters#update_uncap_level'
|
||||
delete 'characters', to: 'grid_characters#destroy'
|
||||
|
||||
get 'raids', to: 'raids#all'
|
||||
get 'raids/groups', to: 'raids#groups'
|
||||
get 'raids/:id', to: 'raids#show'
|
||||
get 'weapon_keys', to: 'weapon_keys#all'
|
||||
post 'weapons', to: 'grid_weapons#create'
|
||||
post 'weapons/resolve', to: 'grid_weapons#resolve'
|
||||
post 'weapons/update_uncap', to: 'grid_weapons#update_uncap_level'
|
||||
delete 'weapons', to: 'grid_weapons#destroy'
|
||||
|
||||
post 'characters', to: 'grid_characters#create'
|
||||
post 'characters/resolve', to: 'grid_characters#resolve'
|
||||
post 'characters/update_uncap', to: 'grid_characters#update_uncap_level'
|
||||
delete 'characters', to: 'grid_characters#destroy'
|
||||
post 'summons', to: 'grid_summons#create'
|
||||
post 'summons/update_uncap', to: 'grid_summons#update_uncap_level'
|
||||
post 'summons/update_quick_summon', to: 'grid_summons#update_quick_summon'
|
||||
delete 'summons', to: 'grid_summons#destroy'
|
||||
|
||||
post 'weapons', to: 'grid_weapons#create'
|
||||
post 'weapons/resolve', to: 'grid_weapons#resolve'
|
||||
post 'weapons/update_uncap', to: 'grid_weapons#update_uncap_level'
|
||||
delete 'weapons', to: 'grid_weapons#destroy'
|
||||
|
||||
post 'summons', to: 'grid_summons#create'
|
||||
post 'summons/update_uncap', to: 'grid_summons#update_uncap_level'
|
||||
post 'summons/update_quick_summon', to: 'grid_summons#update_quick_summon'
|
||||
delete 'summons', to: 'grid_summons#destroy'
|
||||
|
||||
delete 'favorites', to: 'favorites#destroy'
|
||||
end
|
||||
|
||||
if Rails.env.development?
|
||||
get '/party-previews/*filename', to: proc { |env|
|
||||
filename = env['action_dispatch.request.path_parameters'][:filename]
|
||||
path = Rails.root.join('storage', 'party-previews', filename)
|
||||
|
||||
if File.exist?(path)
|
||||
[200, {
|
||||
'Content-Type' => 'image/png',
|
||||
'Cache-Control' => 'no-cache' # Prevent caching during development
|
||||
}, [File.read(path)]]
|
||||
else
|
||||
[404, { 'Content-Type' => 'text/plain' }, ['Preview not found']]
|
||||
end
|
||||
}
|
||||
delete 'favorites', to: 'favorites#destroy'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
:scheduler:
|
||||
cleanup_party_previews:
|
||||
cron: '0 0 * * *' # Daily at midnight
|
||||
class: CleanupPartyPreviewsJob
|
||||
queue: maintenance
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class PopulateWeaponRecruits < ActiveRecord::Migration[7.0]
|
||||
def up
|
||||
# Get all character mappings and convert to hash properly
|
||||
results = execute(<<-SQL)
|
||||
SELECT id, granblue_id
|
||||
FROM characters
|
||||
WHERE granblue_id IS NOT NULL
|
||||
SQL
|
||||
|
||||
character_mapping = {}
|
||||
results.each do |row|
|
||||
character_mapping[row['id']] = row['granblue_id']
|
||||
end
|
||||
|
||||
# Update weapons table using the mapping
|
||||
character_mapping.each do |char_id, granblue_id|
|
||||
execute(<<-SQL)
|
||||
UPDATE weapons
|
||||
SET recruits = #{connection.quote(granblue_id)}
|
||||
WHERE recruits_id = #{connection.quote(char_id)}
|
||||
SQL
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
execute("UPDATE weapons SET recruits = NULL")
|
||||
end
|
||||
end
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class MigrateWeaponSeries < ActiveRecord::Migration[8.0]
|
||||
def up
|
||||
Weapon.transaction do
|
||||
puts 'Starting weapon series migration...'
|
||||
|
||||
puts 'Updating Seraphic Weapons (0 -> 1)...'
|
||||
Weapon.where(series: 0).update_all(new_series: 1)
|
||||
|
||||
puts 'Updating Grand Weapons (1 -> 2)...'
|
||||
Weapon.where(series: 1).update_all(new_series: 2)
|
||||
|
||||
puts 'Updating Dark Opus Weapons (2 -> 3)...'
|
||||
Weapon.where(series: 2).update_all(new_series: 3)
|
||||
|
||||
puts 'Updating Revenant Weapons (4 -> 4)...'
|
||||
Weapon.where(series: 4).update_all(new_series: 4)
|
||||
|
||||
puts 'Updating Primal Weapons (6 -> 5)...'
|
||||
Weapon.where(series: 6).update_all(new_series: 5)
|
||||
|
||||
puts 'Updating Beast Weapons (5, 7 -> 6)...'
|
||||
Weapon.where(series: 5).update_all(new_series: 6)
|
||||
Weapon.where(series: 7).update_all(new_series: 6)
|
||||
|
||||
puts 'Updating Regalia Weapons (8 -> 7)...'
|
||||
Weapon.where(series: 8).update_all(new_series: 7)
|
||||
|
||||
puts 'Updating Omega Weapons (9 -> 8)...'
|
||||
Weapon.where(series: 9).update_all(new_series: 8)
|
||||
|
||||
puts 'Updating Olden Primal Weapons (10 -> 9)...'
|
||||
Weapon.where(series: 10).update_all(new_series: 9)
|
||||
|
||||
puts 'Updating Hollowsky Weapons (12 -> 10)...'
|
||||
Weapon.where(series: 12).update_all(new_series: 10)
|
||||
|
||||
puts 'Updating Xeno Weapons (13 -> 11)...'
|
||||
Weapon.where(series: 13).update_all(new_series: 11)
|
||||
|
||||
puts 'Updating Rose Weapons (15 -> 12)...'
|
||||
Weapon.where(series: 15).update_all(new_series: 12)
|
||||
|
||||
puts 'Updating Ultima Weapons (17 -> 13)...'
|
||||
Weapon.where(series: 17).update_all(new_series: 13)
|
||||
|
||||
puts 'Updating Bahamut Weapons (16 -> 14)...'
|
||||
Weapon.where(series: 16).update_all(new_series: 14)
|
||||
|
||||
puts 'Updating Epic Weapons (18 -> 15)...'
|
||||
Weapon.where(series: 18).update_all(new_series: 15)
|
||||
|
||||
puts 'Updating Cosmos Weapons (20 -> 16)...'
|
||||
Weapon.where(series: 20).update_all(new_series: 16)
|
||||
|
||||
puts 'Updating Superlative Weapons (22 -> 17)...'
|
||||
Weapon.where(series: 22).update_all(new_series: 17)
|
||||
|
||||
puts 'Updating Vintage Weapons (23 -> 18)...'
|
||||
Weapon.where(series: 23).update_all(new_series: 18)
|
||||
|
||||
puts 'Updating Class Champion Weapons (24 -> 19)...'
|
||||
Weapon.where(series: 24).update_all(new_series: 19)
|
||||
|
||||
puts 'Updating Sephira Weapons (28 -> 23)...'
|
||||
Weapon.where(series: 28).update_all(new_series: 23)
|
||||
|
||||
puts 'Updating Astral Weapons (14 -> 26)...'
|
||||
Weapon.where(series: 14).update_all(new_series: 26)
|
||||
|
||||
puts 'Updating Draconic Weapons (3 -> 27)...'
|
||||
Weapon.where(series: 3).update_all(new_series: 27)
|
||||
|
||||
puts 'Updating Ancestral Weapons (21 -> 29)...'
|
||||
Weapon.where(series: 21).update_all(new_series: 29)
|
||||
|
||||
puts 'Updating New World Foundation (29 -> 30)...'
|
||||
Weapon.where(series: 29).update_all(new_series: 30)
|
||||
|
||||
puts 'Updating Ennead Weapons (19 -> 31)...'
|
||||
Weapon.where(series: 19).update_all(new_series: 31)
|
||||
|
||||
puts 'Updating Militis Weapons (11 -> 32)...'
|
||||
Weapon.where(series: 11).update_all(new_series: 32)
|
||||
|
||||
puts 'Updating Malice Weapons (26 -> 33)...'
|
||||
Weapon.where(series: 26).update_all(new_series: 33)
|
||||
|
||||
puts 'Updating Menace Weapons (26 -> 34)...'
|
||||
Weapon.where(series: 26).update_all(new_series: 34)
|
||||
|
||||
puts 'Updating Illustrious Weapons (31 -> 35)...'
|
||||
Weapon.where(series: 31).update_all(new_series: 35)
|
||||
|
||||
puts 'Updating Proven Weapons (25 -> 36)...'
|
||||
Weapon.where(series: 25).update_all(new_series: 36)
|
||||
|
||||
puts 'Updating Revans Weapons (30 -> 37)...'
|
||||
Weapon.where(series: 30).update_all(new_series: 37)
|
||||
|
||||
puts 'Updating World Weapons (32 -> 38)...'
|
||||
Weapon.where(series: 32).update_all(new_series: 38)
|
||||
|
||||
puts 'Updating Exo Weapons (33 -> 39)...'
|
||||
Weapon.where(series: 33).update_all(new_series: 39)
|
||||
|
||||
puts 'Updating Draconic Weapons Providence (34 -> 40)...'
|
||||
Weapon.where(series: 34).update_all(new_series: 40)
|
||||
|
||||
puts 'Updating Celestial Weapons (37 -> 41)...'
|
||||
Weapon.where(series: 37).update_all(new_series: 41)
|
||||
|
||||
puts 'Updating Omega Rebirth Weapons (38 -> 42)...'
|
||||
Weapon.where(series: 38).update_all(new_series: 42)
|
||||
|
||||
puts 'Updating Event Weapons (34 -> 98)...'
|
||||
Weapon.where(series: 34).update_all(new_series: 98) # Event
|
||||
|
||||
puts 'Updating Gacha Weapons (36 -> 99)...'
|
||||
Weapon.where(series: 36).update_all(new_series: 99) # Gacha
|
||||
|
||||
puts 'Migration completed successfully!'
|
||||
rescue StandardError => e
|
||||
puts "Error occurred during migration: #{e.message}"
|
||||
puts "Backtrace: #{e.backtrace}"
|
||||
raise e
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
raise ActiveRecord::IrreversibleMigration
|
||||
end
|
||||
end
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class MigrateSeriesOnWeaponKey < ActiveRecord::Migration[8.0]
|
||||
def up
|
||||
WeaponKey.transaction do
|
||||
puts 'Starting weapon key series migration...'
|
||||
|
||||
puts 'Updating Telumas (3 -> 27)...'
|
||||
WeaponKey.where('? = ANY(series)', 3).update_all('series = array_replace(series, 3, 27)')
|
||||
|
||||
puts 'Updating Providence Telumas (34 -> 40)...'
|
||||
WeaponKey.where('? = ANY(series)', 34).update_all('series = array_replace(series, 34, 40)')
|
||||
|
||||
puts 'Updating Gauph Keys (17 -> 13)...'
|
||||
WeaponKey.where('? = ANY(series)', 17).update_all('series = array_replace(series, 17, 13)')
|
||||
|
||||
puts 'Updating Pendulums (2 -> 3)...'
|
||||
WeaponKey.where('? = ANY(series)', 2).update_all('series = array_replace(series, 2, 3)')
|
||||
|
||||
puts 'Updating Chains (2 -> 3)...'
|
||||
WeaponKey.where('? = ANY(series)', 2).update_all('series = array_replace(series, 2, 3)')
|
||||
|
||||
puts 'Updating Emblems (24 -> 19)...'
|
||||
WeaponKey.where('? = ANY(series)', 24).update_all('series = array_replace(series, 24, 19)')
|
||||
|
||||
puts 'Migration completed successfully!'
|
||||
rescue StandardError => e
|
||||
puts "Error occurred during migration: #{e.message}"
|
||||
puts "Backtrace: #{e.backtrace}"
|
||||
raise e
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
raise ActiveRecord::IrreversibleMigration
|
||||
end
|
||||
end
|
||||
|
|
@ -1 +1 @@
|
|||
DataMigrate::Data.define(version: 20250218025755)
|
||||
DataMigrate::Data.define(version: 20231119051223)
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
class AddTranscendenceToWeapon < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :weapons, :transcendence, :boolean, default: false
|
||||
add_column :weapons, :transcendence_date, :datetime
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue