# legacy-report-to-json

Converts legacy text-format Galaxy turn reports (the *dg* and *gplus*
engines that lived under `tools/local-dev/reports/`) into a JSON
envelope around [`pkg/model/report.Report`](../../../pkg/model/report)
plus full `BattleReport`s (Phase 27).

## Output envelope

```jsonc
{
  "version": 1,
  "report":  { /* report.Report */ },
  "battles": { "<uuid>": { /* report.BattleReport */ }, ... }
}
```

`version: 1` lets the UI distinguish a current-format envelope from a
bare `Report` JSON. The synthetic-report loader accepts both — pre-
envelope synthetic JSON files still load, just without battle
fixtures. `battles` is omitted when the legacy file has no combat
events.

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/KNNTS041.REP \
    --out tools/local-dev/reports/dg/KNNTS041.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`                | `<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[]`       | `<Race> Planets` (one per race)      |
| `UninhabitedPlanet[]` | `Uninhabited Planets`                |
| `UnidentifiedPlanet[]`| `Unidentified Planets`               |
| `LocalShipClass[]`    | `Your Ship Types`                    |
| `OtherShipClass[]`    | `<Race> Ship Types` (Phase 23)       |
| `LocalScience[]`      | `Your Sciences` (Phase 23)           |
| `OtherScience[]`      | `<Race> 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)         |
| `Battle[]` (summary)  | `Battle at (#N) Name` headers + `Battle Protocol` (Phase 27 follow-up) |

The envelope's `battles` map carries the full `BattleReport`-s parsed
out of the same blocks: every roster row turns into a
`BattleReportGroup` (`Number`/`Tech`/`LoadType`/`LoadQuantity`/
`NumberLeft`/`InBattle`), every `... fires on ... : Destroyed|Shields`
line turns into a `BattleActionReport`. UUIDs are synthesised
deterministically — `syntheticBattleID(idx)` for the battle
identifier (per-report 0-based index, SHA1 namespace
`be01a000-0000-0000-0000-000000000002`) and
`syntheticBattleRaceID(name)` for `BattleReport.Races` entries (SHA1
namespace `be01a000-0000-0000-0000-000000000003`). Re-running the
converter on the same input file yields byte-identical JSON, so
synthetic-mode UI URLs (`/games/synthetic-…/battle/<uuid>?turn=N`)
stay stable across regenerations.

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.

## Foreign and unidentified groups

The legacy text format does carry top-level `<Race> Groups` blocks
and a single `Unidentified Groups` block, both outside the battle
rosters — earlier parser revisions silently dropped them. F8-05
wires them up:

- **`OtherGroup[]`** — every `<Race> Groups` section outside a
  `Battle at` block contributes one entry per row. The legacy row
  is `# T D W S C T Q D P M` (count, class, drive/weapons/shields/
  cargo tech, cargo type, load, destination, power=drive·20 — not
  retained, mass). The destination resolves against the parsed
  planet tables (`Your Planets`, `<Race> Planets`, `Uninhabited
  Planets`); rows whose destination is invisible to the local
  player are dropped — preferable to fabricating a number. The
  legacy row carries no origin / range columns, so foreign groups
  surface as stationed at the destination (origin / range nil).
- **`UnidentifiedGroup[]`** — the `Unidentified Groups` section
  carries `X Y` floats only. Each row maps directly onto
  `UnidentifiedGroup{X, Y}`; no planet resolution needed.

## 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).

- 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 `site/ru/rules.md`
   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.
