@dreadwood-types/types 0.1.47 → 0.1.51

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 (124) 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 +43 -31
  6. package/dist/appearance.js.map +1 -1
  7. package/dist/areaTransitions.d.ts +1 -1
  8. package/dist/areaTransitions.d.ts.map +1 -1
  9. package/dist/areaTransitions.js +13 -0
  10. package/dist/areaTransitions.js.map +1 -1
  11. package/dist/bank.d.ts.map +1 -1
  12. package/dist/bearFloes.d.ts +83 -0
  13. package/dist/bearFloes.d.ts.map +1 -0
  14. package/dist/bearFloes.js +126 -0
  15. package/dist/bearFloes.js.map +1 -0
  16. package/dist/blacksmithing.d.ts +3 -0
  17. package/dist/blacksmithing.d.ts.map +1 -1
  18. package/dist/blacksmithing.js +81 -1
  19. package/dist/blacksmithing.js.map +1 -1
  20. package/dist/bloodRiver.d.ts +9 -6
  21. package/dist/bloodRiver.d.ts.map +1 -1
  22. package/dist/bloodRiver.js +19 -17
  23. package/dist/bloodRiver.js.map +1 -1
  24. package/dist/brokenRoad.d.ts.map +1 -1
  25. package/dist/capital.d.ts +541 -165
  26. package/dist/capital.d.ts.map +1 -1
  27. package/dist/capital.js +934 -163
  28. package/dist/capital.js.map +1 -1
  29. package/dist/charcoal.d.ts +43 -0
  30. package/dist/charcoal.d.ts.map +1 -0
  31. package/dist/charcoal.js +78 -0
  32. package/dist/charcoal.js.map +1 -0
  33. package/dist/clientConfig.d.ts +7 -2
  34. package/dist/clientConfig.d.ts.map +1 -1
  35. package/dist/clientConfig.js +14 -5
  36. package/dist/clientConfig.js.map +1 -1
  37. package/dist/constants.d.ts +196 -11
  38. package/dist/constants.d.ts.map +1 -1
  39. package/dist/constants.js +372 -21
  40. package/dist/constants.js.map +1 -1
  41. package/dist/cooking.d.ts.map +1 -1
  42. package/dist/cooking.js +2 -0
  43. package/dist/cooking.js.map +1 -1
  44. package/dist/dragonfire.d.ts +75 -0
  45. package/dist/dragonfire.d.ts.map +1 -0
  46. package/dist/dragonfire.js +73 -0
  47. package/dist/dragonfire.js.map +1 -0
  48. package/dist/dungeonCharts.d.ts +18 -0
  49. package/dist/dungeonCharts.d.ts.map +1 -0
  50. package/dist/dungeonCharts.js +67 -0
  51. package/dist/dungeonCharts.js.map +1 -0
  52. package/dist/equipment.d.ts +2 -0
  53. package/dist/equipment.d.ts.map +1 -1
  54. package/dist/equipment.js +37 -1
  55. package/dist/equipment.js.map +1 -1
  56. package/dist/frozenSet.d.ts +36 -0
  57. package/dist/frozenSet.d.ts.map +1 -0
  58. package/dist/frozenSet.js +58 -0
  59. package/dist/frozenSet.js.map +1 -0
  60. package/dist/goblinCave.d.ts +4 -4
  61. package/dist/goblinCave.d.ts.map +1 -1
  62. package/dist/goblinCave.js +1 -1
  63. package/dist/groundItems.d.ts.map +1 -1
  64. package/dist/groundItems.js +27 -0
  65. package/dist/groundItems.js.map +1 -1
  66. package/dist/harbour.d.ts +223 -0
  67. package/dist/harbour.d.ts.map +1 -0
  68. package/dist/harbour.js +264 -0
  69. package/dist/harbour.js.map +1 -0
  70. package/dist/iceDepths.d.ts +150 -0
  71. package/dist/iceDepths.d.ts.map +1 -0
  72. package/dist/iceDepths.js +132 -0
  73. package/dist/iceDepths.js.map +1 -0
  74. package/dist/index.d.ts +9 -0
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +9 -0
  77. package/dist/index.js.map +1 -1
  78. package/dist/interiorFurniture.d.ts +57 -0
  79. package/dist/interiorFurniture.d.ts.map +1 -0
  80. package/dist/interiorFurniture.js +159 -0
  81. package/dist/interiorFurniture.js.map +1 -0
  82. package/dist/landmarks.d.ts +83 -59
  83. package/dist/landmarks.d.ts.map +1 -1
  84. package/dist/landmarks.js +194 -134
  85. package/dist/landmarks.js.map +1 -1
  86. package/dist/messages.d.ts +77 -5
  87. package/dist/messages.d.ts.map +1 -1
  88. package/dist/messages.js.map +1 -1
  89. package/dist/mineCave.d.ts +7 -7
  90. package/dist/mineCave.d.ts.map +1 -1
  91. package/dist/mining.d.ts +8 -0
  92. package/dist/mining.d.ts.map +1 -1
  93. package/dist/mining.js +8 -0
  94. package/dist/mining.js.map +1 -1
  95. package/dist/questGuide.d.ts +30 -0
  96. package/dist/questGuide.d.ts.map +1 -0
  97. package/dist/questGuide.js +3 -0
  98. package/dist/questGuide.js.map +1 -0
  99. package/dist/quests.d.ts +29 -0
  100. package/dist/quests.d.ts.map +1 -1
  101. package/dist/quests.js +70 -0
  102. package/dist/quests.js.map +1 -1
  103. package/dist/snow.d.ts +2 -2
  104. package/dist/snow.d.ts.map +1 -1
  105. package/dist/snow.js +8 -5
  106. package/dist/snow.js.map +1 -1
  107. package/dist/spiderCave.d.ts.map +1 -1
  108. package/dist/travel.d.ts +7 -4
  109. package/dist/travel.d.ts.map +1 -1
  110. package/dist/travel.js +20 -4
  111. package/dist/travel.js.map +1 -1
  112. package/dist/woodwork.d.ts +8 -2
  113. package/dist/woodwork.d.ts.map +1 -1
  114. package/dist/woodwork.js +13 -3
  115. package/dist/woodwork.js.map +1 -1
  116. package/dist/xp.d.ts +18 -7
  117. package/dist/xp.d.ts.map +1 -1
  118. package/dist/xp.js +20 -9
  119. package/dist/xp.js.map +1 -1
  120. package/dist/zones.d.ts +5 -0
  121. package/dist/zones.d.ts.map +1 -1
  122. package/dist/zones.js +8 -3
  123. package/dist/zones.js.map +1 -1
  124. package/package.json +1 -1
package/dist/constants.js CHANGED
@@ -1,5 +1,15 @@
1
1
  import { filterProfanity, textHasProfanity } from './profanity.js';
2
2
  import { isCarriageStyle } from './woodwork.js';
3
+ /**
4
+ * The Frozen Depths' dressing kind (iceDepths.ts lists the styles): solid
5
+ * unless the style is floor. Lives here, not in iceDepths.ts, because that
6
+ * file reads this one's constants and a cycle would evaluate them as
7
+ * undefined.
8
+ */
9
+ const ICE_DEPTHS_WALK_THROUGH = new Set(['grate', 'medallion', 'runecircle', 'spill-red', 'spill-blue', 'spill-green', 'lantern-red', 'lantern-blue', 'lantern-green', 'ladder']);
10
+ export function iceDepthsPropBlocks(style) {
11
+ return !ICE_DEPTHS_WALK_THROUGH.has(style ?? '');
12
+ }
3
13
  export const TILE_SIZE = 1;
4
14
  export const CHAT_MAX_CHARS = 120;
5
15
  export const CHAT_MAX_WORDS = 24;
@@ -53,14 +63,36 @@ export const PLAYABLE_CHUNKS_X = FARMLAND_CHUNKS_X + VILLAGE_CHUNKS_X + EASTLAND
53
63
  export const PLAYABLE_CHUNKS_Z = 3;
54
64
  export const FARMLAND_WIDTH = CHUNK_SIZE * FARMLAND_CHUNKS_X;
55
65
  export const WORLD_EAST_CHUNKS = 2;
56
- 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;
57
87
  /**
58
88
  * Open sea south of the playable strip. Padding the far side of the beach,
59
89
  * not the near side, so every existing world coordinate is untouched - only
60
90
  * WORLD_HEIGHT grows. It exists so the Long Pier has somewhere to go: the
61
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.
62
94
  */
63
- export const WORLD_SOUTH_CHUNKS = 4;
95
+ export const WORLD_SOUTH_CHUNKS = 5;
64
96
  export const DEMO_CHUNKS_X = PLAYABLE_CHUNKS_X + WORLD_EAST_CHUNKS;
65
97
  export const DEMO_CHUNKS_Z = PLAYABLE_CHUNKS_Z + WORLD_NORTH_CHUNKS + WORLD_SOUTH_CHUNKS;
66
98
  export const PLAYABLE_WIDTH = CHUNK_SIZE * PLAYABLE_CHUNKS_X;
@@ -83,6 +115,14 @@ export const GRID_CHUNKS_X = DEMO_CHUNKS_X + UNDERGROUND_CHUNKS_X;
83
115
  export const GRID_CHUNKS_Z = DEMO_CHUNKS_Z;
84
116
  export const GRID_WIDTH = CHUNK_SIZE * GRID_CHUNKS_X;
85
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
+ }
86
126
  /**
87
127
  * Kinds that never carry an Identify marker, because nothing happens when you
88
128
  * click them.
@@ -139,7 +179,11 @@ export const NON_IDENTIFIABLE_KINDS = new Set([
139
179
  'stalagmite',
140
180
  'icecrystal',
141
181
  'iceshard',
182
+ 'icefloe',
142
183
  'dreadprop',
184
+ 'depthsprop',
185
+ 'frozenchest',
186
+ 'charcoalmound',
143
187
  'icefall',
144
188
  'icearch',
145
189
  'icecell',
@@ -349,6 +393,13 @@ export function entityBlocksWalk(kind) {
349
393
  // The woods' props all hold their tile: a cart, gibbet or shrine is
350
394
  // solid, and nobody should stand in a grave or a cold fire pit either.
351
395
  kind === 'dreadprop' ||
396
+ // The Depths' dressing is solid unless the style says floor (grates,
397
+ // sigils, spills, lanterns hung from the dark); see visitBlockedWalkOffsets.
398
+ kind === 'depthsprop' ||
399
+ // The Frozen Chest is an iron-bound box you open, not walk over.
400
+ kind === 'frozenchest' ||
401
+ // A charcoal mound is a heap of earth over smouldering logs.
402
+ kind === 'charcoalmound' ||
352
403
  // A table of fish and knives is furniture, not floor.
353
404
  kind === 'baittable' ||
354
405
  // The keep is a building you cannot enter yet; a plinth and a basin are solid.
@@ -368,6 +419,8 @@ export function entityBlocksWalk(kind) {
368
419
  kind === 'gardenwall' ||
369
420
  kind === 'banner' ||
370
421
  kind === 'yardprop' ||
422
+ // The Blender buildings: a hollow ring or solid, by model (CAPITAL_BUILDING_MODELS).
423
+ kind === 'capitalbuilding' ||
371
424
  // Crystals and pillars are solid; a gate bars its whole span and a rune
372
425
  // arch its two piers (see visitBlockedWalkOffsets). Frost traps and rope
373
426
  // bridges are floor you walk over.
@@ -688,10 +741,29 @@ export function buildingFloorSurface(kind, style) {
688
741
  }
689
742
  if (kind === 'warehouse')
690
743
  return SurfaceType.Boards;
691
- if (kind === 'temple' || kind === 'forgehall' || kind === 'manor')
744
+ if (kind === 'temple' || kind === 'forgehall' || kind === 'manor' || kind === 'castle')
745
+ return SurfaceType.Flagstones;
746
+ // The Blender halls (the Grand Exchange, the Main Forge) stand on flags.
747
+ if (kind === 'capitalbuilding')
692
748
  return SurfaceType.Flagstones;
693
749
  return SurfaceType.Path;
694
750
  }
751
+ /**
752
+ * The walkable inside of the Great Keep, in tile offsets from its centre:
753
+ * the gate passage (three wide on the keep's row, from the west face to
754
+ * the hall) and the throne hall (the great hall's floor, between its walls
755
+ * at local x -4.25 and 5.25 and z +-7). The asset and the server both
756
+ * build to this; the door is the passage's mouth on the west face.
757
+ */
758
+ export const CASTLE_PASSAGE_HALF_W = 1;
759
+ export const CASTLE_HALL_MIN_DX = -3;
760
+ export const CASTLE_HALL_MAX_DX = 4;
761
+ export const CASTLE_HALL_HALF_DZ = 6;
762
+ export function inCastleKeepRoom(dx, dz) {
763
+ if (Math.abs(dz) <= CASTLE_PASSAGE_HALF_W && dx >= -CASTLE_KEEP_HALF_X && dx < CASTLE_HALL_MIN_DX)
764
+ return true;
765
+ return dx >= CASTLE_HALL_MIN_DX && dx <= CASTLE_HALL_MAX_DX && Math.abs(dz) <= CASTLE_HALL_HALF_DZ;
766
+ }
695
767
  /**
696
768
  * Which way the door looks, as a world-space facing.
697
769
  *
@@ -747,6 +819,68 @@ export function rowhouseTokens(style) {
747
819
  const parts = (style ?? '').split('-');
748
820
  return new Set(parts.slice(1, Math.max(1, parts.length - 1)));
749
821
  }
822
+ export const CAPITAL_BUILDING_MODELS = {
823
+ // v003 (capital-workshop/scripts/grandexchange_v003.py): 19 x 13, door S.
824
+ // Porch columns at x +-2.15 and the small planters at x +-4.7 stand one
825
+ // row out (dz 7); the tree planters at x +-9.8 too; the lamp posts at x
826
+ // +-3.4 and the notice boards at x +-6.6 two rows out (dz 8).
827
+ grandexchange: {
828
+ across: 19,
829
+ deep: 13,
830
+ mouth: 1,
831
+ frontProps: [[-2, 7], [2, 7], [-5, 7], [5, 7], [-10, 7], [10, 7], [-3, 8], [3, 8], [-7, 8], [7, 8]],
832
+ },
833
+ mainforge: { across: 17, deep: 13, mouth: 5 },
834
+ smelter: { across: 7, deep: 7, mouth: 0, solid: true },
835
+ // The King's Statue v003 is 5 across (its cypresses and flower beds flank the
836
+ // plinth, as the Noble sheet draws it) and 3 deep; its plan rows reserve 5x5.
837
+ kingstatue: { across: 5, deep: 3, mouth: 0, solid: true },
838
+ keeplion: { across: 3, deep: 3, mouth: 0, solid: true },
839
+ victorystatue: { across: 5, deep: 5, mouth: 0, solid: true },
840
+ };
841
+ /** The model id in a `capitalbuilding` style (its first dash token), when it names one. */
842
+ export function capitalBuildingModelId(style) {
843
+ const id = (style ?? '').split('-')[0] ?? '';
844
+ return Object.prototype.hasOwnProperty.call(CAPITAL_BUILDING_MODELS, id) ? id : null;
845
+ }
846
+ export function capitalBuildingModel(style) {
847
+ const id = capitalBuildingModelId(style);
848
+ return id ? CAPITAL_BUILDING_MODELS[id] : null;
849
+ }
850
+ /**
851
+ * A local-frame offset (door on +Z) turned to the building's facing: the
852
+ * same turn the assets make (`facingYaw`) and visitFurnitureTiles applies.
853
+ */
854
+ export function turnBuildingOffset(lx, lz, facing) {
855
+ if (facing === 'negZ')
856
+ return [-lx, -lz];
857
+ if (facing === 'posX')
858
+ return [lz, -lx];
859
+ if (facing === 'negX')
860
+ return [-lz, lx];
861
+ return [lx, lz];
862
+ }
863
+ /**
864
+ * THE MOUTH RULE (2026-09-25). How many tiles either side of the door's
865
+ * middle tile are open on the door side: 2 for a `mouth5` style token, 1 for
866
+ * `mouth3`, 3 for `mouth7` (any odd `mouthN`), else the Blender model's own
867
+ * mouth, else 0 (a one-tile door). The collision (visitBlockedWalkOffsets,
868
+ * so the server and the client's prediction) and the roof hiding
869
+ * (isBuildingInteriorOffset) both take it, so the three always agree.
870
+ */
871
+ export function buildingDoorHalfWidth(kind, style) {
872
+ for (const token of (style ?? '').split('-')) {
873
+ const m = /^mouth(\d+)$/.exec(token);
874
+ if (m)
875
+ return Math.max(0, Math.floor((Number(m[1]) - 1) / 2));
876
+ }
877
+ if (kind === 'capitalbuilding') {
878
+ const model = capitalBuildingModel(style);
879
+ if (model && model.mouth > 1)
880
+ return Math.floor((model.mouth - 1) / 2);
881
+ }
882
+ return 0;
883
+ }
750
884
  /**
751
885
  * citywall: the piece stands on the wall's outer row and is two tiles thick
752
886
  * inward (n: +z is inward, s: -z, w: +x, e: -x). A bastion is a 3x3 drum on
@@ -792,19 +926,43 @@ export const GATEHOUSE_SIDE_HALF_SPAN = 4;
792
926
  export const GATEHOUSE_HALF_DEPTH = 3;
793
927
  export const GATEHOUSE_SIDE_HALF_DEPTH = 2;
794
928
  export const GATEHOUSE_PASSAGE_HALF_W = 2;
929
+ /**
930
+ * A gatehouse's facing: the LAST n/s/e/w token of its style (`main-w`,
931
+ * `side-e`, `main-shut-n`). The facing is always written last, because the
932
+ * frontend turns the model by the style's last token.
933
+ */
934
+ export function gatehouseFacing(style) {
935
+ const tokens = (style ?? 'main-w').split('-');
936
+ for (let i = tokens.length - 1; i >= 0; i--) {
937
+ const t = tokens[i];
938
+ if (t === 'n' || t === 's' || t === 'e' || t === 'w')
939
+ return t;
940
+ }
941
+ return 'w';
942
+ }
943
+ /**
944
+ * A barred gatehouse (2026-09-25, the Capital's North and South Gates):
945
+ * a `shut` token (`main-shut-n`) lets the portcullis down, and the passage
946
+ * blocks like the towers, so the collision alone keeps it shut whatever the
947
+ * builder does with the wall rows under it.
948
+ */
949
+ export function isGatehouseShut(style) {
950
+ return (style ?? '').split('-').includes('shut');
951
+ }
795
952
  /** Which way the passage runs: -w/-e styles run along x, -n/-s along z. */
796
953
  export function gatehousePassageAlongX(style) {
797
- const facing = (style ?? 'main-w').split('-').pop();
954
+ const facing = gatehouseFacing(style);
798
955
  return facing === 'w' || facing === 'e';
799
956
  }
800
957
  export function visitGatehouseOffsets(style, visit) {
801
958
  const alongX = gatehousePassageAlongX(style);
802
959
  const main = (style ?? 'main-w').startsWith('main');
960
+ const shut = isGatehouseShut(style);
803
961
  const span = main ? GATEHOUSE_HALF_SPAN : GATEHOUSE_SIDE_HALF_SPAN;
804
962
  const depth = main ? GATEHOUSE_HALF_DEPTH : GATEHOUSE_SIDE_HALF_DEPTH;
805
963
  for (let along = -depth; along <= depth; along++) {
806
964
  for (let across = -span; across <= span; across++) {
807
- if (Math.abs(across) <= GATEHOUSE_PASSAGE_HALF_W)
965
+ if (!shut && Math.abs(across) <= GATEHOUSE_PASSAGE_HALF_W)
808
966
  continue;
809
967
  if (alongX)
810
968
  visit(along, across);
@@ -817,6 +975,19 @@ export function buildingFootprintExtents(kind, scale, style) {
817
975
  if (kind === 'rowhouse' || kind === 'manor' || kind === 'temple' || kind === 'forgehall' || kind === 'warehouse') {
818
976
  return capitalBuildingFootprint(kind, style);
819
977
  }
978
+ if (kind === 'castle')
979
+ return { radiusX: CASTLE_KEEP_HALF_X, radiusZ: CASTLE_KEEP_HALF_Z };
980
+ if (kind === 'capitalbuilding') {
981
+ const model = capitalBuildingModel(style);
982
+ if (!model)
983
+ return { radiusX: 0, radiusZ: 0 };
984
+ const across = Math.floor((model.across - 1) / 2);
985
+ const deep = Math.floor((model.deep - 1) / 2);
986
+ const facing = stoneHouseDoorFacing(style);
987
+ if (facing === 'posX' || facing === 'negX')
988
+ return { radiusX: deep, radiusZ: across };
989
+ return { radiusX: across, radiusZ: deep };
990
+ }
820
991
  if (kind === 'stonehouse') {
821
992
  const variant = stoneHouseVariant(style);
822
993
  if (variant === 'lodge') {
@@ -870,19 +1041,26 @@ export function buildingFootprintRadius(kind, scale, style) {
870
1041
  const { radiusX, radiusZ } = buildingFootprintExtents(kind, scale, style);
871
1042
  return Math.max(radiusX, radiusZ);
872
1043
  }
873
- export function isBuildingDoorOffset(dx, dz, footprint, doorFacing = 'posZ') {
1044
+ /**
1045
+ * The door opening on a building's ring. `halfW` is the mouth rule's
1046
+ * half-width (buildingDoorHalfWidth): 0 is the one middle tile, 2 a
1047
+ * five-wide mouth. The corners always stay wall.
1048
+ */
1049
+ export function isBuildingDoorOffset(dx, dz, footprint, doorFacing = 'posZ', halfW = 0) {
874
1050
  const { radiusX, radiusZ } = normalizeBuildingFootprint(footprint);
875
1051
  if (radiusX <= 0 || radiusZ <= 0)
876
1052
  return false;
1053
+ const alongX = doorFacing === 'posZ' || doorFacing === 'negZ';
1054
+ const w = Math.max(0, Math.min(Math.floor(halfW), (alongX ? radiusX : radiusZ) - 1));
877
1055
  if (doorFacing === 'posZ')
878
- return dx === 0 && dz === radiusZ;
1056
+ return Math.abs(dx) <= w && dz === radiusZ;
879
1057
  if (doorFacing === 'negZ')
880
- return dx === 0 && dz === -radiusZ;
1058
+ return Math.abs(dx) <= w && dz === -radiusZ;
881
1059
  if (doorFacing === 'posX')
882
- return dx === radiusX && dz === 0;
883
- return dx === -radiusX && dz === 0;
1060
+ return dx === radiusX && Math.abs(dz) <= w;
1061
+ return dx === -radiusX && Math.abs(dz) <= w;
884
1062
  }
885
- export function buildingWallBlocksOffset(dx, dz, footprint, doorFacing = 'posZ') {
1063
+ export function buildingWallBlocksOffset(dx, dz, footprint, doorFacing = 'posZ', halfW = 0) {
886
1064
  const { radiusX, radiusZ } = normalizeBuildingFootprint(footprint);
887
1065
  if (radiusX <= 0 || radiusZ <= 0)
888
1066
  return false;
@@ -891,15 +1069,21 @@ export function buildingWallBlocksOffset(dx, dz, footprint, doorFacing = 'posZ')
891
1069
  const onWallRing = Math.abs(dx) === radiusX || Math.abs(dz) === radiusZ;
892
1070
  if (!onWallRing)
893
1071
  return false;
894
- return !isBuildingDoorOffset(dx, dz, { radiusX, radiusZ }, doorFacing);
1072
+ return !isBuildingDoorOffset(dx, dz, { radiusX, radiusZ }, doorFacing, halfW);
895
1073
  }
896
- export function isBuildingInteriorOffset(dx, dz, footprint, doorFacing = 'posZ') {
1074
+ /**
1075
+ * Inside the building for the roof hide: any footprint tile that is not a
1076
+ * blocked ring tile (the door, or the whole mouth, counts as inside). Pass
1077
+ * the mouth rule's `halfW` (buildingDoorHalfWidth(kind, style)) so the roof
1078
+ * lifts across a wide mouth exactly where the collision opens it.
1079
+ */
1080
+ export function isBuildingInteriorOffset(dx, dz, footprint, doorFacing = 'posZ', halfW = 0) {
897
1081
  const { radiusX, radiusZ } = normalizeBuildingFootprint(footprint);
898
1082
  if (radiusX <= 0 || radiusZ <= 0)
899
1083
  return false;
900
1084
  if (Math.abs(dx) > radiusX || Math.abs(dz) > radiusZ)
901
1085
  return false;
902
- return !buildingWallBlocksOffset(dx, dz, { radiusX, radiusZ }, doorFacing);
1086
+ return !buildingWallBlocksOffset(dx, dz, { radiusX, radiusZ }, doorFacing, halfW);
903
1087
  }
904
1088
  export function boulderFootprintRadius(style, scale) {
905
1089
  if (style !== 'boulder')
@@ -1167,6 +1351,97 @@ export function faceStyleDegrees(style) {
1167
1351
  const deg = Number(face[1]);
1168
1352
  return Number.isFinite(deg) ? deg : null;
1169
1353
  }
1354
+ export function parseGardenWallRun(style) {
1355
+ const m = /^run-(\d+)-(ew|ns)(?:-(stone|hedge|noble|fence))?$/.exec(style ?? '');
1356
+ if (!m)
1357
+ return null;
1358
+ const len = Number(m[1]);
1359
+ if (!Number.isFinite(len) || len < 1)
1360
+ return null;
1361
+ return { len, alongX: m[2] === 'ew', look: m[3] ?? 'stone' };
1362
+ }
1363
+ export function gardenWallRunStyle(len, alongX, look = 'stone') {
1364
+ return `run-${Math.max(1, Math.floor(len))}-${alongX ? 'ew' : 'ns'}${look === 'stone' ? '' : `-${look}`}`;
1365
+ }
1366
+ /** The walk-through gate piece for a wall of `look`: noble walls take the iron gate. */
1367
+ export function gardenWallGateStyle(alongX, look = 'stone') {
1368
+ const token = look === 'noble' ? 'iron' : look === 'stone' ? '' : look;
1369
+ return `gate${token ? `-${token}` : ''}-${alongX ? 'ew' : 'ns'}`;
1370
+ }
1371
+ export function isGardenWallGateStyle(style) {
1372
+ return typeof style === 'string' && style.startsWith('gate');
1373
+ }
1374
+ /** The tiles a gardenwall blocks: a whole run, one piece, or nothing for a gate. */
1375
+ export function visitGardenWallOffsets(style, visit) {
1376
+ if (isGardenWallGateStyle(style))
1377
+ return;
1378
+ const run = parseGardenWallRun(style);
1379
+ if (!run) {
1380
+ visit(0, 0);
1381
+ return;
1382
+ }
1383
+ for (let i = 0; i < run.len; i++) {
1384
+ if (run.alongX)
1385
+ visit(i, 0);
1386
+ else
1387
+ visit(0, i);
1388
+ }
1389
+ }
1390
+ /* ------------------------------------------------------------------ *
1391
+ * Tree groves (the Capital redesign, 2026-09-25): a clump of trees drawn as
1392
+ * ONE entity with one mesh per material, for the city's orchards, gardens
1393
+ * and verges. Style `grove-<3|5>[-<species>][-scenic]`: a 3 x 3 clump of
1394
+ * three trees or a 5 x 5 clump of five, about the entity's tile. Each trunk
1395
+ * stands on the tile TREE_GROVE_TRUNKS gives it (the frontend plants the
1396
+ * trees exactly there) and blocks that tile only; the canopy between is
1397
+ * walk-under, as it is round a single tree. A grove is never cut down
1398
+ * (woodwork.ts isCuttableTreeStyle).
1399
+ * ------------------------------------------------------------------ */
1400
+ export const TREE_GROVE_TRUNKS = {
1401
+ 'grove-3': [[-1, -1], [1, -1], [0, 1]],
1402
+ 'grove-5': [[-2, -2], [1, -2], [-1, 0], [2, 1], [-1, 2]],
1403
+ };
1404
+ /** The grove size a tree style names (3 or 5), or null for a single tree. */
1405
+ export function treeGroveSize(style) {
1406
+ if (!style)
1407
+ return null;
1408
+ if (style === 'grove-3' || style.startsWith('grove-3-'))
1409
+ return 3;
1410
+ if (style === 'grove-5' || style.startsWith('grove-5-'))
1411
+ return 5;
1412
+ return null;
1413
+ }
1414
+ /** The trunk tiles of a grove style, about its entity tile, or null for a single tree. */
1415
+ export function treeGroveTrunks(style) {
1416
+ const size = treeGroveSize(style);
1417
+ return size === null ? null : TREE_GROVE_TRUNKS[size === 3 ? 'grove-3' : 'grove-5'];
1418
+ }
1419
+ /*
1420
+ * Statue plinths (the Capital's monuments, 2026-09-25 review): a `statue`
1421
+ * whose style carries a `plinth3` or `plinth5` token stands on a stepped
1422
+ * stone plinth three or five tiles square about its tile, and the whole
1423
+ * square blocks (visitBlockedWalkOffsets). Style `<figure>[-plinth<3|5>][-<facing>]`,
1424
+ * e.g. `king-plinth5`, `knight-plinth3-w`: the figure token first (the asset
1425
+ * reads `king` / `knight` from it) and the facing last, so a statue without
1426
+ * the token is the one-tile statue it always was.
1427
+ */
1428
+ export function statuePlinthSize(style) {
1429
+ const tokens = (style ?? '').split('-');
1430
+ if (tokens.includes('plinth5'))
1431
+ return 5;
1432
+ if (tokens.includes('plinth3'))
1433
+ return 3;
1434
+ return 0;
1435
+ }
1436
+ /** A statue style with its plinth token, the facing (if any) kept last. */
1437
+ export function statueStyleWithPlinth(style, size) {
1438
+ if (!size)
1439
+ return style;
1440
+ const tokens = style.split('-').filter((t) => t !== 'plinth3' && t !== 'plinth5');
1441
+ const last = tokens[tokens.length - 1];
1442
+ const facing = tokens.length > 1 && (last === 'n' || last === 's' || last === 'e' || last === 'w') ? tokens.pop() : undefined;
1443
+ return [...tokens, `plinth${size}`, ...(facing ? [facing] : [])].join('-');
1444
+ }
1170
1445
  /**
1171
1446
  * A work bench is a long table: 2.2 units along its local X and 1.0 deep at
1172
1447
  * scale 1 (createWorkBenchAsset; the mine's is the same shape), so at the
@@ -1202,6 +1477,8 @@ export function visitBlockedWalkOffsets(kind, style, scale, visit, doorFacing =
1202
1477
  // where it passes through the gate.
1203
1478
  if (kind === 'signpost' && style === 'basecamp')
1204
1479
  return;
1480
+ if (kind === 'depthsprop' && !iceDepthsPropBlocks(style))
1481
+ return;
1205
1482
  if (kind === 'fence' && style === 'mine-working-face') {
1206
1483
  for (let dx = -2; dx <= 2; dx++)
1207
1484
  visit(dx, 0);
@@ -1292,11 +1569,41 @@ export function visitBlockedWalkOffsets(kind, style, scale, visit, doorFacing =
1292
1569
  return;
1293
1570
  }
1294
1571
  if (kind === 'castle') {
1295
- // The keep's whole footprint (capital.ts CAPITAL_KEEP_HALF_X/Z); the
1296
- // asset is built to the same extents. Nobody walks into it yet.
1572
+ // The keep's footprint (capital.ts CASTLE_KEEP_HALF_X/Z) is solid but
1573
+ // for the throne hall and the passage to it (2026-09-21): the gate
1574
+ // passage runs in from the west face on the keep's own row, three wide,
1575
+ // into the great hall, whose floor the terrain draws. Everything else
1576
+ // in the footprint (the towers, the wings, the walls) blocks.
1297
1577
  for (let dz = -CASTLE_KEEP_HALF_Z; dz <= CASTLE_KEEP_HALF_Z; dz++) {
1298
- for (let dx = -CASTLE_KEEP_HALF_X; dx <= CASTLE_KEEP_HALF_X; dx++)
1578
+ for (let dx = -CASTLE_KEEP_HALF_X; dx <= CASTLE_KEEP_HALF_X; dx++) {
1579
+ if (inCastleKeepRoom(dx, dz))
1580
+ continue;
1299
1581
  visit(dx, dz);
1582
+ }
1583
+ }
1584
+ return;
1585
+ }
1586
+ if (kind === 'capitalbuilding') {
1587
+ // The Blender buildings (CAPITAL_BUILDING_MODELS). The facing always
1588
+ // comes from the style, whatever door facing the caller passed, so a
1589
+ // stamp without the door flag still agrees with the client.
1590
+ const model = capitalBuildingModel(style);
1591
+ if (!model) {
1592
+ visit(0, 0);
1593
+ return;
1594
+ }
1595
+ const fp = buildingFootprintExtents(kind, scale, style);
1596
+ const facing = stoneHouseDoorFacing(style);
1597
+ const halfW = buildingDoorHalfWidth(kind, style);
1598
+ for (let dz = -fp.radiusZ; dz <= fp.radiusZ; dz++) {
1599
+ for (let dx = -fp.radiusX; dx <= fp.radiusX; dx++) {
1600
+ if (model.solid || buildingWallBlocksOffset(dx, dz, fp, facing, halfW))
1601
+ visit(dx, dz);
1602
+ }
1603
+ }
1604
+ for (const [lx, lz] of model.frontProps ?? []) {
1605
+ const [dx, dz] = turnBuildingOffset(lx, lz, facing);
1606
+ visit(dx, dz);
1300
1607
  }
1301
1608
  return;
1302
1609
  }
@@ -1309,11 +1616,18 @@ export function visitBlockedWalkOffsets(kind, style, scale, visit, doorFacing =
1309
1616
  return;
1310
1617
  }
1311
1618
  if (kind === 'gardenwall') {
1312
- if (style?.startsWith('gate'))
1313
- return;
1314
- visit(0, 0);
1619
+ visitGardenWallOffsets(style, visit);
1315
1620
  return;
1316
1621
  }
1622
+ if (kind === 'tree') {
1623
+ // A grove blocks its trunks; a single tree falls through to its own tile.
1624
+ const trunks = treeGroveTrunks(style);
1625
+ if (trunks) {
1626
+ for (const [dx, dz] of trunks)
1627
+ visit(dx, dz);
1628
+ return;
1629
+ }
1630
+ }
1317
1631
  if (kind === 'fountain') {
1318
1632
  // The basin: three by three about the centre.
1319
1633
  for (let dz = -1; dz <= 1; dz++) {
@@ -1322,6 +1636,18 @@ export function visitBlockedWalkOffsets(kind, style, scale, visit, doorFacing =
1322
1636
  }
1323
1637
  return;
1324
1638
  }
1639
+ if (kind === 'statue') {
1640
+ // A statue on a stepped plinth blocks the plinth's square; one without
1641
+ // a plinth token falls through to its own tile.
1642
+ const half = (statuePlinthSize(style) - 1) / 2;
1643
+ if (half > 0) {
1644
+ for (let dz = -half; dz <= half; dz++) {
1645
+ for (let dx = -half; dx <= half; dx++)
1646
+ visit(dx, dz);
1647
+ }
1648
+ return;
1649
+ }
1650
+ }
1325
1651
  if (kind === 'brokengate') {
1326
1652
  // Posts and wreckage across three tiles; nobody climbs the splinters.
1327
1653
  const acrossZ = frostSpanAcrossZ(style);
@@ -1347,6 +1673,29 @@ export function visitBlockedWalkOffsets(kind, style, scale, visit, doorFacing =
1347
1673
  }
1348
1674
  return;
1349
1675
  }
1676
+ if (kind === 'marketstall') {
1677
+ // A stall is a counter two tiles wide under an awning, and the trailing
1678
+ // letter of the style says which way it fronts. Only its middle tile was
1679
+ // ever solid, so both ends of the counter were walk-through (James,
1680
+ // 2026-09-22: "I can walk through market stalls"). Hope Beach's coastal
1681
+ // stalls are the big ones, five along the counter with their barrels -
1682
+ // the same shape hopeBeach.ts already stamps on the server, now shared
1683
+ // with the client so its prediction agrees.
1684
+ const facing = typeof style === 'string' ? style.charAt(style.length - 1) : 's';
1685
+ const alongZ = facing === 'e' || facing === 'w';
1686
+ const coastal = typeof style === 'string' && style.startsWith('coastal-');
1687
+ const half = coastal ? 2 : 1;
1688
+ const deep = coastal ? 1 : 0;
1689
+ for (let a = -half; a <= half; a++) {
1690
+ for (let b = -deep; b <= deep; b++) {
1691
+ if (alongZ)
1692
+ visit(b, a);
1693
+ else
1694
+ visit(a, b);
1695
+ }
1696
+ }
1697
+ return;
1698
+ }
1350
1699
  if (kind === 'fence' && isFenceGateStyle(style)) {
1351
1700
  if (isOpenGateStyle(style))
1352
1701
  return;
@@ -1374,9 +1723,11 @@ export function visitBlockedWalkOffsets(kind, style, scale, visit, doorFacing =
1374
1723
  if (buildingFp.radiusX > 0 && buildingFp.radiusZ > 0) {
1375
1724
  // Hollow buildings: only the wall ring is solid. The origin is interior
1376
1725
  // floor - blocking it sealed the storage hut so you could never stand inside.
1726
+ // The door is one tile, or a wider mouth by the mouth rule (a `mouth5` token).
1727
+ const mouthHalfW = buildingDoorHalfWidth(kind, style);
1377
1728
  for (let dz = -buildingFp.radiusZ; dz <= buildingFp.radiusZ; dz++) {
1378
1729
  for (let dx = -buildingFp.radiusX; dx <= buildingFp.radiusX; dx++) {
1379
- if (!buildingWallBlocksOffset(dx, dz, buildingFp, doorFacing))
1730
+ if (!buildingWallBlocksOffset(dx, dz, buildingFp, doorFacing, mouthHalfW))
1380
1731
  continue;
1381
1732
  // Cottage is a walk-through: south porch door and north garden door.
1382
1733
  if (kind === 'farmhut' &&