Add balance quota to cap faction territory sizes

- Branch sizes are inherently skewed and a branch IS its faction's
  territory, so without a cap one faction would dominate the start.
- Each branch now keeps only min(eligible, quota) of its eligible
  middle systems, closest to the middle band (|d−0.5|, index tiebreak);
  quota = the median of the three branches' eligible sizes. The big
  branch's overflow stays neutral wild space; a small branch keeps all.
- Expose g.factions.quota and g.factions.territories for the map's
  territory-region fill.
- Add tests verifying the quota, kept-set membership, near-equal
  faction sizes, and no-faction-outruns-typical-branch invariants.
- Fix landing.json: entry 4 now references ice assets (was mislabeled
  lava land with ice clips); entry 5 uses lava-land-02 + lava surface/
  takeoff clips.
- Add original art asset lava-shop-02.png and update docs to describe
  the balance rule.
This commit is contained in:
Brian Fertig 2026-09-10 22:18:07 -06:00
parent e2878da59c
commit 42bf6c17e0
7 changed files with 133 additions and 33 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

View File

@ -1,5 +1,5 @@
{
"_comment": "FACTIONS — the three powers + neutral space. WORLDGEN LAYER (js/galaxy/Galaxy.js → the faction pass, at roster build): the jump tree's top-level BRANCHES (subtrees rooted at the home system's gate neighbors — home has ≤ maxGates gates, so ≤ 3 branches) each become ONE FACTION's territory. TERRITORY rule: a branch's systems at depth 2 … (maxDepth−1) (home = depth 0, its gate neighbors = depth 1) — the ring right outside home and every branch's dead-end tips stay NEUTRAL WILD SPACE, and anything not in a territory is neutral. CAPITALS: the NEUTRAL CAPITAL is a seeded pick of the home system's direct gate neighbors (ALWAYS); its system stays faction-neutral even though it roots a faction's branch (a neutral hub at the edge of that corridor). Each FACTION CAPITAL (apex / mindweave / zenith) is a seeded pick inside its branch's territory, preferred in the middle band (d in 0.37..0.63 — data/galaxy.json → distribution.zones' middle slice), on a non-barren system (it must host a planet); the branch↔faction assignment is a SEEDED SHUFFLE — new galaxy ⇒ new order/placement, same seed ⇒ identical (the determinism contract). Each capital's system grows its capital planet in content generation (js/galaxy/SystemGenerator.js: class capitalNeutral / capitalApex / capitalMindweave / capitalZenith>, planets.png frames 18..21 per data/planets.json → capitals, the 'capital' settlement per data/settlements.json — the faction ones owner:<faction>, the neutral one owner-less; that is the reputation faction seam, js/reputation/Reputation.js → factionStanding()). BRIDGES (data/gates.json → bridges) link the branches across the middle belt so the factions can reach — and fight — each other without routing through the trunk. REACHABILITY / COMBAT / TRADE off the territories is the NEXT layer (the reserved reputation standing + the map's territory-region pass): `color` = the faction's map/diplomacy color (the galaxy plate's star-ring layer, js/ui/GalaxyView.js); the capital planet's ART carries the same hue (frames 18..21).",
"_comment": "FACTIONS — the three powers + neutral space. WORLDGEN LAYER (js/galaxy/Galaxy.js → the faction pass, at roster build): the jump tree's top-level BRANCHES (subtrees rooted at the home system's gate neighbors — home has ≤ maxGates gates, so ≤ 3 branches) each become ONE FACTION's territory. TERRITORY rule: a branch's systems at depth 2 … (maxDepth−1) (home = depth 0, its gate neighbors = depth 1) are ELIGIBLE — the ring right outside home and every branch's dead-end tips stay NEUTRAL WILD SPACE, and anything not in a territory is neutral. BALANCE QUOTA (no dominant faction at the start — branch sizes are inherently skewed, and a branch IS its faction's territory): each branch keeps only its min(eligible, quota) systems, closest to the middle band (|d−0.5|), quota = the MEDIAN of the three branches' eligible sizes (the big branch's overflow stays neutral wild space; a small branch keeps everything it has; a balanced galaxy is untouched) — exposed as g.factions.quota / .territories. CAPITALS: the NEUTRAL CAPITAL is a seeded pick of the home system's direct gate neighbors (ALWAYS); its system stays faction-neutral even though it roots a faction's branch (a neutral hub at the edge of that corridor). Each FACTION CAPITAL (apex / mindweave / zenith) is a seeded pick inside its branch's territory, preferred in the middle band (d in 0.37..0.63 — data/galaxy.json → distribution.zones' middle slice), on a non-barren system (it must host a planet); the branch↔faction assignment is a SEEDED SHUFFLE — new galaxy ⇒ new order/placement, same seed ⇒ identical (the determinism contract). Each capital's system grows its capital planet in content generation (js/galaxy/SystemGenerator.js: class capitalNeutral / capitalApex / capitalMindweave / capitalZenith>, planets.png frames 18..21 per data/planets.json → capitals, the 'capital' settlement per data/settlements.json — the faction ones owner:<faction>, the neutral one owner-less; that is the reputation faction seam, js/reputation/Reputation.js → factionStanding()). BRIDGES (data/gates.json → bridges) link the branches across the middle belt so the factions can reach — and fight — each other without routing through the trunk. REACHABILITY / COMBAT / TRADE off the territories is the NEXT layer (the reserved reputation standing + the map's territory-region pass): `color` = the faction's map/diplomacy color (the galaxy plate's star-ring layer, js/ui/GalaxyView.js); the capital planet's ART carries the same hue (frames 18..21).",
"factions": {
"neutral": { "label": "Neutral", "color": "#b9c4d6" },
"apex": { "label": "Apex", "color": "#ff4d4d" },

View File

@ -66,8 +66,8 @@
{ "land": "ice-land-01.mp4", "surface": "ice-surface-01.mp4", "takeoff": "ice-takeoff-01.mp4", "shop": "ice-shop-01.mp4" },
{ "land": "ice-land-02.mp4", "surface": "ice-surface-02.mp4", "takeoff": "ice-takeoff-02.mp4", "shop": "ice-shop-02.mp4" },
{ "land": "ice-land-03.mp4", "surface": "ice-surface-03.mp4", "takeoff": "ice-takeoff-03.mp4", "shop": "ice-shop-03.mp4" },
{ "land": "lava-land-01.mp4", "surface": "ice-surface-01.mp4", "takeoff": "ice-takeoff-01.mp4", "shop": "ice-shop-01.mp4" },
{ "land": "ice-land-02.mp4", "surface": "ice-surface-02.mp4", "takeoff": "ice-takeoff-02.mp4", "shop": "ice-shop-02.mp4" },
{ "land": "ice-land-01.mp4", "surface": "ice-surface-01.mp4", "takeoff": "ice-takeoff-01.mp4", "shop": "ice-shop-01.mp4" },
{ "land": "lava-land-02.mp4", "surface": "lava-surface-02.mp4", "takeoff": "lava-takeoff-02.mp4", "shop": "ice-shop-02.mp4" },
{ "land": "ice-land-03.mp4", "surface": "ice-surface-03.mp4", "takeoff": "ice-takeoff-03.mp4", "shop": "ice-shop-03.mp4" }
],

View File

@ -121,7 +121,7 @@
]
},
"galaxy": {
"_comment": "THE GALAXY TAB (js/ui/GalaxyView.js) — the whole-galaxy chart on the plate: one GLOWING STAR per system (colored by its archetype, data/systems.json → types, pulsing on that archetype's heartbeat in `pulse`), THIN LANES where the jump gates connect (the JumpNetwork spanning tree — a maze), the CHARTED REGION (the convex hull of the visited systems, inflated — the discovered-area shading), HOME + SHIP markers, ambient dust + the core glow. TRAVELED lanes (the run jumped them — saved with the run) glow brighter + carry a flow packet; FRONTIER lanes (one end visited) are the 'next step'; unexplored lanes are faint threads. Hover = the star's readout + its link state to the current system — STARS ONLY, and the zone hugs each star's drawn dot (glow radius + a few px of mouse pad), so the lanes between the stars never trigger it; click a CHARTED star = its chart in the SYSTEM tab; uncharted stars are readouts only. FACTIONS (planned, not yet implemented): the snapshot carries per-system `faction: null` — the reserved seams are a faction color layer over the stars + a per-faction territory region (the same hull/fill recipe as `hull`, per faction color + relation alpha).",
"_comment": "THE GALAXY TAB (js/ui/GalaxyView.js) — the whole-galaxy chart on the plate: one GLOWING STAR per system (colored by its archetype, data/systems.json → types, pulsing on that archetype's heartbeat in `pulse`), THIN LANES where the jump gates connect (the JumpNetwork spanning tree — a maze), the CHARTED REGION (the convex hull of the visited systems, inflated — the discovered-area shading), HOME + SHIP markers, ambient dust + the core glow. TRAVELED lanes (the run jumped them — saved with the run) glow brighter + carry a flow packet; FRONTIER lanes (one end visited) are the 'next step'; unexplored lanes are faint threads. Hover = the star's readout + its link state to the current system — STARS ONLY, and the zone hugs each star's drawn dot (glow radius + a few px of mouse pad), so the lanes between the stars never trigger it; click a CHARTED star = its chart in the SYSTEM tab; uncharted stars are readouts only. FACTIONS (live — data/factions.json, the roster's faction pass, js/galaxy/Galaxy.js): the snapshot carries per-system `faction` (null = neutral wild space) + `capital` — the live seams are the faction RING layer over the stars (see `factions` below) + a per-faction territory-region FILL (the same hull/fill recipe as `hull`, per faction color + relation alpha — the next layer).",
"name": "GALAXY",
"platePadding": 26,
"_platePadding": "margin kept between the outermost stars and the plate edge at 1× zoom, in PLATE px (js/ui/GalaxyView.js → fitPadded). The field is 2:1 to match the plate, so the fully-zoomed-out galaxy fills the plate with this much breathing room on all sides.",

View File

@ -22,11 +22,15 @@
* non-barren system of its branch, each system carrying the capital
* world (class capital<faction>, frame 19/20/21) + the 'capital'
* settlement owned by the faction;
* - the TERRITORY RULE: every system of a faction's branch at depth 2 …
* (branch maxDepth − 1) carries that branch's faction; the ring right
* outside home (depth 1) and the branch tips (maxDepth) stay neutral —
* except a faction capital demoted onto a branch root (only when the
* branch has no territory at all);
* - the TERRITORY RULE + BALANCE QUOTA: each branch's middle systems
* (depth 2 … maxDepth − 1) are ELIGIBLE; each branch keeps its
* min(eligible, quota) closest to the middle band (|d − 0.5|, index
* tiebreak), quota = the MEDIAN of the eligible sizes — no faction
* outruns a typical branch (no dominant faction; the overflow stays
* neutral wild space, and a small branch keeps everything it has);
* the ring right outside home (depth 1) + the branch tips (maxDepth)
* stay neutral — except a faction capital demoted onto a branch root
* (only when the branch has no territory at all);
* - ARID: every arid planet is ALWAYS habitable (no roll) and hosts a
* COLONY (data/settlements.json → settledKindByClass); arid worlds
* show up in the roster (rebalanced planetClasses);
@ -180,30 +184,73 @@ const isBarren = (id) => {
}
check('faction capitals: capital world (class + frame + habitable) + faction-owned settlement', capWorlds, capWhy);
// TERRITORY: depth 2 … (branch maxDepth − 1) of each branch = that
// branch's faction; the depth-1 ring + the branch tips stay neutral
// (except a faction capital demoted onto a branch root — only when the
// branch has no territory at all).
// TERRITORY + BALANCE QUOTA: the branch's middle systems (depth 2 …
// maxDepth − 1) are ELIGIBLE; each branch keeps its min(eligible, quota)
// closest to the middle band (|d − 0.5|, index tiebreak) — quota = the
// MEDIAN of the eligible sizes, so no faction can outsize a typical
// branch (the overflow stays neutral wild space); a small branch keeps
// everything it has (except a faction capital demoted onto a branch
// root — only when the branch has no territory at all).
const branchMax = new Map(g.factions.branches.map((b) => [b, 0]));
for (const [id, d] of depth) if (id !== HOME && branchOf.has(id)) branchMax.set(branchOf.get(id), Math.max(branchMax.get(branchOf.get(id)), d));
const eligibleOf = new Map(g.factions.branches.map((b) => [b, []]));
for (const [id, d] of depth) {
if (id === HOME || !branchOf.has(id)) continue;
const b = branchOf.get(id);
branchMax.set(b, Math.max(branchMax.get(b), d));
}
for (const r of g.records) {
if (r.id === HOME || !branchOf.has(r.id)) continue;
const b = branchOf.get(r.id);
const d = depth.get(r.id);
if (d >= 2 && d <= branchMax.get(b) - 1) eligibleOf.get(b).push(r.id);
}
const sizes = g.factions.branches.map((b) => eligibleOf.get(b).length).sort((x, y) => x - y);
const quota = sizes[Math.floor((sizes.length - 1) / 2)];
const idx = new Map(g.records.map((r, i) => [r.id, i]));
const mid = (id) => Math.abs((g.byId.get(id)?.d ?? 0.5) - 0.5);
const keepOf = new Map(g.factions.branches.map((b) => {
const elig = eligibleOf.get(b);
return [b, new Set(elig.slice().sort((a, c) => mid(a) - mid(c) || idx.get(a) - idx.get(c)).slice(0, Math.min(elig.length, quota)))];
}));
check('balance quota: the median of the branches’ eligible sizes', g.factions.quota === quota, `${g.factions.quota} vs ${quota} (sizes ${sizes.join(',')})`);
let terrOk = true;
let terrWhy = '';
for (const r of g.records) {
if (r.id === HOME || !branchOf.has(r.id)) continue;
const d = depth.get(r.id);
const b = branchOf.get(r.id);
const fid = g.factions.branchFaction[b];
const maxD = branchMax.get(b);
const expectFaction = d >= 2 && d <= maxD - 1 ? fid : null;
const expectFaction = keepOf.get(b).has(r.id) ? fid : null;
const got = r.faction === fid ? fid : null;
const demoted = g.factions.capitals[fid] === r.id && expectFaction === null;
if (got !== expectFaction && !(demoted && got === fid)) {
terrOk = false;
terrWhy = `${r.id}: d=${d} maxD=${maxD} expect=${expectFaction} got=${got}`;
terrWhy = `${r.id}: keep=${keepOf.get(b).has(r.id)} expect=${expectFaction} got=${got}`;
break;
}
}
check('territory rule: depth 2…(maxDepth−1) per branch = the branch\u2019s faction; ring + tips neutral', terrOk, terrWhy);
check('territory rule: the branch’s kept (quota-capped) middle systems = its faction; the rest neutral', terrOk, terrWhy);
// NO DOMINANT FACTION: every territory is exactly its branch's
// min(eligible, quota) systems — the big branch is trimmed to the
// median, the small branch keeps everything; and the kept set is the
// middle-band-nearest one (the map's territory-region fill will read
// factions.territories).
let terrSizeOk = true;
let terrSizeWhy = '';
for (const b of g.factions.branches) {
const terr = g.factions.territories?.[b] ?? [];
const want = Math.min(eligibleOf.get(b).length, quota);
if (terr.length !== want) { terrSizeOk = false; terrSizeWhy = `${b}: ${terr.length} systems, want ${want}`; break; }
for (const id of terr) {
if (!keepOf.get(b).has(id)) { terrSizeOk = false; terrSizeWhy = `${b}: ${id} is not among its ${want} middle-band-nearest eligible systems`; break; }
}
if (!terrSizeOk) break;
}
check('no dominant faction: every territory is its branch’s min(eligible, quota) middle-band systems', terrSizeOk, terrSizeWhy);
const terrSizes = g.factions.branches.map((b) => g.factions.territories[b].length);
check('faction sizes near-equal: max territory ≤ quota (no faction outruns a typical branch)', Math.max(...terrSizes) <= quota, `sizes ${terrSizes.join('/')} quota ${quota}`);
// OWNERS: every settlement in a faction-controlled system is owned by
// that faction (neutral systems: owner null) — the reputation seam is

View File

@ -460,10 +460,15 @@ defaults to "current system charted, nothing traveled" (old saves load).
**FACTIONS (the worldgen layer is LIVE — data/factions.json):** the
roster's faction pass (`js/galaxy/Galaxy.js → _assignFactions`) assigns
**territories** — each top-level BRANCH of the jump tree (a subtree
rooted at one of home's gate neighbors) is one faction's territory,
starting one system into the branch and stopping one before its
dead-end tips (the ring right outside home + the branch tips stay
neutral wild space) — and designates the **four capitals**: the NEUTRAL
rooted at one of home's gate neighbors) is one faction's territory: a
branch's middle systems (depth 2 … maxDepth−1) are ELIGIBLE, and each
branch keeps its min(eligible, quota) closest to the middle band (|d −
0.5|), **quota = the MEDIAN of the three branches' eligible sizes** —
the BALANCE rule: branch sizes are inherently skewed (a branch IS its
faction's territory), so without the cap one faction would dominate the
start; the big branch's overflow stays neutral wild space, a small
branch keeps everything it has (the ring right outside home + the
branch tips always stay neutral) — and designates the **four capitals**: the NEUTRAL
capital (always a direct gate neighbor of home, its system stays
neutral) + one faction capital per faction (apex / mindweave / zenith,
middle band, seeded). The branch↔faction order is a SEEDED SHUFFLE — a

View File

@ -354,11 +354,19 @@ export class Galaxy {
* reshuffles who sits where (never a fixed order), same seed ⇒
* identical placement.
* TERRITORY — a branch's systems at depth 2 … (maxDepth−1) (home =
* depth 0, its gate neighbors = depth 1): the ring right outside
* home and every branch's dead-end tips stay NEUTRAL WILD SPACE;
* anything not in a territory is neutral. A faction capital always
* sits in its faction's territory (fallbacks only when the branch
* is too short to have one).
* depth 0, its gate neighbors = depth 1) are ELIGIBLE; the ring
* right outside home and every branch's dead-end tips stay NEUTRAL
* WILD SPACE; anything not in a territory is neutral. A faction
* capital always sits in its faction's territory (fallbacks only
* when the branch is too short to have one).
* BALANCE QUOTA (no dominant faction — branch sizes are inherently
* skewed, and a branch IS its faction's territory): each branch
* keeps only the min(eligible, quota) of its eligible systems,
* closest to the middle band (|d − 0.5|, index tiebreak —
* deterministic); quota = the MEDIAN of the three branches'
* eligible sizes. The big branch's overflow stays neutral wild
* space; a small branch keeps everything it has (a working
* territory beats an empty one).
*
* @param {Set<string>|null} barren the gate-only dead ends (excluded from
* capital candidacy — they can't host a world)
@ -414,12 +422,45 @@ export class Galaxy {
for (const id of nodesOf.get(b)) m = Math.max(m, depth.get(id) ?? 1);
maxDepthOf.set(b, m);
}
// Territory = the branch's middle systems (depth 2 … maxDepth−1):
// TERRITORY = the branch's middle systems (depth 2 … maxDepth−1):
// the depth-1 ring at home and the branch tips stay neutral.
const inTerritory = (id, b) => {
const d = depth.get(id) ?? 0;
return d >= 2 && d <= (maxDepthOf.get(b) ?? 1) - 1;
};
const eligibleOf = new Map();
for (const b of branchRoots) {
eligibleOf.set(
b,
(nodesOf.get(b) ?? []).filter((id) => {
const d = depth.get(id) ?? 0;
return d >= 2 && d <= (maxDepthOf.get(b) ?? 1) - 1;
}),
);
}
// BALANCE QUOTA (no dominant faction at the start — branch sizes are
// inherently skewed, and a branch IS its faction's territory, so an
// unbalanced galaxy would crown a dominant faction): quota = the
// MEDIAN of the three branches' eligible sizes; the big branch keeps
// only its systems nearest the middle band (|d − 0.5| — the "middle
// of the galaxy" design, where the bridges sit too; index tiebreak
// for determinism) and its overflow stays NEUTRAL WILD SPACE; a small
// branch keeps everything it has (a working territory beats an empty
// one). A balanced galaxy is untouched (median = each size).
const sizes = branchRoots.map((b) => eligibleOf.get(b).length).sort((x, y) => x - y);
const quota = sizes.length ? sizes[Math.floor((sizes.length - 1) / 2)] : 0;
const index = new Map(this.records.map((r, i) => [r.id, i]));
const midRank = (id) => Math.abs((this.byId.get(id)?.d ?? 0.5) - 0.5);
const territoryOf = new Map();
for (const b of branchRoots) {
const elig = eligibleOf.get(b) ?? [];
territoryOf.set(
b,
new Set(
elig
.slice()
.sort((a, c) => midRank(a) - midRank(c) || index.get(a) - index.get(c))
.slice(0, Math.min(elig.length, quota)),
),
);
}
const inTerritory = (id, b) => territoryOf.get(b)?.has(id) ?? false;
const inBand = (id) => {
const rec = this.byId.get(id);
return !!rec && rec.d >= dMin && rec.d <= dMax;
@ -490,6 +531,11 @@ export class Galaxy {
capitals,
branches: branchRoots,
branchFaction: Object.fromEntries(branchFaction),
// The BALANCE QUOTA (the median of the branches' eligible territory
// sizes) + each branch's final territory (its kept systems, sorted
// — the map's territory-region fill will read these).
quota,
territories: Object.fromEntries(branchRoots.map((b) => [b, [...(territoryOf.get(b) ?? [])].sort()])),
};
const missing = FACTIONS.filter((f) => !capitals[f]);
if (missing.length > 0) {
@ -774,6 +820,8 @@ export class Galaxy {
capitals: this.factions.capitals,
branches: this.factions.branches,
branchFaction: this.factions.branchFaction,
quota: this.factions.quota,
territories: this.factions.territories,
} : null,
jumpNetworkEdges: this.jumpNetwork ? [...this.jumpNetwork.gates.values()].reduce((n, t) => n + t.length, 0) : null,
jumpNetworkBridges: this.jumpNetwork?.bridges ?? [],