#----------------------------------------------------------------
# Party_Sheet.rb
#
# Changelog:
# Paulinchen 2026-10-06: Wrote the log into the game folder's Logs folder
# Paulinchen 2026-10-03: Showed what skills really cost in HP, MP and SP, priced the way the game does
# - Listed skill chains, with the damage and cost of chained skills
# - Ordered effects and boosts by their amount, then by name
# - Various layout changes
# - Added Max SP to the stats
# - Wrote an image of everything about the member the status screen shows at the hotkey
# - Made Hotkey a key binding in Mod Config Remake, a list of keys in older menus
# - Read the side chosen in the untranslated game too
# - Wrote each save's sheet into Party sheets
# - Wrote the sheet in game folders with Japanese or other non-ASCII names
# Paulinchen 2026-09-29: Ignored the hotkey while another mod takes typed text
# Paulinchen 2026-09-28: Replaced the F7 key with a Hotkey option that picks among keys the game leaves free.
# Paulinchen 2026-09-27: Added a Theme option that makes the page white and gold for Ilias, dark and purple for Alice.
# - Blocked scripts and outside requests in the page with a content security policy.
# - Wrote the page indented, an element or CSS declaration per line.
# - Created
#
#----------------------------------------------------------------
# Writes a page per save into Party sheets next to Game.exe at the press of a hotkey or from the
# Mod Config Menu: every party member with the full picture of the Library, levels, stats,
# equipment, abilities, trait and the jobs and races mastered. It can also take an image of the
# Frontline. It must never interrupt the game, so every entry point rescues.
module MGQ_PartySheet
# Turns the sheet off without uninstalling it.
ENABLED = true
# Whether the page carries its images inside (true) or links them from the game folder (false).
# Embedded, the page can be moved and shared on its own, at a few MB.
EMBED_IMAGES = true
# Folder next to Game.exe that holds the sheets.
FOLDER = "Party sheets"
# Page written into FOLDER after the save's name, like 05_party_sheet.html.
FILE = "party_sheet.html"
# Quality from 0 to 1 at which the browser converts the page's embedded portraits to WebP,
# several times smaller than the game's PNGs. nil keeps the PNGs.
PORTRAIT_QUALITY = 0.85
# Image of the Frontline written next to the page after the save's name, like 05_party_sheet.png.
IMAGE_FILE = "party_sheet.png"
# Image of one member written next to the page after the save's name, followed by her name, like
# 05_party_sheet_Alice.png.
CHARACTER_FILE = "party_sheet"
# Folder next to Game.exe that holds the logs, shared with the user's other mods.
LOG_DIR = "Logs"
# Log in LOG_DIR, which only appears when something went wrong.
LOG_FILE = "Party Sheet.log"
# Lines logged per session at most, a part missing from the game would be logged at every write.
MAX_LOG_LINES = 60
# Reports whether the hooks can be installed.
#
# A second copy of this script would wrap the same methods under the same names, and each hook
# would then call itself until the stack overflows.
#
# @return [Boolean] false when the hooks are in place already
def self.hookable?
!Scene_Base.method_defined?(:mgq_party_sheet_update_basic)
end
# Writes what the Output option asks for, for the party that is loaded, and plays the save sound
# when it did, the buzzer when no game is loaded.
def self.write
return unless ENABLED
if $game_party.nil? || $game_party.all_members.empty?
Sound.play_buzzer
return
end
Dir.mkdir(path(FOLDER)) unless File.directory?(path(FOLDER))
page = Options.page? ? Page.build : nil
File.open(sheet(FILE), "wb") { |file| file.write(page) } if page
Browser.run(page, Options.image? ? [[Page.frontline(Browser.base), sheet(IMAGE_FILE)]] : [])
Sound.play_save
rescue => e
log("could not write the party sheet: #{e.class}: #{e.message}")
end
# Takes the image of one member, with everything her card holds, and plays the save sound when
# the browser started, the buzzer when it could not.
#
# @param actor [Game_Actor] the member
def self.write_character(actor)
return unless ENABLED
Dir.mkdir(path(FOLDER)) unless File.directory?(path(FOLDER))
started = Browser.run(nil, [[Page.character(actor, Browser.base), sheet("#{CHARACTER_FILE}_#{file_name(actor.name)}.png")]])
started ? Sound.play_save : Sound.play_buzzer
rescue => e
log("could not write the sheet of #{actor.name}: #{e.class}: #{e.message}")
end
# Characters Windows forbids in file names, and control characters.
FORBIDDEN = /[\\\/:*?"<>|\x00-\x1f]/
# @param name [String] a member's name
# @return [String] the name as part of a file name, without what Windows forbids there
def self.file_name(name)
safe = Text.plain(name).gsub(FORBIDDEN, "").strip
safe.empty? ? "member" : safe
end
# Writes the sheet when the key of the Hotkey option went down, or the image of the member the
# status screen shows. Called once per frame.
def self.check_hotkey
return unless Keyboard.pressed?(Options.hotkey)
actor = Party.status_actor
actor ? write_character(actor) : write
rescue => e
log("hotkey check failed: #{e.class}: #{e.message}")
end
# Builds a part of the page, leaving it out when the game lacks what it reads.
#
# @param part [String] what the part shows, for the log
# @yieldreturn [String] the part's HTML
# @return [String] the HTML, "" when building it failed
def self.safely(part)
yield
rescue => e
log("#{part} left out: #{e.class}: #{e.message}")
""
end
# Builds the path of a file inside the game folder.
#
# @param name [String] the file name, relative to the game folder
# @return [String] the full path
def self.path(name)
"#{game_dir}/#{name}"
end
# @param file [String] FILE or IMAGE_FILE
# @return [String] the full path of that file of the save the party is in, inside FOLDER
def self.sheet(file)
path("#{FOLDER}/#{Slot.name}_#{file}")
end
# @param name [String] the file name, relative to the game folder
# @return [Boolean] whether the file is in the game folder, false for one inside Game.rgss3a
def self.exist?(name)
File.exist?(path(name))
end
# The folder of Game.exe.
#
# Asked of Windows, the working directory is wherever a shortcut or Steam started the game.
#
# @return [String] the folder in UTF-8, with forward slashes
def self.game_dir
@game_dir ||= begin
buffer = Wide.buffer(Wide::MAX_CHARS)
length = Win32API.new('kernel32', 'GetModuleFileNameW', 'lpl', 'l').call(0, buffer, Wide::MAX_CHARS)
File.dirname(Wide.text(buffer, length).tr("\\", "/"))
end
rescue
@game_dir = Dir.pwd
end
# Appends a line to LOG_FILE, prefixed with the time.
#
# @param message [String] the line to append
def self.log(message)
@log_lines = (@log_lines || 0) + 1
return if @log_lines > MAX_LOG_LINES
File.open(log_file, "ab") { |file| file.write("#{Time.now} #{message}\n") }
rescue
end
# Builds the full path of LOG_FILE in LOG_DIR, and makes the folder when it is missing.
#
# @return [String] the full path
def self.log_file
Dir.mkdir(path(LOG_DIR)) unless File.directory?(path(LOG_DIR))
path("#{LOG_DIR}/#{LOG_FILE}")
end
# The save the party was last saved to or loaded from, which names its sheet.
#
# The game's own last_savefile_index stays 0 after a new game or a loaded autosave, so it would
# name those after the first slot.
module Slot
# @param index [Integer, String, nil] the game's index of the save: a slot from 0, the number of
# an autosave like "01", nil for a new game
def self.index=(index)
@index = index
end
# @return [String] the save's number as its file has it, like 05, autosave01 for an autosave,
# unsaved for a new game not saved yet
def self.name
case @index
when Integer then format("%02d", @index + 1)
when nil then "unsaved"
else "autosave#{@index}"
end
end
end
# Passes text to and from Windows in UTF-16.
#
# The functions ending in A use the system's code page, which turns a Japanese folder into
# Shift-JIS bytes or question marks that Ruby's UTF-8 paths cannot open.
module Wide
# Characters a buffer holds, enough for any path or environment variable the sheet reads.
MAX_CHARS = 1024
# @param text [String] text in UTF-8
# @return [String] the text in UTF-16LE with its terminating zero, as Win32API passes it
def self.string(text)
"#{text}\0".encode("UTF-16LE").force_encoding("ASCII-8BIT")
end
# @param chars [Integer] how many characters the buffer holds
# @return [String] a buffer Windows writes UTF-16LE into
def self.buffer(chars)
("\0\0" * chars).force_encoding("ASCII-8BIT")
end
# @param buffer [String] a buffer Windows wrote into
# @param chars [Integer] how many characters it wrote
# @return [String] those characters in UTF-8
def self.text(buffer, chars)
buffer[0, chars * 2].force_encoding("UTF-16LE").encode("UTF-8")
end
# Reads an environment variable, which ENV hands over in the system's code page.
#
# @param name [String] the variable
# @return [String, nil] its value in UTF-8, nil when it is not set
def self.env(name)
buffer = buffer(MAX_CHARS)
@get_env ||= Win32API.new('kernel32', 'GetEnvironmentVariableW', 'ppl', 'l')
length = @get_env.call(string(name), buffer, MAX_CHARS)
length > 0 && length < MAX_CHARS ? text(buffer, length) : nil
end
end
# Reads keys from Windows, as the game's Input knows little more than its buttons and the F keys.
module Keyboard
# Set in a key's state while the key is down.
DOWN = 0x8000
# Reports whether a key went down since the last call for it, while the game's window is in
# front and no mod takes typed text.
#
# Windows reports the key whichever window has the focus, so a press in another window is
# ignored. A mod that takes typed text, such as a chat, sets $mgq_text_input meanwhile, so a
# typed letter writes no sheet.
#
# @param code [Integer] the key's Windows code, 0 for none
# @return [Boolean] whether the key went down
def self.pressed?(code)
return false if code <= 0
@down ||= {}
down = (api('user32', 'GetAsyncKeyState', 'i', 'i').call(code) & DOWN) != 0
pressed = down && !@down[code]
@down[code] = down
pressed && !$mgq_text_input && game_in_front?
end
# Keys whose name Windows only gives with the extended flag, as it names the number pad's
# otherwise: Page Up to Down, Insert, Delete, Num /, Num Lock, Right Ctrl and Right Alt.
EXTENDED = (0x21..0x28).to_a + [0x2D, 0x2E, 0x6F, 0x90, 0xA3, 0xA5]
# @param code [Integer] the key's Windows code
# @return [String] the key's name as the keyboard layout writes it, like F10 or Ö
def self.name(code)
scan = api('user32', 'MapVirtualKeyW', 'ii', 'i').call(code, 0) << 16
scan |= 1 << 24 if EXTENDED.include?(code)
buffer = Wide.buffer(64)
length = api('user32', 'GetKeyNameTextW', 'ipi', 'i').call(scan, buffer, 64)
length > 0 ? Wide.text(buffer, length) : "Key #{code}"
rescue
"Key #{code}"
end
# @return [Boolean] whether the window in front belongs to this game, true when Windows cannot tell
def self.game_in_front?
owner = [0].pack('L')
api('user32', 'GetWindowThreadProcessId', 'lp', 'l').call(api('user32', 'GetForegroundWindow', 'v', 'l').call, owner)
owner.unpack('L')[0] == api('kernel32', 'GetCurrentProcessId', 'v', 'l').call
rescue
true
end
# @param library [String] the Windows library
# @param name [String] the function
# @param arguments [String] its arguments, in Win32API notation
# @param result [String] its result, in Win32API notation
# @return [Win32API] the function, loaded once
def self.api(library, name, arguments, result)
@functions ||= {}
@functions[name] ||= Win32API.new(library, name, arguments, result)
end
end
# The mod's entries in the Mod Config Menu when it is installed, in the game's Config menu
# otherwise: what to write, a button and a hotkey that write it, and the page's colours.
module Options
# What the sheet writes: 0 the page and the image, 1 the page, 2 the image.
OUTPUT = :mod_party_sheet_output
# The button that writes the sheet.
WRITE = :mod_party_sheet_write
# The key that writes the sheet anywhere in the game, by its Windows code, 0 for none.
HOTKEY = :mod_party_sheet_hotkey
# Whether the colours follow the side chosen (0) or are always the ones of Shown Theme (1).
THEME = :mod_party_sheet_theme
# Which colours the page has while Theme is Static: 0 dark and purple, 1 white and gold.
SHOWN_THEME = :mod_party_sheet_shown_theme
# Theme's value that keeps the colours of Shown Theme.
STATIC = 1
# Shown Theme's value for white and gold.
LIGHT = 1
# Output's values by their name and help in the menu. The first one is the default.
OUTPUTS = {
0 => ["Page and Image", "A page with the whole party, and an image of the Frontline."],
1 => ["Page", "A page with the whole party."],
2 => ["Image", "An image of the Frontline."],
}
# Hotkey's values in menus without key bindings, Windows key codes, by the key's name. The first
# one is the default in every menu.
#
# The game takes A, D, Q, S, W, X, Z, Page Up and Page Down as buttons, and the game, its
# plugins or other mods take the F keys.
HOTKEYS = { 0x50 => "P", 0x4F => "O", 0x49 => "I", 0x55 => "U", 0x4B => "K", 0x4C => "L", 0x4D => "M",
0x4E => "N", 0x4A => "J", 0x48 => "H", 0x47 => "G", 0x09 => "Tab", 0x2D => "Insert",
0x24 => "Home", 0x23 => "End", 0 => "None" }
# Theme's values by their name and help in the menu. The first one is the default.
THEMES = {
0 => ["Dynamic", "White and gold if you chose Ilias, dark and purple if you chose Alice or have not chosen yet."],
1 => ["Static", "Always the colours Shown Theme picks."],
}
# Shown Theme's values by their name and help in the menu. The first one is the default.
SHOWN_THEMES = {
0 => ["Alice (Dark)", "Always dark and purple."],
1 => ["Ilias (Light)", "Always white and gold."],
}
# Adds the entries before the menu's last one, which returns, and takes Shown Theme out while
# Theme is Dynamic. Its entry is kept, so arrange can put it back.
#
# The Mod Config Menu defines MOD_CONTENTS in 0_ModConfigMenu.rb, which the mod loader runs
# before this script.
def self.register
config = NWConst::Config
@menu = config.const_defined?(:MOD_CONTENTS) ? config::MOD_CONTENTS : config::CONTENTS
add(OUTPUT, "[Party Sheet] Output", "What the party sheet writes into the Party sheets folder.", OUTPUTS)
@menu.insert(-2, :key => WRITE, :name => " -> Write Party Sheet", :sub => false,
:help => "Write the party sheet now. The key picked under Hotkey does the same anywhere in the game.")
add_hotkey
@theme = add(THEME, "[Party Sheet] Theme", "The colours of the party sheet.", THEMES)
@shown_theme = add(SHOWN_THEME, " -> Shown Theme", "The colours of the party sheet while Theme is Static.", SHOWN_THEMES)
arrange
end
# Adds an option before the menu's last entry.
#
# @param key [Symbol] the option, its key in $game_system.conf
# @param name [String] its name in the menu
# @param help [String] its help in the menu
# @param values [Hash{Integer => Array(String, String)}] its values by their name and help, the
# first one being the default
# @return [Hash] its entry in the menu
def self.add(key, name, help, values)
config = NWConst::Config
entry = { :key => key, :name => name, :sub => true, :help => "#{help}\r\n←/→ Toggle" }
@menu.insert(-2, entry)
config::DATA[key] = values.keys
config::DATA_TEXT[key] = {}
values.each { |value, (label, text)| config::DATA_TEXT[key][value] = { :name => label, :help => text } }
config::DEFAULT[key] = values.keys.first
entry
end
# Adds Hotkey before the menu's last entry: a key binding, which takes the next key pressed, in
# Mod Config Remake 1.3.0 or later, else a choice among HOTKEYS.
#
# Older menus show a key binding as a button that does nothing.
def self.add_hotkey
name = "[Party Sheet] Hotkey"
help = "The key that writes the party sheet anywhere in the game."
return add(HOTKEY, name, help, hotkey_values) unless defined?(ModConfigRemake::Keys)
@menu.insert(-2, :key => HOTKEY, :name => name, :help => help, :keybind => true)
NWConst::Config::DEFAULT[HOTKEY] = HOTKEYS.keys.first
end
# @return [Hash{Integer => Array(String, String)}] HOTKEYS by their name and help in the menu
def self.hotkey_values
HOTKEYS.each_with_object({}) do |(code, name), values|
values[code] = [name, code > 0 ? "Press #{name} anywhere in the game." : "Only Write Party Sheet writes it."]
end
end
# Puts Shown Theme into the menu, below Theme, while Theme is Static, and takes it out otherwise.
# The config windows call it before they draw, so the menu follows every change.
#
# @return [Boolean] whether the menu changed
def self.arrange
return false unless @shown_theme
listed = @menu.any? { |item| item.equal?(@shown_theme) }
return false if listed == static_theme?
if listed
@menu.reject! { |item| item.equal?(@shown_theme) }
else
@menu.insert(@menu.index { |item| item.equal?(@theme) } + 1, @shown_theme)
end
true
end
# Reads an option as the menu currently shows it.
#
# @param key [Symbol] the option
# @return [Integer] its value, its default until it was changed
def self.value(key)
value = $game_system.conf[key] rescue nil
value.nil? ? NWConst::Config::DEFAULT[key] : value
end
# @return [Integer] the Windows code of the key that writes the sheet, 0 for none
def self.hotkey
code = value(HOTKEY).to_i
list_hotkey(code)
code
end
# Adds a key to Hotkey's values when they lack it, as a key bound in Mod Config Remake 1.3.0 or
# later may be once the save is played with an older menu.
#
# The game's own Config window fails on a value it has no name for.
#
# @param code [Integer] the key's Windows code
def self.list_hotkey(code)
values = NWConst::Config::DATA[HOTKEY]
return if values.nil? || values.include?(code)
name = Keyboard.name(code)
values.insert(-2, code)
NWConst::Config::DATA_TEXT[HOTKEY][code] = { :name => name, :help => "Press #{name} anywhere in the game." }
end
# @return [Boolean] whether the sheet writes the page
def self.page?
value(OUTPUT) != 2
end
# @return [Boolean] whether the sheet takes the image of the Frontline
def self.image?
value(OUTPUT) != 1
end
# @return [Boolean] whether the colours are always the ones of Shown Theme
def self.static_theme?
value(THEME) == STATIC
end
# @return [Boolean] whether the page is white and gold rather than dark and purple
def self.light?
static_theme? ? value(SHOWN_THEME) == LIGHT : Party.side == :ilias
end
end
# Turns game values into text that is safe inside the page.
module Text
# Characters HTML reads as markup, by their escaped form.
ESCAPES = { "&" => "&", "<" => "<", ">" => ">", '"' => """, "'" => "'" }
# Control codes the game's windows draw as colors or icons, like \C[2] or \I[125], written with
# a backslash or, once the windows converted them, with an escape character.
CONTROL_CODE = /[\\\e][A-Za-z]+(?:\[[^\]]*\])?|[\\\e][{}|.!<>^$]/
# Spaces, with the no-break space some of the game's written lines use and the full-width one,
# which \s leaves out in Ruby 1.9.
SPACES = /[\s ]+/
# @param value [Object] the value
# @return [String] the value as text, escaped for HTML
def self.html(value)
value.to_s.gsub(/[&<>"']/) { |char| ESCAPES[char] }
end
# @param value [Object] text from the game's data
# @return [String] the text without control codes or repeated spaces, escaped for HTML
def self.game(value)
html(plain(value))
end
# @param value [Object] text from the game's data, like a description, over several lines
# @return [String] each line without control codes, escaped for HTML, joined by line breaks
def self.lines(value)
value.to_s.split(/\r?\n/).map { |line| game(line) }.reject(&:empty?).join("
")
end
# @param value [Object] text from the game's data
# @return [String] the text without control codes, with every run of spaces as one plain space
def self.plain(value)
value.to_s.gsub(CONTROL_CODE, "").gsub(SPACES, " ").strip
end
# Smallest number the status screen shortens.
LARGE = 10_000_000
# @param value [Integer] a whole number
# @return [String] the number with thousands separators, like 1,234,567
def self.number(value)
value.to_i.to_s.reverse.scan(/\d{1,3}/).join(",").reverse
end
# Shortens a number like the status screen does, with the units of the game or its translation.
#
# @param value [Integer] a whole number
# @return [String] the number shortened from LARGE on, like 12.346Mil., with separators below
def self.large(value)
value = value.to_i
return number(value) if value < LARGE || !value.respond_to?(:give_unit_floor)
html(value.give_unit_floor(12))
end
# @param rate [Float] a rate, 1.0 being 100%
# @return [String] the rate in percent, cut like the game's status screen does
def self.percent(rate)
"#{(rate * 100).to_i}%"
end
# @param path [String] a path relative to the page, or a full one with its drive
# @return [String] the path percent-encoded for a URL
def self.url(path)
path.gsub(/[^A-Za-z0-9\-._~\/:]/) { |char| char.unpack("C*").map { |byte| format("%%%02X", byte) }.join }
end
end
# What the page shows of the party, read from the game.
module Party
# Extensions a picture may have, in the order they are looked for.
PICTURE_EXTENSIONS = [".png", ".jpg"]
# @return [Array] the members who fight
def self.front
$game_party.battle_members
end
# @return [Array] the members who wait in reserve
def self.reserve
$game_party.all_members - front
end
# @param actor [Game_Actor] the actor
# @return [RPG::Class, nil] the job, nil while she has none
def self.job(actor)
actor.class_id > 0 ? $data_classes[actor.class_id] : nil
end
# @param actor [Game_Actor] the actor
# @return [RPG::Class, nil] the race, nil while she has none
def self.race(actor)
actor.tribe_id > 0 ? $data_classes[actor.tribe_id] : nil
end
# @return [Game_Actor, nil] the member the status screen shows, nil outside it
def self.status_actor
scene = SceneManager.scene
scene.is_a?(Scene_Status) ? scene.instance_variable_get(:@actor) : nil
end
# Finds the jobs or races of the highest rank an actor has any levels in, mastered or not.
#
# @param actor [Game_Actor] the actor
# @param kind [Symbol] :job? for jobs, :tribe? for races
# @return [Array(Integer, Array)] the rank and its jobs or races in id order, nil
# and none when she has no levels in any
def self.top_rank(actor, kind)
classes = actor.level_list.keys.map { |id| $data_classes[id] }.compact.select { |data| data.send(kind) }
return [nil, []] if classes.empty?
rank = classes.map(&:class_lank).max
[rank, classes.select { |data| data.class_lank == rank }.sort_by(&:id)]
end
# Lists the jobs or races an actor has mastered, in the order of the game's status screen.
#
# A job or race whose level in level_list reached its max_lv counts as mastered.
#
# @param actor [Game_Actor] the actor
# @param kind [Symbol] :job? for jobs, :tribe? for races
# @return [Array] the jobs or races
def self.mastered(actor, kind)
ids = actor.level_list.select do |id, level|
data = $data_classes[id]
data && data.send(kind) && level >= data.max_lv
end.keys
ids.sort_by { |id| [$data_classes[id].class_lank, id] }.map { |id| $data_classes[id] }
end
# Names the ranks of jobs or races as the job change screen does, like Basic Jobs or Sealed Races.
#
# @param type [Integer] 0 for jobs, 1 for races
# @return [Hash{Integer => String}] the names by rank, in the screen's order, empty when the
# game has none
def self.ranks(type)
names = NWConst::JobChange::CLASS_TYPE[type] || []
names.each_with_index.each_with_object({}) { |(name, rank), ranks| ranks[rank] = name unless name.to_s.empty? }
rescue NameError
{}
end
# Finds the picture the Library shows of a companion.
#
# @param actor [Game_Actor] the actor
# @return [String, nil] its path relative to the game folder, nil when it is not a loose file
def self.picture(actor)
image = NWConst::Library::ACTOR_IMAGE[actor.id]
return nil unless image.is_a?(Array)
PICTURE_EXTENSIONS.map { |extension| "#{image[0]}#{image[1]}#{extension}" }
.find { |file| MGQ_PartySheet.exist?(file) }
end
# @param actor [Game_Actor] the actor
# @return [String, nil] the path of her face sheet relative to the game folder, nil when it is
# not a loose file
def self.face(actor)
file = "Graphics/Faces/#{actor.face_name}.png"
!actor.face_name.to_s.empty? && MGQ_PartySheet.exist?(file) ? file : nil
end
# @return [Boolean] whether the icon sheet is a loose file the page can show
def self.icons?
@icons = MGQ_PartySheet.exist?("Graphics/System/IconSet.png") if @icons.nil?
@icons
end
# Pairs the slots of an actor with what she wears, as the game's status screen does.
#
# @param actor [Game_Actor] the actor
# @return [Array] the slot names and items, nil for an empty slot
def self.equipment(actor)
equips = actor.equips
actor.basic_equip_slots.each_with_index.map { |etype, index| [Vocab.etype(etype), equips[index]] }
end
# Lists what an item gives on its own, its gems left out, as the game's status screen names it:
# its stat bonuses, then its effects. An item whose description adds lines of its own shows
# those instead of its effects' names, as in the game.
#
# The socket count the game lists first is left out, as the gems show on their own.
#
# @param item [RPG::EquipItem, RPG::Armor] the item, or an armor that carries a gem's effects
# @return [Array] the bonuses, without control codes or repeats
def self.bonuses(item)
sockets = item.respond_to?(:socket?) && item.socket? ? Text.plain(item.socket_name) : nil
names = [:param_names, :enchant_names].select { |method| item.respond_to?(method) }.map { |method| item.send(method) }
names.flatten.compact.map { |name| Text.plain(name) }.reject { |name| name.empty? || name == sockets }.uniq
end
# @param item [RPG::EquipItem] the item
# @return [Array] the gem in each of its sockets, nil for an empty one
def self.gems(item)
item.respond_to?(:stones) ? item.stones : []
end
# A gem's effects come from the armors it names, which the game adds to whoever wears the item.
#
# @param gem [RPG::Item] the gem
# @return [Array] everything the gem gives, without control codes
def self.gem_bonuses(gem)
Array(gem.enchant_stone_id).flatten.map { |id| $data_armors[id] }.compact.map { |armor| bonuses(armor) }.flatten.uniq
end
# Feature names, by the game's naming method, that Effects leaves out: they show as resists, stats,
# skill types or boosts, or only mean something on the item that has them.
NOT_EFFECTS = [:element_rate_name, :state_rate_name, :state_resist_name, :element_drain_name,
:param_name, :xparam_name, :xparam_ex_name, :sparam_name,
:stype_add_name, :stype_seal_name, :multi_booster_name,
:equip_wtype_name, :equip_atype_name, :equip_fix_name, :equip_seal_name,
:equip_mastery_name, :collaplse_type_name, :dummy_enchant_name]
# Lists the resists an actor has with everything she wears, as the game's status screen shows
# them, lists a mod added there included. Without that screen, the element and status resists
# the game's own lists name.
#
# @param actor [Game_Actor] the actor
# @return [Array)>] each list's name
# and its rows: the icon, name, resist and its kind
def self.resist_lists(actor)
lists = begin
StatusScreen.lists(actor)
rescue => e
MGQ_PartySheet.log("status screen of #{actor.name} not read: #{e.class}: #{e.message}")
nil
end
return lists if lists && !lists.empty?
[["Element Resists", element_resists(actor)], ["Status Resists", state_resists(actor)]]
end
# Lists the element resists an actor has with everything she wears, as the game's status
# screen shows them.
#
# @param actor [Game_Actor] the actor
# @return [Array] the icon, element, resist and its kind
def self.element_resists(actor)
extended = $game_switches[NWConst::Sw::ADD_ELEMENT_RESIST]
ids = extended ? NWConst::Status::ADD_ELEMENT_RESIST : NWConst::Status::ELEMENT_RESIST
icons = extended ? NWConst::Status::ADD_ELEMENT_ICONS : NWConst::Status::ELEMENT_ICONS
ids.each_with_index.map do |id, index|
[icons[index], $data_system.elements[id]] + element_resist(actor, id)
end
end
# @param actor [Game_Actor] the actor
# @param id [Integer] the element
# @return [Array(String, Symbol)] the resist as the status screen writes it, and its kind
def self.element_resist(actor, id)
return ["REFLECT", :reflect] if actor.element_reflection(id)
return ["ABSORB", :absorb] if actor.element_drain?(id)
resist(actor.element_rate(id))
end
# Lists the status resists an actor has with everything she wears, as the game's status
# screen shows them.
#
# @param actor [Game_Actor] the actor
# @return [Array] the icon, state, resist and its kind
def self.state_resists(actor)
NWConst::Status::STATE_RESIST.map do |id|
state = $data_states[id]
[state.icon_index, state.name] + resist(actor.state_resist?(id) ? 0.0 : actor.state_rate(id))
end
end
# @param rate [Float] how much of an element or state gets through, 1.0 being all of it
# @return [Array(String, Symbol)] the rate as the status screen writes it, and whether it is
# :good, :bad, :normal or :null
def self.resist(rate)
percent = (rate * 100).to_i
return ["NULL", :null] if percent == 0
["#{percent}%", percent < 100 ? :good : percent > 100 ? :bad : :normal]
end
# @param actor [Game_Actor] the actor
# @return [Array] the skill types she can use, and whether each is sealed
def self.skill_types(actor)
actor.added_skill_types.sort.map { |id| [$data_system.skill_types[id], actor.skill_type_sealed?(id)] }
end
# Names what an actor's job, race, equipment, abilities and states give her beyond resists, stats
# and boosts, the way the game names it on equipment. Effects of one kind are combined the way
# the game combines them; kinds it applies one by one stay apart.
#
# @param actor [Game_Actor] the actor
# @return [Array] each effect's category and name, how
# its sources were combined, nil for one source, and how many of it apply one by one, the
# largest first
def self.effects(actor)
objects = actor.feature_objects
table = namer.enchant_method_table
codes = table.keys.reject { |code| NOT_EFFECTS.include?(table[code]) }
effects = codes.map { |code| Combine.effects(without_costs(objects.features(code))) }.flatten(1)
by_magnitude(effects + costs(actor)) + chains(actor)
end
# Rates of skill costs, by their data id's name, which costs shows as the rates they add up to.
COST_FEATURES = [:STYPE_COST_RATE, :SKILL_COST_RATE, :TP_COST_RATE, :HP_COST_RATE]
# @param features [Array] features of one code
# @return [Array] the features without the rates of skill costs
def self.without_costs(features)
code = Combine.constant(nil, :FEATURE_BATTLER_ABILITY)
ids = COST_FEATURES.map { |name| Combine.constant(:Battler, name) }.compact
features.reject { |feature| feature.code == code && ids.include?(feature.data_id) }
end
# Each kind of skill cost: its effect category, the actor's method for the rate of every skill,
# and the game's name of the kind.
COSTS = [["HP", :hp_cost_rate, :HP], ["MP", :mcr, :MP], ["SP", :tp_cost_rate, :TP]]
# Works out what an actor's skills cost against their base cost, by the methods the game prices
# skills with: the rate of every skill, times the rate of each of a skill's types, times the
# skill's own rate. Lists the types and the skills she knows whose rate differs from every
# skill's, then the rate of every other skill unless it is 100%.
#
# @param actor [Game_Actor] the actor
# @return [Array] each rate's category and name, like
# "Dagger SP Cost 514.8%", the rates it multiplies, nil for the rate of every skill, and 1
def self.costs(actor)
skills = actor.skills.reject { |skill| Text.plain(skill.name).empty? }
stypes = skills.map(&:stypes).flatten.uniq.sort
own_ids = own_cost_skill_ids(actor)
COSTS.map do |category, method, type|
next [] unless actor.respond_to?(method)
every = actor.send(method)
lines = stypes.map do |id|
rate = actor.stype_cost_rate(id, type)
next nil if rate == 1.0
name = Text.plain($data_system.skill_types[id])
cost_line(category, name, [["Every skill", every], [name, rate]])
end.compact
lines += skills.select { |skill| own_ids.include?(skill.id) }.map do |skill|
own = actor.skill_cost_rate(skill.id, type)
next nil if own == 1.0
name = Text.plain(skill.name)
types = skill.stypes.map { |id| [Text.plain($data_system.skill_types[id]), actor.stype_cost_rate(id, type)] }
cost_line(category, name, [["Every skill", every]] + types + [[name, own]])
end.compact
lines.unshift(cost_line(category, lines.empty? ? nil : "Other Skills", [["Every skill", every]])) unless every == 1.0
lines
end.flatten(1)
end
# @param actor [Game_Actor] the actor
# @return [Array] the skills that have a cost rate of their own
def self.own_cost_skill_ids(actor)
code = Combine.constant(nil, :FEATURE_BATTLER_ABILITY)
id = Combine.constant(:Battler, :SKILL_COST_RATE)
return [] unless code && id
actor.feature_objects.features(code).select { |feature| feature.data_id == id }.map { |feature| feature.value[:id] }.uniq
end
# @param category [String] the cost's effect category, HP, MP or SP
# @param subject [String, nil] the skill type or skill the rate is for, nil for every skill
# @param factors [Array] the rates the game multiplies, each with what it is for
# @return [Array(String, String, String, Integer)] the rate's category and name, the factors
# other than 100% multiplied, nil for fewer than two, and 1
def self.cost_line(category, subject, factors)
total = factors.inject(1.0) { |all, (_, rate)| all * rate }
shown = factors.reject { |_, rate| rate == 1.0 }
parts = shown.map { |label, rate| "#{label} #{rate_text(rate)}" }
formula = shown.size > 1 ? "#{parts.join(' × ')} = #{rate_text(total)}" : nil
[category, "#{subject ? "#{subject} " : ''}#{category} Cost #{rate_text(total)}", formula, 1]
end
# @param rate [Float] a rate, 1.0 being 100%
# @return [String] the rate in percent with up to two decimals, like 514.8%
def self.rate_text(rate)
percent = (rate * 100).round(2)
"#{percent == percent.to_i ? percent.to_i : percent}%"
end
# Category of the skill chains among the effects.
CHAINS = "Chains"
# Category of the damage and cost of chained skills among the effects.
CHAIN_EFFECTS = "Chain Effects"
# Lists the skill chains an actor has, which the game has no names for: after a skill of a
# chain's first skill type she acts again, free, with a skill of the next type, and so on to
# its end. Chains of one first type are merged as the game merges them, so a chain only runs on
# from the type the first skill had; first types with the same chains share a line. The damage
# and cost of chained skills follow, under CHAIN_EFFECTS.
#
# @param actor [Game_Actor] the actor
# @return [Array] each chain's category and name, like
# "Dagger → Throwing → Ninjutsu", how its sources were combined and 1
def self.chains(actor)
code = Combine.constant(nil, :FEATURE_SKILL_CHAIN)
return [] unless code
objects = actor.feature_objects
trees = {}
objects.features(code).each do |feature|
node = (trees[feature.data_id] ||= {})
Array(feature.value).each { |id| node = (node[id] ||= {}) }
end
return [] if trees.empty?
lines = trees.keys.group_by { |id| trees[id] }.map do |tree, firsts|
first = firsts.map { |id| Text.plain($data_system.skill_types[id]) }.join(", ")
chain_paths(tree).map { |path| [CHAINS, ([first] + path.map { |id| Text.plain($data_system.skill_types[id]) }).join(" → "), nil, 1] }
end.flatten(1)
lines + chain_rates(objects)
end
# @param tree [Hash{Integer => Hash}] the skill types that may follow, each with its own
# @return [Array>] every way through the tree to an end
def self.chain_paths(tree)
tree.map { |id, rest| rest.empty? ? [[id]] : chain_paths(rest).map { |path| [id] + path } }.flatten(1)
end
# The features of chained skills, by their name, how the game combines them and their label.
CHAIN_RATES = [[:FEATURE_SKILL_CHAIN_BOOST, :sum, "Chained Skills Damage +%s"],
[:FEATURE_SKILL_CHAIN_COST_RATE, :min, "Chained Skills Cost %s"]]
# @param objects [Array] what gives the actor her features
# @return [Array] the extra damage chained skills deal,
# added up, and what they cost, the lowest rate counting, each with how its sources combine
def self.chain_rates(objects)
CHAIN_RATES.map do |name, rule, label|
code = Combine.constant(nil, name)
values = code ? objects.features(code).map(&:value) : []
next nil if values.empty?
formula = values.size > 1 ? Combine.formula(rule, values) : nil
[CHAIN_EFFECTS, format(label, Combine.amount(Combine.total(rule, values))), formula, 1]
end.compact
end
# Combines an actor's boosters per element, skill type, weapon type or skill, the way the game
# combines them for damage, and names the totals like the game names boosters on equipment.
#
# @param actor [Game_Actor] the actor
# @return [Array] each boost's category and name, and how its
# sources were combined, nil for one source, the largest first
def self.boosts(actor)
code = namer.enchant_method_table.key(:multi_booster_name)
by_magnitude(Combine.boosts(code, actor.feature_objects.features(code)))
end
# The amount in an effect's or boost's name, like 150 in Dagger Booster 150%.
AMOUNT = /\d+(?:\.\d+)?/
# Sorts effects or boosts by the amount in their names, the largest first, and those of one
# amount by name. Those without an amount follow, by name.
#
# @param entries [Array] the effects or boosts, each with its name second
# @return [Array] the entries sorted
def self.by_magnitude(entries)
entries.each_with_index.sort_by do |entry, index|
name = Text.plain(entry[1])
amount = name.gsub(/(\d),(\d)/, '\1\2')[AMOUNT]
[amount ? 0 : 1, amount ? -amount.to_f : 0, name.downcase, index]
end.map(&:first)
end
# @param features [Array] the features
# @return [Array] their names on equipment, leaving out the ones the game has no name for
def self.names_of(features)
features.map do |feature|
method = namer.enchant_method_table[feature.code]
begin
method ? namer.send(method, feature) : nil
rescue
nil
end
end.flatten.compact.map { |name| name.to_s.strip }.reject(&:empty?)
end
# @return [RPG::BaseItem] an item whose methods name features the way the equipment screens do
def self.namer
@namer ||= RPG::BaseItem.new
end
# @param names [Array] names, some repeated
# @return [Array] each name once with how often it came, in first order
def self.tally(names)
counts = Hash.new(0)
names.each { |name| counts[name] += 1 }
counts.to_a
end
# Lists an actor's equipped abilities by category, in the order of the game's ability screen.
# Proof Abilities, which only Part 3 has, cost no AP.
#
# @param actor [Game_Actor] the actor
# @return [Array, Array(Integer, Integer))>] each category
# she has abilities equipped in: its skill type, name, abilities, and AP used and maximum, nil
# for Proof Abilities
def self.abilities(actor)
NWConst::Ability::INIT_ABILITY_SKILL_TYPE.map do |id|
skills = (actor.equip_abilities[id] || []).map { |skill_id| $data_skills[skill_id] }.compact
next nil if skills.empty?
ap = NWConst::Ability::ABILITY_SKILL_TYPE.include?(id) ? [actor.ap(id), actor.max_ap(id)] : nil
[id, $data_system.skill_types[id], skills, ap]
end.compact
end
# @param actor [Game_Actor] the actor
# @return [Array, nil] the name of her trait followed by its description lines, nil when
# she has none
def self.trait(actor)
NWConst::Library::ACTOR_FIX_ABILITY[actor.id]
end
# @return [String] the name of the map the party is on, "" on a map without one
def self.location
$game_map.display_name.to_s
rescue
""
end
# Side chosen, by the name in the editor of the switch that records the choice, in the
# translation and in the untranslated game.
SIDES = { "Ilias Chosen" => :ilias, "イリアス選択" => :ilias, "Alice Chosen" => :alice, "アリス選択" => :alice }
# Reads the switches by their names, so no id is hard-coded.
#
# @return [Symbol, nil] the side this playthrough chose, :ilias or :alice, nil before the choice
def self.side
switch = SIDES.keys.find do |name|
id = $data_system.switches.index(name)
id && $game_switches[id]
end
SIDES[switch]
rescue
nil
end
end
# Reads the lists the game's status screen shows under Basic Info, so a list a mod adds there
# shows on the sheet too. Each page is drawn in a window that is never shown, which only records
# what it would draw; pages drawn as rows of icon, name and value are the lists.
module StatusScreen
# Rows a page needs at least to count as a list.
MIN_ROWS = 2
# Values the status screen writes in place of a percentage, by their kind.
WORDS = { "NULL" => :null, "REFLECT" => :reflect, "ABSORB" => :absorb }
# Kinds of a value the status screen writes in words this mod does not know, by the colour
# method it draws them in.
COLORS = { :special_color => :null, :crisis_color => :absorb, :good_color => :good, :bad_color => :bad }
# @param actor [Game_Actor] the actor
# @return [Array)>, nil] each list's
# page name and rows: the icon, name, value and its kind, nil without the game's status screen
def self.lists(actor)
return nil unless defined?(Foo::Status::Window_MainStatus)
window = Foo::Status::Window_MainStatus.new
window.visible = false
calls = record(window)
window.actor = actor
window.root_index = 0
names = window.data[0]
names.each_index.map do |index|
calls.clear
window.select(index)
rows = rows_of(window, calls)
rows.size >= MIN_ROWS ? [Text.plain(names[index]), rows] : nil
end.compact
ensure
window.dispose if window && !window.disposed?
end
# Makes a window record the icons and texts it draws instead of drawing them.
#
# @param window [Window_Base] the window
# @return [Array] the list the window records into: [:icon, icon, x, y] and
# [:text, x, y, width, text, alignment, colour]
def self.record(window)
calls = []
window.define_singleton_method(:draw_icon) { |icon, x, y, *rest| calls << [:icon, icon, x.to_i, y.to_i] }
window.define_singleton_method(:draw_text) do |*args|
rect = args[0].is_a?(Rect) ? args.shift : Rect.new(*args.shift(4))
calls << [:text, rect.x.to_i, rect.y.to_i, rect.width.to_i, args[0].to_s, args[1] || 0, contents.font.color.dup]
end
calls
end
# Pairs each icon a page drew with the name written beside it and the value written at the
# right of the same line.
#
# @param window [Window_Base] the window that drew the page
# @param calls [Array] what it drew, see record
# @return [Array] the rows: icon, name, value and kind
def self.rows_of(window, calls)
texts = calls.select { |call| call[0] == :text }
calls.select { |call| call[0] == :icon }.map do |_, icon, x, y|
beside = texts.select { |text| text[2] == y && text[1] >= x && text[1] <= x + Images::ICON_SIZE * 2 }
name = beside.find { |text| text[5] != 2 }
value = beside.find { |text| text[5] == 2 }
next nil unless name && value
written = Text.plain(value[4])
[icon, Text.plain(name[4]), written, kind(window, written, value[6])]
end.compact
end
# @param window [Window_Base] the window that drew the value
# @param written [String] the value, like 50%, NULL or REFLECT
# @param color [Color] the colour it was drawn in
# @return [Symbol] whether it is :good, :bad, :normal, :null, :reflect or :absorb
def self.kind(window, written, color)
return WORDS[written] if WORDS.key?(written)
percent = written[/-?\d+/]
if percent
return :good if percent.to_i < 100
return :bad if percent.to_i > 100
return :normal
end
COLORS.each do |method, kind|
drawn = window.respond_to?(method) ? window.send(method) : nil
return kind if drawn && [:red, :green, :blue, :alpha].all? { |part| drawn.send(part) == color.send(part) }
end
:normal
end
end
# Combines features of one kind the way the game does in battle, so the sheet shows the value that
# applies instead of each source's.
module Combine
# How the game combines each kind of effect, read from its Game_BattlerBase: the feature code,
# the module of its data id and the data id, nil for every data id of the code, the rule, and
# whether its values are counts rather than rates. Kinds left out are applied one by one.
#
# Rules: :sum adds up, :product multiplies, :max and :min keep one, :chance combines chances as
# 1 - (1 - a)(1 - b), :once is a flag that applies once however often it comes, :cost multiplies
# cost rates per skill type or skill, :tp adds up max SP, :exp keeps the best bonus less the
# worst cut, :union joins lists of states, :repeat adds up extra strikes.
RULES = [
[:FEATURE_DEBUFF_RATE, nil, nil, :product],
[:FEATURE_ATK_STATE, nil, nil, :sum],
[:FEATURE_ATK_TIMES, nil, nil, :sum, true],
[:FEATURE_ATK_SPEED, nil, nil, :once],
[:FEATURE_ATK_ELEMENT, nil, nil, :once],
[:FEATURE_SLOT_TYPE, nil, nil, :once],
[:FEATURE_SPECIAL_FLAG, nil, nil, :once],
[:FEATURE_PARTY_ABILITY, nil, nil, :once],
[:FEATURE_BLOCK_RATE, nil, nil, :chance],
[:FEATURE_TERRAIN_BOOSTER, nil, nil, :max],
[:FEATURE_PARTY_EX_ABILITY, :PartyEx, :GET_GOLD_RATE, :max],
[:FEATURE_PARTY_EX_ABILITY, :PartyEx, :GET_ITEM_RATE, :max],
[:FEATURE_PARTY_EX_ABILITY, :PartyEx, :COLLECT_RATE, :max],
[:FEATURE_PARTY_EX_ABILITY, :PartyEx, :ENCOUNTER_RATE, :product],
[:FEATURE_PARTY_EX_ABILITY, :PartyEx, :SLOT_CHANCE, :once],
[:FEATURE_PARTY_EX_ABILITY, :PartyEx, :UNLOCK_LEVEL, :max, true],
[:FEATURE_BATTLER_ABILITY, :Battler, :STEAL_SUCCESS, :max],
[:FEATURE_BATTLER_ABILITY, :Battler, :AUTO_STAND, :once],
[:FEATURE_BATTLER_ABILITY, :Battler, :HEEL_REVERSE, :once],
[:FEATURE_BATTLER_ABILITY, :Battler, :AUTO_STATE, :union],
[:FEATURE_BATTLER_ABILITY, :Battler, :METAL_BODY, :once],
[:FEATURE_BATTLER_ABILITY, :Battler, :DEFENSE_WALL, :once],
[:FEATURE_BATTLER_ABILITY, :Battler, :DAMAGE_MP_CONVERT, :once],
[:FEATURE_BATTLER_ABILITY, :Battler, :DAMAGE_GOLD_CONVERT, :once],
[:FEATURE_BATTLER_ABILITY, :Battler, :DAMAGE_MP_DRAIN, :once],
[:FEATURE_BATTLER_ABILITY, :Battler, :DAMAGE_GOLD_DRAIN, :once],
[:FEATURE_BATTLER_ABILITY, :Battler, :STYPE_COST_RATE, :cost],
[:FEATURE_BATTLER_ABILITY, :Battler, :SKILL_COST_RATE, :cost],
[:FEATURE_BATTLER_ABILITY, :Battler, :TP_COST_RATE, :product],
[:FEATURE_BATTLER_ABILITY, :Battler, :HP_COST_RATE, :product],
[:FEATURE_BATTLER_ABILITY, :Battler, :GOLD_COST_RATE, :product],
[:FEATURE_BATTLER_ABILITY, :Battler, :INCREASE_TP, :tp],
[:FEATURE_BATTLER_ABILITY, :Battler, :START_TP_RATE, :max],
[:FEATURE_BATTLER_ABILITY, :Battler, :BATTLE_END_HEEL_HP, :max],
[:FEATURE_BATTLER_ABILITY, :Battler, :BATTLE_END_HEEL_MP, :max],
[:FEATURE_BATTLER_ABILITY, :Battler, :CERTAIN_COUNTER, :once],
[:FEATURE_BATTLER_ABILITY, :Battler, :MAGICAL_COUNTER, :chance],
[:FEATURE_BATTLER_ABILITY, :Battler, :PHYSICAL_REFLECTION, :chance],
[:FEATURE_BATTLER_ABILITY, :Battler, :CONSIDERATE, :once],
[:FEATURE_BATTLER_ABILITY, :Battler, :GET_EXP_RATE, :exp],
[:FEATURE_BATTLER_ABILITY, :Battler, :GET_CLASSEXP_RATE, :exp],
[:FEATURE_BATTLER_ABILITY, :Battler, :INVOKE_REPEATS_TYPE, :repeat],
[:FEATURE_BATTLER_ABILITY, :Battler, :INVOKE_REPEATS_SKILL, :repeat],
[:FEATURE_BATTLER_ABILITY, :Battler, :OWN_CRUSH_RESIST, :once],
[:FEATURE_BATTLER_ABILITY, :Battler, :IGNORE_OVER_DRIVE, :once],
]
# How the game combines each kind of booster, by its data id's name. Boosters by element, skill
# type, weapon type or skill are added up per key; kinds left out are applied one by one.
BOOSTERS = {
:ELEMENT => :sum, :WEAPON_PHYSICAL => :sum, :WEAPON_MAGICAL => :sum, :WEAPON_CERTAIN => :sum,
:STATE_RATIO_TYPE => :sum, :STATE_FIX_TYPE => :sum, :SKILL_TYPE => :sum,
:STATE_RATIO_SKILL => :sum, :SKILL => :sum, :WTYPE_SKILL => :sum,
:CRITICAL => :sum, :COUNTER => :max, :OVER_SOUL => :max, :FALL_HP => :once,
}
# Boosters whose value holds a rate per element, skill type, weapon type or skill.
KEYED_BOOSTERS = [:ELEMENT, :WEAPON_PHYSICAL, :WEAPON_MAGICAL, :WEAPON_CERTAIN, :STATE_RATIO_TYPE,
:STATE_FIX_TYPE, :SKILL_TYPE, :STATE_RATIO_SKILL, :SKILL, :WTYPE_SKILL]
# Categories the effects are sorted into, by their feature code, the module of their data id and
# the data id, nil for every data id of the code. Costs per skill type or skill go by the
# resource they cost, see COST_CATEGORIES; effects left out go to Other.
CATEGORIES = [
["Strikes", :FEATURE_ATK_ELEMENT, nil, nil],
["Strikes", :FEATURE_ATK_STATE, nil, nil],
["Strikes", :FEATURE_ATK_TIMES, nil, nil],
["Strikes", :FEATURE_ATK_SPEED, nil, nil],
["Strikes", :FEATURE_BATTLER_ABILITY, :Battler, :NORMAL_ATTACK],
["Strikes", :FEATURE_BATTLER_ABILITY, :Battler, :INVOKE_REPEATS_TYPE],
["Strikes", :FEATURE_BATTLER_ABILITY, :Battler, :INVOKE_REPEATS_SKILL],
["Wielding", :FEATURE_SLOT_TYPE, nil, nil],
["SP", :FEATURE_BATTLER_ABILITY, :Battler, :TP_COST_RATE],
["SP", :FEATURE_BATTLER_ABILITY, :Battler, :INCREASE_TP],
["Battle Start", :FEATURE_BATTLER_ABILITY, :Battler, :START_TP_RATE],
["SP", :FEATURE_SPECIAL_FLAG, nil, :PRESERVE_TP],
["MP", :FEATURE_BATTLER_ABILITY, :Battler, :DAMAGE_MP_CONVERT],
["MP", :FEATURE_BATTLER_ABILITY, :Battler, :DAMAGE_MP_DRAIN],
["HP", :FEATURE_BATTLER_ABILITY, :Battler, :HP_COST_RATE],
["HP", :FEATURE_BATTLER_ABILITY, :Battler, :HEEL_REVERSE],
["HP", :FEATURE_BATTLER_ABILITY, :Battler, :TRIGGER_STATE],
["Gold", :FEATURE_BATTLER_ABILITY, :Battler, :GOLD_COST_RATE],
["Gold", :FEATURE_BATTLER_ABILITY, :Battler, :DAMAGE_GOLD_CONVERT],
["Gold", :FEATURE_BATTLER_ABILITY, :Battler, :DAMAGE_GOLD_DRAIN],
["Defense", :FEATURE_BLOCK_RATE, nil, nil],
["Defense", :FEATURE_DEBUFF_RATE, nil, nil],
["Defense", :FEATURE_BATTLER_ABILITY, :Battler, :PHYSICAL_REFLECTION],
["Defense", :FEATURE_BATTLER_ABILITY, :Battler, :AUTO_STAND],
["Defense", :FEATURE_BATTLER_ABILITY, :Battler, :METAL_BODY],
["Defense", :FEATURE_BATTLER_ABILITY, :Battler, :DEFENSE_WALL],
["Defense", :FEATURE_BATTLER_ABILITY, :Battler, :INVALIDATE_WALL],
["Defense", :FEATURE_BATTLER_ABILITY, :Battler, :OWN_CRUSH_RESIST],
["Defense", :FEATURE_BATTLER_ABILITY, :Battler, :IGNORE_OVER_DRIVE],
["Defense", :FEATURE_SPECIAL_FLAG, nil, :GUARD],
["Defense", :FEATURE_SPECIAL_FLAG, nil, :SUBSTITUTE],
["Counters", :FEATURE_BATTLER_ABILITY, :Battler, :CERTAIN_COUNTER],
["Counters", :FEATURE_BATTLER_ABILITY, :Battler, :MAGICAL_COUNTER],
["Counters", :FEATURE_BATTLER_ABILITY, :Battler, :CONSIDERATE],
["Battle Start", :FEATURE_BATTLER_ABILITY, :Battler, :AUTO_STATE],
["Battle Start", :FEATURE_BATTLER_ABILITY, :Battler, :BATTLE_START_SKILL],
["Turn", :FEATURE_BATTLER_ABILITY, :Battler, :TURN_START_SKILL],
["Turn", :FEATURE_BATTLER_ABILITY, :Battler, :TURN_END_SKILL],
["Turn", :FEATURE_ACTION_PLUS, nil, nil],
["Defeat", :FEATURE_BATTLER_ABILITY, :Battler, :DEAD_SKILL],
["Defeat", :FEATURE_BATTLER_ABILITY, :Battler, :FINAL_INVOKE],
["After Battle", :FEATURE_BATTLER_ABILITY, :Battler, :BATTLE_END_HEEL_HP],
["After Battle", :FEATURE_BATTLER_ABILITY, :Battler, :BATTLE_END_HEEL_MP],
["After Battle", :FEATURE_BATTLER_ABILITY, :Battler, :GET_EXP_RATE],
["After Battle", :FEATURE_BATTLER_ABILITY, :Battler, :GET_CLASSEXP_RATE],
["After Battle", :FEATURE_BATTLER_ABILITY, :Battler, :STEAL_SUCCESS],
["Party", :FEATURE_PARTY_ABILITY, nil, nil],
["Party", :FEATURE_PARTY_EX_ABILITY, nil, nil],
]
# Order of the effect categories on the sheet.
CATEGORY_ORDER = ["Strikes", "Chains", "Chain Effects", "Wielding", "SP", "MP", "HP", "Gold", "Defense", "Counters",
"Battle Start", "Turn", "Defeat", "After Battle", "Party", "Other"]
# Category of a cost per skill type or skill, by the resource it costs as the game names it.
COST_CATEGORIES = { "TP" => "SP", "MP" => "MP", "HP" => "HP", "GOLD" => "Gold" }
# Category of each kind of booster, by its data id's name. Kinds left out go to Other.
BOOST_CATEGORIES = {
:ELEMENT => "Element",
:SKILL_TYPE => "Skill Type", :STATE_RATIO_TYPE => "Skill Type", :STATE_FIX_TYPE => "Skill Type",
:WEAPON_PHYSICAL => "Weapon", :WEAPON_MAGICAL => "Weapon", :WEAPON_CERTAIN => "Weapon", :WTYPE_SKILL => "Weapon",
:SKILL => "Skill", :STATE_RATIO_SKILL => "Skill",
:CRITICAL => "Critical & Counter", :COUNTER => "Critical & Counter", :OVER_SOUL => "Critical & Counter",
}
# Order of the booster categories on the sheet.
BOOST_ORDER = ["Element", "Skill Type", "Weapon", "Skill", "Critical & Counter", "Other"]
# How each rule reads in a tooltip.
WORDS = {
:sum => "added up", :product => "multiplied", :max => "the highest counts", :min => "the lowest counts",
:chance => "chances combined", :cost => "multiplied, never below 1%", :tp => "added up",
:exp => "the best bonus less the worst cut",
}
# @param features [Array] features of one code, from every source
# @return [Array] each effect's category and name, how
# its sources were combined, nil for one source, and how many of it apply one by one
def self.effects(features)
features.group_by { |feature| key(feature) }.map do |_, group|
rule, count = rule_for(group.first)
category = category_for(group.first)
(rule ? combined(group, rule, count) : apart(group)).map { |entry| [category] + entry }
end.flatten(1)
end
# @param code [Integer] the booster feature code
# @param features [Array] the boosters, from every source
# @return [Array] each boost's category and name, and how its
# sources were combined, nil for one source
def self.boosts(code, features)
features.group_by(&:data_id).map do |id, group|
name = booster_names[id]
rule = BOOSTERS[name]
category = BOOST_CATEGORIES.fetch(name, "Other")
entries = if rule.nil?
apart(group)
elsif rule == :once
Party.names_of([group.first]).map { |label| [label, nil] }
elsif KEYED_BOOSTERS.include?(name)
parts = {}
group.each { |feature| feature.value.each { |part, value| (parts[part] ||= []) << value } }
parts.map { |part, values| named(code, id, { part => total(rule, values) }, rule, values) }.flatten(1)
else
named(code, id, total(rule, group.map(&:value)), rule, group.map(&:value))
end
entries.map { |label, formula, _| [category, label, formula] }
end.flatten(1)
end
# @param feature [RPG::BaseItem::Feature] the feature
# @return [String] the category its effect is sorted into
def self.category_for(feature)
rule, _ = rule_for(feature)
return COST_CATEGORIES.fetch(feature.value[:type].to_s.upcase, "Other") if rule == :cost
categories[[feature.code, feature.data_id]] || categories[feature.code] || "Other"
end
# CATEGORIES with its names looked up, once. A name the game lacks leaves its entry out.
#
# @return [Hash{Array(Integer, Integer), Integer => String}] the categories by code and data id,
# or by code alone
def self.categories
@categories ||= CATEGORIES.each_with_object({}) do |(category, code, scope, id), categories|
code = constant(nil, code)
next unless code
if id
id = constant(scope, id)
categories[[code, id]] = category if id
else
categories[code] = category
end
end
end
# Combines a group of features of one kind by its rule.
#
# @param group [Array] the features
# @param rule [Symbol] the rule, see RULES
# @param count [Boolean] whether the values are counts rather than rates
# @return [Array] the effects' names, formulas and 1
def self.combined(group, rule, count)
first = group.first
case rule
when :once
Party.names_of([first]).map { |label| [label, nil, 1] }
when :union
named(first.code, first.data_id, group.map(&:value).flatten.uniq, nil, [])
when :repeat
strikes = {}
group.each { |feature| feature.value.each { |part, times| strikes[part] = (strikes[part] || 1) + times - 1 } }
named(first.code, first.data_id, strikes, nil, [])
when :cost
rates = group.map { |feature| feature.value[:rate] }
named(first.code, first.data_id, first.value.merge(:rate => total(:cost, rates)), :cost, rates)
when :tp
amounts = group.map { |feature| feature.value[:plus] ? feature.value[:num] : -feature.value[:num] }
sum = amounts.inject(0) { |all, amount| all + amount }
value = first.value.merge(:plus => sum >= 0, :num => sum.abs)
formula = group.size > 1 ? "#{sources(group.size, :tp)}\n#{amounts.map { |amount| signed(amount) }.join(' ')} = #{signed(sum)}" : nil
Party.names_of([RPG::BaseItem::Feature.new(first.code, first.data_id, value)]).map { |label| [label, formula, 1] }
else
values = group.map(&:value)
named(first.code, first.data_id, total(rule, values), rule, values, count)
end
end
# Names features the game applies one by one, counting the ones that repeat.
#
# @param group [Array] the features
# @return [Array] each name once, with how often it came
def self.apart(group)
Party.tally(Party.names_of(group)).map { |label, times| [label, nil, times] }
end
# Names a combined value, with the formula that led to it.
#
# @param code [Integer] the feature code
# @param id [Integer] the feature's data id
# @param value [Object] the combined value
# @param rule [Symbol, nil] the rule, nil when the formula would show nothing new
# @param values [Array] the values that were combined
# @param count [Boolean] whether the values are counts rather than rates
# @return [Array] the names, the formula, nil for one value, and 1
def self.named(code, id, value, rule, values, count = false)
formula = rule && values.size > 1 ? formula(rule, values, count) : nil
Party.names_of([RPG::BaseItem::Feature.new(code, id, value)]).map { |label| [label, formula, 1] }
end
# @param rule [Symbol] the rule
# @param values [Array] the values
# @return [Numeric] the values combined by the rule
def self.total(rule, values)
case rule
when :sum then values.inject { |all, value| all + value }
when :product then values.inject(1.0) { |all, value| all * value }
when :cost then [values.inject(1.0) { |all, value| all * value }, 0.01].max
when :max then values.max
when :min then values.min
when :chance then 1.0 - values.inject(1.0) { |all, value| all * (1.0 - value) }
when :exp
lowest = ([1.0] + values).min
lowest <= 0.0 ? 0.0 : [values.max, 1.0].max - (1.0 - lowest)
end
end
# @param rule [Symbol] the rule
# @param values [Array] the values that were combined
# @param count [Boolean] whether the values are counts rather than rates
# @return [String] how many sources there were, how they combine, and the sum that shows it
def self.formula(rule, values, count = false)
parts = values.map { |value| amount(value, count) }
result = amount(total(rule, values), count)
sum = case rule
when :sum then "#{parts.join(' + ')} = #{result}"
when :product, :cost then "#{parts.join(' × ')} = #{result}"
when :max then "highest of #{parts.join(', ')} = #{result}"
when :min then "lowest of #{parts.join(', ')} = #{result}"
when :chance then "1 − #{parts.map { |part| "(1 − #{part})" }.join} = #{result}"
when :exp then "best bonus and worst cut of #{parts.join(', ')} = #{result}"
end
"#{sources(values.size, rule)}\n#{sum}"
end
# @param number [Integer] how many sources there were
# @param rule [Symbol] the rule
# @return [String] the tooltip's first line
def self.sources(number, rule)
"From #{number} sources, #{WORDS[rule]}:"
end
# @param value [Numeric] a rate, 1.0 being 100%, or a count
# @param count [Boolean] whether the value is a count
# @return [String] the value as the tooltip shows it
def self.amount(value, count = false)
count ? value.to_s.sub(/\.0\z/, "") : "#{(value * 100).round}%"
end
# @param amount [Integer] a change to max SP
# @return [String] the change with its sign
def self.signed(amount)
amount < 0 ? "−#{-amount}" : "+#{amount}"
end
# Groups features that combine: of one code and data id, and for cost rates of one kind of cost
# and skill type or skill, for max SP of fixed or percent changes.
#
# @param feature [RPG::BaseItem::Feature] the feature
# @return [Array] its group
def self.key(feature)
rule, _ = rule_for(feature)
value = feature.value
extra = case rule
when :cost then [value[:type], value[:id]]
when :tp then [value[:per]]
else []
end
[feature.code, feature.data_id] + extra
end
# @param feature [RPG::BaseItem::Feature] the feature
# @return [Array(Symbol, Boolean), nil] the rule and whether its values are counts, nil when the
# game applies the feature one by one
def self.rule_for(feature)
rules[[feature.code, feature.data_id]] || rules[feature.code]
end
# RULES with its names looked up, once. A name the game lacks leaves its rule out.
#
# @return [Hash{Array(Integer, Integer), Integer => Array(Symbol, Boolean)}] the rules by code
# and data id, or by code alone
def self.rules
@rules ||= RULES.each_with_object({}) do |(code, scope, id, rule, count), rules|
code = constant(nil, code)
next unless code
if id
id = constant(scope, id)
rules[[code, id]] = [rule, count] if id
else
rules[code] = [rule, count]
end
end
end
# @return [Hash{Integer => Symbol}] the booster data ids by their names, once
def self.booster_names
@booster_names ||= BOOSTERS.keys.each_with_object({}) do |name, names|
id = constant(:Booster, name)
names[id] = name if id
end
end
# Looks up a constant where the game's own naming methods find it: in its module when that is
# reachable from RPG::BaseItem, else unqualified, as those methods mostly name it.
#
# @param scope [Symbol, nil] the module inside NWFeature that holds it, nil for none
# @param name [Symbol] the constant
# @return [Integer, nil] its value, nil when the game has none of that name
def self.constant(scope, name)
begin
return RPG::BaseItem.const_get(scope).const_get(name) if scope
rescue NameError
end
RPG::BaseItem.const_get(name)
rescue NameError
nil
end
end
# The src of the page's images, embedded as data URIs or linked from the game folder.
module Images
# Size of a face in Graphics/Faces, and faces per row of a face sheet.
FACE_SIZE = 96
FACES_PER_ROW = 4
# Size of an icon in Graphics/System/IconSet.png, and icons per row.
ICON_SIZE = 24
ICONS_PER_ROW = 16
# Icon of an empty socket, as the game's equipment windows draw it.
EMPTY_SOCKET_ICON = 3471
# Media types by the extensions a picture may have.
MEDIA_TYPES = { ".png" => "image/png", ".jpg" => "image/jpeg" }
# Shows a loose image as it is.
#
# @param file [String] the image, relative to the game folder
# @return [String] the src that shows it
def self.file(file)
return link(file) unless EMBED_IMAGES
data = File.open(MGQ_PartySheet.path(file), "rb") { |stream| stream.read }
data_uri(MEDIA_TYPES[File.extname(file).downcase], data)
end
# Links a loose image from the page, which lies in FOLDER. The page's policy forbids a base tag.
#
# @param file [String] the image, relative to the game folder
# @return [String] its address relative to the page
def self.link(file)
"../#{Text.url(file)}"
end
# Cuts a face out of its sheet, loaded through the game so a packed Game.rgss3a works too.
#
# @param name [String] the face sheet in Graphics/Faces
# @param index [Integer] the face on the sheet
# @return [String] the src that shows it, embedded
def self.face(name, index)
@faces ||= {}
@faces[[name, index]] ||= cell(Cache.face(name), index, FACE_SIZE, FACES_PER_ROW)
end
# Cuts an icon out of the icon sheet, loaded through the game so a packed Game.rgss3a works too.
#
# Embedding the whole sheet would add about 3 MB for a few dozen icons.
#
# @param index [Integer] the icon
# @return [String] the src that shows it, embedded
def self.icon(index)
@icons ||= {}
@icons[index] ||= cell(Cache.system("Iconset"), index, ICON_SIZE, ICONS_PER_ROW)
end
# @param sheet [Bitmap] a sheet of equally sized cells
# @param index [Integer] the cell, counted row by row
# @param size [Integer] the width and height of a cell in pixels
# @param per_row [Integer] the cells per row
# @return [String] a data URI of the cell as a PNG
def self.cell(sheet, index, size, per_row)
png = Png.encode(sheet, index % per_row * size, index / per_row * size, size, size)
data_uri("image/png", png)
end
# @param media_type [String] the image's media type
# @param data [String] the image's bytes
# @return [String] a data URI of the image
def self.data_uri(media_type, data)
"data:#{media_type};base64,#{[data].pack('m').delete("\n")}"
end
end
# Writes part of a Bitmap as a PNG, as RGSS3 has no way to save one.
#
# Reading pixel by pixel is slow, so it suits faces and icons, not the Library's pictures.
module Png
# Bytes every PNG starts with.
SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10].pack("C*")
# IHDR fields after the size: 8 bits per channel, RGBA, deflate, no filter, no interlace.
FORMAT = [8, 6, 0, 0, 0]
# @param bitmap [Bitmap] the image to cut from
# @param x [Integer] left edge of the part
# @param y [Integer] top edge of the part
# @param width [Integer] width of the part
# @param height [Integer] height of the part
# @return [String] the PNG's bytes
def self.encode(bitmap, x, y, width, height)
rows = (y...y + height).map do |row|
channels = (x...x + width).map do |column|
color = bitmap.get_pixel(column, row)
[color.red, color.green, color.blue, color.alpha]
end
([0] + channels.flatten.map(&:to_i)).pack("C*")
end
SIGNATURE +
chunk("IHDR", ([width, height] + FORMAT).pack("N2C5")) +
chunk("IDAT", Zlib::Deflate.deflate(rows.join)) +
chunk("IEND", "")
end
# @param type [String] the chunk's four letter type
# @param data [String] the chunk's bytes
# @return [String] the chunk with its length and checksum
def self.chunk(type, data)
[data.bytesize, type, data, Zlib.crc32(type + data)].pack("NA4A*N")
end
end
# Converts the page's portraits to WebP and takes the image of the Frontline, with a browser that
# runs without a window. A PowerShell script drives the browser, so the game does not wait for it.
module Browser
# Browsers that can do both, relative to the folders programs are installed in. The first one
# found is used.
BROWSERS = ["Microsoft/Edge/Application/msedge.exe", "Google/Chrome/Application/chrome.exe"]
# Folder inside the user's temporary folder that holds the pages, the script and the browser's
# profile. A profile of its own keeps an open browser window out of the way.
WORK_DIR = "MGQ Party Sheet"
# Copy of the page that converts its own portraits, inside WORK_DIR.
CONVERT_FILE = "Convert.html"
# Page each image is taken of, inside WORK_DIR, by the image's number.
SHOT_FILE = "Shot%d.html"
# List of the images to take, inside WORK_DIR: a page and its image per line, split by a tab.
SHOTS_FILE = "Shots.txt"
# Script that drives the browser, inside WORK_DIR.
SCRIPT_FILE = "Browser.ps1"
# Replaces each portrait with its WebP, once the page has loaded, unless the WebP comes out
# larger. The script then takes itself out, puts back the page's policy and marks the body, so
# the page the browser dumps is the page without it.
CONVERT_SCRIPT = ""
# Converts the page: the browser loads the converting copy and dumps it once the portraits are
# WebP, which then replaces the page. The dump joins the html and head tags and leaves blank
# lines where CONVERT_SCRIPT was, which the script mends. Takes the images: the browser measures
# what each page shows, which the page reports on its body, then takes a screenshot at exactly
# that size, as a screenshot holds the browser's window and no more.
SCRIPT = <<-'PS1'
param([string]$Browser, [string]$UserData, [string]$Log, [string]$Convert, [string]$Page, [string]$Shots)
$common = @('--headless', '--disable-gpu', '--no-first-run', '--hide-scrollbars', "--user-data-dir=$UserData")
if ($Convert) {
try {
$arguments = "--headless --disable-gpu --no-first-run `"--user-data-dir=$UserData`" --dump-dom `"$(([Uri]$Convert).AbsoluteUri)`""
Start-Process -FilePath $Browser -ArgumentList $arguments -RedirectStandardOutput "$Convert.out" -RedirectStandardError "$Convert.err" -WindowStyle Hidden -Wait
$html = [IO.File]::ReadAllText("$Convert.out", [Text.Encoding]::UTF8)
if ($html -notmatch 'data-converted="1"') { throw 'the browser did not convert the portraits' }
$html = $html.Replace(' data-converted="1"', '')
$html = $html -replace ']*)>', ('' + "`n ")
$html = $html -replace '\s*\s*\s*$', "`n `n`n"
if (-not $html.StartsWith('`n$html" }
[IO.File]::WriteAllText("$Page.tmp", $html, (New-Object Text.UTF8Encoding $false))
Move-Item -LiteralPath "$Page.tmp" -Destination $Page -Force
} catch {
Add-Content -LiteralPath $Log -Value "$(Get-Date) portraits not converted: $_"
}
}
if ($Shots) {
foreach ($line in [IO.File]::ReadAllLines($Shots, [Text.Encoding]::UTF8)) {
$shot, $image = $line -split "`t", 2
if (-not $image) { continue }
try {
$url = ([Uri]$shot).AbsoluteUri
$dom = & $Browser @common '--window-size=4000,3000' '--dump-dom' $url | Out-String
if ($dom -notmatch 'data-size="(\d+)x(\d+)"') { throw 'the page reported no size' }
& $Browser @common "--window-size=$($Matches[1]),$($Matches[2])" "--screenshot=$image" $url | Out-Null
} catch {
Add-Content -LiteralPath $Log -Value "$(Get-Date) image $image failed: $_"
}
}
}
PS1
# Writes what the browser needs and starts the script.
#
# @param page [String, nil] the page just written, nil when none was
# @param shots [Array] the page and the path of each image to take
# @return [Boolean] whether the browser was started
def self.run(page, shots)
convert = page && EMBED_IMAGES && PORTRAIT_QUALITY
return false unless convert || !shots.empty?
unless browser
MGQ_PartySheet.log("no Microsoft Edge or Google Chrome found to convert the portraits or take the images")
return false
end
Dir.mkdir(work_path("")) unless File.directory?(work_path(""))
File.open(work_path(CONVERT_FILE), "wb") { |file| file.write(converting(page)) } if convert
list = shots.each_with_index.map do |(html, image), index|
file = work_path(format(SHOT_FILE, index + 1))
File.open(file, "wb") { |stream| stream.write(html) }
"#{windows_path(file)}\t#{windows_path(image)}"
end
File.open(work_path(SHOTS_FILE), "wb") { |file| file.write(list.join("\r\n")) }
File.open(work_path(SCRIPT_FILE), "wb") { |file| file.write(SCRIPT) }
result = shell_execute.call(0, Wide.string("open"), Wide.string("powershell.exe"), Wide.string(arguments(convert, !list.empty?)),
Wide.string(windows_path(MGQ_PartySheet.game_dir)), 0)
MGQ_PartySheet.log("could not start PowerShell: error #{result}") if result <= 32
result > 32
end
# The page's policy forbids scripts, so the copy allows inline ones until CONVERT_SCRIPT puts
# the policy back.
#
# @param page [String] the page
# @return [String] the page with CONVERT_SCRIPT at the end of its body
def self.converting(page)
script = format(CONVERT_SCRIPT, PORTRAIT_QUALITY, Page::POLICY)
page.sub(Page::POLICY, "#{Page::POLICY}; script-src 'unsafe-inline'").sub("