From 2b84c34f41d583ae234bfe7181dd55c96c9008b9 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Wed, 14 Oct 2026 08:32:36 +0000 Subject: [PATCH 1/6] Add region-key manifest (build:region-keys) + public/region-keys.json (v0.19 P1 of #16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deterministic -<4char> key per sub-region: name via NFKD strip + fold table (ə ø ð đ œ ł) + a-z0-9 collapse; 4-char from FNV-1a of the stable feature id, salted on within-slug collision so all 1,796 keys are unique. Builtins only (no @turf). Idempotent (byte-identical on re-run). --- package.json | 1 + public/region-keys.json | 1798 +++++++++++++++++++++++++++++++++ scripts/build-region-keys.mjs | 158 +++ 3 files changed, 1957 insertions(+) create mode 100644 public/region-keys.json create mode 100644 scripts/build-region-keys.mjs diff --git a/package.json b/package.json index 25bbe35..7589e5f 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "cleanup:regions": "node scripts/cleanup-regions.mjs", "smooth:regions": "node scripts/smooth-regions.mjs", "fill:regions": "node scripts/fill-gaps.mjs", + "build:region-keys": "node scripts/build-region-keys.mjs", "typecheck": "tsc --noEmit", "lint": "eslint .", "e2e": "playwright test" diff --git a/public/region-keys.json b/public/region-keys.json new file mode 100644 index 0000000..aa388ff --- /dev/null +++ b/public/region-keys.json @@ -0,0 +1,1798 @@ +{ + "AF-BAL": "balkh-ckka", + "AF-BAM": "bamyan-mjkz", + "AF-BDG": "badghis-werz", + "AF-BDS": "badakhshan-qjrt", + "AF-BGL": "baghlan-wqdr", + "AF-FRA": "farah-dajq", + "AF-FYB": "faryab-ztoh", + "AF-GHA": "ghazni-nnab", + "AF-GHO": "ghor-bxar", + "AF-HEL": "hilmand-gmdm", + "AF-HER": "hirat-yddg", + "AF-JOW": "jawzjan-ibdk", + "AF-KAB": "kabul-agcw", + "AF-KAN": "kandahar-cacs", + "AF-NIM": "nimroz-eoll", + "AF-NUR": "nuristan-ofkf", + "AF-PIA": "paktika-wbrf", + "AF-PKA": "paktya-opvd", + "AF-SAM": "samangan-iybb", + "AF-SAR": "sari-pul-eubi", + "AF-TAK": "takhar-tbah", + "AF-URU": "uruzgan-ebhn", + "AF-URU-2": "uruzgan-unxj", + "AF-WAR": "wardak-wpjg", + "AF-ZAB": "zabul-ftpc", + "AO-BGO": "bengo-awxb", + "AO-BGU": "benguela-ifxx", + "AO-BIE": "bie-usov", + "AO-CCU": "cuando-cubango-sifc", + "AO-CNN": "cunene-etup", + "AO-CNO": "cuanza-norte-avuq", + "AO-CUS": "cuanza-sul-uays", + "AO-HUA": "huambo-czeb", + "AO-HUI": "huila-ylej", + "CO-HUI": "huila-appk", + "AO-LNO": "lunda-norte-kkid", + "AO-LSU": "lunda-sul-iyot", + "AO-MAL": "malanje-quaz", + "AO-MOX": "moxico-ievb", + "AO-NAM": "namibe-mitv", + "AO-UIG": "uige-alyd", + "AO-ZAI": "zaire-ikgg", + "AL-03": "elbasan-bjpz", + "AL-10": "shkoder-gmnr", + "AE-AZ": "abu-dhabi-mywn", + "AE-DU": "dubay-kskv", + "AR-A": "salta-uunu", + "AR-B": "buenos-aires-mwnv", + "AR-D": "san-luis-amnp", + "AR-E": "entre-rios-wonq", + "AR-F": "la-rioja-opnr", + "ES-LO": "la-rioja-anut", + "AR-G": "santiago-del-estero-grns", + "AR-H": "chaco-yfnb", + "AR-J": "san-juan-iind", + "DO-22": "san-juan-iczh", + "AR-K": "catamarca-ekne", + "AR-L": "la-pampa-aznx", + "AR-M": "mendoza-sany", + "AR-N": "misiones-ocnz", + "AR-P": "formosa-ctnd", + "AR-Q": "neuquen-yvne", + "AR-R": "rio-negro-qxnf", + "AR-S": "santa-fe-iyng", + "AR-T": "tucuman-ennz", + "AR-U": "chubut-apna", + "AR-V": "tierra-del-fuego-sqnb", + "AR-W": "corrientes-ksnc", + "AR-X": "cordoba-cgnl", + "CO-COR": "cordoba-srwy", + "ES-CO": "cordoba-mxza", + "AR-Y": "jujuy-yinm", + "AR-Z": "santa-cruz-mjnn", + "BO-S": "santa-cruz-kptg", + "AM-GR": "gegharkunik-qezd", + "AM-LO": "lori-aqmn", + "AQ-X01~": "antarctica-mvxe", + "AQ-X02~": "antarctica-ccbf", + "TF-X01~": "archipel-des-kerguelen-fnmd", + "TF-X1~": "iles-saint-paul-et-nouvelle-amsterdam-smio", + "TF-X02~": "archipel-des-crozet-oypb", + "TF-X03~": "iles-eparses-de-l-ocean-indien-zmrd", + "AU-NSW": "new-south-wales-guah", + "AU-NSW-2": "lord-howe-island-krnq", + "AU-NT": "northern-territory-ylns", + "AU-QLD": "queensland-opqa", + "AU-SA": "south-australia-cutu", + "AU-TAS": "tasmania-kptu", + "AU-VIC": "victoria-svnw", + "AU-WA": "western-australia-sxjw", + "AU-X03~": "macquarie-island-yijx", + "AT-2": "karnten-cypo", + "AT-3": "niederosterreich-uapp", + "AT-4": "oberosterreich-mbpq", + "AT-6": "steiermark-afps", + "AT-7": "tirol-sgpt", + "AZ-AGC": "agcabadi-yazh", + "AZ-KAL": "kalbacar-zzbg", + "AZ-QBA": "quba-nxpp", + "BI-BR": "bururi-uaba", + "BI-RY": "ruyigi-kdjc", + "BE-VOV": "east-flanders-cicp", + "BE-WLX": "luxembourg-krjs", + "BJ-AK": "atakora-oobn", + "BJ-AL": "alibori-smbm", + "BJ-BO": "borgou-yqft", + "BJ-CO": "collines-qzhd", + "BJ-DO": "donga-geqd", + "BJ-ZO": "zou-qmpu", + "BF-BAZ": "bazega-ufdx", + "BF-BLG": "boulgou-oyva", + "BF-COM": "komoe-siqa", + "BF-GNA": "gnagna-qjxm", + "BF-GOU": "gourma-okzn", + "BF-HOU": "houet-yrsy", + "BF-KEN": "kenedougou-yxcn", + "BF-KMP": "kompienga-wuin", + "BF-MOU": "mou-houn-azqo", + "BF-OUD": "oudalan-ooyt", + "BF-PON": "poni-upoo", + "BF-SIS": "sissili-amog", + "BF-SMT": "sanmatenga-alfl", + "BF-SNG": "sanguie-skxs", + "BF-SOM": "soum-whaq", + "BF-TAP": "tapoa-gpat", + "BF-TUI": "tuy-updk", + "BF-YAT": "yatenga-ajue", + "BD-B": "chittagong-bxry", + "BD-C": "dhaka-gvrx", + "BD-D": "khulna-purw", + "BD-E": "rajshahi-usrv", + "BD-F": "rangpur-drru", + "BD-G": "sylhet-iprt", + "BG-02": "burgas-nowy", + "BG-04": "veliko-tarnovo-zews", + "BG-06": "vratsa-lhwu", + "BG-16": "plovdiv-euus", + "BG-23": "sofia-corh", + "MG-M-2": "sofia-kmae", + "BG-24": "stara-zagora-hmrg", + "BG-27": "shumen-ahrd", + "BS-AK": "acklins-qehg", + "BS-BI": "bimini-sdjg", + "BS-BY": "berry-islands-wejq", + "BS-CI": "cat-island-kqms", + "BS-EX": "exuma-raxf", + "BS-IN": "inagua-ttnr", + "BS-MG": "mayaguana-kkei", + "BS-NE": "north-eleuthera-nwgq", + "BS-NS": "north-andros-dagc", + "BS-RI": "ragged-island-fprn", + "BS-SS": "san-salvador-kzur", + "BA-BIH-3": "west-bosnia-ayyu", + "BA-BIH-6": "zenica-doboj-kqyp", + "BA-X02~": "banja-luka-qguu", + "BY-BR": "brest-uhpa", + "BY-HO": "gomel-evel", + "BY-HR": "grodno-sbey", + "BY-MA": "mogilev-srqt", + "BY-MI": "minsk-sirf", + "BY-VI": "vitebsk-anmn", + "BZ-CY": "cayo-ejvk", + "BO-B": "el-beni-aptp", + "BO-C": "cochabamba-oqtq", + "BO-H": "chuquisaca-mztf", + "BO-L": "la-paz-kftj", + "BO-N": "pando-uitl", + "BO-O": "oruro-qktm", + "BO-P": "potosi-iltd", + "BO-T": "tarija-grth", + "BR-AC": "acre-ekzw", + "BR-AL": "alagoas-ivzd", + "BR-AM": "amazonas-qtzc", + "CO-AMA": "amazonas-isdt", + "PE-AMA": "amazonas-nthu", + "VE-Z": "amazonas-qfqs", + "BR-AP": "amapa-gozp", + "BR-BA": "bahia-gncm", + "BR-CE": "ceara-wceu", + "BR-ES": "espirito-santo-yopi", + "BR-GO": "goias-suuo", + "BR-MA": "maranhao-ojve", + "BR-MG": "minas-gerais-ymvg", + "BR-MS": "mato-grosso-do-sul-wfvs", + "BR-MT": "mato-grosso-aqvz", + "BR-PA": "para-mmiu", + "SR-PR": "para-scsi", + "BR-PB": "paraiba-aniv", + "BR-PE": "pernambuco-kfiq", + "BR-PI": "piaui-icjc", + "BR-PR": "parana-arjl", + "BR-RJ": "rio-de-janeiro-mhoj", + "BR-RN": "rio-grande-do-norte-knon", + "BR-RO": "rondonia-gpoo", + "BR-RR": "roraima-itoh", + "BR-RS": "rio-grande-do-sul-auoi", + "BR-SC": "santa-catarina-ourg", + "BR-SE": "sergipe-glra", + "BR-SP": "sao-paulo-eurp", + "BR-TO": "tocantins-kczu", + "BN-BE": "belait-asmw", + "BT-15": "thimphu-agoq", + "BT-24": "wangdi-phodrang-ipqp", + "BW-CE": "central-ypdw", + "KE-200": "central-sqzo", + "PG-CPM": "central-cczg", + "ZM-02": "central-svtv", + "BW-GH": "ghanzi-oamb", + "BW-KG": "kgalagadi-gaxy", + "BW-KW": "kweneng-kxwi", + "BW-NW": "north-west-qhdz", + "ZA-NW": "north-west-ispe", + "BW-SO": "southern-ctpk", + "SL-S": "southern-ncas", + "ZM-07": "southern-orts", + "CF-AC": "ouham-mnot", + "CF-BB": "bamingui-bangoran-ilrg", + "CF-BK": "basse-kotto-earz", + "CF-HK": "haute-kotto-ymrh", + "CF-HM": "haut-mbomou-qdrb", + "CF-HS": "mambere-kadei-carz", + "CF-KB": "nana-grebizi-mcza", + "CF-KG": "kemo-uuzv", + "CF-LB": "lobaye-uriq", + "CF-MB": "mbomou-ydky", + "CF-MP": "ombella-m-poko-iklq", + "CF-NM": "nana-mambere-glnv", + "CF-OP": "ouham-pende-obpq", + "CF-SE": "sangha-mbaere-mqfz", + "CF-UK": "ouaka-mmrt", + "CF-VK": "vakaga-ahth", + "CA-AB": "alberta-zopq", + "CA-BC": "british-columbia-vmsh", + "CA-MB": "manitoba-thmy", + "CA-NB": "new-brunswick-wcni", + "CA-NL": "newfoundland-and-labrador-kmno", + "CA-NS": "nova-scotia-ndnz", + "CA-NT": "northwest-territories-gynw", + "CA-NU": "nunavut-banx", + "CA-ON": "ontario-bzqu", + "CA-QC": "quebec-yegk", + "CA-SK": "saskatchewan-cmli", + "CA-YT": "yukon-hsje", + "CH-BE": "bern-pmiz", + "CH-ZH": "zurich-kdpk", + "CL-AI": "aisen-del-general-carlos-ibanez-del-campo-mrpn", + "CL-AN": "antofagasta-czps", + "CL-AP": "arica-y-parinacota-mbpk", + "CL-AR": "la-araucania-aepm", + "CL-AT": "atacama-khpo", + "CL-BI": "bio-bio-kfrw", + "CL-CO": "coquimbo-qtuj", + "CL-LI": "libertador-general-bernardo-o-higgins-ghiu", + "CL-LL": "los-lagos-mmix", + "CL-LR": "los-rios-gxix", + "EC-R": "los-rios-mouz", + "CL-MA": "magallanes-y-antartica-chilena-eqmb", + "CL-ML": "maule-kvme", + "CL-NB": "nuble-wmop", + "CL-RM": "region-metropolitana-de-santiago-qwdi", + "CL-TA": "tarapaca-mrpq", + "CL-VS": "valparaiso-mfuk", + "CN-AH": "anhui-sanz", + "CN-BJ": "beijing-qsqi", + "CN-CQ": "chongqing-mcsc", + "CN-FJ": "fujian-gmhk", + "CN-GD": "guangdong-eajt", + "CN-GS": "gansu-exjm", + "CN-GX": "guangxi-cgjb", + "CN-GZ": "guizhou-qjjd", + "CN-HA": "henan-qdfb", + "CN-HB": "hubei-eefc", + "CN-HE": "hebei-owfx", + "CN-HI": "hainan-mpfj", + "CN-HL": "heilongjiang-shfe", + "CN-HN": "hunan-gkfg", + "CN-JL": "jilin-akky", + "CN-JS": "jiangsu-kpkl", + "CN-JX": "jiangxi-geke", + "CN-LN": "liaoning-wswa", + "CN-NM": "inner-mongol-ioap", + "CN-NX": "ningxia-kwbm", + "CN-QH": "qinghai-cnbf", + "CN-SC": "sichuan-eufy", + "CN-SD": "shandong-ajfr", + "CN-SN": "shaanxi-kyfb", + "CN-SX": "shanxi-ubft", + "CN-TJ": "tianjin-krnw", + "CN-X01~": "paracel-islands-yftc", + "CN-XJ": "xinjiang-oasq", + "CN-XZ": "xizang-kzsk", + "CN-YN": "yunnan-mbuv", + "CN-ZJ": "zhejiang-amww", + "CI-01": "lagunes-igqk", + "CI-02": "haut-sassandra-aiql", + "CI-03": "savanes-sjqm", + "CI-04": "vallee-du-bandama-oyqf", + "CI-06": "dix-huit-montagnes-gcqh", + "CI-08": "zanzan-qsqb", + "CI-09": "bas-sassandra-muqc", + "CI-10": "denguele-ycsk", + "CI-11": "n-zi-comoe-uesl", + "CI-14": "worodougou-wiso", + "CI-15": "sud-bandama-sksp", + "CI-16": "agneby-glsq", + "CI-19": "cavally-qqst", + "CM-AD": "adamaoua-kqsc", + "CM-CE": "centre-aexz", + "TG-C": "centre-kurt", + "CM-EN": "extreme-nord-oubi", + "CM-ES": "est-umbd", + "CM-LT": "littoral-oyts", + "CM-NO": "nord-gkzb", + "NC-X03~": "nord-wofd", + "CM-NW": "nord-ouest-yvzz", + "CM-OU": "ouest-kdbx", + "HT-OU": "ouest-wjof", + "CM-SU": "sud-mukj", + "CM-SW": "sud-ouest-yxkl", + "CD-BC": "bas-congo-yhcf", + "CD-BN": "bandundu-gzcq", + "CD-EQ": "equateur-mkpe", + "CD-KA": "katanga-igym", + "CD-KE": "kasai-oriental-gzyi", + "CD-KN": "kinshasa-city-aoyb", + "CD-KW": "kasai-occidental-udyu", + "CD-MA": "maniema-eoks", + "CD-NK": "nord-kivu-gxmp", + "CD-OR": "orientale-scpz", + "CD-SK": "sud-kivu-katu", + "CG-2": "lekoumou-inqi", + "CG-5": "kouilou-qfqd", + "CG-7": "likouala-aiqf", + "CG-8": "cuvette-wtpy", + "CG-9": "niari-svpz", + "CG-11": "bouenza-elwk", + "CG-12": "pool-umwl", + "CG-13": "sangha-oowm", + "CG-14": "plateaux-kqwn", + "TG-P": "plateaux-myrm", + "CG-15": "cuvette-ouest-crwo", + "CO-ANT": "antioquia-kqan", + "CO-ARA": "arauca-yxsw", + "CO-BOL": "bolivar-shkq", + "VE-F": "bolivar-oyqe", + "CO-BOY": "boyaca-uekx", + "CO-CAL": "caldas-wkha", + "CO-CAQ": "caqueta-qfhx", + "CO-CAS": "casanare-aihz", + "CO-CAU": "cauca-szht", + "CO-CES": "cesar-qcua", + "CO-CHO": "choco-qaew", + "CO-CUN-2": "cundinamarca-ieom", + "CO-GUA": "guainia-utmi", + "CO-GUV": "guaviare-spmp", + "CO-LAG": "la-guajira-utww", + "CO-MAG": "magdalena-wrsg", + "CO-MET": "meta-ymbx", + "CO-NAR": "narino-osue", + "CO-NSA": "norte-de-santander-svgs", + "CO-PUT": "putumayo-ksux", + "CO-SAN": "santander-mmat", + "CO-SAP": "san-andres-y-providencia-sbat", + "CO-SUC": "sucre-glea", + "VE-R": "sucre-qsqk", + "CO-TOL": "tolima-gone", + "CO-VAC": "valle-del-cauca-urfx", + "CO-VAU": "vaupes-ihfh", + "CO-VID": "vichada-oozm", + "CO-X01~": "colombia-weor", + "CR-A": "alajuela-vwmk", + "CR-G": "guanacaste-ffmq", + "CR-L": "limon-ynmv", + "CR-P": "puntarenas-evmt", + "CU-01": "pinar-del-rio-ejbc", + "CU-04": "matanzas-kobf", + "CU-05": "villa-clara-cpbg", + "CU-07": "sancti-spiritus-msbi", + "CU-09": "camaguey-ewbu", + "CU-12": "granma-yeei", + "CU-13": "santiago-de-cuba-qfej", + "-99-X04~": "northern-cyprus-mvnb", + "CY-01": "nicosia-gzli", + "CZ-MO": "moravskoslezsky-sbcc", + "CZ-PL": "plzensky-ysxk", + "CZ-ST": "stredocesky-ihft", + "CZ-VY": "vysocina-kamr", + "DE-BB": "brandenburg-yrjj", + "DE-BW": "baden-wurttemberg-oyje", + "DE-BY": "bayern-aiju", + "DE-HE": "hessen-bqlm", + "DE-MV": "mecklenburg-vorpommern-pqcp", + "DE-NI": "niedersachsen-vxfc", + "DE-NW": "nordrhein-westfalen-hafe", + "DE-RP": "rheinland-pfalz-cfwr", + "DE-SH": "schleswig-holstein-gmaj", + "DE-SN": "sachsen-oiah", + "DE-ST": "sachsen-anhalt-arad", + "DE-TH": "thuringen-mxiz", + "DJ-DI": "dikhil-zrvh", + "DJ-TA": "tadjourah-jsgf", + "DK-82": "midtjylland-gjbs", + "DK-83": "syddanmark-ykbt", + "DO-11": "la-altagracia-iexf", + "DZ-01": "adrar-ctwy", + "MR-07": "adrar-dffe", + "DZ-03": "laghouat-mwwa", + "DZ-05": "batna-enwu", + "DZ-07": "biskra-oqww", + "DZ-08": "bechar-kfwp", + "DZ-11": "tamanghasset-mozx", + "DZ-12": "tebessa-iqzy", + "DZ-14": "tiaret-stza", + "DZ-17": "djelfa-uxzd", + "DZ-19": "setif-mbzf", + "DZ-21": "skikda-odba", + "DZ-22": "sidi-bel-abbes-kfbb", + "DZ-26": "medea-elbf", + "DZ-28": "m-sila-sobh", + "DZ-30": "ouargla-wxdg", + "DZ-32": "el-bayadh-gadi", + "DZ-33": "illizi-ccdj", + "DZ-37": "tindouf-ewdf", + "DZ-39": "el-oued-oldp", + "DZ-40": "khenchela-yomx", + "DZ-41": "souk-ahras-uqmy", + "DZ-45": "naama-swmc", + "DZ-47": "ghardaia-czme", + "DZ-48": "relizane-ybmf", + "EC-A": "azuay-kruk", + "EC-D": "orellana-cmuh", + "EC-E": "esmeraldas-ikug", + "EC-G": "guayas-whue", + "EC-L": "loja-czuz", + "EC-M": "manabi-myuy", + "EC-P": "pichincha-wrub", + "EC-S": "morona-santiago-unuy", + "EC-T": "tungurahua-ylux", + "EC-U": "sucumbios-ejuw", + "EC-W": "galapagos-sguu", + "EC-Y": "pastaza-gdus", + "EC-Z": "zamora-chinchipe-qcur", + "EG-ASN": "aswan-sxuz", + "EG-BA": "al-bahr-al-ahmar-oaom", + "EG-BH": "al-buhayrah-ilnf", + "EG-FYM": "al-fayyum-wdgf", + "EG-GZ": "al-jizah-abuz", + "EG-JS": "janub-sina-mrqx", + "EG-MN": "al-minya-mjfl", + "EG-MT": "matruh-cafp", + "EG-SHR": "ash-sharqiyah-wcnm", + "SA-04": "ash-sharqiyah-qlqi", + "EG-SIN": "shamal-sina-uhqn", + "EG-WAD": "al-wadi-at-jadid-spvg", + "ER-DK": "debubawi-keyih-bahri-jrxo", + "ER-DU": "debub-rtxk", + "ER-GB": "gash-barka-coeg", + "ER-MA": "maekel-litf", + "ER-SK": "semenawi-keyih-bahri-comw", + "ES-AB": "albacete-attf", + "ES-B": "barcelona-iybx", + "ES-BA": "badajoz-ciwt", + "ES-BU": "burgos-asxn", + "ES-CA": "cadiz-aazc", + "ES-CC": "caceres-kdze", + "ES-CR": "ciudad-real-adzn", + "ES-CU": "cuenca-chzq", + "ES-GC": "las-palmas-ujic", + "ES-GR": "granada-yhir", + "ES-GU": "guadalajara-izim", + "ES-H": "huelva-qhbd", + "ES-HU": "huesca-ihkb", + "ES-J": "jaen-ekbf", + "ES-L": "lerida-onbh", + "ES-LE": "leon-uyuj", + "NI-LE": "leon-gvhg", + "ES-LU": "lugo-qbvz", + "ES-M": "madrid-kpbi", + "ES-ML": "melilla-uuxv", + "ES-MU": "murcia-qjxo", + "ES-O": "asturias-usbk", + "ES-OR": "orense-aqch", + "ES-P": "palencia-qubl", + "ES-PM": "baleares-ayfx", + "ES-SA": "salamanca-qrmw", + "ES-SE": "sevilla-oxma", + "ES-SO": "soria-comu", + "ES-TE": "teruel-ofof", + "ES-TF": "santa-cruz-de-tenerife-ggog", + "ES-TO": "toledo-yvoz", + "ES-V": "valencia-ydbr", + "ES-Z": "zaragoza-ajbv", + "ES-ZA": "zamora-qhdj", + "EE-37": "harju-qnwd", + "EE-44": "ida-viru-ictd", + "EE-78": "tartu-aymq", + "ET-AF": "afar-cnlj", + "ET-AM": "amhara-kylq", + "ET-BE": "benshangul-gumaz-iznr", + "ET-GA": "gambela-peoples-sggc", + "ET-OR": "oromiya-otzl", + "ET-SN": "southern-nations-nationalities-and-peoples-ohez", + "ET-SO": "somali-eiea", + "ET-TI": "tigray-ucnt", + "FI-03": "southern-ostrobothnia-ihkk", + "FI-04": "southern-savonia-ewkd", + "FI-05": "kainuu-ayke", + "FI-08": "central-finland-cpkp", + "FI-10": "lapland-gcno", + "FI-11": "pirkanmaa-cenp", + "FI-13": "north-karelia-mhnr", + "FI-14": "northern-ostrobothnia-eins", + "FI-15": "northern-savonia-aknt", + "FI-16": "paijat-hame-olnu", + "FI-18": "uusimaa-gpng", + "FJ-R": "rotuma-kdui", + "FJ-W": "western-alun", + "GH-WP": "western-qwvr", + "PG-WPD": "western-adsp", + "RW-04": "western-mygs", + "SB-CH": "western-afoi", + "ZM-01": "western-cutu", + "FK-X01~": "falkland-islands-ubzb", + "FR-01": "ain-lqtt", + "FR-02": "aisne-gstu", + "FR-2B": "haute-corse-gmyy", + "FR-03": "allier-butv", + "FR-04": "alpes-de-haute-provence-svtw", + "FR-07": "ardeche-zatz", + "FR-11": "aude-pmva", + "FR-12": "aveyron-kovb", + "FR-15": "cantal-rgvw", + "FR-17": "charente-maritime-djvy", + "FR-18": "cher-yyvr", + "FR-21": "cote-d-or-zhyz", + "FR-22": "cotes-d-armor-qiya", + "FR-23": "creuse-lkyb", + "FR-25": "doubs-xnyd", + "FR-27": "eure-jqyf", + "FR-30": "gard-guab", + "FR-31": "haute-garonne-bwac", + "FR-32": "gers-wyad", + "FR-33": "gironde-nzae", + "FR-38": "isere-ghaj", + "FR-40": "landes-kudi", + "FR-41": "loir-et-cher-fwdj", + "FR-44": "loire-atlantique-mode", + "FR-47": "lot-et-garonne-psdh", + "FR-51": "marne-hpgs", + "FR-57": "moselle-rygy", + "FR-61": "orne-jeiv", + "FR-62": "pas-de-calais-egiw", + "FR-72": "sarthe-ebkd", + "FR-73": "savoie-zdke", + "FR-77": "seien-et-marne-bxka", + "FR-80": "somme-sgzc", + "FR-85": "vendee-lozh", + "FR-86": "vienne-cpzi", + "FR-88": "vosges-oszk", + "FR-89": "yonne-juzl", + "FR-GF": "guyane-francaise-gmcw", + "FR-GP": "guadeloupe-wqci", + "FR-MQ": "martinique-tuau", + "FR-RE": "la-reunion-voeo", + "FR-YT": "mayotte-gthm", + "GA-1": "estuaire-qawm", + "GA-2": "haut-ogooue-kcwn", + "GA-3": "moyen-ogooue-eewo", + "GA-4": "ngounie-atwh", + "GA-5": "nyanga-suwi", + "GA-6": "ogooue-ivindo-mwwj", + "GA-7": "ogooue-lolo-cxwk", + "GA-8": "ogooue-maritime-ulwt", + "GA-9": "wouleu-ntem-qnwu", + "GB-ABD": "aberdeenshire-kzrn", + "GB-CAM": "cambridgeshire-fwds", + "GB-CMA": "cumbria-oohy", + "GB-DEV": "devon-slaz", + "GB-DGY": "dumfries-and-galloway-sres", + "GB-HLD": "highland-saxg", + "GB-IOS": "isles-of-scilly-oznv", + "GB-LIN": "lincolnshire-odkr", + "GB-NYK": "north-yorkshire-qalk", + "GB-OMH": "omagh-gocy", + "GB-OXF": "oxfordshire-qxcr", + "GB-POW": "powys-zcfq", + "GB-SFK": "suffolk-peqe", + "GB-SHR": "shropshire-mhvl", + "GB-SOM": "somerset-drhz", + "GB-ZET": "shetland-islands-ypae", + "GE-IM": "imereti-vlst", + "GE-KA": "kakheti-lswd", + "GE-MM": "mtskheta-mtianeti-dbcb", + "GE-SZ": "samegrelo-zemo-svaneti-wjlp", + "GH-AH": "ashanti-abux", + "GH-BA": "brong-ahafo-qqwr", + "GH-EP": "eastern-irer", + "KE-400": "eastern-qapp", + "RW-02": "eastern-avgq", + "SL-E": "eastern-dtaw", + "ZM-03": "eastern-mxtw", + "GH-NP": "northern-ibne", + "PG-NPP": "northern-swud", + "SD-NO": "northern-qinm", + "SL-N": "northern-yiap", + "ZM-05": "northern-eotq", + "GH-TV": "volta-ihni", + "GH-UW": "upper-west-avqy", + "GN-BE": "beyla-vyfv", + "GN-BK": "boke-jifl", + "GN-DI": "dinguiraye-vqqn", + "GN-FA": "faranah-lbvx", + "GN-GA": "gaoual-jpxg", + "GN-K": "kankan-kmei", + "GN-KD": "kindia-gbod", + "GN-KO": "kouroussa-jsoo", + "GN-KS": "kissidougou-hloa", + "GN-MD": "mandiana-alzh", + "GN-ML": "mali-ayzp", + "GN-MM": "mamou-rzzq", + "GN-PI": "pita-rhov", + "GN-SI": "siguiri-juvq", + "GN-TE": "telimele-jadt", + "GN-YO": "yomou-dhxs", + "GM-M": "central-river-qqfp", + "GW-BA": "bafata-krfg", + "GW-OI": "oio-eyrz", + "GQ-AN": "annobon-eedl", + "GQ-BS": "bioko-sur-yvgu", + "GQ-CS": "centro-sur-gwiq", + "GR-A": "anatoliki-makedonia-kai-thraki-abyd", + "GR-B": "kentriki-makedonia-wdye", + "GR-C": "dytiki-makedonia-keyf", + "GR-E": "thessalia-yhyh", + "GR-G": "dytiki-ellada-mlyj", + "GR-H": "sterea-ellada-inyu", + "GR-J": "peloponnisos-wqyw", + "GL-KU": "kommune-kujalleq-nbjz", + "GL-QA": "qaasuitsup-kommunia-kawz", + "GL-QE": "qeqqata-kommunia-mhwd", + "GL-SM": "kommuneqarfik-sermersooq-eqcb", + "GL-UO": "nationalparken-menh", + "GT-AV": "alta-verapaz-myed", + "GT-BV": "baja-verapaz-ongg", + "GT-HU": "huehuetenango-mjgd", + "GT-JU": "jutiapa-wdml", + "GT-PE": "peten-kent", + "GT-SM": "san-marcos-cvvw", + "GY-BA": "barima-waini-wifs", + "GY-MA": "mahaica-berbice-igzl", + "GY-PM": "pomeroon-supenaam-iooq", + "GY-PT": "potaro-siparuni-qaor", + "GY-UD": "upper-demerara-berbice-ynhw", + "GY-UT": "upper-takutu-upper-essequibo-cohg", + "HN-CM": "comayagua-gvxb", + "HN-EP": "el-paraiso-gxia", + "HN-GD": "gracias-a-dios-kumu", + "HN-OL": "olancho-qasq", + "HN-SB": "santa-barbara-qgiw", + "HN-YO": "yoro-chgi", + "HR-01": "zagrebacka-cijs", + "HR-09": "licko-senjska-cvjk", + "HR-13": "zadarska-mglb", + "HR-14": "osjecko-baranjska-ivlu", + "HT-AR": "l-artibonite-egma", + "HU-BE": "bekes-itph", + "HU-BK": "bacs-kiskun-udpx", + "HU-HB": "hajdu-bihar-edea", + "HU-PE": "pest-akxn", + "HU-SO": "somogy-ymes", + "HU-VE": "veszprem-czmj", + "ID-AC": "aceh-ocsz", + "ID-BB": "bangka-belitung-ixwo", + "ID-BE": "bengkulu-qwwn", + "ID-BT": "banten-stwc", + "ID-GO": "gorontalo-gihp", + "ID-JA": "jambi-sxbt", + "ID-JB": "jawa-barat-msbq", + "ID-JI": "jawa-timur-skbb", + "ID-JT": "jawa-tengah-eqby", + "ID-KB": "kalimantan-barat-qpei", + "ID-KI": "kalimantan-timur-kxex", + "ID-KS": "kalimantan-selatan-smex", + "ID-KT": "kalimantan-tengah-area", + "ID-LA": "lampung-yjht", + "ID-MA": "maluku-qzjd", + "ID-MU": "maluku-utara-osjp", + "ID-NB": "nusa-tenggara-barat-ywlq", + "ID-NT": "nusa-tenggara-timur-iflm", + "ID-PA": "papua-rydx", + "ID-PB": "papua-barat-mady", + "ID-RI": "riau-zdhp", + "ID-SA": "sulawesi-utara-ojkh", + "ID-SB": "sumatera-barat-vokk", + "ID-SG": "sulawesi-tenggara-ctkn", + "ID-SN": "sulawesi-selatan-thkw", + "ID-SR": "sulawesi-barat-zpku", + "ID-SS": "sumatera-selatan-enkt", + "ID-ST": "sulawesi-tengah-lskw", + "ID-SU": "sumatera-utara-qqkv", + "IN-AN": "andaman-and-nicobar-stzm", + "IN-AP": "andhra-pradesh-amam", + "IN-AR": "arunachal-pradesh-mpao", + "IN-AS": "assam-dqap", + "IN-BR": "bihar-cbcp", + "IN-CT": "chhattisgarh-yiee", + "IN-GJ": "gujarat-sooi", + "IN-HP": "himachal-pradesh-ggdz", + "IN-HR": "haryana-wkdb", + "IN-JH": "jharkhand-qjit", + "IN-JK": "jammu-and-kashmir-vhis", + "IN-KA": "karnataka-rslx", + "IN-KL": "kerala-clls", + "IN-LA": "ladakh-ppmi", + "IN-MH": "maharashtra-qypg", + "IN-ML": "meghalaya-oepk", + "IN-MN": "manipur-ahpm", + "IN-MP": "madhya-pradesh-qlpo", + "IN-MZ": "mizoram-yapy", + "IN-NL": "nagaland-yvrv", + "IN-OR": "odisha-uxvk", + "IN-PB": "punjab-kjkb", + "PK-PB": "punjab-ghlp", + "IN-RJ": "rajasthan-sfpr", + "IN-TG": "telangana-mfvw", + "IN-TN": "tamil-nadu-sqvd", + "IN-TR": "tripura-ylvj", + "IN-UP": "uttar-pradesh-aaxe", + "IN-UT": "uttarakhand-ygxi", + "IN-WB": "west-bengal-isbk", + "IE-CO": "cork-qnps", + "IE-G": "galway-kjar", + "IE-RN": "roscommon-ytzq", + "IE-WX": "wexford-iasb", + "IR-01": "east-azarbaijan-qxil", + "IR-02": "west-azarbaijan-eyim", + "IR-03": "ardebil-aain", + "IR-04": "esfahan-wpig", + "IR-05": "ilam-oqih", + "IR-06": "bushehr-ksii", + "IR-07": "tehran-ytij", + "IR-08": "chahar-mahall-and-bakhtiari-uiis", + "IR-10": "khuzestan-yrkr", + "IR-11": "zanjan-qsks", + "IR-12": "semnan-mukt", + "IR-13": "sistan-and-baluchestan-avku", + "IR-14": "fars-wxkv", + "IR-15": "kerman-oykw", + "IR-16": "kordestan-gakx", + "IR-17": "kermanshah-ccky", + "IR-18": "kohgiluyeh-and-buyer-ahmad-yekj", + "IR-19": "gilan-ugkk", + "IR-20": "lorestan-ydnw", + "IR-21": "mazandaran-ufnx", + "IR-22": "markazi-qhny", + "IR-23": "hormozgan-einz", + "IR-24": "hamadan-akna", + "IR-25": "yazd-slnb", + "IR-26": "qom-knnc", + "IR-27": "golestan-cond", + "IR-28": "qazvin-crno", + "IR-29": "south-khorasan-usnp", + "IR-30": "razavi-khorasan-cdqd", + "IR-31": "north-khorasan-yfqe", + "IQ-AN": "al-anbar-yjqa", + "IQ-AR": "arbil-sbqc", + "IQ-BA": "al-basrah-lrsw", + "IQ-BB": "babil-emst", + "IQ-DI": "diyala-pzxe", + "IQ-DQ": "dhi-qar-pmxm", + "IQ-MA": "maysan-rzfn", + "IQ-MU": "al-muthannia-pffh", + "IQ-NA": "an-najaf-buiy", + "IQ-NI": "ninawa-bhig", + "IQ-SD": "sala-ad-din-simf", + "IQ-SU": "as-sulaymaniyah-fimw", + "IQ-TS": "at-ta-mim-xbob", + "IQ-WA": "wasit-vawa", + "IS-3": "vesturland-ajfn", + "IS-5": "nordurland-vestra-safh", + "IS-6": "nordurland-eystra-kbfi", + "IS-7": "austurland-cdfj", + "IS-8": "sudurland-ysfc", + "IL-D": "hadarom-uves", + "IT-AL": "alessandria-shfc", + "IT-AO": "aoste-ymff", + "IT-BA": "bari-gtie", + "SO-BR": "bari-sedg", + "IT-BL": "belluno-iojp", + "IT-BO": "bologna-otjs", + "IT-BS": "brescia-mzjw", + "IT-BZ": "bozen-ukjd", + "IT-CB": "campobasso-wmlo", + "IT-CS": "cosenza-oolz", + "IT-CT": "catania-kqla", + "IT-FI": "firenze-sksq", + "IT-GR": "grosseto-gtuw", + "IT-PA": "palermo-cprw", + "IT-PG": "perugia-ozrc", + "IT-PR": "parma-uqrn", + "IT-PZ": "potenza-yerf", + "IT-RM": "roma-sawi", + "IT-SS": "sassari-aeyz", + "IT-TO": "turin-yfaa", + "JM-13": "clarendon-muxv", + "JO-AM": "amman-avrc", + "JO-MA": "mafraq-invm", + "JO-MN": "ma-an-lrvp", + "JP-01": "hokkaido-ksfx", + "JP-03": "iwate-uvfz", + "JP-05": "akita-mmft", + "JP-06": "yamagata-gofu", + "JP-07": "fukushima-wpfv", + "JP-08": "ibaraki-odfe", + "JP-09": "tochigi-kfff", + "JP-15": "niigata-idhx", + "JP-19": "yamanashi-gwhj", + "JP-20": "nagano-kfji", + "JP-21": "gifu-agjj", + "JP-22": "shizuoka-ycjg", + "JP-24": "mie-iljm", + "JP-28": "hyogo-ksja", + "JP-33": "okayama-mymn", + "JP-34": "hiroshima-tqmi", + "JP-39": "kochi-aimd", + "JP-40": "fukuoka-ylvc", + "JP-44": "oita-asvg", + "JP-45": "miyazaki-stvh", + "JP-47": "okinawa-cwvj", + "KZ-AKM": "aqmola-etpx", + "KZ-AKT": "aqtobe-sgpw", + "KZ-ALA": "almaty-waxb", + "KZ-ATY": "atyrau-ixli", + "KZ-KAR": "qaraghandy-khxs", + "KZ-KUS": "qostanay-qwtp", + "KZ-KZY": "qyzylorda-emfx", + "KZ-MAN": "mangghystau-ooqu", + "KZ-PAV": "pavlodar-abzq", + "KZ-SEV": "north-kazakhstan-kdzy", + "KZ-VOS": "east-kazakhstan-ipgz", + "KZ-YUZ": "south-kazakhstan-qtgc", + "KZ-ZAP": "west-kazakhstan-aqxa", + "KZ-ZHA": "zhambyl-qhpv", + "KE-300": "coast-kjrv", + "KE-500": "north-eastern-ewzp", + "KE-600": "nyanza-wpnv", + "KE-700": "rift-valley-wlpc", + "KG-B": "batken-cbll", + "KG-C": "chuy-xdlm", + "KG-J": "jalal-abad-colt", + "KG-N": "naryn-eilp", + "KG-O": "osh-vjlq", + "KG-T": "talas-orlv", + "KG-Y": "ysyk-kol-hqmi", + "KH-2": "batdambang-jett", + "KH-5": "kampong-spoe-gatq", + "KH-6": "kampong-thum-lytp", + "KH-9": "kaoh-kong-astc", + "KH-10": "kracheh-suom", + "KH-11": "mondol-kiri-jvon", + "KH-13": "preah-vihear-xsol", + "KH-14": "prey-veng-qaoq", + "KH-15": "pouthisat-lcor", + "KH-16": "rotanokiri-exoo", + "KH-17": "siemreab-vyop", + "KH-19": "stoeng-treng-jiov", + "KR-41": "gyeonggi-asoh", + "KR-42": "gangwon-uuoi", + "KR-43": "north-chungcheong-kvoj", + "KR-46": "south-jeolla-wooe", + "KR-47": "north-gyeongsang-mpof", + "KR-48": "south-gyeongsang-edoo", + "KR-49": "jeju-afop", + "XK-X19~": "podujevo-ozes", + "KW-JA": "al-jahrah-cvgx", + "LA-AT": "attapu-cxyj", + "LA-BL": "bolikhamxai-csag", + "LA-CH": "champasak-agcn", + "LA-HO": "houaphan-lmpf", + "LA-KH": "khammouan-qmwz", + "LA-LM": "louang-namtha-fbyj", + "LA-LP": "louangphrabang-quyo", + "LA-OU": "oudomxai-trgc", + "LA-PH": "phongsali-okhy", + "LA-SV": "savannakhet-ulpr", + "LA-VI-2": "vientiane-qhaq", + "LA-XA": "xaignabouri-hdan", + "LA-XI": "xiangkhoang-hqaf", + "LB-BI": "beqaa-ryfc", + "LR-CM": "grand-cape-mount-acht", + "LR-GB": "grand-bassa-vzwu", + "LR-GG": "grand-gedeh-cewx", + "LR-NI": "nimba-xaov", + "LR-X1~": "gbapolu-iigq", + "LR-X2~": "river-gee-uqio", + "LY-AJ": "ajdabiya-ikqv", + "LY-BU": "al-butnan-gytn", + "LY-GD": "ghadamis-aagv", + "LY-GT": "ghat-ebgf", + "LY-JU": "al-jufrah-mrvm", + "LY-KF": "al-kufrah-ehbh", + "LY-MI": "misratah-cbhm", + "LY-MJ": "al-marj-ydhn", + "LY-MQ": "murzuq-cohu", + "LY-MZ": "mizdah-uchd", + "LY-NQ": "an-nuqat-al-khams-ehkx", + "LY-QB": "al-qubbah-wlez", + "LY-SB": "sabha-sukt", + "LY-SR": "surt-otkn", + "LY-WD": "wadi-al-hayaa-qrtp", + "LY-WS": "ash-shati-qbtm", + "LK-52": "ampara-oqcc", + "LK-71": "anuradhapura-jzhh", + "LK-82": "monaragala-wspd", + "LK-91": "ratnapura-llsf", + "LS-A": "maseru-quuq", + "LS-K": "thaba-tseka-wjua", + "LT-KL": "klaipedos-kooa", + "LT-KU": "kauno-keod", + "LT-PN": "panevezio-cpal", + "LU-D": "diekirch-kyaq", + "LV-033": "gulbene-gcky", + "LV-099": "tukums-qtmk", + "LV-REZ": "rezeknes-mmcq", + "LV-VEN": "ventspils-cuhk", + "MA-01": "tanger-tetouan-eqeu", + "MA-03": "taza-al-hoceima-taounate-otew", + "MA-04": "oriental-kiep", + "MA-05": "fes-boulemane-cjeq", + "MA-06": "meknes-tafilalet-yler", + "MA-09": "chaouia-ouardigha-edem", + "MA-10": "doukkala-abda-sghb", + "MA-11": "marrakech-tensift-al-haouz-weha", + "MA-12": "tadla-azilal-idhz", + "MA-13": "souss-massa-draa-mbhy", + "MA-14": "guelmim-es-semara-qzhx", + "MA-15": "laayoune-boujdour-sakia-el-hamra-cyhw", + "MA-16": "oued-el-dahab-gwhv", + "MD-CM": "cimislia-acda", + "MD-FA": "falesti-qcpf", + "MG-A": "analanjirofo-hgdp", + "MG-A-2": "atsinanana-uoeq", + "MG-A-3": "alaotra-mangoro-lper", + "MG-D": "diana-oydk", + "MG-D-2": "sava-atuj", + "MG-F": "vatovavy-fitovinany-abdm", + "MG-F-2": "atsimo-atsinanana-cjvi", + "MG-F-3": "amoron-i-mania-ulvj", + "MG-F-4": "ihorombe-mmvk", + "MG-F-5": "haute-matsiatra-iovl", + "MG-M": "boeny-fmdt", + "MG-M-3": "melaky-ynaf", + "MG-M-4": "betsiboka-upag", + "MG-T": "analamanga-szdu", + "MG-T-2": "bongolava-irjg", + "MG-T-3": "vakinankaratra-dtjh", + "MG-U": "anosy-jadv", + "MG-U-2": "androy-wzfq", + "MG-U-3": "atsimo-andrefana-qbfr", + "MG-U-4": "menabe-icfs", + "MX-BCN": "baja-california-rkil", + "MX-BCS": "baja-california-sur-uoie", + "MX-CAM": "campeche-qfna", + "MX-CHH": "chihuahua-imen", + "MX-CHP": "chiapas-izef", + "MX-COA": "coahuila-givi", + "MX-DUR": "durango-gnsc", + "MX-GRO": "guerrero-iroj", + "MX-GUA": "guanajuato-uvcd", + "MX-HID": "hidalgo-drfz", + "MX-JAL": "jalisco-kpbu", + "MX-MEX": "mexico-mpba", + "MX-X01~": "mexico-ewrz", + "MX-MIC": "michoacan-uekn", + "MX-NAY": "nayarit-chkm", + "MX-NLE": "nuevo-leon-rfly", + "MX-OAX": "oaxaca-vcjz", + "MX-PUE": "puebla-apfj", + "MX-QUE": "queretaro-mpre", + "MX-ROO": "quintana-roo-ieho", + "MX-SIN": "sinaloa-ahmy", + "MX-SLP": "san-luis-potosi-wfta", + "MX-SON": "sonora-otbs", + "MX-TAB": "tabasco-hhvb", + "MX-TAM": "tamaulipas-apvq", + "MX-VER": "veracruz-kuoh", + "MX-YUC": "yucatan-mpkz", + "MX-ZAC": "zacatecas-pbpt", + "MK-36": "kavadartsi-yiil", + "ML-1": "kayes-twwi", + "ML-2": "koulikoro-oywj", + "ML-3": "sikasso-fzwk", + "ML-4": "segou-aowd", + "ML-5": "mopti-vqwe", + "ML-6": "timbuktu-mrwf", + "ML-7": "gao-htwg", + "ML-8": "kidal-yhwp", + "MM-01": "sagaing-axrl", + "MM-02": "bago-uzrm", + "MM-03": "magway-karn", + "MM-04": "mandalay-gcro", + "MM-05": "tanintharyi-ydrp", + "MM-07": "ayeyarwady-mhrr", + "MM-11": "kachin-etts", + "MM-12": "kayah-uutt", + "MM-13": "kayin-owtu", + "MM-14": "chin-kltn", + "MM-15": "mon-cmto", + "MM-16": "rakhine-wotp", + "MM-17": "shan-qqtq", + "ME-12": "niksic-mcna", + "MN-039": "hentiy-kufs", + "MN-041": "hovsgol-munb", + "MN-043": "hovd-wxnd", + "MN-046": "uvs-ccng", + "MN-047": "tov-udnh", + "MN-049": "selenge-mhnj", + "MN-051": "suhbaatar-quqi", + "MN-053": "omnogovi-axqk", + "MN-055": "ovorhangay-onqe", + "MN-057": "dzavhan-yqqg", + "MN-059": "dundgovi-mgqq", + "MN-061": "dornod-qpsp", + "MN-063": "dornogovi-assr", + "MN-065": "govi-altay-ovst", + "MN-067": "bulgan-czsv", + "MN-069": "bayanhongor-udsh", + "MN-071": "bayan-olgiy-ucvu", + "MN-073": "arhangay-efvw", + "MZ-A": "niassa-eefk", + "MZ-B": "manica-wffl", + "MZ-G": "gaza-mnfq", + "MZ-I": "inhambane-aqfs", + "MZ-L": "maputo-gvfv", + "MZ-N": "nampula-qyfx", + "MZ-P": "cabo-delgado-mdft", + "MZ-Q": "zambezia-eefu", + "MZ-S": "sofala-ohfw", + "MZ-T": "tete-kjfx", + "MR-01": "hodh-ech-chargui-twfu", + "MR-02": "hodh-el-gharbi-oyfv", + "MR-03": "assaba-fzfa", + "MR-04": "gorgol-abfb", + "MR-05": "brakna-rcfc", + "MR-06": "trarza-mefd", + "MR-08": "dakhlet-nouadhibou-cifl", + "MR-09": "tagant-tjfm", + "MR-10": "guidimaka-cqhe", + "MR-11": "tiris-zemmour-xshf", + "MR-12": "inchiri-othg", + "MW-CK": "chikwawa-ulnn", + "MW-CT": "chitipa-qnno", + "MW-KS": "kasungu-scgl", + "MW-LI": "lilongwe-kdpi", + "MW-MG": "mangochi-qesd", + "MW-MH": "machinga-mtss", + "MW-MZ": "mzimba-svso", + "MW-NB": "nkhata-bay-sgvt", + "MY-01": "johor-omus", + "MY-02": "kedah-gout", + "MY-03": "kelantan-ypuu", + "MY-06": "pahang-ihup", + "MY-08": "perak-sxuz", + "MY-10": "selangor-wkxy", + "MY-11": "terengganu-smxz", + "MY-12": "sabah-knxa", + "MY-13": "sarawak-cpxb", + "NA-CA": "caprivi-yapw", + "NA-ER": "erongo-mmar", + "NA-HA": "hardap-qqnp", + "NA-KA": "karas-etva", + "NA-KH": "khomas-ievh", + "NA-KU": "kunene-cmvm", + "NA-OD": "otjozondjupa-yuld", + "NA-OH": "omaheke-ablr", + "NA-OK": "kavango-gglu", + "NA-OS": "omusati-gtlm", + "NA-OT": "oshikoto-cvln", + "NA-OW": "ohangwena-ialq", + "NE-1": "agadez-ijfb", + "NE-2": "diffa-akfc", + "NE-3": "dosso-smfd", + "NE-4": "maradi-knfe", + "NE-5": "tahoua-gpff", + "NE-6": "tillaberi-yrfg", + "NE-7": "zinder-qsfh", + "NG-AD": "adamawa-ehbv", + "NG-BA": "bauchi-spdb", + "NG-BE": "benue-uwdf", + "NG-BO": "borno-emdz", + "NG-CR": "cross-river-erfd", + "NG-DE": "delta-qnoh", + "NG-ED": "edo-utql", + "NG-EN": "enugu-ijqf", + "NG-GO": "gombe-ogwc", + "NG-JI": "jigawa-yzkn", + "NG-KD": "kaduna-aymn", + "NG-KE": "kebbi-wamo", + "NG-KN": "kano-oomx", + "NG-KO": "kogi-gqmy", + "NG-KT": "katsina-ezmx", + "NG-KW": "kwara-kema", + "NG-NA": "nassarawa-mrzh", + "NG-NI": "niger-qfzz", + "NG-OG": "ogun-soba", + "NG-ON": "ondo-aabr", + "NG-OS": "osun-qhbm", + "NG-OY": "oyo-ksbc", + "NG-PL": "plateau-oako", + "NG-RI": "rivers-esqx", + "NG-SO": "sokoto-mjsi", + "NG-TA": "taraba-cqbj", + "NG-YO": "yobe-sitq", + "NG-ZA": "zamfara-iavl", + "NI-AN": "atlantico-norte-ieag", + "NI-AS": "atlantico-sur-ylal", + "NI-CO": "chontales-uefv", + "NI-JI": "jinotega-yvws", + "NI-MT": "matagalpa-iykc", + "NL-BQ1": "bonaire-makp", + "NL-BQ2": "saba-gckq", + "NL-BQ3": "st-eustatius-wdkr", + "NL-GE": "gelderland-ygoe", + "NL-NH": "noord-holland-otrk", + "NO-02": "akershus-ityh", + "NO-04": "hedmark-akyb", + "NO-05": "oppland-slyc", + "NO-06": "buskerud-onyd", + "NO-08": "telemark-ceyx", + "NO-09": "aust-agder-ufyy", + "NO-12": "hordaland-yrai", + "NO-14": "sogn-og-fjordane-iuak", + "NO-15": "more-og-romsdal-aval", + "NO-16": "sor-trondelag-sxam", + "NO-17": "nord-trondelag-kyan", + "NO-18": "nordland-gaao", + "NO-19": "troms-ccap", + "NO-20": "finnmark-mdcj", + "NO-21": "svalbard-eeck", + "NO-X01~": "bouvet-island-vssk", + "NP-BA": "bagmati-ungl", + "NP-BH": "bheri-czgc", + "NP-GA": "gandaki-ohsw", + "NP-KA": "karnali-mbpa", + "NP-KO": "bhojpur-uxpy", + "NP-RA": "rapti-ewpq", + "NP-SA": "sagarmatha-otrd", + "NP-SE": "seti-qarh", + "NZ-BOP": "bay-of-plenty-wlsa", + "NZ-CAN": "canterbury-kxye", + "NZ-CIT": "chatham-islands-territory-svse", + "NZ-HKB": "hawke-s-bay-iunb", + "NZ-MBH": "marlborough-district-oyxs", + "NZ-MWT": "manawatu-wanganui-zrxu", + "NZ-NTL": "northland-aqhd", + "NZ-OTA": "otago-ctsh", + "NZ-STL": "southland-ukrs", + "NZ-WKO": "waikato-gxqe", + "NZ-WTC": "west-coast-ucji", + "NZ-X01~": "kermadec-islands-ocbs", + "NZ-X03~": "three-kings-islands-qafu", + "NZ-X04~": "antipodes-islands-ynit", + "NZ-X05~": "campbell-islands-ywks", + "NZ-X06~": "auckland-islands-ujmt", + "NZ-X07~": "the-snares-mvou", + "TK-X01~": "tokelau-cqiy", + "OM-BA": "al-batnah-north-ilnl", + "OM-DA": "ad-dakhliyah-qtih", + "OM-MU": "musandam-ubnu", + "OM-SH": "ash-sharqiyah-south-gkzv", + "OM-SS": "ash-sharqiyah-north-qdza", + "OM-WU": "al-wusta-mdok", + "OM-ZA": "al-dhahira-eqif", + "OM-ZU": "dhofar-gtht", + "PK-BA": "baluchistan-ihjh", + "PK-GB": "northern-areas-mevu", + "PK-JK": "azad-kashmir-ajpr", + "PK-KP": "k-p-stsm", + "PK-SD": "sind-atud", + "PK-TA": "f-a-t-a-yhan", + "PA-4": "chiriqui-mxiy", + "PA-5": "darien-iziz", + "PA-8": "panama-oriu", + "PA-9": "veraguas-gsiv", + "PE-ANC": "ancash-mtdg", + "PE-APU": "apurimac-aghq", + "PE-ARE": "arequipa-avbe", + "PE-AYA": "ayacucho-vhko", + "PE-CAJ": "cajamarca-mjjy", + "PE-CUS": "cusco-ypen", + "PE-HUC": "huanuco-uwqc", + "PE-HUV": "huancavelica-orqp", + "PE-ICA": "ica-hijd", + "PE-JUN": "junin-rnyf", + "PE-LAL": "la-libertad-sqmm", + "PE-LAM": "lambayeque-yoml", + "PE-LIM": "lima-iioy", + "PE-LOR": "loreto-ipkx", + "PE-MDD": "madre-de-dios-fpxm", + "PE-MOQ": "moquegua-xwwn", + "PE-PAS": "pasco-ydtb", + "PE-PIU": "piura-whve", + "PE-PUN": "puno-vaqi", + "PE-SAM": "san-martin-orum", + "PE-TAC": "tacna-uiaf", + "PE-UCA": "ucayali-hmry", + "PH-ABR": "abra-cmfg", + "PH-AGS": "agusan-del-sur-efza", + "PH-BTN": "batanes-orcp", + "PH-BUK": "bukidnon-faet", + "PH-CAG": "cagayan-sezc", + "PH-CAS": "camarines-sur-ulzq", + "PH-DAO": "davao-oriental-sxtc", + "PH-ILI": "iloilo-xbzy", + "PH-MDC": "mindoro-occidental-sjgh", + "PH-NCO-2": "cotabato-wzzy", + "PH-NEC": "negros-occidental-erlu", + "PH-NUE": "nueva-ecija-ilyq", + "PH-PAN": "pangasinan-cmhx", + "PH-PLW": "palawan-imjh", + "PH-QUE": "quezon-scfg", + "PH-SUK": "sultan-kudarat-pnsz", + "PH-TAW": "tawi-tawi-khxg", + "PH-WSA": "samar-vdxo", + "PH-ZAN": "zamboanga-del-norte-qvgj", + "PH-ZMB-2": "zambales-gjfs", + "PG-EBR": "east-new-britain-bphy", + "PG-EHG": "eastern-highlands-ohoa", + "PG-EPW": "enga-yfvk", + "PG-ESW": "east-sepik-azma", + "PG-GPK": "gulf-yenl", + "PG-MBA": "milne-bay-gubz", + "PG-MPL": "morobe-anuo", + "PG-MPM": "madang-elun", + "PG-MRL": "manus-oxpg", + "PG-NSB": "north-solomons-yvrf", + "PG-SAN": "sandaun-swut", + "PG-SHM": "southern-highlands-wdcq", + "PG-WBK": "west-new-britain-qila", + "PL-DS": "lower-silesian-eayz", + "PL-KP": "kuyavian-pomeranian-gfwe", + "PL-LB": "lubusz-wsem", + "PL-LD": "lodz-gveo", + "PL-LU": "lublin-gyez", + "PL-MA": "lesser-poland-ukhv", + "PL-MZ": "masovian-uuhs", + "PL-PD": "podlachian-amui", + "PL-PK": "subcarpathian-ajux", + "PL-PM": "pomeranian-saur", + "PL-SK": "swietokrzyskie-yobp", + "PL-SL": "silesian-igbk", + "PL-WN": "warmian-masurian-epsm", + "PL-WP": "greater-poland-ausi", + "PL-ZP": "west-pomeranian-mafy", + "US-PR": "puerto-rico-qqlt", + "KP-02": "p-yongan-namdo-uodu", + "KP-03": "p-yongan-bukto-ipdv", + "KP-04": "chagang-do-erdw", + "KP-06": "hwanghae-bukto-oudy", + "KP-07": "kangwon-do-kwdz", + "KP-08": "hamgyong-namdo-cxda", + "KP-09": "hamgyong-bukto-yzdb", + "KP-10": "ryanggang-ggfz", + "PT-02": "beja-ykgw", + "TN-31": "beja-yaie", + "PT-05": "castelo-branco-gcgr", + "PT-07": "evora-qfgt", + "PT-14": "santarem-sojz", + "PT-17": "vila-real-ytjc", + "PT-18": "viseu-uvjd", + "PT-20": "azores-ayly", + "PT-30": "madeira-croh", + "PY-1": "concepcion-vrml", + "PY-2": "san-pedro-qtmm", + "PY-5": "caaguazu-xlmh", + "PY-6": "caazapa-snmi", + "PY-7": "itapua-jomj", + "PY-9": "paraguari-vemt", + "PY-10": "alto-parana-mimn", + "PY-12": "neembucu-cfml", + "PY-13": "amambay-kemk", + "PY-14": "canindeyu-ocmj", + "PY-15": "presidente-hayes-abmi", + "PY-16": "alto-paraguay-ezmh", + "PY-19": "boqueron-uume", + "PS-GZZ": "gaza-strip-sazs", + "PS-WBK": "west-bank-cjlo", + "QA-RA": "ar-rayyan-ynlo", + "RO-AB": "alba-kbti", + "RO-AG": "arges-hxtf", + "RO-BC": "bacau-zdvy", + "RO-BZ": "buzau-ipvp", + "RO-CJ": "cluj-cgyo", + "RO-CT": "constanta-owyy", + "RO-DJ": "dolj-yaab", + "RO-GJ": "gorj-qeiw", + "RO-HR": "harghita-ybxj", + "RO-IS": "iasi-gzad", + "RO-MM": "maramures-iwjv", + "RO-MS": "mures-yhjv", + "RO-TM": "timis-seme", + "RO-TR": "teleorman-gcnr", + "RU-AL": "gorno-altay-cstw", + "RU-ALT": "altay-mfuq", + "RU-AMU": "amur-clbp", + "RU-ARK": "arkhangel-sk-lhbk", + "RU-AST": "astrakhan-anec", + "RU-BA": "bashkortostan-uovk", + "RU-BEL": "belgorod-wxjk", + "RU-BRY": "bryansk-hxom", + "RU-BU": "buryat-wvvy", + "RU-CE": "chechnya-qixv", + "RU-CHE": "chelyabinsk-qnxc", + "RU-CHU": "chukchi-autonomous-okrug-mmxs", + "RU-CU": "chuvash-mhxl", + "RU-DA": "dagestan-qfgq", + "RU-IRK": "irkutsk-wjtq", + "RU-IVA": "ivanovo-uneq", + "RU-KAM": "kamchatka-rncn", + "RU-KB": "kabardin-balkar-kgec", + "RU-KC": "karachay-cherkess-gied", + "RU-KDA": "krasnodar-nzpm", + "RU-KEM": "kemerovo-larh", + "RU-KGD": "kaliningrad-ihxa", + "RU-KGN": "kurgan-yyxu", + "RU-KHA": "khabarovsk-vetk", + "RU-KHM": "khanty-mansiy-tkto", + "RU-KIR": "kirov-gfvm", + "RU-KK": "khakass-cuel", + "RU-KL": "kalmyk-yjee", + "RU-KLU": "kaluga-fmjg", + "RU-KO": "komi-eoeh", + "RU-KOS": "kostroma-hmrp", + "RU-KR": "karelia-ohem", + "RU-KRS": "kursk-obra", + "RU-KYA": "krasnoyarsk-znif", + "RU-LEN": "leningrad-mknu", + "RU-LIP": "lipetsk-qvwo", + "RU-MAG": "maga-buryatdan-wvtk", + "RU-ME": "mariy-el-umod", + "RU-MO": "mordovia-abon", + "RU-MOW": "moskovskaya-chlr", + "RU-MUR": "murmansk-etas", + "RU-NEN": "nenets-qyga", + "RU-NGR": "novgorod-cvlo", + "RU-NIZ": "nizhegorod-clce", + "RU-NVS": "novosibirsk-mivi", + "RU-OMS": "omsk-aufy", + "RU-ORE": "orenburg-nkfp", + "RU-ORL": "orel-svfw", + "RU-PER": "perm-cphx", + "RU-PNZ": "penza-mzew", + "RU-PRI": "primor-ye-edix", + "RU-PSK": "pskov-kakm", + "RU-ROS": "rostov-ylrd", + "RU-RYA": "ryazan-ggrj", + "RU-SA": "sakha-yakutia-yxkf", + "RU-SAK": "sakhalin-wlzc", + "RU-SAM": "samara-koze", + "RU-SAR": "saratov-avzz", + "RU-SE": "north-ossetia-arkb", + "RU-SMO": "smolensk-qrwu", + "RU-STA": "stavropol-mizd", + "RU-SVE": "sverdlovsk-eefj", + "RU-TA": "tatarstan-aotw", + "RU-TAM": "tambov-evce", + "RU-TOM": "tomsk-gews", + "RU-TUL": "tula-cvuo", + "RU-TVE": "tver-qdaj", + "RU-TY": "tuva-ecty", + "RU-TYU": "tyumen-uppf", + "RU-UD": "udmurt-cave", + "RU-ULY": "ul-yanovsk-uovf", + "RU-VGG": "volgograd-omkg", + "RU-VLA": "vladimir-mqvt", + "RU-VLG": "vologda-ynvr", + "RU-VOR": "voronezh-svdp", + "RU-YAN": "yamal-nenets-aqsx", + "RU-YAR": "yaroslavl-cksd", + "RU-YEV": "yevrey-itcn", + "RU-ZAB": "chita-qxms", + "UA-43": "crimea-qsnu", + "EH-X01~": "western-sahara-gozf", + "SA-01": "ar-riyad-gsqn", + "SA-02": "makkah-yuqo", + "SA-03": "al-madinah-uwqp", + "SA-05": "al-quassim-imqj", + "SA-06": "ha-il-eoqk", + "SA-07": "tabuk-spql", + "SA-08": "al-hudud-ash-shamaliyah-kdqu", + "SA-09": "jizan-gfqv", + "SA-10": "najran-wlss", + "SA-11": "al-bahah-ajsr", + "SA-12": "al-jawf-misq", + "YE-JA": "al-jawf-wngz", + "SA-14": "asir-yfso", + "SD-DE": "central-darfur-wrpe", + "SD-DN": "north-darfur-mfpn", + "SD-DS": "southern-darfur-iopm", + "SD-DS-2": "eastern-darfur-kbbu", + "SD-DW": "western-darfur-kvpq", + "SD-GD": "gedarif-oxvo", + "SD-GZ": "gezira-chvu", + "SD-KA": "kassala-odzh", + "SD-KH": "khartoum-wpzy", + "SD-KN": "north-kordufan-kmzw", + "SD-KS": "south-kordufan-ygzz", + "SD-NB": "blue-nile-conz", + "SD-NR": "river-nile-gpnj", + "SD-NW": "white-nile-uwno", + "SD-RS": "red-sea-zspo", + "SD-SI": "sennar-dssz", + "SS-BN": "northern-bahr-el-ghazal-civj", + "SS-BW": "western-bahr-el-ghazal-uwvs", + "SS-EC": "central-equatoria-sogr", + "SS-EE": "eastern-equatoria-ilgp", + "SS-EW": "western-equatoria-yffx", + "SS-JG": "jonglei-setz", + "SS-LK": "lakes-uvxp", + "SS-NU": "upper-nile-kxdz", + "SS-UY": "unity-smuv", + "SS-WR": "warrap-kuzy", + "SN-FK": "fatick-kohi", + "SN-KA": "kaffrine-ufni", + "SN-KD": "kolda-oanf", + "SN-KE": "kedougou-syne", + "SN-LG": "louga-wgwg", + "SN-MT": "matam-qxzf", + "SN-SE": "sedhiou-qgha", + "SN-SL": "saint-louis-iuhj", + "SN-TC": "tambacounda-shqy", + "SN-TH": "thies-mpqd", + "SB-MK": "makira-qcnj", + "SB-RB": "rennell-and-bellona-amuo", + "SB-TE": "temotu-wpdk", + "SV-US": "usulutan-xbjc", + "-99-X11~": "somaliland-gapg", + "SO-BK": "bakool-grdf", + "SO-BY": "bay-uvdr", + "SO-GA": "galguduud-arwl", + "SO-GE": "gedo-yxwp", + "SO-HI": "hiiraan-knsf", + "SO-JD": "jubbada-dhexe-unxw", + "SO-JH": "jubbada-hoose-wuxk", + "SO-MU": "mudug-kpld", + "SO-NU": "nugaal-gznd", + "SO-SD": "shabeellaha-dhexe-ueua", + "SO-SH": "shabeellaha-hoose-sxum", + "RS-06": "juzno-backi-girr", + "RS-07": "sremski-xjrs", + "RS-11": "branicevski-nitf", + "RS-15": "zajecarski-lotj", + "RS-16": "zlatiborski-gqtk", + "SR-SI": "sipaliwini-lvya", + "SK-BC": "banskobystricky-iono", + "SK-NI": "nitriansky-muds", + "SK-PV": "presov-kpwz", + "SI-048": "kocevje-gyjj", + "SE-AC": "vasterbotten-ykth", + "SE-BD": "norrbotten-gqvp", + "SE-C": "uppsala-aeug", + "SE-D": "sodermanland-sfuh", + "SE-E": "ostergotland-ohui", + "SE-F": "jonkoping-eiuj", + "SE-G": "kronoberg-ykuk", + "SE-H": "kalmar-umuv", + "SE-I": "gotland-qouw", + "SE-M": "skane-ouua", + "SE-O": "vastra-gotaland-yxuc", + "SE-S": "varmland-aeuq", + "SE-W": "dalarna-cluu", + "SE-X": "gavleborg-ynuf", + "SE-Y": "vasternorrland-upug", + "SE-Z": "jamtland-kquh", + "SZ-LU": "lubombo-ybpl", + "SY-DY": "dayr-az-zawr-nthe", + "SY-HA": "hasaka-al-haksa-vlle", + "SY-HI": "homs-hims-vcmm", + "SY-HL": "aleppo-cqlh", + "SY-HM": "hamah-xsli", + "SY-ID": "idlib-yoou", + "SY-RA": "ar-raqqah-lhjy", + "SY-RD": "rif-dimashq-smjb", + "SY-SU": "as-suwayda-jzmr", + "TD-BA": "batha-dyem", + "TD-BG": "barh-el-gazel-nhes", + "TD-BO": "borkou-nuea", + "TD-CB": "chari-baguirmi-aeay", + "TD-GR": "guera-qyrk", + "TD-HL": "hadjer-lamis-avbt", + "TD-KA": "kanem-bhvv", + "TD-LC": "lac-rmsk", + "TD-LR": "logone-oriental-wotz", + "TD-MA": "mandoul-jypx", + "TD-MC": "moyen-chari-vbpz", + "TD-ME": "mayo-kebbi-est-hepb", + "TD-MO": "mayo-kebbi-ouest-tupl", + "TD-OD": "ouaddai-ukka", + "TD-SA": "salamat-nrnf", + "TD-SI": "sila-rfnx", + "TD-TA": "tandjile-trlq", + "TD-TI": "tibesti-teli", + "TD-WF": "wadi-fira-wtem", + "TD-X01~": "ennedi-sxtm", + "TG-K": "kara-khrl", + "TH-14": "phra-nakhon-si-ayutthaya-eddh", + "TH-24": "chachoengsao-uwfm", + "TH-27": "sa-kaeo-cufl", + "TH-30": "nakhon-ratchasima-kfip", + "TH-31": "buri-ram-ghiq", + "TH-34": "ubon-ratchathani-mzil", + "TH-36": "chaiyaphum-acin", + "TH-40": "khon-kaen-msqg", + "TH-41": "udon-thani-iuqh", + "TH-42": "loei-avqi", + "TH-45": "roi-et-gerl", + "TH-47": "sakon-nakhon-qhrn", + "TH-49": "mukdahan-ihqz", + "TH-50": "chiang-mai-qstn", + "TH-52": "lampang-avtp", + "TH-53": "uttaradit-swtq", + "TH-55": "nan-kntk", + "TH-57": "chiang-rai-uqtm", + "TH-58": "mae-hong-son-qftf", + "TH-60": "nakhon-sawan-ckvs", + "TH-61": "uthai-thani-ymvt", + "TH-63": "tak-ojvr", + "TH-65": "phitsanulok-agvp", + "TH-67": "phetchabun-mcvn", + "TH-71": "kanchanaburi-udyq", + "TH-76": "phetchaburi-klyv", + "TH-80": "nakhon-si-thammarat-utgo", + "TH-84": "surat-thani-wngk", + "TH-90": "songkhla-eojn", + "TH-95": "yala-ywjs", + "TJ-GB": "gorno-badakhshan-sgex", + "TJ-KT": "khatlon-qlhv", + "TJ-SU": "leninabad-dnbu", + "TJ-X01~": "tadzhikistan-territories-uxcw", + "TM-A": "ahal-weco", + "TM-B": "balkan-edcn", + "TM-D": "tashauz-mmct", + "TM-L": "chardzhou-mzcb", + "TM-M": "mary-qxca", + "TL-OE": "ambeno-kpna", + "TL-VI": "viqueque-wkyr", + "TT-RCM": "rio-claro-mayaro-ojxf", + "TT-WTO": "western-tobago-lmvd", + "TN-33": "le-kef-oxic", + "TN-43": "sidi-bou-zid-ifqk", + "TN-61": "sfax-ktva", + "TN-71": "gafsa-ofsu", + "TN-73": "kebili-ecss", + "TN-83": "tataouine-qrma", + "TR-01": "adana-wdhs", + "TR-03": "afyonkarahisar-gghu", + "TR-04": "agri-cvhn", + "TR-06": "ankara-oyhp", + "TR-07": "antalya-iahq", + "TR-08": "artvin-aohz", + "TR-10": "balikesir-exjy", + "TR-11": "bilecik-wyjz", + "TR-13": "bitlis-gbjb", + "TR-14": "bolu-cdjc", + "TR-16": "bursa-ogje", + "TR-19": "corum-amjr", + "TR-20": "denizli-spld", + "TR-21": "diyarbakir-mrle", + "TR-24": "erzincan-qvlh", + "TR-25": "erzurum-kxli", + "TR-26": "eskisehir-eslf", + "TR-28": "giresun-wdlv", + "TR-29": "gumushane-melw", + "TR-33": "mersin-mqol", + "TR-35": "izmir-ehof", + "TR-36": "kars-uiog", + "TR-37": "kastamonu-okoh", + "TR-38": "kayseri-kzoa", + "TR-40": "kirsehir-iywz", + "TR-42": "konya-ubwb", + "TR-43": "kutahya-odwc", + "TR-44": "malatya-kfwd", + "TR-45": "manisa-cgwe", + "TR-46": "k-maras-wiwf", + "TR-48": "mugla-mmwr", + "TR-55": "samsun-inzb", + "TR-56": "siirt-yozc", + "TR-58": "sivas-ofzw", + "TR-63": "sanliurfa-imci", + "TR-65": "van-ujcg", + "TR-66": "yozgat-oecd", + "TR-68": "aksaray-cbcb", + "TW-HUA": "hualien-babs", + "TW-KHH": "kaohsiung-city-gnnm", + "TW-KIN": "kinmen-mdpx", + "TW-PEN": "penghu-oghr", + "TW-TPQ": "new-taipei-city-wbtm", + "TZ-01": "arusha-ssrk", + "TZ-03": "dodoma-cvrm", + "TZ-04": "iringa-ykrf", + "TZ-05": "kagera-umrg", + "TZ-08": "kigoma-aerb", + "TZ-09": "kilimanjaro-wgrc", + "TZ-10": "kusini-pemba-iltp", + "TZ-12": "lindi-yitn", + "TZ-13": "mara-cgtm", + "TZ-14": "mbeya-getl", + "TZ-16": "morogoro-wbtj", + "TZ-17": "mtwara-eati", + "TZ-18": "mwanza-iyth", + "TZ-19": "pwani-qxtg", + "TZ-20": "rukwa-ygvp", + "TZ-21": "ruvuma-qivq", + "TZ-22": "shinyanga-kdvn", + "TZ-23": "singida-gfvo", + "TZ-24": "tabora-wnvt", + "TZ-25": "tanga-oovu", + "TZ-26": "manyara-mkvr", + "TZ-27": "geita-elvs", + "TZ-28": "katavi-ytvx", + "TZ-29": "njombe-qvvy", + "TZ-30": "simiyu-iyyt", + "UG-107": "mubende-stqq", + "UG-108": "mukono-khqz", + "UG-109": "nakasongola-gjqa", + "UG-120": "buvuma-gikj", + "UG-207": "katakwi-xlyx", + "UG-216": "amuria-izaz", + "UG-305": "kitgum-hetq", + "UG-319": "amuru-fprn", + "UG-328": "napak-yzyn", + "UG-329": "nwoya-payo", + "UG-403": "hoima-dcsv", + "UG-406": "kasese-khsy", + "UG-410": "mbarara-yqvb", + "UG-410-2": "mbarara-mcue", + "UA-05": "vinnytsya-yoxi", + "UA-07": "volyn-irxk", + "UA-09": "luhans-k-whxu", + "UA-12": "dnipropetrovs-k-uszu", + "UA-14": "donets-k-gvzw", + "UA-18": "zhytomyr-iczk", + "UA-21": "transcarpathia-qncy", + "UA-23": "zaporizhzhya-cjcw", + "UA-26": "ivano-frankivs-k-iocz", + "UA-32": "kiev-yefc", + "UA-35": "kirovohrad-gwfx", + "UA-46": "l-viv-wbox", + "UA-48": "mykolayiv-obnj", + "UA-51": "odessa-kpqz", + "UA-53": "poltava-usqb", + "UA-56": "rivne-ekqw", + "UA-59": "sumy-kcqr", + "UA-61": "ternopil-whse", + "UA-63": "kharkiv-mesc", + "UA-65": "kherson-ybsa", + "UA-68": "khmel-nyts-kyy-etsv", + "UA-71": "cherkasy-wzvf", + "UA-74": "chernihiv-cevi", + "UY-AR": "artigas-stkw", + "UY-CL": "cerro-largo-sjom", + "UY-DU": "durazno-qcxo", + "UY-FD": "florida-acct", + "US-FL": "florida-gmqg", + "UY-LA": "lavalleja-mdrw", + "UY-PA": "paysandu-ikte", + "UY-RO": "rocha-ymzo", + "UY-SA": "salto-aobp", + "UY-SJ": "san-jose-qcby", + "UY-SO": "soriano-ixbv", + "UY-TA": "tacuarembo-yekg", + "US-AK": "alaska-qfen", + "US-AL": "alabama-uqeu", + "US-AR": "arkansas-itew", + "US-AZ": "arizona-igee", + "US-CA": "california-kikh", + "US-CO": "colorado-wfkf", + "US-CT": "connecticut-apkc", + "US-GA": "georgia-uotf", + "US-HI": "hawaii-mxvl", + "US-IA": "iowa-cdyh", + "US-ID": "idaho-slym", + "US-IL": "illinois-syye", + "US-IN": "indiana-ivyc", + "US-KS": "kansas-ondv", + "US-KY": "kentucky-abeb", + "US-LA": "louisiana-wlgr", + "US-MA": "massachusetts-ggid", + "US-MD": "maryland-ecia", + "US-ME": "maine-maiz", + "US-MI": "michigan-gtil", + "US-MN": "minnesota-qlig", + "US-MO": "missouri-ykif", + "US-MS": "mississippi-scir", + "US-MT": "montana-abiq", + "US-NC": "north-carolina-ozkp", + "US-ND": "north-dakota-ehku", + "US-NE": "nebraska-wikv", + "US-NH": "new-hampshire-koki", + "US-NJ": "new-jersey-wlkg", + "US-NM": "new-mexico-awkn", + "US-NV": "nevada-uekc", + "US-NY": "new-york-cqkt", + "US-OH": "ohio-kgmu", + "US-OK": "oklahoma-cimv", + "US-OR": "oregon-qznu", + "US-PA": "pennsylvania-ylkc", + "US-SC": "south-carolina-izro", + "US-SD": "south-dakota-oerr", + "US-TN": "tennessee-geup", + "US-TX": "texas-mguh", + "US-UT": "utah-whas", + "US-VA": "virginia-oedb", + "US-VT": "vermont-ujdo", + "US-WA": "washington-exgl", + "US-WI": "wisconsin-ekgt", + "US-WV": "west-virginia-ivga", + "US-WY": "wyoming-ajgj", + "UZ-BU": "bukhoro-udcy", + "UZ-FA": "ferghana-ammo", + "UZ-JI": "jizzakh-uoed", + "UZ-NW": "navoi-ahpf", + "UZ-QA": "kashkadarya-uvou", + "UZ-QR": "karakalpakstan-cuod", + "UZ-SA": "samarkand-ansu", + "UZ-SU": "surkhandarya-usso", + "UZ-TO": "tashkent-awvo", + "VE-B": "anzoategui-mrqa", + "VE-C": "apure-gtqb", + "VE-E": "barinas-uwqd", + "VE-H": "cojedes-abqg", + "VE-I": "falcon-scqh", + "VE-J": "guarico-meqi", + "VE-K": "lara-ggqj", + "VE-L": "merida-yhqk", + "VE-M": "miranda-ujql", + "VE-N": "monagas-kkqm", + "VE-P": "portuguesa-epqi", + "VE-S": "tachira-kuql", + "VE-U": "yaracuy-yxqn", + "VE-V": "zulia-szqo", + "VE-X01~": "venezuela-oxzs", + "VE-Y": "delta-amacuro-wdqr", + "VN-02": "lao-cai-suzu", + "VN-05": "son-la-amzp", + "VN-07": "tuyen-quang-kpzr", + "VN-09": "lang-son-yfzb", + "VN-21": "thanh-hoa-okef", + "VN-22": "nghe-an-ifec", + "VN-24": "quang-binh-wpei", + "VN-27": "quang-nam-cneh", + "VN-28": "kon-tum-ywew", + "VN-30": "gia-lai-kyhl", + "VN-33": "djak-lak-qdho", + "VN-35": "lam-djong-iuhi", + "VN-41": "long-an-irqd", + "VN-47": "kien-giang-qaqj", + "VN-58": "binh-phuoc-qysb", + "VN-71": "djien-bien-uwxm", + "VN-HN": "ha-noi-shnb", + "VU-SAM": "sanma-yzht", + "VU-TAE": "tafea-wskq", + "VU-TOB": "torba-ckkb", + "YE-AB": "abyan-oexn", + "YE-DH": "dhamar-utko", + "YE-HD": "hadramawt-itbu", + "YE-HJ": "hajjah-uqbs", + "YE-HU": "al-hudaydah-aubl", + "YE-LA": "lahij-saqb", + "YE-MA": "ma-rib-oyto", + "YE-MR": "al-mahrah-gatz", + "YE-SD": "sa-dah-kvqv", + "YE-SH": "shabwah-mpqr", + "YE-SN": "sana-a-ylqp", + "YE-TA": "ta-izz-ctzr", + "ZA-EC": "eastern-cape-oqpg", + "ZA-FS": "free-state-idrj", + "ZA-GT": "gauteng-eruj", + "ZA-LP": "limpopo-kvkx", + "ZA-MP": "mpumalanga-yrnu", + "ZA-NC": "northern-cape-glpq", + "ZA-NL": "kwazulu-natal-capj", + "ZA-WC": "western-cape-qngk", + "ZM-04": "luapula-imtp", + "ZM-06": "north-western-uptr", + "ZM-08": "copperbelt-gftb", + "ZM-09": "lusaka-ygtc", + "ZM-10": "muchinga-kova", + "ZW-MA": "manicaland-czzx", + "ZW-MC": "mashonaland-central-mczz", + "ZW-ME": "mashonaland-east-afzb", + "ZW-MI": "midlands-cmzf", + "ZW-MN": "matabeleland-north-otzk", + "ZW-MS": "matabeleland-south-qdzj", + "ZW-MV": "masvingo-wizm", + "ZW-MW": "mashonaland-west-ojzn" +} diff --git a/scripts/build-region-keys.mjs b/scripts/build-region-keys.mjs new file mode 100644 index 0000000..6bd1157 --- /dev/null +++ b/scripts/build-region-keys.mjs @@ -0,0 +1,158 @@ +/** + * scripts/build-region-keys.mjs — build the deterministic region-key manifest. + * + * Region ownership flips from whole-country (ISO_A3) to individual sub-region + * granularity, but the LLM and the persisted `Nation.territory` list need a + * *stable, human-readable, globally unique* identifier per sub-region. Raw + * feature ids (`FR-57`) are opaque to the model, so we mint a readable key of + * the form: + * + * -<4char> e.g. brittany-xk2a, western-alun + * + * where: + * - = properties.name → NFKD → strip combining marks → lowercase + * → a tiny fold table for the handful of letters NFKD does NOT decompose + * (ə ø ð đ œ ł, the only non-folding letters present in this dataset) + * → every non-alphanumeric run collapsed to a single '-' → leading/trailing + * '-' trimmed. + * - <4char> = a deterministic 4-letter lowercase code derived from the + * feature's stable id (FNV-1a 32-bit → 4 base-26 chars). It is stable + * (same id → same code on every run) because the key is persisted in + * SQLite and shown to the LLM. Within any name-slug group (e.g. the six + * regions all named "Western") the 4-char codes are made distinct by + * re-deriving with a deterministic salt on collision, which guarantees the + * full key is unique across all 1,796 regions. + * + * Output: `public/region-keys.json` — a `{ [stableFeatureId]: regionKey }` + * object, e.g. `{ "FR-57": "brittany-xk2a" }`, in file (stable) order. The map + * is looked up by the client (`WorldMap`) and the server (`map`/`presets`/ + * `engine`) to join a sub-region's stable id to its readable key. + * + * Builtins only — deliberately NO `@turf`/geojson import (importing one pulls + * `@types/geojson` into the tsc program and breaks `WorldMap.tsx`; see the + * v0.17 changelog note). Reads the geo file with plain `JSON.parse`. + * + * Idempotent: a re-run rewrites a byte-identical file (no timestamps, no + * randomness, stable feature order, deterministic hash). + * + * Usage: `node scripts/build-region-keys.mjs` (or `npm run build:region-keys`). + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(here, '..'); + +/** + * Letters present in the dataset that NFKD does NOT decompose, and their + * single/multi-letter ASCII equivalents, so their slugs aren't hyphen-soup. + * Kept deliberately tiny + deterministic (only the six that actually occur). + */ +const FOLD = { + 'ə': 'a', // schwa + 'ø': 'o', // o-stroke + 'ð': 'd', // eth + 'đ': 'dj', // d-stroke + 'œ': 'oe', // oe ligature + 'ł': 'l', // l-stroke +}; + +/** + * Readable slug from a region name. NFKD decomposes + we strip the combining + * marks, lowercase, fold the non-decomposing letters, collapse every other + * non-alphanumeric run to one hyphen, and trim leading/trailing hyphens. + */ +function slug(name) { + let s = name.normalize('NFKD').replace(/[\u0300-\u036f]/g, ''); + s = s.toLowerCase(); + s = [...s].map((ch) => FOLD[ch] ?? ch).join(''); + s = s.replace(/[^a-z0-9]+/g, '-'); + s = s.replace(/^-+|-+$/g, ''); + return s; +} + +/** FNV-1a 32-bit — a tiny, stable, pure string hash. */ +function fnv1a32(str) { + let h = 0x811c9dc5; + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + h = (h * 0x01000193) >>> 0; + } + return h >>> 0; +} + +/** Spread a 32-bit hash across four lowercase letters (base 26 per byte). */ +function code4(h) { + let out = ''; + for (let i = 0; i < 4; i++) { + out += String.fromCharCode(97 + ((((h >>> (i * 8)) & 0xff) % 26))); + } + return out; +} + +/** The 4-char code for an id, deterministically salted on within-group collision. */ +function codeFor(id, used) { + let code = code4(fnv1a32(id)); + let attempt = 0; + while (used.has(code)) { + attempt += 1; + code = code4(fnv1a32(`${id}#${attempt}`)); + } + return code; +} + +// --- load the region geo (plain parse; no geojson types) --- +const geoFile = path.join(root, 'public', 'regions.geo.json'); +const geo = JSON.parse(readFileSync(geoFile, 'utf8')); +const features = Array.isArray(geo?.features) ? geo.features : []; + +if (features.length !== 1796) { + throw new Error(`expected 1,796 regions, got ${features.length} — regions.geo.json changed; re-verify the region-key contract`); +} + +// Group features by their (computed) name-slug, preserving file order, so the +// within-slug 4-char uniqueness resolution below is deterministic. +const groups = new Map(); // slug -> array of { id } +for (const f of features) { + const id = f.id == null ? '' : String(f.id); + const name = f.properties?.name; + if (!id || typeof name !== 'string') { + throw new Error(`region with id ${JSON.stringify(id)} has no usable name`); + } + const s = slug(name); + if (!groups.has(s)) groups.set(s, []); + groups.get(s).push({ id }); +} + +// Assign keys, resolving 4-char collisions within each slug group. +const manifest = {}; +for (const [s, ids] of groups) { + const used = new Set(); + for (const { id } of ids) { + manifest[id] = `${s}-${codeFor(id, used)}`; + used.add(manifest[id].slice(-4)); + } +} + +// --- hard assertions (the test re-checks these against the committed file) --- +const keys = Object.values(manifest); +if (new Set(keys).size !== keys.length) { + throw new Error(`region-key collision: ${keys.length} keys but only ${new Set(keys).size} unique`); +} +const KEY_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*-[a-z]{4}$/; +for (const k of keys) { + if (!KEY_RE.test(k) || !k.includes('-')) { + throw new Error(`malformed region key: ${JSON.stringify(k)}`); + } +} + +// --- write idempotently (stable order = insertion order = file order) --- +const out = JSON.stringify(manifest, null, 2) + '\n'; +writeFileSync(path.join(root, 'public', 'region-keys.json'), out); + +const western = Object.entries(manifest) + .filter(([, k]) => k.startsWith('western-')) + .sort((a, b) => a[0] < b[0] ? -1 : 1); +console.log(`[build-region-keys] wrote public/region-keys.json — ${keys.length} unique region keys (1,796 expected)`); +console.log(` western group: ${western.map(([id, k]) => `${id}=${k}`).join(', ')}`); From 2cfa2f78f9bf69083112cb4f51f786a8b0999690 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Wed, 14 Oct 2026 08:32:36 +0000 Subject: [PATCH 2/6] Add region-keys.test.ts: 1,796 unique -<4char> keys, geo-id join (v0.19 P1 of #16) --- scripts/region-keys.test.ts | 61 +++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 scripts/region-keys.test.ts diff --git a/scripts/region-keys.test.ts b/scripts/region-keys.test.ts new file mode 100644 index 0000000..577d18c --- /dev/null +++ b/scripts/region-keys.test.ts @@ -0,0 +1,61 @@ +/** + * scripts/region-keys.test.ts — validation for the committed `public/region-keys.json`. + * + * The manifest maps each sub-region's **stable feature id** (from + * `public/regions.geo.json`) to a deterministic, human-readable, globally + * unique region key of the form `-<4char>` (built by + * `scripts/build-region-keys.mjs`). Region-level ownership (v0.19) keys both + * `Nation.territory` and the `RegionMap` on these keys, so the manifest is a + * load-bearing data artifact — this test pins its contract. + * + * Deliberately dependency-free: it reads both JSON files with plain + * `fs`/`JSON.parse` and never imports `@turf`/geojson types (importing one + * pulls `@types/geojson` into the tsc program and breaks `WorldMap.tsx`; see + * the v0.17 changelog note). Mirrors the style of `scripts/regions.test.ts`. + */ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(here, '..'); + +function readJson(rel: string): Record { + return JSON.parse(readFileSync(path.join(root, rel), 'utf8')); +} + +describe('region-keys.json', () => { + const manifest = readJson('public/region-keys.json') as Record; + const geo = readJson('public/regions.geo.json') as { + features?: Array<{ id?: string | number }>; + }; + const keys = Object.values(manifest); + const featureIds = (geo.features ?? []).map((f) => String(f.id)); + + it('has exactly 1,796 entries (one per region)', () => { + expect(Object.keys(manifest)).toHaveLength(1796); + }); + + it('has 1,796 unique keys (no collisions)', () => { + expect(new Set(keys).size).toBe(keys.length); + expect(new Set(keys).size).toBe(1796); + }); + + it('keys each end in -[a-z]{4} and contain at least one hyphen', () => { + const ending = /-[a-z]{4}$/; + for (const k of keys) { + expect(k, `key does not end in -[a-z]{4}: ${k}`).toMatch(ending); + expect(k, `key has no hyphen: ${k}`).toContain('-'); + } + }); + + it('is a stable join: its keys are exactly the regions.geo.json feature ids', () => { + // The manifest is looked up by feature id, so its key set must line up + // 1:1 with the geo file (catches drift if the geo is regenerated without + // rebuilding the keys). + expect(featureIds).toHaveLength(1796); + const manifestIds = Object.keys(manifest); + expect(new Set(manifestIds)).toEqual(new Set(featureIds)); + }); +}); From 325bdc525578ce1e78b2cf48cf51156b0a94ac85 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Wed, 14 Oct 2026 09:14:10 +0000 Subject: [PATCH 3/6] Flip ownership to region keys: shared contract + server (v0.19 P1 of #16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single source of truth now keys ownership by region key (one per owned sub-region) instead of a parent-country ISO_A3: - shared/types.ts, shared/protocol.ts: TerritoryChange / RegionMap / regionOwner doc flips; mapFeatureOwner now reads the feature's stamped properties.regionKey (never the repacked numeric feature.id, nor the sub-region id — the #6 guard). - server/regions.ts (new, turf-free): loads region-keys.json + regions.geo.json once at boot; expands a nation's ISO_A3 list to its sub-regions' keys, builds the LLM 'regions' reference table (key -> "name (parent)"), and exposes the set of valid keys. Degrades gracefully when the files are missing. - server/engine.ts: buildSystemPrompt instructs the model to use region keys; buildUserPrompt gains an optional regionReference table; applyTurnResult gains an optional validKeys set so hallucinated keys are ignored. - server/index.ts: loads region data at boot; expands each nation's country list to region keys at game creation; the jump route passes the reference + valid keys through runTurn. The stub is unchanged (it emits an empty, region-key-conformant territoryChanges). --- server/engine.ts | 39 ++++++++----- server/index.ts | 10 ++++ server/map.ts | 12 ++-- server/regions.ts | 140 +++++++++++++++++++++++++++++++++++++++++++++ shared/protocol.ts | 47 ++++++++------- shared/types.ts | 6 +- 6 files changed, 214 insertions(+), 40 deletions(-) create mode 100644 server/regions.ts diff --git a/server/engine.ts b/server/engine.ts index c10e596..a442ce0 100644 --- a/server/engine.ts +++ b/server/engine.ts @@ -53,11 +53,12 @@ export function buildSystemPrompt(preset: Preset): string { ' ],', ' "stateChanges": [{"nation":"","stat":"","op":"","value":<0-100>}],', ' "relations": [{"a":"","b":"","set":""}],', - ' "territoryChanges": [{"nation":"","gain":[""],"lose":[""]}]', + ' "territoryChanges": [{"nation":"","gain":[""],"lose":[""]}]', '}', '', 'HARD CONSTRAINTS', - '- Use only nation ids and ISO_A3 codes that exist in the provided state.', + '- Use only nation ids and region keys that exist in the provided state.', + '- A region key identifies one sub-region; the "regions" table in the user message maps each key to " ()". Use those keys in territoryChanges.', '- Prefer 1-4 events per turn; keep each text 1-3 sentences.', '- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.', '- Keep every stat value between 0 and 100.', @@ -75,7 +76,11 @@ export function buildSystemPrompt(preset: Preset): string { * model to simulate the combined consequences of the whole batch (or to let the * world advance on its own when the batch is empty) and return one TurnResult. */ -export function buildUserPrompt(state: GameState, items: RoundItem[]): string { +export function buildUserPrompt( + state: GameState, + items: RoundItem[], + regionReference?: Record, +): string { const player = state.nations[state.playerNationId]; const playerName = player?.name ?? state.playerNationId; const playerActions = items.map((i) => @@ -88,7 +93,7 @@ export function buildUserPrompt(state: GameState, items: RoundItem[]): string { )}. Simulate the combined consequences across all nations and advance the world by one period. Return a single TurnResult.` : 'The player takes no action this period; let the world advance on its own momentum. Return a single TurnResult.'; - const payload = { + const payload: Record = { state: { turn: state.turn, date: state.date, @@ -110,6 +115,7 @@ export function buildUserPrompt(state: GameState, items: RoundItem[]): string { pending: items, roundInstruction, }; + if (regionReference) payload.regions = regionReference; return JSON.stringify(payload, null, 2); } @@ -283,7 +289,7 @@ function genericUnclearEvent(state: GameState): TurnEventSpec { * Apply a TurnResult to a GameState immutably: advance the date/turn, append * events, apply stat changes (clamped), symmetric relations, and territory moves. */ -export function applyTurnResult(state: GameState, result: TurnResult): GameState { +export function applyTurnResult(state: GameState, result: TurnResult, validKeys?: ReadonlySet): GameState { const next: GameState = JSON.parse(JSON.stringify(state)); next.version = (state.version ?? 0) + 1; // every mutation is a new revision const actingTurn = state.turn; // this action resolves the current turn @@ -309,15 +315,18 @@ export function applyTurnResult(state: GameState, result: TurnResult): GameState for (const t of result.territoryChanges) { const n = next.nations[t.nation]; if (!n) continue; - for (const code of t.gain) { + const valid = (k: string) => !validKeys || validKeys.has(k); + for (const key of t.gain) { + if (!valid(key)) continue; // ignore a key that is not a real region for (const other of Object.values(next.nations)) { - const i = other.territory.indexOf(code); + const i = other.territory.indexOf(key); if (i !== -1) other.territory.splice(i, 1); } - if (!n.territory.includes(code)) n.territory.push(code); + if (!n.territory.includes(key)) n.territory.push(key); } - for (const code of t.lose) { - const i = n.territory.indexOf(code); + for (const key of t.lose) { + if (!valid(key)) continue; + const i = n.territory.indexOf(key); if (i !== -1) n.territory.splice(i, 1); } } @@ -367,10 +376,14 @@ export async function runTurn(opts: { preset: Preset; state: GameState; items: RoundItem[]; + /** Region reference table (key -> "name (parent)") to include in the user prompt. */ + regionReference?: Record; + /** Set of valid region keys; territory changes with other keys are ignored. */ + validRegionKeys?: ReadonlySet; }): Promise<{ state: GameState; result: TurnResult }> { - const { llm, preset, state, items } = opts; + const { llm, preset, state, items, regionReference, validRegionKeys } = opts; const system = buildSystemPrompt(preset); - const user = buildUserPrompt(state, items); + const user = buildUserPrompt(state, items, regionReference); const nudge = 'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.'; @@ -398,7 +411,7 @@ export async function runTurn(opts: { }; } - return { state: applyTurnResult(state, result), result }; + return { state: applyTurnResult(state, result, validRegionKeys), result }; } // --- Advisor (brainstorm) ----------------------------------------------------- diff --git a/server/index.ts b/server/index.ts index 6ea59e3..171cae3 100644 --- a/server/index.ts +++ b/server/index.ts @@ -20,6 +20,7 @@ import { createLlmClient } from './llm'; import { makeStubLlmClient } from './stub'; import { askAdvisor, queueRoundItem, removeRoundItem, runTurn } from './engine'; import { buildRegionMap } from './map'; +import { allTerritoryKeys, expandCountriesToKeys, loadRegions, referenceForKeys, validKeys } from './regions'; import { createPresetStore } from './presets'; import { openDb, type Db } from './db'; @@ -49,6 +50,9 @@ const dataDir = process.env.DATA_DIR ?? path.join(root, 'data'); // the e2e config, so honor it as a fallback to keep all three in agreement. const PORT = parseInt(process.env.PORT ?? process.env.HISTORY_API_PORT ?? '4321', 10); const presetStore = createPresetStore(presetsDir); +// Load the region-key manifest + geo once at boot (v0.19 #16 P1). A missing file +// just means region expansion/reference degrade to the raw keys (server still boots). +loadRegions(root); const db: Db = openDb(path.join(dataDir, 'history.db')); // LLM_STUB=1 → deterministic stub (tests/e2e/dev without a live model). const useStub = process.env.LLM_STUB === '1'; @@ -121,6 +125,10 @@ app.post('/api/games', (req: Request, res) => { pending: [], version: 1, // the first saved revision; every later mutation bumps it }; + // v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys + // of all its sub-regions, so ownership (and the choropleth) is at region + // granularity from the very first turn. Preset JSONs stay country-level. + for (const n of Object.values(state.nations)) n.territory = expandCountriesToKeys(n.territory); db.save(state); res.json({ game: state }); }); @@ -227,6 +235,8 @@ app.post('/api/games/:id/jump', async (req, res) => { preset, state: game, items: game.pending, + regionReference: referenceForKeys(allTerritoryKeys(game)), + validRegionKeys: validKeys(), }); db.save(state); broadcast(id, 'turn', { state, result }); diff --git a/server/map.ts b/server/map.ts index df687e2..4491913 100644 --- a/server/map.ts +++ b/server/map.ts @@ -1,6 +1,8 @@ -// Build the choropleth payload from a GameState: every ISO_A3 region maps to its -// owning nation plus that nation's stable color (see shared.nationColor). The -// client merges this onto public/countries.geo.json and calls source.setData(). +// Build the choropleth payload from a GameState: every region key (see +// public/region-keys.json) maps to its owning nation plus that nation's stable +// color (see shared.nationColor). The client merges this onto the sub-national +// regions.geo.json (resolving each feature by its own region key) and calls +// source.setData(). Format-agnostic: it maps whatever keys sit in territory. import { nationColor, type RegionMap } from '../shared/protocol'; import type { GameState } from '../shared/types'; @@ -8,8 +10,8 @@ export function buildRegionMap(state: GameState): RegionMap { const map: RegionMap = {}; for (const [id, nation] of Object.entries(state.nations)) { const color = nationColor(id); - for (const code of nation.territory) { - map[code] = { color, owner: nation.name, ownerId: id }; + for (const key of nation.territory) { + map[key] = { color, owner: nation.name, ownerId: id }; } } return map; diff --git a/server/regions.ts b/server/regions.ts new file mode 100644 index 0000000..e5ab90a --- /dev/null +++ b/server/regions.ts @@ -0,0 +1,140 @@ +// Region-key data access (server). Loads the deterministic region-key manifest +// (public/region-keys.json: stable feature id -> region key) and the sub-national +// geo (public/regions.geo.json: feature id -> { name, parent }), then joins them +// into the lookups the region-level ownership model (v0.19, #16 P1) needs: +// +// - expand a nation's ISO_A3 country list -> the region keys of all its sub-regions +// - build the LLM "regions" reference table (key -> "name (parent ISO_A3)") +// - the set of all valid region keys (to ignore hallucinated keys in applyTurnResult) +// +// Deliberately turf-free: it reads the two JSON files with plain JSON.parse and +// never imports @turf/@types/geojson (importing one pulls @types/geojson into the +// tsc program and breaks WorldMap.tsx; see the v0.17 changelog note). Loaded once +// at boot; the accessors below are pure data reads thereafter. +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import type { GameState } from '../shared/types'; + +/** { name, parent } for one sub-region, where parent is the ISO_A3 country. */ +interface RegionInfo { + name: string; + parent: string; +} + +interface RegionData { + /** stable feature id -> region key (from public/region-keys.json). */ + idToKey: Record; + /** region key -> { name, parent }. */ + keyToInfo: Record; + /** ISO_A3 parent -> region keys of its sub-regions (file order, stable). */ + keysByCountry: Record; + /** the set of all region keys. */ + validKeys: Set; +} + +let data: RegionData | null = null; + +/** + * Load (or reload) the region-key manifest + geo from `root`. Idempotent. + * Returns true when both files loaded cleanly. A missing/broken file clears the + * data and returns false, so the server still boots and degrades gracefully + * (expansion becomes a no-op, ownership falls back to the raw input keys). + */ +export function loadRegions(root: string): boolean { + const keyPath = path.join(root, 'public', 'region-keys.json'); + const geoPath = path.join(root, 'public', 'regions.geo.json'); + if (!existsSync(keyPath) || !existsSync(geoPath)) { + data = null; + return false; + } + try { + const idToKey = JSON.parse(readFileSync(keyPath, 'utf8')) as Record; + const geo = JSON.parse(readFileSync(geoPath, 'utf8')) as { + features?: Array<{ id?: string | number; properties?: { name?: string; parent?: string } }>; + }; + const keyToInfo: Record = {}; + const keysByCountry: Record = {}; + const validKeys = new Set(); + for (const f of geo.features ?? []) { + const id = f.id == null ? '' : String(f.id); + const key = idToKey[id]; + if (!key) continue; // a geo feature with no manifest entry is skipped + const name = typeof f.properties?.name === 'string' ? f.properties.name : ''; + const parent = typeof f.properties?.parent === 'string' ? f.properties.parent : ''; + keyToInfo[key] = { name, parent }; + validKeys.add(key); + if (parent) (keysByCountry[parent] ??= []).push(key); + } + data = { idToKey, keyToInfo, keysByCountry, validKeys }; + return validKeys.size > 0; + } catch (err) { + console.error('[regions] failed to load region data', err); + data = null; + return false; + } +} + +/** Test/dev seam: drop the loaded data (the next accessors behave as "no data"). */ +export function resetRegions(): void { + data = null; +} + +/** True when the manifest + geo loaded cleanly. */ +export function hasRegionData(): boolean { + return data !== null; +} + +/** + * Expand a nation's ISO_A3 country list to the region keys of all its sub-regions + * (file order, deduped). This is what turns a preset's country-level territory + * into region-level ownership at game creation. Returns the input unchanged when + * no region data is loaded (graceful degradation — behaves like the old model). + */ +export function expandCountriesToKeys(countries: string[]): string[] { + if (!data) return countries; + const out: string[] = []; + const seen = new Set(); + for (const c of countries) { + for (const key of data.keysByCountry[c] ?? []) { + if (!seen.has(key)) { + seen.add(key); + out.push(key); + } + } + } + return out; +} + +/** + * Build the LLM reference table: region key -> " ()", e.g. + * `moselle-rygy -> "Moselle (FRA)"`. Only the given keys are included (the caller + * passes the game's owned-region universe) so the prompt stays bounded. + */ +export function referenceForKeys(keys: Iterable): Record { + const out: Record = {}; + if (!data) return out; + for (const key of keys) { + const info = data.keyToInfo[key]; + if (info) out[key] = info.parent ? `${info.name} (${info.parent})` : info.name; + } + return out; +} + +/** The region key's human name (for the dossier), or undefined if unknown. */ +export function regionName(key: string): string | undefined { + return data?.keyToInfo[key]?.name; +} + +/** The union of every nation's territory region keys (the game's region universe). */ +export function allTerritoryKeys(state: GameState): Set { + const out = new Set(); + for (const n of Object.values(state.nations)) { + for (const key of n.territory) out.add(key); + } + return out; +} + +/** The set of all valid region keys (to ignore hallucinated keys in applyTurnResult). */ +export function validKeys(): ReadonlySet { + return data ? data.validKeys : new Set(); +} diff --git a/shared/protocol.ts b/shared/protocol.ts index c9efdc8..1670ba8 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -23,7 +23,7 @@ export interface RelationChange { set: RelationStatus; } -/** A nation gains/loses ISO_A3 regions this turn. */ +/** A nation gains/loses region keys (see `public/region-keys.json`) this turn. */ export interface TerritoryChange { nation: string; gain: string[]; @@ -48,26 +48,32 @@ export interface TurnResult { territoryChanges: TerritoryChange[]; } -/** Per-region map payload: ISO_A3 -> owning nation + its stable color. */ +/** Per-region map payload: region key -> owning nation + its stable color. */ export interface RegionOwner { color: string; owner: string; ownerId: string; } +/** + * The authoritative ownership map: a **region key** (see `public/region-keys.json`) + * -> the owning nation + its stable color. Owned at sub-region granularity (v0.19, + * #16 P1); a key absent from this map is unowned (rendered grey). + */ export type RegionMap = Record; /** * Look up the owner record from the authoritative RegionMap by a RegionMap key. - * The RegionMap is keyed by the **parent country's** ISO_A3 code (ownership and - * the LLM stay at country granularity — see #16), so `key` is a country code such - * as `DEU`, never a sub-region id such as `DEU-03`. Returns `undefined` for an - * empty/missing key or an unowned country. + * The RegionMap is keyed by **region key** (see `public/region-keys.json`) — one + * per owned sub-region (v0.19, #16 P1) — so `key` is a region key such as + * `moselle-rygy`, never a raw feature id such as `FR-57` and never a + * parent-country ISO_A3. Returns `undefined` for an empty/missing key or an + * unowned region. * * Pure + shared: the primitive "key → owner" lookup. The *map feature → owner* * resolution (deciding which key to pass) lives in {@link mapFeatureOwner} below, - * which reads the feature's `properties.parent` — never the repacked top-level - * `id` (MapLibre turns it into a numeric index, the "no owner" bug #6) and never - * the sub-region id. + * which reads the feature's stamped `properties.regionKey` — never the repacked + * top-level `id` (MapLibre turns it into a numeric index, the "no owner" bug #6) + * and never the sub-region id. */ export function regionOwner(regions: RegionMap, key: string | null | undefined): RegionOwner | undefined { if (!key) return undefined; @@ -76,22 +82,23 @@ export function regionOwner(regions: RegionMap, key: string | null | undefined): /** * Resolve the owning-nation record for a **rendered map feature** from the - * authoritative RegionMap. The choropleth renders sub-national regions, but the - * RegionMap is keyed by the *parent country's* ISO_A3 (not the sub-region's own - * id), so a feature's owner is found via its `properties.parent` (the ISO_A3 the - * client stamps from the GeoJSON `parent` property — see WorldMap) and then - * `regionOwner`. This is the single shared source of truth for that resolution, - * so the "use `parent`, never the sub-region `id`/repacked `feature.id`" rule - * lives in one tested place rather than in the React component. + * authoritative RegionMap. The choropleth renders sub-national regions, and the + * RegionMap is now keyed by **region key** (one per sub-region, v0.19 #16 P1), + * so a feature's owner is found via its `properties.regionKey` — the region key + * the client stamps from `public/region-keys.json` by the feature's stable id + * (see WorldMap) — and then `regionOwner`. This is the single shared source of + * truth for that resolution, so the "use `regionKey`, never the sub-region `id` + * / repacked `feature.id`" rule lives in one tested place rather than in the + * React component. * - * Returns `undefined` for a missing feature/parent or an unowned country. + * Returns `undefined` for a missing feature/regionKey or an unowned region. */ export function mapFeatureOwner( regions: RegionMap, - feature: { properties?: { parent?: unknown } } | null | undefined, + feature: { properties?: { regionKey?: unknown } } | null | undefined, ): RegionOwner | undefined { - const parent = feature?.properties?.parent; - return regionOwner(regions, typeof parent === 'string' ? parent : ''); + const regionKey = feature?.properties?.regionKey; + return regionOwner(regions, typeof regionKey === 'string' ? regionKey : ''); } /** Server -> client Server-Sent Events payload (see server/index.ts /sse). */ diff --git a/shared/types.ts b/shared/types.ts index 90a2db4..fd84eed 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -26,8 +26,10 @@ export const RELATION_STATUSES: RelationStatus[] = [ ]; /** - * A single nation in the world. `territory` holds ISO_A3 codes; the map colors - * each of those regions with this nation's stable color (see protocol.nationColor). + * A single nation in the world. `territory` holds **region keys** (see + * `public/region-keys.json`) — one per owned sub-region, so the choropleth can + * color individual sub-regions, not whole countries (v0.19, #16 P1). The map + * resolves each sub-region's owner by its region key (see protocol.mapFeatureOwner). */ export interface Nation { id: string; From 29927bae34580717d9da5477590aca8f6d8198fc Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Wed, 14 Oct 2026 09:14:17 +0000 Subject: [PATCH 4/6] Flip the client to region-key resolution + region names in the dossier (v0.19 P1 of #16) - src/lib/regions.ts (new): cached, turf-free same-origin fetches of /region-keys.json (feature id -> key) and /regions.geo.json (id -> name) so WorldMap and NationPanel share one fetch each. - WorldMap.tsx: stamps each feature's properties.regionKey (from the manifest, by its stable id) and resolves owner / flash / hover / click from that key via the shared mapFeatureOwner; the per-region flash lights the exact sub-region whose owner changed; click carries only the sub-region name. - NationPanel.tsx: territory chips show the region's human name (falling back to the raw key) with the key in the title / data-attr. - App.tsx: the selected-region dossier label drops the now-unused parent code. --- src/App.tsx | 9 +- src/components/NationPanel.tsx | 42 +++++-- src/components/WorldMap.tsx | 193 +++++++++++++++++++-------------- src/lib/regions.ts | 46 ++++++++ 4 files changed, 194 insertions(+), 96 deletions(-) create mode 100644 src/lib/regions.ts diff --git a/src/App.tsx b/src/App.tsx index a704bf4..7ba8a3e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -591,12 +591,11 @@ function GameScreen({ const [viewingId, setViewingId] = useState(game.playerNationId); // When the dossier target was set by a map click, remember which sub-region // was clicked, for a small label in the dossier. The label shows only the - // region's name — the static parent-country code (which never updates on a - // transfer) is no longer displayed, though the info object still carries it. - // Cleared when the user picks a nation via the manual Inspect select. - const [selectedRegion, setSelectedRegion] = useState<{ parent?: string; name?: string } | null>(null); + // region's name. Cleared when the user picks a nation via the manual Inspect + // select. + const [selectedRegion, setSelectedRegion] = useState<{ name?: string } | null>(null); const focusNation = useCallback( - (id: string, info?: { parent?: string; name?: string }) => { + (id: string, info?: { name?: string }) => { setViewingId(id); setSelectedRegion(info ?? null); }, diff --git a/src/components/NationPanel.tsx b/src/components/NationPanel.tsx index e3dd212..ec326ec 100644 --- a/src/components/NationPanel.tsx +++ b/src/components/NationPanel.tsx @@ -1,8 +1,10 @@ +import { useEffect, useState } from 'react'; import { Badge } from './ui/badge'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card'; import StatBar from './StatBar'; import { nationColor } from '@shared/protocol'; import { RELATION_COLORS } from '@/lib/colors'; +import { keyToName } from '@/lib/regions'; import type { Nation, RelationStatus } from '@shared/types'; /** Human labels for a diplomatic status (the raw value is kept in data-attrs). */ @@ -38,6 +40,23 @@ export default function NationPanel({ nation, nations, isPlayer, inWindow = fals const relations = Object.entries(nation.relations).sort(([a], [b]) => a.localeCompare(b)); const regions = nation.territory; + // Human region names for the territory chips (region key -> name), loaded once + // (cached in @/lib/regions). A key with no known name falls back to the raw key. + const [keyNames, setKeyNames] = useState>({}); + useEffect(() => { + let cancelled = false; + keyToName() + .then((m) => { + if (!cancelled) setKeyNames(m); + }) + .catch(() => { + /* names are cosmetic — a failed fetch just leaves the raw keys */ + }); + return () => { + cancelled = true; + }; + }, []); + const identity = (
{regions.length > 0 ? (
- {regions.map((code) => ( - - {code} - - ))} + {regions.map((key) => { + const label = keyNames[key] ?? key; + return ( + + {label} + + ); + })}
) : (

No territory.

diff --git a/src/components/WorldMap.tsx b/src/components/WorldMap.tsx index 30b407e..67e7c38 100644 --- a/src/components/WorldMap.tsx +++ b/src/components/WorldMap.tsx @@ -11,6 +11,7 @@ maplibregl.setWorkerUrl(workerUrl); import { mapFeatureOwner, type RegionMap, type RegionOwner } from '@shared/protocol'; import type { Nation } from '@shared/types'; import { cn } from '@/lib/utils'; +import { idToKey } from '@/lib/regions'; // Minimal GeoJSON types (avoids a hard @types/geojson dependency). The choropleth // source is the sub-national `regions.geo.json`: each feature is a sub-region with @@ -101,26 +102,32 @@ function cameraForPreset(presetId: string): { center: [number, number]; zoom: nu /** * Stamp the current ownership + colors onto a clone of the sub-national base geo. - * Ownership and the LLM stay at **country** granularity (#16, PR 2): a nation - * owns whole countries, so every sub-region of a country shares that country's - * owning nation's color — the map looks like the old country choropleth, but - * subdivided into finer cells. Each sub-region therefore carries: + * Ownership is at **region-key** granularity (v0.19, #16 P1): each sub-region has + * its own region key (from `public/region-keys.json`, looked up by the feature's + * stable id) and the RegionMap is keyed by that region key, so a single + * sub-region can change owner while its neighbours keep theirs. Each feature + * therefore carries: * * - `regionId` – its own sub-region id (e.g. `FRA-03`), kept in a property that * survives into the features MapLibre hands back on click/hover * (the top-level `id` is repacked into a numeric index, #6); - * - `parent` – the parent country's ISO_A3 (e.g. `FRA`), the RegionMap key; - * - `fill` – the owning nation's stable color (from `RegionMap[parent]`); - * - `owner`/`ownerId` – that nation's name/id (empty for an unowned country); + * - `regionKey` – the region key (e.g. `moselle-rygy`), the RegionMap key; + * - `fill` – the owning nation's stable color (from `RegionMap[regionKey]`); + * - `owner`/`ownerId` – that nation's name/id (empty for an unowned region); * - `stroke` – a per-border alpha (owned brighter than unowned); * - `isPlayer` – 1 when the owning nation is the player (drives the amber * player-highlight border). * - * Crucially, the owner lookup keys the RegionMap by `properties.parent` (the - * country ISO_A3), **never** by the sub-region id — the RegionMap has no - * `FRA-03` entries, only `FRA`. + * Crucially, the owner lookup keys the RegionMap by the stamped + * `properties.regionKey`, **never** by the sub-region id or the repacked + * top-level `id` — the RegionMap has no `FRA-03` entries, only region keys. */ -function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId: string): FeatureCollection { +function applyRegions( + geo: FeatureCollection, + regions: RegionMap, + playerNationId: string, + idKey: Record, +): FeatureCollection { const out: FeatureCollection = JSON.parse(JSON.stringify(geo)); for (const f of out.features) { f.properties = f.properties ?? {}; @@ -128,11 +135,11 @@ function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId // survives into the rendered features (the top-level `id` gets repacked). const subId = f.id == null ? '' : String(f.id); f.properties.regionId = subId; - // Ownership lives at the parent country: resolve `properties.parent` - // (the ISO_A3) against the RegionMap. Never the sub-region id. - const parent = typeof f.properties.parent === 'string' ? f.properties.parent : ''; - f.properties.parent = parent; - const r = parent ? regions[parent] : undefined; + // Resolve this feature's **region key** from the manifest (id -> key) and + // key the RegionMap by it — never the sub-region id or the repacked id. + const key = idKey[subId] ?? ''; + f.properties.regionKey = key; + const r = key ? regions[key] : undefined; if (r) { f.properties.fill = r.color; f.properties.owner = r.owner; @@ -151,16 +158,21 @@ function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId } /** - * Build a FeatureCollection of just the sub-regions whose **parent** country is - * in `parents` (cloned). Used by the territory-change flash: a turn changes a - * whole *country* (the RegionMap is ISO_A3-keyed), so a changed country's code - * must light up **all** of its sub-regions, not a single feature. + * Build a FeatureCollection of just the sub-regions whose **region key** is in + * `keys` (cloned). Used by the territory-change flash: a turn can change + * individual sub-regions (the RegionMap is region-keyed, v0.19 #16 P1), so each + * changed region key lights up exactly the sub-region that owns it. */ -function featuresByParents(geo: FeatureCollection, parents: Iterable): FeatureCollection { - const set = new Set(parents); +function featuresByRegionKeys( + geo: FeatureCollection, + keys: Iterable, + idKey: Record, +): FeatureCollection { + const set = new Set(keys); const features = geo.features.filter((f) => { - const parent = f.properties?.parent; - return typeof parent === 'string' && set.has(parent); + const id = f.id == null ? '' : String(f.id); + const key = idKey[id] ?? ''; + return key !== '' && set.has(key); }); return { type: 'FeatureCollection', features: JSON.parse(JSON.stringify(features)) }; } @@ -203,37 +215,36 @@ interface Props { className?: string; /** * Called with a region's owning-nation id when a sub-region is clicked, so the - * dossier switches to that nation. For a sub-region, the owner is the parent - * country's owning nation. The optional `info` carries the clicked sub-region's - * own id/name and its parent country's ISO_A3, for a small "which region" label - * in the dossier. Not called for unowned regions (the dossier keeps its - * current target). See the component doc for the full behavior. + * dossier switches to that nation. The optional `info` carries the clicked + * sub-region's name, for a small "which region" label in the dossier. Not + * called for unowned regions (the dossier keeps its current target). See the + * component doc for the full behavior. */ - onFocusNation?: (id: string, info?: { parent?: string; name?: string }) => void; + onFocusNation?: (id: string, info?: { name?: string }) => void; } /** * World basemap (MapLibre GL) + a **sub-national** choropleth. The map renders * the finer `regions.geo.json` dataset: each country is subdivided into * ~100–200 km sub-regions, drawn as a finer choropleth with thin internal border - * lines. Ownership and the LLM stay at **country** granularity (#16, PR 2): a - * nation owns whole countries, so all of a country's sub-regions share that - * country's owning-nation color. Rendered as a fixed, full-viewport background - * layer (z-0) behind the floating HUD windows: a dark-navy basemap with subtle - * terrain, per-nation stable fills (uniform per country, the choropleth is the - * star), thin sub-region borders, a brighter/thicker amber border for the - * player's countries, a hover tooltip (sub-region name + current owner - * + key stats), a **click that switches the dossier to the sub-region's parent - * country's owning nation** (no popup — see #7), a transient flash when a - * country's owner changes (lighting up all its sub-regions), and a per-scenario - * initial camera. + * lines. Ownership is at **region-key** granularity (v0.19, #16 P1): each + * sub-region has its own region key (from `public/region-keys.json`, looked up + * by the feature's stable id) and the RegionMap is keyed by that key, so a + * single sub-region can change owner independently of its neighbours. Rendered + * as a fixed, full-viewport background layer (z-0) behind the floating HUD + * windows: a dark-navy basemap with subtle terrain, per-nation stable fills, + * thin sub-region borders, a brighter/thicker amber border for the player's + * regions, a hover tooltip (sub-region name + current owner + key stats), a + * **click that switches the dossier to the sub-region's owning nation** (no + * popup — see #7), a transient flash when a sub-region's owner changes, and a + * per-scenario initial camera. * * Ownership for a hovered/clicked sub-region is resolved from the authoritative - * RegionMap by its **parent country's** ISO_A3 code (`properties.parent`, via + * RegionMap by its stamped **region key** (`properties.regionKey`, via * `mapFeatureOwner` in @shared/protocol) — never from the feature's top-level * `id` (MapLibre repacks it into a numeric index at interaction time, #6) and - * never from the sub-region id (not a RegionMap key). Unowned countries (no - * RegionMap entry for the parent) simply do not change the dossier. + * never from the raw sub-region id (not a RegionMap key). Unowned sub-regions + * (no RegionMap entry for their key) simply do not change the dossier. * * Zoom and pan are driven directly by the user (MapLibre wheel/trackpad + drag) * — no floating control and no external API. @@ -249,6 +260,9 @@ export default function WorldMap({ const containerRef = useRef(null); const mapRef = useRef(null); const baseGeoRef = useRef(null); + // The region-key manifest (feature id -> region key), loaded once; used to + // stamp each feature's region key and to expand the flash to its sub-region. + const idKeyRef = useRef | null>(null); // Latest props, so the mount effect's (re)establish closure always sees them. const regionsRef = useRef(regions); const nationsRef = useRef(nations); @@ -304,17 +318,28 @@ export default function WorldMap({ return g; }); + // The region-key manifest (feature id -> region key), fetched once and + // cached in a ref so both the paint path and the flash can resolve keys. + const loadIdKey = (): Promise> => + idKeyRef.current + ? Promise.resolve(idKeyRef.current) + : idToKey().then((k) => { + idKeyRef.current = k; + return k; + }); + const emptyFC = (): FeatureCollection => ({ type: 'FeatureCollection', features: [] }); // Transient flash: pulse a bright outline over the changed regions, then fade // to zero over a few seconds and self-clean. Idempotent per call. `codes` are - // parent-country ISO_A3 codes (a turn changes whole countries); each lights - // up all of that country's sub-regions. + // region keys (a turn can change individual sub-regions, v0.19 #16 P1); each + // lights up exactly the sub-region that owns that key. function animateFlash(codes: string[]) { const geo = baseGeoRef.current; + const idKey = idKeyRef.current; const src = map.getSource(FLASH_SOURCE) as GeoSource | undefined; - if (!geo || !src || cancelled) return; - src.setData(featuresByParents(geo, codes)); + if (!geo || !idKey || !src || cancelled) return; + src.setData(featuresByRegionKeys(geo, codes, idKey)); if (!map.getLayer(FLASH_LINE)) { // Defensive re-add (normally created by ensureLayers). Keep it beneath the // hover highlight when that layer exists; otherwise just append. @@ -350,9 +375,9 @@ export default function WorldMap({ // The sub-region a hovered/clicked feature belongs to is identified by its // stable sub-region id in `properties.regionId` (stamped by `applyRegions`), // and its **owner** is resolved from the authoritative RegionMap via the - // feature's `properties.parent` (the parent country's ISO_A3) through + // feature's stamped `properties.regionKey` through // `mapFeatureOwner` — never from the repacked top-level `id` (a numeric - // index, #6) and never from the sub-region id (not a RegionMap key). + // index, #6) and never from the raw sub-region id (not a RegionMap key). const onHover = (e: maplibregl.MapLayerMouseEvent) => { const f = e.features?.[0]; if (!f) return; @@ -386,23 +411,19 @@ export default function WorldMap({ }; // #7: a click does NOT open a popup — it switches the dossier to the region's - // owning nation. For a sub-region, the owner is the **parent country's** - // owning nation, resolved from `properties.parent` (the ISO_A3) via the - // RegionMap (never the repacked top-level `f.id`, #6, and never the - // sub-region id). A sub-region whose parent country has no owner leaves the - // current dossier target unchanged — never a broken dossier. + // owning nation, resolved from the feature's stamped `properties.regionKey` + // via the RegionMap (never the repacked top-level `f.id`, #6, and never the + // raw sub-region id). A sub-region whose key has no owner leaves the current + // dossier target unchanged — never a broken dossier. const onClick = (e: maplibregl.MapLayerMouseEvent) => { const f = e.features?.[0]; if (!f) return; const region = mapFeatureOwner(regionsRef.current, f); if (!region?.ownerId) return; - // Carry the clicked sub-region's identity (own id/name + parent ISO_A3) so - // the dossier can show which region was selected. - const p = f.properties as { regionId?: string; parent?: string; name?: string }; - const info = { - parent: typeof p?.parent === 'string' ? p.parent : undefined, - name: typeof p?.name === 'string' ? p.name : undefined, - }; + // Carry the clicked sub-region's name so the dossier can show which region + // was selected. + const p = f.properties as { name?: string }; + const info = { name: typeof p?.name === 'string' ? p.name : undefined }; onFocusNationRef.current?.(region.ownerId, info); }; @@ -430,34 +451,40 @@ export default function WorldMap({ } }; - // Stamp ownership + colors onto the regions source (no-op until geo loads). + // Stamp ownership + colors onto the regions source (no-op until both the + // base geo and the region-key manifest are loaded). const paintRegions = () => { const geo = baseGeoRef.current; + const idKey = idKeyRef.current; const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined; - if (!geo || !src) return; - src.setData(applyRegions(geo, regionsRef.current, playerRef.current)); + if (!geo || !idKey || !src) return; + src.setData(applyRegions(geo, regionsRef.current, playerRef.current, idKey)); }; const ensureLayers = async () => { if (cancelled) return; ensureSources(); - if (baseGeoRef.current) { - paintRegions(); - } else { + if (!baseGeoRef.current) { try { await loadGeo(); } catch (err) { console.error('failed to load regions geojson', err); } - if (cancelled) return; - paintRegions(); } + if (!idKeyRef.current) { + try { + await loadIdKey(); + } catch (err) { + console.error('failed to load region keys', err); + } + } + if (cancelled) return; + paintRegions(); if (!map.getLayer(FILL_LAYER)) { // The choropleth is the star: a high opacity so the vivid per-nation // colors read clearly even on the dark background / over the terrain. - // Every sub-region carries its parent country's owner color, so a whole - // country reads as one uniform fill — same as the old country map, but - // subdivided. + // Each sub-region carries its own owning nation's color (v0.19 #16 P1), + // so the fill traces region-key ownership, not country borders. map.addLayer({ id: FILL_LAYER, type: 'fill', @@ -537,28 +564,30 @@ export default function WorldMap({ }; }, []); - // Re-color the choropleth whenever ownership changes, and flash any country - // (all of its sub-regions) whose owner changed since the last update (a - // transient, self-cleaning pulse). The diff is over the ISO_A3-keyed RegionMap; - // `animateFlash` expands each changed country to every one of its sub-regions. + // Re-color the choropleth whenever ownership changes, and flash each + // sub-region whose owner changed since the last update (a transient, + // self-cleaning pulse). The diff is over the region-keyed RegionMap (v0.19 + // #16 P1); `animateFlash` lights up exactly the sub-region for each changed + // key. useEffect(() => { const map = mapRef.current; const geo = baseGeoRef.current; + const idKey = idKeyRef.current; const cur = regions; const prev = prevRegionsRef.current; prevRegionsRef.current = cur; const wasPrimed = primedRef.current; if (Object.keys(cur).length > 0) primedRef.current = true; - if (map && geo) { + if (map && geo && idKey) { const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined; - if (src) void src.setData(applyRegions(geo, cur, playerNationId)); + if (src) void src.setData(applyRegions(geo, cur, playerNationId, idKey)); } - if (wasPrimed && prev) { + if (wasPrimed && prev && idKey) { const changed = new Set(); - for (const code of new Set([...Object.keys(prev), ...Object.keys(cur)])) { - if ((prev[code]?.ownerId ?? '') !== (cur[code]?.ownerId ?? '')) changed.add(code); + for (const key of new Set([...Object.keys(prev), ...Object.keys(cur)])) { + if ((prev[key]?.ownerId ?? '') !== (cur[key]?.ownerId ?? '')) changed.add(key); } if (changed.size > 0) flashApiRef.current?.([...changed]); } diff --git a/src/lib/regions.ts b/src/lib/regions.ts new file mode 100644 index 0000000..2c77296 --- /dev/null +++ b/src/lib/regions.ts @@ -0,0 +1,46 @@ +// Client-side region-key data access. Fetches the two static JSON files served +// from public/ and builds the lookups the region-level UI needs: +// - idToKey(): feature id -> region key (to stamp each map feature + resolve owner) +// - keyToName(): region key -> human name (to show region names in the dossier) +// Each is fetched once and cached (Promise singleton) so WorldMap and NationPanel +// share a single fetch. Same-origin relative URLs (proxy/Traefik safe) — never a +// hardcoded origin. +type GeoLike = { + features?: Array<{ id?: string | number; properties?: { name?: string } }>; +}; + +let idKeyPromise: Promise> | null = null; +let keyNamePromise: Promise> | null = null; + +async function fetchJson(url: string): Promise { + const r = await fetch(url); + if (!r.ok) throw new Error(`${url} → HTTP ${r.status}`); + return (await r.json()) as T; +} + +/** feature id (from regions.geo.json) -> region key (from region-keys.json). */ +export function idToKey(): Promise> { + idKeyPromise ??= fetchJson>('/region-keys.json'); + return idKeyPromise; +} + +/** + * region key -> human region name. Built by joining region-keys.json (id -> key) + * with regions.geo.json (id -> properties.name). A key with no known name is + * simply absent (callers fall back to the raw key). + */ +export function keyToName(): Promise> { + keyNamePromise ??= Promise.all([idToKey(), fetchJson('/regions.geo.json')]).then( + ([keys, geo]) => { + const out: Record = {}; + for (const f of geo.features ?? []) { + const id = f.id == null ? '' : String(f.id); + const key = keys[id]; + const name = f.properties?.name; + if (key && typeof name === 'string') out[key] = name; + } + return out; + }, + ); + return keyNamePromise; +} From 1c64aa1da4cc769c6ab8075d01083fd88ddea6ca Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Wed, 14 Oct 2026 09:14:24 +0000 Subject: [PATCH 5/6] Tests: region-key contract (map rewrite + regions loader + engine) (v0.19 P1 of #16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - map.test.ts: rewrite the fixture for region keys; the #6 regression guard now proves the owner resolves via properties.regionKey while the repacked numeric feature.id and the sub-region regionId do not. - regions.test.ts (new): the loader sees 1,796 valid keys; FRA expands to its 42 sub-region keys (all valid, all labelled '… (FRA)'); allTerritoryKeys unions correctly; and with no data it degrades to the raw input. - engine.test.ts: buildUserPrompt includes a regions table only when provided; applyTurnResult with a validKeys set applies real keys and ignores bogus ones, and without one applies all (the legacy/unit path). --- server/engine.test.ts | 46 ++++++++++++++++++ server/map.test.ts | 75 ++++++++++++++++-------------- server/regions.test.ts | 103 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 189 insertions(+), 35 deletions(-) create mode 100644 server/regions.test.ts diff --git a/server/engine.test.ts b/server/engine.test.ts index a7f55f6..f7f3c0c 100644 --- a/server/engine.test.ts +++ b/server/engine.test.ts @@ -318,6 +318,17 @@ describe('buildUserPrompt (batched round)', () => { it('asks the model to keep its internal reasoning brief (concise-CoT constraint)', () => { expect(buildSystemPrompt(preset)).toMatch(/reasoning brief|a few sentences/i); }); + + it('includes a regions reference table only when one is provided', () => { + // with a reference table: the payload carries it (region key -> "name (parent)") + const withRef = JSON.parse( + buildUserPrompt(baseState(), [], { 'ain-lqtt': 'Ain (FRA)', 'wales-bbbb': 'Wales (GBR)' }), + ) as { regions?: Record }; + expect(withRef.regions).toEqual({ 'ain-lqtt': 'Ain (FRA)', 'wales-bbbb': 'Wales (GBR)' }); + // without one: the field is absent entirely (the legacy prompt shape is unchanged) + const withoutRef = JSON.parse(buildUserPrompt(baseState(), [])) as { regions?: Record }; + expect(withoutRef.regions).toBeUndefined(); + }); }); describe('queueRoundItem', () => { @@ -461,6 +472,41 @@ describe('applyTurnResult', () => { expect(state.pending).toHaveLength(1); // original untouched expect(state.version).toBe(1); }); + + it('with a validKeys set: applies real region keys, ignores hallucinated ones', () => { + const state = baseState(); + const result: TurnResult = { + date: '1939-09-03', + events: [], + stateChanges: [], + relations: [], + // 'wales-bbbb' is a real key (owned by uk in the fixture); 'not-a-region-xyz' + // is not a real key at all. Both are proposed as a gain by DE. + territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }], + }; + const next = applyTurnResult(state, result, new Set(['wales-bbbb'])); + // the valid key was gained (stolen from its owner); the bogus one was ignored + expect(next.nations.de.territory).toContain('wales-bbbb'); + expect(next.nations.uk.territory).not.toContain('wales-bbbb'); + // the bogus key never entered any nation's territory + for (const n of Object.values(next.nations)) { + expect(n.territory).not.toContain('not-a-region-xyz'); + } + }); + + it('without a validKeys set: applies every listed key (legacy/unit path)', () => { + const state = baseState(); + const result: TurnResult = { + date: '1939-09-03', + events: [], + stateChanges: [], + relations: [], + territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }], + }; + const next = applyTurnResult(state, result); // no validKeys -> nothing filtered + expect(next.nations.de.territory).toContain('POL'); + expect(next.nations.de.territory).toContain('wales-bbbb'); + }); }); describe('isMeaningful', () => { diff --git a/server/map.test.ts b/server/map.test.ts index 31338ae..d61bccb 100644 --- a/server/map.test.ts +++ b/server/map.test.ts @@ -3,6 +3,10 @@ import { buildRegionMap } from './map'; import { mapFeatureOwner, nationColor, regionOwner, type RegionMap } from '../shared/protocol'; import type { GameState } from '../shared/types'; +// Region-key fixtures (v0.19, #16 P1): ownership and the RegionMap are now keyed +// by region key (one per owned sub-region), not by a parent-country ISO_A3. The +// keys below are deliberately fake (obviously not real manifest entries) so this +// test stays self-contained and format-agnostic. const state: GameState = { id: 'g', presetId: 'p', @@ -16,7 +20,7 @@ const state: GameState = { name: 'UK', type: 'major', stats: { military: 80, economy: 70, stability: 60, population: 50 }, - territory: ['GBR', 'IRL'], + territory: ['albania-aaaa', 'wales-bbbb'], relations: {}, }, de: { @@ -24,7 +28,7 @@ const state: GameState = { name: 'DE', type: 'major', stats: { military: 90, economy: 70, stability: 70, population: 90 }, - territory: ['DEU'], + territory: ['brandenburg-cccc'], relations: {}, }, }, @@ -33,28 +37,28 @@ const state: GameState = { version: 1, }; -// The RegionMap built from the fixture: the authoritative ISO_A3 → owner source. +// The RegionMap built from the fixture: the authoritative region key → owner source. const regions: RegionMap = buildRegionMap(state); describe('buildRegionMap', () => { - it('maps each ISO_A3 region to its owner + that nation\'s stable color', () => { + it('maps each owned region key to its owner + that nation\'s stable color', () => { const m = buildRegionMap(state); - expect(m.GBR).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' }); - expect(m.IRL).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' }); - expect(m.DEU).toEqual({ color: nationColor('de'), owner: 'DE', ownerId: 'de' }); + expect(m['albania-aaaa']).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' }); + expect(m['wales-bbbb']).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' }); + expect(m['brandenburg-cccc']).toEqual({ color: nationColor('de'), owner: 'DE', ownerId: 'de' }); // a region nobody owns is simply absent - expect(m.FRA).toBeUndefined(); + expect(m['normandie-dddd']).toBeUndefined(); }); }); describe('regionOwner', () => { - it('resolves an owned region\'s ISO_A3 to its owner record', () => { - expect(regionOwner(regions, 'GBR')).toEqual({ + it('resolves an owned region key to its owner record', () => { + expect(regionOwner(regions, 'albania-aaaa')).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk', }); - expect(regionOwner(regions, 'DEU')).toEqual({ + expect(regionOwner(regions, 'brandenburg-cccc')).toEqual({ color: nationColor('de'), owner: 'DE', ownerId: 'de', @@ -62,11 +66,11 @@ describe('regionOwner', () => { }); it('returns undefined for an unowned region (no RegionMap entry)', () => { - expect(regionOwner(regions, 'FRA')).toBeUndefined(); - expect(regionOwner(regions, 'USA')).toBeUndefined(); + expect(regionOwner(regions, 'normandie-dddd')).toBeUndefined(); + expect(regionOwner(regions, 'yorkshire-eeee')).toBeUndefined(); }); - it('returns undefined for an empty or missing code', () => { + it('returns undefined for an empty or missing key', () => { expect(regionOwner(regions, '')).toBeUndefined(); expect(regionOwner(regions, null)).toBeUndefined(); expect(regionOwner(regions, undefined)).toBeUndefined(); @@ -74,28 +78,29 @@ describe('regionOwner', () => { }); /** - * #6 regression guard (sub-national map). The click/hover handlers resolve the - * owning nation from a rendered map feature. MapLibre repacks a GeoJSON - * source's top-level `id` into a *numeric index* when it stores the source, so a - * feature the map hands back at interaction time has a number for `id`. The - * choropleth is now sub-national: a feature's id is a sub-region code - * (`DEU-03`) and its owner lives at the *parent country* (`DEU`), which the - * client stamps into `properties.parent` (the RegionMap is keyed by that - * country code, never the sub-region id). This pins the exact data shape and - * proves the resolution: `mapFeatureOwner` works via `properties.parent`, while - * the two wrong paths (the repacked numeric `feature.id`, and the sub-region - * `regionId`, which is not a RegionMap key) do not. + * #6 regression guard (sub-national map, region-keyed). The click/hover handlers + * resolve the owning nation from a rendered map feature. MapLibre repacks a + * GeoJSON source's top-level `id` into a *numeric index* when it stores the + * source, so a feature the map hands back at interaction time has a number for + * `id`. The choropleth is sub-national: a feature's own sub-region id is a code + * (`DEU-03`), and its owner now lives at its **region key** (`brandenburg-cccc`), + * which the client stamps into `properties.regionKey` from `region-keys.json` + * (the RegionMap is keyed by that key, never the sub-region id nor the repacked + * id). This pins the exact data shape and proves the resolution: + * `mapFeatureOwner` works via `properties.regionKey`, while the two wrong paths + * (the repacked numeric `feature.id`, and the sub-region `regionId`, which is + * not a RegionMap key) do not. */ -describe('#6 — sub-region owner must be resolved from properties.parent, not the sub-region id / repacked feature.id', () => { +describe('#6 — sub-region owner must be resolved from properties.regionKey, not the sub-region id / repacked feature.id', () => { // The shape MapLibre returns for a sub-region feature after setData(): `id` // is a number (repacked), and the stable identifiers live only in properties — - // `regionId` (the sub-region code) and `parent` (the ISO_A3 country). + // `regionId` (the sub-region code) and `regionKey` (the region key). const repackedFeature = { id: 0, - properties: { name: 'Germany R03', regionId: 'DEU-03', parent: 'DEU' }, + properties: { name: 'Brandenburg R03', regionId: 'DEU-03', regionKey: 'brandenburg-cccc' }, }; - it('resolves the owner from properties.parent (the fix)', () => { + it('resolves the owner from properties.regionKey (the fix)', () => { expect(mapFeatureOwner(regions, repackedFeature)).toEqual({ color: nationColor('de'), owner: 'DE', @@ -104,7 +109,7 @@ describe('#6 — sub-region owner must be resolved from properties.parent, not t }); it('the sub-region regionId is NOT a RegionMap key — resolving from it fails', () => { - // 'DEU-03' is a sub-region id; the RegionMap is keyed by the parent 'DEU'. + // 'DEU-03' is a sub-region id; the RegionMap is keyed by region key. expect(regionOwner(regions, repackedFeature.properties.regionId)).toBeUndefined(); }); @@ -116,17 +121,17 @@ describe('#6 — sub-region owner must be resolved from properties.parent, not t }); describe('mapFeatureOwner', () => { - it('returns undefined for a missing feature, missing parent, or non-string parent', () => { + it('returns undefined for a missing feature, missing regionKey, or non-string regionKey', () => { expect(mapFeatureOwner(regions, null)).toBeUndefined(); expect(mapFeatureOwner(regions, undefined)).toBeUndefined(); expect(mapFeatureOwner(regions, {})).toBeUndefined(); expect(mapFeatureOwner(regions, { properties: {} })).toBeUndefined(); - expect(mapFeatureOwner(regions, { properties: { parent: 42 } })).toBeUndefined(); + expect(mapFeatureOwner(regions, { properties: { regionKey: 42 } })).toBeUndefined(); }); - it('returns undefined for a parent country nobody owns', () => { - expect(mapFeatureOwner(regions, { properties: { parent: 'FRA' } })).toBeUndefined(); - expect(mapFeatureOwner(regions, { properties: { parent: 'USA' } })).toBeUndefined(); + it('returns undefined for a region key nobody owns', () => { + expect(mapFeatureOwner(regions, { properties: { regionKey: 'normandie-dddd' } })).toBeUndefined(); + expect(mapFeatureOwner(regions, { properties: { regionKey: 'yorkshire-eeee' } })).toBeUndefined(); }); }); diff --git a/server/regions.test.ts b/server/regions.test.ts new file mode 100644 index 0000000..0859ed6 --- /dev/null +++ b/server/regions.test.ts @@ -0,0 +1,103 @@ +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; +import { fileURLToPath } from 'node:url'; +import { + allTerritoryKeys, + expandCountriesToKeys, + hasRegionData, + loadRegions, + referenceForKeys, + regionName, + resetRegions, + validKeys, +} from './regions'; +import type { GameState } from '../shared/types'; + +// The repo root: this file lives in server/, so '..' is the directory holding +// public/region-keys.json and public/regions.geo.json. cwd-independent (mirrors +// how server/index.ts locates its root via import.meta.url). +const root = fileURLToPath(new URL('..', import.meta.url)); + +describe('regions (region-key loader, v0.19 #16 P1)', () => { + beforeEach(() => { + expect(loadRegions(root)).toBe(true); + }); + + it('loads the full manifest: 1,796 unique valid region keys', () => { + expect(hasRegionData()).toBe(true); + expect(validKeys().size).toBe(1796); + }); + + it('expands an ISO_A3 country to the region keys of all its sub-regions', () => { + const fra = expandCountriesToKeys(['FRA']); + // FRA has 42 sub-regions in the committed dataset. + expect(fra).toHaveLength(42); + // every expanded key is a real region key + for (const k of fra) expect(validKeys().has(k)).toBe(true); + // and the reference table labels each one with its parent country + const ref = referenceForKeys(fra); + expect(Object.keys(ref)).toHaveLength(42); + for (const [k, label] of Object.entries(ref)) { + expect(label.endsWith('(FRA)')).toBe(true); + expect(regionName(k)).toBeTruthy(); + } + // sample known FRA keys, and their exact reference strings + expect(fra).toContain('ain-lqtt'); + expect(ref['ain-lqtt']).toBe('Ain (FRA)'); + expect(ref['aisne-gstu']).toBe('Aisne (FRA)'); + }); + + it('referenceForKeys labels only known keys and omits unknown ones', () => { + const ref = referenceForKeys(['ain-lqtt', 'not-a-real-key-xyz']); + expect(ref['ain-lqtt']).toBe('Ain (FRA)'); + expect(ref['not-a-real-key-xyz']).toBeUndefined(); + }); + + it('allTerritoryKeys unions every nation\'s territory keys (no overlap)', () => { + const state: GameState = { + id: 'g', + presetId: 'p', + presetTitle: 'P', + playerNationId: 'uk', + turn: 1, + date: '1939-09-01', + nations: { + uk: { + id: 'uk', + name: 'UK', + type: 'major', + stats: { military: 80, economy: 70, stability: 60, population: 50 }, + territory: expandCountriesToKeys(['GBR']), + relations: {}, + }, + fr: { + id: 'fr', + name: 'FR', + type: 'major', + stats: { military: 80, economy: 70, stability: 60, population: 50 }, + territory: expandCountriesToKeys(['FRA']), + relations: {}, + }, + }, + events: [], + pending: [], + version: 1, + }; + // 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two. + expect(allTerritoryKeys(state).size).toBe(58); + }); + + describe('graceful degradation with no region data', () => { + beforeEach(() => resetRegions()); + + it('expands to the raw input, omits references, and reports no data', () => { + expect(hasRegionData()).toBe(false); + // no manifest -> the country list passes through unchanged (old model) + expect(expandCountriesToKeys(['FRA', 'GBR'])).toEqual(['FRA', 'GBR']); + expect(referenceForKeys(['FRA'])).toEqual({}); + expect(validKeys().size).toBe(0); + expect(regionName('FRA')).toBeUndefined(); + }); + }); + + afterAll(() => resetRegions()); +}); From b9ac79c57f44fc3b784fa5676ec50802b2991dce Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Wed, 14 Oct 2026 09:15:33 +0000 Subject: [PATCH 6/6] Bump to v0.19.0: region-level ownership (Part 1 of #16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Version 0.19.0 + changelog. Breaking: pre-v0.19 saved games are dropped (territory now holds region keys, not ISO_A3 codes) — deploy wipes the history-data volume. --- CHANGELOG.md | 8 ++++++++ package.json | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f63128..b8da7fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to **History** are listed here, newest first. +## v0.19.0 — 2026-10-14 09:14 +- **Region-level ownership foundation (Part 1 of #16): internal territory ownership flips from whole countries to individual sub-regions.** Previously a nation owned *whole countries* (a `territory` list of ISO_A3 codes) and every sub-region of a country shared the parent's color, so a single region could never change owner independently. Now each owned sub-region is addressed by a stable **region key** — `-<4char>`, e.g. `moselle-rygy`, `ain-lqtt`, `western-alun` — generated deterministically by `scripts/build-region-keys.mjs` into `public/region-keys.json` (**1,796 unique keys** over the 1,796 sub-regions). A key is a slug of the region's name (NFKD, diacritics stripped, lowercased, non-alphanumerics → `-`) plus a 4-character hash of the feature's stable id, salted on within-country name collisions, so it is stable, human-readable, and globally unique (a "western" collision group yields `western-alun` / `western-qwvr` / `western-adsp` / …; an accented name like Shkodër becomes `shkoder-gmnr`). +- **The map, the model, and the LLM all now reason in region keys.** `Nation.territory` and the `RegionMap` are keyed by region key; the choropleth resolves each sub-region's owner by its stamped `properties.regionKey` (never the repacked numeric feature `id`, never the raw sub-region id — the #6 guard, now region-keyed). A new `server/regions.ts` (turf-free) loads the manifest + geo once at boot and **expands each nation's country list to all its sub-regions' keys at game creation**, so a fresh game shows every sub-region in its owner's color — visually identical to the old whole-country choropleth, but each sub-region is now individually addressable. The LLM prompt gains a `regions` reference table (region key → " (parent ISO_A3)"), and `applyTurnResult` ignores any territory key not in the valid set, so a hallucinated key can't corrupt ownership. +- **The dossier shows region names.** NationPanel's territory chips display each sub-region's human name (e.g. "Ain", "Wales", "Moselle") with the raw key in the tooltip / data attribute, instead of bare codes. +- **⚠ Breaking — pre-v0.19 saved games are dropped.** The `territory` field's meaning changed from ISO_A3 country codes to region keys, so a game saved before this version can't be read by it. Deploy runs `docker compose down -v` (dropping the `history-data` volume) then `up -d --build`; there is no migration — start fresh after deploying. +- **Supersedes the abandoned `feat/sub-region-territory` branch**, which prototyped raw feature-id keys (`DEU-03`); this version uses the slugged, human-readable key scheme instead. +- **Verification.** Typecheck, lint, and the unit suite (now **71 tests across 7 files**: +`server/regions.test.ts`, a rewritten `server/map.test.ts`, +2 in `engine.test.ts`) all pass; the production build is clean. The deterministic `LLM_STUB` and the format-agnostic e2e suite (legend/stats/dates) are unchanged. **#16 stays open** — P2 (LLM tools / function-calling for reliable region ops) and the no-man's-land rename/split/merge phases follow. + ## v0.18.0 — 2026-10-13 18:54 - **Fixed a stale-owner display bug: the region hover tooltip and the dossier "Selected" label no longer print the static parent-country code (e.g. "FRA").** The GeoJSON `properties.parent` is a *static geographic-origin* field that never updates when a country is taken, yet it was rendered in the hover tooltip (as a sub-line and as a title fallback) and in the dossier's `Selected:` label (as a `(FRA)` suffix) — so a sub-region of a transferred country still displayed the old country's code next to the (correct) new owner. The tooltip now shows only the region name + the live owner chip (from the RegionMap) + key stats, and the dossier label shows only the region name. Ownership resolution still keys on `properties.parent` (unchanged) — only the display of it was removed. diff --git a/package.json b/package.json index 7589e5f..75567d2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "history", - "version": "0.18.0", + "version": "0.19.0", "private": true, "type": "module", "description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",