traveller_rpg 0.0.1.1 → 0.1.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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 Error < RuntimeError; end
9
- class Ineligible < Error; end
8
+ class Ineligible < RuntimeError; end
10
9
 
11
10
  DRAFT_CAREERS = {
12
- 1 => ['Navy', nil],
13
- 2 => ['Army', nil],
14
- 3 => ['Marines', nil],
15
- 4 => ['Merchant', :merchant_marine],
16
- 5 => ['Scout', nil],
17
- 6 => ['Agent', :law_enforcement],
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.run(careers, character: nil)
25
- character = Generator.character unless character
26
- puts "\n", character.report(desc: :long, stuff: false, credits: false)
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(careers)
33
+ careers = careers.select { |c| path.eligible? c }
30
34
  break if careers.empty?
31
- choice = TravellerRPG.choose("Choose a career:", *careers)
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 run(career)
49
- career = self.fallback unless self.apply(career)
50
- loop {
51
- career.run_term
52
- break unless career.active?
53
- break if career.must_exit?
54
- next if career.must_remain?
55
- break if TravellerRPG.choose("Muster out?", :yes, :no) == :yes
56
- }
57
- career.muster_out
58
- @careers << career
59
- career
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
- def apply(career)
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(career)
82
+ self.enter career, asg
67
83
  else
68
84
  @char.log "Did not qualify for #{career.name}"
69
- false
70
- end
71
- end
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
- puts "Draft roll: #{roll}"
87
- dc = self.class::DRAFT_CAREERS.fetch(roll)
88
- @char.log "Drafted: #{dc.compact.join(', ')}"
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 eligible(careers)
97
- careers.select { |c| self.eligible?(c) }
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 eligible?(career)
101
- case career
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
- def enter(career)
113
- raise(Ineligible, career.name) unless self.eligible?(career)
114
- raise(Error, "career is already active") if career.active?
115
- raise(Error, "career has already started") unless career.term == 0
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
- def basic_training(career)
123
- return unless career.term.zero?
124
- skills = career.class::SERVICE_SKILLS - @char.skills.keys
125
- return if skills.empty?
126
- if @careers.length > 0
127
- skills = [TravellerRPG.choose("Choose service skill:", *skills)]
128
- end
129
- skills.each { |sym|
130
- raise "unknown skill: #{sym}" unless TravellerRPG::SKILLS.key?(sym)
131
- @char.log "Acquired basic training skill: #{sym} 0"
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
- end
135
-
136
- def report(char: true, careers: true)
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
- class Agent < Career
6
- QUALIFICATION = [:intelligence, 6]
7
- ADVANCED_EDUCATION = 8
8
- PERSONAL_SKILLS = [:gun_combat_group, :dexterity, :endurance,
9
- :melee_group, :intelligence, :athletics_group]
10
- SERVICE_SKILLS = [:streetwise, :drive_group, :investigate,
11
- :flyer_group, :recon, :gun_combat_group]
12
- ADVANCED_SKILLS = [:advocate, :language_group, :explosives,
13
- :medic, :vacc_suit, :electronics_group]
14
- RANKS = {
15
- 1 => ['Agent', :deception, 1],
16
- 2 => ['Field Agent', :investigate, 1],
17
- 4 => ['Special Agent', :gun_combat_group, 1],
18
- 5 => ['Assistant Director', nil, nil],
19
- 6 => ['Director', nil, nil],
20
- }
21
- SPECIALIST = {
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
- class Army < MilitaryCareer
306
- QUALIFICATION = [:endurance, 5]
307
- AGE_PENALTY = 30
308
- PERSONAL_SKILLS = [:strength, :dexterity, :endurance,
309
- :gambler, :medic, :melee_group]
310
- SERVICE_SKILLS = [:drive_group, :athletics_group, :gun_combat_group,
311
- :recon, :melee_group, :heavy_weapons_group]
312
- ADVANCED_SKILLS = [:tactics_military, :leadership, :advocate,
313
- :diplomat, :electronics, :admin]
314
- OFFICER_SKILLS = [:tactics_military, :leadership, :advocate,
315
- :diplomat, :electronics, :admin]
316
- RANKS = {
317
- 0 => ['Private', :gun_combat_group, 1],
318
- 1 => ['Lance Corporal', :recon, 1],
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
- SPECIALIST = {
335
- support: {
336
- skills: [:mechanic, :flyer_group, :engineer_group, # TODO: profession
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
- EVENTS = {
359
- 2 => nil,
360
- 3 => nil,
361
- 4 => nil,
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
- MISHAPS = {
373
- 1 => nil,
374
- 2 => nil,
375
- 3 => nil,
376
- 4 => nil,
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
- # roll => [cash, benefit]
382
- MUSTER_OUT = {
383
- 1 => [2000, 'Combat Implant'],
384
- 2 => [5000, 'INT +1'],
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
- class Marines < MilitaryCareer
394
- QUALIFICATION = [:endurance, 6]
395
- AGE_PENALTY = 30
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
- PERSONAL_SKILLS = [:strength, :dexterity, :endurance,
398
- :gambler, :melee_unarmed_combat, :melee_blade]
399
- SERVICE_SKILLS = [:athletics_group, :vacc_suit, :tactics_group,
400
- :heavy_weapons_group, :gun_combat_group, :stealth]
401
- ADVANCED_SKILLS = [:medic, :survival, :explosives,
402
- :engineer_group, :pilot_group, :navigation]
403
- OFFICER_SKILLS = [:engineer_group, :tactics_group, :admin,
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
- SPECIALIST = {
424
- support: {
425
- skills: [:engineer_electronics, :mechanic, :flyer_group, # TODO
426
- :medic, :heavy_weapons_group, :gun_combat_group],
427
- survival: [:endurance, 5],
428
- advancement: [:education, 7],
429
- ranks: RANKS,
430
- },
431
- star_marine: {
432
- skills: [:vacc_suit, :athletics_group, :gunner_group,
433
- :melee_blade, :engineer_electronics, :gun_combat_group],
434
- survival: [:endurance, 6],
435
- advancement: [:education, 6],
436
- ranks: RANKS,
437
- },
438
- ground_assault: {
439
- skills: [:vacc_suit, :heavy_weapons_group, :recon,
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
- MISHAPS = {
462
- 1 => nil,
463
- 2 => nil,
464
- 3 => nil,
465
- 4 => nil,
466
- 5 => nil,
467
- 6 => nil,
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
- MUSTER_OUT = {
471
- 1 => [2000, 'Armour'],
472
- 2 => [5000, 'INT +1'],
473
- 3 => [5000, 'EDU +1'],
474
- 4 => [10_000, 'Weapon'],
475
- 5 => [20_000, 'TAS Membership'],
476
- 6 => [30_000, 'Armour or END +1'],
477
- 7 => [40_000, 'SOC +2'],
478
- }
479
- end
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
- class Navy < MilitaryCareer
482
- QUALIFICATION = [:intelligence, 6]
483
- AGE_PENALTY = 34
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
- PERSONAL_SKILLS = [:strength, :dexterity, :endurance,
486
- :intelligence, :education, :social_standing]
487
- SERVICE_SKILLS = [:pilot_group, :vacc_suit, :athletics_group,
488
- :gunner_group, :mechanic, :gun_combat_group]
489
- ADVANCED_SKILLS = [:engineer_electronics, :astrogation, :engineer_group,
490
- :drive_group, :navigation, :admin]
491
- OFFICER_SKILLS = [:leadership, :engineer_electronics, :pilot,
492
- :melee_blade, :admin, :tactics_naval]
493
- RANKS = {
494
- 0 => ['Crewman', nil, nil],
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
- EVENTS = {
535
- 2 => nil,
536
- 3 => nil,
537
- 4 => nil,
538
- 5 => nil,
539
- 6 => nil,
540
- 7 => nil,
541
- 8 => nil,
542
- 9 => nil,
543
- 10 => nil,
544
- 11 => nil,
545
- 12 => nil,
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
- MISHAPS = {
549
- 1 => nil,
550
- 2 => nil,
551
- 3 => nil,
552
- 4 => nil,
553
- 5 => nil,
554
- 6 => nil,
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
- MUSTER_OUT = {
558
- 1 => [1000, 'Personal Vehicle or Ship Share'],
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