ctworldgen
A Go CLI that generates rules-accurate Classic Traveller subsectors and
sectors from Book 3 Worlds and Adventures, the Worlds chapter (pp. 1-12,
© 1977 text). It writes a JSON record from a recorded seed and renders it
as the Markdown listing a referee runs from, or as a printable PDF booklet
with the hex map drawn and the routes joined.
Install
go install github.com/philoserf/ctworldgen/cmd/ctworldgen@latest
What is built, and what is not
The engine walks the whole of Book 3 pp. 1-12 -- the eighty-hex occurrence
scan, starport types, naval and scout bases, commercial routes, and the
eight characteristics of every world -- all from one seed. render turns a
record into the listing, opening with a text map of the p. 3 hex grid, or
into the booklet. sector lays sixteen subsectors on one 32x40 grid and
throws for the routes at their seams.
Every page of pp. 1-12 is built, including p. 1's offer of an occurrence
DM "on broad areas within a subsector": --occurrence-area puts a rift in
one corner and a cluster in the other. It is new's alone -- an area is a
rectangle of one grid's numbering, and a sector's sixteen members are each
generated on their own p. 3 grid.
docs/COVERAGE.md is the live map of rule to code to test, and it carries a
row for what is not built as well as for what is. Ask it whether a rule is
in here; this section only says what the shape of the thing is.
What the tool should do comes from
issue 1, the first
alpha's report: it was played as a referee rather than as a developer, and
what that found is the backlog. The second alpha shipped everything it
asked for, the technological levels tables included, and it wants reading
the same way -- play it as a referee and file what the table finds.
Use
ctworldgen new [--seed N] [--name X] [--occurrence-dm N] [--occurrence-area DM@FROM-TO]... [-o file] [--force]
ctworldgen sector [--seed N] [--name X] [--occurrence-dm N] [-o file] [--force]
ctworldgen render [--format markdown|pdf] [--lanes legible|all] [-o file] [--force] record.json
ctworldgen version
$ ctworldgen new --seed 1977 --name Aramis --occurrence-dm -1
sector writes one record covering sixteen subsectors on a single 32x40
grid, 0101 through 3240, and throws for the commercial routes that cross
between them -- the routes sixteen independent subsectors can never have.
Every member is unchanged: member i of sector --seed N is exactly the
subsector new --seed N+i writes, so the subsector you read is the
subsector you get. The seams are one further reading (ERRATA.md E006),
stamped on every sector record.
render reads either record, a subsector or a sector, and writes the
Markdown listing by default. A sector's listing opens with an index map of
the whole 32x40 grid and a table of its sixteen, and then carries the
sixteen sub-sector listings themselves -- each on its own p. 3 grid, ringed
by one hex of its neighbours so the lanes crossing a seam have somewhere to
land, and each headed with the seed that writes it standalone (ERRATA.md
E008). --format pdf writes the booklet instead: the
map beside its roster on the first page, then the routes and a page of
detail per world. It is the one output that draws p. 2's "line connecting
the two worlds on the map", which a monospace grid has nowhere to put. A
booklet is a binary, so --format pdf needs -o, and it reproduces byte
for byte from the same record.
--occurrence-dm takes -1, 0 or +1 and nothing else, and defaults to 0. It
applies to every hex no broad area covers.
--occurrence-area is p. 1's other half -- a DM "on broad areas within a
subsector" -- and takes a DM, an @, and the two opposite corners of a
rectangle of hexes: --occurrence-area -1@0101-0805. It may be given more
than once, areas may not overlap, and the record carries every one of them
so a run still reproduces from what it holds. The reading is ERRATA E012.
A sector takes none: an area is a rectangle of one grid's numbering, and a
sector's sixteen members are each generated on their own p. 3 grid.
$ ctworldgen new --seed 1977 --name Aramis \
--occurrence-area -1@0101-0805 --occurrence-area +1@0106-0810
Without --seed, a seed is drawn from OS entropy and written into the
record, so a run is reproducible after the fact; --seed 0 is therefore an
explicit and distinct choice rather than a request for a random one.
Existing files are never overwritten without --force, and flags precede
any filename.
docs/examples/complete.json is a full record with every field populated;
task regenerate rewrites it, so it never drifts from what the tool
writes. docs/examples/minimal.json is the smallest record the schema
admits, and is held by hand.
What a seed fixes, and what it does not
A seed and the inputs reproduce a subsector exactly, and only exactly. All
the dice come from one stream in procedure order, so anything that changes
how many throws are made before a given world changes that world.
Changing either occurrence flag regenerates the subsector; it does not
thin the one you have. The occurrence scan throws one die per hex over all
eighty before anything else, so the same seed gives the same eighty faces
and the star fields nest: every hex placed at -1 is placed at 0, and every
hex placed at 0 is placed at +1. The map looks like a dial. But each extra
world consumes dice for its starport, its bases and its characteristics,
so the hexes two runs share keep almost nothing else — different starports,
different populations, a different string of digits. "The same subsector, a
touch sparser" is not available: change the seed for another subsector, and
hold the DM to keep this one. A broad area is the same story told over
fewer hexes -- it changes which faces succeed inside its rectangle, and
every world after the first difference is a different world.
Separate subsector files are a loop over new. sector is the answer
when the sixteen belong on one grid, because it throws for the routes at
their seams. When you want them as independent records instead, their
seeds are simply consecutive:
for i in $(seq 0 15); do
ctworldgen new --seed $((1977 + i)) --name Aramis -o "aramis-$i.json"
done
Sector seeds closer together than sixteen share subsectors. A sector's
members run on seed through seed + 15, so sector --seed 100 and
sector --seed 110 have six subsectors in common -- the same worlds with
the same digits, sitting in different corners of the two maps. Nothing
warns about it, because each sector is individually correct. Leave a gap
of at least sixteen when generating a second sector to set beside the
first.
Which lanes are drawn
A dense subsector throws a hundred and sixty commercial routes over
forty-six worlds, and a map with all of them on it cannot be read. P. 2
offers the map-drawer a way out in the book's own voice -- a connection
already present "may be ignored" -- and render takes it by default: a
lane whose two worlds are already joined by shorter lanes is not drawn and
not listed (ERRATA.md E007).
It removes about 46% of the lanes and changes the reachability of nothing,
because a lane is only dropped when its ends are already joined. The
summary line says how many were drawn, the route section says how many were
not, and --lanes all draws every one.
The record is unchanged and carries every lane. This is a decision
about ink, not about dice: the engine still examines every pair and
consumes every die (E003), so no seed's meaning moves.
Writing in the record
The record is the referee's notebook page, so there is a place to write in
it. notes on a world, and notes on the record as a whole, belong to the
referee: the tool never generates either and never reads them back. Both
survive re-rendering -- a world's note becomes a line in its detail block
and the record's becomes a paragraph under the heading, in the listing and
in the booklet alike.
{
"name": "Tessarane",
"notes": "The rift campaign. Players start at 0602.",
"worlds": [{ "hex": "0602", "name": "Reagan", "notes": "dust storms" }]
}
Nothing else is admitted. Every other key the record does not define is
still refused, which is what makes the generated fields worth trusting;
notes is a field the record names rather than a hole in that rule.
Where the rules come from
Rules come only from the held PDFs of the FFE reprints, never from memory.
Training-data Traveller is mostly the 1981 revision and later editions, and
the held © 1977 page governs even where it differs -- most visibly in the
string of digits, which carries no hyphen (A867A698, not A867A69-8).
Book 3 pp. 1-12 is the ruleset. Book 1 pp. 2-3 and p. 8 supply the die roll
conventions and the hexadecimal notation that Book 3 uses without
restating. Nothing else is in authority: not Books 2 and 4+, not the
supplements, the Starter Edition, The Traveller Book, JTAS, the
Consolidated Errata, or the rest of Book 3.
Two habits follow, and they are the ones worth knowing from outside:
- Every table is transcribed from a visual read of the page, then
transcribed a second time inside the table package's tests, so the two
must agree. The held PDFs' embedded font maps the em-dash to the glyph
4 and the minus sign to 3, so a text extraction renders the jump
routes table's empty cells as the digit 4 and the size formula 2D − 2
as 2D32. Both readings are wrong and both look like data.
- No ambiguity is resolved in silence. Where the page does not settle
something, the reading goes in
docs/ERRATA.md with its page cite and
the condition under which a record stamps it, and every record carries
the stamps that applied to it.
The documents
docs/ERRATA.md records every reading of an ambiguous or silent page.
docs/COVERAGE.md maps the rules to the implementation and the tests.
docs/record.schema.json is the record's schema, with a minimal and a
complete example beside it. CLAUDE.md carries the authority model in
full, and the traps -- the font, the hex grid parity, the dice-stream
consumption order -- that a change to this code has to respect.
THEORY.md is the account of why the code is shaped as it is: what a
maintainer has to hold in mind to change it without damaging it, and where
that account is uncertain. walkthrough.md is the tour of how it runs,
entry point to output, and every fenced block in it is executable -- uvx showboat verify walkthrough.md re-runs them all and diffs the captured
output, so the document cannot quietly stop being true.
Development
task # the whole gate: tidy, vet, lint (formatting included), nilaway, test -race, coverage ratchet
task regenerate # rewrite the golden fixtures and the complete example, then read the diff
CI runs exactly task. The toolchain is deliberately unpinned: the gate is
meant to fail when a tool moves rather than drift behind it, so a red gate
on code you did not touch is the signal working. Answer the finding; do not
pin a tool or add a linter disable to silence it.
main is protected -- pull request, a green gate, linear history, no
bypass -- so work lands on a branch and merges through CI.
Licence
MIT. See LICENSE. Traveller is © Far Future Enterprises; this tool
implements the rules and reproduces none of the text beyond the table
labels a listing needs.
One thing is cited rather than implemented. What a technological index
means is glossed from Traveller5 Core Book 2 pp. 230-232 (© 2015-2019
Far Future Enterprises), because Book 3 pp. 10-11 print it as ten sparse
columns of 1977 shorthand that cannot be read at a table. The borrowing
is description only -- it changes no throw, no record field and no stamp
-- and both books are FFE's, so this is one of their books glossing
another. docs/ERRATA.md E011 states its whole extent.