diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..dc7ad769 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,22 @@ +name: Code quality + +on: + push: + pull_request: + +jobs: + quality: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + persist-credentials: false + - name: Setup Biome + uses: biomejs/setup-biome@v2 + with: + version: latest + - name: Run Biome + run: biome ci . \ No newline at end of file diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 00000000..b41b4ac2 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,25 @@ +name: Playwright Tests +on: + pull_request: + branches: [ master ] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: '24' + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run Playwright tests + run: npm run test:e2e + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 \ No newline at end of file diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 00000000..7d02cf0f --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,17 @@ +name: Unit Tests +on: + pull_request: + branches: [ master ] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: '24' + - name: Install dependencies + run: npm ci + - name: Run Unit tests + run: npm run test \ No newline at end of file diff --git a/.gitignore b/.gitignore index b0a273f0..c730ec13 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ .vscode .idea /node_modules +*/node_modules /dist /coverage +/playwright-report +/test-results \ No newline at end of file diff --git a/biome.json b/biome.json new file mode 100644 index 00000000..fc1bbe74 --- /dev/null +++ b/biome.json @@ -0,0 +1,58 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.13/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": ["src/**/*.ts"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space" + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style": { + "useTemplate": { + "level": "warn", + "fix": "safe" + }, + "noNonNullAssertion": "off" + }, + "suspicious": { + "noExplicitAny": "off", + "noGlobalIsNan": { + "level": "error", + "fix": "safe" + } + }, + "correctness": { + "noUnusedVariables": { + "level": "error", + "fix": "safe" + }, + "useParseIntRadix": { + "fix": "safe", + "level": "error" + } + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/package-lock.json b/package-lock.json index cafbec00..53616e24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "fantasy-map-generator", - "version": "1.109.5", + "version": "1.110.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fantasy-map-generator", - "version": "1.109.5", + "version": "1.110.0", "license": "MIT", "dependencies": { "alea": "^1.0.1", @@ -15,16 +15,186 @@ "polylabel": "^2.0.1" }, "devDependencies": { + "@biomejs/biome": "2.3.13", + "@playwright/test": "^1.57.0", "@types/d3": "^7.4.3", "@types/delaunator": "^5.0.3", + "@types/node": "^25.0.10", "@types/polylabel": "^1.1.3", + "@vitest/browser": "^4.0.18", + "@vitest/browser-playwright": "^4.0.18", + "playwright": "^1.57.0", "typescript": "^5.9.3", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^4.0.18" }, "engines": { "node": ">=24.0.0" } }, + "node_modules/@biomejs/biome": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.13.tgz", + "integrity": "sha512-Fw7UsV0UAtWIBIm0M7g5CRerpu1eKyKAXIazzxhbXYUyMkwNrkX/KLkGI7b+uVDQ5cLUMfOC9vR60q9IDYDstA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.3.13", + "@biomejs/cli-darwin-x64": "2.3.13", + "@biomejs/cli-linux-arm64": "2.3.13", + "@biomejs/cli-linux-arm64-musl": "2.3.13", + "@biomejs/cli-linux-x64": "2.3.13", + "@biomejs/cli-linux-x64-musl": "2.3.13", + "@biomejs/cli-win32-arm64": "2.3.13", + "@biomejs/cli-win32-x64": "2.3.13" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.13.tgz", + "integrity": "sha512-0OCwP0/BoKzyJHnFdaTk/i7hIP9JHH9oJJq6hrSCPmJPo8JWcJhprK4gQlhFzrwdTBAW4Bjt/RmCf3ZZe59gwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.13.tgz", + "integrity": "sha512-AGr8OoemT/ejynbIu56qeil2+F2WLkIjn2d8jGK1JkchxnMUhYOfnqc9sVzcRxpG9Ycvw4weQ5sprRvtb7Yhcw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.13.tgz", + "integrity": "sha512-xvOiFkrDNu607MPMBUQ6huHmBG1PZLOrqhtK6pXJW3GjfVqJg0Z/qpTdhXfcqWdSZHcT+Nct2fOgewZvytESkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.13.tgz", + "integrity": "sha512-TUdDCSY+Eo/EHjhJz7P2GnWwfqet+lFxBZzGHldrvULr59AgahamLs/N85SC4+bdF86EhqDuuw9rYLvLFWWlXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.13.tgz", + "integrity": "sha512-s+YsZlgiXNq8XkgHs6xdvKDFOj/bwTEevqEY6rC2I3cBHbxXYU1LOZstH3Ffw9hE5tE1sqT7U23C00MzkXztMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.13.tgz", + "integrity": "sha512-0bdwFVSbbM//Sds6OjtnmQGp4eUjOTt6kHvR/1P0ieR9GcTUAlPNvPC3DiavTqq302W34Ae2T6u5VVNGuQtGlQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.13.tgz", + "integrity": "sha512-QweDxY89fq0VvrxME+wS/BXKmqMrOTZlN9SqQ79kQSIc3FrEwvW/PvUegQF6XIVaekncDykB5dzPqjbwSKs9DA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.13.tgz", + "integrity": "sha512-trDw2ogdM2lyav9WFQsdsfdVy1dvZALymRpgmWsvSez0BJzBjulhOT/t+wyKeh3pZWvwP3VMs1SoOKwO3wecMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -467,6 +637,36 @@ "node": ">=18" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.55.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", @@ -817,6 +1017,24 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", @@ -1101,6 +1319,13 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/delaunator": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/@types/delaunator/-/delaunator-5.0.3.tgz", @@ -1122,6 +1347,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.0.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz", + "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, "node_modules/@types/polylabel": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@types/polylabel/-/polylabel-1.1.3.tgz", @@ -1129,12 +1365,191 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitest/browser": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.0.18.tgz", + "integrity": "sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/mocker": "4.0.18", + "@vitest/utils": "4.0.18", + "magic-string": "^0.30.21", + "pixelmatch": "7.1.0", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.0.3", + "ws": "^8.18.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.0.18" + } + }, + "node_modules/@vitest/browser-playwright": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.0.18.tgz", + "integrity": "sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/browser": "4.0.18", + "@vitest/mocker": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "4.0.18" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": false + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/alea": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/alea/-/alea-1.0.1.tgz", "integrity": "sha512-QU+wv+ziDXaMxRdsQg/aH7sVfWdhKps5YP97IIwFkHCsbDZA3k87JXoZ5/iuemf4ntytzIWeScrRpae8+lDrXA==", "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/commander": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", @@ -1555,6 +1970,13 @@ "robust-predicates": "^3.0.2" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -1597,6 +2019,26 @@ "@esbuild/win32-x64": "0.27.2" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1651,6 +2093,26 @@ "node": ">=12" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1670,6 +2132,24 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1691,6 +2171,76 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pixelmatch": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.1.0.tgz", + "integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==", + "dev": true, + "license": "ISC", + "dependencies": { + "pngjs": "^7.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/polylabel": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/polylabel/-/polylabel-2.0.1.tgz", @@ -1792,6 +2342,28 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1802,6 +2374,37 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1825,6 +2428,26 @@ "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", "license": "ISC" }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1839,12 +2462,20 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -1913,6 +2544,124 @@ "optional": true } } + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 7a17e01b..37c2ba5a 100644 --- a/package.json +++ b/package.json @@ -16,14 +16,26 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest", + "test:browser": "vitest --config=vitest.browser.config.ts", + "test:e2e": "playwright test", + "lint": "biome check --write", + "format": "biome format --write" }, "devDependencies": { + "@biomejs/biome": "2.3.13", + "@playwright/test": "^1.57.0", "@types/d3": "^7.4.3", "@types/delaunator": "^5.0.3", + "@types/node": "^25.0.10", "@types/polylabel": "^1.1.3", + "@vitest/browser": "^4.0.18", + "@vitest/browser-playwright": "^4.0.18", + "playwright": "^1.57.0", "typescript": "^5.9.3", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^4.0.18" }, "dependencies": { "alea": "^1.0.1", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..86348c64 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,34 @@ +import { defineConfig, devices } from '@playwright/test' + +const isCI = !!process.env.CI + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + forbidOnly: isCI, + retries: isCI ? 2 : 0, + workers: isCI ? 1 : undefined, + reporter: 'html', + // Use OS-independent snapshot names (HTML content is the same across platforms) + snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}{ext}', + use: { + baseURL: isCI ? 'http://localhost:4173' : 'http://localhost:5173', + trace: 'on-first-retry', + // Fixed viewport to ensure consistent map rendering + viewport: { width: 1280, height: 720 }, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + // In CI: build and preview for production-like testing + // In dev: use vite dev server (faster, no rebuild needed) + command: isCI ? 'npm run build && npm run preview' : 'npm run dev', + url: isCI ? 'http://localhost:4173' : 'http://localhost:5173', + reuseExistingServer: !isCI, + timeout: 120000, + }, +}) diff --git a/public/main.js b/public/main.js index 6da462d5..c0ac9d11 100644 --- a/public/main.js +++ b/public/main.js @@ -187,7 +187,7 @@ const onZoom = debounce(function () { }, 50); const zoom = d3.zoom().scaleExtent([1, 20]).on("zoom", onZoom); -let mapCoordinates = {}; // map coordinates on globe +var mapCoordinates = {}; // map coordinates on globe let populationRate = +byId("populationRateInput").value; let distanceScale = +byId("distanceScaleInput").value; let urbanization = +byId("urbanizationInput").value; @@ -632,6 +632,8 @@ async function generate(options) { Biomes.define(); Features.defineGroups(); + Ice.generate(); + rankCells(); Cultures.generate(); Cultures.expand(); @@ -1227,8 +1229,12 @@ function showStatistics() { Cultures: ${pack.cultures.length - 1}`; mapId = Date.now(); // unique map id is it's creation date number + window.mapId = mapId; // expose for test automation mapHistory.push({seed, width: graphWidth, height: graphHeight, template: heightmap, created: mapId}); INFO && console.info(stats); + + // Dispatch event for test automation and external integrations + window.dispatchEvent(new CustomEvent('map:generated', { detail: { seed, mapId } })); } const regenerateMap = debounce(async function (options) { diff --git a/public/modules/biomes.js b/public/modules/biomes.js deleted file mode 100644 index 06280fad..00000000 --- a/public/modules/biomes.js +++ /dev/null @@ -1,128 +0,0 @@ -"use strict"; - -window.Biomes = (function () { - const MIN_LAND_HEIGHT = 20; - - const getDefault = () => { - const name = [ - "Marine", - "Hot desert", - "Cold desert", - "Savanna", - "Grassland", - "Tropical seasonal forest", - "Temperate deciduous forest", - "Tropical rainforest", - "Temperate rainforest", - "Taiga", - "Tundra", - "Glacier", - "Wetland" - ]; - - const color = [ - "#466eab", - "#fbe79f", - "#b5b887", - "#d2d082", - "#c8d68f", - "#b6d95d", - "#29bc56", - "#7dcb35", - "#409c43", - "#4b6b32", - "#96784b", - "#d5e7eb", - "#0b9131" - ]; - const habitability = [0, 4, 10, 22, 30, 50, 100, 80, 90, 12, 4, 0, 12]; - const iconsDensity = [0, 3, 2, 120, 120, 120, 120, 150, 150, 100, 5, 0, 250]; - const icons = [ - {}, - {dune: 3, cactus: 6, deadTree: 1}, - {dune: 9, deadTree: 1}, - {acacia: 1, grass: 9}, - {grass: 1}, - {acacia: 8, palm: 1}, - {deciduous: 1}, - {acacia: 5, palm: 3, deciduous: 1, swamp: 1}, - {deciduous: 6, swamp: 1}, - {conifer: 1}, - {grass: 1}, - {}, - {swamp: 1} - ]; - const cost = [10, 200, 150, 60, 50, 70, 70, 80, 90, 200, 1000, 5000, 150]; // biome movement cost - const biomesMartix = [ - // hot ↔ cold [>19°C; <-4°C]; dry ↕ wet - new Uint8Array([1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 10]), - new Uint8Array([3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 9, 9, 9, 9, 10, 10, 10]), - new Uint8Array([5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 9, 9, 9, 9, 9, 10, 10, 10]), - new Uint8Array([5, 6, 6, 6, 6, 6, 6, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 10, 10, 10]), - new Uint8Array([7, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 9, 10, 10]) - ]; - - // parse icons weighted array into a simple array - for (let i = 0; i < icons.length; i++) { - const parsed = []; - for (const icon in icons[i]) { - for (let j = 0; j < icons[i][icon]; j++) { - parsed.push(icon); - } - } - icons[i] = parsed; - } - - return {i: d3.range(0, name.length), name, color, biomesMartix, habitability, iconsDensity, icons, cost}; - }; - - // assign biome id for each cell - function define() { - TIME && console.time("defineBiomes"); - - const {fl: flux, r: riverIds, h: heights, c: neighbors, g: gridReference} = pack.cells; - const {temp, prec} = grid.cells; - pack.cells.biome = new Uint8Array(pack.cells.i.length); // biomes array - - for (let cellId = 0; cellId < heights.length; cellId++) { - const height = heights[cellId]; - const moisture = height < MIN_LAND_HEIGHT ? 0 : calculateMoisture(cellId); - const temperature = temp[gridReference[cellId]]; - pack.cells.biome[cellId] = getId(moisture, temperature, height, Boolean(riverIds[cellId])); - } - - function calculateMoisture(cellId) { - let moisture = prec[gridReference[cellId]]; - if (riverIds[cellId]) moisture += Math.max(flux[cellId] / 10, 2); - - const moistAround = neighbors[cellId] - .filter(neibCellId => heights[neibCellId] >= MIN_LAND_HEIGHT) - .map(c => prec[gridReference[c]]) - .concat([moisture]); - return rn(4 + d3.mean(moistAround)); - } - - TIME && console.timeEnd("defineBiomes"); - } - - function getId(moisture, temperature, height, hasRiver) { - if (height < 20) return 0; // all water cells: marine biome - if (temperature < -5) return 11; // too cold: permafrost biome - if (temperature >= 25 && !hasRiver && moisture < 8) return 1; // too hot and dry: hot desert biome - if (isWetland(moisture, temperature, height)) return 12; // too wet: wetland biome - - // in other cases use biome matrix - const moistureBand = Math.min((moisture / 5) | 0, 4); // [0-4] - const temperatureBand = Math.min(Math.max(20 - temperature, 0), 25); // [0-25] - return biomesData.biomesMartix[moistureBand][temperatureBand]; - } - - function isWetland(moisture, temperature, height) { - if (temperature <= -2) return false; // too cold - if (moisture > 40 && height < 25) return true; // near coast - if (moisture > 24 && height > 24 && height < 60) return true; // off coast - return false; - } - - return {getDefault, define, getId}; -})(); diff --git a/public/modules/burgs-generator.js b/public/modules/burgs-generator.js deleted file mode 100644 index 20cd0fd1..00000000 --- a/public/modules/burgs-generator.js +++ /dev/null @@ -1,597 +0,0 @@ -"use strict"; - -window.Burgs = (() => { - const generate = () => { - TIME && console.time("generateBurgs"); - const {cells} = pack; - - let burgs = [0]; // burgs array - cells.burg = new Uint16Array(cells.i.length); - - const populatedCells = cells.i.filter(i => cells.s[i] > 0 && cells.culture[i]); - if (!populatedCells.length) { - ERROR && console.error("There is no populated cells with culture assigned. Cannot generate states"); - return burgs; - } - - let quadtree = d3.quadtree(); - generateCapitals(); - generateTowns(); - - pack.burgs = burgs; - shift(); - - TIME && console.timeEnd("generateBurgs"); - - function generateCapitals() { - const randomize = score => score * (0.5 + Math.random() * 0.5); - const score = new Int16Array(cells.s.map(randomize)); - const sorted = populatedCells.sort((a, b) => score[b] - score[a]); - - const capitalsNumber = getCapitalsNumber(); - let spacing = (graphWidth + graphHeight) / 2 / capitalsNumber; // min distance between capitals - - for (let i = 0; burgs.length <= capitalsNumber; i++) { - const cell = sorted[i]; - const [x, y] = cells.p[cell]; - - if (quadtree.find(x, y, spacing) === undefined) { - burgs.push({cell, x, y}); - quadtree.add([x, y]); - } - - // reset if all cells were checked - if (i === sorted.length - 1) { - WARN && console.warn("Cannot place capitals with current spacing. Trying again with reduced spacing"); - quadtree = d3.quadtree(); - i = -1; - burgs = [0]; - spacing /= 1.2; - } - } - - burgs.forEach((burg, burgId) => { - if (!burgId) return; - burg.i = burgId; - burg.state = burgId; - burg.culture = cells.culture[burg.cell]; - burg.name = Names.getCultureShort(burg.culture); - burg.feature = cells.f[burg.cell]; - burg.capital = 1; - cells.burg[burg.cell] = burgId; - }); - } - - function generateTowns() { - const randomize = score => score * gauss(1, 3, 0, 20, 3); - const score = new Int16Array(cells.s.map(randomize)); - const sorted = populatedCells.sort((a, b) => score[b] - score[a]); - - const burgsNumber = getTownsNumber(); - let spacing = (graphWidth + graphHeight) / 150 / (burgsNumber ** 0.7 / 66); // min distance between town - - for (let added = 0; added < burgsNumber && spacing > 1; ) { - for (let i = 0; added < burgsNumber && i < sorted.length; i++) { - if (cells.burg[sorted[i]]) continue; - const cell = sorted[i]; - const [x, y] = cells.p[cell]; - - const minSpacing = spacing * gauss(1, 0.3, 0.2, 2, 2); // randomize to make placement not uniform - if (quadtree.find(x, y, minSpacing) !== undefined) continue; // to close to existing burg - - const burgId = burgs.length; - const culture = cells.culture[cell]; - const name = Names.getCulture(culture); - const feature = cells.f[cell]; - burgs.push({cell, x, y, i: burgId, state: 0, culture, name, feature, capital: 0}); - added++; - cells.burg[cell] = burgId; - } - - spacing *= 0.5; - } - } - - function getCapitalsNumber() { - let number = +byId("statesNumber").value; - - if (populatedCells.length < number * 10) { - number = Math.floor(populatedCells.length / 10); - WARN && console.warn(`Not enough populated cells. Generating only ${number} capitals/states`); - } - - return number; - } - - function getTownsNumber() { - const manorsInput = byId("manorsInput"); - const isAuto = manorsInput.value === "1000"; // '1000' is considered as auto - if (isAuto) return rn(populatedCells.length / 5 / (grid.points.length / 10000) ** 0.8); - - return Math.min(manorsInput.valueAsNumber, populatedCells.length); - } - }; - - // define port status and shift ports and burgs on rivers close to the edge of the water body - function shift() { - const {cells, features, burgs} = pack; - const temp = grid.cells.temp; - - // port is a capital with any harbor OR any burg with a safe harbor - // safe harbor is a cell having just one adjacent water cell - const featurePortCandidates = {}; - for (const burg of burgs) { - if (!burg.i || burg.lock) continue; - delete burg.port; // reset port status - const cellId = burg.cell; - - const haven = cells.haven[cellId]; - const harbor = cells.harbor[cellId]; - const featureId = cells.f[haven]; - if (!featureId) continue; // no adjacent water body - - const isMulticell = features[featureId].cells > 1; - const isHarbor = (harbor && burg.capital) || harbor === 1; - const isFrozen = temp[cells.g[cellId]] <= 0; - - if (isMulticell && isHarbor && !isFrozen) { - if (!featurePortCandidates[featureId]) featurePortCandidates[featureId] = []; - featurePortCandidates[featureId].push(burg); - } - } - - // shift ports to the edge of the water body - Object.entries(featurePortCandidates).forEach(([featureId, burgs]) => { - if (burgs.length < 2) return; // only one port on water body - skip - burgs.forEach(burg => { - burg.port = featureId; - const haven = cells.haven[burg.cell]; - const [x, y] = getCloseToEdgePoint(burg.cell, haven); - burg.x = x; - burg.y = y; - }); - }); - - // shift non-port river burgs a bit - for (const burg of burgs) { - if (!burg.i || burg.lock || burg.port || !cells.r[burg.cell]) continue; - const cellId = burg.cell; - const shift = Math.min(cells.fl[cellId] / 150, 1); - burg.x = cellId % 2 ? rn(burg.x + shift, 2) : rn(burg.x - shift, 2); - burg.y = cells.r[cellId] % 2 ? rn(burg.y + shift, 2) : rn(burg.y - shift, 2); - } - - function getCloseToEdgePoint(cell1, cell2) { - const {cells, vertices} = pack; - - const [x0, y0] = cells.p[cell1]; - const commonVertices = cells.v[cell1].filter(vertex => vertices.c[vertex].some(cell => cell === cell2)); - const [x1, y1] = vertices.p[commonVertices[0]]; - const [x2, y2] = vertices.p[commonVertices[1]]; - const xEdge = (x1 + x2) / 2; - const yEdge = (y1 + y2) / 2; - - const x = rn(x0 + 0.95 * (xEdge - x0), 2); - const y = rn(y0 + 0.95 * (yEdge - y0), 2); - - return [x, y]; - } - } - - const specify = () => { - TIME && console.time("specifyBurgs"); - - pack.burgs.forEach(burg => { - if (!burg.i || burg.removed || burg.lock) return; - definePopulation(burg); - defineEmblem(burg); - defineFeatures(burg); - }); - - const populations = pack.burgs - .filter(b => b.i && !b.removed) - .map(b => b.population) - .sort((a, b) => a - b); // ascending - - pack.burgs.forEach(burg => { - if (!burg.i || burg.removed) return; - defineGroup(burg, populations); - }); - - TIME && console.timeEnd("specifyBurgs"); - }; - - const getType = (cellId, port) => { - const {cells, features} = pack; - - if (port) return "Naval"; - - const haven = cells.haven[cellId]; - if (haven !== undefined && features[cells.f[haven]].type === "lake") return "Lake"; - - if (cells.h[cellId] > 60) return "Highland"; - - if (cells.r[cellId] && cells.fl[cellId] >= 100) return "River"; - - const biome = cells.biome[cellId]; - const population = cells.pop[cellId]; - if (!cells.burg[cellId] || population <= 5) { - if (population < 5 && [1, 2, 3, 4].includes(biome)) return "Nomadic"; - if (biome > 4 && biome < 10) return "Hunting"; - } - - return "Generic"; - }; - - function definePopulation(burg) { - const cellId = burg.cell; - let population = pack.cells.s[cellId] / 5; - if (burg.capital) population *= 1.5; - const connectivityRate = Routes.getConnectivityRate(cellId); - if (connectivityRate) population *= connectivityRate; - population *= gauss(1, 1, 0.25, 4, 5); // randomize - population += ((burg.i % 100) - (cellId % 100)) / 1000; // unround - burg.population = rn(Math.max(population, 0.01), 3); - } - - function defineEmblem(burg) { - burg.type = getType(burg.cell, burg.port); - - const state = pack.states[burg.state]; - const stateCOA = state.coa; - - let kinship = 0.25; - if (burg.capital) kinship += 0.1; - else if (burg.port) kinship -= 0.1; - if (burg.culture !== state.culture) kinship -= 0.25; - - const type = burg.capital && P(0.2) ? "Capital" : burg.type === "Generic" ? "City" : burg.type; - burg.coa = COA.generate(stateCOA, kinship, null, type); - burg.coa.shield = COA.getShield(burg.culture, burg.state); - } - - function defineFeatures(burg) { - const pop = burg.population; - burg.citadel = Number(burg.capital || (pop > 50 && P(0.75)) || (pop > 15 && P(0.5)) || P(0.1)); - burg.plaza = Number( - Routes.isCrossroad(burg.cell) || (Routes.hasRoad(burg.cell) && P(0.7)) || pop > 20 || (pop > 10 && P(0.8)) - ); - burg.walls = Number(burg.capital || pop > 30 || (pop > 20 && P(0.75)) || (pop > 10 && P(0.5)) || P(0.1)); - burg.shanty = Number(pop > 60 || (pop > 40 && P(0.75)) || (pop > 20 && burg.walls && P(0.4))); - const religion = pack.cells.religion[burg.cell]; - const theocracy = pack.states[burg.state].form === "Theocracy"; - burg.temple = Number( - (religion && theocracy && P(0.5)) || pop > 50 || (pop > 35 && P(0.75)) || (pop > 20 && P(0.5)) - ); - } - - const getDefaultGroups = () => [ - {name: "capital", active: true, order: 9, features: {capital: true}, preview: "watabou-city"}, - {name: "city", active: true, order: 8, percentile: 90, min: 5, preview: "watabou-city"}, - { - name: "fort", - active: true, - features: {citadel: true, walls: false, plaza: false, port: false}, - order: 6, - max: 1 - }, - { - name: "monastery", - active: true, - features: {temple: true, walls: false, plaza: false, port: false}, - order: 5, - max: 0.8 - }, - { - name: "caravanserai", - active: true, - features: {port: false, plaza: true}, - order: 4, - max: 0.8, - biomes: [1, 2, 3] - }, - { - name: "trading_post", - active: true, - order: 3, - features: {plaza: true}, - max: 0.8, - biomes: [5, 6, 7, 8, 9, 10, 11, 12] - }, - { - name: "village", - active: true, - order: 2, - min: 0.1, - max: 2, - preview: "watabou-village" - }, - { - name: "hamlet", - active: true, - order: 1, - features: {plaza: false}, - max: 0.1, - preview: "watabou-village" - }, - {name: "town", active: true, order: 7, isDefault: true, preview: "watabou-city"} - ]; - - function defineGroup(burg, populations) { - if (burg.lock && burg.group) { - // locked burgs: don't change group if it still exists - const group = options.burgs.groups.find(g => g.name === burg.group); - if (group) return; - } - - const defaultGroup = options.burgs.groups.find(g => g.isDefault); - if (!defaultGroup) { - ERROR && console.error("No default group defined"); - return; - } - burg.group = defaultGroup.name; - - for (const group of options.burgs.groups) { - if (!group.active) continue; - - if (group.min) { - const isFit = burg.population >= group.min; - if (!isFit) continue; - } - - if (group.max) { - const isFit = burg.population <= group.max; - if (!isFit) continue; - } - - if (group.features) { - const isFit = Object.entries(group.features).every(([feature, value]) => Boolean(burg[feature]) === value); - if (!isFit) continue; - } - - if (group.biomes) { - const isFit = group.biomes.includes(pack.cells.biome[burg.cell]); - if (!isFit) continue; - } - - if (group.percentile) { - const index = populations.indexOf(burg.population); - const isFit = index >= Math.floor((populations.length * group.percentile) / 100); - if (!isFit) continue; - } - - burg.group = group.name; // apply fitting group - return; - } - } - - const previewGeneratorsMap = { - "watabou-city": createWatabouCityLinks, - "watabou-village": createWatabouVillageLinks, - "watabou-dwelling": createWatabouDwellingLinks - }; - - function getPreview(burg) { - if (burg.link) return {link: burg.link, preview: burg.link}; - - const group = options.burgs.groups.find(g => g.name === burg.group); - if (!group?.preview || !previewGeneratorsMap[group.preview]) return {link: null, preview: null}; - - return previewGeneratorsMap[group.preview](burg); - } - - function createWatabouCityLinks(burg) { - const cells = pack.cells; - const {i, name, population: burgPopulation, cell} = burg; - const burgSeed = burg.MFCG || seed + String(burg.i).padStart(4, 0); - - const sizeRaw = 2.13 * Math.pow((burgPopulation * populationRate) / urbanDensity, 0.385); - const size = minmax(Math.ceil(sizeRaw), 6, 100); - const population = rn(burgPopulation * populationRate * urbanization); - - const river = cells.r[cell] ? 1 : 0; - const coast = Number(burg.port > 0); - const sea = (() => { - if (!coast || !cells.haven[cell]) return null; - - // calculate see direction: 0 = east, 0.5 = north, 1 = west, 1.5 = south - const [x1, y1] = cells.p[cell]; - const [x2, y2] = cells.p[cells.haven[cell]]; - const deg = (Math.atan2(y2 - y1, x2 - x1) * 180) / Math.PI; - - if (deg <= 0) return rn(normalize(Math.abs(deg), 0, 180), 2); - return rn(2 - normalize(deg, 0, 180), 2); - })(); - - const arableBiomes = river ? [1, 2, 3, 4, 5, 6, 7, 8] : [5, 6, 7, 8]; - const farms = +arableBiomes.includes(cells.biome[cell]); - - const citadel = +burg.citadel; - const urban_castle = +(citadel && each(2)(i)); - - const hub = Routes.isCrossroad(cell); - const walls = +burg.walls; - const plaza = +burg.plaza; - const temple = +burg.temple; - const shantytown = +burg.shanty; - - const style = "natural"; - - const url = new URL("https://watabou.github.io/city-generator/"); - url.search = new URLSearchParams({ - name, - population, - size, - seed: burgSeed, - river, - coast, - farms, - citadel, - urban_castle, - hub, - plaza, - temple, - walls, - shantytown, - gates: -1, - style - }); - if (sea) url.searchParams.append("sea", sea); - - const link = url.toString(); - return {link, preview: link + "&preview=1"}; - } - - function createWatabouVillageLinks(burg) { - const {cells, features} = pack; - const {i, population, cell} = burg; - - const burgSeed = seed + String(i).padStart(4, 0); - const pop = rn(population * populationRate * urbanization); - const tags = []; - - if (cells.r[cell] && cells.haven[cell]) tags.push("estuary"); - else if (cells.haven[cell] && features[cells.f[cell]].cells === 1) tags.push("island,district"); - else if (burg.port) tags.push("coast"); - else if (cells.conf[cell]) tags.push("confluence"); - else if (cells.r[cell]) tags.push("river"); - else if (pop < 200 && each(4)(cell)) tags.push("pond"); - - const connectivityRate = Routes.getConnectivityRate(cell); - tags.push(connectivityRate > 1 ? "highway" : connectivityRate === 1 ? "dead end" : "isolated"); - - const biome = cells.biome[cell]; - const arableBiomes = cells.r[cell] ? [1, 2, 3, 4, 5, 6, 7, 8] : [5, 6, 7, 8]; - if (!arableBiomes.includes(biome)) tags.push("uncultivated"); - else if (each(6)(cell)) tags.push("farmland"); - - const temp = grid.cells.temp[cells.g[cell]]; - if (temp <= 0 || temp > 28 || (temp > 25 && each(3)(cell))) tags.push("no orchards"); - - if (!burg.plaza) tags.push("no square"); - if (burg.walls) tags.push("palisade"); - - if (pop < 100) tags.push("sparse"); - else if (pop > 300) tags.push("dense"); - - const width = (() => { - if (pop > 1500) return 1600; - if (pop > 1000) return 1400; - if (pop > 500) return 1000; - if (pop > 200) return 800; - if (pop > 100) return 600; - return 400; - })(); - const height = rn(width / 2.05); - - const style = (() => { - if ([1, 2].includes(biome)) return "sand"; - if (temp <= 5 || [9, 10, 11].includes(biome)) return "snow"; - return "default"; - })(); - - const url = new URL("https://watabou.github.io/village-generator/"); - url.search = new URLSearchParams({pop, name: burg.name, seed: burgSeed, width, height, style, tags}); - - const link = url.toString(); - return {link, preview: link + "&preview=1"}; - } - - function createWatabouDwellingLinks(burg) { - const burgSeed = seed + String(burg.i).padStart(4, 0); - const pop = rn(burg.population * populationRate * urbanization); - - const tags = (() => { - if (pop > 200) return ["large", "tall"]; - if (pop > 100) return ["large"]; - if (pop > 50) return ["tall"]; - if (pop > 20) return ["low"]; - return ["small"]; - })(); - - const url = new URL("https://watabou.github.io/dwellings/"); - url.search = new URLSearchParams({pop, name: "", seed: burgSeed, tags}); - - const link = url.toString(); - return {link, preview: link + "&preview=1"}; - } - - function add([x, y]) { - const {cells} = pack; - - const burgId = pack.burgs.length; - const cellId = findCell(x, y); - const culture = cells.culture[cellId]; - const name = Names.getCulture(culture); - const state = cells.state[cellId]; - const feature = cells.f[cellId]; - - const burg = { - cell: cellId, - x, - y, - i: burgId, - state, - culture, - name, - feature, - capital: 0, - port: 0 - }; - definePopulation(burg); - defineEmblem(burg); - defineFeatures(burg); - - const populations = pack.burgs - .filter(b => b.i && !b.removed) - .map(b => b.population) - .sort((a, b) => a - b); // ascending - defineGroup(burg, populations); - - pack.burgs.push(burg); - cells.burg[cellId] = burgId; - - const newRoute = Routes.connect(cellId); - if (newRoute && layerIsOn("toggleRoutes")) drawRoute(newRoute); - - drawBurgIcon(burg); - drawBurgLabel(burg); - - return burgId; - } - - function changeGroup(burg, group) { - if (group) { - burg.group = group; - } else { - const validBurgs = pack.burgs.filter(b => b.i && !b.removed); - const populations = validBurgs.map(b => b.population).sort((a, b) => a - b); - defineGroup(burg, populations); - } - - drawBurgIcon(burg); - drawBurgLabel(burg); - } - - function remove(burgId) { - const burg = pack.burgs[burgId]; - if (!burg) return tip(`Burg ${burgId} not found`, false, "error"); - - pack.cells.burg[burg.cell] = 0; - burg.removed = true; - - const noteId = notes.findIndex(note => note.id === `burg${burgId}`); - if (noteId !== -1) notes.splice(noteId, 1); - - if (burg.coa) { - byId("burgCOA" + burgId)?.remove(); - emblems.select(`#burgEmblems > use[data-i='${burgId}']`).remove(); - delete burg.coa; - } - - removeBurgIcon(burg.i); - removeBurgLabel(burg.i); - } - - return {generate, getDefaultGroups, shift, specify, defineGroup, getPreview, getType, add, changeGroup, remove}; -})(); diff --git a/public/modules/cultures-generator.js b/public/modules/cultures-generator.js deleted file mode 100644 index 34dc5edd..00000000 --- a/public/modules/cultures-generator.js +++ /dev/null @@ -1,618 +0,0 @@ -"use strict"; - -window.Cultures = (function () { - let cells; - - const generate = function () { - TIME && console.time("generateCultures"); - cells = pack.cells; - - const cultureIds = new Uint16Array(cells.i.length); // cell cultures - - const culturesInputNumber = +byId("culturesInput").value; - const culturesInSetNumber = +byId("culturesSet").selectedOptions[0].dataset.max; - let count = Math.min(culturesInputNumber, culturesInSetNumber); - - const populated = cells.i.filter(i => cells.s[i]); // populated cells - if (populated.length < count * 25) { - count = Math.floor(populated.length / 50); - if (!count) { - WARN && console.warn(`There are no populated cells. Cannot generate cultures`); - pack.cultures = [{name: "Wildlands", i: 0, base: 1, shield: "round"}]; - cells.culture = cultureIds; - - alertMessage.innerHTML = /* html */ `The climate is harsh and people cannot live in this world.
- No cultures, states and burgs will be created.
- Please consider changing climate settings in the World Configurator`; - - $("#alert").dialog({ - resizable: false, - title: "Extreme climate warning", - buttons: { - Ok: function () { - $(this).dialog("close"); - } - } - }); - return; - } else { - WARN && console.warn(`Not enough populated cells (${populated.length}). Will generate only ${count} cultures`); - alertMessage.innerHTML = /* html */ ` There are only ${populated.length} populated cells and it's insufficient livable area.
- Only ${count} out of ${culturesInput.value} requested cultures will be generated.
- Please consider changing climate settings in the World Configurator`; - $("#alert").dialog({ - resizable: false, - title: "Extreme climate warning", - buttons: { - Ok: function () { - $(this).dialog("close"); - } - } - }); - } - } - - const cultures = (pack.cultures = selectCultures(count)); - const centers = d3.quadtree(); - const colors = getColors(count); - const emblemShape = document.getElementById("emblemShape").value; - - const codes = []; - - cultures.forEach(function (c, i) { - const newId = i + 1; - - if (c.lock) { - codes.push(c.code); - centers.add(c.center); - - for (const i of cells.i) { - if (cells.culture[i] === c.i) cultureIds[i] = newId; - } - - c.i = newId; - return; - } - - const sortingFn = c.sort ? c.sort : i => cells.s[i]; - const center = placeCenter(sortingFn); - - centers.add(cells.p[center]); - c.center = center; - c.i = newId; - delete c.odd; - delete c.sort; - c.color = colors[i]; - c.type = defineCultureType(center); - c.expansionism = defineCultureExpansionism(c.type); - c.origins = [0]; - c.code = abbreviate(c.name, codes); - codes.push(c.code); - cultureIds[center] = newId; - if (emblemShape === "random") c.shield = getRandomShield(); - }); - - cells.culture = cultureIds; - - function placeCenter(sortingFn) { - let spacing = (graphWidth + graphHeight) / 2 / count; - const MAX_ATTEMPTS = 100; - - const sorted = [...populated].sort((a, b) => sortingFn(b) - sortingFn(a)); - const max = Math.floor(sorted.length / 2); - - let cellId = 0; - for (let i = 0; i < MAX_ATTEMPTS; i++) { - cellId = sorted[biased(0, max, 5)]; - spacing *= 0.9; - if (!cultureIds[cellId] && !centers.find(cells.p[cellId][0], cells.p[cellId][1], spacing)) break; - } - - return cellId; - } - - // the first culture with id 0 is for wildlands - cultures.unshift({name: "Wildlands", i: 0, base: 1, origins: [null], shield: "round"}); - - // make sure all bases exist in nameBases - if (!nameBases.length) { - ERROR && console.error("Name base is empty, default nameBases will be applied"); - nameBases = Names.getNameBases(); - } - - cultures.forEach(c => (c.base = c.base % nameBases.length)); - - function selectCultures(culturesNumber) { - let defaultCultures = getDefault(culturesNumber); - const cultures = []; - - pack.cultures?.forEach(function (culture) { - if (culture.lock && !culture.removed) cultures.push(culture); - }); - - if (!cultures.length) { - if (culturesNumber === defaultCultures.length) return defaultCultures; - if (defaultCultures.every(d => d.odd === 1)) return defaultCultures.splice(0, culturesNumber); - } - - for (let culture, rnd, i = 0; cultures.length < culturesNumber && defaultCultures.length > 0; ) { - do { - rnd = rand(defaultCultures.length - 1); - culture = defaultCultures[rnd]; - i++; - } while (i < 200 && !P(culture.odd)); - cultures.push(culture); - defaultCultures.splice(rnd, 1); - } - return cultures; - } - - // set culture type based on culture center position - function defineCultureType(i) { - if (cells.h[i] < 70 && [1, 2, 4].includes(cells.biome[i])) return "Nomadic"; // high penalty in forest biomes and near coastline - if (cells.h[i] > 50) return "Highland"; // no penalty for hills and moutains, high for other elevations - const f = pack.features[cells.f[cells.haven[i]]]; // opposite feature - if (f.type === "lake" && f.cells > 5) return "Lake"; // low water cross penalty and high for growth not along coastline - if ( - (cells.harbor[i] && f.type !== "lake" && P(0.1)) || - (cells.harbor[i] === 1 && P(0.6)) || - (pack.features[cells.f[i]].group === "isle" && P(0.4)) - ) - return "Naval"; // low water cross penalty and high for non-along-coastline growth - if (cells.r[i] && cells.fl[i] > 100) return "River"; // no River cross penalty, penalty for non-River growth - if (cells.t[i] > 2 && [3, 7, 8, 9, 10, 12].includes(cells.biome[i])) return "Hunting"; // high penalty in non-native biomes - return "Generic"; - } - - function defineCultureExpansionism(type) { - let base = 1; // Generic - if (type === "Lake") base = 0.8; - else if (type === "Naval") base = 1.5; - else if (type === "River") base = 0.9; - else if (type === "Nomadic") base = 1.5; - else if (type === "Hunting") base = 0.7; - else if (type === "Highland") base = 1.2; - return rn(((Math.random() * byId("sizeVariety").value) / 2 + 1) * base, 1); - } - - TIME && console.timeEnd("generateCultures"); - }; - - const add = function (center) { - const defaultCultures = getDefault(); - let culture, base, name; - - if (pack.cultures.length < defaultCultures.length) { - // add one of the default cultures - culture = pack.cultures.length; - base = defaultCultures[culture].base; - name = defaultCultures[culture].name; - } else { - // add random culture besed on one of the current ones - culture = rand(pack.cultures.length - 1); - name = Names.getCulture(culture, 5, 8, ""); - base = pack.cultures[culture].base; - } - - const code = abbreviate( - name, - pack.cultures.map(c => c.code) - ); - const i = pack.cultures.length; - const color = getRandomColor(); - - // define emblem shape - let shield = culture.shield; - const emblemShape = document.getElementById("emblemShape").value; - if (emblemShape === "random") shield = getRandomShield(); - - pack.cultures.push({ - name, - color, - base, - center, - i, - expansionism: 1, - type: "Generic", - cells: 0, - area: 0, - rural: 0, - urban: 0, - origins: [pack.cells.culture[center]], - code, - shield - }); - }; - - const getDefault = function (count) { - // generic sorting functions - const cells = pack.cells, - s = cells.s, - sMax = d3.max(s), - t = cells.t, - h = cells.h, - temp = grid.cells.temp; - const n = cell => Math.ceil((s[cell] / sMax) * 3); // normalized cell score - const td = (cell, goal) => { - const d = Math.abs(temp[cells.g[cell]] - goal); - return d ? d + 1 : 1; - }; // temperature difference fee - const bd = (cell, biomes, fee = 4) => (biomes.includes(cells.biome[cell]) ? 1 : fee); // biome difference fee - const sf = (cell, fee = 4) => - cells.haven[cell] && pack.features[cells.f[cells.haven[cell]]].type !== "lake" ? 1 : fee; // not on sea coast fee - - if (culturesSet.value === "european") { - return [ - {name: "Shwazen", base: 0, odd: 1, sort: i => n(i) / td(i, 10) / bd(i, [6, 8]), shield: "swiss"}, - {name: "Angshire", base: 1, odd: 1, sort: i => n(i) / td(i, 10) / sf(i), shield: "wedged"}, - {name: "Luari", base: 2, odd: 1, sort: i => n(i) / td(i, 12) / bd(i, [6, 8]), shield: "french"}, - {name: "Tallian", base: 3, odd: 1, sort: i => n(i) / td(i, 15), shield: "horsehead"}, - {name: "Astellian", base: 4, odd: 1, sort: i => n(i) / td(i, 16), shield: "spanish"}, - {name: "Slovan", base: 5, odd: 1, sort: i => (n(i) / td(i, 6)) * t[i], shield: "polish"}, - {name: "Norse", base: 6, odd: 1, sort: i => n(i) / td(i, 5), shield: "heater"}, - {name: "Elladan", base: 7, odd: 1, sort: i => (n(i) / td(i, 18)) * h[i], shield: "boeotian"}, - {name: "Romian", base: 8, odd: 0.2, sort: i => n(i) / td(i, 15) / t[i], shield: "roman"}, - {name: "Soumi", base: 9, odd: 1, sort: i => (n(i) / td(i, 5) / bd(i, [9])) * t[i], shield: "pavise"}, - {name: "Portuzian", base: 13, odd: 1, sort: i => n(i) / td(i, 17) / sf(i), shield: "renaissance"}, - {name: "Vengrian", base: 15, odd: 1, sort: i => (n(i) / td(i, 11) / bd(i, [4])) * t[i], shield: "horsehead2"}, - {name: "Turchian", base: 16, odd: 0.05, sort: i => n(i) / td(i, 14), shield: "round"}, - {name: "Euskati", base: 20, odd: 0.05, sort: i => (n(i) / td(i, 15)) * h[i], shield: "oldFrench"}, - {name: "Keltan", base: 22, odd: 0.05, sort: i => (n(i) / td(i, 11) / bd(i, [6, 8])) * t[i], shield: "oval"} - ]; - } - - if (culturesSet.value === "oriental") { - return [ - {name: "Koryo", base: 10, odd: 1, sort: i => n(i) / td(i, 12) / t[i], shield: "round"}, - {name: "Hantzu", base: 11, odd: 1, sort: i => n(i) / td(i, 13), shield: "banner"}, - {name: "Yamoto", base: 12, odd: 1, sort: i => n(i) / td(i, 15) / t[i], shield: "round"}, - {name: "Turchian", base: 16, odd: 1, sort: i => n(i) / td(i, 12), shield: "round"}, - { - name: "Berberan", - base: 17, - odd: 0.2, - sort: i => (n(i) / td(i, 19) / bd(i, [1, 2, 3], 7)) * t[i], - shield: "oval" - }, - {name: "Eurabic", base: 18, odd: 1, sort: i => (n(i) / td(i, 26) / bd(i, [1, 2], 7)) * t[i], shield: "oval"}, - {name: "Efratic", base: 23, odd: 0.1, sort: i => (n(i) / td(i, 22)) * t[i], shield: "round"}, - {name: "Tehrani", base: 24, odd: 1, sort: i => (n(i) / td(i, 18)) * h[i], shield: "round"}, - {name: "Maui", base: 25, odd: 0.2, sort: i => n(i) / td(i, 24) / sf(i) / t[i], shield: "vesicaPiscis"}, - {name: "Carnatic", base: 26, odd: 0.5, sort: i => n(i) / td(i, 26), shield: "round"}, - {name: "Vietic", base: 29, odd: 0.8, sort: i => n(i) / td(i, 25) / bd(i, [7], 7) / t[i], shield: "banner"}, - {name: "Guantzu", base: 30, odd: 0.5, sort: i => n(i) / td(i, 17), shield: "banner"}, - {name: "Ulus", base: 31, odd: 1, sort: i => (n(i) / td(i, 5) / bd(i, [2, 4, 10], 7)) * t[i], shield: "banner"} - ]; - } - - if (culturesSet.value === "english") { - const getName = () => Names.getBase(1, 5, 9, "", 0); - return [ - {name: getName(), base: 1, odd: 1, shield: "heater"}, - {name: getName(), base: 1, odd: 1, shield: "wedged"}, - {name: getName(), base: 1, odd: 1, shield: "swiss"}, - {name: getName(), base: 1, odd: 1, shield: "oldFrench"}, - {name: getName(), base: 1, odd: 1, shield: "swiss"}, - {name: getName(), base: 1, odd: 1, shield: "spanish"}, - {name: getName(), base: 1, odd: 1, shield: "hessen"}, - {name: getName(), base: 1, odd: 1, shield: "fantasy5"}, - {name: getName(), base: 1, odd: 1, shield: "fantasy4"}, - {name: getName(), base: 1, odd: 1, shield: "fantasy1"} - ]; - } - - if (culturesSet.value === "antique") { - return [ - {name: "Roman", base: 8, odd: 1, sort: i => n(i) / td(i, 14) / t[i], shield: "roman"}, // Roman - {name: "Roman", base: 8, odd: 1, sort: i => n(i) / td(i, 15) / sf(i), shield: "roman"}, // Roman - {name: "Roman", base: 8, odd: 1, sort: i => n(i) / td(i, 16) / sf(i), shield: "roman"}, // Roman - {name: "Roman", base: 8, odd: 1, sort: i => n(i) / td(i, 17) / t[i], shield: "roman"}, // Roman - {name: "Hellenic", base: 7, odd: 1, sort: i => (n(i) / td(i, 18) / sf(i)) * h[i], shield: "boeotian"}, // Greek - {name: "Hellenic", base: 7, odd: 1, sort: i => (n(i) / td(i, 19) / sf(i)) * h[i], shield: "boeotian"}, // Greek - {name: "Macedonian", base: 7, odd: 0.5, sort: i => (n(i) / td(i, 12)) * h[i], shield: "round"}, // Greek - {name: "Celtic", base: 22, odd: 1, sort: i => n(i) / td(i, 11) ** 0.5 / bd(i, [6, 8]), shield: "round"}, - {name: "Germanic", base: 0, odd: 1, sort: i => n(i) / td(i, 10) ** 0.5 / bd(i, [6, 8]), shield: "round"}, - {name: "Persian", base: 24, odd: 0.8, sort: i => (n(i) / td(i, 18)) * h[i], shield: "oval"}, // Iranian - {name: "Scythian", base: 24, odd: 0.5, sort: i => n(i) / td(i, 11) ** 0.5 / bd(i, [4]), shield: "round"}, // Iranian - {name: "Cantabrian", base: 20, odd: 0.5, sort: i => (n(i) / td(i, 16)) * h[i], shield: "oval"}, // Basque - {name: "Estian", base: 9, odd: 0.2, sort: i => (n(i) / td(i, 5)) * t[i], shield: "pavise"}, // Finnic - {name: "Carthaginian", base: 42, odd: 0.3, sort: i => n(i) / td(i, 20) / sf(i), shield: "oval"}, // Levantine - {name: "Hebrew", base: 42, odd: 0.2, sort: i => (n(i) / td(i, 19)) * sf(i), shield: "oval"}, // Levantine - {name: "Mesopotamian", base: 23, odd: 0.2, sort: i => n(i) / td(i, 22) / bd(i, [1, 2, 3]), shield: "oval"} // Mesopotamian - ]; - } - - if (culturesSet.value === "highFantasy") { - return [ - // fantasy races - { - name: "Quenian (Elfish)", - base: 33, - odd: 1, - sort: i => (n(i) / bd(i, [6, 7, 8, 9], 10)) * t[i], - shield: "gondor" - }, // Elves - { - name: "Eldar (Elfish)", - base: 33, - odd: 1, - sort: i => (n(i) / bd(i, [6, 7, 8, 9], 10)) * t[i], - shield: "noldor" - }, // Elves - { - name: "Trow (Dark Elfish)", - base: 34, - odd: 0.9, - sort: i => (n(i) / bd(i, [7, 8, 9, 12], 10)) * t[i], - shield: "hessen" - }, // Dark Elves - { - name: "Lothian (Dark Elfish)", - base: 34, - odd: 0.3, - sort: i => (n(i) / bd(i, [7, 8, 9, 12], 10)) * t[i], - shield: "wedged" - }, // Dark Elves - {name: "Dunirr (Dwarven)", base: 35, odd: 1, sort: i => n(i) + h[i], shield: "ironHills"}, // Dwarfs - {name: "Khazadur (Dwarven)", base: 35, odd: 1, sort: i => n(i) + h[i], shield: "erebor"}, // Dwarfs - {name: "Kobold (Goblin)", base: 36, odd: 1, sort: i => t[i] - s[i], shield: "moriaOrc"}, // Goblin - {name: "Uruk (Orkish)", base: 37, odd: 1, sort: i => h[i] * t[i], shield: "urukHai"}, // Orc - { - name: "Ugluk (Orkish)", - base: 37, - odd: 0.5, - sort: i => (h[i] * t[i]) / bd(i, [1, 2, 10, 11]), - shield: "moriaOrc" - }, // Orc - {name: "Yotunn (Giants)", base: 38, odd: 0.7, sort: i => td(i, -10), shield: "pavise"}, // Giant - {name: "Rake (Drakonic)", base: 39, odd: 0.7, sort: i => -s[i], shield: "fantasy2"}, // Draconic - {name: "Arago (Arachnid)", base: 40, odd: 0.7, sort: i => t[i] - s[i], shield: "horsehead2"}, // Arachnid - {name: "Aj'Snaga (Serpents)", base: 41, odd: 0.7, sort: i => n(i) / bd(i, [12], 10), shield: "fantasy1"}, // Serpents - // fantasy human - {name: "Anor (Human)", base: 32, odd: 1, sort: i => n(i) / td(i, 10), shield: "fantasy5"}, - {name: "Dail (Human)", base: 32, odd: 1, sort: i => n(i) / td(i, 13), shield: "roman"}, - {name: "Rohand (Human)", base: 16, odd: 1, sort: i => n(i) / td(i, 16), shield: "round"}, - { - name: "Dulandir (Human)", - base: 31, - odd: 1, - sort: i => (n(i) / td(i, 5) / bd(i, [2, 4, 10], 7)) * t[i], - shield: "easterling" - } - ]; - } - - if (culturesSet.value === "darkFantasy") { - return [ - // common real-world English - {name: "Angshire", base: 1, odd: 1, sort: i => n(i) / td(i, 10) / sf(i), shield: "heater"}, - {name: "Enlandic", base: 1, odd: 1, sort: i => n(i) / td(i, 12), shield: "heater"}, - {name: "Westen", base: 1, odd: 1, sort: i => n(i) / td(i, 10), shield: "heater"}, - {name: "Nortumbic", base: 1, odd: 1, sort: i => n(i) / td(i, 7), shield: "heater"}, - {name: "Mercian", base: 1, odd: 1, sort: i => n(i) / td(i, 9), shield: "heater"}, - {name: "Kentian", base: 1, odd: 1, sort: i => n(i) / td(i, 12), shield: "heater"}, - // rare real-world western - {name: "Norse", base: 6, odd: 0.7, sort: i => n(i) / td(i, 5) / sf(i), shield: "oldFrench"}, - {name: "Schwarzen", base: 0, odd: 0.3, sort: i => n(i) / td(i, 10) / bd(i, [6, 8]), shield: "gonfalon"}, - {name: "Luarian", base: 2, odd: 0.3, sort: i => n(i) / td(i, 12) / bd(i, [6, 8]), shield: "oldFrench"}, - {name: "Hetallian", base: 3, odd: 0.3, sort: i => n(i) / td(i, 15), shield: "oval"}, - {name: "Astellian", base: 4, odd: 0.3, sort: i => n(i) / td(i, 16), shield: "spanish"}, - // rare real-world exotic - { - name: "Kiswaili", - base: 28, - odd: 0.05, - sort: i => n(i) / td(i, 29) / bd(i, [1, 3, 5, 7]), - shield: "vesicaPiscis" - }, - {name: "Yoruba", base: 21, odd: 0.05, sort: i => n(i) / td(i, 15) / bd(i, [5, 7]), shield: "vesicaPiscis"}, - {name: "Koryo", base: 10, odd: 0.05, sort: i => n(i) / td(i, 12) / t[i], shield: "round"}, - {name: "Hantzu", base: 11, odd: 0.05, sort: i => n(i) / td(i, 13), shield: "banner"}, - {name: "Yamoto", base: 12, odd: 0.05, sort: i => n(i) / td(i, 15) / t[i], shield: "round"}, - {name: "Guantzu", base: 30, odd: 0.05, sort: i => n(i) / td(i, 17), shield: "banner"}, - { - name: "Ulus", - base: 31, - odd: 0.05, - sort: i => (n(i) / td(i, 5) / bd(i, [2, 4, 10], 7)) * t[i], - shield: "banner" - }, - {name: "Turan", base: 16, odd: 0.05, sort: i => n(i) / td(i, 12), shield: "round"}, - { - name: "Berberan", - base: 17, - odd: 0.05, - sort: i => (n(i) / td(i, 19) / bd(i, [1, 2, 3], 7)) * t[i], - shield: "round" - }, - { - name: "Eurabic", - base: 18, - odd: 0.05, - sort: i => (n(i) / td(i, 26) / bd(i, [1, 2], 7)) * t[i], - shield: "round" - }, - {name: "Slovan", base: 5, odd: 0.05, sort: i => (n(i) / td(i, 6)) * t[i], shield: "round"}, - { - name: "Keltan", - base: 22, - odd: 0.1, - sort: i => n(i) / td(i, 11) ** 0.5 / bd(i, [6, 8]), - shield: "vesicaPiscis" - }, - {name: "Elladan", base: 7, odd: 0.2, sort: i => (n(i) / td(i, 18) / sf(i)) * h[i], shield: "boeotian"}, - {name: "Romian", base: 8, odd: 0.2, sort: i => n(i) / td(i, 14) / t[i], shield: "roman"}, - // fantasy races - {name: "Eldar", base: 33, odd: 0.5, sort: i => (n(i) / bd(i, [6, 7, 8, 9], 10)) * t[i], shield: "fantasy5"}, // Elves - {name: "Trow", base: 34, odd: 0.8, sort: i => (n(i) / bd(i, [7, 8, 9, 12], 10)) * t[i], shield: "hessen"}, // Dark Elves - {name: "Durinn", base: 35, odd: 0.8, sort: i => n(i) + h[i], shield: "erebor"}, // Dwarven - {name: "Kobblin", base: 36, odd: 0.8, sort: i => t[i] - s[i], shield: "moriaOrc"}, // Goblin - {name: "Uruk", base: 37, odd: 0.8, sort: i => (h[i] * t[i]) / bd(i, [1, 2, 10, 11]), shield: "urukHai"}, // Orc - {name: "Yotunn", base: 38, odd: 0.8, sort: i => td(i, -10), shield: "pavise"}, // Giant - {name: "Drake", base: 39, odd: 0.9, sort: i => -s[i], shield: "fantasy2"}, // Draconic - {name: "Rakhnid", base: 40, odd: 0.9, sort: i => t[i] - s[i], shield: "horsehead2"}, // Arachnid - {name: "Aj'Snaga", base: 41, odd: 0.9, sort: i => n(i) / bd(i, [12], 10), shield: "fantasy1"} // Serpents - ]; - } - - if (culturesSet.value === "random") { - return d3.range(count).map(function () { - const rnd = rand(nameBases.length - 1); - const name = Names.getBaseShort(rnd); - return {name, base: rnd, odd: 1, shield: getRandomShield()}; - }); - } - - // all-world - return [ - {name: "Shwazen", base: 0, odd: 0.7, sort: i => n(i) / td(i, 10) / bd(i, [6, 8]), shield: "hessen"}, - {name: "Angshire", base: 1, odd: 1, sort: i => n(i) / td(i, 10) / sf(i), shield: "heater"}, - {name: "Luari", base: 2, odd: 0.6, sort: i => n(i) / td(i, 12) / bd(i, [6, 8]), shield: "oldFrench"}, - {name: "Tallian", base: 3, odd: 0.6, sort: i => n(i) / td(i, 15), shield: "horsehead2"}, - {name: "Astellian", base: 4, odd: 0.6, sort: i => n(i) / td(i, 16), shield: "spanish"}, - {name: "Slovan", base: 5, odd: 0.7, sort: i => (n(i) / td(i, 6)) * t[i], shield: "round"}, - {name: "Norse", base: 6, odd: 0.7, sort: i => n(i) / td(i, 5), shield: "heater"}, - {name: "Elladan", base: 7, odd: 0.7, sort: i => (n(i) / td(i, 18)) * h[i], shield: "boeotian"}, - {name: "Romian", base: 8, odd: 0.7, sort: i => n(i) / td(i, 15), shield: "roman"}, - {name: "Soumi", base: 9, odd: 0.3, sort: i => (n(i) / td(i, 5) / bd(i, [9])) * t[i], shield: "pavise"}, - {name: "Koryo", base: 10, odd: 0.1, sort: i => n(i) / td(i, 12) / t[i], shield: "round"}, - {name: "Hantzu", base: 11, odd: 0.1, sort: i => n(i) / td(i, 13), shield: "banner"}, - {name: "Yamoto", base: 12, odd: 0.1, sort: i => n(i) / td(i, 15) / t[i], shield: "round"}, - {name: "Portuzian", base: 13, odd: 0.4, sort: i => n(i) / td(i, 17) / sf(i), shield: "spanish"}, - {name: "Nawatli", base: 14, odd: 0.1, sort: i => h[i] / td(i, 18) / bd(i, [7]), shield: "square"}, - {name: "Vengrian", base: 15, odd: 0.2, sort: i => (n(i) / td(i, 11) / bd(i, [4])) * t[i], shield: "wedged"}, - {name: "Turchian", base: 16, odd: 0.2, sort: i => n(i) / td(i, 13), shield: "round"}, - { - name: "Berberan", - base: 17, - odd: 0.1, - sort: i => (n(i) / td(i, 19) / bd(i, [1, 2, 3], 7)) * t[i], - shield: "round" - }, - {name: "Eurabic", base: 18, odd: 0.2, sort: i => (n(i) / td(i, 26) / bd(i, [1, 2], 7)) * t[i], shield: "round"}, - {name: "Inuk", base: 19, odd: 0.05, sort: i => td(i, -1) / bd(i, [10, 11]) / sf(i), shield: "square"}, - {name: "Euskati", base: 20, odd: 0.05, sort: i => (n(i) / td(i, 15)) * h[i], shield: "spanish"}, - {name: "Yoruba", base: 21, odd: 0.05, sort: i => n(i) / td(i, 15) / bd(i, [5, 7]), shield: "vesicaPiscis"}, - { - name: "Keltan", - base: 22, - odd: 0.05, - sort: i => (n(i) / td(i, 11) / bd(i, [6, 8])) * t[i], - shield: "vesicaPiscis" - }, - {name: "Efratic", base: 23, odd: 0.05, sort: i => (n(i) / td(i, 22)) * t[i], shield: "diamond"}, - {name: "Tehrani", base: 24, odd: 0.1, sort: i => (n(i) / td(i, 18)) * h[i], shield: "round"}, - {name: "Maui", base: 25, odd: 0.05, sort: i => n(i) / td(i, 24) / sf(i) / t[i], shield: "round"}, - {name: "Carnatic", base: 26, odd: 0.05, sort: i => n(i) / td(i, 26), shield: "round"}, - {name: "Inqan", base: 27, odd: 0.05, sort: i => h[i] / td(i, 13), shield: "square"}, - {name: "Kiswaili", base: 28, odd: 0.1, sort: i => n(i) / td(i, 29) / bd(i, [1, 3, 5, 7]), shield: "vesicaPiscis"}, - {name: "Vietic", base: 29, odd: 0.1, sort: i => n(i) / td(i, 25) / bd(i, [7], 7) / t[i], shield: "banner"}, - {name: "Guantzu", base: 30, odd: 0.1, sort: i => n(i) / td(i, 17), shield: "banner"}, - {name: "Ulus", base: 31, odd: 0.1, sort: i => (n(i) / td(i, 5) / bd(i, [2, 4, 10], 7)) * t[i], shield: "banner"}, - {name: "Hebrew", base: 42, odd: 0.2, sort: i => (n(i) / td(i, 18)) * sf(i), shield: "oval"} // Levantine - ]; - }; - - // expand cultures across the map (Dijkstra-like algorithm) - const expand = function () { - TIME && console.time("expandCultures"); - const {cells, cultures} = pack; - - const queue = new FlatQueue(); - const cost = []; - - const neutralRate = byId("neutralRate")?.valueAsNumber || 1; - const maxExpansionCost = cells.i.length * 0.6 * neutralRate; // limit cost for culture growth - - // remove culture from all cells except of locked - const hasLocked = cultures.some(c => !c.removed && c.lock); - if (hasLocked) { - for (const cellId of cells.i) { - const culture = cultures[cells.culture[cellId]]; - if (culture.lock) continue; - cells.culture[cellId] = 0; - } - } else { - cells.culture = new Uint16Array(cells.i.length); - } - - for (const culture of cultures) { - if (!culture.i || culture.removed || culture.lock) continue; - queue.push({cellId: culture.center, cultureId: culture.i, priority: 0}, 0); - } - - while (queue.length) { - const {cellId, priority, cultureId} = queue.pop(); - const {type, expansionism} = cultures[cultureId]; - - cells.c[cellId].forEach(neibCellId => { - if (hasLocked) { - const neibCultureId = cells.culture[neibCellId]; - if (neibCultureId && cultures[neibCultureId].lock) return; // do not overwrite cell of locked culture - } - - const biome = cells.biome[neibCellId]; - const biomeCost = getBiomeCost(cultureId, biome, type); - const biomeChangeCost = biome === cells.biome[neibCellId] ? 0 : 20; // penalty on biome change - const heightCost = getHeightCost(neibCellId, cells.h[neibCellId], type); - const riverCost = getRiverCost(cells.r[neibCellId], neibCellId, type); - const typeCost = getTypeCost(cells.t[neibCellId], type); - - const cellCost = (biomeCost + biomeChangeCost + heightCost + riverCost + typeCost) / expansionism; - const totalCost = priority + cellCost; - - if (totalCost > maxExpansionCost) return; - - if (!cost[neibCellId] || totalCost < cost[neibCellId]) { - if (cells.pop[neibCellId] > 0) cells.culture[neibCellId] = cultureId; // assign culture to populated cell - cost[neibCellId] = totalCost; - queue.push({cellId: neibCellId, cultureId, priority: totalCost}, totalCost); - } - }); - } - - function getBiomeCost(c, biome, type) { - if (cells.biome[cultures[c].center] === biome) return 10; // tiny penalty for native biome - if (type === "Hunting") return biomesData.cost[biome] * 5; // non-native biome penalty for hunters - if (type === "Nomadic" && biome > 4 && biome < 10) return biomesData.cost[biome] * 10; // forest biome penalty for nomads - return biomesData.cost[biome] * 2; // general non-native biome penalty - } - - function getHeightCost(i, h, type) { - const f = pack.features[cells.f[i]], - a = cells.area[i]; - if (type === "Lake" && f.type === "lake") return 10; // no lake crossing penalty for Lake cultures - if (type === "Naval" && h < 20) return a * 2; // low sea/lake crossing penalty for Naval cultures - if (type === "Nomadic" && h < 20) return a * 50; // giant sea/lake crossing penalty for Nomads - if (h < 20) return a * 6; // general sea/lake crossing penalty - if (type === "Highland" && h < 44) return 3000; // giant penalty for highlanders on lowlands - if (type === "Highland" && h < 62) return 200; // giant penalty for highlanders on lowhills - if (type === "Highland") return 0; // no penalty for highlanders on highlands - if (h >= 67) return 200; // general mountains crossing penalty - if (h >= 44) return 30; // general hills crossing penalty - return 0; - } - - function getRiverCost(riverId, cellId, type) { - if (type === "River") return riverId ? 0 : 100; // penalty for river cultures - if (!riverId) return 0; // no penalty for others if there is no river - return minmax(cells.fl[cellId] / 10, 20, 100); // river penalty from 20 to 100 based on flux - } - - function getTypeCost(t, type) { - if (t === 1) return type === "Naval" || type === "Lake" ? 0 : type === "Nomadic" ? 60 : 20; // penalty for coastline - if (t === 2) return type === "Naval" || type === "Nomadic" ? 30 : 0; // low penalty for land level 2 for Navals and nomads - if (t !== -1) return type === "Naval" || type === "Lake" ? 100 : 0; // penalty for mainland for navals - return 0; - } - - TIME && console.timeEnd("expandCultures"); - }; - - const getRandomShield = function () { - const type = rw(COA.shields.types); - return rw(COA.shields[type]); - }; - - return {generate, add, expand, getDefault, getRandomShield}; -})(); diff --git a/public/modules/dynamic/auto-update.js b/public/modules/dynamic/auto-update.js index a3190e3b..0b1cd227 100644 --- a/public/modules/dynamic/auto-update.js +++ b/public/modules/dynamic/auto-update.js @@ -253,8 +253,8 @@ export function resolveVersionConflicts(mapVersion) { const source = findCell(s.x, s.y); const mouth = findCell(e.x, e.y); const name = Rivers.getName(mouth); - const type = length < 25 ? rw({Creek: 9, River: 3, Brook: 3, Stream: 1}) : "River"; - pack.rivers.push({i, parent: 0, length, source, mouth, basin: i, name, type}); + const type = length < 25 ? rw({ Creek: 9, River: 3, Brook: 3, Stream: 1 }) : "River"; + pack.rivers.push({ i, parent: 0, length, source, mouth, basin: i, name, type }); }); } @@ -270,7 +270,7 @@ export function resolveVersionConflicts(mapVersion) { const era = Names.getBaseShort(P(0.7) ? 1 : rand(nameBases.length)) + " Era"; const eraShort = era[0] + "E"; const military = Military.getDefaultOptions(); - options = {winds, year, era, eraShort, military}; + options = { winds, year, era, eraShort, military }; // v1.3 added campaings data for all states States.generateCampaigns(); @@ -481,7 +481,7 @@ export function resolveVersionConflicts(mapVersion) { if (isOlderThan("1.65.0")) { // v1.65 changed rivers data d3.select("#rivers").attr("style", null); // remove style to unhide layer - const {cells, rivers} = pack; + const { cells, rivers } = pack; const defaultWidthFactor = rn(1 / (pointsInput.dataset.cells / 10000) ** 0.25, 2); for (const river of rivers) { @@ -497,8 +497,8 @@ export function resolveVersionConflicts(mapVersion) { for (let i = 0; i <= segments; i++) { const shift = increment * i; - const {x: x1, y: y1} = node.getPointAtLength(length + shift); - const {x: x2, y: y2} = node.getPointAtLength(length - shift); + const { x: x1, y: y1 } = node.getPointAtLength(length + shift); + const { x: x2, y: y2 } = node.getPointAtLength(length - shift); const x = rn((x1 + x2) / 2, 1); const y = rn((y1 + y2) / 2, 1); @@ -565,7 +565,7 @@ export function resolveVersionConflicts(mapVersion) { const fill = circle && circle.getAttribute("fill"); const stroke = circle && circle.getAttribute("stroke"); - const marker = {i, icon, type, x, y, size, cell}; + const marker = { i, icon, type, x, y, size, cell }; if (size && size !== 30) marker.size = size; if (!isNaN(px) && px !== 12) marker.px = px; if (!isNaN(dx) && dx !== 50) marker.dx = dx; @@ -631,7 +631,7 @@ export function resolveVersionConflicts(mapVersion) { if (isOlderThan("1.88.0")) { // v1.87 may have incorrect shield for some reason - pack.states.forEach(({coa}) => { + pack.states.forEach(({ coa }) => { if (coa?.shield === "state") delete coa.shield; }); } @@ -639,13 +639,13 @@ export function resolveVersionConflicts(mapVersion) { if (isOlderThan("1.91.0")) { // from 1.91.00 custom coa is moved to coa object pack.states.forEach(state => { - if (state.coa === "custom") state.coa = {custom: true}; + if (state.coa === "custom") state.coa = { custom: true }; }); pack.provinces.forEach(province => { - if (province.coa === "custom") province.coa = {custom: true}; + if (province.coa === "custom") province.coa = { custom: true }; }); pack.burgs.forEach(burg => { - if (burg.coa === "custom") burg.coa = {custom: true}; + if (burg.coa === "custom") burg.coa = { custom: true }; }); // from 1.91.00 emblems don't have transform attribute @@ -747,7 +747,7 @@ export function resolveVersionConflicts(mapVersion) { const skip = terrs.attr("skip"); const relax = terrs.attr("relax"); - const curveTypes = {0: "curveBasisClosed", 1: "curveLinear", 2: "curveStep"}; + const curveTypes = { 0: "curveBasisClosed", 1: "curveLinear", 2: "curveStep" }; const curve = curveTypes[terrs.attr("curve")] || "curveBasisClosed"; terrs @@ -882,7 +882,7 @@ export function resolveVersionConflicts(mapVersion) { const secondCellId = points[1][2]; const feature = pack.cells.f[secondCellId]; - pack.routes.push({i: pack.routes.length, group, feature, points}); + pack.routes.push({ i: pack.routes.length, group, feature, points }); } } routes.selectAll("path").remove(); @@ -914,7 +914,7 @@ export function resolveVersionConflicts(mapVersion) { const type = this.dataset.type; const color = this.getAttribute("fill"); const cells = this.dataset.cells.split(",").map(Number); - pack.zones.push({i, name, type, cells, color}); + pack.zones.push({ i, name, type, cells, color }); }); zones.style("display", null).selectAll("*").remove(); if (layerIsOn("toggleZones")) drawZones(); @@ -975,7 +975,7 @@ export function resolveVersionConflicts(mapVersion) { if (isOlderThan("1.109.0")) { // v1.109.0 added customizable burg groups and icons - options.burgs = {groups: []}; + options.burgs = { groups: [] }; burgIcons.selectAll("circle, use").each(function () { const group = this.parentNode.id; @@ -987,7 +987,7 @@ export function resolveVersionConflicts(mapVersion) { burgIcons.selectAll("g").each(function (_el, index) { const name = this.id; const isDefault = name === "towns"; - options.burgs.groups.push({name, active: true, order: index + 1, isDefault, preview: "watabou-city"}); + options.burgs.groups.push({ name, active: true, order: index + 1, isDefault, preview: "watabou-city" }); if (!this.dataset.icon) this.dataset.icon = "#icon-circle"; const size = Number(this.getAttribute("size") || 2) * 2; @@ -1036,4 +1036,74 @@ export function resolveVersionConflicts(mapVersion) { delete options.showMFCGMap; delete options.villageMaxPopulation; } + + if (isOlderThan("1.111.0")) { + // v1.111.0 moved ice data from SVG to data model + // Migrate old ice SVG elements to new pack.ice structure + if (!pack.ice.length) { + pack.ice = []; + let iceId = 0; + + const iceLayer = document.getElementById("ice"); + if (iceLayer) { + // Migrate glaciers (type="iceShield") + iceLayer.querySelectorAll("polygon[type='iceShield']").forEach(polygon => { + // Parse points string "x1,y1 x2,y2 x3,y3 ..." into array [[x1,y1], [x2,y2], ...] + const points = [...polygon.points].map(svgPoint => [svgPoint.x, svgPoint.y]); + + const transform = polygon.getAttribute("transform"); + const iceElement = { + i: iceId++, + points, + type: "glacier" + }; + if (transform) { + iceElement.offset = parseTransform(transform); + } + pack.ice.push(iceElement); + }); + + // Migrate icebergs + iceLayer.querySelectorAll("polygon:not([type])").forEach(polygon => { + const cellId = +polygon.getAttribute("cell"); + const size = +polygon.getAttribute("size"); + + // points string must exist, cell attribute must be present, and size must be non-zero + if (polygon.getAttribute("cell") === null || !size) return; + + // Parse points string "x1,y1 x2,y2 x3,y3 ..." into array [[x1,y1], [x2,y2], ...] + const points = [...polygon.points].map(svgPoint => [svgPoint.x, svgPoint.y]); + + const transform = polygon.getAttribute("transform"); + const iceElement = { + i: iceId++, + points, + type: "iceberg", + cellId, + size + }; + if (transform) { + iceElement.offset = parseTransform(transform); + } + pack.ice.push(iceElement); + }); + + // Clear old SVG elements + iceLayer.querySelectorAll("*").forEach(el => el.remove()); + } else { + // If ice layer element doesn't exist, create it + ice = viewbox.insert("g", "#coastline").attr("id", "ice"); + ice + .attr("opacity", null) + .attr("fill", "#e8f0f6") + .attr("stroke", "#e8f0f6") + .attr("stroke-width", 1) + .attr("filter", "url(#dropShadow05)"); + } + + // Re-render ice from migrated data + if (layerIsOn("toggleIce")) drawIce(); + } + + } } diff --git a/public/modules/features.js b/public/modules/features.js deleted file mode 100644 index 714d4f38..00000000 --- a/public/modules/features.js +++ /dev/null @@ -1,267 +0,0 @@ -"use strict"; - -window.Features = (function () { - const DEEPER_LAND = 3; - const LANDLOCKED = 2; - const LAND_COAST = 1; - const UNMARKED = 0; - const WATER_COAST = -1; - const DEEP_WATER = -2; - - // calculate distance to coast for every cell - function markup({distanceField, neighbors, start, increment, limit = INT8_MAX}) { - for (let distance = start, marked = Infinity; marked > 0 && distance !== limit; distance += increment) { - marked = 0; - const prevDistance = distance - increment; - for (let cellId = 0; cellId < neighbors.length; cellId++) { - if (distanceField[cellId] !== prevDistance) continue; - - for (const neighborId of neighbors[cellId]) { - if (distanceField[neighborId] !== UNMARKED) continue; - distanceField[neighborId] = distance; - marked++; - } - } - } - } - - // mark Grid features (ocean, lakes, islands) and calculate distance field - function markupGrid() { - TIME && console.time("markupGrid"); - Math.random = aleaPRNG(seed); // get the same result on heightmap edit in Erase mode - - const {h: heights, c: neighbors, b: borderCells, i} = grid.cells; - const cellsNumber = i.length; - const distanceField = new Int8Array(cellsNumber); // gird.cells.t - const featureIds = new Uint16Array(cellsNumber); // gird.cells.f - const features = [0]; - - const queue = [0]; - for (let featureId = 1; queue[0] !== -1; featureId++) { - const firstCell = queue[0]; - featureIds[firstCell] = featureId; - - const land = heights[firstCell] >= 20; - let border = false; // set true if feature touches map edge - - while (queue.length) { - const cellId = queue.pop(); - if (!border && borderCells[cellId]) border = true; - - for (const neighborId of neighbors[cellId]) { - const isNeibLand = heights[neighborId] >= 20; - - if (land === isNeibLand && featureIds[neighborId] === UNMARKED) { - featureIds[neighborId] = featureId; - queue.push(neighborId); - } else if (land && !isNeibLand) { - distanceField[cellId] = LAND_COAST; - distanceField[neighborId] = WATER_COAST; - } - } - } - - const type = land ? "island" : border ? "ocean" : "lake"; - features.push({i: featureId, land, border, type}); - - queue[0] = featureIds.findIndex(f => f === UNMARKED); // find unmarked cell - } - - // markup deep ocean cells - markup({distanceField, neighbors, start: DEEP_WATER, increment: -1, limit: -10}); - - grid.cells.t = distanceField; - grid.cells.f = featureIds; - grid.features = features; - - TIME && console.timeEnd("markupGrid"); - } - - // mark Pack features (ocean, lakes, islands), calculate distance field and add properties - function markupPack() { - TIME && console.time("markupPack"); - - const {cells, vertices} = pack; - const {c: neighbors, b: borderCells, i} = cells; - const packCellsNumber = i.length; - if (!packCellsNumber) return; // no cells -> there is nothing to do - - const distanceField = new Int8Array(packCellsNumber); // pack.cells.t - const featureIds = new Uint16Array(packCellsNumber); // pack.cells.f - const haven = createTypedArray({maxValue: packCellsNumber, length: packCellsNumber}); // haven: opposite water cell - const harbor = new Uint8Array(packCellsNumber); // harbor: number of adjacent water cells - const features = [0]; - - const queue = [0]; - for (let featureId = 1; queue[0] !== -1; featureId++) { - const firstCell = queue[0]; - featureIds[firstCell] = featureId; - - const land = isLand(firstCell); - let border = Boolean(borderCells[firstCell]); // true if feature touches map border - let totalCells = 1; // count cells in a feature - - while (queue.length) { - const cellId = queue.pop(); - if (borderCells[cellId]) border = true; - if (!border && borderCells[cellId]) border = true; - - for (const neighborId of neighbors[cellId]) { - const isNeibLand = isLand(neighborId); - - if (land && !isNeibLand) { - distanceField[cellId] = LAND_COAST; - distanceField[neighborId] = WATER_COAST; - if (!haven[cellId]) defineHaven(cellId); - } else if (land && isNeibLand) { - if (distanceField[neighborId] === UNMARKED && distanceField[cellId] === LAND_COAST) - distanceField[neighborId] = LANDLOCKED; - else if (distanceField[cellId] === UNMARKED && distanceField[neighborId] === LAND_COAST) - distanceField[cellId] = LANDLOCKED; - } - - if (!featureIds[neighborId] && land === isNeibLand) { - queue.push(neighborId); - featureIds[neighborId] = featureId; - totalCells++; - } - } - } - - features.push(addFeature({firstCell, land, border, featureId, totalCells})); - queue[0] = featureIds.findIndex(f => f === UNMARKED); // find unmarked cell - } - - markup({distanceField, neighbors, start: DEEPER_LAND, increment: 1}); // markup pack land - markup({distanceField, neighbors, start: DEEP_WATER, increment: -1, limit: -10}); // markup pack water - - pack.cells.t = distanceField; - pack.cells.f = featureIds; - pack.cells.haven = haven; - pack.cells.harbor = harbor; - pack.features = features; - - TIME && console.timeEnd("markupPack"); - - function defineHaven(cellId) { - const waterCells = neighbors[cellId].filter(isWater); - const distances = waterCells.map(neibCellId => dist2(cells.p[cellId], cells.p[neibCellId])); - const closest = distances.indexOf(Math.min.apply(Math, distances)); - - haven[cellId] = waterCells[closest]; - harbor[cellId] = waterCells.length; - } - - function addFeature({firstCell, land, border, featureId, totalCells}) { - const type = land ? "island" : border ? "ocean" : "lake"; - const [startCell, featureVertices] = getCellsData(type, firstCell); - const points = clipPoly(featureVertices.map(vertex => vertices.p[vertex])); - const area = d3.polygonArea(points); // feature perimiter area - const absArea = Math.abs(rn(area)); - - const feature = { - i: featureId, - type, - land, - border, - cells: totalCells, - firstCell: startCell, - vertices: featureVertices, - area: absArea - }; - - if (type === "lake") { - if (area > 0) feature.vertices = feature.vertices.reverse(); - feature.shoreline = unique(feature.vertices.map(vertex => vertices.c[vertex].filter(isLand)).flat()); - feature.height = Lakes.getHeight(feature); - } - - return feature; - - function getCellsData(featureType, firstCell) { - if (featureType === "ocean") return [firstCell, []]; - - const getType = cellId => featureIds[cellId]; - const type = getType(firstCell); - const ofSameType = cellId => getType(cellId) === type; - const ofDifferentType = cellId => getType(cellId) !== type; - - const startCell = findOnBorderCell(firstCell); - const featureVertices = getFeatureVertices(startCell); - return [startCell, featureVertices]; - - function findOnBorderCell(firstCell) { - const isOnBorder = cellId => borderCells[cellId] || neighbors[cellId].some(ofDifferentType); - if (isOnBorder(firstCell)) return firstCell; - - const startCell = cells.i.filter(ofSameType).find(isOnBorder); - if (startCell === undefined) - throw new Error(`Markup: firstCell ${firstCell} is not on the feature or map border`); - - return startCell; - } - - function getFeatureVertices(startCell) { - const startingVertex = cells.v[startCell].find(v => vertices.c[v].some(ofDifferentType)); - if (startingVertex === undefined) - throw new Error(`Markup: startingVertex for cell ${startCell} is not found`); - - return connectVertices({vertices, startingVertex, ofSameType, closeRing: false}); - } - } - } - } - - // add properties to pack features - function defineGroups() { - const gridCellsNumber = grid.cells.i.length; - const OCEAN_MIN_SIZE = gridCellsNumber / 25; - const SEA_MIN_SIZE = gridCellsNumber / 1000; - const CONTINENT_MIN_SIZE = gridCellsNumber / 10; - const ISLAND_MIN_SIZE = gridCellsNumber / 1000; - - for (const feature of pack.features) { - if (!feature || feature.type === "ocean") continue; - - if (feature.type === "lake") feature.height = Lakes.getHeight(feature); - feature.group = defineGroup(feature); - } - - function defineGroup(feature) { - if (feature.type === "island") return defineIslandGroup(feature); - if (feature.type === "ocean") return defineOceanGroup(); - if (feature.type === "lake") return defineLakeGroup(feature); - throw new Error(`Markup: unknown feature type ${feature.type}`); - } - - function defineOceanGroup(feature) { - if (feature.cells > OCEAN_MIN_SIZE) return "ocean"; - if (feature.cells > SEA_MIN_SIZE) return "sea"; - return "gulf"; - } - - function defineIslandGroup(feature) { - const prevFeature = pack.features[pack.cells.f[feature.firstCell - 1]]; - if (prevFeature && prevFeature.type === "lake") return "lake_island"; - if (feature.cells > CONTINENT_MIN_SIZE) return "continent"; - if (feature.cells > ISLAND_MIN_SIZE) return "island"; - return "isle"; - } - - function defineLakeGroup(feature) { - if (feature.temp < -3) return "frozen"; - if (feature.height > 60 && feature.cells < 10 && feature.firstCell % 10 === 0) return "lava"; - - if (!feature.inlets && !feature.outlet) { - if (feature.evaporation > feature.flux * 4) return "dry"; - if (feature.cells < 3 && feature.firstCell % 10 === 0) return "sinkhole"; - } - - if (!feature.outlet && feature.evaporation > feature.flux) return "salt"; - - return "freshwater"; - } - } - - return {markupGrid, markupPack, defineGroups}; -})(); diff --git a/public/modules/ice.js b/public/modules/ice.js new file mode 100644 index 00000000..90c7c3e6 --- /dev/null +++ b/public/modules/ice.js @@ -0,0 +1,170 @@ +"use strict"; + +// Ice layer data model - separates ice data from SVG rendering +window.Ice = (function () { + + // Find next available id for new ice element idealy filling gaps + function getNextId() { + if (pack.ice.length === 0) return 0; + // find gaps in existing ids + const existingIds = pack.ice.map(e => e.i).sort((a, b) => a - b); + for (let id = 0; id < existingIds[existingIds.length - 1]; id++) { + if (!existingIds.includes(id)) return id; + } + return existingIds[existingIds.length - 1] + 1; + } + + // Generate glaciers and icebergs based on temperature and height + function generate() { + clear(); + const { cells, features } = grid; + const { temp, h } = cells; + Math.random = aleaPRNG(seed); + + const ICEBERG_MAX_TEMP = 0; + const GLACIER_MAX_TEMP = -8; + const minMaxTemp = d3.min(temp); + + // Generate glaciers on cold land + { + const type = "iceShield"; + const getType = cellId => + h[cellId] >= 20 && temp[cellId] <= GLACIER_MAX_TEMP ? type : null; + const isolines = getIsolines(grid, getType, { polygons: true }); + + if (isolines[type]?.polygons) { + isolines[type].polygons.forEach(points => { + const clipped = clipPoly(points); + pack.ice.push({ + i: getNextId(), + points: clipped, + type: "glacier" + }); + }); + } + } + + // Generate icebergs on cold water + for (const cellId of grid.cells.i) { + const t = temp[cellId]; + if (h[cellId] >= 20) continue; // no icebergs on land + if (t > ICEBERG_MAX_TEMP) continue; // too warm: no icebergs + if (features[cells.f[cellId]].type === "lake") continue; // no icebergs on lakes + if (P(0.8)) continue; // skip most of eligible cells + + const randomFactor = 0.8 + rand() * 0.4; // random size factor + let baseSize = (1 - normalize(t, minMaxTemp, 1)) * 0.8; // size: 0 = zero, 1 = full + if (cells.t[cellId] === -1) baseSize /= 1.3; // coastline: smaller icebergs + const size = minmax(rn(baseSize * randomFactor, 2), 0.1, 1); + + const [cx, cy] = grid.points[cellId]; + const points = getGridPolygon(cellId).map(([x, y]) => [ + rn(lerp(cx, x, size), 2), + rn(lerp(cy, y, size), 2) + ]); + + pack.ice.push({ + i: getNextId(), + points, + type: "iceberg", + cellId, + size + }); + } + } + + function addIceberg(cellId, size) { + const [cx, cy] = grid.points[cellId]; + const points = getGridPolygon(cellId).map(([x, y]) => [ + rn(lerp(cx, x, size), 2), + rn(lerp(cy, y, size), 2) + ]); + const id = getNextId(); + pack.ice.push({ + i: id, + points, + type: "iceberg", + cellId, + size + }); + redrawIceberg(id); + } + + function removeIce(id) { + const index = pack.ice.findIndex(element => element.i === id); + if (index !== -1) { + const type = pack.ice.find(element => element.i === id).type; + pack.ice.splice(index, 1); + if (type === "glacier") { + redrawGlacier(id); + } else { + redrawIceberg(id); + } + + } + } + + function updateIceberg(id, points, size) { + const iceberg = pack.ice.find(element => element.i === id); + if (iceberg) { + iceberg.points = points; + iceberg.size = size; + } + } + + function randomizeIcebergShape(id) { + const iceberg = pack.ice.find(element => element.i === id); + if (!iceberg) return; + + const cellId = iceberg.cellId; + const size = iceberg.size; + const [cx, cy] = grid.points[cellId]; + + // Get a different random cell for the polygon template + const i = ra(grid.cells.i); + const cn = grid.points[i]; + const poly = getGridPolygon(i).map(p => [p[0] - cn[0], p[1] - cn[1]]); + const points = poly.map(p => [ + rn(cx + p[0] * size, 2), + rn(cy + p[1] * size, 2) + ]); + + iceberg.points = points; + } + + function changeIcebergSize(id, newSize) { + const iceberg = pack.ice.find(element => element.i === id); + if (!iceberg) return; + + const cellId = iceberg.cellId; + const [cx, cy] = grid.points[cellId]; + const oldSize = iceberg.size; + + const flat = iceberg.points.flat(); + const pairs = []; + while (flat.length) pairs.push(flat.splice(0, 2)); + const poly = pairs.map(p => [(p[0] - cx) / oldSize, (p[1] - cy) / oldSize]); + const points = poly.map(p => [ + rn(cx + p[0] * newSize, 2), + rn(cy + p[1] * newSize, 2) + ]); + + iceberg.points = points; + iceberg.size = newSize; + } + + // Clear all ice + function clear() { + pack.ice = []; + } + + return { + generate, + addIceberg, + removeIce, + updateIceberg, + randomizeIcebergShape, + changeIcebergSize, + clear + }; +})(); diff --git a/public/modules/io/load.js b/public/modules/io/load.js index 689757b2..9b401733 100644 --- a/public/modules/io/load.js +++ b/public/modules/io/load.js @@ -406,6 +406,7 @@ async function parseLoadedData(data, mapVersion) { pack.cells.province = data[27] ? Uint16Array.from(data[27].split(",")) : new Uint16Array(pack.cells.i.length); // data[28] had deprecated cells.crossroad pack.cells.routes = data[36] ? JSON.parse(data[36]) : {}; + pack.ice = data[39] ? JSON.parse(data[39]) : []; if (data[31]) { const namesDL = data[31].split("/"); @@ -449,7 +450,7 @@ async function parseLoadedData(data, mapVersion) { if (isVisible(routes) && hasChild(routes, "path")) turnOn("toggleRoutes"); if (hasChildren(temperature)) turnOn("toggleTemperature"); if (hasChild(population, "line")) turnOn("togglePopulation"); - if (hasChildren(ice)) turnOn("toggleIce"); + if (isVisible(ice)) turnOn("toggleIce"); if (hasChild(prec, "circle")) turnOn("togglePrecipitation"); if (isVisible(emblems) && hasChild(emblems, "use")) turnOn("toggleEmblems"); if (isVisible(labels)) turnOn("toggleLabels"); diff --git a/public/modules/io/save.js b/public/modules/io/save.js index 304fef59..25cd7493 100644 --- a/public/modules/io/save.js +++ b/public/modules/io/save.js @@ -32,12 +32,13 @@ async function saveMap(method) { $(this).dialog("close"); } }, - position: {my: "center", at: "center", of: "svg"} + position: { my: "center", at: "center", of: "svg" } }); } } function prepareMapData() { + const date = new Date(); const dateString = date.getFullYear() + "-" + (date.getMonth() + 1) + "-" + date.getDate(); const license = "File can be loaded in azgaar.github.io/Fantasy-Map-Generator"; @@ -89,8 +90,8 @@ function prepareMapData() { const serializedSVG = new XMLSerializer().serializeToString(cloneEl); - const {spacing, cellsX, cellsY, boundary, points, features, cellsDesired} = grid; - const gridGeneral = JSON.stringify({spacing, cellsX, cellsY, boundary, points, features, cellsDesired}); + const { spacing, cellsX, cellsY, boundary, points, features, cellsDesired } = grid; + const gridGeneral = JSON.stringify({ spacing, cellsX, cellsY, boundary, points, features, cellsDesired }); const packFeatures = JSON.stringify(pack.features); const cultures = JSON.stringify(pack.cultures); const states = JSON.stringify(pack.states); @@ -102,6 +103,7 @@ function prepareMapData() { const cellRoutes = JSON.stringify(pack.cells.routes); const routes = JSON.stringify(pack.routes); const zones = JSON.stringify(pack.zones); + const ice = JSON.stringify(pack.ice); // store name array only if not the same as default const defaultNB = Names.getNameBases(); @@ -155,21 +157,22 @@ function prepareMapData() { markers, cellRoutes, routes, - zones + zones, + ice ].join("\r\n"); return mapData; } // save map file to indexedDB async function saveToStorage(mapData, showTip = false) { - const blob = new Blob([mapData], {type: "text/plain"}); + const blob = new Blob([mapData], { type: "text/plain" }); await ldb.set("lastMap", blob); showTip && tip("Map is saved to the browser storage", false, "success"); } // download map file function saveToMachine(mapData, filename) { - const blob = new Blob([mapData], {type: "text/plain"}); + const blob = new Blob([mapData], { type: "text/plain" }); const URL = window.URL.createObjectURL(blob); const link = document.createElement("a"); diff --git a/public/modules/lakes.js b/public/modules/lakes.js deleted file mode 100644 index 8ce18793..00000000 --- a/public/modules/lakes.js +++ /dev/null @@ -1,123 +0,0 @@ -"use strict"; - -window.Lakes = (function () { - const LAKE_ELEVATION_DELTA = 0.1; - - // check if lake can be potentially open (not in deep depression) - const detectCloseLakes = h => { - const {cells} = pack; - const ELEVATION_LIMIT = +byId("lakeElevationLimitOutput").value; - - pack.features.forEach(feature => { - if (feature.type !== "lake") return; - delete feature.closed; - - const MAX_ELEVATION = feature.height + ELEVATION_LIMIT; - if (MAX_ELEVATION > 99) { - feature.closed = false; - return; - } - - let isDeep = true; - const lowestShorelineCell = feature.shoreline.sort((a, b) => h[a] - h[b])[0]; - const queue = [lowestShorelineCell]; - const checked = []; - checked[lowestShorelineCell] = true; - - while (queue.length && isDeep) { - const cellId = queue.pop(); - - for (const neibCellId of cells.c[cellId]) { - if (checked[neibCellId]) continue; - if (h[neibCellId] >= MAX_ELEVATION) continue; - - if (h[neibCellId] < 20) { - const nFeature = pack.features[cells.f[neibCellId]]; - if (nFeature.type === "ocean" || feature.height > nFeature.height) isDeep = false; - } - - checked[neibCellId] = true; - queue.push(neibCellId); - } - } - - feature.closed = isDeep; - }); - }; - - const defineClimateData = function (heights) { - const {cells, features} = pack; - const lakeOutCells = new Uint16Array(cells.i.length); - - features.forEach(feature => { - if (feature.type !== "lake") return; - feature.flux = getFlux(feature); - feature.temp = getLakeTemp(feature); - feature.evaporation = getLakeEvaporation(feature); - if (feature.closed) return; // no outlet for lakes in depressed areas - - feature.outCell = getLowestShoreCell(feature); - lakeOutCells[feature.outCell] = feature.i; - }); - - return lakeOutCells; - - function getFlux(lake) { - return lake.shoreline.reduce((acc, c) => acc + grid.cells.prec[cells.g[c]], 0); - } - - function getLakeTemp(lake) { - if (lake.cells < 6) return grid.cells.temp[cells.g[lake.firstCell]]; - return rn(d3.mean(lake.shoreline.map(c => grid.cells.temp[cells.g[c]])), 1); - } - - function getLakeEvaporation(lake) { - const height = (lake.height - 18) ** heightExponentInput.value; // height in meters - const evaporation = ((700 * (lake.temp + 0.006 * height)) / 50 + 75) / (80 - lake.temp); // based on Penman formula, [1-11] - return rn(evaporation * lake.cells); - } - - function getLowestShoreCell(lake) { - return lake.shoreline.sort((a, b) => heights[a] - heights[b])[0]; - } - }; - - const cleanupLakeData = function () { - for (const feature of pack.features) { - if (feature.type !== "lake") continue; - delete feature.river; - delete feature.enteringFlux; - delete feature.outCell; - delete feature.closed; - feature.height = rn(feature.height, 3); - - const inlets = feature.inlets?.filter(r => pack.rivers.find(river => river.i === r)); - if (!inlets || !inlets.length) delete feature.inlets; - else feature.inlets = inlets; - - const outlet = feature.outlet && pack.rivers.find(river => river.i === feature.outlet); - if (!outlet) delete feature.outlet; - } - }; - - const getHeight = function (feature) { - const heights = pack.cells.h; - const minShoreHeight = d3.min(feature.shoreline.map(cellId => heights[cellId])) || 20; - return rn(minShoreHeight - LAKE_ELEVATION_DELTA, 2); - }; - - const defineNames = function () { - pack.features.forEach(feature => { - if (feature.type !== "lake") return; - feature.name = getName(feature); - }); - }; - - const getName = function (feature) { - const landCell = feature.shoreline[0]; - const culture = pack.cells.culture[landCell]; - return Names.getCulture(culture); - }; - - return {defineClimateData, cleanupLakeData, detectCloseLakes, getHeight, defineNames, getName}; -})(); diff --git a/public/modules/names-generator.js b/public/modules/names-generator.js deleted file mode 100644 index c35afedc..00000000 --- a/public/modules/names-generator.js +++ /dev/null @@ -1,328 +0,0 @@ -"use strict"; - -window.Names = (function () { - let chains = []; - - // calculate Markov chain for a namesbase - const calculateChain = function (string) { - const chain = []; - const array = string.split(","); - - for (const n of array) { - let name = n.trim().toLowerCase(); - const basic = !/[^\u0000-\u007f]/.test(name); // basic chars and English rules can be applied - - // split word into pseudo-syllables - for (let i = -1, syllable = ""; i < name.length; i += syllable.length || 1, syllable = "") { - let prev = name[i] || ""; // pre-onset letter - let v = 0; // 0 if no vowels in syllable - - for (let c = i + 1; name[c] && syllable.length < 5; c++) { - const that = name[c], - next = name[c + 1]; // next char - syllable += that; - if (syllable === " " || syllable === "-") break; // syllable starts with space or hyphen - if (!next || next === " " || next === "-") break; // no need to check - - if (vowel(that)) v = 1; // check if letter is vowel - - // do not split some diphthongs - if (that === "y" && next === "e") continue; // 'ye' - if (basic) { - // English-like - if (that === "o" && next === "o") continue; // 'oo' - if (that === "e" && next === "e") continue; // 'ee' - if (that === "a" && next === "e") continue; // 'ae' - if (that === "c" && next === "h") continue; // 'ch' - } - - if (vowel(that) === next) break; // two same vowels in a row - if (v && vowel(name[c + 2])) break; // syllable has vowel and additional vowel is expected soon - } - - if (chain[prev] === undefined) chain[prev] = []; - chain[prev].push(syllable); - } - } - - return chain; - }; - - const updateChain = i => { - chains[i] = nameBases[i]?.b ? calculateChain(nameBases[i].b) : null; - }; - - const clearChains = () => { - chains = []; - }; - - // generate name using Markov's chain - const getBase = function (base, min, max, dupl) { - if (base === undefined) return ERROR && console.error("Please define a base"); - - if (nameBases[base] === undefined) { - if (nameBases[0]) { - WARN && console.warn("Namebase " + base + " is not found. First available namebase will be used"); - base = 0; - } else { - ERROR && console.error("Namebase " + base + " is not found"); - return "ERROR"; - } - } - - if (!chains[base]) updateChain(base); - - const data = chains[base]; - if (!data || data[""] === undefined) { - tip("Namesbase " + base + " is incorrect. Please check in namesbase editor", false, "error"); - ERROR && console.error("Namebase " + base + " is incorrect!"); - return "ERROR"; - } - - if (!min) min = nameBases[base].min; - if (!max) max = nameBases[base].max; - if (dupl !== "") dupl = nameBases[base].d; - - let v = data[""], - cur = ra(v), - w = ""; - for (let i = 0; i < 20; i++) { - if (cur === "") { - // end of word - if (w.length < min) { - cur = ""; - w = ""; - v = data[""]; - } else break; - } else { - if (w.length + cur.length > max) { - // word too long - if (w.length < min) w += cur; - break; - } else v = data[last(cur)] || data[""]; - } - - w += cur; - cur = ra(v); - } - - // parse word to get a final name - const l = last(w); // last letter - if (l === "'" || l === " " || l === "-") w = w.slice(0, -1); // not allow some characters at the end - - let name = [...w].reduce(function (r, c, i, d) { - if (c === d[i + 1] && !dupl.includes(c)) return r; // duplication is not allowed - if (!r.length) return c.toUpperCase(); - if (r.slice(-1) === "-" && c === " ") return r; // remove space after hyphen - if (r.slice(-1) === " ") return r + c.toUpperCase(); // capitalize letter after space - if (r.slice(-1) === "-") return r + c.toUpperCase(); // capitalize letter after hyphen - if (c === "a" && d[i + 1] === "e") return r; // "ae" => "e" - if (i + 2 < d.length && c === d[i + 1] && c === d[i + 2]) return r; // remove three same letters in a row - return r + c; - }, ""); - - // join the word if any part has only 1 letter - if (name.split(" ").some(part => part.length < 2)) - name = name - .split(" ") - .map((p, i) => (i ? p.toLowerCase() : p)) - .join(""); - - if (name.length < 2) { - ERROR && console.error("Name is too short! Random name will be selected"); - name = ra(nameBases[base].b.split(",")); - } - - return name; - }; - - // generate name for culture - const getCulture = function (culture, min, max, dupl) { - if (culture === undefined) return ERROR && console.error("Please define a culture"); - const base = pack.cultures[culture].base; - return getBase(base, min, max, dupl); - }; - - // generate short name for culture - const getCultureShort = function (culture) { - if (culture === undefined) return ERROR && console.error("Please define a culture"); - return getBaseShort(pack.cultures[culture].base); - }; - - // generate short name for base - const getBaseShort = function (base) { - const min = nameBases[base] ? nameBases[base].min - 1 : null; - const max = min ? Math.max(nameBases[base].max - 2, min) : null; - return getBase(base, min, max, "", 0); - }; - - // generate state name based on capital or random name and culture-specific suffix - const getState = function (name, culture, base) { - if (name === undefined) return ERROR && console.error("Please define a base name"); - if (culture === undefined && base === undefined) return ERROR && console.error("Please define a culture"); - if (base === undefined) base = pack.cultures[culture].base; - - // exclude endings inappropriate for states name - if (name.includes(" ")) name = capitalize(name.replace(/ /g, "").toLowerCase()); // don't allow multiword state names - if (name.length > 6 && name.slice(-4) === "berg") name = name.slice(0, -4); // remove -berg for any - if (name.length > 5 && name.slice(-3) === "ton") name = name.slice(0, -3); // remove -ton for any - - if (base === 5 && ["sk", "ev", "ov"].includes(name.slice(-2))) name = name.slice(0, -2); - // remove -sk/-ev/-ov for Ruthenian - else if (base === 12) return vowel(name.slice(-1)) ? name : name + "u"; - // Japanese ends on any vowel or -u - else if (base === 18 && P(0.4)) - name = vowel(name.slice(0, 1).toLowerCase()) ? "Al" + name.toLowerCase() : "Al " + name; // Arabic starts with -Al - - // no suffix for fantasy bases - if (base > 32 && base < 42) return name; - - // define if suffix should be used - if (name.length > 3 && vowel(name.slice(-1))) { - if (vowel(name.slice(-2, -1)) && P(0.85)) name = name.slice(0, -2); - // 85% for vv - else if (P(0.7)) name = name.slice(0, -1); - // ~60% for cv - else return name; - } else if (P(0.4)) return name; // 60% for cc and vc - - // define suffix - let suffix = "ia"; // standard suffix - - const rnd = Math.random(), - l = name.length; - if (base === 3 && rnd < 0.03 && l < 7) suffix = "terra"; - // Italian - else if (base === 4 && rnd < 0.03 && l < 7) suffix = "terra"; - // Spanish - else if (base === 13 && rnd < 0.03 && l < 7) suffix = "terra"; - // Portuguese - else if (base === 2 && rnd < 0.03 && l < 7) suffix = "terre"; - // French - else if (base === 0 && rnd < 0.5 && l < 7) suffix = "land"; - // German - else if (base === 1 && rnd < 0.4 && l < 7) suffix = "land"; - // English - else if (base === 6 && rnd < 0.3 && l < 7) suffix = "land"; - // Nordic - else if (base === 32 && rnd < 0.1 && l < 7) suffix = "land"; - // generic Human - else if (base === 7 && rnd < 0.1) suffix = "eia"; - // Greek - else if (base === 9 && rnd < 0.35) suffix = "maa"; - // Finnic - else if (base === 15 && rnd < 0.4 && l < 6) suffix = "orszag"; - // Hungarian - else if (base === 16) suffix = rnd < 0.6 ? "yurt" : "eli"; - // Turkish - else if (base === 10) suffix = "guk"; - // Korean - else if (base === 11) suffix = " Guo"; - // Chinese - else if (base === 14) suffix = rnd < 0.5 && l < 6 ? "tlan" : "co"; - // Nahuatl - else if (base === 17 && rnd < 0.8) suffix = "a"; - // Berber - else if (base === 18 && rnd < 0.8) suffix = "a"; // Arabic - - return validateSuffix(name, suffix); - }; - - function validateSuffix(name, suffix) { - if (name.slice(-1 * suffix.length) === suffix) return name; // no suffix if name already ends with it - const s1 = suffix.charAt(0); - if (name.slice(-1) === s1) name = name.slice(0, -1); // remove name last letter if it's a suffix first letter - if (vowel(s1) === vowel(name.slice(-1)) && vowel(s1) === vowel(name.slice(-2, -1))) name = name.slice(0, -1); // remove name last char if 2 last chars are the same type as suffix's 1st - if (name.slice(-1) === s1) name = name.slice(0, -1); // remove name last letter if it's a suffix first letter - return name + suffix; - } - - // generato name for the map - const getMapName = function (force) { - if (!force && locked("mapName")) return; - if (force && locked("mapName")) unlock("mapName"); - const base = P(0.7) ? 2 : P(0.5) ? rand(0, 6) : rand(0, 31); - if (!nameBases[base]) { - tip("Namebase is not found", false, "error"); - return ""; - } - const min = nameBases[base].min - 1; - const max = Math.max(nameBases[base].max - 3, min); - const baseName = getBase(base, min, max, "", 0); - const name = P(0.7) ? addSuffix(baseName) : baseName; - mapName.value = name; - }; - - function addSuffix(name) { - const suffix = P(0.8) ? "ia" : "land"; - if (suffix === "ia" && name.length > 6) name = name.slice(0, -(name.length - 3)); - else if (suffix === "land" && name.length > 6) name = name.slice(0, -(name.length - 5)); - return validateSuffix(name, suffix); - } - - const getNameBases = function () { - // name, min length, max length, letters to allow duplication, multi-word name rate [deprecated] - // prettier-ignore - return [ - // real-world bases by Azgaar: - {name: "German", i: 0, min: 5, max: 12, d: "lt", m: 0, b: "Achern,Aichhalden,Aitern,Albbruck,Alpirsbach,Altensteig,Althengstett,Appenweier,Auggen,Badenen,Badenweiler,Baiersbronn,Ballrechten,Bellingen,Berghaupten,Bernau,Biberach,Biederbach,Binzen,Birkendorf,Birkenfeld,Bischweier,Blumberg,Bollen,Bollschweil,Bonndorf,Bosingen,Braunlingen,Breisach,Breisgau,Breitnau,Brigachtal,Buchenbach,Buggingen,Buhl,Buhlertal,Calw,Dachsberg,Dobel,Donaueschingen,Dornhan,Dornstetten,Dottingen,Dunningen,Durbach,Durrheim,Ebhausen,Ebringen,Efringen,Egenhausen,Ehrenkirchen,Ehrsberg,Eimeldingen,Eisenbach,Elzach,Elztal,Emmendingen,Endingen,Engelsbrand,Enz,Enzklosterle,Eschbronn,Ettenheim,Ettlingen,Feldberg,Fischerbach,Fischingen,Fluorn,Forbach,Freiamt,Freiburg,Freudenstadt,Friedenweiler,Friesenheim,Frohnd,Furtwangen,Gaggenau,Geisingen,Gengenbach,Gernsbach,Glatt,Glatten,Glottertal,Gorwihl,Gottenheim,Grafenhausen,Grenzach,Griesbach,Gutach,Gutenbach,Hag,Haiterbach,Hardt,Harmersbach,Hasel,Haslach,Hausach,Hausen,Hausern,Heitersheim,Herbolzheim,Herrenalb,Herrischried,Hinterzarten,Hochenschwand,Hofen,Hofstetten,Hohberg,Horb,Horben,Hornberg,Hufingen,Ibach,Ihringen,Inzlingen,Kandern,Kappel,Kappelrodeck,Karlsbad,Karlsruhe,Kehl,Keltern,Kippenheim,Kirchzarten,Konigsfeld,Krozingen,Kuppenheim,Kussaberg,Lahr,Lauchringen,Lauf,Laufenburg,Lautenbach,Lauterbach,Lenzkirch,Liebenzell,Loffenau,Loffingen,Lorrach,Lossburg,Mahlberg,Malsburg,Malsch,March,Marxzell,Marzell,Maulburg,Monchweiler,Muhlenbach,Mullheim,Munstertal,Murg,Nagold,Neubulach,Neuenburg,Neuhausen,Neuried,Neuweiler,Niedereschach,Nordrach,Oberharmersbach,Oberkirch,Oberndorf,Oberbach,Oberried,Oberwolfach,Offenburg,Ohlsbach,Oppenau,Ortenberg,otigheim,Ottenhofen,Ottersweier,Peterstal,Pfaffenweiler,Pfalzgrafenweiler,Pforzheim,Rastatt,Renchen,Rheinau,Rheinfelden,Rheinmunster,Rickenbach,Rippoldsau,Rohrdorf,Rottweil,Rummingen,Rust,Sackingen,Sasbach,Sasbachwalden,Schallbach,Schallstadt,Schapbach,Schenkenzell,Schiltach,Schliengen,Schluchsee,Schomberg,Schonach,Schonau,Schonenberg,Schonwald,Schopfheim,Schopfloch,Schramberg,Schuttertal,Schwenningen,Schworstadt,Seebach,Seelbach,Seewald,Sexau,Simmersfeld,Simonswald,Sinzheim,Solden,Staufen,Stegen,Steinach,Steinen,Steinmauern,Straubenhardt,Stuhlingen,Sulz,Sulzburg,Teinach,Tiefenbronn,Tiengen,Titisee,Todtmoos,Todtnau,Todtnauberg,Triberg,Tunau,Tuningen,uhlingen,Unterkirnach,Reichenbach,Utzenfeld,Villingen,Villingendorf,Vogtsburg,Vohrenbach,Waldachtal,Waldbronn,Waldkirch,Waldshut,Wehr,Weil,Weilheim,Weisenbach,Wembach,Wieden,Wiesental,Wildbad,Wildberg,Winzeln,Wittlingen,Wittnau,Wolfach,Wutach,Wutoschingen,Wyhlen,Zavelstein"}, - {name: "English", i: 1, min: 6, max: 11, d: "", m: .1, b: "Abingdon,Albrighton,Alcester,Almondbury,Altrincham,Amersham,Andover,Appleby,Ashboume,Atherstone,Aveton,Axbridge,Aylesbury,Baldock,Bamburgh,Barton,Basingstoke,Berden,Bere,Berkeley,Berwick,Betley,Bideford,Bingley,Birmingham,Blandford,Blechingley,Bodmin,Bolton,Bootham,Boroughbridge,Boscastle,Bossinney,Bramber,Brampton,Brasted,Bretford,Bridgetown,Bridlington,Bromyard,Bruton,Buckingham,Bungay,Burton,Calne,Cambridge,Canterbury,Carlisle,Castleton,Caus,Charmouth,Chawleigh,Chichester,Chillington,Chinnor,Chipping,Chisbury,Cleobury,Clifford,Clifton,Clitheroe,Cockermouth,Coleshill,Combe,Congleton,Crafthole,Crediton,Cuddenbeck,Dalton,Darlington,Dodbrooke,Drax,Dudley,Dunstable,Dunster,Dunwich,Durham,Dymock,Exeter,Exning,Faringdon,Felton,Fenny,Finedon,Flookburgh,Fowey,Frampton,Gateshead,Gatton,Godmanchester,Grampound,Grantham,Guildford,Halesowen,Halton,Harbottle,Harlow,Hatfield,Hatherleigh,Haydon,Helston,Henley,Hertford,Heytesbury,Hinckley,Hitchin,Holme,Hornby,Horsham,Kendal,Kenilworth,Kilkhampton,Kineton,Kington,Kinver,Kirby,Knaresborough,Knutsford,Launceston,Leighton,Lewes,Linton,Louth,Luton,Lyme,Lympstone,Macclesfield,Madeley,Malborough,Maldon,Manchester,Manningtree,Marazion,Marlborough,Marshfield,Mere,Merryfield,Middlewich,Midhurst,Milborne,Mitford,Modbury,Montacute,Mousehole,Newbiggin,Newborough,Newbury,Newenden,Newent,Norham,Northleach,Noss,Oakham,Olney,Orford,Ormskirk,Oswestry,Padstow,Paignton,Penkneth,Penrith,Penzance,Pershore,Petersfield,Pevensey,Pickering,Pilton,Pontefract,Portsmouth,Preston,Quatford,Reading,Redcliff,Retford,Rockingham,Romney,Rothbury,Rothwell,Salisbury,Saltash,Seaford,Seasalter,Sherston,Shifnal,Shoreham,Sidmouth,Skipsea,Skipton,Solihull,Somerton,Southam,Southwark,Standon,Stansted,Stapleton,Stottesdon,Sudbury,Swavesey,Tamerton,Tarporley,Tetbury,Thatcham,Thaxted,Thetford,Thornbury,Tintagel,Tiverton,Torksey,Totnes,Towcester,Tregoney,Trematon,Tutbury,Uxbridge,Wallingford,Wareham,Warenmouth,Wargrave,Warton,Watchet,Watford,Wendover,Westbury,Westcheap,Weymouth,Whitford,Wickwar,Wigan,Wigmore,Winchelsea,Winkleigh,Wiscombe,Witham,Witheridge,Wiveliscombe,Woodbury,Yeovil"}, - {name: "French", i: 2, min: 5, max: 13, d: "nlrs", m: .1, b: "Adon,Aillant,Amilly,Andonville,Ardon,Artenay,Ascheres,Ascoux,Attray,Aubin,Audeville,Aulnay,Autruy,Auvilliers,Auxy,Aveyron,Baccon,Bardon,Barville,Batilly,Baule,Bazoches,Beauchamps,Beaugency,Beaulieu,Beaune,Bellegarde,Boesses,Boigny,Boiscommun,Boismorand,Boisseaux,Bondaroy,Bonnee,Bonny,Bordes,Bou,Bougy,Bouilly,Boulay,Bouzonville,Bouzy,Boynes,Bray,Breteau,Briare,Briarres,Bricy,Bromeilles,Bucy,Cepoy,Cercottes,Cerdon,Cernoy,Cesarville,Chailly,Chaingy,Chalette,Chambon,Champoulet,Chanteau,Chantecoq,Chapell,Charme,Charmont,Charsonville,Chateau,Chateauneuf,Chatel,Chatenoy,Chatillon,Chaussy,Checy,Chevannes,Chevillon,Chevilly,Chevry,Chilleurs,Choux,Chuelles,Clery,Coinces,Coligny,Combleux,Combreux,Conflans,Corbeilles,Corquilleroy,Cortrat,Coudroy,Coullons,Coulmiers,Courcelles,Courcy,Courtemaux,Courtempierre,Courtenay,Cravant,Crottes,Dadonville,Dammarie,Dampierre,Darvoy,Desmonts,Dimancheville,Donnery,Dordives,Dossainville,Douchy,Dry,Echilleuses,Egry,Engenville,Epieds,Erceville,Ervauville,Escrennes,Escrignelles,Estouy,Faverelles,Fay,Feins,Ferolles,Ferrieres,Fleury,Fontenay,Foret,Foucherolles,Freville,Gatinais,Gaubertin,Gemigny,Germigny,Gidy,Gien,Girolles,Givraines,Gondreville,Grangermont,Greneville,Griselles,Guigneville,Guilly,Gyleslonains,Huetre,Huisseau,Ingrannes,Ingre,Intville,Isdes,Ivre,Jargeau,Jouy,Juranville,Bussiere,Laas,Ladon,Lailly,Langesse,Leouville,Ligny,Lombreuil,Lorcy,Lorris,Loury,Louzouer,Malesherbois,Marcilly,Mardie,Mareau,Marigny,Marsainvilliers,Melleroy,Menestreau,Merinville,Messas,Meung,Mezieres,Migneres,Mignerette,Mirabeau,Montargis,Montbarrois,Montbouy,Montcresson,Montereau,Montigny,Montliard,Mormant,Morville,Moulinet,Moulon,Nancray,Nargis,Nesploy,Neuville,Neuvy,Nevoy,Nibelle,Nogent,Noyers,Ocre,Oison,Olivet,Ondreville,Onzerain,Orleans,Ormes,Orville,Oussoy,Outarville,Ouzouer,Pannecieres,Pannes,Patay,Paucourt,Pers,Pierrefitte,Pithiverais,Pithiviers,Poilly,Potier,Prefontaines,Presnoy,Pressigny,Puiseaux,Quiers,Ramoulu,Rebrechien,Rouvray,Rozieres,Rozoy,Ruan,Sandillon,Santeau,Saran,Sceaux,Seichebrieres,Semoy,Sennely,Sermaises,Sigloy,Solterre,Sougy,Sully,Sury,Tavers,Thignonville,Thimory,Thorailles,Thou,Tigy,Tivernon,Tournoisis,Trainou,Treilles,Trigueres,Trinay,Vannes,Varennes,Vennecy,Vieilles,Vienne,Viglain,Vignes,Villamblain,Villemandeur,Villemoutiers,Villemurlin,Villeneuve,Villereau,Villevoques,Villorceau,Vimory,Vitry,Vrigny"}, - {name: "Italian", i: 3, min: 5, max: 12, d: "cltr", m: .1, b: "Accumoli,Acquafondata,Acquapendente,Acuto,Affile,Agosta,Alatri,Albano,Allumiere,Alvito,Amaseno,Amatrice,Anagni,Anguillara,Anticoli,Antrodoco,Anzio,Aprilia,Aquino,Arcinazzo,Ariccia,Arpino,Arsoli,Ausonia,Bagnoregio,Bassiano,Bellegra,Belmonte,Bolsena,Bomarzo,Borgorose,Boville,Bracciano,Broccostella,Calcata,Camerata,Campagnano,Campoli,Canale,Canino,Cantalice,Cantalupo,Capranica,Caprarola,Carbognano,Casalattico,Casalvieri,Castelforte,Castelnuovo,Castiglione,Castro,Castrocielo,Ceccano,Celleno,Cellere,Cerreto,Cervara,Cerveteri,Ciampino,Ciciliano,Cittaducale,Cittareale,Civita,Civitella,Colfelice,Colleferro,Collepardo,Colonna,Concerviano,Configni,Contigliano,Cori,Cottanello,Esperia,Faleria,Farnese,Ferentino,Fiamignano,Filacciano,Fiuggi,Fiumicino,Fondi,Fontana,Fonte,Fontechiari,Formia,Frascati,Frasso,Frosinone,Fumone,Gaeta,Gallese,Gavignano,Genazzano,Giuliano,Gorga,Gradoli,Grottaferrata,Grotte,Guarcino,Guidonia,Ischia,Isola,Labico,Labro,Ladispoli,Latera,Lenola,Leonessa,Licenza,Longone,Lubriano,Maenza,Magliano,Marano,Marcellina,Marcetelli,Marino,Mazzano,Mentana,Micigliano,Minturno,Montalto,Montasola,Montebuono,Monteflavio,Montelanico,Monteleone,Montenero,Monterosi,Moricone,Morlupo,Nazzano,Nemi,Nerola,Nespolo,Nettuno,Norma,Olevano,Onano,Oriolo,Orte,Orvinio,Paganico,Paliano,Palombara,Patrica,Pescorocchiano,Petrella,Piansano,Picinisco,Pico,Piedimonte,Piglio,Pignataro,Poggio,Poli,Pomezia,Pontecorvo,Pontinia,Ponzano,Posta,Pozzaglia,Priverno,Proceno,Rignano,Riofreddo,Ripi,Rivodutri,Rocca,Roccagorga,Roccantica,Roccasecca,Roiate,Ronciglione,Roviano,Salisano,Sambuci,Santa,Santini,Scandriglia,Segni,Selci,Sermoneta,Serrone,Settefrati,Sezze,Sgurgola,Sonnino,Sora,Soriano,Sperlonga,Spigno,Subiaco,Supino,Sutri,Tarano,Tarquinia,Terelle,Terracina,Tivoli,Toffia,Tolfa,Torrice,Torricella,Trevi,Trevignano,Trivigliano,Turania,Tuscania,Valentano,Vallecorsa,Vallemaio,Vallepietra,Vallerano,Vasanello,Vejano,Velletri,Ventotene,Veroli,Vetralla,Vicalvi,Vico,Vicovaro,Vignanello,Viterbo,Viticuso,Vitorchiano,Vivaro,Zagarolo"}, - {name: "Castillian", i: 4, min: 5, max: 11, d: "lr", m: 0, b: "Ajofrin,Alameda,Alaminos,Albares,Albarreal,Albendiego,Alcanizo,Alcaudete,Alcolea,Aldea,Aldeanueva,Algar,Algora,Alhondiga,Almadrones,Almendral,Alovera,Anguita,Arbancon,Argecilla,Arges,Arroyo,Atanzon,Atienza,Azuqueca,Baides,Banos,Bargas,Barriopedro,Belvis,Berninches,Brihuega,Buenaventura,Burgos,Burguillos,Bustares,Cabanillas,Calzada,Camarena,Campillo,Cantalojas,Cardiel,Carmena,Casas,Castejon,Castellar,Castilforte,Castillo,Castilnuevo,Cazalegas,Centenera,Cervera,Checa,Chozas,Chueca,Cifuentes,Cincovillas,Ciruelas,Cogollor,Cogolludo,Consuegra,Copernal,Corral,Cuerva,Domingo,Dosbarrios,Driebes,Duron,Escalona,Escalonilla,Escamilla,Escopete,Espinosa,Esplegares,Esquivias,Estables,Estriegana,Fontanar,Fuembellida,Fuensalida,Fuentelsaz,Gajanejos,Galvez,Gascuena,Gerindote,Guadamur,Heras,Herreria,Herreruela,Hinojosa,Hita,Hombrados,Hontanar,Hormigos,Huecas,Huerta,Humanes,Illana,Illescas,Iniestola,Irueste,Jadraque,Jirueque,Lagartera,Ledanca,Lillo,Lominchar,Loranca,Lucillos,Luzaga,Luzon,Madrid,Magan,Malaga,Malpica,Manzanar,Maqueda,Masegoso,Matillas,Medranda,Megina,Mejorada,Millana,Milmarcos,Mirabueno,Miralrio,Mocejon,Mochales,Molina,Mondejar,Montarron,Mora,Moratilla,Morenilla,Navas,Negredo,Noblejas,Numancia,Nuno,Ocana,Ocentejo,Olias,Olmeda,Ontigola,Orea,Orgaz,Oropesa,Otero,Palma,Pardos,Paredes,Penalver,Pepino,Peralejos,Pinilla,Pioz,Piqueras,Portillo,Poveda,Pozo,Pradena,Prados,Puebla,Puerto,Quero,Quintanar,Rebollosa,Retamoso,Riba,Riofrio,Robledo,Romanillos,Romanones,Rueda,Salmeron,Santiuste,Santo,Sauca,Segura,Selas,Semillas,Sesena,Setiles,Sevilla,Siguenza,Solanillos,Somolinos,Sonseca,Sotillo,Talavera,Taravilla,Tembleque,Tendilla,Tierzo,Torralba,Torre,Torrejon,Torrijos,Tortola,Tortuera,Totanes,Trillo,Uceda,Ugena,Urda,Utande,Valdesotos,Valhermoso,Valtablado,Valverde,Velada,Viana,Yebra,Yuncos,Yunquera,Zaorejas,Zarzuela,Zorita"}, - {name: "Ruthenian", i: 5, min: 5, max: 10, d: "", m: 0, b: "Belgorod,Beloberezhye,Belyi,Belz,Berestiy,Berezhets,Berezovets,Berezutsk,Bobruisk,Bolonets,Borisov,Borovsk,Bozhesk,Bratslav,Bryansk,Brynsk,Buryn,Byhov,Chechersk,Chemesov,Cheremosh,Cherlen,Chern,Chernigov,Chernitsa,Chernobyl,Chernogorod,Chertoryesk,Chetvertnia,Demyansk,Derevesk,Devyagoresk,Dichin,Dmitrov,Dorogobuch,Dorogobuzh,Drestvin,Drokov,Drutsk,Dubechin,Dubichi,Dubki,Dubkov,Dveren,Galich,Glebovo,Glinsk,Goloty,Gomiy,Gorodets,Gorodische,Gorodno,Gorohovets,Goroshin,Gorval,Goryshon,Holm,Horobor,Hoten,Hotin,Hotmyzhsk,Ilovech,Ivan,Izborsk,Izheslavl,Kamenets,Kanev,Karachev,Karna,Kavarna,Klechesk,Klyapech,Kolomyya,Kolyvan,Kopyl,Korec,Kornik,Korochunov,Korshev,Korsun,Koshkin,Kotelno,Kovyla,Kozelsk,Kozelsk,Kremenets,Krichev,Krylatsk,Ksniatin,Kulatsk,Kursk,Kursk,Lebedev,Lida,Logosko,Lomihvost,Loshesk,Loshichi,Lubech,Lubno,Lubutsk,Lutsk,Luchin,Luki,Lukoml,Luzha,Lvov,Mtsensk,Mdin,Medniki,Melecha,Merech,Meretsk,Mescherskoe,Meshkovsk,Metlitsk,Mezetsk,Mglin,Mihailov,Mikitin,Mikulino,Miloslavichi,Mogilev,Mologa,Moreva,Mosalsk,Moschiny,Mozyr,Mstislav,Mstislavets,Muravin,Nemech,Nemiza,Nerinsk,Nichan,Novgorod,Novogorodok,Obolichi,Obolensk,Obolensk,Oleshsk,Olgov,Omelnik,Opoka,Opoki,Oreshek,Orlets,Osechen,Oster,Ostrog,Ostrov,Perelai,Peremil,Peremyshl,Pererov,Peresechen,Perevitsk,Pereyaslav,Pinsk,Ples,Polotsk,Pronsk,Proposhesk,Punia,Putivl,Rechitsa,Rodno,Rogachev,Romanov,Romny,Roslavl,Rostislavl,Rostovets,Rsha,Ruza,Rybchesk,Rylsk,Rzhavesk,Rzhev,Rzhischev,Sambor,Serensk,Serensk,Serpeysk,Shilov,Shuya,Sinech,Sizhka,Skala,Slovensk,Slutsk,Smedin,Sneporod,Snitin,Snovsk,Sochevo,Sokolec,Starica,Starodub,Stepan,Sterzh,Streshin,Sutesk,Svinetsk,Svisloch,Terebovl,Ternov,Teshilov,Teterin,Tiversk,Torchevsk,Toropets,Torzhok,Tripolye,Trubchevsk,Tur,Turov,Usvyaty,Uteshkov,Vasilkov,Velil,Velye,Venev,Venicha,Verderev,Vereya,Veveresk,Viazma,Vidbesk,Vidychev,Voino,Volodimer,Volok,Volyn,Vorobesk,Voronich,Voronok,Vorotynsk,Vrev,Vruchiy,Vselug,Vyatichsk,Vyatka,Vyshegorod,Vyshgorod,Vysokoe,Yagniatin,Yaropolch,Yasenets,Yuryev,Yuryevets,Zaraysk,Zhitomel,Zholvazh,Zizhech,Zubkov,Zudechev,Zvenigorod"}, - {name: "Nordic", i: 6, min: 6, max: 10, d: "kln", m: .1, b: "Akureyri,Aldra,Alftanes,Andenes,Austbo,Auvog,Bakkafjordur,Ballangen,Bardal,Beisfjord,Bifrost,Bildudalur,Bjerka,Bjerkvik,Bjorkosen,Bliksvaer,Blokken,Blonduos,Bolga,Bolungarvik,Borg,Borgarnes,Bosmoen,Bostad,Bostrand,Botsvika,Brautarholt,Breiddalsvik,Bringsli,Brunahlid,Budardalur,Byggdakjarni,Dalvik,Djupivogur,Donnes,Drageid,Drangsnes,Egilsstadir,Eiteroga,Elvenes,Engavogen,Ertenvog,Eskifjordur,Evenes,Eyrarbakki,Fagernes,Fallmoen,Fellabaer,Fenes,Finnoya,Fjaer,Fjelldal,Flakstad,Flateyri,Flostrand,Fludir,Gardaber,Gardur,Gimstad,Givaer,Gjeroy,Gladstad,Godoya,Godoynes,Granmoen,Gravdal,Grenivik,Grimsey,Grindavik,Grytting,Hafnir,Halsa,Hauganes,Haugland,Hauknes,Hella,Helland,Hellissandur,Hestad,Higrav,Hnifsdalur,Hofn,Hofsos,Holand,Holar,Holen,Holkestad,Holmavik,Hopen,Hovden,Hrafnagil,Hrisey,Husavik,Husvik,Hvammstangi,Hvanneyri,Hveragerdi,Hvolsvollur,Igeroy,Indre,Inndyr,Innhavet,Innes,Isafjordur,Jarklaustur,Jarnsreykir,Junkerdal,Kaldvog,Kanstad,Karlsoy,Kavosen,Keflavik,Kjelde,Kjerstad,Klakk,Kopasker,Kopavogur,Korgen,Kristnes,Krutoga,Krystad,Kvina,Lande,Laugar,Laugaras,Laugarbakki,Laugarvatn,Laupstad,Leines,Leira,Leiren,Leland,Lenvika,Loding,Lodingen,Lonsbakki,Lopsmarka,Lovund,Luroy,Maela,Melahverfi,Meloy,Mevik,Misvaer,Mornes,Mosfellsber,Moskenes,Myken,Naurstad,Nesberg,Nesjahverfi,Nesset,Nevernes,Obygda,Ofoten,Ogskardet,Okervika,Oknes,Olafsfjordur,Oldervika,Olstad,Onstad,Oppeid,Oresvika,Orsnes,Orsvog,Osmyra,Overdal,Prestoya,Raudalaekur,Raufarhofn,Reipo,Reykholar,Reykholt,Reykjahlid,Rif,Rinoya,Rodoy,Rognan,Rosvika,Rovika,Salhus,Sanden,Sandgerdi,Sandoker,Sandset,Sandvika,Saudarkrokur,Selfoss,Selsoya,Sennesvik,Setso,Siglufjordur,Silvalen,Skagastrond,Skjerstad,Skonland,Skorvogen,Skrova,Sleneset,Snubba,Softing,Solheim,Solheimar,Sorarnoy,Sorfugloy,Sorland,Sormela,Sorvaer,Sovika,Stamsund,Stamsvika,Stave,Stokka,Stokkseyri,Storjord,Storo,Storvika,Strand,Straumen,Strendene,Sudavik,Sudureyri,Sundoya,Sydalen,Thingeyri,Thorlakshofn,Thorshofn,Tjarnabyggd,Tjotta,Tosbotn,Traelnes,Trofors,Trones,Tverro,Ulvsvog,Unnstad,Utskor,Valla,Vandved,Varmahlid,Vassos,Vevelstad,Vidrek,Vik,Vikholmen,Vogar,Vogehamn,Vopnafjordur"}, - {name: "Greek", i: 7, min: 5, max: 11, d: "s", m: .1, b: "Abdera,Acharnae,Aegae,Aegina,Agrinion,Aigosthena,Akragas,Akroinon,Akrotiri,Alalia,Alexandria,Amarynthos,Amaseia,Amphicaea,Amphigeneia,Amphipolis,Antipatrea,Antiochia,Apamea,Aphidna,Apollonia,Argos,Artemita,Argyropolis,Asklepios,Athenai,Athmonia,Bhrytos,Borysthenes,Brauron,Byblos,Byzantion,Bythinion,Calydon,Chamaizi,Chalcis,Chios,Cleona,Corcyra,Croton,Cyrene,Cythera,Decelea,Delos,Delphi,Dicaearchia,Didyma,Dion,Dioscurias,Dodona,Dorylaion,Elateia,Eleusis,Eleutherna,Emporion,Ephesos,Epidamnos,Epidauros,Epizephyrian,Erythrae,Eubea,Golgi,Gonnos,Gorgippia,Gournia,Gortyn,Gytion,Hagios,Halicarnassos,Heliopolis,Hellespontos,Heloros,Heraclea,Hierapolis,Himera,Histria,Hubla,Hyele,Ialysos,Iasos,Idalion,Imbros,Iolcos,Itanos,Ithaca,Juktas,Kallipolis,Kameiros,Karistos,Kasmenai,Kepoi,Kimmerikon,Knossos,Korinthos,Kos,Kourion,Kydonia,Kyrenia,Lamia,Lampsacos,Laodicea,Lapithos,Larissa,Lebena,Lefkada,Lekhaion,Leibethra,Leontinoi,Lilaea,Lindos,Lissos,Magnesia,Mantineia,Marathon,Marmara,Massalia,Megalopolis,Megara,Metapontion,Methumna,Miletos,Morgantina,Mulai,Mukenai,Myonia,Myra,Myrmekion,Myos,Nauplios,Naucratis,Naupaktos,Naxos,Neapolis,Nemea,Nicaea,Nicopolis,Nymphaion,Nysa,Odessos,Olbia,Olympia,Olynthos,Opos,Orchomenos,Oricos,Orestias,Oreos,Onchesmos,Pagasae,Palaikastro,Pandosia,Panticapaion,Paphos,Pargamon,Paros,Pegai,Pelion,Peiraies,Phaistos,Phaleron,Pharos,Pithekussa,Philippopolis,Phocaea,Pinara,Pisa,Pitane,Plataea,Poseidonia,Potidaea,Pseira,Psychro,Pteleos,Pydna,Pylos,Pyrgos,Rhamnos,Rhithymna,Rhypae,Rizinia,Rodos,Salamis,Samos,Skyllaion,Seleucia,Semasos,Sestos,Scidros,Sicyon,,Sinope,Siris,Smyrna,Sozopolis,Sparta,Stagiros,Stratos,Stymphalos,Sybaris,Surakousai,Taras,Tanagra,Tanais,Tauromenion,Tegea,Temnos,Teos,Thapsos,Thassos,Thebai,Theodosia,Therma,Thespian,Thronion,Thoricos,Thurii,Thyreum,Thyria,Tithoraea,Tomis,Tragurion,Tripolis,Troliton,Troy,Tylissos,Tyros,Vathypetros,Zakynthos,Zakros"}, - {name: "Roman", i: 8, min: 6, max: 11, d: "ln", m: .1, b: "Abila,Adflexum,Adnicrem,Aelia,Aelius,Aeminium,Aequum,Agrippina,Agrippinae,Ala,Albanianis,Aleria,Ambianum,Andautonia,Apulum,Aquae,Aquaegranni,Aquensis,Aquileia,Aquincum,Arae,Argentoratum,Ariminum,Ascrivium,Asturica,Atrebatum,Atuatuca,Augusta,Aurelia,Aurelianorum,Batavar,Batavorum,Belum,Biriciana,Blestium,Bonames,Bonna,Bononia,Borbetomagus,Bovium,Bracara,Brigantium,Burgodunum,Caesaraugusta,Caesarea,Caesaromagus,Calleva,Camulodunum,Cannstatt,Cantiacorum,Capitolina,Caralis,Castellum,Castra,Castrum,Cibalae,Clausentum,Colonia,Concangis,Condate,Confluentes,Conimbriga,Corduba,Coria,Corieltauvorum,Corinium,Coriovallum,Cornoviorum,Danum,Deva,Dianium,Divodurum,Dobunnorum,Drusi,Dubris,Dumnoniorum,Durnovaria,Durocobrivis,Durocornovium,Duroliponte,Durovernum,Durovigutum,Eboracum,Ebusus,Edetanorum,Emerita,Emona,Emporiae,Euracini,Faventia,Flaviae,Florentia,Forum,Gerulata,Gerunda,Gesoscribate,Glevensium,Hadriani,Herculanea,Isca,Italica,Iulia,Iuliobrigensium,Iuvavum,Lactodurum,Lagentium,Lapurdum,Lauri,Legionis,Lemanis,Lentia,Lepidi,Letocetum,Lindinis,Lindum,Lixus,Londinium,Lopodunum,Lousonna,Lucus,Lugdunum,Luguvalium,Lutetia,Mancunium,Marsonia,Martius,Massa,Massilia,Matilo,Mattiacorum,Mediolanum,Mod,Mogontiacum,Moridunum,Mursa,Naissus,Nervia,Nida,Nigrum,Novaesium,Noviomagus,Olicana,Olisippo,Ovilava,Parisiorum,Partiscum,Paterna,Pistoria,Placentia,Pollentia,Pomaria,Pompeii,Pons,Portus,Praetoria,Praetorium,Pullum,Ragusium,Ratae,Raurica,Ravenna,Regina,Regium,Regulbium,Rigomagus,Roma,Romula,Rutupiae,Salassorum,Salernum,Salona,Scalabis,Segovia,Silurum,Sirmium,Siscia,Sorviodurum,Sumelocenna,Tarraco,Taurinorum,Theranda,Traiectum,Treverorum,Tungrorum,Turicum,Ulpia,Valentia,Venetiae,Venta,Verulamium,Vesontio,Vetera,Victoriae,Victrix,Villa,Viminacium,Vindelicorum,Vindobona,Vinovia,Viroconium"}, - {name: "Finnic", i: 9, min: 5, max: 11, d: "akiut", m: 0, b: "Aanekoski,Ahlainen,Aholanvaara,Ahtari,Aijala,Akaa,Alajarvi,Antsla,Aspo,Bennas,Bjorkoby,Elva,Emasalo,Espoo,Esse,Evitskog,Forssa,Haapamaki,Haapavesi,Haapsalu,Hameenlinna,Hanko,Harjavalta,Hattuvaara,Hautajarvi,Havumaki,Heinola,Hetta,Hinkabole,Hirmula,Hossa,Huittinen,Husula,Hyryla,Hyvinkaa,Ikaalinen,Iskmo,Itakoski,Jamsa,Jarvenpaa,Jeppo,Jioesuu,Jiogeva,Joensuu,Jokikyla,Jungsund,Jyvaskyla,Kaamasmukka,Kajaani,Kalajoki,Kallaste,Kankaanpaa,Karkku,Karpankyla,Kaskinen,Kasnas,Kauhajoki,Kauhava,Kauniainen,Kauvatsa,Kehra,Kellokoski,Kelottijarvi,Kemi,Kemijarvi,Kerava,Keuruu,Kiljava,Kiuruvesi,Kivesjarvi,Kiviioli,Kivisuo,Klaukkala,Klovskog,Kohtlajarve,Kokemaki,Kokkola,Kolho,Koskue,Kotka,Kouva,Kaupunki,Kuhmo,Kunda,Kuopio,Kuressaare,Kurikka,Kuusamo,Kylmalankyla,Lahti,Laitila,Lankipohja,Lansikyla,Lapua,Laurila,Lautiosaari,Lempaala,Lepsama,Liedakkala,Lieksa,Littoinen,Lohja,Loimaa,Loksa,Loviisa,Malmi,Mantta,Matasvaara,Maula,Miiluranta,Mioisakula,Munapirtti,Mustvee,Muurahainen,Naantali,Nappa,Narpio,Niinimaa,Niinisalo,Nikkila,Nilsia,Nivala,Nokia,Nummela,Nuorgam,Nuvvus,Obbnas,Oitti,Ojakkala,Onninen,Orimattila,Orivesi,Otanmaki,Otava,Otepaa,Oulainen,Oulu,Paavola,Paide,Paimio,Pakankyla,Paldiski,Parainen,Parkumaki,Parola,Perttula,Pieksamaki,Pioltsamaa,Piolva,Pohjavaara,Porhola,Porrasa,Porvoo,Pudasjarvi,Purmo,Pyhajarvi,Raahe,Raasepori,Raisio,Rajamaki,Rakvere,Rapina,Rapla,Rauma,Rautio,Reposaari,Riihimaki,Rovaniemi,Roykka,Ruonala,Ruottala,Rutalahti,Saarijarvi,Salo,Sastamala,Saue,Savonlinna,Seinajoki,Sillamae,Siuntio,Sompujarvi,Suonenjoki,Suurejaani,Syrjantaka,Tamsalu,Tapa,Temmes,Tiorva,Tormasenvaara,Tornio,Tottijarvi,Tulppio,Turenki,Turi,Tuukkala,Tuurala,Tuuri,Tuuski,Tuusniemi,Ulvila,Unari,Upinniemi,Utti,Uusikaupunki,Vaaksy,Vaalimaa,Vaarinmaja,Vaasa,Vainikkala,Valga,Valkeakoski,Vantaa,Varkaus,Vehkapera,Vehmasmaki,Vieki,Vierumaki,Viitasaari,Viljandi,Vilppula,Viohma,Vioru,Virrat,Ylike,Ylivieska,Ylojarvi"}, - {name: "Korean", i: 10, min: 5, max: 11, d: "", m: 0, b: "Anjung,Ansan,Anseong,Anyang,Aphae,Apo,Baekseok,Baeksu,Beolgyo,Boeun,Boseong,Busan,Buyeo,Changnyeong,Changwon,Cheonan,Cheongdo,Cheongjin,Cheongsong,Cheongyang,Cheorwon,Chirwon,Chuncheon,Chungju,Daedeok,Daegaya,Daejeon,Damyang,Dangjin,Dasa,Donghae,Dongsong,Doyang,Eonyang,Gaeseong,Ganggyeong,Ganghwa,Gangneung,Ganseong,Gaun,Geochang,Geoje,Geoncheon,Geumho,Geumil,Geumwang,Gijang,Gimcheon,Gimhwa,Gimje,Goa,Gochang,Gohan,Gongdo,Gongju,Goseong,Goyang,Gumi,Gunpo,Gunsan,Guri,Gurye,Gwangju,Gwangyang,Gwansan,Gyeongseong,Hadong,Hamchang,Hampyeong,Hamyeol,Hanam,Hapcheon,Hayang,Heungnam,Hongnong,Hongseong,Hwacheon,Hwando,Hwaseong,Hwasun,Hwawon,Hyangnam,Incheon,Inje,Iri,Janghang,Jangheung,Jangseong,Jangseungpo,Jangsu,Jecheon,Jeju,Jeomchon,Jeongeup,Jeonggwan,Jeongju,Jeongok,Jeongseon,Jeonju,Jido,Jiksan,Jinan,Jincheon,Jindo,Jingeon,Jinjeop,Jinnampo,Jinyeong,Jocheon,Jochiwon,Jori,Maepo,Mangyeong,Mokpo,Muju,Munsan,Naesu,Naju,Namhae,Namwon,Namyang,Namyangju,Nongong,Nonsan,Ocheon,Okcheon,Okgu,Onam,Onsan,Onyang,Opo,Paengseong,Pogok,Poseung,Pungsan,Pyeongchang,Pyeonghae,Pyeongyang,Sabi,Sacheon,Samcheok,Samho,Samrye,Sancheong,Sangdong,Sangju,Sapgyo,Sariwon,Sejong,Seocheon,Seogwipo,Seonghwan,Seongjin,Seongju,Seongnam,Seongsan,Seosan,Seungju,Siheung,Sindong,Sintaein,Soheul,Sokcho,Songak,Songjeong,Songnim,Songtan,Suncheon,Taean,Taebaek,Tongjin,Uijeongbu,Uiryeong,Uiwang,Uljin,Ulleung,Unbong,Ungcheon,Ungjin,Waegwan,Wando,Wayang,Wiryeseong,Wondeok,Yangju,Yangsan,Yangyang,Yecheon,Yeomchi,Yeoncheon,Yeongam,Yeongcheon,Yeongdeok,Yeongdong,Yeonggwang,Yeongju,Yeongwol,Yeongyang,Yeonil,Yongin,Yongjin,Yugu"}, - {name: "Chinese", i: 11, min: 5, max: 10, d: "", m: 0, b: "Anding,Anlu,Anqing,Anshun,Baixing,Banyang,Baoqing,Binzhou,Caozhou,Changbai,Changchun,Changde,Changling,Changsha,Changzhou,Chengdu,Chenzhou,Chizhou,Chongqing,Chuxiong,Chuzhou,Dading,Daming,Datong,Daxing,Dengzhou,Deqing,Dihua,Dingli,Dongan,Dongchang,Dongchuan,Dongping,Duyun,Fengtian,Fengxiang,Fengyang,Fenzhou,Funing,Fuzhou,Ganzhou,Gaoyao,Gaozhou,Gongchang,Guangnan,Guangning,Guangping,Guangxin,Guangzhou,Guiyang,Hailong,Hangzhou,Hanyang,Hanzhong,Heihe,Hejian,Henan,Hengzhou,Hezhong,Huaian,Huaiqing,Huanglong,Huangzhou,Huining,Hulan,Huzhou,Jiading,Jian,Jianchang,Jiangning,Jiankang,Jiaxing,Jiayang,Jilin,Jinan,Jingjiang,Jingzhao,Jinhua,Jinzhou,Jiujiang,Kaifeng,Kaihua,Kangding,Kuizhou,Laizhou,Lianzhou,Liaoyang,Lijiang,Linan,Linhuang,Lintao,Liping,Liuzhou,Longan,Longjiang,Longxing,Luan,Lubin,Luzhou,Mishan,Nanan,Nanchang,Nandian,Nankang,Nanyang,Nenjiang,Ningbo,Ningguo,Ningwu,Ningxia,Ningyuan,Pingjiang,Pingliang,Pingyang,Puer,Puzhou,Qianzhou,Qingyang,Qingyuan,Qingzhou,Qujing,Quzhou,Raozhou,Rende,Ruian,Ruizhou,Shafeng,Shajing,Shaoqing,Shaowu,Shaoxing,Shaozhou,Shinan,Shiqian,Shouchun,Shuangcheng,Shulei,Shunde,Shuntian,Shuoping,Sicheng,Sinan,Sizhou,Songjiang,Suiding,Suihua,Suining,Suzhou,Taian,Taibei,Taiping,Taiwan,Taiyuan,Taizhou,Taonan,Tengchong,Tingzhou,Tongchuan,Tongqing,Tongzhou,Weihui,Wensu,Wenzhou,Wuchang,Wuding,Wuzhou,Xian,Xianchun,Xianping,Xijin,Xiliang,Xincheng,Xingan,Xingde,Xinghua,Xingjing,Xingyi,Xingyuan,Xingzhong,Xining,Xinmen,Xiping,Xuanhua,Xunzhou,Xuzhou,Yanan,Yangzhou,Yanji,Yanping,Yanzhou,Yazhou,Yichang,Yidu,Yilan,Yili,Yingchang,Yingde,Yingtian,Yingzhou,Yongchang,Yongping,Yongshun,Yuanzhou,Yuezhou,Yulin,Yunnan,Yunyang,Zezhou,Zhang,Zhangzhou,Zhaoqing,Zhaotong,Zhenan,Zhending,Zhenhai,Zhenjiang,Zhenxi,Zhenyun,Zhongshan,Zunyi"}, - {name: "Japanese", i: 12, min: 4, max: 10, d: "", m: 0, b: "Abira,Aga,Aikawa,Aizumisato,Ajigasawa,Akkeshi,Amagi,Ami,Ando,Asakawa,Ashikita,Bandai,Biratori,Chonan,Esashi,Fuchu,Fujimi,Funagata,Genkai,Godo,Goka,Gonohe,Gyokuto,Haboro,Hamatonbetsu,Harima,Hashikami,Hayashima,Heguri,Hidaka,Higashiura,Hiranai,Hirogawa,Hiroo,Hodatsushimizu,Hoki,Hokuei,Hokuryu,Horokanai,Ibigawa,Ichikai,Ichikawa,Ichinohe,Iijima,Iizuna,Ikawa,Inagawa,Itakura,Iwaizumi,Iwate,Kaisei,Kamifurano,Kamiita,Kamijima,Kamikawa,Kamishihoro,Kamiyama,Kanda,Kanna,Kasagi,Kasuya,Katsuura,Kawabe,Kawamoto,Kawanehon,Kawanishi,Kawara,Kawasaki,Kawatana,Kawazu,Kihoku,Kikonai,Kin,Kiso,Kitagata,Kitajima,Kiyama,Kiyosato,Kofu,Koge,Kohoku,Kokonoe,Kora,Kosa,Kotohira,Kudoyama,Kumejima,Kumenan,Kumiyama,Kunitomi,Kurate,Kushimoto,Kutchan,Kyonan,Kyotamba,Mashike,Matsumae,Mifune,Mihama,Minabe,Minami,Minamiechizen,Minamitane,Misaki,Misasa,Misato,Miyashiro,Miyoshi,Mori,Moseushi,Mutsuzawa,Nagaizumi,Nagatoro,Nagayo,Nagomi,Nakadomari,Nakanojo,Nakashibetsu,Namegawa,Nanbu,Nanporo,Naoshima,Nasu,Niseko,Nishihara,Nishiizu,Nishikatsura,Nishikawa,Nishinoshima,Nishiwaga,Nogi,Noto,Nyuzen,Oarai,Obuse,Odai,Ogawara,Oharu,Oirase,Oishida,Oiso,Oizumi,Oji,Okagaki,Okutama,Omu,Ono,Osaka,Otobe,Otsuki,Owani,Reihoku,Rifu,Rikubetsu,Rishiri,Rokunohe,Ryuo,Saka,Sakuho,Samani,Satsuma,Sayo,Saza,Setana,Shakotan,Shibayama,Shikama,Shimamoto,Shimizu,Shintomi,Shirakawa,Shisui,Shitara,Sobetsu,Sue,Sumita,Suooshima,Suttsu,Tabuse,Tachiarai,Tadami,Tadaoka,Taiji,Taiki,Takachiho,Takahama,Taketoyo,Taragi,Tateshina,Tatsugo,Tawaramoto,Teshikaga,Tobe,Tokigawa,Toma,Tomioka,Tonosho,Tosa,Toyokoro,Toyotomi,Toyoyama,Tsubata,Tsubetsu,Tsukigata,Tsuno,Tsuwano,Umi,Wakasa,Yamamoto,Yamanobe,Yamatsuri,Yanaizu,Yasuda,Yoichi,Yonaguni,Yoro,Yoshino,Yubetsu,Yugawara,Yuni,Yusuhara,Yuza"}, - {name: "Portuguese", i: 13, min: 5, max: 11, d: "", m: .1, b: "Abrigada,Afonsoeiro,Agueda,Aguilada,Alagoas,Alagoinhas,Albufeira,Alcanhoes,Alcobaca,Alcoutim,Aldoar,Alenquer,Alfeizerao,Algarve,Almada,Almagreira,Almeirim,Alpalhao,Alpedrinha,Alvorada,Amieira,Anapolis,Apelacao,Aranhas,Arganil,Armacao,Assenceira,Aveiro,Avelar,Balsas,Barcarena,Barreiras,Barretos,Batalha,Beira,Benavente,Betim,Braga,Braganca,Brasilia,Brejo,Cabeceiras,Cabedelo,Cachoeiras,Cadafais,Calhandriz,Calheta,Caminha,Campinas,Canidelo,Canoas,Capinha,Carmoes,Cartaxo,Carvalhal,Carvoeiro,Cascavel,Castanhal,Caxias,Chapadinha,Chaves,Cocais,Coentral,Coimbra,Comporta,Conde,Coqueirinho,Coruche,Damaia,Dourados,Enxames,Ericeira,Ervidel,Escalhao,Esmoriz,Espinhal,Estela,Estoril,Eunapolis,Evora,Famalicao,Fanhoes,Faro,Fatima,Felgueiras,Ferreira,Figueira,Flecheiras,Florianopolis,Fornalhas,Fortaleza,Freiria,Freixeira,Fronteira,Fundao,Gracas,Gradil,Grainho,Gralheira,Guimaraes,Horta,Ilhavo,Ilheus,Lages,Lagos,Laranjeiras,Lavacolhos,Leiria,Limoeiro,Linhares,Lisboa,Lomba,Lorvao,Lourical,Lourinha,Luziania,Macedo,Machava,Malveira,Marinhais,Maxial,Mealhada,Milharado,Mira,Mirandela,Mogadouro,Montalegre,Mourao,Nespereira,Nilopolis,Obidos,Odemira,Odivelas,Oeiras,Oleiros,Olhalvo,Olinda,Olival,Oliveira,Oliveirinha,Palheiros,Palmeira,Palmital,Pampilhosa,Pantanal,Paradinha,Parelheiros,Pedrosinho,Pegoes,Penafiel,Peniche,Pinhao,Pinheiro,Pombal,Pontal,Pontinha,Portel,Portimao,Quarteira,Queluz,Ramalhal,Reboleira,Recife,Redinha,Ribadouro,Ribeira,Ribeirao,Rosais,Sabugal,Sacavem,Sagres,Sandim,Sangalhos,Santarem,Santos,Sarilhos,Seixas,Seixezelo,Seixo,Silvares,Silveira,Sinhaem,Sintra,Sobral,Sobralinho,Tabuaco,Tabuleiro,Taveiro,Teixoso,Telhado,Telheiro,Tomar,Torreira,Trancoso,Troviscal,Vagos,Varzea,Velas,Viamao,Viana,Vidigal,Vidigueira,Vidual,Vilamar,Vimeiro,Vinhais,Vitoria"}, - {name: "Nahuatl", i: 14, min: 6, max: 13, d: "l", m: 0, b: "Acapulco,Acatepec,Acatlan,Acaxochitlan,Acolman,Actopan,Acuamanala,Ahuacatlan,Almoloya,Amacuzac,Amanalco,Amaxac,Apaxco,Apetatitlan,Apizaco,Atenco,Atizapan,Atlacomulco,Atlapexco,Atotonilco,Axapusco,Axochiapan,Axocomanitla,Axutla,Azcapotzalco,Aztahuacan,Calimaya,Calnali,Calpulalpan,Camotlan,Capulhuac,Chalco,Chapulhuacan,Chapultepec,Chiapan,Chiautempan,Chiconautla,Chihuahua,Chilcuautla,Chimalhuacan,Cholollan,Cihuatlan,Coahuila,Coatepec,Coatetelco,Coatlan,Coatlinchan,Coatzacoalcos,Cocotitlan,Cohetzala,Colima,Colotlan,Coyoacan,Coyohuacan,Cuapiaxtla,Cuauhnahuac,Cuauhtemoc,Cuauhtitlan,Cuautepec,Cuautla,Cuaxomulco,Culhuacan,Ecatepec,Eloxochitlan,Epatlan,Epazoyucan,Huamantla,Huascazaloya,Huatlatlauca,Huautla,Huehuetlan,Huehuetoca,Huexotla,Hueyapan,Hueyotlipan,Hueypoxtla,Huichapan,Huimilpan,Huitzilac,Ixtapallocan,Iztacalco,Iztaccihuatl,Iztapalapa,Lolotla,Malinalco,Mapachtlan,Mazatepec,Mazatlan,Metepec,Metztitlan,Mexico,Miacatlan,Michoacan,Minatitlan,Mixcoac,Mixtla,Molcaxac,Nanacamilpa,Naucalpan,Naupan,Nextlalpan,Nezahualcoyotl,Nopalucan,Oaxaca,Ocotepec,Ocotitlan,Ocotlan,Ocoyoacac,Ocuilan,Ocuituco,Omitlan,Otompan,Otzoloapan,Pacula,Pahuatlan,Panotla,Papalotla,Patlachican,Piaztla,Popocatepetl,Sultepec,Tecamac,Tecolotlan,Tecozautla,Temamatla,Temascalapa,Temixco,Temoac,Temoaya,Tenayuca,Tenochtitlan,Teocuitlatlan,Teotihuacan,Teotlalco,Tepeacac,Tepeapulco,Tepehuacan,Tepetitlan,Tepeyanco,Tepotzotlan,Tepoztlan,Tetecala,Tetlatlahuca,Texcalyacac,Texcoco,Tezontepec,Tezoyuca,Timilpan,Tizapan,Tizayuca,Tlacopan,Tlacotenco,Tlahuac,Tlahuelilpan,Tlahuiltepa,Tlalmanalco,Tlalnepantla,Tlalpan,Tlanchinol,Tlatelolco,Tlaxcala,Tlaxcoapan,Tlayacapan,Tocatlan,Tolcayuca,Toluca,Tonanitla,Tonantzintla,Tonatico,Totolac,Totolapan,Tototlan,Tuchtlan,Tulantepec,Tultepec,Tzompantepec,Xalatlaco,Xaloztoc,Xaltocan,Xiloxoxtla,Xochiatipan,Xochicoatlan,Xochimilco,Xochitepec,Xolotlan,Xonacatlan,Yahualica,Yautepec,Yecapixtla,Yehaultepec,Zacatecas,Zacazonapan,Zacoalco,Zacualpan,Zacualtipan,Zapotlan,Zimapan,Zinacantepec,Zoyaltepec,Zumpahuacan"}, - {name: "Hungarian", i: 15, min: 6, max: 13, d: "", m: 0.1, b: "Aba,Abadszalok,Adony,Ajak,Albertirsa,Alsozsolca,Aszod,Babolna,Bacsalmas,Baktaloranthaza,Balassagyarmat,Balatonalmadi,Balatonboglar,Balkany,Balmazujvaros,Barcs,Bataszek,Batonyterenye,Battonya,Bekes,Berettyoujfalu,Berhida,Biatorbagy,Bicske,Biharkeresztes,Bodajk,Boly,Bonyhad,Budakalasz,Budakeszi,Celldomolk,Csakvar,Csenger,Csongrad,Csorna,Csorvas,Csurgo,Dabas,Demecser,Derecske,Devavanya,Devecser,Dombovar,Dombrad,Dunafoldvar,Dunaharaszti,Dunavarsany,Dunavecse,Edeleny,Elek,Emod,Encs,Enying,Ercsi,Fegyvernek,Fehergyarmat,Felsozsolca,Fertoszentmiklos,Fonyod,Fot,Fuzesabony,Fuzesgyarmat,Gardony,God,Gyal,Gyomaendrod,Gyomro,Hajdudorog,Hajduhadhaz,Hajdusamson,Hajduszoboszlo,Halasztelek,Harkany,Hatvan,Heves,Heviz,Ibrany,Isaszeg,Izsak,Janoshalma,Janossomorja,Jaszapati,Jaszarokszallas,Jaszfenyszaru,Jaszkiser,Kaba,Kalocsa,Kapuvar,Karcag,Kecel,Kemecse,Kenderes,Kerekegyhaza,Keszthely,Kisber,Kiskunmajsa,Kistarcsa,Kistelek,Kisujszallas,Kisvarda,Komadi,Komarom,Komlo,Kormend,Korosladany,Koszeg,Kozarmisleny,Kunhegyes,Kunszentmarton,Kunszentmiklos,Labatlan,Lajosmizse,Lenti,Letavertes,Letenye,Lorinci,Maglod,Mako,Mandok,Marcali,Martonvasar,Mateszalka,Melykut,Mezobereny,Mezocsat,Mezohegyes,Mezokeresztes,Mezokovesd,Mezotur,Mindszent,Mohacs,Monor,Mor,Morahalom,Nadudvar,Nagyatad,Nagyecsed,Nagyhalasz,Nagykallo,Nagykoros,Nagymaros,Nyekladhaza,Nyergesujfalu,Nyirbator,Nyirmada,Nyirtelek,Ocsa,Orkeny,Oroszlany,Paks,Pannonhalma,Paszto,Pecel,Pecsvarad,Pilisvorosvar,Polgar,Polgardi,Pomaz,Puspokladany,Pusztaszabolcs,Putnok,Racalmas,Rackeve,Rakamaz,Rakoczifalva,Sajoszent,Sandorfalva,Sarbogard,Sarkad,Sarospatak,Sarvar,Satoraljaujhely,Siklos,Simontornya,Soltvadkert,Sumeg,Szabadszallas,Szarvas,Szazhalombatta,Szecseny,Szeghalom,Szentgotthard,Szentlorinc,Szerencs,Szigethalom,Szigetvar,Szikszo,Tab,Tamasi,Tapioszele,Tapolca,Teglas,Tet,Tiszafoldvar,Tiszafured,Tiszakecske,Tiszalok,Tiszaujvaros,Tiszavasvari,Tokaj,Tokol,Tompa,Torokbalint,Torokszentmiklos,Totkomlos,Tura,Turkeve,Ujkigyos,ujszasz,Vamospercs,Varpalota,Vasarosnameny,Vasvar,Vecses,Veresegyhaz,Verpelet,Veszto,Zahony,Zalaszentgrot,Zirc,Zsambek"}, - {name: "Turkish", i: 16, min: 4, max: 10, d: "", m: 0, b: "Yelkaya,Buyrukkaya,Erdemtepe,Alakesen,Baharbeyli,Bozbay,Karaoklu,Altunbey,Yalkale,Yalkut,Akardere,Altayburnu,Esentepe,Okbelen,Derinsu,Alaoba,Yamanbeyli,Aykor,Ekinova,Saztepe,Baharkale,Devrekdibi,Alpseki,Ormanseki,Erkale,Yalbelen,Aytay,Yamanyaka,Altaydelen,Esen,Yedieli,Alpkor,Demirkor,Yediyol,Erdemkaya,Yayburnu,Ganiler,Bayatyurt,Kopuzteke,Aytepe,Deniz,Ayan,Ayazdere,Tepe,Kayra,Ayyaka,Deren,Adatepe,Kalkaneli,Bozkale,Yedidelen,Kocayolu,Sazdere,Bozkesen,Oguzeli,Yayladibi,Uluyol,Altay,Ayvar,Alazyaka,Yaloba,Suyaka,Baltaberi,Poyrazdelen,Eymir,Yediyuva,Kurt,Yeltepe,Oktar,Kara Ok,Ekinberi,Er Yurdu,Eren,Erenler,Ser,Oguz,Asay,Bozokeli,Aykut,Ormanyol,Yazkaya,Kalkanova,Yazbeyli,Dokuz Teke,Bilge,Ertensuyu,Kopuzyuva,Buyrukkut,Akardiken,Aybaray,Aslanbeyli,Altun Kaynak,Atikobasi,Yayla Eli,Kor Tepe,Salureli,Kor Kaya,Aybarberi,Kemerev,Yanaray,Beydileli,Buyrukoba,Yolduman,Tengri Tepe,Dokuzsu,Uzunkor,Erdem Yurdu,Kemer,Korteke,Bozokev,Bozoba,Ormankale,Askale,Oguztoprak,Yolberi,Kumseki,Esenobasi,Turkbelen,Ayazseki,Cereneli,Taykut,Bayramdelen,Beydilyaka,Boztepe,Uluoba,Yelyaka,Ulgardiken,Esensu,Baykale,Cerenkor,Bozyol,Duranoba,Aladuman,Denizli,Bahar,Yarkesen,Dokuzer,Yamankaya,Kocatarla,Alayaka,Toprakeli,Sarptarla,Sarpkoy,Serkaynak,Adayaka,Ayazkaynak,Kopuz,Turk,Kart,Kum,Erten,Buyruk,Yel,Ada,Alazova,Ayvarduman,Buyrukok,Ayvartoprak,Uzuntepe,Binseki,Yedibey,Durankale,Alaztoprak,Sarp Ok,Yaparobasi,Yaytepe,Asberi,Kalkankor,Beydiltepe,Adaberi,Bilgeyolu,Ganiyurt,Alkanteke,Esenerler,Asbey,Erdemkale,Erenkaynak,Oguzkoyu,Ayazoba,Boynuztoprak,Okova,Yaloklu,Sivriberi,Yuladiken,Sazbey,Karakaynak,Kopuzkoyu,Buyrukay,Kocakaya,Tepeduman,Yanarseki,Atikyurt,Esenev,Akarbeyli,Yayteke,Devreksungur,Akseki,Baykut,Kalkandere,Ulgarova,Devrekev,Yulabey,Bayatev,Yazsu,Vuraleli,Sivribeyli,Alaova,Alpobasi,Yalyurt,Elmatoprak,Alazkaynak,Esenay,Ertenev,Salurkor,Ekinok,Yalbey,Yeldere,Ganibay,Altaykut,Baltaboy,Ereli,Ayvarsu,Uzunsaz,Bayeli,Erenyol,Kocabay,Derintay,Ayazyol,Aslanoba,Esenkaynak,Ekinlik,Alpyolu,Alayunt,Bozeski,Erkil,Duransuyu,Yulak,Kut,Dodurga,Kutlubey,Kutluyurt,Boynuz,Alayol,Aybar,Aslaneli,Kemerseki,Baltasuyu,Akarer,Ayvarburnu,Boynuzbeyli,Adasungur,Esenkor,Yamanoba,Toprakkor,Uzunyurt,Sungur,Bozok,Kemerli,Alaz,Demirci,Kartepe"}, - {name: "Berber", i: 17, min: 4, max: 10, d: "s", m: .2, b: "Abkhouch,Adrar,Aeraysh,Afrag,Agadir,Agelmam,Aghmat,Agrakal,Agulmam,Ahaggar,Ait Baha,Ajdir,Akka,Almou,Amegdul,Amizmiz,Amknas,Amlil,Amurakush,Anfa,Annaba,Aousja,Arbat,Arfud,Argoub,Arif,Asfi,Asfru,Ashawen,Assamer,Assif,Awlluz,Ayt Melel,Azaghar,Azila,Azilal,Azmour,Azro,Azrou,Beccar,Beja,Bennour,Benslimane,Berkane,Berrechid,Bizerte,Bjaed,Bouayach,Boudenib,Boufrah,Bouskoura,Boutferda,Darallouch,Dar Bouazza,Darchaabane,Dcheira,Demnat,Denden,Djebel,Djedeida,Drargua,Elhusima,Essaouira,Ezzahra,Fas,Fnideq,Ghezeze,Goubellat,Grisaffen,Guelmim,Guercif,Hammamet,Harrouda,Hdifa,Hoceima,Houara,Idhan,Idurar,Ifendassen,Ifoghas,Ifrane,Ighoud,Ikbir,Imilchil,Imzuren,Inezgane,Irherm,Izoughar,Jendouba,Kacem,Kelibia,Kenitra,Kerrando,Khalidia,Khemisset,Khenifra,Khouribga,Khourigba,Kidal,Korba,Korbous,Lahraouyine,Larache,Leyun,Lqliaa,Manouba,Martil,Mazagan,Mcherga,Mdiq,Megrine,Mellal,Melloul,Midelt,Misur,Mohammedia,Mornag,Mrirt,Nabeul,Nadhour,Nador,Nawaksut,Nefza,Ouarzazate,Ouazzane,Oued Zem,Oujda,Ouladteima,Qsentina,Rades,Rafraf,Safi,Sefrou,Sejnane,Settat,Sijilmassa,Skhirat,Slimane,Somaa,Sraghna,Susa,Tabarka,Tadrart,Taferka,Tafilalt,Tafrawt,Tafza,Tagbalut,Tagerdayt,Taghzut,Takelsa,Taliouine,Tanja,Tantan,Taourirt,Targuist,Taroudant,Tarudant,Tasfelalayt,Tassort,Tata,Tattiwin,Tawnat,Taza,Tazagurt,Tazerka,Tazizawt,Taznakht,Tebourba,Teboursouk,Temara,Testour,Tetouan,Tibeskert,Tifelt,Tijdit,Tinariwen,Tinduf,Tinja,Tittawan,Tiznit,Toubkal,Trables,Tubqal,Tunes,Ultasila,Urup,Wagguten,Wararni,Warzazat,Watlas,Wehran,Wejda,Xamida,Yedder,Youssoufia,Zaghouan,Zahret,Zemmour,Zriba"}, - {name: "Arabic", i: 18, min: 4, max: 9, d: "ae", m: .2, b: "Abha,Ajman,Alabar,Alarjam,Alashraf,Alawali,Albawadi,Albirk,Aldhabiyah,Alduwaid,Alfareeq,Algayed,Alhazim,Alhrateem,Alhudaydah,Alhuwaya,Aljahra,Aljubail,Alkhafah,Alkhalas,Alkhawaneej,Alkhen,Alkhobar,Alkhuznah,Allisafah,Almshaykh,Almurjan,Almuwayh,Almuzaylif,Alnaheem,Alnashifah,Alqah,Alqouz,Alqurayyat,Alradha,Alraqmiah,Alsadyah,Alsafa,Alshagab,Alshuqaiq,Alsilaa,Althafeer,Alwasqah,Amaq,Amran,Annaseem,Aqbiyah,Arafat,Arar,Ardah,Asfan,Ashayrah,Askar,Ayaar,Aziziyah,Baesh,Bahrah,Balhaf,Banizayd,Bidiyah,Bisha,Biyatah,Buqhayq,Burayda,Dafiyat,Damad,Dammam,Dariyah,Dhafar,Dhahran,Dhalkut,Dhurma,Dibab,Doha,Dukhan,Duwaibah,Enaker,Fadhla,Fahaheel,Fanateer,Farasan,Fardah,Fujairah,Ghalilah,Ghar,Ghizlan,Ghomgyah,Ghran,Hadiyah,Haffah,Hajanbah,Hajrah,Haqqaq,Haradh,Hasar,Hawiyah,Hebaa,Hefar,Hijal,Husnah,Huwailat,Huwaitah,Irqah,Isharah,Ithrah,Jamalah,Jarab,Jareef,Jazan,Jeddah,Jiblah,Jihanah,Jilah,Jizan,Joraibah,Juban,Jumeirah,Kamaran,Keyad,Khab,Khaiybar,Khasab,Khathirah,Khawarah,Khulais,Kumzar,Limah,Linah,Madrak,Mahab,Mahalah,Makhtar,Mashwar,Masirah,Masliyah,Mastabah,Mazhar,Medina,Meeqat,Mirbah,Mokhtara,Muharraq,Muladdah,Musaykah,Mushayrif,Musrah,Mussafah,Nafhan,Najran,Nakhab,Nizwa,Oman,Qadah,Qalhat,Qamrah,Qasam,Qosmah,Qurain,Quriyat,Qurwa,Radaa,Rafha,Rahlah,Rakamah,Rasheedah,Rasmadrakah,Risabah,Rustaq,Ryadh,Sabtaljarah,Sadah,Safinah,Saham,Saihat,Salalah,Salmiya,Shabwah,Shalim,Shaqra,Sharjah,Sharurah,Shatifiyah,Shidah,Shihar,Shoqra,Shuwaq,Sibah,Sihmah,Sinaw,Sirwah,Sohar,Suhailah,Sulaibiya,Sunbah,Tabuk,Taif,Taqah,Tarif,Tharban,Thuqbah,Thuwal,Tubarjal,Turaif,Turbah,Tuwaiq,Ubar,Umaljerem,Urayarah,Urwah,Wabrah,Warbah,Yabreen,Yadamah,Yafur,Yarim,Yemen,Yiyallah,Zabid,Zahwah,Zallaq,Zinjibar,Zulumah"}, - {name: "Inuit", i: 19, min: 5, max: 15, d: "alutsn", m: 0, b: "Aaluik,Aappilattoq,Aasiaat,Agissat,Agssaussat,Akuliarutsip,Akunnaaq,Alluitsup,Alluttoq,Amitsorsuaq,Ammassalik,Anarusuk,Anguniartarfik,Annertussoq,Annikitsoq,Apparsuit,Apusiaajik,Arsivik,Arsuk,Atammik,Ateqanaq,Atilissuaq,Attu,Augpalugtoq,Aukarnersuaq,Aumat,Auvilkikavsaup,Avadtlek,Avallersuaq,Bjornesk,Blabaerdalen,Blomsterdalen,Brattalhid,Bredebrae,Brededal,Claushavn,Edderfulegoer,Egger,Eqalugalinnguit,Eqalugarssuit,Eqaluit,Eqqua,Etah,Graah,Hakluyt,Haredalen,Hareoen,Hundeo,Igaliku,Igdlorssuit,Igdluluarssuk,Iginniafik,Ikamiut,Ikarissat,Ikateq,Ikermiut,Ikermoissuaq,Ikorfarssuit,Ilimanaq,Illorsuit,Illunnguit,Iluileq,Ilulissat,Imaarsivik,Imartunarssuk,Immikkoortukajik,Innaarsuit,Inneruulalik,Inussullissuaq,Iperaq,Ippik,Iqek,Isortok,Isungartussoq,Itileq,Itissaalik,Itivdleq,Ittit,Ittoqqortoormiit,Ivingmiut,Ivittuut,Kanajoorartuut,Kangaamiut,Kangeq,Kangerluk,Kangerlussuaq,Kanglinnguit,Kapisillit,Kekertamiut,Kiatak,Kiataussaq,Kigatak,Kinaussak,Kingittorsuaq,Kitak,Kitsissuarsuit,Kitsissut,Klenczner,Kook,Kraulshavn,Kujalleq,Kullorsuaq,Kulusuk,Kuurmiit,Kuusuaq,Laksedalen,Maniitsoq,Marrakajik,Mattaangassut,Mernoq,Mittivakkat,Moriusaq,Myggbukta,Naajaat,Nangissat,Nanuuseq,Nappassoq,Narsarmijt,Narsarsuaq,Narssaq,Nasiffik,Natsiarsiorfik,Naujanguit,Niaqornaarsuk,Niaqornat,Nordfjordspasset,Nugatsiaq,Nunarssit,Nunarsuaq,Nunataaq,Nunatakavsaup,Nutaarmiut,Nuugaatsiaq,Nuuk,Nuukullak,Olonkinbyen,Oodaaq,Oqaatsut,Oqaitsunguit,Oqonermiut,Paagussat,Paamiut,Paatuut,Palungataq,Pamialluk,Perserajoq,Pituffik,Puugutaa,Puulkuip,Qaanaq,Qaasuitsup,Qaersut,Qajartalik,Qallunaat,Qaneq,Qaqortok,Qasigiannguit,Qassimiut,Qeertartivaq,Qeqertaq,Qeqertasussuk,Qeqqata,Qernertoq,Qernertunnguit,Qianarreq,Qingagssat,Qoornuup,Qorlortorsuaq,Qullikorsuit,Qunnerit,Qutdleq,Ravnedalen,Ritenbenk,Rypedalen,Saarloq,Saatorsuaq,Saattut,Salliaruseq,Sammeqqat,Sammisoq,Sanningassoq,Saqqaq,Saqqarlersuaq,Saqqarliit,Sarfannguit,Sattiaatteq,Savissivik,Serfanguaq,Sermersooq,Sermiligaaq,Sermilik,Sermitsiaq,Simitakaja,Simiutaq,Singamaq,Siorapaluk,Sisimiut,Sisuarsuit,Sullorsuaq,Suunikajik,Sverdrup,Taartoq,Takiseeq,Tasirliaq,Tasiusak,Tiilerilaaq,Timilersua,Timmiarmiut,Tukingassoq,Tussaaq,Tuttulissuup,Tuujuk,Uiivaq,Uilortussoq,Ujuaakajiip,Ukkusissat,Upernavik,Uttorsiutit,Uumannaq,Uunartoq,Uvkusigssat,Ymer"}, - {name: "Basque", i: 20, min: 4, max: 11, d: "r", m: .1, b: "Agurain,Aia,Aiara,Albiztur,Alkiza,Altzaga,Amorebieta,Amurrio,Andoain,Anoeta,Antzuola,Arakaldo,Arantzazu,Arbatzegi,Areatza,Arratzua,Arrieta,Artea,Artziniega,Asteasu,Astigarraga,Ataun,Atxondo,Aulesti,Azkoitia,Azpeitia,Bakio,Baliarrain,Barakaldo,Barrika,Barrundia,Basauri,Beasain,Bedia,Beizama,Belauntza,Berastegi,Bergara,Bermeo,Bernedo,Berriatua,Berriz,Bidania,Bilar,Bilbao,Busturia,Deba,Derio,Donostia,Dulantzi,Durango,Ea,Eibar,Elantxobe,Elduain,Elgeta,Elgoibar,Elorrio,Erandio,Ergoitia,Ermua,Errenteria,Errezil,Eskoriatza,Eskuernaga,Etxebarri,Etxebarria,Ezkio,Forua,Gabiria,Gaintza,Galdakao,Gamiz,Garai,Gasteiz,Gatzaga,Gaubea,Gautegiz,Gaztelu,Gernika,Gerrikaitz,Getaria,Getxo,Gizaburuaga,Goiatz,Gorliz,Gorriaga,Harana,Hernani,Hondarribia,Ibarra,Ibarrangelu,Idiazabal,Iekora,Igorre,Ikaztegieta,Irun,Irura,Iruraiz,Itsaso,Itsasondo,Iurreta,Izurtza,Jatabe,Kanpezu,Karrantza,Kortezubi,Kripan,Kuartango,Lanestosa,Lantziego,Larrabetzu,Lasarte,Laukiz,Lazkao,Leaburu,Legazpi,Legorreta,Legutio,Leintz,Leioa,Lekeitio,Lemoa,Lemoiz,Leza,Lezama,Lezo,Lizartza,Maeztu,Mallabia,Manaria,Markina,Maruri,Menaka,Mendaro,Mendata,Mendexa,Morga,Mundaka,Mungia,Munitibar,Murueta,Muskiz,Mutiloa,Mutriku,Nabarniz,Oiartzun,Oion,Okondo,Olaberria,Onati,Ondarroa,Ordizia,Orendain,Orexa,Oria,Orio,Ormaiztegi,Orozko,Ortuella,Otegi,Otxandio,Pasaia,Plentzia,Santurtzi,Sestao,Sondika,Soraluze,Sukarrieta,Tolosa,Trapagaran,Turtzioz,Ubarrundia,Ubide,Ugao,Urdua,Urduliz,Urizaharra,Urkabustaiz,Urnieta,Urretxu,Usurbil,Xemein,Zabaleta,Zaia,Zaldibar,Zambrana,Zamudio,Zaratamo,Zarautz,Zeberio,Zegama,Zerain,Zestoa,Zierbena,Zigoitia,Ziortza,Zuia,Zumaia,Zumarraga"}, - {name: "Nigerian", i: 21, min: 4, max: 10, d: "", m: .3, b: "Abadogo,Abafon,Adealesu,Adeto,Adyongo,Afaga,Afamju,Agigbigi,Agogoke,Ahute,Aiyelaboro,Ajebe,Ajola,Akarekwu,Akunuba,Alawode,Alkaijji,Amangam,Amgbaye,Amtasa,Amunigun,Animahun,Anyoko,Arapagi,Asande,Awgbagba,Awhum,Awodu,Babateduwa,Bandakwai,Bangdi,Bilikani,Birnindodo,Braidu,Bulakawa,Buriburi,Cainnan,Chakum,Chondugh,Dagwarga,Darpi,Dokatofa,Dozere,Ebelibri,Efem,Ekoku,Ekpe,Ewhoeviri,Galea,Gamen,Ganjin,Gantetudu,Gargar,Garinbode,Gbure,Gerti,Gidan,Gitabaremu,Giyagiri,Giyawa,Gmawa,Golakochi,Golumba,Gunji,Gwambula,Gwodoti,Hayinlere,Hayinmaialewa,Hirishi,Hombo,Ibefum,Iberekodo,Icharge,Idofin,Idofinoka,Igbogo,Ijoko,Ijuwa,Ikawga,Ikhin,Ikpakidout,Ikpeoniong,Imuogo,Ipawo,Ipinlerere,Isicha,Itakpa,Jangi,Jare,Jataudakum,Jaurogomki,Jepel,Kafinmalama,Katab,Katanga,Katinda,Katirije,Kaurakimba,Keffinshanu,Kellumiri,Kiagbodor,Kirbutu,Kita,Kogogo,Kopje,Korokorosei,Kotoku,Kuata,Kujum,Kukau,Kunboon,Kuonubogbene,Kurawe,Kushinahu,Kwaramakeri,Ladimeji,Lafiaro,Lahaga,Laindebajanle,Laindegoro,Lakati,Litenswa,Maba,Madarzai,Maianita,Malikansaa,Mata,Megoyo,Meku,Miama,Modi,Mshi,Msugh,Muduvu,Murnachehu,Namnai,Ndamanma,Ndiwulunbe,Ndonutim,Ngbande,Nguengu,Ntoekpe,Nyajo,Nyior,Odajie,Ogbaga,Ogultu,Ogunbunmi,Ojopode,Okehin,Olugunna,Omotunde,Onipede,Onma,Orhere,Orya,Otukwang,Otunade,Rampa,Rimi,Rugan,Rumbukawa,Sabiu,Sangabama,Sarabe,Seboregetore,Shafar,Shagwa,Shata,Shengu,Sokoron,Sunnayu,Tafoki,Takula,Talontan,Tarhemba,Tayu,Ter,Timtim,Timyam,Tindirke,Tokunbo,Torlwam,Tseakaadza,Tseanongo,Tsebeeve,Tsepaegh,Tuba,Tumbo,Tungalombo,Tunganyakwe,Uhkirhi,Umoru,Umuabai,Umuajuju,Unchida,Ungua,Unguwar,Unongo,Usha,Utongbo,Vembera,Wuro,Yanbashi,Yanmedi,Yoku,Zarunkwari,Zilumo,Zulika"}, - {name: "Celtic", i: 22, min: 4, max: 12, d: "nld", m: 0, b: "Aberaman,Aberangell,Aberarth,Aberavon,Aberbanc,Aberbargoed,Aberbeeg,Abercanaid,Abercarn,Abercastle,Abercegir,Abercraf,Abercregan,Abercych,Abercynon,Aberdare,Aberdaron,Aberdaugleddau,Aberdeen,Aberdulais,Aberdyfi,Aberedw,Abereiddy,Abererch,Abereron,Aberfan,Aberffraw,Aberffrwd,Abergavenny,Abergele,Aberglasslyn,Abergorlech,Abergwaun,Abergwesyn,Abergwili,Abergwynfi,Abergwyngregyn,Abergynolwyn,Aberhafesp,Aberhonddu,Aberkenfig,Aberllefenni,Abermain,Abermaw,Abermorddu,Abermule,Abernant,Aberpennar,Aberporth,Aberriw,Abersoch,Abersychan,Abertawe,Aberteifi,Aberthin,Abertillery,Abertridwr,Aberystwyth,Achininver,Afonhafren,Alisaha,Anfosadh,Antinbhearmor,Ardenna,Attacon,Banwen,Beira,Bhrura,Bleddfa,Boioduro,Bona,Boskyny,Boslowenpolbrogh,Boudobriga,Bravon,Brigant,Briganta,Briva,Brosnach,Caersws,Cambodunum,Cambra,Caracta,Catumagos,Centobriga,Ceredigion,Chalain,Chearbhallain,Chlasaigh,Chormaic,Cuileannach,Dinn,Diwa,Dubingen,Duibhidighe,Duro,Ebora,Ebruac,Eburodunum,Eccles,Egloskuri,Eighe,Eireann,Elerghi,Ferkunos,Fhlaithnin,Gallbhuaile,Genua,Ghrainnse,Gwyles,Heartsease,Hebron,Hordh,Inbhear,Inbhir,Inbhirair,Innerleithen,Innerleven,Innerwick,Inver,Inveraldie,Inverallan,Inveralmond,Inveramsay,Inveran,Inveraray,Inverarnan,Inverbervie,Inverclyde,Inverell,Inveresk,Inverfarigaig,Invergarry,Invergordon,Invergowrie,Inverhaddon,Inverkeilor,Inverkeithing,Inverkeithney,Inverkip,Inverleigh,Inverleith,Inverloch,Inverlochlarig,Inverlochy,Invermay,Invermoriston,Inverness,Inveroran,Invershin,Inversnaid,Invertrossachs,Inverugie,Inveruglas,Inverurie,Iubhrach,Karardhek,Kilninver,Kirkcaldy,Kirkintilloch,Krake,Lanngorrow,Latense,Leming,Lindomagos,Llanaber,Llandidiwg,Llandyrnog,Llanfarthyn,Llangadwaldr,Llansanwyr,Lochinver,Lugduno,Magoduro,Mheara,Monmouthshire,Nanshiryarth,Narann,Novioduno,Nowijonago,Octoduron,Penning,Pheofharain,Ponsmeur,Raithin,Ricomago,Rossinver,Salodurum,Seguia,Sentica,Theorsa,Tobargeal,Trealaw,Trefesgob,Trewedhenek,Trewythelan,Tuaisceart,Uige,Vitodurum,Windobona"}, - {name: "Mesopotamian", i: 23, min: 4, max: 9, d: "srpl", m: .1, b: "Adab,Adamndun,Adma,Admatum,Agrab,Akkad,Akshak,Amnanum,Andarig,Anshan,Apiru,Apum,Arantu,Arbid,Arpachiyah,Arpad,Arrapha,Ashlakka,Assur,Awan,Babilim,Bad-Tibira,Balawat,Barsip,Birtu,Bit-Bunakki,Borsippa,Chuera,Dashrah,Der,Dilbat,Diniktum,Doura,Dur-Kurigalzu,Dur-Sharrukin,Dur-Untash,Dûr-gurgurri,Ebla,Ekallatum,Ekalte,Emar,Erbil,Eresh,Eridu,Eshnunn,Eshnunna,Gargamish,Gasur,Gawra,Gibil,Girsu,Gizza,Habirun,Habur,Hadatu,Hakkulan,Halab,Halabit,Hamazi,Hamoukar,Haradum,Harbidum,Harran,Harranu,Hassuna,Hatarikka,Hatra,Hissar,Hiyawa,Hormirzad,Ida-Maras,Idamaraz,Idu,Imerishu,Imgur-Enlil,Irisagrig,Irnina,Irridu,Isin,Issinnitum,Iturungal,Izubitum,Jarmo,Jemdet,Kabnak,Kadesh,Kahat,Kalhu,Kar-Shulmanu-Asharedu,Kar-Tukulti-Ninurta,Kar-shulmanu-asharedu,Karana,Karatepe,Kartukulti,Kazallu,Kesh,Kidsha,Kinza,Kish,Kisiga,Kisurra,Kuara,Kurda,Kurruhanni,Kutha,Lagaba,Lagash,Larak,Larsa,Leilan,Malgium,Marad,Mardaman,Mari,Marlik,Mashkan,Mashkan-shapir,Matutem,Me-Turan,Meliddu,Mumbaqat,Nabada,Nagar,Nanagugal,Nerebtum,Nigin,Nimrud,Nina,Nineveh,Ninua,Nippur,Niru,Niya,Nuhashe,Nuhasse,Nuzi,Puzrish-Dagan,Qalatjarmo,Qatara,Qatna,Qattunan,Qidshu,Rapiqum,Rawda,Sagaz,Shaduppum,Shaggaratum,Shalbatu,Shanidar,Sharrukin,Shawwan,Shehna,Shekhna,Shemshara,Shibaniba,Shubat-Enlil,Shurkutir,Shuruppak,Shusharra,Shushin,Sikan,Sippar,Sippar-Amnanum,Sippar-sha-Annunitum,Subatum,Susuka,Tadmor,Tarbisu,Telul,Terqa,Tirazish,Tisbon,Tuba,Tushhan,Tuttul,Tutub,Ubaid,Umma,Ur,Urah,Urbilum,Urkesh,Ursa'um,Uruk,Urum,Uzarlulu,Warka,Washukanni,Zabalam,Zarri-Amnan"}, - {name: "Iranian", i: 24, min: 5, max: 11, d: "", m: .1, b: "Abali,Abrisham,Absard,Abuzeydabad,Afus,Alavicheh,Alikosh,Amol,Anarak,Anbar,Andisheh,Anshan,Aran,Ardabil,Arderica,Ardestan,Arjomand,Asgaran,Asgharabad,Ashian,Awan,Babajan,Badrud,Bafran,Baghestan,Baghshad,Bahadoran,Baharan Shahr,Baharestan,Bakun,Bam,Baqershahr,Barzok,Bastam,Behistun,Bitistar,Bumahen,Bushehr,Chadegan,Chahardangeh,Chamgardan,Chermahin,Choghabonut,Chugan,Damaneh,Damavand,Darabgard,Daran,Dastgerd,Dehaq,Dehaqan,Dezful,Dizicheh,Dorcheh,Dowlatabad,Duruntash,Ecbatana,Eslamshahr,Estakhr,Ezhiyeh,Falavarjan,Farrokhi,Fasham,Ferdowsieh,Fereydunshahr,Ferunabad,Firuzkuh,Fuladshahr,Ganjdareh,Ganzak,Gaz,Geoy,Godin,Goldasht,Golestan,Golpayegan,Golshahr,Golshan,Gorgab,Guged,Habibabad,Hafshejan,Hajjifiruz,Hana,Harand,Hasanabad,Hasanlu,Hashtgerd,Hecatompylos,Hormirzad,Imanshahr,Isfahan,Jandaq,Javadabad,Jiroft,Jowsheqan ,Jowzdan,Kabnak,Kahrizak,Kahriz Sang,Kangavar,Karaj,Karkevand,Kashan,Kelishad,Kermanshah,Khaledabad,Khansar,Khorramabad,Khur,Khvorzuq,Kilan,Komeh,Komeshcheh,Konar,Kuhpayeh,Kul,Kushk,Lavasan,Laybid,Liyan,Lyan,Mahabad,Mahallat,Majlesi,Malard,Manzariyeh,Marlik,Meshkat,Meymeh,Miandasht,Mish,Mobarakeh,Nahavand,Nain,Najafabad,Naqshe,Narezzash,Nasimshahr,Nasirshahr,Nasrabad,Natanz,Neyasar,Nikabad,Nimvar,Nushabad,Pakdasht,Parand,Pardis,Parsa,Pasargadai,Patigrabana,Pir Bakran,Pishva,Qahderijan,Qahjaverestan,Qamsar,Qarchak,Qods,Rabat,Ray-shahr,Rezvanshahr,Rhages,Robat Karim,Rozveh,Rudehen,Sabashahr,Safadasht,Sagzi,Salehieh,Sandal,Sarvestan,Sedeh,Sefidshahr,Semirom,Semnan,Shadpurabad,Shah,Shahdad,Shahedshahr,Shahin,Shahpour,Shahr,Shahreza,Shahriar,Sharifabad,Shemshak,Shiraz,Shushan,Shushtar,Sialk,Sin,Sukhteh,Tabas,Tabriz,Takhte,Talkhuncheh,Talli,Tarq,Temukan,Tepe,Tiran,Tudeshk,Tureng,Urmia,Vahidieh,Vahrkana,Vanak,Varamin,Varnamkhast,Varzaneh,Vazvan,Yahya,Yarim,Yasuj,Zarrin Shahr,Zavareh,Zayandeh,Zazeran,Ziar,Zibashahr,Zranka"}, - {name: "Hawaiian", i: 25, min: 5, max: 10, d: "auo", m: 1, b: "Aapueo,Ahoa,Ahuakaio,Ahupau,Alaakua,Alae,Alaeloa,Alamihi,Aleamai,Alena,Alio,Aupokopoko,Halakaa,Haleu,Haliimaile,Hamoa,Hanakaoo,Hanaulu,Hanawana,Hanehoi,Haou,Hikiaupea,Hokuula,Honohina,Honokahua,Honokeana,Honokohau,Honolulu,Honomaele,Hononana,Honopou,Hoolawa,Huelo,Kaalaea,Kaapahu,Kaeo,Kahalehili,Kahana,Kahuai,Kailua,Kainehe,Kakalahale,Kakanoni,Kalenanui,Kaleoaihe,Kalialinui,Kalihi,Kalimaohe,Kaloi,Kamani,Kamehame,Kanahena,Kaniaula,Kaonoulu,Kapaloa,Kapohue,Kapuaikini,Kapunakea,Kauau,Kaulalo,Kaulanamoa,Kauluohana,Kaumakani,Kaumanu,Kaunauhane,Kaupakulua,Kawaloa,Keaa,Keaaula,Keahua,Keahuapono,Kealahou,Keanae,Keauhou,Kelawea,Keokea,Keopuka,Kikoo,Kipapa,Koakupuna,Koali,Kolokolo,Kopili,Kou,Kualapa,Kuhiwa,Kuholilea,Kuhua,Kuia,Kuikui,Kukoae,Kukohia,Kukuiaeo,Kukuipuka,Kukuiula,Kulahuhu,Lapakea,Lapueo,Launiupoko,Lole,Maalo,Mahinahina,Mailepai,Makaakini,Makaalae,Makaehu,Makaiwa,Makaliua,Makapipi,Makapuu,Maluaka,Manawainui,Mehamenui,Moalii,Moanui,Mohopili,Mokae,Mokuia,Mokupapa,Mooiki,Mooloa,Moomuku,Muolea,Nakaaha,Nakalepo,Nakaohu,Nakapehu,Nakula,Napili,Niniau,Nuu,Oloewa,Olowalu,Omaopio,Onau,Onouli,Opaeula,Opana,Opikoula,Paakea,Paeahu,Paehala,Paeohi,Pahoa,Paia,Pakakia,Palauea,Palemo,Paniau,Papaaea,Papaanui,Papaauhau,Papaka,Papauluana,Pauku,Paunau,Pauwalu,Pauwela,Pohakanele,Polaiki,Polanui,Polapola,Poopoo,Poponui,Poupouwela,Puahoowali,Puakea,Puako,Pualaea,Puehuehu,Pueokauiki,Pukaauhuhu,Pukuilua,Pulehu,Puolua,Puou,Puuhaehae,Puuiki,Puuki,Puulani,Puunau,Puuomaile,Uaoa,Uhao,Ukumehame,Ulaino,Ulumalu,Wahikuli,Waianae,Waianu,Waiawa,Waiehu,Waieli,Waikapu,Wailamoa,Wailaulau,Wainee,Waiohole,Waiohonu,Waiohuli,Waiokama,Waiokila,Waiopai,Waiopua,Waipao,Waipionui,Waipouli"}, - {name: "Karnataka", i: 26, min: 5, max: 11, d: "tnl", m: 0, b: "Adityapatna,Adyar,Afzalpur,Aland,Alnavar,Alur,Ambikanagara,Anekal,Ankola,Annigeri,Arkalgud,Arsikere,Athni,Aurad,Badami,Bagalkot,Bagepalli,Bail,Bajpe,Bangalore,Bangarapet,Bankapura,Bannur,Bantval,Basavakalyan,Basavana,Belgaum,Beltangadi,Belur,Bhadravati,Bhalki,Bhatkal,Bhimarayanagudi,Bidar,Bijapur,Bilgi,Birur,Bommasandra,Byadgi,Challakere,Chamarajanagar,Channagiri,Channapatna,Channarayapatna,Chik,Chikmagalur,Chiknayakanhalli,Chikodi,Chincholi,Chintamani,Chitapur,Chitgoppa,Chitradurga,Dandeli,Dargajogihalli,Devadurga,Devanahalli,Dod,Donimalai,Gadag,Gajendragarh,Gangawati,Gauribidanur,Gokak,Gonikoppal,Gubbi,Gudibanda,Gulbarga,Guledgudda,Gundlupet,Gurmatkal,Haliyal,Hangal,Harapanahalli,Harihar,Hassan,Hatti,Haveri,Hebbagodi,Heggadadevankote,Hirekerur,Holalkere,Hole,Homnabad,Honavar,Honnali,Hoovina,Hosakote,Hosanagara,Hosdurga,Hospet,Hubli,Hukeri,Hungund,Hunsur,Ilkal,Indi,Jagalur,Jamkhandi,Jevargi,Jog,Kadigenahalli,Kadur,Kalghatgi,Kamalapuram,Kampli,Kanakapura,Karkal,Karwar,Khanapur,Kodiyal,Kolar,Kollegal,Konnur,Koppa,Koppal,Koratagere,Kotturu,Krishnarajanagara,Krishnarajasagara,Krishnarajpet,Kudchi,Kudligi,Kudremukh,Kumta,Kundapura,Kundgol,Kunigal,Kurgunta,Kushalnagar,Kushtagi,Lakshmeshwar,Lingsugur,Londa,Maddur,Madhugiri,Madikeri,Mahalingpur,Malavalli,Mallar,Malur,Mandya,Mangalore,Manvi,Molakalmuru,Mudalgi,Mudbidri,Muddebihal,Mudgal,Mudhol,Mudigere,Mulbagal,Mulgund,Mulki,Mulur,Mundargi,Mundgod,Munirabad,Mysore,Nagamangala,Nanjangud,Narasimharajapura,Naregal,Nargund,Navalgund,Nipani,Pandavapura,Pavagada,Piriyapatna,Pudu,Puttur,Rabkavi,Raichur,Ramanagaram,Ramdurg,Ranibennur,Raybag,Robertson,Ron,Sadalgi,Sagar,Sakleshpur,Saligram,Sandur,Sankeshwar,Saundatti,Savanur,Sedam,Shahabad,Shahpur,Shaktinagar,Shiggaon,Shikarpur,Shirhatti,Shorapur,Shrirangapattana,Siddapur,Sidlaghatta,Sindgi,Sindhnur,Sira,Siralkoppa,Sirsi,Siruguppa,Somvarpet,Sorab,Sringeri,Srinivaspur,Sulya,Talikota,Tarikere,Tekkalakote,Terdal,Thumbe,Tiptur,Tirthahalli,Tirumakudal,Tumkur,Turuvekere,Udupi,Vijayapura,Wadi,Yadgir,Yelandur,Yelbarga,Yellapur,Yenagudde"}, - {name: "Quechua", i: 27, min: 6, max: 12, d: "l", m: 0, b: "Alpahuaycco,Anchihuay,Anqea,Apurimac,Arequipa,Atahuallpa,Atawalpa,Atico,Ayacucho,Ayahuanco,Ayllu,Cajamarca,Canayre,Canchacancha,Carapo,Carhuac,Carhuacatac,Cashan,Caullaraju,Caxamalca,Cayesh,Ccahuasno,Ccarhuacc,Ccopayoc,Chacchapunta,Chacraraju,Challhuamayo,Champara,Chanchan,Chekiacraju,Chillihua,Chinchey,Chontah,Chopicalqui,Chucuito,Chuito,Chullo,Chumpi,Chuncho,Chupahuacho,Chuquiapo,Chuquisaca,Churup,Cocapata,Cochabamba,Cojup,Collota,Conococha,Corihuayrachina,Cuchoquesera,Cusichaca,Haika,Hanpiq,Hatun,Haywarisqa,Huaca,Huachinga,Hualcan,Hualchancca,Huamanga,Huamashraju,Huancarhuas,Huandoy,Huantsan,Huanupampa,Huarmihuanusca,Huascaran,Huaylas,Huayllabamba,Huayrana,Huaytara,Huichajanca,Huinayhuayna,Huinche,Huinioch,Illiasca,Intipunku,Iquicha,Ishinca,Jahuacocha,Jirishanca,Juli,Jurau,Kakananpunta,Kamasqa,Karpay,Kausay,Khuya,Kuelap,Lanccochayocc,Llaca,Llactapata,Llanganuco,Llaqta,Lloqllasca,Llupachayoc,Luricocha,Machu,Mallku,Matarraju,Mechecc,Mikhuy,Milluacocha,Morochuco,Munay,Ocshapalca,Ollantaytambo,Oroccahua,Oronccoy,Oyolo,Pacamayo,Pacaycasa,Paccharaju,Pachacamac,Pachakamaq,Pachakuteq,Pachakuti,Pachamama,Paititi,Pajaten,Palcaraju,Pallccas,Pampa,Panaka,Paqarina,Paqo,Parap,Paria,Patahuasi,Patallacta,Patibamba,Pisac,Pisco,Pongos,Pucacolpa,Pucahirca,Pucaranra,Pumatambo,Puscanturpa,Putaca,Puyupatamarca,Qawaq,Qayqa,Qochamoqo,Qollana,Qorihuayrachina,Qorimoqo,Qotupuquio,Quenuaracra,Queshque,Quillcayhuanca,Quillya,Quitaracsa,Quitaraju,Qusqu,Rajucolta,Rajutakanan,Rajutuna,Ranrahirca,Ranrapalca,Raria,Rasac,Rimarima,Riobamba,Runkuracay,Rurec,Sacsa,Sacsamarca,Saiwa,Sarapo,Sayacmarca,Sayripata,Sinakara,Sonccopa,Taripaypacha,Taulliraju,Tawantinsuyu,Taytanchis,Tiwanaku,Tocllaraju,Tsacra,Tuco,Tucubamba,Tullparaju,Tumbes,Uchuraccay,Uchuraqay,Ulta,Urihuana,Uruashraju,Vallunaraju,Vilcabamba,Wacho,Wankawillka,Wayra,Yachay,Yahuarraju,Yanamarey,Yanaqucha,Yanesha,Yerupaja"}, - {name: "Swahili", i: 28, min: 4, max: 9, d: "", m: 0, b: "Abim,Adjumani,Alebtong,Amolatar,Amuru,Apac,Arua,Arusha,Babati,Baragoi,Bombo,Budaka,Bugembe,Bugiri,Buikwe,Bukedea,Bukoba,Bukomansimbi,Bukungu,Buliisa,Bundibugyo,Bungoma,Busembatya,Bushenyi,Busia,Busolwe,Butaleja,Butambala,Butere,Buwenge,Buyende,Dadaab,Dodoma,Dokolo,Eldoret,Elegu,Emali,Embu,Entebbe,Garissa,Gede,Gulu,Handeni,Hima,Hoima,Hola,Ibanda,Iganga,Iringa,Isingiro,Isiolo,Jinja,Kaabong,Kabuyanda,Kabwohe,Kagadi,Kajiado,Kakinga,Kakiri,Kakuma,Kalangala,Kaliro,Kalongo,Kalungu,Kampala,Kamwenge,Kanungu,Kapchorwa,Kasese,Kasulu,Katakwi,Kayunga,Keroka,Kiambu,Kibaale,Kibaha,Kibingo,Kibwezi,Kigoma,Kihiihi,Kilifi,Kiruhura,Kiryandongo,Kisii,Kisoro,Kisumu,Kitale,Kitgum,Kitui,Koboko,Korogwe,Kotido,Kumi,Kyazanga,Kyegegwa,Kyenjojo,Kyotera,Lamu,Langata,Lindi,Lodwar,Lokichoggio,Londiani,Loyangalani,Lugazi,Lukaya,Luweero,Lwakhakha,Lwengo,Lyantonde,Machakos,Mafinga,Makambako,Makindu,Malaba,Malindi,Manafwa,Mandera,Marsabit,Masaka,Masindi,Masulita,Matugga,Mayuge,Mbale,Mbarara,Mbeya,Meru,Mitooma,Mityana,Mombasa,Morogoro,Moroto,Moyale,Moyo,Mpanda,Mpigi,Mpondwe,Mtwara,Mubende,Mukono,Muranga,Musoma,Mutomo,Mutukula,Mwanza,Nagongera,Nairobi,Naivasha,Nakapiripirit,Nakaseke,Nakasongola,Nakuru,Namanga,Namayingo,Namutumba,Nansana,Nanyuki,Narok,Naromoru,Nebbi,Ngora,Njeru,Njombe,Nkokonjeru,Ntungamo,Nyahururu,Nyeri,Oyam,Pader,Paidha,Pakwach,Pallisa,Rakai,Ruiru,Rukungiri,Rwimi,Sanga,Sembabule,Shimoni,Shinyanga,Singida,Sironko,Songea,Soroti,Ssabagabo,Sumbawanga,Tabora,Takaungu,Tanga,Thika,Tororo,Tunduma,Vihiga,Voi,Wajir,Wakiso,Watamu,Webuye,Wobulenzi,Wote,Wundanyi,Yumbe,Zanzibar"}, - {name: "Vietnamese", i: 29, min: 3, max: 12, d: "", m: 1, b: "An Giang,Anh Son,An Khe,An Nhon,Ayun Pa,Bac Giang,Bac Kan,Bac Lieu,Bac Ninh,Ba Don,Bao Loc,Ba Ria,Ba Ria-Vung Tau,Ba Thuoc,Ben Cat,Ben Tre,Bien Hoa,Bim Son,Binh Dinh,Binh Duong,Binh Long,Binh Minh,Binh Phuoc,Binh Thuan,Buon Ho,Buon Ma Thuot,Cai Lay,Ca Mau,Cam Khe,Cam Pha,Cam Ranh,Cam Thuy,Can Tho,Cao Bang,Cao Lanh,Cao Phong,Chau Doc,Chi Linh,Con Cuong,Cua Lo,Da Bac,Dak Lak,Da Lat,Da Nang,Di An,Dien Ban,Dien Bien,Dien Bien Phu,Dien Chau,Do Luong,Dong Ha,Dong Hoi,Dong Trieu,Duc Pho,Duyen Hai,Duy Tien,Gia Lai,Gia Nghia,Gia Rai,Go Cong,Ha Giang,Ha Hoa,Hai Duong,Hai Phong,Ha Long,Ha Nam,Ha Noi,Ha Tinh,Ha Trung,Hau Giang,Hoa Binh,Hoang Mai,Hoa Thanh,Ho Chi Minh,Hoi An,Hong Linh,Hong Ngu,Hue,Hung Nguyen,Hung Yen,Huong Thuy,Huong Tra,Khanh Hoa,Kien Tuong,Kim Boi,Kinh Mon,Kon Tum,Ky Anh,Ky Son,Lac Son,Lac Thuy,La Gi,Lai Chau,Lam Thao,Lang Chanh,Lang Son,Lao Cai,Long An,Long Khanh,Long My,Long Xuyen,Luong Son,Mai Chau,Mong Cai,Muong Lat,Muong Lay,My Hao,My Tho,Nam Dan,Nam Dinh,Nga Bay,Nga Nam,Nga Son,Nghe An,Nghia Dan,Nghia Lo,Nghi Loc,Nghi Son,Ngoc Lac,Nha Trang,Nhu Thanh,Nhu Xuan,Ninh Binh,Ninh Hoa,Nong Cong,Phan Rang Thap Cham,Phan Thiet,Pho Yen,Phu Ly,Phu My,Phu Ninh,Phuoc Long,Phu Tho,Phu Yen,Pleiku,Quang Binh,Quang Nam,Quang Ngai,Quang Ninh,Quang Tri,Quang Xuong,Quang Yen,Quan Hoa,Quan Son,Que Phong,Quy Chau,Quy Hop,Quynh Luu,Quy Nhon,Rach Gia,Sa Dec,Sai Gon,Sam Son,Sa Pa,Soc Trang,Song Cau,Song Cong,Son La,Son Tay,Tam Diep,Tam Ky,Tan An,Tan Chau,Tan Ky,Tan Lac,Tan Son,Tan Uyen,Tay Ninh,Thach Thanh,Thai Binh,Thai Hoa,Thai Nguyen,Thanh Chuong,Thanh Hoa,Thieu Hoa,Thuan An,Thua Thien-Hue,Thu Dau Mot,Thu Duc,Thuong Xuan,Tien Giang,Trang Bang,Tra Vinh,Trieu Son,Tu Son,Tuyen Quang,Tuy Hoa,Uong Bi,Viet Tri,Vinh,Vinh Chau,Vinh Loc,Vinh Long,Vinh Yen,Vi Thanh,Vung Tau,Yen Bai,Yen Dinh,Yen Thanh,Yen Thuy"}, - {name: "Cantonese", i: 30, min: 5, max: 11, d: "", m: 0, b: "Chaiwan,Chingchung,Chinghoi,Chingsen,Chingshing,Chiunam,Chiuon,Chiuyeung,Chiyuen,Choihung,Chuehoi,Chuiman,Chungfu,Chungsan,Chunguktsuen,Dakhing,Daopo,Daumun,Dingwu,Dinpak,Donggun,Dongyuen,Duenchau,Fachau,Fanling,Fatgong,Fatshan,Fotan,Fuktien,Fumun,Funggong,Funghoi,Fungshun,Fungtei,Gamtin,Gochau,Goming,Gonghoi,Gongshing,Goyiu,Hanghau,Hangmei,Hengon,Heungchau,Heunggong,Heungkiu,Hingning,Hohfuktong,Hoichue,Hoifung,Hoiping,Hokong,Hokshan,Hoyuen,Hunghom,Hungshuikiu,Jiuling,Kamsheung,Kamwan,Kaulongtong,Keilun,Kinon,Kinsang,Kityeung,Kongmun,Kukgong,Kwaifong,Kwaihing,Kwongchau,Kwongling,Kwongming,Kwuntong,Laichikok,Laiking,Laiwan,Lamtei,Lamtin,Leitung,Leungking,Limkong,Linping,Linshan,Loding,Lokcheong,Lokfu,Longchuen,Longgong,Longmun,Longping,Longwa,Longwu,Lowu,Luichau,Lukfung,Lukho,Lungmun,Macheung,Maliushui,Maonshan,Mauming,Maunam,Meifoo,Mingkum,Mogong,Mongkok,Muichau,Muigong,Muiyuen,Naiwai,Namcheong,Namhoi,Namhong,Namsha,Nganwai,Ngautaukok,Ngchuen,Ngwa,Onting,Pakwun,Paotoishan,Pingshan,Pingyuen,Poklo,Pongon,Poning,Potau,Puito,Punyue,Saiwanho,Saiyingpun,Samshing,Samshui,Samtsen,Samyuenlei,Sanfung,Sanhing,Sanhui,Sanwai,Seiwui,Shamshuipo,Shanmei,Shantau,Shauking,Shekmun,Shekpai,Sheungshui,Shingkui,Shiuhing,Shundak,Shunyi,Shupinwai,Simshing,Siuhei,Siuhong,Siukwan,Siulun,Suikai,Taihing,Taikoo,Taipo,Taishuihang,Taiwai,Taiwohau,Tinhau,Tinshuiwai,Tiukengleng,Toishan,Tongfong,Tonglowan,Tsakyoochung,Tsamgong,Tsangshing,Tseungkwano,Tsimshatsui,Tsinggong,Tsingshantsuen,Tsingwun,Tsingyi,Tsingyuen,Tsiuchau,Tsuenshekshan,Tsuenwan,Tuenmun,Tungchung,Waichap,Waichau,Waidong,Wailoi,Waishing,Waiyeung,Wanchai,Wanfau,Wanshing,Wingon,Wongpo,Wongtaisin,Woping,Wukaisha,Yano,Yaumatei,Yautong,Yenfa,Yeungchun,Yeungdong,Yeungsai,Yeungshan,Yimtin,Yingdak,Yiuping,Yongshing,Yongyuen,Yuenlong,Yuenshing,Yuetsau,Yuknam,Yunping"}, - {name: "Mongolian", i: 31, min: 5, max: 12, d: "aou", m: .3, b: "Adaatsag,Airag,Alag Erdene,Altai,Altanshiree,Altantsogts,Arbulag,Baatsagaan,Batnorov,Batshireet,Battsengel,Bayan Adarga,Bayan Agt,Bayanbulag,Bayandalai,Bayandun,Bayangovi,Bayanjargalan,Bayankhongor,Bayankhutag,Bayanlig,Bayanmonkh,Bayannur,Bayannuur,Bayan Ondor,Bayan Ovoo,Bayantal,Bayantsagaan,Bayantumen,Bayan Uul,Bayanzurkh,Berkh,Biger,Binder,Bogd,Bombogor,Bor Ondor,Bugat,Bugt,Bulgan,Buregkhangai,Burentogtokh,Buutsagaan,Buyant,Chandmani,Chandmani Ondor,Choibalsan,Chuluunkhoroot,Chuluut,Dadal,Dalanjargalan,Dalanzadgad,Darhan Muminggan,Darkhan,Darvi,Dashbalbar,Dashinchilen,Delger,Delgerekh,Delgerkhaan,Delgerkhangai,Delgertsogt,Deluun,Deren,Dorgon,Duut,Erdene,Erdenebulgan,Erdeneburen,Erdenedalai,Erdenemandal,Erdenetsogt,Galshar,Galt,Galuut,Govi Ugtaal,Gurvan,Gurvanbulag,Gurvansaikhan,Gurvanzagal,Hinggan,Hodong,Holingol,Hondlon,Horin Ger,Horqin,Hulunbuir,Hure,Ikhkhet,Ikh Tamir,Ikh Uul,Jargalan,Jargalant,Jargaltkhaan,Jarud,Jinst,Khairkhan,Khalhgol,Khaliun,Khanbogd,Khangai,Khangal,Khankh,Khankhongor,Khashaat,Khatanbulag,Khatgal,Kherlen,Khishig Ondor,Khokh,Kholonbuir,Khongor,Khotont,Khovd,Khovsgol,Khuld,Khureemaral,Khurmen,Khutag Ondor,Luus,Mandakh,Mandal Ovoo,Mankhan,Manlai,Matad,Mogod,Monkhkhairkhan,Moron,Most,Myangad,Nogoonnuur,Nomgon,Norovlin,Noyon,Ogii,Olgii,Olziit,Omnodelger,Ondorkhaan,Ondorshil,Ondor Ulaan,Ongniud,Ordos,Orgon,Orkhon,Rashaant,Renchinlkhumbe,Sagsai,Saikhan,Saikhandulaan,Saikhan Ovoo,Sainshand,Saintsagaan,Selenge,Sergelen,Sevrei,Sharga,Sharyngol,Shine Ider,Shinejinst,Shiveegovi,Sumber,Taishir,Tarialan,Tariat,Teshig,Togrog,Togtoh,Tolbo,Tomorbulag,Tonkhil,Tosontsengel,Tsagaandelger,Tsagaannuur,Tsagaan Ovoo,Tsagaan Uur,Tsakhir,Tseel,Tsengel,Tsenkher,Tsenkhermandal,Tsetseg,Tsetserleg,Tsogt,Tsogt Ovoo,Tsogttsetsii,Tumed,Tunel,Tuvshruulekh,Ulaanbadrakh,Ulaankhus,Ulaan Uul,Ulanhad,Ulanqab,Uyench,Yesonbulag,Zag,Zalainur,Zamyn Uud,Zereg"}, - // fantasy bases by Dopu: - {name: "Human Generic", i: 32, min: 6, max: 11, d: "peolst", m: 0, b: "Amberglen,Angelhand,Arrowden,Autumnband,Autumnkeep,Basinfrost,Basinmore,Bayfrost,Beargarde,Bearmire,Bellcairn,Bellport,Bellreach,Blackwatch,Bleakward,Bonemouth,Boulder,Bridgefalls,Bridgeforest,Brinepeak,Brittlehelm,Bronzegrasp,Castlecross,Castlefair,Cavemire,Claymond,Claymouth,Clearguard,Cliffgate,Cliffshear,Cliffshield,Cloudbay,Cloudcrest,Cloudwood,Coldholde,Cragbury,Crowgrove,Crowvault,Crystalrock,Crystalspire,Cursefield,Curseguard,Cursespell,Dawnforest,Dawnwater,Deadford,Deadkeep,Deepcairn,Deerchill,Demonfall,Dewglen,Dewmere,Diredale,Direden,Dirtshield,Dogcoast,Dogmeadow,Dragonbreak,Dragonhold,Dragonward,Dryhost,Dustcross,Dustwatch,Eaglevein,Earthfield,Earthgate,Earthpass,Ebonfront,Edgehaven,Eldergate,Eldermere,Embervault,Everchill,Evercoast,Falsevale,Faypond,Fayvale,Fayyard,Fearpeak,Flameguard,Flamewell,Freyshell,Ghostdale,Ghostpeak,Gloomburn,Goldbreach,Goldyard,Grassplains,Graypost,Greeneld,Grimegrove,Grimeshire,Heartfall,Heartford,Heartvault,Highbourne,Hillpass,Hollowstorm,Honeywater,Houndcall,Houndholde,Iceholde,Icelight,Irongrave,Ironhollow,Knightlight,Knighttide,Lagoonpass,Lakecross,Lastmere,Laststar,Lightvale,Limeband,Littlehall,Littlehold,Littlemire,Lostcairn,Lostshield,Loststar,Madfair,Madham,Midholde,Mightglen,Millstrand,Mistvault,Mondpass,Moonacre,Moongulf,Moonwell,Mosshand,Mosstide,Mosswind,Mudford,Mudwich,Mythgulch,Mythshear,Nevercrest,Neverfront,Newfalls,Nighthall,Oakenbell,Oakenrun,Oceanstar,Oldreach,Oldwall,Oldwatch,Oxbrook,Oxlight,Pearlhaven,Pinepond,Pondfalls,Pondtown,Pureshell,Quickbell,Quickpass,Ravenside,Roguehaven,Roseborn,Rosedale,Rosereach,Rustmore,Saltmouth,Sandhill,Scorchpost,Scorchstall,Shadeforest,Shademeadow,Shadeville,Shimmerrun,Shimmerwood,Shroudrock,Silentkeep,Silvercairn,Silvergulch,Smallmire,Smoothcliff,Smoothgrove,Smoothtown,Snakemere,Snowbay,Snowshield,Snowtown,Southbreak,Springmire,Springview,Stagport,Steammouth,Steamwall,Steepmoor,Stillhall,Stoneguard,Stonespell,Stormhand,Stormhorn,Sungulf,Sunhall,Swampmaw,Swangarde,Swanwall,Swiftwell,Thorncairn,Thornhelm,Thornyard,Timberside,Tradewick,Westmeadow,Westpoint,Whiteshore,Whitvalley,Wildeden,Wildwell,Wildyard,Winterhaven,Wolfpass"}, - {name: "Elven", i: 33, min: 6, max: 12, d: "lenmsrg", m: 0, b: "Adrindest,Aethel,Afranthemar,Aiqua,Alari,Allanar,Almalian,Alora,Alyanasari,Alyelona,Alyran,Ammar,Anyndell,Arasari,Aren,Ashmebel,Aymlume,Bel-Didhel,Brinorion,Caelora,Chaulssad,Chaundra,Cyhmel,Cyrang,Dolarith,Dolonde,Draethe,Dranzan,Draugaust,E'ana,Eahil,Edhil,Eebel,Efranluma,Eld-Sinnocrin,Elelthyr,Ellanalin,Ellena,Ellorthond,Eltaesi,Elunore,Emyranserine,Entheas,Eriargond,Esari,Esath,Eserius,Eshsalin,Eshthalas,Evraland,Faellenor,Famelenora,Filranlean,Filsaqua,Gafetheas,Gaf Serine,Geliene,Gondorwin,Guallu,Haeth,Hanluna,Haulssad,Heloriath,Himlarien,Himliene,Hinnead,Hlinas,Hloireenil,Hluihei,Hlurthei,Hlynead,Iaenarion,Iaron,Illanathaes,Illfanora,Imlarlon,Imyse,Imyvelian,Inferius,Inlurth,innsshe,Iralserin,Irethtalos,Irholona,Ishal,Ishlashara,Ithelion,Ithlin,Iulil,Jaal,Jamkadi,Kaalume,Kaansera,Karanthanil,Karnosea,Kasethyr,Keatheas,Kelsya,Keth Aiqua,Kmlon,Kyathlenor,Kyhasera,Lahetheas,Lefdorei,Lelhamelle,Lilean,Lindeenil,Lindoress,Litys,Llaughei,Lya,Lyfa,Lylharion,Lynathalas,Machei,Masenoris,Mathethil,Mathentheas,Meethalas,Menyamar,Mithlonde,Mytha,Mythsemelle,Mythsthas,Naahona,Nalore,Nandeedil,Nasad Ilaurth,Nasin,Nathemar,Neadar,Neilon,Nelalon,Nellean,Nelnetaesi,Nilenathyr,Nionande,Nylm,Nytenanas,Nythanlenor,O'anlenora,Obeth,Ofaenathyr,Ollmnaes,Ollsmel,Olwen,Olyaneas,Omanalon,Onelion,Onelond,Orlormel,Ormrion,Oshana,Oshvamel,Raethei,Rauguall,Reisera,Reslenora,Ryanasera,Rymaserin,Sahnor,Saselune,Sel-Zedraazin,Selananor,Sellerion,Selmaluma,Shaeras,Shemnas,Shemserin,Sheosari,Sileltalos,Siriande,Siriathil,Srannor,Sshanntyr,Sshaulu,Syholume,Sylharius,Sylranbel,Taesi,Thalor,Tharenlon,Thelethlune,Thelhohil,Themar,Thene,Thilfalean,Thilnaenor,Thvethalas,Thylathlond,Tiregul,Tlauven,Tlindhe,Ulal,Ullve,Ulmetheas,Ulssin,Umnalin,Umye,Umyheserine,Unanneas,Unarith,Undraeth,Unysarion,Vel-Shonidor,Venas,Vin Argor,Wasrion,Wlalean,Yaeluma,Yeelume,Yethrion,Ymserine,Yueghed,Yuerran,Yuethin"}, - {name: "Dark Elven", i: 34, min: 6, max: 14, d: "nrslamg", m: .2, b: "Abaethaggar,Abburth,Afranthemar,Aharasplit,Aidanat,Ald'ruhn,Ashamanu,Ashesari,Ashletheas,Baerario,Baereghel,Baethei,Bahashae,Balmora,Bel-Didhel,Borethanil,Buiyrandyn,Caellagith,Caellathala,Caergroth,Caldras,Chaggar,Chaggaust,Channtar,Charrvhel'raugaust,Chaulssin,Chaundra,ChedNasad,ChetarIthlin,ChethRrhinn,Chymaer,Clarkarond,Cloibbra,Commoragh,Cyrangroth,Cilben,D'eldarc,Daedhrog,Dalkyn,Do'Urden,Doladress,Dolarith,Dolonde,Draethe,Dranzan,Dranzithl,Draugaust,Dreghei,Drelhei,Dryndlu,Dusklyngh,DyonG'ennivalz,Edraithion,Eld-Sinnocrin,Ellorthond,Enhethyr,Entheas,ErrarIthinn,Eryndlyn,Faladhell,Faneadar,Fethalas,Filranlean,Formarion,Ferdor,Gafetheas,Ghrond,Gilranel,Glamordis,Gnaarmok,Gnisis,Golothaer,Gondorwin,Guallidurth,Guallu,Gulshin,Haeth,Haggraef,Harganeth,Harkaldra,Haulssad,Haundrauth,Heloriath,Hlammachar,Hlaughei,Hloireenil,Hluitar,Inferius,Innsshe,Ithilaughym,Iz'aiogith,Jaal,Jhachalkhyn,Kaerabrae,Karanthanil,Karondkar,Karsoluthiyl,Kellyth,Khuul,Lahetheas,Lidurth,Lindeenil,Lirillaquen,LithMy'athar,LlurthDreier,Lolth,Lothuial,Luihaulen'tar,Maeralyn,Maerimydra,Mathathlona,Mathethil,Mellodona,Menagith,Menegwen,Menerrendil,Menzithl,Menzoberranzan,Mila-Nipal,Mithryn,Molagmar,Mundor,Myvanas,Naggarond,Nandeedil,NasadIlaurth,Nauthor,Navethas,Neadar,Nurtaleewe,Nidiel,Noruiben,Olwen,O'lalona,Obeth,Ofaenathyr,Orlormel,Orlytlar,Pelagiad,Raethei,Raugaust,Rauguall,Rilauven,Rrharrvhei,Sadrith,Sel-Zedraazin,Seydaneen,Shaz'rir,Skaal,Sschindylryn,Shamath,Shamenz,Shanntur,Sshanntynlan,Sshanntyr,Shaulssin,SzithMorcane,Szithlin,Szobaeth,Sirdhemben,T'lindhet,Tebh'zhor,Telmere,Telnarquel,Tharlarast,Thylathlond,Tlaughe,Trizex,Tyrybblyn,Ugauth,Ughym,Uhaelben,Ullmatalos,Ulmetheas,Ulrenserine,Uluitur,Undraeth,Undraurth,Undrek'Thoz,Ungethal,UstNatha,Uthaessien,V'elddrinnsshar,Vaajha,Vel-Shonidor,Velddra,Velothi,Venead,Vhalth'vha,Vinargothr,Vojha,Waethe,Waethei,Xaalkis,Yakaridan,Yeelume,Yridhremben,Yuethin,Yuethindrynn,Zirnakaynin"}, - {name: "Dwarven", i: 35, min: 4, max: 11, d: "dk", m: 0, b: "Addundad,Ahagzad,Ahazil,Akil,Akzizad,Anumush,Araddush,Arar,Arbhur,Badushund,Baragzig,Baragzund,Barakinb,Barakzig,Barakzinb,Barakzir,Baramunz,Barazinb,Barazir,Bilgabar,Bilgatharb,Bilgathaz,Bilgila,Bilnaragz,Bilnulbar,Bilnulbun,Bizaddum,Bizaddush,Bizanarg,Bizaram,Bizinbiz,Biziram,Bunaram,Bundinar,Bundushol,Bundushund,Bundushur,Buzaram,Buzundab,Buzundush,Gabaragz,Gabaram,Gabilgab,Gabilgath,Gabizir,Gabunal,Gabunul,Gabuzan,Gatharam,Gatharbhur,Gathizdum,Gathuragz,Gathuraz,Gila,Giledzir,Gilukkhath,Gilukkhel,Gunala,Gunargath,Gunargil,Gundumunz,Gundusharb,Gundushizd,Kharbharbiln,Kharbhatharb,Kharbhela,Kharbilgab,Kharbuzadd,Khatharbar,Khathizdin,Khathundush,Khazanar,Khazinbund,Khaziragz,Khaziraz,Khizdabun,Khizdusharbh,Khizdushath,Khizdushel,Khizdushur,Kholedzar,Khundabiln,Khundabuz,Khundinarg,Khundushel,Khuragzig,Khuramunz,Kibarak,Kibilnal,Kibizar,Kibunarg,Kibundin,Kibuzan,Kinbadab,Kinbaragz,Kinbarakz,Kinbaram,Kinbizah,Kinbuzar,Nala,Naledzar,Naledzig,Naledzinb,Naragzah,Naragzar,Naragzig,Narakzah,Narakzar,Naramunz,Narazar,Nargabad,Nargabar,Nargatharb,Nargila,Nargundum,Nargundush,Nargunul,Narukthar,Narukthel,Nula,Nulbadush,Nulbaram,Nulbilnarg,Nulbunal,Nulbundab,Nulbundin,Nulbundum,Nulbuzah,Nuledzah,Nuledzig,Nulukkhaz,Nulukkhund,Nulukkhur,Sharakinb,Sharakzar,Sharamunz,Sharbarukth,Shatharbhizd,Shatharbiz,Shathazah,Shathizdush,Shathola,Shaziragz,Shizdinar,Shizdushund,Sholukkharb,Shundinulb,Shundushund,Shurakzund,Shuramunz,Tumunzadd,Tumunzan,Tumunzar,Tumunzinb,Tumunzir,Ukthad,Ulbirad,Ulbirar,Ulunzar,Ulur,Umunzad,Undalar,Undukkhil,Undun,Undur,Unduzur,Unzar,Unzathun,Usharar,Zaddinarg,Zaddushur,Zaharbad,Zaharbhizd,Zarakib,Zarakzar,Zaramunz,Zarukthel,Zinbarukth,Zirakinb,Zirakzir,Ziramunz,Ziruktharbh,Zirukthur,Zundumunz"}, - {name: "Goblin", i: 36, min: 4, max: 9, d: "eag", m: 0, b: "Asinx,Bhiagielt,Biokvish,Blix,Blus,Bratliaq,Breshass,Bridvelb,Brybsil,Bugbig,Buyagh,Cel,Chalk,Chiafzia,Chox,Cielb,Cosvil,Crekork,Crild,Croibieq,Diervaq,Dobruing,Driord,Eebligz,Een,Enissee,Esz,Far,Felhob,Froihiofz,Fruict,Fygsee,Gagablin,Gigganqi,Givzieqee,Glamzofs,Glernaahx,Gneabs,Gnoklig,Gobbledak,gobbok,Gobbrin,Heszai,Hiszils,Hobgar,Honk,Iahzaarm,Ialsirt,Ilm,Ish,Jasheafta,Joimtoilm,Kass,Katmelt,Kleabtong,Kleardeek,Klilm,Kluirm,Kuipuinx,Moft,Mogg,Nilbog,Oimzoishai,Onq,Ozbiard,Paas,Phax,Phigheldai,Preang,Prolkeh,Pyreazzi,Qeerags,Qosx,Rekx,Shaxi,Sios,Slehzit,Slofboif,Slukex,Srefs,Srurd,Stiaggaltia,Stiolx,Stioskurt,Stroir,Strytzakt,Stuikvact,Styrzangai,Suirx,Swaxi,Taxai,Thelt,Thresxea,Thult,Traglila,Treaq,Ulb,Ulm,Utha,Utiarm,Veekz,Vohniots,Vreagaald,Watvielx,Wrogdilk,Wruilt,Xurx,Ziggek,Zriokots"}, - {name: "Orc", i: 37, min: 4, max: 8, d: "gzrcu", m: 0, b: "Adgoz,Adgril-Gha,Adog,Adzurd,Agkadh,Agzil-Ghal,Akh,Ariz-Dru,Arkugzo,Arrordri,Ashnedh,Azrurdrekh,Bagzildre,Bashnud,Bedgez-Graz,Bhakh,Bhegh,Bhiccozdur,Bhicrur,Bhirgoshbel,Bhog,Bhurkrukh,Bod-Rugniz,Bogzel,Bozdra,Bozgrun,Bozziz,Bral-Lazogh,Brazadh,Brogved,Brogzozir,Brolzug,Brordegeg,Brorkril-Zrog,Brugroz,Brukh-Zrabrul,Brur-Korre,Bulbredh,Bulgragh,Chaz-Charard,Chegan-Khed,Chugga,Chuzar,Dhalgron-Mog,Dhazon-Ner,Dhezza,Dhoddud,Dhodh-Brerdrodh,Dhodh-Ghigin,Dhoggun-Bhogh,Dhulbazzol,Digzagkigh,Dirdrurd,Dodkakh,Dorgri,Drizdedh,Drobagh,Drodh-Ashnugh,Drogvukh-Drodh,Drukh-Qodgoz,Drurkuz,Dududh,Dur-Khaddol,Egmod,Ekh-Beccon,Ekh-Krerdrugh,Ekh-Mezred,Gagh-Druzred,Gazdrakh-Vrard,Gegnod,Gerkradh,Ghagrocroz,Ghared-Krin,Ghedgrolbrol,Gheggor,Ghizgil,Gho-Ugnud,Gholgard,Gidh-Ucceg,Goccogmurd,Golkon,Graz-Khulgag,Gribrabrokh,Gridkog,Grigh-Kaggaz,Grirkrun-Qur,Grughokh,Grurro,Gugh-Zozgrod,Gur-Ghogkagh,Ibagh-Chol,Ibruzzed,Ibul-Brad,Iggulzaz,Ikh-Ugnan,Irdrelzug,Irmekh-Bhor,Kacruz,Kalbrugh,Karkor-Zrid,Kazzuz-Zrar,Kezul-Bruz,Kharkiz,Khebun,Khorbric,Khuldrerra,Khuzdraz,Kirgol,Koggodh,Korkrir-Grar,Kraghird,Krar-Zurmurd,Krigh-Bhurdin,Kroddadh,Krudh-Khogzokh,Kudgroccukh,Kudrukh,Kudzal,Kuzgrurd-Dedh,Larud,Legvicrodh,Lorgran,Lugekh,Lulkore,Mazgar,Merkraz,Mocculdrer,Modh-Odod,Morbraz,Mubror,Muccug-Ghuz,Mughakh-Chil,Murmad,Nazad-Ludh,Negvidh,Nelzor-Zroz,Nirdrukh,Nogvolkar,Nubud,Nuccag,Nudh-Kuldra,Nuzecro,Oddigh-Krodh,Okh-Uggekh,Ordol,Orkudh-Bhur,Orrad,Qashnagh,Qiccad-Chal,Qiddolzog,Qidzodkakh,Qirzodh,Rarurd,Reradgri,Rezegh,Rezgrugh,Rodrekh,Rogh-Chirzaz,Rordrushnokh,Rozzez,Ruddirgrad,Rurguz-Vig,Ruzgrin,Ugh-Vruron,Ughudadh,Uldrukh-Bhudh,Ulgor,Ulkin,Ummugh-Ekh,Uzaggor,Uzdriboz,Uzdroz,Uzord,Uzron,Vaddog,Vagord-Khod,Velgrudh,Verrugh,Vrazin,Vrobrun,Vrugh-Nardrer,Vrurgu,Vuccidh,Vun-Gaghukh,Zacrad,Zalbrez,Zigmorbredh,Zordrordud,Zorrudh,Zradgukh,Zragmukh,Zragrizgrakh,Zraldrozzuz,Zrard-Krodog,Zrazzuz-Vaz,Zrigud,Zrulbukh-Dekh,Zubod-Ur,Zulbriz,Zun-Bergrord"}, - {name: "Giant", i: 38, min: 5, max: 10, d: "kdtng", m: 0, b: "Addund,Aerora,Agane,Anumush,Arangrim,Bahourg,Baragzund,Barakinb,Barakzig,Barakzinb,Baramunz,Barazinb,Beornelde,Beratira,Borgbert,Botharic,Bremrol,Brerstin,Brildung,Brozu,Bundushund,Burthug,Chazruc,Chergun,Churtec,Dagdhor,Dankuc,Darnaric,Debuch,Dina,Dinez,Diru,Drard,Druguk,Dugfast,Duhal,Dulkun,Eldond,Enuz,Eraddam,Eradhelm,Froththorn,Fynwyn,Gabaragz,Gabaram,Gabizir,Gabuzan,Gagkake,Galfald,Galgrim,Gatal,Gazin,Geru,Gila,Giledzir,Girkun,Glumvat,Gluthmark,Gomruch,Gorkege,Gortho,Gostuz,Grimor,Grimtira,Guddud,Gudgiz,Gulwo,Gunargath,Gundusharb,Guril,Gurkale,Guruge,Guzi,Hargarth,Hartreo,Heimfara,Hildlaug,Idgurth,Inez,Inginy,Iora,Irkin,Jaldhor,Jarwar,Jornangar,Jornmoth,Kakkek,Kaltoch,Kegkez,Kengord,Kharbharbiln,Khatharbar,Khathizdin,Khazanar,Khaziragz,Khizdabun,Khizdushel,Khundinarg,Kibarak,Kibizar,Kigine,Kilfond,Kilkan,Kinbadab,Kinbuzar,Koril,Kostand,Kuzake,Lindira,Lingarth,Maerdis,Magald,Marbold,Marbrand,Memron,Minu,Mistoch,Morluch,Mornkin,Morntaric,Nagu,Naragzah,Naramunz,Narazar,Nargabar,Nargatharb,Nargundush,Nargunul,Natan,Natil,Neliz,Nelkun,Noluch,Norginny,Nulbaram,Nulbilnarg,Nuledzah,Nuledzig,Nulukkhaz,Nulukkhur,Nurkel,Oci,Olane,Oldstin,Orga,Ranava,Ranhera,Rannerg,Rirkan,Rizen,Rurki,Rurkoc,Sadgach,Sgandrol,Sharakzar,Shatharbiz,Shathizdush,Shathola,Shizdinar,Sholukkharb,Shundushund,Shurakzund,Sidga,Sigbeorn,Sigbi,Solfod,Somrud,Srokvan,Stighere,Sulduch,Talkale,Theoddan,Theodgrim,Throtrek,Tigkiz,Tolkeg,Toren,Tozage,Tulkug,Tumunzar,Umunzad,Undukkhil,Usharar,Valdhere,Varkud,Velfirth,Velhera,Vigkan,Vorkige,Vozig,Vylwed,Widhyrde,Wylaeya,Yili,Yotane,Yudgor,Yulkake,Zigez,Zugkan,Zugke"}, - {name: "Draconic", i: 39, min: 6, max: 14, d: "aliuszrox", m: 0, b: "Aaronarra,Adalon,Adamarondor,Aeglyl,Aerosclughpalar,Aghazstamn,Aglaraerose,Agoshyrvor,Alduin,Alhazmabad,Altagos,Ammaratha,Amrennathed,Anaglathos,Andrathanach,Araemra,Araugauthos,Arauthator,Arharzel,Arngalor,Arveiaturace,Athauglas,Augaurath,Auntyrlothtor,Azarvilandral,Azhaq,Balagos,Baratathlaer,Bleucorundum,BrazzPolis,Canthraxis,Capnolithyl,Charvekkanathor,Chellewis,Chelnadatilar,Cirrothamalan,Claugiyliamatar,Cragnortherma,Dargentum,Dendeirmerdammarar,Dheubpurcwenpyl,Domborcojh,Draconobalen,Dragansalor,Dupretiskava,Durnehviir,Eacoathildarandus,Eldrisithain,Enixtryx,Eormennoth,Esmerandanna,Evenaelorathos,Faenphaele,Felgolos,Felrivenser,Firkraag,Fll'Yissetat,Furlinastis,Galadaeros,Galglentor,Garnetallisar,Garthammus,Gaulauntyr,Ghaulantatra,Glouroth,Greshrukk,Guyanothaz,Haerinvureem,Haklashara,Halagaster,Halaglathgar,Havarlan,Heltipyre,Hethcypressarvil,Hoondarrh,Icehauptannarthanyx,Iiurrendeem,Ileuthra,Iltharagh,Ingeloakastimizilian,Irdrithkryn,Ishenalyr,Iymrith,Jaerlethket,Jalanvaloss,Jharakkan,Kasidikal,Kastrandrethilian,Khavalanoth,Khuralosothantar,Kisonraathiisar,Kissethkashaan,Kistarianth,Klauth,Klithalrundrar,Krashos,Kreston,Kriionfanthicus,Krosulhah,Krustalanos,Kruziikrel,Kuldrak,Lareth,Latovenomer,Lhammaruntosz,Llimark,Ma'fel'no'sei'kedeh'naar,MaelestorRex,Magarovallanthanz,Mahatnartorian,Mahrlee,Malaeragoth,Malagarthaul,Malazan,Maldraedior,Maldrithor,MalekSalerno,Maughrysear,Mejas,Meliordianix,Merah,Mikkaalgensis,Mirmulnir,Mistinarperadnacles,Miteach,Mithbarazak,Morueme,Moruharzel,Naaslaarum,Nahagliiv,Nalavarauthatoryl,Naxorlytaalsxar,Nevalarich,Nolalothcaragascint,Nurvureem,Nymmurh,Odahviing,Olothontor,Ormalagos,Otaaryliakkarnos,Paarthurnax,Pelath,Pelendralaar,Praelorisstan,Praxasalandos,Protanther,Qiminstiir,Quelindritar,Ralionate,Rathalylaug,Rathguul,Rauglothgor,Raumorthadar,Relonikiv,Ringreemeralxoth,Roraurim,Rynnarvyx,Sablaxaahl,Sahloknir,Sahrotaar,Samdralyrion,Saryndalaghlothtor,Sawaka,Shalamalauth,Shammagar,Sharndrel,Shianax,Skarlthoon,Skurge,Smergadas,Ssalangan,Sssurist,Sussethilasis,Sylvallitham,Tamarand,Tantlevgithus,Tarlacoal,Tenaarlaktor,Thalagyrt,Tharas'kalagram,Thauglorimorgorus,Thoklastees,Thyka,Tsenshivah,Ueurwen,Uinnessivar,Urnalithorgathla,Velcuthimmorhar,Velora,Vendrathdammarar,Venomindhar,Viinturuth,Voaraghamanthar,Voslaarum,Vr'tark,Vrondahorevos,Vuljotnaak,Vulthuryol,Wastirek,Worlathaugh,Xargithorvar,Xavarathimius,Yemere,Ylithargathril,Ylveraasahlisar,Za-Jikku,Zarlandris,Zellenesterex,Zilanthar,Zormapalearath,Zundaerazylym,Zz'Pzora"}, - {name: "Arachnid", i: 40, min: 4, max: 10, d: "erlsk", m: 0, b: "Aaqok'ser,Aiced,Aizachis,Allinqel,As'taq,Ashrash,Caaqtos,Ceek'sax,Ceezuq,Cek'sier,Cen'qi,Ceqzocer,Cezeed,Chachocaq,Charis,Chashilieth,Checib,Chernul,Chezi,Chiazu,Chishros,Chixhi,Chizhi,Chollash,Choq'sha,Cinchichail,Collul,Ecush'taid,Ekiqe,Eqas,Er'uria,Erikas,Es'tase,Esrub,Exha,Haqsho,Hiavheesh,Hitha,Hok'thi,Hossa,Iacid,Iciever,Illuq,Isnir,Keezut,Kheellavas,Kheizoh,Khiachod,Khika,Khirzur,Khonrud,Khrakku,Khraqshis,Khrethish'ti,Khriashus,Khrika,Khrirni,Klashirel,Kleil'sha,Klishuth,Krarnit,Kras'tex,Krotieqas,Lais'tid,Laizuh,Lasnoth,Len'qeer,Leqanches,Lezad,Lhilir,Lhivhath,Lhok'thu,Lialliesed,Liaraq,Liceva,Lichorro,Lilla,Lokieqib,Nakur,Neerhaca,Neet'er,Neezoh,Nenchiled,Nerhalneth,Nir'ih,Nizus,Noreeqo,On'qix,Qalitho,Qas'tor,Qasol,Qavrud,Qavud,Qazar,Qazru,Qekno,Qeqravee,Qes'tor,Qhaik'sal,Qhak'sish,Qhazsakais,Qheliva,Qhenchaqes,Qherazal,Qhon'qos,Qhosh,Qish'tur,Qisih,Qorhoci,Qranchiq,Racith,Rak'zes,Ranchis,Rarhie,Rarzi,Rarzisiaq,Ras'tih,Ravosho,Recad,Rekid,Rernee,Rertachis,Rezhokketh,Reziel,Rhacish,Rhail'shel,Rhairhizse,Rhakivex,Rhaqeer,Rhartix,Rheciezsei,Rheevid,Rhel'shir,Rhevhie,Rhiavekot,Rhikkos,Rhiqese,Rhiqi,Rhiqracar,Rhisned,Rhousnateb,Riakeesnex,Rintachal,Rir'ul,Rourk'u,Rouzakri,Sailiqei,Sanchiqed,Saqshu,Sat'ier,Sazi,Seiqas,Shieth'i,Shiqsheh,Shizha,Shrachuvo,Shranqo,Shravhos,Shravuth,Shreerhod,Shrethuh,Shriantieth,Shronqash,Shrovarhir,Shrozih,Siacaqoh,Siezosh,Siq'sha,Sirro,Sornosi,Srachussi,Szaca,Szacih,Szaqova,Szasu,Szazhilos,Szeerrud,Szeezsad,Szeknur,Szesir,Szezhirros,Szilshith,Szon'qol,Szornuq,Xeekke,Yeek'su,Yeeq'zox,Yeqil,Yeqroq,Yeveed,Yevied,Yicaveeh,Yirresh,Yisie,Yithik'thaih,Yorhaqshes,Zacheek'sa,Zakkasa,Zelraq,Zeqo,Zharuncho,Zhath'arhish,Zhavirrit,Zhazilraq,Zhazsachiel,Zhek'tha,Zhequ,Zhias'ted,Zhicat,Zhicur,Zhirhacil,Zhizri,Zhochizses,Ziarih,Zirnib"}, - {name: "Serpents", i: 41, min: 5, max: 11, d: "slrk", m: 0, b: "Aj'ha,Aj'i,Aj'tiss,Ajakess,Aksas,Aksiss,Al'en,An'jeshe,Apjige,Arkkess,Athaz,Atus,Azras,Caji,Cakrasar,Cal'arrun,Capji,Cathras,Cej'han,Ces,Cez'jenta,Cij'te,Cinash,Cizran,Coth'jus,Cothrash,Culzanek,Cunaless,Ej'tesh,Elzazash,Ergek,Eshjuk,Ethris,Gan'jas,Gapja,Gar'thituph,Gopjeguss,Gor'thesh,Gragishaph,Grar'theness,Grath'ji,Gressinas,Grolzesh,Grorjar,Grozrash,Guj'ika,Harji,Hej'hez,Herkush,Horgarrez,Illuph,Ipjar,Ithashin,Kaj'ess,Kar'kash,Kepjusha,Ki'kintus,Kissere,Koph,Kopjess,Kra'kasher,Krak,Krapjez,Krashjuless,Kraz'ji,Krirrigis,Krussin,Ma'lush,Mage,Maj'tak,Mal'a,Mapja,Mar'kash,Mar'kis,Marjin,Mas,Mathan,Men'jas,Meth'jaresh,Mij'hegak,Min'jash,Mith'jas,Monassu,Moss,Naj'hass,Najugash,Nak,Napjiph,Nar'ka,Nar'thuss,Narrusha,Nash,Nashjekez,Nataph,Nij'ass,Nij'tessiph,Nishjiss,Norkkuss,Nus,Olluruss,Or'thi,Or'thuss,Paj'a,Parkka,Pas,Pathujen,Paz'jaz,Pepjerras,Pirkkanar,Pituk,Porjunek,Pu'ke,Ragen,Ran'jess,Rargush,Razjuph,Rilzan,Riss,Rithruz,Rorgiss,Rossez,Rraj'asesh,Rraj'tass,Rrar'kess,Rrar'thuph,Rras,Rrazresh,Rrej'hish,Rrigelash,Rris,Rris,Rroksurrush,Rukrussush,Rurri,Russa,Ruth'jes,Sa'kitesh,Sar'thass,Sarjas,Sazjuzush,Ser'thez,Sezrass,Shajas,Shas,Shashja,Shass,Shetesh,Shijek,Shun'jaler,Shurjarri,Skaler,Skalla,Skallentas,Skaph,Skar'kerriz,Skath'jeruk,Sker'kalas,Skor,Skoz'ji,Sku'lu,Skuph,Skur'thur,Slalli,Slalt'har,Slelziress,Slil'ar,Sloz'jisa,Sojesh,Solle,Sorge,Sral'e,Sran'ji,Srapjess,Srar'thazur,Srash,Srath'jess,Srathrarre,Srerkkash,Srus,Sruss'tugeph,Sun,Suss'tir,Uzrash,Vargush,Vek,Vess'tu,Viph,Vult'ha,Vupjer,Vushjesash,Xagez,Xassa,Xulzessu,Zaj'tiss,Zan'jer,Zarriss,Zassegus,Zirres,Zsor,Zurjass"}, - // additional by Avengium: - {name: "Levantine", i: 42, min: 4, max: 12, d: "ankprs", m: 0, b: "Adme,Adramet,Agadir,Akko,Akzib,Alimas,Alis-Ubbo,Alqosh,Amid,Ammon,Ampi,Amurru,Andarig,Anpa,Araden,Aram,Arwad,Ashkelon,Athar,Atiq,Aza,Azeka,Baalbek,Babel,Batrun,Beerot,Beersheba,Beit Shemesh,Berytus,Bet Agus,Bet Anya,Beth-Horon,Bethel,Bethlehem,Bethuel,Bet Nahrin,Bet Nohadra,Bet Zalin,Birmula,Biruta,Bit Agushi,Bitan,Bit Zamani,Cerne,Dammeseq,Darmsuq,Dor,Eddial,Eden Ekron,Elah,Emek,Emun,Ephratah,Eyn Ganim,Finike,Gades,Galatia,Gaza,Gebal,Gedera,Gerizzim,Gethsemane,Gibeon,Gilead,Gilgal,Golgotha,Goshen,Gytte,Hagalil,Haifa,Halab,Haqel Dma,Har Habayit,Har Nevo,Har Pisga,Havilah,Hazor,Hebron,Hormah,Iboshim,Iriho,Irinem,Irridu,Israel,Kadesh,Kanaan,Kapara,Karaly,Kart-Hadasht,Keret Chadeshet,Kernah,Kesed,Keysariya,Kfar,Kfar Nahum,Khalibon,Khalpe,Khamat,Kiryat,Kittim,Kurda,Lapethos,Larna,Lepqis,Lepriptza,Liksos,Lod,Luv,Malaka,Malet,Marat,Megido,Melitta,Merdin,Metsada,Mishmarot,Mitzrayim,Moab,Mopsos,Motye,Mukish,Nampigi,Nampigu,Natzrat,Nimrud,Nineveh,Nob,Nuhadra,Oea,Ofir,Oyat,Phineka,Phoenicus,Pleshet,Qart-Tubah Sarepta,Qatna,Rabat Amon,Rakkath,Ramat Aviv,Ramitha,Ramta,Rehovot,Reshef,Rushadir,Rushakad,Samrin,Sefarad,Sehyon,Sepat,Sexi,Sharon,Shechem,Shefelat,Shfanim,Shiloh,Shmaya,Shomron,Sidon,Sinay,Sis,Solki,Sur,Suria,Tabetu,Tadmur,Tarshish,Tartus,Teberya,Tefessedt,Tekoa,Teyman,Tinga,Tipasa,Tsabratan,Tur Abdin,Tzarfat,Tziyon,Tzor,Ugarit,Unubaal,Ureshlem,Urhay,Urushalim,Vaga,Yaffa,Yamhad,Yam hamelach,Yam Kineret,Yamutbal,Yathrib,Yaudi,Yavne,Yehuda,Yerushalayim,Yev,Yevus,Yizreel,Yurdnan,Zarefat,Zeboim,Zeurta,Zeytim,Zikhron,Zmurna"} - ]; - }; - - return { - getBase, - getCulture, - getCultureShort, - getBaseShort, - getState, - updateChain, - clearChains, - getNameBases, - getMapName, - calculateChain - }; -})(); diff --git a/public/modules/ocean-layers.js b/public/modules/ocean-layers.js deleted file mode 100644 index 281fad0a..00000000 --- a/public/modules/ocean-layers.js +++ /dev/null @@ -1,92 +0,0 @@ -"use strict"; - -window.OceanLayers = (function () { - let cells, vertices, pointsN, used; - - const OceanLayers = function OceanLayers() { - const outline = oceanLayers.attr("layers"); - if (outline === "none") return; - TIME && console.time("drawOceanLayers"); - - lineGen.curve(d3.curveBasisClosed); - (cells = grid.cells), (pointsN = grid.cells.i.length), (vertices = grid.vertices); - const limits = outline === "random" ? randomizeOutline() : outline.split(",").map(s => +s); - - const chains = []; - const opacity = rn(0.4 / limits.length, 2); - used = new Uint8Array(pointsN); // to detect already passed cells - - for (const i of cells.i) { - const t = cells.t[i]; - if (t > 0) continue; - if (used[i] || !limits.includes(t)) continue; - const start = findStart(i, t); - if (!start) continue; - used[i] = 1; - const chain = connectVertices(start, t); // vertices chain to form a path - if (chain.length < 4) continue; - const relax = 1 + t * -2; // select only n-th point - const relaxed = chain.filter((v, i) => !(i % relax) || vertices.c[v].some(c => c >= pointsN)); - if (relaxed.length < 4) continue; - const points = clipPoly( - relaxed.map(v => vertices.p[v]), - 1 - ); - chains.push([t, points]); - } - - for (const t of limits) { - const layer = chains.filter(c => c[0] === t); - let path = layer.map(c => round(lineGen(c[1]))).join(""); - if (path) oceanLayers.append("path").attr("d", path).attr("fill", "#ecf2f9").attr("fill-opacity", opacity); - } - - // find eligible cell vertex to start path detection - function findStart(i, t) { - if (cells.b[i]) return cells.v[i].find(v => vertices.c[v].some(c => c >= pointsN)); // map border cell - return cells.v[i][cells.c[i].findIndex(c => cells.t[c] < t || !cells.t[c])]; - } - - TIME && console.timeEnd("drawOceanLayers"); - }; - - function randomizeOutline() { - const limits = []; - let odd = 0.2; - for (let l = -9; l < 0; l++) { - if (P(odd)) { - odd = 0.2; - limits.push(l); - } else { - odd *= 2; - } - } - return limits; - } - - // connect vertices to chain - function connectVertices(start, t) { - const chain = []; // vertices chain to form a path - for (let i = 0, current = start; i === 0 || (current !== start && i < 10000); i++) { - const prev = chain[chain.length - 1]; // previous vertex in chain - chain.push(current); // add current vertex to sequence - const c = vertices.c[current]; // cells adjacent to vertex - c.filter(c => cells.t[c] === t).forEach(c => (used[c] = 1)); - const v = vertices.v[current]; // neighboring vertices - const c0 = !cells.t[c[0]] || cells.t[c[0]] === t - 1; - const c1 = !cells.t[c[1]] || cells.t[c[1]] === t - 1; - const c2 = !cells.t[c[2]] || cells.t[c[2]] === t - 1; - if (v[0] !== undefined && v[0] !== prev && c0 !== c1) current = v[0]; - else if (v[1] !== undefined && v[1] !== prev && c1 !== c2) current = v[1]; - else if (v[2] !== undefined && v[2] !== prev && c0 !== c2) current = v[2]; - if (current === chain[chain.length - 1]) { - ERROR && console.error("Next vertex is not found"); - break; - } - } - chain.push(chain[0]); // push first vertex as the last one - return chain; - } - - return OceanLayers; -})(); diff --git a/public/modules/provinces-generator.js b/public/modules/provinces-generator.js deleted file mode 100644 index 3276fdf0..00000000 --- a/public/modules/provinces-generator.js +++ /dev/null @@ -1,257 +0,0 @@ -"use strict"; - -window.Provinces = (function () { - const forms = { - Monarchy: {County: 22, Earldom: 6, Shire: 2, Landgrave: 2, Margrave: 2, Barony: 2, Captaincy: 1, Seneschalty: 1}, - Republic: {Province: 6, Department: 2, Governorate: 2, District: 1, Canton: 1, Prefecture: 1}, - Theocracy: {Parish: 3, Deanery: 1}, - Union: {Province: 1, State: 1, Canton: 1, Republic: 1, County: 1, Council: 1}, - Anarchy: {Council: 1, Commune: 1, Community: 1, Tribe: 1}, - Wild: {Territory: 10, Land: 5, Region: 2, Tribe: 1, Clan: 1, Dependency: 1, Area: 1} - }; - - const generate = (regenerate = false, regenerateLockedStates = false) => { - TIME && console.time("generateProvinces"); - const localSeed = regenerate ? generateSeed() : seed; - Math.random = aleaPRNG(localSeed); - - const {cells, states, burgs} = pack; - const provinces = [0]; // 0 index is reserved for "no province" - const provinceIds = new Uint16Array(cells.i.length); - - const isProvinceLocked = province => province.lock || (!regenerateLockedStates && states[province.state]?.lock); - const isProvinceCellLocked = cell => provinceIds[cell] && isProvinceLocked(provinces[provinceIds[cell]]); - - if (regenerate) { - pack.provinces.forEach(province => { - if (!province.i || province.removed || !isProvinceLocked(province)) return; - - const newId = provinces.length; - for (const i of cells.i) { - if (cells.province[i] === province.i) provinceIds[i] = newId; - } - - province.i = newId; - provinces.push(province); - }); - } - - const provincesRatio = +byId("provincesRatio").value; - const max = provincesRatio == 100 ? 1000 : gauss(20, 5, 5, 100) * provincesRatio ** 0.5; // max growth - - // generate provinces for selected burgs - states.forEach(s => { - s.provinces = []; - if (!s.i || s.removed) return; - if (provinces.length) s.provinces = provinces.filter(p => p.state === s.i).map(p => p.i); // locked provinces ids - if (s.lock && !regenerateLockedStates) return; // don't regenerate provinces of a locked state - - const stateBurgs = burgs - .filter(b => b.state === s.i && !b.removed && !provinceIds[b.cell]) - .sort((a, b) => b.population * gauss(1, 0.2, 0.5, 1.5, 3) - a.population) - .sort((a, b) => b.capital - a.capital); - if (stateBurgs.length < 2) return; // at least 2 provinces are required - - const provincesNumber = Math.max(Math.ceil((stateBurgs.length * provincesRatio) / 100), 2); - const form = Object.assign({}, forms[s.form]); - - for (let i = 0; i < provincesNumber; i++) { - const provinceId = provinces.length; - const center = stateBurgs[i].cell; - const burg = stateBurgs[i]; - const c = stateBurgs[i].culture; - const nameByBurg = P(0.5); - const name = nameByBurg ? stateBurgs[i].name : Names.getState(Names.getCultureShort(c), c); - const formName = rw(form); - form[formName] += 10; - const fullName = name + " " + formName; - const color = getMixedColor(s.color); - const kinship = nameByBurg ? 0.8 : 0.4; - const type = Burgs.getType(center, burg.port); - const coa = COA.generate(stateBurgs[i].coa, kinship, null, type); - coa.shield = COA.getShield(c, s.i); - - s.provinces.push(provinceId); - provinces.push({i: provinceId, state: s.i, center, burg: burg.i, name, formName, fullName, color, coa}); - } - }); - - // expand generated provinces - const queue = new FlatQueue(); - const cost = []; - - provinces.forEach(p => { - if (!p.i || p.removed || isProvinceLocked(p)) return; - provinceIds[p.center] = p.i; - queue.push({e: p.center, province: p.i, state: p.state, p: 0}, 0); - cost[p.center] = 1; - }); - - while (queue.length) { - const {e, p, province, state} = queue.pop(); - - cells.c[e].forEach(e => { - if (isProvinceCellLocked(e)) return; // do not overwrite cell of locked provinces - - const land = cells.h[e] >= 20; - if (!land && !cells.t[e]) return; // cannot pass deep ocean - if (land && cells.state[e] !== state) return; - const evevation = cells.h[e] >= 70 ? 100 : cells.h[e] >= 50 ? 30 : cells.h[e] >= 20 ? 10 : 100; - const totalCost = p + evevation; - - if (totalCost > max) return; - if (!cost[e] || totalCost < cost[e]) { - if (land) provinceIds[e] = province; // assign province to a cell - cost[e] = totalCost; - queue.push({e, province, state, p: totalCost}, totalCost); - } - }); - } - - // justify provinces shapes a bit - for (const i of cells.i) { - if (cells.burg[i]) continue; // do not overwrite burgs - if (isProvinceCellLocked(i)) continue; // do not overwrite cell of locked provinces - - const neibs = cells.c[i] - .filter(c => cells.state[c] === cells.state[i] && !isProvinceCellLocked(c)) - .map(c => provinceIds[c]); - const adversaries = neibs.filter(c => c !== provinceIds[i]); - if (adversaries.length < 2) continue; - - const buddies = neibs.filter(c => c === provinceIds[i]).length; - if (buddies.length > 2) continue; - - const competitors = adversaries.map(p => adversaries.reduce((s, v) => (v === p ? s + 1 : s), 0)); - const max = d3.max(competitors); - if (buddies >= max) continue; - - provinceIds[i] = adversaries[competitors.indexOf(max)]; - } - - // add "wild" provinces if some cells don't have a province assigned - const noProvince = Array.from(cells.i).filter(i => cells.state[i] && !provinceIds[i]); // cells without province assigned - states.forEach(s => { - if (!s.i || s.removed) return; - if (s.lock && !regenerateLockedStates) return; - if (!s.provinces.length) return; - - const coreProvinceNames = s.provinces.map(p => provinces[p]?.name); - const colonyNamePool = [s.name, ...coreProvinceNames].filter(name => name && !/new/i.test(name)); - const getColonyName = () => { - if (colonyNamePool.length < 1) return null; - - const index = rand(colonyNamePool.length - 1); - const spliced = colonyNamePool.splice(index, 1); - return spliced[0] ? `New ${spliced[0]}` : null; - }; - - let stateNoProvince = noProvince.filter(i => cells.state[i] === s.i && !provinceIds[i]); - while (stateNoProvince.length) { - // add new province - const provinceId = provinces.length; - const burgCell = stateNoProvince.find(i => cells.burg[i]); - const center = burgCell ? burgCell : stateNoProvince[0]; - const burg = burgCell ? cells.burg[burgCell] : 0; - provinceIds[center] = provinceId; - - // expand province - const cost = []; - cost[center] = 1; - queue.push({e: center, p: 0}, 0); - while (queue.length) { - const {e, p} = queue.pop(); - - cells.c[e].forEach(nextCellId => { - if (provinceIds[nextCellId]) return; - const land = cells.h[nextCellId] >= 20; - if (cells.state[nextCellId] && cells.state[nextCellId] !== s.i) return; - const ter = land ? (cells.state[nextCellId] === s.i ? 3 : 20) : cells.t[nextCellId] ? 10 : 30; - const totalCost = p + ter; - - if (totalCost > max) return; - if (!cost[nextCellId] || totalCost < cost[nextCellId]) { - if (land && cells.state[nextCellId] === s.i) provinceIds[nextCellId] = provinceId; // assign province to a cell - cost[nextCellId] = totalCost; - queue.push({e: nextCellId, p: totalCost}, totalCost); - } - }); - } - - // generate "wild" province name - const c = cells.culture[center]; - const f = pack.features[cells.f[center]]; - const color = getMixedColor(s.color); - - const provCells = stateNoProvince.filter(i => provinceIds[i] === provinceId); - const singleIsle = provCells.length === f.cells && !provCells.find(i => cells.f[i] !== f.i); - const isleGroup = !singleIsle && !provCells.find(i => pack.features[cells.f[i]].group !== "isle"); - const colony = !singleIsle && !isleGroup && P(0.5) && !isPassable(s.center, center); - - const name = (() => { - const colonyName = colony && P(0.8) && getColonyName(); - if (colonyName) return colonyName; - if (burgCell && P(0.5)) return burgs[burg].name; - return Names.getState(Names.getCultureShort(c), c); - })(); - - const formName = (() => { - if (singleIsle) return "Island"; - if (isleGroup) return "Islands"; - if (colony) return "Colony"; - return rw(forms["Wild"]); - })(); - - const fullName = name + " " + formName; - - const dominion = colony ? P(0.95) : singleIsle || isleGroup ? P(0.7) : P(0.3); - const kinship = dominion ? 0 : 0.4; - const type = Burgs.getType(center, burgs[burg]?.port); - const coa = COA.generate(s.coa, kinship, dominion, type); - coa.shield = COA.getShield(c, s.i); - - provinces.push({i: provinceId, state: s.i, center, burg, name, formName, fullName, color, coa}); - s.provinces.push(provinceId); - - // check if there is a land way within the same state between two cells - function isPassable(from, to) { - if (cells.f[from] !== cells.f[to]) return false; // on different islands - const passableQueue = [from], - used = new Uint8Array(cells.i.length), - state = cells.state[from]; - while (passableQueue.length) { - const current = passableQueue.pop(); - if (current === to) return true; // way is found - cells.c[current].forEach(c => { - if (used[c] || cells.h[c] < 20 || cells.state[c] !== state) return; - passableQueue.push(c); - used[c] = 1; - }); - } - return false; // way is not found - } - - // re-check - stateNoProvince = noProvince.filter(i => cells.state[i] === s.i && !provinceIds[i]); - } - }); - - cells.province = provinceIds; - pack.provinces = provinces; - - TIME && console.timeEnd("generateProvinces"); - }; - - // calculate pole of inaccessibility for each province - const getPoles = () => { - const getType = cellId => pack.cells.province[cellId]; - const poles = getPolesOfInaccessibility(pack, getType); - - pack.provinces.forEach(province => { - if (!province.i || province.removed) return; - province.pole = poles[province.i] || [0, 0]; - }); - }; - - return {generate, getPoles}; -})(); diff --git a/public/modules/religions-generator.js b/public/modules/religions-generator.js deleted file mode 100644 index 527a187c..00000000 --- a/public/modules/religions-generator.js +++ /dev/null @@ -1,921 +0,0 @@ -"use strict"; - -window.Religions = (function () { - // name generation approach and relative chance to be selected - const approach = { - Number: 1, - Being: 3, - Adjective: 5, - "Color + Animal": 5, - "Adjective + Animal": 5, - "Adjective + Being": 5, - "Adjective + Genitive": 1, - "Color + Being": 3, - "Color + Genitive": 3, - "Being + of + Genitive": 2, - "Being + of the + Genitive": 1, - "Animal + of + Genitive": 1, - "Adjective + Being + of + Genitive": 2, - "Adjective + Animal + of + Genitive": 2 - }; - - // turn weighted array into simple array - const approaches = []; - for (const a in approach) { - for (let j = 0; j < approach[a]; j++) { - approaches.push(a); - } - } - - const base = { - number: ["One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Eleven", "Twelve"], - being: [ - "Ancestor", - "Ancient", - "Avatar", - "Brother", - "Champion", - "Chief", - "Council", - "Creator", - "Deity", - "Divine One", - "Elder", - "Enlightened Being", - "Father", - "Forebear", - "Forefather", - "Giver", - "God", - "Goddess", - "Guardian", - "Guide", - "Hierach", - "Lady", - "Lord", - "Maker", - "Master", - "Mother", - "Numen", - "Oracle", - "Overlord", - "Protector", - "Reaper", - "Ruler", - "Sage", - "Seer", - "Sister", - "Spirit", - "Supreme Being", - "Transcendent", - "Virgin" - ], - animal: [ - "Antelope", - "Ape", - "Badger", - "Basilisk", - "Bear", - "Beaver", - "Bison", - "Boar", - "Buffalo", - "Camel", - "Cat", - "Centaur", - "Cerberus", - "Chimera", - "Cobra", - "Cockatrice", - "Crane", - "Crocodile", - "Crow", - "Cyclope", - "Deer", - "Dog", - "Direwolf", - "Drake", - "Dragon", - "Eagle", - "Elephant", - "Elk", - "Falcon", - "Fox", - "Goat", - "Goose", - "Gorgon", - "Gryphon", - "Hare", - "Hawk", - "Heron", - "Hippogriff", - "Horse", - "Hound", - "Hyena", - "Ibis", - "Jackal", - "Jaguar", - "Kitsune", - "Kraken", - "Lark", - "Leopard", - "Lion", - "Manticore", - "Mantis", - "Marten", - "Minotaur", - "Moose", - "Mule", - "Narwhal", - "Owl", - "Ox", - "Panther", - "Pegasus", - "Phoenix", - "Python", - "Rat", - "Raven", - "Roc", - "Rook", - "Scorpion", - "Serpent", - "Shark", - "Sheep", - "Snake", - "Sphinx", - "Spider", - "Swan", - "Tiger", - "Turtle", - "Unicorn", - "Viper", - "Vulture", - "Walrus", - "Wolf", - "Wolverine", - "Worm", - "Wyvern", - "Yeti" - ], - adjective: [ - "Aggressive", - "Almighty", - "Ancient", - "Beautiful", - "Benevolent", - "Big", - "Blind", - "Blond", - "Bloody", - "Brave", - "Broken", - "Brutal", - "Burning", - "Calm", - "Celestial", - "Cheerful", - "Crazy", - "Cruel", - "Dead", - "Deadly", - "Devastating", - "Distant", - "Disturbing", - "Divine", - "Dying", - "Eternal", - "Ethernal", - "Empyreal", - "Enigmatic", - "Enlightened", - "Evil", - "Explicit", - "Fair", - "Far", - "Fat", - "Fatal", - "Favorable", - "Flying", - "Friendly", - "Frozen", - "Giant", - "Good", - "Grateful", - "Great", - "Happy", - "High", - "Holy", - "Honest", - "Huge", - "Hungry", - "Illustrious", - "Immutable", - "Ineffable", - "Infallible", - "Inherent", - "Last", - "Latter", - "Lost", - "Loud", - "Lucky", - "Mad", - "Magical", - "Main", - "Major", - "Marine", - "Mythical", - "Mystical", - "Naval", - "New", - "Noble", - "Old", - "Otherworldly", - "Patient", - "Peaceful", - "Pregnant", - "Prime", - "Proud", - "Pure", - "Radiant", - "Resplendent", - "Sacred", - "Sacrosanct", - "Sad", - "Scary", - "Secret", - "Selected", - "Serene", - "Severe", - "Silent", - "Sleeping", - "Slumbering", - "Sovereign", - "Strong", - "Sunny", - "Superior", - "Supernatural", - "Sustainable", - "Transcendent", - "Transcendental", - "Troubled", - "Unearthly", - "Unfathomable", - "Unhappy", - "Unknown", - "Unseen", - "Waking", - "Wild", - "Wise", - "Worried", - "Young" - ], - genitive: [ - "Cold", - "Day", - "Death", - "Doom", - "Fate", - "Fire", - "Fog", - "Frost", - "Gates", - "Heaven", - "Home", - "Ice", - "Justice", - "Life", - "Light", - "Lightning", - "Love", - "Nature", - "Night", - "Pain", - "Snow", - "Springs", - "Summer", - "Thunder", - "Time", - "Victory", - "War", - "Winter" - ], - theGenitive: [ - "Abyss", - "Blood", - "Dawn", - "Earth", - "East", - "Eclipse", - "Fall", - "Harvest", - "Moon", - "North", - "Peak", - "Rainbow", - "Sea", - "Sky", - "South", - "Stars", - "Storm", - "Sun", - "Tree", - "Underworld", - "West", - "Wild", - "Word", - "World" - ], - color: [ - "Amber", - "Black", - "Blue", - "Bright", - "Bronze", - "Brown", - "Coral", - "Crimson", - "Dark", - "Emerald", - "Golden", - "Green", - "Grey", - "Indigo", - "Lavender", - "Light", - "Magenta", - "Maroon", - "Orange", - "Pink", - "Plum", - "Purple", - "Red", - "Ruby", - "Sapphire", - "Teal", - "Turquoise", - "White", - "Yellow" - ] - }; - - const forms = { - Folk: { - Shamanism: 4, - Animism: 4, - Polytheism: 4, - "Ancestor Worship": 2, - "Nature Worship": 1, - Totemism: 1 - }, - Organized: { - Polytheism: 7, - Monotheism: 7, - Dualism: 3, - Pantheism: 2, - "Non-theism": 2 - }, - Cult: { - Cult: 5, - "Dark Cult": 5, - Sect: 1 - }, - Heresy: { - Heresy: 1 - } - }; - - const namingMethods = { - Folk: { - "Culture + type": 1 - }, - - Organized: { - "Random + type": 3, - "Random + ism": 1, - "Supreme + ism": 5, - "Faith of + Supreme": 5, - "Place + ism": 1, - "Culture + ism": 2, - "Place + ian + type": 6, - "Culture + type": 4 - }, - - Cult: { - "Burg + ian + type": 2, - "Random + ian + type": 1, - "Type + of the + meaning": 2 - }, - - Heresy: { - "Burg + ian + type": 3, - "Random + ism": 3, - "Random + ian + type": 2, - "Type + of the + meaning": 1 - } - }; - - const types = { - Shamanism: {Beliefs: 3, Shamanism: 2, Druidism: 1, Spirits: 1}, - Animism: {Spirits: 3, Beliefs: 1}, - Polytheism: {Deities: 3, Faith: 1, Gods: 1, Pantheon: 1}, - "Ancestor Worship": {Beliefs: 1, Forefathers: 2, Ancestors: 2}, - "Nature Worship": {Beliefs: 3, Druids: 1}, - Totemism: {Beliefs: 2, Totems: 2, Idols: 1}, - - Monotheism: {Religion: 2, Church: 3, Faith: 1}, - Dualism: {Religion: 3, Faith: 1, Cult: 1}, - Pantheism: {Religion: 1, Faith: 1}, - "Non-theism": {Beliefs: 3, Spirits: 1}, - - Cult: {Cult: 4, Sect: 2, Arcanum: 1, Order: 1, Worship: 1}, - "Dark Cult": {Cult: 2, Blasphemy: 1, Circle: 1, Coven: 1, Idols: 1, Occultism: 1}, - Sect: {Sect: 3, Society: 1}, - - Heresy: { - Heresy: 3, - Sect: 2, - Apostates: 1, - Brotherhood: 1, - Circle: 1, - Dissent: 1, - Dissenters: 1, - Iconoclasm: 1, - Schism: 1, - Society: 1 - } - }; - - const expansionismMap = { - Folk: () => 0, - Organized: () => gauss(5, 3, 0, 10, 1), - Cult: () => gauss(0.5, 0.5, 0, 5, 1), - Heresy: () => gauss(1, 0.5, 0, 5, 1) - }; - - function generate() { - TIME && console.time("generateReligions"); - const lockedReligions = pack.religions?.filter(r => r.i && r.lock && !r.removed) || []; - - const folkReligions = generateFolkReligions(); - const organizedReligions = generateOrganizedReligions(+religionsNumber.value, lockedReligions); - - const namedReligions = specifyReligions([...folkReligions, ...organizedReligions]); - const indexedReligions = combineReligions(namedReligions, lockedReligions); - const religionIds = expandReligions(indexedReligions); - const religions = defineOrigins(religionIds, indexedReligions); - - pack.religions = religions; - pack.cells.religion = religionIds; - - checkCenters(); - - TIME && console.timeEnd("generateReligions"); - } - - function generateFolkReligions() { - return pack.cultures - .filter(c => c.i && !c.removed) - .map(culture => ({type: "Folk", form: rw(forms.Folk), culture: culture.i, center: culture.center})); - } - - function generateOrganizedReligions(desiredReligionNumber, lockedReligions) { - const cells = pack.cells; - const lockedReligionCount = lockedReligions.filter(({type}) => type !== "Folk").length || 0; - const requiredReligionsNumber = desiredReligionNumber - lockedReligionCount; - if (requiredReligionsNumber < 1) return []; - - const candidateCells = getCandidateCells(); - const religionCores = placeReligions(); - - const cultsCount = Math.floor((rand(1, 4) / 10) * religionCores.length); // 10-40% - const heresiesCount = Math.floor((rand(0, 3) / 10) * religionCores.length); // 0-30% - const organizedCount = religionCores.length - cultsCount - heresiesCount; - - const getType = index => { - if (index < organizedCount) return "Organized"; - if (index < organizedCount + cultsCount) return "Cult"; - return "Heresy"; - }; - - return religionCores.map((cellId, index) => { - const type = getType(index); - const form = rw(forms[type]); - const cultureId = cells.culture[cellId]; - - return {type, form, culture: cultureId, center: cellId}; - }); - - function placeReligions() { - const religionCells = []; - const religionsTree = d3.quadtree(); - - // pre-populate with locked centers - lockedReligions.forEach(({center}) => religionsTree.add(cells.p[center])); - - // min distance between religion inceptions - const spacing = (graphWidth + graphHeight) / 2 / desiredReligionNumber; - - for (const cellId of candidateCells) { - const [x, y] = cells.p[cellId]; - - if (religionsTree.find(x, y, spacing) === undefined) { - religionCells.push(cellId); - religionsTree.add([x, y]); - - if (religionCells.length === requiredReligionsNumber) return religionCells; - } - } - - WARN && console.warn(`Placed only ${religionCells.length} of ${requiredReligionsNumber} religions`); - return religionCells; - } - - function getCandidateCells() { - const validBurgs = pack.burgs.filter(b => b.i && !b.removed); - - if (validBurgs.length >= requiredReligionsNumber) - return validBurgs.sort((a, b) => b.population - a.population).map(burg => burg.cell); - return cells.i.filter(i => cells.s[i] > 2).sort((a, b) => cells.s[b] - cells.s[a]); - } - } - - function specifyReligions(newReligions) { - const {cells, cultures} = pack; - - const rawReligions = newReligions.map(({type, form, culture: cultureId, center}) => { - const supreme = getDeityName(cultureId); - const deity = form === "Non-theism" || form === "Animism" ? null : supreme; - - const stateId = cells.state[center]; - - let [name, expansion] = generateReligionName(type, form, supreme, center); - if (expansion === "state" && !stateId) expansion = "global"; - - const expansionism = expansionismMap[type](); - const color = getReligionColor(cultures[cultureId], type); - - return {name, type, form, culture: cultureId, center, deity, expansion, expansionism, color}; - }); - - return rawReligions; - - function getReligionColor(culture, type) { - if (!culture.i) return getRandomColor(); - - if (type === "Folk") return culture.color; - if (type === "Heresy") return getMixedColor(culture.color, 0.35, 0.2); - if (type === "Cult") return getMixedColor(culture.color, 0.5, 0); - return getMixedColor(culture.color, 0.25, 0.4); - } - } - - // indexes, conditionally renames, and abbreviates religions - function combineReligions(namedReligions, lockedReligions) { - const indexedReligions = [{name: "No religion", i: 0}]; - - const {lockedReligionQueue, highestLockedIndex, codes, numberLockedFolk} = parseLockedReligions(); - const maxIndex = Math.max( - highestLockedIndex, - namedReligions.length + lockedReligions.length + 1 - numberLockedFolk - ); - - for (let index = 1, progress = 0; index < maxIndex; index = indexedReligions.length) { - // place locked religion back at its old index - if (index === lockedReligionQueue[0]?.i) { - const nextReligion = lockedReligionQueue.shift(); - indexedReligions.push(nextReligion); - continue; - } - - // slot the new religions - if (progress < namedReligions.length) { - const nextReligion = namedReligions[progress]; - progress++; - - if ( - nextReligion.type === "Folk" && - lockedReligions.some(({type, culture}) => type === "Folk" && culture === nextReligion.culture) - ) - continue; // when there is a locked Folk religion for this culture discard duplicate - - const newName = renameOld(nextReligion); - const code = abbreviate(newName, codes); - codes.push(code); - indexedReligions.push({...nextReligion, i: index, name: newName, code}); - continue; - } - - indexedReligions.push({i: index, type: "Folk", culture: 0, name: "Removed religion", removed: true}); - } - return indexedReligions; - - function parseLockedReligions() { - // copy and sort the locked religions list - const lockedReligionQueue = lockedReligions - .map(religion => { - // and filter their origins to locked religions - let newOrigin = religion.origins.filter(n => lockedReligions.some(({i: index}) => index === n)); - if (newOrigin === []) newOrigin = [0]; - return {...religion, origins: newOrigin}; - }) - .sort((a, b) => a.i - b.i); - - const highestLockedIndex = Math.max(...lockedReligions.map(r => r.i)); - const codes = lockedReligions.length > 0 ? lockedReligions.map(r => r.code) : []; - const numberLockedFolk = lockedReligions.filter(({type}) => type === "Folk").length; - - return {lockedReligionQueue, highestLockedIndex, codes, numberLockedFolk}; - } - - // prepend 'Old' to names of folk religions which have organized competitors - function renameOld({name, type, culture: cultureId}) { - if (type !== "Folk") return name; - - const haveOrganized = - namedReligions.some( - ({type, culture, expansion}) => culture === cultureId && type === "Organized" && expansion === "culture" - ) || - lockedReligions.some( - ({type, culture, expansion}) => culture === cultureId && type === "Organized" && expansion === "culture" - ); - if (haveOrganized && name.slice(0, 3) !== "Old") return `Old ${name}`; - return name; - } - } - - // finally generate and stores origins trees - function defineOrigins(religionIds, indexedReligions) { - const religionOriginsParamsMap = { - Organized: {clusterSize: 100, maxReligions: 2}, - Cult: {clusterSize: 50, maxReligions: 3}, - Heresy: {clusterSize: 50, maxReligions: 4} - }; - - const origins = indexedReligions.map(({i, type, culture: cultureId, expansion, center}) => { - if (i === 0) return null; // no religion - if (type === "Folk") return [0]; // folk religions originate from its parent culture only - - const folkReligion = indexedReligions.find(({culture, type}) => type === "Folk" && culture === cultureId); - const isFolkBased = folkReligion && cultureId && expansion === "culture" && each(2)(center); - if (isFolkBased) return [folkReligion.i]; - - const {clusterSize, maxReligions} = religionOriginsParamsMap[type]; - const fallbackOrigin = folkReligion?.i || 0; - return getReligionsInRadius(pack.cells.c, center, religionIds, i, clusterSize, maxReligions, fallbackOrigin); - }); - - return indexedReligions.map((religion, index) => ({...religion, origins: origins[index]})); - } - - function getReligionsInRadius(neighbors, center, religionIds, religionId, clusterSize, maxReligions, fallbackOrigin) { - const foundReligions = new Set(); - const queue = [center]; - const checked = {}; - - for (let size = 0; queue.length && size < clusterSize; size++) { - const cellId = queue.shift(); - checked[cellId] = true; - - for (const neibId of neighbors[cellId]) { - if (checked[neibId]) continue; - checked[neibId] = true; - - const neibReligion = religionIds[neibId]; - if (neibReligion && neibReligion < religionId) foundReligions.add(neibReligion); - if (foundReligions.size >= maxReligions) return [...foundReligions]; - queue.push(neibId); - } - } - - return foundReligions.size ? [...foundReligions] : [fallbackOrigin]; - } - - // growth algorithm to assign cells to religions - function expandReligions(religions) { - const {cells, routes} = pack; - const religionIds = spreadFolkReligions(religions); - - const queue = new FlatQueue(); - const cost = []; - - // limit cost for organized religions growth - const maxExpansionCost = (cells.i.length / 20) * byId("growthRate").valueAsNumber; - - religions - .filter(r => r.i && !r.lock && r.type !== "Folk" && !r.removed) - .forEach(r => { - religionIds[r.center] = r.i; - queue.push({e: r.center, p: 0, r: r.i, s: cells.state[r.center]}, 0); - cost[r.center] = 1; - }); - - const religionsMap = new Map(religions.map(r => [r.i, r])); - - while (queue.length) { - const {e: cellId, p, r, s: state} = queue.pop(); - const {culture, expansion, expansionism} = religionsMap.get(r); - - cells.c[cellId].forEach(nextCell => { - if (expansion === "culture" && culture !== cells.culture[nextCell]) return; - if (expansion === "state" && state !== cells.state[nextCell]) return; - if (religionsMap.get(religionIds[nextCell])?.lock) return; - - const cultureCost = culture !== cells.culture[nextCell] ? 10 : 0; - const stateCost = state !== cells.state[nextCell] ? 10 : 0; - const passageCost = getPassageCost(cellId, nextCell); - - const cellCost = cultureCost + stateCost + passageCost; - const totalCost = p + 10 + cellCost / expansionism; - if (totalCost > maxExpansionCost) return; - - if (!cost[nextCell] || totalCost < cost[nextCell]) { - if (cells.culture[nextCell]) religionIds[nextCell] = r; // assign religion to cell - cost[nextCell] = totalCost; - - queue.push({e: nextCell, p: totalCost, r, s: state}, totalCost); - } - }); - } - - return religionIds; - - function getPassageCost(cellId, nextCellId) { - const route = Routes.getRoute(cellId, nextCellId); - if (isWater(cellId)) return route ? 50 : 500; - - const biomePassageCost = biomesData.cost[cells.biome[nextCellId]]; - - if (route) { - if (route.group === "roads") return 1; - return biomePassageCost / 3; // trails and other routes - } - - return biomePassageCost; - } - } - - // folk religions initially get all cells of their culture, and locked religions are retained - function spreadFolkReligions(religions) { - const cells = pack.cells; - const hasPrior = cells.religion && true; - const religionIds = new Uint16Array(cells.i.length); - - const folkReligions = religions.filter(religion => religion.type === "Folk" && !religion.removed); - const cultureToReligionMap = new Map(folkReligions.map(({i, culture}) => [culture, i])); - - for (const cellId of cells.i) { - const oldId = (hasPrior && cells.religion[cellId]) || 0; - if (oldId && religions[oldId]?.lock && !religions[oldId]?.removed) { - religionIds[cellId] = oldId; - continue; - } - const cultureId = cells.culture[cellId]; - religionIds[cellId] = cultureToReligionMap.get(cultureId) || 0; - } - - return religionIds; - } - - function checkCenters() { - const cells = pack.cells; - pack.religions.forEach(r => { - if (!r.i) return; - // move religion center if it's not within religion area after expansion - if (cells.religion[r.center] === r.i) return; // in area - const firstCell = cells.i.find(i => cells.religion[i] === r.i); - const cultureHome = pack.cultures[r.culture]?.center; - if (firstCell) r.center = firstCell; // move center, othervise it's an extinct religion - else if (r.type === "Folk" && cultureHome) r.center = cultureHome; // reset extinct culture centers - }); - } - - function recalculate() { - const newReligionIds = expandReligions(pack.religions); - pack.cells.religion = newReligionIds; - - checkCenters(); - } - - const add = function (center) { - const {cells, cultures, religions} = pack; - const religionId = cells.religion[center]; - const i = religions.length; - - const cultureId = cells.culture[center]; - const missingFolk = - cultureId !== 0 && - !religions.some(({type, culture, removed}) => type === "Folk" && culture === cultureId && !removed); - const color = missingFolk ? cultures[cultureId].color : getMixedColor(religions[religionId].color, 0.3, 0); - - const type = missingFolk - ? "Folk" - : religions[religionId].type === "Organized" - ? rw({Organized: 4, Cult: 1, Heresy: 2}) - : rw({Organized: 5, Cult: 2}); - const form = rw(forms[type]); - const deity = - type === "Heresy" - ? religions[religionId].deity - : form === "Non-theism" || form === "Animism" - ? null - : getDeityName(cultureId); - - const [name, expansion] = generateReligionName(type, form, deity, center); - - const formName = type === "Heresy" ? religions[religionId].form : form; - const code = abbreviate( - name, - religions.map(r => r.code) - ); - const influences = getReligionsInRadius(cells.c, center, cells.religion, i, 25, 3, 0); - const origins = type === "Folk" ? [0] : influences; - - religions.push({ - i, - name, - color, - culture: cultureId, - type, - form: formName, - deity, - expansion, - expansionism: expansionismMap[type](), - center, - cells: 0, - area: 0, - rural: 0, - urban: 0, - origins, - code - }); - cells.religion[center] = i; - }; - - // get supreme deity name - const getDeityName = function (culture) { - if (culture === undefined) { - ERROR && console.error("Please define a culture"); - return; - } - const meaning = generateMeaning(); - const cultureName = Names.getCulture(culture, null, null, "", 0.8); - return cultureName + ", The " + meaning; - }; - - function generateMeaning() { - const a = ra(approaches); // select generation approach - if (a === "Number") return ra(base.number); - if (a === "Being") return ra(base.being); - if (a === "Adjective") return ra(base.adjective); - if (a === "Color + Animal") return `${ra(base.color)} ${ra(base.animal)}`; - if (a === "Adjective + Animal") return `${ra(base.adjective)} ${ra(base.animal)}`; - if (a === "Adjective + Being") return `${ra(base.adjective)} ${ra(base.being)}`; - if (a === "Adjective + Genitive") return `${ra(base.adjective)} ${ra(base.genitive)}`; - if (a === "Color + Being") return `${ra(base.color)} ${ra(base.being)}`; - if (a === "Color + Genitive") return `${ra(base.color)} ${ra(base.genitive)}`; - if (a === "Being + of + Genitive") return `${ra(base.being)} of ${ra(base.genitive)}`; - if (a === "Being + of the + Genitive") return `${ra(base.being)} of the ${ra(base.theGenitive)}`; - if (a === "Animal + of + Genitive") return `${ra(base.animal)} of ${ra(base.genitive)}`; - if (a === "Adjective + Being + of + Genitive") - return `${ra(base.adjective)} ${ra(base.being)} of ${ra(base.genitive)}`; - if (a === "Adjective + Animal + of + Genitive") - return `${ra(base.adjective)} ${ra(base.animal)} of ${ra(base.genitive)}`; - - ERROR && console.error("Unkown generation approach"); - } - - function generateReligionName(variety, form, deity, center) { - const {cells, cultures, burgs, states} = pack; - - const random = () => Names.getCulture(cells.culture[center], null, null, "", 0); - const type = rw(types[form]); - const supreme = deity.split(/[ ,]+/)[0]; - const culture = cultures[cells.culture[center]].name; - - const place = adj => { - const burgId = cells.burg[center]; - const stateId = cells.state[center]; - - const base = burgId ? burgs[burgId].name : states[stateId].name; - let name = trimVowels(base.split(/[ ,]+/)[0]); - return adj ? getAdjective(name) : name; - }; - - const m = rw(namingMethods[variety]); - if (m === "Random + type") return [random() + " " + type, "global"]; - if (m === "Random + ism") return [trimVowels(random()) + "ism", "global"]; - if (m === "Supreme + ism" && deity) return [trimVowels(supreme) + "ism", "global"]; - if (m === "Faith of + Supreme" && deity) - return [ra(["Faith", "Way", "Path", "Word", "Witnesses"]) + " of " + supreme, "global"]; - if (m === "Place + ism") return [place() + "ism", "state"]; - if (m === "Culture + ism") return [trimVowels(culture) + "ism", "culture"]; - if (m === "Place + ian + type") return [place("adj") + " " + type, "state"]; - if (m === "Culture + type") return [culture + " " + type, "culture"]; - if (m === "Burg + ian + type") return [`${place("adj")} ${type}`, "global"]; - if (m === "Random + ian + type") return [`${getAdjective(random())} ${type}`, "global"]; - if (m === "Type + of the + meaning") return [`${type} of the ${generateMeaning()}`, "global"]; - return [trimVowels(random()) + "ism", "global"]; // else - } - - return {generate, add, getDeityName, recalculate}; -})(); diff --git a/public/modules/renderers/draw-borders.js b/public/modules/renderers/draw-borders.js deleted file mode 100644 index f0f3006e..00000000 --- a/public/modules/renderers/draw-borders.js +++ /dev/null @@ -1,120 +0,0 @@ -"use strict"; - -function drawBorders() { - TIME && console.time("drawBorders"); - const {cells, vertices} = pack; - - const statePath = []; - const provincePath = []; - const checked = {}; - - const isLand = cellId => cells.h[cellId] >= 20; - - for (let cellId = 0; cellId < cells.i.length; cellId++) { - if (!cells.state[cellId]) continue; - const provinceId = cells.province[cellId]; - const stateId = cells.state[cellId]; - - // bordering cell of another province - if (provinceId) { - const provToCell = cells.c[cellId].find(neibId => { - const neibProvinceId = cells.province[neibId]; - return ( - neibProvinceId && - provinceId > neibProvinceId && - !checked[`prov-${provinceId}-${neibProvinceId}-${cellId}`] && - cells.state[neibId] === stateId - ); - }); - - if (provToCell !== undefined) { - const addToChecked = cellId => (checked[`prov-${provinceId}-${cells.province[provToCell]}-${cellId}`] = true); - const border = getBorder({type: "province", fromCell: cellId, toCell: provToCell, addToChecked}); - - if (border) { - provincePath.push(border); - cellId--; // check the same cell again - continue; - } - } - } - - // if cell is on state border - const stateToCell = cells.c[cellId].find(neibId => { - const neibStateId = cells.state[neibId]; - return isLand(neibId) && stateId > neibStateId && !checked[`state-${stateId}-${neibStateId}-${cellId}`]; - }); - - if (stateToCell !== undefined) { - const addToChecked = cellId => (checked[`state-${stateId}-${cells.state[stateToCell]}-${cellId}`] = true); - const border = getBorder({type: "state", fromCell: cellId, toCell: stateToCell, addToChecked}); - - if (border) { - statePath.push(border); - cellId--; // check the same cell again - continue; - } - } - } - - svg.select("#borders").selectAll("path").remove(); - svg.select("#stateBorders").append("path").attr("d", statePath.join(" ")); - svg.select("#provinceBorders").append("path").attr("d", provincePath.join(" ")); - - function getBorder({type, fromCell, toCell, addToChecked}) { - const getType = cellId => cells[type][cellId]; - const isTypeFrom = cellId => cellId < cells.i.length && getType(cellId) === getType(fromCell); - const isTypeTo = cellId => cellId < cells.i.length && getType(cellId) === getType(toCell); - - addToChecked(fromCell); - const startingVertex = cells.v[fromCell].find(v => vertices.c[v].some(i => isLand(i) && isTypeTo(i))); - if (startingVertex === undefined) return null; - - const checkVertex = vertex => - vertices.c[vertex].some(isTypeFrom) && vertices.c[vertex].some(c => isLand(c) && isTypeTo(c)); - const chain = getVerticesLine({vertices, startingVertex, checkCell: isTypeFrom, checkVertex, addToChecked}); - if (chain.length > 1) return "M" + chain.map(cellId => vertices.p[cellId]).join(" "); - - return null; - } - - // connect vertices to chain to form a border - function getVerticesLine({vertices, startingVertex, checkCell, checkVertex, addToChecked}) { - let chain = []; // vertices chain to form a path - let next = startingVertex; - const MAX_ITERATIONS = vertices.c.length; - - for (let run = 0; run < 2; run++) { - // first run: from any vertex to a border edge - // second run: from found border edge to another edge - chain = []; - - for (let i = 0; i < MAX_ITERATIONS; i++) { - const previous = chain.at(-1); - const current = next; - chain.push(current); - - const neibCells = vertices.c[current]; - neibCells.map(addToChecked); - - const [c1, c2, c3] = neibCells.map(checkCell); - const [v1, v2, v3] = vertices.v[current].map(checkVertex); - const [vertex1, vertex2, vertex3] = vertices.v[current]; - - if (v1 && vertex1 !== previous && c1 !== c2) next = vertex1; - else if (v2 && vertex2 !== previous && c2 !== c3) next = vertex2; - else if (v3 && vertex3 !== previous && c1 !== c3) next = vertex3; - - if (next === current || next === startingVertex) { - if (next === startingVertex) chain.push(startingVertex); - startingVertex = next; - break; - } - } - } - - return chain; - } - - TIME && console.timeEnd("drawBorders"); -} diff --git a/public/modules/renderers/draw-burg-icons.js b/public/modules/renderers/draw-burg-icons.js deleted file mode 100644 index 66d2dfcb..00000000 --- a/public/modules/renderers/draw-burg-icons.js +++ /dev/null @@ -1,108 +0,0 @@ -"use strict"; - -function drawBurgIcons() { - TIME && console.time("drawBurgIcons"); - createIconGroups(); - - for (const {name} of options.burgs.groups) { - const burgsInGroup = pack.burgs.filter(b => b.group === name && !b.removed); - if (!burgsInGroup.length) continue; - - const iconsGroup = document.querySelector("#burgIcons > g#" + name); - if (!iconsGroup) continue; - - const icon = iconsGroup.dataset.icon || "#icon-circle"; - iconsGroup.innerHTML = burgsInGroup - .map(b => ``) - .join(""); - - const portsInGroup = burgsInGroup.filter(b => b.port); - if (!portsInGroup.length) continue; - - const portGroup = document.querySelector("#anchors > g#" + name); - if (!portGroup) continue; - - portGroup.innerHTML = portsInGroup - .map(b => ``) - .join(""); - } - - TIME && console.timeEnd("drawBurgIcons"); -} - -function drawBurgIcon(burg) { - const iconGroup = burgIcons.select("#" + burg.group); - if (iconGroup.empty()) { - drawBurgIcons(); - return; // redraw all icons if group is missing - } - - removeBurgIcon(burg.i); - const icon = iconGroup.attr("data-icon") || "#icon-circle"; - burgIcons - .select("#" + burg.group) - .append("use") - .attr("href", icon) - .attr("id", "burg" + burg.i) - .attr("data-id", burg.i) - .attr("x", burg.x) - .attr("y", burg.y); - - if (burg.port) { - anchors - .select("#" + burg.group) - .append("use") - .attr("href", "#icon-anchor") - .attr("id", "anchor" + burg.i) - .attr("data-id", burg.i) - .attr("x", burg.x) - .attr("y", burg.y); - } -} - -function removeBurgIcon(burgId) { - const existingIcon = document.getElementById("burg" + burgId); - if (existingIcon) existingIcon.remove(); - - const existingAnchor = document.getElementById("anchor" + burgId); - if (existingAnchor) existingAnchor.remove(); -} - -function createIconGroups() { - // save existing styles and remove all groups - document.querySelectorAll("g#burgIcons > g").forEach(group => { - style.burgIcons[group.id] = Array.from(group.attributes).reduce((acc, attribute) => { - acc[attribute.name] = attribute.value; - return acc; - }, {}); - group.remove(); - }); - - document.querySelectorAll("g#anchors > g").forEach(group => { - style.anchors[group.id] = Array.from(group.attributes).reduce((acc, attribute) => { - acc[attribute.name] = attribute.value; - return acc; - }, {}); - group.remove(); - }); - - // create groups for each burg group and apply stored or default style - const defaultIconStyle = style.burgIcons.town || Object.values(style.burgIcons)[0] || {}; - const defaultAnchorStyle = style.anchors.town || Object.values(style.anchors)[0] || {}; - const sortedGroups = [...options.burgs.groups].sort((a, b) => a.order - b.order); - for (const {name} of sortedGroups) { - const burgGroup = burgIcons.append("g"); - const iconStyles = style.burgIcons[name] || defaultIconStyle; - Object.entries(iconStyles).forEach(([key, value]) => { - burgGroup.attr(key, value); - }); - burgGroup.attr("id", name); - - const anchorGroup = anchors.append("g"); - const anchorStyles = style.anchors[name] || defaultAnchorStyle; - Object.entries(anchorStyles).forEach(([key, value]) => { - anchorGroup.attr(key, value); - }); - anchorGroup.attr("id", name); - } -} diff --git a/public/modules/renderers/draw-burg-labels.js b/public/modules/renderers/draw-burg-labels.js deleted file mode 100644 index c8a43bbb..00000000 --- a/public/modules/renderers/draw-burg-labels.js +++ /dev/null @@ -1,84 +0,0 @@ -"use strict"; - -function drawBurgLabels() { - TIME && console.time("drawBurgLabels"); - createLabelGroups(); - - for (const {name} of options.burgs.groups) { - const burgsInGroup = pack.burgs.filter(b => b.group === name && !b.removed); - if (!burgsInGroup.length) continue; - - const labelGroup = burgLabels.select("#" + name); - if (labelGroup.empty()) continue; - - const dx = labelGroup.attr("data-dx") || 0; - const dy = labelGroup.attr("data-dy") || 0; - - labelGroup - .selectAll("text") - .data(burgsInGroup) - .enter() - .append("text") - .attr("text-rendering", "optimizeSpeed") - .attr("id", d => "burgLabel" + d.i) - .attr("data-id", d => d.i) - .attr("x", d => d.x) - .attr("y", d => d.y) - .attr("dx", dx + "em") - .attr("dy", dy + "em") - .text(d => d.name); - } - - TIME && console.timeEnd("drawBurgLabels"); -} - -function drawBurgLabel(burg) { - const labelGroup = burgLabels.select("#" + burg.group); - if (labelGroup.empty()) { - drawBurgLabels(); - return; // redraw all labels if group is missing - } - - const dx = labelGroup.attr("data-dx") || 0; - const dy = labelGroup.attr("data-dy") || 0; - - removeBurgLabel(burg.i); - labelGroup - .append("text") - .attr("text-rendering", "optimizeSpeed") - .attr("id", "burgLabel" + burg.i) - .attr("data-id", burg.i) - .attr("x", burg.x) - .attr("y", burg.y) - .attr("dx", dx + "em") - .attr("dy", dy + "em") - .text(burg.name); -} - -function removeBurgLabel(burgId) { - const existingLabel = document.getElementById("burgLabel" + burgId); - if (existingLabel) existingLabel.remove(); -} - -function createLabelGroups() { - // save existing styles and remove all groups - document.querySelectorAll("g#burgLabels > g").forEach(group => { - style.burgLabels[group.id] = Array.from(group.attributes).reduce((acc, attribute) => { - acc[attribute.name] = attribute.value; - return acc; - }, {}); - group.remove(); - }); - - // create groups for each burg group and apply stored or default style - const defaultStyle = style.burgLabels.town || Object.values(style.burgLabels)[0] || {}; - const sortedGroups = [...options.burgs.groups].sort((a, b) => a.order - b.order); - for (const {name} of sortedGroups) { - const group = burgLabels.append("g"); - const styles = style.burgLabels[name] || defaultStyle; - Object.entries(styles).forEach(([key, value]) => { - group.attr(key, value); - }); - group.attr("id", name); - } -} diff --git a/public/modules/renderers/draw-emblems.js b/public/modules/renderers/draw-emblems.js deleted file mode 100644 index 13781239..00000000 --- a/public/modules/renderers/draw-emblems.js +++ /dev/null @@ -1,129 +0,0 @@ -"use strict"; - -function drawEmblems() { - TIME && console.time("drawEmblems"); - const {states, provinces, burgs} = pack; - - const validStates = states.filter(s => s.i && !s.removed && s.coa && s.coa.size !== 0); - const validProvinces = provinces.filter(p => p.i && !p.removed && p.coa && p.coa.size !== 0); - const validBurgs = burgs.filter(b => b.i && !b.removed && b.coa && b.coa.size !== 0); - - const getStateEmblemsSize = () => { - const startSize = minmax((graphHeight + graphWidth) / 40, 10, 100); - const statesMod = 1 + validStates.length / 100 - (15 - validStates.length) / 200; // states number modifier - const sizeMod = +emblems.select("#stateEmblems").attr("data-size") || 1; - return rn((startSize / statesMod) * sizeMod); // target size ~50px on 1536x754 map with 15 states - }; - - const getProvinceEmblemsSize = () => { - const startSize = minmax((graphHeight + graphWidth) / 100, 5, 70); - const provincesMod = 1 + validProvinces.length / 1000 - (115 - validProvinces.length) / 1000; // states number modifier - const sizeMod = +emblems.select("#provinceEmblems").attr("data-size") || 1; - return rn((startSize / provincesMod) * sizeMod); // target size ~20px on 1536x754 map with 115 provinces - }; - - const getBurgEmblemSize = () => { - const startSize = minmax((graphHeight + graphWidth) / 185, 2, 50); - const burgsMod = 1 + validBurgs.length / 1000 - (450 - validBurgs.length) / 1000; // states number modifier - const sizeMod = +emblems.select("#burgEmblems").attr("data-size") || 1; - return rn((startSize / burgsMod) * sizeMod); // target size ~8.5px on 1536x754 map with 450 burgs - }; - - const sizeBurgs = getBurgEmblemSize(); - const burgCOAs = validBurgs.map(burg => { - const {x, y} = burg; - const size = burg.coa.size || 1; - const shift = (sizeBurgs * size) / 2; - return {type: "burg", i: burg.i, x: burg.coa.x || x, y: burg.coa.y || y, size, shift}; - }); - - const sizeProvinces = getProvinceEmblemsSize(); - const provinceCOAs = validProvinces.map(province => { - const [x, y] = province.pole || pack.cells.p[province.center]; - const size = province.coa.size || 1; - const shift = (sizeProvinces * size) / 2; - return {type: "province", i: province.i, x: province.coa.x || x, y: province.coa.y || y, size, shift}; - }); - - const sizeStates = getStateEmblemsSize(); - const stateCOAs = validStates.map(state => { - const [x, y] = state.pole || pack.cells.p[state.center]; - const size = state.coa.size || 1; - const shift = (sizeStates * size) / 2; - return {type: "state", i: state.i, x: state.coa.x || x, y: state.coa.y || y, size, shift}; - }); - - const nodes = burgCOAs.concat(provinceCOAs).concat(stateCOAs); - const simulation = d3 - .forceSimulation(nodes) - .alphaMin(0.6) - .alphaDecay(0.2) - .velocityDecay(0.6) - .force( - "collision", - d3.forceCollide().radius(d => d.shift) - ) - .stop(); - - d3.timeout(function () { - const n = Math.ceil(Math.log(simulation.alphaMin()) / Math.log(1 - simulation.alphaDecay())); - for (let i = 0; i < n; ++i) { - simulation.tick(); - } - - const burgNodes = nodes.filter(node => node.type === "burg"); - const burgString = burgNodes - .map( - d => - `` - ) - .join(""); - emblems.select("#burgEmblems").attr("font-size", sizeBurgs).html(burgString); - - const provinceNodes = nodes.filter(node => node.type === "province"); - const provinceString = provinceNodes - .map( - d => - `` - ) - .join(""); - emblems.select("#provinceEmblems").attr("font-size", sizeProvinces).html(provinceString); - - const stateNodes = nodes.filter(node => node.type === "state"); - const stateString = stateNodes - .map( - d => - `` - ) - .join(""); - emblems.select("#stateEmblems").attr("font-size", sizeStates).html(stateString); - - invokeActiveZooming(); - }); - - TIME && console.timeEnd("drawEmblems"); -} - -const getDataAndType = id => { - if (id === "burgEmblems") return [pack.burgs, "burg"]; - if (id === "provinceEmblems") return [pack.provinces, "province"]; - if (id === "stateEmblems") return [pack.states, "state"]; - throw new Error(`Unknown emblem type: ${id}`); -}; - -async function renderGroupCOAs(g) { - const [data, type] = getDataAndType(g.id); - - for (let use of g.children) { - const i = +use.dataset.i; - const id = type + "COA" + i; - COArenderer.trigger(id, data[i].coa); - use.setAttribute("href", "#" + id); - } -} diff --git a/public/modules/renderers/draw-features.js b/public/modules/renderers/draw-features.js deleted file mode 100644 index 0112a0ae..00000000 --- a/public/modules/renderers/draw-features.js +++ /dev/null @@ -1,66 +0,0 @@ -"use strict"; - -function drawFeatures() { - TIME && console.time("drawFeatures"); - - const html = { - paths: [], - landMask: [], - waterMask: [''], - coastline: {}, - lakes: {} - }; - - for (const feature of pack.features) { - if (!feature || feature.type === "ocean") continue; - - html.paths.push(``); - - if (feature.type === "lake") { - html.landMask.push(``); - - const lakeGroup = feature.group || "freshwater"; - if (!html.lakes[lakeGroup]) html.lakes[lakeGroup] = []; - html.lakes[lakeGroup].push(``); - } else { - html.landMask.push(``); - html.waterMask.push(``); - - const coastlineGroup = feature.group === "lake_island" ? "lake_island" : "sea_island"; - if (!html.coastline[coastlineGroup]) html.coastline[coastlineGroup] = []; - html.coastline[coastlineGroup].push(``); - } - } - - defs.select("#featurePaths").html(html.paths.join("")); - defs.select("#land").html(html.landMask.join("")); - defs.select("#water").html(html.waterMask.join("")); - - coastline.selectAll("g").each(function () { - const paths = html.coastline[this.id] || []; - d3.select(this).html(paths.join("")); - }); - - lakes.selectAll("g").each(function () { - const paths = html.lakes[this.id] || []; - d3.select(this).html(paths.join("")); - }); - - TIME && console.timeEnd("drawFeatures"); -} - -function getFeaturePath(feature) { - const points = feature.vertices.map(vertex => pack.vertices.p[vertex]); - if (points.some(point => point === undefined)) { - ERROR && console.error("Undefined point in getFeaturePath"); - return ""; - } - - const simplifiedPoints = simplify(points, 0.3); - const clippedPoints = clipPoly(simplifiedPoints, 1); - - const lineGen = d3.line().curve(d3.curveBasisClosed); - const path = round(lineGen(clippedPoints)) + "Z"; - - return path; -} diff --git a/public/modules/renderers/draw-markers.js b/public/modules/renderers/draw-markers.js deleted file mode 100644 index f7466a55..00000000 --- a/public/modules/renderers/draw-markers.js +++ /dev/null @@ -1,53 +0,0 @@ -"use strict"; - -function drawMarkers() { - TIME && console.time("drawMarkers"); - - const rescale = +markers.attr("rescale"); - const pinned = +markers.attr("pinned"); - - const markersData = pinned ? pack.markers.filter(({pinned}) => pinned) : pack.markers; - const html = markersData.map(marker => drawMarker(marker, rescale)); - markers.html(html.join("")); - - TIME && console.timeEnd("drawMarkers"); -} - -// prettier-ignore -const pinShapes = { - bubble: (fill, stroke) => ``, - pin: (fill, stroke) => ``, - square: (fill, stroke) => ``, - squarish: (fill, stroke) => ``, - diamond: (fill, stroke) => ``, - hex: (fill, stroke) => ``, - hexy: (fill, stroke) => ``, - shieldy: (fill, stroke) => ``, - shield: (fill, stroke) => ``, - pentagon: (fill, stroke) => ``, - heptagon: (fill, stroke) => ``, - circle: (fill, stroke) => ``, - no: () => "" -}; - -const getPin = (shape = "bubble", fill = "#fff", stroke = "#000") => { - const shapeFunction = pinShapes[shape] || pinShapes.bubble; - return shapeFunction(fill, stroke); -}; - -function drawMarker(marker, rescale = 1) { - const {i, icon, x, y, dx = 50, dy = 50, px = 12, size = 30, pin, fill, stroke} = marker; - const id = `marker${i}`; - const zoomSize = rescale ? Math.max(rn(size / 5 + 24 / scale, 2), 1) : size; - const viewX = rn(x - zoomSize / 2, 1); - const viewY = rn(y - zoomSize, 1); - - const isExternal = icon.startsWith("http") || icon.startsWith("data:image"); - - return /* html */ ` - - ${getPin(pin, fill, stroke)} - ${isExternal ? "" : icon} - - `; -} diff --git a/public/modules/renderers/draw-military.js b/public/modules/renderers/draw-military.js deleted file mode 100644 index a332130f..00000000 --- a/public/modules/renderers/draw-military.js +++ /dev/null @@ -1,155 +0,0 @@ -"use strict"; - -function drawMilitary() { - TIME && console.time("drawMilitary"); - - armies.selectAll("g").remove(); - pack.states.filter(s => s.i && !s.removed).forEach(s => drawRegiments(s.military, s.i)); - - TIME && console.timeEnd("drawMilitary"); -} - -const drawRegiments = function (regiments, s) { - const size = +armies.attr("box-size"); - const w = d => (d.n ? size * 4 : size * 6); - const h = size * 2; - const x = d => rn(d.x - w(d) / 2, 2); - const y = d => rn(d.y - size, 2); - - const baseColor = pack.states[s].color[0] === "#" ? pack.states[s].color : "#999"; - const darkerColor = d3.color(baseColor).darker().hex(); - const army = armies - .append("g") - .attr("id", "army" + s) - .attr("fill", baseColor) - .attr("color", darkerColor); - - const g = army - .selectAll("g") - .data(regiments) - .enter() - .append("g") - .attr("id", d => "regiment" + s + "-" + d.i) - .attr("data-name", d => d.name) - .attr("data-state", s) - .attr("data-id", d => d.i) - .attr("transform", d => (d.angle ? `rotate(${d.angle})` : null)) - .attr("transform-origin", d => `${d.x}px ${d.y}px`); - g.append("rect") - .attr("x", d => x(d)) - .attr("y", d => y(d)) - .attr("width", d => w(d)) - .attr("height", h); - g.append("text") - .attr("x", d => d.x) - .attr("y", d => d.y) - .attr("text-rendering", "optimizeSpeed") - .text(d => Military.getTotal(d)); - g.append("rect") - .attr("fill", "currentColor") - .attr("x", d => x(d) - h) - .attr("y", d => y(d)) - .attr("width", h) - .attr("height", h); - g.append("text") - .attr("class", "regimentIcon") - .attr("text-rendering", "optimizeSpeed") - .attr("x", d => x(d) - size) - .attr("y", d => d.y) - .text(d => (d.icon.startsWith("http") || d.icon.startsWith("data:image") ? "" : d.icon)); - g.append("image") - .attr("class", "regimentImage") - .attr("x", d => x(d) - h) - .attr("y", d => y(d)) - .attr("height", h) - .attr("width", h) - .attr("href", d => (d.icon.startsWith("http") || d.icon.startsWith("data:image") ? d.icon : "")); -}; - -const drawRegiment = function (reg, stateId) { - const size = +armies.attr("box-size"); - const w = reg.n ? size * 4 : size * 6; - const h = size * 2; - const x1 = rn(reg.x - w / 2, 2); - const y1 = rn(reg.y - size, 2); - - let army = armies.select("g#army" + stateId); - if (!army.size()) { - const baseColor = pack.states[stateId].color[0] === "#" ? pack.states[stateId].color : "#999"; - const darkerColor = d3.color(baseColor).darker().hex(); - army = armies - .append("g") - .attr("id", "army" + stateId) - .attr("fill", baseColor) - .attr("color", darkerColor); - } - - const g = army - .append("g") - .attr("id", "regiment" + stateId + "-" + reg.i) - .attr("data-name", reg.name) - .attr("data-state", stateId) - .attr("data-id", reg.i) - .attr("transform", `rotate(${reg.angle || 0})`) - .attr("transform-origin", `${reg.x}px ${reg.y}px`); - g.append("rect").attr("x", x1).attr("y", y1).attr("width", w).attr("height", h); - g.append("text") - .attr("x", reg.x) - .attr("y", reg.y) - .attr("text-rendering", "optimizeSpeed") - .text(Military.getTotal(reg)); - g.append("rect") - .attr("fill", "currentColor") - .attr("x", x1 - h) - .attr("y", y1) - .attr("width", h) - .attr("height", h); - g.append("text") - .attr("class", "regimentIcon") - .attr("text-rendering", "optimizeSpeed") - .attr("x", x1 - size) - .attr("y", reg.y) - .text(reg.icon.startsWith("http") || reg.icon.startsWith("data:image") ? "" : reg.icon); - g.append("image") - .attr("class", "regimentImage") - .attr("x", x1 - h) - .attr("y", y1) - .attr("height", h) - .attr("width", h) - .attr("href", reg.icon.startsWith("http") || reg.icon.startsWith("data:image") ? reg.icon : ""); -}; - -// move one regiment to another -const moveRegiment = function (reg, x, y) { - const el = armies.select("g#army" + reg.state).select("g#regiment" + reg.state + "-" + reg.i); - if (!el.size()) return; - - const duration = Math.hypot(reg.x - x, reg.y - y) * 8; - reg.x = x; - reg.y = y; - const size = +armies.attr("box-size"); - const w = reg.n ? size * 4 : size * 6; - const h = size * 2; - const x1 = x => rn(x - w / 2, 2); - const y1 = y => rn(y - size, 2); - - const move = d3.transition().duration(duration).ease(d3.easeSinInOut); - el.select("rect").transition(move).attr("x", x1(x)).attr("y", y1(y)); - el.select("text").transition(move).attr("x", x).attr("y", y); - el.selectAll("rect:nth-of-type(2)") - .transition(move) - .attr("x", x1(x) - h) - .attr("y", y1(y)); - el.select(".regimentIcon") - .transition(move) - .attr("x", x1(x) - size) - .attr("y", y) - .attr("height", "6") - .attr("width", "6"); - el.select(".regimentImage") - .transition(move) - .attr("x", x1(x) - h) - .attr("y", y1(y)) - .attr("height", "6") - .attr("width", "6"); -}; diff --git a/public/modules/renderers/draw-relief-icons.js b/public/modules/renderers/draw-relief-icons.js deleted file mode 100644 index ffa0b69c..00000000 --- a/public/modules/renderers/draw-relief-icons.js +++ /dev/null @@ -1,124 +0,0 @@ -"use strict"; - -function drawReliefIcons() { - TIME && console.time("drawRelief"); - terrain.selectAll("*").remove(); - - const cells = pack.cells; - const density = terrain.attr("density") || 0.4; - const size = 2 * (terrain.attr("size") || 1); - const mod = 0.2 * size; // size modifier - const relief = []; - - for (const i of cells.i) { - const height = cells.h[i]; - if (height < 20) continue; // no icons on water - if (cells.r[i]) continue; // no icons on rivers - const biome = cells.biome[i]; - if (height < 50 && biomesData.iconsDensity[biome] === 0) continue; // no icons for this biome - - const polygon = getPackPolygon(i); - const [minX, maxX] = d3.extent(polygon, p => p[0]); - const [minY, maxY] = d3.extent(polygon, p => p[1]); - - if (height < 50) placeBiomeIcons(i, biome); - else placeReliefIcons(i); - - function placeBiomeIcons() { - const iconsDensity = biomesData.iconsDensity[biome] / 100; - const radius = 2 / iconsDensity / density; - if (Math.random() > iconsDensity * 10) return; - - for (const [cx, cy] of poissonDiscSampler(minX, minY, maxX, maxY, radius)) { - if (!d3.polygonContains(polygon, [cx, cy])) continue; - let h = (4 + Math.random()) * size; - const icon = getBiomeIcon(i, biomesData.icons[biome]); - if (icon === "#relief-grass-1") h *= 1.2; - relief.push({i: icon, x: rn(cx - h, 2), y: rn(cy - h, 2), s: rn(h * 2, 2)}); - } - } - - function placeReliefIcons(i) { - const radius = 2 / density; - const [icon, h] = getReliefIcon(i, height); - - for (const [cx, cy] of poissonDiscSampler(minX, minY, maxX, maxY, radius)) { - if (!d3.polygonContains(polygon, [cx, cy])) continue; - relief.push({i: icon, x: rn(cx - h, 2), y: rn(cy - h, 2), s: rn(h * 2, 2)}); - } - } - - function getReliefIcon(i, h) { - const temp = grid.cells.temp[pack.cells.g[i]]; - const type = h > 70 && temp < 0 ? "mountSnow" : h > 70 ? "mount" : "hill"; - const size = h > 70 ? (h - 45) * mod : minmax((h - 40) * mod, 3, 6); - return [getIcon(type), size]; - } - } - - // sort relief icons by y+size - relief.sort((a, b) => a.y + a.s - (b.y + b.s)); - - const reliefHTML = new Array(relief.length); - for (const r of relief) { - reliefHTML.push(``); - } - terrain.html(reliefHTML.join("")); - - TIME && console.timeEnd("drawRelief"); - - function getBiomeIcon(i, b) { - let type = b[Math.floor(Math.random() * b.length)]; - const temp = grid.cells.temp[pack.cells.g[i]]; - if (type === "conifer" && temp < 0) type = "coniferSnow"; - return getIcon(type); - } - - function getVariant(type) { - switch (type) { - case "mount": - return rand(2, 7); - case "mountSnow": - return rand(1, 6); - case "hill": - return rand(2, 5); - case "conifer": - return 2; - case "coniferSnow": - return 1; - case "swamp": - return rand(2, 3); - case "cactus": - return rand(1, 3); - case "deadTree": - return rand(1, 2); - default: - return 2; - } - } - - function getOldIcon(type) { - switch (type) { - case "mountSnow": - return "mount"; - case "vulcan": - return "mount"; - case "coniferSnow": - return "conifer"; - case "cactus": - return "dune"; - case "deadTree": - return "dune"; - default: - return type; - } - } - - function getIcon(type) { - const set = terrain.attr("set") || "simple"; - if (set === "simple") return "#relief-" + getOldIcon(type) + "-1"; - if (set === "colored") return "#relief-" + type + "-" + getVariant(type); - if (set === "gray") return "#relief-" + type + "-" + getVariant(type) + "-bw"; - return "#relief-" + getOldIcon(type) + "-1"; // simple - } -} diff --git a/public/modules/renderers/draw-state-labels.js b/public/modules/renderers/draw-state-labels.js deleted file mode 100644 index 9586a9c1..00000000 --- a/public/modules/renderers/draw-state-labels.js +++ /dev/null @@ -1,312 +0,0 @@ -"use strict"; - -// list - an optional array of stateIds to regenerate -function drawStateLabels(list) { - TIME && console.time("drawStateLabels"); - - // temporary make the labels visible - const layerDisplay = labels.style("display"); - labels.style("display", null); - - const {cells, states, features} = pack; - const stateIds = cells.state; - - // increase step to 15 or 30 to make it faster and more horyzontal - // decrease step to 5 to improve accuracy - const ANGLE_STEP = 9; - const angles = precalculateAngles(ANGLE_STEP); - - const LENGTH_START = 5; - const LENGTH_STEP = 5; - const LENGTH_MAX = 300; - - const labelPaths = getLabelPaths(); - const letterLength = checkExampleLetterLength(); - drawLabelPath(letterLength); - - // restore labels visibility - labels.style("display", layerDisplay); - - function getLabelPaths() { - const labelPaths = []; - - for (const state of states) { - if (!state.i || state.removed || state.lock) continue; - if (list && !list.includes(state.i)) continue; - - const offset = getOffsetWidth(state.cells); - const maxLakeSize = state.cells / 20; - const [x0, y0] = state.pole; - - const rays = angles.map(({angle, dx, dy}) => { - const {length, x, y} = raycast({stateId: state.i, x0, y0, dx, dy, maxLakeSize, offset}); - return {angle, length, x, y}; - }); - const [ray1, ray2] = findBestRayPair(rays); - - const pathPoints = [[ray1.x, ray1.y], state.pole, [ray2.x, ray2.y]]; - if (ray1.x > ray2.x) pathPoints.reverse(); - - if (DEBUG.stateLabels) { - drawPoint(state.pole, {color: "black", radius: 1}); - drawPath(pathPoints, {color: "black", width: 0.2}); - } - - labelPaths.push([state.i, pathPoints]); - } - - return labelPaths; - } - - function checkExampleLetterLength() { - const textGroup = d3.select("g#labels > g#states"); - const testLabel = textGroup.append("text").attr("x", 0).attr("y", 0).text("Example"); - const letterLength = testLabel.node().getComputedTextLength() / 7; // approximate length of 1 letter - testLabel.remove(); - - return letterLength; - } - - function drawLabelPath(letterLength) { - const mode = options.stateLabelsMode || "auto"; - const lineGen = d3.line().curve(d3.curveNatural); - - const textGroup = d3.select("g#labels > g#states"); - const pathGroup = d3.select("defs > g#deftemp > g#textPaths"); - - for (const [stateId, pathPoints] of labelPaths) { - const state = states[stateId]; - if (!state.i || state.removed) throw new Error("State must not be neutral or removed"); - if (pathPoints.length < 2) throw new Error("Label path must have at least 2 points"); - - textGroup.select("#stateLabel" + stateId).remove(); - pathGroup.select("#textPath_stateLabel" + stateId).remove(); - - const textPath = pathGroup - .append("path") - .attr("d", round(lineGen(pathPoints))) - .attr("id", "textPath_stateLabel" + stateId); - - const pathLength = textPath.node().getTotalLength() / letterLength; // path length in letters - const [lines, ratio] = getLinesAndRatio(mode, state.name, state.fullName, pathLength); - - // prolongate path if it's too short - const longestLineLength = d3.max(lines.map(({length}) => length)); - if (pathLength && pathLength < longestLineLength) { - const [x1, y1] = pathPoints.at(0); - const [x2, y2] = pathPoints.at(-1); - const [dx, dy] = [(x2 - x1) / 2, (y2 - y1) / 2]; - - const mod = longestLineLength / pathLength; - pathPoints[0] = [x1 + dx - dx * mod, y1 + dy - dy * mod]; - pathPoints[pathPoints.length - 1] = [x2 - dx + dx * mod, y2 - dy + dy * mod]; - - textPath.attr("d", round(lineGen(pathPoints))); - } - - const textElement = textGroup - .append("text") - .attr("text-rendering", "optimizeSpeed") - .attr("id", "stateLabel" + stateId) - .append("textPath") - .attr("startOffset", "50%") - .attr("font-size", ratio + "%") - .node(); - - const top = (lines.length - 1) / -2; // y offset - const spans = lines.map((line, index) => `${line}`); - textElement.insertAdjacentHTML("afterbegin", spans.join("")); - - const {width, height} = textElement.getBBox(); - textElement.setAttribute("href", "#textPath_stateLabel" + stateId); - - if (mode === "full" || lines.length === 1) continue; - - // check if label fits state boundaries. If no, replace it with short name - const [[x1, y1], [x2, y2]] = [pathPoints.at(0), pathPoints.at(-1)]; - const angleRad = Math.atan2(y2 - y1, x2 - x1); - - const isInsideState = checkIfInsideState(textElement, angleRad, width / 2, height / 2, stateIds, stateId); - if (isInsideState) continue; - - // replace name to one-liner - const text = pathLength > state.fullName.length * 1.8 ? state.fullName : state.name; - textElement.innerHTML = `${text}`; - - const correctedRatio = minmax(rn((pathLength / text.length) * 50), 50, 130); - textElement.setAttribute("font-size", correctedRatio + "%"); - } - } - - function getOffsetWidth(cellsNumber) { - if (cellsNumber < 40) return 0; - if (cellsNumber < 200) return 5; - return 10; - } - - function precalculateAngles(step) { - const angles = []; - const RAD = Math.PI / 180; - - for (let angle = 0; angle < 360; angle += step) { - const dx = Math.cos(angle * RAD); - const dy = Math.sin(angle * RAD); - angles.push({angle, dx, dy}); - } - - return angles; - } - - function raycast({stateId, x0, y0, dx, dy, maxLakeSize, offset}) { - let ray = {length: 0, x: x0, y: y0}; - - for (let length = LENGTH_START; length < LENGTH_MAX; length += LENGTH_STEP) { - const [x, y] = [x0 + length * dx, y0 + length * dy]; - // offset points are perpendicular to the ray - const offset1 = [x + -dy * offset, y + dx * offset]; - const offset2 = [x + dy * offset, y + -dx * offset]; - - if (DEBUG.stateLabels) { - drawPoint([x, y], {color: isInsideState(x, y) ? "blue" : "red", radius: 0.8}); - drawPoint(offset1, {color: isInsideState(...offset1) ? "blue" : "red", radius: 0.4}); - drawPoint(offset2, {color: isInsideState(...offset2) ? "blue" : "red", radius: 0.4}); - } - - const inState = isInsideState(x, y) && isInsideState(...offset1) && isInsideState(...offset2); - if (!inState) break; - ray = {length, x, y}; - } - - return ray; - - function isInsideState(x, y) { - if (x < 0 || x > graphWidth || y < 0 || y > graphHeight) return false; - const cellId = findCell(x, y); - - const feature = features[cells.f[cellId]]; - if (feature.type === "lake") return isInnerLake(feature) || isSmallLake(feature); - - return stateIds[cellId] === stateId; - } - - function isInnerLake(feature) { - return feature.shoreline.every(cellId => stateIds[cellId] === stateId); - } - - function isSmallLake(feature) { - return feature.cells <= maxLakeSize; - } - } - - function findBestRayPair(rays) { - let bestPair = null; - let bestScore = -Infinity; - - for (let i = 0; i < rays.length; i++) { - const score1 = rays[i].length * scoreRayAngle(rays[i].angle); - - for (let j = i + 1; j < rays.length; j++) { - const score2 = rays[j].length * scoreRayAngle(rays[j].angle); - const pairScore = (score1 + score2) * scoreCurvature(rays[i].angle, rays[j].angle); - - if (pairScore > bestScore) { - bestScore = pairScore; - bestPair = [rays[i], rays[j]]; - } - } - } - - return bestPair; - } - - function scoreRayAngle(angle) { - const normalizedAngle = Math.abs(angle % 180); // [0, 180] - const horizontality = Math.abs(normalizedAngle - 90) / 90; // [0, 1] - - if (horizontality === 1) return 1; // Best: horizontal - if (horizontality >= 0.75) return 0.9; // Very good: slightly slanted - if (horizontality >= 0.5) return 0.6; // Good: moderate slant - if (horizontality >= 0.25) return 0.5; // Acceptable: more slanted - if (horizontality >= 0.15) return 0.2; // Poor: almost vertical - return 0.1; // Very poor: almost vertical - } - - function scoreCurvature(angle1, angle2) { - const delta = getAngleDelta(angle1, angle2); - const similarity = evaluateArc(angle1, angle2); - - if (delta === 180) return 1; // straight line: best - if (delta < 90) return 0; // acute: not allowed - if (delta < 120) return 0.6 * similarity; - if (delta < 140) return 0.7 * similarity; - if (delta < 160) return 0.8 * similarity; - - return similarity; - } - - function getAngleDelta(angle1, angle2) { - let delta = Math.abs(angle1 - angle2) % 360; - if (delta > 180) delta = 360 - delta; // [0, 180] - return delta; - } - - // compute arc similarity towards x-axis - function evaluateArc(angle1, angle2) { - const proximity1 = Math.abs((angle1 % 180) - 90); - const proximity2 = Math.abs((angle2 % 180) - 90); - return 1 - Math.abs(proximity1 - proximity2) / 90; - } - - function getLinesAndRatio(mode, name, fullName, pathLength) { - if (mode === "short") return getShortOneLine(); - if (pathLength > fullName.length * 2) return getFullOneLine(); - return getFullTwoLines(); - - function getShortOneLine() { - const ratio = pathLength / name.length; - return [[name], minmax(rn(ratio * 60), 50, 150)]; - } - - function getFullOneLine() { - const ratio = pathLength / fullName.length; - return [[fullName], minmax(rn(ratio * 70), 70, 170)]; - } - - function getFullTwoLines() { - const lines = splitInTwo(fullName); - const longestLineLength = d3.max(lines.map(({length}) => length)); - const ratio = pathLength / longestLineLength; - return [lines, minmax(rn(ratio * 60), 70, 150)]; - } - } - - // check whether multi-lined label is mostly inside the state. If no, replace it with short name label - function checkIfInsideState(textElement, angleRad, halfwidth, halfheight, stateIds, stateId) { - const bbox = textElement.getBBox(); - const [cx, cy] = [bbox.x + bbox.width / 2, bbox.y + bbox.height / 2]; - - const points = [ - [-halfwidth, -halfheight], - [+halfwidth, -halfheight], - [+halfwidth, halfheight], - [-halfwidth, halfheight], - [0, halfheight], - [0, -halfheight] - ]; - - const sin = Math.sin(angleRad); - const cos = Math.cos(angleRad); - const rotatedPoints = points.map(([x, y]) => [cx + x * cos - y * sin, cy + x * sin + y * cos]); - - let pointsInside = 0; - for (const [x, y] of rotatedPoints) { - const isInside = stateIds[findCell(x, y)] === stateId; - if (isInside) pointsInside++; - if (pointsInside > 4) return true; - } - - return false; - } - - TIME && console.timeEnd("drawStateLabels"); -} diff --git a/public/modules/renderers/draw-temperature.js b/public/modules/renderers/draw-temperature.js deleted file mode 100644 index 51dc32f5..00000000 --- a/public/modules/renderers/draw-temperature.js +++ /dev/null @@ -1,104 +0,0 @@ -"use strict"; - -function drawTemperature() { - TIME && console.time("drawTemperature"); - - temperature.selectAll("*").remove(); - lineGen.curve(d3.curveBasisClosed); - const scheme = d3.scaleSequential(d3.interpolateSpectral); - - const tMax = +byId("temperatureEquatorOutput").max; - const tMin = +byId("temperatureEquatorOutput").min; - const delta = tMax - tMin; - - const {cells, vertices} = grid; - const n = cells.i.length; - - const checkedCells = new Uint8Array(n); - const addToChecked = cellId => (checkedCells[cellId] = 1); - - const min = d3.min(cells.temp); - const max = d3.max(cells.temp); - const step = Math.max(Math.round(Math.abs(min - max) / 5), 1); - - const isolines = d3.range(min + step, max, step); - const chains = []; - const labels = []; // store label coordinates - - for (const cellId of cells.i) { - const t = cells.temp[cellId]; - if (checkedCells[cellId] || !isolines.includes(t)) continue; - - const startingVertex = findStart(cellId, t); - if (!startingVertex) continue; - checkedCells[cellId] = 1; - - const ofSameType = cellId => cells.temp[cellId] >= t; - const chain = connectVertices({vertices, startingVertex, ofSameType, addToChecked}); - const relaxed = chain.filter((v, i) => i % 4 === 0 || vertices.c[v].some(c => c >= n)); - if (relaxed.length < 6) continue; - - const points = relaxed.map(v => vertices.p[v]); - chains.push([t, points]); - addLabel(points, t); - } - - // min temp isoline covers all graph - temperature - .append("path") - .attr("d", `M0,0 h${graphWidth} v${graphHeight} h${-graphWidth} Z`) - .attr("fill", scheme(1 - (min - tMin) / delta)) - .attr("stroke", "none"); - - for (const t of isolines) { - const path = chains - .filter(c => c[0] === t) - .map(c => round(lineGen(c[1]))) - .join(""); - if (!path) continue; - const fill = scheme(1 - (t - tMin) / delta), - stroke = d3.color(fill).darker(0.2); - temperature.append("path").attr("d", path).attr("fill", fill).attr("stroke", stroke); - } - - const tempLabels = temperature.append("g").attr("id", "tempLabels").attr("fill-opacity", 1); - tempLabels - .selectAll("text") - .data(labels) - .enter() - .append("text") - .attr("x", d => d[0]) - .attr("y", d => d[1]) - .text(d => convertTemperature(d[2])); - - // find cell with temp < isotherm and find vertex to start path detection - function findStart(i, t) { - if (cells.b[i]) return cells.v[i].find(v => vertices.c[v].some(c => c >= n)); // map border cell - return cells.v[i][cells.c[i].findIndex(c => cells.temp[c] < t || !cells.temp[c])]; - } - - function addLabel(points, t) { - const xCenter = svgWidth / 2; - - // add label on isoline top center - const tc = - points[d3.scan(points, (a, b) => a[1] - b[1] + (Math.abs(a[0] - xCenter) - Math.abs(b[0] - xCenter)) / 2)]; - pushLabel(tc[0], tc[1], t); - - // add label on isoline bottom center - if (points.length > 20) { - const bc = - points[d3.scan(points, (a, b) => b[1] - a[1] + (Math.abs(a[0] - xCenter) - Math.abs(b[0] - xCenter)) / 2)]; - const dist2 = (tc[1] - bc[1]) ** 2 + (tc[0] - bc[0]) ** 2; // square distance between this and top point - if (dist2 > 100) pushLabel(bc[0], bc[1], t); - } - } - - function pushLabel(x, y, t) { - if (x < 20 || x > svgWidth - 20) return; - if (y < 20 || y > svgHeight - 20) return; - labels.push([x, y, t]); - } - - TIME && console.timeEnd("drawTemperature"); -} diff --git a/public/modules/resample.js b/public/modules/resample.js index 819214b1..b64dde1f 100644 --- a/public/modules/resample.js +++ b/public/modules/resample.js @@ -28,6 +28,7 @@ window.Resample = (function () { reGraph(); Features.markupPack(); + Ice.generate() createDefaultRuler(); restoreCellData(parentMap, inverse, scale); diff --git a/public/modules/routes-generator.js b/public/modules/routes-generator.js deleted file mode 100644 index 460625ed..00000000 --- a/public/modules/routes-generator.js +++ /dev/null @@ -1,677 +0,0 @@ -const ROUTES_SHARP_ANGLE = 135; -const ROUTES_VERY_SHARP_ANGLE = 115; - -const MIN_PASSABLE_SEA_TEMP = -4; -const ROUTE_TYPE_MODIFIERS = { - "-1": 1, // coastline - "-2": 1.8, // sea - "-3": 4, // open sea - "-4": 6, // ocean - default: 8 // far ocean -}; - -window.Routes = (function () { - function generate(lockedRoutes = []) { - const {capitalsByFeature, burgsByFeature, portsByFeature} = sortBurgsByFeature(pack.burgs); - - const connections = new Map(); - lockedRoutes.forEach(route => addConnections(route.points.map(p => p[2]))); - - const mainRoads = generateMainRoads(); - const trails = generateTrails(); - const seaRoutes = generateSeaRoutes(); - - pack.routes = createRoutesData(lockedRoutes); - pack.cells.routes = buildLinks(pack.routes); - - function sortBurgsByFeature(burgs) { - const burgsByFeature = {}; - const capitalsByFeature = {}; - const portsByFeature = {}; - - const addBurg = (collection, feature, burg) => { - if (!collection[feature]) collection[feature] = []; - collection[feature].push(burg); - }; - - for (const burg of burgs) { - if (burg.i && !burg.removed) { - const {feature, capital, port} = burg; - addBurg(burgsByFeature, feature, burg); - if (capital) addBurg(capitalsByFeature, feature, burg); - if (port) addBurg(portsByFeature, port, burg); - } - } - - return {burgsByFeature, capitalsByFeature, portsByFeature}; - } - - function generateMainRoads() { - TIME && console.time("generateMainRoads"); - const mainRoads = []; - - for (const [key, featureCapitals] of Object.entries(capitalsByFeature)) { - const points = featureCapitals.map(burg => [burg.x, burg.y]); - const urquhartEdges = calculateUrquhartEdges(points); - urquhartEdges.forEach(([fromId, toId]) => { - const start = featureCapitals[fromId].cell; - const exit = featureCapitals[toId].cell; - - const segments = findPathSegments({isWater: false, connections, start, exit}); - for (const segment of segments) { - addConnections(segment); - mainRoads.push({feature: Number(key), cells: segment}); - } - }); - } - - TIME && console.timeEnd("generateMainRoads"); - return mainRoads; - } - - function generateTrails() { - TIME && console.time("generateTrails"); - const trails = []; - - for (const [key, featureBurgs] of Object.entries(burgsByFeature)) { - const points = featureBurgs.map(burg => [burg.x, burg.y]); - const urquhartEdges = calculateUrquhartEdges(points); - urquhartEdges.forEach(([fromId, toId]) => { - const start = featureBurgs[fromId].cell; - const exit = featureBurgs[toId].cell; - - const segments = findPathSegments({isWater: false, connections, start, exit}); - for (const segment of segments) { - addConnections(segment); - trails.push({feature: Number(key), cells: segment}); - } - }); - } - - TIME && console.timeEnd("generateTrails"); - return trails; - } - - function generateSeaRoutes() { - TIME && console.time("generateSeaRoutes"); - const seaRoutes = []; - - for (const [featureId, featurePorts] of Object.entries(portsByFeature)) { - const points = featurePorts.map(burg => [burg.x, burg.y]); - const urquhartEdges = calculateUrquhartEdges(points); - - urquhartEdges.forEach(([fromId, toId]) => { - const start = featurePorts[fromId].cell; - const exit = featurePorts[toId].cell; - const segments = findPathSegments({isWater: true, connections, start, exit}); - for (const segment of segments) { - addConnections(segment); - seaRoutes.push({feature: Number(featureId), cells: segment}); - } - }); - } - - TIME && console.timeEnd("generateSeaRoutes"); - return seaRoutes; - } - - function addConnections(segment) { - for (let i = 0; i < segment.length; i++) { - const cellId = segment[i]; - const nextCellId = segment[i + 1]; - if (nextCellId) { - connections.set(`${cellId}-${nextCellId}`, true); - connections.set(`${nextCellId}-${cellId}`, true); - } - } - } - - function findPathSegments({isWater, connections, start, exit}) { - const getCost = createCostEvaluator({isWater, connections}); - const pathCells = findPath(start, current => current === exit, getCost); - if (!pathCells) return []; - const segments = getRouteSegments(pathCells, connections); - return segments; - } - - function createRoutesData(routes) { - const pointsArray = preparePointsArray(); - - for (const {feature, cells, merged} of mergeRoutes(mainRoads)) { - if (merged) continue; - const points = getPoints("roads", cells, pointsArray); - routes.push({i: routes.length, group: "roads", feature, points}); - } - - for (const {feature, cells, merged} of mergeRoutes(trails)) { - if (merged) continue; - const points = getPoints("trails", cells, pointsArray); - routes.push({i: routes.length, group: "trails", feature, points}); - } - - for (const {feature, cells, merged} of mergeRoutes(seaRoutes)) { - if (merged) continue; - const points = getPoints("searoutes", cells, pointsArray); - routes.push({i: routes.length, group: "searoutes", feature, points}); - } - - return routes; - } - - // merge routes so that the last cell of one route is the first cell of the next route - function mergeRoutes(routes) { - let routesMerged = 0; - - for (let i = 0; i < routes.length; i++) { - const thisRoute = routes[i]; - if (thisRoute.merged) continue; - - for (let j = i + 1; j < routes.length; j++) { - const nextRoute = routes[j]; - if (nextRoute.merged) continue; - - if (nextRoute.cells.at(0) === thisRoute.cells.at(-1)) { - routesMerged++; - thisRoute.cells = thisRoute.cells.concat(nextRoute.cells.slice(1)); - nextRoute.merged = true; - } - } - } - - return routesMerged > 1 ? mergeRoutes(routes) : routes; - } - } - - function createCostEvaluator({isWater, connections}) { - return isWater ? getWaterPathCost : getLandPathCost; - - function getLandPathCost(current, next) { - if (pack.cells.h[next] < 20) return Infinity; // ignore water cells - - const habitability = biomesData.habitability[pack.cells.biome[next]]; - if (!habitability) return Infinity; // inhabitable cells are not passable (e.g. glacier) - - const distanceCost = dist2(pack.cells.p[current], pack.cells.p[next]); - const habitabilityModifier = 1 + Math.max(100 - habitability, 0) / 1000; // [1, 1.1]; - const heightModifier = 1 + Math.max(pack.cells.h[next] - 25, 25) / 25; // [1, 3]; - const connectionModifier = connections.has(`${current}-${next}`) ? 0.5 : 1; - const burgModifier = pack.cells.burg[next] ? 1 : 3; - - const pathCost = distanceCost * habitabilityModifier * heightModifier * connectionModifier * burgModifier; - return pathCost; - } - - function getWaterPathCost(current, next) { - if (pack.cells.h[next] >= 20) return Infinity; // ignore land cells - if (grid.cells.temp[pack.cells.g[next]] < MIN_PASSABLE_SEA_TEMP) return Infinity; // ignore too cold cells - - const distanceCost = dist2(pack.cells.p[current], pack.cells.p[next]); - const typeModifier = ROUTE_TYPE_MODIFIERS[pack.cells.t[next]] || ROUTE_TYPE_MODIFIERS.default; - const connectionModifier = connections.has(`${current}-${next}`) ? 0.5 : 1; - - const pathCost = distanceCost * typeModifier * connectionModifier; - return pathCost; - } - } - - function buildLinks(routes) { - const links = {}; - - for (const {points, i: routeId} of routes) { - const cells = points.map(p => p[2]); - - for (let i = 0; i < cells.length - 1; i++) { - const cellId = cells[i]; - const nextCellId = cells[i + 1]; - - if (cellId !== nextCellId) { - if (!links[cellId]) links[cellId] = {}; - links[cellId][nextCellId] = routeId; - - if (!links[nextCellId]) links[nextCellId] = {}; - links[nextCellId][cellId] = routeId; - } - } - } - - return links; - } - - function preparePointsArray() { - const {cells, burgs} = pack; - return cells.p.map(([x, y], cellId) => { - const burgId = cells.burg[cellId]; - if (burgId) return [burgs[burgId].x, burgs[burgId].y]; - return [x, y]; - }); - } - - function getPoints(group, cells, points) { - const data = cells.map(cellId => [...points[cellId], cellId]); - - // resolve sharp angles - if (group !== "searoutes") { - for (let i = 1; i < cells.length - 1; i++) { - const cellId = cells[i]; - if (pack.cells.burg[cellId]) continue; - - const [prevX, prevY] = data[i - 1]; - const [currX, currY] = data[i]; - const [nextX, nextY] = data[i + 1]; - - const dAx = prevX - currX; - const dAy = prevY - currY; - const dBx = nextX - currX; - const dBy = nextY - currY; - const angle = Math.abs((Math.atan2(dAx * dBy - dAy * dBx, dAx * dBx + dAy * dBy) * 180) / Math.PI); - - if (angle < ROUTES_SHARP_ANGLE) { - const middleX = (prevX + nextX) / 2; - const middleY = (prevY + nextY) / 2; - let newX, newY; - - if (angle < ROUTES_VERY_SHARP_ANGLE) { - newX = rn((currX + middleX * 2) / 3, 2); - newY = rn((currY + middleY * 2) / 3, 2); - } else { - newX = rn((currX + middleX) / 2, 2); - newY = rn((currY + middleY) / 2, 2); - } - - if (findCell(newX, newY) === cellId) { - data[i] = [newX, newY, cellId]; - points[cellId] = [data[i][0], data[i][1]]; // change cell coordinate for all routes - } - } - } - } - - return data; // [[x, y, cell], [x, y, cell]]; - } - - function getRouteSegments(pathCells, connections) { - const segments = []; - let segment = []; - - for (let i = 0; i < pathCells.length; i++) { - const cellId = pathCells[i]; - const nextCellId = pathCells[i + 1]; - const isConnected = connections.has(`${cellId}-${nextCellId}`) || connections.has(`${nextCellId}-${cellId}`); - - if (isConnected) { - if (segment.length) { - // segment stepped into existing segment - segment.push(pathCells[i]); - segments.push(segment); - segment = []; - } - continue; - } - - segment.push(pathCells[i]); - } - - if (segment.length > 1) segments.push(segment); - - return segments; - } - - // Urquhart graph is obtained by removing the longest edge from each triangle in the Delaunay triangulation - // this gives us an aproximation of a desired road network, i.e. connections between burgs - // code from https://observablehq.com/@mbostock/urquhart-graph - function calculateUrquhartEdges(points) { - const score = (p0, p1) => dist2(points[p0], points[p1]); - - const {halfedges, triangles} = Delaunator.from(points); - const n = triangles.length; - - const removed = new Uint8Array(n); - const edges = []; - - for (let e = 0; e < n; e += 3) { - const p0 = triangles[e], - p1 = triangles[e + 1], - p2 = triangles[e + 2]; - - const p01 = score(p0, p1), - p12 = score(p1, p2), - p20 = score(p2, p0); - - removed[ - p20 > p01 && p20 > p12 - ? Math.max(e + 2, halfedges[e + 2]) - : p12 > p01 && p12 > p20 - ? Math.max(e + 1, halfedges[e + 1]) - : Math.max(e, halfedges[e]) - ] = 1; - } - - for (let e = 0; e < n; ++e) { - if (e > halfedges[e] && !removed[e]) { - const t0 = triangles[e]; - const t1 = triangles[e % 3 === 2 ? e - 2 : e + 1]; - edges.push([t0, t1]); - } - } - - return edges; - } - - // connect cell with routes system by land - function connect(cellId) { - const getCost = createCostEvaluator({isWater: false, connections: new Map()}); - const isExit = c => isLand(c) && isConnected(c); - const pathCells = findPath(cellId, isExit, getCost); - if (!pathCells) return; - - const pointsArray = preparePointsArray(); - const points = getPoints("trails", pathCells, pointsArray); - const feature = pack.cells.f[cellId]; - const routeId = getNextId(); - const newRoute = {i: routeId, group: "trails", feature, points}; - pack.routes.push(newRoute); - - for (let i = 0; i < pathCells.length; i++) { - const currentCell = pathCells[i]; - const nextCellId = pathCells[i + 1]; - if (nextCellId) addConnection(currentCell, nextCellId, routeId); - } - - return newRoute; - - function addConnection(from, to, routeId) { - const routes = pack.cells.routes; - - if (!routes[from]) routes[from] = {}; - routes[from][to] = routeId; - - if (!routes[to]) routes[to] = {}; - routes[to][from] = routeId; - } - } - - // utility functions - function isConnected(cellId) { - const routes = pack.cells.routes; - return routes[cellId] && Object.keys(routes[cellId]).length > 0; - } - - function areConnected(from, to) { - const routeId = pack.cells.routes[from]?.[to]; - return routeId !== undefined; - } - - function getRoute(from, to) { - const routeId = pack.cells.routes[from]?.[to]; - if (routeId === undefined) return null; - - const route = pack.routes.find(route => route.i === routeId); - if (!route) return null; - - return route; - } - - function hasRoad(cellId) { - const connections = pack.cells.routes[cellId]; - if (!connections) return false; - - return Object.values(connections).some(routeId => { - const route = pack.routes.find(route => route.i === routeId); - if (!route) return false; - return route.group === "roads"; - }); - } - - function isCrossroad(cellId) { - const connections = pack.cells.routes[cellId]; - if (!connections) return false; - if (Object.keys(connections).length > 3) return true; - const roadConnections = Object.values(connections).filter(routeId => { - const route = pack.routes.find(route => route.i === routeId); - return route?.group === "roads"; - }); - return roadConnections.length > 2; - } - - const connectivityRateMap = { - roads: 0.2, - trails: 0.1, - searoutes: 0.2, - default: 0.1 - }; - - function getConnectivityRate(cellId) { - const connections = pack.cells.routes[cellId]; - if (!connections) return 0; - - const connectivity = Object.values(connections).reduce((acc, routeId) => { - const route = pack.routes.find(route => route.i === routeId); - if (!route) return acc; - const rate = connectivityRateMap[route.group] || connectivityRateMap.default; - return acc + rate; - }, 0.8); - - return connectivity; - } - - // name generator data - const models = { - roads: {burg_suffix: 3, prefix_suffix: 6, the_descriptor_prefix_suffix: 2, the_descriptor_burg_suffix: 1}, - trails: {burg_suffix: 8, prefix_suffix: 1, the_descriptor_burg_suffix: 1}, - searoutes: {burg_suffix: 4, prefix_suffix: 2, the_descriptor_prefix_suffix: 1} - }; - - const prefixes = [ - "King", - "Queen", - "Military", - "Old", - "New", - "Ancient", - "Royal", - "Imperial", - "Great", - "Grand", - "High", - "Silver", - "Dragon", - "Shadow", - "Star", - "Mystic", - "Whisper", - "Eagle", - "Golden", - "Crystal", - "Enchanted", - "Frost", - "Moon", - "Sun", - "Thunder", - "Phoenix", - "Sapphire", - "Celestial", - "Wandering", - "Echo", - "Twilight", - "Crimson", - "Serpent", - "Iron", - "Forest", - "Flower", - "Whispering", - "Eternal", - "Frozen", - "Rain", - "Luminous", - "Stardust", - "Arcane", - "Glimmering", - "Jade", - "Ember", - "Azure", - "Gilded", - "Divine", - "Shadowed", - "Cursed", - "Moonlit", - "Sable", - "Everlasting", - "Amber", - "Nightshade", - "Wraith", - "Scarlet", - "Platinum", - "Whirlwind", - "Obsidian", - "Ethereal", - "Ghost", - "Spike", - "Dusk", - "Raven", - "Spectral", - "Burning", - "Verdant", - "Copper", - "Velvet", - "Falcon", - "Enigma", - "Glowing", - "Silvered", - "Molten", - "Radiant", - "Astral", - "Wild", - "Flame", - "Amethyst", - "Aurora", - "Shadowy", - "Solar", - "Lunar", - "Whisperwind", - "Fading", - "Titan", - "Dawn", - "Crystalline", - "Jeweled", - "Sylvan", - "Twisted", - "Ebon", - "Thorn", - "Cerulean", - "Halcyon", - "Infernal", - "Storm", - "Eldritch", - "Sapphire", - "Crimson", - "Tranquil", - "Paved" - ]; - - const descriptors = [ - "Great", - "Shrouded", - "Sacred", - "Fabled", - "Frosty", - "Winding", - "Echoing", - "Serpentine", - "Breezy", - "Misty", - "Rustic", - "Silent", - "Cobbled", - "Cracked", - "Shaky", - "Obscure" - ]; - - const suffixes = { - roads: {road: 7, route: 3, way: 2, highway: 1}, - trails: {trail: 4, path: 1, track: 1, pass: 1}, - searoutes: {"sea route": 5, lane: 2, passage: 1, seaway: 1} - }; - - function generateName({group, points}) { - if (points.length < 4) return "Unnamed route segment"; - - const model = rw(models[group]); - const suffix = rw(suffixes[group]); - - const burgName = getBurgName(); - if (model === "burg_suffix" && burgName) return `${burgName} ${suffix}`; - if (model === "prefix_suffix") return `${ra(prefixes)} ${suffix}`; - if (model === "the_descriptor_prefix_suffix") return `The ${ra(descriptors)} ${ra(prefixes)} ${suffix}`; - if (model === "the_descriptor_burg_suffix" && burgName) return `The ${ra(descriptors)} ${burgName} ${suffix}`; - return "Unnamed route"; - - function getBurgName() { - const priority = [points.at(-1), points.at(0), points.slice(1, -1).reverse()]; - for (const [_x, _y, cellId] of priority) { - const burgId = pack.cells.burg[cellId]; - if (burgId) return getAdjective(pack.burgs[burgId].name); - } - return null; - } - } - - const ROUTE_CURVES = { - roads: d3.curveCatmullRom.alpha(0.1), - trails: d3.curveCatmullRom.alpha(0.1), - searoutes: d3.curveCatmullRom.alpha(0.5), - default: d3.curveCatmullRom.alpha(0.1) - }; - - function getPath({group, points}) { - const lineGen = d3.line(); - lineGen.curve(ROUTE_CURVES[group] || ROUTE_CURVES.default); - const path = round(lineGen(points.map(p => [p[0], p[1]])), 1); - return path; - } - - function getLength(routeId) { - const path = routes.select("#route" + routeId).node(); - return path.getTotalLength(); - } - - function getNextId() { - return pack.routes.length ? Math.max(...pack.routes.map(r => r.i)) + 1 : 0; - } - - function remove(route) { - const routes = pack.cells.routes; - - for (const point of route.points) { - const from = point[2]; - if (!routes[from]) continue; - - for (const [to, routeId] of Object.entries(routes[from])) { - if (routeId === route.i) { - delete routes[from][to]; - delete routes[to][from]; - } - } - } - - pack.routes = pack.routes.filter(r => r.i !== route.i); - viewbox.select("#route" + route.i).remove(); - } - - return { - generate, - buildLinks, - connect, - isConnected, - areConnected, - getRoute, - hasRoad, - isCrossroad, - getConnectivityRate, - generateName, - getPath, - getLength, - getNextId, - remove - }; -})(); diff --git a/public/modules/states-generator.js b/public/modules/states-generator.js deleted file mode 100644 index 9662e648..00000000 --- a/public/modules/states-generator.js +++ /dev/null @@ -1,640 +0,0 @@ -"use strict"; - -window.States = (() => { - const generate = () => { - TIME && console.time("generateStates"); - pack.states = createStates(); - expandStates(); - normalize(); - getPoles(); - findNeighbors(); - assignColors(); - generateCampaigns(); - generateDiplomacy(); - - TIME && console.timeEnd("generateStates"); - - // for each capital create a state - function createStates() { - const states = [{i: 0, name: "Neutrals"}]; - const each5th = each(5); - const sizeVariety = byId("sizeVariety").valueAsNumber; - - pack.burgs.forEach(burg => { - if (!burg.i || !burg.capital) return; - - const expansionism = rn(Math.random() * sizeVariety + 1, 1); - const basename = burg.name.length < 9 && each5th(burg.cell) ? burg.name : Names.getCultureShort(burg.culture); - const name = Names.getState(basename, burg.culture); - const type = pack.cultures[burg.culture].type; - const coa = COA.generate(null, null, null, type); - coa.shield = COA.getShield(burg.culture, null); - states.push({ - i: burg.i, - name, - expansionism, - capital: burg.i, - type, - center: burg.cell, - culture: burg.culture, - coa - }); - }); - - return states; - } - }; - - // expand cultures across the map (Dijkstra-like algorithm) - const expandStates = () => { - TIME && console.time("expandStates"); - const {cells, states, cultures, burgs} = pack; - - cells.state = cells.state || new Uint16Array(cells.i.length); - - const queue = new FlatQueue(); - const cost = []; - - const globalGrowthRate = byId("growthRate").valueAsNumber || 1; - const statesGrowthRate = byId("statesGrowthRate")?.valueAsNumber || 1; - const growthRate = (cells.i.length / 2) * globalGrowthRate * statesGrowthRate; // limit cost for state growth - - // remove state from all cells except of locked - for (const cellId of cells.i) { - const state = states[cells.state[cellId]]; - if (state.lock) continue; - cells.state[cellId] = 0; - } - - for (const state of states) { - if (!state.i || state.removed) continue; - - const capitalCell = burgs[state.capital].cell; - cells.state[capitalCell] = state.i; - const cultureCenter = cultures[state.culture].center; - const b = cells.biome[cultureCenter]; // state native biome - queue.push({e: state.center, p: 0, s: state.i, b}, 0); - cost[state.center] = 1; - } - - while (queue.length) { - const next = queue.pop(); - - const {e, p, s, b} = next; - const {type, culture} = states[s]; - - cells.c[e].forEach(e => { - const state = states[cells.state[e]]; - if (state.lock) return; // do not overwrite cell of locked states - if (cells.state[e] && e === state.center) return; // do not overwrite capital cells - - const cultureCost = culture === cells.culture[e] ? -9 : 100; - const populationCost = cells.h[e] < 20 ? 0 : cells.s[e] ? Math.max(20 - cells.s[e], 0) : 5000; - const biomeCost = getBiomeCost(b, cells.biome[e], type); - const heightCost = getHeightCost(pack.features[cells.f[e]], cells.h[e], type); - const riverCost = getRiverCost(cells.r[e], e, type); - const typeCost = getTypeCost(cells.t[e], type); - const cellCost = Math.max(cultureCost + populationCost + biomeCost + heightCost + riverCost + typeCost, 0); - const totalCost = p + 10 + cellCost / states[s].expansionism; - - if (totalCost > growthRate) return; - - if (!cost[e] || totalCost < cost[e]) { - if (cells.h[e] >= 20) cells.state[e] = s; // assign state to cell - cost[e] = totalCost; - queue.push({e, p: totalCost, s, b}, totalCost); - } - }); - } - - burgs.filter(b => b.i && !b.removed).forEach(b => (b.state = cells.state[b.cell])); // assign state to burgs - - function getBiomeCost(b, biome, type) { - if (b === biome) return 10; // tiny penalty for native biome - if (type === "Hunting") return biomesData.cost[biome] * 2; // non-native biome penalty for hunters - if (type === "Nomadic" && biome > 4 && biome < 10) return biomesData.cost[biome] * 3; // forest biome penalty for nomads - return biomesData.cost[biome]; // general non-native biome penalty - } - - function getHeightCost(f, h, type) { - if (type === "Lake" && f.type === "lake") return 10; // low lake crossing penalty for Lake cultures - if (type === "Naval" && h < 20) return 300; // low sea crossing penalty for Navals - if (type === "Nomadic" && h < 20) return 10000; // giant sea crossing penalty for Nomads - if (h < 20) return 1000; // general sea crossing penalty - if (type === "Highland" && h < 62) return 1100; // penalty for highlanders on lowlands - if (type === "Highland") return 0; // no penalty for highlanders on highlands - if (h >= 67) return 2200; // general mountains crossing penalty - if (h >= 44) return 300; // general hills crossing penalty - return 0; - } - - function getRiverCost(r, i, type) { - if (type === "River") return r ? 0 : 100; // penalty for river cultures - if (!r) return 0; // no penalty for others if there is no river - return minmax(cells.fl[i] / 10, 20, 100); // river penalty from 20 to 100 based on flux - } - - function getTypeCost(t, type) { - if (t === 1) return type === "Naval" || type === "Lake" ? 0 : type === "Nomadic" ? 60 : 20; // penalty for coastline - if (t === 2) return type === "Naval" || type === "Nomadic" ? 30 : 0; // low penalty for land level 2 for Navals and nomads - if (t !== -1) return type === "Naval" || type === "Lake" ? 100 : 0; // penalty for mainland for navals - return 0; - } - - TIME && console.timeEnd("expandStates"); - }; - - const normalize = () => { - TIME && console.time("normalizeStates"); - const {cells, burgs} = pack; - - for (const i of cells.i) { - if (cells.h[i] < 20 || cells.burg[i]) continue; // do not overwrite burgs - if (pack.states[cells.state[i]]?.lock) continue; // do not overwrite cells of locks states - if (cells.c[i].some(c => burgs[cells.burg[c]].capital)) continue; // do not overwrite near capital - const neibs = cells.c[i].filter(c => cells.h[c] >= 20); - const adversaries = neibs.filter(c => !pack.states[cells.state[c]]?.lock && cells.state[c] !== cells.state[i]); - if (adversaries.length < 2) continue; - const buddies = neibs.filter(c => !pack.states[cells.state[c]]?.lock && cells.state[c] === cells.state[i]); - if (buddies.length > 2) continue; - if (adversaries.length <= buddies.length) continue; - cells.state[i] = cells.state[adversaries[0]]; - } - TIME && console.timeEnd("normalizeStates"); - }; - - // calculate pole of inaccessibility for each state - const getPoles = () => { - const getType = cellId => pack.cells.state[cellId]; - const poles = getPolesOfInaccessibility(pack, getType); - - pack.states.forEach(s => { - if (!s.i || s.removed) return; - s.pole = poles[s.i] || [0, 0]; - }); - }; - - const findNeighbors = () => { - const {cells, states} = pack; - - states.forEach(s => { - if (s.removed) return; - s.neighbors = new Set(); - }); - - for (const i of cells.i) { - if (cells.h[i] < 20) continue; - const s = cells.state[i]; - - cells.c[i] - .filter(c => cells.h[c] >= 20 && cells.state[c] !== s) - .forEach(c => states[s].neighbors.add(cells.state[c])); - } - - // convert neighbors Set object into array - states.forEach(s => { - if (!s.neighbors || s.removed) return; - s.neighbors = Array.from(s.neighbors); - }); - }; - - const assignColors = () => { - TIME && console.time("assignColors"); - const colors = ["#66c2a5", "#fc8d62", "#8da0cb", "#e78ac3", "#a6d854", "#ffd92f"]; // d3.schemeSet2; - const states = pack.states; - - // assign basic color using greedy coloring algorithm - states.forEach(state => { - if (!state.i || state.removed || state.lock) return; - state.color = colors.find(color => state.neighbors.every(neibStateId => states[neibStateId].color !== color)); - if (!state.color) state.color = getRandomColor(); - colors.push(colors.shift()); - }); - - // randomize each already used color a bit - colors.forEach(c => { - const sameColored = states.filter(state => state.color === c && state.i && !state.lock); - sameColored.forEach((state, index) => { - if (!index) return; - state.color = getMixedColor(state.color); - }); - }); - - TIME && console.timeEnd("assignColors"); - }; - - // calculate states data like area, population etc. - const collectStatistics = () => { - TIME && console.time("collectStatistics"); - const {cells, states} = pack; - - states.forEach(s => { - if (s.removed) return; - s.cells = s.area = s.burgs = s.rural = s.urban = 0; - }); - - for (const i of cells.i) { - if (cells.h[i] < 20) continue; - const s = cells.state[i]; - - // collect stats - states[s].cells += 1; - states[s].area += cells.area[i]; - states[s].rural += cells.pop[i]; - if (cells.burg[i]) { - states[s].urban += pack.burgs[cells.burg[i]].population; - states[s].burgs++; - } - } - - TIME && console.timeEnd("collectStatistics"); - }; - - const wars = { - War: 6, - Conflict: 2, - Campaign: 4, - Invasion: 2, - Rebellion: 2, - Conquest: 2, - Intervention: 1, - Expedition: 1, - Crusade: 1 - }; - - const generateCampaign = state => { - const neighbors = state.neighbors.length ? state.neighbors : [0]; - return neighbors - .map(i => { - const name = i && P(0.8) ? pack.states[i].name : Names.getCultureShort(state.culture); - const start = gauss(options.year - 100, 150, 1, options.year - 6); - const end = start + gauss(4, 5, 1, options.year - start - 1); - return {name: getAdjective(name) + " " + rw(wars), start, end}; - }) - .sort((a, b) => a.start - b.start); - }; - - // generate historical conflicts of each state - const generateCampaigns = () => { - pack.states.forEach(s => { - if (!s.i || s.removed) return; - s.campaigns = generateCampaign(s); - }); - }; - - // generate Diplomatic Relationships - const generateDiplomacy = () => { - TIME && console.time("generateDiplomacy"); - const {cells, states} = pack; - const chronicle = (states[0].diplomacy = []); - const valid = states.filter(s => s.i && !states.removed); - - const neibs = {Ally: 1, Friendly: 2, Neutral: 1, Suspicion: 10, Rival: 9}; // relations to neighbors - const neibsOfNeibs = {Ally: 10, Friendly: 8, Neutral: 5, Suspicion: 1}; // relations to neighbors of neighbors - const far = {Friendly: 1, Neutral: 12, Suspicion: 2, Unknown: 6}; // relations to other - const navals = {Neutral: 1, Suspicion: 2, Rival: 1, Unknown: 1}; // relations of naval powers - - valid.forEach(s => (s.diplomacy = new Array(states.length).fill("x"))); // clear all relationships - if (valid.length < 2) return; // no states to renerate relations with - const areaMean = d3.mean(valid.map(s => s.area)); // average state area - - // generic relations - for (let f = 1; f < states.length; f++) { - if (states[f].removed) continue; - - if (states[f].diplomacy.includes("Vassal")) { - // Vassals copy relations from their Suzerains - const suzerain = states[f].diplomacy.indexOf("Vassal"); - - for (let i = 1; i < states.length; i++) { - if (i === f || i === suzerain) continue; - states[f].diplomacy[i] = states[suzerain].diplomacy[i]; - if (states[suzerain].diplomacy[i] === "Suzerain") states[f].diplomacy[i] = "Ally"; - for (let e = 1; e < states.length; e++) { - if (e === f || e === suzerain) continue; - if (states[e].diplomacy[suzerain] === "Suzerain" || states[e].diplomacy[suzerain] === "Vassal") continue; - states[e].diplomacy[f] = states[e].diplomacy[suzerain]; - } - } - continue; - } - - for (let t = f + 1; t < states.length; t++) { - if (states[t].removed) continue; - - if (states[t].diplomacy.includes("Vassal")) { - const suzerain = states[t].diplomacy.indexOf("Vassal"); - states[f].diplomacy[t] = states[f].diplomacy[suzerain]; - continue; - } - - const naval = - states[f].type === "Naval" && - states[t].type === "Naval" && - cells.f[states[f].center] !== cells.f[states[t].center]; - const neib = naval ? false : states[f].neighbors.includes(t); - const neibOfNeib = - naval || neib - ? false - : states[f].neighbors - .map(n => states[n].neighbors) - .join("") - .includes(t); - - let status = naval ? rw(navals) : neib ? rw(neibs) : neibOfNeib ? rw(neibsOfNeibs) : rw(far); - - // add Vassal - if ( - neib && - P(0.8) && - states[f].area > areaMean && - states[t].area < areaMean && - states[f].area / states[t].area > 2 - ) - status = "Vassal"; - states[f].diplomacy[t] = status === "Vassal" ? "Suzerain" : status; - states[t].diplomacy[f] = status; - } - } - - // declare wars - for (let attacker = 1; attacker < states.length; attacker++) { - const ad = states[attacker].diplomacy; // attacker relations; - if (states[attacker].removed) continue; - if (!ad.includes("Rival")) continue; // no rivals to attack - if (ad.includes("Vassal")) continue; // not independent - if (ad.includes("Enemy")) continue; // already at war - - // random independent rival - const defender = ra( - ad.map((r, d) => (r === "Rival" && !states[d].diplomacy.includes("Vassal") ? d : 0)).filter(d => d) - ); - let ap = states[attacker].area * states[attacker].expansionism; - let dp = states[defender].area * states[defender].expansionism; - if (ap < dp * gauss(1.6, 0.8, 0, 10, 2)) continue; // defender is too strong - - const an = states[attacker].name; - const dn = states[defender].name; // names - const attackers = [attacker]; - const defenders = [defender]; // attackers and defenders array - const dd = states[defender].diplomacy; // defender relations; - - // start an ongoing war - const name = `${an}-${trimVowels(dn)}ian War`; - const start = options.year - gauss(2, 3, 0, 10); - const war = [name, `${an} declared a war on its rival ${dn}`]; - const campaign = {name, start, attacker, defender}; - states[attacker].campaigns.push(campaign); - states[defender].campaigns.push(campaign); - - // attacker vassals join the war - ad.forEach((r, d) => { - if (r === "Suzerain") { - attackers.push(d); - war.push(`${an}'s vassal ${states[d].name} joined the war on attackers side`); - } - }); - - // defender vassals join the war - dd.forEach((r, d) => { - if (r === "Suzerain") { - defenders.push(d); - war.push(`${dn}'s vassal ${states[d].name} joined the war on defenders side`); - } - }); - - ap = d3.sum(attackers.map(a => states[a].area * states[a].expansionism)); // attackers joined power - dp = d3.sum(defenders.map(d => states[d].area * states[d].expansionism)); // defender joined power - - // defender allies join - dd.forEach((r, d) => { - if (r !== "Ally" || states[d].diplomacy.includes("Vassal")) return; - if (states[d].diplomacy[attacker] !== "Rival" && ap / dp > 2 * gauss(1.6, 0.8, 0, 10, 2)) { - const reason = states[d].diplomacy.includes("Enemy") ? "Being already at war," : `Frightened by ${an},`; - war.push(`${reason} ${states[d].name} severed the defense pact with ${dn}`); - dd[d] = states[d].diplomacy[defender] = "Suspicion"; - return; - } - defenders.push(d); - dp += states[d].area * states[d].expansionism; - war.push(`${dn}'s ally ${states[d].name} joined the war on defenders side`); - - // ally vassals join - states[d].diplomacy - .map((r, d) => (r === "Suzerain" ? d : 0)) - .filter(d => d) - .forEach(v => { - defenders.push(v); - dp += states[v].area * states[v].expansionism; - war.push(`${states[d].name}'s vassal ${states[v].name} joined the war on defenders side`); - }); - }); - - // attacker allies join if the defender is their rival or joined power > defenders power and defender is not an ally - ad.forEach((r, d) => { - if (r !== "Ally" || states[d].diplomacy.includes("Vassal") || defenders.includes(d)) return; - const name = states[d].name; - if (states[d].diplomacy[defender] !== "Rival" && (P(0.2) || ap <= dp * 1.2)) { - war.push(`${an}'s ally ${name} avoided entering the war`); - return; - } - const allies = states[d].diplomacy.map((r, d) => (r === "Ally" ? d : 0)).filter(d => d); - if (allies.some(ally => defenders.includes(ally))) { - war.push(`${an}'s ally ${name} did not join the war as its allies are in war on both sides`); - return; - } - - attackers.push(d); - ap += states[d].area * states[d].expansionism; - war.push(`${an}'s ally ${name} joined the war on attackers side`); - - // ally vassals join - states[d].diplomacy - .map((r, d) => (r === "Suzerain" ? d : 0)) - .filter(d => d) - .forEach(v => { - attackers.push(v); - dp += states[v].area * states[v].expansionism; - war.push(`${states[d].name}'s vassal ${states[v].name} joined the war on attackers side`); - }); - }); - - // change relations to Enemy for all participants - attackers.forEach(a => defenders.forEach(d => (states[a].diplomacy[d] = states[d].diplomacy[a] = "Enemy"))); - chronicle.push(war); // add a record to diplomatical history - } - - TIME && console.timeEnd("generateDiplomacy"); - }; - - // select a forms for listed or all valid states - const defineStateForms = list => { - TIME && console.time("defineStateForms"); - const states = pack.states.filter(s => s.i && !s.removed && !s.lock); - if (states.length < 1) return; - - const generic = {Monarchy: 25, Republic: 2, Union: 1}; - const naval = {Monarchy: 25, Republic: 8, Union: 3}; - - const median = d3.median(pack.states.map(s => s.area)); - const empireMin = states.map(s => s.area).sort((a, b) => b - a)[Math.max(Math.ceil(states.length ** 0.4) - 2, 0)]; - const expTiers = pack.states.map(s => { - let tier = Math.min(Math.floor((s.area / median) * 2.6), 4); - if (tier === 4 && s.area < empireMin) tier = 3; - return tier; - }); - - const monarchy = ["Duchy", "Grand Duchy", "Principality", "Kingdom", "Empire"]; // per expansionism tier - const republic = { - Republic: 75, - Federation: 4, - "Trade Company": 4, - "Most Serene Republic": 2, - Oligarchy: 2, - Tetrarchy: 1, - Triumvirate: 1, - Diarchy: 1, - Junta: 1 - }; // weighted random - const union = { - Union: 3, - League: 4, - Confederation: 1, - "United Kingdom": 1, - "United Republic": 1, - "United Provinces": 2, - Commonwealth: 1, - Heptarchy: 1 - }; // weighted random - const theocracy = {Theocracy: 20, Brotherhood: 1, Thearchy: 2, See: 1, "Holy State": 1}; - const anarchy = {"Free Territory": 2, Council: 3, Commune: 1, Community: 1}; - - for (const s of states) { - if (list && !list.includes(s.i)) continue; - const tier = expTiers[s.i]; - - const religion = pack.cells.religion[s.center]; - const isTheocracy = - (religion && pack.religions[religion].expansion === "state") || - (P(0.1) && ["Organized", "Cult"].includes(pack.religions[religion].type)); - const isAnarchy = P(0.01 - tier / 500); - - if (isTheocracy) s.form = "Theocracy"; - else if (isAnarchy) s.form = "Anarchy"; - else s.form = s.type === "Naval" ? rw(naval) : rw(generic); - s.formName = selectForm(s, tier); - s.fullName = getFullName(s); - } - - function selectForm(s, tier) { - const base = pack.cultures[s.culture].base; - - if (s.form === "Monarchy") { - const form = monarchy[tier]; - // Default name depends on exponent tier, some culture bases have special names for tiers - if (s.diplomacy) { - if ( - form === "Duchy" && - s.neighbors.length > 1 && - rand(6) < s.neighbors.length && - s.diplomacy.includes("Vassal") - ) - return "Marches"; // some vassal duchies on borderland - if (base === 1 && P(0.3) && s.diplomacy.includes("Vassal")) return "Dominion"; // English vassals - if (P(0.3) && s.diplomacy.includes("Vassal")) return "Protectorate"; // some vassals - } - - if (base === 31 && (form === "Empire" || form === "Kingdom")) return "Khanate"; // Mongolian - if (base === 16 && form === "Principality") return "Beylik"; // Turkic - if (base === 5 && (form === "Empire" || form === "Kingdom")) return "Tsardom"; // Ruthenian - if (base === 16 && (form === "Empire" || form === "Kingdom")) return "Khaganate"; // Turkic - if (base === 12 && (form === "Kingdom" || form === "Grand Duchy")) return "Shogunate"; // Japanese - if ([18, 17].includes(base) && form === "Empire") return "Caliphate"; // Arabic, Berber - if (base === 18 && (form === "Grand Duchy" || form === "Duchy")) return "Emirate"; // Arabic - if (base === 7 && (form === "Grand Duchy" || form === "Duchy")) return "Despotate"; // Greek - if (base === 31 && (form === "Grand Duchy" || form === "Duchy")) return "Ulus"; // Mongolian - if (base === 16 && (form === "Grand Duchy" || form === "Duchy")) return "Horde"; // Turkic - if (base === 24 && (form === "Grand Duchy" || form === "Duchy")) return "Satrapy"; // Iranian - return form; - } - - if (s.form === "Republic") { - // Default name is from weighted array, special case for small states with only 1 burg - if (tier < 2 && s.burgs === 1) { - if (trimVowels(s.name) === trimVowels(pack.burgs[s.capital].name)) { - s.name = pack.burgs[s.capital].name; - return "Free City"; - } - if (P(0.3)) return "City-state"; - } - return rw(republic); - } - - if (s.form === "Union") return rw(union); - if (s.form === "Anarchy") return rw(anarchy); - - if (s.form === "Theocracy") { - // European - if ([0, 1, 2, 3, 4, 6, 8, 9, 13, 15, 20].includes(base)) { - if (P(0.1)) return "Divine " + monarchy[tier]; - if (tier < 2 && P(0.5)) return "Diocese"; - if (tier < 2 && P(0.5)) return "Bishopric"; - } - if (P(0.9) && [7, 5].includes(base)) { - // Greek, Ruthenian - if (tier < 2) return "Eparchy"; - if (tier === 2) return "Exarchate"; - if (tier > 2) return "Patriarchate"; - } - if (P(0.9) && [21, 16].includes(base)) return "Imamah"; // Nigerian, Turkish - if (tier > 2 && P(0.8) && [18, 17, 28].includes(base)) return "Caliphate"; // Arabic, Berber, Swahili - return rw(theocracy); - } - } - - TIME && console.timeEnd("defineStateForms"); - }; - - // state forms requiring Adjective + Name, all other forms use scheme Form + Of + Name - const adjForms = [ - "Empire", - "Sultanate", - "Khaganate", - "Shogunate", - "Caliphate", - "Despotate", - "Theocracy", - "Oligarchy", - "Union", - "Confederation", - "Trade Company", - "League", - "Tetrarchy", - "Triumvirate", - "Diarchy", - "Horde", - "Marches" - ]; - - const getFullName = state => { - if (!state.formName) return state.name; - if (!state.name && state.formName) return "The " + state.formName; - const adjName = adjForms.includes(state.formName) && !/-| /.test(state.name); - return adjName ? `${getAdjective(state.name)} ${state.formName}` : `${state.formName} of ${state.name}`; - }; - - return { - generate, - expandStates, - normalize, - getPoles, - findNeighbors, - assignColors, - collectStatistics, - generateCampaign, - generateCampaigns, - generateDiplomacy, - defineStateForms, - getFullName - }; -})(); diff --git a/public/modules/ui/biomes-editor.js b/public/modules/ui/biomes-editor.js index 8c50993d..125aa0da 100644 --- a/public/modules/ui/biomes-editor.js +++ b/public/modules/ui/biomes-editor.js @@ -136,11 +136,13 @@ function editBiomes() { body.innerHTML = lines; // update footer + const totalMapArea = getArea(d3.sum(pack.cells.area)); biomesFooterBiomes.innerHTML = body.querySelectorAll(":scope > div").length; biomesFooterCells.innerHTML = pack.cells.h.filter(h => h >= 20).length; biomesFooterArea.innerHTML = si(totalArea) + unit; biomesFooterPopulation.innerHTML = si(totalPopulation); biomesFooterArea.dataset.area = totalArea; + biomesFooterArea.dataset.mapArea = totalMapArea; biomesFooterPopulation.dataset.population = totalPopulation; // add listeners @@ -255,6 +257,7 @@ function editBiomes() { body.dataset.type = "percentage"; const totalCells = +biomesFooterCells.innerHTML; const totalArea = +biomesFooterArea.dataset.area; + const totalMapArea = +biomesFooterArea.dataset.mapArea; const totalPopulation = +biomesFooterPopulation.dataset.population; body.querySelectorAll(":scope> div").forEach(function (el) { @@ -262,6 +265,9 @@ function editBiomes() { el.querySelector(".biomeArea").innerHTML = rn((+el.dataset.area / totalArea) * 100) + "%"; el.querySelector(".biomePopulation").innerHTML = rn((+el.dataset.population / totalPopulation) * 100) + "%"; }); + + // update footer to show land percentage of total map + biomesFooterArea.innerHTML = rn((totalArea / totalMapArea) * 100) + "%"; } else { body.dataset.type = "absolute"; biomesEditorAddLines(); diff --git a/public/modules/ui/editors.js b/public/modules/ui/editors.js index 77c391ee..50eaf1c7 100644 --- a/public/modules/ui/editors.js +++ b/public/modules/ui/editors.js @@ -26,7 +26,7 @@ function clicked() { else if (ancestor.id === "labels" && el.tagName === "tspan") editLabel(); else if (grand.id === "burgLabels") editBurg(); else if (grand.id === "burgIcons") editBurg(); - else if (parent.id === "ice") editIce(); + else if (parent.id === "ice") editIce(el); else if (parent.id === "terrain") editReliefIcon(); else if (grand.id === "markers" || great.id === "markers") editMarker(); else if (grand.id === "coastline") editCoastline(); diff --git a/public/modules/ui/heightmap-editor.js b/public/modules/ui/heightmap-editor.js index d655e39d..6e76c4bb 100644 --- a/public/modules/ui/heightmap-editor.js +++ b/public/modules/ui/heightmap-editor.js @@ -259,6 +259,8 @@ function editHeightmap(options) { Rivers.specify(); Lakes.defineNames(); + Ice.generate(); + Military.generate(); Markers.generate(); Zones.generate(); @@ -465,6 +467,10 @@ function editHeightmap(options) { .attr("id", d => base + d); }); + // recalculate ice + Ice.generate(); + ice.selectAll("*").remove(); + TIME && console.timeEnd("restoreRiskedData"); INFO && console.groupEnd("Edit Heightmap"); } @@ -669,7 +675,7 @@ function editHeightmap(options) { if (power === 0) return tip("Power should not be zero", false, "error"); const heights = grid.cells.h; - const operation = power > 0 ? HeightmapGenerator.addRange : HeightmapGenerator.addTrough; + const operation = power > 0 ? HeightmapGenerator.addRange.bind(HeightmapGenerator) : HeightmapGenerator.addTrough.bind(HeightmapGenerator); HeightmapGenerator.setGraph(grid); operation("1", String(Math.abs(power)), null, null, fromCell, toCell); const changedHeights = HeightmapGenerator.getHeights(); diff --git a/public/modules/ui/ice-editor.js b/public/modules/ui/ice-editor.js index a9e6ff28..16818b4c 100644 --- a/public/modules/ui/ice-editor.js +++ b/public/modules/ui/ice-editor.js @@ -1,26 +1,32 @@ "use strict"; -function editIce() { +function editIce(element) { if (customization) return; + if (elSelected && element === elSelected.node()) return; + closeDialogs(".stable"); if (!layerIsOn("toggleIce")) toggleIce(); elSelected = d3.select(d3.event.target); - const type = elSelected.attr("type") ? "Glacier" : "Iceberg"; - document.getElementById("iceRandomize").style.display = type === "Glacier" ? "none" : "inline-block"; - document.getElementById("iceSize").style.display = type === "Glacier" ? "none" : "inline-block"; - if (type === "Iceberg") document.getElementById("iceSize").value = +elSelected.attr("size"); + const id = +elSelected.attr("data-id"); + const iceElement = pack.ice.find(el => el.i === id); + const isGlacier = elSelected.attr("type") === "glacier"; + const type = isGlacier ? "Glacier" : "Iceberg"; + + document.getElementById("iceRandomize").style.display = isGlacier ? "none" : "inline-block"; + document.getElementById("iceSize").style.display = isGlacier ? "none" : "inline-block"; + if (!isGlacier) document.getElementById("iceSize").value = iceElement?.size || ""; + ice.selectAll("*").classed("draggable", true).call(d3.drag().on("drag", dragElement)); $("#iceEditor").dialog({ title: "Edit " + type, resizable: false, - position: {my: "center top+60", at: "top", of: d3.event, collision: "fit"}, + position: { my: "center top+60", at: "top", of: d3.event, collision: "fit" }, close: closeEditor }); if (modules.editIce) return; modules.editIce = true; - // add listeners document.getElementById("iceEditStyle").addEventListener("click", () => editStyle("ice")); document.getElementById("iceRandomize").addEventListener("click", randomizeShape); @@ -28,29 +34,18 @@ function editIce() { document.getElementById("iceNew").addEventListener("click", toggleAdd); document.getElementById("iceRemove").addEventListener("click", removeIce); + function randomizeShape() { - const c = grid.points[+elSelected.attr("cell")]; - const s = +elSelected.attr("size"); - const i = ra(grid.cells.i), - cn = grid.points[i]; - const poly = getGridPolygon(i).map(p => [p[0] - cn[0], p[1] - cn[1]]); - const points = poly.map(p => [rn(c[0] + p[0] * s, 2), rn(c[1] + p[1] * s, 2)]); - elSelected.attr("points", points); + const selectedId = +elSelected.attr("data-id"); + Ice.randomizeIcebergShape(selectedId); + redrawIceberg(selectedId); } function changeSize() { - const c = grid.points[+elSelected.attr("cell")]; - const s = +elSelected.attr("size"); - const flat = elSelected - .attr("points") - .split(",") - .map(el => +el); - const pairs = []; - while (flat.length) pairs.push(flat.splice(0, 2)); - const poly = pairs.map(p => [(p[0] - c[0]) / s, (p[1] - c[1]) / s]); - const size = +this.value; - const points = poly.map(p => [rn(c[0] + p[0] * size, 2), rn(c[1] + p[1] * size, 2)]); - elSelected.attr("points", points).attr("size", size); + const newSize = +this.value; + const selectedId = +elSelected.attr("data-id"); + Ice.changeIcebergSize(selectedId, newSize); + redrawIceberg(selectedId); } function toggleAdd() { @@ -67,17 +62,15 @@ function editIce() { function addIcebergOnClick() { const [x, y] = d3.mouse(this); const i = findGridCell(x, y, grid); - const [cx, cy] = grid.points[i]; const size = +document.getElementById("iceSize")?.value || 1; - const points = getGridPolygon(i).map(([x, y]) => [rn(lerp(cx, x, size), 2), rn(lerp(cy, y, size), 2)]); - const iceberg = ice.append("polygon").attr("points", points).attr("cell", i).attr("size", size); - iceberg.call(d3.drag().on("drag", dragElement)); + Ice.addIceberg(i, size); + if (d3.event.shiftKey === false) toggleAdd(); } function removeIce() { - const type = elSelected.attr("type") ? "Glacier" : "Iceberg"; + const type = elSelected.attr("type") === "glacier" ? "Glacier" : "Iceberg"; alertMessage.innerHTML = /* html */ `Are you sure you want to remove the ${type}?`; $("#alert").dialog({ resizable: false, @@ -85,7 +78,7 @@ function editIce() { buttons: { Remove: function () { $(this).dialog("close"); - elSelected.remove(); + Ice.removeIce(+elSelected.attr("data-id")); $("#iceEditor").dialog("close"); }, Cancel: function () { @@ -96,14 +89,24 @@ function editIce() { } function dragElement() { - const tr = parseTransform(this.getAttribute("transform")); - const dx = +tr[0] - d3.event.x, - dy = +tr[1] - d3.event.y; + const selectedId = +elSelected.attr("data-id"); + const initialTransform = parseTransform(this.getAttribute("transform")); + const dx = initialTransform[0] - d3.event.x; + const dy = initialTransform[1] - d3.event.y; d3.event.on("drag", function () { - const x = d3.event.x, - y = d3.event.y; - this.setAttribute("transform", `translate(${dx + x},${dy + y})`); + const x = d3.event.x; + const y = d3.event.y; + const transform = `translate(${dx + x},${dy + y})`; + this.setAttribute("transform", transform); + + // Update data model with new position + const offset = [dx + x, dy + y]; + const iceData = pack.ice.find(element => element.i === selectedId); + if (iceData) { + // Store offset for visual positioning, actual geometry stays in points + iceData.offset = offset; + } }); } @@ -114,3 +117,4 @@ function editIce() { unselect(); } } + diff --git a/public/modules/ui/layers.js b/public/modules/ui/layers.js index 5037a5ee..f2f04a4b 100644 --- a/public/modules/ui/layers.js +++ b/public/modules/ui/layers.js @@ -417,49 +417,6 @@ function toggleIce(event) { } } -function drawIce() { - TIME && console.time("drawIce"); - - const {cells, features} = grid; - const {temp, h} = cells; - Math.random = aleaPRNG(seed); - - const ICEBERG_MAX_TEMP = 0; - const GLACIER_MAX_TEMP = -8; - const minMaxTemp = d3.min(temp); - - // cold land: draw glaciers - { - const type = "iceShield"; - const getType = cellId => (h[cellId] >= 20 && temp[cellId] <= GLACIER_MAX_TEMP ? type : null); - const isolines = getIsolines(grid, getType, {polygons: true}); - isolines[type]?.polygons?.forEach(points => { - const clipped = clipPoly(points); - ice.append("polygon").attr("points", clipped).attr("type", type); - }); - } - - // cold water: draw icebergs - for (const cellId of grid.cells.i) { - const t = temp[cellId]; - if (h[cellId] >= 20) continue; // no icebergs on land - if (t > ICEBERG_MAX_TEMP) continue; // too warm: no icebergs - if (features[cells.f[cellId]].type === "lake") continue; // no icebers on lakes - if (P(0.8)) continue; // skip most of eligible cells - - const randomFactor = 0.8 + rand() * 0.4; // random size factor - let baseSize = (1 - normalize(t, minMaxTemp, 1)) * 0.8; // size: 0 = zero size, 1 = full size - if (cells.t[cellId] === -1) baseSize /= 1.3; // coasline: smaller icebergs - const size = minmax(rn(baseSize * randomFactor, 2), 0.1, 1); - - const [cx, cy] = grid.points[cellId]; - const points = getGridPolygon(cellId).map(([x, y]) => [rn(lerp(cx, x, size), 2), rn(lerp(cy, y, size), 2)]); - ice.append("polygon").attr("points", points).attr("cell", cellId).attr("size", size); - } - - TIME && console.timeEnd("drawIce"); -} - function toggleCultures(event) { const cultures = pack.cultures.filter(c => c.i && !c.removed); const empty = !cults.selectAll("path").size(); diff --git a/public/modules/ui/tools.js b/public/modules/ui/tools.js index a3df5c00..eade993f 100644 --- a/public/modules/ui/tools.js +++ b/public/modules/ui/tools.js @@ -555,7 +555,7 @@ function regenerateMilitary() { function regenerateIce() { if (!layerIsOn("toggleIce")) toggleIce(); - ice.selectAll("*").remove(); + Ice.generate(); drawIce(); } diff --git a/public/modules/zones-generator.js b/public/modules/zones-generator.js deleted file mode 100644 index 641a0784..00000000 --- a/public/modules/zones-generator.js +++ /dev/null @@ -1,454 +0,0 @@ -"use strict"; - -window.Zones = (function () { - const config = { - invasion: {quantity: 2, generate: addInvasion}, // invasion of enemy lands - rebels: {quantity: 1.5, generate: addRebels}, // rebels along a state border - proselytism: {quantity: 1.6, generate: addProselytism}, // proselitism of organized religion - crusade: {quantity: 1.6, generate: addCrusade}, // crusade on heresy lands - disease: {quantity: 1.4, generate: addDisease}, // disease starting in a random city - disaster: {quantity: 1, generate: addDisaster}, // disaster starting in a random city - eruption: {quantity: 1, generate: addEruption}, // eruption aroung volcano - avalanche: {quantity: 0.8, generate: addAvalanche}, // avalanche impacting highland road - fault: {quantity: 1, generate: addFault}, // fault line in elevated areas - flood: {quantity: 1, generate: addFlood}, // flood on river banks - tsunami: {quantity: 1, generate: addTsunami} // tsunami starting near coast - }; - - const generate = function (globalModifier = 1) { - TIME && console.time("generateZones"); - - const usedCells = new Uint8Array(pack.cells.i.length); - pack.zones = []; - - Object.values(config).forEach(type => { - const expectedNumber = type.quantity * globalModifier; - let number = gauss(expectedNumber, expectedNumber / 2, 0, 100); - while (number--) type.generate(usedCells); - }); - - TIME && console.timeEnd("generateZones"); - }; - - function addInvasion(usedCells) { - const {cells, states} = pack; - - const ongoingConflicts = states - .filter(s => s.i && !s.removed && s.campaigns) - .map(s => s.campaigns) - .flat() - .filter(c => !c.end); - if (!ongoingConflicts.length) return; - const {defender, attacker} = ra(ongoingConflicts); - - const borderCells = cells.i.filter(cellId => { - if (usedCells[cellId]) return false; - if (cells.state[cellId] !== defender) return false; - return cells.c[cellId].some(c => cells.state[c] === attacker); - }); - - const startCell = ra(borderCells); - if (startCell === undefined) return; - - const invasionCells = []; - const queue = [startCell]; - const maxCells = rand(5, 30); - - while (queue.length) { - const cellId = P(0.4) ? queue.shift() : queue.pop(); - invasionCells.push(cellId); - if (invasionCells.length >= maxCells) break; - - cells.c[cellId].forEach(neibCellId => { - if (usedCells[neibCellId]) return; - if (cells.state[neibCellId] !== defender) return; - usedCells[neibCellId] = 1; - queue.push(neibCellId); - }); - } - - const subtype = rw({ - Invasion: 5, - Occupation: 4, - Conquest: 3, - Incursion: 2, - Intervention: 2, - Assault: 1, - Foray: 1, - Intrusion: 1, - Irruption: 1, - Offensive: 1, - Pillaging: 1, - Plunder: 1, - Raid: 1, - Skirmishes: 1 - }); - const name = getAdjective(states[attacker].name) + " " + subtype; - - pack.zones.push({i: pack.zones.length, name, type: "Invasion", cells: invasionCells, color: "url(#hatch1)"}); - } - - function addRebels(usedCells) { - const {cells, states} = pack; - - const state = ra(states.filter(s => s.i && !s.removed && s.neighbors.some(Boolean))); - if (!state) return; - - const neibStateId = ra(state.neighbors.filter(n => n && !states[n].removed)); - if (!neibStateId) return; - - const cellsArray = []; - const queue = []; - const borderCellId = cells.i.find( - i => cells.state[i] === state.i && cells.c[i].some(c => cells.state[c] === neibStateId) - ); - if (borderCellId) queue.push(borderCellId); - const maxCells = rand(10, 30); - - while (queue.length) { - const cellId = queue.shift(); - cellsArray.push(cellId); - if (cellsArray.length >= maxCells) break; - - cells.c[cellId].forEach(neibCellId => { - if (usedCells[neibCellId]) return; - if (cells.state[neibCellId] !== state.i) return; - usedCells[neibCellId] = 1; - if (neibCellId % 4 !== 0 && !cells.c[neibCellId].some(c => cells.state[c] === neibStateId)) return; - queue.push(neibCellId); - }); - } - - const rebels = rw({ - Rebels: 5, - Insurrection: 2, - Mutineers: 1, - Insurgents: 1, - Rebellion: 1, - Renegades: 1, - Revolters: 1, - Revolutionaries: 1, - Rioters: 1, - Separatists: 1, - Secessionists: 1, - Conspiracy: 1 - }); - - const name = getAdjective(states[neibStateId].name) + " " + rebels; - pack.zones.push({i: pack.zones.length, name, type: "Rebels", cells: cellsArray, color: "url(#hatch3)"}); - } - - function addProselytism(usedCells) { - const {cells, religions} = pack; - - const organizedReligions = religions.filter(r => r.i && !r.removed && r.type === "Organized"); - const religion = ra(organizedReligions); - if (!religion) return; - - const targetBorderCells = cells.i.filter( - i => - cells.h[i] < 20 && - cells.pop[i] && - cells.religion[i] !== religion.i && - cells.c[i].some(c => cells.religion[c] === religion.i) - ); - const startCell = ra(targetBorderCells); - if (!startCell) return; - - const targetReligionId = cells.religion[startCell]; - const proselytismCells = []; - const queue = [startCell]; - const maxCells = rand(10, 30); - - while (queue.length) { - const cellId = queue.shift(); - proselytismCells.push(cellId); - if (proselytismCells.length >= maxCells) break; - - cells.c[cellId].forEach(neibCellId => { - if (usedCells[neibCellId]) return; - if (cells.religion[neibCellId] !== targetReligionId) return; - if (cells.h[neibCellId] < 20 || !cells.pop[i]) return; - usedCells[neibCellId] = 1; - queue.push(neibCellId); - }); - } - - const name = `${getAdjective(religion.name.split(" ")[0])} Proselytism`; - pack.zones.push({i: pack.zones.length, name, type: "Proselytism", cells: proselytismCells, color: "url(#hatch6)"}); - } - - function addCrusade(usedCells) { - const {cells, religions} = pack; - - const heresies = religions.filter(r => !r.removed && r.type === "Heresy"); - if (!heresies.length) return; - - const heresy = ra(heresies); - const crusadeCells = cells.i.filter(i => !usedCells[i] && cells.religion[i] === heresy.i); - if (!crusadeCells.length) return; - crusadeCells.forEach(i => (usedCells[i] = 1)); - - const name = getAdjective(heresy.name.split(" ")[0]) + " Crusade"; - pack.zones.push({ - i: pack.zones.length, - name, - type: "Crusade", - cells: Array.from(crusadeCells), - color: "url(#hatch6)" - }); - } - - function addDisease(usedCells) { - const {cells, burgs} = pack; - - const burg = ra(burgs.filter(b => !usedCells[b.cell] && b.i && !b.removed)); // random burg - if (!burg) return; - - const cellsArray = []; - const cost = []; - const maxCells = rand(20, 40); - - const queue = new FlatQueue(); - queue.push({e: burg.cell, p: 0}, 0); - - while (queue.length) { - const next = queue.pop(); - if (cells.burg[next.e] || cells.pop[next.e]) cellsArray.push(next.e); - usedCells[next.e] = 1; - - cells.c[next.e].forEach(nextCellId => { - const c = Routes.getRoute(next.e, nextCellId) ? 5 : 100; - const p = next.p + c; - if (p > maxCells) return; - - if (!cost[nextCellId] || p < cost[nextCellId]) { - cost[nextCellId] = p; - queue.push({e: nextCellId, p}, p); - } - }); - } - - // prettier-ignore - const name = `${(() => { - const model = rw({color: 2, animal: 1, adjective: 1}); - if (model === "color") return ra(["Amber", "Azure", "Black", "Blue", "Brown", "Crimson", "Emerald", "Golden", "Green", "Grey", "Orange", "Pink", "Purple", "Red", "Ruby", "Scarlet", "Silver", "Violet", "White", "Yellow"]); - if (model === "animal") return ra(["Ape", "Bear", "Bird", "Boar", "Cat", "Cow", "Deer", "Dog", "Fox", "Goat", "Horse", "Lion", "Pig", "Rat", "Raven", "Sheep", "Spider", "Tiger", "Viper", "Wolf", "Worm", "Wyrm"]); - if (model === "adjective") return ra(["Blind", "Bloody", "Brutal", "Burning", "Deadly", "Fatal", "Furious", "Great", "Grim", "Horrible", "Invisible", "Lethal", "Loud", "Mortal", "Savage", "Severe", "Silent", "Unknown", "Venomous", "Vicious"]); - })()} ${rw({Fever: 5, Plague: 3, Cough: 3, Flu: 2, Pox: 2, Cholera: 2, Typhoid: 2, Leprosy: 1, Smallpox: 1, Pestilence: 1, Consumption: 1, Malaria: 1, Dropsy: 1})}`; - - pack.zones.push({i: pack.zones.length, name, type: "Disease", cells: cellsArray, color: "url(#hatch12)"}); - } - - function addDisaster(usedCells) { - const {cells, burgs} = pack; - - const burg = ra(burgs.filter(b => !usedCells[b.cell] && b.i && !b.removed)); - if (!burg) return; - usedCells[burg.cell] = 1; - - const cellsArray = []; - const cost = []; - const maxCells = rand(5, 25); - - const queue = new FlatQueue(); - queue.push({e: burg.cell, p: 0}, 0); - - while (queue.length) { - const next = queue.pop(); - if (cells.burg[next.e] || cells.pop[next.e]) cellsArray.push(next.e); - usedCells[next.e] = 1; - - cells.c[next.e].forEach(function (e) { - const c = rand(1, 10); - const p = next.p + c; - if (p > maxCells) return; - - if (!cost[e] || p < cost[e]) { - cost[e] = p; - queue.push({e, p}, p); - } - }); - } - - const type = rw({ - Famine: 5, - Drought: 3, - Earthquake: 3, - Dearth: 1, - Tornadoes: 1, - Wildfires: 1, - Storms: 1, - Blight: 1 - }); - const name = getAdjective(burg.name) + " " + type; - pack.zones.push({i: pack.zones.length, name, type: "Disaster", cells: cellsArray, color: "url(#hatch5)"}); - } - - function addEruption(usedCells) { - const {cells, markers} = pack; - - const volcanoe = markers.find(m => m.type === "volcanoes" && !usedCells[m.cell]); - if (!volcanoe) return; - usedCells[volcanoe.cell] = 1; - - const note = notes.find(n => n.id === "marker" + volcanoe.i); - if (note) note.legend = note.legend.replace("Active volcano", "Erupting volcano"); - const name = note ? note.name.replace(" Volcano", "") + " Eruption" : "Volcano Eruption"; - - const cellsArray = []; - const queue = [volcanoe.cell]; - const maxCells = rand(10, 30); - - while (queue.length) { - const cellId = P(0.5) ? queue.shift() : queue.pop(); - cellsArray.push(cellId); - if (cellsArray.length >= maxCells) break; - - cells.c[cellId].forEach(neibCellId => { - if (usedCells[neibCellId] || cells.h[neibCellId] < 20) return; - usedCells[neibCellId] = 1; - queue.push(neibCellId); - }); - } - - pack.zones.push({i: pack.zones.length, name, type: "Eruption", cells: cellsArray, color: "url(#hatch7)"}); - } - - function addAvalanche(usedCells) { - const {cells} = pack; - - const routeCells = cells.i.filter(i => !usedCells[i] && Routes.isConnected(i) && cells.h[i] >= 70); - if (!routeCells.length) return; - - const startCell = ra(routeCells); - usedCells[startCell] = 1; - - const cellsArray = []; - const queue = [startCell]; - const maxCells = rand(3, 15); - - while (queue.length) { - const cellId = P(0.3) ? queue.shift() : queue.pop(); - cellsArray.push(cellId); - if (cellsArray.length >= maxCells) break; - - cells.c[cellId].forEach(neibCellId => { - if (usedCells[neibCellId] || cells.h[neibCellId] < 65) return; - usedCells[neibCellId] = 1; - queue.push(neibCellId); - }); - } - - const name = getAdjective(Names.getCultureShort(cells.culture[startCell])) + " Avalanche"; - pack.zones.push({i: pack.zones.length, name, type: "Avalanche", cells: cellsArray, color: "url(#hatch5)"}); - } - - function addFault(usedCells) { - const cells = pack.cells; - - const elevatedCells = cells.i.filter(i => !usedCells[i] && cells.h[i] > 50 && cells.h[i] < 70); - if (!elevatedCells.length) return; - - const startCell = ra(elevatedCells); - usedCells[startCell] = 1; - - const cellsArray = []; - const queue = [startCell]; - const maxCells = rand(3, 15); - - while (queue.length) { - const cellId = queue.pop(); - if (cells.h[cellId] >= 20) cellsArray.push(cellId); - if (cellsArray.length >= maxCells) break; - - cells.c[cellId].forEach(neibCellId => { - if (usedCells[neibCellId] || cells.r[neibCellId]) return; - usedCells[neibCellId] = 1; - queue.push(neibCellId); - }); - } - - const name = getAdjective(Names.getCultureShort(cells.culture[startCell])) + " Fault"; - pack.zones.push({i: pack.zones.length, name, type: "Fault", cells: cellsArray, color: "url(#hatch2)"}); - } - - function addFlood(usedCells) { - const cells = pack.cells; - - const fl = cells.fl.filter(Boolean); - const meanFlux = d3.mean(fl); - const maxFlux = d3.max(fl); - const fluxThreshold = (maxFlux - meanFlux) / 2 + meanFlux; - - const bigRiverCells = cells.i.filter( - i => !usedCells[i] && cells.h[i] < 50 && cells.r[i] && cells.fl[i] > fluxThreshold && cells.burg[i] - ); - if (!bigRiverCells.length) return; - - const startCell = ra(bigRiverCells); - usedCells[startCell] = 1; - - const riverId = cells.r[startCell]; - const cellsArray = []; - const queue = [startCell]; - const maxCells = rand(5, 30); - - while (queue.length) { - const cellId = queue.pop(); - cellsArray.push(cellId); - if (cellsArray.length >= maxCells) break; - - cells.c[cellId].forEach(neibCellId => { - if ( - usedCells[neibCellId] || - cells.h[neibCellId] < 20 || - cells.r[neibCellId] !== riverId || - cells.h[neibCellId] > 50 || - cells.fl[neibCellId] < meanFlux - ) - return; - usedCells[neibCellId] = 1; - queue.push(neibCellId); - }); - } - - const name = getAdjective(pack.burgs[cells.burg[startCell]].name) + " Flood"; - pack.zones.push({i: pack.zones.length, name, type: "Flood", cells: cellsArray, color: "url(#hatch13)"}); - } - - function addTsunami(usedCells) { - const {cells, features} = pack; - - const coastalCells = cells.i.filter( - i => !usedCells[i] && cells.t[i] === -1 && features[cells.f[i]].type !== "lake" - ); - if (!coastalCells.length) return; - - const startCell = ra(coastalCells); - usedCells[startCell] = 1; - - const cellsArray = []; - const queue = [startCell]; - const maxCells = rand(10, 30); - - while (queue.length) { - const cellId = queue.shift(); - if (cells.t[cellId] === 1) cellsArray.push(cellId); - if (cellsArray.length >= maxCells) break; - - cells.c[cellId].forEach(neibCellId => { - if (usedCells[neibCellId]) return; - if (cells.t[neibCellId] > 2) return; - if (pack.features[cells.f[neibCellId]].type === "lake") return; - usedCells[neibCellId] = 1; - queue.push(neibCellId); - }); - } - - const name = getAdjective(Names.getCultureShort(cells.culture[startCell])) + " Tsunami"; - pack.zones.push({i: pack.zones.length, name, type: "Tsunami", cells: cellsArray, color: "url(#hatch13)"}); - } - - return {generate}; -})(); diff --git a/public/versioning.js b/public/versioning.js index 11fcde66..fd2a67a2 100644 --- a/public/versioning.js +++ b/public/versioning.js @@ -13,7 +13,7 @@ * Example: 1.102.2 -> Major version 1, Minor version 102, Patch version 2 */ -const VERSION = "1.110.0"; +const VERSION = "1.112.1"; if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format or parsing function"); { diff --git a/src/index.html b/src/index.html index 21d84187..6173e519 100644 --- a/src/index.html +++ b/src/index.html @@ -8490,52 +8490,41 @@ + - - - - - - - - - - - - + - - + - + - + - - + + - + - + @@ -8549,12 +8538,12 @@ - - - + + + - + @@ -8566,22 +8555,9 @@ - - + + - - - - - - - - - - - - - diff --git a/src/modules/biomes.ts b/src/modules/biomes.ts new file mode 100644 index 00000000..b708589f --- /dev/null +++ b/src/modules/biomes.ts @@ -0,0 +1,182 @@ +import { mean, range } from "d3"; +import { rn } from "../utils"; + +declare global { + var Biomes: BiomesModule; +} + +class BiomesModule { + private MIN_LAND_HEIGHT = 20; + + getDefault() { + const name: string[] = [ + "Marine", + "Hot desert", + "Cold desert", + "Savanna", + "Grassland", + "Tropical seasonal forest", + "Temperate deciduous forest", + "Tropical rainforest", + "Temperate rainforest", + "Taiga", + "Tundra", + "Glacier", + "Wetland", + ]; + + const color: string[] = [ + "#466eab", + "#fbe79f", + "#b5b887", + "#d2d082", + "#c8d68f", + "#b6d95d", + "#29bc56", + "#7dcb35", + "#409c43", + "#4b6b32", + "#96784b", + "#d5e7eb", + "#0b9131", + ]; + const habitability: number[] = [ + 0, 4, 10, 22, 30, 50, 100, 80, 90, 12, 4, 0, 12, + ]; + const iconsDensity: number[] = [ + 0, 3, 2, 120, 120, 120, 120, 150, 150, 100, 5, 0, 250, + ]; + const icons: Array<{ [key: string]: number }> = [ + {}, + { dune: 3, cactus: 6, deadTree: 1 }, + { dune: 9, deadTree: 1 }, + { acacia: 1, grass: 9 }, + { grass: 1 }, + { acacia: 8, palm: 1 }, + { deciduous: 1 }, + { acacia: 5, palm: 3, deciduous: 1, swamp: 1 }, + { deciduous: 6, swamp: 1 }, + { conifer: 1 }, + { grass: 1 }, + {}, + { swamp: 1 }, + ]; + const cost: number[] = [ + 10, 200, 150, 60, 50, 70, 70, 80, 90, 200, 1000, 5000, 150, + ]; // biome movement cost + const biomesMatrix: Uint8Array[] = [ + // hot ↔ cold [>19°C; <-4°C]; dry ↕ wet + new Uint8Array([ + 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 10, + ]), + new Uint8Array([ + 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 9, 9, 9, 9, 10, + 10, 10, + ]), + new Uint8Array([ + 5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 9, 9, 9, 9, 9, 10, + 10, 10, + ]), + new Uint8Array([ + 5, 6, 6, 6, 6, 6, 6, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 10, + 10, 10, + ]), + new Uint8Array([ + 7, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 9, + 10, 10, + ]), + ]; + + // parse icons weighted array into a simple array + const parsedIcons: string[][] = []; + for (let i = 0; i < icons.length; i++) { + const parsed: string[] = []; + for (const icon in icons[i]) { + for (let j = 0; j < icons[i][icon]; j++) { + parsed.push(icon); + } + } + parsedIcons[i] = parsed; + } + + return { + i: range(0, name.length), + name, + color, + biomesMatrix, + habitability, + iconsDensity, + icons: parsedIcons, + cost, + }; + } + + define() { + TIME && console.time("defineBiomes"); + + const { + fl: flux, + r: riverIds, + h: heights, + c: neighbors, + g: gridReference, + } = pack.cells; + const { temp, prec } = grid.cells; + pack.cells.biome = new Uint8Array(pack.cells.i.length); // biomes array + + const calculateMoisture = (cellId: number) => { + let moisture = prec[gridReference[cellId]]; + if (riverIds[cellId]) moisture += Math.max(flux[cellId] / 10, 2); + + const moistAround = neighbors[cellId] + .filter( + (neibCellId: number) => heights[neibCellId] >= this.MIN_LAND_HEIGHT, + ) + .map((c: number) => prec[gridReference[c]]) + .concat([moisture]); + return rn(4 + (mean(moistAround) as number)); + }; + + for (let cellId = 0; cellId < heights.length; cellId++) { + const height = heights[cellId]; + const moisture = + height < this.MIN_LAND_HEIGHT ? 0 : calculateMoisture(cellId); + const temperature = temp[gridReference[cellId]]; + pack.cells.biome[cellId] = this.getId( + moisture, + temperature, + height, + Boolean(riverIds[cellId]), + ); + } + + TIME && console.timeEnd("defineBiomes"); + } + + getId( + moisture: number, + temperature: number, + height: number, + hasRiver: boolean, + ) { + if (height < 20) return 0; // all water cells: marine biome + if (temperature < -5) return 11; // too cold: permafrost biome + if (temperature >= 25 && !hasRiver && moisture < 8) return 1; // too hot and dry: hot desert biome + if (this.isWetland(moisture, temperature, height)) return 12; // too wet: wetland biome + + // in other cases use biome matrix + const moistureBand = Math.min((moisture / 5) | 0, 4); // [0-4] + const temperatureBand = Math.min(Math.max(20 - temperature, 0), 25); // [0-25] + return biomesData.biomesMatrix[moistureBand][temperatureBand]; + } + + private isWetland(moisture: number, temperature: number, height: number) { + if (temperature <= -2) return false; // too cold + if (moisture > 40 && height < 25) return true; // near coast + if (moisture > 24 && height > 24 && height < 60) return true; // off coast + return false; + } +} + +window.Biomes = new BiomesModule(); diff --git a/src/modules/burgs-generator.ts b/src/modules/burgs-generator.ts new file mode 100644 index 00000000..0b8033a3 --- /dev/null +++ b/src/modules/burgs-generator.ts @@ -0,0 +1,734 @@ +import { quadtree } from "d3-quadtree"; +import { byId, each, gauss, minmax, normalize, P, rn } from "../utils"; + +declare global { + var Burgs: BurgModule; +} +export interface Burg { + cell: number; + x: number; + y: number; + i?: number; + state?: number; + culture?: number; + name?: string; + feature?: number; + capital?: number; + lock?: boolean; + port?: number; + removed?: boolean; + population?: number; + type?: string; + coa?: any; + citadel?: number; + plaza?: number; + walls?: number; + shanty?: number; + temple?: number; + group?: string; + link?: string; + MFCG?: string; +} + +class BurgModule { + shift() { + const { cells, features, burgs } = pack; + const temp = grid.cells.temp; + + // port is a capital with any harbor OR any burg with a safe harbor + // safe harbor is a cell having just one adjacent water cell + const featurePortCandidates: Record = {}; + for (const burg of burgs) { + if (!burg.i || burg.lock) continue; + delete burg.port; // reset port status + const cellId = burg.cell; + + const haven = cells.haven[cellId]; + const harbor = cells.harbor[cellId]; + const featureId = cells.f[haven]; + if (!featureId) continue; // no adjacent water body + + const isMulticell = features[featureId].cells > 1; + const isHarbor = (harbor && burg.capital) || harbor === 1; + const isFrozen = temp[cells.g[cellId]] <= 0; + + if (isMulticell && isHarbor && !isFrozen) { + if (!featurePortCandidates[featureId]) + featurePortCandidates[featureId] = []; + featurePortCandidates[featureId].push(burg); + } + } + + const getCloseToEdgePoint = (cell1: number, cell2: number) => { + const { cells, vertices } = pack; + + const [x0, y0] = cells.p[cell1]; + const commonVertices = cells.v[cell1].filter((vertex) => + vertices.c[vertex].some((cell) => cell === cell2), + ); + const [x1, y1] = vertices.p[commonVertices[0]]; + const [x2, y2] = vertices.p[commonVertices[1]]; + const xEdge = (x1 + x2) / 2; + const yEdge = (y1 + y2) / 2; + + const x = rn(x0 + 0.95 * (xEdge - x0), 2); + const y = rn(y0 + 0.95 * (yEdge - y0), 2); + + return [x, y]; + }; + + // shift ports to the edge of the water body + Object.entries(featurePortCandidates).forEach(([featureId, burgs]) => { + if (burgs.length < 2) return; // only one port on water body - skip + burgs.forEach((burg) => { + burg.port = Number(featureId); + const haven = cells.haven[burg.cell]; + const [x, y] = getCloseToEdgePoint(burg.cell, haven); + burg.x = x; + burg.y = y; + }); + }); + + // shift non-port river burgs a bit + for (const burg of burgs) { + if (!burg.i || burg.lock || burg.port || !cells.r[burg.cell]) continue; + const cellId = burg.cell; + const shift = Math.min(cells.fl[cellId] / 150, 1); + burg.x = cellId % 2 ? rn(burg.x + shift, 2) : rn(burg.x - shift, 2); + burg.y = + cells.r[cellId] % 2 ? rn(burg.y + shift, 2) : rn(burg.y - shift, 2); + } + } + + generate() { + TIME && console.time("generateBurgs"); + const { cells } = pack; + + let burgs: Burg[] = [0 as any]; // burgs array + cells.burg = new Uint16Array(cells.i.length); + + const populatedCells = cells.i.filter( + (i) => cells.s[i] > 0 && cells.culture[i], + ); + if (!populatedCells.length) { + ERROR && + console.error( + "There is no populated cells with culture assigned. Cannot generate states", + ); + return burgs; + } + + let burgsQuadtree = quadtree(); + + const generateCapitals = () => { + const randomize = (score: number) => score * (0.5 + Math.random() * 0.5); + const score = new Int16Array(cells.s.map(randomize)); + const sorted = populatedCells.sort((a, b) => score[b] - score[a]); + + const capitalsNumber = getCapitalsNumber(); + let spacing = (graphWidth + graphHeight) / 2 / capitalsNumber; // min distance between capitals + + for (let i = 0; burgs.length <= capitalsNumber; i++) { + const cell = sorted[i]; + const [x, y] = cells.p[cell]; + + if (burgsQuadtree.find(x, y, spacing) === undefined) { + burgs.push({ cell, x, y }); + burgsQuadtree.add([x, y]); + } + + // reset if all cells were checked + if (i === sorted.length - 1) { + WARN && + console.warn( + "Cannot place capitals with current spacing. Trying again with reduced spacing", + ); + burgsQuadtree = quadtree(); + i = -1; + burgs = [0 as any]; + spacing /= 1.2; + } + } + + burgs.forEach((burg, burgId) => { + if (!burgId) return; + burg.i = burgId; + burg.state = burgId; + burg.culture = cells.culture[burg.cell]; + burg.name = Names.getCultureShort(burg.culture); + burg.feature = cells.f[burg.cell]; + burg.capital = 1; + cells.burg[burg.cell] = burgId; + }); + }; + + const generateTowns = () => { + const randomize = (score: number) => score * gauss(1, 3, 0, 20, 3); + const score = new Int16Array(cells.s.map(randomize)); + const sorted = populatedCells.sort((a, b) => score[b] - score[a]); + + const burgsNumber = getTownsNumber(); + let spacing = + (graphWidth + graphHeight) / 150 / (burgsNumber ** 0.7 / 66); // min distance between town + + for (let added = 0; added < burgsNumber && spacing > 1; ) { + for (let i = 0; added < burgsNumber && i < sorted.length; i++) { + if (cells.burg[sorted[i]]) continue; + const cell = sorted[i]; + const [x, y] = cells.p[cell]; + + const minSpacing = spacing * gauss(1, 0.3, 0.2, 2, 2); // randomize to make placement not uniform + if (burgsQuadtree.find(x, y, minSpacing) !== undefined) continue; // to close to existing burg + + const burgId = burgs.length; + const culture = cells.culture[cell]; + const name = Names.getCulture(culture); + const feature = cells.f[cell]; + burgs.push({ + cell, + x, + y, + i: burgId, + state: 0, + culture, + name, + feature, + capital: 0, + }); + added++; + cells.burg[cell] = burgId; + } + + spacing *= 0.5; + } + }; + + generateCapitals(); + generateTowns(); + + pack.burgs = burgs; + this.shift(); + + TIME && console.timeEnd("generateBurgs"); + + function getCapitalsNumber() { + let number = (byId("statesNumber") as HTMLInputElement).valueAsNumber; + + if (populatedCells.length < number * 10) { + number = Math.floor(populatedCells.length / 10); + WARN && + console.warn( + `Not enough populated cells. Generating only ${number} capitals/states`, + ); + } + + return number; + } + + function getTownsNumber() { + const manorsInput = byId("manorsInput") as HTMLInputElement; + const isAuto = manorsInput.value === "1000"; // '1000' is considered as auto + if (isAuto) + return rn( + populatedCells.length / 5 / (grid.points.length / 10000) ** 0.8, + ); + + return Math.min(manorsInput.valueAsNumber, populatedCells.length); + } + } + + getType(cellId: number, port?: number) { + const { cells, features } = pack; + + if (port) return "Naval"; + + const haven = cells.haven[cellId]; + if (haven !== undefined && features[cells.f[haven]].type === "lake") + return "Lake"; + + if (cells.h[cellId] > 60) return "Highland"; + + if (cells.r[cellId] && cells.fl[cellId] >= 100) return "River"; + + const biome = cells.biome[cellId]; + const population = cells.pop[cellId]; + if (!cells.burg[cellId] || population <= 5) { + if (population < 5 && [1, 2, 3, 4].includes(biome)) return "Nomadic"; + if (biome > 4 && biome < 10) return "Hunting"; + } + + return "Generic"; + } + + private definePopulation(burg: Burg) { + const cellId = burg.cell; + let population = pack.cells.s[cellId] / 5; + if (burg.capital) population *= 1.5; + const connectivityRate = Routes.getConnectivityRate(cellId); + if (connectivityRate) population *= connectivityRate; + population *= gauss(1, 1, 0.25, 4, 5); // randomize + population += (((burg.i as number) % 100) - (cellId % 100)) / 1000; // unround + burg.population = rn(Math.max(population, 0.01), 3); + } + + private defineEmblem(burg: Burg) { + burg.type = this.getType(burg.cell, burg.port); + + const state = pack.states[burg.state as number]; + const stateCOA = state.coa; + + let kinship = 0.25; + if (burg.capital) kinship += 0.1; + else if (burg.port) kinship -= 0.1; + if (burg.culture !== state.culture) kinship -= 0.25; + + const type = + burg.capital && P(0.2) + ? "Capital" + : burg.type === "Generic" + ? "City" + : burg.type; + burg.coa = COA.generate(stateCOA, kinship, null, type); + burg.coa.shield = COA.getShield(burg.culture, burg.state); + } + + private defineFeatures(burg: Burg) { + const pop = burg.population as number; + burg.citadel = Number( + burg.capital || (pop > 50 && P(0.75)) || (pop > 15 && P(0.5)) || P(0.1), + ); + burg.plaza = Number( + Routes.isCrossroad(burg.cell) || + (Routes.hasRoad(burg.cell) && P(0.7)) || + pop > 20 || + (pop > 10 && P(0.8)), + ); + burg.walls = Number( + burg.capital || + pop > 30 || + (pop > 20 && P(0.75)) || + (pop > 10 && P(0.5)) || + P(0.1), + ); + burg.shanty = Number( + pop > 60 || (pop > 40 && P(0.75)) || (pop > 20 && burg.walls && P(0.4)), + ); + const religion = pack.cells.religion[burg.cell] as number; + const theocracy = pack.states[burg.state as number].form === "Theocracy"; + burg.temple = Number( + (religion && theocracy && P(0.5)) || + pop > 50 || + (pop > 35 && P(0.75)) || + (pop > 20 && P(0.5)), + ); + } + + getDefaultGroups() { + return [ + { + name: "capital", + active: true, + order: 9, + features: { capital: true }, + preview: "watabou-city", + }, + { + name: "city", + active: true, + order: 8, + percentile: 90, + min: 5, + preview: "watabou-city", + }, + { + name: "fort", + active: true, + features: { citadel: true, walls: false, plaza: false, port: false }, + order: 6, + max: 1, + }, + { + name: "monastery", + active: true, + features: { temple: true, walls: false, plaza: false, port: false }, + order: 5, + max: 0.8, + }, + { + name: "caravanserai", + active: true, + features: { port: false, plaza: true }, + order: 4, + max: 0.8, + biomes: [1, 2, 3], + }, + { + name: "trading_post", + active: true, + order: 3, + features: { plaza: true }, + max: 0.8, + biomes: [5, 6, 7, 8, 9, 10, 11, 12], + }, + { + name: "village", + active: true, + order: 2, + min: 0.1, + max: 2, + preview: "watabou-village", + }, + { + name: "hamlet", + active: true, + order: 1, + features: { plaza: false }, + max: 0.1, + preview: "watabou-village", + }, + { + name: "town", + active: true, + order: 7, + isDefault: true, + preview: "watabou-city", + }, + ]; + } + + defineGroup(burg: Burg, populations: number[]) { + if (burg.lock && burg.group) { + // locked burgs: don't change group if it still exists + const group = options.burgs.groups.find( + (g: any) => g.name === burg.group, + ); + if (group) return; + } + + const defaultGroup = options.burgs.groups.find((g: any) => g.isDefault); + if (!defaultGroup) { + ERROR && console.error("No default group defined"); + return; + } + burg.group = defaultGroup.name; + + for (const group of options.burgs.groups) { + if (!group.active) continue; + + if (group.min) { + const isFit = (burg.population as number) >= group.min; + if (!isFit) continue; + } + + if (group.max) { + const isFit = (burg.population as number) <= group.max; + if (!isFit) continue; + } + + if (group.features) { + const isFit = Object.entries( + group.features as Record, + ).every( + ([feature, value]) => Boolean(burg[feature as keyof Burg]) === value, + ); + if (!isFit) continue; + } + + if (group.biomes) { + const isFit = group.biomes.includes(pack.cells.biome[burg.cell]); + if (!isFit) continue; + } + + if (group.percentile) { + const index = populations.indexOf(burg.population as number); + const isFit = + index >= Math.floor((populations.length * group.percentile) / 100); + if (!isFit) continue; + } + + burg.group = group.name; // apply fitting group + return; + } + } + + specify() { + TIME && console.time("specifyBurgs"); + + pack.burgs.forEach((burg) => { + if (!burg.i || burg.removed || burg.lock) return; + this.definePopulation(burg); + this.defineEmblem(burg); + this.defineFeatures(burg); + }); + + const populations = pack.burgs + .filter((b) => b.i && !b.removed) + .map((b) => b.population as number) + .sort((a: number, b: number) => a - b); // ascending + + pack.burgs.forEach((burg) => { + if (!burg.i || burg.removed) return; + this.defineGroup(burg, populations); + }); + + TIME && console.timeEnd("specifyBurgs"); + } + + private createWatabouCityLinks(burg: Burg) { + const cells = pack.cells; + const { i, name, population: burgPopulation, cell } = burg; + const burgSeed = burg.MFCG || seed + String(burg.i).padStart(4, "0"); + + const sizeRaw = + 2.13 * ((burgPopulation! * populationRate) / urbanDensity) ** 0.385; + const size = minmax(Math.ceil(sizeRaw), 6, 100); + const population = rn(burgPopulation! * populationRate * urbanization); + + const river = cells.r[cell] ? 1 : 0; + const coast = Number((burg.port || 0) > 0); + const sea = (() => { + if (!coast || !cells.haven[cell]) return null; + + // calculate see direction: 0 = east, 0.5 = north, 1 = west, 1.5 = south + const [x1, y1] = cells.p[cell]; + const [x2, y2] = cells.p[cells.haven[cell]]; + const deg = (Math.atan2(y2 - y1, x2 - x1) * 180) / Math.PI; + + if (deg <= 0) return rn(normalize(Math.abs(deg), 0, 180), 2); + return rn(2 - normalize(deg, 0, 180), 2); + })(); + + const arableBiomes = river ? [1, 2, 3, 4, 5, 6, 7, 8] : [5, 6, 7, 8]; + const farms = +arableBiomes.includes(cells.biome[cell]); + + const citadel = +(burg.citadel as number); + const urban_castle = +(citadel && each(2)(i as number)); + + const hub = Routes.isCrossroad(cell); + const walls = +(burg.walls as number); + const plaza = +(burg.plaza as number); + const temple = +(burg.temple as number); + const shantytown = +(burg.shanty as number); + + const style = "natural"; + + const url = new URL("https://watabou.github.io/city-generator/"); + url.search = new URLSearchParams({ + name: name || "", + population: population.toString(), + size: size.toString(), + seed: burgSeed, + river: river.toString(), + coast: coast.toString(), + farms: farms.toString(), + citadel: citadel.toString(), + urban_castle: urban_castle.toString(), + hub: hub.toString(), + plaza: plaza.toString(), + temple: temple.toString(), + walls: walls.toString(), + shantytown: shantytown.toString(), + gates: (-1).toString(), + style, + }).toString(); + if (sea) url.searchParams.append("sea", sea.toString()); + + const link = url.toString(); + return { link, preview: `${link}&preview=1` }; + } + + private createWatabouVillageLinks(burg: Burg) { + const { cells, features } = pack; + const { i, population, cell } = burg; + + const burgSeed = seed + String(i).padStart(4, "0"); + const pop = rn(population! * populationRate * urbanization); + const tags = []; + + if (cells.r[cell] && cells.haven[cell]) tags.push("estuary"); + else if (cells.haven[cell] && features[cells.f[cell]].cells === 1) + tags.push("island,district"); + else if (burg.port) tags.push("coast"); + else if (cells.conf[cell]) tags.push("confluence"); + else if (cells.r[cell]) tags.push("river"); + else if (pop < 200 && each(4)(cell)) tags.push("pond"); + + const connectivityRate = Routes.getConnectivityRate(cell); + tags.push( + connectivityRate > 1 + ? "highway" + : connectivityRate === 1 + ? "dead end" + : "isolated", + ); + + const biome = cells.biome[cell]; + const arableBiomes = cells.r[cell] + ? [1, 2, 3, 4, 5, 6, 7, 8] + : [5, 6, 7, 8]; + if (!arableBiomes.includes(biome)) tags.push("uncultivated"); + else if (each(6)(cell)) tags.push("farmland"); + + const temp = grid.cells.temp[cells.g[cell]]; + if (temp <= 0 || temp > 28 || (temp > 25 && each(3)(cell))) + tags.push("no orchards"); + + if (!burg.plaza) tags.push("no square"); + if (burg.walls) tags.push("palisade"); + + if (pop < 100) tags.push("sparse"); + else if (pop > 300) tags.push("dense"); + + const width = (() => { + if (pop > 1500) return 1600; + if (pop > 1000) return 1400; + if (pop > 500) return 1000; + if (pop > 200) return 800; + if (pop > 100) return 600; + return 400; + })(); + const height = rn(width / 2.05); + + const style = (() => { + if ([1, 2].includes(biome)) return "sand"; + if (temp <= 5 || [9, 10, 11].includes(biome)) return "snow"; + return "default"; + })(); + + const url = new URL("https://watabou.github.io/village-generator/"); + url.search = new URLSearchParams({ + pop: pop.toString(), + name: burg.name || "", + seed: burgSeed, + width: width.toString(), + height: height.toString(), + style, + tags: tags.join(","), + }).toString(); + + const link = url.toString(); + return { link, preview: `${link}&preview=1` }; + } + + private createWatabouDwellingLinks(burg: Burg) { + const burgSeed = seed + String(burg.i).padStart(4, "0"); + const pop = rn(burg.population! * populationRate * urbanization); + + const tags = (() => { + if (pop > 200) return ["large", "tall"]; + if (pop > 100) return ["large"]; + if (pop > 50) return ["tall"]; + if (pop > 20) return ["low"]; + return ["small"]; + })(); + + const url = new URL("https://watabou.github.io/dwellings/"); + url.search = new URLSearchParams({ + pop: pop.toString(), + name: "", + seed: burgSeed, + tags: tags.join(","), + }).toString(); + + const link = url.toString(); + return { link, preview: `${link}&preview=1` }; + } + + getPreview(burg: Burg): { link: string | null; preview: string | null } { + const previewGeneratorsMap: Record< + string, + (burg: Burg) => { link: string | null; preview: string | null } + > = { + "watabou-city": (burg: Burg) => this.createWatabouCityLinks(burg), + "watabou-village": (burg: Burg) => this.createWatabouVillageLinks(burg), + "watabou-dwelling": (burg: Burg) => this.createWatabouDwellingLinks(burg), + }; + if (burg.link) return { link: burg.link, preview: burg.link }; + + const group = options.burgs.groups.find((g: any) => g.name === burg.group); + if (!group?.preview || !previewGeneratorsMap[group.preview]) + return { link: null, preview: null }; + + return previewGeneratorsMap[group.preview](burg); + } + + add([x, y]: [number, number]) { + const { cells } = pack; + + const burgId = pack.burgs.length; + const cellId = window.findCell(x, y, undefined, pack); + const culture = cells.culture[cellId as number]; + const name = Names.getCulture(culture); + const state = cells.state[cellId as number]; + const feature = cells.f[cellId as number]; + + const burg: Burg = { + cell: cellId as number, + x, + y, + i: burgId, + state, + culture, + name, + feature, + capital: 0, + port: 0, + }; + this.definePopulation(burg); + this.defineEmblem(burg); + this.defineFeatures(burg); + + const populations = pack.burgs + .filter((b) => b.i && !b.removed) + .map((b) => b.population as number) + .sort((a: number, b: number) => a - b); // ascending + this.defineGroup(burg, populations); + + pack.burgs.push(burg); + cells.burg[cellId as number] = burgId; + + const newRoute = Routes.connect(cellId as number); + if (newRoute && layerIsOn("toggleRoutes")) drawRoute(newRoute); + + drawBurgIcon(burg); + drawBurgLabel(burg); + + return burgId; + } + + changeGroup(burg: Burg, group: string | null) { + if (group) { + burg.group = group; + } else { + const validBurgs = pack.burgs.filter((b) => b.i && !b.removed); + const populations = validBurgs + .map((b) => b.population as number) + .sort((a, b) => a - b); + this.defineGroup(burg, populations); + } + + drawBurgIcon(burg); + drawBurgLabel(burg); + } + + remove(burgId: number) { + const burg = pack.burgs[burgId]; + if (!burg) return tip(`Burg ${burgId} not found`, false, "error"); + + pack.cells.burg[burg.cell] = 0; + burg.removed = true; + + const noteId = notes.findIndex((note) => note.id === `burg${burgId}`); + if (noteId !== -1) notes.splice(noteId, 1); + + if (burg.coa) { + byId(`burgCOA${burgId}`)?.remove(); + emblems.select(`#burgEmblems > use[data-i='${burgId}']`).remove(); + delete burg.coa; + } + + removeBurgIcon(burg.i!); + removeBurgLabel(burg.i!); + } +} +window.Burgs = new BurgModule(); diff --git a/src/modules/cultures-generator.ts b/src/modules/cultures-generator.ts new file mode 100644 index 00000000..91f4690d --- /dev/null +++ b/src/modules/cultures-generator.ts @@ -0,0 +1,1405 @@ +import { max, quadtree, range } from "d3"; +import { + abbreviate, + biased, + byId, + getColors, + getRandomColor, + minmax, + P, + rand, + rn, + rw, +} from "../utils"; + +declare global { + var Cultures: CulturesModule; +} + +export interface Culture { + name: string; + i: number; + base: number; + shield: string; + lock?: boolean; + code?: string; + center?: number; + sort?: (i: number) => number; + odd?: number; + color?: string; + type?: string; + expansionism?: number; + origins?: (number | null)[]; + removed?: boolean; + cells?: number; + area?: number; + rural?: number; + urban?: number; +} + +class CulturesModule { + cells: any; + + getRandomShield() { + const type = rw(COA.shields.types); + return rw(COA.shields[type]); + } + + getDefault(count: number = 0): Omit[] { + // generic sorting functions + const cells = pack.cells, + s = cells.s, + sMax = max(s) as number, + t = cells.t, + h = cells.h, + temp = grid.cells.temp; + const n = (cell: number) => Math.ceil((s[cell] / sMax) * 3); // normalized cell score + const td = (cell: number, goal: number) => { + const d = Math.abs(temp[cells.g[cell]] - goal); + return d ? d + 1 : 1; + }; // temperature difference fee + const bd = (cell: number, biomes: number[], fee = 4) => + biomes.includes(cells.biome[cell]) ? 1 : fee; // biome difference fee + const sf = (cell: number, fee = 4) => + cells.haven[cell] && + pack.features[cells.f[cells.haven[cell]]].type !== "lake" + ? 1 + : fee; // not on sea coast fee + + if (culturesSet.value === "european") { + return [ + { + name: "Shwazen", + base: 0, + odd: 1, + sort: (i: number) => n(i) / td(i, 10) / bd(i, [6, 8]), + shield: "swiss", + }, + { + name: "Angshire", + base: 1, + odd: 1, + sort: (i: number) => n(i) / td(i, 10) / sf(i), + shield: "wedged", + }, + { + name: "Luari", + base: 2, + odd: 1, + sort: (i: number) => n(i) / td(i, 12) / bd(i, [6, 8]), + shield: "french", + }, + { + name: "Tallian", + base: 3, + odd: 1, + sort: (i: number) => n(i) / td(i, 15), + shield: "horsehead", + }, + { + name: "Astellian", + base: 4, + odd: 1, + sort: (i: number) => n(i) / td(i, 16), + shield: "spanish", + }, + { + name: "Slovan", + base: 5, + odd: 1, + sort: (i: number) => (n(i) / td(i, 6)) * t[i], + shield: "polish", + }, + { + name: "Norse", + base: 6, + odd: 1, + sort: (i: number) => n(i) / td(i, 5), + shield: "heater", + }, + { + name: "Elladan", + base: 7, + odd: 1, + sort: (i: number) => (n(i) / td(i, 18)) * h[i], + shield: "boeotian", + }, + { + name: "Romian", + base: 8, + odd: 0.2, + sort: (i: number) => n(i) / td(i, 15) / t[i], + shield: "roman", + }, + { + name: "Soumi", + base: 9, + odd: 1, + sort: (i: number) => (n(i) / td(i, 5) / bd(i, [9])) * t[i], + shield: "pavise", + }, + { + name: "Portuzian", + base: 13, + odd: 1, + sort: (i: number) => n(i) / td(i, 17) / sf(i), + shield: "renaissance", + }, + { + name: "Vengrian", + base: 15, + odd: 1, + sort: (i: number) => (n(i) / td(i, 11) / bd(i, [4])) * t[i], + shield: "horsehead2", + }, + { + name: "Turchian", + base: 16, + odd: 0.05, + sort: (i: number) => n(i) / td(i, 14), + shield: "round", + }, + { + name: "Euskati", + base: 20, + odd: 0.05, + sort: (i: number) => (n(i) / td(i, 15)) * h[i], + shield: "oldFrench", + }, + { + name: "Keltan", + base: 22, + odd: 0.05, + sort: (i: number) => (n(i) / td(i, 11) / bd(i, [6, 8])) * t[i], + shield: "oval", + }, + ]; + } + + if (culturesSet.value === "oriental") { + return [ + { + name: "Koryo", + base: 10, + odd: 1, + sort: (i: number) => n(i) / td(i, 12) / t[i], + shield: "round", + }, + { + name: "Hantzu", + base: 11, + odd: 1, + sort: (i: number) => n(i) / td(i, 13), + shield: "banner", + }, + { + name: "Yamoto", + base: 12, + odd: 1, + sort: (i: number) => n(i) / td(i, 15) / t[i], + shield: "round", + }, + { + name: "Turchian", + base: 16, + odd: 1, + sort: (i: number) => n(i) / td(i, 12), + shield: "round", + }, + { + name: "Berberan", + base: 17, + odd: 0.2, + sort: (i: number) => (n(i) / td(i, 19) / bd(i, [1, 2, 3], 7)) * t[i], + shield: "oval", + }, + { + name: "Eurabic", + base: 18, + odd: 1, + sort: (i: number) => (n(i) / td(i, 26) / bd(i, [1, 2], 7)) * t[i], + shield: "oval", + }, + { + name: "Efratic", + base: 23, + odd: 0.1, + sort: (i: number) => (n(i) / td(i, 22)) * t[i], + shield: "round", + }, + { + name: "Tehrani", + base: 24, + odd: 1, + sort: (i: number) => (n(i) / td(i, 18)) * h[i], + shield: "round", + }, + { + name: "Maui", + base: 25, + odd: 0.2, + sort: (i: number) => n(i) / td(i, 24) / sf(i) / t[i], + shield: "vesicaPiscis", + }, + { + name: "Carnatic", + base: 26, + odd: 0.5, + sort: (i: number) => n(i) / td(i, 26), + shield: "round", + }, + { + name: "Vietic", + base: 29, + odd: 0.8, + sort: (i: number) => n(i) / td(i, 25) / bd(i, [7], 7) / t[i], + shield: "banner", + }, + { + name: "Guantzu", + base: 30, + odd: 0.5, + sort: (i: number) => n(i) / td(i, 17), + shield: "banner", + }, + { + name: "Ulus", + base: 31, + odd: 1, + sort: (i: number) => (n(i) / td(i, 5) / bd(i, [2, 4, 10], 7)) * t[i], + shield: "banner", + }, + ]; + } + + if (culturesSet.value === "english") { + const getName = () => Names.getBase(1, 5, 9, ""); + return [ + { name: getName(), base: 1, odd: 1, shield: "heater" }, + { name: getName(), base: 1, odd: 1, shield: "wedged" }, + { name: getName(), base: 1, odd: 1, shield: "swiss" }, + { name: getName(), base: 1, odd: 1, shield: "oldFrench" }, + { name: getName(), base: 1, odd: 1, shield: "swiss" }, + { name: getName(), base: 1, odd: 1, shield: "spanish" }, + { name: getName(), base: 1, odd: 1, shield: "hessen" }, + { name: getName(), base: 1, odd: 1, shield: "fantasy5" }, + { name: getName(), base: 1, odd: 1, shield: "fantasy4" }, + { name: getName(), base: 1, odd: 1, shield: "fantasy1" }, + ]; + } + + if (culturesSet.value === "antique") { + return [ + { + name: "Roman", + base: 8, + odd: 1, + sort: (i: number) => n(i) / td(i, 14) / t[i], + shield: "roman", + }, // Roman + { + name: "Roman", + base: 8, + odd: 1, + sort: (i: number) => n(i) / td(i, 15) / sf(i), + shield: "roman", + }, // Roman + { + name: "Roman", + base: 8, + odd: 1, + sort: (i: number) => n(i) / td(i, 16) / sf(i), + shield: "roman", + }, // Roman + { + name: "Roman", + base: 8, + odd: 1, + sort: (i: number) => n(i) / td(i, 17) / t[i], + shield: "roman", + }, // Roman + { + name: "Hellenic", + base: 7, + odd: 1, + sort: (i: number) => (n(i) / td(i, 18) / sf(i)) * h[i], + shield: "boeotian", + }, // Greek + { + name: "Hellenic", + base: 7, + odd: 1, + sort: (i: number) => (n(i) / td(i, 19) / sf(i)) * h[i], + shield: "boeotian", + }, // Greek + { + name: "Macedonian", + base: 7, + odd: 0.5, + sort: (i: number) => (n(i) / td(i, 12)) * h[i], + shield: "round", + }, // Greek + { + name: "Celtic", + base: 22, + odd: 1, + sort: (i: number) => n(i) / td(i, 11) ** 0.5 / bd(i, [6, 8]), + shield: "round", + }, + { + name: "Germanic", + base: 0, + odd: 1, + sort: (i: number) => n(i) / td(i, 10) ** 0.5 / bd(i, [6, 8]), + shield: "round", + }, + { + name: "Persian", + base: 24, + odd: 0.8, + sort: (i: number) => (n(i) / td(i, 18)) * h[i], + shield: "oval", + }, // Iranian + { + name: "Scythian", + base: 24, + odd: 0.5, + sort: (i: number) => n(i) / td(i, 11) ** 0.5 / bd(i, [4]), + shield: "round", + }, // Iranian + { + name: "Cantabrian", + base: 20, + odd: 0.5, + sort: (i: number) => (n(i) / td(i, 16)) * h[i], + shield: "oval", + }, // Basque + { + name: "Estian", + base: 9, + odd: 0.2, + sort: (i: number) => (n(i) / td(i, 5)) * t[i], + shield: "pavise", + }, // Finnic + { + name: "Carthaginian", + base: 42, + odd: 0.3, + sort: (i: number) => n(i) / td(i, 20) / sf(i), + shield: "oval", + }, // Levantine + { + name: "Hebrew", + base: 42, + odd: 0.2, + sort: (i: number) => (n(i) / td(i, 19)) * sf(i), + shield: "oval", + }, // Levantine + { + name: "Mesopotamian", + base: 23, + odd: 0.2, + sort: (i: number) => n(i) / td(i, 22) / bd(i, [1, 2, 3]), + shield: "oval", + }, // Mesopotamian + ]; + } + + if (culturesSet.value === "highFantasy") { + return [ + // fantasy races + { + name: "Quenian (Elfish)", + base: 33, + odd: 1, + sort: (i: number) => (n(i) / bd(i, [6, 7, 8, 9], 10)) * t[i], + shield: "gondor", + }, // Elves + { + name: "Eldar (Elfish)", + base: 33, + odd: 1, + sort: (i: number) => (n(i) / bd(i, [6, 7, 8, 9], 10)) * t[i], + shield: "noldor", + }, // Elves + { + name: "Trow (Dark Elfish)", + base: 34, + odd: 0.9, + sort: (i: number) => (n(i) / bd(i, [7, 8, 9, 12], 10)) * t[i], + shield: "hessen", + }, // Dark Elves + { + name: "Lothian (Dark Elfish)", + base: 34, + odd: 0.3, + sort: (i: number) => (n(i) / bd(i, [7, 8, 9, 12], 10)) * t[i], + shield: "wedged", + }, // Dark Elves + { + name: "Dunirr (Dwarven)", + base: 35, + odd: 1, + sort: (i: number) => n(i) + h[i], + shield: "ironHills", + }, // Dwarfs + { + name: "Khazadur (Dwarven)", + base: 35, + odd: 1, + sort: (i: number) => n(i) + h[i], + shield: "erebor", + }, // Dwarfs + { + name: "Kobold (Goblin)", + base: 36, + odd: 1, + sort: (i: number) => t[i] - s[i], + shield: "moriaOrc", + }, // Goblin + { + name: "Uruk (Orkish)", + base: 37, + odd: 1, + sort: (i: number) => h[i] * t[i], + shield: "urukHai", + }, // Orc + { + name: "Ugluk (Orkish)", + base: 37, + odd: 0.5, + sort: (i: number) => (h[i] * t[i]) / bd(i, [1, 2, 10, 11]), + shield: "moriaOrc", + }, // Orc + { + name: "Yotunn (Giants)", + base: 38, + odd: 0.7, + sort: (i: number) => td(i, -10), + shield: "pavise", + }, // Giant + { + name: "Rake (Drakonic)", + base: 39, + odd: 0.7, + sort: (i: number) => -s[i], + shield: "fantasy2", + }, // Draconic + { + name: "Arago (Arachnid)", + base: 40, + odd: 0.7, + sort: (i: number) => t[i] - s[i], + shield: "horsehead2", + }, // Arachnid + { + name: "Aj'Snaga (Serpents)", + base: 41, + odd: 0.7, + sort: (i: number) => n(i) / bd(i, [12], 10), + shield: "fantasy1", + }, // Serpents + // fantasy human + { + name: "Anor (Human)", + base: 32, + odd: 1, + sort: (i: number) => n(i) / td(i, 10), + shield: "fantasy5", + }, + { + name: "Dail (Human)", + base: 32, + odd: 1, + sort: (i: number) => n(i) / td(i, 13), + shield: "roman", + }, + { + name: "Rohand (Human)", + base: 16, + odd: 1, + sort: (i: number) => n(i) / td(i, 16), + shield: "round", + }, + { + name: "Dulandir (Human)", + base: 31, + odd: 1, + sort: (i: number) => (n(i) / td(i, 5) / bd(i, [2, 4, 10], 7)) * t[i], + shield: "easterling", + }, + ]; + } + + if (culturesSet.value === "darkFantasy") { + return [ + // common real-world English + { + name: "Angshire", + base: 1, + odd: 1, + sort: (i: number) => n(i) / td(i, 10) / sf(i), + shield: "heater", + }, + { + name: "Enlandic", + base: 1, + odd: 1, + sort: (i: number) => n(i) / td(i, 12), + shield: "heater", + }, + { + name: "Westen", + base: 1, + odd: 1, + sort: (i: number) => n(i) / td(i, 10), + shield: "heater", + }, + { + name: "Nortumbic", + base: 1, + odd: 1, + sort: (i: number) => n(i) / td(i, 7), + shield: "heater", + }, + { + name: "Mercian", + base: 1, + odd: 1, + sort: (i: number) => n(i) / td(i, 9), + shield: "heater", + }, + { + name: "Kentian", + base: 1, + odd: 1, + sort: (i: number) => n(i) / td(i, 12), + shield: "heater", + }, + // rare real-world western + { + name: "Norse", + base: 6, + odd: 0.7, + sort: (i: number) => n(i) / td(i, 5) / sf(i), + shield: "oldFrench", + }, + { + name: "Schwarzen", + base: 0, + odd: 0.3, + sort: (i: number) => n(i) / td(i, 10) / bd(i, [6, 8]), + shield: "gonfalon", + }, + { + name: "Luarian", + base: 2, + odd: 0.3, + sort: (i: number) => n(i) / td(i, 12) / bd(i, [6, 8]), + shield: "oldFrench", + }, + { + name: "Hetallian", + base: 3, + odd: 0.3, + sort: (i: number) => n(i) / td(i, 15), + shield: "oval", + }, + { + name: "Astellian", + base: 4, + odd: 0.3, + sort: (i: number) => n(i) / td(i, 16), + shield: "spanish", + }, + // rare real-world exotic + { + name: "Kiswaili", + base: 28, + odd: 0.05, + sort: (i: number) => n(i) / td(i, 29) / bd(i, [1, 3, 5, 7]), + shield: "vesicaPiscis", + }, + { + name: "Yoruba", + base: 21, + odd: 0.05, + sort: (i: number) => n(i) / td(i, 15) / bd(i, [5, 7]), + shield: "vesicaPiscis", + }, + { + name: "Koryo", + base: 10, + odd: 0.05, + sort: (i: number) => n(i) / td(i, 12) / t[i], + shield: "round", + }, + { + name: "Hantzu", + base: 11, + odd: 0.05, + sort: (i: number) => n(i) / td(i, 13), + shield: "banner", + }, + { + name: "Yamoto", + base: 12, + odd: 0.05, + sort: (i: number) => n(i) / td(i, 15) / t[i], + shield: "round", + }, + { + name: "Guantzu", + base: 30, + odd: 0.05, + sort: (i: number) => n(i) / td(i, 17), + shield: "banner", + }, + { + name: "Ulus", + base: 31, + odd: 0.05, + sort: (i: number) => (n(i) / td(i, 5) / bd(i, [2, 4, 10], 7)) * t[i], + shield: "banner", + }, + { + name: "Turan", + base: 16, + odd: 0.05, + sort: (i: number) => n(i) / td(i, 12), + shield: "round", + }, + { + name: "Berberan", + base: 17, + odd: 0.05, + sort: (i: number) => (n(i) / td(i, 19) / bd(i, [1, 2, 3], 7)) * t[i], + shield: "round", + }, + { + name: "Eurabic", + base: 18, + odd: 0.05, + sort: (i: number) => (n(i) / td(i, 26) / bd(i, [1, 2], 7)) * t[i], + shield: "round", + }, + { + name: "Slovan", + base: 5, + odd: 0.05, + sort: (i: number) => (n(i) / td(i, 6)) * t[i], + shield: "round", + }, + { + name: "Keltan", + base: 22, + odd: 0.1, + sort: (i: number) => n(i) / td(i, 11) ** 0.5 / bd(i, [6, 8]), + shield: "vesicaPiscis", + }, + { + name: "Elladan", + base: 7, + odd: 0.2, + sort: (i: number) => (n(i) / td(i, 18) / sf(i)) * h[i], + shield: "boeotian", + }, + { + name: "Romian", + base: 8, + odd: 0.2, + sort: (i: number) => n(i) / td(i, 14) / t[i], + shield: "roman", + }, + // fantasy races + { + name: "Eldar", + base: 33, + odd: 0.5, + sort: (i: number) => (n(i) / bd(i, [6, 7, 8, 9], 10)) * t[i], + shield: "fantasy5", + }, // Elves + { + name: "Trow", + base: 34, + odd: 0.8, + sort: (i: number) => (n(i) / bd(i, [7, 8, 9, 12], 10)) * t[i], + shield: "hessen", + }, // Dark Elves + { + name: "Durinn", + base: 35, + odd: 0.8, + sort: (i: number) => n(i) + h[i], + shield: "erebor", + }, // Dwarven + { + name: "Kobblin", + base: 36, + odd: 0.8, + sort: (i: number) => t[i] - s[i], + shield: "moriaOrc", + }, // Goblin + { + name: "Uruk", + base: 37, + odd: 0.8, + sort: (i: number) => (h[i] * t[i]) / bd(i, [1, 2, 10, 11]), + shield: "urukHai", + }, // Orc + { + name: "Yotunn", + base: 38, + odd: 0.8, + sort: (i: number) => td(i, -10), + shield: "pavise", + }, // Giant + { + name: "Drake", + base: 39, + odd: 0.9, + sort: (i: number) => -s[i], + shield: "fantasy2", + }, // Draconic + { + name: "Rakhnid", + base: 40, + odd: 0.9, + sort: (i: number) => t[i] - s[i], + shield: "horsehead2", + }, // Arachnid + { + name: "Aj'Snaga", + base: 41, + odd: 0.9, + sort: (i: number) => n(i) / bd(i, [12], 10), + shield: "fantasy1", + }, // Serpents + ]; + } + + if (culturesSet.value === "random") { + return range(count).map(() => { + const rnd = rand(nameBases.length - 1); + const name = Names.getBaseShort(rnd); + return { name, base: rnd, odd: 1, shield: this.getRandomShield() }; + }); + } + + // all-world + return [ + { + name: "Shwazen", + base: 0, + odd: 0.7, + sort: (i: number) => n(i) / td(i, 10) / bd(i, [6, 8]), + shield: "hessen", + }, + { + name: "Angshire", + base: 1, + odd: 1, + sort: (i: number) => n(i) / td(i, 10) / sf(i), + shield: "heater", + }, + { + name: "Luari", + base: 2, + odd: 0.6, + sort: (i: number) => n(i) / td(i, 12) / bd(i, [6, 8]), + shield: "oldFrench", + }, + { + name: "Tallian", + base: 3, + odd: 0.6, + sort: (i: number) => n(i) / td(i, 15), + shield: "horsehead2", + }, + { + name: "Astellian", + base: 4, + odd: 0.6, + sort: (i: number) => n(i) / td(i, 16), + shield: "spanish", + }, + { + name: "Slovan", + base: 5, + odd: 0.7, + sort: (i: number) => (n(i) / td(i, 6)) * t[i], + shield: "round", + }, + { + name: "Norse", + base: 6, + odd: 0.7, + sort: (i: number) => n(i) / td(i, 5), + shield: "heater", + }, + { + name: "Elladan", + base: 7, + odd: 0.7, + sort: (i: number) => (n(i) / td(i, 18)) * h[i], + shield: "boeotian", + }, + { + name: "Romian", + base: 8, + odd: 0.7, + sort: (i: number) => n(i) / td(i, 15), + shield: "roman", + }, + { + name: "Soumi", + base: 9, + odd: 0.3, + sort: (i: number) => (n(i) / td(i, 5) / bd(i, [9])) * t[i], + shield: "pavise", + }, + { + name: "Koryo", + base: 10, + odd: 0.1, + sort: (i: number) => n(i) / td(i, 12) / t[i], + shield: "round", + }, + { + name: "Hantzu", + base: 11, + odd: 0.1, + sort: (i: number) => n(i) / td(i, 13), + shield: "banner", + }, + { + name: "Yamoto", + base: 12, + odd: 0.1, + sort: (i: number) => n(i) / td(i, 15) / t[i], + shield: "round", + }, + { + name: "Portuzian", + base: 13, + odd: 0.4, + sort: (i: number) => n(i) / td(i, 17) / sf(i), + shield: "spanish", + }, + { + name: "Nawatli", + base: 14, + odd: 0.1, + sort: (i: number) => h[i] / td(i, 18) / bd(i, [7]), + shield: "square", + }, + { + name: "Vengrian", + base: 15, + odd: 0.2, + sort: (i: number) => (n(i) / td(i, 11) / bd(i, [4])) * t[i], + shield: "wedged", + }, + { + name: "Turchian", + base: 16, + odd: 0.2, + sort: (i: number) => n(i) / td(i, 13), + shield: "round", + }, + { + name: "Berberan", + base: 17, + odd: 0.1, + sort: (i: number) => (n(i) / td(i, 19) / bd(i, [1, 2, 3], 7)) * t[i], + shield: "round", + }, + { + name: "Eurabic", + base: 18, + odd: 0.2, + sort: (i: number) => (n(i) / td(i, 26) / bd(i, [1, 2], 7)) * t[i], + shield: "round", + }, + { + name: "Inuk", + base: 19, + odd: 0.05, + sort: (i: number) => td(i, -1) / bd(i, [10, 11]) / sf(i), + shield: "square", + }, + { + name: "Euskati", + base: 20, + odd: 0.05, + sort: (i: number) => (n(i) / td(i, 15)) * h[i], + shield: "spanish", + }, + { + name: "Yoruba", + base: 21, + odd: 0.05, + sort: (i: number) => n(i) / td(i, 15) / bd(i, [5, 7]), + shield: "vesicaPiscis", + }, + { + name: "Keltan", + base: 22, + odd: 0.05, + sort: (i: number) => (n(i) / td(i, 11) / bd(i, [6, 8])) * t[i], + shield: "vesicaPiscis", + }, + { + name: "Efratic", + base: 23, + odd: 0.05, + sort: (i: number) => (n(i) / td(i, 22)) * t[i], + shield: "diamond", + }, + { + name: "Tehrani", + base: 24, + odd: 0.1, + sort: (i: number) => (n(i) / td(i, 18)) * h[i], + shield: "round", + }, + { + name: "Maui", + base: 25, + odd: 0.05, + sort: (i: number) => n(i) / td(i, 24) / sf(i) / t[i], + shield: "round", + }, + { + name: "Carnatic", + base: 26, + odd: 0.05, + sort: (i: number) => n(i) / td(i, 26), + shield: "round", + }, + { + name: "Inqan", + base: 27, + odd: 0.05, + sort: (i: number) => h[i] / td(i, 13), + shield: "square", + }, + { + name: "Kiswaili", + base: 28, + odd: 0.1, + sort: (i: number) => n(i) / td(i, 29) / bd(i, [1, 3, 5, 7]), + shield: "vesicaPiscis", + }, + { + name: "Vietic", + base: 29, + odd: 0.1, + sort: (i: number) => n(i) / td(i, 25) / bd(i, [7], 7) / t[i], + shield: "banner", + }, + { + name: "Guantzu", + base: 30, + odd: 0.1, + sort: (i: number) => n(i) / td(i, 17), + shield: "banner", + }, + { + name: "Ulus", + base: 31, + odd: 0.1, + sort: (i: number) => (n(i) / td(i, 5) / bd(i, [2, 4, 10], 7)) * t[i], + shield: "banner", + }, + { + name: "Hebrew", + base: 42, + odd: 0.2, + sort: (i: number) => (n(i) / td(i, 18)) * sf(i), + shield: "oval", + }, // Levantine + ]; + } + + generate() { + TIME && console.time("generateCultures"); + this.cells = pack.cells; + const cultureIds = new Uint16Array(this.cells.i.length); // cell cultures + + const culturesInputNumber = +(byId("culturesInput") as HTMLInputElement) + .value; + const culturesInSetNumber = +( + (byId("culturesSet") as HTMLSelectElement).selectedOptions[0].dataset + .max ?? "0" + ); + let count = Math.min(culturesInputNumber, culturesInSetNumber); + const populated = this.cells.i.filter((i: number) => this.cells.s[i]); // populated cells + + if (populated.length < count * 25) { + count = Math.floor(populated.length / 50); + if (!count) { + WARN && + console.warn( + `There are no populated cells. Cannot generate cultures`, + ); + pack.cultures = [{ name: "Wildlands", i: 0, base: 1, shield: "round" }]; + this.cells.culture = cultureIds; + + alertMessage.innerHTML = /* html */ `The climate is harsh and people cannot live in this world.
+ No cultures, states and burgs will be created.
+ Please consider changing climate settings in the World Configurator`; + + $("#alert").dialog({ + resizable: false, + title: "Extreme climate warning", + buttons: { + Ok: function () { + $(this).dialog("close"); + }, + }, + }); + return; + } else { + WARN && + console.warn( + `Not enough populated cells (${populated.length}). Will generate only ${count} cultures`, + ); + alertMessage.innerHTML = /* html */ ` There are only ${populated.length} populated cells and it's insufficient livable area.
+ Only ${count} out of ${culturesInput.value} requested cultures will be generated.
+ Please consider changing climate settings in the World Configurator`; + $("#alert").dialog({ + resizable: false, + title: "Extreme climate warning", + buttons: { + Ok: function () { + $(this).dialog("close"); + }, + }, + }); + } + } + + const selectCultures = (culturesNumber: number): Culture[] => { + const defaultCultures = this.getDefault(culturesNumber); + const cultures: Culture[] = []; + + pack.cultures?.forEach((culture) => { + if (culture.lock && !culture.removed) cultures.push(culture); + }); + + if (!cultures.length) { + if (culturesNumber === defaultCultures.length) + return defaultCultures as Culture[]; + if (defaultCultures.every((d) => d.odd === 1)) + return defaultCultures.splice(0, culturesNumber) as Culture[]; + } + + for ( + let culture: Culture, rnd: number, i = 0; + cultures.length < culturesNumber && defaultCultures.length > 0; + ) { + do { + rnd = rand(defaultCultures.length - 1); + culture = defaultCultures[rnd] as Culture; + i++; + } while (i < 200 && !P(culture.odd as number)); + cultures.push(culture); + defaultCultures.splice(rnd, 1); + } + return cultures; + }; + + const cultures = selectCultures(count); + pack.cultures = cultures; + const centers = quadtree(); + const colors = getColors(count); + const emblemShape = (byId("emblemShape") as HTMLInputElement).value; + + const codes: string[] = []; + + const placeCenter = (sortingFn: (i: number) => number) => { + let spacing = (graphWidth + graphHeight) / 2 / count; + const MAX_ATTEMPTS = 100; + + const sorted = [...populated].sort((a, b) => sortingFn(b) - sortingFn(a)); + const max = Math.floor(sorted.length / 2); + + let cellId = 0; + for (let i = 0; i < MAX_ATTEMPTS; i++) { + cellId = sorted[biased(0, max, 5)]; + spacing *= 0.9; + if ( + !cultureIds[cellId] && + !centers.find( + this.cells.p[cellId][0], + this.cells.p[cellId][1], + spacing, + ) + ) + break; + } + + return cellId; + }; + + // set culture type based on culture center position + const defineCultureType = (i: number) => { + if (this.cells.h[i] < 70 && [1, 2, 4].includes(this.cells.biome[i])) + return "Nomadic"; // high penalty in forest biomes and near coastline + if (this.cells.h[i] > 50) return "Highland"; // no penalty for hills and mountains, high for other elevations + const f = pack.features[this.cells.f[this.cells.haven[i]]]; // opposite feature + if (f.type === "lake" && f.cells > 5) return "Lake"; // low water cross penalty and high for growth not along coastline + if ( + (this.cells.harbor[i] && f.type !== "lake" && P(0.1)) || + (this.cells.harbor[i] === 1 && P(0.6)) || + (pack.features[this.cells.f[i]].group === "isle" && P(0.4)) + ) + return "Naval"; // low water cross penalty and high for non-along-coastline growth + if (this.cells.r[i] && this.cells.fl[i] > 100) return "River"; // no River cross penalty, penalty for non-River growth + if ( + this.cells.t[i] > 2 && + [3, 7, 8, 9, 10, 12].includes(this.cells.biome[i]) + ) + return "Hunting"; // high penalty in non-native biomes + return "Generic"; + }; + + const defineCultureExpansionism = (type: string) => { + let base = 1; // Generic + if (type === "Lake") base = 0.8; + else if (type === "Naval") base = 1.5; + else if (type === "River") base = 0.9; + else if (type === "Nomadic") base = 1.5; + else if (type === "Hunting") base = 0.7; + else if (type === "Highland") base = 1.2; + return rn( + ((Math.random() * + (byId("sizeVariety") as HTMLInputElement).valueAsNumber) / + 2 + + 1) * + base, + 1, + ); + }; + + cultures.forEach((c: Culture, i: number) => { + const newId = i + 1; + + if (c.lock) { + codes.push(c.code as string); + centers.add(c.center as number); + + for (const i of this.cells.i) { + if (this.cells.culture[i] === c.i) cultureIds[i] = newId; + } + + c.i = newId; + return; + } + + const sortingFn = c.sort ? c.sort : (i: number) => this.cells.s[i]; + const center = placeCenter(sortingFn); + + centers.add(this.cells.p[center]); + c.center = center; + c.i = newId; + delete c.odd; + delete c.sort; + c.color = colors[i]; + c.type = defineCultureType(center); + c.expansionism = defineCultureExpansionism(c.type); + c.origins = [0]; + c.code = abbreviate(c.name, codes); + codes.push(c.code); + cultureIds[center] = newId; + if (emblemShape === "random") c.shield = this.getRandomShield(); + }); + + this.cells.culture = cultureIds; + + // the first culture with id 0 is for wildlands + cultures.unshift({ + name: "Wildlands", + i: 0, + base: 1, + origins: [null], + shield: "round", + }); + + // make sure all bases exist in nameBases + if (!nameBases.length) { + ERROR && + console.error("Name base is empty, default nameBases will be applied"); + nameBases = Names.getNameBases(); + } + + cultures.forEach((c: Culture) => { + c.base = c.base % nameBases.length; + }); + + TIME && console.timeEnd("generateCultures"); + } + + add(center: number) { + const defaultCultures = this.getDefault(); + let culture: number, base: number, name: string; + + if (pack.cultures.length < defaultCultures.length) { + // add one of the default cultures + culture = pack.cultures.length; + base = defaultCultures[culture].base; + name = defaultCultures[culture].name; + } else { + // add random culture based on one of the current ones + culture = rand(pack.cultures.length - 1); + name = Names.getCulture(culture, 5, 8, ""); + base = pack.cultures[culture].base; + } + + const code = abbreviate(name, pack.cultures.map((c) => c.code) as string[]); + const i = pack.cultures.length; + const color = getRandomColor(); + + // define emblem shape + const emblemShape = ( + document.getElementById("emblemShape") as HTMLInputElement + ).value; + + pack.cultures.push({ + name, + color, + base, + center, + i, + expansionism: 1, + type: "Generic", + cells: 0, + area: 0, + rural: 0, + urban: 0, + origins: [pack.cells.culture[center]], + code, + shield: emblemShape === "random" ? this.getRandomShield() : "", + }); + } + + expand() { + TIME && console.time("expandCultures"); + const { cells, cultures } = pack; + + const queue = new FlatQueue(); + const cost: number[] = []; + + const neutralRate = + (byId("neutralRate") as HTMLInputElement)?.valueAsNumber || 1; + const maxExpansionCost = cells.i.length * 0.6 * neutralRate; // limit cost for culture growth + + // remove culture from all cells except of locked + const hasLocked = cultures.some((c) => !c.removed && c.lock); + if (hasLocked) { + for (const cellId of cells.i) { + const culture = cultures[cells.culture[cellId]]; + if (culture.lock) continue; + cells.culture[cellId] = 0; + } + } else { + cells.culture = new Uint16Array(cells.i.length) as unknown as number[]; + } + + for (const culture of cultures) { + if (!culture.i || culture.removed || culture.lock) continue; + queue.push( + { cellId: culture.center, cultureId: culture.i, priority: 0 }, + 0, + ); + } + + const getBiomeCost = (c: number, biome: number, type: string) => { + if (cells.biome[cultures[c].center as number] === biome) return 10; // tiny penalty for native biome + if (type === "Hunting") return biomesData.cost[biome] * 5; // non-native biome penalty for hunters + if (type === "Nomadic" && biome > 4 && biome < 10) + return biomesData.cost[biome] * 10; // forest biome penalty for nomads + return biomesData.cost[biome] * 2; // general non-native biome penalty + }; + + const getHeightCost = (i: number, h: number, type: string) => { + const f = pack.features[cells.f[i]], + a = cells.area[i]; + if (type === "Lake" && f.type === "lake") return 10; // no lake crossing penalty for Lake cultures + if (type === "Naval" && h < 20) return a * 2; // low sea/lake crossing penalty for Naval cultures + if (type === "Nomadic" && h < 20) return a * 50; // giant sea/lake crossing penalty for Nomads + if (h < 20) return a * 6; // general sea/lake crossing penalty + if (type === "Highland" && h < 44) return 3000; // giant penalty for highlanders on lowlands + if (type === "Highland" && h < 62) return 200; // giant penalty for highlanders on lowhills + if (type === "Highland") return 0; // no penalty for highlanders on highlands + if (h >= 67) return 200; // general mountains crossing penalty + if (h >= 44) return 30; // general hills crossing penalty + return 0; + }; + + const getRiverCost = (riverId: number, cellId: number, type: string) => { + if (type === "River") return riverId ? 0 : 100; // penalty for river cultures + if (!riverId) return 0; // no penalty for others if there is no river + return minmax(cells.fl[cellId] / 10, 20, 100); // river penalty from 20 to 100 based on flux + }; + + const getTypeCost = (t: number, type: string) => { + if (t === 1) + return type === "Naval" || type === "Lake" + ? 0 + : type === "Nomadic" + ? 60 + : 20; // penalty for coastline + if (t === 2) return type === "Naval" || type === "Nomadic" ? 30 : 0; // low penalty for land level 2 for Navals and nomads + if (t !== -1) return type === "Naval" || type === "Lake" ? 100 : 0; // penalty for mainland for navals + return 0; + }; + + while (queue.length) { + const { cellId, priority, cultureId } = queue.pop(); + const { type, expansionism } = cultures[cultureId]; + + cells.c[cellId].forEach((neibCellId) => { + if (hasLocked) { + const neibCultureId = cells.culture[neibCellId]; + if (neibCultureId && cultures[neibCultureId].lock) return; // do not overwrite cell of locked culture + } + + const biome = cells.biome[neibCellId]; + const biomeCost = getBiomeCost(cultureId, biome, type as string); + const biomeChangeCost = biome === cells.biome[neibCellId] ? 0 : 20; // penalty on biome change + const heightCost = getHeightCost( + neibCellId, + cells.h[neibCellId], + type as string, + ); + const riverCost = getRiverCost( + cells.r[neibCellId], + neibCellId, + type as string, + ); + const typeCost = getTypeCost(cells.t[neibCellId], type as string); + const cellCost = + (biomeCost + biomeChangeCost + heightCost + riverCost + typeCost) / + (expansionism as number); + const totalCost = priority + cellCost; + + if (totalCost > maxExpansionCost) return; + + if (!cost[neibCellId] || totalCost < cost[neibCellId]) { + if (cells.pop[neibCellId] > 0) cells.culture[neibCellId] = cultureId; // assign culture to populated cell + cost[neibCellId] = totalCost; + queue.push( + { cellId: neibCellId, cultureId, priority: totalCost }, + totalCost, + ); + } + }); + } + + TIME && console.timeEnd("expandCultures"); + } +} + +window.Cultures = new CulturesModule(); diff --git a/src/modules/features.ts b/src/modules/features.ts new file mode 100644 index 00000000..06984af6 --- /dev/null +++ b/src/modules/features.ts @@ -0,0 +1,415 @@ +import Alea from "alea"; +import { polygonArea } from "d3"; +import { + clipPoly, + connectVertices, + createTypedArray, + distanceSquared, + isLand, + isWater, + rn, + TYPED_ARRAY_MAX_VALUES, + unique, +} from "../utils"; + +declare global { + var Features: FeatureModule; +} + +type FeatureType = "ocean" | "lake" | "island"; + +export interface PackedGraphFeature { + i: number; + type: FeatureType; + land: boolean; + border: boolean; + cells: number; + firstCell: number; + vertices: number[]; + area: number; + shoreline: number[]; + height: number; + group: string; + temp: number; + flux: number; + evaporation: number; + name: string; + + // River related + inlets?: number[]; + outlet?: number; + river?: number; + enteringFlux?: number; + closed?: boolean; + outCell?: number; +} + +export interface GridFeature { + i: number; + land: boolean; + border: boolean; + type: FeatureType; +} + +class FeatureModule { + private DEEPER_LAND = 3; + private LANDLOCKED = 2; + private LAND_COAST = 1; + private UNMARKED = 0; + private WATER_COAST = -1; + private DEEP_WATER = -2; + + /** + * calculate distance to coast for every cell + */ + private markup({ + distanceField, + neighbors, + start, + increment, + limit = TYPED_ARRAY_MAX_VALUES.INT8_MAX, + }: { + distanceField: Int8Array; + neighbors: number[][]; + start: number; + increment: number; + limit?: number; + }) { + for ( + let distance = start, marked = Infinity; + marked > 0 && distance !== limit; + distance += increment + ) { + marked = 0; + const prevDistance = distance - increment; + for (let cellId = 0; cellId < neighbors.length; cellId++) { + if (distanceField[cellId] !== prevDistance) continue; + + for (const neighborId of neighbors[cellId]) { + if (distanceField[neighborId] !== this.UNMARKED) continue; + distanceField[neighborId] = distance; + marked++; + } + } + } + } + + /** + * mark Grid features (ocean, lakes, islands) and calculate distance field + */ + markupGrid() { + TIME && console.time("markupGrid"); + Math.random = Alea(seed); // get the same result on heightmap edit in Erase mode + + const { h: heights, c: neighbors, b: borderCells, i } = grid.cells; + const cellsNumber = i.length; + const distanceField = new Int8Array(cellsNumber); // gird.cells.t + const featureIds = new Uint16Array(cellsNumber); // gird.cells.f + const features: GridFeature[] = []; + + const queue = [0]; + for (let featureId = 1; queue[0] !== -1; featureId++) { + const firstCell = queue[0]; + featureIds[firstCell] = featureId; + + const land = heights[firstCell] >= 20; + let border = false; // set true if feature touches map edge + + while (queue.length) { + const cellId = queue.pop() as number; + if (!border && borderCells[cellId]) border = true; + + for (const neighborId of neighbors[cellId]) { + const isNeibLand = heights[neighborId] >= 20; + + if (land === isNeibLand && featureIds[neighborId] === this.UNMARKED) { + featureIds[neighborId] = featureId; + queue.push(neighborId); + } else if (land && !isNeibLand) { + distanceField[cellId] = this.LAND_COAST; + distanceField[neighborId] = this.WATER_COAST; + } + } + } + + const type = land ? "island" : border ? "ocean" : "lake"; + features.push({ i: featureId, land, border, type }); + + queue[0] = featureIds.indexOf(this.UNMARKED); // find unmarked cell + } + + // markup deep ocean cells + this.markup({ + distanceField, + neighbors, + start: this.DEEP_WATER, + increment: -1, + limit: -10, + }); + grid.cells.t = distanceField; + grid.cells.f = featureIds; + grid.features = [0, ...features]; + + TIME && console.timeEnd("markupGrid"); + } + + /** + * mark PackedGraph features (oceans, lakes, islands) and calculate distance field + */ + markupPack() { + const defineHaven = (cellId: number) => { + const waterCells = neighbors[cellId].filter((index: number) => + isWater(index, pack), + ); + const distances = waterCells.map((neibCellId: number) => + distanceSquared(cells.p[cellId], cells.p[neibCellId]), + ); + const closest = distances.indexOf(Math.min.apply(Math, distances)); + + haven[cellId] = waterCells[closest]; + harbor[cellId] = waterCells.length; + }; + + const getCellsData = ( + featureType: string, + firstCell: number, + ): [number, number[]] => { + if (featureType === "ocean") return [firstCell, []]; + + const getType = (cellId: number) => featureIds[cellId]; + const type = getType(firstCell); + const ofSameType = (cellId: number) => getType(cellId) === type; + const ofDifferentType = (cellId: number) => getType(cellId) !== type; + + const startCell = findOnBorderCell(firstCell); + const featureVertices = getFeatureVertices(startCell); + return [startCell, featureVertices]; + + function findOnBorderCell(firstCell: number) { + const isOnBorder = (cellId: number) => + borderCells[cellId] || neighbors[cellId].some(ofDifferentType); + if (isOnBorder(firstCell)) return firstCell; + + const startCell = cells.i.filter(ofSameType).find(isOnBorder); + if (startCell === undefined) + throw new Error( + `Markup: firstCell ${firstCell} is not on the feature or map border`, + ); + + return startCell; + } + + function getFeatureVertices(startCell: number) { + const startingVertex = cells.v[startCell].find((v: number) => + vertices.c[v].some(ofDifferentType), + ); + if (startingVertex === undefined) + throw new Error( + `Markup: startingVertex for cell ${startCell} is not found`, + ); + + return connectVertices({ + vertices, + startingVertex, + ofSameType, + closeRing: false, + }); + } + }; + + const addFeature = ({ + firstCell, + land, + border, + featureId, + totalCells, + }: { + firstCell: number; + land: boolean; + border: boolean; + featureId: number; + totalCells: number; + }): PackedGraphFeature => { + const type = land ? "island" : border ? "ocean" : "lake"; + const [startCell, featureVertices] = getCellsData(type, firstCell); + const points = clipPoly( + featureVertices.map((vertex: number) => vertices.p[vertex]), + ); + const area = polygonArea(points); // feature perimiter area + const absArea = Math.abs(rn(area)); + + const feature: Partial = { + i: featureId, + type, + land, + border, + cells: totalCells, + firstCell: startCell, + vertices: featureVertices, + area: absArea, + shoreline: [], + height: 0, + }; + + if (type === "lake") { + if (area > 0) + feature.vertices = (feature.vertices as number[]).reverse(); + feature.shoreline = unique( + (feature.vertices as number[]).flatMap((vertexIndex) => + vertices.c[vertexIndex].filter((index) => isLand(index, pack)), + ), + ); + feature.height = Lakes.getHeight(feature as PackedGraphFeature); + } + + return { + ...feature, + } as PackedGraphFeature; + }; + + TIME && console.time("markupPack"); + + const { cells, vertices } = pack; + const { c: neighbors, b: borderCells, i } = cells; + const packCellsNumber = i.length; + if (!packCellsNumber) return; // no cells -> there is nothing to do + + const distanceField = new Int8Array(packCellsNumber); // pack.cells.t + const featureIds = new Uint16Array(packCellsNumber); // pack.cells.f + const haven = createTypedArray({ + maxValue: packCellsNumber, + length: packCellsNumber, + }); // haven: opposite water cell + const harbor = new Uint8Array(packCellsNumber); // harbor: number of adjacent water cells + const features: PackedGraphFeature[] = []; + + const queue = [0]; + for (let featureId = 1; queue[0] !== -1; featureId++) { + const firstCell = queue[0]; + featureIds[firstCell] = featureId; + + const land = isLand(firstCell, pack); + let border = Boolean(borderCells[firstCell]); // true if feature touches map border + let totalCells = 1; // count cells in a feature + + while (queue.length) { + const cellId = queue.pop() as number; + if (borderCells[cellId]) border = true; + + for (const neighborId of neighbors[cellId]) { + const isNeibLand = isLand(neighborId, pack); + + if (land && !isNeibLand) { + distanceField[cellId] = this.LAND_COAST; + distanceField[neighborId] = this.WATER_COAST; + if (!haven[cellId]) defineHaven(cellId); + } else if (land && isNeibLand) { + if ( + distanceField[neighborId] === this.UNMARKED && + distanceField[cellId] === this.LAND_COAST + ) + distanceField[neighborId] = this.LANDLOCKED; + else if ( + distanceField[cellId] === this.UNMARKED && + distanceField[neighborId] === this.LAND_COAST + ) + distanceField[cellId] = this.LANDLOCKED; + } + + if (!featureIds[neighborId] && land === isNeibLand) { + queue.push(neighborId); + featureIds[neighborId] = featureId; + totalCells++; + } + } + } + + features.push( + addFeature({ firstCell, land, border, featureId, totalCells }), + ); + queue[0] = featureIds.indexOf(this.UNMARKED); // find unmarked cell + } + + this.markup({ + distanceField, + neighbors, + start: this.DEEPER_LAND, + increment: 1, + }); // markup pack land + this.markup({ + distanceField, + neighbors, + start: this.DEEP_WATER, + increment: -1, + limit: -10, + }); // markup pack water + + pack.cells.t = distanceField; + pack.cells.f = featureIds; + pack.cells.haven = haven; + pack.cells.harbor = harbor; + pack.features = [0 as unknown as PackedGraphFeature, ...features]; + TIME && console.timeEnd("markupPack"); + } + + /** + * define feature groups (ocean, sea, gulf, continent, island, isle, freshwater lake, salt lake, etc.) + */ + defineGroups() { + const gridCellsNumber = grid.cells.i.length; + const OCEAN_MIN_SIZE = gridCellsNumber / 25; + const SEA_MIN_SIZE = gridCellsNumber / 1000; + const CONTINENT_MIN_SIZE = gridCellsNumber / 10; + const ISLAND_MIN_SIZE = gridCellsNumber / 1000; + + const defineIslandGroup = (feature: PackedGraphFeature) => { + const prevFeature = pack.features[pack.cells.f[feature.firstCell - 1]]; + if (prevFeature && prevFeature.type === "lake") return "lake_island"; + if (feature.cells > CONTINENT_MIN_SIZE) return "continent"; + if (feature.cells > ISLAND_MIN_SIZE) return "island"; + return "isle"; + }; + + const defineOceanGroup = (feature: PackedGraphFeature) => { + if (feature.cells > OCEAN_MIN_SIZE) return "ocean"; + if (feature.cells > SEA_MIN_SIZE) return "sea"; + return "gulf"; + }; + + const defineLakeGroup = (feature: PackedGraphFeature) => { + if (feature.temp < -3) return "frozen"; + if ( + feature.height > 60 && + feature.cells < 10 && + feature.firstCell % 10 === 0 + ) + return "lava"; + + if (!feature.inlets && !feature.outlet) { + if (feature.evaporation > feature.flux * 4) return "dry"; + if (feature.cells < 3 && feature.firstCell % 10 === 0) + return "sinkhole"; + } + + if (!feature.outlet && feature.evaporation > feature.flux) return "salt"; + + return "freshwater"; + }; + + const defineGroup = (feature: PackedGraphFeature) => { + if (feature.type === "island") return defineIslandGroup(feature); + if (feature.type === "ocean") return defineOceanGroup(feature); + if (feature.type === "lake") return defineLakeGroup(feature); + throw new Error(`Markup: unknown feature type ${feature.type}`); + }; + + for (const feature of pack.features) { + if (!feature || feature.type === "ocean") continue; + + if (feature.type === "lake") feature.height = Lakes.getHeight(feature); + feature.group = defineGroup(feature); + } + } +} + +window.Features = new FeatureModule(); diff --git a/src/modules/heightmap-generator.ts b/src/modules/heightmap-generator.ts index eb48f9f4..9a6d462a 100644 --- a/src/modules/heightmap-generator.ts +++ b/src/modules/heightmap-generator.ts @@ -1,45 +1,43 @@ import Alea from "alea"; import { range as d3Range, leastIndex, mean } from "d3"; -import { createTypedArray, byId, findGridCell, getNumberInRange, lim, minmax, P, rand } from "../utils"; +import { + byId, + createTypedArray, + findGridCell, + getNumberInRange, + lim, + minmax, + P, + rand, +} from "../utils"; declare global { - interface Window { - HeightmapGenerator: HeightmapGenerator; - } - var heightmapTemplates: any; - var TIME: boolean; - var ERROR: boolean; + var HeightmapGenerator: HeightmapModule; } -type Tool = "Hill" | "Pit" | "Range" | "Trough" | "Strait" | "Mask" | "Invert" | "Add" | "Multiply" | "Smooth"; +type Tool = + | "Hill" + | "Pit" + | "Range" + | "Trough" + | "Strait" + | "Mask" + | "Invert" + | "Add" + | "Multiply" + | "Smooth"; -class HeightmapGenerator { +class HeightmapModule { grid: any = null; heights: Uint8Array | null = null; blobPower: number = 0; linePower: number = 0; - // TODO: remove after migration to TS and use param in constructor - get seed() { - return (window as any).seed; - } - get graphWidth() { - return (window as any).graphWidth; - } - get graphHeight() { - return (window as any).graphHeight; - } - - constructor() { - - } - private clearData() { this.heights = null; this.grid = null; - }; + } - private getBlobPower(cells: number): number { const blobPowerMap: Record = { 1000: 0.93, @@ -54,11 +52,11 @@ class HeightmapGenerator { 70000: 0.9955, 80000: 0.996, 90000: 0.9964, - 100000: 0.9973 + 100000: 0.9973, }; return blobPowerMap[cells] || 0.98; } - + private getLinePower(cells: number): number { const linePowerMap: Record = { 1000: 0.75, @@ -73,42 +71,47 @@ class HeightmapGenerator { 70000: 0.88, 80000: 0.91, 90000: 0.92, - 100000: 0.93 + 100000: 0.93, }; - + return linePowerMap[cells] || 0.81; } - + private getPointInRange(range: string, length: number): number | undefined { if (typeof range !== "string") { window.ERROR && console.error("Range should be a string"); return; } - - const min = parseInt(range.split("-")[0]) / 100 || 0; - const max = parseInt(range.split("-")[1]) / 100 || min; + + const min = parseInt(range.split("-")[0], 10) / 100 || 0; + const max = parseInt(range.split("-")[1], 10) / 100 || min; return rand(min * length, max * length); } setGraph(graph: any) { - const {cellsDesired, cells, points} = graph; - this.heights = cells.h ? Uint8Array.from(cells.h) : createTypedArray({maxValue: 100, length: points.length}) as Uint8Array; + const { cellsDesired, cells, points } = graph; + this.heights = cells.h + ? Uint8Array.from(cells.h) + : (createTypedArray({ + maxValue: 100, + length: points.length, + }) as Uint8Array); this.blobPower = this.getBlobPower(cellsDesired); this.linePower = this.getLinePower(cellsDesired); this.grid = graph; - }; - + } + addHill(count: string, height: string, rangeX: string, rangeY: string): void { const addOneHill = () => { - if(!this.heights || !this.grid) return; + if (!this.heights || !this.grid) return; const change = new Uint8Array(this.heights.length); let limit = 0; let start: number; - let h = lim(getNumberInRange(height)); + const h = lim(getNumberInRange(height)); do { - const x = this.getPointInRange(rangeX, this.graphWidth); - const y = this.getPointInRange(rangeY, this.graphHeight); + const x = this.getPointInRange(rangeX, graphWidth); + const y = this.getPointInRange(rangeY, graphHeight); if (x === undefined || y === undefined) return; start = findGridCell(x, y, this.grid); limit++; @@ -126,25 +129,25 @@ class HeightmapGenerator { } this.heights = this.heights.map((h, i) => lim(h + change[i])); - } + }; const desiredHillCount = getNumberInRange(count); for (let i = 0; i < desiredHillCount; i++) { addOneHill(); } - }; + } addPit(count: string, height: string, rangeX: string, rangeY: string): void { const addOnePit = () => { - if(!this.heights || !this.grid) return; + if (!this.heights || !this.grid) return; const used = new Uint8Array(this.heights.length); let limit = 0; let start: number; let h = lim(getNumberInRange(height)); do { - const x = this.getPointInRange(rangeX, this.graphWidth); - const y = this.getPointInRange(rangeY, this.graphHeight); + const x = this.getPointInRange(rangeX, graphWidth); + const y = this.getPointInRange(rangeY, graphHeight); if (x === undefined || y === undefined) return; start = findGridCell(x, y, this.grid); limit++; @@ -158,24 +161,33 @@ class HeightmapGenerator { this.grid.cells.c[q].forEach((c: number) => { if (used[c] || this.heights === null) return; - this.heights[c] = lim(this.heights[c] - h * (Math.random() * 0.2 + 0.9)); + this.heights[c] = lim( + this.heights[c] - h * (Math.random() * 0.2 + 0.9), + ); used[c] = 1; queue.push(c); }); } - } + }; const desiredPitCount = getNumberInRange(count); for (let i = 0; i < desiredPitCount; i++) { addOnePit(); } - }; + } - addRange(count: string, height: string, rangeX: string, rangeY: string, startCellId?: number, endCellId?: number): void { - if(!this.heights || !this.grid) return; + addRange( + count: string, + height: string, + rangeX: string, + rangeY: string, + startCellId?: number, + endCellId?: number, + ): void { + if (!this.heights || !this.grid) return; const addOneRange = () => { - if(!this.heights || !this.grid) return; + if (!this.heights || !this.grid) return; // get main ridge const getRange = (cur: number, end: number) => { @@ -200,44 +212,49 @@ class HeightmapGenerator { } return range; - } + }; const used = new Uint8Array(this.heights.length); let h = lim(getNumberInRange(height)); if (rangeX && rangeY) { // find start and end points - const startX = this.getPointInRange(rangeX, this.graphWidth) as number; - const startY = this.getPointInRange(rangeY, this.graphHeight) as number; + const startX = this.getPointInRange(rangeX, graphWidth) as number; + const startY = this.getPointInRange(rangeY, graphHeight) as number; let dist = 0; let limit = 0; - let endY; - let endX; + let endY: number; + let endX: number; do { - endX = Math.random() * this.graphWidth * 0.8 + this.graphWidth * 0.1; - endY = Math.random() * this.graphHeight * 0.7 + this.graphHeight * 0.15; + endX = Math.random() * graphWidth * 0.8 + graphWidth * 0.1; + endY = Math.random() * graphHeight * 0.7 + graphHeight * 0.15; dist = Math.abs(endY - startY) + Math.abs(endX - startX); limit++; - } while ((dist < this.graphWidth / 8 || dist > this.graphWidth / 3) && limit < 50); + } while ( + (dist < graphWidth / 8 || dist > graphWidth / 3) && + limit < 50 + ); startCellId = findGridCell(startX, startY, this.grid); endCellId = findGridCell(endX, endY, this.grid); } - let range = getRange(startCellId as number, endCellId as number); - + const range = getRange(startCellId as number, endCellId as number); // add height to ridge and cells around let queue = range.slice(); let i = 0; while (queue.length) { const frontier = queue.slice(); - (queue = []), i++; + queue = []; + i++; frontier.forEach((i: number) => { - if(!this.heights) return; - this.heights[i] = lim(this.heights[i] + h * (Math.random() * 0.3 + 0.85)); + if (!this.heights) return; + this.heights[i] = lim( + this.heights[i] + h * (Math.random() * 0.3 + 0.85), + ); }); h = h ** this.linePower - 1; if (h < 2) break; @@ -255,31 +272,42 @@ class HeightmapGenerator { range.forEach((cur: number, d: number) => { if (d % 6 !== 0) return; for (const _l of d3Range(i)) { - const index = leastIndex(this.grid.cells.c[cur], (a: number, b: number) => this.heights![a] - this.heights![b]); - if(index === undefined) continue; + const index = leastIndex( + this.grid.cells.c[cur], + (a: number, b: number) => this.heights![a] - this.heights![b], + ); + if (index === undefined) continue; const min = this.grid.cells.c[cur][index]; // downhill cell - this.heights![min] = (this.heights![cur] * 2 + this.heights![min]) / 3; + this.heights![min] = + (this.heights![cur] * 2 + this.heights![min]) / 3; cur = min; } }); - } + }; const desiredRangeCount = getNumberInRange(count); for (let i = 0; i < desiredRangeCount; i++) { addOneRange(); } - }; + } - addTrough(count: string, height: string, rangeX: string, rangeY: string, startCellId?: number, endCellId?: number): void { + addTrough( + count: string, + height: string, + rangeX: string, + rangeY: string, + startCellId?: number, + endCellId?: number, + ): void { const addOneTrough = () => { - if(!this.heights || !this.grid) return; + if (!this.heights || !this.grid) return; - // get main ridge + // get main ridge const getRange = (cur: number, end: number) => { const range = [cur]; const p = this.grid.points; used[cur] = 1; - + while (cur !== end) { let min = Infinity; this.grid.cells.c[cur].forEach((e: number) => { @@ -295,13 +323,13 @@ class HeightmapGenerator { range.push(cur); used[cur] = 1; } - + return range; - } + }; const used = new Uint8Array(this.heights.length); let h = lim(getNumberInRange(height)); - + if (rangeX && rangeY) { // find start and end points let limit = 0; @@ -311,34 +339,39 @@ class HeightmapGenerator { let endX: number; let endY: number; do { - startX = this.getPointInRange(rangeX, this.graphWidth) as number; - startY = this.getPointInRange(rangeY, this.graphHeight) as number; + startX = this.getPointInRange(rangeX, graphWidth) as number; + startY = this.getPointInRange(rangeY, graphHeight) as number; startCellId = findGridCell(startX, startY, this.grid); limit++; } while (this.heights[startCellId] < 20 && limit < 50); - + limit = 0; do { - endX = Math.random() * this.graphWidth * 0.8 + this.graphWidth * 0.1; - endY = Math.random() * this.graphHeight * 0.7 + this.graphHeight * 0.15; + endX = Math.random() * graphWidth * 0.8 + graphWidth * 0.1; + endY = Math.random() * graphHeight * 0.7 + graphHeight * 0.15; dist = Math.abs(endY - startY) + Math.abs(endX - startX); limit++; - } while ((dist < this.graphWidth / 8 || dist > this.graphWidth / 2) && limit < 50); - + } while ( + (dist < graphWidth / 8 || dist > graphWidth / 2) && + limit < 50 + ); + endCellId = findGridCell(endX, endY, this.grid); } - - let range = getRange(startCellId as number, endCellId as number); - - + + const range = getRange(startCellId as number, endCellId as number); + // add height to ridge and cells around let queue = range.slice(), - i = 0; + i = 0; while (queue.length) { const frontier = queue.slice(); - (queue = []), i++; + queue = []; + i++; frontier.forEach((i: number) => { - this.heights![i] = lim(this.heights![i] - h * (Math.random() * 0.3 + 0.85)); + this.heights![i] = lim( + this.heights![i] - h * (Math.random() * 0.3 + 0.85), + ); }); h = h ** this.linePower - 1; if (h < 2) break; @@ -351,41 +384,62 @@ class HeightmapGenerator { }); }); } - + // generate prominences range.forEach((cur: number, d: number) => { if (d % 6 !== 0) return; for (const _l of d3Range(i)) { - const index = leastIndex(this.grid.cells.c[cur], (a: number, b: number) => this.heights![a] - this.heights![b]); - if(index === undefined) continue; + const index = leastIndex( + this.grid.cells.c[cur], + (a: number, b: number) => this.heights![a] - this.heights![b], + ); + if (index === undefined) continue; const min = this.grid.cells.c[cur][index]; // downhill cell //debug.append("circle").attr("cx", p[min][0]).attr("cy", p[min][1]).attr("r", 1); - this.heights![min] = (this.heights![cur] * 2 + this.heights![min]) / 3; + this.heights![min] = + (this.heights![cur] * 2 + this.heights![min]) / 3; cur = min; } }); - } + }; const desiredTroughCount = getNumberInRange(count); - for(let i = 0; i < desiredTroughCount; i++) { + for (let i = 0; i < desiredTroughCount; i++) { addOneTrough(); } - }; - + } + addStrait(width: string, direction = "vertical"): void { - if(!this.heights || !this.grid) return; - const desiredWidth = Math.min(getNumberInRange(width), this.grid.cellsX / 3); + if (!this.heights || !this.grid) return; + const desiredWidth = Math.min( + getNumberInRange(width), + this.grid.cellsX / 3, + ); if (desiredWidth < 1 && P(desiredWidth)) return; const used = new Uint8Array(this.heights.length); const vert = direction === "vertical"; - const startX = vert ? Math.floor(Math.random() * this.graphWidth * 0.4 + this.graphWidth * 0.3) : 5; - const startY = vert ? 5 : Math.floor(Math.random() * this.graphHeight * 0.4 + this.graphHeight * 0.3); + const startX = vert + ? Math.floor(Math.random() * graphWidth * 0.4 + graphWidth * 0.3) + : 5; + const startY = vert + ? 5 + : Math.floor(Math.random() * graphHeight * 0.4 + graphHeight * 0.3); const endX = vert - ? Math.floor(this.graphWidth - startX - this.graphWidth * 0.1 + Math.random() * this.graphWidth * 0.2) - : this.graphWidth - 5; + ? Math.floor( + graphWidth - + startX - + graphWidth * 0.1 + + Math.random() * graphWidth * 0.2, + ) + : graphWidth - 5; const endY = vert - ? this.graphHeight - 5 - : Math.floor(this.graphHeight - startY - this.graphHeight * 0.1 + Math.random() * this.graphHeight * 0.2); + ? graphHeight - 5 + : Math.floor( + graphHeight - + startY - + graphHeight * 0.1 + + Math.random() * graphHeight * 0.2, + ); const start = findGridCell(startX, startY, this.grid); const end = findGridCell(endX, endY, this.grid); @@ -408,14 +462,13 @@ class HeightmapGenerator { } return range; - } + }; let range = getRange(start, end); const query: number[] = []; - const step = 0.1 / desiredWidth; - for(let i = 0; i < desiredWidth; i++) { + for (let i = 0; i < desiredWidth; i++) { const exp = 0.9 - step * desiredWidth; range.forEach((r: number) => { this.grid.cells.c[r].forEach((e: number) => { @@ -428,15 +481,17 @@ class HeightmapGenerator { }); range = query.slice(); } - }; + } modify(range: string, add: number, mult: number, power?: number): void { - if(!this.heights) return; - const min = range === "land" ? 20 : range === "all" ? 0 : +range.split("-")[0]; - const max = range === "land" || range === "all" ? 100 : +range.split("-")[1]; + if (!this.heights) return; + const min = + range === "land" ? 20 : range === "all" ? 0 : +range.split("-")[0]; + const max = + range === "land" || range === "all" ? 100 : +range.split("-")[1]; const isLand = min === 20; - this.heights = this.heights.map(h => { + this.heights = this.heights.map((h) => { if (h < min || h > max) return h; if (add) h = isLand ? Math.max(h + add, 20) : h + add; @@ -444,42 +499,44 @@ class HeightmapGenerator { if (power) h = isLand ? (h - 20) ** power + 20 : h ** power; return lim(h); }); - }; + } smooth(fr = 2, add = 0): void { - if(!this.heights || !this.grid) return; + if (!this.heights || !this.grid) return; this.heights = this.heights.map((h, i) => { const a = [h]; - this.grid.cells.c[i].forEach((c: number) => a.push(this.heights![c])); + this.grid.cells.c[i].forEach((c: number) => { + a.push(this.heights![c]); + }); if (fr === 1) return (mean(a) as number) + add; return lim((h * (fr - 1) + (mean(a) as number) + add) / fr); }); - }; + } mask(power = 1): void { - if(!this.heights || !this.grid) return; + if (!this.heights || !this.grid) return; const fr = power ? Math.abs(power) : 1; this.heights = this.heights.map((h, i) => { const [x, y] = this.grid.points[i]; - const nx = (2 * x) / this.graphWidth - 1; // [-1, 1], 0 is center - const ny = (2 * y) / this.graphHeight - 1; // [-1, 1], 0 is center + const nx = (2 * x) / graphWidth - 1; // [-1, 1], 0 is center + const ny = (2 * y) / graphHeight - 1; // [-1, 1], 0 is center let distance = (1 - nx ** 2) * (1 - ny ** 2); // 1 is center, 0 is edge if (power < 0) distance = 1 - distance; // inverted, 0 is center, 1 is edge const masked = h * distance; return lim((h * (fr - 1) + masked) / fr); }); - }; + } invert(count: number, axes: string): void { if (!P(count) || !this.heights || !this.grid) return; const invertX = axes !== "y"; const invertY = axes !== "x"; - const {cellsX, cellsY} = this.grid; + const { cellsX, cellsY } = this.grid; const inverted = this.heights.map((_h: number, i: number) => { - if(!this.heights) return 0; + if (!this.heights) return 0; const x = i % cellsX; const y = Math.floor(i / cellsX); @@ -490,66 +547,104 @@ class HeightmapGenerator { }); this.heights = inverted; - }; + } addStep(tool: Tool, a2: string, a3: string, a4: string, a5: string): void { - if (tool === "Hill") return this.addHill(a2, a3, a4, a5); - if (tool === "Pit") return this.addPit(a2, a3, a4, a5); - if (tool === "Range") return this.addRange(a2, a3, a4, a5); - if (tool === "Trough") return this.addTrough(a2, a3, a4, a5); - if (tool === "Strait") return this.addStrait(a2, a3); - if (tool === "Mask") return this.mask(+a2); - if (tool === "Invert") return this.invert(+a2, a3); - if (tool === "Add") return this.modify(a3, +a2, 1); - if (tool === "Multiply") return this.modify(a3, 0, +a2); - if (tool === "Smooth") return this.smooth(+a2); + if (tool === "Hill") { + this.addHill(a2, a3, a4, a5); + return; + } + if (tool === "Pit") { + this.addPit(a2, a3, a4, a5); + return; + } + if (tool === "Range") { + this.addRange(a2, a3, a4, a5); + return; + } + if (tool === "Trough") { + this.addTrough(a2, a3, a4, a5); + return; + } + if (tool === "Strait") { + this.addStrait(a2, a3); + return; + } + if (tool === "Mask") { + this.mask(+a2); + return; + } + if (tool === "Invert") { + this.invert(+a2, a3); + return; + } + if (tool === "Add") { + this.modify(a3, +a2, 1); + return; + } + if (tool === "Multiply") { + this.modify(a3, 0, +a2); + return; + } + if (tool === "Smooth") { + this.smooth(+a2); + return; + } } async generate(graph: any): Promise { TIME && console.time("defineHeightmap"); const id = (byId("templateInput")! as HTMLInputElement).value; - - Math.random = Alea(this.seed); + Math.random = Alea(seed); const isTemplate = id in heightmapTemplates; - - const heights = isTemplate ? this.fromTemplate(graph, id) : await this.fromPrecreated(graph, id); + + const heights = isTemplate + ? this.fromTemplate(graph, id) + : await this.fromPrecreated(graph, id); TIME && console.timeEnd("defineHeightmap"); this.clearData(); return heights as Uint8Array; } - fromTemplate(graph: any, id: string): Uint8Array | null { + fromTemplate(graph: any, id: string): Uint8Array | null { const templateString = heightmapTemplates[id]?.template || ""; const steps = templateString.split("\n"); - if (!steps.length) throw new Error(`Heightmap template: no steps. Template: ${id}. Steps: ${steps}`); + if (!steps.length) + throw new Error( + `Heightmap template: no steps. Template: ${id}. Steps: ${steps}`, + ); this.setGraph(graph); for (const step of steps) { const elements = step.trim().split(" "); - if (elements.length < 2) throw new Error(`Heightmap template: steps < 2. Template: ${id}. Step: ${elements}`); - this.addStep(...elements as [Tool, string, string, string, string]); + if (elements.length < 2) + throw new Error( + `Heightmap template: steps < 2. Template: ${id}. Step: ${elements}`, + ); + this.addStep(...(elements as [Tool, string, string, string, string])); } return this.heights; - }; + } private getHeightsFromImageData(imageData: Uint8ClampedArray): void { - if(!this.heights) return; + if (!this.heights) return; for (let i = 0; i < this.heights.length; i++) { const lightness = imageData[i * 4] / 255; - const powered = lightness < 0.2 ? lightness : 0.2 + (lightness - 0.2) ** 0.8; + const powered = + lightness < 0.2 ? lightness : 0.2 + (lightness - 0.2) ** 0.8; this.heights[i] = minmax(Math.floor(powered * 100), 0, 100); } } fromPrecreated(graph: any, id: string): Promise { - return new Promise(resolve => { + return new Promise((resolve) => { // create canvas where 1px corresponds to a cell const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d") as CanvasRenderingContext2D; - const {cellsX, cellsY} = graph; + const { cellsX, cellsY } = graph; canvas.width = cellsX; canvas.height = cellsY; @@ -557,12 +652,10 @@ class HeightmapGenerator { const img = new Image(); img.src = `./heightmaps/${id}.png`; img.onload = () => { - if(!ctx) { + if (!ctx) { throw new Error("Could not get canvas context"); } - if(!this.heights) { - throw new Error("Heights array is not initialized"); - } + this.heights = this.heights || new Uint8Array(cellsX * cellsY); ctx.drawImage(img, 0, 0, cellsX, cellsY); const imageData = ctx.getImageData(0, 0, cellsX, cellsY); this.setGraph(graph); @@ -572,11 +665,11 @@ class HeightmapGenerator { resolve(this.heights); }; }); - }; + } getHeights() { return this.heights; } } -window.HeightmapGenerator = new HeightmapGenerator(); \ No newline at end of file +window.HeightmapGenerator = new HeightmapModule(); diff --git a/src/modules/index.ts b/src/modules/index.ts index fe1135c0..a9ebf2b8 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -1,2 +1,15 @@ import "./voronoi"; -import "./heightmap-generator"; \ No newline at end of file +import "./heightmap-generator"; +import "./features"; +import "./names-generator"; +import "./ocean-layers"; +import "./lakes"; +import "./river-generator"; +import "./burgs-generator"; +import "./biomes"; +import "./cultures-generator"; +import "./routes-generator"; +import "./states-generator"; +import "./zones-generator"; +import "./religions-generator"; +import "./provinces-generator"; diff --git a/src/modules/lakes.ts b/src/modules/lakes.ts new file mode 100644 index 00000000..6a95d0af --- /dev/null +++ b/src/modules/lakes.ts @@ -0,0 +1,146 @@ +import { mean, min } from "d3"; +import { byId, rn } from "../utils"; +import type { PackedGraphFeature } from "./features"; + +declare global { + var Lakes: LakesModule; +} + +export class LakesModule { + private LAKE_ELEVATION_DELTA = 0.1; + + getHeight(feature: PackedGraphFeature) { + const heights = pack.cells.h; + const minShoreHeight = + min(feature.shoreline.map((cellId) => heights[cellId])) || 20; + return rn(minShoreHeight - this.LAKE_ELEVATION_DELTA, 2); + } + + defineNames() { + pack.features.forEach((feature: PackedGraphFeature) => { + if (feature.type !== "lake") return; + feature.name = this.getName(feature); + }); + } + + getName(feature: PackedGraphFeature): string { + const landCell = feature.shoreline[0]; + const culture = pack.cells.culture[landCell]; + return Names.getCulture(culture); + } + + cleanupLakeData = () => { + for (const feature of pack.features) { + if (feature.type !== "lake") continue; + delete feature.river; + delete feature.enteringFlux; + delete feature.outCell; + delete feature.closed; + feature.height = rn(feature.height, 3); + + const inlets = feature.inlets?.filter((r) => + pack.rivers.find((river) => river.i === r), + ); + if (!inlets || !inlets.length) delete feature.inlets; + else feature.inlets = inlets; + + const outlet = + feature.outlet && + pack.rivers.find((river) => river.i === feature.outlet); + if (!outlet) delete feature.outlet; + } + }; + + defineClimateData(heights: number[] | Uint8Array) { + const { cells, features } = pack; + const lakeOutCells = new Uint16Array(cells.i.length); + + const getFlux = (lake: PackedGraphFeature) => { + return lake.shoreline.reduce( + (acc, c) => acc + grid.cells.prec[cells.g[c]], + 0, + ); + }; + + const getLakeTemp = (lake: PackedGraphFeature) => { + if (lake.cells < 6) return grid.cells.temp[cells.g[lake.firstCell]]; + return rn( + mean(lake.shoreline.map((c) => grid.cells.temp[cells.g[c]])) as number, + 1, + ); + }; + + const getLakeEvaporation = (lake: PackedGraphFeature) => { + const height = (lake.height - 18) ** Number(heightExponentInput.value); // height in meters + const evaporation = + ((700 * (lake.temp + 0.006 * height)) / 50 + 75) / (80 - lake.temp); // based on Penman formula, [1-11] + return rn(evaporation * lake.cells); + }; + + const getLowestShoreCell = (lake: PackedGraphFeature) => { + return lake.shoreline.sort((a, b) => heights[a] - heights[b])[0]; + }; + + features.forEach((feature) => { + if (feature.type !== "lake") return; + feature.flux = getFlux(feature); + feature.temp = getLakeTemp(feature); + feature.evaporation = getLakeEvaporation(feature); + if (feature.closed) return; // no outlet for lakes in depressed areas + + feature.outCell = getLowestShoreCell(feature); + lakeOutCells[feature.outCell as number] = feature.i; + }); + + return lakeOutCells; + } + + // check if lake can be potentially open (not in deep depression) + detectCloseLakes(h: number[] | Uint8Array) { + const { cells } = pack; + const ELEVATION_LIMIT = +( + byId("lakeElevationLimitOutput") as HTMLInputElement + )?.value; + + pack.features.forEach((feature) => { + if (feature.type !== "lake") return; + delete feature.closed; + + const MAX_ELEVATION = feature.height + ELEVATION_LIMIT; + if (MAX_ELEVATION > 99) { + feature.closed = false; + return; + } + + let isDeep = true; + const lowestShorelineCell = feature.shoreline.sort( + (a, b) => h[a] - h[b], + )[0]; + const queue = [lowestShorelineCell]; + const checked = []; + checked[lowestShorelineCell] = true; + + while (queue.length && isDeep) { + const cellId: number = queue.pop() as number; + + for (const neibCellId of cells.c[cellId]) { + if (checked[neibCellId]) continue; + if (h[neibCellId] >= MAX_ELEVATION) continue; + + if (h[neibCellId] < 20) { + const nFeature = pack.features[cells.f[neibCellId]]; + if (nFeature.type === "ocean" || feature.height > nFeature.height) + isDeep = false; + } + + checked[neibCellId] = true; + queue.push(neibCellId); + } + } + + feature.closed = isDeep; + }); + } +} + +window.Lakes = new LakesModule(); diff --git a/src/modules/names-generator.ts b/src/modules/names-generator.ts new file mode 100644 index 00000000..5805cc92 --- /dev/null +++ b/src/modules/names-generator.ts @@ -0,0 +1,721 @@ +import { capitalize, isVowel, last, P, ra, rand } from "../utils"; + +declare global { + var Names: NamesGenerator; +} + +export interface NameBase { + name: string; // name of the base + i: number; // index of the base + min: number; // minimum length of generated names + max: number; // maximum length of generated names + d: string; // letters allowed to duplicate + m: number; // multi-word name rate [deprecated] + b: string; // base string with names separated by comma +} + +// Markov chain lookup table: key is a letter (or empty string for word start), value is array of possible next syllables +// Note: Uses array with string keys (sparse array) to match original JS behavior +type MarkovChain = string[][] & Record; + +class NamesGenerator { + chains: (MarkovChain | null)[] = []; // Markov chains for namebases + + calculateChain(namesList: string): MarkovChain { + const chain: MarkovChain = [] as unknown as MarkovChain; + const availableNames = namesList.split(","); + + for (const n of availableNames) { + const name = n.trim().toLowerCase(); + const basic = !/[^\x20-\x7e]/.test(name); // basic printable ASCII chars and English rules can be applied + + // split word into pseudo-syllables + for ( + let i = -1, syllable = ""; + i < name.length; + i += syllable.length || 1, syllable = "" + ) { + const prev = name[i] || ""; // pre-onset letter + let v = 0; // 0 if no vowels in syllable + + for (let c = i + 1; name[c] && syllable.length < 5; c++) { + const that = name[c], + next = name[c + 1]; // next char + syllable += that; + if (syllable === " " || syllable === "-") break; // syllable starts with space or hyphen + if (!next || next === " " || next === "-") break; // no need to check + + if (isVowel(that)) v = 1; // check if letter is vowel + + // do not split some diphthongs + if (that === "y" && next === "e") continue; // 'ye' + if (basic) { + // English-like + if (that === "o" && next === "o") continue; // 'oo' + if (that === "e" && next === "e") continue; // 'ee' + if (that === "a" && next === "e") continue; // 'ae' + if (that === "c" && next === "h") continue; // 'ch' + } + + if (isVowel(that) === (next as unknown as boolean)) break; // two same vowels in a row (original quirky behavior) + if (v && isVowel(name[c + 2])) break; // syllable has vowel and additional vowel is expected soon + } + + if (!chain[prev]) chain[prev] = []; + chain[prev].push(syllable); + } + } + + return chain; + } + + updateChain(index: number): void { + this.chains[index] = nameBases[index]?.b + ? this.calculateChain(nameBases[index].b) + : null; + } + + clearChains(): void { + this.chains = []; + } + + // generate name using Markov's chain + getBase(base: number, min?: number, max?: number, dupl?: string): string { + if (base === undefined) { + ERROR && console.error("Please define a base"); + return "ERROR"; + } + + if (nameBases[base] === undefined) { + if (nameBases[0]) { + WARN && + console.warn( + `Namebase ${base} is not found. First available namebase will be used`, + ); + base = 0; + } else { + ERROR && console.error(`Namebase ${base} is not found`); + return "ERROR"; + } + } + + if (!this.chains[base]) this.updateChain(base); + + const data = this.chains[base]; + if (!data || data[""] === undefined) { + tip( + `Namesbase ${base} is incorrect. Please check in namesbase editor`, + false, + "error", + ); + ERROR && console.error(`Namebase ${base} is incorrect!`); + return "ERROR"; + } + + if (!min) min = nameBases[base].min; + if (!max) max = nameBases[base].max; + if (dupl !== "") dupl = nameBases[base].d; + + let v = data[""], + cur = ra(v), + w = ""; + for (let i = 0; i < 20; i++) { + if (cur === "") { + // end of word + if (w.length < min) { + cur = ""; + w = ""; + v = data[""]; + } else break; + } else { + if (w.length + cur.length > max) { + // word too long + if (w.length < min) w += cur; + break; + } else v = data[last(cur.split("")) as string] || data[""]; + } + + w += cur; + cur = ra(v); + } + + // parse word to get a final name + const l = last(w.split("")); // last letter + if (l === "'" || l === " " || l === "-") w = w.slice(0, -1); // not allow some characters at the end + + let name = [...w].reduce((r, c, i, d) => { + if (c === d[i + 1] && !dupl.includes(c)) return r; // duplication is not allowed + if (!r.length) return c.toUpperCase(); + if (r.slice(-1) === "-" && c === " ") return r; // remove space after hyphen + if (r.slice(-1) === " ") return r + c.toUpperCase(); // capitalize letter after space + if (r.slice(-1) === "-") return r + c.toUpperCase(); // capitalize letter after hyphen + if (c === "a" && d[i + 1] === "e") return r; // "ae" => "e" + if (i + 2 < d.length && c === d[i + 1] && c === d[i + 2]) return r; // remove three same letters in a row + return r + c; + }, ""); + + // join the word if any part has only 1 letter + if (name.split(" ").some((part) => part.length < 2)) + name = name + .split(" ") + .map((p, i) => (i ? p.toLowerCase() : p)) + .join(""); + + if (name.length < 2) { + ERROR && console.error("Name is too short! Random name will be selected"); + name = ra(nameBases[base].b.split(",")); + } + + return name; + } + + // generate name for culture + getCulture( + culture: number, + min?: number, + max?: number, + dupl?: string, + ): string { + if (culture === undefined) { + ERROR && console.error("Please define a culture"); + return "ERROR"; + } + const base = pack.cultures[culture].base; + return this.getBase(base, min, max, dupl); + } + + // generate short name for culture + getCultureShort(culture: number): string { + if (culture === undefined) { + ERROR && console.error("Please define a culture"); + return "ERROR"; + } + return this.getBaseShort(pack.cultures[culture].base); + } + + // generate short name for base + getBaseShort(base: number): string { + const min = nameBases[base] ? nameBases[base].min - 1 : undefined; + const max = min ? Math.max(nameBases[base].max - 2, min) : undefined; + return this.getBase(base, min, max, ""); + } + + private validateSuffix(name: string, suffix: string): string { + if (name.slice(-1 * suffix.length) === suffix) return name; // no suffix if name already ends with it + const s1 = suffix.charAt(0); + if (name.slice(-1) === s1) name = name.slice(0, -1); // remove name last letter if it's a suffix first letter + if ( + isVowel(s1) === isVowel(name.slice(-1)) && + isVowel(s1) === isVowel(name.slice(-2, -1)) + ) + name = name.slice(0, -1); // remove name last char if 2 last chars are the same type as suffix's 1st + if (name.slice(-1) === s1) name = name.slice(0, -1); // remove name last letter if it's a suffix first letter + return name + suffix; + } + + private addSuffix(name: string): string { + const suffix = P(0.8) ? "ia" : "land"; + if (suffix === "ia" && name.length > 6) + name = name.slice(0, -(name.length - 3)); + else if (suffix === "land" && name.length > 6) + name = name.slice(0, -(name.length - 5)); + return this.validateSuffix(name, suffix); + } + + // generate state name based on capital or random name and culture-specific suffix + getState(name: string, culture: number, base?: number): string { + if (name === undefined) { + ERROR && console.error("Please define a base name"); + return "ERROR"; + } + if (culture === undefined && base === undefined) { + ERROR && console.error("Please define a culture"); + return "ERROR"; + } + if (base === undefined) base = pack.cultures[culture].base; + + // exclude endings inappropriate for states name + if (name.includes(" ")) + name = capitalize(name.replace(/ /g, "").toLowerCase()); // don't allow multiword state names + if (name.length > 6 && name.slice(-4) === "berg") name = name.slice(0, -4); // remove -berg for any + if (name.length > 5 && name.slice(-3) === "ton") name = name.slice(0, -3); // remove -ton for any + + if (base === 5 && ["sk", "ev", "ov"].includes(name.slice(-2))) + name = name.slice(0, -2); + // remove -sk/-ev/-ov for Ruthenian + else if (base === 12) return isVowel(name.slice(-1)) ? name : `${name}u`; + // Japanese ends on any vowel or -u + else if (base === 18 && P(0.4)) + name = isVowel(name.slice(0, 1).toLowerCase()) + ? `Al${name.toLowerCase()}` + : `Al ${name}`; // Arabic starts with -Al + + // no suffix for fantasy bases + if (base > 32 && base < 42) return name; + + // define if suffix should be used + if (name.length > 3 && isVowel(name.slice(-1))) { + if (isVowel(name.slice(-2, -1)) && P(0.85)) name = name.slice(0, -2); + // 85% for vv + else if (P(0.7)) name = name.slice(0, -1); + // ~60% for cv + else return name; + } else if (P(0.4)) return name; // 60% for cc and vc + + // define suffix + let suffix = "ia"; // standard suffix + + const rnd = Math.random(), + l = name.length; + if (base === 3 && rnd < 0.03 && l < 7) suffix = "terra"; + // Italian + else if (base === 4 && rnd < 0.03 && l < 7) suffix = "terra"; + // Spanish + else if (base === 13 && rnd < 0.03 && l < 7) suffix = "terra"; + // Portuguese + else if (base === 2 && rnd < 0.03 && l < 7) suffix = "terre"; + // French + else if (base === 0 && rnd < 0.5 && l < 7) suffix = "land"; + // German + else if (base === 1 && rnd < 0.4 && l < 7) suffix = "land"; + // English + else if (base === 6 && rnd < 0.3 && l < 7) suffix = "land"; + // Nordic + else if (base === 32 && rnd < 0.1 && l < 7) suffix = "land"; + // generic Human + else if (base === 7 && rnd < 0.1) suffix = "eia"; + // Greek + else if (base === 9 && rnd < 0.35) suffix = "maa"; + // Finnic + else if (base === 15 && rnd < 0.4 && l < 6) suffix = "orszag"; + // Hungarian + else if (base === 16) suffix = rnd < 0.6 ? "yurt" : "eli"; + // Turkish + else if (base === 10) suffix = "guk"; + // Korean + else if (base === 11) suffix = " Guo"; + // Chinese + else if (base === 14) suffix = rnd < 0.5 && l < 6 ? "tlan" : "co"; + // Nahuatl + else if (base === 17 && rnd < 0.8) suffix = "a"; + // Berber + else if (base === 18 && rnd < 0.8) suffix = "a"; // Arabic + + return this.validateSuffix(name, suffix); + } + + // generato name for the map + getMapName(force: boolean) { + if (!force && locked("mapName")) return; + if (force && locked("mapName")) unlock("mapName"); + const base = P(0.7) ? 2 : P(0.5) ? rand(0, 6) : rand(0, 31); + if (!nameBases[base]) { + tip("Namebase is not found", false, "error"); + return ""; + } + const min = nameBases[base].min - 1; + const max = Math.max(nameBases[base].max - 3, min); + const baseName = this.getBase(base, min, max, "") as string; + const name = P(0.7) ? this.addSuffix(baseName) : baseName; + mapName.value = name; + } + + getNameBases(): NameBase[] { + // name, min length, max length, letters to allow duplication, multi-word name rate [deprecated] + // prettier-ignore + return [ + // real-world bases by Azgaar: + { + name: "German", + i: 0, + min: 5, + max: 12, + d: "lt", + m: 0, + b: "Achern,Aichhalden,Aitern,Albbruck,Alpirsbach,Altensteig,Althengstett,Appenweier,Auggen,Badenen,Badenweiler,Baiersbronn,Ballrechten,Bellingen,Berghaupten,Bernau,Biberach,Biederbach,Binzen,Birkendorf,Birkenfeld,Bischweier,Blumberg,Bollen,Bollschweil,Bonndorf,Bosingen,Braunlingen,Breisach,Breisgau,Breitnau,Brigachtal,Buchenbach,Buggingen,Buhl,Buhlertal,Calw,Dachsberg,Dobel,Donaueschingen,Dornhan,Dornstetten,Dottingen,Dunningen,Durbach,Durrheim,Ebhausen,Ebringen,Efringen,Egenhausen,Ehrenkirchen,Ehrsberg,Eimeldingen,Eisenbach,Elzach,Elztal,Emmendingen,Endingen,Engelsbrand,Enz,Enzklosterle,Eschbronn,Ettenheim,Ettlingen,Feldberg,Fischerbach,Fischingen,Fluorn,Forbach,Freiamt,Freiburg,Freudenstadt,Friedenweiler,Friesenheim,Frohnd,Furtwangen,Gaggenau,Geisingen,Gengenbach,Gernsbach,Glatt,Glatten,Glottertal,Gorwihl,Gottenheim,Grafenhausen,Grenzach,Griesbach,Gutach,Gutenbach,Hag,Haiterbach,Hardt,Harmersbach,Hasel,Haslach,Hausach,Hausen,Hausern,Heitersheim,Herbolzheim,Herrenalb,Herrischried,Hinterzarten,Hochenschwand,Hofen,Hofstetten,Hohberg,Horb,Horben,Hornberg,Hufingen,Ibach,Ihringen,Inzlingen,Kandern,Kappel,Kappelrodeck,Karlsbad,Karlsruhe,Kehl,Keltern,Kippenheim,Kirchzarten,Konigsfeld,Krozingen,Kuppenheim,Kussaberg,Lahr,Lauchringen,Lauf,Laufenburg,Lautenbach,Lauterbach,Lenzkirch,Liebenzell,Loffenau,Loffingen,Lorrach,Lossburg,Mahlberg,Malsburg,Malsch,March,Marxzell,Marzell,Maulburg,Monchweiler,Muhlenbach,Mullheim,Munstertal,Murg,Nagold,Neubulach,Neuenburg,Neuhausen,Neuried,Neuweiler,Niedereschach,Nordrach,Oberharmersbach,Oberkirch,Oberndorf,Oberbach,Oberried,Oberwolfach,Offenburg,Ohlsbach,Oppenau,Ortenberg,otigheim,Ottenhofen,Ottersweier,Peterstal,Pfaffenweiler,Pfalzgrafenweiler,Pforzheim,Rastatt,Renchen,Rheinau,Rheinfelden,Rheinmunster,Rickenbach,Rippoldsau,Rohrdorf,Rottweil,Rummingen,Rust,Sackingen,Sasbach,Sasbachwalden,Schallbach,Schallstadt,Schapbach,Schenkenzell,Schiltach,Schliengen,Schluchsee,Schomberg,Schonach,Schonau,Schonenberg,Schonwald,Schopfheim,Schopfloch,Schramberg,Schuttertal,Schwenningen,Schworstadt,Seebach,Seelbach,Seewald,Sexau,Simmersfeld,Simonswald,Sinzheim,Solden,Staufen,Stegen,Steinach,Steinen,Steinmauern,Straubenhardt,Stuhlingen,Sulz,Sulzburg,Teinach,Tiefenbronn,Tiengen,Titisee,Todtmoos,Todtnau,Todtnauberg,Triberg,Tunau,Tuningen,uhlingen,Unterkirnach,Reichenbach,Utzenfeld,Villingen,Villingendorf,Vogtsburg,Vohrenbach,Waldachtal,Waldbronn,Waldkirch,Waldshut,Wehr,Weil,Weilheim,Weisenbach,Wembach,Wieden,Wiesental,Wildbad,Wildberg,Winzeln,Wittlingen,Wittnau,Wolfach,Wutach,Wutoschingen,Wyhlen,Zavelstein", + }, + { + name: "English", + i: 1, + min: 6, + max: 11, + d: "", + m: 0.1, + b: "Abingdon,Albrighton,Alcester,Almondbury,Altrincham,Amersham,Andover,Appleby,Ashboume,Atherstone,Aveton,Axbridge,Aylesbury,Baldock,Bamburgh,Barton,Basingstoke,Berden,Bere,Berkeley,Berwick,Betley,Bideford,Bingley,Birmingham,Blandford,Blechingley,Bodmin,Bolton,Bootham,Boroughbridge,Boscastle,Bossinney,Bramber,Brampton,Brasted,Bretford,Bridgetown,Bridlington,Bromyard,Bruton,Buckingham,Bungay,Burton,Calne,Cambridge,Canterbury,Carlisle,Castleton,Caus,Charmouth,Chawleigh,Chichester,Chillington,Chinnor,Chipping,Chisbury,Cleobury,Clifford,Clifton,Clitheroe,Cockermouth,Coleshill,Combe,Congleton,Crafthole,Crediton,Cuddenbeck,Dalton,Darlington,Dodbrooke,Drax,Dudley,Dunstable,Dunster,Dunwich,Durham,Dymock,Exeter,Exning,Faringdon,Felton,Fenny,Finedon,Flookburgh,Fowey,Frampton,Gateshead,Gatton,Godmanchester,Grampound,Grantham,Guildford,Halesowen,Halton,Harbottle,Harlow,Hatfield,Hatherleigh,Haydon,Helston,Henley,Hertford,Heytesbury,Hinckley,Hitchin,Holme,Hornby,Horsham,Kendal,Kenilworth,Kilkhampton,Kineton,Kington,Kinver,Kirby,Knaresborough,Knutsford,Launceston,Leighton,Lewes,Linton,Louth,Luton,Lyme,Lympstone,Macclesfield,Madeley,Malborough,Maldon,Manchester,Manningtree,Marazion,Marlborough,Marshfield,Mere,Merryfield,Middlewich,Midhurst,Milborne,Mitford,Modbury,Montacute,Mousehole,Newbiggin,Newborough,Newbury,Newenden,Newent,Norham,Northleach,Noss,Oakham,Olney,Orford,Ormskirk,Oswestry,Padstow,Paignton,Penkneth,Penrith,Penzance,Pershore,Petersfield,Pevensey,Pickering,Pilton,Pontefract,Portsmouth,Preston,Quatford,Reading,Redcliff,Retford,Rockingham,Romney,Rothbury,Rothwell,Salisbury,Saltash,Seaford,Seasalter,Sherston,Shifnal,Shoreham,Sidmouth,Skipsea,Skipton,Solihull,Somerton,Southam,Southwark,Standon,Stansted,Stapleton,Stottesdon,Sudbury,Swavesey,Tamerton,Tarporley,Tetbury,Thatcham,Thaxted,Thetford,Thornbury,Tintagel,Tiverton,Torksey,Totnes,Towcester,Tregoney,Trematon,Tutbury,Uxbridge,Wallingford,Wareham,Warenmouth,Wargrave,Warton,Watchet,Watford,Wendover,Westbury,Westcheap,Weymouth,Whitford,Wickwar,Wigan,Wigmore,Winchelsea,Winkleigh,Wiscombe,Witham,Witheridge,Wiveliscombe,Woodbury,Yeovil", + }, + { + name: "French", + i: 2, + min: 5, + max: 13, + d: "nlrs", + m: 0.1, + b: "Adon,Aillant,Amilly,Andonville,Ardon,Artenay,Ascheres,Ascoux,Attray,Aubin,Audeville,Aulnay,Autruy,Auvilliers,Auxy,Aveyron,Baccon,Bardon,Barville,Batilly,Baule,Bazoches,Beauchamps,Beaugency,Beaulieu,Beaune,Bellegarde,Boesses,Boigny,Boiscommun,Boismorand,Boisseaux,Bondaroy,Bonnee,Bonny,Bordes,Bou,Bougy,Bouilly,Boulay,Bouzonville,Bouzy,Boynes,Bray,Breteau,Briare,Briarres,Bricy,Bromeilles,Bucy,Cepoy,Cercottes,Cerdon,Cernoy,Cesarville,Chailly,Chaingy,Chalette,Chambon,Champoulet,Chanteau,Chantecoq,Chapell,Charme,Charmont,Charsonville,Chateau,Chateauneuf,Chatel,Chatenoy,Chatillon,Chaussy,Checy,Chevannes,Chevillon,Chevilly,Chevry,Chilleurs,Choux,Chuelles,Clery,Coinces,Coligny,Combleux,Combreux,Conflans,Corbeilles,Corquilleroy,Cortrat,Coudroy,Coullons,Coulmiers,Courcelles,Courcy,Courtemaux,Courtempierre,Courtenay,Cravant,Crottes,Dadonville,Dammarie,Dampierre,Darvoy,Desmonts,Dimancheville,Donnery,Dordives,Dossainville,Douchy,Dry,Echilleuses,Egry,Engenville,Epieds,Erceville,Ervauville,Escrennes,Escrignelles,Estouy,Faverelles,Fay,Feins,Ferolles,Ferrieres,Fleury,Fontenay,Foret,Foucherolles,Freville,Gatinais,Gaubertin,Gemigny,Germigny,Gidy,Gien,Girolles,Givraines,Gondreville,Grangermont,Greneville,Griselles,Guigneville,Guilly,Gyleslonains,Huetre,Huisseau,Ingrannes,Ingre,Intville,Isdes,Ivre,Jargeau,Jouy,Juranville,Bussiere,Laas,Ladon,Lailly,Langesse,Leouville,Ligny,Lombreuil,Lorcy,Lorris,Loury,Louzouer,Malesherbois,Marcilly,Mardie,Mareau,Marigny,Marsainvilliers,Melleroy,Menestreau,Merinville,Messas,Meung,Mezieres,Migneres,Mignerette,Mirabeau,Montargis,Montbarrois,Montbouy,Montcresson,Montereau,Montigny,Montliard,Mormant,Morville,Moulinet,Moulon,Nancray,Nargis,Nesploy,Neuville,Neuvy,Nevoy,Nibelle,Nogent,Noyers,Ocre,Oison,Olivet,Ondreville,Onzerain,Orleans,Ormes,Orville,Oussoy,Outarville,Ouzouer,Pannecieres,Pannes,Patay,Paucourt,Pers,Pierrefitte,Pithiverais,Pithiviers,Poilly,Potier,Prefontaines,Presnoy,Pressigny,Puiseaux,Quiers,Ramoulu,Rebrechien,Rouvray,Rozieres,Rozoy,Ruan,Sandillon,Santeau,Saran,Sceaux,Seichebrieres,Semoy,Sennely,Sermaises,Sigloy,Solterre,Sougy,Sully,Sury,Tavers,Thignonville,Thimory,Thorailles,Thou,Tigy,Tivernon,Tournoisis,Trainou,Treilles,Trigueres,Trinay,Vannes,Varennes,Vennecy,Vieilles,Vienne,Viglain,Vignes,Villamblain,Villemandeur,Villemoutiers,Villemurlin,Villeneuve,Villereau,Villevoques,Villorceau,Vimory,Vitry,Vrigny", + }, + { + name: "Italian", + i: 3, + min: 5, + max: 12, + d: "cltr", + m: 0.1, + b: "Accumoli,Acquafondata,Acquapendente,Acuto,Affile,Agosta,Alatri,Albano,Allumiere,Alvito,Amaseno,Amatrice,Anagni,Anguillara,Anticoli,Antrodoco,Anzio,Aprilia,Aquino,Arcinazzo,Ariccia,Arpino,Arsoli,Ausonia,Bagnoregio,Bassiano,Bellegra,Belmonte,Bolsena,Bomarzo,Borgorose,Boville,Bracciano,Broccostella,Calcata,Camerata,Campagnano,Campoli,Canale,Canino,Cantalice,Cantalupo,Capranica,Caprarola,Carbognano,Casalattico,Casalvieri,Castelforte,Castelnuovo,Castiglione,Castro,Castrocielo,Ceccano,Celleno,Cellere,Cerreto,Cervara,Cerveteri,Ciampino,Ciciliano,Cittaducale,Cittareale,Civita,Civitella,Colfelice,Colleferro,Collepardo,Colonna,Concerviano,Configni,Contigliano,Cori,Cottanello,Esperia,Faleria,Farnese,Ferentino,Fiamignano,Filacciano,Fiuggi,Fiumicino,Fondi,Fontana,Fonte,Fontechiari,Formia,Frascati,Frasso,Frosinone,Fumone,Gaeta,Gallese,Gavignano,Genazzano,Giuliano,Gorga,Gradoli,Grottaferrata,Grotte,Guarcino,Guidonia,Ischia,Isola,Labico,Labro,Ladispoli,Latera,Lenola,Leonessa,Licenza,Longone,Lubriano,Maenza,Magliano,Marano,Marcellina,Marcetelli,Marino,Mazzano,Mentana,Micigliano,Minturno,Montalto,Montasola,Montebuono,Monteflavio,Montelanico,Monteleone,Montenero,Monterosi,Moricone,Morlupo,Nazzano,Nemi,Nerola,Nespolo,Nettuno,Norma,Olevano,Onano,Oriolo,Orte,Orvinio,Paganico,Paliano,Palombara,Patrica,Pescorocchiano,Petrella,Piansano,Picinisco,Pico,Piedimonte,Piglio,Pignataro,Poggio,Poli,Pomezia,Pontecorvo,Pontinia,Ponzano,Posta,Pozzaglia,Priverno,Proceno,Rignano,Riofreddo,Ripi,Rivodutri,Rocca,Roccagorga,Roccantica,Roccasecca,Roiate,Ronciglione,Roviano,Salisano,Sambuci,Santa,Santini,Scandriglia,Segni,Selci,Sermoneta,Serrone,Settefrati,Sezze,Sgurgola,Sonnino,Sora,Soriano,Sperlonga,Spigno,Subiaco,Supino,Sutri,Tarano,Tarquinia,Terelle,Terracina,Tivoli,Toffia,Tolfa,Torrice,Torricella,Trevi,Trevignano,Trivigliano,Turania,Tuscania,Valentano,Vallecorsa,Vallemaio,Vallepietra,Vallerano,Vasanello,Vejano,Velletri,Ventotene,Veroli,Vetralla,Vicalvi,Vico,Vicovaro,Vignanello,Viterbo,Viticuso,Vitorchiano,Vivaro,Zagarolo", + }, + { + name: "Castillian", + i: 4, + min: 5, + max: 11, + d: "lr", + m: 0, + b: "Ajofrin,Alameda,Alaminos,Albares,Albarreal,Albendiego,Alcanizo,Alcaudete,Alcolea,Aldea,Aldeanueva,Algar,Algora,Alhondiga,Almadrones,Almendral,Alovera,Anguita,Arbancon,Argecilla,Arges,Arroyo,Atanzon,Atienza,Azuqueca,Baides,Banos,Bargas,Barriopedro,Belvis,Berninches,Brihuega,Buenaventura,Burgos,Burguillos,Bustares,Cabanillas,Calzada,Camarena,Campillo,Cantalojas,Cardiel,Carmena,Casas,Castejon,Castellar,Castilforte,Castillo,Castilnuevo,Cazalegas,Centenera,Cervera,Checa,Chozas,Chueca,Cifuentes,Cincovillas,Ciruelas,Cogollor,Cogolludo,Consuegra,Copernal,Corral,Cuerva,Domingo,Dosbarrios,Driebes,Duron,Escalona,Escalonilla,Escamilla,Escopete,Espinosa,Esplegares,Esquivias,Estables,Estriegana,Fontanar,Fuembellida,Fuensalida,Fuentelsaz,Gajanejos,Galvez,Gascuena,Gerindote,Guadamur,Heras,Herreria,Herreruela,Hinojosa,Hita,Hombrados,Hontanar,Hormigos,Huecas,Huerta,Humanes,Illana,Illescas,Iniestola,Irueste,Jadraque,Jirueque,Lagartera,Ledanca,Lillo,Lominchar,Loranca,Lucillos,Luzaga,Luzon,Madrid,Magan,Malaga,Malpica,Manzanar,Maqueda,Masegoso,Matillas,Medranda,Megina,Mejorada,Millana,Milmarcos,Mirabueno,Miralrio,Mocejon,Mochales,Molina,Mondejar,Montarron,Mora,Moratilla,Morenilla,Navas,Negredo,Noblejas,Numancia,Nuno,Ocana,Ocentejo,Olias,Olmeda,Ontigola,Orea,Orgaz,Oropesa,Otero,Palma,Pardos,Paredes,Penalver,Pepino,Peralejos,Pinilla,Pioz,Piqueras,Portillo,Poveda,Pozo,Pradena,Prados,Puebla,Puerto,Quero,Quintanar,Rebollosa,Retamoso,Riba,Riofrio,Robledo,Romanillos,Romanones,Rueda,Salmeron,Santiuste,Santo,Sauca,Segura,Selas,Semillas,Sesena,Setiles,Sevilla,Siguenza,Solanillos,Somolinos,Sonseca,Sotillo,Talavera,Taravilla,Tembleque,Tendilla,Tierzo,Torralba,Torre,Torrejon,Torrijos,Tortola,Tortuera,Totanes,Trillo,Uceda,Ugena,Urda,Utande,Valdesotos,Valhermoso,Valtablado,Valverde,Velada,Viana,Yebra,Yuncos,Yunquera,Zaorejas,Zarzuela,Zorita", + }, + { + name: "Ruthenian", + i: 5, + min: 5, + max: 10, + d: "", + m: 0, + b: "Belgorod,Beloberezhye,Belyi,Belz,Berestiy,Berezhets,Berezovets,Berezutsk,Bobruisk,Bolonets,Borisov,Borovsk,Bozhesk,Bratslav,Bryansk,Brynsk,Buryn,Byhov,Chechersk,Chemesov,Cheremosh,Cherlen,Chern,Chernigov,Chernitsa,Chernobyl,Chernogorod,Chertoryesk,Chetvertnia,Demyansk,Derevesk,Devyagoresk,Dichin,Dmitrov,Dorogobuch,Dorogobuzh,Drestvin,Drokov,Drutsk,Dubechin,Dubichi,Dubki,Dubkov,Dveren,Galich,Glebovo,Glinsk,Goloty,Gomiy,Gorodets,Gorodische,Gorodno,Gorohovets,Goroshin,Gorval,Goryshon,Holm,Horobor,Hoten,Hotin,Hotmyzhsk,Ilovech,Ivan,Izborsk,Izheslavl,Kamenets,Kanev,Karachev,Karna,Kavarna,Klechesk,Klyapech,Kolomyya,Kolyvan,Kopyl,Korec,Kornik,Korochunov,Korshev,Korsun,Koshkin,Kotelno,Kovyla,Kozelsk,Kozelsk,Kremenets,Krichev,Krylatsk,Ksniatin,Kulatsk,Kursk,Kursk,Lebedev,Lida,Logosko,Lomihvost,Loshesk,Loshichi,Lubech,Lubno,Lubutsk,Lutsk,Luchin,Luki,Lukoml,Luzha,Lvov,Mtsensk,Mdin,Medniki,Melecha,Merech,Meretsk,Mescherskoe,Meshkovsk,Metlitsk,Mezetsk,Mglin,Mihailov,Mikitin,Mikulino,Miloslavichi,Mogilev,Mologa,Moreva,Mosalsk,Moschiny,Mozyr,Mstislav,Mstislavets,Muravin,Nemech,Nemiza,Nerinsk,Nichan,Novgorod,Novogorodok,Obolichi,Obolensk,Obolensk,Oleshsk,Olgov,Omelnik,Opoka,Opoki,Oreshek,Orlets,Osechen,Oster,Ostrog,Ostrov,Perelai,Peremil,Peremyshl,Pererov,Peresechen,Perevitsk,Pereyaslav,Pinsk,Ples,Polotsk,Pronsk,Proposhesk,Punia,Putivl,Rechitsa,Rodno,Rogachev,Romanov,Romny,Roslavl,Rostislavl,Rostovets,Rsha,Ruza,Rybchesk,Rylsk,Rzhavesk,Rzhev,Rzhischev,Sambor,Serensk,Serensk,Serpeysk,Shilov,Shuya,Sinech,Sizhka,Skala,Slovensk,Slutsk,Smedin,Sneporod,Snitin,Snovsk,Sochevo,Sokolec,Starica,Starodub,Stepan,Sterzh,Streshin,Sutesk,Svinetsk,Svisloch,Terebovl,Ternov,Teshilov,Teterin,Tiversk,Torchevsk,Toropets,Torzhok,Tripolye,Trubchevsk,Tur,Turov,Usvyaty,Uteshkov,Vasilkov,Velil,Velye,Venev,Venicha,Verderev,Vereya,Veveresk,Viazma,Vidbesk,Vidychev,Voino,Volodimer,Volok,Volyn,Vorobesk,Voronich,Voronok,Vorotynsk,Vrev,Vruchiy,Vselug,Vyatichsk,Vyatka,Vyshegorod,Vyshgorod,Vysokoe,Yagniatin,Yaropolch,Yasenets,Yuryev,Yuryevets,Zaraysk,Zhitomel,Zholvazh,Zizhech,Zubkov,Zudechev,Zvenigorod", + }, + { + name: "Nordic", + i: 6, + min: 6, + max: 10, + d: "kln", + m: 0.1, + b: "Akureyri,Aldra,Alftanes,Andenes,Austbo,Auvog,Bakkafjordur,Ballangen,Bardal,Beisfjord,Bifrost,Bildudalur,Bjerka,Bjerkvik,Bjorkosen,Bliksvaer,Blokken,Blonduos,Bolga,Bolungarvik,Borg,Borgarnes,Bosmoen,Bostad,Bostrand,Botsvika,Brautarholt,Breiddalsvik,Bringsli,Brunahlid,Budardalur,Byggdakjarni,Dalvik,Djupivogur,Donnes,Drageid,Drangsnes,Egilsstadir,Eiteroga,Elvenes,Engavogen,Ertenvog,Eskifjordur,Evenes,Eyrarbakki,Fagernes,Fallmoen,Fellabaer,Fenes,Finnoya,Fjaer,Fjelldal,Flakstad,Flateyri,Flostrand,Fludir,Gardaber,Gardur,Gimstad,Givaer,Gjeroy,Gladstad,Godoya,Godoynes,Granmoen,Gravdal,Grenivik,Grimsey,Grindavik,Grytting,Hafnir,Halsa,Hauganes,Haugland,Hauknes,Hella,Helland,Hellissandur,Hestad,Higrav,Hnifsdalur,Hofn,Hofsos,Holand,Holar,Holen,Holkestad,Holmavik,Hopen,Hovden,Hrafnagil,Hrisey,Husavik,Husvik,Hvammstangi,Hvanneyri,Hveragerdi,Hvolsvollur,Igeroy,Indre,Inndyr,Innhavet,Innes,Isafjordur,Jarklaustur,Jarnsreykir,Junkerdal,Kaldvog,Kanstad,Karlsoy,Kavosen,Keflavik,Kjelde,Kjerstad,Klakk,Kopasker,Kopavogur,Korgen,Kristnes,Krutoga,Krystad,Kvina,Lande,Laugar,Laugaras,Laugarbakki,Laugarvatn,Laupstad,Leines,Leira,Leiren,Leland,Lenvika,Loding,Lodingen,Lonsbakki,Lopsmarka,Lovund,Luroy,Maela,Melahverfi,Meloy,Mevik,Misvaer,Mornes,Mosfellsber,Moskenes,Myken,Naurstad,Nesberg,Nesjahverfi,Nesset,Nevernes,Obygda,Ofoten,Ogskardet,Okervika,Oknes,Olafsfjordur,Oldervika,Olstad,Onstad,Oppeid,Oresvika,Orsnes,Orsvog,Osmyra,Overdal,Prestoya,Raudalaekur,Raufarhofn,Reipo,Reykholar,Reykholt,Reykjahlid,Rif,Rinoya,Rodoy,Rognan,Rosvika,Rovika,Salhus,Sanden,Sandgerdi,Sandoker,Sandset,Sandvika,Saudarkrokur,Selfoss,Selsoya,Sennesvik,Setso,Siglufjordur,Silvalen,Skagastrond,Skjerstad,Skonland,Skorvogen,Skrova,Sleneset,Snubba,Softing,Solheim,Solheimar,Sorarnoy,Sorfugloy,Sorland,Sormela,Sorvaer,Sovika,Stamsund,Stamsvika,Stave,Stokka,Stokkseyri,Storjord,Storo,Storvika,Strand,Straumen,Strendene,Sudavik,Sudureyri,Sundoya,Sydalen,Thingeyri,Thorlakshofn,Thorshofn,Tjarnabyggd,Tjotta,Tosbotn,Traelnes,Trofors,Trones,Tverro,Ulvsvog,Unnstad,Utskor,Valla,Vandved,Varmahlid,Vassos,Vevelstad,Vidrek,Vik,Vikholmen,Vogar,Vogehamn,Vopnafjordur", + }, + { + name: "Greek", + i: 7, + min: 5, + max: 11, + d: "s", + m: 0.1, + b: "Abdera,Acharnae,Aegae,Aegina,Agrinion,Aigosthena,Akragas,Akroinon,Akrotiri,Alalia,Alexandria,Amarynthos,Amaseia,Amphicaea,Amphigeneia,Amphipolis,Antipatrea,Antiochia,Apamea,Aphidna,Apollonia,Argos,Artemita,Argyropolis,Asklepios,Athenai,Athmonia,Bhrytos,Borysthenes,Brauron,Byblos,Byzantion,Bythinion,Calydon,Chamaizi,Chalcis,Chios,Cleona,Corcyra,Croton,Cyrene,Cythera,Decelea,Delos,Delphi,Dicaearchia,Didyma,Dion,Dioscurias,Dodona,Dorylaion,Elateia,Eleusis,Eleutherna,Emporion,Ephesos,Epidamnos,Epidauros,Epizephyrian,Erythrae,Eubea,Golgi,Gonnos,Gorgippia,Gournia,Gortyn,Gytion,Hagios,Halicarnassos,Heliopolis,Hellespontos,Heloros,Heraclea,Hierapolis,Himera,Histria,Hubla,Hyele,Ialysos,Iasos,Idalion,Imbros,Iolcos,Itanos,Ithaca,Juktas,Kallipolis,Kameiros,Karistos,Kasmenai,Kepoi,Kimmerikon,Knossos,Korinthos,Kos,Kourion,Kydonia,Kyrenia,Lamia,Lampsacos,Laodicea,Lapithos,Larissa,Lebena,Lefkada,Lekhaion,Leibethra,Leontinoi,Lilaea,Lindos,Lissos,Magnesia,Mantineia,Marathon,Marmara,Massalia,Megalopolis,Megara,Metapontion,Methumna,Miletos,Morgantina,Mulai,Mukenai,Myonia,Myra,Myrmekion,Myos,Nauplios,Naucratis,Naupaktos,Naxos,Neapolis,Nemea,Nicaea,Nicopolis,Nymphaion,Nysa,Odessos,Olbia,Olympia,Olynthos,Opos,Orchomenos,Oricos,Orestias,Oreos,Onchesmos,Pagasae,Palaikastro,Pandosia,Panticapaion,Paphos,Pargamon,Paros,Pegai,Pelion,Peiraies,Phaistos,Phaleron,Pharos,Pithekussa,Philippopolis,Phocaea,Pinara,Pisa,Pitane,Plataea,Poseidonia,Potidaea,Pseira,Psychro,Pteleos,Pydna,Pylos,Pyrgos,Rhamnos,Rhithymna,Rhypae,Rizinia,Rodos,Salamis,Samos,Skyllaion,Seleucia,Semasos,Sestos,Scidros,Sicyon,,Sinope,Siris,Smyrna,Sozopolis,Sparta,Stagiros,Stratos,Stymphalos,Sybaris,Surakousai,Taras,Tanagra,Tanais,Tauromenion,Tegea,Temnos,Teos,Thapsos,Thassos,Thebai,Theodosia,Therma,Thespian,Thronion,Thoricos,Thurii,Thyreum,Thyria,Tithoraea,Tomis,Tragurion,Tripolis,Troliton,Troy,Tylissos,Tyros,Vathypetros,Zakynthos,Zakros", + }, + { + name: "Roman", + i: 8, + min: 6, + max: 11, + d: "ln", + m: 0.1, + b: "Abila,Adflexum,Adnicrem,Aelia,Aelius,Aeminium,Aequum,Agrippina,Agrippinae,Ala,Albanianis,Aleria,Ambianum,Andautonia,Apulum,Aquae,Aquaegranni,Aquensis,Aquileia,Aquincum,Arae,Argentoratum,Ariminum,Ascrivium,Asturica,Atrebatum,Atuatuca,Augusta,Aurelia,Aurelianorum,Batavar,Batavorum,Belum,Biriciana,Blestium,Bonames,Bonna,Bononia,Borbetomagus,Bovium,Bracara,Brigantium,Burgodunum,Caesaraugusta,Caesarea,Caesaromagus,Calleva,Camulodunum,Cannstatt,Cantiacorum,Capitolina,Caralis,Castellum,Castra,Castrum,Cibalae,Clausentum,Colonia,Concangis,Condate,Confluentes,Conimbriga,Corduba,Coria,Corieltauvorum,Corinium,Coriovallum,Cornoviorum,Danum,Deva,Dianium,Divodurum,Dobunnorum,Drusi,Dubris,Dumnoniorum,Durnovaria,Durocobrivis,Durocornovium,Duroliponte,Durovernum,Durovigutum,Eboracum,Ebusus,Edetanorum,Emerita,Emona,Emporiae,Euracini,Faventia,Flaviae,Florentia,Forum,Gerulata,Gerunda,Gesoscribate,Glevensium,Hadriani,Herculanea,Isca,Italica,Iulia,Iuliobrigensium,Iuvavum,Lactodurum,Lagentium,Lapurdum,Lauri,Legionis,Lemanis,Lentia,Lepidi,Letocetum,Lindinis,Lindum,Lixus,Londinium,Lopodunum,Lousonna,Lucus,Lugdunum,Luguvalium,Lutetia,Mancunium,Marsonia,Martius,Massa,Massilia,Matilo,Mattiacorum,Mediolanum,Mod,Mogontiacum,Moridunum,Mursa,Naissus,Nervia,Nida,Nigrum,Novaesium,Noviomagus,Olicana,Olisippo,Ovilava,Parisiorum,Partiscum,Paterna,Pistoria,Placentia,Pollentia,Pomaria,Pompeii,Pons,Portus,Praetoria,Praetorium,Pullum,Ragusium,Ratae,Raurica,Ravenna,Regina,Regium,Regulbium,Rigomagus,Roma,Romula,Rutupiae,Salassorum,Salernum,Salona,Scalabis,Segovia,Silurum,Sirmium,Siscia,Sorviodurum,Sumelocenna,Tarraco,Taurinorum,Theranda,Traiectum,Treverorum,Tungrorum,Turicum,Ulpia,Valentia,Venetiae,Venta,Verulamium,Vesontio,Vetera,Victoriae,Victrix,Villa,Viminacium,Vindelicorum,Vindobona,Vinovia,Viroconium", + }, + { + name: "Finnic", + i: 9, + min: 5, + max: 11, + d: "akiut", + m: 0, + b: "Aanekoski,Ahlainen,Aholanvaara,Ahtari,Aijala,Akaa,Alajarvi,Antsla,Aspo,Bennas,Bjorkoby,Elva,Emasalo,Espoo,Esse,Evitskog,Forssa,Haapamaki,Haapavesi,Haapsalu,Hameenlinna,Hanko,Harjavalta,Hattuvaara,Hautajarvi,Havumaki,Heinola,Hetta,Hinkabole,Hirmula,Hossa,Huittinen,Husula,Hyryla,Hyvinkaa,Ikaalinen,Iskmo,Itakoski,Jamsa,Jarvenpaa,Jeppo,Jioesuu,Jiogeva,Joensuu,Jokikyla,Jungsund,Jyvaskyla,Kaamasmukka,Kajaani,Kalajoki,Kallaste,Kankaanpaa,Karkku,Karpankyla,Kaskinen,Kasnas,Kauhajoki,Kauhava,Kauniainen,Kauvatsa,Kehra,Kellokoski,Kelottijarvi,Kemi,Kemijarvi,Kerava,Keuruu,Kiljava,Kiuruvesi,Kivesjarvi,Kiviioli,Kivisuo,Klaukkala,Klovskog,Kohtlajarve,Kokemaki,Kokkola,Kolho,Koskue,Kotka,Kouva,Kaupunki,Kuhmo,Kunda,Kuopio,Kuressaare,Kurikka,Kuusamo,Kylmalankyla,Lahti,Laitila,Lankipohja,Lansikyla,Lapua,Laurila,Lautiosaari,Lempaala,Lepsama,Liedakkala,Lieksa,Littoinen,Lohja,Loimaa,Loksa,Loviisa,Malmi,Mantta,Matasvaara,Maula,Miiluranta,Mioisakula,Munapirtti,Mustvee,Muurahainen,Naantali,Nappa,Narpio,Niinimaa,Niinisalo,Nikkila,Nilsia,Nivala,Nokia,Nummela,Nuorgam,Nuvvus,Obbnas,Oitti,Ojakkala,Onninen,Orimattila,Orivesi,Otanmaki,Otava,Otepaa,Oulainen,Oulu,Paavola,Paide,Paimio,Pakankyla,Paldiski,Parainen,Parkumaki,Parola,Perttula,Pieksamaki,Pioltsamaa,Piolva,Pohjavaara,Porhola,Porrasa,Porvoo,Pudasjarvi,Purmo,Pyhajarvi,Raahe,Raasepori,Raisio,Rajamaki,Rakvere,Rapina,Rapla,Rauma,Rautio,Reposaari,Riihimaki,Rovaniemi,Roykka,Ruonala,Ruottala,Rutalahti,Saarijarvi,Salo,Sastamala,Saue,Savonlinna,Seinajoki,Sillamae,Siuntio,Sompujarvi,Suonenjoki,Suurejaani,Syrjantaka,Tamsalu,Tapa,Temmes,Tiorva,Tormasenvaara,Tornio,Tottijarvi,Tulppio,Turenki,Turi,Tuukkala,Tuurala,Tuuri,Tuuski,Tuusniemi,Ulvila,Unari,Upinniemi,Utti,Uusikaupunki,Vaaksy,Vaalimaa,Vaarinmaja,Vaasa,Vainikkala,Valga,Valkeakoski,Vantaa,Varkaus,Vehkapera,Vehmasmaki,Vieki,Vierumaki,Viitasaari,Viljandi,Vilppula,Viohma,Vioru,Virrat,Ylike,Ylivieska,Ylojarvi", + }, + { + name: "Korean", + i: 10, + min: 5, + max: 11, + d: "", + m: 0, + b: "Anjung,Ansan,Anseong,Anyang,Aphae,Apo,Baekseok,Baeksu,Beolgyo,Boeun,Boseong,Busan,Buyeo,Changnyeong,Changwon,Cheonan,Cheongdo,Cheongjin,Cheongsong,Cheongyang,Cheorwon,Chirwon,Chuncheon,Chungju,Daedeok,Daegaya,Daejeon,Damyang,Dangjin,Dasa,Donghae,Dongsong,Doyang,Eonyang,Gaeseong,Ganggyeong,Ganghwa,Gangneung,Ganseong,Gaun,Geochang,Geoje,Geoncheon,Geumho,Geumil,Geumwang,Gijang,Gimcheon,Gimhwa,Gimje,Goa,Gochang,Gohan,Gongdo,Gongju,Goseong,Goyang,Gumi,Gunpo,Gunsan,Guri,Gurye,Gwangju,Gwangyang,Gwansan,Gyeongseong,Hadong,Hamchang,Hampyeong,Hamyeol,Hanam,Hapcheon,Hayang,Heungnam,Hongnong,Hongseong,Hwacheon,Hwando,Hwaseong,Hwasun,Hwawon,Hyangnam,Incheon,Inje,Iri,Janghang,Jangheung,Jangseong,Jangseungpo,Jangsu,Jecheon,Jeju,Jeomchon,Jeongeup,Jeonggwan,Jeongju,Jeongok,Jeongseon,Jeonju,Jido,Jiksan,Jinan,Jincheon,Jindo,Jingeon,Jinjeop,Jinnampo,Jinyeong,Jocheon,Jochiwon,Jori,Maepo,Mangyeong,Mokpo,Muju,Munsan,Naesu,Naju,Namhae,Namwon,Namyang,Namyangju,Nongong,Nonsan,Ocheon,Okcheon,Okgu,Onam,Onsan,Onyang,Opo,Paengseong,Pogok,Poseung,Pungsan,Pyeongchang,Pyeonghae,Pyeongyang,Sabi,Sacheon,Samcheok,Samho,Samrye,Sancheong,Sangdong,Sangju,Sapgyo,Sariwon,Sejong,Seocheon,Seogwipo,Seonghwan,Seongjin,Seongju,Seongnam,Seongsan,Seosan,Seungju,Siheung,Sindong,Sintaein,Soheul,Sokcho,Songak,Songjeong,Songnim,Songtan,Suncheon,Taean,Taebaek,Tongjin,Uijeongbu,Uiryeong,Uiwang,Uljin,Ulleung,Unbong,Ungcheon,Ungjin,Waegwan,Wando,Wayang,Wiryeseong,Wondeok,Yangju,Yangsan,Yangyang,Yecheon,Yeomchi,Yeoncheon,Yeongam,Yeongcheon,Yeongdeok,Yeongdong,Yeonggwang,Yeongju,Yeongwol,Yeongyang,Yeonil,Yongin,Yongjin,Yugu", + }, + { + name: "Chinese", + i: 11, + min: 5, + max: 10, + d: "", + m: 0, + b: "Anding,Anlu,Anqing,Anshun,Baixing,Banyang,Baoqing,Binzhou,Caozhou,Changbai,Changchun,Changde,Changling,Changsha,Changzhou,Chengdu,Chenzhou,Chizhou,Chongqing,Chuxiong,Chuzhou,Dading,Daming,Datong,Daxing,Dengzhou,Deqing,Dihua,Dingli,Dongan,Dongchang,Dongchuan,Dongping,Duyun,Fengtian,Fengxiang,Fengyang,Fenzhou,Funing,Fuzhou,Ganzhou,Gaoyao,Gaozhou,Gongchang,Guangnan,Guangning,Guangping,Guangxin,Guangzhou,Guiyang,Hailong,Hangzhou,Hanyang,Hanzhong,Heihe,Hejian,Henan,Hengzhou,Hezhong,Huaian,Huaiqing,Huanglong,Huangzhou,Huining,Hulan,Huzhou,Jiading,Jian,Jianchang,Jiangning,Jiankang,Jiaxing,Jiayang,Jilin,Jinan,Jingjiang,Jingzhao,Jinhua,Jinzhou,Jiujiang,Kaifeng,Kaihua,Kangding,Kuizhou,Laizhou,Lianzhou,Liaoyang,Lijiang,Linan,Linhuang,Lintao,Liping,Liuzhou,Longan,Longjiang,Longxing,Luan,Lubin,Luzhou,Mishan,Nanan,Nanchang,Nandian,Nankang,Nanyang,Nenjiang,Ningbo,Ningguo,Ningwu,Ningxia,Ningyuan,Pingjiang,Pingliang,Pingyang,Puer,Puzhou,Qianzhou,Qingyang,Qingyuan,Qingzhou,Qujing,Quzhou,Raozhou,Rende,Ruian,Ruizhou,Shafeng,Shajing,Shaoqing,Shaowu,Shaoxing,Shaozhou,Shinan,Shiqian,Shouchun,Shuangcheng,Shulei,Shunde,Shuntian,Shuoping,Sicheng,Sinan,Sizhou,Songjiang,Suiding,Suihua,Suining,Suzhou,Taian,Taibei,Taiping,Taiwan,Taiyuan,Taizhou,Taonan,Tengchong,Tingzhou,Tongchuan,Tongqing,Tongzhou,Weihui,Wensu,Wenzhou,Wuchang,Wuding,Wuzhou,Xian,Xianchun,Xianping,Xijin,Xiliang,Xincheng,Xingan,Xingde,Xinghua,Xingjing,Xingyi,Xingyuan,Xingzhong,Xining,Xinmen,Xiping,Xuanhua,Xunzhou,Xuzhou,Yanan,Yangzhou,Yanji,Yanping,Yanzhou,Yazhou,Yichang,Yidu,Yilan,Yili,Yingchang,Yingde,Yingtian,Yingzhou,Yongchang,Yongping,Yongshun,Yuanzhou,Yuezhou,Yulin,Yunnan,Yunyang,Zezhou,Zhang,Zhangzhou,Zhaoqing,Zhaotong,Zhenan,Zhending,Zhenhai,Zhenjiang,Zhenxi,Zhenyun,Zhongshan,Zunyi", + }, + { + name: "Japanese", + i: 12, + min: 4, + max: 10, + d: "", + m: 0, + b: "Abira,Aga,Aikawa,Aizumisato,Ajigasawa,Akkeshi,Amagi,Ami,Ando,Asakawa,Ashikita,Bandai,Biratori,Chonan,Esashi,Fuchu,Fujimi,Funagata,Genkai,Godo,Goka,Gonohe,Gyokuto,Haboro,Hamatonbetsu,Harima,Hashikami,Hayashima,Heguri,Hidaka,Higashiura,Hiranai,Hirogawa,Hiroo,Hodatsushimizu,Hoki,Hokuei,Hokuryu,Horokanai,Ibigawa,Ichikai,Ichikawa,Ichinohe,Iijima,Iizuna,Ikawa,Inagawa,Itakura,Iwaizumi,Iwate,Kaisei,Kamifurano,Kamiita,Kamijima,Kamikawa,Kamishihoro,Kamiyama,Kanda,Kanna,Kasagi,Kasuya,Katsuura,Kawabe,Kawamoto,Kawanehon,Kawanishi,Kawara,Kawasaki,Kawatana,Kawazu,Kihoku,Kikonai,Kin,Kiso,Kitagata,Kitajima,Kiyama,Kiyosato,Kofu,Koge,Kohoku,Kokonoe,Kora,Kosa,Kotohira,Kudoyama,Kumejima,Kumenan,Kumiyama,Kunitomi,Kurate,Kushimoto,Kutchan,Kyonan,Kyotamba,Mashike,Matsumae,Mifune,Mihama,Minabe,Minami,Minamiechizen,Minamitane,Misaki,Misasa,Misato,Miyashiro,Miyoshi,Mori,Moseushi,Mutsuzawa,Nagaizumi,Nagatoro,Nagayo,Nagomi,Nakadomari,Nakanojo,Nakashibetsu,Namegawa,Nanbu,Nanporo,Naoshima,Nasu,Niseko,Nishihara,Nishiizu,Nishikatsura,Nishikawa,Nishinoshima,Nishiwaga,Nogi,Noto,Nyuzen,Oarai,Obuse,Odai,Ogawara,Oharu,Oirase,Oishida,Oiso,Oizumi,Oji,Okagaki,Okutama,Omu,Ono,Osaka,Otobe,Otsuki,Owani,Reihoku,Rifu,Rikubetsu,Rishiri,Rokunohe,Ryuo,Saka,Sakuho,Samani,Satsuma,Sayo,Saza,Setana,Shakotan,Shibayama,Shikama,Shimamoto,Shimizu,Shintomi,Shirakawa,Shisui,Shitara,Sobetsu,Sue,Sumita,Suooshima,Suttsu,Tabuse,Tachiarai,Tadami,Tadaoka,Taiji,Taiki,Takachiho,Takahama,Taketoyo,Taragi,Tateshina,Tatsugo,Tawaramoto,Teshikaga,Tobe,Tokigawa,Toma,Tomioka,Tonosho,Tosa,Toyokoro,Toyotomi,Toyoyama,Tsubata,Tsubetsu,Tsukigata,Tsuno,Tsuwano,Umi,Wakasa,Yamamoto,Yamanobe,Yamatsuri,Yanaizu,Yasuda,Yoichi,Yonaguni,Yoro,Yoshino,Yubetsu,Yugawara,Yuni,Yusuhara,Yuza", + }, + { + name: "Portuguese", + i: 13, + min: 5, + max: 11, + d: "", + m: 0.1, + b: "Abrigada,Afonsoeiro,Agueda,Aguilada,Alagoas,Alagoinhas,Albufeira,Alcanhoes,Alcobaca,Alcoutim,Aldoar,Alenquer,Alfeizerao,Algarve,Almada,Almagreira,Almeirim,Alpalhao,Alpedrinha,Alvorada,Amieira,Anapolis,Apelacao,Aranhas,Arganil,Armacao,Assenceira,Aveiro,Avelar,Balsas,Barcarena,Barreiras,Barretos,Batalha,Beira,Benavente,Betim,Braga,Braganca,Brasilia,Brejo,Cabeceiras,Cabedelo,Cachoeiras,Cadafais,Calhandriz,Calheta,Caminha,Campinas,Canidelo,Canoas,Capinha,Carmoes,Cartaxo,Carvalhal,Carvoeiro,Cascavel,Castanhal,Caxias,Chapadinha,Chaves,Cocais,Coentral,Coimbra,Comporta,Conde,Coqueirinho,Coruche,Damaia,Dourados,Enxames,Ericeira,Ervidel,Escalhao,Esmoriz,Espinhal,Estela,Estoril,Eunapolis,Evora,Famalicao,Fanhoes,Faro,Fatima,Felgueiras,Ferreira,Figueira,Flecheiras,Florianopolis,Fornalhas,Fortaleza,Freiria,Freixeira,Fronteira,Fundao,Gracas,Gradil,Grainho,Gralheira,Guimaraes,Horta,Ilhavo,Ilheus,Lages,Lagos,Laranjeiras,Lavacolhos,Leiria,Limoeiro,Linhares,Lisboa,Lomba,Lorvao,Lourical,Lourinha,Luziania,Macedo,Machava,Malveira,Marinhais,Maxial,Mealhada,Milharado,Mira,Mirandela,Mogadouro,Montalegre,Mourao,Nespereira,Nilopolis,Obidos,Odemira,Odivelas,Oeiras,Oleiros,Olhalvo,Olinda,Olival,Oliveira,Oliveirinha,Palheiros,Palmeira,Palmital,Pampilhosa,Pantanal,Paradinha,Parelheiros,Pedrosinho,Pegoes,Penafiel,Peniche,Pinhao,Pinheiro,Pombal,Pontal,Pontinha,Portel,Portimao,Quarteira,Queluz,Ramalhal,Reboleira,Recife,Redinha,Ribadouro,Ribeira,Ribeirao,Rosais,Sabugal,Sacavem,Sagres,Sandim,Sangalhos,Santarem,Santos,Sarilhos,Seixas,Seixezelo,Seixo,Silvares,Silveira,Sinhaem,Sintra,Sobral,Sobralinho,Tabuaco,Tabuleiro,Taveiro,Teixoso,Telhado,Telheiro,Tomar,Torreira,Trancoso,Troviscal,Vagos,Varzea,Velas,Viamao,Viana,Vidigal,Vidigueira,Vidual,Vilamar,Vimeiro,Vinhais,Vitoria", + }, + { + name: "Nahuatl", + i: 14, + min: 6, + max: 13, + d: "l", + m: 0, + b: "Acapulco,Acatepec,Acatlan,Acaxochitlan,Acolman,Actopan,Acuamanala,Ahuacatlan,Almoloya,Amacuzac,Amanalco,Amaxac,Apaxco,Apetatitlan,Apizaco,Atenco,Atizapan,Atlacomulco,Atlapexco,Atotonilco,Axapusco,Axochiapan,Axocomanitla,Axutla,Azcapotzalco,Aztahuacan,Calimaya,Calnali,Calpulalpan,Camotlan,Capulhuac,Chalco,Chapulhuacan,Chapultepec,Chiapan,Chiautempan,Chiconautla,Chihuahua,Chilcuautla,Chimalhuacan,Cholollan,Cihuatlan,Coahuila,Coatepec,Coatetelco,Coatlan,Coatlinchan,Coatzacoalcos,Cocotitlan,Cohetzala,Colima,Colotlan,Coyoacan,Coyohuacan,Cuapiaxtla,Cuauhnahuac,Cuauhtemoc,Cuauhtitlan,Cuautepec,Cuautla,Cuaxomulco,Culhuacan,Ecatepec,Eloxochitlan,Epatlan,Epazoyucan,Huamantla,Huascazaloya,Huatlatlauca,Huautla,Huehuetlan,Huehuetoca,Huexotla,Hueyapan,Hueyotlipan,Hueypoxtla,Huichapan,Huimilpan,Huitzilac,Ixtapallocan,Iztacalco,Iztaccihuatl,Iztapalapa,Lolotla,Malinalco,Mapachtlan,Mazatepec,Mazatlan,Metepec,Metztitlan,Mexico,Miacatlan,Michoacan,Minatitlan,Mixcoac,Mixtla,Molcaxac,Nanacamilpa,Naucalpan,Naupan,Nextlalpan,Nezahualcoyotl,Nopalucan,Oaxaca,Ocotepec,Ocotitlan,Ocotlan,Ocoyoacac,Ocuilan,Ocuituco,Omitlan,Otompan,Otzoloapan,Pacula,Pahuatlan,Panotla,Papalotla,Patlachican,Piaztla,Popocatepetl,Sultepec,Tecamac,Tecolotlan,Tecozautla,Temamatla,Temascalapa,Temixco,Temoac,Temoaya,Tenayuca,Tenochtitlan,Teocuitlatlan,Teotihuacan,Teotlalco,Tepeacac,Tepeapulco,Tepehuacan,Tepetitlan,Tepeyanco,Tepotzotlan,Tepoztlan,Tetecala,Tetlatlahuca,Texcalyacac,Texcoco,Tezontepec,Tezoyuca,Timilpan,Tizapan,Tizayuca,Tlacopan,Tlacotenco,Tlahuac,Tlahuelilpan,Tlahuiltepa,Tlalmanalco,Tlalnepantla,Tlalpan,Tlanchinol,Tlatelolco,Tlaxcala,Tlaxcoapan,Tlayacapan,Tocatlan,Tolcayuca,Toluca,Tonanitla,Tonantzintla,Tonatico,Totolac,Totolapan,Tototlan,Tuchtlan,Tulantepec,Tultepec,Tzompantepec,Xalatlaco,Xaloztoc,Xaltocan,Xiloxoxtla,Xochiatipan,Xochicoatlan,Xochimilco,Xochitepec,Xolotlan,Xonacatlan,Yahualica,Yautepec,Yecapixtla,Yehaultepec,Zacatecas,Zacazonapan,Zacoalco,Zacualpan,Zacualtipan,Zapotlan,Zimapan,Zinacantepec,Zoyaltepec,Zumpahuacan", + }, + { + name: "Hungarian", + i: 15, + min: 6, + max: 13, + d: "", + m: 0.1, + b: "Aba,Abadszalok,Adony,Ajak,Albertirsa,Alsozsolca,Aszod,Babolna,Bacsalmas,Baktaloranthaza,Balassagyarmat,Balatonalmadi,Balatonboglar,Balkany,Balmazujvaros,Barcs,Bataszek,Batonyterenye,Battonya,Bekes,Berettyoujfalu,Berhida,Biatorbagy,Bicske,Biharkeresztes,Bodajk,Boly,Bonyhad,Budakalasz,Budakeszi,Celldomolk,Csakvar,Csenger,Csongrad,Csorna,Csorvas,Csurgo,Dabas,Demecser,Derecske,Devavanya,Devecser,Dombovar,Dombrad,Dunafoldvar,Dunaharaszti,Dunavarsany,Dunavecse,Edeleny,Elek,Emod,Encs,Enying,Ercsi,Fegyvernek,Fehergyarmat,Felsozsolca,Fertoszentmiklos,Fonyod,Fot,Fuzesabony,Fuzesgyarmat,Gardony,God,Gyal,Gyomaendrod,Gyomro,Hajdudorog,Hajduhadhaz,Hajdusamson,Hajduszoboszlo,Halasztelek,Harkany,Hatvan,Heves,Heviz,Ibrany,Isaszeg,Izsak,Janoshalma,Janossomorja,Jaszapati,Jaszarokszallas,Jaszfenyszaru,Jaszkiser,Kaba,Kalocsa,Kapuvar,Karcag,Kecel,Kemecse,Kenderes,Kerekegyhaza,Keszthely,Kisber,Kiskunmajsa,Kistarcsa,Kistelek,Kisujszallas,Kisvarda,Komadi,Komarom,Komlo,Kormend,Korosladany,Koszeg,Kozarmisleny,Kunhegyes,Kunszentmarton,Kunszentmiklos,Labatlan,Lajosmizse,Lenti,Letavertes,Letenye,Lorinci,Maglod,Mako,Mandok,Marcali,Martonvasar,Mateszalka,Melykut,Mezobereny,Mezocsat,Mezohegyes,Mezokeresztes,Mezokovesd,Mezotur,Mindszent,Mohacs,Monor,Mor,Morahalom,Nadudvar,Nagyatad,Nagyecsed,Nagyhalasz,Nagykallo,Nagykoros,Nagymaros,Nyekladhaza,Nyergesujfalu,Nyirbator,Nyirmada,Nyirtelek,Ocsa,Orkeny,Oroszlany,Paks,Pannonhalma,Paszto,Pecel,Pecsvarad,Pilisvorosvar,Polgar,Polgardi,Pomaz,Puspokladany,Pusztaszabolcs,Putnok,Racalmas,Rackeve,Rakamaz,Rakoczifalva,Sajoszent,Sandorfalva,Sarbogard,Sarkad,Sarospatak,Sarvar,Satoraljaujhely,Siklos,Simontornya,Soltvadkert,Sumeg,Szabadszallas,Szarvas,Szazhalombatta,Szecseny,Szeghalom,Szentgotthard,Szentlorinc,Szerencs,Szigethalom,Szigetvar,Szikszo,Tab,Tamasi,Tapioszele,Tapolca,Teglas,Tet,Tiszafoldvar,Tiszafured,Tiszakecske,Tiszalok,Tiszaujvaros,Tiszavasvari,Tokaj,Tokol,Tompa,Torokbalint,Torokszentmiklos,Totkomlos,Tura,Turkeve,Ujkigyos,ujszasz,Vamospercs,Varpalota,Vasarosnameny,Vasvar,Vecses,Veresegyhaz,Verpelet,Veszto,Zahony,Zalaszentgrot,Zirc,Zsambek", + }, + { + name: "Turkish", + i: 16, + min: 4, + max: 10, + d: "", + m: 0, + b: "Yelkaya,Buyrukkaya,Erdemtepe,Alakesen,Baharbeyli,Bozbay,Karaoklu,Altunbey,Yalkale,Yalkut,Akardere,Altayburnu,Esentepe,Okbelen,Derinsu,Alaoba,Yamanbeyli,Aykor,Ekinova,Saztepe,Baharkale,Devrekdibi,Alpseki,Ormanseki,Erkale,Yalbelen,Aytay,Yamanyaka,Altaydelen,Esen,Yedieli,Alpkor,Demirkor,Yediyol,Erdemkaya,Yayburnu,Ganiler,Bayatyurt,Kopuzteke,Aytepe,Deniz,Ayan,Ayazdere,Tepe,Kayra,Ayyaka,Deren,Adatepe,Kalkaneli,Bozkale,Yedidelen,Kocayolu,Sazdere,Bozkesen,Oguzeli,Yayladibi,Uluyol,Altay,Ayvar,Alazyaka,Yaloba,Suyaka,Baltaberi,Poyrazdelen,Eymir,Yediyuva,Kurt,Yeltepe,Oktar,Kara Ok,Ekinberi,Er Yurdu,Eren,Erenler,Ser,Oguz,Asay,Bozokeli,Aykut,Ormanyol,Yazkaya,Kalkanova,Yazbeyli,Dokuz Teke,Bilge,Ertensuyu,Kopuzyuva,Buyrukkut,Akardiken,Aybaray,Aslanbeyli,Altun Kaynak,Atikobasi,Yayla Eli,Kor Tepe,Salureli,Kor Kaya,Aybarberi,Kemerev,Yanaray,Beydileli,Buyrukoba,Yolduman,Tengri Tepe,Dokuzsu,Uzunkor,Erdem Yurdu,Kemer,Korteke,Bozokev,Bozoba,Ormankale,Askale,Oguztoprak,Yolberi,Kumseki,Esenobasi,Turkbelen,Ayazseki,Cereneli,Taykut,Bayramdelen,Beydilyaka,Boztepe,Uluoba,Yelyaka,Ulgardiken,Esensu,Baykale,Cerenkor,Bozyol,Duranoba,Aladuman,Denizli,Bahar,Yarkesen,Dokuzer,Yamankaya,Kocatarla,Alayaka,Toprakeli,Sarptarla,Sarpkoy,Serkaynak,Adayaka,Ayazkaynak,Kopuz,Turk,Kart,Kum,Erten,Buyruk,Yel,Ada,Alazova,Ayvarduman,Buyrukok,Ayvartoprak,Uzuntepe,Binseki,Yedibey,Durankale,Alaztoprak,Sarp Ok,Yaparobasi,Yaytepe,Asberi,Kalkankor,Beydiltepe,Adaberi,Bilgeyolu,Ganiyurt,Alkanteke,Esenerler,Asbey,Erdemkale,Erenkaynak,Oguzkoyu,Ayazoba,Boynuztoprak,Okova,Yaloklu,Sivriberi,Yuladiken,Sazbey,Karakaynak,Kopuzkoyu,Buyrukay,Kocakaya,Tepeduman,Yanarseki,Atikyurt,Esenev,Akarbeyli,Yayteke,Devreksungur,Akseki,Baykut,Kalkandere,Ulgarova,Devrekev,Yulabey,Bayatev,Yazsu,Vuraleli,Sivribeyli,Alaova,Alpobasi,Yalyurt,Elmatoprak,Alazkaynak,Esenay,Ertenev,Salurkor,Ekinok,Yalbey,Yeldere,Ganibay,Altaykut,Baltaboy,Ereli,Ayvarsu,Uzunsaz,Bayeli,Erenyol,Kocabay,Derintay,Ayazyol,Aslanoba,Esenkaynak,Ekinlik,Alpyolu,Alayunt,Bozeski,Erkil,Duransuyu,Yulak,Kut,Dodurga,Kutlubey,Kutluyurt,Boynuz,Alayol,Aybar,Aslaneli,Kemerseki,Baltasuyu,Akarer,Ayvarburnu,Boynuzbeyli,Adasungur,Esenkor,Yamanoba,Toprakkor,Uzunyurt,Sungur,Bozok,Kemerli,Alaz,Demirci,Kartepe", + }, + { + name: "Berber", + i: 17, + min: 4, + max: 10, + d: "s", + m: 0.2, + b: "Abkhouch,Adrar,Aeraysh,Afrag,Agadir,Agelmam,Aghmat,Agrakal,Agulmam,Ahaggar,Ait Baha,Ajdir,Akka,Almou,Amegdul,Amizmiz,Amknas,Amlil,Amurakush,Anfa,Annaba,Aousja,Arbat,Arfud,Argoub,Arif,Asfi,Asfru,Ashawen,Assamer,Assif,Awlluz,Ayt Melel,Azaghar,Azila,Azilal,Azmour,Azro,Azrou,Beccar,Beja,Bennour,Benslimane,Berkane,Berrechid,Bizerte,Bjaed,Bouayach,Boudenib,Boufrah,Bouskoura,Boutferda,Darallouch,Dar Bouazza,Darchaabane,Dcheira,Demnat,Denden,Djebel,Djedeida,Drargua,Elhusima,Essaouira,Ezzahra,Fas,Fnideq,Ghezeze,Goubellat,Grisaffen,Guelmim,Guercif,Hammamet,Harrouda,Hdifa,Hoceima,Houara,Idhan,Idurar,Ifendassen,Ifoghas,Ifrane,Ighoud,Ikbir,Imilchil,Imzuren,Inezgane,Irherm,Izoughar,Jendouba,Kacem,Kelibia,Kenitra,Kerrando,Khalidia,Khemisset,Khenifra,Khouribga,Khourigba,Kidal,Korba,Korbous,Lahraouyine,Larache,Leyun,Lqliaa,Manouba,Martil,Mazagan,Mcherga,Mdiq,Megrine,Mellal,Melloul,Midelt,Misur,Mohammedia,Mornag,Mrirt,Nabeul,Nadhour,Nador,Nawaksut,Nefza,Ouarzazate,Ouazzane,Oued Zem,Oujda,Ouladteima,Qsentina,Rades,Rafraf,Safi,Sefrou,Sejnane,Settat,Sijilmassa,Skhirat,Slimane,Somaa,Sraghna,Susa,Tabarka,Tadrart,Taferka,Tafilalt,Tafrawt,Tafza,Tagbalut,Tagerdayt,Taghzut,Takelsa,Taliouine,Tanja,Tantan,Taourirt,Targuist,Taroudant,Tarudant,Tasfelalayt,Tassort,Tata,Tattiwin,Tawnat,Taza,Tazagurt,Tazerka,Tazizawt,Taznakht,Tebourba,Teboursouk,Temara,Testour,Tetouan,Tibeskert,Tifelt,Tijdit,Tinariwen,Tinduf,Tinja,Tittawan,Tiznit,Toubkal,Trables,Tubqal,Tunes,Ultasila,Urup,Wagguten,Wararni,Warzazat,Watlas,Wehran,Wejda,Xamida,Yedder,Youssoufia,Zaghouan,Zahret,Zemmour,Zriba", + }, + { + name: "Arabic", + i: 18, + min: 4, + max: 9, + d: "ae", + m: 0.2, + b: "Abha,Ajman,Alabar,Alarjam,Alashraf,Alawali,Albawadi,Albirk,Aldhabiyah,Alduwaid,Alfareeq,Algayed,Alhazim,Alhrateem,Alhudaydah,Alhuwaya,Aljahra,Aljubail,Alkhafah,Alkhalas,Alkhawaneej,Alkhen,Alkhobar,Alkhuznah,Allisafah,Almshaykh,Almurjan,Almuwayh,Almuzaylif,Alnaheem,Alnashifah,Alqah,Alqouz,Alqurayyat,Alradha,Alraqmiah,Alsadyah,Alsafa,Alshagab,Alshuqaiq,Alsilaa,Althafeer,Alwasqah,Amaq,Amran,Annaseem,Aqbiyah,Arafat,Arar,Ardah,Asfan,Ashayrah,Askar,Ayaar,Aziziyah,Baesh,Bahrah,Balhaf,Banizayd,Bidiyah,Bisha,Biyatah,Buqhayq,Burayda,Dafiyat,Damad,Dammam,Dariyah,Dhafar,Dhahran,Dhalkut,Dhurma,Dibab,Doha,Dukhan,Duwaibah,Enaker,Fadhla,Fahaheel,Fanateer,Farasan,Fardah,Fujairah,Ghalilah,Ghar,Ghizlan,Ghomgyah,Ghran,Hadiyah,Haffah,Hajanbah,Hajrah,Haqqaq,Haradh,Hasar,Hawiyah,Hebaa,Hefar,Hijal,Husnah,Huwailat,Huwaitah,Irqah,Isharah,Ithrah,Jamalah,Jarab,Jareef,Jazan,Jeddah,Jiblah,Jihanah,Jilah,Jizan,Joraibah,Juban,Jumeirah,Kamaran,Keyad,Khab,Khaiybar,Khasab,Khathirah,Khawarah,Khulais,Kumzar,Limah,Linah,Madrak,Mahab,Mahalah,Makhtar,Mashwar,Masirah,Masliyah,Mastabah,Mazhar,Medina,Meeqat,Mirbah,Mokhtara,Muharraq,Muladdah,Musaykah,Mushayrif,Musrah,Mussafah,Nafhan,Najran,Nakhab,Nizwa,Oman,Qadah,Qalhat,Qamrah,Qasam,Qosmah,Qurain,Quriyat,Qurwa,Radaa,Rafha,Rahlah,Rakamah,Rasheedah,Rasmadrakah,Risabah,Rustaq,Ryadh,Sabtaljarah,Sadah,Safinah,Saham,Saihat,Salalah,Salmiya,Shabwah,Shalim,Shaqra,Sharjah,Sharurah,Shatifiyah,Shidah,Shihar,Shoqra,Shuwaq,Sibah,Sihmah,Sinaw,Sirwah,Sohar,Suhailah,Sulaibiya,Sunbah,Tabuk,Taif,Taqah,Tarif,Tharban,Thuqbah,Thuwal,Tubarjal,Turaif,Turbah,Tuwaiq,Ubar,Umaljerem,Urayarah,Urwah,Wabrah,Warbah,Yabreen,Yadamah,Yafur,Yarim,Yemen,Yiyallah,Zabid,Zahwah,Zallaq,Zinjibar,Zulumah", + }, + { + name: "Inuit", + i: 19, + min: 5, + max: 15, + d: "alutsn", + m: 0, + b: "Aaluik,Aappilattoq,Aasiaat,Agissat,Agssaussat,Akuliarutsip,Akunnaaq,Alluitsup,Alluttoq,Amitsorsuaq,Ammassalik,Anarusuk,Anguniartarfik,Annertussoq,Annikitsoq,Apparsuit,Apusiaajik,Arsivik,Arsuk,Atammik,Ateqanaq,Atilissuaq,Attu,Augpalugtoq,Aukarnersuaq,Aumat,Auvilkikavsaup,Avadtlek,Avallersuaq,Bjornesk,Blabaerdalen,Blomsterdalen,Brattalhid,Bredebrae,Brededal,Claushavn,Edderfulegoer,Egger,Eqalugalinnguit,Eqalugarssuit,Eqaluit,Eqqua,Etah,Graah,Hakluyt,Haredalen,Hareoen,Hundeo,Igaliku,Igdlorssuit,Igdluluarssuk,Iginniafik,Ikamiut,Ikarissat,Ikateq,Ikermiut,Ikermoissuaq,Ikorfarssuit,Ilimanaq,Illorsuit,Illunnguit,Iluileq,Ilulissat,Imaarsivik,Imartunarssuk,Immikkoortukajik,Innaarsuit,Inneruulalik,Inussullissuaq,Iperaq,Ippik,Iqek,Isortok,Isungartussoq,Itileq,Itissaalik,Itivdleq,Ittit,Ittoqqortoormiit,Ivingmiut,Ivittuut,Kanajoorartuut,Kangaamiut,Kangeq,Kangerluk,Kangerlussuaq,Kanglinnguit,Kapisillit,Kekertamiut,Kiatak,Kiataussaq,Kigatak,Kinaussak,Kingittorsuaq,Kitak,Kitsissuarsuit,Kitsissut,Klenczner,Kook,Kraulshavn,Kujalleq,Kullorsuaq,Kulusuk,Kuurmiit,Kuusuaq,Laksedalen,Maniitsoq,Marrakajik,Mattaangassut,Mernoq,Mittivakkat,Moriusaq,Myggbukta,Naajaat,Nangissat,Nanuuseq,Nappassoq,Narsarmijt,Narsarsuaq,Narssaq,Nasiffik,Natsiarsiorfik,Naujanguit,Niaqornaarsuk,Niaqornat,Nordfjordspasset,Nugatsiaq,Nunarssit,Nunarsuaq,Nunataaq,Nunatakavsaup,Nutaarmiut,Nuugaatsiaq,Nuuk,Nuukullak,Olonkinbyen,Oodaaq,Oqaatsut,Oqaitsunguit,Oqonermiut,Paagussat,Paamiut,Paatuut,Palungataq,Pamialluk,Perserajoq,Pituffik,Puugutaa,Puulkuip,Qaanaq,Qaasuitsup,Qaersut,Qajartalik,Qallunaat,Qaneq,Qaqortok,Qasigiannguit,Qassimiut,Qeertartivaq,Qeqertaq,Qeqertasussuk,Qeqqata,Qernertoq,Qernertunnguit,Qianarreq,Qingagssat,Qoornuup,Qorlortorsuaq,Qullikorsuit,Qunnerit,Qutdleq,Ravnedalen,Ritenbenk,Rypedalen,Saarloq,Saatorsuaq,Saattut,Salliaruseq,Sammeqqat,Sammisoq,Sanningassoq,Saqqaq,Saqqarlersuaq,Saqqarliit,Sarfannguit,Sattiaatteq,Savissivik,Serfanguaq,Sermersooq,Sermiligaaq,Sermilik,Sermitsiaq,Simitakaja,Simiutaq,Singamaq,Siorapaluk,Sisimiut,Sisuarsuit,Sullorsuaq,Suunikajik,Sverdrup,Taartoq,Takiseeq,Tasirliaq,Tasiusak,Tiilerilaaq,Timilersua,Timmiarmiut,Tukingassoq,Tussaaq,Tuttulissuup,Tuujuk,Uiivaq,Uilortussoq,Ujuaakajiip,Ukkusissat,Upernavik,Uttorsiutit,Uumannaq,Uunartoq,Uvkusigssat,Ymer", + }, + { + name: "Basque", + i: 20, + min: 4, + max: 11, + d: "r", + m: 0.1, + b: "Agurain,Aia,Aiara,Albiztur,Alkiza,Altzaga,Amorebieta,Amurrio,Andoain,Anoeta,Antzuola,Arakaldo,Arantzazu,Arbatzegi,Areatza,Arratzua,Arrieta,Artea,Artziniega,Asteasu,Astigarraga,Ataun,Atxondo,Aulesti,Azkoitia,Azpeitia,Bakio,Baliarrain,Barakaldo,Barrika,Barrundia,Basauri,Beasain,Bedia,Beizama,Belauntza,Berastegi,Bergara,Bermeo,Bernedo,Berriatua,Berriz,Bidania,Bilar,Bilbao,Busturia,Deba,Derio,Donostia,Dulantzi,Durango,Ea,Eibar,Elantxobe,Elduain,Elgeta,Elgoibar,Elorrio,Erandio,Ergoitia,Ermua,Errenteria,Errezil,Eskoriatza,Eskuernaga,Etxebarri,Etxebarria,Ezkio,Forua,Gabiria,Gaintza,Galdakao,Gamiz,Garai,Gasteiz,Gatzaga,Gaubea,Gautegiz,Gaztelu,Gernika,Gerrikaitz,Getaria,Getxo,Gizaburuaga,Goiatz,Gorliz,Gorriaga,Harana,Hernani,Hondarribia,Ibarra,Ibarrangelu,Idiazabal,Iekora,Igorre,Ikaztegieta,Irun,Irura,Iruraiz,Itsaso,Itsasondo,Iurreta,Izurtza,Jatabe,Kanpezu,Karrantza,Kortezubi,Kripan,Kuartango,Lanestosa,Lantziego,Larrabetzu,Lasarte,Laukiz,Lazkao,Leaburu,Legazpi,Legorreta,Legutio,Leintz,Leioa,Lekeitio,Lemoa,Lemoiz,Leza,Lezama,Lezo,Lizartza,Maeztu,Mallabia,Manaria,Markina,Maruri,Menaka,Mendaro,Mendata,Mendexa,Morga,Mundaka,Mungia,Munitibar,Murueta,Muskiz,Mutiloa,Mutriku,Nabarniz,Oiartzun,Oion,Okondo,Olaberria,Onati,Ondarroa,Ordizia,Orendain,Orexa,Oria,Orio,Ormaiztegi,Orozko,Ortuella,Otegi,Otxandio,Pasaia,Plentzia,Santurtzi,Sestao,Sondika,Soraluze,Sukarrieta,Tolosa,Trapagaran,Turtzioz,Ubarrundia,Ubide,Ugao,Urdua,Urduliz,Urizaharra,Urkabustaiz,Urnieta,Urretxu,Usurbil,Xemein,Zabaleta,Zaia,Zaldibar,Zambrana,Zamudio,Zaratamo,Zarautz,Zeberio,Zegama,Zerain,Zestoa,Zierbena,Zigoitia,Ziortza,Zuia,Zumaia,Zumarraga", + }, + { + name: "Nigerian", + i: 21, + min: 4, + max: 10, + d: "", + m: 0.3, + b: "Abadogo,Abafon,Adealesu,Adeto,Adyongo,Afaga,Afamju,Agigbigi,Agogoke,Ahute,Aiyelaboro,Ajebe,Ajola,Akarekwu,Akunuba,Alawode,Alkaijji,Amangam,Amgbaye,Amtasa,Amunigun,Animahun,Anyoko,Arapagi,Asande,Awgbagba,Awhum,Awodu,Babateduwa,Bandakwai,Bangdi,Bilikani,Birnindodo,Braidu,Bulakawa,Buriburi,Cainnan,Chakum,Chondugh,Dagwarga,Darpi,Dokatofa,Dozere,Ebelibri,Efem,Ekoku,Ekpe,Ewhoeviri,Galea,Gamen,Ganjin,Gantetudu,Gargar,Garinbode,Gbure,Gerti,Gidan,Gitabaremu,Giyagiri,Giyawa,Gmawa,Golakochi,Golumba,Gunji,Gwambula,Gwodoti,Hayinlere,Hayinmaialewa,Hirishi,Hombo,Ibefum,Iberekodo,Icharge,Idofin,Idofinoka,Igbogo,Ijoko,Ijuwa,Ikawga,Ikhin,Ikpakidout,Ikpeoniong,Imuogo,Ipawo,Ipinlerere,Isicha,Itakpa,Jangi,Jare,Jataudakum,Jaurogomki,Jepel,Kafinmalama,Katab,Katanga,Katinda,Katirije,Kaurakimba,Keffinshanu,Kellumiri,Kiagbodor,Kirbutu,Kita,Kogogo,Kopje,Korokorosei,Kotoku,Kuata,Kujum,Kukau,Kunboon,Kuonubogbene,Kurawe,Kushinahu,Kwaramakeri,Ladimeji,Lafiaro,Lahaga,Laindebajanle,Laindegoro,Lakati,Litenswa,Maba,Madarzai,Maianita,Malikansaa,Mata,Megoyo,Meku,Miama,Modi,Mshi,Msugh,Muduvu,Murnachehu,Namnai,Ndamanma,Ndiwulunbe,Ndonutim,Ngbande,Nguengu,Ntoekpe,Nyajo,Nyior,Odajie,Ogbaga,Ogultu,Ogunbunmi,Ojopode,Okehin,Olugunna,Omotunde,Onipede,Onma,Orhere,Orya,Otukwang,Otunade,Rampa,Rimi,Rugan,Rumbukawa,Sabiu,Sangabama,Sarabe,Seboregetore,Shafar,Shagwa,Shata,Shengu,Sokoron,Sunnayu,Tafoki,Takula,Talontan,Tarhemba,Tayu,Ter,Timtim,Timyam,Tindirke,Tokunbo,Torlwam,Tseakaadza,Tseanongo,Tsebeeve,Tsepaegh,Tuba,Tumbo,Tungalombo,Tunganyakwe,Uhkirhi,Umoru,Umuabai,Umuajuju,Unchida,Ungua,Unguwar,Unongo,Usha,Utongbo,Vembera,Wuro,Yanbashi,Yanmedi,Yoku,Zarunkwari,Zilumo,Zulika", + }, + { + name: "Celtic", + i: 22, + min: 4, + max: 12, + d: "nld", + m: 0, + b: "Aberaman,Aberangell,Aberarth,Aberavon,Aberbanc,Aberbargoed,Aberbeeg,Abercanaid,Abercarn,Abercastle,Abercegir,Abercraf,Abercregan,Abercych,Abercynon,Aberdare,Aberdaron,Aberdaugleddau,Aberdeen,Aberdulais,Aberdyfi,Aberedw,Abereiddy,Abererch,Abereron,Aberfan,Aberffraw,Aberffrwd,Abergavenny,Abergele,Aberglasslyn,Abergorlech,Abergwaun,Abergwesyn,Abergwili,Abergwynfi,Abergwyngregyn,Abergynolwyn,Aberhafesp,Aberhonddu,Aberkenfig,Aberllefenni,Abermain,Abermaw,Abermorddu,Abermule,Abernant,Aberpennar,Aberporth,Aberriw,Abersoch,Abersychan,Abertawe,Aberteifi,Aberthin,Abertillery,Abertridwr,Aberystwyth,Achininver,Afonhafren,Alisaha,Anfosadh,Antinbhearmor,Ardenna,Attacon,Banwen,Beira,Bhrura,Bleddfa,Boioduro,Bona,Boskyny,Boslowenpolbrogh,Boudobriga,Bravon,Brigant,Briganta,Briva,Brosnach,Caersws,Cambodunum,Cambra,Caracta,Catumagos,Centobriga,Ceredigion,Chalain,Chearbhallain,Chlasaigh,Chormaic,Cuileannach,Dinn,Diwa,Dubingen,Duibhidighe,Duro,Ebora,Ebruac,Eburodunum,Eccles,Egloskuri,Eighe,Eireann,Elerghi,Ferkunos,Fhlaithnin,Gallbhuaile,Genua,Ghrainnse,Gwyles,Heartsease,Hebron,Hordh,Inbhear,Inbhir,Inbhirair,Innerleithen,Innerleven,Innerwick,Inver,Inveraldie,Inverallan,Inveralmond,Inveramsay,Inveran,Inveraray,Inverarnan,Inverbervie,Inverclyde,Inverell,Inveresk,Inverfarigaig,Invergarry,Invergordon,Invergowrie,Inverhaddon,Inverkeilor,Inverkeithing,Inverkeithney,Inverkip,Inverleigh,Inverleith,Inverloch,Inverlochlarig,Inverlochy,Invermay,Invermoriston,Inverness,Inveroran,Invershin,Inversnaid,Invertrossachs,Inverugie,Inveruglas,Inverurie,Iubhrach,Karardhek,Kilninver,Kirkcaldy,Kirkintilloch,Krake,Lanngorrow,Latense,Leming,Lindomagos,Llanaber,Llandidiwg,Llandyrnog,Llanfarthyn,Llangadwaldr,Llansanwyr,Lochinver,Lugduno,Magoduro,Mheara,Monmouthshire,Nanshiryarth,Narann,Novioduno,Nowijonago,Octoduron,Penning,Pheofharain,Ponsmeur,Raithin,Ricomago,Rossinver,Salodurum,Seguia,Sentica,Theorsa,Tobargeal,Trealaw,Trefesgob,Trewedhenek,Trewythelan,Tuaisceart,Uige,Vitodurum,Windobona", + }, + { + name: "Mesopotamian", + i: 23, + min: 4, + max: 9, + d: "srpl", + m: 0.1, + b: "Adab,Adamndun,Adma,Admatum,Agrab,Akkad,Akshak,Amnanum,Andarig,Anshan,Apiru,Apum,Arantu,Arbid,Arpachiyah,Arpad,Arrapha,Ashlakka,Assur,Awan,Babilim,Bad-Tibira,Balawat,Barsip,Birtu,Bit-Bunakki,Borsippa,Chuera,Dashrah,Der,Dilbat,Diniktum,Doura,Dur-Kurigalzu,Dur-Sharrukin,Dur-Untash,Dûr-gurgurri,Ebla,Ekallatum,Ekalte,Emar,Erbil,Eresh,Eridu,Eshnunn,Eshnunna,Gargamish,Gasur,Gawra,Gibil,Girsu,Gizza,Habirun,Habur,Hadatu,Hakkulan,Halab,Halabit,Hamazi,Hamoukar,Haradum,Harbidum,Harran,Harranu,Hassuna,Hatarikka,Hatra,Hissar,Hiyawa,Hormirzad,Ida-Maras,Idamaraz,Idu,Imerishu,Imgur-Enlil,Irisagrig,Irnina,Irridu,Isin,Issinnitum,Iturungal,Izubitum,Jarmo,Jemdet,Kabnak,Kadesh,Kahat,Kalhu,Kar-Shulmanu-Asharedu,Kar-Tukulti-Ninurta,Kar-shulmanu-asharedu,Karana,Karatepe,Kartukulti,Kazallu,Kesh,Kidsha,Kinza,Kish,Kisiga,Kisurra,Kuara,Kurda,Kurruhanni,Kutha,Lagaba,Lagash,Larak,Larsa,Leilan,Malgium,Marad,Mardaman,Mari,Marlik,Mashkan,Mashkan-shapir,Matutem,Me-Turan,Meliddu,Mumbaqat,Nabada,Nagar,Nanagugal,Nerebtum,Nigin,Nimrud,Nina,Nineveh,Ninua,Nippur,Niru,Niya,Nuhashe,Nuhasse,Nuzi,Puzrish-Dagan,Qalatjarmo,Qatara,Qatna,Qattunan,Qidshu,Rapiqum,Rawda,Sagaz,Shaduppum,Shaggaratum,Shalbatu,Shanidar,Sharrukin,Shawwan,Shehna,Shekhna,Shemshara,Shibaniba,Shubat-Enlil,Shurkutir,Shuruppak,Shusharra,Shushin,Sikan,Sippar,Sippar-Amnanum,Sippar-sha-Annunitum,Subatum,Susuka,Tadmor,Tarbisu,Telul,Terqa,Tirazish,Tisbon,Tuba,Tushhan,Tuttul,Tutub,Ubaid,Umma,Ur,Urah,Urbilum,Urkesh,Ursa'um,Uruk,Urum,Uzarlulu,Warka,Washukanni,Zabalam,Zarri-Amnan", + }, + { + name: "Iranian", + i: 24, + min: 5, + max: 11, + d: "", + m: 0.1, + b: "Abali,Abrisham,Absard,Abuzeydabad,Afus,Alavicheh,Alikosh,Amol,Anarak,Anbar,Andisheh,Anshan,Aran,Ardabil,Arderica,Ardestan,Arjomand,Asgaran,Asgharabad,Ashian,Awan,Babajan,Badrud,Bafran,Baghestan,Baghshad,Bahadoran,Baharan Shahr,Baharestan,Bakun,Bam,Baqershahr,Barzok,Bastam,Behistun,Bitistar,Bumahen,Bushehr,Chadegan,Chahardangeh,Chamgardan,Chermahin,Choghabonut,Chugan,Damaneh,Damavand,Darabgard,Daran,Dastgerd,Dehaq,Dehaqan,Dezful,Dizicheh,Dorcheh,Dowlatabad,Duruntash,Ecbatana,Eslamshahr,Estakhr,Ezhiyeh,Falavarjan,Farrokhi,Fasham,Ferdowsieh,Fereydunshahr,Ferunabad,Firuzkuh,Fuladshahr,Ganjdareh,Ganzak,Gaz,Geoy,Godin,Goldasht,Golestan,Golpayegan,Golshahr,Golshan,Gorgab,Guged,Habibabad,Hafshejan,Hajjifiruz,Hana,Harand,Hasanabad,Hasanlu,Hashtgerd,Hecatompylos,Hormirzad,Imanshahr,Isfahan,Jandaq,Javadabad,Jiroft,Jowsheqan ,Jowzdan,Kabnak,Kahrizak,Kahriz Sang,Kangavar,Karaj,Karkevand,Kashan,Kelishad,Kermanshah,Khaledabad,Khansar,Khorramabad,Khur,Khvorzuq,Kilan,Komeh,Komeshcheh,Konar,Kuhpayeh,Kul,Kushk,Lavasan,Laybid,Liyan,Lyan,Mahabad,Mahallat,Majlesi,Malard,Manzariyeh,Marlik,Meshkat,Meymeh,Miandasht,Mish,Mobarakeh,Nahavand,Nain,Najafabad,Naqshe,Narezzash,Nasimshahr,Nasirshahr,Nasrabad,Natanz,Neyasar,Nikabad,Nimvar,Nushabad,Pakdasht,Parand,Pardis,Parsa,Pasargadai,Patigrabana,Pir Bakran,Pishva,Qahderijan,Qahjaverestan,Qamsar,Qarchak,Qods,Rabat,Ray-shahr,Rezvanshahr,Rhages,Robat Karim,Rozveh,Rudehen,Sabashahr,Safadasht,Sagzi,Salehieh,Sandal,Sarvestan,Sedeh,Sefidshahr,Semirom,Semnan,Shadpurabad,Shah,Shahdad,Shahedshahr,Shahin,Shahpour,Shahr,Shahreza,Shahriar,Sharifabad,Shemshak,Shiraz,Shushan,Shushtar,Sialk,Sin,Sukhteh,Tabas,Tabriz,Takhte,Talkhuncheh,Talli,Tarq,Temukan,Tepe,Tiran,Tudeshk,Tureng,Urmia,Vahidieh,Vahrkana,Vanak,Varamin,Varnamkhast,Varzaneh,Vazvan,Yahya,Yarim,Yasuj,Zarrin Shahr,Zavareh,Zayandeh,Zazeran,Ziar,Zibashahr,Zranka", + }, + { + name: "Hawaiian", + i: 25, + min: 5, + max: 10, + d: "auo", + m: 1, + b: "Aapueo,Ahoa,Ahuakaio,Ahupau,Alaakua,Alae,Alaeloa,Alamihi,Aleamai,Alena,Alio,Aupokopoko,Halakaa,Haleu,Haliimaile,Hamoa,Hanakaoo,Hanaulu,Hanawana,Hanehoi,Haou,Hikiaupea,Hokuula,Honohina,Honokahua,Honokeana,Honokohau,Honolulu,Honomaele,Hononana,Honopou,Hoolawa,Huelo,Kaalaea,Kaapahu,Kaeo,Kahalehili,Kahana,Kahuai,Kailua,Kainehe,Kakalahale,Kakanoni,Kalenanui,Kaleoaihe,Kalialinui,Kalihi,Kalimaohe,Kaloi,Kamani,Kamehame,Kanahena,Kaniaula,Kaonoulu,Kapaloa,Kapohue,Kapuaikini,Kapunakea,Kauau,Kaulalo,Kaulanamoa,Kauluohana,Kaumakani,Kaumanu,Kaunauhane,Kaupakulua,Kawaloa,Keaa,Keaaula,Keahua,Keahuapono,Kealahou,Keanae,Keauhou,Kelawea,Keokea,Keopuka,Kikoo,Kipapa,Koakupuna,Koali,Kolokolo,Kopili,Kou,Kualapa,Kuhiwa,Kuholilea,Kuhua,Kuia,Kuikui,Kukoae,Kukohia,Kukuiaeo,Kukuipuka,Kukuiula,Kulahuhu,Lapakea,Lapueo,Launiupoko,Lole,Maalo,Mahinahina,Mailepai,Makaakini,Makaalae,Makaehu,Makaiwa,Makaliua,Makapipi,Makapuu,Maluaka,Manawainui,Mehamenui,Moalii,Moanui,Mohopili,Mokae,Mokuia,Mokupapa,Mooiki,Mooloa,Moomuku,Muolea,Nakaaha,Nakalepo,Nakaohu,Nakapehu,Nakula,Napili,Niniau,Nuu,Oloewa,Olowalu,Omaopio,Onau,Onouli,Opaeula,Opana,Opikoula,Paakea,Paeahu,Paehala,Paeohi,Pahoa,Paia,Pakakia,Palauea,Palemo,Paniau,Papaaea,Papaanui,Papaauhau,Papaka,Papauluana,Pauku,Paunau,Pauwalu,Pauwela,Pohakanele,Polaiki,Polanui,Polapola,Poopoo,Poponui,Poupouwela,Puahoowali,Puakea,Puako,Pualaea,Puehuehu,Pueokauiki,Pukaauhuhu,Pukuilua,Pulehu,Puolua,Puou,Puuhaehae,Puuiki,Puuki,Puulani,Puunau,Puuomaile,Uaoa,Uhao,Ukumehame,Ulaino,Ulumalu,Wahikuli,Waianae,Waianu,Waiawa,Waiehu,Waieli,Waikapu,Wailamoa,Wailaulau,Wainee,Waiohole,Waiohonu,Waiohuli,Waiokama,Waiokila,Waiopai,Waiopua,Waipao,Waipionui,Waipouli", + }, + { + name: "Karnataka", + i: 26, + min: 5, + max: 11, + d: "tnl", + m: 0, + b: "Adityapatna,Adyar,Afzalpur,Aland,Alnavar,Alur,Ambikanagara,Anekal,Ankola,Annigeri,Arkalgud,Arsikere,Athni,Aurad,Badami,Bagalkot,Bagepalli,Bail,Bajpe,Bangalore,Bangarapet,Bankapura,Bannur,Bantval,Basavakalyan,Basavana,Belgaum,Beltangadi,Belur,Bhadravati,Bhalki,Bhatkal,Bhimarayanagudi,Bidar,Bijapur,Bilgi,Birur,Bommasandra,Byadgi,Challakere,Chamarajanagar,Channagiri,Channapatna,Channarayapatna,Chik,Chikmagalur,Chiknayakanhalli,Chikodi,Chincholi,Chintamani,Chitapur,Chitgoppa,Chitradurga,Dandeli,Dargajogihalli,Devadurga,Devanahalli,Dod,Donimalai,Gadag,Gajendragarh,Gangawati,Gauribidanur,Gokak,Gonikoppal,Gubbi,Gudibanda,Gulbarga,Guledgudda,Gundlupet,Gurmatkal,Haliyal,Hangal,Harapanahalli,Harihar,Hassan,Hatti,Haveri,Hebbagodi,Heggadadevankote,Hirekerur,Holalkere,Hole,Homnabad,Honavar,Honnali,Hoovina,Hosakote,Hosanagara,Hosdurga,Hospet,Hubli,Hukeri,Hungund,Hunsur,Ilkal,Indi,Jagalur,Jamkhandi,Jevargi,Jog,Kadigenahalli,Kadur,Kalghatgi,Kamalapuram,Kampli,Kanakapura,Karkal,Karwar,Khanapur,Kodiyal,Kolar,Kollegal,Konnur,Koppa,Koppal,Koratagere,Kotturu,Krishnarajanagara,Krishnarajasagara,Krishnarajpet,Kudchi,Kudligi,Kudremukh,Kumta,Kundapura,Kundgol,Kunigal,Kurgunta,Kushalnagar,Kushtagi,Lakshmeshwar,Lingsugur,Londa,Maddur,Madhugiri,Madikeri,Mahalingpur,Malavalli,Mallar,Malur,Mandya,Mangalore,Manvi,Molakalmuru,Mudalgi,Mudbidri,Muddebihal,Mudgal,Mudhol,Mudigere,Mulbagal,Mulgund,Mulki,Mulur,Mundargi,Mundgod,Munirabad,Mysore,Nagamangala,Nanjangud,Narasimharajapura,Naregal,Nargund,Navalgund,Nipani,Pandavapura,Pavagada,Piriyapatna,Pudu,Puttur,Rabkavi,Raichur,Ramanagaram,Ramdurg,Ranibennur,Raybag,Robertson,Ron,Sadalgi,Sagar,Sakleshpur,Saligram,Sandur,Sankeshwar,Saundatti,Savanur,Sedam,Shahabad,Shahpur,Shaktinagar,Shiggaon,Shikarpur,Shirhatti,Shorapur,Shrirangapattana,Siddapur,Sidlaghatta,Sindgi,Sindhnur,Sira,Siralkoppa,Sirsi,Siruguppa,Somvarpet,Sorab,Sringeri,Srinivaspur,Sulya,Talikota,Tarikere,Tekkalakote,Terdal,Thumbe,Tiptur,Tirthahalli,Tirumakudal,Tumkur,Turuvekere,Udupi,Vijayapura,Wadi,Yadgir,Yelandur,Yelbarga,Yellapur,Yenagudde", + }, + { + name: "Quechua", + i: 27, + min: 6, + max: 12, + d: "l", + m: 0, + b: "Alpahuaycco,Anchihuay,Anqea,Apurimac,Arequipa,Atahuallpa,Atawalpa,Atico,Ayacucho,Ayahuanco,Ayllu,Cajamarca,Canayre,Canchacancha,Carapo,Carhuac,Carhuacatac,Cashan,Caullaraju,Caxamalca,Cayesh,Ccahuasno,Ccarhuacc,Ccopayoc,Chacchapunta,Chacraraju,Challhuamayo,Champara,Chanchan,Chekiacraju,Chillihua,Chinchey,Chontah,Chopicalqui,Chucuito,Chuito,Chullo,Chumpi,Chuncho,Chupahuacho,Chuquiapo,Chuquisaca,Churup,Cocapata,Cochabamba,Cojup,Collota,Conococha,Corihuayrachina,Cuchoquesera,Cusichaca,Haika,Hanpiq,Hatun,Haywarisqa,Huaca,Huachinga,Hualcan,Hualchancca,Huamanga,Huamashraju,Huancarhuas,Huandoy,Huantsan,Huanupampa,Huarmihuanusca,Huascaran,Huaylas,Huayllabamba,Huayrana,Huaytara,Huichajanca,Huinayhuayna,Huinche,Huinioch,Illiasca,Intipunku,Iquicha,Ishinca,Jahuacocha,Jirishanca,Juli,Jurau,Kakananpunta,Kamasqa,Karpay,Kausay,Khuya,Kuelap,Lanccochayocc,Llaca,Llactapata,Llanganuco,Llaqta,Lloqllasca,Llupachayoc,Luricocha,Machu,Mallku,Matarraju,Mechecc,Mikhuy,Milluacocha,Morochuco,Munay,Ocshapalca,Ollantaytambo,Oroccahua,Oronccoy,Oyolo,Pacamayo,Pacaycasa,Paccharaju,Pachacamac,Pachakamaq,Pachakuteq,Pachakuti,Pachamama,Paititi,Pajaten,Palcaraju,Pallccas,Pampa,Panaka,Paqarina,Paqo,Parap,Paria,Patahuasi,Patallacta,Patibamba,Pisac,Pisco,Pongos,Pucacolpa,Pucahirca,Pucaranra,Pumatambo,Puscanturpa,Putaca,Puyupatamarca,Qawaq,Qayqa,Qochamoqo,Qollana,Qorihuayrachina,Qorimoqo,Qotupuquio,Quenuaracra,Queshque,Quillcayhuanca,Quillya,Quitaracsa,Quitaraju,Qusqu,Rajucolta,Rajutakanan,Rajutuna,Ranrahirca,Ranrapalca,Raria,Rasac,Rimarima,Riobamba,Runkuracay,Rurec,Sacsa,Sacsamarca,Saiwa,Sarapo,Sayacmarca,Sayripata,Sinakara,Sonccopa,Taripaypacha,Taulliraju,Tawantinsuyu,Taytanchis,Tiwanaku,Tocllaraju,Tsacra,Tuco,Tucubamba,Tullparaju,Tumbes,Uchuraccay,Uchuraqay,Ulta,Urihuana,Uruashraju,Vallunaraju,Vilcabamba,Wacho,Wankawillka,Wayra,Yachay,Yahuarraju,Yanamarey,Yanaqucha,Yanesha,Yerupaja", + }, + { + name: "Swahili", + i: 28, + min: 4, + max: 9, + d: "", + m: 0, + b: "Abim,Adjumani,Alebtong,Amolatar,Amuru,Apac,Arua,Arusha,Babati,Baragoi,Bombo,Budaka,Bugembe,Bugiri,Buikwe,Bukedea,Bukoba,Bukomansimbi,Bukungu,Buliisa,Bundibugyo,Bungoma,Busembatya,Bushenyi,Busia,Busolwe,Butaleja,Butambala,Butere,Buwenge,Buyende,Dadaab,Dodoma,Dokolo,Eldoret,Elegu,Emali,Embu,Entebbe,Garissa,Gede,Gulu,Handeni,Hima,Hoima,Hola,Ibanda,Iganga,Iringa,Isingiro,Isiolo,Jinja,Kaabong,Kabuyanda,Kabwohe,Kagadi,Kajiado,Kakinga,Kakiri,Kakuma,Kalangala,Kaliro,Kalongo,Kalungu,Kampala,Kamwenge,Kanungu,Kapchorwa,Kasese,Kasulu,Katakwi,Kayunga,Keroka,Kiambu,Kibaale,Kibaha,Kibingo,Kibwezi,Kigoma,Kihiihi,Kilifi,Kiruhura,Kiryandongo,Kisii,Kisoro,Kisumu,Kitale,Kitgum,Kitui,Koboko,Korogwe,Kotido,Kumi,Kyazanga,Kyegegwa,Kyenjojo,Kyotera,Lamu,Langata,Lindi,Lodwar,Lokichoggio,Londiani,Loyangalani,Lugazi,Lukaya,Luweero,Lwakhakha,Lwengo,Lyantonde,Machakos,Mafinga,Makambako,Makindu,Malaba,Malindi,Manafwa,Mandera,Marsabit,Masaka,Masindi,Masulita,Matugga,Mayuge,Mbale,Mbarara,Mbeya,Meru,Mitooma,Mityana,Mombasa,Morogoro,Moroto,Moyale,Moyo,Mpanda,Mpigi,Mpondwe,Mtwara,Mubende,Mukono,Muranga,Musoma,Mutomo,Mutukula,Mwanza,Nagongera,Nairobi,Naivasha,Nakapiripirit,Nakaseke,Nakasongola,Nakuru,Namanga,Namayingo,Namutumba,Nansana,Nanyuki,Narok,Naromoru,Nebbi,Ngora,Njeru,Njombe,Nkokonjeru,Ntungamo,Nyahururu,Nyeri,Oyam,Pader,Paidha,Pakwach,Pallisa,Rakai,Ruiru,Rukungiri,Rwimi,Sanga,Sembabule,Shimoni,Shinyanga,Singida,Sironko,Songea,Soroti,Ssabagabo,Sumbawanga,Tabora,Takaungu,Tanga,Thika,Tororo,Tunduma,Vihiga,Voi,Wajir,Wakiso,Watamu,Webuye,Wobulenzi,Wote,Wundanyi,Yumbe,Zanzibar", + }, + { + name: "Vietnamese", + i: 29, + min: 3, + max: 12, + d: "", + m: 1, + b: "An Giang,Anh Son,An Khe,An Nhon,Ayun Pa,Bac Giang,Bac Kan,Bac Lieu,Bac Ninh,Ba Don,Bao Loc,Ba Ria,Ba Ria-Vung Tau,Ba Thuoc,Ben Cat,Ben Tre,Bien Hoa,Bim Son,Binh Dinh,Binh Duong,Binh Long,Binh Minh,Binh Phuoc,Binh Thuan,Buon Ho,Buon Ma Thuot,Cai Lay,Ca Mau,Cam Khe,Cam Pha,Cam Ranh,Cam Thuy,Can Tho,Cao Bang,Cao Lanh,Cao Phong,Chau Doc,Chi Linh,Con Cuong,Cua Lo,Da Bac,Dak Lak,Da Lat,Da Nang,Di An,Dien Ban,Dien Bien,Dien Bien Phu,Dien Chau,Do Luong,Dong Ha,Dong Hoi,Dong Trieu,Duc Pho,Duyen Hai,Duy Tien,Gia Lai,Gia Nghia,Gia Rai,Go Cong,Ha Giang,Ha Hoa,Hai Duong,Hai Phong,Ha Long,Ha Nam,Ha Noi,Ha Tinh,Ha Trung,Hau Giang,Hoa Binh,Hoang Mai,Hoa Thanh,Ho Chi Minh,Hoi An,Hong Linh,Hong Ngu,Hue,Hung Nguyen,Hung Yen,Huong Thuy,Huong Tra,Khanh Hoa,Kien Tuong,Kim Boi,Kinh Mon,Kon Tum,Ky Anh,Ky Son,Lac Son,Lac Thuy,La Gi,Lai Chau,Lam Thao,Lang Chanh,Lang Son,Lao Cai,Long An,Long Khanh,Long My,Long Xuyen,Luong Son,Mai Chau,Mong Cai,Muong Lat,Muong Lay,My Hao,My Tho,Nam Dan,Nam Dinh,Nga Bay,Nga Nam,Nga Son,Nghe An,Nghia Dan,Nghia Lo,Nghi Loc,Nghi Son,Ngoc Lac,Nha Trang,Nhu Thanh,Nhu Xuan,Ninh Binh,Ninh Hoa,Nong Cong,Phan Rang Thap Cham,Phan Thiet,Pho Yen,Phu Ly,Phu My,Phu Ninh,Phuoc Long,Phu Tho,Phu Yen,Pleiku,Quang Binh,Quang Nam,Quang Ngai,Quang Ninh,Quang Tri,Quang Xuong,Quang Yen,Quan Hoa,Quan Son,Que Phong,Quy Chau,Quy Hop,Quynh Luu,Quy Nhon,Rach Gia,Sa Dec,Sai Gon,Sam Son,Sa Pa,Soc Trang,Song Cau,Song Cong,Son La,Son Tay,Tam Diep,Tam Ky,Tan An,Tan Chau,Tan Ky,Tan Lac,Tan Son,Tan Uyen,Tay Ninh,Thach Thanh,Thai Binh,Thai Hoa,Thai Nguyen,Thanh Chuong,Thanh Hoa,Thieu Hoa,Thuan An,Thua Thien-Hue,Thu Dau Mot,Thu Duc,Thuong Xuan,Tien Giang,Trang Bang,Tra Vinh,Trieu Son,Tu Son,Tuyen Quang,Tuy Hoa,Uong Bi,Viet Tri,Vinh,Vinh Chau,Vinh Loc,Vinh Long,Vinh Yen,Vi Thanh,Vung Tau,Yen Bai,Yen Dinh,Yen Thanh,Yen Thuy", + }, + { + name: "Cantonese", + i: 30, + min: 5, + max: 11, + d: "", + m: 0, + b: "Chaiwan,Chingchung,Chinghoi,Chingsen,Chingshing,Chiunam,Chiuon,Chiuyeung,Chiyuen,Choihung,Chuehoi,Chuiman,Chungfu,Chungsan,Chunguktsuen,Dakhing,Daopo,Daumun,Dingwu,Dinpak,Donggun,Dongyuen,Duenchau,Fachau,Fanling,Fatgong,Fatshan,Fotan,Fuktien,Fumun,Funggong,Funghoi,Fungshun,Fungtei,Gamtin,Gochau,Goming,Gonghoi,Gongshing,Goyiu,Hanghau,Hangmei,Hengon,Heungchau,Heunggong,Heungkiu,Hingning,Hohfuktong,Hoichue,Hoifung,Hoiping,Hokong,Hokshan,Hoyuen,Hunghom,Hungshuikiu,Jiuling,Kamsheung,Kamwan,Kaulongtong,Keilun,Kinon,Kinsang,Kityeung,Kongmun,Kukgong,Kwaifong,Kwaihing,Kwongchau,Kwongling,Kwongming,Kwuntong,Laichikok,Laiking,Laiwan,Lamtei,Lamtin,Leitung,Leungking,Limkong,Linping,Linshan,Loding,Lokcheong,Lokfu,Longchuen,Longgong,Longmun,Longping,Longwa,Longwu,Lowu,Luichau,Lukfung,Lukho,Lungmun,Macheung,Maliushui,Maonshan,Mauming,Maunam,Meifoo,Mingkum,Mogong,Mongkok,Muichau,Muigong,Muiyuen,Naiwai,Namcheong,Namhoi,Namhong,Namsha,Nganwai,Ngautaukok,Ngchuen,Ngwa,Onting,Pakwun,Paotoishan,Pingshan,Pingyuen,Poklo,Pongon,Poning,Potau,Puito,Punyue,Saiwanho,Saiyingpun,Samshing,Samshui,Samtsen,Samyuenlei,Sanfung,Sanhing,Sanhui,Sanwai,Seiwui,Shamshuipo,Shanmei,Shantau,Shauking,Shekmun,Shekpai,Sheungshui,Shingkui,Shiuhing,Shundak,Shunyi,Shupinwai,Simshing,Siuhei,Siuhong,Siukwan,Siulun,Suikai,Taihing,Taikoo,Taipo,Taishuihang,Taiwai,Taiwohau,Tinhau,Tinshuiwai,Tiukengleng,Toishan,Tongfong,Tonglowan,Tsakyoochung,Tsamgong,Tsangshing,Tseungkwano,Tsimshatsui,Tsinggong,Tsingshantsuen,Tsingwun,Tsingyi,Tsingyuen,Tsiuchau,Tsuenshekshan,Tsuenwan,Tuenmun,Tungchung,Waichap,Waichau,Waidong,Wailoi,Waishing,Waiyeung,Wanchai,Wanfau,Wanshing,Wingon,Wongpo,Wongtaisin,Woping,Wukaisha,Yano,Yaumatei,Yautong,Yenfa,Yeungchun,Yeungdong,Yeungsai,Yeungshan,Yimtin,Yingdak,Yiuping,Yongshing,Yongyuen,Yuenlong,Yuenshing,Yuetsau,Yuknam,Yunping", + }, + { + name: "Mongolian", + i: 31, + min: 5, + max: 12, + d: "aou", + m: 0.3, + b: "Adaatsag,Airag,Alag Erdene,Altai,Altanshiree,Altantsogts,Arbulag,Baatsagaan,Batnorov,Batshireet,Battsengel,Bayan Adarga,Bayan Agt,Bayanbulag,Bayandalai,Bayandun,Bayangovi,Bayanjargalan,Bayankhongor,Bayankhutag,Bayanlig,Bayanmonkh,Bayannur,Bayannuur,Bayan Ondor,Bayan Ovoo,Bayantal,Bayantsagaan,Bayantumen,Bayan Uul,Bayanzurkh,Berkh,Biger,Binder,Bogd,Bombogor,Bor Ondor,Bugat,Bugt,Bulgan,Buregkhangai,Burentogtokh,Buutsagaan,Buyant,Chandmani,Chandmani Ondor,Choibalsan,Chuluunkhoroot,Chuluut,Dadal,Dalanjargalan,Dalanzadgad,Darhan Muminggan,Darkhan,Darvi,Dashbalbar,Dashinchilen,Delger,Delgerekh,Delgerkhaan,Delgerkhangai,Delgertsogt,Deluun,Deren,Dorgon,Duut,Erdene,Erdenebulgan,Erdeneburen,Erdenedalai,Erdenemandal,Erdenetsogt,Galshar,Galt,Galuut,Govi Ugtaal,Gurvan,Gurvanbulag,Gurvansaikhan,Gurvanzagal,Hinggan,Hodong,Holingol,Hondlon,Horin Ger,Horqin,Hulunbuir,Hure,Ikhkhet,Ikh Tamir,Ikh Uul,Jargalan,Jargalant,Jargaltkhaan,Jarud,Jinst,Khairkhan,Khalhgol,Khaliun,Khanbogd,Khangai,Khangal,Khankh,Khankhongor,Khashaat,Khatanbulag,Khatgal,Kherlen,Khishig Ondor,Khokh,Kholonbuir,Khongor,Khotont,Khovd,Khovsgol,Khuld,Khureemaral,Khurmen,Khutag Ondor,Luus,Mandakh,Mandal Ovoo,Mankhan,Manlai,Matad,Mogod,Monkhkhairkhan,Moron,Most,Myangad,Nogoonnuur,Nomgon,Norovlin,Noyon,Ogii,Olgii,Olziit,Omnodelger,Ondorkhaan,Ondorshil,Ondor Ulaan,Ongniud,Ordos,Orgon,Orkhon,Rashaant,Renchinlkhumbe,Sagsai,Saikhan,Saikhandulaan,Saikhan Ovoo,Sainshand,Saintsagaan,Selenge,Sergelen,Sevrei,Sharga,Sharyngol,Shine Ider,Shinejinst,Shiveegovi,Sumber,Taishir,Tarialan,Tariat,Teshig,Togrog,Togtoh,Tolbo,Tomorbulag,Tonkhil,Tosontsengel,Tsagaandelger,Tsagaannuur,Tsagaan Ovoo,Tsagaan Uur,Tsakhir,Tseel,Tsengel,Tsenkher,Tsenkhermandal,Tsetseg,Tsetserleg,Tsogt,Tsogt Ovoo,Tsogttsetsii,Tumed,Tunel,Tuvshruulekh,Ulaanbadrakh,Ulaankhus,Ulaan Uul,Ulanhad,Ulanqab,Uyench,Yesonbulag,Zag,Zalainur,Zamyn Uud,Zereg", + }, + // fantasy bases by Dopu: + { + name: "Human Generic", + i: 32, + min: 6, + max: 11, + d: "peolst", + m: 0, + b: "Amberglen,Angelhand,Arrowden,Autumnband,Autumnkeep,Basinfrost,Basinmore,Bayfrost,Beargarde,Bearmire,Bellcairn,Bellport,Bellreach,Blackwatch,Bleakward,Bonemouth,Boulder,Bridgefalls,Bridgeforest,Brinepeak,Brittlehelm,Bronzegrasp,Castlecross,Castlefair,Cavemire,Claymond,Claymouth,Clearguard,Cliffgate,Cliffshear,Cliffshield,Cloudbay,Cloudcrest,Cloudwood,Coldholde,Cragbury,Crowgrove,Crowvault,Crystalrock,Crystalspire,Cursefield,Curseguard,Cursespell,Dawnforest,Dawnwater,Deadford,Deadkeep,Deepcairn,Deerchill,Demonfall,Dewglen,Dewmere,Diredale,Direden,Dirtshield,Dogcoast,Dogmeadow,Dragonbreak,Dragonhold,Dragonward,Dryhost,Dustcross,Dustwatch,Eaglevein,Earthfield,Earthgate,Earthpass,Ebonfront,Edgehaven,Eldergate,Eldermere,Embervault,Everchill,Evercoast,Falsevale,Faypond,Fayvale,Fayyard,Fearpeak,Flameguard,Flamewell,Freyshell,Ghostdale,Ghostpeak,Gloomburn,Goldbreach,Goldyard,Grassplains,Graypost,Greeneld,Grimegrove,Grimeshire,Heartfall,Heartford,Heartvault,Highbourne,Hillpass,Hollowstorm,Honeywater,Houndcall,Houndholde,Iceholde,Icelight,Irongrave,Ironhollow,Knightlight,Knighttide,Lagoonpass,Lakecross,Lastmere,Laststar,Lightvale,Limeband,Littlehall,Littlehold,Littlemire,Lostcairn,Lostshield,Loststar,Madfair,Madham,Midholde,Mightglen,Millstrand,Mistvault,Mondpass,Moonacre,Moongulf,Moonwell,Mosshand,Mosstide,Mosswind,Mudford,Mudwich,Mythgulch,Mythshear,Nevercrest,Neverfront,Newfalls,Nighthall,Oakenbell,Oakenrun,Oceanstar,Oldreach,Oldwall,Oldwatch,Oxbrook,Oxlight,Pearlhaven,Pinepond,Pondfalls,Pondtown,Pureshell,Quickbell,Quickpass,Ravenside,Roguehaven,Roseborn,Rosedale,Rosereach,Rustmore,Saltmouth,Sandhill,Scorchpost,Scorchstall,Shadeforest,Shademeadow,Shadeville,Shimmerrun,Shimmerwood,Shroudrock,Silentkeep,Silvercairn,Silvergulch,Smallmire,Smoothcliff,Smoothgrove,Smoothtown,Snakemere,Snowbay,Snowshield,Snowtown,Southbreak,Springmire,Springview,Stagport,Steammouth,Steamwall,Steepmoor,Stillhall,Stoneguard,Stonespell,Stormhand,Stormhorn,Sungulf,Sunhall,Swampmaw,Swangarde,Swanwall,Swiftwell,Thorncairn,Thornhelm,Thornyard,Timberside,Tradewick,Westmeadow,Westpoint,Whiteshore,Whitvalley,Wildeden,Wildwell,Wildyard,Winterhaven,Wolfpass", + }, + { + name: "Elven", + i: 33, + min: 6, + max: 12, + d: "lenmsrg", + m: 0, + b: "Adrindest,Aethel,Afranthemar,Aiqua,Alari,Allanar,Almalian,Alora,Alyanasari,Alyelona,Alyran,Ammar,Anyndell,Arasari,Aren,Ashmebel,Aymlume,Bel-Didhel,Brinorion,Caelora,Chaulssad,Chaundra,Cyhmel,Cyrang,Dolarith,Dolonde,Draethe,Dranzan,Draugaust,E'ana,Eahil,Edhil,Eebel,Efranluma,Eld-Sinnocrin,Elelthyr,Ellanalin,Ellena,Ellorthond,Eltaesi,Elunore,Emyranserine,Entheas,Eriargond,Esari,Esath,Eserius,Eshsalin,Eshthalas,Evraland,Faellenor,Famelenora,Filranlean,Filsaqua,Gafetheas,Gaf Serine,Geliene,Gondorwin,Guallu,Haeth,Hanluna,Haulssad,Heloriath,Himlarien,Himliene,Hinnead,Hlinas,Hloireenil,Hluihei,Hlurthei,Hlynead,Iaenarion,Iaron,Illanathaes,Illfanora,Imlarlon,Imyse,Imyvelian,Inferius,Inlurth,innsshe,Iralserin,Irethtalos,Irholona,Ishal,Ishlashara,Ithelion,Ithlin,Iulil,Jaal,Jamkadi,Kaalume,Kaansera,Karanthanil,Karnosea,Kasethyr,Keatheas,Kelsya,Keth Aiqua,Kmlon,Kyathlenor,Kyhasera,Lahetheas,Lefdorei,Lelhamelle,Lilean,Lindeenil,Lindoress,Litys,Llaughei,Lya,Lyfa,Lylharion,Lynathalas,Machei,Masenoris,Mathethil,Mathentheas,Meethalas,Menyamar,Mithlonde,Mytha,Mythsemelle,Mythsthas,Naahona,Nalore,Nandeedil,Nasad Ilaurth,Nasin,Nathemar,Neadar,Neilon,Nelalon,Nellean,Nelnetaesi,Nilenathyr,Nionande,Nylm,Nytenanas,Nythanlenor,O'anlenora,Obeth,Ofaenathyr,Ollmnaes,Ollsmel,Olwen,Olyaneas,Omanalon,Onelion,Onelond,Orlormel,Ormrion,Oshana,Oshvamel,Raethei,Rauguall,Reisera,Reslenora,Ryanasera,Rymaserin,Sahnor,Saselune,Sel-Zedraazin,Selananor,Sellerion,Selmaluma,Shaeras,Shemnas,Shemserin,Sheosari,Sileltalos,Siriande,Siriathil,Srannor,Sshanntyr,Sshaulu,Syholume,Sylharius,Sylranbel,Taesi,Thalor,Tharenlon,Thelethlune,Thelhohil,Themar,Thene,Thilfalean,Thilnaenor,Thvethalas,Thylathlond,Tiregul,Tlauven,Tlindhe,Ulal,Ullve,Ulmetheas,Ulssin,Umnalin,Umye,Umyheserine,Unanneas,Unarith,Undraeth,Unysarion,Vel-Shonidor,Venas,Vin Argor,Wasrion,Wlalean,Yaeluma,Yeelume,Yethrion,Ymserine,Yueghed,Yuerran,Yuethin", + }, + { + name: "Dark Elven", + i: 34, + min: 6, + max: 14, + d: "nrslamg", + m: 0.2, + b: "Abaethaggar,Abburth,Afranthemar,Aharasplit,Aidanat,Ald'ruhn,Ashamanu,Ashesari,Ashletheas,Baerario,Baereghel,Baethei,Bahashae,Balmora,Bel-Didhel,Borethanil,Buiyrandyn,Caellagith,Caellathala,Caergroth,Caldras,Chaggar,Chaggaust,Channtar,Charrvhel'raugaust,Chaulssin,Chaundra,ChedNasad,ChetarIthlin,ChethRrhinn,Chymaer,Clarkarond,Cloibbra,Commoragh,Cyrangroth,Cilben,D'eldarc,Daedhrog,Dalkyn,Do'Urden,Doladress,Dolarith,Dolonde,Draethe,Dranzan,Dranzithl,Draugaust,Dreghei,Drelhei,Dryndlu,Dusklyngh,DyonG'ennivalz,Edraithion,Eld-Sinnocrin,Ellorthond,Enhethyr,Entheas,ErrarIthinn,Eryndlyn,Faladhell,Faneadar,Fethalas,Filranlean,Formarion,Ferdor,Gafetheas,Ghrond,Gilranel,Glamordis,Gnaarmok,Gnisis,Golothaer,Gondorwin,Guallidurth,Guallu,Gulshin,Haeth,Haggraef,Harganeth,Harkaldra,Haulssad,Haundrauth,Heloriath,Hlammachar,Hlaughei,Hloireenil,Hluitar,Inferius,Innsshe,Ithilaughym,Iz'aiogith,Jaal,Jhachalkhyn,Kaerabrae,Karanthanil,Karondkar,Karsoluthiyl,Kellyth,Khuul,Lahetheas,Lidurth,Lindeenil,Lirillaquen,LithMy'athar,LlurthDreier,Lolth,Lothuial,Luihaulen'tar,Maeralyn,Maerimydra,Mathathlona,Mathethil,Mellodona,Menagith,Menegwen,Menerrendil,Menzithl,Menzoberranzan,Mila-Nipal,Mithryn,Molagmar,Mundor,Myvanas,Naggarond,Nandeedil,NasadIlaurth,Nauthor,Navethas,Neadar,Nurtaleewe,Nidiel,Noruiben,Olwen,O'lalona,Obeth,Ofaenathyr,Orlormel,Orlytlar,Pelagiad,Raethei,Raugaust,Rauguall,Rilauven,Rrharrvhei,Sadrith,Sel-Zedraazin,Seydaneen,Shaz'rir,Skaal,Sschindylryn,Shamath,Shamenz,Shanntur,Sshanntynlan,Sshanntyr,Shaulssin,SzithMorcane,Szithlin,Szobaeth,Sirdhemben,T'lindhet,Tebh'zhor,Telmere,Telnarquel,Tharlarast,Thylathlond,Tlaughe,Trizex,Tyrybblyn,Ugauth,Ughym,Uhaelben,Ullmatalos,Ulmetheas,Ulrenserine,Uluitur,Undraeth,Undraurth,Undrek'Thoz,Ungethal,UstNatha,Uthaessien,V'elddrinnsshar,Vaajha,Vel-Shonidor,Velddra,Velothi,Venead,Vhalth'vha,Vinargothr,Vojha,Waethe,Waethei,Xaalkis,Yakaridan,Yeelume,Yridhremben,Yuethin,Yuethindrynn,Zirnakaynin", + }, + { + name: "Dwarven", + i: 35, + min: 4, + max: 11, + d: "dk", + m: 0, + b: "Addundad,Ahagzad,Ahazil,Akil,Akzizad,Anumush,Araddush,Arar,Arbhur,Badushund,Baragzig,Baragzund,Barakinb,Barakzig,Barakzinb,Barakzir,Baramunz,Barazinb,Barazir,Bilgabar,Bilgatharb,Bilgathaz,Bilgila,Bilnaragz,Bilnulbar,Bilnulbun,Bizaddum,Bizaddush,Bizanarg,Bizaram,Bizinbiz,Biziram,Bunaram,Bundinar,Bundushol,Bundushund,Bundushur,Buzaram,Buzundab,Buzundush,Gabaragz,Gabaram,Gabilgab,Gabilgath,Gabizir,Gabunal,Gabunul,Gabuzan,Gatharam,Gatharbhur,Gathizdum,Gathuragz,Gathuraz,Gila,Giledzir,Gilukkhath,Gilukkhel,Gunala,Gunargath,Gunargil,Gundumunz,Gundusharb,Gundushizd,Kharbharbiln,Kharbhatharb,Kharbhela,Kharbilgab,Kharbuzadd,Khatharbar,Khathizdin,Khathundush,Khazanar,Khazinbund,Khaziragz,Khaziraz,Khizdabun,Khizdusharbh,Khizdushath,Khizdushel,Khizdushur,Kholedzar,Khundabiln,Khundabuz,Khundinarg,Khundushel,Khuragzig,Khuramunz,Kibarak,Kibilnal,Kibizar,Kibunarg,Kibundin,Kibuzan,Kinbadab,Kinbaragz,Kinbarakz,Kinbaram,Kinbizah,Kinbuzar,Nala,Naledzar,Naledzig,Naledzinb,Naragzah,Naragzar,Naragzig,Narakzah,Narakzar,Naramunz,Narazar,Nargabad,Nargabar,Nargatharb,Nargila,Nargundum,Nargundush,Nargunul,Narukthar,Narukthel,Nula,Nulbadush,Nulbaram,Nulbilnarg,Nulbunal,Nulbundab,Nulbundin,Nulbundum,Nulbuzah,Nuledzah,Nuledzig,Nulukkhaz,Nulukkhund,Nulukkhur,Sharakinb,Sharakzar,Sharamunz,Sharbarukth,Shatharbhizd,Shatharbiz,Shathazah,Shathizdush,Shathola,Shaziragz,Shizdinar,Shizdushund,Sholukkharb,Shundinulb,Shundushund,Shurakzund,Shuramunz,Tumunzadd,Tumunzan,Tumunzar,Tumunzinb,Tumunzir,Ukthad,Ulbirad,Ulbirar,Ulunzar,Ulur,Umunzad,Undalar,Undukkhil,Undun,Undur,Unduzur,Unzar,Unzathun,Usharar,Zaddinarg,Zaddushur,Zaharbad,Zaharbhizd,Zarakib,Zarakzar,Zaramunz,Zarukthel,Zinbarukth,Zirakinb,Zirakzir,Ziramunz,Ziruktharbh,Zirukthur,Zundumunz", + }, + { + name: "Goblin", + i: 36, + min: 4, + max: 9, + d: "eag", + m: 0, + b: "Asinx,Bhiagielt,Biokvish,Blix,Blus,Bratliaq,Breshass,Bridvelb,Brybsil,Bugbig,Buyagh,Cel,Chalk,Chiafzia,Chox,Cielb,Cosvil,Crekork,Crild,Croibieq,Diervaq,Dobruing,Driord,Eebligz,Een,Enissee,Esz,Far,Felhob,Froihiofz,Fruict,Fygsee,Gagablin,Gigganqi,Givzieqee,Glamzofs,Glernaahx,Gneabs,Gnoklig,Gobbledak,gobbok,Gobbrin,Heszai,Hiszils,Hobgar,Honk,Iahzaarm,Ialsirt,Ilm,Ish,Jasheafta,Joimtoilm,Kass,Katmelt,Kleabtong,Kleardeek,Klilm,Kluirm,Kuipuinx,Moft,Mogg,Nilbog,Oimzoishai,Onq,Ozbiard,Paas,Phax,Phigheldai,Preang,Prolkeh,Pyreazzi,Qeerags,Qosx,Rekx,Shaxi,Sios,Slehzit,Slofboif,Slukex,Srefs,Srurd,Stiaggaltia,Stiolx,Stioskurt,Stroir,Strytzakt,Stuikvact,Styrzangai,Suirx,Swaxi,Taxai,Thelt,Thresxea,Thult,Traglila,Treaq,Ulb,Ulm,Utha,Utiarm,Veekz,Vohniots,Vreagaald,Watvielx,Wrogdilk,Wruilt,Xurx,Ziggek,Zriokots", + }, + { + name: "Orc", + i: 37, + min: 4, + max: 8, + d: "gzrcu", + m: 0, + b: "Adgoz,Adgril-Gha,Adog,Adzurd,Agkadh,Agzil-Ghal,Akh,Ariz-Dru,Arkugzo,Arrordri,Ashnedh,Azrurdrekh,Bagzildre,Bashnud,Bedgez-Graz,Bhakh,Bhegh,Bhiccozdur,Bhicrur,Bhirgoshbel,Bhog,Bhurkrukh,Bod-Rugniz,Bogzel,Bozdra,Bozgrun,Bozziz,Bral-Lazogh,Brazadh,Brogved,Brogzozir,Brolzug,Brordegeg,Brorkril-Zrog,Brugroz,Brukh-Zrabrul,Brur-Korre,Bulbredh,Bulgragh,Chaz-Charard,Chegan-Khed,Chugga,Chuzar,Dhalgron-Mog,Dhazon-Ner,Dhezza,Dhoddud,Dhodh-Brerdrodh,Dhodh-Ghigin,Dhoggun-Bhogh,Dhulbazzol,Digzagkigh,Dirdrurd,Dodkakh,Dorgri,Drizdedh,Drobagh,Drodh-Ashnugh,Drogvukh-Drodh,Drukh-Qodgoz,Drurkuz,Dududh,Dur-Khaddol,Egmod,Ekh-Beccon,Ekh-Krerdrugh,Ekh-Mezred,Gagh-Druzred,Gazdrakh-Vrard,Gegnod,Gerkradh,Ghagrocroz,Ghared-Krin,Ghedgrolbrol,Gheggor,Ghizgil,Gho-Ugnud,Gholgard,Gidh-Ucceg,Goccogmurd,Golkon,Graz-Khulgag,Gribrabrokh,Gridkog,Grigh-Kaggaz,Grirkrun-Qur,Grughokh,Grurro,Gugh-Zozgrod,Gur-Ghogkagh,Ibagh-Chol,Ibruzzed,Ibul-Brad,Iggulzaz,Ikh-Ugnan,Irdrelzug,Irmekh-Bhor,Kacruz,Kalbrugh,Karkor-Zrid,Kazzuz-Zrar,Kezul-Bruz,Kharkiz,Khebun,Khorbric,Khuldrerra,Khuzdraz,Kirgol,Koggodh,Korkrir-Grar,Kraghird,Krar-Zurmurd,Krigh-Bhurdin,Kroddadh,Krudh-Khogzokh,Kudgroccukh,Kudrukh,Kudzal,Kuzgrurd-Dedh,Larud,Legvicrodh,Lorgran,Lugekh,Lulkore,Mazgar,Merkraz,Mocculdrer,Modh-Odod,Morbraz,Mubror,Muccug-Ghuz,Mughakh-Chil,Murmad,Nazad-Ludh,Negvidh,Nelzor-Zroz,Nirdrukh,Nogvolkar,Nubud,Nuccag,Nudh-Kuldra,Nuzecro,Oddigh-Krodh,Okh-Uggekh,Ordol,Orkudh-Bhur,Orrad,Qashnagh,Qiccad-Chal,Qiddolzog,Qidzodkakh,Qirzodh,Rarurd,Reradgri,Rezegh,Rezgrugh,Rodrekh,Rogh-Chirzaz,Rordrushnokh,Rozzez,Ruddirgrad,Rurguz-Vig,Ruzgrin,Ugh-Vruron,Ughudadh,Uldrukh-Bhudh,Ulgor,Ulkin,Ummugh-Ekh,Uzaggor,Uzdriboz,Uzdroz,Uzord,Uzron,Vaddog,Vagord-Khod,Velgrudh,Verrugh,Vrazin,Vrobrun,Vrugh-Nardrer,Vrurgu,Vuccidh,Vun-Gaghukh,Zacrad,Zalbrez,Zigmorbredh,Zordrordud,Zorrudh,Zradgukh,Zragmukh,Zragrizgrakh,Zraldrozzuz,Zrard-Krodog,Zrazzuz-Vaz,Zrigud,Zrulbukh-Dekh,Zubod-Ur,Zulbriz,Zun-Bergrord", + }, + { + name: "Giant", + i: 38, + min: 5, + max: 10, + d: "kdtng", + m: 0, + b: "Addund,Aerora,Agane,Anumush,Arangrim,Bahourg,Baragzund,Barakinb,Barakzig,Barakzinb,Baramunz,Barazinb,Beornelde,Beratira,Borgbert,Botharic,Bremrol,Brerstin,Brildung,Brozu,Bundushund,Burthug,Chazruc,Chergun,Churtec,Dagdhor,Dankuc,Darnaric,Debuch,Dina,Dinez,Diru,Drard,Druguk,Dugfast,Duhal,Dulkun,Eldond,Enuz,Eraddam,Eradhelm,Froththorn,Fynwyn,Gabaragz,Gabaram,Gabizir,Gabuzan,Gagkake,Galfald,Galgrim,Gatal,Gazin,Geru,Gila,Giledzir,Girkun,Glumvat,Gluthmark,Gomruch,Gorkege,Gortho,Gostuz,Grimor,Grimtira,Guddud,Gudgiz,Gulwo,Gunargath,Gundusharb,Guril,Gurkale,Guruge,Guzi,Hargarth,Hartreo,Heimfara,Hildlaug,Idgurth,Inez,Inginy,Iora,Irkin,Jaldhor,Jarwar,Jornangar,Jornmoth,Kakkek,Kaltoch,Kegkez,Kengord,Kharbharbiln,Khatharbar,Khathizdin,Khazanar,Khaziragz,Khizdabun,Khizdushel,Khundinarg,Kibarak,Kibizar,Kigine,Kilfond,Kilkan,Kinbadab,Kinbuzar,Koril,Kostand,Kuzake,Lindira,Lingarth,Maerdis,Magald,Marbold,Marbrand,Memron,Minu,Mistoch,Morluch,Mornkin,Morntaric,Nagu,Naragzah,Naramunz,Narazar,Nargabar,Nargatharb,Nargundush,Nargunul,Natan,Natil,Neliz,Nelkun,Noluch,Norginny,Nulbaram,Nulbilnarg,Nuledzah,Nuledzig,Nulukkhaz,Nulukkhur,Nurkel,Oci,Olane,Oldstin,Orga,Ranava,Ranhera,Rannerg,Rirkan,Rizen,Rurki,Rurkoc,Sadgach,Sgandrol,Sharakzar,Shatharbiz,Shathizdush,Shathola,Shizdinar,Sholukkharb,Shundushund,Shurakzund,Sidga,Sigbeorn,Sigbi,Solfod,Somrud,Srokvan,Stighere,Sulduch,Talkale,Theoddan,Theodgrim,Throtrek,Tigkiz,Tolkeg,Toren,Tozage,Tulkug,Tumunzar,Umunzad,Undukkhil,Usharar,Valdhere,Varkud,Velfirth,Velhera,Vigkan,Vorkige,Vozig,Vylwed,Widhyrde,Wylaeya,Yili,Yotane,Yudgor,Yulkake,Zigez,Zugkan,Zugke", + }, + { + name: "Draconic", + i: 39, + min: 6, + max: 14, + d: "aliuszrox", + m: 0, + b: "Aaronarra,Adalon,Adamarondor,Aeglyl,Aerosclughpalar,Aghazstamn,Aglaraerose,Agoshyrvor,Alduin,Alhazmabad,Altagos,Ammaratha,Amrennathed,Anaglathos,Andrathanach,Araemra,Araugauthos,Arauthator,Arharzel,Arngalor,Arveiaturace,Athauglas,Augaurath,Auntyrlothtor,Azarvilandral,Azhaq,Balagos,Baratathlaer,Bleucorundum,BrazzPolis,Canthraxis,Capnolithyl,Charvekkanathor,Chellewis,Chelnadatilar,Cirrothamalan,Claugiyliamatar,Cragnortherma,Dargentum,Dendeirmerdammarar,Dheubpurcwenpyl,Domborcojh,Draconobalen,Dragansalor,Dupretiskava,Durnehviir,Eacoathildarandus,Eldrisithain,Enixtryx,Eormennoth,Esmerandanna,Evenaelorathos,Faenphaele,Felgolos,Felrivenser,Firkraag,Fll'Yissetat,Furlinastis,Galadaeros,Galglentor,Garnetallisar,Garthammus,Gaulauntyr,Ghaulantatra,Glouroth,Greshrukk,Guyanothaz,Haerinvureem,Haklashara,Halagaster,Halaglathgar,Havarlan,Heltipyre,Hethcypressarvil,Hoondarrh,Icehauptannarthanyx,Iiurrendeem,Ileuthra,Iltharagh,Ingeloakastimizilian,Irdrithkryn,Ishenalyr,Iymrith,Jaerlethket,Jalanvaloss,Jharakkan,Kasidikal,Kastrandrethilian,Khavalanoth,Khuralosothantar,Kisonraathiisar,Kissethkashaan,Kistarianth,Klauth,Klithalrundrar,Krashos,Kreston,Kriionfanthicus,Krosulhah,Krustalanos,Kruziikrel,Kuldrak,Lareth,Latovenomer,Lhammaruntosz,Llimark,Ma'fel'no'sei'kedeh'naar,MaelestorRex,Magarovallanthanz,Mahatnartorian,Mahrlee,Malaeragoth,Malagarthaul,Malazan,Maldraedior,Maldrithor,MalekSalerno,Maughrysear,Mejas,Meliordianix,Merah,Mikkaalgensis,Mirmulnir,Mistinarperadnacles,Miteach,Mithbarazak,Morueme,Moruharzel,Naaslaarum,Nahagliiv,Nalavarauthatoryl,Naxorlytaalsxar,Nevalarich,Nolalothcaragascint,Nurvureem,Nymmurh,Odahviing,Olothontor,Ormalagos,Otaaryliakkarnos,Paarthurnax,Pelath,Pelendralaar,Praelorisstan,Praxasalandos,Protanther,Qiminstiir,Quelindritar,Ralionate,Rathalylaug,Rathguul,Rauglothgor,Raumorthadar,Relonikiv,Ringreemeralxoth,Roraurim,Rynnarvyx,Sablaxaahl,Sahloknir,Sahrotaar,Samdralyrion,Saryndalaghlothtor,Sawaka,Shalamalauth,Shammagar,Sharndrel,Shianax,Skarlthoon,Skurge,Smergadas,Ssalangan,Sssurist,Sussethilasis,Sylvallitham,Tamarand,Tantlevgithus,Tarlacoal,Tenaarlaktor,Thalagyrt,Tharas'kalagram,Thauglorimorgorus,Thoklastees,Thyka,Tsenshivah,Ueurwen,Uinnessivar,Urnalithorgathla,Velcuthimmorhar,Velora,Vendrathdammarar,Venomindhar,Viinturuth,Voaraghamanthar,Voslaarum,Vr'tark,Vrondahorevos,Vuljotnaak,Vulthuryol,Wastirek,Worlathaugh,Xargithorvar,Xavarathimius,Yemere,Ylithargathril,Ylveraasahlisar,Za-Jikku,Zarlandris,Zellenesterex,Zilanthar,Zormapalearath,Zundaerazylym,Zz'Pzora", + }, + { + name: "Arachnid", + i: 40, + min: 4, + max: 10, + d: "erlsk", + m: 0, + b: "Aaqok'ser,Aiced,Aizachis,Allinqel,As'taq,Ashrash,Caaqtos,Ceek'sax,Ceezuq,Cek'sier,Cen'qi,Ceqzocer,Cezeed,Chachocaq,Charis,Chashilieth,Checib,Chernul,Chezi,Chiazu,Chishros,Chixhi,Chizhi,Chollash,Choq'sha,Cinchichail,Collul,Ecush'taid,Ekiqe,Eqas,Er'uria,Erikas,Es'tase,Esrub,Exha,Haqsho,Hiavheesh,Hitha,Hok'thi,Hossa,Iacid,Iciever,Illuq,Isnir,Keezut,Kheellavas,Kheizoh,Khiachod,Khika,Khirzur,Khonrud,Khrakku,Khraqshis,Khrethish'ti,Khriashus,Khrika,Khrirni,Klashirel,Kleil'sha,Klishuth,Krarnit,Kras'tex,Krotieqas,Lais'tid,Laizuh,Lasnoth,Len'qeer,Leqanches,Lezad,Lhilir,Lhivhath,Lhok'thu,Lialliesed,Liaraq,Liceva,Lichorro,Lilla,Lokieqib,Nakur,Neerhaca,Neet'er,Neezoh,Nenchiled,Nerhalneth,Nir'ih,Nizus,Noreeqo,On'qix,Qalitho,Qas'tor,Qasol,Qavrud,Qavud,Qazar,Qazru,Qekno,Qeqravee,Qes'tor,Qhaik'sal,Qhak'sish,Qhazsakais,Qheliva,Qhenchaqes,Qherazal,Qhon'qos,Qhosh,Qish'tur,Qisih,Qorhoci,Qranchiq,Racith,Rak'zes,Ranchis,Rarhie,Rarzi,Rarzisiaq,Ras'tih,Ravosho,Recad,Rekid,Rernee,Rertachis,Rezhokketh,Reziel,Rhacish,Rhail'shel,Rhairhizse,Rhakivex,Rhaqeer,Rhartix,Rheciezsei,Rheevid,Rhel'shir,Rhevhie,Rhiavekot,Rhikkos,Rhiqese,Rhiqi,Rhiqracar,Rhisned,Rhousnateb,Riakeesnex,Rintachal,Rir'ul,Rourk'u,Rouzakri,Sailiqei,Sanchiqed,Saqshu,Sat'ier,Sazi,Seiqas,Shieth'i,Shiqsheh,Shizha,Shrachuvo,Shranqo,Shravhos,Shravuth,Shreerhod,Shrethuh,Shriantieth,Shronqash,Shrovarhir,Shrozih,Siacaqoh,Siezosh,Siq'sha,Sirro,Sornosi,Srachussi,Szaca,Szacih,Szaqova,Szasu,Szazhilos,Szeerrud,Szeezsad,Szeknur,Szesir,Szezhirros,Szilshith,Szon'qol,Szornuq,Xeekke,Yeek'su,Yeeq'zox,Yeqil,Yeqroq,Yeveed,Yevied,Yicaveeh,Yirresh,Yisie,Yithik'thaih,Yorhaqshes,Zacheek'sa,Zakkasa,Zelraq,Zeqo,Zharuncho,Zhath'arhish,Zhavirrit,Zhazilraq,Zhazsachiel,Zhek'tha,Zhequ,Zhias'ted,Zhicat,Zhicur,Zhirhacil,Zhizri,Zhochizses,Ziarih,Zirnib", + }, + { + name: "Serpents", + i: 41, + min: 5, + max: 11, + d: "slrk", + m: 0, + b: "Aj'ha,Aj'i,Aj'tiss,Ajakess,Aksas,Aksiss,Al'en,An'jeshe,Apjige,Arkkess,Athaz,Atus,Azras,Caji,Cakrasar,Cal'arrun,Capji,Cathras,Cej'han,Ces,Cez'jenta,Cij'te,Cinash,Cizran,Coth'jus,Cothrash,Culzanek,Cunaless,Ej'tesh,Elzazash,Ergek,Eshjuk,Ethris,Gan'jas,Gapja,Gar'thituph,Gopjeguss,Gor'thesh,Gragishaph,Grar'theness,Grath'ji,Gressinas,Grolzesh,Grorjar,Grozrash,Guj'ika,Harji,Hej'hez,Herkush,Horgarrez,Illuph,Ipjar,Ithashin,Kaj'ess,Kar'kash,Kepjusha,Ki'kintus,Kissere,Koph,Kopjess,Kra'kasher,Krak,Krapjez,Krashjuless,Kraz'ji,Krirrigis,Krussin,Ma'lush,Mage,Maj'tak,Mal'a,Mapja,Mar'kash,Mar'kis,Marjin,Mas,Mathan,Men'jas,Meth'jaresh,Mij'hegak,Min'jash,Mith'jas,Monassu,Moss,Naj'hass,Najugash,Nak,Napjiph,Nar'ka,Nar'thuss,Narrusha,Nash,Nashjekez,Nataph,Nij'ass,Nij'tessiph,Nishjiss,Norkkuss,Nus,Olluruss,Or'thi,Or'thuss,Paj'a,Parkka,Pas,Pathujen,Paz'jaz,Pepjerras,Pirkkanar,Pituk,Porjunek,Pu'ke,Ragen,Ran'jess,Rargush,Razjuph,Rilzan,Riss,Rithruz,Rorgiss,Rossez,Rraj'asesh,Rraj'tass,Rrar'kess,Rrar'thuph,Rras,Rrazresh,Rrej'hish,Rrigelash,Rris,Rris,Rroksurrush,Rukrussush,Rurri,Russa,Ruth'jes,Sa'kitesh,Sar'thass,Sarjas,Sazjuzush,Ser'thez,Sezrass,Shajas,Shas,Shashja,Shass,Shetesh,Shijek,Shun'jaler,Shurjarri,Skaler,Skalla,Skallentas,Skaph,Skar'kerriz,Skath'jeruk,Sker'kalas,Skor,Skoz'ji,Sku'lu,Skuph,Skur'thur,Slalli,Slalt'har,Slelziress,Slil'ar,Sloz'jisa,Sojesh,Solle,Sorge,Sral'e,Sran'ji,Srapjess,Srar'thazur,Srash,Srath'jess,Srathrarre,Srerkkash,Srus,Sruss'tugeph,Sun,Suss'tir,Uzrash,Vargush,Vek,Vess'tu,Viph,Vult'ha,Vupjer,Vushjesash,Xagez,Xassa,Xulzessu,Zaj'tiss,Zan'jer,Zarriss,Zassegus,Zirres,Zsor,Zurjass", + }, + // additional by Avengium: + { + name: "Levantine", + i: 42, + min: 4, + max: 12, + d: "ankprs", + m: 0, + b: "Adme,Adramet,Agadir,Akko,Akzib,Alimas,Alis-Ubbo,Alqosh,Amid,Ammon,Ampi,Amurru,Andarig,Anpa,Araden,Aram,Arwad,Ashkelon,Athar,Atiq,Aza,Azeka,Baalbek,Babel,Batrun,Beerot,Beersheba,Beit Shemesh,Berytus,Bet Agus,Bet Anya,Beth-Horon,Bethel,Bethlehem,Bethuel,Bet Nahrin,Bet Nohadra,Bet Zalin,Birmula,Biruta,Bit Agushi,Bitan,Bit Zamani,Cerne,Dammeseq,Darmsuq,Dor,Eddial,Eden Ekron,Elah,Emek,Emun,Ephratah,Eyn Ganim,Finike,Gades,Galatia,Gaza,Gebal,Gedera,Gerizzim,Gethsemane,Gibeon,Gilead,Gilgal,Golgotha,Goshen,Gytte,Hagalil,Haifa,Halab,Haqel Dma,Har Habayit,Har Nevo,Har Pisga,Havilah,Hazor,Hebron,Hormah,Iboshim,Iriho,Irinem,Irridu,Israel,Kadesh,Kanaan,Kapara,Karaly,Kart-Hadasht,Keret Chadeshet,Kernah,Kesed,Keysariya,Kfar,Kfar Nahum,Khalibon,Khalpe,Khamat,Kiryat,Kittim,Kurda,Lapethos,Larna,Lepqis,Lepriptza,Liksos,Lod,Luv,Malaka,Malet,Marat,Megido,Melitta,Merdin,Metsada,Mishmarot,Mitzrayim,Moab,Mopsos,Motye,Mukish,Nampigi,Nampigu,Natzrat,Nimrud,Nineveh,Nob,Nuhadra,Oea,Ofir,Oyat,Phineka,Phoenicus,Pleshet,Qart-Tubah Sarepta,Qatna,Rabat Amon,Rakkath,Ramat Aviv,Ramitha,Ramta,Rehovot,Reshef,Rushadir,Rushakad,Samrin,Sefarad,Sehyon,Sepat,Sexi,Sharon,Shechem,Shefelat,Shfanim,Shiloh,Shmaya,Shomron,Sidon,Sinay,Sis,Solki,Sur,Suria,Tabetu,Tadmur,Tarshish,Tartus,Teberya,Tefessedt,Tekoa,Teyman,Tinga,Tipasa,Tsabratan,Tur Abdin,Tzarfat,Tziyon,Tzor,Ugarit,Unubaal,Ureshlem,Urhay,Urushalim,Vaga,Yaffa,Yamhad,Yam hamelach,Yam Kineret,Yamutbal,Yathrib,Yaudi,Yavne,Yehuda,Yerushalayim,Yev,Yevus,Yizreel,Yurdnan,Zarefat,Zeboim,Zeurta,Zeytim,Zikhron,Zmurna", + }, + ]; + } +} + +window.Names = new NamesGenerator(); diff --git a/src/modules/ocean-layers.ts b/src/modules/ocean-layers.ts new file mode 100644 index 00000000..a18b844a --- /dev/null +++ b/src/modules/ocean-layers.ts @@ -0,0 +1,136 @@ +import type { Selection } from "d3"; +import { curveBasisClosed, line } from "d3"; +import { clipPoly, P, rn, round } from "../utils"; + +declare global { + var OceanLayers: typeof OceanModule.prototype.draw; +} +class OceanModule { + private cells: any; + private vertices: any; + private pointsN: any; + private used: any; + private lineGen = line().curve(curveBasisClosed); + private oceanLayers: Selection; + + constructor(oceanLayers: Selection) { + this.oceanLayers = oceanLayers; + } + + randomizeOutline() { + const limits = []; + let odd = 0.2; + for (let l = -9; l < 0; l++) { + if (P(odd)) { + odd = 0.2; + limits.push(l); + } else { + odd *= 2; + } + } + return limits; + } + + // connect vertices to chain + connectVertices(start: number, t: number) { + const chain = []; // vertices chain to form a path + for ( + let i = 0, current = start; + i === 0 || (current !== start && i < 10000); + i++ + ) { + const prev = chain[chain.length - 1]; // previous vertex in chain + chain.push(current); // add current vertex to sequence + const c = this.vertices.c[current]; // cells adjacent to vertex + c.filter((c: number) => this.cells.t[c] === t).forEach((c: number) => { + this.used[c] = 1; + }); + const v = this.vertices.v[current]; // neighboring vertices + const c0 = !this.cells.t[c[0]] || this.cells.t[c[0]] === t - 1; + const c1 = !this.cells.t[c[1]] || this.cells.t[c[1]] === t - 1; + const c2 = !this.cells.t[c[2]] || this.cells.t[c[2]] === t - 1; + if (v[0] !== undefined && v[0] !== prev && c0 !== c1) current = v[0]; + else if (v[1] !== undefined && v[1] !== prev && c1 !== c2) current = v[1]; + else if (v[2] !== undefined && v[2] !== prev && c0 !== c2) current = v[2]; + if (current === chain[chain.length - 1]) { + ERROR && console.error("Next vertex is not found"); + break; + } + } + chain.push(chain[0]); // push first vertex as the last one + return chain; + } + + // find eligible cell vertex to start path detection + findStart(i: number, t: number) { + if (this.cells.b[i]) + return this.cells.v[i].find((v: number) => + this.vertices.c[v].some((c: number) => c >= this.pointsN), + ); // map border cell + return this.cells.v[i][ + this.cells.c[i].findIndex( + (c: number) => this.cells.t[c] < t || !this.cells.t[c], + ) + ]; + } + + draw() { + const outline = this.oceanLayers.attr("layers"); + if (outline === "none") return; + TIME && console.time("drawOceanLayers"); + this.cells = grid.cells; + this.pointsN = grid.cells.i.length; + this.vertices = grid.vertices; + const limits = + outline === "random" + ? this.randomizeOutline() + : outline.split(",").map((s: string) => +s); + + const chains: [number, any[]][] = []; + const opacity = rn(0.4 / limits.length, 2); + this.used = new Uint8Array(this.pointsN); // to detect already passed cells + + for (const i of this.cells.i) { + const t = this.cells.t[i]; + if (t > 0) continue; + if (this.used[i] || !limits.includes(t)) continue; + const start = this.findStart(i, t); + if (!start) continue; + this.used[i] = 1; + const chain = this.connectVertices(start, t); // vertices chain to form a path + if (chain.length < 4) continue; + const relax = 1 + t * -2; // select only n-th point + const relaxed = chain.filter( + (v, i) => + !(i % relax) || + this.vertices.c[v].some((c: number) => c >= this.pointsN), + ); + if (relaxed.length < 4) continue; + + const points = clipPoly( + relaxed.map((v) => this.vertices.p[v]), + graphWidth, + graphHeight, + 1, + ); + chains.push([t, points]); + } + + for (const t of limits) { + const layer = chains.filter((c: [number, any[]]) => c[0] === t); + const path = layer + .map((c: [number, any[]]) => round(this.lineGen(c[1]) || "")) + .join(""); + if (path) + this.oceanLayers + .append("path") + .attr("d", path) + .attr("fill", "#ecf2f9") + .attr("fill-opacity", opacity); + } + + TIME && console.timeEnd("drawOceanLayers"); + } +} + +window.OceanLayers = () => new OceanModule(oceanLayers).draw(); diff --git a/src/modules/provinces-generator.ts b/src/modules/provinces-generator.ts new file mode 100644 index 00000000..68d46f33 --- /dev/null +++ b/src/modules/provinces-generator.ts @@ -0,0 +1,393 @@ +import Alea from "alea"; +import { max } from "d3"; +import { + byId, + gauss, + generateSeed, + getMixedColor, + getPolesOfInaccessibility, + P, + rand, + rw, +} from "../utils"; + +declare global { + var Provinces: ProvinceModule; +} + +export interface Province { + i: number; + removed?: boolean; + state: number; + lock?: boolean; + center: number; + burg: number; + name: string; + formName: string; + fullName: string; + color: string; + coa: any; + pole?: [number, number]; +} + +class ProvinceModule { + forms: Record> = { + Monarchy: { + County: 22, + Earldom: 6, + Shire: 2, + Landgrave: 2, + Margrave: 2, + Barony: 2, + Captaincy: 1, + Seneschalty: 1, + }, + Republic: { + Province: 6, + Department: 2, + Governorate: 2, + District: 1, + Canton: 1, + Prefecture: 1, + }, + Theocracy: { Parish: 3, Deanery: 1 }, + Union: { + Province: 1, + State: 1, + Canton: 1, + Republic: 1, + County: 1, + Council: 1, + }, + Anarchy: { Council: 1, Commune: 1, Community: 1, Tribe: 1 }, + Wild: { + Territory: 10, + Land: 5, + Region: 2, + Tribe: 1, + Clan: 1, + Dependency: 1, + Area: 1, + }, + }; + + generate(regenerate = false, regenerateLockedStates = false) { + TIME && console.time("generateProvinces"); + const localSeed = regenerate ? generateSeed() : seed; + Math.random = Alea(localSeed); + + const { cells, states, burgs } = pack; + const provinces: Province[] = [0 as unknown as Province]; // 0 index is reserved for "no province" + const provinceIds = new Uint16Array(cells.i.length); + + const isProvinceLocked = (province: Province) => + province.lock || + (!regenerateLockedStates && states[province.state]?.lock); + const isProvinceCellLocked = (cell: number) => + provinceIds[cell] && isProvinceLocked(provinces[provinceIds[cell]]); + + if (regenerate) { + pack.provinces.forEach((province) => { + if (!province.i || province.removed || !isProvinceLocked(province)) + return; + + const newId = provinces.length; + for (const i of cells.i) { + if (cells.province[i] === province.i) provinceIds[i] = newId; + } + + province.i = newId; + provinces.push(province); + }); + } + + const provincesRatio = (byId("provincesRatio") as HTMLInputElement) + .valueAsNumber; + const maxGrowth = + provincesRatio === 100 + ? 1000 + : gauss(20, 5, 5, 100) * provincesRatio ** 0.5; // max growth + + // generate provinces for selected burgs + states.forEach((s) => { + s.provinces = []; + if (!s.i || s.removed) return; + if (provinces.length) + s.provinces = provinces.filter((p) => p.state === s.i).map((p) => p.i); // locked provinces ids + if (s.lock && !regenerateLockedStates) return; // don't regenerate provinces of a locked state + + const stateBurgs = burgs + .filter((b) => b.state === s.i && !b.removed && !provinceIds[b.cell]) // burgs in this state without province assigned + .sort( + (a, b) => b.population! * gauss(1, 0.2, 0.5, 1.5, 3) - a.population!, + ) // biggest population first + .sort((a, b) => b.capital! - a.capital!); // capitals first + if (stateBurgs.length < 2) return; // at least 2 provinces are required + + const provincesNumber = Math.max( + Math.ceil((stateBurgs.length * provincesRatio) / 100), + 2, + ); + const form = Object.assign({}, this.forms[s.form!]); + + for (let i = 0; i < provincesNumber; i++) { + const provinceId = provinces.length; + const center = stateBurgs[i].cell; + const burg = stateBurgs[i]; + const c = stateBurgs[i].culture!; + const nameByBurg = P(0.5); + const name = nameByBurg + ? stateBurgs[i].name! + : Names.getState(Names.getCultureShort(c), c); + const formName = rw(form); + form[formName] += 10; + const fullName = `${name} ${formName}`; + const color = getMixedColor(s.color!); + const kinship = nameByBurg ? 0.8 : 0.4; + const type = Burgs.getType(center, burg.port); + const coa = COA.generate(stateBurgs[i].coa, kinship, null, type); + coa.shield = COA.getShield(c, s.i); + + s.provinces.push(provinceId); + provinces.push({ + i: provinceId, + state: s.i, + center, + burg: burg.i!, + name, + formName, + fullName, + color, + coa, + }); + } + }); + + // expand generated provinces + const queue = new FlatQueue(); + const cost: number[] = []; + + provinces.forEach((p) => { + if (!p.i || p.removed || isProvinceLocked(p)) return; + provinceIds[p.center] = p.i; + queue.push({ e: p.center, province: p.i, state: p.state, p: 0 }, 0); + cost[p.center] = 1; + }); + + while (queue.length) { + const { e, p, province, state } = queue.pop(); + + cells.c[e].forEach((e) => { + if (isProvinceCellLocked(e)) return; // do not overwrite cell of locked provinces + + const land = cells.h[e] >= 20; + if (!land && !cells.t[e]) return; // cannot pass deep ocean + if (land && cells.state[e] !== state) return; + const evevation = + cells.h[e] >= 70 + ? 100 + : cells.h[e] >= 50 + ? 30 + : cells.h[e] >= 20 + ? 10 + : 100; + const totalCost = p + evevation; + + if (totalCost > maxGrowth) return; + if (!cost[e] || totalCost < cost[e]) { + if (land) provinceIds[e] = province; // assign province to a cell + cost[e] = totalCost; + queue.push({ e, province, state, p: totalCost }, totalCost); + } + }); + } + + // justify provinces shapes a bit + for (const i of cells.i) { + if (cells.burg[i]) continue; // do not overwrite burgs + if (isProvinceCellLocked(i)) continue; // do not overwrite cell of locked provinces + + const neibs = cells.c[i] + .filter( + (c) => cells.state[c] === cells.state[i] && !isProvinceCellLocked(c), + ) + .map((c) => provinceIds[c]); + const adversaries = neibs.filter((c) => c !== provinceIds[i]); + if (adversaries.length < 2) continue; + + const buddies = neibs.filter((c) => c === provinceIds[i]).length; + if (buddies > 2) continue; + + const competitors = adversaries.map((p) => + adversaries.reduce((s, v) => (v === p ? s + 1 : s), 0), + ); + const maxBuddies = max(competitors) as number; + if (buddies >= maxBuddies) continue; + + provinceIds[i] = adversaries[competitors.indexOf(maxBuddies)]; + } + + // add "wild" provinces if some cells don't have a province assigned + const noProvince = Array.from(cells.i).filter( + (i) => cells.state[i] && !provinceIds[i], + ); // cells without province assigned + states.forEach((s) => { + if (!s.i || s.removed) return; + if (s.lock && !regenerateLockedStates) return; + if (!s.provinces?.length) return; + + const coreProvinceNames = s.provinces.map((p) => provinces[p]?.name); + const colonyNamePool = [s.name, ...coreProvinceNames].filter( + (name) => name && !/new/i.test(name), + ); + const getColonyName = () => { + if (colonyNamePool.length < 1) return null; + + const index = rand(colonyNamePool.length - 1); + const spliced = colonyNamePool.splice(index, 1); + return spliced[0] ? `New ${spliced[0]}` : null; + }; + + let stateNoProvince = noProvince.filter( + (i) => cells.state[i] === s.i && !provinceIds[i], + ); + while (stateNoProvince.length) { + // add new province + const provinceId = provinces.length; + const burgCell = stateNoProvince.find((i) => cells.burg[i]); + const center = burgCell ? burgCell : stateNoProvince[0]; + const burg = burgCell ? cells.burg[burgCell] : 0; + provinceIds[center] = provinceId; + + // expand province + const cost: number[] = []; + cost[center] = 1; + queue.push({ e: center, p: 0 }, 0); + while (queue.length) { + const { e, p } = queue.pop(); + + cells.c[e].forEach((nextCellId) => { + if (provinceIds[nextCellId]) return; + const land = cells.h[nextCellId] >= 20; + if (cells.state[nextCellId] && cells.state[nextCellId] !== s.i) + return; + const ter = land + ? cells.state[nextCellId] === s.i + ? 3 + : 20 + : cells.t[nextCellId] + ? 10 + : 30; + const totalCost = p + ter; + + if (totalCost > maxGrowth) return; + if (!cost[nextCellId] || totalCost < cost[nextCellId]) { + if (land && cells.state[nextCellId] === s.i) + provinceIds[nextCellId] = provinceId; // assign province to a cell + cost[nextCellId] = totalCost; + queue.push({ e: nextCellId, p: totalCost }, totalCost); + } + }); + } + + // generate "wild" province name + const c = cells.culture[center]; + const f = pack.features[cells.f[center]]; + const color = getMixedColor(s.color!); + + const provCells = stateNoProvince.filter( + (i) => provinceIds[i] === provinceId, + ); + const singleIsle = + provCells.length === f.cells && + !provCells.find((i) => cells.f[i] !== f.i); + const isleGroup = + !singleIsle && + !provCells.find((i) => pack.features[cells.f[i]].group !== "isle"); + const colony = + !singleIsle && !isleGroup && P(0.5) && !isPassable(s.center, center); + + const name = (() => { + const colonyName = colony && P(0.8) && getColonyName(); + if (colonyName) return colonyName; + if (burgCell && P(0.5)) return burgs[burg].name; + return Names.getState(Names.getCultureShort(c), c); + })(); + + const formName = (() => { + if (singleIsle) return "Island"; + if (isleGroup) return "Islands"; + if (colony) return "Colony"; + return rw(this.forms["Wild"]); + })(); + + const fullName = `${name} ${formName}`; + + const dominion = colony + ? P(0.95) + : singleIsle || isleGroup + ? P(0.7) + : P(0.3); + const kinship = dominion ? 0 : 0.4; + const type = Burgs.getType(center, burgs[burg]?.port); + const coa = COA.generate(s.coa, kinship, dominion, type); + coa.shield = COA.getShield(c, s.i); + + provinces.push({ + i: provinceId, + state: s.i, + center, + burg, + name: name!, + formName, + fullName, + color, + coa, + }); + s.provinces.push(provinceId); + + // check if there is a land way within the same state between two cells + function isPassable(from: number, to: number) { + if (cells.f[from] !== cells.f[to]) return false; // on different islands + const passableQueue = [from], + used = new Uint8Array(cells.i.length), + state = cells.state[from]; + while (passableQueue.length) { + const current = passableQueue.pop() as number; + if (current === to) return true; // way is found + cells.c[current].forEach((c) => { + if (used[c] || cells.h[c] < 20 || cells.state[c] !== state) + return; + passableQueue.push(c); + used[c] = 1; + }); + } + return false; // way is not found + } + + // re-check + stateNoProvince = noProvince.filter( + (i) => cells.state[i] === s.i && !provinceIds[i], + ); + } + }); + + cells.province = provinceIds; + pack.provinces = provinces; + + TIME && console.timeEnd("generateProvinces"); + } + + // calculate pole of inaccessibility for each province + getPoles() { + const getType = (cellId: number) => pack.cells.province[cellId]; + const poles = getPolesOfInaccessibility(pack, getType); + + pack.provinces.forEach((province) => { + if (!province.i || province.removed) return; + province.pole = poles[province.i] || [0, 0]; + }); + } +} + +window.Provinces = new ProvinceModule(); diff --git a/src/modules/religions-generator.ts b/src/modules/religions-generator.ts new file mode 100644 index 00000000..30397afd --- /dev/null +++ b/src/modules/religions-generator.ts @@ -0,0 +1,1168 @@ +import { quadtree } from "d3"; +import { + abbreviate, + byId, + each, + gauss, + getAdjective, + getMixedColor, + getRandomColor, + isWater, + ra, + rand, + rw, + trimVowels, +} from "../utils"; + +declare global { + var Religions: ReligionsModule; +} + +interface ReligionBase { + type: "Folk" | "Organized" | "Cult" | "Heresy"; + form: string; + culture: number; + center: number; +} + +interface NamedReligion extends ReligionBase { + name: string; + deity: string | null; + expansion: string; + expansionism: number; + color: string; +} + +export interface Religion extends NamedReligion { + i: number; + code?: string; + origins?: number[] | null; + lock?: boolean; + removed?: boolean; + cells?: number; + area?: number; + rural?: number; + urban?: number; +} + +// name generation approach and relative chance to be selected +const approach: Record = { + Number: 1, + Being: 3, + Adjective: 5, + "Color + Animal": 5, + "Adjective + Animal": 5, + "Adjective + Being": 5, + "Adjective + Genitive": 1, + "Color + Being": 3, + "Color + Genitive": 3, + "Being + of + Genitive": 2, + "Being + of the + Genitive": 1, + "Animal + of + Genitive": 1, + "Adjective + Being + of + Genitive": 2, + "Adjective + Animal + of + Genitive": 2, +}; + +// turn weighted array into simple array +const approaches: string[] = []; +for (const a in approach) { + for (let j = 0; j < approach[a]; j++) { + approaches.push(a); + } +} + +const base = { + number: [ + "One", + "Two", + "Three", + "Four", + "Five", + "Six", + "Seven", + "Eight", + "Nine", + "Ten", + "Eleven", + "Twelve", + ], + being: [ + "Ancestor", + "Ancient", + "Avatar", + "Brother", + "Champion", + "Chief", + "Council", + "Creator", + "Deity", + "Divine One", + "Elder", + "Enlightened Being", + "Father", + "Forebear", + "Forefather", + "Giver", + "God", + "Goddess", + "Guardian", + "Guide", + "Hierarch", + "Lady", + "Lord", + "Maker", + "Master", + "Mother", + "Numen", + "Oracle", + "Overlord", + "Protector", + "Reaper", + "Ruler", + "Sage", + "Seer", + "Sister", + "Spirit", + "Supreme Being", + "Transcendent", + "Virgin", + ], + animal: [ + "Antelope", + "Ape", + "Badger", + "Basilisk", + "Bear", + "Beaver", + "Bison", + "Boar", + "Buffalo", + "Camel", + "Cat", + "Centaur", + "Cerberus", + "Chimera", + "Cobra", + "Cockatrice", + "Crane", + "Crocodile", + "Crow", + "Cyclope", + "Deer", + "Dog", + "Direwolf", + "Drake", + "Dragon", + "Eagle", + "Elephant", + "Elk", + "Falcon", + "Fox", + "Goat", + "Goose", + "Gorgon", + "Gryphon", + "Hare", + "Hawk", + "Heron", + "Hippogriff", + "Horse", + "Hound", + "Hyena", + "Ibis", + "Jackal", + "Jaguar", + "Kitsune", + "Kraken", + "Lark", + "Leopard", + "Lion", + "Manticore", + "Mantis", + "Marten", + "Minotaur", + "Moose", + "Mule", + "Narwhal", + "Owl", + "Ox", + "Panther", + "Pegasus", + "Phoenix", + "Python", + "Rat", + "Raven", + "Roc", + "Rook", + "Scorpion", + "Serpent", + "Shark", + "Sheep", + "Snake", + "Sphinx", + "Spider", + "Swan", + "Tiger", + "Turtle", + "Unicorn", + "Viper", + "Vulture", + "Walrus", + "Wolf", + "Wolverine", + "Worm", + "Wyvern", + "Yeti", + ], + adjective: [ + "Aggressive", + "Almighty", + "Ancient", + "Beautiful", + "Benevolent", + "Big", + "Blind", + "Blond", + "Bloody", + "Brave", + "Broken", + "Brutal", + "Burning", + "Calm", + "Celestial", + "Cheerful", + "Crazy", + "Cruel", + "Dead", + "Deadly", + "Devastating", + "Distant", + "Disturbing", + "Divine", + "Dying", + "Eternal", + "Ethernal", + "Empyreal", + "Enigmatic", + "Enlightened", + "Evil", + "Explicit", + "Fair", + "Far", + "Fat", + "Fatal", + "Favorable", + "Flying", + "Friendly", + "Frozen", + "Giant", + "Good", + "Grateful", + "Great", + "Happy", + "High", + "Holy", + "Honest", + "Huge", + "Hungry", + "Illustrious", + "Immutable", + "Ineffable", + "Infallible", + "Inherent", + "Last", + "Latter", + "Lost", + "Loud", + "Lucky", + "Mad", + "Magical", + "Main", + "Major", + "Marine", + "Mythical", + "Mystical", + "Naval", + "New", + "Noble", + "Old", + "Otherworldly", + "Patient", + "Peaceful", + "Pregnant", + "Prime", + "Proud", + "Pure", + "Radiant", + "Resplendent", + "Sacred", + "Sacrosanct", + "Sad", + "Scary", + "Secret", + "Selected", + "Serene", + "Severe", + "Silent", + "Sleeping", + "Slumbering", + "Sovereign", + "Strong", + "Sunny", + "Superior", + "Supernatural", + "Sustainable", + "Transcendent", + "Transcendental", + "Troubled", + "Unearthly", + "Unfathomable", + "Unhappy", + "Unknown", + "Unseen", + "Waking", + "Wild", + "Wise", + "Worried", + "Young", + ], + genitive: [ + "Cold", + "Day", + "Death", + "Doom", + "Fate", + "Fire", + "Fog", + "Frost", + "Gates", + "Heaven", + "Home", + "Ice", + "Justice", + "Life", + "Light", + "Lightning", + "Love", + "Nature", + "Night", + "Pain", + "Snow", + "Springs", + "Summer", + "Thunder", + "Time", + "Victory", + "War", + "Winter", + ], + theGenitive: [ + "Abyss", + "Blood", + "Dawn", + "Earth", + "East", + "Eclipse", + "Fall", + "Harvest", + "Moon", + "North", + "Peak", + "Rainbow", + "Sea", + "Sky", + "South", + "Stars", + "Storm", + "Sun", + "Tree", + "Underworld", + "West", + "Wild", + "Word", + "World", + ], + color: [ + "Amber", + "Black", + "Blue", + "Bright", + "Bronze", + "Brown", + "Coral", + "Crimson", + "Dark", + "Emerald", + "Golden", + "Green", + "Grey", + "Indigo", + "Lavender", + "Light", + "Magenta", + "Maroon", + "Orange", + "Pink", + "Plum", + "Purple", + "Red", + "Ruby", + "Sapphire", + "Teal", + "Turquoise", + "White", + "Yellow", + ], +}; + +const forms: Record> = { + Folk: { + Shamanism: 4, + Animism: 4, + Polytheism: 4, + "Ancestor Worship": 2, + "Nature Worship": 1, + Totemism: 1, + }, + Organized: { + Polytheism: 7, + Monotheism: 7, + Dualism: 3, + Pantheism: 2, + "Non-theism": 2, + }, + Cult: { + Cult: 5, + "Dark Cult": 5, + Sect: 1, + }, + Heresy: { + Heresy: 1, + }, +}; + +const namingMethods: Record> = { + Folk: { + "Culture + type": 1, + }, + + Organized: { + "Random + type": 3, + "Random + ism": 1, + "Supreme + ism": 5, + "Faith of + Supreme": 5, + "Place + ism": 1, + "Culture + ism": 2, + "Place + ian + type": 6, + "Culture + type": 4, + }, + + Cult: { + "Burg + ian + type": 2, + "Random + ian + type": 1, + "Type + of the + meaning": 2, + }, + + Heresy: { + "Burg + ian + type": 3, + "Random + ism": 3, + "Random + ian + type": 2, + "Type + of the + meaning": 1, + }, +}; + +const types: Record> = { + Shamanism: { Beliefs: 3, Shamanism: 2, Druidism: 1, Spirits: 1 }, + Animism: { Spirits: 3, Beliefs: 1 }, + Polytheism: { Deities: 3, Faith: 1, Gods: 1, Pantheon: 1 }, + "Ancestor Worship": { Beliefs: 1, Forefathers: 2, Ancestors: 2 }, + "Nature Worship": { Beliefs: 3, Druids: 1 }, + Totemism: { Beliefs: 2, Totems: 2, Idols: 1 }, + + Monotheism: { Religion: 2, Church: 3, Faith: 1 }, + Dualism: { Religion: 3, Faith: 1, Cult: 1 }, + Pantheism: { Religion: 1, Faith: 1 }, + "Non-theism": { Beliefs: 3, Spirits: 1 }, + + Cult: { Cult: 4, Sect: 2, Arcanum: 1, Order: 1, Worship: 1 }, + "Dark Cult": { + Cult: 2, + Blasphemy: 1, + Circle: 1, + Coven: 1, + Idols: 1, + Occultism: 1, + }, + Sect: { Sect: 3, Society: 1 }, + + Heresy: { + Heresy: 3, + Sect: 2, + Apostates: 1, + Brotherhood: 1, + Circle: 1, + Dissent: 1, + Dissenters: 1, + Iconoclasm: 1, + Schism: 1, + Society: 1, + }, +}; + +const expansionismMap: Record number> = { + Folk: () => 0, + Organized: () => gauss(5, 3, 0, 10, 1), + Cult: () => gauss(0.5, 0.5, 0, 5, 1), + Heresy: () => gauss(1, 0.5, 0, 5, 1), +}; + +class ReligionsModule { + generate() { + TIME && console.time("generateReligions"); + const lockedReligions = + pack.religions?.filter((r) => r.i && r.lock && !r.removed) || []; + + const folkReligions = this.generateFolkReligions(); + const organizedReligions = this.generateOrganizedReligions( + +religionsNumber.value, + lockedReligions, + ); + + const namedReligions = this.specifyReligions([ + ...folkReligions, + ...organizedReligions, + ]); + const indexedReligions = this.combineReligions( + namedReligions, + lockedReligions, + ); + const religionIds = this.expandReligions(indexedReligions); + const religions = this.defineOrigins(religionIds, indexedReligions); + + pack.religions = religions; + pack.cells.religion = religionIds; + + this.checkCenters(); + + TIME && console.timeEnd("generateReligions"); + } + + private generateFolkReligions(): ReligionBase[] { + return pack.cultures + .filter((c) => c.i && !c.removed) + .map((culture) => ({ + type: "Folk" as const, + form: rw(forms.Folk), + culture: culture.i, + center: culture.center!, + })); + } + + private generateOrganizedReligions( + desiredReligionNumber: number, + lockedReligions: Religion[], + ): ReligionBase[] { + const cells = pack.cells; + const lockedReligionCount = + lockedReligions.filter(({ type }) => type !== "Folk").length || 0; + const requiredReligionsNumber = desiredReligionNumber - lockedReligionCount; + if (requiredReligionsNumber < 1) return []; + + const candidateCells = getCandidateCells(); + const religionCores = placeReligions(); + + const cultsCount = Math.floor((rand(1, 4) / 10) * religionCores.length); // 10-40% + const heresiesCount = Math.floor((rand(0, 3) / 10) * religionCores.length); // 0-30% + const organizedCount = religionCores.length - cultsCount - heresiesCount; + + const getType = (index: number): "Organized" | "Cult" | "Heresy" => { + if (index < organizedCount) return "Organized"; + if (index < organizedCount + cultsCount) return "Cult"; + return "Heresy"; + }; + + return religionCores.map((cellId, index) => { + const type = getType(index); + const form = rw(forms[type]); + const cultureId = cells.culture[cellId]; + + return { type, form, culture: cultureId, center: cellId }; + }); + + function placeReligions(): number[] { + const religionCells: number[] = []; + const religionsTree = quadtree<[number, number]>(); + + // pre-populate with locked centers + for (const { center } of lockedReligions) { + religionsTree.add(cells.p[center]); + } + + // min distance between religion inceptions + const spacing = (graphWidth + graphHeight) / 2 / desiredReligionNumber; + + for (const cellId of candidateCells) { + const [x, y] = cells.p[cellId]; + + if (religionsTree.find(x, y, spacing) === undefined) { + religionCells.push(cellId); + religionsTree.add([x, y]); + + if (religionCells.length === requiredReligionsNumber) + return religionCells; + } + } + + WARN && + console.warn( + `Placed only ${religionCells.length} of ${requiredReligionsNumber} religions`, + ); + return religionCells; + } + + function getCandidateCells(): number[] { + const validBurgs = pack.burgs.filter((b) => b.i && !b.removed); + + if (validBurgs.length >= requiredReligionsNumber) + return validBurgs + .sort((a, b) => b.population! - a.population!) + .map((burg) => burg.cell); + return cells.i + .filter((i) => cells.s[i] > 2) + .sort((a, b) => cells.s[b] - cells.s[a]); + } + } + + private specifyReligions(newReligions: ReligionBase[]): NamedReligion[] { + const { cells, cultures } = pack; + + const rawReligions = newReligions.map( + ({ type, form, culture: cultureId, center }) => { + const supreme = this.getDeityName(cultureId); + const deity: string | null = + form === "Non-theism" || form === "Animism" + ? null + : (supreme ?? null); + + const stateId = cells.state[center]; + + let [name, expansion] = this.generateReligionName( + type, + form, + supreme!, + center, + ); + if (expansion === "state" && !stateId) expansion = "global"; + + const expansionism = expansionismMap[type](); + const color = getReligionColor(cultures[cultureId], type); + + return { + name, + type, + form, + culture: cultureId, + center, + deity, + expansion, + expansionism, + color, + }; + }, + ); + + return rawReligions; + + function getReligionColor( + culture: (typeof pack.cultures)[number], + type: string, + ): string { + if (!culture.i) return getRandomColor(); + + if (type === "Folk") return culture.color!; + if (type === "Heresy") return getMixedColor(culture.color!, 0.35, 0.2); + if (type === "Cult") return getMixedColor(culture.color!, 0.5, 0); + return getMixedColor(culture.color!, 0.25, 0.4); + } + } + + // indexes, conditionally renames, and abbreviates religions + private combineReligions( + namedReligions: NamedReligion[], + lockedReligions: Religion[], + ): Religion[] { + const indexedReligions: Religion[] = [ + { name: "No religion", i: 0 } as Religion, + ]; + + const { lockedReligionQueue, highestLockedIndex, codes, numberLockedFolk } = + parseLockedReligions(); + const maxIndex = Math.max( + highestLockedIndex, + namedReligions.length + lockedReligions.length + 1 - numberLockedFolk, + ); + + for ( + let index = 1, progress = 0; + index < maxIndex; + index = indexedReligions.length + ) { + // place locked religion back at its old index + if (index === lockedReligionQueue[0]?.i) { + const nextReligion = lockedReligionQueue.shift()!; + indexedReligions.push(nextReligion); + continue; + } + + // slot the new religions + if (progress < namedReligions.length) { + const nextReligion = namedReligions[progress]; + progress++; + + if ( + nextReligion.type === "Folk" && + lockedReligions.some( + ({ type, culture }) => + type === "Folk" && culture === nextReligion.culture, + ) + ) + continue; // when there is a locked Folk religion for this culture discard duplicate + + const newName = renameOld(nextReligion); + const code = abbreviate(newName, codes); + codes.push(code); + indexedReligions.push({ + ...nextReligion, + i: index, + name: newName, + code, + }); + continue; + } + + indexedReligions.push({ + i: index, + type: "Folk", + culture: 0, + name: "Removed religion", + removed: true, + } as Religion); + } + return indexedReligions; + + function parseLockedReligions() { + // copy and sort the locked religions list + const lockedReligionQueue = lockedReligions + .map((religion) => { + // and filter their origins to locked religions + let newOrigin = religion.origins!.filter((n) => + lockedReligions.some(({ i: index }) => index === n), + ); + if (newOrigin.length === 0) newOrigin = [0]; + return { ...religion, origins: newOrigin }; + }) + .sort((a, b) => a.i - b.i); + + const highestLockedIndex = Math.max( + ...lockedReligions.map((r) => r.i), + 0, + ); + const codes = + lockedReligions.length > 0 ? lockedReligions.map((r) => r.code!) : []; + const numberLockedFolk = lockedReligions.filter( + ({ type }) => type === "Folk", + ).length; + + return { + lockedReligionQueue, + highestLockedIndex, + codes, + numberLockedFolk, + }; + } + + // prepend 'Old' to names of folk religions which have organized competitors + function renameOld({ + name, + type, + culture: cultureId, + }: NamedReligion): string { + if (type !== "Folk") return name; + + const haveOrganized = + namedReligions.some( + ({ type, culture, expansion }) => + culture === cultureId && + type === "Organized" && + expansion === "culture", + ) || + lockedReligions.some( + ({ type, culture, expansion }) => + culture === cultureId && + type === "Organized" && + expansion === "culture", + ); + if (haveOrganized && name.slice(0, 3) !== "Old") return `Old ${name}`; + return name; + } + } + + // finally generate and stores origins trees + private defineOrigins( + religionIds: Uint16Array, + indexedReligions: Religion[], + ): Religion[] { + const religionOriginsParamsMap: Record< + string, + { clusterSize: number; maxReligions: number } + > = { + Organized: { clusterSize: 100, maxReligions: 2 }, + Cult: { clusterSize: 50, maxReligions: 3 }, + Heresy: { clusterSize: 50, maxReligions: 4 }, + }; + + const origins = indexedReligions.map( + ({ i, type, culture: cultureId, expansion, center }) => { + if (i === 0) return null; // no religion + if (type === "Folk") return [0]; // folk religions originate from its parent culture only + + const folkReligion = indexedReligions.find( + ({ culture, type }) => type === "Folk" && culture === cultureId, + ); + const isFolkBased = + folkReligion && + cultureId && + expansion === "culture" && + each(2)(center); + if (isFolkBased) return [folkReligion.i]; + + const { clusterSize, maxReligions } = religionOriginsParamsMap[type]; + const fallbackOrigin = folkReligion?.i || 0; + return this.getReligionsInRadius( + pack.cells.c, + center, + religionIds, + i, + clusterSize, + maxReligions, + fallbackOrigin, + ); + }, + ); + + return indexedReligions.map((religion, index) => ({ + ...religion, + origins: origins[index], + })); + } + + private getReligionsInRadius( + neighbors: number[][], + center: number, + religionIds: Uint16Array, + religionId: number, + clusterSize: number, + maxReligions: number, + fallbackOrigin: number, + ): number[] { + const foundReligions = new Set(); + const queue = [center]; + const checked: Record = {}; + + for (let size = 0; queue.length && size < clusterSize; size++) { + const cellId = queue.shift()!; + checked[cellId] = true; + + for (const neibId of neighbors[cellId]) { + if (checked[neibId]) continue; + checked[neibId] = true; + + const neibReligion = religionIds[neibId]; + if (neibReligion && neibReligion < religionId) + foundReligions.add(neibReligion); + if (foundReligions.size >= maxReligions) return [...foundReligions]; + queue.push(neibId); + } + } + + return foundReligions.size ? [...foundReligions] : [fallbackOrigin]; + } + + // growth algorithm to assign cells to religions + private expandReligions(religions: Religion[]): Uint16Array { + const { cells } = pack; + const religionIds = this.spreadFolkReligions(religions); + + const queue = new FlatQueue(); + const cost: number[] = []; + + // limit cost for organized religions growth + const maxExpansionCost = + (cells.i.length / 20) * + (byId("growthRate") as HTMLInputElement).valueAsNumber; + + religions + .filter((r) => r.i && !r.lock && r.type !== "Folk" && !r.removed) + .forEach((r) => { + religionIds[r.center] = r.i; + queue.push({ e: r.center, p: 0, r: r.i, s: cells.state[r.center] }, 0); + cost[r.center] = 1; + }); + + const religionsMap = new Map(religions.map((r) => [r.i, r])); + + while (queue.length) { + const { e: cellId, p, r, s: state } = queue.pop(); + const religion = religionsMap.get(r)!; + const { culture, expansion, expansionism } = religion; + + cells.c[cellId].forEach((nextCell) => { + if (expansion === "culture" && culture !== cells.culture[nextCell]) + return; + if (expansion === "state" && state !== cells.state[nextCell]) return; + if (religionsMap.get(religionIds[nextCell])?.lock) return; + + const cultureCost = culture !== cells.culture[nextCell] ? 10 : 0; + const stateCost = state !== cells.state[nextCell] ? 10 : 0; + const passageCost = getPassageCost(cellId, nextCell); + + const cellCost = cultureCost + stateCost + passageCost; + const totalCost = p + 10 + cellCost / expansionism; + if (totalCost > maxExpansionCost) return; + + if (!cost[nextCell] || totalCost < cost[nextCell]) { + if (cells.culture[nextCell]) religionIds[nextCell] = r; // assign religion to cell + cost[nextCell] = totalCost; + + queue.push({ e: nextCell, p: totalCost, r, s: state }, totalCost); + } + }); + } + + return religionIds; + + function getPassageCost(cellId: number, nextCellId: number): number { + const route = Routes.getRoute(cellId, nextCellId); + if (isWater(cellId, pack)) return route ? 50 : 500; + + const biomePassageCost = biomesData.cost[cells.biome[nextCellId]]; + + if (route) { + if (route.group === "roads") return 1; + return biomePassageCost / 3; // trails and other routes + } + + return biomePassageCost; + } + } + + // folk religions initially get all cells of their culture, and locked religions are retained + private spreadFolkReligions(religions: Religion[]): Uint16Array { + const cells = pack.cells; + const hasPrior = cells.religion && true; + const religionIds = new Uint16Array(cells.i.length); + + const folkReligions = religions.filter( + (religion) => religion.type === "Folk" && !religion.removed, + ); + const cultureToReligionMap = new Map( + folkReligions.map(({ i, culture }) => [culture, i]), + ); + + for (const cellId of cells.i) { + const oldId = (hasPrior && cells.religion[cellId]) || 0; + if (oldId && religions[oldId]?.lock && !religions[oldId]?.removed) { + religionIds[cellId] = oldId; + continue; + } + const cultureId = cells.culture[cellId]; + religionIds[cellId] = cultureToReligionMap.get(cultureId) || 0; + } + + return religionIds; + } + + private checkCenters() { + const cells = pack.cells; + pack.religions.forEach((r) => { + if (!r.i) return; + // move religion center if it's not within religion area after expansion + if (cells.religion[r.center] === r.i) return; // in area + const firstCell = cells.i.find((i) => cells.religion[i] === r.i); + const cultureHome = pack.cultures[r.culture]?.center; + if (firstCell) + r.center = firstCell; // move center, otherwise it's an extinct religion + else if (r.type === "Folk" && cultureHome) r.center = cultureHome; // reset extinct culture centers + }); + } + + recalculate() { + const newReligionIds = this.expandReligions(pack.religions); + pack.cells.religion = newReligionIds; + + this.checkCenters(); + } + + add(center: number) { + const { cells, cultures, religions } = pack; + const religionId = cells.religion[center]; + const i = religions.length; + + const cultureId = cells.culture[center]; + const missingFolk = + cultureId !== 0 && + !religions.some( + ({ type, culture, removed }) => + type === "Folk" && culture === cultureId && !removed, + ); + const color = missingFolk + ? cultures[cultureId].color! + : getMixedColor(religions[religionId].color!, 0.3, 0); + + const type: "Folk" | "Organized" | "Cult" | "Heresy" = missingFolk + ? "Folk" + : religions[religionId].type === "Organized" + ? (rw({ Organized: 4, Cult: 1, Heresy: 2 }) as + | "Organized" + | "Cult" + | "Heresy") + : (rw({ Organized: 5, Cult: 2 }) as "Organized" | "Cult"); + const form = rw(forms[type]); + const deity = + type === "Heresy" + ? religions[religionId].deity + : form === "Non-theism" || form === "Animism" + ? null + : this.getDeityName(cultureId); + + const [name, expansion] = this.generateReligionName( + type, + form, + deity!, + center, + ); + + const formName = type === "Heresy" ? religions[religionId].form : form; + const code = abbreviate( + name, + religions.map((r) => r.code!), + ); + const influences = this.getReligionsInRadius( + cells.c, + center, + cells.religion as Uint16Array, + i, + 25, + 3, + 0, + ); + const origins = type === "Folk" ? [0] : influences; + + religions.push({ + i, + name, + color, + culture: cultureId, + type, + form: formName, + deity, + expansion, + expansionism: expansionismMap[type](), + center, + cells: 0, + area: 0, + rural: 0, + urban: 0, + origins, + code, + }); + cells.religion[center] = i; + } + + // get supreme deity name + getDeityName(culture: number): string | undefined { + if (culture === undefined) { + ERROR && console.error("Please define a culture"); + return; + } + const meaning = this.generateMeaning(); + const cultureName = Names.getCulture(culture); + return `${cultureName}, The ${meaning}`; + } + + private generateReligionName( + variety: string, + form: string, + deity: string, + center: number, + ): [string, string] { + const { cells, cultures, burgs, states } = pack; + + const random = () => Names.getCulture(cells.culture[center]); + const type = rw(types[form]); + const supreme = deity.split(/[ ,]+/)[0]; + const culture = cultures[cells.culture[center]].name; + + const place = (adj?: boolean): string => { + const burgId = cells.burg[center]; + const stateId = cells.state[center]; + + const base = burgId ? burgs[burgId].name! : states[stateId].name; + const name = trimVowels(base.split(/[ ,]+/)[0]); + return adj ? getAdjective(name) : name; + }; + + const m = rw(namingMethods[variety]); + if (m === "Random + type") return [`${random()} ${type}`, "global"]; + if (m === "Random + ism") return [`${trimVowels(random())}ism`, "global"]; + if (m === "Supreme + ism" && deity) + return [`${trimVowels(supreme)}ism`, "global"]; + if (m === "Faith of + Supreme" && deity) + return [ + `${ra(["Faith", "Way", "Path", "Word", "Witnesses"])} of ${supreme}`, + "global", + ]; + if (m === "Place + ism") return [`${place()}ism`, "state"]; + if (m === "Culture + ism") return [`${trimVowels(culture!)}ism`, "culture"]; + if (m === "Place + ian + type") return [`${place(true)} ${type}`, "state"]; + if (m === "Culture + type") return [`${culture} ${type}`, "culture"]; + if (m === "Burg + ian + type") return [`${place(true)} ${type}`, "global"]; + if (m === "Random + ian + type") + return [`${getAdjective(random())} ${type}`, "global"]; + if (m === "Type + of the + meaning") + return [`${type} of the ${this.generateMeaning()}`, "global"]; + return [`${trimVowels(random())}ism`, "global"]; // else + } + + private generateMeaning(): string { + const a = ra(approaches); // select generation approach + if (a === "Number") return ra(base.number); + if (a === "Being") return ra(base.being); + if (a === "Adjective") return ra(base.adjective); + if (a === "Color + Animal") return `${ra(base.color)} ${ra(base.animal)}`; + if (a === "Adjective + Animal") + return `${ra(base.adjective)} ${ra(base.animal)}`; + if (a === "Adjective + Being") + return `${ra(base.adjective)} ${ra(base.being)}`; + if (a === "Adjective + Genitive") + return `${ra(base.adjective)} ${ra(base.genitive)}`; + if (a === "Color + Being") return `${ra(base.color)} ${ra(base.being)}`; + if (a === "Color + Genitive") + return `${ra(base.color)} ${ra(base.genitive)}`; + if (a === "Being + of + Genitive") + return `${ra(base.being)} of ${ra(base.genitive)}`; + if (a === "Being + of the + Genitive") + return `${ra(base.being)} of the ${ra(base.theGenitive)}`; + if (a === "Animal + of + Genitive") + return `${ra(base.animal)} of ${ra(base.genitive)}`; + if (a === "Adjective + Being + of + Genitive") + return `${ra(base.adjective)} ${ra(base.being)} of ${ra(base.genitive)}`; + if (a === "Adjective + Animal + of + Genitive") + return `${ra(base.adjective)} ${ra(base.animal)} of ${ra(base.genitive)}`; + + ERROR && console.error("Unknown generation approach"); + return ra(base.being); + } +} + +window.Religions = new ReligionsModule(); diff --git a/public/modules/river-generator.js b/src/modules/river-generator.ts similarity index 53% rename from public/modules/river-generator.js rename to src/modules/river-generator.ts index 254e1af8..a953aa51 100644 --- a/public/modules/river-generator.js +++ b/src/modules/river-generator.ts @@ -1,66 +1,99 @@ -"use strict"; +import Alea from "alea"; +import { curveBasis, curveCatmullRom, line, mean, min, sum } from "d3"; +import { each, rn, round, rw } from "../utils"; -window.Rivers = (function () { - const generate = function (allowErosion = true) { +declare global { + var Rivers: RiverModule; +} + +export interface River { + i: number; // river id + source: number; // source cell index + mouth: number; // mouth cell index + parent: number; // parent river id + basin: number; // basin river id + length: number; // river length + discharge: number; // river discharge in m3/s + width: number; // mouth width in km + widthFactor: number; // width scaling factor + sourceWidth: number; // source width in km + name: string; // river name + type: string; // river type + cells: number[]; // cells forming the river path +} + +class RiverModule { + private FLUX_FACTOR = 500; + private MAX_FLUX_WIDTH = 1; + private LENGTH_FACTOR = 200; + private LENGTH_STEP_WIDTH = 1 / this.LENGTH_FACTOR; + private LENGTH_PROGRESSION = [1, 1, 2, 3, 5, 8, 13, 21, 34].map( + (n) => n / this.LENGTH_FACTOR, + ); + private lineGen = line().curve(curveBasis); + + riverTypes = { + main: { + big: { River: 1 }, + small: { Creek: 9, River: 3, Brook: 3, Stream: 1 }, + }, + fork: { + big: { Fork: 1 }, + small: { Branch: 1 }, + }, + }; + + smallLength: number | null = null; + + generate(allowErosion = true) { TIME && console.time("generateRivers"); - Math.random = aleaPRNG(seed); - const {cells, features} = pack; + Math.random = Alea(seed); + const { cells, features } = pack; - const riversData = {}; // rivers data - const riverParents = {}; + const riversData: { [riverId: number]: number[] } = {}; + const riverParents: { [key: number]: number } = {}; - const addCellToRiver = function (cell, river) { - if (!riversData[river]) riversData[river] = [cell]; - else riversData[river].push(cell); + const addCellToRiver = (cellId: number, riverId: number) => { + if (!riversData[riverId]) riversData[riverId] = [cellId]; + else riversData[riverId].push(cellId); }; - cells.fl = new Uint16Array(cells.i.length); // water flux array - cells.r = new Uint16Array(cells.i.length); // rivers array - cells.conf = new Uint8Array(cells.i.length); // confluences array - let riverNext = 1; // first river id is 1 - - const h = alterHeights(); - Lakes.detectCloseLakes(h); - resolveDepressions(h); - drainWater(); - defineRivers(); - - calculateConfluenceFlux(); - Lakes.cleanupLakeData(); - - if (allowErosion) { - cells.h = Uint8Array.from(h); // apply gradient - downcutRivers(); // downcut river beds - } - - TIME && console.timeEnd("generateRivers"); - - function drainWater() { + const drainWater = () => { const MIN_FLUX_TO_FORM_RIVER = 30; - const cellsNumberModifier = (pointsInput.dataset.cells / 10000) ** 0.25; + const cellsNumberModifier = + ((pointsInput.dataset.cells as any) / 10000) ** 0.25; const prec = grid.cells.prec; - const land = cells.i.filter(i => h[i] >= 20).sort((a, b) => h[b] - h[a]); + const land = cells.i + .filter((i: number) => h[i] >= 20) + .sort((a: number, b: number) => h[b] - h[a]); const lakeOutCells = Lakes.defineClimateData(h); - land.forEach(function (i) { + for (const i of land) { cells.fl[i] += prec[cells.g[i]] / cellsNumberModifier; // add flux from precipitation // create lake outlet if lake is not in deep depression and flux > evaporation const lakes = lakeOutCells[i] - ? features.filter(feature => i === feature.outCell && feature.flux > feature.evaporation) + ? features.filter( + (feature: any) => + i === feature.outCell && feature.flux > feature.evaporation, + ) : []; for (const lake of lakes) { - const lakeCell = cells.c[i].find(c => h[c] < 20 && cells.f[c] === lake.i); + const lakeCell = cells.c[i].find( + (c: number) => h[c] < 20 && cells.f[c] === lake.i, + )!; cells.fl[lakeCell] += Math.max(lake.flux - lake.evaporation, 0); // not evaporated lake water drains to outlet // allow chain lakes to retain identity if (cells.r[lakeCell] !== lake.river) { - const sameRiver = cells.c[lakeCell].some(c => cells.r[c] === lake.river); + const sameRiver = cells.c[lakeCell].some( + (c: number) => cells.r[c] === lake.river, + ); if (sameRiver) { - cells.r[lakeCell] = lake.river; - addCellToRiver(lakeCell, lake.river); + cells.r[lakeCell] = lake.river as number; + addCellToRiver(lakeCell, lake.river as number); } else { cells.r[lakeCell] = riverNext; addCellToRiver(lakeCell, riverNext); @@ -77,26 +110,32 @@ window.Rivers = (function () { for (const lake of lakes) { if (!Array.isArray(lake.inlets)) continue; for (const inlet of lake.inlets) { - riverParents[inlet] = outlet; + riverParents[inlet] = outlet as number; } } // near-border cell: pour water out of the screen - if (cells.b[i] && cells.r[i]) return addCellToRiver(-1, cells.r[i]); + if (cells.b[i] && cells.r[i]) { + addCellToRiver(-1, cells.r[i]); + continue; + } // downhill cell (make sure it's not in the source lake) let min = null; if (lakeOutCells[i]) { - const filtered = cells.c[i].filter(c => !lakes.map(lake => lake.i).includes(cells.f[c])); - min = filtered.sort((a, b) => h[a] - h[b])[0]; + const filtered = cells.c[i].filter( + (c: number) => + !lakes.map((lake: any) => lake.i).includes(cells.f[c]), + ); + min = filtered.sort((a: number, b: number) => h[a] - h[b])[0]; } else if (cells.haven[i]) { min = cells.haven[i]; } else { - min = cells.c[i].sort((a, b) => h[a] - h[b])[0]; + min = cells.c[i].sort((a: number, b: number) => h[a] - h[b])[0]; } // cells is depressed - if (h[i] <= h[min]) return; + if (h[i] <= h[min]) continue; // debug // .append("line") @@ -110,7 +149,7 @@ window.Rivers = (function () { if (cells.fl[i] < MIN_FLUX_TO_FORM_RIVER) { // flux is too small to operate as a river if (h[min] >= 20) cells.fl[min] += cells.fl[i]; - return; + continue; } // proclaim a new river @@ -121,10 +160,10 @@ window.Rivers = (function () { } flowDown(min, cells.fl[i], cells.r[i]); - }); - } + } + }; - function flowDown(toCell, fromFlux, river) { + const flowDown = (toCell: number, fromFlux: number, river: number) => { const toFlux = cells.fl[toCell] - cells.conf[toCell]; const toRiver = cells.r[toCell]; @@ -144,7 +183,10 @@ window.Rivers = (function () { // pour water to the water body const waterBody = features[cells.f[toCell]]; if (waterBody.type === "lake") { - if (!waterBody.river || fromFlux > waterBody.enteringFlux) { + if ( + !waterBody.river || + fromFlux > (waterBody.enteringFlux as number) + ) { waterBody.river = river; waterBody.enteringFlux = fromFlux; } @@ -158,15 +200,18 @@ window.Rivers = (function () { } addCellToRiver(toCell, river); - } + }; - function defineRivers() { + const defineRivers = () => { // re-initialize rivers and confluence arrays cells.r = new Uint16Array(cells.i.length); cells.conf = new Uint16Array(cells.i.length); pack.rivers = []; - const defaultWidthFactor = rn(1 / (pointsInput.dataset.cells / 10000) ** 0.25, 2); + const defaultWidthFactor = rn( + 1 / ((pointsInput.dataset.cells as any) / 10000) ** 0.25, + 2, + ); const mainStemWidthFactor = defaultWidthFactor * 1.2; for (const key in riversData) { @@ -186,18 +231,21 @@ window.Rivers = (function () { const mouth = riverCells[riverCells.length - 2]; const parent = riverParents[key] || 0; - const widthFactor = !parent || parent === riverId ? mainStemWidthFactor : defaultWidthFactor; - const meanderedPoints = addMeandering(riverCells); + const widthFactor = + !parent || parent === riverId + ? mainStemWidthFactor + : defaultWidthFactor; + const meanderedPoints = this.addMeandering(riverCells); const discharge = cells.fl[mouth]; // m3 in second - const length = getApproximateLength(meanderedPoints); - const sourceWidth = getSourceWidth(cells.fl[source]); - const width = getWidth( - getOffset({ + const length = this.getApproximateLength(meanderedPoints); + const sourceWidth = this.getSourceWidth(cells.fl[source]); + const width = this.getWidth( + this.getOffset({ flux: discharge, pointIndex: meanderedPoints.length, widthFactor, - startingWidth: sourceWidth - }) + startingWidth: sourceWidth, + }), ); pack.rivers.push({ @@ -210,69 +258,109 @@ window.Rivers = (function () { widthFactor, sourceWidth, parent, - cells: riverCells - }); + cells: riverCells, + } as River); } - } + }; - function downcutRivers() { + const downcutRivers = () => { const MAX_DOWNCUT = 5; for (const i of pack.cells.i) { if (cells.h[i] < 35) continue; // don't donwcut lowlands if (!cells.fl[i]) continue; - const higherCells = cells.c[i].filter(c => cells.h[c] > cells.h[i]); - const higherFlux = higherCells.reduce((acc, c) => acc + cells.fl[c], 0) / higherCells.length; + const higherCells = cells.c[i].filter( + (c: number) => cells.h[c] > cells.h[i], + ); + const higherFlux = + higherCells.reduce((acc: number, c: number) => acc + cells.fl[c], 0) / + higherCells.length; if (!higherFlux) continue; const downcut = Math.floor(cells.fl[i] / higherFlux); if (downcut) cells.h[i] -= Math.min(downcut, MAX_DOWNCUT); } - } + }; - function calculateConfluenceFlux() { + const calculateConfluenceFlux = () => { for (const i of cells.i) { if (!cells.conf[i]) continue; const sortedInflux = cells.c[i] - .filter(c => cells.r[c] && h[c] > h[i]) - .map(c => cells.fl[c]) - .sort((a, b) => b - a); - cells.conf[i] = sortedInflux.reduce((acc, flux, index) => (index ? acc + flux : acc), 0); + .filter((c: number) => cells.r[c] && h[c] > h[i]) + .map((c: number) => cells.fl[c]) + .sort((a: number, b: number) => b - a); + cells.conf[i] = sortedInflux.reduce( + (acc: number, flux: number, index: number) => + index ? acc + flux : acc, + 0, + ); } - } - }; + }; - // add distance to water value to land cells to make map less depressed - const alterHeights = () => { - const {h, c, t} = pack.cells; + cells.fl = new Uint16Array(cells.i.length); // water flux array + cells.r = new Uint16Array(cells.i.length); // rivers array + cells.conf = new Uint8Array(cells.i.length); // confluences array + let riverNext = 1; // first river id is 1 + + const h = this.alterHeights(); + Lakes.detectCloseLakes(h); + this.resolveDepressions(h); + drainWater(); + defineRivers(); + + calculateConfluenceFlux(); + Lakes.cleanupLakeData(); + + if (allowErosion) { + cells.h = Uint8Array.from(h); // apply gradient + downcutRivers(); // downcut river beds + } + + TIME && console.timeEnd("generateRivers"); + } + + alterHeights(): number[] { + const { h, c, t } = pack.cells as { + h: Uint8Array; + c: number[][]; + t: Uint8Array; + }; return Array.from(h).map((h, i) => { if (h < 20 || t[i] < 1) return h; - return h + t[i] / 100 + d3.mean(c[i].map(c => t[c])) / 10000; + return h + t[i] / 100 + (mean(c[i].map((c) => t[c])) as number) / 10000; }); - }; + } // depression filling algorithm (for a correct water flux modeling) - const resolveDepressions = function (h) { - const {cells, features} = pack; - const maxIterations = +document.getElementById("resolveDepressionsStepsOutput").value; + resolveDepressions(h: number[]) { + const { cells, features } = pack; + const maxIterations = +( + document.getElementById( + "resolveDepressionsStepsOutput", + ) as HTMLInputElement + )?.value; const checkLakeMaxIteration = maxIterations * 0.85; const elevateLakeMaxIteration = maxIterations * 0.75; - const height = i => features[cells.f[i]].height || h[i]; // height of lake or specific cell + const height = (i: number) => features[cells.f[i]].height || h[i]; // height of lake or specific cell - const lakes = features.filter(f => f.type === "lake"); - const land = cells.i.filter(i => h[i] >= 20 && !cells.b[i]); // exclude near-border cells - land.sort((a, b) => h[a] - h[b]); // lowest cells go first + const lakes = features.filter((feature) => feature.type === "lake"); + const land = cells.i.filter((i: number) => h[i] >= 20 && !cells.b[i]); // exclude near-border cells + land.sort((a: number, b: number) => h[a] - h[b]); // lowest cells go first const progress = []; let depressions = Infinity; let prevDepressions = null; - for (let iteration = 0; depressions && iteration < maxIterations; iteration++) { - if (progress.length > 5 && d3.sum(progress) > 0) { + for ( + let iteration = 0; + depressions && iteration < maxIterations; + iteration++ + ) { + if (progress.length > 5 && sum(progress) > 0) { // bad progress, abort and set heights back - h = alterHeights(); + h = this.alterHeights(); depressions = progress[0]; break; } @@ -282,23 +370,28 @@ window.Rivers = (function () { if (iteration < checkLakeMaxIteration) { for (const l of lakes) { if (l.closed) continue; - const minHeight = d3.min(l.shoreline.map(s => h[s])); + const minHeight = min(l.shoreline.map((s: number) => h[s])) as number; if (minHeight >= 100 || l.height > minHeight) continue; if (iteration > elevateLakeMaxIteration) { - l.shoreline.forEach(i => (h[i] = cells.h[i])); - l.height = d3.min(l.shoreline.map(s => h[s])) - 1; + l.shoreline.forEach((i: number) => { + h[i] = cells.h[i]; + }); + l.height = + (min(l.shoreline.map((s: number) => h[s])) as number) - 1; l.closed = true; continue; } depressions++; - l.height = minHeight + 0.2; + l.height = (minHeight as number) + 0.2; } } for (const i of land) { - const minHeight = d3.min(cells.c[i].map(c => height(c))); + const minHeight = min( + cells.c[i].map((c: number) => height(c)), + ) as number; if (minHeight >= 100 || h[i] > minHeight) continue; depressions++; @@ -309,15 +402,22 @@ window.Rivers = (function () { prevDepressions = depressions; } - depressions && WARN && console.warn(`Unresolved depressions: ${depressions}. Edit heightmap to fix`); - }; + depressions && + WARN && + console.warn( + `Unresolved depressions: ${depressions}. Edit heightmap to fix`, + ); + } - // add points at 1/3 and 2/3 of a line between adjacents river cells - const addMeandering = function (riverCells, riverPoints = null, meandering = 0.5) { - const {fl, h} = pack.cells; + addMeandering( + riverCells: number[], + riverPoints = null, + meandering = 0.5, + ): [number, number, number][] { + const { fl, h } = pack.cells; const meandered = []; const lastStep = riverCells.length - 1; - const points = getRiverPoints(riverCells, riverPoints); + const points = this.getRiverPoints(riverCells, riverPoints); let step = h[riverCells[0]] < 20 ? 1 : 10; for (let i = 0; i <= lastStep; i++, step++) { @@ -340,7 +440,8 @@ window.Rivers = (function () { const dist2 = (x2 - x1) ** 2 + (y2 - y1) ** 2; // square distance between cells if (dist2 <= 25 && riverCells.length >= 6) continue; - const meander = meandering + 1 / step + Math.max(meandering - step / 100, 0); + const meander = + meandering + 1 / step + Math.max(meandering - step / 100, 0); const angle = Math.atan2(y2 - y1, x2 - x1); const sinMeander = Math.sin(angle) * meander; const cosMeander = Math.cos(angle) * meander; @@ -360,49 +461,65 @@ window.Rivers = (function () { } } - return meandered; - }; + return meandered as [number, number, number][]; + } - const getRiverPoints = (riverCells, riverPoints) => { + getRiverPoints(riverCells: number[], riverPoints: [number, number][] | null) { if (riverPoints) return riverPoints; - const {p} = pack.cells; + const { p } = pack.cells; return riverCells.map((cell, i) => { - if (cell === -1) return getBorderPoint(riverCells[i - 1]); + if (cell === -1) return this.getBorderPoint(riverCells[i - 1]); return p[cell]; }); - }; + } - const getBorderPoint = i => { + getBorderPoint(i: number) { const [x, y] = pack.cells.p[i]; const min = Math.min(y, graphHeight - y, x, graphWidth - x); if (min === y) return [x, 0]; else if (min === graphHeight - y) return [x, graphHeight]; else if (min === x) return [0, y]; return [graphWidth, y]; - }; + } - const FLUX_FACTOR = 500; - const MAX_FLUX_WIDTH = 1; - const LENGTH_FACTOR = 200; - const LENGTH_STEP_WIDTH = 1 / LENGTH_FACTOR; - const LENGTH_PROGRESSION = [1, 1, 2, 3, 5, 8, 13, 21, 34].map(n => n / LENGTH_FACTOR); - - const getOffset = ({flux, pointIndex, widthFactor, startingWidth}) => { + getOffset({ + flux, + pointIndex, + widthFactor, + startingWidth, + }: { + flux: number; + pointIndex: number; + widthFactor: number; + startingWidth: number; + }) { if (pointIndex === 0) return startingWidth; - const fluxWidth = Math.min(flux ** 0.7 / FLUX_FACTOR, MAX_FLUX_WIDTH); - const lengthWidth = pointIndex * LENGTH_STEP_WIDTH + (LENGTH_PROGRESSION[pointIndex] || LENGTH_PROGRESSION.at(-1)); + const fluxWidth = Math.min( + flux ** 0.7 / this.FLUX_FACTOR, + this.MAX_FLUX_WIDTH, + ); + const lengthWidth = + pointIndex * this.LENGTH_STEP_WIDTH + + (this.LENGTH_PROGRESSION[pointIndex] || + (this.LENGTH_PROGRESSION.at(-1) as number)); return widthFactor * (lengthWidth + fluxWidth) + startingWidth; - }; + } - const getSourceWidth = flux => rn(Math.min(flux ** 0.9 / FLUX_FACTOR, MAX_FLUX_WIDTH), 2); + getSourceWidth(flux: number) { + return rn(Math.min(flux ** 0.9 / this.FLUX_FACTOR, this.MAX_FLUX_WIDTH), 2); + } // build polygon from a list of points and calculated offset (width) - const getRiverPath = (points, widthFactor, startingWidth) => { - lineGen.curve(d3.curveCatmullRom.alpha(0.1)); - const riverPointsLeft = []; - const riverPointsRight = []; + getRiverPath( + points: [number, number, number][], + widthFactor: number, + startingWidth: number, + ) { + this.lineGen.curve(curveCatmullRom.alpha(0.1)); + const riverPointsLeft: [number, number][] = []; + const riverPointsRight: [number, number][] = []; let flux = 0; for (let pointIndex = 0; pointIndex < points.length; pointIndex++) { @@ -411,7 +528,12 @@ window.Rivers = (function () { const [x2, y2] = points[pointIndex + 1] || points[pointIndex]; if (pointFlux > flux) flux = pointFlux; - const offset = getOffset({flux, pointIndex, widthFactor, startingWidth}); + const offset = this.getOffset({ + flux, + pointIndex, + widthFactor, + startingWidth, + }); const angle = Math.atan2(y0 - y2, x0 - x2); const sinOffset = Math.sin(angle) * offset; const cosOffset = Math.cos(angle) * offset; @@ -420,101 +542,85 @@ window.Rivers = (function () { riverPointsRight.push([x1 + sinOffset, y1 - cosOffset]); } - const right = lineGen(riverPointsRight.reverse()); - let left = lineGen(riverPointsLeft); + const right = this.lineGen(riverPointsRight.reverse()); + let left = this.lineGen(riverPointsLeft) || ""; left = left.substring(left.indexOf("C")); return round(right + left, 1); - }; + } - const specify = function () { + specify() { const rivers = pack.rivers; if (!rivers.length) return; for (const river of rivers) { - river.basin = getBasin(river.i); - river.name = getName(river.mouth); - river.type = getType(river); + river.basin = this.getBasin(river.i); + river.name = this.getName(river.mouth); + river.type = this.getType(river); } - }; + } - const getName = function (cell) { + getName(cell: number) { return Names.getCulture(pack.cells.culture[cell]); - }; + } - // weighted arrays of river type names - const riverTypes = { - main: { - big: {River: 1}, - small: {Creek: 9, River: 3, Brook: 3, Stream: 1} - }, - fork: { - big: {Fork: 1}, - small: {Branch: 1} - } - }; - - let smallLength = null; - const getType = function ({i, length, parent}) { - if (smallLength === null) { + getType({ i, length, parent }: River) { + if (this.smallLength === null) { const threshold = Math.ceil(pack.rivers.length * 0.15); - smallLength = pack.rivers.map(r => r.length || 0).sort((a, b) => a - b)[threshold]; + this.smallLength = pack.rivers + .map((r) => r.length || 0) + .sort((a: number, b: number) => a - b)[threshold]; } - const isSmall = length < smallLength; + const isSmall: boolean = length < (this.smallLength as number); const isFork = each(3)(i) && parent && parent !== i; - return rw(riverTypes[isFork ? "fork" : "main"][isSmall ? "small" : "big"]); - }; + return rw( + this.riverTypes[isFork ? "fork" : "main"][isSmall ? "small" : "big"], + ); + } - const getApproximateLength = points => { - const length = points.reduce((s, v, i, p) => s + (i ? Math.hypot(v[0] - p[i - 1][0], v[1] - p[i - 1][1]) : 0), 0); + getApproximateLength(points: [number, number, number][]) { + const length = points.reduce( + (s, v, i, p) => + s + (i ? Math.hypot(v[0] - p[i - 1][0], v[1] - p[i - 1][1]) : 0), + 0, + ); return rn(length, 2); - }; + } // Real mouth width examples: Amazon 6000m, Volga 6000m, Dniepr 3000m, Mississippi 1300m, Themes 900m, // Danube 800m, Daugava 600m, Neva 500m, Nile 450m, Don 400m, Wisla 300m, Pripyat 150m, Bug 140m, Muchavets 40m - const getWidth = offset => rn((offset / 1.5) ** 1.8, 2); // mouth width in km + getWidth(offset: number) { + return rn((offset / 1.5) ** 1.8, 2); // mouth width in km + } // remove river and all its tributaries - const remove = function (id) { + remove(id: number) { const cells = pack.cells; - const riversToRemove = pack.rivers.filter(r => r.i === id || r.parent === id || r.basin === id).map(r => r.i); - riversToRemove.forEach(r => rivers.select("#river" + r).remove()); + const riversToRemove = pack.rivers + .filter((r) => r.i === id || r.parent === id || r.basin === id) + .map((r) => r.i); + riversToRemove.forEach((r) => { + rivers.select(`#river${r}`).remove(); + }); cells.r.forEach((r, i) => { if (!r || !riversToRemove.includes(r)) return; cells.r[i] = 0; cells.fl[i] = grid.cells.prec[cells.g[i]]; cells.conf[i] = 0; }); - pack.rivers = pack.rivers.filter(r => !riversToRemove.includes(r.i)); - }; + pack.rivers = pack.rivers.filter((r) => !riversToRemove.includes(r.i)); + } - const getBasin = function (r) { - const parent = pack.rivers.find(river => river.i === r)?.parent; + getBasin(r: number): number { + const parent = pack.rivers.find((river) => river.i === r)?.parent; if (!parent || r === parent) return r; - return getBasin(parent); - }; + return this.getBasin(parent); + } - const getNextId = function (rivers) { - return rivers.length ? Math.max(...rivers.map(r => r.i)) + 1 : 1; - }; + getNextId(rivers: { i: number }[]) { + return rivers.length ? Math.max(...rivers.map((r) => r.i)) + 1 : 1; + } +} - return { - generate, - alterHeights, - resolveDepressions, - addMeandering, - getRiverPath, - specify, - getName, - getType, - getBasin, - getWidth, - getOffset, - getSourceWidth, - getApproximateLength, - getRiverPoints, - remove, - getNextId - }; -})(); +window.Rivers = new RiverModule(); diff --git a/src/modules/routes-generator.ts b/src/modules/routes-generator.ts new file mode 100644 index 00000000..b233db47 --- /dev/null +++ b/src/modules/routes-generator.ts @@ -0,0 +1,786 @@ +import { curveCatmullRom, line } from "d3"; +import Delaunator from "delaunator"; +import { + distanceSquared, + findClosestCell, + findPath, + getAdjective, + isLand, + ra, + rn, + round, + rw, +} from "../utils"; +import type { Burg } from "./burgs-generator"; +import type { Point } from "./voronoi"; + +const ROUTES_SHARP_ANGLE = 135; +const ROUTES_VERY_SHARP_ANGLE = 115; + +const MIN_PASSABLE_SEA_TEMP = -4; +const ROUTE_TYPE_MODIFIERS: Record = { + "-1": 1, // coastline + "-2": 1.8, // sea + "-3": 4, // open sea + "-4": 6, // ocean + default: 8, // far ocean +}; + +// name generator data +const models: Record> = { + roads: { + burg_suffix: 3, + prefix_suffix: 6, + the_descriptor_prefix_suffix: 2, + the_descriptor_burg_suffix: 1, + }, + trails: { burg_suffix: 8, prefix_suffix: 1, the_descriptor_burg_suffix: 1 }, + searoutes: { + burg_suffix: 4, + prefix_suffix: 2, + the_descriptor_prefix_suffix: 1, + }, +}; + +const prefixes: string[] = [ + "King", + "Queen", + "Military", + "Old", + "New", + "Ancient", + "Royal", + "Imperial", + "Great", + "Grand", + "High", + "Silver", + "Dragon", + "Shadow", + "Star", + "Mystic", + "Whisper", + "Eagle", + "Golden", + "Crystal", + "Enchanted", + "Frost", + "Moon", + "Sun", + "Thunder", + "Phoenix", + "Sapphire", + "Celestial", + "Wandering", + "Echo", + "Twilight", + "Crimson", + "Serpent", + "Iron", + "Forest", + "Flower", + "Whispering", + "Eternal", + "Frozen", + "Rain", + "Luminous", + "Stardust", + "Arcane", + "Glimmering", + "Jade", + "Ember", + "Azure", + "Gilded", + "Divine", + "Shadowed", + "Cursed", + "Moonlit", + "Sable", + "Everlasting", + "Amber", + "Nightshade", + "Wraith", + "Scarlet", + "Platinum", + "Whirlwind", + "Obsidian", + "Ethereal", + "Ghost", + "Spike", + "Dusk", + "Raven", + "Spectral", + "Burning", + "Verdant", + "Copper", + "Velvet", + "Falcon", + "Enigma", + "Glowing", + "Silvered", + "Molten", + "Radiant", + "Astral", + "Wild", + "Flame", + "Amethyst", + "Aurora", + "Shadowy", + "Solar", + "Lunar", + "Whisperwind", + "Fading", + "Titan", + "Dawn", + "Crystalline", + "Jeweled", + "Sylvan", + "Twisted", + "Ebon", + "Thorn", + "Cerulean", + "Halcyon", + "Infernal", + "Storm", + "Eldritch", + "Sapphire", + "Crimson", + "Tranquil", + "Paved", +]; + +const descriptors = [ + "Great", + "Shrouded", + "Sacred", + "Fabled", + "Frosty", + "Winding", + "Echoing", + "Serpentine", + "Breezy", + "Misty", + "Rustic", + "Silent", + "Cobbled", + "Cracked", + "Shaky", + "Obscure", +]; + +const suffixes: Record> = { + roads: { road: 7, route: 3, way: 2, highway: 1 }, + trails: { trail: 4, path: 1, track: 1, pass: 1 }, + searoutes: { "sea route": 5, lane: 2, passage: 1, seaway: 1 }, +}; + +export interface Route { + i: number; + group: "roads" | "trails" | "searoutes"; + feature: number; + points: number[][]; + cells?: number[]; + merged?: boolean; +} + +class RoutesModule { + buildLinks(routes: Route[]): Record> { + const links: Record> = {}; + + for (const { points, i: routeId } of routes) { + const cells = points.map((p) => p[2]); + + for (let i = 0; i < cells.length - 1; i++) { + const cellId = cells[i]; + const nextCellId = cells[i + 1]; + + if (cellId !== nextCellId) { + if (!links[cellId]) links[cellId] = {}; + links[cellId][nextCellId] = routeId; + + if (!links[nextCellId]) links[nextCellId] = {}; + links[nextCellId][cellId] = routeId; + } + } + } + + return links; + } + + private sortBurgsByFeature(burgs: Burg[]) { + const burgsByFeature: Record = {}; + const capitalsByFeature: Record = {}; + const portsByFeature: Record = {}; + + const addBurg = ( + collection: Record, + feature: number, + burg: Burg, + ) => { + if (!collection[feature]) collection[feature] = []; + collection[feature].push(burg); + }; + + for (const burg of burgs) { + if (burg.i && !burg.removed) { + const { feature, capital, port } = burg; + addBurg(burgsByFeature, feature as number, burg); + if (capital) addBurg(capitalsByFeature, feature as number, burg); + if (port) addBurg(portsByFeature, port as number, burg); + } + } + + return { burgsByFeature, capitalsByFeature, portsByFeature }; + } + + // Urquhart graph is obtained by removing the longest edge from each triangle in the Delaunay triangulation + // this gives us an aproximation of a desired road network, i.e. connections between burgs + // code from https://observablehq.com/@mbostock/urquhart-graph + private calculateUrquhartEdges(points: Point[]) { + const score = (p0: number, p1: number) => + distanceSquared(points[p0], points[p1]); + + const { halfedges, triangles } = Delaunator.from(points); + const n = triangles.length; + + const removed = new Uint8Array(n); + const edges = []; + + for (let e = 0; e < n; e += 3) { + const p0 = triangles[e], + p1 = triangles[e + 1], + p2 = triangles[e + 2]; + + const p01 = score(p0, p1), + p12 = score(p1, p2), + p20 = score(p2, p0); + + removed[ + p20 > p01 && p20 > p12 + ? Math.max(e + 2, halfedges[e + 2]) + : p12 > p01 && p12 > p20 + ? Math.max(e + 1, halfedges[e + 1]) + : Math.max(e, halfedges[e]) + ] = 1; + } + + for (let e = 0; e < n; ++e) { + if (e > halfedges[e] && !removed[e]) { + const t0 = triangles[e]; + const t1 = triangles[e % 3 === 2 ? e - 2 : e + 1]; + edges.push([t0, t1]); + } + } + + return edges; + } + + private createCostEvaluator({ + isWater, + connections, + }: { + isWater: boolean; + connections: Map; + }) { + function getLandPathCost(current: number, next: number) { + if (pack.cells.h[next] < 20) return Infinity; // ignore water cells + + const habitability = biomesData.habitability[pack.cells.biome[next]]; + if (!habitability) return Infinity; // inhabitable cells are not passable (e.g. glacier) + + const distanceCost = distanceSquared( + pack.cells.p[current], + pack.cells.p[next], + ); + const habitabilityModifier = 1 + Math.max(100 - habitability, 0) / 1000; // [1, 1.1]; + const heightModifier = 1 + Math.max(pack.cells.h[next] - 25, 25) / 25; // [1, 3]; + const connectionModifier = connections.has(`${current}-${next}`) + ? 0.5 + : 1; + const burgModifier = pack.cells.burg[next] ? 1 : 3; + + const pathCost = + distanceCost * + habitabilityModifier * + heightModifier * + connectionModifier * + burgModifier; + return pathCost; + } + + function getWaterPathCost(current: number, next: number) { + if (pack.cells.h[next] >= 20) return Infinity; // ignore land cells + if (grid.cells.temp[pack.cells.g[next]] < MIN_PASSABLE_SEA_TEMP) + return Infinity; // ignore too cold cells + + const distanceCost = distanceSquared( + pack.cells.p[current], + pack.cells.p[next], + ); + const typeModifier = + ROUTE_TYPE_MODIFIERS[pack.cells.t[next]] || + ROUTE_TYPE_MODIFIERS.default; + const connectionModifier = connections.has(`${current}-${next}`) + ? 0.5 + : 1; + + const pathCost = distanceCost * typeModifier * connectionModifier; + return pathCost; + } + return isWater ? getWaterPathCost : getLandPathCost; + } + + private getRouteSegments( + pathCells: number[], + connections: Map, + ) { + const segments = []; + let segment = []; + + for (let i = 0; i < pathCells.length; i++) { + const cellId = pathCells[i]; + const nextCellId = pathCells[i + 1]; + const isConnected = + connections.has(`${cellId}-${nextCellId}`) || + connections.has(`${nextCellId}-${cellId}`); + + if (isConnected) { + if (segment.length) { + // segment stepped into existing segment + segment.push(pathCells[i]); + segments.push(segment); + segment = []; + } + continue; + } + + segment.push(pathCells[i]); + } + + if (segment.length > 1) segments.push(segment); + + return segments; + } + + private findPathSegments({ + isWater, + connections, + start, + exit, + }: { + isWater: boolean; + connections: Map; + start: number; + exit: number; + }) { + const getCost = this.createCostEvaluator({ isWater, connections }); + const pathCells = findPath( + start, + (current) => current === exit, + getCost, + pack, + ); + if (!pathCells) return []; + const segments = this.getRouteSegments(pathCells, connections); + return segments; + } + + private generateMainRoads(connections: Map) { + TIME && console.time("generateMainRoads"); + const { capitalsByFeature } = this.sortBurgsByFeature(pack.burgs); + const mainRoads: Route[] = []; + + for (const [key, featureCapitals] of Object.entries(capitalsByFeature)) { + const points = featureCapitals.map((burg) => [burg.x, burg.y] as Point); + const urquhartEdges = this.calculateUrquhartEdges(points); + urquhartEdges.forEach(([fromId, toId]) => { + const start = featureCapitals[fromId].cell; + const exit = featureCapitals[toId].cell; + + const segments = this.findPathSegments({ + isWater: false, + connections, + start, + exit, + }); + for (const segment of segments) { + this.addConnections(segment, connections); + mainRoads.push({ feature: Number(key), cells: segment } as Route); + } + }); + } + + TIME && console.timeEnd("generateMainRoads"); + return mainRoads; + } + + private addConnections(segment: number[], connections: Map) { + for (let i = 0; i < segment.length; i++) { + const cellId = segment[i]; + const nextCellId = segment[i + 1]; + if (nextCellId) { + connections.set(`${cellId}-${nextCellId}`, true); + connections.set(`${nextCellId}-${cellId}`, true); + } + } + } + + private generateTrails(connections: Map) { + TIME && console.time("generateTrails"); + const { burgsByFeature } = this.sortBurgsByFeature(pack.burgs); + const trails: Route[] = []; + + for (const [key, featureBurgs] of Object.entries(burgsByFeature)) { + const points = featureBurgs.map((burg) => [burg.x, burg.y] as Point); + const urquhartEdges = this.calculateUrquhartEdges(points); + urquhartEdges.forEach(([fromId, toId]) => { + const start = featureBurgs[fromId].cell; + const exit = featureBurgs[toId].cell; + + const segments = this.findPathSegments({ + isWater: false, + connections, + start, + exit, + }); + for (const segment of segments) { + this.addConnections(segment, connections); + trails.push({ feature: Number(key), cells: segment } as Route); + } + }); + } + + TIME && console.timeEnd("generateTrails"); + return trails; + } + + private generateSeaRoutes(connections: Map) { + TIME && console.time("generateSeaRoutes"); + const { portsByFeature } = this.sortBurgsByFeature(pack.burgs); + const seaRoutes: Route[] = []; + + for (const [featureId, featurePorts] of Object.entries(portsByFeature)) { + const points = featurePorts.map((burg) => [burg.x, burg.y] as Point); + const urquhartEdges = this.calculateUrquhartEdges(points); + + urquhartEdges.forEach(([fromId, toId]) => { + const start = featurePorts[fromId].cell; + const exit = featurePorts[toId].cell; + const segments = this.findPathSegments({ + isWater: true, + connections, + start, + exit, + }); + for (const segment of segments) { + this.addConnections(segment, connections); + seaRoutes.push({ + feature: Number(featureId), + cells: segment, + } as Route); + } + }); + } + + TIME && console.timeEnd("generateSeaRoutes"); + return seaRoutes; + } + + private preparePointsArray(): Point[] { + const { cells, burgs } = pack; + return cells.p.map(([x, y], cellId) => { + const burgId = cells.burg[cellId]; + if (burgId) return [burgs[burgId].x, burgs[burgId].y]; + return [x, y]; + }); + } + + private getPoints(group: string, cells: number[], points: Point[]) { + const data = cells.map((cellId) => [...points[cellId], cellId]); + + // resolve sharp angles + if (group !== "searoutes") { + for (let i = 1; i < cells.length - 1; i++) { + const cellId = cells[i]; + if (pack.cells.burg[cellId]) continue; + + const [prevX, prevY] = data[i - 1]; + const [currX, currY] = data[i]; + const [nextX, nextY] = data[i + 1]; + + const dAx = prevX - currX; + const dAy = prevY - currY; + const dBx = nextX - currX; + const dBy = nextY - currY; + const angle = Math.abs( + (Math.atan2(dAx * dBy - dAy * dBx, dAx * dBx + dAy * dBy) * 180) / + Math.PI, + ); + + if (angle < ROUTES_SHARP_ANGLE) { + const middleX = (prevX + nextX) / 2; + const middleY = (prevY + nextY) / 2; + let newX: number, newY: number; + + if (angle < ROUTES_VERY_SHARP_ANGLE) { + newX = rn((currX + middleX * 2) / 3, 2); + newY = rn((currY + middleY * 2) / 3, 2); + } else { + newX = rn((currX + middleX) / 2, 2); + newY = rn((currY + middleY) / 2, 2); + } + + if (findClosestCell(newX, newY, undefined, pack) === cellId) { + data[i] = [newX, newY, cellId]; + points[cellId] = [data[i][0], data[i][1]]; // change cell coordinate for all routes + } + } + } + } + + return data; // [[x, y, cell], [x, y, cell]]; + } + + // merge routes so that the last cell of one route is the first cell of the next route + private mergeRoutes(routes: Route[]): Route[] { + let routesMerged = 0; + + for (let i = 0; i < routes.length; i++) { + const thisRoute = routes[i]; + if (thisRoute.merged) continue; + + for (let j = i + 1; j < routes.length; j++) { + const nextRoute = routes[j]; + if (nextRoute.merged) continue; + + if (nextRoute.cells!.at(0) === thisRoute.cells!.at(-1)) { + routesMerged++; + thisRoute.cells = thisRoute.cells!.concat(nextRoute.cells!.slice(1)); + nextRoute.merged = true; + } + } + } + + return routesMerged > 1 ? this.mergeRoutes(routes) : routes; + } + private createRoutesData(routes: Route[], connections: Map) { + const mainRoads = this.generateMainRoads(connections); + const trails = this.generateTrails(connections); + const seaRoutes = this.generateSeaRoutes(connections); + const pointsArray = this.preparePointsArray(); + + for (const { feature, cells, merged } of this.mergeRoutes(mainRoads)) { + if (merged) continue; + const points = this.getPoints("roads", cells!, pointsArray); + routes.push({ i: routes.length, group: "roads", feature, points }); + } + + for (const { feature, cells, merged } of this.mergeRoutes(trails)) { + if (merged) continue; + const points = this.getPoints("trails", cells!, pointsArray); + routes.push({ i: routes.length, group: "trails", feature, points }); + } + + for (const { feature, cells, merged } of this.mergeRoutes(seaRoutes)) { + if (merged) continue; + const points = this.getPoints("searoutes", cells!, pointsArray); + routes.push({ i: routes.length, group: "searoutes", feature, points }); + } + + return routes; + } + + generate(lockedRoutes: Route[] = []) { + const connections = new Map(); + lockedRoutes.forEach((route: Route) => { + this.addConnections( + route.points.map((p) => p[2]), + connections, + ); + }); + + pack.routes = this.createRoutesData(lockedRoutes, connections); + pack.cells.routes = this.buildLinks(pack.routes); + } + + // utility functions + isConnected(cellId: number): boolean { + const routes = pack.cells.routes; + return routes[cellId] && Object.keys(routes[cellId]).length > 0; + } + + getNextId() { + return pack.routes.length + ? Math.max(...pack.routes.map((r) => r.i)) + 1 + : 0; + } + + // connect cell with routes system by land + connect(cellId: number): Route | undefined { + const getCost = this.createCostEvaluator({ + isWater: false, + connections: new Map(), + }); + const isExit = (c: number) => isLand(c, pack) && this.isConnected(c); + const pathCells = findPath(cellId, isExit, getCost, pack); + if (!pathCells) return; + + const pointsArray = this.preparePointsArray(); + const points = this.getPoints("trails", pathCells, pointsArray); + const feature = pack.cells.f[cellId]; + const routeId = this.getNextId(); + const newRoute = { i: routeId, group: "trails", feature, points }; + pack.routes.push(newRoute as Route); + + const addConnection = (from: number, to: number, routeId: number) => { + const routes = pack.cells.routes; + + if (!routes[from]) routes[from] = {}; + routes[from][to] = routeId; + + if (!routes[to]) routes[to] = {}; + routes[to][from] = routeId; + }; + + for (let i = 0; i < pathCells.length; i++) { + const currentCell = pathCells[i]; + const nextCellId = pathCells[i + 1]; + if (nextCellId) addConnection(currentCell, nextCellId, routeId); + } + + return newRoute as Route; + } + + areConnected(from: number, to: number): boolean { + const routeId = pack.cells.routes[from]?.[to]; + return routeId !== undefined; + } + + getRoute(from: number, to: number) { + const routeId = pack.cells.routes[from]?.[to]; + if (routeId === undefined) return null; + + const route = pack.routes.find((route) => route.i === routeId); + if (!route) return null; + + return route; + } + + hasRoad(cellId: number): boolean { + const connections = pack.cells.routes[cellId]; + if (!connections) return false; + + return Object.values(connections).some((routeId) => { + const route = pack.routes.find((route) => route.i === routeId); + if (!route) return false; + return route.group === "roads"; + }); + } + + isCrossroad(cellId: number): boolean { + const connections = pack.cells.routes[cellId]; + if (!connections) return false; + if (Object.keys(connections).length > 3) return true; + const roadConnections = Object.values(connections).filter((routeId) => { + const route = pack.routes.find((route) => route.i === routeId); + return route?.group === "roads"; + }); + return roadConnections.length > 2; + } + + remove(route: Route) { + const routes = pack.cells.routes; + + for (const point of route.points) { + const from = point[2]; + if (!routes[from]) continue; + + for (const [to, routeId] of Object.entries(routes[from])) { + if (routeId === route.i) { + delete routes[from][parseInt(to, 10)]; + delete routes[parseInt(to, 10)][from]; + } + } + } + + pack.routes = pack.routes.filter((r) => r.i !== route.i); + viewbox.select(`#route${route.i}`).remove(); + } + + getConnectivityRate(cellId: number): number { + const connections = pack.cells.routes[cellId]; + if (!connections) return 0; + + const connectivityRateMap = { + roads: 0.2, + trails: 0.1, + searoutes: 0.2, + default: 0.1, + }; + + const connectivity = Object.values(connections).reduce((acc, routeId) => { + const route = pack.routes.find((route) => route.i === routeId); + if (!route) return acc; + const rate = + connectivityRateMap[route.group] || connectivityRateMap.default; + return acc + rate; + }, 0.8); + + return connectivity; + } + + generateName({ + group, + points, + }: { + group: string; + points: number[][]; + }): string { + if (points.length < 4) return "Unnamed route segment"; + + function getBurgName() { + const priority = [ + points.at(-1), + points.at(0), + points.slice(1, -1).reverse(), + ]; + for (const [_x, _y, cellId] of priority as [number, number, number][]) { + const burgId = pack.cells.burg[cellId as number]; + if (burgId) return getAdjective(pack.burgs[burgId].name!); + } + return null; + } + + const model = rw(models[group]); + const suffix = rw(suffixes[group]); + + const burgName = getBurgName(); + if (model === "burg_suffix" && burgName) return `${burgName} ${suffix}`; + if (model === "prefix_suffix") return `${ra(prefixes)} ${suffix}`; + if (model === "the_descriptor_prefix_suffix") + return `The ${ra(descriptors)} ${ra(prefixes)} ${suffix}`; + if (model === "the_descriptor_burg_suffix" && burgName) + return `The ${ra(descriptors)} ${burgName} ${suffix}`; + return "Unnamed route"; + } + + getPath({ group, points }: { group: string; points: number[][] }): string { + const lineGen = line(); + const ROUTE_CURVES: Record = { + roads: curveCatmullRom.alpha(0.1), + trails: curveCatmullRom.alpha(0.1), + searoutes: curveCatmullRom.alpha(0.5), + default: curveCatmullRom.alpha(0.1), + }; + lineGen.curve(ROUTE_CURVES[group] || ROUTE_CURVES.default); + const path = round(lineGen(points.map((p) => [p[0], p[1]])) as string, 1); + return path; + } + + getLength(routeId: number): number { + const path = routes.select(`#route${routeId}`).node() as SVGPathElement; + return path.getTotalLength(); + } +} + +window.Routes = new RoutesModule(); diff --git a/src/modules/states-generator.ts b/src/modules/states-generator.ts new file mode 100644 index 00000000..dec2445b --- /dev/null +++ b/src/modules/states-generator.ts @@ -0,0 +1,826 @@ +import { mean, median, sum } from "d3"; +import { + byId, + each, + gauss, + getAdjective, + getMixedColor, + getPolesOfInaccessibility, + getRandomColor, + minmax, + P, + ra, + rand, + rn, + rw, + trimVowels, +} from "../utils"; + +declare global { + var States: StatesModule; +} + +interface Campaign { + name: string; + start: number; + end?: number; +} + +export interface State { + i: number; + name: string; + expansionism: number; + capital: number; + type: string; + center: number; + culture: number; + coa: any; + lock?: boolean; + removed?: boolean; + pole?: [number, number]; + neighbors?: number[]; + color?: string; + cells?: number; + area?: number; + burgs?: number; + rural?: number; + urban?: number; + campaigns?: Campaign[]; + diplomacy?: string[]; + formName?: string; + fullName?: string; + form?: string; + military?: any[]; + provinces?: number[]; +} + +class StatesModule { + private createStates() { + const states: State[] = [{ i: 0, name: "Neutrals" } as State]; + const each5th = each(5); + const sizeVariety = (byId("sizeVariety") as HTMLInputElement).valueAsNumber; + + pack.burgs.forEach((burg) => { + if (!burg.i || !burg.capital) return; + + const expansionism = rn(Math.random() * sizeVariety + 1, 1); + const basename = + burg.name!.length < 9 && each5th(burg.cell) + ? burg.name! + : Names.getCultureShort(burg.culture!); + const name = Names.getState(basename, burg.culture!); + const type = pack.cultures[burg.culture!].type; + const coa = COA.generate(null, null, null, type); + coa.shield = COA.getShield(burg.culture, null); + states.push({ + i: burg.i, + name, + expansionism, + capital: burg.i, + type: type!, + center: burg.cell, + culture: burg.culture!, + coa, + }); + }); + + return states; + } + + private getBiomeCost(b: number, biome: number, type: string) { + if (b === biome) return 10; // tiny penalty for native biome + if (type === "Hunting") return biomesData.cost[biome] * 2; // non-native biome penalty for hunters + if (type === "Nomadic" && biome > 4 && biome < 10) + return biomesData.cost[biome] * 3; // forest biome penalty for nomads + return biomesData.cost[biome]; // general non-native biome penalty + } + + private getHeightCost(f: any, h: number, type: string) { + if (type === "Lake" && f.type === "lake") return 10; // low lake crossing penalty for Lake cultures + if (type === "Naval" && h < 20) return 300; // low sea crossing penalty for Navals + if (type === "Nomadic" && h < 20) return 10000; // giant sea crossing penalty for Nomads + if (h < 20) return 1000; // general sea crossing penalty + if (type === "Highland" && h < 62) return 1100; // penalty for highlanders on lowlands + if (type === "Highland") return 0; // no penalty for highlanders on highlands + if (h >= 67) return 2200; // general mountains crossing penalty + if (h >= 44) return 300; // general hills crossing penalty + return 0; + } + + private getRiverCost(r: any, i: number, type: string) { + if (type === "River") return r ? 0 : 100; // penalty for river cultures + if (!r) return 0; // no penalty for others if there is no river + return minmax(pack.cells.fl[i] / 10, 20, 100); // river penalty from 20 to 100 based on flux + } + + private getTypeCost(t: number, type: string) { + if (t === 1) + return type === "Naval" || type === "Lake" + ? 0 + : type === "Nomadic" + ? 60 + : 20; // penalty for coastline + if (t === 2) return type === "Naval" || type === "Nomadic" ? 30 : 0; // low penalty for land level 2 for Navals and nomads + if (t !== -1) return type === "Naval" || type === "Lake" ? 100 : 0; // penalty for mainland for navals + return 0; + } + + generate() { + TIME && console.time("generateStates"); + pack.states = this.createStates(); + this.expandStates(); + this.normalize(); + this.getPoles(); + this.findNeighbors(); + this.assignColors(); + this.generateCampaigns(); + this.generateDiplomacy(); + + TIME && console.timeEnd("generateStates"); + } + + expandStates() { + TIME && console.time("expandStates"); + const { cells, states, cultures, burgs } = pack; + + cells.state = cells.state || new Uint16Array(cells.i.length); + + const queue = new FlatQueue(); + const cost: number[] = []; + + const globalGrowthRate = + (byId("growthRate") as HTMLInputElement)?.valueAsNumber || 1; + const statesGrowthRate = + (byId("statesGrowthRate") as HTMLInputElement)?.valueAsNumber || 1; + const growthRate = + (cells.i.length / 2) * globalGrowthRate * statesGrowthRate; // limit cost for state growth + + // remove state from all cells except of locked + for (const cellId of cells.i) { + const state = states[cells.state[cellId]]; + if (state.lock) continue; + cells.state[cellId] = 0; + } + + for (const state of states) { + if (!state.i || state.removed) continue; + + const capitalCell = burgs[state.capital].cell; + cells.state[capitalCell] = state.i; + const cultureCenter = cultures[state.culture].center!; + const b = cells.biome[cultureCenter]; // state native biome + queue.push({ e: state.center, p: 0, s: state.i, b }, 0); + cost[state.center] = 1; + } + + while (queue.length) { + const next = queue.pop(); + + const { e, p, s, b } = next; + const { type, culture } = states[s]; + + cells.c[e].forEach((e) => { + const state = states[cells.state[e]]; + if (state.lock) return; // do not overwrite cell of locked states + if (cells.state[e] && e === state.center) return; // do not overwrite capital cells + + const cultureCost = culture === cells.culture[e] ? -9 : 100; + const populationCost = + cells.h[e] < 20 + ? 0 + : cells.s[e] + ? Math.max(20 - cells.s[e], 0) + : 5000; + const biomeCost = this.getBiomeCost(b, cells.biome[e], type); + const heightCost = this.getHeightCost( + pack.features[cells.f[e]], + cells.h[e], + type, + ); + const riverCost = this.getRiverCost(cells.r[e], e, type); + const typeCost = this.getTypeCost(cells.t[e], type); + const cellCost = Math.max( + cultureCost + + populationCost + + biomeCost + + heightCost + + riverCost + + typeCost, + 0, + ); + const totalCost = p + 10 + cellCost / states[s].expansionism; + + if (totalCost > growthRate) return; + + if (!cost[e] || totalCost < cost[e]) { + if (cells.h[e] >= 20) cells.state[e] = s; // assign state to cell + cost[e] = totalCost; + queue.push({ e, p: totalCost, s, b }, totalCost); + } + }); + } + + burgs + .filter((b) => b.i && !b.removed) + .forEach((b) => { + b.state = cells.state[b.cell]; // assign state to burgs + }); + TIME && console.timeEnd("expandStates"); + } + + normalize() { + TIME && console.time("normalizeStates"); + const { cells, burgs } = pack; + + for (const i of cells.i) { + if (cells.h[i] < 20 || cells.burg[i]) continue; // do not overwrite burgs + if (pack.states[cells.state[i]]?.lock) continue; // do not overwrite cells of locks states + if (cells.c[i].some((c) => burgs[cells.burg[c]].capital)) continue; // do not overwrite near capital + const neibs = cells.c[i].filter((c) => cells.h[c] >= 20); + const adversaries = neibs.filter( + (c) => + !pack.states[cells.state[c]]?.lock && + cells.state[c] !== cells.state[i], + ); + if (adversaries.length < 2) continue; + const buddies = neibs.filter( + (c) => + !pack.states[cells.state[c]]?.lock && + cells.state[c] === cells.state[i], + ); + if (buddies.length > 2) continue; + if (adversaries.length <= buddies.length) continue; + cells.state[i] = cells.state[adversaries[0]]; + } + TIME && console.timeEnd("normalizeStates"); + } + + // calculate pole of inaccessibility for each state + getPoles() { + const getType = (cellId: number) => pack.cells.state[cellId]; + const poles = getPolesOfInaccessibility(pack, getType); + + pack.states.forEach((s) => { + if (!s.i || s.removed) return; + s.pole = poles[s.i] || [0, 0]; + }); + } + + findNeighbors() { + const { cells, states } = pack; + + const stateNeighbors: Set[] = []; + + states.forEach((s) => { + if (s.removed) return; + stateNeighbors[s.i] = new Set(); + // s.neighbors = stateNeighbors[s.i]; + }); + + for (const i of cells.i) { + if (cells.h[i] < 20) continue; + const s = cells.state[i]; + + cells.c[i] + .filter((c) => cells.h[c] >= 20 && cells.state[c] !== s) + .forEach((c) => { + stateNeighbors[s].add(cells.state[c]); + }); + } + + // convert neighbors Set object into array + states.forEach((s) => { + if (!stateNeighbors[s.i] || s.removed) return; + s.neighbors = Array.from(stateNeighbors[s.i]); + }); + } + + assignColors() { + TIME && console.time("assignColors"); + const colors = [ + "#66c2a5", + "#fc8d62", + "#8da0cb", + "#e78ac3", + "#a6d854", + "#ffd92f", + ]; // d3.schemeSet2; + const states = pack.states; + + // assign basic color using greedy coloring algorithm + states.forEach((state) => { + if (!state.i || state.removed || state.lock) return; + state.color = colors.find((color) => + state.neighbors!.every( + (neibStateId) => states[neibStateId].color !== color, + ), + ); + if (!state.color) state.color = getRandomColor(); + colors.push(colors.shift() as string); + }); + + // randomize each already used color a bit + colors.forEach((c) => { + const sameColored = states.filter( + (state) => state.color === c && state.i && !state.lock, + ); + sameColored.forEach((state, index) => { + if (!index) return; + state.color = getMixedColor(state.color!); + }); + }); + + TIME && console.timeEnd("assignColors"); + } + + // calculate states data like area, population etc. + collectStatistics() { + TIME && console.time("collectStatistics"); + const { cells, states } = pack; + + states.forEach((s) => { + if (s.removed) return; + s.cells = s.area = s.burgs = s.rural = s.urban = 0; + }); + + for (const i of cells.i) { + if (cells.h[i] < 20) continue; + const s = cells.state[i]; + + // collect stats + states[s].cells! += 1; + states[s].area! += cells.area[i]; + states[s].rural! += cells.pop[i]; + if (cells.burg[i]) { + states[s].urban! += pack.burgs[cells.burg[i]].population!; + states[s].burgs!++; + } + } + + TIME && console.timeEnd("collectStatistics"); + } + + generateCampaign(state: State) { + const wars = { + War: 6, + Conflict: 2, + Campaign: 4, + Invasion: 2, + Rebellion: 2, + Conquest: 2, + Intervention: 1, + Expedition: 1, + Crusade: 1, + }; + const neighbors = state.neighbors?.length ? state.neighbors : [0]; + return neighbors + .map((i: number) => { + const name = + i && P(0.8) + ? pack.states[i].name + : Names.getCultureShort(state.culture); + const start = gauss(options.year - 100, 150, 1, options.year - 6); + const end = start + gauss(4, 5, 1, options.year - start - 1); + return { name: `${getAdjective(name)} ${rw(wars)}`, start, end }; + }) + .sort((a, b) => a.start - b.start); + } + + generateCampaigns() { + pack.states.forEach((s) => { + if (!s.i || s.removed) return; + s.campaigns = this.generateCampaign(s); + }); + } + + // generate Diplomatic Relationships + generateDiplomacy() { + TIME && console.time("generateDiplomacy"); + const { cells, states } = pack; + states[0].diplomacy = []; + // FIRST STATE IS ALWAYS NEUTRAL and contains the history of diplomacy + const chronicle = states[0].diplomacy; + const valid = states.filter((s) => s.i && !s.removed); // will filter out neutral as i is 0 => false + + const neibs = { Ally: 1, Friendly: 2, Neutral: 1, Suspicion: 10, Rival: 9 }; // relations to neighbors + const neibsOfNeibs = { Ally: 10, Friendly: 8, Neutral: 5, Suspicion: 1 }; // relations to neighbors of neighbors + const far = { Friendly: 1, Neutral: 12, Suspicion: 2, Unknown: 6 }; // relations to other + const navals = { Neutral: 1, Suspicion: 2, Rival: 1, Unknown: 1 }; // relations of naval powers + + valid.forEach((s) => { + s.diplomacy = new Array(states.length).fill("x"); // clear all relationships + }); + if (valid.length < 2) return; // no states to generate relations with + const areaMean: number = mean(valid.map((s) => s.area!)) as number; // average state area + + // generic relations + for (let f = 1; f < states.length; f++) { + if (states[f].removed) continue; + if (states[f].diplomacy!.includes("Vassal")) { + // Vassals copy relations from their Suzerains + const suzerain = states[f].diplomacy!.indexOf("Vassal"); + + for (let i = 1; i < states.length; i++) { + if (i === f || i === suzerain) continue; + states[f].diplomacy![i] = states[suzerain].diplomacy![i]; + if (states[suzerain].diplomacy![i] === "Suzerain") + states[f].diplomacy![i] = "Ally"; + for (let e = 1; e < states.length; e++) { + if (e === f || e === suzerain) continue; + if ( + states[e].diplomacy![suzerain] === "Suzerain" || + states[e].diplomacy![suzerain] === "Vassal" + ) + continue; + states[e].diplomacy![f] = states[e].diplomacy![suzerain]; + } + } + continue; + } + + for (let t = f + 1; t < states.length; t++) { + if (states[t].removed) continue; + + if (states[t].diplomacy!.includes("Vassal")) { + const suzerain = states[t].diplomacy!.indexOf("Vassal"); + states[f].diplomacy![t] = states[f].diplomacy![suzerain]; + continue; + } + + const naval = + states[f].type === "Naval" && + states[t].type === "Naval" && + cells.f[states[f].center] !== cells.f[states[t].center]; + const neib = naval ? false : states[f].neighbors!.includes(t); + const neibOfNeib = + naval || neib + ? false + : states[f] + .neighbors!.map((n) => states[n].neighbors) + .join("") + .includes(t.toString()); + + let status = naval + ? rw(navals) + : neib + ? rw(neibs) + : neibOfNeib + ? rw(neibsOfNeibs) + : rw(far); + + // add Vassal + if ( + neib && + P(0.8) && + states[f].area! > areaMean && + states[t].area! < areaMean && + states[f].area! / states[t].area! > 2 + ) + status = "Vassal"; + states[f].diplomacy![t] = status === "Vassal" ? "Suzerain" : status; + states[t].diplomacy![f] = status; + } + } + + // declare wars + for (let attacker = 1; attacker < states.length; attacker++) { + const ad = states[attacker].diplomacy as string[]; // attacker relations; + if (states[attacker].removed) continue; + if (!ad.includes("Rival")) continue; // no rivals to attack + if (ad.includes("Vassal")) continue; // not independent + if (ad.includes("Enemy")) continue; // already at war + + // random independent rival + const defender = ra( + ad + .map((r, d) => + r === "Rival" && !states[d].diplomacy!.includes("Vassal") ? d : 0, + ) + .filter((d) => d), + ); + let ap = states[attacker].area! * states[attacker].expansionism; + let dp = states[defender].area! * states[defender].expansionism; + if (ap < dp * gauss(1.6, 0.8, 0, 10, 2)) continue; // defender is too strong + + const an = states[attacker].name; + const dn = states[defender].name; // names + const attackers = [attacker]; + const defenders = [defender]; // attackers and defenders array + const dd = states[defender].diplomacy as string[]; // defender relations; + + // start an ongoing war + const name = `${an}-${trimVowels(dn)}ian War`; + const start = options.year - gauss(2, 3, 0, 10); + const war = [name, `${an} declared a war on its rival ${dn}`]; + const campaign = { name, start, attacker, defender }; + states[attacker].campaigns!.push(campaign); + states[defender].campaigns!.push(campaign); + + // attacker vassals join the war + ad.forEach((r, d) => { + if (r === "Suzerain") { + attackers.push(d); + war.push( + `${an}'s vassal ${states[d].name} joined the war on attackers side`, + ); + } + }); + + // defender vassals join the war + dd.forEach((r, d) => { + if (r === "Suzerain") { + defenders.push(d); + war.push( + `${dn}'s vassal ${states[d].name} joined the war on defenders side`, + ); + } + }); + + ap = sum(attackers.map((a) => states[a].area! * states[a].expansionism)); // attackers joined power + dp = sum(defenders.map((d) => states[d].area! * states[d].expansionism)); // defender joined power + + // defender allies join + dd.forEach((r, d) => { + if (r !== "Ally" || states[d].diplomacy!.includes("Vassal")) return; + if ( + states[d].diplomacy![attacker] !== "Rival" && + ap / dp > 2 * gauss(1.6, 0.8, 0, 10, 2) + ) { + const reason = states[d].diplomacy!.includes("Enemy") + ? "Being already at war," + : `Frightened by ${an},`; + war.push( + `${reason} ${states[d].name} severed the defense pact with ${dn}`, + ); + dd[d] = states[d].diplomacy![defender] = "Suspicion"; + return; + } + defenders.push(d); + dp += states[d].area! * states[d].expansionism; + war.push( + `${dn}'s ally ${states[d].name} joined the war on defenders side`, + ); + + // ally vassals join + states[d] + .diplomacy!.map((r, d) => (r === "Suzerain" ? d : 0)) + .filter((d) => d) + .forEach((v) => { + defenders.push(v); + dp += states[v].area! * states[v].expansionism; + war.push( + `${states[d].name}'s vassal ${states[v].name} joined the war on defenders side`, + ); + }); + }); + + // attacker allies join if the defender is their rival or joined power > defenders power and defender is not an ally + ad.forEach((r, d) => { + if ( + r !== "Ally" || + states[d].diplomacy!.includes("Vassal") || + defenders.includes(d) + ) + return; + const name = states[d].name; + if ( + states[d].diplomacy![defender] !== "Rival" && + (P(0.2) || ap <= dp * 1.2) + ) { + war.push(`${an}'s ally ${name} avoided entering the war`); + return; + } + const allies = states[d] + .diplomacy!.map((r, d) => (r === "Ally" ? d : 0)) + .filter((d) => d); + if (allies.some((ally) => defenders.includes(ally))) { + war.push( + `${an}'s ally ${name} did not join the war as its allies are in war on both sides`, + ); + return; + } + + attackers.push(d); + ap += states[d].area! * states[d].expansionism; + war.push(`${an}'s ally ${name} joined the war on attackers side`); + + // ally vassals join + states[d] + .diplomacy!.map((r, d) => (r === "Suzerain" ? d : 0)) + .filter((d) => d) + .forEach((v) => { + attackers.push(v); + // TODO: I think here is a bug, it should be ap instead of dp + ap += states[v].area! * states[v].expansionism; + war.push( + `${states[d].name}'s vassal ${states[v].name} joined the war on attackers side`, + ); + }); + }); + + // change relations to Enemy for all participants + attackers.forEach((a) => { + defenders.forEach((d: number) => { + states[a].diplomacy![d] = states[d].diplomacy![a] = "Enemy"; + }); + }); + // TODO: record war in chronicle to keep state interface clean + chronicle.push(war as any); // add a record to diplomatical history + } + TIME && console.timeEnd("generateDiplomacy"); + } + + // select a forms for listed or all valid states + defineStateForms(list: number[] | null = null) { + TIME && console.time("defineStateForms"); + const states = pack.states.filter((s) => s.i && !s.removed && !s.lock); + if (states.length < 1) return; + + const generic = { Monarchy: 25, Republic: 2, Union: 1 }; + const naval = { Monarchy: 25, Republic: 8, Union: 3 }; + + const medianState = median(pack.states.map((s) => s.area))!; + const empireMin = states.map((s) => s.area).sort((a = 0, b = 0) => b - a)[ + Math.max(Math.ceil(states.length ** 0.4) - 2, 0) + ]!; + const expTiers = pack.states.map((s) => { + let tier = Math.min(Math.floor((s.area! / medianState) * 2.6), 4); + if (tier === 4 && s.area! < empireMin) tier = 3; + return tier; + }); + + const monarchy = [ + "Duchy", + "Grand Duchy", + "Principality", + "Kingdom", + "Empire", + ]; // per expansionism tier + const republic = { + Republic: 75, + Federation: 4, + "Trade Company": 4, + "Most Serene Republic": 2, + Oligarchy: 2, + Tetrarchy: 1, + Triumvirate: 1, + Diarchy: 1, + Junta: 1, + }; // weighted random + const union = { + Union: 3, + League: 4, + Confederation: 1, + "United Kingdom": 1, + "United Republic": 1, + "United Provinces": 2, + Commonwealth: 1, + Heptarchy: 1, + }; // weighted random + const theocracy = { + Theocracy: 20, + Brotherhood: 1, + Thearchy: 2, + See: 1, + "Holy State": 1, + }; + const anarchy = { + "Free Territory": 2, + Council: 3, + Commune: 1, + Community: 1, + }; + + for (const s of states) { + if (list && !list.includes(s.i)) continue; + const tier = expTiers[s.i]; + + const religion = pack.cells.religion[s.center]; + const isTheocracy = + (religion && pack.religions[religion].expansion === "state") || + (P(0.1) && + ["Organized", "Cult"].includes(pack.religions[religion].type)); + const isAnarchy = P(0.01 - tier / 500); + + if (isTheocracy) s.form = "Theocracy"; + else if (isAnarchy) s.form = "Anarchy"; + else s.form = s.type === "Naval" ? rw(naval) : rw(generic); + + const selectForm = (s: any, tier: number) => { + const base = pack.cultures[s.culture].base; + + if (s.form === "Monarchy") { + const form = monarchy[tier]; + // Default name depends on exponent tier, some culture bases have special names for tiers + if (s.diplomacy) { + if ( + form === "Duchy" && + s.neighbors.length > 1 && + rand(6) < s.neighbors.length && + s.diplomacy.includes("Vassal") + ) + return "Marches"; // some vassal duchies on borderland + if (base === 1 && P(0.3) && s.diplomacy.includes("Vassal")) + return "Dominion"; // English vassals + if (P(0.3) && s.diplomacy.includes("Vassal")) return "Protectorate"; // some vassals + } + + if (base === 31 && (form === "Empire" || form === "Kingdom")) + return "Khanate"; // Mongolian + if (base === 16 && form === "Principality") return "Beylik"; // Turkic + if (base === 5 && (form === "Empire" || form === "Kingdom")) + return "Tsardom"; // Ruthenian + if (base === 16 && (form === "Empire" || form === "Kingdom")) + return "Khaganate"; // Turkic + if (base === 12 && (form === "Kingdom" || form === "Grand Duchy")) + return "Shogunate"; // Japanese + if ([18, 17].includes(base) && form === "Empire") return "Caliphate"; // Arabic, Berber + if (base === 18 && (form === "Grand Duchy" || form === "Duchy")) + return "Emirate"; // Arabic + if (base === 7 && (form === "Grand Duchy" || form === "Duchy")) + return "Despotate"; // Greek + if (base === 31 && (form === "Grand Duchy" || form === "Duchy")) + return "Ulus"; // Mongolian + if (base === 16 && (form === "Grand Duchy" || form === "Duchy")) + return "Horde"; // Turkic + if (base === 24 && (form === "Grand Duchy" || form === "Duchy")) + return "Satrapy"; // Iranian + return form; + } + + if (s.form === "Republic") { + // Default name is from weighted array, special case for small states with only 1 burg + if (tier < 2 && s.burgs === 1) { + if ( + trimVowels(s.name) === trimVowels(pack.burgs[s.capital].name!) + ) { + s.name = pack.burgs[s.capital].name; + return "Free City"; + } + if (P(0.3)) return "City-state"; + } + return rw(republic); + } + + if (s.form === "Union") return rw(union); + if (s.form === "Anarchy") return rw(anarchy); + + if (s.form === "Theocracy") { + // European + if ([0, 1, 2, 3, 4, 6, 8, 9, 13, 15, 20].includes(base)) { + if (P(0.1)) return `Divine ${monarchy[tier]}`; + if (tier < 2 && P(0.5)) return "Diocese"; + if (tier < 2 && P(0.5)) return "Bishopric"; + } + if (P(0.9) && [7, 5].includes(base)) { + // Greek, Ruthenian + if (tier < 2) return "Eparchy"; + if (tier === 2) return "Exarchate"; + if (tier > 2) return "Patriarchate"; + } + if (P(0.9) && [21, 16].includes(base)) return "Imamah"; // Nigerian, Turkish + if (tier > 2 && P(0.8) && [18, 17, 28].includes(base)) + return "Caliphate"; // Arabic, Berber, Swahili + return rw(theocracy); + } + }; + + s.formName = selectForm(s, tier); + s.fullName = this.getFullName(s); + } + + TIME && console.timeEnd("defineStateForms"); + } + + getFullName(state: State) { + // state forms requiring Adjective + Name, all other forms use scheme Form + Of + Name + const adjForms = [ + "Empire", + "Sultanate", + "Khaganate", + "Shogunate", + "Caliphate", + "Despotate", + "Theocracy", + "Oligarchy", + "Union", + "Confederation", + "Trade Company", + "League", + "Tetrarchy", + "Triumvirate", + "Diarchy", + "Horde", + "Marches", + ]; + if (!state.formName) return state.name; + if (!state.name && state.formName) return `The ${state.formName}`; + const adjName = + adjForms.includes(state.formName) && !/-| /.test(state.name); + return adjName + ? `${getAdjective(state.name)} ${state.formName}` + : `${state.formName} of ${state.name}`; + } +} + +window.States = new StatesModule(); diff --git a/src/modules/voronoi.ts b/src/modules/voronoi.ts index 55ac77ab..adceaaa6 100644 --- a/src/modules/voronoi.ts +++ b/src/modules/voronoi.ts @@ -1,6 +1,11 @@ -import Delaunator from "delaunator"; -export type Vertices = { p: Point[], v: number[][], c: number[][] }; -export type Cells = { v: number[][], c: number[][], b: number[], i: Uint32Array } ; +import type Delaunator from "delaunator"; +export type Vertices = { p: Point[]; v: number[][]; c: number[][] }; +export type Cells = { + v: number[][]; + c: number[][]; + b: number[]; + i: Uint32Array; +}; export type Point = [number, number]; /** @@ -11,36 +16,41 @@ export type Point = [number, number]; * @param {number} pointsN The number of points. */ export class Voronoi { - delaunay: Delaunator> + delaunay: Delaunator>; points: Point[]; pointsN: number; cells: Cells = { v: [], c: [], b: [], i: new Uint32Array() }; // voronoi cells: v = cell vertices, c = adjacent cells, b = near-border cell, i = cell indexes; vertices: Vertices = { p: [], v: [], c: [] }; // cells vertices: p = vertex coordinates, v = neighboring vertices, c = adjacent cells - - constructor(delaunay: Delaunator>, points: Point[], pointsN: number) { + + constructor( + delaunay: Delaunator>, + points: Point[], + pointsN: number, + ) { this.delaunay = delaunay; this.points = points; this.pointsN = pointsN; - this.vertices + this.vertices; // Half-edges are the indices into the delaunator outputs: // delaunay.triangles[e] gives the point ID where the half-edge starts // delaunay.halfedges[e] returns either the opposite half-edge in the adjacent triangle, or -1 if there's not an adjacent triangle. for (let e = 0; e < this.delaunay.triangles.length; e++) { - const p = this.delaunay.triangles[this.nextHalfedge(e)]; if (p < this.pointsN && !this.cells.c[p]) { const edges = this.edgesAroundPoint(e); - this.cells.v[p] = edges.map(e => this.triangleOfEdge(e)); // cell: adjacent vertex - this.cells.c[p] = edges.map(e => this.delaunay.triangles[e]).filter(c => c < this.pointsN); // cell: adjacent valid cells - this.cells.b[p] = edges.length > this.cells.c[p].length ? 1 : 0; // cell: is border + this.cells.v[p] = edges.map((e) => this.triangleOfEdge(e)); // cell: adjacent vertex + this.cells.c[p] = edges + .map((e) => this.delaunay.triangles[e]) + .filter((c) => c < this.pointsN); // cell: adjacent valid cells + this.cells.b[p] = edges.length > this.cells.c[p].length ? 1 : 0; // cell: is border } const t = this.triangleOfEdge(e); if (!this.vertices.p[t]) { - this.vertices.p[t] = this.triangleCenter(t); // vertex: coordinates + this.vertices.p[t] = this.triangleCenter(t); // vertex: coordinates this.vertices.v[t] = this.trianglesAdjacentToTriangle(t); // vertex: adjacent vertices - this.vertices.c[t] = this.pointsOfTriangle(t); // vertex: adjacent cells + this.vertices.c[t] = this.pointsOfTriangle(t); // vertex: adjacent cells } } } @@ -51,7 +61,9 @@ export class Voronoi { * @returns {[number, number, number]} The IDs of the points comprising the given triangle. */ private pointsOfTriangle(triangleIndex: number): [number, number, number] { - return this.edgesOfTriangle(triangleIndex).map(edge => this.delaunay.triangles[edge]) as [number, number, number]; + return this.edgesOfTriangle(triangleIndex).map( + (edge) => this.delaunay.triangles[edge], + ) as [number, number, number]; } /** @@ -60,9 +72,9 @@ export class Voronoi { * @returns {number[]} The indices of the triangles that share half-edges with this triangle. */ private trianglesAdjacentToTriangle(triangleIndex: number): number[] { - let triangles = []; - for (let edge of this.edgesOfTriangle(triangleIndex)) { - let opposite = this.delaunay.halfedges[edge]; + const triangles = []; + for (const edge of this.edgesOfTriangle(triangleIndex)) { + const opposite = this.delaunay.halfedges[edge]; triangles.push(this.triangleOfEdge(opposite)); } return triangles; @@ -90,7 +102,9 @@ export class Voronoi { * @returns {[number, number]} The coordinates of the triangle's circumcenter. */ private triangleCenter(triangleIndex: number): Point { - let vertices = this.pointsOfTriangle(triangleIndex).map(p => this.points[p]); + const vertices = this.pointsOfTriangle(triangleIndex).map( + (p) => this.points[p], + ); return this.circumcenter(vertices[0], vertices[1], vertices[2]); } @@ -99,21 +113,27 @@ export class Voronoi { * @param {number} triangleIndex The index of the triangle * @returns {[number, number, number]} The edges of the triangle. */ - private edgesOfTriangle(triangleIndex: number): [number, number, number] { return [3 * triangleIndex, 3 * triangleIndex + 1, 3 * triangleIndex + 2]; } + private edgesOfTriangle(triangleIndex: number): [number, number, number] { + return [3 * triangleIndex, 3 * triangleIndex + 1, 3 * triangleIndex + 2]; + } /** * Enables lookup of a triangle, given one of the half-edges of that triangle. Taken from {@link https://mapbox.github.io/delaunator/#edge-and-triangle| the Delaunator docs.} * @param {number} e The index of the edge * @returns {number} The index of the triangle */ - private triangleOfEdge(e: number): number { return Math.floor(e / 3); } + private triangleOfEdge(e: number): number { + return Math.floor(e / 3); + } /** * Moves to the next half-edge of a triangle, given the current half-edge's index. Taken from {@link https://mapbox.github.io/delaunator/#edge-to-edges| the Delaunator docs.} * @param {number} e The index of the current half edge * @returns {number} The index of the next half edge */ - private nextHalfedge(e: number): number { return (e % 3 === 2) ? e - 2 : e + 1; } + private nextHalfedge(e: number): number { + return e % 3 === 2 ? e - 2 : e + 1; + } /** * Moves to the previous half-edge of a triangle, given the current half-edge's index. Taken from {@link https://mapbox.github.io/delaunator/#edge-to-edges| the Delaunator docs.} @@ -138,8 +158,8 @@ export class Voronoi { const cd = cx * cx + cy * cy; const D = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)); return [ - Math.floor(1 / D * (ad * (by - cy) + bd * (cy - ay) + cd * (ay - by))), - Math.floor(1 / D * (ad * (cx - bx) + bd * (ax - cx) + cd * (bx - ax))) + Math.floor((1 / D) * (ad * (by - cy) + bd * (cy - ay) + cd * (ay - by))), + Math.floor((1 / D) * (ad * (cx - bx) + bd * (ax - cx) + cd * (bx - ax))), ]; } -} \ No newline at end of file +} diff --git a/src/modules/zones-generator.ts b/src/modules/zones-generator.ts new file mode 100644 index 00000000..bef9ad9b --- /dev/null +++ b/src/modules/zones-generator.ts @@ -0,0 +1,668 @@ +import { max, mean } from "d3"; +import { gauss, getAdjective, P, ra, rand, rw } from "../utils"; + +declare global { + var Zones: ZonesModule; +} + +export interface Zone { + i: number; + name: string; + type: string; + cells: number[]; + color: string; +} + +type ZoneGenerator = (usedCells: Uint8Array) => void; + +interface ZoneConfig { + quantity: number; + generate: ZoneGenerator; +} + +class ZonesModule { + private config: Record; + + constructor() { + this.config = { + invasion: { quantity: 2, generate: (u) => this.addInvasion(u) }, + rebels: { quantity: 1.5, generate: (u) => this.addRebels(u) }, + proselytism: { quantity: 1.6, generate: (u) => this.addProselytism(u) }, + crusade: { quantity: 1.6, generate: (u) => this.addCrusade(u) }, + disease: { quantity: 1.4, generate: (u) => this.addDisease(u) }, + disaster: { quantity: 1, generate: (u) => this.addDisaster(u) }, + eruption: { quantity: 1, generate: (u) => this.addEruption(u) }, + avalanche: { quantity: 0.8, generate: (u) => this.addAvalanche(u) }, + fault: { quantity: 1, generate: (u) => this.addFault(u) }, + flood: { quantity: 1, generate: (u) => this.addFlood(u) }, + tsunami: { quantity: 1, generate: (u) => this.addTsunami(u) }, + }; + } + + generate(globalModifier = 1) { + TIME && console.time("generateZones"); + + const usedCells = new Uint8Array(pack.cells.i.length); + pack.zones = []; + + Object.values(this.config).forEach((type) => { + const expectedNumber = type.quantity * globalModifier; + let number = gauss(expectedNumber, expectedNumber / 2, 0, 100); + while (number--) type.generate(usedCells); + }); + + TIME && console.timeEnd("generateZones"); + } + + private addInvasion(usedCells: Uint8Array) { + const { cells, states } = pack; + + const ongoingConflicts = states + .filter((s) => s.i && !s.removed && s.campaigns) + .flatMap((s) => s.campaigns!) + .filter((c) => !c.end); + if (!ongoingConflicts.length) return; + const { defender, attacker } = ra(ongoingConflicts); + + const borderCells = cells.i.filter((cellId) => { + if (usedCells[cellId]) return false; + if (cells.state[cellId] !== defender) return false; + return cells.c[cellId].some((c) => cells.state[c] === attacker); + }); + + const startCell = ra(borderCells); + if (startCell === undefined) return; + + const invasionCells: number[] = []; + const queue = [startCell]; + const maxCells = rand(5, 30); + + while (queue.length) { + const cellId = P(0.4) ? queue.shift()! : queue.pop()!; + invasionCells.push(cellId); + if (invasionCells.length >= maxCells) break; + + cells.c[cellId].forEach((neibCellId) => { + if (usedCells[neibCellId]) return; + if (cells.state[neibCellId] !== defender) return; + usedCells[neibCellId] = 1; + queue.push(neibCellId); + }); + } + + const subtype = rw({ + Invasion: 5, + Occupation: 4, + Conquest: 3, + Incursion: 2, + Intervention: 2, + Assault: 1, + Foray: 1, + Intrusion: 1, + Irruption: 1, + Offensive: 1, + Pillaging: 1, + Plunder: 1, + Raid: 1, + Skirmishes: 1, + }); + const name = `${getAdjective(states[attacker].name)} ${subtype}`; + + pack.zones.push({ + i: pack.zones.length, + name, + type: "Invasion", + cells: invasionCells, + color: "url(#hatch1)", + }); + } + + private addRebels(usedCells: Uint8Array) { + const { cells, states } = pack; + + const state = ra( + states.filter((s) => s.i && !s.removed && s.neighbors?.some(Boolean)), + ); + if (!state) return; + + const neibStateId = ra( + state.neighbors!.filter((n: number) => n && !states[n].removed), + ); + if (!neibStateId) return; + + const cellsArray: number[] = []; + const queue: number[] = []; + const borderCellId = cells.i.find( + (i) => + cells.state[i] === state.i && + cells.c[i].some((c) => cells.state[c] === neibStateId), + ); + if (borderCellId) queue.push(borderCellId); + const maxCells = rand(10, 30); + + while (queue.length) { + const cellId = queue.shift()!; + cellsArray.push(cellId); + if (cellsArray.length >= maxCells) break; + + cells.c[cellId].forEach((neibCellId) => { + if (usedCells[neibCellId]) return; + if (cells.state[neibCellId] !== state.i) return; + usedCells[neibCellId] = 1; + if ( + neibCellId % 4 !== 0 && + !cells.c[neibCellId].some((c) => cells.state[c] === neibStateId) + ) + return; + queue.push(neibCellId); + }); + } + + const rebels = rw({ + Rebels: 5, + Insurrection: 2, + Mutineers: 1, + Insurgents: 1, + Rebellion: 1, + Renegades: 1, + Revolters: 1, + Revolutionaries: 1, + Rioters: 1, + Separatists: 1, + Secessionists: 1, + Conspiracy: 1, + }); + + const name = `${getAdjective(states[neibStateId].name)} ${rebels}`; + pack.zones.push({ + i: pack.zones.length, + name, + type: "Rebels", + cells: cellsArray, + color: "url(#hatch3)", + }); + } + + private addProselytism(usedCells: Uint8Array) { + const { cells, religions } = pack; + + const organizedReligions = religions.filter( + (r) => r.i && !r.removed && r.type === "Organized", + ); + const religion = ra(organizedReligions); + if (!religion) return; + + const targetBorderCells = cells.i.filter( + (i) => + cells.h[i] >= 20 && + cells.pop[i] && + cells.religion[i] !== religion.i && + cells.c[i].some((c) => cells.religion[c] === religion.i), + ); + const startCell = ra(targetBorderCells); + if (!startCell) return; + + const targetReligionId = cells.religion[startCell]; + const proselytismCells: number[] = []; + const queue = [startCell]; + const maxCells = rand(10, 30); + + while (queue.length) { + const cellId = queue.shift()!; + proselytismCells.push(cellId); + if (proselytismCells.length >= maxCells) break; + + cells.c[cellId].forEach((neibCellId) => { + if (usedCells[neibCellId]) return; + if (cells.religion[neibCellId] !== targetReligionId) return; + if (cells.h[neibCellId] < 20 || !cells.pop[neibCellId]) return; + usedCells[neibCellId] = 1; + queue.push(neibCellId); + }); + } + + const name = `${getAdjective(religion.name.split(" ")[0])} Proselytism`; + pack.zones.push({ + i: pack.zones.length, + name, + type: "Proselytism", + cells: proselytismCells, + color: "url(#hatch6)", + }); + } + + private addCrusade(usedCells: Uint8Array) { + const { cells, religions } = pack; + + const heresies = religions.filter((r) => !r.removed && r.type === "Heresy"); + if (!heresies.length) return; + + const heresy = ra(heresies); + const crusadeCells = cells.i.filter( + (i) => !usedCells[i] && cells.religion[i] === heresy.i, + ); + if (!crusadeCells.length) return; + for (const i of crusadeCells) { + usedCells[i] = 1; + } + + const name = `${getAdjective(heresy.name.split(" ")[0])} Crusade`; + pack.zones.push({ + i: pack.zones.length, + name, + type: "Crusade", + cells: Array.from(crusadeCells), + color: "url(#hatch6)", + }); + } + + private addDisease(usedCells: Uint8Array) { + const { cells, burgs } = pack; + + const burg = ra( + burgs.filter((b) => !usedCells[b.cell] && b.i && !b.removed), + ); + if (!burg) return; + + const cellsArray: number[] = []; + const cost: number[] = []; + const maxCells = rand(20, 40); + + const queue = new FlatQueue(); + queue.push({ e: burg.cell, p: 0 }, 0); + + while (queue.length) { + const next = queue.pop(); + if (cells.burg[next.e] || cells.pop[next.e]) cellsArray.push(next.e); + usedCells[next.e] = 1; + + cells.c[next.e].forEach((nextCellId) => { + const c = Routes.getRoute(next.e, nextCellId) ? 5 : 100; + const p = next.p + c; + if (p > maxCells) return; + + if (!cost[nextCellId] || p < cost[nextCellId]) { + cost[nextCellId] = p; + queue.push({ e: nextCellId, p }, p); + } + }); + } + + const colorName = this.getDiseaseName("color"); + const animalName = this.getDiseaseName("animal"); + const adjectiveName = this.getDiseaseName("adjective"); + + const model = rw({ color: 2, animal: 1, adjective: 1 }); + const prefix = + model === "color" + ? colorName + : model === "animal" + ? animalName + : adjectiveName; + + const disease = rw({ + Fever: 5, + Plague: 3, + Cough: 3, + Flu: 2, + Pox: 2, + Cholera: 2, + Typhoid: 2, + Leprosy: 1, + Smallpox: 1, + Pestilence: 1, + Consumption: 1, + Malaria: 1, + Dropsy: 1, + }); + const name = `${prefix} ${disease}`; + + pack.zones.push({ + i: pack.zones.length, + name, + type: "Disease", + cells: cellsArray, + color: "url(#hatch12)", + }); + } + + private getDiseaseName(model: "color" | "animal" | "adjective"): string { + if (model === "color") + return ra([ + "Amber", + "Azure", + "Black", + "Blue", + "Brown", + "Crimson", + "Emerald", + "Golden", + "Green", + "Grey", + "Orange", + "Pink", + "Purple", + "Red", + "Ruby", + "Scarlet", + "Silver", + "Violet", + "White", + "Yellow", + ]); + if (model === "animal") + return ra([ + "Ape", + "Bear", + "Bird", + "Boar", + "Cat", + "Cow", + "Deer", + "Dog", + "Fox", + "Goat", + "Horse", + "Lion", + "Pig", + "Rat", + "Raven", + "Sheep", + "Spider", + "Tiger", + "Viper", + "Wolf", + "Worm", + "Wyrm", + ]); + return ra([ + "Blind", + "Bloody", + "Brutal", + "Burning", + "Deadly", + "Fatal", + "Furious", + "Great", + "Grim", + "Horrible", + "Invisible", + "Lethal", + "Loud", + "Mortal", + "Savage", + "Severe", + "Silent", + "Unknown", + "Venomous", + "Vicious", + ]); + } + + private addDisaster(usedCells: Uint8Array) { + const { cells, burgs } = pack; + + const burg = ra( + burgs.filter((b) => !usedCells[b.cell] && b.i && !b.removed), + ); + if (!burg) return; + usedCells[burg.cell] = 1; + + const cellsArray: number[] = []; + const cost: number[] = []; + const maxCells = rand(5, 25); + + const queue = new FlatQueue(); + queue.push({ e: burg.cell, p: 0 }, 0); + + while (queue.length) { + const next = queue.pop(); + if (cells.burg[next.e] || cells.pop[next.e]) cellsArray.push(next.e); + usedCells[next.e] = 1; + + cells.c[next.e].forEach((e) => { + const c = rand(1, 10); + const p = next.p + c; + if (p > maxCells) return; + + if (!cost[e] || p < cost[e]) { + cost[e] = p; + queue.push({ e, p }, p); + } + }); + } + + const type = rw({ + Famine: 5, + Drought: 3, + Earthquake: 3, + Dearth: 1, + Tornadoes: 1, + Wildfires: 1, + Storms: 1, + Blight: 1, + }); + const name = `${getAdjective(burg.name!)} ${type}`; + pack.zones.push({ + i: pack.zones.length, + name, + type: "Disaster", + cells: cellsArray, + color: "url(#hatch5)", + }); + } + + private addEruption(usedCells: Uint8Array) { + const { cells, markers } = pack; + + const volcanoe = markers.find( + (m) => m.type === "volcanoes" && !usedCells[m.cell], + ); + if (!volcanoe) return; + usedCells[volcanoe.cell] = 1; + + const note = notes.find((n) => n.id === `marker${volcanoe.i}`); + if (note) + note.legend = note.legend.replace("Active volcano", "Erupting volcano"); + const name = note + ? `${note.name.replace(" Volcano", "")} Eruption` + : "Volcano Eruption"; + + const cellsArray: number[] = []; + const queue = [volcanoe.cell]; + const maxCells = rand(10, 30); + + while (queue.length) { + const cellId = P(0.5) ? queue.shift()! : queue.pop()!; + cellsArray.push(cellId); + if (cellsArray.length >= maxCells) break; + + cells.c[cellId].forEach((neibCellId) => { + if (usedCells[neibCellId] || cells.h[neibCellId] < 20) return; + usedCells[neibCellId] = 1; + queue.push(neibCellId); + }); + } + + pack.zones.push({ + i: pack.zones.length, + name, + type: "Eruption", + cells: cellsArray, + color: "url(#hatch7)", + }); + } + + private addAvalanche(usedCells: Uint8Array) { + const { cells } = pack; + + const routeCells = cells.i.filter( + (i) => !usedCells[i] && Routes.isConnected(i) && cells.h[i] >= 70, + ); + if (!routeCells.length) return; + + const startCell = ra(routeCells); + usedCells[startCell] = 1; + + const cellsArray: number[] = []; + const queue = [startCell]; + const maxCells = rand(3, 15); + + while (queue.length) { + const cellId = P(0.3) ? queue.shift()! : queue.pop()!; + cellsArray.push(cellId); + if (cellsArray.length >= maxCells) break; + + cells.c[cellId].forEach((neibCellId) => { + if (usedCells[neibCellId] || cells.h[neibCellId] < 65) return; + usedCells[neibCellId] = 1; + queue.push(neibCellId); + }); + } + + const name = `${getAdjective(Names.getCultureShort(cells.culture[startCell]))} Avalanche`; + pack.zones.push({ + i: pack.zones.length, + name, + type: "Avalanche", + cells: cellsArray, + color: "url(#hatch5)", + }); + } + + private addFault(usedCells: Uint8Array) { + const cells = pack.cells; + + const elevatedCells = cells.i.filter( + (i) => !usedCells[i] && cells.h[i] > 50 && cells.h[i] < 70, + ); + if (!elevatedCells.length) return; + + const startCell = ra(elevatedCells); + usedCells[startCell] = 1; + + const cellsArray: number[] = []; + const queue = [startCell]; + const maxCells = rand(3, 15); + + while (queue.length) { + const cellId = queue.pop()!; + if (cells.h[cellId] >= 20) cellsArray.push(cellId); + if (cellsArray.length >= maxCells) break; + + cells.c[cellId].forEach((neibCellId) => { + if (usedCells[neibCellId] || cells.r[neibCellId]) return; + usedCells[neibCellId] = 1; + queue.push(neibCellId); + }); + } + + const name = `${getAdjective(Names.getCultureShort(cells.culture[startCell]))} Fault`; + pack.zones.push({ + i: pack.zones.length, + name, + type: "Fault", + cells: cellsArray, + color: "url(#hatch2)", + }); + } + + private addFlood(usedCells: Uint8Array) { + const cells = pack.cells; + + const fl = cells.fl.filter(Boolean); + const meanFlux = mean(fl) ?? 0; + const maxFlux = max(fl) ?? 0; + const fluxThreshold = (maxFlux - meanFlux) / 2 + meanFlux; + + const bigRiverCells = cells.i.filter( + (i) => + !usedCells[i] && + cells.h[i] < 50 && + cells.r[i] && + cells.fl[i] > fluxThreshold && + cells.burg[i], + ); + if (!bigRiverCells.length) return; + + const startCell = ra(bigRiverCells); + usedCells[startCell] = 1; + + const riverId = cells.r[startCell]; + const cellsArray: number[] = []; + const queue = [startCell]; + const maxCells = rand(5, 30); + + while (queue.length) { + const cellId = queue.pop()!; + cellsArray.push(cellId); + if (cellsArray.length >= maxCells) break; + + cells.c[cellId].forEach((neibCellId) => { + if ( + usedCells[neibCellId] || + cells.h[neibCellId] < 20 || + cells.r[neibCellId] !== riverId || + cells.h[neibCellId] > 50 || + cells.fl[neibCellId] < meanFlux + ) + return; + usedCells[neibCellId] = 1; + queue.push(neibCellId); + }); + } + + const name = `${getAdjective(pack.burgs[cells.burg[startCell]].name!)} Flood`; + pack.zones.push({ + i: pack.zones.length, + name, + type: "Flood", + cells: cellsArray, + color: "url(#hatch13)", + }); + } + + private addTsunami(usedCells: Uint8Array) { + const { cells, features } = pack; + + const coastalCells = cells.i.filter( + (i) => + !usedCells[i] && + cells.t[i] === -1 && + features[cells.f[i]].type !== "lake", + ); + if (!coastalCells.length) return; + + const startCell = ra(coastalCells); + usedCells[startCell] = 1; + + const cellsArray: number[] = []; + const queue = [startCell]; + const maxCells = rand(10, 30); + + while (queue.length) { + const cellId = queue.shift()!; + if (cells.t[cellId] === 1) cellsArray.push(cellId); + if (cellsArray.length >= maxCells) break; + + cells.c[cellId].forEach((neibCellId) => { + if (usedCells[neibCellId]) return; + if (cells.t[neibCellId] > 2) return; + if (pack.features[cells.f[neibCellId]].type === "lake") return; + usedCells[neibCellId] = 1; + queue.push(neibCellId); + }); + } + + const name = `${getAdjective(Names.getCultureShort(cells.culture[startCell]))} Tsunami`; + pack.zones.push({ + i: pack.zones.length, + name, + type: "Tsunami", + cells: cellsArray, + color: "url(#hatch13)", + }); + } +} + +window.Zones = new ZonesModule(); diff --git a/src/renderers/draw-borders.ts b/src/renderers/draw-borders.ts new file mode 100644 index 00000000..0c78dd69 --- /dev/null +++ b/src/renderers/draw-borders.ts @@ -0,0 +1,181 @@ +declare global { + var drawBorders: () => void; +} + +const bordersRenderer = () => { + TIME && console.time("drawBorders"); + const { cells, vertices } = pack; + + const statePath: string[] = []; + const provincePath: string[] = []; + const checked: { [key: string]: boolean } = {}; + + const isLand = (cellId: number) => cells.h[cellId] >= 20; + + for (let cellId = 0; cellId < cells.i.length; cellId++) { + if (!cells.state[cellId]) continue; + const provinceId = cells.province[cellId]; + const stateId = cells.state[cellId]; + + // bordering cell of another province + if (provinceId) { + const provToCell = cells.c[cellId].find((neibId) => { + const neibProvinceId = cells.province[neibId]; + return ( + neibProvinceId && + provinceId > neibProvinceId && + !checked[`prov-${provinceId}-${neibProvinceId}-${cellId}`] && + cells.state[neibId] === stateId + ); + }); + + if (provToCell !== undefined) { + const addToChecked = (cellId: number) => { + checked[ + `prov-${provinceId}-${cells.province[provToCell]}-${cellId}` + ] = true; + }; + const border = getBorder({ + type: "province", + fromCell: cellId, + toCell: provToCell, + addToChecked, + }); + + if (border) { + provincePath.push(border); + cellId--; // check the same cell again + continue; + } + } + } + + // if cell is on state border + const stateToCell = cells.c[cellId].find((neibId) => { + const neibStateId = cells.state[neibId]; + return ( + isLand(neibId) && + stateId > neibStateId && + !checked[`state-${stateId}-${neibStateId}-${cellId}`] + ); + }); + + if (stateToCell !== undefined) { + const addToChecked = (cellId: number) => { + checked[`state-${stateId}-${cells.state[stateToCell]}-${cellId}`] = + true; + }; + const border = getBorder({ + type: "state", + fromCell: cellId, + toCell: stateToCell, + addToChecked, + }); + + if (border) { + statePath.push(border); + cellId--; // check the same cell again + } + } + } + + svg.select("#borders").selectAll("path").remove(); + svg.select("#stateBorders").append("path").attr("d", statePath.join(" ")); + svg + .select("#provinceBorders") + .append("path") + .attr("d", provincePath.join(" ")); + + function getBorder({ + type, + fromCell, + toCell, + addToChecked, + }: { + type: "state" | "province"; + fromCell: number; + toCell: number; + addToChecked: (cellId: number) => void; + }): string | null { + const getType = (cellId: number) => cells[type][cellId]; + const isTypeFrom = (cellId: number) => + cellId < cells.i.length && getType(cellId) === getType(fromCell); + const isTypeTo = (cellId: number) => + cellId < cells.i.length && getType(cellId) === getType(toCell); + + addToChecked(fromCell); + const startingVertex = cells.v[fromCell].find((v) => + vertices.c[v].some((i) => isLand(i) && isTypeTo(i)), + ); + if (startingVertex === undefined) return null; + + const checkVertex = (vertex: number) => + vertices.c[vertex].some(isTypeFrom) && + vertices.c[vertex].some((c) => isLand(c) && isTypeTo(c)); + const chain = getVerticesLine({ + vertices, + startingVertex, + checkCell: isTypeFrom, + checkVertex, + addToChecked, + }); + if (chain.length > 1) + return `M${chain.map((cellId) => vertices.p[cellId]).join(" ")}`; + + return null; + } + + // connect vertices to chain to form a border + function getVerticesLine({ + vertices, + startingVertex, + checkCell, + checkVertex, + addToChecked, + }: { + vertices: typeof pack.vertices; + startingVertex: number; + checkCell: (cellId: number) => boolean; + checkVertex: (vertex: number) => boolean; + addToChecked: (cellId: number) => void; + }) { + let chain = []; // vertices chain to form a path + let next = startingVertex; + const MAX_ITERATIONS = vertices.c.length; + + for (let run = 0; run < 2; run++) { + // first run: from any vertex to a border edge + // second run: from found border edge to another edge + chain = []; + + for (let i = 0; i < MAX_ITERATIONS; i++) { + const previous = chain.at(-1); + const current = next; + chain.push(current); + + const neibCells = vertices.c[current]; + neibCells.map(addToChecked); + + const [c1, c2, c3] = neibCells.map(checkCell); + const [v1, v2, v3] = vertices.v[current].map(checkVertex); + const [vertex1, vertex2, vertex3] = vertices.v[current]; + + if (v1 && vertex1 !== previous && c1 !== c2) next = vertex1; + else if (v2 && vertex2 !== previous && c2 !== c3) next = vertex2; + else if (v3 && vertex3 !== previous && c1 !== c3) next = vertex3; + + if (next === current || next === startingVertex) { + if (next === startingVertex) chain.push(startingVertex); + startingVertex = next; + break; + } + } + } + + return chain; + } + + TIME && console.timeEnd("drawBorders"); +}; + +window.drawBorders = bordersRenderer; diff --git a/src/renderers/draw-burg-icons.ts b/src/renderers/draw-burg-icons.ts new file mode 100644 index 00000000..9e957fe3 --- /dev/null +++ b/src/renderers/draw-burg-icons.ts @@ -0,0 +1,145 @@ +import type { Burg } from "../modules/burgs-generator"; + +declare global { + var drawBurgIcons: () => void; + var drawBurgIcon: (burg: Burg) => void; + var removeBurgIcon: (burgId: number) => void; +} + +interface BurgGroup { + name: string; + order: number; +} + +const burgIconsRenderer = (): void => { + TIME && console.time("drawBurgIcons"); + createIconGroups(); + + for (const { name } of options.burgs.groups as BurgGroup[]) { + const burgsInGroup = pack.burgs.filter( + (b) => b.group === name && !b.removed, + ); + if (!burgsInGroup.length) continue; + + const iconsGroup = document.querySelector( + `#burgIcons > g#${name}`, + ); + if (!iconsGroup) continue; + + const icon = iconsGroup.dataset.icon || "#icon-circle"; + iconsGroup.innerHTML = burgsInGroup + .map( + (b) => + ``, + ) + .join(""); + + const portsInGroup = burgsInGroup.filter((b) => b.port); + if (!portsInGroup.length) continue; + + const portGroup = document.querySelector( + `#anchors > g#${name}`, + ); + if (!portGroup) continue; + + portGroup.innerHTML = portsInGroup + .map( + (b) => + ``, + ) + .join(""); + } + + TIME && console.timeEnd("drawBurgIcons"); +}; + +const drawBurgIconRenderer = (burg: Burg): void => { + const iconGroup = burgIcons.select(`#${burg.group}`); + if (iconGroup.empty()) { + drawBurgIcons(); + return; // redraw all icons if group is missing + } + + removeBurgIconRenderer(burg.i!); + const icon = iconGroup.attr("data-icon") || "#icon-circle"; + burgIcons + .select(`#${burg.group}`) + .append("use") + .attr("href", icon) + .attr("id", `burg${burg.i}`) + .attr("data-id", burg.i!) + .attr("x", burg.x) + .attr("y", burg.y); + + if (burg.port) { + anchors + .select(`#${burg.group}`) + .append("use") + .attr("href", "#icon-anchor") + .attr("id", `anchor${burg.i}`) + .attr("data-id", burg.i!) + .attr("x", burg.x) + .attr("y", burg.y); + } +}; + +const removeBurgIconRenderer = (burgId: number): void => { + const existingIcon = document.getElementById(`burg${burgId}`); + if (existingIcon) existingIcon.remove(); + + const existingAnchor = document.getElementById(`anchor${burgId}`); + if (existingAnchor) existingAnchor.remove(); +}; + +function createIconGroups(): void { + // save existing styles and remove all groups + document.querySelectorAll("g#burgIcons > g").forEach((group) => { + style.burgIcons[group.id] = Array.from(group.attributes).reduce( + (acc: { [key: string]: string }, attribute) => { + acc[attribute.name] = attribute.value; + return acc; + }, + {}, + ); + group.remove(); + }); + + document.querySelectorAll("g#anchors > g").forEach((group) => { + style.anchors[group.id] = Array.from(group.attributes).reduce( + (acc: { [key: string]: string }, attribute) => { + acc[attribute.name] = attribute.value; + return acc; + }, + {}, + ); + group.remove(); + }); + + // create groups for each burg group and apply stored or default style + const defaultIconStyle = + style.burgIcons.town || Object.values(style.burgIcons)[0] || {}; + const defaultAnchorStyle = + style.anchors.town || Object.values(style.anchors)[0] || {}; + const sortedGroups = [...(options.burgs.groups as BurgGroup[])].sort( + (a, b) => a.order - b.order, + ); + for (const { name } of sortedGroups) { + const burgGroup = burgIcons.append("g"); + const iconStyles = style.burgIcons[name] || defaultIconStyle; + Object.entries(iconStyles).forEach(([key, value]) => { + burgGroup.attr(key, value); + }); + burgGroup.attr("id", name); + + const anchorGroup = anchors.append("g"); + const anchorStyles = style.anchors[name] || defaultAnchorStyle; + Object.entries(anchorStyles).forEach(([key, value]) => { + anchorGroup.attr(key, value); + }); + anchorGroup.attr("id", name); + } +} + +window.drawBurgIcons = burgIconsRenderer; +window.drawBurgIcon = drawBurgIconRenderer; +window.removeBurgIcon = removeBurgIconRenderer; diff --git a/src/renderers/draw-burg-labels.ts b/src/renderers/draw-burg-labels.ts new file mode 100644 index 00000000..5dc6cc71 --- /dev/null +++ b/src/renderers/draw-burg-labels.ts @@ -0,0 +1,107 @@ +import type { Burg } from "../modules/burgs-generator"; + +declare global { + var drawBurgLabels: () => void; + var drawBurgLabel: (burg: Burg) => void; + var removeBurgLabel: (burgId: number) => void; +} + +interface BurgGroup { + name: string; + order: number; +} + +const burgLabelsRenderer = (): void => { + TIME && console.time("drawBurgLabels"); + createLabelGroups(); + + for (const { name } of options.burgs.groups as BurgGroup[]) { + const burgsInGroup = pack.burgs.filter( + (b) => b.group === name && !b.removed, + ); + if (!burgsInGroup.length) continue; + + const labelGroup = burgLabels.select(`#${name}`); + if (labelGroup.empty()) continue; + + const dx = labelGroup.attr("data-dx") || 0; + const dy = labelGroup.attr("data-dy") || 0; + + labelGroup + .selectAll("text") + .data(burgsInGroup) + .enter() + .append("text") + .attr("text-rendering", "optimizeSpeed") + .attr("id", (d) => `burgLabel${d.i}`) + .attr("data-id", (d) => d.i!) + .attr("x", (d) => d.x) + .attr("y", (d) => d.y) + .attr("dx", `${dx}em`) + .attr("dy", `${dy}em`) + .text((d) => d.name!); + } + + TIME && console.timeEnd("drawBurgLabels"); +}; + +const drawBurgLabelRenderer = (burg: Burg): void => { + const labelGroup = burgLabels.select(`#${burg.group}`); + if (labelGroup.empty()) { + drawBurgLabels(); + return; // redraw all labels if group is missing + } + + const dx = labelGroup.attr("data-dx") || 0; + const dy = labelGroup.attr("data-dy") || 0; + + removeBurgLabelRenderer(burg.i!); + labelGroup + .append("text") + .attr("text-rendering", "optimizeSpeed") + .attr("id", `burgLabel${burg.i}`) + .attr("data-id", burg.i!) + .attr("x", burg.x) + .attr("y", burg.y) + .attr("dx", `${dx}em`) + .attr("dy", `${dy}em`) + .text(burg.name!); +}; + +const removeBurgLabelRenderer = (burgId: number): void => { + const existingLabel = document.getElementById(`burgLabel${burgId}`); + if (existingLabel) existingLabel.remove(); +}; + +function createLabelGroups(): void { + // save existing styles and remove all groups + document.querySelectorAll("g#burgLabels > g").forEach((group) => { + style.burgLabels[group.id] = Array.from(group.attributes).reduce( + (acc: { [key: string]: string }, attribute) => { + acc[attribute.name] = attribute.value; + return acc; + }, + {}, + ); + group.remove(); + }); + + // create groups for each burg group and apply stored or default style + const defaultStyle = + style.burgLabels.town || Object.values(style.burgLabels)[0] || {}; + const sortedGroups = [...(options.burgs.groups as BurgGroup[])].sort( + (a, b) => a.order - b.order, + ); + for (const { name } of sortedGroups) { + const group = burgLabels.append("g"); + const styles = style.burgLabels[name] || defaultStyle; + Object.entries(styles).forEach(([key, value]) => { + group.attr(key, value); + }); + group.attr("id", name); + } +} + +window.drawBurgLabels = burgLabelsRenderer; +window.drawBurgLabel = drawBurgLabelRenderer; +window.removeBurgLabel = removeBurgLabelRenderer; diff --git a/src/renderers/draw-emblems.ts b/src/renderers/draw-emblems.ts new file mode 100644 index 00000000..568fbd48 --- /dev/null +++ b/src/renderers/draw-emblems.ts @@ -0,0 +1,200 @@ +import { forceCollide, forceSimulation, timeout } from "d3"; +import type { Burg } from "../modules/burgs-generator"; +import type { State } from "../modules/states-generator"; +import { minmax, rn } from "../utils"; + +declare global { + var drawEmblems: () => void; + var renderGroupCOAs: (g: SVGGElement) => Promise; +} + +interface Province { + i: number; + removed?: boolean; + coa?: { size?: number; x?: number; y?: number }; + pole?: [number, number]; + center: number; +} + +interface EmblemNode { + type: "burg" | "province" | "state"; + i: number; + x: number; + y: number; + size: number; + shift: number; +} + +const emblemsRenderer = (): void => { + TIME && console.time("drawEmblems"); + const { states, provinces, burgs } = pack; + + const validStates = states.filter( + (s) => s.i && !s.removed && s.coa && s.coa.size !== 0, + ); + const validProvinces = (provinces as Province[]).filter( + (p) => p.i && !p.removed && p.coa && p.coa.size !== 0, + ); + const validBurgs = burgs.filter( + (b) => b.i && !b.removed && b.coa && b.coa.size !== 0, + ); + + const getStateEmblemsSize = (): number => { + const startSize = minmax((graphHeight + graphWidth) / 40, 10, 100); + const statesMod = + 1 + validStates.length / 100 - (15 - validStates.length) / 200; // states number modifier + const sizeMod = +emblems.select("#stateEmblems").attr("data-size") || 1; + return rn((startSize / statesMod) * sizeMod); // target size ~50px on 1536x754 map with 15 states + }; + + const getProvinceEmblemsSize = (): number => { + const startSize = minmax((graphHeight + graphWidth) / 100, 5, 70); + const provincesMod = + 1 + validProvinces.length / 1000 - (115 - validProvinces.length) / 1000; // states number modifier + const sizeMod = +emblems.select("#provinceEmblems").attr("data-size") || 1; + return rn((startSize / provincesMod) * sizeMod); // target size ~20px on 1536x754 map with 115 provinces + }; + + const getBurgEmblemSize = (): number => { + const startSize = minmax((graphHeight + graphWidth) / 185, 2, 50); + const burgsMod = + 1 + validBurgs.length / 1000 - (450 - validBurgs.length) / 1000; // states number modifier + const sizeMod = +emblems.select("#burgEmblems").attr("data-size") || 1; + return rn((startSize / burgsMod) * sizeMod); // target size ~8.5px on 1536x754 map with 450 burgs + }; + + const sizeBurgs = getBurgEmblemSize(); + const burgCOAs: EmblemNode[] = validBurgs.map((burg) => { + const { x, y } = burg; + const size = burg.coa!.size || 1; + const shift = (sizeBurgs * size) / 2; + return { + type: "burg", + i: burg.i!, + x: burg.coa!.x || x, + y: burg.coa!.y || y, + size, + shift, + }; + }); + + const sizeProvinces = getProvinceEmblemsSize(); + const provinceCOAs: EmblemNode[] = validProvinces.map((province) => { + const [x, y] = province.pole || pack.cells.p[province.center]; + const size = province.coa!.size || 1; + const shift = (sizeProvinces * size) / 2; + return { + type: "province", + i: province.i, + x: province.coa!.x || x, + y: province.coa!.y || y, + size, + shift, + }; + }); + + const sizeStates = getStateEmblemsSize(); + const stateCOAs: EmblemNode[] = validStates.map((state) => { + const [x, y] = state.pole || pack.cells.p[state.center!]; + const size = state.coa!.size || 1; + const shift = (sizeStates * size) / 2; + return { + type: "state", + i: state.i, + x: state.coa!.x || x, + y: state.coa!.y || y, + size, + shift, + }; + }); + + const nodes = burgCOAs.concat(provinceCOAs).concat(stateCOAs); + const simulation = forceSimulation(nodes) + .alphaMin(0.6) + .alphaDecay(0.2) + .velocityDecay(0.6) + .force( + "collision", + forceCollide().radius((d) => d.shift), + ) + .stop(); + + timeout(() => { + const n = Math.ceil( + Math.log(simulation.alphaMin()) / Math.log(1 - simulation.alphaDecay()), + ); + for (let i = 0; i < n; ++i) { + simulation.tick(); + } + + const burgNodes = nodes.filter((node) => node.type === "burg"); + const burgString = burgNodes + .map( + (d) => + ``, + ) + .join(""); + emblems + .select("#burgEmblems") + .attr("font-size", sizeBurgs) + .html(burgString); + + const provinceNodes = nodes.filter((node) => node.type === "province"); + const provinceString = provinceNodes + .map( + (d) => + ``, + ) + .join(""); + emblems + .select("#provinceEmblems") + .attr("font-size", sizeProvinces) + .html(provinceString); + + const stateNodes = nodes.filter((node) => node.type === "state"); + const stateString = stateNodes + .map( + (d) => + ``, + ) + .join(""); + emblems + .select("#stateEmblems") + .attr("font-size", sizeStates) + .html(stateString); + + invokeActiveZooming(); + }); + + TIME && console.timeEnd("drawEmblems"); +}; + +const getDataAndType = ( + id: string, +): [Burg[] | Province[] | State[], string] => { + if (id === "burgEmblems") return [pack.burgs, "burg"]; + if (id === "provinceEmblems") + return [pack.provinces as Province[], "province"]; + if (id === "stateEmblems") return [pack.states, "state"]; + throw new Error(`Unknown emblem type: ${id}`); +}; + +const renderGroupCOAsRenderer = async (g: SVGGElement): Promise => { + const [data, type] = getDataAndType(g.id); + + for (const use of g.children) { + const i = +(use as SVGUseElement).dataset.i!; + const id = `${type}COA${i}`; + COArenderer.trigger(id, (data[i] as any).coa); + use.setAttribute("href", `#${id}`); + } +}; + +window.drawEmblems = emblemsRenderer; +window.renderGroupCOAs = renderGroupCOAsRenderer; diff --git a/src/renderers/draw-features.ts b/src/renderers/draw-features.ts new file mode 100644 index 00000000..5a6801d8 --- /dev/null +++ b/src/renderers/draw-features.ts @@ -0,0 +1,104 @@ +import { curveBasisClosed, line, select } from "d3"; +import type { PackedGraphFeature } from "../modules/features"; +import { clipPoly, round } from "../utils"; + +declare global { + var drawFeatures: () => void; + var simplify: ( + points: [number, number][], + tolerance: number, + highestQuality?: boolean, + ) => [number, number][]; + var getFeaturePath: (feature: PackedGraphFeature) => string; +} + +interface FeaturesHtml { + paths: string[]; + landMask: string[]; + waterMask: string[]; + coastline: { [key: string]: string[] }; + lakes: { [key: string]: string[] }; +} + +const featuresRenderer = (): void => { + TIME && console.time("drawFeatures"); + + const html: FeaturesHtml = { + paths: [], + landMask: [], + waterMask: [''], + coastline: {}, + lakes: {}, + }; + + for (const feature of pack.features) { + if (!feature || feature.type === "ocean") continue; + + html.paths.push( + ``, + ); + + if (feature.type === "lake") { + html.landMask.push( + ``, + ); + + const lakeGroup = feature.group || "freshwater"; + if (!html.lakes[lakeGroup]) html.lakes[lakeGroup] = []; + html.lakes[lakeGroup].push( + ``, + ); + } else { + html.landMask.push( + ``, + ); + html.waterMask.push( + ``, + ); + + const coastlineGroup = + feature.group === "lake_island" ? "lake_island" : "sea_island"; + if (!html.coastline[coastlineGroup]) html.coastline[coastlineGroup] = []; + html.coastline[coastlineGroup].push( + ``, + ); + } + } + + defs.select("#featurePaths").html(html.paths.join("")); + defs.select("#land").html(html.landMask.join("")); + defs.select("#water").html(html.waterMask.join("")); + + coastline.selectAll("g").each(function () { + const paths = html.coastline[this.id] || []; + select(this).html(paths.join("")); + }); + + lakes.selectAll("g").each(function () { + const paths = html.lakes[this.id] || []; + select(this).html(paths.join("")); + }); + + TIME && console.timeEnd("drawFeatures"); +}; + +function featurePathRenderer(feature: PackedGraphFeature): string { + const points: [number, number][] = feature.vertices.map( + (vertex: number) => pack.vertices.p[vertex], + ); + if (points.some((point) => point === undefined)) { + ERROR && console.error("Undefined point in getFeaturePath"); + return ""; + } + + const simplifiedPoints = simplify(points, 0.3); + const clippedPoints = clipPoly(simplifiedPoints, graphWidth, graphHeight, 1); + + const lineGen = line().curve(curveBasisClosed); + const path = `${round(lineGen(clippedPoints) || "")}Z`; + + return path; +} + +window.drawFeatures = featuresRenderer; +window.getFeaturePath = featurePathRenderer; diff --git a/public/modules/renderers/draw-heightmap.js b/src/renderers/draw-heightmap.ts similarity index 54% rename from public/modules/renderers/draw-heightmap.js rename to src/renderers/draw-heightmap.ts index cefed230..7ccabd47 100644 --- a/public/modules/renderers/draw-heightmap.js +++ b/src/renderers/draw-heightmap.ts @@ -1,25 +1,37 @@ -"use strict"; +import type { CurveFactory } from "d3"; +import * as d3 from "d3"; +import { color, line, range } from "d3"; +import { round } from "../utils"; -function drawHeightmap() { +declare global { + var drawHeightmap: () => void; +} + +const heightmapRenderer = (): void => { TIME && console.time("drawHeightmap"); - const ocean = terrs.select("#oceanHeights"); - const land = terrs.select("#landHeights"); + const ocean = terrs.select("#oceanHeights"); + const land = terrs.select("#landHeights"); ocean.selectAll("*").remove(); land.selectAll("*").remove(); - const paths = new Array(101); - const {cells, vertices} = grid; + const paths: (string | undefined)[] = new Array(101); + const { cells, vertices } = grid; const used = new Uint8Array(cells.i.length); - const heights = Array.from(cells.i).sort((a, b) => cells.h[a] - cells.h[b]); + const heights = Array.from(cells.i as number[]).sort( + (a, b) => cells.h[a] - cells.h[b], + ); // ocean cells const renderOceanCells = Boolean(+ocean.attr("data-render")); if (renderOceanCells) { const skip = +ocean.attr("skip") + 1 || 1; const relax = +ocean.attr("relax") || 0; - lineGen.curve(d3[ocean.attr("curve") || "curveBasisClosed"]); + // TODO: Improve for treeshaking + const curveType: keyof typeof d3 = (ocean.attr("curve") || + "curveBasisClosed") as keyof typeof d3; + const lineGen = line().curve(d3[curveType] as CurveFactory); let currentLayer = 0; for (const i of heights) { @@ -28,14 +40,18 @@ function drawHeightmap() { if (h < currentLayer) continue; if (currentLayer >= 20) break; if (used[i]) continue; // already marked - const onborder = cells.c[i].some(n => cells.h[n] < h); + const onborder = cells.c[i].some((n: number) => cells.h[n] < h); if (!onborder) continue; - const vertex = cells.v[i].find(v => vertices.c[v].some(i => cells.h[i] < h)); + const vertex = cells.v[i].find((v: number) => + vertices.c[v].some((i: number) => cells.h[i] < h), + ); const chain = connectVertices(cells, vertices, vertex, h, used); if (chain.length < 3) continue; - const points = simplifyLine(chain, relax).map(v => vertices.p[v]); + const points = simplifyLine(chain, relax).map( + (v: number) => vertices.p[v], + ); if (!paths[h]) paths[h] = ""; - paths[h] += round(lineGen(points)); + paths[h] += round(lineGen(points) || ""); } } @@ -43,7 +59,9 @@ function drawHeightmap() { { const skip = +land.attr("skip") + 1 || 1; const relax = +land.attr("relax") || 0; - lineGen.curve(d3[land.attr("curve") || "curveBasisClosed"]); + const curveType: keyof typeof d3 = (land.attr("curve") || + "curveBasisClosed") as keyof typeof d3; + const lineGen = line().curve(d3[curveType] as CurveFactory); let currentLayer = 20; for (const i of heights) { @@ -52,21 +70,25 @@ function drawHeightmap() { if (h < currentLayer) continue; if (currentLayer > 100) break; // no layers possible with height > 100 if (used[i]) continue; // already marked - const onborder = cells.c[i].some(n => cells.h[n] < h); + const onborder = cells.c[i].some((n: number) => cells.h[n] < h); if (!onborder) continue; - const startVertex = cells.v[i].find(v => vertices.c[v].some(i => cells.h[i] < h)); + const startVertex = cells.v[i].find((v: number) => + vertices.c[v].some((i: number) => cells.h[i] < h), + ); const chain = connectVertices(cells, vertices, startVertex, h, used); if (chain.length < 3) continue; - const points = simplifyLine(chain, relax).map(v => vertices.p[v]); + const points = simplifyLine(chain, relax).map( + (v: number) => vertices.p[v], + ); if (!paths[h]) paths[h] = ""; - paths[h] += round(lineGen(points)); + paths[h] += round(lineGen(points) || ""); } } // render paths - for (const height of d3.range(0, 101)) { + for (const height of range(0, 101)) { const group = height < 20 ? ocean : land; const scheme = getColorScheme(group.attr("scheme")); @@ -92,33 +114,49 @@ function drawHeightmap() { .attr("fill", scheme(0.8)); } - if (paths[height] && paths[height].length >= 10) { - const terracing = group.attr("terracing") / 10 || 0; - const color = getColor(height, scheme); + if (paths[height] && paths[height]!.length >= 10) { + const terracing = +group.attr("terracing") / 10 || 0; + const fillColor = getColor(height, scheme); if (terracing) { group .append("path") - .attr("d", paths[height]) + .attr("d", paths[height]!) .attr("transform", "translate(.7,1.4)") - .attr("fill", d3.color(color).darker(terracing)) + .attr("fill", color(fillColor)!.darker(terracing).toString()) .attr("data-height", height); } - group.append("path").attr("d", paths[height]).attr("fill", color).attr("data-height", height); + group + .append("path") + .attr("d", paths[height]!) + .attr("fill", fillColor) + .attr("data-height", height); } } // connect vertices to chain: specific case for heightmap - function connectVertices(cells, vertices, start, h, used) { + function connectVertices( + cells: any, + vertices: any, + start: number, + h: number, + used: Uint8Array, + ): number[] { const MAX_ITERATIONS = vertices.c.length; const n = cells.i.length; - const chain = []; // vertices chain to form a path - for (let i = 0, current = start; i === 0 || (current !== start && i < MAX_ITERATIONS); i++) { + const chain: number[] = []; // vertices chain to form a path + for ( + let i = 0, current = start; + i === 0 || (current !== start && i < MAX_ITERATIONS); + i++ + ) { const prev = chain[chain.length - 1]; // previous vertex in chain chain.push(current); // add current vertex to sequence const c = vertices.c[current]; // cells adjacent to vertex - c.filter(c => cells.h[c] === h).forEach(c => (used[c] = 1)); + c.filter((c: number) => cells.h[c] === h).forEach((c: number) => { + used[c] = 1; + }); const c0 = c[0] >= n || cells.h[c[0]] < h; const c1 = c[1] >= n || cells.h[c[1]] < h; const c2 = c[2] >= n || cells.h[c[2]] < h; @@ -134,11 +172,13 @@ function drawHeightmap() { return chain; } - function simplifyLine(chain, simplification) { + function simplifyLine(chain: number[], simplification: number): number[] { if (!simplification) return chain; const n = simplification + 1; // filter each nth element - return chain.filter((d, i) => i % n === 0); + return chain.filter((_d, i) => i % n === 0); } TIME && console.timeEnd("drawHeightmap"); -} +}; + +window.drawHeightmap = heightmapRenderer; diff --git a/src/renderers/draw-ice.ts b/src/renderers/draw-ice.ts new file mode 100644 index 00000000..ce238d08 --- /dev/null +++ b/src/renderers/draw-ice.ts @@ -0,0 +1,102 @@ +declare global { + var drawIce: () => void; + var redrawIceberg: (id: number) => void; + var redrawGlacier: (id: number) => void; +} + +interface IceElement { + i: number; + points: string | [number, number][]; + type: "glacier" | "iceberg"; + offset?: [number, number]; +} + +const iceRenderer = (): void => { + TIME && console.time("drawIce"); + + // Clear existing ice SVG + ice.selectAll("*").remove(); + + let html = ""; + + // Draw all ice elements + pack.ice.forEach((iceElement: IceElement) => { + if (iceElement.type === "glacier") { + html += getGlacierHtml(iceElement); + } else if (iceElement.type === "iceberg") { + html += getIcebergHtml(iceElement); + } + }); + + ice.html(html); + + TIME && console.timeEnd("drawIce"); +}; + +const redrawIcebergRenderer = (id: number): void => { + TIME && console.time("redrawIceberg"); + const iceberg = pack.ice.find((element: IceElement) => element.i === id); + let el = ice.selectAll( + `polygon[data-id="${id}"]:not([type="glacier"])`, + ); + if (!iceberg && !el.empty()) { + el.remove(); + } else if (iceberg) { + if (el.empty()) { + // Create new element if it doesn't exist + const polygon = getIcebergHtml(iceberg); + (ice.node() as SVGGElement).insertAdjacentHTML("beforeend", polygon); + el = ice.selectAll( + `polygon[data-id="${id}"]:not([type="glacier"])`, + ); + } + el.attr("points", iceberg.points as string); + el.attr( + "transform", + iceberg.offset + ? `translate(${iceberg.offset[0]},${iceberg.offset[1]})` + : null, + ); + } + TIME && console.timeEnd("redrawIceberg"); +}; + +const redrawGlacierRenderer = (id: number): void => { + TIME && console.time("redrawGlacier"); + const glacier = pack.ice.find((element: IceElement) => element.i === id); + let el = ice.selectAll( + `polygon[data-id="${id}"][type="glacier"]`, + ); + if (!glacier && !el.empty()) { + el.remove(); + } else if (glacier) { + if (el.empty()) { + // Create new element if it doesn't exist + const polygon = getGlacierHtml(glacier); + (ice.node() as SVGGElement).insertAdjacentHTML("beforeend", polygon); + el = ice.selectAll( + `polygon[data-id="${id}"][type="glacier"]`, + ); + } + el.attr("points", glacier.points as string); + el.attr( + "transform", + glacier.offset + ? `translate(${glacier.offset[0]},${glacier.offset[1]})` + : null, + ); + } + TIME && console.timeEnd("redrawGlacier"); +}; + +function getGlacierHtml(glacier: IceElement): string { + return ``; +} + +function getIcebergHtml(iceberg: IceElement): string { + return ``; +} + +window.drawIce = iceRenderer; +window.redrawIceberg = redrawIcebergRenderer; +window.redrawGlacier = redrawGlacierRenderer; diff --git a/src/renderers/draw-markers.ts b/src/renderers/draw-markers.ts new file mode 100644 index 00000000..38cdebc4 --- /dev/null +++ b/src/renderers/draw-markers.ts @@ -0,0 +1,105 @@ +import { rn } from "../utils"; + +interface Marker { + i: number; + icon: string; + x: number; + y: number; + dx?: number; + dy?: number; + px?: number; + size?: number; + pin?: string; + fill?: string; + stroke?: string; + pinned?: boolean; +} + +declare global { + var drawMarkers: () => void; + var drawMarker: (marker: Marker, rescale?: number) => string; +} + +type PinShapeFunction = (fill: string, stroke: string) => string; +type PinShapes = { [key: string]: PinShapeFunction }; + +// prettier-ignore +const pinShapes: PinShapes = { + bubble: (fill: string, stroke: string) => + ``, + pin: (fill: string, stroke: string) => + ``, + square: (fill: string, stroke: string) => + ``, + squarish: (fill: string, stroke: string) => + ``, + diamond: (fill: string, stroke: string) => + ``, + hex: (fill: string, stroke: string) => + ``, + hexy: (fill: string, stroke: string) => + ``, + shieldy: (fill: string, stroke: string) => + ``, + shield: (fill: string, stroke: string) => + ``, + pentagon: (fill: string, stroke: string) => + ``, + heptagon: (fill: string, stroke: string) => + ``, + circle: (fill: string, stroke: string) => + ``, + no: () => "", +}; + +const getPin = (shape = "bubble", fill = "#fff", stroke = "#000"): string => { + const shapeFunction = pinShapes[shape] || pinShapes.bubble; + return shapeFunction(fill, stroke); +}; + +function markerRenderer(marker: Marker, rescale = 1): string { + const { + i, + icon, + x, + y, + dx = 50, + dy = 50, + px = 12, + size = 30, + pin, + fill, + stroke, + } = marker; + const id = `marker${i}`; + const zoomSize = rescale ? Math.max(rn(size / 5 + 24 / scale, 2), 1) : size; + const viewX = rn(x - zoomSize / 2, 1); + const viewY = rn(y - zoomSize, 1); + + const isExternal = icon.startsWith("http") || icon.startsWith("data:image"); + + return /* html */ ` + + ${getPin(pin, fill, stroke)} + ${isExternal ? "" : icon} + + `; +} + +const markersRenderer = (): void => { + TIME && console.time("drawMarkers"); + + const rescale = +markers.attr("rescale"); + const pinned = +markers.attr("pinned"); + + const markersData: Marker[] = pinned + ? pack.markers.filter((m: Marker) => m.pinned) + : pack.markers; + const html = markersData.map((marker) => markerRenderer(marker, rescale)); + markers.html(html.join("")); + + TIME && console.timeEnd("drawMarkers"); +}; + +window.drawMarkers = markersRenderer; +window.drawMarker = markerRenderer; diff --git a/src/renderers/draw-military.ts b/src/renderers/draw-military.ts new file mode 100644 index 00000000..dc5f1da2 --- /dev/null +++ b/src/renderers/draw-military.ts @@ -0,0 +1,216 @@ +import { color, easeSinInOut, transition } from "d3"; +import { rn } from "../utils"; + +interface Regiment { + i: number; + name: string; + x: number; + y: number; + n?: number; + angle?: number; + icon: string; + state: number; +} + +declare global { + var drawMilitary: () => void; + var drawRegiments: (regiments: Regiment[], stateId: number) => void; + var drawRegiment: (reg: Regiment, stateId: number) => void; + var moveRegiment: (reg: Regiment, x: number, y: number) => void; + var armies: import("d3").Selection; + var Military: { getTotal: (reg: Regiment) => number }; +} + +const militaryRenderer = (): void => { + TIME && console.time("drawMilitary"); + + armies.selectAll("g").remove(); + pack.states + .filter((s) => s.i && !s.removed) + .forEach((s) => { + drawRegiments(s.military || [], s.i); + }); + + TIME && console.timeEnd("drawMilitary"); +}; + +const drawRegimentsRenderer = (regiments: Regiment[], s: number): void => { + const size = +armies.attr("box-size"); + const w = (d: Regiment) => (d.n ? size * 4 : size * 6); + const h = size * 2; + const x = (d: Regiment) => rn(d.x - w(d) / 2, 2); + const y = (d: Regiment) => rn(d.y - size, 2); + + const stateColor = pack.states[s]?.color; + const baseColor = stateColor && stateColor[0] === "#" ? stateColor : "#999"; + const darkerColor = color(baseColor)!.darker().formatHex(); + const army = armies + .append("g") + .attr("id", `army${s}`) + .attr("fill", baseColor) + .attr("color", darkerColor); + + const g = army + .selectAll("g") + .data(regiments) + .enter() + .append("g") + .attr("id", (d) => `regiment${s}-${d.i}`) + .attr("data-name", (d) => d.name) + .attr("data-state", s) + .attr("data-id", (d) => d.i) + .attr("transform", (d) => (d.angle ? `rotate(${d.angle})` : null)) + .attr("transform-origin", (d) => `${d.x}px ${d.y}px`); + g.append("rect") + .attr("x", (d) => x(d)) + .attr("y", (d) => y(d)) + .attr("width", (d) => w(d)) + .attr("height", h); + g.append("text") + .attr("x", (d) => d.x) + .attr("y", (d) => d.y) + .attr("text-rendering", "optimizeSpeed") + .text((d) => Military.getTotal(d)); + g.append("rect") + .attr("fill", "currentColor") + .attr("x", (d) => x(d) - h) + .attr("y", (d) => y(d)) + .attr("width", h) + .attr("height", h); + g.append("text") + .attr("class", "regimentIcon") + .attr("text-rendering", "optimizeSpeed") + .attr("x", (d) => x(d) - size) + .attr("y", (d) => d.y) + .text((d) => + d.icon.startsWith("http") || d.icon.startsWith("data:image") + ? "" + : d.icon, + ); + g.append("image") + .attr("class", "regimentImage") + .attr("x", (d) => x(d) - h) + .attr("y", (d) => y(d)) + .attr("height", h) + .attr("width", h) + .attr("href", (d) => + d.icon.startsWith("http") || d.icon.startsWith("data:image") + ? d.icon + : "", + ); +}; + +const drawRegimentRenderer = (reg: Regiment, stateId: number): void => { + const size = +armies.attr("box-size"); + const w = reg.n ? size * 4 : size * 6; + const h = size * 2; + const x1 = rn(reg.x - w / 2, 2); + const y1 = rn(reg.y - size, 2); + + let army = armies.select(`g#army${stateId}`); + if (!army.size()) { + const stateColor = pack.states[stateId]?.color; + const baseColor = stateColor && stateColor[0] === "#" ? stateColor : "#999"; + const darkerColor = color(baseColor)!.darker().formatHex(); + army = armies + .append("g") + .attr("id", `army${stateId}`) + .attr("fill", baseColor) + .attr("color", darkerColor); + } + + const g = army + .append("g") + .attr("id", `regiment${stateId}-${reg.i}`) + .attr("data-name", reg.name) + .attr("data-state", stateId) + .attr("data-id", reg.i) + .attr("transform", `rotate(${reg.angle || 0})`) + .attr("transform-origin", `${reg.x}px ${reg.y}px`); + g.append("rect") + .attr("x", x1) + .attr("y", y1) + .attr("width", w) + .attr("height", h); + g.append("text") + .attr("x", reg.x) + .attr("y", reg.y) + .attr("text-rendering", "optimizeSpeed") + .text(Military.getTotal(reg)); + g.append("rect") + .attr("fill", "currentColor") + .attr("x", x1 - h) + .attr("y", y1) + .attr("width", h) + .attr("height", h); + g.append("text") + .attr("class", "regimentIcon") + .attr("text-rendering", "optimizeSpeed") + .attr("x", x1 - size) + .attr("y", reg.y) + .text( + reg.icon.startsWith("http") || reg.icon.startsWith("data:image") + ? "" + : reg.icon, + ); + g.append("image") + .attr("class", "regimentImage") + .attr("x", x1 - h) + .attr("y", y1) + .attr("height", h) + .attr("width", h) + .attr( + "href", + reg.icon.startsWith("http") || reg.icon.startsWith("data:image") + ? reg.icon + : "", + ); +}; + +// move one regiment to another +const moveRegimentRenderer = (reg: Regiment, x: number, y: number): void => { + const el = armies + .select(`g#army${reg.state}`) + .select(`g#regiment${reg.state}-${reg.i}`); + if (!el.size()) return; + + const duration = Math.hypot(reg.x - x, reg.y - y) * 8; + reg.x = x; + reg.y = y; + const size = +armies.attr("box-size"); + const w = reg.n ? size * 4 : size * 6; + const h = size * 2; + const x1 = (x: number) => rn(x - w / 2, 2); + const y1 = (y: number) => rn(y - size, 2); + + const move = transition().duration(duration).ease(easeSinInOut); + el.select("rect") + .transition(move as any) + .attr("x", x1(x)) + .attr("y", y1(y)); + el.select("text") + .transition(move as any) + .attr("x", x) + .attr("y", y); + el.selectAll("rect:nth-of-type(2)") + .transition(move as any) + .attr("x", x1(x) - h) + .attr("y", y1(y)); + el.select(".regimentIcon") + .transition(move as any) + .attr("x", x1(x) - size) + .attr("y", y) + .attr("height", "6") + .attr("width", "6"); + el.select(".regimentImage") + .transition(move as any) + .attr("x", x1(x) - h) + .attr("y", y1(y)) + .attr("height", "6") + .attr("width", "6"); +}; + +window.drawMilitary = militaryRenderer; +window.drawRegiments = drawRegimentsRenderer; +window.drawRegiment = drawRegimentRenderer; +window.moveRegiment = moveRegimentRenderer; diff --git a/src/renderers/draw-relief-icons.ts b/src/renderers/draw-relief-icons.ts new file mode 100644 index 00000000..c4960b25 --- /dev/null +++ b/src/renderers/draw-relief-icons.ts @@ -0,0 +1,164 @@ +import { extent, polygonContains } from "d3"; +import { minmax, rand, rn } from "../utils"; + +interface ReliefIcon { + i: string; + x: number; + y: number; + s: number; +} + +declare global { + var drawReliefIcons: () => void; + var terrain: import("d3").Selection; + var getPackPolygon: (i: number) => [number, number][]; +} + +const reliefIconsRenderer = (): void => { + TIME && console.time("drawRelief"); + terrain.selectAll("*").remove(); + + const cells = pack.cells; + const density = Number(terrain.attr("density")) || 0.4; + const size = 2 * (Number(terrain.attr("size")) || 1); + const mod = 0.2 * size; // size modifier + const relief: ReliefIcon[] = []; + + for (const i of cells.i) { + const height = cells.h[i]; + if (height < 20) continue; // no icons on water + if (cells.r[i]) continue; // no icons on rivers + const biome = cells.biome[i]; + if (height < 50 && biomesData.iconsDensity[biome] === 0) continue; // no icons for this biome + + const polygon = getPackPolygon(i); + const [minX, maxX] = extent(polygon, (p) => p[0]) as [number, number]; + const [minY, maxY] = extent(polygon, (p) => p[1]) as [number, number]; + + if (height < 50) placeBiomeIcons(); + else placeReliefIcons(); + + function placeBiomeIcons(): void { + const iconsDensity = biomesData.iconsDensity[biome] / 100; + const radius = 2 / iconsDensity / density; + if (Math.random() > iconsDensity * 10) return; + + for (const [cx, cy] of window.poissonDiscSampler( + minX, + minY, + maxX, + maxY, + radius, + )) { + if (!polygonContains(polygon, [cx, cy])) continue; + let h = (4 + Math.random()) * size; + const icon = getBiomeIcon(i, biomesData.icons[biome]); + if (icon === "#relief-grass-1") h *= 1.2; + relief.push({ + i: icon, + x: rn(cx - h, 2), + y: rn(cy - h, 2), + s: rn(h * 2, 2), + }); + } + } + + function placeReliefIcons(): void { + const radius = 2 / density; + const [icon, h] = getReliefIcon(i, height); + + for (const [cx, cy] of window.poissonDiscSampler( + minX, + minY, + maxX, + maxY, + radius, + )) { + if (!polygonContains(polygon, [cx, cy])) continue; + relief.push({ + i: icon, + x: rn(cx - h, 2), + y: rn(cy - h, 2), + s: rn(h * 2, 2), + }); + } + } + + function getReliefIcon(cellIndex: number, h: number): [string, number] { + const temp = grid.cells.temp[pack.cells.g[cellIndex]]; + const type = h > 70 && temp < 0 ? "mountSnow" : h > 70 ? "mount" : "hill"; + const iconSize = h > 70 ? (h - 45) * mod : minmax((h - 40) * mod, 3, 6); + return [getIcon(type), iconSize]; + } + } + + // sort relief icons by y+size + relief.sort((a, b) => a.y + a.s - (b.y + b.s)); + + const reliefHTML: string[] = []; + for (const r of relief) { + reliefHTML.push( + ``, + ); + } + terrain.html(reliefHTML.join("")); + + TIME && console.timeEnd("drawRelief"); + + function getBiomeIcon(cellIndex: number, b: string[]): string { + let type = b[Math.floor(Math.random() * b.length)]; + const temp = grid.cells.temp[pack.cells.g[cellIndex]]; + if (type === "conifer" && temp < 0) type = "coniferSnow"; + return getIcon(type); + } + + function getVariant(type: string): number { + switch (type) { + case "mount": + return rand(2, 7); + case "mountSnow": + return rand(1, 6); + case "hill": + return rand(2, 5); + case "conifer": + return 2; + case "coniferSnow": + return 1; + case "swamp": + return rand(2, 3); + case "cactus": + return rand(1, 3); + case "deadTree": + return rand(1, 2); + default: + return 2; + } + } + + function getOldIcon(type: string): string { + switch (type) { + case "mountSnow": + return "mount"; + case "vulcan": + return "mount"; + case "coniferSnow": + return "conifer"; + case "cactus": + return "dune"; + case "deadTree": + return "dune"; + default: + return type; + } + } + + function getIcon(type: string): string { + const set = terrain.attr("set") || "simple"; + if (set === "simple") return `#relief-${getOldIcon(type)}-1`; + if (set === "colored") return `#relief-${type}-${getVariant(type)}`; + if (set === "gray") return `#relief-${type}-${getVariant(type)}-bw`; + return `#relief-${getOldIcon(type)}-1`; // simple + } +}; + +window.drawReliefIcons = reliefIconsRenderer; diff --git a/public/modules/renderers/draw-scalebar.js b/src/renderers/draw-scalebar.ts similarity index 59% rename from public/modules/renderers/draw-scalebar.js rename to src/renderers/draw-scalebar.ts index b318f0be..12c46d55 100644 --- a/public/modules/renderers/draw-scalebar.js +++ b/src/renderers/draw-scalebar.ts @@ -1,12 +1,36 @@ -"use strict"; +import type { Selection } from "d3"; +import { range } from "d3"; +import { rn } from "../utils"; -function drawScaleBar(scaleBar, scaleLevel) { +declare global { + var drawScaleBar: ( + scaleBar: Selection, + scaleLevel: number, + ) => void; + var fitScaleBar: ( + scaleBar: Selection, + fullWidth: number, + fullHeight: number, + ) => void; +} + +type ScaleBarSelection = d3.Selection< + SVGGElement, + unknown, + HTMLElement, + unknown +>; + +const scaleBarRenderer = ( + scaleBar: ScaleBarSelection, + scaleLevel: number, +): void => { if (!scaleBar.size() || scaleBar.style("display") === "none") return; const unit = distanceUnitInput.value; const size = +scaleBar.attr("data-bar-size"); - const length = getLength(scaleLevel, size); + const length = getLength(scaleBar, scaleLevel); scaleBar.select("#scaleBarContent").remove(); // redraw content every time const content = scaleBar.append("g").attr("id", "scaleBarContent"); @@ -34,20 +58,27 @@ function drawScaleBar(scaleBar, scaleLevel) { .attr("x2", length + size) .attr("y2", 0) .attr("stroke-width", rn(size * 3, 2)) - .attr("stroke-dasharray", size + " " + rn(length / 5 - size, 2)) + .attr("stroke-dasharray", `${size} ${rn(length / 5 - size, 2)}`) .attr("stroke", "#3d3d3d"); - const texts = content.append("g").attr("text-anchor", "middle").attr("font-family", "var(--serif)"); + const texts = content + .append("g") + .attr("text-anchor", "middle") + .attr("font-family", "var(--serif)"); texts .selectAll("text") - .data(d3.range(0, 6)) + .data(range(0, 6)) .enter() .append("text") .attr("text-rendering", "optimizeSpeed") - .attr("x", d => rn((d * length) / 5, 2)) + .attr("x", (d: number) => rn((d * length) / 5, 2)) .attr("y", 0) .attr("dy", "-.6em") - .text(d => rn((((d * length) / 5) * distanceScale) / scaleLevel) + (d < 5 ? "" : " " + unit)); + .text( + (d: number) => + rn((((d * length) / 5) * distanceScale) / scaleLevel) + + (d < 5 ? "" : ` ${unit}`), + ); const label = scaleBar.attr("data-label"); if (label) { @@ -60,9 +91,9 @@ function drawScaleBar(scaleBar, scaleLevel) { .text(label); } - const scaleBarBack = scaleBar.select("#scaleBarBack"); + const scaleBarBack = scaleBar.select("#scaleBarBack"); if (scaleBarBack.size()) { - const bbox = content.node().getBBox(); + const bbox = (content.node() as SVGGElement).getBBox(); const paddingTop = +scaleBarBack.attr("data-top") || 0; const paddingLeft = +scaleBarBack.attr("data-left") || 0; const paddingRight = +scaleBarBack.attr("data-right") || 0; @@ -75,29 +106,40 @@ function drawScaleBar(scaleBar, scaleLevel) { .attr("width", bbox.width + paddingRight) .attr("height", bbox.height + paddingBottom); } -} +}; -function getLength(scaleLevel) { +function getLength(scaleBar: ScaleBarSelection, scaleLevel: number): number { const init = 100; const size = +scaleBar.attr("data-bar-size"); let val = (init * size * distanceScale) / scaleLevel; // bar length in distance unit - if (val > 900) val = rn(val, -3); // round to 1000 - else if (val > 90) val = rn(val, -2); // round to 100 - else if (val > 9) val = rn(val, -1); // round to 10 + if (val > 900) + val = rn(val, -3); // round to 1000 + else if (val > 90) + val = rn(val, -2); // round to 100 + else if (val > 9) + val = rn(val, -1); // round to 10 else val = rn(val); // round to 1 const length = (val * scaleLevel) / distanceScale; // actual length in pixels on this scale return length; } -function fitScaleBar(scaleBar, fullWidth, fullHeight) { - if (!scaleBar.select("rect").size() || scaleBar.style("display") === "none") return; +const scaleBarResize = ( + scaleBar: ScaleBarSelection, + fullWidth: number, + fullHeight: number, +): void => { + if (!scaleBar.select("rect").size() || scaleBar.style("display") === "none") + return; const posX = +scaleBar.attr("data-x") || 99; const posY = +scaleBar.attr("data-y") || 99; - const bbox = scaleBar.select("rect").node().getBBox(); + const bbox = (scaleBar.select("rect").node() as SVGRectElement).getBBox(); const x = rn((fullWidth * posX) / 100 - bbox.width + 10); const y = rn((fullHeight * posY) / 100 - bbox.height + 20); scaleBar.attr("transform", `translate(${x},${y})`); -} +}; + +window.drawScaleBar = scaleBarRenderer; +window.fitScaleBar = scaleBarResize; diff --git a/src/renderers/draw-state-labels.ts b/src/renderers/draw-state-labels.ts new file mode 100644 index 00000000..24528d45 --- /dev/null +++ b/src/renderers/draw-state-labels.ts @@ -0,0 +1,439 @@ +import { curveNatural, line, max, select } from "d3"; +import { + drawPath, + drawPoint, + findClosestCell, + minmax, + rn, + round, + splitInTwo, +} from "../utils"; + +declare global { + var drawStateLabels: (list?: number[]) => void; +} + +interface Ray { + angle: number; + length: number; + x: number; + y: number; +} + +interface AngleData { + angle: number; + dx: number; + dy: number; +} + +type PathPoints = [number, number][]; + +// list - an optional array of stateIds to regenerate +const stateLabelsRenderer = (list?: number[]): void => { + TIME && console.time("drawStateLabels"); + + // temporary make the labels visible + const layerDisplay = labels.style("display"); + labels.style("display", null); + + const { cells, states, features } = pack; + const stateIds = cells.state; + + // increase step to 15 or 30 to make it faster and more horyzontal + // decrease step to 5 to improve accuracy + const ANGLE_STEP = 9; + const angles = precalculateAngles(ANGLE_STEP); + + const LENGTH_START = 5; + const LENGTH_STEP = 5; + const LENGTH_MAX = 300; + + const labelPaths = getLabelPaths(); + const letterLength = checkExampleLetterLength(); + drawLabelPath(letterLength); + + // restore labels visibility + labels.style("display", layerDisplay); + + function getLabelPaths(): [number, PathPoints][] { + const labelPaths: [number, PathPoints][] = []; + + for (const state of states) { + if (!state.i || state.removed || state.lock) continue; + if (list && !list.includes(state.i)) continue; + + const offset = getOffsetWidth(state.cells!); + const maxLakeSize = state.cells! / 20; + const [x0, y0] = state.pole!; + + const rays: Ray[] = angles.map(({ angle, dx, dy }) => { + const { length, x, y } = raycast({ + stateId: state.i, + x0, + y0, + dx, + dy, + maxLakeSize, + offset, + }); + return { angle, length, x, y }; + }); + const [ray1, ray2] = findBestRayPair(rays); + + const pathPoints: PathPoints = [ + [ray1.x, ray1.y], + state.pole!, + [ray2.x, ray2.y], + ]; + if (ray1.x > ray2.x) pathPoints.reverse(); + + if (DEBUG.stateLabels) { + drawPoint(state.pole!, { color: "black", radius: 1 }); + drawPath(pathPoints, { color: "black", width: 0.2 }); + } + + labelPaths.push([state.i, pathPoints]); + } + + return labelPaths; + } + + function checkExampleLetterLength(): number { + const textGroup = select("g#labels > g#states"); + const testLabel = textGroup + .append("text") + .attr("x", 0) + .attr("y", 0) + .text("Example"); + const letterLength = + (testLabel.node() as SVGTextElement).getComputedTextLength() / 7; // approximate length of 1 letter + testLabel.remove(); + + return letterLength; + } + + function drawLabelPath(letterLength: number): void { + const mode = options.stateLabelsMode || "auto"; + const lineGen = line<[number, number]>().curve(curveNatural); + + const textGroup = select("g#labels > g#states"); + const pathGroup = select( + "defs > g#deftemp > g#textPaths", + ); + + for (const [stateId, pathPoints] of labelPaths) { + const state = states[stateId]; + if (!state.i || state.removed) + throw new Error("State must not be neutral or removed"); + if (pathPoints.length < 2) + throw new Error("Label path must have at least 2 points"); + + textGroup.select(`#stateLabel${stateId}`).remove(); + pathGroup.select(`#textPath_stateLabel${stateId}`).remove(); + + const textPath = pathGroup + .append("path") + .attr("d", round(lineGen(pathPoints) || "")) + .attr("id", `textPath_stateLabel${stateId}`); + + const pathLength = + (textPath.node() as SVGPathElement).getTotalLength() / letterLength; // path length in letters + const [lines, ratio] = getLinesAndRatio( + mode, + state.name!, + state.fullName!, + pathLength, + ); + + // prolongate path if it's too short + const longestLineLength = max(lines.map((line) => line.length)) || 0; + if (pathLength && pathLength < longestLineLength) { + const [x1, y1] = pathPoints.at(0)!; + const [x2, y2] = pathPoints.at(-1)!; + const [dx, dy] = [(x2 - x1) / 2, (y2 - y1) / 2]; + + const mod = longestLineLength / pathLength; + pathPoints[0] = [x1 + dx - dx * mod, y1 + dy - dy * mod]; + pathPoints[pathPoints.length - 1] = [ + x2 - dx + dx * mod, + y2 - dy + dy * mod, + ]; + + textPath.attr("d", round(lineGen(pathPoints) || "")); + } + + const textElement = textGroup + .append("text") + .attr("text-rendering", "optimizeSpeed") + .attr("id", `stateLabel${stateId}`) + .append("textPath") + .attr("startOffset", "50%") + .attr("font-size", `${ratio}%`) + .node() as SVGTextPathElement; + + const top = (lines.length - 1) / -2; // y offset + const spans = lines.map( + (lineText, index) => + `${lineText}`, + ); + textElement.insertAdjacentHTML("afterbegin", spans.join("")); + + const { width, height } = textElement.getBBox(); + textElement.setAttribute("href", `#textPath_stateLabel${stateId}`); + + if (mode === "full" || lines.length === 1) continue; + + // check if label fits state boundaries. If no, replace it with short name + const [[x1, y1], [x2, y2]] = [pathPoints.at(0)!, pathPoints.at(-1)!]; + const angleRad = Math.atan2(y2 - y1, x2 - x1); + + const isInsideState = checkIfInsideState( + textElement, + angleRad, + width / 2, + height / 2, + stateIds, + stateId, + ); + if (isInsideState) continue; + + // replace name to one-liner + const text = + pathLength > state.fullName!.length * 1.8 + ? state.fullName! + : state.name!; + textElement.innerHTML = `${text}`; + + const correctedRatio = minmax( + rn((pathLength / text.length) * 50), + 50, + 130, + ); + textElement.setAttribute("font-size", `${correctedRatio}%`); + } + } + + function getOffsetWidth(cellsNumber: number): number { + if (cellsNumber < 40) return 0; + if (cellsNumber < 200) return 5; + return 10; + } + + function precalculateAngles(step: number): AngleData[] { + const angles: AngleData[] = []; + const RAD = Math.PI / 180; + + for (let angle = 0; angle < 360; angle += step) { + const dx = Math.cos(angle * RAD); + const dy = Math.sin(angle * RAD); + angles.push({ angle, dx, dy }); + } + + return angles; + } + + function raycast({ + stateId, + x0, + y0, + dx, + dy, + maxLakeSize, + offset, + }: { + stateId: number; + x0: number; + y0: number; + dx: number; + dy: number; + maxLakeSize: number; + offset: number; + }): { length: number; x: number; y: number } { + let ray = { length: 0, x: x0, y: y0 }; + + for ( + let length = LENGTH_START; + length < LENGTH_MAX; + length += LENGTH_STEP + ) { + const [x, y] = [x0 + length * dx, y0 + length * dy]; + // offset points are perpendicular to the ray + const offset1: [number, number] = [x + -dy * offset, y + dx * offset]; + const offset2: [number, number] = [x + dy * offset, y + -dx * offset]; + + if (DEBUG.stateLabels) { + drawPoint([x, y], { + color: isInsideState(x, y) ? "blue" : "red", + radius: 0.8, + }); + drawPoint(offset1, { + color: isInsideState(...offset1) ? "blue" : "red", + radius: 0.4, + }); + drawPoint(offset2, { + color: isInsideState(...offset2) ? "blue" : "red", + radius: 0.4, + }); + } + + const inState = + isInsideState(x, y) && + isInsideState(...offset1) && + isInsideState(...offset2); + if (!inState) break; + ray = { length, x, y }; + } + + return ray; + + function isInsideState(x: number, y: number): boolean { + if (x < 0 || x > graphWidth || y < 0 || y > graphHeight) return false; + const cellId = findClosestCell(x, y, undefined, pack) as number; + + const feature = features[cells.f[cellId]]; + if (feature.type === "lake") + return isInnerLake(feature) || isSmallLake(feature); + + return stateIds[cellId] === stateId; + } + + function isInnerLake(feature: { shoreline: number[] }): boolean { + return feature.shoreline.every((cellId) => stateIds[cellId] === stateId); + } + + function isSmallLake(feature: { cells: number }): boolean { + return feature.cells <= maxLakeSize; + } + } + + function findBestRayPair(rays: Ray[]): [Ray, Ray] { + let bestPair: [Ray, Ray] | null = null; + let bestScore = -Infinity; + + for (let i = 0; i < rays.length; i++) { + const score1 = rays[i].length * scoreRayAngle(rays[i].angle); + + for (let j = i + 1; j < rays.length; j++) { + const score2 = rays[j].length * scoreRayAngle(rays[j].angle); + const pairScore = + (score1 + score2) * scoreCurvature(rays[i].angle, rays[j].angle); + + if (pairScore > bestScore) { + bestScore = pairScore; + bestPair = [rays[i], rays[j]]; + } + } + } + + return bestPair!; + } + + function scoreRayAngle(angle: number): number { + const normalizedAngle = Math.abs(angle % 180); // [0, 180] + const horizontality = Math.abs(normalizedAngle - 90) / 90; // [0, 1] + + if (horizontality === 1) return 1; // Best: horizontal + if (horizontality >= 0.75) return 0.9; // Very good: slightly slanted + if (horizontality >= 0.5) return 0.6; // Good: moderate slant + if (horizontality >= 0.25) return 0.5; // Acceptable: more slanted + if (horizontality >= 0.15) return 0.2; // Poor: almost vertical + return 0.1; // Very poor: almost vertical + } + + function scoreCurvature(angle1: number, angle2: number): number { + const delta = getAngleDelta(angle1, angle2); + const similarity = evaluateArc(angle1, angle2); + + if (delta === 180) return 1; // straight line: best + if (delta < 90) return 0; // acute: not allowed + if (delta < 120) return 0.6 * similarity; + if (delta < 140) return 0.7 * similarity; + if (delta < 160) return 0.8 * similarity; + + return similarity; + } + + function getAngleDelta(angle1: number, angle2: number): number { + let delta = Math.abs(angle1 - angle2) % 360; + if (delta > 180) delta = 360 - delta; // [0, 180] + return delta; + } + + // compute arc similarity towards x-axis + function evaluateArc(angle1: number, angle2: number): number { + const proximity1 = Math.abs((angle1 % 180) - 90); + const proximity2 = Math.abs((angle2 % 180) - 90); + return 1 - Math.abs(proximity1 - proximity2) / 90; + } + + function getLinesAndRatio( + mode: string, + name: string, + fullName: string, + pathLength: number, + ): [string[], number] { + if (mode === "short") return getShortOneLine(); + if (pathLength > fullName.length * 2) return getFullOneLine(); + return getFullTwoLines(); + + function getShortOneLine(): [string[], number] { + const ratio = pathLength / name.length; + return [[name], minmax(rn(ratio * 60), 50, 150)]; + } + + function getFullOneLine(): [string[], number] { + const ratio = pathLength / fullName.length; + return [[fullName], minmax(rn(ratio * 70), 70, 170)]; + } + + function getFullTwoLines(): [string[], number] { + const lines = splitInTwo(fullName); + const longestLineLength = max(lines.map((line) => line.length)) || 0; + const ratio = pathLength / longestLineLength; + return [lines, minmax(rn(ratio * 60), 70, 150)]; + } + } + + // check whether multi-lined label is mostly inside the state. If no, replace it with short name label + function checkIfInsideState( + textElement: SVGTextPathElement, + angleRad: number, + halfwidth: number, + halfheight: number, + stateIds: number[], + stateId: number, + ): boolean { + const bbox = textElement.getBBox(); + const [cx, cy] = [bbox.x + bbox.width / 2, bbox.y + bbox.height / 2]; + + const points: [number, number][] = [ + [-halfwidth, -halfheight], + [+halfwidth, -halfheight], + [+halfwidth, halfheight], + [-halfwidth, halfheight], + [0, halfheight], + [0, -halfheight], + ]; + + const sin = Math.sin(angleRad); + const cos = Math.cos(angleRad); + const rotatedPoints = points.map(([x, y]): [number, number] => [ + cx + x * cos - y * sin, + cy + x * sin + y * cos, + ]); + + let pointsInside = 0; + for (const [x, y] of rotatedPoints) { + const isInside = + stateIds[findClosestCell(x, y, undefined, pack) as number] === stateId; + if (isInside) pointsInside++; + if (pointsInside > 4) return true; + } + + return false; + } + + TIME && console.timeEnd("drawStateLabels"); +}; + +window.drawStateLabels = stateLabelsRenderer; diff --git a/src/renderers/draw-temperature.ts b/src/renderers/draw-temperature.ts new file mode 100644 index 00000000..538a7da2 --- /dev/null +++ b/src/renderers/draw-temperature.ts @@ -0,0 +1,155 @@ +import { + color, + curveBasisClosed, + interpolateSpectral, + leastIndex, + line, + max, + min, + range, + scaleSequential, +} from "d3"; +import { byId, connectVertices, convertTemperature, round } from "../utils"; + +declare global { + var drawTemperature: () => void; +} + +const temperatureRenderer = (): void => { + TIME && console.time("drawTemperature"); + + temperature.selectAll("*").remove(); + const lineGen = line<[number, number]>().curve(curveBasisClosed); + const scheme = scaleSequential(interpolateSpectral); + + const tMax = +(byId("temperatureEquatorOutput") as HTMLInputElement).max; + const tMin = +(byId("temperatureEquatorOutput") as HTMLInputElement).min; + const delta = tMax - tMin; + + const { cells, vertices } = grid; + const n = cells.i.length; + + const checkedCells = new Uint8Array(n); + const addToChecked = (cellId: number) => { + checkedCells[cellId] = 1; + }; + + const minTemp = Number(min(cells.temp)) || 0; + const maxTemp = Number(max(cells.temp)) || 0; + const step = Math.max(Math.round(Math.abs(minTemp - maxTemp) / 5), 1); + + const isolines = range(minTemp + step, maxTemp, step); + const chains: [number, [number, number][]][] = []; + const labels: [number, number, number][] = []; // store label coordinates + + for (const cellId of cells.i) { + const t = cells.temp[cellId]; + if (checkedCells[cellId] || !isolines.includes(t)) continue; + + const startingVertex = findStart(cellId, t); + if (!startingVertex) continue; + checkedCells[cellId] = 1; + + const ofSameType = (cellId: number) => cells.temp[cellId] >= t; + const chain = connectVertices({ + vertices, + startingVertex, + ofSameType, + addToChecked, + }); + const relaxed = chain.filter( + (v: number, i: number) => + i % 4 === 0 || vertices.c[v].some((c: number) => c >= n), + ); + if (relaxed.length < 6) continue; + + const points: [number, number][] = relaxed.map( + (v: number) => vertices.p[v], + ); + chains.push([t, points]); + addLabel(points, t); + } + + // min temp isoline covers all graph + temperature + .append("path") + .attr("d", `M0,0 h${graphWidth} v${graphHeight} h${-graphWidth} Z`) + .attr("fill", scheme(1 - (minTemp - tMin) / delta)) + .attr("stroke", "none"); + + for (const t of isolines) { + const path = chains + .filter((c) => c[0] === t) + .map((c) => round(lineGen(c[1]) || "")) + .join(""); + if (!path) continue; + const fill = scheme(1 - (t - tMin) / delta); + const stroke = color(fill)!.darker(0.2); + temperature + .append("path") + .attr("d", path) + .attr("fill", fill) + .attr("stroke", stroke.toString()); + } + + const tempLabels = temperature + .append("g") + .attr("id", "tempLabels") + .attr("fill-opacity", 1); + tempLabels + .selectAll("text") + .data(labels) + .enter() + .append("text") + .attr("x", (d) => d[0]) + .attr("y", (d) => d[1]) + .text((d) => convertTemperature(d[2])); + + // find cell with temp < isotherm and find vertex to start path detection + function findStart(i: number, t: number): number | undefined { + if (cells.b[i]) + return cells.v[i].find((v: number) => + vertices.c[v].some((c: number) => c >= n), + ); // map border cell + return cells.v[i][ + cells.c[i].findIndex((c: number) => cells.temp[c] < t || !cells.temp[c]) + ]; + } + + function addLabel(points: [number, number][], t: number): void { + const xCenter = svgWidth / 2; + + // add label on isoline top center + const tcIndex = leastIndex( + points, + (a: [number, number], b: [number, number]) => + a[1] - b[1] + (Math.abs(a[0] - xCenter) - Math.abs(b[0] - xCenter)) / 2, + ); + const tc = points[tcIndex!]; + pushLabel(tc[0], tc[1], t); + + // add label on isoline bottom center + if (points.length > 20) { + const bcIndex = leastIndex( + points, + (a: [number, number], b: [number, number]) => + b[1] - + a[1] + + (Math.abs(a[0] - xCenter) - Math.abs(b[0] - xCenter)) / 2, + ); + const bc = points[bcIndex!]; + const dist2 = (tc[1] - bc[1]) ** 2 + (tc[0] - bc[0]) ** 2; // square distance between this and top point + if (dist2 > 100) pushLabel(bc[0], bc[1], t); + } + } + + function pushLabel(x: number, y: number, t: number): void { + if (x < 20 || x > svgWidth - 20) return; + if (y < 20 || y > svgHeight - 20) return; + labels.push([x, y, t]); + } + + TIME && console.timeEnd("drawTemperature"); +}; + +window.drawTemperature = temperatureRenderer; diff --git a/src/renderers/index.ts b/src/renderers/index.ts new file mode 100644 index 00000000..5ea6e502 --- /dev/null +++ b/src/renderers/index.ts @@ -0,0 +1,13 @@ +import "./draw-borders"; +import "./draw-burg-icons"; +import "./draw-burg-labels"; +import "./draw-emblems"; +import "./draw-features"; +import "./draw-heightmap"; +import "./draw-ice"; +import "./draw-markers"; +import "./draw-military"; +import "./draw-relief-icons"; +import "./draw-scalebar"; +import "./draw-state-labels"; +import "./draw-temperature"; diff --git a/src/types/PackedGraph.ts b/src/types/PackedGraph.ts new file mode 100644 index 00000000..b8749f0a --- /dev/null +++ b/src/types/PackedGraph.ts @@ -0,0 +1,66 @@ +import type { Burg } from "../modules/burgs-generator"; +import type { Culture } from "../modules/cultures-generator"; +import type { PackedGraphFeature } from "../modules/features"; +import type { Province } from "../modules/provinces-generator"; +import type { River } from "../modules/river-generator"; +import type { Route } from "../modules/routes-generator"; +import type { State } from "../modules/states-generator"; +import type { Zone } from "../modules/zones-generator"; + +type TypedArray = + | Uint8Array + | Uint16Array + | Uint32Array + | Int8Array + | Int16Array + | Float32Array + | Float64Array; + +export interface PackedGraph { + cells: { + i: number[]; // cell indices + c: number[][]; // neighboring cells + v: number[][]; // neighboring vertices + p: [number, number][]; // cell polygon points + b: boolean[]; // cell is on border + h: TypedArray; // cell heights + /** Terrain type */ + t: TypedArray; // cell terrain types + r: TypedArray; // river id passing through cell + f: TypedArray; // feature id occupying cell + fl: TypedArray; // flux presence in cell + s: TypedArray; // cell suitability + pop: TypedArray; // cell population + conf: TypedArray; // cell water confidence + haven: TypedArray; // cell is a haven + g: number[]; // cell ground type + culture: number[]; // cell culture id + biome: TypedArray; // cell biome id + harbor: TypedArray; // cell harbour presence + burg: TypedArray; // cell burg id + religion: TypedArray; // cell religion id + state: number[]; // cell state id + area: TypedArray; // cell area + province: TypedArray; // cell province id + routes: Record>; + }; + vertices: { + i: number[]; // vertex indices + c: [number, number, number][]; // neighboring cells + v: number[][]; // neighboring vertices + x: number[]; // x coordinates + y: number[]; // y coordinates + p: [number, number][]; // vertex points + }; + rivers: River[]; + features: PackedGraphFeature[]; + burgs: Burg[]; + states: State[]; + cultures: Culture[]; + routes: Route[]; + religions: any[]; + zones: Zone[]; + markers: any[]; + ice: any[]; + provinces: Province[]; +} diff --git a/src/types/global.ts b/src/types/global.ts new file mode 100644 index 00000000..cb92d793 --- /dev/null +++ b/src/types/global.ts @@ -0,0 +1,89 @@ +import type { Selection } from "d3"; +import type { NameBase } from "../modules/names-generator"; +import type { PackedGraph } from "./PackedGraph"; + +declare global { + var seed: string; + var pack: PackedGraph; + var grid: any; + var graphHeight: number; + var graphWidth: number; + var TIME: boolean; + var WARN: boolean; + var ERROR: boolean; + var DEBUG: { stateLabels?: boolean; [key: string]: boolean | undefined }; + var options: any; + + var heightmapTemplates: any; + var Routes: any; + var populationRate: number; + var urbanDensity: number; + var urbanization: number; + var distanceScale: number; + var nameBases: NameBase[]; + + var pointsInput: HTMLInputElement; + var culturesInput: HTMLInputElement; + var culturesSet: HTMLSelectElement; + var heightExponentInput: HTMLInputElement; + var alertMessage: HTMLElement; + var mapName: HTMLInputElement; + var religionsNumber: HTMLInputElement; + var distanceUnitInput: HTMLInputElement; + + var rivers: Selection; + var oceanLayers: Selection; + var emblems: Selection; + var svg: Selection; + var ice: Selection; + var labels: Selection; + var burgLabels: Selection; + var burgIcons: Selection; + var anchors: Selection; + var terrs: Selection; + var temperature: Selection; + var markers: Selection; + var defs: Selection; + var coastline: Selection; + var lakes: Selection; + var getColorScheme: (scheme: string | null) => (t: number) => string; + var getColor: (height: number, scheme: (t: number) => string) => string; + var svgWidth: number; + var svgHeight: number; + var viewbox: Selection; + var routes: Selection; + var biomesData: { + i: number[]; + name: string[]; + color: string[]; + biomesMatrix: Uint8Array[]; + habitability: number[]; + iconsDensity: number[]; + icons: string[][]; + cost: number[]; + }; + var COA: any; + var notes: any[]; + var style: { + burgLabels: { [key: string]: { [key: string]: string } }; + burgIcons: { [key: string]: { [key: string]: string } }; + anchors: { [key: string]: { [key: string]: string } }; + [key: string]: any; + }; + + var layerIsOn: (layerId: string) => boolean; + var drawRoute: (route: any) => void; + var invokeActiveZooming: () => void; + var COArenderer: { trigger: (id: string, coa: any) => void }; + var FlatQueue: any; + + var tip: ( + message: string, + autoHide?: boolean, + type?: "info" | "warning" | "error", + ) => void; + var locked: (settingId: string) => boolean; + var unlock: (settingId: string) => void; + var $: (selector: any) => any; + var scale: number; +} diff --git a/src/utils/arrayUtils.ts b/src/utils/arrayUtils.ts index ad2f9486..add587d8 100644 --- a/src/utils/arrayUtils.ts +++ b/src/utils/arrayUtils.ts @@ -5,7 +5,7 @@ */ export const last = (array: T[]): T => { return array[array.length - 1]; -} +}; /** * Get unique elements from an array @@ -14,7 +14,7 @@ export const last = (array: T[]): T => { */ export const unique = (array: T[]): T[] => { return [...new Set(array)]; -} +}; /** * Deep copy an object or array @@ -24,12 +24,15 @@ export const unique = (array: T[]): T[] => { export const deepCopy = (obj: T): T => { const id = (x: T): T => x; const dcTArray = (a: T[]): T[] => a.map(id); - const dcObject = (x: object): object => Object.fromEntries(Object.entries(x).map(([k, d]) => [k, dcAny(d)])); - const dcAny = (x: any): any => (x instanceof Object ? (cf.get(x.constructor) || id)(x) : x); + const dcObject = (x: object): object => + Object.fromEntries(Object.entries(x).map(([k, d]) => [k, dcAny(d)])); + const dcAny = (x: any): any => + x instanceof Object ? (cf.get(x.constructor) || id)(x) : x; // don't map keys, probably this is what we would expect - const dcMapCore = (m: Map): [any, any][] => [...m.entries()].map(([k, v]) => [k, dcAny(v)]); + const dcMapCore = (m: Map): [any, any][] => + [...m.entries()].map(([k, v]) => [k, dcAny(v)]); - const cf: Map any> = new Map any>([ + const cf: Map any> = new Map any>([ [Int8Array, dcTArray], [Uint8Array, dcTArray], [Uint8ClampedArray, dcTArray], @@ -41,17 +44,17 @@ export const deepCopy = (obj: T): T => { [Float64Array, dcTArray], [BigInt64Array, dcTArray], [BigUint64Array, dcTArray], - [Map, m => new Map(dcMapCore(m))], - [WeakMap, m => new WeakMap(dcMapCore(m))], - [Array, a => a.map(dcAny)], - [Set, s => [...s.values()].map(dcAny)], - [Date, d => new Date(d.getTime())], - [Object, dcObject] + [Map, (m) => new Map(dcMapCore(m))], + [WeakMap, (m) => new WeakMap(dcMapCore(m))], + [Array, (a) => a.map(dcAny)], + [Set, (s) => [...s.values()].map(dcAny)], + [Date, (d) => new Date(d.getTime())], + [Object, dcObject], // ... extend here to implement their custom deep copy ]); return dcAny(obj); -} +}; /** * Get the appropriate typed array constructor based on the maximum value @@ -60,15 +63,17 @@ export const deepCopy = (obj: T): T => { */ export const getTypedArray = (maxValue: number) => { console.assert( - Number.isInteger(maxValue) && maxValue >= 0 && maxValue <= TYPED_ARRAY_MAX_VALUES.UINT32_MAX, - `Array maxValue must be an integer between 0 and ${TYPED_ARRAY_MAX_VALUES.UINT32_MAX}, got ${maxValue}` + Number.isInteger(maxValue) && + maxValue >= 0 && + maxValue <= TYPED_ARRAY_MAX_VALUES.UINT32_MAX, + `Array maxValue must be an integer between 0 and ${TYPED_ARRAY_MAX_VALUES.UINT32_MAX}, got ${maxValue}`, ); if (maxValue <= TYPED_ARRAY_MAX_VALUES.UINT8_MAX) return Uint8Array; if (maxValue <= TYPED_ARRAY_MAX_VALUES.UINT16_MAX) return Uint16Array; if (maxValue <= TYPED_ARRAY_MAX_VALUES.UINT32_MAX) return Uint32Array; return Uint32Array; -} +}; /** * Create a typed array based on the maximum value and length or from an existing array @@ -78,18 +83,26 @@ export const getTypedArray = (maxValue: number) => { * @param {Array} [options.from] - An optional array to create the typed array from * @returns The created typed array */ -export const createTypedArray = ({maxValue, length, from}: {maxValue: number; length: number; from?: ArrayLike}): Uint8Array | Uint16Array | Uint32Array => { +export const createTypedArray = ({ + maxValue, + length, + from, +}: { + maxValue: number; + length: number; + from?: ArrayLike; +}): Uint8Array | Uint16Array | Uint32Array => { const typedArray = getTypedArray(maxValue); if (!from) return new typedArray(length); return typedArray.from(from); -} +}; // typed arrays max values export const TYPED_ARRAY_MAX_VALUES = { INT8_MAX: 127, UINT8_MAX: 255, UINT16_MAX: 65535, - UINT32_MAX: 4294967295 + UINT32_MAX: 4294967295, }; declare global { diff --git a/src/utils/colorUtils.ts b/src/utils/colorUtils.ts index 047d6eae..9a2d26f5 100644 --- a/src/utils/colorUtils.ts +++ b/src/utils/colorUtils.ts @@ -1,4 +1,12 @@ -import { color, interpolate, interpolateRainbow, range, RGBColor, scaleSequential, shuffle } from "d3"; +import { + color, + interpolate, + interpolateRainbow, + type RGBColor, + range, + scaleSequential, + shuffler, +} from "d3"; /** * Convert RGB or RGBA color to HEX @@ -8,14 +16,16 @@ import { color, interpolate, interpolateRainbow, range, RGBColor, scaleSequentia export const toHEX = (rgba: string): string => { if (rgba.charAt(0) === "#") return rgba; - const matches = rgba.match(/^rgba?[\s+]?\([\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?/i); + const matches = rgba.match( + /^rgba?[\s+]?\([\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?/i, + ); return matches && matches.length === 4 ? "#" + - ("0" + parseInt(matches[1], 10).toString(16)).slice(-2) + - ("0" + parseInt(matches[2], 10).toString(16)).slice(-2) + - ("0" + parseInt(matches[3], 10).toString(16)).slice(-2) + `0${parseInt(matches[1], 10).toString(16)}`.slice(-2) + + `0${parseInt(matches[2], 10).toString(16)}`.slice(-2) + + `0${parseInt(matches[3], 10).toString(16)}`.slice(-2) : ""; -} +}; /** Predefined set of 12 distinct colors */ export const C_12 = [ @@ -30,30 +40,39 @@ export const C_12 = [ "#ccebc5", "#ffed6f", "#8dd3c7", - "#eb8de7" + "#eb8de7", ]; -/** +/** * Get an array of distinct colors + * Uses shuffler with current Math.random to ensure seeded randomness works * @param {number} count - The count of colors to generate * @returns {string[]} - The array of HEX color strings -*/ + */ export const getColors = (count: number): string[] => { const scaleRainbow = scaleSequential(interpolateRainbow); + // Use shuffler() to create a shuffle function that uses the current Math.random + const shuffle = shuffler(() => Math.random()); const colors = shuffle( - range(count).map(i => (i < 12 ? C_12[i] : color(scaleRainbow((i - 12) / (count - 12)))?.formatHex())) + range(count).map((i) => + i < 12 + ? C_12[i] + : color(scaleRainbow((i - 12) / (count - 12)))?.formatHex(), + ), ); return colors.filter((c): c is string => typeof c === "string"); -} +}; /** * Get a random color in HEX format * @returns {string} - The HEX color string */ export const getRandomColor = (): string => { - const colorFromRainbow: RGBColor = color(scaleSequential(interpolateRainbow)(Math.random())) as RGBColor; + const colorFromRainbow: RGBColor = color( + scaleSequential(interpolateRainbow)(Math.random()), + ) as RGBColor; return colorFromRainbow.formatHex(); -} +}; /** * Get a mixed color by blending a given color with a random color @@ -62,11 +81,17 @@ export const getRandomColor = (): string => { * @param {number} bright - The brightness adjustment * @returns {string} - The mixed HEX color string */ -export const getMixedColor = (colorToMix: string, mix = 0.2, bright = 0.3): string => { +export const getMixedColor = ( + colorToMix: string, + mix = 0.2, + bright = 0.3, +): string => { const c = colorToMix && colorToMix[0] === "#" ? colorToMix : getRandomColor(); // if provided color is not hex (e.g. harching), generate random one - const mixedColor: RGBColor = color(interpolate(c, getRandomColor())(mix)) as RGBColor; + const mixedColor: RGBColor = color( + interpolate(c, getRandomColor())(mix), + ) as RGBColor; return mixedColor.brighter(bright).formatHex(); -} +}; declare global { interface Window { @@ -75,5 +100,5 @@ declare global { getRandomColor: typeof getRandomColor; getMixedColor: typeof getMixedColor; C_12: typeof C_12; - } + } } diff --git a/src/utils/commonUtils.test.ts b/src/utils/commonUtils.test.ts new file mode 100644 index 00000000..c5ed8f7e --- /dev/null +++ b/src/utils/commonUtils.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; +import { getCoordinates, getLatitude, getLongitude } from "./commonUtils"; + +describe("getLongitude", () => { + const mapCoordinates = { lonW: -10, lonT: 20 }; + const graphWidth = 1000; + + it("should calculate longitude at the left edge (x=0)", () => { + expect(getLongitude(0, mapCoordinates, graphWidth, 2)).toBe(-10); + }); + + it("should calculate longitude at the right edge (x=graphWidth)", () => { + expect(getLongitude(1000, mapCoordinates, graphWidth, 2)).toBe(10); + }); + + it("should calculate longitude at the center (x=graphWidth/2)", () => { + expect(getLongitude(500, mapCoordinates, graphWidth, 2)).toBe(0); + }); + + it("should respect decimal precision", () => { + // 333/1000 * 20 = 6.66, -10 + 6.66 = -3.34 + expect(getLongitude(333, mapCoordinates, graphWidth, 4)).toBe(-3.34); + }); + + it("should handle different map coordinate ranges", () => { + const wideMap = { lonW: -180, lonT: 360 }; + expect(getLongitude(500, wideMap, graphWidth, 2)).toBe(0); + expect(getLongitude(0, wideMap, graphWidth, 2)).toBe(-180); + expect(getLongitude(1000, wideMap, graphWidth, 2)).toBe(180); + }); +}); + +describe("getLatitude", () => { + const mapCoordinates = { latN: 60, latT: 40 }; + const graphHeight = 800; + + it("should calculate latitude at the top edge (y=0)", () => { + expect(getLatitude(0, mapCoordinates, graphHeight, 2)).toBe(60); + }); + + it("should calculate latitude at the bottom edge (y=graphHeight)", () => { + expect(getLatitude(800, mapCoordinates, graphHeight, 2)).toBe(20); + }); + + it("should calculate latitude at the center (y=graphHeight/2)", () => { + expect(getLatitude(400, mapCoordinates, graphHeight, 2)).toBe(40); + }); + + it("should respect decimal precision", () => { + // 60 - (333/800 * 40) = 60 - 16.65 = 43.35 + expect(getLatitude(333, mapCoordinates, graphHeight, 4)).toBe(43.35); + }); + + it("should handle equator-centered maps", () => { + const equatorMap = { latN: 45, latT: 90 }; + expect(getLatitude(400, equatorMap, graphHeight, 2)).toBe(0); + }); +}); + +describe("getCoordinates", () => { + const mapCoordinates = { lonW: -10, lonT: 20, latN: 60, latT: 40 }; + const graphWidth = 1000; + const graphHeight = 800; + + it("should return [longitude, latitude] tuple", () => { + const result = getCoordinates( + 500, + 400, + mapCoordinates, + graphWidth, + graphHeight, + 2, + ); + expect(result).toEqual([0, 40]); + }); + + it("should calculate coordinates at top-left corner", () => { + const result = getCoordinates( + 0, + 0, + mapCoordinates, + graphWidth, + graphHeight, + 2, + ); + expect(result).toEqual([-10, 60]); + }); + + it("should calculate coordinates at bottom-right corner", () => { + const result = getCoordinates( + 1000, + 800, + mapCoordinates, + graphWidth, + graphHeight, + 2, + ); + expect(result).toEqual([10, 20]); + }); + + it("should respect decimal precision for both coordinates", () => { + const result = getCoordinates( + 333, + 333, + mapCoordinates, + graphWidth, + graphHeight, + 4, + ); + expect(result[0]).toBe(-3.34); // longitude + expect(result[1]).toBe(43.35); // latitude + }); + + it("should use default precision of 2 decimals", () => { + const result = getCoordinates( + 333, + 333, + mapCoordinates, + graphWidth, + graphHeight, + ); + expect(result[0]).toBe(-3.34); + expect(result[1]).toBe(43.35); + }); + + it("should handle global map coordinates", () => { + const globalMap = { lonW: -180, lonT: 360, latN: 90, latT: 180 }; + const result = getCoordinates( + 500, + 400, + globalMap, + graphWidth, + graphHeight, + 2, + ); + expect(result).toEqual([0, 0]); // center of the world + }); +}); diff --git a/src/utils/commonUtils.ts b/src/utils/commonUtils.ts index d5f2fc9a..6808eb11 100644 --- a/src/utils/commonUtils.ts +++ b/src/utils/commonUtils.ts @@ -1,7 +1,7 @@ -import { distanceSquared } from "./functionUtils"; -import { rand } from "./probabilityUtils"; -import { rn } from "./numberUtils"; import { last } from "./arrayUtils"; +import { distanceSquared } from "./functionUtils"; +import { rn } from "./numberUtils"; +import { rand } from "./probabilityUtils"; /** * Clip polygon points to graph boundaries @@ -11,15 +11,20 @@ import { last } from "./arrayUtils"; * @param secure - Secure clipping to avoid edge artifacts * @returns Clipped polygon points */ -export const clipPoly = (points: [number, number][], graphWidth: number, graphHeight: number, secure: number = 0) => { +export const clipPoly = ( + points: [number, number][], + graphWidth?: number, + graphHeight?: number, + secure: number = 0, +) => { if (points.length < 2) return points; - if (points.some(point => point === undefined)) { + if (points.some((point) => point === undefined)) { window.ERROR && console.error("Undefined point in clipPoly", points); return points; } return window.polygonclip(points, [0, 0, graphWidth, graphHeight], secure); -} +}; /** * Get segment of any point on polyline @@ -28,7 +33,11 @@ export const clipPoly = (points: [number, number][], graphWidth: number, graphHe * @param step - Step size for segment search (default is 10) * @returns The segment ID (1-indexed) */ -export const getSegmentId = (points: [number, number][], point: [number, number], step: number = 10): number => { +export const getSegmentId = ( + points: [number, number][], + point: [number, number], + step: number = 10, +): number => { if (points.length === 2) return 1; let minSegment = 1; @@ -55,7 +64,7 @@ export const getSegmentId = (points: [number, number][], point: [number, number] } return minSegment; -} +}; /** * Creates a debounced function that delays invoking func until after ms milliseconds have elapsed @@ -63,16 +72,21 @@ export const getSegmentId = (points: [number, number][], point: [number, number] * @param ms - The number of milliseconds to delay * @returns The debounced function */ -export const debounce = any>(func: T, ms: number) => { +export const debounce = any>( + func: T, + ms: number, +) => { let isCooldown = false; return function (this: any, ...args: Parameters) { if (isCooldown) return; func.apply(this, args); isCooldown = true; - setTimeout(() => (isCooldown = false), ms); + setTimeout(() => { + isCooldown = false; + }, ms); }; -} +}; /** * Creates a throttled function that only invokes func at most once every ms milliseconds @@ -80,7 +94,10 @@ export const debounce = any>(func: T, ms: number) * @param ms - The number of milliseconds to throttle invocations to * @returns The throttled function */ -export const throttle = any>(func: T, ms: number) => { +export const throttle = any>( + func: T, + ms: number, +) => { let isThrottled = false; let savedArgs: any[] | null = null; let savedThis: any = null; @@ -95,7 +112,7 @@ export const throttle = any>(func: T, ms: number) func.apply(this, args); isThrottled = true; - setTimeout(function () { + setTimeout(() => { isThrottled = false; if (savedArgs) { wrapper.apply(savedThis, savedArgs as Parameters); @@ -105,7 +122,7 @@ export const throttle = any>(func: T, ms: number) } return wrapper; -} +}; /** * Parse error to get the readable string in Chrome and Firefox @@ -114,23 +131,32 @@ export const throttle = any>(func: T, ms: number) */ export const parseError = (error: Error): string => { const isFirefox = navigator.userAgent.toLowerCase().indexOf("firefox") > -1; - const errorString = isFirefox ? error.toString() + " " + error.stack : error.stack || ""; - const regex = /(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gi; - const errorNoURL = errorString.replace(regex, url => "" + last(url.split("/")) + ""); + const errorString = isFirefox + ? `${error.toString()} ${error.stack}` + : error.stack || ""; + const regex = + /(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#/%?=~_|!:,.;]*[-A-Z0-9+&@#/%=~_|])/gi; + const errorNoURL = errorString.replace( + regex, + (url) => `${last(url.split("/"))}`, + ); const errorParsed = errorNoURL.replace(/at /gi, "
  at "); return errorParsed; -} +}; /** * Convert a URL to base64 encoded data * @param url - The URL to convert * @param callback - Callback function that receives the base64 data */ -export const getBase64 = (url: string, callback: (result: string | ArrayBuffer | null) => void): void => { +export const getBase64 = ( + url: string, + callback: (result: string | ArrayBuffer | null) => void, +): void => { const xhr = new XMLHttpRequest(); - xhr.onload = function () { + xhr.onload = () => { const reader = new FileReader(); - reader.onloadend = function () { + reader.onloadend = () => { callback(reader.result); }; reader.readAsDataURL(xhr.response); @@ -138,7 +164,7 @@ export const getBase64 = (url: string, callback: (result: string | ArrayBuffer | xhr.open("GET", url); xhr.responseType = "blob"; xhr.send(); -} +}; /** * Open URL in a new tab or window @@ -146,15 +172,18 @@ export const getBase64 = (url: string, callback: (result: string | ArrayBuffer | */ export const openURL = (url: string): void => { window.open(url, "_blank"); -} +}; /** * Open project wiki-page * @param page - The wiki page name/path to open */ export const wiki = (page: string): void => { - window.open("https://github.com/Azgaar/Fantasy-Map-Generator/wiki/" + page, "_blank"); -} + window.open( + `https://github.com/Azgaar/Fantasy-Map-Generator/wiki/${page}`, + "_blank", + ); +}; /** * Wrap URL into html a element @@ -164,7 +193,7 @@ export const wiki = (page: string): void => { */ export const link = (URL: string, description: string): string => { return `${description}`; -} +}; /** * Check if Ctrl key (or Cmd on Mac) was pressed during an event @@ -174,7 +203,7 @@ export const link = (URL: string, description: string): string => { export const isCtrlClick = (event: MouseEvent | KeyboardEvent): boolean => { // meta key is cmd key on MacOs return event.ctrlKey || event.metaKey; -} +}; /** * Generate a random date within a specified range @@ -186,9 +215,9 @@ export const generateDate = (from: number = 100, to: number = 1000): string => { return new Date(rand(from, to), rand(12), rand(31)).toLocaleDateString("en", { year: "numeric", month: "long", - day: "numeric" + day: "numeric", }); -} +}; /** * Convert x coordinate to longitude @@ -198,9 +227,17 @@ export const generateDate = (from: number = 100, to: number = 1000): string => { * @param decimals - Number of decimal places (default is 2) * @returns Longitude value */ -export const getLongitude = (x: number, mapCoordinates: any, graphWidth: number, decimals: number = 2): number => { - return rn(mapCoordinates.lonW + (x / graphWidth) * mapCoordinates.lonT, decimals); -} +export const getLongitude = ( + x: number, + mapCoordinates: any, + graphWidth: number, + decimals: number = 2, +): number => { + return rn( + mapCoordinates.lonW + (x / graphWidth) * mapCoordinates.lonT, + decimals, + ); +}; /** * Convert y coordinate to latitude @@ -210,9 +247,17 @@ export const getLongitude = (x: number, mapCoordinates: any, graphWidth: number, * @param decimals - Number of decimal places (default is 2) * @returns Latitude value */ -export const getLatitude = (y: number, mapCoordinates: any, graphHeight: number, decimals: number = 2): number => { - return rn(mapCoordinates.latN - (y / graphHeight) * mapCoordinates.latT, decimals); -} +export const getLatitude = ( + y: number, + mapCoordinates: any, + graphHeight: number, + decimals: number = 2, +): number => { + return rn( + mapCoordinates.latN - (y / graphHeight) * mapCoordinates.latT, + decimals, + ); +}; /** * Convert x,y coordinates to longitude,latitude @@ -224,9 +269,19 @@ export const getLatitude = (y: number, mapCoordinates: any, graphHeight: number, * @param decimals - Number of decimal places (default is 2) * @returns Array with [longitude, latitude] */ -export const getCoordinates = (x: number, y: number, mapCoordinates: any, graphWidth: number, graphHeight: number, decimals: number = 2): [number, number] => { - return [getLongitude(x, mapCoordinates, graphWidth, decimals), getLatitude(y, mapCoordinates, graphHeight, decimals)]; -} +export const getCoordinates = ( + x: number, + y: number, + mapCoordinates: any, + graphWidth: number, + graphHeight: number, + decimals: number = 2, +): [number, number] => { + return [ + getLongitude(x, mapCoordinates, graphWidth, decimals), + getLatitude(y, mapCoordinates, graphHeight, decimals), + ]; +}; /** * Prompt options interface @@ -246,22 +301,39 @@ export interface PromptOptions { export const initializePrompt = (): void => { const prompt = document.getElementById("prompt"); if (!prompt) return; - + const form = prompt.querySelector("#promptForm"); if (!form) return; const defaultText = "Please provide an input"; - const defaultOptions: PromptOptions = {default: 1, step: 0.01, min: 0, max: 100, required: true}; + const defaultOptions: PromptOptions = { + default: 1, + step: 0.01, + min: 0, + max: 100, + required: true, + }; - (window as any).prompt = function (promptText: string = defaultText, options: PromptOptions = defaultOptions, callback?: (value: number | string) => void) { + (window as any).prompt = ( + promptText: string = defaultText, + options: PromptOptions = defaultOptions, + callback?: (value: number | string) => void, + ) => { if (options.default === undefined) - return window.ERROR && console.error("Prompt: options object does not have default value defined"); + return ( + window.ERROR && + console.error( + "Prompt: options object does not have default value defined", + ) + ); const input = prompt.querySelector("#promptInput") as HTMLInputElement; - const promptTextElement = prompt.querySelector("#promptText") as HTMLElement; - + const promptTextElement = prompt.querySelector( + "#promptText", + ) as HTMLElement; + if (!input || !promptTextElement) return; - + promptTextElement.innerHTML = promptText; const type = typeof options.default === "number" ? "number" : "text"; @@ -271,8 +343,8 @@ export const initializePrompt = (): void => { if (options.min !== undefined) input.min = options.min.toString(); if (options.max !== undefined) input.max = options.max.toString(); - input.required = options.required === false ? false : true; - input.placeholder = "type a " + type; + input.required = options.required !== false; + input.placeholder = `type a ${type}`; input.value = options.default.toString(); input.style.width = promptText.length > 10 ? "100%" : "auto"; prompt.style.display = "block"; @@ -285,7 +357,7 @@ export const initializePrompt = (): void => { const v = type === "number" ? +input.value : input.value; if (callback) callback(v); }, - {once: true} + { once: true }, ); }; @@ -295,13 +367,13 @@ export const initializePrompt = (): void => { prompt.style.display = "none"; }); } -} +}; declare global { interface Window { ERROR: boolean; polygonclip: any; - + clipPoly: typeof clipPoly; getSegmentId: typeof getSegmentId; debounce: typeof debounce; @@ -317,4 +389,16 @@ declare global { getLatitude: typeof getLatitude; getCoordinates: typeof getCoordinates; } -} \ No newline at end of file + + // Global variables defined in main.js + var mapCoordinates: { + latT?: number; + latN?: number; + latS?: number; + lonT?: number; + lonW?: number; + lonE?: number; + }; + var graphWidth: number; + var graphHeight: number; +} diff --git a/src/utils/debugUtils.ts b/src/utils/debugUtils.ts index 6b236ebe..dec49390 100644 --- a/src/utils/debugUtils.ts +++ b/src/utils/debugUtils.ts @@ -1,7 +1,7 @@ -import {curveBundle, line, max, min} from "d3"; -import { normalize } from "./numberUtils"; -import { getGridPolygon } from "./graphUtils"; +import { curveBundle, line, max, min } from "d3"; import { C_12 } from "./colorUtils"; +import { getGridPolygon } from "./graphUtils"; +import { normalize } from "./numberUtils"; import { round } from "./stringUtils"; /** @@ -19,7 +19,7 @@ export const drawCellsValue = (data: any[], packedGraph: any): void => { .attr("x", (_d: any, i: number) => packedGraph.cells.p[i][0]) .attr("y", (_d: any, i: number) => packedGraph.cells.p[i][1]) .text((d: any) => d); -} +}; /** * Drawing polygons colored according to data values for debugging purposes * @param {number[]} data - Array of numerical values corresponding to each cell @@ -28,9 +28,11 @@ export const drawCellsValue = (data: any[], packedGraph: any): void => { export const drawPolygons = (data: number[], terrs: any, grid: any): void => { const maximum: number = max(data) as number; const minimum: number = min(data) as number; - const scheme = window.getColorScheme(terrs.select("#landHeights").attr("scheme")); + const scheme = window.getColorScheme( + terrs.select("#landHeights").attr("scheme"), + ); - data = data.map(d => 1 - normalize(d, minimum, maximum)); + data = data.map((d) => 1 - normalize(d, minimum, maximum)); window.debug.selectAll("polygon").remove(); window.debug .selectAll("polygon") @@ -40,7 +42,7 @@ export const drawPolygons = (data: number[], terrs: any, grid: any): void => { .attr("points", (_d: number, i: number) => getGridPolygon(i, grid)) .attr("fill", (d: number) => scheme(d)) .attr("stroke", (d: number) => scheme(d)); -} +}; /** * Drawing route connections for debugging purposes @@ -48,7 +50,10 @@ export const drawPolygons = (data: number[], terrs: any, grid: any): void => { */ export const drawRouteConnections = (packedGraph: any): void => { window.debug.select("#connections").remove(); - const routes = window.debug.append("g").attr("id", "connections").attr("stroke-width", 0.8); + const routes = window.debug + .append("g") + .attr("id", "connections") + .attr("stroke-width", 0.8); const points = packedGraph.cells.p; const links = packedGraph.cells.routes; @@ -70,7 +75,7 @@ export const drawRouteConnections = (packedGraph: any): void => { .attr("stroke", C_12[routeId % 12]); } } -} +}; /** * Drawing a point for debugging purposes @@ -79,9 +84,17 @@ export const drawRouteConnections = (packedGraph: any): void => { * @param {string} options.color - Color of the point * @param {number} options.radius - Radius of the point */ -export const drawPoint = ([x, y]: [number, number], {color = "red", radius = 0.5}): void => { - window.debug.append("circle").attr("cx", x).attr("cy", y).attr("r", radius).attr("fill", color); -} +export const drawPoint = ( + [x, y]: [number, number], + { color = "red", radius = 0.5 }, +): void => { + window.debug + .append("circle") + .attr("cx", x) + .attr("cy", y) + .attr("r", radius) + .attr("fill", color); +}; /** * Drawing a path for debugging purposes @@ -90,7 +103,10 @@ export const drawPoint = ([x, y]: [number, number], {color = "red", radius = 0.5 * @param {string} options.color - Color of the path * @param {number} options.width - Stroke width of the path */ -export const drawPath = (points: [number, number][], {color = "red", width = 0.5}): void => { +export const drawPath = ( + points: [number, number][], + { color = "red", width = 0.5 }, +): void => { const lineGen = line().curve(curveBundle); window.debug .append("path") @@ -98,17 +114,17 @@ export const drawPath = (points: [number, number][], {color = "red", width = 0.5 .attr("stroke", color) .attr("stroke-width", width) .attr("fill", "none"); -} +}; declare global { interface Window { debug: any; getColorScheme: (name: string) => (t: number) => string; - + drawCellsValue: typeof drawCellsValue; drawPolygons: typeof drawPolygons; drawRouteConnections: typeof drawRouteConnections; drawPoint: typeof drawPoint; drawPath: typeof drawPath; - } -} \ No newline at end of file + } +} diff --git a/src/utils/functionUtils.ts b/src/utils/functionUtils.ts index 5a3d7283..a753c019 100644 --- a/src/utils/functionUtils.ts +++ b/src/utils/functionUtils.ts @@ -4,7 +4,7 @@ * @param {Function} reduce - The reduce function to apply to each group * @param {...Function} keys - The key functions to group by * @returns {Map} - The regrouped and reduced Map - * + * * @example * const data = [ * {category: 'A', type: 'X', value: 10}, @@ -24,11 +24,20 @@ * // 'B' => Map { 'X' => 30, 'Y' => 40 } * // } */ -export const rollups = (values: any[], reduce: (values: any[]) => any, ...keys: ((value: any, index: number, array: any[]) => any)[]) => { +export const rollups = ( + values: any[], + reduce: (values: any[]) => any, + ...keys: ((value: any, index: number, array: any[]) => any)[] +) => { return nest(values, Array.from, reduce, keys); -} +}; -const nest = (values: any[], map: (iterable: Iterable) => any, reduce: (values: any[]) => any, keys: ((value: any, index: number, array: any[]) => any)[]) => { +const nest = ( + values: any[], + map: (iterable: Iterable) => any, + reduce: (values: any[]) => any, + keys: ((value: any, index: number, array: any[]) => any)[], +) => { return (function regroup(values, i) { if (i >= keys.length) return reduce(values); const groups = new Map(); @@ -45,7 +54,7 @@ const nest = (values: any[], map: (iterable: Iterable) => any, reduce: (val } return map(groups); })(values, 0); -} +}; /** * Calculate squared distance between two points @@ -53,12 +62,15 @@ const nest = (values: any[], map: (iterable: Iterable) => any, reduce: (val * @param {[number, number]} p2 - Second point [x2, y2] * @returns {number} - Squared distance between p1 and p2 */ -export const distanceSquared = ([x1, y1]: [number, number], [x2, y2]: [number, number]) => { +export const distanceSquared = ( + [x1, y1]: [number, number], + [x2, y2]: [number, number], +) => { return (x1 - x2) ** 2 + (y1 - y2) ** 2; -} +}; declare global { interface Window { rollups: typeof rollups; dist2: typeof distanceSquared; } -} \ No newline at end of file +} diff --git a/src/utils/graphUtils.ts b/src/utils/graphUtils.ts index 875445fb..9b241780 100644 --- a/src/utils/graphUtils.ts +++ b/src/utils/graphUtils.ts @@ -1,10 +1,15 @@ -import Delaunator from "delaunator"; import Alea from "alea"; import { color } from "d3"; -import { byId } from "./shorthands"; -import { rn } from "./numberUtils"; +import Delaunator from "delaunator"; +import { + type Cells, + type Point, + type Vertices, + Voronoi, +} from "../modules/voronoi"; import { createTypedArray } from "./arrayUtils"; -import { Cells, Vertices, Voronoi, Point } from "../modules/voronoi"; +import { rn } from "./numberUtils"; +import { byId } from "./shorthands"; /** * Get boundary points on a regular square grid @@ -13,7 +18,11 @@ import { Cells, Vertices, Voronoi, Point } from "../modules/voronoi"; * @param {number} spacing - The spacing between points * @returns {Array} - An array of boundary points */ -const getBoundaryPoints = (width: number, height: number, spacing: number): Point[] => { +const getBoundaryPoints = ( + width: number, + height: number, + spacing: number, +): Point[] => { const offset = rn(-1 * spacing); const bSpacing = spacing * 2; const w = width - offset * 2; @@ -23,17 +32,17 @@ const getBoundaryPoints = (width: number, height: number, spacing: number): Poin const points: Point[] = []; for (let i = 0.5; i < numberX; i++) { - let x = Math.ceil((w * i) / numberX + offset); + const x = Math.ceil((w * i) / numberX + offset); points.push([x, offset], [x, h + offset]); } for (let i = 0.5; i < numberY; i++) { - let y = Math.ceil((h * i) / numberY + offset); + const y = Math.ceil((h * i) / numberY + offset); points.push([offset, y], [w + offset, y]); } return points; -} +}; /** * Get points on a jittered square grid @@ -42,13 +51,17 @@ const getBoundaryPoints = (width: number, height: number, spacing: number): Poin * @param {number} spacing - The spacing between points * @returns {Array} - An array of jittered grid points */ -const getJitteredGrid = (width: number, height: number, spacing: number): Point[] => { +const getJitteredGrid = ( + width: number, + height: number, + spacing: number, +): Point[] => { const radius = spacing / 2; // square radius const jittering = radius * 0.9; // max deviation const doubleJittering = jittering * 2; const jitter = () => Math.random() * doubleJittering - jittering; - let points: Point[] = []; + const points: Point[] = []; for (let y = radius; y < height; y += spacing) { for (let x = radius; x < width; x += spacing) { const xj = Math.min(rn(x + jitter(), 2), width); @@ -57,7 +70,7 @@ const getJitteredGrid = (width: number, height: number, spacing: number): Point[ } } return points; -} +}; /** * Places points on a jittered grid and calculates spacing and cell counts @@ -65,7 +78,17 @@ const getJitteredGrid = (width: number, height: number, spacing: number): Point[ * @param {number} graphHeight - The height of the graph * @returns {Object} - An object containing spacing, cellsDesired, boundary points, grid points, cellsX, and cellsY */ -const placePoints = (graphWidth: number, graphHeight: number): {spacing: number, cellsDesired: number, boundary: Point[], points: Point[], cellsX: number, cellsY: number} => { +const placePoints = ( + graphWidth: number, + graphHeight: number, +): { + spacing: number; + cellsDesired: number; + boundary: Point[]; + points: Point[]; + cellsX: number; + cellsY: number; +} => { TIME && console.time("placePoints"); const cellsDesired = +(byId("pointsInput")?.dataset.cells || 0); const spacing = rn(Math.sqrt((graphWidth * graphHeight) / cellsDesired), 2); // spacing between points before jittering @@ -73,12 +96,20 @@ const placePoints = (graphWidth: number, graphHeight: number): {spacing: number, const boundary = getBoundaryPoints(graphWidth, graphHeight, spacing); const points = getJitteredGrid(graphWidth, graphHeight, spacing); // points of jittered square grid const cellCountX = Math.floor((graphWidth + 0.5 * spacing - 1e-10) / spacing); // number of cells in x direction - const cellCountY = Math.floor((graphHeight + 0.5 * spacing - 1e-10) / spacing); // number of cells in y direction + const cellCountY = Math.floor( + (graphHeight + 0.5 * spacing - 1e-10) / spacing, + ); // number of cells in y direction TIME && console.timeEnd("placePoints"); - return {spacing, cellsDesired, boundary, points, cellsX: cellCountX, cellsY: cellCountY}; -} - + return { + spacing, + cellsDesired, + boundary, + points, + cellsX: cellCountX, + cellsY: cellCountY, + }; +}; /** * Checks if the grid needs to be regenerated based on desired parameters @@ -88,18 +119,34 @@ const placePoints = (graphWidth: number, graphHeight: number): {spacing: number, * @param {number} graphHeight - The height of the graph * @returns {boolean} - True if the grid should be regenerated, false otherwise */ -export const shouldRegenerateGrid = (grid: any, expectedSeed: number, graphWidth: number, graphHeight: number) => { +export const shouldRegenerateGrid = ( + grid: any, + expectedSeed: number, + graphWidth: number, + graphHeight: number, +) => { if (expectedSeed && expectedSeed !== grid.seed) return true; const cellsDesired = +(byId("pointsInput")?.dataset?.cells || 0); if (cellsDesired !== grid.cellsDesired) return true; - const newSpacing = rn(Math.sqrt((graphWidth * graphHeight) / cellsDesired), 2); - const newCellsX = Math.floor((graphWidth + 0.5 * newSpacing - 1e-10) / newSpacing); - const newCellsY = Math.floor((graphHeight + 0.5 * newSpacing - 1e-10) / newSpacing); + const newSpacing = rn( + Math.sqrt((graphWidth * graphHeight) / cellsDesired), + 2, + ); + const newCellsX = Math.floor( + (graphWidth + 0.5 * newSpacing - 1e-10) / newSpacing, + ); + const newCellsY = Math.floor( + (graphHeight + 0.5 * newSpacing - 1e-10) / newSpacing, + ); - return grid.spacing !== newSpacing || grid.cellsX !== newCellsX || grid.cellsY !== newCellsY; -} + return ( + grid.spacing !== newSpacing || + grid.cellsX !== newCellsX || + grid.cellsY !== newCellsY + ); +}; interface Grid { spacing: number; @@ -116,12 +163,27 @@ interface Grid { * Generates a Voronoi grid based on jittered grid points * @returns {Object} - The generated grid object containing spacing, cellsDesired, boundary, points, cellsX, cellsY, cells, vertices, and seed */ -export const generateGrid = (seed: string, graphWidth: number, graphHeight: number): Grid => { +export const generateGrid = ( + seed: string, + graphWidth: number, + graphHeight: number, +): Grid => { Math.random = Alea(seed); // reset PRNG - const {spacing, cellsDesired, boundary, points, cellsX, cellsY} = placePoints(graphWidth, graphHeight); - const {cells, vertices} = calculateVoronoi(points, boundary); - return {spacing, cellsDesired, boundary, points, cellsX, cellsY, cells, vertices, seed}; -} + const { spacing, cellsDesired, boundary, points, cellsX, cellsY } = + placePoints(graphWidth, graphHeight); + const { cells, vertices } = calculateVoronoi(points, boundary); + return { + spacing, + cellsDesired, + boundary, + points, + cellsX, + cellsY, + cells, + vertices, + seed, + }; +}; /** * Calculates the Voronoi diagram from given points and boundary @@ -129,7 +191,10 @@ export const generateGrid = (seed: string, graphWidth: number, graphHeight: numb * @param {Array} boundary - The boundary points to clip the Voronoi cells * @returns {Object} - An object containing Voronoi cells and vertices */ -export const calculateVoronoi = (points: Point[], boundary: Point[]): {cells: Cells, vertices: Vertices} => { +export const calculateVoronoi = ( + points: Point[], + boundary: Point[], +): { cells: Cells; vertices: Vertices } => { TIME && console.time("calculateDelaunay"); const allPoints = points.concat(boundary); const delaunay = Delaunator.from(allPoints); @@ -139,12 +204,15 @@ export const calculateVoronoi = (points: Point[], boundary: Point[]): {cells: Ce const voronoi = new Voronoi(delaunay, allPoints, points.length); const cells = voronoi.cells; - cells.i = createTypedArray({maxValue: points.length, length: points.length}).map((_, i) => i) as Uint32Array; // array of indexes + cells.i = createTypedArray({ + maxValue: points.length, + length: points.length, + }).map((_, i) => i) as Uint32Array; // array of indexes const vertices = voronoi.vertices; TIME && console.timeEnd("calculateVoronoi"); - return {cells, vertices}; -} + return { cells, vertices }; +}; /** * Returns a cell index on a regular square grid based on x and y coordinates @@ -158,9 +226,9 @@ export const findGridCell = (x: number, y: number, grid: any): number => { Math.floor(Math.min(y / grid.spacing, grid.cellsY - 1)) * grid.cellsX + Math.floor(Math.min(x / grid.spacing, grid.cellsX - 1)) ); -} +}; -/** +/** * return array of cell indexes in radius on a regular square grid * @param {number} x - The x coordinate * @param {number} y - The y coordinate @@ -168,7 +236,12 @@ export const findGridCell = (x: number, y: number, grid: any): number => { * @param {Object} grid - The grid object containing spacing, cellsX, and cellsY * @returns {Array} - An array of cell indexes within the specified radius */ -export const findGridAll = (x: number, y: number, radius: number, grid: any): number[] => { +export const findGridAll = ( + x: number, + y: number, + radius: number, + grid: any, +): number[] => { const c = grid.cells.c; let r = Math.floor(radius / grid.spacing); let found = [findGridCell(x, y, grid)]; @@ -177,10 +250,10 @@ export const findGridAll = (x: number, y: number, radius: number, grid: any): nu if (r > 1) { let frontier = c[found[0]]; while (r > 1) { - let cycle = frontier.slice(); + const cycle = frontier.slice(); frontier = []; - cycle.forEach(function (s: number) { - c[s].forEach(function (e: number) { + cycle.forEach((s: number) => { + c[s].forEach((e: number) => { if (found.indexOf(e) !== -1) return; found.push(e); frontier.push(e); @@ -191,7 +264,7 @@ export const findGridAll = (x: number, y: number, radius: number, grid: any): nu } return found; -} +}; /** * Returns the index of the packed cell containing the given x and y coordinates @@ -200,21 +273,151 @@ export const findGridAll = (x: number, y: number, radius: number, grid: any): nu * @param {number} radius - The search radius (default is Infinity) * @returns {number|undefined} - The index of the found cell or undefined if not found */ -export const findClosestCell = (x: number, y: number, radius = Infinity, packedGraph: any): number | undefined => { +export const findClosestCell = ( + x: number, + y: number, + radius = Infinity, + packedGraph: any, +): number | undefined => { if (!packedGraph.cells?.q) return; const found = packedGraph.cells.q.find(x, y, radius); return found ? found[2] : undefined; -} +}; + +/** + * Searches a quadtree for all points within a given radius + * Based on https://bl.ocks.org/lwthatcher/b41479725e0ff2277c7ac90df2de2b5e + * @param {number} x - The x coordinate of the search center + * @param {number} y - The y coordinate of the search center + * @param {number} radius - The search radius + * @param {Object} quadtree - The D3 quadtree to search + * @returns {Array} - An array of found data points within the radius + */ +export const findAllInQuadtree = ( + x: number, + y: number, + radius: number, + quadtree: any, +) => { + let dx: number, dy: number, d2: number; + + const radiusSearchInit = (t: any, radius: number) => { + t.result = []; + t.x0 = t.x - radius; + t.y0 = t.y - radius; + t.x3 = t.x + radius; + t.y3 = t.y + radius; + t.radius = radius * radius; + }; + + const radiusSearchVisit = (t: any, d2: number) => { + t.node.data.scanned = true; + if (d2 < t.radius) { + while (t.node) { + t.result.push(t.node.data); + t.node.data.selected = true; + t.node = t.node.next; + } + } + }; + + class Quad { + node: any; + x0: number; + y0: number; + x1: number; + y1: number; + constructor(node: any, x0: number, y0: number, x1: number, y1: number) { + this.node = node; + this.x0 = x0; + this.y0 = y0; + this.x1 = x1; + this.y1 = y1; + } + } + + const t: any = { + x, + y, + x0: quadtree._x0, + y0: quadtree._y0, + x3: quadtree._x1, + y3: quadtree._y1, + quads: [], + node: quadtree._root, + }; + if (t.node) t.quads.push(new Quad(t.node, t.x0, t.y0, t.x3, t.y3)); + radiusSearchInit(t, radius); + + var _i = 0; + t.q = t.quads.pop(); + while (t.q) { + _i++; + + t.node = t.q.node; + t.x1 = t.q.x0; + t.y1 = t.q.y0; + t.x2 = t.q.x1; + t.y2 = t.q.y1; + + // Stop searching if this quadrant can't contain a closer node. + if (!t.node || t.x1 > t.x3 || t.y1 > t.y3 || t.x2 < t.x0 || t.y2 < t.y0) { + t.q = t.quads.pop(); + continue; + } + + // Bisect the current quadrant. + if (t.node.length) { + t.node.explored = true; + const xm: number = (t.x1 + t.x2) / 2, + ym: number = (t.y1 + t.y2) / 2; + + t.quads.push( + new Quad(t.node[3], xm, ym, t.x2, t.y2), + new Quad(t.node[2], t.x1, ym, xm, t.y2), + new Quad(t.node[1], xm, t.y1, t.x2, ym), + new Quad(t.node[0], t.x1, t.y1, xm, ym), + ); + + // Visit the closest quadrant first. + t.i = (+(y >= ym) << 1) | +(x >= xm); + if (t.i) { + t.q = t.quads[t.quads.length - 1]; + t.quads[t.quads.length - 1] = t.quads[t.quads.length - 1 - t.i]; + t.quads[t.quads.length - 1 - t.i] = t.q; + } + } + + // Visit this point. (Visiting coincident points isn't necessary!) + else { + dx = x - +quadtree._x.call(null, t.node.data); + dy = y - +quadtree._y.call(null, t.node.data); + d2 = dx * dx + dy * dy; + radiusSearchVisit(t, d2); + } + t.q = t.quads.pop(); + } + return t.result; +}; /** * Returns an array of packed cell indexes within a specified radius from given x and y coordinates * @param {number} x - The x coordinate * @param {number} y - The y coordinate + * @param {number} radius - The search radius + * @param {Object} packedGraph - The packed graph containing cells with quadtree + * @returns {number[]} - An array of cell indexes within the radius */ -export const findAllCellsInRadius = (x: number, y: number, radius: number, packedGraph: any): number[] => { - const found = packedGraph.cells.q.findAll(x, y, radius); +export const findAllCellsInRadius = ( + x: number, + y: number, + radius: number, + packedGraph: any, +): number[] => { + // Use findAllInQuadtree directly instead of relying on prototype extension + const found = findAllInQuadtree(x, y, radius, packedGraph.cells.q); return found.map((r: any) => r[2]); -} +}; /** * Returns the polygon points for a packed cell given its index @@ -222,8 +425,10 @@ export const findAllCellsInRadius = (x: number, y: number, radius: number, packe * @returns {Array} - An array of polygon points for the specified cell */ export const getPackPolygon = (cellIndex: number, packedGraph: any) => { - return packedGraph.cells.v[cellIndex].map((v: number) => packedGraph.vertices.p[v]); -} + return packedGraph.cells.v[cellIndex].map( + (v: number) => packedGraph.vertices.p[v], + ); +}; /** * Returns the polygon points for a grid cell given its index @@ -232,7 +437,7 @@ export const getPackPolygon = (cellIndex: number, packedGraph: any) => { */ export const getGridPolygon = (i: number, grid: any) => { return grid.cells.v[i].map((v: number) => grid.vertices.p[v]); -} +}; /** * mbostock's poissonDiscSampler implementation @@ -245,7 +450,14 @@ export const getGridPolygon = (i: number, grid: any) => { * @param {number} k - The number of attempts before rejection (default is 3) * @yields {Array} - An array containing the x and y coordinates of a generated point */ -export function* poissonDiscSampler(x0: number, y0: number, x1: number, y1: number, r: number, k = 3) { +export function* poissonDiscSampler( + x0: number, + y0: number, + x1: number, + y1: number, + r: number, + k = 3, +) { if (!(x1 >= x0) || !(y1 >= y0) || !(r > 0)) throw new Error(); const width = x1 - x0; @@ -279,9 +491,10 @@ export function* poissonDiscSampler(x0: number, y0: number, x1: number, y1: numb return true; } - function sample(x: number, y: number) { + function sample(x: number, y: number): [number, number] { const point: [number, number] = [x, y]; - queue.push((grid[gridWidth * ((y / cellSize) | 0) + ((x / cellSize) | 0)] = point)); + grid[gridWidth * ((y / cellSize) | 0) + ((x / cellSize) | 0)] = point; + queue.push(point); return [x + x0, y + y0]; } @@ -314,7 +527,7 @@ export function* poissonDiscSampler(x0: number, y0: number, x1: number, y1: numb */ export const isLand = (i: number, packedGraph: any) => { return packedGraph.cells.h[i] >= 20; -} +}; /** * Checks if a packed cell is water based on its height @@ -323,90 +536,7 @@ export const isLand = (i: number, packedGraph: any) => { */ export const isWater = (i: number, packedGraph: any) => { return packedGraph.cells.h[i] < 20; -} - -export const findAllInQuadtree = (x: number, y: number, radius: number, quadtree: any) => { - const radiusSearchInit = (t: any, radius: number) => { - t.result = []; - (t.x0 = t.x - radius), (t.y0 = t.y - radius); - (t.x3 = t.x + radius), (t.y3 = t.y + radius); - t.radius = radius * radius; - }; - - const radiusSearchVisit = (t: any, d2: number) => { - t.node.data.scanned = true; - if (d2 < t.radius) { - do { - t.result.push(t.node.data); - t.node.data.selected = true; - } while ((t.node = t.node.next)); - } - }; - - class Quad { - node: any; - x0: number; - y0: number; - x1: number; - y1: number; - constructor(node: any, x0: number, y0: number, x1: number, y1: number) { - this.node = node; - this.x0 = x0; - this.y0 = y0; - this.x1 = x1; - this.y1 = y1; - } - } - - const t: any = {x, y, x0: quadtree._x0, y0: quadtree._y0, x3: quadtree._x1, y3: quadtree._y1, quads: [], node: quadtree._root}; - if (t.node) t.quads.push(new Quad(t.node, t.x0, t.y0, t.x3, t.y3)); - radiusSearchInit(t, radius); - - var i = 0; - while ((t.q = t.quads.pop())) { - i++; - - // Stop searching if this quadrant can’t contain a closer node. - if ( - !(t.node = t.q.node) || - (t.x1 = t.q.x0) > t.x3 || - (t.y1 = t.q.y0) > t.y3 || - (t.x2 = t.q.x1) < t.x0 || - (t.y2 = t.q.y1) < t.y0 - ) - continue; - - // Bisect the current quadrant. - if (t.node.length) { - t.node.explored = true; - var xm: number = (t.x1 + t.x2) / 2, - ym: number = (t.y1 + t.y2) / 2; - - t.quads.push( - new Quad(t.node[3], xm, ym, t.x2, t.y2), - new Quad(t.node[2], t.x1, ym, xm, t.y2), - new Quad(t.node[1], xm, t.y1, t.x2, ym), - new Quad(t.node[0], t.x1, t.y1, xm, ym) - ); - - // Visit the closest quadrant first. - if ((t.i = (+(y >= ym) << 1) | +(x >= xm))) { - t.q = t.quads[t.quads.length - 1]; - t.quads[t.quads.length - 1] = t.quads[t.quads.length - 1 - t.i]; - t.quads[t.quads.length - 1 - t.i] = t.q; - } - } - - // Visit this point. (Visiting coincident points isn’t necessary!) - else { - var dx = x - +quadtree._x.call(null, t.node.data), - dy = y - +quadtree._y.call(null, t.node.data), - d2 = dx * dx + dy * dy; - radiusSearchVisit(t, d2); - } - } - return t.result; -} +}; // draw raster heightmap preview (not used in main generation) /** @@ -419,18 +549,31 @@ export const findAllInQuadtree = (x: number, y: number, radius: number, quadtree * @param {boolean} options.renderOcean - Whether to render ocean heights * @returns {string} - A data URL representing the drawn heightmap image */ -export const drawHeights = ({heights, width, height, scheme, renderOcean}: {heights: number[], width: number, height: number, scheme: (value: number) => string, renderOcean: boolean}) => { +export const drawHeights = ({ + heights, + width, + height, + scheme, + renderOcean, +}: { + heights: number[]; + width: number; + height: number; + scheme: (value: number) => string; + renderOcean: boolean; +}) => { const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d")!; const imageData = ctx.createImageData(width, height); - const getHeight = (height: number) => (height < 20 ? (renderOcean ? height : 0) : height); + const getHeight = (height: number) => + height < 20 ? (renderOcean ? height : 0) : height; for (let i = 0; i < heights.length; i++) { const colorScheme = scheme(1 - getHeight(heights[i]) / 100); - const {r, g, b} = color(colorScheme)!.rgb(); + const { r, g, b } = color(colorScheme)?.rgb() ?? { r: 0, g: 0, b: 0 }; const n = i * 4; imageData.data[n] = r; @@ -441,12 +584,11 @@ export const drawHeights = ({heights, width, height, scheme, renderOcean}: {heig ctx.putImageData(imageData, 0, 0); return canvas.toDataURL("image/png"); -} +}; declare global { var TIME: boolean; interface Window { - shouldRegenerateGrid: typeof shouldRegenerateGrid; generateGrid: typeof generateGrid; findCell: typeof findClosestCell; @@ -462,4 +604,4 @@ declare global { findAllInQuadtree: typeof findAllInQuadtree; drawHeights: typeof drawHeights; } -} \ No newline at end of file +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 73581a38..59b4b528 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,13 +1,22 @@ import "./polyfills"; -import { rn, lim, minmax, normalize, lerp } from "./numberUtils"; +import { lerp, lim, minmax, normalize, rn } from "./numberUtils"; + window.rn = rn; window.lim = lim; window.minmax = minmax; window.normalize = normalize; window.lerp = lerp as typeof window.lerp; -import { isVowel, trimVowels, getAdjective, nth, abbreviate, list } from "./languageUtils"; +import { + abbreviate, + getAdjective, + isVowel, + list, + nth, + trimVowels, +} from "./languageUtils"; + window.vowel = isVowel; window.trimVowels = trimVowels; window.getAdjective = getAdjective; @@ -15,7 +24,15 @@ window.nth = nth; window.abbreviate = abbreviate; window.list = list; -import { last, unique, deepCopy, getTypedArray, createTypedArray, TYPED_ARRAY_MAX_VALUES } from "./arrayUtils"; +import { + createTypedArray, + deepCopy, + getTypedArray, + last, + TYPED_ARRAY_MAX_VALUES, + unique, +} from "./arrayUtils"; + window.last = last; window.unique = unique; window.deepCopy = deepCopy; @@ -26,7 +43,19 @@ window.UINT8_MAX = TYPED_ARRAY_MAX_VALUES.UINT8_MAX; window.UINT16_MAX = TYPED_ARRAY_MAX_VALUES.UINT16_MAX; window.UINT32_MAX = TYPED_ARRAY_MAX_VALUES.UINT32_MAX; -import { rand, P, each, gauss, Pint, biased, generateSeed, getNumberInRange, ra, rw } from "./probabilityUtils"; +import { + biased, + each, + gauss, + generateSeed, + getNumberInRange, + P, + Pint, + ra, + rand, + rw, +} from "./probabilityUtils"; + window.rand = rand; window.P = P; window.each = each; @@ -38,12 +67,23 @@ window.biased = biased; window.getNumberInRange = getNumberInRange; window.generateSeed = generateSeed; -import { convertTemperature, si, getIntegerFromSI } from "./unitUtils"; -window.convertTemperature = (temp:number, scale: any = (window as any).temperatureScale.value || "°C") => convertTemperature(temp, scale); +import { convertTemperature, getIntegerFromSI, si } from "./unitUtils"; + +window.convertTemperature = ( + temp: number, + scale: any = (window as any).temperatureScale.value || "°C", +) => convertTemperature(temp, scale); window.si = si; window.getInteger = getIntegerFromSI; -import { toHEX, getColors, getRandomColor, getMixedColor, C_12 } from "./colorUtils"; +import { + C_12, + getColors, + getMixedColor, + getRandomColor, + toHEX, +} from "./colorUtils"; + window.toHEX = toHEX; window.getColors = getColors; window.getRandomColor = getRandomColor; @@ -51,21 +91,41 @@ window.getMixedColor = getMixedColor; window.C_12 = C_12; import { getComposedPath, getNextId } from "./nodeUtils"; + window.getComposedPath = getComposedPath; window.getNextId = getNextId; -import { rollups, distanceSquared } from "./functionUtils"; +import { distanceSquared, rollups } from "./functionUtils"; + window.rollups = rollups; window.dist2 = distanceSquared; -import { getIsolines, getPolesOfInaccessibility, connectVertices, findPath, getVertexPath } from "./pathUtils"; +import { + connectVertices, + findPath, + getIsolines, + getPolesOfInaccessibility, + getVertexPath, +} from "./pathUtils"; + window.getIsolines = getIsolines; window.getPolesOfInaccessibility = getPolesOfInaccessibility; window.connectVertices = connectVertices; -window.findPath = (start, end, getCost) => findPath(start, end, getCost, (window as any).pack); -window.getVertexPath = (cellsArray) => getVertexPath(cellsArray, (window as any).pack); +window.findPath = (start, end, getCost) => + findPath(start, end, getCost, (window as any).pack); +window.getVertexPath = (cellsArray) => + getVertexPath(cellsArray, (window as any).pack); + +import { + capitalize, + isValidJSON, + parseTransform, + round, + safeParseJSON, + sanitizeId, + splitInTwo, +} from "./stringUtils"; -import { round, capitalize, splitInTwo, parseTransform, isValidJSON, safeParseJSON, sanitizeId } from "./stringUtils"; window.round = round; window.capitalize = capitalize; window.splitInTwo = splitInTwo; @@ -76,6 +136,7 @@ JSON.isValid = isValidJSON; JSON.safeParse = safeParseJSON; import { byId } from "./shorthands"; + window.byId = byId; Node.prototype.on = function (name, fn, options) { this.addEventListener(name, fn, options); @@ -87,27 +148,63 @@ Node.prototype.off = function (name, fn) { }; declare global { - interface JSON { isValid: (str: string) => boolean; safeParse: (str: string) => any; } interface Node { - on: (name: string, fn: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions) => Node; + on: ( + name: string, + fn: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ) => Node; off: (name: string, fn: EventListenerOrEventListenerObject) => Node; } } -import { shouldRegenerateGrid, generateGrid, findGridAll, findGridCell, findClosestCell, calculateVoronoi, findAllCellsInRadius, getPackPolygon, getGridPolygon, poissonDiscSampler, isLand, isWater, findAllInQuadtree, drawHeights } from "./graphUtils"; -window.shouldRegenerateGrid = (grid: any, expectedSeed: number) => shouldRegenerateGrid(grid, expectedSeed, (window as any).graphWidth, (window as any).graphHeight); -window.generateGrid = () => generateGrid((window as any).seed, (window as any).graphWidth, (window as any).graphHeight); -window.findGridAll = (x: number, y: number, radius: number) => findGridAll(x, y, radius, (window as any).grid); -window.findGridCell = (x: number, y: number) => findGridCell(x, y, (window as any).grid); -window.findCell = (x: number, y: number, radius?: number) => findClosestCell(x, y, radius, (window as any).pack); -window.findAll = (x: number, y: number, radius: number) => findAllCellsInRadius(x, y, radius, (window as any).pack); -window.getPackPolygon = (cellIndex: number) => getPackPolygon(cellIndex, (window as any).pack); -window.getGridPolygon = (cellIndex: number) => getGridPolygon(cellIndex, (window as any).grid); +import { + calculateVoronoi, + drawHeights, + findAllCellsInRadius, + findAllInQuadtree, + findClosestCell, + findGridAll, + findGridCell, + generateGrid, + getGridPolygon, + getPackPolygon, + isLand, + isWater, + poissonDiscSampler, + shouldRegenerateGrid, +} from "./graphUtils"; + +window.shouldRegenerateGrid = (grid: any, expectedSeed: number) => + shouldRegenerateGrid( + grid, + expectedSeed, + (window as any).graphWidth, + (window as any).graphHeight, + ); +window.generateGrid = () => + generateGrid( + (window as any).seed, + (window as any).graphWidth, + (window as any).graphHeight, + ); +window.findGridAll = (x: number, y: number, radius: number) => + findGridAll(x, y, radius, (window as any).grid); +window.findGridCell = (x: number, y: number) => + findGridCell(x, y, (window as any).grid); +window.findCell = (x: number, y: number, radius?: number) => + findClosestCell(x, y, radius, (window as any).pack); +window.findAll = (x: number, y: number, radius: number) => + findAllCellsInRadius(x, y, radius, (window as any).pack); +window.getPackPolygon = (cellIndex: number) => + getPackPolygon(cellIndex, (window as any).pack); +window.getGridPolygon = (cellIndex: number) => + getGridPolygon(cellIndex, (window as any).grid); window.calculateVoronoi = calculateVoronoi; window.poissonDiscSampler = poissonDiscSampler; window.findAllInQuadtree = findAllInQuadtree; @@ -115,8 +212,26 @@ window.drawHeights = drawHeights; window.isLand = (i: number) => isLand(i, (window as any).pack); window.isWater = (i: number) => isWater(i, (window as any).pack); -import { clipPoly, getSegmentId, debounce, throttle, parseError, getBase64, openURL, wiki, link, isCtrlClick, generateDate, getLongitude, getLatitude, getCoordinates, initializePrompt } from "./commonUtils"; -window.clipPoly = (points: [number, number][], secure?: number) => clipPoly(points, (window as any).graphWidth, (window as any).graphHeight, secure); +import { + clipPoly, + debounce, + generateDate, + getBase64, + getCoordinates, + getLatitude, + getLongitude, + getSegmentId, + initializePrompt, + isCtrlClick, + link, + openURL, + parseError, + throttle, + wiki, +} from "./commonUtils"; + +window.clipPoly = (points: [number, number][], secure?: number) => + clipPoly(points, graphWidth, graphHeight, secure); window.getSegmentId = getSegmentId; window.debounce = debounce; window.throttle = throttle; @@ -127,25 +242,37 @@ window.wiki = wiki; window.link = link; window.isCtrlClick = isCtrlClick; window.generateDate = generateDate; -window.getLongitude = (x: number, decimals?: number) => getLongitude(x, (window as any).mapCoordinates, (window as any).graphWidth, decimals); -window.getLatitude = (y: number, decimals?: number) => getLatitude(y, (window as any).mapCoordinates, (window as any).graphHeight, decimals); -window.getCoordinates = (x: number, y: number, decimals?: number) => getCoordinates(x, y, (window as any).mapCoordinates, (window as any).graphWidth, (window as any).graphHeight, decimals); +window.getLongitude = (x: number, decimals?: number) => + getLongitude(x, mapCoordinates, graphWidth, decimals); +window.getLatitude = (y: number, decimals?: number) => + getLatitude(y, mapCoordinates, graphHeight, decimals); +window.getCoordinates = (x: number, y: number, decimals?: number) => + getCoordinates(x, y, mapCoordinates, graphWidth, graphHeight, decimals); // Initialize prompt when DOM is ready -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializePrompt); +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initializePrompt); } else { initializePrompt(); } -import { drawCellsValue, drawPolygons, drawRouteConnections, drawPoint, drawPath } from "./debugUtils"; -window.drawCellsValue = (data:any[]) => drawCellsValue(data, (window as any).pack); -window.drawPolygons = (data: any[]) => drawPolygons(data, (window as any).terrs, (window as any).grid); -window.drawRouteConnections = () => drawRouteConnections((window as any).packedGraph); +import { + drawCellsValue, + drawPath, + drawPoint, + drawPolygons, + drawRouteConnections, +} from "./debugUtils"; + +window.drawCellsValue = (data: any[]) => + drawCellsValue(data, (window as any).pack); +window.drawPolygons = (data: any[]) => + drawPolygons(data, (window as any).terrs, (window as any).grid); +window.drawRouteConnections = () => + drawRouteConnections((window as any).packedGraph); window.drawPoint = drawPoint; window.drawPath = drawPath; - export { rn, lim, @@ -232,5 +359,5 @@ export { drawPolygons, drawRouteConnections, drawPoint, - drawPath -} \ No newline at end of file + drawPath, +}; diff --git a/src/utils/languageUtils.ts b/src/utils/languageUtils.ts index 0fbd20c8..ea7c8ebb 100644 --- a/src/utils/languageUtils.ts +++ b/src/utils/languageUtils.ts @@ -9,7 +9,7 @@ import { P } from "./probabilityUtils"; export const isVowel = (c: string): boolean => { const VOWELS = `aeiouyɑ'əøɛœæɶɒɨɪɔɐʊɤɯаоиеёэыуюяàèìòùỳẁȁȅȉȍȕáéíóúýẃőűâêîôûŷŵäëïöüÿẅãẽĩõũỹąęįǫųāēīōūȳăĕĭŏŭǎěǐǒǔȧėȯẏẇạẹịọụỵẉḛḭṵṳ`; return VOWELS.includes(c); -} +}; /** * Remove trailing vowels from a string until it reaches a minimum length. @@ -22,8 +22,7 @@ export const trimVowels = (string: string, minLength: number = 3) => { string = string.slice(0, -1); } return string; -} - +}; /** * Get adjective form of a noun based on predefined rules. @@ -35,131 +34,133 @@ export const getAdjective = (nounToBeAdjective: string) => { { name: "guo", probability: 1, - condition: new RegExp(" Guo$"), - action: (noun: string) => noun.slice(0, -4) + condition: / Guo$/, + action: (noun: string) => noun.slice(0, -4), }, { name: "orszag", probability: 1, - condition: new RegExp("orszag$"), - action: (noun: string) => (noun.length < 9 ? noun + "ian" : noun.slice(0, -6)) + condition: /orszag$/, + action: (noun: string) => + noun.length < 9 ? `${noun}ian` : noun.slice(0, -6), }, { name: "stan", probability: 1, - condition: new RegExp("stan$"), - action: (noun: string) => (noun.length < 9 ? noun + "i" : trimVowels(noun.slice(0, -4))) + condition: /stan$/, + action: (noun: string) => + noun.length < 9 ? `${noun}i` : trimVowels(noun.slice(0, -4)), }, { name: "land", probability: 1, - condition: new RegExp("land$"), + condition: /land$/, action: (noun: string) => { if (noun.length > 9) return noun.slice(0, -4); const root = trimVowels(noun.slice(0, -4), 0); - if (root.length < 3) return noun + "ic"; - if (root.length < 4) return root + "lish"; - return root + "ish"; - } + if (root.length < 3) return `${noun}ic`; + if (root.length < 4) return `${root}lish`; + return `${root}ish`; + }, }, { name: "que", probability: 1, - condition: new RegExp("que$"), - action: (noun: string) => noun.replace(/que$/, "can") + condition: /que$/, + action: (noun: string) => noun.replace(/que$/, "can"), }, { name: "a", probability: 1, - condition: new RegExp("a$"), - action: (noun: string) => noun + "n" + condition: /a$/, + action: (noun: string) => `${noun}n`, }, { name: "o", probability: 1, - condition: new RegExp("o$"), - action: (noun: string) => noun.replace(/o$/, "an") + condition: /o$/, + action: (noun: string) => noun.replace(/o$/, "an"), }, { name: "u", probability: 1, - condition: new RegExp("u$"), - action: (noun: string) => noun + "an" + condition: /u$/, + action: (noun: string) => `${noun}an`, }, { name: "i", probability: 1, - condition: new RegExp("i$"), - action: (noun: string) => noun + "an" + condition: /i$/, + action: (noun: string) => `${noun}an`, }, { name: "e", probability: 1, - condition: new RegExp("e$"), - action: (noun: string) => noun + "an" + condition: /e$/, + action: (noun: string) => `${noun}an`, }, { name: "ay", probability: 1, - condition: new RegExp("ay$"), - action: (noun: string) => noun + "an" + condition: /ay$/, + action: (noun: string) => `${noun}an`, }, { name: "os", probability: 1, - condition: new RegExp("os$"), + condition: /os$/, action: (noun: string) => { const root = trimVowels(noun.slice(0, -2), 0); if (root.length < 4) return noun.slice(0, -1); - return root + "ian"; - } + return `${root}ian`; + }, }, { name: "es", probability: 1, - condition: new RegExp("es$"), + condition: /es$/, action: (noun: string) => { const root = trimVowels(noun.slice(0, -2), 0); if (root.length > 7) return noun.slice(0, -1); - return root + "ian"; - } + return `${root}ian`; + }, }, { name: "l", probability: 0.8, - condition: new RegExp("l$"), - action: (noun: string) => noun + "ese" + condition: /l$/, + action: (noun: string) => `${noun}ese`, }, { name: "n", probability: 0.8, - condition: new RegExp("n$"), - action: (noun: string) => noun + "ese" + condition: /n$/, + action: (noun: string) => `${noun}ese`, }, { name: "ad", probability: 0.8, - condition: new RegExp("ad$"), - action: (noun: string) => noun + "ian" + condition: /ad$/, + action: (noun: string) => `${noun}ian`, }, { name: "an", probability: 0.8, - condition: new RegExp("an$"), - action: (noun: string) => noun + "ian" + condition: /an$/, + action: (noun: string) => `${noun}ian`, }, { name: "ish", probability: 0.25, - condition: new RegExp("^[a-zA-Z]{6}$"), - action: (noun: string) => trimVowels(noun.slice(0, -1)) + "ish" + condition: /^[a-zA-Z]{6}$/, + action: (noun: string) => `${trimVowels(noun.slice(0, -1))}ish`, }, { name: "an", probability: 0.5, - condition: new RegExp("^[a-zA-Z]{0,7}$"), - action: (noun: string) => trimVowels(noun) + "an" - } + condition: /^[a-zA-Z]{0,7}$/, + action: (noun: string) => `${trimVowels(noun)}an`, + }, ]; for (const rule of adjectivizationRules) { if (P(rule.probability) && rule.condition.test(nounToBeAdjective)) { @@ -167,14 +168,15 @@ export const getAdjective = (nounToBeAdjective: string) => { } } return nounToBeAdjective; // no rule applied, return noun as is -} +}; /** * Get the ordinal suffix for a given number. * @param n - The number. * @returns The number with its ordinal suffix. */ -export const nth = (n: number) => n + (["st", "nd", "rd"][((((n + 90) % 100) - 10) % 10) - 1] || "th"); +export const nth = (n: number) => + n + (["st", "nd", "rd"][((((n + 90) % 100) - 10) % 10) - 1] || "th"); /** * Generate an abbreviation for a given name, avoiding restricted codes. @@ -187,12 +189,13 @@ export const abbreviate = (name: string, restricted: string[] = []) => { const words = parsed.split(" "); const letters = words.join(""); - let code = words.length === 2 ? words[0][0] + words[1][0] : letters.slice(0, 2); + let code = + words.length === 2 ? words[0][0] + words[1][0] : letters.slice(0, 2); for (let i = 1; i < letters.length - 1 && restricted.includes(code); i++) { code = letters[0] + letters[i].toUpperCase(); } return code; -} +}; /** * Format a list of strings into a human-readable list. @@ -201,9 +204,12 @@ export const abbreviate = (name: string, restricted: string[] = []) => { */ export const list = (array: string[]) => { if (!Intl.ListFormat) return array.join(", "); - const conjunction = new Intl.ListFormat(document.documentElement.lang || "en", {style: "long", type: "conjunction"}); + const conjunction = new Intl.ListFormat( + document.documentElement.lang || "en", + { style: "long", type: "conjunction" }, + ); return conjunction.format(array); -} +}; declare global { interface Window { @@ -214,4 +220,4 @@ declare global { abbreviate: typeof abbreviate; list: typeof list; } -} \ No newline at end of file +} diff --git a/src/utils/nodeUtils.ts b/src/utils/nodeUtils.ts index 6213840f..f5c66705 100644 --- a/src/utils/nodeUtils.ts +++ b/src/utils/nodeUtils.ts @@ -3,14 +3,14 @@ * @param {Node | Window} node - The starting node or window * @returns {Array} - The composed path as an array */ -export const getComposedPath = function(node: any): Array { - let parent; +export const getComposedPath = (node: any): Array => { + let parent: Node | Window | undefined; if (node.parentNode) parent = node.parentNode; else if (node.host) parent = node.host; else if (node.defaultView) parent = node.defaultView; if (parent !== undefined) return [node].concat(getComposedPath(parent)); return [node]; -} +}; /** * Generate a unique ID for a given core string @@ -18,14 +18,14 @@ export const getComposedPath = function(node: any): Array { * @param {number} [i=1] - The starting index * @returns {string} - The unique ID */ -export const getNextId = function(core: string, i: number = 1): string { +export const getNextId = (core: string, i: number = 1): string => { while (document.getElementById(core + i)) i++; return core + i; -} +}; declare global { interface Window { getComposedPath: typeof getComposedPath; getNextId: typeof getNextId; } -} \ No newline at end of file +} diff --git a/src/utils/numberUtils.ts b/src/utils/numberUtils.ts index a2ab6220..d7516624 100644 --- a/src/utils/numberUtils.ts +++ b/src/utils/numberUtils.ts @@ -5,9 +5,9 @@ * @returns The rounded number. */ export const rn = (v: number, d: number = 0) => { - const m = Math.pow(10, d); + const m = 10 ** d; return Math.round(v * m) / m; -} +}; /** * Clamps a number between a minimum and maximum value. @@ -18,7 +18,7 @@ export const rn = (v: number, d: number = 0) => { */ export const minmax = (value: number, min: number, max: number) => { return Math.min(Math.max(value, min), max); -} +}; /** * Clamps a number between 0 and 100. @@ -27,7 +27,7 @@ export const minmax = (value: number, min: number, max: number) => { */ export const lim = (v: number) => { return minmax(v, 0, 100); -} +}; /** * Normalizes a number within a specified range to a value between 0 and 1. @@ -38,7 +38,7 @@ export const lim = (v: number) => { */ export const normalize = (val: number, min: number, max: number) => { return minmax((val - min) / (max - min), 0, 1); -} +}; /** * Performs linear interpolation between two values. @@ -49,7 +49,7 @@ export const normalize = (val: number, min: number, max: number) => { */ export const lerp = (a: number, b: number, t: number) => { return a + (b - a) * t; -} +}; declare global { interface Window { @@ -59,4 +59,4 @@ declare global { normalize: typeof normalize; lerp: typeof lerp; } -} \ No newline at end of file +} diff --git a/src/utils/pathUtils.ts b/src/utils/pathUtils.ts index b37f17fb..36baec86 100644 --- a/src/utils/pathUtils.ts +++ b/src/utils/pathUtils.ts @@ -8,10 +8,10 @@ import { rn } from "./numberUtils"; * @returns {string} SVG path data for the filled shape. */ const getFillPath = (vertices: any, vertexChain: number[]) => { - const points = vertexChain.map(vertexId => vertices.p[vertexId]); + const points = vertexChain.map((vertexId) => vertices.p[vertexId]); const firstPoint = points.shift(); return `M${firstPoint} L${points.join(" ")} Z`; -} +}; /** * Generates SVG path data for borders based on a chain of vertices and a discontinuation condition. @@ -20,10 +20,14 @@ const getFillPath = (vertices: any, vertexChain: number[]) => { * @param {(vertexId: number) => boolean} discontinue - A function that determines if the path should discontinue at a vertex. * @returns {string} SVG path data for the border. */ -const getBorderPath = (vertices: any, vertexChain: number[], discontinue: (vertexId: number) => boolean) => { +const getBorderPath = ( + vertices: any, + vertexChain: number[], + discontinue: (vertexId: number) => boolean, +) => { let discontinued = true; let lastOperation = ""; - const path = vertexChain.map(vertexId => { + const path = vertexChain.map((vertexId) => { if (discontinue(vertexId)) { discontinued = true; return ""; @@ -33,12 +37,13 @@ const getBorderPath = (vertices: any, vertexChain: number[], discontinue: (verte discontinued = false; lastOperation = operation; - const command = operation === "L" && operation === lastOperation ? "" : operation; + const command = + operation === "L" && operation === lastOperation ? "" : operation; return ` ${command}${vertices.p[vertexId]}`; }); return path.join("").trim(); -} +}; /** * Restores the path from exit to start using the 'from' mapping. @@ -62,7 +67,7 @@ const restorePath = (exit: number, start: number, from: number[]) => { pathCells.push(current); return pathCells.reverse(); -} +}; /** * Returns isolines (borders) for different types of cells in the graph. @@ -75,12 +80,23 @@ const restorePath = (exit: number, start: number, from: number[]) => { * @param {boolean} [options.waterGap=false] - Whether to generate water gap paths for each type. * @returns {object} An object containing isolines for each type based on the specified options. */ -export const getIsolines = (graph: any, getType: (cellId: number) => any, options: {polygons?: boolean, fill?: boolean, halo?: boolean, waterGap?: boolean} = {polygons: false, fill: false, halo: false, waterGap: false}): any => { - const {cells, vertices} = graph; +export const getIsolines = ( + graph: any, + getType: (cellId: number) => any, + options: { + polygons?: boolean; + fill?: boolean; + halo?: boolean; + waterGap?: boolean; + } = { polygons: false, fill: false, halo: false, waterGap: false }, +): any => { + const { cells, vertices } = graph; const isolines: any = {}; const checkedCells = new Uint8Array(cells.i.length); - const addToChecked = (cellId: number) => (checkedCells[cellId] = 1); + const addToChecked = (cellId: number) => { + checkedCells[cellId] = 1; + }; const isChecked = (cellId: number) => checkedCells[cellId] === 1; for (const cellId of cells.i) { @@ -96,12 +112,22 @@ export const getIsolines = (graph: any, getType: (cellId: number) => any, option // check if inner lake. Note there is no shoreline for grid features const feature = graph.features[cells.f[onborderCell]]; - if (feature.type === "lake" && feature.shoreline?.every(ofSameType)) continue; + if (feature.type === "lake" && feature.shoreline?.every(ofSameType)) + continue; - const startingVertex = cells.v[cellId].find((v: number) => vertices.c[v].some(ofDifferentType)); - if (startingVertex === undefined) throw new Error(`Starting vertex for cell ${cellId} is not found`); + const startingVertex = cells.v[cellId].find((v: number) => + vertices.c[v].some(ofDifferentType), + ); + if (startingVertex === undefined) + throw new Error(`Starting vertex for cell ${cellId} is not found`); - const vertexChain = connectVertices({vertices, startingVertex, ofSameType, addToChecked, closeRing: true}); + const vertexChain = connectVertices({ + vertices, + startingVertex, + ofSameType, + addToChecked, + closeRing: true, + }); if (vertexChain.length < 3) continue; addIsolineTo(type, vertices, vertexChain, isolines, options); @@ -109,12 +135,20 @@ export const getIsolines = (graph: any, getType: (cellId: number) => any, option return isolines; - function addIsolineTo(type: any, vertices: any, vertexChain: number[], isolines: any, options: any) { + function addIsolineTo( + type: any, + vertices: any, + vertexChain: number[], + isolines: any, + options: any, + ) { if (!isolines[type]) isolines[type] = {}; if (options.polygons) { if (!isolines[type].polygons) isolines[type].polygons = []; - isolines[type].polygons.push(vertexChain.map(vertexId => vertices.p[vertexId])); + isolines[type].polygons.push( + vertexChain.map((vertexId) => vertices.p[vertexId]), + ); } if (options.fill) { @@ -124,18 +158,27 @@ export const getIsolines = (graph: any, getType: (cellId: number) => any, option if (options.waterGap) { if (!isolines[type].waterGap) isolines[type].waterGap = ""; - const isLandVertex = (vertexId: number) => vertices.c[vertexId].every((i: number) => cells.h[i] >= 20); - isolines[type].waterGap += getBorderPath(vertices, vertexChain, isLandVertex); + const isLandVertex = (vertexId: number) => + vertices.c[vertexId].every((i: number) => cells.h[i] >= 20); + isolines[type].waterGap += getBorderPath( + vertices, + vertexChain, + isLandVertex, + ); } if (options.halo) { if (!isolines[type].halo) isolines[type].halo = ""; - const isBorderVertex = (vertexId: number) => vertices.c[vertexId].some((i: number) => cells.b[i]); - isolines[type].halo += getBorderPath(vertices, vertexChain, isBorderVertex); + const isBorderVertex = (vertexId: number) => + vertices.c[vertexId].some((i: number) => cells.b[i]); + isolines[type].halo += getBorderPath( + vertices, + vertexChain, + isBorderVertex, + ); } } -} - +}; /** * Generates SVG path data for the border of a shape defined by a chain of vertices. @@ -144,14 +187,18 @@ export const getIsolines = (graph: any, getType: (cellId: number) => any, option * @returns {string} SVG path data for the border of the shape. */ export const getVertexPath = (cellsArray: number[], packedGraph: any = {}) => { - const {cells, vertices} = packedGraph; + const { cells, vertices } = packedGraph; - const cellsObj = Object.fromEntries(cellsArray.map(cellId => [cellId, true])); + const cellsObj = Object.fromEntries( + cellsArray.map((cellId) => [cellId, true]), + ); const ofSameType = (cellId: number) => cellsObj[cellId]; const ofDifferentType = (cellId: number) => !cellsObj[cellId]; const checkedCells = new Uint8Array(cells.c.length); - const addToChecked = (cellId: number) => (checkedCells[cellId] = 1); + const addToChecked = (cellId: number) => { + checkedCells[cellId] = 1; + }; const isChecked = (cellId: number) => checkedCells[cellId] === 1; let path = ""; @@ -166,17 +213,26 @@ export const getVertexPath = (cellsArray: number[], packedGraph: any = {}) => { if (feature.shoreline.every(ofSameType)) continue; // inner lake } - const startingVertex = cells.v[cellId].find((v: number) => vertices.c[v].some(ofDifferentType)); - if (startingVertex === undefined) throw new Error(`Starting vertex for cell ${cellId} is not found`); + const startingVertex = cells.v[cellId].find((v: number) => + vertices.c[v].some(ofDifferentType), + ); + if (startingVertex === undefined) + throw new Error(`Starting vertex for cell ${cellId} is not found`); - const vertexChain = connectVertices({vertices, startingVertex, ofSameType, addToChecked, closeRing: true}); + const vertexChain = connectVertices({ + vertices, + startingVertex, + ofSameType, + addToChecked, + closeRing: true, + }); if (vertexChain.length < 3) continue; path += getFillPath(vertices, vertexChain); } return path; -} +}; /** * Finds the poles of inaccessibility for each type of cell in the graph. @@ -184,17 +240,22 @@ export const getVertexPath = (cellsArray: number[], packedGraph: any = {}) => { * @param {(cellId: number) => any} getType - A function that returns the type of a cell given its ID. * @returns {object} An object mapping each type to its pole of inaccessibility coordinates [x, y]. */ -export const getPolesOfInaccessibility = (graph: any, getType: (cellId: number) => any) => { - const isolines = getIsolines(graph, getType, {polygons: true}); +export const getPolesOfInaccessibility = ( + graph: any, + getType: (cellId: number) => any, +) => { + const isolines = getIsolines(graph, getType, { polygons: true }); const poles = Object.entries(isolines).map(([id, isoline]) => { - const multiPolygon = (isoline as any).polygons.sort((a: any, b: any) => b.length - a.length); + const multiPolygon = (isoline as any).polygons.sort( + (a: any, b: any) => b.length - a.length, + ); const [x, y] = polylabel(multiPolygon, 20); return [id, [rn(x), rn(y)]]; }); return Object.fromEntries(poles); -} +}; /** * Connects vertices to form a closed path based on cell type. @@ -206,7 +267,19 @@ export const getPolesOfInaccessibility = (graph: any, getType: (cellId: number) * @param {boolean} [options.closeRing=false] - Whether to close the path into a ring. * @returns {number[]} An array of vertex IDs forming the connected path. */ -export const connectVertices = ({vertices, startingVertex, ofSameType, addToChecked, closeRing}: {vertices: any, startingVertex: number, ofSameType: (cellId: number) => boolean, addToChecked?: (cellId: number) => void, closeRing?: boolean}) => { +export const connectVertices = ({ + vertices, + startingVertex, + ofSameType, + addToChecked, + closeRing, +}: { + vertices: any; + startingVertex: number; + ofSameType: (cellId: number) => boolean; + addToChecked?: (cellId: number) => void; + closeRing?: boolean; +}) => { const MAX_ITERATIONS = vertices.c.length; const chain = []; // vertices chain to form a path @@ -227,24 +300,30 @@ export const connectVertices = ({vertices, startingVertex, ofSameType, addToChec else if (v3 !== previous && c1 !== c3) next = v3; if (next >= vertices.c.length) { - window.ERROR && console.error("ConnectVertices: next vertex is out of bounds"); + window.ERROR && + console.error("ConnectVertices: next vertex is out of bounds"); break; } if (next === current) { - window.ERROR && console.error("ConnectVertices: next vertex is not found"); + window.ERROR && + console.error("ConnectVertices: next vertex is not found"); break; } if (i === MAX_ITERATIONS) { - window.ERROR && console.error("ConnectVertices: max iterations reached", MAX_ITERATIONS); + window.ERROR && + console.error( + "ConnectVertices: max iterations reached", + MAX_ITERATIONS, + ); break; } } if (closeRing) chain.push(startingVertex); return chain; -} +}; /** * Finds the shortest path between two cells using a cost-based pathfinding algorithm. @@ -254,7 +333,12 @@ export const connectVertices = ({vertices, startingVertex, ofSameType, addToChec * @param {object} packedGraph - The packed graph object containing cells and their connections. * @returns {number[] | null} An array of cell IDs of the path from start to exit, or null if no path is found or start and exit are the same. */ -export const findPath = (start: number, isExit: (id: number) => boolean, getCost: (current: number, next: number) => number, packedGraph: any = {}): number[] | null => { +export const findPath = ( + start: number, + isExit: (id: number) => boolean, + getCost: (current: number, next: number) => number, + packedGraph: any = {}, +): number[] | null => { if (isExit(start)) return null; const from = []; @@ -284,7 +368,7 @@ export const findPath = (start: number, isExit: (id: number) => boolean, getCost } return null; -} +}; declare global { interface Window { @@ -297,4 +381,4 @@ declare global { findPath: typeof findPath; getVertexPath: typeof getVertexPath; } -} \ No newline at end of file +} diff --git a/src/utils/polyfills.ts b/src/utils/polyfills.ts index 18f5f1bd..594e7a2f 100644 --- a/src/utils/polyfills.ts +++ b/src/utils/polyfills.ts @@ -1,7 +1,11 @@ // replaceAll if (String.prototype.replaceAll === undefined) { - String.prototype.replaceAll = function (str: string | RegExp, newStr: string | ((substring: string, ...args: any[]) => string)): string { - if (Object.prototype.toString.call(str).toLowerCase() === "[object regexp]") return this.replace(str as RegExp, newStr as any); + String.prototype.replaceAll = function ( + str: string | RegExp, + newStr: string | ((substring: string, ...args: any[]) => string), + ): string { + if (Object.prototype.toString.call(str).toLowerCase() === "[object regexp]") + return this.replace(str as RegExp, newStr as any); return this.replace(new RegExp(str, "g"), newStr as any); }; } @@ -9,7 +13,13 @@ if (String.prototype.replaceAll === undefined) { // flat if (Array.prototype.flat === undefined) { Array.prototype.flat = function (this: T[], depth?: number): any[] { - return (this as Array).reduce((acc: any[], val: unknown) => (Array.isArray(val) ? acc.concat((val as any).flat(depth)) : acc.concat(val)), []); + return (this as Array).reduce( + (acc: any[], val: unknown) => + Array.isArray(val) + ? acc.concat((val as any).flat(depth)) + : acc.concat(val), + [], + ); }; } @@ -24,11 +34,13 @@ if (Array.prototype.at === undefined) { // readable stream iterator: https://bugs.chromium.org/p/chromium/issues/detail?id=929585#c10 if ((ReadableStream.prototype as any)[Symbol.asyncIterator] === undefined) { - (ReadableStream.prototype as any)[Symbol.asyncIterator] = async function* (this: ReadableStream): AsyncGenerator { + (ReadableStream.prototype as any)[Symbol.asyncIterator] = async function* ( + this: ReadableStream, + ): AsyncGenerator { const reader = this.getReader(); try { while (true) { - const {done, value} = await reader.read(); + const { done, value } = await reader.read(); if (done) return; yield value; } @@ -40,7 +52,10 @@ if ((ReadableStream.prototype as any)[Symbol.asyncIterator] === undefined) { declare global { interface String { - replaceAll(searchValue: string | RegExp, replaceValue: string | ((substring: string, ...args: any[]) => string)): string; + replaceAll( + searchValue: string | RegExp, + replaceValue: string | ((substring: string, ...args: any[]) => string), + ): string; } interface Array { diff --git a/src/utils/probabilityUtils.ts b/src/utils/probabilityUtils.ts index 4f6fd66a..ba9806b9 100644 --- a/src/utils/probabilityUtils.ts +++ b/src/utils/probabilityUtils.ts @@ -1,5 +1,5 @@ -import { minmax, rn } from "./numberUtils"; import { randomNormal } from "d3"; +import { minmax, rn } from "./numberUtils"; /** * Creates a random number between min and max (inclusive). @@ -14,7 +14,7 @@ export const rand = (min: number, max?: number): number => { min = 0; } return Math.floor(Math.random() * (max - min + 1)) + min; -} +}; /** * Returns a boolean based on the given probability. @@ -25,7 +25,7 @@ export const P = (probability: number): boolean => { if (probability >= 1) return true; if (probability <= 0) return false; return Math.random() < probability; -} +}; /** * Returns true every n times. @@ -34,10 +34,11 @@ export const P = (probability: number): boolean => { */ export const each = (n: number) => { return (i: number) => i % n === 0; -} +}; /** * Random Gaussian number generator + * Uses randomNormal.source(Math.random) to ensure it uses the current PRNG * @param {number} expected - expected value * @param {number} deviation - standard deviation * @param {number} min - minimum value @@ -45,9 +46,23 @@ export const each = (n: number) => { * @param {number} round - round value to n decimals * @return {number} random number */ -export const gauss = (expected = 100, deviation = 30, min = 0, max = 300, round = 0) => { - return rn(minmax(randomNormal(expected, deviation)(), min, max), round); -} +export const gauss = ( + expected = 100, + deviation = 30, + min = 0, + max = 300, + round = 0, +) => { + // Use .source() to get a version that uses the current Math.random (which may be seeded) + return rn( + minmax( + randomNormal.source(() => Math.random())(expected, deviation)(), + min, + max, + ), + round, + ); +}; /** * Returns the integer part of a float plus one with the probability of the decimal part. @@ -56,7 +71,7 @@ export const gauss = (expected = 100, deviation = 30, min = 0, max = 300, round */ export const Pint = (float: number): number => { return ~~float + +P(float % 1); -} +}; /** * Returns a random element from an array. @@ -65,18 +80,18 @@ export const Pint = (float: number): number => { */ export const ra = (array: any[]): any => { return array[Math.floor(Math.random() * array.length)]; -} +}; /** * Returns a random key from an object where values are weights. * @param {Object} object - object with keys and their weights * @return {string} a random key based on weights - * + * * @example * const obj = { a: 1, b: 3, c: 6 }; * const randomKey = rw(obj); // 'a' has 10% chance, 'b' has 30% chance, 'c' has 60% chance */ -export const rw = (object: {[key: string]: number}): string => { +export const rw = (object: { [key: string]: number }): string => { const array = []; for (const key in object) { for (let i = 0; i < object[key]; i++) { @@ -84,7 +99,7 @@ export const rw = (object: {[key: string]: number}): string => { } } return array[Math.floor(Math.random() * array.length)]; -} +}; /** * Returns a random integer from min to max biased towards one end based on exponent distribution (the bigger ex the higher bias towards min). @@ -94,8 +109,8 @@ export const rw = (object: {[key: string]: number}): string => { * @return {number} biased random integer */ export const biased = (min: number, max: number, ex: number): number => { - return Math.round(min + (max - min) * Math.pow(Math.random(), ex)); -} + return Math.round(min + (max - min) * Math.random() ** ex); +}; const ERROR = false; /** @@ -108,28 +123,28 @@ export const getNumberInRange = (r: string): number => { ERROR && console.error("Range value should be a string", r); return 0; } - if (!isNaN(+r)) return ~~r + +P(+r - ~~r); + if (!Number.isNaN(+r)) return ~~r + +P(+r - ~~r); const sign = r[0] === "-" ? -1 : 1; - if (isNaN(+r[0])) r = r.slice(1); + if (Number.isNaN(+r[0])) r = r.slice(1); const range = r.includes("-") ? r.split("-") : null; if (!range) { ERROR && console.error("Cannot parse the number. Check the format", r); return 0; } const count = rand(parseFloat(range[0]) * sign, +parseFloat(range[1])); - if (isNaN(count) || count < 0) { + if (Number.isNaN(count) || count < 0) { ERROR && console.error("Cannot parse number. Check the format", r); return 0; } return count; -} +}; /** * Generate a random seed string * @return {string} random seed */ export const generateSeed = (): string => { return String(Math.floor(Math.random() * 1e9)); -} +}; declare global { interface Window { @@ -144,4 +159,4 @@ declare global { getNumberInRange: typeof getNumberInRange; generateSeed: typeof generateSeed; } -} \ No newline at end of file +} diff --git a/src/utils/stringUtils.test.ts b/src/utils/stringUtils.test.ts new file mode 100644 index 00000000..86af5c39 --- /dev/null +++ b/src/utils/stringUtils.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "vitest"; +import { round } from "./stringUtils"; + +describe("round", () => { + it("should be able to handle undefined input", () => { + expect(round(undefined)).toBe(""); + }); +}); diff --git a/src/utils/stringUtils.ts b/src/utils/stringUtils.ts index 02c4d42a..01d3f38d 100644 --- a/src/utils/stringUtils.ts +++ b/src/utils/stringUtils.ts @@ -6,11 +6,11 @@ import { rn } from "./numberUtils"; * @param {number} decimals - Number of decimal places (default is 1) * @returns {string} - The string with rounded numbers */ -export const round = (inputString: string, decimals: number = 1) => { - return inputString.replace(/[\d\.-][\d\.e-]*/g, (n: string) => { +export const round = (inputString: string = "", decimals: number = 1) => { + return inputString.replace(/[\d.-][\d.e-]*/g, (n: string) => { return rn(parseFloat(n), decimals).toString(); }); -} +}; /** * Capitalize the first letter of a string @@ -19,7 +19,7 @@ export const round = (inputString: string, decimals: number = 1) => { */ export const capitalize = (inputString: string) => { return inputString.charAt(0).toUpperCase() + inputString.slice(1); -} +}; /** * Split a string into two parts, trying to balance their lengths @@ -46,13 +46,13 @@ export const splitInTwo = (inputString: string): string[] => { if (!last) return [first, middle]; if (first.length < last.length) return [first + middle, last]; return [first, middle + last]; -} +}; /** * Parse an SVG transform string into an array of numbers * @param {string} string - The SVG transform string * @returns {[number, number, number, number, number, number]} - The parsed transform as an array - * + * * @example * parseTransform("matrix(1, 0, 0, 1, 100, 200)") // returns [1, 0, 0, 1, 100, 200] * parseTransform("translate(50, 75)") // returns [50, 75, 0, 0, 0, 1] @@ -65,7 +65,7 @@ export const parseTransform = (string: string) => { .replace(/[ ]/g, ",") .split(","); return [a[0] || 0, a[1] || 0, a[2] || 0, a[3] || 0, a[4] || 0, a[5] || 1]; -} +}; /** * Check if a string is valid JSON @@ -76,7 +76,7 @@ export const isValidJSON = (str: string): boolean => { try { JSON.parse(str); return true; - } catch (e) { + } catch (_e) { return false; } }; @@ -89,7 +89,7 @@ export const isValidJSON = (str: string): boolean => { export const safeParseJSON = (str: string) => { try { return JSON.parse(str); - } catch (e) { + } catch (_e) { return null; } }; @@ -109,10 +109,10 @@ export const sanitizeId = (inputString: string) => { .replace(/\s+/g, "-"); // replace spaces with hyphens // remove leading numbers - if (sanitized.match(/^\d/)) sanitized = "_" + sanitized; + if (sanitized.match(/^\d/)) sanitized = `_${sanitized}`; return sanitized; -} +}; declare global { interface Window { @@ -122,4 +122,4 @@ declare global { parseTransform: typeof parseTransform; sanitizeId: typeof sanitizeId; } -} \ No newline at end of file +} diff --git a/src/utils/unitUtils.ts b/src/utils/unitUtils.ts index 072c0b38..142e139c 100644 --- a/src/utils/unitUtils.ts +++ b/src/utils/unitUtils.ts @@ -7,19 +7,23 @@ type TemperatureScale = "°C" | "°F" | "K" | "°R" | "°De" | "°N" | "°Ré" | * @param {string} targetScale - Target temperature scale * @returns {string} - Converted temperature with unit */ -export const convertTemperature = (temperatureInCelsius: number, targetScale: TemperatureScale = "°C") => { - const temperatureConversionMap: {[key: string]: (temp: number) => string} = { - "°C": (temp: number) => rn(temp) + "°C", - "°F": (temp: number) => rn((temp * 9) / 5 + 32) + "°F", - K: (temp: number) => rn(temp + 273.15) + "K", - "°R": (temp: number) => rn(((temp + 273.15) * 9) / 5) + "°R", - "°De": (temp: number) => rn(((100 - temp) * 3) / 2) + "°De", - "°N": (temp: number) => rn((temp * 33) / 100) + "°N", - "°Ré": (temp: number) => rn((temp * 4) / 5) + "°Ré", - "°Rø": (temp: number) => rn((temp * 21) / 40 + 7.5) + "°Rø" - }; +export const convertTemperature = ( + temperatureInCelsius: number, + targetScale: TemperatureScale = "°C", +) => { + const temperatureConversionMap: { [key: string]: (temp: number) => string } = + { + "°C": (temp: number) => `${rn(temp)}°C`, + "°F": (temp: number) => `${rn((temp * 9) / 5 + 32)}°F`, + K: (temp: number) => `${rn(temp + 273.15)}K`, + "°R": (temp: number) => `${rn(((temp + 273.15) * 9) / 5)}°R`, + "°De": (temp: number) => `${rn(((100 - temp) * 3) / 2)}°De`, + "°N": (temp: number) => `${rn((temp * 33) / 100)}°N`, + "°Ré": (temp: number) => `${rn((temp * 4) / 5)}°Ré`, + "°Rø": (temp: number) => `${rn((temp * 21) / 40 + 7.5)}°Rø`, + }; return temperatureConversionMap[targetScale](temperatureInCelsius); -} +}; /** * Convert number to short string with SI postfix @@ -27,13 +31,13 @@ export const convertTemperature = (temperatureInCelsius: number, targetScale: Te * @returns {string} - The converted string */ export const si = (n: number): string => { - if (n >= 1e9) return rn(n / 1e9, 1) + "B"; - if (n >= 1e8) return rn(n / 1e6) + "M"; - if (n >= 1e6) return rn(n / 1e6, 1) + "M"; - if (n >= 1e4) return rn(n / 1e3) + "K"; - if (n >= 1e3) return rn(n / 1e3, 1) + "K"; + if (n >= 1e9) return `${rn(n / 1e9, 1)}B`; + if (n >= 1e8) return `${rn(n / 1e6)}M`; + if (n >= 1e6) return `${rn(n / 1e6, 1)}M`; + if (n >= 1e4) return `${rn(n / 1e3)}K`; + if (n >= 1e3) return `${rn(n / 1e3, 1)}K`; return rn(n).toString(); -} +}; /** * Convert string with SI postfix to integer @@ -42,11 +46,11 @@ export const si = (n: number): string => { */ export const getIntegerFromSI = (value: string): number => { const metric = value.slice(-1); - if (metric === "K") return parseInt(value.slice(0, -1)) * 1e3; - if (metric === "M") return parseInt(value.slice(0, -1)) * 1e6; - if (metric === "B") return parseInt(value.slice(0, -1)) * 1e9; - return parseInt(value); -} + if (metric === "K") return parseInt(value.slice(0, -1), 10) * 1e3; + if (metric === "M") return parseInt(value.slice(0, -1), 10) * 1e6; + if (metric === "B") return parseInt(value.slice(0, -1), 10) * 1e9; + return parseInt(value, 10); +}; declare global { interface Window { diff --git a/tests/e2e/burgs.spec.ts b/tests/e2e/burgs.spec.ts new file mode 100644 index 00000000..f78bc38f --- /dev/null +++ b/tests/e2e/burgs.spec.ts @@ -0,0 +1,109 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Burgs.add", () => { + test.beforeEach(async ({ context, page }) => { + await context.clearCookies(); + + await page.goto("/"); + await page.evaluate(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + + // Navigate with seed parameter and wait for full load + await page.goto("/?seed=test-burgs&width=1280&height=720"); + + // Wait for map generation to complete + await page.waitForFunction( + () => (window as any).mapId !== undefined, + { timeout: 60000 } + ); + + // Additional wait for any rendering/animations to settle + await page.waitForTimeout(500); + }); + + test("should create burg with falsy port value when not on coast", async ({ + page, + }) => { + const result = await page.evaluate(() => { + const { cells, burgs } = (window as any).pack; + + // Find a land cell that is not on the coast (no harbor) + let inlandCellId: number | null = null; + for (let i = 1; i < cells.i.length; i++) { + const isLand = cells.h[i] >= 20; + const hasNoHarbor = !cells.harbor[i]; + const hasNoBurg = !cells.burg[i]; + if (isLand && hasNoHarbor && hasNoBurg) { + inlandCellId = i; + break; + } + } + + if (!inlandCellId) { + return { error: "No inland cell found" }; + } + + // Get coordinates for the inland cell + const [x, y] = cells.p[inlandCellId]; + + // Add a new burg at this inland location + const Burgs = (window as any).Burgs; + const burgId = Burgs.add([x, y]); + const burg = burgs[burgId]; + + return { + burgId, + port: burg.port, + portType: typeof burg.port, + portIsFalsy: !burg.port, + x: burg.x, + y: burg.y, + }; + }); + + expect(result.error).toBeUndefined(); + // Port should be 0 (number), not "0" (string) + expect(result.port).toBe(0); + expect(result.portType).toBe("number"); + expect(result.portIsFalsy).toBe(true); + // Explicitly verify it's not the buggy string "0" + expect(result.port).not.toBe("0"); + }); + + test("port toggle button should be inactive for non-coastal burg", async ({ + page, + }) => { + // Add a burg on an inland cell + const burgId = await page.evaluate(() => { + const { cells } = (window as any).pack; + + // Find a land cell that is not on the coast + for (let i = 1; i < cells.i.length; i++) { + const isLand = cells.h[i] >= 20; + const hasNoHarbor = !cells.harbor[i]; + const hasNoBurg = !cells.burg[i]; + if (isLand && hasNoHarbor && hasNoBurg) { + const [x, y] = cells.p[i]; + return (window as any).Burgs.add([x, y]); + } + } + return null; + }); + + expect(burgId).not.toBeNull(); + + // Open the burg editor + await page.evaluate((id: number) => { + (window as any).editBurg(id); + }, burgId!); + + // Wait for the editor dialog to appear + await page.waitForSelector("#burgEditor", { state: "visible" }); + + // The port toggle button should have the "inactive" class + const portButton = page.locator("#burgPort"); + await expect(portButton).toHaveClass(/inactive/); + }); +}); diff --git a/tests/e2e/layers.spec.ts b/tests/e2e/layers.spec.ts new file mode 100644 index 00000000..458ded73 --- /dev/null +++ b/tests/e2e/layers.spec.ts @@ -0,0 +1,241 @@ +import { test, expect } from '@playwright/test' + +test.describe('map layers', () => { + test.beforeEach(async ({ context, page }) => { + // Clear all storage to ensure clean state + await context.clearCookies() + + await page.goto('/') + await page.evaluate(() => { + localStorage.clear() + sessionStorage.clear() + }) + + // Navigate with seed parameter and wait for full load + // NOTE: + // - We use a fixed seed ("test-seed") to make map generation deterministic for snapshot tests. + // - Snapshots are OS-independent (configured in playwright.config.ts). + await page.goto('/?seed=test-seed&&width=1280&height=720') + + // Wait for map generation to complete by checking window.mapId + // mapId is exposed on window at the very end of showStatistics() + await page.waitForFunction(() => (window as any).mapId !== undefined, { timeout: 60000 }) + + // Additional wait for any rendering/animations to settle + await page.waitForTimeout(500) + }) + + // Ocean and water layers + test('ocean layer', async ({ page }) => { + const ocean = page.locator('#ocean') + await expect(ocean).toBeAttached() + const html = await ocean.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('ocean.html') + }) + + test('lakes layer', async ({ page }) => { + const lakes = page.locator('#lakes') + await expect(lakes).toBeAttached() + const html = await lakes.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('lakes.html') + }) + + test('coastline layer', async ({ page }) => { + const coastline = page.locator('#coastline') + await expect(coastline).toBeAttached() + const html = await coastline.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('coastline.html') + }) + + // Terrain and heightmap layers + test('terrain layer', async ({ page }) => { + const terrs = page.locator('#terrs') + await expect(terrs).toBeAttached() + const html = await terrs.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('terrain.html') + }) + + test('landmass layer', async ({ page }) => { + const landmass = page.locator('#landmass') + await expect(landmass).toBeAttached() + const html = await landmass.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('landmass.html') + }) + + // Climate and environment layers + test('biomes layer', async ({ page }) => { + const biomes = page.locator('#biomes') + await expect(biomes).toBeAttached() + const html = await biomes.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('biomes.html') + }) + + test('ice layer', async ({ page }) => { + const ice = page.locator('#ice') + await expect(ice).toBeAttached() + const html = await ice.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('ice.html') + }) + + test('temperature layer', async ({ page }) => { + const temperature = page.locator('#temperature') + await expect(temperature).toBeAttached() + const html = await temperature.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('temperature.html') + }) + + test('precipitation layer', async ({ page }) => { + const prec = page.locator('#prec') + await expect(prec).toBeAttached() + const html = await prec.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('precipitation.html') + }) + + // Geographic features + test('rivers layer', async ({ page }) => { + const rivers = page.locator('#rivers') + await expect(rivers).toBeAttached() + const html = await rivers.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('rivers.html') + }) + + test('relief layer', async ({ page }) => { + const terrain = page.locator('#terrain') + await expect(terrain).toBeAttached() + const html = await terrain.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('relief.html') + }) + + // Political layers + test('states/regions layer', async ({ page }) => { + const regions = page.locator('#regions') + await expect(regions).toBeAttached() + const html = await regions.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('regions.html') + }) + + test('provinces layer', async ({ page }) => { + const provs = page.locator('#provs') + await expect(provs).toBeAttached() + const html = await provs.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('provinces.html') + }) + + test('borders layer', async ({ page }) => { + const borders = page.locator('#borders') + await expect(borders).toBeAttached() + const html = await borders.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('borders.html') + }) + + // Cultural layers + test('cultures layer', async ({ page }) => { + const cults = page.locator('#cults') + await expect(cults).toBeAttached() + const html = await cults.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('cultures.html') + }) + + test('religions layer', async ({ page }) => { + const relig = page.locator('#relig') + await expect(relig).toBeAttached() + const html = await relig.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('religions.html') + }) + + // Infrastructure layers + test('routes layer', async ({ page }) => { + const routes = page.locator('#routes') + await expect(routes).toBeAttached() + const html = await routes.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('routes.html') + }) + + // Settlement layers + test('burgs/icons layer', async ({ page }) => { + const icons = page.locator('#icons') + await expect(icons).toBeAttached() + const html = await icons.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('icons.html') + }) + + test('anchors layer', async ({ page }) => { + const anchors = page.locator('#anchors') + await expect(anchors).toBeAttached() + const html = await anchors.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('anchors.html') + }) + + // Labels layer (without text content due to font rendering) + test('labels layer', async ({ page }) => { + const labels = page.locator('#labels') + await expect(labels).toBeAttached() + // Remove text content but keep structure (text rendering varies) + const html = await labels.evaluate((el) => { + const clone = el.cloneNode(true) as Element + clone.querySelectorAll('text, tspan').forEach((t) => t.remove()) + return clone.outerHTML + }) + expect(html).toMatchSnapshot('labels.html') + }) + + // Military and markers + test('markers layer', async ({ page }) => { + const markers = page.locator('#markers') + await expect(markers).toBeAttached() + const html = await markers.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('markers.html') + }) + + test('armies layer', async ({ page }) => { + const armies = page.locator('#armies') + await expect(armies).toBeAttached() + const html = await armies.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('armies.html') + }) + + // Special features + test('zones layer', async ({ page }) => { + const zones = page.locator('#zones') + await expect(zones).toBeAttached() + const html = await zones.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('zones.html') + }) + + test('emblems layer', async ({ page }) => { + const emblems = page.locator('#emblems') + await expect(emblems).toBeAttached() + const html = await emblems.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('emblems.html') + }) + + // Grid and coordinates + test('cells layer', async ({ page }) => { + const cells = page.locator('g#cells') + await expect(cells).toBeAttached() + const html = await cells.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('cells.html') + }) + + test('coordinates layer', async ({ page }) => { + const coordinates = page.locator('#coordinates') + await expect(coordinates).toBeAttached() + const html = await coordinates.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('coordinates.html') + }) + + test('compass layer', async ({ page }) => { + const compass = page.locator('#compass') + await expect(compass).toBeAttached() + const html = await compass.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('compass.html') + }) + + // Population layer + test('population layer', async ({ page }) => { + const population = page.locator('#population') + await expect(population).toBeAttached() + const html = await population.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('population.html') + }) +}) diff --git a/tests/e2e/layers.spec.ts-snapshots/anchors.html b/tests/e2e/layers.spec.ts-snapshots/anchors.html new file mode 100644 index 00000000..3037abb5 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/anchors.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/armies.html b/tests/e2e/layers.spec.ts-snapshots/armies.html new file mode 100644 index 00000000..face6396 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/armies.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/biomes.html b/tests/e2e/layers.spec.ts-snapshots/biomes.html new file mode 100644 index 00000000..582a9c1d --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/biomes.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/borders.html b/tests/e2e/layers.spec.ts-snapshots/borders.html new file mode 100644 index 00000000..47d6122a --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/borders.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/cells.html b/tests/e2e/layers.spec.ts-snapshots/cells.html new file mode 100644 index 00000000..d73d9b2f --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/cells.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/coastline.html b/tests/e2e/layers.spec.ts-snapshots/coastline.html new file mode 100644 index 00000000..7a2c4c51 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/coastline.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/compass.html b/tests/e2e/layers.spec.ts-snapshots/compass.html new file mode 100644 index 00000000..3c0892a6 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/compass.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/coordinates.html b/tests/e2e/layers.spec.ts-snapshots/coordinates.html new file mode 100644 index 00000000..48e6c40b --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/coordinates.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/cultures.html b/tests/e2e/layers.spec.ts-snapshots/cultures.html new file mode 100644 index 00000000..193726a3 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/cultures.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/emblems.html b/tests/e2e/layers.spec.ts-snapshots/emblems.html new file mode 100644 index 00000000..1de7ef9d --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/emblems.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/ice.html b/tests/e2e/layers.spec.ts-snapshots/ice.html new file mode 100644 index 00000000..1729b6ff --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/ice.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/icons.html b/tests/e2e/layers.spec.ts-snapshots/icons.html new file mode 100644 index 00000000..c759dc38 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/icons.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/labels.html b/tests/e2e/layers.spec.ts-snapshots/labels.html new file mode 100644 index 00000000..6ffcf3b9 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/labels.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/lakes.html b/tests/e2e/layers.spec.ts-snapshots/lakes.html new file mode 100644 index 00000000..cce3f70e --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/lakes.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/landmass.html b/tests/e2e/layers.spec.ts-snapshots/landmass.html new file mode 100644 index 00000000..ec70a34e --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/landmass.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/markers.html b/tests/e2e/layers.spec.ts-snapshots/markers.html new file mode 100644 index 00000000..100a1e3f --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/markers.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/ocean.html b/tests/e2e/layers.spec.ts-snapshots/ocean.html new file mode 100644 index 00000000..b950e1a7 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/ocean.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/population.html b/tests/e2e/layers.spec.ts-snapshots/population.html new file mode 100644 index 00000000..10175492 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/population.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/precipitation.html b/tests/e2e/layers.spec.ts-snapshots/precipitation.html new file mode 100644 index 00000000..8ab517cb --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/precipitation.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/provinces.html b/tests/e2e/layers.spec.ts-snapshots/provinces.html new file mode 100644 index 00000000..3fe87d6e --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/provinces.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/regions.html b/tests/e2e/layers.spec.ts-snapshots/regions.html new file mode 100644 index 00000000..86187361 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/regions.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/relief.html b/tests/e2e/layers.spec.ts-snapshots/relief.html new file mode 100644 index 00000000..6883fe5b --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/relief.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/religions.html b/tests/e2e/layers.spec.ts-snapshots/religions.html new file mode 100644 index 00000000..85c96e30 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/religions.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/rivers.html b/tests/e2e/layers.spec.ts-snapshots/rivers.html new file mode 100644 index 00000000..81b2fcf9 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/rivers.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/routes.html b/tests/e2e/layers.spec.ts-snapshots/routes.html new file mode 100644 index 00000000..5c7688c3 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/routes.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/temperature.html b/tests/e2e/layers.spec.ts-snapshots/temperature.html new file mode 100644 index 00000000..36464dbd --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/temperature.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/terrain.html b/tests/e2e/layers.spec.ts-snapshots/terrain.html new file mode 100644 index 00000000..bc13f8be --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/terrain.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/zones.html b/tests/e2e/layers.spec.ts-snapshots/zones.html new file mode 100644 index 00000000..14cd5141 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/zones.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 8b583a9d..01672af5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,5 +22,6 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/e2e"] } \ No newline at end of file diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts new file mode 100644 index 00000000..cf8528b8 --- /dev/null +++ b/vitest.browser.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config' +import { playwright } from '@vitest/browser-playwright' + +export default defineConfig({ + test: { + browser: { + enabled: true, + provider: playwright(), + // https://vitest.dev/config/browser/playwright + instances: [ + { name: 'chromium', browser: 'chromium' }, + ], + locators: { + testIdAttribute: 'id', + }, + }, + }, +})