# legacy-report-to-json Converts legacy text-format Galaxy turn reports (the *dg* and *gplus* engines that lived under `tools/local-dev/reports/`) into the JSON shape of [`pkg/model/report.Report`](../../../pkg/model/report). The output is consumed by the **DEV-only synthetic-report loader** on the UI client's lobby (`import.meta.env.DEV`). With it, the map view, inspectors, and order-overlay can be exercised against rich game states without playing many turns end-to-end against a real backend. The tool is part of the synthetic-report parity rule documented in [`ui/PLAN.md`](../../../ui/PLAN.md). ## Build / run ```sh # from the repo root, with the Go workspace active go run ./tools/local-dev/legacy-report/cmd/legacy-report-to-json \ --in tools/local-dev/reports/dg/KNNTS039.REP \ --out tools/local-dev/reports/dg/KNNTS039.json ``` `--in` reads `-` as stdin; `--out` defaults to stdout when empty or `-`. The tool exits non-zero on any I/O or parse failure. ## Supported input variants | Variant | Sample dir | Status | | ------- | ------------------------------------- | ------------- | | dg | `tools/local-dev/reports/dg/*.REP` | First-class | | gplus | `tools/local-dev/reports/gplus/*.REP` | First-class | | ng | `tools/local-dev/reports/ng/*.rep` | Not supported | | lucky | `tools/local-dev/reports/lucky/*.rep` | Not supported | dg uses CRLF line endings, gplus uses LF and tabs in section indentation; both are space-aligned tabular inside data blocks. The parser splits on runs of whitespace (`strings.Fields`) so the same code handles both. Pseudo-Cyrillic glyphs (`MbI`, `KAMA3`, `9IMA`) appear in some races and ship class names but are stored as plain ASCII letter substitutions — no encoding conversion is needed. ## In-scope fields (current) The parser only fills the subset of `report.Report` that the UI client already decodes from server responses (`ui/frontend/src/api/game-state.ts` → `decodeReport`): | `report.Report` field | Source section in legacy file | | --------------------- | ------------------------------------ | | `Race` | ` Report for Galaxy ...` line | | `Turn` | same | | `Width`, `Height` | `Size: N` (square galaxies) | | `PlanetCount` | `Planets: N` | | `VoteFor`, `Votes` | `Your vote:` block | | `Player[]` | `Status of Players (total ...)` | | `LocalPlanet[]` | `Your Planets` | | `OtherPlanet[]` | ` Planets` (one per race) | | `UninhabitedPlanet[]` | `Uninhabited Planets` | | `UnidentifiedPlanet[]`| `Unidentified Planets` | | `LocalShipClass[]` | `Your Ship Types` | | `OtherShipClass[]` | ` Ship Types` (Phase 23) | | `LocalScience[]` | `Your Sciences` (Phase 23) | | `OtherScience[]` | ` Sciences` (Phase 23) | | `Bombing[]` | `Bombings` (Phase 23) | | `ShipProduction[]` | `Ships In Production` (Phase 23) | | `LocalGroup[]` | `Your Groups` (Phase 19) | | `LocalFleet[]` | `Your Fleets` (Phase 19) | | `IncomingGroup[]` | `Incoming Groups` (Phase 19) | Players whose name in the legacy file ends with `_RIP` are emitted with the suffix stripped and `Extinct: true`. `LocalGroup.ID` is synthesised deterministically from the per-report group index via `uuid.NewSHA1`, so re-running the converter on the same input file yields byte-identical JSON. `LocalGroup.Speed` is left at zero — the legacy "Your Groups" table does not expose ship speed; the UI can derive it from `pkg/calc.Speed` if ever required. Origin / Range names that don't resolve against the parsed planet tables (foreign-only knowledge the local player lacks) cause the entire group / fleet / incoming row to be dropped — preferable to fabricating a destination. `ShipProduction.ProdUsed` is derived from the on-disk `Percent` and the producing planet's material/resources via [`pkg/calc.ShipBuildCost`] (the same helper the engine's `controller.ProduceShip` uses). The legacy text format does not carry a `prod_used` column directly; the derivation gives the cumulative production-equivalent of the build progress so far. The real engine's `ProdUsed` is the per-turn residual production poured into the partial ship, which is not recoverable from a single legacy snapshot. The two numbers stay in the same units and the same ballpark, which is good enough for the synthetic-mode UI — live engine reports come over the FBS wire and do not flow through this parser. A ships-in-production row pointing at a planet that did not appear in `Your Planets` (which would be a malformed legacy file) is dropped. ## Skipped sections (today) These exist in legacy reports but cannot be derived from the legacy text format at all. Each could become in-scope if a strong enough reason arises (see "Adding a new field" below). - Battles (`Battle at (#N) Name`, `Battle Protocol`) — the wire schema carries battle UUIDs (`Report.Battle: []uuid.UUID`); the legacy text carries per-battle rosters with stripped columns (no origin / range / destination) and no stable identifier. Synthesising UUIDs from the text would invent data that future Phase 27 work would have to drop; the synthetic JSON therefore emits `battle: []`. - `OtherGroup[]` — no top-level legacy section. Foreign groups appear only inside battle rosters (see above), with stripped columns; the synthetic JSON emits `otherGroup: []`. - `UnidentifiedGroup[]` — no legacy section at all; synthetic JSON emits `unidentifiedGroup: []`. - Cargo routes — no dedicated section in the legacy text format; the synthetic JSON emits `route: []`. The UI's overlay path (`applyOrderOverlay`) supports running on top of an empty `routes`. ## Adding a new field `ui/PLAN.md` carries a global rule: every UI phase that extends `decodeReport` to read a new `report.Report` field also extends this parser, in the same PR, to populate it from legacy text — or, if the field cannot be derived, adds an entry to the **Skipped sections** list above with a one-line explanation. The Go side of the rule is enforced mechanically: this tool imports `galaxy/model/report`, so any backwards-incompatible change to the schema breaks the tool's compilation before the change ships. When extending: 1. Identify the legacy section in `tools/local-dev/reports/dg/*.REP` (and `gplus/*.REP`) that carries the field, using `game/rules.txt` section "Отчет о результатах хода" as the column-layout reference. 2. Add a section to the state machine in `parser.go` (`classifySection`, the `section` constants, the `parse*` methods). 3. Cover the new section with a unit test in `parser_test.go` (inline minimal fixture) and update the smoke counts in `TestParseDgKNNTS039` / `TestParseGplus40` so a future regression that drops the section is caught. 4. Run `go test ./tools/local-dev/legacy-report/...`, then re-run the CLI on `dg/KNNTS039.REP` and `gplus/40.REP` and visually skim the JSON — the field should appear with sensible values. ## Tests ```sh go test ./tools/local-dev/legacy-report/... ``` Inline fixtures exercise the per-section row parsers; smoke tests parse the real fixtures under `tools/local-dev/reports/dg/` and `tools/local-dev/reports/gplus/` and assert top-level counts. The current smoke set spans: - **dg/KNNTS039–041** — KnightErrants saga; `041` is the only one with `Incoming Groups`, exercising deferred name resolution. - **dg/Killer031** — Killer engine variant with two `Your Fleets` entries (`Fl1`, `F2`). - **dg/Tancordia037** — the richest fixture: 311 local groups in 30 fleets, two incoming groups, "Incoming Groups" landing before "Your Planets". - **gplus/40.REP** — gplus variant; tabs in headers, pseudo-cyrillic ship class names, single fleet, ten incoming groups. Field-level fidelity is the inline tests' responsibility; the smoke tests catch regressions where a refactor of the section classifier silently drops a whole table.