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