diff --git a/assets/images/originals/lava-shop-02.png b/assets/images/originals/lava-shop-02.png new file mode 100644 index 0000000..5fbebbc Binary files /dev/null and b/assets/images/originals/lava-shop-02.png differ diff --git a/data/factions.json b/data/factions.json index 80c1d0f..b2083b1 100644 --- a/data/factions.json +++ b/data/factions.json @@ -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:, 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:, 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" }, diff --git a/data/landing.json b/data/landing.json index 36f0560..71a32b6 100644 --- a/data/landing.json +++ b/data/landing.json @@ -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" } ], diff --git a/data/map.json b/data/map.json index 7ec50ac..c5071dc 100644 --- a/data/map.json +++ b/data/map.json @@ -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.", diff --git a/dev/factions.test.mjs b/dev/factions.test.mjs index 143ac7f..1a9c6d8 100644 --- a/dev/factions.test.mjs +++ b/dev/factions.test.mjs @@ -22,11 +22,15 @@ * non-barren system of its branch, each system carrying the capital * world (class capital, 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 diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index 9fa15a0..eefcbf8 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -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 diff --git a/js/galaxy/Galaxy.js b/js/galaxy/Galaxy.js index 86768b5..e080156 100644 --- a/js/galaxy/Galaxy.js +++ b/js/galaxy/Galaxy.js @@ -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|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 ?? [],