coop_al 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +7 -0
  3. data/.rubocop.yml +9 -0
  4. data/Gemfile +3 -0
  5. data/README.md +89 -0
  6. data/Rakefile +10 -0
  7. data/coop.gemspec +29 -0
  8. data/examples/filler.rb +9 -0
  9. data/examples/test.rb +15 -0
  10. data/exe/coop +131 -0
  11. data/lib/coop_al/adventure.rb +52 -0
  12. data/lib/coop_al/adventure_generator.rb +35 -0
  13. data/lib/coop_al/bestiary.rb +51 -0
  14. data/lib/coop_al/bestiary_generator.rb +3 -0
  15. data/lib/coop_al/bestiary_populator.rb +14 -0
  16. data/lib/coop_al/chapter.rb +51 -0
  17. data/lib/coop_al/chapter_generator.rb +32 -0
  18. data/lib/coop_al/encounter.rb +148 -0
  19. data/lib/coop_al/encounter_generator.rb +67 -0
  20. data/lib/coop_al/exception.rb +7 -0
  21. data/lib/coop_al/item.rb +30 -0
  22. data/lib/coop_al/library.rb +54 -0
  23. data/lib/coop_al/library_generator.rb +4 -0
  24. data/lib/coop_al/loot.rb +49 -0
  25. data/lib/coop_al/loot_generator.rb +42 -0
  26. data/lib/coop_al/monster.rb +19 -0
  27. data/lib/coop_al/monster_definition.rb +18 -0
  28. data/lib/coop_al/path.rb +65 -0
  29. data/lib/coop_al/path_follower.rb +34 -0
  30. data/lib/coop_al/random_encounter.rb +30 -0
  31. data/lib/coop_al/random_encounter_generator.rb +37 -0
  32. data/lib/coop_al/session.rb +55 -0
  33. data/lib/coop_al/session_date_generator.rb +43 -0
  34. data/lib/coop_al/session_encounter.rb +76 -0
  35. data/lib/coop_al/session_log.rb +51 -0
  36. data/lib/coop_al/state.rb +85 -0
  37. data/lib/coop_al/state_reporter.rb +72 -0
  38. data/lib/coop_al/trace.rb +29 -0
  39. data/lib/coop_al/treasure.rb +21 -0
  40. data/lib/coop_al/treasure_tables.rb +28 -0
  41. data/lib/coop_al/value.rb +167 -0
  42. data/lib/coop_al/version.rb +3 -0
  43. data/lib/coop_al/xp.rb +86 -0
  44. data/lib/coop_al.rb +34 -0
  45. data/res/srd.rb +400 -0
  46. metadata +188 -0
@@ -0,0 +1,148 @@
1
+ module CoopAl
2
+ ##
3
+ # Encounter
4
+ #
5
+ class Encounter
6
+ attr_reader :name
7
+
8
+ def initialize(name, parent)
9
+ @name = name
10
+ @parent = parent
11
+ @monsters = []
12
+ @xp = 0
13
+ @loot = Loot.empty
14
+ @sub_encounters = []
15
+ end
16
+
17
+ def adventure_name
18
+ @parent.adventure_name
19
+ end
20
+
21
+ def full_name
22
+ if @parent.nil?
23
+ @name
24
+ else
25
+ @parent.full_name + ' - ' + @name
26
+ end
27
+ end
28
+
29
+ def add_sub_encounter(encounter)
30
+ @sub_encounters << encounter
31
+ end
32
+
33
+ def add_monster(monster)
34
+ @monsters << monster
35
+ end
36
+
37
+ def add_treasure(treasure)
38
+ @loot.add_treasure(treasure)
39
+ end
40
+
41
+ def add_item(item)
42
+ @loot.add_item(item)
43
+ end
44
+
45
+ def add_xp(amount)
46
+ @xp += amount
47
+ end
48
+
49
+ def run(state, log)
50
+ run_sub_encounters(state, log)
51
+ trace unless empty?
52
+ log.record_encounter(adventure_name, full_name, monster_names, total_xp, total_loot)
53
+ state.add_xp(total_xp)
54
+ state.add_loot(total_loot)
55
+ end
56
+
57
+ def empty?
58
+ @monsters.empty? && @xp.zero? && @loot.empty?
59
+ end
60
+
61
+ private
62
+
63
+ def trace
64
+ Trace.instance.info("Encounter: #{full_name}")
65
+ Trace.instance.info("Fighting: #{all_monster_names}") unless @monsters.empty?
66
+ trace_xp
67
+ trace_loot
68
+ end
69
+
70
+ def trace_xp
71
+ Trace.instance.info("Monster XP: #{monster_xp}") unless @monsters.empty?
72
+ Trace.instance.info("Encounter XP: #{encounter_xp}") unless @xp.zero?
73
+ end
74
+
75
+ def trace_loot
76
+ trace_treasure
77
+ trace_items
78
+ end
79
+
80
+ def trace_treasure
81
+ Trace.instance.info("Encounter Treasure: #{encounter_treasure}") unless encounter_loot.treasures.empty?
82
+ Trace.instance.info("Monster Treasure: #{monster_treasure}") unless monster_loot.treasures.empty?
83
+ end
84
+
85
+ def trace_items
86
+ Trace.instance.info("Encounter Items: #{encounter_items}") unless encounter_loot.items.empty?
87
+ Trace.instance.info("Monster Items: #{monster_items}") unless monster_loot.items.empty?
88
+ end
89
+
90
+ def all_monster_names
91
+ @monsters.map(&:id).join(', ')
92
+ end
93
+
94
+ def total_xp
95
+ encounter_xp + monster_xp
96
+ end
97
+
98
+ def encounter_xp
99
+ @xp
100
+ end
101
+
102
+ def monster_xp
103
+ @monsters.inject(0) do |total, monster|
104
+ total + monster.xp
105
+ end
106
+ end
107
+
108
+ def total_loot
109
+ encounter_loot + monster_loot
110
+ end
111
+
112
+ def encounter_loot
113
+ @loot
114
+ end
115
+
116
+ def encounter_treasure
117
+ encounter_loot.treasures.map(&:to_s).join(', ')
118
+ end
119
+
120
+ def encounter_items
121
+ encounter_loot.items.map(&:to_s).join(', ')
122
+ end
123
+
124
+ def monster_loot
125
+ @monsters.inject(Loot.empty) do |loot, monster|
126
+ loot + monster.loot
127
+ end
128
+ end
129
+
130
+ def monster_items
131
+ monster_loot.items.map(&:to_s).join(', ')
132
+ end
133
+
134
+ def monster_treasure
135
+ monster_loot.treasures.map(&:to_s).join(', ')
136
+ end
137
+
138
+ def monster_names
139
+ @monsters.map(&:to_s)
140
+ end
141
+
142
+ def run_sub_encounters(state, log)
143
+ @sub_encounters.each do |encounter|
144
+ encounter.run(state, log)
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,67 @@
1
+ module CoopAl
2
+ ##
3
+ # EncounterGenerator
4
+ #
5
+ class EncounterGenerator
6
+ def initialize(name, parent, bestiary)
7
+ @bestiary = bestiary
8
+ @encounter = Encounter.new(name, parent)
9
+ end
10
+
11
+ def generate_encounter(&blk)
12
+ instance_eval(&blk)
13
+ @encounter
14
+ end
15
+
16
+ def monsters(count, id, treasure = :default)
17
+ expand_count(count).times do
18
+ @encounter.add_monster(@bestiary.create(id, treasure, @encounter))
19
+ end
20
+ end
21
+
22
+ def monster(id, treasure = :default)
23
+ monsters(1, id, treasure)
24
+ end
25
+
26
+ def treasure(value, description = nil)
27
+ @encounter.add_treasure(Treasure.new(value, description))
28
+ end
29
+
30
+ def items(count, description)
31
+ @encounter.add_item(Item.new(expand_count(count), description, @encounter))
32
+ end
33
+
34
+ def item(description)
35
+ items(1, description)
36
+ end
37
+
38
+ def encounter(name, &blk)
39
+ generator = EncounterGenerator.new(name, @encounter, @bestiary)
40
+ @encounter.add_sub_encounter(generator.generate_encounter(&blk))
41
+ end
42
+
43
+ def random(name, &blk)
44
+ generator = RandomEncounterGenerator.new(name, @encounter, @bestiary)
45
+ @encounter.add_sub_encounter(generator.generate_encounter(&blk))
46
+ end
47
+
48
+ def xp(amount)
49
+ @encounter.add_xp(amount)
50
+ end
51
+
52
+ def npc(cr)
53
+ xp(XpRewardTable.new[cr])
54
+ end
55
+
56
+ def npcs(count, cr)
57
+ xp(count * XpRewardTable.new[cr])
58
+ end
59
+
60
+ private
61
+
62
+ def expand_count(count)
63
+ return roll_dice(count) if count.is_a?(String)
64
+ count
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,7 @@
1
+ module CoopAl
2
+ ##
3
+ # Exception
4
+ #
5
+ class Exception < StandardError
6
+ end
7
+ end
@@ -0,0 +1,30 @@
1
+ module CoopAl
2
+ ##
3
+ # Item
4
+ #
5
+ class Item
6
+ attr_reader :description
7
+
8
+ def initialize(count, description, encounter)
9
+ @count = count
10
+ @description = description
11
+ @encounter = encounter
12
+ end
13
+
14
+ def to_s
15
+ if @count == 1
16
+ @description
17
+ else
18
+ "#{@description} (#{@count})"
19
+ end
20
+ end
21
+
22
+ def description_with_origin
23
+ "#{self} (from #{origin})"
24
+ end
25
+
26
+ def origin
27
+ @encounter.full_name
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,54 @@
1
+ require 'singleton'
2
+
3
+ module CoopAl
4
+ ##
5
+ # Library
6
+ #
7
+ class Library
8
+ include Singleton
9
+
10
+ def initialize
11
+ @adventures = {}
12
+ end
13
+
14
+ def empty?
15
+ @adventures.empty?
16
+ end
17
+
18
+ def add_adventure(adventure)
19
+ raise Exception, 'Duplicate adventure' if @adventures.key?(adventure.name)
20
+ @adventures[adventure.name] = adventure
21
+ end
22
+
23
+ def adventure?(name)
24
+ @adventures.key?(name)
25
+ end
26
+
27
+ def adventure(name)
28
+ @adventures[name]
29
+ end
30
+
31
+ def path?(path)
32
+ raise Exception, "Cannot resolve relative path (#{path})" if path.relative?
33
+ return false unless @adventures.key?(path.adventure)
34
+ @adventures[path.adventure].chapter?(path.chapter)
35
+ end
36
+
37
+ def resolve(path)
38
+ raise Exception, "Adventure (#{path.adventure}) not found" unless @adventures.key?(path.adventure)
39
+ @adventures[path.adventure].chapter(path.chapter)
40
+ end
41
+
42
+ def all_entries
43
+ @adventures.values.inject([]) { |a, e| a + e.all_entries }
44
+ end
45
+
46
+ def available_paths_from(path)
47
+ return all_entries if path.root?
48
+ current_chapter = resolve(path)
49
+ paths = current_chapter.links
50
+ paths << Path.root if current_chapter.links_to_downtime?
51
+ paths
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,4 @@
1
+ def adventure(name, description = nil, &blk)
2
+ generator = CoopAl::AdventureGenerator.new(name, description, CoopAl::Bestiary.instance)
3
+ generator.instance_eval(&blk)
4
+ end
@@ -0,0 +1,49 @@
1
+ module CoopAl
2
+ ##
3
+ # Loot
4
+ #
5
+ class Loot
6
+ attr_reader :treasures, :items
7
+
8
+ def initialize(treasures, items)
9
+ @treasures = treasures
10
+ @items = items
11
+ end
12
+
13
+ def empty?
14
+ return false unless @treasures.empty?
15
+ return false unless @items.empty?
16
+ true
17
+ end
18
+
19
+ def add_treasure(treasure)
20
+ @treasures << treasure
21
+ end
22
+
23
+ def add_item(item)
24
+ @items << item
25
+ end
26
+
27
+ def +(other)
28
+ @treasures += other.treasures
29
+ @items += other.items
30
+ self
31
+ end
32
+
33
+ def treasure_value
34
+ @treasures.inject(Value.new) { |a, e| a + e.value }
35
+ end
36
+
37
+ def self.empty
38
+ Loot.new([], [])
39
+ end
40
+
41
+ def self.from_treasure(treasure)
42
+ Loot.new([treasure], [])
43
+ end
44
+
45
+ def self.from_item(item)
46
+ Loot.new([], [item])
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,42 @@
1
+ module CoopAl
2
+ ##
3
+ # LootGenerator
4
+ #
5
+ class LootGenerator
6
+ def initialize
7
+ end
8
+
9
+ def generate(cr, treasure)
10
+ return Loot.empty if treasure == :no_treasure
11
+ return generate_individual(cr) if treasure == :individual
12
+ generate_hoard(cr)
13
+ end
14
+
15
+ private
16
+
17
+ def generate_individual(cr)
18
+ table_name = individual_treasure_table_by_cr(cr)
19
+ treasure = Treasure.new(roll_on(table_name))
20
+ Loot.from_treasure(treasure)
21
+ end
22
+
23
+ def individual_treasure_table_by_cr(cr)
24
+ value = cr_value(cr)
25
+ return :individual_treasure_cr_0_4 if value <= 4
26
+ return :individual_treasure_cr_5_10 if value <= 10
27
+ return :individual_treasure_cr_11_16 if value <= 16
28
+ :individual_treasure_cr_17_
29
+ end
30
+
31
+ def cr_value(cr)
32
+ return 0.125 if cr == :cr1_8
33
+ return 0.25 if cr == :cr1_4
34
+ return 0.5 if cr == :cr1_2
35
+ cr[2..-1].to_i
36
+ end
37
+
38
+ def generate_hoard(_cr)
39
+ raise 'Hoard loot not implemented'
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,19 @@
1
+ module CoopAl
2
+ ##
3
+ # Monster
4
+ #
5
+ class Monster
6
+ attr_reader :id, :xp, :loot, :encounter
7
+
8
+ def initialize(id, xp, loot, encounter)
9
+ @id = id
10
+ @xp = xp
11
+ @loot = loot
12
+ @encounter = encounter
13
+ end
14
+
15
+ def to_s
16
+ @id.to_s
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,18 @@
1
+ module CoopAl
2
+ ##
3
+ # MonsterDefinition
4
+ #
5
+ class MonsterDefinition
6
+ attr_reader :id, :cr, :treasure
7
+
8
+ def initialize(id, cr, treasure)
9
+ @id = id
10
+ @cr = cr
11
+ @treasure = treasure
12
+ end
13
+
14
+ def ==(other)
15
+ @id == other.id && @cr == other.cr && @treasure == other.treasure
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,65 @@
1
+ module CoopAl
2
+ ##
3
+ # Path
4
+ #
5
+ class Path
6
+ attr_reader :adventure, :chapter
7
+
8
+ def initialize(adventure, chapter)
9
+ @adventure = adventure
10
+ @chapter = chapter
11
+ end
12
+
13
+ def root?
14
+ @adventure.nil? && @chapter.nil?
15
+ end
16
+
17
+ def relative?
18
+ @adventure.nil? && !@chapter.nil?
19
+ end
20
+
21
+ def absolute?
22
+ !relative?
23
+ end
24
+
25
+ def self.absolute(adventure, chapter)
26
+ Path.new(adventure, chapter)
27
+ end
28
+
29
+ def self.relative(chapter)
30
+ Path.new(nil, chapter)
31
+ end
32
+
33
+ def self.root
34
+ Path.new(nil, nil)
35
+ end
36
+
37
+ def self.parse(path)
38
+ tokens = path.split('/').map(&:to_sym)
39
+ raise "Invalid path #{path}" if tokens.count > 2
40
+ return absolute(tokens[0], tokens[1]) if tokens.count == 2
41
+ return root if tokens[0] == :downtime
42
+ relative(tokens[0])
43
+ end
44
+
45
+ def ==(other)
46
+ to_s == other.to_s
47
+ end
48
+
49
+ def +(other)
50
+ local_path = other.is_a?(Path) ? other : Path.parse(other)
51
+ return local_path if local_path.absolute?
52
+ raise 'Cannot add two relative paths' if relative?
53
+ Path.absolute(@adventure, other.chapter)
54
+ end
55
+
56
+ def to_s
57
+ adventure_s + @chapter.to_s
58
+ end
59
+
60
+ def adventure_s
61
+ return @adventure.to_s + '/' if absolute?
62
+ ''
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,34 @@
1
+ module CoopAl
2
+ ##
3
+ # PathFollower
4
+ #
5
+ class PathFollower
6
+ attr_reader :state
7
+
8
+ def initialize(library, starting_state)
9
+ @library = library
10
+ @state = starting_state
11
+ end
12
+
13
+ def follow(paths, log)
14
+ paths.each do |path|
15
+ follow_path(path, log)
16
+ end
17
+ @state
18
+ end
19
+
20
+ private
21
+
22
+ def follow_path(path, log)
23
+ if path.root?
24
+ log.record_downtime(@library.resolve(@state.current_path).adventure_name)
25
+ @state.apply_path(path)
26
+ else
27
+ raise Exception, "#{path} not a valid next path" unless @library.path?(@state.current_path + path)
28
+ raise Exception, "Cannot repeat path (#{path})" if @state.history_includes?(@state.current_path + path)
29
+ @state.apply_path(path)
30
+ @library.resolve(@state.current_path).follow(@state, log)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,30 @@
1
+ module CoopAl
2
+ ##
3
+ # RandomEncounter
4
+ #
5
+ class RandomEncounter
6
+ def initialize(name, parent)
7
+ @name = name
8
+ @parent = parent
9
+ @entries = []
10
+ end
11
+
12
+ def add_entry(entry)
13
+ @entries << entry
14
+ end
15
+
16
+ def set_entry(roll, entry)
17
+ @entries[roll - 1] = entry
18
+ end
19
+
20
+ def set_range(range, entry)
21
+ range.each do |i|
22
+ set_entry(i, entry)
23
+ end
24
+ end
25
+
26
+ def run(state, log)
27
+ @entries[roll_dice("d#{@entries.count}") - 1].run(state, log)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,37 @@
1
+ module CoopAl
2
+ ##
3
+ # RandomEncounterGenerator
4
+ #
5
+ class RandomEncounterGenerator
6
+ def initialize(name, parent, bestiary)
7
+ @name = name
8
+ @parent = parent
9
+ @bestiary = bestiary
10
+ @encounter = RandomEncounter.new(name, parent)
11
+ end
12
+
13
+ def generate_encounter(&blk)
14
+ instance_eval(&blk)
15
+ @encounter
16
+ end
17
+
18
+ def fixed(*args, &blk)
19
+ generator = EncounterGenerator.new(@name, @parent, @bestiary)
20
+ entry = generator.generate_encounter(&blk)
21
+ if args.empty?
22
+ @encounter.add_entry(entry)
23
+ else
24
+ roll = args.shift
25
+ if roll.is_a?(Integer)
26
+ @encounter.set_entry(roll, entry)
27
+ elsif roll.is_a?(Range)
28
+ @encounter.set_range(roll, entry)
29
+ else
30
+ raise "Inappropriate roll descriptor (#{roll})"
31
+ end
32
+ end
33
+ end
34
+
35
+ alias f fixed
36
+ end
37
+ end
@@ -0,0 +1,55 @@
1
+ module CoopAl
2
+ ##
3
+ # Session
4
+ #
5
+ class Session
6
+ def initialize(number, date_generator, adventure_name, dm_name, starting_xp, starting_treasure, encounter_count)
7
+ @number = number
8
+ @date_generator = date_generator
9
+ @adventure_name = adventure_name
10
+ @dm_name = dm_name
11
+ @starting_xp = starting_xp
12
+ @starting_treasure = starting_treasure
13
+ @encounter_count = encounter_count
14
+ @encounters = []
15
+
16
+ @date_generator.add_session
17
+ end
18
+
19
+ def add_encounter(encounter)
20
+ @encounters << encounter
21
+ end
22
+
23
+ def done?
24
+ @encounters.count { |e| e.counts? } == @encounter_count
25
+ end
26
+
27
+ def dump(s)
28
+ s.puts "Adventure: #{@adventure_name}"
29
+ s.puts "Session ##{@number}: #{@date_generator.session(@number)}"
30
+ s.puts "DM: #{@dm_name}"
31
+ s.puts "Starting XP: #{@starting_xp} (level #{level(@starting_xp)})"
32
+ s.puts "XP Earned: #{xp_earned}"
33
+ s.puts "XP Total: #{@starting_xp + xp_earned} (level #{level(@starting_xp + xp_earned)})"
34
+ s.puts "Starting Treasure: #{@starting_treasure}"
35
+ s.puts "Treasure +/-: #{treasure_earned}"
36
+ s.puts "Treasure Total: #{@starting_treasure + treasure_earned}"
37
+ @encounters.each { |e| e.dump(s) if e.counts? }
38
+ s.puts
39
+ end
40
+
41
+ private
42
+
43
+ def xp_earned
44
+ @encounters.inject(0) { |a, e| a + e.xp }
45
+ end
46
+
47
+ def treasure_earned
48
+ @encounters.inject(Value.new) { |a, e| a + e.treasure }
49
+ end
50
+
51
+ def level(xp)
52
+ XpRequirementTable.new.level_from_xp(xp)
53
+ end
54
+ end
55
+ end