From 23115d3ca8609e66679b653dbca9eb2be102f8f9 Mon Sep 17 00:00:00 2001 From: Feliks Solovy Date: Sat, 17 Oct 2026 22:40:54 +0300 Subject: [PATCH 01/15] feat: export Build 42 biome maps for foraging zones Every native cell gets media/maps//maps/biomemap_X_Y.png like the vanilla maps: the chunk's foraging zone in red, green and blue (0 on water squares), derived from authored terrain zones or from the terrain (birch-mix forest and deep forest, town around buildings, farm land, ForagingNav roads). Without it a Build 42 map has no foraging zones. server-check now also reports the zones the server's metagrid holds at each probe point. Co-Authored-By: Claude Opus 5.5 --- src/SimplePZMapmaker.Cli/Program.cs | 2 +- src/SimplePZMapmaker.Cli/ServerCheck.cs | 22 +++++++-- src/SimplePZMapmaker.Core/Export/BiomeMap.cs | 47 +++++++++++++++++++ .../Export/NativeMapExporter.cs | 3 +- .../SimplePZMapmaker.Tests/B42MapDataTests.cs | 22 +++++++++ tests/SimplePZMapmaker.Tests/Program.cs | 2 +- 6 files changed, 91 insertions(+), 7 deletions(-) create mode 100644 src/SimplePZMapmaker.Core/Export/BiomeMap.cs create mode 100644 tests/SimplePZMapmaker.Tests/B42MapDataTests.cs diff --git a/src/SimplePZMapmaker.Cli/Program.cs b/src/SimplePZMapmaker.Cli/Program.cs index 2b8b14c..1bd2953 100644 --- a/src/SimplePZMapmaker.Cli/Program.cs +++ b/src/SimplePZMapmaker.Cli/Program.cs @@ -59,7 +59,7 @@ static int Run(string[] args){ return 0;} case "server-check":{ var minutes=int.TryParse(Option("--timeout-minutes"),out var m)?m:10;var result=ServerCheck.Run(input,Option("--game")??throw new ArgumentException("server-check needs --game ."),TimeSpan.FromMinutes(minutes),Console.WriteLine); - foreach(var (point,building,room) in result.Probes)Console.WriteLine($"server metagrid {point.X},{point.Y} {point.Name}: building={building.ToString().ToLowerInvariant()} room={room??"none"}{(building==point.ExpectBuilding?"":" UNEXPECTED")}"); + for(var i=0;i0} z?z:"none")}{(building==point.ExpectBuilding?"":" UNEXPECTED")}");} Console.WriteLine($"server started: {result.Started}; stopped cleanly: {result.Stopped}; {(result.Passed?"server check: passed":"server check: FAILED")}"); if(!result.Passed)File.WriteAllText(Path.ChangeExtension(input,null)+".server.log",result.Log); return result.Passed?0:1;} diff --git a/src/SimplePZMapmaker.Cli/ServerCheck.cs b/src/SimplePZMapmaker.Cli/ServerCheck.cs index 661d7c2..72248ca 100644 --- a/src/SimplePZMapmaker.Cli/ServerCheck.cs +++ b/src/SimplePZMapmaker.Cli/ServerCheck.cs @@ -16,14 +16,16 @@ public static class ServerCheck public sealed record Point(int X,int Y,string Name,bool ExpectBuilding); public sealed record Result(bool Started,bool Stopped,List<(Point Point,bool Building,string? Room)> Probes,string Log) { + /// Metagrid zone types the server reports at each probe point (foraging zones from the biome map included). + public Dictionary Zones {get;init;}=[]; public bool Passed=>Started&&Stopped&&Probes.Count>0&&Probes.All(p=>p.Building==p.Point.ExpectBuilding); } /// Up to ground-floor room tiles spread over the sections (and shared buildings), and one tile without a building. public static List Points(ZipArchive zip,int count=12){ var source=zip.Entries.FirstOrDefault(e=>e.Name is "world.pzworld.json" or "project.pzmap.json")??throw new ArgumentException("The archive has no world.pzworld.json or project.pzmap.json source to pick test buildings from."); - using var stream=source.Open();var buildings=new List<(int OX,int OY,Building B)>();var occupied=new HashSet<(int,int)>(); - if(source.Name=="world.pzworld.json"){var world=JsonSerializer.Deserialize(stream,NativeMapExporter.JsonOptions)!;foreach(var m in world.Maps)foreach(var b in m.Project.Buildings)buildings.Add((m.Project.WorldX*256,m.Project.WorldY*256,b));foreach(var b in world.Buildings)buildings.Add((0,0,b));} - else{var p=JsonSerializer.Deserialize(stream,NativeMapExporter.JsonOptions)!;foreach(var b in p.Buildings)buildings.Add((p.WorldX*256,p.WorldY*256,b));} + using var stream=source.Open();var buildings=new List<(int OX,int OY,Building B)>();var occupied=new HashSet<(int,int)>();var projects=new List(); + if(source.Name=="world.pzworld.json"){var world=JsonSerializer.Deserialize(stream,NativeMapExporter.JsonOptions)!;foreach(var m in world.Maps)projects.Add(m.Project);foreach(var m in world.Maps)foreach(var b in m.Project.Buildings)buildings.Add((m.Project.WorldX*256,m.Project.WorldY*256,b));foreach(var b in world.Buildings)buildings.Add((0,0,b));} + else{var p=JsonSerializer.Deserialize(stream,NativeMapExporter.JsonOptions)!;projects.Add(p);foreach(var b in p.Buildings)buildings.Add((p.WorldX*256,p.WorldY*256,b));} var points=new List(); foreach(var (ox,oy,b) in buildings.Where(x=>x.B.Design is not null)){var f=b.Design!.Floors[-b.Design.MinLevel];for(var i=0;i Points(ZipArchive zip,int count=12){ if(points.Count==0)throw new ArgumentException("The export has no buildings to look for on the server."); // A control tile two rows north of the first building, which no building covers. for(var d=2;d<64;d++){(int X,int Y) c=(points[0].X,points[0].Y-d);if(!occupied.Contains(c)&&!occupied.Contains((c.X,c.Y+1))){points.Add(new(c.X,c.Y,"control without a building",false));break;}} + // Zone checks: the centre of a fully wooded chunk and of an open chunk far from buildings, to read the foraging zones back. + foreach(var (terrain,name) in new[]{(Terrain.Forest,"forest (zone check)"),(Terrain.Grass,"open ground (zone check)")}){ + foreach(var p in projects){var found=false;for(var cy=2;cy? log=null){ @@ -56,7 +65,8 @@ public static Result Run(string archive,string game,TimeSpan timeout,Action(); foreach(Match m in Regex.Matches(text,@"SIMPLEPZ_PROBE (\d+) (true|false) (\S+)")){var i=int.Parse(m.Groups[1].Value);if(iint.Parse(m.Groups[1].Value),m=>m.Groups[2].Value); + return new(ok&&started.Task.IsCompleted,stopped,probes,text){Zones=zones}; }finally{try{Directory.Delete(root,true);}catch(IOException){}catch(UnauthorizedAccessException){}} } static string? ReadMapFolder(string mod)=>Directory.EnumerateDirectories(mod,"maps",SearchOption.AllDirectories).SelectMany(Directory.EnumerateDirectories).Select(Path.GetFileName).FirstOrDefault(); @@ -72,6 +82,10 @@ static void WriteProbe(string folder,List points){ for i, p in ipairs(points) do local room = grid:getRoomAt(p[1], p[2], 0) print(string.format("SIMPLEPZ_PROBE %d %s %s", i - 1, tostring(grid:getBuildingAt(p[1], p[2]) ~= nil), room and room:getName() or "nil")) + local ok, zones = pcall(function() return grid:getZonesAt(p[1], p[2], 0) end) + local names = {} + if ok and zones then for j = 0, zones:size() - 1 do table.insert(names, tostring(zones:get(j):getType())) end elseif not ok then table.insert(names, "error:" .. tostring(zones):gsub("%s", "_")) end + print(string.format("SIMPLEPZ_ZONES %d %s", i - 1, table.concat(names, ","))) end print("SIMPLEPZ_PROBE done") end) diff --git a/src/SimplePZMapmaker.Core/Export/BiomeMap.cs b/src/SimplePZMapmaker.Core/Export/BiomeMap.cs new file mode 100644 index 0000000..85bb150 --- /dev/null +++ b/src/SimplePZMapmaker.Core/Export/BiomeMap.cs @@ -0,0 +1,47 @@ +namespace SimplePZMapmaker.Core.Export; + +/// Build 42 reads media/maps/<map>/maps/biomemap_<cellX>_<cellY>.png, one 256x256 image per native cell. +/// Each value is looked up in the game's BiomeMapConfig: red selects the WorldGen biome of a square, green the foraging zone, +/// read once per 8x8 chunk. Without these images a map has no foraging zones in Build 42: zones such as Forest or TownZone +/// in objects.lua are not registered by the server. Like the vanilla maps, red repeats the green value on land (vanilla +/// Muldraugh uses 230/230 birch-mix forest and 115/115 town) and blue repeats red. +/// Values from the 42.20 table: 0 Water, 64 ForagingNav, 102 TrailerPark, 115 TownZone, 128 Farm, 141 FarmLand, +/// 230 BirchMixForest, 255 DeepForest. +public static class BiomeMap +{ + public const byte Water=0,Nav=64,TrailerPark=102,Town=115,Farm=128,FarmLand=141,Forest=230,DeepForest=255; + /// Foraging zone (green value) of each 8x8 chunk of a native cell, 32x32 values. + public static byte[] Zones(MapProject p,int cellX,int cellY){ + var chunks=p.Width/8;var rows=p.Height/8;var kind=new byte[chunks*rows];var built=new bool[chunks*rows]; + foreach(var b in p.Buildings)for(var y=Math.Max(0,b.Y/8);y<=Math.Min(rows-1,(b.Y+b.Height)/8);y++)for(var x=Math.Max(0,b.X/8);x<=Math.Min(chunks-1,(b.X+b.Width)/8);x++)built[y*chunks+x]=true; + // Majority terrain of each chunk of the whole map, so forest depth and town proximity see across cell edges. + const byte water=0,forest=1,paved=2,open=3; + for(var cy=0;cy32?water:f>32?forest:r>8?paved:open; + } + bool Near(int cx,int cy,int radius,Func test){for(var y=Math.Max(0,cy-radius);y<=Math.Min(rows-1,cy+radius);y++)for(var x=Math.Max(0,cx-radius);x<=Math.Min(chunks-1,cx+radius);x++)if(test(y*chunks+x))return true;return false;} + var result=new byte[32*32]; + for(var y=0;y<32;y++)for(var x=0;x<32;x++){ + var cx=cellX*32+x;var cy=cellY*32+y;var i=cy*chunks+cx; + result[y*32+x]=Authored(p,cx*8+4,cy*8+4)??(kind[i] switch{ + water=>Water, + forest=>Near(cx,cy,3,j=>kind[j]!=forest)?Forest:DeepForest, + _ when built[i]||Near(cx,cy,2,j=>built[j])=>Town, + paved=>Nav, + _=>FarmLand}); + } + return result; + } + /// The foraging zone of an authored terrain zone covering a tile, if any. + static byte? Authored(MapProject p,int x,int y){ + foreach(var z in p.GameZones)if(!z.Vehicle&&ZoneGeometry.Covers(z,x,y))return z.Type switch{"Forest"=>Forest,"DeepForest"=>DeepForest,"Farm"=>Farm,"TownZone"=>Town,"TrailerPark"=>TrailerPark,"Vegitation"=>FarmLand,"Nav"=>Nav,_=>null}; + return null; + } + /// The biome map image of one native cell: the chunk's zone in red, green and blue, and 0 on water squares. + public static byte[] Png(MapProject p,int cellX,int cellY){ + var zones=Zones(p,cellX,cellY);var rgba=new byte[256*256*4]; + for(var y=0;y<256;y++)for(var x=0;x<256;x++){var i=(y*256+x)*4;var zone=zones[y/8*32+x/8];var red=p.Terrain[(cellY*256+y)*p.Width+cellX*256+x]==Terrain.Water?Water:zone;rgba[i]=red;rgba[i+1]=zone;rgba[i+2]=red;rgba[i+3]=255;} + return Export.Png.Encode(new(256,256,rgba)); + } +} diff --git a/src/SimplePZMapmaker.Core/Export/NativeMapExporter.cs b/src/SimplePZMapmaker.Core/Export/NativeMapExporter.cs index 708050c..91b18c1 100644 --- a/src/SimplePZMapmaker.Core/Export/NativeMapExporter.cs +++ b/src/SimplePZMapmaker.Core/Export/NativeMapExporter.cs @@ -68,7 +68,8 @@ internal static void ComposeCells(MapProject p,ZipArchive zip,string root,Cancel var tiles=names.Order(StringComparer.Ordinal).ToArray();var ids=tiles.Select((name,id)=>(name,id)).ToDictionary(t=>t.name,t=>t.id); Bytes(root+$"{p.WorldX+cellX}_{p.WorldY+cellY}.lotheader",Header(p,composer,tiles,cellX,cellY)); Bytes(root+$"world_{p.WorldX+cellX}_{p.WorldY+cellY}.lotpack",Pack(composer,ids)); - Bytes(root+$"chunkdata_{p.WorldX+cellX}_{p.WorldY+cellY}.bin",Navigation(composer));context?.Cell(); + Bytes(root+$"chunkdata_{p.WorldX+cellX}_{p.WorldY+cellY}.bin",Navigation(composer)); + Bytes(root+$"maps/biomemap_{p.WorldX+cellX}_{p.WorldY+cellY}.png",BiomeMap.Png(p,cellX,cellY));context?.Cell(); } } private static string Culture(FormattableString s)=>s.ToString(System.Globalization.CultureInfo.InvariantCulture); diff --git a/tests/SimplePZMapmaker.Tests/B42MapDataTests.cs b/tests/SimplePZMapmaker.Tests/B42MapDataTests.cs new file mode 100644 index 0000000..e069645 --- /dev/null +++ b/tests/SimplePZMapmaker.Tests/B42MapDataTests.cs @@ -0,0 +1,22 @@ +using SimplePZMapmaker.Core; +using SimplePZMapmaker.Core.Export; +using System.IO.Compression; +public static class B42MapDataTests +{ + public static IEnumerable<(string,Action)> Cases(){ + yield return("biome map gives every native cell foraging zones like the vanilla maps",()=>{ + var p=MapGenerator.Generate(new(){Kind="blank"}); + void Fill(int x0,int y0,int w,int h,Terrain t){for(var y=y0;ye.FullName.Contains("/maps/biomemap_")&&e.Name.EndsWith(".png"))==1,"Each native cell needs its biome map"); + }); + } + static void Check(bool ok,string message){if(!ok)throw new Exception(message);} +} diff --git a/tests/SimplePZMapmaker.Tests/Program.cs b/tests/SimplePZMapmaker.Tests/Program.cs index 610a376..07df5ef 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()).Concat(SmallBuildingTests.Cases()).Concat(SharedFenceImportTests.Cases()).Concat(SharedDecorationTests.Cases()).Concat(AppearanceTagTests.Cases()).Concat(AttachedBuildingTests.Cases()).Concat(ModPackageTests.Cases()).Concat(TilesetBuilderTests.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()).Concat(B42MapDataTests.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; From ff59c6f6bb562d4ca3b9d8329f283dfd0f5a4f88 Mon Sep 17 00:00:00 2001 From: Feliks Solovy Date: Sat, 17 Oct 2026 22:43:35 +0300 Subject: [PATCH 02/15] feat: Build 42 room catalog and room names from source tags The editor and validators accept the 588 internal Build 42 room names (from TileZed RoomNames.txt, GPL-2.0-or-later), which select loot distributions and zombie room filters. Imports name rooms from tags: pz:room explicitly; shop and amenity tags rename the sales room (and cafe/restaurant/bakery back rooms); building=barn/shed/greenhouse/garage/church and similar rename the whole building. Unknown names are reported, never fatal. Co-Authored-By: Claude Opus 5.5 --- src/SimplePZMapmaker.Core/BuildingDesigner.cs | 5 +- .../GeographicImporter.Appearance.cs | 2 +- .../GeographicImporter.Rooms.cs | 44 ++ .../GeographicImporter.WorldBuildings.cs | 2 +- .../GeographicImporter.cs | 2 +- src/SimplePZMapmaker.Core/RoomRegionRules.cs | 2 +- .../SimplePZMapmaker.Core.csproj | 2 +- .../Services/ZoneAuthoringEndpoints.cs | 2 +- src/SimplePZMapmaker.Web/wwwroot/building.js | 3 + .../wwwroot/room-catalog.json | 595 ++++++++++++++++++ .../SimplePZMapmaker.Tests/B42MapDataTests.cs | 27 + tests/SimplePZMapmaker.Tests/Program.cs | 2 +- tests/room-catalog.test.mjs | 10 + 13 files changed, 690 insertions(+), 8 deletions(-) create mode 100644 src/SimplePZMapmaker.Core/GeographicImporter.Rooms.cs create mode 100644 src/SimplePZMapmaker.Web/wwwroot/room-catalog.json create mode 100644 tests/room-catalog.test.mjs diff --git a/src/SimplePZMapmaker.Core/BuildingDesigner.cs b/src/SimplePZMapmaker.Core/BuildingDesigner.cs index 61e637c..fbcadd1 100644 --- a/src/SimplePZMapmaker.Core/BuildingDesigner.cs +++ b/src/SimplePZMapmaker.Core/BuildingDesigner.cs @@ -5,6 +5,9 @@ public static class BuildingDesigner public static readonly string[] Kinds=["house","apartments","school","shop","warehouse","industrial","clinic","hotel","garage"]; public static readonly Dictionary Names=new(){["house"]="Detached house",["apartments"]="Apartment building",["school"]="School",["shop"]="Shop",["warehouse"]="Warehouse",["industrial"]="Industrial building",["clinic"]="Clinic",["hotel"]="Hotel",["garage"]="Garage"}; public static readonly string[] RoomNames=["hall","livingroom","kitchen","bedroom","bathroom","classroom","office","conveniencestore","warehouse","medical","hotelroom","garage","storage","balcony"]; + /// Every room name a floor may use: the editor's own names plus the Build 42 internal room names (loot and zombie room filters). + public static readonly HashSet AllRoomNames=[..RoomNames,..LoadRoomCatalog()]; + static IEnumerable LoadRoomCatalog(){using var s=typeof(BuildingDesigner).Assembly.GetManifestResourceStream("SimplePZMapmaker.Core.RoomCatalog.json")!;using var d=System.Text.Json.JsonDocument.Parse(s);return d.RootElement.GetProperty("rooms").EnumerateArray().Select(e=>e.GetString()!).ToArray();} public static char Cell(Building b,BuildingFloor floor,int x,int y)=>x<0||y<0||x>=b.Width||y>=b.Height?'.':floor.Cells[y*b.Width+x]; public static bool Occupies(Building b,int x,int y)=>b.Design is null?x>=0&&y>=0&&xCell(b,f,x,y)!='.'); public static bool OccupiesAbove(Building b,int x,int y)=>b.Design is null?Occupies(b,x,y):b.Design.Floors.Where((_,i)=>i+b.Design.MinLevel>=0).Any(f=>Cell(b,f,x,y)!='.'); @@ -109,7 +112,7 @@ public static void Validate(Building b,bool requireAccess=true){ if(d.FlatRoofTile is not null&&!VisualStyles.TileId(d.FlatRoofTile))Fail("invalid flat roof surface resource."); if(d.Floors.Any(f=>f is null||f.Cells is null||f.Cells.Length!=b.Width*b.Height||f.Objects is null||f.Objects.Count>512||f.Edges is null||f.Edges.Count>2*(b.Width+1)*(b.Height+1)||f.Edges.Any(e=>e is null)||f.Rooms is null))Fail("invalid floor layer."); for(var z=0;z35||f.Rooms.Any(r=>r.Key.Length!=1||!"0123456789abcdefghijklmnopqrstuvwxyz".Contains(r.Key[0])||!RoomNames.Contains(r.Value))||f.Cells.Any(c=>c!='.'&&!f.Rooms.ContainsKey(c.ToString()))||!f.Cells.Any(c=>c!='.')||f.Edges is null||f.Edges.Count>2*(b.Width+1)*(b.Height+1)||f.Objects is null||f.Objects.Count>512)Fail($"invalid level {z+d.MinLevel}."); + var f=d.Floors[z];if(f is null||f.Cells is null||f.Cells.Length!=b.Width*b.Height||f.Rooms is null||f.Rooms.Count is <1 or >35||f.Rooms.Any(r=>r.Key.Length!=1||!"0123456789abcdefghijklmnopqrstuvwxyz".Contains(r.Key[0])||!AllRoomNames.Contains(r.Value))||f.Cells.Any(c=>c!='.'&&!f.Rooms.ContainsKey(c.ToString()))||!f.Cells.Any(c=>c!='.')||f.Edges is null||f.Edges.Count>2*(b.Width+1)*(b.Height+1)||f.Objects is null||f.Objects.Count>512)Fail($"invalid level {z+d.MinLevel}."); if(f.FloorTile is not null&&!VisualStyles.TileId(f.FloorTile)||f.FloorTiles is null||f.FloorTiles.Count>f.Cells.Length||f.FloorTiles.Any(v=>v.Key<0||v.Key>=f.Cells.Length||f.Cells[v.Key]=='.'||!VisualStyles.TileId(v.Value)))Fail("floor resources are invalid or outside a room."); if(f.StaticModels is null||f.StaticModels.Count>512)Fail("invalid model layer.");var modelIds=new HashSet();foreach(var model in f.StaticModels){StaticModelPlacement.Validate(model,b.Width,b.Height,z+d.MinLevel,z+d.MinLevel);if(!modelIds.Add(model.Id)||StaticModelPlacement.FootprintCells(model).Any(c=>Cell(b,f,model.X+c.Dx,model.Y+c.Dy)=='.'))Fail("the complete model footprint is outside a room.");} foreach(var e in f.Edges)if(e.WallTile is not null&&!VisualStyles.TileId(e.WallTile)||e.OpeningTile is not null&&!VisualStyles.TileId(e.OpeningTile)||e.OverlayTile is not null&&(!VisualStyles.TileId(e.OverlayTile)||e.Kind=="open"))Fail("invalid wall or opening resource."); diff --git a/src/SimplePZMapmaker.Core/GeographicImporter.Appearance.cs b/src/SimplePZMapmaker.Core/GeographicImporter.Appearance.cs index 915881a..7938fde 100644 --- a/src/SimplePZMapmaker.Core/GeographicImporter.Appearance.cs +++ b/src/SimplePZMapmaker.Core/GeographicImporter.Appearance.cs @@ -7,7 +7,7 @@ namespace SimplePZMapmaker.Core; public static partial class GeographicImporter { /// pz:* keys the importer understands; any other pz:* key is kept with the source and reported once. - public static readonly HashSet 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"]; + public static readonly HashSet KnownPzKeys=["pz:room","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"]; /// Average opaque colour of the first sprite of each Build 42 roofs_30_NN set, measured from the installed game. 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 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"}; diff --git a/src/SimplePZMapmaker.Core/GeographicImporter.Rooms.cs b/src/SimplePZMapmaker.Core/GeographicImporter.Rooms.cs new file mode 100644 index 0000000..2075372 --- /dev/null +++ b/src/SimplePZMapmaker.Core/GeographicImporter.Rooms.cs @@ -0,0 +1,44 @@ +namespace SimplePZMapmaker.Core; + +/// Room names from source tags. A Build 42 room name selects the loot distribution and zombie room filters, so a bakery +/// should be "bakery" rather than a generic sales floor. pz:room names the main room explicitly; otherwise shop, amenity and +/// building tags pick a vanilla room. Whole-building uses (barn, shed, church…) rename every above-ground room; shops and +/// services rename the sales room, and cafés and restaurants also turn their back rooms into kitchens. +public static partial class GeographicImporter +{ + static readonly Dictionary WholeBuildingRooms=new(StringComparer.Ordinal){ + ["building=shed"]="shed",["building=barn"]="barn",["building=stable"]="barn",["building=cowshed"]="barn",["building=greenhouse"]="greenhouse",["building=farm_auxiliary"]="farmstorage", + ["building=garage"]="garagestorage",["building=garages"]="garagestorage",["building=church"]="church",["building=chapel"]="church",["amenity=place_of_worship"]="church", + ["building=warehouse"]="warehouse",["building=industrial"]="factory",["amenity=fire_station"]="firestorage",["pz:kind=shed"]="shed",["pz:kind=greenhouse"]="greenhouse"}; + static readonly Dictionary SalesRooms=new(StringComparer.Ordinal){ + ["shop=bakery"]=("bakery","bakerykitchen"),["shop=butcher"]=("butcher",null),["shop=convenience"]=("conveniencestore",null),["shop=kiosk"]=("conveniencestore",null), + ["shop=supermarket"]=("grocery","grocerystorage"),["shop=general"]=("generalstore","generalstorestorage"),["shop=greengrocer"]=("grocery",null),["shop=alcohol"]=("liquorstore",null), + ["shop=hardware"]=("toolstore","toolstorestorage"),["shop=doityourself"]=("toolstore","toolstorestorage"),["shop=clothes"]=("clothingstore","clothingstorage"),["shop=shoes"]=("shoestore",null), + ["shop=books"]=("bookstore",null),["shop=furniture"]=("furniturestore",null),["shop=electronics"]=("electronicstore","electronicsstorage"),["shop=mobile_phone"]=("electronicstore",null), + ["shop=florist"]=("florist",null),["shop=jewelry"]=("jewelrystore","jewelrystorage"),["shop=gift"]=("giftstore","giftstorage"),["shop=pet"]=("petstore",null), + ["shop=sports"]=("sportstore","sportstorage"),["shop=toys"]=("toystore","toystorestorage"),["shop=car_repair"]=("mechanic",null),["shop=car_parts"]=("carsupply",null), + ["shop=pawnbroker"]=("pawnshop","pawnshopstorage"),["shop=tobacco"]=("tobaccostore","tobaccostorage"),["shop=garden_centre"]=("gardenstore",null),["shop=farm"]=("generalstore",null), + ["amenity=pharmacy"]=("pharmacy","pharmacystorage"),["shop=chemist"]=("pharmacy",null),["amenity=post_office"]=("post","poststorage"),["amenity=bank"]=("bank",null), + ["amenity=library"]=("library",null),["amenity=cafe"]=("cafe","cafekitchen"),["amenity=restaurant"]=("restaurantdining","restaurantkitchen"),["amenity=fast_food"]=("burgerdining","burgerkitchen"), + ["amenity=bar"]=("bar","barstorage"),["amenity=pub"]=("bar","barkitchen"),["amenity=fuel"]=("gasstore","gasstorage"),["amenity=police"]=("policeoffice","policestorage"), + ["amenity=kindergarten"]=("daycare",null),["amenity=doctors"]=("medicaloffice",null),["amenity=clinic"]=("medicaloffice","medicalstorage"),["amenity=dentist"]=("dentist","dentiststorage"), + ["amenity=community_centre"]=("hall",null),["amenity=townhall"]=("office",null)}; + + /// Every room name the tag tables can assign. + public static IEnumerable MappedRoomNames=>WholeBuildingRooms.Values.Concat(SalesRooms.Values.SelectMany(v=>new[]{v.Room,v.BackRoom}.OfType())).Distinct(); + /// Renames rooms of an imported building from its tags. Unknown room names are reported, never fatal. + public static void ApplyRoomTags(Building b,IReadOnlyDictionary tags,Action problem){ + if(b.Design is not {} d)return; + string? Valid(string? name){if(name is null)return null;if(BuildingDesigner.AllRoomNames.Contains(name))return name;problem($"room '{name}' is not a Build 42 room name");return null;} + IEnumerable Keys(){foreach(var k in new[]{"pz:kind","shop","amenity","building"})if(tags.TryGetValue(k,out var v))yield return k+"="+v;} + var aboveGround=d.Floors.Where((_,i)=>i+d.MinLevel>=0).ToList(); + if(tags.TryGetValue("pz:room",out var explicitRoom)){if(Valid(explicitRoom) is {} room)RenameMain(aboveGround,room);return;} + foreach(var key in Keys()){ + if(SalesRooms.TryGetValue(key,out var sales)){if(Valid(sales.Room) is {} room){var ground=d.Floors[-d.MinLevel];if(!RenameAll(ground,"conveniencestore",room))RenameMain([ground],room);if(Valid(sales.BackRoom) is {} back)RenameAll(ground,"storage",back);}return;} + if(WholeBuildingRooms.TryGetValue(key,out var whole)){if(Valid(whole) is {} room)foreach(var f in aboveGround)foreach(var k in f.Rooms.Keys.Where(k=>k!="0").ToList())f.Rooms[k]=room;return;} + } + } + static bool RenameAll(BuildingFloor f,string from,string to){var keys=f.Rooms.Where(r=>r.Value==from).Select(r=>r.Key).ToList();foreach(var k in keys)f.Rooms[k]=to;return keys.Count>0;} + /// The largest non-hall room of each floor takes the name. + static void RenameMain(IEnumerable floors,string room){foreach(var f in floors){var main=f.Rooms.Keys.Where(k=>k!="0").OrderByDescending(k=>f.Cells.Count(c=>c==k[0])).FirstOrDefault();if(main is not null)f.Rooms[main]=room;}} +} diff --git a/src/SimplePZMapmaker.Core/GeographicImporter.WorldBuildings.cs b/src/SimplePZMapmaker.Core/GeographicImporter.WorldBuildings.cs index 640d5c4..df08ea3 100644 --- a/src/SimplePZMapmaker.Core/GeographicImporter.WorldBuildings.cs +++ b/src/SimplePZMapmaker.Core/GeographicImporter.WorldBuildings.cs @@ -15,7 +15,7 @@ public static (int Added,int Skipped) ImportWorldBuildings(string json,WorldProj 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;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++;} + 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}."));ApplyRoomTags(b,tags,problem=>warnings.Add($"Shared building {b.Name}: room 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 95b9357..afa284e 100644 --- a/src/SimplePZMapmaker.Core/GeographicImporter.cs +++ b/src/SimplePZMapmaker.Core/GeographicImporter.cs @@ -109,7 +109,7 @@ 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); + ApplyAppearanceTags(b,f.Tags,p.ResourcePalettes,problem=>appearanceProblems[problem]=appearanceProblems.GetValueOrDefault(problem)+1);ApplyRoomTags(b,f.Tags,problem=>appearanceProblems[problem]=appearanceProblems.GetValueOrDefault(problem)+1); } if(w>64||h>64||!MapGenerator.CanPlace(p,b)){ skipped++; diff --git a/src/SimplePZMapmaker.Core/RoomRegionRules.cs b/src/SimplePZMapmaker.Core/RoomRegionRules.cs index b057c03..44d04fe 100644 --- a/src/SimplePZMapmaker.Core/RoomRegionRules.cs +++ b/src/SimplePZMapmaker.Core/RoomRegionRules.cs @@ -14,7 +14,7 @@ public static class RoomRegionRules static void Validate(MapProject p,IReadOnlyList rules) { if(rules is null||rules.Count>256||rules.Any(r=>r is null)||rules.Select(r=>r.Id).Distinct().Count()!=rules.Count)throw new ArgumentException("A map supports up to 256 distinct room rules."); - foreach(var r in rules){ZombieDensityRegions.ValidateIdentity(r.Id,r.Name);ZoneGeometry.Validate(r.X,r.Y,r.Width,r.Height,r.Polygon,p.Width,p.Height);ZoneGeometry.ValidateSourceTags(r.SourceTags);if(r.Level is <-32 or >31||r.Selection is not ("intersects" or "contained")||!BuildingDesigner.RoomNames.Contains(r.RoomName)||r.SourceId!="game"||r.RequiredMods is null||r.RequiredMods.Count>0)throw new ArgumentException("Choose an existing supported game room name and whole-room selection rule. Custom loot-zone types are unsupported.");} + foreach(var r in rules){ZombieDensityRegions.ValidateIdentity(r.Id,r.Name);ZoneGeometry.Validate(r.X,r.Y,r.Width,r.Height,r.Polygon,p.Width,p.Height);ZoneGeometry.ValidateSourceTags(r.SourceTags);if(r.Level is <-32 or >31||r.Selection is not ("intersects" or "contained")||!BuildingDesigner.AllRoomNames.Contains(r.RoomName)||r.SourceId!="game"||r.RequiredMods is null||r.RequiredMods.Count>0)throw new ArgumentException("Choose an existing supported game room name and whole-room selection rule. Custom loot-zone types are unsupported.");} } public static RoomRegionChange[] Preview(MapProject p,RoomRegionRule[] rules) { diff --git a/src/SimplePZMapmaker.Core/SimplePZMapmaker.Core.csproj b/src/SimplePZMapmaker.Core/SimplePZMapmaker.Core.csproj index f55a08b..13cc4a2 100644 --- a/src/SimplePZMapmaker.Core/SimplePZMapmaker.Core.csproj +++ b/src/SimplePZMapmaker.Core/SimplePZMapmaker.Core.csproj @@ -1 +1 @@ -net10.0enableenabletrue +net10.0enableenabletrue diff --git a/src/SimplePZMapmaker.Web/Services/ZoneAuthoringEndpoints.cs b/src/SimplePZMapmaker.Web/Services/ZoneAuthoringEndpoints.cs index a72eb1a..8f9f67a 100644 --- a/src/SimplePZMapmaker.Web/Services/ZoneAuthoringEndpoints.cs +++ b/src/SimplePZMapmaker.Web/Services/ZoneAuthoringEndpoints.cs @@ -6,7 +6,7 @@ public sealed record Request(MapProject Project,GameZone[]? Zones=null,ZombieDen public sealed record Result(MapProject Project,RoomRegionChange[] Rooms,DensityRasterChange[] Density,string[] Notes); public static void MapZoneAuthoringEndpoints(this WebApplication app) { - app.MapGet("/api/zones/catalog",()=>new{roomNames=BuildingDesigner.RoomNames.Select(name=>new{id=name,sourceId="game",nativeName=name switch{"hotelroom"=>"motelroom","garage"=>"garagestorage","balcony"=>"emptyoutside",_=>name},requiredMods=Array.Empty()}),selectionRules=new[]{"intersects","contained"},polygonCoverage="tile-centres",densityRasterSize=8}); + app.MapGet("/api/zones/catalog",()=>new{roomNames=BuildingDesigner.AllRoomNames.Order(StringComparer.Ordinal).Select(name=>new{id=name,sourceId="game",nativeName=name switch{"hotelroom"=>"motelroom","garage"=>"garagestorage","balcony"=>"emptyoutside",_=>name},requiredMods=Array.Empty()}),selectionRules=new[]{"intersects","contained"},polygonCoverage="tile-centres",densityRasterSize=8}); app.MapPost("/api/zones/preview",(Request request,GameplayCatalog catalog,CancellationToken token)=>Build(request,catalog,false,token)); app.MapPost("/api/zones/apply",(Request request,GameplayCatalog catalog,CancellationToken token)=>Build(request,catalog,true,token)); } diff --git a/src/SimplePZMapmaker.Web/wwwroot/building.js b/src/SimplePZMapmaker.Web/wwwroot/building.js index d5f27cc..8eedd8b 100644 --- a/src/SimplePZMapmaker.Web/wwwroot/building.js +++ b/src/SimplePZMapmaker.Web/wwwroot/building.js @@ -4,6 +4,7 @@ import {validateAccess} from './building-access.js'; import {validateDependencies,validateModel,validateModelLayer,modelCells,blockingModel,interactiveModel} from './resource-data.js'; import objectCatalog from './building-objects.json' with {type:'json'}; import visualStyles from './visual-styles.json' with {type:'json'}; +import roomCatalog from './room-catalog.json' with {type:'json'}; export {visualStyles}; // Keep optional appearance fields identical across browser edits and .NET roundtrips. export function normalizeBuilding(b){b.requiredMods??=[];b.assetSources??={};if(!b.design)return b;const d=b.design;d.style??='';d.roofTileset??=null;d.flatRoofTile??=null;for(const f of d.floors){f.staticModels??=[];f.floorTile??=null;f.floorTiles??={};for(const e of f.edges){e.wallTile??=null;e.openingTile??=null;e.overlayTile??=null;}for(const o of f.objects){o.style??='';o.asset??=null;o.parts??=[];for(const p of o.parts)p.flags??=0;}}return b;} @@ -23,6 +24,8 @@ export function containsVisiblePoint(b,x,y,level='all'){if(!b.design)return (lev export function combineAttribution(...lists){if(lists.some(a=>!Array.isArray(a)||a.some(s=>typeof s!=='string'||s.length>1000)))throw Error('The template source is corrupted.');const result=[...new Set(lists.flat())];if(result.length>30)throw Error('Too many geography sources.');return result;} export const kinds={house:'Private house',apartments:'Apartment building',school:'School',shop:'Store',warehouse:'Warehouse',industrial:'Industrial building',clinic:'Hospital',hotel:'Hotel',garage:'Garage'}; export const roomNames={hall:'Corridor',livingroom:'Living room',kitchen:'Kitchen',bedroom:'Bedroom',bathroom:'Bathroom',classroom:'Class',office:'Office',conveniencestore:'Sales floor',warehouse:'Warehouse',medical:'Ward',hotelroom:'Number',garage:'Garage',storage:'Pantry',balcony:'Balcony'}; +// Build 42 internal room names (loot distributions and zombie room filters) follow the editor's own names. +for(const name of roomCatalog.rooms)if(!Object.hasOwn(roomNames,name))roomNames[name]=name; export const shapeNames={rect:'Rectangle',l:'L-shaped',t:'T-shape',courtyard:'Courtyard',annex:'Extension',custom:'Custom footprint'}; export const cell=(b,f,x,y)=>x<0||y<0||x>=b.width||y>=b.height?'.':f.cells[y*b.width+x]; export function shapeCells(w,h,shape='rect'){let result='';for(let y=0;y=w-3||y>=h-3:shape==='annex'?x Cases(){ + yield return("room catalog holds the Build 42 room names and every tag mapping uses one",()=>{ + Check(BuildingDesigner.AllRoomNames.Count>500&&BuildingDesigner.AllRoomNames.IsSupersetOf(BuildingDesigner.RoomNames),"Room catalog is incomplete"); + var missing=GeographicImporter.MappedRoomNames.Where(n=>!BuildingDesigner.AllRoomNames.Contains(n)).ToList();Check(missing.Count==0,"Mapped rooms missing from the catalog: "+string.Join(",",missing)); + }); + yield return("source tags give imported buildings their vanilla rooms and the lot keeps them",()=>{ + 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 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=System.Text.Json.JsonSerializer.Serialize(new{type="FeatureCollection",pzTransfer=1,features=new[]{ + Box("osm-way-1",40.2,40.2,14,12,new(){{"building","retail"},{"shop","bakery"}}),Box("osm-way-2",70.2,40.2,12,10,new(){{"building","barn"}}), + Box("osm-way-3",100.2,40.2,14,12,new(){{"building","yes"},{"amenity","cafe"}}),Box("osm-way-4",130.2,40.2,12,10,new(){{"building","yes"},{"pz:room","church"}}), + Box("osm-way-5",160.2,40.2,12,10,new(){{"building","yes"},{"pz:room","no-such-room"}})}}); + var p=GeographicImporter.Import(json,MapGenerator.Generate(new(){Kind="blank"}),reference.Bounds,projection:projection).Project; + HashSet Rooms(string id)=>p.Buildings.Single(b=>b.SourceId=="geo-"+id).Design!.Floors.SelectMany(f=>f.Rooms.Values).ToHashSet(); + Check(Rooms("osm-way-1").Contains("bakery")&&!Rooms("osm-way-1").Contains("conveniencestore"),"shop=bakery should sell from a bakery room"); + Check(Rooms("osm-way-2").All(r=>r is "barn" or "hall"),"building=barn should be barn rooms"); + Check(Rooms("osm-way-3").Contains("cafe"),"amenity=cafe should have a cafe room");Check(Rooms("osm-way-4").Contains("church"),"pz:room should name the main room"); + Check(p.ImportSummary!.Notes.Any(n=>n.Contains("no-such-room")),"An unknown pz:room must be reported"); + ProjectValidator.Validate(p);using var zip=new System.IO.Compression.ZipArchive(new MemoryStream(SimplePZMapmaker.Core.Export.NativeMapExporter.Export(p)));using var r=new StreamReader(zip.Entries.Single(e=>e.Name.EndsWith(".lotheader")).Open(),System.Text.Encoding.Latin1);var header=r.ReadToEnd(); + Check(header.Contains("bakery")&&header.Contains("barn")&&header.Contains("church"),"Vanilla room names did not reach the lot header"); + }); + } + static void Check(bool ok,string message){if(!ok)throw new Exception(message);} +} diff --git a/tests/SimplePZMapmaker.Tests/Program.cs b/tests/SimplePZMapmaker.Tests/Program.cs index 07df5ef..cf1c57b 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()).Concat(SmallBuildingTests.Cases()).Concat(SharedFenceImportTests.Cases()).Concat(SharedDecorationTests.Cases()).Concat(AppearanceTagTests.Cases()).Concat(AttachedBuildingTests.Cases()).Concat(ModPackageTests.Cases()).Concat(TilesetBuilderTests.Cases()).Concat(B42MapDataTests.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()).Concat(B42MapDataTests.Cases()).Concat(RoomCatalogTests.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/room-catalog.test.mjs b/tests/room-catalog.test.mjs new file mode 100644 index 0000000..1189ca8 --- /dev/null +++ b/tests/room-catalog.test.mjs @@ -0,0 +1,10 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {validateDesign,refreshEdges,roomNames} from '../src/SimplePZMapmaker.Web/wwwroot/building.js'; + +test('the editor accepts Build 42 room names and keeps its own labels',()=>{ + assert.equal(roomNames.livingroom,'Living room');assert.ok(Object.keys(roomNames).length>500); + const b={id:'bakery',name:'Bakery',kind:'shop',x:10,y:10,width:4,height:3,design:{material:'wood',roof:'flat',shape:'rect',variant:1,floors:[{cells:'111111111111',rooms:{'1':'bakery'},edges:[],objects:[]}]}}; + refreshEdges(b,0);validateDesign(b); + b.design.floors[0].rooms['1']='no-such-room';assert.throws(()=>validateDesign(b)); +}); From 6068d3773467dc3260a26bfac049da9b19b68192 Mon Sep 17 00:00:00 2001 From: Feliks Solovy Date: Sat, 17 Oct 2026 22:56:07 +0300 Subject: [PATCH 03/15] feat: game zones from source tags, ZombiesType layer and 5x3 parking stalls Land use, parking and pz:zone tags become TownZone, Forest/DeepForest, Farm, Vegitation, ParkingStall and ZombiesType zones. Parking snaps to whole 5x3 stalls with a vanilla vehicle distribution. ZombiesType zones are their own overlap layer. The native section cache carries the biome maps. Co-Authored-By: Claude Opus 5.5 --- src/SimplePZMapmaker.Core/Export/BiomeMap.cs | 2 +- .../Export/GameZonesExporter.cs | 2 +- src/SimplePZMapmaker.Core/GameZone.cs | 7 ++- .../GeographicImporter.Zones.cs | 61 +++++++++++++++++++ .../GeographicImporter.cs | 1 + .../Services/NativeSectionCache.cs | 2 +- .../wwwroot/gameplay-data.js | 7 ++- .../SimplePZMapmaker.Tests/B42MapDataTests.cs | 28 +++++++++ .../SimplePZMapmaker.Tests/ExportJobTests.cs | 2 +- tests/SimplePZMapmaker.Tests/Program.cs | 2 +- 10 files changed, 104 insertions(+), 10 deletions(-) create mode 100644 src/SimplePZMapmaker.Core/GeographicImporter.Zones.cs diff --git a/src/SimplePZMapmaker.Core/Export/BiomeMap.cs b/src/SimplePZMapmaker.Core/Export/BiomeMap.cs index 85bb150..6d9c692 100644 --- a/src/SimplePZMapmaker.Core/Export/BiomeMap.cs +++ b/src/SimplePZMapmaker.Core/Export/BiomeMap.cs @@ -35,7 +35,7 @@ public static byte[] Zones(MapProject p,int cellX,int cellY){ } /// The foraging zone of an authored terrain zone covering a tile, if any. static byte? Authored(MapProject p,int x,int y){ - foreach(var z in p.GameZones)if(!z.Vehicle&&ZoneGeometry.Covers(z,x,y))return z.Type switch{"Forest"=>Forest,"DeepForest"=>DeepForest,"Farm"=>Farm,"TownZone"=>Town,"TrailerPark"=>TrailerPark,"Vegitation"=>FarmLand,"Nav"=>Nav,_=>null}; + foreach(var z in p.GameZones)if(z.Layer=="terrain"&&ZoneGeometry.Covers(z,x,y))return z.Type switch{"Forest"=>Forest,"DeepForest"=>DeepForest,"Farm"=>Farm,"TownZone"=>Town,"TrailerPark"=>TrailerPark,"Vegitation"=>FarmLand,"Nav"=>Nav,_=>null}; return null; } /// The biome map image of one native cell: the chunk's zone in red, green and blue, and 0 on water squares. diff --git a/src/SimplePZMapmaker.Core/Export/GameZonesExporter.cs b/src/SimplePZMapmaker.Core/Export/GameZonesExporter.cs index 01a9f07..093beee 100644 --- a/src/SimplePZMapmaker.Core/Export/GameZonesExporter.cs +++ b/src/SimplePZMapmaker.Core/Export/GameZonesExporter.cs @@ -5,7 +5,7 @@ public static class GameZonesExporter { public static string ObjectsLua(MapProject p,CancellationToken token=default) { - var output=new StringBuilder("objects = {\n");var decomposed=p.GameZones.Select(z=>(Zone:z,Rectangles:ZoneGeometry.Rectangles(z,token))).ToArray();var authored=decomposed.Where(z=>!z.Zone.Vehicle).SelectMany(z=>z.Rectangles).ToArray();var index=new Dictionary<(int,int),List>(); + var output=new StringBuilder("objects = {\n");var decomposed=p.GameZones.Select(z=>(Zone:z,Rectangles:ZoneGeometry.Rectangles(z,token))).ToArray();var authored=decomposed.Where(z=>z.Zone.Layer=="terrain").SelectMany(z=>z.Rectangles).ToArray();var index=new Dictionary<(int,int),List>(); foreach(var z in authored)for(var y=z.Y/8;y<=(z.Y+z.Height-1)/8;y++)for(var x=z.X/8;x<=(z.X+z.Width-1)/8;x++){if(!index.TryGetValue((x,y),out var list))index[(x,y)]=list=[];list.Add(z);} void Add(string name,string type,int x,int y,int width,int height,string properties="")=>output.Append(Culture($" {{ name=\"{name}\", type=\"{type}\", x={p.WorldX*256+x}, y={p.WorldY*256+y}, z=0, width={width}, height={height}{properties} }},\n")); for(var cy=0;cyType is "Vehicle" or "ParkingStall"; - public static string[] Types {get;}=["TownZone","Forest","DeepForest","Vegitation","Farm","TrailerPark","Nav","Vehicle","ParkingStall"]; + /// Zones of one layer may not overlap: terrain zones, vehicle spawns and ZombiesType definitions are separate layers. + public string Layer=>Vehicle?"vehicle":Type=="ZombiesType"?"zombies":"terrain"; + public static string[] Types {get;}=["TownZone","Forest","DeepForest","Vegitation","Farm","TrailerPark","Nav","Vehicle","ParkingStall","ZombiesType"]; public static void Validate(GameZone z,int width,int height) { if(z is null||!Regex.IsMatch(z.Id??"",@"^[a-fA-F0-9]{32}$")||z.Name is null||z.Name.Length>100||z.Name.Any(char.IsControl)||!Types.Contains(z.Type)||z.X<0||z.Y<0||z.Z!=0||z.Width<1||z.Height<1||(long)z.X+z.Width>width||(long)z.Y+z.Height>height)throw new ArgumentException("Game zones need a valid ID/type, ground level and a rectangle inside the map."); + if(z.Type=="ZombiesType"&&string.IsNullOrWhiteSpace(z.Name))throw new ArgumentException("A ZombiesType zone needs the name of its zombie definition."); ZoneGeometry.Validate(z.X,z.Y,z.Width,z.Height,z.Polygon,width,height);ZoneGeometry.ValidateSourceTags(z.SourceTags);ResourceDependencies.Validate(z.RequiredMods,[]); if(z.Vehicle){if(!Regex.IsMatch(z.VehicleDistribution??"",@"^[A-Za-z][A-Za-z0-9_]{0,119}$")||!ResourceDependencies.SafeModId(z.VehicleSourceId)||z.Direction is not ("N" or "S" or "E" or "W"))throw new ArgumentException("Vehicle zones need a registered distribution and N/S/E/W direction.");} else if(z.VehicleDistribution is not null||z.RequiredMods.Count>0)throw new ArgumentException("Vehicle distribution settings belong to a vehicle zone."); @@ -34,7 +37,7 @@ public static void ValidateProject(MapProject p) { if(p.GameZones is null||p.GameZones.Count>4096||p.GameZones.Select(z=>z?.Id).Distinct().Count()!=p.GameZones.Count)throw new ArgumentException("A map can contain up to 4,096 distinct authored game zones."); foreach(var zone in p.GameZones)GameZone.Validate(zone,p.Width,p.Height); - for(var i=0;iz.Vehicle)){for(var y=zone.Y;yb.Xzone.X&&b.Y+b.Height>zone.Y))for(var y=Math.Max(zone.Y,b.Y);yGame zones from source tags (docs/TRANSFER-CONTRACT.md, section 7). Land use becomes TownZone, Forest/DeepForest, Farm or +/// Vegitation polygons; parking areas and pz:zone=Vehicle become ParkingStall vehicle spawns; pz:zone names any zone type explicitly, +/// with pz:zombies (or name) for a ZombiesType definition. Zones that would overlap, cross water or buildings are kept out and counted. +public static partial class GeographicImporter +{ + /// Vanilla vehicle zone names (Build 42 VehicleZoneDistribution). Other names need a mod that registers them. + public static readonly HashSet VanillaVehicleZones=["parkingstall","trailerpark","bad","medium","good","luxuryDealership","sport","junkyard","trafficjamw","trafficjame","trafficjams","trafficjamn","rtrafficjamw","rtrafficjame","rtrafficjams","rtrafficjamn","police","prison","fire","ranger","mccoy","carpenter","postal","spiffo","ambulance","radio","fossoil","scarlet","massgenfac","transit","network3","kyheralds","lectromax","knoxdisti","advertising","airportshuttle","airportservice","farm","burnt","normalburnt","specialburnt"]; + + static string? ZoneType(Dictionary t)=>t.GetValueOrDefault("pz:zone") is {} explicitType?explicitType: + t.GetValueOrDefault("amenity")=="parking"?"ParkingStall": + t.GetValueOrDefault("landuse") switch{"residential"=>"TownZone","forest"=>"Forest","farmland" or "meadow" or "orchard"=>"Farm","allotments"=>"Vegitation",_=>t.GetValueOrDefault("natural")=="wood"?"Forest":null}; + + static void ImportZones(MapProject p,List features,List notes){ + var candidates=new List<(GameZone Zone,long Area)>();var reasons=new Dictionary(StringComparer.Ordinal);void Skip(string reason)=>reasons[reason]=reasons.GetValueOrDefault(reason)+1; + var mpt=p.MetresPerTile??1;var roadDistance=RoadDistance(p,(int)Math.Ceiling(60/mpt)); + foreach(var f in features.Where(f=>f.Rings.Count>0&&!f.Tags.ContainsKey("building"))){ + var type=ZoneType(f.Tags);if(type is null)continue;if(type=="Vehicle")type="ParkingStall"; + if(!GameZone.Types.Contains(type)){Skip($"unknown zone type {type}");continue;} + var ring=f.Rings[0].Select(v=>(X:Math.Clamp((int)Math.Round(v.X),0,p.Width),Y:Math.Clamp((int)Math.Round(v.Y),0,p.Height))).ToList(); + if(!f.Polygon){ // a line: a three-tile band around each straight segment + foreach(var (a,b) in ring.Zip(ring.Skip(1))){var x=Math.Min(a.X,b.X)-1;var y=Math.Min(a.Y,b.Y)-1;Add(type,f.Tags,x,y,Math.Abs(b.X-a.X)+3,Math.Abs(b.Y-a.Y)+3,null);} + continue; + } + var points=ring.Distinct().Select(v=>new ZonePoint(v.X,v.Y)).ToList();if(points.Count>128)points=Enumerable.Range(0,128).Select(i=>points[i*points.Count/128]).Distinct().ToList(); + if(points.Count<3){Skip("outline too small");continue;} + var minX=points.Min(v=>v.X);var minY=points.Min(v=>v.Y);var w=points.Max(v=>v.X)-minX;var h=points.Max(v=>v.Y)-minY;if(w<1||h<1){Skip("outline too small");continue;} + if(type=="ParkingStall"){Add(type,f.Tags,minX+1,minY+1,w-2,h-2,null);continue;} + if(type=="Forest"&&roadDistance is not null){long deep=0,all=0;for(var y=minY;y(int)Math.Ceiling(60/mpt))deep++;}if(all>0&&deep*2>all)type="DeepForest";} + Add(type,f.Tags,minX,minY,w,h,points); + } + void Add(string type,Dictionary tags,int x,int y,int w,int h,List? polygon){ + x=Math.Max(0,x);y=Math.Max(0,y);w=Math.Min(w,p.Width-x);h=Math.Min(h,p.Height-y); + if(type=="ParkingStall"){ // Build 42 spawns cars only in parking zones measuring whole 5x3 stalls + var (sw,sh)=w>=h?(w/5*5,h/3*3):(w/3*3,h/5*5);if(sw<3||sh<3){Skip("parking area smaller than one 5x3 stall");return;}x+=(w-sw)/2;y+=(h-sh)/2;w=sw;h=sh;polygon=null; + } + if(w<1||h<1){Skip("outline outside the map");return;} + var zone=new GameZone{Id=Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes((tags.GetValueOrDefault("__pz_source")??"")+"|zone|"+x+","+y)))[..32],Type=type,X=x,Y=y,Width=w,Height=h,Polygon=polygon, + Name=type=="ZombiesType"?(tags.GetValueOrDefault("pz:zombies")??tags.GetValueOrDefault("name")??""):tags.GetValueOrDefault("name")?[..Math.Min(100,tags["name"].Length)]??"", + SourceTags=tags.Where(t=>!t.Key.StartsWith("__",StringComparison.Ordinal)&&t.Key.Length<=100).Take(32).ToDictionary(t=>t.Key,t=>t.Value[..Math.Min(300,t.Value.Length)])}; + if(zone.Vehicle){var distribution=tags.GetValueOrDefault("pz:vehicles")??"parkingstall";if(!VanillaVehicleZones.Contains(distribution)){Skip($"vehicle zone '{distribution}' is not a vanilla distribution");distribution="parkingstall";}zone.VehicleDistribution=distribution;zone.Direction=tags.GetValueOrDefault("pz:direction") is "N" or "S" or "E" or "W"?tags["pz:direction"]:"N";} + candidates.Add((zone,(long)w*h)); + } + // Smaller, more specific zones first; a zone that would overlap one already placed in its layer is left out. + var placed=0;foreach(var (zone,_) in candidates.OrderBy(c=>c.Area)){ + if(p.GameZones.Count>=4096){Skip("zone limit reached");continue;} + try{GameZone.Validate(zone,p.Width,p.Height);}catch(ArgumentException){Skip("invalid zone outline");continue;} + p.GameZones.RemoveAll(z=>z.Id==zone.Id);p.GameZones.Add(zone);try{GameZones.ValidateProject(p);placed++;}catch(ArgumentException e){p.GameZones.RemoveAt(p.GameZones.Count-1);Skip(e.Message.Contains("overlap")?"overlaps another zone or a building":e.Message.Contains("water")?"crosses water":"rejected by zone checks");} + } + if(placed>0||reasons.Count>0)notes.Add($"Game zones from land use, parking and pz:zone tags: {placed}"+(reasons.Count>0?"; left out: "+string.Join(", ",reasons.Select(r=>$"{r.Key} {r.Value}")):"")+"."); + } + /// Tile distance to the nearest road, capped at +1; null when the map has no road. + static int[]? RoadDistance(MapProject p,int limit){ + var distance=new int[p.Terrain.Length];Array.Fill(distance,limit+1);var queue=new Queue(); + for(var i=0;ilimit)continue;var x=i%p.Width;var y=i/p.Width;foreach(var (nx,ny) in new[]{(x+1,y),(x-1,y),(x,y+1),(x,y-1)})if(nx>=0&&ny>=0&&nxd){distance[ny*p.Width+nx]=d;queue.Enqueue(ny*p.Width+nx);}} + return distance; + } +} diff --git a/src/SimplePZMapmaker.Core/GeographicImporter.cs b/src/SimplePZMapmaker.Core/GeographicImporter.cs index afa284e..77e5022 100644 --- a/src/SimplePZMapmaker.Core/GeographicImporter.cs +++ b/src/SimplePZMapmaker.Core/GeographicImporter.cs @@ -123,6 +123,7 @@ public static ImportResult Import(string json,MapProject project,GeoBounds? boun 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); p.ImportSummary=new(){SourceFeatures=features.Count,SourceBuildings=transformed.Count(f=>f.Polygon&&f.Tags.ContainsKey("building")),PlacedBuildings=p.Buildings.Count,SkipReasons=new(skipReasons),Quality=ImportQuality.Summarize(p.Buildings,omittedGeometry),SkippedOutlines=skippedOutlines}; + ImportZones(p,transformed,p.ImportSummary.Notes); p.ImportSummary.Notes.Add($"Editable source paths: {editableLines.Count}; raster-only paths retained because of authoring limits: {staticLines}. Imported path widths are estimated from source tags or road classes."); p.ImportSummary.Notes.Add("Building outlines preserve concavity and courtyard holes through tile-center resampling; rooms, stairs and furniture are generated, not surveyed. Original geographic rings are retained separately."); 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."); diff --git a/src/SimplePZMapmaker.Web/Services/NativeSectionCache.cs b/src/SimplePZMapmaker.Web/Services/NativeSectionCache.cs index 37e9d8b..441af36 100644 --- a/src/SimplePZMapmaker.Web/Services/NativeSectionCache.cs +++ b/src/SimplePZMapmaker.Web/Services/NativeSectionCache.cs @@ -17,7 +17,7 @@ public sealed class NativeSectionCache:INativeSectionCache token.ThrowIfCancellationRequested();var key=Key(p);var target=Path.Combine(directory,key+".zip");var digest=Path.Combine(directory,key+".sha256");var temporary=Path.Combine(directory,key+".partial");var hit=Valid(target,digest); if(!hit){try{using(var output=new FileStream(temporary,FileMode.Create,FileAccess.ReadWrite,FileShare.None)){using var bounded=new BoundedArchiveStream(output,256L*1024*1024);using var fragment=new ZipArchive(bounded,ZipArchiveMode.Create,true);compose(fragment);}token.ThrowIfCancellationRequested();File.Move(temporary,target,true);File.WriteAllText(digest,Hash(target));}finally{if(File.Exists(temporary))File.Delete(temporary);}} // Holding the cache gate keeps eviction from removing an open fragment on Windows. - using(var source=ZipFile.OpenRead(target)){if(source.Entries.Count!=p.Width/256*(p.Height/256)*3)throw new ArgumentException("Cached native section is incomplete. Retry the export.");var cells=0;foreach(var entry in source.Entries){token.ThrowIfCancellationRequested();if(entry.FullName!=entry.Name||!(entry.Name.EndsWith(".lotheader",StringComparison.Ordinal)||entry.Name.EndsWith(".lotpack",StringComparison.Ordinal)||entry.Name.StartsWith("chunkdata_",StringComparison.Ordinal)&&entry.Name.EndsWith(".bin",StringComparison.Ordinal)))throw new ArgumentException("Cached native section is invalid. Retry the export.");using var input=entry.Open();using var output=destination.CreateEntry(root+entry.Name,CompressionLevel.Fastest).Open();var buffer=new byte[65536];int count;while((count=input.Read(buffer))>0){token.ThrowIfCancellationRequested();output.Write(buffer,0,count);}if(hit&&++cells%3==0)cellCompleted?.Invoke(cells/3);}} + using(var source=ZipFile.OpenRead(target)){if(source.Entries.Count!=p.Width/256*(p.Height/256)*4)throw new ArgumentException("Cached native section is incomplete. Retry the export.");var cells=0;foreach(var entry in source.Entries){token.ThrowIfCancellationRequested();if(!(entry.FullName==entry.Name||entry.FullName=="maps/"+entry.Name&&entry.Name.StartsWith("biomemap_",StringComparison.Ordinal)&&entry.Name.EndsWith(".png",StringComparison.Ordinal))||!(entry.Name.StartsWith("biomemap_",StringComparison.Ordinal)||entry.Name.EndsWith(".lotheader",StringComparison.Ordinal)||entry.Name.EndsWith(".lotpack",StringComparison.Ordinal)||entry.Name.StartsWith("chunkdata_",StringComparison.Ordinal)&&entry.Name.EndsWith(".bin",StringComparison.Ordinal)))throw new ArgumentException("Cached native section is invalid. Retry the export.");using var input=entry.Open();using var output=destination.CreateEntry(root+entry.FullName,CompressionLevel.Fastest).Open();var buffer=new byte[65536];int count;while((count=input.Read(buffer))>0){token.ThrowIfCancellationRequested();output.Write(buffer,0,count);}if(hit&&++cells%4==0)cellCompleted?.Invoke(cells/4);}} File.SetLastWriteTimeUtc(target,DateTime.UtcNow);Prune();return hit; }finally{gate.Release();}} static string Hash(string path){using var s=File.OpenRead(path);return Convert.ToHexString(SHA256.HashData(s));} diff --git a/src/SimplePZMapmaker.Web/wwwroot/gameplay-data.js b/src/SimplePZMapmaker.Web/wwwroot/gameplay-data.js index 1a3d048..e8a022c 100644 --- a/src/SimplePZMapmaker.Web/wwwroot/gameplay-data.js +++ b/src/SimplePZMapmaker.Web/wwwroot/gameplay-data.js @@ -1,6 +1,7 @@ import {validateZoneGeometry,zoneCovers,zoneIntersects} from './zone-data.js'; import {validateDependencies} from './resource-data.js'; -export const gameZoneTypes=['TownZone','Forest','DeepForest','Vegitation','Farm','TrailerPark','Nav','Vehicle','ParkingStall']; +export const gameZoneTypes=['TownZone','Forest','DeepForest','Vegitation','Farm','TrailerPark','Nav','Vehicle','ParkingStall','ZombiesType']; export const vehicleZone=z=>['Vehicle','ParkingStall'].includes(z.type); -export function validateGameZones(p){const zones=p.gameZones??[];if(!Array.isArray(zones)||zones.length>4096||new Set(zones.map(z=>z?.id)).size!==zones.length)throw Error('A map can contain up to 4,096 distinct game zones.');for(const z of zones){if(!z||!/^[a-fA-F0-9]{32}$/.test(z.id)||typeof z.name!=='string'||z.name.length>100||/[\x00-\x1f\x7f]/.test(z.name)||!gameZoneTypes.includes(z.type)||![z.x,z.y,z.z,z.width,z.height].every(Number.isInteger)||z.z!==0||z.x<0||z.y<0||z.width<1||z.height<1||z.x+z.width>p.width||z.y+z.height>p.height)throw Error('Game zones need a valid ground-level rectangle inside the map.');validateZoneGeometry(z,p.width,p.height);validateDependencies(z.requiredMods??[],{});if(vehicleZone(z)){if(!/^[A-Za-z][A-Za-z0-9_]{0,119}$/.test(z.vehicleDistribution??'')||!['N','S','E','W'].includes(z.direction)||typeof z.vehicleSourceId!=='string'||z.vehicleSourceId.length>180)throw Error('Select a registered vehicle distribution and direction.');}else if(z.vehicleDistribution!=null||z.requiredMods?.length)throw Error('Vehicle settings belong to a vehicle zone.');} - for(let i=0;ivehicleZone(z)?'vehicle':z.type==='ZombiesType'?'zombies':'terrain'; +export function validateGameZones(p){const zones=p.gameZones??[];if(!Array.isArray(zones)||zones.length>4096||new Set(zones.map(z=>z?.id)).size!==zones.length)throw Error('A map can contain up to 4,096 distinct game zones.');for(const z of zones){if(z?.type==='ZombiesType'&&!(typeof z.name==='string'&&z.name.trim()))throw Error('A ZombiesType zone needs the name of its zombie definition.');if(!z||!/^[a-fA-F0-9]{32}$/.test(z.id)||typeof z.name!=='string'||z.name.length>100||/[\x00-\x1f\x7f]/.test(z.name)||!gameZoneTypes.includes(z.type)||![z.x,z.y,z.z,z.width,z.height].every(Number.isInteger)||z.z!==0||z.x<0||z.y<0||z.width<1||z.height<1||z.x+z.width>p.width||z.y+z.height>p.height)throw Error('Game zones need a valid ground-level rectangle inside the map.');validateZoneGeometry(z,p.width,p.height);validateDependencies(z.requiredMods??[],{});if(vehicleZone(z)){if(!/^[A-Za-z][A-Za-z0-9_]{0,119}$/.test(z.vehicleDistribution??'')||!['N','S','E','W'].includes(z.direction)||typeof z.vehicleSourceId!=='string'||z.vehicleSourceId.length>180)throw Error('Select a registered vehicle distribution and direction.');}else if(z.vehicleDistribution!=null||z.requiredMods?.length)throw Error('Vehicle settings belong to a vehicle zone.');} + for(let i=0;i Cases(){ + yield return("land use parking and pz:zone tags become game zones with Build 42 vehicle rules",()=>{ + 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 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=System.Text.Json.JsonSerializer.Serialize(new{type="FeatureCollection",pzTransfer=1,features=new object[]{ + Box("osm-way-1",20,20,60,40,new(){{"landuse","residential"}}),Box("osm-way-2",90,20,40,30,new(){{"landuse","farmland"}}), + Box("osm-way-3",140,140,100,100,new(){{"natural","wood"}}),Box("osm-way-4",20,80,20,9,new(){{"amenity","parking"}}), + Box("osm-way-5",50,80,12,6,new(){{"pz:zone","Vehicle"},{"pz:vehicles","junkyard"},{"pz:direction","E"}}),Box("osm-way-6",25,25,20,20,new(){{"pz:zone","ZombiesType"},{"pz:zombies","Farmer"}}), + Box("osm-way-7",70,80,10,6,new(){{"amenity","parking"},{"pz:vehicles","no-such-cars"}}), + new{type="Feature",id="osm-way-8",properties=new Dictionary{{"highway","residential"}},geometry=new{type="LineString",coordinates=new[]{At(5,2),At(5,250)}}}}}); + var p=MapGenerator.Generate(new(){Kind="blank"});for(var y=0;y<256;y++)p.Terrain[y*256+2]=Terrain.Road; + p=GeographicImporter.Import(json,p,reference.Bounds,projection:projection).Project; + Check(p.GameZones.Any(z=>z.Type=="TownZone"&&z.Polygon is not null)&&p.GameZones.Any(z=>z.Type=="Farm"),"Residential or farmland zone missing"); + Check(p.GameZones.Any(z=>z.Type=="DeepForest"),"A wood far from roads should be DeepForest"); + var stalls=p.GameZones.Where(z=>z.Type=="ParkingStall").ToList();Check(stalls.Count==3&&stalls.All(z=>z.Polygon is null&&(z.Width%5==0&&z.Height%3==0||z.Width%3==0&&z.Height%5==0)),"Parking zones must measure whole 5x3 stalls"); + Check(stalls.Any(z=>z.VehicleDistribution=="junkyard"&&z.Direction=="E")&&stalls.Count(z=>z.VehicleDistribution=="parkingstall")==2,"Vehicle distributions were not taken from the tags"); + Check(p.GameZones.Any(z=>z.Type=="ZombiesType"&&z.Name=="Farmer"),"ZombiesType zone missing; it may overlap the town zone"); + Check(p.ImportSummary!.Notes.Any(n=>n.Contains("no-such-cars")),"An unknown vehicle distribution must be reported"); + ProjectValidator.Validate(p);var lua=SimplePZMapmaker.Core.Export.GameZonesExporter.ObjectsLua(p,default); + Check(lua.Contains("type=\"ZombiesType\"")&&lua.Contains("name=\"Farmer\"")&&lua.Contains("name=\"junkyard\", type=\"ParkingStall\""),"objects.lua lacks the imported zones"); + var again=GeographicImporter.Import(json,p,p.Bounds,preserveEdits:true,projection:projection).Project;Check(again.GameZones.Count==p.GameZones.Count,"Reimport duplicated source zones"); + }); + } + static void Check(bool ok,string message){if(!ok)throw new Exception(message);} +} diff --git a/tests/SimplePZMapmaker.Tests/ExportJobTests.cs b/tests/SimplePZMapmaker.Tests/ExportJobTests.cs index d54112e..e99b104 100644 --- a/tests/SimplePZMapmaker.Tests/ExportJobTests.cs +++ b/tests/SimplePZMapmaker.Tests/ExportJobTests.cs @@ -7,7 +7,7 @@ public static class ExportJobTests { public static IEnumerable<(string,Action)> Cases(){ yield return ("native cache hits do not invoke the expensive cell composer and damaged fragments rebuild",()=>InWorkspace(root=>{ - var p=WorldTests.Fixture(1).Maps[0].Project;var directory=Path.Combine(root,"cache");var cache=new NativeSectionCache(directory);var composed=0;bool Write(){using var output=new MemoryStream();using var zip=new ZipArchive(output,ZipArchiveMode.Create,true);return cache.Write(p,zip,"map/",default,null,fragment=>{composed++;foreach(var name in new[]{"100_100.lotheader","world_100_100.lotpack","chunkdata_100_100.bin"}){using var cell=fragment.CreateEntry(name).Open();cell.WriteByte(42);}});}Check(!Write()&&Write()&&composed==1,"Cache hit invoked native composition");File.AppendAllText(Directory.EnumerateFiles(directory,"*.zip").Single(),"damaged");Check(!Write()&&composed==2,"Damaged cached fragment was reused");Check(Write()&&composed==2,"Rebuilt fragment was not reusable"); + var p=WorldTests.Fixture(1).Maps[0].Project;var directory=Path.Combine(root,"cache");var cache=new NativeSectionCache(directory);var composed=0;bool Write(){using var output=new MemoryStream();using var zip=new ZipArchive(output,ZipArchiveMode.Create,true);return cache.Write(p,zip,"map/",default,null,fragment=>{composed++;foreach(var name in new[]{"100_100.lotheader","world_100_100.lotpack","chunkdata_100_100.bin","maps/biomemap_100_100.png"}){using var cell=fragment.CreateEntry(name).Open();cell.WriteByte(42);}});}Check(!Write()&&Write()&&composed==1,"Cache hit invoked native composition");File.AppendAllText(Directory.EnumerateFiles(directory,"*.zip").Single(),"damaged");Check(!Write()&&composed==2,"Damaged cached fragment was reused");Check(Write()&&composed==2,"Rebuilt fragment was not reusable"); })); yield return ("native section cache skips composition and invalidates terrain, buildings, providers and shared decks",()=>InWorkspace(root=>{ var p=WorldTests.Fixture(1).Maps[0].Project;var cache=new NativeSectionCache(Path.Combine(root,"cache"));var progress=Export(p,cache);Check(progress.ReusedSections==0,"First section was unexpectedly cached");Check(Export(p,cache).ReusedSections==1,"Unchanged section was recomposed");var key=NativeSectionCache.Key(p);p.Terrain[4]=Terrain.Forest;Check(NativeSectionCache.Key(p)!=key,"Terrain edit did not invalidate section");Check(Export(p,cache).ReusedSections==0,"Terrain edit reused stale cells");key=NativeSectionCache.Key(p);var b=HousingTemplates.Create("village-izba");b.X=40;b.Y=40;p.Buildings.Add(b);Check(NativeSectionCache.Key(p)!=key,"Building edit omitted from key");key=NativeSectionCache.Key(p);p.AssetSources["blends_natural_01_0"]="provider-mod";Check(NativeSectionCache.Key(p)!=key,"Resource ownership omitted from key");key=NativeSectionCache.Key(p);p.SharedInfrastructure=new([new(20,20,1,InfrastructureGeometry.DeckTile)],[]);Check(NativeSectionCache.Key(p)!=key,"JsonIgnored shared deck omitted from key");key=NativeSectionCache.Key(p);p.SharedInfrastructure=new([new(20,20,2,InfrastructureGeometry.DeckTile)],[]);Check(NativeSectionCache.Key(p)!=key,"Shared deck height edit reused stale cells"); diff --git a/tests/SimplePZMapmaker.Tests/Program.cs b/tests/SimplePZMapmaker.Tests/Program.cs index cf1c57b..26bb5b0 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()).Concat(SmallBuildingTests.Cases()).Concat(SharedFenceImportTests.Cases()).Concat(SharedDecorationTests.Cases()).Concat(AppearanceTagTests.Cases()).Concat(AttachedBuildingTests.Cases()).Concat(ModPackageTests.Cases()).Concat(TilesetBuilderTests.Cases()).Concat(B42MapDataTests.Cases()).Concat(RoomCatalogTests.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()).Concat(B42MapDataTests.Cases()).Concat(RoomCatalogTests.Cases()).Concat(ZoneImportTests.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; From 0d941a49b5b861edf01613091ca1929a826ba18c Mon Sep 17 00:00:00 2001 From: Feliks Solovy Date: Sat, 17 Oct 2026 23:17:34 +0300 Subject: [PATCH 04/15] feat: TileZed wall catalog with NW/SE corners and reference fence sets pz:wall and style walls take window and door tiles from the 175 exterior wall sets in BuildingTiles.txt; 42 of them do not follow the +8/+10 rule. Export draws the NW corner piece and the SE pillar. The built-in fences are TileZed's 14 reference sets with west1/west2 alternation, posts, gate frames and the matching wickets. Co-Authored-By: Claude Opus 5.5 --- docs/TRANSFER-CONTRACT.md | 7 ++- docs/licenses/README.md | 5 ++ .../Export/TileComposer.Design.cs | 18 +++++-- src/SimplePZMapmaker.Core/FenceLines.cs | 11 ++-- .../GeographicImporter.Appearance.cs | 2 +- .../GeographicImporter.Fences.cs | 51 ++++++++++++++----- .../SimplePZMapmaker.Core.csproj | 2 +- src/SimplePZMapmaker.Core/WallCatalog.cs | 27 ++++++++++ .../Services/AssetRules.cs | 2 +- .../Services/FenceAuthoringEndpoints.cs | 4 +- .../wwwroot/fence-authoring.js | 2 +- .../wwwroot/fence-data.js | 2 +- .../wwwroot/layer-edit-guards.js | 2 +- .../wwwroot/wall-catalog.json | 1 + .../SimplePZMapmaker.Tests/B42MapDataTests.cs | 33 ++++++++++++ tests/SimplePZMapmaker.Tests/Program.cs | 2 +- 16 files changed, 142 insertions(+), 29 deletions(-) create mode 100644 src/SimplePZMapmaker.Core/WallCatalog.cs create mode 100644 src/SimplePZMapmaker.Web/wwwroot/wall-catalog.json diff --git a/docs/TRANSFER-CONTRACT.md b/docs/TRANSFER-CONTRACT.md index 958019c..0177d43 100644 --- a/docs/TRANSFER-CONTRACT.md +++ b/docs/TRANSFER-CONTRACT.md @@ -98,7 +98,10 @@ Fences are their own layer, not decorations and not `LinearFeature`s (which are - `W`, `N`: the edge tiles; - `NW`: optional corner tile; - `Post`: optional post tile; -- `WAlt`, `NAlt` with `AltEvery`: optional alternative edges, used for a style that repeats every N edges, such as a brick pillar between forged sections. +- `WAlt`, `NAlt` with `AltEvery`: optional alternative edges, used for a style that repeats every N edges, such as a brick pillar between forged sections; +- `GateSpaceW`, `GateSpaceN`: optional gate frame drawn under a gate on that edge. + +The built-in styles are TileZed's reference fences (`Fences.txt`): `vanilla-planks`, `vanilla-short-planks`, `vanilla-tall-planks-2`, `vanilla-picket`, `vanilla-short-picket`, `vanilla-wire`, `vanilla-tall-wire`, `vanilla-cattle`, `vanilla-black-metal`, `vanilla-black-bars`, `vanilla-sheet` (tall concrete), `vanilla-military`, `vanilla-construction` and `vanilla-construction-barbed`. Two-piece fences alternate `west1`/`west2` with `AltEvery = 2`. A gate added without a style gets the wicket that TileZed pairs with the fence: `vanilla-wicket`, `-short`, `-picket`, `-wire` or `-metal`, each a full closed/open set. `GateStyle`: `Width` 1–8 edges, `ClosedW`, `ClosedN` and optional `OpenW`, `OpenN`, each with one piece per edge in line direction. `OpensTo = "left"` mirrors the order. Vanilla example: `fixtures_doors_fences_01_12`…`15`, the solid wooden wicket (closed W, closed N, open W, open N). Tiles from a resource mod add that mod to `RequiredMods`. A style ID used by both the world and a section must mean the same tiles. @@ -132,7 +135,7 @@ Building feature properties read by the importer (OSM keys keep their OSM meanin | `building:levels` | 1–8 | storeys (existing) | | `roof:shape` | `gabled`, `hipped`, `flat`, … | roof form (existing) | | `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:wall` | `set:`, a bare first tile, or the ID of a project palette whose first wall resource starts the set | exterior wall set, on every exterior edge except balconies. The pieces come from TileZed's wall catalog (`wall-catalog.json`, 175 sets); a set outside it uses wall `+0/+1`, window frame `+8/+9`, door frame `+10/+11` (W/N). Export draws the set's NW corner piece where plain N and W walls meet, and its SE pillar on outer corners | | `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; `bathhouse` and `toilet` make a one-room building a bathroom | diff --git a/docs/licenses/README.md b/docs/licenses/README.md index 8c16002..69a600c 100644 --- a/docs/licenses/README.md +++ b/docs/licenses/README.md @@ -3,3 +3,8 @@ MCP C# SDK2.2.0 license is copied from its package repository commit6fa3825973949a9c4f0cd8af344e15a8db09dc35, as recorded in the NuGet package metadata: https://github.com/modelcontextprotocol/csharp-sdk/blob/6fa3825973949a9c4f0cd8af344e15a8db09dc35/LICENSE . The self-contained publisher copies LICENSE and THIRD-PARTY-NOTICES from the exact included .NET runtime packs into artifacts/app/licenses. The locally bundled Three.js/fflate loader notices remain under wwwroot/vendor/three. Original game/mod resources are read locally and are not distributed. + +Data taken from TileZed in [PZ Mapping Tools](https://github.com/Unjammer/PZ_Mapping_Tools) (Tim Baker; Alree/Unjammer), GPL-2.0-or-later, which is compatible with this project's GPL v2 licence: +- `wwwroot/room-catalog.json`, from `TileZed/RoomNames.txt`; +- `wwwroot/wall-catalog.json`, from the `exterior_walls` category of `TileZed/src/tiled/BuildingEditor/BuildingTiles.txt` (revision 42), with tile indices written without leading zeros; +- the reference fence and wicket styles in `GeographicImporter.Fences.cs`, from `TileZed/Fences.txt`. diff --git a/src/SimplePZMapmaker.Core/Export/TileComposer.Design.cs b/src/SimplePZMapmaker.Core/Export/TileComposer.Design.cs index b659acd..e6f2fb8 100644 --- a/src/SimplePZMapmaker.Core/Export/TileComposer.Design.cs +++ b/src/SimplePZMapmaker.Core/Export/TileComposer.Design.cs @@ -29,11 +29,23 @@ private void ComposeDesign(Building b){ var id=Rooms.Count;building.Add(id);var roomName=room.Value switch{"hotelroom"=>"motelroom","balcony"=>"emptyoutside","garage"=>"garagestorage",_=>room.Value};Rooms.Add(new(roomName,0,0,0,0,Buildings.Count,z,rectangles)); for(var i=0;i(); foreach(var e in floor.Edges){ - 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); + 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 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); + walls[(e.X,e.Y,north)]=(balcony&&e.Kind=="wall"?e.WallTile??$"carpentry_02_{(north?101:100)}":e.WallTile??WallCatalog.Tile(style?.Wall??"walls_exterior_house_01",style?.WallBase??wallBase,e.Kind,north),e.Kind); + } + (string Tile,string Kind) Wall(int x,int y,bool north)=>walls.GetValueOrDefault((x,y,north)); + WallSet? Plain(int x,int y,bool north)=>Wall(x,y,north) is {Kind:"wall",Tile:{} t}&&WallCatalog.Find(t) is {} set&&t==(north?set.North:set.West)?set:null; + foreach(var (x,y,north) in walls.Keys.Where(k=>k.North).ToList()){ + if(Plain(x,y,true) is {NorthWest:{} corner} set&&Plain(x,y,false)==set){walls[(x,y,true)]=(corner,"corner");walls[(x,y,false)]=(corner,"covered");} + } + foreach(var (x,y,_) in walls.Keys.Where(k=>k.North).ToList())if(Plain(x,y,true) is {SouthEast:{} pillar} set&&Plain(x+1,y-1,false)==set&&Wall(x+1,y,true).Tile is null&&Wall(x+1,y,false).Tile is null)AddAt(x0+x+1,y0+y,z,pillar); + foreach(var e in floor.Edges){ + var north=e.Edge=="n";if(!walls.TryGetValue((e.X,e.Y,north),out var wall))continue; + if(wall.Kind!="covered")AddAt(x0+e.X,y0+e.Y,z,wall.Tile,wall.Kind=="corner"?(byte)6:north?(byte)2:(byte)4); if(e.Kind is "door" or "window")AddAt(x0+e.X,y0+e.Y,z,e.OpeningTile??$"fixtures_{(e.Kind=="door"?"doors":"windows")}_01_{(north?1:0)+(e.Kind=="door"?style?.DoorBase??0:style?.WindowBase??0)}"); if(e.OverlayTile is not null)AddAt(x0+e.X,y0+e.Y,z,e.OverlayTile); } diff --git a/src/SimplePZMapmaker.Core/FenceLines.cs b/src/SimplePZMapmaker.Core/FenceLines.cs index 7089860..e41250d 100644 --- a/src/SimplePZMapmaker.Core/FenceLines.cs +++ b/src/SimplePZMapmaker.Core/FenceLines.cs @@ -13,6 +13,9 @@ public sealed class FenceStyle public string? WAlt {get;set;} public string? NAlt {get;set;} public int AltEvery {get;set;} + /// Optional gate frame drawn under a gate on a W or N edge of this fence (TileZed gate-space). + public string? GateSpaceW {get;set;} + public string? GateSpaceN {get;set;} } /// A gate or wicket Width edges wide; piece i of ClosedW/ClosedN/OpenW/OpenN goes on the i-th edge in line direction, reversed when it opens to the left. public sealed class GateStyle @@ -55,7 +58,7 @@ public sealed record Composition(List Pieces,Dictionary Skipp public static void Validate(WorldProject w)=>Validate(w.Fences,w.FenceStyles,w.GateStyles,258048); public static void Validate(List lines,List styles,List gates,int limit){ if(lines is null||styles is null||gates is null||lines.Count>MaximumLines||styles.Count>MaximumStyles||gates.Count>MaximumStyles)throw new ArgumentException($"Fences: at most {MaximumLines} lines and {MaximumStyles} fence and gate styles."); - var styleIds=new HashSet();foreach(var s in styles){if(s is null||!SafeId(s.Id)||!styleIds.Add(s.Id)||!VisualStyles.TileId(s.W)||!VisualStyles.TileId(s.N)||s.NW is not null&&!VisualStyles.TileId(s.NW)||s.Post is not null&&!VisualStyles.TileId(s.Post)||s.WAlt is not null&&!VisualStyles.TileId(s.WAlt)||s.NAlt is not null&&!VisualStyles.TileId(s.NAlt)||s.AltEvery is <0 or >64||s.AltEvery>0&&(s.WAlt is null||s.NAlt is null))throw new ArgumentException("Invalid fence style: it needs an ID, W and N tiles, and both alternative tiles when it repeats one.");} + var styleIds=new HashSet();foreach(var s in styles){if(s is null||!SafeId(s.Id)||!styleIds.Add(s.Id)||!VisualStyles.TileId(s.W)||!VisualStyles.TileId(s.N)||s.NW is not null&&!VisualStyles.TileId(s.NW)||s.Post is not null&&!VisualStyles.TileId(s.Post)||s.WAlt is not null&&!VisualStyles.TileId(s.WAlt)||s.NAlt is not null&&!VisualStyles.TileId(s.NAlt)||s.AltEvery is <0 or >64||s.AltEvery>0&&(s.WAlt is null||s.NAlt is null)||s.GateSpaceW is not null&&!VisualStyles.TileId(s.GateSpaceW)||s.GateSpaceN is not null&&!VisualStyles.TileId(s.GateSpaceN))throw new ArgumentException("Invalid fence style: it needs an ID, W and N tiles, and both alternative tiles when it repeats one.");} var gateIds=new HashSet();foreach(var g in gates){if(g is null||!SafeId(g.Id)||!gateIds.Add(g.Id)||g.Width is <1 or >8||g.ClosedW?.Count!=g.Width||g.ClosedN?.Count!=g.Width||g.OpenW is null||g.OpenN is null||g.OpenW.Count is not 0&&g.OpenW.Count!=g.Width||g.OpenN.Count!=g.OpenW.Count||g.ClosedW.Concat(g.ClosedN).Concat(g.OpenW).Concat(g.OpenN).Any(t=>!VisualStyles.TileId(t)))throw new ArgumentException("Invalid gate style: it needs an ID, a width of 1-8 and one closed (and optionally open) W and N tile per edge.");} var ids=new HashSet();long edges=0; foreach(var f in lines){ @@ -72,7 +75,7 @@ public static void Validate(List lines,List styles,ListEdge tiles, gates and posts of all lines in the area [0,width) x [0,height), offset by (dx, dy). Edges on blocked cells are skipped and counted by reason. public static Composition Compose(IEnumerable lines,IReadOnlyCollection styles,IReadOnlyCollection gateStyles,int width,int height,int dx=0,int dy=0,Func? blocked=null){ var styleById=styles.ToDictionary(s=>s.Id);var gateById=gateStyles.ToDictionary(s=>s.Id);var skipped=new Dictionary(); - var edges=new Dictionary<(int X,int Y,bool North),(string Tile,string Style,bool Gate,string Line)>();var posts=new Dictionary<(int X,int Y),(string Tile,string Line)>(); + var edges=new Dictionary<(int X,int Y,bool North),(string Tile,string Style,bool Gate,string Line)>();var posts=new Dictionary<(int X,int Y),(string Tile,string Line)>();var frames=new Dictionary<(int X,int Y,bool North),(string Tile,string Line)>(); foreach(var f in lines){ var style=styleById[f.Style];var gateAt=new Dictionary(); foreach(var g in f.Gates){var gs=gateById[g.Style];var pieces=g.Open?(W:gs.OpenW,N:gs.OpenN):(W:gs.ClosedW,N:gs.ClosedN);for(var i=0;i lines,IReadOnlyCollecti else{var alt=style.AltEvery>0&&edge%style.AltEvery==style.AltEvery-1;tile=horizontal?alt?style.NAlt!:style.N:alt?style.WAlt!:style.W;} var key=(x,y,horizontal);if(edges.TryGetValue(key,out var prior)&&prior.Gate&&!gate){Skip("overlap");continue;} if(edges.ContainsKey(key))Skip("overlap");edges[key]=(tile,style.Id,gate,f.Id); + if(gate&&(horizontal?style.GateSpaceN:style.GateSpaceW) is {} frame)frames[key]=(frame,f.Id);else frames.Remove(key); } } if(f.PostSpacing>0)Post(f.Points[^1].X,f.Points[^1].Y); @@ -100,8 +104,9 @@ public static Composition Compose(IEnumerable lines,IReadOnlyCollecti var result=new List(); foreach(var group in edges.GroupBy(e=>(e.Key.X,e.Key.Y))){ var north=group.FirstOrDefault(e=>e.Key.North);var west=group.FirstOrDefault(e=>!e.Key.North); - var bothPlain=north.Value.Tile is not null&&west.Value.Tile is not null&&!north.Value.Gate&&!west.Value.Gate&&north.Value.Style==west.Value.Style&&styleById[north.Value.Style] is {NW:{} corner}&&north.Value.Tile==styleById[north.Value.Style].N&&west.Value.Tile==styleById[west.Value.Style].W; + var bothPlain=north.Value.Tile is not null&&west.Value.Tile is not null&&!north.Value.Gate&&!west.Value.Gate&&north.Value.Style==west.Value.Style&&styleById[north.Value.Style] is {NW:{} corner}&&styleById[north.Value.Style] is var s&&(north.Value.Tile==s.N||north.Value.Tile==s.NAlt)&&(west.Value.Tile==s.W||west.Value.Tile==s.WAlt); if(bothPlain){result.Add(new(group.Key.X,group.Key.Y,styleById[north.Value.Style].NW!,6,north.Value.Line));continue;} + foreach(var e in group.OrderBy(e=>e.Key.North))if(frames.TryGetValue(e.Key,out var frame))result.Add(new(e.Key.X,e.Key.Y,frame.Tile,0,frame.Line)); foreach(var e in group.OrderBy(e=>e.Key.North))result.Add(new(e.Key.X,e.Key.Y,e.Value.Tile,(byte)(e.Value.Gate?0:e.Key.North?2:4),e.Value.Line)); } foreach(var p in posts)result.Add(new(p.Key.X,p.Key.Y,p.Value.Tile,0,p.Value.Line)); diff --git a/src/SimplePZMapmaker.Core/GeographicImporter.Appearance.cs b/src/SimplePZMapmaker.Core/GeographicImporter.Appearance.cs index 7938fde..4fb0e2f 100644 --- a/src/SimplePZMapmaker.Core/GeographicImporter.Appearance.cs +++ b/src/SimplePZMapmaker.Core/GeographicImporter.Appearance.cs @@ -42,7 +42,7 @@ public static void ApplyAppearanceTags(Building b,IReadOnlyDictionary public static partial class GeographicImporter { + /// The vanilla fences as TileZed's reference Fences.txt draws them (Unjammer/PZ_Mapping_Tools, GPL-2.0-or-later): + /// west1/north1 on even edges and west2/north2 on odd ones, the NW corner, the post and the gate frame under a wicket. public static readonly FenceStyle[] DefaultFenceStyles=[ - new(){Id="vanilla-sheet",W="fencing_01_40",N="fencing_01_41",NW="fencing_01_42"}, - new(){Id="vanilla-planks",W="fencing_01_10",N="fencing_01_9",NW="fencing_01_12"}, - new(){Id="vanilla-picket",W="fencing_01_4",N="fencing_01_5",NW="fencing_01_6"}, - new(){Id="vanilla-wire",W="fencing_01_20",N="fencing_01_21",NW="fencing_01_28"}]; - /// The solid wooden wicket of the base game: closed W, closed N. - public static readonly GateStyle DefaultWicket=new(){Id="vanilla-wicket",Width=1,ClosedW=["fixtures_doors_fences_01_12"],ClosedN=["fixtures_doors_fences_01_13"]}; + Reference("vanilla-sheet",40,40,41,41,42,43), // Tall Concrete + Reference("vanilla-planks",11,10,8,9,12,13,46,47), // Tall Wooden + Reference("vanilla-picket",4,4,5,5,6,7,22,23), // White Picket + Reference("vanilla-wire",27,26,24,25,28,29,30,31), // Short Chainlink + Reference("vanilla-short-planks",35,34,32,33,36,37,38,39), // Short Wooden + Reference("vanilla-tall-planks-2",72,72,73,73,75,74), // Tall Wooden 2 + Reference("vanilla-tall-wire",59,58,56,57,60,61), // Tall Chainlink + Reference("vanilla-short-picket",121,121,120,120,122,123), // Short Picket + Reference("vanilla-cattle",20,20,21,21,18,19), // Short Cattle Fence + Reference("vanilla-black-metal",2,2,1,1,3,0,14,15), // Short Black Metal + Reference("vanilla-black-bars",67,66,64,65,68,69), // Tall Black Bars + Reference("vanilla-military",51,50,48,49,52,53), // Military Grade + Reference("vanilla-construction",83,82,80,81,84,null), // Tall Construction + Reference("vanilla-construction-barbed",91,90,88,89,92,null)]; // Tall Construction (Barbed Wire) + static FenceStyle Reference(string id,int w1,int w2,int n1,int n2,int nw,int? post,int? spaceW=null,int? spaceN=null){static string T(int i)=>"fencing_01_"+i; + return new(){Id=id,W=T(w1),N=T(n1),WAlt=w2==w1?null:T(w2),NAlt=n2==n1?null:T(n2),AltEvery=w2==w1&&n2==n1?0:2,NW=T(nw),Post=post is {} p?T(p):null,GateSpaceW=spaceW is {} sw?T(sw):null,GateSpaceN=spaceN is {} sn?T(sn):null};} + /// The solid wooden wicket of the base game: closed W, closed N, open W, open N. + public static readonly GateStyle DefaultWicket=Wicket("vanilla-wicket",12); + /// The fence wickets of fixtures_doors_fences_01, four pieces each; TileZed pairs them with these fences. + public static readonly GateStyle[] DefaultGateStyles=[DefaultWicket,Wicket("vanilla-wicket-metal",0),Wicket("vanilla-wicket-short",4),Wicket("vanilla-wicket-picket",8),Wicket("vanilla-wicket-wire",16)]; + static readonly Dictionary ReferenceWickets=new(StringComparer.Ordinal){["vanilla-planks"]="vanilla-wicket",["vanilla-tall-planks-2"]="vanilla-wicket",["vanilla-short-planks"]="vanilla-wicket-short", + ["vanilla-picket"]="vanilla-wicket-picket",["vanilla-wire"]="vanilla-wicket-wire",["vanilla-black-metal"]="vanilla-wicket-metal"}; + static GateStyle Wicket(string id,int first){static string T(int i)=>"fixtures_doors_fences_01_"+i;return new(){Id=id,Width=1,ClosedW=[T(first)],ClosedN=[T(first+1)],OpenW=[T(first+2)],OpenN=[T(first+3)]};} + /// The wicket drawn in a fence of the given style when the source names none. + public static string WicketFor(string fenceStyle)=>ReferenceWickets.GetValueOrDefault(fenceStyle,DefaultWicket.Id); static readonly HashSet FenceBarriers=["fence","wall","city_wall","retaining_wall"]; static readonly HashSet GateBarriers=["gate","wicket_gate","swing_gate","lift_gate","sliding_gate","hampshire_gate"]; static string DefaultFenceStyle(Dictionary tags)=>tags.GetValueOrDefault("fence_type") switch{ - "metal_sheet" or "sheet" or "corrugated" or "metal_bars" or "panel"=>"vanilla-sheet", - "chain_link" or "wire" or "mesh" or "barbed_wire" or "electric"=>"vanilla-wire", - "pickets" or "picket" or "split_rail" or "railing"=>"vanilla-picket", + "metal_sheet" or "sheet" or "panel" or "concrete"=>"vanilla-sheet", + "corrugated"=>"vanilla-construction", + "metal_bars" or "railing"=>"vanilla-black-bars", + "metal"=>"vanilla-black-metal", + "chain_link" or "wire" or "mesh"=>"vanilla-wire", + "barbed_wire"=>"vanilla-construction-barbed", + "electric" or "split_rail" or "rail"=>"vanilla-cattle", + "pickets" or "picket"=>"vanilla-picket", + "hedge_bank" or "wood" or "wooden" or "planks"=>"vanilla-planks", _=>tags.GetValueOrDefault("barrier") is "wall" or "city_wall" or "retaining_wall"?"vanilla-sheet":"vanilla-planks"}; /// Walks a projected polyline over grid vertices with unit steps and merges them into horizontal/vertical runs. @@ -64,19 +91,19 @@ static void ImportFences(MapProject p,List transformed){ if(index==0&&f.Tags.TryGetValue("pz:gates",out var gatesJson)){ try{foreach(var g in JsonDocument.Parse(gatesJson).RootElement.EnumerateArray()){ var at=g.TryGetProperty("at",out var a)?a.GetDouble():0;var gateStyle=g.TryGetProperty("style",out var s)?s.GetString():null; - if(gateStyle is null||p.GateStyles.All(x=>x.Id!=gateStyle))gateStyle=DefaultWicket.Id; + if(gateStyle is null||p.GateStyles.All(x=>x.Id!=gateStyle)&&DefaultGateStyles.All(x=>x.Id!=gateStyle))gateStyle=WicketFor(style); if(NearestEdge(points,PointAlong(ring,at/mpt),2) is not {} gateEdge){droppedGates++;continue;} line.Gates.Add(new(){At=gateEdge,Style=gateStyle!,OpensTo=g.TryGetProperty("opens",out var o)&&o.GetString()=="left"?"left":"right"});}} catch(Exception e) when(e is JsonException or InvalidOperationException or FormatException){droppedGates++;} } // barrier=gate points within a tile of the fence: a wicket on the nearest edge - foreach(var gp in gatePoints){var edge=NearestEdge(points,gp,1);if(edge is {} found&&line.Gates.All(g=>g.At!=found))line.Gates.Add(new(){At=found,Style=DefaultWicket.Id});} + foreach(var gp in gatePoints){var edge=NearestEdge(points,gp,1);if(edge is {} found&&line.Gates.All(g=>g.At!=found))line.Gates.Add(new(){At=found,Style=WicketFor(style)});} lines.Add(line); } } if(lines.Count==0)return; var styles=p.FenceStyles.Concat(DefaultFenceStyles.Where(d=>p.FenceStyles.All(s=>s.Id!=d.Id)&&lines.Any(l=>l.Style==d.Id))).ToList(); - var gates=p.GateStyles.Concat(lines.Any(l=>l.Gates.Any(g=>g.Style==DefaultWicket.Id))&&p.GateStyles.All(g=>g.Id!=DefaultWicket.Id)?[DefaultWicket]:[]).ToList(); + var gates=p.GateStyles.Concat(DefaultGateStyles.Where(d=>p.GateStyles.All(g=>g.Id!=d.Id)&&lines.Any(l=>l.Gates.Any(g=>g.Style==d.Id)))).ToList(); foreach(var line in lines){try{FenceLines.Validate([line],styles,gates,258048);}catch(ArgumentException){droppedGates+=line.Gates.Count;line.Gates=[];}} // with Preserve edits the previous fence layer was copied over: authored lines stay, source lines are refreshed var kept=p.Fences.Where(old=>old.SourceId is null||lines.All(l=>l.SourceId!=old.SourceId)).ToList(); diff --git a/src/SimplePZMapmaker.Core/SimplePZMapmaker.Core.csproj b/src/SimplePZMapmaker.Core/SimplePZMapmaker.Core.csproj index 13cc4a2..3bba1db 100644 --- a/src/SimplePZMapmaker.Core/SimplePZMapmaker.Core.csproj +++ b/src/SimplePZMapmaker.Core/SimplePZMapmaker.Core.csproj @@ -1 +1 @@ -net10.0enableenabletrue +net10.0enableenabletrue diff --git a/src/SimplePZMapmaker.Core/WallCatalog.cs b/src/SimplePZMapmaker.Core/WallCatalog.cs new file mode 100644 index 0000000..5c45d8e --- /dev/null +++ b/src/SimplePZMapmaker.Core/WallCatalog.cs @@ -0,0 +1,27 @@ +using System.Text.Json; +namespace SimplePZMapmaker.Core; + +/// One exterior wall set as TileZed's BuildingEditor draws it: the W and N edges, the NW corner piece that replaces both +/// edges in one square, the SE pillar that closes an outer corner, and the window and door edges. +public sealed record WallSet(string West,string North,string? NorthWest,string? SouthEast,string? WestWindow,string? NorthWindow,string? WestDoor,string? NorthDoor) +{ + /// The edge tile of a wall, window or door edge, or null when the set has none. + public string? Edge(string kind,bool north)=>kind switch{"door"=>north?NorthDoor:WestDoor,"window"=>north?NorthWindow:WestWindow,_=>north?North:West}; +} + +/// The exterior wall sets of TileZed's BuildingTiles.txt (wwwroot/wall-catalog.json, GPL-2.0-or-later). Most sets put the +/// window at +8 and the door at +10 from the west wall, but 42 of them do not, so tiles are looked up here before that rule is used. +public static class WallCatalog +{ + public static readonly IReadOnlyList Sets=Load(); + static readonly Dictionary ByTile=Index(); + static List Load(){using var s=typeof(WallCatalog).Assembly.GetManifestResourceStream("SimplePZMapmaker.Core.WallCatalog.json")!;using var d=JsonDocument.Parse(s); + return d.RootElement.GetProperty("sets").Deserialize>(new JsonSerializerOptions(JsonSerializerDefaults.Web))!;} + static Dictionary Index(){var index=new Dictionary(StringComparer.Ordinal); + foreach(var set in Sets)foreach(var tile in new[]{set.West,set.North,set.WestWindow,set.NorthWindow,set.WestDoor,set.NorthDoor})if(tile is not null)index.TryAdd(tile,set);return index;} + /// The set a wall, window or door edge tile belongs to. + public static WallSet? Find(string? tile)=>tile is not null&&ByTile.TryGetValue(tile,out var set)?set:null; + /// The edge tile of the set whose west wall is _; outside the catalog, the +8 window and +10 door rule. + public static string Tile(string prefix,int first,string kind,bool north)=> + Find($"{prefix}_{first}") is {} set&&set.West==$"{prefix}_{first}"&&set.Edge(kind,north) is {} tile?tile:$"{prefix}_{first+(kind=="door"?10:kind=="window"?8:0)+(north?1:0)}"; +} diff --git a/src/SimplePZMapmaker.Web/Services/AssetRules.cs b/src/SimplePZMapmaker.Web/Services/AssetRules.cs index 481e58d..355b619 100644 --- a/src/SimplePZMapmaker.Web/Services/AssetRules.cs +++ b/src/SimplePZMapmaker.Web/Services/AssetRules.cs @@ -4,7 +4,7 @@ public static partial class AssetRules { public static void Building(Building b,GameAssetCatalog assets,bool requireAccess=true){BuildingDesigner.Validate(b,requireAccess);if(b.Design is null){StampDependencies(b,assets);return;}var d=b.Design; void Floor(string id){var a=assets.Get(id);if(!a.Floor||a.Properties.ContainsKey("water"))throw new ArgumentException("An indoor tile must have a complete dry floor surface.");} - if(d.Style.Length>0){var s=VisualStyles.All[d.Style];Floor(s.Floor);Floor(s.WetFloor);foreach(var direction in new[]{"n","w"})foreach(var kind in new[]{"wall","door","window"})CheckEdge(assets,s.Wall+"_"+(s.WallBase+(kind=="door"?10:kind=="window"?8:0)+(direction=="n"?1:0)),null,direction,kind);} + if(d.Style.Length>0){var s=VisualStyles.All[d.Style];Floor(s.Floor);Floor(s.WetFloor);foreach(var direction in new[]{"n","w"})foreach(var kind in new[]{"wall","door","window"})CheckEdge(assets,WallCatalog.Tile(s.Wall,s.WallBase,kind,direction=="n"),null,direction,kind);} if(d.RoofTileset is not null)foreach(var i in Enumerable.Range(0,30).Where(i=>i%8<6))if(assets.Get(d.RoofTileset+"_"+i).Category!="roofs")throw new ArgumentException("A complete 30-degree roof tileset is required."); if(d.FlatRoofTile is not null)Floor(d.FlatRoofTile); foreach(var f in d.Floors){if(f.FloorTile is not null)Floor(f.FloorTile);foreach(var id in f.FloorTiles.Values)Floor(id);foreach(var e in f.Edges){if(e.WallTile is not null||e.OpeningTile is not null)CheckEdge(assets,e.WallTile,e.OpeningTile,e.Edge,e.Kind);if(e.OverlayTile is not null)CheckOverlay(assets,e.OverlayTile,e.Edge);}foreach(var o in f.Objects.Where(o=>o.Kind=="asset"))CheckParts(assets,o.Asset!,o.Parts);}StampDependencies(b,assets); diff --git a/src/SimplePZMapmaker.Web/Services/FenceAuthoringEndpoints.cs b/src/SimplePZMapmaker.Web/Services/FenceAuthoringEndpoints.cs index c319986..03fc9ce 100644 --- a/src/SimplePZMapmaker.Web/Services/FenceAuthoringEndpoints.cs +++ b/src/SimplePZMapmaker.Web/Services/FenceAuthoringEndpoints.cs @@ -8,7 +8,7 @@ public sealed record Request(MapProject Project,FenceLine Line,FenceStyle? Style public sealed record Result(MapProject Project,Dictionary Skipped,int Pieces); public static void MapFenceAuthoringEndpoints(this WebApplication app) { - app.MapGet("/api/fences/catalog",()=>new{styles=GeographicImporter.DefaultFenceStyles,wicket=GeographicImporter.DefaultWicket,maximumPostSpacing=64,geometry="cardinal-tile-edges"}); + app.MapGet("/api/fences/catalog",()=>new{styles=GeographicImporter.DefaultFenceStyles,wicket=GeographicImporter.DefaultWicket,gates=GeographicImporter.DefaultGateStyles,maximumPostSpacing=64,geometry="cardinal-tile-edges"}); app.MapPost("/api/fences/put",(Request request,GameAssetCatalog assets)=>Build(request,assets)); } public static MapProject Prepare(Request request) @@ -20,7 +20,7 @@ public static MapProject Prepare(Request request) var line=NeighborhoodPlanner.Copy(request.Line);var style=request.Style??GeographicImporter.DefaultFenceStyles.FirstOrDefault(s=>s.Id==line.Style); if(style is not null){if(style.Id!=line.Style)throw new ArgumentException("The selected fence style does not match the line.");Merge(candidate.FenceStyles,style,s=>s.Id);} foreach(var gate in request.GateStyles??[]){if(gate is null)throw new ArgumentException("A gate style is missing.");Merge(candidate.GateStyles,gate,g=>g.Id);} - if(line.Gates?.Any(g=>g?.Style==GeographicImporter.DefaultWicket.Id)==true&&candidate.GateStyles.All(g=>g.Id!=GeographicImporter.DefaultWicket.Id))Merge(candidate.GateStyles,GeographicImporter.DefaultWicket,g=>g.Id); + foreach(var reference in GeographicImporter.DefaultGateStyles)if(line.Gates?.Any(g=>g?.Style==reference.Id)==true&&candidate.GateStyles.All(g=>g.Id!=reference.Id))Merge(candidate.GateStyles,reference,g=>g.Id); candidate.Fences.RemoveAll(f=>f.Id==line.Id);candidate.Fences.Add(line);candidate.Version=Math.Max(candidate.Version,12);FenceLines.Validate(candidate); if(line.Points.Any(p=>p.X<0||p.Y<0||p.X>candidate.Width||p.Y>candidate.Height))throw new ArgumentException("Draw the fence on tile edges inside the active section. Shared world fences are edited in their authoritative world."); for(var i=1;istructuredClone(value); -export function fenceCatalogOptions(project,catalog){const merge=(saved,builtin)=>[...(saved??[]),...(builtin??[]).filter(s=>!(saved??[]).some(p=>p.id===s.id))];return{styles:merge(project.fenceStyles,catalog.styles),gates:merge(project.gateStyles,catalog.wicket?[catalog.wicket]:[])};} +export function fenceCatalogOptions(project,catalog){const merge=(saved,builtin)=>[...(saved??[]),...(builtin??[]).filter(s=>!(saved??[]).some(p=>p.id===s.id))];return{styles:merge(project.fenceStyles,catalog.styles),gates:merge(project.gateStyles,catalog.gates??(catalog.wicket?[catalog.wicket]:[]))};} export function buildFenceProposal(project,draft,{styleId,postSpacing=0,gate=false,gateAt=0,opensTo='right',gateStyleId}={},catalog={}){ if(!project||draft?.kind!=='fence'||(draft.level??0)!==0)throw Error('Fence lines are placed on ground-level tile edges.'); if(draft.frame&&['worldX','worldY','width','height'].some(k=>draft.frame[k]!==project[k]))throw Error('This fence draft belongs to a different section coordinate frame.'); diff --git a/src/SimplePZMapmaker.Web/wwwroot/fence-data.js b/src/SimplePZMapmaker.Web/wwwroot/fence-data.js index 4ce8a56..d6b0726 100644 --- a/src/SimplePZMapmaker.Web/wwwroot/fence-data.js +++ b/src/SimplePZMapmaker.Web/wwwroot/fence-data.js @@ -6,7 +6,7 @@ export function validateFenceLines(project){ const fail=message=>{throw Error('Fences: '+message);},lines=list(project.fences),styles=list(project.fenceStyles),gates=list(project.gateStyles); if(!Array.isArray(lines)||lines.length>100000||!Array.isArray(styles)||styles.length>128||!Array.isArray(gates)||gates.length>128)fail('up to 100,000 lines and 128 fence/gate styles are supported.'); if((lines.length||styles.length||gates.length)&&project.version<12)fail('line authoring requires project format 12.'); - const byStyle=new Map(),byGate=new Map();for(const s of styles){if(!s||!safeId(s.id)||byStyle.has(s.id)||!tileId(s.w)||!tileId(s.n)||['nw','post','wAlt','nAlt'].some(k=>s[k]!=null&&!tileId(s[k]))||!integer(s.altEvery??0,0,64)||(s.altEvery??0)>0&&(!s.wAlt||!s.nAlt))fail('each fence style needs a unique ID, W/N tiles and complete repeating alternatives.');byStyle.set(s.id,s);} + const byStyle=new Map(),byGate=new Map();for(const s of styles){if(!s||!safeId(s.id)||byStyle.has(s.id)||!tileId(s.w)||!tileId(s.n)||['nw','post','wAlt','nAlt','gateSpaceW','gateSpaceN'].some(k=>s[k]!=null&&!tileId(s[k]))||!integer(s.altEvery??0,0,64)||(s.altEvery??0)>0&&(!s.wAlt||!s.nAlt))fail('each fence style needs a unique ID, W/N tiles and complete repeating alternatives.');byStyle.set(s.id,s);} for(const g of gates){const width=g?.width??1,cw=list(g?.closedW),cn=list(g?.closedN),ow=list(g?.openW),on=list(g?.openN);if(!g||!safeId(g.id)||byGate.has(g.id)||!integer(width,1,8)||![cw,cn,ow,on].every(Array.isArray)||cw.length!==width||cn.length!==width||ow.length!==0&&ow.length!==width||on.length!==ow.length||[...cw,...cn,...ow,...on].some(t=>!tileId(t)))fail('each gate style needs matching closed/open W/N pieces for its width of 1–8 edges.');byGate.set(g.id,{...g,width,openW:ow});} const ids=new Set();let total=0;for(const f of lines){if(!f||typeof f.id!=='string'||!f.id.trim()||f.id.length>100||/[\x00-\x1f\x7f]/.test(f.id)||ids.has(f.id)||!byStyle.has(f.style)||!integer(f.postSpacing??0,0,64)||!Array.isArray(f.points)||f.points.length<2||f.points.length>512||f.points.some(p=>!p||!integer(p.x,-258048,258048)||!integer(p.y,-258048,258048))||f.sourceId!=null&&(typeof f.sourceId!=='string'||f.sourceId.length>100))fail('each line needs a unique ID, known style and 2–512 bounded integer vertices.');ids.add(f.id); const tags=list(f.sourceTags,{}),path=list(f.sourcePath),fg=list(f.gates);if(!tags||typeof tags!=='object'||Array.isArray(tags)||Object.keys(tags).length>48||Object.entries(tags).some(([k,v])=>!k.length||k.length>100||typeof v!=='string'||v.length>300)||!Array.isArray(path)||path.length>512||path.some(p=>!p||!Number.isFinite(p.longitude)||!Number.isFinite(p.latitude)||Math.abs(p.longitude)>180||Math.abs(p.latitude)>85)||!Array.isArray(fg)||fg.length>64)fail('source evidence or gate list is damaged.'); diff --git a/src/SimplePZMapmaker.Web/wwwroot/layer-edit-guards.js b/src/SimplePZMapmaker.Web/wwwroot/layer-edit-guards.js index 5ed3f0f..769b8c4 100644 --- a/src/SimplePZMapmaker.Web/wwwroot/layer-edit-guards.js +++ b/src/SimplePZMapmaker.Web/wwwroot/layer-edit-guards.js @@ -20,7 +20,7 @@ export function nativeLayer(node,project,shared=[]){ return node.z!==0?'paths':terrainLayer(project.terrain?.[node.y*project.width+node.x]); } export function nativeLayerClassifier(project,shared=[]){ - const fenceTiles=new Set((project.fenceStyles??[]).flatMap(s=>[s.w,s.n,s.nw,s.post,s.wAlt,s.nAlt]).filter(Boolean).concat((project.gateStyles??[]).flatMap(s=>[...s.closedW,...s.closedN,...s.openW,...s.openN]))); + const fenceTiles=new Set((project.fenceStyles??[]).flatMap(s=>[s.w,s.n,s.nw,s.post,s.wAlt,s.nAlt,s.gateSpaceW,s.gateSpaceN]).filter(Boolean).concat((project.gateStyles??[]).flatMap(s=>[...s.closedW,...s.closedN,...s.openW,...s.openN]))); const owner=new Uint8Array(project.width*project.height),write=(x,y,code)=>{if(x>=0&&y>=0&&x Cases(){ + yield return("reference fences alternate west1/west2, frame their wickets and keep corners on alternate pieces",()=>{ + var planks=GeographicImporter.DefaultFenceStyles.Single(s=>s.Id=="vanilla-planks");var wicket=GeographicImporter.DefaultGateStyles.Single(g=>g.Id==GeographicImporter.WicketFor("vanilla-planks")); + Check(GeographicImporter.DefaultFenceStyles.Length==14&&GeographicImporter.DefaultGateStyles.All(g=>g.OpenW.Count==1),"TileZed reference sets are incomplete"); + var line=new FenceLine{Id="a",Style=planks.Id,Points=[new(10,10),new(14,10)],Gates=[new(){At=1,Style=wicket.Id}]};var corner=new FenceLine{Id="b",Style=planks.Id,Points=[new(20,25),new(20,20),new(25,20)]}; + FenceLines.Validate([line,corner],[planks],[wicket],258048);var pieces=FenceLines.Compose([line,corner],[planks],[wicket],256,256).Pieces; + string[] At(int x,int y)=>pieces.Where(p=>p.X==x&&p.Y==y).Select(p=>p.Tile).ToArray(); + Check(At(10,10).SequenceEqual(["fencing_01_8"])&&At(12,10).SequenceEqual(["fencing_01_8"])&&At(13,10).SequenceEqual(["fencing_01_9"]),"north1/north2 do not alternate"); + Check(At(11,10).SequenceEqual(["fencing_01_47","fixtures_doors_fences_01_13"]),"The wicket needs its gate frame under it"); + Check(At(20,20).SequenceEqual(["fencing_01_12"]),"An alternate W and N piece should still meet in the NW corner"); + }); + } + static void Check(bool ok,string message){if(!ok)throw new Exception(message);} +} +public static class WallCatalogTests +{ + public static IEnumerable<(string,Action)> Cases(){ + yield return("wall catalog draws NW corner pieces, SE pillars and the real door tiles of irregular sets",()=>{ + Check(WallCatalog.Sets.Count>150&&WallCatalog.Find("walls_exterior_house_01_0")?.NorthWest=="walls_exterior_house_01_2","TileZed exterior wall catalog is missing"); + Check(WallCatalog.Tile("location_restaurant_diner_01",0,"door",true)=="location_restaurant_diner_01_5"&&WallCatalog.Tile("walls_exterior_house_01",16,"window",false)=="walls_exterior_house_01_24","Catalog tiles were not used before the +8/+10 rule"); + var p=MapGenerator.Generate(new(){Kind="blank"});var b=new Building{X=100,Y=100,Width=10,Height=8};b.Design=BuildingDesigner.Generate(b,1,42);p.Buildings.Add(b);ProjectValidator.Validate(p); + string[] At(int x,int y)=>NativeScene.Cell(p,x,y,1,1,0).Sprites.Select(s=>s.Tile).ToArray(); + var nw=b.Design.Floors[0].Edges.Single(e=>e.X==0&&e.Y==0&&e.Edge=="n");var next=b.Design.Floors[0].Edges.Single(e=>e.X==1&&e.Y==0&&e.Edge=="n");(nw.Kind,next.Kind,nw.OpeningTile,next.OpeningTile)=(next.Kind,nw.Kind,next.OpeningTile,nw.OpeningTile); + Check(At(100,100).SequenceEqual(["floors_interior_tilesandwood_01_0","walls_exterior_house_01_34"])||At(100,100).Count(t=>WallCatalog.Sets.Any(w=>w.NorthWest==t))==1&&!At(100,100).Any(t=>WallCatalog.Find(t) is {} w&&(t==w.North||t==w.West)),"A square with plain N and W walls should hold one corner piece instead: "+string.Join(",",At(100,100))); + Check(At(110,108).Any(t=>WallCatalog.Sets.Any(s=>s.SouthEast==t)),"The outer SE corner needs its pillar: "+string.Join(",",At(110,108))); + var problems=new List();GeographicImporter.ApplyAppearanceTags(b,new Dictionary{{"pz:wall","location_restaurant_diner_01_0"}},null,problems.Add); + var doors=b.Design!.Floors[0].Edges.Where(e=>e.Kind=="door"&&e.WallTile is not null).ToList();Check(problems.Count==0&&doors.Count>0&&doors.All(e=>e.WallTile==(e.Edge=="n"?"location_restaurant_diner_01_5":"location_restaurant_diner_01_4")),"pz:wall should take the diner's own door walls"); + }); + } + static void Check(bool ok,string message){if(!ok)throw new Exception(message);} +} diff --git a/tests/SimplePZMapmaker.Tests/Program.cs b/tests/SimplePZMapmaker.Tests/Program.cs index 26bb5b0..d63b5a7 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()).Concat(SmallBuildingTests.Cases()).Concat(SharedFenceImportTests.Cases()).Concat(SharedDecorationTests.Cases()).Concat(AppearanceTagTests.Cases()).Concat(AttachedBuildingTests.Cases()).Concat(ModPackageTests.Cases()).Concat(TilesetBuilderTests.Cases()).Concat(B42MapDataTests.Cases()).Concat(RoomCatalogTests.Cases()).Concat(ZoneImportTests.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()).Concat(B42MapDataTests.Cases()).Concat(RoomCatalogTests.Cases()).Concat(ZoneImportTests.Cases()).Concat(ReferenceFenceTests.Cases()).Concat(WallCatalogTests.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; From 0273166113e6085b4506b739e08fcf33002eeaf8 Mon Sep 17 00:00:00 2001 From: Feliks Solovy Date: Sat, 17 Oct 2026 23:32:15 +0300 Subject: [PATCH 05/15] feat: spawn inside houses with every Build 42 profession spawnpoints.lua now lists ground-floor points in houses, gives all 25 vanilla profession keys, and adds workplaces for matching professions. server-check loads the spawn regions as the game does and counts points inside buildings (PilnaVillage: 256 of 256). Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- docs/CLI.md | 2 +- src/SimplePZMapmaker.Cli/Program.cs | 1 + src/SimplePZMapmaker.Cli/ServerCheck.cs | 14 ++++- .../Export/NativeMapExporter.cs | 4 +- .../Export/NativeStreamedWorldExporter.cs | 6 +- .../Export/NativeWorldExporter.cs | 6 +- .../Export/SpawnPlanner.cs | 58 +++++++++++++++++++ .../SimplePZMapmaker.Tests/B42MapDataTests.cs | 18 ++++++ tests/SimplePZMapmaker.Tests/Program.cs | 2 +- 10 files changed, 100 insertions(+), 13 deletions(-) create mode 100644 src/SimplePZMapmaker.Core/Export/SpawnPlanner.cs diff --git a/README.md b/README.md index f5a4a80..569644f 100644 --- a/README.md +++ b/README.md @@ -308,7 +308,7 @@ History is bounded/sparse, so large actions can remove older entries. A world re 4. Create a **new game** using the exported map. Existing saves are not migrated. 5. Enable overlapping example mods separately, or relocate and re-export them. -Exports include Build 42 `mod.info`, `common/`, native `.lotheader/.lotpack/chunkdata_*.bin`, room/building definitions, spawn points, zones, world map XML, editable project, attribution and installation instructions. Name/seed determine the mod ID; changing either creates a different ID. Origin defaults to native cell 100,100. +Exports include Build 42 `mod.info`, `common/`, native `.lotheader/.lotpack/chunkdata_*.bin`, room/building definitions, spawn points, zones, biome maps (`maps/biomemap_X_Y.png`, which Build 42 needs for foraging zones), world map XML, editable project, attribution and installation instructions. Like the vanilla maps, `spawnpoints.lua` starts players inside houses (the living room, kitchen or bedroom of every building with a bedroom, up to 256), gives all 25 Build 42 professions a key, and adds workplaces such as a police office or a restaurant kitchen for matching professions. Authored spawns are used only when the map has no house. Name/seed determine the mod ID; changing either creates a different ID. Origin defaults to native cell 100,100. ## Build from source diff --git a/docs/CLI.md b/docs/CLI.md index 8563199..91aa9a4 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -12,7 +12,7 @@ Run it from the repository with `dotnet run --project src/SimplePZMapmaker.Cli - | `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. | -| `server-check --game ` | Starts the installed Build 42 dedicated server (`zombie.network.GameServer`, `-nosteam`) in a temporary cache folder with the mod and a test-only Lua probe. The probe asks the server's own metagrid for buildings at room tiles taken from the archive's authoring source, plus one empty control tile; the server is then stopped with `quit`. Your `Zomboid` folder is not touched. | +| `server-check --game ` | Starts the installed Build 42 dedicated server (`zombie.network.GameServer`, `-nosteam`) in a temporary cache folder with the mod and a test-only Lua probe. The probe asks the server's own metagrid for buildings at room tiles taken from the archive's authoring source, plus one empty control tile, and loads the map's spawn regions as the game does (profession keys, spawn points and how many lie in a building); the server is then stopped with `quit`. Your `Zomboid` folder is not touched. | Common options: diff --git a/src/SimplePZMapmaker.Cli/Program.cs b/src/SimplePZMapmaker.Cli/Program.cs index 1bd2953..6742798 100644 --- a/src/SimplePZMapmaker.Cli/Program.cs +++ b/src/SimplePZMapmaker.Cli/Program.cs @@ -60,6 +60,7 @@ static int Run(string[] args){ case "server-check":{ var minutes=int.TryParse(Option("--timeout-minutes"),out var m)?m:10;var result=ServerCheck.Run(input,Option("--game")??throw new ArgumentException("server-check needs --game ."),TimeSpan.FromMinutes(minutes),Console.WriteLine); for(var i=0;i0} z?z:"none")}{(building==point.ExpectBuilding?"":" UNEXPECTED")}");} + foreach(var s in result.Spawns)Console.WriteLine($"server spawn region {s.Region}: {s.Professions} professions, {s.Points} spawn points, {s.Inside} inside buildings"); Console.WriteLine($"server started: {result.Started}; stopped cleanly: {result.Stopped}; {(result.Passed?"server check: passed":"server check: FAILED")}"); if(!result.Passed)File.WriteAllText(Path.ChangeExtension(input,null)+".server.log",result.Log); return result.Passed?0:1;} diff --git a/src/SimplePZMapmaker.Cli/ServerCheck.cs b/src/SimplePZMapmaker.Cli/ServerCheck.cs index 72248ca..ba25b40 100644 --- a/src/SimplePZMapmaker.Cli/ServerCheck.cs +++ b/src/SimplePZMapmaker.Cli/ServerCheck.cs @@ -18,7 +18,9 @@ public sealed record Result(bool Started,bool Stopped,List<(Point Point,bool Bui { /// Metagrid zone types the server reports at each probe point (foraging zones from the biome map included). public Dictionary Zones {get;init;}=[]; - public bool Passed=>Started&&Stopped&&Probes.Count>0&&Probes.All(p=>p.Building==p.Point.ExpectBuilding); + /// Spawn regions as the server loads them: profession keys, unemployed points and how many of those lie in a building. + public List<(string Region,int Professions,int Points,int Inside)> Spawns {get;init;}=[]; + public bool Passed=>Started&&Stopped&&Probes.Count>0&&Probes.All(p=>p.Building==p.Point.ExpectBuilding)&&Spawns.Count>0&&Spawns.All(s=>s.Points>0); } /// Up to ground-floor room tiles spread over the sections (and shared buildings), and one tile without a building. public static List Points(ZipArchive zip,int count=12){ @@ -66,7 +68,8 @@ public static Result Run(string archive,string game,TimeSpan timeout,Action(); foreach(Match m in Regex.Matches(text,@"SIMPLEPZ_PROBE (\d+) (true|false) (\S+)")){var i=int.Parse(m.Groups[1].Value);if(iint.Parse(m.Groups[1].Value),m=>m.Groups[2].Value); - return new(ok&&started.Task.IsCompleted,stopped,probes,text){Zones=zones}; + var spawns=Regex.Matches(text,@"SIMPLEPZ_SPAWNS (\S+) (\d+) (\d+) (\d+)").Select(m=>(m.Groups[1].Value,int.Parse(m.Groups[2].Value),int.Parse(m.Groups[3].Value),int.Parse(m.Groups[4].Value))).ToList(); + return new(ok&&started.Task.IsCompleted,stopped,probes,text){Zones=zones,Spawns=spawns}; }finally{try{Directory.Delete(root,true);}catch(IOException){}catch(UnauthorizedAccessException){}} } static string? ReadMapFolder(string mod)=>Directory.EnumerateDirectories(mod,"maps",SearchOption.AllDirectories).SelectMany(Directory.EnumerateDirectories).Select(Path.GetFileName).FirstOrDefault(); @@ -87,6 +90,13 @@ static void WriteProbe(string folder,List points){ if ok and zones then for j = 0, zones:size() - 1 do table.insert(names, tostring(zones:get(j):getType())) end elseif not ok then table.insert(names, "error:" .. tostring(zones):gsub("%s", "_")) end print(string.format("SIMPLEPZ_ZONES %d %s", i - 1, table.concat(names, ","))) end + local loaded, regions = pcall(function() return SpawnRegionMgr.getSpawnRegionsAux() end) + if loaded and regions then for _, region in ipairs(regions) do + local professions, points, inside = 0, 0, 0 + for _ in pairs(region.points) do professions = professions + 1 end + for _, p in ipairs(region.points.unemployed or {}) do points = points + 1; if grid:getBuildingAt(p.posX, p.posY) ~= nil then inside = inside + 1 end end + print(string.format("SIMPLEPZ_SPAWNS %s %d %d %d", tostring(region.name):gsub("%s", "_"), professions, points, inside)) + end else print("SIMPLEPZ_SPAWNS_ERROR " .. tostring(regions)) end print("SIMPLEPZ_PROBE done") end) """); diff --git a/src/SimplePZMapmaker.Core/Export/NativeMapExporter.cs b/src/SimplePZMapmaker.Core/Export/NativeMapExporter.cs index 91b18c1..6829ad5 100644 --- a/src/SimplePZMapmaker.Core/Export/NativeMapExporter.cs +++ b/src/SimplePZMapmaker.Core/Export/NativeMapExporter.cs @@ -32,8 +32,8 @@ internal static void WriteValidatedWorldSection(MapProject p,Stream output,Cance Text($"{slug}/42/mod.info",$"name={p.Name}\nid={slug}\ndescription=Map created with SimplePZMapmaker. Build 42; new world.\nversionMin=42.0\n{require}"); StaticModelExporter.Write(zip,slug,StaticModelExporter.Gather(p).Where(m=>m.X>=p.WorldX*256&&m.Y>=p.WorldY*256&&m.X$" {{ posX = {p.WorldX*256+s.X}, posY = {p.WorldY*256+s.Y}, posZ = 0 }}")); - Text(root+"spawnpoints.lua",$"function SpawnPoints()\n local points = {{\n{spawns}\n }}\n return {{ unemployed = points }}\nend\n"); + + Text(root+"spawnpoints.lua",SpawnPlanner.Lua(p)); Text(root+"spawnregions.lua",$"function SpawnRegions()\n return {{ {{ name = \"{slug}\", file = \"media/maps/{slug}/spawnpoints.lua\" }} }}\nend\n"); var zones=new StringBuilder("objects = {\n"); for(var cy=0;cys.Width/256*(s.Height/256)));using(var zip=new ZipArchive(output,ZipArchiveMode.Create,true)){ void Text(string name,string value){using var writer=new StreamWriter(zip.CreateEntry(name,CompressionLevel.Fastest).Open(),new UTF8Encoding(false));writer.Write(value);} - zip.CreateEntry(slug+"/common/");var zones=new StringBuilder("objects = {\n");var atlas=new XElement("world",new XAttribute("version","1.0"),new XAttribute("cellSize",256));long atlasBytes=0,attributionBytes=0;var spawns=new List();var attributions=new HashSet(StringComparer.Ordinal);var dependencies=new SortedSet(StringComparer.Ordinal);var models=new List();var modelIds=new HashSet<(string,int,int,int)>(); + zip.CreateEntry(slug+"/common/");var zones=new StringBuilder("objects = {\n");var atlas=new XElement("world",new XAttribute("version","1.0"),new XAttribute("cellSize",256));long atlasBytes=0,attributionBytes=0;var spawns=new SpawnPlanner.Plan();var attributions=new HashSet(StringComparer.Ordinal);var dependencies=new SortedSet(StringComparer.Ordinal);var models=new List();var modelIds=new HashSet<(string,int,int,int)>(); foreach(var section in sections){ token.ThrowIfCancellationRequested();context?.Section(section.Id); await WriteSection(section); @@ -36,7 +36,7 @@ async Task WriteSection(StreamedWorldSection section){ NativeMapExporter.WriteCells(p,zip,root,token,context); var text=GameZonesExporter.ObjectsLua(p,token);if(text.Length+zones.Length>64_000_000)throw new ArgumentException("World zone descriptions exceed 64 MB. Export selected sections.");var first=text.IndexOf('{');var last=text.LastIndexOf('}');zones.Append(text.AsSpan(first+1,last-first-1)); var cellAtlas=NativeMapExporter.WorldMap(p);atlasBytes+=Encoding.UTF8.GetByteCount(cellAtlas.ToString());if(atlasBytes>64_000_000)throw new ArgumentException("World atlas descriptions exceed 64 MB. Export selected sections.");foreach(var cell in cellAtlas.Elements("cell"))atlas.Add(new XElement(cell)); - foreach(var point in p.Spawns)spawns.Add($" {{ posX = {p.WorldX*256+point.X}, posY = {p.WorldY*256+point.Y}, posZ = 0 }}"); + spawns.Add(p.Buildings,p.WorldX*256,p.WorldY*256);spawns.AddAuthored(p); foreach(var a in p.Attribution.Concat(p.Buildings.Concat(p.BuildingLibrary).SelectMany(b=>b.Attribution)))if(attributions.Add(a)){attributionBytes+=Encoding.UTF8.GetByteCount(a);if(attributionBytes>16_000_000)throw new ArgumentException("World attribution descriptions exceed 16 MB. Export selected sections.");} 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.X100000)throw new ArgumentException("World static models exceed 100,000 placements.");} @@ -44,7 +44,7 @@ async Task WriteSection(StreamedWorldSection section){ 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"); + if(spawns.Count==0&&memberId is null)throw new ArgumentException("The world needs at least one dry-land spawn.");Text(root+"spawnpoints.lua",spawns.Lua());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); diff --git a/src/SimplePZMapmaker.Core/Export/NativeWorldExporter.cs b/src/SimplePZMapmaker.Core/Export/NativeWorldExporter.cs index dc61a02..622e0cd 100644 --- a/src/SimplePZMapmaker.Core/Export/NativeWorldExporter.cs +++ b/src/SimplePZMapmaker.Core/Export/NativeWorldExporter.cs @@ -16,17 +16,17 @@ public static void ExportWithProgress(WorldProject world,Stream output,NativeExp var bounds=WorldAssembler.Validate(world);context?.Begin(world.Maps.Count,world.Maps.Sum(m=>m.Project.Width/256*(m.Project.Height/256)));var slug="SimplePZWorld_"+Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(world.Id)))[..12].ToLowerInvariant();var root=slug+"/42/media/maps/"+slug+"/"; if(!output.CanWrite||!output.CanSeek)throw new ArgumentException("World export requires a writable, seekable destination.");using(var zip=new ZipArchive(output,ZipArchiveMode.Create,true)){ void Text(string name,string value){var entry=zip.CreateEntry(name,CompressionLevel.Fastest);using var writer=new StreamWriter(entry.Open(),new UTF8Encoding(false));writer.Write(value);} - long mapTextBytes=0;zip.CreateEntry(slug+"/common/");var zones=new StringBuilder("objects = {\n");var xml=new XElement("world",new XAttribute("version","1.0"),new XAttribute("cellSize",256));var spawns=new List();var attributions=new HashSet(); + long mapTextBytes=0;zip.CreateEntry(slug+"/common/");var zones=new StringBuilder("objects = {\n");var xml=new XElement("world",new XAttribute("version","1.0"),new XAttribute("cellSize",256));var spawns=new SpawnPlanner.Plan();var attributions=new HashSet(); var sharedGeometry=world.LinearFeatures.Count>0?WorldInfrastructure.Build(world):null; foreach(var member in world.Maps){token.ThrowIfCancellationRequested();var p=WorldAssembler.ProjectSection(world,member,sharedGeometry);context?.Section(member.Id); NativeMapExporter.WriteCells(p,zip,root,token,context);context?.SectionDone(); var text=GameZonesExporter.ObjectsLua(p,token);if(text.Length+zones.Length>64_000_000)throw new ArgumentException("World zone descriptions exceed 64 MB of text; split the world into multiple mods.");var first=text.IndexOf('{');var last=text.LastIndexOf('}');zones.Append(text.AsSpan(first+1,last-first-1)); var atlas=NativeMapExporter.WorldMap(p);mapTextBytes+=Encoding.UTF8.GetByteCount(atlas.ToString());if(mapTextBytes>64_000_000)throw new ArgumentException("In-game atlas outlines exceed 64 MB; split the world into multiple mods.");foreach(var cell in atlas.Elements("cell"))xml.Add(new XElement(cell)); - foreach(var point in p.Spawns)spawns.Add($" {{ posX = {p.WorldX*256+point.X}, posY = {p.WorldY*256+point.Y}, posZ = 0 }}"); + spawns.Add(p.Buildings,p.WorldX*256,p.WorldY*256);spawns.AddAuthored(p); foreach(var a in p.Attribution.Concat(p.Buildings.Concat(p.BuildingLibrary).SelectMany(b=>b.Attribution)))attributions.Add(a); if(output.Length>256*1024*1024)throw new ArgumentException("World export exceeds 256 MB; split it into multiple mods."); } - zones.Append("}\n");Text(root+"objects.lua",zones.ToString());Text(root+"worldmap.xml",new XDocument(xml).ToString());using(var binary=zip.CreateEntry(root+"worldmap.xml.bin",CompressionLevel.Fastest).Open()){var bytes=NativeWorldMapBinary.Write(xml);binary.Write(bytes);}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"); + zones.Append("}\n");Text(root+"objects.lua",zones.ToString());Text(root+"worldmap.xml",new XDocument(xml).ToString());using(var binary=zip.CreateEntry(root+"worldmap.xml.bin",CompressionLevel.Fastest).Open()){var bytes=NativeWorldMapBinary.Write(xml);binary.Write(bytes);}Text(root+"spawnpoints.lua",spawns.Lua());Text(root+"spawnregions.lua",$"function SpawnRegions()\n return {{ {{ name = \"{slug}\", file = \"media/maps/{slug}/spawnpoints.lua\" }} }}\nend\n"); var dependencies=world.Maps.SelectMany(m=>ResourceDependencies.ForProject(m.Project)).Concat(world.Buildings.SelectMany(b=>b.RequiredMods)).Concat(world.Buildings.SelectMany(b=>b.Design?.Floors.SelectMany(f=>f.StaticModels.SelectMany(m=>m.RequiredMods.Concat(m.LootRequiredMods)))??[])).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray();var require=dependencies.Length>0?"require="+string.Join(',',dependencies)+"\n":""; StaticModelExporter.Write(zip,slug,world.Maps.SelectMany(m=>StaticModelExporter.Gather(m.Project)).Concat(WorldAssembler.GatherModels(world))); Text(slug+"/42/mod.info",$"name={world.Name}\nid={slug}\nversionMin=42.0\ndescription=World assembled from {world.Maps.Count} sections; new Build 42 world.\n{require}");Text(root+"map.info",$"title={world.Name}\nfixed2x=true\ndescription=Stitched area {bounds.Width} x {bounds.Height}; {bounds.Cells} cells.\nzoomX={bounds.X*256+bounds.Width/2}\nzoomY={bounds.Y*256+bounds.Height/2}\nzoomS=10\n"); diff --git a/src/SimplePZMapmaker.Core/Export/SpawnPlanner.cs b/src/SimplePZMapmaker.Core/Export/SpawnPlanner.cs new file mode 100644 index 0000000..8d3cea3 --- /dev/null +++ b/src/SimplePZMapmaker.Core/Export/SpawnPlanner.cs @@ -0,0 +1,58 @@ +using System.Text; +namespace SimplePZMapmaker.Core.Export; + +/// spawnpoints.lua like the vanilla maps: players start inside houses, and a profession with a workplace on the map can also +/// start there (vanilla Muldraugh adds the police station for police officers and doctors' houses for doctors). A house is a building +/// with a bedroom; its point is a free square near the middle of the living room, kitchen or bedroom on the ground floor. Every +/// Build 42 profession gets a key, since the game only falls back to unemployed in some screens. Without houses the authored +/// spawn points are used. +public static class SpawnPlanner +{ + public sealed record Point(int X,int Y,int Z); + /// The profession keys of the vanilla Build 42 spawnpoints.lua. + public static readonly string[] Professions=["unemployed","burgerflipper","burglar","carpenter","chef","constructionworker","doctor","electrician","engineer","farmer","fireofficer","fisherman","fitnessInstructor","lumberjack","mechanics","metalworker","nurse","parkranger","policeofficer","rancher","repairman","securityguard","smither","tailor","veteran"]; + static readonly Dictionary Workplaces=new(StringComparer.Ordinal){ + ["doctor"]=["medicaloffice","medicalstorage","dentist"],["nurse"]=["medicaloffice","medicalstorage"],["policeofficer"]=["policeoffice","policestorage"],["fireofficer"]=["firestorage"], + ["chef"]=["restaurantkitchen","cafekitchen","bakerykitchen"],["burgerflipper"]=["burgerkitchen"],["mechanics"]=["mechanic"],["farmer"]=["farmstorage","barn"],["rancher"]=["barn"], + ["carpenter"]=["toolstore"],["securityguard"]=["bank"],["electrician"]=["electronicstore"]}; + const int MaximumPerList=256; + + /// House points and workplace points per profession, in world tiles, for buildings offset by (dx, dy). + public sealed class Plan + { + public List Homes {get;}=[]; + public List Authored {get;}=[]; + public Dictionary> Work {get;}=new(StringComparer.Ordinal); + public void Add(IEnumerable buildings,int dx,int dy){ + foreach(var b in buildings.OrderBy(b=>b.Y).ThenBy(b=>b.X)){ + if(b.Design is not {} d||d.MinLevel>0||-d.MinLevel>=d.Floors.Count)continue;var floor=d.Floors[-d.MinLevel]; + if(floor.Rooms.ContainsValue("bedroom")||d.Floors.Any(f=>f.Rooms.ContainsValue("bedroom"))){if(Inside(b,floor,["livingroom","kitchen","bedroom"],dx,dy) is {} home&&Homes.CountHomes.Count+Authored.Count; + /// The spawnpoints.lua text. + public string Lua(){ + var everyone=Homes.Count>0?Homes:Authored;var text=new StringBuilder("function SpawnPoints()\n"); + void List(string name,IEnumerable points){text.Append(" local ").Append(name).Append(" = {\n");foreach(var p in points)text.Append(" { posX = ").Append(p.X).Append(", posY = ").Append(p.Y).Append(", posZ = ").Append(p.Z).Append(" },\n");text.Append(" }\n");} + List("houses",everyone);foreach(var (profession,points) in Work.OrderBy(w=>w.Key,StringComparer.Ordinal))List(profession+"_work",everyone.Concat(points)); + text.Append(" return {\n");foreach(var profession in Professions)text.Append(" ").Append(profession).Append(" = ").Append(Work.ContainsKey(profession)?profession+"_work":"houses").Append(",\n"); + return text.Append(" }\nend\n").ToString(); + } + } + + /// A free ground-floor square near the middle of the first of the building has. + static Point? Inside(Building b,BuildingFloor floor,string[] rooms,int dx,int dy){ + var taken=floor.Objects.SelectMany(BuildingObjects.Cells).ToHashSet(); + foreach(var name in rooms){ + var keys=floor.Rooms.Where(r=>r.Value==name&&r.Key.Length==1).Select(r=>r.Key[0]).ToHashSet();if(keys.Count==0)continue; + var cells=Enumerable.Range(0,b.Width*b.Height).Where(i=>i(X:i%b.Width,Y:i/b.Width)).ToList();if(cells.Count==0)continue; + var cx=cells.Average(c=>c.X);var cy=cells.Average(c=>c.Y); + foreach(var c in cells.OrderBy(c=>(c.X-cx)*(c.X-cx)+(c.Y-cy)*(c.Y-cy)).ThenBy(c=>c.Y).ThenBy(c=>c.X))if(!taken.Contains(c))return new(dx+b.X+c.X,dy+b.Y+c.Y,0); + } + return null; + } + /// spawnpoints.lua for one map. + public static string Lua(MapProject p){var plan=new Plan();plan.Add(p.Buildings,p.WorldX*256,p.WorldY*256);plan.AddAuthored(p);return plan.Lua();} +} diff --git a/tests/SimplePZMapmaker.Tests/B42MapDataTests.cs b/tests/SimplePZMapmaker.Tests/B42MapDataTests.cs index d282b43..d48dbe5 100644 --- a/tests/SimplePZMapmaker.Tests/B42MapDataTests.cs +++ b/tests/SimplePZMapmaker.Tests/B42MapDataTests.cs @@ -108,3 +108,21 @@ public static class WallCatalogTests } static void Check(bool ok,string message){if(!ok)throw new Exception(message);} } +public static class SpawnPlannerTests +{ + public static IEnumerable<(string,Action)> Cases(){ + yield return("players spawn inside houses with a key for every profession and workplaces for some",()=>{ + var p=MapGenerator.Generate(new(){Kind="blank"});var home=new Building{X=40,Y=40,Width=12,Height=10};home.Design=BuildingDesigner.Generate(home,1,7); + if(!home.Design.Floors[0].Rooms.ContainsValue("bedroom"))home.Design.Floors[0].Rooms[home.Design.Floors[0].Rooms.Keys.First(k=>k!="0")]="bedroom"; + var police=new Building{X=120,Y=40,Width=12,Height=10};police.Design=BuildingDesigner.Generate(police,1,8);foreach(var k in police.Design.Floors[0].Rooms.Keys.Where(k=>k!="0").ToList())police.Design.Floors[0].Rooms[k]="policeoffice"; + p.Buildings.AddRange([home,police]);ProjectValidator.Validate(p); + var lua=SpawnPlanner.Lua(p);var plan=new SpawnPlanner.Plan();plan.Add(p.Buildings,0,0);string At(MapProject m)=>$"posX = {m.WorldX*256+m.Spawns[0].X}, posY = {m.WorldY*256+m.Spawns[0].Y}"; + Check(SpawnPlanner.Professions.Length==25&&SpawnPlanner.Professions.All(n=>lua.Contains(" "+n+" = ")),"Every vanilla profession needs a key"); + var point=plan.Homes.Single();var floor=home.Design.Floors[0];var room=floor.Rooms[floor.Cells[(point.Y-40)*12+point.X-40].ToString()]; + Check(room is "livingroom" or "kitchen" or "bedroom"&&!lua.Contains(At(p)),"The spawn should be inside the house, not at the authored outdoor point: "+room); + Check(plan.Work["policeofficer"].Single().X>=120&&lua.Contains("policeofficer = policeofficer_work")&&lua.Contains("unemployed = houses"),"Police officers should also start at the police station"); + var empty=MapGenerator.Generate(new(){Kind="blank"});Check(SpawnPlanner.Lua(empty).Contains(At(empty)),"Without houses the authored spawn is used"); + }); + } + static void Check(bool ok,string message){if(!ok)throw new Exception(message);} +} diff --git a/tests/SimplePZMapmaker.Tests/Program.cs b/tests/SimplePZMapmaker.Tests/Program.cs index d63b5a7..678ed9c 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()).Concat(SmallBuildingTests.Cases()).Concat(SharedFenceImportTests.Cases()).Concat(SharedDecorationTests.Cases()).Concat(AppearanceTagTests.Cases()).Concat(AttachedBuildingTests.Cases()).Concat(ModPackageTests.Cases()).Concat(TilesetBuilderTests.Cases()).Concat(B42MapDataTests.Cases()).Concat(RoomCatalogTests.Cases()).Concat(ZoneImportTests.Cases()).Concat(ReferenceFenceTests.Cases()).Concat(WallCatalogTests.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()).Concat(B42MapDataTests.Cases()).Concat(RoomCatalogTests.Cases()).Concat(ZoneImportTests.Cases()).Concat(ReferenceFenceTests.Cases()).Concat(WallCatalogTests.Cases()).Concat(SpawnPlannerTests.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; From 21d9143f3490d4da94849fc5f49bbadb3b65b292 Mon Sep 17 00:00:00 2001 From: Feliks Solovy Date: Sat, 17 Oct 2026 23:42:20 +0300 Subject: [PATCH 06/15] feat: 5x3 vehicle zones in the editor, WorldGen protection and thumb.png The editor shrinks drawn vehicle zones to whole 5x3 stalls like the importer. Biome map squares holding authored content use red 254, the featureless dirt biome: WorldGen deletes authored trees, bushes and grass where its own biome places one. Exports carry a 256x256 thumb.png. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/SimplePZMapmaker.Core/Export/BiomeMap.cs | 24 ++++++++++--- .../Export/MapThumbnail.cs | 35 +++++++++++++++++++ .../Export/NativeMapExporter.cs | 2 +- .../Export/NativeStreamedWorldExporter.cs | 6 ++-- .../Export/NativeWorldExporter.cs | 6 ++-- .../wwwroot/gameplay-authoring.js | 4 +-- .../wwwroot/gameplay-data.js | 2 ++ .../SimplePZMapmaker.Tests/B42MapDataTests.cs | 4 ++- tests/gameplay-authoring.test.mjs | 3 ++ 10 files changed, 73 insertions(+), 15 deletions(-) create mode 100644 src/SimplePZMapmaker.Core/Export/MapThumbnail.cs diff --git a/README.md b/README.md index 569644f..0c84a42 100644 --- a/README.md +++ b/README.md @@ -308,7 +308,7 @@ History is bounded/sparse, so large actions can remove older entries. A world re 4. Create a **new game** using the exported map. Existing saves are not migrated. 5. Enable overlapping example mods separately, or relocate and re-export them. -Exports include Build 42 `mod.info`, `common/`, native `.lotheader/.lotpack/chunkdata_*.bin`, room/building definitions, spawn points, zones, biome maps (`maps/biomemap_X_Y.png`, which Build 42 needs for foraging zones), world map XML, editable project, attribution and installation instructions. Like the vanilla maps, `spawnpoints.lua` starts players inside houses (the living room, kitchen or bedroom of every building with a bedroom, up to 256), gives all 25 Build 42 professions a key, and adds workplaces such as a police office or a restaurant kitchen for matching professions. Authored spawns are used only when the map has no house. Name/seed determine the mod ID; changing either creates a different ID. Origin defaults to native cell 100,100. +Exports include Build 42 `mod.info`, `common/`, native `.lotheader/.lotpack/chunkdata_*.bin`, room/building definitions, spawn points, zones, biome maps (`maps/biomemap_X_Y.png`, which Build 42 needs for foraging zones; squares with buildings, roads, fences, decorations or spawns use the featureless `dirt` biome so WorldGen never replaces authored vegetation there with its own jumbo trees), a 256×256 `thumb.png`, world map XML, editable project, attribution and installation instructions. Like the vanilla maps, `spawnpoints.lua` starts players inside houses (the living room, kitchen or bedroom of every building with a bedroom, up to 256), gives all 25 Build 42 professions a key, and adds workplaces such as a police office or a restaurant kitchen for matching professions. Authored spawns are used only when the map has no house. Name/seed determine the mod ID; changing either creates a different ID. Origin defaults to native cell 100,100. ## Build from source diff --git a/src/SimplePZMapmaker.Core/Export/BiomeMap.cs b/src/SimplePZMapmaker.Core/Export/BiomeMap.cs index 6d9c692..d49ed27 100644 --- a/src/SimplePZMapmaker.Core/Export/BiomeMap.cs +++ b/src/SimplePZMapmaker.Core/Export/BiomeMap.cs @@ -6,10 +6,14 @@ namespace SimplePZMapmaker.Core.Export; /// in objects.lua are not registered by the server. Like the vanilla maps, red repeats the green value on land (vanilla /// Muldraugh uses 230/230 birch-mix forest and 115/115 town) and blue repeats red. /// Values from the 42.20 table: 0 Water, 64 ForagingNav, 102 TrailerPark, 115 TownZone, 128 Farm, 141 FarmLand, -/// 230 BirchMixForest, 255 DeepForest. +/// 230 BirchMixForest, 255 DeepForest. +/// WorldGen also runs over authored squares: where the biome of the red value places a tree, bush or grass on a natural floor it +/// deletes the authored one unless its sprite is in the biome's protected list (forest biomes place jumbo trees). Squares that hold +/// authored content, with a one-square margin around buildings, roads, fences and spawns, therefore use red 254, the "dirt" biome, +/// which has no features and so never replaces anything. public static class BiomeMap { - public const byte Water=0,Nav=64,TrailerPark=102,Town=115,Farm=128,FarmLand=141,Forest=230,DeepForest=255; + public const byte Water=0,Nav=64,TrailerPark=102,Town=115,Farm=128,FarmLand=141,Forest=230,NoFeatures=254,DeepForest=255; /// Foraging zone (green value) of each 8x8 chunk of a native cell, 32x32 values. public static byte[] Zones(MapProject p,int cellX,int cellY){ var chunks=p.Width/8;var rows=p.Height/8;var kind=new byte[chunks*rows];var built=new bool[chunks*rows]; @@ -38,10 +42,22 @@ public static byte[] Zones(MapProject p,int cellX,int cellY){ foreach(var z in p.GameZones)if(z.Layer=="terrain"&&ZoneGeometry.Covers(z,x,y))return z.Type switch{"Forest"=>Forest,"DeepForest"=>DeepForest,"Farm"=>Farm,"TownZone"=>Town,"TrailerPark"=>TrailerPark,"Vegitation"=>FarmLand,"Nav"=>Nav,_=>null}; return null; } + /// Squares WorldGen must leave alone: buildings, roads and pavement, fences and spawns with a one-square margin, and decorations and models. + public static bool[] Protected(MapProject p){ + var kept=new bool[p.Width*p.Height]; + void Mark(int x,int y,int margin){for(var yy=y-margin;yy<=y+margin;yy++)for(var xx=x-margin;xx<=x+margin;xx++)if(xx>=0&&yy>=0&&xxThe biome map image of one native cell: the chunk's zone in red, green and blue, and 0 on water squares. public static byte[] Png(MapProject p,int cellX,int cellY){ - var zones=Zones(p,cellX,cellY);var rgba=new byte[256*256*4]; - for(var y=0;y<256;y++)for(var x=0;x<256;x++){var i=(y*256+x)*4;var zone=zones[y/8*32+x/8];var red=p.Terrain[(cellY*256+y)*p.Width+cellX*256+x]==Terrain.Water?Water:zone;rgba[i]=red;rgba[i+1]=zone;rgba[i+2]=red;rgba[i+3]=255;} + var zones=Zones(p,cellX,cellY);var rgba=new byte[256*256*4];var kept=Protected(p); + for(var y=0;y<256;y++)for(var x=0;x<256;x++){var i=(y*256+x)*4;var zone=zones[y/8*32+x/8];var square=(cellY*256+y)*p.Width+cellX*256+x;var red=p.Terrain[square]==Terrain.Water?Water:kept[square]?NoFeatures:zone;rgba[i]=red;rgba[i+1]=zone;rgba[i+2]=red;rgba[i+3]=255;} return Export.Png.Encode(new(256,256,rgba)); } } diff --git a/src/SimplePZMapmaker.Core/Export/MapThumbnail.cs b/src/SimplePZMapmaker.Core/Export/MapThumbnail.cs new file mode 100644 index 0000000..568ebe1 --- /dev/null +++ b/src/SimplePZMapmaker.Core/Export/MapThumbnail.cs @@ -0,0 +1,35 @@ +namespace SimplePZMapmaker.Core.Export; + +/// media/maps/<map>/thumb.png, the 256x256 picture the game shows for a map (vanilla Riverside has one): terrain colours +/// seen from above with building footprints, scaled to fit the map or world and centred. +public sealed class MapThumbnail +{ + const int Size=256; + static readonly (byte R,byte G,byte B)[] Colours=[(96,128,64),(46,84,44),(72,72,72),(52,96,148),(206,192,142),(124,98,66),(136,136,136)]; + static readonly (byte R,byte G,byte B) Background=(24,24,24),Roof=(150,62,52); + readonly byte[] rgba=new byte[Size*Size*4];readonly int left,top;readonly double scale;readonly int offsetX,offsetY; + /// A picture of the world area from tile (, ), by tiles. + public MapThumbnail(int left,int top,int width,int height){ + this.left=left;this.top=top;scale=Math.Max(width,height)/(double)Size;offsetX=(int)((Size-width/scale)/2);offsetY=(int)((Size-height/scale)/2); + for(var i=0;iPaints one map or section: the terrain at the centre of each pixel's tiles, then the building footprints. + public void Add(MapProject p){ + var ox=p.WorldX*256-left;var oy=p.WorldY*256-top; + for(var py=0;py=p.Width||y>=p.Height)continue; + var c=Colours[Math.Min(Colours.Length-1,(int)p.Terrain[y*p.Width+x])];Set(px,py,c); + } + foreach(var b in p.Buildings){ + int X(double tile)=>(int)Math.Floor((ox+tile)/scale)+offsetX;int Y(double tile)=>(int)Math.Floor((oy+tile)/scale)+offsetY; + for(var py=Math.Max(0,Y(b.Y));py<=Math.Min(Size-1,Y(b.Y+b.Height-1));py++)for(var px=Math.Max(0,X(b.X));px<=Math.Min(Size-1,X(b.X+b.Width-1));px++){ + var x=(int)((px-offsetX+0.5)*scale)-ox-b.X;var y=(int)((py-offsetY+0.5)*scale)-oy-b.Y; + if(scale>=2||BuildingDesigner.Occupies(b,Math.Clamp(x,0,b.Width-1),Math.Clamp(y,0,b.Height-1)))Set(px,py,Roof); + } + } + } + void Set(int x,int y,(byte R,byte G,byte B) c){var i=(y*Size+x)*4;rgba[i]=c.R;rgba[i+1]=c.G;rgba[i+2]=c.B;} + public byte[] Png()=>Export.Png.Encode(new(Size,Size,rgba)); + /// The thumbnail of a single map. + public static byte[] Png(MapProject p){var t=new MapThumbnail(p.WorldX*256,p.WorldY*256,p.Width,p.Height);t.Add(p);return t.Png();} +} diff --git a/src/SimplePZMapmaker.Core/Export/NativeMapExporter.cs b/src/SimplePZMapmaker.Core/Export/NativeMapExporter.cs index 6829ad5..f281897 100644 --- a/src/SimplePZMapmaker.Core/Export/NativeMapExporter.cs +++ b/src/SimplePZMapmaker.Core/Export/NativeMapExporter.cs @@ -33,7 +33,7 @@ internal static void WriteValidatedWorldSection(MapProject p,Stream output,Cance StaticModelExporter.Write(zip,slug,StaticModelExporter.Gather(p).Where(m=>m.X>=p.WorldX*256&&m.Y>=p.WorldY*256&&m.Xs.Width/256*(s.Height/256)));using(var zip=new ZipArchive(output,ZipArchiveMode.Create,true)){ void Text(string name,string value){using var writer=new StreamWriter(zip.CreateEntry(name,CompressionLevel.Fastest).Open(),new UTF8Encoding(false));writer.Write(value);} - zip.CreateEntry(slug+"/common/");var zones=new StringBuilder("objects = {\n");var atlas=new XElement("world",new XAttribute("version","1.0"),new XAttribute("cellSize",256));long atlasBytes=0,attributionBytes=0;var spawns=new SpawnPlanner.Plan();var attributions=new HashSet(StringComparer.Ordinal);var dependencies=new SortedSet(StringComparer.Ordinal);var models=new List();var modelIds=new HashSet<(string,int,int,int)>(); + zip.CreateEntry(slug+"/common/");var zones=new StringBuilder("objects = {\n");var atlas=new XElement("world",new XAttribute("version","1.0"),new XAttribute("cellSize",256));long atlasBytes=0,attributionBytes=0;var spawns=new SpawnPlanner.Plan();var thumbLeft=sections.Min(s=>s.WorldX);var thumbTop=sections.Min(s=>s.WorldY);var thumbnail=new MapThumbnail(thumbLeft*256,thumbTop*256,(sections.Max(s=>s.WorldX+s.Width/256)-thumbLeft)*256,(sections.Max(s=>s.WorldY+s.Height/256)-thumbTop)*256);var attributions=new HashSet(StringComparer.Ordinal);var dependencies=new SortedSet(StringComparer.Ordinal);var models=new List();var modelIds=new HashSet<(string,int,int,int)>(); foreach(var section in sections){ token.ThrowIfCancellationRequested();context?.Section(section.Id); await WriteSection(section); @@ -36,7 +36,7 @@ async Task WriteSection(StreamedWorldSection section){ NativeMapExporter.WriteCells(p,zip,root,token,context); var text=GameZonesExporter.ObjectsLua(p,token);if(text.Length+zones.Length>64_000_000)throw new ArgumentException("World zone descriptions exceed 64 MB. Export selected sections.");var first=text.IndexOf('{');var last=text.LastIndexOf('}');zones.Append(text.AsSpan(first+1,last-first-1)); var cellAtlas=NativeMapExporter.WorldMap(p);atlasBytes+=Encoding.UTF8.GetByteCount(cellAtlas.ToString());if(atlasBytes>64_000_000)throw new ArgumentException("World atlas descriptions exceed 64 MB. Export selected sections.");foreach(var cell in cellAtlas.Elements("cell"))atlas.Add(new XElement(cell)); - spawns.Add(p.Buildings,p.WorldX*256,p.WorldY*256);spawns.AddAuthored(p); + spawns.Add(p.Buildings,p.WorldX*256,p.WorldY*256);spawns.AddAuthored(p);thumbnail.Add(p); foreach(var a in p.Attribution.Concat(p.Buildings.Concat(p.BuildingLibrary).SelectMany(b=>b.Attribution)))if(attributions.Add(a)){attributionBytes+=Encoding.UTF8.GetByteCount(a);if(attributionBytes>16_000_000)throw new ArgumentException("World attribution descriptions exceed 16 MB. Export selected sections.");} 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.X100000)throw new ArgumentException("World static models exceed 100,000 placements.");} @@ -44,7 +44,7 @@ async Task WriteSection(StreamedWorldSection section){ 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",spawns.Lua());Text(root+"spawnregions.lua",$"function SpawnRegions()\n return {{ {{ name = \"{slug}\", file = \"media/maps/{slug}/spawnpoints.lua\" }} }}\nend\n"); + if(spawns.Count==0&&memberId is null)throw new ArgumentException("The world needs at least one dry-land spawn.");Text(root+"spawnpoints.lua",spawns.Lua());using(var thumb=zip.CreateEntry(root+"thumb.png",CompressionLevel.Fastest).Open())thumb.Write(thumbnail.Png());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); diff --git a/src/SimplePZMapmaker.Core/Export/NativeWorldExporter.cs b/src/SimplePZMapmaker.Core/Export/NativeWorldExporter.cs index 622e0cd..54c2edd 100644 --- a/src/SimplePZMapmaker.Core/Export/NativeWorldExporter.cs +++ b/src/SimplePZMapmaker.Core/Export/NativeWorldExporter.cs @@ -13,7 +13,7 @@ public static byte[] Export(WorldProject world,CancellationToken token=default){ } public static void ExportToStream(WorldProject world,Stream output,CancellationToken token=default)=>ExportWithProgress(world,output,null,token); public static void ExportWithProgress(WorldProject world,Stream output,NativeExportContext? context,CancellationToken token=default){ - var bounds=WorldAssembler.Validate(world);context?.Begin(world.Maps.Count,world.Maps.Sum(m=>m.Project.Width/256*(m.Project.Height/256)));var slug="SimplePZWorld_"+Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(world.Id)))[..12].ToLowerInvariant();var root=slug+"/42/media/maps/"+slug+"/"; + var bounds=WorldAssembler.Validate(world);var thumbnail=new MapThumbnail(bounds.X*256,bounds.Y*256,bounds.Width,bounds.Height);context?.Begin(world.Maps.Count,world.Maps.Sum(m=>m.Project.Width/256*(m.Project.Height/256)));var slug="SimplePZWorld_"+Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(world.Id)))[..12].ToLowerInvariant();var root=slug+"/42/media/maps/"+slug+"/"; if(!output.CanWrite||!output.CanSeek)throw new ArgumentException("World export requires a writable, seekable destination.");using(var zip=new ZipArchive(output,ZipArchiveMode.Create,true)){ void Text(string name,string value){var entry=zip.CreateEntry(name,CompressionLevel.Fastest);using var writer=new StreamWriter(entry.Open(),new UTF8Encoding(false));writer.Write(value);} long mapTextBytes=0;zip.CreateEntry(slug+"/common/");var zones=new StringBuilder("objects = {\n");var xml=new XElement("world",new XAttribute("version","1.0"),new XAttribute("cellSize",256));var spawns=new SpawnPlanner.Plan();var attributions=new HashSet(); @@ -22,11 +22,11 @@ public static void ExportWithProgress(WorldProject world,Stream output,NativeExp NativeMapExporter.WriteCells(p,zip,root,token,context);context?.SectionDone(); var text=GameZonesExporter.ObjectsLua(p,token);if(text.Length+zones.Length>64_000_000)throw new ArgumentException("World zone descriptions exceed 64 MB of text; split the world into multiple mods.");var first=text.IndexOf('{');var last=text.LastIndexOf('}');zones.Append(text.AsSpan(first+1,last-first-1)); var atlas=NativeMapExporter.WorldMap(p);mapTextBytes+=Encoding.UTF8.GetByteCount(atlas.ToString());if(mapTextBytes>64_000_000)throw new ArgumentException("In-game atlas outlines exceed 64 MB; split the world into multiple mods.");foreach(var cell in atlas.Elements("cell"))xml.Add(new XElement(cell)); - spawns.Add(p.Buildings,p.WorldX*256,p.WorldY*256);spawns.AddAuthored(p); + spawns.Add(p.Buildings,p.WorldX*256,p.WorldY*256);spawns.AddAuthored(p);thumbnail.Add(p); foreach(var a in p.Attribution.Concat(p.Buildings.Concat(p.BuildingLibrary).SelectMany(b=>b.Attribution)))attributions.Add(a); if(output.Length>256*1024*1024)throw new ArgumentException("World export exceeds 256 MB; split it into multiple mods."); } - zones.Append("}\n");Text(root+"objects.lua",zones.ToString());Text(root+"worldmap.xml",new XDocument(xml).ToString());using(var binary=zip.CreateEntry(root+"worldmap.xml.bin",CompressionLevel.Fastest).Open()){var bytes=NativeWorldMapBinary.Write(xml);binary.Write(bytes);}Text(root+"spawnpoints.lua",spawns.Lua());Text(root+"spawnregions.lua",$"function SpawnRegions()\n return {{ {{ name = \"{slug}\", file = \"media/maps/{slug}/spawnpoints.lua\" }} }}\nend\n"); + zones.Append("}\n");Text(root+"objects.lua",zones.ToString());Text(root+"worldmap.xml",new XDocument(xml).ToString());using(var binary=zip.CreateEntry(root+"worldmap.xml.bin",CompressionLevel.Fastest).Open()){var bytes=NativeWorldMapBinary.Write(xml);binary.Write(bytes);}Text(root+"spawnpoints.lua",spawns.Lua());using(var thumb=zip.CreateEntry(root+"thumb.png",CompressionLevel.Fastest).Open())thumb.Write(thumbnail.Png());Text(root+"spawnregions.lua",$"function SpawnRegions()\n return {{ {{ name = \"{slug}\", file = \"media/maps/{slug}/spawnpoints.lua\" }} }}\nend\n"); var dependencies=world.Maps.SelectMany(m=>ResourceDependencies.ForProject(m.Project)).Concat(world.Buildings.SelectMany(b=>b.RequiredMods)).Concat(world.Buildings.SelectMany(b=>b.Design?.Floors.SelectMany(f=>f.StaticModels.SelectMany(m=>m.RequiredMods.Concat(m.LootRequiredMods)))??[])).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray();var require=dependencies.Length>0?"require="+string.Join(',',dependencies)+"\n":""; StaticModelExporter.Write(zip,slug,world.Maps.SelectMany(m=>StaticModelExporter.Gather(m.Project)).Concat(WorldAssembler.GatherModels(world))); Text(slug+"/42/mod.info",$"name={world.Name}\nid={slug}\nversionMin=42.0\ndescription=World assembled from {world.Maps.Count} sections; new Build 42 world.\n{require}");Text(root+"map.info",$"title={world.Name}\nfixed2x=true\ndescription=Stitched area {bounds.Width} x {bounds.Height}; {bounds.Cells} cells.\nzoomX={bounds.X*256+bounds.Width/2}\nzoomY={bounds.Y*256+bounds.Height/2}\nzoomS=10\n"); diff --git a/src/SimplePZMapmaker.Web/wwwroot/gameplay-authoring.js b/src/SimplePZMapmaker.Web/wwwroot/gameplay-authoring.js index 1eef120..ae09e7c 100644 --- a/src/SimplePZMapmaker.Web/wwwroot/gameplay-authoring.js +++ b/src/SimplePZMapmaker.Web/wwwroot/gameplay-authoring.js @@ -1,8 +1,8 @@ -import {gameZoneTypes,vehicleZone,validateGameZones} from './gameplay-data.js'; +import {gameZoneTypes,vehicleZone,validateGameZones,parkingStalls} from './gameplay-data.js'; export function mountGameplayAuthoring(container,{getProject,apply,onDrawRectangle}){ if(!document.querySelector('link[data-gameplay-style]')){const link=document.createElement('link');link.rel='stylesheet';link.href='./gameplay-authoring.css';link.dataset.gameplayStyle='true';document.head.append(link);}container.classList.add('gameplay-authoring');container.innerHTML='

Game zones and vehicles

Draw ground-level rectangles in the map. Vehicle distributions use actual game spawning probabilities; they do not guarantee a vehicle.

'; const $=s=>container.querySelector(s),events=new AbortController();let distributions=[],rectangle=null,selected=null,disposed=false,busy=false;const type=$('[data-zone-type]'),list=$('[data-zone-list]'),status=$('[data-zone-status]');for(const t of gameZoneTypes)type.add(new Option(t,t));function visible(){const v=vehicleZone({type:type.value});for(const selector of ['[data-zone-distribution]','[data-zone-direction]','[data-zone-face]'])$(selector).parentElement.hidden=!v;}type.addEventListener('change',visible,{signal:events.signal});visible(); fetch('/api/gameplay/catalog').then(async r=>{const b=await r.json();if(!r.ok)throw Error(b.error||'Gameplay catalog unavailable.');if(disposed)return;distributions=b.vehicles;const select=$('[data-zone-distribution]');select.replaceChildren(new Option('Select distribution',''));for(const d of distributions)select.add(new Option(d.id+' · '+d.sourceId,d.id));}).catch(e=>{if(!disposed)status.textContent=e.message;}); function refresh(){list.replaceChildren();for(const z of getProject()?.gameZones??[]){const row=document.createElement('div'),button=document.createElement('button'),remove=document.createElement('button');button.type=remove.type='button';button.textContent=`${z.name||z.type} · ${z.width}×${z.height} at ${z.x},${z.y}`;button.onclick=()=>{selected=z.id;rectangle={x:z.x,y:z.y,width:z.width,height:z.height};$('[data-zone-name]').value=z.name;type.value=z.type;$('[data-zone-distribution]').value=z.vehicleDistribution??'';$('[data-zone-direction]').value=z.direction??'N';$('[data-zone-face]').checked=z.faceDirection??true;visible();status.textContent='Selected existing zone. Draw a new rectangle to move or resize it.';};remove.textContent='Remove';remove.onclick=async()=>{if(busy)return;try{busy=true;const candidate=structuredClone(getProject());candidate.gameZones=candidate.gameZones.filter(item=>item.id!==z.id);const ok=await apply(candidate);if(ok!==false){if(selected===z.id){selected=null;rectangle=null;}refresh();}}catch(e){status.textContent=e.message;}finally{busy=false;}};row.append(button,remove);list.append(row);}} - $('[data-zone-draw]').addEventListener('click',()=>{if(!onDrawRectangle){status.textContent='Rectangle drawing is unavailable in this view.';return;}onDrawRectangle(rect=>{rectangle=rect;status.textContent=`Rectangle ${rect.width}×${rect.height} at ${rect.x},${rect.y}. Apply to save.`;});},{signal:events.signal});container.querySelector('form').addEventListener('submit',async e=>{e.preventDefault();if(busy)return;try{busy=true;if(!rectangle)throw Error('Draw a rectangle first.');const z={id:selected??crypto.randomUUID().replaceAll('-',''),name:$('[data-zone-name]').value,type:type.value,...rectangle,z:0,vehicleDistribution:null,vehicleSourceId:'game',requiredMods:[],direction:$('[data-zone-direction]').value,faceDirection:$('[data-zone-face]').checked};if(vehicleZone(z)){const d=distributions.find(d=>d.id===$('[data-zone-distribution]').value);if(!d)throw Error('Choose an available registered vehicle distribution.');z.vehicleDistribution=d.id;z.vehicleSourceId=d.sourceId;z.requiredMods=structuredClone(d.requiredMods);}const p=structuredClone(getProject());p.gameZones=(p.gameZones??[]).filter(item=>item.id!==z.id);p.gameZones.push(z);p.version=Math.max(9,p.version);validateGameZones(p);const ok=await apply(p);if(ok!==false){selected=null;rectangle=null;refresh();status.textContent='Game zone applied.';}}catch(e){status.textContent=e.message;}finally{busy=false;}},{signal:events.signal});refresh();return{refresh,dispose(){disposed=true;events.abort();container.replaceChildren();}}; + $('[data-zone-draw]').addEventListener('click',()=>{if(!onDrawRectangle){status.textContent='Rectangle drawing is unavailable in this view.';return;}onDrawRectangle(rect=>{rectangle=rect;status.textContent=`Rectangle ${rect.width}×${rect.height} at ${rect.x},${rect.y}. Apply to save.`;});},{signal:events.signal});container.querySelector('form').addEventListener('submit',async e=>{e.preventDefault();if(busy)return;try{busy=true;if(!rectangle)throw Error('Draw a rectangle first.');const z={id:selected??crypto.randomUUID().replaceAll('-',''),name:$('[data-zone-name]').value,type:type.value,...rectangle,z:0,vehicleDistribution:null,vehicleSourceId:'game',requiredMods:[],direction:$('[data-zone-direction]').value,faceDirection:$('[data-zone-face]').checked};if(vehicleZone(z)&&!z.polygon)Object.assign(z,parkingStalls(z));if(vehicleZone(z)){const d=distributions.find(d=>d.id===$('[data-zone-distribution]').value);if(!d)throw Error('Choose an available registered vehicle distribution.');z.vehicleDistribution=d.id;z.vehicleSourceId=d.sourceId;z.requiredMods=structuredClone(d.requiredMods);}const p=structuredClone(getProject());p.gameZones=(p.gameZones??[]).filter(item=>item.id!==z.id);p.gameZones.push(z);p.version=Math.max(9,p.version);validateGameZones(p);const ok=await apply(p);if(ok!==false){selected=null;rectangle=null;refresh();status.textContent='Game zone applied.';}}catch(e){status.textContent=e.message;}finally{busy=false;}},{signal:events.signal});refresh();return{refresh,dispose(){disposed=true;events.abort();container.replaceChildren();}}; } diff --git a/src/SimplePZMapmaker.Web/wwwroot/gameplay-data.js b/src/SimplePZMapmaker.Web/wwwroot/gameplay-data.js index e8a022c..96a388a 100644 --- a/src/SimplePZMapmaker.Web/wwwroot/gameplay-data.js +++ b/src/SimplePZMapmaker.Web/wwwroot/gameplay-data.js @@ -2,6 +2,8 @@ import {validateZoneGeometry,zoneCovers,zoneIntersects} from './zone-data.js'; import {validateDependencies} from './resource-data.js'; export const gameZoneTypes=['TownZone','Forest','DeepForest','Vegitation','Farm','TrailerPark','Nav','Vehicle','ParkingStall','ZombiesType']; export const vehicleZone=z=>['Vehicle','ParkingStall'].includes(z.type); +/** Build 42 spawns cars in whole 5x3 parking stalls: the rectangle shrinks to whole stalls along its long side, centred, like the importer does. */ +export function parkingStalls(r){const [w,h]=r.width>=r.height?[Math.floor(r.width/5)*5,Math.floor(r.height/3)*3]:[Math.floor(r.width/3)*3,Math.floor(r.height/5)*5];if(w<3||h<3)throw Error('A vehicle zone needs room for at least one 5×3 parking stall.');return{...r,x:r.x+Math.floor((r.width-w)/2),y:r.y+Math.floor((r.height-h)/2),width:w,height:h};} export const zoneLayer=z=>vehicleZone(z)?'vehicle':z.type==='ZombiesType'?'zombies':'terrain'; export function validateGameZones(p){const zones=p.gameZones??[];if(!Array.isArray(zones)||zones.length>4096||new Set(zones.map(z=>z?.id)).size!==zones.length)throw Error('A map can contain up to 4,096 distinct game zones.');for(const z of zones){if(z?.type==='ZombiesType'&&!(typeof z.name==='string'&&z.name.trim()))throw Error('A ZombiesType zone needs the name of its zombie definition.');if(!z||!/^[a-fA-F0-9]{32}$/.test(z.id)||typeof z.name!=='string'||z.name.length>100||/[\x00-\x1f\x7f]/.test(z.name)||!gameZoneTypes.includes(z.type)||![z.x,z.y,z.z,z.width,z.height].every(Number.isInteger)||z.z!==0||z.x<0||z.y<0||z.width<1||z.height<1||z.x+z.width>p.width||z.y+z.height>p.height)throw Error('Game zones need a valid ground-level rectangle inside the map.');validateZoneGeometry(z,p.width,p.height);validateDependencies(z.requiredMods??[],{});if(vehicleZone(z)){if(!/^[A-Za-z][A-Za-z0-9_]{0,119}$/.test(z.vehicleDistribution??'')||!['N','S','E','W'].includes(z.direction)||typeof z.vehicleSourceId!=='string'||z.vehicleSourceId.length>180)throw Error('Select a registered vehicle distribution and direction.');}else if(z.vehicleDistribution!=null||z.requiredMods?.length)throw Error('Vehicle settings belong to a vehicle zone.');} for(let i=0;ie.FullName.Contains("/maps/biomemap_")&&e.Name.EndsWith(".png"))==1,"Each native cell needs its biome map"); + using(var stream=new MemoryStream()){using(var thumb=zip.Entries.Single(e=>e.Name=="thumb.png").Open())thumb.CopyTo(stream);var t=Png.Decode(stream.ToArray());Check(t.Width==256&&t.Height==256,"thumb.png must be 256x256"); + (byte,byte,byte) Px(int x,int y){var i=(y*256+x)*4;return(t.Rgba[i],t.Rgba[i+1],t.Rgba[i+2]);}Check(Px(155,45)==(150,62,52)&&Px(190,190)==(52,96,148)&&Px(40,40)==(46,84,44),"thumb.png should show roofs, water and forest from above");} }); } static void Check(bool ok,string message){if(!ok)throw new Exception(message);} diff --git a/tests/gameplay-authoring.test.mjs b/tests/gameplay-authoring.test.mjs index 1f3c3d0..668415a 100644 --- a/tests/gameplay-authoring.test.mjs +++ b/tests/gameplay-authoring.test.mjs @@ -7,3 +7,6 @@ test('vehicle layer may overlay forest but not another spawn or water',()=>{cons import {buildingTemplateVersion,buildingTemplateFromDocument,worldAuthoringVersion} from '../src/SimplePZMapmaker.Web/wwwroot/world-geography.js'; test('shared world templates retain source footprints and reject older declared formats',()=>{const b={id:'c'.repeat(32),name:'Imported shape',kind:'house',x:0,y:0,width:8,height:8,footprintOrigin:'source-grid',sourceOutline:[[{longitude:44,latitude:56},{longitude:44.01,latitude:56},{longitude:44,latitude:56.01},{longitude:44,latitude:56}]]};assert.equal(buildingTemplateVersion(b),6);const imported=buildingTemplateFromDocument({format:'SimplePZBuilding',version:6,building:b});assert.deepEqual(imported.sourceOutline,b.sourceOutline);assert.notEqual(imported,b);assert.throws(()=>buildingTemplateFromDocument({format:'SimplePZBuilding',version:5,building:b}),/format 6/);assert.equal(worldAuthoringVersion({version:1,maps:[],buildings:[b]}),3);assert.equal(worldAuthoringVersion({version:2,maps:[{project:{version:9}}],buildings:[]}),3);assert.equal(worldAuthoringVersion({version:1,maps:[{project:{version:8}}],buildings:[]}),1);}); +test('vehicle zones shrink to whole 5x3 stalls and ZombiesType sits on its own layer',async()=>{const {parkingStalls,zoneLayer}=await import('../src/SimplePZMapmaker.Web/wwwroot/gameplay-data.js'); + assert.deepEqual(parkingStalls({x:10,y:10,width:12,height:7}),{x:11,y:10,width:10,height:6});assert.deepEqual(parkingStalls({x:0,y:0,width:4,height:11}),{x:0,y:0,width:3,height:10}); + assert.throws(()=>parkingStalls({x:0,y:0,width:4,height:2}),/5×3/);assert.equal(zoneLayer({type:'ZombiesType'}),'zombies');assert.equal(zoneLayer({type:'ParkingStall'}),'vehicle');}); From bb836c2bf4faac0f1b39e09f5fec3cb95e8c8cc0 Mon Sep 17 00:00:00 2001 From: Feliks Solovy Date: Sun, 18 Oct 2026 00:09:29 +0300 Subject: [PATCH 07/15] feat: TileZed furniture against walls and building template styles Bedrooms, kitchens, bathrooms and living rooms of generated buildings get beds, counters, stoves, fridges, sinks, toilets, baths, sofas and TVs from BuildingFurniture.txt, backed onto a wall with the square in front free. The new furniture object kind has four orientations, survives settlement and module rotation, and raises maps to format 14 (worlds to 9). pz:style=tilezed: