feat(ui+legacy): F8-05 owner-feedback round 1 — inspector tweaks + parser
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 2m45s

Owner-reported polish on top of #48, plus a legacy-parser gap that
prevented verifying stationed ship groups against a real .REP fixture.

UI:
- Production: drop the empty `(production)` placeholder option. Owned
  planets always produce something, so the primary select now opens on
  `industry` by default when `planet.production` is null/unknown,
  keeping the row inside the four real production kinds at all times.
- Production: lock the row to a single line (no flex-wrap) and strip
  border + padding from the ✓/✗ buttons so the apply/cancel icons read
  as glyphs and the row no longer breaks into two visual rows for
  Research / Ship contexts where both selects are present.
- Cargo routes: the placeholder option is now an `<option disabled>`
  styled like a section header (greyed, italic) and reads "manage
  routes" instead of "cargo routes". The wording shifts the intent
  from a section label to an action prompt.

Legacy parser:
- F8-05 (#48 п.32) "Stationed ship groups" couldn't be verified against
  the dg fixture because the legacy `<Race> Groups` blocks (outside
  battles) and the `Unidentified Groups` block were dropped by the
  parser — both are now wired up. Foreign group rows parse the
  `# T D W S C T Q D P M` columns and resolve the destination against
  the parsed planet tables (rows with an invisible destination drop,
  matching the existing local-group convention). The legacy row
  carries no origin / range columns, so foreign groups surface as
  stationed at the destination.
- Smoke tests on every fixture extended with `otherGroups` and
  `unidentifiedGroups` counts. New focused unit test
  `TestParseOtherAndUnidentifiedGroups` covers the column layout, the
  drop-on-unknown-destination rule, and the `X Y`-only unidentified
  rows.
- `tools/local-dev/reports/dg/KNNTS039.json` and
  `tools/local-dev/reports/dg/KNNTS041.json` regenerated so the
  synthetic-loader fixtures carry the new arrays.
- README updated: the two sections move out of "Skipped sections" into
  a "Foreign and unidentified groups" block; package doc-comment
  reflects the broader scope.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-27 15:21:55 +02:00
parent aee5f39a7e
commit cc4bc3c2b7
10 changed files with 46770 additions and 9291 deletions
+1 -2
View File
@@ -292,7 +292,6 @@ const en = {
"game.inspector.planet.rename.invalid.disallowed_character": "name contains disallowed characters",
"game.inspector.planet.production.title": "production",
"game.inspector.planet.production.main.aria": "production type",
"game.inspector.planet.production.main.placeholder": "(production)",
"game.inspector.planet.production.option.industry": "industry",
"game.inspector.planet.production.option.materials": "materials",
"game.inspector.planet.production.option.research": "research",
@@ -308,7 +307,7 @@ const en = {
"game.inspector.planet.production.ship.no_classes": "no ship classes designed yet",
"game.inspector.planet.production.apply": "apply production change",
"game.inspector.planet.production.cancel": "discard production change",
"game.inspector.planet.cargo.placeholder": "cargo routes",
"game.inspector.planet.cargo.placeholder": "manage routes",
"game.inspector.planet.cargo.slot.col": "colonists",
"game.inspector.planet.cargo.slot.cap": "industry",
"game.inspector.planet.cargo.slot.mat": "materials",
+1 -2
View File
@@ -293,7 +293,6 @@ const ru: Record<keyof typeof en, string> = {
"game.inspector.planet.rename.invalid.disallowed_character": "имя содержит недопустимые символы",
"game.inspector.planet.production.title": "производство",
"game.inspector.planet.production.main.aria": "тип производства",
"game.inspector.planet.production.main.placeholder": "(производство)",
"game.inspector.planet.production.option.industry": "промышленность",
"game.inspector.planet.production.option.materials": "сырьё",
"game.inspector.planet.production.option.research": "исследование",
@@ -309,7 +308,7 @@ const ru: Record<keyof typeof en, string> = {
"game.inspector.planet.production.ship.no_classes": "классы кораблей ещё не спроектированы",
"game.inspector.planet.production.apply": "применить изменение производства",
"game.inspector.planet.production.cancel": "отменить изменение производства",
"game.inspector.planet.cargo.placeholder": "грузовые маршруты",
"game.inspector.planet.cargo.placeholder": "управление маршрутами",
"game.inspector.planet.cargo.slot.col": "колонисты",
"game.inspector.planet.cargo.slot.cap": "промышленность",
"game.inspector.planet.cargo.slot.mat": "сырьё",
@@ -191,7 +191,7 @@ torus distance via the F8-07 (#50) fix.
onchange={pickType}
disabled={disabled || pendingSlot !== null}
>
<option value="">
<option value="" disabled class="placeholder">
{i18n.t("game.inspector.planet.cargo.placeholder")}
</option>
{#each CARGO_LOAD_TYPE_VALUES as loadType (loadType)}
@@ -304,6 +304,10 @@ torus distance via the F8-07 (#50) fix.
cursor: not-allowed;
opacity: 0.5;
}
.select option.placeholder {
color: var(--color-text-muted);
font-style: italic;
}
.destination {
color: var(--color-text);
}
@@ -136,7 +136,14 @@ sciences win because they carry more user intent.
parseTarget(planet.production, localShipClass, localScience),
);
let mainSel = $state<MainSegment | "">("");
// An owned planet always produces something on the engine side, so
// the row defaults to `industry` when `planet.production` is
// somehow null/empty rather than carrying a separate "(none)"
// placeholder option. The user lands on a valid production type
// and can apply or change immediately.
const DEFAULT_MAIN: MainSegment = "industry";
let mainSel = $state<MainSegment>(DEFAULT_MAIN);
let targetSel = $state<string>("");
$effect(() => {
@@ -147,20 +154,19 @@ sciences win because they carry more user intent.
void planet.number;
void parsedMain;
void parsedTarget;
mainSel = parsedMain ?? "";
mainSel = parsedMain ?? DEFAULT_MAIN;
targetSel = parsedTarget ?? "";
});
const needsTarget = $derived(mainSel === "research" || mainSel === "ship");
const dirty = $derived(
(mainSel === "" ? null : mainSel) !== parsedMain
mainSel !== parsedMain
|| (targetSel === "" ? null : targetSel) !== parsedTarget,
);
const applyDisabled = $derived(
disabled
|| mainSel === ""
|| (needsTarget && targetSel === "")
|| !dirty,
);
@@ -168,8 +174,7 @@ sciences win because they carry more user intent.
const cancelDisabled = $derived(disabled || !dirty);
function pickMain(event: Event): void {
const value = (event.target as HTMLSelectElement).value as MainSegment | "";
mainSel = value;
mainSel = (event.target as HTMLSelectElement).value as MainSegment;
// Switching the primary list clears any pending secondary
// choice — the picker for the new main might not even include
// the previous target.
@@ -181,7 +186,7 @@ sciences win because they carry more user intent.
}
async function applyRow(): Promise<void> {
if (applyDisabled || draft === undefined || mainSel === "") return;
if (applyDisabled || draft === undefined) return;
if (mainSel === "industry") {
await emit("CAP", "");
return;
@@ -205,7 +210,7 @@ sciences win because they carry more user intent.
}
function cancelRow(): void {
mainSel = parsedMain ?? "";
mainSel = parsedMain ?? DEFAULT_MAIN;
targetSel = parsedTarget ?? "";
}
@@ -237,9 +242,6 @@ sciences win because they carry more user intent.
onchange={pickMain}
{disabled}
>
<option value="">
{i18n.t("game.inspector.planet.production.main.placeholder")}
</option>
<option value="industry">
{i18n.t("game.inspector.planet.production.option.industry")}
</option>
@@ -353,7 +355,7 @@ sciences win because they carry more user intent.
display: flex;
align-items: center;
gap: 0.35rem;
flex-wrap: wrap;
flex-wrap: nowrap;
}
.select {
flex: 1 1 6rem;
@@ -374,22 +376,21 @@ sciences win because they carry more user intent.
.icon-action {
flex: 0 0 auto;
font: inherit;
font-size: 0.9rem;
font-size: 1rem;
line-height: 1;
padding: 0.25rem 0.5rem;
padding: 0.15rem 0.2rem;
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: 3px;
border: 0;
border-radius: 0;
cursor: pointer;
}
.icon-action:not(:disabled):hover {
color: var(--color-text);
border-color: var(--color-accent);
}
.icon-action:disabled {
cursor: not-allowed;
opacity: 0.4;
opacity: 0.35;
}
.icon-action--apply:not(:disabled) {
color: var(--color-success);