Add faction territory floor and ring all stars by ownership
Branch sizes are inherently skewed; previously only the cap (median quota) applied, so a small faction could start with a one-system sliver. - FLOOR (balance.minTerritory, data/factions.json): a faction below the floor absorbs the nearest unowned eligible overflow first, then the richest faction's least-core systems — territory stays contiguous; a tiny galaxy with too few eligible systems is best-effort. - Territory is now tracked per faction: g.factions.factionTerritories is the truth, the branch view is derived; capitals are placed inside the faction's post-floor territory, so a faction built entirely from borrowed space still gets its seat in its own territory. - The galaxy plate rings EVERY star: faction color for territory, neutral silver for wild space (home included) — the ring color is the ownership read at a glance. - dev/factions.test.mjs now checks the floor, cap, disjointness, and branch-view invariants; new throwaway diagnostics dev/faction-balance.diag.mjs / dev/faction-sweep.diag.mjs sweep seeds for the invariants + determinism.
This commit is contained in:
parent
608ffb7077
commit
831670c8ca
|
|
@ -1,5 +1,9 @@
|
|||
{
|
||||
"_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).",
|
||||
"_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 (data/factions.json → balance, below) keeps the galaxy fair without making it equal: the CAP — each branch keeps only its min(eligible, quota) systems closest to the middle band (|d−0.5|), quota = the MEDIAN of the branches' eligible sizes, so the big branch's overflow stays neutral wild space and no faction dominates; and the FLOOR — balance.minTerritory, a faction whose own branch is too small is lifted out of the neutral wild space (first the branches' eligible overflow, nearest the recipient so the territory stays contiguous, then the richest faction's least-core systems; a faction with no branch is built entirely from borrowed space), so no faction starts with a one-system sliver (a tiny galaxy is best-effort) — exposed as g.factions.quota / .minTerritory / .factionTerritories (per-faction, the truth) / .territories (the branch view). 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).",
|
||||
"balance": {
|
||||
"_comment": "BALANCE (js/galaxy/Galaxy.js → _assignFactions) — fair, not equal. CAP: quota = the MEDIAN of the branches' eligible territory sizes (exposed as g.factions.quota) — the big branch's overflow stays neutral wild space, so no faction dominates. FLOOR: minTerritory — a faction whose own branch is too small to reach this is lifted out of the neutral wild space: first the branches' eligible OVERFLOW (the nearest systems, so the territory stays contiguous), then the richest faction's least-core systems (exposed as g.factions.minTerritory). A tiny galaxy with too few eligible systems is best-effort (a working territory beats an empty one).",
|
||||
"minTerritory": 5
|
||||
},
|
||||
"factions": {
|
||||
"neutral": { "label": "Neutral", "color": "#b9c4d6" },
|
||||
"apex": { "label": "Apex", "color": "#ff4d4d" },
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@
|
|||
"outlineAlpha": 0.26
|
||||
},
|
||||
"factions": {
|
||||
"_comment": "The FACTION LAYER on the plate (data/factions.json — the roster's faction pass, js/galaxy/Galaxy.js). Every system that sits in a faction territory gets a RING in that faction's color (factions.<id>.color — neutral silver for the wild space's edge), and every CAPITAL gets the double-ring seat mark (the neutral capital wears neutral silver). The ring radius = the star's glow dot × ringMul, width = plate-px (both plate-stable under zoom — the dot floor keeps the ring readable even at 1×). alpha = the ring's opacity (territory rings run a touch dimmer than the capital rings — the seat reads first). enabled = false turns the whole layer off (the plate falls back to plain archetype colors). The per-faction territory-region FILL (this file's hull pass, colored per faction) + the relation tiers are the next layer.",
|
||||
"_comment": "The FACTION LAYER on the plate (data/factions.json — the roster's faction pass, js/galaxy/Galaxy.js). EVERY STAR gets a RING — a faction territory star in that faction's color (factions.<id>.color), a NEUTRAL system (wild space, home included) in neutral silver (factions.neutral.color) — and every CAPITAL gets the double-ring seat mark (the neutral capital wears neutral silver). So the ring's color IS the ownership read: faction color = that faction's territory, silver = no faction. The ring radius = the star's glow dot × ringMul, width = plate-px (both plate-stable under zoom — the dot floor keeps the ring readable even at 1×). alpha = the ring's opacity (territory rings run a touch dimmer than the capital rings — the seat reads first). enabled = false turns the whole layer off (the plate falls back to plain archetype colors). The per-faction territory-region FILL (this file's hull pass, colored per faction) + the relation tiers are the next layer.",
|
||||
"enabled": true,
|
||||
"alpha": 0.62,
|
||||
"ringMul": 1.9,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* DIAG (throwaway): inspect faction balance for a given galaxy seed.
|
||||
*
|
||||
* node dev/faction-balance.diag.mjs [SEED]
|
||||
*/
|
||||
process.env.NODE_ENV = 'dev';
|
||||
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
|
||||
const fs = await import('node:fs');
|
||||
const dataDir = join(__dirname, '../data');
|
||||
const configData = {};
|
||||
for (const f of fs.readdirSync(dataDir)) {
|
||||
if (!f.endsWith('.json') || f === 'manifest.json') continue;
|
||||
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
|
||||
}
|
||||
config.init(configData);
|
||||
|
||||
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
|
||||
|
||||
const seed = process.argv[2] ?? 'QB4UP8J5';
|
||||
const g = Galaxy.create(seed);
|
||||
const branchOf = g.jumpNetwork.branchOf;
|
||||
const depth = g.jumpNetwork.depth;
|
||||
const HOME = g.currentSystemId;
|
||||
|
||||
console.log(`seed=${seed} systems=${g.records.length} home=${HOME}`);
|
||||
console.log('branches:', g.factions?.branches ?? null);
|
||||
console.log('branchFaction:', g.factions?.branchFaction ?? null);
|
||||
console.log('quota:', g.factions?.quota);
|
||||
console.log('capitals:', g.factions?.capitals);
|
||||
console.log('neutralCapital:', g.factions?.neutralCapital);
|
||||
|
||||
for (const b of g.factions?.branches ?? []) {
|
||||
const nodes = [...branchOf.entries()].filter(([, x]) => x === b).map(([id]) => id);
|
||||
const maxD = Math.max(1, ...nodes.map((id) => depth.get(id) ?? 1));
|
||||
const elig = nodes.filter((id) => { const d = depth.get(id) ?? 0; return d >= 2 && d <= maxD - 1; });
|
||||
console.log(` branch ${b}: nodes=${nodes.length} maxDepth=${maxD} eligible=${elig.length}`);
|
||||
}
|
||||
|
||||
const counts = {};
|
||||
for (const r of g.records) {
|
||||
const f = r.faction ?? 'neutral';
|
||||
counts[f] = (counts[f] ?? 0) + 1;
|
||||
if (r.capital) console.log(` capital: ${r.id} (${r.capital}) faction=${r.faction}`);
|
||||
}
|
||||
console.log('faction system counts:', counts);
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
/**
|
||||
* DIAG (throwaway): sweep many seeds — faction floor/cap invariants,
|
||||
* determinism, capital placement.
|
||||
*
|
||||
* node dev/faction-sweep.diag.mjs [N]
|
||||
*/
|
||||
process.env.NODE_ENV = 'dev';
|
||||
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
|
||||
const fs = await import('node:fs');
|
||||
const dataDir = join(__dirname, '../data');
|
||||
const configData = {};
|
||||
for (const f of fs.readdirSync(dataDir)) {
|
||||
if (!f.endsWith('.json') || f === 'manifest.json') continue;
|
||||
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
|
||||
}
|
||||
config.init(configData);
|
||||
|
||||
const { Rng } = await import(pathToFileURL(join(__dirname, '../js/utils/Rng.js')).href);
|
||||
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
|
||||
|
||||
const N = Number(process.argv[2] ?? 40);
|
||||
const FACS = ['apex', 'mindweave', 'zenith'];
|
||||
const minT = config.get('factions.balance.minTerritory', 5);
|
||||
|
||||
let bad = 0;
|
||||
const small = [];
|
||||
for (let i = 0; i < N; i++) {
|
||||
const seed = Rng.randomSeedString ? Rng.randomSeedString(8) : 'SEED' + i;
|
||||
const g = Galaxy.create(seed);
|
||||
if (!g.factions) { console.log(` ${seed}: no factions (degenerate)`); continue; }
|
||||
const counts = {};
|
||||
for (const f of FACS) counts[f] = 0;
|
||||
let owners = 0;
|
||||
for (const r of g.records) {
|
||||
if (!r.faction) continue;
|
||||
owners++;
|
||||
if (!FACS.includes(r.faction)) { console.log(` ${seed}: bad faction id ${r.faction}`); bad++; continue; }
|
||||
counts[r.faction]++;
|
||||
}
|
||||
// Disjointness via factionTerritories.
|
||||
const ft = g.factions.factionTerritories;
|
||||
const seen = new Set();
|
||||
let overlap = false;
|
||||
for (const f of FACS) for (const id of ft[f] ?? []) {
|
||||
if (seen.has(id)) overlap = true;
|
||||
seen.add(id);
|
||||
}
|
||||
// Capitals inside their faction's systems.
|
||||
let capOk = FACS.every((f) => {
|
||||
const id = g.factions.capitals?.[f];
|
||||
return id && g.byId.get(id)?.faction === f && g.byId.get(id)?.capital === f;
|
||||
});
|
||||
// Floor (best-effort bound: when total eligible >= 3*minT, all must reach it).
|
||||
const branchOf = g.jumpNetwork.branchOf;
|
||||
const depth = g.jumpNetwork.depth;
|
||||
const branchMax = new Map();
|
||||
for (const [id, b] of branchOf) branchMax.set(b, Math.max(branchMax.get(b) ?? 1, depth.get(id)));
|
||||
const totalElig = [...branchOf.values()].reduce((acc, b) => acc, 0);
|
||||
const eligCount = [...g.records]
|
||||
.filter((r) => branchOf.has(r.id))
|
||||
.filter((r) => { const d = depth.get(r.id) ?? 0; const b = branchOf.get(r.id); return d >= 2 && d <= (branchMax.get(b) ?? 1) - 1; })
|
||||
.length;
|
||||
const floorOk = eligCount >= 3 * minT ? FACS.every((f) => counts[f] >= minT) : true;
|
||||
const capOk2 = Math.max(...FACS.map((f) => counts[f])) <= Math.max(g.factions.quota, minT);
|
||||
if (overlap || !capOk || !floorOk || !capOk2) {
|
||||
bad++;
|
||||
console.log(` ${seed}: FAIL overlap=${overlap} capOk=${capOk} floor=${floorOk}(elig ${eligCount}) cap2=${capOk2} counts=${JSON.stringify(counts)} quota=${g.factions.quota}`);
|
||||
}
|
||||
if (Math.min(...FACS.map((f) => counts[f])) < 10) small.push(`${seed}:${JSON.stringify(counts)}`);
|
||||
// Determinism
|
||||
const g2 = Galaxy.create(seed);
|
||||
if (JSON.stringify(g.factions) !== JSON.stringify(g2.factions) ||
|
||||
g.records.some((r, i) => r.faction !== g2.records[i].faction || r.capital !== g2.records[i].capital)) {
|
||||
bad++;
|
||||
console.log(` ${seed}: DETERMINISM FAIL`);
|
||||
}
|
||||
}
|
||||
console.log(`\n${N} seeds swept, ${bad} failures`);
|
||||
console.log('smallest-faction galaxies (min count < 10):');
|
||||
for (const s of small.slice(0, 10)) console.log(' ' + s);
|
||||
process.exit(bad === 0 ? 0 : 1);
|
||||
|
|
@ -22,15 +22,19 @@
|
|||
* 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 + 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);
|
||||
* - the TERRITORY RULE + BALANCE (data/factions.json → balance):
|
||||
* each branch's middle systems (depth 2 … maxDepth − 1) are
|
||||
* ELIGIBLE; the branch's home faction keeps its min(eligible, quota)
|
||||
* closest to the middle band (|d − 0.5|, index tiebreak) — quota =
|
||||
* the MEDIAN of the eligible sizes (the CAP: no dominant faction,
|
||||
* the overflow stays neutral wild space); and every faction is
|
||||
* lifted UP to the FLOOR (balance.minTerritory — no one-system
|
||||
* sliver), absorbing the nearest unowned eligible overflow first,
|
||||
* then the richest faction's least-core systems (a faction with no
|
||||
* branch is built entirely from borrowed space); the ring right
|
||||
* outside home (depth 1) + the branch tips (maxDepth) stay neutral —
|
||||
* except a faction capital demoted outside its territory (only when
|
||||
* the territory has no free system);
|
||||
* - 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);
|
||||
|
|
@ -184,13 +188,17 @@ const isBarren = (id) => {
|
|||
}
|
||||
check('faction capitals: capital world (class + frame + habitable) + faction-owned settlement', capWorlds, capWhy);
|
||||
|
||||
// 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).
|
||||
// TERRITORY + BALANCE (data/factions.json → balance): the branch's
|
||||
// middle systems (depth 2 … maxDepth − 1) are ELIGIBLE; the branch's
|
||||
// HOME FACTION keeps its min(eligible, quota) closest to the middle
|
||||
// band (|d − 0.5|, index tiebreak) — quota = the MEDIAN of the eligible
|
||||
// sizes (the CAP: no faction outruns a typical branch, the overflow
|
||||
// stays neutral wild space); and every faction is lifted UP to the
|
||||
// FLOOR (balance.minTerritory — no one-system sliver), absorbing the
|
||||
// nearest unowned eligible overflow first, then the richest faction's
|
||||
// least-core systems (a faction with no branch is built entirely from
|
||||
// borrowed space). A faction capital may sit OUTSIDE its territory
|
||||
// (demoted — only when the territory has no free system).
|
||||
const branchMax = new Map(g.factions.branches.map((b) => [b, 0]));
|
||||
const eligibleOf = new Map(g.factions.branches.map((b) => [b, []]));
|
||||
for (const [id, d] of depth) {
|
||||
|
|
@ -206,51 +214,75 @@ const isBarren = (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)))];
|
||||
}));
|
||||
const MIN_T = Math.max(1, Math.floor(Number(config.get('factions.balance.minTerritory') ?? 5)));
|
||||
const counts = { apex: 0, mindweave: 0, zenith: 0 };
|
||||
for (const r of g.records) if (FIDS.includes(r.faction)) counts[r.faction]++;
|
||||
const totalElig = g.factions.branches.reduce((a, b) => a + eligibleOf.get(b).length, 0);
|
||||
|
||||
check('balance quota: the median of the branches’ eligible sizes', g.factions.quota === quota, `${g.factions.quota} vs ${quota} (sizes ${sizes.join(',')})`);
|
||||
check('balance floor: minTerritory exposed on the galaxy', g.factions.minTerritory === MIN_T, `${g.factions.minTerritory} vs ${MIN_T}`);
|
||||
|
||||
// Per-FACTION final territories (the truth — the floor pass can cross
|
||||
// branches; `g.factions.territories` is the derived branch view).
|
||||
const ft = g.factions.factionTerritories ?? {};
|
||||
check('factionTerritories: one list per player faction', FIDS.every((f) => Array.isArray(ft[f])), JSON.stringify(Object.keys(ft)));
|
||||
const ownerOf = new Map();
|
||||
let overlap = false;
|
||||
for (const f of FIDS) for (const id of ft[f] ?? []) {
|
||||
if (ownerOf.has(id)) overlap = true;
|
||||
ownerOf.set(id, f);
|
||||
}
|
||||
check('territories are disjoint (no system twice-owned)', !overlap);
|
||||
|
||||
// Stamp rule: every faction-stamped system is in exactly one
|
||||
// factionTerritory; everything else is neutral — except a demoted
|
||||
// capital (its seat outside the territory, stamped with its own
|
||||
// faction).
|
||||
let terrOk = true;
|
||||
let terrWhy = '';
|
||||
for (const r of g.records) {
|
||||
if (r.id === HOME || !branchOf.has(r.id)) continue;
|
||||
const b = branchOf.get(r.id);
|
||||
const fid = g.factions.branchFaction[b];
|
||||
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}: keep=${keepOf.get(b).has(r.id)} expect=${expectFaction} got=${got}`;
|
||||
break;
|
||||
}
|
||||
if (r.id === HOME) continue;
|
||||
const expectFaction = ownerOf.get(r.id) ?? null;
|
||||
if (r.faction === expectFaction) continue;
|
||||
const demoted = FIDS.includes(r.capital) && r.capital === r.faction;
|
||||
if (!demoted) { terrOk = false; terrWhy = `${r.id}: expect=${expectFaction} got=${r.faction}`; break; }
|
||||
}
|
||||
check('territory rule: the branch’s kept (quota-capped) middle systems = its faction; the rest neutral', terrOk, terrWhy);
|
||||
check('territory stamps: faction systems are exactly the union of the factionTerritories (demoted capitals excepted); 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;
|
||||
// Sources: the floor pass only ever absorbs ELIGIBLE systems (a
|
||||
// demoted capital may sit outside).
|
||||
const eligAll = new Set();
|
||||
for (const b of g.factions.branches) for (const id of eligibleOf.get(b)) eligAll.add(id);
|
||||
let srcOk = true;
|
||||
let srcWhy = '';
|
||||
for (const f of FIDS) for (const id of ft[f] ?? []) {
|
||||
if (!eligAll.has(id) && g.factions.capitals?.[f] !== id) { srcOk = false; srcWhy = `${f}: ${id} is not an eligible system`; 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}`);
|
||||
check('territory systems all come from eligible (middle) space', srcOk, srcWhy);
|
||||
|
||||
// CAP: no faction outruns a typical branch (the FLOOR may only push a
|
||||
// tiny galaxy past the quota).
|
||||
check('CAP: no faction outruns a typical branch (≤ max(quota, floor))',
|
||||
FIDS.every((f) => counts[f] <= Math.max(quota, MIN_T)),
|
||||
`counts ${FIDS.map((f) => `${f}=${counts[f]}`).join(' ')} quota=${quota} floor=${MIN_T}`);
|
||||
// FLOOR: a galaxy with 3× the floor of eligible space must lift every
|
||||
// faction to it (fewer than that is best-effort — a working territory
|
||||
// beats an empty one).
|
||||
check('FLOOR: every faction reaches minTerritory (when 3× the floor of eligible space exists)',
|
||||
totalElig < 3 * MIN_T || FIDS.every((f) => counts[f] >= MIN_T),
|
||||
`counts ${FIDS.map((f) => `${f}=${counts[f]}`).join(' ')} floor=${MIN_T} totalElig=${totalElig}`);
|
||||
|
||||
// The BRANCH VIEW: territories[b] = the branch's systems that ended up
|
||||
// owned (its home faction's kept systems, minus any it lent out, plus
|
||||
// any it absorbed).
|
||||
let brOk = true;
|
||||
let brWhy = '';
|
||||
for (const b of g.factions.branches) {
|
||||
const want = [...(g.factions.territories?.[b] ?? [])].sort();
|
||||
const got = g.records.filter((r) => r.id !== HOME && branchOf.get(r.id) === b && ownerOf.has(r.id)).map((r) => r.id).sort();
|
||||
if (JSON.stringify(want) !== JSON.stringify(got)) { brOk = false; brWhy = `${b}: view ${want.length} vs owned ${got.length}`; break; }
|
||||
}
|
||||
check('branch view: factions.territories[b] = the branch’s owned systems', brOk, brWhy);
|
||||
|
||||
// OWNERS: every settlement in a faction-controlled system is owned by
|
||||
// that faction (neutral systems: owner null) — the reputation seam is
|
||||
|
|
|
|||
|
|
@ -461,17 +461,26 @@ defaults to "current system charted, nothing traveled" (old saves load).
|
|||
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: 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
|
||||
branch's middle systems (depth 2 … maxDepth−1) are ELIGIBLE, and the
|
||||
branch's home faction keeps its min(eligible, quota) closest to the
|
||||
middle band (|d − 0.5|). BALANCE (data/factions.json → balance) keeps
|
||||
the galaxy fair without making it equal — two rules: the **CAP**,
|
||||
quota = the MEDIAN of the branches' eligible sizes (branch sizes are
|
||||
inherently skewed and 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), and the **FLOOR**, balance.minTerritory — a
|
||||
faction whose own branch is too small is lifted out of the neutral wild
|
||||
space (first the branches' eligible overflow, nearest the recipient so
|
||||
the territory stays contiguous, then the richest faction's least-core
|
||||
systems; a faction with no branch is built entirely from borrowed
|
||||
space), so no faction starts with a one-system sliver. A tiny galaxy
|
||||
with too few eligible systems is best-effort (a working territory
|
||||
beats an empty one). 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
|
||||
middle band, seeded, inside the faction's final territory). The
|
||||
branch↔faction order is a SEEDED SHUFFLE — a
|
||||
new galaxy reshuffles who sits where; same seed ⇒ identical. Each
|
||||
capital's system grows its capital planet (class `capital*`,
|
||||
planets.png frames 18..21 — data/planets.json → capitals; the
|
||||
|
|
|
|||
|
|
@ -359,14 +359,18 @@ export class Galaxy {
|
|||
* 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'
|
||||
* BALANCE (data/factions.json → balance) — fair, not equal. Two rules:
|
||||
* CAP — 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 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).
|
||||
* space (no dominant faction).
|
||||
* FLOOR — a faction whose own branch is too small to reach
|
||||
* balance.minTerritory absorbs systems that would otherwise stay
|
||||
* neutral (first the branches' eligible overflow, then the richest
|
||||
* faction's least-core systems — the nearest ones, so the territory
|
||||
* stays contiguous). A tiny galaxy with too few eligible systems is
|
||||
* best-effort (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)
|
||||
|
|
@ -434,24 +438,49 @@ export class Galaxy {
|
|||
}),
|
||||
);
|
||||
}
|
||||
// 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).
|
||||
// BALANCE (data/factions.json → balance) — fair, not equal:
|
||||
// CAP — quota = the MEDIAN of the 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 (no dominant faction);
|
||||
// FLOOR — minTerritory: a faction whose own branch is too small is
|
||||
// lifted up out of the neutral wild space (the floor pass below —
|
||||
// a working territory beats a one-system sliver).
|
||||
const balanceCfg = config.get('factions.balance', {});
|
||||
const minTerritory = Math.max(1, Math.floor(Number(balanceCfg.minTerritory) || 5));
|
||||
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 inBand = (id) => {
|
||||
const rec = this.byId.get(id);
|
||||
return !!rec && rec.d >= dMin && rec.d <= dMax;
|
||||
};
|
||||
const isBarren = (id) => barren instanceof Set && barren.has(id);
|
||||
|
||||
// Branch↔faction: a SEEDED SHUFFLE of the three factions across the
|
||||
// branches — a faction's branch is its HOME territory (the floor pass
|
||||
// below can lend it neighboring space). Fewer than three branches ⇒ a
|
||||
// faction's home branch is null and its territory is built entirely
|
||||
// from borrowed space (the "nice to have" relaxation; its capital
|
||||
// still lands in its own territory).
|
||||
const assign = Rng.derive(this.seed, 'factions', 'assign').shuffle(FACTIONS);
|
||||
const branchFaction = new Map();
|
||||
branchRoots.forEach((b, i) => branchFaction.set(b, assign[i % assign.length]));
|
||||
const factionBranch = new Map(
|
||||
FACTIONS.map((f) => [f, branchRoots.find((b) => branchFaction.get(b) === f) ?? null]),
|
||||
);
|
||||
|
||||
// BASE TERRITORY (per FACTION): the faction's home branch keeps its
|
||||
// min(eligible, quota) systems closest to the middle band.
|
||||
const territoryOf = new Map(FACTIONS.map((f) => [f, new Set()]));
|
||||
for (const f of FACTIONS) {
|
||||
const b = factionBranch.get(f);
|
||||
if (!b) continue;
|
||||
const elig = eligibleOf.get(b) ?? [];
|
||||
territoryOf.set(
|
||||
b,
|
||||
f,
|
||||
new Set(
|
||||
elig
|
||||
.slice()
|
||||
|
|
@ -460,36 +489,106 @@ export class Galaxy {
|
|||
),
|
||||
);
|
||||
}
|
||||
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;
|
||||
|
||||
// FLOOR PASS (balance.minTerritory — no faction left with a sliver):
|
||||
// a faction below the floor absorbs systems that would otherwise stay
|
||||
// NEUTRAL, in this order —
|
||||
// 1. eligible OVERFLOW of any branch (eligible but unowned — the
|
||||
// branch with the most of it, then the system nearest the
|
||||
// recipient, so the region stays contiguous);
|
||||
// 2. the richest faction still above the floor (its system farthest
|
||||
// from the middle band — the least core — nearest the recipient).
|
||||
// Deterministic (branch/faction order + index tiebreaks); a tiny
|
||||
// galaxy with too few eligible systems is best-effort (the loop stops
|
||||
// when there is nothing left to give).
|
||||
const owned = new Set();
|
||||
for (const f of FACTIONS) for (const id of territoryOf.get(f)) owned.add(id);
|
||||
const centroid = (ids) => {
|
||||
let x = 0, y = 0, n = 0;
|
||||
for (const id of ids) {
|
||||
const rec = this.byId.get(id);
|
||||
if (rec) { x += rec.x; y += rec.y; n += 1; }
|
||||
}
|
||||
return n > 0 ? { x: x / n, y: y / n } : null;
|
||||
};
|
||||
const isBarren = (id) => barren instanceof Set && barren.has(id);
|
||||
const dist2 = (id, c) => {
|
||||
const rec = this.byId.get(id);
|
||||
if (!rec || !c) return 0;
|
||||
const dx = rec.x - c.x;
|
||||
const dy = rec.y - c.y;
|
||||
return dx * dx + dy * dy;
|
||||
};
|
||||
const distPts = (p, q) => {
|
||||
if (!p || !q) return Infinity;
|
||||
const dx = p.x - q.x;
|
||||
const dy = p.y - q.y;
|
||||
return dx * dx + dy * dy;
|
||||
};
|
||||
const allEligible = branchRoots.flatMap((b) => eligibleOf.get(b) ?? []);
|
||||
for (const f of FACTIONS) {
|
||||
const terr = territoryOf.get(f);
|
||||
while (terr.size < minTerritory) {
|
||||
const anchor = terr.size > 0
|
||||
? centroid([...terr])
|
||||
: centroid(factionBranch.get(f) ? (nodesOf.get(factionBranch.get(f)) ?? []) : allEligible);
|
||||
// 1) The biggest unowned eligible overflow, nearest the recipient.
|
||||
let best = null; // { count, dist, free }
|
||||
for (const b of branchRoots) {
|
||||
const free = (eligibleOf.get(b) ?? []).filter((id) => !owned.has(id));
|
||||
if (free.length === 0) continue;
|
||||
const dist = distPts(centroid(free), anchor);
|
||||
if (!best || best.count < free.length || (best.count === free.length && best.dist > dist)) {
|
||||
best = { count: free.length, dist, free };
|
||||
}
|
||||
}
|
||||
if (best) {
|
||||
const pickId = best.free
|
||||
.slice()
|
||||
.sort((a, c) => dist2(a, anchor) - dist2(c, anchor) || index.get(a) - index.get(c))[0];
|
||||
terr.add(pickId);
|
||||
owned.add(pickId);
|
||||
continue;
|
||||
}
|
||||
// 2) Nothing unowned left — the richest faction still above the
|
||||
// floor lends its least-core system, nearest the recipient.
|
||||
let donor = null; // { faction, size, dist }
|
||||
for (const d of FACTIONS) {
|
||||
if (d === f) continue;
|
||||
const t = territoryOf.get(d);
|
||||
if (t.size <= minTerritory) continue;
|
||||
const dist = distPts(centroid([...t]), anchor);
|
||||
if (!donor || donor.size < t.size || (donor.size === t.size && donor.dist > dist)) {
|
||||
donor = { faction: d, size: t.size, dist };
|
||||
}
|
||||
}
|
||||
if (!donor) break; // nothing left to give — best effort
|
||||
const donorTerr = territoryOf.get(donor.faction);
|
||||
const pickId = [...donorTerr]
|
||||
.sort((a, c) => midRank(c) - midRank(a) || dist2(a, anchor) - dist2(c, anchor) || index.get(a) - index.get(c))[0];
|
||||
donorTerr.delete(pickId);
|
||||
terr.add(pickId);
|
||||
// `owned` is unchanged — the system stays owned, just re-owned.
|
||||
}
|
||||
}
|
||||
|
||||
// NEUTRAL CAPITAL — one of home's gate neighbors (seeded pick).
|
||||
const neutralId = Rng.derive(this.seed, 'factions', 'neutral').pick(branchRoots);
|
||||
|
||||
// Branch↔faction: a SEEDED SHUFFLE of the three factions across the
|
||||
// branches (fewer than three branches ⇒ factions share one — the
|
||||
// "nice to have" relaxation; their capitals still differ).
|
||||
const assign = Rng.derive(this.seed, 'factions', 'assign').shuffle(FACTIONS);
|
||||
const branchFaction = new Map();
|
||||
branchRoots.forEach((b, i) => branchFaction.set(b, assign[i % assign.length]));
|
||||
|
||||
// FACTION CAPITALS — one per faction, inside its branch (seeded).
|
||||
// FACTION CAPITALS — one per faction, inside its (post-floor) territory
|
||||
// (seeded): a faction built from borrowed space still gets its seat in
|
||||
// its own territory.
|
||||
const taken = new Set([neutralId]);
|
||||
const capitals = {};
|
||||
for (const f of FACTIONS) {
|
||||
const b = branchRoots.find((x) => branchFaction.get(x) === f);
|
||||
if (!b) continue;
|
||||
const nodes = nodesOf.get(b) ?? [];
|
||||
const b = factionBranch.get(f);
|
||||
const nodes = b ? (nodesOf.get(b) ?? []) : [];
|
||||
const free = (ids) => ids.filter((id) => !taken.has(id) && !isBarren(id));
|
||||
// Tiered candidacy: middle-band territory → any territory → any
|
||||
// free branch system (tiny-branch fallback — a working capital
|
||||
// beats no capital).
|
||||
let cands = free(nodes).filter((id) => inTerritory(id, b) && inBand(id));
|
||||
if (cands.length === 0) cands = free(nodes).filter((id) => inTerritory(id, b));
|
||||
const terr = [...territoryOf.get(f)];
|
||||
// Tiered candidacy: middle-band territory → any territory → any free
|
||||
// home-branch system (tiny-branch fallback — a working capital beats
|
||||
// no capital).
|
||||
let cands = free(terr).filter((id) => inBand(id));
|
||||
if (cands.length === 0) cands = free(terr);
|
||||
if (cands.length === 0) cands = free(nodes);
|
||||
if (cands.length === 0) cands = branchRoots.filter((id) => id !== neutralId);
|
||||
const capitalId = Rng.derive(this.seed, 'factions', 'capital', f).pick(cands);
|
||||
|
|
@ -511,17 +610,14 @@ export class Galaxy {
|
|||
}
|
||||
}
|
||||
|
||||
// TERRITORIES — the branch's middle systems take its faction;
|
||||
// the neutral hub is explicitly exempt, everything else untouched
|
||||
// (neutral wild space).
|
||||
for (const b of branchRoots) {
|
||||
const f = branchFaction.get(b);
|
||||
for (const id of nodesOf.get(b) ?? []) {
|
||||
// TERRITORIES — every system in a faction's final territory takes its
|
||||
// faction (its capital already carries the stamp); the neutral hub is
|
||||
// explicitly exempt; everything else stays NEUTRAL WILD SPACE.
|
||||
for (const f of FACTIONS) {
|
||||
for (const id of territoryOf.get(f)) {
|
||||
if (id === neutralId) continue;
|
||||
if (inTerritory(id, b)) {
|
||||
const rec = this.byId.get(id);
|
||||
if (rec && !rec.capital) rec.faction = f; // a capital keeps its own
|
||||
}
|
||||
const rec = this.byId.get(id);
|
||||
if (rec && !rec.capital) rec.faction = f; // a capital keeps its own
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -531,11 +627,17 @@ 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).
|
||||
// BALANCE — the CAP (median quota) + the FLOOR (min territory):
|
||||
quota,
|
||||
territories: Object.fromEntries(branchRoots.map((b) => [b, [...(territoryOf.get(b) ?? [])].sort()])),
|
||||
minTerritory,
|
||||
// PER-FACTION final territories (the floor pass can cross branches —
|
||||
// this is the truth; `territories` below is the branch view).
|
||||
factionTerritories: Object.fromEntries(FACTIONS.map((f) => [f, [...territoryOf.get(f)].sort()])),
|
||||
// Each branch's systems that ended up in a territory (its home
|
||||
// faction's kept systems, minus any it lent out — sorted).
|
||||
territories: Object.fromEntries(
|
||||
branchRoots.map((b) => [b, (nodesOf.get(b) ?? []).filter((id) => owned.has(id)).sort()]),
|
||||
),
|
||||
};
|
||||
const missing = FACTIONS.filter((f) => !capitals[f]);
|
||||
if (missing.length > 0) {
|
||||
|
|
|
|||
|
|
@ -58,10 +58,11 @@
|
|||
* snapshot carries per-system `faction` (null = neutral wild space) +
|
||||
* `capital` (the four designated capitals). The plate's faction layer
|
||||
* (`_redrawFactions`, config below — data/map.json → galaxy.factions)
|
||||
* rings every territory system in its faction's color (factions.<id>
|
||||
* .color) and marks every capital with the double-ring seat — the
|
||||
* neutral capital in neutral silver. The per-faction territory-region
|
||||
* FILL (this file's hull pass, colored per faction) + the relation
|
||||
* rings EVERY star — territory stars in their faction's color
|
||||
* (factions.<id>.color), neutral systems (wild space, home included) in
|
||||
* neutral silver — and marks every capital with the double-ring seat
|
||||
* (the neutral capital in neutral silver). The per-faction territory-
|
||||
* region FILL (this file's hull pass, colored per faction) + the relation
|
||||
* tiers (js/reputation/Reputation.js → factionStanding) are the next
|
||||
* layer. See docs/PROJECT_NOTES.md → "Factions".
|
||||
*
|
||||
|
|
@ -716,12 +717,13 @@ export class GalaxyView {
|
|||
/**
|
||||
* The FACTION LAYER (data/factions.json — the roster's faction pass,
|
||||
* js/galaxy/Galaxy.js; config data/map.json → galaxy.factions): a ring
|
||||
* in the faction's color around every system that sits in a faction
|
||||
* territory (the wild-space ring right outside home stays plain — its
|
||||
* systems are neutral), and the double-ring SEAT mark around every
|
||||
* capital (the neutral capital wears neutral silver — factions.
|
||||
* neutral.color). The ring radius rides the star's dot (the 2px floor
|
||||
* keeps it readable at 1×), so the layer reads at every zoom; the
|
||||
* around EVERY star — a faction territory star in its faction's color,
|
||||
* a NEUTRAL system (no faction — the wild space, home included) in
|
||||
* neutral silver (factions.neutral.color) — and the double-ring SEAT
|
||||
* mark around every capital (the neutral capital wears neutral silver).
|
||||
* Every star is legible as "someone's" (or no one's): the ring's color
|
||||
* IS the ownership read. The ring radius rides the star's dot (the 2px
|
||||
* floor keeps it readable at 1×), so the layer reads at every zoom; the
|
||||
* per-frame redraw follows the star's pulse/reveal like the rest of
|
||||
* the plate. The territory-region FILL (the hull pass, per faction
|
||||
* color) is the next layer.
|
||||
|
|
@ -741,8 +743,10 @@ export class GalaxyView {
|
|||
const capOuter = Number(fc.capitalOuter ?? 2.8);
|
||||
for (const st of this._stars) {
|
||||
if (st.culled) continue;
|
||||
const fid = st.capital ?? st.faction;
|
||||
if (!fid) continue;
|
||||
// EVERY star wears its ring: a faction territory star in its
|
||||
// faction's color, a neutral system (no faction — the wild space,
|
||||
// home included) in neutral silver.
|
||||
const fid = st.capital ?? st.faction ?? 'neutral';
|
||||
const rf = this._easeReveal(tR, st.revealDelay, 480);
|
||||
if (rf <= 0.01) continue; // still in the reveal fade-in
|
||||
const color = toColor(colors?.[fid]?.color ?? 0xb9c4d6);
|
||||
|
|
@ -755,7 +759,8 @@ export class GalaxyView {
|
|||
g.lineStyle(1, color, capAlpha * 0.4 * rf);
|
||||
g.strokeCircle(st.lx, st.ly, base * capOuter);
|
||||
} else {
|
||||
// Territory ring — the faction's color over the archetype dot.
|
||||
// Territory ring — the faction's color (neutral silver for wild
|
||||
// space) over the archetype dot.
|
||||
g.lineStyle(width, color, alpha * rf);
|
||||
g.strokeCircle(st.lx, st.ly, base * ringMul);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue