new name system?
This commit is contained in:
parent
eb5c4a93a9
commit
db8265384c
5 changed files with 525 additions and 232 deletions
|
|
@ -57,9 +57,9 @@
|
||||||
<div class="card-title">Name Override IDs</div>
|
<div class="card-title">Name Override IDs</div>
|
||||||
<p>Add entries to <code>names.js</code> in <code>CUSTOM_NAMES</code>.</p>
|
<p>Add entries to <code>names.js</code> in <code>CUSTOM_NAMES</code>.</p>
|
||||||
<pre class="example">export const CUSTOM_NAMES = {
|
<pre class="example">export const CUSTOM_NAMES = {
|
||||||
"city-0": "Aohara",
|
"city-0": "CA",
|
||||||
"port-0": "Shirahama",
|
"port-0": "PB",
|
||||||
"castle-0": "Kurono"
|
"castle-0": "KC"
|
||||||
};</pre>
|
};</pre>
|
||||||
<div id="nameIds" class="id-list"></div>
|
<div id="nameIds" class="id-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { CUSTOM_NAMES, NAME_PARTS, contextualDefaultName } from "./names.js";
|
import { createNameDebug, generateEntityName } from "./names.js";
|
||||||
import {
|
import {
|
||||||
applyLandscapeUnitAdminPartition,
|
applyLandscapeUnitAdminPartition,
|
||||||
generateAdminRegions,
|
generateAdminRegions,
|
||||||
|
|
@ -561,21 +561,11 @@ function tagInsidePrefecture(points, prefectureMask) {
|
||||||
return points.map((p) => ({ ...p, insidePrefecture: Boolean(prefectureMask[indexOf(p.x, p.y)]) }));
|
return points.map((p) => ({ ...p, insidePrefecture: Boolean(prefectureMask[indexOf(p.x, p.y)]) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultName(seed, id, entity, nameFields, attempt = 0) {
|
function attachIdsAndNames(points, prefix, seed, kindOverride = null, nameFields = null, usedNames = null, nameDebug = null) {
|
||||||
return contextualDefaultName(seed, id, entity, NAME_PARTS, nameFields, attempt);
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachIdsAndNames(points, prefix, seed, kindOverride = null, nameFields = null, usedNames = null) {
|
|
||||||
return points.map((p, i) => {
|
return points.map((p, i) => {
|
||||||
const id = `${prefix}-${i}`;
|
const id = `${prefix}-${i}`;
|
||||||
const kind = kindOverride || p.kind;
|
const kind = kindOverride || p.kind;
|
||||||
let name = CUSTOM_NAMES[id];
|
const name = generateEntityName(seed + prefix.length * 1000, id, { ...p, kind }, nameFields, usedNames, nameDebug);
|
||||||
if (!name) {
|
|
||||||
for (let attempt = 0; attempt < 8; attempt++) {
|
|
||||||
name = defaultName(seed + prefix.length * 1000, id, { ...p, kind }, nameFields, attempt);
|
|
||||||
if (!usedNames || !usedNames.has(name)) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (usedNames) usedNames.add(name);
|
if (usedNames) usedNames.add(name);
|
||||||
return {
|
return {
|
||||||
...p,
|
...p,
|
||||||
|
|
@ -2912,26 +2902,27 @@ export function generateMap(seedInput = 114514, options = {}) {
|
||||||
const abandonedRailways = branchRailways.filter((_, i) => i % 3 === 0);
|
const abandonedRailways = branchRailways.filter((_, i) => i % 3 === 0);
|
||||||
let castleRuins = castles.filter((_, i) => i % 2 === 1).map((c) => ({ ...c, kind: "Castle Ruins" }));
|
let castleRuins = castles.filter((_, i) => i % 2 === 1).map((c) => ({ ...c, kind: "Castle Ruins" }));
|
||||||
const preservedOldRoads = premodernRoads.filter((_, i) => i % 2 === 0);
|
const preservedOldRoads = premodernRoads.filter((_, i) => i % 2 === 0);
|
||||||
const nameFields = { elevation, slope, sea, river, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland };
|
const nameFields = { elevation, slope, sea, river, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity };
|
||||||
const usedNames = new Set(Object.values(CUSTOM_NAMES));
|
const usedNames = new Set();
|
||||||
|
const nameDebug = createNameDebug();
|
||||||
|
|
||||||
villages = attachIdsAndNames(tagInsidePrefecture(villages, prefectureMask), "village", seed, null, nameFields, usedNames);
|
villages = attachIdsAndNames(tagInsidePrefecture(villages, prefectureMask), "village", seed, null, nameFields, usedNames, nameDebug);
|
||||||
ports = attachIdsAndNames(tagInsidePrefecture(ports, prefectureMask), "port", seed, null, nameFields, usedNames);
|
ports = attachIdsAndNames(tagInsidePrefecture(ports, prefectureMask), "port", seed, null, nameFields, usedNames, nameDebug);
|
||||||
crossings = attachIdsAndNames(tagInsidePrefecture(crossings, prefectureMask), "crossing", seed, null, nameFields, usedNames);
|
crossings = attachIdsAndNames(tagInsidePrefecture(crossings, prefectureMask), "crossing", seed, null, nameFields, usedNames, nameDebug);
|
||||||
passes = attachIdsAndNames(tagInsidePrefecture(passes, prefectureMask), "pass", seed, null, nameFields, usedNames);
|
passes = attachIdsAndNames(tagInsidePrefecture(passes, prefectureMask), "pass", seed, null, nameFields, usedNames, nameDebug);
|
||||||
markets = attachIdsAndNames(tagInsidePrefecture(markets, prefectureMask), "market", seed, null, nameFields, usedNames);
|
markets = attachIdsAndNames(tagInsidePrefecture(markets, prefectureMask), "market", seed, null, nameFields, usedNames, nameDebug);
|
||||||
castles = attachIdsAndNames(tagInsidePrefecture(castles, prefectureMask), "castle", seed, null, nameFields, usedNames);
|
castles = attachIdsAndNames(tagInsidePrefecture(castles, prefectureMask), "castle", seed, null, nameFields, usedNames, nameDebug);
|
||||||
castleTowns = attachIdsAndNames(tagInsidePrefecture(castleTowns, prefectureMask), "castleTown", seed, null, nameFields, usedNames);
|
castleTowns = attachIdsAndNames(tagInsidePrefecture(castleTowns, prefectureMask), "castleTown", seed, null, nameFields, usedNames, nameDebug);
|
||||||
modernCities = attachIdsAndNames(tagInsidePrefecture(modernCities, prefectureMask), "city", seed, null, nameFields, usedNames);
|
modernCities = attachIdsAndNames(tagInsidePrefecture(modernCities, prefectureMask), "city", seed, null, nameFields, usedNames, nameDebug);
|
||||||
stations = attachIdsAndNames(tagInsidePrefecture(stations, prefectureMask), "station", seed, null, nameFields, usedNames);
|
stations = attachIdsAndNames(tagInsidePrefecture(stations, prefectureMask), "station", seed, null, nameFields, usedNames, nameDebug);
|
||||||
industrialZones = attachIdsAndNames(tagInsidePrefecture(industrialZones, prefectureMask), "industrial", seed, null, nameFields, usedNames);
|
industrialZones = attachIdsAndNames(tagInsidePrefecture(industrialZones, prefectureMask), "industrial", seed, null, nameFields, usedNames, nameDebug);
|
||||||
interchanges = attachIdsAndNames(tagInsidePrefecture(interchanges, prefectureMask), "interchange", seed, null, nameFields, usedNames);
|
interchanges = attachIdsAndNames(tagInsidePrefecture(interchanges, prefectureMask), "interchange", seed, null, nameFields, usedNames, nameDebug);
|
||||||
logisticsParks = attachIdsAndNames(tagInsidePrefecture(logisticsParks, prefectureMask), "logistics", seed, null, nameFields, usedNames);
|
logisticsParks = attachIdsAndNames(tagInsidePrefecture(logisticsParks, prefectureMask), "logistics", seed, null, nameFields, usedNames, nameDebug);
|
||||||
satelliteCities = attachIdsAndNames(tagInsidePrefecture(satelliteCities, prefectureMask), "satellite", seed, null, nameFields, usedNames);
|
satelliteCities = attachIdsAndNames(tagInsidePrefecture(satelliteCities, prefectureMask), "satellite", seed, null, nameFields, usedNames, nameDebug);
|
||||||
newTowns = attachIdsAndNames(tagInsidePrefecture(newTowns, prefectureMask), "newtown", seed, null, nameFields, usedNames);
|
newTowns = attachIdsAndNames(tagInsidePrefecture(newTowns, prefectureMask), "newtown", seed, null, nameFields, usedNames, nameDebug);
|
||||||
castleRuins = attachIdsAndNames(tagInsidePrefecture(castleRuins, prefectureMask), "castleRuin", seed, null, nameFields, usedNames);
|
castleRuins = attachIdsAndNames(tagInsidePrefecture(castleRuins, prefectureMask), "castleRuin", seed, null, nameFields, usedNames, nameDebug);
|
||||||
externalGateways = attachIdsAndNames(tagInsidePrefecture(externalGateways, prefectureMask), "gateway", seed, "External Gateway", nameFields, usedNames);
|
externalGateways = attachIdsAndNames(tagInsidePrefecture(externalGateways, prefectureMask), "gateway", seed, "External Gateway", nameFields, usedNames, nameDebug);
|
||||||
const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, prefectureMask), "admin", seed, "Municipal Center", nameFields, usedNames);
|
const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, prefectureMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug);
|
||||||
|
|
||||||
const entitiesForNames = [
|
const entitiesForNames = [
|
||||||
...modernCities,
|
...modernCities,
|
||||||
|
|
@ -3019,5 +3010,6 @@ export function generateMap(seedInput = 114514, options = {}) {
|
||||||
smallStreams,
|
smallStreams,
|
||||||
externalGateways,
|
externalGateways,
|
||||||
entitiesForNames,
|
entitiesForNames,
|
||||||
|
nameDebug,
|
||||||
}, options);
|
}, options);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
621
names.js
621
names.js
|
|
@ -1,67 +1,233 @@
|
||||||
import { MAP_H, MAP_W, indexOf, inside } from "./grid.js";
|
import { MAP_H, MAP_W, indexOf, inside } from "./grid.js";
|
||||||
import { hash2 } from "./random.js";
|
import { hash2 } from "./random.js";
|
||||||
|
|
||||||
// Default and custom place-name resources.
|
export const NAME_KANJI_POOLS = {
|
||||||
// Edit only this file to change generated names or override individual IDs.
|
modifiers: [],
|
||||||
|
inlandTerrain: [],
|
||||||
export const NAME_PARTS = {
|
waterTerrain: [],
|
||||||
// May appear only at the beginning of a name.
|
coastalTerrain: [],
|
||||||
prefixOnly: ["青", "白", "黒", "赤", "藍", "大", "小", "新", "古", "浅", "深", "上", "下", "東", "西", "南", "北", "中", "本", "広", "元", "奥", "前", "高", "長", "丸", "一", "壱", "二", "三", "四", "五", "六", "七", "八", "九", "十", "五十", "百", "千", "万", "阿", "佐々", "代々", "千代", "御", "宇", "須", "伊", "牟", "美",],
|
plants: [],
|
||||||
|
postfixes: [],
|
||||||
// Generic morphemes that may appear at the beginning or in the middle.
|
archaicPrefixes: [],
|
||||||
free: [
|
archaicSuffixes: [],
|
||||||
//nature
|
settlementWords: [],
|
||||||
"川", "山", "花", "森", "松", "竹", "梅", "柳", "栃", "杉", "萩", "荻", "柿", "栗", "椎", "榊", "篠", "桑", "笹", "蓮", "橘", "柏", "樫", "麻", "桜", "岡", "木", "林", "森", "根", "葉", "葦", "日", "月", "君",
|
|
||||||
"野", "尾", "砂", "鮎", "鯉", "鯖", "雁", "菊", "錦", "稲", "稗", "朝", "霧", "坂", "葵", "茜", "犬", "馬", "熊", "猿", "鶴", "鹿", "鷲", "鷹", "亀", "龍", "竜", "燕", "鴨",
|
|
||||||
//objects
|
|
||||||
"畠", "坪", "室", "門", "脇", "末", "硯", "釜", "眞", "綾", "倉", "蔵", "鎌", "窯", "弓", "巻", "槙", "薪", "秦", "幡", "井", "真", "田", "井", "口", "関", "矢", "手", "戸", "庄", "村", "土", "鋸", "酒", "笙", "彦", "比", "碑", "多", "垣", "辻",
|
|
||||||
//others
|
|
||||||
"豊", "志", "野辺", "那", "納", "延", "芳", "越", "喜", "久", "方", "賀", "雅", "角", "刈", "神", "乃", "曲", "成", "幸", "徳", "甲", "由", "和", "清", "弘", "保", "徳", "福", "富", "住", "幸", "弥", "住", "生", "昌", "相", "香", "吉", "𠮷", "佐", "多", "保", "加", "間", "衣"
|
|
||||||
],
|
|
||||||
|
|
||||||
// May appear only at the end of a name.
|
|
||||||
suffixOnly: ["宿", "口", "摩", "磨", "茂", "平", "幡", "島", "豊", "川", "多", "田", "谷", "和", "屋"],
|
|
||||||
|
|
||||||
// Context-aware morphemes that may appear at the beginning or in the middle.
|
|
||||||
contextFree: {
|
|
||||||
coastal: ["浜", "浦", "津", "湊", "潮", "磯", "島", "磯", "渚", "潮", "汐", "淵", "渕", "泊", "葦", "芦"],
|
|
||||||
river: ["瀬", "川", "橋", "渡", "沢", "澤", "淵", "沼", "堀", "渕", "淵"],
|
|
||||||
plain: ["原", "野", "田", "畠", "里", "平", "牧", "塚"],
|
|
||||||
mountain: ["山", "谷", "沢", "峰", "尾", "鞍", "久保", "迫", "岳", "窪", "玖保", "久芳", "檜", "桧", "入", "奥"],
|
|
||||||
historic: ["城", "館", "宮", "寺", "社", "陣", "府", "町", "妙", "忌", "地蔵", "裏", "表"],
|
|
||||||
suburban: [],
|
|
||||||
},
|
|
||||||
|
|
||||||
// Context-aware morphemes that may appear only at the end of a name.
|
|
||||||
contextSuffixes: {
|
|
||||||
coastal: ["浜", "湊", "灘", "浦", "津", "崎", "嶋", "潟", "洲"],
|
|
||||||
river: ["川", "瀬", "橋", "渡", "沼", "沢", "滝", "瀧"],
|
|
||||||
plain: ["原", "野", "田", "里", "村", "平", "鼻", "栖"],
|
|
||||||
mountain: ["谷", "峡", "尾根", "山", "沢", "麓", "嶽", "隅", "陰", "奥"],
|
|
||||||
historic: ["城", "通", "館", "宮", "町", "寺", "院", "門", "柵", "稲荷"],
|
|
||||||
suburban: ["丘", "台", "野", "原", "新町", "ヶ丘"],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NAME_PROBABILITIES = {
|
export const NAME_PROBABILITIES = {
|
||||||
prefixOnly: 0.36,
|
customName: 0.20,
|
||||||
contextFreeAsMain: 0.34,
|
forcedName: 1.0,
|
||||||
additionalContextFree: 0.22,
|
retryCount: 24,
|
||||||
secondFree: 0.18,
|
|
||||||
finalContextSuffix: 0.10,
|
categoryFallback: {
|
||||||
finalSuffixOnly: 0.50,
|
modifiers: 1.0,
|
||||||
finalFree: 0.20,
|
inlandTerrain: 1.0,
|
||||||
twoCharacter: 0.80,
|
waterTerrain: 1.0,
|
||||||
|
coastalTerrain: 1.0,
|
||||||
|
plants: 1.0,
|
||||||
|
postfixes: 1.0,
|
||||||
|
archaicPrefixes: 1.0,
|
||||||
|
archaicSuffixes: 1.0,
|
||||||
|
settlementWords: 1.0,
|
||||||
|
},
|
||||||
|
|
||||||
|
contextCategoryWeights: {
|
||||||
|
generic: {
|
||||||
|
inlandTerrain: 1.0,
|
||||||
|
waterTerrain: 0.6,
|
||||||
|
coastalTerrain: 0.2,
|
||||||
|
plants: 0.8,
|
||||||
|
postfixes: 0.7,
|
||||||
|
archaic: 0.25,
|
||||||
|
settlementWords: 0.6,
|
||||||
|
},
|
||||||
|
coastal: {
|
||||||
|
inlandTerrain: 0.25,
|
||||||
|
waterTerrain: 0.8,
|
||||||
|
coastalTerrain: 1.0,
|
||||||
|
plants: 0.5,
|
||||||
|
postfixes: 0.7,
|
||||||
|
archaic: 0.2,
|
||||||
|
settlementWords: 0.6,
|
||||||
|
},
|
||||||
|
river: {
|
||||||
|
inlandTerrain: 0.5,
|
||||||
|
waterTerrain: 1.0,
|
||||||
|
coastalTerrain: 0.1,
|
||||||
|
plants: 0.6,
|
||||||
|
postfixes: 0.7,
|
||||||
|
archaic: 0.25,
|
||||||
|
settlementWords: 0.6,
|
||||||
|
},
|
||||||
|
plain: {
|
||||||
|
inlandTerrain: 1.0,
|
||||||
|
waterTerrain: 0.35,
|
||||||
|
coastalTerrain: 0.05,
|
||||||
|
plants: 0.9,
|
||||||
|
postfixes: 0.8,
|
||||||
|
archaic: 0.25,
|
||||||
|
settlementWords: 0.7,
|
||||||
|
},
|
||||||
|
mountain: {
|
||||||
|
inlandTerrain: 1.0,
|
||||||
|
waterTerrain: 0.45,
|
||||||
|
coastalTerrain: 0.02,
|
||||||
|
plants: 0.85,
|
||||||
|
postfixes: 0.55,
|
||||||
|
archaic: 0.35,
|
||||||
|
settlementWords: 0.45,
|
||||||
|
},
|
||||||
|
historic: {
|
||||||
|
inlandTerrain: 0.65,
|
||||||
|
waterTerrain: 0.45,
|
||||||
|
coastalTerrain: 0.25,
|
||||||
|
plants: 0.6,
|
||||||
|
postfixes: 0.9,
|
||||||
|
archaic: 0.75,
|
||||||
|
settlementWords: 0.9,
|
||||||
|
},
|
||||||
|
suburban: {
|
||||||
|
inlandTerrain: 0.85,
|
||||||
|
waterTerrain: 0.25,
|
||||||
|
coastalTerrain: 0.15,
|
||||||
|
plants: 0.55,
|
||||||
|
postfixes: 0.85,
|
||||||
|
archaic: 0.10,
|
||||||
|
settlementWords: 1.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CONTEXT_SUFFIXES = NAME_PARTS.contextSuffixes;
|
export const NAME_TEMPLATES = {
|
||||||
|
modifierTerrain: {
|
||||||
export const CUSTOM_NAMES = {
|
slots: ["modifier", "terrain"],
|
||||||
// "city-0": "Aohara",
|
baseWeight: 1.0,
|
||||||
// "port-0": "Shirahama",
|
},
|
||||||
// "castle-0": "Kurono",
|
plantTerrain: {
|
||||||
|
slots: ["plant", "terrain"],
|
||||||
|
baseWeight: 0.85,
|
||||||
|
},
|
||||||
|
terrainPostfix: {
|
||||||
|
slots: ["terrain", "postfix"],
|
||||||
|
baseWeight: 0.75,
|
||||||
|
},
|
||||||
|
archaicPair: {
|
||||||
|
slots: ["archaicPrefix", "archaicSuffix"],
|
||||||
|
baseWeight: 0.35,
|
||||||
|
},
|
||||||
|
archaicTerrain: {
|
||||||
|
slots: ["archaicPrefix", "terrain"],
|
||||||
|
baseWeight: 0.30,
|
||||||
|
},
|
||||||
|
modifierSettlement: {
|
||||||
|
slots: ["modifier", "settlement"],
|
||||||
|
baseWeight: 0.65,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const NAME_TEMPLATE_WEIGHTS = {
|
||||||
|
generic: {
|
||||||
|
modifierTerrain: 1.0,
|
||||||
|
plantTerrain: 0.8,
|
||||||
|
terrainPostfix: 0.7,
|
||||||
|
archaicPair: 0.25,
|
||||||
|
archaicTerrain: 0.25,
|
||||||
|
modifierSettlement: 0.55,
|
||||||
|
},
|
||||||
|
coastal: {
|
||||||
|
modifierTerrain: 0.8,
|
||||||
|
plantTerrain: 0.55,
|
||||||
|
terrainPostfix: 0.9,
|
||||||
|
archaicPair: 0.2,
|
||||||
|
archaicTerrain: 0.25,
|
||||||
|
modifierSettlement: 0.55,
|
||||||
|
},
|
||||||
|
river: {
|
||||||
|
modifierTerrain: 0.9,
|
||||||
|
plantTerrain: 0.85,
|
||||||
|
terrainPostfix: 0.85,
|
||||||
|
archaicPair: 0.25,
|
||||||
|
archaicTerrain: 0.25,
|
||||||
|
modifierSettlement: 0.55,
|
||||||
|
},
|
||||||
|
plain: {
|
||||||
|
modifierTerrain: 1.0,
|
||||||
|
plantTerrain: 0.95,
|
||||||
|
terrainPostfix: 0.8,
|
||||||
|
archaicPair: 0.25,
|
||||||
|
archaicTerrain: 0.25,
|
||||||
|
modifierSettlement: 0.7,
|
||||||
|
},
|
||||||
|
mountain: {
|
||||||
|
modifierTerrain: 1.0,
|
||||||
|
plantTerrain: 0.9,
|
||||||
|
terrainPostfix: 0.5,
|
||||||
|
archaicPair: 0.35,
|
||||||
|
archaicTerrain: 0.45,
|
||||||
|
modifierSettlement: 0.35,
|
||||||
|
},
|
||||||
|
historic: {
|
||||||
|
modifierTerrain: 0.6,
|
||||||
|
plantTerrain: 0.45,
|
||||||
|
terrainPostfix: 0.75,
|
||||||
|
archaicPair: 0.8,
|
||||||
|
archaicTerrain: 0.55,
|
||||||
|
modifierSettlement: 0.95,
|
||||||
|
},
|
||||||
|
suburban: {
|
||||||
|
modifierTerrain: 0.85,
|
||||||
|
plantTerrain: 0.45,
|
||||||
|
terrainPostfix: 0.65,
|
||||||
|
archaicPair: 0.1,
|
||||||
|
archaicTerrain: 0.1,
|
||||||
|
modifierSettlement: 1.0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CUSTOM_NAMES = {};
|
||||||
|
export const FORCED_NAMES = {};
|
||||||
|
|
||||||
|
// Legacy export kept only so older imports do not fail.
|
||||||
|
export const NAME_PARTS = {};
|
||||||
|
|
||||||
|
const POOL_KEYS = Object.keys(NAME_KANJI_POOLS);
|
||||||
|
const TERRAIN_POOL_KEYS = ["inlandTerrain", "waterTerrain", "coastalTerrain"];
|
||||||
|
const ASCII_DIAGNOSTIC_PREFIX = "N";
|
||||||
|
|
||||||
|
function stableHash(value) {
|
||||||
|
const text = String(value || "");
|
||||||
|
let h = 2166136261;
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
h ^= text.charCodeAt(i);
|
||||||
|
h = Math.imul(h, 16777619) >>> 0;
|
||||||
|
}
|
||||||
|
return h >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roll(seed, id, attempt, salt) {
|
||||||
|
const h = stableHash(id);
|
||||||
|
return hash2(h + attempt * 1009 + salt * 9173, seed + salt * 1013, (h ^ seed ^ salt) >>> 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(pool, seed, id, attempt, salt) {
|
||||||
|
if (!pool?.length) return null;
|
||||||
|
const index = Math.floor(roll(seed, id, attempt, salt) * pool.length) % pool.length;
|
||||||
|
return pool[index] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countChars(value) {
|
||||||
|
return Array.from(String(value || "")).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function incrementCounter(counter, key, amount = 1) {
|
||||||
|
counter[key] = (counter[key] || 0) + amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundedIndex(entity) {
|
||||||
|
const x = Math.max(0, Math.min(MAP_W - 1, Math.round(entity?.x || 0)));
|
||||||
|
const y = Math.max(0, Math.min(MAP_H - 1, Math.round(entity?.y || 0)));
|
||||||
|
return indexOf(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
function isNearSea(x, y, sea, radius = 3) {
|
function isNearSea(x, y, sea, radius = 3) {
|
||||||
if (!sea) return false;
|
if (!sea) return false;
|
||||||
for (let dy = -radius; dy <= radius; dy++) {
|
for (let dy = -radius; dy <= radius; dy++) {
|
||||||
|
|
@ -74,157 +240,234 @@ function isNearSea(x, y, sea, radius = 3) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function boundedIndex(entity) {
|
function contextWeightsFor(context, probabilities = NAME_PROBABILITIES) {
|
||||||
return indexOf(Math.max(0, Math.min(MAP_W - 1, entity.x)), Math.max(0, Math.min(MAP_H - 1, entity.y)));
|
return probabilities.contextCategoryWeights?.[context] || probabilities.contextCategoryWeights?.generic || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function chooseNameContext(entity, fields) {
|
function poolWeight(poolKey, context, probabilities = NAME_PROBABILITIES) {
|
||||||
const kind = String(entity.kind || "").toLowerCase();
|
const weights = contextWeightsFor(context, probabilities);
|
||||||
const i = boundedIndex(entity);
|
const fallback = probabilities.categoryFallback?.[poolKey] ?? 1.0;
|
||||||
const coastal = (fields?.coastalLowland?.[i] || 0) > 0.35 || isNearSea(entity.x, entity.y, fields?.sea);
|
if (poolKey === "archaicPrefixes" || poolKey === "archaicSuffixes") return (weights.archaic ?? 1.0) * fallback;
|
||||||
const river = (fields?.river?.[i] || 0) > 0.35 || (fields?.valleyField?.[i] || 0) > 0.42;
|
return (weights[poolKey] ?? 1.0) * fallback;
|
||||||
const plain = (fields?.plain?.[i] || 0) > 0.48 || (fields?.agriculture?.[i] || 0) > 0.46 || (fields?.basinField?.[i] || 0) > 0.35;
|
}
|
||||||
const mountain = (fields?.elevation?.[i] || 0) > 0.52 || (fields?.slope?.[i] || 0) > 0.35 || (fields?.ridgeField?.[i] || 0) > 0.42;
|
|
||||||
|
|
||||||
if (kind.includes("port") || kind.includes("harbor") || entity.portClass) return "coastal";
|
function slotPoolKeys(slot, context, probabilities = NAME_PROBABILITIES) {
|
||||||
|
if (slot === "terrain") {
|
||||||
|
return weightedKeyOrder(TERRAIN_POOL_KEYS, context, probabilities, 113);
|
||||||
|
}
|
||||||
|
const map = {
|
||||||
|
modifier: "modifiers",
|
||||||
|
plant: "plants",
|
||||||
|
postfix: "postfixes",
|
||||||
|
archaicPrefix: "archaicPrefixes",
|
||||||
|
archaicSuffix: "archaicSuffixes",
|
||||||
|
settlement: "settlementWords",
|
||||||
|
};
|
||||||
|
return map[slot] ? [map[slot]] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function weightedKeyOrder(keys, context, probabilities, salt) {
|
||||||
|
return [...keys].sort((a, b) => {
|
||||||
|
const wa = poolWeight(a, context, probabilities);
|
||||||
|
const wb = poolWeight(b, context, probabilities);
|
||||||
|
if (wb !== wa) return wb - wa;
|
||||||
|
return stableHash(`${a}:${salt}`) - stableHash(`${b}:${salt}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function weightedChoice(entries, seed, id, attempt, salt) {
|
||||||
|
const active = entries.filter((entry) => entry.weight > 0);
|
||||||
|
const total = active.reduce((sum, entry) => sum + entry.weight, 0);
|
||||||
|
if (total <= 0) return null;
|
||||||
|
let target = roll(seed, id, attempt, salt) * total;
|
||||||
|
for (const entry of active) {
|
||||||
|
target -= entry.weight;
|
||||||
|
if (target <= 0) return entry.key;
|
||||||
|
}
|
||||||
|
return active[active.length - 1]?.key || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickWeightedPoolKey(keys, context, pools, probabilities, seed, id, attempt, salt) {
|
||||||
|
const entries = keys.map((key) => ({
|
||||||
|
key,
|
||||||
|
weight: poolWeight(key, context, probabilities) * (pools[key]?.length ? 1 : 0),
|
||||||
|
}));
|
||||||
|
return weightedChoice(entries, seed, id, attempt, salt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNameDebug(probabilities = NAME_PROBABILITIES, pools = NAME_KANJI_POOLS) {
|
||||||
|
const emptyPools = POOL_KEYS.filter((key) => !pools[key]?.length);
|
||||||
|
const poolsPresent = Object.fromEntries(POOL_KEYS.map((key) => [key, Boolean(pools[key]?.length)]));
|
||||||
|
return {
|
||||||
|
effectiveCustomNameProbability: probabilities.customName,
|
||||||
|
poolsPresent,
|
||||||
|
emptyPools,
|
||||||
|
selectedTemplateCounts: {},
|
||||||
|
selectedContextCounts: {},
|
||||||
|
customNamesUsed: 0,
|
||||||
|
forcedNamesUsed: 0,
|
||||||
|
generatedNamesUsed: 0,
|
||||||
|
invalidNamesRejected: 0,
|
||||||
|
oneCharacterNamesPrevented: 0,
|
||||||
|
duplicateRetries: 0,
|
||||||
|
fallbackAttempts: 0,
|
||||||
|
legacyFallbackUsed: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chooseNameContext(entity, fields) {
|
||||||
|
const kind = String(entity?.kind || "").toLowerCase();
|
||||||
|
const i = boundedIndex(entity);
|
||||||
|
const coastal = (fields?.coastalLowland?.[i] || 0) > 0.35 || isNearSea(entity?.x || 0, entity?.y || 0, fields?.sea);
|
||||||
|
const river = (fields?.river?.[i] || 0) > 0.35 || (fields?.flowAccum?.[i] || 0) > 0.55 || (fields?.valleyField?.[i] || 0) > 0.42;
|
||||||
|
const plain = (fields?.plain?.[i] || 0) > 0.48 || (fields?.agriculture?.[i] || 0) > 0.46;
|
||||||
|
const mountain = (fields?.elevation?.[i] || 0) > 0.52 || (fields?.slope?.[i] || 0) > 0.35 || (fields?.ridgeField?.[i] || 0) > 0.42;
|
||||||
|
const urban = (fields?.populationDensity?.[i] || 0) > 0.35 || [2, 3, 4, 7, 8].includes(fields?.landuse?.[i]);
|
||||||
|
|
||||||
|
if (kind.includes("port") || kind.includes("harbor") || entity?.portClass) return "coastal";
|
||||||
if (kind.includes("crossing") || kind.includes("bridge")) return "river";
|
if (kind.includes("crossing") || kind.includes("bridge")) return "river";
|
||||||
if (kind.includes("pass") || kind.includes("mountain")) return "mountain";
|
if (kind.includes("pass") || kind.includes("mountain")) return "mountain";
|
||||||
if (kind.includes("castle") || kind.includes("market")) return "historic";
|
if (kind.includes("castle") || kind.includes("market")) return "historic";
|
||||||
if (kind.includes("new town") || kind.includes("satellite")) return "suburban";
|
if (kind.includes("newtown") || kind.includes("new town") || kind.includes("satellite") || kind.includes("station") || urban) return "suburban";
|
||||||
if (coastal && !mountain) return "coastal";
|
if (coastal && !mountain) return "coastal";
|
||||||
if (river) return "river";
|
if (river) return "river";
|
||||||
if (mountain && !plain) return "mountain";
|
if (mountain && !plain) return "mountain";
|
||||||
if (plain) return "plain";
|
if (plain) return "plain";
|
||||||
return "plain";
|
return "generic";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function chooseNameSuffixPool(entity, fields) {
|
export function resolveSlotPool(slot, context, pools = NAME_KANJI_POOLS, probabilities = NAME_PROBABILITIES, seed = 0, id = "", attempt = 0) {
|
||||||
const context = chooseNameContext(entity, fields);
|
const keys = slotPoolKeys(slot, context, probabilities);
|
||||||
return NAME_PARTS.contextSuffixes[context] || NAME_PARTS.contextSuffixes.plain;
|
if (!keys.length) return null;
|
||||||
}
|
const selectedKey = pickWeightedPoolKey(keys, context, pools, probabilities, seed, id, attempt, 1501 + stableHash(slot));
|
||||||
|
if (selectedKey) return { key: selectedKey, pool: pools[selectedKey] };
|
||||||
function chooseContextFreePool(entity, fields, nameParts) {
|
for (const key of weightedKeyOrder(keys, context, probabilities, 1559)) {
|
||||||
const context = chooseNameContext(entity, fields);
|
if (pools[key]?.length) return { key, pool: pools[key] };
|
||||||
return nameParts.contextFree?.[context] || nameParts.contextFree?.plain || nameParts.free || [""];
|
|
||||||
}
|
|
||||||
|
|
||||||
function pick(pool, seed, n, salt) {
|
|
||||||
if (!pool?.length) return "";
|
|
||||||
const index = Math.floor(hash2(n + salt * 101, seed + salt * 1009, seed + n * 37 + salt) * pool.length) % pool.length;
|
|
||||||
return pool[index];
|
|
||||||
}
|
|
||||||
|
|
||||||
function chance(seed, n, salt, probability) {
|
|
||||||
return hash2(n + salt * 173, seed + salt * 811, seed + n * 43 + salt) < probability;
|
|
||||||
}
|
|
||||||
|
|
||||||
function characterLength(value) {
|
|
||||||
return Array.from(String(value || "")).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function oneCharacterPool(pool, fallbackPool = pool) {
|
|
||||||
const filtered = (pool || []).filter((part) => characterLength(part) === 1);
|
|
||||||
return filtered.length ? filtered : (fallbackPool || [""]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendPart(parts, part) {
|
|
||||||
if (!part) return;
|
|
||||||
const previous = parts[parts.length - 1] || "";
|
|
||||||
if (previous && (previous === part || previous.endsWith(part) || part.startsWith(previous))) return;
|
|
||||||
parts.push(part);
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendFinalPart(parts, part, fallbackPool, seed, n, salt) {
|
|
||||||
let finalPart = part;
|
|
||||||
if (finalPart && parts.some((existing) => existing === finalPart || existing.endsWith(finalPart))) {
|
|
||||||
finalPart = pick(fallbackPool, seed, n, salt + 1);
|
|
||||||
}
|
}
|
||||||
appendPart(parts, finalPart);
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function trimNameParts(parts) {
|
export function validateGeneratedName(name, options = {}) {
|
||||||
if (parts.length <= 3) return parts;
|
const value = String(name || "");
|
||||||
return [parts[0], ...parts.slice(-2)];
|
const length = countChars(value);
|
||||||
|
if (!value) return { valid: false, reason: "empty" };
|
||||||
|
if (value.includes("\uFFFD")) return { valid: false, reason: "replacement" };
|
||||||
|
if (!options.allowAsciiDiagnostic && value.startsWith(ASCII_DIAGNOSTIC_PREFIX) && /^N[0-9A-Z]+$/.test(value)) {
|
||||||
|
return { valid: false, reason: "asciiDiagnostic" };
|
||||||
|
}
|
||||||
|
if (!options.allowOneCharacter && length < 2) return { valid: false, reason: "oneCharacter" };
|
||||||
|
if (!options.allowLong && length > 4) return { valid: false, reason: "tooLong" };
|
||||||
|
return { valid: true, reason: "valid" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseTemplate(context, seed, id, attempt) {
|
||||||
|
const contextWeights = NAME_TEMPLATE_WEIGHTS[context] || NAME_TEMPLATE_WEIGHTS.generic;
|
||||||
|
const entries = Object.entries(NAME_TEMPLATES).map(([key, template]) => ({
|
||||||
|
key,
|
||||||
|
weight: (template.baseWeight ?? 1.0) * (contextWeights?.[key] ?? 0),
|
||||||
|
}));
|
||||||
|
return weightedChoice(entries, seed, id, attempt, 2003);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateTemplateNameDetails(seed, id, entity, fields, attempt, usedNames, pools = NAME_KANJI_POOLS, probabilities = NAME_PROBABILITIES) {
|
||||||
|
const context = chooseNameContext(entity, fields);
|
||||||
|
const templateKey = chooseTemplate(context, seed, id, attempt);
|
||||||
|
const template = NAME_TEMPLATES[templateKey];
|
||||||
|
if (!template) return { name: null, context, templateKey: null };
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
for (let slotIndex = 0; slotIndex < template.slots.length; slotIndex++) {
|
||||||
|
const slot = template.slots[slotIndex];
|
||||||
|
const slotPool = resolveSlotPool(slot, context, pools, probabilities, seed, id, attempt + slotIndex);
|
||||||
|
if (!slotPool?.pool?.length) return { name: null, context, templateKey };
|
||||||
|
const part = pick(slotPool.pool, seed, id, attempt, 2503 + slotIndex * 127 + stableHash(slotPool.key));
|
||||||
|
if (!part) return { name: null, context, templateKey };
|
||||||
|
parts.push(part);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = parts.join("");
|
||||||
|
const validation = validateGeneratedName(name);
|
||||||
|
if (!validation.valid) return { name: null, context, templateKey, invalidReason: validation.reason };
|
||||||
|
if (usedNames?.has(name)) return { name: null, context, templateKey, duplicate: true };
|
||||||
|
return { name, context, templateKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateTemplateName(seed, id, entity, fields, attempt = 0, usedNames = null) {
|
||||||
|
return generateTemplateNameDetails(seed, id, entity, fields, attempt, usedNames).name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deterministicDiagnosticName(seed, id, attempt = 0) {
|
||||||
|
const h = Math.floor(roll(seed, id, attempt, 3001) * 0xb640).toString(36).toUpperCase();
|
||||||
|
return `${ASCII_DIAGNOSTIC_PREFIX}${h.padStart(3, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueDiagnosticName(seed, id, usedNames, debug, startAttempt = 0) {
|
||||||
|
for (let offset = 0; offset < 128; offset++) {
|
||||||
|
const name = deterministicDiagnosticName(seed, id, startAttempt + offset);
|
||||||
|
if (!usedNames?.has(name)) return name;
|
||||||
|
debug.duplicateRetries++;
|
||||||
|
}
|
||||||
|
return `${ASCII_DIAGNOSTIC_PREFIX}${stableHash(`${seed}:${id}`).toString(36).toUpperCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryCustomName(seed, id, usedNames, debug) {
|
||||||
|
const customName = CUSTOM_NAMES[id];
|
||||||
|
if (!customName) return null;
|
||||||
|
if (roll(seed, id, 0, 3501) >= NAME_PROBABILITIES.customName) return null;
|
||||||
|
const validation = validateGeneratedName(customName, { allowAsciiDiagnostic: true });
|
||||||
|
if (!validation.valid) {
|
||||||
|
if (validation.reason === "oneCharacter") debug.oneCharacterNamesPrevented++;
|
||||||
|
else debug.invalidNamesRejected++;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (usedNames?.has(customName)) {
|
||||||
|
debug.duplicateRetries++;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
debug.customNamesUsed++;
|
||||||
|
return customName;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateEntityName(seed, id, entity, fields, usedNames = null, debug = createNameDebug()) {
|
||||||
|
debug ||= createNameDebug();
|
||||||
|
const forcedName = FORCED_NAMES[id];
|
||||||
|
if (forcedName) {
|
||||||
|
debug.forcedNamesUsed++;
|
||||||
|
return forcedName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const customName = tryCustomName(seed, id, usedNames, debug);
|
||||||
|
if (customName) return customName;
|
||||||
|
|
||||||
|
const retryCount = Math.max(1, NAME_PROBABILITIES.retryCount || 1);
|
||||||
|
for (let attempt = 0; attempt < retryCount; attempt++) {
|
||||||
|
const result = generateTemplateNameDetails(seed, id, entity, fields, attempt, usedNames);
|
||||||
|
if (result.context) incrementCounter(debug.selectedContextCounts, result.context);
|
||||||
|
if (result.templateKey) incrementCounter(debug.selectedTemplateCounts, result.templateKey);
|
||||||
|
if (result.duplicate) {
|
||||||
|
debug.duplicateRetries++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (result.invalidReason) {
|
||||||
|
if (result.invalidReason === "oneCharacter") debug.oneCharacterNamesPrevented++;
|
||||||
|
else debug.invalidNamesRejected++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (result.name) {
|
||||||
|
debug.generatedNamesUsed++;
|
||||||
|
return result.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug.fallbackAttempts++;
|
||||||
|
return uniqueDiagnosticName(seed, id, usedNames, debug, retryCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function contextualDefaultName(seed, id, entity, nameParts = NAME_PARTS, fields, attempt = 0) {
|
export function contextualDefaultName(seed, id, entity, nameParts = NAME_PARTS, fields, attempt = 0) {
|
||||||
const prefixKey = id.split("-")[0];
|
const name = generateTemplateName(seed, id, entity, fields, attempt, null);
|
||||||
const n = Number(id.split("-")[1] || 0) + attempt * 997;
|
return name || deterministicDiagnosticName(seed, id, attempt);
|
||||||
|
}
|
||||||
const prefixOnly = nameParts.prefixOnly || nameParts.prefixes || [""];
|
|
||||||
const free = nameParts.free || nameParts.infixes || [""];
|
export function chooseNameSuffixPool() {
|
||||||
const suffixOnly = nameParts.suffixOnly || nameParts.suffixes || [""];
|
return NAME_KANJI_POOLS.postfixes;
|
||||||
const contextFree = chooseContextFreePool(entity, fields, nameParts);
|
|
||||||
const contextSuffixes = chooseNameSuffixPool(entity, fields);
|
|
||||||
const probabilities = nameParts.probabilities || NAME_PROBABILITIES;
|
|
||||||
|
|
||||||
if (chance(seed, n, 7 + prefixKey.length, probabilities.twoCharacter ?? 0.80)) {
|
|
||||||
const onePrefixOnly = oneCharacterPool(prefixOnly);
|
|
||||||
const oneFree = oneCharacterPool(free);
|
|
||||||
const oneSuffixOnly = oneCharacterPool(suffixOnly, oneFree);
|
|
||||||
const oneContextFree = oneCharacterPool(contextFree, oneFree);
|
|
||||||
const oneContextSuffixes = oneCharacterPool(contextSuffixes, oneSuffixOnly);
|
|
||||||
|
|
||||||
const usePrefix = chance(seed, n, 11 + prefixKey.length, probabilities.prefixOnly);
|
|
||||||
const useContextAsMain = chance(seed, n, 13 + prefixKey.length, probabilities.contextFreeAsMain ?? probabilities.contextFree ?? 0.34);
|
|
||||||
const firstPart = usePrefix
|
|
||||||
? pick(onePrefixOnly, seed, n, 101)
|
|
||||||
: pick(useContextAsMain ? oneContextFree : oneFree, seed, n, useContextAsMain ? 211 : 223);
|
|
||||||
|
|
||||||
const finalContextProbability = probabilities.finalContextSuffix ?? 0.10;
|
|
||||||
const finalSuffixProbability = probabilities.finalSuffixOnly ?? 0.50;
|
|
||||||
const finalFreeProbability = probabilities.finalFree ?? Math.max(0, 1 - finalContextProbability - finalSuffixProbability);
|
|
||||||
const finalTotal = Math.max(0.0001, finalContextProbability + finalSuffixProbability + finalFreeProbability);
|
|
||||||
const finalRoll = hash2(n + prefixKey.length * 23, seed + n * 37, seed + 7919);
|
|
||||||
const contextThreshold = finalContextProbability / finalTotal;
|
|
||||||
const suffixThreshold = (finalContextProbability + finalSuffixProbability) / finalTotal;
|
|
||||||
let finalPart;
|
|
||||||
if (finalRoll < contextThreshold) {
|
|
||||||
finalPart = pick(oneContextSuffixes, seed, n, 503);
|
|
||||||
} else if (finalRoll < suffixThreshold) {
|
|
||||||
finalPart = pick(oneSuffixOnly, seed, n, 509);
|
|
||||||
} else {
|
|
||||||
finalPart = pick(oneFree, seed, n, 521);
|
|
||||||
}
|
|
||||||
if (finalPart === firstPart) finalPart = pick(oneSuffixOnly, seed, n, 601);
|
|
||||||
if (finalPart === firstPart) finalPart = pick(oneFree, seed, n, 607);
|
|
||||||
return `${firstPart || pick(oneFree, seed, n, 613)}${finalPart || pick(oneSuffixOnly, seed, n, 617)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = [];
|
|
||||||
|
|
||||||
if (chance(seed, n, 11 + prefixKey.length, probabilities.prefixOnly)) {
|
|
||||||
appendPart(parts, pick(prefixOnly, seed, n, 101));
|
|
||||||
}
|
|
||||||
|
|
||||||
const useContextAsMain = chance(seed, n, 13 + prefixKey.length, probabilities.contextFreeAsMain ?? probabilities.contextFree ?? 0.34);
|
|
||||||
appendPart(parts, pick(useContextAsMain ? contextFree : free, seed, n, useContextAsMain ? 211 : 223));
|
|
||||||
|
|
||||||
if (!useContextAsMain && chance(seed, n, 17 + prefixKey.length, probabilities.additionalContextFree ?? probabilities.contextFree ?? 0.22)) {
|
|
||||||
appendPart(parts, pick(contextFree, seed, n, 307));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chance(seed, n, 19 + prefixKey.length, probabilities.secondFree)) {
|
|
||||||
appendPart(parts, pick(free, seed, n, 401));
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalRoll = hash2(n + prefixKey.length * 23, seed + n * 37, seed + 7919);
|
|
||||||
let finalPart;
|
|
||||||
const finalContextProbability = probabilities.finalContextSuffix ?? 0.30;
|
|
||||||
const finalSuffixProbability = probabilities.finalSuffixOnly ?? 0.50;
|
|
||||||
const finalFreeProbability = probabilities.finalFree ?? Math.max(0, 1 - finalContextProbability - finalSuffixProbability);
|
|
||||||
const finalTotal = Math.max(0.0001, finalContextProbability + finalSuffixProbability + finalFreeProbability);
|
|
||||||
const contextThreshold = finalContextProbability / finalTotal;
|
|
||||||
const suffixThreshold = (finalContextProbability + finalSuffixProbability) / finalTotal;
|
|
||||||
if (finalRoll < contextThreshold) {
|
|
||||||
finalPart = pick(contextSuffixes, seed, n, 503);
|
|
||||||
} else if (finalRoll < suffixThreshold) {
|
|
||||||
finalPart = pick(suffixOnly, seed, n, 509);
|
|
||||||
} else {
|
|
||||||
finalPart = pick(free, seed, n, 521);
|
|
||||||
}
|
|
||||||
appendFinalPart(parts, finalPart, suffixOnly, seed, n, 601);
|
|
||||||
|
|
||||||
const name = trimNameParts(parts).join("");
|
|
||||||
return name || pick(suffixOnly, seed, n, 601) || "原";
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
22
renderer.js
22
renderer.js
|
|
@ -90,20 +90,20 @@ function discreteColor(map, x, y, mode) {
|
||||||
let color;
|
let color;
|
||||||
|
|
||||||
if (map.sea[i]) {
|
if (map.sea[i]) {
|
||||||
// Google Map風の海色
|
// Google Map like styled
|
||||||
color = [170, 218, 255];
|
color = [170, 218, 255];
|
||||||
} else if (mode === "landuse") {
|
} else if (mode === "landuse") {
|
||||||
const colors = {
|
const colors = {
|
||||||
0: [230, 242, 220], // 農地
|
0: [230, 242, 220], // farmland
|
||||||
1: [235, 245, 225], // 平地
|
1: [235, 245, 225], // plain
|
||||||
2: [235, 230, 220], // 旧市街
|
2: [235, 230, 220], // old city
|
||||||
3: [224, 202, 190], // 中心市街地 / CBD
|
3: [224, 202, 190], // CBD
|
||||||
4: [245, 240, 230], // 郊外
|
4: [245, 240, 230], // suburb
|
||||||
5: [220, 220, 225], // 工業地域
|
5: [220, 220, 225], // industrial area
|
||||||
6: [225, 235, 225], // 物流エリア
|
6: [225, 235, 225], // logistics area
|
||||||
7: [238, 242, 248], // ニュータウン
|
7: [238, 242, 248], // new town
|
||||||
8: [248, 242, 230], // 沿道開発
|
8: [248, 242, 230], // coastal development
|
||||||
9: [225, 238, 220], // その他
|
9: [225, 238, 220], // others
|
||||||
};
|
};
|
||||||
color = colors[map.landuse[i]] || colors[0];
|
color = colors[map.landuse[i]] || colors[0];
|
||||||
} else if (mode === "admin") {
|
} else if (mode === "admin") {
|
||||||
|
|
|
||||||
94
test.js
94
test.js
|
|
@ -1,11 +1,26 @@
|
||||||
import { generateMap, MAP_W, MAP_H, indexOf } from "./mapGenerator.js";
|
import { generateMap, MAP_W, MAP_H, indexOf } from "./mapGenerator.js";
|
||||||
import { CUSTOM_NAMES } from "./names.js";
|
import {
|
||||||
|
CUSTOM_NAMES,
|
||||||
|
FORCED_NAMES,
|
||||||
|
NAME_KANJI_POOLS,
|
||||||
|
NAME_PARTS,
|
||||||
|
NAME_PROBABILITIES,
|
||||||
|
NAME_TEMPLATES,
|
||||||
|
NAME_TEMPLATE_WEIGHTS,
|
||||||
|
generateTemplateName,
|
||||||
|
} from "./names.js";
|
||||||
import { adminBoundaryMetrics, majorCityCoreIntegrity } from "./qualityMetrics.js";
|
import { adminBoundaryMetrics, majorCityCoreIntegrity } from "./qualityMetrics.js";
|
||||||
|
|
||||||
const result = document.getElementById("result");
|
const result = document.getElementById("result");
|
||||||
const logLines = [];
|
const logLines = [];
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|
||||||
|
const [namesSource, mapGeneratorSource, testSource] = await Promise.all([
|
||||||
|
fetch("./names.js").then((response) => response.text()),
|
||||||
|
fetch("./mapGenerator.js").then((response) => response.text()),
|
||||||
|
fetch("./test.js").then((response) => response.text()),
|
||||||
|
]);
|
||||||
|
|
||||||
function assert(condition, message) {
|
function assert(condition, message) {
|
||||||
if (condition) logLines.push(`OK: ${message}`);
|
if (condition) logLines.push(`OK: ${message}`);
|
||||||
else {
|
else {
|
||||||
|
|
@ -79,16 +94,26 @@ try {
|
||||||
const allNameable = map.entitiesForNames || [];
|
const allNameable = map.entitiesForNames || [];
|
||||||
const uniqueNames = new Set(allNameable.map((item) => item.name));
|
const uniqueNames = new Set(allNameable.map((item) => item.name));
|
||||||
const duplicateNameRatio = allNameable.length ? 1 - uniqueNames.size / allNameable.length : 0;
|
const duplicateNameRatio = allNameable.length ? 1 - uniqueNames.size / allNameable.length : 0;
|
||||||
const coastalSuffixes = ["\u6d5c", "\u6e4a", "\u6e2f", "\u6d66", "\u6d25", "\u5d0e", "\u6e7e"];
|
const activePoolChars = new Set(Object.values(NAME_KANJI_POOLS).flat().flatMap((part) => Array.from(String(part))));
|
||||||
const mountainSuffixes = ["\u8c37", "\u5ce0", "\u5c3e\u6839", "\u5c71", "\u6ca2", "\u9e93"];
|
const namedEntityCount = [
|
||||||
const farInlandCoastalNames = allNameable.filter((p) => {
|
...map.villages,
|
||||||
const i = indexOf(p.x, p.y);
|
...map.ports,
|
||||||
return !p.portClass && (map.coastalLowland[i] || 0) < 0.12 && coastalSuffixes.some((suffix) => p.name?.endsWith(suffix));
|
...map.crossings,
|
||||||
}).length;
|
...map.passes,
|
||||||
const flatCoastalMountainNames = allNameable.filter((p) => {
|
...map.markets,
|
||||||
const i = indexOf(p.x, p.y);
|
...map.castles,
|
||||||
return (map.coastalLowland[i] || 0) > 0.35 && (map.slope[i] || 0) < 0.18 && mountainSuffixes.some((suffix) => p.name?.endsWith(suffix));
|
...map.castleTowns,
|
||||||
}).length;
|
...map.modernCities,
|
||||||
|
...map.stations,
|
||||||
|
...map.industrialZones,
|
||||||
|
...map.interchanges,
|
||||||
|
...map.logisticsParks,
|
||||||
|
...map.satelliteCities,
|
||||||
|
...map.newTowns,
|
||||||
|
...map.castleRuins,
|
||||||
|
...map.externalGateways,
|
||||||
|
...map.adminCenters,
|
||||||
|
].filter((item) => item?.id && item?.name).length;
|
||||||
const villageClusterMean = map.villages.length
|
const villageClusterMean = map.villages.length
|
||||||
? map.villages.reduce((sum, p) => sum + (map.settlementCluster?.[indexOf(p.x, p.y)] || 0), 0) / map.villages.length
|
? map.villages.reduce((sum, p) => sum + (map.settlementCluster?.[indexOf(p.x, p.y)] || 0), 0) / map.villages.length
|
||||||
: 0;
|
: 0;
|
||||||
|
|
@ -119,6 +144,20 @@ try {
|
||||||
const adminMetrics = adminBoundaryMetrics(map);
|
const adminMetrics = adminBoundaryMetrics(map);
|
||||||
const cityCoreIntegrity = majorCityCoreIntegrity(map);
|
const cityCoreIntegrity = majorCityCoreIntegrity(map);
|
||||||
|
|
||||||
|
assert(NAME_KANJI_POOLS && Array.isArray(NAME_KANJI_POOLS.modifiers), "NAME_KANJI_POOLS exists");
|
||||||
|
assert(NAME_TEMPLATES && NAME_TEMPLATES.modifierTerrain?.slots?.length === 2, "NAME_TEMPLATES exists");
|
||||||
|
assert(NAME_TEMPLATE_WEIGHTS && NAME_TEMPLATE_WEIGHTS.generic?.modifierTerrain > 0, "NAME_TEMPLATE_WEIGHTS exists");
|
||||||
|
assert(NAME_PROBABILITIES && NAME_PROBABILITIES.contextCategoryWeights?.generic, "NAME_PROBABILITIES exists");
|
||||||
|
const removedContextModule = "placeName" + "Context.js";
|
||||||
|
assert(!namesSource.includes(removedContextModule) && !mapGeneratorSource.includes(removedContextModule) && !testSource.includes(removedContextModule), "removed name-context import is absent");
|
||||||
|
assert(Object.keys(NAME_KANJI_POOLS).every((key) => Array.isArray(NAME_KANJI_POOLS[key])), "name category pools are centralized arrays");
|
||||||
|
assert(Object.values(NAME_KANJI_POOLS).every((pool) => pool.length === 0), "default name category pools are empty");
|
||||||
|
assert(Object.keys(NAME_PARTS).length === 0, "legacy NAME_PARTS has no hidden candidates");
|
||||||
|
const removedContextSuffixConst = "CONTEXT" + "_SUFFIXES";
|
||||||
|
const removedContextSuffixKey = "context" + "Suffixes";
|
||||||
|
assert(!namesSource.includes(removedContextSuffixConst) && !namesSource.includes(removedContextSuffixKey), "hidden context suffix arrays are absent");
|
||||||
|
assert(!/export\s+const\s+NAME_PROBABILITIES[\s\S]*export\s+const\s+NAME_PROBABILITIES/.test(namesSource), "NAME_PROBABILITIES has one source");
|
||||||
|
|
||||||
assert(map.elevation.length === size, "elevation length matches map size");
|
assert(map.elevation.length === size, "elevation length matches map size");
|
||||||
assert(map.sea.length === size, "sea length matches map size");
|
assert(map.sea.length === size, "sea length matches map size");
|
||||||
assert(map.river.length === size, "river length matches map size");
|
assert(map.river.length === size, "river length matches map size");
|
||||||
|
|
@ -204,17 +243,24 @@ try {
|
||||||
assert(capitalInside, "prefectural capital is inside the prefecture");
|
assert(capitalInside, "prefectural capital is inside the prefecture");
|
||||||
assert(maxModernEndpointDegree <= 10, "modern transport endpoints are not over-centralized");
|
assert(maxModernEndpointDegree <= 10, "modern transport endpoints are not over-centralized");
|
||||||
assert(map.entitiesForNames.every((item) => item.id && item.name), "nameable entities have ids and names");
|
assert(map.entitiesForNames.every((item) => item.id && item.name), "nameable entities have ids and names");
|
||||||
|
assert(map.entitiesForNames.every((item) => typeof item.name === "string"), "nameable entity names are strings");
|
||||||
assert(map.entitiesForNames.every((item) => !String(item.name).includes("\uFFFD")), "generated names contain no replacement characters");
|
assert(map.entitiesForNames.every((item) => !String(item.name).includes("\uFFFD")), "generated names contain no replacement characters");
|
||||||
|
assert(map.entitiesForNames.every((item) => Array.from(String(item.name)).length >= 2), "one-character generated names are prevented");
|
||||||
|
assert(map.entitiesForNames.every((item) => String(item.name).length > 0), "empty generated names are prevented");
|
||||||
assert(duplicateNameRatio < 0.18, "generated place-name duplicates stay low");
|
assert(duplicateNameRatio < 0.18, "generated place-name duplicates stay low");
|
||||||
assert(farInlandCoastalNames <= Math.max(2, Math.ceil(allNameable.length * 0.12)), "coastal suffixes are not overused far inland");
|
assert(map.nameDebug && Array.isArray(map.nameDebug.emptyPools), "nameDebug reports empty pools");
|
||||||
assert(flatCoastalMountainNames <= Math.max(2, Math.ceil(allNameable.length * 0.10)), "mountain suffixes are not overused on flat coastal lowlands");
|
assert(map.nameDebug.emptyPools.length === Object.keys(NAME_KANJI_POOLS).length, "empty default pools are visible in nameDebug");
|
||||||
|
assert(map.nameDebug.selectedTemplateCounts && typeof map.nameDebug.selectedTemplateCounts === "object", "nameDebug selectedTemplateCounts exists");
|
||||||
|
assert(map.nameDebug.selectedContextCounts && typeof map.nameDebug.selectedContextCounts === "object", "nameDebug selectedContextCounts exists");
|
||||||
|
assert(
|
||||||
|
map.nameDebug.generatedNamesUsed + map.nameDebug.customNamesUsed + map.nameDebug.forcedNamesUsed + map.nameDebug.fallbackAttempts === namedEntityCount,
|
||||||
|
"nameDebug accounting covers named entities"
|
||||||
|
);
|
||||||
|
assert(generateTemplateName(777, "probe-0", { x: 10, y: 10, kind: "Probe" }, {}, 0, new Set()) === null, "empty pools do not use hidden fallback candidates");
|
||||||
|
assert(activePoolChars.size === 0, "no active pool characters exist until configured");
|
||||||
assert(villageClusterMean > 0.16, "villages prefer clustered valley, basin, coastal, and agricultural cells");
|
assert(villageClusterMean > 0.16, "villages prefer clustered valley, basin, coastal, and agricultural cells");
|
||||||
assert(saneEndpointRatio >= 0.76, "transport endpoints stay near meaningful generated nodes");
|
assert(saneEndpointRatio >= 0.76, "transport endpoints stay near meaningful generated nodes");
|
||||||
if (Object.keys(CUSTOM_NAMES).length > 0) {
|
assert(Object.keys(CUSTOM_NAMES).length === 0 || NAME_PROBABILITIES.customName < 1, "CUSTOM_NAMES are probabilistic by default");
|
||||||
assert(map.entitiesForNames.every((item) => !CUSTOM_NAMES[item.id] || item.name === CUSTOM_NAMES[item.id]), "CUSTOM_NAMES override generated names");
|
|
||||||
} else {
|
|
||||||
assert(true, "CUSTOM_NAMES override hook remains available");
|
|
||||||
}
|
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
map.adminCenters.length !== other.adminCenters.length ||
|
map.adminCenters.length !== other.adminCenters.length ||
|
||||||
|
|
@ -230,6 +276,18 @@ try {
|
||||||
assert(JSON.stringify(againA.adminCenters.map((item) => [item.id, item.x, item.y, item.name])) === JSON.stringify(againB.adminCenters.map((item) => [item.id, item.x, item.y, item.name])), "municipal centers are deterministic for the same seed");
|
assert(JSON.stringify(againA.adminCenters.map((item) => [item.id, item.x, item.y, item.name])) === JSON.stringify(againB.adminCenters.map((item) => [item.id, item.x, item.y, item.name])), "municipal centers are deterministic for the same seed");
|
||||||
assert(JSON.stringify([...againA.adminId]) === JSON.stringify([...againB.adminId]), "municipal adminId snapping is deterministic for the same seed");
|
assert(JSON.stringify([...againA.adminId]) === JSON.stringify([...againB.adminId]), "municipal adminId snapping is deterministic for the same seed");
|
||||||
|
|
||||||
|
CUSTOM_NAMES["city-0"] = "C1";
|
||||||
|
const customSameA = generateMap(321);
|
||||||
|
const customSameB = generateMap(321);
|
||||||
|
const sameTargetA = customSameA.modernCities.find((item) => item.id === "city-0");
|
||||||
|
const sameTargetB = customSameB.modernCities.find((item) => item.id === "city-0");
|
||||||
|
const customSeedMaps = [301, 302, 303, 304, 305, 306, 307, 308].map((seedValue) => generateMap(seedValue));
|
||||||
|
const customTargets = customSeedMaps.map((seeded) => seeded.modernCities.find((item) => item.id === "city-0")).filter(Boolean);
|
||||||
|
const customHits = customTargets.filter((item) => item.name === "C1").length;
|
||||||
|
assert(sameTargetA?.name === sameTargetB?.name, "custom-name probability is deterministic for the same seed");
|
||||||
|
assert(!FORCED_NAMES["city-0"] && customTargets.length > 0 && customHits < customTargets.length, "CUSTOM_NAMES do not force every seed");
|
||||||
|
delete CUSTOM_NAMES["city-0"];
|
||||||
|
|
||||||
for (const seed of [101, 2026, 54321]) {
|
for (const seed of [101, 2026, 54321]) {
|
||||||
const seeded = generateMap(seed);
|
const seeded = generateMap(seed);
|
||||||
const metrics = adminBoundaryMetrics(seeded);
|
const metrics = adminBoundaryMetrics(seeded);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue