hlockey 1 → 3

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.
data/data/links.yaml ADDED
@@ -0,0 +1,2 @@
1
+ :GitHub: https://github.com/Hlockey
2
+ :Discord: https://discord.gg/hQMRZyexUB
@@ -0,0 +1,4 @@
1
+ module Hlockey
2
+ UPDATE_FREQUENCY_SECONDS = 5
3
+ UPDATES_PER_HOUR = 3600 / UPDATE_FREQUENCY_SECONDS
4
+ end
@@ -0,0 +1,17 @@
1
+ require("hlockey/team") # Class stored in league data
2
+ require("yaml")
3
+
4
+ module Hlockey
5
+ ##
6
+ # Module containing methods to load Hlockey data
7
+ module Data
8
+ class << self
9
+ data_dir = File.expand_path("../../data", __dir__)
10
+
11
+ Dir.glob("*.yaml", base: data_dir).each do |data_file|
12
+ category = File.basename(data_file, ".yaml")
13
+ define_method(category) { YAML.unsafe_load_file("#{data_dir}/#{data_file}") }
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,30 @@
1
+ module Hlockey
2
+ class Game
3
+ ##
4
+ # For classes that deal with actions within a Hlockey game.
5
+ # Any class including this must have instance variables indicated by the readers
6
+ module Actions
7
+ # @return [Team] the teams in the game
8
+ attr_reader(:home, :away)
9
+
10
+ # @return [Random] should be the same as the league's
11
+ attr_reader(:prng)
12
+
13
+ # @return [Integer] how many actions have passed
14
+ attr_reader(:actions)
15
+
16
+ # @return [Hash<Symbol => Integer>] the score of each team
17
+ attr_reader(:score)
18
+
19
+ # @return [Boolean] if the sequence of actions is still in progress
20
+ attr_reader(:in_progress)
21
+
22
+ # @param succeed_boost [Numeric] increases chance of action succeeding
23
+ # @param fail_boost [Numeric] decreases chance of action succeeding
24
+ # @return [Boolean] if the action succeeded
25
+ def action_succeeds?(succeed_boost, fail_boost)
26
+ @prng.rand(10) + succeed_boost - fail_boost > 4
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,83 @@
1
+ require("hlockey/game/actions")
2
+ require("hlockey/message")
3
+
4
+ module Hlockey
5
+ class Game
6
+ ##
7
+ # A fight within a Hlockey game
8
+ class Fight
9
+ include(Actions)
10
+
11
+ # @return [Hash<Symbol => Array<Player>>] the players in the fight
12
+ attr_reader(:players)
13
+
14
+ # @param game [Game] the game the fight is a part of
15
+ def initialize(game)
16
+ @home = game.home
17
+ @away = game.away
18
+ @prng = game.prng
19
+ @actions = 0
20
+ @players = {
21
+ home: [@home.random_player(@prng)],
22
+ away: [@away.random_player(@prng)]
23
+ }
24
+ @score = { home: 0, away: 0 }
25
+ @in_progress = true
26
+ end
27
+
28
+ # @return [Message] the message to add to the game stream
29
+ def next_action
30
+ @actions += 1
31
+
32
+ case @prng.rand(@actions)
33
+ when 0..3 # attack
34
+ if rand_is_home?
35
+ attack(:home, :away)
36
+ else
37
+ attack(:away, :home)
38
+ end
39
+ when 4..7 # player joins
40
+ team_joining_sym = rand_is_home? ? :away : :home
41
+ team_joining = send(team_joining_sym)
42
+
43
+ player_joining = team_joining.random_player(
44
+ @prng,
45
+ proc { |player| !@players[team_joining_sym].include?(player) }
46
+ )
47
+ @players[team_joining_sym] << player_joining
48
+
49
+ Message.PlayerJoinedFight(team_joining, player_joining)
50
+ else # fight ends
51
+ @in_progress = false
52
+
53
+ Message.FightEnded
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ # @param attacking_team [Team]
60
+ # @param defending_team [Team]
61
+ # @return [Message] returned by #next_action for same purpose
62
+ def attack(attacking_team, defending_team)
63
+ attacking_player = @players[attacking_team].sample(random: @prng)
64
+ defending_player = @players[defending_team].sample(random: @prng)
65
+
66
+ blocked = !action_succeeds?(attacking_player.stats[:offense],
67
+ defending_player.stats[:defense])
68
+
69
+ @score[attacking_team] += 1 unless blocked
70
+
71
+ Message.FightAttack(attacking_player, defending_player, blocked)
72
+ end
73
+
74
+ # @return [Boolean] if randomly selected team is home team
75
+ def rand_is_home?
76
+ home_player_amount = @home.roster.values.length
77
+ away_player_amount = @away.roster.values.length
78
+
79
+ @prng.rand(home_player_amount + away_player_amount) < home_player_amount
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,254 @@
1
+ require("hlockey/game/actions")
2
+ require("hlockey/game/fight")
3
+ require("hlockey/constants")
4
+ require("hlockey/message")
5
+
6
+ module Hlockey
7
+ ##
8
+ # A single game of Hlockey
9
+ class Game
10
+ include(Actions)
11
+
12
+ ACTIONS_PER_PERIOD = 60
13
+ NON_OT_PERIODS = 4
14
+ NON_OT_ACTIONS = ACTIONS_PER_PERIOD * NON_OT_PERIODS
15
+ TOTAL_ACTIONS = UPDATES_PER_HOUR - ACTIONS_PER_PERIOD
16
+ TOTAL_PERIODS = TOTAL_ACTIONS / ACTIONS_PER_PERIOD
17
+
18
+ # @return [Array<Message>]
19
+ attr_reader(:stream)
20
+
21
+ # @return [Weather]
22
+ attr_reader(:weather)
23
+
24
+ # @param home [Team]
25
+ # @param away [Team]
26
+ # @param prng [Random] should be League#prng
27
+ # @param weather [Class<Weather>]
28
+ def initialize(home, away, prng, weather)
29
+ @home = home
30
+ @away = away
31
+ @prng = prng
32
+ @weather = weather.new(self)
33
+ @stream = [Message.StartOfGame]
34
+ @score = { home: 0, away: 0 }
35
+ @in_progress = true
36
+ @actions = 0
37
+ @period = 1
38
+ @faceoff = true
39
+ @team_with_puck = nil
40
+ @puckless_team = nil
41
+ @puck_holder = nil
42
+ @shooting_chance = 0
43
+ @fight = nil
44
+ @fight_chance = 0
45
+ @total_fight_actions = 0
46
+ @pre_morale_change_stats = {}
47
+
48
+ @weather.on_game_start
49
+ end
50
+
51
+ # @return [String]
52
+ def to_s
53
+ "#{@home} vs #{@away}"
54
+ end
55
+
56
+ def update
57
+ return unless @in_progress
58
+
59
+ unless @fight.nil?
60
+ @stream << @fight.next_action
61
+ if @stream.last.event == :FightEnded
62
+ @total_fight_actions += @fight.actions
63
+
64
+ morale_change_amount = (@fight.score[:home] - @fight.score[:away]) * 0.05
65
+ boost_morale(@home, morale_change_amount)
66
+ boost_morale(@away, -morale_change_amount)
67
+
68
+ @fight = nil
69
+ end
70
+ return
71
+ end
72
+
73
+ @stream << Message.StartOfPeriod(@period) if @actions.zero?
74
+ @actions += 1
75
+
76
+ if @actions >= ACTIONS_PER_PERIOD
77
+ end_period
78
+ return
79
+ end
80
+
81
+ if @faceoff
82
+ do_faceoff
83
+ return
84
+ end
85
+
86
+ @weather.on_action
87
+
88
+ case @prng.rand(5 + @shooting_chance)
89
+ when 0..4
90
+ pass
91
+ when 5..6
92
+ check
93
+ else
94
+ shoot
95
+ end
96
+ end
97
+
98
+ private
99
+
100
+ def end_period
101
+ @weather.on_period_end
102
+ @stream << Message.EndOfPeriod(@period, @home, @away, *@score.values)
103
+ @actions = 0
104
+ @period += 1
105
+ start_faceoff
106
+
107
+ if @period >= NON_OT_PERIODS &&
108
+ !(@score[:home] == @score[:away] &&
109
+ @period <= TOTAL_PERIODS - (@total_fight_actions / ACTIONS_PER_PERIOD))
110
+ # Game is over
111
+ @pre_morale_change_stats.each { |player, stats| player.stats = stats }
112
+ @weather.on_game_end
113
+ @in_progress = false
114
+ winner, loser = @score[:away] > @score[:home] ? [@away, @home] : [@home, @away]
115
+ @stream << Message.EndOfGame(winner)
116
+ winner.wins += 1
117
+ loser.losses += 1
118
+ end
119
+ end
120
+
121
+ def start_faceoff
122
+ @shooting_chance = 0
123
+ @faceoff = true
124
+ @puck_holder = nil
125
+ end
126
+
127
+ def do_faceoff
128
+ if @puck_holder.nil?
129
+ # Pass opposite team to who wins the puck to switch_team_with_puck,
130
+ # so @team_with_puck & @puckless_team are set to the correct values.
131
+ switch_team_with_puck(
132
+ if action_succeeds?(@home.roster[:center].stats[:offense],
133
+ @away.roster[:center].stats[:offense])
134
+ @away
135
+ else
136
+ @home
137
+ end
138
+ )
139
+
140
+ @puck_holder = @team_with_puck.roster[:center]
141
+ @stream << Message.FaceOff(@puck_holder, @team_with_puck)
142
+ else
143
+ pass
144
+ @faceoff = false
145
+ end
146
+ end
147
+
148
+ def switch_team_with_puck(team_with_puck = @team_with_puck)
149
+ @team_with_puck, @puckless_team = if team_with_puck == @home
150
+ [@away, @home]
151
+ else
152
+ [@home, @away]
153
+ end
154
+ @shooting_chance = 0
155
+ end
156
+
157
+ def pass
158
+ sender = @puck_holder
159
+ receiver = random_puckless_non_goalie(@team_with_puck)
160
+ interceptor = random_puckless_non_goalie(@puckless_team)
161
+
162
+ if !@faceoff && try_take_puck(interceptor, 4) # Pass intercepted
163
+ @stream << Message.Pass(sender, receiver, interceptor, @team_with_puck)
164
+ return
165
+ end
166
+
167
+ @stream << Message.Pass(sender, receiver)
168
+ @puck_holder = receiver
169
+ @shooting_chance += 1
170
+ end
171
+
172
+ def check
173
+ defender = random_puckless_non_goalie(@puckless_team)
174
+ @stream << Message.Hit(@puck_holder, defender,
175
+ try_take_puck(defender, 0, :defense), @team_with_puck)
176
+ @fight_chance += 0.1
177
+ end
178
+
179
+ def shoot
180
+ if @shooting_chance < 5 &&
181
+ try_block_shot(@puckless_team.roster[@prng.rand(2).zero? ? :ldef : :rdef])
182
+ return
183
+ end
184
+ return if try_block_shot(@puckless_team.roster[:goalie])
185
+
186
+ # Goal scored
187
+
188
+ scoring_team = @team_with_puck == @home ? :home : :away
189
+ @score[scoring_team] += 1
190
+ weather.on_goal(scoring_team)
191
+
192
+ @stream << Message.ShootScore(@puck_holder, @home, @away, *@score.values)
193
+
194
+ if @prng.rand(3 + @fight_chance) > 5 &&
195
+ @total_fight_actions < TOTAL_ACTIONS - NON_OT_ACTIONS
196
+ start_fight
197
+ end
198
+
199
+ start_faceoff
200
+ @actions = ACTIONS_PER_PERIOD - 1 if @period >= NON_OT_PERIODS # Sudden death OT
201
+ end
202
+
203
+ # @return [Boolean] if taking the puck succeeded
204
+ def try_take_puck(player, disadvantage = 0, stat = :agility)
205
+ return false unless action_succeeds?(player.stats[stat] - disadvantage,
206
+ @puck_holder.stats[stat])
207
+
208
+ switch_team_with_puck
209
+ @puck_holder = player
210
+
211
+ true
212
+ end
213
+
214
+ # @return [Boolean] if blocking the shot succeeded
215
+ def try_block_shot(blocker)
216
+ return false unless action_succeeds?(blocker.stats[:defense],
217
+ @puck_holder.stats[:offense])
218
+
219
+ @shooting_chance += 1
220
+ @stream << Message.ShootBlock(@puck_holder, blocker,
221
+ try_take_puck(blocker), @team_with_puck)
222
+
223
+ true
224
+ end
225
+
226
+ def start_fight
227
+ @fight = Fight.new(self)
228
+ @stream << Message.FightStarted(@fight.players[:home].first,
229
+ @fight.players[:away].first)
230
+ end
231
+
232
+ # @param team [Team]
233
+ # @param amount [Numeric]
234
+ def boost_morale(team, amount)
235
+ return if amount.zero?
236
+
237
+ team.roster.each_value do |player|
238
+ if @pre_morale_change_stats[player].nil?
239
+ @pre_morale_change_stats[player] = player.stats.clone
240
+ end
241
+
242
+ player.stats.transform_values! { |stat| stat + amount }
243
+ end
244
+ end
245
+
246
+ # @return [Player]
247
+ def random_puckless_non_goalie(team)
248
+ team.random_player(
249
+ @prng,
250
+ proc { |player| player != team.roster[:goalie] and player != @puck_holder }
251
+ )
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,150 @@
1
+ require("hlockey/constants")
2
+ require("hlockey/data")
3
+ require("hlockey/game")
4
+ require("hlockey/utils")
5
+ require("hlockey/version")
6
+ require("hlockey/weather")
7
+
8
+ module Hlockey
9
+ ##
10
+ # The Hlockey League
11
+ class League
12
+ REGULAR_SEASON_DAY_AMT = 38
13
+
14
+ # @return [Time]
15
+ attr_reader(:start_time)
16
+
17
+ # @return [Hash<Symbol => Array<Team>>]
18
+ attr_reader(:divisions)
19
+
20
+ # @return [Array<Team>]
21
+ attr_reader(:teams, :playoff_teams)
22
+
23
+ # @return [Integer]
24
+ attr_reader(:day)
25
+
26
+ # @return [Array<Game>]
27
+ attr_reader(:games, :games_in_progress)
28
+
29
+ # @return [Team, nil]
30
+ attr_reader(:champion_team)
31
+
32
+ def initialize
33
+ @start_time, @divisions = Data.league
34
+ @start_time.localtime
35
+ @day = 0
36
+ @games_in_progress = []
37
+ @games = []
38
+ @champion_team = nil
39
+ @last_update_time = @start_time
40
+ @passed_updates = 0
41
+ @prng = Random.new(@start_time.to_i)
42
+ @teams = @divisions.values.reduce(:+)
43
+ @shuffled_teams = @teams.shuffle(random: @prng)
44
+ @playoff_teams = []
45
+ @game_in_matchup = 3
46
+ @matchup_game_amt = 3
47
+ end
48
+
49
+ ##
50
+ # Updates the league to the current state
51
+ # This should be called whenever you need the current state of the league
52
+ def update_state
53
+ return if @champion_team
54
+
55
+ now = Time.at(Time.now.to_i)
56
+ intervals = (now - @last_update_time).div(UPDATE_FREQUENCY_SECONDS)
57
+
58
+ return unless intervals.positive?
59
+
60
+ intervals.times do |i|
61
+ if ((i + @passed_updates) % UPDATES_PER_HOUR).zero?
62
+ new_games
63
+ else
64
+ update_games
65
+ end
66
+
67
+ break if @champion_team
68
+ end
69
+
70
+ @last_update_time = now
71
+ @passed_updates += intervals
72
+ end
73
+
74
+ private
75
+
76
+ def new_games
77
+ if @game_in_matchup != @matchup_game_amt
78
+ # New game in matchups
79
+ @games.map! { |game| new_game(game.away, game.home) }
80
+ @game_in_matchup += 1
81
+ return
82
+ end
83
+
84
+ # New matchups
85
+ @games.clear
86
+ @game_in_matchup = 1
87
+ @day += 1
88
+
89
+ case @day <=> REGULAR_SEASON_DAY_AMT + 1
90
+ when -1 # In regular season
91
+ (@shuffled_teams.length / 2).times do |i|
92
+ pair = [@shuffled_teams[i], @shuffled_teams[-i - 1]]
93
+ pair.reverse! if @day > REGULAR_SEASON_DAY_AMT / 2
94
+ @games << new_game(*pair)
95
+ end
96
+
97
+ @shuffled_teams.insert(1, @shuffled_teams.pop)
98
+ when 0 # Playoffs about to start
99
+ @matchup_game_amt = 5
100
+
101
+ # get teams that qualified for playoffs, put in @playoff_teams
102
+ two_best_teams_each_division = @divisions.values.map do |teams|
103
+ sort_teams_by_wins(teams).first(2)
104
+ end.reduce(:+)
105
+ @playoff_teams = sort_teams_by_wins(two_best_teams_each_division).map(&:clone)
106
+
107
+ new_playoff_matchups
108
+ when 1 # Playoffs started
109
+ @playoff_teams.select! { |team| team.wins > team.losses }
110
+
111
+ if @playoff_teams.length == 1
112
+ @champion_team = @playoff_teams.first
113
+ return
114
+ end
115
+
116
+ new_playoff_matchups
117
+ end
118
+ end
119
+
120
+ def update_games
121
+ @games_in_progress.each(&:update)
122
+ @games_in_progress = @games.select(&:in_progress)
123
+ @divisions.transform_values!(&method(:sort_teams_by_wins))
124
+ end
125
+
126
+ def new_playoff_matchups
127
+ @playoff_teams.each do |team|
128
+ team.wins = 0
129
+ team.losses = 0
130
+ end
131
+
132
+ (@playoff_teams.length / 2).times do |i|
133
+ @games << new_game(@playoff_teams[i], @playoff_teams[-i - 1])
134
+ end
135
+ end
136
+
137
+ # @param home [Team]
138
+ # @param away [Team]
139
+ # @return [Game]
140
+ def new_game(home, away)
141
+ Game.new(home, away, @prng, Utils.weighted_random(Weather::WEIGHTS, @prng))
142
+ end
143
+
144
+ # @param teams [Array<Team>]
145
+ # @return [Array<Team>]
146
+ def sort_teams_by_wins(teams)
147
+ teams.sort { |a, b| b.wins <=> a.wins }
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,133 @@
1
+ require("hlockey/version")
2
+
3
+ module Hlockey
4
+ ##
5
+ # Can be sent by a Game (in Game#stream) or a commonly used text
6
+ class Message
7
+ private_class_method(:new)
8
+
9
+ # @return [Symbol] an action or event the message represents
10
+ attr_reader(:event)
11
+
12
+ # @param event [Symbol]
13
+ # @param fields [Array<Symbol>] fields used in string interpolation in #to_s
14
+ # @param data [Array<Object>] data corresponding to the fields
15
+ def initialize(event, fields, data)
16
+ @event = event
17
+ fields.zip(data) { |f, d| instance_variable_set("@#{f}", d) }
18
+ end
19
+
20
+ class << self
21
+ # These are messages logged to game streams
22
+
23
+ [
24
+ %i[StartOfGame],
25
+ %i[EndOfGame winning_team],
26
+ %i[StartOfPeriod period],
27
+ %i[EndOfPeriod period home away home_score away_score],
28
+ %i[FaceOff winning_player new_puck_team],
29
+ %i[Hit puck_holder defender puck_taken new_puck_team],
30
+ %i[Pass sender receiver interceptor new_puck_team],
31
+ %i[ShootScore shooter home away home_score away_score],
32
+ %i[ShootBlock shooter blocker puck_taken new_puck_team],
33
+ %i[FightStarted home_player away_player],
34
+ %i[FightAttack attacking_player defending_player blocked],
35
+ %i[PlayerJoinedFight team player],
36
+ %i[FightEnded],
37
+ %i[ChickenedOut prev_player next_player],
38
+ %i[InclineFavors team],
39
+ %i[StarsPity team],
40
+ %i[WavesWashedAway prev_player next_player]
41
+ ].each do |event, *fields|
42
+ define_method(event) { |*data| new(event, fields, data) }
43
+ end
44
+
45
+ # These are messages used elsewhere
46
+
47
+ def SeasonDay(day)
48
+ "Season #{VERSION} day #{day}"
49
+ end
50
+
51
+ def SeasonStarts(time)
52
+ time.strftime("Season #{VERSION} starts at %H:%M, %A, %B %d (%Z).")
53
+ end
54
+
55
+ def SeasonChampion(team)
56
+ "Your season #{VERSION} champions are the #{team}!"
57
+ end
58
+
59
+ def NoGames
60
+ "no games right now. it is the offseason. join the Hlockey Discord for updates"
61
+ end
62
+ end
63
+
64
+ def to_s
65
+ case @event
66
+ when :StartOfGame
67
+ "Hocky!"
68
+ when :EndOfGame
69
+ "Game over.\n#{@winning_team} win!"
70
+ when :StartOfPeriod
71
+ "Start#{of_period}"
72
+ when :EndOfPeriod
73
+ "End#{of_period}#{score}"
74
+ when :FaceOff
75
+ "#{@winning_player} wins the faceoff!#{possession_change}"
76
+ when :Hit
77
+ "#{@defender} hits #{@puck_holder}#{takes}"
78
+ when :Pass
79
+ str = "#{@sender} passes to #{@receiver}."
80
+ str += "..\nIntercepted by #{@interceptor}!#{possession_change}" if @interceptor
81
+ str
82
+ when :ShootScore
83
+ "#{shot} and scores!#{score}"
84
+ when :ShootBlock
85
+ "#{shot}...\n#{@blocker} blocks the shot#{takes}"
86
+ when :FightStarted
87
+ "#{@home_player} and #{@away_player} start fighting!"
88
+ when :FightAttack
89
+ str = "#{@attacking_player} punches #{@defending_player}!"
90
+ str += "\n#{@defending_player} blocks the punch!" if @blocked
91
+ str
92
+ when :PlayerJoinedFight
93
+ "#{@player} from #{@team} joins the fight!"
94
+ when :FightEnded
95
+ "The fight has ended."
96
+ when :ChickenedOut
97
+ "#{@prev_player} chickened out!#{replaces_for("game")}"
98
+ when :InclineFavors
99
+ "The incline favors #{@team}."
100
+ when :StarsPity
101
+ "The stars take pity on #{@team} and give them half a goal."
102
+ when :WavesWashedAway
103
+ "#{@prev_player} is washed away by the waves...#{replaces_for("season")}"
104
+ end
105
+ end
106
+
107
+ private
108
+
109
+ def of_period
110
+ " of period #{@period}."
111
+ end
112
+
113
+ def score
114
+ "\n#{@home} #{@home_score.round(1)}, #{@away} #{@away_score.round(1)}"
115
+ end
116
+
117
+ def shot
118
+ "#{@shooter} takes a shot"
119
+ end
120
+
121
+ def takes
122
+ @puck_taken ? " and takes the puck!#{possession_change}" : "!"
123
+ end
124
+
125
+ def possession_change
126
+ "\n#{@new_puck_team} have possession."
127
+ end
128
+
129
+ def replaces_for(period)
130
+ "\n#{@next_player} replaces them for the rest of the #{period}."
131
+ end
132
+ end
133
+ end