# KOISCORE Portal Game Integration Reference

이 문서는 KOISCORE 포털과 게임 사이의 **유일한 웹 통합 계약**이다. 게임은 포털과
같은 저장소나 서버에 있을 필요가 없다. 별도 Git 저장소, 별도 EC2 인스턴스,
별도 서브도메인, 다른 프레임워크로 만든 외부 게임도 이 계약만 구현하면 된다.

| 항목 | 현재 값 |
| --- | --- |
| 프로토콜 | `koiscore.portal.v1` |
| 운영 포털 origin | `https://koiscore.com` |
| 공개 게임 카탈로그 | `GET https://koiscore.com/api/public/games` |
| 공개 레퍼런스 | `GET https://koiscore.com/api-reference/portal-game-api.md` |
| 프레임 통신 | `window.postMessage` |
| 중앙 대시보드 API | `GET /api/admin/health`, `/status`, `/metrics` |
| 데스크톱 기준 해상도 | `1920 × 1080` (`16:9`) |
| 모바일 기준 해상도 | `1080 × 1920` (`9:16`) |
| 최종 갱신 | 2026-07-27 |

일반 웹 포털과 WebView용 KOISCORE 플레이어에 공통으로 적용한다. 모든 채널은 Host
Shell 안의 Player Frame에 같은 Game Surface를 넣는다. Apps in Toss와 Google Play는
별도 게임 런타임이 아니라 인증·광고 adapter가 다른 WebView host다. 신규 등록은
`docs/platform/game-runtime-host-policy-v2.md`를 함께 따른다.

## 1. 핵심 원칙

문서에서 **필수**는 구현하지 않으면 등록할 수 없는 요구사항이고, **권장**은
호환성과 운영 안정성을 위한 기본값이다.

1. 포털은 게임 카탈로그, iframe, 공통 타이틀바, 즐겨찾기, 설치, 광고와
   **포털 마스터 음소거**를 소유한다.
2. 게임은 보드, 규칙, 스테이지, 결과, 게임 내부 옵션과
   **게임 자체 음악·효과음 설정**을 소유한다.
3. 포털 음소거는 게임 옵션을 수정하지 않는 상위 강제 차단이다.
4. 외부 게임은 `embed=koiscore-portal`에서 중복 사이트 UI와 광고를 숨긴다.
5. 모든 메시지는 `origin`, `source`, `protocol`, `gameId`를 검증한다.
6. iframe 로드 순서와 무관하게 초기 상태를 받도록 게임은 `game:ready`를 보낸다.

### 오디오 상태의 정식 계산식

```text
effectiveMuted = portalMuted OR gameMuted
```

- `portalMuted=true`: 게임의 BGM과 효과음을 모두 즉시 차단한다.
- `portalMuted=false`: 포털 차단만 해제한다. 게임 자체 옵션이 꺼져 있으면 계속
  소리가 나지 않는다.
- 포털 토글은 게임의 `musicEnabled`, `effectsEnabled`, `volume`,
  `fruitMergeMuted` 같은 설정값이나 저장 키를 변경하면 안 된다.
- 포털 음소거 해제는 브라우저 정책을 우회해 `AudioContext`를 강제로 재생하는
  명령이 아니다. 소리를 허용할 뿐이며 실제 재생은 게임 옵션과 사용자 제스처
  정책을 따른다.

## 2. 기준 해상도와 반응형 계약

외부 게임의 UI·배경·캔버스 제작 기준은 다음과 같다.

| 모드 | 논리 기준 해상도 | 기준 비율 | 포털 전환 기준 | 필수 QA 브라우저 뷰포트 |
| --- | --- | --- | --- | --- |
| 데스크톱 | `1920 × 1080` | `16:9` | 포털 너비 `721px` 이상 | `1920 × 1080`, `1366 × 768` |
| 모바일 | `1080 × 1920` | `9:16` | 포털 너비 `720px` 이하 | `390 × 844`, `360 × 800` |

`1920 × 1080`과 `1080 × 1920`은 아트와 논리 좌표계의 **제작 기준**이다.
포털이 iframe에 항상 이 CSS 픽셀 크기를 보장한다는 뜻이 아니다. 포털 타이틀바,
광고, 브라우저 UI, 화면 비율에 따라 실제 iframe의 `clientWidth`와
`clientHeight`는 달라진다.

필수 구현 규칙:

1. URL의 `viewport=desktop|mobile`을 현재 포털 레이아웃의 권위값으로 사용한다.
   브라우저 user-agent만으로 모드를 추측하지 않는다.
2. iframe 문서의 `html`, `body`, 앱 root는 `width: 100%`,
   `height: 100%`, `margin: 0`, `overflow: hidden`으로 프레임을 채운다.
3. UI는 기준 해상도에서만 고정 좌표로 만들지 말고 실제 컨테이너 크기에 맞춰
   scale, letterbox 또는 재배치한다. 핵심 조작부를 crop하면 안 된다.
4. Canvas/WebGL 게임은 `ResizeObserver` 또는 동등한 방식으로 iframe 크기
   변경을 처리한다. backing store는 `devicePixelRatio`를 반영하되 게임의 논리
   좌표계와 CSS 표시 크기를 분리한다.
5. 모바일에서는 `100vh`보다 `100dvh` 또는 부모의 실제 측정 높이를 우선하고,
   iframe 내부에 별도 세로 스크롤을 만들지 않는다.
6. 데스크톱과 모바일 모두 포털 타이틀바를 게임 내부에서 다시 그리지 않는다.

권장 기본 CSS:

```css
html,
body,
#game-root {
  width: 100%;
  height: 100%;
  margin: 0;
  overflow: hidden;
}

#game-root {
  position: relative;
}

canvas {
  display: block;
  width: 100%;
  height: 100%;
  touch-action: none;
}
```

게임이 비율을 엄격히 유지해야 한다면 남는 영역에 letterbox를 사용한다. 포털
배경이 보이도록 투명하게 두지 말고 게임이 소유한 안전한 배경색이나 배경 이미지를
채운다.

## 3. 전체 연결 순서

```text
포털                         외부 게임 iframe
  │  URL: embed, viewport, portalMuted  │
  ├────────────────────────────────────>│
  │                                     │ 초기 portalMuted 적용
  │                                     │ 메시지 리스너 등록
  │             game:ready              │
  │<────────────────────────────────────┤
  │             portal:init             │
  ├────────────────────────────────────>│ locale/shell/audio 동기화
  │        game:shell, game:how-to       │
  │<────────────────────────────────────┤
  │             portal:audio            │
  ├────────────────────────────────────>│ 런타임 마스터 음소거 변경
```

`portal:init`은 iframe `load` 직후와 `game:ready` 수신 후 모두 올 수 있다.
게임의 처리는 반드시 멱등이어야 한다.

## 4. 신규 외부 게임 등록

신규 게임은 `koiscore.game-runtime.v2` 정책을 먼저 통과해야 한다. 기존
`koiscore.portal.v1` iframe 쿼리는 라이브 게임의 호환 adapter로 유지하며, 쿼리값만으로
승인된 host를 판별하지 않는다.

외부 게임 서버가 공개 카탈로그에 직접 쓰는 API는 없다. 다음 순서로 등록한다.

1. 외부 게임 프로젝트가 운영 URL과 이 문서의 iframe 계약을 구현한다.
2. 아래 등록 매니페스트를 포털 담당자에게 전달한다.
3. 포털 담당자가 `src/game-registry.js`에 등록한다.
4. 포털 배포 후 `/api/public/games`에서 결과를 확인한다.

### 전달 매니페스트

```json
{
  "id": "example-game",
  "title": {
    "ko": "예제 게임",
    "ja": "サンプルゲーム",
    "en": "Example Game"
  },
  "categories": ["board", "strategy"],
  "iconUrl": "https://example.koiscore.com/icon-512.png",
  "runtimeUrl": "https://example.koiscore.com/games/example/ko",
  "supportedLocales": ["ko", "ja", "en"],
  "localeAware": true,
  "desktopAspect": "16 / 9",
  "mobileAspect": "9 / 16",
  "frameProtocol": "koiscore.portal.v1",
  "shell": {
    "showFavorite": true,
    "showOpen": true,
    "showGameInfo": true
  },
  "ads": {
    "enabled": true,
    "placements": ["play-sidebar", "play-bottom", "play-mobile"]
  }
}
```

| 필드 | 필수 | 규칙 |
| --- | --- | --- |
| `id` | 예 | 변경하지 않는 고유 kebab-case ID |
| `title` | 예 | 지원 언어별 표시명 |
| `categories` | 예 | `board`, `strategy`, `puzzle`, `card`, `casual` 중 하나 이상 |
| `iconUrl` | 예 | 공개 HTTPS 정사각형 이미지 권장 |
| `runtimeUrl` | 예 | launch ticket을 검증하고 승인된 Player Frame에서만 여는 HTTPS 진입점 |
| `supportedLocales` | 예 | 실제 제공 언어 |
| `localeAware` | 예 | URL 첫 경로의 `ko`, `ja`, `en` 교체 가능 여부 |
| `desktopAspect` | 예 | 데스크톱 프레임 비율 |
| `mobileAspect` | 예 | 모바일 프레임 비율 |
| `frameProtocol` | 예 | 현재 `koiscore.portal.v1` |
| `shell` | 예 | 포털 공통 기능 노출 정책 |
| `ads` | 예 | 포털 광고 슬롯 정책 |

포털의 현재 등록 예:

```js
game({
  id: 'example-game',
  title: '예제 게임',
  categories: ['board', 'strategy'],
  image: 'https://example.koiscore.com/icon-512.png',
  url: 'https://example.koiscore.com/games/example/ko',
  desktopAspect: '16 / 9',
  mobileAspect: '9 / 16',
  localeAware: true,
})
```

## 5. iframe URL 계약

포털은 등록된 `runtimeUrl`에 다음 쿼리를 추가한다.

| 쿼리 | 값 | 필수 동작 |
| --- | --- | --- |
| `embed` | `koiscore-portal` | 포털 임베드 모드 활성화 |
| `viewport` | `desktop` 또는 `mobile` | 해당 레이아웃 사용 |
| `portalMuted` | `true` 또는 `false` | 첫 렌더 전에 마스터 음소거 적용 |

```js
const params = new URLSearchParams(location.search);
const embeddedByPortal = params.get('embed') === 'koiscore-portal';
const portalViewport = params.get('viewport');
const initialPortalMuted = params.get('portalMuted') === 'true';

document.documentElement.toggleAttribute('data-portal-embed', embeddedByPortal);
if (portalViewport === 'desktop' || portalViewport === 'mobile') {
  document.documentElement.dataset.portalViewport = portalViewport;
}
```

`portalMuted` 쿼리는 초기 부트스트랩 힌트다. 이후 수신한 `portal:init.audio.muted`와
`portal:audio.muted`가 최신 권위 상태다.

임베드 모드에서는 다음을 숨긴다.

- 자체 사이트 헤더와 푸터
- 포털과 중복되는 게임 제목·홈·즐겨찾기·설치 UI
- iframe 내부 광고
- 게임 외부 콘텐츠와 추천 목록

게임 보드, 게임 내부 설정, 스테이지 선택, 결과 화면은 유지한다. iframe 문서는
부모 크기를 `100% × 100%`로 채우고 불필요한 이중 스크롤을 만들지 않는다.

## 6. 메시지 공통 형식

모든 메시지는 아래 envelope를 사용한다.

```ts
type PortalEnvelope = {
  protocol: "koiscore.portal.v1";
  type: string;
  gameId: string;
};
```

| 방향 | `type` | 필수 | 용도 |
| --- | --- | --- | --- |
| 게임 → 포털 | `game:ready` | 예 | 리스너 준비 완료, 초기 상태 재요청 |
| 포털 → 게임 | `portal:init` | 예 | locale, shell, identity, 초기 오디오 전체 동기화 |
| 포털 → 게임 | `portal:audio` | 예 | 실행 중 마스터 음소거 변경 |
| 게임 → 포털 | `game:shell` | 권장 | 포털 타이틀 셸 장식 갱신 |
| 게임 → 포털 | `game:how-to` | 권장 | 현지화된 조작법과 규칙 |

### `game:ready`

메시지 리스너를 등록한 뒤 전송한다.

```json
{
  "protocol": "koiscore.portal.v1",
  "type": "game:ready",
  "gameId": "example-game"
}
```

### `portal:init`

```json
{
  "protocol": "koiscore.portal.v1",
  "type": "portal:init",
  "gameId": "example-game",
  "locale": "ko",
  "shell": {
    "showFavorite": true,
    "showOpen": true,
    "showGameInfo": true
  },
  "audio": {
    "muted": true
  },
  "identity": {
    "schemaVersion": "koiscore.game-identity.v2",
    "gameId": "example-game",
    "authenticated": true,
    "playerId": "ply_...",
    "displayName": "Koi Player",
    "avatarUrl": "https://...",
    "locale": "ko",
    "provider": "google",
    "assertion": {
      "format": "jwt",
      "token": "header.payload.signature",
      "expiresAt": "2026-08-04T00:01:00.000Z"
    }
  }
}
```

게임은 `audio.muted`를 포털 마스터 상태로 적용한다. 게임 자체 음소거 설정으로
저장하지 않는다. 게임 데이터는 해당 게임에만 안정적인 `identity.playerId`를 키로 저장한다.
서버 쓰기 요청은 assertion을 KOISCORE JWKS로 검증해야 한다. `profileId`, `guestId`, provider
원본 사용자 ID, 이메일, 세션 토큰은 게임 저장소에 복제하지 않는다. 기존 `player` v1은
라이브 게임의 점진적 마이그레이션 기간에만 병행된다.

로그인 브릿지 전체 계약과 JWT 검증 조건은 `docs/koiscore-game-identity-bridge-v2.md`를 따른다.
assertion 갱신이 필요하면 게임은 검증된 포털 origin으로 다음 메시지를 전송한다.

```json
{
  "protocol": "koiscore.portal.v1",
  "type": "game:identity-refresh",
  "gameId": "example-game"
}
```

### `portal:audio`

포털 타이틀바의 스피커 버튼이 바뀔 때 전송된다.

```json
{
  "protocol": "koiscore.portal.v1",
  "type": "portal:audio",
  "gameId": "example-game",
  "muted": false
}
```

### `game:shell`

```json
{
  "protocol": "koiscore.portal.v1",
  "type": "game:shell",
  "gameId": "example-game",
  "title": "예제 게임",
  "description": "짧은 게임 설명",
  "accent": "#123329",
  "bannerUrl": "https://example.koiscore.com/assets/banner.webp"
}
```

| 필드 | 제한 |
| --- | --- |
| `title` | 최대 80자 |
| `description` | 최대 180자 |
| `accent` | `#RRGGBB` |
| `bannerUrl` | 게임과 같은 origin의 URL만 허용 |

### `game:how-to`

```json
{
  "protocol": "koiscore.portal.v1",
  "type": "game:how-to",
  "gameId": "example-game",
  "howTo": {
    "title": "예제 게임 즐기기",
    "steps": [
      {
        "title": "말을 선택하세요",
        "description": "움직일 말을 클릭하거나 터치하세요."
      },
      {
        "title": "목표 칸을 고르세요",
        "description": "표시된 이동 가능 위치를 선택하세요."
      }
    ],
    "rules": {
      "title": "규칙",
      "description": "게임의 핵심 승리 조건을 설명합니다."
    }
  }
}
```

- 단계는 최대 3개다.
- 단계 제목은 80자, 단계 설명은 180자까지다.
- 규칙 제목은 40자, 본문은 500자까지다.
- 형식이 잘못되거나 메시지가 없으면 포털 기본 안내를 유지한다.

## 7. 마스터 음소거 구현

### Web Audio

게임 자체 볼륨 bus와 포털 master bus를 분리한다.

```text
music/effects nodes
  → gameVolumeGain       게임 옵션 소유
  → portalMasterGain     포털 상태 소유
  → audioContext.destination
```

```js
let portalMuted = initialPortalMuted;
let audioContext;
let gameVolumeGain;
let portalMasterGain;

function ensureAudioGraph() {
  if (audioContext) return;
  audioContext = new AudioContext();
  gameVolumeGain = audioContext.createGain();
  portalMasterGain = audioContext.createGain();
  gameVolumeGain.connect(portalMasterGain);
  portalMasterGain.connect(audioContext.destination);
  syncPortalGain();
}

function syncPortalGain() {
  if (!audioContext || !portalMasterGain) return;
  const now = audioContext.currentTime;
  portalMasterGain.gain.cancelScheduledValues(now);
  portalMasterGain.gain.setValueAtTime(portalMuted ? 0 : 1, now);
}

function setPortalMuted(nextMuted) {
  portalMuted = Boolean(nextMuted);
  syncPortalGain();
}
```

게임 노드를 `audioContext.destination`에 직접 연결하지 않는다. `portalMasterGain`
뒤로 우회하는 노드가 하나라도 있으면 포털 음소거 계약 위반이다.

### `<audio>`와 `<video>`

포털 음소거 전 상태를 저장해 해제 시 게임 상태를 복원한다.

```js
const mutedBeforePortal = new WeakMap();

function setMediaPortalMuted(media, muted) {
  if (muted) {
    if (!mutedBeforePortal.has(media)) {
      mutedBeforePortal.set(media, media.muted);
    }
    media.muted = true;
    return;
  }

  const previous = mutedBeforePortal.get(media);
  if (typeof previous === 'boolean') {
    media.muted = previous;
    mutedBeforePortal.delete(media);
  }
}

function applyPortalMuteToMedia(muted) {
  document.querySelectorAll('audio, video').forEach(media => {
    setMediaPortalMuted(media, muted);
  });
}
```

실행 중 추가되는 미디어가 있다면 `MutationObserver`로 같은 처리를 적용한다.

### 잘못된 구현

```js
// 금지: 포털 상태로 게임 옵션을 덮어쓴다.
localStorage.setItem('gameMuted', String(message.muted));
settings.musicEnabled = !message.muted;
settings.effectsEnabled = !message.muted;

// 금지: 포털 음소거 해제만으로 게임 자체 음소거를 해제한다.
if (!message.muted) game.setMuted(false);
```

## 8. 외부 게임용 최소 어댑터

아래 코드는 URL 초기값, 메시지 보안 검증, 준비 완료, 초기·런타임 음소거를 모두
포함한다. `applyPortalMuted` 내부를 게임 오디오 엔진에 연결한다.

```js
const PROTOCOL = 'koiscore.portal.v1';
const GAME_ID = 'example-game';
const params = new URLSearchParams(location.search);
const isPortalEmbed = params.get('embed') === 'koiscore-portal';

const allowedPortalOrigins = new Set([
  'https://koiscore.com',
  'http://localhost:5173',
  'http://127.0.0.1:5173',
]);

let portalMuted = params.get('portalMuted') === 'true';

function applyPortalMuted(nextMuted) {
  portalMuted = Boolean(nextMuted);
  syncPortalGain();
  applyPortalMuteToMedia(portalMuted);
  document.documentElement.dataset.portalMuted = String(portalMuted);
}

function sendToPortal(message, targetOrigin) {
  window.parent.postMessage({
    protocol: PROTOCOL,
    gameId: GAME_ID,
    ...message,
  }, targetOrigin);
}

window.addEventListener('message', event => {
  if (event.source !== window.parent) return;
  if (!allowedPortalOrigins.has(event.origin)) return;

  const message = event.data;
  if (!message || message.protocol !== PROTOCOL) return;
  if (message.gameId !== GAME_ID) return;

  if (message.type === 'portal:init') {
    applyPortalMuted(message.audio?.muted);
    window.KoiscorePlayer = message.player;
    window.dispatchEvent(new CustomEvent('koiscore:player-context', {
      detail: message.player,
    }));
    sendToPortal({
      type: 'game:how-to',
      howTo: getLocalizedHowTo(message.locale),
    }, event.origin);
  }

  if (message.type === 'portal:audio') {
    applyPortalMuted(message.muted);
  }
});

applyPortalMuted(portalMuted);

if (isPortalEmbed) {
  const referrerOrigin = document.referrer
    ? new URL(document.referrer).origin
    : null;
  if (referrerOrigin && allowedPortalOrigins.has(referrerOrigin)) {
    sendToPortal({ type: 'game:ready' }, referrerOrigin);
  }
}
```

운영 빌드에서는 개발 origin을 필요할 때만 포함한다. `targetOrigin="*"`는 사용하지
않는다.

## 9. 중앙 어드민 대시보드 API

포털 카탈로그 등록과 중앙 어드민 대시보드 등록은 별개다. 포털에 게임 카드가
보인다고 대시보드에 자동 등록되지 않는다. 대시보드에서 외부 게임의 상태를
확인하려면 외부 프로젝트가 아래 API를 구현하고 중앙
`externalServiceRegistry`에도 등록돼야 한다.

```text
GET /api/admin/health
GET /api/admin/status
GET /api/admin/metrics
```

세 API는 모두 서버 전용 Bearer token을 검증한다.

```http
Authorization: Bearer <KOISCORE_ADMIN_SERVICE_TOKEN>
Accept: application/json
```

- 외부 게임 서버와 KOISCORE 중앙 서버에 동일한
  `KOISCORE_ADMIN_SERVICE_TOKEN`을 설정한다.
- token이 없거나 다르면 `401` JSON을 반환한다.
- token은 `VITE_*`, `NEXT_PUBLIC_*`, `PUBLIC_*` 등 클라이언트 공개
  환경변수, HTML, JS bundle, 응답 또는 로그에 넣지 않는다.
- 모든 endpoint는 `Content-Type: application/json`을 반환한다.
- 중앙 서버의 요청 제한 시간은 `3.5초`다. 특히 health에서 무거운 DB 집계를
  실행하지 않는다.

### Health

프로세스가 요청을 처리할 수 있는지 빠르게 응답한다.

```json
{
  "ok": true,
  "service": "example-game",
  "instance": "example-game-prod",
  "version": "1.0.0",
  "timestamp": "2026-07-27T00:00:00.000Z"
}
```

정상은 `200`, 게임 운영이 불가능한 필수 의존성 장애는 `503`을 반환한다.
DB 장애가 게임 실행 자체를 막지 않는 구조라면 health는 계속 `200`이고
status에서 `degraded`를 보고하는 방식을 권장한다.

### Status

```json
{
  "status": "live",
  "uptimeSeconds": 12345,
  "database": "ok",
  "build": "2026.07.27.1"
}
```

DB 연결 문자열, 내부 IP, stack trace, token은 반환하지 않는다.

### Metrics

```json
{
  "activeUsers": 12,
  "gamesToday": 340,
  "errors24h": 2
}
```

실제 집계 근거가 없는 값을 만들지 않는다. DB 전체 scan 대신 미리 집계한 값이나
짧은 TTL cache를 사용하고 개인정보와 원시 사용자 데이터를 반환하지 않는다.
status 또는 metrics가 실패해도 게임 페이지와 health 호출은 독립적으로 동작해야
한다.

### 대시보드 등록 전달값

외부 프로젝트는 포털 등록 매니페스트와 함께 아래 값을 포털/중앙 담당자에게
전달한다.

```json
{
  "gameId": "example-game",
  "instance": "example-game-prod",
  "productionOrigin": "https://example.koiscore.com",
  "adminApiBaseUrl": "https://example.koiscore.com/api/admin",
  "publicMetadataUrl": "https://example.koiscore.com/api/public/game-metadata",
  "thumbnailUrl": "https://example.koiscore.com/og-image.png",
  "supportedLocales": ["ko", "ja", "en"]
}
```

중앙 등록이 완료되면 어드민 대시보드의 외부 서비스 영역에서 다음 정보를
확인할 수 있다.

- 등록 상태와 instance
- health 성공 여부와 응답 지연시간
- status 응답
- metrics 응답
- 인증, timeout 또는 외부 서버 오류

## 10. 외부 서버 요구사항

외부 게임 문서는 포털 iframe에서 열릴 수 있어야 한다.

```http
Content-Security-Policy: frame-ancestors https://koiscore.com
```

- HTTPS를 사용한다.
- `X-Frame-Options: DENY` 또는 `SAMEORIGIN`을 함께 보내지 않는다.
- iframe 문서 로드 자체에는 CORS가 필요하지 않다. 게임 API를 다른 origin에서
  호출할 때만 정확한 API CORS를 설정한다.
- 이미지, 폰트, 오디오와 동적 import 자산이 게임 origin에서 정상 응답해야 한다.
- 서비스워커는 자신의 origin과 scope만 제어해야 한다.
- 외부 게임 장애는 iframe에만 격리하고 포털 전체를 멈추게 하지 않는다.

## 11. 직접 접속과 앱인토스

Game Surface는 일반 브라우저의 최상위 문서로 노출하지 않는다. 포털/sandbox는 검증된
launch ticket, WebView는 플랫폼 bootstrap session으로 host를 판별한다. 아래 쿼리 검사는
기존 v1 게임의 재귀 방지용 호환 예시일 뿐 보안 검증 수단이 아니다.

```js
const params = new URLSearchParams(location.search);
const embeddedByPortal = params.get('embed') === 'koiscore-portal';
const isAppsInTossBuild = import.meta.env.MODE === 'toss';

if (!embeddedByPortal && !isAppsInTossBuild) {
  location.replace('https://koiscore.com/#play=예제%20게임');
}
```

| 환경 | 동작 |
| --- | --- |
| 웹 포털 iframe | 공통 셸 안에서 게임 콘텐츠만 렌더링 |
| 일반 웹 직접 접속 | canonical KOISCORE 포털 게임 페이지로 이동 |
| 앱인토스 `.ait` | Toss WebView 호스트 셸에서 동일한 Game Surface 렌더링 |
| Google Play WebView | Android 호스트 셸에서 동일한 Game Surface 렌더링 |

앱인토스 여부는 빌드 타깃, SDK 환경값 또는 플레이어의 `channel=appintoss` 권위값으로
판별한다. 경로 이름이나 user-agent만으로 추측하지 않는다. 앱인토스 채널에서는 포털용
AdSense/H5 슬롯을 숨기고 Toss 호스트가 Toss Ads를 관리한다. Google Play에서는 Android
호스트가 AdMob을 관리한다. 게임 표면은 provider 광고 DOM을 직접 만들지 않는다.

## 12. 보안 계약

### 게임 수신 측

- `event.source === window.parent`
- 허용된 정확한 `event.origin`
- `protocol === "koiscore.portal.v1"`
- `gameId === 자기 게임 ID`
- 알 수 없는 메시지와 필드는 무시

### 포털 수신 측

- `event.source === 등록된 iframe.contentWindow`
- `event.origin === 등록된 runtimeUrl.origin`
- 등록된 `protocol`과 `gameId`
- 문자열 길이 제한과 URL origin 검증

메시지에는 계약에 명시된 공개 `profileId` 외에 인증 토큰, provider 원본 ID, 이메일,
좌표, 내부 DB PK나 서버 비밀값을 넣지 않는다.

## 13. 승인 체크리스트

### 외부 게임

- [ ] 운영 HTTPS URL이 인증 없이 열린다.
- [ ] `embed=koiscore-portal`에서 중복 셸과 iframe 내부 광고가 사라진다.
- [ ] `viewport=desktop|mobile`에서 프레임을 채우고 이중 스크롤이 없다.
- [ ] 데스크톱 `1920 × 1080`, `1366 × 768`에서 핵심 UI가 잘리지 않는다.
- [ ] 모바일 `390 × 844`, `360 × 800`에서 핵심 UI가 잘리지 않는다.
- [ ] `portalMuted=true` 첫 렌더부터 BGM과 효과음이 나지 않는다.
- [ ] 포털 토글 중에도 게임 자체 음소거·볼륨 설정값이 변하지 않는다.
- [ ] 포털 음소거 해제 후 게임 자체 옵션대로 복귀한다.
- [ ] `game:ready`, `portal:init`, `portal:audio` 왕복이 확인된다.
- [ ] `portal:init.player`를 받고 토큰·provider 원본 ID가 없음을 확인한다.
- [ ] 언어별 `game:how-to`가 최대 3단계로 표시된다.
- [ ] 운영 origin, protocol, gameId, parent window를 검증한다.
- [ ] `health`, `status`, `metrics`가 Bearer token을 검증하고 JSON을 반환한다.
- [ ] 무인증·잘못된 token 요청이 `401` JSON을 반환한다.
- [ ] 중앙 대시보드 등록 전달값을 빠짐없이 제공했다.
- [ ] 앱인토스 빌드가 앱인토스용 공통 셸에서 타이틀과 공용 젬 잔액을 표시한다.
- [ ] 앱인토스 채널에서 웹 광고가 숨겨지고 Apps in Toss 광고만 사용된다.

### 포털

- [ ] 고유한 ID, 운영 URL, 아이콘, 언어, 비율을 레지스트리에 등록했다.
- [ ] `/api/public/games`에 동일 ID가 한 번만 나온다.
- [ ] 중앙 `externalServiceRegistry`에 외부 게임을 등록했다.
- [ ] 어드민 대시보드에서 health, latency, status, metrics를 확인했다.
- [ ] 데스크톱과 모바일 타이틀바 안에 스피커 토글이 표시된다.
- [ ] 게임 전환 후에도 포털 마스터 음소거 상태가 유지된다.
- [ ] iframe origin과 `contentWindow`가 일치하는 메시지만 적용한다.
- [ ] 포털 광고와 게임 광고가 중복되지 않는다.

### 권장 배포 순서

1. 외부 게임을 먼저 배포한다.
2. 운영 URL에
   `?embed=koiscore-portal&viewport=desktop&portalMuted=true`를 붙여 검증한다.
3. 포털 레지스트리에 등록하고 테스트한다.
4. 포털을 배포한 뒤 공개 카탈로그와 레퍼런스를 확인한다.
5. 실제 데스크톱·모바일에서 소리와 레이아웃을 최종 확인한다.

## 14. 문제 해결

| 증상 | 확인할 항목 |
| --- | --- |
| 게임 시작 순간 소리가 남 | URL의 `portalMuted`, 초기 적용 시점 |
| 토글해도 Web Audio가 남 | destination으로 직접 연결된 우회 노드 |
| 해제 후 게임 자체 음소거가 풀림 | 포털 상태를 게임 설정에 저장하는 코드 |
| 새 `<video>`만 소리가 남 | 동적 미디어 MutationObserver |
| `portal:init`을 못 받음 | 메시지 리스너 등록 후 `game:ready` 전송 여부 |
| 포털이 게임 메시지를 무시함 | iframe origin, contentWindow, gameId, protocol |
| iframe이 열리지 않음 | CSP `frame-ancestors`, X-Frame-Options, HTTPS |
| 모바일에 이중 스크롤 | iframe 내부 `100%` 크기와 overflow |
| 포털에는 있지만 대시보드에는 없음 | 중앙 `externalServiceRegistry` 등록 여부 |
| 대시보드가 `401` 표시 | 양쪽 서버의 `KOISCORE_ADMIN_SERVICE_TOKEN` 일치 여부 |
| 대시보드 timeout | Admin API가 `3.5초` 안에 응답하는지 확인 |

## 15. 버전 호환성

- v1에 선택 필드가 추가될 수 있으므로 알 수 없는 필드는 무시한다.
- 기존 필드의 의미나 필수 여부를 깨는 변경은
  `koiscore.portal.v2`처럼 새 프로토콜로 올린다.
- 포털 구현의 계약 생성 함수는 `src/game-frame-adapter.js`, 계약 테스트는
  `test/game-frame-adapter.test.js`에 있다.
- 이 문서는 일반 웹 빌드 때
  `dist/api-reference/portal-game-api.md`로 복사되어 공개된다.

## 16. 포털 광고

광고는 외부 iframe 내부가 아니라 포털 공통 셸에서 관리한다. 슬롯 환경변수가
없으면 빈 광고 영역도 표시하지 않는다.

- `VITE_ADSENSE_CLIENT`
- `VITE_ADSENSE_SLOT_PLAY_BOTTOM`
- `VITE_ADSENSE_SLOT_PLAY_MOBILE`
