traveller_rpg 0.0.1.1 → 0.1.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.
- checksums.yaml +4 -4
- data/README.md +417 -167
- data/Rakefile +286 -0
- data/VERSION +1 -1
- data/bin/chargen +17 -3
- data/lib/traveller_rpg.rb +31 -169
- data/lib/traveller_rpg/career.rb +242 -201
- data/lib/traveller_rpg/career_path.rb +92 -85
- data/lib/traveller_rpg/careers.rb +256 -544
- data/lib/traveller_rpg/character.rb +114 -123
- data/lib/traveller_rpg/generator.rb +1 -1
- data/lib/traveller_rpg/homeworld.rb +54 -35
- metadata +2 -2
@@ -1,39 +1,45 @@
|
|
1
1
|
require 'traveller_rpg'
|
2
|
+
require 'traveller_rpg/character'
|
2
3
|
require 'traveller_rpg/careers'
|
4
|
+
require 'traveller_rpg/skill'
|
3
5
|
|
4
6
|
module TravellerRPG
|
5
|
-
autoload :Generator, 'traveller_rpg/generator'
|
6
|
-
|
7
7
|
class CareerPath
|
8
|
-
class
|
9
|
-
class Ineligible < Error; end
|
8
|
+
class Ineligible < RuntimeError; end
|
10
9
|
|
11
10
|
DRAFT_CAREERS = {
|
12
|
-
1 => ['Navy'
|
13
|
-
2 => ['Army'
|
14
|
-
3 => ['Marines'
|
15
|
-
|
16
|
-
|
17
|
-
|
11
|
+
1 => ['Navy'],
|
12
|
+
2 => ['Army'],
|
13
|
+
3 => ['Marines'],
|
14
|
+
# TODO: define Merchant career
|
15
|
+
# 4 => ['Merchant', 'Merchant Marine'],
|
16
|
+
4 => ['Marines'],
|
17
|
+
5 => ['Scout'],
|
18
|
+
6 => ['Agent', 'Law Enforcement'],
|
18
19
|
}
|
19
20
|
|
20
21
|
def self.career_class(str)
|
21
22
|
TravellerRPG.const_get(str.split('::').last)
|
22
23
|
end
|
23
24
|
|
24
|
-
def self.
|
25
|
-
|
26
|
-
|
25
|
+
def self.draft_career?(str)
|
26
|
+
DRAFT_CAREERS.values.map(&:first).include? str
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.run(careers, character:)
|
30
|
+
puts "\n", character.report(stuff: false, credits: false)
|
27
31
|
path = self.new(character)
|
28
32
|
loop {
|
29
|
-
careers = path.eligible
|
33
|
+
careers = careers.select { |c| path.eligible? c }
|
30
34
|
break if careers.empty?
|
31
|
-
|
32
|
-
career = self.career_class(choice).new(character)
|
35
|
+
career = TravellerRPG.choose("Choose a career:", *careers)
|
33
36
|
path.run(career)
|
34
37
|
puts "\n", path.report, "\n"
|
38
|
+
break if character.age > 45
|
35
39
|
break if TravellerRPG.choose("Exit career mode?", :yes, :no) == :yes
|
36
40
|
}
|
41
|
+
puts
|
42
|
+
puts character.log.join("\n")
|
37
43
|
path
|
38
44
|
end
|
39
45
|
|
@@ -45,97 +51,98 @@ module TravellerRPG
|
|
45
51
|
@careers = []
|
46
52
|
end
|
47
53
|
|
48
|
-
def
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
54
|
+
def career(career_name)
|
55
|
+
self.class.career_class(career_name).new(@char)
|
56
|
+
end
|
57
|
+
|
58
|
+
def eligible?(career)
|
59
|
+
case career
|
60
|
+
when Career
|
61
|
+
return false if career.status != :new or career.term != 0
|
62
|
+
cls = career.class
|
63
|
+
when String
|
64
|
+
cls = CareerPath.career_class(career)
|
65
|
+
end
|
66
|
+
!@careers.any? { |c| cls === c }
|
60
67
|
end
|
61
68
|
|
62
|
-
|
69
|
+
# Run career to completion; keep running terms while possible
|
70
|
+
def run(career, asg = nil)
|
71
|
+
run! self.apply career, asg
|
72
|
+
end
|
73
|
+
|
74
|
+
# Pass eligibility and qualify_check
|
75
|
+
# Take Drifter or Draft if disqualified
|
76
|
+
# Then enter career
|
77
|
+
def apply(career, asg = nil)
|
78
|
+
career = self.career(career) if career.is_a? String
|
63
79
|
raise(Ineligible, career.name) unless self.eligible?(career)
|
64
80
|
if career.qualify_check?(dm: -1 * @careers.size)
|
65
81
|
@char.log "Qualified for #{career.name}"
|
66
|
-
self.enter
|
82
|
+
self.enter career, asg
|
67
83
|
else
|
68
84
|
@char.log "Did not qualify for #{career.name}"
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
def fallback
|
74
|
-
case TravellerRPG.choose("Fallback career:", :drifter, :draft)
|
75
|
-
when :drifter
|
76
|
-
self.enter TravellerRPG::Drifter.new(@char)
|
77
|
-
when :draft
|
78
|
-
self.draft
|
85
|
+
case TravellerRPG.choose("Choose fallback:", 'Drifter', 'Draft')
|
86
|
+
when 'Drifter' then self.enter 'Drifter'
|
87
|
+
when 'Draft' then self.draft
|
88
|
+
end
|
79
89
|
end
|
80
90
|
end
|
81
91
|
|
82
|
-
# return an active career, no qualification, that has completed basic
|
92
|
+
# return an active career, no qualification check, that has completed basic
|
83
93
|
# training
|
84
94
|
def draft
|
85
|
-
roll = TravellerRPG.roll('d6')
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
career = CareerPath.career_class(dc.first).new(@char)
|
91
|
-
career.activate(dc.last)
|
92
|
-
self.basic_training(career)
|
93
|
-
career
|
95
|
+
roll = TravellerRPG.roll('d6', label: "Draft")
|
96
|
+
career, asg = self.class::DRAFT_CAREERS.fetch(roll)
|
97
|
+
@char.log "Drafted: #{[career, asg].compact.join(', ')}"
|
98
|
+
self.enter(career, asg)
|
94
99
|
end
|
95
100
|
|
96
|
-
def
|
97
|
-
|
101
|
+
def basic_training(career)
|
102
|
+
raise(Ineligible, "career has already started") unless career.term.zero?
|
103
|
+
raise(Ineligible, "career must be active") unless career.active?
|
104
|
+
if @careers.length > 0
|
105
|
+
choices = career.service_skills.reject { |s| @char.skills[s] }
|
106
|
+
if !choices.empty?
|
107
|
+
@char.basic_training choices.first if choices.size == 1
|
108
|
+
@char.basic_training TravellerRPG.choose('Choose skill:', *choices)
|
109
|
+
end
|
110
|
+
else
|
111
|
+
# Take "all" SERVICE_SKILLS, but choose any choices
|
112
|
+
career.service_skills(choose: true).each { |s|
|
113
|
+
@char.basic_training s
|
114
|
+
}
|
115
|
+
end
|
116
|
+
career
|
98
117
|
end
|
99
118
|
|
100
|
-
def
|
101
|
-
|
102
|
-
when Career
|
103
|
-
return false if career.active?
|
104
|
-
cls = career.class
|
105
|
-
when String
|
106
|
-
cls = CareerPath.career_class(career)
|
107
|
-
end
|
108
|
-
return true if cls == TravellerRPG::Drifter
|
109
|
-
!@careers.any? { |c| c.class == cls }
|
119
|
+
def report(char: true, careers: true)
|
120
|
+
[@char.report, @careers.map(&:report).join("\n\n")].join("\n\n")
|
110
121
|
end
|
111
122
|
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
123
|
+
protected
|
124
|
+
|
125
|
+
# ensure Career object; log; activate; basic_training; return Career
|
126
|
+
def enter(career, asg = nil)
|
127
|
+
career = self.career(career) if career.is_a? String
|
116
128
|
@char.log "Entering new career: #{career.name}"
|
117
|
-
career.activate
|
118
|
-
self.basic_training(career)
|
119
|
-
career
|
129
|
+
self.basic_training(career.activate(asg))
|
120
130
|
end
|
121
131
|
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
@char.skills[sym] = 0
|
132
|
+
# take an active career and run_term until complete
|
133
|
+
# muster_out and update career history
|
134
|
+
def run!(career)
|
135
|
+
raise(Error, "#{career.name} isn't active") unless career.active?
|
136
|
+
loop {
|
137
|
+
career.run_term
|
138
|
+
break unless career.active?
|
139
|
+
break if career.must_exit?
|
140
|
+
next if career.must_remain?
|
141
|
+
break if TravellerRPG.choose("Muster out?", :yes, :no) == :yes
|
133
142
|
}
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
[@char.report,
|
138
|
-
@careers.map(&:report).join("\n\n")].join("\n\n")
|
143
|
+
career.muster_out
|
144
|
+
@careers << career
|
145
|
+
career
|
139
146
|
end
|
140
147
|
end
|
141
148
|
end
|
@@ -1,567 +1,279 @@
|
|
1
|
+
require 'yaml'
|
1
2
|
require 'traveller_rpg'
|
2
3
|
require 'traveller_rpg/career'
|
4
|
+
require 'traveller_rpg/character'
|
5
|
+
require 'traveller_rpg/skill_set'
|
3
6
|
|
4
7
|
module TravellerRPG
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
law_enforcement: {
|
23
|
-
skills: [:investigate, :recon, :streetwise,
|
24
|
-
:stealth, :melee_group, :advocate],
|
25
|
-
survival: [:endurance, 6],
|
26
|
-
advancement: [:intelligence, 6],
|
27
|
-
ranks: {
|
28
|
-
0 => ['Rookie', nil, nil],
|
29
|
-
1 => ['Corporal', :streetwise, 1],
|
30
|
-
2 => ['Sergeant', nil, nil],
|
31
|
-
3 => ['Detective', nil, nil],
|
32
|
-
4 => ['Lieutenant', :investigate, 1],
|
33
|
-
5 => ['Chief', :admin, 1],
|
34
|
-
6 => ['Commissioner', :social_standing, nil],
|
35
|
-
},
|
36
|
-
},
|
37
|
-
intelligence: {
|
38
|
-
skills: [:investigate, :recon, :comms,
|
39
|
-
:stealth, :persuade, :deception],
|
40
|
-
survival: [:intelligence, 7],
|
41
|
-
advancement: [:intelligence, 5],
|
42
|
-
ranks: RANKS,
|
43
|
-
},
|
44
|
-
corporate: {
|
45
|
-
skills: [:investigate, :computers, :stealth,
|
46
|
-
:carouse, :deception, :streetwise],
|
47
|
-
survival: [:intelligence, 5],
|
48
|
-
advancement: [:intelligence, 7],
|
49
|
-
ranks: RANKS,
|
50
|
-
},
|
51
|
-
}
|
52
|
-
|
53
|
-
MUSTER_OUT = {
|
54
|
-
1 => [1000, 'Scientific Equipment'],
|
55
|
-
2 => [2000, 'INT +1'],
|
56
|
-
3 => [5000, 'Ship Share'],
|
57
|
-
4 => [7500, 'Weapon'],
|
58
|
-
5 => [10000, 'Combat Implant'],
|
59
|
-
6 => [25000, 'SOC +1 or Combat Implant'],
|
60
|
-
7 => [50000, 'TAS Membership'],
|
61
|
-
}
|
62
|
-
|
63
|
-
EVENTS = {
|
64
|
-
2 => 'Disaster! Roll on the Mishap Table, but you are not ejected ' +
|
65
|
-
'from this career.',
|
66
|
-
3 => 'An investigation takes on a dangerous turn. Roll ' +
|
67
|
-
'Investigate 8+ or Streetwise 8+. If you fail, roll on the ' +
|
68
|
-
'Mishap Table. If you suceed, increase one skill of ' +
|
69
|
-
'Deception, Jack-of-all-Trades, Persuade, or Tactics.',
|
70
|
-
4 => 'You complete a mission for your superiors, and are suitably ' +
|
71
|
-
'rewarded. Gain DM+1 to any one Benefit Roll from this career.',
|
72
|
-
5 => 'You establish a network of contacts. Gain d3 Contacts.',
|
73
|
-
6 => 'You are given advanced training in a specialist field. Roll ' +\
|
74
|
-
'EDU 8+ to increase any existing skill by 1.',
|
75
|
-
7 => 'Life Event. Roll on the Live Events Table.',
|
76
|
-
8 => 'You go undercover to investigate an enemy. Roll Deception 8+.' +
|
77
|
-
'If you succeed, roll immediately on the Rogue or Citizen Events ' +
|
78
|
-
'Table and make one roll on any Specialist skill table for that ' +
|
79
|
-
'career. If you fail, roll immediately on the Rogue or Citizen ' +
|
80
|
-
'Mishap Table',
|
81
|
-
9 => 'You go above and beyond the call of duty. Gain DM+2 to your ' +
|
82
|
-
'next Advancement check',
|
83
|
-
10 => 'You are given spcialist training in vehicles. Gain one of ' +
|
84
|
-
'Drive 1, Flyer 1, Pilot 1, or Gunner 1.',
|
85
|
-
11 => 'You are befriended by a senior agent. Either increase ' +
|
86
|
-
'Investigate by 1 or DM+4 to an Advancement roll thanks to ' +
|
87
|
-
'their aid.',
|
88
|
-
12 => 'Your efforts uncover a major conspiracy against your ' +
|
89
|
-
'employers. You are automatically promoted.',
|
90
|
-
}
|
91
|
-
|
92
|
-
MISHAPS = {
|
93
|
-
1 => 'Severely injured in action. Roll twice on the Injury table ' +
|
94
|
-
'or take a level 2 Injury.',
|
95
|
-
2 => 'A criminal offers you a deal. Accept the deal to leave career; ' +
|
96
|
-
'Refuse, and you must roll twice on the Injury Table and take ' +
|
97
|
-
'the lower result. Gain an Enemy and one level in any skill.',
|
98
|
-
3 => 'An investigation goes critically wrong, ruining your career. ' +
|
99
|
-
'Roll Advocate 8+; Succeed == keep benefit this term; ' +
|
100
|
-
'Fail, lost benefit as normal. A roll of 2 mandates ' +
|
101
|
-
'Prisoner career next term',
|
102
|
-
4 => 'You learn something you should not know, and people want to ' +
|
103
|
-
'kill you for it. Gain an Enemy and Deception 1',
|
104
|
-
5 => 'Your work comes home with you, and someone gets hurt. ' +
|
105
|
-
'Choose a Contact, Ally, or Family Member, and roll twice on the ' +
|
106
|
-
'Injury Table for them, taking the lower result.',
|
107
|
-
6 => 'Injured. Roll on the Injury table.',
|
108
|
-
}
|
109
|
-
|
110
|
-
end
|
111
|
-
|
112
|
-
class Citizen < Career; end
|
113
|
-
|
114
|
-
class Drifter < Career
|
115
|
-
# QUALIFICATION = [:intelligence, 0]
|
116
|
-
ADVANCED_EDUCATION = 99
|
117
|
-
PERSONAL_SKILLS = [:strength, :endurance, :dexterity,
|
118
|
-
:language_group, :social_sciences_group,
|
119
|
-
:jack_of_all_trades]
|
120
|
-
SERVICE_SKILLS = [:athletics_group, :melee_unarmed_combat, :recon,
|
121
|
-
:streetwise, :stealth, :survival]
|
122
|
-
ADVANCED_SKILLS = []
|
123
|
-
SPECIALIST = {
|
124
|
-
barbarian: {
|
125
|
-
skills: [:animals_group, :carouse, :melee_blade,
|
126
|
-
:stealth, :seafarer_group, :survival],
|
127
|
-
survival: [:endurance, 7],
|
128
|
-
advancement: [:strength, 7],
|
129
|
-
ranks: {
|
130
|
-
1 => [nil, :survival, 1],
|
131
|
-
2 => ['Warrior', :melee_blade, 1],
|
132
|
-
4 => ['Chieftain', :leadership, 1],
|
133
|
-
6 => ['Warlord', nil, nil],
|
134
|
-
},
|
135
|
-
},
|
136
|
-
wanderer: {
|
137
|
-
skills: [:drive_group, :deception, :recon,
|
138
|
-
:stealth, :streetwise, :survival],
|
139
|
-
survival: [:endurance, 7],
|
140
|
-
advancement: [:intelligence, 7],
|
141
|
-
ranks: {
|
142
|
-
1 => [nil, :streetwise, 1],
|
143
|
-
3 => [nil, :deception, 1],
|
144
|
-
},
|
145
|
-
},
|
146
|
-
scavenger: {
|
147
|
-
skills: [:pilot_small_craft, :mechanic, :astrogation,
|
148
|
-
:vacc_suit, :engineer_group, :gun_combat_group],
|
149
|
-
survival: [:dexterity, 7],
|
150
|
-
advancement: [:endurance, 7],
|
151
|
-
ranks: {
|
152
|
-
1 => [nil, :vacc_suit, 1],
|
153
|
-
3 => [nil, :mechanic, 1],
|
154
|
-
},
|
155
|
-
},
|
156
|
-
}
|
157
|
-
|
158
|
-
MUSTER_OUT = {
|
159
|
-
1 => [0, 'Contact'],
|
160
|
-
2 => [0, 'Weapon'],
|
161
|
-
3 => [1000, 'Ally'],
|
162
|
-
4 => [2000, 'Weapon'],
|
163
|
-
5 => [3000, 'EDU +1'],
|
164
|
-
6 => [4000, 'Ship Share'],
|
165
|
-
7 => [8000, 'Ship Share x2'],
|
166
|
-
}
|
167
|
-
|
168
|
-
EVENTS = {
|
169
|
-
1 => '',
|
170
|
-
2 => '',
|
171
|
-
3 => '',
|
172
|
-
4 => '',
|
173
|
-
5 => '',
|
174
|
-
6 => '',
|
175
|
-
7 => '',
|
176
|
-
8 => '',
|
177
|
-
9 => '',
|
178
|
-
10 => '',
|
179
|
-
11 => '',
|
180
|
-
12 => '',
|
181
|
-
}
|
182
|
-
|
183
|
-
MISHAPS = {
|
184
|
-
1 => '',
|
185
|
-
2 => '',
|
186
|
-
3 => '',
|
187
|
-
4 => '',
|
188
|
-
5 => '',
|
189
|
-
6 => '',
|
190
|
-
}
|
191
|
-
|
192
|
-
def qualify_check?(dm: 0)
|
193
|
-
true
|
8
|
+
module Careers
|
9
|
+
class Error < RuntimeError; end
|
10
|
+
class StatError < Error; end
|
11
|
+
class UnknownStat < StatError; end
|
12
|
+
class StatCheckError < StatError; end
|
13
|
+
class SkillError < Error; end
|
14
|
+
class UnknownSkill < SkillError; end
|
15
|
+
class RankError < Error; end
|
16
|
+
class EventError < Error; end
|
17
|
+
class MishapError < Error; end
|
18
|
+
class CreditError < Error; end
|
19
|
+
class BenefitError < Error; end
|
20
|
+
|
21
|
+
def self.load(file_name)
|
22
|
+
hsh = YAML.load_file(self.find(file_name))
|
23
|
+
raise "unexpected object: #{hsh.inspect}" unless hsh.is_a?(Hash)
|
24
|
+
hsh
|
194
25
|
end
|
195
|
-
end
|
196
|
-
|
197
|
-
class Entertainer < Career; end
|
198
|
-
class Merchant < Career; end
|
199
|
-
class Rogue < Career; end
|
200
|
-
class Scholar < Career; end
|
201
|
-
|
202
|
-
class Scout < Career
|
203
|
-
QUALIFICATION = [:intelligence, 5]
|
204
|
-
ADVANCED_EDUCATION = 8
|
205
|
-
PERSONAL_SKILLS = [:strength, :dexterity, :endurance,
|
206
|
-
:intelligence, :education, :jack_of_all_trades]
|
207
|
-
SERVICE_SKILLS = [:pilot_small_craft, :survival, :mechanic,
|
208
|
-
:astrogation, :comms, :gun_combat_group]
|
209
|
-
ADVANCED_SKILLS = [:medic, :navigation, :engineer_group,
|
210
|
-
:computers, :space_sciences_group, :jack_of_all_trades]
|
211
|
-
|
212
|
-
RANKS = {
|
213
|
-
1 => ['Scout', :vacc_suit, 1],
|
214
|
-
3 => ['Senior Scout', :pilot, 1],
|
215
|
-
}
|
216
|
-
|
217
|
-
SPECIALIST = {
|
218
|
-
courier: {
|
219
|
-
skills: [:comms, :sensors, :pilot_spacecraft,
|
220
|
-
:vacc_suit, :zero_g, :astrogation],
|
221
|
-
survival: [:endurance, 5],
|
222
|
-
advancement: [:education, 9],
|
223
|
-
ranks: RANKS,
|
224
|
-
},
|
225
|
-
surveyor: {
|
226
|
-
skills: [:sensors, :persuade, :pilot_small_craft,
|
227
|
-
:navigation, :diplomat, :streetwise],
|
228
|
-
survival: [:endurance, 6],
|
229
|
-
advancement: [:intelligence, 8],
|
230
|
-
ranks: RANKS,
|
231
|
-
},
|
232
|
-
explorer: {
|
233
|
-
skills: [:sensors, :pilot_spacecraft, :pilot_small_craft,
|
234
|
-
:life_sciences_group, :stealth, :recon],
|
235
|
-
survival: [:endurance, 7],
|
236
|
-
advancement: [:education, 7],
|
237
|
-
ranks: RANKS,
|
238
|
-
},
|
239
|
-
}
|
240
|
-
|
241
|
-
MUSTER_OUT = {
|
242
|
-
1 => [20000, 'Ship Share'],
|
243
|
-
2 => [20000, 'INT +1'],
|
244
|
-
3 => [30000, 'EDU +1'],
|
245
|
-
4 => [30000, 'Weapon'],
|
246
|
-
5 => [50000, 'Weapon'],
|
247
|
-
6 => [50000, 'Scout Ship'],
|
248
|
-
7 => [50000, 'Scout Ship'],
|
249
|
-
}
|
250
|
-
|
251
|
-
# Weapon: Select any weapon up to 1000 creds and TL12
|
252
|
-
# If this benefit is rolled more than once, take a different weapon
|
253
|
-
# or one level in the appropriate Melee or Gun Combat skill
|
254
|
-
|
255
|
-
# Scout Ship: Receive a scout ship in exchange for performing periodic
|
256
|
-
# scout missions
|
257
|
-
|
258
|
-
# Ship share: Accumulate these to redeem for a ship. They are worth
|
259
|
-
# roughly 1M creds but cannot be redeemed for creds.
|
260
|
-
|
261
|
-
|
262
|
-
EVENTS = {
|
263
|
-
2 => 'Disaster! Roll on the mishap table but you are not ejected ' +
|
264
|
-
'from career.',
|
265
|
-
3 => 'Ambush! Choose Pilot 8+ to escape or Persuade 10+ to bargain. ' +
|
266
|
-
'Gain an Enemy either way',
|
267
|
-
4 => 'Survey an alien world. Choose Animals, Survival, Recon, or ' +
|
268
|
-
'Life Sciences 1',
|
269
|
-
5 => 'You perform an exemplary service. Gain a benefit roll with +1 DM',
|
270
|
-
6 => 'You spend several years exploring the star system; ' +
|
271
|
-
'Choose Atrogation, Navigation, Pilot (small craft) or Mechanic 1',
|
272
|
-
7 => 'Life event. Roll on the Life Events table',
|
273
|
-
8 => 'Gathered intelligence on an alien race. Roll Sensors 8+ or ' +
|
274
|
-
'Deception 8+. Gain an ally in the Imperium and +2 DM to your ' +
|
275
|
-
'next Advancement roll on success. Roll on the mishap table on ' +
|
276
|
-
'failure, but you are not ejected from career.',
|
277
|
-
9 => 'You rescue disaster survivors. Roll either Medic 8+ or ' +
|
278
|
-
'Engineer 8+. Gain a Contact and +2 DM on next Advancement roll ' +
|
279
|
-
'or else gain an Enemy',
|
280
|
-
10 => 'You spend a great deal of time on the fringes of known space. ' +
|
281
|
-
'Roll Survival 8+ or Pilot 8+. Gain a Contact in an alien race ' +
|
282
|
-
'and one level in any skill, or else roll on the Mishap table.',
|
283
|
-
11 => 'You serve as a courier for an important message for the ' +
|
284
|
-
'Imperium. Gain one level of diplomat or take +4 DM to your ' +
|
285
|
-
'next Advancement roll.',
|
286
|
-
12 => 'You make an important discovery for the Imperium. Gain a ' +
|
287
|
-
'career rank.',
|
288
|
-
}
|
289
|
-
|
290
|
-
MISHAPS = {
|
291
|
-
1 => 'Severely injured in action. Roll twice on the Injury table ' +
|
292
|
-
'or take a level 2 Injury.',
|
293
|
-
2 => 'Suffer psychological damage. Reduce Intelligence or Social ' +
|
294
|
-
'Standing by 1',
|
295
|
-
3 => 'Your ship is damaged, and you have to hitch a ride back to ' +
|
296
|
-
'your nearest scout base. Gain 1d6 Contacts and 1d3 Enemies.',
|
297
|
-
4 => 'You inadvertently cause a conflict between the Imperium and ' +
|
298
|
-
'a minor world or race. Gain a Rival and Diplomat 1.',
|
299
|
-
5 => 'You have no idea what happened to you. Your ship was found ' +
|
300
|
-
'drifting on the fringes of friendly space',
|
301
|
-
6 => 'Injured. Roll on the Injury table.',
|
302
|
-
}
|
303
|
-
end
|
304
26
|
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
|
317
|
-
|
318
|
-
|
319
|
-
2 => ['Corporal', nil, nil],
|
320
|
-
3 => ['Lance Sergeant', :leadership, 1],
|
321
|
-
4 => ['Sergeant', nil, nil],
|
322
|
-
5 => ['Gunnery Sergeant', nil, nil],
|
323
|
-
6 => ['Sergeant Major', nil, nil],
|
324
|
-
}
|
325
|
-
OFFICER_RANKS = {
|
326
|
-
1 => ['Lieutenant', :leadership, 1],
|
327
|
-
2 => ['Captain', nil, nil],
|
328
|
-
3 => ['Major', :tactics_military, 1],
|
329
|
-
4 => ['Lieutenant Colonel', nil, nil],
|
330
|
-
5 => ['Colonel', nil, nil],
|
331
|
-
6 => ['General', :social_status, 10], # TODO
|
332
|
-
}
|
27
|
+
def self.find(file_name)
|
28
|
+
path = File.join(__dir__, 'careers', file_name)
|
29
|
+
files = Dir["#{path}*"].grep %r{\.ya?ml\z}
|
30
|
+
case files.size
|
31
|
+
when 0
|
32
|
+
raise "can't find #{file_name}"
|
33
|
+
when 1
|
34
|
+
files[0]
|
35
|
+
else
|
36
|
+
# prefer .yaml to .yml -- otherwise give up
|
37
|
+
files.grep(/\.yaml\z/).first or
|
38
|
+
raise "#{file_name} matches #{files}"
|
39
|
+
end
|
40
|
+
end
|
333
41
|
|
334
|
-
|
335
|
-
|
336
|
-
|
337
|
-
:explosives, :comms, :medic],
|
338
|
-
survival: [:endurance, 5],
|
339
|
-
advancement: [:education, 7],
|
340
|
-
ranks: RANKS,
|
341
|
-
},
|
342
|
-
infantry: {
|
343
|
-
skills: [:gun_combat_group, :melee_group, :heavy_weapons_group,
|
344
|
-
:stealth, :athletics_group, :recon],
|
345
|
-
survival: [:strength, 6],
|
346
|
-
advancement: [:education, 6],
|
347
|
-
ranks: RANKS,
|
348
|
-
},
|
349
|
-
cavalry: {
|
350
|
-
skills: [:mechanic, :drive_group, :flyer_group,
|
351
|
-
:recon, :heavy_weapons_group, :sensors], # TODO: HW vehicle
|
352
|
-
survival: [:intelligence, 7],
|
353
|
-
advancement: [:intelligence, 5],
|
354
|
-
ranks: RANKS,
|
355
|
-
},
|
356
|
-
}
|
42
|
+
def self.skill?(str)
|
43
|
+
!!TravellerRPG::SkillSet.split_skill!(str) rescue false
|
44
|
+
end
|
357
45
|
|
358
|
-
|
359
|
-
|
360
|
-
|
361
|
-
|
362
|
-
5 => nil,
|
363
|
-
6 => nil,
|
364
|
-
7 => nil,
|
365
|
-
8 => nil,
|
366
|
-
9 => nil,
|
367
|
-
10 => nil,
|
368
|
-
11 => nil,
|
369
|
-
12 => nil,
|
370
|
-
}
|
46
|
+
# accepts symbol or string
|
47
|
+
def self.stat? stat
|
48
|
+
Character::Stats.member? stat
|
49
|
+
end
|
371
50
|
|
372
|
-
|
373
|
-
|
374
|
-
|
375
|
-
|
376
|
-
|
377
|
-
5 => nil,
|
378
|
-
6 => nil,
|
379
|
-
}
|
51
|
+
# accepts symbol or string; raises if not recongnized
|
52
|
+
def self.stat_sym! stat
|
53
|
+
raise(UnknownStat, stat) unless self.stat? stat
|
54
|
+
Character::Stats.sym stat
|
55
|
+
end
|
380
56
|
|
381
|
-
#
|
382
|
-
|
383
|
-
|
384
|
-
|
385
|
-
3 => [10_000, 'EDU +1'],
|
386
|
-
4 => [10_000, 'Weapon'],
|
387
|
-
5 => [10_000, 'Armour'],
|
388
|
-
6 => [20_000, 'END +1 or Combat Implant'],
|
389
|
-
7 => [30_000, 'SOC +1'],
|
390
|
-
}
|
391
|
-
end
|
57
|
+
# accepts symbol or string; returns symbol if stat is recognized
|
58
|
+
def self.stat_sym stat
|
59
|
+
self.stat?(stat) ? Character::Stats.sym(stat) : stat
|
60
|
+
end
|
392
61
|
|
393
|
-
|
394
|
-
|
395
|
-
|
62
|
+
# convert 'choose' to :choose and e.g. 'strength' to :strength
|
63
|
+
def self.fetch_stat_check!(hsh, key)
|
64
|
+
raise(StatCheckError, "#{key} not found: #{hsh}") unless hsh.key? key
|
65
|
+
cfg = hsh[key] or return false # e.g. Drifter
|
66
|
+
raise(StatCheckError, cfg) unless cfg.is_a?(Hash) and cfg.size == 1
|
67
|
+
result = {}
|
68
|
+
if cfg.key? 'choose'
|
69
|
+
result[:choose] = {}
|
70
|
+
cfg['choose'].each { |stat, check|
|
71
|
+
result[:choose][self.stat_sym!(stat)] = check
|
72
|
+
}
|
73
|
+
else
|
74
|
+
result[self.stat_sym!(cfg.keys.first)] = cfg.values.first
|
75
|
+
end
|
76
|
+
result
|
77
|
+
end
|
396
78
|
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
|
402
|
-
|
403
|
-
|
404
|
-
:advocate, :vacc_suit, :leadership]
|
405
|
-
RANKS = {
|
406
|
-
0 => ['Marine', :gun_combat_group, 1], # TODO
|
407
|
-
1 => ['Lance Corporal', :gun_combat_group, 1],
|
408
|
-
2 => ['Corporal', nil, nil],
|
409
|
-
3 => ['Lance Sergeant', :leadership, 1],
|
410
|
-
4 => ['Sergeant', nil, nil],
|
411
|
-
5 => ['Gunnery Sergeant', :endurance, nil],
|
412
|
-
6 => ['Sergeant Major', nil, nil],
|
413
|
-
}
|
414
|
-
OFFICER_RANKS = {
|
415
|
-
1 => ['Lieutenant', :leadership, 1],
|
416
|
-
2 => ['Captain', nil, nil],
|
417
|
-
3 => ['Force Commander', :tactics_group, 1],
|
418
|
-
4 => ['Lieutenant Colonel', nil, nil],
|
419
|
-
5 => ['Colonel', :social_status, 10], # TODO
|
420
|
-
6 => ['Brigadier', nil, nil],
|
421
|
-
}
|
79
|
+
def self.fetch_skills!(hsh, key, stats_ok: true)
|
80
|
+
ary = hsh[key] or raise(SkillError, hsh[key].inspect)
|
81
|
+
unless ary.is_a?(Array) and ary.size == 6
|
82
|
+
raise(SkillError, "unexpected: #{ary}")
|
83
|
+
end
|
84
|
+
self.extract_skills(ary, stats_ok: stats_ok)
|
85
|
+
end
|
422
86
|
|
423
|
-
|
424
|
-
|
425
|
-
|
426
|
-
|
427
|
-
|
428
|
-
|
429
|
-
|
430
|
-
|
431
|
-
|
432
|
-
|
433
|
-
|
434
|
-
|
435
|
-
|
436
|
-
|
437
|
-
|
438
|
-
|
439
|
-
|
440
|
-
:melee_blade, :tactics_military, :gun_combat_group],
|
441
|
-
survival: [:endurance, 7],
|
442
|
-
advancement: [:education, 5],
|
443
|
-
ranks: RANKS,
|
87
|
+
# convert 'choose' to :choose and e.g. 'strength' to :strength
|
88
|
+
def self.extract_skills(ary, stats_ok: true)
|
89
|
+
ary.reduce([]) { |memo, val|
|
90
|
+
case val
|
91
|
+
when Hash
|
92
|
+
raise(SkillError, val) unless val['choose'].is_a? Array
|
93
|
+
val = {
|
94
|
+
choose: self.extract_skills(val['choose'], stats_ok: stats_ok)
|
95
|
+
}
|
96
|
+
else
|
97
|
+
if stats_ok and self.stat? val
|
98
|
+
val = self.stat_sym! val
|
99
|
+
else
|
100
|
+
raise(UnknownSkill, val) unless self.skill? val
|
101
|
+
end
|
102
|
+
end
|
103
|
+
memo + [val]
|
444
104
|
}
|
445
|
-
|
446
|
-
|
447
|
-
EVENTS = {
|
448
|
-
2 => nil,
|
449
|
-
3 => nil,
|
450
|
-
4 => nil,
|
451
|
-
5 => nil,
|
452
|
-
6 => nil,
|
453
|
-
7 => nil,
|
454
|
-
8 => nil,
|
455
|
-
9 => nil,
|
456
|
-
10 => nil,
|
457
|
-
11 => nil,
|
458
|
-
12 => nil,
|
459
|
-
}
|
105
|
+
end
|
460
106
|
|
461
|
-
|
462
|
-
|
463
|
-
|
464
|
-
|
465
|
-
|
466
|
-
|
467
|
-
|
468
|
-
|
107
|
+
def self.ranks(hsh, key = 'ranks')
|
108
|
+
r = hsh[key] or raise(RankError, "no rank: #{hsh}")
|
109
|
+
raise(RankError, r) unless r.is_a? Hash and r.size <= 7
|
110
|
+
result = {}
|
111
|
+
r.each { |rank, h|
|
112
|
+
result[rank] = {}
|
113
|
+
raise(RankError, h) unless h.is_a? Hash and h.size <= 3
|
114
|
+
raise(RankError, h) if (h.keys & %w{title skill stat choose}).empty?
|
115
|
+
title, skill, stat, level = h.values_at(*%w{title skill stat level})
|
116
|
+
if title
|
117
|
+
raise(RankError, "not a string: #{title}") unless title.is_a?(String)
|
118
|
+
result[rank][:title] = title
|
119
|
+
end
|
120
|
+
choices = h['choose']
|
121
|
+
if choices
|
122
|
+
raise("bad choices: #{choices}") unless choices.is_a?(Array)
|
123
|
+
result[rank][:choose] = []
|
124
|
+
choices.each { |hh|
|
125
|
+
if hh['skill'] and hh['stat']
|
126
|
+
raise("can't have both skill and stat: #{hh}")
|
127
|
+
elsif hh['skill']
|
128
|
+
raise(UnknownSkill, hh['skill']) unless self.skill?(hh['skill'])
|
129
|
+
res = { skill: hh['skill'] }
|
130
|
+
res[:level] = hh['level'] if hh['level']
|
131
|
+
result[rank][:choose] << res
|
132
|
+
elsif hh['stat']
|
133
|
+
res = { stat: self.stat_sym!(hh['stat']) }
|
134
|
+
res[:level] = hh['level'] if hh['level']
|
135
|
+
result[rank][:choose] << res
|
136
|
+
else
|
137
|
+
raise("need at least one of skill or stat: #{hh}")
|
138
|
+
end
|
139
|
+
}
|
140
|
+
next
|
141
|
+
end
|
142
|
+
|
143
|
+
# ok, no choose
|
144
|
+
|
145
|
+
if skill
|
146
|
+
case skill
|
147
|
+
when String
|
148
|
+
result[rank][:skill] =
|
149
|
+
self.skill?(skill) ? skill : raise(UnknownSkill, skill)
|
150
|
+
else
|
151
|
+
raise(UnknownSkill, skill)
|
152
|
+
end
|
153
|
+
end
|
154
|
+
if stat
|
155
|
+
case stat
|
156
|
+
when String, Symbol
|
157
|
+
result[rank][:stat] = self.stat_sym! stat
|
158
|
+
else
|
159
|
+
raise(UnknownStat, stat)
|
160
|
+
end
|
161
|
+
end
|
162
|
+
if level
|
163
|
+
raise(RankError, "unexpected level: #{level}") unless skill or stat
|
164
|
+
raise(RankError, "bad level: #{level}") unless (0..5).include?(level)
|
165
|
+
result[rank][:level] = level
|
166
|
+
end
|
167
|
+
}
|
168
|
+
result
|
169
|
+
end
|
469
170
|
|
470
|
-
|
471
|
-
|
472
|
-
|
473
|
-
|
474
|
-
|
475
|
-
|
476
|
-
|
477
|
-
|
478
|
-
|
479
|
-
|
171
|
+
def self.specialist(hsh)
|
172
|
+
result = {}
|
173
|
+
hsh.fetch('specialist').each { |asg, cfg|
|
174
|
+
result[asg] = {}
|
175
|
+
result[asg][:survival] = self.fetch_stat_check!(cfg, 'survival')
|
176
|
+
result[asg][:advancement] = self.fetch_stat_check!(cfg, 'advancement')
|
177
|
+
result[asg][:skills] = self.fetch_skills!(cfg, 'skills')
|
178
|
+
result[asg][:ranks] = self.ranks(cfg)
|
179
|
+
}
|
180
|
+
result
|
181
|
+
end
|
480
182
|
|
481
|
-
|
482
|
-
|
483
|
-
|
183
|
+
def self.events(hsh, key, size)
|
184
|
+
result = {}
|
185
|
+
e = hsh[key]
|
186
|
+
raise(EventError, "#{key} #{e}") unless e.is_a?(Hash) and e.size == size
|
187
|
+
e.each { |num, h|
|
188
|
+
result[num] = {}
|
189
|
+
raise(EventError, h) unless h.is_a?(Hash)
|
190
|
+
result[num][:text] = h.fetch('text')
|
191
|
+
result[num][:script] = h['script'] if h['script']
|
192
|
+
}
|
193
|
+
result
|
194
|
+
end
|
484
195
|
|
485
|
-
|
486
|
-
|
487
|
-
|
488
|
-
|
489
|
-
|
490
|
-
|
491
|
-
|
492
|
-
|
493
|
-
|
494
|
-
|
495
|
-
1 => ['Able Spacehand', :mechanic, 1],
|
496
|
-
2 => ['Petty Officer 3rd class', :vacc_suit, 1],
|
497
|
-
3 => ['Petty Officer 2nd class', nil, nil],
|
498
|
-
4 => ['Petty Officer 1st class', :endurance, nil],
|
499
|
-
5 => ['Chief Petty Officer', nil, nil],
|
500
|
-
6 => ['Master Chief', nil, nil],
|
501
|
-
}
|
502
|
-
OFFICER_RANKS = {
|
503
|
-
1 => ['Ensign', :melee_blade, 1],
|
504
|
-
2 => ['Sublieutenant', :leadership, 1],
|
505
|
-
3 => ['Lieutenant', nil, nil],
|
506
|
-
4 => ['Commander', :tactics_naval, 1],
|
507
|
-
5 => ['Captain', :social_status, 10], # TODO
|
508
|
-
6 => ['Admiral', :social_status, 12], # TODO
|
509
|
-
}
|
510
|
-
SPECIALIST = {
|
511
|
-
line_crew: {
|
512
|
-
skills: [:engineer_electronics, :mechanic, :gun_combat_group,
|
513
|
-
:flyer_group, :melee_group, :vacc_suit],
|
514
|
-
survival: [:intelligence, 5],
|
515
|
-
advancement: [:education, 7],
|
516
|
-
ranks: RANKS,
|
517
|
-
},
|
518
|
-
engineer_gunner: {
|
519
|
-
skills: [:engineer_group, :mechanic, :engineer_electronics,
|
520
|
-
:engineer_group, :gunner_group, :flyer_group],
|
521
|
-
survival: [:intelligence, 6],
|
522
|
-
advancement: [:education, 6],
|
523
|
-
ranks: RANKS,
|
524
|
-
},
|
525
|
-
flight: {
|
526
|
-
skills: [:pilot_group, :flyer_group, :gunner_group,
|
527
|
-
:pilot_small_craft, :astrogation, :engineer_electronics],
|
528
|
-
survival: [:dexterity, 7],
|
529
|
-
advancement: [:education, 5],
|
530
|
-
ranks: RANKS,
|
531
|
-
},
|
532
|
-
}
|
196
|
+
def self.credits(hsh)
|
197
|
+
c = hsh.fetch('credits')
|
198
|
+
raise(CreditError, c) unless c.is_a?(Array) and c.size == 7
|
199
|
+
creds = {}
|
200
|
+
c.each.with_index { |credits, idx|
|
201
|
+
raise(CreditError, credits) unless credits.is_a? Integer
|
202
|
+
creds[idx + 1] = credits
|
203
|
+
}
|
204
|
+
creds
|
205
|
+
end
|
533
206
|
|
534
|
-
|
535
|
-
|
536
|
-
|
537
|
-
|
538
|
-
|
539
|
-
|
540
|
-
|
541
|
-
|
542
|
-
|
543
|
-
|
544
|
-
|
545
|
-
|
546
|
-
|
207
|
+
def self.benefits(hsh)
|
208
|
+
result = {}
|
209
|
+
b = hsh.fetch('benefits')
|
210
|
+
raise(BenefitError, b) unless b.is_a?(Array) and b.size == 7
|
211
|
+
b.each.with_index { |item, idx|
|
212
|
+
case item
|
213
|
+
when String
|
214
|
+
result[idx + 1] = self.stat_sym(item)
|
215
|
+
when Hash
|
216
|
+
ary = item['choose']
|
217
|
+
raise(BenefitError, item) unless ary.is_a?(Array) and ary.size < 5
|
218
|
+
result[idx + 1] = { choose: ary.map { |a| self.stat_sym(a) } }
|
219
|
+
when Array
|
220
|
+
result[idx + 1] = item.map { |a| self.stat_sym(a) }
|
221
|
+
else
|
222
|
+
raise(BenefitError, item)
|
223
|
+
end
|
224
|
+
}
|
225
|
+
result
|
226
|
+
end
|
547
227
|
|
548
|
-
|
549
|
-
|
550
|
-
|
551
|
-
|
552
|
-
|
553
|
-
|
554
|
-
|
555
|
-
|
228
|
+
def self.generate_classes(file_name, superclass)
|
229
|
+
scope = TravellerRPG
|
230
|
+
self.load(file_name).each { |name, cfg|
|
231
|
+
# inherit from Career
|
232
|
+
c = Class.new(superclass)
|
233
|
+
|
234
|
+
begin
|
235
|
+
# create class constants
|
236
|
+
c.const_set('QUALIFICATION',
|
237
|
+
self.fetch_stat_check!(cfg, 'qualification'))
|
238
|
+
c.const_set('PERSONAL_SKILLS', self.fetch_skills!(cfg, 'personal'))
|
239
|
+
c.const_set('SERVICE_SKILLS', self.fetch_skills!(cfg, 'service'))
|
240
|
+
if cfg.key?('advanced_education')
|
241
|
+
c.const_set('ADVANCED_EDUCATION', cfg['advanced_education'])
|
242
|
+
if cfg['advanced_education']
|
243
|
+
c.const_set('ADVANCED_SKILLS',
|
244
|
+
self.fetch_skills!(cfg, 'advanced'))
|
245
|
+
end
|
246
|
+
end
|
247
|
+
c.const_set('SPECIALIST', self.specialist(cfg))
|
248
|
+
# TODO: Events and mishaps are tedious to write.
|
249
|
+
# Currently Career::EVENTS and ::MISHAPS provides defaults.
|
250
|
+
# These should be mandatory once defined for each career.
|
251
|
+
if cfg['events']
|
252
|
+
c.const_set('EVENTS', self.events(cfg, 'events', 11))
|
253
|
+
c.const_set('MISHAPS', self.events(cfg, 'mishaps', 6))
|
254
|
+
end
|
255
|
+
c.const_set('CREDITS', self.credits(cfg))
|
256
|
+
c.const_set('BENEFITS', self.benefits(cfg))
|
257
|
+
|
258
|
+
|
259
|
+
if superclass == MilitaryCareer
|
260
|
+
c.const_set('AGE_PENALTY', cfg.fetch('age_penalty'))
|
261
|
+
c.const_set('COMMISSION',
|
262
|
+
self.fetch_stat_check!(cfg, 'commission'))
|
263
|
+
c.const_set('OFFICER_RANKS',
|
264
|
+
self.ranks(cfg, 'officer_ranks'))
|
265
|
+
c.const_set('OFFICER_SKILLS', self.fetch_skills!(cfg, 'officer'))
|
266
|
+
end
|
267
|
+
rescue => e
|
268
|
+
warn ["Career: #{name}", e.class, e].join("\n")
|
269
|
+
raise
|
270
|
+
end
|
271
|
+
# set class e.g. TravellerRPG::Agent
|
272
|
+
scope.const_set(name, c)
|
273
|
+
}
|
274
|
+
end
|
556
275
|
|
557
|
-
|
558
|
-
|
559
|
-
2 => [5000, 'INT +1'],
|
560
|
-
3 => [5000, 'EDU +1 or two Ship Shares'],
|
561
|
-
4 => [10_000, 'Weapon'],
|
562
|
-
5 => [20_000, 'TAS Membership'],
|
563
|
-
6 => [50_000, "Ship's Boat or two Ship Shares"],
|
564
|
-
7 => [50_000, 'SOC +2'],
|
565
|
-
}
|
276
|
+
self.generate_classes('base', Career)
|
277
|
+
self.generate_classes('military', MilitaryCareer)
|
566
278
|
end
|
567
279
|
end
|