lzrtag-base 1.0.0
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/README.md +98 -0
- data/docs/EventSymbols.md +117 -0
- data/docs/MQTTBranches.md +74 -0
- data/lib/lzrtag.rb +13 -0
- data/lib/lzrtag/game/base_game.rb +220 -0
- data/lib/lzrtag/handler/base_handler.rb +169 -0
- data/lib/lzrtag/handler/count_handler.rb +55 -0
- data/lib/lzrtag/handler/game_handler.rb +258 -0
- data/lib/lzrtag/handler/hitArb_handler.rb +80 -0
- data/lib/lzrtag/hooks/base_hook.rb +143 -0
- data/lib/lzrtag/hooks/standard_hooks.rb +174 -0
- data/lib/lzrtag/map/map_set.rb +48 -0
- data/lib/lzrtag/map/map_zone.rb +95 -0
- data/lib/lzrtag/map/myMaps_parser.rb +136 -0
- data/lib/lzrtag/player/base_player.rb +104 -0
- data/lib/lzrtag/player/effects_player.rb +96 -0
- data/lib/lzrtag/player/hardware_player.rb +251 -0
- data/lib/lzrtag/player/life_player.rb +116 -0
- metadata +131 -0
@@ -0,0 +1,80 @@
|
|
1
|
+
|
2
|
+
require 'json'
|
3
|
+
require_relative 'base_handler.rb'
|
4
|
+
|
5
|
+
module LZRTag
|
6
|
+
module Handler
|
7
|
+
# Hit arbitration handling class.
|
8
|
+
# This class extends the Handler::Base class, adding important features
|
9
|
+
# to ensure that each player shot is only counted once.
|
10
|
+
# Additionally, hooks can manipulate this behavior and prevent friendly-fire,
|
11
|
+
# or enable multiple hits in the case of a shotgun!
|
12
|
+
#
|
13
|
+
# Shot arbitration is performed by listening to "Lasertag/Game/Events",
|
14
|
+
# waiting for a 'type: "hit"'
|
15
|
+
# If such a JSON payload is found, hit and source players will be
|
16
|
+
# determined, and every available hook's "process_raw_hit" function
|
17
|
+
# is called. If this function returns false, the hit will be "vetoed" and
|
18
|
+
# does not count at all. However, a hook can raise {LZRTag::Handler::NoArbitration},
|
19
|
+
# preventing this shot from being logged and thusly enabling multiple hits
|
20
|
+
# @see Base
|
21
|
+
class HitArb < Base
|
22
|
+
def initialize(*data, **options)
|
23
|
+
super(*data, **options);
|
24
|
+
|
25
|
+
@mqtt.subscribe_to "Lasertag/Game/Events" do |data|
|
26
|
+
begin
|
27
|
+
data = JSON.parse(data, symbolize_names: true);
|
28
|
+
|
29
|
+
if(data[:type] == "hit")
|
30
|
+
_handle_hitArb(data);
|
31
|
+
end
|
32
|
+
rescue JSON::ParserError
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def process_raw_hit(*)
|
38
|
+
return true;
|
39
|
+
end
|
40
|
+
|
41
|
+
def _handle_hitArb(data)
|
42
|
+
unless (hitPlayer = get_player(data[:target])) and
|
43
|
+
(sourcePlayer = get_player(data[:shooterID])) and
|
44
|
+
(arbCode = data[:arbCode])
|
45
|
+
return
|
46
|
+
end
|
47
|
+
|
48
|
+
return if (sourcePlayer.hitIDTimetable[arbCode] + 1) > Time.now();
|
49
|
+
|
50
|
+
veto = false;
|
51
|
+
arbitrateShot = true;
|
52
|
+
|
53
|
+
hookList = Array.new();
|
54
|
+
hookList << @hooks;
|
55
|
+
if(@currentGame)
|
56
|
+
hookList << @currentGame.hookList
|
57
|
+
end
|
58
|
+
hookList.flatten
|
59
|
+
|
60
|
+
hookList.each do |h|
|
61
|
+
begin
|
62
|
+
veto |= !(h.process_raw_hit(hitPlayer, sourcePlayer));
|
63
|
+
rescue NoArbitration
|
64
|
+
arbitrateShot = false;
|
65
|
+
end
|
66
|
+
end
|
67
|
+
return if veto;
|
68
|
+
|
69
|
+
if arbitrateShot
|
70
|
+
sourcePlayer.hitIDTimetable[arbCode] = Time.now();
|
71
|
+
end
|
72
|
+
|
73
|
+
send_event(:playerHit, hitPlayer, sourcePlayer);
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
class NoArbitration < Exception
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,143 @@
|
|
1
|
+
|
2
|
+
module LZRTag
|
3
|
+
module Hook
|
4
|
+
=begin
|
5
|
+
Base class for all game hooks, implements DSL.
|
6
|
+
This class shall be used as a base class for any DIY game hooks.
|
7
|
+
The purpose of any hook is to implement a specific element of a game,
|
8
|
+
such as damaging players, regenerating them, handing out teams, etc.
|
9
|
+
|
10
|
+
This hook base class implements a DSL that makes it very easy for the application
|
11
|
+
to implement their own behavior in a modular fashion.
|
12
|
+
|
13
|
+
@example
|
14
|
+
class MyHook < LZRTag::Base::Hook
|
15
|
+
# Hooks can register which parameters are configurable, and how
|
16
|
+
# This will be used to reconfigure them on the fly, but it is not mandatory
|
17
|
+
# to register options.
|
18
|
+
describe_option :myValue, "A description of my Value";
|
19
|
+
|
20
|
+
def initialize(handler, **options)
|
21
|
+
super(handler);
|
22
|
+
|
23
|
+
@myValue = options[:myValue] || "default";
|
24
|
+
end
|
25
|
+
|
26
|
+
# The "on" DSL makes it easy to perform tasks on
|
27
|
+
# any arbitrary event
|
28
|
+
on :playerKilled do |player|
|
29
|
+
puts "Player #{player.name} was killed :C";
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
class MyGame < LZRTag::Game::Base
|
34
|
+
# A game can register that it wants to use this hook, and
|
35
|
+
# even which options to use for it.
|
36
|
+
hook :aHook, MyHook, {myValue: "Not a default!"};
|
37
|
+
end
|
38
|
+
|
39
|
+
# Alternatively, the hook can be added to the game directly
|
40
|
+
handler.add_hook(MyHook);
|
41
|
+
=end
|
42
|
+
class Base
|
43
|
+
def self.getCBs()
|
44
|
+
@globalCBList ||= Hash.new();
|
45
|
+
return @globalCBList;
|
46
|
+
end
|
47
|
+
def self.getOptionDescriptions()
|
48
|
+
@globalOptionDescriptions ||= Hash.new();
|
49
|
+
return @globalOptionDescriptions
|
50
|
+
end
|
51
|
+
|
52
|
+
def initialize(handler)
|
53
|
+
@localCBList = Hash.new();
|
54
|
+
|
55
|
+
@handler = handler
|
56
|
+
end
|
57
|
+
|
58
|
+
# DSL function to describe an option of this hook.
|
59
|
+
# The application can use this DSL to describe a given option,
|
60
|
+
# identified by optionSymbol. The extraDetails hash is optional,
|
61
|
+
# but in the future will allow this hook to be reconfigured remotely
|
62
|
+
# via MQTT!
|
63
|
+
# @param optionSymbol [Symbol] The Symbol used for this option
|
64
|
+
# @param descString [String] String description of this option.
|
65
|
+
# @param extraDetails [Hash] Optional hash to provide further details
|
66
|
+
# of this option, such as "min", "max", "type", etc.
|
67
|
+
def self.describe_option(optionSymbol, descString, extraDetails = {})
|
68
|
+
raise ArgumentError, "Option shall be a symbol!" unless optionSymbol.is_a? Symbol
|
69
|
+
raise ArgumentError, "Description should be a string!" unless descString.is_a? String
|
70
|
+
getOptionDescriptions()[optionSymbol] = extraDetails;
|
71
|
+
getOptionDescriptions()[optionSymbol][:desc] = descString;
|
72
|
+
end
|
73
|
+
|
74
|
+
# DSL function to add a block on an event.
|
75
|
+
# The application can provide a block to this function that will be executed
|
76
|
+
# for every "evtName" game event
|
77
|
+
# @param evtName [Symbol] Event name to trigger this block on
|
78
|
+
def self.on(evtName, &block)
|
79
|
+
raise ArgumentError, "Block needs to be given!" unless block_given?
|
80
|
+
|
81
|
+
evtName = [evtName].flatten
|
82
|
+
evtName.each do |evt|
|
83
|
+
unless (evt.is_a? Symbol)
|
84
|
+
raise ArgumentError, "Event needs to be a symbol or array of symbols!"
|
85
|
+
end
|
86
|
+
getCBs()[evt] ||= Array.new();
|
87
|
+
getCBs()[evt] << block;
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
# Function to add hooks to the already instantiated hook.
|
92
|
+
# This function works exactly like {self.on}, except that it
|
93
|
+
# acts on an instance of hook, and allows the app to extend a standard
|
94
|
+
# hook by extending it afterwards.
|
95
|
+
def on(evtName, &block)
|
96
|
+
raise ArgumentError, "Block needs to be given!" unless block_given?
|
97
|
+
|
98
|
+
evtName = [evtName].flatten
|
99
|
+
evtName.each do |evt|
|
100
|
+
unless (evt.is_a? Symbol)
|
101
|
+
raise ArgumentError, "Event needs to be a symbol or array of symbols!"
|
102
|
+
end
|
103
|
+
@localCBList[evt] ||= Array.new();
|
104
|
+
@localCBList[evt] << block;
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
# @private
|
109
|
+
def consume_event(evtName, data)
|
110
|
+
if(cbList = self.class.getCBs()[evtName])
|
111
|
+
cbList.each do |cb|
|
112
|
+
begin
|
113
|
+
instance_exec(*data, &cb);
|
114
|
+
rescue StandardError => e
|
115
|
+
puts e.message
|
116
|
+
puts e.backtrace.inspect
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
if(cbList = @localCBList[evtName]) then
|
121
|
+
cbList.each do |cb|
|
122
|
+
begin
|
123
|
+
cb.call(*data);
|
124
|
+
rescue StandardError => e
|
125
|
+
puts e.message
|
126
|
+
puts e.backtrace.inspect
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
def on_hookin(handler)
|
133
|
+
@handler = handler;
|
134
|
+
end
|
135
|
+
def on_hookout()
|
136
|
+
end
|
137
|
+
|
138
|
+
def process_raw_hit(*)
|
139
|
+
return true;
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
@@ -0,0 +1,174 @@
|
|
1
|
+
|
2
|
+
require_relative 'base_hook.rb'
|
3
|
+
|
4
|
+
module LZRTag
|
5
|
+
module Hook
|
6
|
+
class Debug < Base
|
7
|
+
attr_accessor :eventWhitelist
|
8
|
+
attr_accessor :eventBlacklist
|
9
|
+
|
10
|
+
def initialize()
|
11
|
+
super();
|
12
|
+
|
13
|
+
@eventWhitelist = Array.new();
|
14
|
+
@eventBlacklist = Array.new();
|
15
|
+
end
|
16
|
+
|
17
|
+
def consume_event(evtName, data)
|
18
|
+
super(evtName, data);
|
19
|
+
|
20
|
+
return if @eventBlacklist.include? evtName
|
21
|
+
unless(@eventWhitelist.empty?)
|
22
|
+
return unless @eventWhitelist.include? evtName
|
23
|
+
end
|
24
|
+
|
25
|
+
puts "Caught event: #{evtName} with data: #{data}";
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
class TeamSelector < Base
|
30
|
+
describe_option :possibleTeams, "List of teams that can be selected", {
|
31
|
+
type: Array
|
32
|
+
}
|
33
|
+
|
34
|
+
def initialize(handler, possibleTeams: [1, 2, 3, 4])
|
35
|
+
super(handler);
|
36
|
+
|
37
|
+
@possibleTeams = possibleTeams;
|
38
|
+
end
|
39
|
+
|
40
|
+
on :gamePhaseEnds do |oldPhase, nextPhase|
|
41
|
+
if((oldPhase == :teamSelect) && (nextPhase != :idle))
|
42
|
+
puts "Selecting active players!"
|
43
|
+
|
44
|
+
@handler.gamePlayers = Array.new();
|
45
|
+
@handler.each do |pl|
|
46
|
+
if(pl.brightness == :active)
|
47
|
+
@handler.gamePlayers << pl;
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
puts "Game players are: #{@handler.gamePlayers}"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
on :gamePhaseStarts do |nextPhase, oldPhase|
|
56
|
+
case(nextPhase)
|
57
|
+
when :teamSelect
|
58
|
+
@handler.each do |pl|
|
59
|
+
pl.brightness = :idle;
|
60
|
+
|
61
|
+
if(!@possibleTeams.include?(pl.team))
|
62
|
+
pl.team = @possibleTeams.sample();
|
63
|
+
end
|
64
|
+
end
|
65
|
+
when :idle
|
66
|
+
@handler.each do |pl|
|
67
|
+
pl.brightness = :idle;
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
on :poseChanged do |pl, nPose|
|
73
|
+
next if(@handler.gamePhase != :teamSelect)
|
74
|
+
next if(pl.brightness == :active)
|
75
|
+
|
76
|
+
pl.brightness = (pl.gyroPose == :laidDown) ? :idle : :teamSelect;
|
77
|
+
end
|
78
|
+
|
79
|
+
on :navSwitchPressed do |player, dir|
|
80
|
+
next if(@handler.gamePhase != :teamSelect)
|
81
|
+
next unless [:teamSelect, :idle].include? player.brightness
|
82
|
+
|
83
|
+
newTeam = @possibleTeams.find_index(player.team) || 0;
|
84
|
+
|
85
|
+
newTeam += 1 if(dir == 2)
|
86
|
+
newTeam -= 1 if(dir == 3)
|
87
|
+
|
88
|
+
player.team = @possibleTeams[newTeam % @possibleTeams.length]
|
89
|
+
if(dir == 1)
|
90
|
+
player.brightness = :active
|
91
|
+
else
|
92
|
+
player.brightness = :teamSelect
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
class Regenerator < Base
|
98
|
+
|
99
|
+
describe_option :regRate, "Regeneraton rate, HP per second"
|
100
|
+
describe_option :regDelay, "Healing delay, in s, after a player was hit"
|
101
|
+
describe_option :healDead, "Whether or not to heal dead players"
|
102
|
+
|
103
|
+
describe_option :autoReviveThreshold, "The HP a player needs before he is revived"
|
104
|
+
|
105
|
+
describe_option :teamFilter, "Which teams this regenerator belongs to"
|
106
|
+
describe_option :phaseFilter, "During which phases this hook should be active"
|
107
|
+
|
108
|
+
def initialize(handler, **options)
|
109
|
+
super(handler);
|
110
|
+
|
111
|
+
@regRate = options[:regRate] || 1;
|
112
|
+
@regDelay = options[:regDelay] || 10;
|
113
|
+
|
114
|
+
@healDead = options[:healDead] || false;
|
115
|
+
@autoReviveThreshold = options[:autoReviveThreshold] || 30;
|
116
|
+
|
117
|
+
@teamFilter = options[:teamFilter] || (0..7).to_a
|
118
|
+
@phaseFilter = options[:phaseFilter] || [:running]
|
119
|
+
end
|
120
|
+
|
121
|
+
on :gameTick do |dT|
|
122
|
+
next unless @phaseFilter.include? @handler.gamePhase
|
123
|
+
|
124
|
+
@handler.each_participating do |pl|
|
125
|
+
next unless @teamFilter.include? pl.team
|
126
|
+
|
127
|
+
if((Time.now() - pl.lastDamageTime) >= @regDelay)
|
128
|
+
pl.regenerate(dT * @regRate);
|
129
|
+
end
|
130
|
+
|
131
|
+
if(pl.dead and pl.life >= @autoReviveThreshold)
|
132
|
+
pl.dead = false;
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
class Damager < Base
|
139
|
+
describe_option :dmgPerShot, "Base damage per shot"
|
140
|
+
describe_option :useDamageMultiplier, "Shall shots be adjusted per-gun?"
|
141
|
+
describe_option :friendlyFire, "Shall friendly-fire be enabled"
|
142
|
+
describe_option :hitThreshold, "Limit below dead players will not be hit"
|
143
|
+
|
144
|
+
def initialize(handler, **options)
|
145
|
+
super(handler);
|
146
|
+
|
147
|
+
@dmgPerShot = options[:dmgPerShot] || 40;
|
148
|
+
@useDamageMultiplier = options[:useDamageMultiplier] || true;
|
149
|
+
@friendlyFire = options[:friendlyFire] || false;
|
150
|
+
@hitThreshold = options[:hitThreshold] || 10;
|
151
|
+
end
|
152
|
+
|
153
|
+
def process_raw_hit(hitPlayer, sourcePlayer)
|
154
|
+
unless(@friendlyFire)
|
155
|
+
return false if hitPlayer.team == sourcePlayer.team
|
156
|
+
end
|
157
|
+
return false if(hitPlayer.dead && (hitPlayer.life < @hitThreshold));
|
158
|
+
|
159
|
+
return true;
|
160
|
+
end
|
161
|
+
|
162
|
+
on :playerHit do |hitPlayer, sourcePlayer|
|
163
|
+
shotMultiplier = 1;
|
164
|
+
|
165
|
+
if((@useDamageMultiplier) && (!sourcePlayer.nil?))
|
166
|
+
shotMultiplier = sourcePlayer.gunDamage();
|
167
|
+
end
|
168
|
+
|
169
|
+
hitPlayer.damage_by(@dmgPerShot * shotMultiplier, sourcePlayer);
|
170
|
+
hitPlayer.hit();
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
|
2
|
+
require_relative 'map_zone.rb'
|
3
|
+
require_relative 'myMaps_parser.rb'
|
4
|
+
|
5
|
+
module LZRTag
|
6
|
+
module Map
|
7
|
+
class Set
|
8
|
+
attr_reader :zones
|
9
|
+
|
10
|
+
attr_accessor :centerpoint
|
11
|
+
|
12
|
+
def initialize(mqtt, zones = Array.new())
|
13
|
+
@mqtt = mqtt;
|
14
|
+
@zones = zones;
|
15
|
+
|
16
|
+
@centerpoint = Array.new();
|
17
|
+
end
|
18
|
+
|
19
|
+
def to_h()
|
20
|
+
outData = Hash.new();
|
21
|
+
|
22
|
+
if(@centerpoint.length != 3)
|
23
|
+
raise ArgumentError, "Center point needs to be set!"
|
24
|
+
end
|
25
|
+
|
26
|
+
outData[:centerpoint] = @centerpoint
|
27
|
+
|
28
|
+
outData[:zones] = Array.new();
|
29
|
+
@zones.each do |z|
|
30
|
+
outData[:zones] << z.to_h;
|
31
|
+
end
|
32
|
+
|
33
|
+
return outData;
|
34
|
+
end
|
35
|
+
|
36
|
+
def to_json()
|
37
|
+
return self.to_h().to_json();
|
38
|
+
end
|
39
|
+
|
40
|
+
def publish()
|
41
|
+
@mqtt.publish_to "Lasertag/Zones", self.to_json, qos: 1, retain: true;
|
42
|
+
end
|
43
|
+
def clear()
|
44
|
+
@mqtt.publish_to "Lasertag/Game/Zones", "", retain: true;
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
|
2
|
+
module LZRTag
|
3
|
+
module Map
|
4
|
+
class Zone
|
5
|
+
attr_accessor :tag
|
6
|
+
|
7
|
+
attr_accessor :centerPoint, :radius
|
8
|
+
attr_accessor :polygon
|
9
|
+
attr_accessor :coordinatesAsGPS
|
10
|
+
|
11
|
+
attr_accessor :teamMask
|
12
|
+
|
13
|
+
attr_accessor :data
|
14
|
+
|
15
|
+
attr_accessor :style
|
16
|
+
|
17
|
+
def initialize()
|
18
|
+
@centerPoint = [0, 0];
|
19
|
+
@radius = 0;
|
20
|
+
@polygon = Array.new();
|
21
|
+
@coordinatesAsGPS = false;
|
22
|
+
|
23
|
+
@teamMask = 255;
|
24
|
+
|
25
|
+
@style = {
|
26
|
+
color: "transparent",
|
27
|
+
borderColor: "black"
|
28
|
+
}
|
29
|
+
|
30
|
+
@data = Hash.new();
|
31
|
+
end
|
32
|
+
|
33
|
+
def affects_teams(teamList)
|
34
|
+
@teamMask = 0;
|
35
|
+
[teamList].flatten.each do |t|
|
36
|
+
@teamMask += (2<<t);
|
37
|
+
end
|
38
|
+
end
|
39
|
+
def ignores_teams(teamList)
|
40
|
+
@teamMask = 255;
|
41
|
+
[teamList].flatten.each do |t|
|
42
|
+
@teamMask -= (2<<t);
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def self.from_raw_zone(rawZone)
|
47
|
+
outZone = Zone.new();
|
48
|
+
|
49
|
+
outZone.tag = rawZone[:arguments]["tag"] || rawZone[:name];
|
50
|
+
|
51
|
+
outZone.polygon = rawZone[:polygon];
|
52
|
+
outZone.coordinatesAsGPS = true;
|
53
|
+
|
54
|
+
if(rawZone[:style])
|
55
|
+
outZone.style = rawZone[:style];
|
56
|
+
end
|
57
|
+
|
58
|
+
if(tMask = rawZone[:arguments]["teamMask"])
|
59
|
+
outZone.teamMask = tMask.to_i;
|
60
|
+
rawZone[:arguments].delete "teamMask"
|
61
|
+
end
|
62
|
+
|
63
|
+
outZone.data = rawZone[:arguments];
|
64
|
+
|
65
|
+
return outZone
|
66
|
+
end
|
67
|
+
|
68
|
+
def to_h()
|
69
|
+
outHash = Hash.new();
|
70
|
+
|
71
|
+
raise ArgumentError, "Tag needs to be set!" if(@tag.nil?);
|
72
|
+
outHash[:tag] = @tag;
|
73
|
+
outHash[:teamMask] = @teamMask;
|
74
|
+
|
75
|
+
if(@radius > 0.1)
|
76
|
+
outHash[:centerPoint] = @centerPoint;
|
77
|
+
outHash[:radius] = @radius;
|
78
|
+
else
|
79
|
+
outHash[:polygon] = @polygon;
|
80
|
+
end
|
81
|
+
outHash[:coordinatesAsGPS] = @coordinatesAsGPS
|
82
|
+
|
83
|
+
outHash[:style] = @style;
|
84
|
+
|
85
|
+
outHash[:data] = @data;
|
86
|
+
|
87
|
+
return outHash;
|
88
|
+
end
|
89
|
+
|
90
|
+
def inspect()
|
91
|
+
return "#<Zone: #{to_h}>";
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|