From a51f0a893383560e475f36109719ade3ef25eaaf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 20:06:09 +0000 Subject: [PATCH 1/2] feat: fence and gate lines as their own layer Fences run along cell edges in a dedicated map/world layer with named fence styles (W, N, NW corner, post, alternating pillar edges) and gate styles (1-8 edges, closed/open pieces, mirrored when opening left). The exporter adds them as wall objects with native edge bits, so a fence no longer takes a cell's single decoration slot. World fences are projected into every section they cross, so they stay continuous at seams. Building and water cells are skipped and counted. A fence layer raises the map format to 12 and the world format to 7. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015ECCGjSrSADcjxBmXdCBpm --- src/SimplePZMapmaker.Core/AuthoringFormats.cs | 1 + .../Export/TileComposer.cs | 1 + src/SimplePZMapmaker.Core/FenceLines.cs | 118 ++++++++++++++++++ .../GeographicImporter.cs | 2 +- src/SimplePZMapmaker.Core/MapProject.cs | 5 +- src/SimplePZMapmaker.Core/ProjectValidator.cs | 4 +- src/SimplePZMapmaker.Core/WorldBuildings.cs | 3 +- src/SimplePZMapmaker.Core/WorldProject.cs | 9 +- src/SimplePZMapmaker.Web/wwwroot/editor.js | 4 +- .../wwwroot/world-geography.js | 2 +- .../SimplePZMapmaker.Tests/FenceLineTests.cs | 66 ++++++++++ tests/SimplePZMapmaker.Tests/Program.cs | 2 +- 12 files changed, 206 insertions(+), 11 deletions(-) create mode 100644 src/SimplePZMapmaker.Core/FenceLines.cs create mode 100644 tests/SimplePZMapmaker.Tests/FenceLineTests.cs diff --git a/src/SimplePZMapmaker.Core/AuthoringFormats.cs b/src/SimplePZMapmaker.Core/AuthoringFormats.cs index 9c152ef..a514a80 100644 --- a/src/SimplePZMapmaker.Core/AuthoringFormats.cs +++ b/src/SimplePZMapmaker.Core/AuthoringFormats.cs @@ -2,6 +2,7 @@ namespace SimplePZMapmaker.Core; public static class AuthoringFormats { public static bool MapHasInfrastructure(MapProject p)=>p.LinearFeatures?.Any(f=>f is not null&&(f.Level!=0||f.Access!="none"||f.GuardRails||f.SupportSpacing!=0||f.GroupId is not null))==true; + public static bool MapHasFences(MapProject p)=>p.Fences?.Count>0||p.FenceStyles?.Count>0||p.GateStyles?.Count>0; public static bool WorldHasInfrastructure(WorldProject w)=>w.LinearFeatures?.Count>0||w.Maps?.Any(m=>m?.Project is {} p&&MapHasInfrastructure(p))==true; // Earlier editor formats supported occupied levels -16..15 and had no explicit open roof option. public static bool BuildingHasExtendedLevels(Building? b)=>b?.Design is {} d&&(d.MinLevel< -16||(long)d.MinLevel+(d.Floors?.Count??0)-1>15||d.Roof=="none"); diff --git a/src/SimplePZMapmaker.Core/Export/TileComposer.cs b/src/SimplePZMapmaker.Core/Export/TileComposer.cs index 2920efd..09e6c64 100644 --- a/src/SimplePZMapmaker.Core/Export/TileComposer.cs +++ b/src/SimplePZMapmaker.Core/Export/TileComposer.cs @@ -26,6 +26,7 @@ internal TileComposer(MapProject p,int cellX,int cellY,InfrastructureGeometry.Ge } foreach(var b in p.Buildings.Where(b=>b.X=ox&&b.Y+b.Height>=oy)){if(b.Design is null)Compose(b);else ComposeDesign(b);} foreach(var d in p.Decorations)foreach(var part in d.Parts){var x=d.X+part.Dx-ox;var y=d.Y+part.Dy-oy;if(x>=0&&y>=0&&x<256&&y<256)Add(x,y,part.Tile,part.Flags);} + if(p.Fences.Count>0){var blocked=FenceLines.Blocker(p);foreach(var piece in FenceLines.Compose(p.Fences,p.FenceStyles,p.GateStyles,256,256,-ox,-oy,(x,y)=>blocked(x+ox,y+oy)).Pieces)Add(piece.X,piece.Y,piece.Tile,piece.Flags);} ComposeInfrastructure(infrastructure); } private bool Tree(int x,int y) => !decorationCells.Contains(y*256+x)&&((uint)((ox+x)*73856093)^((uint)(oy+y)*19349663)^(uint)project.Seed)%19==0 && !project.Spawns.Any(s=>Math.Abs(s.X-ox-x)<=2&&Math.Abs(s.Y-oy-y)<=2); diff --git a/src/SimplePZMapmaker.Core/FenceLines.cs b/src/SimplePZMapmaker.Core/FenceLines.cs new file mode 100644 index 0000000..ba9bd12 --- /dev/null +++ b/src/SimplePZMapmaker.Core/FenceLines.cs @@ -0,0 +1,118 @@ +namespace SimplePZMapmaker.Core; + +/// A vertex of the tile grid: the north-west corner of cell (X, Y). +public sealed record FencePoint(int X,int Y); +/// Tiles of one fence look. A horizontal run puts N on the cell south of it, a vertical run puts W on the cell east of it; a cell holding both edges of one style gets NW when it is set. Every AltEvery-th edge uses WAlt/NAlt (for example a brick pillar between forged sections). +public sealed class FenceStyle +{ + public string Id {get;set;}=""; + public string W {get;set;}=""; + public string N {get;set;}=""; + public string? NW {get;set;} + public string? Post {get;set;} + public string? WAlt {get;set;} + public string? NAlt {get;set;} + public int AltEvery {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 +{ + public string Id {get;set;}=""; + public int Width {get;set;}=1; + public List ClosedW {get;set;}=[]; + public List ClosedN {get;set;}=[]; + public List OpenW {get;set;}=[]; + public List OpenN {get;set;}=[]; +} +public sealed class FenceGate +{ + /// Edges from the first point of the line to the gate's first edge. + public int At {get;set;} + public string Style {get;set;}=""; + public string OpensTo {get;set;}="right"; + public bool Open {get;set;} +} +/// A fence along cell edges (docs/TRANSFER-CONTRACT.md, section 4). It lives in its own layer, so a fence edge never takes the single decoration slot of a cell. +public sealed class FenceLine +{ + public string Id {get;set;}=Guid.NewGuid().ToString("N"); + public string Style {get;set;}=""; + public int PostSpacing {get;set;} + public List Points {get;set;}=[]; + public List Gates {get;set;}=[]; + public string? SourceId {get;set;} + public Dictionary SourceTags {get;set;}=[]; + public List SourcePath {get;set;}=[]; +} +public static class FenceLines +{ + public const int MaximumLines=20000,MaximumPoints=512,MaximumEdges=250000,MaximumStyles=128; + /// One placed fence tile. Flags are the native navigation edge bits (2 north, 4 west). + public sealed record Piece(int X,int Y,string Tile,byte Flags,string Line); + public sealed record Composition(List Pieces,Dictionary Skipped); + + public static void Validate(MapProject p)=>Validate(p.Fences,p.FenceStyles,p.GateStyles,258048); + 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 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){ + if(f is null||string.IsNullOrWhiteSpace(f.Id)||f.Id.Length>100||!ids.Add(f.Id)||!styleIds.Contains(f.Style)||f.PostSpacing is <0 or >64||f.Points is null||f.Points.Count is <2 or >MaximumPoints||f.Points.Any(v=>v is null||Math.Abs(v.X)>limit||Math.Abs(v.Y)>limit)||f.SourceId?.Length>100||f.SourceTags is null||f.SourceTags.Count>48||f.SourceTags.Any(v=>v.Key.Length is <1 or >100||v.Value is null||v.Value.Length>300)||f.SourcePath is null||f.SourcePath.Count>512||f.SourcePath.Any(v=>v is null||!double.IsFinite(v.Longitude)||!double.IsFinite(v.Latitude)||v.Longitude is < -180 or >180||v.Latitude is < -85 or >85)||f.Gates is null||f.Gates.Count>64)throw new ArgumentException("Invalid fence line: it needs a unique ID, a known style and 2-512 grid points."); + var length=0;for(var i=1;iMaximumEdges)throw new ArgumentException($"Fences exceed {MaximumEdges:N0} edges."); + var taken=new HashSet();foreach(var g in f.Gates){var style=g is null?null:gates.FirstOrDefault(s=>s.Id==g.Style);if(style is null||g!.OpensTo is not("left" or "right")||g.At<0||g.At+style.Width>length||g.Open&&style.OpenW.Count==0)throw new ArgumentException($"Fence line {f.Id}: each gate needs a known style, an opening side and room on the line."); + if(Segment(f,g.At)!=Segment(f,g.At+style.Width-1))throw new ArgumentException($"Fence line {f.Id}: a gate cannot turn a corner.");for(var e=g.At;eid is {Length:>0 and <=100}&&id.All(c=>char.IsAsciiLetterOrDigit(c)||c is '_' or '-' or '.'); + static int Segment(FenceLine f,int edge){for(var i=1;iEdge 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)>(); + 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=width||y>=height)return;if(blocked?.Invoke(x,y) is {} reason){Skip(reason);return;}posts.TryAdd((x,y),(style.Post,f.Id));} + void Skip(string reason)=>skipped[reason]=skipped.GetValueOrDefault(reason)+1; + for(var s=1;s0&&(edge%f.PostSpacing==0||i==0))Post(vx,vy); + // The edge between vertex v and the next one belongs to the cell on its south (horizontal) or east (vertical) side. + var x=(horizontal?Math.Min(vx,vx+step):vx)+dx;var y=(horizontal?vy:Math.Min(vy,vy+step))+dy; + if(x<0||y<0||x>=width||y>=height)continue; + if(blocked?.Invoke(x,y) is {} reason){Skip(reason);continue;} + string tile;var gate=gateAt.TryGetValue(edge,out var piece); + if(gate){var parts=piece!.Split('|');tile=horizontal?parts[2]:parts[1];} + 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(f.PostSpacing>0)Post(f.Points[^1].X,f.Points[^1].Y); + } + 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; + 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))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)); + return new(result,skipped); + } + + /// Blocking reasons used by export: building footprints and water never carry a fence. + public static Func Blocker(MapProject p){var occupied=new HashSet();foreach(var b in p.Buildings)foreach(var i in BuildingDesigner.Occupied(b,false)){var x=b.X+i%(b.Width+1);var y=b.Y+i/(b.Width+1);if(x>=0&&y>=0&&xoccupied.Contains(y*p.Width+x)?"building":p.Terrain[y*p.Width+x]==Terrain.Water?"water":null;} + + /// World fences crossing a section, as section-local lines; the composer clips them to the section, so a fence stays continuous across seams. + public static List ForSection(WorldProject world,MapProject p){var left=p.WorldX*256;var top=p.WorldY*256; + return world.Fences.Where(f=>f.Points.Max(v=>v.X)>=left&&f.Points.Min(v=>v.X)<=left+p.Width&&f.Points.Max(v=>v.Y)>=top&&f.Points.Min(v=>v.Y)<=top+p.Height).Select(f=>new FenceLine{Id=f.Id,Style=f.Style,PostSpacing=f.PostSpacing,Points=f.Points.Select(v=>new FencePoint(v.X-left,v.Y-top)).ToList(),Gates=f.Gates,SourceId=f.SourceId,SourceTags=f.SourceTags,SourcePath=f.SourcePath}).ToList();} +} diff --git a/src/SimplePZMapmaker.Core/GeographicImporter.cs b/src/SimplePZMapmaker.Core/GeographicImporter.cs index 37e07bf..b0a423f 100644 --- a/src/SimplePZMapmaker.Core/GeographicImporter.cs +++ b/src/SimplePZMapmaker.Core/GeographicImporter.cs @@ -71,7 +71,7 @@ public static ImportResult Import(string json,MapProject project,GeoBounds? boun imported++; } - if(preserveEdits){p.Generation=NeighborhoodPlanner.Copy(project.Generation);p.GameZones=NeighborhoodPlanner.Copy(project.GameZones);p.Version=Math.Max(p.Version,project.Version);p.TerrainEdits=project.TerrainEdits;var mask=TerrainEditMask.Decode(project);for(var i=0;i>(JsonSerializer.Serialize(project.StaticModels))!;p.Zombies=(int[])project.Zombies.Clone();p.SurfaceRegions=JsonSerializer.Deserialize>(JsonSerializer.Serialize(project.SurfaceRegions))!;if(p.SurfaceRegions.Count>0)p.Version=Math.Max(p.Version,5);p.SurfaceTiles=new(preserved.SurfaceTiles);p.Decorations=JsonSerializer.Deserialize>(JsonSerializer.Serialize(preserved.Decorations))!;foreach(var i in p.SurfaceTiles.Keys)p.Terrain[i]=preserved.Terrain[i];foreach(var model in p.StaticModels)foreach(var cell in StaticModelPlacement.FootprintCells(model)){var i=(model.Y+cell.Dy)*p.Width+model.X+cell.Dx;p.Terrain[i]=preserved.Terrain[i];}foreach(var d in p.Decorations)foreach(var part in d.Parts){var i=(d.Y+part.Dy)*p.Width+d.X+part.Dx;p.Terrain[i]=preserved.Terrain[i];}} + if(preserveEdits){p.Generation=NeighborhoodPlanner.Copy(project.Generation);p.GameZones=NeighborhoodPlanner.Copy(project.GameZones);p.Fences=NeighborhoodPlanner.Copy(project.Fences);p.FenceStyles=NeighborhoodPlanner.Copy(project.FenceStyles);p.GateStyles=NeighborhoodPlanner.Copy(project.GateStyles);p.Version=Math.Max(p.Version,project.Version);p.TerrainEdits=project.TerrainEdits;var mask=TerrainEditMask.Decode(project);for(var i=0;i>(JsonSerializer.Serialize(project.StaticModels))!;p.Zombies=(int[])project.Zombies.Clone();p.SurfaceRegions=JsonSerializer.Deserialize>(JsonSerializer.Serialize(project.SurfaceRegions))!;if(p.SurfaceRegions.Count>0)p.Version=Math.Max(p.Version,5);p.SurfaceTiles=new(preserved.SurfaceTiles);p.Decorations=JsonSerializer.Deserialize>(JsonSerializer.Serialize(preserved.Decorations))!;foreach(var i in p.SurfaceTiles.Keys)p.Terrain[i]=preserved.Terrain[i];foreach(var model in p.StaticModels)foreach(var cell in StaticModelPlacement.FootprintCells(model)){var i=(model.Y+cell.Dy)*p.Width+model.X+cell.Dx;p.Terrain[i]=preserved.Terrain[i];}foreach(var d in p.Decorations)foreach(var part in d.Parts){var i=(d.Y+part.Dy)*p.Width+d.X+part.Dx;p.Terrain[i]=preserved.Terrain[i];}} if(p.BuildingLibrary.Any(b=>b.RequiredMods.Count>0||b.AssetSources.Count>0||b.Design?.Floors.Any(f=>f.StaticModels.Count>0)==true)){p.Version=Math.Max(p.Version,6);p.RequiredMods=p.RequiredMods.Concat(p.BuildingLibrary.SelectMany(b=>b.RequiredMods)).Distinct().ToList();} if(preserveEdits)foreach(var old in project.Buildings.Where(b=>b.Modified||project.Generation?.LockedBuildingIds.Contains(b.Id)==true||project.Sites.Any(s=>s.Locked&&s.BuildingIds.Contains(b.Id)))){ var kept=JsonSerializer.Deserialize(JsonSerializer.Serialize(old))!; diff --git a/src/SimplePZMapmaker.Core/MapProject.cs b/src/SimplePZMapmaker.Core/MapProject.cs index aa748fb..42f88f2 100644 --- a/src/SimplePZMapmaker.Core/MapProject.cs +++ b/src/SimplePZMapmaker.Core/MapProject.cs @@ -4,7 +4,7 @@ public enum Terrain : byte { Grass, Forest, Road, Water, Sand, Dirt, Pavement } public sealed class MapProject { private int version=4; - public int Version { get=>AuthoringFormats.MapHasInfrastructure(this)&&version<11?11:AuthoringFormats.MapHasExtendedLevels(this)&&version<10?10:AuthoringFormats.MapHasNewAuthoring(this)&&version<9?9:ResourcePalettes?.Count>0&&version<8?8:version==6&&(StaticModels?.Any(m=>m?.Profile!="pickup")==true||(Buildings??[]).Concat(BuildingLibrary??[]).Any(b=>b?.Design?.Floors?.Any(f=>f?.StaticModels?.Any(m=>m?.Profile!="pickup")==true)==true))?7:version;set=>version=value; } + public int Version { get=>AuthoringFormats.MapHasFences(this)&&version<12?12:AuthoringFormats.MapHasInfrastructure(this)&&version<11?11:AuthoringFormats.MapHasExtendedLevels(this)&&version<10?10:AuthoringFormats.MapHasNewAuthoring(this)&&version<9?9:ResourcePalettes?.Count>0&&version<8?8:version==6&&(StaticModels?.Any(m=>m?.Profile!="pickup")==true||(Buildings??[]).Concat(BuildingLibrary??[]).Any(b=>b?.Design?.Floors?.Any(f=>f?.StaticModels?.Any(m=>m?.Profile!="pickup")==true)==true))?7:version;set=>version=value; } public bool WorldSection {get;set;} [System.Text.Json.Serialization.JsonIgnore] public InfrastructureGeometry.Geometry? SharedInfrastructure {get;set;} @@ -22,6 +22,9 @@ public sealed class MapProject public List ResourcePalettes {get;set;}=[]; public List LinearFeatures {get;set;}=[]; public List GameZones {get;set;}=[]; + public List Fences {get;set;}=[]; + public List FenceStyles {get;set;}=[]; + public List GateStyles {get;set;}=[]; public List SourceFeatures {get;set;}=[]; public List Sites {get;set;}=[]; public string SurfaceTheme {get;set;}="legacy"; diff --git a/src/SimplePZMapmaker.Core/ProjectValidator.cs b/src/SimplePZMapmaker.Core/ProjectValidator.cs index 7ce6265..d679b21 100644 --- a/src/SimplePZMapmaker.Core/ProjectValidator.cs +++ b/src/SimplePZMapmaker.Core/ProjectValidator.cs @@ -4,7 +4,7 @@ public static class ProjectValidator public static void Validate(MapProject p) { ArgumentNullException.ThrowIfNull(p); - if(p.Version is not(1 or 2 or 3 or 4 or 5 or 6 or 7 or 8 or 9 or 10 or 11)) Fail("Unsupported project version."); + if(p.Version is not(1 or 2 or 3 or 4 or 5 or 6 or 7 or 8 or 9 or 10 or 11 or 12)) Fail("Unsupported project version."); ResourcePalette.Validate(p.ResourcePalettes); if(p.ImportSummary is {} qualityReport) ImportQuality.Validate(qualityReport); @@ -61,7 +61,7 @@ public static void Validate(MapProject p) var door=model.Door!;var otherX=model.X-(door.Direction=="w"?1:0);var otherY=model.Y-(door.Direction=="n"?1:0); if(otherX<0||otherY<0||blockingModels.Contains(model.Y*p.Width+model.X)||blockingModels.Contains(otherY*p.Width+otherX)||decorationCells.Contains(model.Y*p.Width+model.X)||decorationCells.Contains(otherY*p.Width+otherX)||occupied.Contains(otherY*p.Width+otherX)||p.Terrain[otherY*p.Width+otherX]==Terrain.Water)Fail("A model door needs a clear approach on both sides of its edge."); } - LinearFeatureEditor.Validate(p);GameZones.ValidateProject(p); + LinearFeatureEditor.Validate(p);GameZones.ValidateProject(p);FenceLines.Validate(p); SiteModel.Validate(p); if(p.Bounds is not null) ValidateBounds(p.Bounds); if(p.MetresPerTile is double metres && (!double.IsFinite(metres)||metres<=0||metres>100000))Fail("Invalid geographic data scale."); diff --git a/src/SimplePZMapmaker.Core/WorldBuildings.cs b/src/SimplePZMapmaker.Core/WorldBuildings.cs index e3a2bac..e543cf2 100644 --- a/src/SimplePZMapmaker.Core/WorldBuildings.cs +++ b/src/SimplePZMapmaker.Core/WorldBuildings.cs @@ -25,8 +25,9 @@ private static void ValidateBuildings(WorldProject world){ public static Building CloneBuilding(Building b)=>JsonSerializer.Deserialize(JsonSerializer.Serialize(b,NativeMapExporter.JsonOptions),NativeMapExporter.JsonOptions)!; public static MapProject ProjectSection(WorldProject world,WorldMapMember member,InfrastructureGeometry.Geometry? sharedGeometry=null){ var p=member.Project;var projected=world.Buildings.Where(b=>b.X=p.WorldX*256&&b.Y+b.Height>=p.WorldY*256).Select(b=>{var copy=CloneBuilding(b);copy.X-=p.WorldX*256;copy.Y-=p.WorldY*256;return copy;}).ToArray(); - if(projected.Length==0&&world.LinearFeatures.Count==0)return p; + if(projected.Length==0&&world.LinearFeatures.Count==0&&world.Fences.Count==0)return p; var result=new MapProject(){Version=Math.Max(6,p.Version),WorldSection=true,Name=p.Name,Width=p.Width,Height=p.Height,Seed=p.Seed,WorldX=p.WorldX,WorldY=p.WorldY,Terrain=p.Terrain,TerrainEdits=p.TerrainEdits,Zombies=p.Zombies,Buildings=[..p.Buildings,..projected],BuildingLibrary=p.BuildingLibrary,ResourcePalettes=p.ResourcePalettes,LinearFeatures=p.LinearFeatures,GameZones=p.GameZones,SurfaceTheme=p.SurfaceTheme,SurfaceRegions=p.SurfaceRegions,SurfaceTiles=p.SurfaceTiles,Decorations=p.Decorations,StaticModels=p.StaticModels,RequiredMods=p.RequiredMods,AssetSources=p.AssetSources,Spawns=p.Spawns,Attribution=p.Attribution,Bounds=p.Bounds,MetresPerTile=p.MetresPerTile,ImportSummary=p.ImportSummary}; + result.Fences=[..p.Fences,..FenceLines.ForSection(world,p)];result.FenceStyles=[..p.FenceStyles,..world.FenceStyles.Where(w=>p.FenceStyles.All(s=>s.Id!=w.Id))];result.GateStyles=[..p.GateStyles,..world.GateStyles.Where(w=>p.GateStyles.All(s=>s.Id!=w.Id))]; result.SourceFeatures=NeighborhoodPlanner.Copy(p.SourceFeatures);result.Sites=NeighborhoodPlanner.Copy(p.Sites); result.Generation=p.Generation is null?null:JsonSerializer.Deserialize(JsonSerializer.Serialize(p.Generation)); return WorldInfrastructure.Project(world,member,result,sharedGeometry); diff --git a/src/SimplePZMapmaker.Core/WorldProject.cs b/src/SimplePZMapmaker.Core/WorldProject.cs index 172bca6..fb1f4dd 100644 --- a/src/SimplePZMapmaker.Core/WorldProject.cs +++ b/src/SimplePZMapmaker.Core/WorldProject.cs @@ -6,13 +6,16 @@ public sealed class WorldProject { public string Format {get;set;}="SimplePZWorld"; private int version=1; - public int Version {get=>GeoReference?.RotationDegrees is not(null or 0)&&version<7?7:LinearFeatures?.Count>0&&version<6?6:version<5&&AuthoringFormats.WorldHasInfrastructure(this)?5:version<4&&AuthoringFormats.WorldHasExtendedLevels(this)?4:version<3&&AuthoringFormats.WorldHasNewAuthoring(this)?3:version==1&&(Buildings?.Count>0||Elevation is not null||Maps?.Any(m=>m?.Project?.Version==7)==true)?2:version;set=>version=value;} + public int Version {get=>(GeoReference?.RotationDegrees is not(null or 0)||Fences?.Count>0||Maps?.Any(m=>m?.Project is {} p&&AuthoringFormats.MapHasFences(p))==true)&&version<7?7:LinearFeatures?.Count>0&&version<6?6:version<5&&AuthoringFormats.WorldHasInfrastructure(this)?5:version<4&&AuthoringFormats.WorldHasExtendedLevels(this)?4:version<3&&AuthoringFormats.WorldHasNewAuthoring(this)?3:version==1&&(Buildings?.Count>0||Elevation is not null||Maps?.Any(m=>m?.Project?.Version==7)==true)?2:version;set=>version=value;} public string Id {get;set;}=Guid.NewGuid().ToString("N"); public string Name {get;set;}="New world"; public List Maps {get;set;}=[]; public WorldGeoReference? GeoReference {get;set;} public List LinearFeatures {get;set;}=[]; public List Buildings {get;set;}=[]; + public List Fences {get;set;}=[]; + public List FenceStyles {get;set;}=[]; + public List GateStyles {get;set;}=[]; public ElevationSurvey? Elevation {get;set;} } public sealed class WorldMapMember @@ -30,7 +33,7 @@ public static Bounds Validate(WorldProject world){ foreach(var member in world.Maps){if(member is null||!SafeId(member.Id)||!ids.Add(member.Id))throw new ArgumentException("Invalid world section ID.");var p=member.Project;ProjectValidator.Validate(p); for(var y=p.WorldY;y8192)throw new ArgumentException("The world contains more than 8,192 native cells.");} minX=Math.Min(minX,p.WorldX);minY=Math.Min(minY,p.WorldY);maxX=Math.Max(maxX,p.WorldX+p.Width/256);maxY=Math.Max(maxY,p.WorldY+p.Height/256); - }ValidateBuildings(world);WorldInfrastructure.Validate(world);if(world.Elevation is {} survey)ElevationSurvey.Validate(survey);if(world.Maps.Sum(m=>m.Project.Spawns.Count)==0)throw new ArgumentException("The world needs at least one spawn point on dry land.");return new(minX,minY,(maxX-minX)*256,(maxY-minY)*256,cells.Count); + }ValidateBuildings(world);WorldInfrastructure.Validate(world);FenceLines.Validate(world);ValidateFenceStyles(world);if(world.Elevation is {} survey)ElevationSurvey.Validate(survey);if(world.Maps.Sum(m=>m.Project.Spawns.Count)==0)throw new ArgumentException("The world needs at least one spawn point on dry land.");return new(minX,minY,(maxX-minX)*256,(maxY-minY)*256,cells.Count); } public static WorldProject Clone(WorldProject world)=>JsonSerializer.Deserialize(JsonSerializer.Serialize(world,NativeMapExporter.JsonOptions),NativeMapExporter.JsonOptions)!; public static WorldProject Move(WorldProject world,string memberId,int cellX,int cellY){var copy=Clone(world);var member=copy.Maps.SingleOrDefault(m=>m.Id==memberId)??throw new ArgumentException("World section not found.");member.Project.WorldX=cellX;member.Project.WorldY=cellY;Validate(copy);return copy;} @@ -42,5 +45,7 @@ public static Bounds Validate(WorldProject world){ else{var top=a.WorldY0)warnings.Add($"'{a.Name}' / '{b.Name}': surfaces differ in {mismatches} of {compared} seam tiles. Check roads and water before exporting."); }return warnings.Take(64).ToArray();} + // World fences are composed with each section's own styles, so a style ID shared by both must mean the same tiles. + static void ValidateFenceStyles(WorldProject world){foreach(var m in world.Maps){foreach(var s in m.Project.FenceStyles)if(world.FenceStyles.FirstOrDefault(w=>w.Id==s.Id) is {} w&&System.Text.Json.JsonSerializer.Serialize(w)!=System.Text.Json.JsonSerializer.Serialize(s))throw new ArgumentException($"Fence style {s.Id} differs between the world and a section.");foreach(var s in m.Project.GateStyles)if(world.GateStyles.FirstOrDefault(w=>w.Id==s.Id) is {} w&&System.Text.Json.JsonSerializer.Serialize(w)!=System.Text.Json.JsonSerializer.Serialize(s))throw new ArgumentException($"Gate style {s.Id} differs between the world and a section.");}} public static bool SafeId(string? value)=>value is {Length:32}&&value.All(c=>char.IsAsciiHexDigit(c)); } diff --git a/src/SimplePZMapmaker.Web/wwwroot/editor.js b/src/SimplePZMapmaker.Web/wwwroot/editor.js index 4cedacf..01c4d99 100644 --- a/src/SimplePZMapmaker.Web/wwwroot/editor.js +++ b/src/SimplePZMapmaker.Web/wwwroot/editor.js @@ -8,7 +8,7 @@ import {kinds,occupied,occupiedIndices,containsPoint,validateDesign,combineAttri import {validateDependencies,validateModel,validateModelProject,modelCells,blockingModel,interactiveModel,decodeTerrainEdits,encodeTerrainEdits,markTerrainEdit} from './resource-data.js'; export function blank(size=256){return {version:4,worldSection:false,terrainEdits:'',name:'New map',width:size,height:size,seed:42,worldX:100,worldY:100,terrain:Array(size*size).fill(0),zombies:Array(size*size/64).fill(0),buildings:[],buildingLibrary:[],resourcePalettes:[],linearFeatures:[],gameZones:[],decorations:[],staticModels:[],requiredMods:[],assetSources:{},surfaceTiles:{},surfaceTheme:'legacy',surfaceRegions:[],spawns:[{x:size/2,y:size/2}],attribution:[],bounds:null,metresPerTile:null,importSummary:null};} export const usesInfrastructure=p=>(p.linearFeatures??[]).some(f=>(f.level??0)!==0||(f.access??'none')!=='none'||f.guardRails||(f.supportSpacing??0)!==0||f.groupId!=null); -export function mapAuthoringVersion(p){const buildings=[...(p.buildings??[]),...(p.buildingLibrary??[])];return Math.max(p.version??1,usesInfrastructure(p)?11:buildings.some(usesExtendedZ)?10:(p.linearFeatures?.length||p.gameZones?.length||(p.staticModels??[]).some(interactiveModel)||buildings.some(usesInteractiveAuthoring))?9:p.resourcePalettes?.length?8:1);} +export function mapAuthoringVersion(p){const buildings=[...(p.buildings??[]),...(p.buildingLibrary??[])];return Math.max(p.version??1,(p.fences?.length||p.fenceStyles?.length||p.gateStyles?.length)?12:usesInfrastructure(p)?11:buildings.some(usesExtendedZ)?10:(p.linearFeatures?.length||p.gameZones?.length||(p.staticModels??[]).some(interactiveModel)||buildings.some(usesInteractiveAuthoring))?9:p.resourcePalettes?.length?8:1);} export function geographicBase(project,size,keep){if(keep)return project;const result=blank(size);result.surfaceTheme=project.surfaceTheme??'legacy';result.buildingLibrary=structuredClone(project.buildingLibrary||[]);result.resourcePalettes=structuredClone(project.resourcePalettes||[]);result.version=mapAuthoringVersion(result);result.attribution=combineAttribution(project.attribution,...result.buildingLibrary.map(b=>b.attribution||[]));return result;} export function boundsFromSearchResult(item){ if(!Array.isArray(item?.boundingbox)||item.boundingbox.length!==4)return null; @@ -39,7 +39,7 @@ export function canPlace(p,b){ } export function validateProject(p){ const fail=m=>{throw Error(m);}; - if(!p||![1,2,3,4,5,6,7,8,9,10,11].includes(p.version)||!Number.isInteger(p.width)||!Number.isInteger(p.height)||p.width<256||p.height<256||p.width>2048||p.height>2048||p.width%256||p.height%256)fail('Unsupported project size or version.'); + if(!p||![1,2,3,4,5,6,7,8,9,10,11,12].includes(p.version)||!Number.isInteger(p.width)||!Number.isInteger(p.height)||p.width<256||p.height<256||p.width>2048||p.height>2048||p.width%256||p.height%256)fail('Unsupported project size or version.'); validateGameZones(p);validateLinearFeatures(p);validateModelProject(p);validateGeneration(p);validateSourceSites(p); validatePalettes(p.resourcePalettes===undefined?[]:p.resourcePalettes);if(p.resourcePalettes?.length&&p.version<8)fail('Resource palettes require project format 8.'); decodeTerrainEdits(p);if(p.version<6&&(p.terrainEdits?.length||p.worldSection)||p.worldSection!==undefined&&typeof p.worldSection!=='boolean')fail('Manual terrain edits and world sections require format 6.'); diff --git a/src/SimplePZMapmaker.Web/wwwroot/world-geography.js b/src/SimplePZMapmaker.Web/wwwroot/world-geography.js index 18baa83..4f0d464 100644 --- a/src/SimplePZMapmaker.Web/wwwroot/world-geography.js +++ b/src/SimplePZMapmaker.Web/wwwroot/world-geography.js @@ -5,7 +5,7 @@ import {usesExtendedZ} from './z-levels.js'; const $=id=>document.getElementById(id); export const buildingTemplateVersion=b=>usesExtendedZ(b)?7:usesInteractiveAuthoring(b)?6:usesNativeModelBehavior(b)?5:4; export function buildingTemplateFromDocument(data){if(data?.format!=='SimplePZBuilding'||![1,2,3,4,5,6,7].includes(data.version))throw Error('Template .pzbuilding.json expected.');const building=normalizeBuilding(structuredClone(data.building));if(usesExtendedZ(building)&&data.version<7)throw Error('Extended Z levels and open roofs require building template format 7.');if(usesInteractiveAuthoring(building)&&data.version<6)throw Error('Interactive objects and imported footprints require building template format 6.');validateDesign(building);return building;} -export const worldAuthoringVersion=world=>world.geoReference?.rotationDegrees?Math.max(7,world.version):world.linearFeatures?.length?Math.max(6,world.version):world.maps?.some(m=>m.project?.version>=11)?Math.max(5,world.version):world.maps?.some(m=>m.project?.version>=10)||world.buildings?.some(usesExtendedZ)?Math.max(4,world.version):world.maps?.some(m=>m.project?.version>=9)||world.buildings?.some(usesInteractiveAuthoring)?Math.max(3,world.version):world.version; +export const worldAuthoringVersion=world=>world.geoReference?.rotationDegrees||world.fences?.length||world.maps?.some(m=>m.project?.version>=12)?Math.max(7,world.version):world.linearFeatures?.length?Math.max(6,world.version):world.maps?.some(m=>m.project?.version>=11)?Math.max(5,world.version):world.maps?.some(m=>m.project?.version>=10)||world.buildings?.some(usesExtendedZ)?Math.max(4,world.version):world.maps?.some(m=>m.project?.version>=9)||world.buildings?.some(usesInteractiveAuthoring)?Math.max(3,world.version):world.version; export function elevationSample(grid,longitude,latitude){let x=longitude,y=latitude;if(grid.coordinateSystem==='EPSG:3857'){x=longitude*Math.PI/180*6378137;y=Math.log(Math.tan(Math.PI/4+latitude*Math.PI/360))*6378137;}const column=Math.floor((x-grid.west)/grid.cellSize+1e-9),row=grid.rows-1-Math.floor((y-grid.south)/grid.cellSize+1e-9);return column>=0&&row>=0&&column lon/lat, rotated reference included. export function worldGeographic(reference,x,y){const b=reference.bounds,cosine=Math.cos((b.north+b.south)/2*Math.PI/180);if(!reference.rotationDegrees)return {longitude:b.west+(x-reference.originCellX*256)*reference.metresPerTile/(cosine*111320),latitude:b.north-(y-reference.originCellY*256)*reference.metresPerTile/111320};const angle=(reference.rotationDegrees||0)*Math.PI/180,sin=Math.sin(angle),cos=Math.cos(angle),px=cosine*111320/reference.metresPerTile,py=111320/reference.metresPerTile,cx=(b.west+b.east)/2,cy=(b.north+b.south)/2,rotated=(lon,lat)=>{const e=(lon-cx)*px,n=(cy-lat)*py;return [e*cos-n*sin,e*sin+n*cos];},corners=[[b.west,b.north],[b.east,b.north],[b.west,b.south],[b.east,b.south]].map(c=>rotated(...c)),rx=x-reference.originCellX*256+Math.min(...corners.map(c=>c[0])),ry=y-reference.originCellY*256+Math.min(...corners.map(c=>c[1]));return {longitude:cx+(rx*cos+ry*sin)/px,latitude:cy-(-rx*sin+ry*cos)/py};} diff --git a/tests/SimplePZMapmaker.Tests/FenceLineTests.cs b/tests/SimplePZMapmaker.Tests/FenceLineTests.cs new file mode 100644 index 0000000..60a76d7 --- /dev/null +++ b/tests/SimplePZMapmaker.Tests/FenceLineTests.cs @@ -0,0 +1,66 @@ +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using SimplePZMapmaker.Core; +using SimplePZMapmaker.Core.Export; +public static class FenceLineTests +{ + static FenceStyle Sheet=>new(){Id="sheet",W="fencing_01_40",N="fencing_01_41",NW="fencing_01_42",Post="fencing_01_43"}; + static FenceStyle Forged=>new(){Id="forged",W="pilna_fence_01_25",N="pilna_fence_01_26",WAlt="pilna_fence_01_28",NAlt="pilna_fence_01_29",AltEvery=3}; + static GateStyle Wicket=>new(){Id="wicket",Width=1,ClosedW=["fixtures_doors_fences_01_12"],ClosedN=["fixtures_doors_fences_01_13"],OpenW=["fixtures_doors_fences_01_14"],OpenN=["fixtures_doors_fences_01_15"]}; + static GateStyle Double=>new(){Id="double",Width=2,ClosedW=["fixtures_doors_fences_01_60","fixtures_doors_fences_01_61"],ClosedN=["fixtures_doors_fences_01_62","fixtures_doors_fences_01_63"]}; + static FenceLine Yard(params FenceGate[] gates)=>new(){Id="yard",Style="sheet",Points=[new(10,10),new(16,10),new(16,14),new(10,14),new(10,10)],Gates=[..gates]}; + static Dictionary<(int,int),List> Cells(FenceLines.Composition c)=>c.Pieces.GroupBy(p=>(p.X,p.Y)).ToDictionary(g=>g.Key,g=>g.Select(p=>p.Tile).OrderBy(t=>t,StringComparer.Ordinal).ToList()); + public static IEnumerable<(string,Action)> Cases() { + yield return("fence lines put N and W edges on the right cells, merge corners and keep gates in line order",()=>{ + var c=FenceLines.Compose([Yard(new(){At=2,Style="double"},new(){At=7,Style="wicket",Open=true}),new(){Id="lane",Style="forged",Points=[new(20,10),new(27,10)]}],[Sheet,Forged],[Wicket,Double],64,64);var cells=Cells(c); + // Top run (10,10)->(16,10): N edges of cells (10..15,10); the double gate covers edges 2-3, so cells 12 and 13. + Check(cells[(10,10)].SequenceEqual(["fencing_01_42"]),"Corner cell did not merge into NW");Check(cells[(11,10)].SequenceEqual(["fencing_01_41"]),"Top run lost its N edge"); + Check(cells[(12,10)].SequenceEqual(["fixtures_doors_fences_01_62"])&&cells[(13,10)].SequenceEqual(["fixtures_doors_fences_01_63"]),"Double gate pieces are not in line order"); + // Right run (16,10)->(16,14): W edges of cells (16,10..13); the wicket is edge 7 = the second edge of that run, opened. + Check(cells[(16,11)].SequenceEqual(["fixtures_doors_fences_01_14"])&&cells[(16,10)].SequenceEqual(["fencing_01_40"]),"Wicket is not on its edge"); + // Bottom run (16,14)->(10,14): N edges of cells (10..15,14); left run back up: W edges of (10,10..13). + Check(Enumerable.Range(10,6).All(x=>cells[(x,14)].Contains("fencing_01_41"))&&Enumerable.Range(11,3).All(y=>cells[(10,y)].SequenceEqual(["fencing_01_40"])),"Closing runs are incomplete"); + Check(c.Pieces.Single(p=>p.Tile=="fixtures_doors_fences_01_62").Flags==0&&c.Pieces.First(p=>p.Tile=="fencing_01_41").Flags==2&&c.Pieces.First(p=>p.Tile=="fencing_01_40").Flags==4,"Navigation edge bits are wrong"); + // Forged sections with a pillar every third edge. + Check(Enumerable.Range(20,7).Select(x=>cells[(x,10)].Single()).SequenceEqual(["pilna_fence_01_26","pilna_fence_01_26","pilna_fence_01_29","pilna_fence_01_26","pilna_fence_01_26","pilna_fence_01_29","pilna_fence_01_26"]),"Alternating pillar pattern is wrong"); + Check(c.Skipped.Count==0&&!c.Pieces.Any(p=>p.Tile=="fencing_01_43"),"Posts appeared without a post spacing"); + }); + yield return("gates opening to the left reverse their pieces, posts follow the spacing and blocked cells are skipped and counted",()=>{ + var line=new FenceLine{Id="street",Style="sheet",PostSpacing=3,Points=[new(0,5),new(9,5)],Gates=[new(){At=4,Style="double",OpensTo="left"}]}; + var c=FenceLines.Compose([line],[Sheet],[Double],64,64,blocked:(x,y)=>x==8?"building":null);var cells=Cells(c); + Check(cells[(4,5)].Contains("fixtures_doors_fences_01_63")&&cells[(5,5)].Contains("fixtures_doors_fences_01_62"),"Left-opening gate was not mirrored"); + Check(new[]{0,3,6,9}.All(x=>cells.TryGetValue((x,5),out var t)&&t.Contains("fencing_01_43")),"Posts are not every third vertex and at the end"); + Check(!cells.ContainsKey((8,5))&&c.Skipped["building"]==1,"An edge on a building was not skipped and counted"); + }); + yield return("fence lines validate grid runs, styles and gate placement",()=>{ + var p=MapGenerator.Generate(new(){Kind="blank"});p.FenceStyles=[Sheet];p.GateStyles=[Wicket,Double]; + p.Fences=[Yard(new FenceGate{At=0,Style="wicket"})];ProjectValidator.Validate(p);Check(p.Version==12,"A fence layer must raise the map format"); + var copy=JsonSerializer.Deserialize(JsonSerializer.Serialize(p,NativeMapExporter.JsonOptions),NativeMapExporter.JsonOptions)!;Check(JsonSerializer.Serialize(copy.Fences)==JsonSerializer.Serialize(p.Fences),"Fences did not survive JSON"); + void Bad(Action change){var q=JsonSerializer.Deserialize(JsonSerializer.Serialize(p,NativeMapExporter.JsonOptions),NativeMapExporter.JsonOptions)!;change(q);Reject(()=>ProjectValidator.Validate(q));} + Bad(q=>q.Fences[0].Points=[new(1,1),new(4,5)]);Bad(q=>q.Fences[0].Style="missing");Bad(q=>q.Fences[0].Gates=[new(){At=5,Style="double"}]); + Bad(q=>q.Fences[0].Gates=[new(){At=0,Style="wicket"},new(){At=0,Style="wicket"}]);Bad(q=>q.Fences[0].Gates=[new(){At=0,Style="wicket",OpensTo="up"}]); + Bad(q=>q.Fences[0].Gates=[new(){At=0,Style="double",Open=true}]);Bad(q=>q.FenceStyles[0].W="not a tile");Bad(q=>q.Fences.Add(Yard())); + }); + yield return("a fence shares a cell with a decoration and both reach the native lot",()=>{ + var p=MapGenerator.Generate(new(){Kind="blank"});p.FenceStyles=[Sheet];p.GateStyles=[Wicket];p.Fences=[Yard(new FenceGate{At=1,Style="wicket"})]; + p.Decorations=[new(){Id="bush",X=11,Y=10,Asset="vegetation_ornamental_01_0",Parts=[new(0,0,"vegetation_ornamental_01_0")]}];ProjectValidator.Validate(p); + var header=Lotheader(NativeMapExporter.Export(p),p.WorldX,p.WorldY); + foreach(var tile in new[]{"fencing_01_40","fencing_01_41","fencing_01_42","fixtures_doors_fences_01_13","vegetation_ornamental_01_0"})Check(header.Contains(tile),$"{tile} missing from the lot header"); + }); + yield return("a world fence crossing a section seam stays continuous in both sections",()=>{ + MapProject Section(int x){var s=MapGenerator.Generate(new(){Kind="blank"});s.WorldX=x;s.WorldY=100;s.WorldSection=true;s.Version=6;if(x!=100)s.Spawns=[];return s;} + var world=new WorldProject{Name="Seam",Maps=[new(){Project=Section(100)},new(){Project=Section(101)}],FenceStyles=[Sheet],Fences=[new(){Id="across",Style="sheet",Points=[new(100*256+250,100*256+20),new(101*256+6,100*256+20)]}]}; + WorldAssembler.Validate(world);Check(world.Version==7,"World fences must raise the world format"); + var left=WorldAssembler.ProjectSection(world,world.Maps[0]);var right=WorldAssembler.ProjectSection(world,world.Maps[1]); + var a=FenceLines.Compose(left.Fences,left.FenceStyles,left.GateStyles,256,256).Pieces;var b=FenceLines.Compose(right.Fences,right.FenceStyles,right.GateStyles,256,256).Pieces; + Check(a.Select(p=>p.X).OrderBy(x=>x).SequenceEqual(Enumerable.Range(250,6))&&b.Select(p=>p.X).OrderBy(x=>x).SequenceEqual(Enumerable.Range(0,6))&&a.Concat(b).All(p=>p.Y==20&&p.Tile=="fencing_01_41"),"Fence is not continuous across the seam"); + using var zip=new ZipArchive(new MemoryStream(NativeWorldExporter.Export(world))); + foreach(var cell in new[]{"100_100","101_100"}){var entry=zip.Entries.Single(e=>e.Name==$"{cell}.lotheader");using var r=new StreamReader(entry.Open(),Encoding.Latin1);Check(r.ReadToEnd().Contains("fencing_01_41"),$"Cell {cell} lost its part of the fence");} + world.Maps[1].Project.FenceStyles=[new(){Id="sheet",W="fencing_01_40",N="fencing_01_44"}];Reject(()=>WorldAssembler.Validate(world)); + }); + } + static string Lotheader(byte[] archive,int x,int y){using var zip=new ZipArchive(new MemoryStream(archive));var entry=zip.Entries.Single(e=>e.Name==$"{x}_{y}.lotheader");using var r=new StreamReader(entry.Open(),Encoding.Latin1);return r.ReadToEnd();} + static void Check(bool ok,string message){if(!ok)throw new Exception(message);} + static void Reject(Action action){try{action();}catch(ArgumentException){return;}throw new Exception("Expected validation failure");} +} diff --git a/tests/SimplePZMapmaker.Tests/Program.cs b/tests/SimplePZMapmaker.Tests/Program.cs index 5ecdab2..918aa16 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(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()).ToArray(); +var all=tests.Concat(GeoTests.Cases()).Concat(RotatedProjectionTests.Cases()).Concat(FenceLineTests.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()).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 e6e393cf368c7b9b06de82c4a54ee7052b31c254 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 20:35:35 +0000 Subject: [PATCH 2/2] fix: allow 100,000 fence lines (a whole village has ~40,000 straight runs) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015ECCGjSrSADcjxBmXdCBpm --- src/SimplePZMapmaker.Core/FenceLines.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SimplePZMapmaker.Core/FenceLines.cs b/src/SimplePZMapmaker.Core/FenceLines.cs index ba9bd12..7089860 100644 --- a/src/SimplePZMapmaker.Core/FenceLines.cs +++ b/src/SimplePZMapmaker.Core/FenceLines.cs @@ -46,7 +46,7 @@ public sealed class FenceLine } public static class FenceLines { - public const int MaximumLines=20000,MaximumPoints=512,MaximumEdges=250000,MaximumStyles=128; + public const int MaximumLines=100000,MaximumPoints=512,MaximumEdges=250000,MaximumStyles=128; /// One placed fence tile. Flags are the native navigation edge bits (2 north, 4 west). public sealed record Piece(int X,int Y,string Tile,byte Flags,string Line); public sealed record Composition(List Pieces,Dictionary Skipped);