File Coverage

blib/lib/Game/TextMapper/Traveller.pm
Criterion Covered Total %
statement 237 239 99.1
branch 128 144 88.8
condition 108 120 90.0
subroutine 26 26 100.0
pod 1 15 6.6
total 500 544 91.9


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   30 use Game::TextMapper::Log;
  1         2  
  1         31  
35 1     1   5 use Modern::Perl '2018';
  1         3  
  1         9  
36 1     1   214 use List::Util qw(shuffle max any);
  1         3  
  1         94  
37 1     1   6 use Mojo::Base -base;
  1         2  
  1         8  
38 1     1   156 use Role::Tiny::With;
  1         3  
  1         61  
39 1     1   8 use Game::TextMapper::Constants qw($dx $dy);
  1         2  
  1         4024  
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 12 my $self = shift;
56 1         8 $self->digraphs($self->compute_digraphs);
57             # coordinates are an index into the system array
58 1         19 my @coordinates = (0 .. $self->rows * $self->cols - 1);
59 1         27 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
  44         88  
  80         100  
62 1         12 my $comms = $self->comms(\%systems);
63 1 100       5 my $tiles = [map { $systems{$_} || ["empty"] } (@coordinates)];
  80         186  
64 1         6 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 44     44 0 56 my $self = shift;
70 44         66 my $size = roll2d6() - 2;
71 44         63 my $atmosphere = max(0, roll2d6() - 7 + $size);
72 44 100       80 $atmosphere = 0 if $size == 0;
73 44         62 my $hydro = roll2d6() - 7 + $atmosphere;
74 44 100 100     118 $hydro -= 4 if $atmosphere < 2 or $atmosphere >= 10;
75 44 100 100     103 $hydro = 0 if $hydro < 0 or $size < 2;
76 44 50       68 $hydro = 10 if $hydro > 10;
77 44         59 my $population = roll2d6() - 2;
78 44         61 my $government = max(0, roll2d6() - 7 + $population);
79 44         64 my $law = max(0, roll2d6() - 7 + $government);
80 44         66 my $starport = roll2d6();
81 44         50 my $naval_base = 0;
82 44         136 my $scout_base = 0;
83 44         48 my $research_base = 0;
84 44         46 my $pirate_base = 0;
85 44         52 my $tech = roll1d6();
86 44 100       112 if ($starport <= 4) {
    100          
    100          
    100          
    100          
87 9         232 $starport = "A";
88 9         21 $tech += 6;
89 9 100       17 $scout_base = 1 if roll2d6() >= 10;
90 9 100       18 $naval_base = 1 if roll2d6() >= 8;
91 9 100       17 $research_base = 1 if roll2d6() >= 8;
92             } elsif ($starport <= 6) {
93 14         19 $starport = "B";
94 14         25 $tech += 4;
95 14 100       23 $scout_base = 1 if roll2d6() >= 9;
96 14 100       24 $naval_base = 1 if roll2d6() >= 8;
97 14 100       20 $research_base = 1 if roll2d6() >= 10;
98             } elsif ($starport <= 8) {
99 10         15 $starport = "C";
100 10         13 $tech += 2;
101 10 100       19 $scout_base = 1 if roll2d6() >= 8;
102 10 100       17 $research_base = 1 if roll2d6() >= 10;
103 10 50       17 $pirate_base = 1 if roll2d6() >= 12;
104             } elsif ($starport <= 9) {
105 4         5 $starport = "D";
106 4 100       12 $scout_base = 1 if roll2d6() >= 7;
107 4 50       10 $pirate_base = 1 if roll2d6() >= 10;
108             } elsif ($starport <= 11) {
109 5         8 $starport = "E";
110 5 50       13 $pirate_base = 1 if roll2d6() >= 10;
111             } else {
112 2         6 $starport = "X";
113 2         5 $tech -= 4;
114             }
115 44 100       80 $tech += 1 if $size <= 4;
116 44 100       68 $tech += 1 if $size <= 1; # +2 total
117 44 100 100     103 $tech += 1 if $atmosphere <= 3 or $atmosphere >= 10;
118 44 100       70 $tech += 1 if $hydro >= 9;
119 44 100       66 $tech += 1 if $hydro >= 10; # +2 total
120 44 100 100     107 $tech += 1 if $population >= 1 and $population <= 5;
121 44 100       66 $tech += 2 if $population >= 9;
122 44 50       73 $tech += 2 if $population >= 10; # +4 total
123 44 100 100     105 $tech += 1 if $government == 0 or $government == 5;
124 44 50       68 $tech -= 2 if $government == 13; # D
125 44 50       66 $tech = 0 if $tech < 0;
126 44         58 my $gas_giant = roll2d6() <= 9;
127 44         142 my $name = $self->compute_name();
128 44 100       78 $name = uc($name) if $population >= 9;
129 44         69 my $uwp = join("", $starport, map { code($_) } $size, $atmosphere, $hydro, $population, $government, $law) . "-" . code($tech);
  264         317  
130             # these things determine the order in which text is generated by Hex Describe
131 44         75 my @tiles;
132 44 100       87 push(@tiles, "gas") if $gas_giant;
133 44         63 push(@tiles, "size-" . code($size));
134 44 100       79 push(@tiles, "asteroid")
135             if $size == 0;
136 44         276 push(@tiles, "atmosphere-" . code($atmosphere));
137 44 100       82 push(@tiles, "vacuum")
138             if $atmosphere == 0;
139 44         61 push(@tiles, "hydrosphere-" . code($hydro));
140 44 50       79 push(@tiles, "water")
141             if $hydro eq "A";
142 44 100 100     110 push(@tiles, "desert")
143             if $atmosphere >= 2
144             and $hydro == 0;
145 44 100 100     106 push(@tiles, "ice")
146             if $hydro >= 1
147             and $atmosphere <= 1;
148 44 100 100     104 push(@tiles, "fluid")
149             if $hydro >= 1
150             and $atmosphere >= 10;
151 44         59 push(@tiles, "population-" . code($population));
152 44 100 100     99 push(@tiles, "barren")
      66        
153             if $population eq 0
154             and $law eq 0
155             and $government eq 0;
156 44 100 100     112 push(@tiles, "low")
157             if $population >= 1 and $population <= 3;
158 44 100       77 push(@tiles, "high")
159             if $population >= 9;
160 44 100 100     188 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 44 100 100     105 push(@tiles, "non-agriculture")
      100        
165             if $atmosphere <= 3
166             and $hydro <= 3
167             and $population >= 6;
168             push(@tiles, "industrial")
169 44 100 100 204   166 if any { $atmosphere == $_ } 0, 1, 2, 4, 7, 9
  204         270  
170             and $population >= 9;
171 44 100       130 push(@tiles, "non-industrial")
172             if $population <= 6;
173 44 50 100     160 push(@tiles, "rich")
      66        
      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 44 100 100     136 push(@tiles, "poor")
      100        
178             if $atmosphere >= 2 and $atmosphere <= 5
179             and $hydro <= 3;
180 44         67 push(@tiles, "tech-" . code($tech));
181 44         70 push(@tiles, "government-" . code($government));
182 44         78 push(@tiles, "starport-$starport");
183 44         69 push(@tiles, "law-" . code($law));
184 44 100       83 push(@tiles, "naval") if $naval_base;
185 44 100       66 push(@tiles, "scout") if $scout_base;
186 44 100       69 push(@tiles, "research") if $research_base;
187 44 50       61 push(@tiles, "pirate", "red") if $pirate_base;
188 44 100 100     335 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 44         114 push(@tiles, qq{name="$name"}, qq{uwp="$uwp"});
198 44         229 return \@tiles;
199             }
200              
201             sub code {
202 616     616 0 713 my $code = shift;
203 616 100       1262 return $code if $code <= 9;
204 52         128 return chr(55+$code); # 10 is A
205             }
206              
207             sub compute_digraphs {
208 1     1 0 18 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         7 my @second = qw(a e i o u a e i o u a e i o u .);
214 1         3 my @d;
215 1         7 for (1 .. 10+rand(20)) {
216 29         49 push(@d, one(@first));
217 29         38 push(@d, one(@second));
218             }
219 1         10 return \@d;
220             }
221              
222             sub compute_name {
223 44     44 0 58 my $self = shift;
224 44         47 my $max = scalar @{$self->digraphs};
  44         93  
225 44         179 my $length = 3 + rand(3); # length of name before adding one more
226 44         49 my $name = '';
227 44         88 while (length($name) < $length) {
228 105         281 my $i = 2*int(rand($max/2));
229 105         163 $name .= $self->digraphs->[$i];
230 105         346 $name .= $self->digraphs->[$i+1];
231             }
232 44         189 $name =~ s/\.//g;
233 44         101 return ucfirst($name);
234             }
235              
236             sub one {
237 58     58 0 101 return $_[int(rand(scalar @_))];
238             }
239              
240             sub roll1d6 {
241 1052     1052 0 1633 return 1+int(rand(6));
242             }
243              
244             sub roll2d6 {
245 464     464 0 544 return roll1d6() + roll1d6();
246             }
247              
248             sub xy {
249 44     44 0 51 my $self = shift;
250 44         48 my $i = shift;
251 44         69 my $y = int($i / $self->cols);
252 44         164 my $x = $i % $self->cols;
253 44         195 $log->debug("$i ($x, $y)");
254 44         244 return $x + 1, $y + 1;
255             }
256              
257             sub label {
258 51     51 0 67 my ($self, $from, $to, $d, $label) = @_;
259 51         208 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 3 my $self = shift;
266 1         2 my %systems = %{shift()};
  1         21  
267 1         10 my @coordinates = map { [ $self->xy($_), $systems{$_} ] } keys(%systems);
  44         67  
268 1         9 my @comms;
269             my @trade;
270 1         0 my @rich_trade;
271 1         6 while (@coordinates) {
272 44         66 my $from = shift(@coordinates);
273 44         77 my ($x1, $y1, $system1) = @$from;
274 44 100   607   136 next if any { /^starport-X$/ } @$system1; # skip systems without starports
  607         707  
275 42         87 for my $to (@coordinates) {
276 941         1412 my ($x2, $y2, $system2) = @$to;
277 941 100   12874   2282 next if any { /^starport-X$/ } @$system2; # skip systems without starports
  12874         14348  
278 861         2098 my $d = $self->distance($x1, $y1, $x2, $y2);
279 861 100 100     1778 if ($d <= 2 and match(qr/^(starport-[AB]|naval)$/, qr/^(starport-[AB]|naval)$/, $system1, $system2)) {
280 41         103 push(@comms, [$from, $to, $d]);
281             }
282 861 50 33     1537 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 42         114 push(@trade, [$from, $to, $d]);
325             }
326 861 50 66     2264 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 0         0 push(@rich_trade, [$from, $to, $d]);
332             }
333             }
334             }
335 1         5 @comms = sort map { $self->label(@$_, "communication") } @{$self->minimal_spanning_tree(@comms)};
  20         37  
  1         7  
336 1         7 @trade = sort map { $self->label(@$_, "trade") } @{$self->minimal_spanning_tree(@trade)};
  31         63  
  1         7  
337 1         11 @rich_trade = sort map { $self->label(@$_, "rich") } @{$self->minimal_spanning_tree(@rich_trade)};
  0         0  
  1         3  
338 1         21 return [@rich_trade, @comms, @trade];
339             }
340              
341             sub match {
342 1963     1963 0 3039 my ($re1, $re2, $sys1, $sys2) = @_;
343 1963 100 100 25178   4504 return 1 if any { /$re1/ } @$sys1 and any { /$re2/ } @$sys2;
  25178         38505  
  3610         6415  
344 1895 100 100 21601   5182 return 1 if any { /$re2/ } @$sys1 and any { /$re1/ } @$sys2;
  21601         39566  
  7921         11960  
345 1880         7675 return 0;
346             }
347              
348             sub minimal_spanning_tree {
349             # http://en.wikipedia.org/wiki/Kruskal%27s_algorithm
350 3     3 0 7 my $self = shift;
351             # Initialize a priority queue Q to contain all edges in G, using the
352             # weights as keys.
353 3         15 my @Q = sort { @{$a}[2] <=> @{$b}[2] } @_;
  313         328  
  313         353  
  313         387  
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         7 foreach my $edge (@Q) {
360             # edge u,v is the minimum weighted route from u to v
361 83         90 my ($u, $v) = @{$edge};
  83         122  
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 83 100 100     269 if (not $C{$u} or not $C{$v} or $C{$u} != $C{$v}) {
      100        
365             # Add edge (v,u) to T.
366 51         70 push(@T, $edge);
367             # Merge C(v) and C(u) into one cluster, that is, union C(v) and C(u).
368 51 100 100     220 if ($C{$u} and $C{$v}) {
    100 66        
    100 66        
    50 33        
369 9         10 my @group;
370 9         34 foreach (keys %C) {
371 216 100       375 push(@group, $_) if $C{$_} == $C{$v};
372             }
373 9         41 $C{$_} = $C{$u} foreach @group;
374             } elsif ($C{$v} and not $C{$u}) {
375 7         25 $C{$u} = $C{$v};
376             } elsif ($C{$u} and not $C{$v}) {
377 22         68 $C{$v} = $C{$u};
378             } elsif (not $C{$u} and not $C{$v}) {
379 13         56 $C{$v} = $C{$u} = ++$id;
380             }
381             }
382             }
383 3         21 return \@T;
384             }
385              
386             sub to_text {
387 1     1 0 4 my $self = shift;
388 1         4 my $tiles = shift;
389 1         2 my $comms = shift;
390 1         3 my $text = "";
391 1         9 for my $x (0 .. $self->cols - 1) {
392 8         30 for my $y (0 .. $self->rows - 1) {
393 80         144 my $tile = $tiles->[$x + $y * $self->cols];
394 80 50       260 if ($tile) {
395 80         302 $text .= sprintf("%02d%02d @$tile\n", $x + 1, $y + 1);
396             }
397             }
398             }
399 1         9 $text .= join("\n", @$comms, "\ninclude traveller.txt\n");
400 1         7 $text .= $self->legend();
401 1         59 return $text;
402             }
403              
404             sub legend {
405 1     1 0 3 my $self = shift;
406 1         2 my $template = qq{# frame and legend};
407 1         3 my $x = int(($self->cols + 1) * 1.5 * $dx);
408 1         8 my $y = int(($self->rows + 1) * $dy + 5);
409 1         13 $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         9 $template .= qq{
414             other coreward
415             other rimward};
416 1         3 $x = int($self->rows * $dy / 2);
417 1         7 $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       11 $template .= qq{
429             other ■ imperial consulate – ☼ TAS – communication – trade long distance trade
430             } if $self->rows > 8;
431 1         18 return $template;
432             }
433              
434             1;