amar-tui 2.1.1 → 2.1.5

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/amar-tui.rb +8198 -0
  3. data/cli_enc_output.rb +87 -0
  4. data/cli_enc_output_new.rb +433 -0
  5. data/cli_enc_output_new_3tier.rb +198 -0
  6. data/cli_enc_output_new_compact.rb +238 -0
  7. data/cli_name_gen.rb +21 -0
  8. data/cli_npc_output.rb +279 -0
  9. data/cli_npc_output_new.rb +752 -0
  10. data/cli_town_output.rb +39 -0
  11. data/cli_weather_output.rb +36 -0
  12. data/includes/class_enc.rb +341 -0
  13. data/includes/class_enc_new.rb +512 -0
  14. data/includes/class_monster_new.rb +551 -0
  15. data/includes/class_npc.rb +1378 -0
  16. data/includes/class_npc_new.rb +1322 -0
  17. data/includes/class_npc_new.rb.backup +706 -0
  18. data/includes/class_npc_new_skills.rb +153 -0
  19. data/includes/class_town.rb +237 -0
  20. data/includes/d6s.rb +40 -0
  21. data/includes/equipment_tables.rb +120 -0
  22. data/includes/functions.rb +67 -0
  23. data/includes/includes.rb +30 -0
  24. data/includes/randomizer.rb +15 -0
  25. data/includes/spell_catalog.rb +446 -0
  26. data/includes/tables/armour.rb +13 -0
  27. data/includes/tables/chartype.rb +4412 -0
  28. data/includes/tables/chartype_new.rb +765 -0
  29. data/includes/tables/chartype_new_full.rb +2713 -0
  30. data/includes/tables/enc_specific.rb +168 -0
  31. data/includes/tables/enc_type.rb +17 -0
  32. data/includes/tables/encounters.rb +99 -0
  33. data/includes/tables/magick.rb +169 -0
  34. data/includes/tables/melee.rb +36 -0
  35. data/includes/tables/missile.rb +17 -0
  36. data/includes/tables/monster_stats_new.rb +264 -0
  37. data/includes/tables/month.rb +18 -0
  38. data/includes/tables/names.rb +21 -0
  39. data/includes/tables/personality.rb +12 -0
  40. data/includes/tables/race_templates.rb +318 -0
  41. data/includes/tables/religions.rb +266 -0
  42. data/includes/tables/spells_new.rb +496 -0
  43. data/includes/tables/tier_system.rb +104 -0
  44. data/includes/tables/town.rb +71 -0
  45. data/includes/tables/weather.rb +41 -0
  46. data/includes/town_relations.rb +127 -0
  47. data/includes/weather.rb +108 -0
  48. data/includes/weather2latex.rb +114 -0
  49. data/lib/rcurses.rb +33 -0
  50. metadata +116 -10
@@ -0,0 +1,752 @@
1
+ # Compact output module for new 3-tier NPC system with full details
2
+ require 'io/console'
3
+
4
+ # Try to load string_extensions for color support
5
+ begin
6
+ require 'string_extensions'
7
+ rescue LoadError
8
+ # Define a fallback pure method if string_extensions is not available
9
+ class String
10
+ def pure
11
+ self.gsub(/\e\[[0-9;]*m/, '')
12
+ end
13
+ end
14
+ end
15
+
16
+ def npc_output_new(n, cli, custom_width = nil)
17
+ # Clear screen before displaying character (only for true CLI mode)
18
+ if cli == "cli_direct"
19
+ system("clear") || system("cls") # Works on both Unix and Windows
20
+ end
21
+
22
+ f = ""
23
+
24
+ # Use UTF-8 box drawing characters (no side borders for easy copy-paste)
25
+ # Adjust width based on output mode
26
+ if custom_width
27
+ width = custom_width
28
+ elsif cli == "cli_direct"
29
+ width = 120
30
+ else
31
+ width = 80 # Default for other modes
32
+ end
33
+
34
+ # Define colors if terminal output
35
+ if cli == "cli"
36
+ # Colors for different elements
37
+ @header_color = "\e[1;36m" # Bright cyan
38
+ @char_color = "\e[1;33m" # Bright yellow
39
+ @attr_color = "\e[1;35m" # Bright magenta
40
+ @skill_color = "\e[0;37m" # White
41
+ @stat_color = "\e[1;32m" # Bright green
42
+ @weapon_color = "\e[38;5;202m" # Brighter red (202)
43
+ @spell_color = "\e[0;34m" # Blue
44
+ @desc_color = "\e[38;5;229m" # Light color for description
45
+ @reset = "\e[0m"
46
+ else
47
+ @header_color = @char_color = @attr_color = @skill_color = ""
48
+ @stat_color = @weapon_color = @spell_color = @desc_color = @reset = ""
49
+ end
50
+
51
+ # Compact header: Name (sex age) H/W: height/weight with right-justified type
52
+ name_hw_part = "#{n.name} (#{n.sex} #{n.age}) H/W: #{n.height}cm/#{n.weight}kg"
53
+ type_part = "#{n.type} (#{n.level})"
54
+ spaces_needed = width - name_hw_part.length - type_part.length
55
+ f += "#{@header_color}#{name_hw_part}#{' ' * spaces_needed}#{type_part}#{@reset}\n"
56
+
57
+ # Area and Social Status on same line, with status right-justified
58
+ social_status = generate_social_status(n.type, n.level)
59
+ area_part = "Area: #{n.area}"
60
+ status_part = "Social Status: #{social_status}"
61
+ spaces_needed = width - area_part.length - status_part.length
62
+ f += "#{area_part}#{' ' * spaces_needed}#{status_part}\n"
63
+
64
+
65
+ # Add religion line for priests and anyone with Attunement
66
+ attunement_level = 0
67
+ if n.tiers && n.tiers["SPIRIT"] && n.tiers["SPIRIT"]["Attunement"]
68
+ attunement_level = n.tiers["SPIRIT"]["Attunement"]["level"] || 0
69
+ end
70
+
71
+ # Check if NPC should have religion info (skip for monsters)
72
+ is_humanoid = !n.type.to_s.match(/Monster:|Animal:|monster/i)
73
+ if is_humanoid && (["Priest", "Clergyman", "Monk"].include?(n.type) || (n.respond_to?(:has_magic?) && n.has_magic?) || attunement_level > 0)
74
+ cult_info = generate_cult_info(n.type, n.level, attunement_level, n.sex)
75
+ f += "#{cult_info}\n" if cult_info
76
+ end
77
+
78
+ # Add description line with correct light color (229)
79
+ description = n.description && !n.description.empty? ? n.description : generate_description(n.type, n.level, n.sex)
80
+ if cli == "cli"
81
+ f += "#{@desc_color}Description: #{description}#{@reset}\n"
82
+ else
83
+ f += "Description: #{description}\n"
84
+ end
85
+ f += "─" * width + "\n"
86
+
87
+ # Three-column layout for skills
88
+ col_width = 40
89
+ columns = [[], [], []]
90
+
91
+ # BODY hierarchy with non-zero skills
92
+ body_char = n.get_characteristic('BODY')
93
+ body_lines = ["#{@char_color}BODY (#{body_char.to_s.rjust(2)})#{@reset}"]
94
+ n.tiers["BODY"].each do |attr_name, attr_data|
95
+ # Skip if attr_data is not a hash or doesn't have skills
96
+ next unless attr_data.is_a?(Hash) && attr_data["skills"].is_a?(Hash)
97
+
98
+ non_zero_skills = attr_data["skills"].select { |_, v| v > 0 }
99
+
100
+ # For Melee Combat and Missile Combat, show attribute but not individual weapon skills
101
+ if attr_name == "Melee Combat" || attr_name == "Missile Combat"
102
+ # Show attribute only if there are any skills
103
+ if non_zero_skills.any?
104
+ attr_level = attr_data["level"]
105
+ body_lines << " #{@attr_color}#{attr_name} (#{attr_level.to_s.rjust(2)})#{@reset}"
106
+ end
107
+ next # Skip individual weapon skills
108
+ end
109
+
110
+ next if non_zero_skills.empty?
111
+
112
+ attr_level = attr_data["level"]
113
+ body_lines << " #{@attr_color}#{attr_name} (#{attr_level.to_s.rjust(2)})#{@reset}"
114
+ non_zero_skills.each do |skill_name, skill_level|
115
+ total = body_char + attr_level + skill_level
116
+ skill_display = " #{skill_name} (#{skill_level.to_s.rjust(2)})"
117
+ body_lines << "#{@skill_color}#{skill_display.ljust(30)}: #{total.to_s.rjust(2)}#{@reset}"
118
+ end
119
+ end
120
+
121
+ # MIND hierarchy with non-zero skills
122
+ mind_char = n.get_characteristic('MIND')
123
+ mind_lines = ["#{@char_color}MIND (#{mind_char.to_s.rjust(2)})#{@reset}"]
124
+ n.tiers["MIND"].each do |attr_name, attr_data|
125
+ # Skip if attr_data is not a hash or doesn't have skills
126
+ next unless attr_data.is_a?(Hash) && attr_data["skills"].is_a?(Hash)
127
+
128
+ non_zero_skills = attr_data["skills"].select { |_, v| v > 0 }
129
+ next if non_zero_skills.empty?
130
+
131
+ attr_level = attr_data["level"]
132
+ mind_lines << " #{@attr_color}#{attr_name} (#{attr_level.to_s.rjust(2)})#{@reset}"
133
+ non_zero_skills.each do |skill_name, skill_level|
134
+ total = mind_char + attr_level + skill_level
135
+ skill_display = " #{skill_name} (#{skill_level.to_s.rjust(2)})"
136
+ mind_lines << "#{@skill_color}#{skill_display.ljust(30)}: #{total.to_s.rjust(2)}#{@reset}"
137
+ end
138
+ end
139
+
140
+ # SPIRIT hierarchy with non-zero skills
141
+ spirit_char = n.get_characteristic("SPIRIT")
142
+ spirit_lines = []
143
+ spirit_attrs_with_skills = n.tiers["SPIRIT"].select do |_, attr_data|
144
+ attr_data.is_a?(Hash) && attr_data["skills"].is_a?(Hash) && attr_data["skills"].any? { |_, v| v > 0 }
145
+ end
146
+
147
+ if !spirit_attrs_with_skills.empty?
148
+ spirit_lines << "#{@char_color}SPIRIT (#{spirit_char.to_s.rjust(2)})#{@reset}"
149
+ n.tiers["SPIRIT"].each do |attr_name, attr_data|
150
+ # Skip if attr_data is not a hash or doesn't have skills
151
+ next unless attr_data.is_a?(Hash) && attr_data["skills"].is_a?(Hash)
152
+
153
+ non_zero_skills = attr_data["skills"].select { |_, v| v > 0 }
154
+ next if non_zero_skills.empty?
155
+
156
+ attr_level = attr_data["level"]
157
+ spirit_lines << " #{@attr_color}#{attr_name} (#{attr_level.to_s.rjust(2)})#{@reset}"
158
+ non_zero_skills.each do |skill_name, skill_level|
159
+ total = spirit_char + attr_level + skill_level
160
+ skill_display = " #{skill_name} (#{skill_level.to_s.rjust(2)})"
161
+ spirit_lines << "#{@skill_color}#{skill_display.ljust(30)}: #{total.to_s.rjust(2)}#{@reset}"
162
+ end
163
+ end
164
+ end
165
+
166
+ # Distribute content across three columns more evenly
167
+ all_lines = []
168
+ all_lines.concat(body_lines) unless body_lines.length == 1
169
+ all_lines.concat(mind_lines) unless mind_lines.length == 1
170
+ all_lines.concat(spirit_lines) unless spirit_lines.empty?
171
+
172
+ # Calculate lines per column for even distribution
173
+ total_lines = all_lines.length
174
+ lines_per_col = (total_lines / 3.0).ceil
175
+
176
+ # Fill columns
177
+ all_lines.each_with_index do |line, idx|
178
+ col_idx = idx / lines_per_col
179
+ col_idx = 2 if col_idx > 2
180
+ columns[col_idx] << line
181
+ end
182
+
183
+ # Balance columns
184
+ max_lines = columns.map(&:length).max
185
+ columns.each { |col| col.concat([""] * (max_lines - col.length)) }
186
+
187
+ # Print three columns with vertical separators
188
+ max_lines.times do |i|
189
+ line = ""
190
+ columns.each_with_index do |col, idx|
191
+ # Strip color codes for length calculation
192
+ plain_text = col[i].to_s.gsub(/\e\[[0-9;]*m/, '')
193
+ padding = col_width - plain_text.length
194
+ line += col[i].to_s + " " * padding
195
+ line += "│" if idx < 2
196
+ end
197
+ f += line.rstrip + "\n"
198
+ end
199
+
200
+ # Stats line
201
+ f += "─" * width + "\n"
202
+
203
+ # Calculate proper encumbrance based on weight carried
204
+ total_weight = calculate_total_weight(n)
205
+
206
+ # Calculate carrying capacity based on Strength
207
+ strength = n.tiers["BODY"]["Strength"]["level"] || 2
208
+ base_capacity = strength * 5 # Base carrying capacity in kg
209
+
210
+ # Calculate ENC penalty based on Amar rules
211
+ enc_penalty = if total_weight <= base_capacity
212
+ 0 # No penalty under normal load
213
+ elsif total_weight <= base_capacity * 2
214
+ -1 # Light encumbrance
215
+ elsif total_weight <= base_capacity * 5
216
+ -3 # Medium encumbrance
217
+ elsif total_weight <= base_capacity * 10
218
+ -5 # Heavy encumbrance
219
+ else
220
+ -7 # Extreme encumbrance (not -10)
221
+ end
222
+
223
+ # Format SIZE for display (3.5 becomes "3½")
224
+ size_display = n.SIZE % 1 == 0.5 ? "#{n.SIZE.floor}½" : n.SIZE.to_s
225
+ # Calculate combat totals for the summary line
226
+ dodge_total = n.get_skill_total("BODY", "Athletics", "Dodge") rescue 0
227
+ reaction_total = n.get_skill_total("BODY", "Athletics", "Reaction Speed") rescue 0
228
+
229
+ f += "#{@stat_color}SIZE: #{size_display} BP:#{n.BP.to_s} DB: #{n.DB.to_s} MD: #{n.MD.to_s} Reaction: #{reaction_total} Dodge: #{dodge_total} Carried: #{total_weight}kg ENC: #{enc_penalty}#{@reset}\n"
230
+
231
+ # Armor section - use original format if available
232
+ f += "─" * width + "\n"
233
+ if n.respond_to?(:armour) && !n.armour.to_s.empty?
234
+ # Use original armor format
235
+ f += "#{@weapon_color}ARMOR: #{n.armour.ljust(20)} AP: #{n.ap.to_s.rjust(2)} Weight: #{get_armor_weight(n.armour).to_s.rjust(2)}kg#{@reset}\n"
236
+ elsif n.armor && n.armor[:name] && !n.armor[:name].empty?
237
+ # Fall back to new format
238
+ f += "#{@weapon_color}ARMOR: #{n.armor[:name].ljust(20)} AP: #{n.armor[:ap].to_s.rjust(2)} Weight: #{get_armor_weight(n.armor[:name]).to_s.rjust(2)}kg#{@reset}\n"
239
+ else
240
+ # No armor
241
+ f += "#{@weapon_color}ARMOR: None AP: 0 Weight: 0kg#{@reset}\n"
242
+ end
243
+
244
+ # Check if we have original weapon data and display it
245
+ # DISABLED: Old weapon format only shows 2-3 weapons, new format shows all skills
246
+ if false && n.respond_to?(:melee1) && !n.melee1.to_s.empty?
247
+ # Display ORIGINAL weapon format (DEPRECATED)
248
+ f += "─" * width + "\n"
249
+ f += "#{@weapon_color}WEAPON SKILL INI OFF DEF DAM HP RANGE#{@reset}\n"
250
+
251
+ # Melee weapon 1
252
+ if n.melee1s && n.melee1s > 0
253
+ f += "#{n.melee1.ljust(19)}"
254
+ f += "#{n.melee1s.to_s.ljust(9)}"
255
+ f += "#{n.melee1i.to_s.ljust(8)}"
256
+ f += "#{n.melee1o.to_s.ljust(7)}"
257
+ f += "#{n.melee1d.to_s.ljust(7)}"
258
+ f += "#{n.melee1dam.to_s.ljust(7)}"
259
+ f += "#{n.melee1hp.to_s.ljust(6)}"
260
+ f += "\n"
261
+ end
262
+
263
+ # Melee weapon 2
264
+ if n.respond_to?(:melee2s) && n.melee2s && n.melee2s > 0
265
+ f += "#{n.melee2.ljust(19)}"
266
+ f += "#{n.melee2s.to_s.ljust(9)}"
267
+ f += "#{n.melee2i.to_s.ljust(8)}"
268
+ f += "#{n.melee2o.to_s.ljust(7)}"
269
+ f += "#{n.melee2d.to_s.ljust(7)}"
270
+ f += "#{n.melee2dam.to_s.ljust(7)}"
271
+ f += "#{n.melee2hp.to_s.ljust(6)}"
272
+ f += "\n"
273
+ end
274
+
275
+ # Missile weapon
276
+ if n.respond_to?(:missiles) && n.missiles && n.missiles > 0
277
+ f += "#{n.missile.ljust(19)}"
278
+ f += "#{n.missiles.to_s.ljust(9)}"
279
+ f += "#{' '.ljust(8)}" # No init for missile
280
+ f += "#{n.missileo.to_s.ljust(7)}"
281
+ f += "#{' '.ljust(7)}" # No def for missile
282
+ f += "#{n.missiledam.to_s.ljust(7)}"
283
+ f += "#{n.melee1hp.to_s.ljust(6)}" # Use melee1 HP as placeholder
284
+ f += "#{n.missilerange}m"
285
+ f += "\n"
286
+ end
287
+ else
288
+ # 3-tier weapon display - show ALL weapon skills with actual weapon names
289
+ melee_weapons = n.tiers["BODY"]["Melee Combat"]["skills"].select { |_, v| v > 0 } rescue {}
290
+ missile_weapons = n.tiers["BODY"]["Missile Combat"]["skills"].select { |_, v| v > 0 } rescue {}
291
+ actual_melee = (n.tiers["BODY"]["Melee Combat"]["actual_weapons"] rescue nil) || {}
292
+ actual_missile = (n.tiers["BODY"]["Missile Combat"]["actual_weapons"] rescue nil) || {}
293
+
294
+ if melee_weapons.any? || missile_weapons.any?
295
+ # Load weapon tables for stats lookup
296
+ unless defined?($Melee)
297
+ load File.join($pgmdir, "includes/tables/melee.rb")
298
+ end
299
+ unless defined?($Missile)
300
+ load File.join($pgmdir, "includes/tables/missile.rb")
301
+ end
302
+
303
+ f += "─" * width + "\n"
304
+ f += "#{@weapon_color}WEAPON SKILL INI OFF DEF DAM HP RANGE#{@reset}\n"
305
+
306
+ body_char = n.get_characteristic("BODY")
307
+
308
+ # Calculate Dodge bonus for defense (Dodge/5 rounded down)
309
+ dodge_total = n.get_skill_total("BODY", "Athletics", "Dodge") || 0
310
+ dodge_bonus = (dodge_total / 5).to_i
311
+
312
+ # Get reaction speed for initiative
313
+ reaction_speed = n.get_skill_total("MIND", "Awareness", "Reaction speed") || 0
314
+
315
+ # Display melee weapons
316
+ melee_weapons.sort_by { |_, skill| -skill }.each do |weapon_skill, skill|
317
+ # Get actual weapon name
318
+ actual_weapon_name = actual_melee[weapon_skill] || weapon_skill
319
+
320
+ # Find weapon in $Melee table
321
+ weapon_data = $Melee.find { |w| w && w[0] && w[0].strip == actual_weapon_name.strip }
322
+
323
+ attr = n.get_attribute("BODY", "Melee Combat") || 0
324
+ skill_total = body_char + attr + skill
325
+
326
+ if weapon_data
327
+ # Use actual weapon stats from table: [Name, Type, Str, Dam, Init, Off, Def, HP, Wt]
328
+ init = reaction_speed + weapon_data[4]
329
+ off = skill_total + weapon_data[5]
330
+ defense = skill_total + weapon_data[6] + dodge_bonus
331
+ dmg = (n.DB || 0) + weapon_data[3]
332
+ hp = weapon_data[7]
333
+ else
334
+ # Fallback to pattern matching
335
+ wpn_stats = get_weapon_stats(actual_weapon_name)
336
+ init = reaction_speed + (wpn_stats[:init] || 0)
337
+ off = skill_total + (wpn_stats[:off] || 0)
338
+ defense = skill_total + (wpn_stats[:def] || 0) + dodge_bonus
339
+ dmg_mod = wpn_stats[:dmg].to_s =~ /special/ ? 0 : (wpn_stats[:dmg].to_s.to_i || 0)
340
+ dmg = (n.DB || 0) + dmg_mod
341
+ hp = wpn_stats[:hp] || 0
342
+ end
343
+
344
+ f += "#{actual_weapon_name.ljust(19)}"
345
+ f += "#{skill_total.to_s.ljust(9)}"
346
+ f += "#{init.to_s.ljust(8)}"
347
+ f += "#{off.to_s.ljust(7)}"
348
+ f += "#{defense.to_s.ljust(7)}"
349
+ f += "#{dmg.to_s.ljust(7)}"
350
+ f += "#{hp.to_s.ljust(6)}"
351
+ f += "\n"
352
+ end
353
+
354
+ # Display missile weapons
355
+ missile_weapons.sort_by { |_, skill| -skill }.each do |weapon_skill, skill|
356
+ # Get actual weapon name
357
+ actual_weapon_name = actual_missile[weapon_skill] || weapon_skill
358
+
359
+ # Find weapon in $Missile table
360
+ weapon_data = $Missile.find { |w| w && w[0] && w[0].strip == actual_weapon_name.strip }
361
+
362
+ attr = n.get_attribute("BODY", "Missile Combat") || 0
363
+ skill_total = body_char + attr + skill
364
+
365
+ if weapon_data
366
+ # Use actual weapon stats from table: [Name, Type, Str, Dam, Off, Rng, Max, Init, Wt]
367
+ off = skill_total + weapon_data[4]
368
+ dmg = (n.DB || 0) + weapon_data[3]
369
+ range = "#{weapon_data[5]}m"
370
+ hp = weapon_data[7]
371
+ else
372
+ # Fallback to pattern matching
373
+ wpn_stats = get_missile_stats(actual_weapon_name)
374
+ off = skill_total + (wpn_stats[:off] || 0)
375
+ range = wpn_stats[:range] || "30m"
376
+ dmg_mod = wpn_stats[:dmg].to_s =~ /special/ ? 0 : (wpn_stats[:dmg].to_s.to_i || 0)
377
+ dmg = (n.DB || 0) + dmg_mod
378
+ hp = wpn_stats[:hp] || 0
379
+ end
380
+
381
+ f += "#{actual_weapon_name.ljust(19)}"
382
+ f += "#{skill_total.to_s.ljust(9)}"
383
+ f += "#{' '.ljust(8)}" # No init for missile
384
+ f += "#{off.to_s.ljust(7)}"
385
+ f += "#{' '.ljust(7)}" # No def for missile
386
+ f += "#{dmg.to_s.ljust(7)}"
387
+ f += "#{hp.to_s.ljust(6)}"
388
+ f += "#{range}"
389
+ f += "\n"
390
+ end
391
+ end
392
+ end # Close the original/3-tier weapon display if/else block
393
+
394
+ # Equipment section with money
395
+ equipment = generate_equipment(n.type, n.level)
396
+ social_status = generate_social_status(n.type, n.level)
397
+ money = generate_money(social_status, n.level)
398
+
399
+ # Convert money format from "X silver" to currency abbreviations
400
+ money_value = money.split(' ').first.to_i
401
+ money_str = if money_value >= 100
402
+ gp = money_value / 100
403
+ sp = (money_value % 100) / 10
404
+ cp = money_value % 10
405
+ parts = []
406
+ parts << "#{gp}gp" if gp > 0
407
+ parts << "#{sp}sp" if sp > 0
408
+ parts << "#{cp}cp" if cp > 0
409
+ parts.join(" ")
410
+ elsif money_value >= 10
411
+ sp = money_value / 10
412
+ cp = money_value % 10
413
+ parts = []
414
+ parts << "#{sp}sp" if sp > 0
415
+ parts << "#{cp}cp" if cp > 0
416
+ parts.join(" ")
417
+ else
418
+ "#{money_value}cp"
419
+ end
420
+
421
+ f += "─" * width + "\n"
422
+ equipment_list = equipment + [money_str]
423
+ f += "EQUIPMENT: #{equipment_list.join(", ")}\n"
424
+
425
+ # Creative Spells section using new spell catalog
426
+ require_relative 'includes/spell_catalog'
427
+ spells = assign_spells_to_npc(n)
428
+
429
+ if !spells.empty?
430
+ f += "─" * width + "\n"
431
+ # Use color 165 for SPELLS title (purple for magic)
432
+ spell_title_color = cli == "cli" ? "\e[38;5;165m\e[1m" : ""
433
+ f += "#{spell_title_color}SPELLS (#{spells.length}):#{@reset}\n"
434
+
435
+ spells.each do |spell|
436
+ # Spell name and basic info
437
+ spell_name_color = cli == "cli" ? "\e[38;5;226m\e[1m" : ""
438
+ difficulty_color = cli == "cli" ? "\e[38;5;202m" : ""
439
+ desc_color = cli == "cli" ? "\e[38;5;51m" : ""
440
+
441
+ f += " • #{spell_name_color}#{spell[:name]}#{@reset} (Difficulty: #{difficulty_color}#{spell[:difficulty]}#{@reset}, Cooldown: #{difficulty_color}#{spell[:cooldown]}h#{@reset})\n"
442
+ f += " #{desc_color}#{spell[:description]}#{@reset}\n"
443
+
444
+ # Casting path and details
445
+ path_color = cli == "cli" ? "\e[38;5;111m" : ""
446
+ detail_color = cli == "cli" ? "\e[38;5;240m" : ""
447
+
448
+ f += " #{detail_color}Cast via: #{path_color}#{spell[:skill_path]}#{@reset}\n"
449
+ f += " #{detail_color}Range: #{@reset}#{spell[:range]}, #{detail_color}Duration: #{@reset}#{spell[:duration]}\n"
450
+
451
+ if spell[:side_effects] && !spell[:side_effects].empty?
452
+ side_effect_color = cli == "cli" ? "\e[38;5;196m" : ""
453
+ f += " #{detail_color}Side effect: #{side_effect_color}#{spell[:side_effects]}#{@reset}\n"
454
+ end
455
+ f += "\n"
456
+ end
457
+ end
458
+
459
+ # Equipment section
460
+
461
+ # Output handling
462
+ if cli == "cli_direct"
463
+ # This is the direct CLI mode - print and handle editing
464
+ # Save clean version without ANSI codes for editing
465
+ File.write("saved/temp_new.npc", f.pure, perm: 0644)
466
+ # Display version with colors
467
+ print f
468
+
469
+ # Options
470
+ puts "\nPress 'e' to edit, any other key to continue"
471
+ begin
472
+ key = STDIN.getch
473
+ rescue Errno::ENOTTY
474
+ # Not in a terminal, skip the interactive part
475
+ key = nil
476
+ end
477
+
478
+ if key == "e"
479
+ # Use vim with settings to avoid binary file warnings
480
+ if $editor.include?("vim") || $editor.include?("vi")
481
+ system("#{$editor} -c 'set fileformat=unix' saved/temp_new.npc")
482
+ else
483
+ system("#{$editor} saved/temp_new.npc")
484
+ end
485
+ end
486
+
487
+ return f
488
+ elsif cli == "cli"
489
+ # This is TUI mode - just return the colored string without printing
490
+ return f
491
+ else
492
+ # Plain mode - return without colors
493
+ return f
494
+ end
495
+ end
496
+
497
+ # Helper functions
498
+
499
+ def generate_cult_info(type, level, attunement_level = 0, sex = nil)
500
+ # Generate religion and cult standing for religious types
501
+ # Format: "Cult: [Religion], [Standing] (CS)"
502
+
503
+ # Load religion table if not loaded
504
+ unless defined?($CharacterReligions)
505
+ load File.join($pgmdir, "includes/tables/religions.rb")
506
+ end
507
+
508
+ # Get appropriate deity from table - pass sex for nobility handling
509
+ cult = get_character_religion(type, sex)
510
+
511
+ # Determine cult standing based on level and attunement
512
+ # Higher attunement means deeper religious involvement
513
+ standing, cs = case level
514
+ when 1..2
515
+ if attunement_level >= 3
516
+ ["Initiate", rand(3..5)]
517
+ else
518
+ ["Lay Member", rand(1..3)]
519
+ end
520
+ when 3..4
521
+ if attunement_level >= 4
522
+ ["Priest", rand(6..8)]
523
+ else
524
+ ["Initiate", rand(4..7)]
525
+ end
526
+ when 5..6
527
+ if attunement_level >= 5
528
+ ["High Priest", rand(10..12)]
529
+ else
530
+ ["Priest", rand(8..11)]
531
+ end
532
+ else
533
+ ["High Priest", rand(12..15)]
534
+ end
535
+
536
+ # Special handling for non-priest types (they progress slower in cult)
537
+ if !["Priest", "Clergyman", "Monk"].include?(type)
538
+ standing, cs = case level
539
+ when 1..3
540
+ if attunement_level >= 3
541
+ ["Initiate", rand(2..3)]
542
+ else
543
+ ["Lay Member", rand(1..2)]
544
+ end
545
+ when 4..5
546
+ if attunement_level >= 4
547
+ ["Initiate", rand(4..6)]
548
+ else
549
+ ["Initiate", rand(3..5)]
550
+ end
551
+ else
552
+ if attunement_level >= 5
553
+ ["Priest", rand(7..9)]
554
+ else
555
+ ["Initiate", rand(6..8)]
556
+ end
557
+ end
558
+ end
559
+
560
+ "Cult: #{cult}, #{standing} (#{cs})"
561
+ end
562
+
563
+ def generate_description(type, level, sex)
564
+ # Generate random description if none provided
565
+ build = ["lean", "muscular", "stocky", "thin", "athletic", "sturdy", "wiry", "robust"].sample
566
+ hair = ["dark", "brown", "blonde", "red", "grey", "black", "auburn", "silver"].sample
567
+ eyes = ["blue", "brown", "green", "grey", "hazel", "dark", "amber"].sample
568
+ feature = ["scarred", "weathered", "youthful", "stern", "friendly", "intense", "calm", "sharp"].sample
569
+
570
+ pronoun = sex == "F" ? "She" : "He"
571
+ pronoun_pos = sex == "F" ? "Her" : "His"
572
+
573
+ case type
574
+ when "Warrior", "Guard", "Soldier"
575
+ "#{build.capitalize} build with #{hair} hair. #{pronoun} has #{eyes} eyes and a #{feature} face. Battle-hardened."
576
+ when "Mage", "Wizard", "Scholar"
577
+ "#{pronoun} has #{eyes} eyes that show intelligence. #{hair.capitalize} hair, #{build} build. Scholarly demeanor."
578
+ when "Thief", "Bandit", "Assassin"
579
+ "#{feature.capitalize} features with #{eyes} eyes. #{build.capitalize} and agile. Moves with quiet confidence."
580
+ when "Noble", "Merchant"
581
+ "Well-groomed with #{hair} hair and #{eyes} eyes. #{build.capitalize} build. #{pronoun_pos} bearing shows status."
582
+ when "Priest", "Monk"
583
+ "#{pronoun} has a serene face with #{eyes} eyes. #{hair.capitalize} hair. #{feature.capitalize} presence."
584
+ when "Ranger", "Hunter"
585
+ "Weather-beaten with #{hair} hair. #{eyes.capitalize} eyes scan surroundings. #{build.capitalize} and outdoorsy."
586
+ else
587
+ "#{build.capitalize} build with #{hair} hair and #{eyes} eyes. #{feature.capitalize} appearance."
588
+ end
589
+ end
590
+
591
+ def generate_social_status(type, level)
592
+ # Generate social status using old system abbreviations
593
+ # S=Slave, LC=Lower Class, LMC=Lower Middle Class, MC=Middle Class, UC=Upper Class, N=Noble
594
+ case type
595
+ when "Noble"
596
+ "N"
597
+ when "Priest", "Mage", "Wizard (air)", "Wizard (earth)", "Wizard (fire)", "Wizard (prot.)", "Wizard (water)", "Sorcerer", "Summoner"
598
+ level > 3 ? "UC" : "MC"
599
+ when "Merchant", "Scholar", "Sage", "Seer"
600
+ level > 2 ? "MC" : "LMC"
601
+ when "Warrior", "Guard", "Soldier", "Army officer", "Body guard"
602
+ level > 4 ? "MC" : "LMC"
603
+ when "Bandit", "Thief", "Assassin", "Prostitute", "Highwayman"
604
+ "LC"
605
+ when "Commoner", "Farmer", "House wife", "Nanny"
606
+ "LMC"
607
+ when "Executioner", "Gladiator"
608
+ "LC"
609
+ else
610
+ level > 3 ? "LMC" : "LC"
611
+ end
612
+ end
613
+
614
+ def generate_money(status, level)
615
+ base = case status
616
+ when "N" then 100 * level # Noble
617
+ when "UC" then 50 * level # Upper Class
618
+ when "MC" then 30 * level # Middle Class
619
+ when "LMC" then 15 * level # Lower Middle Class
620
+ when "LC" then 8 * level # Lower Class
621
+ when "S" then 2 * level # Slave
622
+ else 10 * level
623
+ end
624
+
625
+ variance = rand(base / 2) - base / 4
626
+ total = base + variance
627
+ "#{total} silver"
628
+ end
629
+
630
+ # Load the new equipment tables if not already loaded
631
+ def generate_equipment(type, level)
632
+ unless defined?(generate_npc_equipment)
633
+ load File.join($pgmdir, "includes/equipment_tables.rb")
634
+ end
635
+ generate_npc_equipment(type, level)
636
+ end
637
+
638
+ def calculate_total_weight(n)
639
+ weight = 0
640
+
641
+ # Armor weight
642
+ if n.armor
643
+ weight += get_armor_weight(n.armor[:name])
644
+ end
645
+
646
+ # Weapon weights (approximate)
647
+ melee_count = n.tiers["BODY"]["Melee Combat"]["skills"].select { |_, v| v > 0 }.size
648
+ missile_count = n.tiers["BODY"]["Missile Combat"]["skills"].select { |_, v| v > 0 }.size
649
+ weight += melee_count * 2 # Average 2kg per melee weapon
650
+ weight += missile_count * 1.5 # Average 1.5kg per missile weapon
651
+
652
+ # Basic equipment
653
+ weight += 5 # Basic gear
654
+
655
+ weight.round(1)
656
+ end
657
+
658
+ def get_armor_weight(armor_name)
659
+ case armor_name
660
+ when /Leather/i then 5
661
+ when /Padded|Quilt/i then 7
662
+ when /Chain shirt/i then 12
663
+ when /Scale/i then 15
664
+ when /Chain mail/i then 19
665
+ when /Plate/i then 25
666
+ when /Full plate/i then 30
667
+ else 0
668
+ end
669
+ end
670
+
671
+ # Get weapon stats from tables with init modifier
672
+ def get_weapon_stats(weapon)
673
+ # Ensure weapon is a string and handle nil
674
+ weapon = weapon.to_s.downcase
675
+
676
+ # Return default stats if weapon is empty
677
+ if weapon.empty?
678
+ return { init: 0, off: 0, def: 0, dmg: "0" }
679
+ end
680
+
681
+ # Default values for weapons based on name patterns
682
+ stats = case weapon
683
+ when /sword/
684
+ { init: 0, off: 0, def: 0, dmg: "+1" }
685
+ when /dagger/, /knife/
686
+ { init: 2, off: 1, def: -1, dmg: "-1" }
687
+ when /axe/
688
+ { init: -1, off: 0, def: -1, dmg: "+2" }
689
+ when /spear/, /pike/
690
+ { init: 1, off: 0, def: 1, dmg: "0" }
691
+ when /mace/, /club/, /hammer/
692
+ { init: -1, off: -1, def: 0, dmg: "+1" }
693
+ when /staff/, /quarterstaff/
694
+ { init: 0, off: 0, def: 2, dmg: "-1" }
695
+ when /shield/
696
+ { init: 0, off: -2, def: 3, dmg: "-2" }
697
+ when /net/
698
+ { init: -2, off: 0, def: 0, dmg: "special" }
699
+ when /unarmed/
700
+ { init: 1, off: -2, def: -4, dmg: "-4" }
701
+ else
702
+ { init: 0, off: 0, def: 0, dmg: "0" }
703
+ end
704
+
705
+ # Make absolutely sure all values are present
706
+ stats ||= {}
707
+ stats[:init] ||= 0
708
+ stats[:off] ||= 0
709
+ stats[:def] ||= 0
710
+ stats[:dmg] ||= "0"
711
+
712
+ stats
713
+ end
714
+
715
+ def get_missile_stats(weapon)
716
+ # Ensure weapon is a string
717
+ weapon = weapon.to_s.downcase
718
+
719
+ # Return default stats if weapon is empty
720
+ if weapon.empty?
721
+ return { range: "30m", dmg: "0" }
722
+ end
723
+
724
+ # Default values for missile weapons
725
+ stats = case weapon
726
+ when /longbow/
727
+ { range: "150m", dmg: "+1" }
728
+ when /bow/
729
+ { range: "100m", dmg: "0" }
730
+ when /x-bow/, /crossbow/
731
+ { range: "150m", dmg: "+2" }
732
+ when /sling/
733
+ { range: "50m", dmg: "-1" }
734
+ when /throwing/, /javelin/
735
+ { range: "30m", dmg: "0" }
736
+ when /net/
737
+ { range: "10m", dmg: "special" }
738
+ when /spear/
739
+ { range: "30m", dmg: "0" }
740
+ when /blowgun/
741
+ { range: "20m", dmg: "-5" }
742
+ else
743
+ { range: "30m", dmg: "0" }
744
+ end
745
+
746
+ # Ensure all values are present
747
+ stats ||= {}
748
+ stats[:range] ||= "30m"
749
+ stats[:dmg] ||= "0"
750
+
751
+ stats
752
+ end