chess_engine_rb 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +83 -0
- data/lib/chess_engine/data_definitions/README.md +10 -0
- data/lib/chess_engine/data_definitions/board.rb +192 -0
- data/lib/chess_engine/data_definitions/components/castling_rights.rb +48 -0
- data/lib/chess_engine/data_definitions/components/persistent_array.rb +114 -0
- data/lib/chess_engine/data_definitions/events.rb +174 -0
- data/lib/chess_engine/data_definitions/piece.rb +159 -0
- data/lib/chess_engine/data_definitions/position.rb +137 -0
- data/lib/chess_engine/data_definitions/primitives/castling_data.rb +61 -0
- data/lib/chess_engine/data_definitions/primitives/colors.rb +26 -0
- data/lib/chess_engine/data_definitions/primitives/core_notation.rb +111 -0
- data/lib/chess_engine/data_definitions/primitives/movement.rb +52 -0
- data/lib/chess_engine/data_definitions/square.rb +98 -0
- data/lib/chess_engine/engine.rb +299 -0
- data/lib/chess_engine/errors.rb +35 -0
- data/lib/chess_engine/event_handlers/base_event_handler.rb +112 -0
- data/lib/chess_engine/event_handlers/castling_event_handler.rb +48 -0
- data/lib/chess_engine/event_handlers/en_passant_event_handler.rb +59 -0
- data/lib/chess_engine/event_handlers/init.rb +41 -0
- data/lib/chess_engine/event_handlers/move_event_handler.rb +144 -0
- data/lib/chess_engine/formatters/eran_formatters.rb +71 -0
- data/lib/chess_engine/formatters/init.rb +19 -0
- data/lib/chess_engine/formatters/validation.rb +54 -0
- data/lib/chess_engine/game/history.rb +25 -0
- data/lib/chess_engine/game/init.rb +15 -0
- data/lib/chess_engine/game/legal_moves_helper.rb +126 -0
- data/lib/chess_engine/game/query.rb +168 -0
- data/lib/chess_engine/game/state.rb +198 -0
- data/lib/chess_engine/parsers/eran_parser.rb +85 -0
- data/lib/chess_engine/parsers/identity_parser.rb +18 -0
- data/lib/chess_engine/parsers/init.rb +21 -0
- data/lib/chess_engine_rb.rb +46 -0
- metadata +112 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Core components
|
|
4
|
+
require_relative 'data_definitions/position'
|
|
5
|
+
require_relative 'game/init'
|
|
6
|
+
require_relative 'event_handlers/init'
|
|
7
|
+
require_relative 'parsers/init'
|
|
8
|
+
|
|
9
|
+
module ChessEngine
|
|
10
|
+
# The `Engine` is the central coordinator and interpreter of the chess game.
|
|
11
|
+
#
|
|
12
|
+
# It interprets structured input (chess notation) and translates it into concrete
|
|
13
|
+
# state transitions, producing `GameUpdate` objects and notifying registered listeners.
|
|
14
|
+
#
|
|
15
|
+
# The engine is responsible for a single game session at a time.
|
|
16
|
+
# On initialization, you must choose the notation parser the engine will use (or use the default one).
|
|
17
|
+
#
|
|
18
|
+
# After initializing an engine, you must begin a session either by:
|
|
19
|
+
# - starting a new game with `#new_game`.
|
|
20
|
+
# - loading a game from existing state with `#from_fen` or `#load_game_state`.
|
|
21
|
+
#
|
|
22
|
+
# The game can be exported at any point with `#to_fen`.
|
|
23
|
+
#
|
|
24
|
+
# Clients advance the engine through two mechanisms:
|
|
25
|
+
# - By playing a turn, via the `#play_turn` method.
|
|
26
|
+
# - By attempting a game-ending action: offering, accepting, claiming a draw, or by resigning.
|
|
27
|
+
# Those actions are triggered by `#offer_draw`, `#accept_draw`, `#claim_draw` and `#resign` respectively.
|
|
28
|
+
#
|
|
29
|
+
# To observe game progress, clients can:
|
|
30
|
+
# - Register as listeners and implement `#on_game_update(game_update)` to get turn-by-turn updates,
|
|
31
|
+
# as well as error messages(a `GameUpdate.failure` object) for an invalid client operation.
|
|
32
|
+
# - call the `#last_update` method to get the last game update status.
|
|
33
|
+
class Engine # rubocop:disable Metrics/ClassLength
|
|
34
|
+
DEFAULT_PARSER = Parsers::ERANParser
|
|
35
|
+
|
|
36
|
+
def initialize(default_parser: DEFAULT_PARSER)
|
|
37
|
+
raise ArgumentError, "Not a valid `Parser`: #{default_parser}" unless default_parser.respond_to?(:call)
|
|
38
|
+
|
|
39
|
+
@listeners = []
|
|
40
|
+
@default_parser = default_parser
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def add_listener(listener)
|
|
44
|
+
@listeners << listener unless @listeners.include?(listener)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def remove_listener(listener) = @listeners.delete(listener)
|
|
48
|
+
|
|
49
|
+
def default_parser(parser)
|
|
50
|
+
raise ArgumentError, "Not a valid `Parser`: #{default_parser}" unless parser.respond_to?(:call)
|
|
51
|
+
|
|
52
|
+
@default_parser = parser
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Starts a new game. Resets the game's state and starts a new session.
|
|
56
|
+
def new_game
|
|
57
|
+
load_game_state(Game::State.start)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Starts a new game session with the given state.
|
|
61
|
+
# A lower-level operation - use with caution.
|
|
62
|
+
def load_game_state(state, offered_draw: nil)
|
|
63
|
+
update_game(
|
|
64
|
+
state: state,
|
|
65
|
+
endgame_status: detect_endgame_status(state.query),
|
|
66
|
+
offered_draw: offered_draw,
|
|
67
|
+
event: nil,
|
|
68
|
+
session: @session.nil? ? SessionInfo.started(0) : @session.next
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def from_fen(fen_str)
|
|
73
|
+
position = Position.from_fen(fen_str)
|
|
74
|
+
load_game_state(Game::State.load(position))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def to_fen
|
|
78
|
+
@state.position.to_fen
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The last update that was made.
|
|
82
|
+
# Useful for directly retrieving the most recent `GameUpdate`,
|
|
83
|
+
# allowing inspection of game state changes without engaging with the listener model.
|
|
84
|
+
attr_reader :last_update
|
|
85
|
+
alias status last_update
|
|
86
|
+
|
|
87
|
+
# Plays one side’s turn.
|
|
88
|
+
#
|
|
89
|
+
# Parses the given notation, interprets the corresponding event,
|
|
90
|
+
# updates the engine’s state, and notifies listeners with a `GameUpdate`.
|
|
91
|
+
#
|
|
92
|
+
# Returns a corresponding `GameUpdate`.
|
|
93
|
+
def play_turn(notation, parser = @default_parser)
|
|
94
|
+
result = interpret_turn(notation, parser)
|
|
95
|
+
result.success? ? update_game(**result.success_attributes) : notify_listeners(result)
|
|
96
|
+
result
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Registers a draw offer from the current player.
|
|
100
|
+
# Although FIDE technically allows offering a draw at any point,
|
|
101
|
+
# the engine enforces that only the current player may do so,
|
|
102
|
+
# which results in a simpler interface and therefore makes more sense from an engine perspective.
|
|
103
|
+
def offer_draw
|
|
104
|
+
failure = detect_general_failure || (GameUpdate.failure(:draw_offer_not_allowed) if @offered_draw)
|
|
105
|
+
|
|
106
|
+
failure ? notify_listeners(failure) : update_game(offered_draw: query.position.current_color)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Accepts a pending draw offer from the opponent and ends the game.
|
|
110
|
+
# Does nothing if no offer exists.
|
|
111
|
+
def accept_draw
|
|
112
|
+
invalid_offer = @offered_draw.nil? || @offered_draw == query&.position&.current_color
|
|
113
|
+
failure = detect_general_failure || (GameUpdate.failure(:draw_accept_not_allowed) if invalid_offer)
|
|
114
|
+
|
|
115
|
+
failure ? notify_listeners(failure) : update_game(endgame_status: GameOutcome[:draw, :agreement])
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Claims a draw by rule (50-move rule or repetition) if eligible.
|
|
119
|
+
# Does nothing if the claim is invalid.
|
|
120
|
+
def claim_draw # rubocop:disable Metrics/CyclomaticComplexity
|
|
121
|
+
eligible_cause = if query&.threefold_repetition? then :threefold_repetition
|
|
122
|
+
elsif query&.fifty_move_rule? then :fifty_move
|
|
123
|
+
end
|
|
124
|
+
failure = detect_general_failure || (GameUpdate.failure(:draw_claim_not_allowed) unless eligible_cause)
|
|
125
|
+
|
|
126
|
+
failure ? notify_listeners(failure) : update_game(endgame_status: GameOutcome[:draw, eligible_cause])
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Resign and end the game immediately.
|
|
130
|
+
def resign
|
|
131
|
+
failure = detect_general_failure
|
|
132
|
+
winner = query&.position&.other_color # For non-failure
|
|
133
|
+
|
|
134
|
+
failure ? notify_listeners(failure) : update_game(endgame_status: GameOutcome[winner, :resignation])
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
# Interprets a move notation through all internal processing stages.
|
|
140
|
+
# If valid, advances the engine state; Returns a `GameUpdate`.
|
|
141
|
+
def interpret_turn(notation, parser)
|
|
142
|
+
failure = detect_general_failure
|
|
143
|
+
return failure unless failure.nil?
|
|
144
|
+
|
|
145
|
+
event = parser.call(notation, @state.query)
|
|
146
|
+
return GameUpdate.failure(:invalid_notation) unless event
|
|
147
|
+
|
|
148
|
+
interpret_event(event)
|
|
149
|
+
rescue InvariantViolationError => e
|
|
150
|
+
raise InternalEngineError, "The engine encountered a problem: #{e}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Executes a given event.
|
|
154
|
+
#
|
|
155
|
+
# On success:
|
|
156
|
+
# - Applies event to the current `Game::State`
|
|
157
|
+
# - Returns a `GameUpdate.success` with updated fields
|
|
158
|
+
#
|
|
159
|
+
# On failure:
|
|
160
|
+
# - Returns a `GameUpdate.failure(:invalid_event)`
|
|
161
|
+
def interpret_event(event)
|
|
162
|
+
result = EventHandlers.handle(event, @state.query)
|
|
163
|
+
return GameUpdate.failure(:invalid_event) if result.failure?
|
|
164
|
+
|
|
165
|
+
state = @state.apply_event(result.event)
|
|
166
|
+
GameUpdate.success(
|
|
167
|
+
event: result.event,
|
|
168
|
+
state: state,
|
|
169
|
+
endgame_status: detect_endgame_status(state.query),
|
|
170
|
+
# Clear draw offer if the opponent just moved without accepting
|
|
171
|
+
offered_draw: state.position.current_color == @offered_draw ? nil : @offered_draw,
|
|
172
|
+
session: @session.current
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Updates the engine with the provided fields:
|
|
177
|
+
# - updates the specified subset of instance variables related to current game session
|
|
178
|
+
# - notifies listeners of the update and returns it
|
|
179
|
+
def update_game(state: :not_provided, endgame_status: :not_provided, offered_draw: :not_provided,
|
|
180
|
+
session: :not_provided, event: :not_provided)
|
|
181
|
+
@state = state unless state == :not_provided
|
|
182
|
+
@endgame_status = endgame_status unless endgame_status == :not_provided
|
|
183
|
+
@offered_draw = offered_draw unless offered_draw == :not_provided
|
|
184
|
+
@session = session unless session == :not_provided
|
|
185
|
+
@event = event unless event == :not_provided
|
|
186
|
+
@last_update = GameUpdate.success(event: @event, state: @state, endgame_status: @endgame_status,
|
|
187
|
+
offered_draw: @offered_draw, session: @session)
|
|
188
|
+
notify_listeners(@last_update)
|
|
189
|
+
@last_update
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def notify_listeners(game_update)
|
|
193
|
+
@listeners.each do |listener|
|
|
194
|
+
listener.on_game_update(game_update)
|
|
195
|
+
end
|
|
196
|
+
game_update
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Checks the given state for checkmate or automatic draw conditions.
|
|
200
|
+
# The provided `Game::Query` should reflect the latest position after a move,
|
|
201
|
+
# so this method always returns the endgame status resulting from the most recent update.
|
|
202
|
+
# Returns a `GameOutcome` object if the game has ended, otherwise returns nil
|
|
203
|
+
def detect_endgame_status(query)
|
|
204
|
+
return GameOutcome[query.position.other_color, :checkmate] if query.in_checkmate?
|
|
205
|
+
|
|
206
|
+
cause = if query.stalemate? then :stalemate
|
|
207
|
+
elsif query.insufficient_material? then :insufficient_material
|
|
208
|
+
elsif query.fivefold_repetition? then :fivefold_repetition
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
return nil if cause.nil?
|
|
212
|
+
|
|
213
|
+
GameOutcome[:draw, cause]
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# General detection for invalid client action
|
|
217
|
+
def detect_general_failure
|
|
218
|
+
return GameUpdate.failure(:no_ongoing_session) unless @session
|
|
219
|
+
return GameUpdate.failure(:game_already_ended) if @endgame_status
|
|
220
|
+
|
|
221
|
+
nil
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Convenience accessor
|
|
225
|
+
def query = @state&.query
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
class Engine
|
|
229
|
+
# Represents the outcome of a change in the state of the game, like playing a turn or accepting a draw.
|
|
230
|
+
#
|
|
231
|
+
# On success, it contains:
|
|
232
|
+
# - The event that describes what happened.
|
|
233
|
+
# - The full `Game::State` and `Game::Query` for inspection.
|
|
234
|
+
# - The current endgame status (if any).
|
|
235
|
+
# - The current draw offer status.
|
|
236
|
+
# - Metadata about the current game session.
|
|
237
|
+
#
|
|
238
|
+
# On failure, contains only `error`, which is one of:
|
|
239
|
+
# - `:invalid_notation`
|
|
240
|
+
# - `:invalid_event`
|
|
241
|
+
# - `:game_already_ended`
|
|
242
|
+
# - `:no_ongoing_session`
|
|
243
|
+
# - `:draw_offer_not_allowed`
|
|
244
|
+
# - `:draw_accept_not_allowed`
|
|
245
|
+
# - `:draw_claim_not_allowed`
|
|
246
|
+
#
|
|
247
|
+
# Note: `error` may later be replaced with a structured object to support
|
|
248
|
+
# more detailed error handling.
|
|
249
|
+
#
|
|
250
|
+
# Typically, clients should rely on the event sequence and status fields;
|
|
251
|
+
# direct access to `Game::Query` is for specialized use cases.
|
|
252
|
+
GameUpdate = Data.define(:event, :state, :endgame_status, :offered_draw, :session, :error) do
|
|
253
|
+
def success? = error.nil?
|
|
254
|
+
def failure? = !success?
|
|
255
|
+
|
|
256
|
+
def self.success(event:, state:, endgame_status:, offered_draw:, session:)
|
|
257
|
+
new(event, state, endgame_status, offered_draw, session, nil)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def self.failure(error)
|
|
261
|
+
new(nil, nil, nil, nil, nil, error)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# A successful result has no `error` field
|
|
265
|
+
def success_attributes
|
|
266
|
+
to_h.except(:error)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# A failed result has only an `error` field
|
|
270
|
+
def failure_attributes
|
|
271
|
+
{ error: error }
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def game_ended? = !endgame_status.nil?
|
|
275
|
+
def in_check? = game_query.in_check?
|
|
276
|
+
def can_draw? = game_query.can_draw?
|
|
277
|
+
|
|
278
|
+
def game_query = state.query
|
|
279
|
+
def position = state.position
|
|
280
|
+
def board = position.board
|
|
281
|
+
def current_color = position.current_color
|
|
282
|
+
|
|
283
|
+
private_class_method :new # enforces use of factories
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Metadata about the current game session
|
|
287
|
+
SessionInfo = Data.define(:id, :new?) do
|
|
288
|
+
def self.ongoing(id) = new(id, false)
|
|
289
|
+
def self.started(id) = new(id, true)
|
|
290
|
+
def next = self.class.started(id + 1)
|
|
291
|
+
def current = self.class.ongoing(id)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# winner is one of: :white, :black, :draw
|
|
295
|
+
# cause is one of: :checkmate, :resignation, :agreement, :stalemate, :insufficient_material, :fivefold_repetition,
|
|
296
|
+
# :threefold_repetition, :fifty_move
|
|
297
|
+
GameOutcome = Data.define(:winner, :cause)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChessEngine
|
|
4
|
+
# Those are errors that should be used internally by `Engine` and its components when something goes wrong.
|
|
5
|
+
#
|
|
6
|
+
# Not to be confused with errors that are expected as part of engine-client interaction,
|
|
7
|
+
# which are a regular part of the control flow, and returned to the listeners in the form of `GameUpdate.error`.
|
|
8
|
+
module Errors
|
|
9
|
+
# A base class for the specific invariant violations below.
|
|
10
|
+
# Signifies that an invariant inside of one of the engine's components, such as `Game::State`, has been violated.
|
|
11
|
+
class InvariantViolationError < StandardError; end
|
|
12
|
+
|
|
13
|
+
# Raised when `Game::State` receives an invalid event.
|
|
14
|
+
class InvalidEventError < InvariantViolationError; end
|
|
15
|
+
|
|
16
|
+
# Raised when attempting illegal manipulations on the `Board`.
|
|
17
|
+
# (e.g., removing a piece from an empty square)
|
|
18
|
+
class BoardManipulationError < InvariantViolationError; end
|
|
19
|
+
|
|
20
|
+
# Raised when attempting to use an invalid `Square`.
|
|
21
|
+
# Note that squares can be created invalidly by design, but using them is forbidden.
|
|
22
|
+
class InvalidSquareError < InvariantViolationError; end
|
|
23
|
+
|
|
24
|
+
# Signals to the client that the engine malfunctioned internally.
|
|
25
|
+
# While the other errors are used inside the engine for communication,
|
|
26
|
+
# this is the single error that clients should see.
|
|
27
|
+
class InternalError < StandardError; end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
InvariantViolationError = Errors::InvariantViolationError
|
|
31
|
+
InvalidEventError = Errors::InvalidEventError
|
|
32
|
+
BoardManipulationError = Errors::BoardManipulationError
|
|
33
|
+
InvalidSquareError = Errors::InvalidSquareError
|
|
34
|
+
InternalEngineError = Errors::InternalError
|
|
35
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../data_definitions/events'
|
|
4
|
+
|
|
5
|
+
module ChessEngine
|
|
6
|
+
module EventHandlers
|
|
7
|
+
# Base class for all event handlers.
|
|
8
|
+
#
|
|
9
|
+
# Each handler validates an event against the current game state.
|
|
10
|
+
# It either produces a fully resolved event (suitable for application to the game state), or returns an error.
|
|
11
|
+
#
|
|
12
|
+
# Handlers are implemented as classes for convenience (shared helpers, instance context),
|
|
13
|
+
# but their public API is procedural:
|
|
14
|
+
# `.call(query, event)` runs the handler and returns an `EventResult`.
|
|
15
|
+
#
|
|
16
|
+
# Subclasses must implement `#resolve`.
|
|
17
|
+
class BaseEventHandler
|
|
18
|
+
attr_reader :query, :event
|
|
19
|
+
|
|
20
|
+
def initialize(query, event)
|
|
21
|
+
@query = query
|
|
22
|
+
@event = event
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Primary entry point.
|
|
26
|
+
|
|
27
|
+
def self.call(query, event)
|
|
28
|
+
new(query, event).call
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Validates and completes the event, returning an `EventResult`.
|
|
32
|
+
# Clients should use the class-level `.call` instead.
|
|
33
|
+
def call
|
|
34
|
+
result = resolve
|
|
35
|
+
return result if result.failure?
|
|
36
|
+
|
|
37
|
+
post_process(result.event)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# To be implemented by subclasses.
|
|
43
|
+
# Validates and resolves the given event into a full, valid event, suitable for `Game::State` application.
|
|
44
|
+
# Returns an `EventResult` with either the finalized event or an error.
|
|
45
|
+
def resolve
|
|
46
|
+
raise NotImplementedError
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Common post-processing logic, applied to all valid results.
|
|
50
|
+
# (e.g., flagging check or checkmate events, enforcing turn-based constraints, etc.)
|
|
51
|
+
def post_process(event)
|
|
52
|
+
return failure if next_turn_in_check?(event)
|
|
53
|
+
|
|
54
|
+
success(event)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def next_turn_in_check?(event)
|
|
58
|
+
new_query = query.state.apply_event(event).query
|
|
59
|
+
new_query.in_check?(position.current_color)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
### Helpers for subclasses
|
|
63
|
+
|
|
64
|
+
# Runs a series of resolution steps in order.
|
|
65
|
+
# Stops when out of steps, or when result doesn't match the specified condition.
|
|
66
|
+
# By default, the condition is for `result` to be successful.
|
|
67
|
+
# You can set custom stop conditions for specific steps using `stop_conditions`.
|
|
68
|
+
#
|
|
69
|
+
# `steps`: a series of method names on `self` to execute. Each step takes the event and returns an `EventResult`.
|
|
70
|
+
# `stop_conditions`: a hash where each key is a step and the value is a condition lambda.
|
|
71
|
+
def run_resolution_pipeline(*steps, **stop_conditions)
|
|
72
|
+
default_condition = lambda(&:success?)
|
|
73
|
+
result = success(event)
|
|
74
|
+
steps.each do |step|
|
|
75
|
+
result = send(step, result.event)
|
|
76
|
+
condition = stop_conditions.fetch(step, default_condition)
|
|
77
|
+
return result unless condition.call(result)
|
|
78
|
+
end
|
|
79
|
+
result
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def success(event)
|
|
83
|
+
EventResult.success(event)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def failure(msg = '')
|
|
87
|
+
msg = "Message: #{msg}" unless msg.empty?
|
|
88
|
+
EventResult.failure("Invalid result for #{event.inspect}. #{msg}")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Useful accessors
|
|
92
|
+
def board = @query.board
|
|
93
|
+
def position = query.position
|
|
94
|
+
def current_color = position.current_color
|
|
95
|
+
def other_color = position.other_color
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Represents the outcome of processing a chess event.
|
|
99
|
+
#
|
|
100
|
+
# - On success: contains the finalized event, and `error` is nil.
|
|
101
|
+
# - On failure: contains an error message (`error`), and `event` is nil.
|
|
102
|
+
EventResult = Data.define(:event, :error) do
|
|
103
|
+
def success? = error.nil?
|
|
104
|
+
def failure? = !success?
|
|
105
|
+
|
|
106
|
+
def self.success(event) = new(event, nil)
|
|
107
|
+
def self.failure(error) = new(nil, error)
|
|
108
|
+
|
|
109
|
+
private_class_method :new # enforces use of factories
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base_event_handler'
|
|
4
|
+
require_relative '../data_definitions/events'
|
|
5
|
+
require_relative '../data_definitions/primitives/colors'
|
|
6
|
+
require_relative '../data_definitions/primitives/castling_data'
|
|
7
|
+
|
|
8
|
+
module ChessEngine
|
|
9
|
+
module EventHandlers
|
|
10
|
+
# Event handler for `CastlingEvent`.
|
|
11
|
+
class CastlingEventHandler < BaseEventHandler
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def resolve
|
|
15
|
+
return failure("#{event} is not a CastlingEvent") unless event.is_a?(CastlingEvent)
|
|
16
|
+
|
|
17
|
+
run_resolution_pipeline(:resolve_color, :resolve_side)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def resolve_color(event)
|
|
21
|
+
return failure("Not a color: #{event.color}") unless event.color.nil? || Colors.valid?(event.color)
|
|
22
|
+
return failure("Unexpected color: #{event.color} (expected #{current_color})") if event.color == other_color
|
|
23
|
+
|
|
24
|
+
success(event.with(color: current_color))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def resolve_side(event)
|
|
28
|
+
return failure("Not a valid side: #{event.side}") unless CastlingData::SIDES.include?(event.side)
|
|
29
|
+
|
|
30
|
+
sides = position.castling_rights.sides(event.color)
|
|
31
|
+
return failure("No rights for side #{event.side}") unless sides.side?(event.side)
|
|
32
|
+
|
|
33
|
+
king_is_attacked = CastlingData.king_path(event.color, event.side).any? do |sq|
|
|
34
|
+
query.square_attacked?(sq, other_color)
|
|
35
|
+
end
|
|
36
|
+
return failure('King is under attack somewhere on the path') if king_is_attacked
|
|
37
|
+
|
|
38
|
+
path_is_clear = CastlingData.intermediate_squares(event.color, event.side).all? do |sq|
|
|
39
|
+
board.get(sq).nil?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
return failure('Path between king and rook is obstructed') unless path_is_clear
|
|
43
|
+
|
|
44
|
+
success(event)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base_event_handler'
|
|
4
|
+
require_relative '../data_definitions/events'
|
|
5
|
+
require_relative '../data_definitions/square'
|
|
6
|
+
require_relative '../data_definitions/primitives/colors'
|
|
7
|
+
|
|
8
|
+
module ChessEngine
|
|
9
|
+
module EventHandlers
|
|
10
|
+
# Event handler for `EnPassantEvent`
|
|
11
|
+
class EnPassantEventHandler < BaseEventHandler
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def resolve
|
|
15
|
+
return failure("#{event} is not an EnPassantEvent") unless event.is_a?(EnPassantEvent)
|
|
16
|
+
return failure('EnPassant not available') unless en_passant_target
|
|
17
|
+
|
|
18
|
+
run_resolution_pipeline(:resolve_color, :resolve_to, :resolve_from)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def resolve_color(event)
|
|
22
|
+
return failure("Not a color: #{event.color}") unless event.color.nil? || Colors.valid?(event.color)
|
|
23
|
+
return failure("Unexpected color: #{event.color} (expected #{current_color})") if event.color == other_color
|
|
24
|
+
|
|
25
|
+
success(event.with(color: current_color))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def resolve_to(event)
|
|
29
|
+
return failure(":to is not a Square: #{event.to}") unless event.to.is_a?(Square)
|
|
30
|
+
return failure("Cannot en passant to #{event.to}") unless event.to.matches?(en_passant_target)
|
|
31
|
+
|
|
32
|
+
success(event.with(to: en_passant_target))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def resolve_from(event)
|
|
36
|
+
return failure(":from is not a Square: #{event.from}") unless event.from.nil? || event.from.is_a?(Square)
|
|
37
|
+
|
|
38
|
+
filtered_squares = determine_from(event)
|
|
39
|
+
return failure("#{event.from} is not a valid :from square for this move") if filtered_squares.empty?
|
|
40
|
+
if filtered_squares.size > 1
|
|
41
|
+
return failure("Disambiguation failed. :from (#{event.from}) could be either one of: #{filtered_squares.inspect}")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
success(event.with(from: filtered_squares.first))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def determine_from(event)
|
|
48
|
+
rank_offset = event.color == :white ? -1 : 1
|
|
49
|
+
offsets = [[1, rank_offset], [-1, rank_offset]]
|
|
50
|
+
offsets.filter_map do |file_off, rank_off|
|
|
51
|
+
sq = en_passant_target.offset(file_off, rank_off)
|
|
52
|
+
sq if sq.valid? && board.get(sq) == event.piece && (event.from.nil? || event.from.matches?(sq))
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def en_passant_target = position.en_passant_target
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'move_event_handler'
|
|
4
|
+
require_relative 'en_passant_event_handler'
|
|
5
|
+
require_relative 'castling_event_handler'
|
|
6
|
+
require_relative '../errors'
|
|
7
|
+
require_relative '../data_definitions/events'
|
|
8
|
+
|
|
9
|
+
module ChessEngine
|
|
10
|
+
# Handles chess events by dispatching them to the appropriate event handler class.
|
|
11
|
+
#
|
|
12
|
+
# Event handling workflow:
|
|
13
|
+
# 1. The Engine provides an event representing a move or action as interpreted from user input or UI.
|
|
14
|
+
# 2. The `handle` method selects the correct handler for the event type and invokes it.
|
|
15
|
+
# 3. The handler validates the event against the current `Game::State`, resolves ambiguities, fills in missing data,
|
|
16
|
+
# and returns an `EventResult`:
|
|
17
|
+
# - On success: returns `EventResult.success` with a fully resolved event.
|
|
18
|
+
# - On failure: returns `EventResult.failure` with an error message.
|
|
19
|
+
#
|
|
20
|
+
# Entry point: `handle(event, query)`
|
|
21
|
+
# Raises `InvalidEventError` if no handler exists for the event type.
|
|
22
|
+
module EventHandlers
|
|
23
|
+
# Individual handlers
|
|
24
|
+
HANDLER_MAP = {
|
|
25
|
+
MovePieceEvent => MoveEventHandler,
|
|
26
|
+
EnPassantEvent => EnPassantEventHandler,
|
|
27
|
+
CastlingEvent => CastlingEventHandler
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
# Get the appropriate event handler and process the event
|
|
33
|
+
def handle(event, query)
|
|
34
|
+
handler_class = HANDLER_MAP.fetch(event.class) do
|
|
35
|
+
raise InvalidEventError, "no handler for #{event}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
handler_class.call(query, event)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|