hlockey 1 → 3

Sign up to get free protection for your applications and to get access to all the features.
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