diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 97195d9..c88f371 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -17,6 +17,19 @@ jobs: - run: pip install pytest - name: verify the tiling and regenerate data run: python -m pytest tools/tests -q + - name: validate the mod manifest + run: | + python3 -c " + import json + d = json.load(open('info.json')) + assert d['factorio_version'] == '2.0', d['factorio_version'] + for k in ('name','version','title','author','description','dependencies'): + assert k in d, k + print('manifest ok:', d['name'], d['version']) + " + # Catch version drift on every push, not only when a release is cut. + - name: check version consistency + run: python3 tools/check_version.py core: name: tiling core (Lua 5.2) @@ -32,4 +45,4 @@ jobs: - run: luarocks install busted - run: luarocks install luacheck - run: busted - - run: luacheck hat spec + - run: luacheck hat mod prototypes spec diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..55e7b42 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,100 @@ +name: release + +# Releases are cut by pushing a tag. info.json is the single source of truth +# for the version; the tag and the changelog must agree with it or nothing is +# built. That is the whole point -- a mod portal page describing a build +# nobody has is the failure being designed against. +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + publish: + description: "Also upload to the Factorio mod portal" + type: boolean + default: false + +permissions: + contents: write + +jobs: + build: + name: verify and build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install pytest + + # Before anything is built, let alone published. + - name: check version consistency + run: python3 tools/check_version.py ${{ github.ref_type == 'tag' && github.ref_name || '' }} + + - name: offline pipeline tests + run: python -m pytest tools/tests -q + + # Factorio embeds Lua 5.2.1; testing on anything newer would mask the + # all-doubles behaviour the tiling core depends on. + - uses: leafo/gh-actions-lua@v10 + with: + luaVersion: "5.2" + - uses: leafo/gh-actions-luarocks@v4 + - run: luarocks install busted + - run: luarocks install luacheck + - run: busted + - run: luacheck hat mod prototypes spec + + - name: build the mod zip + run: python3 tools/package.py + + - name: name the artifact + id: zip + run: | + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import json + i = json.load(open("info.json")) + print(f"path=build/{i['name']}_{i['version']}.zip") + print(f"version={i['version']}") + PY + + - uses: actions/upload-artifact@v4 + with: + name: mod-zip + path: ${{ steps.zip.outputs.path }} + if-no-files-found: error + + - name: attach to the GitHub release + if: github.ref_type == 'tag' + uses: softprops/action-gh-release@v2 + with: + files: ${{ steps.zip.outputs.path }} + generate_release_notes: true + + publish: + name: upload to the mod portal + needs: build + runs-on: ubuntu-latest + # Only on a tag, or an explicit dispatch asking for it. A portal release + # cannot be withdrawn, so this must never fire by accident. + if: github.ref_type == 'tag' || inputs.publish + # Configure this environment with a required reviewer if you want a human + # gate before anything reaches the portal. + environment: mod-portal + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: mod-zip + path: build + - name: upload + env: + FACTORIO_API_KEY: ${{ secrets.FACTORIO_API_KEY }} + run: | + if [ -z "$FACTORIO_API_KEY" ]; then + echo "::error::FACTORIO_API_KEY is not set; add it as a repository secret" + exit 1 + fi + sh tools/publish_mod.sh build/*.zip diff --git a/.gitignore b/.gitignore index 453f897..3e9996a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ __pycache__/ .venv/ luacov.* .wiki-tmp/ +build/ diff --git a/.luacheckrc b/.luacheckrc index ec09e8f..b462679 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -1,3 +1,30 @@ std = "lua52" -files["spec"] = { std = "+busted" } max_line_length = 100 + +-- Factorio's Lua environment. These are provided by the game, not by Lua. +local factorio_globals = { + "data", "mods", "settings", "defines", "game", "script", + "prototypes", "remote", "commands", "rendering", "log", "serpent", + "table_size", "helpers", +} + +-- storage is the mod's persistent table: Factorio provides it, mods write to +-- it, so it is not read-only. +local factorio_writable = { "storage", "table" } + +files["prototypes"] = { + read_globals = factorio_globals, + -- Factorio adds table.deepcopy + globals = factorio_writable, +} + +files["mod"] = { + read_globals = factorio_globals, + globals = factorio_writable, +} + +files["spec"] = { std = "+busted" } + +-- hat/ is deliberately free of Factorio dependencies: it must load under +-- plain lua5.2 so it can be tested in CI. No Factorio globals here. +files["hat"] = {} diff --git a/Makefile b/Makefile index 4a2aa7a..0aa398d 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ else RUN_BUSTED := $(BUSTED) endif -.PHONY: all test pytest lint data check preview wiki +.PHONY: all test pytest lint data check preview wiki package version all: check @@ -38,9 +38,17 @@ preview: done lint: - luacheck hat spec + luacheck hat mod prototypes spec -check: pytest test lint +check: version pytest test lint + +## check info.json, changelog and tag agree +version: + python3 tools/check_version.py + +## build the distributable mod zip +package: + python3 tools/package.py ## publish wiki/ to the GitHub wiki (needs one page created in the UI first) wiki: diff --git a/README.md b/README.md index dfc39a3..d9f8620 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,10 @@ discovered in 2023. Cells never repeat, so neither can your blueprints. ![The hat tiling as Hattorio generates it](docs/preview/tiling.png) -> **Status: in development.** The tiling engine is finished and tested. The -> mod itself — terrain, build restrictions, settings — is not written yet. -> There is nothing to install from the mod portal today. +> **Status: playable, not yet released.** Terrain, build restrictions and +> settings all work and have been tested in game. Not on the mod portal yet, +> and **multiplayer is unverified** — the determinism argument is sound but +> has never been confirmed with two clients. Single player should be solid. ## What it does to your game @@ -24,33 +25,35 @@ That single rule changes how you build: default settings there are **624 distinct buildable tile shapes**, and no single one covers more than 1.1% of the map. A layout that fills one cell will not fit the next. -- **Small blueprints still travel.** Anything up to 11×11 at the default +- **Small blueprints still travel.** Anything up to 18×18 at the default settings fits every cell. Assembler clusters are fine; a 30×30 mall is not. - **Every connection between cells is a puzzle.** Belts cross by underground pair, against an edge that is never axis-aligned. The result is a factory that has to be fitted to the ground it sits on. -![Cells rasterised onto Factorio's tile grid](docs/preview/bands-26-2.png) +![Cells rasterised onto Factorio's tile grid](docs/preview/bands-41-3.png) ## Settings Per planet, set at world creation. -| Setting | Default | Range | +| Setting | Default | Choices | |---|---|---| -| Hat size (centre to vertex, tiles) | **26** | 15–90 | -| Band width (tiles) | **2** | 1–6 | +| Cell size (centre to vertex, tiles) | **41** (normal) | 15, 26, 41, 52 | +| Band width (tiles) | **3** (wide) | 1, 2, 3, 4 | +| Band appearance | Dark liquid | liquid, void | +| Band colour | Deep violet | violet, cold, oily, ember, void black | Hat size is the dial that matters. It decides how much room you get and how large a blueprint survives: -| size | cell area | unbuildable | reusable blueprint | preview | +| size | difficulty | cell area | reusable blueprint | preview | |---|---|---|---|---| -| 15 | 116 t | 37.4% | ~5×5 | [look](docs/preview/bands-15-2.png) | -| **26** | **347 t** | **22.5%** | **11×11** | [look](docs/preview/bands-26-2.png) | -| 41 | 944 t | 14.6% | 18×18 | [look](docs/preview/bands-41-2.png) | -| 52 | 1,584 t | 11.5% | 23×23 | [look](docs/preview/bands-52-2.png) | +| 15 | very hard | 116 t | ~5×5 | [look](docs/preview/bands-15-2.png) | +| 26 | hard | 347 t | 11×11 | [look](docs/preview/bands-26-2.png) | +| **41** | **normal** | **944 t** | **18×18** | [look](docs/preview/bands-41-3.png) | +| 52 | easy | 1,584 t | 23×23 | [look](docs/preview/bands-52-2.png) | Changing the setting later affects only newly created surfaces; existing ones keep the geometry their terrain was built with, so nothing ever seams. diff --git a/changelog.txt b/changelog.txt new file mode 100644 index 0000000..353378e --- /dev/null +++ b/changelog.txt @@ -0,0 +1,7 @@ +--------------------------------------------------------------------------------------------------- +Version: 0.1.0 +Date: 2026-09-20 + Major Features: + - Planets are divided into cells of the aperiodic hat monotile tiling. + - Cells are separated by narrow unbuildable bands. Undergrounds tunnel + beneath them; rails cross them; bots and power ignore them. diff --git a/control.lua b/control.lua new file mode 100644 index 0000000..39a26d1 --- /dev/null +++ b/control.lua @@ -0,0 +1,2 @@ +require("mod.terrain") +require("mod.commands") diff --git a/data-final-fixes.lua b/data-final-fixes.lua new file mode 100644 index 0000000..dbad61c --- /dev/null +++ b/data-final-fixes.lua @@ -0,0 +1 @@ +require("prototypes.patch") diff --git a/data.lua b/data.lua new file mode 100644 index 0000000..3a6ae92 --- /dev/null +++ b/data.lua @@ -0,0 +1,2 @@ +require("prototypes.collision") +require("prototypes.tiles") diff --git a/docs/design/hattorio-design.md b/docs/design/hattorio-design.md index 35fa3e6..ffcb030 100644 --- a/docs/design/hattorio-design.md +++ b/docs/design/hattorio-design.md @@ -409,13 +409,36 @@ Per planet, startup: | Setting | Default | Range | |---|---|---| -| `hattorio-hat-size-` | **26** (centre-to-vertex, tiles) | 15-90 | -| `hattorio-band-width-` | **2** | 1-6 | - -Both are genuine player settings, per planet, exactly as Hextorio does -it. 26/2 gives a 347-tile cell where blueprints up to 11x11 -- about -one assembler cluster -- stay reusable while everything larger must be -hand-fitted. Hextorio parity would be size 52; 41 is a middle option. +| `hattorio-hat-size-` | **41** (normal) | 15, 26, 41, 52 | +| `hattorio-band-width-` | **3** (wide) | 1, 2, 3, 4 | +| `hattorio-band-style` | liquid | liquid, void | +| `hattorio-band-colour` | violet | violet, cold, oily, ember, ink | +| `hattorio-richness-override` (map) | 0 = automatic | 0 - 10 | +| `hattorio-show-outline` (per player) | off | on, off | + +Size and band are **dropdowns**, not sliders: the difficulty of a given +size is not readable from the number, and the interesting range is four +values rather than seventy-six. Each is labelled with the difficulty it +implies -- 15 very hard, 26 hard, 41 normal, 52 easy. + +**41/3 is the default, chosen by playtest.** An earlier 26/2 proved +cramped in practice. 41 gives a 944-tile cell where blueprints up to +18x18 stay reusable -- enough to build in, while every standard layout +still breaks. Band 3 keeps the bands visually substantial, which band 2 +does not at that cell size. + +Band style and band colour are **independent**: the style picks the +shader laid over the surface, the colour picks what it is tinted. + +A third style using Vulcanus's lava shader was built and then removed +after playtesting -- it read as a hazard rather than a void and fought +the calm look of everything else. Removing it also dropped a Space Age +conditional from the data stage. + +Only size and band are per planet. Band style and colour are global, and the other +two are scoped by what they affect: richness is map-wide because ore is +shared, and the outline is per player because it is pure presentation +and cannot change what anyone else sees. **Why startup rather than runtime.** The geometry determines terrain, so changing it under an existing save would leave every generated diff --git a/docs/design/toolchain-decisions.md b/docs/design/toolchain-decisions.md index 28e0888..d598a4b 100644 --- a/docs/design/toolchain-decisions.md +++ b/docs/design/toolchain-decisions.md @@ -94,3 +94,34 @@ Design section 7 states the three rules that make it explicit. The one deliberate exception is the descent's output accumulator, mutated in place for speed. It is local to `hat/tiling.lua` and never escapes. + +## Releasing + +`info.json` is the single source of truth for the version. Everything else +must agree with it, and `tools/check_version.py` enforces that on every push, +not only when a release is cut: + +- the changelog must have a `Version: X.Y.Z` entry for it, listed first +- the changelog must open with exactly 99 dashes, or Factorio refuses to load + the mod with an unhelpful message +- a release tag, if given, must be `v` + +A release is cut by pushing a tag: + +```bash +# bump info.json and add a changelog entry first +git tag v0.2.0 && git push origin v0.2.0 +``` + +`.github/workflows/release.yml` then verifies the version, runs the full suite +under Lua 5.2, builds the zip, attaches it to a GitHub release, and uploads it +to the Factorio mod portal. + +The portal upload needs a `FACTORIO_API_KEY` repository secret, from +https://factorio.com/profile with the *ModPortal: Upload Mods* permission. It +runs in a `mod-portal` GitHub environment, which can be given a required +reviewer so a human approves before anything is published. + +**A portal release cannot be withdrawn.** That is why every check runs before +the upload step rather than after, and why the upload never fires on a branch +push — only on a tag, or a manual dispatch that explicitly asks for it. diff --git a/docs/plans/the-mod.md b/docs/plans/the-mod.md index 3fa4965..2877311 100644 --- a/docs/plans/the-mod.md +++ b/docs/plans/the-mod.md @@ -1069,7 +1069,12 @@ git commit -m "feat(mod): thumbnail and distributable zip" --- -### Task 10: Multiplayer determinism check +### Task 10: Multiplayer determinism check [DEFERRED] + +> **Deferred by decision, not forgotten.** This needs a second machine and +> ideally a second operating system, which is the whole point of the check -- +> the risk being tested is divergence between platforms. It gates *release*, +> not the code, and the mod is marked single-player-only until it is done. The spec's determinism argument is from first principles — correctly-rounded IEEE operations, no `sin`/`cos`/`pow`, one VM instruction per operation. It diff --git a/docs/preview/band-colours.png b/docs/preview/band-colours.png new file mode 100644 index 0000000..98cff9f Binary files /dev/null and b/docs/preview/band-colours.png differ diff --git a/docs/preview/bands-41-3.png b/docs/preview/bands-41-3.png new file mode 100644 index 0000000..8698c88 Binary files /dev/null and b/docs/preview/bands-41-3.png differ diff --git a/docs/preview/bands-41-3.svg b/docs/preview/bands-41-3.svg new file mode 100644 index 0000000..400d66e --- /dev/null +++ b/docs/preview/bands-41-3.svg @@ -0,0 +1,21971 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/info.json b/info.json new file mode 100644 index 0000000..f377dcb --- /dev/null +++ b/info.json @@ -0,0 +1,9 @@ +{ + "name": "hattorio", + "version": "0.1.0", + "title": "Hattorio", + "author": "vetkat", + "factorio_version": "2.0", + "description": "Replaces the square grid with the aperiodic hat monotile tiling. Cells never repeat, so neither can your blueprints.", + "dependencies": ["base >= 2.0.0", "? space-age"] +} diff --git a/locale/en/hattorio.cfg b/locale/en/hattorio.cfg new file mode 100644 index 0000000..976e929 --- /dev/null +++ b/locale/en/hattorio.cfg @@ -0,0 +1,87 @@ +[mod-setting-name] +hattorio-hat-size-nauvis=Cell size (Nauvis) +hattorio-band-width-nauvis=Band width (Nauvis) +hattorio-hat-size-vulcanus=Cell size (Vulcanus) +hattorio-band-width-vulcanus=Band width (Vulcanus) +hattorio-hat-size-fulgora=Cell size (Fulgora) +hattorio-band-width-fulgora=Band width (Fulgora) +hattorio-hat-size-gleba=Cell size (Gleba) +hattorio-band-width-gleba=Band width (Gleba) +hattorio-hat-size-aquilo=Cell size (Aquilo) +hattorio-band-width-aquilo=Band width (Aquilo) + +hattorio-band-style=Band appearance +hattorio-band-colour=Band colour +hattorio-richness-override=Ore richness override +hattorio-show-outline=Show cell outlines + +[string-mod-setting] +hattorio-band-style-liquid=Dark liquid (animated) +hattorio-band-style-void=Void (flat black) +hattorio-band-colour-violet=Deep violet +hattorio-band-colour-cold=Near-black, cold +hattorio-band-colour-oily=Oily green-black +hattorio-band-colour-ember=Ember black +hattorio-band-colour-ink=Pure void black +hattorio-hat-size-nauvis-15=15 tiles (very hard) +hattorio-hat-size-nauvis-26=26 tiles (hard) +hattorio-hat-size-nauvis-41=41 tiles (normal) +hattorio-hat-size-nauvis-52=52 tiles (easy) +hattorio-band-width-nauvis-1=1 tile (thin) +hattorio-band-width-nauvis-2=2 tiles (normal) +hattorio-band-width-nauvis-3=3 tiles (wide) +hattorio-band-width-nauvis-4=4 tiles (very wide) +hattorio-hat-size-vulcanus-15=15 tiles (very hard) +hattorio-hat-size-vulcanus-26=26 tiles (hard) +hattorio-hat-size-vulcanus-41=41 tiles (normal) +hattorio-hat-size-vulcanus-52=52 tiles (easy) +hattorio-band-width-vulcanus-1=1 tile (thin) +hattorio-band-width-vulcanus-2=2 tiles (normal) +hattorio-band-width-vulcanus-3=3 tiles (wide) +hattorio-band-width-vulcanus-4=4 tiles (very wide) +hattorio-hat-size-fulgora-15=15 tiles (very hard) +hattorio-hat-size-fulgora-26=26 tiles (hard) +hattorio-hat-size-fulgora-41=41 tiles (normal) +hattorio-hat-size-fulgora-52=52 tiles (easy) +hattorio-band-width-fulgora-1=1 tile (thin) +hattorio-band-width-fulgora-2=2 tiles (normal) +hattorio-band-width-fulgora-3=3 tiles (wide) +hattorio-band-width-fulgora-4=4 tiles (very wide) +hattorio-hat-size-gleba-15=15 tiles (very hard) +hattorio-hat-size-gleba-26=26 tiles (hard) +hattorio-hat-size-gleba-41=41 tiles (normal) +hattorio-hat-size-gleba-52=52 tiles (easy) +hattorio-band-width-gleba-1=1 tile (thin) +hattorio-band-width-gleba-2=2 tiles (normal) +hattorio-band-width-gleba-3=3 tiles (wide) +hattorio-band-width-gleba-4=4 tiles (very wide) +hattorio-hat-size-aquilo-15=15 tiles (very hard) +hattorio-hat-size-aquilo-26=26 tiles (hard) +hattorio-hat-size-aquilo-41=41 tiles (normal) +hattorio-hat-size-aquilo-52=52 tiles (easy) +hattorio-band-width-aquilo-1=1 tile (thin) +hattorio-band-width-aquilo-2=2 tiles (normal) +hattorio-band-width-aquilo-3=3 tiles (wide) +hattorio-band-width-aquilo-4=4 tiles (very wide) + +[mod-setting-description] +hattorio-band-style=What the bands between cells are made of. Dark liquid moves and oozes. Void is a flat, still hole. +hattorio-band-colour=What colour the bands are tinted. Every option is near-black. What separates them is the highlight the surface catches at its edges. Reflected cells, about one in eight, are tinted more strongly so you can pick them out. +hattorio-richness-override=Multiplier applied to ore in newly generated chunks, replacing the automatic compensation for ore the bands put out of reach. Leave at 0 to compensate automatically from your cell size and band width. +hattorio-show-outline=Draw a line along the centre of each band. Affects only your own view. +hattorio-hat-size-nauvis=How large each buildable cell is. Smaller cells are harder: less room, and only smaller blueprints stay reusable. 15 gives about 116 tiles per cell, 41 about 944, 52 about 1,584. +hattorio-band-width-nauvis=How wide the unbuildable band between cells is. Wider bands take more of the map and make crossings more awkward. All widths can be spanned by underground belts. +hattorio-hat-size-vulcanus=How large each buildable cell is. Smaller cells are harder. +hattorio-band-width-vulcanus=How wide the unbuildable band between cells is. +hattorio-hat-size-fulgora=How large each buildable cell is. Smaller cells are harder. +hattorio-band-width-fulgora=How wide the unbuildable band between cells is. +hattorio-hat-size-gleba=How large each buildable cell is. Smaller cells are harder. +hattorio-band-width-gleba=How wide the unbuildable band between cells is. +hattorio-hat-size-aquilo=How large each buildable cell is. Smaller cells are harder. +hattorio-band-width-aquilo=How wide the unbuildable band between cells is. + +[command-help] +hattorio-info=Report the cell size, band width and ore richness in force on this surface. + +[tile-name] +hattorio-band=Cell boundary diff --git a/mod/bands.lua b/mod/bands.lua new file mode 100644 index 0000000..011a31b --- /dev/null +++ b/mod/bands.lua @@ -0,0 +1,100 @@ +-- Which tiles of an area fall on a band. +-- +-- Pure: no Factorio API, so this runs under plain Lua in CI. A tile is a band +-- tile when its centre lies within half the band width of any hat edge. +-- +-- PERFORMANCE. The obvious implementation -- test every tile against every +-- edge -- is O(tiles x hats x edges) and measured 39.7 ms per chunk, fifty +-- times the cost of the tiling descent itself. Instead each edge is +-- rasterised into its own bounding box, clipped to the area, which touches +-- only tiles that could possibly be near it. +-- +-- Mirror marking: a reflected hat paints its own edges in the mirror variant, +-- and where a reflected and an unreflected hat share an edge the reflected one +-- wins. That is an OR over the hats covering a tile, so it does not depend on +-- iteration order -- which matters, because two clients must agree. + +local G = require("hat.geometry") + +local Bands = {} + +local floor, ceil = math.floor, math.ceil + +--- @param tiling a hat.tiling instance +--- @param geom from mod.config.geometry, plus tile names +--- @param area { left, top, right, bottom } in tile coordinates; right and +--- bottom are exclusive, matching Factorio's chunk areas +--- @return array of { x = , y = , mirror = boolean }, in scan order +function Bands.for_area(tiling, geom, area) + local unit, half = geom.unit, geom.half + + -- Query wider than the area: a hat centred outside it can still own an edge + -- running through it. + local pad = geom.band + 2 + local hats = tiling:hats_in_box({ + left = area.left - pad, top = area.top - pad, + right = area.right + pad, bottom = area.bottom + pad, + }) + + local left, top = area.left, area.top + local right, bottom = area.right - 1, area.bottom - 1 + + -- rows[ty][tx] = false for a plain band tile, true for a mirror one + local rows = {} + local dist = G.dist_to_segment + + for i = 1, #hats do + local mirror = hats[i].reflected + local edges = G.edges(hats[i].xf, unit) + for j = 1, #edges do + local s = edges[j] + local x1, y1, x2, y2 = s[1], s[2], s[3], s[4] + + -- only the tiles within `half` of this segment's bounding box can match + local lo_x = floor((x1 < x2 and x1 or x2) - half) + local hi_x = ceil((x1 > x2 and x1 or x2) + half) + local lo_y = floor((y1 < y2 and y1 or y2) - half) + local hi_y = ceil((y1 > y2 and y1 or y2) + half) + + if lo_x < left then lo_x = left end + if hi_x > right then hi_x = right end + if lo_y < top then lo_y = top end + if hi_y > bottom then hi_y = bottom end + + for ty = lo_y, hi_y do + local row = rows[ty] + local py = ty + 0.5 + for tx = lo_x, hi_x do + if row == nil or row[tx] ~= true then + if dist(tx + 0.5, py, x1, y1, x2, y2) < half then + if row == nil then + row = {} + rows[ty] = row + end + if mirror or row[tx] == nil then + row[tx] = mirror + end + end + end + end + end + end + end + + -- Emit in scan order so the result never depends on hat iteration order. + local out = {} + for ty = top, bottom do + local row = rows[ty] + if row then + for tx = left, right do + local m = row[tx] + if m ~= nil then + out[#out + 1] = { x = tx, y = ty, mirror = m } + end + end + end + end + return out +end + +return Bands diff --git a/mod/colours.lua b/mod/colours.lua new file mode 100644 index 0000000..f6350cf --- /dev/null +++ b/mod/colours.lua @@ -0,0 +1,19 @@ +-- Band colourways, selected by a startup setting. +-- +-- `body` is the surface itself and is near-black in every option: the bands +-- are meant to read as depth, not as paint. `highlight` is what the shader +-- catches at the edges, and is what actually distinguishes one option from +-- another -- comparing the bodies alone, they are indistinguishable. +-- +-- `mirror` tints the reflected cells, about one in eight, which are a +-- mathematical property of this tiling rather than a decoration. +-- +-- Colour is independent of band style: each works with the animated liquid +-- and with the flat void. +return { + violet = { body = { 30, 19, 48 }, highlight = { 98, 60, 152 }, mirror = { 158, 104, 208 } }, + cold = { body = { 23, 27, 34 }, highlight = { 62, 78, 93 }, mirror = { 82, 124, 166 } }, + oily = { body = { 14, 26, 19 }, highlight = { 42, 85, 58 }, mirror = { 102, 180, 122 } }, + ember = { body = { 29, 16, 11 }, highlight = { 120, 56, 24 }, mirror = { 216, 120, 56 } }, + ink = { body = { 4, 4, 6 }, highlight = { 18, 18, 24 }, mirror = { 104, 104, 126 } }, +} diff --git a/mod/commands.lua b/mod/commands.lua new file mode 100644 index 0000000..e2a5691 --- /dev/null +++ b/mod/commands.lua @@ -0,0 +1,37 @@ +-- /hattorio-info -- what geometry and richness are actually in force. +-- +-- Exists because several of this mod's settings have effects that cannot be +-- observed directly. "Ore richness override = 0" means "derive it from cell +-- size and band width", and there is otherwise no way to see what that +-- derivation produced, or whether an override took effect at all. + +local Surface = require("mod.surface") + +local function report(command) + local player = command.player_index and game.get_player(command.player_index) + local function say(msg) + if player then player.print(msg) else game.print(msg) end + end + + local surface = player and player.surface or game.surfaces[1] + if not Surface.is_tiled(surface) then + say("hattorio: " .. surface.name .. " is not tiled by this mod.") + return + end + + local g = Surface.get(surface) + local mult, source = Surface.richness(g) + + say(string.format("hattorio on %s:", surface.name)) + say(string.format(" cell size %d tiles, band %d tiles", g.size, g.band)) + say(string.format(" substitution depth %d", g.depth)) + say(string.format(" ore richness x%.2f (%s)", mult, source)) + if source == "automatic" then + say(" set the ore richness override above 0 to replace that") + end + say(" geometry is fixed for this surface and unaffected by later " .. + "settings changes") +end + +commands.add_command("hattorio-info", + { "command-help.hattorio-info" }, report) diff --git a/mod/config.lua b/mod/config.lua new file mode 100644 index 0000000..9829fd3 --- /dev/null +++ b/mod/config.lua @@ -0,0 +1,132 @@ +-- Settings numbers in, geometry numbers out. +-- +-- Pure: no Factorio API, so this runs under plain Lua in CI alongside hat/. +-- Everything that reads `settings` or `storage` lives in mod/surface.lua. + +local Ti = require("hat.tiling") + +local Config = {} + +Config.MIN_SIZE, Config.MAX_SIZE = 15, 90 +Config.MIN_BAND, Config.MAX_BAND = 1, 6 +Config.DEFAULT_SIZE, Config.DEFAULT_BAND = 41, 3 + +-- The sizes and widths offered in the settings dropdowns. MIN/MAX above still +-- bound anything that arrives from elsewhere; these are only the choices a +-- player is given, each measured rather than guessed. +-- +-- size cell area largest blueprint reusable in EVERY cell +-- 15 116 t ~5x5 very hard +-- 26 347 t 11x11 hard +-- 41 944 t 18x18 normal +-- 52 1,584 t 23x23 easy +Config.SIZE_CHOICES = { 15, 26, 41, 52 } +Config.BAND_CHOICES = { 1, 2, 3, 4 } + +-- A Factorio map is 2e6 x 2e6, so its half-diagonal is ~1.41e6. Ti:coverage() +-- is the CIRCUMRADIUS of a hexagonal root, and a square region inscribed in +-- that circle is not fully tiled -- its corners fall outside. So ask for +-- comfortably more than the half-diagonal, never the half-width. +Config.REQUIRED_COVER = 1.6e6 + +local function clamp1(v, lo, hi, default) + if type(v) ~= "number" then return default end + if v ~= v then return lo end -- x ~= x is the only NaN test + if v < lo then return lo end + if v > hi then return hi end + return v +end + +function Config.clamp(size, band) + return clamp1(size, Config.MIN_SIZE, Config.MAX_SIZE, Config.DEFAULT_SIZE), + clamp1(band, Config.MIN_BAND, Config.MAX_BAND, Config.DEFAULT_BAND) +end + +--- Everything the mod needs to place cells, derived from two settings. +-- @return { size, band, unit, depth, half } +function Config.geometry(size, band) + size, band = Config.clamp(size, band) + local unit = Ti.unit_for_hat_size(size) + local depth = Ti.depth_covering(unit, Config.REQUIRED_COVER) + if not depth then + error("hattorio: no emitted depth covers the map at cell size " .. size) + end + return { size = size, band = band, unit = unit, depth = depth, half = band / 2 } +end + +-- Measured properties of every combination the dropdowns offer. +-- +-- These are not fitted. Each was measured by rasterising a cell's interior at +-- that size and band width and taking the worst case across all orientations: +-- +-- area buildable tiles in a cell +-- square largest axis-aligned square that fits EVERY cell, so the +-- largest blueprint that stays universally reusable +-- drillable fraction of a cell's ore a 3x3 mining drill can reach +-- +-- Exact beats fitted here: the settings offer sixteen combinations and all +-- sixteen are known. Config.fit_drillable below is only a fallback for values +-- that did not come from a dropdown. +Config.CELL_STATS = { + [15] = { [1] = { area = 113, square = 6, drillable = 0.364 }, + [2] = { area = 94, square = 5, drillable = 0.242 }, + [3] = { area = 63, square = 5, drillable = 0.108 }, + [4] = { area = 46, square = 4, drillable = 0.054 } }, + [26] = { [1] = { area = 388, square = 11, drillable = 0.610 }, + [2] = { area = 346, square = 11, drillable = 0.522 }, + [3] = { area = 294, square = 10, drillable = 0.426 }, + [4] = { area = 256, square = 9, drillable = 0.343 } }, + [41] = { [1] = { area = 1012, square = 19, drillable = 0.742 }, + [2] = { area = 939, square = 18, drillable = 0.682 }, + [3] = { area = 858, square = 17, drillable = 0.613 }, + [4] = { area = 790, square = 16, drillable = 0.557 } }, + [52] = { [1] = { area = 1670, square = 24, drillable = 0.801 }, + [2] = { area = 1581, square = 23, drillable = 0.749 }, + [3] = { area = 1473, square = 22, drillable = 0.695 }, + [4] = { area = 1385, square = 21, drillable = 0.645 } }, +} + +--- Measured properties of a cell, or nil for a combination never measured. +function Config.cell_stats(size, band) + size, band = Config.clamp(size, band) + local row = Config.CELL_STATS[size] + return row and row[band] or nil +end + +--- Fallback estimate, fitted to all sixteen measurements. +-- +-- Worst error 7.5 percentage points, against 14.6 for the earlier fit, which +-- was calibrated on six points and went badly wrong at small cells -- it +-- claimed 20% of ore was reachable at size 15 band 4 where the truth is 5.4%, +-- under-compensating precisely where compensation matters most. +function Config.fit_drillable(size, band) + size, band = Config.clamp(size, band) + local d = 1 - (2.3 * band + 7.9) / size + if d < 0.05 then d = 0.05 end + if d > 1.0 then d = 1.0 end + return d +end + +--- Fraction of a cell's ore a 3x3 mining drill can actually reach. +function Config.drillable_fraction(size, band) + local stats = Config.cell_stats(size, band) + if stats then return stats.drillable end + return Config.fit_drillable(size, band) +end + +--- How much to raise ore richness to offset what bands put out of reach. +function Config.richness_multiplier(size, band) + return 1 / Config.drillable_fraction(size, band) +end + +--- Is this combination so constrained that it is barely playable? +-- +-- A mining drill and an assembling machine are both 3x3, so a cell whose +-- largest universal square is under 5 leaves almost nothing that fits with +-- room to connect it. +function Config.is_severe(size, band) + local stats = Config.cell_stats(size, band) + return stats ~= nil and stats.square < 5 +end + +return Config diff --git a/mod/outline.lua b/mod/outline.lua new file mode 100644 index 0000000..18b754a --- /dev/null +++ b/mod/outline.lua @@ -0,0 +1,111 @@ +-- Optional per-player overlay: a line down the centre of each band. +-- +-- Per player because it is pure presentation -- one player turning it on +-- cannot change the terrain or what anyone else sees. The render objects are +-- created with `players = {index}` so they are genuinely private. +-- +-- Render objects are game state, so they are tracked in `storage` rather than +-- a module local: a module local would be lost across save/load and the +-- objects would leak, accumulating every time the save was reloaded. + +local Ti = require("hat.tiling") +local G = require("hat.geometry") +local Surface = require("mod.surface") + +local Outline = {} + +-- How far around the player to draw, and how far they may move before it is +-- redrawn. Redrawing on every move would be wasteful; never redrawing would +-- leave the overlay behind. +local RADIUS = 64 +local MOVE_BEFORE_REDRAW = 24 + +local tilings = {} + +local function tiling_for(surface) + local g = Surface.get(surface) + if not g then return nil, nil end + local t = tilings[surface.index] + if not t then + t = Ti.new({ unit = g.unit, depth = g.depth }) + tilings[surface.index] = t + end + return t, g +end + +local function clear(player_index) + local state = storage.outlines and storage.outlines[player_index] + if not state then return end + for i = 1, #state.objects do + local o = state.objects[i] + if o.valid then o.destroy() end + end + storage.outlines[player_index] = nil +end + +Outline.clear = clear + +local function draw(player) + local surface = player.surface + local t, g = tiling_for(surface) + if not t then return end + + local pos = player.position + local hats = t:hats_in_box({ + left = pos.x - RADIUS, top = pos.y - RADIUS, + right = pos.x + RADIUS, bottom = pos.y + RADIUS, + }) + + local objects = {} + for i = 1, #hats do + local edges = G.edges(hats[i].xf, g.unit) + for j = 1, #edges do + local e = edges[j] + objects[#objects + 1] = rendering.draw_line({ + color = { 0.55, 0.62, 0.72, 0.5 }, + width = 1.5, + from = { e[1], e[2] }, + to = { e[3], e[4] }, + surface = surface, + players = { player.index }, + draw_on_ground = true, + }) + end + end + + storage.outlines = storage.outlines or {} + storage.outlines[player.index] = { objects = objects, x = pos.x, y = pos.y } +end + +--- Bring one player's overlay up to date. Cheap when nothing has changed. +function Outline.refresh(player) + if not (player and player.valid and player.connected) then return end + + local on = settings.get_player_settings(player)["hattorio-show-outline"] + if not (on and on.value) then + clear(player.index) + return + end + if not Surface.is_tiled(player.surface) then + clear(player.index) + return + end + + local state = storage.outlines and storage.outlines[player.index] + if state then + local dx = player.position.x - state.x + local dy = player.position.y - state.y + if dx * dx + dy * dy < MOVE_BEFORE_REDRAW * MOVE_BEFORE_REDRAW then + return + end + end + + clear(player.index) + draw(player) +end + +function Outline.reset() + tilings = {} +end + +return Outline diff --git a/mod/planets.lua b/mod/planets.lua new file mode 100644 index 0000000..b4d06e4 --- /dev/null +++ b/mod/planets.lua @@ -0,0 +1,15 @@ +-- Which surfaces get tiled. +-- +-- A set rather than a list: mod/surface.lua tests membership by name, and +-- anything absent here is left as vanilla terrain -- including space +-- platforms and any planet a mod adds that we have no settings for. +-- +-- Band colour is NOT per planet. It is a player setting, shared across every +-- surface, so that the mod looks like one thing rather than five. +return { + nauvis = true, + vulcanus = true, + fulgora = true, + gleba = true, + aquilo = true, +} diff --git a/mod/surface.lua b/mod/surface.lua new file mode 100644 index 0000000..a4f472d --- /dev/null +++ b/mod/surface.lua @@ -0,0 +1,103 @@ +-- Per-surface geometry, derived once at creation and stored. +-- +-- Startup settings can change between sessions. Storing the numbers the +-- terrain was actually built with means a later change affects only NEW +-- surfaces; existing ones keep their geometry and never seam. This is the one +-- idea worth taking from Hextorio's continuous_geometry flag. + +local Config = require("mod.config") +local planets = require("mod.planets") + +local Surface = {} + +--- Name of the planet palette for this surface, or nil if it is not tiled. +local function planet_of(surface) + -- Space platforms are never tiled, and neither is any surface we have no + -- palette for -- including modded planets, which fail open to vanilla. + if surface.platform then return nil end + if planets[surface.name] then return surface.name end + return nil +end + +function Surface.is_tiled(surface) + return planet_of(surface) ~= nil +end + +-- The size and band dropdowns hold strings, as Factorio requires for +-- allowed_values. Convert here, at the single point where settings are read. +local function read_number_setting(name, default) + local s = settings.startup[name] + if s == nil then return default end + return tonumber(s.value) or default +end + +--- Derive and store this surface's geometry. Idempotent. +function Surface.init(surface) + local planet = planet_of(surface) + if not planet then return nil end + + storage.surfaces = storage.surfaces or {} + local existing = storage.surfaces[surface.index] + if existing then return existing end + + local g = Config.geometry( + read_number_setting("hattorio-hat-size-" .. planet, Config.DEFAULT_SIZE), + read_number_setting("hattorio-band-width-" .. planet, Config.DEFAULT_BAND)) + g.planet = planet + g.tile = "hattorio-band-" .. planet + g.mirror_tile = "hattorio-band-" .. planet .. "-mirror" + + storage.surfaces[surface.index] = g + + local mult, source = Surface.richness(g) + log(string.format( + "hattorio: %s initialised -- cell size %d, band %d, depth %d, " .. + "ore richness x%.2f (%s)", + planet, g.size, g.band, g.depth, mult, source)) + + -- Some combinations the dropdowns allow leave almost nothing buildable. + -- At cell size 15 with a band of 4 a cell holds 46 tiles and its largest + -- universal square is 4x4 -- a mining drill and an assembling machine are + -- both 3x3, so there is no room to connect anything. Say so once rather + -- than let it be discovered an hour in. + if Config.is_severe(g.size, g.band) then + local stats = Config.cell_stats(g.size, g.band) + log(string.format( + "hattorio: WARNING %s uses cell size %d with band %d -- only %d " .. + "buildable tiles per cell and a largest square of %dx%d. This is " .. + "close to unplayable.", planet, g.size, g.band, stats.area, + stats.square, stats.square)) + if game then + game.print(string.format( + "[Hattorio] Cell size %d with band %d leaves about %d buildable " .. + "tiles per cell, and the largest blueprint that fits every cell is " .. + "%dx%d. A mining drill is 3x3. Consider a larger cell size.", + g.size, g.band, stats.area, stats.square, stats.square)) + end + end + + return g +end + +--- The ore richness multiplier in force for a surface, and where it came from. +-- +-- Shared by terrain generation and the /hattorio-info command so the number a +-- player is shown is the number actually applied, not a second derivation of +-- it that could drift. +-- +-- @return multiplier, source where source is "override" or "automatic" +function Surface.richness(g) + local setting = settings.global["hattorio-richness-override"] + local override = setting and setting.value or 0 + if override > 0 then + return override, "override" + end + return Config.richness_multiplier(g.size, g.band), "automatic" +end + +function Surface.get(surface) + storage.surfaces = storage.surfaces or {} + return storage.surfaces[surface.index] or Surface.init(surface) +end + +return Surface diff --git a/mod/terrain.lua b/mod/terrain.lua new file mode 100644 index 0000000..6b80aa3 --- /dev/null +++ b/mod/terrain.lua @@ -0,0 +1,153 @@ +-- Terrain generation: paint bands into each chunk as it is generated. +-- +-- Painting once at generation means zero per-tick cost. Nothing about the +-- bands is persisted beyond the tiles themselves and the per-surface geometry: +-- the tiling is derived from that geometry and rebuilt on load. + +local Ti = require("hat.tiling") +local Surface = require("mod.surface") +local Bands = require("mod.bands") +local Outline = require("mod.outline") + +-- Derived state, keyed by surface index. Deliberately NOT in storage: it is +-- reconstructible from the stored geometry, and a tiling holds closures. +local tilings = {} + +local function tiling_for(surface) + local g = Surface.get(surface) + if not g then return nil, nil end + local t = tilings[surface.index] + if not t then + t = Ti.new({ unit = g.unit, depth = g.depth }) + tilings[surface.index] = t + end + return t, g +end + +local function area_of(event_area) + return { + left = event_area.left_top.x, top = event_area.left_top.y, + right = event_area.right_bottom.x, bottom = event_area.right_bottom.y, + } +end + +--- Paint the band tiles of one chunk. +local function paint_chunk(surface, area) + local t, g = tiling_for(surface) + if not t then return end + + local band = Bands.for_area(t, g, area) + if #band == 0 then return end + + local tiles = {} + for i = 1, #band do + local p = band[i] + -- Never pave water: a band across a lake would fill it, and vanilla + -- shore transitions would be lost. + -- + -- Factorio's LuaObject methods take dot notation and no self, so this is + -- surface.get_tile(x, y) -- caching the function and passing `surface` + -- would silently put the surface where x belongs. + if not surface.get_tile(p.x, p.y).collides_with("water_tile") then + tiles[#tiles + 1] = { + name = p.mirror and g.mirror_tile or g.tile, + position = { p.x, p.y }, + } + end + end + if #tiles == 0 then return end + + -- remove_colliding_entities = false: ore, trees and rocks already placed on + -- a band stay put. Ore there is simply out of reach of a 3x3 drill, which + -- the richness multiplier compensates for. + surface.set_tiles(tiles, true, false, false, false) +end + +--- Raise ore richness to offset what the bands put out of reach. +-- +-- Applied per chunk as it generates, so it never touches ore a player has +-- already mined. Infinite resources are skipped: their yield is a percentage, +-- and scaling that would be wrong. +local function compensate_resources(surface, area) + local g = Surface.get(surface) + if not g then return end + local mult = Surface.richness(g) + if mult == 1.0 then return end + + local found = surface.find_entities_filtered({ + area = { { area.left, area.top }, { area.right, area.bottom } }, + type = "resource", + }) + for i = 1, #found do + local e = found[i] + if e.valid and not e.prototype.infinite_resource then + e.amount = math.floor(e.amount * mult) + end + end +end + +script.on_event(defines.events.on_chunk_generated, function(event) + if not Surface.is_tiled(event.surface) then return end + local area = area_of(event.area) + paint_chunk(event.surface, area) + compensate_resources(event.surface, area) +end) + +script.on_event(defines.events.on_surface_created, function(event) + local surface = game.surfaces[event.surface_index] + if surface then Surface.init(surface) end +end) + +script.on_nth_tick(30, function() + for _, player in pairs(game.connected_players) do + Outline.refresh(player) + end +end) + +script.on_event(defines.events.on_runtime_mod_setting_changed, function(event) + if event.setting == "hattorio-show-outline" and event.player_index then + Outline.clear(event.player_index) + end +end) + +script.on_event(defines.events.on_player_changed_surface, function(event) + Outline.clear(event.player_index) +end) + +-- Derived state only; rebuilt lazily on next use. +script.on_load(function() + tilings = {} + Outline.reset() +end) + +--- Warn when the mod is added to a world that already has terrain. +-- +-- Bands are painted as chunks generate, so chunks that already existed keep +-- their vanilla terrain forever. The result is a hard, permanent line between +-- the old area and everything explored afterwards. Nothing can fix that +-- retroactively without regenerating the map, so the least we can do is say +-- so rather than let it be discovered later. +local function warn_if_added_mid_save(data) + local change = data and data.mod_changes and data.mod_changes["hattorio"] + if not (change and change.old_version == nil) then return end + + for _, surface in pairs(game.surfaces) do + if Surface.is_tiled(surface) then + -- is_chunk_generated on the origin is a cheap proxy for "this world has + -- been played": a brand new surface has nothing generated yet. + if surface.is_chunk_generated({ 0, 0 }) then + game.print("[Hattorio] Added to a world that already has terrain. " .. + "Existing chunks keep their vanilla ground and will never grow " .. + "cells, so there will be a permanent boundary where you have " .. + "already explored. For the intended experience, start a new world.") + return + end + end + end +end + +script.on_configuration_changed(function(data) + tilings = {} + Outline.reset() + warn_if_added_mid_save(data) +end) diff --git a/prototypes/collision.lua b/prototypes/collision.lua new file mode 100644 index 0000000..5a08e95 --- /dev/null +++ b/prototypes/collision.lua @@ -0,0 +1,10 @@ +-- A layer the band tiles carry and buildings are given in data-final-fixes. +-- +-- Two prototypes collide only if they SHARE a layer, so the character, +-- vehicles and rails -- which never receive this one -- cross bands freely, +-- while anything that does receive it cannot be placed on one. The game then +-- refuses the placement natively: red preview, blueprints skip the tiles, +-- construction bots never attempt them. +data:extend({ + { type = "collision-layer", name = "hattorio_band" }, +}) diff --git a/prototypes/patch.lua b/prototypes/patch.lua new file mode 100644 index 0000000..898f18c --- /dev/null +++ b/prototypes/patch.lua @@ -0,0 +1,63 @@ +-- data-final-fixes, so this sees every entity every other mod has added. +-- +-- Two prototypes collide only if they SHARE a collision layer. Giving +-- buildable structures the band layer makes the game refuse to place them on +-- a band natively: red preview, blueprints skip the tiles, construction bots +-- never attempt them, and no ghosts are left behind. Script-side rejection +-- would place the entity and then remove it, which is visibly worse and needs +-- four event handlers plus ghost cleanup to approximate this. +-- +-- CAREFUL: a prototype that does not declare collision_mask inherits a +-- per-type default. Assigning a fresh mask would WIPE that default and strip +-- the entity of its normal collisions. collision_mask_util.get_mask returns +-- the explicit mask or the type default, which is the only safe way to read +-- it in the data stage. + +local cmu = require("__core__/lualib/collision-mask-util") + +local LAYER = "hattorio_band" + +-- Player-built structures. Deliberately ABSENT, each for a reason: +-- * rails, signals, ramps -- the spec exempts rails so trains still work +-- * vehicles -- you must be able to drive across a band +-- * character -- you must be able to walk across a band +-- * tree, cliff, resource, enemy types -- placed by map generation, and +-- must be free to sit on a band; blocking them would fight terrain gen +local BLOCKED_TYPES = { + "accumulator", "agricultural-tower", "ammo-turret", "arithmetic-combinator", + "artillery-turret", "assembling-machine", "asteroid-collector", "beacon", + "boiler", "burner-generator", "cargo-landing-pad", "constant-combinator", + "container", "decider-combinator", "display-panel", + "electric-energy-interface", "electric-pole", "electric-turret", + "fluid-turret", "furnace", "fusion-generator", "fusion-reactor", "gate", + "generator", "heat-interface", "heat-pipe", "heating-tower", + "infinity-container", "infinity-pipe", "inserter", "lab", "lamp", + "land-mine", "lightning-attractor", "linked-belt", "linked-container", + "loader", "loader-1x1", "logistic-container", "mining-drill", + "offshore-pump", "pipe", "pipe-to-ground", "power-switch", + "programmable-speaker", "pump", "radar", "reactor", "roboport", + "rocket-silo", "selector-combinator", "simple-entity-with-owner", + "solar-panel", "splitter", "storage-tank", "thruster", "train-stop", + "transport-belt", "underground-belt", "valve", "wall", +} + +local patched, skipped = 0, 0 + +for _, type_name in ipairs(BLOCKED_TYPES) do + for name, proto in pairs(data.raw[type_name] or {}) do + -- get_mask errors on a type it has no default for, which a mod can + -- introduce. Failing open (buildable on bands) beats failing to load. + local ok, mask = pcall(cmu.get_mask, proto) + if ok and mask then + mask.layers = mask.layers or {} + mask.layers[LAYER] = true + proto.collision_mask = mask + patched = patched + 1 + else + skipped = skipped + 1 + log("hattorio: could not read collision mask of " .. type_name .. "/" .. name) + end + end +end + +log("hattorio: added " .. LAYER .. " to " .. patched .. " entity prototypes, skipped " .. skipped) diff --git a/prototypes/settings.lua b/prototypes/settings.lua new file mode 100644 index 0000000..6f0eb46 --- /dev/null +++ b/prototypes/settings.lua @@ -0,0 +1,84 @@ +local planets = require("mod.planets") + +-- STARTUP, not runtime. The geometry decides terrain, so changing it under an +-- existing save would leave every generated chunk built to the old geometry. +-- mod/surface.lua stores the values per surface at creation, so a later change +-- affects only NEW surfaces and nothing ever seams. +-- +-- The bounds are repeated here rather than required from mod/config.lua: the +-- settings stage cannot load a module that pulls in hat/tiling.lua's generated +-- data. A test in spec/config_spec.lua keeps the two in step. +local settings_list = {} +local order = 0 + +for planet, _ in pairs(planets) do + order = order + 1 + -- Dropdowns rather than sliders: the difficulty of a given size is not + -- obvious from the number, and the interesting range is four values, not + -- seventy-six. Values are strings, as Factorio requires for a dropdown, and + -- mod/surface.lua converts them. + settings_list[#settings_list + 1] = { + type = "string-setting", + name = "hattorio-hat-size-" .. planet, + setting_type = "startup", + default_value = "41", + allowed_values = { "15", "26", "41", "52" }, + order = string.format("a[size]-%02d[%s]", order, planet), + } + settings_list[#settings_list + 1] = { + type = "string-setting", + name = "hattorio-band-width-" .. planet, + setting_type = "startup", + default_value = "3", + allowed_values = { "1", "2", "3", "4" }, + order = string.format("b[band]-%02d[%s]", order, planet), + } +end + +-- Band appearance. Startup, because it selects which tile prototypes exist. +settings_list[#settings_list + 1] = { + type = "string-setting", + name = "hattorio-band-style", + setting_type = "startup", + default_value = "liquid", + allowed_values = { "liquid", "void" }, + order = "c[style]-a[style]", +} + +-- Band colour, independent of style: any colourway works with any shader. +-- Deep violet by default -- it is the only option that reads as deliberate +-- rather than as dark ground, since every body colour here is near-black and +-- it is the highlight that distinguishes them. +settings_list[#settings_list + 1] = { + type = "string-setting", + name = "hattorio-band-colour", + setting_type = "startup", + default_value = "violet", + allowed_values = { "violet", "cold", "oily", "ember", "ink" }, + order = "c[style]-b[colour]", +} + +-- Map-scoped, because ore is shared between everyone on it. 0 means "use the +-- automatic compensation derived from cell size and band width"; any other +-- value replaces it outright. +settings_list[#settings_list + 1] = { + type = "double-setting", + name = "hattorio-richness-override", + setting_type = "runtime-global", + default_value = 0.0, + minimum_value = 0.0, + maximum_value = 10.0, + order = "d[richness]", +} + +-- Per player, because it is pure presentation: one player having the overlay +-- on cannot affect what anyone else sees or what the terrain is. +settings_list[#settings_list + 1] = { + type = "bool-setting", + name = "hattorio-show-outline", + setting_type = "runtime-per-user", + default_value = false, + order = "e[outline]", +} + +data:extend(settings_list) diff --git a/prototypes/tiles.lua b/prototypes/tiles.lua new file mode 100644 index 0000000..c762488 --- /dev/null +++ b/prototypes/tiles.lua @@ -0,0 +1,64 @@ +local planets = require("mod.planets") +local colours = require("mod.colours") + +-- Band appearance is two independent startup settings: STYLE picks the shader +-- laid over the surface, COLOUR picks what that shader is tinted. +-- +-- Both styles clone `deepwater`. Its transitions are authored for liquid +-- meeting land, which is the edge a rift wants, and it carries the only +-- animated shader in the base game. +-- +-- A glowing lava-shader style was tried and removed: it read as a hazard +-- rather than as a void, and fought the calm look the rest of the mod has. +local STYLE = settings.startup["hattorio-band-style"].value +local COLOUR = settings.startup["hattorio-band-colour"].value + +-- Only the effect differs between styles; the colours come from COLOUR. +local STYLE_EFFECT = { + liquid = "water", + void = nil, +} + +local c = colours[COLOUR] or colours.violet +local effect = STYLE_EFFECT[STYLE] + +local function band_tile(name, highlight, map_color) + local src = data.raw.tile["deepwater"] + if not src then + error("hattorio: base tile 'deepwater' does not exist") + end + local t = table.deepcopy(src) + t.name = name + t.map_color = map_color + t.order = "z[hattorio]" + t.localised_name = { "tile-name.hattorio-band" } + + -- Only the band layer. The tile blocks nothing else, so the character and + -- vehicles pass over it -- a rift you can step across. + t.collision_mask = { layers = { hattorio_band = true } } + + t.effect = effect + t.effect_color = c.body + t.effect_color_secondary = highlight + + -- Not water: must not feed an offshore pump, must not merge with lakes, + -- must not be minable or appear in blueprints, and must never autoplace. + t.fluid = nil + t.transition_merges_with_tile = nil + t.autoplace = nil + t.minable = nil + t.mined_sound = nil + t.can_be_part_of_blueprint = false + t.walking_speed_modifier = nil + t.factoriopedia_alternative = nil + + return t +end + +local tiles = {} +for planet, _ in pairs(planets) do + tiles[#tiles + 1] = band_tile("hattorio-band-" .. planet, c.highlight, c.body) + tiles[#tiles + 1] = band_tile("hattorio-band-" .. planet .. "-mirror", c.mirror, c.mirror) +end + +data:extend(tiles) diff --git a/settings.lua b/settings.lua new file mode 100644 index 0000000..8e1c2f5 --- /dev/null +++ b/settings.lua @@ -0,0 +1 @@ +require("prototypes.settings") diff --git a/spec/bands_spec.lua b/spec/bands_spec.lua new file mode 100644 index 0000000..82c6e2d --- /dev/null +++ b/spec/bands_spec.lua @@ -0,0 +1,72 @@ +local Ti = require("hat.tiling") +local Config = require("mod.config") +local Bands = require("mod.bands") + +local function setup(size, band) + local g = Config.geometry(size, band) + return Ti.new({ unit = g.unit, depth = g.depth }), g +end + +describe("band rasterisation", function() + it("returns band tiles inside the requested area only", function() + local t, g = setup(26, 2) + local area = { left = -40, top = -40, right = 40, bottom = 40 } + local tiles = Bands.for_area(t, g, area) + assert.is_true(#tiles > 0) + for _, p in ipairs(tiles) do + assert.is_true(p.x >= area.left and p.x < area.right, "x " .. p.x) + assert.is_true(p.y >= area.top and p.y < area.bottom, "y " .. p.y) + end + end) + + it("covers a plausible fraction of the area", function() + local t, g = setup(26, 2) + local n = #Bands.for_area(t, g, { left=-64, top=-64, right=64, bottom=64 }) + local frac = n / (128 * 128) + assert.is_true(frac > 0.10 and frac < 0.40, "fraction " .. frac) + end) + + it("marks some tiles as mirror and most not", function() + local t, g = setup(26, 2) + local tiles = Bands.for_area(t, g, { left=-96, top=-96, right=96, bottom=96 }) + local m = 0 + for _, p in ipairs(tiles) do if p.mirror then m = m + 1 end end + assert.is_true(m > 0, "no mirror tiles") + assert.is_true(m < #tiles / 2, "too many mirror tiles") + end) + + it("is deterministic", function() + local t, g = setup(26, 2) + local a = { left = 0, top = 0, right = 32, bottom = 32 } + local one, two = Bands.for_area(t, g, a), Bands.for_area(t, g, a) + assert.are.equal(#one, #two) + for i = 1, #one do + assert.are.equal(one[i].x, two[i].x) + assert.are.equal(one[i].y, two[i].y) + assert.are.equal(one[i].mirror, two[i].mirror) + end + end) + + it("agrees across a chunk boundary", function() + -- a band must not break where two chunks meet: every chunk derives its + -- tiles from the tiling, never from a neighbour's tiles + local t, g = setup(26, 2) + local l = Bands.for_area(t, g, { left=-32, top=0, right=0, bottom=32 }) + local r = Bands.for_area(t, g, { left=0, top=0, right=32, bottom=32 }) + local b = Bands.for_area(t, g, { left=-32, top=0, right=32, bottom=32 }) + assert.are.equal(#l + #r, #b) + end) + + it("makes a wider band cover more tiles", function() + local t1, g1 = setup(26, 1) + local t3, g3 = setup(26, 3) + local a = { left = -48, top = -48, right = 48, bottom = 48 } + assert.is_true(#Bands.for_area(t3, g3, a) > #Bands.for_area(t1, g1, a)) + end) + + it("leaves cells connected: no area is entirely band", function() + local t, g = setup(26, 2) + local a = { left = -64, top = -64, right = 64, bottom = 64 } + assert.is_true(#Bands.for_area(t, g, a) < 128 * 128) + end) +end) diff --git a/spec/config_spec.lua b/spec/config_spec.lua new file mode 100644 index 0000000..63c5acd --- /dev/null +++ b/spec/config_spec.lua @@ -0,0 +1,268 @@ +local Config = require("mod.config") +local Ti = require("hat.tiling") + +describe("config", function() + it("clamps out-of-range settings", function() + local s, b = Config.clamp(3, 99) + assert.are.equal(Config.MIN_SIZE, s) + assert.are.equal(Config.MAX_BAND, b) + s, b = Config.clamp(1000, -5) + assert.are.equal(Config.MAX_SIZE, s) + assert.are.equal(Config.MIN_BAND, b) + end) + + it("leaves valid settings alone", function() + local s, b = Config.clamp(26, 2) + assert.are.equal(26, s) + assert.are.equal(2, b) + end) + + it("falls back to defaults for nil or NaN", function() + local s, b = Config.clamp(nil, nil) + assert.are.equal(Config.DEFAULT_SIZE, s) + assert.are.equal(Config.DEFAULT_BAND, b) + local nan = 0 / 0 + s, b = Config.clamp(nan, nan) + assert.are.equal(Config.MIN_SIZE, s) + assert.are.equal(Config.MIN_BAND, b) + end) + + it("derives a unit that yields the requested hat size", function() + for _, size in ipairs({ 15, 26, 41, 90 }) do + local g = Config.geometry(size, 2) + assert.is_true(math.abs(Ti.hat_size_for_unit(g.unit) - size) < 1e-9, + "size " .. size) + end + end) + + it("derives a depth that covers the whole map", function() + for _, size in ipairs({ 15, 26, 41, 90 }) do + local g = Config.geometry(size, 2) + local t = Ti.new({ unit = g.unit, depth = g.depth }) + assert.is_true(t:coverage() >= Config.REQUIRED_COVER, "size " .. size) + end + end) + + it("uses a deeper root for a smaller cell", function() + assert.is_true(Config.geometry(15, 2).depth > Config.geometry(90, 2).depth) + end) + + it("carries half the band width for distance tests", function() + assert.are.equal(1, Config.geometry(26, 2).half) + assert.are.equal(1.5, Config.geometry(26, 3).half) + end) + + it("compensates richness more when less ore is drillable", function() + local small = Config.richness_multiplier(15, 2) + local large = Config.richness_multiplier(52, 1) + assert.is_true(small > large, small .. " should exceed " .. large) + assert.is_true(large >= 1.0) + end) + + it("compensates more for a wider band", function() + assert.is_true(Config.richness_multiplier(26, 4) > Config.richness_multiplier(26, 1)) + end) + + it("defaults to 41 and 3", function() + assert.are.equal(41, Config.DEFAULT_SIZE) + assert.are.equal(3, Config.DEFAULT_BAND) + end) + +end) + +describe("cell statistics", function() + + it("has measurements for every combination the dropdowns offer", function() + -- a missing entry silently falls back to the fitted estimate, which is + -- much worse at small cells; this catches a choice added without + -- measuring it + for _, size in ipairs(Config.SIZE_CHOICES) do + for _, band in ipairs(Config.BAND_CHOICES) do + local st = Config.cell_stats(size, band) + assert.is_not_nil(st, "no measurements for size " .. size .. " band " .. band) + assert.is_true(st.area > 0) + assert.is_true(st.square > 0) + assert.is_true(st.drillable > 0 and st.drillable <= 1) + end + end + end) + + it("uses the measurement, not the fit, for offered combinations", function() + for _, size in ipairs(Config.SIZE_CHOICES) do + for _, band in ipairs(Config.BAND_CHOICES) do + assert.are.equal(Config.cell_stats(size, band).drillable, + Config.drillable_fraction(size, band)) + end + end + end) + + it("gets smaller cells and wider bands right, monotonically", function() + for _, size in ipairs(Config.SIZE_CHOICES) do + local prev + for _, band in ipairs(Config.BAND_CHOICES) do + local st = Config.cell_stats(size, band) + if prev then + assert.is_true(st.area < prev.area, "wider band must shrink the cell") + assert.is_true(st.drillable < prev.drillable, + "wider band must reduce drillable ore") + end + prev = st + end + end + for _, band in ipairs(Config.BAND_CHOICES) do + local prev + for _, size in ipairs(Config.SIZE_CHOICES) do + local st = Config.cell_stats(size, band) + if prev then + assert.is_true(st.area > prev.area, "larger cells must hold more") + end + prev = st + end + end + end) + + it("compensates richness back to roughly vanilla yield", function() + for _, size in ipairs(Config.SIZE_CHOICES) do + for _, band in ipairs(Config.BAND_CHOICES) do + local st = Config.cell_stats(size, band) + local effective = st.drillable * Config.richness_multiplier(size, band) + assert.is_true(math.abs(effective - 1) < 1e-9, + string.format("size %d band %d: effective yield %.3f", size, band, effective)) + end + end + end) + + it("flags only the combinations that are barely playable", function() + -- 15/4 gives a 4x4 largest square; a drill and an assembler are both 3x3 + assert.is_true(Config.is_severe(15, 4)) + assert.is_false(Config.is_severe(15, 2)) + assert.is_false(Config.is_severe(26, 4)) + assert.is_false(Config.is_severe(41, 3)) + end) + + it("keeps the fallback fit close to the measurements", function() + -- used only for sizes that never came from a dropdown + for _, size in ipairs(Config.SIZE_CHOICES) do + for _, band in ipairs(Config.BAND_CHOICES) do + local got = Config.fit_drillable(size, band) + local want = Config.cell_stats(size, band).drillable + assert.is_true(math.abs(got - want) < 0.08, + string.format("size %d band %d: fit %.3f vs measured %.3f", + size, band, got, want)) + end + end + end) +end) + +describe("band colours", function() + local colours = require("mod.colours") + + it("declares the same colourways as prototypes/settings.lua", function() + local f = assert(io.open("prototypes/settings.lua", "r")) + local src = f:read("*a"); f:close() + local allowed = src:match('name = "hattorio%-band%-colour".-allowed_values = {(.-)}') + assert.is_not_nil(allowed, "no band colour setting found") + for name, _ in pairs(colours) do + assert.is_not_nil(allowed:find('"' .. name .. '"', 1, true), + "colourway " .. name .. " is not offered in settings") + end + for quoted in allowed:gmatch('"([%a]+)"') do + assert.is_not_nil(colours[quoted], "settings offer unknown colourway " .. quoted) + end + end) + + it("defaults to deep violet", function() + local f = assert(io.open("prototypes/settings.lua", "r")) + local src = f:read("*a"); f:close() + local block = src:match('name = "hattorio%-band%-colour".-order') + assert.is_not_nil(block:find('default_value = "violet"', 1, true)) + end) + + it("gives every colourway a body, a highlight and a mirror tint", function() + for name, c in pairs(colours) do + for _, key in ipairs({ "body", "highlight", "mirror" }) do + assert.is_table(c[key], name .. " is missing " .. key) + assert.are.equal(3, #c[key], name .. "." .. key .. " is not RGB") + for i = 1, 3 do + assert.is_true(c[key][i] >= 0 and c[key][i] <= 255, + name .. "." .. key .. " component out of range") + end + end + end + end) + + it("has a locale label for every colourway", function() + local f = assert(io.open("locale/en/hattorio.cfg", "r")) + local src = f:read("*a"); f:close() + for name, _ in pairs(colours) do + assert.is_not_nil(src:find("hattorio%-band%-colour%-" .. name .. "="), + "no label for colourway " .. name) + end + end) + + it("keeps every body near-black, so bands read as depth", function() + for name, c in pairs(colours) do + local brightness = (c.body[1] + c.body[2] + c.body[3]) / 3 + assert.is_true(brightness < 40, name .. " body is too light: " .. brightness) + -- the highlight must actually be lighter, or the option is invisible + local hl = (c.highlight[1] + c.highlight[2] + c.highlight[3]) / 3 + assert.is_true(hl >= brightness, name .. " highlight is not lighter than its body") + end + end) +end) + +describe("band styles", function() + it("offers only styles the tiles module implements", function() + local f = assert(io.open("prototypes/settings.lua", "r")) + local src = f:read("*a"); f:close() + local allowed = src:match('name = "hattorio%-band%-style".-allowed_values = {(.-)}') + assert.is_not_nil(allowed, "no band style setting found") + + local g = assert(io.open("prototypes/tiles.lua", "r")) + local tiles = g:read("*a"); g:close() + local effects = tiles:match("local STYLE_EFFECT = {(.-)}") + assert.is_not_nil(effects, "no STYLE_EFFECT table found") + + for quoted in allowed:gmatch('"([%a]+)"') do + assert.is_not_nil(effects:find(quoted .. " ="), + "settings offer style '" .. quoted .. "' with no effect defined") + end + end) + + it("no longer offers the glowing rift", function() + -- removed after playtesting: it read as a hazard rather than a void + local f = assert(io.open("prototypes/settings.lua", "r")) + local src = f:read("*a"); f:close() + assert.is_nil(src:find("rift", 1, true)) + end) +end) + +describe("highlight contrast", function() + local colours = require("mod.colours") + + local function brightness(c) return (c[1] + c[2] + c[3]) / 3 end + + it("gives every colourway a clearly lighter highlight", function() + -- without real contrast between body and highlight the shader has nothing + -- to catch and the option looks like every other one + for name, c in pairs(colours) do + local b, h = brightness(c.body), brightness(c.highlight) + assert.is_true(h > b + 8, + string.format("%s: highlight %.0f is too close to body %.0f", name, h, b)) + end + end) + + it("keeps the colourways distinguishable from each other", function() + local seen = {} + for name, c in pairs(colours) do + for other, oc in pairs(seen) do + local d = math.abs(c.highlight[1] - oc.highlight[1]) + + math.abs(c.highlight[2] - oc.highlight[2]) + + math.abs(c.highlight[3] - oc.highlight[3]) + assert.is_true(d > 30, + name .. " and " .. other .. " have near-identical highlights") + end + seen[name] = c + end + end) +end) diff --git a/spec/locale_spec.lua b/spec/locale_spec.lua new file mode 100644 index 0000000..46a33f2 --- /dev/null +++ b/spec/locale_spec.lua @@ -0,0 +1,87 @@ +-- Guards on the locale files. +-- +-- These failures are all silent in-game: a mangled value shows as truncated +-- text, a mangled key shows as the raw key like +-- "hattorio-band-colour-violet". None of them crash, so none of them announce +-- themselves. + +local function locale_files() + -- busted runs from the repo root, and there is only the one language today + return { "locale/en/hattorio.cfg" } +end + +local function read(path) + local f = assert(io.open(path, "r"), "cannot open " .. path) + local s = f:read("*a") + f:close() + return s +end + +describe("locale", function() + it("never puts a semicolon in a value", function() + -- Factorio's .cfg format documents ';' as a comment marker. Whether it is + -- honoured mid-line is undocumented, and vanilla never risks it: zero of + -- 7,301 vanilla English values contain one, while '#' appears four times. + -- Cheaper to avoid than to depend on. + for _, path in ipairs(locale_files()) do + local n = 0 + for line in read(path):gmatch("[^\n]+") do + n = n + 1 + if not line:match("^%s*[;#]") and not line:match("^%s*%[") then + assert.is_nil(line:find(";", 1, true), + path .. ":" .. n .. " has a semicolon in a value: " .. line) + end + end + end + end) + + it("has no whitespace around the equals sign in keys", function() + -- "title =Value" defines the key "title " with a trailing space, which + -- then never matches. Factorio gives no warning. + for _, path in ipairs(locale_files()) do + local n = 0 + for line in read(path):gmatch("[^\n]+") do + n = n + 1 + if line:find("=", 1, true) and not line:match("^%s*[;#]") + and not line:match("^%s*%[") then + assert.is_nil(line:match("^[^=]*%s="), + path .. ":" .. n .. " has whitespace before '=': " .. line) + end + end + end + end) + + it("declares no key twice", function() + for _, path in ipairs(locale_files()) do + local seen, section = {}, "" + for line in read(path):gmatch("[^\n]+") do + local header = line:match("^%s*%[(.-)%]%s*$") + if header then + section = header + elseif not line:match("^%s*[;#]") then + local key = line:match("^([^=]+)=") + if key then + local full = section .. "." .. key + assert.is_nil(seen[full], "duplicate key " .. full .. " in " .. path) + seen[full] = true + end + end + end + end + end) + + it("puts every key inside a section", function() + for _, path in ipairs(locale_files()) do + local section, n = nil, 0 + for line in read(path):gmatch("[^\n]+") do + n = n + 1 + if line:match("^%s*%[(.-)%]%s*$") then + section = line + elseif line:find("=", 1, true) and not line:match("^%s*[;#]") then + assert.is_not_nil(section, + path .. ":" .. n .. " defines a key before any [section]") + end + end + end + end) +end) diff --git a/thumbnail.png b/thumbnail.png new file mode 100644 index 0000000..c5b0ca8 Binary files /dev/null and b/thumbnail.png differ diff --git a/tools/bench.lua b/tools/bench.lua index 1b9445c..67dc94c 100644 --- a/tools/bench.lua +++ b/tools/bench.lua @@ -20,6 +20,26 @@ for i = 1, CHUNKS do total = total + #t:hats_in_box({ left=cx, top=cy, right=cx+32, bottom=cy+32 }) end local ms = 1000 * (os.clock() - start) / CHUNKS -print(string.format("%d chunks: %.3f ms/chunk, %.2f hats/chunk", CHUNKS, ms, total/CHUNKS)) +print(string.format("descent: %d chunks, %.3f ms/chunk, %.2f hats/chunk", + CHUNKS, ms, total/CHUNKS)) assert(ms < 50, "descent too slow for chunk generation: " .. ms .. " ms") + +-- Full per-chunk cost, which is what terrain generation actually pays. +-- Rasterising each edge into its own bounding box rather than testing every +-- tile against every edge took this from 39.7 ms/chunk to about 1.3. +local Config = require("mod.config") +local Bands = require("mod.bands") +local g = Config.geometry(26, 2) +local bt = Ti.new({ unit = g.unit, depth = g.depth }) + +local bstart = os.clock() +local tiles = 0 +for i = 1, CHUNKS do + local cx = (i % 20) * 32 + local cy = math.floor(i / 20) * 32 + tiles = tiles + #Bands.for_area(bt, g, { left=cx, top=cy, right=cx+32, bottom=cy+32 }) +end +local bms = 1000 * (os.clock() - bstart) / CHUNKS +print(string.format("bands: %.3f ms/chunk, %.0f band tiles/chunk", bms, tiles/CHUNKS)) +assert(bms < 10, "band rasterisation too slow for chunk generation: " .. bms .. " ms") print("OK") diff --git a/tools/check_version.py b/tools/check_version.py new file mode 100644 index 0000000..24e2359 --- /dev/null +++ b/tools/check_version.py @@ -0,0 +1,67 @@ +"""Verify the version is consistent everywhere it appears. + +info.json is the single source of truth. Everything else must agree with it: +the changelog must document that version, and a release tag must name it. +Drift here is how a mod portal page ends up describing a build nobody has. + + python3 tools/check_version.py # info.json vs changelog + python3 tools/check_version.py v0.2.0 # also check a release tag +""" +from __future__ import annotations + +import json +import pathlib +import re +import sys + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SEMVER = re.compile(r"^\d+\.\d+\.\d+$") + + +def changelog_versions(text: str) -> list[str]: + return re.findall(r"^Version:\s*(\S+)\s*$", text, re.MULTILINE) + + +def main(argv: list[str]) -> int: + info = json.loads((ROOT / "info.json").read_text()) + version = info["version"] + problems = [] + + if not SEMVER.match(version): + problems.append(f"info.json version {version!r} is not X.Y.Z") + + changelog = (ROOT / "changelog.txt").read_text() + versions = changelog_versions(changelog) + if version not in versions: + problems.append( + f"changelog.txt has no 'Version: {version}' entry " + f"(found: {', '.join(versions) or 'none'})") + elif versions[0] != version: + problems.append( + f"changelog.txt leads with {versions[0]}, but info.json says {version}; " + "the newest release must come first") + + # Factorio refuses to load a mod whose changelog separator is malformed, + # and the message it gives is not obvious. + first = changelog.splitlines()[0] if changelog else "" + if len(first) != 99 or set(first) != {"-"}: + problems.append("changelog.txt must start with exactly 99 dashes") + + if len(argv) > 1: + tag = argv[1] + expected = f"v{version}" + if tag != expected: + problems.append(f"tag {tag} does not match info.json ({expected})") + + if problems: + for p in problems: + print(f"version check: {p}", file=sys.stderr) + return 1 + + print(f"version check: {info['name']} {version} consistent" + + (f", tag {argv[1]}" if len(argv) > 1 else "")) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/tools/package.py b/tools/package.py new file mode 100644 index 0000000..516b9d2 --- /dev/null +++ b/tools/package.py @@ -0,0 +1,66 @@ +"""Build a mod zip Factorio will accept. + +The archive must contain a single top-level directory named _ +with info.json at its root. Development files -- tests, the offline pipeline, +docs, wiki source, CI config -- never ship. + +Uses zipfile rather than the zip(1) binary, which is not installed everywhere. +""" +from __future__ import annotations + +import json +import pathlib +import zipfile + +ROOT = pathlib.Path(__file__).resolve().parents[1] + +# Everything the game needs, and nothing else. +SHIP = [ + "info.json", "changelog.txt", "thumbnail.png", + "data.lua", "data-final-fixes.lua", "settings.lua", "control.lua", + "LICENSE", "THIRD_PARTY.md", + "prototypes", "mod", "hat", "data", "locale", +] + +EXCLUDE_SUFFIX = {".pyc"} +EXCLUDE_DIR = {"__pycache__", "tests"} + + +def files_under(path: pathlib.Path): + if path.is_file(): + yield path + return + for p in sorted(path.rglob("*")): + if p.is_dir(): + continue + if p.suffix in EXCLUDE_SUFFIX: + continue + if any(part in EXCLUDE_DIR for part in p.parts): + continue + yield p + + +def main(): + info = json.loads((ROOT / "info.json").read_text()) + stem = f"{info['name']}_{info['version']}" + out_dir = ROOT / "build" + out_dir.mkdir(exist_ok=True) + out = out_dir / f"{stem}.zip" + if out.exists(): + out.unlink() + + count = 0 + with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z: + for entry in SHIP: + src = ROOT / entry + if not src.exists(): + raise SystemExit(f"missing from the working tree: {entry}") + for f in files_under(src): + z.write(f, f"{stem}/{f.relative_to(ROOT)}") + count += 1 + + print(f"built {out.relative_to(ROOT)} ({count} files, {out.stat().st_size // 1024} KB)") + + +if __name__ == "__main__": + main() diff --git a/tools/publish_mod.sh b/tools/publish_mod.sh new file mode 100755 index 0000000..79537c7 --- /dev/null +++ b/tools/publish_mod.sh @@ -0,0 +1,47 @@ +#!/bin/sh +# Upload a built mod zip to the Factorio mod portal. +# +# FACTORIO_API_KEY=... sh tools/publish_mod.sh build/hattorio_0.1.0.zip +# +# The key comes from https://factorio.com/profile and needs the +# "ModPortal: Upload Mods" permission. +# +# Two steps, per https://wiki.factorio.com/Mod_upload_API : +# 1. POST the mod name to init_upload, which returns a one-shot upload_url +# 2. POST the zip to that URL +# +# A portal release cannot be withdrawn once accepted, so every check that can +# run before this point should already have run. +set -e + +ZIP="$1" +[ -n "$ZIP" ] || { echo "usage: publish_mod.sh " >&2; exit 2; } +[ -f "$ZIP" ] || { echo "no such file: $ZIP" >&2; exit 2; } +[ -n "$FACTORIO_API_KEY" ] || { echo "FACTORIO_API_KEY is not set" >&2; exit 2; } + +MOD=$(python3 -c "import json;print(json.load(open('info.json'))['name'])") +VERSION=$(python3 -c "import json;print(json.load(open('info.json'))['version'])") +echo "publishing $MOD $VERSION from $ZIP" + +INIT=$(curl -sS -X POST \ + -H "Authorization: Bearer $FACTORIO_API_KEY" \ + -F "mod=$MOD" \ + https://mods.factorio.com/api/v2/mods/releases/init_upload) + +UPLOAD_URL=$(printf '%s' "$INIT" | python3 -c " +import json, sys +d = json.load(sys.stdin) +if 'upload_url' not in d: + sys.exit('init_upload failed: %s -- %s' % (d.get('error', '?'), d.get('message', d))) +print(d['upload_url']) +") + +RESULT=$(curl -sS -X POST -F "file=@$ZIP" "$UPLOAD_URL") + +printf '%s' "$RESULT" | python3 -c " +import json, sys +d = json.load(sys.stdin) +if not d.get('success'): + sys.exit('upload failed: %s -- %s' % (d.get('error', '?'), d.get('message', d))) +print('uploaded successfully') +" diff --git a/tools/render_bands.lua b/tools/render_bands.lua index bee95cd..1795a83 100644 --- a/tools/render_bands.lua +++ b/tools/render_bands.lua @@ -15,6 +15,8 @@ local size = tonumber(arg[1]) or 20 local band = tonumber(arg[2]) or 2 local tiles = tonumber(arg[3]) or 200 local out = arg[4] or "bands.svg" +local band_c = arg[5] or "#2b2f36" -- band colour +local land_c = arg[6] or "#7a8b5a" -- surrounding land local unit = Ti.unit_for_hat_size(size) local half = band / 2 @@ -38,8 +40,8 @@ local f = assert(io.open(out, "w")) f:write(string.format('\n', tiles * 4, tiles * 4, lo, lo, tiles, tiles)) -f:write(string.format('\n', - lo, lo, tiles, tiles)) +f:write(string.format('\n', + lo, lo, tiles, tiles, land_c)) local band_tiles = 0 for ty = lo, hi do @@ -59,8 +61,8 @@ for ty = lo, hi do end if on_band then band_tiles = band_tiles + 1 - f:write(string.format('\n', - tx, ty)) + f:write(string.format('\n', + tx, ty, band_c)) end end end diff --git a/tools/tests/test_emit.py b/tools/tests/test_emit.py index 9de2c79..2e710cf 100644 --- a/tools/tests/test_emit.py +++ b/tools/tests/test_emit.py @@ -1,3 +1,4 @@ +import json import math import pathlib import re @@ -55,3 +56,36 @@ def test_golden_fixture_is_written(): p = ROOT / "spec" / "fixtures" / "golden_depth3.lua" assert p.exists() assert len(p.read_text().splitlines()) > 1000 + + +def test_mod_package_has_the_right_shape(): + """The zip must hold one _/ directory with info.json at its + root, the tiling core and its data, and no development files.""" + import zipfile + subprocess.run([sys.executable, "tools/package.py"], cwd=ROOT, check=True) + info = json.loads((ROOT / "info.json").read_text()) + stem = f"{info['name']}_{info['version']}" + z = zipfile.ZipFile(ROOT / "build" / f"{stem}.zip") + names = z.namelist() + + assert f"{stem}/info.json" in names + assert f"{stem}/hat/tiling.lua" in names + assert f"{stem}/data/hat_rules.lua" in names + assert f"{stem}/control.lua" in names + for forbidden in ("/spec/", "/docs/", "/tools/", "/wiki/", "__pycache__"): + assert not any(forbidden in n for n in names), forbidden + + +def test_version_is_consistent(): + """info.json, changelog.txt and any release tag must agree. A mod portal + page describing a build nobody has is the failure this prevents.""" + r = subprocess.run([sys.executable, "tools/check_version.py"], + cwd=ROOT, capture_output=True, text=True) + assert r.returncode == 0, r.stderr + + +def test_version_check_rejects_a_mismatched_tag(): + r = subprocess.run([sys.executable, "tools/check_version.py", "v99.99.99"], + cwd=ROOT, capture_output=True, text=True) + assert r.returncode != 0, "a wrong tag must fail the check" + assert "does not match" in r.stderr diff --git a/wiki/Home.md b/wiki/Home.md index fdfda8b..494b696 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -8,9 +8,10 @@ alike. Your blueprints will not save you. ![The tiling](https://raw.githubusercontent.com/vetkat/hattorio/main/docs/preview/tiling.png) -> **Not playable yet.** The tiling engine is finished and tested; the mod -> around it is still being built. Nothing to install from the mod portal today. -> Watch the repo if you want to know when that changes. +> **Playable, but not released.** Everything works and has been tested in a +> real game. It is not on the mod portal yet, and multiplayer has not been +> verified — play single player for now. Watch +> [the repo](https://github.com/vetkat/hattorio) for the release. ## How it plays diff --git a/wiki/Settings.md b/wiki/Settings.md index d7baecd..df8e30d 100644 --- a/wiki/Settings.md +++ b/wiki/Settings.md @@ -2,31 +2,37 @@ Two settings per planet, both fixed when the world is created. -| Setting | Default | Range | +| Setting | Default | Choices | |---|---|---| -| Hat size (centre to vertex, in tiles) | **26** | 15–90 | -| Band width (tiles) | **2** | 1–6 | +| Cell size (centre to vertex, in tiles) | **41** (normal) | 15, 26, 41, 52 | +| Band width (tiles) | **3** (wide) | 1, 2, 3, 4 | +| Band appearance | Dark liquid | liquid, void | +| Band colour | Deep violet | violet, cold, oily, ember, void black | + +Cell size and band width are dropdowns, labelled with how hard each makes the +game. They are set per planet. ## Hat size is the dial It decides how much room a cell gives you, and — more importantly — how large a blueprint can still be reused everywhere. -| Size | Cell area | Unbuildable | Largest blueprint that fits every cell | +| Size | Difficulty | Cell area | Largest blueprint that fits every cell | |---|---|---|---| -| 15 | 116 tiles | 37% | about 5×5 | -| **26** (default) | **347 tiles** | **22%** | **11×11** | -| 41 | 944 tiles | 15% | 18×18 | -| 52 | 1,584 tiles | 12% | 23×23 | +| 15 | very hard | 116 tiles | about 5×5 | +| 26 | hard | 347 tiles | 11×11 | +| **41** (default) | **normal** | **944 tiles** | **18×18** | +| 52 | easy | 1,584 tiles | 23×23 | For scale: a smelter block is around 20×20 and a mall around 30×30. **Every size on this table breaks those**, so the choice is not whether blueprints survive — it is how much room you get to improvise in. - **15** is brutal. One machine and some belt. Novel, probably exhausting. -- **26** is the default because 11×11 is roughly one assembler cluster: enough - that you are building, not fighting, while everything larger is hand-fitted. -- **41** is comfortable. Real sub-builds per cell, spaghetti mostly between them. +- **26** is hard. 11×11 is roughly one assembler cluster: enough that you are + building, not fighting, while everything larger is hand-fitted. +- **41** is the default. Real sub-builds per cell, spaghetti mostly between + them, and every imported blueprint still breaks. - **52** matches Hextorio's hexagons by area, if you want that feel. ![size 15](https://raw.githubusercontent.com/vetkat/hattorio/main/docs/preview/bands-15-2.png) @@ -34,6 +40,23 @@ survive — it is how much room you get to improvise in. ![size 41](https://raw.githubusercontent.com/vetkat/hattorio/main/docs/preview/bands-41-2.png) ![size 52](https://raw.githubusercontent.com/vetkat/hattorio/main/docs/preview/bands-52-2.png) +## Band appearance + +Two independent settings: what the bands are made of, and what colour they +are. Any combination works. + +**Style** picks the surface. *Dark liquid* is the default: animated, it moves +and oozes. *Void* is flat and still, a hole rather than a liquid. + +**Colour** tints it. Every option is near-black on purpose, so the bands read +as depth rather than paint — what separates them is the highlight the surface +catches at its edges. *Deep violet* is the default and the most deliberate +looking; *near-black cold*, *oily green-black* and *ember black* are quieter; +*pure void black* is a flat hole with no sheen at all. + +Reflected cells — about one in eight, and mathematically unavoidable in this +tiling — are tinted more strongly so you can pick them out. + ## Band width How wide the unbuildable strip is. Keep it below underground-belt reach or @@ -55,6 +78,23 @@ ones keep the geometry their terrain was built with, so nothing ever seams. ## Ore Bands cut through ore patches, and a mining drill needs a clear 3×3, so some -ore ends up unreachable — from about 40% reachable at size 26 up to 68% at -size 41. Resource richness is raised to compensate, scaled to your size -setting. +ore ends up out of reach. Richness is raised to compensate, using measured +values rather than a guess — the fraction reachable ranges from 80% at the +largest cells down to 5% at the smallest with the widest band. + +`/hattorio-info` reports the cell size, band width and the richness multiplier +actually in force on your surface, and whether it was derived automatically or +taken from the override setting. + +## Combinations to avoid + +Cell size and band width are independent dropdowns, so a few combinations are +possible but not sensible: + +| | cell | largest blueprint | reachable ore | | +|---|---|---|---|---| +| 15 / 3 | 63 tiles | 5×5 | 11% | ore very scarce | +| 15 / 4 | 46 tiles | 4×4 | 5% | close to unplayable | + +A mining drill and an assembling machine are both 3×3, so a 4×4 cell leaves no +room to connect anything. The mod warns you on load if you pick one of these. diff --git a/wiki/The-mathematics.md b/wiki/The-mathematics.md index 2f00794..c4b73cb 100644 --- a/wiki/The-mathematics.md +++ b/wiki/The-mathematics.md @@ -36,9 +36,12 @@ integers with `φ² = φ+1` and `√3² = 3` closing multiplication. That works beautifully offline. In Lua it collapses. -Lua 5.2 has no integer type; every number is a double, exact only to 2⁵³. The -substitution's per-level rules are exact rationals *converging on irrational -limits*, so their numerators explode: +Lua 5.2 has no integer subtype — its single number type is a double in any +standard build — so integers are exact only up to 2⁵³. (Lua 5.3 added a +genuine 64-bit integer, which is exactly why this project's tests pin 5.2: +running them on 5.4 would mask the all-doubles behaviour the core depends on). +The substitution's per-level rules are exact rationals *converging on +irrational limits*, so their numerators explode: ``` level 6: numerators 1.4e14 fine