diff --git a/Rakefile b/Rakefile index 4a2452e56..14050f9fa 100644 --- a/Rakefile +++ b/Rakefile @@ -40,7 +40,8 @@ namespace :test do 'src/test/setup', 'src/test/testDiceBotLoaders.rb', 'src/test/testDiceBotPrefixesCompatibility.rb', - 'src/test/test_srs_help_messages.rb' + 'src/test/test_srs_help_messages.rb', + 'src/test/range_table_test.rb', ] end end diff --git a/src/diceBot/BattleTech.rb b/src/diceBot/BattleTech.rb index 136de9984..929b6c88f 100644 --- a/src/diceBot/BattleTech.rb +++ b/src/diceBot/BattleTech.rb @@ -1,4 +1,8 @@ # -*- coding: utf-8 -*- +# frozen_string_literal: true + +require 'utils/table' +require 'utils/range_table' class BattleTech < DiceBot setPrefixes(['\d*SRM\d+.+', '\d*LRM\d+.+', '\d*BT.+', 'CT', 'DW', 'CD\d+']) @@ -35,15 +39,17 @@ def getHelpMessage MESSAGETEXT end + # 致命的命中が発生しない上限値 + NO_CRITICAL_HIT_LIMIT = 7 + def changeText(string) string.sub(/PPC/, 'BT10') end - def undefCommandResult - '1' - end - def rollDiceCommand(command) + result = roll_tables(command, TABLES) + return result if result + count = 1 if /^(\d+)(.+)/ === command count = Regexp.last_match(1).to_i @@ -54,11 +60,6 @@ def rollDiceCommand(command) debug('executeCommandCatched command', command) case command - when /^CT$/ - criticalDice, criticalText = getCriticalResult() - return "#{criticalDice} > #{criticalText}" - when /^DW$/ - return getDownResult() when /^CD(\d+)$/ damage = Regexp.last_match(1).to_i return getCheckDieResult(damage) @@ -79,48 +80,31 @@ def rollDiceCommand(command) end def getXrmDamage(type) - table, isLrm = getXrmDamageTable(type) + raise "unknown XRM type:#{type}" unless XRM_DAMAGE_TABLES.key?(type) - table = table.collect { |i| i * 2 } unless isLrm + table = XRM_DAMAGE_TABLES[type] + roll_result = table.roll(bcdice) - damage, dice = get_table_by_2d6(table) - return damage, dice, isLrm - end + lrm = type.start_with?('L') + damage = roll_result.content + modified_damage = lrm ? damage : (2 * damage) - def getXrmDamageTable(type) - # table, isLrm - case type - when /^SRM2$/i - [[1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2], false] - when /^SRM4$/i - [[1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4], false] - when /^SRM6$/i - [[2, 2, 3, 3, 4, 4, 4, 5, 5, 6, 6], false] - when /^LRM5$/i - [[1, 2, 2, 3, 3, 3, 3, 4, 4, 5, 5], true] - when /^LRM10$/i - [[3, 3, 4, 6, 6, 6, 6, 8, 8, 10, 10], true] - when /^LRM15$/i - [[5, 5, 6, 9, 9, 9, 9, 12, 12, 15, 15], true] - when /^LRM20$/i - [[6, 6, 9, 12, 12, 12, 12, 16, 16, 20, 20], true] - else - raise "unknown XRM type:#{type}" - end + return modified_damage, roll_result.sum, lrm end @@lrmLimit = 5 def getHitResult(count, damageFunc, tail) - return nil unless /(\w*)(\+\d+)?>=(\d+)/ === tail + m = /([LCR][LU]?)?(\+\d+)?>=(\d+)/.match(tail) + return nil unless m - side = Regexp.last_match(1) - baseString = Regexp.last_match(2) - target = Regexp.last_match(3).to_i + side = m[1] || 'C' + baseString = m[2] + target = m[3].to_i base = getBaseValue(baseString) debug("side, base, target", side, base, target) - partTable = getHitPart(side) + partTable = HitPart::TABLES[side] resultTexts = [] damages = {} @@ -157,33 +141,6 @@ def getBaseValue(baseString) return base end - def getHitPart(side) - case side - when /^L$/i - ['左胴@', '左脚', '左腕', '左腕', '左脚', '左胴', '胴中央', '右胴', '右腕', '右脚', '頭'] - when /^C$/i, '', nil - ['胴中央@', '右腕', '右腕', '右脚', '右胴', '胴中央', '左胴', '左脚', '左腕', '左腕', '頭'] - when /^R$/i - ['右胴@', '右脚', '右腕', '右腕', '右脚', '右胴', '胴中央', '左胴', '左腕', '左脚', '頭'] - - when /^LU$/i - ['左胴', '左胴', '胴中央', '左腕', '左腕', '頭'] - when /^CU$/i - ['左腕', '左胴', '胴中央', '右胴', '右腕', '頭'] - when /^RU$/i - ['右胴', '右胴', '胴中央', '右腕', '右腕', '頭'] - - when /^LL$/i - ['左脚', '左脚', '左脚', '左脚', '左脚', '左脚'] - when /^CL$/i - ['右脚', '右脚', '右脚', '左脚', '左脚', '左脚'] - when /^RL$/i - ['右脚', '右脚', '右脚', '右脚', '右脚', '右脚'] - else - raise "unknown hit part side :#{side}" - end - end - def getHitText(base, target) dice1, = roll(1, 6) dice2, = roll(1, 6) @@ -202,6 +159,9 @@ def getHitText(base, target) return isHit, result end + # @param [Proc] damageFunc ダメージを返す手続き + # @param [RangeTable] partTable 命中部位表 + # @param [Hash] damages 蓄積したダメージの情報 def getDamages(damageFunc, partTable, damages) resultText = '' damage, dice, isLrm = damageFunc.call() @@ -283,64 +243,40 @@ def getTotalDamage(damages) return result end - def getHitResultOne(damageText, partTable) - part, value = getPart(partTable) - - result = "" - result += "[#{value}] #{part.gsub(/@/, '(致命的命中)')} #{damageText}点" - debug('result', result) - - index = part.index('@') - isCritical = !index.nil? - debug("isCritical", isCritical) - - part = part.gsub(/@/, '') - + # 攻撃を1回行い、その結果を返す + # @param [String] damage_text ダメージを表す文字列 + # @param [RangeTable] hit_part_table 命中部位表 + def getHitResultOne(damage_text, hit_part_table) + hit_part_roll_result = hit_part_table.roll(bcdice) + hit_part = hit_part_roll_result.content + + critical_hit_may_occur_str = + hit_part.critical_hit_may_occur ? '(致命的命中)' : '' + + result_parts = [ + [ + "[#{hit_part_roll_result.sum}]", + "#{hit_part.name}#{critical_hit_may_occur_str}", + "#{damage_text}点", + ].join(' ') + ] + + critical_hit_occurred = false criticalText = '' - if isCritical - criticalDice, criticalText = getCriticalResult() - result += " > [#{criticalDice}] #{criticalText}" - end - - criticalText = '' if criticalText == @@noCritical + if hit_part.critical_hit_may_occur + ct_roll_result = TABLES['CT'].roll(bcdice) - return result, part, criticalText - end + # 致命的命中が発生したか + critical_hit_occurred = ct_roll_result.sum > NO_CRITICAL_HIT_LIMIT + if critical_hit_occurred + criticalText = ct_roll_result.content + end - def getPart(partTable) - diceCount = 2 - if partTable.length == 6 - diceCount = 1 + result_parts.push("[#{ct_roll_result.sum}] #{ct_roll_result.content}") end - part, value = get_table_by_nD6(partTable, diceCount) - return part, value - end - - @@noCritical = '致命的命中はなかった' - - def getCriticalResult() - table = [[ 7, @@noCritical], - [ 9, '1箇所の致命的命中'], - [11, '2箇所の致命的命中'], - [12, 'その部位が吹き飛ぶ(腕、脚、頭)または3箇所の致命的命中(胴)'],] - - dice, = roll(2, 6) - result = get_table_by_number(dice, table, '') - - return dice, result - end - - def getDownResult() - table = ['同じ(前面から転倒) 正面/背面', - '1ヘクスサイド右(側面から転倒) 右側面', - '2ヘクスサイド右(側面から転倒) 右側面', - '180度逆(背面から転倒) 正面/背面', - '2ヘクスサイド左(側面から転倒) 左側面', - '1ヘクスサイド左(側面から転倒) 左側面',] - result, dice = get_table_by_1d6(table) - - return "#{dice} > #{result}" + # TODO: 構造体で表現する + return result_parts.join(' > '), hit_part.name, criticalText end def getCheckDieResult(damage) @@ -364,4 +300,236 @@ def getCheckDieResult(damage) return text end + + # 表の集合 + TABLES = { + 'CT' => RangeTable.new( + '致命的命中表', + '2D6', + [ + [2..NO_CRITICAL_HIT_LIMIT, '致命的命中はなかった'], + [8..9, '1箇所の致命的命中'], + [10..11, '2箇所の致命的命中'], + [12, 'その部位が吹き飛ぶ(腕、脚、頭)または3箇所の致命的命中(胴)'], + ] + ), + 'DW' => Table.new( + '転倒後の向き表', + '1D6', + [ + '同じ(前面から転倒) 正面/背面', + '1ヘクスサイド右(側面から転倒) 右側面', + '2ヘクスサイド右(側面から転倒) 右側面', + '180度逆(背面から転倒) 正面/背面', + '2ヘクスサイド左(側面から転倒) 左側面', + '1ヘクスサイド左(側面から転倒) 左側面', + ] + ) + }.freeze + + # 命中部位を表す構造体 + # @!attribute [rw] name + # @return [String] 部位名 + # @!attribute [rw] critical_hit_may_occur + # @return [Boolean] 致命的命中が発生し得るか + HitPart = Struct.new(:name, :critical_hit_may_occur) + + class HitPart + LEFT_TORSO = '左胴' + CENTER_TORSO = '胴中央' + RIGHT_TORSO = '右胴' + + LEFT_ARM = '左腕' + RIGHT_ARM = '右腕' + + LEFT_LEG = '左脚' + RIGHT_LEG = '右脚' + + HEAD = '頭' + + # 命中部位表 + TABLES = { + 'L' => RangeTable.new( + '命中部位表(左)', + '2D6', + [ + [2, new(LEFT_TORSO, true)], + [3, new(LEFT_LEG, false)], + [4..5, new(LEFT_ARM, false)], + [6, new(LEFT_LEG, false)], + [7, new(LEFT_TORSO, false)], + [8, new(CENTER_TORSO, false)], + [9, new(RIGHT_TORSO, false)], + [10, new(RIGHT_ARM, false)], + [11, new(RIGHT_LEG, false)], + [12, new(HEAD, false)], + ] + ), + 'C' => RangeTable.new( + '命中部位表(正面)', + '2D6', + [ + [2, new(CENTER_TORSO, true)], + [3..4, new(RIGHT_ARM, false)], + [5, new(RIGHT_LEG, false)], + [6, new(RIGHT_TORSO, false)], + [7, new(CENTER_TORSO, false)], + [8, new(LEFT_TORSO, false)], + [9, new(LEFT_LEG, false)], + [10..11, new(LEFT_ARM, false)], + [12, new(HEAD, false)], + ] + ), + 'R' => RangeTable.new( + '命中部位表(右)', + '2D6', + [ + [2, new(RIGHT_TORSO, true)], + [3, new(RIGHT_LEG, false)], + [4..5, new(RIGHT_ARM, false)], + [6, new(RIGHT_LEG, false)], + [7, new(RIGHT_TORSO, false)], + [8, new(CENTER_TORSO, false)], + [9, new(LEFT_TORSO, false)], + [10, new(LEFT_ARM, false)], + [11, new(LEFT_LEG, false)], + [12, new(HEAD, false)], + ] + ), + + 'LU' => RangeTable.new( + '命中部位表(左上半身)', + '1D6', + [ + [1..2, new(LEFT_TORSO, false)], + [3, new(CENTER_TORSO, false)], + [4..5, new(LEFT_ARM, false)], + [6, new(HEAD, false)], + ] + ), + # TODO: 普通のTableで書く + 'CU' => RangeTable.new( + '命中部位表(正面上半身)', + '1D6', + [ + [1, new(LEFT_ARM, false)], + [2, new(LEFT_TORSO, false)], + [3, new(CENTER_TORSO, false)], + [4, new(RIGHT_TORSO, false)], + [5, new(RIGHT_ARM, false)], + [6, new(HEAD, false)], + ] + ), + 'RU' => RangeTable.new( + '命中部位表(右上半身)', + '1D6', + [ + [1..2, new(RIGHT_TORSO, false)], + [3, new(CENTER_TORSO, false)], + [4..5, new(RIGHT_ARM, false)], + [6, new(HEAD, false)], + ] + ), + + 'LL' => RangeTable.new( + '命中部位表(左下半身)', + '1D6', + [ + [1..6, new(LEFT_LEG, false)], + ] + ), + 'CL' => RangeTable.new( + '命中部位表(右下半身)', + '1D6', + [ + [1..3, new(RIGHT_LEG, false)], + [4..6, new(LEFT_LEG, false)], + ] + ), + 'RL' => RangeTable.new( + '命中部位表(右下半身)', + '1D6', + [ + [1..6, new(RIGHT_LEG, false)], + ] + ), + }.freeze + end + + # ミサイルダメージ表 + XRM_DAMAGE_TABLES = { + 'SRM2' => RangeTable.new( + 'SRM2ダメージ表', + '2D6', + [ + [2..7, 1], + [8..12, 2], + ] + ), + 'SRM4' => RangeTable.new( + 'SRM4ダメージ表', + '2D6', + [ + [2, 1], + [3..6, 2], + [7..10, 3], + [11..12, 4], + ] + ), + 'SRM6' => RangeTable.new( + 'SRM6ダメージ表', + '2D6', + [ + [2..3, 2], + [4..5, 3], + [6..8, 4], + [9..10, 5], + [11..12, 6], + ] + ), + 'LRM5' => RangeTable.new( + 'LRM5ダメージ表', + '2D6', + [ + [2, 1], + [3..4, 2], + [5..8, 3], + [9..10, 4], + [11..12, 5], + ] + ), + 'LRM10' => RangeTable.new( + 'LRM10ダメージ表', + '2D6', + [ + [2..3, 3], + [4, 4], + [5..8, 6], + [9..10, 8], + [11..12, 10], + ] + ), + 'LRM15' => RangeTable.new( + 'LRM15ダメージ表', + '2D6', + [ + [2..3, 5], + [4, 6], + [5..8, 9], + [9..10, 12], + [11..12, 15], + ] + ), + 'LRM20' => RangeTable.new( + 'LRM20ダメージ表', + '2D6', + [ + [2..3, 6], + [4, 9], + [5..8, 12], + [9..10, 16], + [11..12, 20], + ] + ) + }.freeze end diff --git a/src/diceBot/DiceBot.rb b/src/diceBot/DiceBot.rb index a4b773d78..8a46d6a88 100644 --- a/src/diceBot/DiceBot.rb +++ b/src/diceBot/DiceBot.rb @@ -522,6 +522,6 @@ def roll_tables(command, tables) return nil end - return table.roll(bcdice) + return table.roll(bcdice).to_s end end diff --git a/src/diceBot/KemonoNoMori.rb b/src/diceBot/KemonoNoMori.rb index 6e7f04c5b..aa5935ac0 100644 --- a/src/diceBot/KemonoNoMori.rb +++ b/src/diceBot/KemonoNoMori.rb @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- require 'utils/table.rb' +require 'utils/range_table' class KemonoNoMori < DiceBot def initialize @@ -113,202 +114,124 @@ def getEscapeExperienceTableResult(command) end TABLES = { - 'FT' => Table.new( + 'FT' => RangeTable.new( '大失敗表', '1D12', [ - '【余裕】が3点減少する(最低0まで)', - '【余裕】が3点減少する(最低0まで)', - '【余裕】が3点減少する(最低0まで)', - 'ランダムな荷物1個が落ちて行方不明になる(大失敗したエリアのアイテム調査で見つけることが可能)', - 'ランダムな荷物1個が落ちて行方不明になる(大失敗したエリアのアイテム調査で見つけることが可能)', - 'ランダムな荷物1個が破壊される', - 'ランダムな荷物1個が破壊される', - 'ランダム天気表を使用し、結果をターンの終了まで適用する', - 'ランダム天気表を使用し、結果をターンの終了まで適用する', - 'ランダムな準備している小道具1個が破壊される', - '着想している防具が破壊される', - '準備している武器が破壊される', + [1..3, '【余裕】が3点減少する(最低0まで)'], + [4..5, 'ランダムな荷物1個が落ちて行方不明になる(大失敗したエリアのアイテム調査で見つけることが可能)'], + [6..7, 'ランダムな荷物1個が破壊される'], + [8..9, 'ランダム天気表を使用し、結果をターンの終了まで適用する'], + [10, 'ランダムな準備している小道具1個が破壊される'], + [11, '着想している防具が破壊される'], + [12, '準備している武器が破壊される'], ] ), - 'RST' => Table.new( + 'RST' => RangeTable.new( '能力値ランダム決定表', '1D12', [ - '【移動】', - '【移動】', - '【格闘】', - '【格闘】', - '【射撃】', - '【射撃】', - '【製作】', - '【製作】', - '【察知】', - '【察知】', - '【自制】', - '【自制】', + [1..2, '【移動】'], + [3..4, '【格闘】'], + [5..6, '【射撃】'], + [7..8, '【製作】'], + [9..10, '【察知】'], + [11..12, '【自制】'], ] ), - 'RTT' => Table.new( + 'RTT' => RangeTable.new( 'ランダム所要時間表', '1D12', [ - '2', - '2', - '2', - '3', - '3', - '3', - '4', - '4', - '4', - '5', - '5', - '5', + [1..3, '2'], + [4..6, '3'], + [7..9, '4'], + [10..12, '5'], ] ), - 'RET' => Table.new( + 'RET' => RangeTable.new( 'ランダム消耗表', '1D12', [ - '0', - '0', - '0', - '1', - '1', - '1', - '2', - '2', - '2', - '4', - '4', - '4', + [1..3, '0'], + [4..6, '1'], + [7..9, '2'], + [10..12, '4'], ] ), - 'RWT' => Table.new( + 'RWT' => RangeTable.new( 'ランダム天気表', '1D12', [ - '濃霧', - '濃霧', - '大雨', - '大雨', - '雷雨', - '雷雨', - '強風', - '強風', - '酷暑', - '酷暑', - '極寒', - '極寒', + [1..2, '濃霧'], + [3..4, '大雨'], + [5..6, '雷雨'], + [7..8, '強風'], + [9..10, '酷暑'], + [11..12, '極寒'], ] ), - 'RWDT' => Table.new( + 'RWDT' => RangeTable.new( 'ランダム天気持続表', '1D12', [ - '1ターン', - '1ターン', - '3ターン', - '3ターン', - '6ターン', - '6ターン', - '24ターン', - '24ターン', - '72ターン', - '72ターン', - '156ターン', - '156ターン', + [1..2, '1ターン'], + [3..4, '3ターン'], + [5..6, '6ターン'], + [7..8, '24ターン'], + [9..10, '72ターン'], + [11..12, '156ターン'], ] ), - 'ROMT' => Table.new( + 'ROMT' => RangeTable.new( 'ランダム遮蔽物表(屋外)', '1D12', [ - '【藪】耐久度3,軽減値1,特殊効果:コンタクト内のキャラクターに対する射撃攻撃判定に-1の修正を付加', - '【藪】耐久度3,軽減値1,特殊効果:コンタクト内のキャラクターに対する射撃攻撃判定に-1の修正を付加', - '【木】耐久度5,軽減値2,特殊効果:コンタクト内のキャラクターに対する射撃攻撃判定に-1の修正を付加', - '【木】耐久度5,軽減値2,特殊効果:コンタクト内のキャラクターに対する射撃攻撃判定に-1の修正を付加', - '【木】耐久度5,軽減値2,特殊効果:コンタクト内のキャラクターに対する射撃攻撃判定に-1の修正を付加', - '【大木】耐久度7,軽減値3,特殊効果:コンタクト内のキャラクターに対する射撃攻撃判定に-2の修正を付加', - '【大木】耐久度7,軽減値3,特殊効果:コンタクト内のキャラクターに対する射撃攻撃判定に-2の修正を付加', - '【大木】耐久度7,軽減値3,特殊効果:コンタクト内のキャラクターに対する射撃攻撃判定に-2の修正を付加', - '【岩】耐久度6,軽減値4,特殊効果:コンタクト内のキャラクターに対する射撃攻撃判定に-1の修正を付加/コンタクト内で行われる格闘攻撃のダメージ+1', - '【岩】耐久度6,軽減値4,特殊効果:コンタクト内のキャラクターに対する射撃攻撃判定に-1の修正を付加/コンタクト内で行われる格闘攻撃のダメージ+1', - '【岩壁】耐久度8,軽減値4,特殊効果:コンタクト内のキャラクターに対する射撃攻撃判定に-2の修正を付加/コンタクト内で行われる格闘攻撃のダメージ+2', - '【岩壁】耐久度8,軽減値4,特殊効果:コンタクト内のキャラクターに対する射撃攻撃判定に-2の修正を付加/コンタクト内で行われる格闘攻撃のダメージ+2', + [1..2, '【藪】耐久度3,軽減値1,特殊効果:コンタクト内のキャラクターに対する射撃攻撃判定に-1の修正を付加'], + [3..5, '【木】耐久度5,軽減値2,特殊効果:コンタクト内のキャラクターに対する射撃攻撃判定に-1の修正を付加'], + [6..8, '【大木】耐久度7,軽減値3,特殊効果:コンタクト内のキャラクターに対する射撃攻撃判定に-2の修正を付加'], + [9..10, '【岩】耐久度6,軽減値4,特殊効果:コンタクト内のキャラクターに対する射撃攻撃判定に-1の修正を付加/コンタクト内で行われる格闘攻撃のダメージ+1'], + [11..12, '【岩壁】耐久度8,軽減値4,特殊効果:コンタクト内のキャラクターに対する射撃攻撃判定に-2の修正を付加/コンタクト内で行われる格闘攻撃のダメージ+2'], ] ), - 'RIMT' => Table.new( + 'RIMT' => RangeTable.new( 'ランダム遮蔽物表(屋内)', '1D12', [ - '【木材の壁】耐久度4,軽減値2,特殊効果:コンタクト内のキャラクターに対する射撃攻撃判定に-1の修正を付加', - '【木材の壁】耐久度4,軽減値2,特殊効果:コンタクト内のキャラクターに対する射撃攻撃判定に-1の修正を付加', - '【木材の壁】耐久度4,軽減値2,特殊効果:コンタクト内のキャラクターに対する射撃攻撃判定に-1の修正を付加', - '【木材の壁】耐久度4,軽減値2,特殊効果:コンタクト内のキャラクターに対する射撃攻撃判定に-1の修正を付加', - '【木材の扉】耐久度4,軽減値2,特殊効果:コンタクト内のキャラクターに対する射撃攻撃判定に-1、接触判定と突撃判定に-2の修正を付加', - '【木材の扉】耐久度4,軽減値2,特殊効果:コンタクト内のキャラクターに対する射撃攻撃判定に-1、接触判定と突撃判定に-2の修正を付加', - '【木材の扉】耐久度4,軽減値2,特殊効果:コンタクト内のキャラクターに対する射撃攻撃判定に-1、接触判定と突撃判定に-2の修正を付加', - '【木材の扉】耐久度4,軽減値2,特殊効果:コンタクト内のキャラクターに対する射撃攻撃判定に-1、接触判定と突撃判定に-2の修正を付加', - '【木製家具】耐久度3,軽減値2,特殊効果:コンタクト内で行われる格闘攻撃のダメージ+1', - '【木製家具】耐久度3,軽減値2,特殊効果:コンタクト内で行われる格闘攻撃のダメージ+1', - '【木製家具】耐久度3,軽減値2,特殊効果:コンタクト内で行われる格闘攻撃のダメージ+1', - '【木製家具】耐久度3,軽減値2,特殊効果:コンタクト内で行われる格闘攻撃のダメージ+1', + [1..4, '【木材の壁】耐久度4,軽減値2,特殊効果:コンタクト内のキャラクターに対する射撃攻撃判定に-1の修正を付加'], + [5..8, '【木材の扉】耐久度4,軽減値2,特殊効果:コンタクト内のキャラクターに対する射撃攻撃判定に-1、接触判定と突撃判定に-2の修正を付加'], + [9..12, '【木製家具】耐久度3,軽減値2,特殊効果:コンタクト内で行われる格闘攻撃のダメージ+1'], ] ), - 'EET' => Table.new( + 'EET' => RangeTable.new( '逃走体験表', '1D12', [ - '【余裕】が0になる', - '【余裕】が0になる', - '【余裕】が0になる', - '任意の【絆】を合計2点減少する', - '任意の【絆】を合計2点減少する', - '任意の【絆】を合計2点減少する', - '全ての荷物を失う(逃走したエリアに配置され、調査で発見可能)', - '全ての荷物を失う(逃走したエリアに配置され、調査で発見可能)', - '全ての荷物を失う(逃走したエリアに配置され、調査で発見可能)', - '全ての武器と防具と小道具と荷物を失う(逃走したエリアに配置され、調査で発見可能)', - '全ての武器と防具と小道具と荷物を失う(逃走したエリアに配置され、調査で発見可能)', - '全ての武器と防具と小道具と荷物を失う(逃走したエリアに配置され、調査で発見可能)', + [1..3, '【余裕】が0になる'], + [4..6, '任意の【絆】を合計2点減少する'], + [7..9, '全ての荷物を失う(逃走したエリアに配置され、調査で発見可能)'], + [10..12, '全ての武器と防具と小道具と荷物を失う(逃走したエリアに配置され、調査で発見可能)'], ] ), - 'GFT' => Table.new( + 'GFT' => RangeTable.new( '食材採集表', '1D12', [ - '食べられる根(栄養価:2)', - '食べられる根(栄養価:2)', - '食べられる草(栄養価:3)', - '食べられる草(栄養価:3)', - '食べられる草(栄養価:3)', - '食べられる実(栄養価:5)', - '食べられる実(栄養価:5)', - '食べられる実(栄養価:5)', - '小型動物(栄養価:10)', - '小型動物(栄養価:10)', - '大型動物(栄養価:40)', - '気持ち悪い虫(栄養価:1)', + [1..2, '食べられる根(栄養価:2)'], + [3..5, '食べられる草(栄養価:3)'], + [6..8, '食べられる実(栄養価:5)'], + [9..10, '小型動物(栄養価:10)'], + [11, '大型動物(栄養価:40)'], + [12, '気持ち悪い虫(栄養価:1)'], ] ), - 'GWT' => Table.new( + 'GWT' => RangeTable.new( '水採集表', '1D12', [ - '汚水', - '汚水', - '汚水', - '汚水', - '汚水', - '汚水', - '飲料水', - '飲料水', - '飲料水', - '飲料水', - '飲料水', - '毒水', + [1..6, '汚水'], + [7..11, '飲料水'], + [12, '毒水'], ] ), 'WST' => Table.new( diff --git a/src/diceBot/MetalHead.rb b/src/diceBot/MetalHead.rb index 21dfa03c4..25494fe64 100644 --- a/src/diceBot/MetalHead.rb +++ b/src/diceBot/MetalHead.rb @@ -1,4 +1,8 @@ # -*- coding: utf-8 -*- +# frozen_string_literal: true + +require 'utils/ArithmeticEvaluator' +require 'utils/range_table' class MetalHead < DiceBot setPrefixes(['AR', 'SR', 'HR<=.+', 'CC', 'ACT', 'ACL', 'ACS', 'CRC[A-Z]\d+']) @@ -37,29 +41,22 @@ def getHelpMessage end def rollDiceCommand(command) - debug("rollDiceCommand", command) - - tableName = "" - tableNumber = "" - tableResult = "" - - case command.upcase - when /^CC/ - tableName, tableResult, tableNumber = mh_cc_table - when /^ACL/ - tableName, tableResult, tableNumber = mh_acl_table - when /^ACS/ - tableName, tableResult, tableNumber = mh_acs_table - when /^CRC(\w)(\d+)/ - tableName, tableResult, tableNumber = mh_crc_table(Regexp.last_match(1), Regexp.last_match(2)) - when /^HR<=(.+)$/ - target = parren_killer("(" + Regexp.last_match(1) + ")").to_i + result = roll_tables(command, TABLES) + return result if result + + case command + when /\ACRC(\w)(\d+)\z/ + suv = Regexp.last_match(1) + num = Regexp.last_match(2) + return mh_crc_table(suv, num) + when /\AHR<=(.+)/ + target = ArithmeticEvaluator.new.eval( + Regexp.last_match(1), @fractionType.to_sym + ) return rollHit(target) end - unless tableName.empty? - return "#{tableName} > #{tableNumber} > #{tableResult}" - end + return nil end def changeText(string) @@ -118,67 +115,18 @@ def getResult(total_n, dice_n, diff) return ' > 失敗' end - def mh_cc_table - name = "クリティカルチャート" - table = [ - "相手は知覚系に多大なダメージを受けた。PERを1にして頭部にHWのダメージ、および心理チェック。", - "相手の運動神経を断ち切った。DEXを1にして腕部にHWのダメージ、および心理チェック。さらに腕に持っていた武器などは落としてしまう。", - "相手の移動手段は完全に奪われた。REFを1にして脚部にHWダメージと心理チェック。また、次回からのこちらの攻撃は必ず命中する。", - "相手の急所に命中。激痛のため気絶した上、胴にHWダメージ。", - "相手の急所に命中。激痛のため気絶した上、胴にHWダメージ。", - "効果的な一撃。胴にHWダメージ。心理チェック。", - "効果的な一撃。胴にMOダメージ。心理チェック。", - "君の一撃は相手の中枢を完全に破壊した。即死である。", - "君の一撃は相手の中枢を完全に破壊した。即死である。", - "君の一撃は相手の中枢を完全に破壊した。即死である。", - ] - result, num = get_table_by_nDx(table, 1, 10) - return name, result, num - end - - def mh_acl_table - name = "アクシデントチャート(射撃・投擲)" - table = [ - "ささいなミス。特にペナルティーはない。", - "ささいなミス。特にペナルティーはない。", - "ささいなミス。特にペナルティーはない。", - "ささいなミス。特にペナルティーはない。", - "ささいなミス。特にペナルティーはない。", - "ささいなミス。特にペナルティーはない。", - "ささいなミス。特にペナルティーはない。", - "不発、またはジャム。弾を取り出さねばならない物は次のターンは射撃できない。", - "ささいな故障。可能なら次のターンから個別武器のスキルロールで修理を行える。", - "武器の暴発、または爆発。頭部HWの心理効果ロール。さらに、その武器は破壊されPERとDEXのどちらか、または両方に計2ポイントのマイナスを与える。(遠隔操作の場合、射手への被害は無し)", - ] - result, num = get_table_by_nDx(table, 1, 10) - return name, result, num - end - - def mh_acs_table - name = "アクシデントチャート(格闘)" - table = [ - "足を滑らせて転倒し、起き上がるまで相手に+20の命中修正を与える。", - "足を滑らせて転倒し、起き上がるまで相手に+20の命中修正を与える。", - "足を滑らせて転倒し、起き上がるまで相手に+20の命中修正を与える。", - "手を滑らせて、武器を落とす。素手の時は関係ない。", - "手を滑らせて、武器を落とす。素手の時は関係ない。", - "手を滑らせて、武器を落とす。素手の時は関係ない。", - "使用武器の破壊。素手戦闘のときはMWのダメージを受ける。", - "使用武器の破壊。素手戦闘のときはMWのダメージを受ける。", - "使用武器の破壊。素手戦闘のときはMWのダメージを受ける。", - "手を滑らせ、不幸にも武器は飛んでいき、5m以内に人がいれば誰かに刺さるか、または打撃を与えるかもしれない。ランダムに決定し、普通どおり判定を続ける。素手のときは関係ない。", - ] - result, num = get_table_by_nDx(table, 1, 10) - return name, result, num - end - + # 戦闘結果チャートを振る + # @param [String] suv 耐久レベル + # @param [String] num 数値 + # @return [String] 振った結果 def mh_crc_table(suv, num) - name = "戦闘結果チャート" + header_parts = ['戦闘結果チャート', num] + separator = ' > ' suv = suv.to_s.upcase numbuf = num.to_i if numbuf < 1 - return name, '数値が不正です', num + return (header_parts + ['数値が不正です']).join(separator) end num_d1 = numbuf % 10 @@ -218,7 +166,10 @@ def mh_crc_table(suv, num) } if table_damage[suv].nil? - return name, "耐久レベル(SUV)[#{suv}] > 耐久レベル(SUV)の値が不正です", num + return (header_parts + [ + "耐久レベル(SUV)[#{suv}]", + "耐久レベル(SUV)の値が不正です", + ]).join(separator) end damage_level = '' @@ -231,18 +182,66 @@ def mh_crc_table(suv, num) end end - result = "" + result_parts = [] if numbuf != num.to_i - result = "#{numbuf} > " + result_parts.push(numbuf.to_s) end if suv == 'M' - result += "耐物 > HP[#{damage_level}]" + result_parts.push('耐物', "HP[#{damage_level}]") else - result += "耐久レベル(SUV)[#{suv}] > 部位[#{table_point[num_d1]}] : 損傷種別[#{damage_level}]" + result_parts.push( + "耐久レベル(SUV)[#{suv}]", + "部位[#{table_point[num_d1]}] : 損傷種別[#{damage_level}]" + ) end - return name, result, num + return (header_parts + result_parts).join(separator) end + + # 表を振った結果の整形処理 + TABLE_ROLL_RESULT_FORMATTER = lambda do |table, result| + [table.name, result.sum, result.content].join(' > ') + end + + # 表の集合 + TABLES = { + 'CC' => RangeTable.new( + 'クリティカルチャート', + '1D10', + [ + [1, '相手は知覚系に多大なダメージを受けた。PERを1にして頭部にHWのダメージ、および心理チェック。'], + [2, '相手の運動神経を断ち切った。DEXを1にして腕部にHWのダメージ、および心理チェック。さらに腕に持っていた武器などは落としてしまう。'], + [3, '相手の移動手段は完全に奪われた。REFを1にして脚部にHWダメージと心理チェック。また、次回からのこちらの攻撃は必ず命中する。'], + [4..5, '相手の急所に命中。激痛のため気絶した上、胴にHWダメージ。'], + [6, '効果的な一撃。胴にHWダメージ。心理チェック。'], + [7, '効果的な一撃。胴にMOダメージ。心理チェック。'], + [8..10, '君の一撃は相手の中枢を完全に破壊した。即死である。'], + ], + &TABLE_ROLL_RESULT_FORMATTER + ), + 'ACL' => RangeTable.new( + 'アクシデントチャート(射撃・投擲)', + '1D10', + [ + [1..7, 'ささいなミス。特にペナルティーはない。'], + [8, '不発、またはジャム。弾を取り出さねばならない物は次のターンは射撃できない。'], + [9, 'ささいな故障。可能なら次のターンから個別武器のスキルロールで修理を行える。'], + [10, '武器の暴発、または爆発。頭部HWの心理効果ロール。さらに、その武器は破壊されPERとDEXのどちらか、または両方に計2ポイントのマイナスを与える。(遠隔操作の場合、射手への被害は無し)'], + ], + &TABLE_ROLL_RESULT_FORMATTER + ), + 'ACS' => RangeTable.new( + 'アクシデントチャート(格闘)', + '1D10', + [ + [1..3, '足を滑らせて転倒し、起き上がるまで相手に+20の命中修正を与える。'], + [4..6, '手を滑らせて、武器を落とす。素手の時は関係ない。'], + [7..9, '使用武器の破壊。素手戦闘のときはMWのダメージを受ける。'], + [10, '手を滑らせ、不幸にも武器は飛んでいき、5m以内に人がいれば誰かに刺さるか、または打撃を与えるかもしれない。ランダムに決定し、普通どおり判定を続ける。素手のときは関係ない。'], + ], + &TABLE_ROLL_RESULT_FORMATTER + ), + }.freeze end diff --git a/src/test/data/BattleTech.txt b/src/test/data/BattleTech.txt index a9fb55e46..31bfc7afd 100644 --- a/src/test/data/BattleTech.txt +++ b/src/test/data/BattleTech.txt @@ -156,13 +156,31 @@ rand:6/6,5/6 input: CT output: -BattleTech : 11 > 2箇所の致命的命中 -rand:6/6,5/6 +BattleTech : 致命的命中表(5) > 致命的命中はなかった +rand:4/6,1/6 +============================ +input: +CT +output: +BattleTech : 致命的命中表(8) > 1箇所の致命的命中 +rand:6/6,2/6 +============================ +input: +CT +output: +BattleTech : 致命的命中表(10) > 2箇所の致命的命中 +rand:6/6,4/6 +============================ +input: +CT +output: +BattleTech : 致命的命中表(12) > その部位が吹き飛ぶ(腕、脚、頭)または3箇所の致命的命中(胴) +rand:6/6,6/6 ============================ input: DW output: -BattleTech : 6 > 1ヘクスサイド左(側面から転倒) 左側面 +BattleTech : 転倒後の向き表(6) > 1ヘクスサイド左(側面から転倒) 左側面 rand:6/6 ============================ input: diff --git a/src/test/range_table_test.rb b/src/test/range_table_test.rb new file mode 100644 index 000000000..a99cb99fd --- /dev/null +++ b/src/test/range_table_test.rb @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- +# frozen_string_literal: true + +bcdice_root = File.expand_path('..', File.dirname(__FILE__)) +$:.unshift(bcdice_root) unless $:.include?(bcdice_root) + +require 'test/unit' +require 'utils/range_table' + +class TestRangeTable < Test::Unit::TestCase + # ダイスロール方法の書式が正しい場合、受理される + def test_valid_dice_roll_method_should_be_accepted_1 + assert_nothing_raised do + RangeTable.new( + 'Table', + '2D6', + [ + [2..7, 'A'], + [8..12, 'B'], + ] + ) + end + end + + # ダイスロール方法の書式が正しい場合、受理される + def test_valid_dice_roll_method_should_be_accepted_2 + assert_nothing_raised do + RangeTable.new( + 'Table', + '1D100', + [ + [1..25, 'A'], + [26..50, 'B'], + [51..75, 'C'], + [76..100, 'D'], + ] + ) + end + end + + # ダイスロール方法の書式が正しい場合、受理される + def test_valid_dice_roll_method_should_be_accepted_3 + assert_nothing_raised do + RangeTable.new( + 'Table', + '2D6', + [ + [2..6, 'A'], + [7, 'B'], + [8..11, 'C'], + [12, 'D'], + ] + ) + end + end + + # ダイスロール方法の書式が正しい場合、受理される + def test_valid_dice_roll_method_should_be_accepted_4 + assert_nothing_raised do + RangeTable.new( + 'Table', + '2D6', + [ + [2...8, 'A'], + [8...13, 'B'], + ] + ) + end + end + + # ダイスロール方法の書式が正しくない場合、拒絶される + def test_invalid_dice_roll_method_should_be_denied_1 + assert_raise(ArgumentError) do + RangeTable.new( + 'Table', + 'D6', + [ + [1..3, 'A'], + [4..6, 'B'], + ] + ) + end + end + + # ダイスロール方法の書式が正しくない場合、拒絶される + def test_invalid_dice_roll_method_should_be_denied_2 + assert_raise(ArgumentError) do + RangeTable.new( + 'Table', + '2B6', + [ + [2..7, 'A'], + [8..12, 'B'], + ] + ) + end + end + + # 範囲の型が正しくなかった場合、拒絶される + def test_invalid_typed_range_should_be_denied + assert_raise(TypeError) do + RangeTable.new( + 'Table', + '2D6', + [ + [2.0..3, 'A'], + [4..6.0, 'B'], + [7.0, 'C'], + [8..12, 'D'], + ] + ) + end + end + + # カバーしきれていない出目の合計値の範囲がある場合、拒絶される + def test_range_gap_should_be_denied_1 + assert_raise(RangeError) do + RangeTable.new( + 'Table', + '2D6', + [ + [2..7, 'A'], + [9..12, 'B'], + ] + ) + end + end + + # カバーしきれていない出目の合計値の範囲がある場合、拒絶される + def test_range_gap_should_be_denied_2 + assert_raise(RangeError) do + RangeTable.new( + 'Table', + '2D6', + [ + [2...7, 'A'], + [8..12, 'B'], + ] + ) + end + end + + # 出目の合計値の範囲が重なっている場合、拒絶される + def test_range_overlap_should_be_denied + assert_raise(RangeError) do + RangeTable.new( + 'Table', + '2D6', + [ + [2..7, 'A'], + [7..12, 'B'], + ] + ) + end + end +end diff --git a/src/utils/range_table.rb b/src/utils/range_table.rb new file mode 100644 index 000000000..3ac94c6da --- /dev/null +++ b/src/utils/range_table.rb @@ -0,0 +1,266 @@ +# -*- coding: utf-8 -*- +# frozen_string_literal: true + +# 各項目について、Rangeを用いて出目の合計の範囲を指定する、表のクラス。 +# +# このクラスを使うと、表の定義を短く書ける。 +# このクラスを使って表を定義するときは、各項目を以下の形で書く。 +# +# [出目の合計の範囲, 内容] +# +# 「出目の合計の範囲」には、Integerを要素とするRangeか、Integerを置ける。 +# +# roll メソッドで表を振ると、出目の合計値と対応する項目が選ばれる。 +# +# @example 表の定義(バトルテックの致命的命中表) +# CRITICAL_TABLE = RangeTable.new( +# '致命的命中表', +# '2D6', +# [ +# [2..7, '致命的命中はなかった'], +# [8..9, '1箇所の致命的命中'], +# [10..11, '2箇所の致命的命中'], +# [12, 'その部位が吹き飛ぶ(腕、脚、頭)または3箇所の致命的命中(胴)'] +# ] +# ) +# +# @example 表を振った結果 +# CRITICAL_TABLE.roll(bcdice).formatted +# # 出目の合計が7の場合 :"致命的命中表(7) > 致命的命中はなかった" +# # 出目の合計が8の場合 :"致命的命中表(8) > 1箇所の致命的命中" +# # 出目の合計が9の場合 :"致命的命中表(9) > 1箇所の致命的命中" +# # 出目の合計が10の場合:"致命的命中表(10) > 2箇所の致命的命中" +class RangeTable + # 表を振った結果を表す構造体 + # @!attribute [rw] sum + # @return [Integer] 出目の合計 + # @!attribute [rw] values + # @return [Array] 出目の配列 + # @!attribute [rw] content + # @return [Object] 選ばれた項目の内容 + # @!attribute [rw] formatted + # @return [String] 整形された結果 + RollResult = Struct.new(:sum, :values, :content, :formatted) do + alias_method :to_s, :formatted + end + + # 表の項目を表す構造体 + # @!attribute [rw] range + # @return [Range] 出目の合計の範囲 + # @!attribute [rw] content + # @return [Object] 内容 + Item = Struct.new(:range, :content) + + # 項目を選ぶときのダイスロールの方法を表す正規表現 + DICE_ROLL_METHOD_RE = /\A(\d+)D(\d+)\z/i.freeze + + # 表を振った結果の整形処理(既定の処理) + DEFAULT_FORMATTER = lambda do |table, result| + "#{table.name}(#{result.sum}) > #{result.content}" + end + + # @return [String] 表の名前 + attr_reader :name + # @return [Integer] 振るダイスの個数 + attr_reader :num_of_dice + # @return [Integer] 振るダイスの面数 + attr_reader :num_of_sides + + # 表を初期化する + # + # ブロックを与えると、独自の結果整形処理を指定できる。 + # ブロックは振った表(+table+)と振った結果(+result+)を引数として受け取る。 + # + # @param [String] name 表の名前 + # @param [String] dice_roll_method + # 項目を選ぶときのダイスロールの方法(+'1D6'+ など) + # @param [Array<(Range, Object)>, Array<(Integer, Object)>] items + # 表の項目の配列。[出目の合計の範囲, 内容] + # @yieldparam [RangeTable] table 振った表 + # @yieldparam [RollResult] result 表を振った結果 + # @raise [ArgumentError] ダイスロール方法が正しい書式で指定されていなかった場合 + # @raise [TypeError] 範囲の型が正しくなかった場合 + # @raise [RangeError] 出目の合計の最小値がカバーされていなかった場合 + # @raise [RangeError] 出目の合計の最大値がカバーされていなかった場合 + # @raise [RangeError] 出目の合計の範囲にずれや重なりがあった場合 + # + # @example 表の定義(バトルテックの致命的命中表) + # CRITICAL_TABLE = RangeTable.new( + # '致命的命中表', + # '2D6', + # [ + # [2..7, '致命的命中はなかった'], + # [8..9, '1箇所の致命的命中'], + # [10..11, '2箇所の致命的命中'], + # [12, 'その部位が吹き飛ぶ(腕、脚、頭)または3箇所の致命的命中(胴)'] + # ] + # ) + # + # @example 独自の結果整形処理を指定する場合 + # CRITICAL_TABLE_WITH_FORMATTER = RangeTable.new( + # '致命的命中表', + # '2D6', + # [ + # [2..7, '致命的命中はなかった'], + # [8..9, '1箇所の致命的命中'], + # [10..11, '2箇所の致命的命中'], + # [12, 'その部位が吹き飛ぶ(腕、脚、頭)または3箇所の致命的命中(胴)'] + # ] + # ) do |table, result| + # "致命的命中発生? > #{result.sum}[#{result.values}] > #{result.content}" + # end + # + # CRITICAL_TABLE_WITH_FORMATTER.roll(bcdice).formatted + # #=> "致命的命中発生? > 11[5,6] > 2箇所の致命的命中" + def initialize(name, dice_roll_method, items, &formatter) + @name = name.freeze + @formatter = formatter || DEFAULT_FORMATTER + + m = DICE_ROLL_METHOD_RE.match(dice_roll_method) + unless m + raise( + ArgumentError, + "#{@name}: invalid dice roll method: #{dice_roll_method}" + ) + end + + @num_of_dice = m[1].to_i + @num_of_sides = m[2].to_i + + store(items) + end + + # 指定された値に対応する項目を返す + # @param [Integer] value 値(出目の合計) + # @return [Item] 指定された値に対応する項目 + # @raise [RangeError] 範囲外の値が指定された場合 + def fetch(value) + item = @items.find { |i| i.range.include?(value) } + unless item + raise RangeError, "#{@name}: value is out of range: #{value}" + end + + return item + end + + # 表を振る + # @param [BCDice] bcdice BCDice本体 + # @return [RollResult] 表を振った結果 + def roll(bcdice) + sum, values_str, = bcdice.roll(@num_of_dice, @num_of_sides) + + # TODO: BCDice#roll から直接、整数の配列として出目を受け取りたい + values = values_str.split(',').map(&:to_i) + + result = RollResult.new(sum, values, fetch(sum).content) + result.formatted = @formatter[self, result] + + return result + end + + private + + # 表の項目を格納する + # @param [Array<(Range, Object)>, Array<(Integer, Object)>] items + # 表の項目の配列。[出目の合計の範囲, 内容] + # @return [self] + # @raise [TypeError] 範囲の型が正しくなかった場合 + # @raise [RangeError] 出目の合計の最小値がカバーされていなかった場合 + # @raise [RangeError] 出目の合計の最大値がカバーされていなかった場合 + # @raise [RangeError] 出目の合計の範囲にずれや重なりがあった場合 + def store(items) + items_with_range = items.map { |r, c| [coerce_to_int_range(r), c] } + sorted_items = items_with_range.sort_by { |r, _| r.min } + + assert_min_sum_is_covered(sorted_items) + assert_max_sum_is_covered(sorted_items) + assert_no_gap_or_overlap_in_ranges(sorted_items) + + @items = sorted_items. + map { |range, content| Item.new(range, content.freeze).freeze }. + freeze + + self + end + + # 引数を強制的に整数を要素とするRangeに変換する + # @param [Range, Integer] x 変換対象 + # @return [Range] 整数を要素とするRange + # @raise [TypeError] xの型に対応していなかった場合 + def coerce_to_int_range(x) + case x + when Integer + return Range.new(x, x) + when Range + if x.begin.is_a?(Integer) && x.end.is_a?(Integer) + return x + end + end + + raise( + TypeError, + "#{@name}: #{x} (#{x.class}) must be an Integer or a Range with Integers " + ) + end + + # 出目の合計の最小値がカバーされていることを確認する + # @param [Array<(Range, Object)>] sorted_items + # ソートされた、項目の配列 + # @return [self] + # @raise [RangeError] 出目の合計の最小値がカバーされていなかった場合 + def assert_min_sum_is_covered(sorted_items) + min_sum = @num_of_dice + range = sorted_items.first[0] + unless range.include?(min_sum) + raise( + RangeError, + "#{@name}: min value (#{min_sum}) is not covered: #{range}" + ) + end + + self + end + + # 出目の合計の最大値がカバーされていることを確認する + # @param [Array<(Range, Object)>] sorted_items + # ソートされた、項目の配列 + # @return [self] + # @raise [RangeError] 出目の合計の最大値がカバーされていなかった場合 + def assert_max_sum_is_covered(sorted_items) + max_sum = @num_of_dice * @num_of_sides + range = sorted_items.last[0] + unless range.include?(max_sum) + raise( + RangeError, + "#{@name}: max value (#{max_sum}) is not covered: #{range}" + ) + end + + self + end + + # 出目の合計の範囲にずれや重なりがないことを確認する + # @param [Array<(Range, Object)>] sorted_items + # ソートされた、項目の配列 + # @return [self] + # @raise [RangeError] 出目の合計の範囲にずれや重なりがあった場合 + def assert_no_gap_or_overlap_in_ranges(sorted_items) + sorted_items.each_cons(2) do |i1, i2| + r1 = i1[0] + r2 = i2[0] + + max1 = r1.max + next_of_max1 = max1 + 1 + + if r2.include?(max1) + raise RangeError, "#{@name}: Range overlap: #{r1} and #{r2}" + end + + unless r2.include?(next_of_max1) + raise RangeError, "#{@name}: Range gap: #{r1} and #{r2}" + end + end + + self + end +end