@dreadwood-types/types 0.1.49 → 0.1.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/dist/adventure.d.ts +6 -6
  2. package/dist/adventure.d.ts.map +1 -1
  3. package/dist/appearance.d.ts +16 -6
  4. package/dist/appearance.d.ts.map +1 -1
  5. package/dist/appearance.js +46 -34
  6. package/dist/appearance.js.map +1 -1
  7. package/dist/bank.d.ts.map +1 -1
  8. package/dist/bearFloes.d.ts +83 -0
  9. package/dist/bearFloes.d.ts.map +1 -0
  10. package/dist/bearFloes.js +126 -0
  11. package/dist/bearFloes.js.map +1 -0
  12. package/dist/blacksmithing.d.ts.map +1 -1
  13. package/dist/blacksmithing.js +78 -1
  14. package/dist/blacksmithing.js.map +1 -1
  15. package/dist/bloodRiver.d.ts +9 -6
  16. package/dist/bloodRiver.d.ts.map +1 -1
  17. package/dist/bloodRiver.js +19 -17
  18. package/dist/bloodRiver.js.map +1 -1
  19. package/dist/brokenRoad.d.ts.map +1 -1
  20. package/dist/capital.d.ts +541 -180
  21. package/dist/capital.d.ts.map +1 -1
  22. package/dist/capital.js +934 -184
  23. package/dist/capital.js.map +1 -1
  24. package/dist/charcoal.d.ts +43 -0
  25. package/dist/charcoal.d.ts.map +1 -0
  26. package/dist/charcoal.js +78 -0
  27. package/dist/charcoal.js.map +1 -0
  28. package/dist/clientConfig.d.ts +7 -2
  29. package/dist/clientConfig.d.ts.map +1 -1
  30. package/dist/clientConfig.js +13 -3
  31. package/dist/clientConfig.js.map +1 -1
  32. package/dist/constants.d.ts +177 -9
  33. package/dist/constants.d.ts.map +1 -1
  34. package/dist/constants.js +336 -17
  35. package/dist/constants.js.map +1 -1
  36. package/dist/dragonfire.d.ts +75 -0
  37. package/dist/dragonfire.d.ts.map +1 -0
  38. package/dist/dragonfire.js +73 -0
  39. package/dist/dragonfire.js.map +1 -0
  40. package/dist/dungeonCharts.d.ts +18 -0
  41. package/dist/dungeonCharts.d.ts.map +1 -0
  42. package/dist/dungeonCharts.js +67 -0
  43. package/dist/dungeonCharts.js.map +1 -0
  44. package/dist/equipment.d.ts +2 -0
  45. package/dist/equipment.d.ts.map +1 -1
  46. package/dist/equipment.js +33 -1
  47. package/dist/equipment.js.map +1 -1
  48. package/dist/frozenSet.d.ts +36 -0
  49. package/dist/frozenSet.d.ts.map +1 -0
  50. package/dist/frozenSet.js +58 -0
  51. package/dist/frozenSet.js.map +1 -0
  52. package/dist/goblinCave.d.ts +4 -4
  53. package/dist/goblinCave.d.ts.map +1 -1
  54. package/dist/goblinCave.js +1 -1
  55. package/dist/groundItems.d.ts.map +1 -1
  56. package/dist/groundItems.js +16 -0
  57. package/dist/groundItems.js.map +1 -1
  58. package/dist/harbour.d.ts +60 -24
  59. package/dist/harbour.d.ts.map +1 -1
  60. package/dist/harbour.js +84 -50
  61. package/dist/harbour.js.map +1 -1
  62. package/dist/iceDepths.d.ts +11 -0
  63. package/dist/iceDepths.d.ts.map +1 -1
  64. package/dist/iceDepths.js +14 -0
  65. package/dist/iceDepths.js.map +1 -1
  66. package/dist/index.d.ts +7 -0
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +7 -0
  69. package/dist/index.js.map +1 -1
  70. package/dist/interiorFurniture.d.ts +57 -0
  71. package/dist/interiorFurniture.d.ts.map +1 -0
  72. package/dist/interiorFurniture.js +159 -0
  73. package/dist/interiorFurniture.js.map +1 -0
  74. package/dist/landmarks.d.ts +77 -58
  75. package/dist/landmarks.d.ts.map +1 -1
  76. package/dist/landmarks.js +145 -119
  77. package/dist/landmarks.js.map +1 -1
  78. package/dist/messages.d.ts +74 -5
  79. package/dist/messages.d.ts.map +1 -1
  80. package/dist/messages.js.map +1 -1
  81. package/dist/mineCave.d.ts +7 -7
  82. package/dist/mineCave.d.ts.map +1 -1
  83. package/dist/mining.d.ts +8 -0
  84. package/dist/mining.d.ts.map +1 -1
  85. package/dist/mining.js +8 -0
  86. package/dist/mining.js.map +1 -1
  87. package/dist/questGuide.d.ts +30 -0
  88. package/dist/questGuide.d.ts.map +1 -0
  89. package/dist/questGuide.js +3 -0
  90. package/dist/questGuide.js.map +1 -0
  91. package/dist/quests.d.ts +29 -0
  92. package/dist/quests.d.ts.map +1 -1
  93. package/dist/quests.js +70 -0
  94. package/dist/quests.js.map +1 -1
  95. package/dist/snow.d.ts +2 -2
  96. package/dist/snow.d.ts.map +1 -1
  97. package/dist/snow.js +8 -5
  98. package/dist/snow.js.map +1 -1
  99. package/dist/spiderCave.d.ts.map +1 -1
  100. package/dist/woodwork.d.ts +8 -2
  101. package/dist/woodwork.d.ts.map +1 -1
  102. package/dist/woodwork.js +13 -3
  103. package/dist/woodwork.js.map +1 -1
  104. package/dist/xp.d.ts +18 -7
  105. package/dist/xp.d.ts.map +1 -1
  106. package/dist/xp.js +20 -9
  107. package/dist/xp.js.map +1 -1
  108. package/dist/zones.d.ts +5 -0
  109. package/dist/zones.d.ts.map +1 -1
  110. package/dist/zones.js +8 -3
  111. package/dist/zones.js.map +1 -1
  112. package/package.json +1 -1
package/dist/constants.js CHANGED
@@ -63,14 +63,36 @@ export const PLAYABLE_CHUNKS_X = FARMLAND_CHUNKS_X + VILLAGE_CHUNKS_X + EASTLAND
63
63
  export const PLAYABLE_CHUNKS_Z = 3;
64
64
  export const FARMLAND_WIDTH = CHUNK_SIZE * FARMLAND_CHUNKS_X;
65
65
  export const WORLD_EAST_CHUNKS = 2;
66
- export const WORLD_NORTH_CHUNKS = 23;
66
+ /**
67
+ * The northern extension (James, 2026-09-25: "extend the map north a bit"
68
+ * for the Frozen Shore and its polar bears): two chunks of new rows above the
69
+ * old north edge. Every surface place moved south by NORTH_SHIFT rows with
70
+ * it, pad-relative ones through WORLD_NORTH_PAD and the hand-written world z
71
+ * values by +NORTH_SHIFT in place; the underground strip (x >=
72
+ * UNDERGROUND_ORIGIN_X) did not move. Coordinate hashes read `legacyZ` so the
73
+ * scatter stayed where it was, and saved characters are moved once, at the
74
+ * backend's boot (db/characters.ts, WORLD_REVISION). Proved with the
75
+ * backend's scripts/world-fingerprint.ts and world-shift-diff.ts.
76
+ */
77
+ export const WORLD_NORTH_EXTENSION_CHUNKS = 2;
78
+ export const NORTH_SHIFT = CHUNK_SIZE * WORLD_NORTH_EXTENSION_CHUNKS;
79
+ export const WORLD_NORTH_CHUNKS = 23 + WORLD_NORTH_EXTENSION_CHUNKS;
80
+ /**
81
+ * The world's layout revision, saved on each character: 1 is the 64-row
82
+ * northern extension. A character saved before it (no revision) is moved
83
+ * NORTH_SHIFT rows south once, when the backend boots, if it was on the
84
+ * surface.
85
+ */
86
+ export const WORLD_REVISION = 1;
67
87
  /**
68
88
  * Open sea south of the playable strip. Padding the far side of the beach,
69
89
  * not the near side, so every existing world coordinate is untouched - only
70
90
  * WORLD_HEIGHT grows. It exists so the Long Pier has somewhere to go: the
71
91
  * playable ocean is fourteen rows deep, which is a jetty, not a pier.
92
+ * Five since 2026-09-24, when the pier stretched to 130 rows: the ship loop
93
+ * (SeaTraffic) needs its offshore leg past the pier head's clearance.
72
94
  */
73
- export const WORLD_SOUTH_CHUNKS = 4;
95
+ export const WORLD_SOUTH_CHUNKS = 5;
74
96
  export const DEMO_CHUNKS_X = PLAYABLE_CHUNKS_X + WORLD_EAST_CHUNKS;
75
97
  export const DEMO_CHUNKS_Z = PLAYABLE_CHUNKS_Z + WORLD_NORTH_CHUNKS + WORLD_SOUTH_CHUNKS;
76
98
  export const PLAYABLE_WIDTH = CHUNK_SIZE * PLAYABLE_CHUNKS_X;
@@ -93,6 +115,14 @@ export const GRID_CHUNKS_X = DEMO_CHUNKS_X + UNDERGROUND_CHUNKS_X;
93
115
  export const GRID_CHUNKS_Z = DEMO_CHUNKS_Z;
94
116
  export const GRID_WIDTH = CHUNK_SIZE * GRID_CHUNKS_X;
95
117
  export const GRID_HEIGHT = CHUNK_SIZE * GRID_CHUNKS_Z;
118
+ /**
119
+ * The z a coordinate hash should read: the tile's z from before the northern
120
+ * extension, so scatter, noise and per-tile variety landed where they always
121
+ * had. The underground strip never moved, so its z is its own.
122
+ */
123
+ export function legacyZ(x, z) {
124
+ return x < UNDERGROUND_ORIGIN_X ? z - NORTH_SHIFT : z;
125
+ }
96
126
  /**
97
127
  * Kinds that never carry an Identify marker, because nothing happens when you
98
128
  * click them.
@@ -149,8 +179,12 @@ export const NON_IDENTIFIABLE_KINDS = new Set([
149
179
  'stalagmite',
150
180
  'icecrystal',
151
181
  'iceshard',
182
+ 'icefloe',
152
183
  'dreadprop',
184
+ 'dreadmodel',
153
185
  'depthsprop',
186
+ 'frozenchest',
187
+ 'charcoalmound',
154
188
  'icefall',
155
189
  'icearch',
156
190
  'icecell',
@@ -360,9 +394,15 @@ export function entityBlocksWalk(kind) {
360
394
  // The woods' props all hold their tile: a cart, gibbet or shrine is
361
395
  // solid, and nobody should stand in a grave or a cold fire pit either.
362
396
  kind === 'dreadprop' ||
397
+ // The woods' Blender props hold their whole footprint (visitBlockedWalkOffsets).
398
+ kind === 'dreadmodel' ||
363
399
  // The Depths' dressing is solid unless the style says floor (grates,
364
400
  // sigils, spills, lanterns hung from the dark); see visitBlockedWalkOffsets.
365
401
  kind === 'depthsprop' ||
402
+ // The Frozen Chest is an iron-bound box you open, not walk over.
403
+ kind === 'frozenchest' ||
404
+ // A charcoal mound is a heap of earth over smouldering logs.
405
+ kind === 'charcoalmound' ||
366
406
  // A table of fish and knives is furniture, not floor.
367
407
  kind === 'baittable' ||
368
408
  // The keep is a building you cannot enter yet; a plinth and a basin are solid.
@@ -382,6 +422,8 @@ export function entityBlocksWalk(kind) {
382
422
  kind === 'gardenwall' ||
383
423
  kind === 'banner' ||
384
424
  kind === 'yardprop' ||
425
+ // The Blender buildings: a hollow ring or solid, by model (CAPITAL_BUILDING_MODELS).
426
+ kind === 'capitalbuilding' ||
385
427
  // Crystals and pillars are solid; a gate bars its whole span and a rune
386
428
  // arch its two piers (see visitBlockedWalkOffsets). Frost traps and rope
387
429
  // bridges are floor you walk over.
@@ -704,6 +746,9 @@ export function buildingFloorSurface(kind, style) {
704
746
  return SurfaceType.Boards;
705
747
  if (kind === 'temple' || kind === 'forgehall' || kind === 'manor' || kind === 'castle')
706
748
  return SurfaceType.Flagstones;
749
+ // The Blender halls (the Grand Exchange, the Main Forge) stand on flags.
750
+ if (kind === 'capitalbuilding')
751
+ return SurfaceType.Flagstones;
707
752
  return SurfaceType.Path;
708
753
  }
709
754
  /**
@@ -777,6 +822,73 @@ export function rowhouseTokens(style) {
777
822
  const parts = (style ?? '').split('-');
778
823
  return new Set(parts.slice(1, Math.max(1, parts.length - 1)));
779
824
  }
825
+ export const CAPITAL_BUILDING_MODELS = {
826
+ // v003 (capital-workshop/scripts/grandexchange_v003.py): 19 x 13, door S.
827
+ // Porch columns at x +-2.15 and the small planters at x +-4.7 stand one
828
+ // row out (dz 7); the tree planters at x +-9.8 too; the lamp posts at x
829
+ // +-3.4 and the notice boards at x +-6.6 two rows out (dz 8).
830
+ grandexchange: {
831
+ across: 19,
832
+ deep: 13,
833
+ mouth: 1,
834
+ frontProps: [[-2, 7], [2, 7], [-5, 7], [5, 7], [-10, 7], [10, 7], [-3, 8], [3, 8], [-7, 8], [7, 8]],
835
+ },
836
+ mainforge: { across: 17, deep: 13, mouth: 5 },
837
+ smelter: { across: 7, deep: 7, mouth: 0, solid: true },
838
+ // The King's Statue v003 is 5 across (its cypresses and flower beds flank the
839
+ // plinth, as the Noble sheet draws it) and 3 deep; its plan rows reserve 5x5.
840
+ kingstatue: { across: 5, deep: 3, mouth: 0, solid: true },
841
+ keeplion: { across: 3, deep: 3, mouth: 0, solid: true },
842
+ victorystatue: { across: 5, deep: 5, mouth: 0, solid: true },
843
+ // The Dreadwoods' props (kind dreadmodel, dreadwoods-workshop): the Spider
844
+ // Matriarch's lair set. The gallows' beam runs across its three tiles.
845
+ spideregg: { across: 3, deep: 3, mouth: 0, solid: true },
846
+ cocoongallows: { across: 3, deep: 1, mouth: 0, solid: true },
847
+ webcolumn: { across: 1, deep: 1, mouth: 0, solid: true },
848
+ };
849
+ /** The model id in a `capitalbuilding` style (its first dash token), when it names one. */
850
+ export function capitalBuildingModelId(style) {
851
+ const id = (style ?? '').split('-')[0] ?? '';
852
+ return Object.prototype.hasOwnProperty.call(CAPITAL_BUILDING_MODELS, id) ? id : null;
853
+ }
854
+ export function capitalBuildingModel(style) {
855
+ const id = capitalBuildingModelId(style);
856
+ return id ? CAPITAL_BUILDING_MODELS[id] : null;
857
+ }
858
+ /**
859
+ * A local-frame offset (door on +Z) turned to the building's facing: the
860
+ * same turn the assets make (`facingYaw`) and visitFurnitureTiles applies.
861
+ */
862
+ export function turnBuildingOffset(lx, lz, facing) {
863
+ if (facing === 'negZ')
864
+ return [-lx, -lz];
865
+ if (facing === 'posX')
866
+ return [lz, -lx];
867
+ if (facing === 'negX')
868
+ return [-lz, lx];
869
+ return [lx, lz];
870
+ }
871
+ /**
872
+ * THE MOUTH RULE (2026-09-25). How many tiles either side of the door's
873
+ * middle tile are open on the door side: 2 for a `mouth5` style token, 1 for
874
+ * `mouth3`, 3 for `mouth7` (any odd `mouthN`), else the Blender model's own
875
+ * mouth, else 0 (a one-tile door). The collision (visitBlockedWalkOffsets,
876
+ * so the server and the client's prediction) and the roof hiding
877
+ * (isBuildingInteriorOffset) both take it, so the three always agree.
878
+ */
879
+ export function buildingDoorHalfWidth(kind, style) {
880
+ for (const token of (style ?? '').split('-')) {
881
+ const m = /^mouth(\d+)$/.exec(token);
882
+ if (m)
883
+ return Math.max(0, Math.floor((Number(m[1]) - 1) / 2));
884
+ }
885
+ if (kind === 'capitalbuilding') {
886
+ const model = capitalBuildingModel(style);
887
+ if (model && model.mouth > 1)
888
+ return Math.floor((model.mouth - 1) / 2);
889
+ }
890
+ return 0;
891
+ }
780
892
  /**
781
893
  * citywall: the piece stands on the wall's outer row and is two tiles thick
782
894
  * inward (n: +z is inward, s: -z, w: +x, e: -x). A bastion is a 3x3 drum on
@@ -822,19 +934,43 @@ export const GATEHOUSE_SIDE_HALF_SPAN = 4;
822
934
  export const GATEHOUSE_HALF_DEPTH = 3;
823
935
  export const GATEHOUSE_SIDE_HALF_DEPTH = 2;
824
936
  export const GATEHOUSE_PASSAGE_HALF_W = 2;
937
+ /**
938
+ * A gatehouse's facing: the LAST n/s/e/w token of its style (`main-w`,
939
+ * `side-e`, `main-shut-n`). The facing is always written last, because the
940
+ * frontend turns the model by the style's last token.
941
+ */
942
+ export function gatehouseFacing(style) {
943
+ const tokens = (style ?? 'main-w').split('-');
944
+ for (let i = tokens.length - 1; i >= 0; i--) {
945
+ const t = tokens[i];
946
+ if (t === 'n' || t === 's' || t === 'e' || t === 'w')
947
+ return t;
948
+ }
949
+ return 'w';
950
+ }
951
+ /**
952
+ * A barred gatehouse (2026-09-25, the Capital's North and South Gates):
953
+ * a `shut` token (`main-shut-n`) lets the portcullis down, and the passage
954
+ * blocks like the towers, so the collision alone keeps it shut whatever the
955
+ * builder does with the wall rows under it.
956
+ */
957
+ export function isGatehouseShut(style) {
958
+ return (style ?? '').split('-').includes('shut');
959
+ }
825
960
  /** Which way the passage runs: -w/-e styles run along x, -n/-s along z. */
826
961
  export function gatehousePassageAlongX(style) {
827
- const facing = (style ?? 'main-w').split('-').pop();
962
+ const facing = gatehouseFacing(style);
828
963
  return facing === 'w' || facing === 'e';
829
964
  }
830
965
  export function visitGatehouseOffsets(style, visit) {
831
966
  const alongX = gatehousePassageAlongX(style);
832
967
  const main = (style ?? 'main-w').startsWith('main');
968
+ const shut = isGatehouseShut(style);
833
969
  const span = main ? GATEHOUSE_HALF_SPAN : GATEHOUSE_SIDE_HALF_SPAN;
834
970
  const depth = main ? GATEHOUSE_HALF_DEPTH : GATEHOUSE_SIDE_HALF_DEPTH;
835
971
  for (let along = -depth; along <= depth; along++) {
836
972
  for (let across = -span; across <= span; across++) {
837
- if (Math.abs(across) <= GATEHOUSE_PASSAGE_HALF_W)
973
+ if (!shut && Math.abs(across) <= GATEHOUSE_PASSAGE_HALF_W)
838
974
  continue;
839
975
  if (alongX)
840
976
  visit(along, across);
@@ -849,6 +985,17 @@ export function buildingFootprintExtents(kind, scale, style) {
849
985
  }
850
986
  if (kind === 'castle')
851
987
  return { radiusX: CASTLE_KEEP_HALF_X, radiusZ: CASTLE_KEEP_HALF_Z };
988
+ if (kind === 'capitalbuilding' || kind === 'dreadmodel') {
989
+ const model = capitalBuildingModel(style);
990
+ if (!model)
991
+ return { radiusX: 0, radiusZ: 0 };
992
+ const across = Math.floor((model.across - 1) / 2);
993
+ const deep = Math.floor((model.deep - 1) / 2);
994
+ const facing = stoneHouseDoorFacing(style);
995
+ if (facing === 'posX' || facing === 'negX')
996
+ return { radiusX: deep, radiusZ: across };
997
+ return { radiusX: across, radiusZ: deep };
998
+ }
852
999
  if (kind === 'stonehouse') {
853
1000
  const variant = stoneHouseVariant(style);
854
1001
  if (variant === 'lodge') {
@@ -902,19 +1049,26 @@ export function buildingFootprintRadius(kind, scale, style) {
902
1049
  const { radiusX, radiusZ } = buildingFootprintExtents(kind, scale, style);
903
1050
  return Math.max(radiusX, radiusZ);
904
1051
  }
905
- export function isBuildingDoorOffset(dx, dz, footprint, doorFacing = 'posZ') {
1052
+ /**
1053
+ * The door opening on a building's ring. `halfW` is the mouth rule's
1054
+ * half-width (buildingDoorHalfWidth): 0 is the one middle tile, 2 a
1055
+ * five-wide mouth. The corners always stay wall.
1056
+ */
1057
+ export function isBuildingDoorOffset(dx, dz, footprint, doorFacing = 'posZ', halfW = 0) {
906
1058
  const { radiusX, radiusZ } = normalizeBuildingFootprint(footprint);
907
1059
  if (radiusX <= 0 || radiusZ <= 0)
908
1060
  return false;
1061
+ const alongX = doorFacing === 'posZ' || doorFacing === 'negZ';
1062
+ const w = Math.max(0, Math.min(Math.floor(halfW), (alongX ? radiusX : radiusZ) - 1));
909
1063
  if (doorFacing === 'posZ')
910
- return dx === 0 && dz === radiusZ;
1064
+ return Math.abs(dx) <= w && dz === radiusZ;
911
1065
  if (doorFacing === 'negZ')
912
- return dx === 0 && dz === -radiusZ;
1066
+ return Math.abs(dx) <= w && dz === -radiusZ;
913
1067
  if (doorFacing === 'posX')
914
- return dx === radiusX && dz === 0;
915
- return dx === -radiusX && dz === 0;
1068
+ return dx === radiusX && Math.abs(dz) <= w;
1069
+ return dx === -radiusX && Math.abs(dz) <= w;
916
1070
  }
917
- export function buildingWallBlocksOffset(dx, dz, footprint, doorFacing = 'posZ') {
1071
+ export function buildingWallBlocksOffset(dx, dz, footprint, doorFacing = 'posZ', halfW = 0) {
918
1072
  const { radiusX, radiusZ } = normalizeBuildingFootprint(footprint);
919
1073
  if (radiusX <= 0 || radiusZ <= 0)
920
1074
  return false;
@@ -923,15 +1077,21 @@ export function buildingWallBlocksOffset(dx, dz, footprint, doorFacing = 'posZ')
923
1077
  const onWallRing = Math.abs(dx) === radiusX || Math.abs(dz) === radiusZ;
924
1078
  if (!onWallRing)
925
1079
  return false;
926
- return !isBuildingDoorOffset(dx, dz, { radiusX, radiusZ }, doorFacing);
1080
+ return !isBuildingDoorOffset(dx, dz, { radiusX, radiusZ }, doorFacing, halfW);
927
1081
  }
928
- export function isBuildingInteriorOffset(dx, dz, footprint, doorFacing = 'posZ') {
1082
+ /**
1083
+ * Inside the building for the roof hide: any footprint tile that is not a
1084
+ * blocked ring tile (the door, or the whole mouth, counts as inside). Pass
1085
+ * the mouth rule's `halfW` (buildingDoorHalfWidth(kind, style)) so the roof
1086
+ * lifts across a wide mouth exactly where the collision opens it.
1087
+ */
1088
+ export function isBuildingInteriorOffset(dx, dz, footprint, doorFacing = 'posZ', halfW = 0) {
929
1089
  const { radiusX, radiusZ } = normalizeBuildingFootprint(footprint);
930
1090
  if (radiusX <= 0 || radiusZ <= 0)
931
1091
  return false;
932
1092
  if (Math.abs(dx) > radiusX || Math.abs(dz) > radiusZ)
933
1093
  return false;
934
- return !buildingWallBlocksOffset(dx, dz, { radiusX, radiusZ }, doorFacing);
1094
+ return !buildingWallBlocksOffset(dx, dz, { radiusX, radiusZ }, doorFacing, halfW);
935
1095
  }
936
1096
  export function boulderFootprintRadius(style, scale) {
937
1097
  if (style !== 'boulder')
@@ -1199,6 +1359,97 @@ export function faceStyleDegrees(style) {
1199
1359
  const deg = Number(face[1]);
1200
1360
  return Number.isFinite(deg) ? deg : null;
1201
1361
  }
1362
+ export function parseGardenWallRun(style) {
1363
+ const m = /^run-(\d+)-(ew|ns)(?:-(stone|hedge|noble|fence))?$/.exec(style ?? '');
1364
+ if (!m)
1365
+ return null;
1366
+ const len = Number(m[1]);
1367
+ if (!Number.isFinite(len) || len < 1)
1368
+ return null;
1369
+ return { len, alongX: m[2] === 'ew', look: m[3] ?? 'stone' };
1370
+ }
1371
+ export function gardenWallRunStyle(len, alongX, look = 'stone') {
1372
+ return `run-${Math.max(1, Math.floor(len))}-${alongX ? 'ew' : 'ns'}${look === 'stone' ? '' : `-${look}`}`;
1373
+ }
1374
+ /** The walk-through gate piece for a wall of `look`: noble walls take the iron gate. */
1375
+ export function gardenWallGateStyle(alongX, look = 'stone') {
1376
+ const token = look === 'noble' ? 'iron' : look === 'stone' ? '' : look;
1377
+ return `gate${token ? `-${token}` : ''}-${alongX ? 'ew' : 'ns'}`;
1378
+ }
1379
+ export function isGardenWallGateStyle(style) {
1380
+ return typeof style === 'string' && style.startsWith('gate');
1381
+ }
1382
+ /** The tiles a gardenwall blocks: a whole run, one piece, or nothing for a gate. */
1383
+ export function visitGardenWallOffsets(style, visit) {
1384
+ if (isGardenWallGateStyle(style))
1385
+ return;
1386
+ const run = parseGardenWallRun(style);
1387
+ if (!run) {
1388
+ visit(0, 0);
1389
+ return;
1390
+ }
1391
+ for (let i = 0; i < run.len; i++) {
1392
+ if (run.alongX)
1393
+ visit(i, 0);
1394
+ else
1395
+ visit(0, i);
1396
+ }
1397
+ }
1398
+ /* ------------------------------------------------------------------ *
1399
+ * Tree groves (the Capital redesign, 2026-09-25): a clump of trees drawn as
1400
+ * ONE entity with one mesh per material, for the city's orchards, gardens
1401
+ * and verges. Style `grove-<3|5>[-<species>][-scenic]`: a 3 x 3 clump of
1402
+ * three trees or a 5 x 5 clump of five, about the entity's tile. Each trunk
1403
+ * stands on the tile TREE_GROVE_TRUNKS gives it (the frontend plants the
1404
+ * trees exactly there) and blocks that tile only; the canopy between is
1405
+ * walk-under, as it is round a single tree. A grove is never cut down
1406
+ * (woodwork.ts isCuttableTreeStyle).
1407
+ * ------------------------------------------------------------------ */
1408
+ export const TREE_GROVE_TRUNKS = {
1409
+ 'grove-3': [[-1, -1], [1, -1], [0, 1]],
1410
+ 'grove-5': [[-2, -2], [1, -2], [-1, 0], [2, 1], [-1, 2]],
1411
+ };
1412
+ /** The grove size a tree style names (3 or 5), or null for a single tree. */
1413
+ export function treeGroveSize(style) {
1414
+ if (!style)
1415
+ return null;
1416
+ if (style === 'grove-3' || style.startsWith('grove-3-'))
1417
+ return 3;
1418
+ if (style === 'grove-5' || style.startsWith('grove-5-'))
1419
+ return 5;
1420
+ return null;
1421
+ }
1422
+ /** The trunk tiles of a grove style, about its entity tile, or null for a single tree. */
1423
+ export function treeGroveTrunks(style) {
1424
+ const size = treeGroveSize(style);
1425
+ return size === null ? null : TREE_GROVE_TRUNKS[size === 3 ? 'grove-3' : 'grove-5'];
1426
+ }
1427
+ /*
1428
+ * Statue plinths (the Capital's monuments, 2026-09-25 review): a `statue`
1429
+ * whose style carries a `plinth3` or `plinth5` token stands on a stepped
1430
+ * stone plinth three or five tiles square about its tile, and the whole
1431
+ * square blocks (visitBlockedWalkOffsets). Style `<figure>[-plinth<3|5>][-<facing>]`,
1432
+ * e.g. `king-plinth5`, `knight-plinth3-w`: the figure token first (the asset
1433
+ * reads `king` / `knight` from it) and the facing last, so a statue without
1434
+ * the token is the one-tile statue it always was.
1435
+ */
1436
+ export function statuePlinthSize(style) {
1437
+ const tokens = (style ?? '').split('-');
1438
+ if (tokens.includes('plinth5'))
1439
+ return 5;
1440
+ if (tokens.includes('plinth3'))
1441
+ return 3;
1442
+ return 0;
1443
+ }
1444
+ /** A statue style with its plinth token, the facing (if any) kept last. */
1445
+ export function statueStyleWithPlinth(style, size) {
1446
+ if (!size)
1447
+ return style;
1448
+ const tokens = style.split('-').filter((t) => t !== 'plinth3' && t !== 'plinth5');
1449
+ const last = tokens[tokens.length - 1];
1450
+ const facing = tokens.length > 1 && (last === 'n' || last === 's' || last === 'e' || last === 'w') ? tokens.pop() : undefined;
1451
+ return [...tokens, `plinth${size}`, ...(facing ? [facing] : [])].join('-');
1452
+ }
1202
1453
  /**
1203
1454
  * A work bench is a long table: 2.2 units along its local X and 1.0 deep at
1204
1455
  * scale 1 (createWorkBenchAsset; the mine's is the same shape), so at the
@@ -1340,6 +1591,30 @@ export function visitBlockedWalkOffsets(kind, style, scale, visit, doorFacing =
1340
1591
  }
1341
1592
  return;
1342
1593
  }
1594
+ if (kind === 'capitalbuilding' || kind === 'dreadmodel') {
1595
+ // The Blender buildings (CAPITAL_BUILDING_MODELS). The facing always
1596
+ // comes from the style, whatever door facing the caller passed, so a
1597
+ // stamp without the door flag still agrees with the client.
1598
+ const model = capitalBuildingModel(style);
1599
+ if (!model) {
1600
+ visit(0, 0);
1601
+ return;
1602
+ }
1603
+ const fp = buildingFootprintExtents(kind, scale, style);
1604
+ const facing = stoneHouseDoorFacing(style);
1605
+ const halfW = buildingDoorHalfWidth(kind, style);
1606
+ for (let dz = -fp.radiusZ; dz <= fp.radiusZ; dz++) {
1607
+ for (let dx = -fp.radiusX; dx <= fp.radiusX; dx++) {
1608
+ if (model.solid || buildingWallBlocksOffset(dx, dz, fp, facing, halfW))
1609
+ visit(dx, dz);
1610
+ }
1611
+ }
1612
+ for (const [lx, lz] of model.frontProps ?? []) {
1613
+ const [dx, dz] = turnBuildingOffset(lx, lz, facing);
1614
+ visit(dx, dz);
1615
+ }
1616
+ return;
1617
+ }
1343
1618
  if (kind === 'citywall') {
1344
1619
  visitCityWallOffsets(style, visit);
1345
1620
  return;
@@ -1349,11 +1624,18 @@ export function visitBlockedWalkOffsets(kind, style, scale, visit, doorFacing =
1349
1624
  return;
1350
1625
  }
1351
1626
  if (kind === 'gardenwall') {
1352
- if (style?.startsWith('gate'))
1353
- return;
1354
- visit(0, 0);
1627
+ visitGardenWallOffsets(style, visit);
1355
1628
  return;
1356
1629
  }
1630
+ if (kind === 'tree') {
1631
+ // A grove blocks its trunks; a single tree falls through to its own tile.
1632
+ const trunks = treeGroveTrunks(style);
1633
+ if (trunks) {
1634
+ for (const [dx, dz] of trunks)
1635
+ visit(dx, dz);
1636
+ return;
1637
+ }
1638
+ }
1357
1639
  if (kind === 'fountain') {
1358
1640
  // The basin: three by three about the centre.
1359
1641
  for (let dz = -1; dz <= 1; dz++) {
@@ -1362,6 +1644,18 @@ export function visitBlockedWalkOffsets(kind, style, scale, visit, doorFacing =
1362
1644
  }
1363
1645
  return;
1364
1646
  }
1647
+ if (kind === 'statue') {
1648
+ // A statue on a stepped plinth blocks the plinth's square; one without
1649
+ // a plinth token falls through to its own tile.
1650
+ const half = (statuePlinthSize(style) - 1) / 2;
1651
+ if (half > 0) {
1652
+ for (let dz = -half; dz <= half; dz++) {
1653
+ for (let dx = -half; dx <= half; dx++)
1654
+ visit(dx, dz);
1655
+ }
1656
+ return;
1657
+ }
1658
+ }
1365
1659
  if (kind === 'brokengate') {
1366
1660
  // Posts and wreckage across three tiles; nobody climbs the splinters.
1367
1661
  const acrossZ = frostSpanAcrossZ(style);
@@ -1387,6 +1681,29 @@ export function visitBlockedWalkOffsets(kind, style, scale, visit, doorFacing =
1387
1681
  }
1388
1682
  return;
1389
1683
  }
1684
+ if (kind === 'marketstall') {
1685
+ // A stall is a counter two tiles wide under an awning, and the trailing
1686
+ // letter of the style says which way it fronts. Only its middle tile was
1687
+ // ever solid, so both ends of the counter were walk-through (James,
1688
+ // 2026-09-22: "I can walk through market stalls"). Hope Beach's coastal
1689
+ // stalls are the big ones, five along the counter with their barrels -
1690
+ // the same shape hopeBeach.ts already stamps on the server, now shared
1691
+ // with the client so its prediction agrees.
1692
+ const facing = typeof style === 'string' ? style.charAt(style.length - 1) : 's';
1693
+ const alongZ = facing === 'e' || facing === 'w';
1694
+ const coastal = typeof style === 'string' && style.startsWith('coastal-');
1695
+ const half = coastal ? 2 : 1;
1696
+ const deep = coastal ? 1 : 0;
1697
+ for (let a = -half; a <= half; a++) {
1698
+ for (let b = -deep; b <= deep; b++) {
1699
+ if (alongZ)
1700
+ visit(b, a);
1701
+ else
1702
+ visit(a, b);
1703
+ }
1704
+ }
1705
+ return;
1706
+ }
1390
1707
  if (kind === 'fence' && isFenceGateStyle(style)) {
1391
1708
  if (isOpenGateStyle(style))
1392
1709
  return;
@@ -1414,9 +1731,11 @@ export function visitBlockedWalkOffsets(kind, style, scale, visit, doorFacing =
1414
1731
  if (buildingFp.radiusX > 0 && buildingFp.radiusZ > 0) {
1415
1732
  // Hollow buildings: only the wall ring is solid. The origin is interior
1416
1733
  // floor - blocking it sealed the storage hut so you could never stand inside.
1734
+ // The door is one tile, or a wider mouth by the mouth rule (a `mouth5` token).
1735
+ const mouthHalfW = buildingDoorHalfWidth(kind, style);
1417
1736
  for (let dz = -buildingFp.radiusZ; dz <= buildingFp.radiusZ; dz++) {
1418
1737
  for (let dx = -buildingFp.radiusX; dx <= buildingFp.radiusX; dx++) {
1419
- if (!buildingWallBlocksOffset(dx, dz, buildingFp, doorFacing))
1738
+ if (!buildingWallBlocksOffset(dx, dz, buildingFp, doorFacing, mouthHalfW))
1420
1739
  continue;
1421
1740
  // Cottage is a walk-through: south porch door and north garden door.
1422
1741
  if (kind === 'farmhut' &&