Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,17 @@ dependencies. (The former `docs/automation-catalog.md` planning doc has been ret
- **YAGNI.** The user actively prunes speculative scaffolding — do not create empty directories,
placeholder packages, or `.gitkeep` trees ahead of real content. Add structure when something
goes in it.
- **Naming: rules-faithful without being a slave to the terminology.** When a book term and Go
readability conflict, Go readability wins for ad-hoc abbreviations and chart-formula names
baked into a signature (`csk` → `attackerAsset`, `CCMode` → `ControllingCharMode`, `szPlus1D` →
`detonationRoll`) — keep the book's own wording in the doc comment instead of the identifier. A
genuine rulebook term-of-art, one a reader will meet by that exact name in the book (`Flux`,
`Poice`, `ThisIsHardDice`), stays as the identifier verbatim, book-cited in the doc comment as
usual — that is the case where the book term should win. Go conventions (a consistent suffix
family, no collision with a sibling package's flagship type, and no name-stutters-package
(`revive`) — e.g. `route.World` renamed to `route.Node` to stop colliding with
`worldgen.World` without stuttering as `route.RouteWorld` would) win over literal
transcription generally.
- Commit only when asked (see the user's global git guidance).
- **A fix that changes nothing measurable usually means the reading is wrong.** If a rules
correction (a formula tweak, a re-derived cell, a re-read chart) lands and every golden test
Expand Down
16 changes: 8 additions & 8 deletions cmd/shipgen/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,18 +245,18 @@ func missionCode(s string) (string, error) {
// name and the default spec differ.
type installation struct {
name string
mount Mount
rng Range
mount optionalMount
rng optionalRange
}

// Mount and Range here are the optional halves of an entry — "unset" is a real
// state, meaning "use the component's own default".
// optionalMount and optionalRange here are the optional halves of an entry —
// "unset" is a real state, meaning "use the component's own default".
type (
Mount struct {
optionalMount struct {
set bool
value shipgen.Mount
}
Range struct {
optionalRange struct {
set bool
value shipgen.Range
}
Expand Down Expand Up @@ -290,7 +290,7 @@ func parseInstallations(list, kind string) ([]installation, error) {
parts[1], strings.Join(shipgen.MountCodes(), ", "))
}

inst.mount = Mount{set: true, value: m}
inst.mount = optionalMount{set: true, value: m}
}

if len(parts) > 2 && parts[2] != "" {
Expand All @@ -300,7 +300,7 @@ func parseInstallations(list, kind string) ([]installation, error) {
parts[2], strings.Join(shipgen.RangeNames(), ", "))
}

inst.rng = Range{set: true, value: r}
inst.rng = optionalRange{set: true, value: r}
}

out = append(out, inst)
Expand Down
6 changes: 3 additions & 3 deletions docs/rules/mastermod.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ with both key columns blank, so they cannot be rolled and are correctly excluded
**None.** `mastermod.Table.Lookup` accepts an already-rolled total (the caller's own dice, rolled
however the caller's context requires) — it never calls a `*dice.Roller` itself, so there is
nothing to audit here the way `vehiclegen.Generate`'s roll sequence needs auditing. Dispatching a
table's own `Dice` notation (e.g. "2x1D", "Hits/2") through `dice.Parse`/`Eval` to actually
table's own `Notation` string (e.g. "2x1D", "Hits/2") through `dice.Parse`/`Eval` to actually
produce that total is the deferred roll bridge, #366 — deliberately not built ahead of a
consumer (YAGNI), per that issue's own text.

Expand All @@ -107,8 +107,8 @@ Confirmed against the rendered pages in this pass — all three are genuine excl
- **Barrier Height/Width/Depth** (chart 14, p.268): blank cells in the printed appendix.
- **Scene Mods** (chart 15, p.268): a formula, not a die table.
- **Imperiallines and Hortalez** (chart 21, p.269): no 2×1D key printed, so unrollable.
- **The roll bridge** (dispatching a table's `Dice` string through `dice.Parse`/`Eval` to actually
produce a Lookup-ready total) — tracked as #366, waiting on a first consumer.
- **The roll bridge** (dispatching a table's `Notation` string through `dice.Parse`/`Eval` to
actually produce a Lookup-ready total) — tracked as #366, waiting on a first consumer.

## Errata / resolved conflicts

Expand Down
4 changes: 2 additions & 2 deletions internal/benchmark/benchmark_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,9 +287,9 @@ func TestSizeAt(t *testing.T) {
descriptor string
ok bool
}{{SizeR, "Reading", true}, {Size5, "Person", true}, {Size9, "Moonlet", true}, {"X", "", false}} {
got, ok := SizeAt(c.code)
got, ok := Size(c.code)
if ok != c.ok || got.Descriptor != c.descriptor {
t.Errorf("SizeAt(%q) = (%+v, %v), want descriptor %q ok %v", c.code, got, ok, c.descriptor, c.ok)
t.Errorf("Size(%q) = (%+v, %v), want descriptor %q ok %v", c.code, got, ok, c.descriptor, c.ok)
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions internal/benchmark/size.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ var sizes = map[SizeCode]SizeEntry{
Size7: {Size7, "Ship"}, Size8: {Size8, "Big Ship"}, Size9: {Size9, "Moonlet"},
}

// SizeAt returns a broad benchmark size.
func SizeAt(code SizeCode) (SizeEntry, bool) {
// Size returns a broad benchmark size.
func Size(code SizeCode) (SizeEntry, bool) {
value, ok := sizes[code]

return value, ok
Expand Down
6 changes: 3 additions & 3 deletions internal/chargen/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ auto-begin Citizen life (T5 has no draft — no one ends up careerless). Educati
`Policy.PursueEducation`, so a no-education policy leaves any dice trace (e.g. the golden
Scout's) untouched.

The term engine (`career.go`) is career-agnostic with pluggable seams (`CCMode` Rotate/Fixed —
under FixedCC the policy picks one CC that serves the whole career, `AdvanceRule`
The term engine (`career.go`) is career-agnostic with pluggable seams (`ControllingCharMode`
Rotating/Fixed — under FixedControllingChar the policy picks one CC that serves the whole career, `AdvanceRule`
RollLow/RollHigh, `Qualification` char-set, `ContinueRule` fixed/char/UseCC/TermsMod, `BenefitDM`
selecting the muster Benefit-column die modifier (`MusterDM`: Terms/OfficerRank/Rank/FameHalf),
and the rank ladders `EnlistedRanks`/`OfficerRanks` +
Expand All @@ -49,7 +49,7 @@ character's `Medals` — earned on a held Risk (an XS) as well as a passed Rewar
documented at `promoted`. Each rank grants its automatic skill.

Careers are data, each a file + hand-traced golden: `ScoutCareer` (`scout.go`, p. 79),
`RogueCareer` (`rogue.go`, p. 84 — FixedCC), `SoldierCareer` (`soldier.go`, p. 82 — the first
`RogueCareer` (`rogue.go`, p. 84 — FixedControllingChar), `SoldierCareer` (`soldier.go`, p. 82 — the first
ranked career), `MarineCareer` (`marine.go`, p. 86), `SpacerCareer` (`spacer.go`, p. 81 — the
naval career, whose Rating ladder uses the engine's EnlistedPromote), `AgentCareer` (`agent.go`,
p. 83 — a rankless `Term UndercoverTerm` whose `awardUndercover` borrows one skill from a rolled
Expand Down
2 changes: 1 addition & 1 deletion internal/chargen/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ var AgentCareer = Career{
ID: Agent,
Name: "Agent",
Qualify: Qualification{Chars: []Characteristic{Endurance}}, // C3
CCMode: RotateCC,
CCMode: RotatingCC,
ControllingChars: []Characteristic{Strength, Dexterity, Endurance, Intelligence}, // C1 C2 C3 C4
Continue: ContinueRule{
UseChar: true,
Expand Down
20 changes: 10 additions & 10 deletions internal/chargen/career.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,15 @@ const (
Merchant // a dual-track (Rating/Officer) career with Ship Shares (Book 1 p. 80)
)

// CCMode controls how a career's Controlling Characteristic is chosen each term:
// ControllingCharMode controls how a career's Controlling Characteristic is chosen each term:
// rotated through the available set (the default) or fixed for the whole career
// (Rogue).
type CCMode int
type ControllingCharMode int

// Controlling-Characteristic selection modes for the term engine.
const (
RotateCC CCMode = iota
FixedCC
RotatingCC ControllingCharMode = iota
FixedControllingChar
)

// A Qualification is a career's entry gate: roll 2D at or under the best of the
Expand Down Expand Up @@ -121,7 +121,7 @@ type Career struct {
ID CareerID
Name string
Qualify Qualification
CCMode CCMode
CCMode ControllingCharMode
ControllingChars []Characteristic
Continue ContinueRule
EligPerTerm int // number of skill rolls a surviving term grants
Expand Down Expand Up @@ -226,7 +226,7 @@ type CareerRecord struct {
// generation (kept out of Character, like systemgen's orbit bookkeeping).
type careerRun struct {
ccPool []Characteristic // Controlling Characteristics not yet used this cycle
fixed Characteristic // the chosen Controlling Characteristic under FixedCC
fixed Characteristic // the chosen Controlling Characteristic under FixedControllingChar
fixedChosen bool // whether fixed has been selected yet
rank int // current rank number (1-based) for a rank career
officer bool // whether rank is on the officer track
Expand Down Expand Up @@ -340,7 +340,7 @@ func beginCareer(r *dice.Roller, p Policy, c *Character, run *careerRun, career
}

var target int
if career.CCMode == FixedCC {
if career.CCMode == FixedControllingChar {
target = c.Score(selectCC(p, *c, run, career))
} else {
target = career.Qualify.target(*c)
Expand Down Expand Up @@ -629,12 +629,12 @@ func classifyInjury(original, negMods, flux int) (Injury, int) {
}
}

// selectCC picks the term's Controlling Characteristic. Under RotateCC a
// selectCC picks the term's Controlling Characteristic. Under RotatingCC a
// characteristic cannot be reused until the whole set has been used; under
// FixedCC the policy chooses one characteristic on the first term and it serves
// FixedControllingChar the policy chooses one characteristic on the first term and it serves
// the entire career.
func selectCC(p Policy, c Character, run *careerRun, career Career) Characteristic {
if career.CCMode == FixedCC {
if career.CCMode == FixedControllingChar {
if !run.fixedChosen {
run.fixed = p.ChooseCC(c, career.ControllingChars)
run.fixedChosen = true
Expand Down
4 changes: 2 additions & 2 deletions internal/chargen/career_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ var testCareer = Career{
ID: Scout,
Name: "Scout",
Qualify: Qualification{Chars: []Characteristic{Intelligence}},
CCMode: RotateCC,
CCMode: RotatingCC,
ControllingChars: []Characteristic{Strength, Dexterity, Endurance, Intelligence},
Continue: ContinueRule{UseChar: true, Char: Intelligence},
}
Expand Down Expand Up @@ -77,7 +77,7 @@ func TestSelectCCRotation(t *testing.T) {
}

func TestSelectCCFixed(t *testing.T) {
fixed := Career{CCMode: FixedCC, ControllingChars: []Characteristic{Endurance}}
fixed := Career{CCMode: FixedControllingChar, ControllingChars: []Characteristic{Endurance}}

run := careerRun{}
for range 3 {
Expand Down
2 changes: 1 addition & 1 deletion internal/chargen/marine.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ var MarineCareer = Career{
ID: Marine,
Name: "Marine",
Qualify: Qualification{Chars: []Characteristic{Strength}},
CCMode: RotateCC,
CCMode: RotatingCC,
ControllingChars: []Characteristic{Strength, Intelligence}, // C1 C4
Continue: ContinueRule{UseChar: true, Char: Strength}, // C1
EligPerTerm: 4,
Expand Down
4 changes: 2 additions & 2 deletions internal/chargen/rogue.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@ var soldierSkls = []string{"Fighter", "Gunner", "Heavy Weapons", "Tactics"}

// RogueCareer is the Rogue (Book 1 p. 84): a fixed-CC career. To Begin, Risk &
// Reward, and Continue all use the one selected Controlling Characteristic, so
// the career carries no Qualification — under FixedCC, beginCareer rolls against
// the career carries no Qualification — under FixedControllingChar, beginCareer rolls against
// the chosen CC and never reads one.
var RogueCareer = Career{
ID: Rogue,
Name: "Rogue",
CCMode: FixedCC,
CCMode: FixedControllingChar,
ControllingChars: allChars,
Continue: ContinueRule{UseCC: true, TermsMod: true}, // "Mod +Terms" (Book 1 p. 84)
AutoFailOn12: true, // "But, 12 is always automatic failure" (p. 84)
Expand Down
2 changes: 1 addition & 1 deletion internal/chargen/rogue_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ func TestRogueSchemeInfamy(t *testing.T) {
}

func TestRogueFixedCCChosenOnce(t *testing.T) {
// Under FixedCC the policy selects the CC once (highest score) and it is
// Under FixedControllingChar the policy selects the CC once (highest score) and it is
// reused every term.
c := Character{scores: [count]int{6, 6, 9, 6, 6, 6}} // Endurance highest
run := careerRun{}
Expand Down
2 changes: 1 addition & 1 deletion internal/chargen/scholar.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ var ScholarCareer = Career{
Chars: []Characteristic{Education},
}, // an Amateur (Edu <8) rolls 2D <= Edu to Begin
PromoteEduMin: 8, // Edu 8+ auto-begins at Scholar1 and may promote; else an Amateur (rank 0)
CCMode: RotateCC,
CCMode: RotatingCC,
ControllingChars: []Characteristic{Strength, Dexterity, Endurance, Intelligence}, // C1 C2 C3 C4
Continue: ContinueRule{
UseChar: true,
Expand Down
2 changes: 1 addition & 1 deletion internal/chargen/scout.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ var ScoutCareer = Career{
ID: Scout,
Name: "Scout",
Qualify: Qualification{Chars: []Characteristic{Strength, Dexterity, Endurance}},
CCMode: RotateCC,
CCMode: RotatingCC,
ControllingChars: []Characteristic{Strength, Dexterity, Endurance},
Continue: ContinueRule{UseChar: true, Char: Intelligence},
EligPerTerm: 8, // Explorer duty
Expand Down
2 changes: 1 addition & 1 deletion internal/chargen/soldier.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ var SoldierCareer = Career{
ID: Soldier,
Name: "Soldier",
Qualify: Qualification{Chars: []Characteristic{Strength}},
CCMode: RotateCC,
CCMode: RotatingCC,
ControllingChars: []Characteristic{Strength, Endurance, Intelligence}, // C1 C3 C4
Continue: ContinueRule{UseChar: true, Char: Endurance}, // C3
EligPerTerm: 4,
Expand Down
7 changes: 7 additions & 0 deletions internal/chargen/sophont.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ func GenerateSophont(r *dice.Roller, species sophont.Species) Character {
// A slot with 0 dice carries no characteristic value — the Caste C6 of a
// caste species, whose caste comes from the table roll below. RollValue rolls
// nothing for it, leaving the score at its 0 sentinel and the stream intact.
//
// This loop trusts species.Chars[i] to be slot C(i+1), matching
// Characteristic(i)'s own C1..C6 order (Strength=C1 ... Social=C6) — it never
// reads species.Chars[i].Name, only .Dice. That correspondence currently holds
// because sophont.rollCharacteristics is the sole constructor of Chars and
// always fills it in fixed C1..C6 order, but nothing here checks it: a
// reordered build or a second constructor would silently misassign scores.
for i := range c.scores {
c.scores[i] = sophont.RollValue(r, species.Chars[i].Dice)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/chargen/spacer.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ var SpacerCareer = Career{
ID: Spacer,
Name: "Spacer",
Qualify: Qualification{Chars: []Characteristic{Intelligence}},
CCMode: RotateCC,
CCMode: RotatingCC,
ControllingChars: []Characteristic{Strength, Dexterity, Intelligence}, // C1 C2 C4
Continue: ContinueRule{UseChar: true, Char: Strength}, // C1
EligPerTerm: 4,
Expand Down
11 changes: 6 additions & 5 deletions internal/epic/epic.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,10 @@ type Act struct {
Scenes []Scene
}

// A Finale is the climax after Act 4. Unlike an act, it has as many scenes as
// the authored resolution needs, so a scaffold begins with no fixed slots.
type Finale struct {
// A Climax is the freeform final block of scenes after Act 4. Unlike an act,
// it has as many scenes as the authored resolution needs, so a scaffold
// begins with no fixed slots.
type Climax struct {
Purpose string
Scenes []Scene
}
Expand All @@ -42,7 +43,7 @@ type Finale struct {
type Adventure struct {
Theme string
Acts []Act
Climax Finale
Climax Climax
}

var actPurposes = [...]string{
Expand All @@ -68,7 +69,7 @@ func Scaffold() Adventure {

return Adventure{
Acts: acts,
Climax: Finale{Purpose: climaxPurpose},
Climax: Climax{Purpose: climaxPurpose},
}
}

Expand Down
2 changes: 1 addition & 1 deletion internal/mastermod/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ cell's correctness is in question.
**Diagnosis Severity** (p.269) has NINE.
- The chart 03/07/12 "Flux" tables (pp.265–267) run **-6..+6** — 13 rows,
wider than plain Flux.
- `Dice` strings not parseable by `dice.Parse` are allowlisted by exact
- `Notation` strings not parseable by `dice.Parse` are allowlisted by exact
string in the tests: `Bad Flux`, `2x1D`, `Hits/2`. A typo fails the suite.
- **Printed typos are preserved, not corrected** (verified against the
rendered PDF pages, not the text extract): `Truthfullness` (p.267 — also
Expand Down
26 changes: 13 additions & 13 deletions internal/mastermod/mastermod.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,16 @@ import (
"strings"
)

// Table is a named die-total lookup. Dice documents the source roll notation.
// With Rolls empty the keys are contiguous and Rows[0] corresponds to Minimum;
// a non-empty Rolls lists each row's key explicitly, for sparse or
// Table is a named die-total lookup. Notation documents the source roll
// notation. With Rolls empty the keys are contiguous and Rows[0] corresponds
// to Minimum; a non-empty Rolls lists each row's key explicitly, for sparse or
// non-contiguous source columns.
type Table struct {
Name string
Dice string
Minimum int
Rows []string
Rolls []int // optional explicit keys for sparse/non-contiguous source columns
Name string
Notation string
Minimum int
Rows []string
Rolls []int // optional explicit keys for sparse/non-contiguous source columns
}

// Maximum returns the largest accepted total.
Expand All @@ -40,7 +40,7 @@ func (t Table) Maximum() int {
// Valid reports whether a table has a name, roll notation, and at least one
// non-placeholder row. Blank source cells are omitted when tables are built.
func (t Table) Valid() bool {
if strings.TrimSpace(t.Name) == "" || strings.TrimSpace(t.Dice) == "" || len(t.Rows) == 0 {
if strings.TrimSpace(t.Name) == "" || strings.TrimSpace(t.Notation) == "" || len(t.Rows) == 0 {
return false
}

Expand Down Expand Up @@ -126,14 +126,14 @@ func register(tables ...Table) {
}
}

func table(name, dice string, minimum int, rows ...string) Table {
return Table{Name: name, Dice: dice, Minimum: minimum, Rows: rows}
func table(name, notation string, minimum int, rows ...string) Table {
return Table{Name: name, Notation: notation, Minimum: minimum, Rows: rows}
}

func sparse(name, dice string, rolls []int, rows ...string) Table {
func sparse(name, notation string, rolls []int, rows ...string) Table {
if len(rolls) == 0 {
panic(fmt.Sprintf("mastermod: sparse table %q has no rolls", name))
}

return Table{Name: name, Dice: dice, Minimum: rolls[0], Rolls: rolls, Rows: rows}
return Table{Name: name, Notation: notation, Minimum: rolls[0], Rolls: rolls, Rows: rows}
}
Loading