diff options
| author | Jérémy Zurcher <jeremy@asynk.ch> | 2026-03-15 21:42:14 +0100 |
|---|---|---|
| committer | Jérémy Zurcher <jeremy@asynk.ch> | 2026-03-15 21:42:14 +0100 |
| commit | f0c2066e3ffffe3212658313cd6e30d85028412c (patch) | |
| tree | 7fffcf8fcde438d1a565e154a52909fa6c054832 | |
| parent | e4e09f936d38a89082f40354fdf451ad875baffa (diff) | |
| download | colonial-twilight-f0c2066e3ffffe3212658313cd6e30d85028412c.zip colonial-twilight-f0c2066e3ffffe3212658313cd6e30d85028412c.tar.gz | |
implement FLN action & operations
| -rw-r--r-- | lib/colonial_twilight/actions/action.rb | 2 | ||||
| -rw-r--r-- | lib/colonial_twilight/actions/fln/agitate.rb | 2 | ||||
| -rw-r--r-- | lib/colonial_twilight/actions/fln/ambush.rb | 35 | ||||
| -rw-r--r-- | lib/colonial_twilight/actions/fln/attack.rb | 57 | ||||
| -rw-r--r-- | lib/colonial_twilight/actions/fln/extort.rb | 35 | ||||
| -rw-r--r-- | lib/colonial_twilight/actions/fln/march.rb | 62 | ||||
| -rw-r--r-- | lib/colonial_twilight/actions/fln/oas.rb | 33 | ||||
| -rw-r--r-- | lib/colonial_twilight/actions/fln/rally.rb | 2 | ||||
| -rw-r--r-- | lib/colonial_twilight/actions/fln/subvert.rb | 62 | ||||
| -rw-r--r-- | lib/colonial_twilight/actions/fln/terror.rb | 33 | ||||
| -rw-r--r-- | spec/fln_actions_spec.rb | 297 |
11 files changed, 617 insertions, 3 deletions
diff --git a/lib/colonial_twilight/actions/action.rb b/lib/colonial_twilight/actions/action.rb index 3be8483..4b7ea6c 100644 --- a/lib/colonial_twilight/actions/action.rb +++ b/lib/colonial_twilight/actions/action.rb @@ -67,7 +67,7 @@ module ColonialTwilight end def available_modes(_space) - nil + {} end def possible_spaces(board) diff --git a/lib/colonial_twilight/actions/fln/agitate.rb b/lib/colonial_twilight/actions/fln/agitate.rb index fd0e287..8434662 100644 --- a/lib/colonial_twilight/actions/fln/agitate.rb +++ b/lib/colonial_twilight/actions/fln/agitate.rb @@ -34,7 +34,7 @@ module ColonialTwilight # with Base and or Control && terror or shift to oppose possible def applicable?(space) - Rally.applicable?(space) && + Rally.applicable?(space) && !space.country? && (space.fln_bases.positive? || space.fln_control?) && (space.terror.positive? || !space.oppose?) end diff --git a/lib/colonial_twilight/actions/fln/ambush.rb b/lib/colonial_twilight/actions/fln/ambush.rb new file mode 100644 index 0000000..9756c55 --- /dev/null +++ b/lib/colonial_twilight/actions/fln/ambush.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require_relative 'fln_action' + +module ColonialTwilight + module Actions + module FLN + # Ambush 4.3.3 : max 2 + class Ambush < FlnAction + def initialize(space) + super(space, {}, cost: 0) + end + + # Activate only 1 Underground Guerrilla + # Remove 1 Government piece (Police first, then Troops, then Base) into casualties + # No Attrition + # -1 Commitment per French Base removed + def apply!(board) + raise NotImplementedError + end + + class << self + def special? + true + end + + # any space where an Attack is occurring with Underground Guerrillas. + def applicable?(space) + Attack.applicable?(space) && space.fln_underground.positive? + end + end + end + end + end +end diff --git a/lib/colonial_twilight/actions/fln/attack.rb b/lib/colonial_twilight/actions/fln/attack.rb new file mode 100644 index 0000000..298f28e --- /dev/null +++ b/lib/colonial_twilight/actions/fln/attack.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require_relative 'fln_action' +require_relative 'ambush' + +module ColonialTwilight + module Actions + module FLN + # Attack 3.3.3 + class Attack < FlnAction + def initialize(space) + super(space, {}, cost: 1) + @ambush = nil + end + + # can be combined with Ambush (replaces Attack procedure) + def ambush! + raise 'ambush! called twice' unless @ambush.nil? + + @ambush = Ambush.new(space) + self + end + + + # def validate! + # super + # raise 'select conduct mode' unless mode.key?(:conduct) + # end + + # Activate all Guerrillas in the space + # Roll 1d6: if result is less than or equal to number of Guerrillas + # remove up to 2 Gov pieces (Police first, then Troops, then Base) into casualties + # If result is a 1, add 1 underground Guerrillas + # Attrition (not Ambush) : for each French piece removed : remove 1 FLN Guerrillas (alternate available / casualties) + # -1 Commitment per French Base removed + def apply!(board) + raise NotImplementedError + end + + class << self + def op? + true + end + + # any space with FLN cubes and Gov pieces + def applicable?(space) + space.guerrillas.positive? && space.gov.positive? + end + + # def available_modes(_space) + # { conduct: 1 } + # end + end + end + end + end +end diff --git a/lib/colonial_twilight/actions/fln/extort.rb b/lib/colonial_twilight/actions/fln/extort.rb new file mode 100644 index 0000000..58b4e18 --- /dev/null +++ b/lib/colonial_twilight/actions/fln/extort.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require_relative 'fln_action' + +module ColonialTwilight + module Actions + module FLN + # Extort 4.3.1 + class Extort < FlnAction + def initialize(space, mode) + super(space, mode, cost: 0) + end + + # flip 1 Underground Guerrilla to Active + # add 1 Resources to FLN track per space. + def apply!(board) + raise NotImplementedError + end + + class << self + def special? + true + end + + # any space with Population, Underground Guerrillas, and FLN Control + # also Morocco/Tunisia if Independent and have Underground Guerrillas + def applicable?(space) + space.fln_underground.positive? && + (space.country? ? space.independent? : space.pop.positive? && space.fln_control?) + end + end + end + end + end +end diff --git a/lib/colonial_twilight/actions/fln/march.rb b/lib/colonial_twilight/actions/fln/march.rb new file mode 100644 index 0000000..e738733 --- /dev/null +++ b/lib/colonial_twilight/actions/fln/march.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require_relative 'fln_action' + +module ColonialTwilight + module Actions + module FLN + # March 3.3.2 + class March < FlnAction + def initialize(space, mode) + super(space, mode, cost: 1) + end + + def cost + # Cost is 1 Resource per destination space moved into + mode.keys.size + end + + def validate! + super + raise 'select at least 1 destination' if mode.empty? + + total_moved = mode.values.sum + raise "total moved #{total_moved} exceeds available #{space.guerrillas}" if total_moved > space.guerrillas + end + + # move any number of Guerrillas to adjacent spaces as a group + def apply!(board) + raise NotImplementedError + end + + class << self + def op? + true + end + + # any space with FLN cubes + def applicable?(space) + space.guerrillas.positive? + end + + def available_modes(space) + space.adjacents.to_h { |idx| [idx, space.guerrillas] } + end + + # the group must stop if moving across a Wilaya border or an International border + def must_stop?(space_from, space_to) + space_from.wilaya != space_to.wilaya || space_from.country? || space_to.country? + end + + # if destination is at Support: activate group if moved FLN + Gov cubes > 3 + # when crossing International border: activate group if moved FLN + Gov cubes + Border level > 3 + def must_activate?(board, space_from, space_to, num = 1) + international = space_from.country? || space_to.country? + (international || space_to.support?) && + (num + space_to.gov_cubes + (international ? board.border_zone_track : 0)) > 3 + end + end + end + end + end +end diff --git a/lib/colonial_twilight/actions/fln/oas.rb b/lib/colonial_twilight/actions/fln/oas.rb new file mode 100644 index 0000000..6d38e0f --- /dev/null +++ b/lib/colonial_twilight/actions/fln/oas.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require_relative 'fln_action' + +module ColonialTwilight + module Actions + module FLN + # OAS 5.3.1 + class Oas < FlnAction + def initialize(space, mode) + super(space, mode, cost: 0) + end + + # add 1 Terror, set to Neutral + # GOV lose Commitment equal to Population, FLN lose Resources equal to twice Population. + def apply!(board) + raise NotImplementedError + end + + class << self + def special? + true + end + + # 1 populated space with no Terror not Country. + def applicable?(space) + !space.country? && space.pop.positive? && space.terror.zero? + end + end + end + end + end +end diff --git a/lib/colonial_twilight/actions/fln/rally.rb b/lib/colonial_twilight/actions/fln/rally.rb index c9ed489..bfa7ab2 100644 --- a/lib/colonial_twilight/actions/fln/rally.rb +++ b/lib/colonial_twilight/actions/fln/rally.rb @@ -9,7 +9,7 @@ module ColonialTwilight # Rally 3.3.1 class Rally < FlnAction def initialize(space, mode) - super(space, mode) + super(space, mode, cost: 1) @agitate = nil end diff --git a/lib/colonial_twilight/actions/fln/subvert.rb b/lib/colonial_twilight/actions/fln/subvert.rb new file mode 100644 index 0000000..a6ec252 --- /dev/null +++ b/lib/colonial_twilight/actions/fln/subvert.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require_relative 'fln_action' + +module ColonialTwilight + module Actions + module FLN + # Subvert 4.3.2 + class Subvert < FlnAction + def initialize(space, mode) + super(space, mode, cost: 0) + @second = nil + end + + def validate! + super + return if @second.nil? + + p = (mode[:remove_police] || 0) + (@second.mode[:remove_police] || 0) + t = (mode[:remove_troops] || 0) + (@second.mode[:remove_troops] || 0) + raise "remove #{p} police + #{t} troops > 2" if p + t > 2 + end + + def subvert!(space2, mode2) + raise 'subvert! called twice' unless @second.nil? + raise 'cannot subvert! after replace algerian police' if mode.key?(:replace_police) + raise 'cannot subvert! with replace algerian police' if mode2.key?(:replace_police) + + @second = Subvert.new(space2, mode2) + validate! + end + + # remove 2 Algerian cubes into Available, among selected spaces + # or remove 1 Algerian Police and replace it with 1 Underground Guerrilla from Available. + def apply!(board) + raise NotImplementedError + end + + class << self + def special? + true + end + + # spaces with Underground Guerrillas and Algerian cubes. + def applicable?(space) + space.fln_underground.positive? && space.algerian_cubes.positive? + end + + def available_modes(space) + modes = {} + if space.algerian_police.positive? + modes[:replace_police] = 1 + modes[:remove_police] = space.algerian_police > 1 ? 2 : 1 + end + modes[:remove_troops] = space.algerian_troops > 1 ? 2 : 1 if space.algerian_troops.positive? + modes + end + end + end + end + end +end diff --git a/lib/colonial_twilight/actions/fln/terror.rb b/lib/colonial_twilight/actions/fln/terror.rb new file mode 100644 index 0000000..b812bb4 --- /dev/null +++ b/lib/colonial_twilight/actions/fln/terror.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require_relative 'fln_action' + +module ColonialTwilight + module Actions + module FLN + # Terror 3.3.4 + class Terror < FlnAction + def initialize(space, mode) + super(space, mode, cost: 1) + end + + # flip 1 Underground Guerrilla to Active. + # place 1 Terror marker if none (max 12 on the map), set to Neutral + def apply!(board) + raise NotImplementedError + end + + class << self + def op? + true + end + + # Populated not Resettled space with Underground Guerrillas. + def applicable?(space) + !space.country? && space.pop.positive? && space.fln_underground.positive? # && !space.resettled? + end + end + end + end + end +end diff --git a/spec/fln_actions_spec.rb b/spec/fln_actions_spec.rb new file mode 100644 index 0000000..5301e72 --- /dev/null +++ b/spec/fln_actions_spec.rb @@ -0,0 +1,297 @@ +# frozen_string_literal: true + +require './lib/colonial_twilight/actions/fln/rally' +require './lib/colonial_twilight/actions/fln/agitate' +require './lib/colonial_twilight/actions/fln/attack' +require './lib/colonial_twilight/actions/fln/march' +require './lib/colonial_twilight/actions/fln/terror' +require './lib/colonial_twilight/actions/fln/extort' +require './lib/colonial_twilight/actions/fln/subvert' +require './lib/colonial_twilight/actions/fln/ambush' +require './lib/colonial_twilight/actions/fln/oas' +require './lib/colonial_twilight/board' +require './spec/mock_board' + +describe ColonialTwilight::Actions::FLN do + before do + @board = ColonialTwilight::Board.new + end + + describe 'Rally' do + let(:action_class) { ColonialTwilight::Actions::FLN::Rally } + + it 'collects spaces where operation can be conducted' do + # all but countries + expect(action_class.possible_spaces(@board).size).to eq(28) + end + + it 'collects spaces where operation can be conducted' do + @board.load :short + # 25 sectors + 2 countries + expect(action_class.possible_spaces(@board).size).to eq(27) + end + + it 'applicable? France track' do + t = Track.new(5, 'France track') + expect(action_class.applicable?(t)).to be true + t = Track.new(5, 'France ') + expect(action_class.applicable?(t)).to be false + end + + it 'applicable? sector' do + a = Sector.new + expect(action_class.applicable?(a)).to be true + end + + it 'applicable? in city not at support' do + a = Sector.new({ name: 'city', support: false }) + expect(action_class.applicable?(a)).to be true + end + + it 'not applicable? in city at support' do + a = Sector.new({ name: 'city', support: true }) + expect(action_class.applicable?(a)).to be false + end + + it 'applicable? in independent country' do + a = Sector.new({ name: 'country', independent: true }) + expect(action_class.applicable?(a)).to be true + end + + it 'not applicable? not in not independent country' do + a = Sector.new({ name: 'country', independent: false }) + expect(action_class.applicable?(a)).to be false + end + + it 'may place 1 guerrillas' do + a = Sector.new({ pop: 3 }) + modes = action_class.available_modes(a) + expect(modes.keys.size).to eq(1) + expect(modes[:place_guerilla]).to eq(1) + end + + it 'may place pop + base guerrillas' do + a = Sector.new({ pop: 3, fln_bases: 2 }) + modes = action_class.available_modes(a) + expect(modes.keys.size).to eq(1) + expect(modes[:place_guerilla]).to eq(5) + end + + it 'may flip underground guerillas' do + a = Sector.new({ fln_active: 3, fln_underground: 2, fln_bases: 1 }) + modes = action_class.available_modes(a) + expect(modes.keys.size).to eq(3) + expect(modes[:place_base]).to eq(1) + expect(modes[:place_guerilla]).to eq(1) + expect(modes[:underground]).to eq(3) + end + + it 'may create action within available modes' do + a = Sector.new({ fln_active: 3, fln_underground: 2, fln_bases: 1 }) + expect { action_class.new(a, { underground: 3 }) }.not_to raise_error + expect(action_class.new(a, { underground: 3 }).cost).to eq(1) + end + + it 'may not create action within a not applicable space' do + a = Sector.new({ name: 'city', support: true }) + expect { action_class.new(a, {}) }.to raise_error(/not applicable/) + end + + it 'may not create action within higher value' do + a = Sector.new({ fln_active: 3, fln_underground: 2, fln_bases: 1 }) + expect { action_class.new(a, { underground: 4 }) }.to raise_error(/value:/) + end + + it 'may create action within available modes' do + a = Sector.new({ fln_active: 3, fln_underground: 2, fln_bases: 1 }) + expect { action_class.new(a, { wrong: 3 }) }.to raise_error(/mode:/) + end + + it 'may agitate and compute cost' do + a = Sector.new({ fln_active: 3, fln_underground: 2, fln_bases: 1, terror: 2 }) + expect { action_class.new(a, { underground: 3 }).agitate!({ remove_terror: 1 }) }.not_to raise_error + expect(action_class.new(a, { underground: 3 }).agitate!({ remove_terror: 1 }).cost).to eq(2) + expect(action_class.new(a, { underground: 3 }).agitate!({ remove_terror: 2 }).cost).to eq(3) + end + + it 'may not agitate if not applicable' do + a = Sector.new({ fln_active: 3, fln_underground: 2, fln_bases: 1, oppose: true }) + expect { action_class.new(a, { underground: 3 }).agitate!({ remove_terror: 1 }) }.to raise_error(/not applicable/) + end + + it 'may not agitate twice' do + a = Sector.new({ fln_active: 3, fln_underground: 2, fln_bases: 1, terror: 1 }) + expect { action_class.new(a, { underground: 3 }).agitate!({ remove_terror: 1 }).agitate!({ remove_terror: 1 }) }.to raise_error(/agitate! called/) + end + end + + describe 'Attack' do + let(:action_class) { ColonialTwilight::Actions::FLN::Attack } + + it 'is applicable where FLN and GOV are present' do + a = Sector.new(fln_active: 1, gov_cubes: 1) + expect(action_class.applicable?(a)).to be true + end + + it 'is not applicable without FLN' do + a = Sector.new(fln_active: 0, gov_cubes: 1) + expect(action_class.applicable?(a)).to be false + end + + it 'is not applicable without GOV' do + a = Sector.new(fln_active: 1, gov_cubes: 0) + expect(action_class.applicable?(a)).to be false + end + + it 'has no modes' do + a = Sector.new(fln_active: 1, gov_cubes: 1) + expect(action_class.available_modes(a).empty?).to be true + end + + it 'can have an ambush' do + a = Sector.new(fln_active: 1, fln_underground: 1, gov_cubes: 1) + action = action_class.new(a) + expect { action.ambush! }.not_to raise_error + end + end + + describe 'March' do + let(:action_class) { ColonialTwilight::Actions::FLN::March } + + it 'is applicable where FLN guerrillas are present' do + a = Sector.new(fln_active: 1) + expect(action_class.applicable?(a)).to be true + end + + it 'is not applicable without FLN guerrillas' do + a = Sector.new(fln_active: 0) + expect(action_class.applicable?(a)).to be false + end + + it 'calculates cost based on destinations' do + a = Sector.new(fln_active: 5) + allow(a).to receive(:adjacents).and_return([1, 2, 3]) + action = action_class.new(a, { 1 => 2, 2 => 3 }) + expect(action.cost).to eq(2) + end + end + + describe 'Terror' do + let(:action_class) { ColonialTwilight::Actions::FLN::Terror } + + it 'is applicable in populated space with underground FLN' do + a = Sector.new(pop: 1, fln_underground: 1) + expect(action_class.applicable?(a)).to be true + end + + it 'is not applicable in unpopulated space' do + a = Sector.new(pop: 0, fln_underground: 1) + expect(action_class.applicable?(a)).to be false + end + + it 'is not applicable without underground FLN' do + a = Sector.new(pop: 1, fln_underground: 0) + expect(action_class.applicable?(a)).to be false + end + + it 'is not applicable in countries' do + a = Sector.new(name: 'country', pop: 1, fln_underground: 1) + expect(action_class.applicable?(a)).to be false + end + + it 'has no mode' do + a = Sector.new(pop: 1, fln_underground: 1) + expect(action_class.available_modes(a).empty?).to be true + end + end + + describe 'Extort' do + let(:action_class) { ColonialTwilight::Actions::FLN::Extort } + + it 'is applicable in populated space with underground FLN and FLN control' do + a = Sector.new(pop: 1, fln_underground: 1, fln_active: 2, gov_cubes: 0) + expect(action_class.applicable?(a)).to be true + end + + it 'is applicable in independent countries with underground FLN' do + a = Sector.new(name: 'country', independent: true, fln_underground: 1) + expect(action_class.applicable?(a)).to be true + end + + it 'is not applicable without FLN control in sector' do + a = Sector.new(pop: 1, fln_underground: 1, fln_active: 0, gov_cubes: 2) + expect(action_class.applicable?(a)).to be false + end + end + + describe 'Subvert' do + let(:action_class) { ColonialTwilight::Actions::FLN::Subvert } + + it 'is applicable with underground FLN and Algerian cubes' do + a = Sector.new(fln_underground: 1, algerian_police: 1) + expect(action_class.applicable?(a)).to be true + end + + it 'provides replace_police and remove_police modes' do + a = Sector.new(fln_underground: 1, algerian_police: 2) + modes = action_class.available_modes(a) + expect(modes[:replace_police]).to eq(1) + expect(modes[:remove_police]).to eq(2) + expect(modes[:remove_troops]).to be_nil + end + + it 'provides replace_police and remove_troops modes' do + a = Sector.new(fln_underground: 1, algerian_troops: 2) + modes = action_class.available_modes(a) + expect(modes[:replace_police]).to be_nil + expect(modes[:remove_police]).to be_nil + expect(modes[:remove_troops]).to eq(2) + end + + it 'may chain subvert in 2 spaces' do + a = Sector.new(fln_underground: 1, algerian_police: 2) + b = Sector.new(fln_underground: 1, algerian_troops: 1) + act = action_class.new(a, { remove_police: 1 }) + expect { act.subvert!(b, { remove_troops: 1 }) }.to_not raise_error + expect { act.subvert!(b, { remove_troops: 2 }) }.to raise_error(/subvert! called/) + act = action_class.new(a, { remove_police: 1 }) + b = Sector.new(fln_underground: 1, algerian_police: 2) + expect { act.subvert!(b, { remove_police: 2 }) }.to raise_error(/remove 3 police/) + b = Sector.new(fln_underground: 1, algerian_troops: 2) + act = action_class.new(a, { remove_police: 1 }) + expect { act.subvert!(b, { remove_troops: 2 }) }.to raise_error(/remove 1 police/) + end + + it 'cannot chain after replace_police' do + a = Sector.new(fln_underground: 1, algerian_police: 2) + b = Sector.new(fln_underground: 1, algerian_troops: 1) + act = action_class.new(a, { replace_police: 1 }) + expect { act.subvert!(b, {}) }.to raise_error(/cannot subvert! after/) + act = action_class.new(a, { remove_police: 1 }) + expect { act.subvert!(b, { replace_police: 1 }) }.to raise_error(/cannot subvert! with/) + end + end + + describe 'Ambush' do + let(:action_class) { ColonialTwilight::Actions::FLN::Ambush } + + it 'is applicable with underground FLN and GOV present' do + a = Sector.new(fln_underground: 1, gov_cubes: 1) + expect(action_class.applicable?(a)).to be true + end + end + + describe 'OAS' do + let(:action_class) { ColonialTwilight::Actions::FLN::Oas } + + it 'is applicable in populated space with no terror and not a country' do + a = Sector.new(pop: 1, terror: 0) + expect(action_class.applicable?(a)).to be true + end + + it 'is not applicable if terror present' do + a = Sector.new(pop: 1, terror: 1) + expect(action_class.applicable?(a)).to be false + end + end +end |
