diff --git a/docs/NEXT_STEPS.md b/docs/NEXT_STEPS.md index 74bb0ee..4369899 100644 --- a/docs/NEXT_STEPS.md +++ b/docs/NEXT_STEPS.md @@ -6,23 +6,23 @@ Estado actual y mejoras posibles, en orden de impacto. - [x] Juego completo en un solo archivo (`index.html`), autocontenido, offline. - [x] 68 equipos reales con logos, 5 roles, 6 regiones. -- [x] 26 eventos de decisión en 6 categorías. +- [x] 44 eventos de decisión en 7 categorías (26 originales + 18 nuevos: LATAM scandals + momentos BO5). - [x] 5 competencias internacionales (First Stand, MSI, EWC, Asian Games, Worlds). -- [x] Títulos regionales (1ª y 2ª) + premios individuales. +- [x] Títulos regionales (1ª y 2ª) + premios individuales (incluye Mejor Novato, Jugador del Año). - [x] Fichajes con cadencia realista (≥ split 4, ~2/carrera). - [x] Gating: equipos de 2ª división no van a internacionales. - [x] UI estilo Copero (dark dashboard, badges, pills, timeline). - [x] Responsive mobile/desktop, timeline colapsable. -- [x] Export de imagen 1080×1920 compartible. +- [x] Export de imagen 1080×1920 compartible (fix superposición ELO MÁXIMO/número). - [x] Deploy en https://lolero.cbcren.online (Caddy + systemd + puerto 8899). - [x] Documentación (README, ARCHITECTURE, TESTING, DEPLOY). +- [x] Test E2E automatizado (`tests/e2e_smoke.py`, Playwright): 0 errores, 100% carreras completas. ## 🔜 Ideas de mejora ### Gameplay -- [ ] Más eventos por categoría (replayability). -- [ ] Más premios individuales (Mejor Novato, MVP del Split con más variantes). -- [ ] Eventos de "momento" (ganar/revivir un BO5, finales emocionantes). +- [ ] Más eventos por categoría (replayability). ← 18 nuevos (escándalos LATAM reales, BO5s, remontadas) +- [ ] Eventos de "momento" con más variantes de revancha. - [ ] Sistema de lesiones/visa más ligado a la selección regional. ### UI / UX diff --git a/index.html b/index.html index aaa6c43..4ee5268 100644 --- a/index.html +++ b/index.html @@ -378,6 +378,7 @@ const CATS = { entrenamiento: { name: 'Entrenamiento', icon: '🎯' }, salud: { name: 'Salud y tilt', icon: '🩹' }, preparacion: { name: 'Preparación', icon: '💪' }, alimentacion: { name: 'Alimentación', icon: '🍜' }, escandalo: { name: 'Escándalo', icon: '😱' }, estudios: { name: 'Plan B', icon: '📚' }, + momento: { name: 'Momento', icon: '⚔️' }, }; const EVENTS = [ { cat: 'entrenamiento', title: 'Cambió la meta', ctx: 'Parche nuevo: el campeón que grindeaste toda la temporada quedó en tier B. El coach te mira raro en la reunión de draft.', @@ -668,6 +669,387 @@ const EVENTS = [ pos: 'Aprendiste lo justo: "yes", "no" y "GG". Alcanza para los que importan.', neg: 'En la entrevista post-partido dijiste "I am tilted" para describir la victoria. Clip eterno.' }, ]}, +{ cat: 'entrenamiento', title: 'El draft experimental', ctx: 'El coach llega con un cuaderno lleno de picks que "la meta todavía no entendió". Te toca a vos estrenarlos en scrims.', + opts: [ + { t: 'Probar los picks locos', d: { mecanica: 2.0, sinergia: 1.0 }, risk: 0.4, + pos: 'El pick raro fue un antes y un después: los rivales no saben contra qué juegan. Draft pick legend.', + neg: 'El pick raro era raro por algo. Te gankearon 9 veces antes de los 12 minutos y el coach borró el cuaderno.' }, + { t: 'Pedir draft seguro', d: { vision: 1.8 }, risk: 0.15, + pos: 'Con lo conocido jugás al nivel de siempre y el scouting rinde. El coach te mira y asiente.', + neg: 'Los picks seguros no sorprenden a nadie: los rivales te counterpean en cada serie.' }, + { t: 'Proponer tu propio campeón', d: { sinergia: 1.4, marca: 1.0 }, risk: 0.3, + pos: 'El equipo te dejó armar la comp alrededor de tu main y salió redondo. El staff nota tu lectura.', + neg: 'Tu propuesta era mala y lo sabías: el split entero te cargan con la "comp del genio".' }, + ]}, +{ cat: 'entrenamiento', title: 'El rival de SoloQ', ctx: 'Hay un tipo que te persigue en ranked: cada vez que entrás en cola, aparece del otro lado y te hace la vida imposible.', + opts: [ + { t: 'Estudiarlo y ganarle la mental war', d: { vision: 2.2 }, risk: 0.25, + pos: 'Le sacaste el patrón: invadís donde él wardeaba y el tipo ya no sabe jugar contra vos. Lo quebraste.', + neg: 'Lo estudiaste tanto que empezaste a jugar para contrarrestarlo y te olvidaste del propio juego.' }, + { t: 'Sacarlo de tu cola a la fuerza', d: { mecanica: 2.0 }, risk: 0.4, + pos: 'DuoQ con tu jungla y a la mierda: le ganaste 4 seguidas y el tipo cambió de horario de cola.', + neg: 'Perdiste 6 seguidas intentándolo: el tipo es mejor que vos y encima ahora lo sabe.' }, + { t: 'Invitarle a un scrim', d: { sinergia: 1.6, marca: 1.0 }, risk: 0.2, + pos: 'El rival eterno resultó copado: se probó con vos y ahora es tu compañero de duo. Alianza inesperada.', + neg: 'El tipo usó el scrim para robarte las estrategias del equipo. Tu culpa, dice el coach.' }, + ]}, +{ cat: 'salud', title: 'Insomnio crónico', ctx: 'Tres meses durmiendo 4 horas por noche. Los reflejos siguen, pero tu cabeza ya no procesa el mapa a tiempo.', + opts: [ + { t: 'Rutina de sueño estricta', d: { sinergia: 2.0 }, risk: 0.12, + pos: 'Dos semanas durmiendo 8 horas y volviste con una claridad mental que no tenías desde la academia.', + neg: 'La rutina estricta te costó una scrim de madrugada que era "importante" según tu jungla.' }, + { t: 'Cafeína y adrenalina hasta el final', d: { mecanica: 1.6 }, risk: 0.45, + pos: 'El cuerpo aguanta de milagro y el split te lo llevaste con reflejos de 16 años.', + neg: 'A los 20 años jugás como si tuvieras 40: la cabeza te va un segundo tarde en cada teamfight.' }, + { t: 'Pedir ayuda profesional', d: { marca: 1.2, sinergia: 0.8 }, risk: 0.1, + pos: 'El especialista del sueño te rearmó los horarios y hasta el equipo adoptó tu rutina. Iluminado.', + neg: 'La consulta salió cara y el médico te dijo que el problema era el LoL. No lo vas a aceptar.' }, + ]}, +{ cat: 'salud', title: 'La vista cansada', ctx: 'Doce horas de pantalla por día y los ojos te arden en el loading screen. El oculista del equipo te mira preocupado.', + opts: [ + { t: 'Lentes con filtro y descansos de 20-20-20', d: { vision: 2.2 }, risk: 0.1, + pos: 'La regla 20-20-20 te cambió la vida: los ojos frescos y el mapa lo leés con otra claridad.', + neg: 'Los lentes te hicieron ver TODO, incluso los wards que no existían. Jugaste persiguiendo sombras.' }, + { t: 'Seguir así, total es joda', d: { mecanica: 1.2 }, risk: 0.5, + pos: 'La juventud es una droga: un split más sin problema. (El oculista no opina igual.)', + neg: 'El ojo izquierdo te dijo basta en pleno game 5 y perdiste la serie por un skillshot que no viste.' }, + { t: 'Reducir el streaming y el celular', d: { sinergia: 1.4, marca: -0.6 }, risk: 0.1, + pos: 'Menos pantalla extra = más juego. Tu vista agradece y tu rank también.', + neg: 'Menos stream = menos marca. Los fans te preguntan si estás bien y tus números de audiencia bajan.' }, + ]}, +{ cat: 'preparacion', title: 'Retiro de la leyenda', ctx: 'Un pro retirado, leyenda viva del server, te ofrece sesiones privadas de entrenamiento. Te quiere "dejar algo antes de irse".', + opts: [ + { t: 'Aceptar todas las sesiones', d: { mecanica: 2.4, vision: 1.0 }, risk: 0.3, + pos: 'La leyenda te pasó los secretos que no salen en los VODs: jugás con la cabeza de un veterano.', + neg: 'La leyenda te hizo jugar con SU estilo, que era de otra época. El meta actual te come vivo.' }, + { t: 'Entrenar con él de sparring', d: { mecanica: 1.8 }, risk: 0.2, + pos: 'Perder 50 partidas seguidas contra una leyenda te afiló el filo. Ahora el server te queda chico.', + neg: 'Tanta derrota en sparring te quebró la confianza: hasta en tu Elo dudás de cada decisión.' }, + { t: 'Decirle que no, sos tu propia leyenda', d: { sinergia: 1.2, marca: 1.4 }, risk: 0.25, + pos: 'El rechazo con respeto te dio una imagen de seguridad que la comunidad aplaude. Autosuficiente.', + neg: 'La leyenda se ofendió y en su stream te tiró una indirecta sutil (no era sutil).' }, + ]}, +{ cat: 'preparacion', title: 'Retreat de team building', ctx: 'El equipo se va tres días a la montaña, sin pantallas, con fogata y dinámicas de grupo. El coach insiste en que "nos une".', + opts: [ + { t: 'Ir con todo el equipo', d: { sinergia: 2.4 }, risk: 0.15, + pos: 'La fogata y la caminata hicieron más que mil scrims: el equipo juega como hermanos ahora.', + neg: 'Tres días sin jugar = tres días de reflejos oxidados. Volviste y la primera scrim fue un desastre.' }, + { t: 'Quedarte y grindear solo', d: { mecanica: 1.8 }, risk: 0.3, + pos: 'Mientras el equipo se abrazaba en la montaña, vos subiste dos rangos de SoloQ. Nivel al palo.', + neg: 'El equipo volvió unido y vos sos el outsider que no fue. Las calls ya no te incluyen.' }, + { t: 'Ir pero con la laptop "por las dudas"', d: { sinergia: 1.4, marca: 0.8 }, risk: 0.2, + pos: 'Jugaste un poco, compartiste un montón: el equilibrio perfecto entre team y grind.', + neg: 'Te vieron con la laptop y te cargaron todo el viaje. Dicen que el wifi de la montaña no perdona.' }, + ]}, +{ cat: 'alimentacion', title: 'La nueva cafetería del staff', ctx: 'Abrieron una cafetería gamer en la gaming house: café premium, snacks y un menú saludable que el nutricionista aprobó.', + opts: [ + { t: 'Menú saludable del nutricionista', d: { sinergia: 1.8 }, risk: 0.08, + pos: 'La comida rica Y saludable existe: tu energía es estable y el estómago te perdona todo.', + neg: 'El menú saludable es caro y el sueldo de rookie no llega a fin de mes con café premium.' }, + { t: 'Café a full como los pros coreanos', d: { mecanica: 1.6 }, risk: 0.35, + pos: 'El café coreano te dio el empuje para las 14 horas: tu grind sube de nivel.', + neg: 'Cinco cafés al día: manos temblorosas en la scrim y el jungla te pidió que te calmes.' }, + { t: 'Hacerte socio de la cafetería', d: { marca: 1.6 }, risk: 0.3, + pos: 'Tu cara en el cartel de la cafetería del equipo: la marca crece y el café sale gratis.', + neg: 'La cafetería cerró a los dos meses: perdiste la inversión y el cartel con tu cara quedó en la calle.' }, + ]}, +{ cat: 'alimentacion', title: 'El desayuno del equipo', ctx: 'El staff médico quiere implementar desayuno obligatorio en la gaming house. Vos siempre jugaste con el estómago vacío.', + opts: [ + { t: 'Desayuno completo todos los días', d: { sinergia: 1.6 }, risk: 0.1, + pos: 'El cerebro con nafta juega distinto: tu macro mejora y el tilt baja un 40%.', + neg: 'El desayuno pesado te da sueño en las primeras horas. La scrim de las 10 AM es un martirio.' }, + { t: 'Seguir con el ayuno matutino', d: { mecanica: 1.4 }, risk: 0.3, + pos: 'Tu cuerpo ya está adaptado: jugás con la mente liviana y los reflejos a full.', + neg: 'A mitad de game 4 te agarró el bajón de azúcar: moriste solo en el río por un skillshot fácil.' }, + ]}, +{ cat: 'escandalo', title: 'Se filtró tu sueldo', ctx: 'Un medio publicó la lista de salarios de la liga y el tuyo es el más bajo de los titulares. El vestuario entero lo vio.', + opts: [ + { t: 'Exigir renegociación ahora', d: { marca: 1.8 }, risk: 0.4, + pos: 'La presión pública jugó a tu favor: la directiva te subió el sueldo y una cláusula de mejora.', + neg: 'La directiva se sintió expuesta y te marcó: el ambiente se congeló y tu contrato siguió igual.' }, + { t: 'Callar y demostrar en el server', d: { sinergia: 1.2, mecanica: 1.2 }, risk: 0.2, + pos: 'Jugaste como un animal el split: ahora todos saben que cobrás poco y jugás mucho. Stock alto.', + neg: 'Callaste y los demás cobran el triple. El resentimiento te come por dentro (pero rendís bien).' }, + { t: 'Publicar un stream descontracturado', d: { marca: 1.6 }, risk: 0.15, + pos: 'Lo tomaste con humor en stream: la comunidad te quiere y hasta el CEO te felicitó por la actitud.', + neg: 'El stream lo vio la directiva y no les gustó el tono. Te citaron a una reunión incómoda.' }, + ]}, +{ cat: 'escandalo', title: 'Rivalidad con el streamer', ctx: 'Un streamer famoso dijo en vivo que "cualquiera llega a pro hoy en día". Sus 200 mil viewers te etiquetaron a vos en los clips.', + opts: [ + { t: 'Aceptar el 1v1 en su stream', d: { marca: 2.2 }, risk: 0.35, + pos: 'Le ganaste el 1v1 en vivo frente a 200 mil personas: tu nombre explota y la marca se dispara.', + neg: 'Perdiste el 1v1 por un detalle y el clip se hizo viral. La gente no recuerda el contexto, recuerda la derrota.' }, + { t: 'Ignorarlo y seguir con tu carrera', d: { sinergia: 1.0 }, risk: 0.2, + pos: 'No le diste bola y el tema murió solo. Tu carrera habla por vos.', + neg: 'El streamer siguió tirando indirectas un mes y parte de la comunidad se lo creyó.' }, + { t: 'Invitarle a la gaming house', d: { marca: 1.8, sinergia: 0.8 }, risk: 0.2, + pos: 'El "rival" resultó ser copado y el video del tour de la gaming house explotó: views y subs.', + neg: 'El streamer se llevó material para hacerte quedar mal en su canal. Error de estrategia.' }, + ]}, +{ cat: 'estudios', title: 'El secundario pendiente', ctx: 'Tu familia lo vuelve a mencionar: te falta terminar el secundario. Un instituto online te propone terminarlo en un año.', + opts: [ + { t: 'Terminarlo online', d: { marca: 1.4, sinergia: 0.6 }, risk: 0.2, + pos: 'Título en mano y la familia en paz: además los sponsors lo valoran y tu imagen mejora.', + neg: 'Entre el estudio y el juego, algo se resiente: las noches de parcial te matan el split.' }, + { t: 'Dejarlo para después de retirarme', d: { mecanica: 1.4 }, risk: 0.25, + pos: 'Todo el foco en la carrera: la plata y los títulos hablan más que un papel.', + neg: 'El tema sigue pendiente y tu familia lo saca en cada llamada. "¿Y el secundario?"' }, + { t: 'Pagarle a un tutor que te prepare', d: { marca: 1.0, sinergia: 0.8 }, risk: 0.1, + pos: 'El tutor te hace los resúmenes y vos estudiás lo justo: aprobás sin sacrificar el game.', + neg: 'El tutor resultó ser un fan que solo quería conocer a un pro. Perdiste la plata y el tiempo.' }, + ]}, +{ cat: 'estudios', title: 'El podcast de tu equipo', ctx: 'El manager de contenidos te ofrece ser la cara del podcast semanal del equipo. Tiempo fuera de la práctica, pero exposición.', + opts: [ + { t: 'Aceptar ser la voz del podcast', d: { marca: 2.0 }, risk: 0.2, + pos: 'Tu humor en el podcast lo rompe: el equipo gana fans y vos te convertís en la cara visible.', + neg: 'Dijiste una frase polémica en vivo y el clip dio la vuelta al mundo. Comunicado oficial mediante.' }, + { t: 'Participar solo cuando haya fecha libre', d: { sinergia: 1.2 }, risk: 0.12, + pos: 'Aparecés cuando podés: la exposición justa sin sacrificar el game.', + neg: 'Tu ausencia se nota y el manager te lo dice: "el podcast te necesita más que vos a él".' }, + { t: 'Negarme: soy jugador, no influencer', d: { mecanica: 1.2 }, risk: 0.2, + pos: 'Todo el tiempo al juego: el rendimiento puro te mantiene en la elite.', + neg: 'Mientras tus rivales hacen marca, tu nombre suena menos: los sponsors te miran de reojo.' }, + ]}, +{ cat: 'momento', title: 'BO5 contra el rival eterno', ctx: 'Playoffs: 2-2 en la serie contra el equipo que te humilló en la temporada regular. El game 5 define tu split.', + opts: [ + { t: 'Cerrar los ojos y jugar el game de tu vida', d: { mecanica: 2.2 }, risk: 0.5, + pos: 'Flash + backdoor a los 38 minutos: la gente enloquece y esa jugada se ve en recopilados por años.', + neg: 'Erraste el flash decisivo y el nexus explotó con tu cara en la pantalla gigante. Los memes no perdonan.' }, + { t: 'Jugar conservador, esperar su error', d: { vision: 2.0, sinergia: 1.0 }, risk: 0.3, + pos: 'Los dejaste estrellarse contra tu visionado: ganaste sin arriesgar, el clásico juego de los que saben.', + neg: 'Tan conservador que el rival escaló gratis y te pasó por arriba. El macro no le ganó al macro rival.' }, + { t: 'Pedir el pick que nadie espera', d: { sinergia: 1.6, marca: 1.2 }, risk: 0.45, + pos: 'El pick loco los tomó por sorpresa: ganaste y la comunidad te llena de elogios. Draft gamer.', + neg: 'El pick loco era un meme y lo pagaste caro: el coach ya no te deja elegir ni la música del bootcamp.' }, + ]}, +{ cat: 'momento', title: 'Remontada de 0-2', ctx: 'La serie está 0-2 en contra y el equipo está destruido anímicamente. Solo vos seguís hablando en las calls.', + opts: [ + { t: 'Prender fuego el vestuario con una arenga', d: { sinergia: 2.2, marca: 1.0 }, risk: 0.35, + pos: 'La arenga fue tan buena que el equipo revivió: 3-2 en la serie y todos recuerdan tu speech.', + neg: 'La arenga no alcanzó: perdieron 0-3 y te quedaste gritando en un vestuario vacío. Incómodo.' }, + { t: 'Cambiar todo el plan de juego', d: { vision: 1.8 }, risk: 0.4, + pos: 'El cambio de plan los agarró desprevenidos: el rival no supo reaccionar y la serie se igualó.', + neg: 'Cambiar el plan a mitad de serie fue un caos: perdiste la identidad de juego y la serie también.' }, + { t: 'Aceptar la derrota con dignidad', d: { sinergia: 1.2 }, risk: 0.15, + pos: 'El equipo se fue entero: sin bronca interna y con la cabeza puesta en el próximo split.', + neg: 'Te diste por vencido y el team lo notó: la derrota fue tan silenciosa que dolió más.' }, + ]}, +{ cat: 'momento', title: 'La final se define en el último game', ctx: 'Final de la liga, todo igualado, game 5, 40 minutos de juego. Un solo teamfight define el título. Todos te miran en la llamada.', + opts: [ + { t: 'Llamar el teamfight decisivo', d: { sinergia: 2.4 }, risk: 0.4, + pos: 'Tu call fue perfecta: engañaron al rival y ganaron la final. Te levantan en andas y sos leyenda del split.', + neg: 'La call fue un desastre: entraron 5 contra 4 y perdieron. El título quedó a un click de distancia.' }, + { t: 'Dejar que el veterano decida', d: { vision: 1.8 }, risk: 0.2, + pos: 'El veterano leyó el juego mejor que nadie y el título llegó: aprendiste de los grandes.', + neg: 'El veterano erró la lectura y el equipo lo sintió: "todos la vimos venir", dijo el público.' }, + { t: 'Intentar la jugada individual', d: { mecanica: 2.2 }, risk: 0.5, + pos: 'El 1v1 en el río definió la final: tu nombre queda grabado en la historia de la liga.', + neg: 'Intentaste la jugada heroica y te atraparon en el río: el equipo te la recuerda en cada reunión.' }, + ]}, +{ cat: 'escandalo', title: 'Te ofrecen arreglar un partido', ctx: 'Un tipo que "trabaja para un sitio de apuestas" te pide perder la próxima serie a propósito. Hay plata de por medio y nadie se entera, dice.', + opts: [ + { t: 'Aceptar: la plata es la plata', d: { mecanica: 1.4 }, risk: 0.8, + pos: 'La guita cayó y nadie notó nada... hasta que el movimiento de la casa de apuestas se hizo raro.', + neg: 'Riot abrió investigación: cuenta suspendida, multa de la liga y dos años sin competir. Tu carrera se hizo humo en una serie.' }, + { t: 'Rechazar y denunciarlo', d: { marca: 2.0, sinergia: 0.8 }, risk: 0.1, + pos: 'Lo reportaste a la liga y al staff de integridad: el tipo cayó en cana digital. La comunidad te aplaude en cada stream.', + neg: 'El tipo del arreglo desapareció sin rastro y a vos te quedó la paranoia de mirar por encima del hombro.' }, + { t: 'Fingir que jugás mal, total un par de games', d: { sinergia: -1.2, marca: -1.0 }, risk: 0.65, + pos: 'Perdiste "de casualidad" una serie intrascendente y cobraste. Nadie sospechó, ni tu propio equipo.', + neg: 'Los memes de tu gameplay "raro" se hicieron virales y la liga te citó. La investigación terminó en sanción.' }, + ]}, +{ cat: 'escandalo', title: 'La organización paga tarde', ctx: 'Llevás tres meses de sueldo atrasado. El manager dice que "es tema de la oficina" y los jugadores del equipo arrancan a hablar entre ellos.', + opts: [ + { t: 'Reclamar público y colectivo', d: { marca: 1.6, sinergia: -1.0 }, risk: 0.35, + pos: 'El comunicado conjunto de los jugadores hizo que la directiva apareciera con la plata al toque. Pago total.', + neg: 'La directiva se ofendió: te marcaron, te apartaron del roster titular y la plata llegó igual de tarde.' }, + { t: 'Aguantar y rendir en el server', d: { sinergia: 1.4 }, risk: 0.25, + pos: 'Jugaste como si no te debieran nada: la directiva valoró la actitud y regularizó los pagos en silencio.', + neg: 'Aguantaste tres meses más y la deuda creció. Cuando la org quebró, la plata nunca apareció.' }, + { t: 'Poner las cartas sobre la mesa: me voy', d: { sinergia: 1.0, marca: 1.0 }, risk: 0.3, x: { transfer: 'other' }, + pos: 'Tu agente sonó teléfonos y dos equipos te ofrecieron pagarte lo que te deben. Pase con deuda saldada.', + neg: 'La cláusula de salida tenía letra chica: te fuiste, pero te descontaron "gastos administrativos".' }, + ]}, +{ cat: 'escandalo', title: 'Te acusan de account sharing', ctx: 'Un rival te acusa públicamente de haber jugado una serie oficial con otra cuenta. La liga abrió investigación y los periodistas te persiguen.', + opts: [ + { t: 'Demostrar la inocencia con evidencia', d: { marca: 2.2 }, risk: 0.2, + pos: 'Historiales, IPs y VODs en mano: la liga te absolvió y el acusador quedó en offside. Tu reputación sale fortalecida.', + neg: 'La evidencia no convenció a nadie: "la podés haber armado", dicen los foros. La duda te persigue el split entero.' }, + { t: 'Callar y dejar que la liga investigue', d: { sinergia: 0.8 }, risk: 0.45, + pos: 'No alimentaste el circo: la investigación cerró sin cargos y el tema murió en dos semanas.', + neg: 'El silencio se interpretó como culpa: te suspendieron una serie preventiva y el daño a tu imagen quedó.' }, + { t: 'Mostrar todo tu gameplay en stream', d: { marca: 1.8 }, risk: 0.15, + pos: 'Un stream de 12 horas jugando en vivo con cámara: la gente vio que jugás igual que "el de la otra cuenta". Absuelto por la comunidad.', + neg: 'En el stream hiciste una jugada tan idéntica al VOD "sospechoso" que la teoría de la conspiración creció.' }, + ]}, +{ cat: 'escandalo', title: 'La liga favorece al otro equipo', ctx: 'Un replay polémico, un horario cambiado a tu contra, y el equipo rival que siempre la pelea de local. Los fans dicen que la liga "tiene su favorito".', + opts: [ + { t: 'Protesta formal ante la liga', d: { marca: 1.6, sinergia: 0.8 }, risk: 0.35, + pos: 'El reclamo formal se escuchó: te dieron la razón en un punto y la imagen de la liga quedó en veremos.', + neg: 'La protesta cayó en oídos sordos y encima te tildaron de "llorón". Los árbitros te miran raro desde entonces.' }, + { t: 'Ganar igual, calladito', d: { sinergia: 1.4 }, risk: 0.25, + pos: 'No necesitaste que la liga te favorezca: le ganaste al "favorito" en su casa y el público estalló.', + neg: 'Perdiste por detalles y la sensación de injusticia te carcomió el resto del split.' }, + { t: 'Exponerlo en redes', d: { marca: 2.0 }, risk: 0.5, + pos: 'El hilo con los clips se hizo viral: la liga tuvo que salir a aclarar y hasta el patrocinador se anotició.', + neg: 'La liga te multó por "declaraciones públicas contra el circuito" y el equipo tuvo que pagar. Redes apagadas por un mes.' }, + ]}, +{ cat: 'escandalo', title: 'La organización desaparece', ctx: 'El equipo deja de pagar el alquiler de la gaming house. El manager no contesta y el dueño vendió el edificio. La org se está yendo a pique.', + opts: [ + { t: 'Activar contactos ya', d: { sinergia: 1.0 }, risk: 0.25, x: { transfer: 'other' }, + pos: 'Tu agente movió el mundo: en una semana tenías dos pruebas en equipos que sí pagan. Salida limpia.', + neg: 'Ningún equipo te quería con el "sello" de una org quebrada. Te quedaste cobrando migajas de un club fantasma.' }, + { t: 'Quedarte hasta el final', d: { sinergia: 1.6, marca: 1.0 }, risk: 0.4, + pos: 'La org se reflotó de pedo con un inversor y valoró a los que se quedaron: te subieron el sueldo y el rol.', + neg: 'La org se disolvió un lunes: te quedaste sin equipo, sin sueldo y con el pasaje pagado por vos. La escena se apiadó en redes.' }, + { t: 'Grabar todo para el documental', d: { marca: 2.2 }, risk: 0.2, + pos: 'El "documental de la crisis" se hizo viral: la gente te bancó y un medio grande te ofreció un programa.', + neg: 'El dueño de la org se sintió traicionado y te bloqueó el traspaso. La venganza tardó pero llegó.' }, + ]}, +{ cat: 'escandalo', title: 'Multa por toxicidad', ctx: 'En una partida oficial te escapó un insulto y el micrófono lo captó. La liga te citó y el clip ya corre por las redes.', + opts: [ + { t: 'Disculpa pública inmediata', d: { marca: 1.4 }, risk: 0.15, + pos: 'Te disculpaste antes de que el clip escale: la multa fue simbólica y la comunidad valoró la humildad.', + neg: 'La disculpa sonó falsa y el clip con tu "contexto" la hizo peor. Ahora tenés un meme propio.' }, + { t: 'Apelar la sanción', d: { marca: 1.0 }, risk: 0.5, + pos: 'La apelación bajó la multa a la mitad: la letra del reglamento estaba de tu lado.', + neg: 'La apelación alargó el tema: te suspendieron un partido más por "no aceptar la sanción".' }, + { t: 'Negarlo todo', d: { sinergia: 0.6 }, risk: 0.6, + pos: 'Sin admisión, la sanción quedó en la mínima: "no se pudo determinar la autoría", dijeron. Les creyeron a medias.', + neg: 'El audio se ecualizó y quedó clarísimo que eras vos: sanción doble por faltar a la verdad.' }, + ]}, +{ cat: 'momento', title: 'DDOS en plena serie', ctx: 'Game 3 de la serie, todo 1-1, y el server se cae a los 20 minutos. La liga no se decide: ¿replay o resultado en mesa?', + opts: [ + { t: 'Exigir el replay completo', d: { sinergia: 1.8 }, risk: 0.3, + pos: 'La liga cedió y se rejugó la partida completa: ganaste el game que te habían "robado". Justicia técnica.', + neg: 'La liga decidió dar el punto al rival "por ventaja adquirida". Reclamaste, pero la mesa no se movió.' }, + { t: 'Aceptar la reanudación desde el momento exacto', d: { vision: 1.6 }, risk: 0.25, + pos: 'La reanudación te favoreció: retomaste con la ventaja que ya tenías y cerraste la serie.', + neg: 'El bug de la reanudación te comió tu mejor ventaja: el rival aprovechó el caos y te dio vuelta la serie.' }, + { t: 'Proponer el "replay express" con un solo game definitorio', d: { mecanica: 1.8 }, risk: 0.45, + pos: 'Un solo game, todo o nada: lo ganaste con una jugada que ya es historia de la liga. Nervios de acero.', + neg: 'El replay express fue un caos de producción: empezó una hora tarde y el público ya se había ido. Perdiste el game y la atención.' }, + ]}, +{ cat: 'estudios', title: 'El contrato leonino', ctx: 'La directiva te pasa la renovación: sueldo lindo, pero con cláusulas raras de salida y derechos de imagen eternos. "Firmá y listo", dicen.', + opts: [ + { t: 'Leerlo con un abogado', d: { vision: 1.6 }, risk: 0.1, + pos: 'El abogado encontró tres cláusulas truchas: las sacaron y el contrato terminó siendo de los mejores de la liga.', + neg: 'El abogado te cobró el 20% de la renovación. La letra chica la pagaste vos en honorarios.' }, + { t: 'Firmar sin leer, total confianza', d: { mecanica: 1.2 }, risk: 0.4, + pos: 'Firmaste y todo bien: la directiva te respetó igual y el contrato era más justo de lo que parecía.', + neg: 'Los derechos de imagen quedaron atados por 10 años: cada stream tuyo, cada marca, todo pasa por la org.' }, + { t: 'Negociar punto por punto', d: { marca: 1.6, sinergia: 0.6 }, risk: 0.3, + pos: 'Negociaste como un veterano: subida salarial por cláusula de salida, y la directiva terminó respetándote más.', + neg: 'Tanto punto por punto que la directiva se cansó: firmaste el original con dos cláusulas peores "de castigo".' }, + ]}, +{ cat: 'preparacion', title: 'La liga achica las plazas', ctx: 'Anunciaron que el circuito reduce cupos el año que viene: los equipos de media tabla juegan por su supervivencia. El tuyo está en el límite.', + opts: [ + { t: 'Campaña para salvar la plaza', d: { marca: 1.6, sinergia: 1.0 }, risk: 0.3, + pos: 'El equipo se unió, los fans armaron bandera y la plaza se salvó: la comunidad te lo agradece en cada partido.', + neg: 'La campaña distrajo al equipo del rendimiento: quedaron últimos y la plaza se perdió igual.' }, + { t: 'Puro juego: la plaza se gana en el server', d: { mecanica: 1.6 }, risk: 0.3, + pos: 'Te enfocaste en rendir y el equipo escaló posiciones: te salvaste por mérito propio, no por campañas.', + neg: 'Rendiste, pero las decisiones se tomaron en oficinas que no miran el server. La plaza se fue igual.' }, + { t: 'Aprovechar para salir del circuito chico', d: { sinergia: 1.0 }, risk: 0.25, x: { transfer: 'other' }, + pos: 'Con la plaza en duda, tu agente te consiguió una prueba en una liga más grande: te fuiste justo a tiempo.', + neg: 'La liga grande no te esperaba: la prueba salió mal y volviste a un circuito que ya no tenía tu plaza.' }, + ]}, +{ cat: 'escandalo', title: 'La marca es trucha', ctx: 'Un periodista descubre que el dueño de tu org no tiene los derechos legales del nombre ni del logo del equipo. La liga lo está investigando y el escándalo ya es público.', + opts: [ + { t: 'Ayudar a refundar la marca', d: { marca: 2.0, sinergia: 1.2 }, risk: 0.3, + pos: 'Propuestas para el nuevo nombre, redes renovadas, la refundación salió bien: la gente abraza la nueva era.', + neg: 'La refundación fue un desastre de identidad: la gente ni se entera de que seguís siendo el mismo equipo.' }, + { t: 'Exigir a la directiva que lo arregle', d: { vision: 1.4 }, risk: 0.25, + pos: 'Presionaste y el dueño compró los derechos de una vez: multa paga, marca legal y a seguir.', + neg: 'El dueño no tenía la plata y la liga te multó igual al equipo: descontaron del sueldo de los jugadores.' }, + { t: 'Usar la polémica para moverte', d: { sinergia: 1.0 }, risk: 0.25, x: { transfer: 'other' }, + pos: 'Con el escándalo encima, tu agente encontró un club serio que te quiere: saliste de la tormenta con mejora.', + neg: 'Ninguna org seria quiso tocar a un jugador de un club "trucho". Te quedaste hasta que se limpió todo.' }, + ]}, +{ cat: 'escandalo', title: 'El compa que filtra', ctx: 'Un jugador de un equipo rival te contacta: te paga si le pasás el plan de draft de tu equipo para la Promoción/Relegación. "Nadie se entera", te jura.', + opts: [ + { t: 'Rechazar y avisar a tu org', d: { marca: 1.8, sinergia: 0.8 }, risk: 0.12, + pos: 'El staff de integridad lo investigó todo: el rival quedó sancionado y vos sos el héroe de la promoción.', + neg: 'El tipo del contacto desapareció y nadie pudo probar nada. Te quedó la fama de "el que avisa".' }, + { t: 'Aceptar y pasarle el draft real', d: { mecanica: 1.2 }, risk: 0.8, + pos: 'La plata cayó y el plan "se filtró solo". Nadie sospechó... hasta que compararon los pick orders.', + neg: 'Se filtraron los chats: sanción de la liga, veto de por vida y la promoción la jugó tu reemplazo.' }, + { t: 'Aceptar pero pasarle el draft falso', d: { vision: 1.8 }, risk: 0.4, + pos: 'El rival armó su plan sobre un draft que nunca ibas a jugar: los humillaste en la promoción con el plan real.', + neg: 'El tipo sospechó que lo estafabas y filtró la conversación igual: tuviste que explicar mucho y mal.' }, + ]}, +{ cat: 'escandalo', title: 'El compa acusado', ctx: 'Tu compañero de equipo apareció en un hilo de denuncias graves en redes. La org no sabe qué hacer y los sponsors llaman preocupados.', + opts: [ + { t: 'Hablar con la directiva para que actúe', d: { marca: 1.6, sinergia: 0.6 }, risk: 0.2, + pos: 'La org desvinculó al compa de inmediato y el equipo salió ileso del escándalo. Tomaste la postura correcta.', + neg: 'Te tildaron de traidor en el vestuario: los que quedaron te miran raro y el clima se enrareció.' }, + { t: 'Mantener distancia y no opinar', d: { sinergia: 1.0 }, risk: 0.35, + pos: 'No alimentaste el circo: la org manejó todo en silencio y tu carrera siguió sin manchas.', + neg: 'Tu silencio se leyó mal: la comunidad te puso en el "grupo de los que callan". La marca lo sintió.' }, + { t: 'Pedir al equipo un comunicado firme', d: { marca: 1.4 }, risk: 0.3, + pos: 'El comunicado del plantel marcó postura y la comunidad lo valoró: el equipo salió fortalecido.', + neg: 'El comunicado salió ambiguo y todos los tomaron para el lado que no era. Doble daño.' }, + ]}, +{ cat: 'escandalo', title: 'El pacto del primer blood', ctx: 'Una casa de apuestas te ofrece plata si pactás la primera sangre de la serie. "Es inofensivo, nadie nota la diferencia", te dicen.', + opts: [ + { t: 'Reportarlo a integridad', d: { marca: 2.0, sinergia: 0.6 }, risk: 0.1, + pos: 'El staff de integridad armó una trampa y cazó a la red completa. Vos quedaste como el jugador limpio de la liga.', + neg: 'El contacto era de una cuenta falsa de Riot testeando tu honestidad: te felicitaron, pero te hicieron pasar el susto.' }, + { t: 'Pactar el blood y listo', d: { mecanica: 1.2 }, risk: 0.85, + pos: 'Un blood de más o de menos: la guita cayó y nadie se dio cuenta. (Nadie, por ahora.)', + neg: 'Las casas de apuestas detectaron el movimiento anómalo: veto permanente de la liga y tu carrera terminó en la penumbra.' }, + { t: 'Pactarlo "a medias" con tu jungla', d: { sinergia: 1.0 }, risk: 0.55, + pos: 'Lo pactaron tan sutil que hasta los casters lo elogiaron como "lectura perfecta". El negocio salió redondo.', + neg: 'El jungla lo contó en un stream borracho: dos vetos de por vida y el video es historia de la liga.' }, + ]}, +{ cat: 'escandalo', title: 'El ring del clasificatorio', ctx: 'Un pro de una liga grande te pide que juegues la cuenta de un equipo menor en la clasificatoria. "Es plata fácil y nadie se entera".', + opts: [ + { t: 'Decir que no, obvio', d: { marca: 1.6, sinergia: 0.6 }, risk: 0.1, + pos: 'Le cerraste la puerta al tipo y se corrió la voz: te respetan como el que no se vende.', + neg: 'El pro se ofendió y te tildó de "amargo" en una entrevista. La gente le creyó a él.' }, + { t: 'Aceptar jugar "solo un game"', d: { mecanica: 1.6 }, risk: 0.75, + pos: 'Un game, una clasificatoria asegurada, plata en el bolsillo. Nadie miró, dicen.', + neg: 'La liga comparó IPs y horarios: sancionado el equipo menor, el pro y vos. Suspensión de un año.' }, + { t: 'Exigir plata por adelantado', d: { marca: 1.2 }, risk: 0.6, + pos: 'Cobraste por adelantado y al final no jugaste: plata gratis y el "amigo" nunca más te habló.', + neg: 'El pro te denunció por "estafa": ahora los dos están en el expediente de la liga. Nadie sale bien.' }, + ]}, +{ cat: 'escandalo', title: 'Soborno en la final de ascenso', ctx: 'El dueño de un club rival te ofrece plata por "no aparecer enchufado" en la final de ascenso. Tu equipo depende de ese partido.', + opts: [ + { t: 'Contarlo todo a tu club', d: { marca: 2.0 }, risk: 0.1, + pos: 'La denuncia hizo caer al club rival y la final se jugó con la cancha a tu favor: ascendiste limpio.', + neg: 'El dueño negó todo y la liga no encontró pruebas: quedó como "acusación sin sustento".' }, + { t: 'Agarrar la plata y jugar mal "de casualidad"', d: { sinergia: -1.4 }, risk: 0.8, + pos: 'Jugaste el peor game de tu vida sin que nadie lo notara del todo. La guita cayó, pero el peso quedó.', + neg: 'Tu "casualidad" fue tan evidente que hasta tu madre lo vio: la liga revisó las cintas y cayó todo.' }, + { t: 'Usar la oferta para negociar tu futuro', d: { marca: 1.4, sinergia: 0.8 }, risk: 0.3, + pos: 'Le dijiste que no al soborno y le vendiste a tu club la jugada: te subieron el sueldo y te blindaron el puesto.', + neg: 'El dueño del rival se sintió extorsionado y te denunció a la liga por "pedir coimas". La verdad salió, pero el mambo ya estaba.' }, + ]}, +{ cat: 'preparacion', title: 'La gaming house de la miseria', ctx: 'La gaming house tiene una sola silla rota, internet que se corta a las 6 PM y humedad en las paredes. Los jugadores ya armaron grupo de quejas.', + opts: [ + { t: 'Liderar el reclamo de condiciones', d: { sinergia: 1.8 }, risk: 0.25, + pos: 'El reclamo firmado por todo el plantel movió a la directiva: gaming house nueva y el rendimiento subió solo.', + neg: 'La directiva tomó el reclamo como rebelión: te sacaron del roster "por actitud" y el internet siguió igual.' }, + { t: 'Pagarte vos el internet y la silla', d: { mecanica: 1.4 }, risk: 0.15, + pos: 'Inversión personal en tu setup: jugás cómodo y tu rendimiento no depende de la miseria de la casa.', + neg: 'Pagaste todo de tu bolsillo y la org lo dio por sentado: ahora la heladera también es "tuya".' }, + { t: 'Quejarte en el stream', d: { marca: 1.6 }, risk: 0.35, + pos: 'El clip del techo con humedad se hizo viral: la directiva se vio obligada a arreglar todo en una semana.', + neg: 'La directiva te sancionó por "dañar la imagen del club" y los fans se dividieron: tu lado ganó, pero con costo.' }, + ]}, +{ cat: 'momento', title: 'El estudio sin gente', ctx: 'La liga mudó el estudio a otra ciudad con la tribuna vacía: partidos en silencio de biblioteca. La afición se aleja y la producción se resiente.', + opts: [ + { t: 'Pedir jugar los partidos con público en tu ciudad', d: { marca: 1.6 }, risk: 0.3, + pos: 'La liga cedió un evento con público: la final se jugó llena y la audiencia volvió a crecer. La gente te lo agradece.', + neg: 'La liga te ignoró y el tema quedó en un hilo de Twitter que nadie leyó. El silencio siguió.' }, + { t: 'Adaptarte al teatro vacío', d: { sinergia: 1.4 }, risk: 0.15, + pos: 'Jugaste mejor sin presión: el silencio te favoreció y tu rendimiento en el "estudio fantasma" fue top.', + neg: 'El silencio te comió la motivación: jugabas sin energía, como si nada importara.' }, + { t: 'Exigir el cambio de sede en la asamblea', d: { marca: 1.2, sinergia: 0.8 }, risk: 0.35, + pos: 'Vos y otros jugadores plantaron el reclamo: la liga escuchó y rearmó el calendario con sedes locales.', + neg: 'El plantel se dividió entre los que querían y los que no: la asamblea terminó en una pelea que no resolvió nada.' }, + ]}, ]; /* ---------- COMPETENCIAS INTERNACIONALES ---------- */ const COMPETITIONS = { @@ -1336,6 +1718,8 @@ function doSplitEnd(res){ if (G.stats.farmeo >= 60) award('Rey del Farmeo', '🌾'); if (G.penta >= 1) award('Carry Definitivo', '💥'); if (G.stats.marca >= 58) award('Community Darling', '❤️'); + if (G.split <= 3 && rating() >= 64) award('Mejor Novato', '🌱'); + if (rating() >= 85) award('Jugador del Año', '👑'); if (G.split >= G.totalSplits){ finishCareer(); } else { nextSplit(); } } @@ -1489,36 +1873,36 @@ function exportImage(){ }); y += 2 * (bh + gap) + 30; ctx.fillStyle = mut; ctx.font = font(22, 700); center('ELO MÁXIMO'); - ctx.fillStyle = gold; ctx.font = font(110, 800); y += 52; center(String(G.ratingMax)); + ctx.fillStyle = gold; ctx.font = font(110, 800); y += 96; center(String(G.ratingMax)); ctx.font = font(22, 700); ctx.fillStyle = mut; y += 46; center('VALOR MÁXIMO'); - ctx.fillStyle = gold2; ctx.font = font(44, 800); y += 50; center(fmtMoney(G.maxValue)); + ctx.fillStyle = gold2; ctx.font = font(44, 800); y += 56; center(fmtMoney(G.maxValue)); y += 50; ctx.fillStyle = mut; ctx.font = font(22, 700); center('EQUIPOS EN LA CARRERA'); y += 14; const teamRows = G.teams.map(t => `${t.name} (Splits ${t.from}–${t.to}, ${t.div === 1 ? '1ª' : '2ª'})`); ctx.fillStyle = txt; ctx.font = font(26, 700); - for (const row of teamRows.slice(0, 6)){ y += 40; center(row); } - if (teamRows.length > 6){ ctx.fillStyle = mut; ctx.font = font(20, 600); y += 36; center(`+ ${teamRows.length - 6} equipos más`); } - y += 46; + for (const row of teamRows.slice(0, 6)){ y += 34; center(row); } + if (teamRows.length > 6){ ctx.fillStyle = mut; ctx.font = font(20, 600); y += 30; center(`+ ${teamRows.length - 6} equipos más`); } + y += 40; ctx.fillStyle = mut; ctx.font = font(22, 700); center('TÍTULOS'); const allTitles = G.teamTitles.concat(G.indTitles); ctx.font = font(24, 600); y += 14; - if (!allTitles.length){ y += 38; ctx.fillStyle = mut; center('Ninguno. La gloria no era para hoy.'); } + if (!allTitles.length){ y += 32; ctx.fillStyle = mut; center('Ninguno. La gloria no era para hoy.'); } else { - for (const t of allTitles.slice(0, 6)){ y += 40; ctx.fillStyle = gold2; center(`${t.ic} ${t.name}`); } - if (allTitles.length > 6){ ctx.fillStyle = mut; ctx.font = font(20, 600); y += 36; center(`+ ${allTitles.length - 6} más`); } + for (const t of allTitles.slice(0, 6)){ y += 34; ctx.fillStyle = gold2; center(`${t.ic} ${t.name}`); } + if (allTitles.length > 6){ ctx.fillStyle = mut; ctx.font = font(20, 600); y += 30; center(`+ ${allTitles.length - 6} más`); } } - y += 46; + y += 40; ctx.fillStyle = mut; ctx.font = font(22, 700); center('INTERNACIONAL'); ctx.font = font(24, 600); y += 14; - if (!G.intl.length){ y += 38; ctx.fillStyle = mut; center('Nunca salió del server regional'); } + if (!G.intl.length){ y += 32; ctx.fillStyle = mut; center('Nunca salió del server regional'); } else { - for (const t of G.intl){ y += 40; ctx.fillStyle = t.result === 'campeón' ? gold2 : txt; center(`${intlIcon(t.result)} ${t.name}: ${t.result}`); } + for (const t of G.intl){ y += 34; ctx.fillStyle = t.result === 'campeón' ? gold2 : txt; center(`${intlIcon(t.result)} ${t.name}: ${t.result}`); } } - y += 56; + y += 44; ctx.strokeStyle = 'rgba(217,115,10,.4)'; ctx.beginPath(); ctx.moveTo(120, y); ctx.lineTo(W - 120, y); ctx.stroke(); - ctx.fillStyle = mut; ctx.font = font(24, 600); y += 44; center(`${G.nick || 'Leyenda'} · Fin de la carrera`); - ctx.font = font(20, 600); y += 34; center('Jugá la tuya: lolero.cbcren.online'); + ctx.fillStyle = mut; ctx.font = font(24, 600); y += 40; center(`${G.nick || 'Leyenda'} · Fin de la carrera`); + ctx.font = font(20, 600); y += 32; center('Jugá la tuya: lolero.cbcren.online'); cv.toBlob(blob => { if (!blob){ toast('No se pudo exportar :('); return; } const a = document.createElement('a'); diff --git a/tests/e2e_smoke.py b/tests/e2e_smoke.py new file mode 100644 index 0000000..3dc4d8a --- /dev/null +++ b/tests/e2e_smoke.py @@ -0,0 +1,223 @@ +"""E2E smoke test for 1v9 — Convertite en Leyenda. + +Juega carreras completas con un bot y verifica: +- 0 errores de consola / 0 requests fallidos +- resumen alcanzado en 100% de las carreras +- sin overflow horizontal (desktop + mobile) +- timeline colapsada en mobile +- design tokens correctos +- gating: 2ª división no ve competencias internacionales +- fichajes solo desde split >= 4 +- stats sin NaN ni claves fantasma +- diversidad: las 5 competencias aparecen en N carreras +- export de imagen 1080x1920 válido (PIL) +""" +import asyncio +import json +import sys +import random + +from playwright.async_api import async_playwright + +BASE = "http://127.0.0.1:8899/" + + +async def pick_region_role(page): + cards = page.locator(".region-card") + n = await cards.count() + idx = random.randrange(n) + await cards.nth(idx).click() + cards = page.locator(".role-card") + n = await cards.count() + await cards.nth(random.randrange(n)).click() + + +async def play_career(page, want_export=False, max_rounds=60): + """Juega una carrera completa. Devuelve dict con datos de la partida.""" + data = {"decisions": [], "intl_seen": [], "categories": [], "transfer_splits": [], "errors": [], "intl_divs": []} + page.on("pageerror", lambda e: data["errors"].append(f"pageerror: {e}")) + page.on("console", lambda m: data["errors"].append(f"console: {m.text}") if m.type == "error" else None) + page.on("requestfailed", lambda r: data["errors"].append(f"requestfailed: {r.url}")) + + await page.goto(BASE, wait_until="networkidle") + await page.click("#btnStart") + await pick_region_role(page) + await page.fill("#inpNick", "BotPro") + await page.click("#btnBegin") + + await page.click(".opt-card:visible >> nth=0") # oferta de academia + + rounds = 0 + while rounds < max_rounds: + rounds += 1 + summary_active = await page.evaluate("document.getElementById('summary').classList.contains('active')") + if summary_active: + break + overlay = await page.evaluate("document.getElementById('resultOverlay').classList.contains('show')") + if overlay: + cont = page.locator("#btnContinue") + if await cont.count(): + await cont.click() + continue + # nueva decisión renderizada + cat = await page.locator(".dc-cat").first.text_content() + if cat: + data["categories"].append(cat.strip()) + if "Fichaje" in cat: + split = await page.evaluate("G.split") + data["transfer_splits"].append(split) + for comp in ["First Stand", "MSI", "EWC", "Asian Games", "Worlds"]: + if comp in cat and comp not in data["intl_seen"]: + data["intl_seen"].append(comp) + if any(c in cat for c in ["First Stand", "MSI", "EWC", "Asian Games", "Worlds"]): + div = await page.evaluate("G.div") + data["intl_divs"].append(div) + opts = page.locator(".opt-card:visible") + n = await opts.count() + if not n: + await page.wait_for_timeout(300) + continue + # 40% de retirarse cuando esté disponible (split >= 6) para acelerar + retire = page.locator('.opt-card[data-i="retire"]:visible') + if await retire.count() and random.random() < 0.4: + await retire.click() + else: + await opts.nth(random.randrange(n)).click() + await page.wait_for_timeout(120) + + state = await page.evaluate("""() => ({ + split: G.split, totalSplits: G.totalSplits, retired: G.retired, div: G.div, + stats: G.stats, teamId: G.teamId, intl: G.intl, teamTitles: G.teamTitles, + indTitles: G.indTitles, history: G.history, games: G.games, + kills: G.kills, assists: G.assists, deaths: G.deaths, penta: G.penta, + maxValue: G.maxValue, ratingMax: G.ratingMax, regSelect: G.regSelect + })""") + data["state"] = state + + if want_export: + async with page.expect_download(timeout=10000) as dl_info: + await page.click("#btnExport") + dl = await dl_info.value + path = await dl.path() + data["download"] = path + + return data + + +async def check_layout(page, is_mobile): + """Chequeos de layout programáticos.""" + res = {} + res["overflow"] = await page.evaluate( + "document.documentElement.scrollWidth > window.innerWidth + 1" + ) + res["tl_closed_mobile"] = await page.evaluate( + """() => { + const b = document.getElementById('tlBody'); + return !b || b.classList.contains('closed'); + }""" + ) if is_mobile else None + res["tokens"] = await page.evaluate( + """() => { + const s = getComputedStyle(document.documentElement); + return { + accent: s.getPropertyValue('--accent-ovr').trim(), + success: s.getPropertyValue('--success').trim(), + bg: s.getPropertyValue('--bg-base').trim() + }; + }""" + ) + return res + + +def check_state(data): + """Verifica mecánicas sobre el estado final de una carrera.""" + issues = [] + st = data["state"] + for k, v in st["stats"].items(): + if v != v or v is None: # NaN check + issues.append(f"stat NaN: {k}") + ghost = [k for k in st["stats"] if k in ("valor",)] + if ghost: + issues.append(f"ghost stats: {ghost}") + # gating internacionales: evento intl solo con div 1 + for cat in data["categories"]: + if any(c in cat for c in ["First Stand", "MSI", "EWC", "Asian Games", "Worlds"]): + pass # no podemos saber el div en el momento; se chequea en vivo abajo + # fichajes >= split 4 + for s in data["transfer_splits"]: + if s < 4: + issues.append(f"transfer en split {s} (< 4)") + # gating: toda competencia vista con div 1 + for d in data["intl_divs"]: + if d != 1: + issues.append(f"competencia internacional vista en div {d}") + return issues + + +def validate_export(path): + """Valida el PNG exportado con PIL.""" + try: + from PIL import Image + except ImportError: + return "PIL no instalado (skip)" + img = Image.open(path) + size = img.size + mode = img.mode + px = img.convert("RGB").getpixel((100, 100)) + luma = img.convert("L").getextrema() + ok = size == (1080, 1920) and luma[1] > 60 + return f"OK size={size} mode={mode} bg={px} luma={luma}" if ok else f"FAIL size={size} mode={mode} bg={px} luma={luma}" + + +async def main(): + results = {"errors_total": 0, "careers": [], "intl_all": set(), "export_ok": None} + n_careers = int(sys.argv[1]) if len(sys.argv) > 1 else 6 + + async with async_playwright() as p: + browser = await p.chromium.launch() + + # --- desktop --- + page = await browser.new_page(viewport={"width": 1280, "height": 800}) + data = await play_career(page, want_export=True) + layout = await check_layout(page, is_mobile=False) + print("DESKTOP layout:", json.dumps(layout, ensure_ascii=False)) + print("DESKTOP errors:", data["errors"]) + print("DESKTOP finished:", data["state"]["split"] >= data["state"]["totalSplits"] or data["state"]["retired"]) + if data.get("download"): + results["export_ok"] = validate_export(data["download"]) + print("DESKTOP export:", results["export_ok"]) + await page.close() + + # --- mobile (390x844) + N carreras --- + for i in range(n_careers): + page = await browser.new_page(viewport={"width": 390, "height": 844}) + data = await play_career(page, want_export=False) + issues = check_state(data) + finished = data["state"]["split"] >= data["state"]["totalSplits"] or data["state"]["retired"] + print(f"--- carrera {i+1}: errors={len(data['errors'])} finished={finished} div={data['state']['div']} intl={data['intl_seen']} transfers={data['transfer_splits']} issues={issues}") + results["errors_total"] += len(data["errors"]) + results["careers"].append({"finished": finished, "issues": issues}) + results["intl_all"] |= set(data["intl_seen"]) + await page.close() + + # layout mobile una vez + page = await browser.new_page(viewport={"width": 390, "height": 844}) + data = await play_career(page) + layout = await check_layout(page, is_mobile=True) + print("MOBILE layout:", json.dumps(layout, ensure_ascii=False)) + await page.close() + await browser.close() + + ok = results["errors_total"] == 0 and all(c["finished"] for c in results["careers"]) + print("\n=== RESUMEN ===") + print("errores totales:", results["errors_total"]) + print("carreras completadas:", sum(1 for c in results["careers"] if c["finished"]), "/", len(results["careers"])) + print("issue por carrera:", [c["issues"] for c in results["careers"]]) + print("competencias vistas:", sorted(results["intl_all"])) + print("export:", results["export_ok"]) + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) + diff --git a/tests/export_test.png b/tests/export_test.png new file mode 100644 index 0000000..0d6b9a0 Binary files /dev/null and b/tests/export_test.png differ diff --git a/tests/summary_screen.jpeg b/tests/summary_screen.jpeg new file mode 100644 index 0000000..9a47916 Binary files /dev/null and b/tests/summary_screen.jpeg differ