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.
@@ -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