bevy_grimoire_core 0.8.0

Engine-free world-building core: constraint IR, solvers, WFC, deterministic RNG.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
//! **The map** โ€” what is placed where, and what can be done there.
//!
//! A map references [`crate::descriptor::Descriptor`]s by id and positions them. It also carries
//! **locations**, which is where interactions live.
//!
//! # Why interactions belong to a location and not to a prop
//!
//! The obvious design is `interactions: [...]` on each descriptor: a chair affords sitting, a bed
//! affords sleeping. It does not survive contact with a dining table.
//!
//! A table plus four chairs is **one** affordance with four seats, not five affordances. FINAL
//! FANTASY XV solved this with *smart locations* โ€” *"invisible objects that refer to multiple
//! concrete objects. For example, a single smart location may refer to two chairs and a table. This
//! allows it not only to inform agents about the existence and usability of individual objects, but
//! also to capture relationships between them, such as furniture groupingโ€ฆ they essentially govern
//! the usage of the objects they refer to"* (Game AI Pro 3 ch.35). Smart Zones (Game AI Pro 2 ch.11)
//! add the role strata this reuses.
//!
//! A single-prop interaction is then the degenerate case โ€” one prop, one `Main` role โ€” so nothing is
//! lost by starting here, and retrofitting group ownership after interactions ship would be a schema
//! migration through every authored map.
//!
//! # Prose is a field, not a comment
//!
//! Every addressable thing here carries an optional `note:`. That is deliberate and it is what lets a
//! map be written by an ordinary serializer.
//!
//! This project already knows what happens otherwise. `assets/site/site67.ron` is 15% comments and its
//! props list carries more prose than data; `assets/config/config.ron` carries ~563 comment lines, and
//! on 2026-07-16 a `to_string_pretty` bake deleted 279 of them. The response there was
//! [`crate::ron_surgery`] โ€” rewrite the file as text so the comments survive. That is the right answer
//! for a file a human authored and a tool visits.
//!
//! For a format being designed now it is the wrong problem to solve. If the reasoning is a **field**,
//! no serializer can lose it, no writer needs to be surgical, and the note survives a round-trip
//! through any tool that understands the schema. [`Placed::owned_because`] was already this idea in
//! one specific place โ€” a reason stored as data precisely so nothing can strip it โ€” and `note:`
//! generalises it.
//!
//! So: an grimoire map is serialized normally and **never** text-spliced. The surgical writer stays for
//! `site67.ron` and `config.ron`, whose prose is a 48-line ASCII floor plan and paragraphs introducing
//! blocks of records โ€” none of it attached to a record, so none of it with a field to live in.
//!
//! # Versioning: refuse what you cannot understand
//!
//! `persist.rs` states the rule this follows: a map from another schema is a loud error rather than a
//! guess. What that means precisely is **a floor, not an equality** โ€” a file numbered *above* this
//! build is refused, and one at or below it is read and re-saved at the current number.
//!
//! It was an equality until [`Stamped`] arrived, and the difference is worth stating because it looks
//! like a loosening and is not. The hazard versioning exists to stop is an **old** build opening a
//! **new** file, understanding half of it, and writing the other half away โ€” a map silently losing
//! every stamp in it. That is exactly what the floor still refuses. The equality additionally refused
//! the harmless direction, where a build that knows about stamps opens a file written before they
//! existed: that file says `stamps: []`, which is precisely what it meant. Refusing it destroyed a
//! map to prevent nothing.
//!
//! So the rule reads: **refuse if the file's version is greater than [`MAP_VERSION`].** One rule, one
//! direction, and no compatibility branch โ€” a field added since is `#[serde(default)]` and its default
//! is what the older file already meant.

use serde::{Deserialize, Serialize};

use crate::descriptor::Descriptor;
use crate::placement::ir::Guard;

/// Bumped whenever the shape below changes. A file numbered above this is refused; at or below it is
/// read and re-saved here. See the module note on why that is a floor rather than an equality.
///
/// `2` added [`Map::stamps`]. `3` took `face_bands` and `snap_divisor` off the kit's policy, where
/// they described a lattice a *kit* does not have. `4` added `palette`, whose empty default
/// is what every earlier map already meant. **`5` gave the two lattice fields up again**, to
/// [`crate::kits::Lattice`]: a map does have exactly one lattice, but so does the project, and a
/// tile's adjacency contract cannot depend on which map happens to be open. That one is a
/// *removal*, so `deny_unknown_fields` means a map still carrying them is refused by name rather
/// than read with them ignored โ€” which is the loud failure this schema's rules are for. **`6`
/// replaced `palette` with [`Map::bash`]** โ€” the ad-hoc per-map list became a combination declared
/// once in `kits.ron`, so two maps can draw on the same one and it is validated in one place.
/// **`7` added [`Map::enclosure`]** โ€” how much of a generated region should close into rooms was a
/// constant in the editor, so the author who wanted more enclosed rooms edited Rust (E-005). Its
/// default is the pair of numbers the editor shipped, so an older map generates exactly as it did.
/// **`8` added [`Map::runs`]** โ€” a piece repeated along a path, held as the run and not the rows it
/// lays; an older map says `runs: []` by saying nothing.
/// **`9` added [`Placed::family`]** โ€” the family-crate settings a piece carries; an older map says
/// `family: {}` by saying nothing.
pub const MAP_VERSION: u32 = 9;

/// One authored world.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Map {
    pub version: u32,
    /// What this map is called โ€” **snake_case**, and it is also the file it lives in.
    ///
    /// One spelling, because a name is simultaneously a filename, a manifest key and something typed
    /// into a shell, and those have different tolerances: two names differing only by case are one
    /// file on some platforms and two on others. `crate::naming` forces it at the point of entry
    /// rather than complaining afterwards.
    #[serde(default)]
    pub name: String,
    /// Where this map sits in the world: **the centre of its floor.**
    ///
    /// X and Z are centred on it, Y runs upward from it. That asymmetry is the honest one โ€” a map is
    /// a floor plan with headroom, and nobody builds below the floor โ€” and centring the horizontal
    /// axes is what makes "a 32 metre map" mean 16 metres in every direction rather than a quadrant.
    ///
    /// Corner-at-origin was the first attempt and it failed the moment it was used: the editor opens
    /// looking at the origin, so half the visible ground was outside the map and a flood fill aimed at
    /// the middle of the screen was refused as out of bounds. The bug was the convention, not the fill.
    pub origin: (f32, f32, f32),
    /// **How big this map is**, in metres on each axis, measured from [`Self::origin`].
    ///
    /// A map without a stated size is not a smaller map, it is a map with no edges โ€” and an edge is
    /// what several things need in order to be answerable. A flood fill needs somewhere to stop; a
    /// generator needs a domain to solve over; a validator needs to be able to say a placement is
    /// outside. Leaving it implicit makes each of those invent its own answer, which is how three
    /// slightly different ideas of "the level" end up in one codebase.
    ///
    /// Y is real and not decoration: a map is a volume, because `Mount::OnWall` and `OnCeiling` put
    /// things above the floor and the ceiling height is what decides where.
    #[serde(default = "default_bounds")]
    pub bounds: (f32, f32, f32),
    /// **The named combination of kits this map offers**, or `None` for every bound kit.
    ///
    /// # A filter on the palette, and never on what loads
    ///
    /// This does **not** decide what the map can resolve. Every kit the project binds is loaded
    /// whatever this says, so a piece already placed always resolves and a composition may still
    /// cross kits. `OpenMap::palette_namespaces` folds the namespaces the content already names
    /// back in, so naming a bash cannot strand a placement.
    ///
    /// The name is not checked here โ€” a map validates in isolation and cannot see `kits.ron`.
    /// `OpenMap::open` refuses a name the project does not declare.
    ///
    /// **No `serde(default)`, and it would change nothing if there were one.** Serde deserializes a
    /// missing field of `Option` type as `None` (`serde::__private::de::missing_field`, which only
    /// answers `deserialize_option`), so an omitted `bash` and a written `bash: None` are the same
    /// value in every format โ€” there is no hook that makes a bare `Option` field required. That is
    /// survivable here and would not be for [`crate::kits::Kits::bash`]: this field has exactly two
    /// meanings and the absent spelling maps onto the one that is also the new-map state, while an
    /// absent list of *declarations* would hide the difference between "none" and "not stated".
    /// Everything this program writes states it: nothing here is `skip_serializing_if`.
    pub bash: Option<String>,
    pub placements: Vec<Placed>,
    /// **Compositions this map stamps** โ€” a reference each, never the rows they stand for.
    ///
    /// Expanded by [`crate::composition::expand`] at render, at validation and at load, and **never**
    /// written back into [`Self::placements`]. That is what makes editing a composition change every
    /// map that stamped it, and it is also what keeps this file's undo history addressable: an edit
    /// somewhere else cannot renumber rows that were never here.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub stamps: Vec<crate::composition::Stamped>,
    /// **Runs this map holds** โ€” a piece repeated along a path, stored as the run (descriptor, path,
    /// pitch, gaps) and never as the rows it lays. [`Self::expanded`] lays each with
    /// [`crate::run::lay`] and names the rows `<run id>/<n>`; they are never written back into
    /// [`Self::placements`].
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub runs: Vec<crate::run::RunPlaced>,
    #[serde(default)]
    pub locations: Vec<Location>,
    /// What this map is and why it is laid out this way โ€” the header prose, as data. See the module
    /// docs on why this is a field rather than a comment.
    ///
    /// **Written even when absent, unlike every field of [`Placed`].** A map has one of these, so the
    /// line costs a dozen bytes once per file and buys a reader a visible place to write โ€” the whole
    /// reason the field exists instead of a comment. `Placed::note` is the opposite case: it is per
    /// row, so 720 copies of `note: None` is 17 KB of saying nothing.
    #[serde(default)]
    pub note: Option<String>,
    /// **How much of a generated region should close into rooms, and what that wish is worth.**
    ///
    /// **Authored, because it is a taste.** The editor used to hold `0.25` and `200` as constants in
    /// `editor.rs`, so a developer who wanted more enclosed rooms โ€” a keep, a cellar, a maze โ€” had to
    /// edit Rust and rebuild (E-005). What an author means by "rooms" differs per map, and it is
    /// exactly the kind of number someone tunes while looking at a result.
    ///
    /// Absent means [`Enclosure::default`], which is the pair the editor shipped, so a map written
    /// before this field existed generates exactly as it did.
    #[serde(default)]
    pub enclosure: Enclosure,
}

/// **The enclosure wish a generator aims at, and its weight against everything else.**
///
/// A *soft* wish: a region that cannot reach the target returns its best arrangement and says so
/// rather than refusing, which is what makes a tuned number safe.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Enclosure {
    /// **The fraction of a generated region that should close into rooms**, `0.0..=1.0`.
    pub wish: f32,
    /// **What that wish is worth** against the other terms of the solve. Any positive value makes it
    /// decisive, because it is the only soft constraint in play; the number leaves room for a second
    /// wish to be weighed against it later.
    pub weight: u32,
}

impl Default for Enclosure {
    /// The pair `editor.rs` held as constants before E-005: a quarter of the region, weighted 200.
    fn default() -> Self {
        Enclosure { wish: 0.25, weight: 200 }
    }
}

impl Default for Map {
    /// **A new, empty, valid map** โ€” not a zeroed struct.
    ///
    /// The derived `Default` would give `version: 0` and zero bounds, which `validate` rejects on both
    /// counts. An editor starts a map from this, so "default" has to mean something an author can
    /// immediately work in rather than something they must first repair.
    fn default() -> Self {
        Map {
            version: MAP_VERSION,
            // Empty, not "untitled": a substituted name is a name nobody chose, and the second one
            // collides with the first. An unnamed map is a map that has to be named before it saves.
            name: String::new(),
            origin: (0.0, 0.0, 0.0),
            bounds: default_bounds(),
            bash: None,
            placements: Vec::new(),
            stamps: Vec::new(),
            runs: Vec::new(),
            locations: Vec::new(),
            note: None,
            enclosure: Enclosure::default(),
        }
    }
}

/// serde's `skip_serializing_if` needs a function, and each of these says the only thing it exists
/// to say: **an absent field and its default are the same fact, so a default is not written.**
///
/// Every field of [`Placed`] that has a `#[serde(default)]` needs one, because `default` is only half
/// the promise โ€” it says what an absent field *means*, and `skip_serializing_if` is what stops the
/// writer stating it. Without the pair, a map carries a line per placement per default, which on the
/// real town map was 41 % of the file (E-057).
fn lift_is_zero(v: &f32) -> bool {
    *v == 0.0
}

fn tip_is_zero(v: &(u8, u8)) -> bool {
    *v == (0, 0)
}

fn paint_is_zero(v: &i8) -> bool {
    *v == 0
}

/// A placement is not owned unless it says so, so `owned: false` is a line that states the assumption.
fn not_owned(v: &bool) -> bool {
    !*v
}

/// `Option::is_none`, named for the same reason the others are: a default and an absent field are
/// one fact, and a writer that states a default has written nothing.
fn absent<T>(v: &Option<T>) -> bool {
    v.is_none()
}

/// A new map's size before anyone has said otherwise: 32 m square and one storey, so an author opens
/// on 16 m of workable ground in every direction.
///
/// Not zero. A zero-sized map is a map every placement is outside of, so the first thing an author
/// would do is see every piece flagged โ€” a default should be somewhere to start work, not a puzzle.
/// The most a map may divide one tile.
fn default_bounds() -> (f32, f32, f32) {
    // **Ten metres square, four tall.** It was 32 x 32, which is 1,024 cells of floor an author
    // has to pan across before the map reads as a place rather than a plain โ€” and the grid at the
    // finest rung over 32 m is the wash `draw_map_grid` records. Ten is a room-and-corridor's worth
    // of ground: big enough to lay a kit out on, small enough to see whole at the opening zoom.
    // Asked for at the keyboard, 2026-08-15. A map that wants more says so in its own file; this is
    // only what a NEW one starts at.
    (10.0, 4.0, 10.0)
}

/// One instance of a descriptor, somewhere.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct Placed {
    /// Unique within the map. Referenced by [`Location::props`], so it must be stable across edits โ€”
    /// which is why it is an authored string and not a vec index. An index would silently re-point
    /// every location the moment a placement above it was deleted.
    pub id: String,
    /// Which [`Descriptor`] this is an instance of.
    pub descriptor: String,
    /// Position in map space, metres. **The floor plan only** โ€” the height comes from the
    /// descriptor's [`crate::descriptor::Mount`], never from the author, so a lamp cannot be authored
    /// hovering 3 cm above the table it is meant to be standing on. ([`Self::lift`] is the one
    /// deliberate amendment.)
    pub at: (f32, f32),
    pub yaw: f32,
    /// **Authored vertical offset, metres, layered on top of the resolved height.**
    ///
    /// The deliberate amendment to the rule on [`Self::at`]: `stack::resolve_y` still decides the
    /// datum โ€” floor, wall height, the host's surface, so a lamp still follows its table โ€” and this
    /// nudges the result afterwards. The editor's lift keys step it by one subgrid unit
    /// (`grid::SNAP / divisions`); it is stored in metres so the file keeps meaning if the project
    /// ever re-divides.
    ///
    /// Zero in every file written before the field existed, and skipped when zero so those files
    /// stay byte-identical. A build from before the field refuses a map that carries it
    /// (`deny_unknown_fields`) โ€” loud, which is [`MAP_VERSION`]'s own rule.
    #[serde(default, skip_serializing_if = "lift_is_zero")]
    pub lift: f32,
    /// **Quarter turns tipping the piece over: (about X, about Z), each `0..=3`.**
    ///
    /// Set dressing โ€” a tipped crate, a fallen chair. Quarter turns rather than free angles because
    /// a footprint stays answerable under axis swaps and under nothing else (`fill::cell_extents`
    /// records the lesson: a 30ยฐ-tipped rectangle has no honest cell). Applied in the piece's own
    /// frame *before* [`Self::yaw`] turns it, and the spawner re-seats the tipped bounds on the
    /// resolved height so tipping never buries a mesh.
    ///
    /// A tipped piece offers no surface โ€” `stack::host_under` skips it, and the editor refuses to
    /// tip a piece while something rests on it โ€” because "where is the tabletop of a table lying on
    /// its side" has no answer worth inventing.
    #[serde(default, skip_serializing_if = "tip_is_zero")]
    pub tip: (u8, u8),
    /// The [`Self::id`] of the placement this one **rests on**, for a descriptor that mounts
    /// `OnSurface`.
    ///
    /// A reference rather than a Y coordinate, and that is the whole point: move the table and the
    /// lamp goes with it. A stored height would be correct exactly until someone dragged the host, and
    /// then silently wrong in a way that reads as the lamp being badly authored.
    ///
    /// `None` for everything that stands on the floor, hangs from the ceiling or fills a doorway โ€”
    /// those layers derive their height from the map, not from a neighbour.
    ///
    /// # And it is the record of which mount a two-way piece took
    ///
    /// For a [`Descriptor`](crate::descriptor::Descriptor) mounted
    /// [`Mount::OnSurfaceOrFloor`](crate::descriptor::Mount::OnSurfaceOrFloor), this field **is** the
    /// answer to "did it take the surface or the floor": `Some(host)` says the surface, `None` says the
    /// floor. That is why the variant needs no second field โ€” [`crate::stack::resolve_y`] already
    /// derives the height from `on`, so the two seatings reproduce on a reload without anything else
    /// being stored.
    #[serde(default, skip_serializing_if = "absent")]
    pub on: Option<String>,
    /// **Owned by the author** โ€” a generator must route around it rather than through it.
    ///
    /// This is the lock in Smelik et al.'s sense and the `initial` domain in WFC's: an owned cell is a
    /// unary constraint, a cell whose domain is narrowed to one prototype before propagation. See
    /// `docs/2026-08-03-kitbash-editor.md`.
    #[serde(default, skip_serializing_if = "not_owned")]
    pub owned: bool,
    /// Why it is owned. A **reason, never a bool** โ€” the same call `PropPlacement::waive` makes, and
    /// for the same argument: a bool lets "I could not be bothered" and "this is the cell block's only
    /// entrance" look identical in a diff.
    #[serde(default, skip_serializing_if = "absent")]
    pub owned_because: Option<String>,
    /// Per-instance overrides layered over the descriptor. Absence inherits.
    #[serde(default, skip_serializing_if = "absent")]
    pub patch: Option<Descriptor>,
    /// **Paint order among things at the same spot** โ€” higher draws in front.
    ///
    /// Purely cosmetic. It does **not** gate overlap (`stack::blocking` never reads it), does not
    /// change what a tile presents to its neighbours, and does not move anything: a floor, the grime
    /// on it and the marking over that are all at the same height and differ only here.
    ///
    /// # What it does and does not deliver
    ///
    /// Applied as `StandardMaterial::depth_bias`, which biases the **depth comparison**. That is the
    /// right tool for coplanar surfaces โ€” two decals on one floor โ€” and it is **not** a general
    /// stacking order: it will not lift something in front of unrelated geometry it sits well behind.
    /// Say so here, because the field's name promises more than the mechanism gives.
    ///
    /// `i8` on purpose. The renderer caches one material per `(base, paint)` pair, so an unbounded
    /// value is an unbounded cache โ€” and a kit needs a handful of layers, not thousands.
    #[serde(default, skip_serializing_if = "paint_is_zero")]
    pub paint: i8,
    /// Why this prop is here โ€” the trailing `// records desk` of the old format, as data.
    ///
    /// Distinct from [`Self::owned_because`], which answers a narrower question a generator has to
    /// respect. This one is for the reader.
    #[serde(default, skip_serializing_if = "absent")]
    pub note: Option<String>,
    /// **Settings of the family crates this piece carries, by crate name** (#103): a brazier's
    /// `bevy_lights` `Flicker`, as the crate's own serde type written as RON text. The editor holds no
    /// schema for it, so the piece moves, copies and undoes like any other and the game's own copy of
    /// the plugin reads the same text. Skipped when empty, so every older file stays byte-identical.
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub family: std::collections::BTreeMap<String, String>,
}

/// An invisible thing that owns a group of props and governs their use.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Location {
    pub id: String,
    /// [`Placed::id`]s this location governs. May be one.
    pub props: Vec<String>,
    pub interactions: Vec<Interaction>,
    /// What this grouping *is* โ€” "the galley's near table", "the bunk nobody uses". A location is
    /// invisible, so without this it is the one thing in a map with no way to explain itself.
    #[serde(default)]
    pub note: Option<String>,
}

/// Something that can happen here.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Interaction {
    /// `"eat"`, `"sleep"`, `"repair"`. Opaque โ€” matched, never interpreted.
    pub verb: String,
    pub roles: Vec<RoleSlot>,
    /// Precondition over world state. `ir::Guard` has been declared and unused since the constraint IR
    /// landed; this is the consumer it was reserved for.
    #[serde(default)]
    pub guard: Option<Guard>,
    pub effects: Vec<Effect>,
    /// Why this interaction exists here, and anything a reader would otherwise have to infer from the
    /// role counts.
    #[serde(default)]
    pub note: Option<String>,
}

/// A part an agent can play in an interaction.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RoleSlot {
    pub name: String,
    pub kind: RoleKind,
    pub min: u8,
    pub max: u8,
    /// Which [`crate::descriptor::Socket::role`] an occupant stands at.
    #[serde(default)]
    pub socket_role: Option<String>,
    /// What an occupant must be able to do โ€” tokens from the `capabilities` axis, **all** of which
    /// are required.
    ///
    /// Empty means anybody, which is the honest reading of a role that states no requirement, and is
    /// right for an `Extra` standing around. A `Main` role usually wants something: a diner can eat, a
    /// server can cook.
    ///
    /// All rather than any: a role wanting somebody who can cook *and* carry wants both, and an
    /// "intersects" test would hand it somebody who can only carry. Game AI Pro 4 ch.4's mask compare,
    /// with the requirement side as the subset test it has to be.
    #[serde(default)]
    pub requires: Vec<String>,
}

/// Smart Zones' three strata, kept because they encode *when a scene may start*.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum RoleKind {
    /// *"Main roles are essentialโ€ฆ The scene won't start unless all the main roles are fulfilled."*
    Main,
    /// Favourable but not required.
    Supporting,
    /// Ambient bystanders.
    Extra,
}

/// What an interaction does.
///
/// Deliberately **closed** โ€” there is no `Custom(String)` escape hatch. The IR's `Role::Custom` was
/// added for exactly that reason and has never been constructed in the life of the codebase; an open
/// variant that nothing produces is dead surface that every reader has to consider. Growing this enum
/// should be a deliberate edit.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Effect {
    /// Refill a named drive at this rate per second while the interaction runs.
    Restore { drive: String, rate: f32 },
    /// Drain one, same units.
    Drain { drive: String, rate: f32 },
}

/// One value in the row printer's config, with the refusal naming what it was for.
fn compact<T: Serialize>(
    value: &T,
    config: &ron::ser::PrettyConfig,
    what: &str,
) -> Result<String, String> {
    ron::ser::to_string_pretty(value, config.clone())
        .map_err(|e| format!("map: serialize `{what}`: {e}"))
}

/// **A patch written as only what it overrides**, or as all of it when that cannot be proved safe.
///
/// `full` and `default` are the patch and [`Descriptor::default`] in the row printer's compact
/// form, so their struct fields line up one for one. Every field the two say alike is dropped, at
/// every depth of nested structs; what is left is read back, and kept only if it is the same patch.
/// A field whose serde default is not its `Default` value would read back differently, and then the
/// whole patch is written, so the shortening can never lose an override.
fn minimal_patch(patch: &Descriptor, full: &str, default: &str) -> String {
    let short = prune(full, default).unwrap_or_else(|| "()".to_owned());
    match ron::from_str::<Descriptor>(&short) {
        Ok(back) if back == *patch => short,
        _ => full.to_owned(),
    }
}

/// `value` without the struct fields `default` also says, or `None` when it says nothing else.
fn prune(value: &str, default: &str) -> Option<String> {
    if value == default {
        return None;
    }
    let (Some(fields), Some(defaults)) = (struct_fields(value), struct_fields(default)) else {
        return Some(value.to_owned());
    };
    let kept: Vec<String> = fields
        .iter()
        .filter_map(|(name, field)| {
            match defaults.iter().find(|(other, _)| other == name) {
                Some((_, was)) => prune(field, was),
                None => Some((*field).to_owned()),
            }
            .map(|written| format!("{name}: {written}"))
        })
        .collect();
    if kept.is_empty() {
        return Some(value.to_owned());
    }
    Some(format!("({})", kept.join(", ")))
}

/// The `name: value` fields of a compact struct, `(a: 1, b: (c: 2))`, or `None` for anything else
/// (a tuple, a list, an option, a scalar, an empty `()`).
fn struct_fields(text: &str) -> Option<Vec<(&str, &str)>> {
    let inner = text.strip_prefix('(')?.strip_suffix(')')?;
    let items = split_top(inner)?;
    if items.is_empty() {
        return None;
    }
    items
        .into_iter()
        .map(|item| {
            let (name, value) = item.split_once(": ")?;
            let ident = !name.is_empty()
                && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
                && !name.starts_with(|c: char| c.is_ascii_digit());
            ident.then_some((name, value))
        })
        .collect()
}

/// Split `inner` on the commas at its own depth, outside strings. `None` when the brackets do not
/// balance, which means `inner` was not the inside of one value.
fn split_top(inner: &str) -> Option<Vec<&str>> {
    let mut items = Vec::new();
    let (mut depth, mut start, mut in_string, mut escaped) = (0usize, 0usize, false, false);
    for (i, c) in inner.char_indices() {
        if in_string {
            match (escaped, c) {
                (true, _) => escaped = false,
                (false, '\\') => escaped = true,
                (false, '"') => in_string = false,
                _ => {}
            }
            continue;
        }
        match c {
            '"' => in_string = true,
            '(' | '[' | '{' => depth += 1,
            ')' | ']' | '}' => depth = depth.checked_sub(1)?,
            ',' if depth == 0 => {
                items.push(inner[start..i].trim());
                start = i + 1;
            }
            _ => {}
        }
    }
    if depth != 0 || in_string {
        return None;
    }
    let last = inner[start..].trim();
    if !last.is_empty() {
        items.push(last);
    }
    Some(items)
}

/// `\'` written back as `'`. The printer escapes a string with `char::escape_debug`, which escapes
/// an apostrophe that RON reads the same either way. A backslash is always an escape's first
/// character, so walking escape by escape never mistakes an escaped backslash for one.
fn unescape_apostrophes(text: &str) -> String {
    let mut out = String::with_capacity(text.len());
    let mut chars = text.chars();
    while let Some(c) = chars.next() {
        if c != '\\' {
            out.push(c);
            continue;
        }
        match chars.next() {
            Some('\'') => out.push('\''),
            Some(next) => {
                out.push('\\');
                out.push(next);
            }
            None => out.push('\\'),
        }
    }
    out
}

impl Map {
    /// Parse and validate. One path, no fallback โ€” every failure names what and where.
    pub fn parse(text: &str) -> Result<Map, String> {
        let map: Map = ron::from_str(text).map_err(|e| format!("map: {e}"))?;
        map.validate()?;
        Ok(map)
    }

    /// **The map as its file: one placement a line.**
    ///
    /// # Why not `to_string_pretty` of the whole map
    ///
    /// The pretty printer puts every field of every placement on its own line, so a 739-row town is
    /// four thousand lines and a person reading a diff scrolls past five lines to see one yaw. A
    /// hand-exported map writes a row a line, and the first save of one through the plain printer
    /// reformatted the whole file: an edit of one placement became a diff of every placement. That
    /// matters most when the editor writes every change through to a running game (`--live`), where
    /// each write is a commit-sized diff in someone's tree.
    ///
    /// So the header is the pretty printer's, unchanged, and each row is the same printer with
    /// `compact_structs` (and compact arrays and maps), which writes a placement on one line. The
    /// result reads back as the same map; a file already written this way reads back and writes byte
    /// for byte.
    ///
    /// # Two things the printer says that a hand-written file does not
    ///
    /// - **A placement's `patch` states only what it overrides.** [`Descriptor`] writes every field,
    ///   defaults included, because the library is written that way; a patch is an override, and a
    ///   road's `Some((extent: (footprint: Some((4.877, 190.1975)))))` became a 400-character row
    ///   of `None`s and empty lists. [`minimal_patch`] drops every field the default descriptor
    ///   also says, and checks that what is left reads back as the same patch.
    /// - **An apostrophe is written as itself.** The printer escapes every string with
    ///   `char::escape_debug`, which writes `'` as `\'`; both read back as the same string, and
    ///   prose with apostrophes is what notes are made of.
    pub fn to_ron(&self) -> Result<String, String> {
        let pretty = ron::ser::PrettyConfig::default();
        let header = Map { placements: Vec::new(), ..self.clone() };
        let mut text = ron::ser::to_string_pretty(&header, pretty.clone())
            .map_err(|e| format!("map: serialize: {e}"))?;
        if !self.placements.is_empty() {
            const EMPTY: &str = "\n    placements: [],\n";
            let at = text.find(EMPTY).ok_or_else(|| {
                "map: serialize: the header has no `placements: [],` line to fill".to_owned()
            })?;
            let row_config = pretty
                .compact_structs(true)
                .compact_arrays(true)
                .compact_maps(true);
            let default_patch = compact(&Descriptor::default(), &row_config, "the default patch")?;
            let mut rows = String::from("\n    placements: [\n");
            for placed in &self.placements {
                let row = match &placed.patch {
                    None => compact(placed, &row_config, &placed.id)?,
                    Some(patch) => {
                        // The row with the default descriptor standing in for the patch, so the
                        // patch's place in the row is the printer's, then the minimal patch in it.
                        let stand_in = Placed {
                            patch: Some(Descriptor::default()),
                            ..placed.clone()
                        };
                        let row = compact(&stand_in, &row_config, &placed.id)?;
                        let full = compact(patch, &row_config, &placed.id)?;
                        let written = minimal_patch(patch, &full, &default_patch);
                        row.replacen(
                            &format!("patch: Some({default_patch})"),
                            &format!("patch: Some({written})"),
                            1,
                        )
                    }
                };
                rows.push_str("        ");
                rows.push_str(&row);
                rows.push_str(",\n");
            }
            rows.push_str("    ],\n");
            text.replace_range(at..at + EMPTY.len(), &rows);
        }
        let mut text = unescape_apostrophes(&text);
        if !text.ends_with('\n') {
            text.push('\n');
        }
        Ok(text)
    }

    /// **The map as a consumer sees it**: the same map, with every stamp's rows in
    /// [`Self::placements`].
    ///
    /// # Why this exists
    ///
    /// A map holds **references** to compositions, never the rows they stand for โ€” that is what
    /// makes editing a group change every map that stamped it. But a consumer that reads
    /// [`Self::placements`] and finds only the hand-authored rows is reading a *different level*
    /// from the one the author drew: the editor showed the group, the game does not have it.
    ///
    /// That was measured, not supposed. Sanctuary's `town.map.ron` was stamped once with
    /// `town/house_cottage_plaster`; the editor drew its 18 members, and the game, loading the
    /// same file through [`crate::project_data::load`], received 728 placements where the editor
    /// had 746. The 18 missing rows were the house.
    ///
    /// So the expansion is not optional for a consumer, and this is where it happens. The map's
    /// [`Self::stamps`] are **kept** โ€” the reference survives, so saving the expanded map writes
    /// the reference and not the rows, and editing the group still moves every stamp.
    ///
    /// # One expander
    ///
    /// This is [`crate::composition::expand_map`] โ€” the same function the editor's ghost and the
    /// editor's save gate call. There is no second implementation, so the editor's picture and the
    /// game's world cannot disagree about what a stamp means.
    ///
    /// # A refusal, not a degradation
    ///
    /// A stamp that does not expand โ€” a group the project no longer defines, a member the library
    /// lacks, a member with nowhere to rest โ€” fails **here**, and the error names the stamp. The
    /// map is not returned with its rows missing: a level that quietly lacks a house is the exact
    /// failure this method exists to remove.
    pub fn expanded(
        &self,
        compositions: &[crate::composition::Composition],
        library: &crate::library::Library,
    ) -> Result<Map, String> {
        if self.stamps.is_empty() && self.runs.is_empty() {
            return Ok(self.clone());
        }
        let mut out = self.clone();
        if !self.stamps.is_empty() {
            let expansion = crate::composition::expand_map(self, compositions, library)?;
            // **The rows, in the order `expand` emits them** โ€” stamp id order, then member-path order.
            // A total order, so two runs and two readers agree on which row is which.
            out.placements.extend(expansion.placements);
            // A stamp's locations come with it, or the group's affordances vanish: a location governs
            // props by id, and those ids are the stamped ones.
            out.locations.extend(expansion.locations);
        }
        // **Runs last, in the order the map lists them**, each post named `<run id>/<n>` and owned
        // like its run. A run that does not lay refuses here, naming the run.
        for r in &self.runs {
            let posts = crate::run::lay(&r.run, library)
                .map_err(|e| format!("map: run `{}` does not lay: {e}", r.id))?;
            for (n, post) in posts.iter().enumerate() {
                out.placements.push(Placed {
                    id: crate::run::post_id(&r.id, n),
                    descriptor: r.run.descriptor.clone(),
                    at: post.at,
                    yaw: post.yaw,
                    owned: r.owned,
                    owned_because: r.owned_because.clone(),
                    ..Placed::default()
                });
            }
        }
        Ok(out)
    }

    /// **Replace rows by the run that lays exactly them** (#115, one way).
    ///
    /// `rows` are placement ids in path order; the run is added as `run_id` and the rows leave
    /// `placements`. Refuses, naming the first row that does not fit (see
    /// [`crate::run::rows_to_run`]), a taken id, and rows other things point at (a piece resting on
    /// one, a location listing one), because the run names its rows `<id>/<n>` and those pointers
    /// would dangle. The map is unchanged on a refusal.
    pub fn placements_to_run(
        &mut self,
        run_id: &str,
        rows: &[String],
        library: &crate::library::Library,
    ) -> Result<(), String> {
        let mut picked: Vec<Placed> = Vec::with_capacity(rows.len());
        for id in rows {
            let row = self
                .placements
                .iter()
                .find(|p| &p.id == id)
                .ok_or_else(|| format!("map: there is no placement `{id}` to convert"))?;
            picked.push(row.clone());
        }
        for id in rows {
            if self.placements.iter().any(|p| p.on.as_deref() == Some(id) && !rows.contains(&p.id)) {
                return Err(format!("map: something rests on `{id}`, so it cannot become part of a run"));
            }
            if self.locations.iter().any(|l| l.props.contains(id)) {
                return Err(format!("map: a location lists `{id}`, so it cannot become part of a run"));
            }
        }
        let (run, owned, owned_because) = crate::run::rows_to_run(&picked, library)?;
        let mut next = self.clone();
        next.placements.retain(|p| !rows.contains(&p.id));
        next.runs.push(crate::run::RunPlaced { id: run_id.to_owned(), run, owned, owned_because });
        next.validate()?;
        *self = next;
        Ok(())
    }

    /// **The stamp a row belongs to**, and the member within it โ€” `None` for a hand-placed row.
    ///
    /// A consumer reading an expanded map has a mixture of rows: the ones the author placed and
    /// the ones a group brought. Which is which is not a matter of taste โ€” a game that validates
    /// every row against the descriptors it knows has to skip the rows a group brought, because
    /// the group is the *game's* business (a house's wall panels are chosen by the house, not by
    /// the town), while the map's own rows are the town's.
    ///
    /// # The id alone is not enough
    ///
    /// A placement id is an authored string and nothing shape-checks it โ€” `fridge@1` ships, and
    /// `mess_a/table` is legal too. So a `/` in an id does **not** make a row a stamped one; the
    /// map's own stamp list is what says so. A hand-placed row named `mess_a/table` on a map with
    /// no such stamp is a hand-placed row, and answering otherwise would hand a consumer a
    /// classification it cannot check.
    ///
    /// The id *shape* comes from the one convention [`crate::composition::stamped_id`] writes, so
    /// the two cannot drift; the *membership* is this map's own.
    pub fn stamp_of<'a>(&self, placement_id: &'a str) -> Option<(&'a str, &'a str)> {
        let (stamp, member) = crate::composition::split_stamped_id(placement_id)?;
        // **The stamp must be one this map holds.** The id says the shape; the map says the fact.
        if !self.stamps.iter().any(|s| s.id == stamp) {
            return None;
        }
        Some((stamp, member))
    }

    /// Everything that must hold before a map is usable.
    ///
    /// `descriptors` is the set of known ids; pass an empty slice to skip that cross-check when
    /// validating a map in isolation.
    pub fn validate_against(&self, descriptors: &[String]) -> Result<(), String> {
        self.validate()?;
        if descriptors.is_empty() {
            return Ok(());
        }
        for p in &self.placements {
            if !descriptors.iter().any(|d| d == &p.descriptor) {
                return Err(format!(
                    "map: placement `{}` names descriptor `{}`, which does not exist",
                    p.id, p.descriptor
                ));
            }
        }
        Ok(())
    }

    /// The map's floor rectangle **in map space**: `(min_x, min_z, max_x, max_z)`.
    ///
    /// Map space, because that is the space [`Placed::at`] is in and this rectangle exists to say
    /// which `at` values are inside the map. It is a floor *plan*: centred on zero, and the same
    /// rectangle wherever the map is standing.
    ///
    /// It used to return world metres โ€” origin ยฑ half โ€” while every caller compared it against an
    /// `at`. The two agree for a map at the origin, which is the only kind the editor authors, so the
    /// disagreement was invisible: a map moved to `(100, 0, 0)` would have called every one of its own
    /// placements out of bounds. Add [`Self::origin`] to draw it.
    ///
    /// One place computes this, because a convention re-derived at three call sites is a convention
    /// that will disagree with itself at one of them.
    pub fn floor_rect(&self) -> (f32, f32, f32, f32) {
        let (hx, hz) = (self.bounds.0 * 0.5, self.bounds.2 * 0.5);
        (-hx, -hz, hx, hz)
    }

    /// A world point on the ground, expressed in map space โ€” what a cursor hit has to become before
    /// it can be compared against a [`Placed::at`] or written into one.
    pub fn to_map_space(&self, world_xz: (f32, f32)) -> (f32, f32) {
        (world_xz.0 - self.origin.0, world_xz.1 - self.origin.2)
    }

    /// Floor and ceiling heights in world metres. Y runs upward from the origin, not either side of
    /// it โ€” see [`Self::origin`].
    pub fn height_span(&self) -> (f32, f32) {
        (self.origin.1, self.origin.1 + self.bounds.1)
    }

    pub fn validate(&self) -> Result<(), String> {
        if !crate::naming::is_snake_case(&self.name) {
            let suggestion = crate::naming::to_snake_case(&self.name);
            return Err(if suggestion.is_empty() {
                format!(
                    "map: `{}` is not a usable name. Names are snake_case โ€” lowercase letters, \
                     digits and single underscores, starting with a letter.",
                    self.name
                )
            } else {
                format!(
                    "map: `{}` is not snake_case. Call it `{suggestion}`.",
                    self.name
                )
            });
        }

        for (axis, v) in [
            ("x", self.bounds.0),
            ("y", self.bounds.1),
            ("z", self.bounds.2),
        ] {
            if !(v.is_finite() && v > 0.0) {
                return Err(format!(
                    "map: bounds.{axis} is {v}. A map has to enclose something โ€” a non-positive \
                     extent makes every placement out of bounds and leaves a flood fill nowhere to \
                     stop."
                ));
            }
        }

        if self.version > MAP_VERSION {
            return Err(format!(
                "map: version {} but this build reads {MAP_VERSION} โ€” refusing a map written by a \
                 newer tool. Opening it would mean understanding part of it and writing the rest \
                 away.",
                self.version
            ));
        }

        let mut seen: Vec<&str> = Vec::with_capacity(self.placements.len());
        for p in &self.placements {
            if p.id.is_empty() {
                return Err("map: a placement has an empty id".to_owned());
            }
            if seen.contains(&p.id.as_str()) {
                return Err(format!(
                    "map: placement id `{}` is used twice โ€” locations reference placements by id, so \
                     a duplicate makes `props` ambiguous",
                    p.id
                ));
            }
            seen.push(&p.id);
            if !p.lift.is_finite() {
                return Err(format!(
                    "map: placement `{}` has lift {} โ€” a non-finite offset places it nowhere",
                    p.id, p.lift
                ));
            }
            if p.tip.0 > 3 || p.tip.1 > 3 {
                return Err(format!(
                    "map: placement `{}` has tip {:?} โ€” quarter turns are 0..=3 per axis",
                    p.id, p.tip
                ));
            }
            // An owned placement without a reason is the bool-instead-of-reason shape this schema
            // refuses on purpose.
            if p.owned && p.owned_because.as_ref().is_none_or(|r| r.trim().is_empty()) {
                return Err(format!(
                    "map: placement `{}` is owned but says nothing about why. An owned placement \
                     constrains a generator; in six months only that sentence can say whether it \
                     still should.",
                    p.id
                ));
            }
        }

        // **What rests on what.** Checked after every id is known, because a host may be authored
        // below its guest in the file and order is not the author's problem.
        for p in &self.placements {
            let Some(host) = &p.on else { continue };
            if host == &p.id {
                return Err(format!(
                    "map: placement `{}` rests on itself. Its height would be its own height, which \
                     has no answer.",
                    p.id
                ));
            }
            if !seen.contains(&host.as_str()) {
                return Err(format!(
                    "map: placement `{}` rests on `{host}`, which does not exist. A piece whose host \
                     is missing has no floor to stand on โ€” placing it at zero would put it through \
                     the ground.",
                    p.id
                ));
            }
        }
        self.no_stacking_cycles()?;

        // **Stamps share the placement id space.** The rows a stamp expands to are named
        // `<stamp>/<member>`, so a stamp sharing a name with a placement is a location's `props`
        // pointing at two different things.
        for st in &self.stamps {
            if st.id.is_empty() {
                return Err("map: a stamp has an empty id".to_owned());
            }
            if seen.contains(&st.id.as_str()) {
                return Err(format!(
                    "map: stamp `{}` shares its id with a placement. Every row a stamp expands to is \
                     named after it, so the two id spaces are one.",
                    st.id
                ));
            }
            if !st.at.0.is_finite() || !st.at.1.is_finite() || !st.yaw.is_finite() {
                return Err(format!(
                    "map: stamp `{}` is at a position that is not a number",
                    st.id
                ));
            }
            if st.owned
                && st
                    .owned_because
                    .as_ref()
                    .is_none_or(|r| r.trim().is_empty())
            {
                return Err(format!(
                    "map: stamp `{}` is owned but says nothing about why. An owned stamp constrains a \
                     generator; in six months only that sentence can say whether it still should.",
                    st.id
                ));
            }
        }
        // **Runs share the same id space**, for the reason stamps do: their rows are `<run>/<n>`.
        let mut runs_seen: Vec<&str> = Vec::with_capacity(self.runs.len());
        for r in &self.runs {
            if r.id.is_empty() {
                return Err("map: a run has an empty id".to_owned());
            }
            if seen.contains(&r.id.as_str()) || self.stamps.iter().any(|s| s.id == r.id) {
                return Err(format!(
                    "map: run `{}` shares its id with a placement or a stamp. Every row a run lays \
                     is named after it, so the id spaces are one.",
                    r.id
                ));
            }
            if runs_seen.contains(&r.id.as_str()) {
                return Err(format!(
                    "map: run id `{}` is used twice โ€” the rows it lays are named after it",
                    r.id
                ));
            }
            runs_seen.push(&r.id);
            if r.owned && r.owned_because.as_ref().is_none_or(|b| b.trim().is_empty()) {
                return Err(format!(
                    "map: run `{}` is owned but says nothing about why. An owned run constrains a \
                     generator; in six months only that sentence can say whether it still should.",
                    r.id
                ));
            }
        }
        let mut stamped: Vec<&str> = Vec::with_capacity(self.stamps.len());
        for st in &self.stamps {
            if stamped.contains(&st.id.as_str()) {
                return Err(format!(
                    "map: stamp id `{}` is used twice โ€” the rows it expands to are named after it, so \
                     a duplicate would produce two rows with one id",
                    st.id
                ));
            }
            stamped.push(&st.id);
        }

        for loc in &self.locations {
            if loc.props.is_empty() {
                return Err(format!(
                    "map: location `{}` governs no props โ€” it would advertise interactions with \
                     nothing to perform them on",
                    loc.id
                ));
            }
            for prop in &loc.props {
                if !seen.contains(&prop.as_str()) {
                    return Err(format!(
                        "map: location `{}` references placement `{prop}`, which does not exist",
                        loc.id
                    ));
                }
            }
            for i in &loc.interactions {
                if i.roles.is_empty() {
                    return Err(format!(
                        "map: interaction `{}` in location `{}` has no roles โ€” nobody could perform it",
                        i.verb, loc.id
                    ));
                }
                for r in &i.roles {
                    if r.max < r.min {
                        return Err(format!(
                            "map: role `{}` in `{}`/`{}` has max {} below min {}",
                            r.name, loc.id, i.verb, r.max, r.min
                        ));
                    }
                    // Smart Zones: a Main role gates the scene starting. One that can be filled by
                    // nobody is either a Supporting role that was mislabelled or a scene that can
                    // never run โ€” both worth failing at the door.
                    if r.kind == RoleKind::Main && r.min == 0 {
                        return Err(format!(
                            "map: role `{}` in `{}`/`{}` is Main with min 0. A Main role is what \
                             gates the scene starting; a Main role nobody has to fill is a \
                             Supporting role.",
                            r.name, loc.id, i.verb
                        ));
                    }
                }
            }
        }
        Ok(())
    }

    /// Nothing rests, however indirectly, on itself.
    ///
    /// A lamp on a table on that lamp has no height โ€” the resolver would recurse forever looking for
    /// a floor. Refusing here means [`crate::stack::resolve_y`] can walk the chain without a depth
    /// cap it would have to invent a number for.
    fn no_stacking_cycles(&self) -> Result<(), String> {
        // Iterative rather than recursive: a map is author data and a deep chain must not blow the
        // stack of whatever is reading it.
        for start in &self.placements {
            let mut seen_ids: Vec<&str> = vec![start.id.as_str()];
            let mut at = start;
            while let Some(host_id) = &at.on {
                let Some(host) = self.placements.iter().find(|q| &q.id == host_id) else {
                    // Already refused above; reaching it means this was called on its own.
                    break;
                };
                if seen_ids.contains(&host.id.as_str()) {
                    seen_ids.push(&host.id);
                    return Err(format!(
                        "map: these placements rest on each other in a loop: {}. Nothing in the \
                         chain touches the floor, so none of them has a height.",
                        seen_ids.join(" โ†’ ")
                    ));
                }
                seen_ids.push(&host.id);
                at = host;
            }
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// **A map written a row a line reads back, and writes back, byte for byte.**
    ///
    /// The hand-exported style of Sanctuary's town file: a header, one placement a line, the
    /// locations and the note. A live editor writes the file on every change, so a writer that
    /// reformatted it would turn an edit of one yaw into a diff of every row.
    #[test]
    fn a_map_written_one_row_a_line_reads_back_byte_for_byte() -> Result<(), String> {
        let text = r#"(
    version: 7,
    name: "yard",
    origin: (0.0, 0.0, 0.0),
    bounds: (300.0, 60.0, 1120.0),
    bash: None,
    placements: [
        (id: "torch_00", descriptor: "town/torch", at: (-137.1617, -94.9464), yaw: 0.0, lift: 9.1441),
        (id: "smelter_table", descriptor: "town/table", at: (-66.1, 120.6), yaw: 180.0, owned: true, owned_because: Some("the smith works here")),
        (id: "smelter_ingots", descriptor: "dress_ingots_steel", at: (-66.1, 120.6), yaw: 0.0, on: Some("smelter_table")),
        (id: "road_0_0", descriptor: "town/road", at: (-109.7293, 0.0), yaw: 90.0, patch: Some((extent: (footprint: Some((4.877, 190.1975)))))),
    ],
    locations: [],
    note: Some("A yard, for the writer's test. Positions are metres."),
    enclosure: (
        wish: 0.25,
        weight: 200,
    ),
)
"#;
        let map = Map::parse(text)?;
        let written = map.to_ron()?;
        assert_eq!(written, text);
        assert_eq!(Map::parse(&written)?, map);
        Ok(())
    }

    /// **A map still carrying the lattice fields is refused by name, not read with them ignored.**
    ///
    /// `face_bands` and `snap_divisor` left this schema at version 5 for
    /// [`crate::kits::Lattice`] โ€” a map has exactly one lattice, but so does the project, and a
    /// tile's adjacency contract cannot depend on which map happens to be open.
    ///
    /// A *removal* is the one schema change the version floor cannot absorb quietly: an old file
    /// carrying the fields would parse with them silently dropped, and the author would never learn
    /// that the number they set stopped meaning anything. `deny_unknown_fields` is what makes that
    /// loud, and this pins it โ€” the shipped maps were migrated in the same commit.
    #[test]
    fn a_map_still_carrying_the_moved_lattice_is_refused_by_name() {
        let stale = r#"(version: 4, name: "m", origin: (0.0, 0.0, 0.0), face_bands: 2, placements: [], locations: [])"#;
        let e = Map::parse(stale).err().unwrap_or_default();
        assert!(e.contains("face_bands"), "the refusal names the field: {e}");

        let stale = r#"(version: 4, name: "m", origin: (0.0, 0.0, 0.0), snap_divisor: 4, placements: [], locations: [])"#;
        let e = Map::parse(stale).err().unwrap_or_default();
        assert!(e.contains("snap_divisor"), "the refusal names the field: {e}");

        // And one carrying neither of them still opens, meaning exactly what it meant.
        let before =
            r#"(version: 2, name: "m", origin: (0.0, 0.0, 0.0), placements: [], locations: [])"#;
        Map::parse(before).unwrap_or_else(|e| panic!("a pre-lattice map still opens: {e}"));
    }

    /// **A map states the combination it draws on, and `None` is every bound kit.**
    ///
    /// The absent spelling means the same thing, and cannot be made to mean anything else: serde
    /// answers a missing `Option` field with `None` in every format, so there is no version of this
    /// field that is both `Option<String>` and required. Pinned here because that is the fact the
    /// schema doc rests on โ€” everything this program writes states `bash`, and a hand-written file
    /// that leaves it out has written the new-map state rather than something nobody chose.
    #[test]
    fn a_map_names_a_bash_or_says_none_and_none_is_every_bound_kit() {
        let stated =
            r#"(version: 6, name: "m", origin: (0.0, 0.0, 0.0), bash: None, placements: [], locations: [])"#;
        let m = Map::parse(stated).unwrap_or_else(|e| panic!("{e}"));
        assert_eq!(m.bash, None, "`None` is every bound kit, and it is written down");
        assert_eq!(Map::default().bash, None, "a new map starts there too");

        let silent = r#"(version: 6, name: "m", origin: (0.0, 0.0, 0.0), placements: [], locations: [])"#;
        let m = Map::parse(silent).unwrap_or_else(|e| panic!("{e}"));
        assert_eq!(m.bash, None, "and omitting it is the same value, not a second meaning");

        let named =
            r#"(version: 6, name: "m", origin: (0.0, 0.0, 0.0), bash: Some("hub"), placements: [], locations: [])"#;
        let m = Map::parse(named).unwrap_or_else(|e| panic!("{e}"));
        assert_eq!(m.bash.as_deref(), Some("hub"));

        // **Written back out every time.** No `skip_serializing_if`, so a map saved from this
        // program always says which combination it draws on.
        let out = ron::ser::to_string(&Map { bash: Some("hub".into()), ..Map::default() })
            .unwrap_or_else(|e| panic!("{e}"));
        assert!(out.contains("bash:Some(\"hub\")") || out.contains("bash: Some(\"hub\")"), "{out}");
        let out = ron::ser::to_string(&Map::default()).unwrap_or_else(|e| panic!("{e}"));
        assert!(out.contains("bash:None") || out.contains("bash: None"), "{out}");
    }

    fn table_map() -> Map {
        Map {
            version: MAP_VERSION,
            name: "galley".into(),
            origin: (0.0, 0.0, 0.0),
            bounds: default_bounds(),
            bash: None,
            stamps: Vec::new(),
            placements: vec![
                Placed {
                    id: "table_1".into(),
                    descriptor: "mess_table".into(),
                    at: (4.0, 4.0),
                    yaw: 0.0,
                    ..Placed::default()
                },
                Placed {
                    id: "stool_1".into(),
                    descriptor: "stool".into(),
                    at: (4.0, 3.0),
                    yaw: 180.0,
                    ..Placed::default()
                },
            ],
            locations: vec![Location {
                id: "galley_table".into(),
                props: vec!["table_1".into(), "stool_1".into()],
                note: Some("the galley's near table".into()),
                interactions: vec![Interaction {
                    verb: "eat".into(),
                    roles: vec![RoleSlot {
                        name: "diner".into(),
                        kind: RoleKind::Main,
                        min: 1,
                        max: 4,
                        socket_role: Some("diner".into()),
                        requires: vec!["eat".into()],
                    }],
                    guard: None,
                    effects: vec![Effect::Restore {
                        drive: "stamina".into(),
                        rate: 0.2,
                    }],
                    note: None,
                }],
            }],
            note: Some("a galley, for the schema tests".into()),
            ..Default::default()
        }
    }

    #[test]
    fn a_table_and_its_stools_are_one_affordance() {
        let m = table_map();
        m.validate().expect("valid");
        // The point of the whole design: two props, one interaction.
        assert_eq!(m.locations[0].props.len(), 2);
        assert_eq!(m.locations[0].interactions.len(), 1);
    }

    /// **A map from a newer tool is refused, never half-read.**
    ///
    /// The direction that matters: this build would understand the fields it knows, drop the ones it
    /// does not, and write the file back without them. A map silently losing every stamp in it is the
    /// failure the version number exists for.
    #[test]
    fn a_map_from_a_newer_schema_is_refused_not_migrated() {
        let mut m = table_map();
        m.version = MAP_VERSION + 1;
        let err = m.validate().expect_err("must refuse");
        assert!(err.contains("newer tool"), "{err}");
    }

    /// **A map from before a field existed still loads**, because its absence is what it always meant.
    ///
    /// The other half of the floor rule, and the reason it is a floor. Refusing this direction would
    /// destroy a map to prevent nothing: a file written before [`Map::stamps`] says `stamps: []`,
    /// which is exactly what it said then.
    #[test]
    fn a_map_from_before_a_field_existed_still_loads() {
        let mut m = table_map();
        m.version = MAP_VERSION - 1;
        m.validate()
            .expect("an older map means what it always meant");
        assert!(m.stamps.is_empty());
    }

    #[test]
    fn a_location_cannot_reference_a_placement_that_is_not_there() {
        let mut m = table_map();
        m.locations[0].props.push("ghost_chair".into());
        let err = m.validate().expect_err("must refuse");
        assert!(err.contains("ghost_chair"), "{err}");
    }

    /// Placement ids are how locations name their props, so a duplicate is an ambiguity, not a typo.
    #[test]
    fn duplicate_placement_ids_are_refused() {
        let mut m = table_map();
        m.placements[1].id = "table_1".into();
        assert!(m.validate().is_err());
    }

    #[test]
    fn an_owned_placement_must_say_why() {
        let mut m = table_map();
        m.placements[0].owned = true;
        let err = m.validate().expect_err("must refuse a reasonless lock");
        assert!(err.contains("why"), "{err}");

        m.placements[0].owned_because = Some("the cell block's only entrance".into());
        m.validate().expect("a reason satisfies it");
    }

    /// A Main role with min 0 cannot gate anything, which is the only thing Main means.
    #[test]
    fn a_main_role_nobody_must_fill_is_refused() {
        let mut m = table_map();
        m.locations[0].interactions[0].roles[0].min = 0;
        let err = m.validate().expect_err("must refuse");
        assert!(err.contains("Supporting"), "{err}");
    }

    #[test]
    fn a_placement_naming_an_unknown_descriptor_is_refused() {
        let m = table_map();
        let known = vec!["mess_table".to_string()];
        let err = m.validate_against(&known).expect_err("stool is unknown");
        assert!(err.contains("stool"), "{err}");

        let known = vec!["mess_table".to_string(), "stool".to_string()];
        m.validate_against(&known).expect("both known");
    }

    #[test]
    fn the_map_round_trips_through_ron() {
        let m = table_map();
        let text =
            ron::ser::to_string_pretty(&m, ron::ser::PrettyConfig::default()).expect("serializes");
        assert_eq!(Map::parse(&text).expect("parses"), m);
    }

    /// **A map's file is not padded with defaults it does not need** (E-057).
    ///
    /// A save of the real town map wrote 226,923 bytes to say 739 placements, and **four of the
    /// fields on every one of them were a default**: `on: None`, `owned: false`, `owned_because:
    /// None`, `patch: None`, `note: None`. Measured on that file, dropping those lines is **41 % of
    /// it** โ€” and nothing is lost, because an absent field in RON deserializes to exactly the value
    /// the line stated, which is what `#[serde(default)]` already promises.
    ///
    /// **The assertion is on both facts at once, and it has to be.** A test that only measured the
    /// size would pass with a writer that dropped a *meaningful* field; a test that only round-tripped
    /// would pass today, with every default written out. So this one weighs the text AND parses it
    /// back to the same map, so the saving cannot be bought with data.
    #[test]
    fn a_default_field_is_not_written_and_nothing_is_lost() {
        let mut m = Map { name: "sparse".to_owned(), ..Map::default() };
        for i in 0..20 {
            m.placements.push(Placed {
                id: format!("piece_{i}"),
                descriptor: "kit/fence".to_owned(),
                at: (i as f32, 0.0),
                ..Placed::default()
            });
        }
        let text =
            ron::ser::to_string_pretty(&m, ron::ser::PrettyConfig::default()).expect("serializes");

        // The lines that used to be written for every placement, none of which says anything when
        // it holds its default. **`note` is counted on the placement rows only**: the map's own
        // header `note` is written even when absent, on purpose โ€” one line per file buys a reader a
        // visible place to write, which is why the field exists.
        for padding in [
            "on: None",
            "owned: false",
            "owned_because: None",
            "patch: None",
        ] {
            assert!(
                !text.contains(padding),
                "`{padding}` is a default and is still being written {} times; \
                 the file is padded with facts its reader already assumes:\n{text}",
                text.matches(padding).count()
            );
        }
        assert_eq!(
            text.matches("note:").count(),
            1,
            "only the map's own header note is written; 20 placement rows must not say `note: None`"
        );
        assert_eq!(
            Map::parse(&text).expect("the sparse file still parses"),
            m,
            "dropping the defaults must not change the map that comes back"
        );
    }

    /// **And a field that is NOT its default is still written**, which is the half a size test alone
    /// would not catch: the point of skipping a default is that a stated value survives.
    #[test]
    fn a_stated_field_is_still_written() {
        let mut m = Map { name: "stated".to_owned(), ..Map::default() };
        m.placements.push(Placed {
            id: "owned_1".to_owned(),
            descriptor: "kit/gate".to_owned(),
            at: (1.0, 2.0),
            yaw: 90.0,
            // **A host that is not in the map is refused by `parse`**, so the fixture states a real
            // one: `owned_1` stands on the piece below it, which is what `on` means.
            on: None,
            owned: true,
            owned_because: Some("the gate the level starts at".to_owned()),
            note: Some("swings both ways".to_owned()),
            paint: 3,
            ..Placed::default()
        });
        let text =
            ron::ser::to_string_pretty(&m, ron::ser::PrettyConfig::default()).expect("serializes");
        for stated in [
            "owned: true",
            "the gate the level starts at",
            "swings both ways",
            "paint: 3",
        ] {
            assert!(
                text.contains(stated),
                "`{stated}` is a stated value and must be written:\n{text}"
            );
        }
        assert_eq!(Map::parse(&text).expect("parses"), m);
    }

    /// **The reason `note:` is a field.** The same `to_string_pretty` that deleted 279 lines of
    /// rationale from `config.ron` on 2026-07-16 carries these through untouched, because they are
    /// data. No surgical writer, no comment-preserving pass, nothing to forget.
    ///
    /// The assertion is deliberately on the *serialized text* as well as the parsed value: a
    /// round-trip through `PartialEq` would still pass if a future serializer config dropped the
    /// field and the parser defaulted it back to `None` on both sides.
    #[test]
    fn prose_survives_an_ordinary_serializer_because_it_is_a_field() {
        let mut m = table_map();
        m.placements[0].note = Some("the slab the specimen goes on".into());

        let text =
            ron::ser::to_string_pretty(&m, ron::ser::PrettyConfig::default()).expect("serializes");
        for prose in [
            "a galley, for the schema tests",
            "the slab the specimen goes on",
        ] {
            assert!(
                text.contains(prose),
                "`{prose}` was lost by the serializer:\n{text}"
            );
        }

        let back = Map::parse(&text).expect("parses");
        assert_eq!(
            back, m,
            "a note must survive the round trip, not just the write"
        );
        assert_eq!(
            back.placements[0].note.as_deref(),
            Some("the slab the specimen goes on")
        );
    }

    /// A note is prose, so it may contain the things prose contains โ€” including the `//` that would
    /// have ended a comment, and quotes.
    ///
    /// **The serializer escapes more than it needs to.** Measured against `ron 0.12.2`: an apostrophe
    /// inside a double-quoted string comes out as `\'`, so "the galley's near table" is written
    /// `"the galley\'s near table"`. It parses back identically, which is the property that matters โ€”
    /// but it means a *textual* grep of a map file for an author's note can miss it, and a tool that
    /// diffs notes must compare parsed values rather than bytes.
    #[test]
    fn a_note_may_contain_anything_prose_contains() {
        for prose in [
            r#"see docs/ui.md ยง5 // and the "kit" notes"#,
            "the galley's near table",
            "a\nnote\nover several lines",
        ] {
            let mut m = table_map();
            m.note = Some(prose.to_owned());
            let text = ron::ser::to_string_pretty(&m, ron::ser::PrettyConfig::default())
                .expect("serializes");
            assert_eq!(
                Map::parse(&text).expect("parses").note.as_deref(),
                Some(prose),
                "prose did not survive:\n{text}"
            );
        }
    }

    /// Absence is the normal case โ€” a map with nothing to explain must not be forced to say so.
    #[test]
    fn a_note_is_optional_everywhere() {
        let text = r#"(
            version: 1,
            name: "bare",
            origin: (0.0, 0.0, 0.0),
            placements: [
                ( id: "a", descriptor: "crate", at: (1.0, 1.0), yaw: 0.0 ),
            ],
        )"#;
        let m = Map::parse(text).expect("a map with no notes must parse");
        assert_eq!(m.note, None);
        assert_eq!(m.placements[0].note, None);
        m.validate().expect("and validate");
    }
}