File Coverage

blib/lib/Game/TextMapper/Traveller.pm
Criterion Covered Total %
statement 237 239 99.1
branch 127 144 88.1
condition 105 120 87.5
subroutine 26 26 100.0
pod 1 15 6.6
total 496 544 91.1


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   6 use Game::TextMapper::Log;
  1         2  
  1         28  
35 1     1   4 use Modern::Perl '2018';
  1         2  
  1         9  
36 1     1   124 use List::Util qw(shuffle max any);
  1         2  
  1         53  
37 1     1   16 use Mojo::Base -base;
  1         3  
  1         6  
38 1     1   121 use Role::Tiny::With;
  1         1  
  1         52  
39 1     1   7 use Game::TextMapper::Constants qw($dx $dy);
  1         2  
  1         3950  
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 11 my $self = shift;
56 1         4 $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         18 my @randomized = shuffle(@coordinates);
60             # %systems maps coordinates to arrays of tiles
61 1         3 my %systems = map { $_ => $self->system() } grep { roll1d6() > 3 } @randomized; # density
  41         83  
  80         97  
62 1         7 my $comms = $self->comms(\%systems);
63 1 100       3 my $tiles = [map { $systems{$_} || ["empty"] } (@coordinates)];
  80         172  
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 41     41 0 50 my $self = shift;
70 41         62 my $size = roll2d6() - 2;
71 41         65 my $atmosphere = max(0, roll2d6() - 7 + $size);
72 41 50       65 $atmosphere = 0 if $size == 0;
73 41         50 my $hydro = roll2d6() - 7 + $atmosphere;
74 41 100 100     112 $hydro -= 4 if $atmosphere < 2 or $atmosphere >= 10;
75 41 100 66     91 $hydro = 0 if $hydro < 0 or $size < 2;
76 41 100       64 $hydro = 10 if $hydro > 10;
77 41         54 my $population = roll2d6() - 2;
78 41         55 my $government = max(0, roll2d6() - 7 + $population);
79 41         53 my $law = max(0, roll2d6() - 7 + $government);
80 41         56 my $starport = roll2d6();
81 41         48 my $naval_base = 0;
82 41         46 my $scout_base = 0;
83 41         46 my $research_base = 0;
84 41         47 my $pirate_base = 0;
85 41         52 my $tech = roll1d6();
86 41 100       98 if ($starport <= 4) {
    100          
    100          
    100          
    50          
87 3         6 $starport = "A";
88 3         4 $tech += 6;
89 3 50       6 $scout_base = 1 if roll2d6() >= 10;
90 3 100       5 $naval_base = 1 if roll2d6() >= 8;
91 3 100       5 $research_base = 1 if roll2d6() >= 8;
92             } elsif ($starport <= 6) {
93 10         12 $starport = "B";
94 10         13 $tech += 4;
95 10 100       13 $scout_base = 1 if roll2d6() >= 9;
96 10 100       14 $naval_base = 1 if roll2d6() >= 8;
97 10 100       13 $research_base = 1 if roll2d6() >= 10;
98             } elsif ($starport <= 8) {
99 14         18 $starport = "C";
100 14         16 $tech += 2;
101 14 100       22 $scout_base = 1 if roll2d6() >= 8;
102 14 100       18 $research_base = 1 if roll2d6() >= 10;
103 14 50       40 $pirate_base = 1 if roll2d6() >= 12;
104             } elsif ($starport <= 9) {
105 5         7 $starport = "D";
106 5 100       8 $scout_base = 1 if roll2d6() >= 7;
107 5 50       10 $pirate_base = 1 if roll2d6() >= 10;
108             } elsif ($starport <= 11) {
109 9         12 $starport = "E";
110 9 100       13 $pirate_base = 1 if roll2d6() >= 10;
111             } else {
112 0         0 $starport = "X";
113 0         0 $tech -= 4;
114             }
115 41 100       74 $tech += 1 if $size <= 4;
116 41 100       58 $tech += 1 if $size <= 1; # +2 total
117 41 100 100     96 $tech += 1 if $atmosphere <= 3 or $atmosphere >= 10;
118 41 100       59 $tech += 1 if $hydro >= 9;
119 41 100       62 $tech += 1 if $hydro >= 10; # +2 total
120 41 100 66     100 $tech += 1 if $population >= 1 and $population <= 5;
121 41 100       63 $tech += 2 if $population >= 9;
122 41 100       67 $tech += 2 if $population >= 10; # +4 total
123 41 100 100     88 $tech += 1 if $government == 0 or $government == 5;
124 41 100       59 $tech -= 2 if $government == 13; # D
125 41 50       61 $tech = 0 if $tech < 0;
126 41         204 my $gas_giant = roll2d6() <= 9;
127 41         66 my $name = $self->compute_name();
128 41 100       76 $name = uc($name) if $population >= 9;
129 41         68 my $uwp = join("", $starport, map { code($_) } $size, $atmosphere, $hydro, $population, $government, $law) . "-" . code($tech);
  246         301  
130             # these things determine the order in which text is generated by Hex Describe
131 41         65 my @tiles;
132 41 100       74 push(@tiles, "gas") if $gas_giant;
133 41         78 push(@tiles, "size-" . code($size));
134 41 50       71 push(@tiles, "asteroid")
135             if $size == 0;
136 41         56 push(@tiles, "atmosphere-" . code($atmosphere));
137 41 100       78 push(@tiles, "vacuum")
138             if $atmosphere == 0;
139 41         54 push(@tiles, "hydrosphere-" . code($hydro));
140 41 50       77 push(@tiles, "water")
141             if $hydro eq "A";
142 41 100 100     106 push(@tiles, "desert")
143             if $atmosphere >= 2
144             and $hydro == 0;
145 41 100 100     96 push(@tiles, "ice")
146             if $hydro >= 1
147             and $atmosphere <= 1;
148 41 100 100     81 push(@tiles, "fluid")
149             if $hydro >= 1
150             and $atmosphere >= 10;
151 41         65 push(@tiles, "population-" . code($population));
152 41 0 33     80 push(@tiles, "barren")
      33        
153             if $population eq 0
154             and $law eq 0
155             and $government eq 0;
156 41 100 66     98 push(@tiles, "low")
157             if $population >= 1 and $population <= 3;
158 41 100       62 push(@tiles, "high")
159             if $population >= 9;
160 41 100 100     158 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 41 100 100     93 push(@tiles, "non-agriculture")
      100        
165             if $atmosphere <= 3
166             and $hydro <= 3
167             and $population >= 6;
168             push(@tiles, "industrial")
169 41 100 100 199   141 if any { $atmosphere == $_ } 0, 1, 2, 4, 7, 9
  199         249  
170             and $population >= 9;
171 41 100       118 push(@tiles, "non-industrial")
172             if $population <= 6;
173 41 100 100     137 push(@tiles, "rich")
      100        
      100        
      66        
      100        
174             if $government >= 4 and $government <= 9
175             and ($atmosphere == 6 or $atmosphere == 8)
176             and $population >= 6 and $population <= 8;
177 41 100 100     109 push(@tiles, "poor")
      100        
178             if $atmosphere >= 2 and $atmosphere <= 5
179             and $hydro <= 3;
180 41         58 push(@tiles, "tech-" . code($tech));
181 41         71 push(@tiles, "government-" . code($government));
182 41         67 push(@tiles, "starport-$starport");
183 41         61 push(@tiles, "law-" . code($law));
184 41 100       78 push(@tiles, "naval") if $naval_base;
185 41 100       147 push(@tiles, "scout") if $scout_base;
186 41 100       90 push(@tiles, "research") if $research_base;
187 41 100       58 push(@tiles, "pirate", "red") if $pirate_base;
188 41 100 100     286 push(@tiles, "amber")
      100        
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 41         105 push(@tiles, qq{name="$name"}, qq{uwp="$uwp"});
198 41         121 return \@tiles;
199             }
200              
201             sub code {
202 574     574 0 640 my $code = shift;
203 574 100       1116 return $code if $code <= 9;
204 54         111 return chr(55+$code); # 10 is A
205             }
206              
207             sub compute_digraphs {
208 1     1 0 12 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         5 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         5 for (1 .. 10+rand(20)) {
216 19         28 push(@d, one(@first));
217 19         27 push(@d, one(@second));
218             }
219 1         7 return \@d;
220             }
221              
222             sub compute_name {
223 41     41 0 47 my $self = shift;
224 41         46 my $max = scalar @{$self->digraphs};
  41         77  
225 41         147 my $length = 3 + rand(3); # length of name before adding one more
226 41         51 my $name = '';
227 41         74 while (length($name) < $length) {
228 102         275 my $i = 2*int(rand($max/2));
229 102         149 $name .= $self->digraphs->[$i];
230 102         302 $name .= $self->digraphs->[$i+1];
231             }
232 41         167 $name =~ s/\.//g;
233 41         95 return ucfirst($name);
234             }
235              
236             sub one {
237 38     38 0 69 return $_[int(rand(scalar @_))];
238             }
239              
240             sub roll1d6 {
241 977     977 0 1447 return 1+int(rand(6));
242             }
243              
244             sub roll2d6 {
245 428     428 0 473 return roll1d6() + roll1d6();
246             }
247              
248             sub xy {
249 41     41 0 46 my $self = shift;
250 41         49 my $i = shift;
251 41         58 my $y = int($i / $self->cols);
252 41         148 my $x = $i % $self->cols;
253 41         171 $log->debug("$i ($x, $y)");
254 41         214 return $x + 1, $y + 1;
255             }
256              
257             sub label {
258 49     49 0 64 my ($self, $from, $to, $d, $label) = @_;
259 49         158 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         14  
267 1         7 my @coordinates = map { [ $self->xy($_), $systems{$_} ] } keys(%systems);
  41         60  
268 1         5 my @comms;
269             my @trade;
270 1         0 my @rich_trade;
271 1         4 while (@coordinates) {
272 41         57 my $from = shift(@coordinates);
273 41         67 my ($x1, $y1, $system1) = @$from;
274 41 50   563   100 next if any { /^starport-X$/ } @$system1; # skip systems without starports
  563         632  
275 41         88 for my $to (@coordinates) {
276 820         1215 my ($x2, $y2, $system2) = @$to;
277 820 50   11382   2304 next if any { /^starport-X$/ } @$system2; # skip systems without starports
  11382         12503  
278 820         2019 my $d = $self->distance($x1, $y1, $x2, $y2);
279 820 100 100     1640 if ($d <= 2 and match(qr/^(starport-[AB]|naval)$/, qr/^(starport-[AB]|naval)$/, $system1, $system2)) {
280 16         34 push(@comms, [$from, $to, $d]);
281             }
282 820 50 33     1472 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 40         88 push(@trade, [$from, $to, $d]);
325             }
326 820 100 100     2222 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 17         67 push(@rich_trade, [$from, $to, $d]);
332             }
333             }
334             }
335 1         2 @comms = sort map { $self->label(@$_, "communication") } @{$self->minimal_spanning_tree(@comms)};
  10         20  
  1         6  
336 1         5 @trade = sort map { $self->label(@$_, "trade") } @{$self->minimal_spanning_tree(@trade)};
  26         39  
  1         4  
337 1         8 @rich_trade = sort map { $self->label(@$_, "rich") } @{$self->minimal_spanning_tree(@rich_trade)};
  13         23  
  1         4  
338 1         17 return [@rich_trade, @comms, @trade];
339             }
340              
341             sub match {
342 1851     1851 0 3002 my ($re1, $re2, $sys1, $sys2) = @_;
343 1851 100 100 2604   4476 return 1 if any { /$re1/ } @$sys1 and any { /$re2/ } @$sys2;
  23077         36313  
  2604         4843  
344 1813 100 100 6190   5281 return 1 if any { /$re2/ } @$sys1 and any { /$re1/ } @$sys2;
  20149         39458  
  6190         9654  
345 1778         7524 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         13 my @Q = sort { @{$a}[2] <=> @{$b}[2] } @_;
  224         232  
  224         248  
  224         269  
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 73         80 my ($u, $v) = @{$edge};
  73         99  
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 73 100 100     226 if (not $C{$u} or not $C{$v} or $C{$u} != $C{$v}) {
      100        
365             # Add edge (v,u) to T.
366 49         65 push(@T, $edge);
367             # Merge C(v) and C(u) into one cluster, that is, union C(v) and C(u).
368 49 100 100     188 if ($C{$u} and $C{$v}) {
    100 66        
    100 66        
    50 33        
369 9         11 my @group;
370 9         28 foreach (keys %C) {
371 142 100       249 push(@group, $_) if $C{$_} == $C{$v};
372             }
373 9         36 $C{$_} = $C{$u} foreach @group;
374             } elsif ($C{$v} and not $C{$u}) {
375 13         30 $C{$u} = $C{$v};
376             } elsif ($C{$u} and not $C{$v}) {
377 13         34 $C{$v} = $C{$u};
378             } elsif (not $C{$u} and not $C{$v}) {
379 14         39 $C{$v} = $C{$u} = ++$id;
380             }
381             }
382             }
383 3         17 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         2 my $text = "";
391 1         7 for my $x (0 .. $self->cols - 1) {
392 8         26 for my $y (0 .. $self->rows - 1) {
393 80         146 my $tile = $tiles->[$x + $y * $self->cols];
394 80 50       262 if ($tile) {
395 80         267 $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         5 $text .= $self->legend();
401 1         48 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         7 my $y = int(($self->rows + 1) * $dy + 5);
409 1         9 $template .= qq{
410             other },
411             $x = int(($self->cols + 1) * 0.75 * $dx);
412 1         6 $y = int(($self->rows + 1) * $dy - 60);
413 1         8 $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         3 $y = int(($self->cols + 1) * 1.5 * $dx);
421 1         7 $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       10 $template .= qq{
429             other ■ imperial consulate – ☼ TAS – communication – trade long distance trade
430             } if $self->rows > 8;
431 1         16 return $template;
432             }
433              
434             1;