diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4f43c1b..0a513de 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -15,3 +15,41 @@ jobs:
bundler-cache: true
- name: Run RuboCop
run: bundle exec rubocop
+ test:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ env:
+ BUNDLE_PATH: vendor/bundle
+ strategy:
+ fail-fast: false
+ matrix:
+ ruby:
+ - "3.2"
+ - "3.3"
+ - "3.4"
+ appraisal:
+ - rails-7_0
+ - rails-7_1
+ - rails-7_2
+ - rails-8_0
+ - rails-8_1
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Set up Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: ${{ matrix.ruby }}
+ bundler-cache: true
+
+ - name: Install system deps
+ run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev pkg-config
+
+ - name: Install Appraisal gemfiles
+ run: bundle exec appraisal install
+
+ - name: Run specs (${{ matrix.appraisal }})
+ run: bundle exec appraisal ${{ matrix.appraisal }} rspec spec
+
+ - name: Run rails sample specs (${{ matrix.appraisal }})
+ run: bundle exec appraisal ${{ matrix.appraisal }} rake rails_sample_spec
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b350041
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+/tmp/
+/.bundle/
+/vendor/bundle/
+/Gemfile.lock
+/gemfiles/*.gemfile.lock
+/.idea/
+/pkg
+*.lock
+*.gem
diff --git a/.rubocop.yml b/.rubocop.yml
index 70ade74..0c2a8fd 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -1,8 +1,14 @@
AllCops:
DisplayCopNames: true
NewCops: enable
+ SuggestExtensions: false
plugins:
+ - rubocop-rspec
+ - rubocop-performance
+ - rubocop-rails
+ - rubocop-rspec_rails
+ - rubocop-factory_bot
### Lint ###
Lint/AmbiguousOperatorPrecedence:
@@ -143,3 +149,61 @@ Metrics/ModuleLength:
Metrics/PerceivedComplexity:
Enabled: false
+
+### Rails ###
+Rails:
+ Enabled: true
+
+Rails/Blank:
+ Enabled: false
+
+Rails/Delegate:
+ Enabled: false
+
+Rails/Output:
+ Enabled: false
+
+Rails/SquishedSQLHeredocs:
+ Enabled: false
+
+Rails/RedundantActiveRecordAllMethod:
+ Enabled: false
+
+Rails/DynamicFindBy:
+ AllowedMethods:
+ - find_by_id
+ - find_by_id!
+ - find_by_ids
+ - find_by_ids!
+
+Rails/InverseOf:
+ Enabled: false
+
+Rails/HasManyOrHasOneDependent:
+ Enabled: false
+
+Rails/I18nLocaleTexts:
+ Enabled: false
+
+Rails/FindEach:
+ Enabled: false
+
+### Performance ###
+Performance/CollectionLiteralInLoop:
+ Enabled: false
+
+### RSpec ###
+RSpec/MultipleExpectations:
+ Enabled: false
+
+RSpec/ExampleLength:
+ Enabled: false
+
+RSpec/DescribeClass:
+ Enabled: false
+
+RSpec/MultipleMemoizedHelpers:
+ Enabled: false
+
+RSpec/LetSetup:
+ Enabled: false
diff --git a/Appraisals b/Appraisals
new file mode 100644
index 0000000..b6cf8d4
--- /dev/null
+++ b/Appraisals
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+rails_versions = ENV.fetch("RAILS_VERSIONS", "7.0 7.1 7.2 8.0 8.1").split
+
+rails_versions.each do |version|
+ appraise "rails-#{version.tr('.', '_')}" do
+ gem "rails", "~> #{version}"
+ gem "sqlite3", "~> 2.1"
+ gem "rspec"
+ gem "factory_bot"
+ gem "puma"
+ end
+end
diff --git a/Gemfile b/Gemfile
index 2160871..2bd9e07 100644
--- a/Gemfile
+++ b/Gemfile
@@ -4,4 +4,18 @@ source "https://rubygems.org"
gemspec
+gem "factory_bot", group: :test
+gem "puma", group: :development
+gem "rails", ENV.fetch("RAILS_VERSION", "~> 7.2"), group: :development
+
+gem "rspec", group: :test
gem "rubocop", group: :development
+gem 'rubocop-factory_bot', group: :development
+gem 'rubocop-performance', group: :development
+gem 'rubocop-rails', group: :development
+gem 'rubocop-rspec', group: :development
+gem 'rubocop-rspec_rails', group: :development
+gem "sqlite3", "~> 2.1", group: :development
+
+gem "appraisal", group: :development
+gem "rake", group: :development
diff --git a/Gemfile.lock b/Gemfile.lock
deleted file mode 100644
index a272c17..0000000
--- a/Gemfile.lock
+++ /dev/null
@@ -1,89 +0,0 @@
-PATH
- remote: .
- specs:
- simple_master (0.1.0)
- activerecord (>= 7.0)
- activesupport (>= 7.0)
- request_store (>= 1.0)
-
-GEM
- remote: https://rubygems.org/
- specs:
- activemodel (8.1.1)
- activesupport (= 8.1.1)
- activerecord (8.1.1)
- activemodel (= 8.1.1)
- activesupport (= 8.1.1)
- timeout (>= 0.4.0)
- activesupport (8.1.1)
- base64
- bigdecimal
- concurrent-ruby (~> 1.0, >= 1.3.1)
- connection_pool (>= 2.2.5)
- drb
- i18n (>= 1.6, < 2)
- json
- logger (>= 1.4.2)
- minitest (>= 5.1)
- securerandom (>= 0.3)
- tzinfo (~> 2.0, >= 2.0.5)
- uri (>= 0.13.1)
- ast (2.4.3)
- base64 (0.3.0)
- bigdecimal (4.0.1)
- concurrent-ruby (1.3.6)
- connection_pool (3.0.2)
- drb (2.2.3)
- i18n (1.14.8)
- concurrent-ruby (~> 1.0)
- json (2.18.0)
- language_server-protocol (3.17.0.5)
- lint_roller (1.1.0)
- logger (1.7.0)
- minitest (6.0.0)
- prism (~> 1.5)
- parallel (1.27.0)
- parser (3.3.10.0)
- ast (~> 2.4.1)
- racc
- prism (1.7.0)
- racc (1.8.1)
- rack (3.2.4)
- rainbow (3.1.1)
- regexp_parser (2.11.3)
- request_store (1.7.0)
- rack (>= 1.4)
- rubocop (1.82.0)
- json (~> 2.3)
- language_server-protocol (~> 3.17.0.2)
- lint_roller (~> 1.1.0)
- parallel (~> 1.10)
- parser (>= 3.3.0.2)
- rainbow (>= 2.2.2, < 4.0)
- regexp_parser (>= 2.9.3, < 3.0)
- rubocop-ast (>= 1.48.0, < 2.0)
- ruby-progressbar (~> 1.7)
- unicode-display_width (>= 2.4.0, < 4.0)
- rubocop-ast (1.48.0)
- parser (>= 3.3.7.2)
- prism (~> 1.4)
- ruby-progressbar (1.13.0)
- securerandom (0.4.1)
- timeout (0.6.0)
- tzinfo (2.0.6)
- concurrent-ruby (~> 1.0)
- unicode-display_width (3.2.0)
- unicode-emoji (~> 4.1)
- unicode-emoji (4.2.0)
- uri (1.1.1)
-
-PLATFORMS
- arm64-darwin-24
- x86_64-linux
-
-DEPENDENCIES
- rubocop
- simple_master!
-
-BUNDLED WITH
- 2.7.1
diff --git a/Rakefile b/Rakefile
new file mode 100644
index 0000000..63bb6d1
--- /dev/null
+++ b/Rakefile
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require "bundler/setup"
+require "rspec/core/rake_task"
+
+RSpec::Core::RakeTask.new(:spec)
+RSpec::Core::RakeTask.new(:rails_sample_spec) do |task|
+ task.pattern = "examples/rails_sample/spec/**/*_spec.rb"
+ task.rspec_opts = "--require ./examples/rails_sample/spec/spec_helper --default-path examples/rails_sample/spec"
+end
+
+task default: :spec
diff --git a/examples/rails_sample/.gitignore b/examples/rails_sample/.gitignore
new file mode 100644
index 0000000..f88a268
--- /dev/null
+++ b/examples/rails_sample/.gitignore
@@ -0,0 +1,19 @@
+/log/
+/tmp/
+/db/*.sqlite3
+/db/*.sqlite3-*
+/db/*.sqlite3-journal
+/db/*.sqlite3-wal
+/db/*.sqlite3-shm
+/storage/
+/node_modules/
+/vendor/bundle/
+/.bundle/
+/.byebug_history
+/coverage/
+/public/assets/
+/public/packs/
+/public/packs-test/
+/public/webpack/
+/yarn-error.log
+/npm-debug.log
diff --git a/examples/rails_sample/README.md b/examples/rails_sample/README.md
new file mode 100644
index 0000000..6fe5d94
--- /dev/null
+++ b/examples/rails_sample/README.md
@@ -0,0 +1,106 @@
+# Rails Sample App
+
+This sample app demonstrates SimpleMaster in a compact, game-like domain.
+It is used to exercise column casting and association patterns (STI, polymorphic
+`belongs_to`, `has_many`).
+
+## Development Setup
+```bash
+bundle install
+cd examples/rails_sample
+bundle exec rails db:prepare
+bundle exec rails s
+```
+
+## Database Configuration
+Database settings live in `examples/rails_sample/config/database.yml`.
+
+- `development`: sqlite3 at `examples/rails_sample/db/development.sqlite3`
+- `test`: sqlite3 in-memory (`:memory:`)
+- `production`: sqlite3 at `examples/rails_sample/db/production.sqlite3`
+
+If you need a different DB, edit `config/database.yml` or set `DATABASE_URL`.
+
+## Domain Model
+```
+SimpleMaster (masters) ActiveRecord
+
+[Weapon] (STI: Gun, Blade) [Player] --< player_items >-- (polymorphic to Weapon/Armor/Potion)
+[Armor] PlayerItem: belongs_to :item, polymorphic
+[Potion]
+[Level] --< players (lv) >-- [Player]
+[Enemy] --< rewards >-- [Reward] (reward_type/reward_id -> Weapon/Armor/Potion)
+```
+
+## Masters
+- **Weapon** (`Gun`, `Blade`)
+ - `id`
+ - `type`
+ - `name`
+ - `attack` (float)
+ - `info` (json, symbolize_names: true)
+ - `metadata` (json, symbolize_names: false)
+ - `rarity` (enum)
+ - `flags` (bitmask)
+ - Notes: polymorphic target (PlayerItem, Reward)
+- **Armor**
+ - `id`
+ - `name`
+ - `defence` (float)
+ - Notes: polymorphic target
+- **Potion**
+ - `id`
+ - `name`
+ - `hp` (float)
+ - Notes: polymorphic target
+- **Level**
+ - `id`
+ - `lv` (unique)
+ - `attack` (float)
+ - `defence` (float)
+ - `hp` (float)
+ - Associations: `has_many :players` (lv)
+- **Enemy**
+ - `id`
+ - `name`
+ - `is_boss` (boolean)
+ - `start_at` (time)
+ - `end_at` (time)
+ - `attack`
+ - `defence`
+ - `hp`
+ - Associations: `has_many :rewards`
+- **Reward**
+ - `id`
+ - `enemy_id`
+ - `reward_type`
+ - `reward_id`
+ - Associations: `belongs_to :enemy`; polymorphic `belongs_to` Weapon/Armor/Potion
+
+## ActiveRecord
+- **Player**
+ - `id`
+ - `name`
+ - `lv`
+ - Associations: `belongs_to :level` (lv); `has_many :player_items`; `has_many :items, through: :player_items`
+- **PlayerItem**
+ - `player_id`
+ - `item_type`
+ - `item_id`
+ - Associations: `belongs_to :player`; polymorphic `belongs_to :item`
+
+## Fixtures
+Fixtures live in `examples/rails_sample/fixtures/masters`.
+
+- `weapons.json` (STI Gun/Blade), `armors.json`, `potions.json`, `levels.json` (uses `lv` as unique key),
+ `enemies.json`, `rewards.json`
+- Aim to include representative casts (float/json/globalize where useful) and polymorphic/has_many links.
+
+## Related Specs
+- `simple_master/active_record/extension_spec.rb`: AR↔master (`belongs_to_master`)
+- `simple_master/master/item_spec.rb`: column casting and master associations
+- `simple_master/master/filterable_spec.rb`: find/find_by/all_by/all_in
+- `simple_master/master/cache_spec.rb`: cache_method, cache_class_method
+- `simple_master/storage/loader_spec.rb`: STI instantiation, diff application
+- `simple_master/loader/marshal_loader_spec.rb`: Marshal dump/load roundtrip
+- `simple_master/storage/dataset_spec.rb`: dataset cache/diff duplication
diff --git a/examples/rails_sample/Rakefile b/examples/rails_sample/Rakefile
new file mode 100644
index 0000000..c4f9523
--- /dev/null
+++ b/examples/rails_sample/Rakefile
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+require_relative "config/application"
+
+Rails.application.load_tasks
diff --git a/examples/rails_sample/app/controllers/game_controller.rb b/examples/rails_sample/app/controllers/game_controller.rb
new file mode 100644
index 0000000..3f82d94
--- /dev/null
+++ b/examples/rails_sample/app/controllers/game_controller.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+class GameController < ActionController::Base # rubocop:disable Rails/ApplicationController
+ skip_forgery_protection
+
+ def show
+ @player = current_player
+ @now = Time.current
+ @enemies =
+ Enemy.available_at(@now)
+ .sort_by { |enemy| [enemy.is_boss? ? 1 : 0, enemy.attack.to_f + enemy.defence.to_f + enemy.hp.to_f] }
+ @potions = Potion.all
+ end
+
+ def play
+ @player = current_player
+
+ case params[:op]
+ when "create_player"
+ @player = Player.create!(name: player_name, lv: player_level)
+ session[:player_id] = @player.id
+ flash.now[:notice] = "Player created."
+ when "challenge"
+ enemy = Enemy.find_by_id(params[:enemy_id].to_i)
+ if @player && enemy
+ result = @player.challenge_enemy(enemy, at: Time.current)
+ flash[:notice] = battle_message(result)
+ else
+ flash[:notice] = "Missing player or enemy."
+ end
+ when "potion"
+ potion = Potion.find_by_id(params[:potion_id].to_i)
+ flash.now[:notice] = if @player && potion
+ @player.use_potion(potion, at: Time.current) ? "Healed." : "Potion failed."
+ else
+ "Missing player or potion."
+ end
+ when "equip_weapon"
+ weapon = Weapon.find_by_id(params[:weapon_id].to_i)
+ flash.now[:notice] = if @player && weapon
+ @player.equip_weapon(weapon) ? "Weapon equipped." : "Cannot equip weapon."
+ else
+ "Missing player or weapon."
+ end
+ when "equip_armor"
+ armor = Armor.find_by_id(params[:armor_id].to_i)
+ flash.now[:notice] = if @player && armor
+ @player.equip_armor(armor) ? "Armor equipped." : "Cannot equip armor."
+ else
+ "Missing player or armor."
+ end
+ end
+
+ redirect_to root_path
+ end
+
+ private
+
+ def current_player
+ player_id = session[:player_id]
+ return unless player_id
+
+ Player.find_by(id: player_id)
+ end
+
+ def player_name
+ name = params[:name].to_s.strip
+ name.empty? ? "Hero" : name
+ end
+
+ def player_level
+ 1
+ end
+
+ def battle_message(result)
+ return "No player." if result.nil?
+ return "Win! Level up." if result[:ok] && result[:leveled_up]
+ return "Win! Rewards #{result[:rewards].size}" if result[:ok]
+
+ "Lost: #{result[:reason]}"
+ end
+end
diff --git a/examples/rails_sample/app/models/application_master.rb b/examples/rails_sample/app/models/application_master.rb
new file mode 100644
index 0000000..26af50c
--- /dev/null
+++ b/examples/rails_sample/app/models/application_master.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class ApplicationMaster < SimpleMaster::Master
+ self.abstract_class = true
+
+ def self.validate_all_records
+ Thread.current[:errors] = {}
+
+ classes = descendants.reject(&:abstract_class).select(&:base_class?)
+ classes.each do |klass|
+ klass.all.each(&:valid?)
+ end
+
+ Thread.current[:errors]
+ ensure
+ Thread.current[:errors] = {}
+ end
+end
diff --git a/examples/rails_sample/app/models/application_record.rb b/examples/rails_sample/app/models/application_record.rb
new file mode 100644
index 0000000..9ada3ce
--- /dev/null
+++ b/examples/rails_sample/app/models/application_record.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class ApplicationRecord < ActiveRecord::Base
+ self.abstract_class = true
+ include SimpleMaster::ActiveRecord::Extension
+end
diff --git a/examples/rails_sample/app/models/armor.rb b/examples/rails_sample/app/models/armor.rb
new file mode 100644
index 0000000..93d89fd
--- /dev/null
+++ b/examples/rails_sample/app/models/armor.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class Armor < ApplicationMaster
+ include ItemReceivable
+
+ def_column :id
+ def_column :icon, type: :string
+ def_column :name, type: :string
+ def_column :defence, type: :float
+
+ validates :name, presence: true
+ validates :defence, numericality: { greater_than_or_equal_to: 0 }
+
+ def self.max_quantity
+ 1
+ end
+end
diff --git a/examples/rails_sample/app/models/concerns/item_receivable.rb b/examples/rails_sample/app/models/concerns/item_receivable.rb
new file mode 100644
index 0000000..7f1a57e
--- /dev/null
+++ b/examples/rails_sample/app/models/concerns/item_receivable.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module ItemReceivable
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def max_quantity
+ nil
+ end
+ end
+
+ included do
+ cache_class_method def self.receivable_sources
+ sources = {}
+ Reward.all.each do |reward|
+ reward_item = reward.reward
+
+ klass = reward_item.class
+ while klass <= self
+ array = sources.fetch(reward_item.id) { sources[reward_item.id] = [] }
+ array << reward.enemy
+
+ klass = klass.superclass
+ end
+ end
+
+ sources.each_value do |array|
+ array.uniq!
+ array.freeze
+ end
+
+ sources
+ end
+
+ # This is an example. `self.class.receivable_sources[id]` may work better here.
+ cache_method def receivable_sources
+ self.class.receivable_sources.fetch(id) { [] }
+ end
+ end
+
+ def self.receivable_item?
+ true
+ end
+end
diff --git a/examples/rails_sample/app/models/enemy.rb b/examples/rails_sample/app/models/enemy.rb
new file mode 100644
index 0000000..29dcbac
--- /dev/null
+++ b/examples/rails_sample/app/models/enemy.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+class Enemy < ApplicationMaster
+ def_column :id
+ def_column :name, type: :string
+ def_column :is_boss, type: :boolean
+ def_column :start_at, type: :time
+ def_column :end_at, type: :time, group_key: true
+ def_column :attack, type: :float
+ def_column :defence, type: :float
+ def_column :hp, type: :float
+ def_column :exp, type: :integer
+ def_column :stamina_cost, type: :integer
+
+ has_many :rewards
+
+ validates :name, presence: true
+ validates :attack, :defence, :hp, numericality: { greater_than_or_equal_to: 0 }
+ validates :exp, :stamina_cost, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ validate :end_after_start_at
+
+ cache_class_method def self.sorted_end_ats
+ # end_atを降順でソートした配列
+ [nil, *grouped_hash[:end_at].keys.compact.sort!.reverse!]
+ end
+
+ def self.not_ended(time = Time.current)
+ sorted_end_ats
+ .take_while { |end_at| end_at.nil? || end_at > time }
+ .flat_map { |end_at| all_by(:end_at, end_at) }
+ end
+
+ def self.available_at(time = Time.current)
+ not_ended(time).filter { _1.has_started?(time) }
+ end
+
+ def has_started?(time = Time.current)
+ start_at.nil? || start_at <= time
+ end
+
+ def not_ended?(time = Time.current)
+ end_at.nil? || end_at > time
+ end
+
+ def available?(time = Time.current)
+ has_started?(time) && not_ended?(time)
+ end
+
+ private
+
+ def end_after_start_at
+ return if start_at.nil? || end_at.nil?
+ return if end_at > start_at
+
+ errors.add(:end_at, :invalid)
+ end
+end
diff --git a/examples/rails_sample/app/models/level.rb b/examples/rails_sample/app/models/level.rb
new file mode 100644
index 0000000..5a9406d
--- /dev/null
+++ b/examples/rails_sample/app/models/level.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+class Level < ApplicationMaster
+ def_column :id
+ def_column :lv, type: :integer, group_key: true
+ def_column :attack, type: :float
+ def_column :defence, type: :float
+ def_column :hp, type: :float
+ def_column :next_exp, type: :integer
+ def_column :hp_recovery_sec, type: :integer
+ def_column :stamina, type: :integer
+ def_column :stamina_recovery_sec, type: :integer
+
+ has_many :players, foreign_key: :lv, primary_key: :lv
+
+ validates :lv, presence: true, numericality: { only_integer: true, greater_than: 0 }
+ validates :attack, :defence, :hp, numericality: { greater_than_or_equal_to: 0 }
+ validates :next_exp, :hp_recovery_sec, :stamina, :stamina_recovery_sec,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+
+ cache_class_method def self.max_lv
+ lvs = all.filter_map(&:lv)
+ lvs.max || 0
+ end
+
+ def self.find_by_lv(lv)
+ find_by(:lv, lv)
+ end
+end
diff --git a/examples/rails_sample/app/models/player.rb b/examples/rails_sample/app/models/player.rb
new file mode 100644
index 0000000..ef734c0
--- /dev/null
+++ b/examples/rails_sample/app/models/player.rb
@@ -0,0 +1,251 @@
+# frozen_string_literal: true
+
+class Player < ApplicationRecord
+ belongs_to :level, foreign_key: :lv, primary_key: :lv
+ belongs_to :weapon
+ belongs_to :armor
+ has_many :player_items
+ has_many :items, through: :player_items
+
+ def items
+ player_items.flat_map do |player_item|
+ quantity = player_item.quantity.to_i
+ next [] if quantity <= 0
+ next [] unless player_item.item
+
+ Array.new(quantity, player_item.item)
+ end
+ end
+
+ def equip_weapon(weapon)
+ return false unless weapon.is_a?(Weapon)
+
+ record = find_item_record(weapon)
+ return false unless record&.quantity.to_i.positive?
+
+ update!(weapon_id: weapon.id)
+ true
+ end
+
+ def equip_armor(armor)
+ return false unless armor.is_a?(Armor)
+
+ record = find_item_record(armor)
+ return false unless record&.quantity.to_i.positive?
+
+ update!(armor_id: armor.id)
+ true
+ end
+
+ def attack_power
+ base = level.attack
+ bonus = (weapon&.attack || 0).to_f
+ base + bonus
+ end
+
+ def defence_power
+ base = level.defence
+ bonus = (armor&.defence || 0).to_f
+ base + bonus
+ end
+
+ def challenge_enemy(enemy, at: Time.current)
+ return { ok: false, reason: :out_of_period } unless enemy.available?(at)
+ player_attack = attack_power
+ return { ok: false, reason: :attack_too_low } if player_attack <= enemy.defence.to_f
+
+ cost = stamina_cost_for(enemy)
+ return { ok: false, reason: :not_enough_stamina } unless consume_stamina(cost, at: at)
+
+ apply_hp_regen!(at)
+
+ player_hp = hp.to_f
+ enemy_hp = enemy.hp.to_f
+ player_damage = scaled_damage(player_attack, enemy.defence.to_f)
+ enemy_damage = scaled_damage(enemy.attack.to_f, defence_power)
+
+ while enemy_hp > 0 && player_hp > 0
+ enemy_hp -= player_damage
+ break if enemy_hp <= 0
+
+ player_hp -= enemy_damage
+ end
+
+ self.hp = [player_hp, 0.0].max
+ self.hp_updated_at = at
+
+ if enemy_hp > 0
+ save!
+ return { ok: false, reason: :defeated }
+ end
+
+ gain_exp(enemy.exp.to_i)
+ rewards = receive_rewards(enemy)
+ leveled_up = consume_exp_for_level_up(at: at)
+ cap_resources!(at)
+ save!
+
+ { ok: true, leveled_up: leveled_up, rewards: rewards }
+ end
+
+ def use_potion(potion, at: Time.current)
+ return false unless potion
+ item_record = find_item_record(potion)
+ return false unless item_record
+
+ apply_hp_regen!(at)
+ heal_amount = potion.hp.to_f
+ return false if heal_amount <= 0
+
+ self.hp = [hp.to_f + heal_amount, max_hp].min
+ self.hp_updated_at = at
+ if item_record.quantity.to_i > 1
+ item_record.update!(quantity: item_record.quantity.to_i - 1)
+ else
+ item_record.destroy!
+ end
+ save!
+ true
+ end
+
+ def current_hp(at: Time.current)
+ max = max_hp
+ return 0.0 if max <= 0.0
+
+ base = hp.nil? ? max : hp
+ last = hp_updated_at || at
+ recovered = recovered_amount(base, max, last, at, level.hp_recovery_sec)
+
+ [base + recovered, max].min
+ end
+
+ def current_stamina(at: Time.current)
+ max = max_stamina
+ return 0 if max <= 0
+
+ base = stamina.nil? ? max : stamina
+ last = stamina_updated_at || at
+ recovered = recovered_amount(base, max, last, at, level.stamina_recovery_sec)
+
+ [(base + recovered).to_i, max].min
+ end
+
+ def max_hp
+ level.hp
+ end
+
+ def max_stamina
+ level.stamina
+ end
+
+ def self.find_by_lv(lv)
+ find_by(lv: lv)
+ end
+
+ private
+
+ def scaled_damage(attack, defence)
+ return 0.0 if attack.to_f <= 0.0
+
+ attack.to_f * 100.0 / (100.0 + defence.to_f)
+ end
+
+ def stamina_cost_for(enemy)
+ [enemy.stamina_cost.to_i, 1].max
+ end
+
+ def apply_hp_regen!(at)
+ self.hp = current_hp(at: at)
+ self.hp_updated_at = at
+ end
+
+ def apply_stamina_regen!(at)
+ self.stamina = current_stamina(at: at)
+ self.stamina_updated_at = at
+ end
+
+ def recovered_amount(base, max, from_time, to_time, recovery_sec)
+ return 0.0 if max <= base
+ interval = recovery_sec.to_i
+ return 0.0 if interval <= 0
+
+ elapsed = (to_time - from_time).to_i
+ return 0.0 if elapsed <= 0
+
+ steps = elapsed / interval
+ [steps.to_f, max - base].min
+ end
+
+ def consume_stamina(cost, at:)
+ apply_stamina_regen!(at)
+ return false if stamina.to_i < cost
+
+ self.stamina = stamina.to_i - cost
+ self.stamina_updated_at = at
+ true
+ end
+
+ def gain_exp(amount)
+ self.exp = exp.to_i + amount
+ end
+
+ def consume_exp_for_level_up(at:)
+ leveled_up = false
+
+ loop do
+ current_level = level
+ required = current_level.next_exp.to_i
+ break if required <= 0
+ break if exp.to_i < required
+
+ next_level = Level.find_by(:lv, lv + 1)
+ break unless next_level
+
+ self.exp = exp.to_i - required
+ self.lv = next_level.lv
+ leveled_up = true
+ end
+
+ if leveled_up
+ self.stamina = max_stamina
+ self.stamina_updated_at = at
+ end
+
+ leveled_up
+ end
+
+ def cap_resources!(at)
+ apply_hp_regen!(at)
+ apply_stamina_regen!(at)
+
+ self.hp = [hp.to_f, max_hp].min if max_hp.positive?
+ self.stamina = [stamina.to_i, max_stamina].min if max_stamina.positive?
+ end
+
+ def receive_rewards(enemy)
+ enemy.rewards.filter_map do |reward|
+ next if reward.reward_type.nil? || reward.reward_id.nil?
+
+ add_item(reward.reward_type, reward.reward_id)
+ end
+ end
+
+ def find_item_record(item)
+ player_items.find_by(item: item)
+ end
+
+ def add_item(item_type, item_id, amount = 1)
+ item_class = item_type.safe_constantize
+ unless item_class && item_class <= ItemReceivable
+ fail ArgumentError, "Unsupported item_type: #{item_type}"
+ end
+
+ record = player_items.find_or_initialize_by(item_type: item_type, item_id: item_id)
+ max_quantity = item_class.max_quantity
+ quantity = record.quantity.to_i + amount
+ quantity = [quantity, max_quantity].min if max_quantity
+ record.quantity = quantity
+ record.save!
+ record
+ end
+end
diff --git a/examples/rails_sample/app/models/player_item.rb b/examples/rails_sample/app/models/player_item.rb
new file mode 100644
index 0000000..b4e058d
--- /dev/null
+++ b/examples/rails_sample/app/models/player_item.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class PlayerItem < ApplicationRecord
+ belongs_to :player
+ belongs_to :item, polymorphic: true
+end
diff --git a/examples/rails_sample/app/models/potion.rb b/examples/rails_sample/app/models/potion.rb
new file mode 100644
index 0000000..8c05e1f
--- /dev/null
+++ b/examples/rails_sample/app/models/potion.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class Potion < ApplicationMaster
+ include ItemReceivable
+
+ def_column :id
+ def_column :name
+ def_column :hp, type: :float
+
+ globalize :name
+
+ validates :name, presence: true
+ validates :hp, numericality: { greater_than_or_equal_to: 0 }
+end
diff --git a/examples/rails_sample/app/models/reward.rb b/examples/rails_sample/app/models/reward.rb
new file mode 100644
index 0000000..8df4981
--- /dev/null
+++ b/examples/rails_sample/app/models/reward.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class Reward < ApplicationMaster
+ def_column :id
+ def_column :enemy_id, type: :integer, group_key: true
+ def_column :reward_type, polymorphic_type: true, group_key: true
+ def_column :reward_id, type: :integer
+
+ belongs_to :enemy
+ belongs_to :reward, polymorphic: true
+
+ validates :reward_type, presence: true
+ validate :reward_type_receivable
+
+ private
+
+ def reward_type_receivable
+ klass = reward_type.safe_constantize
+ return if klass && klass <= ItemReceivable
+
+ errors.add(:reward_type, :invalid)
+ end
+end
diff --git a/examples/rails_sample/app/models/weapon.rb b/examples/rails_sample/app/models/weapon.rb
new file mode 100644
index 0000000..7a769bd
--- /dev/null
+++ b/examples/rails_sample/app/models/weapon.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+class Weapon < ApplicationMaster
+ include ItemReceivable
+
+ RARITY = {
+ common: 0,
+ rare: 1,
+ epic: 2,
+ }.freeze
+
+ def_column :id
+ def_column :type, sti: true
+ def_column :icon, type: :string
+ def_column :name, type: :string
+ def_column :attack, type: :float
+ def_column :info, type: :json, symbolize_names: true
+ def_column :metadata, type: :json, symbolize_names: false
+ def_column :rarity, type: :integer
+ def_column :flags, type: :integer
+
+ globalize :name
+
+ enum :rarity, RARITY
+ bitmask :flags, as: [:tradeable, :soulbound, :limited]
+
+ validates :name, presence: true
+ validates :attack, numericality: { greater_than_or_equal_to: 0 }
+ validates :rarity, inclusion: { in: RARITY.keys }
+
+ cache_method def cached_signature
+ "#{name}-#{rarity}"
+ end
+
+ def self.max_quantity
+ 1
+ end
+end
+
+class Gun < Weapon
+end
+
+class Blade < Weapon
+end
diff --git a/examples/rails_sample/app/views/game/show.html.erb b/examples/rails_sample/app/views/game/show.html.erb
new file mode 100644
index 0000000..0bd9e25
--- /dev/null
+++ b/examples/rails_sample/app/views/game/show.html.erb
@@ -0,0 +1,340 @@
+
+
+
+
+
+
+
+
+
Player
+ <% if @player %>
+ <% equipped_weapon = @player.weapon %>
+ <% equipped_armor = @player.armor %>
+ <% weapon_icon = equipped_weapon&.icon.to_s.strip %>
+ <% armor_icon = equipped_armor&.icon.to_s.strip %>
+
Name<%= @player.name %>
+
LevelLv <%= @player.lv %> (EXP <%= @player.exp %>)
+
ATK / DEF<%= @player.attack_power %> / <%= @player.defence_power %>
+
+ Weapon
+
+ <% if equipped_weapon %>
+ <% if weapon_icon != "" %><% end %>
+ <%= equipped_weapon.name %> (ATK +<%= equipped_weapon.attack.to_f %>)
+ <% else %>
+ none
+ <% end %>
+
+
+
+ Armor
+
+ <% if equipped_armor %>
+ <% if armor_icon != "" %><% end %>
+ <%= equipped_armor.name %> (DEF +<%= equipped_armor.defence.to_f %>)
+ <% else %>
+ none
+ <% end %>
+
+
+ <% hp_now = @player.current_hp(at: @now).round(1) %>
+ <% hp_max = @player.max_hp %>
+ <% hp_rate = hp_max.to_f.zero? ? 0 : ((hp_now / hp_max.to_f) * 100).round %>
+
HP<%= hp_now %> / <%= hp_max %>
+
+ <% st_now = @player.current_stamina(at: @now) %>
+ <% st_max = @player.max_stamina %>
+ <% st_rate = st_max.to_i.zero? ? 0 : ((st_now.to_f / st_max.to_f) * 100).round %>
+
Stamina<%= st_now %> / <%= st_max %>
+
+
Items:
+ <% if @player.player_items.any? %>
+ <% equipment_items = @player.player_items.select { |pi| pi.item.is_a?(Weapon) || pi.item.is_a?(Armor) } %>
+ <% potion_items = @player.player_items.select { |pi| pi.item.is_a?(Potion) } %>
+ <% other_items = @player.player_items.reject { |pi| pi.item.is_a?(Weapon) || pi.item.is_a?(Armor) || pi.item.is_a?(Potion) } %>
+
+
+ <% if equipment_items.any? %>
+
Equipment
+ <% equipment_items.each do |player_item| %>
+ <% item = player_item.item %>
+ <% next unless item %>
+ <% icon_label = item.respond_to?(:icon) ? item.icon.to_s.strip : "" %>
+
+ <% if icon_label != "" %>
+
+ <% end %>
+ <%= item.name %>
+ x<%= player_item.quantity %>
+ <% if item.is_a?(Weapon) %>
+ ATK +<%= item.attack.to_f %>
+ <% if @player.weapon_id == item.id %>
+ Equipped
+ <% else %>
+
+ <% end %>
+ <% elsif item.is_a?(Armor) %>
+ DEF +<%= item.defence.to_f %>
+ <% if @player.armor_id == item.id %>
+ Equipped
+ <% else %>
+
+ <% end %>
+ <% end %>
+
+ <% end %>
+ <% end %>
+
+ <% if potion_items.any? %>
+
Potions
+ <% potion_items.each do |player_item| %>
+ <% item = player_item.item %>
+ <% next unless item %>
+
+ <%= item.name %>
+ x<%= player_item.quantity %>
+ Potion +<%= item.hp %> HP
+
+
+ <% end %>
+ <% end %>
+
+ <% if other_items.any? %>
+
Other
+ <% other_items.each do |player_item| %>
+ <% item = player_item.item %>
+ <% next unless item %>
+ <% icon_label = item.respond_to?(:icon) ? item.icon.to_s.strip : "" %>
+
+ <% if icon_label != "" %>
+
+ <% end %>
+ <%= item.name %>
+ x<%= player_item.quantity %>
+
+ <% end %>
+ <% end %>
+
+ <% else %>
+
none
+ <% end %>
+ <% else %>
+
No player yet.
+ <% end %>
+
+
+
+
Enemies
+
+ <% @enemies.each do |enemy| %>
+
+
<%= enemy.name %>
+ <% if enemy.is_boss? %>
+
BOSS
+ <% end %>
+
EXP <%= enemy.exp.to_i %>
+
ATK <%= enemy.attack %> / DEF <%= enemy.defence %> / HP <%= enemy.hp %> / Stamina <%= enemy.stamina_cost.to_i %>
+ <% if enemy.rewards.any? %>
+
Rewards: <%= enemy.rewards.map { |reward| reward.reward&.name }.compact.join(", ") %>
+ <% end %>
+ <% if @player %>
+
+ <% else %>
+
Create a player to fight.
+ <% end %>
+
+ <% end %>
+
+
+
+
+
diff --git a/examples/rails_sample/bin/rails b/examples/rails_sample/bin/rails
new file mode 100755
index 0000000..22f2d8d
--- /dev/null
+++ b/examples/rails_sample/bin/rails
@@ -0,0 +1,6 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+APP_PATH = File.expand_path("../config/application", __dir__)
+require_relative "../config/boot"
+require "rails/commands"
diff --git a/examples/rails_sample/config.ru b/examples/rails_sample/config.ru
new file mode 100644
index 0000000..7e804ae
--- /dev/null
+++ b/examples/rails_sample/config.ru
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+require_relative "config/environment"
+
+run Rails.application
diff --git a/examples/rails_sample/config/application.rb b/examples/rails_sample/config/application.rb
new file mode 100644
index 0000000..ec826dc
--- /dev/null
+++ b/examples/rails_sample/config/application.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require_relative "boot"
+
+require "rails"
+require "active_record/railtie"
+require "action_controller/railtie"
+require "simple_master"
+
+module Dummy
+ class Application < Rails::Application
+ config.root = File.expand_path("..", __dir__)
+ config.eager_load = false
+ config.logger = Logger.new($stdout)
+ config.logger.level = Logger::WARN
+ config.active_support.test_order = :random
+ config.hosts = nil
+ config.autoload_paths << Rails.root.join("lib")
+ end
+end
diff --git a/examples/rails_sample/config/boot.rb b/examples/rails_sample/config/boot.rb
new file mode 100644
index 0000000..5b865f6
--- /dev/null
+++ b/examples/rails_sample/config/boot.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", __dir__)
+
+require "bundler/setup"
diff --git a/examples/rails_sample/config/database.yml b/examples/rails_sample/config/database.yml
new file mode 100644
index 0000000..7e9c547
--- /dev/null
+++ b/examples/rails_sample/config/database.yml
@@ -0,0 +1,16 @@
+default: &default
+ adapter: sqlite3
+ pool: 5
+ timeout: 5000
+
+development:
+ <<: *default
+ database: db/development.sqlite3
+
+test:
+ <<: *default
+ database: ":memory:"
+
+production:
+ <<: *default
+ database: db/production.sqlite3
diff --git a/examples/rails_sample/config/environment.rb b/examples/rails_sample/config/environment.rb
new file mode 100644
index 0000000..fe49ece
--- /dev/null
+++ b/examples/rails_sample/config/environment.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+require_relative "application"
+
+Dummy::Application.initialize!
diff --git a/examples/rails_sample/config/initializers/simple_master.rb b/examples/rails_sample/config/initializers/simple_master.rb
new file mode 100644
index 0000000..40df7f8
--- /dev/null
+++ b/examples/rails_sample/config/initializers/simple_master.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require "json_loader"
+require "yaml"
+
+Rails.application.config.after_initialize do
+ Rails.application.eager_load!
+
+ SimpleMaster.init(for_test: Rails.env.test?)
+
+ translations =
+ begin
+ YAML.load_file(Rails.root.join("fixtures/translations.yml")) || {}
+ rescue Errno::ENOENT
+ {}
+ end
+
+ globalize_proc = lambda do |klass, records|
+ table_translation = translations[klass.table_name]
+ column_names = klass.all_columns.filter_map { |column| column.name.to_s if column.options[:globalize] }
+ return records if table_translation.blank? || column_names.empty?
+
+ records.map do |record|
+ record_translation = table_translation[record.id]
+ next record unless record_translation
+
+ record = record.dup if record.frozen?
+ column_names.each do |column_name|
+ translation = record_translation[column_name]
+ next unless translation
+
+ record.public_send(:"_globalized_#{column_name}=", translation.symbolize_keys)
+ end
+ record
+ end
+ end
+
+ loader = JsonLoader.new(globalize_proc: globalize_proc)
+ $current_dataset = SimpleMaster::Storage::Dataset.new(loader: loader)
+ $current_dataset.load
+end
diff --git a/examples/rails_sample/config/routes.rb b/examples/rails_sample/config/routes.rb
new file mode 100644
index 0000000..0184269
--- /dev/null
+++ b/examples/rails_sample/config/routes.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+Rails.application.routes.draw do
+ root "game#show"
+ post "/play", to: "game#play"
+end
diff --git a/examples/rails_sample/db/schema.rb b/examples/rails_sample/db/schema.rb
new file mode 100644
index 0000000..b3e0de4
--- /dev/null
+++ b/examples/rails_sample/db/schema.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+ActiveRecord::Schema.define do
+ create_table :weapons, force: true do |t|
+ t.string :type
+ t.string :icon
+ t.string :name
+ t.float :attack
+ t.json :info
+ t.json :metadata
+ t.integer :rarity
+ t.integer :flags
+ end
+
+ create_table :armors, force: true do |t|
+ t.string :icon
+ t.string :name
+ t.float :defence
+ end
+
+ create_table :potions, force: true do |t|
+ t.string :name
+ t.float :hp
+ end
+
+ create_table :levels, force: true do |t|
+ t.integer :lv
+ t.float :attack
+ t.float :defence
+ t.float :hp
+ t.integer :next_exp
+ t.integer :hp_recovery_sec
+ t.integer :stamina
+ t.integer :stamina_recovery_sec
+ end
+
+ create_table :enemies, force: true do |t|
+ t.string :name
+ t.boolean :is_boss
+ t.datetime :start_at
+ t.datetime :end_at
+ t.float :attack
+ t.float :defence
+ t.float :hp
+ t.integer :exp
+ t.integer :stamina_cost
+ end
+
+ create_table :rewards, force: true do |t|
+ t.integer :enemy_id
+ t.string :reward_type
+ t.integer :reward_id
+ end
+
+ create_table :players, force: true do |t|
+ t.string :name, null: false
+ t.integer :lv, null: false
+ t.integer :exp, null: false, default: 0
+ t.integer :weapon_id
+ t.integer :armor_id
+ t.float :hp
+ t.datetime :hp_updated_at
+ t.integer :stamina
+ t.datetime :stamina_updated_at
+ end
+
+ create_table :player_items, force: true do |t|
+ t.integer :player_id, null: false
+ t.string :item_type, null: false
+ t.integer :item_id, null: false
+ t.integer :quantity, null: false, default: 0
+ end
+end
diff --git a/examples/rails_sample/fixtures/masters/armors.json b/examples/rails_sample/fixtures/masters/armors.json
new file mode 100644
index 0000000..a9a6787
--- /dev/null
+++ b/examples/rails_sample/fixtures/masters/armors.json
@@ -0,0 +1,5 @@
+[
+ { "id": 1, "icon": "fa-solid fa-shirt", "name": "Leather Vest", "defence": 5.0 },
+ { "id": 2, "icon": "fa-solid fa-shield-halved", "name": "Chain Mail", "defence": 12.5 },
+ { "id": 3, "icon": "fa-solid fa-shield", "name": "Dragon Plate", "defence": 28.0 }
+]
diff --git a/examples/rails_sample/fixtures/masters/enemies.json b/examples/rails_sample/fixtures/masters/enemies.json
new file mode 100644
index 0000000..dc62ea2
--- /dev/null
+++ b/examples/rails_sample/fixtures/masters/enemies.json
@@ -0,0 +1,122 @@
+[
+ {
+ "id": 1,
+ "name": "Goblin Scout",
+ "is_boss": false,
+ "start_at": "2024-05-01T10:00:00Z",
+ "end_at": "2024-05-01T18:00:00Z",
+ "attack": 5.5,
+ "defence": 2.5,
+ "hp": 18.0,
+ "exp": 8,
+ "stamina_cost": 2
+ },
+ {
+ "id": 2,
+ "name": "Ogre Chief",
+ "is_boss": true,
+ "start_at": "2024-05-02T12:00:00Z",
+ "end_at": "2024-05-02T23:00:00Z",
+ "attack": 14.0,
+ "defence": 8.5,
+ "hp": 52.0,
+ "exp": 26,
+ "stamina_cost": 6
+ },
+ {
+ "id": 3,
+ "name": "Clockwork Rat",
+ "is_boss": false,
+ "start_at": null,
+ "end_at": null,
+ "attack": 3.5,
+ "defence": 1.5,
+ "hp": 12.0,
+ "exp": 5,
+ "stamina_cost": 1
+ },
+ {
+ "id": 4,
+ "name": "Sand Wyrm",
+ "is_boss": false,
+ "start_at": "2020-01-01T00:00:00Z",
+ "end_at": "2030-01-01T00:00:00Z",
+ "attack": 8.5,
+ "defence": 4.0,
+ "hp": 30.0,
+ "exp": 14,
+ "stamina_cost": 3
+ },
+ {
+ "id": 5,
+ "name": "Frost Knight",
+ "is_boss": false,
+ "start_at": null,
+ "end_at": null,
+ "attack": 10.5,
+ "defence": 6.5,
+ "hp": 38.0,
+ "exp": 18,
+ "stamina_cost": 4
+ },
+ {
+ "id": 6,
+ "name": "Moss Golem",
+ "is_boss": false,
+ "start_at": null,
+ "end_at": null,
+ "attack": 7.0,
+ "defence": 4.5,
+ "hp": 26.0,
+ "exp": 12,
+ "stamina_cost": 3
+ },
+ {
+ "id": 7,
+ "name": "Sky Harrier",
+ "is_boss": false,
+ "start_at": "2020-01-01T00:00:00Z",
+ "end_at": "2030-01-01T00:00:00Z",
+ "attack": 4.5,
+ "defence": 2.0,
+ "hp": 14.0,
+ "exp": 6,
+ "stamina_cost": 2
+ },
+ {
+ "id": 8,
+ "name": "Iron Crab",
+ "is_boss": false,
+ "start_at": null,
+ "end_at": null,
+ "attack": 7.5,
+ "defence": 6.0,
+ "hp": 28.0,
+ "exp": 13,
+ "stamina_cost": 3
+ },
+ {
+ "id": 9,
+ "name": "Nightshade",
+ "is_boss": true,
+ "start_at": "2020-01-01T00:00:00Z",
+ "end_at": "2030-01-01T00:00:00Z",
+ "attack": 12.0,
+ "defence": 7.5,
+ "hp": 45.0,
+ "exp": 24,
+ "stamina_cost": 5
+ },
+ {
+ "id": 10,
+ "name": "Ancient Dragon",
+ "is_boss": true,
+ "start_at": null,
+ "end_at": null,
+ "attack": 34.0,
+ "defence": 59.0,
+ "hp": 180.0,
+ "exp": 80,
+ "stamina_cost": 10
+ }
+]
diff --git a/examples/rails_sample/fixtures/masters/levels.json b/examples/rails_sample/fixtures/masters/levels.json
new file mode 100644
index 0000000..05557dd
--- /dev/null
+++ b/examples/rails_sample/fixtures/masters/levels.json
@@ -0,0 +1,112 @@
+[
+ {
+ "id": 1,
+ "lv": 1,
+ "attack": 5.0,
+ "defence": 2.0,
+ "hp": 18.0,
+ "next_exp": 12,
+ "hp_recovery_sec": 55,
+ "stamina": 12,
+ "stamina_recovery_sec": 28
+ },
+ {
+ "id": 2,
+ "lv": 2,
+ "attack": 6.5,
+ "defence": 3.0,
+ "hp": 24.0,
+ "next_exp": 24,
+ "hp_recovery_sec": 50,
+ "stamina": 14,
+ "stamina_recovery_sec": 24
+ },
+ {
+ "id": 3,
+ "lv": 3,
+ "attack": 8.5,
+ "defence": 4.5,
+ "hp": 32.0,
+ "next_exp": 40,
+ "hp_recovery_sec": 45,
+ "stamina": 16,
+ "stamina_recovery_sec": 22
+ },
+ {
+ "id": 4,
+ "lv": 4,
+ "attack": 11.0,
+ "defence": 6.0,
+ "hp": 40.0,
+ "next_exp": 60,
+ "hp_recovery_sec": 40,
+ "stamina": 18,
+ "stamina_recovery_sec": 20
+ },
+ {
+ "id": 5,
+ "lv": 5,
+ "attack": 13.5,
+ "defence": 7.5,
+ "hp": 48.0,
+ "next_exp": 85,
+ "hp_recovery_sec": 36,
+ "stamina": 21,
+ "stamina_recovery_sec": 18
+ },
+ {
+ "id": 6,
+ "lv": 6,
+ "attack": 16.0,
+ "defence": 9.0,
+ "hp": 58.0,
+ "next_exp": 120,
+ "hp_recovery_sec": 32,
+ "stamina": 24,
+ "stamina_recovery_sec": 16
+ },
+ {
+ "id": 7,
+ "lv": 7,
+ "attack": 18.5,
+ "defence": 10.5,
+ "hp": 68.0,
+ "next_exp": 160,
+ "hp_recovery_sec": 28,
+ "stamina": 27,
+ "stamina_recovery_sec": 14
+ },
+ {
+ "id": 8,
+ "lv": 8,
+ "attack": 21.0,
+ "defence": 12.0,
+ "hp": 79.0,
+ "next_exp": 210,
+ "hp_recovery_sec": 24,
+ "stamina": 30,
+ "stamina_recovery_sec": 12
+ },
+ {
+ "id": 9,
+ "lv": 9,
+ "attack": 24.0,
+ "defence": 13.5,
+ "hp": 90.0,
+ "next_exp": 270,
+ "hp_recovery_sec": 21,
+ "stamina": 33,
+ "stamina_recovery_sec": 10
+ },
+ {
+ "id": 10,
+ "lv": 10,
+ "attack": 27.0,
+ "defence": 15.5,
+ "hp": 104.0,
+ "next_exp": 0,
+ "hp_recovery_sec": 18,
+ "stamina": 36,
+ "stamina_recovery_sec": 9
+ }
+]
diff --git a/examples/rails_sample/fixtures/masters/potions.json b/examples/rails_sample/fixtures/masters/potions.json
new file mode 100644
index 0000000..a5d73c0
--- /dev/null
+++ b/examples/rails_sample/fixtures/masters/potions.json
@@ -0,0 +1,20 @@
+[
+ {
+ "id": 1,
+ "name": "Minor Heal",
+ "_globalized_name": "{\"en\":\"Minor Heal\",\"ja\":\"マイナーヒール\"}",
+ "hp": 25.0
+ },
+ {
+ "id": 2,
+ "name": "Major Heal",
+ "_globalized_name": "{\"en\":\"Major Heal\",\"ja\":\"メジャーヒール\"}",
+ "hp": 60.0
+ },
+ {
+ "id": 3,
+ "name": "Elixir",
+ "_globalized_name": "{\"en\":\"Elixir\",\"ja\":\"エリクサー\"}",
+ "hp": 120.0
+ }
+]
diff --git a/examples/rails_sample/fixtures/masters/rewards.json b/examples/rails_sample/fixtures/masters/rewards.json
new file mode 100644
index 0000000..54ebbab
--- /dev/null
+++ b/examples/rails_sample/fixtures/masters/rewards.json
@@ -0,0 +1,16 @@
+[
+ { "id": 1, "enemy_id": 1, "reward_type": "Weapon", "reward_id": 1 },
+ { "id": 2, "enemy_id": 1, "reward_type": "Potion", "reward_id": 1 },
+ { "id": 3, "enemy_id": 2, "reward_type": "Potion", "reward_id": 2 },
+ { "id": 4, "enemy_id": 2, "reward_type": "Weapon", "reward_id": 3 },
+ { "id": 5, "enemy_id": 3, "reward_type": "Potion", "reward_id": 1 },
+ { "id": 6, "enemy_id": 4, "reward_type": "Weapon", "reward_id": 2 },
+ { "id": 7, "enemy_id": 4, "reward_type": "Armor", "reward_id": 2 },
+ { "id": 8, "enemy_id": 5, "reward_type": "Armor", "reward_id": 3 },
+ { "id": 9, "enemy_id": 6, "reward_type": "Potion", "reward_id": 3 },
+ { "id": 10, "enemy_id": 7, "reward_type": "Armor", "reward_id": 1 },
+ { "id": 11, "enemy_id": 8, "reward_type": "Potion", "reward_id": 2 },
+ { "id": 12, "enemy_id": 9, "reward_type": "Weapon", "reward_id": 3 },
+ { "id": 13, "enemy_id": 10, "reward_type": "Weapon", "reward_id": 3 },
+ { "id": 14, "enemy_id": 10, "reward_type": "Potion", "reward_id": 3 }
+]
diff --git a/examples/rails_sample/fixtures/masters/weapons.json b/examples/rails_sample/fixtures/masters/weapons.json
new file mode 100644
index 0000000..e2e2ede
--- /dev/null
+++ b/examples/rails_sample/fixtures/masters/weapons.json
@@ -0,0 +1,46 @@
+[
+ {
+ "id": 1,
+ "type": "Gun",
+ "icon": "fa-solid fa-gun",
+ "name": "Bronze Pistol",
+ "attack": 12.5,
+ "info": "{\"slots\":1,\"origin\":\"forge\"}",
+ "metadata": "{\"source\":\"archive\",\"tags\":[\"starter\",\"event\"],\"stats\":{\"calibration\":0.92,\"batch\":\"A1\"},\"notes\":\"legacy\"}",
+ "rarity": "common",
+ "flags": 1
+ },
+ {
+ "id": 2,
+ "type": "Blade",
+ "icon": "fa-solid fa-sword",
+ "name": "Silver Saber",
+ "attack": 20.0,
+ "info": "{\"slots\":2,\"element\":\"wind\"}",
+ "metadata": "{\"source\":\"drop\",\"tags\":[\"mid\",\"wind\"],\"tuning\":{\"sharpness\":7,\"balance\":0.4},\"deprecated\":false}",
+ "rarity": "rare",
+ "flags": 3
+ },
+ {
+ "id": 3,
+ "type": "Gun",
+ "icon": "fa-solid fa-crosshairs",
+ "name": "Crimson Rifle",
+ "attack": 35.75,
+ "info": "{\"slots\":3,\"element\":\"fire\"}",
+ "metadata": "{\"source\":\"boss\",\"tags\":[\"rare\",\"fire\"],\"mods\":[{\"name\":\"burst\",\"level\":2},{\"name\":\"overheat\",\"level\":1}],\"extra\":null}",
+ "rarity": "epic",
+ "flags": 6
+ },
+ {
+ "id": 4,
+ "type": "Blade",
+ "icon": "fa-solid fa-bolt",
+ "name": "Storm Edge",
+ "attack": 28.0,
+ "info": "{\"slots\":2,\"element\":\"lightning\"}",
+ "metadata": "{\"source\":\"event\",\"tags\":[\"storm\",\"limited\"],\"tuning\":{\"sharpness\":8,\"balance\":0.6},\"deprecated\":false}",
+ "rarity": "rare",
+ "flags": 5
+ }
+]
diff --git a/examples/rails_sample/fixtures/translations.yml b/examples/rails_sample/fixtures/translations.yml
new file mode 100644
index 0000000..6a690cd
--- /dev/null
+++ b/examples/rails_sample/fixtures/translations.yml
@@ -0,0 +1,17 @@
+weapons:
+ 1:
+ name:
+ en: "Bronze Pistol"
+ ja: "ブロンズピストル"
+ 2:
+ name:
+ en: "Silver Saber"
+ ja: "シルバーセイバー"
+ 3:
+ name:
+ en: "Crimson Rifle"
+ ja: "クリムゾンライフル"
+ 4:
+ name:
+ en: "Storm Edge"
+ ja: "ストームエッジ"
diff --git a/examples/rails_sample/lib/json_loader.rb b/examples/rails_sample/lib/json_loader.rb
new file mode 100644
index 0000000..4777c6f
--- /dev/null
+++ b/examples/rails_sample/lib/json_loader.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require "json"
+
+# Loads master data from JSON files under examples/rails_sample/fixtures/masters/
.json
+class JsonLoader < SimpleMaster::Loader
+ FIXTURE_DIR = File.expand_path("../fixtures/masters", __dir__)
+
+ def read_raw(table)
+ path = File.join(FIXTURE_DIR, "#{table.klass.table_name}.json")
+ File.read(path)
+ end
+
+ def build_records(klass, raw)
+ record_hashes = JSON.parse(raw)
+
+ if klass.sti_class?
+ sti_column_name = klass.sti_column.to_s
+
+ record_hashes.map do |record_hash|
+ sti_klass = record_hash[sti_column_name].constantize
+ sti_klass.new(record_hash)
+ end
+ else
+ record_hashes.map do |record_hash|
+ klass.new(record_hash)
+ end
+ end
+ end
+end
diff --git a/examples/rails_sample/spec/enemy_spec.rb b/examples/rails_sample/spec/enemy_spec.rb
new file mode 100644
index 0000000..fdf499f
--- /dev/null
+++ b/examples/rails_sample/spec/enemy_spec.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require_relative "spec_helper"
+
+RSpec.describe Enemy do
+ describe "availability" do
+ let!(:now) { Time.current }
+ let(:within_window) { now }
+ let(:before_window) { now - 2.hours }
+ let(:after_window) { now + 2.hours }
+ let!(:bounded_enemy) do
+ create(:enemy, name: "Bounded Enemy", start_at: now - 1.hour, end_at: now + 1.hour)
+ end
+ let!(:timeless_enemy) do
+ create(:enemy, name: "Unbounded Enemy", start_at: nil, end_at: nil)
+ end
+
+ it "checks availability per enemy" do
+ expect(bounded_enemy.available?(within_window)).to be(true)
+ expect(bounded_enemy.available?(before_window)).to be(false)
+ expect(bounded_enemy.available?(after_window)).to be(false)
+ expect(timeless_enemy.available?(within_window)).to be(true)
+ expect(timeless_enemy.available?(before_window)).to be(true)
+ expect(timeless_enemy.available?(after_window)).to be(true)
+ end
+
+ it "filters enemies available at the time" do
+ enemies = described_class.available_at(within_window)
+
+ expect(enemies).to include(bounded_enemy, timeless_enemy)
+
+ earlier_enemies = described_class.available_at(before_window)
+
+ expect(earlier_enemies).to include(timeless_enemy)
+ expect(earlier_enemies).not_to include(bounded_enemy)
+
+ later_enemies = described_class.available_at(after_window)
+
+ expect(later_enemies).to include(timeless_enemy)
+ expect(later_enemies).not_to include(bounded_enemy)
+ end
+ end
+end
diff --git a/examples/rails_sample/spec/factories/armor_factory.rb b/examples/rails_sample/spec/factories/armor_factory.rb
new file mode 100644
index 0000000..de72fe9
--- /dev/null
+++ b/examples/rails_sample/spec/factories/armor_factory.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :armor do
+ icon { "fa-solid fa-shield" }
+ name { "Factory Armor" }
+ defence { 8.0 }
+ end
+end
diff --git a/examples/rails_sample/spec/factories/enemy_factory.rb b/examples/rails_sample/spec/factories/enemy_factory.rb
new file mode 100644
index 0000000..da42315
--- /dev/null
+++ b/examples/rails_sample/spec/factories/enemy_factory.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :enemy do
+ name { "Factory Enemy" }
+ is_boss { false }
+ start_at { Time.utc(2024, 5, 1, 10, 0, 0) }
+ end_at { Time.utc(2024, 5, 1, 18, 0, 0) }
+ attack { 6.0 }
+ defence { 3.0 }
+ hp { 18.0 }
+ exp { 8 }
+ stamina_cost { 2 }
+ end
+end
diff --git a/examples/rails_sample/spec/factories/gun_factory.rb b/examples/rails_sample/spec/factories/gun_factory.rb
new file mode 100644
index 0000000..c4f7cec
--- /dev/null
+++ b/examples/rails_sample/spec/factories/gun_factory.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :gun, class: "Gun" do
+ name { "Factory Gun" }
+ attack { 12.0 }
+ info { { slots: 2, origin: "factory" } }
+ metadata { { "source" => "factory", "tags" => ["gun"] } }
+ rarity { :rare }
+ flags { [:tradeable, :limited] }
+ end
+end
diff --git a/examples/rails_sample/spec/factories/level_factory.rb b/examples/rails_sample/spec/factories/level_factory.rb
new file mode 100644
index 0000000..daab7ad
--- /dev/null
+++ b/examples/rails_sample/spec/factories/level_factory.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :level do
+ lv { 1 }
+ attack { 2.0 }
+ defence { 1.0 }
+ hp { 10.0 }
+ next_exp { 10 }
+ hp_recovery_sec { 60 }
+ stamina { 10 }
+ stamina_recovery_sec { 30 }
+ end
+end
diff --git a/examples/rails_sample/spec/factories/player_factory.rb b/examples/rails_sample/spec/factories/player_factory.rb
new file mode 100644
index 0000000..185b8e3
--- /dev/null
+++ b/examples/rails_sample/spec/factories/player_factory.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :player do
+ name { "Factory Player" }
+ lv { 1 }
+ association :level, strategy: :build
+
+ after(:build) do |player, evaluator|
+ player.level = build(:level, lv: evaluator.lv)
+ end
+ end
+end
diff --git a/examples/rails_sample/spec/factories/player_item_factory.rb b/examples/rails_sample/spec/factories/player_item_factory.rb
new file mode 100644
index 0000000..033076d
--- /dev/null
+++ b/examples/rails_sample/spec/factories/player_item_factory.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :player_item do
+ association :player, strategy: :build
+ item_type { "Weapon" }
+ item_id { 1 }
+ quantity { 1 }
+ end
+end
diff --git a/examples/rails_sample/spec/factories/potion_factory.rb b/examples/rails_sample/spec/factories/potion_factory.rb
new file mode 100644
index 0000000..927e4ee
--- /dev/null
+++ b/examples/rails_sample/spec/factories/potion_factory.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :potion do
+ name { "Factory Potion" }
+ hp { 25.0 }
+ end
+end
diff --git a/examples/rails_sample/spec/factories/reward_factory.rb b/examples/rails_sample/spec/factories/reward_factory.rb
new file mode 100644
index 0000000..ae2ebfd
--- /dev/null
+++ b/examples/rails_sample/spec/factories/reward_factory.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :reward do
+ enemy_id { 1 }
+ association :enemy, strategy: :build
+ association :reward, factory: :weapon, strategy: :build
+ end
+end
diff --git a/examples/rails_sample/spec/factories/weapon_factory.rb b/examples/rails_sample/spec/factories/weapon_factory.rb
new file mode 100644
index 0000000..6591c23
--- /dev/null
+++ b/examples/rails_sample/spec/factories/weapon_factory.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :weapon do
+ type { "Gun" }
+ icon { "fa-solid fa-gun" }
+ name { "Factory Weapon" }
+ attack { 10.0 }
+ info { { slots: 1, origin: "factory" } }
+ metadata { { "source" => "factory", "tags" => ["default"] } }
+ rarity { :common }
+ flags { [:tradeable] }
+ end
+end
diff --git a/examples/rails_sample/spec/player_spec.rb b/examples/rails_sample/spec/player_spec.rb
new file mode 100644
index 0000000..32317f8
--- /dev/null
+++ b/examples/rails_sample/spec/player_spec.rb
@@ -0,0 +1,126 @@
+# frozen_string_literal: true
+
+require_relative "spec_helper"
+
+RSpec.describe Player do
+ before do
+ PlayerItem.delete_all
+ described_class.delete_all
+ end
+
+ describe "creation" do
+ context "when creating a player" do
+ let!(:level1) do
+ create(:level, lv: 1, attack: 5.0, defence: 2.0, hp: 18.0, next_exp: 10, hp_recovery_sec: 60, stamina: 5, stamina_recovery_sec: 30)
+ end
+ let!(:player) { described_class.create!(name: "Nova", lv: level1.lv) }
+
+ it "creates a player with the matching level" do
+ expect(player).to be_a(described_class)
+ expect(player.level).to be_a(Level)
+ expect(player.exp).to eq(0)
+ end
+ end
+ end
+
+ describe "#challenge_enemy" do
+ context "when the player wins" do
+ let!(:now) { Time.current }
+ let!(:battle_enemy) do
+ create(:enemy, name: "Clockwork Rat", start_at: now - 1.hour, end_at: now + 1.hour, attack: 7.0, defence: 3.0, hp: 10.0, exp: 12, stamina_cost: 4)
+ end
+ let!(:battle_level_one) do
+ create(:level, lv: 1, attack: 8.0, defence: 2.0, hp: 20.0, next_exp: 10, hp_recovery_sec: 60, stamina: 6, stamina_recovery_sec: 30)
+ end
+ let!(:battle_level_two) do
+ create(:level, lv: 2, attack: 12.0, defence: 4.0, hp: 26.0, next_exp: 20, hp_recovery_sec: 50, stamina: 8, stamina_recovery_sec: 25)
+ end
+ let!(:battle_reward) { create(:weapon, name: "Victory Blade") }
+ let!(:battle_reward_entry) do
+ create(:reward, enemy_id: battle_enemy.id, reward_type: "Weapon", reward_id: battle_reward.id)
+ end
+ let!(:battle_player) { described_class.create!(name: "Raider", lv: 1) }
+ let!(:existing_item) do
+ create(:player_item, player: battle_player, item_type: "Weapon", item_id: battle_reward.id, quantity: 1)
+ end
+
+ it "levels up and receives rewards after a win" do
+ result = battle_player.challenge_enemy(battle_enemy, at: now)
+
+ expect(result[:ok]).to be(true)
+ expect(battle_player.reload.lv).to eq(2)
+ expect(battle_player.exp).to eq(2)
+ expect(battle_player.hp).to be_within(0.01).of(13.1373)
+ expect(battle_player.stamina).to eq(8)
+ expect(battle_player.items.map(&:id)).to include(battle_reward.id)
+ expect(battle_player.player_items.find_by(item_type: "Weapon", item_id: battle_reward.id).quantity).to eq(1)
+ end
+ end
+
+ context "when the player loses" do
+ let!(:now) { Time.current }
+ let!(:battle_enemy) do
+ create(:enemy, name: "Stone Golem", start_at: now - 1.hour, end_at: now + 1.hour, attack: 5.0, defence: 50.0, hp: 30.0, exp: 1, stamina_cost: 1)
+ end
+ let!(:battle_level_one) do
+ create(:level, lv: 1, attack: 3.0, defence: 1.0, hp: 12.0, next_exp: 10, hp_recovery_sec: 60, stamina: 4, stamina_recovery_sec: 30)
+ end
+ let!(:battle_player) { described_class.create!(name: "Hero", lv: 1) }
+
+ it "fails with an attack_too_low reason" do
+ result = battle_player.challenge_enemy(battle_enemy, at: now)
+
+ expect(result[:ok]).to be(false)
+ expect(result[:reason]).to eq(:attack_too_low)
+ expect(battle_player.reload.lv).to eq(1)
+ end
+ end
+ end
+
+ describe "#use_potion" do
+ context "when a potion is used" do
+ let!(:now) { Time.current }
+ let!(:potion_level) do
+ create(:level, lv: 1, attack: 5.0, defence: 2.0, hp: 18.0, next_exp: 10, hp_recovery_sec: 60, stamina: 5, stamina_recovery_sec: 30)
+ end
+ let!(:potion) { create(:potion, hp: 8.0) }
+ let!(:healing_player) { described_class.create!(name: "Cleric", lv: 1, hp: 6.0, hp_updated_at: now) }
+ let!(:potion_item) { create(:player_item, player: healing_player, item_type: "Potion", item_id: potion.id, quantity: 1) }
+
+ it "heals instantly with a potion" do
+ expect(healing_player.use_potion(potion, at: now)).to be(true)
+ expect(healing_player.reload.hp).to eq(14.0)
+ expect(healing_player.player_items.where(item_type: "Potion", item_id: potion.id)).to be_empty
+ end
+ end
+ end
+
+ describe "equipment" do
+ context "when equipping a weapon" do
+ let!(:level1) do
+ create(:level, lv: 1, attack: 5.0, defence: 2.0, hp: 18.0, next_exp: 10, hp_recovery_sec: 60, stamina: 5, stamina_recovery_sec: 30)
+ end
+ let!(:weapon) { create(:weapon, name: "Starter Gun") }
+ let!(:player) { described_class.create!(name: "Hero", lv: 1) }
+ let!(:player_item) { create(:player_item, player: player, item_type: "Weapon", item_id: weapon.id, quantity: 1) }
+
+ it "equips the weapon" do
+ expect(player.equip_weapon(weapon)).to be(true)
+ expect(player.reload.weapon_id).to eq(weapon.id)
+ end
+ end
+
+ context "when equipping armor without owning it" do
+ let!(:level1) do
+ create(:level, lv: 1, attack: 5.0, defence: 2.0, hp: 18.0, next_exp: 10, hp_recovery_sec: 60, stamina: 5, stamina_recovery_sec: 30)
+ end
+ let!(:armor) { create(:armor, name: "Starter Armor") }
+ let!(:player) { described_class.create!(name: "Hero", lv: 1) }
+
+ it "rejects equipping armor" do
+ expect(player.equip_armor(armor)).to be(false)
+ expect(player.reload.armor_id).to be_nil
+ end
+ end
+ end
+end
diff --git a/examples/rails_sample/spec/spec_helper.rb b/examples/rails_sample/spec/spec_helper.rb
new file mode 100644
index 0000000..e2c1417
--- /dev/null
+++ b/examples/rails_sample/spec/spec_helper.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+ENV["RAILS_ENV"] = "test"
+ENV["DATABASE_URL"] ||= "sqlite3::memory:"
+
+require "bundler/setup"
+require "rspec"
+require "rails"
+require "active_record/railtie"
+require "active_support/time"
+require "factory_bot"
+require "logger"
+require "simple_master"
+
+require_relative "../config/environment"
+
+ApplicationMaster.prepend(SimpleMaster::Master::Editable)
+
+ActiveRecord::Base.establish_connection(:test)
+ActiveRecord::Base.logger = Rails.logger
+ActiveRecord::Base.logger.level = Logger::WARN
+ActiveRecord::Migration.verbose = false
+
+load File.expand_path("../db/schema.rb", __dir__)
+FactoryBot.definition_file_paths = [
+ File.expand_path("factories", __dir__),
+]
+FactoryBot.find_definitions
+
+RSpec.configure do |config|
+ config.order = :random
+ config.expect_with :rspec do |c|
+ c.syntax = :expect
+ end
+ config.include FactoryBot::Syntax::Methods
+ config.around do |example|
+ dataset = SimpleMaster::Storage::Dataset.new(table_class: SimpleMaster::Storage::TestTable)
+ SimpleMaster.use_dataset(dataset) do
+ example.run
+ end
+ RequestStore.clear!
+ end
+end
diff --git a/gemfiles/rails_7_0.gemfile b/gemfiles/rails_7_0.gemfile
new file mode 100644
index 0000000..be65741
--- /dev/null
+++ b/gemfiles/rails_7_0.gemfile
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+# This file was generated by Appraisal
+
+source "https://rubygems.org"
+
+gem "appraisal", group: :development
+gem "factory_bot"
+gem "puma"
+gem "rails", "~> 7.0"
+gem "rake", group: :development
+gem "rspec"
+gem "rubocop", group: :development
+gem "rubocop-factory_bot", group: :development
+gem "rubocop-performance", group: :development
+gem "rubocop-rails", group: :development
+gem "rubocop-rspec", group: :development
+gem "rubocop-rspec_rails", group: :development
+gem "sqlite3", "~> 2.1"
+
+gemspec path: "../"
diff --git a/gemfiles/rails_7_1.gemfile b/gemfiles/rails_7_1.gemfile
new file mode 100644
index 0000000..bf2a088
--- /dev/null
+++ b/gemfiles/rails_7_1.gemfile
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+# This file was generated by Appraisal
+
+source "https://rubygems.org"
+
+gem "appraisal", group: :development
+gem "factory_bot"
+gem "puma"
+gem "rails", "~> 7.1"
+gem "rake", group: :development
+gem "rspec"
+gem "rubocop", group: :development
+gem "rubocop-factory_bot", group: :development
+gem "rubocop-performance", group: :development
+gem "rubocop-rails", group: :development
+gem "rubocop-rspec", group: :development
+gem "rubocop-rspec_rails", group: :development
+gem "sqlite3", "~> 2.1"
+
+gemspec path: "../"
diff --git a/gemfiles/rails_7_2.gemfile b/gemfiles/rails_7_2.gemfile
new file mode 100644
index 0000000..386ea51
--- /dev/null
+++ b/gemfiles/rails_7_2.gemfile
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+# This file was generated by Appraisal
+
+source "https://rubygems.org"
+
+gem "appraisal", group: :development
+gem "factory_bot"
+gem "puma"
+gem "rails", "~> 7.2"
+gem "rake", group: :development
+gem "rspec"
+gem "rubocop", group: :development
+gem "rubocop-factory_bot", group: :development
+gem "rubocop-performance", group: :development
+gem "rubocop-rails", group: :development
+gem "rubocop-rspec", group: :development
+gem "rubocop-rspec_rails", group: :development
+gem "sqlite3", "~> 2.1"
+
+gemspec path: "../"
diff --git a/gemfiles/rails_8_0.gemfile b/gemfiles/rails_8_0.gemfile
new file mode 100644
index 0000000..5b34199
--- /dev/null
+++ b/gemfiles/rails_8_0.gemfile
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+# This file was generated by Appraisal
+
+source "https://rubygems.org"
+
+gem "appraisal", group: :development
+gem "factory_bot"
+gem "puma"
+gem "rails", "~> 8.0"
+gem "rake", group: :development
+gem "rspec"
+gem "rubocop", group: :development
+gem "rubocop-factory_bot", group: :development
+gem "rubocop-performance", group: :development
+gem "rubocop-rails", group: :development
+gem "rubocop-rspec", group: :development
+gem "rubocop-rspec_rails", group: :development
+gem "sqlite3", "~> 2.1"
+
+gemspec path: "../"
diff --git a/gemfiles/rails_8_1.gemfile b/gemfiles/rails_8_1.gemfile
new file mode 100644
index 0000000..f857131
--- /dev/null
+++ b/gemfiles/rails_8_1.gemfile
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+# This file was generated by Appraisal
+
+source "https://rubygems.org"
+
+gem "appraisal", group: :development
+gem "factory_bot"
+gem "puma"
+gem "rails", "~> 8.1"
+gem "rake", group: :development
+gem "rspec"
+gem "rubocop", group: :development
+gem "rubocop-factory_bot", group: :development
+gem "rubocop-performance", group: :development
+gem "rubocop-rails", group: :development
+gem "rubocop-rspec", group: :development
+gem "rubocop-rspec_rails", group: :development
+gem "sqlite3", "~> 2.1"
+
+gemspec path: "../"
diff --git a/lib/simple_master.rb b/lib/simple_master.rb
index 2be9d98..156c2b1 100644
--- a/lib/simple_master.rb
+++ b/lib/simple_master.rb
@@ -2,6 +2,7 @@
require "active_support/dependencies"
require "active_record"
+require "request_store"
require "logger"
require "simple_master/version"
diff --git a/lib/simple_master/master/dsl.rb b/lib/simple_master/master/dsl.rb
index 4040b31..fcaa38b 100644
--- a/lib/simple_master/master/dsl.rb
+++ b/lib/simple_master/master/dsl.rb
@@ -11,7 +11,7 @@ module Dsl
bitmask: Column::BitmaskColumn,
}.freeze
- def def_column(column_name, options = EMPTY_HASH)
+ def def_column(column_name, options = {})
column = column_type(column_name, options).new(column_name, options)
columns << column
diff --git a/lib/simple_master/master/queryable.rb b/lib/simple_master/master/queryable.rb
index 0401247..18bd189 100644
--- a/lib/simple_master/master/queryable.rb
+++ b/lib/simple_master/master/queryable.rb
@@ -37,11 +37,11 @@ def insert_queries(records, on_duplicate_key_update = false, batch_size: 10000)
sql_column_methods
.zip(column_names)
.map { |method_name, column_name|
- if [:updated_at, :created_at].include?(column_name)
- current_time
- else
- record.send(method_name)
- end
+ if [:updated_at, :created_at].include?(column_name)
+ current_time
+ else
+ record.send(method_name)
+ end
}.join(", ").then { "(#{_1})" }
}.join(", \n")
diff --git a/simple_master.gemspec b/simple_master.gemspec
index a425d14..1a9bdfa 100644
--- a/simple_master.gemspec
+++ b/simple_master.gemspec
@@ -12,7 +12,7 @@ Gem::Specification.new do |spec|
spec.description = "SimpleMaster loads master tables into memory, builds associations, and offers a small DSL for master data models."
spec.homepage = "https://github.com/aktsk/simple_master"
spec.license = "MIT"
- spec.required_ruby_version = ">= 3.1"
+ spec.required_ruby_version = ">= 3.2"
spec.files = Dir.chdir(__dir__) {
Dir["lib/**/*"]
diff --git a/spec/simple_master/active_record/extension_spec.rb b/spec/simple_master/active_record/extension_spec.rb
new file mode 100644
index 0000000..f7453c2
--- /dev/null
+++ b/spec/simple_master/active_record/extension_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe SimpleMaster::ActiveRecord::Extension do
+ before do
+ reset_active_record_tables
+ end
+
+ it "connects ActiveRecord to master" do
+ player = Player.create!(name: "Hero", lv: 2)
+ level = Level.find_by(:lv, 2)
+
+ expect(player.level).to eq(level)
+ end
+
+ it "resolves polymorphic belongs_to for master records" do
+ player = Player.create!(name: "Hero", lv: 1)
+ player_item1 = PlayerItem.create!(player: player, item_type: "Weapon", item_id: 1)
+
+ expect(player_item1.item).to be_a(Weapon)
+ expect(player_item1.item.name).to eq("Bronze Pistol")
+
+ player_item2 = PlayerItem.create!(player: player, item: Weapon.find(1))
+
+ expect(player_item2.item_id).to eq(1)
+ expect(player_item2.item_type).to eq("Gun")
+ end
+
+ it "aggregates items through player_items" do
+ player = Player.create!(name: "Hero", lv: 1)
+ PlayerItem.create!(player: player, item_type: "Weapon", item_id: 1, quantity: 1)
+ PlayerItem.create!(player: player, item_type: "Armor", item_id: 2, quantity: 1)
+ PlayerItem.create!(player: player, item_type: "Potion", item_id: 3, quantity: 1)
+
+ expect(player.items.map(&:class)).to contain_exactly(Gun, Armor, Potion)
+ expect(player.items.map { |item| item.class.base_class }).to contain_exactly(Weapon, Armor, Potion)
+ expect(player.items.map(&:name)).to contain_exactly("Bronze Pistol", "Chain Mail", "Elixir")
+ end
+end
diff --git a/spec/simple_master/loader/marshal_loader_spec.rb b/spec/simple_master/loader/marshal_loader_spec.rb
new file mode 100644
index 0000000..206df7a
--- /dev/null
+++ b/spec/simple_master/loader/marshal_loader_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+require "tmpdir"
+
+RSpec.describe SimpleMaster::Loader::MarshalLoader do
+ it "loads marshaled records for all tables" do
+ Dir.mktmpdir do |dir|
+ # dump current dataset to marshal files
+ $current_dataset.tables.each do |klass, table|
+ path = File.join(dir, "#{klass.table_name}.marshal")
+ File.binwrite(path, Marshal.dump(table.all))
+ end
+
+ loader = described_class.new(path: dir)
+ dataset = SimpleMaster::Storage::Dataset.new(loader: loader)
+ dataset.load
+
+ SimpleMaster.use_dataset(dataset) do
+ expect(Weapon.find(1).name).to eq("Bronze Pistol")
+ expect(Level.find(2).lv).to eq(2)
+ expect(Enemy.find(2).name).to eq("Ogre Chief")
+ end
+ end
+ end
+end
diff --git a/spec/simple_master/loader/query_loader_spec.rb b/spec/simple_master/loader/query_loader_spec.rb
new file mode 100644
index 0000000..5650b92
--- /dev/null
+++ b/spec/simple_master/loader/query_loader_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe SimpleMaster::Loader::QueryLoader do
+ it "loads records from database tables" do
+ connection = ActiveRecord::Base.connection
+
+ %w(weapons armors potions levels enemies rewards).each do |table|
+ connection.execute("DELETE FROM #{table}")
+ end
+
+ info_json = connection.quote('{"slots":1,"origin":"db"}')
+ metadata_json = connection.quote('{"source":"query","tags":["loader"]}')
+
+ connection.execute <<~SQL
+ INSERT INTO weapons (id, type, name, attack, info, metadata, rarity, flags)
+ VALUES (1, 'Gun', 'Query Pistol', 12.5, #{info_json}, #{metadata_json}, 1, 5)
+ SQL
+
+ connection.execute <<~SQL
+ INSERT INTO enemies (id, name, is_boss, start_at, end_at, attack, defence, hp)
+ VALUES (1, 'DB Ogre', 1, '2024-05-01 10:00:00', '2024-05-01 18:00:00', 14.0, 8.0, 45.0)
+ SQL
+
+ dataset = SimpleMaster::Storage::Dataset.new(loader: described_class.new)
+ dataset.load
+
+ SimpleMaster.use_dataset(dataset) do
+ weapon = Weapon.find(1)
+
+ expect(weapon).to be_a(Gun)
+ expect(weapon.name).to eq("Query Pistol")
+ expect(weapon.attack).to eq(12.5)
+ expect(weapon.rarity).to eq(:rare)
+ expect(weapon.flags).to eq([:tradeable, :limited])
+ expect(weapon.info).to(satisfy { |value|
+ [{ slots: 1, origin: "db" }, { "slots" => 1, "origin" => "db" }].include?(value)
+ })
+ expect(weapon.metadata).to eq({ "source" => "query", "tags" => ["loader"] })
+
+ enemy = Enemy.find(1)
+ expect(enemy.is_boss).to be(true)
+ expect(enemy.start_at).to eq(Time.utc(2024, 5, 1, 10, 0, 0))
+ expect(enemy.end_at).to eq(Time.utc(2024, 5, 1, 18, 0, 0))
+ end
+ end
+end
diff --git a/spec/simple_master/master/associations_spec.rb b/spec/simple_master/master/associations_spec.rb
new file mode 100644
index 0000000..506093e
--- /dev/null
+++ b/spec/simple_master/master/associations_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe "associations" do
+ before { reset_active_record_tables }
+
+ describe "belongs_to (master)" do
+ it "works correctly" do
+ reward = Reward.find(1)
+
+ expect(reward.enemy).to eq(Enemy.find(1))
+ end
+ end
+
+ describe "belongs_to (polymorphic master)" do
+ it "works correctly" do
+ reward = Reward.find(3)
+
+ expect(reward.reward).to eq(Potion.find(2))
+ end
+ end
+
+ describe "has_many (master)" do
+ it "works correctly" do
+ enemy = Enemy.find(1)
+
+ expect(enemy.rewards.map(&:id)).to eq([1, 2])
+ end
+ end
+
+ describe "has_many (ActiveRecord)" do
+ it "works correctly" do
+ player = Player.create!(name: "Hero", lv: 2)
+ level = Level.find_by(:lv, 2)
+
+ expect(level.players).to contain_exactly(player)
+ end
+ end
+end
diff --git a/spec/simple_master/master/cache_spec.rb b/spec/simple_master/master/cache_spec.rb
new file mode 100644
index 0000000..001a3e5
--- /dev/null
+++ b/spec/simple_master/master/cache_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe "cache" do
+ describe "cache_class_method" do
+ subject(:cached_result) { Weapon.receivable_sources }
+
+ let!(:result) { Weapon.receivable_sources }
+ let(:enemies) { Enemy.id_hash }
+
+ it "collects receivable sources per item" do
+ expect(cached_result).to match({
+ 1 => contain_exactly(enemies[1]),
+ 2 => contain_exactly(enemies[4]),
+ 3 => contain_exactly(enemies[2], enemies[9], enemies[10]),
+ })
+ expect(cached_result.object_id).to be(result.object_id)
+ end
+
+ it "keeps STI sub-table caches separate" do
+ expect(Blade.receivable_sources.keys).to contain_exactly(2)
+ expect(Gun.receivable_sources.keys).to contain_exactly(1, 3)
+ end
+ end
+
+ describe "cache_method" do
+ subject(:cached_result) { weapon.receivable_sources }
+
+ let(:weapon) { Weapon.find(1) }
+ let!(:result) { weapon.receivable_sources }
+
+ it "collects receivable sources per item" do
+ expect(cached_result.map(&:id)).to contain_exactly(1)
+ expect(cached_result.object_id).to be(result.object_id)
+ end
+ end
+end
diff --git a/spec/simple_master/master/columns_spec.rb b/spec/simple_master/master/columns_spec.rb
new file mode 100644
index 0000000..17b1bfe
--- /dev/null
+++ b/spec/simple_master/master/columns_spec.rb
@@ -0,0 +1,172 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe "columns" do
+ before { reset_active_record_tables }
+
+ describe "IntegerColumn" do
+ it "casts correctly" do
+ level = Level.new
+
+ level.lv = "3"
+ expect(level.lv).to eq(3)
+ expect(level.lv_value_for_sql).to eq(3)
+
+ level.lv = " "
+ expect(level.lv).to be_nil
+ expect(level.lv_value_for_sql).to eq("NULL")
+
+ level.lv = 4
+ expect(level.lv).to eq(4)
+ expect(level.lv_value_for_sql).to eq(4)
+ end
+ end
+
+ describe "FloatColumn" do
+ it "casts correctly" do
+ weapon = Weapon.new
+
+ weapon.attack = "12.5"
+ expect(weapon.attack).to eq(12.5)
+ expect(weapon.attack_value_for_sql).to eq(12.5)
+
+ weapon.attack = 7
+ expect(weapon.attack).to eq(7.0)
+ expect(weapon.attack_value_for_sql).to eq(7.0)
+
+ weapon.attack = " "
+ expect(weapon.attack).to be_nil
+ expect(weapon.attack_value_for_sql).to eq("NULL")
+ end
+ end
+
+ describe "BooleanColumn" do
+ it "casts correctly" do
+ enemy = Enemy.new
+
+ enemy.is_boss = "true"
+ expect(enemy.is_boss).to be(true)
+ expect(enemy.is_boss?).to be(true)
+ expect(enemy.is_boss_value_for_sql).to eq(1)
+
+ enemy.is_boss = "0"
+ expect(enemy.is_boss).to be(false)
+ expect(enemy.is_boss?).to be(false)
+ expect(enemy.is_boss_value_for_sql).to eq(0)
+
+ enemy.is_boss = nil
+ expect(enemy.is_boss).to be_nil
+ expect(enemy.is_boss?).to be(false)
+ expect(enemy.is_boss_value_for_sql).to eq("NULL")
+ end
+ end
+
+ describe "TimeColumn" do
+ it "casts correctly" do
+ enemy = Enemy.new
+
+ enemy.start_at = "2024-05-01T10:00:00Z"
+ expect(enemy.start_at).to eq(Time.utc(2024, 5, 1, 10, 0, 0))
+ expect(enemy.start_at_value_for_sql).to eq("'2024-05-01 10:00:00'")
+
+ time_value = Time.utc(2024, 5, 2, 12, 30, 15)
+ enemy.end_at = time_value
+ expect(enemy.end_at).to eq(time_value)
+
+ enemy.start_at = nil
+ expect(enemy.start_at_value_for_sql).to eq("NULL")
+ end
+ end
+
+ describe "JsonColumn" do
+ it "(with symbolize_names: true) casts correctly" do
+ weapon = Weapon.new
+
+ weapon.info = { slots: 2, origin: "ruins" }
+ expect(weapon.info).to eq({ slots: 2, origin: "ruins" })
+ expect(weapon.info_value_for_sql).to eq("'{\"slots\":2,\"origin\":\"ruins\"}'")
+
+ weapon.info = "{\"slots\":1,\"origin\":\"forge\"}"
+ expect(weapon.info).to eq({ slots: 1, origin: "forge" })
+ expect(weapon.info_value_for_sql).to eq("'{\"slots\":1,\"origin\":\"forge\"}'")
+
+ weapon.info = "null"
+ expect(weapon.info).to be_nil
+ expect(weapon.info_value_for_sql).to eq("NULL")
+ end
+
+ it "(with symbolize_names: false) casts correctly" do
+ weapon = Weapon.new
+
+ weapon.metadata = "{\"source\":\"archive\",\"tags\":[\"starter\"]}"
+ expect(weapon.metadata).to eq({ "source" => "archive", "tags" => ["starter"] })
+ expect(weapon.metadata_value_for_sql).to eq("'{\"source\":\"archive\",\"tags\":[\"starter\"]}'")
+
+ weapon.metadata = { "source" => "manual", "tags" => ["custom"] }
+ expect(weapon.metadata).to eq({ "source" => "manual", "tags" => ["custom"] })
+ expect(weapon.metadata_value_for_sql).to eq("'{\"source\":\"manual\",\"tags\":[\"custom\"]}'")
+
+ weapon.metadata = "null"
+ expect(weapon.metadata).to be_nil
+ expect(weapon.metadata_value_for_sql).to eq("NULL")
+ end
+ end
+
+ describe "EnumColumn" do
+ it "casts correctly" do
+ weapon = Weapon.new
+
+ weapon.rarity = "1"
+ expect(weapon.rarity).to eq(:rare)
+ expect(weapon.rarity_value_for_sql).to eq(1)
+
+ weapon.rarity = 2
+ expect(weapon.rarity).to eq(:epic)
+ expect(weapon.rarity_value_for_sql).to eq(2)
+
+ weapon.rarity = nil
+ expect(weapon.rarity_value_for_sql).to eq("NULL")
+ end
+ end
+
+ describe "BitmaskColumn" do
+ it "casts correctly" do
+ weapon = Weapon.new
+
+ weapon.flags = [:tradeable, :limited]
+ expect(weapon.flags).to eq([:tradeable, :limited])
+ expect(weapon.flags_value_for_sql).to eq(5)
+
+ weapon.flags = :soulbound
+ expect(weapon.flags).to eq([:soulbound])
+
+ weapon.flags = nil
+ expect(weapon.flags_value_for_sql).to eq("NULL")
+ end
+ end
+
+ describe "PolymorphicTypeColumn" do
+ it "casts correctly" do
+ reward = Reward.new
+
+ reward.reward_type = :Potion
+ expect(reward.reward_type).to eq("Potion")
+ expect(reward.reward_type_class).to eq(Potion)
+ expect(reward.reward_type_value_for_sql).to eq("'Potion'")
+
+ reward = Reward.find(3)
+ expect(reward.reward_type_class).to eq(Potion)
+ expect(reward.reward).to eq(Potion.find(2))
+ end
+ end
+
+ describe "StiTypeColumn" do
+ it "casts correctly" do
+ weapon = Gun.new
+
+ expect(weapon.type).to eq("Gun")
+ expect(weapon.type_value_for_sql).to eq("'Gun'")
+ end
+ end
+end
diff --git a/spec/simple_master/master/filterable_spec.rb b/spec/simple_master/master/filterable_spec.rb
new file mode 100644
index 0000000..c4e7eb0
--- /dev/null
+++ b/spec/simple_master/master/filterable_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe SimpleMaster::Master::Filterable do
+ it "supports id-based find helpers" do
+ expect(Weapon.find(1).name).to eq("Bronze Pistol")
+ expect(Weapon.find_by_id(99)).to be_nil
+ expect(Weapon.find_by_ids([1, 3]).map(&:id)).to eq([1, 3])
+ expect { Weapon.find_by_ids!([1, 99]) }.to raise_error(KeyError)
+ end
+
+ it "supports grouped lookups" do
+ expect(Reward.find_by(:enemy_id, 1).id).to eq(1)
+ expect(Reward.all_by(:enemy_id, 1).map(&:id)).to eq([1, 2])
+ expect { Reward.all_by!(:enemy_id, 99) }.to raise_error(KeyError)
+ expect(Reward.all_in(:enemy_id, [1, 2]).map(&:id)).to eq([1, 2, 3, 4])
+ expect(Reward.all_in(:enemy_id, [1, 2])).to be_frozen
+ end
+
+ it "delegates collection helpers to all" do
+ expect(Weapon.pluck(:name)).to include("Bronze Pistol", "Silver Saber", "Crimson Rifle")
+ expect(Weapon.first).to be_a(Weapon)
+ end
+
+ it "checks id existence" do
+ expect(Weapon.exists?(1)).to be(true)
+ expect(Weapon.exists?(99)).to be(false)
+ end
+
+ it "raises on missing group key and handles empty all_in" do
+ expect { Reward.all_by(:unknown_key, 1) }.to raise_error(KeyError)
+ expect(Reward.all_in(:enemy_id, [])).to eq([])
+ expect(Reward.all_in(:enemy_id, [])).to be_frozen
+ end
+end
diff --git a/spec/simple_master/master/model_spec.rb b/spec/simple_master/master/model_spec.rb
new file mode 100644
index 0000000..5a2bf7c
--- /dev/null
+++ b/spec/simple_master/master/model_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe "model" do
+ describe "sti_class?" do
+ it "returns true for STI base classes and subclasses" do
+ expect(Weapon.sti_class?).to be(true)
+ expect(Gun.sti_class?).to be(true)
+ expect(Armor.sti_class?).to be(false)
+ end
+ end
+
+ describe "sti_base_class?" do
+ it "returns true only for STI base classes" do
+ expect(Weapon.sti_base_class?).to be(true)
+ expect(Gun.sti_base_class?).to be(false)
+ expect(Armor.sti_base_class?).to be(false)
+ end
+ end
+
+ describe "sti_sub_class?" do
+ it "returns true only for STI subclasses" do
+ expect(Gun.sti_sub_class?).to be(true)
+ expect(Blade.sti_sub_class?).to be(true)
+ expect(Weapon.sti_sub_class?).to be(false)
+ expect(Armor.sti_sub_class?).to be(false)
+ end
+ end
+
+ describe "base_class" do
+ it "returns the STI base class when present" do
+ expect(Weapon.base_class).to eq(Weapon)
+ expect(Gun.base_class).to eq(Weapon)
+ expect(Blade.base_class).to eq(Weapon)
+ expect(Armor.base_class).to eq(Armor)
+ end
+ end
+
+ describe "base_class?" do
+ it "returns true for STI base classes and non-STI classes" do
+ expect(Weapon.base_class?).to be(true)
+ expect(Gun.base_class?).to be(false)
+ expect(Armor.base_class?).to be(true)
+ end
+ end
+
+ describe "globalized?" do
+ it "returns true when any column is globalized" do
+ expect(Weapon.globalized?).to be(true)
+ end
+
+ it "returns false when no globalized columns exist" do
+ expect(Armor.globalized?).to be(false)
+ end
+ end
+end
diff --git a/spec/simple_master/master/queryable_spec.rb b/spec/simple_master/master/queryable_spec.rb
new file mode 100644
index 0000000..37bfaec
--- /dev/null
+++ b/spec/simple_master/master/queryable_spec.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe SimpleMaster::Master::Queryable do
+ let(:connection) { ActiveRecord::Base.connection }
+
+ before do
+ %w(weapons levels).each do |table|
+ connection.execute("DELETE FROM #{table}")
+ end
+ end
+
+ describe ".table_available?" do
+ it "returns true for existing tables" do
+ expect(Weapon.table_available?).to be(true)
+ end
+
+ it "returns false for missing tables" do
+ allow(Weapon).to receive(:table_name).and_return("missing_table")
+
+ expect(Weapon.table_available?).to be(false)
+ end
+ end
+
+ describe ".query_select_all" do
+ it "returns rows from the backing table" do
+ connection.execute <<~SQL
+ INSERT INTO weapons (id, type, name, attack, rarity, flags)
+ VALUES (1, 'Gun', 'Queryable Sword', 9.5, 2, 1)
+ SQL
+
+ result = Weapon.query_select_all
+ row = result.to_a.find { |record| record["id"] == 1 }
+
+ expect(result.columns).to include("name", "attack")
+ expect(row["name"]).to eq("Queryable Sword")
+ expect(row["attack"]).to eq(9.5)
+ end
+ end
+
+ describe ".sqlite_insert_query" do
+ it "builds SQL that can be executed" do
+ weapon = Weapon.new(
+ id: 1,
+ type: "Gun",
+ name: "SQL Pistol",
+ attack: 11.0,
+ info: { slots: 1 },
+ metadata: { "source" => "sql" },
+ rarity: :rare,
+ flags: [:tradeable],
+ )
+
+ sql = Weapon.sqlite_insert_query([weapon])
+ connection.execute(sql)
+
+ row = connection.select_all("SELECT name, rarity, flags FROM weapons WHERE id = 1").to_a.first
+ expect(row["name"]).to eq("SQL Pistol")
+ expect(row["rarity"].to_i).to eq(1)
+ expect(row["flags"].to_i).to eq(1)
+ end
+ end
+
+ describe ".query_delete_all" do
+ it "removes all rows from the table" do
+ connection.execute("INSERT INTO levels (id, lv, attack) VALUES (1, 3, 2.5)")
+
+ expect(connection.select_all("SELECT * FROM levels").to_a.size).to eq(1)
+
+ Level.query_delete_all
+
+ expect(connection.select_all("SELECT * FROM levels").to_a).to be_empty
+ end
+ end
+end
diff --git a/spec/simple_master/master/validatable_spec.rb b/spec/simple_master/master/validatable_spec.rb
new file mode 100644
index 0000000..aebb779
--- /dev/null
+++ b/spec/simple_master/master/validatable_spec.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe SimpleMaster::Master::Validatable do
+ it "validates all master records" do
+ errors = ApplicationMaster.validate_all_records
+ expect(errors).to be_empty
+ end
+end
diff --git a/spec/simple_master/storage/dataset_spec.rb b/spec/simple_master/storage/dataset_spec.rb
new file mode 100644
index 0000000..e69602d
--- /dev/null
+++ b/spec/simple_master/storage/dataset_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe SimpleMaster::Storage::Dataset do
+ it "keeps loaded records when digest is unchanged" do
+ weapon = Weapon.first
+ $current_dataset.load
+
+ expect(Weapon.first.object_id).to equal(weapon.object_id)
+ end
+
+ it "duplicates dataset without reloading tables" do
+ dup_dataset = $current_dataset.duplicate
+ dup_dataset.load
+
+ expect(SimpleMaster.use_dataset(dup_dataset) { Weapon.first.object_id }).to equal(Weapon.first.object_id)
+ end
+
+ it "memoizes dataset cache_fetch" do
+ dataset = described_class.new(loader: JsonLoader.new)
+
+ first_time = dataset.cache_fetch(:foo) { Object.new }
+ second_time = dataset.cache_fetch(:foo) { Object.new }
+
+ expect(first_time.object_id).to eq(second_time.object_id)
+ end
+end
diff --git a/spec/simple_master/storage/loader_spec.rb b/spec/simple_master/storage/loader_spec.rb
new file mode 100644
index 0000000..9fab7c8
--- /dev/null
+++ b/spec/simple_master/storage/loader_spec.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe "Loader" do
+ describe "loading" do
+ it "instantiates STI records as subclasses" do
+ pistol = Weapon.find(1)
+
+ expect(pistol).to be_a(Gun)
+ expect(pistol.attack).to eq(12.5)
+
+ expect(Gun.all).to include(pistol)
+ expect(Weapon.all).to include(pistol)
+
+ expect(Blade.find_by_id(1)).to be_nil
+ expect(Gun.find(1)).to eq(pistol)
+
+ expect(Weapon.all.map(&:id)).to contain_exactly(1, 2, 3, 4)
+ expect(Gun.all.map(&:id)).to contain_exactly(1, 3)
+ expect(Blade.all.map(&:id)).to contain_exactly(2, 4)
+ end
+ end
+
+ describe "Globalization" do
+ let(:potion) { Potion.find(1) }
+ let(:weapon) { Weapon.find(1) }
+
+ it "can be loaded by loader" do
+ I18n.with_locale(:ja) do
+ expect(potion.name).to eq("マイナーヒール")
+ end
+ end
+
+ it "applies globalize_proc when provided" do
+ I18n.with_locale(:ja) do
+ expect(weapon.name).to eq("ブロンズピストル")
+ end
+ end
+ end
+
+ it "applies diff json to dataset" do
+ diff = {
+ "weapons" => {
+ "2" => { "attack" => 42.0, "_globalized_name" => { en: "Gold Saber", ja: "ゴールドセイバー" } },
+ "3" => nil,
+ },
+ }
+
+ dataset = $current_dataset.duplicate(diff: diff)
+ dataset.load
+
+ SimpleMaster.use_dataset(dataset) do
+ weapon = Weapon.find(2)
+
+ expect(weapon.attack).to eq(42.0)
+ expect(weapon.name).to eq("Gold Saber")
+ I18n.with_locale(:ja) do
+ expect(weapon.name).to eq("ゴールドセイバー")
+ end
+ expect(Weapon.id_hash).not_to have_key(3)
+ end
+ end
+end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
new file mode 100644
index 0000000..5b8b7da
--- /dev/null
+++ b/spec/spec_helper.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+ENV["DATABASE_URL"] ||= "sqlite3::memory:"
+
+require "bundler/setup"
+require "rspec"
+require "rails"
+require "active_record/railtie"
+require "active_support/time"
+require "factory_bot"
+require "logger"
+require "simple_master"
+
+# Boot sample Rails app
+require_relative "../examples/rails_sample/lib/json_loader"
+require_relative "../examples/rails_sample/config/environment"
+
+ActiveRecord::Base.establish_connection(:test)
+ActiveRecord::Base.logger = Rails.logger
+ActiveRecord::Base.logger.level = Logger::WARN
+ActiveRecord::Migration.verbose = false
+
+# Load schema and models
+load File.expand_path("../examples/rails_sample/db/schema.rb", __dir__)
+%w(
+ application_record
+ player
+ player_item
+ weapon
+ armor
+ potion
+ level
+ enemy
+ reward
+).each do |model|
+ require File.expand_path("../examples/rails_sample/app/models/#{model}", __dir__)
+end
+
+# Test support helpers
+require_relative "support/dataset_helper" if File.exist?(File.expand_path("support/dataset_helper.rb", __dir__))
+FactoryBot.find_definitions
+
+I18n.available_locales = [:en, :ja]
+
+RSpec.configure do |config|
+ config.order = :random
+ config.expect_with :rspec do |c|
+ c.syntax = :expect
+ end
+ config.include DatasetHelper
+ config.include FactoryBot::Syntax::Methods
+ config.after { RequestStore.clear! }
+end
diff --git a/spec/support/dataset_helper.rb b/spec/support/dataset_helper.rb
new file mode 100644
index 0000000..267bbe6
--- /dev/null
+++ b/spec/support/dataset_helper.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module DatasetHelper
+ def build_dataset(loader: JsonLoader.new, diff: nil)
+ dataset = SimpleMaster::Storage::Dataset.new(loader: loader)
+ dataset.diff = diff if diff
+ # dataset.load
+ dataset
+ end
+
+ def with_dataset(loader: JsonLoader.new, diff: nil)
+ dataset = build_dataset(loader: loader, diff: diff)
+ SimpleMaster.use_dataset(dataset) do
+ yield dataset
+ end
+ end
+
+ def reset_active_record_tables
+ PlayerItem.delete_all
+ Player.delete_all
+ end
+end
+
+RSpec.configure do |config|
+ config.include DatasetHelper
+end