diff --git a/AGENTS.md b/AGENTS.md
index 229a38e..1726b56 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -46,6 +46,10 @@ npm run models:optimize # comprimir los .glb de public/assets/models
- Nunca hacer commit, amend, push ni PR sin que lo pida explícitamente la
persona usuaria.
- Remoto: `origin` → https://github.com/jmoredev/PC-Simulator.git
+- **Colaboración externa:** quien no trabaja aquí propone los cambios desde una
+ rama propia con un *pull request* hacia `main`, para que se revisen antes de
+ publicarse (las instrucciones para la persona colaboradora están en
+ `README.md`, sección «Cómo colaborar»).
## 4. Estructura de carpetas
@@ -194,7 +198,13 @@ Reglas de layout:
| 57 | **En práctica, cada fase empieza con su cartel** | Antes el cartel salía solo al completar una fase, así que quien arrancaba la partida o saltaba de fase no veía qué había que hacer. Ahora sale al **empezar** cada fase en práctica (también la primera, y también al reiniciar), se quita solo a los 3,6 s, y el estado guarda `lastAdvance` para poder decir «Fase completada» solo cuando se llegó completando y no cuando se saltó. En examen no se monta: el examen no da explicaciones. Cubierto en §16. |
| 58 | **La pieza seleccionada se ve ampliada en una ventanita** (fase 1) | En la fase 1 están todas las piezas sobre la mesa y los conectores se ven muy pequeños. Al seleccionar una, una ventanita la enseña entera: el **PNG del conector** cuando existe (más nítido que el dibujo 3D: `ps2`, `usb`, `lan`, `hdmi`, `displayport`, `dvi`, `vga`) y, si no, una **vista 3D de cerca** con la misma `ComponentVisual` (drei no trae `View` en esta versión, así que es un mini lienzo). Va al centro-abajo —la captura enseñó que a la izquierda tapaba la lista de fases—, con `pointer-events: none` para no estorbar el arrastre, solo en la fase 1 y **sin el nombre en examen** (misma regla que la 56). Cubierto en §17. |
| 59 | **La ventanita del zoom se monta una vez por fase, no por pieza** | Montarla condicionada a la pieza seleccionada la desmontaba **en cada colocación** de la fase 1 (18 por partida) y **cada montaje creaba un contexto WebGL nuevo**; el que el navegador se lleva por agotamiento es el más antiguo, o sea el de la **escena principal**, y la vista 3D dejaría de pintar. Ahora el panel (y su lienzo) viven toda la fase 1 y sin selección se **ocultan** con `zoom--oculto` (así el lienzo conserva su tamaño y su contexto); fuera de la fase 1 se desmontan, porque ahí no hacen falta. Hallazgo `R4-001` de la revisión nativa; cubierto en §17, donde el ciclo **seleccionar → colocar (que deselecciona) → seleccionar** se comprueba de verdad y un marcador sobre el nodo del lienzo detecta cualquier remontaje (falsado: con el montaje condicional fallan las cinco comprobaciones de ese ciclo). |
-| 60 | **Los carteles de la fase 1 se reparten en 6 × 3, con las filas a 1,75 u** | Cada cartel acepta **un solo tipo** de pieza, así que la pieza nunca se coloca en el cartel equivocado: el defecto que reportó el alumnado era de **conteo**. `endDrag` cuenta fallo cuando **no** hay cartel correcto a `snapRadius` (0,6) y **sí** uno ajeno a `wrongRadius` (0,6), y con las filas a 0,95 u esa banda empezaba **a 0,35 u del centro**, o sea 0,15 u más allá del borde visible de la tarjeta (1,0 × 0,4): soltar cerca del borde contaba el vecino y **restaba puntos** (medido en examen: 100 → 97, 0 → 1 fallos). Con 1,75 u las dos regiones ya no se solapan (1,75 ≥ 0,6 + 0,6): fallar de largo solo deja la pieza sin colocar, sin coste. El peor hueco en pantalla entre dos tarjetas pasa de **23 px a 31 px**. Cuatro filas de cinco columnas no caben más separadas —a 1,25 u la de atrás queda a 0,1 u del borde de la mesa y a 1,3 se sale—, así que la forma pasa a 6 × 3 (18 celdas exactas, sin huecos). Todo medido con la proyección de la cámara, y el agarre de la pieza en el arnés quedó unificado en `COGER_PIEZA` (el monitor caía 0,15 u por encima del punto calculado y no se dejaba coger). Cubierto en §19, con la regla geométrica, el hueco en pantalla y la suelta real a 0,7 u; falsado: con 0,95 u fallan los cuatro. |
+| 60 | **Los carteles de la fase 1 se reparten en 6 × 3, con las filas a 1,75 u** | Cada cartel acepta **un solo tipo** de pieza, así que la pieza nunca se coloca en el cartel equivocado: el defecto que reportó el alumnado era de **conteo**. `endDrag` cuenta fallo cuando **no** hay cartel correcto a `snapRadius` (0,6) y **sí** uno ajeno a `wrongRadius` (0,6), y lo cuenta **pasado `snapRadius`** —antes de eso la pieza coloca—, así que lo que decide es hasta dónde alcanza el vecino: con las filas a 0,95 u su banda llegaba a los **0,35 u** del centro, 0,15 u más allá del borde visible de la tarjeta (1,0 × 0,4), de modo que **cualquier tiro de más de 0,6 u caía dentro**, contaba el vecino y **restaba puntos** (medido en examen: 100 → 97, 0 → 1 fallos). Con las filas a 1,75 u (y la columna a 1,5 u, que es el mínimo) el vecino no entra hasta los **0,9 u**: los tiros entre 0,6 y 0,9 u ya no cuestan nada y solo dejan la pieza sin colocar, y pasado 0,9 u el vecino vuelve a contar — el margen sin coste pasa de 0,6 u a 0,9 u, **no a infinito**. El peor hueco en pantalla entre dos tarjetas pasa de **23 px a 31 px**. Cuatro filas de cinco columnas no caben más separadas —a 1,25 u la de atrás queda a 0,1 u del borde de la mesa y a 1,3 se sale—, así que la forma pasa a 6 × 3 (18 celdas exactas, sin huecos). Todo medido con la proyección de la cámara, y el agarre de la pieza en el arnés quedó unificado en `COGER_PIEZA` (el monitor caía 0,15 u por encima del punto calculado y no se dejaba coger). Cubierto en §19 —regla geométrica, hueco en pantalla, suelta real a 0,7 u y los márgenes de `snapRadius` y del vecino—; falsado: con 0,95 u fallan los seis. Las dos primeras redacciones de esta fila decían que el fallo empezaba a 0,35 u y que fallar de largo no costaba nada: lo corrigió la verificación independiente (V1-V5 MET, sin bloqueantes) y los checks que la acompañan lo dejan fijado. |
+| 61 | **La rejilla de la fase 1 se deriva del número de piezas** | La fase 1 tiene una pieza por cada tipo de conector de la placa, así que su número cambia con la placa: **18** con ocho tipos y **19** en la 03, que trae uno más. La rejilla estaba fija en 6 columnas × 3 filas = 18 celdas, y los carteles en `ceil(n / 6)` filas, así que con 19 piezas el reparto se rompía por dos sitios a la vez: la pieza 19 repetía la celda de la 15 (una columna de más hacía que `floor(18 / 3)` se recortara a la última) y los carteles, repartidos en `ceil(19 / 6) = 4` filas de 1,75 u, dejaban **una fila entera fuera de la mesa** —medido: borde trasero en z = −6,2 con la mesa acabando en −4,9, y en pantalla a y = 111 px, por encima del borde lejano de la mesa (y = 154)—. Es el defecto que se reportó («los cables de audio, micrófono y E/S salen fuera de la mesa»): los carteles de esos cables eran algunos de los que quedaban flotando más allá del borde, y la pieza que compartía celda desaparecía al colocarla. Ahora `gridCols` calcula las columnas que hacen falta —hasta el suelo de separación: 0,9 u entre piezas (huella 0,75 + aire), 1,5 u entre carteles (`snapRadius` + `wrongRadius`) y ±4 / ±4,5 u de ancho— y avisa por consola si ni así caben, en vez de dejar dos piezas compartiendo sitio en silencio. Las filas y sus separaciones no cambian, así que con 18 piezas el reparto sale **idéntico** al de antes, y el arnés mide las dos formas: `traySeparation` e `identifyCards` corren en `motherboard-01` y `motherboard-03` —§11 y §19—. |
+| 62 | **La pieza procedural se alinea midiendo en su espacio, no en el mundo** | Las piezas que dibuja el código (`Placeholder`) se ajustan con `Align`, que centraba la pieza midiendo su caja envolvente con `Box3.setFromObject` —en coordenadas de **mundo**—, así que el centrado incluía la propia colocación en la mesa y la dejaba en el **origen del mundo**: el centro de la mesa. Solo se notaba en las **cuatro piezas procedurales de la fase 1** (los tres jacks y el USB-C, los únicos sin PNG ni `.glb`): su celda quedaba vacía —el cartel con su nombre sí salía, así que el cable parecía estar fuera de la mesa— y al cogerlas con el ratón el fantasma aparecía en el centro de la mesa en vez de bajo el cursor, de modo que al soltarlas se perdían. Los `.glb` se ajustan dentro de `GltfModel` y los conectores con imagen son un plano que no se alinea, por eso llevaba tiempo ahí sin que lo cazara nadie: lo reportó el alumnado («algunos cables de los construidos por ti salen fuera de la mesa y desaparecen»). Ahora `localBox` mide cada malla por su matriz **relativa al grupo** (mundo → grupo) y el centrado vuelve a ser el correcto. Cubierto en §20 —barrido del lienzo, pieza por pieza: ninguna puede quedarse sin geometría en su celda—; falsado: sin el arreglo falla nombrando las cuatro piezas procedurales. |
+| 63 | **La imagen del USB-C es una foto de Wikimedia Commons (CC BY-SA 4.0)** | El cable USB-C (señuelo de la fase 2) era el único conector sin PNG: se dibujaba con la forma procedural, justo la que destapó el defecto del `Align` (fila 62). Se parte de la foto de Santeri Viinamäki (`USB_Type-C_plug_20170626_crop.jpg`, Commons), se gira para que la clavija apunte hacia abajo como en el resto de imágenes y se le quita el fondo (PNG con transparencia; el fondo se detecta por crecimiento de región desde el borde, parando en el gradiente del contorno). Es la primera imagen del proyecto con licencia **BY-SA**: el archivo sigue bajo CC BY-SA 4.0 y hay que seguir citando autor y fuente. Está en `CREDITS.md` y en `src/data/credits.ts`, que se mantienen en paralelo (decisión 42). |
+| 64 | **La portada se presenta como proyecto colaborativo y construido con Gentle-AI** | El repositorio es público, así que el `README.md` lleva el enlace al repositorio, el sello **«Built with Gentle-AI»** (el enlace y la imagen que pide el propio proyecto) y la sección **«Cómo colaborar»** con el flujo que pide su responsable: **nunca trabajar sobre `main`**, cada cambio en su rama (`feat/…`, `fix/…`, `docs/…`) y una **propuesta de cambio (pull request) hacia `main`** para que la revise antes de fusionarla —desde un *fork* si quien colabora no tiene permiso de escritura—. El desarrollo interno sigue en `develop` (sección 3). Es documentación pura, así que no hay prueba automática: se comprobaron los tres enlaces (repositorio, destino del sello e imagen del sello, los tres 200) y el snippet HTML renderizado en la portada. |
+| 65 | **El proyecto se publica con licencia MIT (el código) y los recursos de terceros mantienen la suya** | El repositorio no tenía licencia, así que se añade `LICENSE` con **MIT**: permite a cualquiera usar, copiar y modificar el producto **citando el original** (conservar el aviso de copyright y el texto de la licencia), que es exactamente lo que se pidió y sin las obligaciones de una licencia copyleft. El titular es `jmoredev` (autoría de todos los commits del proyecto), con el año del primer commit (2026). `package.json` lo declara (`"license": "MIT"`) y el `README.md` lo explica. **Lo importante**: la licencia MIT cubre el **código** —lo que hay en el repositorio salvo los recursos de terceros—, y los **modelos 3D y las imágenes** NO se relicencian: cada archivo mantiene su licencia (CC BY, CC BY-SA 4.0 la foto del USB-C), con su autor y su fuente en `CREDITS.md`; si algún día se quisiera relicenciar todo, habría que sustituir los recursos de terceros por otros propios o compatibles. GitHub enseña la licencia en el lateral del repositorio cuando el archivo `LICENSE` está en la rama por defecto (`main`), así que llega al publicarse el PR. Es documentación y metadatos: no hay prueba automática, se comprobó el texto estándar de MIT sin modificar y que `npm run build`/`lint` siguen igual. |
+
## 7. Pipeline de assets 3D
@@ -388,6 +398,13 @@ disfrazaba de fallo de producto: siete comprobaciones «se quedó en identificac
pasada entera, que dura unos 11 minutos): `ONLY=touchDrag,loadingBar node
tests/run.mjs`, con los nombres de `SECTIONS` separados por comas.
+**El socket de HMR de Vite no cuenta como error de la app**: el arnés ignora los
+`console.error` de `WebSocket connection to ws://… failed` (una vez, en una
+pasada de 20 minutos, el servidor rechazó una reconexión y la sección «sin
+errores de página» salía en rojo con todos sus checks funcionales en verde). Si
+el servidor se cae de verdad, las secciones fallan por sus propios checks y
+`requireServer()` avisa antes de empezar.
+
## 11. Despliegue (GitHub Pages)
- **URL pública (tras activar Pages):**
diff --git a/CREDITS.md b/CREDITS.md
index d816860..7819baa 100644
--- a/CREDITS.md
+++ b/CREDITS.md
@@ -1,5 +1,9 @@
# Créditos y licencias
+El **código** del simulador es [MIT](../LICENSE) (usar, copiar y modificar citando
+el original). Lo de abajo son los **recursos de terceros**, que mantienen su propia
+licencia.
+
Los **modelos 3D** y las **imágenes de los conectores** de este proyecto son obra
de sus autores y se usan bajo la licencia que se indica en la tabla. Se pueden
consultar también dentro del simulador, en **Menú → Créditos y licencias**.
@@ -64,6 +68,11 @@ Todos los modelos 3D proceden de [Sketchfab](https://sketchfab.com) y son
| `displayport.png` | Conector DisplayPort | | | | |
| `dvi.png` | Conector DVI | | | | |
| `vga.png` | Conector VGA | | | | |
+| `usb-c.png` | Conector USB-C | Santeri Viinamäki | [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:USB_Type-C_plug_20170626_crop.jpg) | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) | Recortada, girada y con el fondo quitado (PNG con transparencia) |
+
+> La imagen del USB-C es una **foto** de Wikimedia Commons: al ser **CC BY-SA**,
+> el archivo `usb-c.png` (versión recortada y con fondo transparente) se
+> distribuye con la misma licencia, y hay que seguir citando a su autor.
## Otras librerías
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..5969e71
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 jmoredev
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 4e1d4ec..2d6f143 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,13 @@ didáctica para cada componente.

+**Repositorio:** — si quieres
+proponer un cambio, mira [Cómo colaborar](#cómo-colaborar).
+
+
+
+
+
## Versión publicada
La versión online para el aula se publica desde la rama `main` en
@@ -90,6 +97,9 @@ npm run models:optimize # comprime los .glb de public/assets/models
- **`develop`** → rama de trabajo; **todo el desarrollo va aquí**.
- Ramas cortas desde `develop` con prefijo `feat/`, `fix/` o `docs/`.
+Si vas a proponer un cambio para que se revise, sigue [Cómo
+colaborar](#cómo-colaborar): rama propia y *pull request* hacia `main`.
+
```bash
git clone https://github.com/jmoredev/PC-Simulator.git
cd PC-Simulator
@@ -100,6 +110,41 @@ npm install
Las decisiones de diseño, la estructura de carpetas y las convenciones están
documentadas en [`AGENTS.md`](AGENTS.md).
+## Cómo colaborar
+
+El proyecto vive en y **todo cambio
+pasa por una revisión** antes de llegar a la versión publicada: se propone en una
+rama y se revisa en un *pull request* hacia `main`.
+
+1. **Trabaja siempre en una rama**, nunca directamente sobre `main`:
+
+ ```bash
+ git clone https://github.com/jmoredev/PC-Simulator.git
+ cd PC-Simulator
+ git checkout -b feat/mi-cambio # o fix/..., docs/...
+ ```
+
+2. **Haz el cambio y pruébalo** antes de proponerlo:
+
+ ```bash
+ npm run build # compila sin errores
+ npm run lint # sin avisos
+ node tests/run.mjs # arnés de pruebas (necesita el servidor de dev levantado)
+ ```
+
+3. **Abre un *pull request* hacia `main`**, no hacia otra rama, explicando qué
+ cambia y por qué. El responsable del proyecto lo revisa antes de fusionarlo;
+ hasta entonces el cambio vive en tu rama (y en `main` no entra nada sin
+ revisar).
+
+4. **Si no tienes permiso de escritura en el repositorio**, haz antes un *fork*
+ con el botón **Fork** de GitHub y abre el pull request desde tu copia a
+ `main` del repositorio original.
+
+Si el cambio añade o cambia algo visible (una fase, una pieza, un texto de la
+interfaz), cuenta también qué hay que mirar para comprobarlo: es lo que se
+revisa en el pull request.
+
## Pruebas
Con el servidor de desarrollo levantado:
@@ -245,6 +290,17 @@ src/
y [@react-three/drei](https://github.com/pmndrs/drei)
- [Zustand](https://zustand.docs.pmnd.rs/) para el estado
+## Licencia
+
+El **código** de este proyecto (todo lo que hay en el repositorio salvo los
+recursos de terceros) es **MIT**: cualquiera puede usarlo, copiarlo y modificarlo,
+**citando el original** (conservar el aviso de copyright y la licencia, ver
+[`LICENSE`](LICENSE)).
+
+Los **modelos 3D y las imágenes** de terceros **no** entran en esa licencia: cada
+archivo mantiene la suya (CC BY, CC BY-SA…), con su autor y su fuente, en
+[`CREDITS.md`](CREDITS.md).
+
## Créditos y licencias
Los modelos 3D y las imágenes de los conectores son obra de sus autores y se usan
diff --git a/package.json b/package.json
index fed45e4..4fc8cba 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,7 @@
"private": true,
"version": "0.0.0",
"type": "module",
+ "license": "MIT",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
diff --git a/src/assets/connectors/README.md b/src/assets/connectors/README.md
index c9a62a1..0fc51d2 100644
--- a/src/assets/connectors/README.md
+++ b/src/assets/connectors/README.md
@@ -33,3 +33,10 @@ El nombre debe ser el `kind` del conector. Estos son los que se usan:
Si un PNG falta, ese conector se dibuja con la forma procedural de
`src/three/Placeholder.tsx` y el simulador sigue funcionando.
+
+## Créditos
+
+El autor, la fuente, la licencia y los cambios de cada imagen están en
+[`CREDITS.md`](../../CREDITS.md) y en `src/data/credits.ts`. Ojo con
+`usb-c.png`: es una foto de Wikimedia Commons con licencia **CC BY-SA 4.0**, así
+que hay que seguir citando a su autor y el archivo se queda con esa licencia.
diff --git a/src/assets/connectors/usb-c.png b/src/assets/connectors/usb-c.png
new file mode 100644
index 0000000..5fd7026
Binary files /dev/null and b/src/assets/connectors/usb-c.png differ
diff --git a/src/data/components.ts b/src/data/components.ts
index f05ec4a..22552ac 100644
--- a/src/data/components.ts
+++ b/src/data/components.ts
@@ -42,13 +42,6 @@ export const BOARD_MODEL = {
rotation: ACTIVE_BOARD.rotation,
}
-function traySlot(stage: StageId, indexInStage: number): [number, number] {
- const { cols, rows } = STAGE_BY_ID[stage].tray
- const col = Math.floor(indexInStage / rows.length)
- const row = indexInStage % rows.length
- return [cols[Math.min(col, cols.length - 1)], rows[row]]
-}
-
/**
* Dimensión mayor con la que se dibuja una pieza en la mesa. Los modelos se
* normalizan a `def.size` y luego se escalan para no pasar de 0,75
@@ -57,13 +50,89 @@ function traySlot(stage: StageId, indexInStage: number): [number, number] {
*/
export const TRAY_MAX_SPAN = 0.75
+/** Aire que se deja como mínimo entre dos piezas vecinas de la mesa. */
+const TRAY_MIN_GAP = 0.15
+
/** Separación más pequeña entre dos líneas de una rejilla (columnas o filas). */
function gridGap(lines: number[]): number {
return Math.min(...lines.slice(1).map((v, i) => Math.abs(v - lines[i])))
}
-/** Aire que se deja como mínimo entre dos piezas vecinas de la mesa. */
-const TRAY_MIN_GAP = 0.15
+/** Cómo se reparten las columnas de una bandeja. */
+interface TrayGrid {
+ /** Nombre del reparto, para el aviso de consola. */
+ name: string
+ /** Separación entre columnas mientras caben todas. */
+ pitch: number
+ /** Anchura disponible para el reparto, que queda centrado en 0. */
+ width: number
+ /** Suelo de la separación entre columnas: huella de una pieza más aire. */
+ minPitch: number
+}
+
+/**
+ * Columnas de una rejilla para `count` celdas repartidas en `rowCount` filas.
+ *
+ * Las filas las fija la bandeja y su separación está medida (en la fase 1: 2,1 u
+ * entre filas de piezas, para que la cámara oblicua no dibuje una justo detrás de
+ * otra, y 1,75 u entre carteles, para que el cartel de al lado no entre en la
+ * banda de fallo), así que el número de columnas sale del de celdas: las que hagan
+ * falta hasta tocar el suelo duro.
+ *
+ * Avisa por consola si ni con el suelo duro caben, porque a partir de ahí dos
+ * piezas compartirían celda en silencio: es el defecto que se midió con la placa
+ * de 19 piezas (los tres jacks de audio entre ellas). La pieza 19 repetía la celda
+ * de la 15 y los carteles, repartidos en `ceil(19 / 6) = 4` filas, ponían una fila
+ * entera **fuera de la mesa** (z = −6,2 con la mesa acabando en −4,9).
+ */
+function gridCols(rowCount: number, count: number, grid: TrayGrid): number[] {
+ const maxCols = Math.floor(grid.width / grid.minPitch) + 1
+ const cols = Math.max(1, Math.min(maxCols, Math.ceil(count / rowCount)))
+ if (count > cols * rowCount) {
+ console.warn(
+ `bandeja: «${grid.name}» no da para ${count} celdas en ${cols}×${rowCount} ` +
+ `(caben ${cols * rowCount}); dos piezas compartirán sitio.`,
+ )
+ }
+ const step = cols > 1 ? Math.min(grid.pitch, grid.width / (cols - 1)) : 0
+ const span = step * (cols - 1)
+ return Array.from({ length: cols }, (_, i) => -span / 2 + i * step)
+}
+
+/**
+ * Rejilla de las piezas de la fase 1: 6 columnas × 3 filas = 18 celdas para las 18
+ * piezas de una placa con ocho tipos de conector, y una columna más (hasta 7, que
+ * dan 21) cuando la placa trae más.
+ *
+ * La anchura se queda en 8 u (±4) a propósito: más ancho sacaba piezas de la mesa
+ * visible (x ≈ 118 px) y las metía debajo de los paneles laterales.
+ */
+const PIECE_GRID: TrayGrid = {
+ name: 'piezas de la fase 1',
+ pitch: 1.6,
+ width: 8,
+ minPitch: TRAY_MAX_SPAN + TRAY_MIN_GAP,
+}
+
+/**
+ * Columnas de la bandeja de una fase. La de la fase 1 se calcula la primera vez que
+ * hace falta, porque depende de cuántas piezas tenga la partida (`IDENTIFY_CELLS`,
+ * que se conoce unas líneas más abajo, cuando ya está montado el catálogo).
+ */
+let identifyCols: number[] | null = null
+function trayCols(stage: StageId): number[] {
+ const { cols, rows } = STAGE_BY_ID[stage].tray
+ if (stage !== 'identify') return cols
+ return (identifyCols ??= gridCols(rows.length, IDENTIFY_CELLS, PIECE_GRID))
+}
+
+function traySlot(stage: StageId, indexInStage: number): [number, number] {
+ const { rows } = STAGE_BY_ID[stage].tray
+ const cols = trayCols(stage)
+ const col = Math.floor(indexInStage / rows.length)
+ const row = indexInStage % rows.length
+ return [cols[Math.min(col, cols.length - 1)], rows[row]]
+}
/**
* Desplazamiento aleatorio máximo de una pieza dentro de su celda.
@@ -77,8 +146,8 @@ const TRAY_MIN_GAP = 0.15
* la da el barajado, no el desplazamiento.
*/
export function trayJitter(stage: StageId): number {
- const { cols, rows } = STAGE_BY_ID[stage].tray
- const gap = Math.min(gridGap(cols), gridGap(rows))
+ const { rows } = STAGE_BY_ID[stage].tray
+ const gap = Math.min(gridGap(trayCols(stage)), gridGap(rows))
// Una rejilla de una sola línea no tiene distancia que medir en ese eje
// (`gridGap` devuelve infinito) y una de 1×1 no tiene parejas: sin este
// guardia el desplazamiento saldría infinito y la pieza se iría de la mesa.
@@ -495,6 +564,9 @@ function cableComponents(): RawComponent[] {
const ALL_COMPONENTS: RawComponent[] = [...RAW, ...cableComponents()]
+/** Celdas de la mesa de la fase 1: una por componente del catálogo. */
+const IDENTIFY_CELLS = ALL_COMPONENTS.length
+
export const COMPONENTS: ComponentDef[] = ALL_COMPONENTS.map((component, index) => {
const indexInStage = stageCounters[component.stage]++
const variant = pickVariant(component)
@@ -655,40 +727,55 @@ export function rotationPreviewDef(target: RotationTarget, rotation: Vec3): Comp
}
/**
- * Rejilla de los carteles de la identificación: 6 columnas × 3 filas (18 carteles,
- * uno por pieza, así que no sobra ninguna celda).
+ * Rejilla de los carteles de la identificación: 3 filas y las columnas que hagan
+ * falta, calculadas por `gridCols` (6 para 18 piezas, 7 para 19).
*
* La separación entre filas no es estética: en la fase 1 la colocación se resuelve
* por cercanía al centro del cartel con `snapRadius` 0,6, y el fallo se cuenta
* cuando un cartel AJENO está a `wrongRadius` (0,6) del punto donde se suelta
- * (`endDrag` de `useGameStore`). Con las filas a 0,95 u —lo que había— la banda
- * que cuenta el vecino empezaba a 0,35 u del centro, o sea **0,15 u más allá del
- * borde visible de la tarjeta** (1,0 × 0,4): soltar cerca del borde contaba como
- * fallo del cartel de al lado y restaba puntos. Ahora la fila mide 1,75 u, así que
- * las dos regiones ya no se solapan (1,75 ≥ 0,6 + 0,6): fallar de largo solo
- * significa que la pieza no se coloca, y el peor hueco en pantalla entre dos
- * tarjetas pasa de 23 px a 31 px.
+ * (`endDrag` de `useGameStore`). El fallo se cuenta **pasado** `snapRadius` —antes de
+ * eso la pieza coloca—, así que lo que decide es hasta dónde alcanza el vecino: con
+ * las filas a 0,95 u, lo que había, su banda llegaba a los **0,35 u** del centro,
+ * 0,15 u más allá del borde visible de la tarjeta (1,0 × 0,4), y en la práctica
+ * **cualquier tiro de más de 0,6 u caía dentro**: soltar cerca del borde contaba
+ * como fallo del cartel de al lado y restaba puntos. Con la fila a 1,75 u (y la
+ * columna a 1,5, que es el mínimo) el vecino no entra hasta los 0,9 u: los tiros
+ * entre 0,6 y 0,9 u solo dejan la pieza sin colocar, y pasado ese punto el vecino
+ * vuelve a contar. El peor hueco en pantalla entre dos tarjetas pasa de 23 px a
+ * 31 px.
*
* Todo medido con la proyección de la cámara (`tests/`, sonda de la fase 1): cuatro
* filas de 5 columnas no caben más separadas —con 1,25 la de atrás queda a 0,1 u
- * del borde de la mesa y con 1,3 se sale— y seis columnas dejan 55 px de hueco
- * horizontal con tres filas de 1,75.
+ * del borde de la mesa y con 1,3 se sale— y seis columnas dejan las tarjetas a
+ * 31 px en la misma fila (el peor hueco) y 55 px en la misma columna.
*/
-const PLATE_COLS = [-3.75, -2.25, -0.75, 0.75, 2.25, 3.75]
-const PLATE_Z0 = -0.95
-const PLATE_DZ = 1.75
-
-function plateSlot(index: number, rowCount: number): [number, number] {
- const col = Math.min(Math.floor(index / rowCount), PLATE_COLS.length - 1)
- const row = index % rowCount
- return [PLATE_COLS[col], PLATE_Z0 - row * PLATE_DZ]
+const CARD_ROWS = [-0.95, -2.7, -4.45]
+
+/**
+ * 1,5 u entre columnas es la separación mínima (con menos, el cartel de al lado
+ * entra en la banda de fallo) y la anchura disponible son 9 u (±4,5), que es donde
+ * caben las 7 columnas de una placa con más conectores. La forma que se elige es la
+ * que deja todo dentro de la mesa: repartir 19 carteles en una cuarta fila los saca
+ * de ella (la fila de atrás caía en z = −6,2 y la mesa acaba en −4,9).
+ */
+const CARD_GRID: TrayGrid = {
+ name: 'carteles de la fase 1',
+ pitch: 1.5,
+ width: 9,
+ minPitch: 1.5,
+}
+const CARD_COLS = gridCols(CARD_ROWS.length, IDENTIFY_CELLS, CARD_GRID)
+
+function plateSlot(index: number): [number, number] {
+ const rowCount = CARD_ROWS.length
+ const col = Math.min(Math.floor(index / rowCount), CARD_COLS.length - 1)
+ return [CARD_COLS[col], CARD_ROWS[index % rowCount]]
}
/** Carteles de la identificación: uno por pieza, con el nombre de su tipo. */
function identifyMounts(): MountPoint[] {
- const rowCount = Math.ceil(COMPONENTS.length / PLATE_COLS.length)
return COMPONENTS.map((def, index) => {
- const [x, z] = plateSlot(index, rowCount)
+ const [x, z] = plateSlot(index)
return {
id: `identify_${index}`,
stage: 'identify' as const,
@@ -744,9 +831,8 @@ export function shuffleLayout(): void {
// Los carteles se barajan aparte: si no, cada pieza caería sobre el suyo.
const plates = MOUNTS_BY_STAGE.identify
const plateOrder = shuffle(plates.length)
- const rowCount = Math.ceil(plates.length / PLATE_COLS.length)
plates.forEach((mount, i) => {
- const [x, z] = plateSlot(plateOrder[i], rowCount)
+ const [x, z] = plateSlot(plateOrder[i])
mount.position = [x, 0.09, z]
})
diff --git a/src/data/credits.ts b/src/data/credits.ts
index a7224ed..18eee28 100644
--- a/src/data/credits.ts
+++ b/src/data/credits.ts
@@ -160,4 +160,13 @@ export const CREDITS: Credit[] = [
{ file: 'connectors/displayport.png', what: 'Conector DisplayPort' },
{ file: 'connectors/dvi.png', what: 'Conector DVI' },
{ file: 'connectors/vga.png', what: 'Conector VGA' },
+ {
+ file: 'connectors/usb-c.png',
+ what: 'Conector USB-C',
+ author: 'Santeri Viinamäki',
+ source: 'https://commons.wikimedia.org/wiki/File:USB_Type-C_plug_20170626_crop.jpg',
+ license: 'CC BY-SA 4.0',
+ licenseUrl: 'https://creativecommons.org/licenses/by-sa/4.0/',
+ changes: 'Recortada, girada y con el fondo quitado (PNG con transparencia). Al ser CC BY-SA, el archivo sigue bajo la misma licencia.',
+ },
]
diff --git a/src/data/stages.ts b/src/data/stages.ts
index d6edf29..aaf9869 100644
--- a/src/data/stages.ts
+++ b/src/data/stages.ts
@@ -39,6 +39,13 @@ export const STAGES: Stage[] = [
// lo que ocupan (0,75). El desplazamiento aleatorio se deriva de estas
// separaciones (`trayJitter`), así que ensanchar la rejilla las separa.
//
+ // Las columnas de esta fase NO son las de aquí abajo: la fase 1 tiene una
+ // pieza por cada tipo de conector de la placa, así que su número cambia de
+ // una placa a otra (18 piezas con ocho tipos, 19 si la placa trae uno más) y
+ // `components.ts` las deriva del catálogo (`trayCols`). Estos 6 valores son
+ // los que salen para 18 piezas; con 19 la rejilla pasa a 7 columnas y las
+ // piezas siguen dentro de ±4. Las filas SÍ son estas.
+ //
// 6 columnas × 3 filas = 18 celdas para 18 piezas, con las filas a 2,1 u: dos
// piezas de la misma columna —que la cámara oblicua dibuja una detrás de
// otra— quedan a 1,4 u en profundidad (~110 px en pantalla) en vez de a 0,9.
diff --git a/src/three/ComponentModel.tsx b/src/three/ComponentModel.tsx
index e9a99b5..44775ac 100644
--- a/src/three/ComponentModel.tsx
+++ b/src/three/ComponentModel.tsx
@@ -10,6 +10,35 @@ import { Placeholder } from './Placeholder'
const IDENTITY: Vec3 = [0, 0, 0]
+/**
+ * Caja envolvente de un grupo **en su propio espacio**.
+ *
+ * `Box3.setFromObject` mide en coordenadas de MUNDO, así que incluye dónde está
+ * colocada la pieza: al centrar con ese resultado, la pieza acababa en el origen
+ * del mundo —el centro de la mesa— en vez de en su celda. Solo se notaba en las
+ * piezas procedurales (los cuatro conectores sin PNG ni .glb), que son las únicas
+ * que pasan por `Align` colocadas fuera del origen: los `.glb` se ajustan dentro
+ * de `GltfModel` y los conectores con imagen son un plano que no se alinea.
+ *
+ * Se mide multiplicando la caja de cada malla por su matriz relativa al grupo
+ * (mundo → grupo), que es lo que ignora a los padres.
+ */
+function localBox(group: THREE.Object3D): THREE.Box3 {
+ const box = new THREE.Box3()
+ const alGrupo = new THREE.Matrix4().copy(group.matrixWorld).invert()
+ const matriz = new THREE.Matrix4()
+ group.traverse((obj) => {
+ const mesh = obj as THREE.Mesh
+ if (!mesh.isMesh || !mesh.geometry) return
+ mesh.geometry.computeBoundingBox()
+ const bb = mesh.geometry.boundingBox
+ if (!bb) return
+ matriz.multiplyMatrices(alGrupo, mesh.matrixWorld)
+ box.union(bb.clone().applyMatrix4(matriz))
+ })
+ return box
+}
+
/**
* Gira un objeto ligero (placeholder) y lo reajusta: centrado en XZ y con la
* base en y=0. Los modelos .glb se ajustan dentro de GltfModel, que memoriza
@@ -22,8 +51,9 @@ function Align({ rotation, children }: { rotation: Vec3; children: ReactNode })
if (!group) return
group.rotation.set(...rotation)
group.position.set(0, 0, 0)
- group.updateMatrixWorld(true)
- const box = new THREE.Box3().setFromObject(group)
+ // Los padres primero: la caja local necesita la matriz de mundo del grupo.
+ group.updateWorldMatrix(true, true)
+ const box = localBox(group)
const center = box.getCenter(new THREE.Vector3())
group.position.set(-center.x, -box.min.y, -center.z)
group.updateMatrixWorld(true)
diff --git a/tests/run.mjs b/tests/run.mjs
index e2011cc..3f7191a 100644
--- a/tests/run.mjs
+++ b/tests/run.mjs
@@ -171,7 +171,15 @@ async function newPage(browser, url, seed) {
const warns = []
page.on('pageerror', (e) => errors.push(e.message))
page.on('console', (m) => {
- if (m.type() === 'error') errors.push('console: ' + m.text())
+ // El socket de HMR de Vite no es un error de la aplicación: si el servidor de
+ // desarrollo se queda sin aceptar una reconexión —le pasó una vez en una
+ // pasada larga— el cliente registra un `console.error` que no tiene nada que
+ // ver con el simulador, y la sección entera salía en rojo por eso (los checks
+ // funcionales habían pasado). Si de verdad se cae el servidor, las secciones
+ // fallan igual por sus propios checks, y `requireServer()` avisa antes.
+ if (m.type() === 'error' && !/WebSocket connection to 'ws:\/\/[^']+' failed/.test(m.text())) {
+ errors.push('console: ' + m.text())
+ }
if (m.type() === 'warn') warns.push(m.text())
})
// seed: siembra localStorage ANTES de que arranque la app (el store la lee
@@ -1228,7 +1236,7 @@ async function examNoHelp() {
}
/* ---------- 11. La mesa nunca apila dos piezas ---------- */
-async function traySeparation() {
+async function traySeparation(board = 'motherboard-01') {
// Dos piezas se solapan si su separación en LOS DOS ejes es menor que su
// huella. Ninguna pieza dibujada supera 0,75 u en ningún eje (los modelos se
// normalizan a `def.size` y luego se escalan para no pasar de 0,75; los cables
@@ -1237,11 +1245,11 @@ async function traySeparation() {
// siempre: antes era ±0,4 fijo y dos vecinas podían quedar a 0,1 u.
const SPAN = 0.75
const RUNS = 200
- const { page, errors } = await newPage(browser, `${BASE_URL}/?board=motherboard-01`)
+ const { page, errors } = await newPage(browser, `${BASE_URL}/?board=${board}`)
const menu = await page
.waitForSelector('button.mode-card', { timeout: 60000 })
.catch(() => null)
- check('mesa: menú de modos visible (sonda de datos)', !!menu)
+ check(`mesa (${board}): menú de modos visible (sonda de datos)`, !!menu)
if (!menu) {
await page.close()
return
@@ -1298,7 +1306,7 @@ async function traySeparation() {
for (const stage of ['identify', 'board', 'ports', 'peripherals']) {
const r = result[stage]
check(
- `mesa: ninguna pieza se solapa en «${stage}» (${RUNS} barajados)`,
+ `mesa (${board}): ninguna pieza se solapa en «${stage}» (${RUNS} barajados)`,
r.solapados === 0 && r.peor >= SPAN,
`${r.piezas} piezas, separación mínima ${r.peor} u (mínimo ${SPAN}) y ${r.solapados} pares por debajo`,
)
@@ -1307,7 +1315,7 @@ async function traySeparation() {
// ninguna debe esconderse detrás de otra en la misma columna visual.
const id = result.identify
check(
- 'mesa: en la fase 1 ninguna pieza se esconde en la misma columna visual',
+ `mesa (${board}): en la fase 1 ninguna pieza se esconde en la misma columna visual`,
id.columnaCorta === 0 && id.peorColumna >= 1.3,
`misma columna: separación mínima en profundidad ${id.peorColumna} u (mínimo 1.3) y ${id.columnaCorta} pares por debajo`,
)
@@ -1317,12 +1325,12 @@ async function traySeparation() {
// rejilla de esa fase siga dejando más aire del mínimo (0,9 en vez de 0,75),
// que es la regresión que provocó el solape cuando la rejilla se estrechó.
check(
- 'mesa: la identificación mantiene su suelo de diseño (0,9 u)',
+ `mesa (${board}): la identificación mantiene su suelo de diseño (0,9 u)`,
result.identify.peor >= 0.9,
`separación mínima ${result.identify.peor} u (suelo de diseño 0,9; mínimo duro 0,75)`,
)
- check('mesa: sin errores', errors.length === 0, errors.join(' | '))
+ check(`mesa (${board}): sin errores`, errors.length === 0, errors.join(' | '))
await page.close()
}
@@ -2154,8 +2162,11 @@ async function stageBanner() {
.catch(() => null)
await menuCards[0].click() // práctica
- await page.waitForSelector('.topbar', { timeout: 60000 })
- await wait(700)
+ // El cartel se quita solo a los 3,6 s, así que se ESPERA a que salga en vez de
+ // mirar a un tiempo fijo: con la máquina cargada (la pasada entera son 18 minutos
+ // de Chromium con render por software) se llegaba tarde a la lectura y el cartel
+ // ya no estaba —falso negativo medido en una pasada completa—.
+ await page.waitForSelector('.stage-banner', { timeout: 60000 })
const esperado = await page.evaluate(async () => {
const m = await import('/src/data/stages.ts')
return m.STAGES.map((s) => ({ id: s.id, titulo: s.title, texto: s.hint, corto: s.short }))
@@ -2585,13 +2596,13 @@ async function examIdentifyHint() {
* observable en examen —soltar 0,7 u hacia el cartel vecino más próximo— que es lo
* que hacía perder puntos.
*/
-async function identifyCards() {
- const { page, errors } = await newPage(browser, `${BASE_URL}/?board=motherboard-01`)
+async function identifyCards(board = 'motherboard-01') {
+ const { page, errors } = await newPage(browser, `${BASE_URL}/?board=${board}`)
const cartas = await page
.waitForSelector('button.mode-card', { timeout: 60000 })
.then(() => page.$$('button.mode-card'))
.catch(() => [])
- check('carteles: menú de modos visible', cartas.length >= 2, `${cartas.length}`)
+ check(`carteles (${board}): menú de modos visible`, cartas.length >= 2, `${cartas.length}`)
if (cartas.length < 2) {
await page.close()
return
@@ -2621,6 +2632,7 @@ async function identifyCards() {
piezas: c.COMPONENTS_BY_STAGE.identify.length,
snap: Math.max(...mounts.map((m) => m.snapRadius)),
wrong: stage.drop.wrongRadius,
+ cardHalf: Math.max(w, d) / 2,
minPlano,
minX,
minZ,
@@ -2636,20 +2648,42 @@ async function identifyCards() {
})
check(
- 'carteles: hay uno por cada pieza',
+ `carteles (${board}): hay uno por cada pieza`,
reparto.cuantos === reparto.piezas,
`${reparto.cuantos} carteles para ${reparto.piezas} piezas`,
)
check(
- 'carteles: ningún cartel ajeno cabe en la banda de fallo del suyo',
+ `carteles (${board}): ningún cartel ajeno cabe en la banda de fallo del suyo`,
reparto.minPlano >= reparto.snap + reparto.wrong,
`separación mínima ${reparto.minPlano.toFixed(2)} u (X ${reparto.minX.toFixed(2)}, Z ${reparto.minZ.toFixed(2)}) frente a snapRadius + wrongRadius = ${(reparto.snap + reparto.wrong).toFixed(2)} u`,
)
check(
- 'carteles: la fila de atrás cabe en la mesa',
+ `carteles (${board}): la fila de atrás cabe en la mesa`,
reparto.bordeTrasero >= reparto.mesaTrasera,
`borde trasero ${reparto.bordeTrasero.toFixed(2)} u, la mesa llega a ${reparto.mesaTrasera.toFixed(2)} u`,
)
+ // El requisito de la persona usuaria: soltar SOBRE la tarjeta tiene que colocar
+ // siempre. Basta con que medio lado de la tarjeta quepa en `snapRadius`.
+ check(
+ `carteles (${board}): soltar sobre la propia tarjeta siempre coloca`,
+ reparto.cardHalf < reparto.snap,
+ `medio lado ${reparto.cardHalf.toFixed(2)} u frente a snapRadius ${reparto.snap.toFixed(2)} u`,
+ )
+ // El fallo se cuenta PASADO `snapRadius` (antes coloca), así que lo que decide es
+ // hasta dónde llega el alcance del vecino: si `snapRadius` y ese alcance se
+ // solapan, no queda ningún tiro largo sin coste.
+ check(
+ `carteles (${board}): el vecino solo cuenta pasado snapRadius`,
+ reparto.minPlano - reparto.wrong > reparto.snap,
+ `el vecino entra en su banda a ${(reparto.minPlano - reparto.wrong).toFixed(2)} u del centro, más allá de snapRadius ${reparto.snap.toFixed(2)} u`,
+ )
+ // El tiro que hace esta sección (0,7 u hacia el vecino más próximo) no puede
+ // alcanzar su banda: deja la pieza sin colocar y no cuesta nada.
+ check(
+ `carteles (${board}): el tiro de 0,7 u hacia el vecino no llega a su banda`,
+ reparto.minPlano >= reparto.snap + 0.7,
+ `separación mínima ${reparto.minPlano.toFixed(2)} u frente a ${(reparto.snap + 0.7).toFixed(2)} u`,
+ )
// Hueco en pantalla entre los rectángulos de dos tarjetas vecinas: es lo que se
// ve («están muy pegadas»). El umbral es el medido con este reparto (31 px); con
@@ -2670,22 +2704,22 @@ async function identifyCards() {
}
}
check(
- 'carteles: dos tarjetas vecinas no se pegan en pantalla',
+ `carteles (${board}): dos tarjetas vecinas no se pegan en pantalla`,
hueco >= 28,
`${Math.round(hueco)} px de hueco entre las dos más próximas; hacen falta 28`,
)
- check('carteles: el reparto se mide sin errores de página', errors.length === 0, errors.join(' | '))
+ check(`carteles (${board}): el reparto se mide sin errores de página`, errors.length === 0, errors.join(' | '))
await page.close()
// El efecto que reportó el alumnado, en examen y con el ratón: se elige la pieza
// cuyo cartel tiene al vecino MÁS próximo (con el reparto anterior son las filas a
// 0,95 u) y se suelta 0,7 u hacia él, que es media tarjeta más allá de su borde.
- const examen = await newPage(browser, `${BASE_URL}/?board=motherboard-01`)
+ const examen = await newPage(browser, `${BASE_URL}/?board=${board}`)
const cartasExamen = await examen.page
.waitForSelector('button.mode-card', { timeout: 60000 })
.then(() => examen.page.$$('button.mode-card'))
.catch(() => [])
- check('carteles: el examen arranca', cartasExamen.length >= 2, `${cartasExamen.length} tarjetas`)
+ check(`carteles (${board}): el examen arranca`, cartasExamen.length >= 2, `${cartasExamen.length} tarjetas`)
if (cartasExamen.length < 2) {
await examen.page.close()
return
@@ -2727,7 +2761,7 @@ async function identifyCards() {
const [lejos] = await proyectaFase1(examen.page, [[cerca.x, objetivo.cartel[1], cerca.z]])
const [encima] = await proyectaFase1(examen.page, [objetivo.cartel])
check(
- 'carteles: la pieza, su cartel y el punto vecino se localizan en pantalla',
+ `carteles (${board}): la pieza, su cartel y el punto vecino se localizan en pantalla`,
!!pieza && !!lejos && !!encima,
`${objetivo.name}: pieza ${JSON.stringify(pieza)}, a 0,7 u ${JSON.stringify(lejos)} (vecino a ${objetivo.distancia.toFixed(2)} u)`,
)
@@ -2784,22 +2818,22 @@ async function identifyCards() {
const despues = await juego()
const puntosDespues = await puntos()
check(
- 'carteles: la pieza se coge y se suelta a 0,7 u de su cartel',
+ `carteles (${board}): la pieza se coge y se suelta a 0,7 u de su cartel`,
soltado,
`${objetivo.name}`,
)
check(
- 'carteles: soltar pasado el borde no cuenta el cartel de al lado',
+ `carteles (${board}): soltar pasado el borde no cuenta el cartel de al lado`,
despues.errors === antes.errors && despues.filas === antes.filas,
`fallos ${antes.errors} → ${despues.errors}, filas del resumen ${antes.filas} → ${despues.filas}`,
)
check(
- 'carteles: soltar pasado el borde no resta puntos',
+ `carteles (${board}): soltar pasado el borde no resta puntos`,
puntosDespues === puntosAntes,
`${puntosAntes} → ${puntosDespues}`,
)
check(
- 'carteles: la pieza no se coloca en el cartel de al lado',
+ `carteles (${board}): la pieza no se coloca en el cartel de al lado`,
despues.colocadas === antes.colocadas,
`${antes.colocadas} → ${despues.colocadas} colocadas`,
)
@@ -2810,23 +2844,140 @@ async function identifyCards() {
const colocado = await arrastra(encima)
const final = await juego()
check(
- 'carteles: sobre su propio cartel la pieza se coloca',
+ `carteles (${board}): sobre su propio cartel la pieza se coloca`,
colocado && final.colocadas === despues.colocadas + 1,
`${despues.colocadas} → ${final.colocadas} colocadas`,
)
check(
- 'carteles: colocar bien no cuesta ningún fallo',
+ `carteles (${board}): colocar bien no cuesta ningún fallo`,
final.errors === despues.errors,
`${despues.errors} → ${final.errors}`,
)
- check('carteles: sin errores de página', examen.errors.length === 0, examen.errors.join(' | '))
+ check(`carteles (${board}): sin errores de página`, examen.errors.length === 0, examen.errors.join(' | '))
await examen.page.close()
}
+/* ---------- 20. Cada pieza de la fase 1 se dibuja en la celda que dice su dato ---------- */
+/**
+ * El fallback procedural (`Placeholder`) alineaba la pieza midiendo su caja
+ * envolvente en coordenadas de MUNDO (`Box3.setFromObject`), así que el centrado
+ * incluía la propia colocación de la pieza en la mesa y la dejaba en el origen del
+ * mundo: el centro de la mesa. Solo se notaba en las piezas que dibuja el código
+ * (los tres jacks y el USB-C, que no tienen ni PNG ni .glb): su celda quedaba vacía
+ * y, al arrastrarlas, el fantasma salía en el centro de la mesa en vez de bajo el
+ * cursor. Ninguna prueba las cogía con el ratón, así que pasaba desapercibido.
+ *
+ * La comprobación no mira el código: pregunta al lienzo por la geometría. Con
+ * `pointermove` sintéticos sobre el canvas —que es exactamente el evento que R3F
+ * escucha, y que no pasa por los paneles de la interfaz porque son DOM por encima—
+ * comprueba que debajo del punto donde la pieza DEBERÍA estar hay algo que responde
+ * al ratón (el cursor se pone en `grab`). Un `page.mouse.move` por punto costaría
+ * minutos; así se barren todas las piezas en un par de segundos.
+ */
+async function identifyDrawn(board = 'motherboard-01') {
+ const { page, errors } = await newPage(browser, `${BASE_URL}/?board=${board}`)
+ const cartas = await page
+ .waitForSelector('button.mode-card', { timeout: 60000 })
+ .then(() => page.$$('button.mode-card'))
+ .catch(() => [])
+ check(`dibujo (${board}): menú de modos visible`, cartas.length >= 2, `${cartas.length}`)
+ if (cartas.length < 2) {
+ await page.close()
+ return
+ }
+ await cartas[0].click()
+ // Las piezas con .glb tardan: se espera a que la barra de carga se vaya (si no
+ // aparece, porque ya estaba todo en caché, sigue de largo).
+ await page
+ .waitForFunction(() => !document.querySelector('.loadingbar'), { timeout: 120000 })
+ .catch(() => null)
+ await wait(800)
+
+ const datos = await page.evaluate(async () => {
+ const c = await import('/src/data/components.ts')
+ const st = await import('/src/data/stages.ts')
+ const assets = await import('/src/data/assets.ts')
+ const stage = st.STAGES[0]
+ return {
+ total: c.COMPONENTS.length,
+ // Las que dibuja el código (sin PNG y sin .glb): son las que destaparon el
+ // defecto, así que se nombran en el detalle.
+ procedurales: c.COMPONENTS.filter(
+ (d) => !assets.connectorImageUrl(d.kind) && !assets.modelUrl(d),
+ ).map((d) => d.id),
+ mundos: c.COMPONENTS.map((d) => [
+ d.identifyPos[0],
+ stage.tray.surfaceY + 0.05,
+ d.identifyPos[1],
+ ]),
+ nombres: c.COMPONENTS.map((d) => d.id),
+ }
+ })
+ const pantallas = await proyectaFase1(page, datos.mundos)
+
+ const sinDibujar = await page.evaluate(
+ async (puntos) => {
+ const canvas = document.querySelector('canvas')
+ const rect = canvas.getBoundingClientRect()
+ const hayPieza = (x, y) => {
+ canvas.dispatchEvent(
+ new PointerEvent('pointermove', {
+ clientX: rect.left + x,
+ clientY: rect.top + y,
+ bubbles: true,
+ pointerId: 1,
+ pointerType: 'mouse',
+ isPrimary: true,
+ buttons: 0,
+ }),
+ )
+ return document.body.style.cursor === 'grab'
+ }
+ // Nueve muestras alrededor del centro de la celda: el punto exacto puede caer
+ // en un hueco de la geometría (el latiguillo de un jack, el asa del monitor).
+ const offsets = [
+ [0, 0], [12, 0], [-12, 0], [0, 12], [0, -12],
+ [12, 12], [-12, -12], [12, -12], [-12, 12],
+ ]
+ const fuera = []
+ for (const p of puntos) {
+ const hay = offsets.some(([dx, dy]) => hayPieza(p.x + dx, p.y + dy))
+ if (!hay) fuera.push(p.id)
+ }
+ return fuera
+ },
+ pantallas.map((p, i) => ({ id: datos.nombres[i], x: p.x, y: p.y })),
+ )
+
+ check(
+ `dibujo (${board}): las ${datos.total} piezas de la fase 1 están dibujadas en su celda`,
+ sinDibujar.length === 0,
+ sinDibujar.length
+ ? `sin geometría en su celda: ${sinDibujar.join(', ')}`
+ : `${datos.procedurales.length} de ellas procedurales (${datos.procedurales.join(', ')})`,
+ )
+ check(`dibujo (${board}): sin errores de página`, errors.length === 0, errors.join(' | '))
+ await page.close()
+}
+
/**
* Secciones de la pasada, en orden. `ONLY=examSummary,loadingBar` acota la
* ejecución a las nombradas (para falsar un cambio sin pagar la pasada entera).
*/
+/**
+ * Las dos formas que tiene hoy la fase 1: 18 piezas (una placa de ocho tipos de
+ * conector) y 19 (una más). La rejilla de esa fase se deriva del número de piezas
+ * —tanto la de las piezas como la de los carteles—, así que sus dos secciones se
+ * miden en las dos placas: con 19 piezas y la rejilla fija de 6 columnas, una fila
+ * entera de carteles caía fuera de la mesa y la pieza 19 pisaba la celda de la 15.
+ * La 02 tiene los mismos tipos que la 01, así que no aporta un reparto distinto.
+ */
+const PLACAS_FASE1 = ['motherboard-01', 'motherboard-03']
+
+async function enLasDosPlacas(seccion) {
+ for (const board of PLACAS_FASE1) await seccion(board)
+}
+
const SECTIONS = [
['fullGame', fullGame],
['examGiveUpGuard', examGiveUpGuard],
@@ -2837,7 +2988,7 @@ const SECTIONS = [
['dragGhostVisible', dragGhostVisible],
['sidePanels', sidePanels],
['examNoHelp', examNoHelp],
- ['traySeparation', traySeparation],
+ ['traySeparation', () => enLasDosPlacas(traySeparation)],
['examDrag', examDrag],
['examSummary', examSummary],
['touchDrag', touchDrag],
@@ -2845,7 +2996,8 @@ const SECTIONS = [
['stageBanner', stageBanner],
['componentZoom', componentZoom],
['examIdentifyHint', examIdentifyHint],
- ['identifyCards', identifyCards],
+ ['identifyCards', () => enLasDosPlacas(identifyCards)],
+ ['identifyDrawn', () => enLasDosPlacas(identifyDrawn)],
]
const ONLY = process.env.ONLY