jspsych-tangram 0.0.1

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 (72) hide show
  1. package/README.md +25 -0
  2. package/dist/construct/index.browser.js +20431 -0
  3. package/dist/construct/index.browser.js.map +1 -0
  4. package/dist/construct/index.browser.min.js +42 -0
  5. package/dist/construct/index.browser.min.js.map +1 -0
  6. package/dist/construct/index.cjs +3720 -0
  7. package/dist/construct/index.cjs.map +1 -0
  8. package/dist/construct/index.d.ts +204 -0
  9. package/dist/construct/index.js +3718 -0
  10. package/dist/construct/index.js.map +1 -0
  11. package/dist/index.cjs +3920 -0
  12. package/dist/index.cjs.map +1 -0
  13. package/dist/index.d.ts +340 -0
  14. package/dist/index.js +3917 -0
  15. package/dist/index.js.map +1 -0
  16. package/dist/prep/index.browser.js +20455 -0
  17. package/dist/prep/index.browser.js.map +1 -0
  18. package/dist/prep/index.browser.min.js +42 -0
  19. package/dist/prep/index.browser.min.js.map +1 -0
  20. package/dist/prep/index.cjs +3744 -0
  21. package/dist/prep/index.cjs.map +1 -0
  22. package/dist/prep/index.d.ts +139 -0
  23. package/dist/prep/index.js +3742 -0
  24. package/dist/prep/index.js.map +1 -0
  25. package/package.json +77 -0
  26. package/src/core/components/README.md +249 -0
  27. package/src/core/components/board/BoardView.tsx +352 -0
  28. package/src/core/components/board/GameBoard.tsx +682 -0
  29. package/src/core/components/board/index.ts +70 -0
  30. package/src/core/components/board/useAnchorGrid.ts +110 -0
  31. package/src/core/components/board/useClickController.ts +436 -0
  32. package/src/core/components/board/useDragController.ts +1051 -0
  33. package/src/core/components/board/usePieceState.ts +178 -0
  34. package/src/core/components/board/utils.ts +76 -0
  35. package/src/core/components/index.ts +33 -0
  36. package/src/core/components/pieces/BlueprintRing.tsx +238 -0
  37. package/src/core/config/config.ts +85 -0
  38. package/src/core/domain/blueprints.ts +25 -0
  39. package/src/core/domain/layout.ts +159 -0
  40. package/src/core/domain/primitives.ts +159 -0
  41. package/src/core/domain/solve.ts +184 -0
  42. package/src/core/domain/types.ts +111 -0
  43. package/src/core/engine/collision/grid-snapping.ts +283 -0
  44. package/src/core/engine/collision/index.ts +4 -0
  45. package/src/core/engine/collision/sat-collision.ts +46 -0
  46. package/src/core/engine/collision/validation.ts +166 -0
  47. package/src/core/engine/geometry/bounds.ts +91 -0
  48. package/src/core/engine/geometry/collision.ts +64 -0
  49. package/src/core/engine/geometry/index.ts +19 -0
  50. package/src/core/engine/geometry/math.ts +101 -0
  51. package/src/core/engine/geometry/pieces.ts +290 -0
  52. package/src/core/engine/geometry/polygons.ts +43 -0
  53. package/src/core/engine/state/BaseGameController.ts +368 -0
  54. package/src/core/engine/validation/border-rendering.ts +318 -0
  55. package/src/core/engine/validation/complete.ts +102 -0
  56. package/src/core/engine/validation/face-to-face.ts +217 -0
  57. package/src/core/index.ts +3 -0
  58. package/src/core/io/InteractionTracker.ts +742 -0
  59. package/src/core/io/data-tracking.ts +271 -0
  60. package/src/core/io/json-to-tangram-spec.ts +110 -0
  61. package/src/core/io/quickstash.ts +141 -0
  62. package/src/core/io/stims.ts +110 -0
  63. package/src/core/types/index.ts +5 -0
  64. package/src/core/types/plugin-interfaces.ts +101 -0
  65. package/src/index.spec.ts +19 -0
  66. package/src/index.ts +2 -0
  67. package/src/plugins/tangram-construct/ConstructionApp.tsx +105 -0
  68. package/src/plugins/tangram-construct/index.ts +156 -0
  69. package/src/plugins/tangram-prep/PrepApp.tsx +182 -0
  70. package/src/plugins/tangram-prep/index.ts +122 -0
  71. package/tangram-construct.min.js +42 -0
  72. package/tangram-prep.min.js +42 -0
@@ -0,0 +1,742 @@
1
+ /**
2
+ * InteractionTracker - Event accumulation and data tracking for experiments
3
+ *
4
+ * This class listens to game controller events and builds structured data
5
+ * according to the data-tracking.ts schema. It generates InteractionEvent
6
+ * objects and accumulates TrialEndData for export.
7
+ *
8
+ * ## Key Responsibilities
9
+ * - Track interaction lifecycle (pickup → placedown)
10
+ * - Accumulate click events and mouse tracking between interactions
11
+ * - Generate InteractionEvent objects with state snapshots
12
+ * - Build TrialEndData on trial completion
13
+ * - Call onInteraction/onTrialEnd callbacks for data export
14
+ *
15
+ * ## Architecture
16
+ * ```
17
+ * User Interaction
18
+ * ↓
19
+ * UI Controllers (drag/click)
20
+ * ↓
21
+ * BaseGameController Events
22
+ * ↓
23
+ * InteractionTracker
24
+ * ↓
25
+ * onInteraction(event) → socket.emit()
26
+ * onTrialEnd(data) → socket.emit()
27
+ * ```
28
+ */
29
+
30
+ import { v4 as uuidv4 } from 'uuid';
31
+ import type { BaseGameController } from '../engine/state/BaseGameController';
32
+ import { boundsOfBlueprint, piecePolysAt } from '../engine/geometry';
33
+ import { CONFIG } from '../config/config';
34
+ import type {
35
+ InteractionEvent,
36
+ StateSnapshot,
37
+ ConstructionTrialData,
38
+ PrepTrialData,
39
+ DataTrackingCallbacks,
40
+ SectorSnapshot,
41
+ PieceSnapshot,
42
+ MacroSnapshot
43
+ } from './data-tracking';
44
+
45
+ /**
46
+ * Pickup event data (temporary storage until placedown)
47
+ */
48
+ interface PickupData {
49
+ pieceId: string;
50
+ blueprintId: string;
51
+ blueprintType: 'primitive' | 'composite';
52
+ timestamp: number;
53
+ source: 'blueprint' | 'sector';
54
+ sectorId: string | undefined;
55
+ position: { x: number; y: number };
56
+ vertices: number[][][];
57
+ }
58
+
59
+ /**
60
+ * Click event data (accumulated until interaction completes)
61
+ */
62
+ interface ClickEventData {
63
+ timestamp: number;
64
+ location: { x: number; y: number };
65
+ clickType: 'blueprint_view_switch' | 'invalid_placement' | 'sector_complete_attempt';
66
+ blueprintViewSwitch?: {
67
+ from: 'quickstash' | 'primitives';
68
+ to: 'quickstash' | 'primitives';
69
+ };
70
+ invalidPlacement?: {
71
+ reason: 'overlapping' | 'outside_bounds' | 'no_valid_anchor' | 'sector_complete';
72
+ attemptedSectorId?: string;
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Mouse tracking event data (accumulated until interaction completes)
78
+ */
79
+ interface MouseTrackingData {
80
+ timestamp: number;
81
+ anchorX: number;
82
+ anchorY: number;
83
+ sectorId: string | undefined;
84
+ phase: 'before_pickup' | 'while_holding';
85
+ }
86
+
87
+ /**
88
+ * InteractionTracker - Main tracking class
89
+ */
90
+ export class InteractionTracker {
91
+ // IDs
92
+ private gameId: string;
93
+ private trialId: string;
94
+
95
+ // Callbacks
96
+ private callbacks: DataTrackingCallbacks;
97
+
98
+ // Controller reference
99
+ private controller: BaseGameController;
100
+
101
+ // Trial timing
102
+ private trialStartTime: number;
103
+ private readonly gridStep: number = CONFIG.layout.grid.stepPx;
104
+
105
+ // Interaction state
106
+ private interactionIndex: number = 0;
107
+ private currentPickup: PickupData | null = null;
108
+ private previousSnapshotId: string | undefined = undefined;
109
+
110
+ // Event accumulation (reset after each interaction)
111
+ private clickEvents: ClickEventData[] = [];
112
+ private mouseTracking: MouseTrackingData[] = [];
113
+
114
+ // Interaction history (for TrialEndData)
115
+ private interactions: InteractionEvent[] = [];
116
+
117
+ // Construction-specific tracking
118
+ private completionTimes: Array<{ sectorId: string; completedAt: number }> = [];
119
+
120
+ // Prep-specific tracking
121
+ private createdMacros: MacroSnapshot[] = [];
122
+
123
+ constructor(
124
+ controller: BaseGameController,
125
+ callbacks: DataTrackingCallbacks,
126
+ trialId?: string,
127
+ gameId?: string
128
+ ) {
129
+ this.controller = controller;
130
+ this.callbacks = callbacks;
131
+ this.trialId = trialId || uuidv4();
132
+ this.gameId = gameId || uuidv4();
133
+ this.trialStartTime = Date.now();
134
+
135
+ // Register tracking callbacks with controller
136
+ this.controller.setTrackingCallbacks({
137
+ onSectorCompleted: (sectorId) => this.recordSectorCompletion(sectorId)
138
+ });
139
+ }
140
+
141
+ /**
142
+ * Cleanup method to unregister from controller
143
+ */
144
+ dispose(): void {
145
+ this.controller.setTrackingCallbacks(null);
146
+ }
147
+
148
+ // ===== Event Recording Methods =====
149
+ // Called by UI controllers to record events
150
+
151
+ /**
152
+ * Record piece pickup event
153
+ */
154
+ recordPickup(
155
+ pieceId: string,
156
+ blueprintId: string,
157
+ blueprintType: 'primitive' | 'composite',
158
+ source: 'blueprint' | 'sector',
159
+ position: { x: number; y: number },
160
+ vertices: number[][][],
161
+ sectorId?: string
162
+ ): void {
163
+ const anchorPosition = this.toAnchorPoint(position);
164
+ const anchorVertices = this.toAnchorVertices(vertices);
165
+
166
+ this.currentPickup = {
167
+ pieceId,
168
+ blueprintId,
169
+ blueprintType,
170
+ timestamp: Date.now(),
171
+ source,
172
+ sectorId,
173
+ position: anchorPosition,
174
+ vertices: anchorVertices
175
+ };
176
+
177
+ // Mark all subsequent mouse tracking as "while_holding"
178
+ // (UI controllers should call recordMouseMove after this)
179
+ }
180
+
181
+ /**
182
+ * Record piece placedown event
183
+ */
184
+ recordPlacedown(
185
+ outcome: 'placed' | 'deleted',
186
+ sectorId?: string,
187
+ position?: { x: number; y: number },
188
+ vertices?: number[][][],
189
+ anchorId?: string,
190
+ wasValid?: boolean,
191
+ wasOverlapping?: boolean,
192
+ completedSector?: boolean
193
+ ): void {
194
+ if (!this.currentPickup) {
195
+ console.warn('[InteractionTracker] recordPlacedown called without pickup');
196
+ return;
197
+ }
198
+
199
+ const interactionType = this.determineInteractionType(this.currentPickup.source, outcome);
200
+ const placedownTimestamp = Date.now();
201
+ const holdDuration = placedownTimestamp - this.currentPickup.timestamp;
202
+
203
+ // Build post-interaction snapshot
204
+ // If this placement completes a sector, mark it in the snapshot
205
+ const postSnapshot = this.buildStateSnapshot(completedSector ? sectorId : undefined);
206
+
207
+ const anchorPosition = position ? this.toAnchorPoint(position) : undefined;
208
+ const anchorVertices = vertices ? this.toAnchorVertices(vertices) : undefined;
209
+
210
+ // Create interaction event - build incrementally to satisfy exactOptionalPropertyTypes
211
+ const event: InteractionEvent = {
212
+ // Metadata
213
+ interactionId: uuidv4(),
214
+ trialId: this.trialId,
215
+ gameId: this.gameId,
216
+ interactionIndex: this.interactionIndex++,
217
+
218
+ // Interaction type
219
+ interactionType,
220
+
221
+ // Piece information
222
+ pieceId: this.currentPickup.pieceId,
223
+ blueprintId: this.currentPickup.blueprintId,
224
+ blueprintType: this.currentPickup.blueprintType,
225
+
226
+ // Pickup event
227
+ pickupTimestamp: this.currentPickup.timestamp,
228
+ pickupSource: this.currentPickup.source,
229
+ pickupSectorId: this.currentPickup.sectorId,
230
+ pickupPosition: this.currentPickup.position,
231
+ pickupVertices: this.currentPickup.vertices,
232
+
233
+ // Placedown event
234
+ placedownTimestamp,
235
+ placedownOutcome: outcome,
236
+
237
+ // Validation context
238
+ wasValid: outcome === 'placed' && wasValid === true,
239
+ wasOverlapping: outcome === 'placed' && wasOverlapping === true,
240
+
241
+ // Timing
242
+ holdDuration,
243
+
244
+ // Events
245
+ clickEvents: [...this.clickEvents],
246
+ mouseTracking: [...this.mouseTracking],
247
+
248
+ // Snapshots
249
+ postSnapshot
250
+ };
251
+
252
+ // Add optional placedown properties based on outcome
253
+ if (outcome === 'placed' && sectorId) {
254
+ event.placedownSectorId = sectorId;
255
+ }
256
+ if (outcome === 'placed' && anchorPosition) {
257
+ event.placedownPosition = anchorPosition;
258
+ }
259
+ if (outcome === 'placed' && anchorVertices) {
260
+ event.placedownVertices = anchorVertices;
261
+ }
262
+ if (anchorId) {
263
+ event.placedownAnchorId = anchorId;
264
+ }
265
+ if (this.previousSnapshotId !== undefined) {
266
+ event.preSnapshotId = this.previousSnapshotId;
267
+ }
268
+
269
+ // Store for trial end data
270
+ this.interactions.push(event);
271
+ this.previousSnapshotId = postSnapshot.snapshotId;
272
+
273
+ // Call interaction callback
274
+ if (this.callbacks.onInteraction) {
275
+ this.callbacks.onInteraction(event);
276
+ }
277
+
278
+ // Reset interaction state
279
+ this.currentPickup = null;
280
+ this.clickEvents = [];
281
+ this.mouseTracking = [];
282
+ }
283
+
284
+ /**
285
+ * Record click event
286
+ */
287
+ recordClickEvent(
288
+ location: { x: number; y: number },
289
+ clickType: 'blueprint_view_switch' | 'invalid_placement' | 'sector_complete_attempt',
290
+ metadata?: {
291
+ blueprintViewSwitch?: { from: 'quickstash' | 'primitives'; to: 'quickstash' | 'primitives' };
292
+ invalidPlacement?: { reason: 'overlapping' | 'outside_bounds' | 'no_valid_anchor' | 'sector_complete'; attemptedSectorId?: string };
293
+ }
294
+ ): void {
295
+ const anchorLocation = this.toAnchorPoint(location);
296
+
297
+ this.clickEvents.push({
298
+ timestamp: Date.now(),
299
+ location: anchorLocation,
300
+ clickType,
301
+ ...metadata
302
+ });
303
+ }
304
+
305
+ /**
306
+ * Record mouse movement to different anchor
307
+ */
308
+ recordMouseMove(
309
+ anchorX: number,
310
+ anchorY: number,
311
+ sectorId?: string
312
+ ): void {
313
+ const phase: 'before_pickup' | 'while_holding' = this.currentPickup ? 'while_holding' : 'before_pickup';
314
+ const anchorCoordX = this.toAnchorValue(anchorX);
315
+ const anchorCoordY = this.toAnchorValue(anchorY);
316
+
317
+ // Only record if anchor position changed
318
+ const lastTracking = this.mouseTracking[this.mouseTracking.length - 1];
319
+ if (lastTracking && lastTracking.anchorX === anchorCoordX && lastTracking.anchorY === anchorCoordY) {
320
+ return; // Same position, skip
321
+ }
322
+
323
+ this.mouseTracking.push({
324
+ timestamp: Date.now(),
325
+ anchorX: anchorCoordX,
326
+ anchorY: anchorCoordY,
327
+ sectorId,
328
+ phase
329
+ });
330
+ }
331
+
332
+ /**
333
+ * Record sector completion
334
+ */
335
+ recordSectorCompletion(sectorId: string): void {
336
+ this.completionTimes.push({
337
+ sectorId,
338
+ completedAt: Date.now()
339
+ });
340
+ }
341
+
342
+ /**
343
+ * Record macro creation (prep mode only)
344
+ */
345
+ recordMacroCreation(macro: MacroSnapshot): void {
346
+ this.createdMacros.push(macro);
347
+ }
348
+
349
+ // ===== Trial Completion =====
350
+
351
+ /**
352
+ * Finalize trial and call onTrialEnd callback
353
+ */
354
+ finalizeTrial(endReason: 'timeout' | 'auto_complete' | 'submit'): void {
355
+ const trialEndTime = Date.now();
356
+ const totalDuration = trialEndTime - this.trialStartTime;
357
+ const finalSnapshot = this.buildStateSnapshot();
358
+
359
+ const mode = this.controller.state.cfg.mode;
360
+
361
+ if (mode === 'construction') {
362
+ const finalBlueprintState = this.buildFinalBlueprintState(finalSnapshot);
363
+
364
+ // Extract composites as quickstash macros (ready for next trial input)
365
+ const quickstashMacros = finalBlueprintState
366
+ .filter(bp => bp.blueprintType === 'composite')
367
+ .map(bp => ({
368
+ id: bp.blueprintId,
369
+ parts: bp.parts!, // Composites always have parts
370
+ ...(bp.label && { label: bp.label })
371
+ }));
372
+
373
+ const data: ConstructionTrialData = {
374
+ trialType: 'construction',
375
+ trialId: this.trialId,
376
+ gameId: this.gameId,
377
+ trialNum: 0, // TODO: Plugin should provide this
378
+ trialStartTime: this.trialStartTime,
379
+ trialEndTime,
380
+ totalDuration,
381
+ endReason: endReason as 'timeout' | 'auto_complete',
382
+ completionTimes: this.completionTimes,
383
+ finalBlueprintState,
384
+ quickstashMacros,
385
+ finalSnapshot
386
+ };
387
+
388
+ if (this.callbacks.onTrialEnd) {
389
+ this.callbacks.onTrialEnd(data);
390
+ }
391
+ } else {
392
+ const finalMacros = this.createdMacros.length
393
+ ? this.createdMacros
394
+ : this.buildMacrosFromSnapshot(finalSnapshot);
395
+
396
+ // Convert MacroSnapshot to quickstash input format
397
+ // Ensure IDs start with "comp:" for proper border rendering
398
+ const quickstashMacros = finalMacros.map(macro => ({
399
+ id: macro.macroId.startsWith('comp:') ? macro.macroId : `comp:${macro.macroId}`,
400
+ parts: macro.parts
401
+ // Note: label not included in prep macros
402
+ }));
403
+
404
+ const data: PrepTrialData = {
405
+ trialType: 'prep',
406
+ trialId: this.trialId,
407
+ gameId: this.gameId,
408
+ trialNum: 0, // TODO: Plugin should provide this
409
+ trialStartTime: this.trialStartTime,
410
+ trialEndTime,
411
+ totalDuration,
412
+ endReason: 'submit',
413
+ createdMacros: finalMacros,
414
+ quickstashMacros,
415
+ finalSnapshot
416
+ };
417
+
418
+ if (this.callbacks.onTrialEnd) {
419
+ this.callbacks.onTrialEnd(data);
420
+ }
421
+ }
422
+ }
423
+
424
+ // ===== Helper Methods =====
425
+
426
+ /**
427
+ * Determine interaction type from source and outcome
428
+ */
429
+ private determineInteractionType(
430
+ source: 'blueprint' | 'sector',
431
+ outcome: 'placed' | 'deleted'
432
+ ): 'spawn' | 'move' | 'delete' {
433
+ if (outcome === 'deleted') return 'delete';
434
+ if (source === 'blueprint') return 'spawn';
435
+ return 'move';
436
+ }
437
+
438
+ private toAnchorValue(value: number): number {
439
+ return Math.round(value / this.gridStep);
440
+ }
441
+
442
+ private toAnchorPoint(point: { x: number; y: number }): { x: number; y: number } {
443
+ return { x: this.toAnchorValue(point.x), y: this.toAnchorValue(point.y) };
444
+ }
445
+
446
+ private toAnchorVertices(vertices: number[][][]): number[][][] {
447
+ return vertices.map(ring =>
448
+ ring.map(vertex => [
449
+ this.toAnchorValue(vertex[0]!),
450
+ this.toAnchorValue(vertex[1]!)
451
+ ])
452
+ );
453
+ }
454
+
455
+ private buildMacrosFromSnapshot(snapshot: StateSnapshot): MacroSnapshot[] {
456
+ return snapshot.sectors.map(sec => {
457
+ // STEP 1: Convert bbox positions to shape origin positions in anchor coordinates
458
+ const shapeOriginPositions = sec.pieces.map(piece => {
459
+ const bp = this.controller.getBlueprint(piece.blueprintId);
460
+ const kind = bp && 'kind' in bp ? bp.kind : piece.blueprintId;
461
+
462
+ // Get the piece's actual position in pixels from controller state
463
+ const actualPiece = this.controller.findPiece(piece.pieceId);
464
+ if (!actualPiece || !bp) {
465
+ return {
466
+ kind,
467
+ position: piece.position // Fallback to bbox position
468
+ };
469
+ }
470
+
471
+ // Get bounding box of the blueprint using the geometry helper
472
+ const bb = boundsOfBlueprint(bp, this.controller.getPrimitive);
473
+
474
+ // Convert bbox position to shape origin position
475
+ // In pixels: shapeOriginPos = piece.pos - bb.min
476
+ // Then convert to anchor coordinates
477
+ const shapeOriginPixels = {
478
+ x: actualPiece.pos.x - bb.min.x,
479
+ y: actualPiece.pos.y - bb.min.y
480
+ };
481
+ const shapeOriginAnchor = this.toAnchorPoint(shapeOriginPixels);
482
+
483
+ return {
484
+ kind,
485
+ position: shapeOriginAnchor // Shape origin in anchor coordinates
486
+ };
487
+ });
488
+
489
+ // STEP 2: Calculate centroid and convert to relative offsets
490
+ if (shapeOriginPositions.length === 0) {
491
+ return {
492
+ macroId: sec.sectorId,
493
+ parts: [],
494
+ shape: [],
495
+ pieceCount: 0
496
+ };
497
+ }
498
+
499
+ // Calculate centroid of all pieces (in anchor coordinates)
500
+ let sumX = 0, sumY = 0;
501
+ for (const item of shapeOriginPositions) {
502
+ sumX += item.position.x;
503
+ sumY += item.position.y;
504
+ }
505
+ const centroidX = Math.round(sumX / shapeOriginPositions.length);
506
+ const centroidY = Math.round(sumY / shapeOriginPositions.length);
507
+
508
+ // Convert absolute positions to relative offsets (like ANCHOR_COMPOSITES format)
509
+ const parts = shapeOriginPositions.map(item => ({
510
+ kind: item.kind,
511
+ anchorOffset: {
512
+ x: Math.round(item.position.x - centroidX),
513
+ y: Math.round(item.position.y - centroidY)
514
+ }
515
+ }));
516
+
517
+ const shape = sec.pieces.flatMap(piece => piece.vertices || []);
518
+
519
+ return {
520
+ macroId: sec.sectorId,
521
+ parts,
522
+ shape,
523
+ pieceCount: sec.pieceCount
524
+ };
525
+ });
526
+ }
527
+
528
+ /**
529
+ * Build state snapshot from current controller state
530
+ * @param justCompletedSectorId - Sector ID that was just completed (but controller state hasn't updated yet)
531
+ */
532
+ private buildStateSnapshot(justCompletedSectorId?: string): StateSnapshot {
533
+ const snapshotId = uuidv4();
534
+ const timestamp = Date.now();
535
+
536
+ // Build sector snapshots
537
+ const sectors: SectorSnapshot[] = [];
538
+ const completedSectorIds: string[] = [];
539
+
540
+ for (const sector of this.controller.state.cfg.sectors) {
541
+ const sectorState = this.controller.state.sectors[sector.id];
542
+ // Check if this sector was just completed OR is already completed
543
+ const completed = sector.id === justCompletedSectorId || !!sectorState?.completedAt;
544
+
545
+ if (completed) {
546
+ completedSectorIds.push(sector.id);
547
+ }
548
+
549
+ const pieces: PieceSnapshot[] = (sectorState?.pieces || []).map(p => ({
550
+ pieceId: p.id,
551
+ blueprintId: p.blueprintId,
552
+ blueprintType: this.getBlueprintType(p.blueprintId),
553
+ position: this.toAnchorPoint(p.pos),
554
+ vertices: this.getPieceVertices(p.id)
555
+ }));
556
+
557
+ const snapshot: SectorSnapshot = {
558
+ sectorId: sector.id,
559
+ completed,
560
+ pieceCount: pieces.length,
561
+ pieces
562
+ };
563
+
564
+ if (sectorState?.completedAt !== undefined) {
565
+ snapshot.completedAt = sectorState.completedAt;
566
+ }
567
+
568
+ sectors.push(snapshot);
569
+ }
570
+
571
+ // Build sector-tangram map
572
+ const sectorTangramMap = this.controller.state.cfg.sectors.map(s => ({
573
+ sectorId: s.id,
574
+ tangramId: s.id // In our system, sector ID == tangram ID
575
+ }));
576
+
577
+ // Build blueprint order
578
+ const blueprintOrder = {
579
+ primitives: this.controller.state.primitives.map(bp => bp.id),
580
+ quickstash: this.controller.state.quickstash.map(bp => bp.id)
581
+ };
582
+
583
+ return {
584
+ snapshotId,
585
+ timestamp,
586
+ sectors,
587
+ completedSectorIds,
588
+ sectorTangramMap,
589
+ blueprintOrder
590
+ };
591
+ }
592
+
593
+ /**
594
+ * Get blueprint type from blueprint ID
595
+ */
596
+ private getBlueprintType(blueprintId: string): 'primitive' | 'composite' {
597
+ const bp = this.controller.getBlueprint(blueprintId);
598
+ if (!bp) return 'primitive'; // Fallback
599
+ return 'kind' in bp ? 'primitive' : 'composite';
600
+ }
601
+
602
+ /**
603
+ * Get piece vertices in world space
604
+ */
605
+ private getPieceVertices(pieceId: string): number[][][] {
606
+ const piece = this.controller.findPiece(pieceId);
607
+ if (!piece) return [];
608
+
609
+ const bp = this.controller.getBlueprint(piece.blueprintId);
610
+ if (!bp) return [];
611
+
612
+ const bb = boundsOfBlueprint(bp, (k) => this.controller.getPrimitive(k));
613
+ const polys = piecePolysAt(bp, bb, piece.pos) ?? [];
614
+ const asNumbers = polys.map(ring => ring.map(pt => [pt.x, pt.y] as [number, number]));
615
+ return this.toAnchorVertices(asNumbers);
616
+ }
617
+
618
+ /**
619
+ * Build final blueprint state (usage counts + definitions)
620
+ */
621
+ private buildFinalBlueprintState(snapshot: StateSnapshot): Array<{
622
+ blueprintId: string;
623
+ blueprintType: 'primitive' | 'composite';
624
+ totalPieces: number;
625
+ bySector: Array<{ sectorId: string; count: number }>;
626
+ parts?: Array<{ kind: string; anchorOffset: { x: number; y: number } }>;
627
+ shape?: Array<Array<{ x: number; y: number }>>;
628
+ label?: string;
629
+ }> {
630
+ const counts = new Map<string, {
631
+ blueprintType: 'primitive' | 'composite';
632
+ totalPieces: number;
633
+ bySector: Map<string, number>;
634
+ }>();
635
+
636
+ // Accumulate counts
637
+ for (const sector of snapshot.sectors) {
638
+ for (const piece of sector.pieces) {
639
+ if (!counts.has(piece.blueprintId)) {
640
+ counts.set(piece.blueprintId, {
641
+ blueprintType: piece.blueprintType,
642
+ totalPieces: 0,
643
+ bySector: new Map()
644
+ });
645
+ }
646
+
647
+ const entry = counts.get(piece.blueprintId)!;
648
+ entry.totalPieces++;
649
+ entry.bySector.set(sector.sectorId, (entry.bySector.get(sector.sectorId) || 0) + 1);
650
+ }
651
+ }
652
+
653
+ // Ensure all primitives are represented, even if count is zero
654
+ for (const primitive of this.controller.state.primitives) {
655
+ if (!counts.has(primitive.id)) {
656
+ counts.set(primitive.id, {
657
+ blueprintType: 'primitive',
658
+ totalPieces: 0,
659
+ bySector: new Map()
660
+ });
661
+ }
662
+ }
663
+
664
+ // Ensure all composites are represented, even if count is zero
665
+ for (const composite of this.controller.state.quickstash) {
666
+ if (!counts.has(composite.id)) {
667
+ counts.set(composite.id, {
668
+ blueprintType: 'composite',
669
+ totalPieces: 0,
670
+ bySector: new Map()
671
+ });
672
+ }
673
+ }
674
+
675
+ // Convert to array format with blueprint definitions
676
+ return Array.from(counts.entries()).map(([blueprintId, data]) => {
677
+ const blueprint = this.controller.getBlueprint(blueprintId);
678
+
679
+ const result: {
680
+ blueprintId: string;
681
+ blueprintType: 'primitive' | 'composite';
682
+ totalPieces: number;
683
+ bySector: Array<{ sectorId: string; count: number }>;
684
+ parts?: Array<{ kind: string; anchorOffset: { x: number; y: number } }>;
685
+ shape?: Array<Array<{ x: number; y: number }>>;
686
+ label?: string;
687
+ } = {
688
+ blueprintId,
689
+ blueprintType: data.blueprintType,
690
+ totalPieces: data.totalPieces,
691
+ bySector: Array.from(data.bySector.entries()).map(([sectorId, count]) => ({
692
+ sectorId,
693
+ count
694
+ }))
695
+ };
696
+
697
+ // Add blueprint definition for both primitives and composites
698
+ if (blueprint) {
699
+ if (data.blueprintType === 'composite' && 'parts' in blueprint) {
700
+ // Composite blueprint: convert pixel-based parts to anchor-based parts
701
+ result.parts = blueprint.parts.map(part => ({
702
+ kind: part.kind,
703
+ anchorOffset: {
704
+ x: Math.round(part.offset.x / this.gridStep),
705
+ y: Math.round(part.offset.y / this.gridStep)
706
+ }
707
+ }));
708
+
709
+ // Convert pixel-based shape to anchor-based shape
710
+ if (blueprint.shape) {
711
+ result.shape = blueprint.shape.map(poly =>
712
+ poly.map(vertex => ({
713
+ x: Math.round(vertex.x / this.gridStep),
714
+ y: Math.round(vertex.y / this.gridStep)
715
+ }))
716
+ );
717
+ }
718
+
719
+ // Add label if available
720
+ if (blueprint.label) {
721
+ result.label = blueprint.label;
722
+ }
723
+ } else if (data.blueprintType === 'primitive' && 'kind' in blueprint) {
724
+ // Primitive blueprint: convert pixel-based shape to anchor-based shape
725
+ if (blueprint.shape) {
726
+ result.shape = blueprint.shape.map(poly =>
727
+ poly.map(vertex => ({
728
+ x: Math.round(vertex.x / this.gridStep),
729
+ y: Math.round(vertex.y / this.gridStep)
730
+ }))
731
+ );
732
+ }
733
+
734
+ // Primitives don't have labels, but we could add a default one based on kind
735
+ result.label = blueprint.kind;
736
+ }
737
+ }
738
+
739
+ return result;
740
+ });
741
+ }
742
+ }