> For the complete documentation index, see [llms.txt](https://claude.fractalgamestudios.es/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://claude.fractalgamestudios.es/bolas/game-info/arquitectura-arma-imperial-y-afk-parte-2.md).

# Arquitectura, arma Imperial y AFK (parte 2)

### 2026-08-02 (Sword\_Collision desalineada tras el fix de orientación; migración de las interfaces "a mano" (ScreenGui) a React-Roblox con Views/Controladores)

Dos correcciones pedidas: la caja de colisión de la Sword Imperial seguía sin coincidir con la orientación ya corregida de la espada, y las interfaces que había construido en sesiones anteriores (HUD de combate, panel de unión a partidas) usaban `ScreenGui`/`Instance.new` a mano en vez de React-Roblox con la estructura de Views/Controladores/Router ya montada en el proyecto (`Front.Modules` + `Front.Controllers` + `Front.Router`, con `BallsConfig` como referencia) — corregido por instrucción explícita del usuario.

#### 1. Sword\_Collision no giraba con la espada

`Sword_Collision` nunca se tocó durante el arreglo de orientación de la sesión anterior — seguía con su CFrame original, pensado para la pose VERTICAL rota de antes. Arreglado aplicando a la caja de colisión el mismo delta rígido (rotación + traslación) que se le aplicó a la malla visual para llevarla de su pose original a la corregida, así ambas se mueven como un solo cuerpo. Validado en playtest volviendo visible temporalmente la copia equipada de la colisión (normalmente `Transparency = 1`): la caja verde queda alineada a lo largo de toda la hoja, desde la empuñadura hasta la punta, en vez de fuera de sitio.

#### 2. Migración de ScreenGui "a mano" a React-Roblox

Investigado primero cómo funciona de verdad el sistema existente (`Front.App` monta un único `ScreenGui` con `ReactRoblox.createRoot`; `Front.Router.routes` registra cada vista con su `layer`/`viewType`; las vistas `isSticky = true` se montan siempre, desde el arranque; `Front.Router.RemoteRouteBridge` es el puente ya existente entre LocalScripts fuera de React y el router). Migrados los dos sistemas que construí a mano en sesiones anteriores:

* **HUD de combate**: `StarterPlayer.StarterPlayerScripts.CombatHUD` (LocalScript, ahora deshabilitado) → `Front.Controllers.CombatController` (única fuente de verdad: escanea `Workspace` por bolas, escucha sus atributos y el remote `Arena/SetCamera`, expone `onChanged`) + `Front.Shared.Hooks.useCombat` + `Front.Modules.CombatHUD.CombatHUD` (componente, pura presentación), registrado como vista `sticky` en el router.
* **Panel de unión a partidas**: los dos `ScreenGui` de `StarterPlayer.StarterPlayerScripts.LobbyVisuals` (panel de unión + píldora de cola/avisos) → `Front.Controllers.LobbyQueueController` (detección de proximidad por `Heartbeat` sobre `Workspace.Lobbies`, estado de cola, remotes `Lobby/JoinQueue`+`LeaveQueue`+`QueueUpdate`+`JoinQueueDenied`+`MatchStart`+`Arena/SetCamera`) + `Front.Shared.Hooks.useLobbyQueue` + `Front.Modules.LobbyJoinPanel.LobbyJoinPanel`, también `sticky`. `LobbyVisuals.lua` se dejó recortado a solo los carteles 3D (`BillboardGui`) flotantes sobre cada hitbox — eso es señalización anclada al mundo, no una interfaz 2D de pantalla, así que se quedó tal cual.

Ambas vistas nuevas siguen el mismo patrón exacto que `BallsConfig`/`HUD` (Controller como fuente de verdad → Hook de React → Componente de presentación pura), registradas en `routes.ROUTES` y `RouterView.ROUTE_COMPONENTS` igual que las vistas existentes.

**Nota de estilo encontrada de paso**: `Theme.Colors` (el módulo compartido de estilos) no define `PrimaryDark`/`Surface`/`Primary`/`White`, pese a que `HUD.luau`/`MoneyDisplay.luau` ya los usan — un bug preexistente (React-Lua ignora silenciosamente una prop en `nil` en vez de fallar, así que nunca se notó). No se tocó por no ser parte de este pedido, pero los componentes nuevos usan solo colores que sí existen en `Theme.Colors` para no heredar el mismo problema.

#### Validación en playtest (flujo completo, no solo revisión de código)

* **CombatHUD**: partida 1v1 real iniciada — captura de pantalla confirma ambas filas ("★ ByBlackDead" y el rival) con vida, arma y estadísticas específicas correctas; tras terminar la partida, el panel desaparece solo (vuelve a `nil`) y el jugador reaparece en el Lobby.
* **LobbyJoinPanel**: de pie junto al hitbox "1v1", el `JoinCard` aparece junto con el cartel 3D de siempre (ambos sistemas funcionando a la vez, sin interferirse); **clic real** (no simulado por código, via `simulate_mouse_input` sobre las coordenadas reales del botón) en "LOCAL" → la píldora de cola aparece ("1v1 · Local · 5s") y el cartel 3D pasa a "STARTING IN: 6" en paralelo; al arrancar la partida, la píldora desaparece y el `CombatHUD` aparece en su lugar, sin huecos ni solapes.
* Sin errores nuevos en `get_runtime_logs` en ningún punto (el único error visto, `MemoryStoreService: InternalError` en `GlobalQueue_BattleRoyale`, es un problema de infraestructura del backend de matchmaking global preexistente, no relacionado con este cambio).

**Archivos nuevos:** `Front.Controllers.CombatController`, `Front.Shared.Hooks.useCombat`, `Front.Modules.CombatHUD.CombatHUD`, `Front.Controllers.LobbyQueueController`, `Front.Shared.Hooks.useLobbyQueue`, `Front.Modules.LobbyJoinPanel.LobbyJoinPanel`. **Archivos tocados:** `Front.Router.routes` (2 rutas sticky nuevas), `Front.Router.RouterView` (registro de componentes), `ServerStorage.Models.Skins.Weapons.Sword.ImperialWeapon_A`/`_B` (`Sword_Collision`). **Archivos recortados/deshabilitados:** `StarterPlayer.StarterPlayerScripts.CombatHUD` (deshabilitado), `StarterPlayer.StarterPlayerScripts.LobbyVisuals` (recortado a solo los carteles 3D).

***

### 2026-08-02 (Sword Imperial horizontal pero al revés: el filo miraba a la bola en vez del mango)

Corrección directa al fix anterior: la Sword Imperial ya salía horizontal, pero con la orientación invertida — el filo (parte ancha/afilada) tocando la bola y el mango+pomo asomando hacia fuera, en vez de al revés.

#### Por qué costó encontrar el flip correcto

Girar el mesh 180° en cualquiera de sus tres ejes LOCALES (X, Y o Z, en el propio espacio de coordenadas del mesh o incluso ya multiplicado sobre la pose relativa a `WeaponAttach`) rompía por completo la horizontalidad y devolvía la espada a la posición vertical del bug original — nada intuitivo, porque la pose correcta (heredada del `Default`, con Euler ≈ `(-90°, 1°, -180°)`) es una rotación compuesta donde ningún eje local se corresponde limpiamente con "el eje vertical real du mundo". La solución fue construir explícitamente una rotación de 180° alrededor del eje VERTICAL DEL MUNDO, pivotando sobre la posición propia del arma ya equipada (`CFrame.new(pos) * CFrame.Angles(0, math.rad(180), 0) * (CFrame.new(-pos) * poseActual)`) — así sí se preserva "tumbada, horizontal" y solo se invierte qué extremo mira hacia la bola.

Validado visualmente en playtest con capturas de cerca, cambiando de ángulo de cámara hasta poder distinguir con claridad filo/guarda/mango: antes del flip, filo (dorado/ancho) pegado a la bola y mango+pomo (gris/rojo) apuntando hacia fuera; después del flip, la guarda queda justo en la superficie de la bola (el mango, lógicamente, queda oculto dentro) y el filo se extiende hacia fuera — exactamente como en `Default`. Confirmado en ambas variantes (A y B).

**Archivo tocado:** `ServerStorage.Models.Skins.Weapons.Sword.ImperialWeapon_A` / `_B` (CFrame de la malla `weapon_sword_001_epic`, `PrimaryPart` sin cambios respecto al fix anterior).

***

### 2026-08-02 (gravedad demasiado brusca tras el fix anterior, no se podía volver a la skin de arma Default tras elegir una temporal, y la Sword Imperial salía en vertical en vez de horizontal)

Tres bugs de seguimiento a los arreglos de la sesión anterior:

#### 1. Gravedad demasiado brusca — bolas atascadas contra la pared "Bottom"

El fix anterior (atraer hacia la pared "Bottom" real en vez de un "abajo" fijo del mundo) usaba `GravityScale=0.25 + GravityBias=0.5` = 75% de la gravedad del mundo, CONSTANTE (no decae con la distancia) empujando siempre hacia esa pared. Al ser mas fuerte que la fuerza de movimiento normal de la bola, una vez llegaba a la pared quedaba aplastada contra ella sin poder despegarse — "se bugean en la parte inferior". Reducido a `GravityScale=0.05 + GravityBias=0.03` (8% total, \~1/9 de lo anterior) y alargada la rampa de entrada (`GravityRamp` 0.35s → 0.6s) para que el empuje entre progresivo, no de golpe. Validado en una partida 1v1 real: posiciones de ambas bolas muestreadas cada 2s durante 12s — desplazamientos normales de decenas de studs entre muestras, sin ninguna quedando fija/pegada a una pared.

#### 2. No se podía volver a la skin de arma Default tras elegir una temporal (Robux gratis)

Bug real en el sistema de acceso gratuito temporal (`RobuxFreeAccess`, de dos sesiones atrás): `RobuxFreeAccess.SetSelected` guarda la seleccion de un articulo temporal en `overlay.selected[slot]`, y `GetEffectiveBallInfo` la fusiona SIEMPRE por encima de la seleccion real del perfil — pero nada la borraba nunca. Resultado: una vez elegida una vez una skin de arma gratuita de Studio (p.ej. Imperial), esa elección quedaba fusionada PARA SIEMPRE por encima de cualquier seleccion real posterior para ese mismo slot — intentar volver a "Default" (una skin realmente desbloqueada) se guardaba bien en el perfil, pero el overlay seguia imponiendo la vieja seleccion temporal. Arreglado con `RobuxFreeAccess.ClearSelected(player, ballId, slot)`, llamado desde `BallsController.selectSkin` cada vez que se persiste una seleccion REAL (no via overlay) para ese slot. Validado en playtest: tras elegir Imperial y luego Default, el snapshot del cliente y el mesh 3D equipado confirman `DefaultWeapon`/`SM_Sword` correctamente.

#### 3. Sword Imperial en vertical en vez de horizontal

Investigado por que: el modelo `ImperialWeapon_A`/`_B` tiene una part llamada literalmente "PrimaryPart" (para servir de referencia de pivote/agarre), pero la propiedad REAL `Model.PrimaryPart` nunca se había asignado a esa part — tener una part con ESE NOMBRE no la convierte automáticamente en el `PrimaryPart` del modelo. Sin esa propiedad puesta, `Model:PivotTo()` (usado por `WeaponSystem.createWeapon` al montar el arma) recurre a un pivote automático basado en la caja delimitadora del modelo, impredecible y no relacionado con la orientación real de la malla — de ahí la espada saliendo en un ángulo aleatorio (vertical) en vez de horizontal como Default.

Arreglo en dos partes:

1. Asignada de verdad la propiedad `Model.PrimaryPart` a esa part de referencia en `ImperialWeapon_A` y `_B`.
2. Recalculada la orientación de la malla (`weapon_sword_001_epic`) para que, una vez equipada, quede en la MISMA posición/rotación relativa a `WeaponAttach` que ya usa `DefaultWeapon_A`'s `SM_Sword` — calculado y validado empíricamente en playtest (capturando la transformación real aplicada al equipar Default, y resolviendo la malla de Imperial para reproducir exactamente esa misma pose), no adivinado a ciegas por ensayo-error con ángulos random.

Validado en playtest con capturas de pantalla: variante A y B de Imperial ahora salen horizontales, en la misma pose que Default (antes: la espada apuntaba recta hacia arriba, separada de la bola).

**Archivos tocados:** `ServerStorage.Classes.Arena.ArenaConfig` (gravedad), `ServerStorage.Managers.RobuxFreeAccess` (`ClearSelected`), `ServerScriptService.Controllers.BallsController` (llamada a `ClearSelected`), `ServerStorage.Models.Skins.Weapons.Sword.ImperialWeapon_A` / `_B` (`PrimaryPart` + CFrame de la malla).

***

### 2026-08-02 (gravedad de partida usando siempre "abajo" del mundo en vez de la pared "Bottom" del estadio; cambiar de skin de arma no se veía sin reabrir la terminal)

Nuevo `/goal`, dos bugs sin relación entre sí:

1. El sistema de gravedad de las partidas (pensado para que la bola "caiga" y no se quede fija) debía atraer SOLO hacia la pared del estadio con la tag `"Bottom"`, no hacia un "abajo" fijo del mundo.
2. En la terminal de customización, cambiar de ARMA (skin/variante dentro del slot Arma) no se reflejaba en la bola hasta cerrar la terminal y volver a abrirla.

#### 1. Gravedad: dirección fija en vez de la pared "Bottom" real de cada estadio

`ArenaConfig.GravityPull.Direction` está a `Vector3.new(0,0,0)` (nunca se llegó a configurar de verdad), y `BallFactory` caía siempre a su fallback `(0,-1,0)` — "abajo" del MUNDO, sin tener en cuenta hacia dónde está orientado realmente el estadio de esa partida. Si ese "abajo" del mundo no coincide con el suelo real del estadio, la bola queda empujada contra la pared equivocada y se desliza en línea recta (atascada) en vez de caer hacia el centro — exactamente el síntoma descrito.

Lo interesante: esto YA estaba resuelto correctamente en `ArenaController_old` (script deprecado, deshabilitado, nunca migrado al sistema activo `ArenaManager`): busca la pared con la tag "Bottom" entre las paredes de la arena y usa su `CFrame.LookVector` como dirección de gravedad. Confirmado que las 4 arenas (`1v1`, `3v3`, `BattleRoyale`, `BossFight`) ya tienen esa tag puesta en Studio (`Wall`, y `FakeWall` en las que tienen dos). Portada esa misma lógica a `ArenaManager.StartMatch`: se calcula UNA vez por partida (justo tras crear la arena) y se inyecta en `ballConfig.GravityPull.Direction` de cada bola — con fallback al valor de config si por lo que sea la arena no tuviera ninguna pared con esa tag. Como beneficio colateral, esto también arregla la dirección del (actualmente deshabilitado) sistema de rebote `BottomWallBounce`, que lee del mismo campo.

**Validado en playtest**: partida 1v1 real iniciada, inspeccionado el `VectorForce "BiasedGravity"` de la bola — su `Force` apunta en la misma dirección exacta que `Workspace["1v1"]["1v1"].Wall.CFrame.LookVector` (ambos en -Z), no en el antiguo `(0,-1,0)`. Sin errores.

#### 2. Cambiar de skin de arma no se veía hasta reabrir la terminal

`Customization.SetWeapon` (el que se llama desde `SetupSkin` al aplicar la selección) SOLO recolorea el modelo de arma que YA esté montado bajo `WeaponAttach` — nunca lo sustituye. El que de verdad crea/sustituye el modelo 3D es `Customization.EquipWeaponModel` (que internamente llama a `WeaponSystem.createWeapon`, el cual sí destruye el modelo viejo y monta el nuevo). El problema: `EquipWeaponModel` solo se llamaba al ABRIR la terminal (`CustomizationController.ShowYourBall`) — el flujo de "seleccionar una skin con la terminal ya abierta" (`BallsController.selectSkin` → `Customization.SetupSkin`) nunca lo volvía a llamar. La elección se guardaba bien en el perfil (por eso al cerrar y reabrir SÍ se veía — `ShowYourBall` sí llama a `EquipWeaponModel` de nuevo), pero la bola de preview seguía enseñando el modelo viejo mientras tanto.

Arreglado añadiendo la llamada a `EquipWeaponModel` dentro de `Customization.SetupSkin` mismo (antes de `SetWeapon`, mismo orden que ya usa `ShowYourBall` — recolorear un modelo que aún no existe no hace nada), así cualquier llamador de `SetupSkin` queda con el modelo correcto sin tener que acordarse de llamar a `EquipWeaponModel` por separado. `WeaponSystem.createWeapon` ya destruye cualquier `WeaponModel` anterior antes de crear uno nuevo, así que llamarlo de más (p. ej. una vez extra al spawnear una bola de partida real, donde `BallFactory` ya lo llama por separado para bots y jugadores) es inofensivo, solo un pelín redundante en ese caso puntual — aceptable a cambio de que la terminal ya no pueda olvidarse de refrescar el modelo.

**Validado en playtest**: bola de preview abierta con `DefaultWeapon` equipado (mesh `SM_Sword`, `rbxassetid://82471123192596`), cambiada la skin de Arma a `ImperialWeapon` vía `Balls/SelectSkin` SIN cerrar la terminal → el mesh de la bola de preview cambió inmediatamente a `weapon_sword_001_epic` (`rbxassetid://120457214364070`, el mesh real de ImperialWeapon). Sin errores.

**Archivos tocados:** `ServerScriptService.Managers.ArenaManager` (`StartMatch`), `ServerStorage.Classes.BallSystem.Customization` (`SetupSkin`).

***

### 2026-08-02 (muerte simultánea del último jugador: el ganador se perdía y quedaba "nadie gana")

Nuevo `/goal`: si el último jugador en pie de una partida muere en el mismo instante en que gana (p.ej. veneno/flecha con daño retardado que remata justo cuando el rival también remata), la partida debía declarar ganador igualmente a quien acaba de morir, en vez de quedarse sin ganador.

#### Causa raíz

`checkWinCondition` (en `ArenaManager.lua`), al detectar `#alivePlayers <= 1`, hacía una pausa dramática (`task.wait(2)`) **antes** de leer `alivePlayers[1]` para pasárselo a `EndRound`. Esa pausa es una cesión real (yield): si en esos 2 segundos una SEGUNDA bola también muere (perfectamente posible con efectos de daño retardado, o simplemente dos golpes letales casi simultáneos), su propio `onPlayerDied` se ejecuta de forma intercalada, vacía `alivePlayers` del todo y arranca su propia pausa de 2s. Cuando la PRIMERA llamada retoma tras su pausa, `alivePlayers[1]` ya no es el ganador que debería ser — es `nil`, porque la lista se vació mientras dormía. Resultado: `EndRound({winner = nil})`, "nadie gana".

#### Arreglo

* El ganador (o equipo ganador) se **captura antes** de la pausa dramática, no después — elimina la ventana de carrera en el caso normal.
* Respaldo adicional para el caso límite en que la lista YA está vacía en el momento de capturar el snapshot (dos muertes procesándose casi en el mismo instante, sin que a ninguna le haya dado tiempo a "ganar" la carrera): `checkWinCondition` ahora recibe también `lastEliminated` (el jugador cuya muerte disparó ESTA llamada, capturado en `onPlayerDied` antes de tocar la lista) y lo usa como ganador de última instancia si `alivePlayers[1]` es `nil`. Para 3v3, el respaldo equivalente es el equipo CONTRARIO al del último jugador eliminado (su equipo es, por definición, el que se acaba de quedar sin nadie en esta llamada) — el `Team` se lee de `ballObject` en `onPlayerDied` ANTES de `disintegrateCharacter` (que ancla/oculta la bola y la destruye 1s después), para no depender de que el atributo siga vivo más tarde.

#### Validación en playtest (reproducción real de la condición de carrera, no solo revisión de código)

Iniciada una partida 1v1 real (jugador + bot, vía `Lobby/JoinQueue`), y forzadas las Health de AMBAS bolas a 0 en la misma llamada síncrona (sin ningún `task.wait` entre medias) para reproducir exactamente el entrelazado que causaba el bug. Con el código anterior esto habría producido `winner = nil`; con el arreglo, el payload de `Lobby/MatchEnd` recibido por el cliente mostró un ganador válido (`Test_Dummy813`, el bot) pese a que ambos jugadores aparecen con `survived = false` (los dos murieron de verdad). Sin errores en `get_runtime_logs`.

**Archivo tocado:** `ServerScriptService.Managers.ArenaManager` (`checkWinCondition`, `onPlayerDied`).

***

### 2026-08-02 (simulación reversible en Studio para la Lobby AFK, en vez de solo registrar un log)

Ajuste pedido tras la corrección de arquitectura anterior: en Studio, en vez de limitarse a avisar por log que "en producción aquí se haría un teleport" y no hacer nada más, `AFKLobbyController` debía simular de verdad estar en el server AFK, deshabilitando localmente lo mismo que se deshabilitaría en el server real — para poder probar el flujo completo sin depender de un server publicado.

#### Cambio

Nueva función `setStudioAFKSimulation(active)`, reversible (a diferencia de `applyAFKServerStripping`, que es de un solo uso porque el server AFK real es desechable): en vez de `Destroy()`, mueve los hijos de `Lobby.Interactables` (salvo `Customization`) y de `Workspace.Lobbies` a dos carpetas nuevas en `ServerStorage` (`_AFKSim_Interactables`, `_AFKSim_Lobbies`), y los devuelve a su sitio al desactivar. El botón único `AFKTogglePoint` y el aviso de inactividad (`AFKLobby/PlayerIdle`) ahora, en Studio, llaman a esta simulación en vez de solo registrar un log; en producción siguen usando el teleport real sin cambios. La recompensa periódica (5 Dollars/60s) también corre mientras la simulación esté activa.

#### Validado en playtest (con alguna inestabilidad del propio plugin de Studio esta sesión — versión del plugin v2.22.4 desincronizada con el MCP v2.23.0, provocó varios reinicios de playtest sin relación con el código; una vez estable, todo funcionó sin errores)

* Estado inicial: `Lobby.Interactables` = `{3v3, 1v1, BR, Boss, Customization}`, `Workspace.Lobbies` = `{3v3, 1v1, BattleRoyale, BossFight}`.
* Pulsado **E** real sobre `AFKTogglePoint` (`simulate_keyboard_input`) → log `StudioSimulateAFK_On` → `Interactables` quedó en `{Customization}`, `Lobbies` en `{}`, texto del prompt cambiado a "Volver al Lobby principal".
* Esperado un ciclo de recompensa (60s) → Dollars subió de 3320 a 3325, confirmando que la simulación también activa la recompensa periódica.
* Pulsado **E** de nuevo → log `StudioSimulateAFK_Off` → `Interactables` y `Lobbies` recuperaron exactamente sus hijos originales, texto del prompt vuelto a "Ir a zona AFK". Reversibilidad confirmada.
* Sin errores en `get_runtime_logs` en ningún punto de la prueba.

**Archivo tocado:** `ServerScriptService.Controllers.AFKLobbyController` (nueva `setStudioAFKSimulation`, sustituye al log-only anterior en los dos puntos de entrada — botón e idle-remote — para el caso Studio).

***

### 2026-08-02 (corrección de arquitectura: la Lobby AFK debía ser un server reservado real, no un área nueva dentro del mismo server)

Nuevo `/goal`, corrigiendo la Lobby AFK de la sesión anterior: cuando el usuario pidió "una zona AFK" se refería a un **server reservado real** (mismo Place que la Lobby, `TeleportService:ReserveServer`) cuya forma fuera literalmente la carpeta `Workspace.Lobby` sin los interactables de partida (salvo Customization) y sin los "círculos" de `Workspace.Lobbies` — no la plataforma flotante nueva que construí. También pidió un botón/zona en la Lobby principal para ir AFK voluntariamente, y confirmó que a quien no vaya voluntariamente se le debe mandar igualmente al llegar a 2 minutos del límite de inactividad de Roblox (esto último ya lo hacía la versión anterior, solo había que reapuntarlo al nuevo mecanismo).

#### Descubrimiento clave: `Lobby.Interactables` NO es el sistema real de hitboxes

Al releer `LobbyVisuals.lua` a fondo (línea \~408) para decidir qué destruir en el server AFK, resultó que la detección de proximidad real que dispara el panel de unión itera `Workspace.Lobbies:GetChildren()` (las 4 carpetas "3v3"/"1v1"/"BattleRoyale"/"BossFight", cada una con un `Hitbox` real) — **no** `Workspace.Lobby.Interactables`, que resulta ser mayormente decorativo/informativo. Esto confirma por qué el usuario pidió explícitamente quitar "los círculos de Lobbies" además de los interactables: son dos sistemas distintos y hay que vaciar los dos para que de verdad no se pueda buscar partida desde el server AFK.

#### Arquitectura nueva

* **`Workspace.AFKLobby`** (la plataforma flotante de la sesión anterior) **eliminada** — ya no aplica.
* **`Workspace.Lobby.AFKTogglePoint`** (Part + ProximityPrompt nuevo, en la Lobby principal): un único punto físico que sirve para las dos direcciones. En el server principal dispara "Ir a zona AFK"; en el server AFK (mismo Place, mismo Part) dispara "Volver al Lobby principal". Qué texto/acción tiene se decide una sola vez al arrancar el script, según el rol de ese server concreto.
* **`ServerScriptService.Controllers.AFKLobbyController`** (reescrito de cero):
  * Detecta si ES el server AFK leyendo el `TeleportData` (`IsAFKServer = true`) del primer jugador que se une (`player:GetJoinData().TeleportData`) — cubre tanto `Players.PlayerAdded` como los jugadores ya presentes al arrancar (la documentación oficial de Roblox advierte que `PlayerAdded` no dispara para el jugador ya presente en un playtest en solitario de Studio).
  * Si es el server AFK: destruye todos los hijos de `Lobby.Interactables` salvo `Customization`, y **vacía** (no destruye la carpeta, solo sus hijos) `Workspace.Lobbies` — `LobbyVisuals.lua` hace `WaitForChild("Lobbies")` y si se destruye la carpeta entera se quedaría esperando para siempre.
  * Reutiliza `ServerScriptService.Managers.MatchDispatcher` (ya existente, usado por el matchmaking global real) para `ReserveServer`/`TeleportToPrivateServer` — mismo patrón exacto que `MatchStarter.Initiate`, incluyendo el `if RunService:IsStudio() then ... end` (en Studio solo registra un log, TeleportService no funciona ahí, límitación ya documentada y asumida en el resto del proyecto).
  * Cachea el `accessCode` de la reserva la primera vez que hace falta y lo reutiliza para todos los jugadores siguientes, así todos los que van a la zona AFK acaban en el **mismo** server compartido ("estar allí con otra gente", como pidió el usuario originalmente), no cada uno en su propio server privado.
  * Para volver, reutiliza `ServerScriptService.Managers.MatchReturnService.ReturnPlayer` (ya existente, con su propio fallback automático si el server de origen ya no existe) en vez de reinventar la lógica de retorno.
  * El aviso de "2 minutos antes del límite de inactividad de Roblox" (`AFKLobby/PlayerIdle`, de la sesión anterior — sin cambios en `AFKIdleWatcher`) ahora dispara la misma función `sendToAFKServer` que el botón voluntario, en vez de un teleport local a una plataforma.
  * La recompensa periódica (5 Dollars cada 60s) solo corre cuando el propio server es el server AFK, a todos los jugadores presentes en él.

#### Validación (con las mismas limitaciones de Studio ya documentadas en el proyecto para TeleportService)

* Sin errores al arrancar el script en playtest.
* Botón "Ir a zona AFK" visible y funcional (capturado por pantalla), pulsado con **E** real (`simulate_keyboard_input`) → log confirmó `StudioSkip_WouldSendToAFKServer` (comportamiento correcto: en Studio no intenta el teleport real, solo avisa).
* Disparado el remote `AFKLobby/PlayerIdle` manualmente → mismo log, mismo camino compartido con el botón — confirma que ambas entradas (voluntaria e inactividad) usan la misma función.
* La lógica de "vaciado" del server AFK (la parte que SÍ se puede probar sin teleport real) se ejecutó directamente dentro de una copia efímera de playtest: `Lobby.Interactables` pasó de `{3v3, 1v1, BR, Boss, Customization}` a `{Customization}`, y `Workspace.Lobbies` pasó de `{3v3, 1v1, BattleRoyale, BossFight}` a `{}` (carpeta vacía, no destruida) — sin errores en el cliente después del vaciado. Verificado además que el place GUARDADO no se vio afectado por esta prueba (Lobby.Interactables sigue teniendo sus 5 hijos originales) — la prueba corrió solo dentro del playtest, nunca contra el DataModel persistente.
* **No verificable en Studio** (limitación conocida y ya asumida en el resto del proyecto para todo lo que usa `TeleportService`): el salto real entre servers, que múltiples jugadores AFK acaben en el mismo server reservado, y que la detección de rol vía `TeleportData` funcione en un teleport real. Esto requiere probarse en un server publicado.

**Archivos nuevos:** `Workspace.Lobby.AFKTogglePoint` (Part + ProximityPrompt). **Archivos eliminados:** `Workspace.AFKLobby` (plataforma flotante + terminal clonado de la sesión anterior). **Archivos reescritos:** `ServerScriptService.Controllers.AFKLobbyController`. **Sin cambios:** `StarterPlayer.StarterPlayerScripts.Controllers.AFKIdleWatcher`, el remote `AFKLobby/PlayerIdle` (ya registrado en `GameServer.lua`).


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://claude.fractalgamestudios.es/bolas/game-info/arquitectura-arma-imperial-y-afk-parte-2.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
