File Coverage

blib/lib/Game/TextMapper/Traveller.pm
Criterion Covered Total %
statement 239 239 100.0
branch 125 144 86.8
condition 107 120 89.1
subroutine 26 26 100.0
pod 1 15 6.6
total 498 544 91.5


line stmt bran cond sub pod time code
1             # Copyright (C) 2009-2021 Alex Schroeder
2             #
3             # This program is free software: you can redistribute it and/or modify it under
4             # the terms of the GNU Affero General Public License as published by the Free
5             # Software Foundation, either version 3 of the License, or (at your option) any
6             # later version.
7             #
8             # This program is distributed in the hope that it will be useful, but WITHOUT
9             # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
10             # FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
11             # details.
12             #
13             # You should have received a copy of the GNU Affero General Public License along
14             # with this program. If not, see .
15              
16             =encoding utf8
17              
18             =head1 NAME
19              
20             Game::TextMapper::Traveller - generate Traveller subsector maps
21              
22             =head1 DESCRIPTION
23              
24             This generates subsector maps suitable for the Traveller game in its various
25             editions. Trade and communication routes are based on starports, bases, and
26             trade codes and jump distance; the potential connections are then winnowed down
27             using a minimal spanning tree.
28              
29             =head1 METHODS
30              
31             =cut
32              
33             package Game::TextMapper::Traveller;
34 1     1   7 use Game::TextMapper::Log;
  1         2  
  1         27  
35 1     1   5 use Modern::Perl '2018';
  1         2  
  1         5  
36 1     1   108 use List::Util qw(shuffle max any);
  1         2  
  1         63  
37 1     1   6 use Mojo::Base -base;
  1         2  
  1         6  
38 1     1   124 use Role::Tiny::With;
  1         1  
  1         53  
39 1     1   8 use Game::TextMapper::Constants qw($dx $dy);
  1         2  
  1         3616  
40             with 'Game::TextMapper::Schroeder::Hex';
41              
42             my $log = Game::TextMapper::Log->get;
43              
44             has 'rows' => 10;
45             has 'cols' => 8;
46             has 'digraphs';
47              
48             =head2 generate_map
49              
50             This method takes no arguments. Subsectors are always 8×10.
51              
52             =cut
53              
54             sub generate_map {
55 1     1 1 10 my $self = shift;
56 1         6 $self->digraphs($self->compute_digraphs);
57             # coordinates are an index into the system array
58 1         14 my @coordinates = (0 .. $self->rows * $self->cols - 1);
59 1         21 my @randomized = shuffle(@coordinates);
60             # %systems maps coordinates to arrays of tiles
61 1         4 my %systems = map { $_ => $self->system() } grep { roll1d6() > 3 } @randomized; # density
  37         70  
  80         99  
62 1         8 my $comms = $self->comms(\%systems);
63 1 100       5 my $tiles = [map { $systems{$_} || ["empty"] } (@coordinates)];
  80         185  
64 1         9 return $self->to_text($tiles, $comms);
65             }
66              
67             # Each system is an array of tiles, e.g. ["size-1", "population-3", ...]
68             sub system {
69 37     37 0 47 my $self = shift;
70 37         53 my $size = roll2d6() - 2;
71 37         53 my $atmosphere = max(0, roll2d6() - 7 + $size);
72 37 100       71 $atmosphere = 0 if $size == 0;
73 37         49 my $hydro = roll2d6() - 7 + $atmosphere;
74 37 100 100     93 $hydro -= 4 if $atmosphere < 2 or $atmosphere >= 10;
75 37 100 100     83 $hydro = 0 if $hydro < 0 or $size < 2;
76 37 100       58 $hydro = 10 if $hydro > 10;
77 37         49 my $population = roll2d6() - 2;
78 37         50 my $government = max(0, roll2d6() - 7 + $population);
79 37         64 my $law = max(0, roll2d6() - 7 + $government);
80 37         49 my $starport = roll2d6();
81 37         45 my $naval_base = 0;
82 37         40 my $scout_base = 0;
83 37         45 my $research_base = 0;
84 37         47 my $pirate_base = 0;
85 37         44 my $tech = roll1d6();
86 37 100       92 if ($starport <= 4) {
    100          
    100          
    100          
    100          
87 8         10 $starport = "A";
88 8         12 $tech += 6;
89 8 50       11 $scout_base = 1 if roll2d6() >= 10;
90 8 100       13 $naval_base = 1 if roll2d6() >= 8;
91 8 100       104 $research_base = 1 if roll2d6() >= 8;
92             } elsif ($starport <= 6) {
93 12         14 $starport = "B";
94 12         14 $tech += 4;
95 12 100       16 $scout_base = 1 if roll2d6() >= 9;
96 12 100       22 $naval_base = 1 if roll2d6() >= 8;
97 12 100       15 $research_base = 1 if roll2d6() >= 10;
98             } elsif ($starport <= 8) {
99 8         10 $starport = "C";
100 8         12 $tech += 2;
101 8 100       12 $scout_base = 1 if roll2d6() >= 8;
102 8 50       12 $research_base = 1 if roll2d6() >= 10;
103 8 50       12 $pirate_base = 1 if roll2d6() >= 12;
104             } elsif ($starport <= 9) {
105 2         4 $starport = "D";
106 2 100       4 $scout_base = 1 if roll2d6() >= 7;
107 2 50       6 $pirate_base = 1 if roll2d6() >= 10;
108             } elsif ($starport <= 11) {
109 5         9 $starport = "E";
110 5 50       8 $pirate_base = 1 if roll2d6() >= 10;
111             } else {
112 2         5 $starport = "X";
113 2         3 $tech -= 4;
114             }
115 37 100       69 $tech += 1 if $size <= 4;
116 37 100       63 $tech += 1 if $size <= 1; # +2 total
117 37 100 100     82 $tech += 1 if $atmosphere <= 3 or $atmosphere >= 10;
118 37 100       59 $tech += 1 if $hydro >= 9;
119 37 100       53 $tech += 1 if $hydro >= 10; # +2 total
120 37 100 100     90 $tech += 1 if $population >= 1 and $population <= 5;
121 37 50       78 $tech += 2 if $population >= 9;
122 37 50       52 $tech += 2 if $population >= 10; # +4 total
123 37 100 100     84 $tech += 1 if $government == 0 or $government == 5;
124 37 100       55 $tech -= 2 if $government == 13; # D
125 37 100       53 $tech = 0 if $tech < 0;
126 37         45 my $gas_giant = roll2d6() <= 9;
127 37         62 my $name = $self->compute_name();
128 37 50       59 $name = uc($name) if $population >= 9;
129 37         59 my $uwp = join("", $starport, map { code($_) } $size, $atmosphere, $hydro, $population, $government, $law) . "-" . code($tech);
  222         262  
130             # these things determine the order in which text is generated by Hex Describe
131 37         58 my @tiles;
132 37 100       67 push(@tiles, "gas") if $gas_giant;
133 37         54 push(@tiles, "size-" . code($size));
134 37 100       68 push(@tiles, "asteroid")
135             if $size == 0;
136 37         56 push(@tiles, "atmosphere-" . code($atmosphere));
137 37 100       62 push(@tiles, "vacuum")
138             if $atmosphere == 0;
139 37         49 push(@tiles, "hydrosphere-" . code($hydro));
140 37 50       67 push(@tiles, "water")
141             if $hydro eq "A";
142 37 100 100     282 push(@tiles, "desert")
143             if $atmosphere >= 2
144             and $hydro == 0;
145 37 50 66     83 push(@tiles, "ice")
146             if $hydro >= 1
147             and $atmosphere <= 1;
148 37 100 100     81 push(@tiles, "fluid")
149             if $hydro >= 1
150             and $atmosphere >= 10;
151 37         51 push(@tiles, "population-" . code($population));
152 37 50 66     76 push(@tiles, "barren")
      33        
153             if $population eq 0
154             and $law eq 0
155             and $government eq 0;
156 37 100 100     105 push(@tiles, "low")
157             if $population >= 1 and $population <= 3;
158 37 50       48 push(@tiles, "high")
159             if $population >= 9;
160 37 100 100     147 push(@tiles, "agriculture")
      100        
      100        
      100        
      100        
161             if $atmosphere >= 4 and $atmosphere <= 9
162             and $hydro >= 4 and $hydro <= 8
163             and $population >= 5 and $population <= 7;
164 37 100 100     87 push(@tiles, "non-agriculture")
      100        
165             if $atmosphere <= 3
166             and $hydro <= 3
167             and $population >= 6;
168             push(@tiles, "industrial")
169 37 50 66 171   130 if any { $atmosphere == $_ } 0, 1, 2, 4, 7, 9
  171         223  
170             and $population >= 9;
171 37 100       100 push(@tiles, "non-industrial")
172             if $population <= 6;
173 37 100 100     130 push(@tiles, "rich")
      100        
      100        
      100        
      66        
174             if $government >= 4 and $government <= 9
175             and ($atmosphere == 6 or $atmosphere == 8)
176             and $population >= 6 and $population <= 8;
177 37 100 100     98 push(@tiles, "poor")
      100        
178             if $atmosphere >= 2 and $atmosphere <= 5
179             and $hydro <= 3;
180 37         59 push(@tiles, "tech-" . code($tech));
181 37         62 push(@tiles, "government-" . code($government));
182 37         58 push(@tiles, "starport-$starport");
183 37         57 push(@tiles, "law-" . code($law));
184 37 100       60 push(@tiles, "naval") if $naval_base;
185 37 100       58 push(@tiles, "scout") if $scout_base;
186 37 100       53 push(@tiles, "research") if $research_base;
187 37 50       61 push(@tiles, "pirate", "red") if $pirate_base;
188 37 100 100     256 push(@tiles, "amber")
      66        
189             if not $pirate_base
190             and ($atmosphere >= 10
191             or $population and $government == 0
192             or $population and $law == 0
193             or $government == 7
194             or $government == 10
195             or $law >= 9);
196             # last is the name
197 37         91 push(@tiles, qq{name="$name"}, qq{uwp="$uwp"});
198 37         122 return \@tiles;
199             }
200              
201             sub code {
202 518     518 0 586 my $code = shift;
203 518 100       990 return $code if $code <= 9;
204 50         102 return chr(55+$code); # 10 is A
205             }
206              
207             sub compute_digraphs {
208 1     1 0 16 my @first = qw(b c d f g h j k l m n p q r s t v w x y z
209             b c d f g h j k l m n p q r s t v w x y z .
210             sc ng ch gh ph rh sh th wh zh wr qu
211             st sp tr tw fl dr pr dr);
212             # make missing vowel rare
213 1         6 my @second = qw(a e i o u a e i o u a e i o u .);
214 1         2 my @d;
215 1         6 for (1 .. 10+rand(20)) {
216 15         29 push(@d, one(@first));
217 15         26 push(@d, one(@second));
218             }
219 1         8 return \@d;
220             }
221              
222             sub compute_name {
223 37     37 0 43 my $self = shift;
224 37         38 my $max = scalar @{$self->digraphs};
  37         73  
225 37         126 my $length = 3 + rand(3); # length of name before adding one more
226 37         53 my $name = '';
227 37         71 while (length($name) < $length) {
228 93         259 my $i = 2*int(rand($max/2));
229 93         127 $name .= $self->digraphs->[$i];
230 93         288 $name .= $self->digraphs->[$i+1];
231             }
232 37         141 $name =~ s/\.//g;
233 37         82 return ucfirst($name);
234             }
235              
236             sub one {
237 30     30 0 43 return $_[int(rand(scalar @_))];
238             }
239              
240             sub roll1d6 {
241 895     895 0 1340 return 1+int(rand(6));
242             }
243              
244             sub roll2d6 {
245 389     389 0 456 return roll1d6() + roll1d6();
246             }
247              
248             sub xy {
249 37     37 0 44 my $self = shift;
250 37         39 my $i = shift;
251 37         56 my $y = int($i / $self->cols);
252 37         142 my $x = $i % $self->cols;
253 37         163 $log->debug("$i ($x, $y)");
254 37         219 return $x + 1, $y + 1;
255             }
256              
257             sub label {
258 40     40 0 55 my ($self, $from, $to, $d, $label) = @_;
259 40         134 return sprintf("%02d%02d-%02d%02d $label", @$from[0..1], @$to[0..1]);
260             }
261              
262             # Communication routes have distance 1–2 and connect navy bases and A-class
263             # starports.
264             sub comms {
265 1     1 0 2 my $self = shift;
266 1         2 my %systems = %{shift()};
  1         15  
267 1         8 my @coordinates = map { [ $self->xy($_), $systems{$_} ] } keys(%systems);
  37         60  
268 1         6 my @comms;
269             my @trade;
270 1         0 my @rich_trade;
271 1         3 while (@coordinates) {
272 37         54 my $from = shift(@coordinates);
273 37         64 my ($x1, $y1, $system1) = @$from;
274 37 100   504   100 next if any { /^starport-X$/ } @$system1; # skip systems without starports
  504         608  
275 35         70 for my $to (@coordinates) {
276 644         934 my ($x2, $y2, $system2) = @$to;
277 644 100   8714   1528 next if any { /^starport-X$/ } @$system2; # skip systems without starports
  8714         9527  
278 595         1469 my $d = $self->distance($x1, $y1, $x2, $y2);
279 595 100 100     1145 if ($d <= 2 and match(qr/^(starport-[AB]|naval)$/, qr/^(starport-[AB]|naval)$/, $system1, $system2)) {
280 40         87 push(@comms, [$from, $to, $d]);
281             }
282 595 50 66     1099 if ($d <= 2
      66        
283             # many of these can be eliminated, but who knows, perhaps one day
284             # directionality will make a difference
285             and (match(qr/^agriculture$/,
286             qr/^(agriculture|astroid|desert|high|industrial|low|non-agriculture|rich)$/,
287             $system1, $system2)
288             or match(qr/^asteroid$/,
289             qr/^(asteroid|industrial|non-agriculture|rich|vacuum)$/,
290             $system1, $system2)
291             or match(qr/^desert$/,
292             qr/^(desert|non-agriculture)$/,
293             $system1, $system2)
294             or match(qr/^fluid$/,
295             qr/^(fluid|industrial)$/,
296             $system1, $system2)
297             or match(qr/^high$/,
298             qr/^(high|low|rich)$/,
299             $system1, $system2)
300             or match(qr/^ice$/,
301             qr/^industrial$/,
302             $system1, $system2)
303             or match(qr/^industrial$/,
304             qr/^(agriculture|astroid|desert|fluid|high|industrial|non-industrial|poor|rich|vacuum|water)$/,
305             $system1, $system2)
306             or match(qr/^low$/,
307             qr/^(industrial|rich)$/,
308             $system1, $system2)
309             or match(qr/^non-agriculture$/,
310             qr/^(asteroid|desert|vacuum)$/,
311             $system1, $system2)
312             or match(qr/^non-industrial$/,
313             qr/^industrial$/,
314             $system1, $system2)
315             or match(qr/^rich$/,
316             qr/^(agriculture|desert|high|industrial|non-agriculture|rich)$/,
317             $system1, $system2)
318             or match(qr/^vacuum$/,
319             qr/^(asteroid|industrial|vacuum)$/,
320             $system1, $system2)
321             or match(qr/^water$/,
322             qr/^(industrial|rich|water)$/,
323             $system1, $system2))) {
324 20         46 push(@trade, [$from, $to, $d]);
325             }
326 595 100 100     1665 if ($d <= 3
327             # subsidized liners only
328             and match(qr/^rich$/,
329             qr/^(asteroid|agriculture|desert|high|industrial|non-agriculture|water|rich|low)$/,
330             $system1, $system2)) {
331 9         35 push(@rich_trade, [$from, $to, $d]);
332             }
333             }
334             }
335 1         4 @comms = sort map { $self->label(@$_, "communication") } @{$self->minimal_spanning_tree(@comms)};
  18         33  
  1         5  
336 1         7 @trade = sort map { $self->label(@$_, "trade") } @{$self->minimal_spanning_tree(@trade)};
  16         27  
  1         4  
337 1         7 @rich_trade = sort map { $self->label(@$_, "rich") } @{$self->minimal_spanning_tree(@rich_trade)};
  6         13  
  1         4  
338 1         19 return [@rich_trade, @comms, @trade];
339             }
340              
341             sub match {
342 1570     1570 0 2488 my ($re1, $re2, $sys1, $sys2) = @_;
343 1570 100 100 2693   3777 return 1 if any { /$re1/ } @$sys1 and any { /$re2/ } @$sys2;
  20112         30829  
  2693         4805  
344 1520 100 100 17394   4088 return 1 if any { /$re2/ } @$sys1 and any { /$re1/ } @$sys2;
  17394         31938  
  5690         8707  
345 1501         6038 return 0;
346             }
347              
348             sub minimal_spanning_tree {
349             # http://en.wikipedia.org/wiki/Kruskal%27s_algorithm
350 3     3 0 4 my $self = shift;
351             # Initialize a priority queue Q to contain all edges in G, using the
352             # weights as keys.
353 3         14 my @Q = sort { @{$a}[2] <=> @{$b}[2] } @_;
  223         244  
  223         253  
  223         279  
354             # Define a forest T ← Ø; T will ultimately contain the edges of the MST
355 3         9 my @T;
356             # Define an elementary cluster C(v) ← {v}.
357             my %C;
358 3         0 my $id;
359 3         6 foreach my $edge (@Q) {
360             # edge u,v is the minimum weighted route from u to v
361 69         75 my ($u, $v) = @{$edge};
  69         93  
362             # prevent cycles in T; add u,v only if T does not already contain
363             # a path between u and v; also silence warnings
364 69 100 100     232 if (not $C{$u} or not $C{$v} or $C{$u} != $C{$v}) {
      100        
365             # Add edge (v,u) to T.
366 40         55 push(@T, $edge);
367             # Merge C(v) and C(u) into one cluster, that is, union C(v) and C(u).
368 40 100 100     198 if ($C{$u} and $C{$v}) {
    100 66        
    100 66        
    50 33        
369 6         9 my @group;
370 6         19 foreach (keys %C) {
371 94 100       161 push(@group, $_) if $C{$_} == $C{$v};
372             }
373 6         26 $C{$_} = $C{$u} foreach @group;
374             } elsif ($C{$v} and not $C{$u}) {
375 5         14 $C{$u} = $C{$v};
376             } elsif ($C{$u} and not $C{$v}) {
377 16         43 $C{$v} = $C{$u};
378             } elsif (not $C{$u} and not $C{$v}) {
379 13         38 $C{$v} = $C{$u} = ++$id;
380             }
381             }
382             }
383 3         16 return \@T;
384             }
385              
386             sub to_text {
387 1     1 0 2 my $self = shift;
388 1         3 my $tiles = shift;
389 1         2 my $comms = shift;
390 1         3 my $text = "";
391 1         7 for my $x (0 .. $self->cols - 1) {
392 8         25 for my $y (0 .. $self->rows - 1) {
393 80         153 my $tile = $tiles->[$x + $y * $self->cols];
394 80 50       270 if ($tile) {
395 80         249 $text .= sprintf("%02d%02d @$tile\n", $x + 1, $y + 1);
396             }
397             }
398             }
399 1         8 $text .= join("\n", @$comms, "\ninclude traveller.txt\n");
400 1         6 $text .= $self->legend();
401 1         50 return $text;
402             }
403              
404             sub legend {
405 1     1 0 2 my $self = shift;
406 1         3 my $template = qq{# frame and legend};
407 1         3 my $x = int(($self->cols + 1) * 1.5 * $dx);
408 1         9 my $y = int(($self->rows + 1) * $dy + 5);
409 1         12 $template .= qq{
410             other },
411             $x = int(($self->cols + 1) * 0.75 * $dx);
412 1         7 $y = int(($self->rows + 1) * $dy - 60);
413 1         10 $template .= qq{
414             other coreward
415             other rimward};
416 1         4 $x = int($self->rows * $dy / 2);
417 1         8 $template .= qq{
418             other spinward
419             };
420 1         4 $y = int(($self->cols + 1) * 1.5 * $dx);
421 1         9 $template .= qq{
422             other trailing
423             };
424 1         3 $x = int(($self->rows + 0.5) * $dy);
425 1 50       6 $template .= qq{
426             other ◉ gas giant – ▲ scout base – ★ navy base – π research base – ☠ pirate base
427             } if $self->rows > 8;
428 1 50       12 $template .= qq{
429             other ■ imperial consulate – ☼ TAS – communication – trade long distance trade
430             } if $self->rows > 8;
431 1         14 return $template;
432             }
433              
434             1;