live_f1-core 0.0.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.
- data/.autotest +23 -0
- data/.gitignore +6 -0
- data/.rspec +1 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +65 -0
- data/Guardfile +11 -0
- data/README.rdoc +62 -0
- data/Rakefile +11 -0
- data/bin/live_f1_example +74 -0
- data/features/fixtures/sessions/2012.03.china.qualifying/7136/keyframes.yaml +32722 -0
- data/features/fixtures/sessions/2012.03.china.qualifying/7136/session.key +1 -0
- data/features/fixtures/sessions/2012.03.china.qualifying/session.bin +0 -0
- data/features/fixtures/sessions/2012.04.bahrain.practice.2/7164/keyframes.yaml +22403 -0
- data/features/fixtures/sessions/2012.04.bahrain.practice.2/7164/session.key +1 -0
- data/features/fixtures/sessions/2012.04.bahrain.practice.2/session.bin +0 -0
- data/features/fixtures/sessions/2012.05.bahrain.race.post/7167/keyframes.yaml +1204 -0
- data/features/fixtures/sessions/2012.05.bahrain.race.post/7167/session.key +1 -0
- data/features/fixtures/sessions/2012.05.bahrain.race.post/session.bin +0 -0
- data/features/live_f1.feature +14 -0
- data/features/step_definitions/live_f1_steps.rb +69 -0
- data/features/support/env.rb +9 -0
- data/lib/live_f1/debug.rb +6 -0
- data/lib/live_f1/enum.rb +9 -0
- data/lib/live_f1/event.rb +23 -0
- data/lib/live_f1/packet/car/best_lap_time.rb +10 -0
- data/lib/live_f1/packet/car/driver.rb +14 -0
- data/lib/live_f1/packet/car/gap.rb +10 -0
- data/lib/live_f1/packet/car/interval.rb +10 -0
- data/lib/live_f1/packet/car/lap_count.rb +10 -0
- data/lib/live_f1/packet/car/lap_time.rb +10 -0
- data/lib/live_f1/packet/car/num_pits.rb +10 -0
- data/lib/live_f1/packet/car/number.rb +14 -0
- data/lib/live_f1/packet/car/period_1.rb +11 -0
- data/lib/live_f1/packet/car/period_2.rb +11 -0
- data/lib/live_f1/packet/car/period_3.rb +11 -0
- data/lib/live_f1/packet/car/pit_count.rb +18 -0
- data/lib/live_f1/packet/car/pit_lap_1.rb +10 -0
- data/lib/live_f1/packet/car/pit_lap_2.rb +10 -0
- data/lib/live_f1/packet/car/pit_lap_3.rb +10 -0
- data/lib/live_f1/packet/car/position.rb +14 -0
- data/lib/live_f1/packet/car/position_history.rb +14 -0
- data/lib/live_f1/packet/car/position_update.rb +13 -0
- data/lib/live_f1/packet/car/sector_1.rb +11 -0
- data/lib/live_f1/packet/car/sector_2.rb +11 -0
- data/lib/live_f1/packet/car/sector_3.rb +11 -0
- data/lib/live_f1/packet/car.rb +13 -0
- data/lib/live_f1/packet/decryptable.rb +22 -0
- data/lib/live_f1/packet/header.rb +127 -0
- data/lib/live_f1/packet/sector_time.rb +28 -0
- data/lib/live_f1/packet/sys/commentary.rb +33 -0
- data/lib/live_f1/packet/sys/copyright.rb +9 -0
- data/lib/live_f1/packet/sys/key_frame.rb +17 -0
- data/lib/live_f1/packet/sys/notice.rb +10 -0
- data/lib/live_f1/packet/sys/reset.rb +9 -0
- data/lib/live_f1/packet/sys/session_start.rb +25 -0
- data/lib/live_f1/packet/sys/speed.rb +29 -0
- data/lib/live_f1/packet/sys/timestamp.rb +23 -0
- data/lib/live_f1/packet/sys/track_status.rb +18 -0
- data/lib/live_f1/packet/sys/weather.rb +33 -0
- data/lib/live_f1/packet/sys.rb +10 -0
- data/lib/live_f1/packet.rb +129 -0
- data/lib/live_f1/source/keyframe.rb +30 -0
- data/lib/live_f1/source/live.rb +163 -0
- data/lib/live_f1/source/log.rb +29 -0
- data/lib/live_f1/source/session.rb +41 -0
- data/lib/live_f1/source.rb +83 -0
- data/lib/live_f1/version.rb +3 -0
- data/lib/live_f1.rb +32 -0
- data/live_f1-core.gemspec +26 -0
- data/spec/live_f1/event_spec.rb +5 -0
- data/spec/live_f1/packet/car/best_lap_time_spec.rb +19 -0
- data/spec/live_f1/packet/car/driver_spec.rb +20 -0
- data/spec/live_f1/packet/car/gap_spec.rb +20 -0
- data/spec/live_f1/packet/car/interval_spec.rb +20 -0
- data/spec/live_f1/packet/car/lap_count_spec.rb +20 -0
- data/spec/live_f1/packet/car/lap_time_spec.rb +20 -0
- data/spec/live_f1/packet/car/number_spec.rb +20 -0
- data/spec/live_f1/packet/car/period_1_spec.rb +21 -0
- data/spec/live_f1/packet/car/period_2_spec.rb +21 -0
- data/spec/live_f1/packet/car/period_3_spec.rb +21 -0
- data/spec/live_f1/packet/car/pit_count_spec.rb +20 -0
- data/spec/live_f1/packet/car/pit_lap_1_spec.rb +20 -0
- data/spec/live_f1/packet/car/pit_lap_2_spec.rb +20 -0
- data/spec/live_f1/packet/car/pit_lap_3_spec.rb +20 -0
- data/spec/live_f1/packet/car/position_history_spec.rb +20 -0
- data/spec/live_f1/packet/car/position_spec.rb +20 -0
- data/spec/live_f1/packet/car/position_update_spec.rb +19 -0
- data/spec/live_f1/packet/car/sector_1_spec.rb +21 -0
- data/spec/live_f1/packet/car/sector_2_spec.rb +21 -0
- data/spec/live_f1/packet/car/sector_3_spec.rb +21 -0
- data/spec/live_f1/packet/header_spec.rb +148 -0
- data/spec/live_f1/packet/sys/commentary_spec.rb +45 -0
- data/spec/live_f1/packet/sys/copyright_spec.rb +19 -0
- data/spec/live_f1/packet/sys/key_frame_spec.rb +39 -0
- data/spec/live_f1/packet/sys/notice_spec.rb +20 -0
- data/spec/live_f1/packet/sys/reset_spec.rb +26 -0
- data/spec/live_f1/packet/sys/session_start_spec.rb +31 -0
- data/spec/live_f1/packet/sys/speed_spec.rb +20 -0
- data/spec/live_f1/packet/sys/timestamp_spec.rb +34 -0
- data/spec/live_f1/packet/sys/track_status_spec.rb +20 -0
- data/spec/live_f1/packet/sys/weather_spec.rb +20 -0
- data/spec/live_f1/packet_spec.rb +112 -0
- data/spec/live_f1/source/keyframe_spec.rb +56 -0
- data/spec/live_f1/source/live_spec.rb +106 -0
- data/spec/live_f1/source/session_spec.rb +39 -0
- data/spec/live_f1/source_spec.rb +140 -0
- data/spec/spec_helper.rb +10 -0
- data/spec/support/packet_type_examples.rb +122 -0
- metadata +337 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
AF661706
|
|
Binary file
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Feature: Live timing connection
|
|
2
|
+
In order to follow a Formula 1 race live
|
|
3
|
+
As a user of the LiveF1 library
|
|
4
|
+
I want to receive packets of data
|
|
5
|
+
|
|
6
|
+
Scenario: Connecting before the session has started
|
|
7
|
+
Given the live timing session is about to start
|
|
8
|
+
When I successfully connect to the live timing service
|
|
9
|
+
Then I should receive packets of data
|
|
10
|
+
|
|
11
|
+
Scenario: Connecting after the session has completed
|
|
12
|
+
Given the live timing session has been completed
|
|
13
|
+
When I successfully connect to the live timing service
|
|
14
|
+
Then I should receive packets of data
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
$:.unshift(File.dirname(__FILE__) + '/../../lib')
|
|
2
|
+
require 'live_f1'
|
|
3
|
+
require 'cucumber/rspec/doubles'
|
|
4
|
+
|
|
5
|
+
Given /^the live timing session is about to start$/ do
|
|
6
|
+
# fixture_session '2012.04.bahrain.practice.2'
|
|
7
|
+
fixture_session '2012.03.china.qualifying'
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
Given /^the live timing session has been completed$/ do
|
|
11
|
+
fixture_session '2012.05.bahrain.race.post'
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
When /^I successfully connect to the live timing service$/ do
|
|
15
|
+
@stream = LiveF1::Source::Live.new 'gareth@example.com', 'swordfish'
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
Then /^I should receive packets of data$/ do
|
|
19
|
+
packets = []
|
|
20
|
+
@stream.run do |packet|
|
|
21
|
+
packets << packet
|
|
22
|
+
end
|
|
23
|
+
packets.should include_a(LiveF1::Packet::Sys::SessionStart)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def fixture_session name
|
|
27
|
+
fixture_base = File.expand_path(File.join(File.dirname(__FILE__),'../fixtures/sessions',name))
|
|
28
|
+
|
|
29
|
+
stream_filename = File.join(fixture_base,'session.bin')
|
|
30
|
+
# Stubbing a Socket with a File may have consequences
|
|
31
|
+
TCPSocket.stub(:open) { File.open(stream_filename) }
|
|
32
|
+
|
|
33
|
+
sessions = Dir[File.join(fixture_base,'*')].select { |f| File.directory? f }
|
|
34
|
+
sessions.each do |session|
|
|
35
|
+
keyframe_data = YAML.load(File.read(File.join(session, "keyframes.yaml")))
|
|
36
|
+
keyframe_data.each do |filename, data|
|
|
37
|
+
url = "http://80.231.178.249/#{filename}"
|
|
38
|
+
FakeWeb.register_uri(:get, url, :body => data)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
session_number = File.basename(session)
|
|
42
|
+
keyfile = File.join(session,'session.key')
|
|
43
|
+
FakeWeb.register_uri(:post, 'http://80.231.178.249/reg/login', :set_cookie => "USER=abc123def")
|
|
44
|
+
FakeWeb.register_uri(:get, "http://80.231.178.249/reg/getkey/#{session_number}.asp?auth=abc123def", :body => keyfile)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# def live_timing_session &block
|
|
49
|
+
# receiver = Object.new
|
|
50
|
+
# class << receiver
|
|
51
|
+
# attr_writer :packets
|
|
52
|
+
#
|
|
53
|
+
# def packets
|
|
54
|
+
# @packets ||= []
|
|
55
|
+
# end
|
|
56
|
+
#
|
|
57
|
+
# def method_missing m, *args
|
|
58
|
+
# self.packets += MockPacket.send(m, *args)
|
|
59
|
+
# end
|
|
60
|
+
# end
|
|
61
|
+
#
|
|
62
|
+
# yield receiver
|
|
63
|
+
#
|
|
64
|
+
# receiver.packets << nil
|
|
65
|
+
#
|
|
66
|
+
# socket = mock("socket")
|
|
67
|
+
# socket.stub(:read) { receiver.packets.shift }
|
|
68
|
+
# TCPSocket.stub(:open) { socket }
|
|
69
|
+
# end
|
data/lib/live_f1/enum.rb
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
module LiveF1
|
|
2
|
+
# Adds a convenience method for converting between constant values and names
|
|
3
|
+
module Enum
|
|
4
|
+
# Returns the first constant (in definition order) matching the given value
|
|
5
|
+
def name_for value
|
|
6
|
+
constants.detect { |c| const_get(c) == value }
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
require 'live_f1/enum'
|
|
2
|
+
|
|
3
|
+
module LiveF1
|
|
4
|
+
# An Event represents a usable metric relating to a car or live session.
|
|
5
|
+
class Event
|
|
6
|
+
module Type
|
|
7
|
+
extend LiveF1::Enum
|
|
8
|
+
RACE = 1
|
|
9
|
+
PRACTICE = 2
|
|
10
|
+
QUALIFYING = 3
|
|
11
|
+
end
|
|
12
|
+
include Type
|
|
13
|
+
|
|
14
|
+
module TrackStatus
|
|
15
|
+
extend LiveF1::Enum
|
|
16
|
+
GREEN_FLAG = 1
|
|
17
|
+
YELLOW_FLAG = 2
|
|
18
|
+
SAFETY_CAR_STANDBY = 3
|
|
19
|
+
SAFETY_CAR_DEPLOYED = 4
|
|
20
|
+
RED_FLAG = 5
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module LiveF1
|
|
2
|
+
class Packet
|
|
3
|
+
# Packets which mixin the Decryptable module represent data that is encrypted
|
|
4
|
+
# in the data stream.
|
|
5
|
+
#
|
|
6
|
+
# When setting the packet data we transparently decrypt the data, and also
|
|
7
|
+
# set a raw_data containing the original, encrypted bytes in case they are
|
|
8
|
+
# useful
|
|
9
|
+
module Decryptable
|
|
10
|
+
attr_reader :raw_data
|
|
11
|
+
|
|
12
|
+
def data= bytes
|
|
13
|
+
@raw_data = bytes
|
|
14
|
+
@data = source.decrypt(bytes)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def bytes
|
|
18
|
+
@raw_data
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
require_relative '../packet'
|
|
2
|
+
require_relative '../event'
|
|
3
|
+
|
|
4
|
+
module LiveF1
|
|
5
|
+
class Packet
|
|
6
|
+
# The Unknown packet is a special placeholder packet for situations where a
|
|
7
|
+
# zero-length packet is delivered but seems to have no effect on the stream
|
|
8
|
+
class Unknown < Packet
|
|
9
|
+
include Packet::Type::Special
|
|
10
|
+
|
|
11
|
+
def to_s
|
|
12
|
+
"Unknown packet type #{header.packet_type}" + (header.car > 0 ? " for car #{header.car}" : "")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# A Header uses 2 bytes of data from a live timing stream to determine all
|
|
17
|
+
# the necessary information about the packet which follows it.
|
|
18
|
+
class Header < Struct.new(:data, :packet_type, :car, :event_type)
|
|
19
|
+
attr_reader :bytes
|
|
20
|
+
|
|
21
|
+
def self.from_source source, event_type
|
|
22
|
+
bytes = source.read_bytes(2)
|
|
23
|
+
raise "No data from #{source.inspect}" unless bytes.to_s.length == 2
|
|
24
|
+
bits = bytes.to_s.reverse.unpack("B*").first
|
|
25
|
+
_, data, packet_type, car = bits.match(/^(.{7})(.{4})(.{5})$/).to_a.map { |s| s.to_i(2) }
|
|
26
|
+
|
|
27
|
+
new(data, packet_type, car, event_type).tap do |header|
|
|
28
|
+
# TODO: Maybe need a nicer way of setting this
|
|
29
|
+
header.instance_variable_set "@bytes", bytes
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def car?
|
|
34
|
+
!car.zero?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def packet_klass
|
|
38
|
+
case
|
|
39
|
+
when car?
|
|
40
|
+
case event_type
|
|
41
|
+
when Event::RACE
|
|
42
|
+
case packet_type
|
|
43
|
+
when 0 then Packet::Car::PositionUpdate
|
|
44
|
+
when 1 then Packet::Car::Position
|
|
45
|
+
when 2 then Packet::Car::Number
|
|
46
|
+
when 3 then Packet::Car::Driver
|
|
47
|
+
when 4 then Packet::Car::Gap
|
|
48
|
+
when 5 then Packet::Car::Interval
|
|
49
|
+
when 6 then Packet::Car::LapTime
|
|
50
|
+
when 7 then Packet::Car::Sector1
|
|
51
|
+
when 8 then Packet::Car::PitLap1
|
|
52
|
+
when 9 then Packet::Car::Sector2
|
|
53
|
+
when 10 then Packet::Car::PitLap2
|
|
54
|
+
when 11 then Packet::Car::Sector3
|
|
55
|
+
when 12 then Packet::Car::PitLap3
|
|
56
|
+
when 13 then Packet::Car::NumPits
|
|
57
|
+
when 14 then nil
|
|
58
|
+
when 15 then Packet::Car::PositionHistory
|
|
59
|
+
end
|
|
60
|
+
when Event::PRACTICE
|
|
61
|
+
case packet_type
|
|
62
|
+
when 0 then Packet::Car::PositionUpdate
|
|
63
|
+
when 1 then Packet::Car::Position
|
|
64
|
+
when 2 then Packet::Car::Number
|
|
65
|
+
when 3 then Packet::Car::Driver
|
|
66
|
+
when 4 then Packet::Car::BestLapTime
|
|
67
|
+
when 5 then Packet::Car::Gap
|
|
68
|
+
when 6 then Packet::Car::Sector1
|
|
69
|
+
when 7 then Packet::Car::Sector2
|
|
70
|
+
when 8 then Packet::Car::Sector3
|
|
71
|
+
when 9 then Packet::Car::LapCount
|
|
72
|
+
when 10 then Packet::Car::LapCount
|
|
73
|
+
when 15 then nil
|
|
74
|
+
end
|
|
75
|
+
when Event::QUALIFYING
|
|
76
|
+
case packet_type
|
|
77
|
+
when 0 then Packet::Car::PositionUpdate
|
|
78
|
+
when 1 then Packet::Car::Position
|
|
79
|
+
when 2 then Packet::Car::Number
|
|
80
|
+
when 3 then Packet::Car::Driver
|
|
81
|
+
when 4 then Packet::Car::Period1
|
|
82
|
+
when 5 then Packet::Car::Period2
|
|
83
|
+
when 6 then Packet::Car::Period3
|
|
84
|
+
when 7 then Packet::Car::Sector1
|
|
85
|
+
when 8 then Packet::Car::Sector2
|
|
86
|
+
when 9 then Packet::Car::Sector3
|
|
87
|
+
when 10 then Packet::Car::LapCount
|
|
88
|
+
when 15 then nil
|
|
89
|
+
end
|
|
90
|
+
else
|
|
91
|
+
raise MissingEventType, "Unrecognised event type (#{event_type.inspect}), can't determine class for car packet #{packet_type.inspect}"
|
|
92
|
+
end
|
|
93
|
+
else
|
|
94
|
+
case packet_type
|
|
95
|
+
when 0 then Packet::Unknown
|
|
96
|
+
when 1 then Packet::Sys::SessionStart
|
|
97
|
+
when 2 then Packet::Sys::KeyFrame
|
|
98
|
+
when 3 then Packet::Unknown
|
|
99
|
+
when 4 then Packet::Sys::Commentary
|
|
100
|
+
when 5 then Packet::Unknown
|
|
101
|
+
when 6 then Packet::Sys::Notice
|
|
102
|
+
when 7 then Packet::Sys::Timestamp
|
|
103
|
+
when 8 then nil # Packet::Unknown
|
|
104
|
+
when 9 then Packet::Sys::Weather
|
|
105
|
+
when 10 then Packet::Sys::Speed
|
|
106
|
+
when 11 then Packet::Sys::TrackStatus
|
|
107
|
+
when 12 then Packet::Sys::Copyright
|
|
108
|
+
when 13 then nil # Packet::Unknown
|
|
109
|
+
when 14 then nil # Packet::Unknown
|
|
110
|
+
when 15 then nil # Packet::Unknown
|
|
111
|
+
end
|
|
112
|
+
end or raise UnexpectedPacket, "Unexpected #{car? ? 'car' : 'sys'} packet type #{packet_type.inspect} for #{Event::Type.name_for event_type} event"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
class MissingEventType < RuntimeError
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# An unexpected packet is one that we don't expect to appear in the data stream
|
|
119
|
+
class UnexpectedPacket < RuntimeError
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# An unknown packet is one that we expect (from experience) to appear in the data stream but don't know its purpose
|
|
123
|
+
class UnknownPacket < RuntimeError
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module LiveF1
|
|
2
|
+
class Packet
|
|
3
|
+
module SectorTime
|
|
4
|
+
|
|
5
|
+
# Note of which bits appear in packet headers to represent different
|
|
6
|
+
# coloured sectors
|
|
7
|
+
# TODO: Use these colours somewhere
|
|
8
|
+
COLORS = {
|
|
9
|
+
0b010 => :red,
|
|
10
|
+
0b110 => :yellow,
|
|
11
|
+
0b001 => :white,
|
|
12
|
+
0b100 => :purple,
|
|
13
|
+
0b011 => :green
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
def seconds
|
|
17
|
+
if match = data.match(/^(?:(\d+):)?(\d+.\d+)$/)
|
|
18
|
+
_, minutes, seconds = match.to_a
|
|
19
|
+
(Rational(minutes.to_i * 60) + Rational(seconds)).to_f
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_s
|
|
24
|
+
seconds || data
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# encoding: utf-8
|
|
2
|
+
|
|
3
|
+
module LiveF1
|
|
4
|
+
class Packet
|
|
5
|
+
class Sys
|
|
6
|
+
class Commentary < Sys
|
|
7
|
+
include Packet::Type::Long
|
|
8
|
+
include Packet::Decryptable
|
|
9
|
+
|
|
10
|
+
# Is this the last line of this commentary string?
|
|
11
|
+
#
|
|
12
|
+
# If not, the next packet should also be a Commentary packet continuing this text
|
|
13
|
+
def terminal?
|
|
14
|
+
data.bytes.to_a[1] == 1
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Returns the line of commentary, which may only be a partial line if
|
|
18
|
+
# this commentary was split over multiple packets
|
|
19
|
+
def line
|
|
20
|
+
# The commentary packet encoding used to be all messed up. Its UTF-8
|
|
21
|
+
# characters were treated as Windows-1252 and then reconverted back
|
|
22
|
+
# to UTF-8. We used to fix that but now the issue has been
|
|
23
|
+
# corrected.
|
|
24
|
+
data[2..-1].force_encoding("UTF-8")#.encode("Windows-1252").force_encoding("UTF-8")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_s
|
|
28
|
+
"%s%s" % [line, (terminal? ? "" : "…")]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|