diff --git a/.env.example b/.env.example index a88f010..18a76e4 100644 --- a/.env.example +++ b/.env.example @@ -1,20 +1,37 @@ -# ── DB (NAS PostgreSQL 16 + pgvector, 단일 SoR) ── -DATABASE_URL=postgresql://user:pass@nas:5432/vignette +# Vignette local development env example. +# Production/compose deployment should use infra/.env.example instead. -# ── 엔진 게이트웨이 (람다 소유, app 은 HTTP 호출만) ── -ENGINE_URL=http://engine:8100 -ENGINE_MODE=claude_api # claude_api | claude_cli | openai | solar +ENVIRONMENT=dev +DATABASE_URL=postgresql://vignette_app:change-me-app@127.0.0.1:55432/vignette -# ── 외부 LLM 키 (PII 마스킹 후만, 게이트웨이가 주로 사용) ── +# Engine gateway. For local Claude CLI gateway, run apps/api/engine_gateway on 9099. +ENGINE_URL=http://127.0.0.1:9099 +ENGINE_MODE=claude_cli + +# External providers. Keep real secrets in .env files only. ANTHROPIC_API_KEY= OPENAI_API_KEY= +OPENAI_BASE_URL=https://api.openai.com/v1 -# ── 세션/인증 (BFF OAuth 2.1, 토큰 서버 보관) ── +# Auth/session. Local dev may enable dev-login; public/prod must not. SESSION_SECRET=dev-insecure-change-me OAUTH_GOOGLE_CLIENT_ID= OAUTH_GOOGLE_CLIENT_SECRET= -OAUTH_REDIRECT_URI=https://chanpaca.net/auth/callback +OAUTH_REDIRECT_URI=https://api-vignette.chanpaca.net/auth/callback +AUTH_ALLOWED_EMAIL_DOMAINS=["hs.ac.kr","twentyoz.kr"] +AUTH_TEACHER_EMAILS=[] +AUTH_ADMIN_EMAILS=[] +AUTH_DEV_LOGIN_ENABLED=true +DEFAULT_AFFILIATION= +AUTO_SEED_PERSONAS=false +ALLOW_SEED_PERSONA_FALLBACK=false +EVALUATOR_GOLDEN_FEWSHOT_ENABLED=false +FRONTEND_BASE_URL=http://localhost:5173 +CORS_ORIGINS=["http://localhost:5173","http://127.0.0.1:5173"] -# ── 엔진 CLI 폴백 ── +# Live2D runtime. Models must be configured per persona via live2dModelUrl. +VITE_LIVE2D_CUBISM_CORE=/live2d/live2dcubismcore.min.js + +# Engine CLI fallback path. CLAUDE_P_PATH=claude GLM_OR_FALLBACK_MODEL= diff --git a/.gitignore b/.gitignore index 0be601d..c39965c 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ build/ .next/ .turbo/ *.log +*.err .pnpm-store/ # === Python / 백엔드 === @@ -45,3 +46,4 @@ postgres-data/ apps/api/gateway.restart.* apps/api/e2e_*.py apps/web/_pptr_check.cjs +apps/web/_pptr*.cjs diff --git a/apps/api/app/auth_sessions.py b/apps/api/app/auth_sessions.py new file mode 100644 index 0000000..f67c73a --- /dev/null +++ b/apps/api/app/auth_sessions.py @@ -0,0 +1,796 @@ +"""Server-side browser sessions for the BFF auth boundary. + +Sessions are DB-backed when PostgreSQL is available and fall back to the +process-local store for local degraded development. The browser only receives an +opaque HttpOnly cookie; roles and user identity stay on the server. +""" + +from __future__ import annotations + +import hashlib +import hmac +import secrets +import time +import uuid +from datetime import datetime, timezone +from dataclasses import dataclass +from typing import Literal + +from .config import settings +from .db import get_pool +from .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed + + +@dataclass(slots=True) +class SessionUser: + user_id: str + email: str + display_name: str + role: str + cohort_ids: list[str] + expires_at: float + + +@dataclass(slots=True) +class ManagedUser: + user_id: str + email: str + display_name: str + role: str + cohort_ids: list[str] + affiliation: str + created_at: float + last_seen_at: float + + +class InactiveUserError(Exception): + """Raised when an inactive managed user attempts to create a login session.""" + + +_sessions: dict[str, SessionUser] = {} +_users: dict[str, ManagedUser] = {} +_email_index: dict[str, str] = {} +_inactive_emails: set[str] = set() + +DEFAULT_AFFILIATION = settings.default_affiliation.strip() +RoleName = Literal["learner", "teacher", "admin"] +DB_ROLE_BY_APP = {"learner": "learner", "teacher": "instructor", "admin": "admin"} +APP_ROLE_BY_DB = {"learner": "learner", "instructor": "teacher", "admin": "admin"} + + +def _sid_hash(raw_sid: str) -> str: + return hmac.new( + settings.session_secret.encode("utf-8"), + raw_sid.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + +def user_id_from_email(email: str) -> str: + return str(uuid.uuid5(uuid.NAMESPACE_URL, f"vignette:user:{email.strip().lower()}")) + + +def _normalize_email(email: str) -> str: + return email.strip().lower() + + +def _db_role(role: str) -> str: + return DB_ROLE_BY_APP.get(role, role) + + +def _app_role(role: str) -> str: + return APP_ROLE_BY_DB.get(role, role) + + +def _ts(value: datetime | None) -> float: + return (value or datetime.now(timezone.utc)).timestamp() + + +def _cohort_ids(cohort: str | None) -> list[str]: + return [cohort] if cohort else [] + + +def _cohort_value(cohort_ids: list[str] | None) -> str | None: + return (cohort_ids or [None])[0] + + +async def _runtime_tables_ready(conn) -> bool: + row = await conn.fetchrow( + """ + SELECT + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'app' + AND table_name = 'app_user' + AND column_name IN ('affiliation', 'last_seen_at', 'updated_at') + GROUP BY table_schema, table_name + HAVING count(*) = 3 + ) AS has_user_columns, + to_regclass('app.auth_session') IS NOT NULL AS has_auth_session, + to_regclass('app.user_preferences') IS NOT NULL AS has_preferences, + to_regclass('app.admin_engine_config') IS NOT NULL AS has_engine_config, + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'app' + AND table_name = 'sessions' + AND column_name IN ( + 'runtime_case_id', + 'persona_code', + 'persona_display_name', + 'persona_difficulty', + 'prev_rapport_credit' + ) + GROUP BY table_schema, table_name + HAVING count(*) = 5 + ) AS has_session_columns, + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'app' + AND table_name = 'session_state' + AND column_name = 'turns_in_stage' + ) AS has_state_columns, + ( + SELECT count(*) = 4 + FROM app.stage_def + WHERE stage_code IN ('라포','탐색','개입','정리') + ) AS has_stage_defs, + EXISTS ( + SELECT 1 FROM pg_policies + WHERE schemaname = 'app' + AND tablename = 'sessions' + AND policyname IN ('p_sessions_insert','p_sessions_update','p_sessions_delete') + GROUP BY schemaname, tablename + HAVING count(*) = 3 + ) AS has_session_write_policies, + NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE schemaname = 'app' + AND tablename = 'sessions' + AND policyname = 'p_sessions_modify' + ) AS removed_old_session_policy, + EXISTS ( + SELECT 1 FROM pg_policies + WHERE schemaname = 'app' + AND tablename = 'turns' + AND policyname IN ('p_turns_insert','p_turns_update','p_turns_delete') + GROUP BY schemaname, tablename + HAVING count(*) = 3 + ) AS has_turn_write_policies, + NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE schemaname = 'app' + AND tablename = 'turns' + AND policyname = 'p_turns_modify' + ) AS removed_old_turn_policy + """ + ) + return bool( + row + and row["has_user_columns"] + and row["has_auth_session"] + and row["has_preferences"] + and row["has_engine_config"] + and row["has_session_columns"] + and row["has_state_columns"] + and row["has_stage_defs"] + and row["has_session_write_policies"] + and row["removed_old_session_policy"] + and row["has_turn_write_policies"] + and row["removed_old_turn_policy"] + ) + + +async def ensure_runtime_tables() -> None: + """Ensure DB-backed auth/user runtime tables exist when a pool is available.""" + pool = get_pool() + async with pool.acquire() as conn: + await conn.execute( + """ + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'app' + AND table_name = 'app_user' + AND column_name = 'affiliation' + ) THEN + ALTER TABLE app.app_user ALTER COLUMN affiliation SET DEFAULT ''; + END IF; + END $$; + """ + ) + if await _runtime_tables_ready(conn): + return + await conn.execute( + """ + ALTER TABLE app.app_user + ADD COLUMN IF NOT EXISTS affiliation TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + """ + ) + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS app.auth_session ( + sid_hash TEXT PRIMARY KEY, + user_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE CASCADE, + role TEXT NOT NULL, + display_name TEXT NOT NULL, + cohort_ids JSONB NOT NULL DEFAULT '[]'::jsonb, + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """ + ) + await conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_auth_session_user_active + ON app.auth_session(user_id, expires_at) + WHERE revoked_at IS NULL + """ + ) + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS app.user_preferences ( + user_id UUID PRIMARY KEY REFERENCES app.app_user(user_id) ON DELETE CASCADE, + theme TEXT NOT NULL DEFAULT 'system', + voice_preset_id TEXT NOT NULL DEFAULT 'soft-young-fem', + voice_rate REAL NOT NULL DEFAULT 1.0, + notifications JSONB NOT NULL DEFAULT '{}'::jsonb, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """ + ) + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS app.admin_engine_config ( + id BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (id), + engine_mode TEXT NOT NULL, + engine_url TEXT NOT NULL, + model TEXT NOT NULL, + updated_by TEXT, + updated_at TIMESTAMPTZ + ) + """ + ) + await conn.execute( + """ + ALTER TABLE app.sessions + ADD COLUMN IF NOT EXISTS runtime_case_id UUID, + ADD COLUMN IF NOT EXISTS persona_code TEXT, + ADD COLUMN IF NOT EXISTS persona_display_name TEXT, + ADD COLUMN IF NOT EXISTS persona_difficulty TEXT, + ADD COLUMN IF NOT EXISTS prev_rapport_credit REAL NOT NULL DEFAULT 0.0 + """ + ) + await conn.execute( + """ + ALTER TABLE app.session_state + ADD COLUMN IF NOT EXISTS turns_in_stage INT NOT NULL DEFAULT 0 + """ + ) + await conn.execute( + """ + INSERT INTO app.stage_def (stage_code, display_name, seq, base_openness) + VALUES + ('라포', '라포', 1, 0.15), + ('탐색', '탐색', 2, 0.35), + ('개입', '개입', 3, 0.55), + ('정리', '정리', 4, 0.45) + ON CONFLICT (stage_code) DO UPDATE SET + display_name = EXCLUDED.display_name, + seq = EXCLUDED.seq, + base_openness = EXCLUDED.base_openness + """ + ) + await conn.execute( + """ + DROP POLICY IF EXISTS p_sessions_modify ON app.sessions; + DROP POLICY IF EXISTS p_sessions_insert ON app.sessions; + DROP POLICY IF EXISTS p_sessions_update ON app.sessions; + DROP POLICY IF EXISTS p_sessions_delete ON app.sessions; + + CREATE POLICY p_sessions_insert ON app.sessions FOR INSERT WITH CHECK ( + app.is_ai_context() OR app.current_role_name() IN ('admin','instructor') + OR learner_id = app.current_uid() + ); + CREATE POLICY p_sessions_update ON app.sessions FOR UPDATE USING ( + app.is_ai_context() OR app.current_role_name() IN ('admin','instructor') + OR learner_id = app.current_uid() + ) WITH CHECK ( + app.is_ai_context() OR app.current_role_name() IN ('admin','instructor') + OR learner_id = app.current_uid() + ); + CREATE POLICY p_sessions_delete ON app.sessions FOR DELETE USING ( + app.is_ai_context() OR app.current_role_name() IN ('admin','instructor') + OR learner_id = app.current_uid() + ) + """ + ) + await conn.execute( + """ + DROP POLICY IF EXISTS p_turns_modify ON app.turns; + DROP POLICY IF EXISTS p_turns_insert ON app.turns; + DROP POLICY IF EXISTS p_turns_update ON app.turns; + DROP POLICY IF EXISTS p_turns_delete ON app.turns; + + CREATE POLICY p_turns_insert ON app.turns FOR INSERT WITH CHECK ( + app.is_ai_context() + OR app.current_role_name() IN ('admin','instructor') + OR EXISTS ( + SELECT 1 FROM app.sessions s + WHERE s.id = app.turns.session_id + AND s.learner_id = app.current_uid() + ) + ); + CREATE POLICY p_turns_update ON app.turns FOR UPDATE USING ( + app.is_ai_context() + OR app.current_role_name() IN ('admin','instructor') + OR EXISTS ( + SELECT 1 FROM app.sessions s + WHERE s.id = app.turns.session_id + AND s.learner_id = app.current_uid() + ) + ) WITH CHECK ( + app.is_ai_context() + OR app.current_role_name() IN ('admin','instructor') + OR EXISTS ( + SELECT 1 FROM app.sessions s + WHERE s.id = app.turns.session_id + AND s.learner_id = app.current_uid() + ) + ); + CREATE POLICY p_turns_delete ON app.turns FOR DELETE USING ( + app.is_ai_context() + OR app.current_role_name() IN ('admin','instructor') + OR EXISTS ( + SELECT 1 FROM app.sessions s + WHERE s.id = app.turns.session_id + AND s.learner_id = app.current_uid() + ) + ) + """ + ) + + +def _managed_user_from_row(row) -> ManagedUser: + return ManagedUser( + user_id=str(row["user_id"]), + email=row["email"], + display_name=row["display_name"] or row["email"], + role=_app_role(row["role"]), + cohort_ids=_cohort_ids(row.get("cohort") if hasattr(row, "get") else row["cohort"]), + affiliation=row["affiliation"] or DEFAULT_AFFILIATION, + created_at=_ts(row["created_at"]), + last_seen_at=_ts(row["last_seen_at"]), + ) + + +def _memory_upsert_managed_user( + *, + email: str, + display_name: str, + role: str, + cohort_ids: list[str] | None = None, + user_id: str | None = None, + affiliation: str | None = None, + reactivate: bool = False, +) -> ManagedUser: + now = time.time() + normalized_email = _normalize_email(email) + if normalized_email in _inactive_emails and not reactivate: + raise InactiveUserError("user is inactive") + if reactivate: + _inactive_emails.discard(normalized_email) + uid = user_id or _email_index.get(normalized_email) or user_id_from_email(normalized_email) + current = _users.get(uid) + user = ManagedUser( + user_id=uid, + email=normalized_email, + display_name=(display_name.strip() if display_name else "") or normalized_email, + role=role, + cohort_ids=list(cohort_ids or current.cohort_ids if current else cohort_ids or []), + affiliation=( + affiliation.strip() + if affiliation + else (current.affiliation if current else DEFAULT_AFFILIATION) + ), + created_at=current.created_at if current else now, + last_seen_at=now, + ) + _users[uid] = user + _email_index[normalized_email] = uid + return user + + +async def upsert_managed_user( + *, + email: str, + display_name: str, + role: str, + cohort_ids: list[str] | None = None, + user_id: str | None = None, + affiliation: str | None = None, + reactivate: bool = False, +) -> ManagedUser: + normalized_email = _normalize_email(email) + try: + pool = get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + """ + INSERT INTO app.app_user ( + external_id, email, display_name, role, cohort, affiliation, last_seen_at, updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, now(), now()) + ON CONFLICT (external_id) DO UPDATE SET + email = EXCLUDED.email, + display_name = COALESCE(NULLIF(EXCLUDED.display_name, ''), app.app_user.display_name), + role = EXCLUDED.role, + cohort = COALESCE(EXCLUDED.cohort, app.app_user.cohort), + affiliation = COALESCE(NULLIF(EXCLUDED.affiliation, ''), app.app_user.affiliation), + is_active = CASE WHEN $7 THEN TRUE ELSE app.app_user.is_active END, + last_seen_at = now(), + updated_at = now() + WHERE app.app_user.is_active OR $7 + RETURNING user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at + """, + f"email:{normalized_email}", + normalized_email, + (display_name.strip() if display_name else normalized_email), + _db_role(role), + _cohort_value(cohort_ids), + affiliation or DEFAULT_AFFILIATION, + reactivate, + ) + if row is None: + _inactive_emails.add(normalized_email) + raise InactiveUserError("user is inactive") + user = _managed_user_from_row(row) + _memory_upsert_managed_user( + email=user.email, + display_name=user.display_name, + role=user.role, + cohort_ids=user.cohort_ids, + user_id=user.user_id, + affiliation=user.affiliation, + reactivate=True, + ) + return user + except InactiveUserError: + raise + except Exception: + require_runtime_fallback_allowed("managed user") + return _memory_upsert_managed_user( + email=normalized_email, + display_name=display_name, + role=role, + cohort_ids=cohort_ids, + user_id=user_id, + affiliation=affiliation, + reactivate=reactivate, + ) + + +async def get_managed_user(user_id: str) -> ManagedUser | None: + try: + pool = get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at + FROM app.app_user + WHERE user_id = $1::uuid AND is_active + """, + user_id, + ) + if row is not None: + return _managed_user_from_row(row) + except Exception: + require_runtime_fallback_allowed("managed user") + if not runtime_fallback_allowed(): + return None + return _users.get(user_id) + + +async def list_managed_users() -> tuple[list[ManagedUser], bool]: + try: + pool = get_pool() + async with pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at + FROM app.app_user + WHERE is_active + ORDER BY last_seen_at DESC + """ + ) + return [_managed_user_from_row(row) for row in rows], True + except Exception: + require_runtime_fallback_allowed("managed user list") + return sorted(_users.values(), key=lambda u: u.last_seen_at, reverse=True), False + + +async def update_managed_user( + user_id: str, + *, + display_name: str | None = None, + role: RoleName | None = None, + affiliation: str | None = None, + cohort_ids: list[str] | None = None, +) -> ManagedUser | None: + try: + pool = get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + """ + UPDATE app.app_user SET + display_name = COALESCE($2, display_name), + role = COALESCE($3, role), + cohort = CASE WHEN $4 THEN $5 ELSE cohort END, + affiliation = COALESCE($6, affiliation), + updated_at = now(), + last_seen_at = now() + WHERE user_id = $1::uuid AND is_active + RETURNING user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at + """, + user_id, + display_name.strip() if display_name is not None else None, + _db_role(role) if role is not None else None, + cohort_ids is not None, + _cohort_value(cohort_ids), + affiliation.strip() if affiliation is not None else None, + ) + if row is not None: + next_user = _managed_user_from_row(row) + _memory_upsert_managed_user( + email=next_user.email, + display_name=next_user.display_name, + role=next_user.role, + cohort_ids=next_user.cohort_ids, + user_id=next_user.user_id, + affiliation=next_user.affiliation, + ) + for session in _sessions.values(): + if session.user_id == user_id: + session.display_name = next_user.display_name + session.role = next_user.role + session.cohort_ids = list(next_user.cohort_ids) + return next_user + except Exception: + require_runtime_fallback_allowed("managed user update") + + if not runtime_fallback_allowed(): + return None + current = _users.get(user_id) + if current is None: + return None + + next_user = ManagedUser( + user_id=current.user_id, + email=current.email, + display_name=display_name.strip() if display_name is not None else current.display_name, + role=role if role is not None else current.role, + cohort_ids=list(cohort_ids) if cohort_ids is not None else current.cohort_ids, + affiliation=affiliation.strip() if affiliation is not None else current.affiliation, + created_at=current.created_at, + last_seen_at=time.time(), + ) + _users[user_id] = next_user + + for session in _sessions.values(): + if session.user_id == user_id: + session.display_name = next_user.display_name + session.role = next_user.role + session.cohort_ids = list(next_user.cohort_ids) + return next_user + + +async def deactivate_managed_user(user_id: str) -> bool: + """Deactivate a managed user and revoke their active browser sessions.""" + changed = False + inactive_email: str | None = None + try: + pool = get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT email FROM app.app_user WHERE user_id = $1::uuid", + user_id, + ) + if row is not None and row["email"]: + inactive_email = _normalize_email(row["email"]) + result = await conn.execute( + """ + UPDATE app.app_user SET + is_active = FALSE, + updated_at = now(), + last_seen_at = now() + WHERE user_id = $1::uuid AND is_active + """, + user_id, + ) + changed = result.endswith(" 1") + await conn.execute( + "UPDATE app.auth_session SET revoked_at = now() WHERE user_id = $1::uuid", + user_id, + ) + except Exception: + require_runtime_fallback_allowed("managed user deactivation") + + if not runtime_fallback_allowed(): + return changed + memory_user = _users.pop(user_id, None) + if memory_user is not None: + inactive_email = _normalize_email(memory_user.email) + if inactive_email: + _inactive_emails.add(inactive_email) + for email, uid in list(_email_index.items()): + if uid == user_id: + _email_index.pop(email, None) + for sid_hash, session in list(_sessions.items()): + if session.user_id == user_id: + _sessions.pop(sid_hash, None) + return changed or memory_user is not None + + +async def active_session_count(user_id: str) -> int: + try: + pool = get_pool() + async with pool.acquire() as conn: + return int( + await conn.fetchval( + """ + SELECT count(*) + FROM app.auth_session + WHERE user_id = $1::uuid + AND revoked_at IS NULL + AND expires_at > now() + """, + user_id, + ) + or 0 + ) + except Exception: + require_runtime_fallback_allowed("auth session count") + now = time.time() + count = 0 + for session in _sessions.values(): + if session.user_id == user_id and session.expires_at > now: + count += 1 + return count + + +async def create_session( + *, + email: str, + display_name: str, + role: str, + cohort_ids: list[str] | None = None, + user_id: str | None = None, +) -> tuple[str, SessionUser]: + raw_sid = secrets.token_urlsafe(32) + normalized_email = _normalize_email(email) + managed = await upsert_managed_user( + email=normalized_email, + display_name=display_name, + role=role, + cohort_ids=cohort_ids, + user_id=user_id, + reactivate=False, + ) + expires_at = time.time() + settings.session_ttl_seconds + user = SessionUser( + user_id=managed.user_id, + email=normalized_email, + display_name=managed.display_name, + role=managed.role, + cohort_ids=list(managed.cohort_ids), + expires_at=expires_at, + ) + sid_hash = _sid_hash(raw_sid) + try: + pool = get_pool() + async with pool.acquire() as conn: + await conn.execute( + """ + INSERT INTO app.auth_session ( + sid_hash, user_id, role, display_name, cohort_ids, expires_at, last_seen_at + ) + VALUES ($1, $2::uuid, $3, $4, $5::jsonb, $6, now()) + ON CONFLICT (sid_hash) DO UPDATE SET + user_id = EXCLUDED.user_id, + role = EXCLUDED.role, + display_name = EXCLUDED.display_name, + cohort_ids = EXCLUDED.cohort_ids, + expires_at = EXCLUDED.expires_at, + revoked_at = NULL, + last_seen_at = now() + """, + sid_hash, + managed.user_id, + managed.role, + managed.display_name, + list(managed.cohort_ids), + datetime.fromtimestamp(expires_at, tz=timezone.utc), + ) + except Exception: + require_runtime_fallback_allowed("browser session") + _sessions[sid_hash] = user + return raw_sid, user + + +async def get_session(raw_sid: str | None) -> SessionUser | None: + if not raw_sid: + return None + key = _sid_hash(raw_sid) + try: + pool = get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT + s.expires_at, + u.user_id, + u.email, + COALESCE(u.display_name, s.display_name, u.email) AS display_name, + u.role, + u.cohort + FROM app.auth_session s + JOIN app.app_user u ON u.user_id = s.user_id + WHERE s.sid_hash = $1 + AND s.revoked_at IS NULL + AND s.expires_at > now() + AND u.is_active + """, + key, + ) + if row is not None: + await conn.execute( + "UPDATE app.auth_session SET last_seen_at = now() WHERE sid_hash = $1", + key, + ) + await conn.execute( + "UPDATE app.app_user SET last_seen_at = now() WHERE user_id = $1", + row["user_id"], + ) + return SessionUser( + user_id=str(row["user_id"]), + email=row["email"], + display_name=row["display_name"], + role=_app_role(row["role"]), + cohort_ids=_cohort_ids(row["cohort"]), + expires_at=_ts(row["expires_at"]), + ) + except Exception: + require_runtime_fallback_allowed("browser session") + + if not runtime_fallback_allowed(): + return None + user = _sessions.get(key) + if user is None: + return None + if user.expires_at <= time.time(): + _sessions.pop(key, None) + return None + managed = _users.get(user.user_id) + if managed is not None: + managed.last_seen_at = time.time() + return user + + +async def revoke_session(raw_sid: str | None) -> None: + if not raw_sid: + return + key = _sid_hash(raw_sid) + _sessions.pop(key, None) + try: + pool = get_pool() + async with pool.acquire() as conn: + await conn.execute( + "UPDATE app.auth_session SET revoked_at = now() WHERE sid_hash = $1", + key, + ) + except Exception: + require_runtime_fallback_allowed("browser session revoke") diff --git a/apps/api/app/config.py b/apps/api/app/config.py index f05a164..142702a 100644 --- a/apps/api/app/config.py +++ b/apps/api/app/config.py @@ -8,8 +8,9 @@ from __future__ import annotations from functools import lru_cache from typing import Literal +from urllib.parse import urlsplit -from pydantic import Field +from pydantic import Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict # 엔진 어댑터 provider 플래그 (마스터플랜 §0, R1: claude -p 과금누수 회피) @@ -20,12 +21,19 @@ from pydantic_settings import BaseSettings, SettingsConfigDict EngineMode = Literal["claude_api", "claude_cli", "openai", "solar"] +def _is_local_url(value: str) -> bool: + parsed = urlsplit(value) + host = (parsed.hostname or "").lower() + return host in {"localhost", "127.0.0.1", "::1"} + + class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", extra="ignore", case_sensitive=False, + populate_by_name=True, ) # ── 앱 ─────────────────────────────────────────────── @@ -59,6 +67,10 @@ class Settings(BaseSettings): # ── 외부 LLM 키 (게이트웨이가 못 받을 때 직접 폴백, PII 마스킹 후만) ── anthropic_api_key: str = Field(default="", validation_alias="ANTHROPIC_API_KEY") openai_api_key: str = Field(default="", validation_alias="OPENAI_API_KEY") + openai_base_url: str = Field( + default="https://api.openai.com/v1", + validation_alias="OPENAI_BASE_URL", + ) # ── 세션/인증 (BFF OAuth 2.1, 토큰 서버 보관) ──────── session_secret: str = Field( @@ -75,16 +87,54 @@ class Settings(BaseSettings): default="", validation_alias="OAUTH_GOOGLE_CLIENT_SECRET" ) oauth_redirect_uri: str = Field( - default="https://chanpaca.net/auth/callback", + default="https://api-vignette.chanpaca.net/auth/callback", validation_alias="OAUTH_REDIRECT_URI", ) + auth_allowed_email_domains: list[str] = Field( + default=["hs.ac.kr", "twentyoz.kr"], + validation_alias="AUTH_ALLOWED_EMAIL_DOMAINS", + ) + auth_teacher_emails: list[str] = Field( + default=[], + validation_alias="AUTH_TEACHER_EMAILS", + ) + auth_admin_emails: list[str] = Field( + default=[], + validation_alias="AUTH_ADMIN_EMAILS", + ) + auth_dev_login_enabled: bool = Field( + default=False, + validation_alias="AUTH_DEV_LOGIN_ENABLED", + ) + default_affiliation: str = Field( + default="", + validation_alias="DEFAULT_AFFILIATION", + ) + frontend_base_url: str = Field( + default="http://localhost:5173", + validation_alias="FRONTEND_BASE_URL", + ) # ── CORS (정적 프론트 + SSE 분리경로) ──────────────── cors_origins: list[str] = Field( - default=["https://chanpaca.net", "https://stream.chanpaca.net", "http://localhost:5173"], + default=[ + "https://vignette.chanpaca.net", + "https://vignette-b1q.pages.dev", + ], validation_alias="CORS_ORIGINS", ) + # Built-in personas are developer/bootstrap fixtures, not runtime truth. + # Production should use approved rows from app.persona_card only. + auto_seed_personas: bool = Field( + default=False, + validation_alias="AUTO_SEED_PERSONAS", + ) + allow_seed_persona_fallback: bool = Field( + default=False, + validation_alias="ALLOW_SEED_PERSONA_FALLBACK", + ) + # ── SSE 스트리밍 ───────────────────────────────────── sse_heartbeat_seconds: int = 30 # Cloudflare 100초 timeout 회피 (R2) @@ -92,6 +142,31 @@ class Settings(BaseSettings): def is_prod(self) -> bool: return self.environment == "prod" + @model_validator(mode="after") + def validate_non_dev_runtime_flags(self) -> "Settings": + if self.environment != "dev": + forbidden: list[str] = [] + if self.auth_dev_login_enabled: + forbidden.append("AUTH_DEV_LOGIN_ENABLED") + if self.auto_seed_personas: + forbidden.append("AUTO_SEED_PERSONAS") + if self.allow_seed_persona_fallback: + forbidden.append("ALLOW_SEED_PERSONA_FALLBACK") + if not self.oauth_google_client_id.strip(): + forbidden.append("OAUTH_GOOGLE_CLIENT_ID") + if not self.oauth_google_client_secret.strip(): + forbidden.append("OAUTH_GOOGLE_CLIENT_SECRET") + if self.session_secret == "dev-insecure-change-me": + forbidden.append("SESSION_SECRET") + if _is_local_url(self.frontend_base_url): + forbidden.append("FRONTEND_BASE_URL") + if any(_is_local_url(origin) for origin in self.cors_origins): + forbidden.append("CORS_ORIGINS") + if forbidden: + joined = ", ".join(forbidden) + raise ValueError(f"{joined} must be production-safe when ENVIRONMENT={self.environment}") + return self + @lru_cache def get_settings() -> Settings: diff --git a/apps/api/app/db.py b/apps/api/app/db.py index f1b172c..53f9e83 100644 --- a/apps/api/app/db.py +++ b/apps/api/app/db.py @@ -1,7 +1,8 @@ """asyncpg 연결 풀 + pgvector 등록. DB = NAS PostgreSQL 16 단일 SoR (마스터플랜 §0). 스키마 4분할: app / kb / audit / ds. -RLS 이중강제: 커넥션 획득 시 SET LOCAL app.current_role / app.current_ai_view 주입 +RLS 이중강제: 커넥션 획득 시 SET LOCAL app.current_role / app.current_uid / +app.current_cohort / app.ai_context / app.current_ai_view / app.current_sens_max 주입 (deps.py 의 RBAC 의존성과 짝). 여기선 풀 + 헬퍼만 제공한다. """ @@ -9,7 +10,7 @@ from __future__ import annotations import json from contextlib import asynccontextmanager -from typing import Any, AsyncIterator, Optional +from typing import Any, AsyncIterator, Optional, Sequence import asyncpg @@ -70,17 +71,44 @@ def get_pool() -> asyncpg.Pool: return _pool +def _db_role(role: str) -> str: + return "instructor" if role == "teacher" else role + + +def _default_sensitivity_max(ai_view: str | None) -> int | None: + return { + "client": 1, + "counselor": 0, + "evaluator": 2, + }.get(ai_view or "") + + +def _cohort_value(cohort_ids: Sequence[str] | None, cohort: str | None) -> str: + if cohort: + return cohort + if not cohort_ids: + return "" + return cohort_ids[0] or "" + + @asynccontextmanager async def acquire( *, role: Optional[str] = None, + user_id: Optional[str] = None, + cohort_ids: Optional[Sequence[str]] = None, + cohort: Optional[str] = None, ai_view: Optional[str] = None, + ai_context: Optional[bool] = None, + sensitivity_max: Optional[int] = None, ) -> AsyncIterator[asyncpg.Connection]: """커넥션 획득 + RLS 컨텍스트 주입. RLS 이중강제 (설계서 §4.1, F-30): - 레이어1 = AI 정보비대칭: app.current_ai_view (visible_to[] WHERE 강제) - 레이어2 = 인간 RBAC×cohort: app.current_role (RLS 정책) + 레이어1 = AI 정보비대칭: app.ai_context + app.current_ai_view + + app.current_sens_max (visible_to[]/sensitivity WHERE 강제) + 레이어2 = 인간 RBAC×cohort: app.current_role + app.current_uid + + app.current_cohort (RLS 정책) 트랜잭션 내 SET LOCAL 로 주입해 커넥션 풀 재사용 시 누수 방지. NOTE: RLS 정책/세션변수는 Phase 0 마이그레이션에서 정의(설계서 §3.3 / §4). @@ -89,20 +117,47 @@ async def acquire( pool = get_pool() async with pool.acquire() as conn: async with conn.transaction(): + is_ai = ai_context if ai_context is not None else ai_view is not None + await conn.execute("SELECT set_config('app.ai_context', $1, true)", "1" if is_ai else "") if role is not None: - await conn.execute("SELECT set_config('app.current_role', $1, true)", role) + await conn.execute("SELECT set_config('app.current_role', $1, true)", _db_role(role)) + if user_id is not None: + await conn.execute("SELECT set_config('app.current_uid', $1, true)", user_id) + cohort_name = _cohort_value(cohort_ids, cohort) + if cohort_name: + await conn.execute("SELECT set_config('app.current_cohort', $1, true)", cohort_name) if ai_view is not None: await conn.execute("SELECT set_config('app.current_ai_view', $1, true)", ai_view) + sens = sensitivity_max if sensitivity_max is not None else _default_sensitivity_max(ai_view) + if sens is not None: + await conn.execute( + "SELECT set_config('app.current_sens_max', $1, true)", + str(sens), + ) yield conn async def healthcheck() -> bool: - """SELECT 1 핑. /health 에서 사용.""" + """Return true only when the DB is reachable and required app tables exist.""" try: pool = get_pool() async with pool.acquire() as conn: - val = await conn.fetchval("SELECT 1") - return val == 1 + row = await conn.fetchrow( + """ + SELECT + to_regclass('app.app_user') IS NOT NULL AS has_user, + to_regclass('app.auth_session') IS NOT NULL AS has_auth_session, + to_regclass('app.user_preferences') IS NOT NULL AS has_preferences, + to_regclass('app.admin_engine_config') IS NOT NULL AS has_engine_config + """ + ) + return bool( + row + and row["has_user"] + and row["has_auth_session"] + and row["has_preferences"] + and row["has_engine_config"] + ) except Exception: return False diff --git a/apps/api/app/deps.py b/apps/api/app/deps.py index f18301a..6b45a4c 100644 --- a/apps/api/app/deps.py +++ b/apps/api/app/deps.py @@ -1,12 +1,4 @@ -"""의존성 — RBAC × visible_to 정보비대칭 게이트. - -2-레이어 강제 (설계서 §4.1, F-30): - 레이어1 = AI 정보비대칭: current_ai_view (CLIENT_AI 분기엔 CCD/정답 로드 코드경로 자체 부재) - 레이어2 = 인간 RBAC×cohort: current_role (RLS DB 레벨 방어선) -인간이 turn 읽을 때 두 게이트 AND. 여기선 요청 컨텍스트 추출 + DB 세션변수 주입 계약만 제공. - -NOTE: 실제 세션/쿠키 검증은 auth.py BFF + Redis 세션 구현 시 완성(현재 스텁). -""" +"""FastAPI dependencies for authentication, RBAC, and RLS context.""" from __future__ import annotations @@ -16,36 +8,39 @@ from typing import Annotated, AsyncIterator, Optional import asyncpg from fastapi import Cookie, Depends, HTTPException, status +from .auth_sessions import get_session from .config import Settings, get_settings from .db import acquire -# ── 인간 역할 (RBAC) ──────────────────────────────────── class Role(str, Enum): - LEARNER = "learner" # 본인 세션만 (/learn) - TEACHER = "teacher" # 담당 코호트 전체 열람+검수 (/teach) - ADMIN = "admin" # 전부 + 교수활동 감사 (/admin) + LEARNER = "learner" + TEACHER = "teacher" + ADMIN = "admin" -# ── AI 뷰 (정보비대칭, current_ai_view enum) ──────────── class AIView(str, Enum): - CLIENT = "client" # 가상내담자 AI — CCD/정답/점수 절대 비노출 - COUNSELOR = "counselor" # 상담사 AI(보조) — 표면 대화만, DSM 차단 - EVALUATOR = "evaluator" # 평가 AI — 전부 봄 (학습자엔 비노출) + CLIENT = "client" + COUNSELOR = "counselor" + EVALUATOR = "evaluator" class Principal: - """인증된 요청 주체. 인간 role + (선택) cohort 범위.""" + """Authenticated human principal.""" def __init__( self, user_id: str, role: Role, cohort_ids: Optional[list[str]] = None, + email: str = "", + display_name: str = "", ) -> None: self.user_id = user_id self.role = role self.cohort_ids = cohort_ids or [] + self.email = email + self.display_name = display_name def get_settings_dep() -> Settings: @@ -53,28 +48,37 @@ def get_settings_dep() -> Settings: async def get_current_principal( - # __Host- HttpOnly 쿠키 (config.cookie_name). 브라우저엔 토큰 미노출. session_cookie: Annotated[Optional[str], Cookie(alias="__Host-vignette_sid")] = None, + dev_session_cookie: Annotated[Optional[str], Cookie(alias="vignette_sid")] = None, ) -> Principal: - """세션 쿠키 -> Principal. + """Restore the principal from the opaque HttpOnly browser session cookie.""" + raw_cookie = session_cookie or (dev_session_cookie if get_settings().environment == "dev" else None) + session = await get_session(raw_cookie) + if session is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="not authenticated", + ) - TODO(auth.py 완성 시): Redis 세션 조회로 user_id/role/cohort 복원. - 현재 스텁: 쿠키 없으면 401, 있으면 LEARNER 더미(개발용). - prod 에선 session_cookie 검증 실패 시 무조건 401. - """ - if not session_cookie: - # dev 환경에선 쿠키 없어도 더미 학습자로 통과(로컬 라이브 테스트). prod 는 무조건 401. - if get_settings().environment != "dev": - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="not authenticated", - ) - # TODO: Redis 세션 룩업. 아래는 개발 스텁. - return Principal(user_id="dev-user", role=Role.LEARNER, cohort_ids=[]) + try: + role = Role(session.role) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="invalid session role", + ) from exc + + return Principal( + user_id=session.user_id, + role=role, + cohort_ids=session.cohort_ids, + email=session.email, + display_name=session.display_name, + ) def require_role(*allowed: Role): - """역할 화이트리스트 의존성 팩토리. 예: Depends(require_role(Role.TEACHER, Role.ADMIN)).""" + """Role allowlist dependency factory.""" async def _checker( principal: Annotated[Principal, Depends(get_current_principal)], @@ -92,30 +96,24 @@ def require_role(*allowed: Role): async def db_for_human( principal: Annotated[Principal, Depends(get_current_principal)], ) -> AsyncIterator[asyncpg.Connection]: - """인간 요청용 RLS 컨텍스트 커넥션 (레이어2 강제). - - app.current_role 주입 -> RLS 정책이 코호트/소유권 필터. - 라우트에서: conn: Annotated[asyncpg.Connection, Depends(db_for_human)] - """ - async with acquire(role=principal.role.value) as conn: - # cohort 스코프는 RLS 정책이 current_role + 소유 테이블로 강제 (설계서 §4). + """Acquire a DB connection with the human RBAC context attached.""" + async with acquire( + role=principal.role.value, + user_id=principal.user_id, + cohort_ids=principal.cohort_ids, + ) as conn: yield conn def db_for_ai_view(view: AIView): - """AI 역할용 RLS 컨텍스트 (레이어1 강제) 의존성 팩토리. - - app.current_ai_view 주입 -> visible_to[] WHERE 강제. - CLIENT 분기는 ccd/정답 로드 함수 자체를 부르지 않음(코드경로 부재 1차방어). - """ + """Dependency factory for AI-side RLS visibility context.""" async def _provider() -> AsyncIterator[asyncpg.Connection]: - async with acquire(ai_view=view.value) as conn: + async with acquire(ai_view=view.value, ai_context=True) as conn: yield conn return _provider -# 타입 별칭 (라우트 시그니처 간결화) CurrentPrincipal = Annotated[Principal, Depends(get_current_principal)] HumanDB = Annotated[asyncpg.Connection, Depends(db_for_human)] diff --git a/apps/api/app/engine_client.py b/apps/api/app/engine_client.py index daa786f..e4ae027 100644 --- a/apps/api/app/engine_client.py +++ b/apps/api/app/engine_client.py @@ -15,6 +15,7 @@ text 는 *PII 마스킹 후(text_masked)* 만 보낸다 (R7/F-03, 마스킹은 from __future__ import annotations +import asyncio from typing import Any, AsyncIterator, Literal, Optional import httpx @@ -72,10 +73,18 @@ class EngineClient: def __init__(self, base_url: Optional[str] = None) -> None: self.base_url = (base_url or settings.engine_url).rstrip("/") + self.engine_mode = settings.engine_mode + self.default_model: Optional[str] = None self._client: Optional[httpx.AsyncClient] = None + self._lock = asyncio.Lock() async def startup(self) -> None: - self._client = httpx.AsyncClient( + async with self._lock: + if self._client is None: + self._client = self._new_client() + + def _new_client(self) -> httpx.AsyncClient: + return httpx.AsyncClient( base_url=self.base_url, timeout=httpx.Timeout( settings.engine_timeout, @@ -84,9 +93,42 @@ class EngineClient: ) async def shutdown(self) -> None: - if self._client is not None: - await self._client.aclose() - self._client = None + async with self._lock: + if self._client is not None: + await self._client.aclose() + self._client = None + + @staticmethod + def _model_override(model: Optional[str]) -> Optional[str]: + value = (model or "").strip() + if not value or value == "gateway-default": + return None + return value + + async def configure( + self, + *, + base_url: str, + engine_mode: str, + default_model: Optional[str] = None, + ) -> None: + next_url = base_url.rstrip("/") + next_model = self._model_override(default_model) + async with self._lock: + url_changed = next_url != self.base_url + self.base_url = next_url + self.engine_mode = engine_mode + self.default_model = next_model + if self._client is not None and url_changed: + old_client = self._client + self._client = self._new_client() + await old_client.aclose() + + def _payload(self, req: GenerateRequest) -> dict[str, Any]: + payload = req.model_dump(exclude_none=True) + if self.default_model and "model" not in payload: + payload["model"] = self.default_model + return payload @property def client(self) -> httpx.AsyncClient: @@ -95,16 +137,40 @@ class EngineClient: return self._client async def health(self) -> bool: + return bool((await self.health_detail()).get("ok")) + + async def health_detail(self) -> dict[str, Any]: try: - r = await self.client.get("/health") - return r.status_code == 200 - except httpx.HTTPError: - return False + r = await self.client.get("/ready") + if r.status_code == 404: + live = await self.client.get("/health") + return { + "ok": live.status_code == 200, + "detail": "gateway liveness only; readiness endpoint unavailable", + "status_code": live.status_code, + } + payload: dict[str, Any] = {} + try: + payload = r.json() + except ValueError: + payload = {} + return { + "ok": r.status_code == 200 and bool(payload.get("ok", False)), + "detail": str(payload.get("detail") or r.text or "engine readiness failed"), + "status_code": r.status_code, + "cached": bool(payload.get("cached", False)), + } + except httpx.HTTPError as exc: + return { + "ok": False, + "detail": f"engine readiness transport error: {exc}", + "status_code": None, + } async def generate(self, req: GenerateRequest) -> GenerateResponse: """단발 생성. TODO: 게이트웨이 응답 스키마 확정 후 cost 텔레메트리 turns 적재.""" try: - r = await self.client.post("/v1/generate", json=req.model_dump(exclude_none=True)) + r = await self.client.post("/v1/generate", json=self._payload(req)) r.raise_for_status() except httpx.HTTPStatusError as e: raise EngineError(f"engine generate {e.response.status_code}: {e.response.text}") from e @@ -121,7 +187,7 @@ class EngineClient: """ try: async with self.client.stream( - "POST", "/v1/stream", json=req.model_dump(exclude_none=True) + "POST", "/v1/stream", json=self._payload(req) ) as r: r.raise_for_status() async for line in r.aiter_lines(): diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 1795659..18385df 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -13,14 +13,22 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from . import __version__ +from .auth_sessions import ensure_runtime_tables from .config import settings from .db import close_pool, healthcheck, init_pool from .engine_client import engine_client +from .persona_repository import materialize_seed_personas +from .session_persistence import ensure_review_tables from .routes import auth as auth_routes +from .routes import admin as admin_routes from .routes import eval as eval_routes from .routes import kb as kb_routes +from .routes import personas as persona_routes from .routes import sessions as session_routes +from .routes import teacher as teacher_routes +from .routes import users as user_routes from .routes import voice as voice_routes +from .services.voice import voice_service @asynccontextmanager @@ -31,15 +39,24 @@ async def lifespan(app: FastAPI): """ try: await init_pool() + await ensure_runtime_tables() + await ensure_review_tables() + if settings.auto_seed_personas: + await materialize_seed_personas() + await admin_routes.apply_engine_config_from_store() except Exception as exc: # DB 없어도 store 폴백으로 1턴 동작 (dev/로컬) + if settings.environment != "dev": + raise import logging logging.getLogger("uvicorn.error").warning( "DB 풀 초기화 실패 — store 인메모리 폴백으로 degraded 기동: %s", exc ) await engine_client.startup() + await voice_service.startup() try: yield finally: + await voice_service.shutdown() await engine_client.shutdown() try: await close_pool() @@ -59,14 +76,18 @@ app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins, allow_credentials=True, # __Host- HttpOnly 쿠키 전송 - allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], allow_headers=["*"], ) # TODO: Presidio PII 마스킹 미들웨어 (외부 LLM 경로 진입 전 하드 게이트, R7/F-03) app.include_router(auth_routes.router) +app.include_router(admin_routes.router) +app.include_router(persona_routes.router) app.include_router(session_routes.router) +app.include_router(teacher_routes.router) +app.include_router(user_routes.router) # Features 트랙 스텁 라우터(evaluator/voice/rag 가 채움). 등록만 — import 가능 보장. app.include_router(eval_routes.router) app.include_router(voice_routes.router) @@ -77,12 +98,14 @@ app.include_router(kb_routes.router) async def health() -> dict[str, object]: """liveness + DB + 엔진 게이트웨이 readiness.""" db_ok = await healthcheck() - engine_ok = await engine_client.health() + engine = await engine_client.health_detail() + engine_ok = bool(engine.get("ok")) return { - "status": "ok" if db_ok else "degraded", + "status": "ok" if db_ok and engine_ok else "degraded", "version": __version__, "environment": settings.environment, "db": db_ok, "engine": engine_ok, + "engine_detail": engine.get("detail"), "engine_mode": settings.engine_mode, } diff --git a/apps/api/app/persona_repository.py b/apps/api/app/persona_repository.py new file mode 100644 index 0000000..258e066 --- /dev/null +++ b/apps/api/app/persona_repository.py @@ -0,0 +1,243 @@ +"""DB-backed approved persona catalog. + +The runtime still uses services.persona.PersonaCard as the in-process card +shape. This module is the boundary that materializes seed cards into +app.persona_card and converts approved DB rows back into PersonaCard values. +""" + +from __future__ import annotations + +import json +import uuid +from dataclasses import dataclass +from typing import Any, Iterable + +from .config import settings +from .db import acquire, get_pool +from .services.persona import PersonaCard, SEED_PERSONAS, get_seed_persona + + +SEED_VERSION = 1 + +_CARD_COLUMNS = """ + persona_id, code, version, status, display_name, difficulty, theory_target, + demographics, presenting, history, big5, resistance, speech_style, + affect_baseline, ccd, dsm5_dimensional, source_provenance, is_synthetic +""" + + +@dataclass(frozen=True, slots=True) +class CatalogPersona: + card: PersonaCard + persona_id: str | None + version: int | None + source: str + degraded: bool = False + + +def seed_persona_id(code: str) -> str: + return str(uuid.uuid5(uuid.NAMESPACE_URL, f"vignette:persona:{code.upper()}")) + + +def _json_dict(value: Any) -> dict[str, Any]: + if value is None: + return {} + if isinstance(value, str): + parsed = json.loads(value) + return dict(parsed) if isinstance(parsed, dict) else {} + return dict(value) + + +def _string_list(value: Iterable[Any] | None) -> list[str]: + if value is None: + return [] + return [str(item) for item in value] + + +def card_from_row(row: Any) -> PersonaCard: + return PersonaCard( + code=str(row["code"]).upper(), + display_name=str(row["display_name"]), + difficulty=str(row["difficulty"]), + theory_target=_string_list(row["theory_target"]), + demographics=_json_dict(row["demographics"]), + presenting=_json_dict(row["presenting"]), + history=_json_dict(row["history"]), + big5=_json_dict(row["big5"]), + resistance=_json_dict(row["resistance"]), + speech_style=_json_dict(row["speech_style"]), + affect_baseline=_json_dict(row["affect_baseline"]), + ccd=_json_dict(row["ccd"]), + dsm5_dimensional=_json_dict(row["dsm5_dimensional"]), + source_provenance=str(row["source_provenance"] or ""), + is_synthetic=bool(row["is_synthetic"]), + ) + + +def catalog_persona_from_row(row: Any) -> CatalogPersona: + return CatalogPersona( + card=card_from_row(row), + persona_id=str(row["persona_id"]), + version=int(row["version"]), + source="database", + degraded=False, + ) + + +def seed_fallback_persona(code: str) -> CatalogPersona | None: + card = get_seed_persona(code) + if card is None: + return None + return CatalogPersona( + card=card, + persona_id=seed_persona_id(card.code), + version=SEED_VERSION, + source="seed_fallback", + degraded=True, + ) + + +def seed_fallback_personas() -> list[CatalogPersona]: + return [ + CatalogPersona( + card=card, + persona_id=seed_persona_id(card.code), + version=SEED_VERSION, + source="seed_fallback", + degraded=True, + ) + for card in SEED_PERSONAS.values() + ] + + +async def materialize_seed_personas() -> int: + """Upsert built-in seed personas as approved DB catalog rows.""" + get_pool() + count = 0 + async with acquire(role="admin") as conn: + for card in SEED_PERSONAS.values(): + await conn.execute( + """ + INSERT INTO app.persona_card ( + persona_id, code, version, status, display_name, difficulty, + theory_target, demographics, presenting, history, big5, + resistance, speech_style, affect_baseline, ccd, + dsm5_dimensional, source_provenance, is_synthetic, + approved_at + ) + VALUES ( + $1::uuid, $2, $3, 'approved', $4, $5, + $6::text[], $7::jsonb, $8::jsonb, $9::jsonb, $10::jsonb, + $11::jsonb, $12::jsonb, $13::jsonb, $14::jsonb, + $15::jsonb, $16, $17, now() + ) + ON CONFLICT (code, version) DO UPDATE SET + status = 'approved', + display_name = EXCLUDED.display_name, + difficulty = EXCLUDED.difficulty, + theory_target = EXCLUDED.theory_target, + demographics = EXCLUDED.demographics, + presenting = EXCLUDED.presenting, + history = EXCLUDED.history, + big5 = EXCLUDED.big5, + resistance = EXCLUDED.resistance, + speech_style = EXCLUDED.speech_style, + affect_baseline = EXCLUDED.affect_baseline, + ccd = EXCLUDED.ccd, + dsm5_dimensional = EXCLUDED.dsm5_dimensional, + source_provenance = EXCLUDED.source_provenance, + is_synthetic = EXCLUDED.is_synthetic, + approved_at = COALESCE(persona_card.approved_at, now()) + """, + seed_persona_id(card.code), + card.code, + SEED_VERSION, + card.display_name, + card.difficulty, + card.theory_target, + card.demographics, + card.presenting, + card.history, + card.big5, + card.resistance, + card.speech_style, + card.affect_baseline, + card.ccd, + card.dsm5_dimensional, + card.source_provenance, + card.is_synthetic, + ) + count += 1 + return count + + +async def list_approved_personas() -> list[CatalogPersona]: + get_pool() + async with acquire(ai_context=True) as conn: + rows = await conn.fetch( + f""" + SELECT {_CARD_COLUMNS} + FROM ( + SELECT DISTINCT ON (code) {_CARD_COLUMNS} + FROM app.persona_card + WHERE status = 'approved' + ORDER BY code, version DESC + ) approved + ORDER BY code + """ + ) + return [catalog_persona_from_row(row) for row in rows] + + +async def get_approved_persona(code: str) -> CatalogPersona | None: + normalized = code.strip().upper() + if not normalized: + return None + get_pool() + async with acquire(ai_context=True) as conn: + row = await conn.fetchrow( + f""" + SELECT {_CARD_COLUMNS} + FROM app.persona_card + WHERE status = 'approved' + AND upper(code) = $1 + ORDER BY version DESC + LIMIT 1 + """, + normalized, + ) + return catalog_persona_from_row(row) if row is not None else None + + +async def list_catalog_personas() -> list[CatalogPersona]: + try: + return await list_approved_personas() + except Exception: + if settings.allow_seed_persona_fallback: + return seed_fallback_personas() + raise + + +async def get_catalog_persona(code: str) -> CatalogPersona | None: + try: + return await get_approved_persona(code) + except Exception: + if settings.allow_seed_persona_fallback: + return seed_fallback_persona(code) + raise + + +__all__ = [ + "CatalogPersona", + "SEED_VERSION", + "card_from_row", + "catalog_persona_from_row", + "get_approved_persona", + "get_catalog_persona", + "list_approved_personas", + "list_catalog_personas", + "materialize_seed_personas", + "seed_fallback_persona", + "seed_fallback_personas", + "seed_persona_id", +] diff --git a/apps/api/app/routes/admin.py b/apps/api/app/routes/admin.py new file mode 100644 index 0000000..f04f850 --- /dev/null +++ b/apps/api/app/routes/admin.py @@ -0,0 +1,539 @@ +"""Admin operations routes.""" + +from __future__ import annotations + +import time +from datetime import datetime, timezone +from typing import Annotated, Literal + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field + +from ..auth_sessions import ( + active_session_count, + deactivate_managed_user, + list_managed_users, + update_managed_user, + upsert_managed_user, +) +from ..config import settings +from ..db import acquire, get_pool, healthcheck +from ..deps import Principal, Role, require_role +from ..engine_client import engine_client +from ..runtime_policy import require_runtime_fallback_allowed +from ..services.voice import voice_service +from ..services import rag + +router = APIRouter(prefix="/admin", tags=["admin"]) + +AdminPrincipal = Annotated[Principal, Depends(require_role(Role.ADMIN))] +HealthStatus = Literal["ok", "degraded", "down"] + + +class AdminServiceHealth(BaseModel): + key: str + name: str + status: HealthStatus + detail: str + metric: str + load: float + + +class AdminHealthResponse(BaseModel): + status: HealthStatus + environment: str + engine_mode: str + services: list[AdminServiceHealth] + + +class AdminEngineConfigResponse(BaseModel): + engine_mode: str + engine_url: str + model: str + updated_by: str | None = None + updated_at: float | None = None + durable: bool = False + source: Literal["database", "runtime_cache", "runtime_default"] = "runtime_default" + + +class AdminEngineConfigPatch(BaseModel): + engine_mode: str | None = None + engine_url: str | None = None + model: str | None = None + + +RoleName = Literal["learner", "teacher", "admin"] + + +class AdminUserResponse(BaseModel): + user_id: str + email: str + display_name: str + role: RoleName + cohort_ids: list[str] + affiliation: str + active_sessions: int + created_at: float + last_seen_at: float + source: Literal["database", "server_session_registry"] + + +class AdminUsersResponse(BaseModel): + source: Literal["database", "server_session_registry"] + durable: bool + users: list[AdminUserResponse] + + +class AdminUserPatch(BaseModel): + display_name: str | None = Field(default=None, min_length=1, max_length=80) + role: RoleName | None = None + affiliation: str | None = Field(default=None, max_length=120) + cohort_ids: list[str] | None = None + + +class RuntimeHealthMetrics(BaseModel): + engine_latency_ms: float | None = None + db_pool_size: int = 0 + db_pool_idle: int = 0 + db_pool_max: int = 0 + active_users: int = 0 + active_auth_sessions: int = 0 + active_sessions: int = 0 + ended_sessions: int = 0 + pending_reviews: int = 0 + + +def _clamp01(value: float) -> float: + return round(max(0.0, min(1.0, value)), 3) + + +def _pool_load(metrics: RuntimeHealthMetrics) -> float: + if metrics.db_pool_max <= 0: + return 0.0 + busy = max(0, metrics.db_pool_size - metrics.db_pool_idle) + return _clamp01(busy / metrics.db_pool_max) + + +def _workload_load(count: int, expected_capacity: int) -> float: + if expected_capacity <= 0: + return 0.0 + return _clamp01(count / expected_capacity) + + +async def _runtime_health_metrics(*, db_ok: bool) -> RuntimeHealthMetrics: + metrics = RuntimeHealthMetrics() + + try: + pool = get_pool() + metrics.db_pool_size = int(pool.get_size()) + metrics.db_pool_idle = int(pool.get_idle_size()) + metrics.db_pool_max = int(pool.get_max_size()) + except Exception: + pass + + if not db_ok: + return metrics + + try: + async with acquire(role="admin") as conn: + row = await conn.fetchrow( + """ + SELECT + (SELECT COUNT(*) FROM app.app_user WHERE is_active) AS active_users, + ( + SELECT COUNT(*) + FROM app.auth_session + WHERE revoked_at IS NULL AND expires_at > now() + ) AS active_auth_sessions, + ( + SELECT COUNT(*) + FROM app.sessions + WHERE ended_at IS NULL + ) AS active_sessions, + ( + SELECT COUNT(*) + FROM app.sessions + WHERE ended_at IS NOT NULL + ) AS ended_sessions, + ( + SELECT COUNT(*) + FROM app.sessions s + LEFT JOIN app.session_summary ss ON ss.session_id = s.id + WHERE s.ended_at IS NOT NULL AND ss.session_id IS NULL + ) AS pending_reviews + """ + ) + if row is not None: + metrics.active_users = int(row["active_users"] or 0) + metrics.active_auth_sessions = int(row["active_auth_sessions"] or 0) + metrics.active_sessions = int(row["active_sessions"] or 0) + metrics.ended_sessions = int(row["ended_sessions"] or 0) + metrics.pending_reviews = int(row["pending_reviews"] or 0) + except Exception: + return metrics + + return metrics + + +class AdminUserCreate(BaseModel): + email: str = Field(..., min_length=3, max_length=254) + display_name: str = Field(..., min_length=1, max_length=80) + role: RoleName = "learner" + affiliation: str | None = Field(default=None, max_length=120) + cohort_ids: list[str] = Field(default_factory=list) + + +class AdminUserDeleteResponse(BaseModel): + ok: bool + user_id: str + + +_ENGINE_CONFIG: AdminEngineConfigResponse | None = None +ENGINE_MODES = {"claude_api", "claude_cli", "openai", "solar"} +ENGINE_MODE_ALIASES = {"messages_api": "claude_api"} + + +def _normalize_email(value: str) -> str: + email = value.strip().lower() + if "@" not in email: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="email is invalid") + local, domain = email.rsplit("@", 1) + if not local or not domain: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="email is invalid") + allowed = {item.strip().lower().lstrip("@") for item in settings.auth_allowed_email_domains if item.strip()} + if domain not in allowed: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="email domain is not allowed") + return email + + +def _default_engine_config() -> AdminEngineConfigResponse: + return AdminEngineConfigResponse( + engine_mode=settings.engine_mode, + engine_url=settings.engine_url, + model="gateway-default", + durable=False, + source="runtime_default", + ) + + +def _normalize_engine_mode(value: str) -> str: + mode = ENGINE_MODE_ALIASES.get(value.strip(), value.strip()) + if mode not in ENGINE_MODES: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"unsupported engine mode {value}", + ) + return mode + + +def _normalize_engine_url(value: str) -> str: + url = value.strip().rstrip("/") + if not (url.startswith("http://") or url.startswith("https://")): + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="engine_url must start with http:// or https://", + ) + return url + + +def _updated_at_ts(value: datetime | None) -> float | None: + if value is None: + return None + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.timestamp() + + +def _engine_config_from_row(row) -> AdminEngineConfigResponse: + return AdminEngineConfigResponse( + engine_mode=_normalize_engine_mode(row["engine_mode"]), + engine_url=_normalize_engine_url(row["engine_url"]), + model=row["model"], + updated_by=row["updated_by"], + updated_at=_updated_at_ts(row["updated_at"]), + durable=True, + source="database", + ) + + +async def _current_engine_config() -> AdminEngineConfigResponse: + if _ENGINE_CONFIG is not None: + return _ENGINE_CONFIG + try: + pool = get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT engine_mode, engine_url, model, updated_by, updated_at + FROM app.admin_engine_config + WHERE id = TRUE + """ + ) + if row is not None: + return _engine_config_from_row(row) + except Exception: + require_runtime_fallback_allowed("admin engine config") + require_runtime_fallback_allowed("admin engine config") + return _default_engine_config() + + +async def apply_engine_config_from_store() -> AdminEngineConfigResponse: + """Load admin engine settings and apply them to the live engine client.""" + config = await _current_engine_config() + await engine_client.configure( + base_url=config.engine_url, + engine_mode=config.engine_mode, + default_model=config.model, + ) + return config + + +def _overall_status(services: list[AdminServiceHealth]) -> HealthStatus: + if any(s.status == "down" for s in services): + return "down" + if any(s.status == "degraded" for s in services): + return "degraded" + return "ok" + + +def _engine_unavailable_detail(detail: str) -> str: + if detail.lstrip().startswith("{") and '"ok":false' in detail: + return "Engine readiness failed" + return detail + + +async def _admin_user_response(user, *, durable: bool) -> AdminUserResponse: + return AdminUserResponse( + user_id=user.user_id, + email=user.email, + display_name=user.display_name, + role=user.role, + cohort_ids=user.cohort_ids, + affiliation=user.affiliation, + active_sessions=await active_session_count(user.user_id), + created_at=user.created_at, + last_seen_at=user.last_seen_at, + source="database" if durable else "server_session_registry", + ) + + +@router.get("/health", response_model=AdminHealthResponse) +async def admin_health(principal: AdminPrincipal) -> AdminHealthResponse: + """Return operational health from live backend checks.""" + current_engine = await _current_engine_config() + db_ok = await healthcheck() + engine_started = time.perf_counter() + engine = await engine_client.health_detail() + engine_ok = bool(engine.get("ok")) + engine_detail = _engine_unavailable_detail( + str(engine.get("detail") or "engine readiness unavailable") + ) + engine_latency_ms = (time.perf_counter() - engine_started) * 1000 + voice_ok = voice_service.is_available() + metrics = await _runtime_health_metrics(db_ok=db_ok) + metrics.engine_latency_ms = engine_latency_ms if engine_ok else None + pool_load = _pool_load(metrics) + session_load = _workload_load(metrics.active_sessions, 50) + review_load = _workload_load(metrics.pending_reviews, 50) + runtime_fallback_is_enabled = settings.environment == "dev" + db_status: HealthStatus = "ok" if db_ok else ("degraded" if runtime_fallback_is_enabled else "down") + db_detail = ( + "사용자, 세션, 리뷰 저장" + if db_ok + else ( + "DB 연결 전까지 비영구 개발 런타임 기록 사용" + if runtime_fallback_is_enabled + else "DB 저장소에 연결할 수 없습니다" + ) + ) + db_metric = ( + f"풀 {max(0, metrics.db_pool_size - metrics.db_pool_idle)}/{metrics.db_pool_max}" + if db_ok + else ("비영구 런타임 기록" if runtime_fallback_is_enabled else "저장소 중단") + ) + + services = [ + AdminServiceHealth( + key="engine", + name="응답 생성", + status="ok" if engine_ok else "down", + detail="AI 엔진 생성 준비 완료" if engine_ok else engine_detail, + metric=f"{engine_latency_ms:.0f}ms" if engine_ok else "로그인/설정 필요", + load=_clamp01(engine_latency_ms / 1500) if engine_ok else 0.0, + ), + AdminServiceHealth( + key="db", + name="영구 저장소", + status=db_status, + detail=db_detail, + metric=db_metric, + load=max(pool_load, session_load) if db_ok else 0.0, + ), + AdminServiceHealth( + key="voice", + name="음성 입력", + status="ok" if voice_ok else "degraded", + detail="음성 입력과 재생", + metric="OpenAI 연결" if voice_ok else "설정 필요", + load=0.05 if voice_ok else 0.0, + ), + AdminServiceHealth( + key="evaluation", + name="리뷰 생성", + status="ok" if engine_ok else "degraded", + detail="회기 종료 후 피드백 생성", + metric=f"대기 {metrics.pending_reviews}건", + load=review_load if engine_ok else 0.0, + ), + AdminServiceHealth( + key="kb", + name="지식 검색", + status="ok" if db_ok else "degraded", + detail=f"검색 기준값 {rag.CRAG_TOP1_THRESHOLD}", + metric=( + f"활성 세션 {metrics.active_sessions}건" + if db_ok + else "대기 중" + ), + load=max(pool_load, session_load) if db_ok else 0.0, + ), + ] + + return AdminHealthResponse( + status=_overall_status(services), + environment=settings.environment, + engine_mode=current_engine.engine_mode, + services=services, + ) + + +@router.get("/engine-config", response_model=AdminEngineConfigResponse) +async def get_engine_config(principal: AdminPrincipal) -> AdminEngineConfigResponse: + """Return the current admin-managed engine settings.""" + return await _current_engine_config() + + +@router.patch("/engine-config", response_model=AdminEngineConfigResponse) +async def patch_engine_config( + body: AdminEngineConfigPatch, + principal: AdminPrincipal, +) -> AdminEngineConfigResponse: + """Persist engine settings for administrators.""" + global _ENGINE_CONFIG + + current = await _current_engine_config() + next_mode = _normalize_engine_mode(body.engine_mode or current.engine_mode) + next_url = _normalize_engine_url(body.engine_url or current.engine_url) + next_config = AdminEngineConfigResponse( + engine_mode=next_mode, + engine_url=next_url, + model=(body.model or current.model).strip(), + updated_by=principal.email, + updated_at=time.time(), + durable=False, + source="runtime_cache", + ) + try: + pool = get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + """ + INSERT INTO app.admin_engine_config ( + id, engine_mode, engine_url, model, updated_by, updated_at + ) + VALUES (TRUE, $1, $2, $3, $4, now()) + ON CONFLICT (id) DO UPDATE SET + engine_mode = EXCLUDED.engine_mode, + engine_url = EXCLUDED.engine_url, + model = EXCLUDED.model, + updated_by = EXCLUDED.updated_by, + updated_at = now() + RETURNING engine_mode, engine_url, model, updated_by, updated_at + """, + next_config.engine_mode, + next_config.engine_url, + next_config.model, + principal.email, + ) + next_config = _engine_config_from_row(row) + except Exception as exc: + if settings.environment != "dev": + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + detail="engine config persistence unavailable", + ) from exc + _ENGINE_CONFIG = next_config + await engine_client.configure( + base_url=next_config.engine_url, + engine_mode=next_config.engine_mode, + default_model=next_config.model, + ) + return next_config + + +@router.get("/users", response_model=AdminUsersResponse) +async def list_users(principal: AdminPrincipal) -> AdminUsersResponse: + """Return users observed by the server-side auth/session boundary.""" + users, durable = await list_managed_users() + if not durable: + require_runtime_fallback_allowed("admin user list") + return AdminUsersResponse( + source="database" if durable else "server_session_registry", + durable=durable, + users=[await _admin_user_response(user, durable=durable) for user in users], + ) + + +@router.post("/users", response_model=AdminUserResponse, status_code=status.HTTP_201_CREATED) +async def create_user( + body: AdminUserCreate, + principal: AdminPrincipal, +) -> AdminUserResponse: + """Create or reactivate a managed user without requiring that user to log in first.""" + user = await upsert_managed_user( + email=_normalize_email(body.email), + display_name=body.display_name, + role=body.role, + affiliation=body.affiliation, + cohort_ids=body.cohort_ids, + reactivate=True, + ) + users, durable = await list_managed_users() + if not durable: + require_runtime_fallback_allowed("admin user create") + return await _admin_user_response(user, durable=durable) + + +@router.patch("/users/{user_id}", response_model=AdminUserResponse) +async def patch_user( + user_id: str, + body: AdminUserPatch, + principal: AdminPrincipal, +) -> AdminUserResponse: + """Update a server-known user's role/profile for the current API process.""" + next_user = await update_managed_user( + user_id, + display_name=body.display_name, + role=body.role, + affiliation=body.affiliation, + cohort_ids=body.cohort_ids, + ) + if next_user is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found") + users, durable = await list_managed_users() + if not durable: + require_runtime_fallback_allowed("admin user update") + return await _admin_user_response(next_user, durable=durable) + + +@router.delete("/users/{user_id}", response_model=AdminUserDeleteResponse) +async def delete_user( + user_id: str, + principal: AdminPrincipal, +) -> AdminUserDeleteResponse: + """Deactivate a managed user and revoke any active browser sessions.""" + if user_id == principal.user_id: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="cannot deactivate current admin") + if not await deactivate_managed_user(user_id): + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found") + return AdminUserDeleteResponse(ok=True, user_id=user_id) diff --git a/apps/api/app/routes/auth.py b/apps/api/app/routes/auth.py index 0edd7b9..9f47bea 100644 --- a/apps/api/app/routes/auth.py +++ b/apps/api/app/routes/auth.py @@ -1,84 +1,452 @@ -"""인증 라우트 — BFF OAuth 2.1 Auth Code + PKCE(S256) 스텁. +"""BFF authentication routes. -마스터플랜 §5: BFF + OAuth 2.1, 토큰은 서버(Redis)에만, 브라우저엔 __Host- HttpOnly 쿠키. -미성년 사례데이터 + 상담 민감정보 -> XSS 토큰탈취 원천 차단. -1차 = Google OIDC 단독, 한신대 SSO 는 2차(R11, Authlib provider 추상화 뒤). - -이 파일은 라우트 시그니처 + 흐름 + TODO. 실제 OAuth 교환/Redis 세션은 Phase 2 트랙 B. +The production path is Google OIDC authorization code + PKCE. Until the DB +session table is wired, the issued browser sessions are server-side in-proc +sessions backed by an opaque HttpOnly cookie. Local development also has a +dev-only server login endpoint so Playwright can exercise auth without trusting +browser localStorage. """ from __future__ import annotations -from typing import Annotated, Optional +import base64 +import hashlib +import secrets +import time +from dataclasses import dataclass +from typing import Annotated, Literal, Optional +from urllib.parse import urlencode, urlsplit -from fastapi import APIRouter, HTTPException, Query, Response, status +import httpx +from fastapi import APIRouter, Cookie, HTTPException, Query, Request, Response, status from fastapi.responses import RedirectResponse from pydantic import BaseModel +from ..auth_sessions import InactiveUserError, SessionUser, create_session, revoke_session from ..config import settings -from ..deps import CurrentPrincipal +from ..deps import CurrentPrincipal, Principal, Role router = APIRouter(prefix="/auth", tags=["auth"]) +GOOGLE_AUTHORIZE_URL = "https://accounts.google.com/o/oauth2/v2/auth" +GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" +GOOGLE_TOKENINFO_URL = "https://oauth2.googleapis.com/tokeninfo" +OAUTH_STATE_TTL_SECONDS = 10 * 60 + + +@dataclass(slots=True) +class OAuthState: + code_verifier: str + next_path: str + created_at: float + + +_oauth_states: dict[str, OAuthState] = {} + class MeResponse(BaseModel): user_id: str + email: str + display_name: str role: str cohort_ids: list[str] +class AuthConfigResponse(BaseModel): + google_oauth_configured: bool + allowed_email_domains: list[str] + redirect_uri: str + dev_login_enabled: bool + + +class DevLoginRequest(BaseModel): + email: str + role: Literal["learner", "teacher", "admin"] = "learner" + display_name: str | None = None + + +def _normalize_domain(domain: str | None) -> str: + return (domain or "").strip().lower().lstrip("@") + + +def _normalize_email(email: str | None) -> str: + return (email or "").strip().lower() + + +def _email_domain(email: str | None) -> str: + value = _normalize_email(email) + if "@" not in value: + return "" + return value.rsplit("@", 1)[1] + + +def _normalize_email_set(values: list[str]) -> set[str]: + return {email for value in values if (email := _normalize_email(value))} + + +def allowed_email_domains() -> set[str]: + """Configured login email domains, normalized for claim checks.""" + return { + normalized + for domain in settings.auth_allowed_email_domains + if (normalized := _normalize_domain(domain)) + } + + +def validate_google_identity_domain( + *, + email: str | None, + email_verified: bool, + hosted_domain: str | None = None, +) -> str: + """Reject Google identities outside the allowed email domain list. + + Google Console authorized domains protect app/redirect domains, not user + email domains. After id_token signature/audience/issuer validation, call + this check with the `email`, `email_verified`, and optional `hd` claims. + """ + normalized_email = _normalize_email(email) + domain = _email_domain(normalized_email) + allowed = allowed_email_domains() + + if not allowed: + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="allowed email domains are not configured", + ) + if not normalized_email or not domain: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="email claim is required") + if not email_verified: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="email is not verified") + if domain not in allowed: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="email domain is not allowed") + + hd = _normalize_domain(hosted_domain) + if hd and hd not in allowed: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="hosted domain is not allowed") + + return normalized_email + + +def _role_for_email(email: str) -> Role: + normalized = _normalize_email(email) + if normalized in _normalize_email_set(settings.auth_admin_emails): + return Role.ADMIN + if normalized in _normalize_email_set(settings.auth_teacher_emails): + return Role.TEACHER + return Role.LEARNER + + +def _safe_next_path(next_path: str | None) -> str: + if not next_path or not next_path.startswith("/") or next_path.startswith("//"): + return "/" + return next_path + + +def _url_origin(value: str | None) -> str | None: + if not value: + return None + parsed = urlsplit(value) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + return None + return f"{parsed.scheme}://{parsed.netloc}".rstrip("/") + + +def _is_local_origin(origin: str) -> bool: + host = urlsplit(origin).hostname or "" + return host in {"localhost", "127.0.0.1", "::1"} + + +def _configured_frontend_origins() -> list[str]: + origins: list[str] = [] + for value in [settings.frontend_base_url, *settings.cors_origins]: + origin = _url_origin(value) + if origin and origin not in origins: + origins.append(origin) + return origins + + +def _frontend_origin_for_request(request: Request | None = None) -> str: + origins = _configured_frontend_origins() + fallback = (_url_origin(settings.frontend_base_url) or "http://localhost:5173").rstrip("/") + + if request is not None: + for header_name in ("origin", "referer"): + candidate = _url_origin(request.headers.get(header_name)) + if candidate in origins: + return candidate + + forwarded_host = request.headers.get("x-forwarded-host") + host = (forwarded_host or request.headers.get("host") or "").split(",", 1)[0].strip() + hostname = host.rsplit(":", 1)[0].lower() if host else "" + if hostname == "api-vignette.chanpaca.net": + return "https://vignette.chanpaca.net" + if hostname in {"localhost", "127.0.0.1", "::1"}: + return fallback + + if not _is_local_origin(fallback): + return fallback + + for origin in origins: + hostname = (urlsplit(origin).hostname or "").lower() + if not _is_local_origin(origin) and hostname != "api-vignette.chanpaca.net": + return origin + return fallback + + +def _frontend_url(path: str, request: Request | None = None) -> str: + base = _frontend_origin_for_request(request) + return f"{base}{_safe_next_path(path)}" + + +def _pkce_challenge(verifier: str) -> str: + digest = hashlib.sha256(verifier.encode("ascii")).digest() + return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") + + +def _prune_oauth_states() -> None: + cutoff = time.time() - OAUTH_STATE_TTL_SECONDS + stale = [key for key, value in _oauth_states.items() if value.created_at < cutoff] + for key in stale: + _oauth_states.pop(key, None) + + +def _cookie_secure() -> bool: + # The __Host- prefix requires Secure, Path=/, and no Domain. Modern Chrome + # accepts Secure cookies on localhost, which keeps dev and prod semantics + # aligned. + return settings.is_prod or settings.cookie_name.startswith("__Host-") + + +def _set_session_cookie(response: Response, sid: str) -> None: + response.set_cookie( + key=settings.cookie_name, + value=sid, + max_age=settings.session_ttl_seconds, + httponly=True, + secure=_cookie_secure(), + samesite="lax", + path="/", + ) + if settings.environment == "dev": + response.set_cookie( + key="vignette_sid", + value=sid, + max_age=settings.session_ttl_seconds, + httponly=True, + secure=False, + samesite="lax", + path="/", + ) + + +def _delete_session_cookie(response: Response) -> None: + response.delete_cookie( + settings.cookie_name, + httponly=True, + secure=_cookie_secure(), + samesite="lax", + path="/", + ) + if settings.environment == "dev": + response.delete_cookie( + "vignette_sid", + httponly=True, + secure=False, + samesite="lax", + path="/", + ) + + +def _me_response(user: SessionUser | Principal) -> MeResponse: + return MeResponse( + user_id=user.user_id, + email=getattr(user, "email", ""), + display_name=getattr(user, "display_name", "") or getattr(user, "email", ""), + role=user.role.value if isinstance(user.role, Role) else user.role, + cohort_ids=user.cohort_ids, + ) + + +def _frontend_login_redirect(reason: str, request: Request) -> RedirectResponse: + base_url = _frontend_origin_for_request(request) + return RedirectResponse(f"{base_url}/login?{urlencode({'oauth': reason})}", status_code=302) + + +def _dev_login_available(request: Request) -> bool: + if settings.environment != "dev" or not settings.auth_dev_login_enabled: + return False + + for header_name in ("origin", "referer"): + origin = _url_origin(request.headers.get(header_name)) + if origin and not _is_local_origin(origin): + return False + + forwarded_host = request.headers.get("x-forwarded-host") + host = (forwarded_host or request.headers.get("host") or "").split(",", 1)[0].strip() + origin = _url_origin(f"http://{host}") if host else None + return bool(origin and _is_local_origin(origin)) + + +@router.get("/config", response_model=AuthConfigResponse) +async def auth_config(request: Request) -> AuthConfigResponse: + """Return non-secret login configuration for the browser login screen.""" + return AuthConfigResponse( + google_oauth_configured=bool( + settings.oauth_google_client_id and settings.oauth_google_client_secret + ), + allowed_email_domains=sorted(allowed_email_domains()), + redirect_uri=settings.oauth_redirect_uri, + dev_login_enabled=_dev_login_available(request), + ) + + @router.get("/login") async def login( + request: Request, provider: Annotated[str, Query()] = "google", + next: Annotated[str | None, Query()] = None, ) -> RedirectResponse: - """OAuth Auth Code + PKCE 시작 (BFF). - - 절차: - 1. code_verifier 생성 -> S256 code_challenge - 2. state(CSRF) + verifier 를 서버 세션(Redis)에 저장 - 3. provider authorize URL 로 302 (Google OIDC 1차) - TODO: Authlib provider 추상화 + Redis state 저장. 현재 스텁 501. - """ + """Start Google OIDC authorization code + PKCE login.""" if provider != "google": - # 한신대 SSO 는 2차 (R11) - raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail=f"provider {provider} not yet supported") - raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail="OAuth login TODO (Phase 2 트랙 B)") + return _frontend_login_redirect("unsupported_provider", request) + if not settings.oauth_google_client_id or not settings.oauth_google_client_secret: + return _frontend_login_redirect("not_configured", request) + + _prune_oauth_states() + state = secrets.token_urlsafe(32) + verifier = secrets.token_urlsafe(64) + _oauth_states[state] = OAuthState( + code_verifier=verifier, + next_path=_safe_next_path(next), + created_at=time.time(), + ) + + params = { + "client_id": settings.oauth_google_client_id, + "redirect_uri": settings.oauth_redirect_uri, + "response_type": "code", + "scope": "openid email profile", + "state": state, + "code_challenge": _pkce_challenge(verifier), + "code_challenge_method": "S256", + "prompt": "select_account", + } + return RedirectResponse(f"{GOOGLE_AUTHORIZE_URL}?{urlencode(params)}", status_code=302) @router.get("/callback") async def callback( - response: Response, + request: Request, code: Annotated[Optional[str], Query()] = None, state: Annotated[Optional[str], Query()] = None, ) -> RedirectResponse: - """OAuth 콜백 — code -> token 교환 후 서버 세션 발급. + """Exchange Google auth code, validate identity, and issue a BFF cookie.""" + if not code or not state: + return _frontend_login_redirect("missing_callback", request) - 절차: - 1. state 검증 (Redis 의 저장값과 대조, CSRF) - 2. code + code_verifier 로 token 교환 (PKCE) - 3. id_token 검증 -> user upsert -> role/cohort 매핑 - 4. Redis 세션 생성 -> __Host- HttpOnly Secure SameSite=Lax 쿠키 set - 5. IRB 동의 미이행 시 동의 게이트로 리다이렉트 (마스터플랜 §7) - TODO: 전체 교환 구현. 현재 스텁 501. + _prune_oauth_states() + stored = _oauth_states.pop(state, None) + if stored is None: + return _frontend_login_redirect("invalid_state", request) + + async with httpx.AsyncClient(timeout=10.0) as client: + token_res = await client.post( + GOOGLE_TOKEN_URL, + data={ + "client_id": settings.oauth_google_client_id, + "client_secret": settings.oauth_google_client_secret, + "code": code, + "grant_type": "authorization_code", + "redirect_uri": settings.oauth_redirect_uri, + "code_verifier": stored.code_verifier, + }, + headers={"Accept": "application/json"}, + ) + if token_res.status_code >= 400: + return _frontend_login_redirect("token_exchange_failed", request) + token_payload = token_res.json() + id_token = token_payload.get("id_token") + if not isinstance(id_token, str) or not id_token: + return _frontend_login_redirect("id_token_missing", request) + + info_res = await client.get(GOOGLE_TOKENINFO_URL, params={"id_token": id_token}) + if info_res.status_code >= 400: + return _frontend_login_redirect("id_token_invalid", request) + claims = info_res.json() + + if claims.get("aud") != settings.oauth_google_client_id: + return _frontend_login_redirect("audience_mismatch", request) + issuer = claims.get("iss") + if issuer not in {"accounts.google.com", "https://accounts.google.com"}: + return _frontend_login_redirect("issuer_mismatch", request) + + try: + email = validate_google_identity_domain( + email=claims.get("email"), + email_verified=claims.get("email_verified") in {True, "true", "True", "1", 1}, + hosted_domain=claims.get("hd"), + ) + except HTTPException: + return _frontend_login_redirect("domain_not_allowed", request) + role = _role_for_email(email) + display_name = str(claims.get("name") or email) + try: + sid, _ = await create_session( + email=email, + display_name=display_name, + role=role.value, + cohort_ids=[], + ) + except InactiveUserError as exc: + return _frontend_login_redirect("inactive_user", request) + + response = RedirectResponse(_frontend_url(stored.next_path, request), status_code=302) + _set_session_cookie(response, sid) + return response + + +@router.post("/dev-login", response_model=MeResponse) +async def dev_login(request: Request, body: DevLoginRequest, response: Response) -> MeResponse: + """Dev-only server login for local E2E and manual testing. + + This is not a browser-side auth shortcut: the role is stored server-side and + the browser only gets the same opaque HttpOnly cookie used by OAuth. """ - raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail="OAuth callback TODO (Phase 2 트랙 B)") + if not _dev_login_available(request): + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="dev login is disabled") + + email = validate_google_identity_domain( + email=str(body.email), + email_verified=True, + hosted_domain=_email_domain(str(body.email)), + ) + try: + sid, user = await create_session( + email=email, + display_name=body.display_name or email, + role=body.role, + cohort_ids=[], + ) + except InactiveUserError as exc: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="user is inactive") from exc + _set_session_cookie(response, sid) + return _me_response(user) @router.post("/logout") -async def logout(response: Response) -> dict[str, bool]: - """세션 무효화 (Redis 삭제 + 쿠키 만료). IRB 철회 즉시 무효화 경로 겸용. - - TODO: Redis 세션 삭제. 현재 쿠키 만료만. - """ - response.delete_cookie(settings.cookie_name, httponly=True, secure=settings.is_prod, samesite="lax") +async def logout( + response: Response, + session_cookie: Annotated[Optional[str], Cookie(alias="__Host-vignette_sid")] = None, + dev_session_cookie: Annotated[Optional[str], Cookie(alias="vignette_sid")] = None, +) -> dict[str, bool]: + """Revoke the current server session and expire the browser cookie.""" + await revoke_session(session_cookie or (dev_session_cookie if settings.environment == "dev" else None)) + _delete_session_cookie(response) return {"ok": True} @router.get("/me", response_model=MeResponse) async def me(principal: CurrentPrincipal) -> MeResponse: - """현재 세션 주체 (프론트 부트스트랩용). 미인증이면 deps 에서 401.""" - return MeResponse( - user_id=principal.user_id, - role=principal.role.value, - cohort_ids=principal.cohort_ids, - ) + """Return the current authenticated user. Unauthenticated requests are 401.""" + return _me_response(principal) diff --git a/apps/api/app/routes/eval.py b/apps/api/app/routes/eval.py index 49dedf6..7aa96cc 100644 --- a/apps/api/app/routes/eval.py +++ b/apps/api/app/routes/eval.py @@ -11,8 +11,8 @@ services/evaluator.py 의 2-loop 평가(fast/deep)를 교수자(TEACHER)·관리 POST /eval/sessions/{id}/reevaluate — 회기 deep-loop 재평가 트리거(전체 축어록) GET /eval/sessions/{id}/evaluation — 회기 평가 조회(분포 + 최근 deep 결과) -DB(feedback_scores/supervisor_comment) SoR 적재는 Phase 2. 현재는 in-proc store + 엔진 직접 호출 -(degraded). DB 가 붙으면 조회 경로를 turns.evaluation / supervisor_comment 조인으로 교체한다. +평가 결과는 session_persistence 의 DB-backed evaluation 저장소를 사용한다. DB 미가용 시 +in-proc cache/session fallback 은 local dev 에서만 허용한다. """ from __future__ import annotations @@ -22,10 +22,13 @@ from typing import Annotated, Any, Optional from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field +from .. import session_persistence from ..deps import Principal, Role, require_role from ..engine_client import EngineError, engine_client +from ..runtime_policy import runtime_fallback_allowed from ..services import evaluator from ..services.evaluator import SessionEvaluation, TurnEvaluation +from ..store import InProcSession from ..store import store router = APIRouter(prefix="/eval", tags=["eval"]) @@ -52,14 +55,12 @@ class EvaluationSummary(BaseModel): distribution: dict[str, Any] = Field(default_factory=dict) -# ── in-proc 평가 결과 캐시 (DB 적재 전 degraded 보관) ─────────────────────── -# DB 가 붙으면 turns.evaluation / supervisor_comment 로 대체. 지금은 트리거 결과를 보관해 -# 조회 GET 이 재호출 없이 마지막 deep 결과를 돌려주게 한다. -_DEEP_CACHE: dict[str, SessionEvaluation] = {} - - -def _load_session_or_404(session_id: str): - sess = store.get(session_id) +async def _load_session_or_404(session_id: str, principal: Principal) -> InProcSession: + sess = await session_persistence.load_session(session_id, principal, allow_ended=True) + if sess is not None: + store.put(sess) + elif runtime_fallback_allowed(): + sess = store.get(session_id) if sess is None: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="session not found") return sess @@ -90,10 +91,10 @@ async def reevaluate_session( ) -> SessionEvaluation: """회기 전체 deep-loop 재평가(슈퍼바이저 rationale/critique + 개선점 + 대안발화). - in-proc store 의 마스킹 축어록을 evaluator.evaluate_session 으로 평가한다. + 저장된 마스킹 축어록을 evaluator.evaluate_session 으로 평가한다. 엔진 장애는 503 으로 변환(평가는 비치명적이지만 트리거는 사용자 명시 요청이라 에러 노출). """ - sess = _load_session_or_404(session_id) + sess = await _load_session_or_404(session_id, principal) masked = sess.masked_turns() # 발화 seq 보강(deep 프롬프트 가독성 — store 가 seq 미포함이라 인덱스로 부여) enriched: list[dict[str, Any]] = [] @@ -121,7 +122,16 @@ async def reevaluate_session( if result.error and result.error.startswith("engine_error"): raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=result.error) - _DEEP_CACHE[session_id] = result + await session_persistence.save_session_evaluation( + session_id=session_id, + learner_id=sess.learner_id, + status="error" if result.error else "ready", + source="engine", + scope=result.scope, + stage=result.stage, + payload=result.to_dict(), + error=result.error, + ) return result @@ -136,10 +146,10 @@ async def reevaluate_turn( ) -> TurnEvaluation: """단일 상담자 발화 fast-loop 재평가(기법/내담자상태/적절성/의도이탈). - store 의 축어록에서 해당 turn_seq 상담자 발화 + 직후 내담자 응답을 재구성해 + 저장된 축어록에서 해당 turn_seq 상담자 발화 + 직후 내담자 응답을 재구성해 경량 TurnContext 로 evaluator.evaluate_turn 을 호출한다. """ - sess = _load_session_or_404(session_id) + sess = await _load_session_or_404(session_id, principal) # 대상 상담자 발화 + 직후 내담자 응답 찾기 target_idx: Optional[int] = None @@ -188,18 +198,20 @@ async def get_session_evaluation( session_id: str, principal: TeacherOrAdmin, ) -> EvaluationSummary: - """회기 평가 조회(읽기) — 마지막 deep 재평가 결과 + 기법 분포. + """회기 평가 조회(읽기) — 저장된 마지막 deep 재평가 결과 + 기법 분포. - DB 적재 전 degraded: deep 결과는 reevaluate 트리거가 보관한 캐시에서, 분포는 그 결과에서. 아직 평가 트리거가 없었다면 deep=None + 빈 분포. """ - _load_session_or_404(session_id) - cached = _DEEP_CACHE.get(session_id) - if cached is None: + await _load_session_or_404(session_id, principal) + record, _durable = await session_persistence.load_session_evaluation(session_id, principal) + if record is None: return EvaluationSummary(session_id=session_id, stage="", deep=None, distribution={}) + payload = record.get("payload") + deep = payload if isinstance(payload, dict) else {} + distribution = deep.get("distribution") return EvaluationSummary( session_id=session_id, - stage=cached.stage, - deep=cached.to_dict(), - distribution=cached.distribution.model_dump(), + stage=str(record.get("stage") or deep.get("stage") or ""), + deep=deep, + distribution=distribution if isinstance(distribution, dict) else {}, ) diff --git a/apps/api/app/routes/personas.py b/apps/api/app/routes/personas.py new file mode 100644 index 0000000..df8be3f --- /dev/null +++ b/apps/api/app/routes/personas.py @@ -0,0 +1,66 @@ +"""Persona catalog routes.""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, HTTPException, Response, status +from pydantic import BaseModel + +from ..deps import CurrentPrincipal +from ..persona_repository import CatalogPersona, list_catalog_personas + +router = APIRouter(prefix="/personas", tags=["personas"]) + + +class PersonaSummary(BaseModel): + code: str + display_name: str + difficulty: str + theory_target: list[str] + demographics: dict[str, Any] + presenting_summary: str + voice_preset: str | None = None + source: str = "database" + degraded: bool = False + + +def _first_text_value(data: dict[str, Any]) -> str: + for value in data.values(): + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + + +def _summary(entry: CatalogPersona) -> PersonaSummary: + card = entry.card + return PersonaSummary( + code=card.code, + display_name=card.display_name, + difficulty=card.difficulty, + theory_target=card.theory_target, + demographics=card.demographics, + presenting_summary=_first_text_value(card.presenting), + source=entry.source, + degraded=entry.degraded, + ) + + +@router.get("", response_model=list[PersonaSummary]) +async def list_personas(response: Response, _principal: CurrentPrincipal) -> list[PersonaSummary]: + """Return latest approved personas from app.persona_card.""" + try: + personas = await list_catalog_personas() + except Exception as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + detail="persona catalog database unavailable", + ) from exc + + if any(entry.degraded for entry in personas): + response.headers["X-Vignette-Degraded"] = "true" + response.headers["X-Vignette-Catalog-Source"] = "seed_fallback" + else: + response.headers["X-Vignette-Catalog-Source"] = "database" + + return [_summary(entry) for entry in personas] diff --git a/apps/api/app/routes/sessions.py b/apps/api/app/routes/sessions.py index 89804e6..771fe7f 100644 --- a/apps/api/app/routes/sessions.py +++ b/apps/api/app/routes/sessions.py @@ -1,82 +1,59 @@ -"""상담 세션 라우트 — 시작 / 턴 / 스트림 / 종료 (services 실호출). +"""Counseling session routes. -흐름 (설계서 §2 회기 라이프사이클 + 마스터플랜 §2.2 턴 사이클): - POST /sessions — 회기 시작 (페르소나 핀 + 회상 + 상태머신 init) - POST /sessions/{id}/turn — 수련생 발화 1턴 (가드레일→상태머신→내담자AI→출력가드) - GET /sessions/{id}/stream — 내담자 AI 응답 SSE 스트림 (heartbeat 포함) - POST /sessions/{id}/end — 회기 종료 (무손실 carry-over + 압축 트리거) - -DB(NAS Postgres)가 SoR 이지만 Docker off 에서도 엔진만 떠 있으면 1턴이 돌도록 -**store(in-memory)** 폴백을 둔다(degraded). 인증도 dev 폴백을 허용한다(개발 편의). - -상태머신(라포→탐색→개입→정리)은 백엔드가 결정론적으로 소유(LLM 아님, 마스터플랜 §0). +The DB-backed source of truth is still pending, so this route uses the existing +in-process session store when DB is degraded. Unlike the previous dev fallback, +all browser calls now require a verified server-side auth session and every +session operation checks learner ownership. """ from __future__ import annotations import asyncio import json -from typing import Annotated, Literal, Optional +from collections import Counter +from datetime import datetime +from typing import Literal, Optional -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, HTTPException, status from pydantic import BaseModel, Field from sse_starlette.sse import EventSourceResponse -from fastapi import Cookie - +from .. import session_persistence from ..config import settings -from ..deps import Principal, Role +from ..deps import CurrentPrincipal, Principal, Role from ..engine_client import EngineError, engine_client -from ..services import memory, orchestrator, persona, state_machine -from ..store import TurnRecord, store +from ..persona_repository import get_catalog_persona +from ..runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed +from ..services import evaluator, memory, orchestrator, state_machine +from ..store import InProcSession, TurnRecord, store router = APIRouter(prefix="/sessions", tags=["sessions"]) -StageLiteral = Literal["라포", "탐색", "개입", "정리"] +TheoryMode = Literal["humanistic", "cbt", "integrative"] -# ── 인증 — dev 폴백 허용 (쿠키 없으면 dev learner) ─────────────────────────── -async def get_principal_dev( - session_cookie: Annotated[Optional[str], Cookie(alias="__Host-vignette_sid")] = None, -) -> Principal: - """세션 쿠키 → Principal. 미인증(쿠키 없음)이면 dev learner 폴백. - - 개발/시연(쿠키 없음, DB off)에서도 상담 루프가 돌게 한다. - prod 에선 auth.py BFF + Redis 세션이 완성되면 deps.get_current_principal 로 교체. - TODO: Redis 세션 룩업으로 user_id/role/cohort 복원. - """ - if not session_cookie: - return Principal(user_id="dev-learner", role=Role.LEARNER, cohort_ids=[]) - # TODO: Redis 세션 검증. 현재는 쿠키 존재만으로 dev learner. - return Principal(user_id="dev-user", role=Role.LEARNER, cohort_ids=[]) - - -DevPrincipal = Annotated[Principal, Depends(get_principal_dev)] - - -# ── 요청/응답 모델 ────────────────────────────────────── class SessionStartRequest(BaseModel): - persona_code: str = Field(..., examples=["P1"]) # 시드 페르소나 (P1/P2/P3) - theory_mode: Literal["humanistic", "cbt", "integrative"] = "humanistic" + persona_code: str = Field(..., examples=["P1"]) + theory_mode: TheoryMode = "humanistic" class SessionStartResponse(BaseModel): session_id: str case_id: str session_no: int - stage: StageLiteral + stage: str effective_openness: float recall_summary: Optional[str] = None - degraded: bool = False # DB 미가용 in-proc 모드 여부(시연 투명성) + degraded: bool = False class TurnRequest(BaseModel): - text: str = Field(..., min_length=1) # 수련생 발화 (저장 전 PII 마스킹) + text: str = Field(..., min_length=1) class TurnResponse(BaseModel): turn_seq: int - stage: StageLiteral + stage: str effective_openness: float client_reply: Optional[str] = None safety_flagged: bool = False @@ -86,33 +63,591 @@ class TurnResponse(BaseModel): class SessionEndResponse(BaseModel): session_id: str session_no: int - digest_pending: bool # 압축은 비동기 비블로킹 (설계서 §2-C) + digest_pending: bool end_state: dict -# ════════════════════════════════════════════════════════════════════════════ -# 회기 시작 -# ════════════════════════════════════════════════════════════════════════════ +class LearnerSessionSummary(BaseModel): + session_id: str + persona_code: str + persona_name: str + session_no: int + status: Literal["active", "ended"] + stage: str + turn_count: int + learner_turn_count: int + client_turn_count: int + started_at: str + ended_at: str | None = None + review_ready: bool = False + + +class LearnerSessionsResponse(BaseModel): + source: str = "runtime" + sessions: list[LearnerSessionSummary] = Field(default_factory=list) + + +class SessionDetailTurn(BaseModel): + turn_seq: int + speaker: Literal["learner", "client"] + stage: str + text: str + created_at: str + + +class SessionDetailResponse(BaseModel): + session_id: str + case_id: str + persona_code: str + persona_name: str + theory_mode: str + status: Literal["active", "ended"] + stage: str + effective_openness: float + started_at: str + ended_at: str | None = None + turns: list[SessionDetailTurn] = Field(default_factory=list) + review_ready: bool = False + + +class ReviewClient(BaseModel): + name: str + initial: str + persona: str + + +class ReviewTechnique(BaseModel): + kind: str + label: str + + +class ReviewNote(BaseModel): + author: str + tone: str + title: str + body: str + quote: Optional[str] = None + + +class ReviewTurn(BaseModel): + id: str + ts: str + speaker: Literal["learner", "client"] + who: str + text: str + techniques: list[ReviewTechnique] = Field(default_factory=list) + note: Optional[ReviewNote] = None + + +class ReviewPhaseSegment(BaseModel): + key: str + label: str + weight: float + + +class ReviewValencePoint(BaseModel): + t: float + v: float + + +class ReviewRubricRow(BaseModel): + name: str + cluster: str + ratio: float + quality: Literal["good", "watch"] + freq: str + + +class ReviewPoint(BaseModel): + title: str + body: str + jumpTo: Optional[str] = None + + +class SessionReviewResponse(BaseModel): + session_id: str + client: ReviewClient + date: str + durationLabel: str + durationSeconds: int + reachedPhase: str + sessionSignal: str + supervisorState: str + supervisorName: str + summary: str + phases: list[ReviewPhaseSegment] = Field(default_factory=list) + phaseAxis: list[str] = Field(default_factory=list) + valenceAxis: list[str] = Field(default_factory=list) + clientValence: list[ReviewValencePoint] = Field(default_factory=list) + counselorBaseline: list[ReviewValencePoint] = Field(default_factory=list) + turns: list[ReviewTurn] = Field(default_factory=list) + rubric: list[ReviewRubricRow] = Field(default_factory=list) + goodMoments: list[ReviewPoint] = Field(default_factory=list) + growthPoints: list[ReviewPoint] = Field(default_factory=list) + nextLine: Optional[str] = None + clientFeedback: Optional[str] = None + audioUrl: Optional[str] = None + pdfExportUrl: Optional[str] = None + degraded: bool = True + reviewReady: bool = False + + +_RECALL_CACHE: dict[str, memory.RecallContext] = {} + +_PHASE_KEY_BY_LABEL = { + "라포": "rapport", + "탐색": "explore", + "개입": "intervene", + "정리": "closing", +} + + +def _stage_label(stage: object) -> str: + name = getattr(stage, "name", "") + return { + "RAPPORT": "라포", + "EXPLORE": "탐색", + "INTERVENE": "개입", + "CLOSE": "정리", + }.get(name, str(getattr(stage, "value", stage))) + + +def _ensure_learner(principal: Principal) -> None: + if principal.role != Role.LEARNER: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="only learners can use sessions") + + +async def _load_session_or_404( + session_id: str, + principal: Principal, + *, + allow_ended: bool = False, +) -> InProcSession: + sess = await session_persistence.load_session(session_id, principal, allow_ended=True) + if sess is not None: + store.put(sess) + elif runtime_fallback_allowed(): + sess = store.get(session_id) + if sess is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="session not found") + if sess.learner_id != principal.user_id: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="session does not belong to user") + if sess.ended and not allow_ended: + raise HTTPException(status.HTTP_409_CONFLICT, detail="session already ended") + return sess + + +async def _append_session_turn(sess: InProcSession, turn: TurnRecord) -> None: + if await session_persistence.append_turn( + session_id=sess.session_id, + learner_id=sess.learner_id, + turn=turn, + ): + sess.turns.append(turn) + store.put(sess) + return + require_runtime_fallback_allowed("session turn append") + store.append_turn(sess.session_id, turn) + + +async def _update_session_state( + sess: InProcSession, + state: state_machine.SessionState, +) -> None: + if await session_persistence.update_state( + session_id=sess.session_id, + learner_id=sess.learner_id, + state=state, + ): + sess.state = state + store.put(sess) + return + require_runtime_fallback_allowed("session state update") + store.update_state(sess.session_id, state) + + +async def _end_persisted_session(sess: InProcSession, carry: memory.CarryOver) -> None: + if await session_persistence.end_session(sess, carry): + sess.ended = True + sess.ended_at = datetime.now().timestamp() + store.put(sess) + return + require_runtime_fallback_allowed("session end") + store.end(sess.session_id) + + +def _offset_label(seconds: float) -> str: + whole = max(0, int(round(seconds))) + minutes, sec = divmod(whole, 60) + return f"{minutes}:{sec:02d}" + + +def _iso(ts: float | None) -> str | None: + if ts is None: + return None + return datetime.fromtimestamp(ts).isoformat(timespec="seconds") + + +def _duration_label(seconds: int) -> str: + if seconds < 60: + return f"{seconds}초" + minutes, sec = divmod(seconds, 60) + return f"{minutes}분 {sec}초" + + +def _client_name(raw: str) -> str: + name = raw.split("(", 1)[0].strip() + return name or raw.strip() or "내담자" + + +def _review_summary(*, client_name: str, reached_phase: str, turns: list[ReviewTurn]) -> str: + if not turns: + return ( + "아직 실제 발화가 없어 리뷰를 만들 수 없습니다. 회기를 진행한 뒤 종료하면 " + "저장된 축어록을 기준으로 리뷰가 표시됩니다." + ) + learner_count = sum(1 for turn in turns if turn.speaker == "learner") + client_count = sum(1 for turn in turns if turn.speaker == "client") + return ( + f"이 리뷰는 현재 세션에 저장된 실제 축어록 {len(turns)}개를 기반으로 합니다. " + f"{client_name}와의 회기는 {reached_phase} 단계까지 진행되었고, " + f"학습자 발화 {learner_count}개와 내담자 응답 {client_count}개가 기록되었습니다. " + "평가 AI 또는 교수자 코멘트가 아직 생성되지 않은 항목은 빈 상태로 남겨 둡니다." + ) + + +def _phase_segments(stage_labels: list[str]) -> list[ReviewPhaseSegment]: + counts = Counter(stage_labels) + return [ + ReviewPhaseSegment( + key=_PHASE_KEY_BY_LABEL.get(label, label), + label=label, + weight=float(count), + ) + for label, count in counts.items() + if count > 0 + ] + + +def _clamp_ratio(value: float) -> float: + return round(max(0.0, min(1.0, value)), 3) + + +def _compact_text(text: str) -> str: + return " ".join(text.split()) + + +def _clip_text(text: str, limit: int = 180) -> str: + compact = _compact_text(text) + if len(compact) <= limit: + return compact + return f"{compact[: max(0, limit - 1)].rstrip()}..." + + +def _point_title(text: str, fallback: str) -> str: + compact = _clip_text(text, 72) + for sep in (".", "。", "!", "?", "\n"): + if sep in compact: + first = compact.split(sep, 1)[0].strip() + if first: + return _clip_text(first, 44) + return _clip_text(compact, 44) or fallback + + +def _ai_review_points(values: object, *, fallback_prefix: str) -> list[ReviewPoint]: + if not isinstance(values, list): + return [] + points: list[ReviewPoint] = [] + for index, value in enumerate(values, start=1): + body = _compact_text(str(value or "")) + if not body: + continue + points.append( + ReviewPoint( + title=_point_title(body, f"{fallback_prefix} {index}"), + body=body, + jumpTo=None, + ) + ) + return points[:3] + + +def _intent_deviation_points(values: object) -> list[ReviewPoint]: + if not isinstance(values, list): + return [] + points: list[ReviewPoint] = [] + for index, value in enumerate(values, start=1): + if not isinstance(value, dict): + continue + dimension = _compact_text(str(value.get("dimension") or f"의도 이탈 {index}")) + expected = _compact_text(str(value.get("expected") or "")) + actual = _compact_text(str(value.get("actual") or "")) + severity = _compact_text(str(value.get("severity") or "minor")) + body_parts = [] + if expected: + body_parts.append(f"기대: {expected}") + if actual: + body_parts.append(f"실제: {actual}") + if severity: + body_parts.append(f"심각도: {severity}") + if body_parts: + points.append( + ReviewPoint( + title=dimension, + body=" · ".join(body_parts), + jumpTo=None, + ) + ) + return points[:3] + + +def _rubric_from_evaluation(payload: dict[str, object]) -> list[ReviewRubricRow]: + distribution = payload.get("distribution") + if not isinstance(distribution, dict): + return [] + by_category = distribution.get("by_category") + if not isinstance(by_category, dict): + return [] + total = int(distribution.get("total") or 0) + if total <= 0: + return [] + overused = {str(item) for item in distribution.get("overused") or []} + underused = {str(item) for item in distribution.get("underused") or []} + rows: list[ReviewRubricRow] = [] + for category, raw_count in sorted(by_category.items(), key=lambda item: str(item[0])): + try: + count = int(raw_count) + except (TypeError, ValueError): + continue + code = str(category) + watch = code in overused or code in underused + rows.append( + ReviewRubricRow( + name=code.replace("_", " ").title(), + cluster="평가 AI 기법 분포", + ratio=_clamp_ratio(count / max(1, total)), + quality="watch" if watch else "good", + freq=f"{count}/{total} labels", + ) + ) + return rows + + +def _review_summary_from_evaluation( + *, + fallback: str, + evaluation_record: dict[str, object] | None, + payload: dict[str, object], +) -> str: + if not evaluation_record: + return fallback + status = str(evaluation_record.get("status") or "") + if status != "ready": + error = _compact_text(str(evaluation_record.get("error") or payload.get("error") or "")) + return ( + "저장된 축어록은 확인했지만 평가 AI 산출물이 아직 준비되지 않았습니다. " + + (f"사유: {error}" if error else "평가가 완료되면 코칭 항목이 갱신됩니다.") + ) + rationale = _compact_text(str(payload.get("supervisor_rationale") or "")) + critique = _compact_text(str(payload.get("supervisor_critique") or "")) + evaluated = payload.get("turns_evaluated") + prefix = f"평가 AI가 학습자 발화 {evaluated}개를 deep-loop로 분석했습니다. " + details = " ".join(part for part in [rationale, critique] if part) + return prefix + (details if details else "아래 코칭 항목은 저장된 축어록과 평가 AI 결과를 기준으로 합니다.") + + +def _next_line_from_evaluation(payload: dict[str, object]) -> str | None: + alternatives = payload.get("alternative_utterances") + if not isinstance(alternatives, list): + return None + for value in alternatives: + line = _compact_text(str(value or "")) + if line: + return line + return None + + +def _latest_client_feedback(turns: list[ReviewTurn]) -> str | None: + for turn in reversed(turns): + if turn.speaker == "client": + return _clip_text(turn.text) + return None + + +def _evaluation_payload(record: dict[str, object] | None) -> dict[str, object]: + if not record: + return {} + payload = record.get("payload") + return payload if isinstance(payload, dict) else {} + + +async def _generate_and_save_session_evaluation(sess: InProcSession) -> None: + if not sess.turns: + return + + enriched: list[dict[str, object]] = [] + for index, turn in enumerate(sess.masked_turns(), start=1): + item: dict[str, object] = dict(turn) + item["seq"] = index + enriched.append(item) + + try: + result = await asyncio.wait_for( + evaluator.evaluate_session( + session_id=sess.session_id, + stage=_stage_label(sess.state.stage), + masked_turns=enriched, + engine=engine_client, + technique_codes=[], + theory_mode=sess.theory_mode, + scope="session_end", + ), + timeout=min(float(settings.engine_timeout), 45.0), + ) + status_value = "error" if result.error else "ready" + await session_persistence.save_session_evaluation( + session_id=sess.session_id, + learner_id=sess.learner_id, + status=status_value, + source="engine", + scope=result.scope, + stage=result.stage, + payload=result.to_dict(), + error=result.error, + ) + except Exception as exc: + await session_persistence.save_session_evaluation( + session_id=sess.session_id, + learner_id=sess.learner_id, + status="error", + source="engine", + scope="session_end", + stage=_stage_label(sess.state.stage), + payload={}, + error=str(exc), + ) + + +def _schedule_session_evaluation(sess: InProcSession) -> None: + if not sess.turns: + return + asyncio.create_task(_generate_and_save_session_evaluation(sess)) + + +def _learner_summary(sess: InProcSession, *, review_ready: bool = False) -> LearnerSessionSummary: + learner_turns = sum(1 for turn in sess.turns if turn.speaker == "counselor") + client_turns = sum(1 for turn in sess.turns if turn.speaker == "client") + return LearnerSessionSummary( + session_id=sess.session_id, + persona_code=sess.persona_code, + persona_name=sess.persona.display_name, + session_no=sess.session_no, + status="ended" if sess.ended else "active", + stage=_stage_label(sess.state.stage), + turn_count=len(sess.turns), + learner_turn_count=learner_turns, + client_turn_count=client_turns, + started_at=_iso(sess.created_at) or "", + ended_at=_iso(sess.ended_at), + review_ready=review_ready, + ) + + +async def _review_ready(sess: InProcSession, principal: Principal) -> bool: + if not sess.ended or not sess.turns: + return False + evaluation_record, _ = await session_persistence.load_session_evaluation( + sess.session_id, + principal, + ) + return bool(evaluation_record and evaluation_record.get("status") == "ready") + + +def _session_detail( + sess: InProcSession, + *, + review_ready: bool = False, +) -> SessionDetailResponse: + return SessionDetailResponse( + session_id=sess.session_id, + case_id=sess.case_id, + persona_code=sess.persona_code, + persona_name=sess.persona.display_name, + theory_mode=sess.theory_mode, + status="ended" if sess.ended else "active", + stage=_stage_label(sess.state.stage), + effective_openness=round(sess.state.effective_openness, 4), + started_at=_iso(sess.created_at) or "", + ended_at=_iso(sess.ended_at), + turns=[ + SessionDetailTurn( + turn_seq=turn.turn_seq, + speaker="learner" if turn.speaker == "counselor" else "client", + stage=turn.stage, + text=turn.text_masked, + created_at=_iso(turn.created_at) or "", + ) + for turn in sess.turns + ], + review_ready=review_ready, + ) + + +@router.get("", response_model=LearnerSessionsResponse) +async def list_learner_sessions(principal: CurrentPrincipal) -> LearnerSessionsResponse: + """Return the current learner's real practice sessions.""" + _ensure_learner(principal) + sessions, durable = await session_persistence.list_sessions(principal) + if not durable: + require_runtime_fallback_allowed("session list") + sessions = [ + sess + for sess in store.list() + if sess.learner_id == principal.user_id + ] + sessions.sort(key=lambda sess: sess.created_at, reverse=True) + + summaries: list[LearnerSessionSummary] = [] + for sess in sessions[:20]: + summaries.append(_learner_summary(sess, review_ready=await _review_ready(sess, principal))) + + return LearnerSessionsResponse( + source="database" if durable else "runtime", + sessions=summaries, + ) + + +@router.get("/{session_id}", response_model=SessionDetailResponse) +async def get_session_detail( + session_id: str, + principal: CurrentPrincipal, +) -> SessionDetailResponse: + """Return a learner-owned session with transcript for resume/history.""" + _ensure_learner(principal) + sess = await _load_session_or_404(session_id, principal, allow_ended=True) + return _session_detail(sess, review_ready=await _review_ready(sess, principal)) + + @router.post("", response_model=SessionStartResponse, status_code=status.HTTP_201_CREATED) async def start_session( body: SessionStartRequest, - principal: DevPrincipal, + principal: CurrentPrincipal, ) -> SessionStartResponse: - """회기 시작 — 페르소나 핀 + 회상 + 결정론 상태 init (설계서 §2-A). + """Start a learner-owned practice session.""" + _ensure_learner(principal) - DB 가용 시: persona_card(approved) 조회 + case_profile/직전 summary 회상. - DB 미가용(degraded): 시드 페르소나(persona.SEED) + 빈 회상(첫 회기)으로 in-proc. - """ - card = persona.get_seed_persona(body.persona_code) - if card is None: - # TODO: DB app.persona_card WHERE code=$1 AND status='approved' 조회 경로 + try: + catalog_persona = await get_catalog_persona(body.persona_code) + except Exception as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + detail="persona catalog database unavailable", + ) from exc + if catalog_persona is None: raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"unknown persona {body.persona_code}") + card = catalog_persona.card - # 회상 — DB/RAG 미가용 시 빈 컨텍스트(첫 회기). 가용 시 case_digest/summary/episodic 주입. - # TODO(Phase 2a): memory.build_recall_context(case_digest=..., prev_summary=..., episodic_snippets=...) recall = memory.build_recall_context() - - # 결정론 상태 init (carry-over 가 있으면 이월; 첫 회기는 None) st = state_machine.init_state( base_resistance=card.base_resistance(), unlock_rate=card.unlock_rate(), @@ -121,56 +656,181 @@ async def start_session( carry=recall.carry, ) - sess = store.create( + carry_rapport = st.rapport_credit + sess = await session_persistence.create_session( learner_id=principal.user_id, - persona=card, + card=card, theory_mode=body.theory_mode, state=st, session_no=1, + carry_rapport=carry_rapport, + persona_id=catalog_persona.persona_id, + persona_version=catalog_persona.version, ) - # 회상 핀(pinned facts)을 세션에 묶어 둔다(턴마다 재조립). store 는 간단히 state 만 보유하므로 - # recall_summary/pinned 는 in-proc 캐시로 별도 보관. + degraded = catalog_persona.degraded or sess is None + if sess is None: + require_runtime_fallback_allowed("session creation") + sess = store.create( + learner_id=principal.user_id, + persona=card, + theory_mode=body.theory_mode, + state=st, + session_no=1, + carry_rapport=carry_rapport, + ) + else: + store.put(sess) _RECALL_CACHE[sess.session_id] = recall return SessionStartResponse( session_id=sess.session_id, case_id=sess.case_id, session_no=sess.session_no, - stage=st.stage.value, # type: ignore[arg-type] + stage=_stage_label(st.stage), effective_openness=round(st.effective_openness, 4), recall_summary=recall.recall_summary, - degraded=True, # 현재 in-proc 경로(DB 붙으면 False 분기) + degraded=degraded, ) -# 회상 컨텍스트 in-proc 캐시 (회기 내 재사용, recall_context). DB 붙으면 session_state.recall_context. -_RECALL_CACHE: dict[str, memory.RecallContext] = {} +@router.get("/{session_id}/review", response_model=SessionReviewResponse) +async def get_session_review( + session_id: str, + principal: CurrentPrincipal, +) -> SessionReviewResponse: + """Return a learner-safe review built only from the stored session transcript.""" + _ensure_learner(principal) + sess = await _load_session_or_404(session_id, principal, allow_ended=True) + + end_ts = sess.ended_at or datetime.now().timestamp() + duration_seconds = max(0, int(round(end_ts - sess.created_at))) + client_name = _client_name(sess.persona.display_name) + client_initial = client_name[:1] or "내" + + reached_phase = _stage_label(sess.state.stage) + stage_labels = [turn.stage for turn in sess.turns] or [reached_phase] + axis = ["0:00"] + if duration_seconds > 0: + axis.append(_offset_label(duration_seconds)) + + evaluation_record, evaluation_durable = await session_persistence.load_session_evaluation( + session_id, + principal, + ) + evaluation_payload = _evaluation_payload(evaluation_record) + evaluation_status = str(evaluation_record.get("status") or "") if evaluation_record else "" + evaluation_ready = evaluation_status == "ready" + + first_turn_ts = sess.turns[0].created_at if sess.turns else sess.created_at + turns: list[ReviewTurn] = [] + for index, turn in enumerate(sess.turns): + speaker: Literal["learner", "client"] = ( + "learner" if turn.speaker == "counselor" else "client" + ) + turns.append( + ReviewTurn( + id=f"t{index + 1}", + ts=_offset_label(turn.created_at - first_turn_ts), + speaker=speaker, + who="학습자" if speaker == "learner" else client_name, + text=turn.text_masked, + techniques=[], + note=None, + ) + ) + + if not turns: + session_signal = "기록 없음" + elif sess.ended: + session_signal = "종료됨" + else: + session_signal = "진행 중" + + transcript_summary = _review_summary( + client_name=client_name, + reached_phase=reached_phase, + turns=turns, + ) + + rubric: list[ReviewRubricRow] = [] + good_moments: list[ReviewPoint] = [] + growth_points: list[ReviewPoint] = [] + next_line: str | None = None + if evaluation_ready: + rubric = _rubric_from_evaluation(evaluation_payload) + good_moments = _ai_review_points( + evaluation_payload.get("strengths"), + fallback_prefix="강점", + ) + growth_points = _ai_review_points( + evaluation_payload.get("improvements"), + fallback_prefix="개선점", + ) + if not growth_points: + growth_points = _intent_deviation_points(evaluation_payload.get("intent_deviations")) + next_line = _next_line_from_evaluation(evaluation_payload) + + client_feedback = _latest_client_feedback(turns) + review_degraded = bool(turns) and not evaluation_ready + if evaluation_ready: + supervisor_state = "평가 완료" + elif evaluation_status == "error": + supervisor_state = "평가 실패" + elif turns: + supervisor_state = "평가 대기" + else: + supervisor_state = "기록 대기" + + summary = _review_summary_from_evaluation( + fallback=transcript_summary, + evaluation_record=evaluation_record, + payload=evaluation_payload, + ) + if evaluation_record and not evaluation_durable: + summary += " 현재 평가는 런타임 캐시에서 복원되었습니다." + + return SessionReviewResponse( + session_id=session_id, + client=ReviewClient( + name=client_name, + initial=client_initial, + persona=f"{sess.persona_code} · {sess.persona.difficulty}", + ), + date=datetime.fromtimestamp(sess.created_at).strftime("%Y-%m-%d"), + durationLabel=_duration_label(duration_seconds), + durationSeconds=duration_seconds, + reachedPhase=reached_phase, + sessionSignal=session_signal, + supervisorState=supervisor_state, + supervisorName="AI", + summary=summary, + phases=_phase_segments(stage_labels), + phaseAxis=axis, + valenceAxis=axis, + clientValence=[], + counselorBaseline=[], + turns=turns, + rubric=rubric, + goodMoments=good_moments, + growthPoints=growth_points, + nextLine=next_line, + clientFeedback=client_feedback, + audioUrl=None, + pdfExportUrl=None, + degraded=review_degraded, + reviewReady=evaluation_ready, + ) -def _load_session_or_404(session_id: str): - sess = store.get(session_id) - if sess is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, detail="session not found") - if sess.ended: - raise HTTPException(status.HTTP_409_CONFLICT, detail="session already ended") - return sess - - -# ════════════════════════════════════════════════════════════════════════════ -# 턴 (동기 폴백 — 기본 UX 는 /stream) -# ════════════════════════════════════════════════════════════════════════════ @router.post("/{session_id}/turn", response_model=TurnResponse) async def submit_turn( session_id: str, body: TurnRequest, - principal: DevPrincipal, + principal: CurrentPrincipal, ) -> TurnResponse: - """수련생 발화 1턴 (마스터플랜 §2.2 / 설계서 §2-B). - - 오케스트레이터로 1~8단계 결정론 파이프라인 실행. 내담자 응답은 동기로 한 번에 받는다 - (기본 UX 는 GET /stream 토큰 스트리밍; 이 경로는 폴백/테스트). - """ - sess = _load_session_or_404(session_id) + """Submit one trainee utterance and return the generated client reply.""" + _ensure_learner(principal) + sess = await _load_session_or_404(session_id, principal) recall = _RECALL_CACHE.get(session_id) or memory.RecallContext() ctx = orchestrator.prepare_turn( @@ -183,42 +843,43 @@ async def submit_turn( pinned_facts=recall.pinned_facts, recent_turns=sess.recent_turns(), ) - - # 수련생 발화 로깅(② episodic 미러) — 마스킹본 저장 assert ctx.state_after is not None - store.append_turn( - session_id, + + try: + result = await orchestrator.run_turn_generate(ctx, engine_client) + except EngineError as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"engine unavailable: {exc}", + ) from exc + + await _append_session_turn( + sess, TurnRecord( turn_seq=ctx.state_after.turn_seq, speaker="counselor", - stage=ctx.state_after.stage.value, + stage=_stage_label(ctx.state_after.stage), text=body.text, text_masked=ctx.learner_text_masked, ), ) - try: - result = await orchestrator.run_turn_generate(ctx, engine_client) - except EngineError as e: - raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"engine unavailable: {e}") - - # 내담자 응답 로깅 + 상태 체크포인트(① working UPSERT 미러) if result.client_reply: - store.append_turn( - session_id, + await _append_session_turn( + sess, TurnRecord( turn_seq=result.turn_seq, speaker="client", - stage=result.stage, + stage=_stage_label(result.state_after.stage), text=result.client_reply, - text_masked=result.client_reply, # 내담자 응답은 합성(원문PII 없음) + text_masked=result.client_reply, ), ) - store.update_state(session_id, result.state_after) + await _update_session_state(sess, result.state_after) return TurnResponse( turn_seq=result.turn_seq, - stage=result.stage, # type: ignore[arg-type] + stage=_stage_label(result.state_after.stage), effective_openness=round(result.effective_openness, 4), client_reply=result.client_reply, safety_flagged=result.safety_flagged, @@ -226,22 +887,15 @@ async def submit_turn( ) -# ════════════════════════════════════════════════════════════════════════════ -# 스트림 (기본 UX — SSE 토큰) -# ════════════════════════════════════════════════════════════════════════════ @router.post("/{session_id}/stream") async def stream_turn( session_id: str, body: TurnRequest, - principal: DevPrincipal, + principal: CurrentPrincipal, ): - """수련생 발화 1턴을 받아 내담자 AI 응답을 SSE 토큰 스트림으로 흘린다. - - - Cloudflare 100초 timeout 회피: settings.sse_heartbeat_seconds 마다 ping [R2] - - 오케스트레이터 run_turn_stream(가드레일·상태머신·페르소나·출력가드 적용)을 프록시 - - 이벤트: token | ping | safety | done | error - """ - sess = _load_session_or_404(session_id) + """Stream a generated client reply for one trainee utterance.""" + _ensure_learner(principal) + sess = await _load_session_or_404(session_id, principal) recall = _RECALL_CACHE.get(session_id) or memory.RecallContext() ctx = orchestrator.prepare_turn( @@ -256,66 +910,62 @@ async def stream_turn( ) assert ctx.state_after is not None - # 수련생 발화 로깅 + 상태 체크포인트(스트림은 응답 전 상태 갱신 — 결정론이라 무방) - store.append_turn( - session_id, - TurnRecord( - turn_seq=ctx.state_after.turn_seq, - speaker="counselor", - stage=ctx.state_after.stage.value, - text=body.text, - text_masked=ctx.learner_text_masked, - ), - ) - store.update_state(session_id, ctx.state_after) - async def event_generator(): - last_beat = asyncio.get_event_loop().time() + last_beat = asyncio.get_running_loop().time() final_reply = "" try: async for ev in orchestrator.run_turn_stream(ctx, engine_client): if ev.event == "token": - final_reply += ev.data.get("text", "") - yield {"event": ev.event, "data": json.dumps(ev.data, ensure_ascii=False)} - now = asyncio.get_event_loop().time() + text = str(ev.data.get("text", "")) + final_reply += text + yield {"event": "token", "data": text} + elif ev.event == "done": + data = {**ev.data, "stage": _stage_label(ctx.state_after.stage)} + await _append_session_turn( + sess, + TurnRecord( + turn_seq=ctx.state_after.turn_seq, + speaker="counselor", + stage=_stage_label(ctx.state_after.stage), + text=body.text, + text_masked=ctx.learner_text_masked, + ), + ) + await _update_session_state(sess, ctx.state_after) + if final_reply: + await _append_session_turn( + sess, + TurnRecord( + turn_seq=ctx.state_after.turn_seq, + speaker="client", + stage=_stage_label(ctx.state_after.stage), + text=final_reply, + text_masked=final_reply, + ), + ) + yield {"event": "done", "data": json.dumps(data, ensure_ascii=False)} + else: + yield {"event": ev.event, "data": json.dumps(ev.data, ensure_ascii=False)} + + now = asyncio.get_running_loop().time() if now - last_beat >= settings.sse_heartbeat_seconds: yield {"event": "ping", "data": "{}"} last_beat = now - except Exception as e: # 방어 — 어떤 예외도 SSE error 프레임으로 - yield {"event": "error", "data": json.dumps({"detail": str(e)}, ensure_ascii=False)} + except Exception as exc: + yield {"event": "error", "data": json.dumps({"detail": str(exc)}, ensure_ascii=False)} return - # 내담자 응답 로깅(② episodic) — 스트림 종료 후 - if final_reply: - store.append_turn( - session_id, - TurnRecord( - turn_seq=ctx.state_after.turn_seq, - speaker="client", - stage=ctx.state_after.stage.value, - text=final_reply, - text_masked=final_reply, - ), - ) return EventSourceResponse(event_generator()) -# ════════════════════════════════════════════════════════════════════════════ -# 회기 종료 -# ════════════════════════════════════════════════════════════════════════════ @router.post("/{session_id}/end", response_model=SessionEndResponse) async def end_session( session_id: str, - principal: DevPrincipal, + principal: CurrentPrincipal, ) -> SessionEndResponse: - """회기 종료 — 무손실 carry-over + 압축 트리거 (설계서 §2-C, 비동기 비블로킹). - - (A) 무손실 carry-over: end_state = state.snapshot() (코드 복사, LLM 미경유) [P4] - (C) narrative 압축(LLM)은 CompressionJob 으로 큐잉(여기선 페이로드만; 실제 호출은 후속 워커) - """ - sess = store.get(session_id) - if sess is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, detail="session not found") + """End a learner-owned session and prepare carry-over state.""" + _ensure_learner(principal) + sess = await _load_session_or_404(session_id, principal, allow_ended=True) recall = _RECALL_CACHE.get(session_id) or memory.RecallContext() carry = memory.make_carry_over( @@ -328,13 +978,9 @@ async def end_session( open_threads=recall.open_threads, ) - # TODO(Phase 2a): BackgroundTasks 로 carry.compression_job 을 - # engine_client.generate(GenerateRequest(ai_role='evaluator', tier='feedback', - # messages=memory.build_compression_messages(job))) 호출 → session_summary UPSERT + 임베딩. - # 현재는 큐잉만(digest_pending=True). DB 없으면 압축 결과 적재 생략. - - store.end(session_id) + await _end_persisted_session(sess, carry) _RECALL_CACHE.pop(session_id, None) + _schedule_session_evaluation(sess) return SessionEndResponse( session_id=session_id, diff --git a/apps/api/app/routes/teacher.py b/apps/api/app/routes/teacher.py new file mode 100644 index 0000000..aca9824 --- /dev/null +++ b/apps/api/app/routes/teacher.py @@ -0,0 +1,103 @@ +"""Teacher dashboard routes backed by real server session state.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Annotated + +from fastapi import APIRouter, Depends +from pydantic import BaseModel, Field + +from .. import session_persistence +from ..deps import Principal, Role, require_role +from ..runtime_policy import require_runtime_fallback_allowed +from ..store import InProcSession, store + +router = APIRouter(prefix="/teacher", tags=["teacher"]) + +TeacherPrincipal = Annotated[Principal, Depends(require_role(Role.TEACHER, Role.ADMIN))] + + +class TeacherSessionSummary(BaseModel): + session_id: str + learner_id: str + learner_label: str + persona_code: str + persona_name: str + session_no: int + status: str + stage: str + turn_count: int + learner_turn_count: int + client_turn_count: int + started_at: str + ended_at: str | None = None + + +class TeacherDashboardResponse(BaseModel): + source: str = "in_memory" + cohort_label: str = "현재 학습 기록" + total_learners: int + active_sessions: int + ended_sessions: int + pending_reviews: list[TeacherSessionSummary] = Field(default_factory=list) + recent_sessions: list[TeacherSessionSummary] = Field(default_factory=list) + message: str + + +def _iso(ts: float | None) -> str | None: + if ts is None: + return None + return datetime.fromtimestamp(ts).isoformat(timespec="seconds") + + +def _learner_label(learner_id: str) -> str: + suffix = learner_id[-6:] if len(learner_id) > 6 else learner_id + return f"학습자 {suffix}" + + +def _summary(sess: InProcSession) -> TeacherSessionSummary: + learner_turns = sum(1 for turn in sess.turns if turn.speaker == "counselor") + client_turns = sum(1 for turn in sess.turns if turn.speaker == "client") + return TeacherSessionSummary( + session_id=sess.session_id, + learner_id=sess.learner_id, + learner_label=_learner_label(sess.learner_id), + persona_code=sess.persona_code, + persona_name=sess.persona.display_name, + session_no=sess.session_no, + status="ended" if sess.ended else "active", + stage=sess.state.stage.value, + turn_count=len(sess.turns), + learner_turn_count=learner_turns, + client_turn_count=client_turns, + started_at=_iso(sess.created_at) or "", + ended_at=_iso(sess.ended_at), + ) + + +@router.get("/dashboard", response_model=TeacherDashboardResponse) +async def teacher_dashboard(principal: TeacherPrincipal) -> TeacherDashboardResponse: + """Return teacher-visible dashboard data from real sessions only.""" + sessions, durable = await session_persistence.list_sessions(principal) + if not durable: + require_runtime_fallback_allowed("teacher dashboard") + sessions = sorted(store.list(), key=lambda sess: sess.created_at, reverse=True) + summaries = [_summary(sess) for sess in sessions] + pending_reviews = [item for item in summaries if item.status == "ended"] + learners = {sess.learner_id for sess in sessions} + + if sessions: + message = "현재 기록된 실제 학습 세션만 표시합니다." + else: + message = "아직 표시할 실제 학습자 세션이 없습니다." + + return TeacherDashboardResponse( + source="database" if durable else "runtime", + total_learners=len(learners), + active_sessions=sum(1 for sess in sessions if not sess.ended), + ended_sessions=sum(1 for sess in sessions if sess.ended), + pending_reviews=pending_reviews[:20], + recent_sessions=summaries[:20], + message=message, + ) diff --git a/apps/api/app/routes/users.py b/apps/api/app/routes/users.py new file mode 100644 index 0000000..a55d20f --- /dev/null +++ b/apps/api/app/routes/users.py @@ -0,0 +1,280 @@ +"""Current-user profile, preference, and voice-preset routes.""" + +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, status +from pydantic import BaseModel, Field + +from ..auth_sessions import DEFAULT_AFFILIATION, get_managed_user, update_managed_user +from ..db import get_pool +from ..deps import CurrentPrincipal +from ..runtime_policy import require_runtime_fallback_allowed +from ..services.voice import PRESET_RATE, PRESET_TO_OPENAI_VOICE + +router = APIRouter(prefix="/users", tags=["users"]) + + +class UserProfileResponse(BaseModel): + user_id: str + email: str + display_name: str + role: str + cohort_ids: list[str] + affiliation: str + + +class UserProfilePatch(BaseModel): + display_name: str | None = Field(default=None, min_length=1, max_length=80) + affiliation: str | None = Field(default=None, max_length=120) + + +class NotificationPreferences(BaseModel): + session_done: bool = True + safety_signal: bool = True + learner_progress: bool = False + product_news: bool = False + + +class UserPreferencesResponse(BaseModel): + theme: str = "system" + voice_preset_id: str = "soft-young-fem" + voice_rate: float = 1.0 + notifications: NotificationPreferences = Field(default_factory=NotificationPreferences) + + +class UserPreferencesPatch(BaseModel): + theme: str | None = None + voice_preset_id: str | None = None + voice_rate: float | None = Field(default=None, ge=0.8, le=1.2) + notifications: NotificationPreferences | None = None + + +class VoicePresetResponse(BaseModel): + id: str + voice_id: str + name: str + desc: str + persona_hint: str + + +_preferences: dict[str, UserPreferencesResponse] = {} + +VOICE_PRESET_META = { + "soft-young-fem": { + "name": "서린", + "desc": "부드럽고 낮은 긴장감", + "persona_hint": "청소년 내담자", + }, + "calm-adult-male": { + "name": "민재", + "desc": "차분하고 안정적인 성인 남성", + "persona_hint": "성인 남성", + }, + "warm-adult-fem": { + "name": "지영", + "desc": "따뜻하지만 지친 성인 여성", + "persona_hint": "성인 여성", + }, + "neutral": { + "name": "기본", + "desc": "중립적인 기본 음성", + "persona_hint": "범용", + }, +} + + +def _voice_preset_ids() -> set[str]: + return set(PRESET_TO_OPENAI_VOICE.keys()) + + +def _normalize_voice_preset(value: str | None) -> str: + if value in _voice_preset_ids(): + return str(value) + return "soft-young-fem" + + +def _assert_voice_preset(value: str | None) -> None: + if value is None: + return + if value not in _voice_preset_ids(): + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"unsupported voice preset {value}", + ) + + +def _voice_presets() -> list[VoicePresetResponse]: + presets: list[VoicePresetResponse] = [] + for preset_id in PRESET_TO_OPENAI_VOICE.keys(): + meta = VOICE_PRESET_META.get( + preset_id, + { + "name": preset_id, + "desc": f"rate {PRESET_RATE.get(preset_id, 1.0):.2f}", + "persona_hint": "사용자 지정", + }, + ) + presets.append( + VoicePresetResponse( + id=preset_id, + voice_id=preset_id, + name=meta["name"], + desc=meta["desc"], + persona_hint=meta["persona_hint"], + ) + ) + return presets + + +def _preferences_from_row(row) -> UserPreferencesResponse: + return UserPreferencesResponse( + theme=row["theme"], + voice_preset_id=_normalize_voice_preset(row["voice_preset_id"]), + voice_rate=float(row["voice_rate"]), + notifications=NotificationPreferences.model_validate(row["notifications"] or {}), + ) + + +async def _profile_for(principal: CurrentPrincipal) -> UserProfileResponse: + managed = await get_managed_user(principal.user_id) + return UserProfileResponse( + user_id=principal.user_id, + email=principal.email, + display_name=( + (managed.display_name if managed else "") + or principal.display_name + or principal.email + ), + role=(managed.role if managed else principal.role.value), + cohort_ids=(managed.cohort_ids if managed else principal.cohort_ids), + affiliation=(managed.affiliation if managed else DEFAULT_AFFILIATION), + ) + + +@router.get("/me", response_model=UserProfileResponse) +async def get_me(principal: CurrentPrincipal) -> UserProfileResponse: + return await _profile_for(principal) + + +@router.patch("/me", response_model=UserProfileResponse) +async def patch_me(body: UserProfilePatch, principal: CurrentPrincipal) -> UserProfileResponse: + profile = await _profile_for(principal) + await update_managed_user( + principal.user_id, + display_name=body.display_name if body.display_name is not None else profile.display_name, + affiliation=body.affiliation if body.affiliation is not None else profile.affiliation, + ) + return await _profile_for(principal) + + +@router.get("/me/preferences", response_model=UserPreferencesResponse) +async def get_preferences(principal: CurrentPrincipal) -> UserPreferencesResponse: + try: + pool = get_pool() + async with pool.acquire() as conn: + await conn.execute( + """ + INSERT INTO app.user_preferences (user_id, notifications) + VALUES ($1::uuid, $2::jsonb) + ON CONFLICT (user_id) DO NOTHING + """, + principal.user_id, + NotificationPreferences().model_dump(), + ) + row = await conn.fetchrow( + """ + SELECT theme, voice_preset_id, voice_rate, notifications + FROM app.user_preferences + WHERE user_id = $1::uuid + """, + principal.user_id, + ) + if row is not None: + return _preferences_from_row(row) + except Exception: + require_runtime_fallback_allowed("user preferences") + return _preferences.setdefault(principal.user_id, UserPreferencesResponse()) + + +@router.patch("/me/preferences", response_model=UserPreferencesResponse) +async def patch_preferences( + body: UserPreferencesPatch, + principal: CurrentPrincipal, +) -> UserPreferencesResponse: + _assert_voice_preset(body.voice_preset_id) + try: + pool = get_pool() + async with pool.acquire() as conn: + await conn.execute( + """ + INSERT INTO app.user_preferences (user_id, notifications) + VALUES ($1::uuid, $2::jsonb) + ON CONFLICT (user_id) DO NOTHING + """, + principal.user_id, + NotificationPreferences().model_dump(), + ) + current = await conn.fetchrow( + """ + SELECT theme, voice_preset_id, voice_rate, notifications + FROM app.user_preferences + WHERE user_id = $1::uuid + """, + principal.user_id, + ) + current_prefs = _preferences_from_row(current) + next_prefs = UserPreferencesResponse( + theme=body.theme if body.theme is not None else current_prefs.theme, + voice_preset_id=( + body.voice_preset_id + if body.voice_preset_id is not None + else current_prefs.voice_preset_id + ), + voice_rate=body.voice_rate if body.voice_rate is not None else current_prefs.voice_rate, + notifications=( + body.notifications + if body.notifications is not None + else current_prefs.notifications + ), + ) + row = await conn.fetchrow( + """ + UPDATE app.user_preferences SET + theme = $2, + voice_preset_id = $3, + voice_rate = $4, + notifications = $5::jsonb, + updated_at = now() + WHERE user_id = $1::uuid + RETURNING theme, voice_preset_id, voice_rate, notifications + """, + principal.user_id, + next_prefs.theme, + next_prefs.voice_preset_id, + next_prefs.voice_rate, + next_prefs.notifications.model_dump(), + ) + return _preferences_from_row(row) + except HTTPException: + raise + except Exception: + require_runtime_fallback_allowed("user preferences") + + current = _preferences.setdefault(principal.user_id, UserPreferencesResponse()) + data = current.model_dump() + if body.theme is not None: + data["theme"] = body.theme + if body.voice_preset_id is not None: + data["voice_preset_id"] = body.voice_preset_id + if body.voice_rate is not None: + data["voice_rate"] = body.voice_rate + if body.notifications is not None: + data["notifications"] = body.notifications.model_dump() + next_prefs = UserPreferencesResponse.model_validate(data) + _preferences[principal.user_id] = next_prefs + return next_prefs + + +@router.get("/me/voice-presets", response_model=list[VoicePresetResponse]) +async def get_voice_presets(principal: CurrentPrincipal) -> list[VoicePresetResponse]: + return _voice_presets() diff --git a/apps/api/app/routes/voice.py b/apps/api/app/routes/voice.py index a88a6b1..360928c 100644 --- a/apps/api/app/routes/voice.py +++ b/apps/api/app/routes/voice.py @@ -1,22 +1,14 @@ -"""음성 라우트 — OpenAI STT/TTS 캐스케이드 + WSS 실시간 턴테이킹. +"""Voice routes for the OpenAI STT/TTS cascade over WebSocket. -한신대 요구 '음성 필수'. 학습자가 마이크로 말하면 → STT → orchestrator 상담 1턴 → -내담자 텍스트 → TTS 오디오 + 립싱크 힌트(설계 §4.3 RMS)를 역방향으로 흘린다. +Client sends JSON controls plus binary audio chunks: + audio_start -> binary audio chunks -> audio_end -캐스케이드(설계 §5.2 음성 오브 4상태 listening→thinking→speaking→idle): - [클라] audio_start(JSON) → 바이너리 오디오 청크들 → audio_end(JSON) - [서버] state(listening) → STT → transcript(JSON) → state(thinking) - → orchestrator.run_turn(가드레일·상태머신·페르소나·내담자AI·출력가드) - → reply(JSON, 내담자 텍스트 + stage/openness) → state(speaking) - → [tts_chunk(JSON: seq/rms) + 바이너리 오디오] × N → tts_end(JSON) → state(idle) +Server emits: + ready -> state(listening) -> state(thinking) -> transcript -> reply + -> state(speaking) -> tts_chunk + binary audio chunks -> tts_end -> state(idle) -프로토콜(JSON 제어 + 바이너리 오디오 혼합, 단일 WS): - - 클라→서버 텍스트 = JSON 제어({"type": ...}); 클라→서버 바이너리 = 오디오 청크 - - 서버→클라 텍스트 = JSON 이벤트; 서버→클라 바이너리 = TTS 오디오 청크 - - 각 TTS 바이너리 청크 *직전*에 메타 JSON(tts_chunk: seq, rms)을 보내 프론트가 짝짓는다. - -음성 미설정(OPENAI_API_KEY 없음): GET /voice/health → 503 degraded, -WS 는 핸드셰이크 직후 degraded 이벤트 + close(1011). 절대 크래시 금지. +When voice is not configured, the route reports degraded state and closes +cleanly instead of crashing. """ from __future__ import annotations @@ -28,67 +20,84 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect from fastapi.responses import JSONResponse from starlette.websockets import WebSocketState +from .. import session_persistence +from ..auth_sessions import get_session +from ..config import settings +from ..deps import Principal, Role from ..engine_client import EngineError, engine_client -from ..services import memory, orchestrator, persona +from ..persona_repository import get_catalog_persona +from ..runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed +from ..services import memory, orchestrator, state_machine from ..services import voice as voice_svc from ..services.voice import VoicePreset, VoiceUnavailable, resolve_voice, voice_service -from ..store import TurnRecord, store +from ..store import InProcSession, TurnRecord, store router = APIRouter(prefix="/voice", tags=["voice"]) -# WS close 코드(섹션별 의미 명시) -WS_CLOSE_DEGRADED = 1011 # 서버측 음성 미설정/장애 -WS_CLOSE_BAD_REQUEST = 1008 # 프로토콜 위반(세션 누락 등) +# WebSocket close codes. +WS_CLOSE_DEGRADED = 1011 +WS_CLOSE_BAD_REQUEST = 1008 +WS_CLOSE_UNAUTHORIZED = 1008 -# 한 발화당 누적 오디오 상한(메모리 방어, ~10MB) +# Per-utterance audio cap to avoid unbounded memory growth. _MAX_AUDIO_BYTES = 10 * 1024 * 1024 -# ════════════════════════════════════════════════════════════════════════════ -# 헬스 — 음성 가용성(키 설정) 노출 -# ════════════════════════════════════════════════════════════════════════════ @router.get("/health") async def voice_health() -> JSONResponse: - """음성 라우터 헬스. 키 미설정이면 503 degraded(시연 투명성).""" + """Return voice service readiness.""" available = voice_service.is_available() body = { "status": "ok" if available else "degraded", "available": available, "stt_model": voice_svc.STT_MODEL, "tts_model": voice_svc.TTS_MODEL, - "reason": None if available else "OPENAI_API_KEY 미설정", + "reason": None if available else "OPENAI_API_KEY is not configured", } return JSONResponse(body, status_code=200 if available else 503) -# ════════════════════════════════════════════════════════════════════════════ -# WebSocket — 실시간 음성 캐스케이드 -# ════════════════════════════════════════════════════════════════════════════ @router.websocket("/ws") async def voice_ws(websocket: WebSocket) -> None: - """음성 실시간 턴 캐스케이드. - - 쿼리: ?session_id= (없으면 persona_code 로 일회용 in-proc 세션 생성 — 시연용) - 오디오 in(바이너리) → STT → 상담 1턴 → TTS out(바이너리) + 립싱크 힌트. - """ + """Run one authenticated learner voice cascade.""" await websocket.accept() - # 1) 음성 미설정 → degraded 알리고 정상 종료(크래시 금지) - if not voice_service.is_available(): - await _safe_send_json( - websocket, - {"type": "degraded", "reason": "OPENAI_API_KEY 미설정 — 음성 기능 비활성"}, - ) - await _safe_close(websocket, WS_CLOSE_DEGRADED) + # Authenticate the same server-side browser session used by REST routes. + principal = await _principal_from_websocket(websocket) + if principal is None: + await _safe_send_json(websocket, {"type": "error", "detail": "not authenticated"}) + await _safe_close(websocket, WS_CLOSE_UNAUTHORIZED) + return + if principal.role != Role.LEARNER: + await _safe_send_json(websocket, {"type": "error", "detail": "only learners can use voice"}) + await _safe_close(websocket, WS_CLOSE_UNAUTHORIZED) return - # 2) 세션 바인딩 — session_id 우선, 없으면 persona_code 로 시연 세션 생성 - session_id, voice_preset, err = _bind_session(websocket) + # Bind to an existing session first. persona_code creation is dev-only. + session_id, voice_preset, err, bind_meta = await _bind_session(websocket, principal) if err is not None: await _safe_send_json(websocket, {"type": "error", "detail": err}) await _safe_close(websocket, WS_CLOSE_BAD_REQUEST) return assert session_id is not None and voice_preset is not None + if bind_meta.get("degraded"): + await _safe_send_json( + websocket, + { + "type": "degraded", + "reason": bind_meta.get("degraded_reason", "voice session binding degraded"), + **bind_meta, + }, + ) + + # Voice misconfiguration is reported explicitly and then closed cleanly. + if not voice_service.is_available(): + await _safe_send_json( + websocket, + {"type": "degraded", "reason": "OPENAI_API_KEY is not configured"}, + ) + await _safe_close(websocket, WS_CLOSE_DEGRADED) + return await _safe_send_json( websocket, @@ -98,6 +107,7 @@ async def voice_ws(websocket: WebSocket) -> None: "voice": voice_preset.openai_voice, "preset": voice_preset.preset, "state": "idle", + **bind_meta, }, ) @@ -111,10 +121,10 @@ async def voice_ws(websocket: WebSocket) -> None: if mtype == "websocket.disconnect": break - # ── 바이너리 = 오디오 청크 누적 ── + # Binary frames are audio chunks. if msg.get("bytes") is not None: if not receiving: - # audio_start 없이 들어온 바이너리 — 관용적으로 자동 시작 + # Be tolerant when audio arrives before audio_start. receiving = True audio_buf.clear() await _safe_send_json(websocket, {"type": "state", "state": "listening"}) @@ -122,13 +132,13 @@ async def voice_ws(websocket: WebSocket) -> None: if len(audio_buf) > _MAX_AUDIO_BYTES: await _safe_send_json( websocket, - {"type": "error", "detail": "audio too large — 발화를 짧게 끊어 주세요"}, + {"type": "error", "detail": "audio too large; please send a shorter utterance"}, ) audio_buf.clear() receiving = False continue - # ── 텍스트 = JSON 제어 ── + # Text frames are JSON controls. text = msg.get("text") if text is None: continue @@ -149,6 +159,7 @@ async def voice_ws(websocket: WebSocket) -> None: await _handle_utterance( websocket, session_id=session_id, + principal=principal, voice_preset=voice_preset, audio=bytes(audio_buf), fmt=ctrl.get("format"), @@ -156,7 +167,7 @@ async def voice_ws(websocket: WebSocket) -> None: audio_buf.clear() elif ctype == "text_turn": - # 음성 없이 텍스트만 보내는 경로(접근성/디버그): STT 건너뛰고 바로 턴. + # Text-only path for accessibility and deterministic tests. receiving = False audio_buf.clear() learner_text = (ctrl.get("text") or "").strip() @@ -164,6 +175,7 @@ async def voice_ws(websocket: WebSocket) -> None: await _run_turn_and_speak( websocket, session_id=session_id, + principal=principal, voice_preset=voice_preset, learner_text=learner_text, ) @@ -176,30 +188,28 @@ async def voice_ws(websocket: WebSocket) -> None: except WebSocketDisconnect: pass - except Exception as e: # 어떤 예외도 WS 를 깨끗이 닫고 알린다(크래시 금지) + except Exception as e: await _safe_send_json(websocket, {"type": "error", "detail": f"voice ws error: {e}"}) finally: await _safe_close(websocket) -# ════════════════════════════════════════════════════════════════════════════ -# 발화 1건 처리 — STT → 턴 → TTS -# ════════════════════════════════════════════════════════════════════════════ async def _handle_utterance( websocket: WebSocket, *, session_id: str, + principal: Principal, voice_preset: VoicePreset, audio: bytes, fmt: Optional[str], ) -> None: - """오디오 1발화 → STT → 상담 턴 → TTS 캐스케이드.""" + """Transcribe one utterance, generate the client reply, then synthesize TTS.""" if not audio: await _safe_send_json(websocket, {"type": "transcript", "text": "", "final": True}) await _safe_send_json(websocket, {"type": "state", "state": "idle"}) return - # 1) STT (thinking 진입) + # STT begins after the learner stops speaking. await _safe_send_json(websocket, {"type": "state", "state": "thinking"}) filename, content_type = _audio_meta(fmt) try: @@ -211,7 +221,7 @@ async def _handle_utterance( await _safe_send_json(websocket, {"type": "state", "state": "idle"}) return except Exception as e: - await _safe_send_json(websocket, {"type": "error", "detail": f"STT 실패: {e}"}) + await _safe_send_json(websocket, {"type": "error", "detail": f"STT failed: {e}"}) await _safe_send_json(websocket, {"type": "state", "state": "idle"}) return @@ -221,13 +231,13 @@ async def _handle_utterance( {"type": "transcript", "text": learner_text, "final": True, "speaker": "counselor"}, ) if not learner_text: - # 무음/인식 실패 — 턴 진행 안 함 await _safe_send_json(websocket, {"type": "state", "state": "idle"}) return await _run_turn_and_speak( websocket, session_id=session_id, + principal=principal, voice_preset=voice_preset, learner_text=learner_text, ) @@ -237,13 +247,14 @@ async def _run_turn_and_speak( websocket: WebSocket, *, session_id: str, + principal: Principal, voice_preset: VoicePreset, learner_text: str, ) -> None: - """상담 1턴(orchestrator) → 내담자 텍스트 → TTS 오디오/립싱크 힌트 역방향 전송.""" - sess = store.get(session_id) - if sess is None or sess.ended: - await _safe_send_json(websocket, {"type": "error", "detail": "세션 없음/종료됨"}) + """Run one counseling turn and stream synthesized client speech.""" + sess, err = await _load_voice_session(session_id, principal) + if sess is None: + await _safe_send_json(websocket, {"type": "error", "detail": err or "session not found or ended"}) await _safe_send_json(websocket, {"type": "state", "state": "idle"}) return @@ -260,19 +271,7 @@ async def _run_turn_and_speak( ) assert ctx.state_after is not None - # 학습자 발화 로깅(마스킹본) — sessions.py 패턴과 동일 - store.append_turn( - session_id, - TurnRecord( - turn_seq=ctx.state_after.turn_seq, - speaker="counselor", - stage=ctx.state_after.stage.value, - text=learner_text, - text_masked=ctx.learner_text_masked, - ), - ) - - # 2) 내담자 AI 1턴(동기 — 음성은 TTS 전 전체 텍스트가 필요) + # Voice needs the full client reply before TTS starts. try: result = await orchestrator.run_turn_generate(ctx, engine_client) except EngineError as e: @@ -281,10 +280,22 @@ async def _run_turn_and_speak( return reply = result.client_reply or "" - # 내담자 응답 로깅 + 상태 체크포인트 + # Persist only after the client reply has been generated. A failed AI turn + # must not leave a learner-only transcript in review or history. + await _append_voice_turn( + sess, + TurnRecord( + turn_seq=ctx.state_after.turn_seq, + speaker="counselor", + stage=ctx.state_after.stage.value, + text=learner_text, + text_masked=ctx.learner_text_masked, + ), + ) if reply: - store.append_turn( - session_id, + # Persist the generated client reply before TTS playback. + await _append_voice_turn( + sess, TurnRecord( turn_seq=result.turn_seq, speaker="client", @@ -293,9 +304,9 @@ async def _run_turn_and_speak( text_masked=reply, ), ) - store.update_state(session_id, result.state_after) + await _update_voice_state(sess, result.state_after) - # 내담자 텍스트 이벤트(설계 §5.3 자막 — partial 없이 final) + # Send the final client text before audio playback. await _safe_send_json( websocket, { @@ -314,7 +325,7 @@ async def _run_turn_and_speak( await _safe_send_json(websocket, {"type": "state", "state": "idle"}) return - # 3) TTS (speaking) — 청크별 메타 JSON(립싱크 rms) + 바이너리 오디오 + # TTS speaking state comes before chunk metadata and binary audio. await _safe_send_json( websocket, {"type": "state", "state": "speaking", "voice": voice_preset.openai_voice}, @@ -322,7 +333,7 @@ async def _run_turn_and_speak( try: n = 0 async for ck in voice_service.synthesize_stream(reply, voice_preset): - # 메타 먼저(프론트가 직후 바이너리와 짝지음) — 설계 §4.3 RMS 1채널 + # Metadata precedes the binary chunk so the client can pair them. await _safe_send_json( websocket, {"type": "tts_chunk", "seq": ck.seq, "rms": round(ck.rms, 4)} ) @@ -332,46 +343,112 @@ async def _run_turn_and_speak( except VoiceUnavailable as e: await _safe_send_json(websocket, {"type": "degraded", "reason": str(e)}) except Exception as e: - await _safe_send_json(websocket, {"type": "error", "detail": f"TTS 실패: {e}"}) + await _safe_send_json(websocket, {"type": "error", "detail": f"TTS failed: {e}"}) await _safe_send_json(websocket, {"type": "state", "state": "idle"}) -# ════════════════════════════════════════════════════════════════════════════ -# 세션 바인딩 / 메타 헬퍼 -# ════════════════════════════════════════════════════════════════════════════ -def _bind_session( - websocket: WebSocket, -) -> tuple[Optional[str], Optional[VoicePreset], Optional[str]]: - """쿼리에서 세션을 바인딩(또는 시연 세션 생성)하고 voice preset 을 해석. +async def _load_voice_session( + session_id: str, + principal: Principal, +) -> tuple[InProcSession | None, str | None]: + sess = await session_persistence.load_session(session_id, principal, allow_ended=True) + if sess is not None: + store.put(sess) + elif runtime_fallback_allowed(): + sess = store.get(session_id) + if sess is None: + return None, f"unknown session {session_id}" + if sess.learner_id != principal.user_id: + return None, "session does not belong to user" + if sess.ended: + return None, "session already ended" + return sess, None - 우선순위: - ?session_id= — 기존 세션(REST 로 시작된)에 음성 부착 - ?persona_code=P1[&preset=] — in-proc 시연 세션 생성(DB off 폴백) - 반환 (session_id, voice_preset, error). - """ + +async def _append_voice_turn(sess: InProcSession, turn: TurnRecord) -> None: + if await session_persistence.append_turn( + session_id=sess.session_id, + learner_id=sess.learner_id, + turn=turn, + ): + sess.turns.append(turn) + store.put(sess) + return + require_runtime_fallback_allowed("voice session turn append") + store.append_turn(sess.session_id, turn) + + +async def _update_voice_state( + sess: InProcSession, + state: state_machine.SessionState, +) -> None: + if await session_persistence.update_state( + session_id=sess.session_id, + learner_id=sess.learner_id, + state=state, + ): + sess.state = state + store.put(sess) + return + require_runtime_fallback_allowed("voice session state update") + store.update_state(sess.session_id, state) + + +async def _principal_from_websocket(websocket: WebSocket) -> Principal | None: + """Restore the same server-side browser session used by REST routes.""" + raw_cookie = websocket.cookies.get(settings.cookie_name) + if raw_cookie is None and settings.environment == "dev": + raw_cookie = websocket.cookies.get("vignette_sid") + + session = await get_session(raw_cookie) + if session is None: + return None + + try: + role = Role(session.role) + except ValueError: + return None + + return Principal( + user_id=session.user_id, + role=role, + cohort_ids=session.cohort_ids, + email=session.email, + display_name=session.display_name, + ) + + +async def _bind_session( + websocket: WebSocket, + principal: Principal, +) -> tuple[Optional[str], Optional[VoicePreset], Optional[str], dict[str, object]]: + """Bind an existing session or create a dev-only voice session.""" qp = websocket.query_params explicit_preset = qp.get("preset") session_id = qp.get("session_id") if session_id: - sess = store.get(session_id) + sess, err = await _load_voice_session(session_id, principal) if sess is None: - return None, None, f"unknown session {session_id}" - if sess.ended: - return None, None, "session already ended" + return None, None, err or f"unknown session {session_id}", {} vp = resolve_voice(persona_code=sess.persona.code, preset=explicit_preset) - return session_id, vp, None + return session_id, vp, None, {"degraded": False, "persona_catalog_source": "session"} + + # persona_code session creation is local-dev only. Production uses REST start. + if settings.environment != "dev": + return None, None, "session_id required", {} - # persona_code 로 시연 세션 생성(REST 미경유 음성 단독 데모) persona_code = qp.get("persona_code") if not persona_code: - return None, None, "session_id 또는 persona_code 쿼리 필요" - card = persona.get_seed_persona(persona_code) - if card is None: - return None, None, f"unknown persona {persona_code}" - - from ..services import state_machine + return None, None, "session_id or persona_code query required", {} + try: + catalog_persona = await get_catalog_persona(persona_code) + except Exception: + return None, None, "persona catalog database unavailable", {} + if catalog_persona is None: + return None, None, f"unknown persona {persona_code}", {} + card = catalog_persona.card st = state_machine.init_state( base_resistance=card.base_resistance(), @@ -379,19 +456,47 @@ def _bind_session( decay_floor=card.decay_floor(), ideation_baseline=card.ideation_baseline(), ) - sess = store.create( - learner_id="dev-learner-voice", - persona=card, + sess = await session_persistence.create_session( + learner_id=principal.user_id, + card=card, theory_mode="humanistic", state=st, session_no=1, + carry_rapport=st.rapport_credit, + persona_id=catalog_persona.persona_id, + persona_version=catalog_persona.version, ) + session_source = "database" + if sess is None: + require_runtime_fallback_allowed("voice session creation") + sess = store.create( + learner_id=principal.user_id, + persona=card, + theory_mode="humanistic", + state=st, + session_no=1, + carry_rapport=st.rapport_credit, + ) + session_source = "runtime" + else: + store.put(sess) vp = resolve_voice(persona_code=card.code, preset=explicit_preset) - return sess.session_id, vp, None + degraded_reasons: list[str] = [] + if catalog_persona.degraded: + degraded_reasons.append("카탈로그 원본을 확인하지 못해 음성 회기를 시작하지 않습니다") + if session_source == "runtime": + degraded_reasons.append("세션 저장소 연결 전까지 비영구 개발 런타임 기록을 사용합니다") + bind_meta = { + "degraded": bool(degraded_reasons), + "degraded_reason": "; ".join(degraded_reasons) if degraded_reasons else None, + "persona_catalog_source": catalog_persona.source, + "session_source": session_source, + } + return sess.session_id, vp, None, bind_meta def _audio_meta(fmt: Optional[str]) -> tuple[str, str]: - """클라가 알려준 포맷 → (filename, content_type). 기본 webm/opus.""" + """Map the browser audio format to upload metadata.""" f = (fmt or "webm").lower().lstrip(".") table = { "webm": ("audio.webm", "audio/webm"), @@ -406,7 +511,6 @@ def _audio_meta(fmt: Optional[str]) -> tuple[str, str]: return table.get(f, ("audio.webm", "audio/webm")) -# ── 안전 송수신(연결 끊김 시 조용히 무시) ─────────────────────────────────── async def _safe_send_json(websocket: WebSocket, payload: dict) -> None: if websocket.client_state != WebSocketState.CONNECTED: return diff --git a/apps/api/app/runtime_policy.py b/apps/api/app/runtime_policy.py new file mode 100644 index 0000000..e15704c --- /dev/null +++ b/apps/api/app/runtime_policy.py @@ -0,0 +1,33 @@ +"""Runtime fallback policy shared by auth/session routes. + +The in-process stores exist only to keep local development usable when Docker or +NAS PostgreSQL is offline. Staging/prod must fail loudly instead of becoming a +second source of truth. +""" + +from __future__ import annotations + +from typing import NoReturn + +from fastapi import HTTPException, status + +from .config import settings + + +def runtime_fallback_allowed() -> bool: + return settings.environment == "dev" + + +def raise_runtime_fallback_disabled(feature: str) -> NoReturn: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=( + f"{feature} persistence unavailable; runtime fallback is disabled " + f"in {settings.environment}" + ), + ) + + +def require_runtime_fallback_allowed(feature: str) -> None: + if not runtime_fallback_allowed(): + raise_runtime_fallback_disabled(feature) diff --git a/apps/api/app/services/evaluator.py b/apps/api/app/services/evaluator.py index 6c105f5..16dcb21 100644 --- a/apps/api/app/services/evaluator.py +++ b/apps/api/app/services/evaluator.py @@ -301,9 +301,14 @@ def _client_state_candidates_block() -> str: return f"[내담자 상태 후보 — code:라벨]\n- {items}" -# ─ few-shot 골든셋 예시(data/golden) — 발화→기법 라벨 일관성 보정 ──────────── -# 윤찬 6결정 '재귀학습 few-shot부터' + taxonomy '0615 합성변형' 설계의 실제 활용. -# 컨테이너/배포에선 GOLDEN_DIR env 로 마운트 경로 지정. 없으면 graceful(빈 블록). +# ─ few-shot 골든셋 예시(data/golden) — 명시적으로 켠 환경에서만 로딩 ───────── +# 골든셋은 학습/평가 보정 자료이지 운영 런타임의 기본 데이터가 아니다. +_GOLDEN_FEWSHOT_ENABLED = os.environ.get("EVALUATOR_GOLDEN_FEWSHOT_ENABLED", "").lower() in { + "1", + "true", + "yes", + "on", +} _GOLDEN_DIR = os.environ.get("GOLDEN_DIR") or os.path.join( os.path.dirname(__file__), "..", "..", "..", "..", "data", "golden" ) @@ -312,6 +317,8 @@ _GOLDEN_DIR = os.environ.get("GOLDEN_DIR") or os.path.join( def _load_fewshot_examples(max_n: int = 6) -> list[dict[str, Any]]: """골든셋에서 기법 다양성을 커버하는 상담자 발화 few-shot 예시(없으면 빈 리스트).""" out: list[dict[str, Any]] = [] + if not _GOLDEN_FEWSHOT_ENABLED: + return out seen: set[str] = set() try: files = sorted(f for f in os.listdir(_GOLDEN_DIR) if f.endswith(".jsonl")) diff --git a/apps/api/app/services/voice.py b/apps/api/app/services/voice.py index f5c3704..76795f5 100644 --- a/apps/api/app/services/voice.py +++ b/apps/api/app/services/voice.py @@ -185,9 +185,9 @@ def estimate_chunk_rms(chunk: bytes) -> float: class VoiceService: """OpenAI STT/TTS 어댑터. 앱 수명주기 동안 1 인스턴스 재사용(httpx 풀 공유).""" - def __init__(self, api_key: Optional[str] = None, base_url: str = OPENAI_BASE_URL) -> None: + def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None) -> None: self._api_key = (api_key if api_key is not None else settings.openai_api_key) or "" - self._base_url = base_url.rstrip("/") + self._base_url = (base_url or settings.openai_base_url or OPENAI_BASE_URL).rstrip("/") self._client: Optional[httpx.AsyncClient] = None # ── 수명주기 ────────────────────────────────────────── diff --git a/apps/api/app/session_persistence.py b/apps/api/app/session_persistence.py new file mode 100644 index 0000000..1f40b22 --- /dev/null +++ b/apps/api/app/session_persistence.py @@ -0,0 +1,656 @@ +"""DB-backed counseling session persistence with in-process fallback support.""" + +from __future__ import annotations + +import time +import uuid +from datetime import datetime, timezone +from typing import Any, Iterable + +from .db import acquire, get_pool +from .deps import Principal +from .config import settings +from .persona_repository import SEED_VERSION, card_from_row, seed_fallback_persona, seed_persona_id +from .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed +from .services import memory, state_machine +from .services.persona import PersonaCard +from .store import InProcSession, TurnRecord + +_EVALUATION_CACHE: dict[str, dict[str, Any]] = {} + + +_JOINED_CARD_COLUMNS = ( + "card_persona_id", + "card_code", + "card_version", + "card_status", + "card_display_name", + "card_difficulty", + "card_theory_target", + "card_demographics", + "card_presenting", + "card_history", + "card_big5", + "card_resistance", + "card_speech_style", + "card_affect_baseline", + "card_ccd", + "card_dsm5_dimensional", + "card_source_provenance", + "card_is_synthetic", +) + + +def _ts(value: datetime | None) -> float | None: + if value is None: + return None + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.timestamp() + + +def _row_value(row, key: str): + try: + return row[key] + except Exception: + return None + + +def _card_from_joined_session_row(row) -> PersonaCard | None: + if _row_value(row, "card_persona_id") is None: + return None + card_row = { + key.removeprefix("card_"): _row_value(row, key) + for key in _JOINED_CARD_COLUMNS + } + return card_from_row(card_row) + + +def _stage(stage: object) -> str: + return getattr(stage, "value", str(stage)) + + +def _state_from_row(row, card: PersonaCard) -> state_machine.SessionState: + if row is None: + return state_machine.init_state( + base_resistance=card.base_resistance(), + unlock_rate=card.unlock_rate(), + decay_floor=card.decay_floor(), + ideation_baseline=card.ideation_baseline(), + ) + return state_machine.SessionState( + stage=state_machine.Stage(row["stage"]), + turn_seq=int(row["turn_seq"]), + effective_openness=float(row["effective_openness"]), + rapport_credit=float(row["rapport_credit"]), + resistance=float(row["resistance"]), + ideation_stage=int(row["ideation_stage"]), + turns_in_stage=int(row["turns_in_stage"] or 0), + affect_state=dict(row["affect_state"] or {}), + ) + + +def _turn_from_row(row) -> TurnRecord: + created_at = _ts(row["created_at"]) or time.time() + return TurnRecord( + turn_seq=int(row["seq"]), + speaker=row["speaker"], + stage=row["stage"], + text=row["text"] or row["text_masked"] or "", + text_masked=row["text_masked"] or row["text"] or "", + created_at=created_at, + ) + + +async def ensure_review_tables() -> None: + """Create runtime review/evaluation storage when the DB role allows it.""" + try: + get_pool() + async with acquire(role="admin") as conn: + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS app.session_evaluation ( + session_id UUID PRIMARY KEY REFERENCES app.sessions(id) ON DELETE CASCADE, + status TEXT NOT NULL CHECK (status IN ('ready','degraded','error')), + source TEXT NOT NULL, + scope TEXT NOT NULL, + stage TEXT NOT NULL, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """ + ) + await conn.execute( + """ + ALTER TABLE app.session_evaluation ENABLE ROW LEVEL SECURITY; + + DROP POLICY IF EXISTS p_session_evaluation_select ON app.session_evaluation; + DROP POLICY IF EXISTS p_session_evaluation_insert ON app.session_evaluation; + DROP POLICY IF EXISTS p_session_evaluation_update ON app.session_evaluation; + DROP POLICY IF EXISTS p_session_evaluation_delete ON app.session_evaluation; + + CREATE POLICY p_session_evaluation_select + ON app.session_evaluation FOR SELECT USING ( + app.is_ai_context() + OR app.current_role_name() IN ('admin','instructor') + OR EXISTS ( + SELECT 1 FROM app.sessions s + WHERE s.id = app.session_evaluation.session_id + AND s.learner_id = app.current_uid() + ) + ); + CREATE POLICY p_session_evaluation_insert + ON app.session_evaluation FOR INSERT WITH CHECK ( + app.is_ai_context() + OR app.current_role_name() IN ('admin','instructor') + OR EXISTS ( + SELECT 1 FROM app.sessions s + WHERE s.id = app.session_evaluation.session_id + AND s.learner_id = app.current_uid() + ) + ); + CREATE POLICY p_session_evaluation_update + ON app.session_evaluation FOR UPDATE USING ( + app.is_ai_context() + OR app.current_role_name() IN ('admin','instructor') + OR EXISTS ( + SELECT 1 FROM app.sessions s + WHERE s.id = app.session_evaluation.session_id + AND s.learner_id = app.current_uid() + ) + ) WITH CHECK ( + app.is_ai_context() + OR app.current_role_name() IN ('admin','instructor') + OR EXISTS ( + SELECT 1 FROM app.sessions s + WHERE s.id = app.session_evaluation.session_id + AND s.learner_id = app.current_uid() + ) + ); + CREATE POLICY p_session_evaluation_delete + ON app.session_evaluation FOR DELETE USING ( + app.is_ai_context() + OR app.current_role_name() IN ('admin','instructor') + OR EXISTS ( + SELECT 1 FROM app.sessions s + WHERE s.id = app.session_evaluation.session_id + AND s.learner_id = app.current_uid() + ) + ) + """ + ) + except Exception: + return + + +async def save_session_evaluation( + *, + session_id: str, + learner_id: str, + status: str, + source: str, + scope: str, + stage: str, + payload: dict[str, Any], + error: str | None = None, +) -> bool: + record = { + "status": status, + "source": source, + "scope": scope, + "stage": stage, + "payload": payload, + "error": error, + } + if runtime_fallback_allowed(): + _EVALUATION_CACHE[session_id] = record + try: + get_pool() + async with acquire(role="learner", user_id=learner_id) as conn: + await conn.execute( + """ + INSERT INTO app.session_evaluation ( + session_id, status, source, scope, stage, payload, error, + created_at, updated_at + ) + VALUES ($1::uuid, $2, $3, $4, $5, $6::jsonb, $7, now(), now()) + ON CONFLICT (session_id) DO UPDATE SET + status = EXCLUDED.status, + source = EXCLUDED.source, + scope = EXCLUDED.scope, + stage = EXCLUDED.stage, + payload = EXCLUDED.payload, + error = EXCLUDED.error, + updated_at = now() + """, + session_id, + status, + source, + scope, + stage, + payload, + error, + ) + return True + except Exception: + require_runtime_fallback_allowed("session evaluation") + return False + + +async def load_session_evaluation( + session_id: str, + principal: Principal, +) -> tuple[dict[str, Any] | None, bool]: + try: + get_pool() + async with acquire( + role=principal.role.value, + user_id=principal.user_id, + cohort_ids=principal.cohort_ids, + ) as conn: + row = await conn.fetchrow( + """ + SELECT status, source, scope, stage, payload, error, updated_at + FROM app.session_evaluation + WHERE session_id = $1::uuid + """, + session_id, + ) + if row is None: + return ( + _EVALUATION_CACHE.get(session_id) if runtime_fallback_allowed() else None + ), False + return { + "status": row["status"], + "source": row["source"], + "scope": row["scope"], + "stage": row["stage"], + "payload": dict(row["payload"] or {}), + "error": row["error"], + "updated_at": _ts(row["updated_at"]), + }, True + except Exception: + require_runtime_fallback_allowed("session evaluation") + return _EVALUATION_CACHE.get(session_id), False + + +def _session_from_rows(row, state_row, turn_rows: Iterable) -> InProcSession | None: + card = _card_from_joined_session_row(row) + if card is None: + if not settings.allow_seed_persona_fallback: + return None + persona_code = (row["persona_code"] or "").upper() + legacy_entry = seed_fallback_persona(persona_code) + if legacy_entry is None: + return None + card = legacy_entry.card + persona_code = card.code + + ended_at = _ts(row["ended_at"]) + started_at = _ts(row["started_at"]) or time.time() + return InProcSession( + session_id=str(row["id"]), + case_id=str(row["runtime_case_id"] or row["case_id"] or row["id"]), + learner_id=str(row["learner_id"]), + persona_code=persona_code, + theory_mode=row["theory_mode"] or "humanistic", + persona=card, + state=_state_from_row(state_row, card), + session_no=int(row["session_no"] or 1), + created_at=started_at, + ended_at=ended_at, + turns=[_turn_from_row(turn_row) for turn_row in turn_rows], + ended=ended_at is not None, + prev_rapport_credit=float(row["prev_rapport_credit"] or 0.0), + ) + + +async def _upsert_state(conn, session_id: str, state: state_machine.SessionState) -> None: + await conn.execute( + """ + INSERT INTO app.session_state ( + session_id, stage, turn_seq, effective_openness, rapport_credit, + resistance, ideation_stage, turns_in_stage, affect_state, updated_at + ) + VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, now()) + ON CONFLICT (session_id) DO UPDATE SET + stage = EXCLUDED.stage, + turn_seq = EXCLUDED.turn_seq, + effective_openness = EXCLUDED.effective_openness, + rapport_credit = EXCLUDED.rapport_credit, + resistance = EXCLUDED.resistance, + ideation_stage = EXCLUDED.ideation_stage, + turns_in_stage = EXCLUDED.turns_in_stage, + affect_state = EXCLUDED.affect_state, + updated_at = now() + """, + session_id, + _stage(state.stage), + state.turn_seq, + state.effective_openness, + state.rapport_credit, + state.resistance, + state.ideation_stage, + state.turns_in_stage, + state.affect_state, + ) + + +async def create_session( + *, + learner_id: str, + card: PersonaCard, + theory_mode: str, + state: state_machine.SessionState, + session_no: int = 1, + carry_rapport: float = 0.0, + persona_id: str | None = None, + persona_version: int | None = None, +) -> InProcSession | None: + """Create a DB-backed session, returning None when DB persistence is unavailable.""" + try: + get_pool() + runtime_case_id = str(uuid.uuid4()) + pinned_persona_id = persona_id or seed_persona_id(card.code) + pinned_persona_version = persona_version or SEED_VERSION + async with acquire(role="learner", user_id=learner_id) as conn: + row = await conn.fetchrow( + """ + INSERT INTO app.sessions ( + runtime_case_id, learner_id, persona_id, persona_version, + persona_code, persona_display_name, persona_difficulty, + session_no, theory_mode, stage_path, prev_rapport_credit + ) + VALUES ( + $1::uuid, $2::uuid, $3::uuid, $4, + $5, $6, $7, + $8, $9, '[]'::jsonb, $10 + ) + RETURNING id, runtime_case_id, case_id, learner_id, persona_code, + session_no, theory_mode, started_at, ended_at, prev_rapport_credit + """, + runtime_case_id, + learner_id, + pinned_persona_id, + pinned_persona_version, + card.code, + card.display_name, + card.difficulty, + session_no, + theory_mode, + carry_rapport, + ) + await _upsert_state(conn, str(row["id"]), state) + return InProcSession( + session_id=str(row["id"]), + case_id=runtime_case_id, + learner_id=learner_id, + persona_code=card.code, + theory_mode=theory_mode, + persona=card, + state=state, + session_no=session_no, + created_at=_ts(row["started_at"]) or time.time(), + ended_at=None, + turns=[], + ended=False, + prev_rapport_credit=carry_rapport, + ) + except Exception: + require_runtime_fallback_allowed("session creation") + return None + + +async def load_session( + session_id: str, + principal: Principal, + *, + allow_ended: bool = False, +) -> InProcSession | None: + try: + get_pool() + async with acquire( + role=principal.role.value, + user_id=principal.user_id, + cohort_ids=principal.cohort_ids, + ) as conn: + row = await conn.fetchrow( + """ + SELECT + s.id, s.runtime_case_id, s.case_id, s.learner_id, s.persona_code, + s.session_no, s.theory_mode, s.started_at, s.ended_at, + s.prev_rapport_credit, + pc.persona_id AS card_persona_id, + pc.code AS card_code, + pc.version AS card_version, + pc.status AS card_status, + pc.display_name AS card_display_name, + pc.difficulty AS card_difficulty, + pc.theory_target AS card_theory_target, + pc.demographics AS card_demographics, + pc.presenting AS card_presenting, + pc.history AS card_history, + pc.big5 AS card_big5, + pc.resistance AS card_resistance, + pc.speech_style AS card_speech_style, + pc.affect_baseline AS card_affect_baseline, + pc.ccd AS card_ccd, + pc.dsm5_dimensional AS card_dsm5_dimensional, + pc.source_provenance AS card_source_provenance, + pc.is_synthetic AS card_is_synthetic + FROM app.sessions s + LEFT JOIN app.persona_card pc + ON pc.persona_id = s.persona_id + AND pc.version = s.persona_version + WHERE s.id = $1::uuid + """, + session_id, + ) + if row is None: + return None + if row["ended_at"] is not None and not allow_ended: + return None + state_row = await conn.fetchrow( + """ + SELECT stage, turn_seq, effective_openness, rapport_credit, resistance, + ideation_stage, turns_in_stage, affect_state + FROM app.session_state + WHERE session_id = $1::uuid + """, + session_id, + ) + turn_rows = await conn.fetch( + """ + SELECT seq, speaker, stage, text, text_masked, created_at + FROM app.turns + WHERE session_id = $1::uuid + ORDER BY seq + """, + session_id, + ) + return _session_from_rows(row, state_row, turn_rows) + except Exception: + require_runtime_fallback_allowed("session load") + return None + + +async def append_turn( + *, + session_id: str, + learner_id: str, + turn: TurnRecord, +) -> bool: + try: + get_pool() + async with acquire(role="learner", user_id=learner_id) as conn: + locked = await conn.fetchval( + "SELECT id FROM app.sessions WHERE id = $1::uuid FOR UPDATE", + session_id, + ) + if locked is None: + return False + seq = int( + await conn.fetchval( + "SELECT COALESCE(MAX(seq), 0) + 1 FROM app.turns WHERE session_id = $1::uuid", + session_id, + ) + or 1 + ) + await conn.execute( + """ + INSERT INTO app.turns ( + session_id, seq, speaker, stage, text, text_masked, actor_kind, visible_to + ) + VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8::text[]) + ON CONFLICT (session_id, seq) DO NOTHING + """, + session_id, + seq, + turn.speaker, + turn.stage, + turn.text_masked, + turn.text_masked, + "human_learner" if turn.speaker == "counselor" else "client_ai", + ["client", "counselor", "evaluator"], + ) + return True + except Exception: + require_runtime_fallback_allowed("session turn append") + return False + + +async def update_state( + *, + session_id: str, + learner_id: str, + state: state_machine.SessionState, +) -> bool: + try: + get_pool() + async with acquire(role="learner", user_id=learner_id) as conn: + await _upsert_state(conn, session_id, state) + return True + except Exception: + require_runtime_fallback_allowed("session state update") + return False + + +async def end_session(sess: InProcSession, carry: memory.CarryOver) -> bool: + try: + get_pool() + digest = ( + f"회기 축어록 {len(sess.turns)}개가 저장되었습니다. 정밀 리뷰는 생성 대기 중입니다." + if sess.turns + else "실제 발화가 없어 요약을 생성하지 않았습니다." + ) + async with acquire(role="learner", user_id=sess.learner_id) as conn: + await conn.execute( + """ + UPDATE app.sessions + SET ended_at = COALESCE(ended_at, now()) + WHERE id = $1::uuid + """, + sess.session_id, + ) + await _upsert_state(conn, sess.session_id, sess.state) + await conn.execute( + """ + INSERT INTO app.session_summary ( + session_id, case_id, session_no, end_state, rapport_delta, + digest, open_threads, created_at + ) + VALUES ($1::uuid, $2::uuid, $3, $4::jsonb, $5, $6, $7::jsonb, now()) + ON CONFLICT (session_id) DO UPDATE SET + end_state = EXCLUDED.end_state, + rapport_delta = EXCLUDED.rapport_delta, + digest = EXCLUDED.digest, + open_threads = EXCLUDED.open_threads + """, + sess.session_id, + sess.case_id, + sess.session_no, + carry.end_state, + carry.rapport_delta, + digest, + list(carry.compression_job.open_threads if carry.compression_job else []), + ) + return True + except Exception: + require_runtime_fallback_allowed("session end") + return False + + +async def list_sessions(principal: Principal) -> tuple[list[InProcSession], bool]: + try: + get_pool() + learner_filter = "WHERE s.learner_id = $1::uuid" if principal.role.value == "learner" else "" + query_args = [principal.user_id] if principal.role.value == "learner" else [] + async with acquire( + role=principal.role.value, + user_id=principal.user_id, + cohort_ids=principal.cohort_ids, + ) as conn: + rows = await conn.fetch( + f""" + SELECT + s.id, s.runtime_case_id, s.case_id, s.learner_id, s.persona_code, + s.session_no, s.theory_mode, s.started_at, s.ended_at, + s.prev_rapport_credit, + pc.persona_id AS card_persona_id, + pc.code AS card_code, + pc.version AS card_version, + pc.status AS card_status, + pc.display_name AS card_display_name, + pc.difficulty AS card_difficulty, + pc.theory_target AS card_theory_target, + pc.demographics AS card_demographics, + pc.presenting AS card_presenting, + pc.history AS card_history, + pc.big5 AS card_big5, + pc.resistance AS card_resistance, + pc.speech_style AS card_speech_style, + pc.affect_baseline AS card_affect_baseline, + pc.ccd AS card_ccd, + pc.dsm5_dimensional AS card_dsm5_dimensional, + pc.source_provenance AS card_source_provenance, + pc.is_synthetic AS card_is_synthetic + FROM app.sessions s + LEFT JOIN app.persona_card pc + ON pc.persona_id = s.persona_id + AND pc.version = s.persona_version + {learner_filter} + ORDER BY s.started_at DESC + LIMIT 100 + """, + *query_args, + ) + sessions: list[InProcSession] = [] + for row in rows: + session_id = str(row["id"]) + state_row = await conn.fetchrow( + """ + SELECT stage, turn_seq, effective_openness, rapport_credit, resistance, + ideation_stage, turns_in_stage, affect_state + FROM app.session_state + WHERE session_id = $1::uuid + """, + session_id, + ) + turn_rows = await conn.fetch( + """ + SELECT seq, speaker, stage, text, text_masked, created_at + FROM app.turns + WHERE session_id = $1::uuid + ORDER BY seq + """, + session_id, + ) + sess = _session_from_rows(row, state_row, turn_rows) + if sess is not None: + sessions.append(sess) + return sessions, True + except Exception: + require_runtime_fallback_allowed("session list") + return [], False diff --git a/apps/api/app/store.py b/apps/api/app/store.py index 4f6bb34..2def265 100644 --- a/apps/api/app/store.py +++ b/apps/api/app/store.py @@ -42,6 +42,8 @@ class InProcSession: persona: PersonaCard state: SessionState session_no: int = 1 + created_at: float = field(default_factory=time.time) + ended_at: Optional[float] = None turns: list[TurnRecord] = field(default_factory=list) ended: bool = False prev_rapport_credit: float = 0.0 # carry-over delta 계산용 @@ -55,7 +57,7 @@ class InProcSession: class SessionStore: - """in-memory 세션 레지스트리. DB degraded 시 SoR 대용.""" + """in-memory 세션 저장소. DB degraded 시 SoR 대용.""" def __init__(self) -> None: self._sessions: dict[str, InProcSession] = {} @@ -89,6 +91,12 @@ class SessionStore: def get(self, session_id: str) -> Optional[InProcSession]: return self._sessions.get(session_id) + def put(self, session: InProcSession) -> None: + self._sessions[session.session_id] = session + + def list(self) -> list[InProcSession]: + return list(self._sessions.values()) + def append_turn(self, session_id: str, turn: TurnRecord) -> None: s = self._sessions.get(session_id) if s is not None: @@ -103,6 +111,7 @@ class SessionStore: s = self._sessions.get(session_id) if s is not None: s.ended = True + s.ended_at = time.time() return s def remove(self, session_id: str) -> None: diff --git a/apps/api/app/test_runtime_policy.py b/apps/api/app/test_runtime_policy.py new file mode 100644 index 0000000..ce2a108 --- /dev/null +++ b/apps/api/app/test_runtime_policy.py @@ -0,0 +1,269 @@ +"""Regression tests for DB outage runtime fallback policy.""" + +from __future__ import annotations + +import unittest +from contextlib import contextmanager +from unittest.mock import AsyncMock, patch + +from fastapi import HTTPException + +from . import auth_sessions, session_persistence +from .config import Settings, settings +from .deps import Principal, Role +from .routes import admin as admin_routes +from .routes import eval as eval_routes +from .routes import kb as kb_routes +from .routes import users as users_routes + + +@contextmanager +def environment(value: str): + previous = settings.environment + settings.environment = value + try: + yield + finally: + settings.environment = previous + + +class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + auth_sessions._sessions.clear() + auth_sessions._users.clear() + auth_sessions._email_index.clear() + auth_sessions._inactive_emails.clear() + users_routes._preferences.clear() + admin_routes._ENGINE_CONFIG = None + + async def asyncTearDown(self) -> None: + admin_routes._ENGINE_CONFIG = None + + async def test_dev_allows_auth_registry_fallback_when_db_pool_missing(self) -> None: + with environment("dev"): + users, durable = await auth_sessions.list_managed_users() + + self.assertFalse(durable) + self.assertEqual(users, []) + + async def test_staging_blocks_auth_registry_fallback_when_db_pool_missing(self) -> None: + with environment("staging"): + with self.assertRaises(HTTPException) as caught: + await auth_sessions.list_managed_users() + + self.assertEqual(caught.exception.status_code, 503) + self.assertIn("runtime fallback is disabled in staging", caught.exception.detail) + + async def test_prod_blocks_browser_session_creation_when_db_pool_missing(self) -> None: + with environment("prod"): + with self.assertRaises(HTTPException) as caught: + await auth_sessions.create_session( + email="learner@hs.ac.kr", + display_name="Learner", + role="learner", + ) + + self.assertEqual(caught.exception.status_code, 503) + self.assertIn("runtime fallback is disabled in prod", caught.exception.detail) + + async def test_staging_blocks_session_store_fallback_when_db_pool_missing(self) -> None: + principal = Principal( + user_id="00000000-0000-0000-0000-000000000001", + role=Role.LEARNER, + cohort_ids=[], + email="learner@hs.ac.kr", + display_name="Learner", + ) + with environment("staging"): + with self.assertRaises(HTTPException) as caught: + await session_persistence.list_sessions(principal) + + self.assertEqual(caught.exception.status_code, 503) + self.assertIn("runtime fallback is disabled in staging", caught.exception.detail) + + async def test_staging_blocks_eval_store_cache_fallback_when_db_pool_missing(self) -> None: + principal = Principal( + user_id="00000000-0000-0000-0000-000000000002", + role=Role.TEACHER, + cohort_ids=[], + email="teacher@hs.ac.kr", + display_name="Teacher", + ) + with environment("staging"): + with self.assertRaises(HTTPException) as caught: + await eval_routes.get_session_evaluation("missing-session-id", principal) + + self.assertEqual(caught.exception.status_code, 503) + self.assertIn("runtime fallback is disabled in staging", caught.exception.detail) + + async def test_staging_kb_search_fails_closed_when_db_pool_missing(self) -> None: + with environment("staging"): + with self.assertRaises(HTTPException) as caught: + await kb_routes.search(kb_routes.KBSearchRequest(query="rapport")) + + self.assertEqual(caught.exception.status_code, 503) + self.assertIn("DB not ready", caught.exception.detail) + + async def test_staging_blocks_user_preferences_fallback_when_db_pool_missing(self) -> None: + principal = Principal( + user_id="00000000-0000-0000-0000-000000000003", + role=Role.LEARNER, + cohort_ids=[], + email="learner@hs.ac.kr", + display_name="Learner", + ) + with environment("staging"): + with self.assertRaises(HTTPException) as caught: + await users_routes.get_preferences(principal) + + self.assertEqual(caught.exception.status_code, 503) + self.assertIn("runtime fallback is disabled in staging", caught.exception.detail) + + async def test_dev_allows_user_preferences_fallback_when_db_pool_missing(self) -> None: + principal = Principal( + user_id="00000000-0000-0000-0000-000000000004", + role=Role.LEARNER, + cohort_ids=[], + email="learner@hs.ac.kr", + display_name="Learner", + ) + with environment("dev"): + prefs = await users_routes.get_preferences(principal) + + self.assertEqual(prefs.voice_preset_id, "soft-young-fem") + + async def test_staging_blocks_admin_engine_config_default_when_row_missing(self) -> None: + class EmptyConfigConn: + async def fetchrow(self, *args, **kwargs): + return None + + class EmptyConfigAcquire: + async def __aenter__(self): + return EmptyConfigConn() + + async def __aexit__(self, exc_type, exc, tb): + return None + + class EmptyConfigPool: + def acquire(self): + return EmptyConfigAcquire() + + with environment("staging"), patch.object(admin_routes, "get_pool", return_value=EmptyConfigPool()): + with self.assertRaises(HTTPException) as caught: + await admin_routes._current_engine_config() + + self.assertEqual(caught.exception.status_code, 503) + self.assertIn("runtime fallback is disabled in staging", caught.exception.detail) + + async def test_prod_admin_health_marks_db_down_when_persistence_unavailable(self) -> None: + principal = Principal( + user_id="00000000-0000-0000-0000-000000000005", + role=Role.ADMIN, + cohort_ids=[], + email="admin@twentyoz.kr", + display_name="Admin", + ) + engine_config = admin_routes.AdminEngineConfigResponse( + engine_mode="claude_cli", + engine_url="http://127.0.0.1:9099", + model="gateway-default", + durable=True, + source="database", + ) + + with ( + environment("prod"), + patch.object(admin_routes, "_current_engine_config", AsyncMock(return_value=engine_config)), + patch.object(admin_routes, "healthcheck", AsyncMock(return_value=False)), + patch.object(admin_routes.engine_client, "health_detail", AsyncMock(return_value={"ok": True})), + patch.object(admin_routes.voice_service, "is_available", return_value=False), + ): + health = await admin_routes.admin_health(principal) + + db = next(service for service in health.services if service.key == "db") + self.assertEqual(db.status, "down") + self.assertEqual(db.metric, "저장소 중단") + self.assertIn("DB 저장소", db.detail) + + async def test_dev_admin_health_labels_db_fallback_as_non_durable_runtime(self) -> None: + principal = Principal( + user_id="00000000-0000-0000-0000-000000000006", + role=Role.ADMIN, + cohort_ids=[], + email="admin@twentyoz.kr", + display_name="Admin", + ) + engine_config = admin_routes.AdminEngineConfigResponse( + engine_mode="claude_cli", + engine_url="http://127.0.0.1:9099", + model="gateway-default", + durable=False, + source="runtime_cache", + ) + + with ( + environment("dev"), + patch.object(admin_routes, "_current_engine_config", AsyncMock(return_value=engine_config)), + patch.object(admin_routes, "healthcheck", AsyncMock(return_value=False)), + patch.object(admin_routes.engine_client, "health_detail", AsyncMock(return_value={"ok": True})), + patch.object(admin_routes.voice_service, "is_available", return_value=False), + ): + health = await admin_routes.admin_health(principal) + + db = next(service for service in health.services if service.key == "db") + self.assertEqual(db.status, "degraded") + self.assertEqual(db.metric, "비영구 런타임 기록") + self.assertIn("비영구 개발 런타임 기록", db.detail) + + def test_non_dev_rejects_fixture_runtime_flags(self) -> None: + with self.assertRaises(ValueError) as caught: + Settings( + environment="staging", + auth_dev_login_enabled=True, + auto_seed_personas=True, + allow_seed_persona_fallback=True, + ) + + self.assertIn("AUTH_DEV_LOGIN_ENABLED", str(caught.exception)) + self.assertIn("AUTO_SEED_PERSONAS", str(caught.exception)) + self.assertIn("ALLOW_SEED_PERSONA_FALLBACK", str(caught.exception)) + + def test_non_dev_rejects_missing_public_runtime_config(self) -> None: + with self.assertRaises(ValueError) as caught: + Settings( + environment="prod", + auth_dev_login_enabled=False, + auto_seed_personas=False, + allow_seed_persona_fallback=False, + oauth_google_client_id="", + oauth_google_client_secret="", + session_secret="dev-insecure-change-me", + frontend_base_url="http://localhost:5173", + cors_origins=["http://localhost:5173"], + ) + + error = str(caught.exception) + self.assertIn("OAUTH_GOOGLE_CLIENT_ID", error) + self.assertIn("OAUTH_GOOGLE_CLIENT_SECRET", error) + self.assertIn("SESSION_SECRET", error) + self.assertIn("FRONTEND_BASE_URL", error) + + def test_non_dev_accepts_public_runtime_config(self) -> None: + cfg = Settings( + environment="staging", + auth_dev_login_enabled=False, + auto_seed_personas=False, + allow_seed_persona_fallback=False, + session_secret="staging-secret-change-me", + oauth_google_client_id="google-client-id", + oauth_google_client_secret="google-client-secret", + frontend_base_url="https://vignette.chanpaca.net", + cors_origins=["https://vignette.chanpaca.net"], + ) + + self.assertEqual(cfg.environment, "staging") + self.assertEqual(cfg.cors_origins, ["https://vignette.chanpaca.net"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/api/app/test_session_turn_persistence.py b/apps/api/app/test_session_turn_persistence.py new file mode 100644 index 0000000..cf2b387 --- /dev/null +++ b/apps/api/app/test_session_turn_persistence.py @@ -0,0 +1,143 @@ +"""Regression tests for session turn persistence ordering.""" + +from __future__ import annotations + +import unittest +from unittest.mock import AsyncMock, patch + +from .deps import Principal, Role +from .engine_client import EngineError +from .routes import sessions +from .routes import voice as voice_routes +from .services import orchestrator, persona as persona_service, state_machine +from .services.voice import VoicePreset +from .store import InProcSession, store + + +def _principal() -> Principal: + return Principal( + user_id="00000000-0000-0000-0000-000000000101", + role=Role.LEARNER, + cohort_ids=[], + email="turn-test@hs.ac.kr", + display_name="Turn Test", + ) + + +def _session(principal: Principal) -> InProcSession: + card = persona_service.P1 + sess = InProcSession( + session_id="turn-persistence-session", + case_id="turn-persistence-case", + learner_id=principal.user_id, + persona_code=card.code, + theory_mode="humanistic", + persona=card, + state=state_machine.SessionState( + resistance=card.base_resistance(), + ideation_stage=card.ideation_baseline(), + ), + ) + store.put(sess) + return sess + + +async def _consume_event_source(response: object) -> bytes: + body = bytearray() + iterator = getattr(response, "body_iterator") + async for chunk in iterator: + if isinstance(chunk, str): + body.extend(chunk.encode("utf-8")) + elif isinstance(chunk, (bytes, bytearray)): + body.extend(chunk) + else: + body.extend(str(chunk).encode("utf-8")) + return bytes(body) + + +class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + store._sessions.clear() + + async def asyncTearDown(self) -> None: + store._sessions.clear() + + async def test_generate_turn_engine_failure_does_not_append_learner_turn(self) -> None: + principal = _principal() + sess = _session(principal) + + with patch.object( + sessions.orchestrator, + "run_turn_generate", + AsyncMock(side_effect=EngineError("engine unavailable: test")), + ): + with self.assertRaises(sessions.HTTPException) as caught: + await sessions.submit_turn( + sess.session_id, + sessions.TurnRequest(text="실패한 발화"), + principal, + ) + + self.assertEqual(caught.exception.status_code, 503) + self.assertEqual(sess.turns, []) + + async def test_stream_turn_engine_error_event_does_not_append_partial_turns(self) -> None: + principal = _principal() + sess = _session(principal) + + async def failing_stream(*args, **kwargs): + yield orchestrator.StreamEvent("token", {"text": "부분 응답"}) + yield orchestrator.StreamEvent("error", {"detail": "engine unavailable: stream"}) + + with patch.object(sessions.orchestrator, "run_turn_stream", failing_stream): + response = await sessions.stream_turn( + sess.session_id, + sessions.TurnRequest(text="스트림 실패 발화"), + principal, + ) + body = await _consume_event_source(response) + + self.assertIn(b"engine unavailable: stream", body) + self.assertEqual(sess.turns, []) + + async def test_voice_turn_engine_failure_does_not_append_learner_turn(self) -> None: + class FakeWebSocket: + def __init__(self) -> None: + self.messages: list[dict[str, object]] = [] + self.client_state = voice_routes.WebSocketState.CONNECTED + + async def send_text(self, data: str) -> None: + import json + + self.messages.append(json.loads(data)) + + principal = _principal() + sess = _session(principal) + websocket = FakeWebSocket() + + with patch.object( + voice_routes.orchestrator, + "run_turn_generate", + AsyncMock(side_effect=EngineError("voice engine unavailable")), + ): + await voice_routes._run_turn_and_speak( + websocket, # type: ignore[arg-type] + session_id=sess.session_id, + principal=principal, + voice_preset=VoicePreset(preset="neutral", openai_voice="sage"), + learner_text="음성 실패 발화", + ) + + self.assertTrue( + any( + message.get("type") == "error" + and "engine unavailable" in str(message.get("detail")) + for message in websocket.messages + ), + websocket.messages, + ) + self.assertEqual(sess.turns, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/api/engine_gateway/gateway.py b/apps/api/engine_gateway/gateway.py index b242a26..c57f187 100644 --- a/apps/api/engine_gateway/gateway.py +++ b/apps/api/engine_gateway/gateway.py @@ -11,17 +11,21 @@ Vignette 엔진 게이트웨이 — 로컬 claude -p(Opus 4.8) 상주 멀티턴 import asyncio import json import os +import time import uuid from typing import Any, Literal, Optional from fastapi import FastAPI, HTTPException -from fastapi.responses import StreamingResponse +from fastapi.responses import JSONResponse, StreamingResponse from pydantic import BaseModel, Field CLAUDE_BIN = os.environ.get("CLAUDE_BIN", "claude") DEFAULT_MODEL = os.environ.get("ENGINE_MODEL", "") # 비우면 CLI 기본(Opus 4.8) FALLBACK_MODEL = os.environ.get("ENGINE_FALLBACK_MODEL", "") DEFAULT_BUDGET = float(os.environ.get("SESSION_BUDGET_USD", "5.0")) +READY_TTL_SECONDS = float(os.environ.get("ENGINE_READY_TTL_SECONDS", "30")) +READY_TIMEOUT_SECONDS = float(os.environ.get("ENGINE_READY_TIMEOUT_SECONDS", "20")) +READY_BUDGET_USD = float(os.environ.get("ENGINE_READY_BUDGET_USD", "0.5")) BASE_ARGS = [ "-p", @@ -32,13 +36,26 @@ BASE_ARGS = [ ] +def _model_override(model: Optional[str]) -> Optional[str]: + value = (model or "").strip() + if not value or value == "gateway-default": + return None + return value + + class EngineSession: """claude -p 상주 프로세스 1개 = 상담 회기 1개.""" - def __init__(self, system_prompt: str | None = None, budget: float = DEFAULT_BUDGET): + def __init__( + self, + system_prompt: str | None = None, + budget: float = DEFAULT_BUDGET, + model: str | None = None, + ): self.id = uuid.uuid4().hex self.system_prompt = system_prompt self.budget = budget + self.model = _model_override(model) self.proc: asyncio.subprocess.Process | None = None self.lock = asyncio.Lock() # 한 회기 안의 턴은 직렬(상담 왕복) self.cost_usd = 0.0 @@ -46,8 +63,9 @@ class EngineSession: async def start(self) -> None: args = [CLAUDE_BIN, *BASE_ARGS, "--max-budget-usd", str(self.budget)] - if DEFAULT_MODEL: - args += ["--model", DEFAULT_MODEL] + model = self.model or DEFAULT_MODEL + if model: + args += ["--model", model] if FALLBACK_MODEL: args += ["--fallback-model", FALLBACK_MODEL] if self.system_prompt: @@ -93,12 +111,19 @@ class EngineSession: result = await asyncio.wait_for(_read_until_result(), timeout=timeout) self.cost_usd = result.get("total_cost_usd", self.cost_usd) self.turns += 1 + error_detail = ( + result.get("error") + or result.get("result") + or (result.get("errors") or [None])[0] + or "".join(text_parts) + or None + ) return { "text": "".join(text_parts), "cost_usd": self.cost_usd, "turns": self.turns, "is_error": result.get("is_error", False), - "error": (result.get("errors") or [None])[0], + "error": error_detail, } async def turn_stream(self, content: str, timeout: float = 600.0): @@ -153,13 +178,20 @@ class EngineSession: elif t == "result": self.cost_usd = obj.get("total_cost_usd", self.cost_usd) self.turns += 1 + error_detail = ( + obj.get("error") + or obj.get("result") + or (obj.get("errors") or [None])[0] + or emitted + or None + ) yield { "type": "done", "text": emitted, "cost_usd": self.cost_usd, "turns": self.turns, "is_error": obj.get("is_error", False), - "error": (obj.get("errors") or [None])[0], + "error": error_detail, } return @@ -176,6 +208,8 @@ class EngineSession: SESSIONS: dict[str, EngineSession] = {} +_READY_CACHE: dict[str, Any] = {"checked_at": 0.0, "ok": False, "detail": "not checked"} +_READY_LOCK = asyncio.Lock() app = FastAPI(title="Vignette Engine Gateway") @@ -193,6 +227,62 @@ async def health(): return {"ok": True, "engine": "claude_p", "model": DEFAULT_MODEL or "default(opus-4-8)", "sessions": len(SESSIONS)} +def _ready_response(*, cached: bool, age_seconds: float = 0.0) -> JSONResponse: + ok = bool(_READY_CACHE.get("ok")) + return JSONResponse( + { + "ok": ok, + "engine": "claude_p", + "model": DEFAULT_MODEL or "default(opus-4-8)", + "sessions": len(SESSIONS), + "detail": _READY_CACHE.get("detail"), + "age_seconds": round(max(0.0, age_seconds), 3), + "cached": cached, + }, + status_code=200 if ok else 503, + ) + + +@app.get("/ready") +async def ready(force: bool = False): + """Prove that claude -p can complete a real generation. + + /health is shallow process liveness. This endpoint catches the installed-but- + not-authenticated CLI state before a learner reaches POST /sessions/:id/turn. + """ + age = time.monotonic() - float(_READY_CACHE.get("checked_at", 0.0) or 0.0) + if not force and age < READY_TTL_SECONDS: + return _ready_response(cached=True, age_seconds=age) + + async with _READY_LOCK: + age = time.monotonic() - float(_READY_CACHE.get("checked_at", 0.0) or 0.0) + if not force and age < READY_TTL_SECONDS: + return _ready_response(cached=True, age_seconds=age) + + probe = EngineSession( + system_prompt="You are a readiness probe. Reply with exactly OK.", + budget=READY_BUDGET_USD, + ) + ok = False + detail = "unknown readiness failure" + try: + await probe.start() + result = await probe.turn("Reply with exactly OK.", timeout=READY_TIMEOUT_SECONDS) + if result.get("is_error"): + detail = str(result.get("error") or "engine returned an error") + else: + text = str(result.get("text") or "").strip() + ok = bool(text) + detail = text or "empty engine response" + except Exception as exc: + detail = str(exc) + finally: + await probe.close() + + _READY_CACHE.update({"checked_at": time.monotonic(), "ok": ok, "detail": detail}) + return _ready_response(cached=False) + + @app.post("/session") async def create_session(req: CreateReq): s = EngineSession(system_prompt=req.system_prompt, budget=req.budget_usd or DEFAULT_BUDGET) @@ -291,12 +381,18 @@ async def _resolve_session(req: GwGenerateReq, system_prompt: str) -> tuple[Engi 반환: (session, ephemeral). ephemeral=True 면 호출부가 응답 후 close 한다. """ + requested_model = _model_override(req.model) if req.session_id and req.session_id in SESSIONS: s = SESSIONS[req.session_id] if s.proc is not None and s.proc.returncode is None: - return s, False + if requested_model is None or (s.model or DEFAULT_MODEL) == requested_model: + return s, False # 단발(또는 죽은 세션) → 1회성 세션 - s = EngineSession(system_prompt=system_prompt or None, budget=DEFAULT_BUDGET) + s = EngineSession( + system_prompt=system_prompt or None, + budget=DEFAULT_BUDGET, + model=requested_model, + ) await s.start() return s, True @@ -327,7 +423,7 @@ async def v1_generate(req: GwGenerateReq): structured = None # 파싱 실패는 호출부가 text 로 폴백 return { "text": text, - "model": DEFAULT_MODEL or "claude-opus-4-8", + "model": s.model or DEFAULT_MODEL or "claude-opus-4-8", "provider": "claude_cli", "tokens_in": 0, "tokens_out": 0, diff --git a/apps/api/engine_gateway/test_gateway_model.py b/apps/api/engine_gateway/test_gateway_model.py new file mode 100644 index 0000000..b728407 --- /dev/null +++ b/apps/api/engine_gateway/test_gateway_model.py @@ -0,0 +1,125 @@ +import asyncio +import unittest +from unittest.mock import patch + +from engine_gateway import gateway + + +class _FakeStdin: + def close(self): + pass + + +class _FakeProcess: + def __init__(self): + self.returncode = None + self.stdin = _FakeStdin() + self.stdout = None + self.stderr = None + + async def wait(self): + self.returncode = 0 + + def kill(self): + self.returncode = -9 + + +def _capture_subprocess(): + captured = [] + + async def fake_create_subprocess_exec(*args, **kwargs): + captured.append(args) + return _FakeProcess() + + return captured, patch.object( + gateway.asyncio, + "create_subprocess_exec", + fake_create_subprocess_exec, + ) + + +def _request(model=None, session_id=None): + return gateway.GwGenerateReq( + ai_role="client", + messages=[gateway.GwMessage(role="user", content="hello")], + model=model, + session_id=session_id, + ) + + +def _model_arg(args): + if "--model" not in args: + return None + return args[args.index("--model") + 1] + + +class GatewayModelTest(unittest.TestCase): + def setUp(self): + gateway.SESSIONS.clear() + + def tearDown(self): + gateway.SESSIONS.clear() + + def test_resolve_session_uses_request_model_for_claude_cli(self): + captured, process_patch = _capture_subprocess() + with ( + patch.object(gateway, "DEFAULT_MODEL", "env-default"), + patch.object(gateway, "FALLBACK_MODEL", ""), + process_patch, + ): + session, ephemeral = asyncio.run( + gateway._resolve_session(_request(model=" request-model "), "system prompt") + ) + + try: + self.assertIs(ephemeral, True) + self.assertEqual(session.model, "request-model") + self.assertEqual(_model_arg(captured[0]), "request-model") + self.assertIn("--append-system-prompt", captured[0]) + finally: + asyncio.run(session.close()) + + def test_resolve_session_preserves_default_model_for_gateway_default(self): + captured, process_patch = _capture_subprocess() + with ( + patch.object(gateway, "DEFAULT_MODEL", "env-default"), + patch.object(gateway, "FALLBACK_MODEL", ""), + process_patch, + ): + session, ephemeral = asyncio.run( + gateway._resolve_session(_request(model="gateway-default"), "") + ) + + try: + self.assertIs(ephemeral, True) + self.assertIsNone(session.model) + self.assertEqual(_model_arg(captured[0]), "env-default") + finally: + asyncio.run(session.close()) + + def test_resolve_session_does_not_reuse_session_with_different_model(self): + captured, process_patch = _capture_subprocess() + existing = gateway.EngineSession(model="old-model") + existing.proc = _FakeProcess() + gateway.SESSIONS["sid"] = existing + + with ( + patch.object(gateway, "DEFAULT_MODEL", ""), + patch.object(gateway, "FALLBACK_MODEL", ""), + process_patch, + ): + session, ephemeral = asyncio.run( + gateway._resolve_session(_request(model="new-model", session_id="sid"), "") + ) + + try: + self.assertIs(ephemeral, True) + self.assertIsNot(session, existing) + self.assertEqual(_model_arg(captured[0]), "new-model") + self.assertIs(gateway.SESSIONS["sid"], existing) + finally: + asyncio.run(session.close()) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/web/README.md b/apps/web/README.md index 14047eb..ed73ad8 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -21,25 +21,34 @@ npm run typecheck # tsc --noEmit (타입 체크만) ``` 백엔드(FastAPI)는 `apps/api`에서 `uvicorn app.main:app --port 8000`으로 띄운다. -미가동이어도 프론트는 동작한다(개발용 mock 로그인으로 진입 가능). +프론트는 실제 API 세션과 DB 기반 데이터를 사용한다. 로컬 개발 로그인은 +`AUTH_DEV_LOGIN_ENABLED=true`인 개발 API에서만 제공되며, 공개 환경은 Google OAuth만 사용한다. ### 환경변수 | 변수 | 기본값 | 설명 | |---|---|---| | `VITE_API_BASE` | `/api` | API 베이스. 개발은 vite proxy, 프로덕션은 nginx가 백엔드로 라우팅 | +| `VITE_LIVE2D_CUBISM_CORE` | `/live2d/live2dcubismcore.min.js` | Cubism Core JS URL. 페르소나별 `live2dModelUrl`이 있을 때만 사용 | + +Live2D 샘플 자산은 `public/live2d/` 아래에 로컬 통합 테스트용으로 남겨 둔다. +세션 아바타는 기본적으로 SVG/persona 렌더러를 사용하며, 실제 페르소나 전용 +`AvatarPersona.live2dModelUrl`이 있을 때만 Cubism 모델을 렌더링한다. +Mao/Haru 샘플 모델을 모든 페르소나의 공용 프로덕션 폴백으로 쓰지 않는다. +프로덕션 빌드는 번들된 Mao/Haru 샘플 URL을 무시하고 SVG 아바타를 유지한다. +모델이 없거나 로딩에 실패하면 세션 화면은 기존 SVG 아바타를 그대로 렌더링한다. ## 라우트 | 경로 | 페이지 | 역할 | 비고 | |---|---|---|---| -| `/login` | Login | — | 완성. OAuth(한신대/구글) mock + 역할 선택 | -| `/learn` | LearnerHome | learner | 스텁 (Features 단계 교체) | -| `/learn/session/:sessionId` | Session | learner | 스텁 + 동작하는 아바타 미리보기 | -| `/learn/session/:sessionId/review` | SessionReview | learner | 스텁 | -| `/teach` | Professor | teacher | 스텁. `data-role=instructor` accent | -| `/admin` | Admin | admin | 스텁. `data-role=admin` accent | -| `/settings` | Settings | 전체 | 스텁 | +| `/login` | Login | — | 실제 Google OAuth + 개발 API 한정 로컬 로그인 | +| `/learn` | LearnerHome | learner | 실제 persona catalog와 서버 세션 기록 | +| `/learn/session/:sessionId` | Session | learner | 실제 세션 시작/이어하기 + 서버 SSE 응답 | +| `/learn/session/:sessionId/review` | SessionReview | learner | 저장된 세션 기반 리뷰/기록 | +| `/teach` | Professor | teacher | 실제 담당 학습자/회기 요약 | +| `/admin` | Admin | admin | 실제 사용자 관리, health, AI 운영 설정 | +| `/settings` | Settings | 전체 | 실제 계정/환경설정, 관리자 AI 운영 설정 | | `/` | → 역할 홈 | — | 미인증이면 `/login` | 보호 라우트는 `AuthContext` 기반 `RequireAuth` 가드(미인증 → `/login`, @@ -64,8 +73,9 @@ src/ ProgressBar Field/Input Dot Icon(inline svg) SectionHead. 배럴: index.ts → import { Button, ... } from "../components/ui" shell/ AppShell Sidebar Topbar (§6.3 공통 셸). - avatar/ClientAvatar.tsx 가상 내담자 SVG 아바타(동작 placeholder). - pages/ Login(완성) + 6개 스텁. + avatar/ClientAvatar.tsx 가상 내담자 아바타(Live2D + SVG 폴백). + avatar/Live2DAvatar.tsx Cubism 4/Pixi 런타임 브리지. + pages/ Login + learner/teacher/admin/settings/session 화면. ``` ## 디자인 철칙 (위반 시 재작업) diff --git a/apps/web/e2e/README.md b/apps/web/e2e/README.md new file mode 100644 index 0000000..1c8ac02 --- /dev/null +++ b/apps/web/e2e/README.md @@ -0,0 +1,51 @@ +# Playwright E2E + +These tests exercise the app through the Vite `/api` proxy and a running local +FastAPI server. They do not replace `/auth/me`, `/personas`, sessions, admin, or +review endpoints with Playwright route fixtures. + +Required local services: + +```sh +# apps/api +python -m uvicorn app.main:app --host 127.0.0.1 --port 8000 + +# apps/web, started automatically by Playwright unless already running +npm run dev -- --host 127.0.0.1 --port 5173 +``` + +Useful overrides: + +```sh +PLAYWRIGHT_PORT=5174 npm run e2e +PLAYWRIGHT_BASE_URL=http://localhost:5173 npm run e2e +VITE_API_BASE=http://127.0.0.1:8000 npm run e2e +``` + +Public Google OAuth `/turn` smoke: + +```sh +# 1) Verify the public API is not accidentally serving the dev runtime. +$env:E2E_PUBLIC_AUTH="1" +npx playwright test e2e/public-auth-turn.spec.ts --project=chromium-public-auth --grep "production-safe" + +# 2) Open a browser, sign in with an allowed Google account, then close codegen. +npx playwright codegen https://vignette.chanpaca.net/login --save-storage=./node_modules/.tmp/public-auth.json + +# 3) Reuse that authenticated storage state for the public API turn smoke. +$env:E2E_PUBLIC_AUTH="1" +$env:E2E_PUBLIC_STORAGE_STATE="./node_modules/.tmp/public-auth.json" +npx playwright test e2e/public-auth-turn.spec.ts --project=chromium-public-auth +``` + +Notes: + +- `E2E_PUBLIC_AUTH=1` targets the public site and does not start the local Vite web server. +- `public-auth.json` contains the HttpOnly API session cookie exported by Playwright. Treat it as sensitive and keep it under `node_modules/.tmp`. +- The API session TTL is currently 8 hours, so recapture storage state when the smoke begins returning `401`. + +Install browser binaries once with: + +```sh +npx playwright install chromium +``` diff --git a/apps/web/e2e/admin.spec.ts b/apps/web/e2e/admin.spec.ts new file mode 100644 index 0000000..90ed0de --- /dev/null +++ b/apps/web/e2e/admin.spec.ts @@ -0,0 +1,304 @@ +import { expect, test, type APIResponse, type Page, type Response, type TestInfo } from "@playwright/test"; +import { + expectNoHorizontalOverflow, + signInAsLearner, + useRealApi, + withGlobalEngineConfigLock, +} from "./support"; + +type AdminHealthStatus = "ok" | "degraded" | "down"; + +interface AdminServiceHealth { + key: string; + name: string; + status: AdminHealthStatus; + detail: string; + metric: string; + load: number; +} + +interface AdminHealthResponse { + status: AdminHealthStatus; + environment: string; + engine_mode: string; + services: AdminServiceHealth[]; +} + +interface AdminManagedUser { + user_id: string; + email: string; + display_name: string; + role: "learner" | "teacher" | "admin"; + affiliation: string; + cohort_ids: string[]; +} + +interface AdminUsersResponse { + source: "database" | "server_session_registry"; + durable: boolean; + users: AdminManagedUser[]; +} + +async function expectResponseOk(response: APIResponse | Response) { + if (!response.ok()) { + expect(response.ok(), await response.text()).toBeTruthy(); + } +} + +async function signInAsAdmin(page: Page) { + const res = await page.request.post("/api/auth/dev-login", { + data: { + email: "admin@twentyoz.kr", + role: "admin", + display_name: "E2E Admin", + }, + }); + await expectResponseOk(res); +} + +function isAdminHealthResponse(response: Response) { + const url = new URL(response.url()); + return response.request().method() === "GET" && url.pathname.endsWith("/admin/health"); +} + +function isAdminUsersResponse(response: Response) { + const url = new URL(response.url()); + return response.request().method() === "GET" && url.pathname.endsWith("/admin/users"); +} + +function isAdminUserCreate(response: Response) { + const url = new URL(response.url()); + return response.request().method() === "POST" && url.pathname.endsWith("/admin/users"); +} + +function isAdminUserPatch(userId: string) { + return (response: Response) => { + const url = new URL(response.url()); + return ( + response.request().method() === "PATCH" && + url.pathname.endsWith(`/admin/users/${userId}`) + ); + }; +} + +function isAdminUserDelete(userId: string) { + return (response: Response) => { + const url = new URL(response.url()); + return ( + response.request().method() === "DELETE" && + url.pathname.endsWith(`/admin/users/${userId}`) + ); + }; +} + +function environmentLabel(value: string) { + if (value === "prod") return "운영"; + if (value === "staging") return "스테이징"; + if (value === "dev") return "개발"; + return value; +} + +function engineModeLabel(value: string) { + if (value === "claude_cli") return "Claude CLI 게이트웨이"; + if (value === "claude_api" || value === "messages_api") return "Anthropic API"; + if (value === "openai") return "OpenAI 호환"; + if (value === "solar") return "Solar"; + return value; +} + +async function openAdminAndReadHealth(page: Page) { + const healthResponsePromise = page.waitForResponse(isAdminHealthResponse); + + await page.goto("/admin"); + + const healthResponse = await healthResponsePromise; + await expectResponseOk(healthResponse); + + const health = (await healthResponse.json()) as AdminHealthResponse; + expect(health.services.length).toBeGreaterThan(0); + return health; +} + +async function openAdminAndReadUsers(page: Page) { + const usersResponsePromise = page.waitForResponse(isAdminUsersResponse); + + await page.goto("/admin"); + + const usersResponse = await usersResponsePromise; + await expectResponseOk(usersResponse); + return (await usersResponse.json()) as AdminUsersResponse; +} + +test.describe("admin route", () => { + test.beforeEach(async ({ page }) => { + await useRealApi(page); + }); + + test("allows an admin to open the live health dashboard", async ({ page }) => { + await signInAsAdmin(page); + + await withGlobalEngineConfigLock("admin-health-dashboard", async () => { + const health = await openAdminAndReadHealth(page); + const serviceCards = page.locator(".ad-service:not(.ad-service--skeleton)"); + const counts = { + ok: health.services.filter((service) => service.status === "ok").length, + degraded: health.services.filter((service) => service.status === "degraded").length, + down: health.services.filter((service) => service.status === "down").length, + }; + + await expect(page).toHaveURL(/\/admin$/); + await expect(page.locator(".ad-status")).toContainText(environmentLabel(health.environment)); + await expect(page.locator(".ad-status")).toContainText(engineModeLabel(health.engine_mode)); + await expect(page.locator(".ad-kpi b")).toHaveText([ + String(health.services.length), + String(counts.ok), + String(counts.degraded), + String(counts.down), + ]); + await expect(serviceCards).toHaveCount(health.services.length); + + for (const service of health.services) { + const card = serviceCards.filter({ hasText: service.name }); + await expect(card).toBeVisible(); + await expect(card).toContainText(service.detail); + expect(service.load, `${service.key} load must be normalized`).toBeGreaterThanOrEqual(0); + expect(service.load, `${service.key} load must be normalized`).toBeLessThanOrEqual(1); + + if (service.key === "engine" && service.status === "ok") { + await expect(card).toContainText(/\d+ms/); + } else if (service.key === "db" && service.status === "ok") { + await expect(card).toContainText(/풀 \d+\/\d+/); + } else if (service.key === "evaluation") { + await expect(card).toContainText(/대기 \d+건/); + } else if (service.key === "kb" && service.status === "ok") { + await expect(card).toContainText(/활성 세션 \d+건/); + } else { + await expect(card).toContainText(service.metric); + } + } + + const byKey = Object.fromEntries(health.services.map((service) => [service.key, service])); + if (byKey.engine?.status === "ok") { + expect(byKey.engine.metric).toMatch(/^\d+ms$/); + } + if (byKey.db?.status === "ok") { + expect(byKey.db.metric).toMatch(/^풀 \d+\/\d+$/); + } + expect(byKey.evaluation?.metric).toMatch(/^대기 \d+건$/); + if (byKey.kb?.status === "ok") { + expect(byKey.kb.metric).toMatch(/^활성 세션 \d+건$/); + } + }); + }); + + test("allows an admin to manage real server-known users", async ({ page }, testInfo) => { + await signInAsAdmin(page); + + const slug = `${testInfo.project.name}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`; + const email = `admin-users.${slug}@hs.ac.kr`.toLowerCase(); + const displayName = `관리 대상 ${testInfo.project.name}`; + const users = await openAdminAndReadUsers(page); + expect(users.source).toBe("database"); + expect(users.durable).toBe(true); + + await page.getByLabel("사용자 검색").fill(`no-match-${slug}`); + await expect(page.getByText("검색 조건에 맞는 사용자가 없습니다.")).toBeVisible(); + await expect(page.getByText("아직 등록된 사용자가 없습니다.")).toHaveCount(0); + await page.getByLabel("사용자 검색").fill(""); + + await page.getByLabel("새 사용자 이메일").fill(email); + await page.getByLabel("새 사용자 표시 이름").fill(displayName); + await page.getByLabel("새 사용자 역할").selectOption("learner"); + await page.getByLabel("새 사용자 코호트").fill(`created-${testInfo.project.name}`); + + const createPromise = page.waitForResponse(isAdminUserCreate); + const reloadAfterCreatePromise = page.waitForResponse(isAdminUsersResponse); + await page.getByRole("button", { name: "사용자 등록" }).click(); + const createResponse = await createPromise; + await expectResponseOk(createResponse); + await expectResponseOk(await reloadAfterCreatePromise); + const created = (await createResponse.json()) as AdminManagedUser; + expect(created).toMatchObject({ + email, + display_name: displayName, + role: "learner", + cohort_ids: [`created-${testInfo.project.name}`], + }); + + await page.getByLabel("사용자 검색").fill(email); + const card = page.locator(".ad-user").filter({ hasText: email }); + await expect(card).toBeVisible(); + await expect(card).toContainText(displayName); + + const nextName = `교수자 ${testInfo.project.name}`; + const nameInput = card.getByLabel(`${email} 표시 이름`); + const roleSelect = card.getByLabel(`${email} 역할`); + const cohortInput = card.getByLabel(`${email} 코호트`); + const saveButton = card.getByRole("button", { name: "저장" }); + const nextCohort = `cohort-${testInfo.project.name}`; + await nameInput.fill(nextName); + await expect(nameInput).toHaveValue(nextName); + await page.evaluate(() => new Promise(requestAnimationFrame)); + await roleSelect.selectOption("teacher"); + await expect(roleSelect).toHaveValue("teacher"); + await cohortInput.fill(nextCohort); + await expect(cohortInput).toHaveValue(nextCohort); + await page.evaluate(() => new Promise(requestAnimationFrame)); + await expect(saveButton).toBeEnabled(); + + const patchPromise = page.waitForResponse(isAdminUserPatch(created.user_id)); + await saveButton.click(); + const patchResponse = await patchPromise; + await expectResponseOk(patchResponse); + const updated = (await patchResponse.json()) as AdminManagedUser; + expect(updated).toMatchObject({ + user_id: created.user_id, + email, + display_name: nextName, + role: "teacher", + cohort_ids: [nextCohort], + }); + + await expect(card).toContainText(nextName); + await expect(card).toContainText("교수자"); + await expect(card).toContainText(nextCohort); + + const deletePromise = page.waitForResponse(isAdminUserDelete(created.user_id)); + await card.getByRole("button", { name: "비활성화" }).click(); + const deleteResponse = await deletePromise; + await expectResponseOk(deleteResponse); + await expect(card).toHaveCount(0); + + const blockedLogin = await page.request.post("/api/auth/dev-login", { + data: { + email, + role: "teacher", + display_name: nextName, + }, + }); + expect(blockedLogin.status(), await blockedLogin.text()).toBe(403); + }); + + test("denies learner access to the admin API and UI", async ({ page }) => { + await signInAsLearner(page); + + const denied = await page.request.get("/api/admin/health"); + expect(denied.status(), await denied.text()).toBe(403); + const deniedUsers = await page.request.get("/api/admin/users"); + expect(deniedUsers.status(), await deniedUsers.text()).toBe(403); + + await page.goto("/admin"); + + await expect(page).toHaveURL(/\/learn$/); + await expect(page.locator(".ad-root")).toHaveCount(0); + }); + + test("does not horizontally overflow at a mobile viewport", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await signInAsAdmin(page); + + await openAdminAndReadHealth(page); + + await expectNoHorizontalOverflow(page); + }); +}); diff --git a/apps/web/e2e/auth.spec.ts b/apps/web/e2e/auth.spec.ts new file mode 100644 index 0000000..7c0d9e3 --- /dev/null +++ b/apps/web/e2e/auth.spec.ts @@ -0,0 +1,183 @@ +import { expect, test } from "@playwright/test"; +import { useRealApi } from "./support"; + +const apiBase = process.env.E2E_API_BASE ?? "http://127.0.0.1:8000"; +const publicBase = process.env.E2E_PUBLIC_BASE_URL ?? "https://vignette.chanpaca.net"; +const publicApiBase = process.env.E2E_PUBLIC_API_BASE ?? "https://api-vignette.chanpaca.net"; + +function isLocalHostname(hostname: string): boolean { + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; +} + +test.describe("auth domain policy", () => { + test.beforeEach(async ({ page }) => { + await useRealApi(page); + }); + + test("requires authentication for the persona catalog", async ({ page }) => { + const response = await page.request.get("/api/personas"); + expect(response.status(), await response.text()).toBe(401); + }); + + test("allows only the configured school and operator email domains", async ({ + page, + }, testInfo) => { + const slug = `${testInfo.project.name}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`; + const allowed = [ + `domain-learner.${slug}@hs.ac.kr`, + `domain-admin.${slug}@twentyoz.kr`, + ]; + + for (const email of allowed) { + const response = await page.request.post("/api/auth/dev-login", { + data: { + email: email.toLowerCase(), + role: email.endsWith("@twentyoz.kr") ? "admin" : "learner", + display_name: "Domain Policy E2E", + }, + }); + expect(response.ok(), await response.text()).toBeTruthy(); + } + + const denied = await page.request.post("/api/auth/dev-login", { + data: { + email: `domain-denied.${slug}@example.com`.toLowerCase(), + role: "learner", + display_name: "Denied Domain", + }, + }); + + const deniedText = await denied.text(); + expect(denied.status(), deniedText).toBe(403); + expect(deniedText).toContain("email domain is not allowed"); + }); + + test("shows Google OAuth readiness without exposing a raw JSON error page", async ({ + page, + }) => { + const configResponse = await page.request.get("/api/auth/config"); + expect(configResponse.ok(), await configResponse.text()).toBeTruthy(); + const config = (await configResponse.json()) as { + google_oauth_configured: boolean; + allowed_email_domains: string[]; + redirect_uri: string; + dev_login_enabled: boolean; + }; + + await page.goto("/login"); + + const googleButtons = page.locator(".lg-obtn"); + await expect(googleButtons).toHaveCount(2); + await expect(page.locator(".lg-policy b")).toContainText(config.allowed_email_domains); + const currentHost = new URL(page.url()).hostname; + const redirectHost = new URL(config.redirect_uri).hostname; + const localOAuthUnavailable = + isLocalHostname(currentHost) && + config.dev_login_enabled && + !isLocalHostname(redirectHost); + if (config.dev_login_enabled) { + await expect(page.locator(".lg-dev")).toBeVisible(); + } else { + await expect(page.locator(".lg-dev")).toHaveCount(0); + } + + if (config.google_oauth_configured && !localOAuthUnavailable) { + await expect(googleButtons.first()).toBeEnabled(); + await expect(page.locator(".lg-config")).toHaveCount(0); + } else { + await expect(googleButtons.first()).toBeDisabled(); + await expect(googleButtons.nth(1)).toBeDisabled(); + await expect(page.locator(".lg-config")).toBeVisible(); + await expect(page).toHaveURL(/\/login$/); + await expect(page.locator("body")).not.toContainText("Google OAuth is not configured"); + + if (localOAuthUnavailable) { + await expect(page.locator(".lg-config")).toContainText("로컬 테스트 계정으로 로그인"); + return; + } + + await page.goto("/api/auth/login?provider=google&next=%2Flearn"); + await expect(page).toHaveURL(/\/login\?oauth=not_configured$/); + await expect(page.locator(".lg-error")).toContainText("Google 로그인이 아직 연결되지 않았습니다"); + await expect(page.locator("body")).not.toContainText("Google OAuth is not configured"); + } + }); + + test("logs in locally with the server dev session and redirects to learner home", async ({ + page, + }) => { + await page.goto("/login"); + + await expect(page.locator(".lg-dev")).toBeVisible(); + await Promise.all([ + page.waitForURL(/\/learn(?:$|[/?#])/), + page.getByRole("button", { name: "로컬 테스트 계정으로 계속" }).click(), + ]); + + const me = await page.request.get("/api/auth/me"); + expect(me.status(), await me.text()).toBe(200); + await expect(page.getByRole("heading", { name: "오늘의 회기를 준비합니다." })).toBeVisible(); + }); + + test("keeps dev login closed for the public API origin", async ({ request }) => { + const publicHeaders = { + origin: "https://vignette.chanpaca.net", + "x-forwarded-host": "api-vignette.chanpaca.net", + }; + + const configResponse = await request.get(`${apiBase}/auth/config`, { + headers: publicHeaders, + }); + expect(configResponse.ok(), await configResponse.text()).toBeTruthy(); + const config = (await configResponse.json()) as { dev_login_enabled: boolean }; + expect(config.dev_login_enabled).toBe(false); + + const response = await request.post(`${apiBase}/auth/dev-login`, { + headers: publicHeaders, + data: { + email: "public-dev-login-probe@hs.ac.kr", + role: "learner", + display_name: "Public Probe", + }, + }); + + expect(response.status(), await response.text()).toBe(404); + }); + + test("keeps the public login screen on real Google OAuth only", async ({ page, request }) => { + const configResponse = await request.get(`${publicApiBase}/auth/config`, { + headers: { + origin: publicBase, + "x-forwarded-host": "api-vignette.chanpaca.net", + }, + }); + expect(configResponse.ok(), await configResponse.text()).toBeTruthy(); + const config = (await configResponse.json()) as { + google_oauth_configured: boolean; + allowed_email_domains: string[]; + dev_login_enabled: boolean; + }; + expect(config).toMatchObject({ + google_oauth_configured: true, + dev_login_enabled: false, + }); + expect(config.allowed_email_domains).toEqual(expect.arrayContaining(["hs.ac.kr", "twentyoz.kr"])); + + await page.goto(`${publicBase}/login`, { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => undefined); + + await expect(page.locator(".lg-obtn")).toHaveCount(2); + await expect(page.locator(".lg-dev")).toHaveCount(0); + await expect(page.getByText("로컬 테스트")).toHaveCount(0); + await expect(page.getByText("로컬 테스트 계정으로 계속")).toHaveCount(0); + await expect(page.locator("body")).not.toContainText("Google OAuth is not configured"); + await expect(page.locator(".lg-obtn").first()).toBeEnabled(); + + await Promise.all([ + page.waitForURL(/accounts\.google\.com/, { timeout: 20_000 }), + page.locator(".lg-obtn").first().click(), + ]); + expect(page.url()).toContain("client_id="); + expect(page.url()).toContain("redirect_uri=https%3A%2F%2Fapi-vignette.chanpaca.net%2Fauth%2Fcallback"); + }); +}); diff --git a/apps/web/e2e/db-persistence.spec.ts b/apps/web/e2e/db-persistence.spec.ts new file mode 100644 index 0000000..cd8ada7 --- /dev/null +++ b/apps/web/e2e/db-persistence.spec.ts @@ -0,0 +1,177 @@ +import { expect, test, type APIResponse } from "@playwright/test"; +import { useRealApi, withGlobalEngineConfigLock } from "./support"; + +interface HealthResponse { + db: boolean; +} + +interface UserProfileResponse { + user_id: string; + email: string; + display_name: string; + role: string; + cohort_ids: string[]; + affiliation: string; +} + +interface UserPreferencesResponse { + theme: string; + voice_preset_id: string; + voice_rate: number; + notifications: { + session_done: boolean; + safety_signal: boolean; + learner_progress: boolean; + product_news: boolean; + }; +} + +interface AdminUsersResponse { + source: "database" | "server_session_registry"; + durable: boolean; + users: UserProfileResponse[]; +} + +interface AdminEngineConfigResponse { + engine_mode: string; + engine_url: string; + model: string; + updated_by: string | null; + updated_at: number | null; +} + +async function expectResponseOk(response: APIResponse) { + expect(response.ok(), await response.text()).toBeTruthy(); +} + +function slugFor(testInfo: { project: { name: string }; workerIndex: number; retry: number }) { + const project = testInfo.project.name.includes("mobile") ? "mob" : "desk"; + return `${project}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`; +} + +test.describe("database-backed runtime state", () => { + test.beforeEach(async ({ page }) => { + await useRealApi(page); + }); + + test("persists user settings and admin AI configuration through DB-backed APIs @single-run", async ({ + page, + }, testInfo) => { + const healthResponse = await page.request.get("/api/health"); + await expectResponseOk(healthResponse); + const health = (await healthResponse.json()) as HealthResponse; + test.skip(!health.db, "PostgreSQL is not connected for this run"); + + const slug = slugFor(testInfo); + const learnerEmail = `db-persist.${slug}@hs.ac.kr`.toLowerCase(); + const learnerName = `DB Persist Learner ${testInfo.project.name}`; + const nextName = `DB Persist Updated ${testInfo.project.name}`; + const affiliation = "Hanshin University"; + + const learnerLogin = await page.request.post("/api/auth/dev-login", { + data: { + email: learnerEmail, + role: "learner", + display_name: learnerName, + }, + }); + await expectResponseOk(learnerLogin); + + const profilePatch = await page.request.patch("/api/users/me", { + data: { + display_name: nextName, + affiliation, + }, + }); + await expectResponseOk(profilePatch); + const profile = (await profilePatch.json()) as UserProfileResponse; + expect(profile).toMatchObject({ + email: learnerEmail, + display_name: nextName, + affiliation, + }); + + const preferencesPatch = await page.request.patch("/api/users/me/preferences", { + data: { + theme: "dark", + voice_rate: 1.1, + notifications: { + session_done: true, + safety_signal: true, + learner_progress: true, + product_news: true, + }, + }, + }); + await expectResponseOk(preferencesPatch); + const preferences = (await preferencesPatch.json()) as UserPreferencesResponse; + expect(preferences.theme).toBe("dark"); + expect(preferences.voice_rate).toBeCloseTo(1.1); + expect(preferences.notifications.product_news).toBe(true); + + const adminEmail = `db-persist-admin.${slug}@twentyoz.kr`.toLowerCase(); + const adminLogin = await page.request.post("/api/auth/dev-login", { + data: { + email: adminEmail, + role: "admin", + display_name: `DB Persist Admin ${testInfo.project.name}`, + }, + }); + await expectResponseOk(adminLogin); + + const usersResponse = await page.request.get("/api/admin/users"); + await expectResponseOk(usersResponse); + const users = (await usersResponse.json()) as AdminUsersResponse; + expect(users.source).toBe("database"); + expect(users.durable).toBe(true); + const learner = users.users.find((user) => user.email === learnerEmail); + expect(learner, `Expected ${learnerEmail} in DB-backed admin list`).toBeTruthy(); + expect(learner).toMatchObject({ + display_name: nextName, + affiliation, + }); + + await withGlobalEngineConfigLock(`db-persistence-${slug}`, async () => { + const engineResponse = await page.request.get("/api/admin/engine-config"); + await expectResponseOk(engineResponse); + const currentEngine = (await engineResponse.json()) as AdminEngineConfigResponse; + const nextModel = `db-persist-${slug}`; + + try { + const enginePatch = await page.request.patch("/api/admin/engine-config", { + data: { + engine_mode: currentEngine.engine_mode, + engine_url: currentEngine.engine_url, + model: nextModel, + }, + }); + await expectResponseOk(enginePatch); + const updatedEngine = (await enginePatch.json()) as AdminEngineConfigResponse; + expect(updatedEngine).toMatchObject({ + engine_mode: currentEngine.engine_mode, + engine_url: currentEngine.engine_url, + model: nextModel, + updated_by: adminEmail, + }); + expect(updatedEngine.updated_at).toBeGreaterThan(0); + + const persistedEngineResponse = await page.request.get("/api/admin/engine-config"); + await expectResponseOk(persistedEngineResponse); + const persistedEngine = (await persistedEngineResponse.json()) as AdminEngineConfigResponse; + expect(persistedEngine).toMatchObject({ + model: nextModel, + updated_by: adminEmail, + }); + } finally { + const restoreResponse = await page.request.patch("/api/admin/engine-config", { + data: { + engine_mode: currentEngine.engine_mode, + engine_url: currentEngine.engine_url, + model: currentEngine.model, + }, + }); + await expectResponseOk(restoreResponse); + } + }); + }); +}); diff --git a/apps/web/e2e/learner.spec.ts b/apps/web/e2e/learner.spec.ts new file mode 100644 index 0000000..9f98de5 --- /dev/null +++ b/apps/web/e2e/learner.spec.ts @@ -0,0 +1,212 @@ +import { expect, test } from "@playwright/test"; +import { + expectNoDocumentOverflow, + expectNoHorizontalOverflow, + fetchAvailablePersona, + fetchAvailablePersonas, + signInAsLearner, + useRealApi, +} from "./support"; + +async function signInAsLearnerEmail( + page: import("@playwright/test").Page, + email: string, + displayName = "History Learner", +) { + const res = await page.request.post("/api/auth/dev-login", { + data: { + email, + role: "learner", + display_name: displayName, + }, + }); + expect(res.ok(), await res.text()).toBeTruthy(); +} + +async function createPracticeSession(page: import("@playwright/test").Page, personaCode?: string) { + const selectedPersonaCode = personaCode ?? (await fetchAvailablePersona(page)).code; + const res = await page.request.post("/api/sessions", { + data: { + persona_code: selectedPersonaCode, + theory_mode: "humanistic", + }, + }); + expect(res.ok(), await res.text()).toBeTruthy(); + return (await res.json()) as { session_id: string }; +} + +async function expectNoLearnerInternalCopy(page: import("@playwright/test").Page) { + await expect(page.getByText(/API|GET \/|OPENAI_API_KEY|teacher\/dashboard/)).toHaveCount(0); +} + +async function expectVisibleResumeLoadedSignal(page: import("@playwright/test").Page) { + const result = await page.evaluate(() => { + const candidates = Array.from( + document.querySelectorAll( + ".sx-page--active .sx-mobile-context__resume, .sx-page--active .sx-mic-block__h", + ), + ); + return candidates.map((el) => { + const rect = el.getBoundingClientRect(); + const style = window.getComputedStyle(el); + return { + text: el.textContent ?? "", + visible: + style.display !== "none" && + style.visibility !== "hidden" && + Number(style.opacity) !== 0 && + rect.width > 0 && + rect.height > 0, + }; + }); + }); + + expect( + result.some( + (entry) => entry.visible && entry.text.includes("이전 회기 기록을 불러왔습니다."), + ), + `Expected visible resume-loaded signal: ${JSON.stringify(result)}`, + ).toBeTruthy(); +} + +test.describe("learner app shell and session launcher", () => { + test.beforeEach(async ({ page }) => { + await useRealApi(page); + }); + + test("redirects an unauthenticated learner route to login", async ({ page }) => { + await page.goto("/learn"); + + await expect(page).toHaveURL(/\/login$/); + await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible(); + expect(await page.evaluate(() => window.localStorage.getItem("vignette.dev-auth"))).toBeNull(); + }); + + test("renders API personas without legacy session rows", async ({ page }) => { + await signInAsLearnerEmail(page, `learner.catalog.${Date.now()}@hs.ac.kr`, "Catalog Learner"); + await page.goto("/learn"); + const personas = await fetchAvailablePersonas(page); + + const launcher = page.getByRole("listbox", { name: "연습 페르소나" }); + await expect(launcher).toBeVisible(); + await expect(launcher.getByRole("option")).toHaveCount(personas.length); + for (const persona of personas) { + await expect(launcher.getByRole("option", { name: new RegExp(persona.code) })).toBeVisible(); + await expect(launcher.getByRole("option", { name: new RegExp(persona.display_name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) })).toBeVisible(); + } + + await expect(page.getByText(/12회/)).toHaveCount(0); + await expect(page.getByText(/최근 연습/)).toHaveCount(0); + await expect(page.locator(".vg-nav")).toHaveCount(0); + await expect(page.locator(".vg-main")).toHaveClass(/(^|\s)vg-main--bleed(\s|$)/); + await expect(page.getByText("음성")).toBeInViewport(); + await expect(page.getByRole("button", { name: "새 회기 시작" })).toBeInViewport(); + await expectNoLearnerInternalCopy(page); + await expectNoDocumentOverflow(page); + await expectNoHorizontalOverflow(page); + }); + + test("routes the launcher CTA to the selected database persona", async ({ page }) => { + await signInAsLearner(page); + await page.goto("/learn"); + const persona = await fetchAvailablePersona(page, 1); + + const option = page.getByRole("option", { name: new RegExp(persona.code) }); + await option.click(); + await expect(option).toHaveAttribute("aria-selected", "true"); + + await page.getByRole("button", { name: "새 회기 시작" }).click(); + + await expect(page).toHaveURL(new RegExp(`/learn/session/${persona.code}$`)); + await expect(page.getByRole("button", { name: "회기 시작" })).toBeVisible(); + await expectNoDocumentOverflow(page); + + await page.getByRole("button", { name: "회기 시작" }).click(); + + await expect(page.locator(".sx-grid")).toBeVisible(); + await expectNoLearnerInternalCopy(page); + await expectNoDocumentOverflow(page); + await expectNoHorizontalOverflow(page); + }); + + test("shows real session history with resume, record, and retry actions", async ({ + page, + }, testInfo) => { + const suffix = `${testInfo.project.name.replace(/\W+/g, "-")}-${testInfo.workerIndex}-${Date.now()}`.toLowerCase(); + await signInAsLearnerEmail(page, `history.${suffix}@hs.ac.kr`); + + const persona = await fetchAvailablePersona(page); + const active = await createPracticeSession(page, persona.code); + const ended = await createPracticeSession(page, persona.code); + const endResponse = await page.request.post(`/api/sessions/${ended.session_id}/end`); + expect(endResponse.ok(), await endResponse.text()).toBeTruthy(); + + await page.goto("/learn"); + + await expect(page.getByText("기존 회기")).toBeVisible(); + await expect(page.getByRole("button", { name: "이어하기" })).toBeVisible(); + await expect(page.getByRole("button", { name: "기록" })).toBeVisible(); + await expect(page.getByRole("button", { name: "다시 연습" })).toHaveCount(2); + await expect(page.getByRole("button", { name: "새 회기 시작" })).toBeInViewport(); + await expect(page.getByRole("button", { name: "이어하기" })).toBeInViewport(); + await expect(page.getByRole("button", { name: "기록" })).toBeInViewport(); + await expect(page.getByRole("button", { name: "다시 연습" }).first()).toBeInViewport(); + await expect(page.locator(".lh-activity__stats")).toContainText("누적 회기"); + await expect(page.locator(".lh-activity__stats")).toContainText("2"); + + await page.getByRole("button", { name: "이어하기" }).click(); + + await expect(page).toHaveURL(new RegExp(`/learn/session/${active.session_id}$`)); + await expect(page.locator(".sx-page.sx-page--active")).toBeVisible(); + await expectVisibleResumeLoadedSignal(page); + await expectNoDocumentOverflow(page); + await expectNoHorizontalOverflow(page); + }); + + test("does not fall back to P1 for an unavailable persona code", async ({ page }) => { + await signInAsLearner(page); + await page.goto("/learn/session/P9"); + + await expect(page.getByText(/P9 페르소나는 현재 연습 목록에 없습니다/)).toBeVisible(); + await expect(page.getByText("페르소나 P9")).toHaveCount(0); + await expect( + page.getByRole("heading", { name: "연습 대상 정보를 확인하고 있습니다." }), + ).toBeVisible(); + await expect(page.getByRole("button", { name: "회기 시작" })).toBeDisabled(); + await expectNoDocumentOverflow(page); + await expectNoHorizontalOverflow(page); + }); + + test("blocks direct session starts for degraded or non-database personas", async ({ page }) => { + await signInAsLearner(page); + await page.route("**/api/personas", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { + code: "SEEDX", + display_name: "검증 불가 내담자", + difficulty: "easy", + theory_target: ["humanistic"], + demographics: { age_band: "20대" }, + presenting_summary: "서버 카탈로그 원본이 확인되지 않은 항목", + voice_preset: null, + source: "seed_fallback", + degraded: true, + }, + ]), + }); + }); + + await page.goto("/learn/session/SEEDX"); + + await expect( + page.getByRole("heading", { name: "이 내담자는 현재 연습에 사용할 수 없습니다." }), + ).toBeVisible(); + await expect(page.getByText("카탈로그 원본을 확인하지 못해 현재 연습에 사용할 수 없습니다.")).toBeVisible(); + await expect(page.getByRole("button", { name: "회기 시작" })).toBeDisabled(); + await expectNoDocumentOverflow(page); + await expectNoHorizontalOverflow(page); + }); +}); diff --git a/apps/web/e2e/public-auth-turn.spec.ts b/apps/web/e2e/public-auth-turn.spec.ts new file mode 100644 index 0000000..22ff8b5 --- /dev/null +++ b/apps/web/e2e/public-auth-turn.spec.ts @@ -0,0 +1,160 @@ +import { expect, test } from "@playwright/test"; + +const publicApiBase = process.env.E2E_PUBLIC_API_BASE ?? "https://api-vignette.chanpaca.net"; + +interface PublicHealthResponse { + status: string; + environment: string; + db: boolean; + engine: boolean; +} + +async function browserFetchJson( + page: import("@playwright/test").Page, + path: string, + init: RequestInit = {}, +) { + return page.evaluate( + async ({ apiBase, apiPath, requestInit }) => { + const response = await fetch(`${apiBase}${apiPath}`, { + credentials: "include", + ...requestInit, + headers: { + "content-type": "application/json", + ...(requestInit.headers ?? {}), + }, + }); + const text = await response.text(); + let json: unknown = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = null; + } + return { + ok: response.ok, + status: response.status, + text, + json, + }; + }, + { + apiBase: publicApiBase, + apiPath: path, + requestInit: { + ...init, + body: typeof init.body === "string" ? init.body : undefined, + }, + }, + ) as Promise<{ ok: boolean; status: number; text: string; json: T | null }>; +} + +test.describe("public Google OAuth turn smoke", () => { + test("uses production-safe public API configuration @public-auth", async ({ + request, + playwright, + }) => { + const healthResponse = await request.get(`${publicApiBase}/health`); + expect(healthResponse.ok(), await healthResponse.text()).toBeTruthy(); + const health = (await healthResponse.json()) as PublicHealthResponse; + + expect(health.environment, JSON.stringify(health)).not.toBe("dev"); + expect(health.db, JSON.stringify(health)).toBe(true); + expect(health.engine, JSON.stringify(health)).toBe(true); + + const configResponse = await request.get(`${publicApiBase}/auth/config`, { + headers: { + origin: "https://vignette.chanpaca.net", + "x-forwarded-host": "api-vignette.chanpaca.net", + }, + }); + expect(configResponse.ok(), await configResponse.text()).toBeTruthy(); + await expect(await configResponse.json()).toMatchObject({ + google_oauth_configured: true, + allowed_email_domains: expect.arrayContaining(["hs.ac.kr", "twentyoz.kr"]), + dev_login_enabled: false, + }); + + const unauthRequest = await playwright.request.newContext(); + try { + const personasResponse = await unauthRequest.get(`${publicApiBase}/personas`); + expect(personasResponse.status(), await personasResponse.text()).toBe(401); + } finally { + await unauthRequest.dispose(); + } + }); + + test("starts a real public session and completes one /turn @public-auth", async ({ page }) => { + if (!process.env.E2E_PUBLIC_STORAGE_STATE) { + throw new Error( + "Set E2E_PUBLIC_STORAGE_STATE to a Playwright storageState file captured after Google login.", + ); + } + + await page.goto("/learn", { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => undefined); + await expect(page).toHaveURL(/\/learn(?:$|[/?#])/); + await expect(page.getByRole("button", { name: "새 회기 시작" })).toBeVisible({ + timeout: 15_000, + }); + + const me = await browserFetchJson<{ email?: string; role?: string }>(page, "/auth/me"); + expect(me.status, me.text).toBe(200); + expect(me.json).toMatchObject({ role: "learner" }); + + const personas = await browserFetchJson< + Array<{ code: string; source: string; degraded: boolean }> + >(page, "/personas"); + expect(personas.status, personas.text).toBe(200); + const persona = personas.json?.find( + (item) => item.source === "database" && !item.degraded, + ); + expect(persona, personas.text).toBeTruthy(); + + let startedSessionId: string | null = null; + const started = await browserFetchJson<{ + session_id: string; + degraded: boolean; + }>(page, "/sessions", { + method: "POST", + body: JSON.stringify({ + persona_code: persona!.code, + theory_mode: "humanistic", + }), + }); + expect(started.status, started.text).toBe(201); + expect(started.json).toMatchObject({ degraded: false }); + expect(started.json?.session_id).toEqual(expect.any(String)); + startedSessionId = started.json!.session_id; + + try { + const turn = await browserFetchJson<{ + client_reply?: string; + stage?: string; + turn_seq?: number; + }>(page, `/sessions/${startedSessionId}/turn`, { + method: "POST", + body: JSON.stringify({ + text: "처음 오신 자리라 긴장될 수 있어요. 지금 가장 이야기하고 싶은 것부터 천천히 말해도 괜찮습니다.", + }), + }); + expect(turn.status, turn.text).toBe(200); + expect(turn.json?.client_reply).toEqual(expect.any(String)); + expect(turn.json!.client_reply!.length).toBeGreaterThan(0); + + const detail = await browserFetchJson<{ + session_id: string; + turns: Array<{ speaker: string; text: string }>; + }>(page, `/sessions/${startedSessionId}`); + expect(detail.status, detail.text).toBe(200); + expect(detail.json?.turns.length).toBeGreaterThanOrEqual(2); + } finally { + if (startedSessionId) { + await browserFetchJson(page, `/sessions/${startedSessionId}/end`, { + method: "POST", + body: JSON.stringify({}), + }).catch(() => undefined); + } + } + }); +}); diff --git a/apps/web/e2e/readiness.spec.ts b/apps/web/e2e/readiness.spec.ts new file mode 100644 index 0000000..6f93d9e --- /dev/null +++ b/apps/web/e2e/readiness.spec.ts @@ -0,0 +1,79 @@ +import { expect, test, type APIResponse, type Page } from "@playwright/test"; +import { expectNoHorizontalOverflow } from "./support"; + +async function expectResponseOk(response: APIResponse) { + if (!response.ok()) { + expect(response.ok(), await response.text()).toBeTruthy(); + } +} + +async function signIn( + page: Page, + role: "learner" | "admin", + email: string, + displayName: string, +) { + const res = await page.request.post("/api/auth/dev-login", { + data: { email, role, display_name: displayName }, + }); + await expectResponseOk(res); +} + +test.describe("production readiness gates", () => { + test("learner-facing data comes from server persistence, not browser or seed mocks", async ({ + page, + }, testInfo) => { + const email = `readiness.${testInfo.project.name}.${testInfo.workerIndex}.${Date.now()}@hs.ac.kr`; + await signIn(page, "learner", email.toLowerCase(), "Readiness Learner"); + + const personaResponse = await page.request.get("/api/personas"); + await expectResponseOk(personaResponse); + expect(personaResponse.headers()["x-vignette-catalog-source"]).toBe("database"); + const personas = (await personaResponse.json()) as Array<{ + source: string; + degraded: boolean; + }>; + expect(personas.length).toBeGreaterThan(0); + expect(personas.every((persona) => persona.source === "database" && !persona.degraded)).toBe( + true, + ); + + const sessionsResponse = await page.request.get("/api/sessions"); + await expectResponseOk(sessionsResponse); + const sessions = (await sessionsResponse.json()) as { source: string; sessions: unknown[] }; + expect(sessions).toMatchObject({ source: "database", sessions: [] }); + + await page.goto("/learn"); + await expect(page.getByText("지금까지 12회 연습했어요")).toHaveCount(0); + await expect(page.getByText("최근 8회")).toHaveCount(0); + await expect(page.getByText("기존 회기")).toBeVisible(); + await expect(page.locator(".lh-activity__stats")).toContainText("누적 회기"); + await expect(page.locator(".lh-activity__stats")).toContainText("0"); + await expect(page.getByText("저장된 기존 회기가 없습니다.")).toBeVisible(); + await expect(page.getByRole("button", { name: "새 회기 시작" })).toBeInViewport(); + await expectNoHorizontalOverflow(page); + }); + + test("admin-owned runtime controls are durable database state", async ({ page }) => { + await signIn(page, "admin", "readiness-admin@twentyoz.kr", "Readiness Admin"); + + const usersResponse = await page.request.get("/api/admin/users"); + await expectResponseOk(usersResponse); + const users = (await usersResponse.json()) as { source: string; durable: boolean }; + expect(users).toMatchObject({ source: "database", durable: true }); + + const engineResponse = await page.request.get("/api/admin/engine-config"); + await expectResponseOk(engineResponse); + const engine = (await engineResponse.json()) as { + durable: boolean; + source: string; + engine_mode: string; + engine_url: string; + model: string; + }; + expect(engine).toMatchObject({ durable: true, source: "database" }); + expect(["claude_cli", "claude_api", "openai", "solar"]).toContain(engine.engine_mode); + expect(engine.engine_url).toMatch(/^https?:\/\//); + expect(engine.model.trim().length).toBeGreaterThan(0); + }); +}); diff --git a/apps/web/e2e/session-layout.spec.ts b/apps/web/e2e/session-layout.spec.ts new file mode 100644 index 0000000..5106307 --- /dev/null +++ b/apps/web/e2e/session-layout.spec.ts @@ -0,0 +1,313 @@ +import { expect, test, type Page } from "@playwright/test"; +import { + expectNoDocumentOverflow, + expectNoHorizontalOverflow, + fetchAvailablePersona, + signInAsLearner, +} from "./support"; + +async function expectSessionPageHeightToMatchViewport(page: Page) { + const metrics = await page.evaluate(() => { + const sessionPage = document.querySelector(".sx-page--active"); + const topbar = document.querySelector(".vg-topbar"); + + if (!sessionPage) { + return null; + } + + const pageHeight = sessionPage.getBoundingClientRect().height; + const expectedHeight = window.innerHeight; + + return { + pageHeight: Math.round(pageHeight), + expectedHeight: Math.round(expectedHeight), + delta: Math.abs(pageHeight - expectedHeight), + hasTopbar: Boolean(topbar), + }; + }); + + expect(metrics, "Expected active session page to be present").not.toBeNull(); + expect(metrics!.hasTopbar, "Active session should hide the global topbar").toBe(false); + expect( + metrics!.delta, + `Expected .sx-page height ${metrics!.pageHeight}px to match viewport ${metrics!.expectedHeight}px`, + ).toBeLessThanOrEqual(1); +} + +async function expectNoSessionInternalCopy(page: Page) { + await expect(page.getByText(/API|GET \/|OPENAI_API_KEY|API와 엔진/)).toHaveCount(0); +} + +async function expectNoLocalStageDemoControl(page: Page) { + await expect(page.getByRole("button", { name: /다음 단계로/ })).toHaveCount(0); + await expect(page.locator(".sx-track__advance")).toHaveCount(0); +} + +async function expectMobileContextIfNarrow(page: Page) { + const isNarrow = await page.evaluate(() => + window.matchMedia("(max-width: 1180px)").matches, + ); + + if (!isNarrow) { + return; + } + + const isPhoneLayout = await page.evaluate(() => + window.matchMedia("(max-width: 880px)").matches, + ); + if (isPhoneLayout) { + await expect(page.locator(".sx-page--active .sx-col-left")).toBeHidden(); + await expect(page.locator(".sx-page--active .sx-col-right")).toBeHidden(); + } else { + const feedbackSurface = page.locator(".sx-page--active .sx-col-right"); + await expect(feedbackSurface).toBeVisible(); + await expect(feedbackSurface).toBeInViewport(); + } + const mobileContext = page.getByLabel("현재 회기 요약"); + await expect(mobileContext).toBeVisible(); + await expect(mobileContext).toContainText("조용히 표시"); + await expect(mobileContext).toContainText("내담자"); + await expect(mobileContext).toContainText("마이크"); +} + +async function expectSessionControlsInsideViewport(page: Page) { + const selectors = [ + ".sx-grid", + ".sx-stage", + ".sx-transcript", + ".sx-transcript__scroll", + ".sx-compose", + ".sx-controlbar", + ]; + const result = await page.evaluate((items) => { + const viewport = { width: window.innerWidth, height: window.innerHeight }; + const checks = items.map((selector) => { + const el = document.querySelector(selector); + if (!el) return { selector, ok: false, reason: "missing" }; + const rect = el.getBoundingClientRect(); + const style = window.getComputedStyle(el); + const visible = + style.display !== "none" && + style.visibility !== "hidden" && + Number(style.opacity) !== 0 && + rect.width > 0 && + rect.height > 0; + const ok = + visible && + rect.top >= -1 && + rect.left >= -1 && + rect.right <= viewport.width + 1 && + rect.bottom <= viewport.height + 1; + return { + selector, + ok, + reason: visible ? "out-of-viewport" : "not-visible", + rect: { + top: Math.round(rect.top), + left: Math.round(rect.left), + right: Math.round(rect.right), + bottom: Math.round(rect.bottom), + width: Math.round(rect.width), + height: Math.round(rect.height), + }, + }; + }); + return { viewport, checks }; + }, selectors); + + const failures = result.checks.filter((check) => !check.ok); + expect( + failures, + `Viewport ${result.viewport.width}x${result.viewport.height} clipped session controls: ${JSON.stringify(failures)}`, + ).toEqual([]); +} + +async function expectActiveSessionUsableLayout(page: Page) { + const result = await page.evaluate(() => { + const grid = document.querySelector(".sx-page--active .sx-grid"); + const center = document.querySelector(".sx-page--active .sx-col-center"); + const stage = document.querySelector(".sx-page--active .sx-stage"); + const transcript = document.querySelector(".sx-page--active .sx-transcript"); + const scroll = document.querySelector(".sx-page--active .sx-transcript__scroll"); + const compose = document.querySelector(".sx-page--active .sx-compose"); + const status = document.querySelector(".sx-page--active .sx-stage__status"); + const timer = document.querySelector(".sx-page--active .sx-stage__timer"); + if (!grid || !center || !stage || !transcript || !scroll || !compose || !status || !timer) { + return { ok: false, reason: "missing" }; + } + + const gridRect = grid.getBoundingClientRect(); + const centerRect = center.getBoundingClientRect(); + const stageRect = stage.getBoundingClientRect(); + const transcriptRect = transcript.getBoundingClientRect(); + const composeRect = compose.getBoundingClientRect(); + const stageOverflow = stage.scrollHeight - stage.clientHeight; + const transcriptOverflow = transcript.scrollHeight - transcript.clientHeight; + const phone = window.matchMedia("(max-width: 880px)").matches; + + return { + ok: true, + phone, + gridWidth: Math.round(gridRect.width), + centerWidth: Math.round(centerRect.width), + scrollHeight: Math.round(scroll.getBoundingClientRect().height), + stageOverflow, + transcriptOverflow, + stageBottom: Math.round(stageRect.bottom), + transcriptTop: Math.round(transcriptRect.top), + transcriptBottom: Math.round(transcriptRect.bottom), + composeTop: Math.round(composeRect.top), + statusText: status.textContent ?? "", + timerText: timer.textContent ?? "", + }; + }); + + expect(result.ok, `Expected active session layout elements: ${JSON.stringify(result)}`).toBeTruthy(); + if ("phone" in result && result.phone) { + expect( + Math.abs(result.gridWidth - result.centerWidth), + `Phone center column should use full grid width: ${JSON.stringify(result)}`, + ).toBeLessThanOrEqual(2); + } + expect(result.scrollHeight, `Transcript viewport too small: ${JSON.stringify(result)}`).toBeGreaterThanOrEqual(110); + expect(result.stageOverflow, `Stage content clipped: ${JSON.stringify(result)}`).toBeLessThanOrEqual(4); + expect(result.transcriptOverflow, `Transcript chrome clipped: ${JSON.stringify(result)}`).toBeLessThanOrEqual(4); + expect(result.stageBottom, `Stage overlaps transcript: ${JSON.stringify(result)}`).toBeLessThanOrEqual(result.transcriptTop); + expect(result.composeTop, `Compose overlaps transcript bounds: ${JSON.stringify(result)}`).toBeLessThan(result.transcriptBottom); + expect(result.statusText, `Missing visible running status: ${JSON.stringify(result)}`).toContain("회기"); + expect(result.timerText, `Missing visible timer: ${JSON.stringify(result)}`).toMatch(/\d/); +} + +test.describe("learner session full-screen layout", () => { + test("keeps the prestart and active session routes inside the viewport", async ({ page }) => { + await signInAsLearner(page); + const persona = await fetchAvailablePersona(page, 1); + await page.goto(`/learn/session/${persona.code}`); + + await expect(page.getByRole("button", { name: "회기 시작" })).toBeVisible(); + await expectNoHorizontalOverflow(page); + + await page.getByRole("button", { name: "회기 시작" }).click(); + + await expect(page.locator(".sx-page.sx-page--active")).toBeVisible(); + await expect(page.locator(".sx-grid")).toBeVisible(); + await expect(page.locator(".vg-topbar")).toHaveCount(0); + await expect(page.locator(".vg-nav")).toHaveCount(0); + await expect(page.locator(".vg-main")).toHaveClass(/(^|\s)vg-main--bleed(\s|$)/); + await expect(page.locator(".vg-shell__body")).toHaveClass(/(^|\s)vg-shell__body--bare(\s|$)/); + + await expectNoSessionInternalCopy(page); + await expectNoLocalStageDemoControl(page); + await expectNoDocumentOverflow(page); + await expectSessionPageHeightToMatchViewport(page); + await expectMobileContextIfNarrow(page); + await expectSessionControlsInsideViewport(page); + }); + + test("keeps critical session controls visible across dense viewport sizes", async ({ page }) => { + await signInAsLearner(page); + const persona = await fetchAvailablePersona(page, 1); + + const viewports = [ + { width: 1366, height: 768 }, + { width: 1366, height: 720 }, + { width: 1024, height: 768 }, + { width: 1024, height: 640 }, + { width: 820, height: 1180 }, + { width: 390, height: 844 }, + { width: 375, height: 667 }, + { width: 320, height: 568 }, + ]; + + await page.setViewportSize(viewports[0]); + await page.goto(`/learn/session/${persona.code}`); + await page.getByRole("button", { name: "회기 시작" }).click(); + await expect(page.locator(".sx-page.sx-page--active")).toBeVisible(); + + for (const viewport of viewports) { + await page.setViewportSize(viewport); + await page.evaluate(() => new Promise(requestAnimationFrame)); + + await expect(page.locator(".sx-page.sx-page--active")).toBeVisible(); + await expectNoDocumentOverflow(page); + await expectNoHorizontalOverflow(page); + await expectNoLocalStageDemoControl(page); + await expectSessionControlsInsideViewport(page); + await expectSessionPageHeightToMatchViewport(page); + await expectActiveSessionUsableLayout(page); + + if (viewport.width <= 1180) { + await expect(page.locator(".sx-page--active .sx-mobile-context")).toBeVisible(); + } + if (viewport.width > 880 && viewport.width <= 1180) { + await expect(page.locator(".sx-page--active .sx-col-right")).toBeVisible(); + await expect(page.locator(".sx-page--active .sx-col-right")).toBeInViewport(); + await expect(page.locator(".sx-page--active .sx-col-left")).toBeVisible(); + } + if (viewport.width <= 880) { + await expect(page.locator(".sx-page--active .sx-col-right")).toBeHidden(); + } + } + }); + + test("does not leave an unsaved local transcript when a text turn is rejected", async ({ page }) => { + await signInAsLearner(page); + const persona = await fetchAvailablePersona(page, 1); + await page.goto(`/learn/session/${persona.code}`); + await page.getByRole("button", { name: "회기 시작" }).click(); + await expect(page.locator(".sx-page.sx-page--active")).toBeVisible(); + + await page.route("**/api/sessions/*/stream", async (route) => { + await route.fulfill({ + status: 503, + contentType: "application/json", + body: JSON.stringify({ detail: "engine unavailable: e2e rejection" }), + }); + }); + + const learnerText = "오늘은 너무 힘들었어요"; + const input = page.getByLabel("학습자 발화 입력"); + await input.fill(learnerText); + await page.getByRole("button", { name: "보내기" }).click(); + + await expect(page.getByRole("alert")).toContainText("AI 엔진이 응답하지 않습니다"); + await expect(input).toHaveValue(learnerText); + await expect(page.locator(".sx-utt")).toHaveCount(0); + await expect(page.locator(".sx-utt").filter({ hasText: learnerText })).toHaveCount(0); + await expect(page.getByText("내담자 응답 없음")).toHaveCount(0); + }); + + test("removes pending transcript when an accepted stream later errors", async ({ page }) => { + await signInAsLearner(page); + const persona = await fetchAvailablePersona(page, 1); + await page.goto(`/learn/session/${persona.code}`); + await page.getByRole("button", { name: "회기 시작" }).click(); + await expect(page.locator(".sx-page.sx-page--active")).toBeVisible(); + + await page.route("**/api/sessions/*/stream", async (route) => { + await route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: [ + "event: token", + 'data: {"text":"부분 응답"}', + "", + "event: error", + 'data: {"detail":"engine unavailable: e2e stream error"}', + "", + ].join("\n"), + }); + }); + + const learnerText = "스트림 중간에 실패하면 남기지 말아 주세요"; + const input = page.getByLabel("학습자 발화 입력"); + await input.fill(learnerText); + await page.getByRole("button", { name: "보내기" }).click(); + + await expect(page.getByRole("alert")).toContainText("AI 엔진이 응답하지 않습니다"); + await expect(input).toHaveValue(learnerText); + await expect(page.locator(".sx-utt")).toHaveCount(0); + await expect(page.locator(".sx-utt").filter({ hasText: learnerText })).toHaveCount(0); + await expect(page.getByText("부분 응답")).toHaveCount(0); + }); +}); diff --git a/apps/web/e2e/session-persistence.spec.ts b/apps/web/e2e/session-persistence.spec.ts new file mode 100644 index 0000000..32ddd83 --- /dev/null +++ b/apps/web/e2e/session-persistence.spec.ts @@ -0,0 +1,95 @@ +import { expect, test, type Page } from "@playwright/test"; +import { fetchAvailablePersona, signInAsLearner, signInAsTeacher } from "./support"; + +interface SessionStartResponse { + session_id: string; + degraded: boolean; +} + +async function expectResponseOk(response: { ok: () => boolean; text: () => Promise }) { + if (!response.ok()) { + expect(response.ok(), await response.text()).toBeTruthy(); + } +} + +async function createSessionWithTurn(page: Page) { + const persona = await fetchAvailablePersona(page); + const startedResponse = await page.request.post("/api/sessions", { + data: { + persona_code: persona.code, + theory_mode: "humanistic", + }, + }); + await expectResponseOk(startedResponse); + + const started = (await startedResponse.json()) as SessionStartResponse; + expect(started.degraded).toBe(false); + + let turnResponse = await page.request.post(`/api/sessions/${started.session_id}/turn`, { + data: { + text: "오늘 많이 버거웠겠어요. 지금 가장 크게 남아 있는 마음은 어떤 건가요?", + }, + }); + for (let attempt = 0; attempt < 2 && !turnResponse.ok(); attempt += 1) { + await page.waitForTimeout(1000); + turnResponse = await page.request.post(`/api/sessions/${started.session_id}/turn`, { + data: { + text: "조금 천천히 이야기해도 괜찮습니다. 지금 마음에 남는 장면이 있나요?", + }, + }); + } + let expectedMinTurns = 2; + if (!turnResponse.ok()) { + expect(turnResponse.status(), await turnResponse.text()).toBe(503); + expectedMinTurns = 1; + } + + const endedResponse = await page.request.post(`/api/sessions/${started.session_id}/end`); + await expectResponseOk(endedResponse); + + return { sessionId: started.session_id, expectedMinTurns }; +} + +test.describe("session persistence", () => { + test("persists learner turns into DB-backed review and teacher dashboard @single-run", async ({ page }) => { + await signInAsLearner(page); + const { sessionId, expectedMinTurns } = await createSessionWithTurn(page); + + const reviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`); + await expectResponseOk(reviewResponse); + const review = await reviewResponse.json(); + expect(review.session_id).toBe(sessionId); + expect(review.turns.length).toBeGreaterThanOrEqual(expectedMinTurns); + expect(review.turns[0].speaker).toBe("learner"); + if (review.reviewReady) { + expect(review.degraded).toBe(false); + expect(review.summary).toContain("평가 AI"); + } else { + expect(review.degraded).toBe(true); + expect(review.rubric).toHaveLength(0); + expect(review.goodMoments).toHaveLength(0); + expect(review.growthPoints).toHaveLength(0); + expect(review.summary).not.toContain("잘한 구체적"); + } + + const hasClientTurn = review.turns.some( + (turn: { speaker: string }) => turn.speaker === "client", + ); + if (hasClientTurn) { + expect(review.clientFeedback).toEqual(expect.any(String)); + expect(review.clientFeedback.length).toBeGreaterThan(0); + } + + await signInAsTeacher(page); + const dashboardResponse = await page.request.get("/api/teacher/dashboard"); + await expectResponseOk(dashboardResponse); + const dashboard = await dashboardResponse.json(); + expect(dashboard.source).toBe("database"); + expect( + dashboard.recent_sessions.some( + (session: { session_id: string; turn_count: number }) => + session.session_id === sessionId && session.turn_count >= expectedMinTurns, + ), + ).toBe(true); + }); +}); diff --git a/apps/web/e2e/session-review.spec.ts b/apps/web/e2e/session-review.spec.ts new file mode 100644 index 0000000..24ae994 --- /dev/null +++ b/apps/web/e2e/session-review.spec.ts @@ -0,0 +1,78 @@ +import { expect, test, type Page, type Response } from "@playwright/test"; +import { expectNoHorizontalOverflow, fetchAvailablePersona, signInAsLearner } from "./support"; + +interface SessionStartResponse { + session_id: string; +} + +async function expectResponseOk(response: { ok: () => boolean; text: () => Promise }) { + if (!response.ok()) { + expect(response.ok(), await response.text()).toBeTruthy(); + } +} + +function isReviewResponse(sessionId: string) { + return (response: Response) => { + const url = new URL(response.url()); + return ( + response.request().method() === "GET" && + url.pathname.endsWith(`/sessions/${sessionId}/review`) + ); + }; +} + +async function createEndedSession(page: Page) { + const persona = await fetchAvailablePersona(page, 1); + const start = await page.request.post("/api/sessions", { + data: { + persona_code: persona.code, + theory_mode: "humanistic", + }, + }); + await expectResponseOk(start); + + const session = (await start.json()) as SessionStartResponse; + const ended = await page.request.post(`/api/sessions/${session.session_id}/end`); + await expectResponseOk(ended); + return session.session_id; +} + +test.describe("session review", () => { + test("renders server review data without legacy transcript fixtures", async ({ page }) => { + await signInAsLearner(page); + const sessionId = await createEndedSession(page); + + const reviewResponsePromise = page.waitForResponse(isReviewResponse(sessionId)); + await page.goto(`/learn/session/${sessionId}/review`); + const reviewResponse = await reviewResponsePromise; + await expectResponseOk(reviewResponse); + + const review = await reviewResponse.json(); + expect(review.session_id).toBe(sessionId); + expect(review.reviewReady).toBe(false); + expect(review.turns).toHaveLength(0); + + await expect(page).toHaveURL(new RegExp(`/learn/session/${sessionId}/review$`)); + await expect(page.getByText("축어록 없음")).toBeVisible(); + await expect(page.getByText("감정 타임라인 대기")).toBeVisible(); + await expect(page.getByText("개선점 대기")).toBeVisible(); + await expect(page.getByRole("button", { name: "오디오 다시 듣기" })).toBeDisabled(); + await expect(page.getByRole("button", { name: "PDF 내보내기" })).toBeDisabled(); + await expect(page.getByText("32분 14초")).toHaveCount(0); + await expect(page.getByText("시연")).toHaveCount(0); + const filterMetrics = await page.locator(".sr-chip-toggle").evaluateAll((buttons) => + buttons.map((button) => { + const rect = button.getBoundingClientRect(); + const style = window.getComputedStyle(button); + return { + height: Math.ceil(rect.height), + whiteSpace: style.whiteSpace, + }; + }), + ); + expect(filterMetrics.every((item) => item.height <= 34 && item.whiteSpace === "nowrap")).toBe( + true, + ); + await expectNoHorizontalOverflow(page); + }); +}); diff --git a/apps/web/e2e/settings.spec.ts b/apps/web/e2e/settings.spec.ts new file mode 100644 index 0000000..2af254a --- /dev/null +++ b/apps/web/e2e/settings.spec.ts @@ -0,0 +1,428 @@ +import { expect, test, type APIResponse, type Page, type Response, type TestInfo } from "@playwright/test"; +import { expectNoHorizontalOverflow, useRealApi, withGlobalEngineConfigLock } from "./support"; + +type Role = "learner" | "admin"; + +interface UserProfileResponse { + user_id: string; + email: string; + display_name: string; + role: string; + cohort_ids: string[]; + affiliation: string; +} + +interface UserPreferencesResponse { + theme: string; + voice_preset_id: string; + voice_rate: number; + notifications: { + session_done: boolean; + safety_signal: boolean; + learner_progress: boolean; + product_news: boolean; + }; +} + +interface AdminEngineConfigResponse { + engine_mode: string; + engine_url: string; + model: string; + updated_by: string | null; + updated_at: number | null; +} + +async function expectResponseOk(response: APIResponse | Response) { + if (!response.ok()) { + const body = await response.text(); + const method = + typeof (response as Response).request === "function" + ? (response as Response).request().method() + : "API"; + expect( + response.ok(), + `Expected ${method} ${response.url()} to be OK, got ${response.status()}: ${body}`, + ).toBeTruthy(); + } +} + +function isApiResponse(method: string, pathnameSuffix: string) { + return (response: Response) => { + const url = new URL(response.url()); + return response.request().method() === method && url.pathname.endsWith(pathnameSuffix); + }; +} + +function isApiRequest(method: string, pathnameSuffix: string) { + return (request: { method: () => string; url: () => string }) => { + const url = new URL(request.url()); + return request.method() === method && url.pathname.endsWith(pathnameSuffix); + }; +} + +async function runAndWaitForApiResponse( + page: Page, + method: string, + pathnameSuffix: string, + action: () => Promise, + responsePredicate: (response: Response) => boolean = () => true, +) { + const responsePromise = page.waitForResponse( + (response) => isApiResponse(method, pathnameSuffix)(response) && responsePredicate(response), + { + timeout: 10_000, + }, + ); + const failedRequestPromise = page + .waitForEvent("requestfailed", { + predicate: isApiRequest(method, pathnameSuffix), + timeout: 10_000, + }) + .then((request) => { + throw new Error( + `${method} ${pathnameSuffix} failed before a response: ${ + request.failure()?.errorText ?? "unknown network error" + }`, + ); + }); + + await action(); + return Promise.race([responsePromise, failedRequestPromise]); +} + +async function waitForReactInputCommit(page: Page) { + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve()); + }); + }), + ); +} + +function hasEngineConfigRequestBody(engineMode: string, model: string) { + return (response: Response) => { + try { + const body = response.request().postDataJSON() as { engine_mode?: string; model?: string }; + return body.engine_mode === engineMode && body.model === model; + } catch { + return false; + } + }; +} + +function slugFor(testInfo: TestInfo) { + let hash = 0; + for (const char of testInfo.title) { + hash = (hash * 31 + char.charCodeAt(0)) >>> 0; + } + const project = testInfo.project.name.includes("mobile") ? "mob" : "desk"; + return `${project}.${testInfo.workerIndex}.${testInfo.retry}.${hash.toString(36)}`; +} + +function deferred() { + let resolve!: () => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +function testEmail(role: Role, testInfo: TestInfo) { + const domain = role === "admin" ? "twentyoz.kr" : "hs.ac.kr"; + return `settings.${role}.${slugFor(testInfo)}@${domain}`; +} + +async function signInAs( + page: Page, + role: Role, + testInfo: TestInfo, + displayName: string, + email = testEmail(role, testInfo), +) { + const res = await page.request.post("/api/auth/dev-login", { + data: { + email, + role, + display_name: displayName, + }, + }); + await expectResponseOk(res); + return email; +} + +async function openSettings(page: Page, options: { admin?: boolean } = {}) { + const profilePromise = page.waitForResponse(isApiResponse("GET", "/users/me")); + const preferencesPromise = page.waitForResponse( + isApiResponse("GET", "/users/me/preferences"), + ); + const voicePresetsPromise = page.waitForResponse( + isApiResponse("GET", "/users/me/voice-presets"), + ); + const engineConfigPromise = options.admin + ? page.waitForResponse(isApiResponse("GET", "/admin/engine-config")) + : null; + + await page.goto("/settings"); + + const [profileResponse, preferencesResponse, voicePresetsResponse, engineConfigResponse] = + await Promise.all([ + profilePromise, + preferencesPromise, + voicePresetsPromise, + engineConfigPromise, + ]); + + await expectResponseOk(profileResponse); + await expectResponseOk(preferencesResponse); + await expectResponseOk(voicePresetsResponse); + if (engineConfigResponse) await expectResponseOk(engineConfigResponse); + + const profile = (await profileResponse.json()) as UserProfileResponse; + const preferences = (await preferencesResponse.json()) as UserPreferencesResponse; + const engineConfig = engineConfigResponse + ? ((await engineConfigResponse.json()) as AdminEngineConfigResponse) + : null; + + await expect(page).toHaveURL(/\/settings$/); + await expect(page.locator(".vg-set")).toBeVisible(); + + return { profile, preferences, engineConfig }; +} + +test.describe("settings page", () => { + test.beforeEach(async ({ page }) => { + await useRealApi(page); + }); + + test("learner opens settings with server-backed account details", async ({ page }, testInfo) => { + await page.addInitScript(() => { + window.localStorage.setItem( + "vignette.dev-auth", + JSON.stringify({ + email: "browser-local@hs.ac.kr", + name: "Browser Local Learner", + role: "learner", + }), + ); + }); + + const displayName = `Settings Learner ${testInfo.project.name}`; + const email = await signInAs(page, "learner", testInfo, displayName); + + const { profile } = await openSettings(page); + + expect(profile).toMatchObject({ + email, + display_name: displayName, + role: "learner", + }); + + const account = page.locator("#set-account"); + await expect(account.locator(".vg-set__profile-meta .n")).toHaveText(displayName); + await expect(account.locator(".vg-set__profile-meta .e")).toHaveText(email); + await expect(account.locator("input").nth(0)).toHaveValue(displayName); + await expect(account.locator("input").nth(1)).toHaveValue(email); + await expect(page.getByText("Browser Local Learner")).toHaveCount(0); + await expect(page.getByText("browser-local@hs.ac.kr")).toHaveCount(0); + }); + + test("does not expose fallback settings before server state arrives", async ({ + page, + }, testInfo) => { + const displayName = `Settings Loading Admin ${testInfo.project.name}`; + await signInAs(page, "admin", testInfo, displayName); + + const preferencesGate = deferred(); + const preferencesSeen = deferred(); + const voicesGate = deferred(); + const voicesSeen = deferred(); + const engineGate = deferred(); + const engineSeen = deferred(); + + await page.route("**/api/users/me/preferences", async (route) => { + preferencesSeen.resolve(); + await preferencesGate.promise; + await route.continue(); + }); + await page.route("**/api/users/me/voice-presets", async (route) => { + voicesSeen.resolve(); + await voicesGate.promise; + await route.continue(); + }); + await page.route("**/api/admin/engine-config", async (route) => { + engineSeen.resolve(); + await engineGate.promise; + await route.continue(); + }); + + await page.goto("/settings"); + await Promise.all([preferencesSeen.promise, voicesSeen.promise, engineSeen.promise]); + + await expect(page.getByTestId("settings-preferences-loading")).toBeVisible(); + await expect(page.getByTestId("settings-voice-loading")).toBeVisible(); + await expect(page.getByTestId("settings-notify-loading")).toBeVisible(); + await expect(page.getByTestId("settings-engine-loading")).toBeVisible(); + await expect(page.getByText("soft-young-fem")).toHaveCount(0); + await expect(page.getByRole("radio", { name: "Claude CLI 게이트웨이" })).toHaveCount(0); + await expect(page.getByLabel("말하기 속도")).toHaveCount(0); + await expect(page.getByRole("button", { name: "음성 저장" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "알림 저장" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "운영 설정 저장" })).toHaveCount(0); + + preferencesGate.resolve(); + voicesGate.resolve(); + engineGate.resolve(); + + await expect(page.getByTestId("settings-preferences-loading")).toHaveCount(0); + await expect(page.getByRole("button", { name: "음성 저장" })).toBeVisible(); + await expect(page.getByRole("button", { name: "알림 저장" })).toBeVisible(); + await expect(page.getByRole("button", { name: "운영 설정 저장" })).toBeVisible(); + }); + + test("learner saves display name and preferences", async ({ page }, testInfo) => { + const displayName = `Settings Save Learner ${testInfo.project.name}`; + await signInAs(page, "learner", testInfo, displayName); + await openSettings(page); + + const account = page.locator("#set-account"); + const displayNameInput = account.locator("input").nth(0); + const nextName = `Updated Learner ${testInfo.project.name}`; + await expect(displayNameInput).toHaveValue(displayName); + + const profilePatchResponse = await runAndWaitForApiResponse( + page, + "PATCH", + "/users/me", + async () => { + await displayNameInput.fill(nextName); + await expect(displayNameInput).toHaveValue(nextName); + await waitForReactInputCommit(page); + await account.locator(".vg-set__foot .vg-btn").click(); + }, + ); + await expectResponseOk(profilePatchResponse); + const profile = (await profilePatchResponse.json()) as UserProfileResponse; + expect(profile.display_name).toBe(nextName); + const persistedProfileResponse = await page.request.get("/api/users/me"); + await expectResponseOk(persistedProfileResponse); + const persistedProfile = (await persistedProfileResponse.json()) as UserProfileResponse; + expect(persistedProfile.display_name).toBe(nextName); + await expect(account.locator(".vg-set__profile-meta .n")).toHaveText(nextName); + await expect(displayNameInput).toHaveValue(nextName); + + const notify = page.locator("#set-notify"); + const productNews = notify.getByRole("switch").last(); + const currentProductNews = (await productNews.getAttribute("aria-checked")) === "true"; + const nextProductNews = !currentProductNews; + await productNews.click(); + await expect(productNews).toHaveAttribute("aria-checked", String(nextProductNews)); + + const preferencesPatchResponse = await runAndWaitForApiResponse( + page, + "PATCH", + "/users/me/preferences", + async () => { + const saveButton = notify.locator(".vg-set__foot .vg-btn"); + await saveButton.scrollIntoViewIfNeeded(); + await saveButton.focus(); + await page.keyboard.press("Enter"); + }, + ); + await expectResponseOk(preferencesPatchResponse); + const preferences = (await preferencesPatchResponse.json()) as UserPreferencesResponse; + expect(preferences.notifications.product_news).toBe(nextProductNews); + }); + + test("admin sees and updates the AI engine settings panel @single-run", async ({ page }, testInfo) => { + const displayName = `Settings Admin ${testInfo.project.name}`; + const email = await signInAs(page, "admin", testInfo, displayName); + + await withGlobalEngineConfigLock(`settings-${slugFor(testInfo)}`, async () => { + const { engineConfig } = await openSettings(page, { admin: true }); + expect(engineConfig).not.toBeNull(); + const originalEngineConfig = engineConfig!; + + const engine = page.locator("#set-engine"); + await expect(engine).toBeVisible(); + await expect(engine.locator("input").nth(0)).toHaveValue(originalEngineConfig.engine_url); + await expect(engine.locator("input").nth(1)).toHaveValue(originalEngineConfig.model); + + const nextMode = + originalEngineConfig.engine_mode === "claude_api" ? "claude_cli" : "claude_api"; + const nextModel = `e2e-model-${slugFor(testInfo)}`; + + try { + const nextModeButton = engine.locator(`[data-engine-mode="${nextMode}"]`); + await nextModeButton.click(); + await expect(nextModeButton).toHaveAttribute("aria-checked", "true"); + await engine.locator("input").nth(1).fill(nextModel); + await expect(engine.locator("input").nth(1)).toHaveValue(nextModel); + await waitForReactInputCommit(page); + + const enginePatchResponse = await runAndWaitForApiResponse( + page, + "PATCH", + "/admin/engine-config", + async () => { + await engine.locator(".vg-set__foot .vg-btn").click(); + }, + hasEngineConfigRequestBody(nextMode, nextModel), + ); + await expectResponseOk(enginePatchResponse); + const updatedEngine = (await enginePatchResponse.json()) as AdminEngineConfigResponse; + expect(updatedEngine).toMatchObject({ + engine_mode: nextMode, + model: nextModel, + updated_by: email, + }); + await expect(engine.locator("input").nth(1)).toHaveValue(nextModel); + + const healthResponse = await page.request.get("/api/admin/health"); + await expectResponseOk(healthResponse); + const health = await healthResponse.json(); + expect(health.engine_mode).toBe(nextMode); + } finally { + const restoreResponse = await page.request.patch("/api/admin/engine-config", { + data: { + engine_mode: originalEngineConfig.engine_mode, + engine_url: originalEngineConfig.engine_url, + model: originalEngineConfig.model, + }, + }); + await expectResponseOk(restoreResponse); + } + }); + }); + + test("admin engine settings panel stays readable at a mobile viewport", async ({ + page, + }, testInfo) => { + await page.setViewportSize({ width: 390, height: 844 }); + const displayName = `Settings Mobile Admin ${testInfo.project.name}`; + await signInAs(page, "admin", testInfo, displayName); + + const { engineConfig } = await openSettings(page, { admin: true }); + expect(engineConfig).not.toBeNull(); + + const engine = page.locator("#set-engine"); + await expect(engine).toBeVisible(); + await expect(engine.locator("[data-engine-mode]")).toHaveCount(4); + await expect(engine.locator("input").nth(0)).toBeVisible(); + await expect(engine.locator("input").nth(0)).toHaveValue(engineConfig!.engine_url); + await expect(engine.locator("input").nth(1)).toBeVisible(); + await expect(engine.locator("input").nth(1)).toHaveValue(engineConfig!.model); + + await expectNoHorizontalOverflow(page); + }); + + test("does not horizontally overflow at a mobile viewport", async ({ page }, testInfo) => { + await page.setViewportSize({ width: 390, height: 844 }); + await signInAs(page, "admin", testInfo, "E2E Admin", "admin@twentyoz.kr"); + + await openSettings(page, { admin: true }); + + await expectNoHorizontalOverflow(page); + }); +}); diff --git a/apps/web/e2e/support.ts b/apps/web/e2e/support.ts new file mode 100644 index 0000000..551adc7 --- /dev/null +++ b/apps/web/e2e/support.ts @@ -0,0 +1,172 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { expect, type Page } from "@playwright/test"; + +export interface E2EPersona { + code: string; + display_name: string; + source: string; + degraded: boolean; +} + +export async function useRealApi(_page: Page) { + // Intentionally empty. E2E should exercise the local API through the Vite + // proxy instead of replacing app data with browser-side route fixtures. +} + +function sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +export async function withGlobalEngineConfigLock( + label: string, + action: () => Promise, +): Promise { + const dir = path.join(process.cwd(), "node_modules", ".tmp"); + const lockPath = path.join(dir, "engine-config.lock"); + const startedAt = Date.now(); + + await fs.mkdir(dir, { recursive: true }); + + while (true) { + try { + const handle = await fs.open(lockPath, "wx"); + try { + await handle.writeFile(`${process.pid} ${label} ${new Date().toISOString()}`); + return await action(); + } finally { + await handle.close().catch(() => undefined); + await fs.unlink(lockPath).catch(() => undefined); + } + } catch (err) { + const code = + typeof err === "object" && err !== null && "code" in err + ? String((err as { code?: unknown }).code) + : ""; + if (code !== "EEXIST") throw err; + + const stat = await fs.stat(lockPath).catch(() => null); + if (stat && Date.now() - stat.mtimeMs > 60_000) { + await fs.unlink(lockPath).catch(() => undefined); + continue; + } + if (Date.now() - startedAt > 30_000) { + throw new Error(`Timed out waiting for engine config lock: ${label}`); + } + await sleep(100); + } + } +} + +export async function signInAsLearner(page: Page) { + const res = await page.request.post("/api/auth/dev-login", { + data: { + email: "learner@hs.ac.kr", + role: "learner", + display_name: "E2E Learner", + }, + }); + expect(res.ok(), await res.text()).toBeTruthy(); +} + +export async function signInAsTeacher(page: Page) { + const res = await page.request.post("/api/auth/dev-login", { + data: { + email: "teacher@hs.ac.kr", + role: "teacher", + display_name: "E2E Teacher", + }, + }); + expect(res.ok(), await res.text()).toBeTruthy(); +} + +export async function fetchAvailablePersonas(page: Page): Promise { + const res = await page.request.get("/api/personas"); + expect(res.ok(), await res.text()).toBeTruthy(); + const personas = (await res.json()) as E2EPersona[]; + const usable = personas.filter((persona) => persona.source === "database" && !persona.degraded); + expect(usable.length, `Expected at least one database persona: ${JSON.stringify(personas)}`).toBeGreaterThan(0); + return usable; +} + +export async function fetchAvailablePersona(page: Page, index = 0): Promise { + const personas = await fetchAvailablePersonas(page); + return personas[index] ?? personas[0]; +} + +export async function expectNoHorizontalOverflow(page: Page) { + await expect + .poll(async () => { + try { + return await page.evaluate(() => { + const doc = document.documentElement; + return Math.ceil(doc.scrollWidth - doc.clientWidth); + }); + } catch (err) { + if (isNavigationRace(err)) return Number.MAX_SAFE_INTEGER; + throw err; + } + }) + .toBeLessThanOrEqual(1); + + const overflow = await page.evaluate(() => { + const doc = document.documentElement; + const viewportWidth = doc.clientWidth; + const delta = Math.ceil(doc.scrollWidth - viewportWidth); + const offenders = Array.from(document.querySelectorAll("body *")) + .map((el) => { + const rect = el.getBoundingClientRect(); + const style = window.getComputedStyle(el); + return { + tag: el.tagName.toLowerCase(), + className: String(el.className || ""), + text: (el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 80), + left: Math.floor(rect.left), + right: Math.ceil(rect.right), + width: Math.ceil(rect.width), + visible: + style.display !== "none" && + style.visibility !== "hidden" && + Number(style.opacity) !== 0 && + rect.width > 0 && + rect.height > 0, + }; + }) + .filter((item) => item.visible && (item.left < -1 || item.right > viewportWidth + 1)) + .slice(0, 8); + + return { delta, viewportWidth, offenders }; + }); + + expect( + overflow.delta, + `Horizontal overflow ${overflow.delta}px at ${overflow.viewportWidth}px viewport. Offenders: ${JSON.stringify( + overflow.offenders, + )}`, + ).toBeLessThanOrEqual(1); +} + +export async function expectNoDocumentOverflow(page: Page) { + await expect + .poll(async () => { + try { + return await page.evaluate(() => { + const doc = document.documentElement; + return { + x: Math.ceil(doc.scrollWidth - doc.clientWidth), + y: Math.ceil(doc.scrollHeight - doc.clientHeight), + }; + }); + } catch (err) { + if (isNavigationRace(err)) return { x: Number.MAX_SAFE_INTEGER, y: Number.MAX_SAFE_INTEGER }; + throw err; + } + }) + .toEqual({ x: 0, y: 0 }); +} + +function isNavigationRace(err: unknown) { + return err instanceof Error && /Execution context was destroyed|most likely because of a navigation/i.test(err.message); +} diff --git a/apps/web/e2e/teacher.spec.ts b/apps/web/e2e/teacher.spec.ts new file mode 100644 index 0000000..9733844 --- /dev/null +++ b/apps/web/e2e/teacher.spec.ts @@ -0,0 +1,111 @@ +import { expect, test, type Page, type Response } from "@playwright/test"; +import { + expectNoHorizontalOverflow, + fetchAvailablePersona, + signInAsLearner, + signInAsTeacher, +} from "./support"; + +interface SessionStartResponse { + session_id: string; +} + +async function expectResponseOk(response: { ok: () => boolean; text: () => Promise }) { + if (!response.ok()) { + expect(response.ok(), await response.text()).toBeTruthy(); + } +} + +function isTeacherDashboardResponse(response: Response) { + const url = new URL(response.url()); + return response.request().method() === "GET" && url.pathname.endsWith("/teacher/dashboard"); +} + +async function createEndedLearnerSession(page: Page) { + await signInAsLearner(page); + const persona = await fetchAvailablePersona(page); + const start = await page.request.post("/api/sessions", { + data: { + persona_code: persona.code, + theory_mode: "humanistic", + }, + }); + await expectResponseOk(start); + const session = (await start.json()) as SessionStartResponse; + const ended = await page.request.post(`/api/sessions/${session.session_id}/end`); + await expectResponseOk(ended); + return session.session_id; +} + +test.describe("teacher console", () => { + test("renders real server sessions from server-owned rows", async ({ page }) => { + const sessionId = await createEndedLearnerSession(page); + await signInAsTeacher(page); + + const dashboardResponsePromise = page.waitForResponse(isTeacherDashboardResponse); + await page.goto("/teach"); + const dashboardResponse = await dashboardResponsePromise; + await expectResponseOk(dashboardResponse); + + const dashboard = await dashboardResponse.json(); + expect(dashboard.recent_sessions.some((session: { session_id: string }) => session.session_id === sessionId)).toBe( + true, + ); + + await expect(page.locator("code").filter({ hasText: sessionId }).first()).toBeVisible(); + await expect(page.getByRole("heading", { name: /\d+건의 리뷰가 대기 중입니다\./ })).toBeVisible(); + await expect(page.getByText("3명에게 개입")).toHaveCount(0); + await expect(page.getByText("김상담")).toHaveCount(0); + await expectNoHorizontalOverflow(page); + }); + + test("keeps long teacher lists in bounded panels", async ({ page }) => { + await createEndedLearnerSession(page); + await signInAsTeacher(page); + + await page.goto("/teach"); + await expect(page.locator(".pf-list")).toBeVisible(); + await expect(page.locator(".pf-tablewrap")).toBeVisible(); + + const metrics = await page.evaluate(() => { + const list = document.querySelector(".pf-list"); + const table = document.querySelector(".pf-tablewrap"); + const header = document.querySelector(".pf-table th"); + if (!list || !table || !header) { + throw new Error("teacher list panels were not rendered"); + } + const listStyle = window.getComputedStyle(list); + const tableStyle = window.getComputedStyle(table); + const headerStyle = window.getComputedStyle(header); + const doc = document.documentElement; + return { + docHeight: doc.scrollHeight, + viewportHeight: doc.clientHeight, + listMaxHeight: listStyle.maxHeight, + listOverflowY: listStyle.overflowY, + tableMaxHeight: tableStyle.maxHeight, + tableOverflowY: tableStyle.overflowY, + headerPosition: headerStyle.position, + }; + }); + + expect(metrics.listMaxHeight).not.toBe("none"); + expect(metrics.tableMaxHeight).not.toBe("none"); + expect(["auto", "scroll"]).toContain(metrics.listOverflowY); + expect(["auto", "scroll"]).toContain(metrics.tableOverflowY); + expect(metrics.headerPosition).toBe("sticky"); + expect(metrics.docHeight - metrics.viewportHeight).toBeLessThanOrEqual(2200); + await expectNoHorizontalOverflow(page); + }); + + test("denies learner access to the teacher dashboard API and UI", async ({ page }) => { + await signInAsLearner(page); + + const denied = await page.request.get("/api/teacher/dashboard"); + expect(denied.status(), await denied.text()).toBe(403); + + await page.goto("/teach"); + await expect(page).toHaveURL(/\/learn$/); + await expect(page.locator(".pf-root")).toHaveCount(0); + }); +}); diff --git a/apps/web/e2e/voice-success.spec.ts b/apps/web/e2e/voice-success.spec.ts new file mode 100644 index 0000000..28e4ffb --- /dev/null +++ b/apps/web/e2e/voice-success.spec.ts @@ -0,0 +1,360 @@ +import { expect, test, type Page } from "@playwright/test"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { existsSync } from "node:fs"; +import http, { type IncomingMessage, type ServerResponse } from "node:http"; +import net from "node:net"; + +interface TestServer { + url: string; + requests: () => string[]; + close: () => Promise; +} + +interface SpawnedApi { + baseURL: string; + logs: () => string; + stop: () => Promise; +} + +interface VoiceProbe { + code: number; + messages: string[]; + binaryChunks: number; +} + +// This fixture intentionally starts a DB-offline API with ALLOW_SEED_PERSONA_FALLBACK=true +// so the voice provider cascade can be exercised without a Postgres dependency. +const SEEDED_VOICE_PERSONA_CODE = "P1"; + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + req.on("end", () => resolve(Buffer.concat(chunks))); + req.on("error", reject); + }); +} + +async function freePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + server.close(() => resolve(port)); + }); + }); +} + +async function startHttpServer( + handler: (req: IncomingMessage, res: ServerResponse) => void | Promise, +): Promise { + const port = await freePort(); + const requests: string[] = []; + const server = http.createServer((req, res) => { + requests.push(`${req.method ?? "?"} ${req.url ?? "?"}`); + void Promise.resolve(handler(req, res)).catch((err) => { + res.writeHead(500, { "content-type": "text/plain" }); + res.end(String(err)); + }); + }); + await new Promise((resolve) => server.listen(port, "127.0.0.1", resolve)); + return { + url: `http://127.0.0.1:${port}`, + requests: () => requests, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +async function startFakeOpenAI(): Promise { + return startHttpServer(async (req, res) => { + await readBody(req); + if (req.method === "POST" && req.url === "/v1/audio/transcriptions") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ text: "요즘 잠을 잘 못 자요.", language: "ko", duration: 1.2 })); + return; + } + if (req.method === "POST" && req.url === "/v1/audio/speech") { + res.writeHead(200, { "content-type": "audio/mpeg" }); + res.end(Buffer.alloc(8192, 128)); + return; + } + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "not found" })); + }); +} + +async function startFakeEngine(): Promise { + return startHttpServer(async (req, res) => { + await readBody(req); + if (req.method === "GET" && req.url === "/health") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true, engine: "fake" })); + return; + } + if (req.method === "POST" && req.url === "/v1/generate") { + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + text: "괜찮아요. 천천히 말해볼게요.", + model: "fake-client", + provider: "e2e", + tokens_in: 1, + tokens_out: 1, + cost_usd: 0, + inference_geo: "us", + structured: null, + }), + ); + return; + } + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "not found" })); + }); +} + +async function waitForApi(baseURL: string, proc: ChildProcessWithoutNullStreams): Promise { + const started = Date.now(); + let lastError = ""; + while (Date.now() - started < 20_000) { + if (proc.exitCode !== null) { + throw new Error(`API exited early with code ${proc.exitCode}: ${lastError}`); + } + try { + const response = await fetch(`${baseURL}/health`); + if (response.ok) return; + lastError = await response.text(); + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for API ${baseURL}: ${lastError}`); +} + +async function startApi({ + engineURL, + openAIBaseURL, +}: { + engineURL: string; + openAIBaseURL: string; +}): Promise { + const port = await freePort(); + const baseURL = `http://127.0.0.1:${port}`; + const localPython311 = process.env.USERPROFILE + ? `${process.env.USERPROFILE}\\AppData\\Local\\Programs\\Python\\Python311\\python.exe` + : ""; + const python = + process.env.E2E_PYTHON ?? + process.env.PYTHON311 ?? + (localPython311 && existsSync(localPython311) ? localPython311 : (process.env.PYTHON ?? "python")); + const proc = spawn( + python, + [ + "-m", + "uvicorn", + "app.main:app", + "--host", + "127.0.0.1", + "--port", + String(port), + "--log-level", + "debug", + ], + { + cwd: "../api", + env: { + ...process.env, + PYTHONUNBUFFERED: "1", + ENVIRONMENT: "dev", + AUTH_DEV_LOGIN_ENABLED: "true", + AUTH_ALLOWED_EMAIL_DOMAINS: '["hs.ac.kr","twentyoz.kr"]', + ALLOW_SEED_PERSONA_FALLBACK: "true", + DATABASE_URL: "postgresql://user:pass@127.0.0.1:1/vignette", + DB_POOL_MIN_SIZE: "0", + DB_COMMAND_TIMEOUT: "1", + ENGINE_URL: engineURL, + ENGINE_MODE: "claude_api", + ENGINE_TIMEOUT: "10", + ENGINE_CONNECT_TIMEOUT: "2", + OPENAI_API_KEY: "e2e-fake-key", + OPENAI_BASE_URL: `${openAIBaseURL}/v1`, + FRONTEND_BASE_URL: "http://localhost:5173", + CORS_ORIGINS: '["http://localhost:5173"]', + }, + windowsHide: true, + }, + ); + let logs = ""; + proc.stdout.on("data", (chunk) => { + logs += String(chunk).slice(-4000); + }); + proc.stderr.on("data", (chunk) => { + logs += String(chunk).slice(-4000); + }); + await waitForApi(baseURL, proc).catch((err) => { + proc.kill(); + throw new Error(`${err instanceof Error ? err.message : String(err)}\n${logs}`); + }); + return { + baseURL, + logs: () => logs, + stop: async () => { + if (proc.exitCode === null) proc.kill(); + await new Promise((resolve) => { + if (proc.exitCode !== null) { + resolve(); + return; + } + proc.once("exit", () => resolve()); + setTimeout(resolve, 3000); + }); + }, + }; +} + +async function probeVoiceCascade(page: Page, apiBaseURL: string, sessionId: string): Promise { + return page.evaluate( + ({ apiBase, sid }) => + new Promise((resolve) => { + const wsURL = new URL(`/voice/ws?session_id=${encodeURIComponent(sid)}`, apiBase); + wsURL.protocol = "ws:"; + const ws = new WebSocket(wsURL.href); + ws.binaryType = "arraybuffer"; + const messages: string[] = []; + let binaryChunks = 0; + let sawTtsEnd = false; + const timeout = window.setTimeout(() => { + ws.close(); + resolve({ code: -1, messages, binaryChunks }); + }, 20_000); + + ws.onopen = () => { + ws.send(JSON.stringify({ type: "audio_start", format: "webm" })); + ws.send(new Uint8Array([1, 2, 3, 4, 5, 6]).buffer); + ws.send(JSON.stringify({ type: "audio_end", format: "webm" })); + }; + ws.onmessage = (event) => { + if (typeof event.data === "string") { + messages.push(event.data); + try { + const parsed = JSON.parse(event.data) as { type?: string; state?: string }; + if (parsed.type === "tts_end") sawTtsEnd = true; + if (sawTtsEnd && parsed.type === "state" && parsed.state === "idle") { + ws.send(JSON.stringify({ type: "close" })); + } + } catch { + messages.push(JSON.stringify({ type: "error", detail: "invalid json from ws" })); + } + } else { + binaryChunks += 1; + } + }; + ws.onerror = () => { + messages.push(JSON.stringify({ type: "error", detail: "browser websocket error" })); + }; + ws.onclose = (event) => { + window.clearTimeout(timeout); + resolve({ code: event.code, messages, binaryChunks }); + }; + }), + { apiBase: apiBaseURL, sid: sessionId }, + ); +} + +test.describe("voice cascade success path", () => { + test("runs STT, client turn, TTS, and audio chunks against controlled providers @single-run", async ({ + page, + }, testInfo) => { + test.setTimeout(60_000); + + const openai = await startFakeOpenAI(); + const engine = await startFakeEngine(); + const api = await startApi({ engineURL: engine.url, openAIBaseURL: openai.url }); + try { + const health = await page.request.get(`${api.baseURL}/voice/health`); + expect(health.ok(), await health.text()).toBeTruthy(); + await expect(await health.json()).toMatchObject({ status: "ok", available: true }); + + await page.goto(`${api.baseURL}/health`); + const browserSetup = await page.evaluate(async ({ apiBase, seededPersonaCode, workerIndex }) => { + const login = await fetch(`${apiBase}/auth/dev-login`, { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: `voice-success.${workerIndex}@hs.ac.kr`, + role: "learner", + display_name: "Voice Success", + }), + }); + const loginBody = await login.text(); + if (!login.ok) { + return { ok: false, step: "login", status: login.status, body: loginBody }; + } + const me = await fetch(`${apiBase}/auth/me`, { credentials: "include" }); + const meBody = await me.text(); + if (!me.ok) { + return { ok: false, step: "me", status: me.status, body: meBody }; + } + const start = await fetch(`${apiBase}/sessions`, { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ persona_code: seededPersonaCode, theory_mode: "humanistic" }), + }); + const startBody = await start.text(); + if (!start.ok) { + return { ok: false, step: "sessions", status: start.status, body: startBody }; + } + return { + ok: true, + me: JSON.parse(meBody) as unknown, + started: JSON.parse(startBody) as { session_id: string }, + }; + }, { + apiBase: api.baseURL, + seededPersonaCode: SEEDED_VOICE_PERSONA_CODE, + workerIndex: testInfo.workerIndex, + }); + expect(browserSetup, api.logs()).toMatchObject({ ok: true }); + if (!browserSetup.ok) throw new Error(JSON.stringify(browserSetup)); + const started = browserSetup.started; + + const result = await probeVoiceCascade(page, api.baseURL, started.session_id); + const events = result.messages.map((message) => JSON.parse(message) as { type: string; [key: string]: unknown }); + + expect( + result.code, + [ + JSON.stringify(result, null, 2), + `fakeOpenAI=${JSON.stringify(openai.requests())}`, + `fakeEngine=${JSON.stringify(engine.requests())}`, + api.logs(), + ].join("\n\n"), + ).toBe(1000); + expect(events.some((event) => event.type === "degraded")).toBe(false); + expect(events.some((event) => event.type === "error")).toBe(false); + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "ready", session_id: started.session_id }), + expect.objectContaining({ type: "state", state: "listening" }), + expect.objectContaining({ type: "state", state: "thinking" }), + expect.objectContaining({ type: "transcript", text: "요즘 잠을 잘 못 자요." }), + expect.objectContaining({ type: "reply", text: "괜찮아요. 천천히 말해볼게요." }), + expect.objectContaining({ type: "state", state: "speaking" }), + expect.objectContaining({ type: "tts_chunk", seq: 0 }), + expect.objectContaining({ type: "tts_end" }), + expect.objectContaining({ type: "state", state: "idle" }), + ]), + ); + expect(result.binaryChunks).toBeGreaterThan(0); + } finally { + await api.stop(); + await engine.close(); + await openai.close(); + } + }); +}); diff --git a/apps/web/e2e/voice.spec.ts b/apps/web/e2e/voice.spec.ts new file mode 100644 index 0000000..f1efd7a --- /dev/null +++ b/apps/web/e2e/voice.spec.ts @@ -0,0 +1,115 @@ +import { expect, test, type Page, type TestInfo } from "@playwright/test"; +import { fetchAvailablePersona } from "./support"; + +interface WsResult { + code: number; + messages: string[]; +} + +async function signInLearner(page: Page, testInfo: TestInfo, label: string) { + const res = await page.request.post("/api/auth/dev-login", { + data: { + email: `voice.${label}.${testInfo.project.name}.${testInfo.workerIndex}@hs.ac.kr`, + role: "learner", + display_name: `Voice ${label}`, + }, + }); + expect(res.ok(), await res.text()).toBeTruthy(); +} + +async function openVoiceSocket(page: Page, path: string): Promise { + return page.evaluate( + ({ wsPath }) => + new Promise((resolve) => { + const url = new URL(wsPath, window.location.href); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + const ws = new WebSocket(url.href); + const messages: string[] = []; + const timeout = window.setTimeout(() => { + ws.close(); + resolve({ code: -1, messages }); + }, 5000); + + ws.onmessage = (event) => { + messages.push(String(event.data)); + }; + ws.onclose = (event) => { + window.clearTimeout(timeout); + resolve({ code: event.code, messages }); + }; + ws.onerror = () => { + messages.push(JSON.stringify({ type: "error", detail: "browser websocket error" })); + }; + }), + { wsPath: path }, + ); +} + +function parsedMessages(result: WsResult) { + return result.messages.map((message) => JSON.parse(message) as { type: string; detail?: string }); +} + +test.describe("voice websocket auth boundary", () => { + test("advertises only voice presets accepted by user preferences", async ({ page }, testInfo) => { + await signInLearner(page, testInfo, "presets"); + + const presetsResponse = await page.request.get("/api/users/me/voice-presets"); + expect(presetsResponse.ok(), await presetsResponse.text()).toBeTruthy(); + const presets = (await presetsResponse.json()) as { id: string; voice_id: string }[]; + const ids = presets.map((preset) => preset.id); + + expect(ids).toEqual(["soft-young-fem", "calm-adult-male", "warm-adult-fem", "neutral"]); + expect(ids).not.toContain("calm-adult-fem"); + expect(ids).not.toContain("steady-adult-male"); + + for (const id of ids) { + const saveResponse = await page.request.patch("/api/users/me/preferences", { + data: { voice_preset_id: id }, + }); + expect(saveResponse.ok(), await saveResponse.text()).toBeTruthy(); + expect(await saveResponse.json()).toMatchObject({ voice_preset_id: id }); + } + + const unsupported = await page.request.patch("/api/users/me/preferences", { + data: { voice_preset_id: "calm-adult-fem" }, + }); + expect(unsupported.status(), await unsupported.text()).toBe(422); + }); + + test("rejects unauthenticated websocket clients before degraded voice handling", async ({ page }) => { + await page.goto("/login"); + + const result = await openVoiceSocket(page, "/api/voice/ws?persona_code=UNAUTHENTICATED"); + const messages = parsedMessages(result); + + expect(result.code).toBe(1008); + expect(messages).toContainEqual({ type: "error", detail: "not authenticated" }); + expect(messages.some((message) => message.type === "degraded")).toBeFalsy(); + }); + + test("rejects binding another learner's session id", async ({ page }, testInfo) => { + await signInLearner(page, testInfo, "owner"); + const persona = await fetchAvailablePersona(page); + const start = await page.request.post("/api/sessions", { + data: { persona_code: persona.code, theory_mode: "humanistic" }, + }); + expect(start.ok(), await start.text()).toBeTruthy(); + const { session_id } = (await start.json()) as { session_id: string }; + + await signInLearner(page, testInfo, "other"); + await page.goto("/learn"); + + const result = await openVoiceSocket( + page, + `/api/voice/ws?session_id=${encodeURIComponent(session_id)}`, + ); + const messages = parsedMessages(result); + + expect(result.code).toBe(1008); + expect(messages).toContainEqual({ + type: "error", + detail: "session does not belong to user", + }); + expect(messages.some((message) => message.type === "degraded")).toBeFalsy(); + }); +}); diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 21d48ac..1da73ad 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -8,14 +8,18 @@ "name": "@vignette/web", "version": "0.1.0", "dependencies": { + "pixi-live2d-display": "^0.4.0", + "pixi.js": "^6.5.10", "react": "^19.1.0", "react-dom": "^19.1.0", "react-router-dom": "^6.30.1" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "puppeteer-core": "^25.2.1", "typescript": "^5.8.3", "vite": "^6.3.5" } @@ -794,6 +798,499 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pixi/accessibility": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/accessibility/-/accessibility-6.5.10.tgz", + "integrity": "sha512-URrI1H+1kjjHSyhY1QXcUZ8S3omdVTrXg5y0gndtpOhIelErBTC9NWjJfw6s0Rlmv5+x5VAitQTgw9mRiatDgw==", + "license": "MIT", + "peerDependencies": { + "@pixi/core": "6.5.10", + "@pixi/display": "6.5.10", + "@pixi/utils": "6.5.10" + } + }, + "node_modules/@pixi/app": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/app/-/app-6.5.10.tgz", + "integrity": "sha512-VsNHLajZ5Dbc/Zrj7iWmIl3eu6Fec+afjW/NXXezD8Sp3nTDF0bv5F+GDgN/zSc2gqIvPHyundImT7hQGBDghg==", + "license": "MIT", + "peerDependencies": { + "@pixi/core": "6.5.10", + "@pixi/display": "6.5.10", + "@pixi/math": "6.5.10", + "@pixi/utils": "6.5.10" + } + }, + "node_modules/@pixi/compressed-textures": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/compressed-textures/-/compressed-textures-6.5.10.tgz", + "integrity": "sha512-41NT5mkfam47DrkB8xMp3HUZDt7139JMB6rVNOmb3u2vm+2mdy9tzi5s9nN7bG9xgXlchxcFzytTURk+jwXVJA==", + "license": "MIT", + "peerDependencies": { + "@pixi/constants": "6.5.10", + "@pixi/core": "6.5.10", + "@pixi/loaders": "6.5.10", + "@pixi/settings": "6.5.10", + "@pixi/utils": "6.5.10" + } + }, + "node_modules/@pixi/constants": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/constants/-/constants-6.5.10.tgz", + "integrity": "sha512-PUF2Y9YISRu5eVrVVHhHCWpc/KmxQTg3UH8rIUs8UI9dCK41/wsPd3pEahzf7H47v7x1HCohVZcFO3XQc1bUDw==", + "license": "MIT" + }, + "node_modules/@pixi/core": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/core/-/core-6.5.10.tgz", + "integrity": "sha512-Gdzp5ENypyglvsh5Gv3teUZnZnmizo4xOsL+QqmWALdFlJXJwLJMVhKVThV/q/095XR6i4Ou54oshn+m4EkuFw==", + "license": "MIT", + "dependencies": { + "@types/offscreencanvas": "^2019.6.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/pixijs" + }, + "peerDependencies": { + "@pixi/constants": "6.5.10", + "@pixi/extensions": "6.5.10", + "@pixi/math": "6.5.10", + "@pixi/runner": "6.5.10", + "@pixi/settings": "6.5.10", + "@pixi/ticker": "6.5.10", + "@pixi/utils": "6.5.10" + } + }, + "node_modules/@pixi/display": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/display/-/display-6.5.10.tgz", + "integrity": "sha512-NxFdDDxlbH5fQkzGHraLGoTMucW9pVgXqQm13TSmkA3NWIi/SItHL4qT2SI8nmclT9Vid1VDEBCJFAbdeuQw1Q==", + "license": "MIT", + "peerDependencies": { + "@pixi/constants": "6.5.10", + "@pixi/math": "6.5.10", + "@pixi/settings": "6.5.10", + "@pixi/utils": "6.5.10" + } + }, + "node_modules/@pixi/extensions": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/extensions/-/extensions-6.5.10.tgz", + "integrity": "sha512-EIUGza+E+sCy3dupuIjvRK/WyVyfSzHb5XsxRaxNrPwvG1iIUIqNqZ3owLYCo4h17fJWrj/yXVufNNtUKQccWQ==", + "license": "MIT" + }, + "node_modules/@pixi/extract": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/extract/-/extract-6.5.10.tgz", + "integrity": "sha512-hXFIc4EGs14GFfXAjT1+6mzopzCMWeXeai38/Yod3vuBXkkp8+ksen6kE09vTnB9l1IpcIaCM+XZEokuqoGX2A==", + "license": "MIT", + "peerDependencies": { + "@pixi/constants": "6.5.10", + "@pixi/core": "6.5.10", + "@pixi/math": "6.5.10", + "@pixi/utils": "6.5.10" + } + }, + "node_modules/@pixi/filter-alpha": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/filter-alpha/-/filter-alpha-6.5.10.tgz", + "integrity": "sha512-GWHLJvY0QOIDRjVx0hdUff6nl/PePQg84i8XXPmANrvA+gJ/eSRTQRmQcdgInQfawENADB/oRqpcCct6IAcKpQ==", + "license": "MIT", + "peerDependencies": { + "@pixi/core": "6.5.10" + } + }, + "node_modules/@pixi/filter-blur": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/filter-blur/-/filter-blur-6.5.10.tgz", + "integrity": "sha512-LJsRocVOdM9hTzZKjP+jmkfoL1nrJi5XpR0ItgRN8fflOC7A7Ln4iPe7nukbbq3H7QhZSunbygMubbO6xhThZw==", + "license": "MIT", + "peerDependencies": { + "@pixi/constants": "6.5.10", + "@pixi/core": "6.5.10", + "@pixi/settings": "6.5.10" + } + }, + "node_modules/@pixi/filter-color-matrix": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/filter-color-matrix/-/filter-color-matrix-6.5.10.tgz", + "integrity": "sha512-C2S44/EoWTrhqedLWOZTq9GZV5loEq1+MhyK9AUzEubWGMHhou1Juhn2mRZ7R6flKPCRQNKrXpStUwCAouud3Q==", + "license": "MIT", + "peerDependencies": { + "@pixi/core": "6.5.10" + } + }, + "node_modules/@pixi/filter-displacement": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/filter-displacement/-/filter-displacement-6.5.10.tgz", + "integrity": "sha512-fbblMYyPX/hO3Tpoaa4tOBYxqp4TxjNrz6xyt15tKSVxWQElk+Tx98GJ+aaBoiHOKt8ezzHplStWoHG++JIv/w==", + "license": "MIT", + "peerDependencies": { + "@pixi/core": "6.5.10", + "@pixi/math": "6.5.10" + } + }, + "node_modules/@pixi/filter-fxaa": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/filter-fxaa/-/filter-fxaa-6.5.10.tgz", + "integrity": "sha512-wbHL9UtY3g7jTyvO8JaZks6DqV8AO5c96Hfu0zfndWBPs79Ul6/sq3LD2eE+yq5vK5T2R9Sr4s54ls1JT3Sppg==", + "license": "MIT", + "peerDependencies": { + "@pixi/core": "6.5.10" + } + }, + "node_modules/@pixi/filter-noise": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/filter-noise/-/filter-noise-6.5.10.tgz", + "integrity": "sha512-CX+/06NVaw3HsjipZVb7aemkca0TC8I6qfKI4lx2ugxS/6G6zkY5zqd8+nVSXW4DpUXB6eT0emwfRv6N00NvuA==", + "license": "MIT", + "peerDependencies": { + "@pixi/core": "6.5.10" + } + }, + "node_modules/@pixi/graphics": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/graphics/-/graphics-6.5.10.tgz", + "integrity": "sha512-KPHGJ910fi8bRQQ+VcTIgrK+bKIm8yAQaZKPqMtm14HzHPGcES6HkgeNY1sd7m8J4aS9btm5wOSyFu0p5IzTpA==", + "license": "MIT", + "peerDependencies": { + "@pixi/constants": "6.5.10", + "@pixi/core": "6.5.10", + "@pixi/display": "6.5.10", + "@pixi/math": "6.5.10", + "@pixi/sprite": "6.5.10", + "@pixi/utils": "6.5.10" + } + }, + "node_modules/@pixi/interaction": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/interaction/-/interaction-6.5.10.tgz", + "integrity": "sha512-v809pJmXA2B9dV/vdrDMUqJT+fBB/ARZli2YRmI2dPbEbkaYr8FNmxCAJnwT8o+ymTx044Ie820hn9tVrtMtfA==", + "license": "MIT", + "peerDependencies": { + "@pixi/core": "6.5.10", + "@pixi/display": "6.5.10", + "@pixi/math": "6.5.10", + "@pixi/ticker": "6.5.10", + "@pixi/utils": "6.5.10" + } + }, + "node_modules/@pixi/loaders": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/loaders/-/loaders-6.5.10.tgz", + "integrity": "sha512-AuK7mXBmyVsDFL9DDFPB8sqP8fwQ2NOktvu98bQuJl0/p/UeK/0OAQnF3wcf3FeBv5YGXfNHL21c2DCisjKfTg==", + "license": "MIT", + "peerDependencies": { + "@pixi/constants": "6.5.10", + "@pixi/core": "6.5.10", + "@pixi/utils": "6.5.10" + } + }, + "node_modules/@pixi/math": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/math/-/math-6.5.10.tgz", + "integrity": "sha512-fxeu7ykVbMGxGV2S3qRTupHToeo1hdWBm8ihyURn3BMqJZe2SkZEECPd5RyvIuuNUtjRnmhkZRnF3Jsz2S+L0g==", + "license": "MIT" + }, + "node_modules/@pixi/mesh": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/mesh/-/mesh-6.5.10.tgz", + "integrity": "sha512-tUNPsdp5/t/yRsCmfxIcufIfbQVzgAlMNgQ1igWOkSxzhB7vlEbZ8ZLLW5tQcNyM/r7Nhjz+RoB+RD+/BCtvlA==", + "license": "MIT", + "peerDependencies": { + "@pixi/constants": "6.5.10", + "@pixi/core": "6.5.10", + "@pixi/display": "6.5.10", + "@pixi/math": "6.5.10", + "@pixi/settings": "6.5.10", + "@pixi/utils": "6.5.10" + } + }, + "node_modules/@pixi/mesh-extras": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/mesh-extras/-/mesh-extras-6.5.10.tgz", + "integrity": "sha512-UCG7OOPPFeikrX09haCibCMR0jPQ4UJ+4HiYiAv/3dahq5eEzBx+yAwVtxcVCjonkTf/lu5SzmHdzpsbHLx5aw==", + "license": "MIT", + "peerDependencies": { + "@pixi/constants": "6.5.10", + "@pixi/core": "6.5.10", + "@pixi/math": "6.5.10", + "@pixi/mesh": "6.5.10", + "@pixi/utils": "6.5.10" + } + }, + "node_modules/@pixi/mixin-cache-as-bitmap": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/mixin-cache-as-bitmap/-/mixin-cache-as-bitmap-6.5.10.tgz", + "integrity": "sha512-HV4qPZt8R7uuPZf1XE5S0e3jbN4+/EqgAIkueIyK3Em+0IO1rCmIbzzYxFPxkElMUu5VvN1r4hXK846z9ITnhw==", + "license": "MIT", + "peerDependencies": { + "@pixi/constants": "6.5.10", + "@pixi/core": "6.5.10", + "@pixi/display": "6.5.10", + "@pixi/math": "6.5.10", + "@pixi/settings": "6.5.10", + "@pixi/sprite": "6.5.10", + "@pixi/utils": "6.5.10" + } + }, + "node_modules/@pixi/mixin-get-child-by-name": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/mixin-get-child-by-name/-/mixin-get-child-by-name-6.5.10.tgz", + "integrity": "sha512-YYd9wjnI/4aKY0H5Ij413UppVZn3YE1No2CZrNevV6WbhylsJucowY3hJihtl9mxkpwtaUIyWMjmphkbOinbzA==", + "license": "MIT", + "peerDependencies": { + "@pixi/display": "6.5.10" + } + }, + "node_modules/@pixi/mixin-get-global-position": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/mixin-get-global-position/-/mixin-get-global-position-6.5.10.tgz", + "integrity": "sha512-A83gTZP9CdQAyrAvOZl1P707Q0QvIC0V8UnBAMd4GxuhMOXJtXVPCdmfPVXUrfoywgnH+/Bgimq5xhsXTf8Hzg==", + "license": "MIT", + "peerDependencies": { + "@pixi/display": "6.5.10", + "@pixi/math": "6.5.10" + } + }, + "node_modules/@pixi/particle-container": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/particle-container/-/particle-container-6.5.10.tgz", + "integrity": "sha512-CCNAdYGzKoOc3FtK2kyWCNjygdHppeOEqqK189yhg3yRSsvby+HMms/cM6bLK/4Vf6mFoAy1na3w/oXpqTR2Ag==", + "license": "MIT", + "peerDependencies": { + "@pixi/constants": "6.5.10", + "@pixi/core": "6.5.10", + "@pixi/display": "6.5.10", + "@pixi/math": "6.5.10", + "@pixi/sprite": "6.5.10", + "@pixi/utils": "6.5.10" + } + }, + "node_modules/@pixi/polyfill": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/polyfill/-/polyfill-6.5.10.tgz", + "integrity": "sha512-KDTWyr285VvPM8GGTVIZAhmxGrOlTznUGK/9kWS3GtrogwLWn41S/86Yej1gYvotVyUomCcOok33Jzahb+vX1w==", + "license": "MIT", + "dependencies": { + "object-assign": "^4.1.1", + "promise-polyfill": "^8.2.0" + } + }, + "node_modules/@pixi/prepare": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/prepare/-/prepare-6.5.10.tgz", + "integrity": "sha512-PHMApz/GPg7IX/7+2S98criN2+Mp+fgiKpojV9cnl0SlW2zMxfAHBBi8zik9rHBgjx8X6d6bR0MG1rPtb6vSxQ==", + "license": "MIT", + "peerDependencies": { + "@pixi/core": "6.5.10", + "@pixi/display": "6.5.10", + "@pixi/graphics": "6.5.10", + "@pixi/settings": "6.5.10", + "@pixi/text": "6.5.10", + "@pixi/ticker": "6.5.10", + "@pixi/utils": "6.5.10" + } + }, + "node_modules/@pixi/runner": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/runner/-/runner-6.5.10.tgz", + "integrity": "sha512-4HiHp6diCmigJT/DSbnqQP62OfWKmZB7zPWMdV1AEdr4YT1QxzXAW1wHg7dkoEfyTHqZKl0tm/zcqKq/iH7tMA==", + "license": "MIT" + }, + "node_modules/@pixi/settings": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/settings/-/settings-6.5.10.tgz", + "integrity": "sha512-ypAS5L7pQ2Qb88yQK72bXtc7sD8OrtLWNXdZ/gnw5kwSWCFaOSoqhKqJCXrR5DQtN98+RQefwbEAmMvqobhFyw==", + "license": "MIT", + "peerDependencies": { + "@pixi/constants": "6.5.10" + } + }, + "node_modules/@pixi/sprite": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/sprite/-/sprite-6.5.10.tgz", + "integrity": "sha512-UiK+8LgM9XQ/SBDKjRgZ8WggdOSlFRXqiWjEZVmNkiyU8HvXeFzWPRhpc8RR1zDwAUhZWKtMhF8X/ba9m+z2lg==", + "license": "MIT", + "peerDependencies": { + "@pixi/constants": "6.5.10", + "@pixi/core": "6.5.10", + "@pixi/display": "6.5.10", + "@pixi/math": "6.5.10", + "@pixi/settings": "6.5.10", + "@pixi/utils": "6.5.10" + } + }, + "node_modules/@pixi/sprite-animated": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/sprite-animated/-/sprite-animated-6.5.10.tgz", + "integrity": "sha512-x1kayucAqpVbNk+j+diC/7sQGQsAl6NCH1J2/EEaiQjlV3GOx1MXS9Tft1N1Y1y7otbg1XsnBd60/Yzcp05pxA==", + "license": "MIT", + "peerDependencies": { + "@pixi/core": "6.5.10", + "@pixi/sprite": "6.5.10", + "@pixi/ticker": "6.5.10" + } + }, + "node_modules/@pixi/sprite-tiling": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/sprite-tiling/-/sprite-tiling-6.5.10.tgz", + "integrity": "sha512-lDFcPuwExrdJhli+WmjPivChjeCG6NiRl36iQ8n2zVi/MYVv9qfKCA6IdU7HBWk1AZdsg6KUTpwfmVLUI+qz3w==", + "license": "MIT", + "peerDependencies": { + "@pixi/constants": "6.5.10", + "@pixi/core": "6.5.10", + "@pixi/display": "6.5.10", + "@pixi/math": "6.5.10", + "@pixi/sprite": "6.5.10", + "@pixi/utils": "6.5.10" + } + }, + "node_modules/@pixi/spritesheet": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/spritesheet/-/spritesheet-6.5.10.tgz", + "integrity": "sha512-7uOZ1cYyYtPb0ZEgXV1SZ8ujtluZNY0TL5z3+Qc8cgGGZK/MaWG7N6Wf+uR4BR2x8FLNwcyN5IjbQDKCpblrmg==", + "license": "MIT", + "peerDependencies": { + "@pixi/core": "6.5.10", + "@pixi/loaders": "6.5.10", + "@pixi/math": "6.5.10", + "@pixi/utils": "6.5.10" + } + }, + "node_modules/@pixi/text": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/text/-/text-6.5.10.tgz", + "integrity": "sha512-ikwkonLJ+6QmEVW8Ji9fS5CjrKNbU4mHzYuwRQas/VJQuSWgd0myCcaw6ZbF1oSfQe70HgbNOR0sH8Q3Com0qg==", + "license": "MIT", + "peerDependencies": { + "@pixi/core": "6.5.10", + "@pixi/math": "6.5.10", + "@pixi/settings": "6.5.10", + "@pixi/sprite": "6.5.10", + "@pixi/utils": "6.5.10" + } + }, + "node_modules/@pixi/text-bitmap": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/text-bitmap/-/text-bitmap-6.5.10.tgz", + "integrity": "sha512-g/iFIMGp6Pfi0BvX6Ykp48Z6JXVgKOrc7UCIR9CM21wYcCiQGqtdFwstV236xk6/D8NToUtSOcifhtQ28dVTdQ==", + "license": "MIT", + "peerDependencies": { + "@pixi/constants": "6.5.10", + "@pixi/core": "6.5.10", + "@pixi/display": "6.5.10", + "@pixi/loaders": "6.5.10", + "@pixi/math": "6.5.10", + "@pixi/mesh": "6.5.10", + "@pixi/settings": "6.5.10", + "@pixi/text": "6.5.10", + "@pixi/utils": "6.5.10" + } + }, + "node_modules/@pixi/ticker": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/ticker/-/ticker-6.5.10.tgz", + "integrity": "sha512-UqX1XYtzqFSirmTOy8QAK4Ccg4KkIZztrBdRPKwFSOEiKAJoGDCSBmyQBo/9aYQKGObbNnrJ7Hxv3/ucg3/1GA==", + "license": "MIT", + "peerDependencies": { + "@pixi/extensions": "6.5.10", + "@pixi/settings": "6.5.10" + } + }, + "node_modules/@pixi/utils": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/@pixi/utils/-/utils-6.5.10.tgz", + "integrity": "sha512-4f4qDMmAz9IoSAe08G2LAxUcEtG9jSdudfsMQT2MG+OpfToirboE6cNoO0KnLCvLzDVE/mfisiQ9uJbVA9Ssdw==", + "license": "MIT", + "dependencies": { + "@types/earcut": "^2.1.0", + "earcut": "^2.2.4", + "eventemitter3": "^3.1.0", + "url": "^0.11.0" + }, + "peerDependencies": { + "@pixi/constants": "6.5.10", + "@pixi/settings": "6.5.10" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@puppeteer/browsers": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.0.5.tgz", + "integrity": "sha512-xYXNuEQmHNIPWWcbL/skf2KF7seyp7c1xmKFRk3wmdFx7VwBsKVrtOLKs8ecaezsKPsWeF1YsgwIiElAscaryA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "modern-tar": "^0.7.6", + "yargs": "^18.0.0" + }, + "bin": { + "browsers": "lib/main-cli.js" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "proxy-agent": ">=8.0.1" + }, + "peerDependenciesMeta": { + "proxy-agent": { + "optional": true + } + } + }, "node_modules/@remix-run/router": { "version": "1.23.3", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", @@ -1205,6 +1702,12 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/earcut": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.4.tgz", + "integrity": "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1212,6 +1715,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -1253,6 +1762,47 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.38", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", @@ -1266,6 +1816,18 @@ "node": ">=6.0.0" } }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/browserslist": { "version": "4.28.4", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", @@ -1300,6 +1862,35 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001799", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", @@ -1321,6 +1912,53 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chromium-bidi": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-16.0.1.tgz", + "integrity": "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1353,6 +1991,45 @@ } } }, + "node_modules/devtools-protocol": { + "version": "0.0.1638949", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1638949.tgz", + "integrity": "sha512-mXwg4Fqnv0WR4iuAT/gYUmctNkjILwXFHyZ+m7Ty1dfr0ezZt2U3gnrrJTfRobJTHoXf+IbuFvFITzLrLFjwJA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/earcut": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", + "license": "ISC" + }, "node_modules/electron-to-chromium": { "version": "1.5.378", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.378.tgz", @@ -1360,6 +2037,49 @@ "dev": true, "license": "ISC" }, + "node_modules/email-addresses": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/email-addresses/-/email-addresses-5.0.0.tgz", + "integrity": "sha512-4OIPYlA6JXqtVn8zpHpGiI7vE6EQOAg16aGnDMIAlZVinnoZ8208tW1hAbjWydgN/4PLTT9q+O1K6AH/vALJGw==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1412,6 +2132,46 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/eventemitter3": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", + "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1430,6 +2190,88 @@ } } }, + "node_modules/filename-reserved-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz", + "integrity": "sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/filenamify": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/filenamify/-/filenamify-4.3.0.tgz", + "integrity": "sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==", + "license": "MIT", + "dependencies": { + "filename-reserved-regex": "^2.0.0", + "strip-outer": "^1.0.1", + "trim-repeated": "^1.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "license": "MIT", + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-extra": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1445,6 +2287,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -1455,6 +2306,201 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gh-pages": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/gh-pages/-/gh-pages-6.3.0.tgz", + "integrity": "sha512-Ot5lU6jK0Eb+sszG8pciXdjMXdBJ5wODvgjR+imihTqsUWF2K6dJ9HST55lgqcs8wWcw6o6wAsUzfcYRhJPXbA==", + "license": "MIT", + "dependencies": { + "async": "^3.2.4", + "commander": "^13.0.0", + "email-addresses": "^5.0.0", + "filenamify": "^4.3.0", + "find-cache-dir": "^3.3.1", + "fs-extra": "^11.1.1", + "globby": "^11.1.0" + }, + "bin": { + "gh-pages": "bin/gh-pages.js", + "gh-pages-clean": "bin/gh-pages-clean.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1488,6 +2534,30 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -1498,6 +2568,81 @@ "yallist": "^3.0.2" } }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/modern-tar": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.6.tgz", + "integrity": "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1534,6 +2679,81 @@ "node": ">=18" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1554,6 +2774,130 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pixi-live2d-display": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/pixi-live2d-display/-/pixi-live2d-display-0.4.0.tgz", + "integrity": "sha512-xeYC6y4Y0Bxe9ksWNlGFZC1rII/MPrzPQK7t1c3ubA8RhkOISIqHJl38fNumXqhGEs+yItmgDOkFT+9dsyGDjA==", + "license": "MIT", + "dependencies": { + "gh-pages": "^4.0.0" + }, + "peerDependencies": { + "@pixi/core": "^6", + "@pixi/display": "^6", + "@pixi/loaders": "^6", + "@pixi/math": "^6", + "@pixi/sprite": "^6", + "@pixi/utils": "^6" + } + }, + "node_modules/pixi.js": { + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-6.5.10.tgz", + "integrity": "sha512-Z2mjeoISml2iuVwT1e/BQwERYM2yKoiR08ZdGrg8y5JjeuVptfTrve4DbPMRN/kEDodesgQZGV/pFv0fE9Q2SA==", + "license": "MIT", + "dependencies": { + "@pixi/accessibility": "6.5.10", + "@pixi/app": "6.5.10", + "@pixi/compressed-textures": "6.5.10", + "@pixi/constants": "6.5.10", + "@pixi/core": "6.5.10", + "@pixi/display": "6.5.10", + "@pixi/extensions": "6.5.10", + "@pixi/extract": "6.5.10", + "@pixi/filter-alpha": "6.5.10", + "@pixi/filter-blur": "6.5.10", + "@pixi/filter-color-matrix": "6.5.10", + "@pixi/filter-displacement": "6.5.10", + "@pixi/filter-fxaa": "6.5.10", + "@pixi/filter-noise": "6.5.10", + "@pixi/graphics": "6.5.10", + "@pixi/interaction": "6.5.10", + "@pixi/loaders": "6.5.10", + "@pixi/math": "6.5.10", + "@pixi/mesh": "6.5.10", + "@pixi/mesh-extras": "6.5.10", + "@pixi/mixin-cache-as-bitmap": "6.5.10", + "@pixi/mixin-get-child-by-name": "6.5.10", + "@pixi/mixin-get-global-position": "6.5.10", + "@pixi/particle-container": "6.5.10", + "@pixi/polyfill": "6.5.10", + "@pixi/prepare": "6.5.10", + "@pixi/runner": "6.5.10", + "@pixi/settings": "6.5.10", + "@pixi/sprite": "6.5.10", + "@pixi/sprite-animated": "6.5.10", + "@pixi/sprite-tiling": "6.5.10", + "@pixi/spritesheet": "6.5.10", + "@pixi/text": "6.5.10", + "@pixi/text-bitmap": "6.5.10", + "@pixi/ticker": "6.5.10", + "@pixi/utils": "6.5.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/pixijs" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -1583,6 +2927,72 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/promise-polyfill": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz", + "integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", + "license": "MIT" + }, + "node_modules/puppeteer-core": { + "version": "25.2.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.2.1.tgz", + "integrity": "sha512-MwEZ4FFGJ1ZLOmu/04eISxoEMKtCnHyJBRFfgpwPPSYNG6gT6Xw1laNziFSV7uwDcx3jK+ATYIo9SfOd8Uhc3w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.0.5", + "chromium-bidi": "16.0.1", + "devtools-protocol": "0.0.1638949", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.2", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", @@ -1646,6 +3056,16 @@ "react-dom": ">=16.8" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -1691,6 +3111,29 @@ "fsevents": "~2.3.2" } }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -1701,12 +3144,92 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" } }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1717,6 +3240,52 @@ "node": ">=0.10.0" } }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-outer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strip-outer/-/strip-outer-1.0.1.tgz", + "integrity": "sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1734,6 +3303,37 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/trim-repeated": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz", + "integrity": "sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "dev": true, + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1748,6 +3348,15 @@ "node": ">=14.17" } }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -1779,6 +3388,19 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/url": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", + "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", + "license": "MIT", + "dependencies": { + "punycode": "^1.4.1", + "qs": "^6.12.3" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/vite": { "version": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", @@ -1854,12 +3476,107 @@ } } }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", + "integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/apps/web/package.json b/apps/web/package.json index 8b2ac5a..25be3e7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,17 +9,27 @@ "build": "tsc -b && vite build", "preview": "vite preview", "typecheck": "tsc -b", - "lint": "tsc -b" + "lint": "tsc -b", + "e2e": "playwright test", + "e2e:headed": "playwright test --headed", + "e2e:ui": "playwright test --ui" }, "dependencies": { + "pixi-live2d-display": "^0.4.0", + "pixi.js": "^6.5.10", "react": "^19.1.0", "react-dom": "^19.1.0", "react-router-dom": "^6.30.1" }, + "overrides": { + "gh-pages": "^6.3.0" + }, "devDependencies": { + "@playwright/test": "^1.61.1", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "puppeteer-core": "^25.2.1", "typescript": "^5.8.3", "vite": "^6.3.5" } diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 0000000..1c25108 --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,89 @@ +import { defineConfig, devices } from "@playwright/test"; + +const host = process.env.PLAYWRIGHT_HOST ?? "localhost"; +const port = Number(process.env.PLAYWRIGHT_PORT ?? 5173); +const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://${host}:${port}`; +const publicBaseURL = process.env.E2E_PUBLIC_BASE_URL ?? "https://vignette.chanpaca.net"; +const publicStorageState = process.env.E2E_PUBLIC_STORAGE_STATE; +const includePublicAuth = process.env.E2E_PUBLIC_AUTH === "1"; +const shouldStartWebServer = + !includePublicAuth && !process.env.PLAYWRIGHT_BASE_URL && !process.env.PLAYWRIGHT_SKIP_WEB_SERVER; +const singleRunPattern = /@single-run/; +const publicAuthPattern = /@public-auth/; +const localProjectExclusions = /@single-run|@public-auth/; + +export default defineConfig({ + testDir: "./e2e", + outputDir: "./node_modules/.tmp/playwright-results", + timeout: 30_000, + expect: { + timeout: 5_000, + }, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI + ? [ + ["list"], + ["html", { open: "never", outputFolder: "node_modules/.tmp/playwright-report" }], + ] + : "list", + use: { + baseURL, + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + webServer: shouldStartWebServer + ? { + command: `npm run dev -- --host ${host} --port ${port}`, + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + env: { + VITE_API_BASE: process.env.VITE_API_BASE ?? "/api", + }, + } + : undefined, + projects: [ + { + name: "chromium-desktop", + grepInvert: localProjectExclusions, + use: { + ...devices["Desktop Chrome"], + viewport: { width: 1440, height: 900 }, + }, + }, + { + name: "chromium-mobile", + grepInvert: localProjectExclusions, + use: { + ...devices["Pixel 5"], + }, + }, + { + name: "chromium-single-run", + grep: singleRunPattern, + grepInvert: publicAuthPattern, + use: { + ...devices["Desktop Chrome"], + viewport: { width: 1280, height: 800 }, + }, + }, + ...(includePublicAuth + ? [ + { + name: "chromium-public-auth", + grep: publicAuthPattern, + use: { + ...devices["Desktop Chrome"], + baseURL: publicBaseURL, + viewport: { width: 1440, height: 900 }, + storageState: publicStorageState, + }, + }, + ] + : []), + ], +}); diff --git a/apps/web/public/live2d/README.md b/apps/web/public/live2d/README.md new file mode 100644 index 0000000..431b8e7 --- /dev/null +++ b/apps/web/public/live2d/README.md @@ -0,0 +1,30 @@ +# Live2D Assets + +This directory contains the Live2D runtime and optional sample models served by the Vite/nginx static web app. + +Default runtime: + +```text +/live2d/live2dcubismcore.min.js +``` + +Sample models: + +```text +/live2d/mao/Mao.model3.json +/live2d/haru/haru_greeter_t03.model3.json +``` + +Sources: + +- Cubism Core: https://cubism.live2d.com/sdk-web/cubismcore/live2dcubismcore.min.js +- Mao model: https://github.com/Live2D/CubismWebSamples/tree/develop/Samples/Resources/Mao +- Haru model: https://github.com/guansss/pixi-live2d-display/tree/master/test/assets/haru + +The Live2D `CubismWebSamples` license lists `Samples/Resources/Mao` under Live2D's Free Material License. +The `pixi-live2d-display` README states that the Haru sample is redistributed under Live2D's Free Material License. +The local `model3.json` removes optional `DisplayInfo` and motion sound references that are absent from the copied asset set. + +No sample model is loaded by default. Session avatars use the SVG/persona renderer unless the active `AvatarPersona.live2dModelUrl` points to a real persona-specific Cubism `*.model3.json` asset. The Mao and Haru samples are for local wiring checks only; production builds ignore those bundled sample URLs instead of rendering them as shared persona fallbacks. + +The app also falls back to the SVG avatar when `live2dModelUrl` is omitted, set to `off`/`false`/`none`, or the model cannot be loaded. diff --git a/apps/web/public/live2d/haru/expressions/F01.exp3.json b/apps/web/public/live2d/haru/expressions/F01.exp3.json new file mode 100644 index 0000000..3b7fe71 --- /dev/null +++ b/apps/web/public/live2d/haru/expressions/F01.exp3.json @@ -0,0 +1,10 @@ +{ + "Type": "Live2D Expression", + "Parameters": [ + { + "Id": "ParamMouthOpenY", + "Value": 0.27, + "Blend": "Add" + } + ] +} diff --git a/apps/web/public/live2d/haru/expressions/F02.exp3.json b/apps/web/public/live2d/haru/expressions/F02.exp3.json new file mode 100644 index 0000000..253a61e --- /dev/null +++ b/apps/web/public/live2d/haru/expressions/F02.exp3.json @@ -0,0 +1,35 @@ +{ + "Type": "Live2D Expression", + "Parameters": [ + { + "Id": "ParamBrowLY", + "Value": -1, + "Blend": "Add" + }, + { + "Id": "ParamBrowRY", + "Value": -1, + "Blend": "Add" + }, + { + "Id": "ParamBrowLForm", + "Value": 1, + "Blend": "Add" + }, + { + "Id": "ParamBrowRForm", + "Value": 1, + "Blend": "Add" + }, + { + "Id": "ParamMouthOpenY", + "Value": 1, + "Blend": "Add" + }, + { + "Id": "ParamEyeForm", + "Value": 0.54, + "Blend": "Add" + } + ] +} diff --git a/apps/web/public/live2d/haru/expressions/F03.exp3.json b/apps/web/public/live2d/haru/expressions/F03.exp3.json new file mode 100644 index 0000000..55b8fd2 --- /dev/null +++ b/apps/web/public/live2d/haru/expressions/F03.exp3.json @@ -0,0 +1,55 @@ +{ + "Type": "Live2D Expression", + "Parameters": [ + { + "Id": "ParamBrowLY", + "Value": -1, + "Blend": "Add" + }, + { + "Id": "ParamBrowRY", + "Value": -1, + "Blend": "Add" + }, + { + "Id": "ParamBrowLX", + "Value": -1, + "Blend": "Add" + }, + { + "Id": "ParamBrowRX", + "Value": -1, + "Blend": "Add" + }, + { + "Id": "ParamBrowRAngle", + "Value": -1, + "Blend": "Add" + }, + { + "Id": "ParamBrowLForm", + "Value": -1, + "Blend": "Add" + }, + { + "Id": "ParamBrowRForm", + "Value": -1, + "Blend": "Add" + }, + { + "Id": "ParamMouthForm", + "Value": -2, + "Blend": "Add" + }, + { + "Id": "ParamMouthOpenY", + "Value": 1, + "Blend": "Add" + }, + { + "Id": "ParamEyeForm", + "Value": -1, + "Blend": "Add" + } + ] +} diff --git a/apps/web/public/live2d/haru/expressions/F04.exp3.json b/apps/web/public/live2d/haru/expressions/F04.exp3.json new file mode 100644 index 0000000..554f48e --- /dev/null +++ b/apps/web/public/live2d/haru/expressions/F04.exp3.json @@ -0,0 +1,60 @@ +{ + "Type": "Live2D Expression", + "Parameters": [ + { + "Id": "ParamEyeLOpen", + "Value": 0.8, + "Blend": "Multiply" + }, + { + "Id": "ParamEyeROpen", + "Value": 0.8, + "Blend": "Multiply" + }, + { + "Id": "ParamBrowLY", + "Value": -0.56, + "Blend": "Add" + }, + { + "Id": "ParamBrowRY", + "Value": -0.56, + "Blend": "Add" + }, + { + "Id": "ParamBrowRX", + "Value": -1, + "Blend": "Add" + }, + { + "Id": "ParamBrowLAngle", + "Value": 0.35, + "Blend": "Add" + }, + { + "Id": "ParamBrowRAngle", + "Value": 0.35, + "Blend": "Add" + }, + { + "Id": "ParamBrowLForm", + "Value": -0.74, + "Blend": "Add" + }, + { + "Id": "ParamBrowRForm", + "Value": -0.74, + "Blend": "Add" + }, + { + "Id": "ParamMouthForm", + "Value": -1.76, + "Blend": "Add" + }, + { + "Id": "ParamEyeForm", + "Value": 1, + "Blend": "Add" + } + ] +} diff --git a/apps/web/public/live2d/haru/expressions/F05.exp3.json b/apps/web/public/live2d/haru/expressions/F05.exp3.json new file mode 100644 index 0000000..82c308a --- /dev/null +++ b/apps/web/public/live2d/haru/expressions/F05.exp3.json @@ -0,0 +1,35 @@ +{ + "Type": "Live2D Expression", + "Parameters": [ + { + "Id": "ParamEyeLOpen", + "Value": 0, + "Blend": "Multiply" + }, + { + "Id": "ParamEyeLSmile", + "Value": 1, + "Blend": "Add" + }, + { + "Id": "ParamEyeROpen", + "Value": 0, + "Blend": "Multiply" + }, + { + "Id": "ParamEyeRSmile", + "Value": 1, + "Blend": "Add" + }, + { + "Id": "ParamBrowLY", + "Value": 0.32, + "Blend": "Add" + }, + { + "Id": "ParamBrowRY", + "Value": 0.32, + "Blend": "Add" + } + ] +} diff --git a/apps/web/public/live2d/haru/expressions/F06.exp3.json b/apps/web/public/live2d/haru/expressions/F06.exp3.json new file mode 100644 index 0000000..1783f20 --- /dev/null +++ b/apps/web/public/live2d/haru/expressions/F06.exp3.json @@ -0,0 +1,35 @@ +{ + "Type": "Live2D Expression", + "Parameters": [ + { + "Id": "ParamEyeLOpen", + "Value": 2, + "Blend": "Multiply" + }, + { + "Id": "ParamEyeROpen", + "Value": 2, + "Blend": "Multiply" + }, + { + "Id": "ParamBrowLY", + "Value": 1, + "Blend": "Add" + }, + { + "Id": "ParamBrowRY", + "Value": 1, + "Blend": "Add" + }, + { + "Id": "ParamMouthForm", + "Value": -1, + "Blend": "Add" + }, + { + "Id": "ParamEyeBallForm", + "Value": -0.65, + "Blend": "Add" + } + ] +} diff --git a/apps/web/public/live2d/haru/expressions/F07.exp3.json b/apps/web/public/live2d/haru/expressions/F07.exp3.json new file mode 100644 index 0000000..7155c07 --- /dev/null +++ b/apps/web/public/live2d/haru/expressions/F07.exp3.json @@ -0,0 +1,65 @@ +{ + "Type": "Live2D Expression", + "Parameters": [ + { + "Id": "ParamEyeLOpen", + "Value": 0.89, + "Blend": "Multiply" + }, + { + "Id": "ParamEyeROpen", + "Value": 0.89, + "Blend": "Multiply" + }, + { + "Id": "ParamBrowLY", + "Value": -0.56, + "Blend": "Add" + }, + { + "Id": "ParamBrowRY", + "Value": -0.56, + "Blend": "Add" + }, + { + "Id": "ParamBrowLX", + "Value": -1, + "Blend": "Add" + }, + { + "Id": "ParamBrowRX", + "Value": -1, + "Blend": "Add" + }, + { + "Id": "ParamBrowLAngle", + "Value": 0.35, + "Blend": "Add" + }, + { + "Id": "ParamBrowRAngle", + "Value": 0.35, + "Blend": "Add" + }, + { + "Id": "ParamBrowLForm", + "Value": -0.74, + "Blend": "Add" + }, + { + "Id": "ParamBrowRForm", + "Value": -0.74, + "Blend": "Add" + }, + { + "Id": "ParamMouthForm", + "Value": -0.46, + "Blend": "Add" + }, + { + "Id": "ParamTere", + "Value": 1, + "Blend": "Add" + } + ] +} diff --git a/apps/web/public/live2d/haru/expressions/F08.exp3.json b/apps/web/public/live2d/haru/expressions/F08.exp3.json new file mode 100644 index 0000000..f2c3787 --- /dev/null +++ b/apps/web/public/live2d/haru/expressions/F08.exp3.json @@ -0,0 +1,30 @@ +{ + "Type": "Live2D Expression", + "Parameters": [ + { + "Id": "ParamEyeLOpen", + "Value": 0.8, + "Blend": "Multiply" + }, + { + "Id": "ParamEyeROpen", + "Value": 0.8, + "Blend": "Multiply" + }, + { + "Id": "ParamBrowLForm", + "Value": -0.33, + "Blend": "Add" + }, + { + "Id": "ParamBrowRForm", + "Value": -0.33, + "Blend": "Add" + }, + { + "Id": "ParamMouthForm", + "Value": -1.76, + "Blend": "Add" + } + ] +} diff --git a/apps/web/public/live2d/haru/haru_greeter_t03.2048/texture_00.png b/apps/web/public/live2d/haru/haru_greeter_t03.2048/texture_00.png new file mode 100644 index 0000000..881ad67 Binary files /dev/null and b/apps/web/public/live2d/haru/haru_greeter_t03.2048/texture_00.png differ diff --git a/apps/web/public/live2d/haru/haru_greeter_t03.2048/texture_01.png b/apps/web/public/live2d/haru/haru_greeter_t03.2048/texture_01.png new file mode 100644 index 0000000..9caa21e Binary files /dev/null and b/apps/web/public/live2d/haru/haru_greeter_t03.2048/texture_01.png differ diff --git a/apps/web/public/live2d/haru/haru_greeter_t03.moc3 b/apps/web/public/live2d/haru/haru_greeter_t03.moc3 new file mode 100644 index 0000000..e5fc43a Binary files /dev/null and b/apps/web/public/live2d/haru/haru_greeter_t03.moc3 differ diff --git a/apps/web/public/live2d/haru/haru_greeter_t03.model3.json b/apps/web/public/live2d/haru/haru_greeter_t03.model3.json new file mode 100644 index 0000000..5ad1d4c --- /dev/null +++ b/apps/web/public/live2d/haru/haru_greeter_t03.model3.json @@ -0,0 +1,94 @@ +{ + "Version": 3, + "FileReferences": { + "Moc": "haru_greeter_t03.moc3", + "Textures": [ + "haru_greeter_t03.2048/texture_00.png", + "haru_greeter_t03.2048/texture_01.png" + ], + "Physics": "haru_greeter_t03.physics3.json", + "Pose": "haru_greeter_t03.pose3.json", + "Expressions": [ + { + "Name": "f00", + "File": "expressions/F01.exp3.json" + }, + { + "Name": "f01", + "File": "expressions/F02.exp3.json" + }, + { + "Name": "f02", + "File": "expressions/F03.exp3.json" + }, + { + "Name": "f03", + "File": "expressions/F04.exp3.json" + }, + { + "Name": "f04", + "File": "expressions/F05.exp3.json" + }, + { + "Name": "f05", + "File": "expressions/F06.exp3.json" + }, + { + "Name": "f06", + "File": "expressions/F07.exp3.json" + }, + { + "Name": "f07", + "File": "expressions/F08.exp3.json" + } + ], + "Motions": { + "Idle": [ + { + "File": "motion/haru_g_idle.motion3.json" + }, + { + "File": "motion/haru_g_m07.motion3.json" + }, + { + "File": "motion/haru_g_m15.motion3.json" + } + ], + "Tap": [ + { + "File": "motion/haru_g_m14.motion3.json" + }, + { + "File": "motion/haru_g_m05.motion3.json" + } + ] + } + }, + "Groups": [ + { + "Target": "Parameter", + "Name": "EyeBlink", + "Ids": [ + "ParamEyeLOpen", + "ParamEyeROpen" + ] + }, + { + "Target": "Parameter", + "Name": "LipSync", + "Ids": [ + "ParamMouthOpenY" + ] + } + ], + "HitAreas": [ + { + "Id": "HitArea", + "Name": "Head" + }, + { + "Id": "HitArea2", + "Name": "Body" + } + ] +} diff --git a/apps/web/public/live2d/haru/haru_greeter_t03.physics3.json b/apps/web/public/live2d/haru/haru_greeter_t03.physics3.json new file mode 100644 index 0000000..3f14797 --- /dev/null +++ b/apps/web/public/live2d/haru/haru_greeter_t03.physics3.json @@ -0,0 +1,373 @@ +{ + "Version": 3, + "Meta": { + "PhysicsSettingCount": 4, + "TotalInputCount": 14, + "TotalOutputCount": 4, + "VertexCount": 8, + "EffectiveForces": { + "Gravity": { + "X": 0, + "Y": -1 + }, + "Wind": { + "X": 0, + "Y": 0 + } + }, + "PhysicsDictionary": [ + { + "Id": "PhysicsSetting1", + "Name": "前髪" + }, + { + "Id": "PhysicsSetting2", + "Name": "横髪" + }, + { + "Id": "PhysicsSetting3", + "Name": "後ろ髪" + }, + { + "Id": "PhysicsSetting4", + "Name": "スカーフ" + } + ] + }, + "PhysicsSettings": [ + { + "Id": "PhysicsSetting1", + "Input": [ + { + "Source": { + "Target": "Parameter", + "Id": "ParamAngleX" + }, + "Weight": 60, + "Type": "X", + "Reflect": false + }, + { + "Source": { + "Target": "Parameter", + "Id": "ParamAngleZ" + }, + "Weight": 60, + "Type": "Angle", + "Reflect": false + }, + { + "Source": { + "Target": "Parameter", + "Id": "ParamBodyAngleX" + }, + "Weight": 40, + "Type": "X", + "Reflect": false + }, + { + "Source": { + "Target": "Parameter", + "Id": "ParamBodyAngleZ" + }, + "Weight": 40, + "Type": "Angle", + "Reflect": false + } + ], + "Output": [ + { + "Destination": { + "Target": "Parameter", + "Id": "ParamHairFront" + }, + "VertexIndex": 1, + "Scale": 1.821, + "Weight": 100, + "Type": "Angle", + "Reflect": false + } + ], + "Vertices": [ + { + "Position": { + "X": 0, + "Y": 0 + }, + "Mobility": 1, + "Delay": 1, + "Acceleration": 1, + "Radius": 0 + }, + { + "Position": { + "X": 0, + "Y": 8 + }, + "Mobility": 0.95, + "Delay": 0.8, + "Acceleration": 1.5, + "Radius": 8 + } + ], + "Normalization": { + "Position": { + "Minimum": -10, + "Default": 0, + "Maximum": 10 + }, + "Angle": { + "Minimum": -10, + "Default": 0, + "Maximum": 10 + } + } + }, + { + "Id": "PhysicsSetting2", + "Input": [ + { + "Source": { + "Target": "Parameter", + "Id": "ParamAngleX" + }, + "Weight": 60, + "Type": "X", + "Reflect": false + }, + { + "Source": { + "Target": "Parameter", + "Id": "ParamAngleZ" + }, + "Weight": 60, + "Type": "Angle", + "Reflect": false + }, + { + "Source": { + "Target": "Parameter", + "Id": "ParamBodyAngleX" + }, + "Weight": 40, + "Type": "X", + "Reflect": false + }, + { + "Source": { + "Target": "Parameter", + "Id": "ParamBodyAngleZ" + }, + "Weight": 40, + "Type": "Angle", + "Reflect": false + } + ], + "Output": [ + { + "Destination": { + "Target": "Parameter", + "Id": "ParamHairSide" + }, + "VertexIndex": 1, + "Scale": 1.593, + "Weight": 100, + "Type": "Angle", + "Reflect": false + } + ], + "Vertices": [ + { + "Position": { + "X": 0, + "Y": 0 + }, + "Mobility": 1, + "Delay": 1, + "Acceleration": 1, + "Radius": 0 + }, + { + "Position": { + "X": 0, + "Y": 8 + }, + "Mobility": 0.95, + "Delay": 0.8, + "Acceleration": 1, + "Radius": 8 + } + ], + "Normalization": { + "Position": { + "Minimum": -10, + "Default": 0, + "Maximum": 10 + }, + "Angle": { + "Minimum": -10, + "Default": 0, + "Maximum": 10 + } + } + }, + { + "Id": "PhysicsSetting3", + "Input": [ + { + "Source": { + "Target": "Parameter", + "Id": "ParamAngleX" + }, + "Weight": 60, + "Type": "X", + "Reflect": false + }, + { + "Source": { + "Target": "Parameter", + "Id": "ParamAngleZ" + }, + "Weight": 60, + "Type": "Angle", + "Reflect": false + }, + { + "Source": { + "Target": "Parameter", + "Id": "ParamBodyAngleX" + }, + "Weight": 40, + "Type": "X", + "Reflect": false + }, + { + "Source": { + "Target": "Parameter", + "Id": "ParamBodyAngleZ" + }, + "Weight": 40, + "Type": "Angle", + "Reflect": false + } + ], + "Output": [ + { + "Destination": { + "Target": "Parameter", + "Id": "ParamHairBack" + }, + "VertexIndex": 1, + "Scale": 1.943, + "Weight": 100, + "Type": "Angle", + "Reflect": false + } + ], + "Vertices": [ + { + "Position": { + "X": 0, + "Y": 0 + }, + "Mobility": 1, + "Delay": 1, + "Acceleration": 1, + "Radius": 0 + }, + { + "Position": { + "X": 0, + "Y": 8 + }, + "Mobility": 0.95, + "Delay": 0.8, + "Acceleration": 1.5, + "Radius": 8 + } + ], + "Normalization": { + "Position": { + "Minimum": -10, + "Default": 0, + "Maximum": 10 + }, + "Angle": { + "Minimum": -10, + "Default": 0, + "Maximum": 10 + } + } + }, + { + "Id": "PhysicsSetting4", + "Input": [ + { + "Source": { + "Target": "Parameter", + "Id": "ParamBodyAngleX" + }, + "Weight": 100, + "Type": "X", + "Reflect": false + }, + { + "Source": { + "Target": "Parameter", + "Id": "ParamBodyAngleZ" + }, + "Weight": 100, + "Type": "Angle", + "Reflect": false + } + ], + "Output": [ + { + "Destination": { + "Target": "Parameter", + "Id": "ParamScarf" + }, + "VertexIndex": 1, + "Scale": 0.873, + "Weight": 100, + "Type": "Angle", + "Reflect": false + } + ], + "Vertices": [ + { + "Position": { + "X": 0, + "Y": 0 + }, + "Mobility": 1, + "Delay": 1, + "Acceleration": 1, + "Radius": 0 + }, + { + "Position": { + "X": 0, + "Y": 10 + }, + "Mobility": 0.9, + "Delay": 0.6, + "Acceleration": 1.5, + "Radius": 10 + } + ], + "Normalization": { + "Position": { + "Minimum": -10, + "Default": 0, + "Maximum": 10 + }, + "Angle": { + "Minimum": -10, + "Default": 0, + "Maximum": 10 + } + } + } + ] +} \ No newline at end of file diff --git a/apps/web/public/live2d/haru/haru_greeter_t03.pose3.json b/apps/web/public/live2d/haru/haru_greeter_t03.pose3.json new file mode 100644 index 0000000..b88b67f --- /dev/null +++ b/apps/web/public/live2d/haru/haru_greeter_t03.pose3.json @@ -0,0 +1,25 @@ +{ + "Type": "Live2D Pose", + "Groups": [ + [ + { + "Id": "Part01ArmRA001", + "Link": [] + }, + { + "Id": "Part01ArmRB001", + "Link": [] + } + ], + [ + { + "Id": "Part01ArmLA001", + "Link": [] + }, + { + "Id": "Part01ArmLB001", + "Link": [] + } + ] + ] +} diff --git a/apps/web/public/live2d/haru/motion/haru_g_idle.motion3.json b/apps/web/public/live2d/haru/motion/haru_g_idle.motion3.json new file mode 100644 index 0000000..1139b04 --- /dev/null +++ b/apps/web/public/live2d/haru/motion/haru_g_idle.motion3.json @@ -0,0 +1,2266 @@ +{ + "Version": 3, + "Meta": { + "Duration": 10, + "Fps": 30.0, + "Loop": true, + "AreBeziersRestricted": true, + "CurveCount": 63, + "TotalSegmentCount": 296, + "TotalPointCount": 785, + "UserDataCount": 1, + "TotalUserDataSize": 0 + }, + "UserData": [ + { + "Time": 0.0, + "Value": "test" + } + ], + "Curves": [ + { + "Target": "Model", + "Id": "Opacity", + "Segments": [ + 0, + 1, + 1, + 3.32, + 1, + 6.64, + 1, + 9.97, + 1, + 0, + 10, + 1 + ] + }, + { + "Target": "Parameter", + "Id": "ParamAngleX", + "Segments": [ + 0, + 0, + 1, + 0.178, + 0, + 0.356, + -4, + 0.533, + -4, + 1, + 0.989, + -4, + 1.444, + -4, + 1.9, + -4, + 1, + 2.378, + -4, + 2.856, + 0, + 3.333, + 0, + 1, + 3.444, + 0, + 3.556, + 0, + 3.667, + 0, + 1, + 4.411, + 0, + 5.156, + 0, + 5.9, + 0, + 1, + 6.433, + 0, + 6.967, + 14, + 7.5, + 14, + 1, + 7.833, + 14, + 8.167, + 14, + 8.5, + 14, + 1, + 8.989, + 14, + 9.478, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamAngleY", + "Segments": [ + 0, + 0, + 1, + 0.178, + 0, + 0.356, + -6, + 0.533, + -6, + 1, + 0.989, + -6, + 1.444, + -6, + 1.9, + -6, + 1, + 2.378, + -6, + 2.856, + 0, + 3.333, + 0, + 1, + 3.444, + 0, + 3.556, + 0, + 3.667, + 0, + 1, + 4.411, + 0, + 5.156, + 0, + 5.9, + 0, + 1, + 6.211, + 0, + 6.522, + 5, + 6.833, + 5, + 1, + 7.056, + 5, + 7.278, + -6, + 7.5, + -6, + 1, + 7.833, + -6, + 8.167, + -6, + 8.5, + -6, + 1, + 8.989, + -6, + 9.478, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamAngleZ", + "Segments": [ + 0, + 0, + 1, + 0.178, + 0, + 0.356, + -6, + 0.533, + -6, + 1, + 0.733, + -6, + 0.933, + 1.432, + 1.133, + 5, + 1, + 1.389, + 9.559, + 1.644, + 10, + 1.9, + 10, + 1, + 2.378, + 10, + 2.856, + -6, + 3.333, + -6, + 1, + 3.444, + -6, + 3.556, + -6, + 3.667, + -6, + 1, + 4.156, + -6, + 4.644, + 6, + 5.133, + 6, + 1, + 5.389, + 6, + 5.644, + 6, + 5.9, + 6, + 1, + 6.211, + 6, + 6.522, + 12, + 6.833, + 12, + 1, + 7.389, + 12, + 7.944, + -6, + 8.5, + -6, + 1, + 8.989, + -6, + 9.478, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamTere", + "Segments": [ + 0, + 0, + 1, + 0.633, + 0, + 1.267, + 0, + 1.9, + 0, + 1, + 2.489, + 0, + 3.078, + 0, + 3.667, + 0, + 1, + 4.411, + 0, + 5.156, + 0, + 5.9, + 0, + 1, + 6.767, + 0, + 7.633, + 0, + 8.5, + 0, + 1, + 8.989, + 0, + 9.478, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamFaceForm", + "Segments": [ + 0, + 0, + 1, + 3.322, + 0, + 6.644, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeLOpen", + "Segments": [ + 0, + 1, + 1, + 0.633, + 1, + 1.267, + 1, + 1.9, + 1, + 1, + 2, + 1, + 2.1, + 1, + 2.2, + 1, + 1, + 2.244, + 1, + 2.289, + 0, + 2.333, + 0, + 1, + 2.356, + 0, + 2.378, + 0, + 2.4, + 0, + 1, + 2.456, + 0, + 2.511, + 1, + 2.567, + 1, + 1, + 2.933, + 1, + 3.3, + 1, + 3.667, + 1, + 1, + 3.711, + 1, + 3.756, + 0, + 3.8, + 0, + 1, + 3.822, + 0, + 3.844, + 0, + 3.867, + 0, + 1, + 3.922, + 0, + 3.978, + 1, + 4.033, + 1, + 1, + 4.656, + 1, + 5.278, + 1, + 5.9, + 1, + 1, + 6.433, + 1, + 6.967, + 1, + 7.5, + 1, + 1, + 7.544, + 1, + 7.589, + 0, + 7.633, + 0, + 1, + 7.656, + 0, + 7.678, + 0, + 7.7, + 0, + 1, + 7.756, + 0, + 7.811, + 1, + 7.867, + 1, + 1, + 8.078, + 1, + 8.289, + 1, + 8.5, + 1, + 1, + 8.989, + 1, + 9.478, + 1, + 9.967, + 1, + 0, + 10, + 1 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeLSmile", + "Segments": [ + 0, + 0, + 1, + 0.633, + 0, + 1.267, + 0, + 1.9, + 0, + 1, + 2.489, + 0, + 3.078, + 0, + 3.667, + 0, + 1, + 4.411, + 0, + 5.156, + 0, + 5.9, + 0, + 1, + 6.767, + 0, + 7.633, + 0, + 8.5, + 0, + 1, + 8.989, + 0, + 9.478, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeROpen", + "Segments": [ + 0, + 1, + 1, + 0.633, + 1, + 1.267, + 1, + 1.9, + 1, + 1, + 2, + 1, + 2.1, + 1, + 2.2, + 1, + 1, + 2.244, + 1, + 2.289, + 0, + 2.333, + 0, + 1, + 2.356, + 0, + 2.378, + 0, + 2.4, + 0, + 1, + 2.456, + 0, + 2.511, + 1, + 2.567, + 1, + 1, + 2.933, + 1, + 3.3, + 1, + 3.667, + 1, + 1, + 3.711, + 1, + 3.756, + 0, + 3.8, + 0, + 1, + 3.822, + 0, + 3.844, + 0, + 3.867, + 0, + 1, + 3.922, + 0, + 3.978, + 1, + 4.033, + 1, + 1, + 4.656, + 1, + 5.278, + 1, + 5.9, + 1, + 1, + 6.433, + 1, + 6.967, + 1, + 7.5, + 1, + 1, + 7.544, + 1, + 7.589, + 0, + 7.633, + 0, + 1, + 7.656, + 0, + 7.678, + 0, + 7.7, + 0, + 1, + 7.756, + 0, + 7.811, + 1, + 7.867, + 1, + 1, + 8.078, + 1, + 8.289, + 1, + 8.5, + 1, + 1, + 8.989, + 1, + 9.478, + 1, + 9.967, + 1, + 0, + 10, + 1 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeRSmile", + "Segments": [ + 0, + 0, + 1, + 0.633, + 0, + 1.267, + 0, + 1.9, + 0, + 1, + 2.489, + 0, + 3.078, + 0, + 3.667, + 0, + 1, + 4.411, + 0, + 5.156, + 0, + 5.9, + 0, + 1, + 6.767, + 0, + 7.633, + 0, + 8.5, + 0, + 1, + 8.989, + 0, + 9.478, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeForm", + "Segments": [ + 0, + 0, + 1, + 0.633, + 0, + 1.267, + 0, + 1.9, + 0, + 1, + 2.489, + 0, + 3.078, + 0, + 3.667, + 0, + 1, + 4.411, + 0, + 5.156, + 0, + 5.9, + 0, + 1, + 6.767, + 0, + 7.633, + 0, + 8.5, + 0, + 1, + 8.989, + 0, + 9.478, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeBallForm", + "Segments": [ + 0, + 0, + 1, + 0.633, + 0, + 1.267, + 0, + 1.9, + 0, + 1, + 2.489, + 0, + 3.078, + 0, + 3.667, + 0, + 1, + 4.411, + 0, + 5.156, + 0, + 5.9, + 0, + 1, + 6.767, + 0, + 7.633, + 0, + 8.5, + 0, + 1, + 8.989, + 0, + 9.478, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamTear", + "Segments": [ + 0, + 0, + 1, + 3.322, + 0, + 6.644, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeBallX", + "Segments": [ + 0, + 0, + 1, + 0.178, + 0, + 0.356, + 0.23, + 0.533, + 0.23, + 1, + 0.989, + 0.23, + 1.444, + 0.23, + 1.9, + 0.23, + 1, + 2.611, + 0.23, + 3.322, + 0, + 4.033, + 0, + 1, + 4.656, + 0, + 5.278, + 0, + 5.9, + 0, + 1, + 6.211, + 0, + 6.522, + -0.198, + 6.833, + -0.3, + 1, + 7.056, + -0.373, + 7.278, + -0.398, + 7.5, + -0.43, + 1, + 7.833, + -0.477, + 8.167, + -0.49, + 8.5, + -0.49, + 1, + 8.989, + -0.49, + 9.478, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeBallY", + "Segments": [ + 0, + 0, + 1, + 0.178, + 0, + 0.356, + 0.25, + 0.533, + 0.25, + 1, + 0.989, + 0.25, + 1.444, + 0.25, + 1.9, + 0.25, + 1, + 2.611, + 0.25, + 3.322, + 0, + 4.033, + 0, + 1, + 4.656, + 0, + 5.278, + 0, + 5.9, + 0, + 1, + 6.211, + 0, + 6.522, + -0.17, + 6.833, + -0.17, + 1, + 7.056, + -0.17, + 7.278, + 0.29, + 7.5, + 0.29, + 1, + 7.833, + 0.29, + 8.167, + 0.282, + 8.5, + 0.21, + 1, + 8.989, + 0.104, + 9.478, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowLY", + "Segments": [ + 0, + 0, + 1, + 0.633, + 0, + 1.267, + 0, + 1.9, + 0, + 1, + 2.489, + 0, + 3.078, + 0, + 3.667, + 0, + 1, + 4.411, + 0, + 5.156, + 0, + 5.9, + 0, + 1, + 6.767, + 0, + 7.633, + 0, + 8.5, + 0, + 1, + 8.989, + 0, + 9.478, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowRY", + "Segments": [ + 0, + 0, + 1, + 0.633, + 0, + 1.267, + 0, + 1.9, + 0, + 1, + 2.489, + 0, + 3.078, + 0, + 3.667, + 0, + 1, + 4.411, + 0, + 5.156, + 0, + 5.9, + 0, + 1, + 6.767, + 0, + 7.633, + 0, + 8.5, + 0, + 1, + 8.989, + 0, + 9.478, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowLX", + "Segments": [ + 0, + 0, + 1, + 0.633, + 0, + 1.267, + 0, + 1.9, + 0, + 1, + 2.489, + 0, + 3.078, + 0, + 3.667, + 0, + 1, + 4.411, + 0, + 5.156, + 0, + 5.9, + 0, + 1, + 6.767, + 0, + 7.633, + 0, + 8.5, + 0, + 1, + 8.989, + 0, + 9.478, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowRX", + "Segments": [ + 0, + 0, + 1, + 0.633, + 0, + 1.267, + 0, + 1.9, + 0, + 1, + 2.489, + 0, + 3.078, + 0, + 3.667, + 0, + 1, + 4.411, + 0, + 5.156, + 0, + 5.9, + 0, + 1, + 6.767, + 0, + 7.633, + 0, + 8.5, + 0, + 1, + 8.989, + 0, + 9.478, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowLAngle", + "Segments": [ + 0, + 0, + 1, + 0.633, + 0, + 1.267, + 0, + 1.9, + 0, + 1, + 2.489, + 0, + 3.078, + 0, + 3.667, + 0, + 1, + 4.411, + 0, + 5.156, + 0, + 5.9, + 0, + 1, + 6.767, + 0, + 7.633, + 0, + 8.5, + 0, + 1, + 8.989, + 0, + 9.478, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowRAngle", + "Segments": [ + 0, + 0, + 1, + 0.633, + 0, + 1.267, + 0, + 1.9, + 0, + 1, + 2.489, + 0, + 3.078, + 0, + 3.667, + 0, + 1, + 4.411, + 0, + 5.156, + 0, + 5.9, + 0, + 1, + 6.767, + 0, + 7.633, + 0, + 8.5, + 0, + 1, + 8.989, + 0, + 9.478, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowLForm", + "Segments": [ + 0, + 0, + 1, + 0.633, + 0, + 1.267, + 0, + 1.9, + 0, + 1, + 2.489, + 0, + 3.078, + 0, + 3.667, + 0, + 1, + 4.411, + 0, + 5.156, + 0, + 5.9, + 0, + 1, + 6.767, + 0, + 7.633, + 0, + 8.5, + 0, + 1, + 8.989, + 0, + 9.478, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowRForm", + "Segments": [ + 0, + 0, + 1, + 0.633, + 0, + 1.267, + 0, + 1.9, + 0, + 1, + 2.489, + 0, + 3.078, + 0, + 3.667, + 0, + 1, + 4.411, + 0, + 5.156, + 0, + 5.9, + 0, + 1, + 6.767, + 0, + 7.633, + 0, + 8.5, + 0, + 1, + 8.989, + 0, + 9.478, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamMouthForm", + "Segments": [ + 0, + 1, + 1, + 3.322, + 1, + 6.644, + 1, + 9.967, + 1, + 0, + 10, + 1 + ] + }, + { + "Target": "Parameter", + "Id": "ParamMouthOpenY", + "Segments": [ + 0, + 0, + 1, + 3.322, + 0, + 6.644, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamScarf", + "Segments": [ + 0, + 0, + 1, + 3.322, + 0, + 6.644, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBodyAngleX", + "Segments": [ + 0, + 0, + 1, + 0.178, + 0, + 0.356, + -3, + 0.533, + -3, + 1, + 0.989, + -3, + 1.444, + -3, + 1.9, + -3, + 1, + 1.989, + -3, + 2.078, + -3, + 2.167, + -3, + 1, + 2.556, + -3, + 2.944, + 0, + 3.333, + 0, + 1, + 3.444, + 0, + 3.556, + 0, + 3.667, + 0, + 1, + 3.722, + 0, + 3.778, + 0, + 3.833, + 0, + 1, + 4.267, + 0, + 4.7, + 4, + 5.133, + 4, + 1, + 5.389, + 4, + 5.644, + 4, + 5.9, + 4, + 1, + 6, + 4, + 6.1, + 4, + 6.2, + 4, + 1, + 6.411, + 4, + 6.622, + 4.702, + 6.833, + 6, + 1, + 7.056, + 7.367, + 7.278, + 8, + 7.5, + 8, + 1, + 7.833, + 8, + 8.167, + 8, + 8.5, + 8, + 1, + 8.6, + 8, + 8.7, + 8, + 8.8, + 8, + 1, + 9.189, + 8, + 9.578, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBodyAngleY", + "Segments": [ + 0, + 0, + 1, + 0.633, + 0, + 1.267, + 0, + 1.9, + 0, + 1, + 1.989, + 0, + 2.078, + 0, + 2.167, + 0, + 1, + 2.667, + 0, + 3.167, + 0, + 3.667, + 0, + 1, + 3.722, + 0, + 3.778, + 0, + 3.833, + 0, + 1, + 4.522, + 0, + 5.211, + 0, + 5.9, + 0, + 1, + 6, + 0, + 6.1, + 0, + 6.2, + 0, + 1, + 6.967, + 0, + 7.733, + 0, + 8.5, + 0, + 1, + 8.6, + 0, + 8.7, + 0, + 8.8, + 0, + 1, + 9.189, + 0, + 9.578, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBodyAngleZ", + "Segments": [ + 0, + 0, + 1, + 0.244, + 0, + 0.489, + 3.788, + 0.733, + 4.987, + 1, + 0.956, + 6.077, + 1.178, + 5.954, + 1.4, + 5.954, + 1, + 1.778, + 5.954, + 2.156, + 5.242, + 2.533, + 2.985, + 1, + 2.8, + 1.393, + 3.067, + 0, + 3.333, + 0, + 1, + 3.444, + 0, + 3.556, + 0, + 3.667, + 0, + 1, + 3.722, + 0, + 3.778, + 0, + 3.833, + 0, + 1, + 4.267, + 0, + 4.7, + -2, + 5.133, + -2, + 1, + 5.389, + -2, + 5.644, + -2, + 5.9, + -2, + 1, + 6, + -2, + 6.1, + -2, + 6.2, + -2, + 1, + 6.411, + -2, + 6.622, + -4, + 6.833, + -4, + 1, + 7.056, + -4, + 7.278, + -2, + 7.5, + -2, + 1, + 7.833, + -2, + 8.167, + -5, + 8.5, + -5, + 1, + 8.6, + -5, + 8.7, + -5, + 8.8, + -5, + 1, + 9.189, + -5, + 9.578, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBodyUpper", + "Segments": [ + 0, + 0, + 1, + 3.322, + 0, + 6.644, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBreath", + "Segments": [ + 0, + 0, + 1, + 0.633, + 0, + 1.267, + 0, + 1.9, + 0, + 1, + 2.489, + 0, + 3.078, + 0, + 3.667, + 0, + 1, + 4.411, + 0, + 5.156, + 0, + 5.9, + 0, + 1, + 6.767, + 0, + 7.633, + 0, + 8.5, + 0, + 1, + 8.989, + 0, + 9.478, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBustY", + "Segments": [ + 0, + 0, + 1, + 0.633, + 0, + 1.267, + 0, + 1.9, + 0, + 1, + 2.489, + 0, + 3.078, + 0, + 3.667, + 0, + 1, + 4.411, + 0, + 5.156, + 0, + 5.9, + 0, + 1, + 6.767, + 0, + 7.633, + 0, + 8.5, + 0, + 1, + 8.989, + 0, + 9.478, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamArmLA", + "Segments": [ + 0, + 1, + 1, + 3.322, + 1, + 6.644, + 1, + 9.967, + 1, + 0, + 10, + 1 + ] + }, + { + "Target": "Parameter", + "Id": "ParamArmRA", + "Segments": [ + 0, + 1, + 1, + 3.322, + 1, + 6.644, + 1, + 9.967, + 1, + 0, + 10, + 1 + ] + }, + { + "Target": "Parameter", + "Id": "ParamArmLB", + "Segments": [ + 0, + 0, + 1, + 3.322, + 0, + 6.644, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamArmRB", + "Segments": [ + 0, + 0, + 1, + 3.322, + 0, + 6.644, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHandChangeR", + "Segments": [ + 0, + 0, + 1, + 3.322, + 0, + 6.644, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHandAngleR", + "Segments": [ + 0, + -0.1, + 1, + 3.322, + -0.1, + 6.644, + -0.1, + 9.967, + -0.1, + 0, + 10, + -0.1 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHandDhangeL", + "Segments": [ + 0, + 0, + 1, + 3.322, + 0, + 6.644, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHandAngleL", + "Segments": [ + 0, + 0.1, + 1, + 3.322, + 0.1, + 6.644, + 0.1, + 9.967, + 0.1, + 0, + 10, + 0.1 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHairFront", + "Segments": [ + 0, + 0, + 1, + 0.633, + 0, + 1.267, + 0, + 1.9, + 0, + 1, + 2.489, + 0, + 3.078, + 0, + 3.667, + 0, + 1, + 4.411, + 0, + 5.156, + 0, + 5.9, + 0, + 1, + 6.767, + 0, + 7.633, + 0, + 8.5, + 0, + 1, + 8.989, + 0, + 9.478, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHairSide", + "Segments": [ + 0, + 0, + 1, + 3.322, + 0, + 6.644, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHairBack", + "Segments": [ + 0, + 0, + 1, + 0.633, + 0, + 1.267, + 0, + 1.9, + 0, + 1, + 2.489, + 0, + 3.078, + 0, + 3.667, + 0, + 1, + 4.411, + 0, + 5.156, + 0, + 5.9, + 0, + 1, + 6.767, + 0, + 7.633, + 0, + 8.5, + 0, + 1, + 8.989, + 0, + 9.478, + 0, + 9.967, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Core", + "Segments": [ + 0, + 1, + 2, + 9.97, + 1, + 0, + 10, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Hoho001", + "Segments": [ + 0, + 1, + 2, + 9.97, + 1, + 0, + 10, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Brow001", + "Segments": [ + 0, + 1, + 2, + 9.97, + 1, + 0, + 10, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Tear", + "Segments": [ + 0, + 1, + 2, + 9.97, + 1, + 0, + 10, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01EyeBall001", + "Segments": [ + 0, + 1, + 2, + 9.97, + 1, + 0, + 10, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Eye001", + "Segments": [ + 0, + 1, + 2, + 9.97, + 1, + 0, + 10, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Nose001", + "Segments": [ + 0, + 1, + 2, + 9.97, + 1, + 0, + 10, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Mouth001", + "Segments": [ + 0, + 1, + 2, + 9.97, + 1, + 0, + 10, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Face001", + "Segments": [ + 0, + 1, + 2, + 9.97, + 1, + 0, + 10, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Ear001", + "Segments": [ + 0, + 1, + 2, + 9.97, + 1, + 0, + 10, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Neck001", + "Segments": [ + 0, + 1, + 2, + 9.97, + 1, + 0, + 10, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01HairFront001", + "Segments": [ + 0, + 1, + 2, + 9.97, + 1, + 0, + 10, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01HairSide001", + "Segments": [ + 0, + 1, + 2, + 9.97, + 1, + 0, + 10, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01HairBack001", + "Segments": [ + 0, + 1, + 2, + 9.97, + 1, + 0, + 10, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01ArmRB001", + "Segments": [ + 0, + 0, + 2, + 9.97, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01ArmLB001", + "Segments": [ + 0, + 0, + 2, + 9.97, + 0, + 0, + 10, + 0 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01ArmRA001", + "Segments": [ + 0, + 1, + 2, + 9.97, + 1, + 0, + 10, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01ArmLA001", + "Segments": [ + 0, + 1, + 2, + 9.97, + 1, + 0, + 10, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Body001", + "Segments": [ + 0, + 1, + 2, + 9.97, + 1, + 0, + 10, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Sketch", + "Segments": [ + 0, + 0, + 2, + 9.97, + 0, + 0, + 10, + 0 + ] + } + ] +} diff --git a/apps/web/public/live2d/haru/motion/haru_g_m05.motion3.json b/apps/web/public/live2d/haru/motion/haru_g_m05.motion3.json new file mode 100644 index 0000000..1c7c630 --- /dev/null +++ b/apps/web/public/live2d/haru/motion/haru_g_m05.motion3.json @@ -0,0 +1,1443 @@ +{ + "Version": 3, + "Meta": { + "Duration": 2.03, + "Fps": 30.0, + "Loop": true, + "AreBeziersRestricted": true, + "CurveCount": 63, + "TotalSegmentCount": 179, + "TotalPointCount": 434, + "UserDataCount": 0, + "TotalUserDataSize": 0, + "FadeInTime": 0.499, + "FadeOutTime": 0.499 + }, + "Curves": [ + { + "Target": "Model", + "Id": "Opacity", + "Segments": [ + 0, + 1, + 1, + 0.67, + 1, + 1.33, + 1, + 2, + 1, + 0, + 2.03, + 1 + ] + }, + { + "Target": "Parameter", + "Id": "ParamAngleX", + "Segments": [ + 0, + 0, + 1, + 0.122, + 0, + 0.244, + 0, + 0.367, + 0, + 1, + 0.489, + 0, + 0.611, + 0, + 0.733, + 0, + 1, + 0.833, + 0, + 0.933, + 0, + 1.033, + 0, + 1, + 1.222, + 0, + 1.411, + 0, + 1.6, + 0, + 1, + 1.733, + 0, + 1.867, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamAngleY", + "Segments": [ + 0, + 0, + 1, + 0.1, + 0, + 0.2, + -5.165, + 0.3, + -9, + 1, + 0.333, + -10.278, + 0.367, + -10, + 0.4, + -10, + 1, + 0.489, + -10, + 0.578, + 13, + 0.667, + 13, + 1, + 0.767, + 13, + 0.867, + -9, + 0.967, + -9, + 1, + 1.156, + -9, + 1.344, + 0, + 1.533, + 0, + 1, + 1.689, + 0, + 1.844, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamAngleZ", + "Segments": [ + 0, + 0, + 1, + 0.667, + 0, + 1.333, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamTere", + "Segments": [ + 0, + 0, + 1, + 0.667, + 0, + 1.333, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamFaceForm", + "Segments": [ + 0, + 0, + 1, + 0.667, + 0, + 1.333, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeLOpen", + "Segments": [ + 0, + 2, + 1, + 0.233, + 2, + 0.467, + 2, + 0.7, + 2, + 1, + 0.756, + 2, + 0.811, + 0, + 0.867, + 0, + 1, + 0.967, + 0, + 1.067, + 0, + 1.167, + 0, + 1, + 1.244, + 0, + 1.322, + 2, + 1.4, + 2, + 1, + 1.6, + 2, + 1.8, + 2, + 2, + 2, + 0, + 2.033, + 2 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeLSmile", + "Segments": [ + 0, + 0, + 1, + 0.667, + 0, + 1.333, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeROpen", + "Segments": [ + 0, + 2, + 1, + 0.233, + 2, + 0.467, + 2, + 0.7, + 2, + 1, + 0.756, + 2, + 0.811, + 0, + 0.867, + 0, + 1, + 0.967, + 0, + 1.067, + 0, + 1.167, + 0, + 1, + 1.244, + 0, + 1.322, + 2, + 1.4, + 2, + 1, + 1.6, + 2, + 1.8, + 2, + 2, + 2, + 0, + 2.033, + 2 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeRSmile", + "Segments": [ + 0, + 0, + 1, + 0.667, + 0, + 1.333, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeForm", + "Segments": [ + 0, + 0, + 1, + 0.667, + 0, + 1.333, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeBallForm", + "Segments": [ + 0, + 0, + 1, + 0.667, + 0, + 1.333, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamTear", + "Segments": [ + 0, + 0, + 1, + 0.667, + 0, + 1.333, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeBallX", + "Segments": [ + 0, + 0, + 1, + 0.111, + 0, + 0.222, + 0, + 0.333, + 0, + 1, + 0.422, + 0, + 0.511, + 0, + 0.6, + 0, + 1, + 0.633, + 0, + 0.667, + 0, + 0.7, + 0, + 1, + 1.133, + 0, + 1.567, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeBallY", + "Segments": [ + 0, + 0.3, + 1, + 0.111, + 0.3, + 0.222, + 0.3, + 0.333, + 0.3, + 1, + 0.422, + 0.3, + 0.511, + 0.3, + 0.6, + 0.3, + 1, + 0.633, + 0.3, + 0.667, + 0.3, + 0.7, + 0.3, + 1, + 0.756, + 0.3, + 0.811, + -0.2, + 0.867, + -0.2, + 1, + 0.967, + -0.2, + 1.067, + -0.2, + 1.167, + -0.2, + 1, + 1.244, + -0.2, + 1.322, + 0.3, + 1.4, + 0.3, + 1, + 1.6, + 0.3, + 1.8, + 0.3, + 2, + 0.3, + 0, + 2.033, + 0.3 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowLY", + "Segments": [ + 0, + 1, + 1, + 0.233, + 1, + 0.467, + 1, + 0.7, + 1, + 1, + 0.756, + 1, + 0.811, + 0.7, + 0.867, + 0.7, + 1, + 0.967, + 0.7, + 1.067, + 0.7, + 1.167, + 0.7, + 1, + 1.244, + 0.7, + 1.322, + 1, + 1.4, + 1, + 1, + 1.6, + 1, + 1.8, + 1, + 2, + 1, + 0, + 2.033, + 1 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowRY", + "Segments": [ + 0, + 1, + 1, + 0.233, + 1, + 0.467, + 1, + 0.7, + 1, + 1, + 0.756, + 1, + 0.811, + 0.7, + 0.867, + 0.7, + 1, + 0.967, + 0.7, + 1.067, + 0.7, + 1.167, + 0.7, + 1, + 1.244, + 0.7, + 1.322, + 1, + 1.4, + 1, + 1, + 1.6, + 1, + 1.8, + 1, + 2, + 1, + 0, + 2.033, + 1 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowLX", + "Segments": [ + 0, + 0, + 1, + 0.667, + 0, + 1.333, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowRX", + "Segments": [ + 0, + 0, + 1, + 0.667, + 0, + 1.333, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowLAngle", + "Segments": [ + 0, + 0, + 1, + 0.667, + 0, + 1.333, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowRAngle", + "Segments": [ + 0, + 0, + 1, + 0.667, + 0, + 1.333, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowLForm", + "Segments": [ + 0, + 0, + 1, + 0.667, + 0, + 1.333, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowRForm", + "Segments": [ + 0, + 0, + 1, + 0.667, + 0, + 1.333, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamMouthForm", + "Segments": [ + 0, + -0.84, + 1, + 0.667, + -0.84, + 1.333, + -0.84, + 2, + -0.84, + 0, + 2.033, + -0.84 + ] + }, + { + "Target": "Parameter", + "Id": "ParamMouthOpenY", + "Segments": [ + 0, + 0, + 1, + 0.667, + 0, + 1.333, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamScarf", + "Segments": [ + 0, + 0, + 1, + 0.667, + 0, + 1.333, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBodyAngleX", + "Segments": [ + 0, + 0, + 1, + 0.667, + 0, + 1.333, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBodyAngleY", + "Segments": [ + 0, + 0, + 1, + 0.133, + 0, + 0.267, + -2, + 0.4, + -2, + 1, + 0.533, + -2, + 0.667, + 2, + 0.8, + 2, + 1, + 0.911, + 2, + 1.022, + 0, + 1.133, + 0, + 1, + 1.422, + 0, + 1.711, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBodyAngleZ", + "Segments": [ + 0, + 0, + 1, + 0.667, + 0, + 1.333, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBodyUpper", + "Segments": [ + 0, + 0, + 1, + 0.667, + 0, + 1.333, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBreath", + "Segments": [ + 0, + 0, + 1, + 0.667, + 0, + 1.333, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBustY", + "Segments": [ + 0, + 0, + 1, + 0.667, + 0, + 1.333, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamArmLA", + "Segments": [ + 0, + -0.16, + 1, + 0.189, + -0.16, + 0.378, + -0.16, + 0.567, + -0.16, + 1, + 0.678, + -0.16, + 0.789, + -0.026, + 0.9, + -0.026, + 1, + 1, + -0.026, + 1.1, + -0.182, + 1.2, + -0.182, + 1, + 1.322, + -0.182, + 1.444, + -0.16, + 1.567, + -0.16, + 1, + 1.711, + -0.16, + 1.856, + -0.16, + 2, + -0.16, + 0, + 2.033, + -0.16 + ] + }, + { + "Target": "Parameter", + "Id": "ParamArmRA", + "Segments": [ + 0, + -0.15, + 1, + 0.189, + -0.15, + 0.378, + -0.15, + 0.567, + -0.15, + 1, + 0.678, + -0.15, + 0.789, + -0.025, + 0.9, + -0.025, + 1, + 1, + -0.025, + 1.1, + -0.181, + 1.2, + -0.181, + 1, + 1.322, + -0.181, + 1.444, + -0.15, + 1.567, + -0.15, + 1, + 1.711, + -0.15, + 1.856, + -0.15, + 2, + -0.15, + 0, + 2.033, + -0.15 + ] + }, + { + "Target": "Parameter", + "Id": "ParamArmLB", + "Segments": [ + 0, + 0, + 1, + 0.667, + 0, + 1.333, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamArmRB", + "Segments": [ + 0, + 0, + 1, + 0.667, + 0, + 1.333, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHandChangeR", + "Segments": [ + 0, + 0, + 1, + 0.667, + 0, + 1.333, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHandAngleR", + "Segments": [ + 0, + 0, + 1, + 0.189, + 0, + 0.378, + 0, + 0.567, + 0, + 1, + 0.644, + 0, + 0.722, + 0.614, + 0.8, + 0.614, + 1, + 0.889, + 0.614, + 0.978, + -0.1, + 1.067, + -0.1, + 1, + 1.178, + -0.1, + 1.289, + 0, + 1.4, + 0, + 1, + 1.6, + 0, + 1.8, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHandDhangeL", + "Segments": [ + 0, + 0, + 1, + 0.667, + 0, + 1.333, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHandAngleL", + "Segments": [ + 0, + 0, + 1, + 0.189, + 0, + 0.378, + 0, + 0.567, + 0, + 1, + 0.644, + 0, + 0.722, + -0.6, + 0.8, + -0.6, + 1, + 0.889, + -0.6, + 0.978, + 0.1, + 1.067, + 0.1, + 1, + 1.178, + 0.1, + 1.289, + 0, + 1.4, + 0, + 1, + 1.6, + 0, + 1.8, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHairFront", + "Segments": [ + 0, + 0, + 1, + 0.667, + 0, + 1.333, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHairSide", + "Segments": [ + 0, + 0, + 1, + 0.667, + 0, + 1.333, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHairBack", + "Segments": [ + 0, + 0, + 1, + 0.667, + 0, + 1.333, + 0, + 2, + 0, + 0, + 2.033, + 0 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Core", + "Segments": [ + 0, + 1, + 2, + 2, + 1, + 0, + 2.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Hoho001", + "Segments": [ + 0, + 1, + 2, + 2, + 1, + 0, + 2.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Brow001", + "Segments": [ + 0, + 1, + 2, + 2, + 1, + 0, + 2.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Tear", + "Segments": [ + 0, + 1, + 2, + 2, + 1, + 0, + 2.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01EyeBall001", + "Segments": [ + 0, + 1, + 2, + 2, + 1, + 0, + 2.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Eye001", + "Segments": [ + 0, + 1, + 2, + 2, + 1, + 0, + 2.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Nose001", + "Segments": [ + 0, + 1, + 2, + 2, + 1, + 0, + 2.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Mouth001", + "Segments": [ + 0, + 1, + 2, + 2, + 1, + 0, + 2.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Face001", + "Segments": [ + 0, + 1, + 2, + 2, + 1, + 0, + 2.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Ear001", + "Segments": [ + 0, + 1, + 2, + 2, + 1, + 0, + 2.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Neck001", + "Segments": [ + 0, + 1, + 2, + 2, + 1, + 0, + 2.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01HairFront001", + "Segments": [ + 0, + 1, + 2, + 2, + 1, + 0, + 2.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01HairSide001", + "Segments": [ + 0, + 1, + 2, + 2, + 1, + 0, + 2.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01HairBack001", + "Segments": [ + 0, + 1, + 2, + 2, + 1, + 0, + 2.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01ArmRB001", + "Segments": [ + 0, + 0, + 2, + 2, + 0, + 0, + 2.03, + 0 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01ArmLB001", + "Segments": [ + 0, + 0, + 2, + 2, + 0, + 0, + 2.03, + 0 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01ArmRA001", + "Segments": [ + 0, + 1, + 2, + 2, + 1, + 0, + 2.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01ArmLA001", + "Segments": [ + 0, + 1, + 2, + 2, + 1, + 0, + 2.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Body001", + "Segments": [ + 0, + 1, + 2, + 2, + 1, + 0, + 2.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Sketch", + "Segments": [ + 0, + 0, + 2, + 2, + 0, + 0, + 2.03, + 0 + ] + } + ] +} diff --git a/apps/web/public/live2d/haru/motion/haru_g_m07.motion3.json b/apps/web/public/live2d/haru/motion/haru_g_m07.motion3.json new file mode 100644 index 0000000..a7d6d86 --- /dev/null +++ b/apps/web/public/live2d/haru/motion/haru_g_m07.motion3.json @@ -0,0 +1,1497 @@ +{ + "Version": 3, + "Meta": { + "Duration": 3.93, + "Fps": 30.0, + "Loop": true, + "AreBeziersRestricted": true, + "CurveCount": 63, + "TotalSegmentCount": 187, + "TotalPointCount": 458, + "UserDataCount": 0, + "TotalUserDataSize": 0 + }, + "Curves": [ + { + "Target": "Model", + "Id": "Opacity", + "Segments": [ + 0, + 1, + 1, + 1.3, + 1, + 2.6, + 1, + 3.9, + 1, + 0, + 3.93, + 1 + ] + }, + { + "Target": "Parameter", + "Id": "ParamAngleX", + "Segments": [ + 0, + 0, + 1, + 0.467, + 0, + 0.933, + 9.534, + 1.4, + 14, + 1, + 1.567, + 15.595, + 1.733, + 15, + 1.9, + 15, + 1, + 2.289, + 15, + 2.678, + 0, + 3.067, + 0, + 1, + 3.344, + 0, + 3.622, + 0, + 3.9, + 0, + 0, + 3.933, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamAngleY", + "Segments": [ + 0, + 0, + 1, + 0.244, + 0, + 0.489, + 0.061, + 0.733, + -1, + 1, + 0.9, + -1.723, + 1.067, + -13, + 1.233, + -13, + 1, + 1.422, + -13, + 1.611, + 5, + 1.8, + 5, + 1, + 2.189, + 5, + 2.578, + 0, + 2.967, + 0, + 1, + 3.278, + 0, + 3.589, + 0, + 3.9, + 0, + 0, + 3.933, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamAngleZ", + "Segments": [ + 0, + 0, + 1, + 0.367, + 0, + 0.733, + -18, + 1.1, + -18, + 1, + 1.4, + -18, + 1.7, + -18, + 2, + -18, + 1, + 2.3, + -18, + 2.6, + 18, + 2.9, + 18, + 1, + 3.233, + 18, + 3.567, + 18, + 3.9, + 18, + 0, + 3.933, + 18 + ] + }, + { + "Target": "Parameter", + "Id": "ParamTere", + "Segments": [ + 0, + 0, + 1, + 1.3, + 0, + 2.6, + 0, + 3.9, + 0, + 0, + 3.933, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamFaceForm", + "Segments": [ + 0, + 0, + 1, + 1.3, + 0, + 2.6, + 0, + 3.9, + 0, + 0, + 3.933, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeLOpen", + "Segments": [ + 0, + 1, + 1, + 0.344, + 1, + 0.689, + 1, + 1.033, + 1, + 1, + 1.078, + 1, + 1.122, + 0, + 1.167, + 0, + 1, + 1.222, + 0, + 1.278, + 0, + 1.333, + 0, + 1, + 1.411, + 0, + 1.489, + 1, + 1.567, + 1, + 1, + 2.344, + 1, + 3.122, + 1, + 3.9, + 1, + 0, + 3.933, + 1 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeLSmile", + "Segments": [ + 0, + 0, + 1, + 1.3, + 0, + 2.6, + 0, + 3.9, + 0, + 0, + 3.933, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeROpen", + "Segments": [ + 0, + 1, + 1, + 0.344, + 1, + 0.689, + 1, + 1.033, + 1, + 1, + 1.078, + 1, + 1.122, + 0, + 1.167, + 0, + 1, + 1.222, + 0, + 1.278, + 0, + 1.333, + 0, + 1, + 1.411, + 0, + 1.489, + 1, + 1.567, + 1, + 1, + 2.344, + 1, + 3.122, + 1, + 3.9, + 1, + 0, + 3.933, + 1 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeRSmile", + "Segments": [ + 0, + 0, + 1, + 1.3, + 0, + 2.6, + 0, + 3.9, + 0, + 0, + 3.933, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeForm", + "Segments": [ + 0, + 0, + 1, + 1.3, + 0, + 2.6, + 0, + 3.9, + 0, + 0, + 3.933, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeBallForm", + "Segments": [ + 0, + 0, + 1, + 1.3, + 0, + 2.6, + 0, + 3.9, + 0, + 0, + 3.933, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamTear", + "Segments": [ + 0, + 0, + 1, + 1.3, + 0, + 2.6, + 0, + 3.9, + 0, + 0, + 3.933, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeBallX", + "Segments": [ + 0, + 0, + 1, + 0.3, + 0, + 0.6, + -0.27, + 0.9, + -0.27, + 1, + 0.989, + -0.27, + 1.078, + -0.27, + 1.167, + -0.27, + 1, + 1.2, + -0.27, + 1.233, + 0.37, + 1.267, + 0.37, + 1, + 1.378, + 0.37, + 1.489, + 0.37, + 1.6, + 0.37, + 1, + 1.944, + 0.37, + 2.289, + 0, + 2.633, + 0, + 1, + 3.056, + 0, + 3.478, + 0, + 3.9, + 0, + 0, + 3.933, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeBallY", + "Segments": [ + 0, + 0, + 1, + 0.3, + 0, + 0.6, + 0.58, + 0.9, + 0.58, + 1, + 0.989, + 0.58, + 1.078, + 0.58, + 1.167, + 0.58, + 1, + 1.2, + 0.58, + 1.233, + 0.29, + 1.267, + 0.29, + 1, + 1.378, + 0.29, + 1.489, + 0.29, + 1.6, + 0.29, + 1, + 1.944, + 0.29, + 2.289, + 0, + 2.633, + 0, + 1, + 3.056, + 0, + 3.478, + 0, + 3.9, + 0, + 0, + 3.933, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowLY", + "Segments": [ + 0, + 0, + 1, + 1.3, + 0, + 2.6, + 0, + 3.9, + 0, + 0, + 3.933, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowRY", + "Segments": [ + 0, + 0, + 1, + 1.3, + 0, + 2.6, + 0, + 3.9, + 0, + 0, + 3.933, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowLX", + "Segments": [ + 0, + 0, + 1, + 1.3, + 0, + 2.6, + 0, + 3.9, + 0, + 0, + 3.933, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowRX", + "Segments": [ + 0, + 0, + 1, + 1.3, + 0, + 2.6, + 0, + 3.9, + 0, + 0, + 3.933, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowLAngle", + "Segments": [ + 0, + 0, + 1, + 1.3, + 0, + 2.6, + 0, + 3.9, + 0, + 0, + 3.933, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowRAngle", + "Segments": [ + 0, + 0, + 1, + 1.3, + 0, + 2.6, + 0, + 3.9, + 0, + 0, + 3.933, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowLForm", + "Segments": [ + 0, + 0, + 1, + 1.3, + 0, + 2.6, + 0, + 3.9, + 0, + 0, + 3.933, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowRForm", + "Segments": [ + 0, + 0, + 1, + 1.3, + 0, + 2.6, + 0, + 3.9, + 0, + 0, + 3.933, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamMouthForm", + "Segments": [ + 0, + 1, + 1, + 1.3, + 1, + 2.6, + 1, + 3.9, + 1, + 0, + 3.933, + 1 + ] + }, + { + "Target": "Parameter", + "Id": "ParamMouthOpenY", + "Segments": [ + 0, + 0, + 1, + 1.3, + 0, + 2.6, + 0, + 3.9, + 0, + 0, + 3.933, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamScarf", + "Segments": [ + 0, + 0, + 1, + 1.3, + 0, + 2.6, + 0, + 3.9, + 0, + 0, + 3.933, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBodyAngleX", + "Segments": [ + 0, + 0, + 1, + 0.533, + 0, + 1.067, + -7, + 1.6, + -7, + 1, + 2.056, + -7, + 2.511, + 4, + 2.967, + 4, + 1, + 3.278, + 4, + 3.589, + 0, + 3.9, + 0, + 0, + 3.933, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBodyAngleY", + "Segments": [ + 0, + 0, + 1, + 1.3, + 0, + 2.6, + 0, + 3.9, + 0, + 0, + 3.933, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBodyAngleZ", + "Segments": [ + 0, + 0, + 1, + 0.578, + 0, + 1.156, + -10, + 1.733, + -10, + 1, + 2.1, + -10, + 2.467, + -7.702, + 2.833, + -4, + 1, + 3.122, + -1.083, + 3.411, + 0, + 3.7, + 0, + 1, + 3.767, + 0, + 3.833, + 0, + 3.9, + 0, + 0, + 3.933, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBodyUpper", + "Segments": [ + 0, + 0, + 1, + 1.3, + 0, + 2.6, + 0, + 3.9, + 0, + 0, + 3.933, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBreath", + "Segments": [ + 0, + 0, + 1, + 1.3, + 0, + 2.6, + 0, + 3.9, + 0, + 0, + 3.933, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBustY", + "Segments": [ + 0, + 0, + 1, + 1.3, + 0, + 2.6, + 0, + 3.9, + 0, + 0, + 3.933, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamArmLA", + "Segments": [ + 0, + 0.5, + 1, + 0.156, + 0.5, + 0.311, + 0.734, + 0.467, + 0.76, + 1, + 1.533, + 0.94, + 2.6, + 1, + 3.667, + 1, + 1, + 3.744, + 1, + 3.822, + 0.5, + 3.9, + 0.5, + 0, + 3.933, + 0.5 + ] + }, + { + "Target": "Parameter", + "Id": "ParamArmRA", + "Segments": [ + 0, + 0.5, + 1, + 0.156, + 0.5, + 0.311, + 0.734, + 0.467, + 0.76, + 1, + 1.533, + 0.94, + 2.6, + 1, + 3.667, + 1, + 1, + 3.744, + 1, + 3.822, + 0.5, + 3.9, + 0.5, + 0, + 3.933, + 0.5 + ] + }, + { + "Target": "Parameter", + "Id": "ParamArmLB", + "Segments": [ + 0, + 2.5, + 1, + 0.133, + 2.5, + 0.267, + 2.3, + 0.4, + 2.3, + 1, + 0.567, + 2.3, + 0.733, + 2.42, + 0.9, + 2.9, + 1, + 1.067, + 3.38, + 1.233, + 3.8, + 1.4, + 3.8, + 1, + 1.556, + 3.8, + 1.711, + 3.857, + 1.867, + 3.7, + 1, + 2.356, + 3.207, + 2.844, + 2.4, + 3.333, + 2.4, + 1, + 3.522, + 2.4, + 3.711, + 2.5, + 3.9, + 2.5, + 0, + 3.933, + 2.5 + ] + }, + { + "Target": "Parameter", + "Id": "ParamArmRB", + "Segments": [ + 0, + 3.5, + 1, + 0.133, + 3.5, + 0.267, + 3.1, + 0.4, + 3.1, + 1, + 0.733, + 3.1, + 1.067, + 5, + 1.4, + 5, + 1, + 1.522, + 5, + 1.644, + 5, + 1.767, + 5, + 1, + 2, + 5, + 2.233, + 4.5, + 2.467, + 4.5, + 1, + 2.944, + 4.5, + 3.422, + 4.5, + 3.9, + 4.5, + 0, + 3.933, + 4.5 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHandChangeR", + "Segments": [ + 0, + 0, + 1, + 1.3, + 0, + 2.6, + 0, + 3.9, + 0, + 0, + 3.933, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHandAngleR", + "Segments": [ + 0, + 0, + 1, + 0.089, + 0, + 0.178, + -0.42, + 0.267, + -0.42, + 1, + 0.344, + -0.42, + 0.422, + 0.35, + 0.5, + 0.35, + 1, + 0.833, + 0.35, + 1.167, + 0, + 1.5, + 0, + 1, + 1.622, + 0, + 1.744, + 0.36, + 1.867, + 0.36, + 1, + 2.111, + 0.36, + 2.356, + -1, + 2.6, + -1, + 1, + 2.867, + -1, + 3.133, + -0.92, + 3.4, + -0.92, + 1, + 3.567, + -0.92, + 3.733, + -0.92, + 3.9, + -0.92, + 0, + 3.933, + -0.92 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHandDhangeL", + "Segments": [ + 0, + 0.5, + 1, + 0.278, + 0.5, + 0.556, + 0.5, + 0.833, + 0.5, + 1, + 0.9, + 0.5, + 0.967, + 0, + 1.033, + 0, + 1, + 1.989, + 0, + 2.944, + 0, + 3.9, + 0, + 0, + 3.933, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHandAngleL", + "Segments": [ + 0, + 0, + 1, + 0.089, + 0, + 0.178, + -0.39, + 0.267, + -0.39, + 1, + 0.344, + -0.39, + 0.422, + -0.33, + 0.5, + -0.33, + 1, + 0.656, + -0.33, + 0.811, + -0.68, + 0.967, + -0.68, + 1, + 1.222, + -0.68, + 1.478, + 0.36, + 1.733, + 0.36, + 1, + 1.933, + 0.36, + 2.133, + 0.36, + 2.333, + 0.36, + 1, + 2.422, + 0.36, + 2.511, + -0.26, + 2.6, + -0.26, + 1, + 2.767, + -0.26, + 2.933, + -0.26, + 3.1, + -0.26, + 1, + 3.367, + -0.26, + 3.633, + -0.26, + 3.9, + -0.26, + 0, + 3.933, + -0.26 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHairFront", + "Segments": [ + 0, + 0, + 1, + 1.3, + 0, + 2.6, + 0, + 3.9, + 0, + 0, + 3.933, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHairSide", + "Segments": [ + 0, + 0, + 1, + 1.3, + 0, + 2.6, + 0, + 3.9, + 0, + 0, + 3.933, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHairBack", + "Segments": [ + 0, + 0, + 1, + 1.3, + 0, + 2.6, + 0, + 3.9, + 0, + 0, + 3.933, + 0 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Core", + "Segments": [ + 0, + 1, + 2, + 3.9, + 1, + 0, + 3.93, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Hoho001", + "Segments": [ + 0, + 1, + 2, + 3.9, + 1, + 0, + 3.93, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Brow001", + "Segments": [ + 0, + 1, + 2, + 3.9, + 1, + 0, + 3.93, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Tear", + "Segments": [ + 0, + 1, + 2, + 3.9, + 1, + 0, + 3.93, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01EyeBall001", + "Segments": [ + 0, + 1, + 2, + 3.9, + 1, + 0, + 3.93, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Eye001", + "Segments": [ + 0, + 1, + 2, + 3.9, + 1, + 0, + 3.93, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Nose001", + "Segments": [ + 0, + 1, + 2, + 3.9, + 1, + 0, + 3.93, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Mouth001", + "Segments": [ + 0, + 1, + 2, + 3.9, + 1, + 0, + 3.93, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Face001", + "Segments": [ + 0, + 1, + 2, + 3.9, + 1, + 0, + 3.93, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Ear001", + "Segments": [ + 0, + 1, + 2, + 3.9, + 1, + 0, + 3.93, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Neck001", + "Segments": [ + 0, + 1, + 2, + 3.9, + 1, + 0, + 3.93, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01HairFront001", + "Segments": [ + 0, + 1, + 2, + 3.9, + 1, + 0, + 3.93, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01HairSide001", + "Segments": [ + 0, + 1, + 2, + 3.9, + 1, + 0, + 3.93, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01HairBack001", + "Segments": [ + 0, + 1, + 2, + 3.9, + 1, + 0, + 3.93, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01ArmRB001", + "Segments": [ + 0, + 1, + 2, + 3.9, + 1, + 0, + 3.93, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01ArmLB001", + "Segments": [ + 0, + 1, + 2, + 3.9, + 1, + 0, + 3.93, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01ArmRA001", + "Segments": [ + 0, + 0, + 2, + 3.9, + 0, + 0, + 3.93, + 0 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01ArmLA001", + "Segments": [ + 0, + 0, + 2, + 3.9, + 0, + 0, + 3.93, + 0 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Body001", + "Segments": [ + 0, + 1, + 2, + 3.9, + 1, + 0, + 3.93, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Sketch", + "Segments": [ + 0, + 0, + 2, + 3.9, + 0, + 0, + 3.93, + 0 + ] + } + ] +} \ No newline at end of file diff --git a/apps/web/public/live2d/haru/motion/haru_g_m14.motion3.json b/apps/web/public/live2d/haru/motion/haru_g_m14.motion3.json new file mode 100644 index 0000000..a0c55c0 --- /dev/null +++ b/apps/web/public/live2d/haru/motion/haru_g_m14.motion3.json @@ -0,0 +1,2176 @@ +{ + "Version": 3, + "Meta": { + "Duration": 3.03, + "Fps": 30.0, + "Loop": true, + "AreBeziersRestricted": true, + "CurveCount": 63, + "TotalSegmentCount": 284, + "TotalPointCount": 749, + "UserDataCount": 0, + "TotalUserDataSize": 0 + }, + "Curves": [ + { + "Target": "Model", + "Id": "Opacity", + "Segments": [ + 0, + 1, + 1, + 1, + 1, + 2, + 1, + 3, + 1, + 0, + 3.03, + 1 + ] + }, + { + "Target": "Parameter", + "Id": "ParamAngleX", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.589, + 0, + 0.678, + 1, + 0.767, + 1, + 1, + 0.889, + 1, + 1.011, + 1.06, + 1.133, + 0.632, + 1, + 1.256, + 0.204, + 1.378, + -2, + 1.5, + -2, + 1, + 2, + -2, + 2.5, + -2, + 3, + -2, + 0, + 3.033, + -2 + ] + }, + { + "Target": "Parameter", + "Id": "ParamAngleY", + "Segments": [ + 0, + 0, + 1, + 0.122, + 0, + 0.244, + 0, + 0.367, + 0, + 1, + 0.433, + 0, + 0.5, + -1, + 0.567, + -1, + 1, + 0.689, + -1, + 0.811, + 16, + 0.933, + 16, + 1, + 1.067, + 16, + 1.2, + 16.106, + 1.333, + 13.915, + 1, + 1.444, + 12.09, + 1.556, + -1, + 1.667, + -1, + 1, + 2.111, + -1, + 2.556, + -1, + 3, + -1, + 0, + 3.033, + -1 + ] + }, + { + "Target": "Parameter", + "Id": "ParamAngleZ", + "Segments": [ + 0, + 0, + 1, + 0.211, + 0, + 0.422, + 0, + 0.633, + 0, + 1, + 0.767, + 0, + 0.9, + -9, + 1.033, + -9, + 1, + 1.1, + -9, + 1.167, + -9.696, + 1.233, + -5.567, + 1, + 1.356, + 2.004, + 1.478, + 15.49, + 1.6, + 19, + 1, + 1.778, + 19.291, + 1.956, + 18, + 2.133, + 18, + 1, + 2.422, + 18, + 2.711, + 18, + 3, + 18, + 0, + 3.033, + 18 + ] + }, + { + "Target": "Parameter", + "Id": "ParamTere", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.711, + 0, + 0.922, + 0, + 1.133, + 0, + 1, + 1.256, + 0, + 1.378, + 0, + 1.5, + 0, + 1, + 2, + 0, + 2.5, + 0, + 3, + 0, + 0, + 3.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamFaceForm", + "Segments": [ + 0, + 0, + 1, + 1, + 0, + 2, + 0, + 3, + 0, + 0, + 3.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeLOpen", + "Segments": [ + 0, + 1, + 1, + 0.167, + 1, + 0.333, + 1, + 0.5, + 1, + 1, + 0.589, + 1, + 0.678, + 0, + 0.767, + 0, + 1, + 0.8, + 0, + 0.833, + 0, + 0.867, + 0, + 1, + 0.956, + 0, + 1.044, + 1.332, + 1.133, + 1.616, + 1, + 1.256, + 2.007, + 1.378, + 2, + 1.5, + 2, + 1, + 1.667, + 2, + 1.833, + 2, + 2, + 2, + 1, + 2.044, + 2, + 2.089, + 0, + 2.133, + 0, + 1, + 2.167, + 0, + 2.2, + 0, + 2.233, + 0, + 1, + 2.311, + 0, + 2.389, + 2, + 2.467, + 2, + 1, + 2.644, + 2, + 2.822, + 2, + 3, + 2, + 0, + 3.033, + 2 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeLSmile", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.711, + 0, + 0.922, + 0, + 1.133, + 0, + 1, + 1.256, + 0, + 1.378, + 0, + 1.5, + 0, + 1, + 2, + 0, + 2.5, + 0, + 3, + 0, + 0, + 3.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeROpen", + "Segments": [ + 0, + 1, + 1, + 0.167, + 1, + 0.333, + 1, + 0.5, + 1, + 1, + 0.589, + 1, + 0.678, + 0, + 0.767, + 0, + 1, + 0.8, + 0, + 0.833, + 0, + 0.867, + 0, + 1, + 0.956, + 0, + 1.044, + 1.332, + 1.133, + 1.616, + 1, + 1.256, + 2.007, + 1.378, + 2, + 1.5, + 2, + 1, + 1.667, + 2, + 1.833, + 2, + 2, + 2, + 1, + 2.044, + 2, + 2.089, + 0, + 2.133, + 0, + 1, + 2.167, + 0, + 2.2, + 0, + 2.233, + 0, + 1, + 2.311, + 0, + 2.389, + 2, + 2.467, + 2, + 1, + 2.644, + 2, + 2.822, + 2, + 3, + 2, + 0, + 3.033, + 2 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeRSmile", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.711, + 0, + 0.922, + 0, + 1.133, + 0, + 1, + 1.256, + 0, + 1.378, + 0, + 1.5, + 0, + 1, + 2, + 0, + 2.5, + 0, + 3, + 0, + 0, + 3.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeForm", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.711, + 0, + 0.922, + 0, + 1.133, + 0, + 1, + 1.256, + 0, + 1.378, + 0.3, + 1.5, + 0.3, + 1, + 2, + 0.3, + 2.5, + 0.3, + 3, + 0.3, + 0, + 3.033, + 0.3 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeBallForm", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.711, + 0, + 0.922, + 0, + 1.133, + 0, + 1, + 1.256, + 0, + 1.378, + 0, + 1.5, + 0, + 1, + 2, + 0, + 2.5, + 0, + 3, + 0, + 0, + 3.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamTear", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.711, + 0, + 0.922, + 0, + 1.133, + 0, + 1, + 1.256, + 0, + 1.378, + 0, + 1.5, + 0, + 1, + 2, + 0, + 2.5, + 0, + 3, + 0, + 0, + 3.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeBallX", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.711, + 0, + 0.922, + 0, + 1.133, + 0, + 1, + 1.256, + 0, + 1.378, + 0, + 1.5, + 0, + 1, + 2, + 0, + 2.5, + 0, + 3, + 0, + 0, + 3.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeBallY", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.711, + 0, + 0.922, + 0.039, + 1.133, + 0.123, + 1, + 1.256, + 0.172, + 1.378, + 0.2, + 1.5, + 0.2, + 1, + 1.667, + 0.2, + 1.833, + 0.2, + 2, + 0.2, + 1, + 2.044, + 0.2, + 2.089, + -0.2, + 2.133, + -0.2, + 1, + 2.167, + -0.2, + 2.2, + -0.2, + 2.233, + -0.2, + 1, + 2.311, + -0.2, + 2.389, + 0.2, + 2.467, + 0.2, + 1, + 2.644, + 0.2, + 2.822, + 0.2, + 3, + 0.2, + 0, + 3.033, + 0.2 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowLY", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.589, + 0, + 0.678, + -0.1, + 0.767, + -0.1, + 1, + 0.8, + -0.1, + 0.833, + -0.1, + 0.867, + -0.1, + 1, + 0.956, + -0.1, + 1.044, + 0.291, + 1.133, + 0.493, + 1, + 1.256, + 0.77, + 1.378, + 0.8, + 1.5, + 0.8, + 1, + 2, + 0.8, + 2.5, + 0.8, + 3, + 0.8, + 0, + 3.033, + 0.8 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowRY", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.589, + 0, + 0.678, + -0.1, + 0.767, + -0.1, + 1, + 0.8, + -0.1, + 0.833, + -0.1, + 0.867, + -0.1, + 1, + 0.956, + -0.1, + 1.044, + 0.291, + 1.133, + 0.493, + 1, + 1.256, + 0.77, + 1.378, + 0.8, + 1.5, + 0.8, + 1, + 2, + 0.8, + 2.5, + 0.8, + 3, + 0.8, + 0, + 3.033, + 0.8 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowLX", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.711, + 0, + 0.922, + 0.039, + 1.133, + 0.123, + 1, + 1.256, + 0.172, + 1.378, + 0.2, + 1.5, + 0.2, + 1, + 2, + 0.2, + 2.5, + 0.2, + 3, + 0.2, + 0, + 3.033, + 0.2 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowRX", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.711, + 0, + 0.922, + 0.039, + 1.133, + 0.123, + 1, + 1.256, + 0.172, + 1.378, + 0.2, + 1.5, + 0.2, + 1, + 2, + 0.2, + 2.5, + 0.2, + 3, + 0.2, + 0, + 3.033, + 0.2 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowLAngle", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.711, + 0, + 0.922, + 0.039, + 1.133, + 0.123, + 1, + 1.256, + 0.172, + 1.378, + 0.2, + 1.5, + 0.2, + 1, + 2, + 0.2, + 2.5, + 0.2, + 3, + 0.2, + 0, + 3.033, + 0.2 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowRAngle", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.711, + 0, + 0.922, + 0.039, + 1.133, + 0.123, + 1, + 1.256, + 0.172, + 1.378, + 0.2, + 1.5, + 0.2, + 1, + 2, + 0.2, + 2.5, + 0.2, + 3, + 0.2, + 0, + 3.033, + 0.2 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowLForm", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.711, + 0, + 0.922, + 0.078, + 1.133, + 0.246, + 1, + 1.256, + 0.344, + 1.378, + 0.4, + 1.5, + 0.4, + 1, + 2, + 0.4, + 2.5, + 0.4, + 3, + 0.4, + 0, + 3.033, + 0.4 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowRForm", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.711, + 0, + 0.922, + 0.078, + 1.133, + 0.246, + 1, + 1.256, + 0.344, + 1.378, + 0.4, + 1.5, + 0.4, + 1, + 2, + 0.4, + 2.5, + 0.4, + 3, + 0.4, + 0, + 3.033, + 0.4 + ] + }, + { + "Target": "Parameter", + "Id": "ParamMouthForm", + "Segments": [ + 0, + 1, + 1, + 0.167, + 1, + 0.333, + 1, + 0.5, + 1, + 1, + 0.589, + 1, + 0.678, + -0.5, + 0.767, + -0.5, + 1, + 0.889, + -0.5, + 1.011, + -0.232, + 1.133, + -0.232, + 1, + 1.256, + -0.232, + 1.378, + -1, + 1.5, + -1, + 1, + 2, + -1, + 2.5, + -1, + 3, + -1, + 0, + 3.033, + -1 + ] + }, + { + "Target": "Parameter", + "Id": "ParamMouthOpenY", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.711, + 0, + 0.922, + 0, + 1.133, + 0, + 1, + 1.256, + 0, + 1.378, + 0, + 1.5, + 0, + 1, + 2, + 0, + 2.5, + 0, + 3, + 0, + 0, + 3.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamScarf", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.711, + 0, + 0.922, + 0, + 1.133, + 0, + 1, + 1.256, + 0, + 1.378, + 0, + 1.5, + 0, + 1, + 2, + 0, + 2.5, + 0, + 3, + 0, + 0, + 3.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBodyAngleX", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.589, + 0, + 0.678, + 3, + 0.767, + 3, + 1, + 0.889, + 3, + 1.011, + 1.294, + 1.133, + -0.616, + 1, + 1.256, + -2.526, + 1.378, + -3, + 1.5, + -3, + 1, + 2, + -3, + 2.5, + -3, + 3, + -3, + 0, + 3.033, + -3 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBodyAngleY", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.711, + 0, + 0.922, + 7, + 1.133, + 7, + 1, + 1.256, + 7, + 1.378, + -9, + 1.5, + -9, + 1, + 2, + -9, + 2.5, + -9, + 3, + -9, + 0, + 3.033, + -9 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBodyAngleZ", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.589, + 0, + 0.678, + -4.509, + 0.767, + -4.728, + 1, + 0.889, + -5.029, + 1.011, + -5, + 1.133, + -5, + 1, + 1.256, + -5, + 1.378, + 7, + 1.5, + 7, + 1, + 2, + 7, + 2.5, + 7, + 3, + 7, + 0, + 3.033, + 7 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBodyUpper", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.589, + 0, + 0.678, + -1.507, + 0.767, + -1.507, + 1, + 0.889, + -1.507, + 1.011, + -1.675, + 1.133, + -0.529, + 1, + 1.256, + 0.618, + 1.378, + 10, + 1.5, + 10, + 1, + 2, + 10, + 2.5, + 10, + 3, + 10, + 0, + 3.033, + 10 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBreath", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.711, + 0, + 0.922, + 0, + 1.133, + 0, + 1, + 1.256, + 0, + 1.378, + 0, + 1.5, + 0, + 1, + 2, + 0, + 2.5, + 0, + 3, + 0, + 0, + 3.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBustY", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.711, + 0, + 0.922, + 0, + 1.133, + 0, + 1, + 1.256, + 0, + 1.378, + 0, + 1.5, + 0, + 1, + 2, + 0, + 2.5, + 0, + 3, + 0, + 0, + 3.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamArmLA", + "Segments": [ + 0, + 1, + 1, + 0.167, + 1, + 0.333, + 1, + 0.5, + 1, + 1, + 0.711, + 1, + 0.922, + 0.3, + 1.133, + 0.3, + 1, + 1.256, + 0.3, + 1.378, + 0.327, + 1.5, + 0.4, + 1, + 1.6, + 0.459, + 1.7, + 0.5, + 1.8, + 0.5, + 1, + 2.2, + 0.5, + 2.6, + 0.5, + 3, + 0.5, + 0, + 3.033, + 0.5 + ] + }, + { + "Target": "Parameter", + "Id": "ParamArmRA", + "Segments": [ + 0, + 1, + 1, + 0.167, + 1, + 0.333, + 1, + 0.5, + 1, + 1, + 0.711, + 1, + 0.922, + 0.3, + 1.133, + 0.3, + 1, + 1.256, + 0.3, + 1.378, + 0.327, + 1.5, + 0.4, + 1, + 1.6, + 0.459, + 1.7, + 0.5, + 1.8, + 0.5, + 1, + 2.2, + 0.5, + 2.6, + 0.5, + 3, + 0.5, + 0, + 3.033, + 0.5 + ] + }, + { + "Target": "Parameter", + "Id": "ParamArmLB", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.711, + 0, + 0.922, + 0, + 1.133, + 0, + 1, + 1.256, + 0, + 1.378, + 0, + 1.5, + 0, + 1, + 2, + 0, + 2.5, + 0, + 3, + 0, + 0, + 3.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamArmRB", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.711, + 0, + 0.922, + 0, + 1.133, + 0, + 1, + 1.256, + 0, + 1.378, + 0, + 1.5, + 0, + 1, + 2, + 0, + 2.5, + 0, + 3, + 0, + 0, + 3.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHandChangeR", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.744, + 0, + 0.989, + 0, + 1.233, + 0, + 1, + 1.467, + 0, + 1.7, + 0, + 1.933, + 0, + 1, + 2.289, + 0, + 2.644, + 0, + 3, + 0, + 0, + 3.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHandAngleR", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.589, + 0, + 0.678, + 0.9, + 0.767, + 0.9, + 1, + 0.922, + 0.9, + 1.078, + 0.681, + 1.233, + 0, + 1, + 1.344, + -0.486, + 1.456, + -0.9, + 1.567, + -0.9, + 1, + 1.689, + -0.9, + 1.811, + -0.6, + 1.933, + -0.6, + 1, + 2.289, + -0.6, + 2.644, + -0.6, + 3, + -0.6, + 0, + 3.033, + -0.6 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHandDhangeL", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.744, + 0, + 0.989, + 0, + 1.233, + 0, + 1, + 1.467, + 0, + 1.7, + 0, + 1.933, + 0, + 1, + 2.289, + 0, + 2.644, + 0, + 3, + 0, + 0, + 3.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHandAngleL", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.589, + 0, + 0.678, + -0.9, + 0.767, + -0.9, + 1, + 0.922, + -0.9, + 1.078, + -0.681, + 1.233, + 0, + 1, + 1.344, + 0.486, + 1.456, + 0.9, + 1.567, + 0.9, + 1, + 1.689, + 0.9, + 1.811, + 0.6, + 1.933, + 0.6, + 1, + 2.289, + 0.6, + 2.644, + 0.6, + 3, + 0.6, + 0, + 3.033, + 0.6 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHairFront", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.711, + 0, + 0.922, + 0, + 1.133, + 0, + 1, + 1.256, + 0, + 1.378, + 0, + 1.5, + 0, + 1, + 2, + 0, + 2.5, + 0, + 3, + 0, + 0, + 3.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHairSide", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.711, + 0, + 0.922, + 0, + 1.133, + 0, + 1, + 1.256, + 0, + 1.378, + 0, + 1.5, + 0, + 1, + 2, + 0, + 2.5, + 0, + 3, + 0, + 0, + 3.033, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHairBack", + "Segments": [ + 0, + 0, + 1, + 0.167, + 0, + 0.333, + 0, + 0.5, + 0, + 1, + 0.711, + 0, + 0.922, + 0, + 1.133, + 0, + 1, + 1.256, + 0, + 1.378, + 0, + 1.5, + 0, + 1, + 2, + 0, + 2.5, + 0, + 3, + 0, + 0, + 3.033, + 0 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Core", + "Segments": [ + 0, + 1, + 2, + 3, + 1, + 0, + 3.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Hoho001", + "Segments": [ + 0, + 1, + 2, + 3, + 1, + 0, + 3.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Brow001", + "Segments": [ + 0, + 1, + 2, + 3, + 1, + 0, + 3.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Tear", + "Segments": [ + 0, + 1, + 2, + 3, + 1, + 0, + 3.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01EyeBall001", + "Segments": [ + 0, + 1, + 2, + 3, + 1, + 0, + 3.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Eye001", + "Segments": [ + 0, + 1, + 2, + 3, + 1, + 0, + 3.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Nose001", + "Segments": [ + 0, + 1, + 2, + 3, + 1, + 0, + 3.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Mouth001", + "Segments": [ + 0, + 1, + 2, + 3, + 1, + 0, + 3.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Face001", + "Segments": [ + 0, + 1, + 2, + 3, + 1, + 0, + 3.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Ear001", + "Segments": [ + 0, + 1, + 2, + 3, + 1, + 0, + 3.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Neck001", + "Segments": [ + 0, + 1, + 2, + 3, + 1, + 0, + 3.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01HairFront001", + "Segments": [ + 0, + 1, + 2, + 3, + 1, + 0, + 3.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01HairSide001", + "Segments": [ + 0, + 1, + 2, + 3, + 1, + 0, + 3.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01HairBack001", + "Segments": [ + 0, + 1, + 2, + 3, + 1, + 0, + 3.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01ArmRB001", + "Segments": [ + 0, + 0, + 2, + 3, + 0, + 0, + 3.03, + 0 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01ArmLB001", + "Segments": [ + 0, + 0, + 2, + 3, + 0, + 0, + 3.03, + 0 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01ArmRA001", + "Segments": [ + 0, + 1, + 2, + 3, + 1, + 0, + 3.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01ArmLA001", + "Segments": [ + 0, + 1, + 2, + 3, + 1, + 0, + 3.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Body001", + "Segments": [ + 0, + 1, + 2, + 3, + 1, + 0, + 3.03, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Sketch", + "Segments": [ + 0, + 0, + 2, + 3, + 0, + 0, + 3.03, + 0 + ] + } + ] +} \ No newline at end of file diff --git a/apps/web/public/live2d/haru/motion/haru_g_m15.motion3.json b/apps/web/public/live2d/haru/motion/haru_g_m15.motion3.json new file mode 100644 index 0000000..c774ddf --- /dev/null +++ b/apps/web/public/live2d/haru/motion/haru_g_m15.motion3.json @@ -0,0 +1,1357 @@ +{ + "Version": 3, + "Meta": { + "Duration": 5.33, + "Fps": 30.0, + "Loop": true, + "AreBeziersRestricted": true, + "CurveCount": 63, + "TotalSegmentCount": 167, + "TotalPointCount": 398, + "UserDataCount": 0, + "TotalUserDataSize": 0 + }, + "Curves": [ + { + "Target": "Model", + "Id": "Opacity", + "Segments": [ + 0, + 1, + 1, + 1.77, + 1, + 3.53, + 1, + 5.3, + 1, + 0, + 5.33, + 1 + ] + }, + { + "Target": "Parameter", + "Id": "ParamAngleX", + "Segments": [ + 0, + 0, + 1, + 0.2, + 0, + 0.4, + -3, + 0.6, + -3, + 1, + 1.333, + -3, + 2.067, + -3, + 2.8, + -3, + 1, + 3.056, + -3, + 3.311, + 1, + 3.567, + 1, + 1, + 3.833, + 1, + 4.1, + 0, + 4.367, + 0, + 1, + 4.678, + 0, + 4.989, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamAngleY", + "Segments": [ + 0, + 0, + 1, + 0.2, + 0, + 0.4, + -10, + 0.6, + -10, + 1, + 1.333, + -10, + 2.067, + -10, + 2.8, + -10, + 1, + 3.056, + -10, + 3.311, + 11, + 3.567, + 11, + 1, + 3.833, + 11, + 4.1, + 0, + 4.367, + 0, + 1, + 4.678, + 0, + 4.989, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamAngleZ", + "Segments": [ + 0, + 0, + 1, + 0.211, + 0, + 0.422, + -30, + 0.633, + -30, + 1, + 0.689, + -30, + 0.744, + -30, + 0.8, + -30, + 1, + 1.1, + -30, + 1.4, + 30, + 1.7, + 30, + 1, + 1.744, + 30, + 1.789, + 30, + 1.833, + 30, + 1, + 2.167, + 30, + 2.5, + -30, + 2.833, + -30, + 1, + 2.878, + -30, + 2.922, + -30, + 2.967, + -30, + 1, + 3.222, + -30, + 3.478, + 0, + 3.733, + 0, + 1, + 4.256, + 0, + 4.778, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamTere", + "Segments": [ + 0, + 0, + 1, + 1.767, + 0, + 3.533, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamFaceForm", + "Segments": [ + 0, + 0, + 1, + 1.767, + 0, + 3.533, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeLOpen", + "Segments": [ + 0, + 1, + 1, + 1.767, + 1, + 3.533, + 1, + 5.3, + 1, + 0, + 5.333, + 1 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeLSmile", + "Segments": [ + 0, + 0, + 1, + 1.767, + 0, + 3.533, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeROpen", + "Segments": [ + 0, + 1, + 1, + 1.767, + 1, + 3.533, + 1, + 5.3, + 1, + 0, + 5.333, + 1 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeRSmile", + "Segments": [ + 0, + 0, + 1, + 1.767, + 0, + 3.533, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeForm", + "Segments": [ + 0, + 0, + 1, + 1.767, + 0, + 3.533, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeBallForm", + "Segments": [ + 0, + 0, + 1, + 1.767, + 0, + 3.533, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamTear", + "Segments": [ + 0, + 0, + 1, + 1.767, + 0, + 3.533, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeBallX", + "Segments": [ + 0, + 0, + 1, + 0.2, + 0, + 0.4, + 0.03, + 0.6, + 0.03, + 1, + 0.989, + 0.03, + 1.378, + 0.03, + 1.767, + 0.03, + 1, + 2.144, + 0.03, + 2.522, + 0.03, + 2.9, + 0.03, + 1, + 3.1, + 0.03, + 3.3, + -0.1, + 3.5, + -0.1, + 1, + 3.767, + -0.1, + 4.033, + 0, + 4.3, + 0, + 1, + 4.633, + 0, + 4.967, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamEyeBallY", + "Segments": [ + 0, + 0, + 1, + 0.2, + 0, + 0.4, + 0.67, + 0.6, + 0.67, + 1, + 0.989, + 0.67, + 1.378, + 0.67, + 1.767, + 0.67, + 1, + 2.144, + 0.67, + 2.522, + 0.67, + 2.9, + 0.67, + 1, + 3.1, + 0.67, + 3.3, + -0.21, + 3.5, + -0.21, + 1, + 3.767, + -0.21, + 4.033, + 0, + 4.3, + 0, + 1, + 4.633, + 0, + 4.967, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowLY", + "Segments": [ + 0, + 0, + 1, + 1.767, + 0, + 3.533, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowRY", + "Segments": [ + 0, + 0, + 1, + 1.767, + 0, + 3.533, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowLX", + "Segments": [ + 0, + 0, + 1, + 1.767, + 0, + 3.533, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowRX", + "Segments": [ + 0, + 0, + 1, + 1.767, + 0, + 3.533, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowLAngle", + "Segments": [ + 0, + 0, + 1, + 1.767, + 0, + 3.533, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowRAngle", + "Segments": [ + 0, + 0, + 1, + 1.767, + 0, + 3.533, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowLForm", + "Segments": [ + 0, + 0, + 1, + 1.767, + 0, + 3.533, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBrowRForm", + "Segments": [ + 0, + 0, + 1, + 1.767, + 0, + 3.533, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamMouthForm", + "Segments": [ + 0, + 1, + 1, + 1.767, + 1, + 3.533, + 1, + 5.3, + 1, + 0, + 5.333, + 1 + ] + }, + { + "Target": "Parameter", + "Id": "ParamMouthOpenY", + "Segments": [ + 0, + 0, + 1, + 1.767, + 0, + 3.533, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamScarf", + "Segments": [ + 0, + 0, + 1, + 1.767, + 0, + 3.533, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBodyAngleX", + "Segments": [ + 0, + 0, + 1, + 0.322, + 0, + 0.644, + 6, + 0.967, + 6, + 1, + 1.322, + 6, + 1.678, + -2, + 2.033, + -2, + 1, + 2.344, + -2, + 2.656, + 0, + 2.967, + 0, + 1, + 3.178, + 0, + 3.389, + -3, + 3.6, + -3, + 1, + 3.833, + -3, + 4.067, + 0, + 4.3, + 0, + 1, + 4.633, + 0, + 4.967, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBodyAngleY", + "Segments": [ + 0, + 0, + 1, + 0.967, + 0, + 1.933, + 0, + 2.9, + 0, + 1, + 3.111, + 0, + 3.322, + 2, + 3.533, + 2, + 1, + 3.767, + 2, + 4, + 0, + 4.233, + 0, + 1, + 4.589, + 0, + 4.944, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBodyAngleZ", + "Segments": [ + 0, + 0, + 1, + 1.767, + 0, + 3.533, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBodyUpper", + "Segments": [ + 0, + 0, + 1, + 1.767, + 0, + 3.533, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBreath", + "Segments": [ + 0, + 0, + 1, + 1.767, + 0, + 3.533, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamBustY", + "Segments": [ + 0, + 0, + 1, + 1.767, + 0, + 3.533, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamArmLA", + "Segments": [ + 0, + 0.5, + 1, + 0.156, + 0.5, + 0.311, + 0.75, + 0.467, + 0.76, + 1, + 1.978, + 0.855, + 3.489, + 0.89, + 5, + 0.89, + 1, + 5.1, + 0.89, + 5.2, + 0.5, + 5.3, + 0.5, + 0, + 5.333, + 0.5 + ] + }, + { + "Target": "Parameter", + "Id": "ParamArmRA", + "Segments": [ + 0, + 0.5, + 1, + 0.156, + 0.5, + 0.311, + 0.746, + 0.467, + 0.76, + 1, + 1.978, + 0.899, + 3.489, + 0.95, + 5, + 0.95, + 1, + 5.1, + 0.95, + 5.2, + 0.5, + 5.3, + 0.5, + 0, + 5.333, + 0.5 + ] + }, + { + "Target": "Parameter", + "Id": "ParamArmLB", + "Segments": [ + 0, + 0, + 1, + 1.767, + 0, + 3.533, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamArmRB", + "Segments": [ + 0, + 0, + 1, + 1.767, + 0, + 3.533, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHandChangeR", + "Segments": [ + 0, + 0, + 1, + 1.767, + 0, + 3.533, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHandAngleR", + "Segments": [ + 0, + 0.24, + 1, + 0.356, + 0.24, + 0.711, + 0, + 1.067, + 0, + 1, + 1.389, + 0, + 1.711, + 0.24, + 2.033, + 0.24, + 1, + 3.122, + 0.24, + 4.211, + 0.24, + 5.3, + 0.24, + 0, + 5.333, + 0.24 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHandDhangeL", + "Segments": [ + 0, + 0, + 1, + 1.767, + 0, + 3.533, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHandAngleL", + "Segments": [ + 0, + -0.24, + 1, + 0.356, + -0.24, + 0.711, + 0, + 1.067, + 0, + 1, + 1.389, + 0, + 1.711, + -0.24, + 2.033, + -0.24, + 1, + 3.122, + -0.24, + 4.211, + -0.24, + 5.3, + -0.24, + 0, + 5.333, + -0.24 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHairFront", + "Segments": [ + 0, + 0, + 1, + 1.767, + 0, + 3.533, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHairSide", + "Segments": [ + 0, + 0, + 1, + 1.767, + 0, + 3.533, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "Parameter", + "Id": "ParamHairBack", + "Segments": [ + 0, + 0, + 1, + 1.767, + 0, + 3.533, + 0, + 5.3, + 0, + 0, + 5.333, + 0 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Core", + "Segments": [ + 0, + 1, + 2, + 5.3, + 1, + 0, + 5.33, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Hoho001", + "Segments": [ + 0, + 1, + 2, + 5.3, + 1, + 0, + 5.33, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Brow001", + "Segments": [ + 0, + 1, + 2, + 5.3, + 1, + 0, + 5.33, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Tear", + "Segments": [ + 0, + 1, + 2, + 5.3, + 1, + 0, + 5.33, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01EyeBall001", + "Segments": [ + 0, + 1, + 2, + 5.3, + 1, + 0, + 5.33, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Eye001", + "Segments": [ + 0, + 1, + 2, + 5.3, + 1, + 0, + 5.33, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Nose001", + "Segments": [ + 0, + 1, + 2, + 5.3, + 1, + 0, + 5.33, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Mouth001", + "Segments": [ + 0, + 1, + 2, + 5.3, + 1, + 0, + 5.33, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Face001", + "Segments": [ + 0, + 1, + 2, + 5.3, + 1, + 0, + 5.33, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Ear001", + "Segments": [ + 0, + 1, + 2, + 5.3, + 1, + 0, + 5.33, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Neck001", + "Segments": [ + 0, + 1, + 2, + 5.3, + 1, + 0, + 5.33, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01HairFront001", + "Segments": [ + 0, + 1, + 2, + 5.3, + 1, + 0, + 5.33, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01HairSide001", + "Segments": [ + 0, + 1, + 2, + 5.3, + 1, + 0, + 5.33, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01HairBack001", + "Segments": [ + 0, + 1, + 2, + 5.3, + 1, + 0, + 5.33, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01ArmRB001", + "Segments": [ + 0, + 1, + 2, + 5.3, + 1, + 0, + 5.33, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01ArmLB001", + "Segments": [ + 0, + 1, + 2, + 5.3, + 1, + 0, + 5.33, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01ArmRA001", + "Segments": [ + 0, + 0, + 2, + 5.3, + 0, + 0, + 5.33, + 0 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01ArmLA001", + "Segments": [ + 0, + 0, + 2, + 5.3, + 0, + 0, + 5.33, + 0 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Body001", + "Segments": [ + 0, + 1, + 2, + 5.3, + 1, + 0, + 5.33, + 1 + ] + }, + { + "Target": "PartOpacity", + "Id": "Part01Sketch", + "Segments": [ + 0, + 0, + 2, + 5.3, + 0, + 0, + 5.33, + 0 + ] + } + ] +} \ No newline at end of file diff --git a/apps/web/public/live2d/live2dcubismcore.min.js b/apps/web/public/live2d/live2dcubismcore.min.js new file mode 100644 index 0000000..d225300 --- /dev/null +++ b/apps/web/public/live2d/live2dcubismcore.min.js @@ -0,0 +1,9 @@ +/** + * Live2D Cubism Core + * (C) 2019 Live2D Inc. All rights reserved. + * + * This file is licensed pursuant to the license agreement below. + * This file corresponds to the "Redistributable Code" in the agreement. + * https://www.live2d.com/eula/live2d-proprietary-software-license-agreement_en.html + */ +var Live2DCubismCore;!function(Live2DCubismCore){var _scriptDir,_csm=function(){function _csm(){}return _csm.getVersion=function(){return _em.ccall("csmGetVersion","number",[],[])},_csm.getLatestMocVersion=function(){return _em.ccall("csmGetLatestMocVersion","number",[],[])},_csm.getMocVersion=function(moc,mocSize){return _em.ccall("csmGetMocVersion","number",["number","number"],[moc,mocSize])},_csm.getLogFunction=function(){return _em.ccall("csmGetLogFunction","number",[],[])},_csm.getSizeofModel=function(moc){return _em.ccall("csmGetSizeofModel","number",["number"],[moc])},_csm.reviveMocInPlace=function(memory,mocSize){return _em.ccall("csmReviveMocInPlace","number",["number","number"],[memory,mocSize])},_csm.initializeModelInPlace=function(moc,memory,modelSize){return _em.ccall("csmInitializeModelInPlace","number",["number","number","number"],[moc,memory,modelSize])},_csm.hasMocConsistency=function(memory,mocSize){return _em.ccall("csmHasMocConsistency","number",["number","number"],[memory,mocSize])},_csm.getParameterCount=function(model){return _em.ccall("csmGetParameterCount","number",["number"],[model])},_csm.getParameterIds=function(model){return _em.ccall("csmGetParameterIds","number",["number"],[model])},_csm.getParameterMinimumValues=function(model){return _em.ccall("csmGetParameterMinimumValues","number",["number"],[model])},_csm.getParameterTypes=function(model){return _em.ccall("csmGetParameterTypes","number",["number"],[model])},_csm.getParameterMaximumValues=function(model){return _em.ccall("csmGetParameterMaximumValues","number",["number"],[model])},_csm.getParameterDefaultValues=function(model){return _em.ccall("csmGetParameterDefaultValues","number",["number"],[model])},_csm.getParameterValues=function(model){return _em.ccall("csmGetParameterValues","number",["number"],[model])},_csm.getParameterRepeats=function(model){return _em.ccall("csmGetParameterRepeats","number",["number"],[model])},_csm.getParameterKeyCounts=function(model){return _em.ccall("csmGetParameterKeyCounts","number",["number"],[model])},_csm.getParameterKeyValues=function(model){return _em.ccall("csmGetParameterKeyValues","number",["number"],[model])},_csm.getPartCount=function(model){return _em.ccall("csmGetPartCount","number",["number"],[model])},_csm.getPartIds=function(model){return _em.ccall("csmGetPartIds","number",["number"],[model])},_csm.getPartOpacities=function(model){return _em.ccall("csmGetPartOpacities","number",["number"],[model])},_csm.getPartParentPartIndices=function(model){return _em.ccall("csmGetPartParentPartIndices","number",["number"],[model])},_csm.getDrawableCount=function(model){return _em.ccall("csmGetDrawableCount","number",["number"],[model])},_csm.getDrawableIds=function(model){return _em.ccall("csmGetDrawableIds","number",["number"],[model])},_csm.getDrawableConstantFlags=function(model){return _em.ccall("csmGetDrawableConstantFlags","number",["number"],[model])},_csm.getDrawableDynamicFlags=function(model){return _em.ccall("csmGetDrawableDynamicFlags","number",["number"],[model])},_csm.getDrawableTextureIndices=function(model){return _em.ccall("csmGetDrawableTextureIndices","number",["number"],[model])},_csm.getDrawableDrawOrders=function(model){return _em.ccall("csmGetDrawableDrawOrders","number",["number"],[model])},_csm.getDrawableRenderOrders=function(model){return _em.ccall("csmGetDrawableRenderOrders","number",["number"],[model])},_csm.getDrawableOpacities=function(model){return _em.ccall("csmGetDrawableOpacities","number",["number"],[model])},_csm.getDrawableMaskCounts=function(model){return _em.ccall("csmGetDrawableMaskCounts","number",["number"],[model])},_csm.getDrawableMasks=function(model){return _em.ccall("csmGetDrawableMasks","number",["number"],[model])},_csm.getDrawableVertexCounts=function(model){return _em.ccall("csmGetDrawableVertexCounts","number",["number"],[model])},_csm.getDrawableVertexPositions=function(model){return _em.ccall("csmGetDrawableVertexPositions","number",["number"],[model])},_csm.getDrawableVertexUvs=function(model){return _em.ccall("csmGetDrawableVertexUvs","number",["number"],[model])},_csm.getDrawableIndexCounts=function(model){return _em.ccall("csmGetDrawableIndexCounts","number",["number"],[model])},_csm.getDrawableIndices=function(model){return _em.ccall("csmGetDrawableIndices","number",["number"],[model])},_csm.getDrawableMultiplyColors=function(model){return _em.ccall("csmGetDrawableMultiplyColors","number",["number"],[model])},_csm.getDrawableScreenColors=function(model){return _em.ccall("csmGetDrawableScreenColors","number",["number"],[model])},_csm.getDrawableParentPartIndices=function(model){return _em.ccall("csmGetDrawableParentPartIndices","number",["number"],[model])},_csm.mallocMoc=function(mocSize){return _em.ccall("csmMallocMoc","number",["number"],[mocSize])},_csm.mallocModelAndInitialize=function(moc){return _em.ccall("csmMallocModelAndInitialize","number",["number"],[moc])},_csm.malloc=function(size){return _em.ccall("csmMalloc","number",["number"],[size])},_csm.setLogFunction=function(handler){_em.ccall("csmSetLogFunction",null,["number"],[handler])},_csm.updateModel=function(model){_em.ccall("csmUpdateModel",null,["number"],[model])},_csm.readCanvasInfo=function(model,outSizeInPixels,outOriginInPixels,outPixelsPerUnit){_em.ccall("csmReadCanvasInfo",null,["number","number","number","number"],[model,outSizeInPixels,outOriginInPixels,outPixelsPerUnit])},_csm.resetDrawableDynamicFlags=function(model){_em.ccall("csmResetDrawableDynamicFlags",null,["number"],[model])},_csm.free=function(memory){_em.ccall("csmFree",null,["number"],[memory])},_csm.initializeAmountOfMemory=function(size){_em.ccall("csmInitializeAmountOfMemory",null,["number"],[size])},_csm}(),Version=(Live2DCubismCore.AlignofMoc=64,Live2DCubismCore.AlignofModel=16,Live2DCubismCore.MocVersion_Unknown=0,Live2DCubismCore.MocVersion_30=1,Live2DCubismCore.MocVersion_33=2,Live2DCubismCore.MocVersion_40=3,Live2DCubismCore.MocVersion_42=4,Live2DCubismCore.MocVersion_50=5,Live2DCubismCore.ParameterType_Normal=0,Live2DCubismCore.ParameterType_BlendShape=1,function(){function Version(){}return Version.csmGetVersion=function(){return _csm.getVersion()},Version.csmGetLatestMocVersion=function(){return _csm.getLatestMocVersion()},Version.csmGetMocVersion=function(moc,mocBytes){return _csm.getMocVersion(moc._ptr,mocBytes.byteLength)},Version}()),Version=(Live2DCubismCore.Version=Version,function(){function Logging(){}return Logging.csmSetLogFunction=function(handler){Logging.logFunction=handler;handler=_em.addFunction(Logging.wrapLogFunction,"vi");_csm.setLogFunction(handler)},Logging.csmGetLogFunction=function(){return Logging.logFunction},Logging.wrapLogFunction=function(messagePtr){messagePtr=_em.UTF8ToString(messagePtr);Logging.logFunction(messagePtr)},Logging}()),Version=(Live2DCubismCore.Logging=Version,function(){function Moc(mocBytes){var memory=_csm.mallocMoc(mocBytes.byteLength);memory&&(new Uint8Array(_em.HEAPU8.buffer,memory,mocBytes.byteLength).set(new Uint8Array(mocBytes)),this._ptr=_csm.reviveMocInPlace(memory,mocBytes.byteLength),this._ptr||_csm.free(memory))}return Moc.prototype.hasMocConsistency=function(mocBytes){var memory=_csm.mallocMoc(mocBytes.byteLength);if(memory)return new Uint8Array(_em.HEAPU8.buffer,memory,mocBytes.byteLength).set(new Uint8Array(mocBytes)),mocBytes=_csm.hasMocConsistency(memory,mocBytes.byteLength),_csm.free(memory),mocBytes},Moc.fromArrayBuffer=function(buffer){return buffer&&(buffer=new Moc(buffer))._ptr?buffer:null},Moc.prototype._release=function(){_csm.free(this._ptr),this._ptr=0},Moc}()),Version=(Live2DCubismCore.Moc=Version,function(){function Model(moc){this._ptr=_csm.mallocModelAndInitialize(moc._ptr),this._ptr&&(this.parameters=new Parameters(this._ptr),this.parts=new Parts(this._ptr),this.drawables=new Drawables(this._ptr),this.canvasinfo=new CanvasInfo(this._ptr))}return Model.fromMoc=function(moc){moc=new Model(moc);return moc._ptr?moc:null},Model.prototype.update=function(){_csm.updateModel(this._ptr)},Model.prototype.release=function(){_csm.free(this._ptr),this._ptr=0},Model}()),CanvasInfo=(Live2DCubismCore.Model=Version,function(modelPtr){var _canvasSize_data,_canvasSize_dataPtr,_canvasSize_nDataBytes,_canvasOrigin_dataPtr,_canvasOrigin_nDataBytes,_canvasPPU_nDataBytes,_canvasPPU_dataPtr;modelPtr&&(_canvasSize_nDataBytes=(_canvasSize_data=new Float32Array(2)).length*_canvasSize_data.BYTES_PER_ELEMENT,_canvasSize_dataPtr=_csm.malloc(_canvasSize_nDataBytes),(_canvasSize_dataPtr=new Uint8Array(_em.HEAPU8.buffer,_canvasSize_dataPtr,_canvasSize_nDataBytes)).set(new Uint8Array(_canvasSize_data.buffer)),_canvasOrigin_nDataBytes=(_canvasSize_nDataBytes=new Float32Array(2)).length*_canvasSize_nDataBytes.BYTES_PER_ELEMENT,_canvasOrigin_dataPtr=_csm.malloc(_canvasOrigin_nDataBytes),(_canvasOrigin_dataPtr=new Uint8Array(_em.HEAPU8.buffer,_canvasOrigin_dataPtr,_canvasOrigin_nDataBytes)).set(new Uint8Array(_canvasSize_nDataBytes.buffer)),_canvasPPU_nDataBytes=(_canvasOrigin_nDataBytes=new Float32Array(1)).length*_canvasOrigin_nDataBytes.BYTES_PER_ELEMENT,_canvasPPU_dataPtr=_csm.malloc(_canvasPPU_nDataBytes),(_canvasPPU_dataPtr=new Uint8Array(_em.HEAPU8.buffer,_canvasPPU_dataPtr,_canvasPPU_nDataBytes)).set(new Uint8Array(_canvasOrigin_nDataBytes.buffer)),_csm.readCanvasInfo(modelPtr,_canvasSize_dataPtr.byteOffset,_canvasOrigin_dataPtr.byteOffset,_canvasPPU_dataPtr.byteOffset),_canvasSize_data=new Float32Array(_canvasSize_dataPtr.buffer,_canvasSize_dataPtr.byteOffset,_canvasSize_dataPtr.length),_canvasSize_nDataBytes=new Float32Array(_canvasOrigin_dataPtr.buffer,_canvasOrigin_dataPtr.byteOffset,_canvasOrigin_dataPtr.length),_canvasOrigin_nDataBytes=new Float32Array(_canvasPPU_dataPtr.buffer,_canvasPPU_dataPtr.byteOffset,_canvasPPU_dataPtr.length),this.CanvasWidth=_canvasSize_data[0],this.CanvasHeight=_canvasSize_data[1],this.CanvasOriginX=_canvasSize_nDataBytes[0],this.CanvasOriginY=_canvasSize_nDataBytes[1],this.PixelsPerUnit=_canvasOrigin_nDataBytes[0],_csm.free(_canvasSize_dataPtr.byteOffset),_csm.free(_canvasOrigin_dataPtr.byteOffset),_csm.free(_canvasPPU_dataPtr.byteOffset))}),Parameters=(Live2DCubismCore.CanvasInfo=CanvasInfo,function(modelPtr){this.count=_csm.getParameterCount(modelPtr),length=_csm.getParameterCount(modelPtr),this.ids=new Array(length);for(var length,length2,_ids=new Uint32Array(_em.HEAPU32.buffer,_csm.getParameterIds(modelPtr),length),i=0;i<_ids.length;i++)this.ids[i]=_em.UTF8ToString(_ids[i]);length=_csm.getParameterCount(modelPtr),this.minimumValues=new Float32Array(_em.HEAPF32.buffer,_csm.getParameterMinimumValues(modelPtr),length),length=_csm.getParameterCount(modelPtr),this.types=new Int32Array(_em.HEAP32.buffer,_csm.getParameterTypes(modelPtr),length),length=_csm.getParameterCount(modelPtr),this.maximumValues=new Float32Array(_em.HEAPF32.buffer,_csm.getParameterMaximumValues(modelPtr),length),length=_csm.getParameterCount(modelPtr),this.defaultValues=new Float32Array(_em.HEAPF32.buffer,_csm.getParameterDefaultValues(modelPtr),length),length=_csm.getParameterCount(modelPtr),this.values=new Float32Array(_em.HEAPF32.buffer,_csm.getParameterValues(modelPtr),length),length=_csm.getParameterCount(modelPtr),this.repeats=new Int32Array(_em.HEAP32.buffer,_csm.getParameterRepeats(modelPtr),length),length=_csm.getParameterCount(modelPtr),this.keyCounts=new Int32Array(_em.HEAP32.buffer,_csm.getParameterKeyCounts(modelPtr),length),length=_csm.getParameterCount(modelPtr),length2=new Int32Array(_em.HEAP32.buffer,_csm.getParameterKeyCounts(modelPtr),length),this.keyValues=new Array(length);for(var _keyValues=new Uint32Array(_em.HEAPU32.buffer,_csm.getParameterKeyValues(modelPtr),length),i=0;i<_keyValues.length;i++)this.keyValues[i]=new Float32Array(_em.HEAPF32.buffer,_keyValues[i],length2[i])}),Parts=(Live2DCubismCore.Parameters=Parameters,function(modelPtr){this.count=_csm.getPartCount(modelPtr),length=_csm.getPartCount(modelPtr),this.ids=new Array(length);for(var length,_ids=new Uint32Array(_em.HEAPU32.buffer,_csm.getPartIds(modelPtr),length),i=0;i<_ids.length;i++)this.ids[i]=_em.UTF8ToString(_ids[i]);length=_csm.getPartCount(modelPtr),this.opacities=new Float32Array(_em.HEAPF32.buffer,_csm.getPartOpacities(modelPtr),length),length=_csm.getPartCount(modelPtr),this.parentIndices=new Int32Array(_em.HEAP32.buffer,_csm.getPartParentPartIndices(modelPtr),length)}),Drawables=(Live2DCubismCore.Parts=Parts,function(){function Drawables(modelPtr){this._modelPtr=modelPtr;for(var length,length2=null,_ids=(this.count=_csm.getDrawableCount(modelPtr),length=_csm.getDrawableCount(modelPtr),this.ids=new Array(length),new Uint32Array(_em.HEAPU32.buffer,_csm.getDrawableIds(modelPtr),length)),i=0;i<_ids.length;i++)this.ids[i]=_em.UTF8ToString(_ids[i]);length=_csm.getDrawableCount(modelPtr),this.constantFlags=new Uint8Array(_em.HEAPU8.buffer,_csm.getDrawableConstantFlags(modelPtr),length),length=_csm.getDrawableCount(modelPtr),this.dynamicFlags=new Uint8Array(_em.HEAPU8.buffer,_csm.getDrawableDynamicFlags(modelPtr),length),length=_csm.getDrawableCount(modelPtr),this.textureIndices=new Int32Array(_em.HEAP32.buffer,_csm.getDrawableTextureIndices(modelPtr),length),length=_csm.getDrawableCount(modelPtr),this.drawOrders=new Int32Array(_em.HEAP32.buffer,_csm.getDrawableDrawOrders(modelPtr),length),length=_csm.getDrawableCount(modelPtr),this.renderOrders=new Int32Array(_em.HEAP32.buffer,_csm.getDrawableRenderOrders(modelPtr),length),length=_csm.getDrawableCount(modelPtr),this.opacities=new Float32Array(_em.HEAPF32.buffer,_csm.getDrawableOpacities(modelPtr),length),length=_csm.getDrawableCount(modelPtr),this.maskCounts=new Int32Array(_em.HEAP32.buffer,_csm.getDrawableMaskCounts(modelPtr),length),length=_csm.getDrawableCount(modelPtr),this.vertexCounts=new Int32Array(_em.HEAP32.buffer,_csm.getDrawableVertexCounts(modelPtr),length),length=_csm.getDrawableCount(modelPtr),this.indexCounts=new Int32Array(_em.HEAP32.buffer,_csm.getDrawableIndexCounts(modelPtr),length),length=_csm.getDrawableCount(modelPtr),this.multiplyColors=new Float32Array(_em.HEAPF32.buffer,_csm.getDrawableMultiplyColors(modelPtr),4*length),length=_csm.getDrawableCount(modelPtr),this.screenColors=new Float32Array(_em.HEAPF32.buffer,_csm.getDrawableScreenColors(modelPtr),4*length),length=_csm.getDrawableCount(modelPtr),this.parentPartIndices=new Int32Array(_em.HEAP32.buffer,_csm.getDrawableParentPartIndices(modelPtr),length),length=_csm.getDrawableCount(modelPtr),length2=new Int32Array(_em.HEAP32.buffer,_csm.getDrawableMaskCounts(modelPtr),length),this.masks=new Array(length);for(var _masks=new Uint32Array(_em.HEAPU32.buffer,_csm.getDrawableMasks(modelPtr),length),i=0;i<_masks.length;i++)this.masks[i]=new Int32Array(_em.HEAP32.buffer,_masks[i],length2[i]);length=_csm.getDrawableCount(modelPtr),length2=new Int32Array(_em.HEAP32.buffer,_csm.getDrawableVertexCounts(modelPtr),length),this.vertexPositions=new Array(length);for(var _vertexPositions=new Uint32Array(_em.HEAPU32.buffer,_csm.getDrawableVertexPositions(modelPtr),length),i=0;i<_vertexPositions.length;i++)this.vertexPositions[i]=new Float32Array(_em.HEAPF32.buffer,_vertexPositions[i],2*length2[i]);length=_csm.getDrawableCount(modelPtr),length2=new Int32Array(_em.HEAP32.buffer,_csm.getDrawableVertexCounts(modelPtr),length),this.vertexUvs=new Array(length);for(var _vertexUvs=new Uint32Array(_em.HEAPU32.buffer,_csm.getDrawableVertexUvs(modelPtr),length),i=0;i<_vertexUvs.length;i++)this.vertexUvs[i]=new Float32Array(_em.HEAPF32.buffer,_vertexUvs[i],2*length2[i]);length=_csm.getDrawableCount(modelPtr),length2=new Int32Array(_em.HEAP32.buffer,_csm.getDrawableIndexCounts(modelPtr),length),this.indices=new Array(length);for(var _indices=new Uint32Array(_em.HEAPU32.buffer,_csm.getDrawableIndices(modelPtr),length),i=0;i<_indices.length;i++)this.indices[i]=new Uint16Array(_em.HEAPU16.buffer,_indices[i],length2[i])}return Drawables.prototype.resetDynamicFlags=function(){_csm.resetDrawableDynamicFlags(this._modelPtr)},Drawables}()),Version=(Live2DCubismCore.Drawables=Drawables,function(){function Utils(){}return Utils.hasBlendAdditiveBit=function(bitfield){return 1==(1&bitfield)},Utils.hasBlendMultiplicativeBit=function(bitfield){return 2==(2&bitfield)},Utils.hasIsDoubleSidedBit=function(bitfield){return 4==(4&bitfield)},Utils.hasIsInvertedMaskBit=function(bitfield){return 8==(8&bitfield)},Utils.hasIsVisibleBit=function(bitfield){return 1==(1&bitfield)},Utils.hasVisibilityDidChangeBit=function(bitfield){return 2==(2&bitfield)},Utils.hasOpacityDidChangeBit=function(bitfield){return 4==(4&bitfield)},Utils.hasDrawOrderDidChangeBit=function(bitfield){return 8==(8&bitfield)},Utils.hasRenderOrderDidChangeBit=function(bitfield){return 16==(16&bitfield)},Utils.hasVertexPositionsDidChangeBit=function(bitfield){return 32==(32&bitfield)},Utils.hasBlendColorDidChangeBit=function(bitfield){return 64==(64&bitfield)},Utils}()),Version=(Live2DCubismCore.Utils=Version,function(){function Memory(){}return Memory.initializeAmountOfMemory=function(size){16777216>2]+(Mn<<5)|0)>>2],Qn=q[a+60>>2]+w(Xn,24)|0,Mn=(lo=q[Qn+8>>2])+-1|0,yo=(ro=q[Qn+4>>2])+-1|0,zo=vo=(Xn=q[q[a+152>>2]+(Xn<<2)>>2])+(lo<<3)|0,Ao=wo=Xn+((uo=w(ro,mo=lo+1|0))<<3)|0,Bo=xo=Xn+(lo+uo<<3)|0,Jo=q[Qn+12>>2],so=x(0|ro),to=x(0|lo),a=0;Wn=u[4+(Qn=(po=a<<3)+Nn|0)>>2],Sn=x(Wn*so),Yn=u[Qn>>2],Rn=x(Yn*to),Qn=Wn>=x(1),Sn=!(Wn=x(1)|Yn>2],Co=u[Xn+4>>2],Zn=x(bo-Co),Do=u[4+zo>>2],Eo=u[4+Ao>>2],_n=x(Do-Eo),co=x(x(Zn-_n)*x(.5)),Fo=u[xo>>2],Go=u[Xn>>2],$n=x(Fo-Go),Ho=u[vo>>2],Io=u[wo>>2],ao=x(Ho-Io),eo=x(x($n-ao)*x(.5)),_n=x(x(_n+Zn)*x(.5)),ao=x(x(ao+$n)*x(.5)),Ko=1,Zn=x(x(x(x(x(Co+Do)+Eo)+bo)*x(.25))-x(Zn*x(.5))),$n=x(x(x(x(x(Go+Ho)+Io)+Fo)*x(.25))-x($n*x(.5)))),Wnx(-2)^1|(Ynx(-2)^1)?(u[On+po>>2]=x(Wn*eo)+x(x(Yn*ao)+$n),Rn=x(Wn*co),x(x(Yn*_n)+Zn)):(Yn<=x(0)?Wn<=x(0)?(Vn=x(x(Wn+x(2))*x(.5)),Un=x(x(Yn+x(2))*x(.5)),Rn=x(co+co),no=x(Zn-Rn),Sn=x(eo+eo),oo=x($n-Sn),jo=x(Zn-x(_n+_n)),fo=x(jo-Rn),ko=x($n-x(ao+ao)),go=x(ko-Sn),ho=u[Xn+4>>2],io=u[Xn>>2]):Qn?(Rn=x(co*x(3)),Sn=x(Zn-x(_n+_n)),jo=x(Rn+Sn),fo=x(eo*x(3)),go=x($n-x(ao+ao)),ko=x(fo+go),Vn=x(x(Wn+x(-1))*x(.5)),Un=x(x(Yn+x(2))*x(.5)),ho=x(Rn+Zn),io=x(fo+$n),fo=x(co+Sn),go=x(eo+go),no=u[4+Ao>>2],oo=u[wo>>2]):(Rn=x(Zn-x(_n+_n)),Qn=yo,Tn=x(y(Sn))>2],oo=u[Qn>>2],Qn=Xn+(w(Tn,mo)<<3)|0,ho=u[Qn+4>>2],io=u[Qn>>2]):Yn>=x(1)?Wn<=x(0)?(Vn=x(x(Wn+x(2))*x(.5)),Un=x(x(Yn+x(-1))*x(.5)),Rn=x(co+co),fo=x(x(_n+Zn)-Rn),Sn=x(eo+eo),go=x(x(ao+$n)-Sn),ho=x(x(_n*x(3))+Zn),no=x(ho-Rn),io=x(x(ao*x(3))+$n),oo=x(io-Sn),jo=u[4+zo>>2],ko=u[vo>>2]):Qn?(Rn=x(co*x(3)),jo=x(Rn+x(_n+Zn)),Sn=x(eo*x(3)),ko=x(Sn+x(ao+$n)),bo=Rn,Rn=x(x(_n*x(3))+Zn),ho=x(bo+Rn),bo=Sn,Sn=x(x(ao*x(3))+$n),io=x(bo+Sn),Vn=x(x(Wn+x(-1))*x(.5)),Un=x(x(Yn+x(-1))*x(.5)),no=x(co+Rn),oo=x(eo+Sn),fo=u[4+Bo>>2],go=u[xo>>2]):(Rn=x(x(_n*x(3))+Zn),Qn=yo,Tn=x(y(Sn))>2],go=u[Qn>>2],Qn=Xn+(w(Tn,mo)+lo<<3)|0,jo=u[Qn+4>>2],ko=u[Qn>>2]):Wn<=x(0)?(Vn=x(x(Wn+x(2))*x(.5)),Qn=Mn,Tn=x(y(Sn=Rn))>2],ko=u[Qn>>2],ho=u[4+(Qn=Xn+(Tn<<3)|0)>>2],io=u[Qn>>2]):Qn?(bo=Sn=x(co*x(3)),Qn=Mn,Tn=x(y(Rn))>2],go=u[Qn>>2],no=u[4+(Qn=Xn+(Tn+uo<<3)|0)>>2],oo=u[Qn>>2]):(v[16+qo>>3]=Wn,q[qo>>2]=a,v[8+qo>>3]=Yn,Y(4,1107,qo)),x(Un+Vn)<=x(1)?(u[On+po>>2]=x(go+x(x(oo-go)*Un))+x(x(ko-go)*Vn),Rn=x(fo+x(x(no-fo)*Un)),x(x(jo-fo)*Vn)):(Rn=x(x(1)-Un),Sn=x(x(1)-Vn),u[On+po>>2]=x(io+x(x(ko-io)*Rn))+x(x(oo-io)*Sn),Rn=x(ho+x(x(jo-ho)*Rn)),x(x(no-ho)*Sn)))):(Qn=x(y(bo=Sn))>2]=x(x(x(Rn*x(Sn*u[Tn>>2]))+x(Rn*x(Un*u[Tn+8>>2])))+x(Vn*x(Sn*u[Qn>>2])))+x(Vn*x(Un*u[Qn+8>>2])),Rn=x(x(x(Rn*x(Sn*u[Tn+4>>2]))+x(Rn*x(Un*u[Tn+12>>2])))+x(Vn*x(Sn*u[Qn+4>>2]))),x(Vn*x(Un*u[Qn+12>>2]))):x(Un+Vn)<=x(1)?(Rn=x(x(x(1)-Un)-Vn),Tn=Xn+(Qn<<3)|0,Qn=Xn+(Qn+mo<<3)|0,u[On+po>>2]=x(x(Rn*u[Tn>>2])+x(Un*u[Tn+8>>2]))+x(Vn*u[Qn>>2]),Rn=x(x(Rn*u[Tn+4>>2])+x(Un*u[Tn+12>>2])),x(Vn*u[Qn+4>>2])):(Rn=x(x(Un+x(-1))+Vn),Tn=Xn+(Qn+mo<<3)|0,Sn=x(x(1)-Un),Wn=x(x(1)-Vn),Qn=Xn+(Qn<<3)|0,u[On+po>>2]=x(x(Rn*u[Tn+8>>2])+x(Sn*u[Tn>>2]))+x(Wn*u[Qn+8>>2]),Rn=x(x(Rn*u[Tn+12>>2])+x(Sn*u[Tn+4>>2])),x(Wn*u[Qn+12>>2]))),u[4+(On+po|0)>>2]=Rn+Sn,(0|Pn)!=(0|(a=a+1|0)););L=32+qo|0},n[2]=function(a,jh){a|=0,jh|=0;var Ah=0,Bh=0,Ch=0,Dh=0,Eh=0,Fh=x(0),Gh=0,Hh=0,Jh=(x(0),0),Kh=0,Dh=q[a+320>>2],Ah=q[a+316>>2],Eh=q[a+308>>2];-1==(0|(Bh=q[8+(Ch=Eh+(jh<<5)|0)>>2]))?(q[(Kh=Ah)+(Ah=jh<<2)>>2]=q[q[a+148>>2]+(q[Ch+16>>2]<<2)>>2],q[Ah+Dh>>2]=1065353216):(Gh=q[Ch+16>>2],Hh=q[q[a+152>>2]+(Gh<<2)>>2],n[q[24+(Eh+(Bh<<5)|0)>>2]](a,Bh,Hh,Hh,q[16+(q[a+60>>2]+w(Gh,24)|0)>>2]),Fh=u[q[a+148>>2]+(q[Ch+16>>2]<<2)>>2],Ch=q[Ch+8>>2]<<2,u[(Bh=jh<<2)+Ah>>2]=Fh*u[Ch+Ah>>2],q[Bh+Dh>>2]=q[Ch+Dh>>2]),4<=r[q[a>>2]+4|0]&&(Dh=jh<<2,Ah=q[a+308>>2]+(jh<<5)|0,Bh=q[Ah+16>>2]<<2,Ch=q[a+328>>2],jh=q[a+324>>2],-1==(0|(Eh=q[Ah+8>>2]))?(Eh=q[a+156>>2],q[(Ah=Dh<<2)+jh>>2]=q[Eh+(Bh<<=2)>>2],q[(Gh=4|Ah)+jh>>2]=q[(Hh=4|Bh)+Eh>>2],q[(Jh=8|Ah)+jh>>2]=q[Eh+(Kh=8|Bh)>>2],q[jh+((Dh|=3)<<2)>>2]=1065353216,a=q[a+160>>2],q[Ah+Ch>>2]=q[a+Bh>>2],q[Ch+Gh>>2]=q[a+Hh>>2],q[Ch+Jh>>2]=q[a+Kh>>2]):(Bh=(Hh=Bh<<2)+q[a+156>>2]|0,u[(Ah=(Gh=Dh<<2)+jh|0)>>2]=u[Bh>>2]*u[(Eh=(Jh=Eh<<4)+jh|0)>>2],u[Ah+4>>2]=u[Bh+4>>2]*u[Eh+4>>2],u[Ah+8>>2]=u[Bh+8>>2]*u[Eh+8>>2],q[jh+((Dh|=3)<<2)>>2]=1065353216,a=Hh+q[a+160>>2]|0,Kh=u[a>>2],Fh=u[(Ah=Ch+Jh|0)>>2],u[(jh=Ch+Gh|0)>>2]=x(Kh+Fh)-x(Kh*Fh),Kh=u[a+4>>2],Fh=u[Ah+4>>2],u[jh+4>>2]=x(Kh+Fh)-x(Kh*Fh),Kh=u[a+8>>2],Fh=u[Ah+8>>2],u[jh+8>>2]=x(Kh+Fh)-x(Kh*Fh)),q[Ch+(Dh<<2)>>2]=1065353216)},n[3]=function(a,rn,sn,vn,wn){a|=0,rn|=0,sn|=0,vn|=0,wn|=0;var Bn,Cn,Dn,Fn,Gn,xn=0,xn=(x(0),x(0),x(0),x(0),x(0),x(0),x(0),x(0),(rn=q[16+(q[a+308>>2]+(rn<<5)|0)>>2])<<2),zn=function(a){var Cl,Fl,Dl,El,Bl=x(0);L=Dl=L-16|0,j(a);a:if((Cl=2147483647&(El=b[0]))>>>0<=1061752794)Bl=x(1),Cl>>>0<964689920||(Bl=ba(+a));else if(Cl>>>0<=1081824209)Fl=+a,Bl=1075235812<=Cl>>>0?x(-ba(((0|El)<0?3.141592653589793:-3.141592653589793)+Fl)):aa((0|El)<=-1?1.5707963267948966+Fl:1.5707963267948966-Fl);else if(Cl>>>0<=1088565717)Bl=1085271520<=Cl>>>0?ba(+a+((0|El)<0?6.283185307179586:-6.283185307179586)):aa((0|El)<=-1?-4.71238898038469-+a:+a-4.71238898038469);else if(Bl=x(a-a),!(2139095040<=Cl>>>0))if((Cl=3&Da(a,8+Dl|0))>>>0<=2){switch(Cl-1|0){default:Bl=ba(v[8+Dl>>3]);break a;case 0:Bl=aa(-v[8+Dl>>3]);break a;case 1:}Bl=x(-ba(v[8+Dl>>3]))}else Bl=aa(v[8+Dl>>3]);return L=16+Dl|0,Bl}(yn=x(x(x(u[4+(q[a+168>>2]+w(rn,12)|0)>>2]+u[xn+q[a+284>>2]>>2])*x(3.1415927410125732))/x(180))),An=u[xn+q[a+272>>2]>>2],En=q[xn+q[a+292>>2]>>2],yn=function(a){var Tk,yl,Al,zl=0;L=yl=L-16|0,j(a);a:if((Tk=2147483647&(Al=b[0]))>>>0<=1061752794)Tk>>>0<964689920||(a=aa(+a));else if(Tk>>>0<=1081824209)zl=+a,a=Tk>>>0<=1075235811?(0|Al)<=-1?x(-ba(zl+1.5707963267948966)):ba(zl+-1.5707963267948966):aa(-(((0|Al)<0?3.141592653589793:-3.141592653589793)+zl));else if(Tk>>>0<=1088565717)zl=+a,a=Tk>>>0<=1085271519?(0|Al)<=-1?ba(zl+4.71238898038469):x(-ba(zl+-4.71238898038469)):aa(((0|Al)<0?6.283185307179586:-6.283185307179586)+zl);else if(2139095040<=Tk>>>0)a=x(a-a);else if((Tk=3&Da(a,8+yl|0))>>>0<=2){switch(Tk-1|0){default:a=aa(v[8+yl>>3]);break a;case 0:a=ba(v[8+yl>>3]);break a;case 1:}a=aa(-v[8+yl>>3])}else a=x(-ba(v[8+yl>>3]));return L=16+yl|0,a}(yn);if((rn=0)<(0|wn))for(zn=x(An*zn),Cn=x(En?-1:1),Fn=x(zn*Cn),Bn=q[xn+q[a+288>>2]>>2]?x(-1):x(1),Gn=x(x(An*yn)*Bn),zn=x(zn*Bn),An=x(x(An*x(-yn))*Cn),yn=u[xn+q[a+280>>2]>>2],Cn=u[xn+q[a+276>>2]>>2];xn=(a=rn<<3)+vn|0,Bn=u[(a=a+sn|0)>>2],Dn=u[a+4>>2],u[xn+4>>2]=yn+x(x(Gn*Bn)+x(Fn*Dn)),u[xn>>2]=Cn+x(x(zn*Bn)+x(An*Dn)),(0|wn)!=(0|(rn=rn+1|0)););},n[4]=function(a,jh){a|=0,jh|=0;var vh,wh,xh,yh,zh,kh,lh=0,mh=0,nh=0,oh=x(0),ph=0,qh=0,rh=x(0),sh=0,th=0,uh=0;if(x(0),x(0),x(0),x(0),L=kh=L+-64|0,sh=q[a+320>>2],th=q[a+316>>2],mh=q[a+308>>2],-1==(0|(ph=q[8+(nh=mh+(jh<<5)|0)>>2])))lh=q[nh+16>>2]<<2,q[(mh=jh<<2)+th>>2]=q[lh+q[a+268>>2]>>2],q[mh+sh>>2]=q[lh+q[a+272>>2]>>2];else{lh=q[nh+16>>2]<<2,uh=q[lh+q[a+276>>2]>>2],q[24+kh>>2]=uh,lh=q[lh+q[a+280>>2]>>2],q[28+kh>>2]=lh,q[16+kh>>2]=0,wh=1==q[12+(qh=mh+(ph<<5)|0)>>2]?x(-10):x(-.10000000149011612),u[20+kh>>2]=wh,q[60+kh>>2]=lh,q[56+kh>>2]=uh,n[q[qh+24>>2]](a,ph,56+kh|0,48+kh|0,1),oh=x(1),mh=9;b:{for(;;){if(lh=mh,rh=x(oh*x(0)),u[32+kh>>2]=rh+u[56+kh>>2],vh=x(wh*oh),u[36+kh>>2]=vh+u[60+kh>>2],n[q[qh+24>>2]](a,ph,32+kh|0,40+kh|0,1),xh=x(u[44+kh>>2]-u[52+kh>>2]),u[44+kh>>2]=xh,yh=x(u[40+kh>>2]-u[48+kh>>2]),u[40+kh>>2]=yh,xh!=x(0)||yh!=x(0)){mh=q[44+kh>>2],q[8+kh>>2]=q[40+kh>>2],q[12+kh>>2]=mh;break b}if(u[32+kh>>2]=u[56+kh>>2]-rh,u[36+kh>>2]=u[60+kh>>2]-vh,n[q[qh+24>>2]](a,ph,32+kh|0,40+kh|0,1),rh=x(u[40+kh>>2]-u[48+kh>>2]),u[40+kh>>2]=rh,vh=x(u[44+kh>>2]-u[52+kh>>2]),(u[44+kh>>2]=vh)!=x(0)||rh!=x(0)){u[12+kh>>2]=-vh,u[8+kh>>2]=-rh;break b}if(mh=lh+-1|0,oh=x(oh*x(.10000000149011612)),!lh)break}Y(3,1311,0)}oh=function(a,Sh){var Th=x(0);if((Th=x(Ba(u[4+a>>2],u[a>>2])-Ba(u[4+Sh>>2],u[Sh>>2])))x(3.1415927410125732))for(;(Th=x(Th+x(-6.2831854820251465)))>x(3.1415927410125732););return Th}(16+kh|0,8+kh|0),n[q[qh+24>>2]](a,q[nh+8>>2],24+kh|0,24+kh|0,1),mh=q[nh+16>>2]<<2,q[mh+q[a+276>>2]>>2]=q[24+kh>>2],q[mh+q[a+280>>2]>>2]=q[28+kh>>2],lh=mh+q[a+284>>2]|0,u[lh>>2]=u[lh>>2]+x(x(oh*x(-180))/x(3.1415927410125732)),nh=q[nh+8>>2]<<2,u[(lh=jh<<2)+th>>2]=u[mh+q[a+268>>2]>>2]*u[nh+th>>2],mh=mh+q[a+272>>2]|0,oh=x(u[mh>>2]*u[nh+sh>>2]),u[lh+sh>>2]=oh,u[mh>>2]=oh}4<=r[q[a>>2]+4|0]&&(lh=jh<<2,nh=q[a+308>>2]+(jh<<5)|0,ph=q[nh+16>>2]<<2,mh=q[a+328>>2],jh=q[a+324>>2],-1==(0|(qh=q[nh+8>>2]))?(qh=q[a+296>>2],q[(nh=lh<<2)+jh>>2]=q[qh+(ph<<=2)>>2],q[(sh=4|nh)+jh>>2]=q[(th=4|ph)+qh>>2],q[(uh=8|nh)+jh>>2]=q[qh+(zh=8|ph)>>2],q[jh+((lh|=3)<<2)>>2]=1065353216,a=q[a+300>>2],q[mh+nh>>2]=q[a+ph>>2],q[mh+sh>>2]=q[a+th>>2],q[mh+uh>>2]=q[a+zh>>2]):(ph=(th=ph<<2)+q[a+296>>2]|0,u[(nh=(sh=lh<<2)+jh|0)>>2]=u[ph>>2]*u[(qh=(uh=qh<<4)+jh|0)>>2],u[nh+4>>2]=u[ph+4>>2]*u[qh+4>>2],u[nh+8>>2]=u[ph+8>>2]*u[qh+8>>2],q[jh+((lh|=3)<<2)>>2]=1065353216,a=th+q[a+300>>2]|0,oh=u[a>>2],rh=u[(nh=mh+uh|0)>>2],u[(jh=mh+sh|0)>>2]=x(oh+rh)-x(oh*rh),oh=u[a+4>>2],rh=u[nh+4>>2],u[jh+4>>2]=x(oh+rh)-x(oh*rh),oh=u[a+8>>2],rh=u[nh+8>>2],u[jh+8>>2]=x(oh+rh)-x(oh*rh)),q[mh+(lh<<2)>>2]=1065353216),L=64+kh|0},n[5]=function(a,Tk){return a|=0,Tk|=0,x(0),x(0),0|((a=u[a>>2])<(Tk=u[Tk>>2])?-1:Tk>2])))for(Yj=q[a+12>>2],Xj=q[a+20>>2];u[(Uj=Tj<<2)+Yj>>2]=u[tj+Uj>>2]*u[Uj+Xj>>2],(0|(Tj=Tj+1|0))<(0|Wj););if(!((0|(Wj=q[a>>2]))<1))if(Yj=q[a+4>>2],wj)for(Uj=tj=0;;){if(q[wj>>2]){if((0|(Tj=q[(Xj=tj<<2)+q[a+16>>2]>>2]))<1)Vj=x(0);else for(Zj=Tj+Uj|0,_j=q[a+12>>2],Vj=x(0),Tj=Uj;Vj=x(Vj+u[_j+(Tj<<2)>>2]),(0|(Tj=Tj+1|0))<(0|Zj););u[vj+Xj>>2]=Vj}if(wj=wj+4|0,Uj=q[Yj+(tj<<2)>>2]+Uj|0,!((0|(tj=tj+1|0))<(0|Wj)))break}else for(Xj=q[a+16>>2],tj=wj=0;;){if((0|(Tj=q[(Uj=wj<<2)+Xj>>2]))<=0)Vj=x(0);else for(Zj=tj+Tj|0,_j=q[a+12>>2],Vj=x(0),Tj=tj;Vj=x(Vj+u[_j+(Tj<<2)>>2]),(0|(Tj=Tj+1|0))<(0|Zj););if(u[vj+Uj>>2]=Vj,tj=q[Uj+Yj>>2]+tj|0,!((0|(wj=wj+1|0))<(0|Wj)))break}},n[7]=function(a,tj,vj,wj){a|=0,tj|=0,vj|=0,wj|=0;var xj=0,yj=x(0),Oj=0,Pj=0,Qj=0,Rj=0,Sj=0;if(1<=(0|(Rj=q[a+8>>2])))for(Pj=q[a+12>>2],Qj=q[a+20>>2];u[(Oj=xj<<2)+Pj>>2]=u[tj+Oj>>2]*u[Oj+Qj>>2],(0|(xj=xj+1|0))<(0|Rj););if(!((0|(xj=q[a>>2]))<1))if(Rj=q[a+4>>2],wj)for(Oj=tj=0;;){if(q[wj>>2]){if((0|(xj=q[(Pj=tj<<2)+q[a+16>>2]>>2]))<1)yj=x(0);else for(Qj=xj+Oj|0,Sj=q[a+12>>2],yj=x(0),xj=Oj;yj=x(yj+u[Sj+(xj<<2)>>2]),(0|(xj=xj+1|0))<(0|Qj););xj=vj+Pj|0,yj=x(yj+x(.0010000000474974513)),Pj=x(y(yj))>2]=Pj,xj=q[a>>2]}if(wj=wj+4|0,Oj=q[Rj+(tj<<2)>>2]+Oj|0,!((0|(tj=tj+1|0))<(0|xj)))break}else for(Pj=q[a+16>>2],tj=wj=0;;){if((0|(xj=q[(Oj=wj<<2)+Pj>>2]))<=0)yj=x(0);else for(Qj=tj+xj|0,Sj=q[a+12>>2],yj=x(0),xj=tj;yj=x(yj+u[Sj+(xj<<2)>>2]),(0|(xj=xj+1|0))<(0|Qj););if(xj=vj+Oj|0,yj=x(yj+x(.0010000000474974513)),Qj=x(y(yj))>2]=Qj,tj=q[Oj+Rj>>2]+tj|0,!((0|(wj=wj+1|0))>2]))break}},n[8]=function(a,tj,vj,wj,xj,yj){a|=0,tj|=0,vj|=0,wj|=0,xj|=0,yj|=0;var Mj,Nj,zj=0,Aj=0,Bj=0,Cj=0,Dj=0,Ej=0,Fj=0,Gj=0,Ij=0,Jj=0,Kj=x(0),Lj=0,Hj=q[a>>2];if(!((0|Hj)<1))if(Mj=xj<<2,Nj=q[a+4>>2],yj)for(;;){if(q[yj>>2]&&(Bj=q[(zj=Cj<<2)+q[a+16>>2]>>2],Fj=q[vj+zj>>2],Aj=q[wj+zj>>2],(zj=(0|(Gj=w(Aj,xj)))<1)||ca(Fj,0,w(Aj,Mj)),!(zj|(0|Bj)<1)))for(Ij=Bj+Ej|0,Jj=q[a+20>>2],zj=Ej;;){for(Kj=u[(Aj=zj<<2)+Jj>>2],Lj=q[tj+Aj>>2],Dj=0;u[(Aj=(Bj=Dj<<2)+Fj|0)>>2]=u[Aj>>2]+x(Kj*u[Bj+Lj>>2]),(0|Gj)!=(0|(Dj=Dj+1|0)););if(!((0|(zj=zj+1|0))<(0|Ij)))break}if(yj=yj+4|0,Ej=q[(Cj<<2)+Nj>>2]+Ej|0,!((0|(Cj=Cj+1|0))<(0|Hj)))break}else for(yj=0;;){if(Bj=q[(Cj=yj<<2)+q[a+16>>2]>>2],Fj=q[vj+Cj>>2],Aj=q[wj+Cj>>2],(zj=(0|(Gj=w(Aj,xj)))<1)||ca(Fj,0,w(Aj,Mj)),!(zj|(0|Bj)<=0))for(Ij=Bj+Ej|0,Jj=q[a+20>>2],zj=Ej;;){for(Kj=u[(Aj=zj<<2)+Jj>>2],Lj=q[tj+Aj>>2],Dj=0;u[(Aj=(Bj=Dj<<2)+Fj|0)>>2]=u[Aj>>2]+x(Kj*u[Bj+Lj>>2]),(0|Gj)!=(0|(Dj=Dj+1|0)););if(!((0|(zj=zj+1|0))<(0|Ij)))break}if(Ej=q[Cj+Nj>>2]+Ej|0,!((0|(yj=yj+1|0))<(0|Hj)))break}},n[9]=function(a){var Je,Ke,Le,Fe=0,Ge=0,He=0,Ie=0;if(!(q[(a|=0)+648>>2]||(0|(Fe=q[a+332>>2]))<1))for(Ke=(Ge=q[a+336>>2])+w(Fe,20)|0,Fe=q[a+424>>2],Ie=q[a+444>>2];;){if(q[Fe>>2]&&!((0|(He=q[Ge+16>>2]))<(a=1)))for(He<<=1,Le=q[Ie>>2];u[(Je=(a<<2)+Le|0)>>2]=-u[Je>>2],(0|(a=a+2|0))<(0|He););if(Ie=Ie+4|0,Fe=Fe+4|0,!((Ge=Ge+20|0)>>>0>>0))break}},n[10]=function(a,rn,sn){var un;return $(un=q[20+(a|=0)>>2],rn|=0,rn=(sn|=0)>>>0<(rn=q[a+16>>2]-un|0)>>>0?sn:rn),q[a+20>>2]=rn+q[a+20>>2],0|sn},n[11]=function(a,Gl,Pm,Qm,Rm,Sm){a|=0,Gl=+Gl,Pm|=0,Qm|=0,Rm|=0,Sm|=0;var dn,on,Xm,hn,Tm=0,Um=0,Vm=0,Wm=0,Ym=0,Zm=0,_m=0,$m=0,an=0,bn=0,cn=0,en=0,fn=0,gn=0,kn=0;if(q[44+(L=Xm=L-560|0)>>2]=0,h(+Gl),Tm=0|b[1],on=4294967295>>0?0:1,hn=(0|Tm)<-1||(0|Tm)<=-1&&on?(h(Gl=-Gl),Tm=0|b[1],b[0],gn=1,3872):2048&Rm?(gn=1,3875):(gn=1&Rm)?3878:3873,2146435072==(2146435072&Tm))_(a,32,Pm,Zm=gn+3|0,-65537&Rm),Z(a,hn,gn),Qm=Sm>>>5&1,Z(a,Gl!=Gl?Qm?3899:3903:Qm?3891:3895,3);else if(Gl=function Ja(a,ic){var kc,lc,jc=0;if(h(+a),jc=0|b[1],kc=0|b[0],2047!=(0|(jc=(lc=jc)>>>20&2047))){if(!jc)return jc=ic,ic=0==a?0:(a=Ja(0x10000000000000000*a,ic),q[ic>>2]+-64|0),q[jc>>2]=ic,a;q[ic>>2]=jc+-1022,f(0,0|kc),f(1,-2146435073&lc|1071644672),a=+g()}return a}(Gl,44+Xm|0),0!=(Gl+=Gl)&&(q[44+Xm>>2]=q[44+Xm>>2]+-1),dn=16+Xm|0,97==(0|(on=32|Sm))){if(cn=(bn=32&Sm)?9+hn|0:hn,!(11>>0)&&(Tm=12-Qm|0)){for(en=8;en*=16,Tm=Tm+-1|0;);Gl=45==r[0|cn]?-(en+(-Gl-en)):Gl+en-en}for((0|dn)==(0|(Tm=ga((Vm=(Tm=q[44+Xm>>2])>>31)^Tm+Vm,0,dn)))&&(o[15+Xm|0]=48,Tm=15+Xm|0),Ym=2|gn,Vm=q[44+Xm>>2],o[0|(an=Tm+-2|0)]=Sm+15,o[Tm+-1|0]=(0|Vm)<0?45:43,Tm=8&Rm,Um=16+Xm|0;Sm=Um,$m=bn,Vm=y(Gl)<2147483648?~~Gl:-2147483648,o[0|Um]=$m|r[Vm+3856|0],1!=((Um=Sm+1|0)-(16+Xm|0)|0)|(0==(Gl=16*(Gl-(0|Vm)))?!(Tm|0<(0|Qm)):0)||(o[Sm+1|0]=46,Um=Sm+2|0),0!=Gl;);_(a,32,Pm,Zm=(Sm=!Qm|(0|Qm)<=((Um-Xm|0)-18|0)?((dn-(16+Xm|0)|0)-an|0)+Um|0:2+((Qm+dn|0)-an|0)|0)+Ym|0,Rm),Z(a,cn,Ym),_(a,48,Pm,Zm,65536^Rm),Z(a,16+Xm|0,Qm=Um-(16+Xm|0)|0),_(a,48,Sm-((Tm=Qm)+(Qm=dn-an|0)|0)|0,0,0),Z(a,an,Qm)}else{for(Tm=(0|Qm)<0,0==Gl?Wm=q[44+Xm>>2]:(Wm=q[44+Xm>>2]+-28|0,q[44+Xm>>2]=Wm,Gl*=268435456),_m=Tm?6:Qm,Vm=bn=(0|Wm)<0?48+Xm|0:336+Xm|0;Vm=(Qm=Vm)+4|0,0!=(Gl=1e9*(Gl-((q[Qm>>2]=Tm=Gl<4294967296&0<=Gl?~~Gl>>>0:0)>>>0))););if((0|Wm)<1)Tm=Vm,Um=bn;else for(Um=bn;;){if(an=(0|Wm)<29?Wm:29,!((Tm=Vm+-4|0)>>>0>>0)){for(Qm=an,$m=0;kn=$m,$m=q[(cn=Tm)>>2],Ym=31&Qm,Ym=32<=(63&Qm)>>>(Zm=0)?(Wm=$m<>>32-Ym,$m<>>0>>0?Zm+1|0:Zm,kn=cn,cn=cd($m=dd(Ym=$m,Zm,1e9),M,1e9),q[kn>>2]=Ym-cn,Um>>>0<=(Tm=Tm+-4|0)>>>0;);(Qm=$m)&&(q[(Um=Um+-4|0)>>2]=Qm)}for(;Um>>>0<(Tm=Vm)>>>0&&!q[(Vm=Tm+-4|0)>>2];);if(Wm=q[44+Xm>>2]-an|0,Vm=Tm,!(0<(0|(q[44+Xm>>2]=Wm))))break}if((0|Wm)<=-1)for(fn=1+((_m+25|0)/9|0)|0,an=102==(0|on);;){if($m=(0|Wm)<-9?9:0-Wm|0,Tm>>>0<=Um>>>0)Um=q[Um>>2]?Um:Um+4|0;else{for(cn=1e9>>>$m,Ym=-1<<$m^-1,Wm=0,Vm=Um;Qm=q[Vm>>2],q[Vm>>2]=(Qm>>>$m)+Wm,Wm=w(cn,Qm&Ym),(Vm=Vm+4|0)>>>0>>0;);Um=q[Um>>2]?Um:Um+4|0,Wm&&(q[Tm>>2]=Wm,Tm=Tm+4|0)}if(Wm=$m+q[44+Xm>>2]|0,Tm=(0|fn)>2?Qm+(fn<<2)|0:Tm,!((0|(q[44+Xm>>2]=Wm))<0))break}if(!(Tm>>>(Vm=0)<=Um>>>0||(Vm=w(bn-Um>>2,9),(Qm=q[Um>>2])>>>0<(Wm=10))))for(;Vm=Vm+1|0,(Wm=w(Wm,10))>>>0<=Qm>>>0;);if((0|(Qm=(_m-(102==(0|on)?0:Vm)|0)-(103==(0|on)&0!=(0|_m))|0))<(w(Tm-bn>>2,9)+-9|0)){if(Zm=(bn+((Qm=(0|(Ym=Qm+9216|0))/9|0)<<2)|0)-4092|0,Wm=10,(0|(Qm=1+(Ym-w(Qm,9)|0)|0))<=8)for(;Wm=w(Wm,10),9!=(0|(Qm=Qm+1|0)););if(fn=Zm+4|0,((an=(cn=q[Zm>>2])-w(Wm,Ym=(cn>>>0)/(Wm>>>0)|0)|0)||(0|fn)!=(0|Tm))&&(en=an>>>0<(Qm=Wm>>>1)>>>0?.5:(0|Tm)==(0|fn)&&(0|Qm)==(0|an)?1:1.5,Gl=1&Ym?9007199254740994:9007199254740992,!gn|45!=r[0|hn]||(en=-en,Gl=-Gl),q[Zm>>2]=Qm=cn-an|0,Gl+en!=Gl)){if(1e9<=(q[Zm>>2]=Qm=Qm+Wm|0)>>>0)for(;(Zm=Zm+-4|(q[Zm>>2]=0))>>>0>>0&&(q[(Um=Um+-4|0)>>2]=0),Qm=q[Zm>>2]+1|0,999999999<(q[Zm>>2]=Qm)>>>0;);if(Vm=w(bn-Um>>2,9),!((Qm=q[Um>>2])>>>0<(Wm=10)))for(;Vm=Vm+1|0,(Wm=w(Wm,10))>>>0<=Qm>>>0;);}Tm=(Qm=Zm+4|0)>>>0>>0?Qm:Tm}j:{for(;;){if((an=Tm)>>>(cn=0)<=Um>>>0)break j;if(q[(Tm=an+-4|0)>>2])break}cn=1}if(103!=(0|on))Ym=8&Rm;else if(_m=((Qm=(0|Vm)<(0|(Tm=_m||1))&-5<(0|Vm))?-1^Vm:-1)+Tm|0,Sm=(Qm?-1:-2)+Sm|0,!(Ym=8&Rm)){if(Tm=9,cn&&(Ym=q[an+-4>>2])&&!((Ym>>>(Tm=0))%(Qm=10)))for(;Tm=Tm+1|0,!((Ym>>>0)%((Qm=w(Qm,10))>>>0)););Qm=w(an-bn>>2,9)+-9|0,_m=102==(32|Sm)?((Ym=0)|_m)<(0|(Qm=0<(0|(Qm=Qm-Tm|0))?Qm:0))?_m:Qm:((Ym=0)|_m)<(0|(Qm=0<(0|(Qm=(Qm+Vm|0)-Tm|0))?Qm:0))?_m:Qm}if(Zm=0!=(0|(Wm=_m|Ym)),Qm=a,kn=Pm,Tm=0<(0|Vm)?Vm:0,102!=(0|($m=32|Sm))){if((dn-(Tm=ga((Tm=Vm>>31)+Vm^Tm,0,dn))|0)<=1)for(;o[0|(Tm=Tm+-1|0)]=48,(dn-Tm|0)<2;);o[0|(fn=Tm+-2|0)]=Sm,o[Tm+-1|0]=(0|Vm)<0?45:43,Tm=dn-fn|0}if(_(Qm,32,kn,Zm=1+(Tm+(Zm+(_m+gn|0)|0)|0)|0,Rm),Z(a,hn,gn),_(a,48,Pm,Zm,65536^Rm),102==(0|$m)){for(Qm=16+Xm|8,Vm=16+Xm|9,Um=Sm=bn>>>0>>0?bn:Um;;){if(Tm=ga(q[Um>>2],0,Vm),(0|Sm)!=(0|Um)){if(!(Tm>>>0<=16+Xm>>>0))for(;o[0|(Tm=Tm+-1|0)]=48,16+Xm>>>0>>0;);}else(0|Tm)==(0|Vm)&&(o[24+Xm|0]=48,Tm=Qm);if(Z(a,Tm,Vm-Tm|0),!((Um=Um+4|0)>>>0<=bn>>>0))break}Wm&&Z(a,3907,1);p:if(!((0|_m)<1|an>>>0<=Um>>>0))for(;;){if(16+Xm>>>0<(Tm=ga(q[Um>>2],0,Vm))>>>0)for(;o[0|(Tm=Tm+-1|0)]=48,16+Xm>>>0>>0;);if(Z(a,Tm,(0|_m)<9?_m:9),_m=_m+-9|0,an>>>0<=(Um=Um+4|0)>>>0)break p;if(!(0<(0|_m)))break}_(a,48,_m+9|0,9,0)}else{q:if(!((0|_m)<0))for(Sm=cn?an:Um+4|0,Qm=16+Xm|8,bn=16+Xm|9,Vm=Um;;){if((0|bn)==(0|(Tm=ga(q[Vm>>2],0,bn)))&&(o[24+Xm|0]=48,Tm=Qm),(0|Um)!=(0|Vm)){if(!(Tm>>>0<=16+Xm>>>0))for(;o[0|(Tm=Tm+-1|0)]=48,16+Xm>>>0>>0;);}else Z(a,Tm,1),Tm=Tm+1|0,(0|_m)<1&&!Ym||Z(a,3907,1);if(Z(a,$m=Tm,(0|(Tm=bn-Tm|0))<(0|_m)?Tm:_m),_m=_m-Tm|0,Sm>>>0<=(Vm=Vm+4|0)>>>0)break q;if(!(-1<(0|_m)))break}_(a,48,_m+18|0,18,0),Z(a,fn,dn-fn|0)}}return _(a,32,Pm,Zm,8192^Rm),L=560+Xm|0,0|((0|Zm)<(0|Pm)?Pm:Zm)},n[12]=function(a,Gl){a|=0;var Cm=Gl|=0;Gl=q[Gl>>2]+15&-16,q[Cm>>2]=Gl+16,Cm=a,a=function(a,Gl,Hl,Zl){var dm,am,_l=0,$l=0,bm=0,cm=0;return L=am=L-32|0,_l=(cm=_l=2147483647&Zl)-1006698496|0,$l=_l=(dm=$l=bm=Hl)>>>0<0?_l+1|0:_l,_l=cm-1140785152|0,(0|(_l=bm>>>0<0?_l+1|0:_l))==(0|$l)&dm>>>0>>0|$l>>>0<_l>>>0?(_l=Zl<<4|Hl>>>28,Hl=Hl<<4|Gl>>>28,134217728==(0|(bm=Gl&=268435455))&1<=a>>>0|134217728>>0?(_l=_l+1073741824|0,(a=Hl+1|0)>>>0<1&&(_l=_l+1|0),$l=a):(_l=_l-((($l=Hl)>>>0<0)+-1073741824|0)|0,a|134217728^bm||((a=$l+(1&$l)|0)>>>0<$l>>>0&&(_l=_l+1|0),$l=a))):(!bm&2147418112==(0|cm)?!(a|Gl):2147418112==(0|cm)&bm>>>0<0|cm>>>0<2147418112)?(_l=2146435072,1140785151==(($l=0)|cm)&4294967295>>0|1140785151>>0||(bm=cm>>>16)>>>(_l=0)<15249||(function(a,Gl,Hl,Cm,Dm,Em){var Hm,Im,Fm=0,Gm=0;64&Em?(Gl=31&(Hl=Em-64|0),Gl=32<=(63&Hl)>>>0?(Hl=0,Dm>>>Gl):(Hl=Dm>>>Gl,((1<>>Gl),Dm=Cm=0):Em&&(Gm=Dm,Fm=31&(Im=64-Em|0),Im=32<=(63&Im)>>>0?(Gm=Cm<>>32-Fm|Gm<>>0?(Fm=0,Hl>>>Gl):(Fm=Hl>>>Gl,((1<>>Gl),Gl|=Im,Hl=Fm|Gm,Fm=Cm,Cm=31&Em,Cm=32<=(63&Em)>>>0?(Gm=0,Dm>>>Cm):(Gm=Dm>>>Cm,((1<>>Cm),Dm=Gm),q[a>>2]=Gl,q[4+a>>2]=Hl,q[8+a>>2]=Cm,q[12+a>>2]=Dm}(am,a,Gl,Hl,_l=65535&Zl|65536,15361-bm|0),function(a,Gl,Hl,Zl,fm,gm){var jm,hm,im=0;64&gm?(Zl=Gl,Gl=31&(fm=gm+-64|0),32<=(63&fm)>>>0?(fm=Zl<>>32-Gl|Hl<>>0?(im=hm<>>32-Zl|fm<>>0?(gm=0,Zl>>>=fm):(gm=Zl>>>fm,Zl=((1<>>fm),Zl|=hm,fm=gm|im,gm=Gl,Gl=31&jm,Gl=32<=(63&jm)>>>0?(im=gm<>>32-Gl|Hl<>2]=Gl,q[4+a>>2]=Hl,q[8+a>>2]=Zl,q[12+a>>2]=fm}(16+am|0,a,Gl,Hl,_l,bm+-15233|0),Hl=q[4+am>>2],a=q[8+am>>2],_l=q[12+am>>2]<<4|a>>>28,$l=a<<4|Hl>>>28,134217728==(0|(Hl=a=268435455&Hl))&1<=(Gl=q[am>>2]|(0!=(q[16+am>>2]|q[24+am>>2])|0!=(q[20+am>>2]|q[28+am>>2])))>>>0|134217728>>0?((a=$l+1|0)>>>0<1&&(_l=_l+1|0),$l=a):Gl|134217728^Hl||((a=$l+(1&$l)|0)>>>0<$l>>>0&&(_l=_l+1|0),$l=a))):($l=Hl<<4|Gl>>>28,_l=524287&(_l=Zl<<4|Hl>>>28)|2146959360),L=32+am|0,f(0,0|$l),f(1,-2147483648&Zl|_l),+g()}(q[Gl>>2],q[Gl+4>>2],q[Gl+8>>2],q[Gl+12>>2]),v[Cm>>3]=a},n[13]=function(a){return 0},n[14]=function(a,Gl,Cm){Gl|=0,Cm|=0;var Mm,Em,Dm=0,Jm=0,Km=0,Lm=0;for(L=Em=L-32|0,Dm=q[28+(a|=0)>>2],q[16+Em>>2]=Dm,Km=q[a+20>>2],q[28+Em>>2]=Cm,q[24+Em>>2]=Gl,Km=(q[20+Em>>2]=Gl=Km-Dm|0)+Cm|0,Lm=2,Gl=16+Em|0;;){a:{if((Jm=(Dm=0)|K(q[a+60>>2],0|Gl,0|Lm,12+Em|0))&&(q[2094]=Jm,Dm=-1),(0|(Dm=Dm?q[12+Em>>2]=-1:q[12+Em>>2]))==(0|Km))Gl=q[a+44>>2],q[a+28>>2]=Gl,q[a+20>>2]=Gl,q[a+16>>2]=Gl+q[a+48>>2],a=Cm;else{if(-1<(0|Dm))break a;q[a+28>>2]=0,q[a+16>>2]=0,q[a+20>>2]=0,q[a>>2]=32|q[a>>2],2!=((a=0)|Lm)&&(a=Cm-q[Gl+4>>2]|0)}return L=32+Em|0,0|a}Jm=q[Gl+4>>2],q[(Gl=(Mm=Jm>>>0>>0)?Gl+8|0:Gl)>>2]=(Jm=Dm-(Mm?Jm:0)|0)+q[Gl>>2],q[Gl+4>>2]=q[Gl+4>>2]-Jm,Km=Km-Dm|0,Lm=Lm-Mm|0}},n[15]=function(a,Gl,Cm,Dm){return M=0},{d:function(){},e:function(){return q[1813]},f:function(){return 83951616},g:function(){return 5},h:function(a,tj){return tj|=0,L=tj=L-16|0,a=(a|=0)?sa(a)?(Y(4,2150,0),0):r[a+4|0]:(q[tj+4>>2]=1444,q[tj>>2]=2267,Y(4,1294,tj),0),L=tj+16|0,0|a},i:function(a,tj){var uj;return tj|=0,L=uj=L-48|0,a=(a|=0)?(a+63&-64)!=(0|a)?(q[36+uj>>2]=1522,q[32+uj>>2]=2284,Y(4,1294,32+uj|0),0):(tj+63&-64)==(0|tj)&&tj?function(a,Tk){var nl,Uk=0,Vk=0,Wk=0,Xk=0,Yk=0,Zk=0,_k=0,$k=0,al=0,bl=0,cl=0,dl=0,el=0,fl=0,gl=0,hl=0,il=0,jl=0,kl=0,ll=0,ml=0;L=Yk=(nl=Vk=L)-704&-64;a:if(Tk>>>0<=1343)Y(4,1235,0);else if(sa(a))Y(4,1469,0);else if(Vk=r[0|(ll=a+4|0)]){if(!(6<=Vk>>>0)){(hl=1==(0|!r[a+5|0]))||(da(ll,1),X(a- -64|0,4,160)),ca(Yk- -64|0,0,640),na(a,Yk- -64|0),Vk=a+Tk|0,Tk=q[Yk+64>>2];b:{c:{d:{if(5<=(gl=r[a+4|0])>>>0){if(Tk>>>0>>0|Vk>>>0>>0)break c;if((Xk=Tk+256|0)>>>0>>0)break c;if(Xk>>>0<=Vk>>>0)break d;break c}if(Tk>>>0>>0|Vk>>>0>>0)break c;if((Xk=Tk+128|0)>>>0>>0|Vk>>>0>>0)break c}if(!((Wk=q[Yk+68>>2])>>>0>>0|Vk>>>0>>0|Wk>>>0>>0||(Wk=Wk- -64|0)>>>0>>0|Vk>>>0>>0||(0|(bl=q[Tk>>2]))<0||(Xk=q[Yk+72>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0||(Wk=(Uk=Xk)+(Xk=bl<<2)|0)>>>0>>0|Vk>>>0>>0||(_k=q[Yk+76>>2])>>>0>>0|Vk>>>0<_k>>>0|_k>>>0>>0||(Uk=(bl<<6)+_k|0)>>>0>>0|Vk>>>0>>0||(Wk=q[Yk+80>>2])>>>0>>0|Vk>>>0>>0|Wk>>>0>>0||(Uk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0||(Wk=q[Yk+84>>2])>>>0>>0|Vk>>>0>>0|Wk>>>0>>0||(Uk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0||(Wk=q[Yk+88>>2])>>>0>>0|Vk>>>0>>0|Wk>>>0>>0||(Uk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0||(Wk=q[Yk+92>>2])>>>0>>0|Vk>>>0>>0|Wk>>>0>>0||(Uk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0||(Wk=q[Yk+96>>2])>>>0>>0|Vk>>>0>>0|Wk>>>0>>0||(Uk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0||(Wk=q[Yk+100>>2])>>>0>>0|Vk>>>0>>0|Wk>>>0>>0||(Wk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0||(0|(Uk=q[Tk+4>>2]))<0||(Xk=q[Yk+104>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0||(Zk=(Wk=Xk)+(Xk=Uk<<2)|0)>>>0>>0|Vk>>>0>>0||(Wk=q[Yk+108>>2])>>>0>>0|Vk>>>0>>0|Wk>>>0>>0||(Uk=Wk+(Uk<<6)|0)>>>0>>0|Vk>>>0>>0||(Wk=q[Yk+112>>2])>>>0>>0|Vk>>>0>>0|Wk>>>0>>0||(Uk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0||(Wk=q[Yk+116>>2])>>>0>>0|Vk>>>0>>0|Wk>>>0>>0||(Uk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0||(Wk=q[Yk+120>>2])>>>0>>0|Vk>>>0>>0|Wk>>>0>>0||(Uk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0||(Wk=q[Yk+124>>2])>>>0>>0|Vk>>>0>>0|Wk>>>0>>0||(Uk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0||(Wk=q[Yk+128>>2])>>>0>>0|Vk>>>0>>0|Wk>>>0>>0||(Uk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0||(Wk=q[Yk+132>>2])>>>0>>0|Vk>>>0>>0|Wk>>>0>>0||(Uk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0||(Wk=q[Yk+136>>2])>>>0>>0|Vk>>>0>>0|Wk>>>0>>0||(Wk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0||(0|(Uk=q[Tk+8>>2]))<0||(Xk=q[Yk+140>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0||(Wk=(cl=Uk<<2)+Xk|0)>>>0>>0|Vk>>>0>>0||(Xk=q[Yk+144>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0||(Wk=Xk+cl|0)>>>0>>0|Vk>>>0>>0||(Xk=q[Yk+148>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0||(Wk=Xk+cl|0)>>>0>>0|Vk>>>0>>0||(Xk=q[Yk+156>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0||(Wk=Xk+cl|0)>>>0>>0|Vk>>>0>>0||(Xk=q[Yk+160>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0||(Wk=Xk+cl|0)>>>0>>0|Vk>>>0>>0||(Xk=q[Yk+164>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0||(Wk=Xk+cl|0)>>>0>>0|Vk>>>0>>0||(0|(Uk=q[Tk+12>>2]))<0||(Xk=q[Yk+172>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0||(Wk=(dl=Uk<<2)+Xk|0)>>>0>>0|Vk>>>0>>0||(Xk=q[Yk+176>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0||(Wk=Xk+dl|0)>>>0>>0|Vk>>>0>>0||(Xk=q[Yk+180>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0||(Wk=Xk+dl|0)>>>0>>0|Vk>>>0>>0||(Xk=q[Yk+188>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0||(Uk=Xk+dl|0)>>>0>>0|Vk>>>0>>0||(0|(Wk=q[Tk+16>>2]))<0||(Xk=q[Yk+192>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0||(Zk=(Uk=Xk)+(Xk=Wk<<2)|0)>>>0>>0|Vk>>>0>>0||(Uk=q[Yk+196>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||(Zk=Uk+Xk|0)>>>0>>0|Vk>>>0>>0||(Uk=q[Yk+200>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||(Zk=Uk+Xk|0)>>>0>>0|Vk>>>0>>0||(Uk=q[Yk+204>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||(Zk=Uk+Xk|0)>>>0>>0|Vk>>>0>>0||(Uk=q[Yk+208>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||(Zk=Uk+(Wk<<6)|0)>>>0>>0|Vk>>>0>>0||(Uk=q[Yk+212>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||(Zk=Uk+Xk|0)>>>0>>0|Vk>>>0>>0||(Uk=q[Yk+216>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||(Zk=Uk+Xk|0)>>>0>>0|Vk>>>0>>0||(Uk=q[Yk+220>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||(Zk=Uk+Xk|0)>>>0>>0|Vk>>>0>>0||(Uk=q[Yk+228>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||(Zk=Uk+Xk|0)>>>0>>0|Vk>>>0>>0||(Uk=q[Yk+232>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||(Zk=Uk+Xk|0)>>>0>>0|Vk>>>0>>0||(Uk=q[Yk+236>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||(Zk=Uk+Xk|0)>>>0>>0|Vk>>>0>>0||(Uk=q[Yk+240>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||(Zk=Uk+Xk|0)>>>0>>0|Vk>>>0>>0||(Uk=q[Yk+244>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||(Zk=Uk+Xk|0)>>>0>>0|Vk>>>0>>0||(Uk=q[Yk+248>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||(Uk=Uk+Wk|0)>>>0>>0|Vk>>>0>>0||(Wk=q[Yk+252>>2])>>>0>>0|Vk>>>0>>0|Wk>>>0>>0||(Uk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0||(Wk=q[Yk+256>>2])>>>0>>0|Vk>>>0>>0|Wk>>>0>>0||(Uk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0||(Wk=q[Yk+260>>2])>>>0>>0|Vk>>>0>>0|Wk>>>0>>0||(Uk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0||(Wk=q[Yk+264>>2])>>>0>>0|Vk>>>0>>0|Wk>>>0>>0||(Uk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0||(Wk=q[Yk+268>>2])>>>0>>0|Vk>>>0>>0|Wk>>>0>>0||(Uk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0||(Wk=q[Yk+272>>2])>>>0>>0|Vk>>>0>>0|Wk>>>0>>0||(Uk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0||(0|(Zk=q[Tk+20>>2]))<0||(Wk=q[Yk+276>>2])>>>0>>0|Vk>>>0>>0|Wk>>>0>>0||(el=(Uk=Wk)+(Wk=Zk<<2)|0)>>>0>>0|Vk>>>0>>0||(Uk=q[Yk+280>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||(Zk=Uk+(Zk<<6)|0)>>>0>>0|Vk>>>0>>0||(Uk=q[Yk+284>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||(Zk=Uk+Wk|0)>>>0>>0|Vk>>>0>>0||(Uk=q[Yk+288>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||(Zk=Uk+Wk|0)>>>0>>0|Vk>>>0>>0||(Uk=q[Yk+292>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||(Zk=Uk+Wk|0)>>>0>>0|Vk>>>0>>0||(Uk=q[Yk+296>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||(Zk=Uk+Wk|0)>>>0>>0|Vk>>>0>>0||(Uk=q[Yk+300>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||(Zk=Uk+Wk|0)>>>0>>0|Vk>>>0>>0||(Uk=q[Yk+308>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||(Zk=Uk+Wk|0)>>>0>>0|Vk>>>0>>0||(Uk=q[Yk+312>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||(Zk=Uk+Wk|0)>>>0>>0|Vk>>>0>>0||(0|(el=q[Tk+24>>2]))<0||(Uk=q[Yk+336>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||(Zk=Uk+(el<<2)|0)>>>0>>0|Vk>>>0>>0||(0|(el=q[Tk+28>>2]))<0||(Uk=q[Yk+340>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||(Zk=(jl=el<<2)+Uk|0)>>>0>>0|Vk>>>0>>0||(Uk=q[Yk+344>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||(Zk=Uk+jl|0)>>>0>>0|Vk>>>0>>0||(0|(el=q[Tk+32>>2]))<0||(Uk=q[Yk+356>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||(el=(Zk=el<<2)+Uk|0)>>>0>>0|Vk>>>0>>0||(Uk=q[Yk+360>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||(el=Uk+Zk|0)>>>0>>0|Vk>>>0>>0||(Uk=q[Yk+364>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||(el=Uk+Zk|0)>>>0>>0|Vk>>>0>>0||(Uk=q[Yk+368>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||(el=Uk+Zk|0)>>>0>>0|Vk>>>0>>0||(Uk=q[Yk+372>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||(el=Uk+Zk|0)>>>0>>0|Vk>>>0>>0||(Uk=q[Yk+376>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||(el=Uk+Zk|0)>>>0>>0|Vk>>>0>>0||(Uk=q[Yk+380>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||(el=Uk+Zk|0)>>>0>>0|Vk>>>0>>0||(0|($k=q[Tk+36>>2]))<0||(Uk=q[Yk+392>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||($k=(el=$k<<2)+Uk|0)>>>0>>0|Vk>>>0<$k>>>0||(Uk=q[Yk+396>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0<$k>>>0||($k=Uk+el|0)>>>0>>0|Vk>>>0<$k>>>0||(Uk=q[Yk+400>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0<$k>>>0||($k=Uk+el|0)>>>0>>0|Vk>>>0<$k>>>0||(0|(al=q[Tk+40>>2]))<0||(Uk=q[Yk+412>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0<$k>>>0||($k=Uk+(al<<2)|0)>>>0>>0|Vk>>>0<$k>>>0||(0|(al=q[Tk+44>>2]))<0||(Uk=q[Yk+424>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0<$k>>>0||($k=Uk+(al<<2)|0)>>>0>>0|Vk>>>0<$k>>>0||(0|(al=q[Tk+48>>2]))<0||(Uk=q[Yk+428>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0<$k>>>0||($k=(al<<=2)+Uk|0)>>>0>>0|Vk>>>0<$k>>>0||(Uk=q[Yk+432>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0<$k>>>0||($k=Uk+al|0)>>>0>>0|Vk>>>0<$k>>>0||(0|(al=q[Tk+52>>2]))<0||(Uk=q[Yk+416>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0<$k>>>0||($k=(al<<=2)+Uk|0)>>>0>>0|Vk>>>0<$k>>>0||(Uk=q[Yk+420>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0<$k>>>0||($k=Uk+al|0)>>>0>>0|Vk>>>0<$k>>>0||(0|(al=q[Tk+56>>2]))<0||(Uk=q[Yk+552>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0<$k>>>0||($k=Uk+(al<<2)|0)>>>0>>0|Vk>>>0<$k>>>0||(0|(al=q[Tk+60>>2]))<0||(Uk=q[Yk+556>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0<$k>>>0||($k=Uk+(al<<2)|0)>>>0>>0|Vk>>>0<$k>>>0||(0|(al=q[Tk+64>>2]))<0||(Uk=q[Yk+560>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0<$k>>>0||($k=Uk+(al<<1)|0)>>>0>>0|Vk>>>0<$k>>>0||(0|(al=q[Tk+68>>2]))<0||(Uk=q[Yk+564>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0<$k>>>0||($k=Uk+(al<<2)|0)>>>0>>0|Vk>>>0<$k>>>0||(0|(al=q[Tk+72>>2]))<0||(Uk=q[Yk+568>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0<$k>>>0||(al=($k=Uk)+(Uk=al<<2)|0)>>>0>>0|Vk>>>0>>0||($k=q[Yk+572>>2])>>>0>>0|Vk>>>0<$k>>>0|$k>>>0>>0||(al=Uk+$k|0)>>>0>>0|Vk>>>0>>0||($k=q[Yk+576>>2])>>>0>>0|Vk>>>0<$k>>>0|$k>>>0>>0||(al=Uk+$k|0)>>>0>>0|Vk>>>0>>0||($k=q[Yk+580>>2])>>>0>>0|Vk>>>0<$k>>>0|$k>>>0>>0||(al=Uk+$k|0)>>>0>>0|Vk>>>0>>0||($k=q[Yk+584>>2])>>>0>>0|Vk>>>0<$k>>>0|$k>>>0>>0||($k=Uk+$k|0)>>>0>>0|Vk>>>0<$k>>>0||(0|(al=q[Tk+76>>2]))<0||(Uk=q[Yk+588>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0<$k>>>0||(al=($k=al<<2)+Uk|0)>>>0>>0|Vk>>>0>>0||(Uk=q[Yk+592>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||(al=Uk+$k|0)>>>0>>0|Vk>>>0>>0||(Uk=q[Yk+596>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0||($k=Uk+$k|0)>>>0>>0|Vk>>>0<$k>>>0||(0|(al=q[Tk+80>>2]))<0||(Uk=q[Yk+600>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0<$k>>>0||(fl=($k=Uk)+(Uk=al<<2)|0)>>>0>>0|Vk>>>0>>0||($k=q[Yk+604>>2])>>>0>>0|Vk>>>0<$k>>>0|$k>>>0>>0||(al=$k+(al<<6)|0)>>>0>>0|Vk>>>0>>0||($k=q[Yk+608>>2])>>>0>>0|Vk>>>0<$k>>>0|$k>>>0>>0||(al=Uk+$k|0)>>>0>>0|Vk>>>0>>0||($k=q[Yk+612>>2])>>>0>>0|Vk>>>0<$k>>>0|$k>>>0>>0||(al=Uk+$k|0)>>>0>>0|Vk>>>0>>0||($k=q[Yk+616>>2])>>>0>>0|Vk>>>0<$k>>>0|$k>>>0>>0||(al=Uk+$k|0)>>>0>>0|Vk>>>0>>0||($k=q[Yk+620>>2])>>>0>>0|Vk>>>0<$k>>>0|$k>>>0>>0||(al=Uk+$k|0)>>>0>>0|Vk>>>0>>0||($k=q[Yk+624>>2])>>>0>>0|Vk>>>0<$k>>>0|$k>>>0>>0||(al=Uk+$k|0)>>>0>>0|Vk>>>0>>0||($k=q[Yk+628>>2])>>>0>>0|Vk>>>0<$k>>>0|$k>>>0>>0||(al=Uk+$k|0)>>>0>>0|Vk>>>0>>0||($k=q[Yk+632>>2])>>>0>>0|Vk>>>0<$k>>>0|$k>>>0>>0||($k=Uk+$k|0)>>>0>>0|Vk>>>0<$k>>>0||(0|(al=q[Tk+84>>2]))<0||(Uk=q[Yk+636>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0<$k>>>0||($k=Uk+(al<<2)|0)>>>0>>0|Vk>>>0<$k>>>0||(Uk=q[Yk+640>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0<$k>>>0||($k=Uk+(al<<1)|0)>>>0>>0|Vk>>>0<$k>>>0||(0|(al=q[Tk+88>>2]))<0||(Uk=q[Yk+644>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0<$k>>>0||(Uk=Uk+(al<<2)|0)>>>0>>0|Vk>>>0>>0)){if(!(gl>>>0<2)){if(($k=q[Yk+168>>2])>>>0>>0|Vk>>>0<$k>>>0|$k>>>0>>0)break c;if((Uk=$k+cl|0)>>>0>>0|Vk>>>0>>0)break c;if(!(gl>>>0<4)){if(($k=q[Yk+324>>2])>>>0>>0|Vk>>>0<$k>>>0|$k>>>0>>0)break c;if(($k=Wk+$k|0)>>>0>>0|Vk>>>0<$k>>>0)break c;if((Uk=q[Yk+328>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0<$k>>>0)break c;if(($k=Uk+Wk|0)>>>0>>0|Vk>>>0<$k>>>0)break c;if((Uk=q[Yk+332>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0<$k>>>0)break c;if(($k=Uk+Wk|0)>>>0>>0|Vk>>>0<$k>>>0)break c;if((Uk=q[Yk+152>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0<$k>>>0)break c;if((cl=Uk+cl|0)>>>0>>0|Vk>>>0>>0)break c;if((Uk=q[Yk+184>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0)break c;if((cl=Uk+dl|0)>>>0>>0|Vk>>>0>>0)break c;if((Uk=q[Yk+224>>2])>>>0>>0|Vk>>>0>>0|Uk>>>0>>0)break c;if((Uk=Uk+Xk|0)>>>0>>0|Vk>>>0>>0)break c;if((0|(cl=q[Tk+92>>2]))<0)break c;if((Xk=q[Yk+648>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0)break c;if((cl=(Uk=cl<<2)+Xk|0)>>>0>>0|Vk>>>0>>0)break c;if((Xk=q[Yk+652>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0)break c;if((cl=Uk+Xk|0)>>>0>>0|Vk>>>0>>0)break c;if((Xk=q[Yk+656>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0)break c;if((Uk=Uk+Xk|0)>>>0>>0|Vk>>>0>>0)break c;if((0|(cl=q[Tk+96>>2]))<0)break c;if((Xk=q[Yk+660>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0)break c;if((cl=(Uk=cl<<2)+Xk|0)>>>0>>0|Vk>>>0>>0)break c;if((Xk=q[Yk+664>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0)break c;if((cl=Uk+Xk|0)>>>0>>0|Vk>>>0>>0)break c;if((Xk=q[Yk+668>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0)break c;if((Uk=Uk+Xk|0)>>>0>>0|Vk>>>0>>0)break c;if((Xk=q[Yk+304>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0)break c;if((Uk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0)break c;if((Xk=q[Yk+316>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0)break c;if((Uk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0)break c;if((Xk=q[Yk+320>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0)break c;if((Wk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0)break c;if((0|(Uk=q[Tk+100>>2]))<0)break c;if((Xk=q[Yk+436>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0)break c;if((Uk=(Wk=Uk<<2)+Xk|0)>>>0>>0|Vk>>>0>>0)break c;if((Xk=q[Yk+440>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0)break c;if((Uk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0)break c;if((Xk=q[Yk+444>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0)break c;if((Wk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0)break c;if((0|(Uk=q[Tk+104>>2]))<0)break c;if((Xk=q[Yk+448>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0)break c;if((Uk=(Wk=Xk)+(Xk=Uk<<2)|0)>>>0>>0|Vk>>>0>>0)break c;if((Wk=q[Yk+452>>2])>>>0>>0|Vk>>>0>>0|Wk>>>0>>0)break c;if((Uk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0)break c;if((Wk=q[Yk+456>>2])>>>0>>0|Vk>>>0>>0|Wk>>>0>>0)break c;if((Uk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0)break c;if((Wk=q[Yk+460>>2])>>>0>>0|Vk>>>0>>0|Wk>>>0>>0)break c;if((Uk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0)break c;if((Wk=q[Yk+464>>2])>>>0>>0|Vk>>>0>>0|Wk>>>0>>0)break c;if((Wk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0)break c;if((0|(Uk=q[Tk+108>>2]))<0)break c;if((Xk=q[Yk+480>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0)break c;if((Uk=(Wk=Uk<<2)+Xk|0)>>>0>>0|Vk>>>0>>0)break c;if((Xk=q[Yk+484>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0)break c;if((Uk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0)break c;if((Xk=q[Yk+488>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0)break c;if((Wk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0)break c;if((0|(Uk=q[Tk+112>>2]))<0)break c;if((Xk=q[Yk+504>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0)break c;if((Uk=(Wk=Uk<<2)+Xk|0)>>>0>>0|Vk>>>0>>0)break c;if((Xk=q[Yk+508>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0)break c;if((Uk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0)break c;if((Xk=q[Yk+512>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0)break c;if((Wk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0)break c;if((0|(Uk=q[Tk+116>>2]))<0)break c;if((Xk=q[Yk+528>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0)break c;if((Wk=Xk+(Uk<<2)|0)>>>0>>0|Vk>>>0>>0)break c;if((0|(Uk=q[Tk+120>>2]))<0)break c;if((Xk=q[Yk+532>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0)break c;if((Uk=(Wk=Uk<<2)+Xk|0)>>>0>>0|Vk>>>0>>0)break c;if((Xk=q[Yk+536>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0)break c;if((Uk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0)break c;if((Xk=q[Yk+540>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0)break c;if((Wk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0)break c;if((0|(Uk=q[Tk+124>>2]))<0)break c;if((Xk=q[Yk+544>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0)break c;if((Wk=(Uk<<=2)+Xk|0)>>>0>>0|Vk>>>0>>0)break c;if((Xk=q[Yk+548>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0)break c;if((Uk=Uk+Xk|0)>>>0>>0|Vk>>>0>>0)break c}}if(gl>>>0<5)break b;if(!((Xk=q[Yk+348>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0||(Wk=Xk+jl|0)>>>0>>0|Vk>>>0>>0||(Xk=q[Yk+352>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0||(Wk=Xk+jl|0)>>>0>>0|Vk>>>0>>0||(Xk=q[Yk+384>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0||(Wk=Xk+Zk|0)>>>0>>0|Vk>>>0>>0||(Xk=q[Yk+388>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0||(Wk=Xk+Zk|0)>>>0>>0|Vk>>>0>>0||(Xk=q[Yk+404>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0||(Wk=Xk+el|0)>>>0>>0|Vk>>>0>>0||(Xk=q[Yk+408>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0||(Wk=Xk+el|0)>>>0>>0|Vk>>>0>>0||(0|(Uk=q[Tk+128>>2]))<0||(Xk=q[Yk+468>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0||(Uk=(Wk=Uk<<2)+Xk|0)>>>0>>0|Vk>>>0>>0||(Xk=q[Yk+472>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0||(Uk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0||(Xk=q[Yk+476>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0||(Wk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0||(0|(Uk=q[Tk+132>>2]))<0||(Xk=q[Yk+492>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0||(Uk=(Wk=Uk<<2)+Xk|0)>>>0>>0|Vk>>>0>>0||(Xk=q[Yk+496>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0||(Uk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0||(Xk=q[Yk+500>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0||(Wk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0||(0|(Uk=q[Tk+136>>2]))<0||(Xk=q[Yk+516>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0||(Uk=(Wk=Uk<<2)+Xk|0)>>>0>>0|Vk>>>0>>0||(Xk=q[Yk+520>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0||(Uk=Wk+Xk|0)>>>0>>0|Vk>>>0>>0||(Xk=q[Yk+524>>2])>>>0>>0|Vk>>>0>>0|Xk>>>0>>0||(Xk=Wk+Xk|0)>>>0>>0)&&Xk>>>0<=Vk>>>0)break b}}Y(4,1760,0),da(ll,1),X(a- -64|0,4,160);break a}hl||(ya(a),o[a+5|0]=0,Tk=q[Yk+64>>2],bl=q[Tk>>2],_k=q[Yk+76>>2],gl=r[a+4|0]);f:{if((a=0)<(0|bl)){for(;;){if(63>>0)break f;if((0|bl)==(0|(a=a+1|0)))break}if(Uk=Tk+48|0,(Vk=0)<(0|(a=q[Tk>>2]))){for(Xk=q[Tk+48>>2],Wk=q[Yk+80>>2];;){if((0|(_k=q[Wk+(Vk<<2)>>2]))<0|(0|Xk)<=(0|_k))break f;if((0|a)==(0|(Vk=Vk+1|0)))break}for(el=Tk+24|0,Xk=q[Tk+24>>2],Zk=q[Yk+88>>2],bl=q[Yk+84>>2],Vk=0;;){if(Wk=q[(_k=Vk<<2)+Zk>>2]){if((0|Wk)<0|(0|Xk)<(0|Wk))break f;if((0|(_k=q[_k+bl>>2]))<0|(0|Xk)<=(0|_k))break f;if((Wk=Wk+_k|0)>>>31|(0|Xk)<(0|Wk))break f}if((0|a)==(0|(Vk=Vk+1|0)))break}for(Vk=0,Xk=q[Yk+92>>2];;){if(1>2])break f;if((0|a)==(0|(Vk=Vk+1|0)))break}for(Vk=0,Xk=q[Yk+96>>2];;){if(1>2])break f;if((0|a)==(0|(Vk=Vk+1|0)))break}for(Vk=0,Xk=q[Yk+100>>2];;){if((0|(Wk=q[Xk+(Vk<<2)>>2]))<-1|(0|a)<=(0|Wk))break f;if((0|a)==(0|(Vk=Vk+1|0)))break}}else el=Tk+24|0}else el=Tk+24|0,Uk=Tk+48|0;if((a=0)<(0|(Vk=q[Tk+4>>2]))){for(Xk=q[Yk+108>>2];;){if(63>>0)break f;if((0|Vk)==(0|(a=a+1|0)))break}if(Zk=(Xk=q[Tk+48>>2])+-1|0,!(((Vk=0)|(a=q[Tk+4>>2]))<=0)){for(Wk=q[Yk+112>>2];;){if((0|(_k=q[Wk+(Vk<<2)>>2]))<0|(0|Xk)<=(0|_k))break f;if((0|a)==(0|(Vk=Vk+1|0)))break}for(Vk=0,Xk=q[Yk+116>>2];;){if(1>2])break f;if((0|a)==(0|(Vk=Vk+1|0)))break}for(Vk=0,Xk=q[Yk+120>>2];;){if(1>2])break f;if((0|a)==(0|(Vk=Vk+1|0)))break}for(Xk=q[Tk>>2],Vk=0,Wk=q[Yk+124>>2];;){if((0|(_k=q[Wk+(Vk<<2)>>2]))<-1|(0|Xk)<=(0|_k))break f;if((0|a)==(0|(Vk=Vk+1|0)))break}for(Vk=0,Xk=q[Yk+128>>2];;){if((0|(Wk=q[Xk+(Vk<<2)>>2]))<-1|(0|a)<=(0|Wk))break f;if((0|a)==(0|(Vk=Vk+1|0)))break}for(Vk=0,Xk=q[Yk+132>>2];;){if(1>2])break f;if((0|a)==(0|(Vk=Vk+1|0)))break}for(Wk=Tk+8|0,_k=Tk+12|0,bl=q[Yk+136>>2],Vk=0;;){if(1<(dl=q[(cl=Vk<<2)+Xk>>2])>>>0)break f;if((0|(cl=q[bl+cl>>2]))<0|(0|cl)>=q[(dl-1|0?Wk:_k)>>2])break f;if((0|a)==(0|(Vk=Vk+1|0)))break}}}else Zk=q[Uk>>2]+-1|0;if((a=0)<(0|(Vk=q[Tk+8>>2]))){for(Xk=q[Yk+140>>2];;){if((0|(Wk=q[Xk+(a<<2)>>2]))<0|(0|Zk)<(0|Wk))break f;if((0|Vk)==(0|(a=a+1|0)))break}for(jl=Tk+28|0,Xk=q[Tk+28>>2],bl=q[Yk+148>>2],cl=q[Yk+144>>2],a=0;;){if(Wk=q[(_k=a<<2)+bl>>2]){if((0|Wk)<0|(0|Xk)<(0|Wk))break f;if((0|(_k=q[_k+cl>>2]))<0|(0|Xk)<=(0|_k))break f;if((Wk=Wk+_k|0)>>>31|(0|Xk)<(0|Wk))break f}if((0|Vk)==(0|(a=a+1|0)))break}for(a=0,Wk=q[Yk+156>>2],_k=q[Yk+164>>2],bl=q[Yk+160>>2];;){if((0|(cl=q[(Xk=a<<2)+bl>>2]))<1)break f;if((0|(dl=q[Xk+_k>>2]))<1)break f;if((0|(Xk=q[Wk+Xk>>2]))<1|(0|Xk)!=(0|w(dl+1|0,cl+1|0)))break f;if((0|Vk)==(0|(a=a+1|0)))break}}else jl=Tk+28|0;if((a=0)<(0|(Wk=q[Tk+12>>2]))){for(Vk=q[Yk+172>>2];;){if((0|(Xk=q[Vk+(a<<2)>>2]))<0|(0|Zk)<(0|Xk))break f;if((0|Wk)==(0|(a=a+1|0)))break}for($k=Tk+32|0,Vk=q[Tk+32>>2],Zk=q[Yk+180>>2],bl=q[Yk+176>>2],a=0;;){if(Xk=q[(_k=a<<2)+Zk>>2]){if((0|Xk)<0|(0|Vk)<(0|Xk))break f;if((0|(_k=q[_k+bl>>2]))<0|(0|Vk)<=(0|_k))break f;if((Xk=Xk+_k|0)>>>31|(0|Vk)<(0|Xk))break f}if((0|Wk)==(0|(a=a+1|0)))break}}else $k=Tk+32|0;Xk=Tk+16|0;m:{n:{if(!(((a=0)|(Vk=q[Tk+16>>2]))<=0)){for(Wk=q[Yk+208>>2];;){if(63>>0)break f;if((0|Vk)==(0|(a=a+1|0)))break}if(!(((Vk=0)|(a=q[Xk>>2]))<=0)){for(Wk=q[Uk>>2],_k=q[Yk+212>>2];;){if((0|(Zk=q[_k+(Vk<<2)>>2]))<0|(0|Wk)<=(0|Zk))break f;if((0|a)==(0|(Vk=Vk+1|0)))break}for(ll=Tk+36|0,Wk=q[Tk+36>>2],bl=q[Yk+220>>2],cl=q[Yk+216>>2],Vk=0;;){if(_k=q[(Zk=Vk<<2)+bl>>2]){if((0|_k)<0|(0|Wk)<(0|_k))break f;if((0|(Zk=q[Zk+cl>>2]))<0|(0|Wk)<=(0|Zk))break f;if((_k=_k+Zk|0)>>>31|(0|Wk)<(0|_k))break f}if((0|a)==(0|(Vk=Vk+1|0)))break}for(Vk=0,Wk=q[Yk+228>>2];;){if(1>2])break f;if((0|a)==(0|(Vk=Vk+1|0)))break}for(Vk=0,Wk=q[Yk+232>>2];;){if(1>2])break f;if((0|a)==(0|(Vk=Vk+1|0)))break}for(Wk=q[Tk>>2],Vk=0,_k=q[Yk+236>>2];;){if((0|(Zk=q[_k+(Vk<<2)>>2]))<-1|(0|Wk)<=(0|Zk))break f;if((0|a)==(0|(Vk=Vk+1|0)))break}for(Wk=q[Tk+4>>2],Vk=0,_k=q[Yk+240>>2];;){if((0|(Zk=q[_k+(Vk<<2)>>2]))<-1|(0|Wk)<=(0|Zk))break f;if((0|a)==(0|(Vk=Vk+1|0)))break}for(Wk=q[Yk+244>>2],Vk=0;;){if(q[Wk+(Vk<<2)>>2]<0)break f;if((0|a)==(0|(Vk=Vk+1|0)))break}break n}}_k=Tk+68|0,ll=Tk+36|0;break m}for(Wk=q[Yk+252>>2],Vk=0;;){if(q[Wk+(Vk<<2)>>2]<0)break f;if((0|a)==(0|(Vk=Vk+1|0)))break}for(_k=q[Tk+60>>2],Vk=0,Zk=q[Yk+256>>2];;){if((bl=q[(bl=Vk<<2)+Zk>>2]+(q[Wk+bl>>2]<<1)|0)>>>31|(0|_k)<(0|bl))break f;if((0|a)==(0|(Vk=Vk+1|0)))break}for(Wk=q[Tk+64>>2],bl=q[Yk+264>>2],cl=q[Yk+260>>2],Vk=0;;){if(_k=q[(Zk=Vk<<2)+bl>>2]){if((0|_k)<0|(0|Wk)<(0|_k))break f;if((0|(Zk=q[Zk+cl>>2]))<0|(0|Wk)<=(0|Zk))break f;if((_k=_k+Zk|0)>>>31|(0|Wk)<(0|_k))break f}if((0|a)==(0|(Vk=Vk+1|0)))break}for(_k=Tk+68|0,Wk=q[Tk+68>>2],cl=q[Yk+272>>2],dl=q[Yk+268>>2],Vk=0;;){if(Zk=q[(bl=Vk<<2)+cl>>2]){if((0|Zk)<0|(0|Wk)<(0|Zk))break f;if((0|(bl=q[bl+dl>>2]))<0|(0|Wk)<=(0|bl))break f;if((Zk=Zk+bl|0)>>>31|(0|Wk)<(0|Zk))break f}if((0|a)==(0|(Vk=Vk+1|0)))break}}p:{q:{if(!(((a=0)|(Vk=q[Tk+20>>2]))<=0)){for(Wk=q[Yk+280>>2];;){if(63>>0)break f;if((0|Vk)==(0|(a=a+1|0)))break}if(!(((a=0)|(Vk=q[Tk+20>>2]))<=0)){for(Wk=q[Yk+296>>2];;){if(1>2])break f;if((0|Vk)==(0|(a=a+1|0)))break}for(Wk=q[Yk+300>>2],a=0;;){if(q[Wk+(a<<2)>>2]<0)break f;if((0|Vk)==(0|(a=a+1|0)))break}break q}}a=q[Tk+52>>2];break p}for(a=q[Tk+52>>2],cl=q[Yk+312>>2],dl=q[Yk+308>>2],Zk=0;;){if(Wk=q[(bl=Zk<<2)+cl>>2]){if((0|Wk)<0|(0|a)<(0|Wk))break f;if((0|(bl=q[bl+dl>>2]))<0|(0|a)<=(0|bl))break f;if((Wk=Wk+bl|0)>>>31|(0|a)<(0|Wk))break f}if((0|Vk)==(0|(Zk=Zk+1|0)))break}}if(Wk=q[Tk+40>>2],(Vk=0)<(0|(Zk=q[Tk+8>>2])))for(bl=q[Yk+344>>2],cl=q[Yk+156>>2];;){if((dl=q[(dl=Vk<<2)+bl>>2]+(q[cl+dl>>2]<<1)|0)>>>31|(0|Wk)<(0|dl))break f;if((0|Zk)==(0|(Vk=Vk+1|0)))break}if((Vk=0)<(0|(Zk=q[$k>>2]))){for(bl=q[Yk+376>>2];;){if(1>2])break f;if((0|Zk)==(0|(Vk=Vk+1|0)))break}for(Vk=0,bl=q[Yk+380>>2];;){if(1>2])break f;if((0|Zk)==(0|(Vk=Vk+1|0)))break}}if((Vk=0)<(0|(Zk=q[Xk>>2])))for(bl=q[Yk+400>>2],cl=q[Yk+252>>2];;){if((dl=q[(dl=Vk<<2)+bl>>2]+(q[cl+dl>>2]<<1)|0)>>>31|(0|Wk)<(0|dl))break f;if((0|Zk)==(0|(Vk=Vk+1|0)))break}if((Vk=0)<(0|(Wk=q[Tk+44>>2])))for(bl=q[Yk+424>>2];;){if((0|(cl=q[bl+(Vk<<2)>>2]))<0|(0|a)<=(0|cl))break f;if((0|Wk)==(0|(Vk=Vk+1|0)))break}if(1<=(0|(cl=q[Uk>>2])))for(Vk=0,dl=q[Yk+432>>2],al=q[Yk+428>>2];;){if(Uk=q[(bl=Vk<<2)+dl>>2]){if((0|Uk)<0|(0|Wk)<(0|Uk))break f;if((0|(bl=q[al+bl>>2]))<0|(0|Wk)<=(0|bl))break f;if((Uk=Uk+bl|0)>>>31|(0|Wk)<(0|Uk))break f}if((0|cl)==(0|(Vk=Vk+1|0)))break}if(1<=(0|a))for(Wk=q[Tk+56>>2],Vk=0,cl=q[Yk+420>>2],dl=q[Yk+416>>2];;){if(Uk=q[(bl=Vk<<2)+cl>>2]){if((0|Uk)<0|(0|Wk)<(0|Uk))break f;if((0|(bl=q[bl+dl>>2]))<0|(0|Wk)<=(0|bl))break f;if((Uk=Uk+bl|0)>>>31|(0|Wk)<(0|Uk))break f}if((0|(Vk=Vk+1|0))==(0|a))break}if((a=0)<(0|(Vk=q[_k>>2])))for(Wk=q[Yk+564>>2];;){if((0|(_k=q[Wk+(a<<2)>>2]))<-1|(0|Zk)<=(0|_k))break f;if((0|Vk)==(0|(a=a+1|0)))break}if(a=q[Tk+76>>2],1<=(0|(_k=q[Tk+72>>2])))for(Vk=0,Zk=q[Yk+572>>2],bl=q[Yk+568>>2];;){if(Wk=q[(Uk=Vk<<2)+Zk>>2]){if((0|Wk)<0|(0|a)<(0|Wk))break f;if((0|(Uk=q[Uk+bl>>2]))<0|(0|a)<=(0|Uk))break f;if((Wk=Uk+Wk|0)>>>31|(0|a)<(0|Wk))break f}if((0|_k)==(0|(Vk=Vk+1|0)))break}if((Vk=0)<(0|a)){for(Wk=q[Yk+588>>2];;){if(1>2])break f;if((0|a)==(0|(Vk=Vk+1|0)))break}for(Uk=q[Yk+592>>2],Vk=0;;){if(1<(bl=q[(Zk=Vk<<2)+Wk>>2])>>>0)break f;if((0|(Zk=q[Uk+Zk>>2]))<0|(0|Zk)>=q[(bl-1|0?Xk:Tk)>>2])break f;if((0|a)==(0|(Vk=Vk+1|0)))break}for(Vk=0,Wk=q[Yk+596>>2];;){if((0|(Uk=q[Wk+(Vk<<2)>>2]))<-1|(0|_k)<=(0|Uk))break f;if((0|a)==(0|(Vk=Vk+1|0)))break}}s:{if(!(((a=0)|(Zk=q[Tk+80>>2]))<=0)){for(Vk=q[Yk+604>>2];;){if(63>>0)break f;if((0|Zk)==(0|(a=a+1|0)))break}if(!(((a=0)|(Zk=q[Tk+80>>2]))<=0)){for(Vk=q[Tk+48>>2],Wk=q[Yk+608>>2];;){if((0|(_k=q[Wk+(a<<2)>>2]))<0|(0|Vk)<=(0|_k))break f;if((0|Zk)==(0|(a=a+1|0)))break}for(cl=q[Tk+88>>2],_k=q[Yk+616>>2],Uk=q[Yk+612>>2],a=0;;){if(Vk=q[(Wk=a<<2)+_k>>2]){if((0|Vk)<0|(0|cl)<(0|Vk))break f;if((0|(Wk=q[Uk+Wk>>2]))<0|(0|cl)<=(0|Wk))break f;if((Vk=Vk+Wk|0)>>>31|(0|cl)<(0|Vk))break f}if((0|Zk)==(0|(a=a+1|0)))break}for(Xk=q[Xk>>2],_k=q[Yk+620>>2],a=0;;){if((0|(Vk=q[_k+(a<<2)>>2]))<0|(0|Xk)<=(0|Vk))break f;if((0|Zk)==(0|(a=a+1|0)))break}for(Uk=q[Yk+624>>2],a=0;;){if((0|(Vk=q[Uk+(a<<2)>>2]))<0|(0|Xk)<=(0|Vk))break f;if((0|Zk)==(0|(a=a+1|0)))break}for(Vk=q[Tk+84>>2],bl=q[Yk+632>>2],dl=q[Yk+628>>2],a=0;;){if(Wk=q[(al=a<<2)+bl>>2]){if((0|Wk)<0|(0|Vk)<(0|Wk))break f;if((0|(al=q[al+dl>>2]))<0|(0|Vk)<=(0|al))break f;if((Wk=Wk+al|0)>>>31|(0|Vk)<(0|Wk))break f}if((0|Zk)==(0|(a=a+1|0)))break}for(fl=q[Yk+640>>2],Vk=q[Yk+252>>2],Wk=0;;){if(0<(0|(hl=q[(a=Wk<<2)+bl>>2])))for(al=fl+(q[a+dl>>2]<<1)|0,ml=q[Vk+(q[a+Uk>>2]<<2)>>2],il=q[Vk+(q[a+_k>>2]<<2)>>2],a=0;;){if((0|ml)<=s[al+(2|(kl=a<<1))>>1]|(0|il)<=s[al+kl>>1])break f;if(!((0|(a=a+2|0))<(0|hl)))break}if((0|Zk)==(0|(Wk=Wk+1|0)))break}break s}}Xk=q[Tk+16>>2],cl=q[Tk+88>>2]}if(!((255&gl)>>>0<2)){if((a=0)<(0|(bl=q[Tk+8>>2])))for(Vk=q[Yk+168>>2];;){if(1>2])break f;if((0|bl)==(0|(a=a+1|0)))break}if(!((255&gl)>>>0<4)){if(_k=q[Tk+56>>2],1<=(0|(dl=q[Tk+20>>2])))for(Uk=q[Yk+332>>2],al=q[Yk+328>>2],a=0;;){if(Vk=q[(Wk=a<<2)+Uk>>2]){if((0|Vk)<0|(0|_k)<(0|Vk))break f;if((0|(Wk=q[Wk+al>>2]))<0|(0|_k)<=(0|Wk))break f;if((Vk=Vk+Wk|0)>>>31|(0|_k)<(0|Vk))break f}if((0|dl)==(0|(a=a+1|0)))break}if((0|(a=q[Tk+92>>2]))!=q[Tk+96>>2])break f;if(1<=(0|bl))for(al=q[Yk+152>>2],Vk=0,fl=q[Yk+148>>2];;){if(Wk=q[(Uk=Vk<<2)+fl>>2]){if((0|Wk)<0|(0|a)<(0|Wk))break f;if((0|(Uk=q[Uk+al>>2]))<0|(0|a)<=(0|Uk))break f;if((Wk=Uk+Wk|0)>>>31|(0|a)<(0|Wk))break f}if((0|bl)==(0|(Vk=Vk+1|0)))break}if(1<=(0|(ml=q[Tk+12>>2])))for(al=q[Yk+184>>2],Vk=0,fl=q[Yk+180>>2];;){if(Wk=q[(Uk=Vk<<2)+fl>>2]){if((0|Wk)<0|(0|a)<(0|Wk))break f;if((0|(Uk=q[Uk+al>>2]))<0|(0|a)<=(0|Uk))break f;if((Wk=Uk+Wk|0)>>>31|(0|a)<(0|Wk))break f}if((0|ml)==(0|(Vk=Vk+1|0)))break}if(1<=(0|Xk))for(al=q[Yk+224>>2],Vk=0,fl=q[Yk+220>>2];;){if(Wk=q[(Uk=Vk<<2)+fl>>2]){if((0|Wk)<0|(0|a)<(0|Wk))break f;if((0|(Uk=q[Uk+al>>2]))<0|(0|a)<=(0|Uk))break f;if((Wk=Uk+Wk|0)>>>31|(0|a)<(0|Wk))break f}if((0|Xk)==(0|(Vk=Vk+1|0)))break}if((Vk=0)<(0|dl)){for(Wk=q[Yk+304>>2];;){if(1>2])break f;if((0|dl)==(0|(Vk=Vk+1|0)))break}for(Wk=q[Tk+100>>2],fl=q[Yk+320>>2],hl=q[Yk+316>>2],Vk=0;;){if(Uk=q[(al=Vk<<2)+fl>>2]){if((0|Uk)<0|(0|Wk)<(0|Uk))break f;if((0|(al=q[al+hl>>2]))<0|(0|Wk)<=(0|al))break f;if((Uk=Uk+al|0)>>>31|(0|Wk)<(0|Uk))break f}if((0|dl)==(0|(Vk=Vk+1|0)))break}}else Wk=q[Tk+100>>2];if(1<=(0|Wk)){for(al=q[Yk+440>>2],Vk=0,hl=q[Yk+436>>2];;){if(Uk=q[(fl=Vk<<2)+al>>2]){if((0|Uk)<0|(0|_k)<(0|Uk))break f;if((0|(fl=q[fl+hl>>2]))<0|(0|_k)<=(0|fl))break f;if((Uk=Uk+fl|0)>>>31|(0|_k)<(0|Uk))break f}if((0|Wk)==(0|(Vk=Vk+1|0)))break}for(_k=q[Yk+444>>2],Vk=0;;){if((0|(fl=q[(Uk=Vk<<2)+_k>>2]))<0|(0|fl)>=q[Uk+al>>2])break f;if((0|Wk)==(0|(Vk=Vk+1|0)))break}}if((_k=0)<(0|(Vk=q[Tk+104>>2]))){for(Uk=q[Yk+448>>2];;){if((0|(al=q[Uk+(_k<<2)>>2]))<0|(0|Wk)<=(0|al))break f;if((0|Vk)==(0|(_k=_k+1|0)))break}for(Uk=q[Tk+116>>2],fl=q[Yk+464>>2],hl=q[Yk+460>>2],Wk=0;;){if(_k=q[(al=Wk<<2)+fl>>2]){if((0|_k)<0|(0|Uk)<(0|_k))break f;if((0|(al=q[al+hl>>2]))<0|(0|Uk)<=(0|al))break f;if((_k=_k+al|0)>>>31|(0|Uk)<(0|_k))break f}if((0|Vk)==(0|(Wk=Wk+1|0)))break}}else Uk=q[Tk+116>>2];if((Wk=0)<(0|(al=q[Tk+108>>2]))){for(_k=q[Yk+480>>2];;){if((0|(fl=q[_k+(Wk<<2)>>2]))<0|(0|bl)<=(0|fl))break f;if((0|al)==(0|(Wk=Wk+1|0)))break}for(fl=q[Yk+488>>2],il=q[Yk+484>>2],Wk=0;;){if(_k=q[(bl=Wk<<2)+fl>>2]){if((0|_k)<0|(0|Vk)<(0|_k))break f;if((0|(bl=q[bl+il>>2]))<0|(0|Vk)<=(0|bl))break f;if((_k=_k+bl|0)>>>31|(0|Vk)<(0|_k))break f}if((0|al)==(0|(Wk=Wk+1|0)))break}for(fl=q[jl>>2],Wk=q[Yk+456>>2],bl=q[Yk+452>>2],_k=0;;){if(kl=q[il+(_k<<2)>>2]<<2,hl=q[kl+Wk>>2]){if((0|hl)<0|(0|fl)<(0|hl))break f;if((0|(kl=q[bl+kl>>2]))<0|(0|fl)<=(0|kl))break f;if((0|(hl=hl+kl|0))<0|(0|fl)<(0|hl))break f}if((0|al)==(0|(_k=_k+1|0)))break}}else Wk=q[Yk+456>>2],bl=q[Yk+452>>2];if((_k=0)<(0|(al=q[Tk+112>>2]))){for(fl=q[Yk+504>>2];;){if((0|(hl=q[fl+(_k<<2)>>2]))<0|(0|Xk)<=(0|hl))break f;if((0|al)==(0|(_k=_k+1|0)))break}for(il=q[Yk+512>>2],hl=q[Yk+508>>2],Xk=0;;){if(_k=q[(fl=Xk<<2)+il>>2]){if((0|_k)<0|(0|Vk)<(0|_k))break f;if((0|(fl=q[fl+hl>>2]))<0|(0|Vk)<=(0|fl))break f;if((_k=_k+fl|0)>>>31|(0|Vk)<(0|_k))break f}if((0|al)==(0|(Xk=Xk+1|0)))break}for(_k=q[ll>>2],Xk=0;;){if(il=q[hl+(Xk<<2)>>2]<<2,fl=q[il+Wk>>2]){if((0|fl)<0|(0|_k)<(0|fl))break f;if((0|(il=q[bl+il>>2]))<0|(0|_k)<=(0|il))break f;if((0|(fl=fl+il|0))<0|(0|_k)<(0|fl))break f}if((0|al)==(0|(Xk=Xk+1|0)))break}}if(_k=q[Tk+120>>2],(Xk=0)<(0|Uk))for(al=q[Yk+528>>2];;){if((0|(fl=q[al+(Xk<<2)>>2]))<0|(0|_k)<=(0|fl))break f;if((0|(Xk=Xk+1|0))==(0|Uk))break}if((Xk=0)<(0|_k)){for(Uk=q[Yk+532>>2];;){if((0|(al=q[Uk+(Xk<<2)>>2]))<-1|(0|dl)<=(0|al))break f;if((0|_k)==(0|(Xk=Xk+1|0)))break}for(Uk=q[Tk+124>>2],fl=q[Yk+540>>2],hl=q[Yk+536>>2],Xk=0;;){if(dl=q[(al=Xk<<2)+fl>>2]){if((0|dl)<0|(0|Uk)<(0|dl))break f;if((0|(al=q[al+hl>>2]))<0|(0|Uk)<=(0|al))break f;if((dl=al+dl|0)>>>31|(0|Uk)<(0|dl))break f}if((0|_k)==(0|(Xk=Xk+1|0)))break}}if(!((255&gl)>>>0<5)){if((Xk=0)<(0|(gl=q[jl>>2]))){for(_k=q[Yk+348>>2];;){if((0|(Uk=q[_k+(Xk<<2)>>2]))<0|(0|a)<(0|Uk))break f;if((0|gl)==(0|(Xk=Xk+1|0)))break}for(_k=q[Yk+352>>2],Xk=0;;){if((0|(Uk=q[_k+(Xk<<2)>>2]))<0|(0|a)<(0|Uk))break f;if((0|gl)==(0|(Xk=Xk+1|0)))break}}if((gl=0)<(0|(Xk=q[$k>>2]))){for(_k=q[Yk+384>>2];;){if((0|(Uk=q[_k+(gl<<2)>>2]))<0|(0|a)<(0|Uk))break f;if((0|Xk)==(0|(gl=gl+1|0)))break}for(_k=q[Yk+388>>2],gl=0;;){if((0|(Uk=q[_k+(gl<<2)>>2]))<0|(0|a)<(0|Uk))break f;if((0|Xk)==(0|(gl=gl+1|0)))break}}if((gl=0)<(0|(_k=q[ll>>2]))){for(Uk=q[Yk+404>>2];;){if((0|(dl=q[Uk+(gl<<2)>>2]))<0|(0|a)<(0|dl))break f;if((0|_k)==(0|(gl=gl+1|0)))break}for(Uk=q[Yk+408>>2],gl=0;;){if((0|(dl=q[Uk+(gl<<2)>>2]))<0|(0|a)<(0|dl))break f;if((0|_k)==(0|(gl=gl+1|0)))break}}if((a=0)<(0|(gl=q[Tk+128>>2]))){for(_k=q[Tk>>2],Uk=q[Yk+468>>2];;){if((0|(dl=q[Uk+(a<<2)>>2]))<0|(0|_k)<=(0|dl))break f;if((0|gl)==(0|(a=a+1|0)))break}for(jl=q[Yk+476>>2],dl=q[Yk+472>>2],a=0;;){if(_k=q[(Uk=a<<2)+jl>>2]){if((0|_k)<0|(0|Vk)<(0|_k))break f;if((0|(Uk=q[Uk+dl>>2]))<0|(0|Vk)<=(0|Uk))break f;if((_k=Uk+_k|0)>>>31|(0|Vk)<(0|_k))break f}if((0|gl)==(0|(a=a+1|0)))break}for(_k=q[el>>2],a=0;;){if(el=q[dl+(a<<2)>>2]<<2,Uk=q[el+Wk>>2]){if((0|Uk)<0|(0|_k)<(0|Uk))break f;if((0|(el=q[bl+el>>2]))<0|(0|_k)<=(0|el))break f;if((0|(Uk=Uk+el|0))<0|(0|_k)<(0|Uk))break f}if((0|gl)==(0|(a=a+1|0)))break}}if((a=0)<(0|(gl=q[Tk+132>>2]))){for(_k=q[Yk+492>>2];;){if((0|(Uk=q[_k+(a<<2)>>2]))<0|(0|ml)<=(0|Uk))break f;if((0|gl)==(0|(a=a+1|0)))break}for(el=q[Yk+500>>2],Uk=q[Yk+496>>2],a=0;;){if(_k=q[(dl=a<<2)+el>>2]){if((0|_k)<0|(0|Vk)<(0|_k))break f;if((0|(dl=q[Uk+dl>>2]))<0|(0|Vk)<=(0|dl))break f;if((_k=_k+dl|0)>>>31|(0|Vk)<(0|_k))break f}if((0|gl)==(0|(a=a+1|0)))break}for(a=0;;){if(dl=q[Uk+(a<<2)>>2]<<2,_k=q[dl+Wk>>2]){if((0|_k)<0|(0|Xk)<(0|_k))break f;if((0|(dl=q[bl+dl>>2]))<0|(0|Xk)<=(0|dl))break f;if((0|(_k=_k+dl|0))<0|(0|Xk)<(0|_k))break f}if((0|gl)==(0|(a=a+1|0)))break}}if(!(((a=0)|(Tk=q[Tk+136>>2]))<=0)){for(Xk=q[Yk+516>>2];;){if((0|(gl=q[Xk+(a<<2)>>2]))<0|(0|Zk)<=(0|gl))break f;if((0|Tk)==(0|(a=a+1|0)))break}for(_k=q[Yk+524>>2],Xk=q[Yk+520>>2],a=0;;){if(Yk=q[(gl=a<<2)+_k>>2]){if((0|Yk)<0|(0|Vk)<(0|Yk))break f;if((0|(gl=q[Xk+gl>>2]))<0|(0|Vk)<=(0|gl))break f;if((Yk=Yk+gl|0)>>>31|(0|Vk)<(0|Yk))break f}if((0|Tk)==(0|(a=a+1|0)))break}for(a=0;;){if(Yk=q[Xk+(a<<2)>>2]<<2,Vk=q[Yk+Wk>>2]){if((0|Vk)<0|(0|cl)<(0|Vk))break f;if((0|(Yk=q[Yk+bl>>2]))<0|(0|cl)<=(0|Yk))break f;if((0|(Vk=Vk+Yk|0))<0|(0|cl)<(0|Vk))break f}if((0|Tk)==(0|(a=a+1|0)))break}}}}}return L=nl,1}return Y(4,1846,0),L=nl,0}q[Yk+52>>2]=Vk,q[Yk+48>>2]=5,Y(4,1640,Yk+48|0)}else q[Yk+32>>2]=Vk,Y(4,1554,Yk+32|0);return L=nl,0}(a,tj):(q[20+uj>>2]=1621,q[16+uj>>2]=2284,Y(4,1294,16+uj|0),0):(q[4+uj>>2]=1444,q[uj>>2]=2284,Y(4,1294,uj),0),L=48+uj|0,0|a},j:function(a){q[1813]=a|=0},k:function(a,cj){var dj;return cj|=0,L=dj=L-48|0,a=(a|=0)?(a+63&-64)!=(0|a)?(q[36+dj>>2]=1522,q[32+dj>>2]=2305,Y(4,1294,32+dj|0),0):(cj+63&-64)==(0|cj)&&cj?function(a){var Ok,Rk,Sk,Rh,Ik=0,Jk=0,Kk=0,Lk=0,Mk=0,Nk=0,Pk=0,Qk=0;q[24+(L=Ok=L-32|0)>>2]=0,q[16+Ok>>2]=5,q[20+Ok>>2]=1,Ka(16+(L=Rh=L-272|0)|0,2227,q[12+Rh>>2]=16+Ok|0),function(a){var Dc;q[(L=Dc=L-16|0)>>2]=a,function(a,Gl){var Hl;q[12+(L=Hl=L-16|0)>>2]=Gl,Ia(a,1432,Gl,0,0),L=16+Hl|0}(q[978],Dc),L=16+Dc|0}(16+Rh|0),L=272+Rh|0;a:{if(sa(a))Y(4,1932,0);else{if(!(6<=(Kk=r[a+4|0])>>>0)){if(1!=(0|!r[a+5|0])?(da(a+4|0,1),X(a- -64|0,4,160),na(a,a+704|(o[a+5|0]=0)),ya(a)):na(a,a+704|0),r[7256]||(q[1815]=6,o[7256]=1,q[1816]=7,q[1817]=8,q[1818]=9),Jk=q[a+704>>2],1<=(0|(Kk=q[Jk+16>>2]))){for(Qk=(Lk=q[a+912>>2])+(Kk<<2)|0,Mk=q[a+908>>2];;){Pk=q[a+1204>>2]+(q[Mk>>2]<<2)|0;d:if(!(((Jk=0)|(Ik=(Kk=q[Lk>>2])+-1|0))<1))e:for(;;){for(;;){if(q[(Nk=Pk+(Jk<<2)|0)>>2]<=-1){if(function(a,Tk,ol){var pl=0,ql=0;a:if((0|a)!=(0|Tk)){if(!(a>>>0>>0&&Tk>>>0<(ql=a+ol|0)>>>0))return $(a,Tk,ol);if(pl=3&(a^Tk),a>>>0>>0){if(!pl){if(3&a)for(;;){if(!ol)break a;if(o[0|a]=r[0|Tk],Tk=Tk+1|0,ol=ol+-1|0,!(3&(a=a+1|0)))break}if(!(ol>>>0<=3)){for(pl=ol;q[a>>2]=q[Tk>>2],Tk=Tk+4|0,a=a+4|0,3<(pl=pl+-4|0)>>>0;);ol&=3}}if(ol)for(;o[0|a]=r[0|Tk],a=a+1|0,Tk=Tk+1|0,ol=ol+-1|0;);}else{if(!pl){if(3&ql)for(;;){if(!ol)break a;if(o[0|(pl=(ol=ol+-1|0)+a|0)]=r[Tk+ol|0],!(3&pl))break}if(!(ol>>>0<=3))for(;q[(ol=ol+-4|0)+a>>2]=q[Tk+ol>>2],3
    >>0;);}if(ol)for(;o[(ol=ol+-1|0)+a|0]=r[Tk+ol|0],ol;);}}}(Nk,Nk+4|0,(-1^Jk)+Kk<<2),(0|Jk)<(0|(Ik=(Kk=Ik)+-1|0)))continue e;break d}if(!((0|(Jk=Jk+1|0))<(0|Ik)))break}break}if(Jk=Lk,0<(0|Kk)&&(Kk=q[Pk+(Ik<<2)>>2]<0?Ik:Kk),q[Jk>>2]=Kk,Mk=Mk+4|0,!((Lk=Lk+4|0)>>>0>>0))break}Jk=q[a+704>>2]}if(1<=q[Jk>>2])for(Ik=0;q[q[a+712>>2]+(Ik<<2)>>2]=q[a+716>>2]+(Ik<<6),Jk=q[a+704>>2],(0|(Ik=Ik+1|0))>2];);if(1<=q[Jk+4>>2])for(Ik=0;q[q[a+744>>2]+(Ik<<2)>>2]=q[a+748>>2]+(Ik<<6),Jk=q[a+704>>2],(0|(Ik=Ik+1|0))>2];);if(1<=q[Jk+16>>2])for(Ik=0;q[(Kk=Ik<<2)+q[a+832>>2]>>2]=q[a+848>>2]+(Ik<<6),q[Kk+q[a+836>>2]>>2]=q[a+1196>>2]+(q[Kk+q[a+896>>2]>>2]<<2),q[Kk+q[a+840>>2]>>2]=q[a+1200>>2]+(q[Kk+q[a+900>>2]>>2]<<1),q[Kk+q[a+844>>2]>>2]=q[a+1204>>2]+(q[Kk+q[a+908>>2]>>2]<<2),Jk=q[a+704>>2],(0|(Ik=Ik+1|0))>2];);if(1<=q[Jk+20>>2])for(Ik=0;q[q[a+916>>2]+(Ik<<2)>>2]=q[a+920>>2]+(Ik<<6),Jk=q[a+704>>2],(0|(Ik=Ik+1|0))>2];);if(1<=q[Jk+80>>2])for(Ik=0;q[q[a+1240>>2]+(Ik<<2)>>2]=q[a+1244>>2]+(Ik<<6),Jk=q[a+704>>2],(0|(Ik=Ik+1|0))>2];);if(1&o[q[a+708>>2]+20|0])break a;if((0|(Lk=q[Jk+16>>2]))<1)break a;for(Ik=q[a+904>>2],Pk=q[a+900>>2],Nk=q[a+1200>>2],Mk=0;;){if(0<(0|(Qk=q[(Kk=Mk<<2)+Ik>>2]+-1|0)))for(Rk=Nk+(q[Kk+Pk>>2]<<1)|0,Jk=0;Sk=s[(Kk=Rk+(Jk<<1)|0)>>1],p[Kk>>1]=s[Kk+4>>1],p[Kk+4>>1]=Sk,(0|(Jk=Jk+3|0))<(0|Qk););if((0|Lk)==(0|(Mk=Mk+1|0)))break}for(Kk=q[a+892>>2],Mk=q[a+896>>2],Pk=q[a+1196>>2],Ik=0;;){if(1<=(0|(Nk=q[(Jk=Ik<<2)+Kk>>2])))for(Nk=(Jk=Pk+(q[Jk+Mk>>2]<<2)|0)+(Nk<<3)|0,Jk=Jk+4|0;u[Jk>>2]=x(1)-u[Jk>>2],(Jk=Jk+8|0)>>>0>>0;);if((0|Lk)==(0|(Ik=Ik+1|0)))break}break a}q[4+Ok>>2]=Kk,q[Ok>>2]=5,Y(4,2023,Ok)}a=0}return L=32+Ok|0,a}(a):(q[20+dj>>2]=1621,q[16+dj>>2]=2305,Y(4,1294,16+dj|0),0):(q[4+dj>>2]=1444,q[dj>>2]=2305,Y(4,1294,dj),0),L=48+dj|0,0|a},l:function(a,cj,dj,ej){var fj;cj|=0,dj|=0,ej|=0,L=fj=L+-64|0,(a|=0)?cj?dj?ej?(a=q[q[a>>2]+708>>2],q[cj>>2]=q[a+12>>2],q[cj+4>>2]=q[a+16>>2],q[dj>>2]=q[a+4>>2],q[dj+4>>2]=q[a+8>>2],q[ej>>2]=q[a>>2]):(q[52+fj>>2]=1995,q[48+fj>>2]=2325,Y(4,1294,48+fj|0)):(q[36+fj>>2]=1903,q[32+fj>>2]=2325,Y(4,1294,32+fj|0)):(q[20+fj>>2]=1819,q[16+fj>>2]=2325,Y(4,1294,16+fj|0)):(q[4+fj>>2]=1740,q[fj>>2]=2325,Y(4,1294,fj)),L=64+fj|0},m:xa,n:wa,o:function(a){var bj;L=bj=L-16|0,(a|=0)?ua(a):(q[4+bj>>2]=1740,q[bj>>2]=2387,Y(4,1294,bj)),L=16+bj|0},p:function(a){var aj;return L=aj=L-16|0,a=(a|=0)?q[a+540>>2]:(q[4+aj>>2]=1740,q[aj>>2]=2402,Y(4,1294,aj),-1),L=16+aj|0,0|a},q:function(a){var $i;return L=$i=L-16|0,a=(a|=0)?q[q[a>>2]+916>>2]:(q[4+$i>>2]=1740,q[$i>>2]=2423,Y(4,1294,$i),0),L=16+$i|0,0|a},r:function(a){var _i;return L=_i=L-16|0,a=(a|=0)?q[a+548>>2]:(q[4+_i>>2]=1740,q[_i>>2]=2442,Y(4,1294,_i),0),L=16+_i|0,0|a},s:function(a){var Zi;return L=Zi=L-16|0,a=(a|=0)?q[q[a>>2]+928>>2]:(q[4+Zi>>2]=1740,q[Zi>>2]=2463,Y(4,1294,Zi),0),L=16+Zi|0,0|a},t:function(a){var Yi;return L=Yi=L-16|0,a=(a|=0)?q[q[a>>2]+924>>2]:(q[4+Yi>>2]=1740,q[Yi>>2]=2492,Y(4,1294,Yi),0),L=16+Yi|0,0|a},u:function(a){var Xi;return L=Xi=L-16|0,a=(a|=0)?q[q[a>>2]+932>>2]:(q[4+Xi>>2]=1740,q[Xi>>2]=2521,Y(4,1294,Xi),0),L=16+Xi|0,0|a},v:function(a){var Wi;return L=Wi=L-16|0,a=(a|=0)?q[a+552>>2]:(q[4+Wi>>2]=1740,q[Wi>>2]=2550,Y(4,1294,Wi),0),L=16+Wi|0,0|a},w:function(a){var Vi;return L=Vi=L-16|0,a=(a|=0)?q[q[a>>2]+936>>2]:(q[4+Vi>>2]=1740,q[Vi>>2]=2572,Y(4,1294,Vi),0),L=16+Vi|0,0|a},x:function(a){var Ui;return L=Ui=L-16|0,a=(a|=0)?q[a+4>>2]:(q[4+Ui>>2]=1740,q[Ui>>2]=2595,Y(4,1294,Ui),-1),L=16+Ui|0,0|a},y:function(a){var Ti;return L=Ti=L-16|0,a=(a|=0)?q[q[a>>2]+712>>2]:(q[4+Ti>>2]=1740,q[Ti>>2]=2611,Y(4,1294,Ti),0),L=16+Ti|0,0|a},z:function(a){var Si;return L=Si=L-16|0,a=(a|=0)?q[a+52>>2]:(q[4+Si>>2]=1740,q[Si>>2]=2625,Y(4,1294,Si),0),L=16+Si|0,0|a},A:function(a){var Ri;return L=Ri=L-16|0,a=(a|=0)?q[q[a>>2]+740>>2]:(q[4+Ri>>2]=1740,q[Ri>>2]=2645,Y(4,1294,Ri),0),L=16+Ri|0,0|a},B:function(a){var Qi;return L=Qi=L-16|0,a=(a|=0)?q[a+332>>2]:(q[4+Qi>>2]=1740,q[Qi>>2]=2673,Y(4,1294,Qi),-1),L=16+Qi|0,0|a},C:function(a){var Pi;return L=Pi=L-16|0,a=(a|=0)?q[q[a>>2]+832>>2]:(q[4+Pi>>2]=1740,q[Pi>>2]=2693,Y(4,1294,Pi),0),L=16+Pi|0,0|a},D:function(a){var Oi;return L=Oi=L-16|0,a=(a|=0)?q[q[a>>2]+888>>2]:(q[4+Oi>>2]=1740,q[Oi>>2]=2711,Y(4,1294,Oi),0),L=16+Oi|0,0|a},E:function(a){var Ni;return L=Ni=L-16|0,a=(a|=0)?q[a+432>>2]:(q[4+Ni>>2]=1740,q[Ni>>2]=2739,Y(4,1294,Ni),0),L=16+Ni|0,0|a},F:function(a){var Mi;return L=Mi=L-16|0,a=(a|=0)?q[q[a>>2]+884>>2]:(q[4+Mi>>2]=1740,q[Mi>>2]=2766,Y(4,1294,Mi),0),L=16+Mi|0,0|a},G:function(a){var Li;return L=Li=L-16|0,a=(a|=0)?q[a+440>>2]:(q[4+Li>>2]=1740,q[Li>>2]=2795,Y(4,1294,Li),0),L=16+Li|0,0|a},H:function(a){var Ki;return L=Ki=L-16|0,a=(a|=0)?q[a+436>>2]:(q[4+Ki>>2]=1740,q[Ki>>2]=2820,Y(4,1294,Ki),0),L=16+Ki|0,0|a},I:function(a){var Ji;return L=Ji=L-16|0,a=(a|=0)?q[a+448>>2]:(q[4+Ji>>2]=1740,q[Ji>>2]=2847,Y(4,1294,Ji),0),L=16+Ji|0,0|a},J:function(a){var Ii;return L=Ii=L-16|0,a=(a|=0)?q[q[a>>2]+912>>2]:(q[4+Ii>>2]=1740,q[Ii>>2]=2871,Y(4,1294,Ii),0),L=16+Ii|0,0|a},K:function(a){var Hi;return L=Hi=L-16|0,a=(a|=0)?q[q[a>>2]+844>>2]:(q[4+Hi>>2]=1740,q[Hi>>2]=2896,Y(4,1294,Hi),0),L=16+Hi|0,0|a},L:function(a){var Gi;return L=Gi=L-16|0,a=(a|=0)?q[q[a>>2]+892>>2]:(q[4+Gi>>2]=1740,q[Gi>>2]=2916,Y(4,1294,Gi),0),L=16+Gi|0,0|a},M:function(a){var qi;return L=qi=L-16|0,a=(a|=0)?q[a+444>>2]:(q[4+qi>>2]=1740,q[qi>>2]=2943,Y(4,1294,qi),0),L=16+qi|0,0|a},N:function(a){var pi;return L=pi=L-16|0,a=(a|=0)?q[q[a>>2]+836>>2]:(q[4+pi>>2]=1740,q[pi>>2]=2973,Y(4,1294,pi),0),L=16+pi|0,0|a},O:function(a){var oi;return L=oi=L-16|0,a=(a|=0)?q[q[a>>2]+904>>2]:(q[4+oi>>2]=1740,q[oi>>2]=2997,Y(4,1294,oi),0),L=16+oi|0,0|a},P:function(a){var ni;return L=ni=L-16|0,a=(a|=0)?q[q[a>>2]+840>>2]:(q[4+ni>>2]=1740,q[ni>>2]=3023,Y(4,1294,ni),0),L=16+ni|0,0|a},Q:function(a){var mi;return L=mi=L-16|0,a=(a|=0)?q[a+452>>2]:(q[4+mi>>2]=1740,q[mi>>2]=3045,Y(4,1294,mi),0),L=16+mi|0,0|a},R:function(a){var li;return L=li=L-16|0,a=(a|=0)?q[a+456>>2]:(q[4+li>>2]=1740,q[li>>2]=3074,Y(4,1294,li),0),L=16+li|0,0|a},S:function(a){var ki;return L=ki=L-16|0,a=(a|=0)?q[q[a>>2]+876>>2]:(q[4+ki>>2]=1740,q[ki>>2]=3101,Y(4,1294,ki),0),L=16+ki|0,0|a},T:function(a){var ji;L=ji=L-16|0,(a|=0)?q[a+428>>2]=1:(q[4+ji>>2]=1740,q[ji>>2]=3133,Y(4,1294,ji)),L=16+ji|0},U:function(a){var ii;return L=ii=L-16|0,a=(a|=0)?q[a+640>>2]:(q[4+ii>>2]=1740,q[ii>>2]=3162,Y(4,1294,ii),0),L=16+ii|0,0|a},V:function(a){var hi;return L=hi=L-16|0,a=(a|=0)?q[a+636>>2]:(q[4+hi>>2]=1740,q[hi>>2]=3187,Y(4,1294,hi),0),L=16+hi|0,0|a},W:function(a){var Cc;return oa(12+(L=Cc=L-16|0)|0,64,a|=0),L=16+Cc|0,q[12+Cc>>2]},X:function(a){var Ln,Jn,Kn=0;return L=Jn=L-16|0,!(a|=0)||oa(12+Jn|0,16,Ln=xa(a))||(Kn=wa(a,q[12+Jn>>2],Ln))||(pa(q[12+Jn>>2]),Kn=0),L=16+Jn|0,0|Kn},Y:function(a){return 0|qa(a|=0)},Z:function(a){pa(a|=0)},_:function(a){var rn;oa(12+(L=rn=L-16|0)|0,64,a|=0),pa(q[12+rn>>2]),L=16+rn|0},$:function(){return 0|L},aa:function(a){return 0|(L=L-(0|a)&-16)},ba:function(a){L=0|a},ca:function(a){return 0|(a=0|(a|=0),(P=0|N())<(a=P+(a|=0)|0)&&a<65536&&(a=new ArrayBuffer(w(a,65536)),(S=new global.Int8Array(a)).set(o),o=S,o=new global.Int8Array(a),p=new global.Int16Array(a),q=new global.Int32Array(a),r=new global.Uint8Array(a),s=new global.Uint16Array(a),t=new global.Uint32Array(a),u=new global.Float32Array(a),v=new global.Float64Array(a),buffer=a,m.buffer=a),P);var S,P},da:function(a,Tk){n[a|=0](Tk|=0)}};function X(a,b,c){var e,f,d=0;if(c)for(;;){if(c=c+-1|0,a>>>0<(d=(e=a+b|0)-1|0)>>>0)for(;f=r[0|a],o[0|a]=r[0|d],o[0|d]=f,(a=a+1|0)>>>0<(d=d+-1|0)>>>0;);if(a=e,!c)break}}function Y(a,b,c){var g;L=g=L-272|0,t[1812]>a>>>0||(a=q[1813])&&(Ka(16+g|0,b,q[12+g>>2]=c),n[a](16+g|0)),L=272+g|0}function Z(a,b,c){32&r[0|a]||!function(a,Gl,Pm){var Qm=0,Rm=0,Sm=0;a:{if(!(Qm=q[Pm+16>>2])){if(function(a){var Pm;return Pm=r[a+74|0],o[a+74|0]=Pm+-1|Pm,8&(Pm=q[a>>2])?(q[a>>2]=32|Pm,1):(q[a+4>>2]=0,q[a+8>>2]=0,Pm=q[a+44>>2],q[a+28>>2]=Pm,q[a+20>>2]=Pm,q[a+16>>2]=Pm+q[a+48>>2],0)}(Pm))break a;Qm=q[Pm+16>>2]}if(Qm-(Sm=q[Pm+20>>2])>>>0>>0)return n[q[Pm+36>>2]](Pm,a,Gl);b:if(!(o[Pm+75|0]<0)){for(Qm=Gl;;){if(!(Rm=Qm))break b;if(10==r[(Qm=Rm+-1|0)+a|0])break}if(n[q[Pm+36>>2]](Pm,a,Rm)>>>0>>0)break a;Gl=Gl-Rm|0,a=a+Rm|0,Sm=q[Pm+20>>2]}$(Sm,a,Gl),q[Pm+20>>2]=q[Pm+20>>2]+Gl}}(b,c,a)}function _(a,b,c,h,i){var k,l,j;if(L=j=L-256|0,!(73728&i|(0|c)<=(0|h))){if(ca(j,b,(k=(i=c-h|0)>>>0<256)?i:256),b=a,l=j,!k){for(c=c-h|0;Z(a,j,256),255<(i=i+-256|0)>>>0;);i=255&c}Z(b,l,i)}L=256+j|0}function $(a,b,c){var h,i=0;if(8192<=c>>>0)I(0|a,0|b,0|c);else{if(h=a+c|0,3&(a^b))if(h>>>0<4)c=a;else if((i=h-4|0)>>>0>>0)c=a;else for(c=a;o[0|c]=r[0|b],o[c+1|0]=r[b+1|0],o[c+2|0]=r[b+2|0],o[c+3|0]=r[b+3|0],b=b+4|0,(c=c+4|0)>>>0<=i>>>0;);else{b:if((0|c)<1)c=a;else if(3&a)for(c=a;;){if(o[0|c]=r[0|b],b=b+1|0,h>>>0<=(c=c+1|0)>>>0)break b;if(!(3&c))break}else c=a;if(!((a=-4&h)>>>0<64||(i=a+-64|0)>>>0>>0))for(;q[c>>2]=q[b>>2],q[c+4>>2]=q[b+4>>2],q[c+8>>2]=q[b+8>>2],q[c+12>>2]=q[b+12>>2],q[c+16>>2]=q[b+16>>2],q[c+20>>2]=q[b+20>>2],q[c+24>>2]=q[b+24>>2],q[c+28>>2]=q[b+28>>2],q[c+32>>2]=q[b+32>>2],q[c+36>>2]=q[b+36>>2],q[c+40>>2]=q[b+40>>2],q[c+44>>2]=q[b+44>>2],q[c+48>>2]=q[b+48>>2],q[c+52>>2]=q[b+52>>2],q[c+56>>2]=q[b+56>>2],q[c+60>>2]=q[b+60>>2],b=b- -64|0,(c=c- -64|0)>>>0<=i>>>0;);if(!(a>>>0<=c>>>0))for(;q[c>>2]=q[b>>2],b=b+4|0,(c=c+4|0)>>>0>>0;);}if(c>>>0>>0)for(;o[0|c]=r[0|b],b=b+1|0,(0|h)!=(0|(c=c+1|0)););}}function aa(a){var b,c;return x((b=a*a)*b*(c=b*a)*(2718311493989822e-21*b-.00019839334836096632)+(c*(.008333329385889463*b-.16666666641626524)+a))}function ba(a){var m;return x(-.499999997251031*(a*=a)+1+.04166662332373906*(m=a*a)+a*m*(2439044879627741e-20*a-.001388676377460993))}function ca(a,n,p){var r,s,t,u;if(p&&(o[(r=a+p|0)-1|0]=n,o[0|a]=n,!(p>>>0<3||(o[r-2|0]=n,o[a+1|0]=n,o[r-3|0]=n,o[a+2|0]=n,p>>>0<7)||(o[r-4|0]=n,o[a+3|0]=n,p>>>0<9)||(s=(r=0-a&3)+a|0,n=w(255&n,16843009),q[s>>2]=n,q[(r=(p=p-r&-4)+s|0)-4>>2]=n,p>>>0<9)||(q[8+s>>2]=n,q[4+s>>2]=n,q[r-8>>2]=n,q[r-12>>2]=n,p>>>0<25)||(q[24+s>>2]=n,q[20+s>>2]=n,q[16+s>>2]=n,q[12+s>>2]=n,q[r-16>>2]=n,q[r-20>>2]=n,q[r-24>>2]=n,q[r-28>>2]=n,(p=p-(u=4&s|24)|0)>>>0<32))))for(t=r=n,n=s+u|0;q[n+24>>2]=t,q[n+28>>2]=r,q[n+16>>2]=t,q[n+20>>2]=r,q[n+8>>2]=t,q[n+12>>2]=r,q[n>>2]=t,q[n+4>>2]=r,n=n+32|0,31<(p=p+-32|0)>>>0;);return a}function da(a,n){var p;if(a>>>0<(n=(a+n|0)-1|0)>>>0)for(;p=r[0|a],o[0|a]=r[0|n],o[0|n]=p,(a=a+1|0)>>>0<(n=n+-1|0)>>>0;);}function ea(a){var n,o=N();return(a=(n=q[2224])+a|0)>>>0<=o<<16>>>0||J(0|a)?(q[2224]=a,n):(q[2094]=48,-1)}function fa(a,v,y,z,B,C,D){var H,I,K,N,Q,R,S,O,P,J,E=0,F=x(0),G=x(0),M=x(0);if(x(0),x(0),x(0),x(0),L=J=L-16|0,1<=(0|a))for(R=w(a,12)+v|0;;){if(1<=(0|(I=q[v+4>>2])))for(S=(a=q[v+8>>2])+w(I,48)|0,I=(H=q[v>>2]<<4)+D|0,K=(8|H)+D|0,H=(4|H)+D|0;(E=q[a+8>>2])&&((O=E+-1|0)>>>0<=1?(P=(q[a+4>>2]<<2)+y|0,E=q[P+(q[a+12>>2]<<2)>>2]<<2,F=u[E+C>>2],Q=u[B+E>>2],G=u[z+E>>2],O-1?(M=G,G=u[a+20>>2],u[I>>2]=u[I>>2]+x(u[a+44>>2]*x(M*G)),u[H>>2]=u[H>>2]+x(x(Q*G)*u[a+44>>2]),u[K>>2]=u[K>>2]+x(x(F*G)*u[a+44>>2])):(E=q[(q[a+16>>2]<<2)+P>>2]<<2,O=u[E+C>>2],P=u[B+E>>2],M=G,G=u[a+20>>2],N=u[a+24>>2],u[I>>2]=u[I>>2]+x(u[a+44>>2]*x(x(M*G)+x(u[z+E>>2]*N))),u[H>>2]=u[H>>2]+x(x(x(Q*G)+x(P*N))*u[a+44>>2]),u[K>>2]=u[K>>2]+x(x(x(F*G)+x(O*N))*u[a+44>>2]))):(q[J>>2]=E,Y(4,1024,J))),(a=a+48|0)>>>0>>0;);if(a=(q[v>>2]<<4)+D|0,F=u[a>>2],u[a>>2]=F>2],u[a+4>>2]=F>2],u[a+8>>2]=F>>0>>0))break}L=16+J|0}function ga(a,q,v){var y,z,x=0;if(1==(0|q)&a>>>0<0|q>>>0<1)x=a;else for(;y=cd(x=dd(a,q,10),z=M,10),o[0|(v=v+-1|0)]=a-y|48,y=9==(0|q)&4294967295>>0|9>>0,a=x,q=z,y;);if(x)for(;o[0|(v=v+-1|0)]=x-w(a=(x>>>0)/10|0,10)|48,q=9>>0,x=a,q;);return v}function ha(a){return a+-48>>>0<10}function ia(a){var q;return(q=La(a,64))?q-a|0:64}function ja(a,v){var w=0;return 1024<=(0|v)?(a*=898846567431158e293,v=(0|(w=v+-1023|0))<1024?w:(a*=898846567431158e293,((0|v)<3069?v:3069)+-2046|0)):-1023<(0|v)||(a*=22250738585072014e-324,v=-1023<(0|(w=v+1022|0))?w:(a*=22250738585072014e-324,(-3066<(0|v)?v:-3066)+2044|0)),f(0,0),f(1,v+1023<<20),a*+g()}function ka(a,v){var A=0,C=a,B=v>>>0<=31?(A=q[a+4>>2],q[a>>2]):(A=q[a>>2],q[a+4>>2]=A,v=v+-32|(q[a>>2]=0),0);q[C>>2]=B<>2]=A<>>32-v}function la(a,v,D,V,W){var X,Y=0,Z=0,_=0;L=X=L-240|0,Y=q[v>>2],q[232+X>>2]=Y,v=q[v+4>>2],q[X>>2]=a,Z=1;a:{b:{c:{if(((q[236+X>>2]=v)||1!=(0|Y))&&(Y=a-q[(D<<2)+W>>2]|0,!((0|n[5](Y,a))<1))){for(_=!V;;){e:{if(v=Y,!(!_|(0|D)<2)){if(V=q[((D<<2)+W|0)-8>>2],-1<(0|n[5](Y=a+-4|0,v)))break e;if(-1<(0|n[5](Y-V|0,v)))break e}if(q[(Z<<2)+X>>2]=v,Z=Z+1|0,ma(232+X|0,a=Oa(232+X|0)),D=a+D|0,!q[236+X>>2]&&1==q[232+X>>2])break b;if(_=1,Y=(a=v)-q[(D<<2)+W>>2]|(V=0),0<(0|n[5](Y,q[X>>2])))continue;break c}break}v=a;break b}v=a}if(V)break a}Na(X,Z),ta(v,D,W)}L=240+X|0}function ma(a,v){var D=0,V=a,L=v>>>0<=31?(D=q[a>>2],q[a+4>>2]):(D=q[a+4>>2],q[a+4>>2]=0,q[a>>2]=D,v=v+-32|0,0);q[V+4>>2]=L>>>v,q[a>>2]=L<<32-v|D>>>v}function na(a,v){var W=r[a+4|0];q[v>>2]=q[a+64>>2]+a,q[v+4>>2]=q[a+68>>2]+a,q[v+8>>2]=q[a+72>>2]+a,q[v+12>>2]=q[a+76>>2]+a,q[v+16>>2]=q[a+80>>2]+a,q[v+20>>2]=q[a+84>>2]+a,q[v+24>>2]=q[a+88>>2]+a,q[v+28>>2]=q[a+92>>2]+a,q[v+32>>2]=q[a+96>>2]+a,q[v+36>>2]=q[a+100>>2]+a,q[v+40>>2]=q[a+104>>2]+a,q[v+44>>2]=q[a+108>>2]+a,q[v+48>>2]=q[a+112>>2]+a,q[v+52>>2]=q[a+116>>2]+a,q[v+56>>2]=q[a+120>>2]+a,q[v+60>>2]=q[a+124>>2]+a,q[v- -64>>2]=q[a+128>>2]+a,q[v+68>>2]=q[a+132>>2]+a,q[v+72>>2]=q[a+136>>2]+a,q[v+76>>2]=q[a+140>>2]+a,q[v+80>>2]=q[a+144>>2]+a,q[v+84>>2]=q[a+148>>2]+a,q[v+92>>2]=q[a+152>>2]+a,q[v+96>>2]=q[a+156>>2]+a,q[v+100>>2]=q[a+160>>2]+a,q[v+108>>2]=q[a+164>>2]+a,q[v+112>>2]=q[a+168>>2]+a,q[v+116>>2]=q[a+172>>2]+a,q[v+124>>2]=q[a+176>>2]+a,q[v+128>>2]=q[a+180>>2]+a,q[v+132>>2]=q[a+184>>2]+a,q[v+136>>2]=q[a+188>>2]+a,q[v+140>>2]=q[a+192>>2]+a,q[v+144>>2]=q[a+196>>2]+a,q[v+148>>2]=q[a+200>>2]+a,q[v+152>>2]=q[a+204>>2]+a,q[v+156>>2]=q[a+208>>2]+a,q[v+164>>2]=q[a+212>>2]+a,q[v+168>>2]=q[a+216>>2]+a,q[v+172>>2]=q[a+220>>2]+a,q[v+176>>2]=q[a+224>>2]+a,q[v+180>>2]=q[a+228>>2]+a,q[v+184>>2]=q[a+232>>2]+a,q[v+188>>2]=q[a+236>>2]+a,q[v+192>>2]=q[a+240>>2]+a,q[v+196>>2]=q[a+244>>2]+a,q[v+200>>2]=q[a+248>>2]+a,q[v+204>>2]=q[a+252>>2]+a,q[v+208>>2]=q[a+256>>2]+a,q[v+212>>2]=q[a+260>>2]+a,q[v+216>>2]=q[a+264>>2]+a,q[v+220>>2]=q[a+268>>2]+a,q[v+224>>2]=q[a+272>>2]+a,q[v+228>>2]=q[a+276>>2]+a,q[v+232>>2]=q[a+280>>2]+a,q[v+236>>2]=q[a+284>>2]+a,q[v+244>>2]=q[a+288>>2]+a,q[v+248>>2]=q[a+292>>2]+a,q[v+272>>2]=q[a+296>>2]+a,q[v+276>>2]=q[a+300>>2]+a,q[v+280>>2]=q[a+304>>2]+a,q[v+292>>2]=q[a+308>>2]+a,q[v+296>>2]=q[a+312>>2]+a,q[v+300>>2]=q[a+316>>2]+a,q[v+304>>2]=q[a+320>>2]+a,q[v+308>>2]=q[a+324>>2]+a,q[v+312>>2]=q[a+328>>2]+a,q[v+316>>2]=q[a+332>>2]+a,q[v+328>>2]=q[a+336>>2]+a,q[v+332>>2]=q[a+340>>2]+a,q[v+336>>2]=q[a+344>>2]+a,q[v+348>>2]=q[a+348>>2]+a,q[v+360>>2]=q[a+352>>2]+a,q[v+364>>2]=q[a+356>>2]+a,q[v+368>>2]=q[a+360>>2]+a,q[v+352>>2]=q[a+364>>2]+a,q[v+356>>2]=q[a+368>>2]+a,q[v+488>>2]=q[a+372>>2]+a,q[v+492>>2]=q[a+376>>2]+a,q[v+496>>2]=q[a+380>>2]+a,q[v+500>>2]=q[a+384>>2]+a,q[v+504>>2]=q[a+388>>2]+a,q[v+508>>2]=q[a+392>>2]+a,q[v+512>>2]=q[a+396>>2]+a,q[v+516>>2]=q[a+400>>2]+a,q[v+520>>2]=q[a+404>>2]+a,q[v+524>>2]=q[a+408>>2]+a,q[v+528>>2]=q[a+412>>2]+a,q[v+532>>2]=q[a+416>>2]+a,q[v+536>>2]=q[a+420>>2]+a,q[v+540>>2]=q[a+424>>2]+a,q[v+544>>2]=q[a+428>>2]+a,q[v+548>>2]=q[a+432>>2]+a,q[v+552>>2]=q[a+436>>2]+a,q[v+556>>2]=q[a+440>>2]+a,q[v+560>>2]=q[a+444>>2]+a,q[v+564>>2]=q[a+448>>2]+a,q[v+568>>2]=q[a+452>>2]+a,q[v+572>>2]=q[a+456>>2]+a,q[v+576>>2]=q[a+460>>2]+a,q[v+580>>2]=q[a+464>>2]+a,W>>>0<2||(q[v+104>>2]=q[a+468>>2]+a,W>>>0<4)||(q[v+260>>2]=q[a+472>>2]+a,q[v+264>>2]=q[a+476>>2]+a,q[v+268>>2]=q[a+480>>2]+a,q[v+88>>2]=q[a+484>>2]+a,q[v+120>>2]=q[a+488>>2]+a,q[v+160>>2]=q[a+492>>2]+a,q[v+584>>2]=q[a+496>>2]+a,q[v+588>>2]=q[a+500>>2]+a,q[v+592>>2]=q[a+504>>2]+a,q[v+596>>2]=q[a+508>>2]+a,q[v+600>>2]=q[a+512>>2]+a,q[v+604>>2]=q[a+516>>2]+a,q[v+240>>2]=q[a+520>>2]+a,q[v+252>>2]=q[a+524>>2]+a,q[v+256>>2]=q[a+528>>2]+a,q[v+372>>2]=q[a+532>>2]+a,q[v+376>>2]=q[a+536>>2]+a,q[v+380>>2]=q[a+540>>2]+a,q[v+384>>2]=q[a+544>>2]+a,q[v+388>>2]=q[a+548>>2]+a,q[v+392>>2]=q[a+552>>2]+a,q[v+396>>2]=q[a+556>>2]+a,q[v+400>>2]=q[a+560>>2]+a,q[v+416>>2]=q[a+564>>2]+a,q[v+420>>2]=q[a+568>>2]+a,q[v+424>>2]=q[a+572>>2]+a,q[v+440>>2]=q[a+576>>2]+a,q[v+444>>2]=q[a+580>>2]+a,q[v+448>>2]=q[a+584>>2]+a,q[v+464>>2]=q[a+588>>2]+a,q[v+468>>2]=q[a+592>>2]+a,q[v+472>>2]=q[a+596>>2]+a,q[v+476>>2]=q[a+600>>2]+a,q[v+480>>2]=q[a+604>>2]+a,q[v+484>>2]=q[a+608>>2]+a,4!=(0|W)&&(q[v+284>>2]=q[a+612>>2]+a,q[v+288>>2]=q[a+616>>2]+a,q[v+320>>2]=q[a+620>>2]+a,q[v+324>>2]=q[a+624>>2]+a,q[v+340>>2]=q[a+628>>2]+a,q[v+344>>2]=q[a+632>>2]+a,q[v+404>>2]=q[a+636>>2]+a,q[v+408>>2]=q[a+640>>2]+a,q[v+412>>2]=q[a+644>>2]+a,q[v+428>>2]=q[a+648>>2]+a,q[v+432>>2]=q[a+652>>2]+a,q[v+436>>2]=q[a+656>>2]+a,q[v+452>>2]=q[a+660>>2]+a,q[v+456>>2]=q[a+664>>2]+a,q[v+460>>2]=q[a+668>>2]+a))}function oa(a,v,$){var aa=0;a:{if(8==(0|v))v=qa($);else{if(aa=28,3&v|1!=(0|function(a){for(var ap=0,bp=0;bp=ap,a;)a&=a-1,ap=ap+1|0;return bp}(v>>>2)))break a;if(aa=48,-64-v>>>0<$>>>0)break a;v=function(a,Tk){var tl,ul,ol=0,rl=0,sl=0;if((rl=a>>>0>(ol=16)?a:16)+-1&rl)for(;ol=(a=ol)<<1,a>>>0>>0;);else a=rl;return-64-a>>>0<=Tk>>>0?(q[2094]=48,0):(ol=qa(12+((rl=Tk>>>0<11?16:Tk+11&-8)+a|0)|0))?(Tk=ol+-8|0,ol&a+-1?(sl=(-8&(ul=q[(tl=ol+-4|0)>>2]))-(ol=(a=15<(ol=((a+ol|0)-1&0-a)-8|0)-Tk>>>0?ol:a+ol|0)-Tk|0)|0,3&ul?(q[a+4>>2]=sl|1&q[a+4>>2]|2,q[4+(sl=a+sl|0)>>2]=1|q[4+sl>>2],q[tl>>2]=ol|1&q[tl>>2]|2,q[a+4>>2]=1|q[a+4>>2],za(Tk,ol)):(Tk=q[Tk>>2],q[a+4>>2]=sl,q[a>>2]=Tk+ol)):a=Tk,3&(Tk=q[a+4>>2])&&((ol=-8&Tk)>>>0<=rl+16>>>0||(q[a+4>>2]=rl|1&Tk|2,q[(Tk=a+rl|0)+4>>2]=3|(rl=ol-rl|0),q[4+(ol=a+ol|0)>>2]=1|q[ol+4>>2],za(Tk,rl))),a+8|0):0}(16>>0?v:16,$)}if(!v)return 1;q[a>>2]=v,aa=0}return aa}function pa(a){var da,v=0,$=0,ba=0,ca=0,ea=0,fa=0,ha=0;a:if(a){da=(ba=a+-8|0)+(a=-8&($=q[a+-4>>2]))|0;b:if(!(1&$)){if(!(3&$))break a;if((ba=ba-($=q[ba>>2])|0)>>>0>>0<=255)ca=q[ba+8>>2],$>>>=3,(0|(v=q[ba+12>>2]))==(0|ca)?(ha=q[2095]&fd($),q[2095]=ha):(q[ca+12>>2]=v,q[v+8>>2]=ca);else{if(fa=q[ba+24>>2],(0|ba)!=(0|($=q[ba+12>>2])))v=q[ba+8>>2],q[v+12>>2]=$,q[$+8>>2]=v;else if(v=(v=q[(ca=ba+20|0)>>2])||q[(ca=ba+16|0)>>2]){for(;ea=ca,(v=q[(ca=($=v)+20|0)>>2])||(ca=$+16|0,v=q[$+16>>2]););q[ea>>2]=0}else $=0;if(fa){ca=q[ba+28>>2];e:{if(q[(v=8684+(ca<<2)|0)>>2]==(0|ba)){if(q[v>>2]=$)break e;ha=q[2096]&fd(ca),q[2096]=ha;break b}if(!(q[fa+(q[fa+16>>2]==(0|ba)?16:20)>>2]=$))break b}q[$+24>>2]=fa,(v=q[ba+16>>2])&&(q[$+16>>2]=v,q[v+24>>2]=$),(v=q[ba+20>>2])&&(q[$+20>>2]=v,q[v+24>>2]=$)}}else if(3==(3&($=q[4+da>>2])))return q[2097]=a,q[4+da>>2]=-2&$,q[ba+4>>2]=1|a,q[a+ba>>2]=a}if(!(da>>>0<=ba>>>0)&&1&($=q[4+da>>2])){f:{if(!(2&$)){if(q[2101]==(0|da)){if(q[2101]=ba,a=q[2098]+a|0,q[2098]=a,q[ba+4>>2]=1|a,q[2100]!=(0|ba))break a;return q[2097]=0,q[2100]=0}if(q[2100]==(0|da))return q[2100]=ba,a=q[2097]+a|0,q[2097]=a,q[ba+4>>2]=1|a,q[a+ba>>2]=a;a=(-8&$)+a|0;g:if($>>>0<=255)$>>>=3,(0|(v=q[8+da>>2]))==(0|(ca=q[12+da>>2]))?(ha=q[2095]&fd($),q[2095]=ha):(q[v+12>>2]=ca,q[ca+8>>2]=v);else{if(fa=q[24+da>>2],(0|da)!=(0|($=q[12+da>>2])))v=q[8+da>>2],q[v+12>>2]=$,q[$+8>>2]=v;else if(v=(v=q[(ca=20+da|0)>>2])||q[(ca=16+da|0)>>2]){for(;ea=ca,(v=q[(ca=($=v)+20|0)>>2])||(ca=$+16|0,v=q[$+16>>2]););q[ea>>2]=0}else $=0;if(fa){ca=q[28+da>>2];j:{if(q[(v=8684+(ca<<2)|0)>>2]==(0|da)){if(q[v>>2]=$)break j;ha=q[2096]&fd(ca),q[2096]=ha;break g}if(!(q[fa+(q[fa+16>>2]==(0|da)?16:20)>>2]=$))break g}q[$+24>>2]=fa,(v=q[16+da>>2])&&(q[$+16>>2]=v,q[v+24>>2]=$),(v=q[20+da>>2])&&(q[$+20>>2]=v,q[v+24>>2]=$)}}if(q[ba+4>>2]=1|a,q[a+ba>>2]=a,q[2100]!=(0|ba))break f;return q[2097]=a}q[4+da>>2]=-2&$,q[ba+4>>2]=1|a,q[a+ba>>2]=a}if(a>>>0<=255)return $=8420+((a>>>=3)<<3)|0,a=(v=q[2095])&(a=1<>2]:(q[2095]=a|v,$),q[$+8>>2]=ba,q[a+12>>2]=ba,q[ba+12>>2]=$,q[ba+8>>2]=a;q[ba+16>>2]=0,v=q[ba+20>>2]=0,(ca=a>>>8)&&(v=31,16777215>>0||(v=ca,v=28+((v=((v=(v<<=ca=ca+1048320>>>16&8)<<(fa=v+520192>>>16&4))<<(ea=v+245760>>>16&2)>>>15)-(ea|ca|fa)|0)<<1|a>>>v+21&1)|0)),ea=8684+((q[($=ba)+28>>2]=v)<<2)|0;m:if((ca=q[2096])&($=1<>>1)|0),$=q[ea>>2];n:{for(;;){if((-8&q[(v=$)+4>>2])==(0|a))break n;if($=ca>>>29,ca<<=1,!($=q[16+(ea=v+(4&$)|0)>>2]))break}q[ea+16>>2]=ba,q[ba+12>>2]=ba,q[ba+24>>2]=v,q[ba+8>>2]=ba;break m}a=q[v+8>>2],q[a+12>>2]=ba,q[v+8>>2]=ba,q[ba+24>>2]=0,q[ba+12>>2]=v,q[ba+8>>2]=a}else q[2096]=$|ca,q[ea>>2]=ba,q[ba+12>>2]=ba,q[ba+24>>2]=ea,q[ba+8>>2]=ba;if(a=q[2103]+-1|0,!(q[2103]=a)){for(ba=8836;ba=(a=q[ba>>2])+8|0,a;);q[2103]=-1}}}}function qa(a){var sa,ia=0,ja=0,ka=0,la=0,ma=0,na=0,oa=0,pa=0,qa=0,ra=0,ua=0;L=sa=L-16|0;a:{b:{c:{d:{e:{f:{g:{h:{i:{j:{k:{if(a>>>0<=244){if(3&(ia=(ma=q[2095])>>>(a=(na=a>>>0<11?16:a+11&-8)>>>3))){a=(ia=q[8428+(la=(ja=a+(1&(-1^ia))|0)<<3)>>2])+8|0,(0|(ka=q[ia+8>>2]))==(0|(la=la+8420|0))?(ua=fd(ja)&ma,q[2095]=ua):(q[ka+12>>2]=la,q[la+8>>2]=ka),q[ia+4>>2]=3|(ja<<=3),q[4+(ia=ia+ja|0)>>2]=1|q[ia+4>>2];break a}if(na>>>0<=(pa=q[2097])>>>0)break k;if(ia){ja=ia=(a=(0-(a=(0-(ja=2<>>12&16,ia=q[8428+(ka=(ja=((ja=(ja|=ia=(a>>>=ia)>>>5&8)|(ia=(a>>>=ia)>>>2&4)|(ia=(a>>>=ia)>>>1&2))|(ia=(a>>>=ia)>>>1&1))+(a>>>ia)|0)<<3)>>2],(0|(a=q[ia+8>>2]))==(0|(ka=ka+8420|0))?(ma=fd(ja)&ma,q[2095]=ma):(q[a+12>>2]=ka,q[ka+8>>2]=a),a=ia+8|0,q[ia+4>>2]=3|na,q[4+(oa=ia+na|0)>>2]=1|(la=(ja<<=3)-na|0),q[ia+ja>>2]=la,pa&&(ia=8420+((ja=pa>>>3)<<3)|0,ka=q[2100],ja=(ja=1<>2]:(q[2095]=ja|ma,ia),q[ia+8>>2]=ka,q[ja+12>>2]=ka,q[ka+12>>2]=ia,q[ka+8>>2]=ja),q[2100]=oa,q[2097]=la;break a}if(!(ra=q[2096]))break k;for(ja=ia=(a=(ra&0-ra)-1|0)>>>12&16,ia=q[8684+(((ja=(ja|=ia=(a>>>=ia)>>>5&8)|(ia=(a>>>=ia)>>>2&4)|(ia=(a>>>=ia)>>>1&2))|(ia=(a>>>=ia)>>>1&1))+(a>>>ia)<<2)>>2],ka=(-8&q[ia+4>>2])-na|0,ja=ia;a=(a=q[ja+16>>2])||q[ja+20>>2];)ka=(ja=(la=(-8&q[a+4>>2])-na|0)>>>0>>0)?la:ka,ia=ja?a:ia,ja=a;if(qa=q[ia+24>>2],(0|(la=q[ia+12>>2]))!=(0|ia)){a=q[ia+8>>2],q[a+12>>2]=la,q[la+8>>2]=a;break b}if(!(a=q[(ja=ia+20|0)>>2])){if(!(a=q[ia+16>>2]))break j;ja=ia+16|0}for(;oa=ja,(a=q[(ja=(la=a)+20|0)>>2])||(ja=la+16|0,a=q[la+16>>2]););q[oa>>2]=0;break b}if(na=-1,!(4294967231>>0)&&(na=-8&(ia=a+11|0),pa=q[2096])){ja=0-na|0,ma=0,(ia>>>=8)&&(ma=31,16777215>>0||(ma=28+((a=((ma=(ia<<=ka=ia+1048320>>>16&8)<<(a=ia+520192>>>16&4))<<(ia=ma+245760>>>16&2)>>>15)-(ia|a|ka)|0)<<1|na>>>a+21&1)|0));q:{r:{if(ka=q[8684+(ma<<2)>>2])for(ia=na<<(31==(0|ma)?0:25-(ma>>>1)|0),a=0;;){if(!(ja>>>0<=(oa=(-8&q[ka+4>>2])-na|0)>>>0||(la=ka,ja=oa))){ja=0,a=ka;break r}if(oa=q[ka+20>>2],ka=q[16+((ia>>>29&4)+ka|0)>>2],a=oa&&(0|oa)!=(0|ka)?oa:a,ia<<=0!=(0|ka),!ka)break}else a=0;if(!(a|la)){if(!(a=(0-(a=2<>>12&16,a=q[8684+(((ka=(ka|=ia=(a>>>=ia)>>>5&8)|(ia=(a>>>=ia)>>>2&4)|(ia=(a>>>=ia)>>>1&2))|(ia=(a>>>=ia)>>>1&1))+(a>>>ia)<<2)>>2]}if(!a)break q}for(;ja=(ia=(ka=(-8&q[a+4>>2])-na|0)>>>0>>0)?ka:ja,la=ia?a:la,a=(ia=q[a+16>>2])||q[a+20>>2];);}if(!(!la|ja>>>0>=q[2097]-na>>>0)){if(oa=q[la+24>>2],(0|la)!=(0|(ia=q[la+12>>2]))){a=q[la+8>>2],q[a+12>>2]=ia,q[ia+8>>2]=a;break c}if(!(a=q[(ka=la+20|0)>>2])){if(!(a=q[la+16>>2]))break i;ka=la+16|0}for(;ma=ka,(a=q[(ka=(ia=a)+20|0)>>2])||(ka=ia+16|0,a=q[ia+16>>2]););q[ma>>2]=0;break c}}}if(na>>>0<=(ia=q[2097])>>>0){a=q[2100],16<=(ja=ia-na|0)>>>0?(q[2097]=ja,q[2100]=ka=a+na|0,q[ka+4>>2]=1|ja,q[a+ia>>2]=ja,q[a+4>>2]=3|na):(q[2100]=0,q[2097]=0,q[a+4>>2]=3|ia,q[4+(ia=a+ia|0)>>2]=1|q[ia+4>>2]),a=a+8|0;break a}if(na>>>0<(ka=q[2098])>>>0){q[2098]=ia=ka-na|0,a=q[2101],q[2101]=ja=a+na|0,q[ja+4>>2]=1|ia,q[a+4>>2]=3|na,a=a+8|0;break a}if((ja=(ma=(ja=la=na+47|(a=0))+(ia=q[2213]?q[2215]:(q[2216]=-1,q[2217]=-1,q[2214]=4096,q[2215]=4096,q[2213]=12+sa&-16^1431655768,q[2218]=0,q[2206]=0,4096))|0)&(oa=0-ia|0))>>>0<=na>>>0)break a;if((ia=q[2205])&&(qa=(pa=q[2203])+ja|0)>>>0<=pa>>>0|ia>>>0>>0)break a;if(4&r[8824])break f;v:{w:{if(ia=q[2101])for(a=8828;;){if((pa=q[a>>2])+q[a+4>>2]>>>0>ia>>>0&&pa>>>0<=ia>>>0)break w;if(!(a=q[a+8>>2]))break}if(-1==(0|(ia=ea(0))))break g;if(ma=ja,(ma=(ka=(a=q[2214])+-1|0)&ia?(ja-ia|0)+(ia+ka&0-a)|0:ma)>>>0<=na>>>0|2147483646>>0)break g;if((a=q[2205])&&(oa=(ka=q[2203])+ma|0)>>>0<=ka>>>0|a>>>0>>0)break g;if((0|ia)!=(0|(a=ea(ma))))break v;break e}if(2147483646<(ma=oa&ma-ka)>>>0)break g;if((0|(ia=ea(ma)))==(q[a>>2]+q[a+4>>2]|0))break h;a=ia}if(!(na+48>>>0<=ma>>>0|2147483646>>0|-1==(0|(ia=a)))){if(2147483646<(a=(a=q[2215])+(la-ma|0)&0-a)>>>0)break e;if(-1!=(0|ea(a))){ma=a+ma|0;break e}ea(0-ma|0);break g}if(-1!=(0|ia))break e;break g}la=0;break b}ia=0;break c}if(-1!=(0|ia))break e}q[2206]=4|q[2206]}if(2147483646>>0)break d;if(ia=ea(ja),(a=ea(0))>>>0<=ia>>>0|-1==(0|ia)|-1==(0|a))break d;if((ma=a-ia|0)>>>0<=na+40>>>0)break d}a=q[2203]+ma|0,(q[2203]=a)>>>0>t[2204]&&(q[2204]=a);x:{y:{z:{if(ja=q[2101]){for(a=8828;;){if(((ka=q[a>>2])+(la=q[a+4>>2])|0)==(0|ia))break z;if(!(a=q[a+8>>2]))break}break y}for((a=q[2099])>>>0<=ia>>>0&&a||(q[2099]=ia),a=0,q[2208]=ma,q[2207]=ia,q[2103]=-1,q[2104]=q[2213],q[2210]=0;q[8428+(ja=a<<3)>>2]=ka=ja+8420|0,q[ja+8432>>2]=ka,32!=(0|(a=a+1|0)););q[2098]=ka=(a=ma+-40|0)-(ja=ia+8&7?-8-ia&7:0)|0,q[2101]=ja=ia+ja|0,q[ja+4>>2]=1|ka,q[4+(a+ia|0)>>2]=40,q[2102]=q[2217];break x}if(!(8&r[a+12|0]|ia>>>0<=ja>>>0|ja>>>0>>0)){q[a+4>>2]=la+ma,q[2101]=ia=(a=ja+8&7?-8-ja&7:0)+ja|0,ka=q[2098]+ma|0,q[2098]=a=ka-a|0,q[ia+4>>2]=1|a,q[4+(ja+ka|0)>>2]=40,q[2102]=q[2217];break x}}ia>>>0<(la=q[2099])>>>0&&(q[2099]=ia,la=0),ka=ia+ma|0,a=8828;A:{B:{C:{D:{E:{F:{for(;(0|ka)!=q[a>>2];)if(!(a=q[a+8>>2]))break F;if(!(8&r[a+12|0]))break E}for(a=8828;;){if((ka=q[a>>2])>>>0<=ja>>>0&&ja>>>0<(la=ka+q[a+4>>2]|0)>>>0)break D;a=q[a+8>>2]}}if(q[a>>2]=ia,q[a+4>>2]=q[a+4>>2]+ma,q[4+(qa=(ia+8&7?-8-ia&7:0)+ia|0)>>2]=3|na,a=((ia=ka+(ka+8&7?-8-ka&7:0)|0)-qa|0)-na|0,oa=na+qa|0,(0|ia)==(0|ja)){q[2101]=oa,a=q[2098]+a|0,q[2098]=a,q[oa+4>>2]=1|a;break B}if(q[2100]==(0|ia)){q[2100]=oa,a=q[2097]+a|0,q[2097]=a,q[oa+4>>2]=1|a,q[a+oa>>2]=a;break B}if(1==(3&(ja=q[ia+4>>2]))){ra=-8&ja;G:if(ja>>>0<=255)la=ja>>>3,ja=q[ia+8>>2],(0|(ka=q[ia+12>>2]))==(0|ja)?(ua=q[2095]&fd(la),q[2095]=ua):(q[ja+12>>2]=ka,q[ka+8>>2]=ja);else{if(pa=q[ia+24>>2],(0|(ma=q[ia+12>>2]))!=(0|ia))ja=q[ia+8>>2],q[ja+12>>2]=ma,q[ma+8>>2]=ja;else if(na=(na=q[(ka=ia+20|0)>>2])||q[(ka=ia+16|0)>>2]){for(;ja=ka,(na=q[(ka=(ma=na)+20|0)>>2])||(ka=ma+16|0,na=q[ma+16>>2]););q[ja>>2]=0}else ma=0;if(pa){ja=q[ia+28>>2];J:{if(q[(ka=8684+(ja<<2)|0)>>2]==(0|ia)){if(q[ka>>2]=ma)break J;ua=q[2096]&fd(ja),q[2096]=ua;break G}if(!(q[pa+(q[pa+16>>2]==(0|ia)?16:20)>>2]=ma))break G}q[ma+24>>2]=pa,(ja=q[ia+16>>2])&&(q[ma+16>>2]=ja,q[ja+24>>2]=ma),(ja=q[ia+20>>2])&&(q[ma+20>>2]=ja,q[ja+24>>2]=ma)}}ia=ia+ra|0,a=a+ra|0}if(q[ia+4>>2]=-2&q[ia+4>>2],q[oa+4>>2]=1|a,(q[a+oa>>2]=a)>>>0<=255){a=8420+((ia=a>>>3)<<3)|0,ia=(ja=q[2095])&(ia=1<>2]:(q[2095]=ia|ja,a),q[a+8>>2]=oa,q[ia+12>>2]=oa,q[oa+12>>2]=a,q[oa+8>>2]=ia;break B}if(ia=0,(ka=a>>>8)&&(ia=31,16777215>>0||(ia=28+((ia=((na=(ka<<=la=ka+1048320>>>16&8)<<(ia=ka+520192>>>16&4))<<(ka=na+245760>>>16&2)>>>15)-(ka|ia|la)|0)<<1|a>>>ia+21&1)|0)),q[(ja=oa)+28>>2]=ia,q[oa+16>>2]=0,ja=8684+(ia<<2)|(q[oa+20>>2]=0),(ka=q[2096])&(la=1<>>1)|0),ia=q[ja>>2];;){if((-8&q[(ja=ia)+4>>2])==(0|a))break C;if(ia=ka>>>29,ka<<=1,!(ia=q[16+(la=(4&ia)+ja|0)>>2]))break}q[la+16>>2]=oa}else q[2096]=ka|la,q[ja>>2]=oa;q[oa+24>>2]=ja,q[oa+12>>2]=oa,q[oa+8>>2]=oa;break B}for(q[2098]=oa=(a=ma+-40|0)-(ka=ia+8&7?-8-ia&7:0)|0,q[2101]=ka=ia+ka|0,q[ka+4>>2]=1|oa,q[4+(a+ia|0)>>2]=40,q[2102]=q[2217],q[(ka=(a=(la+(la+-39&7?39-la&7:0)|0)-47|0)>>>0>>0?ja:a)+4>>2]=27,a=q[2210],q[ka+16>>2]=q[2209],q[ka+20>>2]=a,a=q[2208],q[ka+8>>2]=q[2207],q[ka+12>>2]=a,q[2209]=ka+8,q[2208]=ma,q[2207]=ia,a=ka+24|(q[2210]=0);q[a+4>>2]=7,ia=a+8|0,a=a+4|0,ia>>>0>>0;);if((0|ja)==(0|ka))break x;if(q[ka+4>>2]=-2&q[ka+4>>2],q[ja+4>>2]=1|(la=ka-ja|0),(q[ka>>2]=la)>>>0<=255){a=8420+((ia=la>>>3)<<3)|0,ia=(ka=q[2095])&(ia=1<>2]:(q[2095]=ia|ka,a),q[a+8>>2]=ja,q[ia+12>>2]=ja,q[ja+12>>2]=a,q[ja+8>>2]=ia;break x}if(q[ja+16>>2]=0,a=q[ja+20>>2]=0,(ka=la>>>8)&&(a=31,16777215>>0||(a=28+((a=((oa=(ka<<=ma=ka+1048320>>>16&8)<<(a=ka+520192>>>16&4))<<(ka=oa+245760>>>16&2)>>>15)-(ka|a|ma)|0)<<1|la>>>a+21&1)|0)),ia=8684+((q[(ia=ja)+28>>2]=a)<<2)|0,(ka=q[2096])&(ma=1<>>1)|0),ia=q[ia>>2];;){if((0|la)==(-8&q[(ka=ia)+4>>2]))break A;if(ia=a>>>29,a<<=1,!(ia=q[16+(ma=ka+(4&ia)|0)>>2]))break}q[ma+16>>2]=ja,q[ja+24>>2]=ka}else q[2096]=ka|ma,q[ia>>2]=ja,q[ja+24>>2]=ia;q[ja+12>>2]=ja,q[ja+8>>2]=ja;break x}a=q[ja+8>>2],q[a+12>>2]=oa,q[ja+8>>2]=oa,q[oa+24>>2]=0,q[oa+12>>2]=ja,q[oa+8>>2]=a}a=qa+8|0;break a}a=q[ka+8>>2],q[a+12>>2]=ja,q[ka+8>>2]=ja,q[ja+24>>2]=0,q[ja+12>>2]=ka,q[ja+8>>2]=a}if(!((a=q[2098])>>>0<=na>>>0)){q[2098]=ia=a-na|0,a=q[2101],q[2101]=ja=a+na|0,q[ja+4>>2]=1|ia,q[a+4>>2]=3|na,a=a+8|0;break a}}q[2094]=48,a=0;break a}Q:if(oa){a=q[la+28>>2];R:{if(q[(ka=8684+(a<<2)|0)>>2]==(0|la)){if(q[ka>>2]=ia)break R;pa=fd(a)&pa,q[2096]=pa;break Q}if(!(q[oa+(q[oa+16>>2]==(0|la)?16:20)>>2]=ia))break Q}q[ia+24>>2]=oa,(a=q[la+16>>2])&&(q[ia+16>>2]=a,q[a+24>>2]=ia),(a=q[la+20>>2])&&(q[ia+20>>2]=a,q[a+24>>2]=ia)}S:if(ja>>>0<=15)q[la+4>>2]=3|(a=ja+na|0),q[4+(a=a+la|0)>>2]=1|q[a+4>>2];else if(q[la+4>>2]=3|na,q[4+(ka=la+na|0)>>2]=1|ja,(q[ja+ka>>2]=ja)>>>0<=255)a=8420+((ia=ja>>>3)<<3)|0,ia=(ja=q[2095])&(ia=1<>2]:(q[2095]=ia|ja,a),q[a+8>>2]=ka,q[ia+12>>2]=ka,q[ka+12>>2]=a,q[ka+8>>2]=ia;else{a=0,(na=ja>>>8)&&(a=31,16777215>>0||(a=28+((a=((oa=(na<<=ma=na+1048320>>>16&8)<<(a=na+520192>>>16&4))<<(na=oa+245760>>>16&2)>>>15)-(na|a|ma)|0)<<1|ja>>>a+21&1)|0)),q[(ia=ka)+28>>2]=a,q[ka+16>>2]=0,ia=8684+(a<<2)|(q[ka+20>>2]=0);V:{if((na=1<>>1)|0),na=q[ia>>2];;){if((-8&q[(ia=na)+4>>2])==(0|ja))break V;if(na=a>>>29,a<<=1,!(na=q[16+(ma=(4&na)+ia|0)>>2]))break}q[ma+16>>2]=ka}else q[2096]=na|pa,q[ia>>2]=ka;q[ka+24>>2]=ia,q[ka+12>>2]=ka,q[ka+8>>2]=ka;break S}a=q[ia+8>>2],q[a+12>>2]=ka,q[ia+8>>2]=ka,q[ka+24>>2]=0,q[ka+12>>2]=ia,q[ka+8>>2]=a}a=la+8|0;break a}X:if(qa){a=q[ia+28>>2];Y:{if(q[(ja=8684+(a<<2)|0)>>2]==(0|ia)){if(q[ja>>2]=la)break Y;ua=fd(a)&ra,q[2096]=ua;break X}if(!(q[qa+(q[qa+16>>2]==(0|ia)?16:20)>>2]=la))break X}q[la+24>>2]=qa,(a=q[ia+16>>2])&&(q[la+16>>2]=a,q[a+24>>2]=la),(a=q[ia+20>>2])&&(q[la+20>>2]=a,q[a+24>>2]=la)}ka>>>0<=15?(q[ia+4>>2]=3|(a=ka+na|0),q[4+(a=a+ia|0)>>2]=1|q[a+4>>2]):(q[ia+4>>2]=3|na,q[4+(na=ia+na|0)>>2]=1|ka,q[ka+na>>2]=ka,pa&&(a=8420+((ja=pa>>>3)<<3)|0,la=q[2100],ja=(ja=1<>2]:(q[2095]=ja|ma,a),q[a+8>>2]=la,q[ja+12>>2]=la,q[la+12>>2]=a,q[la+8>>2]=ja),q[2100]=na,q[2097]=ka),a=ia+8|0}return L=16+sa|0,a}function ra(a,va,wa,xa,ya,za,Aa){var Qa,Ta,Ba,Ca=0,Da=0,Fa=0,Ia=0,Ja=0,Ka=0,Ma=0,Na=0,Oa=0,Pa=0,Ra=0,Sa=0;q[76+(L=Ba=L-80|0)>>2]=va,Ta=55+Ba|0,Qa=56+Ba|0,va=0;a:{b:{c:for(;;){(0|Oa)<0||(Oa=(2147483647-Oa|0)<(0|va)?(q[2094]=61,-1):va+Oa|0);e:{f:{g:{h:{i:{j:{k:{l:{m:{n:{o:{p:{q:{if(Ia=q[76+Ba>>2],Fa=r[0|(va=Ia)]){for(;;){r:{s:{t:if(Ca=255&Fa){if(37!=(0|Ca))break s;for(Fa=va;;){if(37!=r[va+1|0])break t;if(q[76+Ba>>2]=Ca=va+2|0,Fa=Fa+1|0,Da=r[va+2|0],va=Ca,37!=(0|Da))break}}else Fa=va;if(va=Fa-Ia|0,a&&Z(a,Ia,va),va)continue c;Pa=-1,Ja=!ha(o[q[76+(Ca=Ba)>>2]+(Fa=1)|0]),va=q[76+Ba>>2],Ja|36!=r[va+2|0]||(Pa=o[va+1|0]+-48|0,Ra=1,Fa=3),q[Ca+76>>2]=va=Fa+va|0;u:if(31<(Da=(Ma=o[(Fa=0)|va])+-32|0)>>>0)Ca=va;else if(Ca=va,75913&(Da=1<>2]=Ca=va+1|0,Fa|=Da,31<(Da=(Ma=o[va+1|0])+-32|0)>>>0)break u;if(va=Ca,!(75913&(Da=1<>2],36==r[va+2|0]))q[((o[va+1|0]<<2)+ya|0)-192>>2]=10,Na=q[((o[va+1|0]<<3)+xa|0)-384>>2],Ra=1,va=va+3|0;else{if(Ra)break b;Na=Ra=0,a&&(va=q[wa>>2],q[wa>>2]=va+4,Na=q[va>>2]),va=q[76+Ba>>2]+1|0}q[Ja+76>>2]=va,-1<(0|Na)||(Na=0-Na|0,Fa|=8192)}else{if((0|(Na=Ha(76+Ba|0)))<0)break b;va=q[76+Ba>>2]}if(Da=-1,46==r[0|va])if(42==r[va+1|0])if(ha(o[va+2|0])&&(va=q[76+Ba>>2],36==r[va+3|0]))q[((o[va+2|0]<<2)+ya|0)-192>>2]=10,Da=q[((o[va+2|0]<<3)+xa|0)-384>>2],q[76+Ba>>2]=va=va+4|0;else{if(Ra)break b;Da=a?(va=q[wa>>2],q[wa>>2]=va+4,q[va>>2]):0,va=q[76+Ba>>2]+2|0,q[76+Ba>>2]=va}else q[76+Ba>>2]=va+1,Da=Ha(76+Ba|0),va=q[76+Ba>>2];for(Ca=0;;){if(Sa=Ca,Ka=-1,57>>0)break a;if(q[76+Ba>>2]=Ma=va+1|0,Ca=o[0|va],va=Ma,!((Ca=r[3327+(Ca+w(Sa,58)|0)|0])+-1>>>0<8))break}if(!Ca)break a;A:{B:{C:{if(19==(0|Ca)){if((0|Pa)<=-1)break C;break a}if((0|Pa)<0)break B;q[(Pa<<2)+ya>>2]=Ca,Ca=q[4+(va=(Pa<<3)+xa|0)>>2],q[64+Ba>>2]=q[va>>2],q[68+Ba>>2]=Ca}if(va=0,a)break A;continue c}if(!a)break e;Ga(64+Ba|0,Ca,wa,Aa),Ma=q[76+Ba>>2]}if(Ja=-65537&Fa,Fa=8192&Fa?Ja:Fa,Pa=3360,Ca=Qa,va=o[Ma+-1|(Ka=0)],(Ma=(va=Sa&&3==(15&va)?-33&va:va)+-88|0)>>>0<=32)break r;D:{E:{F:{G:{if(6<(Ja=va+-65|0)>>>0){if(83!=(0|va))break f;if(!Da)break G;Ca=q[64+Ba>>2];break E}switch(Ja-1|0){case 1:break F;case 0:case 2:break f;default:break q}}_(a,32,Na,va=0,Fa);break D}q[12+Ba>>2]=0,q[8+Ba>>2]=q[64+Ba>>2],q[64+Ba>>2]=8+Ba,Da=-1,Ca=8+Ba|0}va=0;H:{for(;;){if(!(Ia=q[Ca>>2]))break H;if((Ja=(0|(Ia=Ea(4+Ba|0,Ia)))<0)|Da-va>>>0>>0)break;if(Ca=Ca+4|0,!((va=va+Ia|0)>>>0>>0))break H}if(Ka=-1,Ja)break a}if(_(a,32,Na,va,Fa),va)for(Da=0,Ca=q[64+Ba>>2];;){if(!(Ia=q[Ca>>2]))break D;if((0|va)<(0|(Da=(Ia=Ea(4+Ba|0,Ia))+Da|0)))break D;if(Z(a,4+Ba|0,Ia),Ca=Ca+4|0,!(Da>>>0>>0))break}else va=0}_(a,32,Na,va,8192^Fa),va=(0|va)<(0|Na)?Na:va;continue c}q[76+Ba>>2]=Ca=va+1|0,Fa=r[va+1|0],va=Ca;continue}break}switch(Ma-1|0){case 28:break i;case 21:break j;case 23:break l;case 22:break m;case 11:case 16:break n;case 10:break o;case 26:break p;case 8:case 12:case 13:case 14:break q;case 0:case 1:case 2:case 3:case 4:case 5:case 6:case 7:case 9:case 15:case 17:case 18:case 19:case 20:case 24:case 25:case 27:case 29:case 30:break f;default:break k}}if(Ka=Oa,a)break a;if(!Ra)break e;for(va=1;;){if(a=q[(va<<2)+ya>>2]){if(Ga((va<<3)+xa|0,a,wa,Aa),10!=(0|(va=va+(Ka=1)|0)))continue;break a}break}if(Ka=1,9>>0)break a;if(Ka=-1,q[(va<<2)+ya>>2])break a;for(;!q[((va=va+1|0)<<2)+ya>>2]&&10!=(0|va););Ka=va>>>0<10?-1:1;break a}va=0|n[za](a,v[64+Ba>>3],Na,Da,Fa,va);continue}Ca=(va=La(Ia=(va=q[64+Ba>>2])||3370,Da))||Da+Ia|0,Fa=Ja,Da=va?va-Ia|0:Da;break f}o[55+Ba|0]=q[64+Ba>>2],Da=1,Ia=Ta,Fa=Ja;break f}if(va=Ja=q[68+Ba>>2],Ia=q[64+Ba>>2],(0|va)<-1||(0|va)<=-1&&!(4294967295>>0)){va=0-(va+(0>>0)|0)|0,q[64+Ba>>2]=Ia=0-Ia|0,q[68+Ba>>2]=va,Ka=1,Pa=3360;break h}if(2048&Fa){Ka=1,Pa=3361;break h}Pa=(Ka=1&Fa)?3362:3360;break h}if(Ia=function(a,Gl,Pm){if(a|Gl)for(;o[0|(Pm=Pm+-1|0)]=7&a|48,(a=(7&Gl)<<29|a>>>3)|(Gl>>>=3););return Pm}(q[64+Ba>>2],q[68+Ba>>2],Qa),!(8&Fa))break g;Da=(0|(va=Qa-Ia|0))<(0|Da)?Da:va+1|0;break g}Da=8>>0?Da:8,Fa|=8,va=120}if(Ia=function(a,Gl,Pm,Qm){if(a|Gl)for(;o[0|(Pm=Pm+-1|0)]=r[3856+(15&a)|0]|Qm,(a=(15&Gl)<<28|a>>>4)|(Gl>>>=4););return Pm}(q[64+Ba>>2],q[68+Ba>>2],Qa,32&va),!(8&Fa)|!(q[64+Ba>>2]|q[68+Ba>>2]))break g;Pa=3360+(va>>>4)|0,Ka=2;break g}if(7<(Ca=255&Sa)>>>(va=0))continue;switch(Ca-1|0){default:case 0:q[q[64+Ba>>2]>>2]=Oa;continue;case 1:Ca=q[64+Ba>>2],q[Ca>>2]=Oa,q[Ca+4>>2]=Oa>>31;continue;case 2:p[q[64+Ba>>2]>>1]=Oa;continue;case 3:o[q[64+Ba>>2]]=Oa;continue;case 5:q[q[64+Ba>>2]>>2]=Oa;continue;case 4:continue;case 6:}Ca=q[64+Ba>>2],q[Ca>>2]=Oa,q[Ca+4>>2]=Oa>>31;continue}Ia=q[64+Ba>>2],va=q[68+Ba>>2],Pa=3360}Ia=ga(Ia,va,Qa)}Fa=-1<(0|Da)?-65537&Fa:Fa,Da=!!((Ja=va=q[68+Ba>>2])|(Ma=q[64+Ba>>2]))|Da?(0|(va=!(Ja|Ma)+(Qa-Ia|0)|0))<(0|Da)?Da:va:(Ia=Qa,0)}_(a,32,va=(0|Na)<(0|(Ca=(Da=(0|Da)<(0|(Ja=Ca-Ia|0))?Ja:Da)+Ka|0))?Ca:Na,Ca,Fa),Z(a,Pa,Ka),_(a,48,va,Ca,65536^Fa),_(a,48,Da,Ja,0),Z(a,Ia,Ja),_(a,32,va,Ca,8192^Fa);continue}break}Ka=0;break a}Ka=-1}return L=80+Ba|0,Ka}function sa(a){var ya,va=0,wa=0,xa=0,za=0,xa=4,wa=1439;a:if(va=r[0|a]){for(;!((0|(ya=r[0|wa]))!=(0|va)||!(xa=xa+-1|0)|!ya);)if(wa=wa+1|0,va=r[a+1|0],a=a+1|0,!va)break a;za=va}return(255&za)-r[0|wa]|0}function ta(a,Aa,Ea){var La,Ga,Ha=0,Ua=0,Va=0;q[(L=Ga=L-240|0)>>2]=a,Ua=1;a:if(!((0|Aa)<2))for(Ha=a;;){if(Ha=(La=Ha+-4|0)-q[((Va=Aa+-2|0)<<2)+Ea>>2]|0,0<=(0|n[5](a,Ha))&&-1<(0|n[5](a,La)))break a;if(a=(Ua<<2)+Ga|0,0<=(0|n[5](Ha,La))?(q[a>>2]=Ha,Va=Aa+-1|0):Ha=q[a>>2]=La,Ua=Ua+1|0,(0|Va)<2)break a;a=q[Ga>>2],Aa=Va}Na(Ga,Ua),L=240+Ga|0}function ua(a){var Ea,_a,Aa=0;if(x(0),function(a){var Hg,Ig;q[a+428>>2]&&(Ig=q[a+332>>2],$(q[a+460>>2],q[a+436>>2],Hg=Ig<<2),$(q[a+464>>2],q[a+440>>2],Hg),$(q[a+468>>2],q[a+448>>2],Hg),r[q[a>>2]+4|0]<4||($(q[a+472>>2],q[a+452>>2],Hg=Ig<<4),$(q[a+476>>2],q[a+456>>2],Hg)))}(a),function(a){var he,ie,je,ce=0,de=x(0),ee=x(0),fe=0,ge=x(0);x(0),x(0);if(1<=(0|(fe=q[a>>2])))for(je=(ce=q[a+4>>2])+w(fe,52)|0,a=q[a+12>>2];de=u[a>>2],u[ce+44>>2]!=(de=(he=q[ce+16>>2])?(ee=de,de=u[ce+4>>2],ge=u[ce+12>>2],ee=x(x(ee-de)/ge),ie=x(C(ee)),fe=x(y(ie))>2],ee=u[ce+8>>2],de>2]=de,q[ce+48>>2]=1):q[ce+48>>2]=0,he||(u[a>>2]=de),a=a+4|0,(ce=ce+52|0)>>>0>>0;);}(a+540|0),function(a){var Td,Ud,Zd,$d,ae,be,Od=0,Pd=0,Qd=x(0),Rd=0,Sd=x(0),Vd=(x(0),x(0),0),Wd=x(0),Xd=0,Yd=0,_d=0;x(0);if(1<=(0|(Rd=q[a+540>>2])))for(ae=(Vd=q[a+544>>2])+w(Rd,52)|0,be=q[a+644>>2];;){a:if(!(q[Vd>>2]||(0|(Rd=q[Vd+32>>2]))<1))if(Zd=(a=q[Vd+28>>2])+w(Rd,28)|0,$d=u[Vd+24>>2],Ud=u[Vd+20>>2],Td=u[Vd+44>>2],be)for(;;){Wd=x(Yd=0);h:{i:{j:{if((0|(Pd=q[a>>2]))<1)Od=Rd=0;else if(Xd=q[a+4>>2],Sd=u[Xd>>2],Qd=x(Sd-Ud),1==(0|Pd))Rd=Td>2],!(Td>2],Td>2])break h;break i}Od=Pd+-1|0,Rd=1}Xd=(Pd=(Sd=u[a+12>>2])!=Wd)&(Wd==x(0)|Sd==x(0))|q[a+8>>2]!=(0|Od),Yd=Rd}if(q[a+20>>2]=Xd,q[a+24>>2]=Pd,u[a+12>>2]=Wd,q[a+16>>2]=Yd,q[a+8>>2]=Od,!((a=a+28|0)>>>0>>0))break}else{if(!q[Vd+48>>2])for(;;)if(q[a+20>>2]=0,!((a=a+28|(q[a+24>>2]=0))>>>0>>0))break a;for(;;){Wd=x(Yd=0);b:{c:{d:{e:if(!(((Pd=0)|(Od=q[(Xd=a)>>2]))<1)){if(Rd=q[a+4>>2],Sd=u[Rd>>2],Qd=x(Sd-Ud),1!=(0|Od)){if(!(Td>2],!(Td>2],Td>2]))break b}Yd=Rd,_d=(Pd=(Sd=u[a+12>>2])!=Wd)&(Wd==x(0)|Sd==x(0))|q[a+8>>2]!=(0|Od)}if(q[Xd+20>>2]=_d,q[a+24>>2]=Pd,u[a+12>>2]=Wd,q[a+16>>2]=Yd,q[a+8>>2]=Od,!((a=a+28|0)>>>0>>0))break}}if(!((Vd=Vd+52|0)>>>0>>0))break}}(a),function(a){var od,pd,qd,rd,sd,hd=0,id=x(0),jd=0,kd=0,ld=x(0),md=0,nd=x(0);x(0);if(!(r[q[a>>2]+4|0]<4||(0|(hd=q[a+540>>2]))<1))for(rd=(md=q[a+544>>2])+w(hd,52)|0,sd=q[a+644>>2];;){b:if(1==q[md>>2]&&!((0|(hd=q[md+40>>2]))<1))if(pd=(a=q[md+36>>2])+w(hd,28)|0,od=u[md+44>>2],sd)for(;;){nd=x(hd=0);d:if(!((0|(kd=q[a>>2]))<2||(jd=q[a+4>>2],od<=(id=u[jd>>2])))){hd=1;e:if(!(od<(ld=u[jd+4>>2]))){for(;id=ld,(0|kd)!=(0|(hd=hd+1|0));)if(od<(ld=u[jd+(hd<<2)>>2]))break e;hd=kd+-1|0;break d}nd=x(x(od-id)/x(ld-id)),hd=hd+-1|0}if(id=u[a+16>>2],u[a+16>>2]=nd,kd=q[a+12>>2],q[a+12>>2]=hd,q[a+24>>2]=jd=id!=nd,q[a+20>>2]=jd&(nd==x(0)|id==x(0))|(0|hd)!=(0|kd),!((a=a+28|0)>>>0>>0))break}else{if(!q[md+48>>2])for(;;)if(q[a+20>>2]=0,!((a=a+28|(q[a+24>>2]=0))>>>0>>0))break b;for(;;){nd=x(kd=0);c:if(!((0|(qd=q[a>>2]))<2||(jd=q[a+4>>2],od<=(id=u[jd>>2])))){if(hd=1,!(od<(ld=u[jd+4>>2])))for(kd=qd+-1|0;;){if(id=ld,(0|hd)==(0|kd))break c;if(od<(ld=u[jd+((hd=hd+1|0)<<2)>>2]))break}nd=x(x(od-id)/x(ld-id)),kd=hd+-1|0}if(id=u[a+16>>2],u[a+16>>2]=nd,hd=q[a+12>>2],q[a+12>>2]=kd,q[a+24>>2]=jd=id!=nd,q[a+20>>2]=jd&(nd==x(0)|id==x(0))|(0|hd)!=(0|kd),!((a=a+28|0)>>>0>>0))break}}if(!((md=md+52|0)>>>0>>0))break}}(a),function(a){var Fd,Gd,Jd,Kd,Ld,Md,Nd,td=0,ud=0,vd=0,wd=0,xd=0,yd=0,zd=0,Ad=x(0),Bd=0,Dd=0,Ed=0,Hd=0,Id=0;if(1<=(0|(ud=q[a+564>>2])))for(Md=(xd=q[a+568>>2])+w(ud,36)|0,Kd=q[a+644>>2];;){a:{if(!(yd=((vd=wd=ud=0)|(Gd=q[xd+4>>2]))<1))for(Bd=q[xd>>2],a=Hd=0;;){if(td=q[Bd+(a<<2)>>2],q[td+16>>2]){td=1,Id=0;break a}if(vd=vd||q[td+24>>2],ud=ud||q[td+20>>2],wd=(u[td+12>>2]!=x(0))+wd|0,(0|Gd)==(0|(a=a+1|0)))break}if(td=0,(Hd=Kd?1:vd)|(Id=Kd?1:ud)&&(q[xd+12>>2]=Bd=1<>2],Ld=q[xd>>2],vd=(a=q[xd+16>>2])+(zd=Bd<<2)|0,vd=ca(a,0,4+((-1^a)+((a=a+4|0)>>>0>>0?vd:a)|0)&-4),zd=ud+zd|0,a=ud;q[a>>2]=1065353216,(a=a+4|0)>>>0>>0;);if(!yd){if(yd=0,zd=td=1,wd)for(;;){if(wd=q[(yd<<2)+Ld>>2],Dd=q[wd+8>>2],Ed=w(Dd,td),(Ad=u[wd+12>>2])!=x(a=0)){for(q[vd>>2]=Ed+q[vd>>2],u[ud>>2]=x(x(1)-Ad)*u[ud>>2],Dd=w(Dd+(a=1)|0,td);Ad=u[wd+12>>2],Nd=q[(Jd=(Fd=a<<2)+vd|0)>>2],q[Jd>>2]=Nd+((Jd=a&zd)?Dd:Ed),u[(Fd=ud+Fd|0)>>2]=(Jd?Ad:x(x(1)-Ad))*u[Fd>>2],(0|Bd)!=(0|(a=a+1|0)););zd<<=1}else for(;q[(Dd=vd+(a<<2)|0)>>2]=Ed+q[Dd>>2],(0|Bd)!=(0|(a=a+1|0)););if(td=w(q[wd>>2],td),(0|Gd)==(0|(yd=yd+1|0)))break}else for(;;){if(wd=q[(yd<<2)+Ld>>2],zd=w(q[wd+8>>2],td),(Ad=u[wd+12>>2])!=x(a=0))q[vd>>2]=zd+q[vd>>2],u[ud>>2]=x(x(1)-Ad)*u[ud>>2];else for(;q[(Ed=vd+(a<<2)|0)>>2]=zd+q[Ed>>2],(0|Bd)!=(0|(a=a+1|0)););if(td=w(q[wd>>2],td),(0|Gd)==(0|(yd=yd+1|0)))break}td=0}}}if(q[xd+32>>2]=td,q[xd+24>>2]=Id,q[xd+28>>2]=Hd,!((xd=xd+36|0)>>>0>>0))break}}(a),function(a){var dd,ed,fd,gd,Tc=x(0),Uc=0,Vc=0,Wc=0,Xc=0,Yc=0,Zc=x(0),_c=x(0),$c=x(0),ad=0,bd=0,cd=0;if(!(r[q[a>>2]+4|0]<4||(0|(Uc=q[a+588>>2]))<1))for(gd=(Wc=q[a+592>>2])+w(Uc,48)|0,dd=q[a+644>>2];;){if(a=q[Wc>>2],(bd=dd?1:q[a+20>>2])|(cd=dd?1:q[a+24>>2])){c:{d:{Yc=Wc,Xc=q[a+8>>2],Uc=q[a+12>>2],Tc=u[a+16>>2],a=(0|Xc)!=(0|Uc);e:{if(Tc!=x(0)){if(a=Uc+1|0,(0|Uc)==(0|Xc)){q[Wc+8>>2]=bd=1,Tc=x(x(1)-Tc),cd=1;break e}a=(0|a)==(0|Xc)?1:2}if(q[Yc+8>>2]=a,!cd)break d;a=Uc}u[Wc+24>>2]=Tc,u[Wc+20>>2]=x(1)-Tc;break c}cd=0,a=Uc}bd?(q[Wc+12>>2]=a,q[Wc+16>>2]=a+1):bd=0}else bd=cd=0;g:if((0|(ed=q[Wc+36>>2]))<1)$c=x(1);else{if(fd=q[Wc+40>>2],a=0,$c=x(1),!dd)for(;;){h:{i:{if(Uc=q[(a<<2)+fd>>2],Vc=q[Uc>>2]){if(!q[Vc+48>>2]){Tc=u[Uc+16>>2];break h}if((0|(Xc=q[Uc+12>>2]))<1){Tc=x(1),u[Uc+16>>2]=1;break h}if(ad=q[Uc+8>>2],1!=(0|Xc)&&(Zc=u[Vc+44>>2],Yc=q[Uc+4>>2],!(Zc<=(_c=u[Yc>>2]))))break i;Tc=u[ad>>2],u[Uc+16>>2]=Tc;break h}q[Uc+16>>2]=1065353216,Tc=x(1);break h}Vc=1;j:if(!(Zc<(Tc=u[Yc+4>>2]))){for(;_c=Tc,(0|Xc)!=(0|(Vc=Vc+1|0));)if(Zc<(Tc=u[Yc+(Vc<<2)>>2]))break j;Tc=u[(ad+(Xc<<2)|0)-4>>2],u[Uc+16>>2]=Tc;break h}Yc=Uc,Tc=x(x(Zc-_c)/x(Tc-_c)),Tc=x(x(Tc*u[(Uc=ad+(Vc<<2)|0)>>2])+x(u[Uc+-4>>2]*x(x(1)-Tc))),u[Yc+16>>2]=Tc}if($c=$c>2],Vc=q[Uc>>2],Tc=x(1);l:if(Vc&&(ad=q[Uc+12>>2],Tc=x(1),!((0|ad)<1))&&(Xc=q[Uc+8>>2],Tc=u[Xc>>2],1!=(0|ad))){m:{if(Zc=u[Vc+44>>2],Yc=q[Uc+4>>2],Zc<=(_c=u[Yc>>2])){Tc=u[Xc>>2];break l}if(Vc=1,!(Zc<(Tc=u[Yc+4>>2]))){for(;_c=Tc,(0|ad)!=(0|(Vc=Vc+1|0));)if(Zc<(Tc=u[Yc+(Vc<<2)>>2]))break m;Tc=u[(Xc+(ad<<2)|0)-4>>2];break l}}Tc=x(x(Zc-_c)/x(Tc-_c)),Tc=x(x(Tc*u[(Vc=Xc+(Vc<<2)|0)>>2])+x(u[Vc+-4>>2]*x(x(1)-Tc)))}if($c=$c<(u[Uc+16>>2]=Tc)?$c:Tc,(0|ed)==(0|(a=a+1|0)))break}}if(q[Wc+32>>2]=cd,q[Wc+28>>2]=bd,u[Wc+44>>2]=$c,!((Wc=Wc+48|0)>>>0>>0))break}}(a),1<=(0|(Ea=q[a+4>>2])))for(Ea=(Aa=q[a+52>>2])+(Ea<<2)|0;_a=u[Aa>>2],u[Aa>>2]=_a>>0>>0;);!function(a){var Ce,De,Ee,Ae=0,Be=0;if(1<=(0|(Be=q[a+4>>2])))for(Ee=(Ae=q[a+8>>2])+w(Be,12)|0,a=Ce=q[a+40>>2];Be=0,q[Ae+8>>2]&&(De=q[Ae+4>>2],!q[(De<<2)+Ce>>2]&&-1!=(0|De)||(Be=!q[q[Ae>>2]+32>>2])),q[a>>2]=Be,a=a+4|0,(Ae=Ae+12|0)>>>0>>0;);}(a),function(a){var og,sg,tg,ug,vg,wg,xg,mg=0,ng=0,pg=0,qg=0,rg=0;if(1<=(0|(sg=q[a+4>>2])))for(ug=q[a+8>>2],tg=q[a>>2],vg=q[tg+724>>2];;){if(og=q[w(qg,12)+ug>>2],(q[og+28>>2]||q[og+24>>2])&&(q[(mg=qg<<2)+q[a+28>>2]>>2]=q[og+12>>2],q[og+24>>2])&&!((0|(pg=q[og+12>>2]))<1))for(pg=(ng=q[og+16>>2])+(pg<<2)|0,wg=q[mg+vg>>2],mg=q[a+36>>2]+(rg<<2)|0,xg=q[tg+976>>2];q[mg>>2]=q[(q[ng>>2]+wg<<2)+xg>>2],mg=mg+4|0,(ng=ng+4|0)>>>0>>0;);if(q[og+28>>2]&&!((0|(mg=q[og+12>>2]))<1))for(pg=(ng=q[og+20>>2])+(mg<<2)|0,mg=q[a+32>>2]+(rg<<2)|0;q[mg>>2]=q[ng>>2],mg=mg+4|0,(ng=ng+4|0)>>>0>>0;);if(rg=q[og+8>>2]+rg|0,(0|sg)==(0|(qg=qg+1|0)))break}}(a),n[q[1816]](a+12|0,q[a+36>>2],q[a+44>>2],q[a+40>>2]),function(a){var ue,ve,we,xe,ye,ze,re=0,se=0,te=0;if(1<=(0|(te=q[a+304>>2])))for(we=(re=q[a+308>>2])+(te<<5)|0,xe=q[a+264>>2],ye=q[a+144>>2],ze=q[a+40>>2],te=ve=q[a+312>>2];ue=te,se=0,a=se=!q[re+28>>2]||-1!=(0|(a=q[re+4>>2]))&&(se=0,!q[(a<<2)+ze>>2])||-1!=(0|(a=q[re+8>>2]))&&(se=0,!q[(a<<2)+ve>>2])?se:!q[q[re>>2]+32>>2],q[ue>>2]=a,(ue=q[re+12>>2])>>>0<=1?ue-1?q[(q[re+16>>2]<<2)+ye>>2]=a:q[(q[re+16>>2]<<2)+xe>>2]=a:Y(4,1372,0),te=te+4|0,(re=re+32|0)>>>0>>0;);}(a),function(a){var dg,eg,fg,gg,hg,ig,jg,kg,lg,Rf=0,Sf=0,Tf=0,Uf=0,Vf=0,Wf=0,Xf=0,Yf=0,Zf=0,_f=0,$f=0,ag=0,bg=0,cg=0,Vf=q[a>>2];if(1<=(0|(Yf=q[a+56>>2]))){for(Zf=q[a+60>>2],_f=q[Vf+1052>>2],$f=q[Vf+784>>2];;){if(Rf=q[Zf+w(Wf,24)>>2],(q[Rf+28>>2]||q[Rf+24>>2])&&(q[(Sf=Wf<<2)+q[a+80>>2]>>2]=q[Rf+12>>2],q[Rf+24>>2])&&!((0|(Uf=q[Rf+12>>2]))<1))for(ag=(Tf=q[Rf+16>>2])+(Uf<<2)|0,bg=q[Sf+$f>>2],Sf=(Uf=Xf<<2)+q[a+92>>2]|0,Uf=Uf+q[a+88>>2]|0;cg=bg+q[Tf>>2]<<2,q[Sf>>2]=_f+(q[cg+q[Vf+984>>2]>>2]<<2),q[Uf>>2]=q[cg+q[Vf+980>>2]>>2],Uf=Uf+4|0,Sf=Sf+4|0,(Tf=Tf+4|0)>>>0>>0;);if(q[Rf+28>>2]&&!((0|(Sf=q[Rf+12>>2]))<1))for(Uf=(Tf=q[Rf+20>>2])+(Sf<<2)|0,Sf=q[a+84>>2]+(Xf<<2)|0;q[Sf>>2]=q[Tf>>2],Sf=Sf+4|0,(Tf=Tf+4|0)>>>0>>0;);if(Xf=q[Rf+8>>2]+Xf|0,(0|Yf)==(0|(Wf=Wf+1|0)))break}Vf=q[a>>2]}if(!(r[Vf+4|0]<4||(0|(bg=q[a+56>>2]))<1))for(cg=q[Vf+792>>2],dg=q[a+60>>2],Tf=Xf=0;;){if(Wf=q[w(Tf,24)+dg>>2],q[Wf+24>>2]&&!((0|(Rf=q[Wf+12>>2]))<1))for(eg=(Sf=q[Wf+16>>2])+(Rf<<2)|0,fg=q[cg+(Tf<<2)>>2],Uf=(Rf=Xf<<2)+q[a+96>>2]|0,Yf=Rf+q[a+100>>2]|0,Zf=Rf+q[a+104>>2]|0,_f=Rf+q[a+108>>2]|0,$f=Rf+q[a+112>>2]|0,ag=Rf+q[a+116>>2]|0,gg=q[Vf+1308>>2],hg=q[Vf+1304>>2],ig=q[Vf+1300>>2],jg=q[Vf+1296>>2],kg=q[Vf+1292>>2],lg=q[Vf+1288>>2];Rf=q[Sf>>2]+fg<<2,q[Uf>>2]=q[Rf+lg>>2],q[Yf>>2]=q[Rf+kg>>2],q[Zf>>2]=q[Rf+jg>>2],q[_f>>2]=q[Rf+ig>>2],q[$f>>2]=q[Rf+hg>>2],q[ag>>2]=q[Rf+gg>>2],ag=ag+4|0,$f=$f+4|0,_f=_f+4|0,Zf=Zf+4|0,Yf=Yf+4|0,Uf=Uf+4|0,(Sf=Sf+4|0)>>>0>>0;);if(Xf=q[Wf+8>>2]+Xf|0,(0|bg)==(0|(Tf=Tf+1|0)))break}}(a),function(a){var uf=0,vf=0,xf=0,yf=0,zf=0,Af=0,Bf=0,Cf=0,Df=0,Ef=0,Ff=0,Gf=0,Hf=0,If=0,Jf=0,Kf=0,Lf=0,Mf=0,Nf=0,Of=0,Pf=0,Qf=q[a+168>>2],wf=q[a>>2];if(1<=(0|(Hf=q[a+164>>2])))for(Jf=q[wf+816>>2];;){if(xf=q[w(Bf,12)+Qf>>2],(q[xf+28>>2]||q[xf+24>>2])&&(q[(Cf=Bf<<2)+q[a+188>>2]>>2]=q[xf+12>>2],q[xf+24>>2])){if(vf=q[xf+16>>2],If=q[Cf+Jf>>2],1<=(0|(uf=q[xf+12>>2])))for(Kf=vf+(uf<<2)|0,yf=(uf=Af<<2)+q[a+200>>2]|0,Df=uf+q[a+204>>2]|0,Ef=uf+q[a+208>>2]|0,Ff=uf+q[a+212>>2]|0,Gf=uf+q[a+196>>2]|0,Lf=q[wf+996>>2],Mf=q[wf+1012>>2],Nf=q[wf+1008>>2],Of=q[wf+1004>>2],Pf=q[wf+1e3>>2],uf=vf;zf=If+q[uf>>2]<<2,q[yf>>2]=q[zf+Pf>>2],q[Df>>2]=q[zf+Of>>2],q[Ef>>2]=q[zf+Nf>>2],q[Ff>>2]=q[zf+Mf>>2],q[Gf>>2]=q[zf+Lf>>2],Gf=Gf+4|0,Ff=Ff+4|0,Ef=Ef+4|0,Df=Df+4|0,yf=yf+4|0,(uf=uf+4|0)>>>0>>0;);uf=If+q[vf>>2]<<2,q[Cf+q[a+288>>2]>>2]=q[uf+q[wf+1016>>2]>>2],q[Cf+q[a+292>>2]>>2]=q[uf+q[wf+1020>>2]>>2]}if(q[xf+28>>2]&&!((0|(vf=q[xf+12>>2]))<1))for(vf=(uf=q[xf+20>>2])+(vf<<2)|0,yf=q[a+192>>2]+(Af<<2)|0;q[yf>>2]=q[uf>>2],yf=yf+4|0,(uf=uf+4|0)>>>0>>0;);if(Af=q[xf+8>>2]+Af|0,(0|Hf)==(0|(Bf=Bf+1|0)))break}if(!(r[wf+4|0]<4||(0|(Cf=q[a+164>>2]))<1))for(If=q[wf+824>>2],Af=xf=0;;){if(zf=q[w(Af,12)+Qf>>2],q[zf+24>>2]&&!((0|(uf=q[zf+12>>2]))<1))for(Hf=(yf=q[zf+16>>2])+(uf<<2)|0,Jf=q[If+(Af<<2)>>2],Df=(vf=xf<<2)+q[a+216>>2]|0,Ef=vf+q[a+220>>2]|0,Ff=vf+q[a+224>>2]|0,Gf=vf+q[a+228>>2]|0,uf=vf+q[a+232>>2]|0,Bf=vf+q[a+236>>2]|0,Kf=q[wf+1308>>2],Lf=q[wf+1304>>2],Mf=q[wf+1300>>2],Nf=q[wf+1296>>2],Of=q[wf+1292>>2],Pf=q[wf+1288>>2];vf=Jf+q[yf>>2]<<2,q[Df>>2]=q[vf+Pf>>2],q[Ef>>2]=q[vf+Of>>2],q[Ff>>2]=q[vf+Nf>>2],q[Gf>>2]=q[vf+Mf>>2],q[uf>>2]=q[vf+Lf>>2],q[Bf>>2]=q[vf+Kf>>2],Bf=Bf+4|0,uf=uf+4|0,Gf=Gf+4|0,Ff=Ff+4|0,Ef=Ef+4|0,Df=Df+4|0,(yf=yf+4|0)>>>0>>0;);if(xf=q[zf+8>>2]+xf|0,(0|Cf)==(0|(Af=Af+1|0)))break}}(a),function(a){var wk,ok=0,pk=0,qk=0,rk=0,sk=0,tk=0,uk=0,vk=0,ok=a- -64|0;if(n[q[1815]](ok,q[a+88>>2],q[a+148>>2],q[a+144>>2]),n[q[1817]](ok,q[a+92>>2],q[a+152>>2],q[q[a>>2]+796>>2],2,q[a+144>>2]),!(r[q[a>>2]+4|0]<4||(n[q[1815]](ok,q[a+96>>2],q[a+120>>2],q[a+144>>2]),n[q[1815]](ok,q[a+100>>2],q[a+124>>2],q[a+144>>2]),n[q[1815]](ok,q[a+104>>2],q[a+128>>2],q[a+144>>2]),n[q[1815]](ok,q[a+108>>2],q[a+132>>2],q[a+144>>2]),n[q[1815]](ok,q[a+112>>2],q[a+136>>2],q[a+144>>2]),n[q[1815]](ok,q[a+116>>2],q[a+140>>2],q[a+144>>2]),(0|(tk=q[a+56>>2]))<1))){for(uk=q[a+128>>2],vk=q[a+124>>2],wk=q[a+120>>2],pk=q[a+156>>2],ok=0;q[(qk=rk<<2)+pk>>2]=q[(sk=ok<<2)+wk>>2],q[pk+(4|qk)>>2]=q[sk+vk>>2],q[pk+(8|qk)>>2]=q[sk+uk>>2],rk=rk+4|0,(0|tk)!=(0|(ok=ok+1|0)););for(pk=q[a+160>>2],sk=q[a+140>>2],uk=q[a+136>>2],vk=q[a+132>>2],ok=a=0;q[(rk=a<<2)+pk>>2]=q[(qk=ok<<2)+vk>>2],q[pk+(4|rk)>>2]=q[qk+uk>>2],q[pk+(8|rk)>>2]=q[qk+sk>>2],a=a+4|0,(0|tk)!=(0|(ok=ok+1|0)););}}(a),function(a){var nk,fk=0,gk=0,hk=0,ik=0,jk=0,kk=0,lk=0,mk=0,fk=a+172|0;if(n[q[1815]](fk,q[a+196>>2],q[a+268>>2],q[a+264>>2]),n[q[1815]](fk,q[a+200>>2],q[a+284>>2],q[a+264>>2]),n[q[1815]](fk,q[a+204>>2],q[a+276>>2],q[a+264>>2]),n[q[1815]](fk,q[a+208>>2],q[a+280>>2],q[a+264>>2]),n[q[1815]](fk,q[a+212>>2],q[a+272>>2],q[a+264>>2]),!(r[q[a>>2]+4|0]<4||(n[q[1815]](fk,q[a+216>>2],q[a+240>>2],q[a+264>>2]),n[q[1815]](fk,q[a+220>>2],q[a+244>>2],q[a+264>>2]),n[q[1815]](fk,q[a+224>>2],q[a+248>>2],q[a+264>>2]),n[q[1815]](fk,q[a+228>>2],q[a+252>>2],q[a+264>>2]),n[q[1815]](fk,q[a+232>>2],q[a+256>>2],q[a+264>>2]),n[q[1815]](fk,q[a+236>>2],q[a+260>>2],q[a+264>>2]),(0|(kk=q[a+164>>2]))<1))){for(lk=q[a+248>>2],mk=q[a+244>>2],nk=q[a+240>>2],gk=q[a+296>>2],fk=0;q[(hk=ik<<2)+gk>>2]=q[(jk=fk<<2)+nk>>2],q[gk+(4|hk)>>2]=q[jk+mk>>2],q[gk+(8|hk)>>2]=q[jk+lk>>2],ik=ik+4|0,(0|kk)!=(0|(fk=fk+1|0)););for(gk=q[a+300>>2],jk=q[a+260>>2],lk=q[a+256>>2],mk=q[a+252>>2],fk=a=0;q[(ik=a<<2)+gk>>2]=q[(hk=fk<<2)+mk>>2],q[gk+(4|ik)>>2]=q[hk+lk>>2],q[gk+(8|ik)>>2]=q[hk+jk>>2],a=a+4|0,(0|kk)!=(0|(fk=fk+1|0)););}}(a),function(a){var oe,pe,qe,le=0,me=0,ne=0;if(1<=(0|(me=q[a+332>>2])))for(oe=(le=q[a+336>>2])+w(me,20)|0,pe=q[a+312>>2],qe=q[a+40>>2],a=q[a+424>>2];me=0,q[le+12>>2]&&(ne=q[le+4>>2],q[(ne<<2)+qe>>2]||-1==(0|ne))&&(ne=q[le+8>>2],q[(ne<<2)+pe>>2]||-1==(0|ne))&&(me=!q[q[le>>2]+32>>2]),q[a>>2]=me,a=a+4|0,(le=le+20|0)>>>0>>0;);}(a),function(a){var mf,nf,of,pf,qf,rf,sf,tf,Ye=0,Ze=0,_e=0,$e=0,af=0,bf=0,cf=0,df=0,ef=0,ff=0,gf=0,hf=0,jf=0,kf=0,lf=0,$e=q[a>>2];if(1<=(0|(ff=q[a+332>>2]))){for(gf=q[a+336>>2],hf=q[$e+1052>>2],jf=q[$e+856>>2];;){if(Ye=q[gf+w(cf,20)>>2],(q[Ye+28>>2]||q[Ye+24>>2])&&(q[(Ze=cf<<2)+q[a+356>>2]>>2]=q[Ye+12>>2],q[Ye+24>>2])&&!((0|(af=q[Ye+12>>2]))<1))for(kf=(_e=q[Ye+16>>2])+(af<<2)|0,lf=q[Ze+jf>>2],Ze=(bf=df<<2)+q[a+372>>2]|0,af=bf+q[a+364>>2]|0,bf=bf+q[a+368>>2]|0;ef=lf+q[_e>>2]<<2,q[Ze>>2]=hf+(q[ef+q[$e+1040>>2]>>2]<<2),q[af>>2]=q[ef+q[$e+1032>>2]>>2],q[bf>>2]=q[ef+q[$e+1036>>2]>>2],bf=bf+4|0,af=af+4|0,Ze=Ze+4|0,(_e=_e+4|0)>>>0>>0;);if(q[Ye+28>>2]&&!((0|(Ze=q[Ye+12>>2]))<1))for(af=(_e=q[Ye+20>>2])+(Ze<<2)|0,Ze=q[a+360>>2]+(df<<2)|0;q[Ze>>2]=q[_e>>2],Ze=Ze+4|0,(_e=_e+4|0)>>>0>>0;);if(df=q[Ye+8>>2]+df|0,(0|ff)==(0|(cf=cf+1|0)))break}$e=q[a>>2]}if(!(r[$e+4|0]<4||(0|(jf=q[a+332>>2]))<1))for(kf=q[$e+864>>2],lf=q[a+336>>2],_e=df=0;;){if(cf=q[lf+w(_e,20)>>2],q[cf+24>>2]&&!((0|(Ye=q[cf+12>>2]))<1))for(mf=(Ze=q[cf+16>>2])+(Ye<<2)|0,nf=q[kf+(_e<<2)>>2],af=(Ye=df<<2)+q[a+376>>2]|0,bf=Ye+q[a+380>>2]|0,ef=Ye+q[a+384>>2]|0,ff=Ye+q[a+388>>2]|0,gf=Ye+q[a+392>>2]|0,hf=Ye+q[a+396>>2]|0,of=q[$e+1308>>2],pf=q[$e+1304>>2],qf=q[$e+1300>>2],rf=q[$e+1296>>2],sf=q[$e+1292>>2],tf=q[$e+1288>>2];Ye=q[Ze>>2]+nf<<2,q[af>>2]=q[Ye+tf>>2],q[bf>>2]=q[Ye+sf>>2],q[ef>>2]=q[Ye+rf>>2],q[ff>>2]=q[Ye+qf>>2],q[gf>>2]=q[Ye+pf>>2],q[hf>>2]=q[Ye+of>>2],hf=hf+4|0,gf=gf+4|0,ff=ff+4|0,ef=ef+4|0,bf=bf+4|0,af=af+4|0,(Ze=Ze+4|0)>>>0>>0;);if(df=q[cf+8>>2]+df|0,(0|jf)==(0|(_e=_e+1|0)))break}}(a),function(a){var ek,tj=0,vj=0,wj=0,$j=0,ak=0,bk=0,ck=0,dk=0,tj=a+340|0;if(n[q[1815]](tj,q[a+364>>2],q[a+448>>2],q[a+424>>2]),n[q[1816]](tj,q[a+368>>2],q[a+440>>2],q[a+424>>2]),n[q[1817]](tj,q[a+372>>2],q[a+444>>2],q[q[a>>2]+892>>2],2,q[a+424>>2]),!(r[q[a>>2]+4|0]<4||(n[q[1815]](tj,q[a+376>>2],q[a+400>>2],q[a+424>>2]),n[q[1815]](tj,q[a+380>>2],q[a+404>>2],q[a+424>>2]),n[q[1815]](tj,q[a+384>>2],q[a+408>>2],q[a+424>>2]),n[q[1815]](tj,q[a+388>>2],q[a+412>>2],q[a+424>>2]),n[q[1815]](tj,q[a+392>>2],q[a+416>>2],q[a+424>>2]),n[q[1815]](tj,q[a+396>>2],q[a+420>>2],q[a+424>>2]),(0|(bk=q[a+332>>2]))<1))){for(ck=q[a+408>>2],dk=q[a+404>>2],ek=q[a+400>>2],vj=q[a+452>>2],tj=0;q[(wj=$j<<2)+vj>>2]=q[(ak=tj<<2)+ek>>2],q[vj+(4|wj)>>2]=q[ak+dk>>2],q[vj+(8|wj)>>2]=q[ak+ck>>2],$j=$j+4|0,(0|bk)!=(0|(tj=tj+1|0)););for(vj=q[a+456>>2],ak=q[a+420>>2],ck=q[a+416>>2],dk=q[a+412>>2],tj=a=0;q[($j=a<<2)+vj>>2]=q[(wj=tj<<2)+dk>>2],q[vj+(4|$j)>>2]=q[wj+ck>>2],q[vj+(8|$j)>>2]=q[wj+ak>>2],a=a+4|0,(0|bk)!=(0|(tj=tj+1|0)););}}(a),function(a){var Oe,Se,Te,Ue,Ve,We,Xe,Me=0,Ne=0,Pe=0,Qe=0,Re=0;if(1<=(0|(Se=q[a+500>>2])))for(Ue=q[a+504>>2],Te=q[a>>2],Ve=q[Te+1252>>2];;){if(Oe=q[w(Qe,24)+Ue>>2],(q[Oe+28>>2]||q[Oe+24>>2])&&(q[(Me=Qe<<2)+q[a+524>>2]>>2]=q[Oe+12>>2],q[Oe+24>>2])&&!((0|(Pe=q[Oe+12>>2]))<1))for(Pe=(Ne=q[Oe+16>>2])+(Pe<<2)|0,We=q[Me+Ve>>2],Me=q[a+532>>2]+(Re<<2)|0,Xe=q[Te+1284>>2];q[Me>>2]=q[(q[Ne>>2]+We<<2)+Xe>>2],Me=Me+4|0,(Ne=Ne+4|0)>>>0>>0;);if(q[Oe+28>>2]&&!((0|(Me=q[Oe+12>>2]))<1))for(Pe=(Ne=q[Oe+20>>2])+(Me<<2)|0,Me=q[a+528>>2]+(Re<<2)|0;q[Me>>2]=q[Ne>>2],Me=Me+4|0,(Ne=Ne+4|0)>>>0>>0;);if(Re=q[Oe+8>>2]+Re|0,(0|Se)==(0|(Qe=Qe+1|0)))break}}(a),n[q[1815]](a+508|0,q[a+532>>2],q[a+536>>2],0),function(a){var Ck,Dk,Ek,Fk,Gk,Hk,xk=x(0),yk=0,zk=0,Ak=0,Bk=0;x(0);if(L=Ck=L-16|0,Ak=q[a>>2],!(r[Ak+4|0]<5||(0|(Bk=q[a+596>>2]))<1))for(Fk=(zk=q[a+600>>2])+w(Bk,12)|0,Gk=q[a+44>>2],Bk=q[Ak+976>>2];;){if(Ak=(q[zk>>2]<<2)+Gk|0,xk=x(q[Ak>>2]),1<=(0|(yk=q[zk+4>>2])))for(Hk=(a=q[zk+8>>2])+w(yk,48)|0;(yk=q[a+8>>2])&&((Dk=yk+-1|0)>>>0<=1?(yk=q[a+4>>2],Ek=u[Bk+(yk+q[a+12>>2]<<2)>>2],xk=x(Dk-1?xk+x(u[a+44>>2]*x(Ek*u[a+20>>2])):xk+x(u[a+44>>2]*x(x(Ek*u[a+20>>2])+x(u[Bk+(yk+q[a+16>>2]<<2)>>2]*u[a+24>>2]))))):(q[Ck>>2]=yk,Y(4,1024,Ck))),(a=a+48|0)>>>0>>0;);if(xk=(xk=x(xk+x(.0010000000474974513)))>2]=a,!((zk=zk+12|0)>>>0>>0))break}L=16+Ck|0}(a),function(a){var kj,lj,mj,nj,oj,pj,qj,rj,sj,cj=0,ej=0,gj=0,hj=0,ij=0,jj=x(0);if(L=kj=L-16|0,cj=q[a>>2],!(r[cj+4|0]<4||(va(a,q[a+604>>2],q[a+608>>2],q[cj+984>>2],q[a+152>>2],q[cj+796>>2]),ej=q[a>>2],r[ej+4|0]<5))){if(gj=q[a+608>>2],oj=q[ej+992>>2],pj=q[ej+988>>2],1<=(0|(cj=q[a+604>>2]))){for(qj=w(cj,12)+gj|0,rj=q[a+148>>2],lj=q[ej+980>>2];;){if(mj=(q[gj>>2]<<2)+rj|0,ij=q[mj>>2],1<=(0|(hj=q[gj+4>>2])))for(sj=(cj=q[gj+8>>2])+w(hj,48)|0;(hj=q[cj+8>>2])&&((nj=hj+-1|0)>>>0<=1?(hj=q[cj+4>>2],jj=u[(hj+q[cj+12>>2]<<2)+lj>>2],j(x(nj-1?x(u[cj+44>>2]*x(jj*u[cj+20>>2]))+(f(0,ij),k()):x(u[cj+44>>2]*x(x(jj*u[cj+20>>2])+x(u[(hj+q[cj+16>>2]<<2)+lj>>2]*u[cj+24>>2])))+(f(0,ij),k()))),ij=b[0]):(q[kj>>2]=hj,Y(4,1024,kj))),(cj=cj+48|0)>>>0>>0;);if(f(0,ij),jj=k(),u[mj>>2]=jj>>0>>0))break}gj=q[a+608>>2],cj=q[a+604>>2]}fa(cj,gj,pj,q[ej+1288>>2],q[ej+1292>>2],q[ej+1296>>2],q[a+156>>2]),fa(q[a+604>>2],q[a+608>>2],oj,q[ej+1300>>2],q[ej+1304>>2],q[ej+1308>>2],q[a+160>>2])}L=16+kj|0}(a),function(a){var yi,ri=0,si=0,ti=0,ui=0,vi=0,wi=x(0),xi=0,zi=0,Ai=0,Bi=0,Ci=0,Di=0,Ei=0,Fi=0;if(L=yi=L-80|0,vi=q[a>>2],!(r[vi+4|0]<5)){if(Di=q[vi+1028>>2],Ei=q[vi+1024>>2],ti=si=q[a+616>>2],!((0|(ri=q[a+612>>2]))<1)){for(zi=w(ri,12)+si|0,Ai=q[a+276>>2],xi=q[vi+1004>>2];;){if(Bi=Ai+(q[si>>2]<<2)|0,ti=q[Bi>>2],1<=(0|(ui=q[si+4>>2])))for(Fi=(ri=q[si+8>>2])+w(ui,48)|0;(ui=q[ri+8>>2])&&((Ci=ui+-1|0)>>>0<=1?(ui=q[ri+4>>2],wi=u[xi+(ui+q[ri+12>>2]<<2)>>2],j(x(Ci-1?x(u[ri+44>>2]*x(wi*u[ri+20>>2]))+(f(0,ti),k()):x(u[ri+44>>2]*x(x(wi*u[ri+20>>2])+x(u[xi+(ui+q[ri+16>>2]<<2)>>2]*u[ri+24>>2])))+(f(0,ti),k()))),ti=b[0]):(q[64+yi>>2]=ui,Y(4,1024,64+yi|0))),(ri=ri+48|0)>>>0>>0;);if(q[Bi>>2]=ti,!((si=si+12|0)>>>0>>0))break}if(ti=si=q[a+616>>2],!((0|(ri=q[a+612>>2]))<1)){for(zi=w(ri,12)+si|0,Ai=q[a+280>>2],xi=q[q[a>>2]+1008>>2];;){if(Bi=Ai+(q[si>>2]<<2)|0,ti=q[Bi>>2],1<=(0|(ui=q[si+4>>2])))for(Fi=(ri=q[si+8>>2])+w(ui,48)|0;(ui=q[ri+8>>2])&&((Ci=ui+-1|0)>>>0<=1?(ui=q[ri+4>>2],wi=u[xi+(ui+q[ri+12>>2]<<2)>>2],j(x(Ci-1?x(u[ri+44>>2]*x(wi*u[ri+20>>2]))+(f(0,ti),k()):x(u[ri+44>>2]*x(x(wi*u[ri+20>>2])+x(u[xi+(ui+q[ri+16>>2]<<2)>>2]*u[ri+24>>2])))+(f(0,ti),k()))),ti=b[0]):(q[48+yi>>2]=ui,Y(4,1024,48+yi|0))),(ri=ri+48|0)>>>0>>0;);if(q[Bi>>2]=ti,!((si=si+12|0)>>>0>>0))break}if(ti=si=q[a+616>>2],!((0|(ri=q[a+612>>2]))<1)){for(zi=w(ri,12)+si|0,Ai=q[a+268>>2],xi=q[q[a>>2]+996>>2];;){if(Bi=Ai+(q[si>>2]<<2)|0,ti=q[Bi>>2],1<=(0|(ui=q[si+4>>2])))for(Fi=(ri=q[si+8>>2])+w(ui,48)|0;(ui=q[ri+8>>2])&&((Ci=ui+-1|0)>>>0<=1?(ui=q[ri+4>>2],wi=u[xi+(ui+q[ri+12>>2]<<2)>>2],j(x(Ci-1?x(u[ri+44>>2]*x(wi*u[ri+20>>2]))+(f(0,ti),k()):x(u[ri+44>>2]*x(x(wi*u[ri+20>>2])+x(u[xi+(ui+q[ri+16>>2]<<2)>>2]*u[ri+24>>2])))+(f(0,ti),k()))),ti=b[0]):(q[32+yi>>2]=ui,Y(4,1024,32+yi|0))),(ri=ri+48|0)>>>0>>0;);if(f(0,ti),wi=k(),u[Bi>>2]=wi>>0>>0))break}ri=q[a+612>>2],ti=q[a+616>>2]}}}if(fa(ri,ti,Ei,q[vi+1288>>2],q[vi+1292>>2],q[vi+1296>>2],q[a+296>>2]),fa(q[a+612>>2],q[a+616>>2],Di,q[vi+1300>>2],q[vi+1304>>2],q[vi+1308>>2],q[a+300>>2]),!((0|(ri=q[a+612>>2]))<1)){for(ui=(si=q[a+616>>2])+w(ri,12)|0,Di=q[a+284>>2],vi=q[q[a>>2]+1e3>>2];;){if(Ei=Di+(q[si>>2]<<2)|0,ti=q[Ei>>2],1<=(0|(xi=q[si+4>>2])))for(zi=(ri=q[si+8>>2])+w(xi,48)|0;(xi=q[ri+8>>2])&&((Ai=xi+-1|0)>>>0<=1?(xi=q[ri+4>>2],wi=u[vi+(xi+q[ri+12>>2]<<2)>>2],j(x(Ai-1?x(u[ri+44>>2]*x(wi*u[ri+20>>2]))+(f(0,ti),k()):x(u[ri+44>>2]*x(x(wi*u[ri+20>>2])+x(u[vi+(xi+q[ri+16>>2]<<2)>>2]*u[ri+24>>2])))+(f(0,ti),k()))),ti=b[0]):(q[16+yi>>2]=xi,Y(4,1024,16+yi|0))),(ri=ri+48|0)>>>0>>0;);if(f(0,ti),wi=k(),u[Ei>>2]=wi>>0>>0))break}if(!((0|(ri=q[a+612>>2]))<1))for(xi=(si=q[a+616>>2])+w(ri,12)|0,ui=q[a+272>>2],a=q[q[a>>2]+1012>>2];;){if(Di=ui+(q[si>>2]<<2)|0,ti=q[Di>>2],1<=(0|(vi=q[si+4>>2])))for(Ei=(ri=q[si+8>>2])+w(vi,48)|0;(vi=q[ri+8>>2])&&((zi=vi+-1|0)>>>0<=1?(vi=q[ri+4>>2],wi=u[a+(vi+q[ri+12>>2]<<2)>>2],j(x(zi-1?x(u[ri+44>>2]*x(wi*u[ri+20>>2]))+(f(0,ti),k()):x(u[ri+44>>2]*x(x(wi*u[ri+20>>2])+x(u[a+(vi+q[ri+16>>2]<<2)>>2]*u[ri+24>>2])))+(f(0,ti),k()))),ti=b[0]):(q[yi>>2]=vi,Y(4,1024,yi))),(ri=ri+48|0)>>>0>>0;);if(f(0,ti),wi=k(),u[Di>>2]=wi>>0>>0))break}}}L=80+yi|0}(a),function(a){var Zh,di,ei,fi,gi,Sh=0,Uh=0,Vh=0,Wh=0,Xh=x(0),Yh=0,_h=0,$h=0,ai=0,bi=0,ci=0;x(0);if(L=Zh=L-32|0,Vh=q[a>>2],!(r[Vh+4|0]<4||(va(a,q[a+620>>2],q[a+624>>2],q[Vh+1040>>2],q[a+444>>2],q[Vh+892>>2]),Yh=q[a>>2],r[Yh+4|0]<5))){if(fi=q[Yh+1048>>2],gi=q[Yh+1044>>2],Vh=Wh=q[a+624>>2],!((0|(Sh=q[a+620>>2]))<1)){for(bi=w(Sh,12)+Wh|0,ci=q[a+440>>2],_h=q[Yh+1036>>2];;){if(Vh=ci+(q[Wh>>2]<<2)|0,Xh=x(q[Vh>>2]),1<=(0|(Uh=q[Wh+4>>2])))for($h=(Sh=q[Wh+8>>2])+w(Uh,48)|0;(Uh=q[Sh+8>>2])&&((ai=Uh+-1|0)>>>0<=1?(Uh=q[Sh+4>>2],di=u[_h+(Uh+q[Sh+12>>2]<<2)>>2],Xh=x(ai-1?Xh+x(u[Sh+44>>2]*x(di*u[Sh+20>>2])):Xh+x(u[Sh+44>>2]*x(x(di*u[Sh+20>>2])+x(u[_h+(Uh+q[Sh+16>>2]<<2)>>2]*u[Sh+24>>2]))))):(q[16+Zh>>2]=Uh,Y(4,1024,16+Zh|0))),(Sh=Sh+48|0)>>>0<$h>>>0;);if(Xh=(Xh=x(Xh+x(.0010000000474974513)))>2]=Sh,!((Wh=Wh+12|0)>>>0>>0))break}if(Vh=Wh=q[a+624>>2],!((0|(Sh=q[a+620>>2]))<1)){for(bi=w(Sh,12)+Wh|0,ci=q[a+448>>2],_h=q[q[a>>2]+1032>>2];;){if($h=ci+(q[Wh>>2]<<2)|0,Vh=q[$h>>2],1<=(0|(Uh=q[Wh+4>>2])))for(ai=(Sh=q[Wh+8>>2])+w(Uh,48)|0;(Uh=q[Sh+8>>2])&&((ei=Uh+-1|0)>>>0<=1?(Uh=q[Sh+4>>2],Xh=u[_h+(Uh+q[Sh+12>>2]<<2)>>2],j(x(ei-1?x(u[Sh+44>>2]*x(Xh*u[Sh+20>>2]))+(f(0,Vh),k()):x(u[Sh+44>>2]*x(x(Xh*u[Sh+20>>2])+x(u[_h+(Uh+q[Sh+16>>2]<<2)>>2]*u[Sh+24>>2])))+(f(0,Vh),k()))),Vh=b[0]):(q[Zh>>2]=Uh,Y(4,1024,Zh))),(Sh=Sh+48|0)>>>0>>0;);if(f(0,Vh),Xh=k(),u[$h>>2]=Xh>>0>>0))break}Sh=q[a+620>>2],Vh=q[a+624>>2]}}fa(Sh,Vh,gi,q[Yh+1288>>2],q[Yh+1292>>2],q[Yh+1296>>2],q[a+452>>2]),fa(q[a+620>>2],q[a+624>>2],fi,q[Yh+1300>>2],q[Yh+1304>>2],q[Yh+1308>>2],q[a+456>>2])}L=32+Zh|0}(a),function(a){var Og,Pg,Qg,Rg,Sg,Tg,Jg=0,Kg=0,Lg=0,Mg=0,Ng=x(0);if(L=Og=L-16|0,Kg=q[a>>2],!(r[Kg+4|0]<5||(0|(Mg=q[a+628>>2]))<1))for(Rg=(Lg=q[a+632>>2])+w(Mg,12)|0,Sg=q[a+536>>2],Mg=q[Kg+1284>>2];;){if(Pg=(q[Lg>>2]<<2)+Sg|0,Kg=q[Pg>>2],1<=(0|(Jg=q[Lg+4>>2])))for(Tg=(a=q[Lg+8>>2])+w(Jg,48)|0;(Jg=q[a+8>>2])&&((Qg=Jg+-1|0)>>>0<=1?(Jg=q[a+4>>2],Ng=u[Mg+(Jg+q[a+12>>2]<<2)>>2],j(x(Qg-1?x(u[a+44>>2]*x(Ng*u[a+20>>2]))+(f(0,Kg),k()):x(u[a+44>>2]*x(x(Ng*u[a+20>>2])+x(u[Mg+(Jg+q[a+16>>2]<<2)>>2]*u[a+24>>2])))+(f(0,Kg),k()))),Kg=b[0]):(q[Og>>2]=Jg,Y(4,1024,Og))),(a=a+48|0)>>>0>>0;);if(f(0,Kg),Ng=k(),u[Pg>>2]=Ng>>0>>0))break}L=16+Og|0}(a),function(a){var Nh,Qh,jh=0,Lh=0,Mh=0,Oh=x(0),Ph=0;if(1<=(0|(jh=q[a+4>>2])))for(Qh=(Lh=q[a+8>>2])+w(jh,12)|0,jh=q[a+40>>2],Mh=q[a+52>>2],a=Nh=q[a+48>>2];q[jh>>2]&&(Oh=u[Mh>>2],u[a>>2]=Oh,-1!=(0|(Ph=q[Lh+4>>2])))&&(u[a>>2]=Oh*u[(Ph<<2)+Nh>>2]),a=a+4|0,Mh=Mh+4|0,jh=jh+4|0,(Lh=Lh+12|0)>>>0>>0;);}(a),function(a){var ih,fh=0,gh=0,hh=0;if(1<=(0|(ih=q[a+304>>2])))for(fh=q[a+308>>2],gh=q[a+312>>2];q[gh>>2]&&n[q[fh+20>>2]](a,hh),gh=gh+4|0,fh=fh+32|0,(0|ih)!=(0|(hh=hh+1|0)););}(a),function(a){var Wg,Xg,$g,dh,eh,Ug=0,Vg=0,Yg=(x(0),x(0),0),Zg=0,_g=0,ah=(x(0),0),bh=0,ch=0;if(1<=(0|(Ug=q[a+332>>2])))for(bh=(Vg=q[a+336>>2])+w(Ug,20)|0,ch=q[a+308>>2],ah=q[a+316>>2],eh=q[a+48>>2],Ug=q[a+448>>2],Yg=q[a+444>>2],_g=q[a+424>>2];q[_g>>2]&&(-1!=(0|(Zg=q[Vg+4>>2]))&&(u[Ug>>2]=u[(Zg<<2)+eh>>2]*u[Ug>>2]),-1!=(0|(Zg=q[Vg+8>>2])))&&(u[Ug>>2]=u[ah+(Zg<<2)>>2]*u[Ug>>2],dh=q[Yg>>2],n[q[24+(ch+(Zg<<5)|0)>>2]](a,Zg,dh,dh,q[Vg+16>>2])),Yg=Yg+4|0,Ug=Ug+4|0,_g=_g+4|0,(Vg=Vg+20|0)>>>0>>0;);if(!(r[q[a>>2]+4|0]<4||(0|(Ug=q[a+332>>2]))<1))for(Zg=(Yg=q[a+336>>2])+w(Ug,20)|0,bh=q[a+328>>2],ch=q[a+324>>2],Vg=q[a+452>>2],Ug=q[a+456>>2],_g=q[a+424>>2];q[_g>>2]&&-1!=(0|(a=q[Yg+8>>2]))&&(a=(ah=a<<4)+ch|0,Wg=x(u[Vg>>2]*u[a>>2]),u[Vg>>2]=Wg,Xg=x(u[Vg+4>>2]*u[a+4>>2]),u[Vg+4>>2]=Xg,$g=u[a+8>>2],q[Vg+12>>2]=1065353216,u[Vg+4>>2]=Xg>2]=Wg>2]),u[Vg+8>>2]=Wg>2],Xg=u[(a=bh+ah|0)>>2],Wg=x(x(Wg+Xg)-x(Wg*Xg)),u[Ug>>2]=Wg,Xg=u[Ug+4>>2],$g=u[a+4>>2],Xg=x(x(Xg+$g)-x(Xg*$g)),u[Ug+4>>2]=Xg,$g=u[a+8>>2],q[Ug+12>>2]=1065353216,u[Ug+4>>2]=Xg>2]=Wg>2],Wg=x(x($g+Wg)-x(Wg*$g)),u[Ug+8>>2]=Wg>>0>>0;);}(a),function(a){var Mn,Nn,Pn,Lo,Mo,No,Oo,Po,Qo,Ro,So,To,Uo,Vo,Wo,Xo,Yo,Zo,_o,$o,On=0;x(0),x(0),x(0),x(0),x(0),x(0),x(0);if(1<=(0|(Po=q[a+500>>2])))for(_o=q[a+536>>2],Qo=q[a+444>>2],$o=q[a+504>>2];;){if(a=w(On,24)+$o|0,0<(0|(Ro=q[a+12>>2])))for(Pn=u[(On<<2)+_o>>2],So=q[a+20>>2],To=q[a+16>>2],Uo=q[(q[a+4>>2]<<2)+Qo>>2],Vo=q[(q[a+8>>2]<<2)+Qo>>2],a=0;Wo=u[((Mn=1|a)<<2)+To>>2],Nn=s[(a<<1)+So>>1]<<3&262136,Lo=u[(Xo=(4|Nn)+Uo|0)>>2],Mn=s[(Mn<<1)+So>>1]<<3&262136,Mo=u[(Yo=(4|Mn)+Vo|0)>>2],No=u[(Nn=Nn+Uo|0)>>2],Zo=u[(a<<2)+To>>2],Oo=u[(Mn=Mn+Vo|0)>>2],u[Nn>>2]=No+x(Pn*x(Zo*x(Oo-No))),u[Xo>>2]=Lo+x(Pn*x(Zo*x(Mo-Lo))),u[Mn>>2]=Oo+x(Pn*x(Wo*x(No-Oo))),u[Yo>>2]=Mo+x(Pn*x(Wo*x(Lo-Mo))),(0|(a=a+2|0))<(0|Ro););if(!((0|(On=On+1|0))<(0|Po)))break}}(a),n[q[1818]](a),function(a){var Oc,Pc,Rc,Sc,Ec=0,Fc=0,Gc=0,Hc=0,Ic=0,Jc=0,Kc=0,Lc=0,Mc=0,Nc=0,Qc=0;if(!((0|(Oc=q[a+480>>2]))<1)){for(Hc=(Pc=q[a+484>>2])+w(Oc,28)|0,Kc=q[a+424>>2],Lc=q[a+40>>2],Ic=q[a+44>>2],Qc=q[a+440>>2],Ec=Pc;;){if(1<=(0|(Jc=q[Ec+4>>2])))for(Nc=Ec+20|0,Mc=q[Ec+12>>2],Fc=0;Rc=q[4+(Gc=Mc+(Fc<<4)|0)>>2]<<2,Gc=1==q[(Sc=Gc)>>2],q[Sc+12>>2]=q[(q[(Gc?Lc:Kc)+Rc>>2]?(Gc?Ic:Qc)+Rc|0:Nc)>>2],(0|(Fc=Fc+1|0))<(0|Jc););if(!((Ec=Ec+28|0)>>>0>>0))break}if(!((0|Oc)<1))for(Qc=q[a+436>>2],Lc=0;;){if(Hc=w(Lc,28)+Pc|0,!(q[(Kc=Hc)+24>>2]<1)){for(Gc=q[a+488>>2],Fc=0;q[Gc+(Fc<<2)>>2]=-1,(0|(Fc=Fc+1|0))<(0|(Ec=q[Kc+24>>2])););if(!((0|Ec)<1))for(Ec=q[a+496>>2],Fc=0;q[Ec+(Fc<<2)>>2]=-1,(0|(Fc=Fc+1|0))>2];);}if(!(q[Hc+4>>2]<1)){for(Ic=q[a+492>>2],Fc=0;q[Ic+(Fc<<2)>>2]=-1,(0|(Fc=Fc+1|0))<(0|(Ec=q[Hc+4>>2])););if(!((0|Ec)<1))for(Jc=q[Hc+12>>2],Nc=q[a+496>>2],Fc=0;Mc=q[12+(Jc+(Fc<<4)|0)>>2]-q[Hc+20>>2]<<2,Ec=-1==(0|(Ec=q[(Gc=Mc+Nc|0)>>2]))?Mc+q[a+488>>2]|0:Ic+(Ec<<2)|0,q[Ec>>2]=Fc,(0|(Fc=(q[Gc>>2]=Fc)+1|0))>2];);}if(1<=(0|(Ec=q[Kc+24>>2])))for(Ic=q[Hc+8>>2],Nc=q[a+488>>2],Jc=0;;){if(-1!=(0|(Fc=q[Nc+(Jc<<2)>>2]))){for(Mc=q[a+492>>2],Gc=q[Hc+12>>2];Ic=(Ec=1==q[(Ec=Gc+(Fc<<4)|0)>>2]?(Ec=w(q[Ec+8>>2],28)+Pc|0,q[Ec+8>>2]=Ic,q[Ec>>2]):(q[Qc+(q[Ec+4>>2]<<2)>>2]=Ic,1))+Ic|0,(0|Fc)<(0|(Ec=q[Mc+(Fc<<2)>>2]))&&-1!=(0|(Fc=Ec)););Ec=q[Kc+24>>2]}if(!((0|(Jc=Jc+1|0))<(0|Ec)))break}if((0|Oc)==(0|(Lc=Lc+1|0)))break}}}(a),function(a){var yg=0,zg=0,Ag=0,Bg=0,Dg=0,Eg=x(0),Fg=0,Gg=0,Cg=q[a+332>>2];if(q[a+644>>2]){if(!(((q[a+428>>2]=0)|Cg)<1))for(;yg=126,Fg=q[a+432>>2]+Ag|0,!q[(zg=Ag<<2)+q[a+424>>2]>>2]|u[zg+q[a+448>>2]>>2]==x(0)||(yg=127),o[0|Fg]=yg,(0|Cg)!=(0|(Ag=Ag+1|0)););}else if(q[a+428>>2]){if(yg=r[q[a>>2]+4|0],!(((q[a+428>>2]=0)|Cg)<1))if(4<=yg>>>0)for(;Eg=u[(yg=Ag<<2)+q[a+448>>2]>>2],Bg=q[yg+q[a+424>>2]>>2],zg=Eg!=x(0)&0!=(0|Bg),Fg=q[a+432>>2]+Ag|0,zg=(0|zg)==(1&o[0|Fg])?zg:2|zg,zg=Eg!=u[yg+q[a+468>>2]>>2]?4|zg:zg,zg=q[yg+q[a+440>>2]>>2]==q[yg+q[a+464>>2]>>2]?zg:8|zg,yg=q[yg+q[a+436>>2]>>2]==q[yg+q[a+460>>2]>>2]?zg:16|zg,yg=Bg?32|yg:yg,Bg=(zg=Gg<<2)+q[a+452>>2]|0,Dg=zg+q[a+472>>2]|0,(u[Bg>>2]!=u[Dg>>2]|u[Bg+4>>2]!=u[Dg+4>>2]|(u[Bg+8>>2]!=u[Dg+8>>2]|u[Bg+12>>2]!=u[Dg+12>>2])||(Bg=zg+q[a+456>>2]|0,zg=zg+q[a+476>>2]|0,u[Bg>>2]!=u[zg>>2]|u[Bg+4>>2]!=u[zg+4>>2]|u[Bg+8>>2]!=u[zg+8>>2])||u[Bg+12>>2]!=u[zg+12>>2])&&(yg|=64),o[0|Fg]=yg,Gg=Gg+4|0,(0|Cg)!=(0|(Ag=Ag+1|0)););else for(;Eg=u[(yg=Ag<<2)+q[a+448>>2]>>2],Bg=q[yg+q[a+424>>2]>>2],zg=Eg!=x(0)&0!=(0|Bg),Dg=q[a+432>>2]+Ag|0,zg=(0|zg)==(1&o[0|Dg])?zg:2|zg,zg=Eg!=u[yg+q[a+468>>2]>>2]?4|zg:zg,zg=q[yg+q[a+440>>2]>>2]==q[yg+q[a+464>>2]>>2]?zg:8|zg,yg=q[yg+q[a+436>>2]>>2]==q[yg+q[a+460>>2]>>2]?zg:16|zg,o[0|Dg]=Bg?32|yg:yg,(0|Cg)!=(0|(Ag=Ag+1|0)););}else if(!((0|Cg)<1))for(;!q[(yg=Ag<<2)+q[a+424>>2]>>2]|u[yg+q[a+448>>2]>>2]==x(0)?(yg=q[a+432>>2]+Ag|0,o[0|yg]=254&r[0|yg]):(yg=q[a+432>>2]+Ag|0,o[0|yg]=1|r[0|yg]),(0|Cg)!=(0|(Ag=Ag+1|0)););}(a),q[a+644>>2]=0}function va(a,Wa,Xa,Ya,Za,$a){var fb,gb,hb,jb,kb,cb,db,ab=0,bb=0,eb=0,ib=0;if(L=cb=L-32|0,1<=(0|Wa))for(kb=w(Wa,12)+Xa|0;;){if(!((0|(ab=q[Xa+4>>2]))<1))if(fb=(Wa=q[Xa+8>>2])+w(ab,48)|0,ab=q[Xa>>2]<<2,1<=(0|(db=q[ab+$a>>2])))for(db<<=1,gb=q[q[a>>2]+1052>>2],hb=q[Za+ab>>2];;){b:if(ab=q[Wa+8>>2]){c:{if((bb=ab+-1|0)>>>0<=1){if(ab=(q[Wa+4>>2]<<2)+Ya|0,ib=(q[ab+(q[Wa+12>>2]<<2)>>2]<<2)+gb|0,bb-1)break c;for(eb=(q[ab+(q[Wa+16>>2]<<2)>>2]<<2)+gb|0,ab=0;u[(jb=(bb=ab<<2)+hb|0)>>2]=u[jb>>2]+x(u[Wa+44>>2]*x(x(u[bb+ib>>2]*u[Wa+20>>2])+x(u[bb+eb>>2]*u[Wa+24>>2]))),(0|db)!=(0|(ab=ab+1|0)););break b}q[cb>>2]=ab,Y(4,1024,cb);break b}for(ab=0;u[(eb=(bb=ab<<2)+hb|0)>>2]=u[eb>>2]+x(u[Wa+44>>2]*x(u[bb+ib>>2]*u[Wa+20>>2])),(0|db)!=(0|(ab=ab+1|0)););}if(!((Wa=Wa+48|0)>>>0>>0))break}else for(;3<=(ab=q[Wa+8>>2])>>>0&&(q[16+cb>>2]=ab,Y(4,1024,16+cb|0)),(Wa=Wa+48|0)>>>0>>0;);if(!((Xa=Xa+12|0)>>>0>>0))break}L=32+cb|0}function wa(a,Wa,Xa){var Ya;Wa|=0,Xa|=0,L=Ya=L+-64|0;a:{if(a|=0)if(Wa)if((Wa+15&-16)!=(0|Wa))q[52+Ya>>2]=1522,q[48+Ya>>2]=2361,Y(4,1294,48+Ya|0);else{if(Wa=function(a,Gl,Hl){var lm,Zl=0,fm=0,gm=0,mm=0,nm=0,om=0,pm=0,qm=0,rm=0,sm=0,tm=0,um=0,vm=0,wm=x(0),xm=0,ym=0,zm=0,Am=0,Bm=0;if(ca(16+(L=lm=L-576|0)|0,0,560),Fa(a,16+lm|0,12+lm|0),(mm=q[12+lm>>2])>>>0<=Hl>>>0){if(Zl=(fm=ca(Gl,0,mm))+q[16+lm>>2]|0,q[Zl+8>>2]=fm+q[20+lm>>2],q[Zl+40>>2]=fm+q[24+lm>>2],q[Zl+44>>2]=fm+q[28+lm>>2],q[Zl+48>>2]=fm+q[32+lm>>2],q[Zl+52>>2]=fm+q[36+lm>>2],q[Zl+16>>2]=fm+q[40+lm>>2],q[Zl+24>>2]=fm+q[44+lm>>2],q[Zl+28>>2]=fm+q[48+lm>>2],q[Zl+32>>2]=fm+q[52+lm>>2],q[Zl+36>>2]=fm+q[56+lm>>2],Gl=q[a+704>>2],q[Zl+308>>2]=fm+q[60+lm>>2],q[Zl+312>>2]=fm+q[64+lm>>2],q[Zl+316>>2]=fm+q[68+lm>>2],q[Zl+320>>2]=fm+q[72+lm>>2],q[Zl+324>>2]=fm+q[76+lm>>2],q[Zl+328>>2]=fm+q[80+lm>>2],q[Zl+60>>2]=fm+q[84+lm>>2],q[Zl+144>>2]=fm+q[88+lm>>2],q[Zl+148>>2]=fm+q[92+lm>>2],Hl=fm+q[96+lm>>2]|0,q[Zl+152>>2]=Hl,!((0|(mm=q[Gl+8>>2]))<1)&&(Gl=fm+q[100+lm>>2]|0,q[Hl>>2]=Gl,1!=(0|mm)))for(Hl=1;Gl=(15+(q[q[a+796>>2]+(gm<<2)>>2]<<3)&-16)+Gl|0,q[q[Zl+152>>2]+(Hl<<2)>>2]=Gl,(0|mm)!=(0|(Hl=(gm=Hl)+1|0)););if(q[Zl+156>>2]=fm+q[104+lm>>2],q[Zl+160>>2]=fm+q[108+lm>>2],q[Zl+68>>2]=fm+q[112+lm>>2],q[Zl+76>>2]=fm+q[116+lm>>2],q[Zl+80>>2]=fm+q[120+lm>>2],q[Zl+84>>2]=fm+q[124+lm>>2],q[Zl+88>>2]=fm+q[128+lm>>2],q[Zl+92>>2]=fm+q[132+lm>>2],q[Zl+96>>2]=fm+q[136+lm>>2],q[Zl+100>>2]=fm+q[140+lm>>2],q[Zl+104>>2]=fm+q[144+lm>>2],q[Zl+108>>2]=fm+q[148+lm>>2],q[Zl+112>>2]=fm+q[152+lm>>2],q[Zl+116>>2]=fm+q[156+lm>>2],q[Zl+120>>2]=fm+q[160+lm>>2],q[Zl+124>>2]=fm+q[164+lm>>2],q[Zl+128>>2]=fm+q[168+lm>>2],q[Zl+132>>2]=fm+q[172+lm>>2],q[Zl+136>>2]=fm+q[176+lm>>2],q[Zl+140>>2]=fm+q[180+lm>>2],q[Zl+168>>2]=fm+q[184+lm>>2],q[Zl+264>>2]=fm+q[188+lm>>2],q[Zl+268>>2]=fm+q[192+lm>>2],q[Zl+272>>2]=fm+q[196+lm>>2],q[Zl+276>>2]=fm+q[200+lm>>2],q[Zl+280>>2]=fm+q[204+lm>>2],q[Zl+284>>2]=fm+q[208+lm>>2],q[Zl+288>>2]=fm+q[212+lm>>2],q[Zl+292>>2]=fm+q[216+lm>>2],q[Zl+296>>2]=fm+q[220+lm>>2],q[Zl+300>>2]=fm+q[224+lm>>2],q[Zl+176>>2]=fm+q[228+lm>>2],q[Zl+184>>2]=fm+q[232+lm>>2],q[Zl+188>>2]=fm+q[236+lm>>2],q[Zl+192>>2]=fm+q[240+lm>>2],q[Zl+196>>2]=fm+q[244+lm>>2],q[Zl+200>>2]=fm+q[248+lm>>2],q[Zl+204>>2]=fm+q[252+lm>>2],q[Zl+208>>2]=fm+q[256+lm>>2],q[Zl+212>>2]=fm+q[260+lm>>2],q[Zl+216>>2]=fm+q[264+lm>>2],q[Zl+220>>2]=fm+q[268+lm>>2],q[Zl+224>>2]=fm+q[272+lm>>2],q[Zl+228>>2]=fm+q[276+lm>>2],q[Zl+232>>2]=fm+q[280+lm>>2],q[Zl+236>>2]=fm+q[284+lm>>2],q[Zl+240>>2]=fm+q[288+lm>>2],q[Zl+244>>2]=fm+q[292+lm>>2],q[Zl+248>>2]=fm+q[296+lm>>2],q[Zl+252>>2]=fm+q[300+lm>>2],q[Zl+256>>2]=fm+q[304+lm>>2],q[Zl+260>>2]=fm+q[308+lm>>2],Gl=q[a+704>>2],q[Zl+336>>2]=fm+q[312+lm>>2],q[Zl+424>>2]=fm+q[316+lm>>2],q[Zl+432>>2]=fm+q[320+lm>>2],q[Zl+436>>2]=fm+q[324+lm>>2],q[Zl+440>>2]=fm+q[328+lm>>2],Hl=fm+q[332+lm>>2]|0,q[Zl+444>>2]=Hl,!((0|(mm=q[Gl+16>>2]))<1)&&(gm=fm+q[336+lm>>2]|0,q[Hl>>2]=gm,(Hl=1)!=(0|mm)))for(Gl=0;gm=(15+(q[q[a+892>>2]+(Gl<<2)>>2]<<3)&-16)+gm|0,q[q[Zl+444>>2]+(Hl<<2)>>2]=gm,(0|mm)!=(0|(Hl=(Gl=Hl)+1|0)););if(q[Zl+448>>2]=fm+q[340+lm>>2],q[Zl+452>>2]=fm+q[344+lm>>2],q[Zl+456>>2]=fm+q[348+lm>>2],q[Zl+460>>2]=fm+q[352+lm>>2],q[Zl+464>>2]=fm+q[356+lm>>2],q[Zl+468>>2]=fm+q[360+lm>>2],q[Zl+472>>2]=fm+q[364+lm>>2],q[Zl+476>>2]=fm+q[368+lm>>2],q[Zl+344>>2]=fm+q[372+lm>>2],q[Zl+352>>2]=fm+q[376+lm>>2],q[Zl+356>>2]=fm+q[380+lm>>2],q[Zl+360>>2]=fm+q[384+lm>>2],q[Zl+364>>2]=fm+q[388+lm>>2],q[Zl+368>>2]=fm+q[392+lm>>2],q[Zl+372>>2]=fm+q[396+lm>>2],q[Zl+376>>2]=fm+q[400+lm>>2],q[Zl+380>>2]=fm+q[404+lm>>2],q[Zl+384>>2]=fm+q[408+lm>>2],q[Zl+388>>2]=fm+q[412+lm>>2],q[Zl+392>>2]=fm+q[416+lm>>2],q[Zl+396>>2]=fm+q[420+lm>>2],q[Zl+400>>2]=fm+q[424+lm>>2],q[Zl+404>>2]=fm+q[428+lm>>2],q[Zl+408>>2]=fm+q[432+lm>>2],q[Zl+412>>2]=fm+q[436+lm>>2],q[Zl+416>>2]=fm+q[440+lm>>2],q[Zl+420>>2]=fm+q[444+lm>>2],Gl=q[448+lm>>2],Hl=q[452+lm>>2],q[Zl+552>>2]=fm+q[456+lm>>2],q[Zl+548>>2]=Hl+fm,q[Zl+544>>2]=Gl+fm,q[Zl+560>>2]=fm+q[460+lm>>2],Gl=q[a+704>>2],pm=fm+q[464+lm>>2]|0,q[Zl+568>>2]=pm,1<=(0|(om=q[Gl+48>>2])))for(gm=fm+q[468+lm>>2]|0,Gl=fm+q[472+lm>>2]|0,nm=fm+q[476+lm>>2]|0,qm=q[a+1072>>2],Hl=0;mm=pm+w(Hl,36)|0,q[mm+20>>2]=nm,q[mm+16>>2]=Gl,q[mm>>2]=gm,gm=((mm=q[qm+(Hl<<2)>>2])<<2)+gm|0,nm=(mm=1<>2],mm=fm+q[516+lm>>2]|0,q[Zl+484>>2]=mm,1<=(0|(Gl=q[Gl+72>>2])))for(gm=fm+q[520+lm>>2]|0,nm=q[a+1212>>2],Hl=0;q[12+(mm+w(Hl,28)|0)>>2]=gm,gm=(q[nm+(Hl<<2)>>2]<<4)+gm|0,(0|Gl)!=(0|(Hl=Hl+1|0)););q[Zl+488>>2]=fm+q[524+lm>>2],q[Zl+492>>2]=fm+q[528+lm>>2],q[Zl+496>>2]=fm+q[532+lm>>2],q[Zl+504>>2]=fm+q[536+lm>>2],q[Zl+536>>2]=fm+q[540+lm>>2],q[Zl+512>>2]=fm+q[544+lm>>2],q[Zl+520>>2]=fm+q[548+lm>>2],q[Zl+524>>2]=fm+q[552+lm>>2],q[Zl+528>>2]=fm+q[556+lm>>2],q[Zl+532>>2]=fm+q[560+lm>>2];c:{if(4<=(om=r[a+4|0])>>>0){if(q[Zl+576>>2]=fm+q[480+lm>>2],q[Zl+584>>2]=fm+q[484+lm>>2],Gl=q[a+704>>2],Hl=q[492+lm>>2],mm=fm+q[488+lm>>2]|0,q[Zl+592>>2]=mm,1<=(0|(Gl=q[Gl+104>>2])))for(gm=Hl+fm|0,nm=q[a+1104>>2],Hl=0;q[40+(mm+w(Hl,48)|0)>>2]=gm,gm=(q[nm+(Hl<<2)>>2]<<2)+gm|0,(0|Gl)!=(0|(Hl=Hl+1|0)););q[Zl+608>>2]=fm+q[500+lm>>2],q[Zl+624>>2]=fm+q[508+lm>>2]}else{if(Gl=q[572+lm>>2],Hl=q[568+lm>>2],q[Zl+636>>2]=fm+q[564+lm>>2],q[Zl+640>>2]=Hl+fm,q[q[a+704>>2]+20>>2]<1)break c;for(mm=Gl+fm|0,pm=0;;){e:{if((0|(gm=q[(Gl=pm<<2)+q[a+952>>2]>>2]))<=0)Gl=Gl+q[Zl+636>>2]|0;else{for(nm=gm+(Hl=q[Gl+q[a+948>>2]>>2])|0,qm=q[a+1060>>2],gm=0;gm=q[qm+(Hl<<2)>>2]+gm|0,(0|(Hl=Hl+1|0))<(0|nm););if(Gl=Gl+q[Zl+636>>2]|0,Hl=mm,gm)break e}Hl=gm=0}if(q[Gl>>2]=Hl,mm=(gm<<2)+mm|0,!((0|(pm=pm+1|0))>2]+20>>2]))break}}om>>>0<5||(q[Zl+600>>2]=fm+q[496+lm>>2],q[Zl+616>>2]=fm+q[504+lm>>2],q[Zl+632>>2]=fm+q[512+lm>>2])}q[Zl+644>>2]=1,q[Zl>>2]=a,q[Zl+648>>2]=1&o[q[a+708>>2]+20|0],fm=q[a+704>>2],pm=q[fm+20>>2];g:if(!((0|(q[Zl+540>>2]=pm))<1)){if(Gl=pm+-1|0,qm=q[a+952>>2],rm=q[a+940>>2],sm=q[a+932>>2],tm=q[a+936>>2],um=q[a+924>>2],vm=q[a+928>>2],xm=q[Zl+552>>2],zm=q[Zl+544>>2],om>>>0<4)for(;;)if(Hl=zm+w(Gl,52)|0,gm=(mm=Gl<<2)+vm|(q[Hl>>2]=0),q[Hl+4>>2]=q[gm>>2],q[Hl+8>>2]=q[(nm=mm+um|0)>>2],u[Hl+12>>2]=u[nm>>2]-u[gm>>2],q[Hl+16>>2]=q[mm+tm>>2],q[Hl+44>>2]=q[(gm=mm+sm|0)>>2],wm=Aa(x(q[mm+rm>>2])),u[Hl+20>>2]=wm,u[Hl+24>>2]=wm*x(1.5),ym=q[mm+qm>>2],nm=0,nm=(q[Hl+32>>2]=ym)?q[Zl+560>>2]+w(q[mm+q[a+948>>2]>>2],28)|0:nm,q[Hl+48>>2]=1,q[Hl+28>>2]=nm,q[mm+xm>>2]=q[gm>>2],Hl=0<(0|Gl),Gl=Gl+-1|0,!Hl)break g;for(ym=q[a+960>>2],Bm=q[a+944>>2];Hl=zm+w(Gl,52)|0,q[Hl>>2]=q[(gm=Gl<<2)+Bm>>2],q[Hl+4>>2]=q[(mm=gm+vm|0)>>2],q[Hl+8>>2]=q[(nm=gm+um|0)>>2],u[Hl+12>>2]=u[nm>>2]-u[mm>>2],q[Hl+16>>2]=q[gm+tm>>2],q[Hl+44>>2]=q[(Am=gm+sm|0)>>2],wm=Aa(x(q[gm+rm>>2])),u[Hl+20>>2]=wm,u[Hl+24>>2]=wm*x(1.5),nm=q[gm+qm>>2],q[Hl+32>>2]=nm,q[Hl+28>>2]=nm?q[Zl+560>>2]+w(q[gm+q[a+948>>2]>>2],28)|0:0,mm=q[gm+ym>>2],mm=(q[Hl+40>>2]=mm)?q[Zl+584>>2]+w(q[gm+q[a+956>>2]>>2],28)|0:0,q[Hl+48>>2]=1,q[Hl+36>>2]=mm,q[gm+xm>>2]=q[Am>>2],Hl=0<(0|Gl),Gl=Gl+-1|0,Hl;);}if(4<=om>>>0?(q[Zl+548>>2]=q[a+944>>2],mm=a):(ca(q[Zl+548>>2],0,pm<<2),mm=q[Zl>>2],fm=q[mm+704>>2]),gm=q[fm+52>>2],1<=(0|(q[Zl+556>>2]=gm)))for(Hl=q[mm+1056>>2],nm=q[mm+1192>>2],pm=q[mm+1060>>2],om=q[Zl+560>>2];Gl=om+w(gm=gm+-1|0,28)|0,q[Gl>>2]=q[(qm=gm<<2)+pm>>2],qm=q[Hl+qm>>2],q[Gl+24>>2]=1,q[Gl+16>>2]=0,q[Gl+20>>2]=1,q[Gl+8>>2]=0,q[Gl+12>>2]=0,q[Gl+4>>2]=nm+(qm<<2),0<(0|gm););if(gm=q[fm+48>>2],1<=(0|(q[Zl+564>>2]=gm))){for(;;){if(Gl=q[Zl+568>>2]+w(gm=gm+-1|0,36)|0,nm=q[(fm=gm<<2)+q[mm+1072>>2]>>2],1<=(0|(q[Gl+4>>2]=nm)))for(Hl=0;q[q[Gl>>2]+(Hl<<2)>>2]=q[Zl+560>>2]+w(q[q[mm+1064>>2]+(q[fm+q[mm+1068>>2]>>2]+Hl<<2)>>2],28),(0|nm)!=(0|(Hl=Hl+1|0)););if(q[Gl+24>>2]=1,q[Gl+28>>2]=1,q[Gl+8>>2]=1<>2],fm=q[mm+704>>2]}if(Gl=q[fm>>2],(0|(q[Zl+4>>2]=Gl))<1)Hl=0;else{for(qm=q[mm+732>>2],rm=q[mm+736>>2],sm=q[mm+740>>2],nm=q[mm+720>>2],tm=q[Zl+52>>2],pm=q[Zl+568>>2],um=q[Zl+8>>2],gm=Gl;om=um+w(gm=gm+-1|0,12)|0,q[om>>2]=pm+w(q[(Hl=gm<<2)+nm>>2],36),q[om+4>>2]=q[Hl+sm>>2],q[om+8>>2]=q[Hl+rm>>2],u[Hl+tm>>2]=q[Hl+qm>>2]?x(1):x(0),0<(0|gm););for(om=q[Zl+16>>2],Hl=0;gm=q[8+(pm+w(q[(qm=(Gl=Gl+-1|0)<<2)+nm>>2],36)|0)>>2],Hl=Hl+(q[om+qm>>2]=gm)|0,0<(0|Gl););Gl=q[Zl+4>>2]}if(q[Zl+12>>2]=Gl,q[Zl+20>>2]=Hl,Gl=q[fm+4>>2],1<=(0|(q[Zl+304>>2]=Gl))){for(;Hl=q[Zl+308>>2]+((Gl=Gl+-1|0)<<5)|0,q[Hl>>2]=q[Zl+568>>2]+w(q[(gm=Gl<<2)+q[mm+752>>2]>>2],36),q[Hl+4>>2]=q[gm+q[mm+764>>2]>>2],q[Hl+8>>2]=q[gm+q[mm+768>>2]>>2],nm=q[gm+q[mm+772>>2]>>2],q[Hl+12>>2]=nm,fm=q[gm+q[mm+776>>2]>>2],q[Hl+16>>2]=fm,q[Hl+28>>2]=q[gm+q[mm+760>>2]>>2],nm>>>0<=1?nm-1?(q[20+(q[Zl+60>>2]+w(fm,24)|0)>>2]=Gl,q[Hl+24>>2]=1,q[Hl+20>>2]=2):(q[8+(q[Zl+168>>2]+w(fm,12)|0)>>2]=Gl,q[Hl+24>>2]=3,q[Hl+20>>2]=4):Y(4,1179,0),0<(0|Gl););mm=q[Zl>>2],fm=q[mm+704>>2]}gm=q[fm+8>>2];k:if(!((0|(q[Zl+56>>2]=gm))<1)){if(Hl=gm+-1|0,pm=q[mm+796>>2],om=q[mm+804>>2],qm=q[mm+800>>2],rm=q[mm+780>>2],sm=q[Zl+568>>2],tm=q[Zl+60>>2],r[mm+4|0]<2)for(;;)if(Gl=tm+w(Hl,24)|0,q[Gl>>2]=sm+w(q[(nm=Hl<<2)+rm>>2],36),q[Gl+4>>2]=q[nm+qm>>2],q[Gl+8>>2]=q[nm+om>>2],nm=q[nm+pm>>2],q[Gl+12>>2]=0,q[Gl+16>>2]=nm,Gl=0<(0|Hl),Hl=Hl+-1|0,!Gl)break k;for(um=q[mm+808>>2];Gl=tm+w(Hl,24)|0,q[Gl>>2]=sm+w(q[(nm=Hl<<2)+rm>>2],36),q[Gl+4>>2]=q[nm+qm>>2],q[Gl+8>>2]=q[nm+om>>2],q[Gl+16>>2]=q[nm+pm>>2],q[Gl+12>>2]=q[nm+um>>2],Gl=0<(0|Hl),Hl=Hl+-1|0,Gl;);}if(Hl=q[fm+12>>2],1<=(0|(q[Zl+164>>2]=Hl)))for(nm=q[mm+828>>2],pm=q[mm+812>>2],om=q[Zl+568>>2],qm=q[Zl+168>>2],Gl=Hl;rm=qm+w(Gl=Gl+-1|0,12)|0,q[rm>>2]=om+w(q[(sm=Gl<<2)+pm>>2],36),q[rm+4>>2]=q[nm+sm>>2],0<(0|Gl););if(((Gl=0)|gm)<1)nm=0;else{for(pm=q[Zl+68>>2],om=q[Zl+60>>2],nm=0;Hl=q[q[om+w(gm=gm+-1|0,24)>>2]+8>>2],nm=(q[pm+(gm<<2)>>2]=Hl)+nm|0,0<(0|gm););Hl=q[Zl+164>>2],gm=q[Zl+56>>2]}if(q[Zl+64>>2]=gm,q[Zl+72>>2]=nm,gm=Zl,1<=(0|Hl)){for(pm=q[Zl+176>>2],om=q[Zl+168>>2];nm=q[q[om+w(Hl=Hl+-1|0,12)>>2]+8>>2],Gl=Gl+(q[pm+(Hl<<2)>>2]=nm)|0,0<(0|Hl););Hl=q[Zl+164>>2]}if(q[gm+172>>2]=Hl,q[Zl+180>>2]=Gl,nm=q[fm+16>>2],1<=(0|(q[Zl+332>>2]=nm))){for(qm=q[mm+872>>2],rm=q[mm+892>>2],sm=q[mm+880>>2],tm=q[mm+876>>2],pm=q[mm+852>>2],om=q[Zl+568>>2],um=q[Zl+336>>2],Gl=nm;Hl=um+w(Gl=Gl+-1|0,20)|0,q[Hl>>2]=om+w(q[(gm=Gl<<2)+pm>>2],36),q[Hl+4>>2]=q[gm+tm>>2],q[Hl+8>>2]=q[gm+sm>>2],q[Hl+16>>2]=q[gm+rm>>2],q[Hl+12>>2]=q[gm+qm>>2],0<(0|Gl););for(gm=q[Zl+344>>2],Hl=0;Gl=q[8+(om+w(q[(qm=(nm=nm+-1|0)<<2)+pm>>2],36)|0)>>2],Hl=(q[gm+qm>>2]=Gl)+Hl|0,0<(0|nm););if(q[Zl+348>>2]=Hl,nm=q[Zl+332>>2],!((0|(q[Zl+340>>2]=nm))<1))for(Hl=nm<<2,gm=q[Zl+456>>2],pm=q[Zl+452>>2];q[(om=(Gl=Hl+-4|0)<<2)+pm>>2]=1065353216,q[(qm=(Hl<<=2)-4|0)+pm>>2]=1065353216,q[(rm=(Hl=Hl+-12|0)+pm|0)>>2]=1065353216,q[rm+4>>2]=1065353216,q[gm+om>>2]=0,q[gm+qm>>2]=1065353216,q[(Hl=Hl+gm|0)>>2]=0,q[Hl+4>>2]=0,Hl=Gl,0<(0|(nm=nm+-1|0)););}else q[Zl+340>>2]=nm,q[Zl+348>>2]=0;if(nm=q[fm+72>>2],1<=(0|(q[Zl+480>>2]=nm)))for(qm=q[mm+1208>>2],rm=q[mm+1224>>2],sm=q[mm+1220>>2],tm=q[mm+1216>>2],um=q[mm+1212>>2],vm=q[Zl+484>>2],gm=0;;){if(Gl=vm+w(gm,28)|0,pm=q[(Hl=gm<<2)+um>>2],q[Gl+4>>2]=pm,q[Gl>>2]=q[Hl+tm>>2],om=q[Hl+sm>>2],q[Gl+16>>2]=om,xm=q[Hl+rm>>2],q[Gl+20>>2]=xm,q[Gl+8>>2]=0,q[Gl+24>>2]=1+(om-xm|0),1<=(0|pm))for(xm=q[Hl+qm>>2],zm=q[Gl+12>>2],ym=q[mm+1236>>2],Bm=q[mm+1228>>2],Am=q[mm+1232>>2],Hl=0;q[4+(Gl=zm+(Hl<<4)|0)>>2]=q[(om=Hl+xm<<2)+Am>>2],q[Gl>>2]=q[om+Bm>>2],om=q[om+ym>>2],q[Gl+12>>2]=0,q[Gl+8>>2]=om,(0|pm)!=(0|(Hl=Hl+1|0)););if((0|nm)==(0|(gm=gm+1|0)))break}if(Hl=q[fm+80>>2],(0|(q[Zl+500>>2]=Hl))<1)gm=0;else{for(om=q[mm+1280>>2],qm=q[mm+1268>>2],rm=q[mm+1276>>2],sm=q[mm+1272>>2],tm=q[mm+1264>>2],um=q[mm+1260>>2],nm=q[mm+1248>>2],pm=q[Zl+568>>2],vm=q[Zl+504>>2];Gl=vm+w(Hl=Hl+-1|0,24)|0,q[Gl>>2]=pm+w(q[(gm=Hl<<2)+nm>>2],36),q[Gl+4>>2]=q[gm+um>>2],q[Gl+8>>2]=q[gm+tm>>2],q[Gl+12>>2]=q[gm+sm>>2],gm=q[gm+qm>>2],q[Gl+20>>2]=om+(gm<<1),q[Gl+16>>2]=rm+(gm<<2),0<(0|Hl););if((0|(Hl=q[Zl+500>>2]))<1)gm=0;else{for(om=q[Zl+512>>2],gm=0;Gl=q[8+(pm+w(q[(qm=(Hl=Hl+-1|0)<<2)+nm>>2],36)|0)>>2],gm=(q[om+qm>>2]=Gl)+gm|0,0<(0|Hl););Hl=q[Zl+500>>2]}}q[Zl+508>>2]=Hl,q[Zl+516>>2]=gm;o:if(4<=r[a+4|0]){if(!((nm=r[mm+4|0])>>>0<4)){if(Hl=q[fm+120>>2],1<=(0|(q[Zl+572>>2]=Hl))){for(om=q[mm+1172>>2],qm=q[Zl+576>>2];nm=(0|(fm=q[(Gl=(Hl=Hl+-1|0)<<2)+om>>2]))<0?fm=pm=gm=0:(gm=(nm=q[Gl+q[mm+1176>>2]>>2]<<2)+q[mm+1188>>2]|0,pm=q[Gl+q[mm+1180>>2]>>2],fm=q[Zl+544>>2]+w(fm,52)|0,nm+q[mm+1184>>2]|0),Gl=qm+w(Hl,20)|0,q[Gl+12>>2]=pm,q[Gl+8>>2]=gm,q[Gl+4>>2]=nm,q[Gl>>2]=fm,0<(0|Hl););if(mm=q[Zl>>2],(nm=r[mm+4|0])>>>0<4)break o}if(fm=q[mm+704>>2],gm=q[fm+100>>2],1<=(0|(q[Zl+580>>2]=gm)))for(pm=q[mm+1084>>2],om=q[mm+1076>>2],qm=q[mm+1192>>2],rm=q[mm+1080>>2],sm=q[Zl+584>>2];Gl=sm+w(gm=gm+-1|0,28)|0,q[Gl>>2]=q[(Hl=gm<<2)+rm>>2],q[Gl+4>>2]=qm+(q[Hl+om>>2]<<2),Hl=q[Hl+pm>>2],q[Gl+20>>2]=1,q[Gl+24>>2]=1,q[Gl+12>>2]=0,q[Gl+16>>2]=0,q[Gl+8>>2]=Hl,0<(0|gm););if(gm=q[fm+104>>2],1<=(0|(q[Zl+588>>2]=gm))){for(;;){if(Gl=q[Zl+592>>2]+w(gm=gm+-1|0,48)|0,q[Gl>>2]=q[Zl+584>>2]+w(q[(nm=gm<<2)+q[mm+1088>>2]>>2],28),Hl=q[nm+q[mm+1092>>2]>>2],q[Gl+28>>2]=1,q[Gl+32>>2]=1,q[Gl+8>>2]=0,q[Gl+4>>2]=Hl,fm=q[nm+q[mm+1104>>2]>>2],1<=(0|(q[Gl+36>>2]=fm)))for(Hl=0;q[q[Gl+40>>2]+(Hl<<2)>>2]=q[Zl+576>>2]+w(q[q[mm+1168>>2]+(q[nm+q[mm+1100>>2]>>2]+Hl<<2)>>2],20),(0|fm)!=(0|(Hl=Hl+1|0)););if(!(1<=(0|gm)))break}mm=q[Zl>>2],nm=r[mm+4|0]}if(!(nm>>>0<4)){if(nm=q[a+704>>2],Hl=q[nm+108>>2],1<=(0|(q[Zl+604>>2]=Hl)))for(fm=q[a+1124>>2],pm=q[a+1128>>2],om=q[a+1120>>2],qm=q[Zl+592>>2],rm=q[Zl+608>>2];Gl=rm+w(Hl=Hl+-1|0,12)|0,q[Gl>>2]=q[(gm=Hl<<2)+om>>2],q[Gl+4>>2]=q[gm+pm>>2],q[Gl+8>>2]=qm+w(q[fm+gm>>2],48),0<(0|Hl););if(Hl=q[nm+112>>2],1<=(0|(q[Zl+620>>2]=Hl)))for(nm=q[a+1148>>2],fm=q[a+1152>>2],pm=q[a+1144>>2],om=q[Zl+592>>2],qm=q[Zl+624>>2];Gl=qm+w(Hl=Hl+-1|0,12)|0,q[Gl>>2]=q[(gm=Hl<<2)+pm>>2],q[Gl+4>>2]=q[fm+gm>>2],q[Gl+8>>2]=om+w(q[gm+nm>>2],48),0<(0|Hl););if(gm=q[mm+1192>>2],Gl=q[q[mm+704>>2]+20>>2],q[Zl+640>>2]=q[mm+972>>2],nm=q[mm+964>>2],q[Zl+636>>2]=nm,!((0|Gl)<(Hl=1))&&(q[nm>>2]=gm+(q[q[mm+968>>2]>>2]<<2),1!=(0|Gl)))for(;q[(nm=Hl<<2)+q[Zl+636>>2]>>2]=gm+(q[nm+q[mm+968>>2]>>2]<<2),(0|Gl)!=(0|(Hl=Hl+1|0)););}}}else if(!(q[fm+20>>2]<1))for(fm=0;;){if(gm=q[(pm=fm<<2)+q[Zl+636>>2]>>2],1<=((Gl=0)|(Hl=q[pm+q[mm+952>>2]>>2])))for(rm=Hl+(om=q[pm+q[mm+948>>2]>>2])|0,sm=q[mm+1060>>2],tm=q[mm+1056>>2];;){if(1<=(0|(qm=q[(Hl=om<<2)+sm>>2])))for(um=qm+(nm=q[Hl+tm>>2])|0,vm=q[mm+1192>>2];;){qm=gm+(Gl<<2)|0,wm=u[vm+(nm<<2)>>2],Hl=gm;q:{if(0<(0|Gl))for(;;){if(u[Hl>>2]==wm)break q;if(!((Hl=Hl+4|0)>>>0>>0))break}u[qm>>2]=wm,Gl=Gl+1|0}if(!((0|(nm=nm+1|0))<(0|um)))break}if(!((0|(om=om+1|0))<(0|rm)))break}if(function(a,rn){var sn,In,vn=0,wn=0,Hn=0;q[8+(L=sn=L-208|0)>>2]=1,q[12+sn>>2]=0;a:if(In=rn<<2){for(q[16+sn>>2]=4,Hn=rn=q[20+sn>>2]=4,vn=2;rn=(Hn+4|0)+(wn=rn)|0,q[(16+sn|0)+(vn<<2)>>2]=rn,vn=vn+1|0,Hn=wn,rn>>>0>>0;);if((wn=(a+In|0)-4|0)>>>0<=a>>>0)rn=vn=1;else for(rn=vn=1;rn=3==(3&vn)?(ta(a,rn,16+sn|0),ma(8+sn|0,2),rn+2|0):(t[(16+sn|0)+((Hn=rn+-1|0)<<2)>>2]>=wn-a>>>0?la(a,8+sn|0,rn,0,16+sn|0):ta(a,rn,16+sn|0),1==(0|rn)?(ka(8+sn|0,1),0):(ka(8+sn|0,Hn),1)),vn=1|q[8+sn>>2],q[8+sn>>2]=vn,(a=a+4|0)>>>0>>0;);for(la(a,8+sn|0,rn,0,16+sn|0);;){e:{f:{g:{if(!(1!=(0|rn)|1!=(0|vn))){if(q[12+sn>>2])break g;break a}if(1<(0|rn))break f}ma(8+sn|0,wn=Oa(8+sn|0)),vn=q[8+sn>>2],rn=rn+wn|0;break e}ka(8+sn|0,2),q[8+sn>>2]=7^q[8+sn>>2],ma(8+sn|0,1),la((Hn=a+-4|0)-q[(16+sn|0)+((wn=rn+-2|0)<<2)>>2]|0,8+sn|0,rn+-1|0,1,16+sn|0),ka(8+sn|0,1),vn=1|q[8+sn>>2],q[8+sn>>2]=vn,la(Hn,8+sn|0,wn,1,16+sn|0),rn=wn}a=a+-4|0}}L=208+sn|0}(gm,Gl),q[pm+q[Zl+640>>2]>>2]=Gl,!((0|(fm=fm+1|0))>2]+20>>2]))break}if(!(r[a+4|0]<5|r[q[Zl>>2]+4|0]<4)){if(Gl=q[a+704>>2],Hl=q[Gl+128>>2],1<=(0|(q[Zl+596>>2]=Hl)))for(nm=q[a+1112>>2],fm=q[a+1116>>2],pm=q[a+1108>>2],om=q[Zl+592>>2],qm=q[Zl+600>>2];mm=qm+w(Hl=Hl+-1|0,12)|0,q[mm>>2]=q[(gm=Hl<<2)+pm>>2],q[mm+4>>2]=q[fm+gm>>2],q[mm+8>>2]=om+w(q[gm+nm>>2],48),0<(0|Hl););if(Hl=q[Gl+132>>2],1<=(0|(q[Zl+612>>2]=Hl)))for(nm=q[a+1136>>2],fm=q[a+1140>>2],pm=q[a+1132>>2],om=q[Zl+592>>2],qm=q[Zl+616>>2];mm=qm+w(Hl=Hl+-1|0,12)|0,q[mm>>2]=q[(gm=Hl<<2)+pm>>2],q[mm+4>>2]=q[fm+gm>>2],q[mm+8>>2]=om+w(q[gm+nm>>2],48),0<(0|Hl););if(Hl=q[Gl+136>>2],!((0|(q[Zl+628>>2]=Hl))<1))for(mm=q[a+1160>>2],gm=q[a+1164>>2],nm=q[a+1156>>2],fm=q[Zl+592>>2],pm=q[Zl+632>>2];a=pm+w(Hl=Hl+-1|0,12)|0,q[a>>2]=q[(Gl=Hl<<2)+nm>>2],q[a+4>>2]=q[Gl+gm>>2],q[a+8>>2]=fm+w(q[Gl+mm>>2],48),0<(0|Hl););}ua(Zl)}return L=576+lm|0,Zl}(a,Wa,Xa))break a;q[36+Ya>>2]=2209,q[32+Ya>>2]=2361,Y(4,1294,32+Ya|0)}else q[20+Ya>>2]=1444,q[16+Ya>>2]=2361,Y(4,1294,16+Ya|0);else q[4+Ya>>2]=2132,q[Ya>>2]=2361,Y(4,1294,Ya);Wa=0}return L=64+Ya|0,0|Wa}function xa(a){var Wa;return L=Wa=L-16|0,a=(a|=0)?function(a){var Gl;return ca(16+(L=Gl=L-576|0)|0,0,560),Fa(a,16+Gl|0,12+Gl|0),L=576+Gl|0,q[12+Gl>>2]}(a):(q[4+Wa>>2]=2132,q[Wa>>2]=2343,Y(4,1294,Wa),0),L=16+Wa|0,0|a}function ya(a){var Xa=r[a+4|0];X(q[a+704>>2],4,64),da(q[a+708>>2],4),da(q[a+708>>2]+4|0,4),da(q[a+708>>2]+8|0,4),da(q[a+708>>2]+12|0,4),da(q[a+708>>2]+16|0,4),da(q[a+708>>2]+20|0,1),X(q[a+720>>2],4,q[q[a+704>>2]>>2]),X(q[a+724>>2],4,q[q[a+704>>2]>>2]),X(q[a+728>>2],4,q[q[a+704>>2]>>2]),X(q[a+732>>2],4,q[q[a+704>>2]>>2]),X(q[a+736>>2],4,q[q[a+704>>2]>>2]),X(q[a+740>>2],4,q[q[a+704>>2]>>2]),X(q[a+752>>2],4,q[q[a+704>>2]+4>>2]),X(q[a+756>>2],4,q[q[a+704>>2]+4>>2]),X(q[a+760>>2],4,q[q[a+704>>2]+4>>2]),X(q[a+764>>2],4,q[q[a+704>>2]+4>>2]),X(q[a+768>>2],4,q[q[a+704>>2]+4>>2]),X(q[a+772>>2],4,q[q[a+704>>2]+4>>2]),X(q[a+776>>2],4,q[q[a+704>>2]+4>>2]),X(q[a+780>>2],4,q[q[a+704>>2]+8>>2]),X(q[a+784>>2],4,q[q[a+704>>2]+8>>2]),X(q[a+788>>2],4,q[q[a+704>>2]+8>>2]),X(q[a+796>>2],4,q[q[a+704>>2]+8>>2]),X(q[a+800>>2],4,q[q[a+704>>2]+8>>2]),X(q[a+804>>2],4,q[q[a+704>>2]+8>>2]),X(q[a+812>>2],4,q[q[a+704>>2]+12>>2]),X(q[a+816>>2],4,q[q[a+704>>2]+12>>2]),X(q[a+820>>2],4,q[q[a+704>>2]+12>>2]),X(q[a+828>>2],4,q[q[a+704>>2]+12>>2]),X(q[a+852>>2],4,q[q[a+704>>2]+16>>2]),X(q[a+856>>2],4,q[q[a+704>>2]+16>>2]),X(q[a+860>>2],4,q[q[a+704>>2]+16>>2]),X(q[a+868>>2],4,q[q[a+704>>2]+16>>2]),X(q[a+872>>2],4,q[q[a+704>>2]+16>>2]),X(q[a+876>>2],4,q[q[a+704>>2]+16>>2]),X(q[a+880>>2],4,q[q[a+704>>2]+16>>2]),X(q[a+884>>2],4,q[q[a+704>>2]+16>>2]),X(q[a+888>>2],1,q[q[a+704>>2]+16>>2]),X(q[a+892>>2],4,q[q[a+704>>2]+16>>2]),X(q[a+896>>2],4,q[q[a+704>>2]+16>>2]),X(q[a+900>>2],4,q[q[a+704>>2]+16>>2]),X(q[a+904>>2],4,q[q[a+704>>2]+16>>2]),X(q[a+908>>2],4,q[q[a+704>>2]+16>>2]),X(q[a+912>>2],4,q[q[a+704>>2]+16>>2]),X(q[a+924>>2],4,q[q[a+704>>2]+20>>2]),X(q[a+928>>2],4,q[q[a+704>>2]+20>>2]),X(q[a+932>>2],4,q[q[a+704>>2]+20>>2]),X(q[a+936>>2],4,q[q[a+704>>2]+20>>2]),X(q[a+940>>2],4,q[q[a+704>>2]+20>>2]),X(q[a+948>>2],4,q[q[a+704>>2]+20>>2]),X(q[a+952>>2],4,q[q[a+704>>2]+20>>2]),X(q[a+976>>2],4,q[q[a+704>>2]+24>>2]),X(q[a+980>>2],4,q[q[a+704>>2]+28>>2]),X(q[a+984>>2],4,q[q[a+704>>2]+28>>2]),X(q[a+996>>2],4,q[q[a+704>>2]+32>>2]),X(q[a+1e3>>2],4,q[q[a+704>>2]+32>>2]),X(q[a+1004>>2],4,q[q[a+704>>2]+32>>2]),X(q[a+1008>>2],4,q[q[a+704>>2]+32>>2]),X(q[a+1012>>2],4,q[q[a+704>>2]+32>>2]),X(q[a+1016>>2],4,q[q[a+704>>2]+32>>2]),X(q[a+1020>>2],4,q[q[a+704>>2]+32>>2]),X(q[a+1032>>2],4,q[q[a+704>>2]+36>>2]),X(q[a+1036>>2],4,q[q[a+704>>2]+36>>2]),X(q[a+1040>>2],4,q[q[a+704>>2]+36>>2]),X(q[a+1052>>2],4,q[q[a+704>>2]+40>>2]),X(q[a+1064>>2],4,q[q[a+704>>2]+44>>2]),X(q[a+1068>>2],4,q[q[a+704>>2]+48>>2]),X(q[a+1072>>2],4,q[q[a+704>>2]+48>>2]),X(q[a+1056>>2],4,q[q[a+704>>2]+52>>2]),X(q[a+1060>>2],4,q[q[a+704>>2]+52>>2]),X(q[a+1192>>2],4,q[q[a+704>>2]+56>>2]),X(q[a+1196>>2],4,q[q[a+704>>2]+60>>2]),X(q[a+1200>>2],2,q[q[a+704>>2]+64>>2]),X(q[a+1204>>2],4,q[q[a+704>>2]+68>>2]),X(q[a+1208>>2],4,q[q[a+704>>2]+72>>2]),X(q[a+1212>>2],4,q[q[a+704>>2]+72>>2]),X(q[a+1216>>2],4,q[q[a+704>>2]+72>>2]),X(q[a+1220>>2],4,q[q[a+704>>2]+72>>2]),X(q[a+1224>>2],4,q[q[a+704>>2]+72>>2]),X(q[a+1228>>2],4,q[q[a+704>>2]+76>>2]),X(q[a+1232>>2],4,q[q[a+704>>2]+76>>2]),X(q[a+1236>>2],4,q[q[a+704>>2]+76>>2]),X(q[a+1248>>2],4,q[q[a+704>>2]+80>>2]),X(q[a+1252>>2],4,q[q[a+704>>2]+80>>2]),X(q[a+1256>>2],4,q[q[a+704>>2]+80>>2]),X(q[a+1260>>2],4,q[q[a+704>>2]+80>>2]),X(q[a+1264>>2],4,q[q[a+704>>2]+80>>2]),X(q[a+1268>>2],4,q[q[a+704>>2]+80>>2]),X(q[a+1272>>2],4,q[q[a+704>>2]+80>>2]),X(q[a+1276>>2],4,q[q[a+704>>2]+84>>2]),X(q[a+1280>>2],2,q[q[a+704>>2]+84>>2]),X(q[a+1284>>2],4,q[q[a+704>>2]+88>>2]),Xa>>>0<2||(X(q[a+808>>2],4,q[q[a+704>>2]+8>>2]),Xa>>>0<4)||(X(q[a+968>>2],4,q[q[a+704>>2]+20>>2]),X(q[a+972>>2],4,q[q[a+704>>2]+20>>2]),X(q[a+792>>2],4,q[q[a+704>>2]+8>>2]),X(q[a+824>>2],4,q[q[a+704>>2]+12>>2]),X(q[a+864>>2],4,q[q[a+704>>2]+16>>2]),X(q[a+1288>>2],4,q[q[a+704>>2]+92>>2]),X(q[a+1292>>2],4,q[q[a+704>>2]+92>>2]),X(q[a+1296>>2],4,q[q[a+704>>2]+92>>2]),X(q[a+1300>>2],4,q[q[a+704>>2]+96>>2]),X(q[a+1304>>2],4,q[q[a+704>>2]+96>>2]),X(q[a+1308>>2],4,q[q[a+704>>2]+96>>2]),X(q[a+944>>2],4,q[q[a+704>>2]+20>>2]),X(q[a+956>>2],4,q[q[a+704>>2]+20>>2]),X(q[a+960>>2],4,q[q[a+704>>2]+20>>2]),X(q[a+1076>>2],4,q[q[a+704>>2]+100>>2]),X(q[a+1080>>2],4,q[q[a+704>>2]+100>>2]),X(q[a+1084>>2],4,q[q[a+704>>2]+100>>2]),X(q[a+1088>>2],4,q[q[a+704>>2]+104>>2]),X(q[a+1092>>2],4,q[q[a+704>>2]+104>>2]),X(q[a+1096>>2],4,q[q[a+704>>2]+104>>2]),X(q[a+1100>>2],4,q[q[a+704>>2]+104>>2]),X(q[a+1104>>2],4,q[q[a+704>>2]+104>>2]),X(q[a+1120>>2],4,q[q[a+704>>2]+108>>2]),X(q[a+1124>>2],4,q[q[a+704>>2]+108>>2]),X(q[a+1128>>2],4,q[q[a+704>>2]+108>>2]),X(q[a+1144>>2],4,q[q[a+704>>2]+112>>2]),X(q[a+1148>>2],4,q[q[a+704>>2]+112>>2]),X(q[a+1152>>2],4,q[q[a+704>>2]+112>>2]),X(q[a+1168>>2],4,q[q[a+704>>2]+116>>2]),X(q[a+1172>>2],4,q[q[a+704>>2]+120>>2]),X(q[a+1176>>2],4,q[q[a+704>>2]+120>>2]),X(q[a+1180>>2],4,q[q[a+704>>2]+120>>2]),X(q[a+1184>>2],4,q[q[a+704>>2]+124>>2]),X(q[a+1188>>2],4,q[q[a+704>>2]+124>>2]),4!=(0|Xa)&&(X(q[a+988>>2],4,q[q[a+704>>2]+28>>2]),X(q[a+992>>2],4,q[q[a+704>>2]+28>>2]),X(q[a+1024>>2],4,q[q[a+704>>2]+32>>2]),X(q[a+1028>>2],4,q[q[a+704>>2]+32>>2]),X(q[a+1044>>2],4,q[q[a+704>>2]+36>>2]),X(q[a+1048>>2],4,q[q[a+704>>2]+36>>2]),X(q[a+1108>>2],4,q[q[a+704>>2]+128>>2]),X(q[a+1112>>2],4,q[q[a+704>>2]+128>>2]),X(q[a+1116>>2],4,q[q[a+704>>2]+128>>2]),X(q[a+1132>>2],4,q[q[a+704>>2]+132>>2]),X(q[a+1136>>2],4,q[q[a+704>>2]+132>>2]),X(q[a+1140>>2],4,q[q[a+704>>2]+132>>2]),X(q[a+1156>>2],4,q[q[a+704>>2]+136>>2]),X(q[a+1160>>2],4,q[q[a+704>>2]+136>>2]),X(q[a+1164>>2],4,q[q[a+704>>2]+136>>2])))}function za(a,Za){var $a=0,lb=0,mb=0,ob=0,pb=0,rb=0,nb=a+Za|0;a:{b:if(!(1&($a=q[a+4>>2]))){if(!(3&$a))break a;if(Za=($a=q[a>>2])+Za|0,(0|(a=a-$a|0))!=q[2100])if($a>>>0<=255)mb=$a>>>3,$a=q[a+8>>2],(0|(lb=q[a+12>>2]))==(0|$a)?(rb=q[2095]&fd(mb),q[2095]=rb):(q[$a+12>>2]=lb,q[lb+8>>2]=$a);else{if(pb=q[a+24>>2],(0|($a=q[a+12>>2]))!=(0|a))lb=q[a+8>>2],q[lb+12>>2]=$a,q[$a+8>>2]=lb;else if(mb=(mb=q[(lb=a+20|0)>>2])||q[(lb=a+16|0)>>2]){for(;ob=lb,(mb=q[(lb=($a=mb)+20|0)>>2])||(lb=$a+16|0,mb=q[$a+16>>2]););q[ob>>2]=0}else $a=0;if(pb){lb=q[a+28>>2];e:{if(q[(mb=8684+(lb<<2)|0)>>2]==(0|a)){if(q[mb>>2]=$a)break e;rb=q[2096]&fd(lb),q[2096]=rb;break b}if(!(q[pb+(q[pb+16>>2]==(0|a)?16:20)>>2]=$a))break b}q[$a+24>>2]=pb,(lb=q[a+16>>2])&&(q[$a+16>>2]=lb,q[lb+24>>2]=$a),(lb=q[a+20>>2])&&(q[$a+20>>2]=lb,q[lb+24>>2]=$a)}}else if(3==(3&($a=q[4+nb>>2])))return q[2097]=Za,q[4+nb>>2]=-2&$a,q[a+4>>2]=1|Za,q[nb>>2]=Za}f:{if(!(2&($a=q[4+nb>>2]))){if(q[2101]==(0|nb)){if(q[2101]=a,Za=q[2098]+Za|0,q[2098]=Za,q[a+4>>2]=1|Za,q[2100]!=(0|a))break a;return q[2097]=0,q[2100]=0}if(q[2100]==(0|nb))return q[2100]=a,Za=q[2097]+Za|0,q[2097]=Za,q[a+4>>2]=1|Za,q[a+Za>>2]=Za;Za=(-8&$a)+Za|0;g:if($a>>>0<=255)mb=$a>>>3,$a=q[8+nb>>2],(0|(lb=q[12+nb>>2]))==(0|$a)?(rb=q[2095]&fd(mb),q[2095]=rb):(q[$a+12>>2]=lb,q[lb+8>>2]=$a);else{if(pb=q[24+nb>>2],(0|nb)!=(0|($a=q[12+nb>>2])))lb=q[8+nb>>2],q[lb+12>>2]=$a,q[$a+8>>2]=lb;else if(mb=(mb=q[(lb=20+nb|0)>>2])||q[(lb=16+nb|0)>>2]){for(;ob=lb,(mb=q[(lb=($a=mb)+20|0)>>2])||(lb=$a+16|0,mb=q[$a+16>>2]););q[ob>>2]=0}else $a=0;if(pb){lb=q[28+nb>>2];j:{if(q[(mb=8684+(lb<<2)|0)>>2]==(0|nb)){if(q[mb>>2]=$a)break j;rb=q[2096]&fd(lb),q[2096]=rb;break g}if(!(q[pb+(q[pb+16>>2]==(0|nb)?16:20)>>2]=$a))break g}q[$a+24>>2]=pb,(lb=q[16+nb>>2])&&(q[$a+16>>2]=lb,q[lb+24>>2]=$a),(lb=q[20+nb>>2])&&(q[$a+20>>2]=lb,q[lb+24>>2]=$a)}}if(q[a+4>>2]=1|Za,q[a+Za>>2]=Za,q[2100]!=(0|a))break f;return q[2097]=Za}q[4+nb>>2]=-2&$a,q[a+4>>2]=1|Za,q[a+Za>>2]=Za}if(Za>>>0<=255)return Za=8420+(($a=Za>>>3)<<3)|0,$a=(lb=q[2095])&($a=1<<$a)?q[Za+8>>2]:(q[2095]=$a|lb,Za),q[Za+8>>2]=a,q[$a+12>>2]=a,q[a+12>>2]=Za,q[a+8>>2]=$a;q[a+16>>2]=0,$a=q[a+20>>2]=0,(mb=Za>>>8)&&($a=31,16777215>>0||($a=28+(($a=((nb=(mb<<=ob=mb+1048320>>>16&8)<<($a=mb+520192>>>16&4))<<(mb=245760+nb>>>16&2)>>>15)-(mb|$a|ob)|0)<<1|Za>>>$a+21&1)|0)),mb=8684+((q[(lb=a)+28>>2]=$a)<<2)|0;m:{if((lb=q[2096])&(ob=1<<$a)){for(lb=Za<<(31==(0|$a)?0:25-($a>>>1)|0),$a=q[mb>>2];;){if((-8&q[(mb=$a)+4>>2])==(0|Za))break m;if($a=lb>>>29,lb<<=1,!($a=q[16+(ob=mb+(4&$a)|0)>>2]))break}q[ob+16>>2]=a}else q[2096]=lb|ob,q[mb>>2]=a;return q[a+24>>2]=mb,q[a+12>>2]=a,q[a+8>>2]=a}Za=q[mb+8>>2],q[Za+12>>2]=a,q[mb+8>>2]=a,q[a+24>>2]=0,q[a+12>>2]=mb,q[a+8>>2]=Za}}function Aa(a){var vb,xb,yb,Ab,Bb,Cb,sb,wb,Za=x(0),tb=(x(0),0),ub=0,zb=(x(0),x(0),x(0),x(0),0);x(0),x(0);a:{b:{if(j(a),ub=2147483647&(tb=b[0])){if(!(ub>>>0<2139095041))return x(x(.10000000149011612)+a);if(1065353216==(0|ub))return x(-1<(0|tb)?.10000000149011612:10);if(2139095040==(0|ub))return x(-1<(0|tb)?0:-a);if(1073741824==(0|tb))return x(.010000000707805157);if(1056964608==(0|tb))return x(.3162277638912201);if(1291845633<=ub>>>0)return x((0|tb)<0?H:0);if(vb=u[1709],wb=x(x(1.600000023841858)-vb),xb=x(x(1)/x(vb+x(1.600000023841858))),f(0,-4096&(j(sb=x(wb*xb)),b[0])),Za=k(),yb=x(Za*Za),Bb=u[1713],vb=x(xb*x(x(wb-x((Ab=Za)*x(3.099609375)))-x(Za*x(x(1.600000023841858)-x(x(3.099609375)-vb))))),xb=x(x(sb+Za)*vb),Za=x(sb*sb),wb=x(xb+x(x(Za*Za)*x(x(Za*x(x(Za*x(x(Za*x(x(Za*x(x(Za*x(.20697501301765442))+x(.23066075146198273)))+x(.2727281153202057)))+x(.3333333432674408)))+x(.4285714328289032)))+x(.6000000238418579)))),f(0,-4096&(j(x(x(yb+x(3))+wb)),b[0])),Za=k(),xb=x(Ab*Za),sb=x(x(vb*Za)+x(sb*x(wb-x(x(Za+x(-3))-yb)))),f(0,-4096&(j(x(xb+sb)),b[0])),Za=k(),vb=x(Za*x(.9619140625)),yb=x(u[1711]+x(x(x(sb-x(Za-xb))*x(.9617967009544373))+x(Za*x(-.00011736857413779944)))),f(0,-4096&(j(x(x(Bb+x(vb+yb))+x(-4))),b[0])),sb=k(),f(0,-4096&tb),wb=k(),Za=x(sb*wb),a=x(x(x(yb-x(x(x(sb-x(-4))-Bb)-vb))*a)+x(x(a-wb)*sb)),j(sb=x(Za+a)),1124073473<=(0|(tb=b[0])))break b;d:{if((ub=1124073472)==(0|tb)){if(x(a+x(4.299566569443414e-8))>x(sb-Za))break b}else{if(ub=2147483647&tb,!(a<=x(sb-Za)^1|-1021968384!=(0|tb))|1125515265<=ub>>>0)break a;if(ub>>>0<1056964609)break d}zb=(8388607&(ub=(8388608>>>(ub>>>23)-126)+tb|0)|8388608)>>>150-(Cb=ub>>>23&255),zb=(0|tb)<0?0-zb|0:zb,Za=x(Za-(f(0,ub&-8388608>>Cb-127),k())),j(x(a+Za)),tb=b[0]}f(0,-32768&tb),sb=k(),vb=x(sb*x(.693145751953125)),sb=x(x(sb*x(14286065379565116e-22))+x(x(a-x(sb-Za))*x(.6931471824645996))),a=x(vb+sb),Za=x(a*a),Za=x(a-x(Za*x(x(Za*x(x(Za*x(x(Za*x(x(Za*x(4.138136944220605e-8))+x(-16533901998627698e-22)))+x(661375597701408e-19)))+x(-.0027777778450399637)))+x(.1666666716337204)))),Ab=x(x(a*Za)/x(Za+x(-2))),Za=x(sb-x(a-vb)),a=(0|(tb=0|(j(a=x(x(a-x(Ab-x(Za+x(a*Za))))+x(1))),b[0]+(zb<<23))))<=8388607?function(a,Tk){var vl=0;return 128<=(0|Tk)?(a=x(a*x(17014118346046923e22)),Tk=(0|(vl=Tk+-127|0))<128?vl:(a=x(a*x(17014118346046923e22)),((0|Tk)<381?Tk:381)+-254|0)):-127<(0|Tk)||(a=x(a*x(11754943508222875e-54)),Tk=-127<(0|(vl=Tk+126|0))?vl:(a=x(a*x(11754943508222875e-54)),(-378<(0|Tk)?Tk:-378)+252|0)),x(a*(f(0,1065353216+(Tk<<23)|0),k()))}(a,zb):(f(0,tb),k()),a=x(x(1)*a)}else a=x(1);return a}return x(H)}return x(0)}function Ba(a,Db){var Jb,Eb,Gb,Fb=0,Hb=0,Ib=x(0);if(j(Db),!((Gb=2147483647&(Eb=b[0]))>>>0<=2139095040&&(j(a),(Fb=2147483647&(Hb=b[0]))>>>0<2139095041)))return x(a+Db);if(1065353216==(0|Eb))return Ca(a);Eb=(Jb=Eb>>>30&2)|Hb>>>31;b:{c:{d:{e:{if(!Fb){switch(Eb-2|0){case 0:break e;case 1:break;default:break d}return x(-3.1415927410125732)}if(2139095040!=(0|Gb)){if(!Gb|!(Fb>>>0<=218103808+Gb>>>0&&2139095040!=(0|Fb)))break b;if(a=Ib=Fb+218103808>>>0>>0&&(Ib=x(0),Jb)?Ib:Ca(x(y(x(a/Db)))),Eb>>>0<=2){switch(Eb-1|0){case 0:return x(-a);case 1:break;default:break d}return x(x(3.1415927410125732)-x(a+x(8.742277657347586e-8)))}return x(x(a+x(8.742277657347586e-8))+x(-3.1415927410125732))}if(2139095040==(0|Fb))break c;return u[6816+(Eb<<2)>>2]}a=x(3.1415927410125732)}return a}return u[6800+(Eb<<2)>>2]}return x((0|Hb)<0?-1.5707963705062866:1.5707963705062866)}function Ca(a){x(0);var Kb,Nb,Ob,Db,Mb,Lb=0;x(0),x(0),j(a);a:{if(1283457024<=(Db=2147483647&(Mb=b[0]))>>>0){if(2139095040>>0)break a;return x((0|Mb)<0?-1.570796251296997:1.570796251296997)}b:{if(Db>>>0<=1054867455){if(Lb=-1,964689920<=Db>>>0)break b;break a}a=x(y(a)),Lb=Db>>>0<=1066926079?Db>>>0<=1060110335?(a=x(x(x(a+a)+x(-1))/x(a+x(2))),0):(a=x(x(a+x(-1))/x(a+x(1))),1):Db>>>0<=1075576831?(a=x(x(a+x(-1.5))/x(x(a*x(1.5))+x(1))),2):(a=x(x(-1)/a),3)}if(Db=Lb,Nb=x(a*a),Kb=x(Nb*Nb),Ob=x(Kb*x(x(Kb*x(-.106480173766613))+x(-.19999158382415771))),Kb=x(Nb*x(x(Kb*x(x(Kb*x(.06168760731816292))+x(.14253635704517365)))+x(.333333283662796))),(0|Db)<=-1)return x(a-x(a*x(Ob+Kb)));a=x(u[6768+(Db<<=2)>>2]-x(x(x(a*x(Ob+Kb))-u[6784+Db>>2])-a)),a=(0|Mb)<0?x(-a):a}return a}function Da(a,Pb){var Ub,Sb,Tb,Qb=0,Rb=0;return L=Sb=L-16|0,j(a),(Qb=2147483647&(Tb=b[0]))>>>0<=1305022426?(v[Pb>>3]=(Ub=+a)+-1.5707963109016418*(Rb=.6366197723675814*Ub+6755399441055744-6755399441055744)+-1.5893254773528196e-8*Rb,Qb=y(Rb)<2147483648?~~Rb:-2147483648):2139095040<=Qb>>>0?(v[Pb>>3]=x(a-a),Qb=0):(Ub=Qb,v[8+Sb>>3]=(f(0,Ub-((Qb=(Qb>>>23)-150|0)<<23)|0),k()),Qb=function(a,Gl,Hl){var Ll,Ql,Ul,Vl,Xl,Yl,Il=0,Jl=0,Kl=0,Ml=0,Nl=0,Ol=0,Pl=0,Rl=0,Sl=0,Tl=0,Wl=0;if(L=Ll=L-560|0,Pl=(Jl=Hl)+w(Ul=0<(0|(Hl=(Hl+-3|0)/24|0))?Hl:0,-24)|0,0<=(0|(Ql=q[980])))for(Jl=Ql+1|0,Hl=Ul;v[(320+Ll|0)+(Kl<<3)>>3]=(0|Hl)<0?0:+q[3936+(Hl<<2)>>2],Hl=Hl+1|0,(0|Jl)!=(0|(Kl=Kl+1|0)););for(Nl=Pl+-24|0,Jl=0;;){for(Il=Hl=0;Il+=v[(Hl<<3)+a>>3]*v[(320+Ll|0)+(Jl-Hl<<3)>>3],1!=(0|(Hl=Hl+1|0)););if(v[(Jl<<3)+Ll>>3]=Il,Hl=(0|Jl)<(0|Ql),Jl=Jl+1|0,!Hl)break}Yl=23-Nl|0,Vl=24-Nl|0,Jl=Ql;a:{for(;;){if(Il=v[(Jl<<3)+Ll>>3],!(Sl=((Hl=0)|(Kl=Jl))<1))for(;Ol=(480+Ll|0)+(Hl<<2)|0,Rl=Il,Ml=y(Il*=5.960464477539063e-8)<2147483648?~~Il:-2147483648,Ml=y(Rl+=-16777216*(Il=0|Ml))<2147483648?~~Rl:-2147483648,q[Ol>>2]=Ml,Il=v[((Kl=Kl+-1|0)<<3)+Ll>>3]+Il,(0|Jl)!=(0|(Hl=Hl+1|0)););Il=ja(Il,Nl),Il=(Il+=-8*C(.125*Il))-(0|(Ol=y(Il)<2147483648?~~Il:-2147483648));e:{f:{g:{if(Wl=(0|Nl)<1){if(Nl)break g;Ml=q[476+((Jl<<2)+Ll|0)>>2]>>23}else Ml=q[476+(Kl=(Jl<<2)+Ll|0)>>2],Tl=Kl,Kl=Ml-((Hl=Ml>>Vl)<>2]=Kl)>>Yl;if((0|Ml)<1)break e;break f}if(Ml=2,!(.5<=Il)){Ml=0;break e}}if(Kl=Hl=0,!Sl)for(;;){Sl=q[(Xl=(480+Ll|0)+(Hl<<2)|0)>>2],Tl=16777215;i:{j:{if(!Kl){if(!Sl)break j;Tl=16777216,Kl=1}q[Xl>>2]=Tl-Sl;break i}Kl=0}if((0|Jl)==(0|(Hl=Hl+1|0)))break}Wl||1<(Hl=Nl+-1|0)>>>0||(q[476+(Hl=(Jl<<2)+Ll|0)>>2]=Hl-1?8388607&q[Hl+476>>2]:4194303&q[Hl+476>>2]),Ol=Ol+1|0,2==(0|Ml)&&(Il=1-Il,Ml=2,Kl)&&(Il-=ja(1,Nl))}if(0!=Il)break;if(!(((Kl=0)|(Hl=Jl))<=(0|Ql))){for(;Kl=q[(480+Ll|0)+((Hl=Hl+-1|0)<<2)>>2]|Kl,(0|Ql)<(0|Hl););if(Kl){for(Pl=Nl;Pl=Pl+-24|0,!q[(480+Ll|0)+((Jl=Jl+-1|0)<<2)>>2];);break a}}for(Hl=1;Hl=(Kl=Hl)+1|0,!q[(480+Ll|0)+(Ql-Kl<<2)>>2];);for(Kl=Jl+Kl|0;;){for(Jl=Ol=Jl+1|0,v[(320+Ll|0)+(Ol<<3)>>3]=q[3936+(Ul+Jl<<2)>>2],Il=Hl=0;Il+=v[(Hl<<3)+a>>3]*v[(320+Ll|0)+(Ol-Hl<<3)>>3],1!=(0|(Hl=Hl+1|0)););if(v[(Jl<<3)+Ll>>3]=Il,!((0|Jl)<(0|Kl)))break}Jl=Kl}16777216<=(Il=ja(Il,0-Nl|0))?(a=(480+Ll|0)+(Jl<<2)|0,Rl=Il,Hl=y(Il*=5.960464477539063e-8)<2147483648?~~Il:-2147483648,Kl=y(Il=Rl+-16777216*(0|Hl))<2147483648?~~Il:-2147483648,q[a>>2]=Kl,Jl=Jl+1|0):(Hl=y(Il)<2147483648?~~Il:-2147483648,Pl=Nl),q[(480+Ll|0)+(Jl<<2)>>2]=Hl}if(Il=ja(1,Pl),!((0|Jl)<=-1)){for(Hl=Jl;v[(Hl<<3)+Ll>>3]=Il*+q[(480+Ll|0)+(Hl<<2)>>2],Il*=5.960464477539063e-8,a=0<(0|Hl),Hl=Hl+-1|0,a;);if(!((0|Jl)<=-1))for(Hl=Jl;;){for(Nl=Jl-(a=Hl)|0,Hl=Il=0;Il+=v[6704+(Hl<<3)>>3]*v[(a+Hl<<3)+Ll>>3],!((0|Ql)<=(0|Hl))&&(Pl=Hl>>>0>>0,Hl=Hl+1|0,Pl););if(v[(160+Ll|0)+(Nl<<3)>>3]=Il,Hl=a+-1|0,!(0<(0|a)))break}}if(0<=(Jl|(Il=0)))for(;Il+=v[(160+Ll|0)+(Jl<<3)>>3],a=0<(0|Jl),Jl=Jl+-1|0,a;);return v[Gl>>3]=Ml?-Il:Il,L=560+Ll|0,7&Ol}(8+Sb|0,Sb,Qb),Rb=v[Sb>>3],(0|Tb)<=-1?(v[Pb>>3]=-Rb,Qb=0-Qb|0):v[Pb>>3]=Rb),L=16+Sb|0,Qb}function Ea(a,Pb){return a?function(a,Gl){a:{if(a){if(Gl>>>0<=127)break a;if(q[q[1797]>>2]){if(Gl>>>0<=2047)return o[a+1|0]=63&Gl|128,o[0|a]=Gl>>>6|192,2;if(!(57344!=(-8192&Gl)&&55296<=Gl>>>0))return o[a+2|0]=63&Gl|128,o[0|a]=Gl>>>12|224,o[a+1|0]=Gl>>>6&63|128,3;if(Gl+-65536>>>0<=1048575)return o[a+3|0]=63&Gl|128,o[0|a]=Gl>>>18|240,o[a+2|0]=Gl>>>6&63|128,o[a+1|0]=Gl>>>12&63|128,4}else if(57216==(-128&Gl))break a;q[2094]=25,a=-1}else a=1;return a}return o[0|a]=Gl,1}(a,Pb):0}function Fa(a,Pb,Wb){var fc,gc,Xb=0,Yb=0,Zb=0,_b=0,$b=0,ac=0,bc=0,cc=0,dc=0,ec=r[a+4|0];if(q[Pb>>2]=652,Yb=q[a+704>>2],1<=(0|(_b=q[Yb>>2]))){for($b=q[a+720>>2],bc=q[a+1072>>2];Zb=(1<>2]<<2)>>2])+Zb|0,(0|_b)!=(0|(Xb=Xb+1|0)););Xb=Zb<<2}if(q[Pb+4>>2]=w(_b,12),q[Pb+8>>2]=q[Yb>>2]<<2,q[Pb+12>>2]=q[Yb>>2]<<2,q[Pb+16>>2]=q[Yb>>2]<<2,q[Pb+20>>2]=q[Yb>>2]<<2,Zb=q[Yb>>2],q[Pb+28>>2]=Xb,q[Pb+24>>2]=Zb<<2,Zb=q[Yb>>2],q[Pb+40>>2]=Xb,q[Pb+36>>2]=Xb,q[Pb+32>>2]=Zb<<2,q[Pb+44>>2]=q[Yb+4>>2]<<5,q[Pb+48>>2]=q[Yb+4>>2]<<2,q[Pb+52>>2]=q[Yb+4>>2]<<2,q[Pb+56>>2]=q[Yb+4>>2]<<2,q[Pb+60>>2]=q[Yb+4>>2]<<4,q[Pb+64>>2]=q[Yb+4>>2]<<4,1<=((Xb=0)|(_b=q[Yb+8>>2]))){for($b=q[a+780>>2],bc=q[a+1072>>2],dc=q[a+796>>2],Zb=0;ac=(15+(q[(cc=Xb<<2)+dc>>2]<<3)&-16)+ac|0,Zb=(1<>2]<<2)>>2])+Zb|0,(0|_b)!=(0|(Xb=Xb+1|0)););Xb=Zb<<2}if(q[Pb+68>>2]=w(_b,24),q[Pb+72>>2]=q[Yb+8>>2]<<2,q[Pb+76>>2]=q[Yb+8>>2]<<2,Zb=q[Yb+8>>2],q[Pb+84>>2]=ac,q[Pb+80>>2]=Zb<<2,q[Pb+88>>2]=q[Yb+8>>2]<<4,q[Pb+92>>2]=q[Yb+8>>2]<<4,Zb=q[Yb+8>>2],q[Pb+100>>2]=Xb,q[Pb+96>>2]=Zb<<2,Zb=q[Yb+8>>2],q[Pb+140>>2]=Xb,q[Pb+136>>2]=Xb,q[Pb+132>>2]=Xb,q[Pb+128>>2]=Xb,q[Pb+124>>2]=Xb,q[Pb+120>>2]=Xb,q[Pb+116>>2]=Xb,q[Pb+112>>2]=Xb,q[Pb+108>>2]=Xb,q[Pb+104>>2]=Zb<<2,q[Pb+144>>2]=q[Yb+8>>2]<<2,q[Pb+148>>2]=q[Yb+8>>2]<<2,q[Pb+152>>2]=q[Yb+8>>2]<<2,q[Pb+156>>2]=q[Yb+8>>2]<<2,q[Pb+160>>2]=q[Yb+8>>2]<<2,q[Pb+164>>2]=q[Yb+8>>2]<<2,1<=((Xb=ac=0)|(_b=q[Yb+12>>2]))){for($b=q[a+812>>2],bc=q[a+1072>>2],Zb=0;Zb=(1<>2]<<2)>>2])+Zb|0,(0|_b)!=(0|(Xb=Xb+1|0)););Xb=Zb<<2}if(q[Pb+168>>2]=w(_b,12),q[Pb+172>>2]=q[Yb+12>>2]<<2,q[Pb+176>>2]=q[Yb+12>>2]<<2,q[Pb+180>>2]=q[Yb+12>>2]<<2,q[Pb+184>>2]=q[Yb+12>>2]<<2,q[Pb+188>>2]=q[Yb+12>>2]<<2,q[Pb+192>>2]=q[Yb+12>>2]<<2,q[Pb+196>>2]=q[Yb+12>>2]<<2,q[Pb+200>>2]=q[Yb+12>>2]<<2,q[Pb+204>>2]=q[Yb+12>>2]<<4,q[Pb+208>>2]=q[Yb+12>>2]<<4,Zb=q[Yb+12>>2],q[Pb+216>>2]=Xb,q[Pb+212>>2]=Zb<<2,Zb=q[Yb+12>>2],q[Pb+268>>2]=Xb,q[Pb+264>>2]=Xb,q[Pb+260>>2]=Xb,q[Pb+256>>2]=Xb,q[Pb+252>>2]=Xb,q[Pb+248>>2]=Xb,q[Pb+244>>2]=Xb,q[Pb+240>>2]=Xb,q[Pb+236>>2]=Xb,q[Pb+232>>2]=Xb,q[Pb+228>>2]=Xb,q[Pb+224>>2]=Xb,q[Pb+220>>2]=Zb<<2,q[Pb+272>>2]=q[Yb+12>>2]<<2,q[Pb+276>>2]=q[Yb+12>>2]<<2,q[Pb+280>>2]=q[Yb+12>>2]<<2,q[Pb+284>>2]=q[Yb+12>>2]<<2,q[Pb+288>>2]=q[Yb+12>>2]<<2,q[Pb+292>>2]=q[Yb+12>>2]<<2,1<=((Xb=0)|(Zb=q[Yb+16>>2]))){for($b=q[a+852>>2],bc=q[a+1072>>2],dc=q[a+892>>2],_b=0;ac=(15+(q[(cc=Xb<<2)+dc>>2]<<3)&-16)+ac|0,_b=(1<>2]<<2)>>2])+_b|0,(0|Zb)!=(0|(Xb=Xb+1|0)););Xb=_b<<2}if(q[Pb+296>>2]=w(Zb,20),q[Pb+300>>2]=q[Yb+16>>2]<<2,q[Pb+304>>2]=q[Yb+16>>2],q[Pb+308>>2]=q[Yb+16>>2]<<2,q[Pb+312>>2]=q[Yb+16>>2]<<2,Zb=q[Yb+16>>2],q[Pb+320>>2]=ac,q[Pb+316>>2]=Zb<<2,q[Pb+324>>2]=q[Yb+16>>2]<<2,q[Pb+328>>2]=q[Yb+16>>2]<<4,q[Pb+332>>2]=q[Yb+16>>2]<<4,q[Pb+336>>2]=q[Yb+16>>2]<<2,q[Pb+340>>2]=q[Yb+16>>2]<<2,q[Pb+344>>2]=q[Yb+16>>2]<<2,q[Pb+348>>2]=q[Yb+16>>2]<<4,q[Pb+352>>2]=q[Yb+16>>2]<<4,Zb=q[Yb+16>>2],q[Pb+360>>2]=Xb,q[Pb+356>>2]=Zb<<2,Zb=q[Yb+16>>2],q[Pb+404>>2]=Xb,q[Pb+400>>2]=Xb,q[Pb+396>>2]=Xb,q[Pb+392>>2]=Xb,q[Pb+388>>2]=Xb,q[Pb+384>>2]=Xb,q[Pb+380>>2]=Xb,q[Pb+376>>2]=Xb,q[Pb+372>>2]=Xb,q[Pb+368>>2]=Xb,q[Pb+364>>2]=Zb<<2,q[Pb+408>>2]=q[Yb+16>>2]<<2,q[Pb+412>>2]=q[Yb+16>>2]<<2,q[Pb+416>>2]=q[Yb+16>>2]<<2,q[Pb+420>>2]=q[Yb+16>>2]<<2,q[Pb+424>>2]=q[Yb+16>>2]<<2,q[Pb+428>>2]=q[Yb+16>>2]<<2,$b=q[a+704>>2],q[Pb+432>>2]=w(q[$b+20>>2],52),q[Pb+436>>2]=ec>>>(Xb=_b=0)<=3?q[$b+20>>2]<<2:0,q[Pb+440>>2]=q[$b+20>>2]<<2,q[Pb+444>>2]=w(q[$b+52>>2],28),1<=(0|(Yb=q[$b+48>>2]))){for(Zb=q[a+1072>>2],ac=0;ac=(bc=q[Zb+(Xb<<2)>>2])+ac|0,_b=(1<>2]=Xb,q[Pb+456>>2]=Xb,q[Pb+452>>2]=_b,q[Pb+448>>2]=w(Yb,36),q[Pb+500>>2]=w(q[$b+72>>2],28),1<=((ac=Xb=Zb=0)|(bc=q[$b+72>>2]))){for(dc=q[a+1224>>2],cc=q[a+1220>>2],gc=q[a+1212>>2],_b=0;_b=(0|(fc=q[(Yb=ac<<2)+cc>>2]-q[Yb+dc>>2]|0))<(0|_b)?_b:1+fc|0,Xb=(0|Xb)<(0|(Yb=q[Yb+gc>>2]))?Yb:Xb,(0|bc)!=(0|(ac=ac+1|0)););ac=Xb<<2,Xb=_b<<2}if(Yb=q[$b+76>>2],q[Pb+516>>2]=Xb,q[Pb+512>>2]=ac,q[Pb+508>>2]=Xb,q[Pb+504>>2]=Yb<<4,1<=(0|(Yb=q[$b+80>>2]))){for(Zb=q[a+1248>>2],ac=q[a+1072>>2],_b=Xb=0;_b=(1<>2]<<2)>>2])+_b|0,(0|Yb)!=(0|(Xb=Xb+1|0)););Zb=_b<<2}if(q[Pb+520>>2]=w(Yb,24),q[Pb+524>>2]=q[$b+80>>2]<<2,Yb=q[$b+80>>2],q[Pb+532>>2]=Zb,q[Pb+528>>2]=Yb<<2,Yb=q[$b+80>>2],q[Pb+544>>2]=Zb,q[Pb+540>>2]=Zb,q[Pb+536>>2]=Yb<<2,Yb=Pb,4<=ec>>>0){if(q[Pb+464>>2]=w(q[$b+120>>2],20),q[Pb+468>>2]=w(q[$b+100>>2],28),Zb=Pb,1<=((Xb=ac=0)|(bc=q[$b+104>>2]))){for(a=q[a+1104>>2],_b=0;_b=q[a+(Xb<<2)>>2]+_b|0,(0|bc)!=(0|(Xb=Xb+1|0)););a=_b<<2}else a=0;q[Zb+476>>2]=a,q[Pb+472>>2]=w(bc,48),q[Pb+484>>2]=w(q[$b+108>>2],12),a=q[$b+112>>2],q[Pb+552>>2]=0,q[Pb+492>>2]=w(a,12),a=0}else{if((0|(ac=q[$b+20>>2]))<1)_b=0;else for(bc=q[a+1060>>2],dc=q[a+952>>2],a=q[a+948>>2],Zb=_b=0;;){if(1<=(0|(cc=q[(Xb=Zb<<2)+dc>>2])))for(cc=(Xb=bc+(q[a+Xb>>2]<<2)|0)+(cc<<2)|0;_b=q[Xb>>2]+_b|0,(Xb=Xb+4|0)>>>0>>0;);if((0|ac)==(0|(Zb=Zb+1|0)))break}q[Pb+552>>2]=ac<<2,ac=q[$b+20>>2]<<2,a=_b<<2}for(q[Yb+556>>2]=a,q[Pb+548>>2]=ac,4>>0&&(q[Pb+480>>2]=w(q[$b+128>>2],12),q[Pb+488>>2]=w(q[$b+132>>2],12),q[Pb+496>>2]=w(q[$b+136>>2],12)),Xb=_b=0;Xb=((Yb=q[(a=(_b<<2)+Pb|0)>>2])+15&-16)+(q[a>>2]=Xb)|0,140!=(0|(_b=_b+1|0)););q[Wb>>2]=Xb}function Ga(a,Pb,Wb,hc){a:{if(!(20>>0||9<(Pb=Pb+-9|0)>>>0)){switch(Pb-1|0){default:return Pb=q[Wb>>2],q[Wb>>2]=Pb+4,q[a>>2]=q[Pb>>2];case 0:return Pb=q[Wb>>2],q[Wb>>2]=Pb+4,Pb=q[Pb>>2],q[a>>2]=Pb,q[a+4>>2]=Pb>>31;case 1:return Pb=q[Wb>>2],q[Wb>>2]=Pb+4,q[a>>2]=q[Pb>>2],q[a+4>>2]=0;case 3:return Pb=q[Wb>>2],q[Wb>>2]=Pb+4,Pb=p[Pb>>1],q[a>>2]=Pb,q[a+4>>2]=Pb>>31;case 4:return Pb=q[Wb>>2],q[Wb>>2]=Pb+4,q[a>>2]=s[Pb>>1],q[a+4>>2]=0;case 5:return Pb=q[Wb>>2],q[Wb>>2]=Pb+4,Pb=o[0|Pb],q[a>>2]=Pb,q[a+4>>2]=Pb>>31;case 6:return Pb=q[Wb>>2],q[Wb>>2]=Pb+4,q[a>>2]=r[0|Pb],q[a+4>>2]=0;case 2:case 7:break a;case 8:}n[hc](a,Wb)}return}Pb=q[Wb>>2]+7&-8,q[Wb>>2]=Pb+8,Wb=q[Pb+4>>2],q[a>>2]=q[Pb>>2],q[a+4>>2]=Wb}function Ha(a){var Pb,hc,Wb=0;if(ha(o[q[a>>2]]))for(;Pb=q[a>>2],hc=o[0|Pb],q[a>>2]=Pb+1,Wb=(w(Wb,10)+hc|0)-48|0,ha(o[Pb+1|0]););return Wb}function Ia(a,ic,jc,kc,lc){var oc,mc;q[204+(L=mc=L-208|0)>>2]=jc,ca(160+mc|(jc=0),0,40),q[200+mc>>2]=q[204+mc>>2],(0|ra(0,ic,200+mc|0,80+mc|0,160+mc|0,kc,lc))<0||(jc=0<=q[a+76>>2]?1:jc,jc=q[a>>2],o[a+74|0]<=0&&(q[a>>2]=-33&jc),oc=32&jc,q[a+48>>2]?ra(a,ic,200+mc|0,80+mc|0,160+mc|0,kc,lc):(q[a+48>>2]=80,q[a+16>>2]=80+mc,q[a+28>>2]=mc,q[a+20>>2]=mc,jc=q[a+44>>2],ra(a,ic,200+(q[a+44>>2]=mc)|0,80+mc|0,160+mc|0,kc,lc),jc&&(n[q[a+36>>2]](a,0,0),q[a+48>>2]=0,q[a+44>>2]=jc,q[a+28>>2]=0,q[a+16>>2]=0,q[a+20>>2]=0)),q[a>>2]=q[a>>2]|oc),L=208+mc|0}function Ka(a,ic,pc){var rc,qc;$(8+(L=qc=L-160|0)|0,3216,144),q[52+qc>>2]=a,q[28+qc>>2]=a,q[56+qc>>2]=rc=(rc=-2-a|0)>>>0<256?rc:256,q[36+qc>>2]=a=a+rc|0,q[24+qc>>2]=a,Ia(8+qc|0,ic,pc,11,12),rc&&(a=q[28+qc>>2],o[a-((0|a)==q[24+qc>>2])|0]=0),L=160+qc|0}function La(a,ic){var sc,tc,pc=0,pc=0!=(0|ic);a:{b:{c:{d:if(!(!ic|!(3&a)))for(;;){if(!r[0|a])break c;if(a=a+1|0,pc=0!=(0|(ic=ic+-1|0)),!ic)break d;if(!(3&a))break}if(!pc)break b}if(!r[0|a])break a;e:{if(4<=ic>>>0){for(pc=(pc=ic+-4|0)-(sc=-4&pc)|0,sc=4+(a+sc|0)|0;;){if((-1^(tc=q[a>>2]))&tc+-16843009&-2139062144)break e;if(a=a+4|0,!(3<(ic=ic+-4|0)>>>0))break}ic=pc,a=sc}if(!ic)break b}for(;;){if(!r[0|a])break a;if(a=a+1|0,!(ic=ic+-1|0))break}}return 0}return a}function Ma(a){var uc,ic=0;if(!a)return 32;if(!(1&a))for(;ic=ic+1|0,uc=2&a,a>>>=1,!uc;);return ic}function Na(a,vc){var zc,Ac,Bc,yc,wc=0,xc=0,xc=4;L=yc=L-256|0;a:if(!((0|vc)<2))for(wc=q[(Bc=(vc<<2)+a|0)>>2]=yc;;){for($(wc,q[a>>2],zc=xc>>>0<256?xc:256),wc=0;$(q[(Ac=(wc<<2)+a|0)>>2],q[((wc=wc+1|0)<<2)+a>>2],zc),q[Ac>>2]=q[Ac>>2]+zc,(0|vc)!=(0|wc););if(!(xc=xc-zc|0))break a;wc=q[Bc>>2]}L=256+yc|0}function Oa(a){return Ma(q[a>>2]+-1|0)||((a=Ma(q[a+4>>2]))?a+32|0:0)}function $c(a,ap){ap|=0,b[0]=a|=0,b[1]=ap}function cd(a,ap,bp){return function(a,ap,bp){var fp,dp,cp,ep,gp=w(dp=bp>>>16,cp=a>>>16);return a=(65535&(cp=((fp=w(ep=65535&bp,a&=65535))>>>16)+w(cp,ep)|0))+w(a,dp)|0,M=((gp+w(ap,bp)|0)+(cp>>>16)|0)+(a>>>16)|0,65535&fp|a<<16}(a,ap,bp)}function dd(a,ap,bp){return function(a,ap,bp){var op,np,hp=0,ip=0,jp=0,kp=0,lp=0,mp=0,pp=0;a:{b:{c:{d:{e:{if(!(ip=ap))return $c((ap=a)-w(a=(a>>>0)/(bp>>>0)|0,bp)|0,0),M=0,a;if(hp=bp){if(!((kp=hp+-1|0)&hp))break e;lp=0-(kp=(z(hp)+33|0)-z(ip)|0)|0;break c}if(!a)return $c(0,ip-w(a=(ip>>>0)/0|0,0)|0),M=0,a;if((hp=32-z(ip)|0)>>>0<31)break d;break b}if($c(a&kp,0),1==(0|hp))break a;return bp=31&(hp=hp?31-z(hp+-1^hp)|0:32),a=32<=(63&hp)>>>0?(ip=0,ap>>>bp):(ip=ap>>>bp,((1<>>bp),M=ip,a}kp=hp+1|0,lp=63-hp|0}if(hp=ap,jp=31&(ip=63&kp),jp=32<=ip>>>0?(ip=0,hp>>>jp):(ip=hp>>>jp,((1<>>jp),hp=31&(lp&=63),32<=lp>>>0?(ap=a<>>32-hp|ap<>>0<4294967295&&(hp=0);jp=(np=mp=jp<<1|ap>>>31)-(op=bp&(mp=hp-((ip=ip<<1|jp>>>31)+(lp>>>0>>0)|0)>>31))|0,ip=ip-(np>>>0>>0)|0,ap=ap<<1|a>>>31,a=pp|a<<1,pp=mp&=1,kp=kp+-1|0;);return $c(jp,ip),M=ap<<1|a>>>31,mp|a<<1}$c(a,ap),ap=a=0}return M=ap,a}(a,ap,bp)}function fd(a){var qp;return(-1>>>(qp=31&a)&-2)<>>a}function N(){return buffer.byteLength/65536|0}}(H,I,J)}}l=null,b.wasmBinary&&(F=b.wasmBinary);var WebAssembly={},F=[];"object"!=typeof WebAssembly&&E("no native wasm support detected");var I,J=new function(a){var c=Array(16);return c.grow=function(){17<=c.length&&B("Unable to grow wasm table. Use a higher value for RESERVED_FUNCTION_POINTERS or set ALLOW_TABLE_GROWTH."),c.push(null)},c.set=function(a,e){c[a]=e},c.get=function(a){return c[a]},c},K=!1;function assert(a,c){a||B("Assertion failed: "+c)}var buffer,M,L,N,ha="undefined"!=typeof TextDecoder?new TextDecoder("utf8"):void 0;function ia(a,c,d){var e=c+d;for(d=c;a[d]&&!(e<=d);)++d;if(16>10,56320|1023&f)))):e+=String.fromCharCode(f)}return e}function ja(a,c){return a?ia(L,a,c):""}function ka(a){return 0>>16)*e+d*(c>>>16)<<16)|0}),Math.fround||(ra=new Float32Array(1),Math.fround=function(a){return ra[0]=a,ra[0]}),Math.clz32||(Math.clz32=function(a){var c=32,d=a>>16;return d&&(c-=16,a=d),(d=a>>8)&&(c-=8,a=d),(d=a>>4)&&(c-=4,a=d),(d=a>>2)&&(c-=2,a=d),a>>1?c-2:c-a}),Math.trunc||(Math.trunc=function(a){return a<0?Math.ceil(a):Math.floor(a)}),0),Q=null,U=null;function B(a){throw b.onAbort&&b.onAbort(a),D(a),E(a),K=!0,"abort("+a+"). Build with -s ASSERTIONS=1 for more info."}b.preloadedImages={},b.preloadedAudios={};var V="data:application/octet-stream;base64,";function W(a){return String.prototype.startsWith?a.startsWith(V):0===a.indexOf(V)}var X="_em_module.wasm";function ta(){try{if(F)return new Uint8Array(F);var a=z(X);if(a)return a;if(w)return w(X);throw"both async and sync fetching of the wasm failed"}catch(c){B(c)}}W(X)||(t=X,X=b.locateFile?b.locateFile(t,u):u+t),na.push({fa:function(){va()}});var wa=[null,[],[]],xa=!1;function C(a){for(var c=[],d=0;d>4,f=(15&f)<<4|g>>2,h=(3&g)<<6|m}while(c+=String.fromCharCode(e),64!==g&&(c+=String.fromCharCode(f)),64!==m&&(c+=String.fromCharCode(h)),d>16),la(I.buffer);var d=1;break a}catch(e){}d=void 0}return!!d},c:function(a,c,d,e){try{for(var f=0,g=0;g>2],h=N[c+(8*g+4)>>2],A=0;A>2]=f,0}catch(T){return"undefined"!=typeof FS&&T instanceof FS.ga||B(T),T.ha}},memory:I,table:J},u=function(){function a(a){b.asm=a.exports,P--,b.monitorRunDependencies&&b.monitorRunDependencies(P),0==P&&(null!==Q&&(clearInterval(Q),Q=null),U)&&(a=U,U=null,a())}function c(c){a(c.instance)}function d(a){(F||!p&&!q||"function"!=typeof fetch?new Promise(function(a){a(ta())}):fetch(X,{credentials:"same-origin"}).then(function(a){if(a.ok)return a.arrayBuffer();throw"failed to load wasm binary file at '"+X+"'"}).catch(ta)).then(function(){return{then:function(a){a({instance:new da})}}}).then(a,function(a){E("failed to asynchronously prepare wasm: "+a),B(a)})}var e={env:H,wasi_unstable:H};if(P++,b.monitorRunDependencies&&b.monitorRunDependencies(P),b.instantiateWasm)try{return b.instantiateWasm(e,a)}catch(f){return E("Module.instantiateWasm callback failed with error: "+f),!1}return F||"function"!=typeof WebAssembly.instantiateStreaming||W(X)||"function"!=typeof fetch?d(c):fetch(X,{credentials:"same-origin"}).then(function(a){return WebAssembly.instantiateStreaming(a,e).then(c,function(a){E("wasm streaming compile failed: "+a),E("falling back to ArrayBuffer instantiation"),d(c)})}),{}}(),va=(b.asm=u,b.___wasm_call_ctors=function(){return b.asm.d.apply(null,arguments)}),Aa=(b._csmGetLogFunction=function(){return b.asm.e.apply(null,arguments)},b._csmGetVersion=function(){return b.asm.f.apply(null,arguments)},b._csmGetLatestMocVersion=function(){return b.asm.g.apply(null,arguments)},b._csmGetMocVersion=function(){return b.asm.h.apply(null,arguments)},b._csmHasMocConsistency=function(){return b.asm.i.apply(null,arguments)},b._csmSetLogFunction=function(){return b.asm.j.apply(null,arguments)},b._csmReviveMocInPlace=function(){return b.asm.k.apply(null,arguments)},b._csmReadCanvasInfo=function(){return b.asm.l.apply(null,arguments)},b._csmGetSizeofModel=function(){return b.asm.m.apply(null,arguments)},b._csmInitializeModelInPlace=function(){return b.asm.n.apply(null,arguments)},b._csmUpdateModel=function(){return b.asm.o.apply(null,arguments)},b._csmGetParameterCount=function(){return b.asm.p.apply(null,arguments)},b._csmGetParameterIds=function(){return b.asm.q.apply(null,arguments)},b._csmGetParameterTypes=function(){return b.asm.r.apply(null,arguments)},b._csmGetParameterMinimumValues=function(){return b.asm.s.apply(null,arguments)},b._csmGetParameterMaximumValues=function(){return b.asm.t.apply(null,arguments)},b._csmGetParameterDefaultValues=function(){return b.asm.u.apply(null,arguments)},b._csmGetParameterValues=function(){return b.asm.v.apply(null,arguments)},b._csmGetParameterRepeats=function(){return b.asm.w.apply(null,arguments)},b._csmGetPartCount=function(){return b.asm.x.apply(null,arguments)},b._csmGetPartIds=function(){return b.asm.y.apply(null,arguments)},b._csmGetPartOpacities=function(){return b.asm.z.apply(null,arguments)},b._csmGetPartParentPartIndices=function(){return b.asm.A.apply(null,arguments)},b._csmGetDrawableCount=function(){return b.asm.B.apply(null,arguments)},b._csmGetDrawableIds=function(){return b.asm.C.apply(null,arguments)},b._csmGetDrawableConstantFlags=function(){return b.asm.D.apply(null,arguments)},b._csmGetDrawableDynamicFlags=function(){return b.asm.E.apply(null,arguments)},b._csmGetDrawableTextureIndices=function(){return b.asm.F.apply(null,arguments)},b._csmGetDrawableDrawOrders=function(){return b.asm.G.apply(null,arguments)},b._csmGetDrawableRenderOrders=function(){return b.asm.H.apply(null,arguments)},b._csmGetDrawableOpacities=function(){return b.asm.I.apply(null,arguments)},b._csmGetDrawableMaskCounts=function(){return b.asm.J.apply(null,arguments)},b._csmGetDrawableMasks=function(){return b.asm.K.apply(null,arguments)},b._csmGetDrawableVertexCounts=function(){return b.asm.L.apply(null,arguments)},b._csmGetDrawableVertexPositions=function(){return b.asm.M.apply(null,arguments)},b._csmGetDrawableVertexUvs=function(){return b.asm.N.apply(null,arguments)},b._csmGetDrawableIndexCounts=function(){return b.asm.O.apply(null,arguments)},b._csmGetDrawableIndices=function(){return b.asm.P.apply(null,arguments)},b._csmGetDrawableMultiplyColors=function(){return b.asm.Q.apply(null,arguments)},b._csmGetDrawableScreenColors=function(){return b.asm.R.apply(null,arguments)},b._csmGetDrawableParentPartIndices=function(){return b.asm.S.apply(null,arguments)},b._csmResetDrawableDynamicFlags=function(){return b.asm.T.apply(null,arguments)},b._csmGetParameterKeyCounts=function(){return b.asm.U.apply(null,arguments)},b._csmGetParameterKeyValues=function(){return b.asm.V.apply(null,arguments)},b._csmMallocMoc=function(){return b.asm.W.apply(null,arguments)},b._csmMallocModelAndInitialize=function(){return b.asm.X.apply(null,arguments)},b._csmMalloc=function(){return b.asm.Y.apply(null,arguments)},b._csmFree=function(){return b.asm.Z.apply(null,arguments)},b._csmInitializeAmountOfMemory=function(){return b.asm._.apply(null,arguments)},b.stackSave=function(){return b.asm.$.apply(null,arguments)}),Ba=b.stackAlloc=function(){return b.asm.aa.apply(null,arguments)},Ca=b.stackRestore=function(){return b.asm.ba.apply(null,arguments)},ca=b.__growWasmMemory=function(){return b.asm.ca.apply(null,arguments)};function Z(){function a(){if(!Y&&(Y=!0,!K)){if(O(na),O(oa),b.onRuntimeInitialized&&b.onRuntimeInitialized(),b.postRun)for("function"==typeof b.postRun&&(b.postRun=[b.postRun]);b.postRun.length;){var a=b.postRun.shift();pa.unshift(a)}O(pa)}}if(!(0>6}else{if(k<=65535){if(d<=e+2)break;f[e++]=224|k>>12}else{if(d<=e+3)break;f[e++]=240|k>>18,f[e++]=128|k>>12&63}f[e++]=128|k>>6&63}f[e++]=128|63&k}}f[e]=0}}return c},array:function(a){var c=Ba(a.length);return M.set(a,c),c}},g=function(a){var c=b["_"+a];return assert(c,"Cannot call unknown function "+a+", make sure it is exported"),c}(a),m=[];if(a=0,e)for(var h=0;h+useAvatarMotion 묶음만 로 교체한다. - AffectParams(persona.ts) → .riv state-machine input 으로 wiring. + Live2DAvatar 가 Cubism model parameter 로 wiring 한다. 모델/코어가 없거나 + 로딩 실패하면 SVG 폴백이 그대로 남는다. ===================================================================== */ -import { useEffect, useMemo, useState } from "react"; +import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from "react"; import { ageLookFor, baseResistanceOf, @@ -41,6 +41,10 @@ import { Mouth } from "./Mouth"; Session.tsx 등이 ClientAvatar 모듈에서 타입을 가져갈 수 있으므로 유지. */ export type { AvatarState, AvatarAffect, AvatarPersona } from "./persona"; +const Live2DAvatar = lazy(() => + import("./Live2DAvatar").then((mod) => ({ default: mod.Live2DAvatar })), +); + export interface ClientAvatarProps { persona: AvatarPersona; state: AvatarState; @@ -53,7 +57,7 @@ export interface ClientAvatarProps { */ rapport?: number; /** - * analyser 없을 때 가짜 립싱크용 타이핑 진행도 0~1. + * analyser 없을 때 선택적으로 전달하는 타이핑 진행도 0~1. * null/미지정이면 speaking 동안 차분한 의사 발화 모션. */ speakingProgress?: number | null; @@ -89,6 +93,29 @@ const STATE_TEXT: Record = { speaking: "이야기하는 중", }; +const DISABLED_LIVE2D_MODEL_URLS = new Set(["off", "false", "none"]); +const BUNDLED_DEMO_LIVE2D_MODEL_PATHS = new Set([ + "/live2d/mao/Mao.model3.json", + "/live2d/haru/haru_greeter_t03.model3.json", +]); + +function isBundledDemoLive2DModel(url: string): boolean { + try { + return BUNDLED_DEMO_LIVE2D_MODEL_PATHS.has( + new URL(url, "http://vignette.local").pathname, + ); + } catch { + return false; + } +} + +function resolveLive2DModelUrl(persona: AvatarPersona): string | null { + const url = persona.live2dModelUrl?.trim(); + if (!url || DISABLED_LIVE2D_MODEL_URLS.has(url.toLowerCase())) return null; + if (import.meta.env.PROD && isBundledDemoLive2DModel(url)) return null; + return url; +} + export function ClientAvatar({ persona, state, @@ -115,6 +142,29 @@ export function ClientAvatar({ () => resolveAffectParams(affect, state, effectiveRapport), [affect, state, effectiveRapport], ); + const live2DModelUrl = useMemo(() => resolveLive2DModelUrl(persona), [persona]); + const [live2DReady, setLive2DReady] = useState(false); + const [live2DFailed, setLive2DFailed] = useState(false); + const [live2DError, setLive2DError] = useState(null); + + useEffect(() => { + setLive2DReady(false); + setLive2DFailed(false); + setLive2DError(null); + }, [live2DModelUrl]); + + const handleLive2DReady = useCallback(() => { + setLive2DReady(true); + setLive2DError(null); + }, []); + + const handleLive2DUnavailable = useCallback((reason: string) => { + setLive2DReady(false); + setLive2DFailed(true); + setLive2DError(reason); + }, []); + + const shouldUseLive2D = Boolean(live2DModelUrl) && !live2DFailed; // 모션 루프 (reduced 면 정지) const frame = useAvatarMotion({ @@ -148,6 +198,8 @@ export function ClientAvatar({ style={{ width: size }} data-state={state} data-affect={affect} + data-live2d={live2DReady ? "ready" : shouldUseLive2D ? "loading" : "off"} + data-live2d-error={live2DError ?? undefined} data-realism={realism} /* 사실성은 데이터로만 유지(시각 표식 비노출, §4.6) */ aria-label={`교육용 가상 내담자: ${persona.label}, ${STATE_TEXT[state]}`} > @@ -171,7 +223,7 @@ export function ClientAvatar({ /> + + {shouldUseLive2D && live2DModelUrl ? ( + + + + ) : null} {/* 페르소나 메타 + 상태 텍스트 */} @@ -237,7 +306,10 @@ const AVATAR_CSS = ` } .vg-avatar__aura.is-reduced{animation:none;} @keyframes vgAuraBreathe{0%,100%{opacity:.85;transform:scale(1)}50%{opacity:1;transform:scale(1.04)}} -.vg-avatar__svg{position:relative;z-index:1;display:block;} +.vg-avatar__svg{position:relative;z-index:1;display:block;transition:opacity var(--dur-base) var(--ease-out);} +.vg-avatar__svg.is-live2d-covered{opacity:0;} +.vg-avatar__live2d{position:absolute;inset:0;z-index:2;display:flex;align-items:center;justify-content:center;pointer-events:none;} +.vg-avatar__live2d-canvas{width:100%;height:100%;display:block;} .vg-avatar__meta{display:flex;flex-direction:column;align-items:center;gap:3px;text-align:center;} .vg-avatar__persona{font-size:var(--fs-sm);font-weight:600;color:var(--text-strong);} .vg-avatar__state{font-size:var(--fs-xs);color:var(--text-muted);} diff --git a/apps/web/src/components/avatar/Live2DAvatar.tsx b/apps/web/src/components/avatar/Live2DAvatar.tsx new file mode 100644 index 0000000..5d49003 --- /dev/null +++ b/apps/web/src/components/avatar/Live2DAvatar.tsx @@ -0,0 +1,383 @@ +import { useEffect, useRef } from "react"; +import type { AffectParams, AvatarAffect, AvatarState } from "./persona"; + +type PixiNamespace = typeof import("pixi.js"); +type PixiApplication = import("pixi.js").Application; +type Live2DModule = typeof import("pixi-live2d-display/cubism4"); +type Live2DModelCtor = Live2DModule["Live2DModel"]; +type Live2DModelInstance = import("pixi-live2d-display/cubism4").Live2DModel; + +interface Live2DAvatarProps { + modelUrl: string; + state: AvatarState; + affect: AvatarAffect; + params: AffectParams; + analyser: AnalyserNode | null; + speakingProgress: number | null; + reduced: boolean; + size: number; + onReady: () => void; + onUnavailable: (reason: string) => void; +} + +type MutableLive2DModel = Live2DModelInstance & { + internalModel?: { + coreModel?: { + setParameterValueById?: (id: string, value: number, weight?: number) => void; + addParameterValueById?: (id: string, value: number, weight?: number) => void; + }; + }; +}; + +type Live2DRefState = Pick< + Live2DAvatarProps, + "state" | "affect" | "params" | "analyser" | "speakingProgress" | "reduced" | "size" +>; + +declare global { + interface Window { + PIXI?: PixiNamespace; + Live2DCubismCore?: unknown; + } +} + +const DEFAULT_CUBISM_CORE = "/live2d/live2dcubismcore.min.js"; + +const CORE_SCRIPT = + import.meta.env.VITE_LIVE2D_CUBISM_CORE?.trim() || DEFAULT_CUBISM_CORE; + +let pixiRegistered = false; +let cubismCorePromise: Promise | null = null; +let runtimePromise: + | Promise<{ PIXI: PixiNamespace; Live2DModel: Live2DModelCtor; MotionPreloadStrategy: Live2DModule["MotionPreloadStrategy"] }> + | null = null; + +function registerPixi(PIXI: PixiNamespace, Live2DModel: Live2DModelCtor) { + if (pixiRegistered) return; + window.PIXI = PIXI; + Live2DModel.registerTicker(PIXI.Ticker); + pixiRegistered = true; +} + +function loadLive2DRuntime() { + if (!runtimePromise) { + runtimePromise = Promise.all([ + import("pixi.js"), + import("pixi-live2d-display/cubism4"), + ]).then(([PIXI, live2d]) => { + registerPixi(PIXI, live2d.Live2DModel); + return { + PIXI, + Live2DModel: live2d.Live2DModel, + MotionPreloadStrategy: live2d.MotionPreloadStrategy, + }; + }); + } + return runtimePromise; +} + +function hasCubismCore(): boolean { + return typeof window !== "undefined" && Boolean(window.Live2DCubismCore); +} + +function appendScript(src: string): Promise { + if (hasCubismCore()) return Promise.resolve(); + + const existing = Array.from(document.scripts).find( + (script) => script.dataset.vgLive2dCore === src, + ); + if (existing) { + return new Promise((resolve, reject) => { + existing.addEventListener("load", () => resolve(), { once: true }); + existing.addEventListener("error", () => reject(new Error(`Cubism Core load failed: ${src}`)), { + once: true, + }); + }); + } + + return new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = src; + script.async = true; + script.dataset.vgLive2dCore = src; + script.onload = () => resolve(); + script.onerror = () => reject(new Error(`Cubism Core load failed: ${src}`)); + document.head.appendChild(script); + }); +} + +function ensureCubismCore(): Promise { + if (hasCubismCore()) return Promise.resolve(); + if (!cubismCorePromise) { + cubismCorePromise = (async () => { + await appendScript(CORE_SCRIPT); + if (!hasCubismCore()) throw new Error("Cubism Core did not initialize."); + })().catch((err) => { + cubismCorePromise = null; + throw err; + }); + } + return cubismCorePromise; +} + +function fitModel(model: Live2DModelInstance, size: number) { + model.anchor.set(0.5, 0.5); + model.scale.set(1); + + const bounds = model.getLocalBounds(); + const boundsWidth = Math.max(1, bounds.width); + const boundsHeight = Math.max(1, bounds.height); + const scale = Math.min((size * 0.86) / boundsWidth, (size * 0.98) / boundsHeight); + + model.scale.set(scale); + model.x = size / 2; + model.y = size * 0.58; +} + +function fallbackMouthTarget(tSec: number, progress: number | null): number { + if (progress !== null && progress >= 1) return 0; + const syl = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 5.5); + const jitter = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 11 + 1.3); + return Math.min(0.85, 0.18 + 0.42 * syl * jitter); +} + +function mouthFromAnalyser( + analyser: AnalyserNode | null, + buffer: Float32Array, + previous: number, + dtSec: number, + tSec: number, + progress: number | null, +): number { + let target = fallbackMouthTarget(tSec, progress); + if (analyser) { + analyser.getFloatTimeDomainData(buffer); + let sum = 0; + for (let i = 0; i < buffer.length; i++) sum += buffer[i] * buffer[i]; + const rms = Math.sqrt(sum / buffer.length); + target = rms < 0.04 ? 0 : Math.min(1, rms * 3.2); + } + const tau = 0.16; + const alpha = 1 - Math.exp(-dtSec / tau); + return previous + (target - previous) * alpha; +} + +function setParam(model: MutableLive2DModel, id: string, value: number, weight = 0.85) { + try { + model.internalModel?.coreModel?.setParameterValueById?.(id, value, weight); + } catch { + /* Some models omit optional standard parameters. */ + } +} + +function applyLive2DParams( + model: MutableLive2DModel, + snapshot: Live2DRefState, + mouth: number, + tSec: number, +) { + const { state, affect, params, reduced } = snapshot; + if (reduced) return; + + const gazeBase = state === "listening" ? -params.gazeAvert * 0.15 : -params.gazeAvert * 0.22; + const thinkingDrift = state === "thinking" ? Math.sin(tSec * 0.9) * 0.08 : 0; + const speakEnergy = state === "speaking" ? mouth : 0; + const smile = Math.max(0, params.mouthCurve); + const downturn = Math.max(0, -params.mouthCurve); + + setParam(model, "ParamMouthOpenY", speakEnergy, 1); + setParam(model, "ParamA", speakEnergy, 1); + setParam(model, "ParamMouthForm", params.mouthCurve * 0.25, 0.35); + setParam(model, "ParamMouthUp", smile * 0.45, 0.25); + setParam(model, "ParamMouthDown", downturn * 0.45, 0.25); + setParam(model, "ParamMouthAngry", affect === "resistant" ? 0.55 : 0, 0.25); + setParam(model, "ParamEyeBallX", gazeBase + thinkingDrift, 0.5); + setParam(model, "ParamEyeBallY", state === "thinking" ? -0.12 : 0.03, 0.45); + setParam(model, "ParamAngleX", gazeBase * 14, 0.35); + setParam(model, "ParamAngleY", state === "thinking" ? -3 : 1, 0.3); + setParam(model, "ParamAngleZ", -params.shoulderTurn * 0.25, 0.25); + setParam(model, "ParamBodyAngleX", -params.shoulderTurn * 0.45, 0.35); + setParam(model, "ParamBreath", 0.5 + Math.sin(tSec * 2 * Math.PI / params.breathPeriod) * 0.22, 0.35); +} + +function focusPointFor(state: AvatarState, params: AffectParams, size: number): [number, number] { + if (state === "thinking") return [size * 0.42, size * 0.62]; + if (state === "listening") return [size * 0.56, size * 0.48]; + if (state === "speaking") return [size * 0.5, size * 0.46]; + return [size * (0.5 - params.gazeAvert * 0.008), size * 0.52]; +} + +function expressionsFor(affect: AvatarAffect): string[] { + if (affect === "depressed") return ["sad", "f02", "f03", "exp_05", "exp_06"]; + if (affect === "anxious") return ["surprised", "f03", "f04", "exp_03", "exp_04"]; + if (affect === "resistant") return ["angry", "f06", "f07", "exp_07", "exp_08"]; + return ["normal", "f00", "f01", "exp_01", "exp_02"]; +} + +async function tryMotion(model: Live2DModelInstance, groups: string[]) { + for (const group of groups) { + const ok = await model.motion(group).catch(() => false); + if (ok) return; + } +} + +async function tryExpression(model: Live2DModelInstance, names: string[]) { + for (const name of names) { + const ok = await model.expression(name).catch(() => false); + if (ok) return; + } +} + +export function Live2DAvatar({ + modelUrl, + state, + affect, + params, + analyser, + speakingProgress, + reduced, + size, + onReady, + onUnavailable, +}: Live2DAvatarProps) { + const hostRef = useRef(null); + const appRef = useRef(null); + const modelRef = useRef(null); + const snapshotRef = useRef({ + state, + affect, + params, + analyser, + speakingProgress, + reduced, + size, + }); + + useEffect(() => { + snapshotRef.current = { state, affect, params, analyser, speakingProgress, reduced, size }; + }, [state, affect, params, analyser, speakingProgress, reduced, size]); + + useEffect(() => { + const model = modelRef.current; + if (!model) return; + model.autoUpdate = !reduced; + }, [reduced]); + + useEffect(() => { + const model = modelRef.current; + if (!model || reduced) return; + const [x, y] = focusPointFor(state, params, size); + model.focus(x, y); + void tryExpression(model, expressionsFor(affect)); + if (state === "speaking") void tryMotion(model, ["Speak", "Speaking", "TapBody"]); + else if (state === "thinking") void tryMotion(model, ["Think", "Thinking"]); + else if (state === "idle") void tryMotion(model, ["Idle", "idle"]); + }, [affect, params, reduced, size, state]); + + useEffect(() => { + const app = appRef.current; + const model = modelRef.current; + if (!app || !model) return; + app.renderer.resize(size, size); + fitModel(model, size); + }, [size]); + + useEffect(() => { + const host = hostRef.current; + if (!host) return; + + let cancelled = false; + let mouth = 0; + let lastT = performance.now(); + const t0 = lastT; + const audioBuffer: Float32Array = new Float32Array(1024); + + const tick = () => { + const model = modelRef.current; + if (!model) return; + const now = performance.now(); + const dtSec = Math.min(0.05, (now - lastT) / 1000); + const tSec = (now - t0) / 1000; + lastT = now; + const snapshot = snapshotRef.current; + if (snapshot.state === "speaking") { + mouth = mouthFromAnalyser( + snapshot.analyser, + audioBuffer, + mouth, + dtSec, + tSec, + snapshot.speakingProgress, + ); + } else if (mouth > 0.001) { + mouth = mouth * Math.exp(-dtSec / 0.1); + if (mouth < 0.005) mouth = 0; + } + applyLive2DParams(model, snapshot, mouth, tSec); + }; + + (async () => { + try { + await ensureCubismCore(); + const runtime = await loadLive2DRuntime(); + if (cancelled) return; + + const app = new runtime.PIXI.Application({ + width: size, + height: size, + antialias: true, + autoDensity: true, + backgroundAlpha: 0, + resolution: Math.min(window.devicePixelRatio || 1, 2), + }); + appRef.current = app; + + const canvas = app.view as HTMLCanvasElement; + canvas.className = "vg-avatar__live2d-canvas"; + canvas.setAttribute("aria-hidden", "true"); + host.appendChild(canvas); + + const model = (await runtime.Live2DModel.from(modelUrl, { + autoInteract: false, + autoUpdate: !snapshotRef.current.reduced, + motionPreload: runtime.MotionPreloadStrategy.IDLE, + })) as MutableLive2DModel; + if (cancelled) { + model.destroy({ children: true, texture: true, baseTexture: true }); + app.destroy(true, { children: true, texture: true, baseTexture: true }); + return; + } + + modelRef.current = model; + fitModel(model, size); + app.stage.addChild(model); + app.ticker.add(tick); + void tryMotion(model, ["Idle", "idle"]); + onReady(); + } catch (err) { + if (!cancelled) { + const reason = err instanceof Error ? err.message : "Live2D model load failed."; + onUnavailable(reason); + } + } + })(); + + return () => { + cancelled = true; + const app = appRef.current; + const model = modelRef.current; + if (app) app.ticker.remove(tick); + modelRef.current = null; + appRef.current = null; + if (model && !model.destroyed) { + model.destroy({ children: true, texture: true, baseTexture: true }); + } + if (app) { + app.destroy(true, { children: true, texture: true, baseTexture: true }); + } + host.replaceChildren(); + }; + }, [modelUrl, onReady, onUnavailable, size]); + + return