diff --git a/README.md b/README.md index 653831e..f5a4a80 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,13 @@ A local visual map and building editor for **Project Zomboid Build 42**, written ## Streamed world editor (development branch) - **Work on one section:** save a world as a section workspace, browse its manifest and open only the section being edited. Switching saves committed edits first; stale revisions retain the local copy and offer an explicit reload/discard action. -- **See neighbouring geometry:** section outlines distinguish loaded and unloaded terrain. Crossing buildings and infrastructure remain visible and reserved; shared geometry is read-only in an isolated section. +- **See neighbouring geometry:** section outlines distinguish loaded and unloaded terrain. Crossing buildings, decorations, fences and infrastructure remain visible and reserved; shared geometry is read-only in an isolated section. +- **Share decorations across seams:** move a section decoration group into the world's **Shared decorations** layer. It is stored once in world tiles, and every section draws the part inside it. - **Control editing layers:** hide or lock terrain, roads, paths, buildings, objects, models, spawns and authoring zones. Annotation order is adjustable; physical sprites keep their depth order. -- **Draw and review:** finish geometry drafts before applying them. Author polygon game/vehicle zones, reversible zombie-density regions and reviewed whole-room mappings. The merged core supports native fence lines and geographic rotation; unsupported adapter operations stay downloadable drafts. The current building adapter accepts footprints from 6×6 tiles. +- **Draw and review:** finish geometry drafts before applying them. Author polygon game/vehicle zones, reversible zombie-density regions and reviewed whole-room mappings. The merged core supports native fence lines and geographic rotation; unsupported adapter operations stay downloadable drafts. Buildings accept footprints from 2×2 tiles; under 6 tiles on a side a building is one room (a shed, kiosk or booth). - **Export a saved revision:** section and world export jobs read immutable section data in sequence. Compatibility whole-world JSON loading retains its separate size limit. +- **Name and install the mod:** every export accepts a mod id, map folder and title, can place `media/maps` under `common/`, and can install straight into `Zomboid/mods` without replacing folders it did not create. +- **Build without the browser:** `simplepzmap build scene.json` imports a real place into a world, honours building tags (`pz:kind`, `pz:style`, `roof:colour`, `pz:wall`, `pz:trim`, attached buildings), runs the seam check and exports; `validate --game` checks every tile name against the installed game, `tiles` builds `.pack`/`.tiles` tile mods from PNG frames and `probe` loads the export with the game's own classes. [Command line guide](docs/CLI.md). [Step-by-step section, layer and polygon guide](docs/STREAMED-WORLD-EDITOR.md) · [Zone semantics and API](docs/ZONE-AUTHORING.md) · [Core/editor integration contract](docs/SPZM-SCENE-CONTRACT.md) diff --git a/SimplePZMapmaker.slnx b/SimplePZMapmaker.slnx index 6f96b27..1106b8d 100644 --- a/SimplePZMapmaker.slnx +++ b/SimplePZMapmaker.slnx @@ -1,5 +1,6 @@ + diff --git a/docs/CLI.md b/docs/CLI.md new file mode 100644 index 0000000..7515f02 --- /dev/null +++ b/docs/CLI.md @@ -0,0 +1,84 @@ +# Headless command line + +`simplepzmap` builds, checks and exports maps without the browser editor. It uses the same importer, validators and native exporter as the application, so a scene built here opens unchanged in the editor. + +Run it from the repository with `dotnet run --project src/SimplePZMapmaker.Cli -- …`, or build it once with `dotnet build src/SimplePZMapmaker.Cli -c Release` and call `simplepzmap.exe`. + +| Command | What it does | +|---|---| +| `build ` | Imports GeoJSON or Overpass JSON into a geographic world, promotes seam-crossing buildings and fences to the world, runs the seam check and writes `.pzworld.json`; `--export mod.zip` also writes the Build 42 mod. | +| `validate ` | Validates a `.pzmap.json` or `.pzworld.json`. With `--source` it runs the seam check against the original data. | +| `export --out mod.zip` | Writes the Build 42 mod of a map or world. | +| `tiles --out tiles.zip` | Builds a tile mod (`.pack` + `.tiles`) from PNG frames. | +| `probe --game ` | Loads the exported lots with the installed game's own classes (headers, chunks, rooms, every tile sprite; stairs with `--expect-stairs`). Needs the repository's `scripts/` folder and Java from the game. | + +Common options: + +- `--game ` points to the Build 42 installation. `build`, `validate` and `export` then check every tile name the exporter would write against the game's tile definitions and validate resources; `tiles` reads properties for `copyFrom`. +- `--strict` makes `build` fail when the seam check finds a lost or duplicated source feature. +- `--report report.json` saves the seam check. +- Mod options: `--mod-id`, `--map-folder` (the server `Map=` value, defaults to the mod id), `--mod-name` (title in the mod list and map selector), `--common-maps` (puts `media/maps` under `common/` instead of `42/`), `--install` (extracts into `Zomboid/mods`, or `--mods-dir`). An install replaces only a folder that SimplePZMapmaker created earlier (it carries a `.simplepzmapmaker` marker). + +The same mod options are available on every HTTP export as query parameters: `modId`, `mapFolder`, `modName`, `commonMaps=true`, `install=true` (for example `POST /api/world/export?modId=PilnaVillage&install=true`). `install=true` is accepted only on POST, which the editor host limits to JSON requests from its own origin; workspace exports and job downloads accept POST with an empty JSON body for that. A GET can only rename the returned archive. + +## Scene file + +```json +{ + "format": "SimplePZScene", "version": 1, + "name": "PilnaVillage", + "source": "pilna.geojson", + "reference": { + "bounds": { "south": 55.5363, "north": 55.5781, "west": 45.8833, "east": 45.9422 }, + "metresPerTile": 1, "originCellX": 100, "originCellY": 100, "rotationDegrees": -11.22 + }, + "sectionSize": 1024, "seed": 42, "skipEmptySections": true, + "export": { "modId": "PilnaVillage", "name": "Pilna village", "commonMaps": false } +} +``` + +- `source` is one GeoJSON FeatureCollection or Overpass JSON file; `sources` lists several GeoJSON files whose features are merged (a feature `id` seen twice is kept once). Paths are relative to the scene file. +- `reference` is the rotated geographic reference of the [transfer contract](TRANSFER-CONTRACT.md#1-coordinates). The area is cut into `sectionSize` sections; a world holds up to 128 sections and 8,192 native cells. +- Section identities follow contract section 2: the first 32 hex digits of SHA-256(`name|X,Y`), so a rebuild keeps them. +- `skipEmptySections` leaves out sections without buildings, fences or paths. + +Every section imports the whole source; buildings and barrier lines crossing a section edge are stored once at world level. The import honours the building tags of contract sections 5 and 6 (`pz:kind`, `pz:style`, `roof:colour`, `pz:wall`, `pz:trim`, `pz:attached`, `pz:door-to`). + +## Seam check + +The check reads the source again and assigns every building outline and barrier line to exactly one outcome: + +Real output for the Pilna OpenStreetMap extract in `examples/Pilna` (1 m per tile, 1024-tile sections, rotated −11.22°): + +``` +world PilnaVillage: 26 sections, 1640 section buildings, 61 shared buildings, 0 shared fences +seam check building: placed-section 1640, placed-world 61, evidence 88, lost 0, duplicated 0 + evidence 42: skipped: Water or road + evidence 27: skipped: Outline too large + evidence 18: skipped: Building overlap + evidence 1: skipped: crossing outline rejected at world level (water, overlap or access) +seam check: passed +tiles: all 2471 references exist in the game +``` + +The build took about five minutes; `probe --expect-stairs` on the resulting mod loaded 416 cells, 425,984 chunks and 9,565 rooms (1,429 upper-floor rooms, 249 stair tiles) with the installed game's classes. That extract contains no barrier lines, so fences are covered by the automated tests instead. + +`evidence` features were skipped for a recorded reason (water or road, overlap, outside the world, a crossing outline rejected at world level). `lost` and `duplicated` features fail the check. + +## Tileset file + +```json +{ + "format": "SimplePZTileset", "version": 1, + "modId": "PilnaTiles", "name": "Pilna tiles", + "tileset": "pilna_fence_01", "fileNumber": 300, + "tiles": [ + { "png": "fence_w.png", "copyFrom": "fencing_01_40", "properties": { "CustomName": "Pilna fence" } }, + { "png": "fence_n.png", "copyFrom": "fencing_01_41" } + ] +} +``` + +Each PNG is one 8-bit RGBA or RGB frame, normally 128×256 like the game's 2x tiles. Frames are trimmed to their opaque pixels and packed into one atlas page. Tiles are named `_` in file order, eight per row. `copyFrom` copies the properties of an installed tile (wall, fence, door flags and materials); `properties` adds or overrides keys. `fileNumber` is the `tiledef` number (100–8190) and must not clash with other active mods. The archive contains `42/mod.info` (`pack=` and `tiledef=`), `42/media/texturepacks/.pack` and `42/media/.tiles`. + +Enable the tile mod in **Game resources** to use its tiles in the editor; maps that use them then list it in `require=` automatically. diff --git a/docs/SPZM-SCENE-CONTRACT.md b/docs/SPZM-SCENE-CONTRACT.md index 392888f..50afcb8 100644 --- a/docs/SPZM-SCENE-CONTRACT.md +++ b/docs/SPZM-SCENE-CONTRACT.md @@ -22,7 +22,7 @@ The implemented fence edge layer uses `Fences`, `FenceStyles` and `GateStyles` o Fence authoring requires map format **12** and world format **7**; nonzero geographic rotation also requires world format **7**. A browser adapter must commit the actual fence layer and retain its resource bindings, source paths and gate styles. It must never approximate fences as ordinary decorations. Unsupported geometry or unavailable adapter capabilities remain explicit drafts. -Small-building drawing records the exact requested footprint and must not silently enlarge a 2×2 shed. Apply is enabled only for the active adapter's supported size and shared-wall behavior. The current general building adapter supports 6–64 tiles per side; the [canonical small/attached building extension](TRANSFER-CONTRACT.md#6-small-and-attached-buildings) must not be advertised as implemented merely because its schema is documented. +Small-building drawing records the exact requested footprint and must not silently enlarge a 2×2 shed. Apply is enabled only for the active adapter's supported size and shared-wall behavior. The general building adapter supports 2–64 tiles per side; footprints under 6 tiles on a side are one room. Attached buildings from the [small/attached building extension](TRANSFER-CONTRACT.md#6-small-and-attached-buildings) are implemented in the importer (`pz:attached`, map format 13); the editor keeps and validates them but has no manual attach control. ## Source appearance tags diff --git a/docs/STATUS.md b/docs/STATUS.md index 04ffa14..1e1ffb0 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,10 +1,27 @@ +# Transfer items 2, 4–7 and 9 — 2026-10-17 + +- **Building tags (item 4):** `pz:kind`, `pz:style`, `roof:colour` (named or `#rrggbb`, matched to roof sets by colours measured from the installed sprites), `pz:wall` and `pz:trim` are applied to section and shared imports and reach the native lot; unknown `pz:*` keys and unusable values are reported, never fatal. +- **Attached buildings (item 2):** `pz:attached`/`pz:door-to` place a building against another with no free line; the larger one owns the single shared wall, the smaller one keeps its own entrance, and a door joins them. Map format 13. +- **Mod export (item 6):** every export takes a mod id, map folder and title, can move `media/maps` under `common/`, and installs into `Zomboid/mods` without touching folders it did not create. +- **Command line (items 7 and 9):** `simplepzmap build/validate/export/tiles/probe`; see [CLI](CLI.md). `--game` checks every exported tile name against the installed definitions. +- **Tile mods (item 5):** `tiles` writes a PZPK v1 atlas and tdef v1 definitions from PNG frames, copying properties from installed tiles. +- **Seam check (contract §3):** every source building and fence is placed in one section, once at world level, or kept as evidence with a reason. +- Real Pilna OSM through `build --strict --game`: 26 sections, 1,640 section and 61 shared buildings, 0 lost or duplicated, 2,471 tile names found; installed game classes loaded 416 cells, 425,984 chunks and 9,565 rooms. Suites pass **404 C# tests and 262 JavaScript tests**. + +# Remaining streamed-world features — 2026-10-17 + +- **Buildings under 6×6:** footprints from 2×2 tiles generate one accessible room; stairs fit only along a side of at least 6 tiles. Imported outlines are no longer inflated to 6×6, and tiny sheds/kiosks keep one floor with a report note. Geometry drafts, the building workshop and new one-room presets accept the smaller sizes. +- **Imported fences across seams:** the OSM world importer promotes crossing barrier lines once into the world fence layer, merges per-section gates and keeps authored lines local. Preserve edits keeps authored world fences. +- **Shared decorations:** world format 8 stores decoration groups once in world tiles. They are validated against every section, clipped into each section, protected in isolated editing, kept on their prior ground by preserve-edits reimport, stored in section workspaces and exported identically by streamed and complete-world exports. +- Suites pass **392 C# tests and 261 JavaScript tests**. A live browser session against the installed game catalog placed a 3×3 kiosk, promoted a real two-tile bed across a seam, moved, removed and exported it, and edited it through the world inspector. + # Streamed world editor verification — 2026-10-17 - Integrated the companion rotation, native fence layer, barrier import and seam ownership changes from `develop`; retained polygon zones, density and reviewed room metadata. The combined suites pass **377 C# tests and 257 JavaScript tests**. - Section workspaces pin immutable revisions, read/save independent sections, retain old revisions and export without materializing a complete world. Explicit compatibility limits remain: 48 MiB per section, 16 MiB shared metadata, 128 MiB monolithic world JSON and 256 MiB compressed native output. - The self-contained browser test uses **13 synthetic 1024×1024 sections**, with a house and fence crossing a seam plus a shared road. Switching loads only the requested section; committed edits persist. Polygon density, resource-backed fence drawing with a wicket, reload restoration, historical section review and locked undo were exercised through the UI. - Independent native decoding accepts the selected section (**16 cells, 16,384 chunks, 16 rooms**) and combined world (**208 cells, 212,992 chunks, 20 room fragments**). Streamed authoring source hashes match the pinned index. Installed game classes accept the section and report **8 upper rooms and 6 stair tiles**. No interactive game session was started. -- Private populated Pilna scenes were not used for acceptance. The illustrated [guide](STREAMED-WORLD-EDITOR.md) records remaining source fence promotion, tiny-building, shared-decoration and isolated-lighting boundaries. Public packaging excludes private art and local installation paths. +- Private populated Pilna scenes were not used for acceptance. The illustrated [guide](STREAMED-WORLD-EDITOR.md) records the isolated-lighting boundary. Public packaging excludes private art and local installation paths. # Real-place transfer development verification — 2026-10-16 diff --git a/docs/STREAMED-WORKSPACE-STORAGE.md b/docs/STREAMED-WORKSPACE-STORAGE.md index d1dfdf9..1cbf531 100644 --- a/docs/STREAMED-WORKSPACE-STORAGE.md +++ b/docs/STREAMED-WORKSPACE-STORAGE.md @@ -23,6 +23,8 @@ The native writer composes one authored/projected section at a time. Shared supp World format 7 stores shared `Fences`, `FenceStyles`, and `GateStyles` in the metadata blob. A section source retains only its own fences. Projections translate complete shared lines before the native composer clips them, preserving gate position and post phase across seams. Conflicting world/section style definitions and duplicate local/shared fence IDs are rejected. Fence pieces resolve through the active resource catalog; existing section provider bindings are checked before they can be replaced. The current fence DTO has no independent world resource-binding field, so never infer an original provider for an unbound imported style. +World format 8 adds shared `Decorations` to the metadata blob. Each group keeps absolute world tiles, its parts and its own `RequiredMods`/`AssetSources`. A section source never stores them; projection clips each group to the section and adds its providers to the projected section. A section save is refused when local content covers or floods a shared decoration, or repeats its ID. + ## Explicit budgets - One section blob: 48 MiB; shared metadata: 16 MiB; manifest: 1 MiB. diff --git a/docs/STREAMED-WORLD-EDITOR.md b/docs/STREAMED-WORLD-EDITOR.md index 2df4cca..52bbb7a 100644 --- a/docs/STREAMED-WORLD-EDITOR.md +++ b/docs/STREAMED-WORLD-EDITOR.md @@ -51,7 +51,7 @@ The historical section must have the same origin and dimensions. It is validated Crossing buildings and infrastructure use the shared world geometry. Their projected footprints remain visible in an isolated section and participate in placement checks. Required nearby support samples are read for shared infrastructure; unrelated terrain sections remain unloaded. Missing required samples produce an error rather than invented ground. -Shared geometry is **read-only** in an isolated section. To change a shared building or path, save the active section and choose **Load complete world**. That compatibility operation loads the whole world and retains the complete-world size budget; it is not a streaming shared-object editor. +Shared geometry is **read-only** in an isolated section. To change a shared building, decoration or path, save the active section and choose **Load complete world**. That compatibility operation loads the whole world and retains the complete-world size budget; it is not a streaming shared-object editor. For native output, choose **Export saved section** or **Export saved world** inside **Section workspaces**. Dirty edits in the active selected workspace save first. The queued job pins a saved revision, checks resources and processes section rasters in sequence. Editing after queueing does not alter that job's input. Progress, cancellation and download use the existing [export queue](EXPORT-JOBS.md). @@ -70,6 +70,17 @@ These are application storage/authoring budgets. They do not establish a 2,048-t The core geographic reference supports `RotationDegrees` from **−45° to +45°**, with zero preserving existing projection. Positive angles turn source content clockwise in the east/south tile plane. Rotation uses the centre of the geographic bounds and one shared origin for all sections; original WGS84 source coordinates stay unchanged. The [transfer contract](TRANSFER-CONTRACT.md#1-coordinates) provides formulas and the −11.22° Pilna example. This is a saved/import reference capability, not a free-rotation control for an already authored section. +### Shared decorations + +A decoration group (a bench, bed, counter or any multi-tile game object) can cross a section seam when it lives in the world layer: + +1. Open the world, select the section that holds the decoration and expand **Shared decorations** in the world inspector. +2. Choose the group in **Selected section decoration**. Its absolute world tile appears in **World tile X/Y**; change it to straddle the seam if needed. +3. Select **Move to shared decorations**. The group leaves the section and is stored once in `.pzworld.json` (world format 8). +4. To reposition it, choose it in **Shared decorations**, edit the coordinates and select **Apply decoration coordinates**. **Remove shared decoration** deletes it. Each action is one world history step. + +Every part must land on dry ground of an existing section, clear of buildings, spawns, models and other decorations. An isolated section shows the shared parts in light blue and refuses local edits that would cover or flood them. Mod tiles keep their provider; a section that draws a mod tile requires that mod in its export. + ## Use map layers Expand **Map layers** in the main inspector. The controls cover terrain, roads, paths/fences, buildings, objects, static models, spawns, game zones, zombie density, source boundaries and section seams. @@ -92,7 +103,7 @@ Expand **Geometry tools**. Select **Polygon zone**, **Path**, **Fence line** or The current path adapter creates a two-tile sidewalk. Its width and other path properties can then be changed through the existing path editor. A generic polygon applies as a `TownZone`; use the zone panel below when a particular type, density or room mapping is required. -The current building adapter creates a ground-level outbuilding with an exact footprint of **6–64 tiles per side**. Add floors or edit its design afterward. A smaller draft keeps its requested dimensions and cannot be applied through this adapter. Tiny 2×2 sheds and shared-wall behavior await the companion core implementation. +The building adapter creates a ground-level outbuilding with the exact footprint of **2–64 tiles per side**; a 2×2 shed is applied as drawn. Under 6 tiles on a side the building is one room; it can take more floors or a basement only when its long side has at least 6 tiles, because stairs need a straight run of five tiles plus a landing. The **Type** list also offers one-room presets (shed 3×3, kiosk 3×3, booth 2×2). Attached buildings are imported from `pz:attached` tags; there is no manual attach control yet. The merged core supports a native fence edge layer with separate fence/gate styles, posts and crossing world lines; see the [fence contract](TRANSFER-CONTRACT.md#4-fences-and-gates). Geometry tools enable **Apply draft** only for supported adapter operations. An unavailable operation keeps its exact draft. Fences are never approximated as ordinary decorations. Existing/imported fence data and dependencies remain part of map/world authoring; a basic drawing adapter does not imply that every gate/style setting has a visual editor. @@ -139,7 +150,9 @@ Select an existing region in the list to edit it. **Preview removal** follows th Module tests cover revision conflicts, stale/cancelled section loads, explicit reload failure, unloaded-neighbour presentation, geometry capability gates, polygon coverage, density ownership and reviewed room changes. The self-contained browser check uses 13 synthetic 1024×1024 sections, a shared house/fence and a crossing road. It verifies save/switch/reload, polygon density, a drawn fence with a wicket, revision restoration and locked undo. Both the section and combined world archives pass independent native decoding; installed game classes load the section archive. See [verification status](STATUS.md). -Current boundaries: native lighting requires **Load complete world** so that its neighbour halo is present; isolated editing refuses an incomplete lighting calculation. Small building footprints still follow the core's 6×6 minimum. Imported fences crossing sections still need authoritative world-layer promotion in the source importer; their retained source data is not proof of that promotion. Arbitrary shared decoration groups do not yet have a world-level authoring DTO. These limitations are separate from the bounded section readers and native fence export. +Imported fences: the OpenStreetMap world importer moves every barrier line whose edges reach more than one section into the shared world fence layer, once, in absolute tiles. Gates found near each section are merged; authored section lines are never moved. A style conflict with an existing world style keeps the local copies and is reported. **Preserve edits** keeps authored world fences and shared decorations and rebuilds imported world fences from the refreshed source. + +Current boundaries: native lighting requires **Load complete world** so that its neighbour halo is present; isolated editing refuses an incomplete lighting calculation. Attached (shared-wall) buildings come from imports (`pz:attached`); the editor has no control to attach two buildings by hand yet. These limitations are separate from the bounded section readers and native fence export. The companion developer's private 13 populated Pilna scenes have not been used as an acceptance fixture for this change. Synthetic fixtures establish the tested behavior without claiming that the complete real settlement has already passed validation. diff --git a/docs/TRANSFER-CONTRACT.md b/docs/TRANSFER-CONTRACT.md index cd22017..194ef50 100644 --- a/docs/TRANSFER-CONTRACT.md +++ b/docs/TRANSFER-CONTRACT.md @@ -77,7 +77,7 @@ A rebuild from the same source must reproduce the same identities, so that **Pre - placed at world level; - kept as evidence with a reason (`skipped: `). - The world import report lists the counts per source kind (`placed-section`, `placed-world`, `evidence`), and the seam check fails when a feature is in none or in more than one. + The world import report lists the counts per source kind (`placed-section`, `placed-world`, `evidence`), and the seam check fails when a feature is in none or in more than one. *Implemented:* `GeographicImporter.TransferCheck` re-reads the source and reports buildings and fences; `simplepzmap build`/`validate --source` print it and `--strict` fails on lost or duplicated features ([CLI](CLI.md#seam-check)). - A section may be built separately; the world build reads the per-section scene plus a seam list (features that crossed its edge), deduplicates by `SourceId` and imports them once at world level. ## 4. Fences and gates @@ -127,20 +127,22 @@ Building feature properties read by the importer (OSM keys keep their OSM meanin | Key | Values | Effect | |---|---|---| +*Implemented* for section and shared world imports. Tags are applied after the footprint is fitted, so regenerated edges never drop them; unusable values are reported once per value in the import notes. + | `building:levels` | 1–8 | storeys (existing) | | `roof:shape` | `gabled`, `hipped`, `flat`, … | roof form (existing) | -| `roof:colour` | `grey`, `red`, `blue`, `green`, `brown` | roof tile set | -| `pz:wall` | wall set id from the palette, or `set:` | exterior wall set | +| `roof:colour` | `grey`, `red`, `blue`, `green`, `brown`, `white`, `black`, `silver`, or `#rrggbb` | roof tile set: grey `roofs_30_04`, red `roofs_30_09`, blue `roofs_30_01`, green `roofs_30_05`, brown `roofs_30_07`, white `roofs_30_03`; a hex colour takes the nearest set by the average colour measured from the installed Build 42 sprites. Flat roofs keep their own surface. | +| `pz:wall` | `set:`, a bare first tile, or the ID of a project palette whose first wall resource starts the set | exterior wall set: wall `+0/+1`, window frame `+8/+9`, door frame `+10/+11` (W/N), on every exterior edge except balconies | | `pz:trim` | overlay tile pair `:` | window surround overlay on exterior window edges (wall edge overlays, PR #1) | | `pz:style` | building style id | interior and furniture style | -| `pz:kind` | `house`, `shed`, `garage`, `bathhouse`, `toilet`, `greenhouse` | building kind when OSM `building=*` is ambiguous | +| `pz:kind` | `house`, `shed`, `garage`, `bathhouse`, `toilet`, `greenhouse` | building kind when OSM `building=*` is ambiguous; `bathhouse` and `toilet` make a one-room building a bathroom | Unknown `pz:*` keys are kept as source tags and reported once per key as a warning; they never fail an import. ## 6. Small and attached buildings -- **Small buildings.** A building with `pz:kind` in {`shed`, `toilet`, `bathhouse`, `greenhouse`} may be as small as 2×2 tiles, and its footprint may be smaller than 16 cells. It has one room, no stairs and an optional door. Today such outbuildings are inflated to 4×4. -- **Attached buildings.** `pz:attached = ` lets a building touch the referenced building with no 1-tile gap. A typical case is a covered yard or barn joined to the house. The shared edge is a wall of the larger building; a door on it is allowed when `pz:door-to` names the other building. +- **Small buildings (implemented).** Any outline may be as small as 2×2 tiles; it is no longer inflated, and its footprint may hold as few as 4 cells. Under 6 tiles on a side the building is one room with a door. It keeps one floor unless its long side has at least 6 tiles for a straight stair run; otherwise source floor tags are retained but only one floor is built, and the import report says so. +- **Attached buildings (implemented).** `pz:attached = ` lets a building touch the referenced building with no 1-tile gap. A typical case is a covered yard or barn joined to the house. The shared edge is a wall of the larger building (by floor area; the target on a tie); the smaller building draws nothing there, and the larger building's windows on that wall become plain wall. A door on it is added when `pz:door-to` names the other building. The smaller building keeps an exterior entrance off the shared wall. The relation is stored as `Building.AttachedTo` (map format 13) inside one section; shared world buildings cannot be attached. Wall rings of the pair may share tiles; rooms never overlap. ## 7. Game zones @@ -161,12 +163,12 @@ Pipeline-generated verge wrecks are emitted as `pz:zone=Vehicle` polygons with ` ## 8. Versions - Input GeoJSON declares `"pzTransfer": 1` at the FeatureCollection level. Without it, `pz:*` keys are still read, and the import report notes the missing declaration. -- Writing `RotationDegrees ≠ 0` or a fence layer raises the world format to 7, and a fence layer raises the map format to 12. Small and attached buildings will raise it again when they land. All of this goes through `AuthoringFormats`, as for other new authoring features. An older editor refuses such files instead of silently dropping data, and files without these features keep their current version. +- Writing `RotationDegrees ≠ 0` or a fence layer raises the world format to 7, and a fence layer raises the map format to 12. A shared decoration layer raises the world format to 8. Small buildings need no new format; attached buildings raise the map format to 13. All of this goes through `AuthoringFormats`, as for other new authoring features. An older editor refuses such files instead of silently dropping data, and files without these features keep their current version. - This document is versioned with the code. Incompatible changes create `TRANSFER-CONTRACT.md` version 2 and keep version 1 readable. ## Acceptance -Implementation note: authoritative `WorldProject.Fences` now survive isolated section reads, editing validation and streamed export with gate/post phase preserved across seams. The OSM world-import pipeline still needs to promote and deduplicate crossing imported fence lines: the current per-section importer can retain a complete crossing source line in each touched section. This remains an import ownership gap; stored local lines are preserved rather than silently deleted or clipped. No zero-duplicate source-transfer acceptance claim follows from the fence layer alone. +Implementation note: authoritative `WorldProject.Fences` survive isolated section reads, editing validation and streamed export with gate/post phase preserved across seams. The world importers (editor OSM import and `simplepzmap build`) promote every imported fence line whose edges reach two or more sections into the world layer once, merge the gates found near each section, and keep only the owning section's copy of a line that merely touches a neighbour. The seam check then verifies zero lost or duplicated buildings and fences. `PilnaVillage` is built from the section scenes of real Pilna data: 21 sections of 1024 tiles under the rotated reference (the earlier, pre-rotated frame had 13). It is accepted when all of the following hold: - `WorldAssembler.Validate` passes; diff --git a/scripts/NativeGameProbe.java b/scripts/NativeGameProbe.java index 10c9c4e..70884bf 100644 --- a/scripts/NativeGameProbe.java +++ b/scripts/NativeGameProbe.java @@ -44,7 +44,9 @@ public static void main(String[] args) throws Exception { int expectedMin=args.length>4?Integer.parseInt(args[4]):-16; if(expectedMin < -32 || expectedMin > -1)throw new AssertionError("Basement minimum outside supported range"); if(args.length>5&&maxLoadedLevel!=Integer.parseInt(args[5]))throw new AssertionError("Expected top room was not loaded by the installed reader"); - if(stairTiles==0||(!requireBasements&&upperRooms==0)||(requireBasements&&(minLoadedLevel!=expectedMin||basementRooms==0||basementStairs==0)))throw new AssertionError("Expected signed rooms/stair sprites not loaded"); + // no-stairs checks a map that has no upper floors: headers, chunks, rooms and every tile sprite are still loaded and checked. + boolean requireStairs=!Arrays.asList(args).contains("no-stairs"); + if(requireStairs&&(stairTiles==0||(!requireBasements&&upperRooms==0))||(requireBasements&&(minLoadedLevel!=expectedMin||basementRooms==0||basementStairs==0)))throw new AssertionError("Expected signed rooms/stair sprites not loaded"); System.out.printf("NATIVE_GAME_PROBE cells=%d chunks=%d rooms=%d upperRooms=%d basementRooms=%d minLevel=%d maxRoomLevel=%d stairTiles=%d basementStairs=%d status=passed%n",cellCount,chunkCount,roomCount,upperRooms,basementRooms,minLoadedLevel,maxLoadedLevel,stairTiles,basementStairs); } } diff --git a/scripts/verify-game-runtime.ps1 b/scripts/verify-game-runtime.ps1 index b2cb0e2..25a642c 100644 --- a/scripts/verify-game-runtime.ps1 +++ b/scripts/verify-game-runtime.ps1 @@ -1,4 +1,4 @@ -param([string]$MapArchive='examples/Pilna/Pilna-B42.zip',[string]$GamePath='',[switch]$Basements,[ValidateRange(-32,-1)][int]$BasementMinLevel=-16) +param([string]$MapArchive='examples/Pilna/Pilna-B42.zip',[string]$GamePath='',[switch]$Basements,[switch]$NoStairs,[ValidateRange(-32,-1)][int]$BasementMinLevel=-16) $ErrorActionPreference='Stop' . (Join-Path $PSScriptRoot 'WorkspaceEnv.ps1') if([string]::IsNullOrWhiteSpace($GamePath)){$GamePath=$env:PZMAP_GAME_PATH} @@ -18,10 +18,11 @@ $javaOptions=@('--enable-native-access=ALL-UNNAMED',('-Duser.home='+$probeRoot), & $java @javaOptions -jar $compiler -25 -cp $gameJar -d $classes (Join-Path $PSScriptRoot 'NativeGameProbe.java') if($LASTEXITCODE -ne 0){throw 'Runtime probe compile failed.'} $extracted=Join-Path $probeRoot ('mods\'+[Guid]::NewGuid().ToString('N')) -Expand-Archive -LiteralPath (Join-Path $taskRoot $MapArchive) -DestinationPath $extracted +$archivePath=if([IO.Path]::IsPathRooted($MapArchive)){$MapArchive}else{Join-Path $taskRoot $MapArchive} +Expand-Archive -LiteralPath $archivePath -DestinationPath $extracted $mapFolder=(Get-ChildItem -LiteralPath $extracted -Recurse -File -Filter '*.lotheader' | Select-Object -First 1).Directory.FullName if(-not $mapFolder){throw 'Archive contains no native map.'} -$probeOptions=if($Basements){@('--basements',$BasementMinLevel.ToString())}else{@()} +$probeOptions=@(if($Basements){'--basements',$BasementMinLevel.ToString()}elseif($NoStairs){'no-stairs'}) & $java @javaOptions -cp ($classes+';'+$gameJar) NativeGameProbe $mapFolder (Join-Path $probeRoot 'cache') $GamePath @probeOptions if($LASTEXITCODE -ne 0){throw 'Installed game load/room/stair probe failed.'} Write-Host 'Native game classes loaded headers, chunks, checked room levels and staircase sprite properties. Character walking/visual playthrough is a separate check.' diff --git a/src/SimplePZMapmaker.Cli/Program.cs b/src/SimplePZMapmaker.Cli/Program.cs new file mode 100644 index 0000000..0ae9ac9 --- /dev/null +++ b/src/SimplePZMapmaker.Cli/Program.cs @@ -0,0 +1,99 @@ +using System.Diagnostics; +using System.Text.Json; +using SimplePZMapmaker.Cli; +using SimplePZMapmaker.Core; +using SimplePZMapmaker.Core.Export; +using SimplePZMapmaker.Web.Services; + +const string Usage=""" +simplepzmap build [--out world.pzworld.json] [--export mod.zip] [--report report.json] [--strict] [--game ] [mod options] +simplepzmap validate [--game ] [--source data.geojson] [--strict] +simplepzmap export --out mod.zip [--game ] [mod options] +simplepzmap probe --game [--expect-stairs] +simplepzmap tiles --out tiles.zip [--game ] [--install] [--mods-dir ] + +mod options: --mod-id --map-folder --mod-name "" --common-maps --install [--mods-dir <dir>] +--game checks every tile name against the installed Build 42 tile definitions and validates resources. +--strict fails the command when the seam check finds lost or duplicated source features. +"""; +try{return Run(args);} +catch(ArgumentException e){Console.Error.WriteLine("error: "+e.Message);return 1;} +catch(Exception e) when(e is IOException or UnauthorizedAccessException or JsonException){Console.Error.WriteLine("error: "+e.Message);return 1;} + +static int Run(string[] args){ + if(args.Length<2||args[0] is "-h" or "--help"){Console.WriteLine(Usage);return args.Length==0||args[0] is "-h" or "--help"?0:2;} + var command=args[0];var input=Path.GetFullPath(args[1]);var options=Options(args[2..]); + string? Option(string name)=>options.GetValueOrDefault(name);bool Flag(string name)=>options.ContainsKey(name); + var workspace=Environment.GetEnvironmentVariable("PZMAP_WORKSPACE")??Directory.GetCurrentDirectory(); + GameAssetCatalog? assets=null;if(Option("--game") is {} game){assets=new GameAssetCatalog(workspace,Path.GetFullPath(game));if(!assets.Ready)throw new ArgumentException("No Build 42 tile definitions found: "+(assets.Error??game));Console.WriteLine($"game: {assets.GamePath} ({assets.Count} tiles)");} + var catalog=assets??new GameAssetCatalog(workspace,"");var models=new StaticModelCatalog(catalog); + NativeModOptions? Mod(NativeModOptions? scene){var o=new NativeModOptions(Option("--mod-id")??scene?.ModId,Option("--map-folder")??scene?.MapFolder,Option("--mod-name")??scene?.Name,Flag("--common-maps")||scene?.CommonMaps==true,Flag("--install")||scene?.Install==true);NativeModPackage.Validate(o);return o.Renames||o.Install?o:null;} + switch(command){ + case "build":{ + var scene=SceneBuilder.Read(input);var build=SceneBuilder.Build(scene,Path.GetDirectoryName(input)!,w=>WorldEndpoints.Validate(w,catalog,models),Console.WriteLine); + foreach(var w in build.Warnings)Console.WriteLine("note: "+w); + var world=build.World;Console.WriteLine($"world {world.Name}: {world.Maps.Count} sections, {world.Maps.Sum(m=>m.Project.Buildings.Count)} section buildings, {world.Buildings.Count} shared buildings, {world.Fences.Count} shared fences"); + var failed=Report(build.Report,Option("--report"));if(assets is not null)failed|=CheckTiles(world,assets); + var output=Path.GetFullPath(Option("--out")??Path.ChangeExtension(input,null)+".pzworld.json");using(var file=File.Create(output))JsonSerializer.Serialize(file,world,NativeMapExporter.JsonOptions);Console.WriteLine("wrote "+output); + if(Option("--export") is {} target)Export(s=>NativeWorldExporter.ExportToStream(world,s),Path.GetFullPath(target),Mod(scene.Export),Option("--mods-dir")); + return failed&&Flag("--strict")?1:0;} + case "validate":{ + var (map,world)=Load(input);var failed=false; + if(world is not null){WorldEndpoints.Validate(world,catalog,models);Console.WriteLine($"world {world.Name}: valid, {world.Maps.Count} sections");if(Option("--source") is {} source)failed|=Report(GeographicImporter.TransferCheck(File.ReadAllText(Path.GetFullPath(source)),world),Option("--report"));if(assets is not null)failed|=CheckTiles(world,assets);} + else{AssetRules.Project(map!,catalog);models.Validate(map!);Console.WriteLine($"map {map!.Name}: valid, format {map.Version}");if(assets is not null)failed|=CheckTiles(map,assets);} + return failed?1:0;} + case "export":{ + var target=Option("--out")??throw new ArgumentException("export needs --out <mod.zip>.");var (map,world)=Load(input); + if(world is not null){WorldEndpoints.Validate(world,catalog,models);Export(s=>NativeWorldExporter.ExportToStream(world,s),Path.GetFullPath(target),Mod(null),Option("--mods-dir"));} + else{AssetRules.Project(map!,catalog);models.Validate(map!);Export(s=>NativeMapExporter.ExportToStream(map!,s),Path.GetFullPath(target),Mod(null),Option("--mods-dir"));} + return 0;} + case "tiles":{ + var target=Path.GetFullPath(Option("--out")??throw new ArgumentException("tiles needs --out <tiles.zip>."));var mod=TilesetSpec.Read(input,assets); + using(var file=File.Create(target))TilesetBuilder.Write(mod,file);Console.WriteLine($"wrote {target}: {mod.Sprites.Count} tiles {mod.Tileset}_0..{mod.Sprites.Count-1}, tiledef {mod.FileNumber}"); + if(Flag("--install")){using var archive=File.OpenRead(target);Console.WriteLine("installed "+NativeModPackage.Install(archive,Option("--mods-dir")??NativeModPackage.DefaultModsDirectory()));} + return 0;} + case "probe":return Probe(input,Option("--game")??throw new ArgumentException("probe needs --game <Build 42 installation>."),Flag("--expect-stairs")); + default:Console.Error.WriteLine(Usage);return 2; + } +} +static Dictionary<string,string> Options(string[] args){ + var flags=new HashSet<string>{"--common-maps","--install","--strict","--expect-stairs"};var result=new Dictionary<string,string>(StringComparer.Ordinal); + for(var i=0;i<args.Length;i++){if(!args[i].StartsWith("--",StringComparison.Ordinal))throw new ArgumentException("Unexpected argument: "+args[i]);if(flags.Contains(args[i])){result[args[i]]="true";continue;}if(i+1>=args.Length)throw new ArgumentException(args[i]+" needs a value.");result[args[i]]=args[++i];} + return result; +} +static (MapProject? Map,WorldProject? World) Load(string path){ + using var document=JsonDocument.Parse(File.ReadAllText(path),new JsonDocumentOptions{MaxDepth=64}); + var format=document.RootElement.TryGetProperty("format",out var f)?f.GetString():null; + if(format=="SimplePZWorld")return(null,document.RootElement.Deserialize<WorldProject>(NativeMapExporter.JsonOptions)); + var map=document.RootElement.Deserialize<MapProject>(NativeMapExporter.JsonOptions)??throw new ArgumentException("The project file is empty.");ProjectValidator.Validate(map);return(map,null); +} +static bool Report(TransferReport report,string? file){ + foreach(var (kind,o) in report.ByKind.OrderBy(k=>k.Key))Console.WriteLine($"seam check {kind}: placed-section {o.PlacedSection}, placed-world {o.PlacedWorld}, evidence {o.Evidence}, lost {o.Lost}, duplicated {o.Duplicated}"); + foreach(var (reason,count) in report.EvidenceReasons.OrderByDescending(r=>r.Value))Console.WriteLine($" evidence {count}: {reason}"); + foreach(var problem in report.Problems)Console.WriteLine(" "+problem); + Console.WriteLine(report.Passed?"seam check: passed":"seam check: FAILED"); + if(file is not null)File.WriteAllText(Path.GetFullPath(file),JsonSerializer.Serialize(new{passed=report.Passed,byKind=report.ByKind,evidenceReasons=report.EvidenceReasons,problems=report.Problems},new JsonSerializerOptions(NativeMapExporter.JsonOptions){WriteIndented=true})); + return !report.Passed; +} +/// <summary>Every tile the native exporter would write must exist in the installed game's tile definitions (or an active mod).</summary> +static bool CheckTiles(object project,GameAssetCatalog assets){ + var sections=project is WorldProject world?world.Maps.Select(m=>WorldAssembler.ProjectSection(world,m)):[(MapProject)project];var missing=new SortedSet<string>(StringComparer.Ordinal);var total=0; + foreach(var p in sections)foreach(var id in NativeScene.ResourceIds(p).Concat(p.Fences.Count>0?FenceLines.Compose(p.Fences,p.FenceStyles,p.GateStyles,p.Width,p.Height).Pieces.Select(x=>x.Tile):[])){total++;try{assets.Get(id);}catch(ArgumentException){missing.Add(id);}} + Console.WriteLine(missing.Count==0?$"tiles: all {total} references exist in the game":$"tiles: {missing.Count} unknown tile names: {string.Join(", ",missing.Take(40))}"); + return missing.Count>0; +} +static void Export(Action<Stream> write,string target,NativeModOptions? package,string? modsDirectory){ + var temporary=target+".tmp";try{ + using(var file=File.Create(temporary))write(file); + if(package?.Renames==true){using(var source=File.OpenRead(temporary))using(var file=File.Create(target))NativeModPackage.Repack(source,file,package);}else File.Copy(temporary,target,true); + }finally{if(File.Exists(temporary))File.Delete(temporary);} + Console.WriteLine("wrote "+target); + if(package?.Install==true){using var archive=File.OpenRead(target);Console.WriteLine("installed "+NativeModPackage.Install(archive,modsDirectory??NativeModPackage.DefaultModsDirectory()));} +} +/// <summary>Runs scripts/verify-game-runtime.ps1: the installed game's own classes load the lot headers, chunks, rooms and stairs.</summary> +static int Probe(string archive,string game,bool expectStairs){ + var script=new[]{AppContext.BaseDirectory,Directory.GetCurrentDirectory()}.Select(d=>Path.Combine(d,"scripts","verify-game-runtime.ps1")).Concat(Ancestors(AppContext.BaseDirectory).Select(d=>Path.Combine(d,"scripts","verify-game-runtime.ps1"))).FirstOrDefault(File.Exists)??throw new ArgumentException("scripts/verify-game-runtime.ps1 was not found; run probe from the repository."); + var start=new ProcessStartInfo("powershell",["-NoProfile","-ExecutionPolicy","Bypass","-File",script,"-MapArchive",archive,"-GamePath",Path.GetFullPath(game),..(expectStairs?Array.Empty<string>():["-NoStairs"])]){UseShellExecute=false}; + using var process=Process.Start(start)??throw new ArgumentException("Could not start PowerShell.");process.WaitForExit();return process.ExitCode==0?0:1; +} +static IEnumerable<string> Ancestors(string directory){for(var d=new DirectoryInfo(directory);d is not null;d=d.Parent)yield return d.FullName;} diff --git a/src/SimplePZMapmaker.Cli/SceneBuilder.cs b/src/SimplePZMapmaker.Cli/SceneBuilder.cs new file mode 100644 index 0000000..c2ab989 --- /dev/null +++ b/src/SimplePZMapmaker.Cli/SceneBuilder.cs @@ -0,0 +1,71 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using SimplePZMapmaker.Core; +using SimplePZMapmaker.Core.Export; +namespace SimplePZMapmaker.Cli; + +/// <summary>A headless world build request (format SimplePZScene, version 1). Paths are relative to the scene file.</summary> +public sealed class Scene +{ + public string Format {get;set;}=""; + public int Version {get;set;} + public string Name {get;set;}=""; + public string? Source {get;set;} + public List<string> Sources {get;set;}=[]; + public WorldGeoReference Reference {get;set;}=new(); + public int SectionSize {get;set;}=1024; + public int Seed {get;set;}=42; + public bool SkipEmptySections {get;set;}=true; + public NativeModOptions? Export {get;set;} +} +public sealed record SceneBuild(WorldProject World,string SourceJson,List<string> Warnings,TransferReport Report); + +/// <summary>Imports a GeoJSON/OSM scene into one geographic world the same way the editor's world import does: +/// every section imports the whole source, crossing fences and buildings are promoted to the world, and the seam check runs last.</summary> +public static class SceneBuilder +{ + public static Scene Read(string path){ + var scene=JsonSerializer.Deserialize<Scene>(File.ReadAllText(path),NativeMapExporter.JsonOptions)??throw new ArgumentException("The scene file is empty."); + if(scene.Format!="SimplePZScene"||scene.Version!=1)throw new ArgumentException("Expected a scene with \"format\": \"SimplePZScene\" and \"version\": 1."); + if(string.IsNullOrWhiteSpace(scene.Name)||scene.Name.Length>80||scene.Name.Any(char.IsControl))throw new ArgumentException("Scene name: 1-80 characters."); + if(scene.Source is null&&scene.Sources.Count==0)throw new ArgumentException("The scene needs \"source\" or \"sources\" (GeoJSON or Overpass JSON files)."); + if(scene.SectionSize is <256 or >2048||scene.SectionSize%256!=0)throw new ArgumentException("Section size: 256-2048 tiles in multiples of 256."); + if(scene.Export is {} export)NativeModPackage.Validate(export); + return scene; + } + /// <summary>Concatenates the features of several FeatureCollections; a feature id seen twice is kept once.</summary> + public static string MergeSources(IEnumerable<string> files){ + var features=new JsonArray();var seen=new HashSet<string>(StringComparer.Ordinal); + foreach(var file in files){var root=JsonNode.Parse(File.ReadAllText(file))??throw new ArgumentException(file+" is empty."); + if(root["features"] is not JsonArray list)throw new ArgumentException(file+" is not a GeoJSON FeatureCollection; pass a single Overpass JSON as \"source\"."); + foreach(var feature in list.ToList()){var id=feature?["id"]?.ToString();if(id is not null&&!seen.Add(id))continue;list.Remove(feature);features.Add(feature);}} + return new JsonObject{["type"]="FeatureCollection",["pzTransfer"]=1,["features"]=features}.ToJsonString(); + } + /// <summary>Contract section 2: section identity is SHA-256(world name | X,Y), so rebuilds keep it.</summary> + public static string SectionId(string world,int x,int y)=>Hash($"{world}|{x},{y}"); + static string Hash(string text)=>Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(text)))[..32]; + + public static SceneBuild Build(Scene scene,string sceneDirectory,Action<WorldProject>? validate=null,Action<string>? progress=null,CancellationToken token=default){ + var files=(scene.Source is null?scene.Sources:[scene.Source,..scene.Sources]).Select(f=>Path.GetFullPath(Path.Combine(sceneDirectory,f))).ToList(); + var json=files.Count==1?File.ReadAllText(files[0]):MergeSources(files); + var reference=scene.Reference;WorldGeoReference.Validate(reference);var size=GeographicProjection.RequiredTiles(reference); + var columns=Math.Max(1,(int)Math.Ceiling(size.Width/(double)scene.SectionSize));var rows=Math.Max(1,(int)Math.Ceiling(size.Height/(double)scene.SectionSize));var step=scene.SectionSize/256; + if(columns*rows>128||columns*rows*step*step>8192)throw new ArgumentException($"The area needs {columns}x{rows} sections; a world holds up to 128 sections and 8,192 native cells. Increase metresPerTile or the section size."); + var world=new WorldProject{Name=scene.Name,Id=Hash("world|"+scene.Name),GeoReference=reference};var warnings=new HashSet<string>(StringComparer.Ordinal);var skipped=0; + for(var y=0;y<rows;y++)for(var x=0;x<columns;x++){ + token.ThrowIfCancellationRequested();var cellX=reference.OriginCellX+x*step;var cellY=reference.OriginCellY+y*step;var projection=new GeographicProjection(reference,cellX,cellY); + var project=MapGenerator.Generate(new(){Kind="blank",Size=scene.SectionSize,Seed=scene.Seed,Name=$"{scene.Name[..Math.Min(60,scene.Name.Length)]} {x+1}/{y+1}"});project.WorldX=cellX;project.WorldY=cellY;project.WorldSection=true;project.Version=Math.Max(project.Version,6); + var result=GeographicImporter.Import(json,project,projection.SectionBounds(scene.SectionSize,scene.SectionSize),token,projection:projection);project=result.Project;foreach(var w in result.Warnings)warnings.Add(w); + if(scene.SkipEmptySections&&project.Buildings.Count==0&&project.Fences.Count==0&&project.LinearFeatures.Count==0){skipped++;continue;} + world.Maps.Add(new(){Id=SectionId(scene.Name,cellX,cellY),Project=project});progress?.Invoke($"section {x+1}/{y+1}: {project.Buildings.Count} buildings, {project.Fences.Count} fence lines"); + } + if(world.Maps.Count==0)throw new ArgumentException("No section received buildings, fences or paths. Check the source and the reference bounds."); + if(world.Maps.All(m=>m.Project.Spawns.Count==0)){var first=world.Maps[0].Project;first.Spawns=[MapGenerator.FindSafeSpawn(first)];} + if(skipped>0)warnings.Add($"Empty sections left out of the world: {skipped}."); + var notes=new List<string>();GeographicImporter.PromoteSharedFences(world,notes);GeographicImporter.ImportWorldBuildings(json,world,scene.Seed,notes,token);foreach(var n in notes)warnings.Add(n); + if(validate is null)WorldAssembler.Validate(world);else validate(world); + return new(world,json,warnings.ToList(),GeographicImporter.TransferCheck(json,world)); + } +} diff --git a/src/SimplePZMapmaker.Cli/SimplePZMapmaker.Cli.csproj b/src/SimplePZMapmaker.Cli/SimplePZMapmaker.Cli.csproj new file mode 100644 index 0000000..db21180 --- /dev/null +++ b/src/SimplePZMapmaker.Cli/SimplePZMapmaker.Cli.csproj @@ -0,0 +1 @@ +<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable><TreatWarningsAsErrors>true</TreatWarningsAsErrors><AssemblyName>simplepzmap</AssemblyName></PropertyGroup><ItemGroup><ProjectReference Include="../SimplePZMapmaker.Core/SimplePZMapmaker.Core.csproj" /><ProjectReference Include="../SimplePZMapmaker.Web/SimplePZMapmaker.Web.csproj" /></ItemGroup></Project> diff --git a/src/SimplePZMapmaker.Cli/TilesetSpec.cs b/src/SimplePZMapmaker.Cli/TilesetSpec.cs new file mode 100644 index 0000000..a49cf8f --- /dev/null +++ b/src/SimplePZMapmaker.Cli/TilesetSpec.cs @@ -0,0 +1,31 @@ +using System.Text.Json; +using SimplePZMapmaker.Core.Export; +using SimplePZMapmaker.Web.Services; +namespace SimplePZMapmaker.Cli; + +/// <summary>A tile mod request (format SimplePZTileset, version 1). Each tile is a PNG frame; copyFrom takes the properties of an +/// installed tile (walls, fences, doors and their flags) and properties then adds or overrides keys.</summary> +public sealed class TilesetSpec +{ + public sealed class Tile {public string Png {get;set;}="";public string? CopyFrom {get;set;}public Dictionary<string,string> Properties {get;set;}=[];} + public string Format {get;set;}=""; + public int Version {get;set;} + public string ModId {get;set;}=""; + public string Name {get;set;}=""; + public string Tileset {get;set;}=""; + public int FileNumber {get;set;} + public List<Tile> Tiles {get;set;}=[]; + + public static TilesetMod Read(string path,GameAssetCatalog? assets){ + var spec=JsonSerializer.Deserialize<TilesetSpec>(File.ReadAllText(path),NativeMapExporter.JsonOptions)??throw new ArgumentException("The tileset file is empty."); + if(spec.Format!="SimplePZTileset"||spec.Version!=1)throw new ArgumentException("Expected a tileset with \"format\": \"SimplePZTileset\" and \"version\": 1."); + var folder=Path.GetDirectoryName(Path.GetFullPath(path))!;var sprites=new List<TilesetSprite>(); + foreach(var tile in spec.Tiles){ + var properties=new Dictionary<string,string>(StringComparer.Ordinal); + if(tile.CopyFrom is {} source){if(assets is null)throw new ArgumentException($"copyFrom {source} needs --game <Build 42 installation> to read the original tile properties.");foreach(var (key,value) in assets.Get(source).Properties)properties[key]=value;} + foreach(var (key,value) in tile.Properties)properties[key]=value; + sprites.Add(new(File.ReadAllBytes(Path.GetFullPath(Path.Combine(folder,tile.Png))),properties)); + } + var mod=new TilesetMod(spec.ModId,spec.Name,spec.Tileset,spec.FileNumber,sprites);TilesetBuilder.Validate(mod);return mod; + } +} diff --git a/src/SimplePZMapmaker.Core/AttachedBuildings.cs b/src/SimplePZMapmaker.Core/AttachedBuildings.cs new file mode 100644 index 0000000..ffc07b9 --- /dev/null +++ b/src/SimplePZMapmaker.Core/AttachedBuildings.cs @@ -0,0 +1,67 @@ +namespace SimplePZMapmaker.Core; + +/// <summary>Attached buildings (docs/TRANSFER-CONTRACT.md, section 6): a building with AttachedTo touches that building with no free line. +/// Their wall rings may share tiles, their rooms never do. On a shared edge the larger building owns the wall and the +/// smaller one draws nothing, so the edge never carries two walls.</summary> +public static class AttachedBuildings +{ + public static bool Attached(Building a,Building b)=>a.AttachedTo is not null&&a.AttachedTo==b.Id||b.AttachedTo is not null&&b.AttachedTo==a.Id; + /// <summary>Whether a world tile used by both buildings is allowed: only for an attached pair, and only when it is not a room of both.</summary> + public static bool MayShare(Building a,Building b,int x,int y)=>Attached(a,b)&&!(BuildingDesigner.Occupies(a,x-a.X,y-a.Y)&&BuildingDesigner.Occupies(b,x-b.X,y-b.Y)); + static int Area(Building b)=>b.Design is null?b.Width*b.Height:b.Design.Floors.Sum(f=>f.Cells.Count(c=>c!='.')); + /// <summary>The building that owns the wall on edges shared by an attached pair: the larger one, or the attachment target on a tie.</summary> + public static Building WallOwner(Building attached,Building target)=>Area(attached)>Area(target)?attached:target; + /// <summary>Edges, keyed by the building that must not draw them, in project tile coordinates with signed level: its attached partner owns the wall there.</summary> + public static HashSet<(string Building,int X,int Y,int Z,string Edge)> SuppressedEdges(IReadOnlyList<Building> buildings){ + var result=new HashSet<(string,int,int,int,string)>();var byId=buildings.Where(b=>b.Id is not null).GroupBy(b=>b.Id).ToDictionary(g=>g.Key,g=>g.First()); + foreach(var b in buildings){ + if(b.AttachedTo is null||!byId.TryGetValue(b.AttachedTo,out var target)||b.Design is null||target.Design is null)continue; + var owner=WallOwner(b,target);var other=ReferenceEquals(owner,b)?target:b;var owned=Edges(owner).Where(e=>e.Kind!="open").Select(e=>(e.X,e.Y,e.Z,e.Edge)).ToHashSet(); + foreach(var e in Edges(other))if(owned.Contains((e.X,e.Y,e.Z,e.Edge)))result.Add((other.Id,e.X,e.Y,e.Z,e.Edge)); + } + return result; + } + static IEnumerable<(int X,int Y,int Z,string Edge,string Kind)> Edges(Building b)=>b.Design!.Floors.SelectMany((f,i)=>f.Edges.Select(e=>(b.X+e.X,b.Y+e.Y,i+b.Design.MinLevel,e.Edge,e.Kind))); + public static void Validate(MapProject p){ + var ids=p.Buildings.Select(b=>b.Id).ToHashSet(StringComparer.Ordinal); + foreach(var b in p.Buildings.Concat(p.BuildingLibrary))if(b.AttachedTo is {} target&&(target.Length is 0 or >100||target==b.Id||!ids.Contains(target)||p.BuildingLibrary.Contains(b)))throw new ArgumentException($"Building '{b.Name}' is attached to a building that is not on this map."); + } + /// <summary>Joins an attached pair after both are placed: the smaller building keeps an exterior entrance off the shared wall, + /// and with <paramref name="door"/> the wall owner gets one door on the shared ground-level wall. Returns false when no door fits.</summary> + public static bool Join(Building attached,Building target,bool door){ + if(attached.Design is null||target.Design is null)return false; + var owner=WallOwner(attached,target);var other=ReferenceEquals(owner,attached)?target:attached; + var suppressed=SuppressedEdges([attached,target]); + EnsureEntrance(other,suppressed); + CloseSharedWindows(owner,other); + if(!door)return true; + var ground=owner.Design!.Floors[-owner.Design.MinLevel];var otherGround=other.Design!.MinLevel<=0&&other.Design.Floors.Count>-other.Design.MinLevel?other.Design.Floors[-other.Design.MinLevel]:null;if(otherGround is null)return false; + var candidates=ground.Edges.Where(e=>e.Kind=="wall").Where(e=>{var north=e.Edge=="n";var a=BuildingDesigner.Cell(owner,ground,e.X,e.Y);var b=north?BuildingDesigner.Cell(owner,ground,e.X,e.Y-1):BuildingDesigner.Cell(owner,ground,e.X-1,e.Y);if((a=='.')==(b=='.'))return false; + var (ox,oy)=a=='.'?(owner.X+e.X,owner.Y+e.Y):north?(owner.X+e.X,owner.Y+e.Y-1):(owner.X+e.X-1,owner.Y+e.Y);return BuildingDesigner.Cell(other,otherGround,ox-other.X,oy-other.Y)!='.';}).ToList(); + if(candidates.Count==0)return false; + var before=NeighborhoodPlanner.Copy(owner.Design);var chosen=candidates[candidates.Count/2];chosen.Kind="door";chosen.OpeningTile=null;chosen.OverlayTile=null; + if(chosen.WallTile is {} wall&&wall.LastIndexOf('_') is var split and >0&&int.TryParse(wall[(split+1)..],out var index))chosen.WallTile=$"{wall[..split]}_{index+10}"; + try{BuildingDesigner.Furnish(owner,-owner.Design.MinLevel,new Random(owner.Design.Variant));BuildingDesigner.Validate(owner);return true;}catch(ArgumentException){owner.Design=before;return false;} + } + /// <summary>A window of the wall owner that would look into the partner's room becomes a plain wall.</summary> + static void CloseSharedWindows(Building owner,Building other){ + for(var i=0;i<owner.Design!.Floors.Count;i++){var z=i+owner.Design.MinLevel;var level=z-other.Design!.MinLevel;if(level<0||level>=other.Design.Floors.Count)continue;var f=owner.Design.Floors[i];var g=other.Design.Floors[level]; + foreach(var e in f.Edges.Where(e=>e.Kind=="window")){var north=e.Edge=="n";var a=BuildingDesigner.Cell(owner,f,e.X,e.Y);var (x,y)=a=='.'?(owner.X+e.X,owner.Y+e.Y):north?(owner.X+e.X,owner.Y+e.Y-1):(owner.X+e.X-1,owner.Y+e.Y); + if(BuildingDesigner.Cell(other,g,x-other.X,y-other.Y)=='.')continue;e.Kind="wall";e.OpeningTile=null;e.OverlayTile=null;if(e.WallTile is {} wall&&wall.LastIndexOf('_') is var split and >0&&int.TryParse(wall[(split+1)..],out var index))e.WallTile=$"{wall[..split]}_{index-8}";}} + } + /// <summary>When every exterior door of a building lies on a wall its partner owns, the first other exterior wall at ground level becomes a door.</summary> + static void EnsureEntrance(Building b,HashSet<(string Building,int X,int Y,int Z,string Edge)> suppressed){ + var f=b.Design!.Floors[-b.Design.MinLevel];bool Outer(BuildingEdge e){var a=BuildingDesigner.Cell(b,f,e.X,e.Y);var o=e.Edge=="n"?BuildingDesigner.Cell(b,f,e.X,e.Y-1):BuildingDesigner.Cell(b,f,e.X-1,e.Y);return(a=='.')!=(o=='.');} + bool Shared(BuildingEdge e)=>suppressed.Contains((b.Id,b.X+e.X,b.Y+e.Y,0,e.Edge)); + if(f.Edges.Any(e=>e.Kind is "door" or "open"&&Outer(e)&&!Shared(e)))return; + var before=NeighborhoodPlanner.Copy(b.Design); + foreach(var (x,y,edge) in f.Edges.Where(e=>e.Kind is "wall" or "window"&&Outer(e)&&!Shared(e)).OrderBy(e=>e.Edge=="n"?0:1).ThenBy(e=>e.Y).ThenBy(e=>e.X).Select(e=>(e.X,e.Y,e.Edge)).ToList()){ + b.Design=NeighborhoodPlanner.Copy(before);f=b.Design.Floors[-b.Design.MinLevel];var e=f.Edges.Single(e=>e.X==x&&e.Y==y&&e.Edge==edge); + // A wall set keeps the door frame 10 tiles after the plain wall and 2 after the window frame. + if(e.WallTile is {} wall&&wall.LastIndexOf('_') is var split and >0&&int.TryParse(wall[(split+1)..],out var index))e.WallTile=$"{wall[..split]}_{index+(e.Kind=="window"?2:10)}"; + e.Kind="door";e.OpeningTile=null;e.OverlayTile=null; + try{BuildingDesigner.Furnish(b,-b.Design.MinLevel,new Random(b.Design.Variant));BuildingDesigner.Validate(b);return;}catch(ArgumentException){} + } + b.Design=before; + } +} diff --git a/src/SimplePZMapmaker.Core/AuthoringFormats.cs b/src/SimplePZMapmaker.Core/AuthoringFormats.cs index 8670c9f..440335e 100644 --- a/src/SimplePZMapmaker.Core/AuthoringFormats.cs +++ b/src/SimplePZMapmaker.Core/AuthoringFormats.cs @@ -2,6 +2,7 @@ namespace SimplePZMapmaker.Core; public static class AuthoringFormats { public static bool MapHasInfrastructure(MapProject p)=>p.LinearFeatures?.Any(f=>f is not null&&(f.Level!=0||f.Access!="none"||f.GuardRails||f.SupportSpacing!=0||f.GroupId is not null))==true; + public static bool MapHasAttachedBuildings(MapProject p)=>p.Buildings?.Any(b=>b?.AttachedTo is not null)==true; public static bool MapHasFences(MapProject p)=>p.Fences?.Count>0||p.FenceStyles?.Count>0||p.GateStyles?.Count>0; public static bool WorldHasInfrastructure(WorldProject w)=>w.LinearFeatures?.Count>0||w.Maps?.Any(m=>m?.Project is {} p&&MapHasInfrastructure(p))==true; // Earlier editor formats supported occupied levels -16..15 and had no explicit open roof option. diff --git a/src/SimplePZMapmaker.Core/BuildingDesigner.cs b/src/SimplePZMapmaker.Core/BuildingDesigner.cs index 4bd2026..61e637c 100644 --- a/src/SimplePZMapmaker.Core/BuildingDesigner.cs +++ b/src/SimplePZMapmaker.Core/BuildingDesigner.cs @@ -12,8 +12,14 @@ public static HashSet<int> Occupied(Building b,bool perimeter=true){ var result=new HashSet<int>();for(var y=0;y<b.Height;y++)for(var x=0;x<b.Width;x++)if(Occupies(b,x,y)){result.Add(y*(b.Width+1)+x);if(perimeter){result.Add(y*(b.Width+1)+x+1);result.Add((y+1)*(b.Width+1)+x);}} if(b.Design is null&&perimeter)for(var y=0;y<=b.Height;y++)for(var x=0;x<=b.Width;x++)result.Add(y*(b.Width+1)+x);return result; } + /// <summary>Smallest footprint side. Below <see cref="RoomyFootprint"/> a building is one room (a shed, kiosk or booth).</summary> + public const int MinimumFootprint=2,RoomyFootprint=6; + public static bool Small(int width,int height)=>width<RoomyFootprint||height<RoomyFootprint; + /// <summary>Stairs need five tiles in a straight line plus a landing, so a small footprint holds them only along a side of at least six tiles.</summary> + public static bool HoldsStairs(int width,int height)=>Math.Max(width,height)>=RoomyFootprint; public static BuildingDesign Generate(Building b,int levels,int seed,string shape="rect",int basements=0){ - if(b.Width is <6 or >64||b.Height is <6 or >64||levels is <1 or >ZLevels.AboveGroundCount||basements is <0 or >ZLevels.BasementCount||!Kinds.Contains(b.Kind)||shape is not("rect" or "l" or "t" or "courtyard" or "annex"))throw new ArgumentException("Check the building type, dimensions, shape, and floor count: 1-32 above-ground floors (z=0 to 31) and 0-32 basement levels (z=-1 to -32)."); + if(b.Width is <MinimumFootprint or >64||b.Height is <MinimumFootprint or >64||levels is <1 or >ZLevels.AboveGroundCount||basements is <0 or >ZLevels.BasementCount||!Kinds.Contains(b.Kind)||shape is not("rect" or "l" or "t" or "courtyard" or "annex"))throw new ArgumentException("Check the building type, dimensions, shape, and floor count: 2-64 tiles per side, 1-32 above-ground floors (z=0 to 31) and 0-32 basement levels (z=-1 to -32)."); + if(Small(b.Width,b.Height))return GenerateSmall(b,levels,seed,shape,basements); levels+=basements; var rng=new Random(seed);var design=new BuildingDesign{MinLevel=-basements,Variant=seed,Shape=shape,Material=new[]{"wood","brick","plaster"}[rng.Next(3)],Roof=shape=="rect"?new[]{"flat","gabled","shed","hipped"}[rng.Next(4)]:"flat"}; var names=b.Kind switch{"house"=>new[]{"livingroom","kitchen","bedroom","bathroom"},"apartments"=>new[]{"bedroom","kitchen","livingroom","bathroom"},"school"=>new[]{"classroom","office","classroom","bathroom"},"shop"=>new[]{"conveniencestore","storage","office","bathroom"},"clinic"=>new[]{"medical","office","medical","bathroom"},"hotel"=>new[]{"hotelroom","hotelroom","office","bathroom"},"garage"=>new[]{"garage","storage","garage","storage"},_=>new[]{"warehouse","storage","office","storage"}}; @@ -53,6 +59,22 @@ public static BuildingDesign Generate(Building b,int levels,int seed,string shap Validate(b); return design; } + /// <summary>A footprint under 6 tiles on a side: one room, plus a hall strip for stairs when the long side holds them.</summary> + static BuildingDesign GenerateSmall(Building b,int levels,int seed,string shape,int basements){ + if(shape!="rect")throw new ArgumentException("Footprints under 6 tiles on a side use the rectangular shape."); + if(levels+basements>1&&!HoldsStairs(b.Width,b.Height))throw new ArgumentException("A footprint under 6 tiles on both sides has room for one floor only: stairs need a straight run of five tiles plus a landing."); + var total=levels+basements;var rng=new Random(seed);var design=new BuildingDesign{MinLevel=-basements,Variant=seed,Shape="rect",Material=new[]{"wood","brick","plaster"}[rng.Next(3)],Roof=new[]{"flat","gabled","shed","hipped"}[rng.Next(4)]}; + var room=b.Kind switch{"shop"=>"conveniencestore","garage"=>"garage","school" or "clinic" or "hotel" or "apartments"=>"office",_=>"storage"}; + var direction=b.Height>=RoomyFootprint?"n":"w";var stairX=direction=="n"?0:1;var stairY=direction=="n"?1:0; + for(var z=0;z<total;z++){ + var cells=Enumerable.Repeat('1',b.Width*b.Height).ToArray();var floor=new BuildingFloor{Rooms=new(){["0"]="hall",["1"]=z<basements?"storage":room}}; + if(total>1)for(var i=0;i<5;i++)cells[(stairY+(direction=="n"?i:0))*b.Width+stairX+(direction=="w"?i:0)]='0';else floor.Rooms.Remove("0"); + floor.Cells=new(cells);if(z<total-1)floor.Objects.Add(new(){X=stairX,Y=stairY,Kind="stairs",Direction=direction});design.Floors.Add(floor); + } + b.Design=design; + for(var z=0;z<total;z++){design.Floors[z].Edges=AutoEdges(b,design.Floors[z],z+design.MinLevel);Furnish(b,z,rng);} + RoofGeometry.SelectSafeAutomaticRoof(b);Validate(b);return design; + } public static void Furnish(Building b,int z,Random rng){ var f=b.Design!.Floors[z];f.Objects.RemoveAll(o=>o.Kind!="stairs"); foreach(var code in f.Rooms.Keys.Where(k=>k!="0"&&f.Rooms[k]!="balcony")){ @@ -77,7 +99,7 @@ public static List<BuildingEdge> AutoEdges(Building b,BuildingFloor f,int z){ }return edges; } public static void Validate(Building b,bool requireAccess=true){ - if(b is null||b.Width is <6 or >64||b.Height is <6 or >64||!Kinds.Contains(b.Kind))throw new ArgumentException("Check the building dimensions and type."); + if(b is null||b.Width is <MinimumFootprint or >64||b.Height is <MinimumFootprint or >64||!Kinds.Contains(b.Kind))throw new ArgumentException("Check the building dimensions (2-64 tiles per side) and type."); if(b.Attribution is null||b.Attribution.Count>30||b.Attribution.Any(s=>s is null||s.Length>1000))throw new ArgumentException("Invalid building template source."); ResourceDependencies.Validate(b.RequiredMods,b.AssetSources); if(b.Design is null)return;var d=b.Design; diff --git a/src/SimplePZMapmaker.Core/Export/NativeLightingGeometry.cs b/src/SimplePZMapmaker.Core/Export/NativeLightingGeometry.cs index a195ed2..e65047f 100644 --- a/src/SimplePZMapmaker.Core/Export/NativeLightingGeometry.cs +++ b/src/SimplePZMapmaker.Core/Export/NativeLightingGeometry.cs @@ -22,7 +22,7 @@ public static Scene FromWorld(WorldProject world,string memberId,int x,int y,int var selected=member.Project;ValidateCrop(selected,x,y,size,level); var left=selected.WorldX*256+x-Halo;var top=selected.WorldY*256+y-Halo;var right=left+size+2*Halo;var bottom=top+size+2*Halo; // Project each intersecting section independently. Shared buildings retain both sides of a section seam. - var relevantWorld=new WorldProject{Id=world.Id,Name=world.Name,Version=world.Version,Maps=world.Maps,LinearFeatures=world.LinearFeatures,Buildings=world.Buildings.Where(b=>b.X<right&&b.Y<bottom&&b.X+b.Width>=left&&b.Y+b.Height>=top).ToList(),GeoReference=world.GeoReference,Elevation=world.Elevation}; + var relevantWorld=new WorldProject{Id=world.Id,Name=world.Name,Version=world.Version,Maps=world.Maps,LinearFeatures=world.LinearFeatures,Buildings=world.Buildings.Where(b=>b.X<right&&b.Y<bottom&&b.X+b.Width>=left&&b.Y+b.Height>=top).ToList(),GeoReference=world.GeoReference,Elevation=world.Elevation,Decorations=world.Decorations}; var projects=world.Maps.Where(m=>m.Project.WorldX*256<right&&m.Project.WorldY*256<bottom&&m.Project.WorldX*256+m.Project.Width>left&&m.Project.WorldY*256+m.Project.Height>top).Select(m=>WorldAssembler.ProjectSection(relevantWorld,m)).ToArray(); return Build(selected,projects,x,y,size,level,roomLightsPowered,properties,cancellation); } diff --git a/src/SimplePZMapmaker.Core/Export/NativeModPackage.cs b/src/SimplePZMapmaker.Core/Export/NativeModPackage.cs new file mode 100644 index 0000000..41595b4 --- /dev/null +++ b/src/SimplePZMapmaker.Core/Export/NativeModPackage.cs @@ -0,0 +1,61 @@ +using System.IO.Compression; +using System.Text; +using System.Text.RegularExpressions; +namespace SimplePZMapmaker.Core.Export; + +/// <summary>How an exported Build 42 mod is named and laid out. ModId names the mod folder and mod.info id; MapFolder names +/// media/maps/<folder> (the server's Map= value) and defaults to ModId; Name is the title in the mod list and map selector. +/// CommonMaps puts media/maps under common/ instead of 42/.</summary> +public sealed record NativeModOptions(string? ModId=null,string? MapFolder=null,string? Name=null,bool CommonMaps=false,bool Install=false) +{ + public bool Renames=>ModId is not null||MapFolder is not null||Name is not null||CommonMaps; +} +public static class NativeModPackage +{ + /// <summary>Marks a mod folder written by the editor, so a later install may replace it and never replaces anything else.</summary> + public const string InstallMarker=".simplepzmapmaker"; + static readonly Regex SafeName=new("^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$",RegexOptions.CultureInvariant,TimeSpan.FromMilliseconds(100)); + public static void Validate(NativeModOptions o){ + if(o.ModId is not null&&!SafeName.IsMatch(o.ModId)||o.MapFolder is not null&&!SafeName.IsMatch(o.MapFolder))throw new ArgumentException("Mod id and map folder: 1-64 letters, digits, '_', '.' or '-', starting with a letter, digit or '_'."); + if(o.Name is not null&&(string.IsNullOrWhiteSpace(o.Name)||o.Name.Length>80||o.Name.Any(char.IsControl)))throw new ArgumentException("Mod name: 1-80 characters without line breaks."); + } + /// <summary>Copies an exported archive under a new mod id, map folder, title and layout. Text files that name the mod or map are rewritten.</summary> + public static void Repack(Stream source,Stream target,NativeModOptions o){ + Validate(o);using var input=new ZipArchive(source,ZipArchiveMode.Read,true); + var slug=input.Entries.Select(e=>e.FullName.Split('/')[0]).FirstOrDefault(s=>s.Length>0)??throw new ArgumentException("The export archive is empty."); + if(input.Entries.Any(e=>e.FullName.Split('/')[0]!=slug))throw new ArgumentException("The export archive must contain one mod folder."); + var mod=o.ModId??slug;var map=o.MapFolder??o.ModId??slug;var oldMaps=$"{slug}/42/media/maps/{slug}/";var newMaps=$"{mod}/{(o.CommonMaps?"common":"42")}/media/maps/{map}/"; + using var output=new ZipArchive(target,ZipArchiveMode.Create,true);var written=new HashSet<string>(StringComparer.Ordinal); + foreach(var entry in input.Entries){ + var path=entry.FullName.StartsWith(oldMaps,StringComparison.Ordinal)?newMaps+entry.FullName[oldMaps.Length..]:mod+entry.FullName[slug.Length..]; + if(!written.Add(path))continue;var copy=output.CreateEntry(path,CompressionLevel.Fastest);if(path.EndsWith('/'))continue; + using var from=entry.Open();using var to=copy.Open();var name=entry.Name; + if(name is "mod.info" or "map.info" or "spawnregions.lua" or "INSTALL.txt"){using var reader=new StreamReader(from,Encoding.UTF8);var text=reader.ReadToEnd(); + text=name switch{ + "mod.info"=>Regex.Replace(Regex.Replace(text,"^id=.*$","id="+mod,RegexOptions.Multiline),"^name=.*$",o.Name is null?"$0":"name="+o.Name,RegexOptions.Multiline), + "map.info"=>o.Name is null?text:Regex.Replace(text,"^title=.*$","title="+o.Name,RegexOptions.Multiline), + "spawnregions.lua"=>text.Replace($"media/maps/{slug}/",$"media/maps/{map}/").Replace($"name = \"{slug}\"",$"name = \"{map}\""), + _=>text.Replace($"Map={slug}",$"Map={map}").Replace(slug,mod)}; + var bytes=new UTF8Encoding(false).GetBytes(text);to.Write(bytes);} + else from.CopyTo(to); + } + foreach(var folder in new[]{$"{mod}/common/",$"{mod}/common/media/",$"{mod}/common/media/maps/"})if(written.Add(folder))output.CreateEntry(folder); + } + /// <summary>Extracts an export archive into a mods folder (normally Zomboid/mods). A folder the editor did not create is never replaced.</summary> + public static string Install(Stream archive,string modsDirectory){ + using var zip=new ZipArchive(archive,ZipArchiveMode.Read,true);var mod=zip.Entries.Select(e=>e.FullName.Split('/')[0]).FirstOrDefault(s=>s.Length>0)??throw new ArgumentException("The export archive is empty."); + if(!SafeName.IsMatch(mod))throw new ArgumentException("The export archive has an unsafe mod folder name."); + var root=Path.GetFullPath(modsDirectory);Directory.CreateDirectory(root);var target=Path.GetFullPath(Path.Combine(root,mod));var staging=target+".installing"; + if(Directory.Exists(target)&&!File.Exists(Path.Combine(target,InstallMarker)))throw new ArgumentException($"{target} already exists and was not created by SimplePZMapmaker. Rename the mod id or remove that folder yourself."); + if(Directory.Exists(staging))Directory.Delete(staging,true); + try{ + foreach(var entry in zip.Entries){var relative=entry.FullName[mod.Length..].TrimStart('/');var path=Path.GetFullPath(Path.Combine(staging,relative));if(!path.StartsWith(staging+Path.DirectorySeparatorChar,StringComparison.OrdinalIgnoreCase)&&path!=staging)throw new ArgumentException("The export archive contains a path outside its mod folder."); + if(entry.FullName.EndsWith('/')){Directory.CreateDirectory(path);continue;}Directory.CreateDirectory(Path.GetDirectoryName(path)!);entry.ExtractToFile(path,true);} + File.WriteAllText(Path.Combine(staging,InstallMarker),"Installed by SimplePZMapmaker. A later install of the same mod id replaces this folder.\n"); + if(Directory.Exists(target))Directory.Delete(target,true);Directory.Move(staging,target); + }finally{if(Directory.Exists(staging))Directory.Delete(staging,true);} + return target; + } + /// <summary>Zomboid/mods of the current user, or PZMAP_MODS_DIR when set.</summary> + public static string DefaultModsDirectory()=>Environment.GetEnvironmentVariable("PZMAP_MODS_DIR") is {Length:>0} configured?configured:Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),"Zomboid","mods"); +} diff --git a/src/SimplePZMapmaker.Core/Export/NativeStreamedWorldExporter.cs b/src/SimplePZMapmaker.Core/Export/NativeStreamedWorldExporter.cs index d42c9b2..37bfac4 100644 --- a/src/SimplePZMapmaker.Core/Export/NativeStreamedWorldExporter.cs +++ b/src/SimplePZMapmaker.Core/Export/NativeStreamedWorldExporter.cs @@ -41,14 +41,14 @@ async Task WriteSection(StreamedWorldSection section){ foreach(var dependency in ResourceDependencies.ForProject(p))dependencies.Add(dependency); foreach(var model in StaticModelExporter.Gather(p).Where(m=>m.X>=p.WorldX*256&&m.Y>=p.WorldY*256&&m.X<p.WorldX*256+p.Width&&m.Y<p.WorldY*256+p.Height)){if(modelIds.Add((model.Placement.Id,model.X,model.Y,model.Z)))models.Add(model);if(models.Count>100000)throw new ArgumentException("World static models exceed 100,000 placements.");} using(var entry=zip.CreateEntry(slug+"/source/sections/"+section.Id+".pzmap.json",CompressionLevel.Fastest).Open())await source.CopySectionSourceAsync(section.Id,entry,token); - if(memberId is not null){using(var entry=zip.CreateEntry(slug+"/project.pzmap.json",CompressionLevel.Fastest).Open())await source.CopySectionSourceAsync(section.Id,entry,token);Text(slug+"/section-world-context.json",JsonSerializer.Serialize(new{format="SimplePZSectionContext",version=1,worldId=world.Id,worldName=world.Name,memberId,sections=source.Sections,world.Buildings,world.LinearFeatures,world.Fences,world.FenceStyles,world.GateStyles,world.GeoReference},NativeMapExporter.JsonOptions));} + if(memberId is not null){using(var entry=zip.CreateEntry(slug+"/project.pzmap.json",CompressionLevel.Fastest).Open())await source.CopySectionSourceAsync(section.Id,entry,token);Text(slug+"/section-world-context.json",JsonSerializer.Serialize(new{format="SimplePZSectionContext",version=1,worldId=world.Id,worldName=world.Name,memberId,sections=source.Sections,world.Buildings,world.LinearFeatures,world.Fences,world.FenceStyles,world.GateStyles,world.Decorations,world.GeoReference},NativeMapExporter.JsonOptions));} } zones.Append("}\n");Text(root+"objects.lua",zones.ToString());Text(root+"worldmap.xml",new XDocument(atlas).ToString());using(var binary=zip.CreateEntry(root+"worldmap.xml.bin",CompressionLevel.Fastest).Open()){var bytes=NativeWorldMapBinary.Write(atlas);binary.Write(bytes);} if(spawns.Count==0&&memberId is null)throw new ArgumentException("The world needs at least one dry-land spawn.");Text(root+"spawnpoints.lua","function SpawnPoints()\n local points = {\n"+string.Join(",\n",spawns)+"\n }\n return { unemployed = points }\nend\n");Text(root+"spawnregions.lua",$"function SpawnRegions()\n return {{ {{ name = \"{slug}\", file = \"media/maps/{slug}/spawnpoints.lua\" }} }}\nend\n"); StaticModelExporter.Write(zip,slug,models);var require=dependencies.Count>0?"require="+string.Join(',',dependencies)+"\n":"";Text(slug+"/42/mod.info",$"name={world.Name}\nid={slug}\nversionMin=42.0\ndescription=Streamed world assembled from {sections.Count} sections.\n{require}"); var minX=sections.Min(s=>s.WorldX);var minY=sections.Min(s=>s.WorldY);var width=(sections.Max(s=>s.WorldX+s.Width/256)-minX)*256;var height=(sections.Max(s=>s.WorldY+s.Height/256)-minY)*256;Text(root+"map.info",$"title={world.Name}\nfixed2x=true\ndescription=Stitched area {width} x {height}.\nzoomX={minX*256+width/2}\nzoomY={minY*256+height/2}\nzoomS=10\n"); using(var index=zip.CreateEntry(slug+"/source/world.pzworld.index.json",CompressionLevel.Fastest).Open())await JsonSerializer.SerializeAsync(index,source.AuthoringIndex,NativeMapExporter.JsonOptions,token);using(var metadata=zip.CreateEntry(slug+"/source/world.metadata.json",CompressionLevel.Fastest).Open())await source.CopyMetadataSourceAsync(metadata,token); - Text(slug+"/source/README.txt","The index pins original immutable content hashes. world.metadata.json preserves shared buildings, paths, fences, geographic evidence and elevation. Section IDs map to sections/<id>.pzmap.json. Reconstruct SimplePZWorld using metadata Version/Id/Name/Buildings/LinearFeatures/Fences/FenceStyles/GateStyles/GeoReference/Elevation and Maps=[{id,project:section JSON}]. A selected section export contains only its section source and retains the complete layout as context; export all sections to reconstruct the complete world.\n"); + Text(slug+"/source/README.txt","The index pins original immutable content hashes. world.metadata.json preserves shared buildings, paths, fences, decorations, geographic evidence and elevation. Section IDs map to sections/<id>.pzmap.json. Reconstruct SimplePZWorld using metadata Version/Id/Name/Buildings/LinearFeatures/Fences/FenceStyles/GateStyles/Decorations/GeoReference/Elevation and Maps=[{id,project:section JSON}]. A selected section export contains only its section source and retains the complete layout as context; export all sections to reconstruct the complete world.\n"); if(world.Elevation is {} survey){Text(slug+"/elevation.asc",survey.ToAscii());attributions.Add("Elevation: "+survey.Source);attributions.Add(survey.License);} Text(slug+"/ATTRIBUTION.txt",string.Join('\n',attributions.Prepend("Created with SimplePZMapmaker. Sparse streamed Build 42 world."))+"\n");Text(slug+"/INSTALL.txt",$"Extract {slug} into Zomboid/mods, enable the mod, and create a NEW world with the {world.Name} map. Sections share one native map. Original authoring data is reconstructible from source/world.pzworld.index.json, source/world.metadata.json and source/sections/*.pzmap.json. Game resources are not included. For a server: Map={slug}, Mods={slug}.\n"); }token.ThrowIfCancellationRequested();context?.Complete(); diff --git a/src/SimplePZMapmaker.Core/Export/NativeWorldExporter.cs b/src/SimplePZMapmaker.Core/Export/NativeWorldExporter.cs index f499ed0..dc61a02 100644 --- a/src/SimplePZMapmaker.Core/Export/NativeWorldExporter.cs +++ b/src/SimplePZMapmaker.Core/Export/NativeWorldExporter.cs @@ -7,7 +7,7 @@ namespace SimplePZMapmaker.Core.Export; public static class NativeWorldExporter { public static void ExportSectionToStream(WorldProject world,string memberId,Stream output,CancellationToken token=default)=>ExportSectionWithProgress(world,memberId,output,null,token); - public static void ExportSectionWithProgress(WorldProject world,string memberId,Stream output,NativeExportContext? context,CancellationToken token=default){WorldAssembler.Validate(world);var member=world.Maps.SingleOrDefault(m=>m.Id==memberId)??throw new ArgumentException("Section not found.");var p=WorldAssembler.ProjectSection(world,member);context?.Begin(1,p.Width/256*(p.Height/256));context?.Section(memberId);var sharedContext=new{format="SimplePZSectionContext",version=1,worldId=world.Id,worldName=world.Name,memberId,sections=world.Maps.Select(m=>new{m.Id,m.Project.WorldX,m.Project.WorldY,m.Project.Width,m.Project.Height}),buildings=world.Buildings.Where(b=>b.X<p.WorldX*256+p.Width&&b.Y<p.WorldY*256+p.Height&&b.X+b.Width>=p.WorldX*256&&b.Y+b.Height>=p.WorldY*256),world.GeoReference,world.LinearFeatures,world.Fences,world.FenceStyles,world.GateStyles,note="Native cells include clipped shared buildings. project.pzmap.json retains only the independently editable local section; shared buildings remain authoritative in the original world workspace. Export the complete world for a single combined mod."};NativeMapExporter.WriteValidatedWorldSection(p,output,token,member.Project,sharedContext,"SimplePZSection_"+Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(world.Id+":"+memberId)))[..12].ToLowerInvariant(),context);context?.SectionDone();context?.Complete();} + public static void ExportSectionWithProgress(WorldProject world,string memberId,Stream output,NativeExportContext? context,CancellationToken token=default){WorldAssembler.Validate(world);var member=world.Maps.SingleOrDefault(m=>m.Id==memberId)??throw new ArgumentException("Section not found.");var p=WorldAssembler.ProjectSection(world,member);context?.Begin(1,p.Width/256*(p.Height/256));context?.Section(memberId);var sharedContext=new{format="SimplePZSectionContext",version=1,worldId=world.Id,worldName=world.Name,memberId,sections=world.Maps.Select(m=>new{m.Id,m.Project.WorldX,m.Project.WorldY,m.Project.Width,m.Project.Height}),buildings=world.Buildings.Where(b=>b.X<p.WorldX*256+p.Width&&b.Y<p.WorldY*256+p.Height&&b.X+b.Width>=p.WorldX*256&&b.Y+b.Height>=p.WorldY*256),world.GeoReference,world.LinearFeatures,world.Fences,world.FenceStyles,world.GateStyles,decorations=WorldAssembler.DecorationsFor(world.Decorations,member.Project).Select(d=>world.Decorations.Single(s=>s.Id==d.Id)),note="Native cells include clipped shared buildings. project.pzmap.json retains only the independently editable local section; shared buildings remain authoritative in the original world workspace. Export the complete world for a single combined mod."};NativeMapExporter.WriteValidatedWorldSection(p,output,token,member.Project,sharedContext,"SimplePZSection_"+Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(world.Id+":"+memberId)))[..12].ToLowerInvariant(),context);context?.SectionDone();context?.Complete();} public static byte[] Export(WorldProject world,CancellationToken token=default){ using var output=new MemoryStream();ExportToStream(world,output,token);return output.ToArray(); } diff --git a/src/SimplePZMapmaker.Core/Export/TileComposer.Design.cs b/src/SimplePZMapmaker.Core/Export/TileComposer.Design.cs index d7d799e..b659acd 100644 --- a/src/SimplePZMapmaker.Core/Export/TileComposer.Design.cs +++ b/src/SimplePZMapmaker.Core/Export/TileComposer.Design.cs @@ -3,6 +3,8 @@ internal sealed partial class TileComposer { private readonly Dictionary<int,List<string>> layers=[]; private readonly Dictionary<int,int> upperRoomIds=[]; + // Edges of an attached pair whose wall belongs to the partner building (AttachedBuildings.SuppressedEdges). + private HashSet<(string Building,int X,int Y,int Z,string Edge)> suppressedEdges=[]; internal int MaxLevel {get;private set;}=1; internal int MinLevel {get;private set;} internal int RoomAt(int x,int y,int z)=>z==0?RoomIds[y*256+x]:upperRoomIds.GetValueOrDefault(z*65536+y*256+x,-1); @@ -28,7 +30,7 @@ private void ComposeDesign(Building b){ for(var i=0;i<floor.Cells.Length;i++)if(floor.Cells[i]==room.Key[0]&&InCell(x0+i%b.Width,y0+i/b.Width)){var key=(y0+i/b.Width)*256+x0+i%b.Width;if(z==0)RoomIds[key]=id;else upperRoomIds[z*65536+key]=id;} } foreach(var e in floor.Edges){ - if(e.Kind=="open")continue;var north=e.Edge=="n";var tile=(style?.WallBase??wallBase)+(e.Kind=="door"?10:e.Kind=="window"?8:0)+(north?1:0); + if(e.Kind=="open"||suppressedEdges.Contains((b.Id,b.X+e.X,b.Y+e.Y,z,e.Edge)))continue;var north=e.Edge=="n";var tile=(style?.WallBase??wallBase)+(e.Kind=="door"?10:e.Kind=="window"?8:0)+(north?1:0); var a=BuildingDesigner.Cell(b,floor,e.X,e.Y);var other=north?BuildingDesigner.Cell(b,floor,e.X,e.Y-1):BuildingDesigner.Cell(b,floor,e.X-1,e.Y);var inside=a=='.'?other:a;var balcony=(a=='.'||other=='.')&&floor.Rooms.GetValueOrDefault(inside.ToString())=="balcony"; if(balcony&&e.Kind=="wall"){AddAt(x0+e.X,y0+e.Y,z,e.WallTile??$"carpentry_02_{(north?101:100)}",north?(byte)2:(byte)4);continue;} AddAt(x0+e.X,y0+e.Y,z,e.WallTile??$"{style?.Wall??"walls_exterior_house_01"}_{tile}",north?(byte)2:(byte)4); diff --git a/src/SimplePZMapmaker.Core/Export/TileComposer.cs b/src/SimplePZMapmaker.Core/Export/TileComposer.cs index 09e6c64..92ac8d5 100644 --- a/src/SimplePZMapmaker.Core/Export/TileComposer.cs +++ b/src/SimplePZMapmaker.Core/Export/TileComposer.cs @@ -24,7 +24,9 @@ internal TileComposer(MapProject p,int cellX,int cellY,InfrastructureGeometry.Ge var terrain=p.Terrain[(oy+y)*p.Width+ox+x]; Navigation[y*256+x]=terrain==Terrain.Water?(byte)8:(byte)0; } - foreach(var b in p.Buildings.Where(b=>b.X<ox+256&&b.Y<oy+256&&b.X+b.Width>=ox&&b.Y+b.Height>=oy)){if(b.Design is null)Compose(b);else ComposeDesign(b);} + // The wall owner of an attached pair composes after its partner, so the shared wall lands on the partner's floor tile instead of being replaced by it. + suppressedEdges=AttachedBuildings.SuppressedEdges(p.Buildings);var yields=suppressedEdges.Select(e=>e.Building).ToHashSet(StringComparer.Ordinal); + foreach(var b in p.Buildings.OrderBy(b=>yields.Contains(b.Id)?0:1).Where(b=>b.X<ox+256&&b.Y<oy+256&&b.X+b.Width>=ox&&b.Y+b.Height>=oy)){if(b.Design is null)Compose(b);else ComposeDesign(b);} foreach(var d in p.Decorations)foreach(var part in d.Parts){var x=d.X+part.Dx-ox;var y=d.Y+part.Dy-oy;if(x>=0&&y>=0&&x<256&&y<256)Add(x,y,part.Tile,part.Flags);} if(p.Fences.Count>0){var blocked=FenceLines.Blocker(p);foreach(var piece in FenceLines.Compose(p.Fences,p.FenceStyles,p.GateStyles,256,256,-ox,-oy,(x,y)=>blocked(x+ox,y+oy)).Pieces)Add(piece.X,piece.Y,piece.Tile,piece.Flags);} ComposeInfrastructure(infrastructure); diff --git a/src/SimplePZMapmaker.Core/Export/TilesetBuilder.cs b/src/SimplePZMapmaker.Core/Export/TilesetBuilder.cs new file mode 100644 index 0000000..06c1c70 --- /dev/null +++ b/src/SimplePZMapmaker.Core/Export/TilesetBuilder.cs @@ -0,0 +1,90 @@ +using System.Buffers.Binary; +using System.IO.Compression; +using System.Text; +using System.Text.RegularExpressions; +namespace SimplePZMapmaker.Core.Export; + +/// <summary>One sprite of a custom tileset: a PNG frame (normally 128x256 for Build 42 2x tiles) and its tile properties.</summary> +public sealed record TilesetSprite(byte[] Png,Dictionary<string,string> Properties); +/// <summary>A tile mod: Build 42 reads media/texturepacks/<Tileset>.pack and media/<Tileset>.tiles registered in mod.info. +/// Tiles are named <Tileset>_<index>; FileNumber is the tiledef number (100-8190) that must not clash with other active mods.</summary> +public sealed record TilesetMod(string ModId,string Name,string Tileset,int FileNumber,List<TilesetSprite> Sprites); + +/// <summary>Builds a Build 42 tile mod from PNG frames, replacing an external make_fence_tiles step: a PZPK v1 atlas page and a tdef v1 definition.</summary> +public static class TilesetBuilder +{ + public const int Columns=8; + static readonly Regex Name=new("^[A-Za-z0-9_]{1,64}$",RegexOptions.CultureInvariant,TimeSpan.FromMilliseconds(100)); + public static void Validate(TilesetMod mod){ + NativeModPackage.Validate(new(mod.ModId,Name:mod.Name)); + if(!Name.IsMatch(mod.Tileset)||mod.Tileset.EndsWith('_'))throw new ArgumentException("Tileset name: 1-64 letters, digits or '_'; tiles are named <tileset>_<index>."); + if(mod.FileNumber is <100 or >8190)throw new ArgumentException("Tile definition number: 100-8190, unique among the active mods."); + if(mod.Sprites is null||mod.Sprites.Count is <1 or >1024)throw new ArgumentException("A tileset holds 1-1,024 sprites."); + foreach(var s in mod.Sprites)if(s.Properties is null||s.Properties.Count>256||s.Properties.Any(p=>p.Key.Length is <1 or >100||p.Key.Contains('\n')||p.Value is null||p.Value.Length>1000||p.Value.Contains('\n')))throw new ArgumentException("Tile properties: up to 256 single-line keys and values."); + } + /// <summary>Writes the tile mod archive: 42/mod.info, 42/media/texturepacks/<tileset>.pack, 42/media/<tileset>.tiles and an empty common/.</summary> + public static void Write(TilesetMod mod,Stream output){ + Validate(mod);var frames=mod.Sprites.Select(s=>Png.Decode(s.Png)).ToList(); + using var zip=new ZipArchive(output,ZipArchiveMode.Create,true); + void Entry(string path,byte[] bytes){using var s=zip.CreateEntry(path,CompressionLevel.Fastest).Open();s.Write(bytes);} + zip.CreateEntry(mod.ModId+"/common/"); + Entry($"{mod.ModId}/42/mod.info",Encoding.UTF8.GetBytes($"name={mod.Name}\nid={mod.ModId}\nversionMin=42.0\ndescription=Tiles {mod.Tileset}_0..{mod.Sprites.Count-1} built with SimplePZMapmaker.\npack={mod.Tileset}\ntiledef={mod.Tileset} {mod.FileNumber}\n")); + Entry($"{mod.ModId}/42/media/texturepacks/{mod.Tileset}.pack",Pack(mod.Tileset,frames)); + Entry($"{mod.ModId}/42/media/{mod.Tileset}.tiles",Tiles(mod.Tileset,mod.Sprites.Select(s=>s.Properties).ToList())); + } + /// <summary>PZPK version 1 with one page: sprites are trimmed to their opaque bounds and shelf-packed into one PNG atlas.</summary> + public static byte[] Pack(string tileset,IReadOnlyList<Png.Image> frames){ + var trimmed=frames.Select((f,i)=>(Index:i,Frame:f,Box:f.OpaqueBounds())).ToList(); + const int atlasWidth=2048;var placed=new (int X,int Y)[frames.Count];int x=0,y=0,shelf=0; + foreach(var t in trimmed.OrderByDescending(t=>t.Box.Height)){var (w,h)=(t.Box.Width,t.Box.Height);if(w>atlasWidth)throw new ArgumentException("A sprite is wider than the 2,048-pixel atlas.");if(x+w>atlasWidth){x=0;y+=shelf+1;shelf=0;}placed[t.Index]=(x,y);x+=w+1;shelf=Math.Max(shelf,h);} + var height=Math.Max(1,y+shelf);if(height>8192)throw new ArgumentException("The sprites do not fit one 2048x8192 atlas page; split the tileset."); + var atlas=new Png.Image(atlasWidth,height,new byte[atlasWidth*height*4]); + foreach(var t in trimmed){var (px,py)=placed[t.Index];for(var row=0;row<t.Box.Height;row++)Array.Copy(t.Frame.Rgba,((t.Box.Y+row)*t.Frame.Width+t.Box.X)*4,atlas.Rgba,((py+row)*atlasWidth+px)*4,t.Box.Width*4);} + var png=Png.Encode(atlas);using var s=new MemoryStream();using var w2=new BinaryWriter(s,Encoding.ASCII); + void Text(string text){w2.Write(text.Length);w2.Write(Encoding.ASCII.GetBytes(text));} + w2.Write("PZPK"u8.ToArray());w2.Write(1);w2.Write(1);Text(tileset+"0");w2.Write(frames.Count);w2.Write(1); + foreach(var t in trimmed){var (px,py)=placed[t.Index];Text($"{tileset}_{t.Index}");foreach(var v in new[]{px,py,t.Box.Width,t.Box.Height,t.Box.X,t.Box.Y,t.Frame.Width,t.Frame.Height})w2.Write(v);} + w2.Write(png.Length);w2.Write(png);w2.Flush();return s.ToArray(); + } + /// <summary>tdef version 1 with one tileset of eight columns; the tileset index inside the file is 0.</summary> + public static byte[] Tiles(string tileset,IReadOnlyList<Dictionary<string,string>> properties){ + using var s=new MemoryStream();using var w=new BinaryWriter(s,Encoding.Latin1);void Line(string text){w.Write(Encoding.Latin1.GetBytes(text));w.Write((byte)'\n');} + var rows=(properties.Count+Columns-1)/Columns;w.Write("tdef"u8.ToArray());w.Write(1);w.Write(1);Line(tileset);Line(tileset+".png");w.Write(Columns);w.Write(rows);w.Write(0);w.Write(properties.Count); + foreach(var p in properties){w.Write(p.Count);foreach(var (key,value) in p.OrderBy(p=>p.Key,StringComparer.Ordinal)){Line(key);Line(value);}} + w.Flush();return s.ToArray(); + } +} + +/// <summary>Minimal PNG codec for tile frames: 8-bit RGBA or RGB, non-interlaced, all five scanline filters. Writes RGBA with filter 0.</summary> +public static class Png +{ + public sealed record Image(int Width,int Height,byte[] Rgba) + { + /// <summary>The smallest rectangle holding every pixel with alpha above zero (1x1 at the origin for an empty frame).</summary> + public (int X,int Y,int Width,int Height) OpaqueBounds(){int minX=Width,minY=Height,maxX=-1,maxY=-1;for(var y=0;y<Height;y++)for(var x=0;x<Width;x++)if(Rgba[(y*Width+x)*4+3]!=0){minX=Math.Min(minX,x);maxX=Math.Max(maxX,x);minY=Math.Min(minY,y);maxY=Math.Max(maxY,y);}return maxX<0?(0,0,1,1):(minX,minY,maxX-minX+1,maxY-minY+1);} + } + static readonly byte[] Signature=[137,80,78,71,13,10,26,10]; + public static Image Decode(byte[] png){ + if(png.Length<33||!png.AsSpan(0,8).SequenceEqual(Signature))throw new ArgumentException("A tile frame is not a PNG file."); + int width=0,height=0,channels=0;using var data=new MemoryStream();var at=8; + while(at+12<=png.Length){var length=BinaryPrimitives.ReadInt32BigEndian(png.AsSpan(at));var type=Encoding.ASCII.GetString(png,at+4,4);if(length<0||at+12+length>png.Length)throw new ArgumentException("A tile frame PNG is truncated.");var body=png.AsSpan(at+8,length); + if(type=="IHDR"){width=BinaryPrimitives.ReadInt32BigEndian(body);height=BinaryPrimitives.ReadInt32BigEndian(body[4..]);if(body[8]!=8||body[9] is not(2 or 6)||body[12]!=0)throw new ArgumentException("Tile frames must be 8-bit RGBA or RGB PNGs without interlacing.");channels=body[9]==6?4:3;} + else if(type=="IDAT")data.Write(body);else if(type=="IEND")break;at+=12+length;} + if(width is <1 or >4096||height is <1 or >4096||channels==0)throw new ArgumentException("A tile frame PNG must be 1-4096 pixels on each side."); + data.Position=0;using var inflate=new ZLibStream(data,CompressionMode.Decompress);var stride=width*channels;var raw=new byte[(stride+1)*height];var read=0;while(read<raw.Length){var n=inflate.Read(raw,read,raw.Length-read);if(n==0)throw new ArgumentException("A tile frame PNG has too little image data.");read+=n;} + var pixels=new byte[stride*height]; + for(var y=0;y<height;y++){var filter=raw[y*(stride+1)];for(var x=0;x<stride;x++){var value=raw[y*(stride+1)+1+x];int a=x>=channels?pixels[y*stride+x-channels]:0,b=y>0?pixels[(y-1)*stride+x]:0,c=x>=channels&&y>0?pixels[(y-1)*stride+x-channels]:0; + pixels[y*stride+x]=(byte)(filter switch{0=>value,1=>value+a,2=>value+b,3=>value+(a+b)/2,4=>value+Paeth(a,b,c),_=>throw new ArgumentException("A tile frame PNG uses an unknown filter.")});}} + if(channels==4)return new(width,height,pixels);var rgba=new byte[width*height*4];for(var i=0;i<width*height;i++){rgba[i*4]=pixels[i*3];rgba[i*4+1]=pixels[i*3+1];rgba[i*4+2]=pixels[i*3+2];rgba[i*4+3]=255;}return new(width,height,rgba); + } + static int Paeth(int a,int b,int c){var p=a+b-c;var pa=Math.Abs(p-a);var pb=Math.Abs(p-b);var pc=Math.Abs(p-c);return pa<=pb&&pa<=pc?a:pb<=pc?b:c;} + public static byte[] Encode(Image image){ + using var output=new MemoryStream();output.Write(Signature); + void Chunk(string type,byte[] body){Span<byte> length=stackalloc byte[4];BinaryPrimitives.WriteInt32BigEndian(length,body.Length);output.Write(length);var typed=Encoding.ASCII.GetBytes(type).Concat(body).ToArray();output.Write(typed);BinaryPrimitives.WriteUInt32BigEndian(length,Crc(typed));output.Write(length);} + var header=new byte[13];BinaryPrimitives.WriteInt32BigEndian(header,image.Width);BinaryPrimitives.WriteInt32BigEndian(header.AsSpan(4),image.Height);header[8]=8;header[9]=6;Chunk("IHDR",header); + using(var compressed=new MemoryStream()){using(var deflate=new ZLibStream(compressed,CompressionLevel.Optimal,true))for(var y=0;y<image.Height;y++){deflate.WriteByte(0);deflate.Write(image.Rgba,y*image.Width*4,image.Width*4);}Chunk("IDAT",compressed.ToArray());} + Chunk("IEND",[]);return output.ToArray(); + } + static readonly uint[] CrcTable=Enumerable.Range(0,256).Select(n=>{var c=(uint)n;for(var k=0;k<8;k++)c=(c&1)!=0?0xEDB88320u^(c>>1):c>>1;return c;}).ToArray(); + static uint Crc(byte[] bytes){var c=0xFFFFFFFFu;foreach(var b in bytes)c=CrcTable[(c^b)&0xFF]^(c>>8);return c^0xFFFFFFFFu;} +} diff --git a/src/SimplePZMapmaker.Core/GeographicImporter.Appearance.cs b/src/SimplePZMapmaker.Core/GeographicImporter.Appearance.cs new file mode 100644 index 0000000..915881a --- /dev/null +++ b/src/SimplePZMapmaker.Core/GeographicImporter.Appearance.cs @@ -0,0 +1,52 @@ +using System.Globalization; +namespace SimplePZMapmaker.Core; + +/// <summary>Building appearance tags of the transfer contract (docs/TRANSFER-CONTRACT.md, section 5): +/// pz:kind picks the building kind, pz:style the interior style, roof:colour the roof tile set, +/// pz:wall the exterior wall set and pz:trim the window surround overlay.</summary> +public static partial class GeographicImporter +{ + /// <summary>pz:* keys the importer understands; any other pz:* key is kept with the source and reported once.</summary> + public static readonly HashSet<string> KnownPzKeys=["pz:wall","pz:trim","pz:style","pz:kind","pz:attached","pz:door-to","pz:fence","pz:posts","pz:gates","pz:zone","pz:vehicles","pz:direction"]; + /// <summary>Average opaque colour of the first sprite of each Build 42 roofs_30_NN set, measured from the installed game.</summary> + static readonly (string Tileset,int R,int G,int B)[] RoofSwatches=[("roofs_30_01",55,63,70),("roofs_30_02",70,58,55),("roofs_30_03",211,209,209),("roofs_30_04",57,59,63),("roofs_30_05",48,60,45),("roofs_30_06",93,91,84),("roofs_30_07",121,95,79),("roofs_30_08",80,93,80),("roofs_30_09",131,83,88),("roofs_30_10",76,44,34)]; + static readonly Dictionary<string,string> RoofColourNames=new(StringComparer.OrdinalIgnoreCase){["grey"]="roofs_30_04",["gray"]="roofs_30_04",["black"]="roofs_30_04",["red"]="roofs_30_09",["maroon"]="roofs_30_09",["blue"]="roofs_30_01",["green"]="roofs_30_05",["brown"]="roofs_30_07",["white"]="roofs_30_03",["silver"]="roofs_30_06"}; + + /// <summary>Building kind named by pz:kind, or null when absent or unknown.</summary> + public static string? KindFromTag(IReadOnlyDictionary<string,string> tags)=>tags.GetValueOrDefault("pz:kind") switch{ + "house" or "bathhouse"=>"house","shed" or "toilet" or "greenhouse"=>"warehouse","garage"=>"garage",_=>null}; + + /// <summary>roof:colour as a named colour or #rrggbb; a hex colour takes the nearest measured roof set.</summary> + public static string? RoofTilesetFor(string? colour){ + if(string.IsNullOrWhiteSpace(colour))return null;colour=colour.Trim();if(RoofColourNames.TryGetValue(colour,out var named))return named; + if(colour is ['#',_,_,_,_,_,_]&&int.TryParse(colour[1..],NumberStyles.HexNumber,CultureInfo.InvariantCulture,out var rgb)){var (r,g,b)=(rgb>>16&255,rgb>>8&255,rgb&255);return RoofSwatches.MinBy(s=>(s.R-r)*(s.R-r)+(s.G-g)*(s.G-g)+(s.B-b)*(s.B-b)).Tileset;} + return null; + } + + /// <summary>The first tile of an exterior wall set: "set:<tile>", a bare tile id, or the ID of a palette whose first wall resource starts the set.</summary> + public static (string Prefix,int Base)? WallSetFor(string? value,IReadOnlyList<ResourcePalette>? palettes){ + if(string.IsNullOrWhiteSpace(value))return null;var tile=value.StartsWith("set:",StringComparison.Ordinal)?value[4..]:value; + if(palettes?.FirstOrDefault(p=>p.Id==tile)?.Resources.FirstOrDefault(r=>r.Role=="wall")?.Id is {} first)tile=first; + if(!VisualStyles.TileId(tile))return null;var split=tile.LastIndexOf('_');return(tile[..split],int.Parse(tile[(split+1)..],CultureInfo.InvariantCulture)); + } + + /// <summary>Applies the appearance tags to a building whose final footprint and edges are already set. Unusable values are reported through <paramref name="problem"/>.</summary> + public static void ApplyAppearanceTags(Building b,IReadOnlyDictionary<string,string> tags,IReadOnlyList<ResourcePalette>? palettes,Action<string> problem){ + if(b.Design is not {} d)return; + if(tags.TryGetValue("pz:style",out var style)){if(VisualStyles.All.ContainsKey(style))VisualStyles.Apply(b,style);else problem($"pz:style '{style}' is not a known building style");} + if(tags.GetValueOrDefault("pz:kind") is "bathhouse" or "toilet")foreach(var f in d.Floors.Where(f=>f.Rooms.Count(r=>r.Key!="0")==1))f.Rooms[f.Rooms.Keys.Single(k=>k!="0")]="bathroom"; + if(tags.TryGetValue("roof:colour",out var colour)){if(RoofTilesetFor(colour) is {} roof)d.RoofTileset=roof;else problem($"roof:colour '{colour}' has no matching roof set");} + (string Prefix,int Base)? wall=null;if(tags.TryGetValue("pz:wall",out var wallTag)&&(wall=WallSetFor(wallTag,palettes)) is null)problem($"pz:wall '{wallTag}' is not a wall set"); + (string N,string W)? trim=null;if(tags.TryGetValue("pz:trim",out var trimTag)){var pair=trimTag.Split(':');if(pair.Length==2&&VisualStyles.TileId(pair[0])&&VisualStyles.TileId(pair[1]))trim=(pair[0],pair[1]);else problem($"pz:trim '{trimTag}' is not a '<north tile>:<west tile>' pair");} + if(wall is null&&trim is null)return; + foreach(var f in d.Floors)foreach(var e in f.Edges){ + var north=e.Edge=="n";var a=BuildingDesigner.Cell(b,f,e.X,e.Y);var other=north?BuildingDesigner.Cell(b,f,e.X,e.Y-1):BuildingDesigner.Cell(b,f,e.X-1,e.Y); + if(e.Kind=="open"||(a=='.')==(other=='.')||f.Rooms.GetValueOrDefault((a=='.'?other:a).ToString())=="balcony")continue; + if(wall is {} set)e.WallTile=$"{set.Prefix}_{set.Base+(e.Kind=="door"?10:e.Kind=="window"?8:0)+(north?1:0)}"; + if(trim is {} t&&e.Kind=="window")e.OverlayTile=north?t.N:t.W; + } + } + + /// <summary>One report line per unknown pz:* key across the imported features.</summary> + static IEnumerable<string> UnknownPzKeys(IEnumerable<IReadOnlyDictionary<string,string>> tags)=>tags.SelectMany(t=>t.Keys).Where(k=>k.StartsWith("pz:",StringComparison.Ordinal)&&!KnownPzKeys.Contains(k)).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).Take(32).Select(k=>$"Unknown source key {k} was kept with the source data and not applied."); +} diff --git a/src/SimplePZMapmaker.Core/GeographicImporter.Fences.cs b/src/SimplePZMapmaker.Core/GeographicImporter.Fences.cs index d60eb3a..e252648 100644 --- a/src/SimplePZMapmaker.Core/GeographicImporter.Fences.cs +++ b/src/SimplePZMapmaker.Core/GeographicImporter.Fences.cs @@ -84,6 +84,44 @@ static void ImportFences(MapProject p,List<Feature> transformed){ p.Fences=total;p.FenceStyles=styles;p.GateStyles=gates; p.ImportSummary?.Notes.Add($"Fence lines from barrier=* paths: {lines.Count}" +(skippedLines>0?$", {skippedLines} outside the map or beyond limits":"")+(droppedGates>0?$", {droppedGates} gates dropped (unknown placement)":"")+". Diagonal fences follow cell edges as staircases."); } + /// <summary>Moves imported fence lines whose edges reach more than one section into the world fence layer (absolute tiles), once. + /// Every section imports the whole barrier way, so a crossing line arrives as one copy per section; gates found near each + /// section are merged. Authored section lines (no source) are never moved.</summary> + public static int PromoteSharedFences(WorldProject world,List<string> warnings){ + var copies=new Dictionary<string,List<(WorldMapMember Member,FenceLine Line)>>(StringComparer.Ordinal); + foreach(var m in world.Maps)foreach(var f in m.Project.Fences.Where(f=>f.SourceId is not null)){if(!copies.TryGetValue(f.Id,out var list))copies[f.Id]=list=[];list.Add((m,f));} + var promoted=0;var conflicts=0;var droppedGates=0; + foreach(var (id,list) in copies){ + var (member,line)=list[0];var ox=member.Project.WorldX*256;var oy=member.Project.WorldY*256; + var points=line.Points.Select(v=>new FencePoint(v.X+ox,v.Y+oy)).ToList(); + var owners=EdgeCells(points).Select(c=>WorldAssembler.MemberAt(world,c.X,c.Y)?.Id).Where(o=>o is not null).Distinct().ToList(); + // A line owned by one section keeps only that section's copy; neighbours that merely touch its end hold no edge of it. + if(owners.Count==1){if(list.Any(c=>c.Member.Id==owners[0]))foreach(var (m,_) in list.Where(c=>c.Member.Id!=owners[0]))m.Project.Fences.RemoveAll(f=>f.Id==id);continue;} + if(owners.Count==0)continue; + if(world.Fences.Any(f=>f.Id==id)){conflicts++;continue;} + var style=member.Project.FenceStyles.Single(s=>s.Id==line.Style); + bool Same<T>(T a,T b)=>JsonSerializer.Serialize(a)==JsonSerializer.Serialize(b); + if(world.FenceStyles.FirstOrDefault(s=>s.Id==style.Id) is {} known&&!Same(known,style)){conflicts++;continue;} + var gates=new List<FenceGate>(); + foreach(var (copyMember,copy) in list){var shift=copy.Points.Select(v=>new FencePoint(v.X+copyMember.Project.WorldX*256,v.Y+copyMember.Project.WorldY*256)).SequenceEqual(points); + foreach(var g in copy.Gates){if(!shift||gates.Any(o=>o.At==g.At)){droppedGates+=shift?0:1;continue;}gates.Add(new(){At=g.At,Style=g.Style,OpensTo=g.OpensTo,Open=g.Open});}} + var gateStyles=gates.Select(g=>g.Style).Distinct().Select(g=>list.SelectMany(c=>c.Member.Project.GateStyles).First(s=>s.Id==g)).ToList(); + if(gateStyles.Any(g=>world.GateStyles.FirstOrDefault(s=>s.Id==g.Id) is {} known&&!Same(known,g))){conflicts++;continue;} + var shared=new FenceLine{Id=id,Style=line.Style,PostSpacing=line.PostSpacing,Points=points,Gates=gates.OrderBy(g=>g.At).ToList(),SourceId=line.SourceId,SourceTags=new(line.SourceTags),SourcePath=[..line.SourcePath]}; + var styles=world.FenceStyles.Any(s=>s.Id==style.Id)?world.FenceStyles:[..world.FenceStyles,style];var gateLayer=world.GateStyles.Concat(gateStyles.Where(g=>world.GateStyles.All(s=>s.Id!=g.Id))).ToList(); + try{FenceLines.Validate([..world.Fences,shared],styles,gateLayer,258048);}catch(ArgumentException){droppedGates+=shared.Gates.Count;shared.Gates=[];try{FenceLines.Validate([..world.Fences,shared],styles,gateLayer,258048);}catch(ArgumentException){conflicts++;continue;}} + world.Fences.Add(shared);world.FenceStyles=styles;world.GateStyles=gateLayer;foreach(var (m,_) in list)m.Project.Fences.RemoveAll(f=>f.Id==id);promoted++; + } + if(promoted>0)warnings.Add($"Fence lines crossing section boundaries moved to the shared world fence layer: {promoted}. Each is stored once in absolute tiles and drawn in every section it reaches."); + if(conflicts>0)warnings.Add($"Crossing fence lines kept in their sections because a shared style, ID or world fence limit conflicts: {conflicts}."); + if(droppedGates>0)warnings.Add($"Gates dropped while merging crossing fence lines: {droppedGates}."); + return promoted; + } + /// <summary>The cell holding each edge of a grid walk (south of a horizontal edge, east of a vertical one), as in <see cref="FenceLines.Compose"/>.</summary> + static IEnumerable<(int X,int Y)> EdgeCells(List<FencePoint> points){ + for(var s=1;s<points.Count;s++){var a=points[s-1];var b=points[s];var horizontal=a.Y==b.Y;var step=horizontal?Math.Sign(b.X-a.X):Math.Sign(b.Y-a.Y);var run=horizontal?Math.Abs(b.X-a.X):Math.Abs(b.Y-a.Y); + for(var i=0;i<run;i++){var vx=horizontal?a.X+i*step:a.X;var vy=horizontal?a.Y:a.Y+i*step;yield return(horizontal?Math.Min(vx,vx+step):vx,horizontal?vy:Math.Min(vy,vy+step));}} + } static (double X,double Y) PointAlong(List<(double X,double Y)> path,double distance){ for(var i=1;i<path.Count;i++){var a=path[i-1];var b=path[i];var length=Math.Sqrt(Math.Pow(b.X-a.X,2)+Math.Pow(b.Y-a.Y,2));if(distance<=length||i==path.Count-1){var t=length>0?Math.Clamp(distance/length,0,1):0;return(a.X+(b.X-a.X)*t,a.Y+(b.Y-a.Y)*t);}distance-=length;} return path[0];} diff --git a/src/SimplePZMapmaker.Core/GeographicImporter.TransferReport.cs b/src/SimplePZMapmaker.Core/GeographicImporter.TransferReport.cs new file mode 100644 index 0000000..5063d43 --- /dev/null +++ b/src/SimplePZMapmaker.Core/GeographicImporter.TransferReport.cs @@ -0,0 +1,44 @@ +namespace SimplePZMapmaker.Core; + +/// <summary>Where each source building and fence of a world import ended up (docs/TRANSFER-CONTRACT.md, section 3).</summary> +public sealed record TransferOutcome(int PlacedSection,int PlacedWorld,int Evidence,int Lost,int Duplicated); +public sealed record TransferReport(Dictionary<string,TransferOutcome> ByKind,List<string> Problems,Dictionary<string,int> EvidenceReasons) +{ + /// <summary>The seam check passes when no source feature is lost or placed more than once.</summary> + public bool Passed=>ByKind.Values.All(o=>o.Lost==0&&o.Duplicated==0); +} +public static partial class GeographicImporter +{ + /// <summary>Checks a geographic world against the source it was imported from: every building outline and barrier line must be + /// placed in exactly one section, placed once at world level, or kept as evidence with a reason.</summary> + public static TransferReport TransferCheck(string json,WorldProject world){ + if(world.GeoReference is not {} reference)throw new ArgumentException("The seam check needs a geographic world reference."); + using var document=Parse(json);var features=ReadFeatures(document.RootElement,out _);var projection=new GeographicProjection(reference,reference.OriginCellX,reference.OriginCellY); + (double X,double Y) Global((double X,double Y) point){var p=projection.Project(point);return(p.X+reference.OriginCellX*256,p.Y+reference.OriginCellY*256);} + var problems=new List<string>();var reasons=new Dictionary<string,int>(StringComparer.Ordinal);var byKind=new Dictionary<string,TransferOutcome>(StringComparer.Ordinal); + void Count(string kind,string id,int section,int shared,Func<string?> evidence){ + var o=byKind.GetValueOrDefault(kind)??new(0,0,0,0,0); + if(section+shared>1){o=o with{Duplicated=o.Duplicated+1};if(problems.Count<64)problems.Add($"duplicated {kind} {id}: {section} section and {shared} world placements");} + else if(section==1)o=o with{PlacedSection=o.PlacedSection+1}; + else if(shared==1)o=o with{PlacedWorld=o.PlacedWorld+1}; + else if(evidence() is {} reason){o=o with{Evidence=o.Evidence+1};reasons[reason]=reasons.GetValueOrDefault(reason)+1;} + else{o=o with{Lost=o.Lost+1};if(problems.Count<64)problems.Add($"lost {kind} {id}");} + byKind[kind]=o; + } + var sectionBuildings=world.Maps.SelectMany(m=>m.Project.Buildings).Where(b=>b.SourceId is not null).GroupBy(b=>b.SourceId!).ToDictionary(g=>g.Key,g=>g.Count()); + var sharedBuildings=world.Buildings.Where(b=>b.SourceId is not null).GroupBy(b=>b.SourceId!).ToDictionary(g=>g.Key,g=>g.Count()); + var skipped=world.Maps.Where(m=>m.Project.ImportSummary is not null).SelectMany(m=>m.Project.ImportSummary!.SkippedOutlines.Where(o=>o.SourceId is not null&&o.Reason!="Outside the map").Select(o=>(o.SourceId!,o.Reason))).GroupBy(o=>o.Item1).ToDictionary(g=>g.Key,g=>g.First().Item2); + var truncated=world.Maps.Any(m=>m.Project.ImportSummary?.SkippedOutlines.Count>=256); + foreach(var f in features.Where(f=>f.Polygon&&f.Tags.ContainsKey("building")&&f.Rings.Count>0)){ + var id=f.Tags.GetValueOrDefault("__pz_source")??"";var ring=f.Rings[0].Select(Global).ToList(); + var owners=ring.Select(p=>WorldAssembler.MemberAt(world,(int)Math.Floor(p.X),(int)Math.Floor(p.Y))?.Id).ToHashSet(); + Count("building",id,sectionBuildings.GetValueOrDefault(id),sharedBuildings.GetValueOrDefault(id),()=>skipped.TryGetValue(id,out var reason)?"skipped: "+reason:owners.All(o=>o is null)?"skipped: outside the world":owners.Count(o=>o is not null)>1||owners.Contains(null)?"skipped: crossing outline rejected at world level (water, overlap or access)":truncated?"skipped: listed only in a truncated section report":null); + } + foreach(var f in features.Where(f=>!f.Polygon&&FenceBarriers.Contains(f.Tags.GetValueOrDefault("barrier",""))&&f.Rings.Count>0)){ + var id=f.Tags.GetValueOrDefault("__pz_source")??"";var touches=f.Rings.SelectMany(r=>r).Select(Global).Any(p=>WorldAssembler.MemberAt(world,(int)Math.Floor(p.X),(int)Math.Floor(p.Y)) is not null); + var sections=world.Maps.Count(m=>m.Project.Fences.Any(l=>l.SourceId==id));var shared=world.Fences.Any(l=>l.SourceId==id)?1:0; + Count("fence",id,sections,shared,()=>touches?null:"skipped: outside the world"); + } + return new(byKind,problems,reasons); + } +} diff --git a/src/SimplePZMapmaker.Core/GeographicImporter.WorldBuildings.cs b/src/SimplePZMapmaker.Core/GeographicImporter.WorldBuildings.cs index 65c02f9..640d5c4 100644 --- a/src/SimplePZMapmaker.Core/GeographicImporter.WorldBuildings.cs +++ b/src/SimplePZMapmaker.Core/GeographicImporter.WorldBuildings.cs @@ -6,16 +6,16 @@ public static (int Added,int Skipped) ImportWorldBuildings(string json,WorldProj if(world.GeoReference is null)return(0,0);using var document=Parse(json);var features=ReadFeatures(document.RootElement,out _);var reference=world.GeoReference;var projection=new GeographicProjection(reference,reference.OriginCellX,reference.OriginCellY);var added=0;var skipped=0;var existing=world.Maps.SelectMany(m=>m.Project.Buildings).Concat(world.Buildings).Select(b=>b.SourceId).Where(s=>s is not null).ToHashSet(); foreach(var feature in features.Where(f=>f.Polygon&&f.Tags.ContainsKey("building"))){ token.ThrowIfCancellationRequested();var sourceId=feature.Tags.GetValueOrDefault("__pz_source");if(sourceId is not null&&existing.Contains(sourceId))continue; - var rings=feature.Rings.Select(r=>r.Select(point=>{var p=projection.Project(point);return(X:p.X+reference.OriginCellX*256,Y:p.Y+reference.OriginCellY*256);}).ToList()).ToList();var ring=rings[0];var x=(int)Math.Floor(ring.Min(p=>p.X));var y=(int)Math.Floor(ring.Min(p=>p.Y));var width=Math.Max(6,(int)Math.Ceiling(ring.Max(p=>p.X)-x));var height=Math.Max(6,(int)Math.Ceiling(ring.Max(p=>p.Y)-y));if(width>64||height>64)continue; + var rings=feature.Rings.Select(r=>r.Select(point=>{var p=projection.Project(point);return(X:p.X+reference.OriginCellX*256,Y:p.Y+reference.OriginCellY*256);}).ToList()).ToList();var ring=rings[0];var x=(int)Math.Floor(ring.Min(p=>p.X));var y=(int)Math.Floor(ring.Min(p=>p.Y));var width=Math.Max(BuildingDesigner.MinimumFootprint,(int)Math.Ceiling(ring.Max(p=>p.X)-x));var height=Math.Max(BuildingDesigner.MinimumFootprint,(int)Math.Ceiling(ring.Max(p=>p.Y)-y));if(width>64||height>64)continue; // shared when the footprint or the free line a section keeps around a building (MapGenerator.CanPlace) reaches another section; // a section rejects such an outline as outside its map, so it would be lost at the seam var owners=new[]{x-1,x,x+width}.SelectMany(cx=>new[]{y-1,y,y+height}.Select(cy=>WorldAssembler.MemberAt(world,cx,cy))).ToArray();if(new[]{owners[4],owners[5],owners[7],owners[8]}.Any(m=>m is null)||owners.Where(m=>m is not null).Select(m=>m!.Id).Distinct().Count()<2)continue; - var tags=feature.Tags;var sourceKind=tags.GetValueOrDefault("building","yes");var kind=tags.ContainsKey("shop")?"shop":sourceKind switch{"retail" or "commercial" or "train_station"=>"shop","apartments" or "residential"=>"apartments","school" or "college" or "kindergarten"=>"school","hospital"=>"clinic","hotel"=>"hotel","industrial"=>"industrial","warehouse" or "shed" or "service"=>"warehouse","garages" or "garage"=>"garage",_=>"house"}; + var tags=feature.Tags;var sourceKind=tags.GetValueOrDefault("building","yes");var kind=KindFromTag(tags)??(tags.ContainsKey("shop")?"shop":sourceKind switch{"retail" or "commercial" or "train_station"=>"shop","apartments" or "residential"=>"apartments","school" or "college" or "kindergarten"=>"school","hospital"=>"clinic","hotel"=>"hotel","industrial"=>"industrial","warehouse" or "shed" or "service"=>"warehouse","garages" or "garage"=>"garage",_=>"house"}); string? Tag(string name)=>tags.TryGetValue(name,out var value)?value[..Math.Min(100,value.Length)]:null; var b=new Building{X=x,Y=y,Width=width,Height=height,Kind=kind,Name=Tag("name")??BuildingDesigner.Names[kind],SourceId=sourceId,SourceKind=sourceKind,SourceLevels=Tag("building:levels"),SourceBasements=Tag("building:levels:underground"),SourceRoof=Tag("roof:shape")};var levels=kind is "apartments" or "school" or "clinic" or "hotel"?2:1; if(int.TryParse(b.SourceLevels,out var specified)&&specified>0){levels=Math.Clamp(specified,1,ZLevels.AboveGroundCount);b.LevelsOrigin="osm-levels";}else if(tags.TryGetValue("height",out var h)&&double.TryParse(h.Replace(" m",""),NumberStyles.Float,CultureInfo.InvariantCulture,out var metres)&&double.IsFinite(metres)&&metres>0){levels=Math.Clamp((int)Math.Min(ZLevels.AboveGroundCount,Math.Round(metres/3)),1,ZLevels.AboveGroundCount);b.LevelsOrigin="osm-height";} - var basements=int.TryParse(b.SourceBasements,out var depth)?Math.Clamp(depth,0,ZLevels.BasementCount):0; - try{b.Design=BuildingDesigner.Generate(b,levels,unchecked(seed^x*73856093^y*19349663),"rect",basements);if(b.SourceRoof is "flat" or "gabled" or "shed" or "hipped")b.Design.Roof=b.SourceRoof;else if(b.SourceRoof=="skillion")b.Design.Roof="shed";if(RoofGeometry.SelectSafeAutomaticRoof(b))warnings.Add($"Roof for shared building {b.Name} was adapted to fit z={ZLevels.Max}; original source tags were preserved.");if(specified>ZLevels.AboveGroundCount||depth>ZLevels.BasementCount)warnings.Add($"Source floor counts for shared building {b.Name} were limited to 32 above-ground and 32 basement levels.");if(!ImportedBuildingGeometry.Apply(b,rings,out var footprintReason))throw new ArgumentException(footprintReason);b.SourceOutline=ImportedBuildingGeometry.Capture(feature.Rings,out var simplified);b.SourceOutlineSimplified=simplified;world.Buildings.Add(b);WorldAssembler.Validate(world);added++;if(sourceId is not null)existing.Add(sourceId);}catch(ArgumentException){world.Buildings.Remove(b);skipped++;} + var basements=int.TryParse(b.SourceBasements,out var depth)?Math.Clamp(depth,0,ZLevels.BasementCount):0;if(levels+basements>1&&!BuildingDesigner.HoldsStairs(width,height)){levels=1;basements=0;warnings.Add($"Shared building {b.Name} is under 6 tiles on both sides and keeps one floor; original source tags were preserved.");} + try{b.Design=BuildingDesigner.Generate(b,levels,unchecked(seed^x*73856093^y*19349663),"rect",basements);if(b.SourceRoof is "flat" or "gabled" or "shed" or "hipped")b.Design.Roof=b.SourceRoof;else if(b.SourceRoof=="skillion")b.Design.Roof="shed";if(RoofGeometry.SelectSafeAutomaticRoof(b))warnings.Add($"Roof for shared building {b.Name} was adapted to fit z={ZLevels.Max}; original source tags were preserved.");if(specified>ZLevels.AboveGroundCount||depth>ZLevels.BasementCount)warnings.Add($"Source floor counts for shared building {b.Name} were limited to 32 above-ground and 32 basement levels.");if(!ImportedBuildingGeometry.Apply(b,rings,out var footprintReason))throw new ArgumentException(footprintReason);b.SourceOutline=ImportedBuildingGeometry.Capture(feature.Rings,out var simplified);b.SourceOutlineSimplified=simplified;ApplyAppearanceTags(b,tags,null,problem=>warnings.Add($"Shared building {b.Name}: appearance tag ignored: {problem}."));world.Buildings.Add(b);WorldAssembler.Validate(world);added++;if(sourceId is not null)existing.Add(sourceId);}catch(ArgumentException){world.Buildings.Remove(b);skipped++;} } if(added>0)warnings.Add($"Shared buildings spanning section boundaries restored from complete OSM outlines: {added}. Each is stored once in the world; rooms and tiles are clipped to native cells.");if(skipped>0)warnings.Add($"Shared buildings skipped because of water, overlaps, or inaccessible layouts: {skipped}.");return(added,skipped); } diff --git a/src/SimplePZMapmaker.Core/GeographicImporter.cs b/src/SimplePZMapmaker.Core/GeographicImporter.cs index b77294d..95b9357 100644 --- a/src/SimplePZMapmaker.Core/GeographicImporter.cs +++ b/src/SimplePZMapmaker.Core/GeographicImporter.cs @@ -43,7 +43,7 @@ public static ImportResult Import(string json,MapProject project,GeoBounds? boun double work=0; foreach(var f in transformed.Where(f=>f.Polygon&&!f.Tags.ContainsKey("building")&&(f.Tags.ContainsKey("landuse")||f.Tags.ContainsKey("natural")))){var outer=f.Rings[0];var rows=Math.Clamp(outer.Max(v=>v.Y)-outer.Min(v=>v.Y)+2,0,p.Height);var columns=Math.Clamp(outer.Max(v=>v.X)-outer.Min(v=>v.X)+2,0,p.Width);work+=rows*(columns+f.Rings.Sum(r=>r.Count));} if(work>40_000_000)throw new ArgumentException("The outlines are too complex for this map. Reduce the area or map size, or simplify the GeoJSON."); - var imported=0;var skipped=0;var limitedLevels=0;var approximateRoofs=0;var skipReasons=new Dictionary<string,int>();var skippedOutlines=new List<SkippedImportOutline>(); + var imported=0;var skipped=0;var limitedLevels=0;var singleFloorSmall=0;var appearanceProblems=new Dictionary<string,int>(StringComparer.Ordinal);var approximateRoofs=0;var skipReasons=new Dictionary<string,int>();var skippedOutlines=new List<SkippedImportOutline>(); foreach(var f in transformed.Where(f=>f.Polygon&&!f.Tags.ContainsKey("building")&&(f.Tags.ContainsKey("landuse")||f.Tags.ContainsKey("natural")))) { cancellationToken.ThrowIfCancellationRequested(); var natural=f.Tags.GetValueOrDefault("natural");var land=f.Tags.GetValueOrDefault("landuse"); @@ -81,15 +81,19 @@ public static ImportResult Import(string json,MapProject project,GeoBounds? boun var plannedLines=editableLines.Concat(preserveEdits?project.LinearFeatures.Where(f=>f.Modified).Select(NeighborhoodPlanner.Copy):[]).ToList(); foreach(var line in plannedLines){line.Underlay=[];line.GeneratedDecorations=[];line.DecorationIds=[];} if(plannedLines.Count>0)p=LinearFeatureEditor.Rebuild(p,plannedLines); - foreach(var f in transformed.Where(f=>f.Polygon&&f.Tags.ContainsKey("building"))) { + // Attached outlines go last, so the building they touch is already placed. + foreach(var f in transformed.Where(f=>f.Polygon&&f.Tags.ContainsKey("building")).OrderBy(f=>f.Tags.ContainsKey("pz:attached")?1:0)) { cancellationToken.ThrowIfCancellationRequested(); if(preserveEdits&&f.Tags.TryGetValue("__pz_source",out var sourceKey)&&p.Buildings.Any(b=>b.SourceId==sourceKey)){imported++;continue;} var ring=f.Rings[0]; var x=(int)Math.Floor(Math.Clamp(ring.Min(v=>v.X),-100000,100000));var y=(int)Math.Floor(Math.Clamp(ring.Min(v=>v.Y),-100000,100000)); var w=(int)Math.Clamp(Math.Ceiling(ring.Max(v=>v.X)-x),0,100000);var h=(int)Math.Clamp(Math.Ceiling(ring.Max(v=>v.Y)-y),0,100000); var sourceKind=f.Tags.GetValueOrDefault("building","yes"); - var kind=f.Tags.ContainsKey("shop")?"shop":sourceKind switch{"retail" or "commercial" or "train_station"=>"shop","apartments" or "residential"=>"apartments","school" or "college" or "kindergarten"=>"school","hospital"=>"clinic","hotel"=>"hotel","industrial"=>"industrial","warehouse" or "shed" or "service"=>"warehouse","garages" or "garage"=>"garage",_=>"house"}; - var b=new Building{Id="import-"+Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(f.Tags["__pz_source"])))[..24].ToLowerInvariant(),X=x,Y=y,Width=Math.Max(6,w),Height=Math.Max(6,h),Kind=kind,Name=f.Tags.GetValueOrDefault("name",BuildingDesigner.Names[kind]),SourceKind=sourceKind,SourceId=f.Tags.GetValueOrDefault("__pz_source")}; + var kind=KindFromTag(f.Tags)??(f.Tags.ContainsKey("shop")?"shop":sourceKind switch{"retail" or "commercial" or "train_station"=>"shop","apartments" or "residential"=>"apartments","school" or "college" or "kindergarten"=>"school","hospital"=>"clinic","hotel"=>"hotel","industrial"=>"industrial","warehouse" or "shed" or "service"=>"warehouse","garages" or "garage"=>"garage",_=>"house"}); + var b=new Building{Id="import-"+Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(f.Tags["__pz_source"])))[..24].ToLowerInvariant(),X=x,Y=y,Width=Math.Max(BuildingDesigner.MinimumFootprint,w),Height=Math.Max(BuildingDesigner.MinimumFootprint,h),Kind=kind,Name=f.Tags.GetValueOrDefault("name",BuildingDesigner.Names[kind]),SourceKind=sourceKind,SourceId=f.Tags.GetValueOrDefault("__pz_source")}; b.Name=b.Name[..Math.Min(100,b.Name.Length)]; + var attachTo=f.Tags.TryGetValue("pz:attached",out var attachedSource)?p.Buildings.FirstOrDefault(a=>a.SourceId is {} s&&(s==attachedSource||s=="geo-"+attachedSource)):null; + if(attachedSource is not null&&attachTo is null)appearanceProblems["pz:attached names a building that was not placed on this map"]=appearanceProblems.GetValueOrDefault("pz:attached names a building that was not placed on this map")+1; + b.AttachedTo=attachTo?.Id; b.SourceLevels=f.Tags.GetValueOrDefault("building:levels")?[..Math.Min(100,f.Tags["building:levels"].Length)];b.SourceRoof=f.Tags.GetValueOrDefault("roof:shape")?[..Math.Min(100,f.Tags["roof:shape"].Length)]; if(w<=64&&h<=64){ var levels=kind is "apartments" or "school" or "clinic" or "hotel"?2:1; @@ -97,6 +101,7 @@ public static ImportResult Import(string json,MapProject project,GeoBounds? boun else if(f.Tags.TryGetValue("height",out var heightText)&&double.TryParse(heightText.Replace(" m",""),System.Globalization.NumberStyles.Float,System.Globalization.CultureInfo.InvariantCulture,out var height)&&height>0&&double.IsFinite(height)){levels=Math.Clamp((int)Math.Min(ZLevels.AboveGroundCount,Math.Round(height/3)),1,ZLevels.AboveGroundCount);b.LevelsOrigin="osm-height";} var basements=0;b.SourceBasements=f.Tags.GetValueOrDefault("building:levels:underground")?[..Math.Min(100,f.Tags["building:levels:underground"].Length)]; if(b.SourceBasements is not null&&int.TryParse(b.SourceBasements,out var depth)&&depth>0){basements=Math.Clamp(depth,0,ZLevels.BasementCount);if(depth>ZLevels.BasementCount)limitedLevels++;} + if(levels+basements>1&&!BuildingDesigner.HoldsStairs(b.Width,b.Height)){levels=1;basements=0;singleFloorSmall++;} var seed=unchecked(project.Seed^x*73856093^y*19349663);b.Design=BuildingDesigner.Generate(b,levels,seed,"rect",basements); if(p.SurfaceTheme!="legacy")VisualStyles.Apply(b); if(f.Tags.GetValueOrDefault("roof:shape") is "flat" or "gabled" or "shed" or "skillion" or "hipped")b.Design.Roof=f.Tags["roof:shape"]=="skillion"?"shed":f.Tags["roof:shape"]; @@ -104,13 +109,16 @@ public static ImportResult Import(string json,MapProject project,GeoBounds? boun if(RoofGeometry.SelectSafeAutomaticRoof(b))approximateRoofs++; if(!ImportedBuildingGeometry.Apply(b,f.Rings,out var footprintReason)){skipped++;skipReasons[footprintReason]=skipReasons.GetValueOrDefault(footprintReason)+1;if(skippedOutlines.Count<256)skippedOutlines.Add(new(b.SourceId,b.Name,footprintReason,x,y,w,h));continue;} b.SourceOutline=ImportedBuildingGeometry.Capture(f.OriginalRings!,out var sourceSimplified);b.SourceOutlineSimplified=sourceSimplified; + ApplyAppearanceTags(b,f.Tags,p.ResourcePalettes,problem=>appearanceProblems[problem]=appearanceProblems.GetValueOrDefault(problem)+1); } if(w>64||h>64||!MapGenerator.CanPlace(p,b)){ skipped++; var reason=w>64||h>64?"Outline too large":b.X<1||b.Y<1||b.X+b.Width>=p.Width||b.Y+b.Height>=p.Height?"Outside the map":p.Buildings.Any(a=>b.X<a.X+a.Width+1&&b.X+b.Width+1>a.X&&b.Y<a.Y+a.Height+1&&b.Y+b.Height+1>a.Y)?"Building overlap":p.Decorations.Any(d=>d.Parts.Any(part=>d.X+part.Dx>=b.X&&d.X+part.Dx<=b.X+b.Width&&d.Y+part.Dy>=b.Y&&d.Y+part.Dy<=b.Y+b.Height))?"Environment object":"Water or road"; - skipReasons[reason]=skipReasons.GetValueOrDefault(reason)+1;if(skippedOutlines.Count<256)skippedOutlines.Add(new(b.SourceId,b.Name,reason,x,y,w,h));continue; + skipReasons[reason]=skipReasons.GetValueOrDefault(reason)+1;// In a world section an outline outside the section belongs to a neighbour or the world layer, not to this report. + if(skippedOutlines.Count<256&&!(p.WorldSection&&reason=="Outside the map"))skippedOutlines.Add(new(b.SourceId,b.Name,reason,x,y,w,h));continue; } p.Buildings.Add(b);imported++; + if(attachTo is not null&&!AttachedBuildings.Join(b,attachTo,f.Tags.GetValueOrDefault("pz:door-to") is {} doorTo&&(doorTo==attachedSource||"geo-"+doorTo==attachTo.SourceId)))appearanceProblems["pz:door-to found no shared ground-floor wall between the rooms"]=appearanceProblems.GetValueOrDefault("pz:door-to found no shared ground-floor wall between the rooms")+1; } if(preserveEdits){p.Spawns=project.Spawns.Select(s=>new SpawnPoint{X=s.X,Y=s.Y}).ToList();foreach(var spawn in p.Spawns)p.Terrain[spawn.Y*p.Width+spawn.X]=preserved.Terrain[spawn.Y*p.Width+spawn.X];}else{try{p.Spawns=[MapGenerator.FindSafeSpawn(p)];}catch(ArgumentException) when(p.WorldSection){p.Spawns=[];}} CaptureEvidence(p,transformed,project,preserveEdits,cancellationToken); @@ -120,6 +128,9 @@ public static ImportResult Import(string json,MapProject project,GeoBounds? boun if(omittedRails>0)p.ImportSummary.Notes.Add($"Railway paths skipped because cardinal native artwork or authoring limits were not supported: {omittedRails}. No train simulation is provided."); if(simplifiedLines>0)p.ImportSummary.Notes.Add($"Long source paths simplified to 512 samples: {simplifiedLines}."); p.ImportSummary.Notes.Add($"OSM floor counts: {p.Buildings.Count(b=>b.LevelsOrigin=="osm-levels")}; estimated from height: {p.Buildings.Count(b=>b.LevelsOrigin=="osm-height")}; assumed: {p.Buildings.Count(b=>b.LevelsOrigin=="inferred")}."); + foreach(var (problem,count) in appearanceProblems.Take(32))p.ImportSummary.Notes.Add($"Appearance tag ignored: {problem} ({count} outlines)."); + foreach(var note in UnknownPzKeys(features.Select(f=>(IReadOnlyDictionary<string,string>)f.Tags)))p.ImportSummary.Notes.Add(note); + if(singleFloorSmall>0)p.ImportSummary.Notes.Add($"Outlines under 6 tiles on both sides have room for one floor only (no straight five-tile stair run): {singleFloorSmall}. Source floor tags were preserved in the building properties."); if(limitedLevels>0)p.ImportSummary.Notes.Add($"Above-ground and basement levels were limited to 32 for {limitedLevels} source outlines.");if(approximateRoofs>0)p.ImportSummary.Notes.Add($"Unusual roof shapes were adapted for {approximateRoofs} outlines; original tags were preserved in the building properties."); if(preserveEdits)p.ImportSummary.Notes.Add($"Manually edited buildings preserved: {p.Buildings.Count(b=>b.Modified)}. Adjacent source outlines may be skipped to preserve these edits."); if(preserveEdits&&project.TerrainEdits.Length==0)p.ImportSummary.Notes.Add("Legacy manual terrain changes without a mask cannot be identified. Explicit surfaces, decoration, and buildings were preserved; unmarked terrain was refreshed from geographic data."); diff --git a/src/SimplePZMapmaker.Core/ImportedBuildingGeometry.cs b/src/SimplePZMapmaker.Core/ImportedBuildingGeometry.cs index cb58f5d..534213d 100644 --- a/src/SimplePZMapmaker.Core/ImportedBuildingGeometry.cs +++ b/src/SimplePZMapmaker.Core/ImportedBuildingGeometry.cs @@ -6,7 +6,7 @@ public static bool Apply(Building b,List<List<(double X,double Y)>> rings,out st reason="";if(rings.Count==0||rings.Any(r=>r.Count<4||r[0]!=r[^1])){reason="Open or incomplete source footprint";return false;} var mask=new bool[b.Width*b.Height];var intersections=new List<double>(); for(var y=0;y<b.Height;y++){intersections.Clear();var scan=b.Y+y+.5;foreach(var ring in rings)for(int i=0,j=ring.Count-1;i<ring.Count;j=i++)if((ring[i].Y>scan)!=(ring[j].Y>scan))intersections.Add(ring[i].X+(scan-ring[i].Y)*(ring[j].X-ring[i].X)/(ring[j].Y-ring[i].Y));intersections.Sort();for(var i=0;i+1<intersections.Count;i+=2){var first=Math.Clamp((int)Math.Ceiling(intersections[i]-b.X-.5),0,b.Width);var end=Math.Clamp((int)Math.Ceiling(intersections[i+1]-b.X-.5),0,b.Width);for(var x=first;x<end;x++)mask[y*b.Width+x]=true;}} - var count=mask.Count(v=>v);if(count<16){reason="Source footprint too narrow at this scale";return false;} + var count=mask.Count(v=>v);if(count<BuildingDesigner.MinimumFootprint*BuildingDesigner.MinimumFootprint){reason="Source footprint too narrow at this scale";return false;} var start=Array.FindIndex(mask,v=>v);var queue=new Queue<int>();var visited=new HashSet<int>{start};queue.Enqueue(start);while(queue.TryDequeue(out var i)){var x=i%b.Width;var y=i/b.Width;foreach(var (dx,dy) in new[]{(1,0),(-1,0),(0,1),(0,-1)}){var xx=x+dx;var yy=y+dy;if(xx>=0&&yy>=0&&xx<b.Width&&yy<b.Height&&mask[yy*b.Width+xx]&&visited.Add(yy*b.Width+xx))queue.Enqueue(yy*b.Width+xx);}} if(visited.Count!=count){reason="Disconnected source footprint at this scale";return false;} var original=b.Design??throw new ArgumentException("A source footprint needs a generated interior.");var direction="n";var stair=-1; diff --git a/src/SimplePZMapmaker.Core/MapGenerator.cs b/src/SimplePZMapmaker.Core/MapGenerator.cs index 4a990e5..ab36a2c 100644 --- a/src/SimplePZMapmaker.Core/MapGenerator.cs +++ b/src/SimplePZMapmaker.Core/MapGenerator.cs @@ -45,7 +45,7 @@ private static void PaintRoad(MapProject p,int x,int y,int radius) { public static bool CanPlace(MapProject p,Building b,bool avoidRoads=true) { if(b.X<1||b.Y<1||b.X+b.Width>=p.Width||b.Y+b.Height>=p.Height) return false; var occupied=BuildingDesigner.Occupied(b).Select(i=>(b.Y+i/(b.Width+1))*p.Width+b.X+i%(b.Width+1)).ToHashSet(); - if(p.Buildings.Where(a=>a!=b&&b.X<a.X+a.Width+1&&b.X+b.Width+1>a.X&&b.Y<a.Y+a.Height+1&&b.Y+b.Height+1>a.Y).Any(a=>BuildingDesigner.Occupied(a).Any(i=>occupied.Contains((a.Y+i/(a.Width+1))*p.Width+a.X+i%(a.Width+1)))))return false; + if(p.Buildings.Where(a=>a!=b&&b.X<a.X+a.Width+1&&b.X+b.Width+1>a.X&&b.Y<a.Y+a.Height+1&&b.Y+b.Height+1>a.Y).Any(a=>BuildingDesigner.Occupied(a).Any(i=>{var x=a.X+i%(a.Width+1);var y=a.Y+i/(a.Width+1);return occupied.Contains(y*p.Width+x)&&!AttachedBuildings.MayShare(a,b,x,y);})))return false; if(p.Decorations.Any(d=>d.Parts.Any(part=>occupied.Contains((d.Y+part.Dy)*p.Width+d.X+part.Dx))))return false; for(var y=0;y<b.Height;y++) for(var x=0;x<b.Width;x++) if(BuildingDesigner.Occupies(b,x,y)&&(p.Terrain[(b.Y+y)*p.Width+b.X+x]==Terrain.Water||avoidRoads&&p.Terrain[(b.Y+y)*p.Width+b.X+x]==Terrain.Road)) return false; return true; diff --git a/src/SimplePZMapmaker.Core/MapProject.cs b/src/SimplePZMapmaker.Core/MapProject.cs index 995f886..a9bcf37 100644 --- a/src/SimplePZMapmaker.Core/MapProject.cs +++ b/src/SimplePZMapmaker.Core/MapProject.cs @@ -4,7 +4,7 @@ public enum Terrain : byte { Grass, Forest, Road, Water, Sand, Dirt, Pavement } public sealed class MapProject { private int version=4; - public int Version { get=>AuthoringFormats.MapHasFences(this)&&version<12?12:AuthoringFormats.MapHasInfrastructure(this)&&version<11?11:AuthoringFormats.MapHasExtendedLevels(this)&&version<10?10:AuthoringFormats.MapHasNewAuthoring(this)&&version<9?9:ResourcePalettes?.Count>0&&version<8?8:version==6&&(StaticModels?.Any(m=>m?.Profile!="pickup")==true||(Buildings??[]).Concat(BuildingLibrary??[]).Any(b=>b?.Design?.Floors?.Any(f=>f?.StaticModels?.Any(m=>m?.Profile!="pickup")==true)==true))?7:version;set=>version=value; } + public int Version { get=>AuthoringFormats.MapHasAttachedBuildings(this)&&version<13?13:AuthoringFormats.MapHasFences(this)&&version<12?12:AuthoringFormats.MapHasInfrastructure(this)&&version<11?11:AuthoringFormats.MapHasExtendedLevels(this)&&version<10?10:AuthoringFormats.MapHasNewAuthoring(this)&&version<9?9:ResourcePalettes?.Count>0&&version<8?8:version==6&&(StaticModels?.Any(m=>m?.Profile!="pickup")==true||(Buildings??[]).Concat(BuildingLibrary??[]).Any(b=>b?.Design?.Floors?.Any(f=>f?.StaticModels?.Any(m=>m?.Profile!="pickup")==true)==true))?7:version;set=>version=value; } public bool WorldSection {get;set;} [System.Text.Json.Serialization.JsonIgnore] public InfrastructureGeometry.Geometry? SharedInfrastructure {get;set;} @@ -77,6 +77,8 @@ public sealed class Building public Dictionary<string,string> AssetSources {get;set;}=[]; public string LevelsOrigin {get;set;}="inferred"; public bool Modified {get;set;} + /// <summary>Id of a building on the same map that this one touches with no free line (format 13).</summary> + public string? AttachedTo {get;set;} } public sealed class BuildingDesign { public int MinLevel {get;set;} diff --git a/src/SimplePZMapmaker.Core/ProjectValidator.cs b/src/SimplePZMapmaker.Core/ProjectValidator.cs index dc3f732..1f43da7 100644 --- a/src/SimplePZMapmaker.Core/ProjectValidator.cs +++ b/src/SimplePZMapmaker.Core/ProjectValidator.cs @@ -4,7 +4,7 @@ public static class ProjectValidator public static void Validate(MapProject p) { ArgumentNullException.ThrowIfNull(p); - if(p.Version is not(1 or 2 or 3 or 4 or 5 or 6 or 7 or 8 or 9 or 10 or 11 or 12)) Fail("Unsupported project version."); + if(p.Version is not(1 or 2 or 3 or 4 or 5 or 6 or 7 or 8 or 9 or 10 or 11 or 12 or 13)) Fail("Unsupported project version."); ResourcePalette.Validate(p.ResourcePalettes); if(p.ImportSummary is {} qualityReport) ImportQuality.Validate(qualityReport); @@ -21,7 +21,7 @@ public static void Validate(MapProject p) if(p.Zombies is null || p.Zombies.Length!=p.Width*p.Height/64 || p.Zombies.Any(z=>z is <0 or >255)) Fail("Invalid zombie layer."); if(p.WorldSection&&p.Version<6)Fail("World sections require project format version 6."); if(p.Buildings is null || p.Buildings.Count>4000 || p.Spawns is null || p.Spawns.Count<(p.WorldSection?0:1)||p.Spawns.Count>100 || p.Attribution is null || p.Attribution.Count>30 || p.Attribution.Any(a=>a is null || a.Length>1000)) Fail("Invalid project objects."); - var occupied=new HashSet<int>(); var ids=new HashSet<string>(); + var occupied=new HashSet<int>(); var ids=new HashSet<string>();var owners=new Dictionary<int,Building>(); if(p.SurfaceRegions is null||p.SurfaceRegions.Count>128||p.SurfaceRegions.Any(r=>r is null||r.Theme is null||!EnvironmentStyles.Names.ContainsKey(r.Theme)||r.X<0||r.Y<0||r.Width<1||r.Height<1||(long)r.X+r.Width>p.Width||(long)r.Y+r.Height>p.Height))Fail("The appearance region is invalid or outside the map (limit: 128 regions)."); if(p.Version<5&&p.SurfaceRegions.Count>0)Fail("Individual appearance regions require project format version 5."); if(p.SurfaceTheme is null||!EnvironmentStyles.Names.ContainsKey(p.SurfaceTheme)||p.SurfaceTiles is null||p.SurfaceTiles.Count>100000||p.SurfaceTiles.Any(v=>v.Key<0||v.Key>=p.Terrain.Length||!VisualStyles.TileId(v.Value))||p.Decorations is null||p.Decorations.Count>10000)Fail("Invalid game surfaces or decoration."); @@ -34,11 +34,12 @@ public static void Validate(MapProject p) foreach(var b in p.Buildings) { if(b is null || !BuildingDesigner.Kinds.Contains(b.Kind) || string.IsNullOrEmpty(b.Id) || !ids.Add(b.Id) || b.Name is null || b.Name.Length>100||b.SourceId?.Length>100||b.SourceKind?.Length>100||b.SourceLevels?.Length>100||b.SourceBasements?.Length>100||b.SourceRoof?.Length>100||b.LevelsOrigin is not("inferred" or "osm-levels" or "osm-height" or "manual")) Fail("Unknown or invalid building."); - if(b.Width is <6 or >64 || b.Height is <6 or >64 || b.X<1 || b.Y<1 || b.X>p.Width-b.Width-1 || b.Y>p.Height-b.Height-1) Fail("The building extends beyond the map area. Move it or expand the area."); + if(b.Width is <BuildingDesigner.MinimumFootprint or >64 || b.Height is <BuildingDesigner.MinimumFootprint or >64 || b.X<1 || b.Y<1 || b.X>p.Width-b.Width-1 || b.Y>p.Height-b.Height-1) Fail("The building extends beyond the map area. Move it or expand the area."); BuildingDesigner.Validate(b);ImportedBuildingGeometry.Validate(b); - foreach(var index in BuildingDesigner.Occupied(b)){var x=b.X+index%(b.Width+1);var y=b.Y+index/(b.Width+1);if(!occupied.Add(y*p.Width+x))Fail("Building walls or rooms overlap.");} + foreach(var index in BuildingDesigner.Occupied(b)){var x=b.X+index%(b.Width+1);var y=b.Y+index/(b.Width+1);if(!occupied.Add(y*p.Width+x)&&!(owners.TryGetValue(y*p.Width+x,out var other)&&AttachedBuildings.MayShare(b,other,x,y)))Fail("Building walls or rooms overlap.");owners.TryAdd(y*p.Width+x,b);} for(var y=0;y<b.Height;y++)for(var x=0;x<b.Width;x++)if(BuildingDesigner.Occupies(b,x,y)&&p.Terrain[(b.Y+y)*p.Width+b.X+x]==Terrain.Water)Fail("The building stands on water."); } + AttachedBuildings.Validate(p); foreach(var s in p.Spawns) { if(s is null || s.X<0 || s.Y<0 || s.X>=p.Width || s.Y>=p.Height) Fail("The spawn point is outside the map."); diff --git a/src/SimplePZMapmaker.Core/WorldBuildings.cs b/src/SimplePZMapmaker.Core/WorldBuildings.cs index 87155f5..81b8fb3 100644 --- a/src/SimplePZMapmaker.Core/WorldBuildings.cs +++ b/src/SimplePZMapmaker.Core/WorldBuildings.cs @@ -12,6 +12,7 @@ private static void ValidateBuildings(WorldProject world){ foreach(var member in world.Maps)foreach(var b in member.Project.Buildings){ids.Add(b.Id);foreach(var point in BuildingCells(b))occupied.Add((point.X+member.Project.WorldX*256,point.Y+member.Project.WorldY*256));} foreach(var b in world.Buildings){ if(b is null||string.IsNullOrWhiteSpace(b.Id)||b.Id.Length>100||!ids.Add(b.Id)||b.X<0||b.Y<0||b.X>258000||b.Y>258000||b.Name is null||b.Name.Length>100||b.SourceId?.Length>100||b.SourceKind?.Length>100||b.SourceLevels?.Length>100||b.SourceBasements?.Length>100||b.SourceRoof?.Length>100||b.LevelsOrigin is not("inferred" or "osm-levels" or "osm-height" or "manual"))throw new ArgumentException("Invalid shared world building or duplicate ID."); + if(b.AttachedTo is not null)throw new ArgumentException($"'{b.Name}': shared world buildings cannot be attached; attach buildings inside one section."); BuildingDesigner.Validate(b); foreach(var point in BuildingCells(b)){ var member=MemberAt(world,point.X,point.Y)??throw new ArgumentException($"'{b.Name}': the building extends into an empty part of the world ({point.X},{point.Y}). Add an adjacent section.");var p=member.Project;var x=point.X-p.WorldX*256;var y=point.Y-p.WorldY*256; @@ -25,8 +26,11 @@ private static void ValidateBuildings(WorldProject world){ public static Building CloneBuilding(Building b)=>JsonSerializer.Deserialize<Building>(JsonSerializer.Serialize(b,NativeMapExporter.JsonOptions),NativeMapExporter.JsonOptions)!; public static MapProject ProjectSection(WorldProject world,WorldMapMember member,InfrastructureGeometry.Geometry? sharedGeometry=null){ var p=member.Project;var projected=world.Buildings.Where(b=>b.X<p.WorldX*256+p.Width&&b.Y<p.WorldY*256+p.Height&&b.X+b.Width>=p.WorldX*256&&b.Y+b.Height>=p.WorldY*256).Select(b=>{var copy=CloneBuilding(b);copy.X-=p.WorldX*256;copy.Y-=p.WorldY*256;return copy;}).ToArray(); - if(projected.Length==0&&world.LinearFeatures.Count==0&&world.Fences.Count==0)return p; - var result=new MapProject(){Version=Math.Max(6,p.Version),WorldSection=true,Name=p.Name,Width=p.Width,Height=p.Height,Seed=p.Seed,WorldX=p.WorldX,WorldY=p.WorldY,Terrain=p.Terrain,TerrainEdits=p.TerrainEdits,Zombies=p.Zombies,Buildings=[..p.Buildings,..projected],BuildingLibrary=p.BuildingLibrary,ResourcePalettes=p.ResourcePalettes,LinearFeatures=p.LinearFeatures,GameZones=p.GameZones,SurfaceTheme=p.SurfaceTheme,SurfaceRegions=p.SurfaceRegions,SurfaceTiles=p.SurfaceTiles,Decorations=p.Decorations,StaticModels=p.StaticModels,RequiredMods=p.RequiredMods,AssetSources=p.AssetSources,Spawns=p.Spawns,Attribution=p.Attribution,Bounds=p.Bounds,MetresPerTile=p.MetresPerTile,ImportSummary=p.ImportSummary}; + var decorations=DecorationsFor(world.Decorations,p); + if(projected.Length==0&&world.LinearFeatures.Count==0&&world.Fences.Count==0&&decorations.Count==0)return p; + var result=new MapProject(){Version=Math.Max(6,p.Version),WorldSection=true,Name=p.Name,Width=p.Width,Height=p.Height,Seed=p.Seed,WorldX=p.WorldX,WorldY=p.WorldY,Terrain=p.Terrain,TerrainEdits=p.TerrainEdits,Zombies=p.Zombies,Buildings=[..p.Buildings,..projected],BuildingLibrary=p.BuildingLibrary,ResourcePalettes=p.ResourcePalettes,LinearFeatures=p.LinearFeatures,GameZones=p.GameZones,SurfaceTheme=p.SurfaceTheme,SurfaceRegions=p.SurfaceRegions,SurfaceTiles=p.SurfaceTiles,Decorations=decorations.Count==0?p.Decorations:[..p.Decorations,..decorations],StaticModels=p.StaticModels,RequiredMods=p.RequiredMods,AssetSources=p.AssetSources,Spawns=p.Spawns,Attribution=p.Attribution,Bounds=p.Bounds,MetresPerTile=p.MetresPerTile,ImportSummary=p.ImportSummary}; + // Shared decorations bring their tile providers into the section that draws them. + var shared=world.Decorations.Where(d=>decorations.Any(local=>local.Id==d.Id)).ToList();if(shared.Any(d=>d.AssetSources.Count>0||d.RequiredMods.Count>0)){result.AssetSources=new(p.AssetSources);foreach(var (tile,provider) in shared.SelectMany(d=>d.AssetSources)){if(result.AssetSources.TryGetValue(tile,out var known)&&known!=provider)throw new ArgumentException($"Tile {tile} has different providers in a shared decoration and its section.");result.AssetSources[tile]=provider;}result.RequiredMods=p.RequiredMods.Concat(shared.SelectMany(d=>d.RequiredMods)).Distinct(StringComparer.Ordinal).ToList();} result.ZombieDensityRegions=NeighborhoodPlanner.Copy(p.ZombieDensityRegions);result.ZombieDensityState=NeighborhoodPlanner.Copy(p.ZombieDensityState);result.RoomRegionRules=NeighborhoodPlanner.Copy(p.RoomRegionRules); result.Fences=[..p.Fences,..FenceLines.ForSection(world,p)];result.FenceStyles=[..p.FenceStyles,..world.FenceStyles.Where(w=>p.FenceStyles.All(s=>s.Id!=w.Id))];result.GateStyles=[..p.GateStyles,..world.GateStyles.Where(w=>p.GateStyles.All(s=>s.Id!=w.Id))]; result.SourceFeatures=NeighborhoodPlanner.Copy(p.SourceFeatures);result.Sites=NeighborhoodPlanner.Copy(p.Sites); diff --git a/src/SimplePZMapmaker.Core/WorldDecorations.cs b/src/SimplePZMapmaker.Core/WorldDecorations.cs new file mode 100644 index 0000000..e682c0d --- /dev/null +++ b/src/SimplePZMapmaker.Core/WorldDecorations.cs @@ -0,0 +1,74 @@ +namespace SimplePZMapmaker.Core; + +/// <summary>A decoration group stored once in absolute world tiles. It may cross section seams: every section draws the parts inside it. +/// Mod tiles carry their providers like a shared building does.</summary> +public sealed class SharedDecoration +{ + public string Id {get;set;}=Guid.NewGuid().ToString("N"); + public int X {get;set;} public int Y {get;set;} + public string Asset {get;set;}=""; + public List<BuildingObjects.Part> Parts {get;set;}=[]; + public List<string> RequiredMods {get;set;}=[]; + public Dictionary<string,string> AssetSources {get;set;}=[]; +} +public static partial class WorldAssembler +{ + public const int MaximumSharedDecorations=10000; + public static IEnumerable<(int X,int Y)> DecorationCells(SharedDecoration d)=>d.Parts.Select(p=>(d.X+p.Dx,d.Y+p.Dy)); + /// <summary>Shared decoration IDs never use the "world-" prefix of projected infrastructure decorations.</summary> + public static bool SafeDecorationId(string? id)=>id is {Length:>0 and <=64}&&!id.StartsWith("world-",StringComparison.Ordinal)&&id.All(c=>char.IsAsciiLetterOrDigit(c)||c is '-' or '_'); + public static void ValidateDecoration(SharedDecoration d){ + if(d is null||!SafeDecorationId(d.Id)||d.X is <0 or >258000||d.Y is <0 or >258000)throw new ArgumentException("Invalid shared decoration: it needs an ID of 1-64 letters, digits, '-' or '_' (not starting with 'world-') and world tiles 0-258000."); + VisualStyles.ValidateParts(d.Asset,d.Parts);ResourceDependencies.Validate(d.RequiredMods,d.AssetSources); + } + private static void ValidateDecorations(WorldProject world){ + if(world.Decorations is null||world.Decorations.Count>MaximumSharedDecorations)throw new ArgumentException($"A world can contain up to {MaximumSharedDecorations:N0} shared decorations."); + if(world.Decorations.Count==0)return; + var blocked=new Dictionary<(int,int),string>();var ids=new HashSet<string>(StringComparer.Ordinal); + foreach(var b in world.Buildings)foreach(var c in BuildingCells(b))blocked.TryAdd(c,"a shared building"); + foreach(var m in world.Maps){var p=m.Project;var ox=p.WorldX*256;var oy=p.WorldY*256; + foreach(var d in p.Decorations){ids.Add(d.Id);foreach(var part in d.Parts)blocked.TryAdd((ox+d.X+part.Dx,oy+d.Y+part.Dy),"a section decoration");} + foreach(var b in p.Buildings)foreach(var c in BuildingCells(b))blocked.TryAdd((ox+c.X,oy+c.Y),"a building"); + foreach(var s in p.Spawns)blocked.TryAdd((ox+s.X,oy+s.Y),"a spawn point"); + foreach(var s in p.StaticModels)foreach(var c in StaticModelPlacement.FootprintCells(s))blocked.TryAdd((ox+s.X+c.Dx,oy+s.Y+c.Dy),"a model");} + foreach(var d in world.Decorations){ + ValidateDecoration(d);if(!ids.Add(d.Id))throw new ArgumentException($"Shared decoration {d.Id} repeats a decoration ID."); + foreach(var (x,y) in DecorationCells(d)){ + var member=MemberAt(world,x,y)??throw new ArgumentException($"Shared decoration {d.Id} reaches an empty part of the world ({x},{y}). Add an adjacent section.");var p=member.Project; + if(p.Terrain[(y-p.WorldY*256)*p.Width+x-p.WorldX*256]==Terrain.Water)throw new ArgumentException($"Shared decoration {d.Id} stands on water at {x},{y}."); + if(blocked.TryGetValue((x,y),out var what))throw new ArgumentException($"Shared decoration {d.Id} overlaps {what} at {x},{y}.");blocked[(x,y)]="another shared decoration"; + } + } + } + /// <summary>The parts of shared decorations inside a section, as section-local decorations with the shared ID.</summary> + public static List<WorldDecoration> DecorationsFor(IEnumerable<SharedDecoration> decorations,MapProject p){ + var ox=p.WorldX*256;var oy=p.WorldY*256;var result=new List<WorldDecoration>(); + foreach(var d in decorations){var parts=d.Parts.Where(part=>d.X+part.Dx>=ox&&d.Y+part.Dy>=oy&&d.X+part.Dx<ox+p.Width&&d.Y+part.Dy<oy+p.Height).ToList();if(parts.Count>0)result.Add(new(){Id=d.Id,X=d.X-ox,Y=d.Y-oy,Asset=d.Asset,Parts=parts});} + return result; + } + public static WorldProject PutDecoration(WorldProject world,SharedDecoration decoration){ValidateDecoration(decoration);var copy=Clone(world);var prior=copy.Decorations.FindIndex(d=>d.Id==decoration.Id);var stored=CloneDecoration(decoration);if(prior>=0)copy.Decorations[prior]=stored;else copy.Decorations.Add(stored);Validate(copy);return copy;} + public static WorldProject RemoveDecoration(WorldProject world,string id){var copy=Clone(world);if(copy.Decorations.RemoveAll(d=>d.Id==id)!=1)throw new ArgumentException("Shared decoration not found.");Validate(copy);return copy;} + /// <summary>Moves a section decoration into the shared layer at its absolute position, or at (x, y) when given.</summary> + public static WorldProject PromoteDecoration(WorldProject world,string memberId,string decorationId,int? x=null,int? y=null){ + var copy=Clone(world);var member=copy.Maps.SingleOrDefault(m=>m.Id==memberId)??throw new ArgumentException("Section not found.");var p=member.Project;var d=p.Decorations.SingleOrDefault(d=>d.Id==decorationId)??throw new ArgumentException("Section decoration not found."); + if(!SafeDecorationId(d.Id))throw new ArgumentException("This decoration belongs to shared infrastructure or has an unsupported ID."); + var tiles=d.Parts.Select(part=>part.Tile).Append(d.Asset).ToHashSet(StringComparer.Ordinal);var sources=p.AssetSources.Where(s=>tiles.Contains(s.Key)).ToDictionary(s=>s.Key,s=>s.Value,StringComparer.Ordinal); + p.Decorations.Remove(d);copy.Decorations.Add(new(){Id=d.Id,X=x??p.WorldX*256+d.X,Y=y??p.WorldY*256+d.Y,Asset=d.Asset,Parts=[..d.Parts],AssetSources=sources,RequiredMods=sources.Values.Where(v=>v!="game").Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToList()}); + Validate(copy);return copy; + } + /// <summary>After a preserve-edits reimport: shared decorations keep their ground from the prior world, and refreshed local + /// decorations or unmodified source buildings under them give way, because the shared layer is authored.</summary> + public static int KeepSharedDecorations(WorldProject world,WorldProject prior,List<string> warnings){ + var cleared=0;var touched=new HashSet<MapProject>(); + foreach(var d in world.Decorations)foreach(var (x,y) in DecorationCells(d)){ + var target=MemberAt(world,x,y);if(target is null)continue;var p=target.Project;var lx=x-p.WorldX*256;var ly=y-p.WorldY*256; + if(MemberAt(prior,x,y)?.Project is {} o)p.Terrain[ly*p.Width+lx]=o.Terrain[(y-o.WorldY*256)*o.Width+x-o.WorldX*256]; + var removed=p.Decorations.RemoveAll(local=>local.Parts.Any(part=>local.X+part.Dx==lx&&local.Y+part.Dy==ly))+p.Buildings.RemoveAll(b=>b.SourceId is not null&&!b.Modified&&BuildingCells(b).Contains((lx,ly))); + if(removed>0){cleared+=removed;touched.Add(p);} + } + foreach(var p in touched)SiteModel.DetachMissingMembers(p); + if(cleared>0)warnings.Add($"Refreshed section objects under shared decorations were removed to keep the shared layer: {cleared}."); + return cleared; + } + public static SharedDecoration CloneDecoration(SharedDecoration d)=>new(){Id=d.Id,X=d.X,Y=d.Y,Asset=d.Asset,Parts=[..d.Parts],RequiredMods=[..d.RequiredMods],AssetSources=new(d.AssetSources,StringComparer.Ordinal)}; +} diff --git a/src/SimplePZMapmaker.Core/WorldInfrastructure.cs b/src/SimplePZMapmaker.Core/WorldInfrastructure.cs index 4a34b12..de64d87 100644 --- a/src/SimplePZMapmaker.Core/WorldInfrastructure.cs +++ b/src/SimplePZMapmaker.Core/WorldInfrastructure.cs @@ -43,7 +43,7 @@ public static InfrastructureGeometry.Geometry Build(WorldProject world,Func<int, internal static MapProject Project(WorldProject world,WorldMapMember member,MapProject projected,InfrastructureGeometry.Geometry? geometry){ if(world.LinearFeatures.Count==0)return projected;var p=member.Project;var ox=p.WorldX*256;var oy=p.WorldY*256;bool Inside(int x,int y)=>x>=ox&&y>=oy&&x<ox+p.Width&&y<oy+p.Height;geometry??=Build(world); // A projection owns no authoring state. Its ground array and tile sources are private copies. - projected.Terrain=projected.Terrain.ToArray();projected.SurfaceTiles=new(projected.SurfaceTiles);projected.AssetSources=new(projected.AssetSources);projected.Decorations=[..projected.Decorations];var mask=TerrainEditMask.Decode(p);var blocked=new HashSet<int>();foreach(var b in projected.Buildings)foreach(var i in BuildingDesigner.Occupied(b)){var x=b.X+i%(b.Width+1);var y=b.Y+i/(b.Width+1);if(x>=0&&y>=0&&x<p.Width&&y<p.Height)blocked.Add(y*p.Width+x);}foreach(var s in p.Spawns)blocked.Add(s.Y*p.Width+s.X);foreach(var d in p.Decorations)foreach(var part in d.Parts)blocked.Add((d.Y+part.Dy)*p.Width+d.X+part.Dx);foreach(var m in p.StaticModels)foreach(var cell in StaticModelPlacement.FootprintCells(m))blocked.Add((m.Y+cell.Dy)*p.Width+m.X+cell.Dx); + projected.Terrain=projected.Terrain.ToArray();projected.SurfaceTiles=new(projected.SurfaceTiles);projected.AssetSources=new(projected.AssetSources);projected.Decorations=[..projected.Decorations];var mask=TerrainEditMask.Decode(p);var blocked=new HashSet<int>();foreach(var b in projected.Buildings)foreach(var i in BuildingDesigner.Occupied(b)){var x=b.X+i%(b.Width+1);var y=b.Y+i/(b.Width+1);if(x>=0&&y>=0&&x<p.Width&&y<p.Height)blocked.Add(y*p.Width+x);}foreach(var s in p.Spawns)blocked.Add(s.Y*p.Width+s.X);foreach(var d in projected.Decorations)foreach(var part in d.Parts)blocked.Add((d.Y+part.Dy)*p.Width+d.X+part.Dx);foreach(var m in p.StaticModels)foreach(var cell in StaticModelPlacement.FootprintCells(m))blocked.Add((m.Y+cell.Dy)*p.Width+m.X+cell.Dx); foreach(var feature in world.LinearFeatures.Where(f=>f.Level==0)){var cells=geometry.WorldCells?.GetValueOrDefault(feature.Id)??Rasterize(feature);var terrain=feature.Kind switch{"river"=>Terrain.Water,"sidewalk"=>Terrain.Pavement,"railway"=>Terrain.Dirt,_=>Terrain.Road};foreach(var (x,y) in cells.Where(c=>Inside(c.X,c.Y))){var lx=x-ox;var ly=y-oy;var i=ly*p.Width+lx;if(blocked.Contains(i)||TerrainEditMask.IsEdited(mask,i)||p.SurfaceTiles.ContainsKey(i))continue;var old=projected.Terrain[i];if(feature.Kind is "road" or "sidewalk" or "railway"&&old==Terrain.Water||feature.Kind=="river"&&old is Terrain.Road or Terrain.Pavement||feature.Kind=="sidewalk"&&old==Terrain.Road)continue;projected.Terrain[i]=terrain;} if(feature.Kind=="railway")foreach(var segment in feature.Points.Zip(feature.Points.Skip(1))){var horizontal=segment.First.Y==segment.Second.Y;var low=(int)Math.Floor(horizontal?Math.Min(segment.First.X,segment.Second.X):Math.Min(segment.First.Y,segment.Second.Y));var high=(int)Math.Ceiling(horizontal?Math.Max(segment.First.X,segment.Second.X):Math.Max(segment.First.Y,segment.Second.Y));var axis=(int)Math.Round(horizontal?segment.First.Y:segment.First.X);for(var at=low;at<=high;at++)for(var side=0;side<2;side++){var x=horizontal?at:axis+side;var y=horizontal?axis+side:at;if(!Inside(x,y))continue;var lx=x-ox;var ly=y-oy;var i=ly*p.Width+lx;if(!cells.Contains((x,y))||blocked.Contains(i)||TerrainEditMask.IsEdited(mask,i)||p.SurfaceTiles.ContainsKey(i)||projected.Terrain[i]==Terrain.Water)continue;var tile=$"industry_railroad_01_{(horizontal?(side==0?3:5):(side==0?0:2))}";if(projected.AssetSources.TryGetValue(tile,out var provider)&&provider!="game")throw new ArgumentException("Shared railway artwork requires its original game tile provider.");projected.AssetSources[tile]="game";projected.Decorations.Add(new(){Id=$"world-{feature.Id}-{x}-{y}",X=lx,Y=ly,Asset=tile,Parts=[new(0,0,tile)]});blocked.Add(i);}} } diff --git a/src/SimplePZMapmaker.Core/WorldProject.cs b/src/SimplePZMapmaker.Core/WorldProject.cs index fb1f4dd..14ab06e 100644 --- a/src/SimplePZMapmaker.Core/WorldProject.cs +++ b/src/SimplePZMapmaker.Core/WorldProject.cs @@ -6,7 +6,7 @@ public sealed class WorldProject { public string Format {get;set;}="SimplePZWorld"; private int version=1; - public int Version {get=>(GeoReference?.RotationDegrees is not(null or 0)||Fences?.Count>0||Maps?.Any(m=>m?.Project is {} p&&AuthoringFormats.MapHasFences(p))==true)&&version<7?7:LinearFeatures?.Count>0&&version<6?6:version<5&&AuthoringFormats.WorldHasInfrastructure(this)?5:version<4&&AuthoringFormats.WorldHasExtendedLevels(this)?4:version<3&&AuthoringFormats.WorldHasNewAuthoring(this)?3:version==1&&(Buildings?.Count>0||Elevation is not null||Maps?.Any(m=>m?.Project?.Version==7)==true)?2:version;set=>version=value;} + public int Version {get=>Decorations?.Count>0&&version<8?8:(GeoReference?.RotationDegrees is not(null or 0)||Fences?.Count>0||Maps?.Any(m=>m?.Project is {} p&&AuthoringFormats.MapHasFences(p))==true)&&version<7?7:LinearFeatures?.Count>0&&version<6?6:version<5&&AuthoringFormats.WorldHasInfrastructure(this)?5:version<4&&AuthoringFormats.WorldHasExtendedLevels(this)?4:version<3&&AuthoringFormats.WorldHasNewAuthoring(this)?3:version==1&&(Buildings?.Count>0||Elevation is not null||Maps?.Any(m=>m?.Project?.Version==7)==true)?2:version;set=>version=value;} public string Id {get;set;}=Guid.NewGuid().ToString("N"); public string Name {get;set;}="New world"; public List<WorldMapMember> Maps {get;set;}=[]; @@ -16,6 +16,8 @@ public sealed class WorldProject public List<FenceLine> Fences {get;set;}=[]; public List<FenceStyle> FenceStyles {get;set;}=[]; public List<GateStyle> GateStyles {get;set;}=[]; + /// <summary>Decoration groups stored once in absolute tiles; they may cross section seams (format 8).</summary> + public List<SharedDecoration> Decorations {get;set;}=[]; public ElevationSurvey? Elevation {get;set;} } public sealed class WorldMapMember @@ -27,13 +29,13 @@ public static partial class WorldAssembler { public sealed record Bounds(int X,int Y,int Width,int Height,int Cells); public static Bounds Validate(WorldProject world){ - if(world is null||world.Format!="SimplePZWorld"||world.Version is not(1 or 2 or 3 or 4 or 5 or 6 or 7)||!SafeId(world.Id)||string.IsNullOrWhiteSpace(world.Name)||world.Name.Length>80||world.Name.Any(char.IsControl)||world.Maps is null||world.Maps.Count is <1 or >128)throw new ArgumentException("Invalid world project: 1-128 maps are required."); + if(world is null||world.Format!="SimplePZWorld"||world.Version is not(1 or 2 or 3 or 4 or 5 or 6 or 7 or 8)||!SafeId(world.Id)||string.IsNullOrWhiteSpace(world.Name)||world.Name.Length>80||world.Name.Any(char.IsControl)||world.Maps is null||world.Maps.Count is <1 or >128)throw new ArgumentException("Invalid world project: 1-128 maps are required."); if(world.GeoReference is {} geographic)WorldGeoReference.Validate(geographic); var ids=new HashSet<string>();var cells=new HashSet<(int,int)>();var minX=int.MaxValue;var minY=int.MaxValue;var maxX=0;var maxY=0; foreach(var member in world.Maps){if(member is null||!SafeId(member.Id)||!ids.Add(member.Id))throw new ArgumentException("Invalid world section ID.");var p=member.Project;ProjectValidator.Validate(p); for(var y=p.WorldY;y<p.WorldY+p.Height/256;y++)for(var x=p.WorldX;x<p.WorldX+p.Width/256;x++){if(!cells.Add((x,y)))throw new ArgumentException($"World sections overlap cell {x},{y}.");if(cells.Count>8192)throw new ArgumentException("The world contains more than 8,192 native cells.");} minX=Math.Min(minX,p.WorldX);minY=Math.Min(minY,p.WorldY);maxX=Math.Max(maxX,p.WorldX+p.Width/256);maxY=Math.Max(maxY,p.WorldY+p.Height/256); - }ValidateBuildings(world);WorldInfrastructure.Validate(world);FenceLines.Validate(world);ValidateFenceStyles(world);if(world.Elevation is {} survey)ElevationSurvey.Validate(survey);if(world.Maps.Sum(m=>m.Project.Spawns.Count)==0)throw new ArgumentException("The world needs at least one spawn point on dry land.");return new(minX,minY,(maxX-minX)*256,(maxY-minY)*256,cells.Count); + }ValidateBuildings(world);ValidateDecorations(world);WorldInfrastructure.Validate(world);FenceLines.Validate(world);ValidateFenceStyles(world);if(world.Elevation is {} survey)ElevationSurvey.Validate(survey);if(world.Maps.Sum(m=>m.Project.Spawns.Count)==0)throw new ArgumentException("The world needs at least one spawn point on dry land.");return new(minX,minY,(maxX-minX)*256,(maxY-minY)*256,cells.Count); } public static WorldProject Clone(WorldProject world)=>JsonSerializer.Deserialize<WorldProject>(JsonSerializer.Serialize(world,NativeMapExporter.JsonOptions),NativeMapExporter.JsonOptions)!; public static WorldProject Move(WorldProject world,string memberId,int cellX,int cellY){var copy=Clone(world);var member=copy.Maps.SingleOrDefault(m=>m.Id==memberId)??throw new ArgumentException("World section not found.");member.Project.WorldX=cellX;member.Project.WorldY=cellY;Validate(copy);return copy;} diff --git a/src/SimplePZMapmaker.Core/WorldSeams.cs b/src/SimplePZMapmaker.Core/WorldSeams.cs index c81431c..1591bd9 100644 --- a/src/SimplePZMapmaker.Core/WorldSeams.cs +++ b/src/SimplePZMapmaker.Core/WorldSeams.cs @@ -4,7 +4,7 @@ public static class WorldSeams public sealed record RepairResult(WorldProject World,int ChangedCells,string[] Issues); public static RepairResult Repair(WorldProject original,string mode="both"){ WorldAssembler.Validate(original);if(mode is not("road" or "water" or "both"))throw new ArgumentException("Choose roads, rivers, or both seam types.");var world=WorldAssembler.Clone(original);var issues=new List<string>();var changed=0; - var protectedCells=new HashSet<(int,int)>();foreach(var b in world.Buildings)foreach(var c in WorldAssembler.BuildingCells(b))protectedCells.Add(c); + var protectedCells=new HashSet<(int,int)>();foreach(var b in world.Buildings)foreach(var c in WorldAssembler.BuildingCells(b))protectedCells.Add(c);foreach(var d in world.Decorations)foreach(var c in WorldAssembler.DecorationCells(d))protectedCells.Add(c); foreach(var member in world.Maps){var p=member.Project;foreach(var b in p.Buildings)foreach(var c in WorldAssembler.BuildingCells(b))protectedCells.Add((p.WorldX*256+c.X,p.WorldY*256+c.Y));foreach(var s in p.Spawns)protectedCells.Add((p.WorldX*256+s.X,p.WorldY*256+s.Y));foreach(var m in p.StaticModels)protectedCells.Add((p.WorldX*256+m.X,p.WorldY*256+m.Y));foreach(var d in p.Decorations)foreach(var part in d.Parts)protectedCells.Add((p.WorldX*256+d.X+part.Dx,p.WorldY*256+d.Y+part.Dy));} var masks=world.Maps.ToDictionary(m=>m.Id,m=>TerrainEditMask.Decode(m.Project)); void Change(WorldMapMember member,int x,int y,Terrain terrain){var p=member.Project;if(x<0||y<0||x>=p.Width||y>=p.Height)return;var index=y*p.Width+x;if(p.Terrain[index]==terrain)return;var global=(p.WorldX*256+x,p.WorldY*256+y);if(protectedCells.Contains(global)||p.SurfaceTiles.ContainsKey(index)||TerrainEditMask.IsEdited(masks[member.Id],index)){if(issues.Count<64)issues.Add($"'{p.Name}': tile {global.Item1},{global.Item2} is protected by an object or a manual surface edit.");return;}p.Terrain[index]=terrain;p.Version=6;masks[member.Id][index/8]|=(byte)(1<<(index%8));changed++;} diff --git a/src/SimplePZMapmaker.Web/Program.cs b/src/SimplePZMapmaker.Web/Program.cs index a704e20..30a71ab 100644 --- a/src/SimplePZMapmaker.Web/Program.cs +++ b/src/SimplePZMapmaker.Web/Program.cs @@ -88,7 +88,7 @@ app.MapPost("/api/building/validate",(GameAssetCatalog assets,StaticModelCatalog models,Building b)=>{AssetRules.Building(b,assets);foreach(var m in b.Design?.Floors.SelectMany(f=>f.StaticModels)??[])models.Validate(m);return Results.Ok(new{valid=true,building=b});}); app.MapPost("/api/buildings/regenerate",(BuildingRegenerateRequest request)=>{var changed=BuildingDesigner.Regenerate(request.Project,request.Id);return Results.Ok(new{project=request.Project,changed,skipped=request.Project.Buildings.Count-changed});}); app.MapPost("/api/search",async(SearchRequest request,OsmClient client,CancellationToken token)=>await client.Search(request.Query,token)); -app.MapPost("/api/export",async(GameAssetCatalog assets,StaticModelCatalog models,NativeExportService exporter,MapProject project,CancellationToken token)=>{AssetRules.Project(project,assets);models.Validate(project);return await exporter.Map(project,token);}); +app.MapPost("/api/export",async(HttpRequest request,GameAssetCatalog assets,StaticModelCatalog models,NativeExportService exporter,MapProject project,CancellationToken token)=>{var package=NativeExportService.Options(request);AssetRules.Project(project,assets);models.Validate(project);return await exporter.Map(project,token,package);}); app.MapPost("/api/validate",(GameAssetCatalog assets,StaticModelCatalog models,MapProject project)=>{AssetRules.Project(project,assets);models.Validate(project);return Results.Ok(new{valid=true,project});}); app.MapGet("/api/recovery/{id}",async(string id,RecoveryStore store,CancellationToken token)=>{var project=await store.LoadAsync(id,token);return project is null?Results.NotFound():Results.Ok(project);}); app.MapPost("/api/recovery/{id}",async(string id,MapProject project,RecoveryStore store,GameAssetCatalog assets,StaticModelCatalog models,CancellationToken token)=>{AssetRules.Project(project,assets);models.Validate(project);await store.SaveAsync(id,project,token);return Results.Ok(new{saved=true});}); diff --git a/src/SimplePZMapmaker.Web/Services/AssetRules.Mods.cs b/src/SimplePZMapmaker.Web/Services/AssetRules.Mods.cs index 434ff36..095c15f 100644 --- a/src/SimplePZMapmaker.Web/Services/AssetRules.Mods.cs +++ b/src/SimplePZMapmaker.Web/Services/AssetRules.Mods.cs @@ -26,6 +26,12 @@ public static void StampDependencies(MapProject p,GameAssetCatalog assets) p.RequiredMods=assets.ResolveRequirements(ids).Select(m=>m.ModId).Concat(p.Buildings.SelectMany(b=>b.RequiredMods)).Concat(p.StaticModels.SelectMany(m=>m.RequiredMods)).Distinct(StringComparer.Ordinal).ToList();if(p.RequiredMods.Count>0||p.AssetSources.Count>0)p.Version=Math.Max(p.Version,6); } public static void ValidateDependencies(MapProject p,GameAssetCatalog assets)=>StampDependencies(p,assets); + /// <summary>A shared decoration is a complete game tile group; its providers travel with it into every section that draws it.</summary> + public static void SharedDecoration(SharedDecoration d,GameAssetCatalog assets) + { + CheckParts(assets,d.Asset,d.Parts);if(!assets.HasActiveMods&&d.AssetSources.Count==0&&d.RequiredMods.Count==0)return; + var ids=d.Parts.Select(p=>p.Tile).Append(d.Asset).Distinct().ToArray();d.AssetSources=StampSources(ids,d.AssetSources,assets);d.RequiredMods=assets.ResolveRequirements(ids).Select(m=>m.ModId).Distinct(StringComparer.Ordinal).ToList(); + } private static Dictionary<string,string> StampSources(IEnumerable<string> ids,Dictionary<string,string> saved,GameAssetCatalog assets) { var next=new Dictionary<string,string>(StringComparer.Ordinal);foreach(var id in ids){var provider=assets.Get(id).ModId??"game";if(saved.TryGetValue(id,out var original)&&original!=provider)throw new ArgumentException($"Tile {id} has a different source: expected {original}, active {provider}. Enable the original mod.");next[id]=provider;}return next; diff --git a/src/SimplePZMapmaker.Web/Services/ExportJobEndpoints.cs b/src/SimplePZMapmaker.Web/Services/ExportJobEndpoints.cs index b7309a4..b078ca6 100644 --- a/src/SimplePZMapmaker.Web/Services/ExportJobEndpoints.cs +++ b/src/SimplePZMapmaker.Web/Services/ExportJobEndpoints.cs @@ -8,6 +8,6 @@ public static IEndpointRouteBuilder MapExportJobEndpoints(this IEndpointRouteBui app.MapPost("/api/world/export/jobs/sections/{memberId}",(string memberId,WorldProject world,GameAssetCatalog assets,StaticModelCatalog models,ExportJobStore store)=>{WorldEndpoints.Validate(world,assets,models);var job=store.StartWorld(world,memberId);return Results.Accepted($"/api/export/jobs/{job.Id}",job);}); app.MapPost("/api/world/workspaces/{id}/export/jobs",async(string id,WorldStore worlds,ExportJobStore store,GameAssetCatalog assets,StaticModelCatalog models,CancellationToken token)=>{var lease=await worlds.PinAsync(id,token);var job=store.StartWorkspace(lease,assets,models);return Results.Accepted($"/api/export/jobs/{job.Id}",job);}); app.MapPost("/api/world/workspaces/{id}/sections/{memberId}/export/jobs",async(string id,string memberId,WorldStore worlds,ExportJobStore store,GameAssetCatalog assets,StaticModelCatalog models,CancellationToken token)=>{var lease=await worlds.PinAsync(id,token);var job=store.StartWorkspace(lease,assets,models,memberId);return Results.Accepted($"/api/export/jobs/{job.Id}",job);}); - app.MapGet("/api/export/jobs",(ExportJobStore store)=>store.List());app.MapGet("/api/export/jobs/{id}",(string id,ExportJobStore store)=>store.Status(id));app.MapPost("/api/export/jobs/{id}/cancel",(string id,ExportJobStore store)=>store.Cancel(id));app.MapGet("/api/export/jobs/{id}/download",(string id,ExportJobStore store)=>store.Download(id));return app; + app.MapGet("/api/export/jobs",(ExportJobStore store)=>store.List());app.MapGet("/api/export/jobs/{id}",(string id,ExportJobStore store)=>store.Status(id));app.MapPost("/api/export/jobs/{id}/cancel",(string id,ExportJobStore store)=>store.Cancel(id));app.MapMethods("/api/export/jobs/{id}/download",["GET","POST"],(HttpRequest request,string id,ExportJobStore store,NativeExportService exporter)=>NativeExportService.Options(request) is {} package?exporter.Package(store.OpenArchive(id),"SimplePZMod.zip",package):store.Download(id));return app; } } diff --git a/src/SimplePZMapmaker.Web/Services/ExportJobStore.cs b/src/SimplePZMapmaker.Web/Services/ExportJobStore.cs index cf71019..495e439 100644 --- a/src/SimplePZMapmaker.Web/Services/ExportJobStore.cs +++ b/src/SimplePZMapmaker.Web/Services/ExportJobStore.cs @@ -23,6 +23,8 @@ public ExportJobStatus StartWorkspace(WorldSectionArchive.RevisionLease lease,Ga public ExportJobStatus Status(string id)=>Get(id).State; public ExportJobStatus[] List()=>jobs.Values.Select(j=>j.State).OrderByDescending(j=>j.CreatedAtUtc).ToArray(); public ExportJobStatus Cancel(string id){lock(gate){var job=Get(id);if(job.State.Status is "queued" or "running"){job.Cancel.Cancel();if(job.State.Status=="queued"){Change(job,job.State with{Status="cancelled",Error="Export canceled. The project was preserved."});TryDelete(PathFor(id,"source"));job.Workspace?.Dispose();job.Workspace=null;}}return job.State;}} + /// <summary>The completed archive of a job, opened for reading.</summary> + public FileStream OpenArchive(string id){var job=Get(id);if(job.State.Status!="completed"||!File.Exists(PathFor(id,"zip")))throw new ArgumentException("The native archive is not ready or has expired. Queue a new export.");return new FileStream(PathFor(id,"zip"),FileMode.Open,FileAccess.Read,FileShare.Read);} public IResult Download(string id){var job=Get(id);if(job.State.Status!="completed"||!File.Exists(PathFor(id,"zip")))throw new ArgumentException("The native archive is not ready or has expired. Queue a new export.");return Results.File(PathFor(id,"zip"),"application/zip",job.State.Kind=="world"?"SimplePZWorld.zip":job.State.Kind=="section"?"SimplePZSection.zip":"SimplePZMap.zip",enableRangeProcessing:true);} Job Get(string id)=>Safe(id)&&jobs.TryGetValue(id,out var job)?job:throw new ArgumentException("Export job not found."); async Task Worker(){try{await foreach(var job in queue.Reader.ReadAllAsync(shutdown.Token)){if(job.Cancel.IsCancellationRequested)continue;try{lock(gate){if(job.Cancel.IsCancellationRequested)continue;Change(job,job.State with{Status="running"});}using var linked=CancellationTokenSource.CreateLinkedTokenSource(job.Cancel.Token,shutdown.Token);linked.CancelAfter(TimeSpan.FromMinutes(30));var token=linked.Token;var context=new NativeExportContext(Cache,p=>{lock(gate){job.State=job.State with{Progress=p};if(p.Stage is "section-completed" or "completed")Save(job);}});using(var file=File.Create(PathFor(job.State.Id,"partial"))){using var bounded=new BoundedArchiveStream(file,256L*1024*1024);if(job.Workspace is {} lease){var metadata=await lease.ReadMetadataAsync(token);var snapshot=new WorkspaceExportSource(lease,metadata,job.Assets,job.Models);await NativeStreamedWorldExporter.ExportAsync(snapshot,bounded,context,token,job.Section);}else{using var source=File.OpenRead(PathFor(job.State.Id,"source"));if(job.State.Kind=="map")NativeMapExporter.ExportWithProgress(JsonSerializer.Deserialize<MapProject>(source,NativeMapExporter.JsonOptions)!,bounded,context,token);else{var world=JsonSerializer.Deserialize<WorldProject>(source,NativeMapExporter.JsonOptions)!;if(job.Section is {} section)NativeWorldExporter.ExportSectionWithProgress(world,section,bounded,context,token);else NativeWorldExporter.ExportWithProgress(world,bounded,context,token);}}} diff --git a/src/SimplePZMapmaker.Web/Services/NativeExportService.cs b/src/SimplePZMapmaker.Web/Services/NativeExportService.cs index 4c5666d..16ab0d9 100644 --- a/src/SimplePZMapmaker.Web/Services/NativeExportService.cs +++ b/src/SimplePZMapmaker.Web/Services/NativeExportService.cs @@ -6,17 +6,35 @@ public sealed class NativeExportService(string workspace,ExportJobStore? jobs=nu { readonly NativeSectionCache cache=jobs?.Cache??new(Path.Combine(workspace,"work","native-section-cache")); readonly SemaphoreSlim gate=new(2,2); - public async Task<IResult> World(WorldProject world,CancellationToken token)=>await Export(s=>NativeWorldExporter.ExportWithProgress(world,s,new(cache),token),"SimplePZWorld.zip",token); - public async Task<IResult> Map(MapProject p,CancellationToken token)=>await Export(s=>NativeMapExporter.ExportWithProgress(p,s,new(cache),token),"SimplePZMap.zip",token); + public async Task<IResult> World(WorldProject world,CancellationToken token,NativeModOptions? package=null)=>await Export(s=>NativeWorldExporter.ExportWithProgress(world,s,new(cache),token),"SimplePZWorld.zip",token,package); + public async Task<IResult> Map(MapProject p,CancellationToken token,NativeModOptions? package=null)=>await Export(s=>NativeMapExporter.ExportWithProgress(p,s,new(cache),token),"SimplePZMap.zip",token,package); public async Task<IResult> Section(WorldProject world,string id,CancellationToken token)=>await Export(s=>NativeWorldExporter.ExportSectionWithProgress(world,id,s,new(cache),token),"Section-"+id+".zip",token); - public async Task<IResult> Workspace(WorldSectionArchive.RevisionLease lease,GameAssetCatalog assets,StaticModelCatalog models,CancellationToken token,string? memberId=null){ - using(lease){var metadata=await lease.ReadMetadataAsync(token);var source=new WorkspaceExportSource(lease,metadata,assets,models);return await ExportAsync(s=>NativeStreamedWorldExporter.ExportAsync(source,s,new(cache),token,memberId),memberId is null?"SimplePZWorld.zip":"SimplePZSection.zip",token);} + public async Task<IResult> Workspace(WorldSectionArchive.RevisionLease lease,GameAssetCatalog assets,StaticModelCatalog models,CancellationToken token,string? memberId=null,NativeModOptions? package=null){ + using(lease){var metadata=await lease.ReadMetadataAsync(token);var source=new WorkspaceExportSource(lease,metadata,assets,models);return await ExportAsync(s=>NativeStreamedWorldExporter.ExportAsync(source,s,new(cache),token,memberId),memberId is null?"SimplePZWorld.zip":"SimplePZSection.zip",token,package);} } - async Task<IResult> ExportAsync(Func<Stream,Task> write,string name,CancellationToken token){ + /// <summary>Mod naming, layout and install options from the query: modId, mapFolder, modName, commonMaps=true, install=true. + /// Installing writes into the user's mods folder, so it is accepted only on POST, which the host middleware limits to JSON + /// requests from the editor's own origin; a GET (a link or image on any web page) can only rename the returned archive.</summary> + public static NativeModOptions? Options(HttpRequest request){ + string? Text(string key)=>request.Query.TryGetValue(key,out var v)&&!string.IsNullOrWhiteSpace(v.ToString())?v.ToString().Trim():null;bool Flag(string key)=>Text(key) is "true" or "1"; + var o=new NativeModOptions(Text("modId"),Text("mapFolder"),Text("modName"),Flag("commonMaps"),Flag("install"));if(!o.Renames&&!o.Install)return null; + if(o.Install&&!HttpMethods.IsPost(request.Method))throw new ArgumentException("Installing into Zomboid/mods needs a POST request from the local editor.");NativeModPackage.Validate(o);return o; + } + /// <summary>Applies naming/layout to a finished archive, then streams it or installs it into the mods folder. Takes ownership of the archive.</summary> + public IResult Package(Stream archive,string name,NativeModOptions? package){ + if(package is null)return Results.Stream(archive,"application/zip",name); + var result=archive; + if(package.Renames){var folder=Path.Combine(workspace,"work","native-exports");Directory.CreateDirectory(folder);var repacked=new FileStream(Path.Combine(folder,Guid.NewGuid().ToString("N")+".zip"),FileMode.CreateNew,FileAccess.ReadWrite,FileShare.Read,65536,FileOptions.DeleteOnClose);try{NativeModPackage.Repack(archive,repacked,package);}catch{repacked.Dispose();throw;}archive.Dispose();repacked.Position=0;result=repacked;} + if(!package.Install)return Results.Stream(result,"application/zip",name); + using(result){var installed=NativeModPackage.Install(result,ModsDirectory);return Results.Ok(new{installed,modsDirectory=ModsDirectory,note="Enable the mod and create a NEW game, or set Mods= and Map= on a server. A later install of the same mod id replaces this folder."});} + } + /// <summary>Zomboid/mods of the current user, or PZMAP_MODS_DIR.</summary> + public string ModsDirectory {get;init;}=NativeModPackage.DefaultModsDirectory(); + async Task<IResult> ExportAsync(Func<Stream,Task> write,string name,CancellationToken token,NativeModOptions? package=null){ if(!await gate.WaitAsync(0,token))throw new ArgumentException("Two native exports are already running. Wait or cancel an export.");FileStream? stream=null; - try{var folder=Path.Combine(workspace,"work","native-exports");Directory.CreateDirectory(folder);var path=Path.Combine(folder,Guid.NewGuid().ToString("N")+".zip");stream=new FileStream(path,FileMode.CreateNew,FileAccess.ReadWrite,FileShare.Read,65536,FileOptions.Asynchronous|FileOptions.DeleteOnClose);using(var bounded=new BoundedArchiveStream(stream,256L*1024*1024))await write(bounded);token.ThrowIfCancellationRequested();stream.Position=0;var result=Results.Stream(stream,"application/zip",name);stream=null;return result;} + try{var folder=Path.Combine(workspace,"work","native-exports");Directory.CreateDirectory(folder);var path=Path.Combine(folder,Guid.NewGuid().ToString("N")+".zip");stream=new FileStream(path,FileMode.CreateNew,FileAccess.ReadWrite,FileShare.Read,65536,FileOptions.Asynchronous|FileOptions.DeleteOnClose);using(var bounded=new BoundedArchiveStream(stream,256L*1024*1024))await write(bounded);token.ThrowIfCancellationRequested();stream.Position=0;var archive=stream;stream=null;try{return Package(archive,name,package);}catch{archive.Dispose();throw;}} catch(Exception e) when(e is IOException or UnauthorizedAccessException){throw new ArgumentException("The native export could not write its workspace file. Check free disk space and folder permissions, then retry.");} finally{stream?.Dispose();gate.Release();} } - Task<IResult> Export(Action<Stream> write,string name,CancellationToken token)=>ExportAsync(stream=>{write(stream);return Task.CompletedTask;},name,token); + Task<IResult> Export(Action<Stream> write,string name,CancellationToken token,NativeModOptions? package=null)=>ExportAsync(stream=>{write(stream);return Task.CompletedTask;},name,token,package); } diff --git a/src/SimplePZMapmaker.Web/Services/OsmWorldImportService.cs b/src/SimplePZMapmaker.Web/Services/OsmWorldImportService.cs index 4535d77..7b81e41 100644 --- a/src/SimplePZMapmaker.Web/Services/OsmWorldImportService.cs +++ b/src/SimplePZMapmaker.Web/Services/OsmWorldImportService.cs @@ -28,13 +28,17 @@ public static OsmWorldPlan Plan(OsmWorldImportRequest request){ private async Task Run(Job job,OsmWorldImportRequest request,OsmWorldPlan plan){string? temporary=null;try{ using var deadline=CancellationTokenSource.CreateLinkedTokenSource(job.Cancel.Token);deadline.CancelAfter(TimeSpan.FromMinutes(15));var token=deadline.Token;lock(job.Sync)job.Status="running"; var progress=new InlineProgress<OsmProgress>(value=>{lock(job.Sync)job.Progress=value;});var download=await client.FetchTiledDetailed(request.Bounds,token,progress,64_000_000);var geometry=new OsmGeometrySet(64_000_000);geometry.Add(download.Data); - var world=new WorldProject{Name=request.Name,Id=request.ExistingWorld?.Id??Guid.NewGuid().ToString("N"),GeoReference=plan.GeoReference,LinearFeatures=request.PreserveEdits?request.ExistingWorld?.LinearFeatures??[]:[],Buildings=request.PreserveEdits?request.ExistingWorld?.Buildings??[]:[],Elevation=request.PreserveEdits?request.ExistingWorld?.Elevation:null};var total=plan.Columns*plan.Rows;var completed=0;var warnings=new HashSet<string>(download.Warnings){"Map data © OpenStreetMap contributors — https://www.openstreetmap.org/copyright — ODbL 1.0",$"Source: {download.Source}; data date: {download.DataTimestamp??"not specified"}; retrieved: {download.RetrievedAtUtc:O}; requests: {download.Requests}; cache: {(download.FromCache?"yes":"no")}."}; + var world=new WorldProject{Name=request.Name,Id=request.ExistingWorld?.Id??Guid.NewGuid().ToString("N"),GeoReference=plan.GeoReference,LinearFeatures=request.PreserveEdits?request.ExistingWorld?.LinearFeatures??[]:[],Buildings=request.PreserveEdits?request.ExistingWorld?.Buildings??[]:[],Elevation=request.PreserveEdits?request.ExistingWorld?.Elevation:null}; + // Preserve edits keeps authored world fences and their styles; imported world fences are rebuilt from the refreshed source below. + if(request.PreserveEdits&&request.ExistingWorld is {} fenced){world.Fences=fenced.Fences.Where(f=>f.SourceId is null).ToList();world.FenceStyles=[..fenced.FenceStyles];world.GateStyles=[..fenced.GateStyles];world.Decorations=[..fenced.Decorations];}var total=plan.Columns*plan.Rows;var completed=0;var warnings=new HashSet<string>(download.Warnings){"Map data © OpenStreetMap contributors — https://www.openstreetmap.org/copyright — ODbL 1.0",$"Source: {download.Source}; data date: {download.DataTimestamp??"not specified"}; retrieved: {download.RetrievedAtUtc:O}; requests: {download.Requests}; cache: {(download.FromCache?"yes":"no")}."}; for(var y=0;y<plan.Rows;y++)for(var x=0;x<plan.Columns;x++){ token.ThrowIfCancellationRequested();var cellX=request.OriginCellX+x*request.SectionSize/256;var cellY=request.OriginCellY+y*request.SectionSize/256;var projection=new GeographicProjection(plan.GeoReference,cellX,cellY);var bounds=projection.SectionBounds(request.SectionSize,request.SectionSize);var old=request.ExistingWorld?.Maps.SingleOrDefault(m=>m.Project.WorldX==cellX&&m.Project.WorldY==cellY); var project=old?.Project??MapGenerator.Generate(new(){Kind="blank",Size=request.SectionSize,Seed=request.Seed,Name=$"{request.Name[..Math.Min(60,request.Name.Length)]} {x+1}/{y+1}"});project.WorldX=cellX;project.WorldY=cellY;project.WorldSection=true;project.Version=Math.Max(project.Version,6);var json=geometry.ForSection(bounds);using var data=JsonDocument.Parse(json);if(data.RootElement.GetProperty("elements").GetArrayLength()>0){var result=GeographicImporter.Import(json,project,bounds,token,request.PreserveEdits,projection);project=result.Project;foreach(var warning in result.Warnings)warnings.Add(warning);}else{project.Bounds=bounds;project.MetresPerTile=request.MetresPerTile;} const string attribution="Map data © OpenStreetMap contributors — https://www.openstreetmap.org/copyright — ODbL 1.0";if(!project.Attribution.Contains(attribution))project.Attribution.Add(attribution);AssetRules.Project(project,assets);modelCatalog.Validate(project);world.Maps.Add(new(){Id=old?.Id??Guid.NewGuid().ToString("N"),Project=project});progress.Report(new("compose",++completed,total,download.Source,download.DataTimestamp)); } if(request.PreserveEdits&&request.ExistingWorld is {} priorWorld)foreach(var b in world.Buildings){foreach(var c in WorldAssembler.BuildingCells(b)){var targetMember=WorldAssembler.MemberAt(world,c.X,c.Y);var prior=WorldAssembler.MemberAt(priorWorld,c.X,c.Y);if(targetMember is null||prior is null)continue;var p=targetMember.Project;var oldp=prior.Project;p.Terrain[(c.Y-p.WorldY*256)*p.Width+c.X-p.WorldX*256]=oldp.Terrain[(c.Y-oldp.WorldY*256)*oldp.Width+c.X-oldp.WorldX*256];}if(b.SourceId is not null)foreach(var m in world.Maps){m.Project.Buildings.RemoveAll(local=>local.SourceId==b.SourceId);SiteModel.DetachMissingMembers(m.Project);}} + if(request.PreserveEdits&&request.ExistingWorld is {} decorated&&world.Decorations.Count>0){var decorationWarnings=new List<string>();WorldAssembler.KeepSharedDecorations(world,decorated,decorationWarnings);foreach(var warning in decorationWarnings)warnings.Add(warning);} + var fenceWarnings=new List<string>();GeographicImporter.PromoteSharedFences(world,fenceWarnings);foreach(var warning in fenceWarnings)warnings.Add(warning); var worldBuildingWarnings=new List<string>();GeographicImporter.ImportWorldBuildings(download.Data,world,request.Seed,worldBuildingWarnings,token);foreach(var warning in worldBuildingWarnings)warnings.Add(warning);foreach(var b in world.Buildings){AssetRules.Building(b,assets);if(b.Design is {} d)foreach(var f in d.Floors)foreach(var m in f.StaticModels)modelCatalog.Validate(m);} WorldEndpoints.Validate(world,assets,modelCatalog);token.ThrowIfCancellationRequested();Directory.CreateDirectory(directory);var target=Path.Combine(directory,job.Id+".pzworld.json");temporary=target+".tmp";await using(var file=File.Create(temporary)){await using var bounded=new BoundedOutput(file,WorldStore.MaxBytes);await JsonSerializer.SerializeAsync(bounded,world,NativeMapExporter.JsonOptions,token);}token.ThrowIfCancellationRequested();File.Move(temporary,target,true);temporary=null;lock(job.Sync){job.File=target;job.Warnings=warnings.Take(64).ToList();job.Progress=new("completed",total,total,download.Source,download.DataTimestamp);job.Status="completed";} }catch(OperationCanceledException){lock(job.Sync){job.Status=job.Cancel.IsCancellationRequested?"cancelled":"failed";job.Error=job.Cancel.IsCancellationRequested?"Import canceled. The original world was preserved.":"Import did not finish within 15 minutes. The original world was preserved.";}}catch(Exception e) when(e is ArgumentException or HttpRequestException or IOException or JsonException or InvalidOperationException){lock(job.Sync){job.Status="failed";job.Error=e.Message;}}finally{if(temporary is not null&&File.Exists(temporary))File.Delete(temporary);}} diff --git a/src/SimplePZMapmaker.Web/Services/WorkspaceExportSource.cs b/src/SimplePZMapmaker.Web/Services/WorkspaceExportSource.cs index d4cb73f..c03a1d8 100644 --- a/src/SimplePZMapmaker.Web/Services/WorkspaceExportSource.cs +++ b/src/SimplePZMapmaker.Web/Services/WorkspaceExportSource.cs @@ -5,7 +5,7 @@ namespace SimplePZMapmaker.Web.Services; /// <summary>Adapts an immutable workspace lease; resource validation runs on the same revision as the writer.</summary> internal sealed class WorkspaceExportSource(WorldSectionArchive.RevisionLease lease,WorldSectionArchive.Metadata metadata,GameAssetCatalog? assets,StaticModelCatalog? models):IStreamedWorldExportSource { - public WorldProject Metadata {get;}=new(){Version=metadata.Version,Id=metadata.Id,Name=metadata.Name,Buildings=metadata.Buildings,LinearFeatures=metadata.LinearFeatures,Fences=metadata.Fences,FenceStyles=metadata.FenceStyles,GateStyles=metadata.GateStyles,GeoReference=metadata.GeoReference,Elevation=metadata.Elevation}; + public WorldProject Metadata {get;}=new(){Version=metadata.Version,Id=metadata.Id,Name=metadata.Name,Buildings=metadata.Buildings,LinearFeatures=metadata.LinearFeatures,Fences=metadata.Fences,FenceStyles=metadata.FenceStyles,GateStyles=metadata.GateStyles,Decorations=metadata.Decorations,GeoReference=metadata.GeoReference,Elevation=metadata.Elevation}; public IReadOnlyList<StreamedWorldSection> Sections {get;}=lease.PinnedManifest.Sections.Select(s=>new StreamedWorldSection(s.Id,s.Name,s.WorldX,s.WorldY,s.Width,s.Height)).ToArray(); public object AuthoringIndex=>lease.PinnedManifest; public async Task<MapProject> ReadSectionAsync(string id,CancellationToken token){var p=await lease.ReadSectionAsync(id,token);var sharedTiles=metadata.FenceStyles.SelectMany(s=>new[]{s.W,s.N,s.NW,s.Post,s.WAlt,s.NAlt}.OfType<string>()).Concat(metadata.GateStyles.SelectMany(s=>s.ClosedW.Concat(s.ClosedN).Concat(s.OpenW).Concat(s.OpenN))).ToHashSet(StringComparer.Ordinal);var fenceOwners=p.AssetSources.Where(pair=>sharedTiles.Contains(pair.Key)).ToArray();if(assets is not null)AssetRules.Project(p,assets);foreach(var pair in fenceOwners)p.AssetSources.TryAdd(pair.Key,pair.Value);models?.Validate(p);return p;} diff --git a/src/SimplePZMapmaker.Web/Services/WorldEndpoints.cs b/src/SimplePZMapmaker.Web/Services/WorldEndpoints.cs index dcbe1ee..02904fb 100644 --- a/src/SimplePZMapmaker.Web/Services/WorldEndpoints.cs +++ b/src/SimplePZMapmaker.Web/Services/WorldEndpoints.cs @@ -12,8 +12,8 @@ public static IEndpointRouteBuilder MapWorldEndpoints(this IEndpointRouteBuilder var world=WorldAssembler.Move(request.World,request.MemberId,request.CellX,request.CellY);var bounds=Validate(world,assets,models); return Results.Ok(new{world,bounds,warnings=WorldAssembler.SeamWarnings(world)}); }); - app.MapPost("/api/world/export",async(WorldProject world,GameAssetCatalog assets,StaticModelCatalog models,NativeExportService exporter,CancellationToken token)=>{ - Validate(world,assets,models);return await exporter.World(world,token); + app.MapPost("/api/world/export",async(HttpRequest request,WorldProject world,GameAssetCatalog assets,StaticModelCatalog models,NativeExportService exporter,CancellationToken token)=>{ + var package=NativeExportService.Options(request);Validate(world,assets,models);return await exporter.World(world,token,package); }); app.MapPost("/api/world/buildings",(WorldBuildingRequest request,GameAssetCatalog assets,StaticModelCatalog models)=>{ var world=WorldAssembler.PutBuilding(request.World,request.Building);Validate(world,assets,models);return Results.Ok(new{world}); @@ -24,6 +24,15 @@ public static IEndpointRouteBuilder MapWorldEndpoints(this IEndpointRouteBuilder app.MapPost("/api/world/buildings/remove",(WorldRemoveBuildingRequest request,GameAssetCatalog assets,StaticModelCatalog models)=>{ var world=WorldAssembler.Clone(request.World);if(world.Buildings.RemoveAll(b=>b.Id==request.BuildingId)!=1)throw new ArgumentException("Shared building not found.");Validate(world,assets,models);return Results.Ok(new{world}); }); + app.MapPost("/api/world/decorations",(WorldDecorationRequest request,GameAssetCatalog assets,StaticModelCatalog models)=>{ + var world=WorldAssembler.PutDecoration(request.World,request.Decoration);Validate(world,assets,models);return Results.Ok(new{world}); + }); + app.MapPost("/api/world/decorations/promote",(WorldPromoteDecorationRequest request,GameAssetCatalog assets,StaticModelCatalog models)=>{ + var world=WorldAssembler.PromoteDecoration(request.World,request.MemberId,request.DecorationId,request.X,request.Y);Validate(world,assets,models);return Results.Ok(new{world}); + }); + app.MapPost("/api/world/decorations/remove",(WorldRemoveDecorationRequest request,GameAssetCatalog assets,StaticModelCatalog models)=>{ + var world=WorldAssembler.RemoveDecoration(request.World,request.DecorationId);Validate(world,assets,models);return Results.Ok(new{world}); + }); app.MapPost("/api/world/seams/repair",(WorldSeamRequest request,GameAssetCatalog assets,StaticModelCatalog models)=>{ var result=WorldSeams.Repair(request.World,request.Mode);Validate(result.World,assets,models);return Results.Ok(result); }); @@ -45,11 +54,14 @@ public static IEndpointRouteBuilder MapWorldEndpoints(this IEndpointRouteBuilder }); app.MapPost("/api/world/recovery/{id}/clear",async(string id,WorldStore store,CancellationToken token)=>{await store.ClearAsync(id,token);return Results.Ok(new{cleared=true});});return app; } - internal static WorldAssembler.Bounds Validate(WorldProject world,GameAssetCatalog assets,StaticModelCatalog models){var bounds=WorldAssembler.Validate(world);foreach(var member in world.Maps){AssetRules.Project(member.Project,assets);models.Validate(member.Project);}foreach(var building in world.Buildings){AssetRules.Building(building,assets);ModelRules.Building(building,models);}AssetRules.WorldInfrastructure(world,assets);return bounds;} + public static WorldAssembler.Bounds Validate(WorldProject world,GameAssetCatalog assets,StaticModelCatalog models){var bounds=WorldAssembler.Validate(world);foreach(var member in world.Maps){AssetRules.Project(member.Project,assets);models.Validate(member.Project);}foreach(var building in world.Buildings){AssetRules.Building(building,assets);ModelRules.Building(building,models);}foreach(var decoration in world.Decorations)AssetRules.SharedDecoration(decoration,assets);if(world.Decorations.Count>0)WorldAssembler.Validate(world);AssetRules.WorldInfrastructure(world,assets);return bounds;} } public sealed record WorldMoveRequest(WorldProject World,string MemberId,int CellX,int CellY); public sealed record WorldBuildingRequest(WorldProject World,Building Building); public sealed record WorldPromoteRequest(WorldProject World,string MemberId,string BuildingId,int X,int Y); public sealed record WorldRemoveBuildingRequest(WorldProject World,string BuildingId); public sealed record WorldSeamRequest(WorldProject World,string Mode); +public sealed record WorldDecorationRequest(WorldProject World,SharedDecoration Decoration); +public sealed record WorldPromoteDecorationRequest(WorldProject World,string MemberId,string DecorationId,int? X=null,int? Y=null); +public sealed record WorldRemoveDecorationRequest(WorldProject World,string DecorationId); public sealed record WorldElevationRequest(WorldProject World,string Text,string CoordinateSystem,string Source,string License); diff --git a/src/SimplePZMapmaker.Web/Services/WorldSectionArchive.cs b/src/SimplePZMapmaker.Web/Services/WorldSectionArchive.cs index 8759022..cc000c4 100644 --- a/src/SimplePZMapmaker.Web/Services/WorldSectionArchive.cs +++ b/src/SimplePZMapmaker.Web/Services/WorldSectionArchive.cs @@ -21,6 +21,7 @@ public sealed class Metadata { public int Version {get;set;} public string Id {get;set;}="";public string Name {get;set;}=""; public List<LinearFeature> LinearFeatures {get;set;}=[];public List<Building> Buildings {get;set;}=[];public WorldGeoReference? GeoReference {get;set;} public ElevationSurvey? Elevation {get;set;} public List<FenceLine> Fences {get;set;}=[];public List<FenceStyle> FenceStyles {get;set;}=[];public List<GateStyle> GateStyles {get;set;}=[]; + public List<SharedDecoration> Decorations {get;set;}=[]; } public sealed record SectionSnapshot(long Revision,WorldMapMember Member,Metadata Shared){public List<SectionReference> Sections {get;init;}=[];public InfrastructureGeometry.Geometry? SharedInfrastructure {get;init;}} sealed class LeaseState {public readonly object Gate=new();public readonly Dictionary<Guid,Manifest> Revisions=[];} @@ -49,10 +50,10 @@ async Task<FileStream> OpenBlob(string id,string hash,long bytes,int maximum,Can } async Task<T> ReadBlob<T>(string id,string hash,long bytes,int maximum,CancellationToken token){await using var stream=await OpenBlob(id,hash,bytes,maximum,token);return await JsonSerializer.DeserializeAsync<T>(stream,NativeMapExporter.JsonOptions,token)??throw new ArgumentException("A saved section is empty.");} async Task<MapProject> ReadProject(string id,SectionReference s,CancellationToken token){var p=await ReadBlob<MapProject>(id,s.Hash,s.Bytes,SectionBytes,token);ProjectValidator.Validate(p);if(p.Name!=s.Name||p.WorldX!=s.WorldX||p.WorldY!=s.WorldY||p.Width!=s.Width||p.Height!=s.Height||s.SpawnCount is {} count&&p.Spawns.Count!=count)throw new ArgumentException("The section differs from its saved index.");return p;} - async Task<Metadata> ReadMetadata(string id,Manifest m,CancellationToken token){var meta=await ReadBlob<Metadata>(id,m.MetadataHash,m.MetadataBytes,MetadataBytes,token);if(meta.Id!=m.Id||meta.Name!=m.Name||meta.Version is <1 or >7||meta.Buildings is null||meta.LinearFeatures is null||meta.Buildings.Count>4000||meta.LinearFeatures.Count>64||meta.LinearFeatures.Any(f=>f is null)||meta.Buildings.Any(b=>b is null))throw new ArgumentException("World metadata differs from its saved index.");FenceLines.Validate(meta.Fences,meta.FenceStyles,meta.GateStyles,258048);if(meta.GeoReference is {} geo)WorldGeoReference.Validate(geo);if(meta.Elevation is {} survey)ElevationSurvey.Validate(survey);foreach(var f in meta.LinearFeatures)WorldInfrastructure.ValidateSource(f);if(meta.LinearFeatures.Select(f=>f.Id).Distinct().Count()!=meta.LinearFeatures.Count)throw new ArgumentException("Duplicate shared path ID.");var occupied=new HashSet<(int,int)>();var ids=new HashSet<string>();foreach(var b in meta.Buildings){if(!ids.Add(b.Id)||b.X<0||b.Y<0||b.X>258000||b.Y>258000)throw new ArgumentException("Invalid shared building identity or coordinates.");BuildingDesigner.Validate(b);foreach(var cell in WorldAssembler.BuildingCells(b)){if(!m.Sections.Any(s=>Inside(s,cell.X,cell.Y)))throw new ArgumentException("A shared building extends into an unloaded gap in the world layout.");if(!occupied.Add(cell))throw new ArgumentException("Shared buildings overlap.");}}return meta;} + async Task<Metadata> ReadMetadata(string id,Manifest m,CancellationToken token){var meta=await ReadBlob<Metadata>(id,m.MetadataHash,m.MetadataBytes,MetadataBytes,token);if(meta.Id!=m.Id||meta.Name!=m.Name||meta.Version is <1 or >8||meta.Decorations is null||meta.Decorations.Count>WorldAssembler.MaximumSharedDecorations||meta.Buildings is null||meta.LinearFeatures is null||meta.Buildings.Count>4000||meta.LinearFeatures.Count>64||meta.LinearFeatures.Any(f=>f is null)||meta.Buildings.Any(b=>b is null))throw new ArgumentException("World metadata differs from its saved index.");FenceLines.Validate(meta.Fences,meta.FenceStyles,meta.GateStyles,258048);if(meta.GeoReference is {} geo)WorldGeoReference.Validate(geo);if(meta.Elevation is {} survey)ElevationSurvey.Validate(survey);foreach(var f in meta.LinearFeatures)WorldInfrastructure.ValidateSource(f);if(meta.LinearFeatures.Select(f=>f.Id).Distinct().Count()!=meta.LinearFeatures.Count)throw new ArgumentException("Duplicate shared path ID.");var occupied=new HashSet<(int,int)>();var ids=new HashSet<string>();foreach(var b in meta.Buildings){if(!ids.Add(b.Id)||b.X<0||b.Y<0||b.X>258000||b.Y>258000)throw new ArgumentException("Invalid shared building identity or coordinates.");BuildingDesigner.Validate(b);foreach(var cell in WorldAssembler.BuildingCells(b)){if(!m.Sections.Any(s=>Inside(s,cell.X,cell.Y)))throw new ArgumentException("A shared building extends into an unloaded gap in the world layout.");if(!occupied.Add(cell))throw new ArgumentException("Shared buildings overlap.");}}var decorationIds=new HashSet<string>(StringComparer.Ordinal);foreach(var d in meta.Decorations){WorldAssembler.ValidateDecoration(d);if(!decorationIds.Add(d.Id))throw new ArgumentException("Duplicate shared decoration ID.");foreach(var cell in WorldAssembler.DecorationCells(d)){if(!m.Sections.Any(s=>Inside(s,cell.X,cell.Y)))throw new ArgumentException("A shared decoration extends into an unloaded gap in the world layout.");if(!occupied.Add(cell))throw new ArgumentException("A shared decoration overlaps a shared building or another shared decoration.");}}return meta;} internal static bool Inside(SectionReference s,int x,int y)=>x>=s.WorldX*256&&y>=s.WorldY*256&&x<s.WorldX*256+s.Width&&y<s.WorldY*256+s.Height; public async Task Save(string id,WorldProject world,CancellationToken token){ - FileName(id);WorldAssembler.Validate(world);var old=await RecoveryArchive.Read<Manifest>(FileName(id),ManifestBytes,Validate,token);var metadata=new Metadata{Version=world.Version,Id=world.Id,Name=world.Name,LinearFeatures=world.LinearFeatures,Buildings=world.Buildings,Fences=world.Fences,FenceStyles=world.FenceStyles,GateStyles=world.GateStyles,GeoReference=world.GeoReference,Elevation=world.Elevation};var meta=await WriteBlob(id,metadata,MetadataBytes,token); + FileName(id);WorldAssembler.Validate(world);var old=await RecoveryArchive.Read<Manifest>(FileName(id),ManifestBytes,Validate,token);var metadata=new Metadata{Version=world.Version,Id=world.Id,Name=world.Name,LinearFeatures=world.LinearFeatures,Buildings=world.Buildings,Fences=world.Fences,FenceStyles=world.FenceStyles,GateStyles=world.GateStyles,Decorations=world.Decorations,GeoReference=world.GeoReference,Elevation=world.Elevation};var meta=await WriteBlob(id,metadata,MetadataBytes,token); var manifest=new Manifest{Id=world.Id,StorageId=id,Name=world.Name,Revision=checked((old?.Revision??0)+1),MetadataHash=meta.Hash,MetadataBytes=meta.Bytes};foreach(var m in world.Maps){var p=m.Project;var blob=await WriteBlob(id,p,SectionBytes,token);manifest.Sections.Add(new(m.Id,blob.Hash,p.Name,p.WorldX,p.WorldY,p.Width,p.Height,blob.Bytes,p.Spawns.Count));}Validate(manifest);await RecoveryArchive.Write(FileName(id),manifest,ManifestBytes,token);Prune(id); } public RevisionLease Pin(string id,Manifest manifest){Validate(manifest);var copy=JsonSerializer.Deserialize<Manifest>(JsonSerializer.Serialize(manifest,NativeMapExporter.JsonOptions),NativeMapExporter.JsonOptions)!;copy.StorageId=id;var state=State(id);lock(state.Gate){var key=Guid.NewGuid();state.Revisions.Add(key,copy);return new(this,id,copy,()=>{lock(state.Gate)state.Revisions.Remove(key);});}} @@ -75,7 +76,7 @@ public async Task<SectionSnapshot> ReadSection(string id,Manifest m,string membe using var lease=Pin(id,m);var p=await lease.ReadSectionAsync(memberId,token);var meta=await lease.ReadMetadataAsync(token);var projected=await lease.ProjectSectionAsync(memberId,p,meta,token);var shared=WorldSectionProjection.Context(meta,p);return new(m.Revision,new(){Id=memberId,Project=p},shared){Sections=m.Sections,SharedInfrastructure=projected.SharedInfrastructure}; } public async Task<WorldProject> ReadWorld(string id,Manifest m,CancellationToken token){ - if(m.MetadataBytes+m.Sections.Sum(s=>s.Bytes)>WorldStore.MaxBytes)throw new WorldCompatibilityBudgetException();using var lease=Pin(id,m);var meta=await lease.ReadMetadataAsync(token);var world=new WorldProject{Version=meta.Version,Id=meta.Id,Name=meta.Name,LinearFeatures=meta.LinearFeatures,Buildings=meta.Buildings,Fences=meta.Fences,FenceStyles=meta.FenceStyles,GateStyles=meta.GateStyles,GeoReference=meta.GeoReference,Elevation=meta.Elevation};foreach(var s in m.Sections)world.Maps.Add(new(){Id=s.Id,Project=await lease.ReadSectionAsync(s.Id,token)});WorldAssembler.Validate(world);return world; + if(m.MetadataBytes+m.Sections.Sum(s=>s.Bytes)>WorldStore.MaxBytes)throw new WorldCompatibilityBudgetException();using var lease=Pin(id,m);var meta=await lease.ReadMetadataAsync(token);var world=new WorldProject{Version=meta.Version,Id=meta.Id,Name=meta.Name,LinearFeatures=meta.LinearFeatures,Buildings=meta.Buildings,Fences=meta.Fences,FenceStyles=meta.FenceStyles,GateStyles=meta.GateStyles,Decorations=meta.Decorations,GeoReference=meta.GeoReference,Elevation=meta.Elevation};foreach(var s in m.Sections)world.Maps.Add(new(){Id=s.Id,Project=await lease.ReadSectionAsync(s.Id,token)});WorldAssembler.Validate(world);return world; } public async Task ValidateSection(string id,string memberId,long revision,MapProject project,CancellationToken token){var m=await ReadManifest(id,0,token)??throw new ArgumentException("Section workspace not found.");if(m.Revision!=revision)throw new AgentRevisionConflict(m.Revision);using var lease=Pin(id,m);ValidateLayout(m,memberId,project);await lease.ProjectSectionAsync(memberId,project,token:token);} static int ValidateLayout(Manifest m,string memberId,MapProject project){ProjectValidator.Validate(project);var index=m.Sections.FindIndex(s=>s.Id==memberId);if(index<0)throw new ArgumentException("Section not found.");var original=m.Sections[index];if(project.WorldX!=original.WorldX||project.WorldY!=original.WorldY||project.Width!=original.Width||project.Height!=original.Height)throw new ArgumentException("A section-only save preserves its dimensions and cell position. Load and save the complete world to change its layout.");return index;} @@ -104,6 +105,11 @@ internal static void ValidateSharedReplacement(Metadata meta,MapProject p){ foreach(var s in p.Spawns)if(shared.Contains(s.Y*p.Width+s.X))throw new ArgumentException("A spawn overlaps a shared world building."); foreach(var m in p.StaticModels)foreach(var c in StaticModelPlacement.FootprintCells(m))if(shared.Contains((m.Y+c.Dy)*p.Width+m.X+c.Dx))throw new ArgumentException("A model footprint overlaps a shared world building."); foreach(var d in p.Decorations)foreach(var c in d.Parts)if(shared.Contains((d.Y+c.Dy)*p.Width+d.X+c.Dx))throw new ArgumentException("An object overlaps a shared world building."); + // Shared decorations are read-only in a section: local content keeps clear of their tiles and the ground under them stays dry. + var sharedDecorationIds=meta.Decorations.Select(d=>d.Id).ToHashSet(StringComparer.Ordinal);if(p.Decorations.Any(d=>sharedDecorationIds.Contains(d.Id)))throw new ArgumentException("A local decoration repeats a shared world decoration ID."); + var decorated=new HashSet<int>();foreach(var d in WorldAssembler.DecorationsFor(meta.Decorations,p))foreach(var c in d.Parts){var i=(d.Y+c.Dy)*p.Width+d.X+c.Dx;decorated.Add(i);if(p.Terrain[i]==Terrain.Water)throw new ArgumentException("The changed section places a shared decoration on water.");} + if(decorated.Count>0){foreach(var b in p.Buildings)foreach(var i in BuildingDesigner.Occupied(b)){var x=b.X+i%(b.Width+1);var y=b.Y+i/(b.Width+1);if(x>=0&&y>=0&&x<p.Width&&y<p.Height&&decorated.Contains(y*p.Width+x))throw new ArgumentException("A local building overlaps a shared world decoration.");} + if(p.Spawns.Any(s=>decorated.Contains(s.Y*p.Width+s.X))||p.StaticModels.Any(m=>StaticModelPlacement.FootprintCells(m).Any(c=>decorated.Contains((m.Y+c.Dy)*p.Width+m.X+c.Dx)))||p.Decorations.Any(d=>d.Parts.Any(c=>decorated.Contains((d.Y+c.Dy)*p.Width+d.X+c.Dx))))throw new ArgumentException("A spawn, model or decoration overlaps a shared world decoration.");} if(p.LinearFeatures.Any(f=>f.Level!=0)){var geometry=InfrastructureGeometry.Build(p);if(geometry.Tiles.Any(t=>shared.Contains(t.Y*p.Width+t.X))||geometry.Holes.Any(h=>shared.Contains(h.Y*p.Width+h.X)))throw new ArgumentException("Infrastructure overlaps a shared world building.");} foreach(var z in p.GameZones.Where(z=>z.Vehicle))for(var y=z.Y;y<z.Y+z.Height;y++)for(var x=z.X;x<z.X+z.Width;x++)if(ZoneGeometry.Covers(z,x,y)&&rooms.Contains(y*p.Width+x))throw new ArgumentException("A vehicle zone overlaps a shared world building."); } diff --git a/src/SimplePZMapmaker.Web/Services/WorldSectionProjection.cs b/src/SimplePZMapmaker.Web/Services/WorldSectionProjection.cs index 63961b5..90914a7 100644 --- a/src/SimplePZMapmaker.Web/Services/WorldSectionProjection.cs +++ b/src/SimplePZMapmaker.Web/Services/WorldSectionProjection.cs @@ -9,11 +9,11 @@ internal static WorldSectionArchive.Metadata Context(WorldSectionArchive.Metadat bool BuildingTouches(Building b)=>b.X<ox+project.Width&&b.Y<oy+project.Height&&b.X+b.Width>=ox&&b.Y+b.Height>=oy; bool PathTouches(LinearFeature f){var pad=Math.Ceiling(f.Width/2)+1+(f.Access=="stairs"?4*Math.Abs(f.Level):0);return f.Points.Min(p=>p.X)-pad<ox+project.Width&&f.Points.Max(p=>p.X)+pad>=ox&&f.Points.Min(p=>p.Y)-pad<oy+project.Height&&f.Points.Max(p=>p.Y)+pad>=oy;} var fences=metadata.Fences.Where(f=>f.Points.Max(v=>v.X)>=ox&&f.Points.Min(v=>v.X)<=ox+project.Width&&f.Points.Max(v=>v.Y)>=oy&&f.Points.Min(v=>v.Y)<=oy+project.Height).ToList();var styles=fences.Select(f=>f.Style).ToHashSet(StringComparer.Ordinal);var gates=fences.SelectMany(f=>f.Gates.Select(g=>g.Style)).ToHashSet(StringComparer.Ordinal); - return new(){Version=metadata.Version,Id=metadata.Id,Name=metadata.Name,GeoReference=metadata.GeoReference,Buildings=metadata.Buildings.Where(BuildingTouches).ToList(),LinearFeatures=metadata.LinearFeatures.Where(PathTouches).ToList(),Fences=fences,FenceStyles=metadata.FenceStyles.Where(s=>styles.Contains(s.Id)).ToList(),GateStyles=metadata.GateStyles.Where(s=>gates.Contains(s.Id)).ToList()}; + return new(){Version=metadata.Version,Id=metadata.Id,Name=metadata.Name,GeoReference=metadata.GeoReference,Buildings=metadata.Buildings.Where(BuildingTouches).ToList(),LinearFeatures=metadata.LinearFeatures.Where(PathTouches).ToList(),Fences=fences,FenceStyles=metadata.FenceStyles.Where(s=>styles.Contains(s.Id)).ToList(),GateStyles=metadata.GateStyles.Where(s=>gates.Contains(s.Id)).ToList(),Decorations=metadata.Decorations.Where(d=>WorldAssembler.DecorationsFor([d],project).Count>0).ToList()}; } internal static async Task<MapProject> Project(WorldSectionArchive.RevisionLease lease,WorldSectionArchive.SectionReference section,MapProject project,WorldSectionArchive.Metadata metadata,CancellationToken token){ ProjectValidator.Validate(project);WorldSectionArchive.ValidateSharedReplacement(metadata,project);var context=Context(metadata,project); - var layout=new WorldProject{Id=metadata.Id,Name=metadata.Name,Version=metadata.Version,Buildings=context.Buildings,LinearFeatures=context.LinearFeatures,Fences=context.Fences,FenceStyles=context.FenceStyles,GateStyles=context.GateStyles,GeoReference=metadata.GeoReference}; + var layout=new WorldProject{Id=metadata.Id,Name=metadata.Name,Version=metadata.Version,Buildings=context.Buildings,LinearFeatures=context.LinearFeatures,Fences=context.Fences,FenceStyles=context.FenceStyles,GateStyles=context.GateStyles,Decorations=context.Decorations,GeoReference=metadata.GeoReference}; foreach(var s in lease.PinnedManifest.Sections)layout.Maps.Add(new(){Id=s.Id,Project=s.Id==section.Id?project:new(){WorldX=s.WorldX,WorldY=s.WorldY,Width=s.Width,Height=s.Height,Name=s.Name}}); // Build uses layout rectangles for coverage and asks for actual terrain only at support/landing cells. // It never receives invented grass for an unloaded section. diff --git a/src/SimplePZMapmaker.Web/Services/WorldWorkspaceEndpoints.cs b/src/SimplePZMapmaker.Web/Services/WorldWorkspaceEndpoints.cs index d04a88b..3a33ad6 100644 --- a/src/SimplePZMapmaker.Web/Services/WorldWorkspaceEndpoints.cs +++ b/src/SimplePZMapmaker.Web/Services/WorldWorkspaceEndpoints.cs @@ -13,8 +13,8 @@ public static void MapWorldWorkspaceEndpoints(this WebApplication app){ app.MapGet("/api/world/workspaces/{id}/sections/{memberId}",async(string id,string memberId,int? version,WorldStore store,GameAssetCatalog assets,StaticModelCatalog models,CancellationToken token)=>{using var lease=await store.PinAsync(id,token,version??0);var prepared=await WorkspaceResourceRules.PrepareAsync(lease,memberId,null,assets,models,token);var manifest=lease.Manifest;return new WorldSectionArchive.SectionSnapshot(manifest.Revision,new(){Id=memberId,Project=prepared.Project},WorldSectionProjection.Context(prepared.Metadata,prepared.Project)){Sections=manifest.Sections,SharedInfrastructure=prepared.Projected.SharedInfrastructure};}); app.MapPost("/api/world/workspaces/{id}/sections/{memberId}",async(string id,string memberId,SectionRequest request,WorldStore store,GameAssetCatalog assets,StaticModelCatalog models,CancellationToken token)=>{using var lease=await store.PinAsync(id,token);if(lease.Manifest.Revision!=request.ExpectedRevision)throw new AgentRevisionConflict(lease.Manifest.Revision);var prepared=await WorkspaceResourceRules.PrepareAsync(lease,memberId,request.Project,assets,models,token);return await store.SaveSectionAsync(id,memberId,request.ExpectedRevision,prepared.Project,token);}); app.MapPost("/api/world/workspaces/{id}/sections/{memberId}/validate",async(string id,string memberId,SectionRequest request,WorldStore store,GameAssetCatalog assets,StaticModelCatalog models,CancellationToken token)=>{using var lease=await store.PinAsync(id,token);if(lease.Manifest.Revision!=request.ExpectedRevision)throw new AgentRevisionConflict(lease.Manifest.Revision);var prepared=await WorkspaceResourceRules.PrepareAsync(lease,memberId,request.Project,assets,models,token);await store.ValidateSectionAsync(id,memberId,request.ExpectedRevision,prepared.Project,token);return Results.Ok(new{valid=true,revision=request.ExpectedRevision});}); - app.MapGet("/api/world/workspaces/{id}/export",async(string id,WorldStore store,NativeExportService exporter,GameAssetCatalog assets,StaticModelCatalog models,CancellationToken token)=>{var lease=await store.PinAsync(id,token);return await exporter.Workspace(lease,assets,models,token);}); - app.MapGet("/api/world/workspaces/{id}/sections/{memberId}/export",async(string id,string memberId,WorldStore store,NativeExportService exporter,GameAssetCatalog assets,StaticModelCatalog models,CancellationToken token)=>{var lease=await store.PinAsync(id,token);return await exporter.Workspace(lease,assets,models,token,memberId);}); + app.MapMethods("/api/world/workspaces/{id}/export",["GET","POST"],async(HttpRequest request,string id,WorldStore store,NativeExportService exporter,GameAssetCatalog assets,StaticModelCatalog models,CancellationToken token)=>{var package=NativeExportService.Options(request);var lease=await store.PinAsync(id,token);return await exporter.Workspace(lease,assets,models,token,package:package);}); + app.MapMethods("/api/world/workspaces/{id}/sections/{memberId}/export",["GET","POST"],async(HttpRequest request,string id,string memberId,WorldStore store,NativeExportService exporter,GameAssetCatalog assets,StaticModelCatalog models,CancellationToken token)=>{var package=NativeExportService.Options(request);var lease=await store.PinAsync(id,token);return await exporter.Workspace(lease,assets,models,token,memberId,package);}); app.MapGet("/api/world/workspaces/{id}/world",async(string id,WorldStore store,GameAssetCatalog assets,StaticModelCatalog models,CancellationToken token)=>{var world=await store.LoadAsync(id,token)??throw new ArgumentException("Section workspace not found.");WorldEndpoints.Validate(world,assets,models);return world;}); } } diff --git a/src/SimplePZMapmaker.Web/wwwroot/app.js b/src/SimplePZMapmaker.Web/wwwroot/app.js index 86eaadf..cbb962a 100644 --- a/src/SimplePZMapmaker.Web/wwwroot/app.js +++ b/src/SimplePZMapmaker.Web/wwwroot/app.js @@ -32,12 +32,16 @@ import {EnvironmentWorkshop} from './environment-workshop.js'; import {OsmWorldImport} from './osm-world-import.js'; import {RecoveryVersions} from './recovery-versions.js'; import {TerrainRasterCache,buildingVisible} from './terrain-raster.js'; -import {globalBuildingMask,overlapsGlobalBuilding,validateGlobalProtection} from './world-protection.js'; +import {globalBuildingMask,overlapsGlobalBuilding,validateGlobalProtection,sharedLocalDecorations} from './world-protection.js'; const $=id=>document.getElementById(id); const colors=['#8b9768','#526b4b','#787975','#507b85','#c6b58d','#8e765e','#a5a395']; const terrainNames=['grass','forest','asphalt','water','sand','dirt','sidewalk']; const catalog=Object.fromEntries(Object.entries(kinds).map(([kind,name])=>[kind,{width:kind==='house'?12:kind==='garage'?10:20,height:kind==='house'?10:kind==='garage'?8:16,name}])); +// One-room presets: a footprint under 6 tiles on a side is a single room with one floor. +const smallPresets={'small-shed':{kind:'warehouse',width:3,height:3,name:'Shed 3×3 (one room)'},'small-kiosk':{kind:'shop',width:3,height:3,name:'Kiosk 3×3 (one room)'},'small-booth':{kind:'garage',width:2,height:2,name:'Booth 2×2 (one room)'}}; +Object.assign(catalog,smallPresets); +const presetKind=preset=>catalog[preset]?.kind||preset; const recoveryKey='simple-pz-mapmaker:project:v1'; const recoveryClientKey='simple-pz-mapmaker:recovery-client:v1'; let recoveryId=crypto.randomUUID().replaceAll('-',''); @@ -70,13 +74,13 @@ const workshop=new BuildingWorkshop({api,download,resources,preview:gamePreview, b.modified=true;if(aboveLevels(b)!==aboveLevels(old))b.levelsOrigin='manual';validateDesign(b);if(!canPlace({...project,buildings:project.buildings.filter(a=>a!==old)},b))throw Error('The new outline intersects with water, another building, a spawn point, or a cell boundary.'); history.begin(project);Object.assign(old,b);history.commit(project);selectedId=b.id;changed(); },template:saveTemplate,getModules:()=>project.buildingLibrary||[],saveModule:saveBuildingModule,attribution:()=>project.attribution}); -function updateLibrary(){const selected=$('building-preset').value;$('building-preset').replaceChildren();for(const [value,name] of Object.entries(kinds))$('building-preset').add(new Option(name,value));for(const b of project.buildingLibrary||[])$('building-preset').add(new Option('Template: '+b.name,'template:'+b.id));$('building-preset').value=[...$('building-preset').options].some(o=>o.value===selected)?selected:buildingKind;} +function updateLibrary(){const selected=$('building-preset').value;$('building-preset').replaceChildren();for(const [value,name] of Object.entries(kinds))$('building-preset').add(new Option(name,value));for(const [value,preset] of Object.entries(smallPresets))$('building-preset').add(new Option(preset.name,value));for(const b of project.buildingLibrary||[])$('building-preset').add(new Option('Template: '+b.name,'template:'+b.id));$('building-preset').value=[...$('building-preset').options].some(o=>o.value===selected)?selected:buildingKind;} function listBuildings(){const q=$('building-list-search').value.toLocaleLowerCase('en-GB');$('building-list').replaceChildren();const items=project.buildings.filter(b=>(b.name+' '+kinds[b.kind]).toLocaleLowerCase('en-GB').includes(q)).slice(0,100);for(const b of items){const button=document.createElement('button');button.textContent=`${b.name} · ${kinds[b.kind]} · ${aboveLevels(b)} floor(s) / ${-minLevel(b)} basement(s)`;button.onclick=()=>{selectedId=b.id;$('building-list-dialog').close();update();$('edit-building').click();};$('building-list').append(button);}if(!items.length){const p=document.createElement('p');p.textContent=project.buildings.length?'Specify the name.':'There are no buildings on the map yet. Create your template.';$('building-list').append(p);}} const housingGallery=new HousingGallery({api,select:id=>{if(busy)return;$('building-list-dialog').close();task('Preparing housing template…',async signal=>{const b=await api('/api/building/presets/'+encodeURIComponent(id),{},signal);standaloneId=b.id;workshop.open(b,true);});}}); function openHousingCatalog(){if(busy)return;$('building-list-search').value='';listBuildings();$('building-list-dialog').showModal();housingGallery.open();} $('browse-housing-presets').onclick=openHousingCatalog; $('open-building-editor').onclick=()=>{if(busy)return;if(selectedId){$('edit-building').click();return;}openHousingCatalog();};$('building-list-search').oninput=listBuildings; -$('new-building-template').onclick=()=>{ $('building-list-dialog').close();task('Preparing a new template...',async signal=>{const b=await api('/api/building/design',{building:{id:crypto.randomUUID(),name:'Your home',kind:buildingKind,x:8,y:8,width:catalog[buildingKind].width,height:catalog[buildingKind].height},levels:+$('placement-levels').value,basements:+$('placement-basements').value,seed:project.seed},signal);standaloneId=b.id;workshop.open(b,true);});}; +$('new-building-template').onclick=()=>{ $('building-list-dialog').close();task('Preparing a new template...',async signal=>{const b=await api('/api/building/design',{building:{id:crypto.randomUUID(),name:'Your home',kind:presetKind(buildingKind),x:8,y:8,width:catalog[buildingKind].width,height:catalog[buildingKind].height},levels:+$('placement-levels').value,basements:+$('placement-basements').value,seed:project.seed},signal);standaloneId=b.id;workshop.open(b,true);});}; $('open-resources').onclick=()=>resources.open();$('pick-world-resource').onclick=()=>resources.open('world',a=>{worldAsset=a;setTool('world-asset');$('world-resource-name').textContent=a.name+' · '+a.id;});$('pick-surface-resource').onclick=()=>resources.open('surface',a=>{surfaceAsset=a;setTool('surface-asset');$('world-resource-name').textContent=a.id;}); $('erase-world-resource').onclick=()=>setTool('erase-decor'); const appearance=new AppearancePanel({project:()=>project,selectArea:()=>{if(isometric)$('view-top').click();$('map-level').value='0';setTool('appearance-area');toast('Draw a rectangle on the map. Then choose an appearance preset.');},apply:options=>task('Applying appearance to the selected area…',async signal=>{const result=await api('/api/styles/apply',{project,...options},signal);history.begin(project);project.version=result.project.version;project.surfaceTheme=result.project.surfaceTheme;project.surfaceRegions=result.project.surfaceRegions;project.buildings=result.project.buildings;history.commit(project);appearance.done();changed();toast(`Appearance applied: ${options.region?'selected area':'entire map'}. Designed buildings: ${result.changed}.`);}),preview:options=>{const r=options.region,center=r?{x:r.x+Math.floor(r.width/2),y:r.y+Math.floor(r.height/2)}:cursor||{x:Math.floor(project.width/2),y:Math.floor(project.height/2)},cellX=Math.max(0,Math.min(project.width/256-1,Math.floor(center.x/256))),cellY=Math.max(0,Math.min(project.height/256-1,Math.floor(center.y/256))),x=Math.min(208,Math.max(0,center.x-cellX*256-24)),y=Math.min(208,Math.max(0,center.y-cellY*256-24));gamePreview.open('Selected appearance preview',level=>api('/api/preview/appearance',{project,...options,cellX,cellY,x,y,level}),minZ,maxZ,'0');}}); @@ -102,7 +106,7 @@ mapLayers=installMapLayers({host:document.querySelector('.inspector'),getContext fenceControls=mountFenceControls(document.querySelector('.inspector'),{getProject:()=>project}); nativeEditor.layerGuard=(operation,target)=>{if(operation==='execute')return true;const layer=operation.includes('model')||['move','delete-selection'].includes(operation)&&target?.type==='model'?'models':operation==='object'||['move','delete-selection'].includes(operation)&&['object','decoration'].includes(target?.type)?'decorations':'buildings';mapLayers.guardEdit(layer,()=>{});if(target?.building)mapLayers.guardEdit('buildings',()=>{});return true;}; geometryTools=installGeographicAuthoringTools({host:document.querySelector('.inspector'),getContext:getNativeContext,layers:mapLayers,requestDraw,message:toast,download, - getCapabilities:()=>({polygon:{version:1},polyline:{version:1},fence:{version:1},smallBuilding:{minWidth:6,minHeight:6,maxWidth:64,maxHeight:64}}), + getCapabilities:()=>({polygon:{version:1},polyline:{version:1},fence:{version:1},smallBuilding:{minWidth:2,minHeight:2,maxWidth:64,maxHeight:64}}), onDrawFinished:async draft=>{ const snapshot=JSON.stringify(project); if(draft.kind==='polygon'&&polygonReceiver){const receive=polygonReceiver;polygonReceiver=null;receive(draft.points);return {applied:true};} @@ -240,7 +244,7 @@ function draw(){ } ctx.restore(); } - if(!nativeActive&&mapLayers.isVisible('decorations')&&['all','0'].includes($('map-level').value))for(const d of project.decorations){for(const part of d.parts){const p=screen(d.x+part.dx,d.y+part.dy);ctx.fillStyle='#d5b575';ctx.strokeStyle='#28382d';ctx.lineWidth=1;ctx.fillRect(p.x,p.y,Math.max(3,zoom),Math.max(3,zoom));ctx.strokeRect(p.x,p.y,Math.max(3,zoom),Math.max(3,zoom));}} + if(!nativeActive&&mapLayers.isVisible('decorations')&&['all','0'].includes($('map-level').value)){const context=getNativeContext(),shared=sharedLocalDecorations(context.workspace?.shared??(worldWorkshop.editedId?worldWorkshop.world:null),project);for(const d of [...project.decorations,...shared.map(d=>({...d,shared:true}))]){for(const part of d.parts){const p=screen(d.x+part.dx,d.y+part.dy);ctx.fillStyle=d.shared?'#9fc7d6':'#d5b575';ctx.strokeStyle='#28382d';ctx.lineWidth=1;ctx.fillRect(p.x,p.y,Math.max(3,zoom),Math.max(3,zoom));ctx.strokeRect(p.x,p.y,Math.max(3,zoom),Math.max(3,zoom));}}} if(!nativeActive&&mapLayers.isVisible('models')&&['all','0'].includes($('map-level').value))for(const m of project.staticModels||[]){const point=screen(m.x+.5,m.y+.5);modelSprites.draw(ctx,m,point.x,point.y,Math.max(12,zoom*4));} if(mapLayers.isVisible('spawns')&&$('show-spawns').checked&&['all','0'].includes($('map-level').value))for(let i=0;i<project.spawns.length;i++){const s=project.spawns[i],p=screen(s.x+.5,s.y+.5);ctx.beginPath();ctx.arc(p.x,p.y,selectedSpawn===i?8:6,0,Math.PI*2);ctx.fillStyle='#2b3528';ctx.fill();ctx.lineWidth=2;ctx.strokeStyle='#edb16b';ctx.stroke();ctx.beginPath();ctx.moveTo(p.x-3,p.y);ctx.lineTo(p.x+3,p.y);ctx.moveTo(p.x,p.y-3);ctx.lineTo(p.x,p.y+3);ctx.lineWidth=1;ctx.stroke();} if(nativeActive){nativeLighting.draw(ctx,{zoom,pan,viewport});nativeEditor.draw(ctx);} @@ -269,7 +273,7 @@ function drawDesignedBuilding(b){ if(!isometric&&zoom>2){for(const e of f.edges){const p=screen(b.x+e.x,b.y+e.y);ctx.strokeStyle={wall:'#4e4a3d',door:'#e9b477',window:'#a1cbdb',open:'#87997d'}[e.kind];ctx.lineWidth=e.kind==='wall'?Math.max(1,zoom*.18):Math.max(1,zoom*.25);ctx.beginPath();ctx.moveTo(p.x,p.y);ctx.lineTo(p.x+(e.edge==='n'?zoom:0),p.y+(e.edge==='w'?zoom:0));ctx.stroke();}} }); } -function ghost(point){const template=project.buildingLibrary?.find(b=>'template:'+b.id===$('building-preset').value);const c=template?structuredClone(template):{...catalog[buildingKind],kind:buildingKind};const b={...c,id:'preview',x:Math.floor(point.x-c.width/2),y:Math.floor(point.y-c.height/2)};if(!template){b.design={material:'wood',roof:'flat',shape:$('placement-shape').value,variant:project.seed,floors:[{cells:shapeCells(b.width,b.height,$('placement-shape').value),rooms:{'1':'livingroom'},edges:[],objects:[]}]};}return b;} +function ghost(point){const template=project.buildingLibrary?.find(b=>'template:'+b.id===$('building-preset').value);const c=template?structuredClone(template):{...catalog[buildingKind],kind:presetKind(buildingKind)};const b={...c,id:'preview',x:Math.floor(point.x-c.width/2),y:Math.floor(point.y-c.height/2)};if(!template){b.design={material:'wood',roof:'flat',shape:$('placement-shape').value,variant:project.seed,floors:[{cells:shapeCells(b.width,b.height,$('placement-shape').value),rooms:{'1':'livingroom'},edges:[],objects:[]}]};}return b;} function paintAt(point,last){ const size=+$('brush-size').value; if(tool==='zombies'){ @@ -317,7 +321,7 @@ mini.addEventListener('pointerdown',e=>{if(isometric)return;const r=mini.getBoun document.querySelectorAll('[data-tool]').forEach(b=>b.onclick=()=>setTool(b.dataset.tool)); document.querySelectorAll('[data-terrain]').forEach(b=>b.onclick=()=>{terrain=+b.dataset.terrain;document.querySelectorAll('[data-terrain]').forEach(a=>a.classList.toggle('active',a===b));if(!['brush','rectangle'].includes(tool))setTool('brush');update();}); document.querySelectorAll('[data-building]').forEach(b=>b.onclick=()=>{buildingKind=b.dataset.building;$('building-preset').value=buildingKind;document.querySelectorAll('[data-building]').forEach(a=>a.classList.toggle('active',a===b));setTool('building');}); -$('building-preset').onchange=()=>{if(kinds[$('building-preset').value])buildingKind=$('building-preset').value;setTool('building');}; +$('building-preset').onchange=()=>{if(catalog[$('building-preset').value])buildingKind=$('building-preset').value;setTool('building');}; $('import-building-template').onclick=()=>$('building-template-file').click(); $('building-template-file').onchange=e=>{const file=e.target.files[0];if(!file)return;task('Reading the building template...',async signal=>{validateTemplateBytes(file.size);const value=JSON.parse(await file.text());if(value.format!=='SimplePZBuilding'||![1,2,3,4,5,6,7].includes(value.version))throw Error('This is not a SimplePZMapmaker template.');const b=value.building;if(value.version<7&&usesExtendedZ(b))throw Error('Extended Z levels and No automatic roof require template version 7.');if(value.version<6&&usesInteractiveAuthoring(b))throw Error('Interactive authoring requires template version 6.');if(value.version<4&&(b.requiredMods?.length||Object.keys(b.assetSources||{}).length||b.design?.floors.some(f=>f.staticModels?.length)))throw Error('Mods and models require template version 4.');if(value.version<3&&usesAssets(b))throw Error('Appearance requires template format version 3.');if(value.version===1&&minLevel(b)!==0)throw Error('Basements require template format version 2.');b.attribution=combineAttribution(value.attribution??[],b.attribution??[]);validateDesign(b);await api('/api/building/validate',b,signal);project.buildingLibrary??=[];if(project.buildingLibrary.length>=64)throw Error('The library is full.');const source=combineAttribution(project.attribution,b.attribution);if(source.length>30)throw Error('Too many geography sources.');history.begin(project);b.id=crypto.randomUUID();b.sourceId=null;b.sourceKind=null;b.modified=false;project.attribution=source;project.version=Math.max(project.version,value.version===7?10:value.version===6?9:value.version===5?7:value.version===4?6:4);project.buildingLibrary.push(b);history.commit(project);changed();toast('The template has been added to the library.');}).finally(()=>e.target.value='');}; $('edit-building').onclick=()=>{const b=project.buildings.find(b=>b.id===selectedId);if(!b)return;if(b.design)workshop.open(b);else task('We are preparing a building plan...',async signal=>workshop.open(await api('/api/building/design',{building:structuredClone(b),levels:1,seed:project.seed,shape:'rect'},signal)));}; @@ -337,7 +341,7 @@ for(const [id,key] of [['world-x','worldX'],['world-y','worldY']])$(id).onchange $('apply-building').onclick=()=>{const b=project.buildings.find(b=>b.id===selectedId);if(!b)return;const updated={...b,name:$('building-name').value,x:+$('building-x').value,y:+$('building-y').value,width:+$('building-width').value,height:+$('building-height').value}; if(b.design&&(updated.width!==b.width||updated.height!==b.height)){toast('The size of the floors varies in the building’s workshop.',true);return;} if(!canPlace({...project,buildings:project.buildings.filter(a=>a!==b)},updated)){toast('A building intersects with objects, water, or a cell boundary.',true);return;}history.begin(project);Object.assign(b,updated,{modified:true});history.commit(project);changed();}; -function deleteSelection(){if(busy)return;history.begin(project);if(selectedId){project.buildings=project.buildings.filter(b=>b.id!==selectedId);selectedId=null;}else if(selectedSpawn>=0){if(project.spawns.length===1){toast('At least one spawn point is required.',true);history.commit(project);return;}project.spawns.splice(selectedSpawn,1);selectedSpawn=-1;}history.commit(project);changed();} +function deleteSelection(){if(busy)return;history.begin(project);if(selectedId){const removed=selectedId;project.buildings=project.buildings.filter(b=>b.id!==removed).map(b=>b.attachedTo===removed?{...b,attachedTo:undefined}:b);selectedId=null;}else if(selectedSpawn>=0){if(project.spawns.length===1){toast('At least one spawn point is required.',true);history.commit(project);return;}project.spawns.splice(selectedSpawn,1);selectedSpawn=-1;}history.commit(project);changed();} $('delete-building').onclick=deleteSelection; document.querySelectorAll('[data-close]').forEach(b=>{if(b.dataset.close)b.onclick=()=>$(b.dataset.close).close();}); $('new-map').onclick=()=>{if(busy)return;$('new-dialog').showModal();}; diff --git a/src/SimplePZMapmaker.Web/wwwroot/building.js b/src/SimplePZMapmaker.Web/wwwroot/building.js index b4a5dd6..d5f27cc 100644 --- a/src/SimplePZMapmaker.Web/wwwroot/building.js +++ b/src/SimplePZMapmaker.Web/wwwroot/building.js @@ -65,7 +65,7 @@ export function installStairLink(b,lowerIndex,newIndex){ } export function validateDesign(b,requireAccess=true){ validateSourceFootprint(b); - const fail=text=>{throw Error(text);};if(!b||!Object.hasOwn(kinds,b.kind)||![b.width,b.height].every(v=>Number.isInteger(v)&&v>=6&&v<=64))fail('Building size: 6–64 tiles.');if(b.attribution!==undefined&&(!Array.isArray(b.attribution)||b.attribution.length>30||b.attribution.some(s=>typeof s!=='string'||s.length>1000)))fail('The template source is corrupted.');validateDependencies(b.requiredMods??[],b.assetSources??{});if(!b.design)return b; + const fail=text=>{throw Error(text);};if(!b||!Object.hasOwn(kinds,b.kind)||![b.width,b.height].every(v=>Number.isInteger(v)&&v>=2&&v<=64))fail('Building size: 2–64 tiles.');if(b.attribution!==undefined&&(!Array.isArray(b.attribution)||b.attribution.length>30||b.attribution.some(s=>typeof s!=='string'||s.length>1000)))fail('The template source is corrupted.');validateDependencies(b.requiredMods??[],b.assetSources??{});if(!b.design)return b; const d=b.design;if(!['wood','brick','plaster'].includes(d.material)||!['flat','gabled','shed','hipped','none'].includes(d.roof)||!Object.hasOwn(shapeNames,d.shape)||!Number.isInteger(d.variant)||d.variant< -2147483648||d.variant>2147483647||(d.minLevel!==undefined&&!Number.isInteger(d.minLevel))||!Number.isInteger(minLevel(b))||minLevel(b)<minZ||minLevel(b)>0||!Array.isArray(d.floors)||d.floors.length<1||d.floors.length>totalLevels||aboveLevels(b)<1||aboveLevels(b)>aboveGroundCount)fail('Check material, roof and number of floors: 1–32 floors and 0–32 basements (-32 through 31).'); if(d.roof!=='none'&&aboveLevels(b)>maxZ)fail('Floor 32 (z=31) requires No automatic roof; z=32 is outside the game range.'); if(d.style!==undefined&&(typeof d.style!=='string'||d.style!==''&&!Object.hasOwn(visualStyles,d.style))||d.roofTileset!=null&&(typeof d.roofTileset!=='string'||!/^roofs_30_[0-9]{2}$/.test(d.roofTileset)))fail('Decor kit or roof is damaged.');if(d.flatRoofTile!=null&&!tileId(d.flatRoofTile))fail('The flat roof surface resource is invalid.'); diff --git a/src/SimplePZMapmaker.Web/wwwroot/editor.js b/src/SimplePZMapmaker.Web/wwwroot/editor.js index 55615e9..a109dba 100644 --- a/src/SimplePZMapmaker.Web/wwwroot/editor.js +++ b/src/SimplePZMapmaker.Web/wwwroot/editor.js @@ -29,10 +29,12 @@ export function paint(p,x,y,terrain,radius=1,history){ export function line(p,x0,y0,x1,y1,terrain,radius=1,history){ const n=Math.max(Math.abs(x1-x0),Math.abs(y1-y0));for(let i=0;i<=n;i++){const t=n?i/n:0;paint(p,Math.round(x0+(x1-x0)*t),Math.round(y0+(y1-y0)*t),terrain,radius,history);} } +// Attached buildings (map format 13) may share wall tiles with their partner, never room tiles. +export const attachedPair=(a,b)=>!!a.attachedTo&&a.attachedTo===b.id||!!b.attachedTo&&b.attachedTo===a.id; export function canPlace(p,b){ - if(!b||!Object.hasOwn(kinds,b.kind)||![b.x,b.y,b.width,b.height].every(Number.isInteger)||b.width<6||b.height<6||b.width>64||b.height>64||b.x<1||b.y<1||b.x+b.width>=p.width||b.y+b.height>=p.height)return false; + if(!b||!Object.hasOwn(kinds,b.kind)||![b.x,b.y,b.width,b.height].every(Number.isInteger)||b.width<2||b.height<2||b.width>64||b.height>64||b.x<1||b.y<1||b.x+b.width>=p.width||b.y+b.height>=p.height)return false; const points=occupiedIndices(b),global=new Set([...points].map(i=>(b.y+Math.floor(i/(b.width+1)))*p.width+b.x+i%(b.width+1))); - if(p.buildings.some(a=>a!==b&&b.x<a.x+a.width+1&&b.x+b.width+1>a.x&&b.y<a.y+a.height+1&&b.y+b.height+1>a.y&&[...occupiedIndices(a)].some(i=>global.has((a.y+Math.floor(i/(a.width+1)))*p.width+a.x+i%(a.width+1)))))return false; + if(p.buildings.some(a=>a!==b&&b.x<a.x+a.width+1&&b.x+b.width+1>a.x&&b.y<a.y+a.height+1&&b.y+b.height+1>a.y&&[...occupiedIndices(a)].some(i=>{const x=a.x+i%(a.width+1),y=a.y+Math.floor(i/(a.width+1));return global.has(y*p.width+x)&&!(attachedPair(a,b)&&!(occupied(a,x-a.x,y-a.y)&&occupied(b,x-b.x,y-b.y)));})))return false; if((p.staticModels||[]).some(m=>global.has(m.y*p.width+m.x)))return false; if((p.decorations||[]).some(d=>d.parts.some(part=>global.has((d.y+part.dy)*p.width+d.x+part.dx))))return false; if(p.spawns.some(s=>global.has(s.y*p.width+s.x)))return false; @@ -41,7 +43,7 @@ export function canPlace(p,b){ } export function validateProject(p){ const fail=m=>{throw Error(m);}; - if(!p||![1,2,3,4,5,6,7,8,9,10,11,12].includes(p.version)||!Number.isInteger(p.width)||!Number.isInteger(p.height)||p.width<256||p.height<256||p.width>2048||p.height>2048||p.width%256||p.height%256)fail('Unsupported project size or version.'); + if(!p||![1,2,3,4,5,6,7,8,9,10,11,12,13].includes(p.version)||!Number.isInteger(p.width)||!Number.isInteger(p.height)||p.width<256||p.height<256||p.width>2048||p.height>2048||p.width%256||p.height%256)fail('Unsupported project size or version.'); validateFenceLines(p);validateGameZones(p);validateZoneAuthoring(p,roomNames);validateLinearFeatures(p);validateModelProject(p);validateGeneration(p);validateSourceSites(p); validatePalettes(p.resourcePalettes===undefined?[]:p.resourcePalettes);if(p.resourcePalettes?.length&&p.version<8)fail('Resource palettes require project format 8.'); decodeTerrainEdits(p);if(p.version<6&&(p.terrainEdits?.length||p.worldSection)||p.worldSection!==undefined&&typeof p.worldSection!=='boolean')fail('Manual terrain edits and world sections require format 6.'); @@ -66,6 +68,7 @@ export function validateProject(p){ if(p.importSummary?.notes!==undefined&&(!Array.isArray(p.importSummary.notes)||p.importSummary.notes.length>20||p.importSummary.notes.some(s=>typeof s!=='string'||s.length>1000)))fail('Import notes are corrupted.'); if([...p.buildings,...library].reduce((sum,b)=>sum+(b?.design?.floors?.reduce((n,f)=>n+(f?.cells?.length||0),0)||0),0)>20_000_000)fail('There are too many tiles on the floors.'); const ids=new Set();for(const b of p.buildings){validateDesign(b);if(!b||typeof b.id!=='string'||!b.id||ids.has(b.id)||typeof b.name!=='string'||b.name.length>100||b.sourceId?.length>100||b.sourceKind?.length>100||!['inferred','osm-levels','osm-height','manual'].includes(b.levelsOrigin??'inferred')||!canPlace(p,b))fail('The building is damaged, intersects with another, or sits on water.');ids.add(b.id);} + for(const b of p.buildings)if(b.attachedTo!==undefined&&b.attachedTo!==null&&(typeof b.attachedTo!=='string'||b.attachedTo===b.id||!ids.has(b.attachedTo)||p.version<13))fail(`Building “${b.name}” is attached to a building that is not on this map, or the map format is older than 13.`); for(const s of p.spawns)if(!s||!Number.isInteger(s.x)||!Number.isInteger(s.y)||s.x<0||s.y<0||s.x>=p.width||s.y>=p.height||p.terrain[s.y*p.width+s.x]===3||p.buildings.some(b=>containsPoint(b,s.x-b.x,s.y-b.y)))fail('The spawn point should be on the ground outside the building.'); return p; } diff --git a/src/SimplePZMapmaker.Web/wwwroot/index.html b/src/SimplePZMapmaker.Web/wwwroot/index.html index 011ba0a..e9dad9e 100644 --- a/src/SimplePZMapmaker.Web/wwwroot/index.html +++ b/src/SimplePZMapmaker.Web/wwwroot/index.html @@ -43,7 +43,7 @@ <div class="eyebrow">02 / PROJECT</div><h1 id="inspector-title">New map</h1><p class="target">PROJECT ZOMBOID / BUILD 42</p> <dl class="stats"><div><dt>Size</dt><dd id="size-stat">256 × 256</dd></div><div><dt>Buildings</dt><dd id="building-count">0</dd></div><div><dt>Spawn points</dt><dd id="spawn-count">1</dd></div><div><dt>Seed</dt><dd id="seed-stat">42</dd></div></dl> <section><h2>World position</h2><div class="two-fields"><label>Cell X<input id="world-x" type="number" min="0" max="1000" value="100"></label><label>Cell Y<input id="world-y" type="number" min="0" max="1000" value="100"></label></div><p class="hint">Coordinates in 256 × 256 cells. Choose free space if connecting with other maps.</p></section> - <section id="selection-panel" hidden><h2>Selected building</h2><label>Name<input id="building-name" maxlength="100"></label><p id="building-details" class="hint"></p><div class="two-fields"><label>X<input id="building-x" type="number" min="1"></label><label>Y<input id="building-y" type="number" min="1"></label><label>Width<input id="building-width" type="number" min="6" max="64"></label><label>Depth<input id="building-height" type="number" min="6" max="64"></label></div><button id="edit-building" class="primary">Building workshop</button><button id="apply-building">Apply position</button><button id="regenerate-building">Another option</button><button id="delete-building" class="danger">Delete building</button></section> + <section id="selection-panel" hidden><h2>Selected building</h2><label>Name<input id="building-name" maxlength="100"></label><p id="building-details" class="hint"></p><div class="two-fields"><label>X<input id="building-x" type="number" min="1"></label><label>Y<input id="building-y" type="number" min="1"></label><label>Width<input id="building-width" type="number" min="2" max="64"></label><label>Depth<input id="building-height" type="number" min="2" max="64"></label></div><button id="edit-building" class="primary">Building workshop</button><button id="apply-building">Apply position</button><button id="regenerate-building">Another option</button><button id="delete-building" class="danger">Delete building</button></section> <section id="geography-panel" hidden><h2>Geography</h2><strong id="scale-stat"></strong><p id="import-summary" class="hint"></p></section> <section><h2>Layers</h2><label class="check"><input id="show-buildings" type="checkbox" checked>Buildings</label><label class="check"><input id="show-spawns" type="checkbox" checked>Spawn Points</label><label class="check"><input id="show-zombies" type="checkbox">Zombie Density</label></section> <section><h2>Working with the map</h2><p id="tool-help" class="hint">Draw with the left button. The wheel changes scale. The middle button or Space moves the map.</p><button id="open-help" class="text-button">How to load a map into the game</button></section> @@ -74,7 +74,7 @@ <div class="building-workshop"> <aside class="building-controls"> <label>Name<input id="be-name" maxlength="100"></label><label>Purpose<select id="be-kind"></select></label><label>Style preset<select id="be-style"><option value="">By material</option></select></label><button id="be-preview" class="primary">Preview game tiles</button> - <div class="two-fields"><label>Width<input id="be-width" type="number" min="6" max="64"></label><label>Depth<input id="be-height" type="number" min="6" max="64"></label></div><button id="be-resize">Change boundaries</button> + <div class="two-fields"><label>Width<input id="be-width" type="number" min="2" max="64"></label><label>Depth<input id="be-height" type="number" min="2" max="64"></label></div><button id="be-resize">Change boundaries</button> <label>Shape<select id="be-shape"></select></label><button id="be-shape-apply">Set the outline of this floor</button> <div class="two-fields"><label>Facade<select id="be-material"><option value="wood">Wood</option><option value="brick">Brick</option><option value="plaster">Plaster</option></select></label><label>Roof<select id="be-roof"><option value="flat">Flat</option><option value="gabled">Gabled</option><option value="shed">Shed</option><option value="hipped">Hipped</option><option value="none">No automatic roof</option></select></label></div> <h3>Floors</h3><div id="be-floors" class="floor-buttons"></div><div class="two-fields"><button id="be-add-floor">Copy up</button><button id="be-remove-floor">Remove top</button></div><div class="two-fields"><button id="be-add-basement">Copy down</button><button id="be-remove-basement">Remove bottom</button></div><button id="be-auto-stairs">Connect floors with stairs</button> diff --git a/src/SimplePZMapmaker.Web/wwwroot/workshop.js b/src/SimplePZMapmaker.Web/wwwroot/workshop.js index 2bbf7fe..1c0d406 100644 --- a/src/SimplePZMapmaker.Web/wwwroot/workshop.js +++ b/src/SimplePZMapmaker.Web/wwwroot/workshop.js @@ -71,7 +71,7 @@ export class BuildingWorkshop{ const origins={'osm-levels':'Floors from OSM','osm-height':'Number of storeys estimated by OSM height',inferred:'Floor count inferred by generator',manual:'Number of storeys set manually'};$('be-origin').textContent=(origins[b.levelsOrigin||'inferred']||origins.inferred)+(b.sourceKind?` · original type: ${b.sourceKind}`:'')+(b.sourceLevels?` · number of floors in the source: ${b.sourceLevels}`:'')+(b.sourceBasements?` · basements in source: ${b.sourceBasements}`:'')+(b.sourceRoof?` · roof in source: ${b.sourceRoof}`:''); $('be-undo').disabled=!this.history.past.length;$('be-redo').disabled=!this.history.future.length;try{validateDesign(b);this.message('The building is ready for use.');}catch(e){this.message(e.message,true);}if(this.lastZ!==floorLevel(b,this.level)){this.visual.changedLevel();this.lastZ=floorLevel(b,this.level);}this.draw();this.visual.schedule();this.visual.brush();this.visual.readonly(); } - resize(w,h){if(![w,h].every(n=>Number.isInteger(n)&&n>=6&&n<=64))throw Error('Sizes: 6–64 tiles.');const b=this.draft;for(const f of b.design.floors){let cells='';for(let y=0;y<h;y++)for(let x=0;x<w;x++)cells+=x<b.width&&y<b.height?f.cells[y*b.width+x]:'.';f.floorTiles=Object.fromEntries(Object.entries(f.floorTiles||{}).map(([i,id])=>({x:+i%b.width,y:Math.floor(+i/b.width),id})).filter(p=>p.x<w&&p.y<h).map(p=>[p.y*w+p.x,p.id]));f.cells=cells;f.edges=f.edges.filter(e=>e.x<=w&&e.y<=h);f.objects=f.objects.filter(o=>o.x<w&&o.y<h);f.staticModels=(f.staticModels||[]).filter(m=>m.x<w&&m.y<h);}b.width=w;b.height=h;b.design.shape='custom';for(let z=0;z<b.design.floors.length;z++)refreshEdges(b,z);} + resize(w,h){if(![w,h].every(n=>Number.isInteger(n)&&n>=2&&n<=64))throw Error('Sizes: 2–64 tiles. Under 6 tiles on a side a building is one room.');const b=this.draft;for(const f of b.design.floors){let cells='';for(let y=0;y<h;y++)for(let x=0;x<w;x++)cells+=x<b.width&&y<b.height?f.cells[y*b.width+x]:'.';f.floorTiles=Object.fromEntries(Object.entries(f.floorTiles||{}).map(([i,id])=>({x:+i%b.width,y:Math.floor(+i/b.width),id})).filter(p=>p.x<w&&p.y<h).map(p=>[p.y*w+p.x,p.id]));f.cells=cells;f.edges=f.edges.filter(e=>e.x<=w&&e.y<=h);f.objects=f.objects.filter(o=>o.x<w&&o.y<h);f.staticModels=(f.staticModels||[]).filter(m=>m.x<w&&m.y<h);}b.width=w;b.height=h;b.design.shape='custom';for(let z=0;z<b.design.floors.length;z++)refreshEdges(b,z);} point(event){const rect=$('building-canvas').getBoundingClientRect(),fx=(event.clientX-rect.left-this.origin.x)/this.scale,fy=(event.clientY-rect.top-this.origin.y)/this.scale;return{x:Math.floor(fx),y:Math.floor(fy),fx,fy};} place(p){const b=this.draft,f=this.floor; if(['wall','door','window','open'].includes(this.tool)){ diff --git a/src/SimplePZMapmaker.Web/wwwroot/world-geography.js b/src/SimplePZMapmaker.Web/wwwroot/world-geography.js index 4f0d464..a8c82a5 100644 --- a/src/SimplePZMapmaker.Web/wwwroot/world-geography.js +++ b/src/SimplePZMapmaker.Web/wwwroot/world-geography.js @@ -5,18 +5,31 @@ import {usesExtendedZ} from './z-levels.js'; const $=id=>document.getElementById(id); export const buildingTemplateVersion=b=>usesExtendedZ(b)?7:usesInteractiveAuthoring(b)?6:usesNativeModelBehavior(b)?5:4; export function buildingTemplateFromDocument(data){if(data?.format!=='SimplePZBuilding'||![1,2,3,4,5,6,7].includes(data.version))throw Error('Template .pzbuilding.json expected.');const building=normalizeBuilding(structuredClone(data.building));if(usesExtendedZ(building)&&data.version<7)throw Error('Extended Z levels and open roofs require building template format 7.');if(usesInteractiveAuthoring(building)&&data.version<6)throw Error('Interactive objects and imported footprints require building template format 6.');validateDesign(building);return building;} -export const worldAuthoringVersion=world=>world.geoReference?.rotationDegrees||world.fences?.length||world.maps?.some(m=>m.project?.version>=12)?Math.max(7,world.version):world.linearFeatures?.length?Math.max(6,world.version):world.maps?.some(m=>m.project?.version>=11)?Math.max(5,world.version):world.maps?.some(m=>m.project?.version>=10)||world.buildings?.some(usesExtendedZ)?Math.max(4,world.version):world.maps?.some(m=>m.project?.version>=9)||world.buildings?.some(usesInteractiveAuthoring)?Math.max(3,world.version):world.version; +export const worldAuthoringVersion=world=>world.decorations?.length?Math.max(8,world.version):world.geoReference?.rotationDegrees||world.fences?.length||world.maps?.some(m=>m.project?.version>=12)?Math.max(7,world.version):world.linearFeatures?.length?Math.max(6,world.version):world.maps?.some(m=>m.project?.version>=11)?Math.max(5,world.version):world.maps?.some(m=>m.project?.version>=10)||world.buildings?.some(usesExtendedZ)?Math.max(4,world.version):world.maps?.some(m=>m.project?.version>=9)||world.buildings?.some(usesInteractiveAuthoring)?Math.max(3,world.version):world.version; export function elevationSample(grid,longitude,latitude){let x=longitude,y=latitude;if(grid.coordinateSystem==='EPSG:3857'){x=longitude*Math.PI/180*6378137;y=Math.log(Math.tan(Math.PI/4+latitude*Math.PI/360))*6378137;}const column=Math.floor((x-grid.west)/grid.cellSize+1e-9),row=grid.rows-1-Math.floor((y-grid.south)/grid.cellSize+1e-9);return column>=0&&row>=0&&column<grid.columns&&row<grid.rows?grid.values[row*grid.columns+column]:null;} // Mirrors GeographicProjection.Geographic (docs/TRANSFER-CONTRACT.md, section 1): global tile -> lon/lat, rotated reference included. export function worldGeographic(reference,x,y){const b=reference.bounds,cosine=Math.cos((b.north+b.south)/2*Math.PI/180);if(!reference.rotationDegrees)return {longitude:b.west+(x-reference.originCellX*256)*reference.metresPerTile/(cosine*111320),latitude:b.north-(y-reference.originCellY*256)*reference.metresPerTile/111320};const angle=(reference.rotationDegrees||0)*Math.PI/180,sin=Math.sin(angle),cos=Math.cos(angle),px=cosine*111320/reference.metresPerTile,py=111320/reference.metresPerTile,cx=(b.west+b.east)/2,cy=(b.north+b.south)/2,rotated=(lon,lat)=>{const e=(lon-cx)*px,n=(cy-lat)*py;return [e*cos-n*sin,e*sin+n*cos];},corners=[[b.west,b.north],[b.east,b.north],[b.west,b.south],[b.east,b.south]].map(c=>rotated(...c)),rx=x-reference.originCellX*256+Math.min(...corners.map(c=>c[0])),ry=y-reference.originCellY*256+Math.min(...corners.map(c=>c[1]));return {longitude:cx+(rx*cos+ry*sin)/px,latitude:cy-(-rx*sin+ry*cos)/py};} export function validateWorldExtras(world){ validateWorldInfrastructure(world); if(world.buildings!==undefined&&(!Array.isArray(world.buildings)||world.buildings.length>4000))throw Error('The shared world building layer is invalid.');const ids=new Set(),taken=new Set();const cellKey=(x,y)=>`${x}:${y}`;for(const member of world.maps||[])for(const b of member.project.buildings){ids.add(b.id);for(const i of occupiedIndices(b))taken.add(cellKey(member.project.worldX*256+b.x+i%(b.width+1),member.project.worldY*256+b.y+Math.floor(i/(b.width+1))));}for(const b of world.buildings||[]){validateDesign(b);if(typeof b.id!=='string'||!b.id||b.id.length>100||ids.has(b.id)||!Number.isInteger(b.x)||!Number.isInteger(b.y)||b.x<0||b.y<0||b.x>258000||b.y>258000)throw Error('The shared world building is invalid.');ids.add(b.id);for(const i of occupiedIndices(b)){const x=b.x+i%(b.width+1),y=b.y+Math.floor(i/(b.width+1)),member=world.maps?.find(m=>x>=m.project.worldX*256&&y>=m.project.worldY*256&&x<m.project.worldX*256+m.project.width&&y<m.project.worldY*256+m.project.height);if(!member)throw Error(`“${b.name}”: The shared building faces an empty area of the world. Add an adjacent section.`);const p=member.project,lx=x-p.worldX*256,ly=y-p.worldY*256,key=cellKey(x,y);if(taken.has(key))throw Error(`“${b.name}”: A shared building intersects with another building.`);taken.add(key);if(occupied(b,x-b.x,y-b.y)&&p.terrain[ly*p.width+lx]===3)throw Error(`“${b.name}”: The shared building is located on the water.`);if(p.spawns.some(s=>s.x===lx&&s.y===ly)||(p.staticModels||[]).some(m=>m.x===lx&&m.y===ly)||(p.decorations||[]).some(d=>d.parts.some(part=>d.x+part.dx===lx&&d.y+part.dy===ly)))throw Error(`“${b.name}”: A shared building intersects an environment object or spawn point.`);}} + validateSharedDecorations(world); if(world.elevation){const g=world.elevation;if(!['EPSG:4326','EPSG:3857'].includes(g.coordinateSystem)||!Number.isInteger(g.columns)||!Number.isInteger(g.rows)||g.columns<1||g.rows<1||g.columns*g.rows>1000000||!Array.isArray(g.values)||g.values.length!==g.columns*g.rows||![g.west,g.south,g.cellSize].every(Number.isFinite)||g.cellSize<=0||g.values.some(v=>v!==null&&(!Number.isFinite(v)||v< -12000||v>9000)))throw Error('The real height layer is damaged.');} } +// Mirrors WorldAssembler.ValidateDecorations: a shared group is stored once in world tiles and every part lands on dry, free ground of a section. +export const sharedDecorationId=id=>typeof id==='string'&&/^[A-Za-z0-9_-]{1,64}$/.test(id)&&!id.startsWith('world-'); +export function validateSharedDecorations(world){ + if(world.decorations===undefined)return;if(!Array.isArray(world.decorations)||world.decorations.length>10000)throw Error('The shared decoration layer is invalid: up to 10,000 groups.');if(!world.decorations.length)return; + const key=(x,y)=>`${x}:${y}`,blocked=new Map(),ids=new Set();const sectionAt=(x,y)=>world.maps?.find(m=>x>=m.project.worldX*256&&y>=m.project.worldY*256&&x<m.project.worldX*256+m.project.width&&y<m.project.worldY*256+m.project.height); + for(const b of world.buildings||[])for(const i of occupiedIndices(b))blocked.set(key(b.x+i%(b.width+1),b.y+Math.floor(i/(b.width+1))),'a shared building'); + for(const m of world.maps||[]){const p=m.project,ox=p.worldX*256,oy=p.worldY*256;for(const d of p.decorations||[]){ids.add(d.id);for(const part of d.parts)blocked.set(key(ox+d.x+part.dx,oy+d.y+part.dy),'a section decoration');}for(const b of p.buildings)for(const i of occupiedIndices(b))blocked.set(key(ox+b.x+i%(b.width+1),oy+b.y+Math.floor(i/(b.width+1))),'a building');for(const s of p.spawns)blocked.set(key(ox+s.x,oy+s.y),'a spawn point');for(const s of p.staticModels||[])blocked.set(key(ox+s.x,oy+s.y),'a model');} + for(const d of world.decorations){ + if(!d||!sharedDecorationId(d.id)||ids.has(d.id)||![d.x,d.y].every(n=>Number.isInteger(n)&&n>=0&&n<=258000)||typeof d.asset!=='string'||!d.asset||!Array.isArray(d.parts)||d.parts.length<1||d.parts.length>32||d.parts.some(p=>!p||!Number.isInteger(p.dx)||!Number.isInteger(p.dy)||p.dx<0||p.dy<0||p.dx>7||p.dy>7||typeof p.tile!=='string'||!p.tile))throw Error('A shared decoration is invalid or repeats a decoration ID.');ids.add(d.id); + for(const part of d.parts){const x=d.x+part.dx,y=d.y+part.dy,m=sectionAt(x,y);if(!m)throw Error(`Shared decoration ${d.id} reaches an empty part of the world. Add an adjacent section.`);const p=m.project;if(p.terrain[(y-p.worldY*256)*p.width+x-p.worldX*256]===3)throw Error(`Shared decoration ${d.id} stands on water.`);const what=blocked.get(key(x,y));if(what)throw Error(`Shared decoration ${d.id} overlaps ${what}.`);blocked.set(key(x,y),'another shared decoration');} + } +} export class WorldGeography{ constructor(workshop){this.workshop=workshop;this.selectedBuilding=null;this.heightImages=new Map();this.grid=null;this.minimum=0;this.maximum=0;this.editBuilding=null; - const panel=document.createElement('section');panel.className='world-geography';panel.innerHTML=`<h3>Shared buildings and geography</h3><p class="hint">A shared building is stored once and can cross several adjacent sections.</p><label>Selected section building<select id="world-promote-source"><option value="">No buildings</option></select></label><div class="two-fields"><label>World tile X<input id="world-building-x" type="number" min="0" max="258000"></label><label>World tile Y<input id="world-building-y" type="number" min="0" max="258000"></label></div><button id="world-promote-building">Move to shared buildings</button><button id="world-import-building">Add building template</button><input id="world-building-file" type="file" accept=".json,.pzbuilding.json" hidden><label>Shared buildings<select id="world-common-buildings"><option value="">No shared buildings</option></select></label><button id="world-building-edit">Open building editor</button><button id="world-building-move">Apply coordinates</button><button id="world-building-export">Save building template</button><button id="world-building-remove" class="danger">Remove shared building</button><h3>Continuity of roads and rivers</h3><label>Connect<select id="world-seam-mode"><option value="both">Roads and rivers</option><option value="road">Roads only</option><option value="water">Rivers only</option></select></label><button id="world-repair-seams">Repair seam surfaces</button><p class="hint">Changes cover three tiles on each side. Check bridges, objects, and authored surfaces; the operation can be undone through world history.</p><h3>Measured DEM elevations</h3><label>Coordinate system<select id="world-elevation-crs"><option value="EPSG:4326">EPSG:4326 — longitude/latitude</option><option value="EPSG:3857">EPSG:3857 — Web Mercator</option></select></label><label>Elevation source<input id="world-elevation-source" placeholder="For example: Copernicus DEM, section and date"></label><label>License/terms<input id="world-elevation-license" placeholder="Source license"></label><button id="world-import-elevation">Import ESRI ASCII .asc</button><input id="world-elevation-file" type="file" accept=".asc,.txt" hidden><label class="world-height-toggle"><input id="world-height-overlay" type="checkbox"> Show elevations</label><p id="world-elevation-summary" class="hint">Elevation data has not yet been loaded.</p><p class="hint">The geographic layer and elevation.asc preserve real elevations in meters. Native PZ terrain remains flat; building floors do not represent terrain elevation.</p>`; + const panel=document.createElement('section');panel.className='world-geography';panel.innerHTML=`<h3>Shared buildings and geography</h3><p class="hint">A shared building is stored once and can cross several adjacent sections.</p><label>Selected section building<select id="world-promote-source"><option value="">No buildings</option></select></label><div class="two-fields"><label>World tile X<input id="world-building-x" type="number" min="0" max="258000"></label><label>World tile Y<input id="world-building-y" type="number" min="0" max="258000"></label></div><button id="world-promote-building">Move to shared buildings</button><button id="world-import-building">Add building template</button><input id="world-building-file" type="file" accept=".json,.pzbuilding.json" hidden><label>Shared buildings<select id="world-common-buildings"><option value="">No shared buildings</option></select></label><button id="world-building-edit">Open building editor</button><button id="world-building-move">Apply coordinates</button><button id="world-building-export">Save building template</button><button id="world-building-remove" class="danger">Remove shared building</button><h3>Shared decorations</h3><p class="hint">A shared decoration group is stored once in world tiles and may cross a section seam. Each section draws its part and keeps it read-only.</p><label>Selected section decoration<select id="world-promote-decoration-source"><option value="">No decorations</option></select></label><div class="two-fields"><label>World tile X<input id="world-decoration-x" type="number" min="0" max="258000"></label><label>World tile Y<input id="world-decoration-y" type="number" min="0" max="258000"></label></div><button id="world-promote-decoration">Move to shared decorations</button><label>Shared decorations<select id="world-common-decorations"><option value="">No shared decorations</option></select></label><button id="world-decoration-move">Apply decoration coordinates</button><button id="world-decoration-remove" class="danger">Remove shared decoration</button><h3>Continuity of roads and rivers</h3><label>Connect<select id="world-seam-mode"><option value="both">Roads and rivers</option><option value="road">Roads only</option><option value="water">Rivers only</option></select></label><button id="world-repair-seams">Repair seam surfaces</button><p class="hint">Changes cover three tiles on each side. Check bridges, objects, and authored surfaces; the operation can be undone through world history.</p><h3>Measured DEM elevations</h3><label>Coordinate system<select id="world-elevation-crs"><option value="EPSG:4326">EPSG:4326 — longitude/latitude</option><option value="EPSG:3857">EPSG:3857 — Web Mercator</option></select></label><label>Elevation source<input id="world-elevation-source" placeholder="For example: Copernicus DEM, section and date"></label><label>License/terms<input id="world-elevation-license" placeholder="Source license"></label><button id="world-import-elevation">Import ESRI ASCII .asc</button><input id="world-elevation-file" type="file" accept=".asc,.txt" hidden><label class="world-height-toggle"><input id="world-height-overlay" type="checkbox"> Show elevations</label><p id="world-elevation-summary" class="hint">Elevation data has not yet been loaded.</p><p class="hint">The geographic layer and elevation.asc preserve real elevations in meters. Native PZ terrain remains flat; building floors do not represent terrain elevation.</p>`; document.querySelector('.world-inspector').append(panel); $('world-promote-source').onchange=()=>this.coordinatesForLocal();$('world-common-buildings').onchange=()=>{this.selectedBuilding=$('world-common-buildings').value;this.coordinatesForGlobal();this.updateButtons();workshop.draw();}; $('world-promote-building').onclick=()=>this.run('We transfer the building to the shared layer...',async signal=>{const member=this.member(),building=member?.project.buildings.find(b=>b.id===$('world-promote-source').value);if(!building)throw Error('Select a section building.');const result=await workshop.api('/api/world/buildings/promote',{world:workshop.world,memberId:member.id,buildingId:building.id,...this.coordinates()},signal);this.selectedBuilding=building.id;workshop.acceptWorld(result.world);this.coordinatesForGlobal();workshop.state('Building moved to the shared layer. Its shape and floors are preserved.');}); @@ -25,12 +38,20 @@ export class WorldGeography{ $('world-building-remove').onclick=()=>this.run('Removing the shared building...',async signal=>{const result=await workshop.api('/api/world/buildings/remove',{world:workshop.world,buildingId:this.selectedBuilding},signal);this.selectedBuilding=null;workshop.acceptWorld(result.world);workshop.state('The building was deleted by one history operation.');}); $('world-building-edit').onclick=()=>{const b=this.globalBuilding();if(!b||!this.editBuilding){workshop.state('The shared building editor is unavailable.',true);return;}this.editBuilding(structuredClone(b),async edited=>{workshop.flush();await this.put(edited);workshop.state('Shared building preserved; all sections have been checked.');});}; $('world-building-export').onclick=()=>{const b=this.globalBuilding();if(!b)return;workshop.download(new Blob([JSON.stringify({format:'SimplePZBuilding',version:buildingTemplateVersion(b),building:b,attribution:b.attribution||[]})],{type:'application/json'}),'world-building.pzbuilding.json');}; + $('world-promote-decoration-source').onchange=()=>this.decorationCoordinatesForLocal();$('world-common-decorations').onchange=()=>{this.selectedDecoration=$('world-common-decorations').value;this.decorationCoordinatesForGlobal();this.updateButtons();workshop.draw();}; + $('world-promote-decoration').onclick=()=>this.run('Moving the decoration to the shared layer...',async signal=>{const member=this.member(),decoration=member?.project.decorations?.find(d=>d.id===$('world-promote-decoration-source').value);if(!decoration)throw Error('Select a section decoration.');const result=await workshop.api('/api/world/decorations/promote',{world:workshop.world,memberId:member.id,decorationId:decoration.id,...this.decorationCoordinates()},signal);this.selectedDecoration=decoration.id;workshop.acceptWorld(result.world);this.decorationCoordinatesForGlobal();workshop.state('Decoration moved to the shared layer. Sections it reaches draw their part.');}); + $('world-decoration-move').onclick=()=>this.run('Checking the new position of the decoration...',async signal=>{const d=this.globalDecoration();if(!d)throw Error('Select a shared decoration.');const result=await workshop.api('/api/world/decorations',{world:workshop.world,decoration:{...structuredClone(d),...this.decorationCoordinates()}},signal);workshop.acceptWorld(result.world);this.decorationCoordinatesForGlobal();workshop.state('Shared decoration moved; every section it reaches has been checked.');}); + $('world-decoration-remove').onclick=()=>this.run('Removing the shared decoration...',async signal=>{const result=await workshop.api('/api/world/decorations/remove',{world:workshop.world,decorationId:this.selectedDecoration},signal);this.selectedDecoration=null;workshop.acceptWorld(result.world);workshop.state('The shared decoration was removed by one history operation.');}); $('world-repair-seams').onclick=()=>this.run('Connecting roads and rivers...',async signal=>{const result=await workshop.api('/api/world/seams/repair',{world:workshop.world,mode:$('world-seam-mode').value},signal);workshop.acceptWorld(result.world);$('world-warnings').replaceChildren(...result.issues.map(text=>{const p=document.createElement('p');p.textContent=text;return p;}));workshop.state(`Surfaces connected: modified ${result.changedCells} tiles. Warnings: ${result.issues.length}.`);}); $('world-import-elevation').onclick=()=>$('world-elevation-file').click();$('world-elevation-file').onchange=e=>this.importElevation(e);$('world-height-overlay').onchange=()=>workshop.draw(); const download=document.createElement('button');download.id='world-download-elevation';download.textContent='Download area DEM';$('world-import-elevation').before(download);download.onclick=()=>this.run('Downloading measured elevations from Terrain Tiles…',async signal=>{const result=await workshop.api('/api/world/elevation/download',workshop.world,signal);workshop.acceptWorld(result.world);$('world-height-overlay').checked=true;workshop.draw();workshop.state(`${result.minimum}…${result.maximum}m. ${result.note}`);}); } member(){return this.workshop.world.maps.find(m=>m.id===this.workshop.selectedId);} globalBuilding(){return this.workshop.world.buildings?.find(b=>b.id===this.selectedBuilding);} + globalDecoration(){return this.workshop.world.decorations?.find(d=>d.id===this.selectedDecoration);} + decorationCoordinates(){const x=+$('world-decoration-x').value,y=+$('world-decoration-y').value;if(![x,y].every(n=>Number.isInteger(n)&&n>=0&&n<=258000))throw Error('World tiles X/Y: integers from 0 to 258000.');return {x,y};} + decorationCoordinatesForLocal(){const m=this.member(),d=m?.project.decorations?.find(d=>d.id===$('world-promote-decoration-source').value);if(!d)return;$('world-decoration-x').value=m.project.worldX*256+d.x;$('world-decoration-y').value=m.project.worldY*256+d.y;} + decorationCoordinatesForGlobal(){const d=this.globalDecoration();if(!d)return;$('world-decoration-x').value=d.x;$('world-decoration-y').value=d.y;} coordinates(){const x=+$('world-building-x').value,y=+$('world-building-y').value;if(![x,y].every(n=>Number.isInteger(n)&&n>=0&&n<=258000))throw Error('World tiles X/Y: integers from 0 to 258000.');return {x,y};} coordinatesForLocal(){const m=this.member(),b=m?.project.buildings.find(b=>b.id===$('world-promote-source').value);if(!b)return;$('world-building-x').value=m.project.worldX*256+b.x;$('world-building-y').value=m.project.worldY*256+b.y;} coordinatesForGlobal(){const b=this.globalBuilding();if(!b)return;$('world-building-x').value=b.x;$('world-building-y').value=b.y;} @@ -38,11 +59,12 @@ export class WorldGeography{ async put(building,signal){const result=await this.workshop.api('/api/world/buildings',{world:this.workshop.world,building},signal);this.selectedBuilding=building.id;this.workshop.acceptWorld(result.world);this.coordinatesForGlobal();} async importBuilding(event){const file=event.target.files[0];if(!file)return;await this.run('Adding a template to the world...',async signal=>{validateTemplateBytes(file.size);const data=JSON.parse(await file.text());const building=buildingTemplateFromDocument(data);building.id=crypto.randomUUID().replaceAll('-','');Object.assign(building,this.coordinates());building.modified=true;validateDesign(building);await this.put(building,signal);this.workshop.state('The template has been added to the shared world layer.');});event.target.value='';} async importElevation(event){const file=event.target.files[0];if(!file)return;await this.run('Importing real heights...',async signal=>{if(file.size>25000000)throw Error('DEM exceeds 25MB.');const result=await this.workshop.api('/api/world/elevation/import',{world:this.workshop.world,text:await file.text(),coordinateSystem:$('world-elevation-crs').value,source:$('world-elevation-source').value,license:$('world-elevation-license').value},signal);this.workshop.acceptWorld(result.world);$('world-height-overlay').checked=true;this.workshop.draw();this.workshop.state(`DEM: ${result.minimum}…${result.maximum}m; covered by ${result.measured} from ${result.sampled} test points. ${result.note}`);});event.target.value='';} - update(){const m=this.member(),selected=$('world-promote-source').value;const sectionChanged=this.memberId!==m?.id;this.memberId=m?.id;$('world-promote-source').replaceChildren(...(m?.project.buildings||[]).map(b=>new Option(b.name,b.id)));if([...$('world-promote-source').options].some(o=>o.value===selected))$('world-promote-source').value=selected;const buildings=this.workshop.world.buildings||[];$('world-common-buildings').replaceChildren(...buildings.map(b=>new Option(`${b.name} · ${b.x},${b.y}`,b.id)));if(!buildings.some(b=>b.id===this.selectedBuilding))this.selectedBuilding=buildings[0]?.id||null;$('world-common-buildings').value=this.selectedBuilding||'';if(sectionChanged){if(this.globalBuilding())this.coordinatesForGlobal();else if(m?.project.buildings.length)this.coordinatesForLocal();else if(m){$('world-building-x').value=m.project.worldX*256+250;$('world-building-y').value=m.project.worldY*256+80;}} + update(){const m=this.member(),selected=$('world-promote-source').value;const sectionChanged=this.memberId!==m?.id;this.memberId=m?.id;$('world-promote-source').replaceChildren(...(m?.project.buildings||[]).map(b=>new Option(b.name,b.id)));if([...$('world-promote-source').options].some(o=>o.value===selected))$('world-promote-source').value=selected;const buildings=this.workshop.world.buildings||[];$('world-common-buildings').replaceChildren(...buildings.map(b=>new Option(`${b.name} · ${b.x},${b.y}`,b.id)));if(!buildings.some(b=>b.id===this.selectedBuilding))this.selectedBuilding=buildings[0]?.id||null;$('world-common-buildings').value=this.selectedBuilding||'';const decorationSource=$('world-promote-decoration-source').value;$('world-promote-decoration-source').replaceChildren(...(m?.project.decorations||[]).filter(d=>sharedDecorationId(d.id)).slice(0,2000).map(d=>new Option(`${d.asset} · ${d.x},${d.y}`,d.id)));if([...$('world-promote-decoration-source').options].some(o=>o.value===decorationSource))$('world-promote-decoration-source').value=decorationSource;const shared=this.workshop.world.decorations||[];$('world-common-decorations').replaceChildren(...shared.slice(0,2000).map(d=>new Option(`${d.asset} · ${d.x},${d.y}`,d.id)));if(!shared.some(d=>d.id===this.selectedDecoration))this.selectedDecoration=shared[0]?.id||null;$('world-common-decorations').value=this.selectedDecoration||'';if(sectionChanged){if(this.globalDecoration())this.decorationCoordinatesForGlobal();else this.decorationCoordinatesForLocal();} + if(sectionChanged){if(this.globalBuilding())this.coordinatesForGlobal();else if(m?.project.buildings.length)this.coordinatesForLocal();else if(m){$('world-building-x').value=m.project.worldX*256+250;$('world-building-y').value=m.project.worldY*256+80;}} const grid=this.workshop.world.elevation;if(grid!==this.grid){this.grid=grid;this.rangeKey=null;this.heightImages.clear();if(grid){this.minimum=Infinity;this.maximum=-Infinity;for(const value of grid.values)if(value!==null){this.minimum=Math.min(this.minimum,value);this.maximum=Math.max(this.maximum,value);}}}const validHeight=!!grid&&!!this.workshop.world.geoReference;$('world-height-overlay').disabled=!validHeight;$('world-elevation-summary').textContent=grid?`${grid.columns}×${grid.rows} readings ${this.minimum}…${this.maximum}m ${grid.source}`:'Elevation data has not yet been loaded.';$('world-import-elevation').disabled=!this.workshop.world.geoReference;this.updateButtons(); } - updateButtons(){for(const id of ['world-building-edit','world-building-move','world-building-export','world-building-remove'])$(id).disabled=!this.globalBuilding();$('world-promote-building').disabled=!this.member()?.project.buildings.length;$('world-import-building').disabled=!this.workshop.world.maps.length;$('world-repair-seams').disabled=this.workshop.world.maps.length<2;$('world-download-elevation').disabled=!this.workshop.world.geoReference;} + updateButtons(){for(const id of ['world-building-edit','world-building-move','world-building-export','world-building-remove'])$(id).disabled=!this.globalBuilding();$('world-promote-building').disabled=!this.member()?.project.buildings.length;for(const id of ['world-decoration-move','world-decoration-remove'])$(id).disabled=!this.globalDecoration();$('world-promote-decoration').disabled=!this.member()?.project.decorations?.some(d=>sharedDecorationId(d.id));$('world-import-building').disabled=!this.workshop.world.maps.length;$('world-repair-seams').disabled=this.workshop.world.maps.length<2;$('world-download-elevation').disabled=!this.workshop.world.geoReference;} heightImage(member){const world=this.workshop.world,p=member.project,rangeKey=JSON.stringify([world.geoReference,world.maps.map(m=>[m.id,m.project.worldX,m.project.worldY,m.project.width,m.project.height])]);if(rangeKey!==this.rangeKey){this.rangeKey=rangeKey;this.heightImages.clear();this.displayMinimum=Infinity;this.displayMaximum=-Infinity;for(const m of world.maps)for(let y=0;y<32;y++)for(let x=0;x<32;x++){const g=worldGeographic(world.geoReference,m.project.worldX*256+(x+.5)*m.project.width/32,m.project.worldY*256+(y+.5)*m.project.height/32),h=elevationSample(this.grid,g.longitude,g.latitude);if(h!==null){this.displayMinimum=Math.min(this.displayMinimum,h);this.displayMaximum=Math.max(this.displayMaximum,h);}}if(!Number.isFinite(this.displayMinimum)){this.displayMinimum=this.minimum;this.displayMaximum=this.maximum;}this.legend??=document.createElement('p');this.legend.id='world-elevation-legend';this.legend.className='hint';$('world-elevation-summary').after(this.legend);this.legend.textContent=`Preview contrast: ${this.displayMinimum.toFixed(1)}…${this.displayMaximum.toFixed(1)} m (32 × 32 samples per section). Blue indicates lower elevations; ochre indicates higher elevations.`;} const key=`${member.id}:${p.worldX}:${p.worldY}:${p.width}:${p.height}`;if(this.heightImages.has(key))return this.heightImages.get(key);const c=document.createElement('canvas');c.width=64;c.height=64;const ctx=c.getContext('2d'),image=ctx.createImageData(64,64),span=Math.max(1,this.displayMaximum-this.displayMinimum);for(let y=0;y<64;y++)for(let x=0;x<64;x++){const g=worldGeographic(world.geoReference,p.worldX*256+(x+.5)*p.width/64,p.worldY*256+(y+.5)*p.height/64),height=elevationSample(this.grid,g.longitude,g.latitude);if(height===null)continue;const q=Math.max(0,Math.min(1,(height-this.displayMinimum)/span));image.data.set([Math.round(55+190*q),Math.round(130+50*(1-Math.abs(q-.5)*2)),Math.round(185*(1-q)+40),175],(y*64+x)*4);}ctx.putImageData(image,0,0);this.heightImages.set(key,c);while(this.heightImages.size>32)this.heightImages.delete(this.heightImages.keys().next().value);return c;} - draw(ctx,t){if(this.grid&&this.workshop.world.geoReference&&$('world-height-overlay').checked)for(const m of this.workshop.world.maps){const p=m.project;ctx.drawImage(this.heightImage(m),t.x+p.worldX*t.scale,t.y+p.worldY*t.scale,p.width/256*t.scale,p.height/256*t.scale);}for(const b of this.workshop.world.buildings||[]){const x=t.x+b.x/256*t.scale,y=t.y+b.y/256*t.scale,w=Math.max(3,b.width/256*t.scale),h=Math.max(3,b.height/256*t.scale);ctx.fillStyle=b.id===this.selectedBuilding?'#f0bc69':'#d8af86';ctx.fillRect(x,y,w,h);ctx.strokeStyle='#29241d';ctx.lineWidth=1;ctx.strokeRect(x,y,w,h);}ctx.globalAlpha=1;} + draw(ctx,t){if(this.grid&&this.workshop.world.geoReference&&$('world-height-overlay').checked)for(const m of this.workshop.world.maps){const p=m.project;ctx.drawImage(this.heightImage(m),t.x+p.worldX*t.scale,t.y+p.worldY*t.scale,p.width/256*t.scale,p.height/256*t.scale);}for(const b of this.workshop.world.buildings||[]){const x=t.x+b.x/256*t.scale,y=t.y+b.y/256*t.scale,w=Math.max(3,b.width/256*t.scale),h=Math.max(3,b.height/256*t.scale);ctx.fillStyle=b.id===this.selectedBuilding?'#f0bc69':'#d8af86';ctx.fillRect(x,y,w,h);ctx.strokeStyle='#29241d';ctx.lineWidth=1;ctx.strokeRect(x,y,w,h);}for(const d of this.workshop.world.decorations||[])for(const part of d.parts){const size=Math.max(2,t.scale/256);ctx.fillStyle=d.id===this.selectedDecoration?'#7fd0e6':'#9fc7d6';ctx.fillRect(t.x+(d.x+part.dx)/256*t.scale,t.y+(d.y+part.dy)/256*t.scale,size,size);}ctx.globalAlpha=1;} } diff --git a/src/SimplePZMapmaker.Web/wwwroot/world-protection.js b/src/SimplePZMapmaker.Web/wwwroot/world-protection.js index bcb28e3..5d3b362 100644 --- a/src/SimplePZMapmaker.Web/wwwroot/world-protection.js +++ b/src/SimplePZMapmaker.Web/wwwroot/world-protection.js @@ -1,7 +1,9 @@ import {occupiedIndices} from './building.js'; -export function globalBuildingMask(world,project){const mask=new Set();for(const b of world?.buildings||[]){if(b.x+b.width<project.worldX*256||b.y+b.height<project.worldY*256||b.x>=project.worldX*256+project.width||b.y>=project.worldY*256+project.height)continue;for(const i of occupiedIndices(b)){const x=b.x+i%(b.width+1)-project.worldX*256,y=b.y+Math.floor(i/(b.width+1))-project.worldY*256;if(x>=0&&y>=0&&x<project.width&&y<project.height)mask.add(y*project.width+x);}}return mask;} +export function globalBuildingMask(world,project){const mask=new Set();for(const b of world?.buildings||[]){if(b.x+b.width<project.worldX*256||b.y+b.height<project.worldY*256||b.x>=project.worldX*256+project.width||b.y>=project.worldY*256+project.height)continue;for(const i of occupiedIndices(b)){const x=b.x+i%(b.width+1)-project.worldX*256,y=b.y+Math.floor(i/(b.width+1))-project.worldY*256;if(x>=0&&y>=0&&x<project.width&&y<project.height)mask.add(y*project.width+x);}}for(const d of sharedLocalDecorations(world,project))for(const part of d.parts)mask.add((d.y+part.dy)*project.width+d.x+part.dx);return mask;} +// Shared decorations are stored once in absolute world tiles; a section sees only the parts inside it. +export function sharedLocalDecorations(world,project){const ox=project.worldX*256,oy=project.worldY*256,result=[];for(const d of world?.decorations||[]){const parts=d.parts.filter(p=>d.x+p.dx>=ox&&d.y+p.dy>=oy&&d.x+p.dx<ox+project.width&&d.y+p.dy<oy+project.height);if(parts.length)result.push({...d,x:d.x-ox,y:d.y-oy,parts});}return result;} export function overlapsGlobalBuilding(project,building,mask){return [...occupiedIndices(building)].some(i=>mask.has((building.y+Math.floor(i/(building.width+1)))*project.width+building.x+i%(building.width+1)));} export function validateGlobalProtection(project,mask){ - if(project.buildings.some(b=>overlapsGlobalBuilding(project,b,mask))||project.spawns.some(s=>mask.has(s.y*project.width+s.x))||(project.staticModels||[]).some(m=>mask.has(m.y*project.width+m.x))||(project.decorations||[]).some(d=>d.parts.some(part=>mask.has((d.y+part.dy)*project.width+d.x+part.dx))))throw Error('This change overlaps a shared world building. Edit it in the building workshop.'); - for(const i of mask)if(project.terrain[i]===3)throw Error('You cannot flood the shared building of the world with water.'); + if(project.buildings.some(b=>overlapsGlobalBuilding(project,b,mask))||project.spawns.some(s=>mask.has(s.y*project.width+s.x))||(project.staticModels||[]).some(m=>mask.has(m.y*project.width+m.x))||(project.decorations||[]).some(d=>d.parts.some(part=>mask.has((d.y+part.dy)*project.width+d.x+part.dx))))throw Error('This change overlaps a shared world building or decoration. Edit it in the world inspector or the building workshop.'); + for(const i of mask)if(project.terrain[i]===3)throw Error('You cannot flood a shared world building or decoration with water.'); } diff --git a/src/SimplePZMapmaker.Web/wwwroot/world-workshop.js b/src/SimplePZMapmaker.Web/wwwroot/world-workshop.js index 452ae63..30370a4 100644 --- a/src/SimplePZMapmaker.Web/wwwroot/world-workshop.js +++ b/src/SimplePZMapmaker.Web/wwwroot/world-workshop.js @@ -19,7 +19,7 @@ export function placementIssue(world,p,x,y,skipId=null){ return null; } export function validateWorld(world,allowEmpty=false){ - if(!world||world.format!=='SimplePZWorld'||![1,2,3,4,5,6,7].includes(world.version)||!safeId(world.id))throw Error('The world format or identifier is corrupted.'); + if(!world||world.format!=='SimplePZWorld'||![1,2,3,4,5,6,7,8].includes(world.version)||!safeId(world.id))throw Error('The world format or identifier is corrupted.'); if(typeof world.name!=='string'||!world.name.trim()||world.name.length>80||/[\x00-\x1f\x7f]/.test(world.name))throw Error('Enter the name of the world up to 80 characters.'); if(!Array.isArray(world.maps)||world.maps.length>(128)||(!allowEmpty&&!world.maps.length))throw Error('A world must contain 1–128 sections.'); const ids=new Set();for(const m of world.maps){if(!m||!safeId(m.id)||ids.has(m.id))throw Error('The world section identifier is invalid or duplicated.');ids.add(m.id);validateProject(m.project);const issue=placementIssue(world,m.project,m.project.worldX,m.project.worldY,m.id);if(issue)throw Error(issue);} @@ -27,7 +27,7 @@ export function validateWorld(world,allowEmpty=false){ if(worldBounds(world).cells>8192)throw Error('The world contains more than 8192 native cells.');validateWorldExtras(world);world.version=worldAuthoringVersion(world);return world; } const projectBytes=p=>{const {terrain,zombies,...metadata}=p;return terrain.length*8+zombies.length*8+JSON.stringify(metadata).length*2;}; -const operationBytes=o=>o.kind==='native-edit'?JSON.stringify(o).length*2:o.kind==='replace'?[o.before,o.after].reduce((sum,w)=>sum+w.maps.reduce((n,m)=>n+projectBytes(m.project),0)+JSON.stringify({buildings:w.buildings||[],linearFeatures:w.linearFeatures||[],elevation:w.elevation||null}).length*2,0):o.member?projectBytes(o.member.project):512; +const operationBytes=o=>o.kind==='native-edit'?JSON.stringify(o).length*2:o.kind==='replace'?[o.before,o.after].reduce((sum,w)=>sum+w.maps.reduce((n,m)=>n+projectBytes(m.project),0)+JSON.stringify({buildings:w.buildings||[],decorations:w.decorations||[],linearFeatures:w.linearFeatures||[],elevation:w.elevation||null}).length*2,0):o.member?projectBytes(o.member.project):512; class WorldHistory{ constructor(){this.past=[];this.future=[];this.budget=128*1024*1024;} push(operation){if(operationBytes(operation)>512*1024*1024)throw Error('Replacing a world requires more than 512MB of history. Keep the old world, then open a new empty world before replacing it. The current world is saved.');this.past.push(operation);this.future=[];while(this.past.length>1&&(this.past.length>30||this.past.reduce((n,o)=>n+operationBytes(o),0)>this.budget))this.past.shift();} diff --git a/tests/SimplePZMapmaker.Tests/AppearanceTagTests.cs b/tests/SimplePZMapmaker.Tests/AppearanceTagTests.cs new file mode 100644 index 0000000..3afe31f --- /dev/null +++ b/tests/SimplePZMapmaker.Tests/AppearanceTagTests.cs @@ -0,0 +1,43 @@ +using SimplePZMapmaker.Core; +using SimplePZMapmaker.Core.Export; +using System.IO.Compression; +using System.Text; +using System.Text.Json; +public static class AppearanceTagTests +{ + public static IEnumerable<(string,Action)> Cases(){ + yield return("appearance tags set style roof colour wall set trim and kind on imported buildings",()=>{ + var p=Import(new(){{"building","house"},{"pz:style","brick"},{"roof:shape","gabled"},{"roof:colour","red"},{"pz:wall","set:walls_exterior_house_02_32"},{"pz:trim","fixtures_windows_detailing_01_1:fixtures_windows_detailing_01_0"},{"pz:unknown-thing","x"}}, + ("osm-way-2",new(){{"building","yes"},{"pz:kind","bathhouse"}},3,3)); + var house=p.Buildings.Single(b=>b.SourceId=="geo-osm-way-1");var d=house.Design!; + Check(d.Style=="brick"&&d.RoofTileset=="roofs_30_09","pz:style or roof:colour was not applied"); + var outer=d.Floors.SelectMany(f=>f.Edges.Select(e=>(f,e))).Where(x=>x.e.Kind!="open"&&((BuildingDesigner.Cell(house,x.f,x.e.X,x.e.Y)=='.')!=((x.e.Edge=="n"?BuildingDesigner.Cell(house,x.f,x.e.X,x.e.Y-1):BuildingDesigner.Cell(house,x.f,x.e.X-1,x.e.Y))=='.'))).ToList(); + Check(outer.Count>0&&outer.All(x=>x.e.WallTile?.StartsWith("walls_exterior_house_02_")==true),"pz:wall did not reach every exterior edge"); + Check(outer.Where(x=>x.e.Kind=="window").All(x=>x.e.OverlayTile==(x.e.Edge=="n"?"fixtures_windows_detailing_01_1":"fixtures_windows_detailing_01_0"))&&outer.Any(x=>x.e.Kind=="window"),"pz:trim did not reach exterior windows"); + Check(outer.Where(x=>x.e.Kind=="door").All(x=>x.e.WallTile==$"walls_exterior_house_02_{42+(x.e.Edge=="n"?1:0)}"),"Door frames do not follow the wall set"); + Check(d.Floors.SelectMany(f=>f.Edges).Where(e=>e.WallTile is not null).All(e=>outer.Any(x=>x.e==e)),"Interior walls were changed"); + var bath=p.Buildings.Single(b=>b.SourceId=="geo-osm-way-2");Check(bath.Kind=="house"&&bath.Design!.Floors[0].Rooms.ContainsValue("bathroom"),"pz:kind=bathhouse is not a one-room bath"); + Check(p.ImportSummary!.Notes.Any(n=>n.Contains("pz:unknown-thing")),"Unknown pz:* key is not reported"); + ProjectValidator.Validate(p);using var zip=new ZipArchive(new MemoryStream(NativeMapExporter.Export(p)));using var r=new StreamReader(zip.Entries.Single(e=>e.Name.EndsWith(".lotheader")).Open(),Encoding.Latin1);var header=r.ReadToEnd(); + Check(header.Contains("walls_exterior_house_02_32"),"wall missing");Check(header.Contains("roofs_30_09"),"roof missing: "+house.Design!.Roof);Check(header.Contains("fixtures_windows_detailing_01_"),"trim missing"); + }); + yield return("bad appearance values are reported and never fail the import",()=>{ + var p=Import(new(){{"building","house"},{"pz:style","no-such-style"},{"roof:colour","plaid"},{"pz:wall","not a tile"},{"pz:trim","onlyone_1"}}); + Check(p.Buildings.Count==1&&p.ImportSummary!.Notes.Count(n=>n.StartsWith("Appearance tag ignored"))==4,"Bad tag values must be reported, not fatal"); + }); + yield return("hex roof colours pick the nearest measured roof set and palettes name wall sets",()=>{ + Check(GeographicImporter.RoofTilesetFor("#d0d0d0")=="roofs_30_03"&&GeographicImporter.RoofTilesetFor("#304030")=="roofs_30_05"&&GeographicImporter.RoofTilesetFor("Grey")=="roofs_30_04","Roof colour lookup changed"); + var palette=new ResourcePalette{Id=new string('a',32),Name="Pilna",Resources=[new(){Role="wall",Id="walls_exterior_wooden_01_24"}]}; + Check(GeographicImporter.WallSetFor(palette.Id,[palette])==("walls_exterior_wooden_01",24)&&GeographicImporter.WallSetFor("walls_exterior_house_01_48",null)==("walls_exterior_house_01",48),"Wall set lookup changed"); + }); + } + static MapProject Import(Dictionary<string,string> tags,params (string Id,Dictionary<string,string> Tags,int W,int H)[] more){ + var reference=new WorldGeoReference{Bounds=new(){West=32,East=32.01,South=54,North=54.01},MetresPerTile=1,OriginCellX=100,OriginCellY=100};var projection=new GeographicProjection(reference,100,100); + double[] At(double x,double y){var g=projection.Geographic(x,y);return[g.Longitude,g.Latitude];} + object Box(string id,double x,double y,double w,double h,Dictionary<string,string> t)=>new{type="Feature",id,properties=t,geometry=new{type="Polygon",coordinates=new[]{new[]{At(x,y),At(x+w,y),At(x+w,y+h),At(x,y+h),At(x,y)}}}}; + var features=new List<object>{Box("osm-way-1",40.2,40.2,12,10,tags)};var x=70.2;foreach(var m in more){features.Add(Box(m.Id,x,40.2,m.W,m.H,m.Tags));x+=20;} + var json=JsonSerializer.Serialize(new{type="FeatureCollection",pzTransfer=1,features}); + return GeographicImporter.Import(json,MapGenerator.Generate(new(){Kind="blank"}),reference.Bounds,projection:projection).Project; + } + static void Check(bool ok,string message){if(!ok)throw new Exception(message);} +} diff --git a/tests/SimplePZMapmaker.Tests/AttachedBuildingTests.cs b/tests/SimplePZMapmaker.Tests/AttachedBuildingTests.cs new file mode 100644 index 0000000..4665992 --- /dev/null +++ b/tests/SimplePZMapmaker.Tests/AttachedBuildingTests.cs @@ -0,0 +1,45 @@ +using SimplePZMapmaker.Core; +using SimplePZMapmaker.Core.Export; +using System.Text.Json; +public static class AttachedBuildingTests +{ + public static IEnumerable<(string,Action)> Cases(){ + yield return("pz:attached places a shed against the house with one shared wall and a door between them",()=>{ + var p=Import(new(){{"building","shed"},{"pz:kind","shed"},{"pz:attached","osm-way-1"},{"pz:door-to","osm-way-1"}}); + var house=p.Buildings.Single(b=>b.SourceId=="geo-osm-way-1");var shed=p.Buildings.Single(b=>b.SourceId=="geo-osm-way-2"); + Check(shed.AttachedTo==house.Id&&p.Version==13,"The shed is not attached or the map format was not raised"); + var houseCells=Cells(house);var shedCells=Cells(shed);Check(!houseCells.Overlaps(shedCells)&&shedCells.Any(c=>houseCells.Contains((c.X-1,c.Y))),"The rooms do not touch without a free line"); + var suppressed=AttachedBuildings.SuppressedEdges(p.Buildings);Check(suppressed.Count>0&&suppressed.All(e=>e.Building==shed.Id&&e.Edge=="w"&&e.X==shedCells.Min(c=>c.X)),"Only the shed's side of the shared wall should be suppressed"); + var ground=house.Design!.Floors[-house.Design.MinLevel];var sharedX=shedCells.Min(c=>c.X)-house.X; + Check(ground.Edges.Any(e=>e.X==sharedX&&e.Edge=="w"&&e.Kind=="door"&&shedCells.Contains((house.X+e.X,house.Y+e.Y))),"pz:door-to did not open the shared wall"); + var shedGround=shed.Design!.Floors[-shed.Design.MinLevel];Check(shedGround.Edges.Any(e=>e.Kind=="door"&&!suppressed.Contains((shed.Id,shed.X+e.X,shed.Y+e.Y,0,e.Edge))),"The shed lost its own entrance"); + Check(ground.Edges.Where(e=>e.X==sharedX&&e.Edge=="w"&&shedCells.Contains((house.X+e.X,house.Y+e.Y))).All(e=>e.Kind is "wall" or "door"),"A window still opens into the attached shed"); + ProjectValidator.Validate(p); + var scene=NativeScene.Cell(p,house.X-2,house.Y-2,house.Width+shed.Width+6,house.Height+4,0);var sharedWall=shedCells.Min(c=>c.X); + foreach(var y in shedCells.Select(c=>c.Y).Distinct()){var walls=scene.Sprites.Where(s=>s.Z==0&&s.X==sharedWall&&s.Y==y&&s.Tile.StartsWith("walls_",StringComparison.Ordinal)).ToList();var ownNorth=shedGround.Edges.Count(e=>e.X==sharedWall-shed.X&&e.Y==y-shed.Y&&e.Edge=="n"&&e.Kind!="open");Check(walls.Count==1+ownNorth,$"Shared wall drawn {walls.Count-ownNorth} times at row {y}");} + }); + yield return("without pz:attached the same shed keeps the free line and is skipped",()=>{ + var p=Import(new(){{"building","shed"}});Check(p.Buildings.Count==1&&p.ImportSummary!.SkipReasons.ContainsKey("Building overlap"),"A touching outline without attachment must keep the free line"); + }); + yield return("attachment must name a building on the same map and rooms never overlap",()=>{ + var p=Import(new(){{"building","shed"},{"pz:attached","osm-way-1"}});var shed=p.Buildings.Single(b=>b.SourceId=="geo-osm-way-2"); + shed.AttachedTo="missing";Reject(()=>ProjectValidator.Validate(p));shed.AttachedTo=shed.Id;Reject(()=>ProjectValidator.Validate(p)); + var house=p.Buildings.Single(b=>b.SourceId=="geo-osm-way-1");shed.AttachedTo=house.Id;shed.X-=1;Reject(()=>ProjectValidator.Validate(p)); + var json=JsonSerializer.Serialize(p,NativeMapExporter.JsonOptions);shed.X+=1;var round=JsonSerializer.Deserialize<MapProject>(JsonSerializer.Serialize(p,NativeMapExporter.JsonOptions),NativeMapExporter.JsonOptions)!;ProjectValidator.Validate(round);Check(round.Buildings.Single(b=>b.SourceId=="geo-osm-way-2").AttachedTo==house.Id,"AttachedTo did not survive JSON"); + }); + yield return("shared world buildings refuse attachment",()=>{ + var w=WorldTests.Fixture(2);var b=new Building{X=100*256+250,Y=100*256+80,Width=12,Height=10,AttachedTo="x"};b.Design=BuildingDesigner.Generate(b,1,42);w.Buildings.Add(b);Reject(()=>WorldAssembler.Validate(w)); + }); + } + static HashSet<(int X,int Y)> Cells(Building b){var f=b.Design!.Floors[-b.Design.MinLevel];var result=new HashSet<(int,int)>();for(var y=0;y<b.Height;y++)for(var x=0;x<b.Width;x++)if(BuildingDesigner.Cell(b,f,x,y)!='.')result.Add((b.X+x,b.Y+y));return result;} + /// <summary>A 12x10 house and a 4x4 shed sharing the house's east side.</summary> + static MapProject Import(Dictionary<string,string> shedTags){ + var reference=new WorldGeoReference{Bounds=new(){West=32,East=32.01,South=54,North=54.01},MetresPerTile=1,OriginCellX=100,OriginCellY=100};var projection=new GeographicProjection(reference,100,100); + double[] At(double x,double y){var g=projection.Geographic(x,y);return[g.Longitude,g.Latitude];} + object Box(string id,double x,double y,double w,double h,Dictionary<string,string> t)=>new{type="Feature",id,properties=t,geometry=new{type="Polygon",coordinates=new[]{new[]{At(x,y),At(x+w,y),At(x+w,y+h),At(x,y+h),At(x,y)}}}}; + var json=JsonSerializer.Serialize(new{type="FeatureCollection",pzTransfer=1,features=new[]{Box("osm-way-2",52,43,4,4,shedTags),Box("osm-way-1",40,40,12,10,new(){{"building","house"}})}}); + return GeographicImporter.Import(json,MapGenerator.Generate(new(){Kind="blank"}),reference.Bounds,projection:projection).Project; + } + static void Check(bool ok,string message){if(!ok)throw new Exception(message);} + static void Reject(Action action){try{action();}catch(ArgumentException){return;}throw new Exception("Expected validation failure");} +} diff --git a/tests/SimplePZMapmaker.Tests/ModPackageTests.cs b/tests/SimplePZMapmaker.Tests/ModPackageTests.cs new file mode 100644 index 0000000..f7570a6 --- /dev/null +++ b/tests/SimplePZMapmaker.Tests/ModPackageTests.cs @@ -0,0 +1,41 @@ +using SimplePZMapmaker.Core; +using SimplePZMapmaker.Core.Export; +using System.IO.Compression; +using System.Text; +public static class ModPackageTests +{ + public static IEnumerable<(string,Action)> Cases(){ + yield return("mod package renames the mod id map folder title and can move maps under common",()=>{ + var p=MapGenerator.Generate(new(){Kind="blank",Name="Pilna"});var original=NativeMapExporter.Export(p);var slug=NativeMapExporter.ModId(p); + using var target=new MemoryStream();NativeModPackage.Repack(new MemoryStream(original),target,new("PilnaVillage",null,"Pilna village",true)); + using var zip=new ZipArchive(new MemoryStream(target.ToArray()));string Read(string path){using var r=new StreamReader(zip.GetEntry(path)!.Open(),Encoding.UTF8);return r.ReadToEnd();} + Check(zip.Entries.All(e=>e.FullName.StartsWith("PilnaVillage/",StringComparison.Ordinal))&&!zip.Entries.Any(e=>e.FullName.Contains(slug)),"Old mod folder name remains"); + var info=Read("PilnaVillage/42/mod.info");Check(info.Contains("id=PilnaVillage\n")&&info.Contains("name=Pilna village\n"),"mod.info was not renamed: "+info); + const string maps="PilnaVillage/common/media/maps/PilnaVillage/";Check(zip.Entries.Any(e=>e.FullName.StartsWith(maps,StringComparison.Ordinal)&&e.Name.EndsWith(".lotheader"))&&!zip.Entries.Any(e=>e.FullName.Contains("/42/media/maps/")),"Maps did not move under common"); + Check(Read(maps+"spawnregions.lua").Contains("media/maps/PilnaVillage/spawnpoints.lua")&&Read(maps+"map.info").Contains("title=Pilna village"),"Map references were not rewritten"); + Check(Read("PilnaVillage/INSTALL.txt").Contains("Map=PilnaVillage"),"Server instructions still name the old map"); + using var versioned=new MemoryStream();NativeModPackage.Repack(new MemoryStream(original),versioned,new(ModId:"Pilna",MapFolder:"PilnaMap"));using var v=new ZipArchive(new MemoryStream(versioned.ToArray()));Check(v.Entries.Any(e=>e.FullName.StartsWith("Pilna/42/media/maps/PilnaMap/",StringComparison.Ordinal))&&v.GetEntry("Pilna/common/media/maps/") is not null,"Separate map folder or common skeleton missing"); + Reject(()=>NativeModPackage.Validate(new("../escape")));Reject(()=>NativeModPackage.Validate(new(Name:"two\nlines"))); + }); + yield return("HTTP exports install only on POST and a GET can only rename the archive",()=>{ + Microsoft.AspNetCore.Http.HttpRequest Request(string method,string query){var context=new Microsoft.AspNetCore.Http.DefaultHttpContext();context.Request.Method=method;context.Request.QueryString=new(query);return context.Request;} + Reject(()=>SimplePZMapmaker.Web.Services.NativeExportService.Options(Request("GET","?install=true"))); + Check(SimplePZMapmaker.Web.Services.NativeExportService.Options(Request("GET","?modId=Pilna")) is {ModId:"Pilna",Install:false},"A GET rename was refused"); + Check(SimplePZMapmaker.Web.Services.NativeExportService.Options(Request("POST","?install=true")) is {Install:true},"A POST install was refused"); + Check(SimplePZMapmaker.Web.Services.NativeExportService.Options(Request("GET","")) is null,"An export without options must not be repackaged"); + }); + yield return("install writes a marked mod folder, replaces only its own folder and refuses escaping paths",()=>{ + var mods=Path.Combine(Environment.GetEnvironmentVariable("PZMAP_WORKSPACE")??Path.GetFullPath("."),"work","mod-install-tests",Guid.NewGuid().ToString("N")); + try{ + var p=MapGenerator.Generate(new(){Kind="blank",Name="Pilna"});using var named=new MemoryStream();NativeModPackage.Repack(new MemoryStream(NativeMapExporter.Export(p)),named,new("PilnaVillage")); + var folder=NativeModPackage.Install(new MemoryStream(named.ToArray()),mods);Check(File.Exists(Path.Combine(folder,"42","mod.info"))&&File.Exists(Path.Combine(folder,NativeModPackage.InstallMarker)),"Installed mod is incomplete"); + File.WriteAllText(Path.Combine(folder,"stale.txt"),"old");NativeModPackage.Install(new MemoryStream(named.ToArray()),mods);Check(!File.Exists(Path.Combine(folder,"stale.txt")),"Reinstall did not replace the editor's own folder"); + File.Delete(Path.Combine(folder,NativeModPackage.InstallMarker));Reject(()=>NativeModPackage.Install(new MemoryStream(named.ToArray()),mods));Check(File.Exists(Path.Combine(folder,"42","mod.info")),"A foreign folder was touched"); + using var evil=new MemoryStream();using(var z=new ZipArchive(evil,ZipArchiveMode.Create,true)){z.CreateEntry("Evil/42/mod.info");z.CreateEntry("Evil/../../outside.txt");} + Reject(()=>NativeModPackage.Install(new MemoryStream(evil.ToArray()),mods));Check(!File.Exists(Path.Combine(mods,"..","outside.txt")),"Archive escaped the mods folder"); + }finally{if(Directory.Exists(mods))Directory.Delete(mods,true);} + }); + } + static void Check(bool ok,string message){if(!ok)throw new Exception(message);} + static void Reject(Action action){try{action();}catch(ArgumentException){return;}throw new Exception("Expected validation failure");} +} diff --git a/tests/SimplePZMapmaker.Tests/Program.cs b/tests/SimplePZMapmaker.Tests/Program.cs index e907965..610a376 100644 --- a/tests/SimplePZMapmaker.Tests/Program.cs +++ b/tests/SimplePZMapmaker.Tests/Program.cs @@ -27,7 +27,7 @@ }) }; var failures=0; -var all=tests.Concat(GeoTests.Cases()).Concat(RotatedProjectionTests.Cases()).Concat(FenceLineTests.Cases()).Concat(FenceAuthoringTests.Cases()).Concat(ExportTests.Cases()).Concat(RegressionTests.Cases()).Concat(UpstreamTests.Cases()).Concat(PilnaTests.Cases()).Concat(RecoveryTests.Cases()).Concat(BuildingTests.Cases()).Concat(AssetTests.Cases()).Concat(VisualWorkshopTests.Cases()).Concat(HousingTemplateTests.Cases()).Concat(WorldTests.Cases()).Concat(AgentSessionTests.Cases()).Concat(ModAssetTests.Cases()).Concat(StaticModelTests.Cases()).Concat(CrossCellTests.Cases()).Concat(OsmWorldTests.Cases()).Concat(NeighborhoodTests.Cases()).Concat(RecoveryVersionTests.Cases()).Concat(WorldGeographyTests.Cases()).Concat(ArchitectureTests.Cases()).Concat(PlanningSessionTests.Cases()).Concat(IntegrationQualityTests.Cases()).Concat(TerrariumTests.Cases()).Concat(NeighborhoodParcelTests.Cases()).Concat(ImportQualityTests.Cases()).Concat(NativeViewportTests.Cases()).Concat(MapSceneStoreTests.Cases()).Concat(SimplePZMapmaker.Tests.PaletteTests.Cases()).Concat(SimplePZMapmaker.Tests.WorldMapBinaryTests.Cases()).Concat(LinearGeographyTests.Cases()).Concat(GameplayAuthoringTests.Cases()).Concat(NativeLightingGeometryTests.Cases()).Concat(AuthoringIntegrationTests.Cases()).Concat(ZLevelTests.Cases()).Concat(NativeVerticalRangeTests.Cases()).Concat(ZFormatTests.Cases()).Concat(SectionWorkspaceTests.Cases()).Concat(StreamingExportTests.Cases()).Concat(BuildingModuleTests.Cases()).Concat(InfrastructureTests.Cases()).Concat(ZonePlanningTests.Cases()).Concat(WorldInfrastructureTests.Cases()).Concat(ParametricBuildingTests.Cases()).Concat(StreetEnsembleTests.Cases()).Concat(ExportJobTests.Cases()).Concat(WalkabilityTests.Cases()).Concat(CheckedDesignTests.Cases()).Concat(InstallationDiscoveryTests.Cases()).Concat(RoofSurfaceTests.Cases()).Concat(WallOverlayTests.Cases()).Concat(SettlementGenerationTests.Cases()).Concat(GenerationStudioTests.Cases()).Concat(GenerationQualityTests.Cases()).Concat(GeographicEvidenceTests.Cases()).Concat(SiteTests.Cases()).Concat(SourceTransferTests.Cases()).Concat(SectionStreamingTests.Cases()).Concat(PolygonZoneTests.Cases()).Concat(RegionAuthoringTests.Cases()).ToArray(); +var all=tests.Concat(GeoTests.Cases()).Concat(RotatedProjectionTests.Cases()).Concat(FenceLineTests.Cases()).Concat(FenceAuthoringTests.Cases()).Concat(ExportTests.Cases()).Concat(RegressionTests.Cases()).Concat(UpstreamTests.Cases()).Concat(PilnaTests.Cases()).Concat(RecoveryTests.Cases()).Concat(BuildingTests.Cases()).Concat(AssetTests.Cases()).Concat(VisualWorkshopTests.Cases()).Concat(HousingTemplateTests.Cases()).Concat(WorldTests.Cases()).Concat(AgentSessionTests.Cases()).Concat(ModAssetTests.Cases()).Concat(StaticModelTests.Cases()).Concat(CrossCellTests.Cases()).Concat(OsmWorldTests.Cases()).Concat(NeighborhoodTests.Cases()).Concat(RecoveryVersionTests.Cases()).Concat(WorldGeographyTests.Cases()).Concat(ArchitectureTests.Cases()).Concat(PlanningSessionTests.Cases()).Concat(IntegrationQualityTests.Cases()).Concat(TerrariumTests.Cases()).Concat(NeighborhoodParcelTests.Cases()).Concat(ImportQualityTests.Cases()).Concat(NativeViewportTests.Cases()).Concat(MapSceneStoreTests.Cases()).Concat(SimplePZMapmaker.Tests.PaletteTests.Cases()).Concat(SimplePZMapmaker.Tests.WorldMapBinaryTests.Cases()).Concat(LinearGeographyTests.Cases()).Concat(GameplayAuthoringTests.Cases()).Concat(NativeLightingGeometryTests.Cases()).Concat(AuthoringIntegrationTests.Cases()).Concat(ZLevelTests.Cases()).Concat(NativeVerticalRangeTests.Cases()).Concat(ZFormatTests.Cases()).Concat(SectionWorkspaceTests.Cases()).Concat(StreamingExportTests.Cases()).Concat(BuildingModuleTests.Cases()).Concat(InfrastructureTests.Cases()).Concat(ZonePlanningTests.Cases()).Concat(WorldInfrastructureTests.Cases()).Concat(ParametricBuildingTests.Cases()).Concat(StreetEnsembleTests.Cases()).Concat(ExportJobTests.Cases()).Concat(WalkabilityTests.Cases()).Concat(CheckedDesignTests.Cases()).Concat(InstallationDiscoveryTests.Cases()).Concat(RoofSurfaceTests.Cases()).Concat(WallOverlayTests.Cases()).Concat(SettlementGenerationTests.Cases()).Concat(GenerationStudioTests.Cases()).Concat(GenerationQualityTests.Cases()).Concat(GeographicEvidenceTests.Cases()).Concat(SiteTests.Cases()).Concat(SourceTransferTests.Cases()).Concat(SectionStreamingTests.Cases()).Concat(PolygonZoneTests.Cases()).Concat(RegionAuthoringTests.Cases()).Concat(SmallBuildingTests.Cases()).Concat(SharedFenceImportTests.Cases()).Concat(SharedDecorationTests.Cases()).Concat(AppearanceTagTests.Cases()).Concat(AttachedBuildingTests.Cases()).Concat(ModPackageTests.Cases()).Concat(TilesetBuilderTests.Cases()).ToArray(); if(Environment.GetEnvironmentVariable("PZMAP_TEST_FILTER") is {Length:>0} filter) all=all.Where(t=>t.Item1.Contains(filter,StringComparison.OrdinalIgnoreCase)).ToArray(); foreach(var (name,run) in all) { try {run(); Console.WriteLine($"PASS {name}");} catch(Exception e) {failures++;Console.WriteLine($"FAIL {name}: {e.Message}");} } Console.WriteLine($"{all.Length-failures}/{all.Length} passed"); return failures==0 ? 0:1; diff --git a/tests/SimplePZMapmaker.Tests/SharedDecorationTests.cs b/tests/SimplePZMapmaker.Tests/SharedDecorationTests.cs new file mode 100644 index 0000000..d33643d --- /dev/null +++ b/tests/SimplePZMapmaker.Tests/SharedDecorationTests.cs @@ -0,0 +1,69 @@ +using SimplePZMapmaker.Core; +using SimplePZMapmaker.Core.Export; +using SimplePZMapmaker.Web.Services; +using System.IO.Compression; +using System.Text; +using System.Text.Json; +public static class SharedDecorationTests +{ + /// <summary>A three-tile counter whose middle tile sits on the last column of section 100; the rest is in section 101.</summary> + static SharedDecoration Counter(string id="shared-counter",int x=100*256+254,int y=100*256+40)=>new(){Id=id,X=x,Y=y,Asset="fixtures_counters_01_0",Parts=[new(0,0,"fixtures_counters_01_0"),new(1,0,"fixtures_counters_01_1"),new(2,0,"fixtures_counters_01_2")]}; + public static IEnumerable<(string,Action)> Cases(){ + yield return("shared decoration crosses a seam, is stored once and reaches both native cells",()=>{ + var w=WorldTests.Fixture(2);w.Decorations.Add(Counter());WorldAssembler.Validate(w);Check(w.Version==8,"Shared decorations must raise the world format to 8"); + Check(w.Maps.All(m=>m.Project.Decorations.Count==0),"Shared decoration was copied into sections"); + var left=WorldAssembler.ProjectSection(w,w.Maps[0]);var right=WorldAssembler.ProjectSection(w,w.Maps[1]); + var l=left.Decorations.Single();var r=right.Decorations.Single(); + Check(l.Id=="shared-counter"&&l.X==254&&l.Parts.Select(p=>p.Tile).SequenceEqual(["fixtures_counters_01_0","fixtures_counters_01_1"]),"Left section got the wrong parts"); + Check(r.X==-2&&r.Parts.Single().Dx==2&&r.Parts.Single().Tile=="fixtures_counters_01_2","Right section got the wrong part"); + ProjectValidator.Validate(left);ProjectValidator.Validate(right); + using var zip=new ZipArchive(new MemoryStream(NativeWorldExporter.Export(w))); + Check(Header(zip,"100_100").Contains("fixtures_counters_01_1")&&!Header(zip,"100_100").Contains("fixtures_counters_01_2"),"Cell 100 has the wrong counter tiles"); + Check(Header(zip,"101_100").Contains("fixtures_counters_01_2"),"Cell 101 lost its counter tile"); + var round=JsonSerializer.Deserialize<WorldProject>(JsonSerializer.Serialize(w,NativeMapExporter.JsonOptions),NativeMapExporter.JsonOptions)!;Check(round.Decorations.Single().Parts.Count==3&&round.Version==8,"Shared decorations did not survive JSON"); + }); + yield return("shared decorations reject overlaps water gaps spawns and reserved IDs",()=>{ + WorldProject With(Action<WorldProject> change){var w=WorldTests.Fixture(2);w.Decorations.Add(Counter());change(w);return w;} + Reject(()=>WorldAssembler.Validate(With(w=>w.Maps[1].Project.Decorations.Add(new(){Id="bush",X=0,Y=40,Asset="vegetation_ornamental_01_0",Parts=[new(0,0,"vegetation_ornamental_01_0")]})))); + Reject(()=>WorldAssembler.Validate(With(w=>w.Maps[1].Project.Terrain[40*256]=Terrain.Water))); + Reject(()=>WorldAssembler.Validate(With(w=>w.Maps[0].Project.Spawns.Add(new(){X=255,Y=40})))); + Reject(()=>WorldAssembler.Validate(With(w=>w.Decorations[0].X=101*256+254))); + Reject(()=>WorldAssembler.Validate(With(w=>w.Decorations.Add(Counter("other",100*256+255,100*256+40))))); + Reject(()=>WorldAssembler.Validate(With(w=>w.Decorations[0].Id="world-road-1-2"))); + Reject(()=>WorldAssembler.Validate(With(w=>w.Maps[0].Project.Decorations.Add(new(){Id="shared-counter",X=10,Y=10,Asset="vegetation_ornamental_01_0",Parts=[new(0,0,"vegetation_ornamental_01_0")]})))); + Reject(()=>WorldAssembler.Validate(With(w=>{var b=new Building{X=100*256+250,Y=100*256+36,Width=8,Height=8};b.Design=BuildingDesigner.Generate(b,1,42);w.Buildings.Add(b);}))); + }); + yield return("section decoration promotes put and remove through the shared layer",()=>{ + var w=WorldTests.Fixture(2);var local=new WorldDecoration{Id="bench",X=255,Y=60,Asset="fixtures_counters_01_0",Parts=[new(0,0,"fixtures_counters_01_0"),new(1,0,"fixtures_counters_01_1")]};w.Maps[0].Project.Decorations.Add(local);w.Maps[0].Project.AssetSources["fixtures_counters_01_1"]="BenchMod";w.Maps[0].Project.RequiredMods=["BenchMod"];w.Maps[0].Project.Version=Math.Max(6,w.Maps[0].Project.Version); + Reject(()=>WorldAssembler.Validate(w));// a local group cannot leave its section + w.Maps[0].Project.Decorations[0].X=200;WorldAssembler.Validate(w); + var promoted=WorldAssembler.PromoteDecoration(w,w.Maps[0].Id,"bench",100*256+255,100*256+60); + var shared=promoted.Decorations.Single();Check(shared.X==100*256+255&&promoted.Maps[0].Project.Decorations.Count==0&&w.Maps[0].Project.Decorations.Count==1,"Promotion did not move the decoration or mutated the input"); + Check(shared.AssetSources.GetValueOrDefault("fixtures_counters_01_1")=="BenchMod"&&shared.RequiredMods.SequenceEqual(["BenchMod"]),"Mod provider did not travel with the decoration"); + var right=WorldAssembler.ProjectSection(promoted,promoted.Maps[1]);Check(right.AssetSources.GetValueOrDefault("fixtures_counters_01_1")=="BenchMod"&&right.RequiredMods.Contains("BenchMod"),"The section drawing a mod tile does not require its mod"); + var moved=WorldAssembler.PutDecoration(promoted,new(){Id="bench",X=100*256+100,Y=100*256+60,Asset=shared.Asset,Parts=shared.Parts});Check(moved.Decorations.Single().X==100*256+100,"Put did not replace the decoration"); + var removed=WorldAssembler.RemoveDecoration(moved,"bench");Check(removed.Decorations.Count==0&&removed.Version==8,"Remove failed or lowered the saved format");Reject(()=>WorldAssembler.RemoveDecoration(removed,"bench")); + }); + yield return("shared decorations block seam repair and shared path surfaces",()=>{ + var w=WorldTests.Fixture(2);w.Decorations.Add(Counter());w.Maps[0].Project.Terrain[40*256+255]=Terrain.Road;var result=WorldSeams.Repair(w,"road"); + Check(result.World.Maps[1].Project.Terrain[40*256]!=Terrain.Road&&result.Issues.Any(i=>i.Contains("protected")),"Seam repair rewrote ground under a shared decoration"); + }); + yield return("streamed workspace keeps shared decorations read-only and exports them like the complete world",()=>InWorkspace(root=>{ + using var store=new WorldStore(root);var w=WorldTests.Fixture(2);w.Decorations.Add(Counter());store.SaveAsync(w.Id,w).GetAwaiter().GetResult(); + var snapshot=store.LoadSectionAsync(w.Id,w.Maps[1].Id).GetAwaiter().GetResult();Check(snapshot.Shared.Decorations.Single().Id=="shared-counter"&&snapshot.Member.Project.Decorations.Count==0,"Section context lost the shared decoration or copied it into the section"); + var p=snapshot.Member.Project;p.Decorations.Add(new(){Id="bush",X=0,Y=40,Asset="vegetation_ornamental_01_0",Parts=[new(0,0,"vegetation_ornamental_01_0")]}); + Reject(()=>store.SaveSectionAsync(w.Id,w.Maps[1].Id,snapshot.Revision,p).GetAwaiter().GetResult()); + p.Decorations[0].X=5;var saved=store.SaveSectionAsync(w.Id,w.Maps[1].Id,snapshot.Revision,p).GetAwaiter().GetResult();Check(saved.Revision==snapshot.Revision+1,"A clear local edit was not saved"); + var whole=store.LoadAsync(w.Id).GetAwaiter().GetResult()!;Check(whole.Decorations.Single().Id=="shared-counter"&&whole.Version==8,"Complete-world read lost shared decorations"); + using var jobs=new ExportJobStore(root);var done=Wait(jobs,jobs.StartWorkspace(store.PinAsync(w.Id).GetAwaiter().GetResult()).Id);Check(done.Status=="completed",done.Error??"Streamed export failed"); + using var actual=ZipFile.OpenRead(Path.Combine(root,"work","export-jobs",done.Id+".zip"));using var expected=new ZipArchive(new MemoryStream(NativeWorldExporter.Export(whole))); + foreach(var entry in expected.Entries.Where(e=>e.Name.EndsWith(".lotheader")||e.Name.EndsWith(".lotpack"))){using var a=entry.Open();using var b=actual.Entries.Single(e=>e.Name==entry.Name).Open();Check(Read(a).SequenceEqual(Read(b)),"Streamed shared decoration differs in "+entry.Name);} + })); + } + static string Header(ZipArchive zip,string cell){using var r=new StreamReader(zip.Entries.Single(e=>e.Name==$"{cell}.lotheader").Open(),Encoding.Latin1);return r.ReadToEnd();} + static byte[] Read(Stream source){using var result=new MemoryStream();source.CopyTo(result);return result.ToArray();} + static ExportJobStatus Wait(ExportJobStore jobs,string id){var deadline=DateTime.UtcNow.AddSeconds(60);while(DateTime.UtcNow<deadline){var state=jobs.Status(id);if(state.Status is not ("queued" or "running")){Thread.Sleep(50);return state;}Thread.Sleep(10);}throw new Exception("Streamed native job timed out");} + static void InWorkspace(Action<string> run){var root=Path.Combine(Environment.GetEnvironmentVariable("PZMAP_WORKSPACE")??Path.GetFullPath("."),"work","shared-decoration-tests",Guid.NewGuid().ToString("N"));Directory.CreateDirectory(root);try{run(root);}finally{Directory.Delete(root,true);}} + static void Check(bool ok,string message){if(!ok)throw new Exception(message);} + static void Reject(Action action){try{action();}catch(ArgumentException){return;}catch(AggregateException e) when(e.InnerException is ArgumentException){return;}throw new Exception("Expected validation failure");} +} diff --git a/tests/SimplePZMapmaker.Tests/SharedFenceImportTests.cs b/tests/SimplePZMapmaker.Tests/SharedFenceImportTests.cs new file mode 100644 index 0000000..6a92788 --- /dev/null +++ b/tests/SimplePZMapmaker.Tests/SharedFenceImportTests.cs @@ -0,0 +1,88 @@ +using SimplePZMapmaker.Core; +using SimplePZMapmaker.Core.Export; +using System.IO.Compression; +using System.Text; +using System.Net; +using System.Text.Json; +using SimplePZMapmaker.Web.Services; +public static class SharedFenceImportTests +{ + public static IEnumerable<(string,Action)> Cases(){ + yield return("imported fence crossing a section seam is promoted once to the world fence layer",()=>{ + var (world,json)=ImportedWorld(); + Check(world.Maps.All(m=>m.Project.Fences.Any(f=>f.SourceId=="geo-osm-way-1")),"Fixture: each section should import its own copy first"); + var notes=new List<string>();var promoted=GeographicImporter.PromoteSharedFences(world,notes); + Check(promoted==1&&world.Fences.Count==1&&world.Maps.All(m=>m.Project.Fences.All(f=>f.SourceId!="geo-osm-way-1")),"The crossing line was not moved exactly once"); + var shared=world.Fences.Single();Check(shared.Points.Min(v=>v.X)<101*256&&shared.Points.Max(v=>v.X)>101*256&&shared.Points.All(v=>v.Y>=100*256),"Shared points are not absolute world tiles"); + Check(shared.Gates.Count==2,"Gates found near each section were not merged"); + Check(world.FenceStyles.Any(s=>s.Id==shared.Style)&&world.GateStyles.Any(s=>s.Id=="vanilla-wicket"),"Styles were not carried to the world layer"); + Check(world.Maps[0].Project.Fences.Any(f=>f.SourceId=="geo-osm-way-2"),"A line inside one section must stay local"); + Check(notes.Any(n=>n.Contains("shared world fence layer")),"Promotion is not reported"); + WorldAssembler.Validate(world);Check(world.Version==7,"World fence format not raised"); + var left=WorldAssembler.ProjectSection(world,world.Maps[0]);var right=WorldAssembler.ProjectSection(world,world.Maps[1]); + var a=FenceLines.Compose(left.Fences,left.FenceStyles,left.GateStyles,256,256).Pieces;var b=FenceLines.Compose(right.Fences,right.FenceStyles,right.GateStyles,256,256).Pieces; + Check(a.Any(p=>p.X==255)&&b.Any(p=>p.X==0),"The promoted fence is not continuous across the seam"); + Check(a.Count(p=>p.Tile=="fixtures_doors_fences_01_13")+b.Count(p=>p.Tile=="fixtures_doors_fences_01_13")==2,"Merged gates are not drawn once each"); + using var zip=new ZipArchive(new MemoryStream(NativeWorldExporter.Export(world))); + foreach(var cell in new[]{"100_100","101_100"}){using var r=new StreamReader(zip.Entries.Single(e=>e.Name==$"{cell}.lotheader").Open(),Encoding.Latin1);Check(r.ReadToEnd().Contains("fencing_01_41"),$"Cell {cell} lost its part of the shared fence");} + }); + yield return("promotion keeps authored lines local and is idempotent",()=>{ + var (world,_)=ImportedWorld();world.Maps[1].Project.Fences.Add(new(){Id="authored",Style="vanilla-planks",Points=[new(-4,30),new(10,30)]}); + GeographicImporter.PromoteSharedFences(world,[]);var again=GeographicImporter.PromoteSharedFences(world,[]); + Check(again==0&&world.Fences.Count==1&&world.Maps[1].Project.Fences.Any(f=>f.Id=="authored"),"Authored lines must not be promoted and a second pass must change nothing"); + }); + yield return("a conflicting world fence style keeps the crossing line local",()=>{ + var (world,_)=ImportedWorld();var style=world.Maps[0].Project.Fences.Single(f=>f.SourceId=="geo-osm-way-1").Style; + world.FenceStyles.Add(new(){Id=style,W="fencing_01_10",N="fencing_01_9"});var notes=new List<string>(); + Check(GeographicImporter.PromoteSharedFences(world,notes)==0&&world.Fences.Count==0&¬es.Any(n=>n.Contains("conflicts")),"A style conflict should keep the local copies"); + }); + yield return("seam check counts every source feature once and catches duplicates and losses",()=>{ + var (world,json)=ImportedWorld();GeographicImporter.PromoteSharedFences(world,[]);var report=GeographicImporter.TransferCheck(json,world); + Check(report.Passed&&report.ByKind["fence"]==new TransferOutcome(1,1,0,0,0),"Fence outcomes are wrong: "+report.ByKind["fence"]); + world.Maps[1].Project.Fences.Add(world.Maps[0].Project.Fences.Single(f=>f.SourceId=="geo-osm-way-2"));report=GeographicImporter.TransferCheck(json,world);Check(!report.Passed&&report.ByKind["fence"].Duplicated==1&&report.Problems.Any(p=>p.Contains("geo-osm-way-2")),"A duplicated fence was not caught"); + world.Maps[1].Project.Fences.Clear();world.Maps[0].Project.Fences.Clear();report=GeographicImporter.TransferCheck(json,world);Check(!report.Passed&&report.ByKind["fence"].Lost==1,"A lost fence was not caught"); + }); + yield return("OSM world import promotes a crossing fence and preserve edits keeps authored world fences",()=>WithDirectory(root=>{ + var osm=JsonSerializer.Serialize(new{osm3s=new{timestamp_osm_base="2026-10-13T00:00:00Z"},elements=new object[]{new{type="way",id=7,tags=new Dictionary<string,string>{{"barrier","fence"}},geometry=new[]{new{lon=30.003,lat=50.003},new{lon=30.008,lat=50.003}}}}}); + var assets=new GameAssetCatalog(root,Environment.GetEnvironmentVariable("PZMAP_GAME_PATH")??"");using var client=new OsmClient(Path.Combine(root,"cache"),new OneResponse(osm),TimeSpan.Zero);using var service=new OsmWorldImportService(client,assets,Path.Combine(root,"jobs")); + var request=new OsmWorldImportRequest(new(){West=30,East=30.01,South=50,North=50.006},SectionSize:512); + WorldProject Run(OsmWorldImportRequest r){var id=JsonSerializer.SerializeToElement(service.Start(r)).GetProperty("id").GetString()!;var timer=System.Diagnostics.Stopwatch.StartNew();while(timer.Elapsed<TimeSpan.FromSeconds(30)){var state=JsonSerializer.SerializeToElement(service.Status(id));var status=state.GetProperty("status").GetString();if(status is "completed" or "failed" or "cancelled"){Check(status=="completed","Import failed: "+state);using var file=File.OpenRead(service.ResultFile(id));return JsonSerializer.Deserialize<WorldProject>(file,NativeMapExporter.JsonOptions)!;}Thread.Sleep(10);}throw new Exception("Import timed out");} + var world=Run(request); + Check(world.Fences.Count==1&&world.Fences[0].SourceId?.EndsWith("way-7")==true,"The crossing barrier was not promoted to the world fence layer"); + Check(world.Maps.All(m=>m.Project.Fences.Count==0),"Section copies of the promoted fence remain"); + var style=world.Fences[0].Style;world.Fences.Add(new(){Id="authored-world",Style=style,Points=[new(100*256+500,100*256+100),new(100*256+530,100*256+100)]});WorldAssembler.Validate(world); + var again=Run(request with{ExistingWorld=world,PreserveEdits=true}); + Check(again.Fences.Count==2&&again.Fences.Any(f=>f.Id=="authored-world")&&again.Fences.Count(f=>f.SourceId is not null)==1,"Preserve edits lost the authored world fence or duplicated the imported one"); + })); + yield return("preserve edits keeps shared decorations on their prior ground and clears refreshed objects under them",()=>{ + var prior=WorldTests.Fixture(2);var shared=new SharedDecoration{Id="shared-bench",X=100*256+255,Y=100*256+40,Asset="fixtures_counters_01_0",Parts=[new(0,0,"fixtures_counters_01_0"),new(1,0,"fixtures_counters_01_1")]};prior.Decorations.Add(shared);WorldAssembler.Validate(prior); + var next=WorldTests.Fixture(2);next.Decorations=[WorldAssembler.CloneDecoration(shared)];next.Maps[1].Project.Terrain[40*256]=Terrain.Water;next.Maps[0].Project.Decorations.Add(new(){Id="lamp",X=255,Y=40,Asset="lighting_outdoor_01_0",Parts=[new(0,0,"lighting_outdoor_01_0")]}); + var notes=new List<string>();Check(WorldAssembler.KeepSharedDecorations(next,prior,notes)==1&&next.Maps[1].Project.Terrain[40*256]!=Terrain.Water&&next.Maps[0].Project.Decorations.Count==0,"Shared decoration ground or clearance was not restored"); + WorldAssembler.Validate(next);Check(notes.Count==1,"Clearance is not reported"); + }); + } + sealed class OneResponse(string json):HttpMessageHandler{protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,CancellationToken token)=>Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK){Content=new StringContent(json)});} + static void WithDirectory(Action<string> test){var dir=Path.Combine(Environment.GetEnvironmentVariable("PZMAP_WORKSPACE")??Path.GetFullPath("."),"work","shared-fence-import-tests",Guid.NewGuid().ToString("N"));Directory.CreateDirectory(dir);try{test(dir);}finally{Directory.Delete(dir,true);}} + /// <summary>Two 256-tile sections importing the same OSM data, the way the world importer does: every section receives every way that touches it.</summary> + static (WorldProject World,string Json) ImportedWorld(){ + var reference=new WorldGeoReference{Bounds=new(){West=32,East=32.01,South=54,North=54.01},MetresPerTile=1,OriginCellX=100,OriginCellY=100}; + var first=new GeographicProjection(reference,100,100);double[] At(double x,double y){var g=first.Geographic(x,y);return[g.Longitude,g.Latitude];} + var json=JsonSerializer.Serialize(new{type="FeatureCollection",pzTransfer=1,features=new object[]{ + new{type="Feature",id="osm-way-1",properties=new Dictionary<string,string>{{"barrier","fence"},{"fence_type","metal_sheet"}},geometry=new{type="LineString",coordinates=new[]{At(230.2,60.3),At(290.1,60.4)}}}, + new{type="Feature",id="osm-way-2",properties=new Dictionary<string,string>{{"barrier","fence"}},geometry=new{type="LineString",coordinates=new[]{At(40.2,90.3),At(70.1,90.4)}}}, + new{type="Feature",id="osm-node-3",properties=new Dictionary<string,string>{{"barrier","gate"}},geometry=new{type="Point",coordinates=At(240.5,60.4)}}, + new{type="Feature",id="osm-node-4",properties=new Dictionary<string,string>{{"barrier","gate"}},geometry=new{type="Point",coordinates=At(280.5,60.4)}}}}); + var world=new WorldProject{Name="Fenced",GeoReference=reference}; + foreach(var cell in new[]{100,101}){ + var projection=new GeographicProjection(reference,cell,100);var bounds=projection.SectionBounds(256,256); + var p=MapGenerator.Generate(new(){Kind="blank",Size=256});p.WorldX=cell;p.WorldY=100;p.WorldSection=true;p.Version=Math.Max(p.Version,6); + p=GeographicImporter.Import(json,p,bounds,projection:projection).Project;if(cell!=100)p.Spawns=[]; + // Each section only sees gate nodes inside its own bounds. + var own=p.Fences.Single(f=>f.SourceId=="geo-osm-way-1");own.Gates.RemoveAll(g=>cell==100?g.At>=40:g.At<40); + world.Maps.Add(new(){Project=p}); + } + if(world.Maps[0].Project.Spawns.Count==0)world.Maps[0].Project.Spawns=[MapGenerator.FindSafeSpawn(world.Maps[0].Project)]; + return(world,json); + } + static void Check(bool ok,string message){if(!ok)throw new Exception(message);} +} diff --git a/tests/SimplePZMapmaker.Tests/SmallBuildingTests.cs b/tests/SimplePZMapmaker.Tests/SmallBuildingTests.cs new file mode 100644 index 0000000..9b1b4d4 --- /dev/null +++ b/tests/SimplePZMapmaker.Tests/SmallBuildingTests.cs @@ -0,0 +1,57 @@ +using SimplePZMapmaker.Core; +using SimplePZMapmaker.Core.Export; +using System.IO.Compression; +using System.Text; +using System.Text.Json; +public static class SmallBuildingTests +{ + public static IEnumerable<(string,Action)> Cases(){ + yield return("footprints under 6x6 generate one accessible room and export",()=>{ + var p=MapGenerator.Generate(new(){Kind="blank"}); + foreach(var (w,h,kind) in new[]{(2,2,"warehouse"),(3,3,"house"),(5,4,"shop"),(3,8,"garage")}){ + var b=new Building{X=30+p.Buildings.Count*12,Y=30,Width=w,Height=h,Kind=kind};BuildingDesigner.Generate(b,1,42); + var f=b.Design!.Floors.Single();Check(f.Cells.Length==w*h&&f.Cells.All(c=>c=='1')&&f.Rooms.Count==1,$"{w}x{h} is not a single room"); + Check(f.Edges.Any(e=>e.Kind=="door"),$"{w}x{h} has no entrance");p.Buildings.Add(b); + } + ProjectValidator.Validate(p);Check(p.Buildings.Single(b=>b.Kind=="shop").Design!.Floors[0].Rooms["1"]=="conveniencestore","A kiosk is not a shop room"); + BuildingDesigner.Regenerate(p);ProjectValidator.Validate(p); + using var zip=new ZipArchive(new MemoryStream(NativeMapExporter.Export(p)));Check(zip.Entries.Any(e=>e.Name.EndsWith(".lotheader")),"Small buildings did not export"); + }); + yield return("small footprints reject one-tile sides, odd shapes and stairs that cannot fit",()=>{ + Reject(()=>BuildingDesigner.Generate(new Building{Width=1,Height=4},1,42)); + Reject(()=>BuildingDesigner.Generate(new Building{Width=4,Height=4},1,42,"l")); + Reject(()=>BuildingDesigner.Generate(new Building{Width=5,Height=5},2,42)); + Reject(()=>BuildingDesigner.Generate(new Building{Width=5,Height=5},1,42,"rect",1)); + Check(BuildingDesigner.HoldsStairs(4,10)&&!BuildingDesigner.HoldsStairs(5,5),"Stair fit rule changed"); + }); + yield return("a narrow footprint with a long side holds stairs and a basement",()=>{ + foreach(var (w,h) in new[]{(3,10),(12,2)}){ + var b=new Building{Width=w,Height=h,Kind="house"};BuildingDesigner.Generate(b,2,7,"rect",1); + Check(b.Design!.MinLevel==-1&&b.Design.Floors.Count==3&&b.Design.Floors.Take(2).All(f=>f.Objects.Any(o=>o.Kind=="stairs")),$"{w}x{h} lost its stairs"); + Check(b.Design.Floors[0].Rooms["1"]=="storage","Basement room should be storage"); + } + }); + yield return("imported small outlines keep their size and one floor",()=>{ + var reference=new WorldGeoReference{Bounds=new(){West=32,East=32.01,South=54,North=54.01},MetresPerTile=1,OriginCellX=100,OriginCellY=100};var projection=new GeographicProjection(reference,100,100); + double[] At(double x,double y){var g=projection.Geographic(x,y);return[g.Longitude,g.Latitude];} + object Box(string id,double x,double y,double w,double h,Dictionary<string,string> tags)=>new{type="Feature",id,properties=tags,geometry=new{type="Polygon",coordinates=new[]{new[]{At(x,y),At(x+w,y),At(x+w,y+h),At(x,y+h),At(x,y)}}}}; + var json=JsonSerializer.Serialize(new{type="FeatureCollection",pzTransfer=1,features=new[]{ + Box("osm-way-1",40,40,3,3,new(){{"building","shed"}}), + Box("osm-way-2",60,40,3,3,new(){{"building","kiosk"},{"shop","newsagent"},{"building:levels","2"}}), + Box("osm-way-3",80,40,2,2,new(){{"building","yes"}})}}); + var p=GeographicImporter.Import(json,MapGenerator.Generate(new(){Kind="blank"}),reference.Bounds,projection:projection).Project; + var shed=p.Buildings.Single(b=>b.SourceId=="geo-osm-way-1");var kiosk=p.Buildings.Single(b=>b.SourceId=="geo-osm-way-2"); + Check(shed.Width<=4&&shed.Height<=4&&shed.Design!.Floors.Single().Cells.Count(c=>c!='.')>=4,"Shed was inflated or lost"); + Check(kiosk.Width<=4&&kiosk.Height<=4&&kiosk.Kind=="shop"&&kiosk.Design!.Floors.Count==1&&kiosk.SourceLevels=="2",$"Kiosk floors were not limited or tags were lost: {kiosk.Width}x{kiosk.Height} {kiosk.Kind} {kiosk.Design!.Floors.Count} {kiosk.SourceLevels}"); + Check(p.ImportSummary!.Notes.Any(n=>n.Contains("one floor only")),"The single-floor limit is not explained"); + Check(p.Buildings.Any(b=>b.SourceId=="geo-osm-way-3"),"A 2x2 outline should be placed"); + ProjectValidator.Validate(p); + }); + yield return("a small shared building crosses a seam without a 6x6 reservation",()=>{ + var w=WorldTests.Fixture(2);var b=new Building{X=100*256+254,Y=100*256+40,Width=4,Height=3,Kind="garage"};b.Design=BuildingDesigner.Generate(b,1,42);w.Buildings.Add(b);WorldAssembler.Validate(w); + var right=WorldAssembler.ProjectSection(w,w.Maps[1]);Check(right.Buildings.Single().X==-2&&right.Buildings.Single().Width==4,"Projection of the small shared building is wrong"); + }); + } + static void Check(bool ok,string message){if(!ok)throw new Exception(message);} + static void Reject(Action action){try{action();}catch(ArgumentException){return;}throw new Exception("Expected validation failure");} +} diff --git a/tests/SimplePZMapmaker.Tests/TilesetBuilderTests.cs b/tests/SimplePZMapmaker.Tests/TilesetBuilderTests.cs new file mode 100644 index 0000000..01dad57 --- /dev/null +++ b/tests/SimplePZMapmaker.Tests/TilesetBuilderTests.cs @@ -0,0 +1,30 @@ +using SimplePZMapmaker.Core.Export; +using SimplePZMapmaker.Web.Services; +using System.IO.Compression; +using System.Text; +public static class TilesetBuilderTests +{ + /// <summary>A 128x256 frame with an opaque block at (40..79, 150..229) in one colour.</summary> + static byte[] Frame(byte r,byte g,byte b){var rgba=new byte[128*256*4];for(var y=150;y<230;y++)for(var x=40;x<80;x++){var i=(y*128+x)*4;rgba[i]=r;rgba[i+1]=g;rgba[i+2]=b;rgba[i+3]=255;}return Png.Encode(new(128,256,rgba));} + public static IEnumerable<(string,Action)> Cases(){ + yield return("tileset builder writes a pack and tile definitions the editor readers accept",()=>{ + var mod=new TilesetMod("PilnaTiles","Pilna tiles","pilna_fence_01",300,[new(Frame(200,40,40),new(){["WallW"]="",["CustomName"]="Pilna fence"}),new(Frame(40,200,40),new(){["WallN"]=""})]); + using var archive=new MemoryStream();TilesetBuilder.Write(mod,archive);using var zip=new ZipArchive(new MemoryStream(archive.ToArray())); + string Text(string p){using var r=new StreamReader(zip.GetEntry(p)!.Open(),Encoding.UTF8);return r.ReadToEnd();} + var info=Text("PilnaTiles/42/mod.info");Check(info.Contains("pack=pilna_fence_01\n")&&info.Contains("tiledef=pilna_fence_01 300\n")&&info.Contains("id=PilnaTiles\n"),"mod.info does not register the pack and tiledef"); + using var pack=new MemoryStream();zip.GetEntry("PilnaTiles/42/media/texturepacks/pilna_fence_01.pack")!.Open().CopyTo(pack);pack.Position=0;var page=TexturePackIndex.Read(pack,"pilna_fence_01.pack").Single(); + var first=page.Sprites.Single(s=>s.Name=="pilna_fence_01_0");Check(page.Sprites.Count==2&&first.FrameWidth==128&&first.FrameHeight==256&&first.OffsetX==40&&first.OffsetY==150&&first.Width==40&&first.Height==80,"Sprite trim or frame size is wrong"); + pack.Position=page.Offset;var png=new byte[page.Length];pack.ReadExactly(png);var atlas=Png.Decode(png);var pixel=((first.Y+10)*atlas.Width+first.X+10)*4;Check(atlas.Rgba[pixel]==200&&atlas.Rgba[pixel+1]==40&&atlas.Rgba[pixel+3]==255,"Atlas pixels do not match the frame"); + using var tiles=new MemoryStream();zip.GetEntry("PilnaTiles/42/media/pilna_fence_01.tiles")!.Open().CopyTo(tiles);tiles.Position=0;var defs=TileDefinitionReader.Read(tiles); + Check(defs.Count==8&&defs[0].Id=="pilna_fence_01_0"&&defs[0].Properties["CustomName"]=="Pilna fence"&&defs[1].Properties.ContainsKey("WallN")&&defs[2].Properties.Count==0,"Tile definitions do not round-trip"); + }); + yield return("tileset builder rejects bad names numbers and non-RGBA frames",()=>{ + Reject(()=>TilesetBuilder.Validate(new("Ok","Ok","bad name",300,[new(Frame(1,1,1),[])]))); + Reject(()=>TilesetBuilder.Validate(new("Ok","Ok","good_01",42,[new(Frame(1,1,1),[])]))); + Reject(()=>TilesetBuilder.Write(new("Ok","Ok","good_01",300,[new([1,2,3],[])]),new MemoryStream())); + var round=Png.Decode(Png.Encode(new(3,2,[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24])));Check(round.Rgba.SequenceEqual(new byte[]{1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24}),"PNG codec does not round-trip"); + }); + } + static void Check(bool ok,string message){if(!ok)throw new Exception(message);} + static void Reject(Action action){try{action();}catch(ArgumentException){return;}throw new Exception("Expected validation failure");} +} diff --git a/tests/attached-buildings.test.mjs b/tests/attached-buildings.test.mjs new file mode 100644 index 0000000..102c9d2 --- /dev/null +++ b/tests/attached-buildings.test.mjs @@ -0,0 +1,13 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {blank,canPlace,attachedPair} from '../src/SimplePZMapmaker.Web/wwwroot/editor.js'; +import {refreshEdges} from '../src/SimplePZMapmaker.Web/wwwroot/building.js'; + +const room=(id,x,y,width,height,extra={})=>{const b={id,name:id,kind:'warehouse',x,y,width,height,design:{material:'wood',roof:'flat',shape:'rect',variant:1,floors:[{cells:'1'.repeat(width*height),rooms:{'1':'storage'},edges:[],objects:[]}]},...extra};refreshEdges(b,0);return b;}; + +test('an attached shed may share the house wall line but never its rooms',()=>{ + const p=blank(),house=room('house',10,10,6,6);p.buildings=[house]; + const loose=room('shed',16,11,2,2);assert.equal(canPlace(p,loose),false,'touching without attachment keeps the free line'); + const shed=room('shed',16,11,2,2,{attachedTo:'house'});assert.equal(attachedPair(house,shed),true);assert.equal(canPlace(p,shed),true); + assert.equal(canPlace(p,room('shed',15,11,2,2,{attachedTo:'house'})),false,'rooms of an attached pair must not overlap'); +}); diff --git a/tests/shared-decorations.test.mjs b/tests/shared-decorations.test.mjs new file mode 100644 index 0000000..c76e93c --- /dev/null +++ b/tests/shared-decorations.test.mjs @@ -0,0 +1,39 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {blank} from '../src/SimplePZMapmaker.Web/wwwroot/editor.js'; +import {globalBuildingMask,validateGlobalProtection,sharedLocalDecorations} from '../src/SimplePZMapmaker.Web/wwwroot/world-protection.js'; +import {validateSharedDecorations,worldAuthoringVersion,sharedDecorationId} from '../src/SimplePZMapmaker.Web/wwwroot/world-geography.js'; +import {validateDesign,refreshEdges} from '../src/SimplePZMapmaker.Web/wwwroot/building.js'; + +// A three-tile counter whose first two tiles end section 100 and whose last tile starts section 101. +const counter=()=>({id:'shared-counter',x:100*256+254,y:100*256+40,asset:'fixtures_counters_01_0',parts:[{dx:0,dy:0,tile:'fixtures_counters_01_0'},{dx:1,dy:0,tile:'fixtures_counters_01_1'},{dx:2,dy:0,tile:'fixtures_counters_01_2'}]}); +const world=()=>{const p=blank(),q=blank();q.worldX=101;q.spawns=[];return {format:'SimplePZWorld',version:7,id:'a'.repeat(32),name:'World',maps:[{id:'b'.repeat(32),project:p},{id:'c'.repeat(32),project:q}],decorations:[counter()]};}; + +test('a shared decoration is clipped into each section it reaches',()=>{ + const w=world(),[left]=sharedLocalDecorations(w,w.maps[0].project),[right]=sharedLocalDecorations(w,w.maps[1].project); + assert.equal(left.x,254);assert.deepEqual(left.parts.map(p=>p.tile),['fixtures_counters_01_0','fixtures_counters_01_1']); + assert.equal(right.x,-2);assert.deepEqual(right.parts.map(p=>p.dx),[2]); + assert.equal(worldAuthoringVersion(w),8); +}); + +test('shared decorations protect their tiles from local objects and water',()=>{ + const w=world(),p=w.maps[1].project,mask=globalBuildingMask(w,p);assert.ok(mask.has(40*p.width)); + p.decorations=[{id:'bush',x:0,y:40,asset:'vegetation_ornamental_01_0',parts:[{dx:0,dy:0,tile:'vegetation_ornamental_01_0'}]}]; + assert.throws(()=>validateGlobalProtection(p,mask),/shared world building or decoration/); + p.decorations=[];p.terrain[40*p.width]=3;assert.throws(()=>validateGlobalProtection(p,mask),/flood/); +}); + +test('shared decoration validation mirrors the core rules',()=>{ + validateSharedDecorations(world()); + const gap=world();gap.decorations[0].x=101*256+254;assert.throws(()=>validateSharedDecorations(gap),/empty part/); + const wet=world();wet.maps[1].project.terrain[40*256]=3;assert.throws(()=>validateSharedDecorations(wet),/water/); + const spawn=world();spawn.maps[0].project.spawns.push({x:255,y:40});assert.throws(()=>validateSharedDecorations(spawn),/spawn/); + const twice=world();twice.decorations.push({...counter(),id:'other',x:100*256+255});assert.throws(()=>validateSharedDecorations(twice),/another shared decoration/); + assert.equal(sharedDecorationId('world-road-1-2'),false);assert.equal(sharedDecorationId(crypto.randomUUID()),true); +}); + +test('a building under 6x6 is a valid one-room design',()=>{ + const b={id:'shed',name:'Shed',kind:'warehouse',x:10,y:10,width:3,height:2,design:{material:'wood',roof:'flat',shape:'rect',variant:1,floors:[{cells:'111111',rooms:{'1':'storage'},edges:[],objects:[]}]}}; + refreshEdges(b,0);validateDesign(b); + assert.throws(()=>validateDesign({...b,width:1,height:6,design:{...b.design,floors:[{...b.design.floors[0],cells:'111111'}]}}),/2–64/); +});