<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <meta name="theme-color" content="#151515" />
  <meta name="description" content="DOTCONQUEST is a dot-and-line triangle and star territory strategy game for web, mobile, and PWA. Draw lines, reserve triangle land by securing two sides, reserve star land by securing four of five sides, and battle AI or friends." />
  <meta name="robots" content="index, follow" />
  <meta name="keywords" content="DOTCONQUEST, dot and line territory game, triangle territory game, star territory game, five-side territory game, line drawing strategy game, AI strategy game, mobile strategy game, 점과 선 전략게임, 삼각형 영토 게임, 별 영토 게임, 5변 영토 게임" />
  <meta property="og:type" content="website" />
  <meta property="og:title" content="DOTCONQUEST - Triangle and Star Territory Strategy Game" />
  <meta property="og:description" content="A new territory strategy game where one line can attack, defend, reserve triangle land, or set a trap against AI and friends." />
  <meta property="og:image" content="dotconquest-logo-vo30.svg" />
  <meta property="og:site_name" content="DOTCONQUEST" />
  <meta name="twitter:card" content="summary" />
  <meta name="twitter:title" content="DOTCONQUEST - Triangle and Star Territory Strategy" />
  <meta name="twitter:description" content="Draw lines, reserve triangles by securing two sides first, and play AI or friend matches on mobile." />
  <meta name="apple-mobile-web-app-capable" content="yes" />
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
  <meta name="apple-mobile-web-app-title" content="DOTCONQUEST" />
  <link rel="manifest" href="manifest.json" />
  <link rel="alternate" type="application/rss+xml" title="DOTCONQUEST RSS" href="https://dotconquest.com/rss.xml" />
  <link rel="icon" href="favicon.ico" sizes="any" />
  <link rel="icon" href="favicon-48.png" type="image/png" sizes="48x48" />
  <link rel="icon" href="dotconquest-logo-vo30.svg" type="image/svg+xml" />
  <link rel="apple-touch-icon" sizes="192x192" href="dotconquest-logo-vo30-192.png" />
  <meta name="msapplication-TileImage" content="dotconquest-logo-vo30-512.png" />
  <title>DOTCONQUEST - Dot and Line Triangle and Star Territory Strategy Game</title>
  <script src="cloudflare-config.js"></script>
  <script type="application/ld+json">
    {
      "@context": "https://schema.org",
      "@type": "VideoGame",
      "name": "DOTCONQUEST",
      "description": "A turn-based dot-and-line triangle territory strategy game where players draw one line per turn, reserve triangle land by securing two sides first, and outplay AI or friends with attack and defense.",
      "genre": ["Strategy", "Board game", "Territory game", "Puzzle"],
      "gamePlatform": ["Web browser", "Mobile browser", "Progressive Web App"],
      "applicationCategory": "Game",
      "operatingSystem": "Any",
      "playMode": ["SinglePlayer", "MultiPlayer"],
      "inLanguage": ["en", "ko"],
      "url": "https://dotconquest.com/",
      "sameAs": [
        "https://dotconquest.com/what-is-dotconquest.html",
        "https://dotconquest.com/how-to-play-dotconquest.html",
        "https://dotconquest.com/triangle-territory-game.html"
      ],
      "offers": {
        "@type": "Offer",
        "price": "0",
        "priceCurrency": "USD"
      }
    }
  </script>
  <style>
    :root {
      --bg: #f7f7f2;
      --ink: #151515;
      --muted: #6d6d68;
      --line: #d8d7ce;
      --canvas: #fffefb;
      --red: #e23a3a;
      --blue: #2069d8;
      --green: #188b63;
      --panel: rgba(255, 255, 255, 0.86);
      --shadow: 0 18px 45px rgba(20, 20, 18, 0.12);
    }

    body.night {
      --bg: #121314;
      --ink: #f4f1e8;
      --muted: #aaa59a;
      --line: #343536;
      --canvas: #1a1b1c;
      --panel: rgba(29, 30, 31, 0.88);
      --shadow: 0 18px 45px rgba(0, 0, 0, 0.38);
    }

    * { box-sizing: border-box; }

    body {
      margin: 0;
      min-height: 100vh;
      min-height: 100dvh;
      background:
        linear-gradient(90deg, rgba(0,0,0,0.035) 1px, transparent 1px),
        linear-gradient(rgba(0,0,0,0.035) 1px, transparent 1px),
        var(--bg);
      background-size: 34px 34px;
      color: var(--ink);
      font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      overflow: hidden;
      overscroll-behavior: none;
      -webkit-tap-highlight-color: transparent;
      user-select: none;
    }

    button, select, input {
      font: inherit;
    }

    button, select {
      max-width: 100%;
    }

    .app {
      display: grid;
      grid-template-columns: minmax(0, 1fr) 340px;
      height: 100vh;
      height: 100dvh;
      min-height: 0;
    }

    .stage {
      position: relative;
      min-width: 0;
      min-height: 0;
      padding: 22px;
    }

    canvas {
      width: 100%;
      height: 100%;
      display: block;
      background: var(--canvas);
      border: 1px solid var(--line);
      box-shadow: var(--shadow);
      cursor: crosshair;
      touch-action: none;
    }

    .brand {
      position: absolute;
      left: 42px;
      top: 34px;
      pointer-events: none;
      display: flex;
      gap: 12px;
      align-items: center;
    }

    .mark {
      width: 38px;
      height: 38px;
      display: grid;
      place-items: center;
      overflow: hidden;
    }

    .mark img {
      width: 100%;
      height: 100%;
      display: block;
    }

    h1 {
      margin: 0;
      font-size: 28px;
      line-height: 1;
      letter-spacing: 0;
    }

    .tagline {
      margin-top: 5px;
      color: var(--muted);
      font-size: 13px;
      font-weight: 600;
      line-height: 1.35;
      white-space: pre-line;
    }

    aside {
      height: 100vh;
      height: 100dvh;
      min-height: 0;
      padding: 22px 22px 22px 0;
      display: flex;
      flex-direction: column;
      gap: 14px;
    }

    .sideShell {
      height: 100%;
      min-height: 0;
      display: flex;
      flex-direction: column;
      gap: 14px;
      padding: 12px;
      background: rgba(255,255,255,0.48);
      border: 1px solid rgba(0,0,0,0.08);
      box-shadow: var(--shadow);
      backdrop-filter: blur(18px);
      overflow-x: hidden;
      overflow-y: auto;
      overscroll-behavior: contain;
      scrollbar-width: thin;
      scrollbar-color: rgba(21,21,21,0.42) rgba(255,255,255,0.42);
      -webkit-overflow-scrolling: touch;
      background-image:
        linear-gradient(#fbfaf6, #fbfaf6),
        linear-gradient(#fbfaf6, #fbfaf6),
        linear-gradient(rgba(0,0,0,0.14), transparent),
        linear-gradient(transparent, rgba(0,0,0,0.12));
      background-position: top, bottom, top, bottom;
      background-repeat: no-repeat;
      background-size: 100% 18px, 100% 18px, 100% 10px, 100% 10px;
      background-attachment: local, local, scroll, scroll;
    }

    .sideShell::-webkit-scrollbar {
      width: 10px;
    }

    .sideShell::-webkit-scrollbar-track {
      background: rgba(255,255,255,0.5);
    }

    .sideShell::-webkit-scrollbar-thumb {
      background: rgba(21,21,21,0.34);
      border: 3px solid rgba(255,255,255,0.72);
    }

    .panel {
      background: var(--panel);
      border: 1px solid rgba(0,0,0,0.08);
      box-shadow: 0 10px 26px rgba(20, 20, 18, 0.08);
      backdrop-filter: blur(18px);
      padding: 16px;
    }

    .panelTitle {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 10px;
      margin-bottom: 12px;
      font-size: 12px;
      color: var(--muted);
      font-weight: 900;
      text-transform: uppercase;
      letter-spacing: 0;
    }

    .menuBadge {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      min-width: 28px;
      height: 24px;
      padding: 0 8px;
      background: var(--ink);
      color: white;
      font-size: 11px;
      font-weight: 900;
    }

    .topbar {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 10px;
      flex-wrap: wrap;
    }

    .modeHelp {
      display: grid;
      gap: 10px;
      padding: 13px 14px;
    }

    .modeHelp.hidden {
      display: none;
    }

    .modeHelpTitle {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 10px;
      font-size: 12px;
      color: var(--muted);
      font-weight: 900;
      text-transform: uppercase;
    }

    .modeHelp ol {
      margin: 0;
      padding-left: 20px;
      display: grid;
      gap: 6px;
      color: var(--ink);
      font-size: 13px;
      line-height: 1.4;
      font-weight: 700;
    }

    .modeHelp li {
      padding-left: 2px;
      overflow-wrap: anywhere;
      word-break: keep-all;
    }

    .topActions {
      display: flex;
      align-items: center;
      gap: 8px;
    }

    .iconButton {
      width: 36px;
      min-height: 36px;
      padding: 0;
      display: grid;
      place-items: center;
      background: white;
      color: var(--ink);
      border-color: var(--line);
      font-size: 18px;
    }

    body.night .iconButton,
    body.night .select,
    body.night .score,
    body.night .manualCard,
    body.night .segmented button,
    body.night .skinButton,
    body.night .boardGraphic,
    body.night .timerBox {
      background: #232426;
      color: var(--ink);
    }

    body.night .sideShell {
      background: rgba(24, 25, 26, 0.72);
      border-color: rgba(255,255,255,0.12);
      background-image:
        linear-gradient(#171819, #171819),
        linear-gradient(#171819, #171819),
        linear-gradient(rgba(255,255,255,0.12), transparent),
        linear-gradient(transparent, rgba(255,255,255,0.1));
      scrollbar-color: rgba(244,241,232,0.46) rgba(35,36,38,0.7);
    }

    body.night .panel,
    body.night .modal {
      border-color: rgba(255,255,255,0.12);
      box-shadow: 0 18px 45px rgba(0,0,0,0.44);
    }

    body.night .modeHelp ol {
      color: #f4f1e8;
    }

    body.night .menuBadge,
    body.night .pill.dark,
    body.night .levelBox {
      background: #f4f1e8;
      color: #121314;
      border-color: #f4f1e8;
    }

    body.night button {
      background: #f4f1e8;
      color: #121314;
      border-color: #f4f1e8;
    }

    body.night button.secondary,
    body.night .rps button,
    body.night .resultScore div {
      background: #232426;
      color: #f4f1e8;
      border-color: #4a4b4d;
    }

    body.night .segmented button.active,
    body.night .skinButton.active {
      background: #f4f1e8;
      color: #121314;
      border-color: #f4f1e8;
      box-shadow: inset 0 0 0 2px #f4f1e8;
    }

    body.night .overlay {
      background: rgba(18, 19, 20, 0.74);
    }

    body.night .modal {
      background: #1a1b1c;
      color: #f4f1e8;
    }

    body.night .modal p,
    body.night .startBrand span,
    body.night .resultScore strong,
    body.night .score strong,
    body.night .score small,
    body.night .panelTitle,
    body.night .tagline {
      color: #c9c3b7;
    }

    .select {
      border: 1px solid var(--line);
      background: white;
      height: 36px;
      padding: 0 10px;
      color: var(--ink);
      min-width: 98px;
    }

    .turn {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 12px;
      min-height: 64px;
    }

    .turnName {
      font-size: 24px;
      font-weight: 850;
      overflow-wrap: anywhere;
      word-break: keep-all;
    }

    .timerBox {
      width: 52px;
      height: 52px;
      display: grid;
      place-items: center;
      border: 3px solid var(--ink);
      background: white;
      font-size: 22px;
      line-height: 1;
      font-weight: 950;
    }

    .pill {
      min-width: 84px;
      height: 34px;
      display: inline-flex;
      align-items: center;
      justify-content: center;
      color: white;
      font-weight: 850;
    }

    .pill.red { background: var(--red); }
    .pill.blue { background: var(--blue); }
    .pill.dark { background: var(--ink); }

    .scoreGrid {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 10px;
    }

    .score {
      border: 1px solid var(--line);
      background: white;
      padding: 12px;
      min-height: 98px;
    }

    .score.roleRed {
      border-color: rgba(226,58,58,0.62);
      box-shadow: inset 0 3px 0 var(--red);
    }

    .score.roleBlue {
      border-color: rgba(32,105,216,0.62);
      box-shadow: inset 0 3px 0 var(--blue);
    }

    .score strong {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      font-size: 13px;
      color: var(--muted);
      margin-bottom: 8px;
      min-height: 28px;
      padding: 0 10px;
      border-radius: 999px;
      border: 1px solid transparent;
    }

    .score strong.roleRed,
    .resultScore strong.roleRed {
      background: var(--red);
      color: #fff;
      border-color: var(--red);
    }

    .score strong.roleBlue,
    .resultScore strong.roleBlue {
      background: var(--blue);
      color: #fff;
      border-color: var(--blue);
    }

    #winnerTitle.roleRed { color: var(--red); }
    #winnerTitle.roleBlue { color: var(--blue); }

    .scoreValue {
      font-size: 31px;
      line-height: 1;
      font-weight: 900;
    }

    .score small {
      display: block;
      margin-top: 8px;
      color: var(--muted);
      font-weight: 650;
    }

    .controls {
      display: grid;
      gap: 12px;
    }

    .manual {
      display: grid;
      gap: 10px;
    }

    .boardGraphic {
      position: relative;
      height: 118px;
      overflow: hidden;
      background:
        linear-gradient(135deg, rgba(226,58,58,0.13), transparent 48%),
        linear-gradient(315deg, rgba(32,105,216,0.14), transparent 52%),
        #fffefb;
      border: 1px solid var(--line);
    }

    .boardGraphic svg {
      position: absolute;
      inset: 10px;
      width: calc(100% - 20px);
      height: calc(100% - 20px);
    }

    .starGuideGraphic {
      position: relative;
      height: 128px;
      overflow: hidden;
      background: #fffefb;
      border: 1px solid var(--line);
    }

    .starGuideGraphic svg {
      position: absolute;
      inset: 8px;
      width: calc(100% - 16px);
      height: calc(100% - 16px);
    }

    .manualCard {
      display: grid;
      gap: 10px;
      padding: 12px;
      background: #fffefb;
      border: 1px solid var(--line);
      width: 100%;
      min-width: 0;
      overflow: hidden;
    }

    .manualStep {
      display: grid;
      grid-template-columns: 28px 1fr;
      gap: 10px;
      align-items: start;
      color: var(--muted);
      font-size: 13px;
      line-height: 1.35;
      font-weight: 650;
      min-width: 0;
    }

    .manualStep b {
      display: grid;
      place-items: center;
      width: 28px;
      height: 28px;
      background: #fff;
      border: 1px solid var(--line);
      color: var(--ink);
      font-size: 12px;
    }

    .manualStep span {
      display: block;
      min-width: 0;
      max-width: 100%;
      overflow-wrap: anywhere;
      word-break: keep-all;
    }

    .field {
      display: grid;
      gap: 7px;
    }

    .segmented {
      display: grid;
      grid-template-columns: repeat(4, 1fr);
      gap: 6px;
    }

    .segmented.three {
      grid-template-columns: repeat(3, 1fr);
    }

    .segmented.two {
      grid-template-columns: repeat(2, 1fr);
    }

    .segmented.dotGrid {
      grid-template-columns: repeat(5, 1fr);
    }

    .levelControl {
      display: grid;
      grid-template-columns: 1fr 58px;
      gap: 10px;
      align-items: center;
    }

    .onlineBox {
      display: grid;
      gap: 8px;
      padding: 10px;
      border: 1px solid var(--line);
      background: rgba(255,255,255,0.62);
    }

    .onlineRow {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 8px;
    }

    .onlineInput {
      width: 100%;
      min-height: 38px;
      padding: 0 10px;
      border: 1px solid var(--line);
      background: #fff;
      color: var(--ink);
      font-weight: 850;
      text-transform: uppercase;
    }

    .roomCode {
      min-height: 34px;
      display: grid;
      place-items: center;
      border: 1px dashed var(--line);
      color: var(--muted);
      font-size: 12px;
      font-weight: 850;
      text-align: center;
    }

    body.night .onlineBox,
    body.night .onlineInput {
      background: #232426;
      color: #f4f1e8;
      border-color: #4a4b4d;
    }

    .levelControl input {
      width: 100%;
      accent-color: var(--ink);
    }

    .levelBox {
      height: 36px;
      display: grid;
      place-items: center;
      background: var(--ink);
      color: white;
      font-weight: 950;
    }

    .skinGrid {
      display: grid;
      grid-template-columns: repeat(2, minmax(0, 1fr));
      gap: 8px;
    }

    .skinButton {
      min-height: 54px;
      display: grid;
      grid-template-columns: 26px 1fr;
      gap: 8px;
      align-items: center;
      padding: 8px;
      background: white;
      color: var(--ink);
      border-color: var(--line);
      text-align: left;
      font-size: 12px;
      line-height: 1.15;
    }

    .skinButton.active {
      border-color: var(--ink);
      box-shadow: inset 0 0 0 2px var(--ink);
    }

    .skinButton.locked {
      color: var(--ink);
      background: white;
    }

    .skinIcon {
      width: 24px;
      height: 24px;
      display: grid;
      place-items: center;
    }

    .skinIcon::before {
      content: "";
      display: block;
      width: 15px;
      height: 15px;
      background: #151515;
      border: 2px solid #151515;
    }

    .skin-square::before { border-radius: 2px; }
    .skin-diamond::before { transform: rotate(45deg); }
    .skin-neon::before { border-radius: 50%; background: #32ffc8; box-shadow: 0 0 12px #32ffc8; }
    .skin-ink::before { border-radius: 44% 56% 48% 52%; background: #111; }
    .skin-star::before { clip-path: polygon(50% 0,61% 34%,98% 34%,68% 55%,79% 91%,50% 70%,21% 91%,32% 55%,2% 34%,39% 34%); }
    .skin-planet::before { border-radius: 50%; background: #2069d8; box-shadow: inset -5px -4px 0 rgba(0,0,0,0.24); }
    .skin-pixel::before { image-rendering: pixelated; border-radius: 0; box-shadow: 6px 0 0 #151515, 0 6px 0 #151515; width: 8px; height: 8px; }
    .skin-ring::before { border-radius: 50%; background: transparent; border-width: 4px; }
    .skin-hex::before { clip-path: polygon(25% 4%,75% 4%,100% 50%,75% 96%,25% 96%,0 50%); }

    .payButton {
      width: 100%;
      margin-top: 10px;
      background: #ffc439;
      color: #111;
      border-color: #d5a42d;
    }

    .microNote {
      color: var(--muted);
      font-size: 12px;
      line-height: 1.35;
      font-weight: 650;
    }

    .segmented button {
      min-height: 38px;
      padding: 0 6px;
      background: white;
      color: var(--ink);
      border-color: var(--line);
      font-size: 13px;
    }

    .segmented button.active {
      background: var(--ink);
      color: white;
      border-color: var(--ink);
    }

    label {
      display: flex;
      justify-content: space-between;
      gap: 10px;
      color: var(--muted);
      font-size: 13px;
      font-weight: 750;
      min-width: 0;
    }

    label span {
      min-width: 0;
      overflow-wrap: anywhere;
      word-break: keep-all;
    }

    input[type="range"] {
      width: 100%;
      accent-color: var(--ink);
    }

    .btnRow {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 10px;
    }

    button {
      border: 1px solid var(--ink);
      background: var(--ink);
      color: white;
      min-height: 42px;
      padding: 0 13px;
      font-weight: 850;
      cursor: pointer;
    }

    button.secondary {
      background: white;
      color: var(--ink);
      border-color: var(--line);
    }

    button:disabled {
      opacity: 0.55;
      cursor: default;
    }

    .log {
      flex: 0 0 auto;
      min-height: 0;
      max-height: 170px;
      overflow: auto;
      display: flex;
      flex-direction: column;
      gap: 7px;
      color: var(--muted);
      font-size: 13px;
      line-height: 1.35;
    }

    .logLine {
      padding-bottom: 7px;
      border-bottom: 1px solid rgba(0,0,0,0.07);
    }

    .rules {
      color: var(--muted);
      font-size: 13px;
      line-height: 1.45;
    }

    .adSpace {
      min-height: 72px;
      display: grid;
      place-items: center;
      border: 1px dashed var(--line);
      color: var(--muted);
      font-size: 12px;
      line-height: 1.35;
      text-align: center;
      background: rgba(255,255,255,0.62);
    }
    .ownerExcluded .adSpace {
      display: none !important;
    }

    .siteLinks {
      display: flex;
      flex-wrap: wrap;
      gap: 10px;
      font-size: 12px;
    }

    .siteLinks a {
      color: var(--muted);
      text-decoration: none;
      font-weight: 750;
    }

    .appFooter {
      display: grid;
      gap: 10px;
      padding: 12px;
      border: 1px solid var(--line);
      background: rgba(255,255,255,0.58);
      color: var(--muted);
      font-size: 12px;
      line-height: 1.35;
    }

    .copyright {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 10px;
      color: var(--ink);
      font-weight: 900;
    }

    .copyrightMark {
      display: inline-grid;
      place-items: center;
      width: 22px;
      height: 22px;
      margin-right: 6px;
      border: 1px solid var(--line);
      background: var(--ink);
      color: white;
      font-size: 12px;
      line-height: 1;
    }

    .copyrightIcon {
      width: 24px;
      height: 24px;
      display: inline-grid;
      place-items: center;
      overflow: hidden;
      flex: 0 0 auto;
    }

    .copyrightIcon img {
      width: 100%;
      height: 100%;
      display: block;
    }

    .footerActions {
      display: grid;
      grid-template-columns: repeat(2, minmax(0, 1fr));
      gap: 8px;
    }

    .footerMenu {
      display: grid;
      grid-template-columns: repeat(2, minmax(0, 1fr));
      gap: 8px;
    }

    .footerButton {
      min-height: 36px;
      padding: 0 10px;
      font-size: 12px;
    }

    .footerMenu button,
    .footerMenu a {
      min-height: 36px;
      padding: 0 10px;
      background: white;
      color: var(--ink);
      border-color: var(--line);
      font-size: 12px;
      display: inline-flex;
      align-items: center;
      justify-content: center;
      border: 1px solid var(--line);
      border-radius: 8px;
      font-weight: 800;
      text-decoration: none;
    }

    .contactForm {
      display: grid;
      gap: 8px;
      padding-top: 2px;
    }

    .contactTitle {
      color: var(--muted);
      font-size: 12px;
      font-weight: 850;
    }

    .contactForm input,
    .contactForm textarea {
      width: 100%;
      min-height: 38px;
      padding: 9px 10px;
      border: 1px solid var(--line);
      background: #fffefb;
      color: var(--ink);
      font: inherit;
      font-size: 13px;
      font-weight: 750;
      outline: none;
    }

    .contactForm textarea {
      min-height: 86px;
      resize: vertical;
      line-height: 1.35;
    }

    .contactForm input:focus,
    .contactForm textarea:focus {
      border-color: var(--ink);
      box-shadow: inset 0 0 0 1px var(--ink);
    }

    .contactForm button {
      min-height: 38px;
      padding: 0 12px;
      font-size: 12px;
    }

    .premiumBox {
      display: grid;
      gap: 12px;
    }

    .premiumLead,
    .premiumNote {
      margin: 0 !important;
    }

    .premiumPlanGrid {
      display: grid;
      gap: 8px;
    }

    .premiumPlan {
      display: grid;
      gap: 6px;
      padding: 12px;
      border: 1px solid var(--line);
      background: rgba(255,255,255,0.58);
    }

    .premiumPlan strong {
      display: flex;
      justify-content: space-between;
      gap: 10px;
      color: var(--ink);
      font-size: 14px;
      font-weight: 900;
    }

    .premiumPlan span {
      color: var(--muted);
      font-size: 12px;
      font-weight: 750;
      line-height: 1.35;
    }

    .premiumPlan button {
      min-height: 36px;
      font-size: 12px;
      opacity: 0.72;
      cursor: default;
    }

    body.night .footerMenu button,
    body.night .footerMenu a,
    body.night .contactForm input,
    body.night .contactForm textarea {
      background: #232426;
      color: #f4f1e8;
      border-color: #4a4b4d;
    }

    body.night .premiumPlan {
      background: #232426;
      border-color: #4a4b4d;
    }

    body.night .premiumPlan strong {
      color: #f4f1e8;
    }

    .blogTopicGrid {
      display: grid;
      gap: 8px;
      margin-top: 12px;
    }

    .blogTopicButton,
    .blogBackButton {
      width: 100%;
      min-height: 40px;
      padding: 9px 12px;
      background: #fff;
      color: var(--ink);
      border: 1px solid var(--line);
      border-radius: 8px;
      font-size: 12px;
      font-weight: 900;
      text-align: left;
    }

    .blogBackButton {
      width: auto;
      margin-bottom: 12px;
      text-align: center;
    }

    .blogArticle {
      max-height: min(60dvh, 560px);
      overflow: auto;
      padding-right: 4px;
    }

    .blogArticle h1 {
      font-size: 14pt;
      line-height: 1.35;
      margin: 0 0 10px;
    }

    .blogArticle .hook {
      font-size: 13pt;
      font-weight: 900;
      margin: 0 0 18px;
    }

    .blogArticle h2 {
      font-size: 12pt !important;
      line-height: 1.35;
      margin: 20px 0 8px;
    }

    .blogArticle p {
      font-size: 11pt;
      line-height: 1.72;
      margin: 0 0 14px;
    }

    body.night .blogTopicButton,
    body.night .blogBackButton {
      background: #232426;
      color: #f4f1e8;
      border-color: #4a4b4d;
    }

    body.night .appFooter {
      background: rgba(24,25,26,0.72);
      border-color: rgba(255,255,255,0.12);
    }

    body.night .copyrightMark {
      background: #f4f1e8;
      color: #121314;
      border-color: #f4f1e8;
    }

    body.night .skinIcon::before {
      background: #f4f1e8;
      border-color: #f4f1e8;
    }

    body.night .skin-neon::before {
      background: #32ffc8;
      border-color: #32ffc8;
    }

    body.night .skin-planet::before {
      background: #4a8dff;
      border-color: #4a8dff;
    }

    body.night .skin-ring::before {
      background: transparent;
    }

    .overlay {
      position: fixed;
      inset: 0;
      display: grid;
      place-items: center;
      background: rgba(247, 247, 242, 0.72);
      backdrop-filter: blur(12px);
      z-index: 20;
    }

    .modal {
      width: min(520px, calc(100vw - 28px));
      max-height: calc(100dvh - 28px);
      overflow: auto;
      overscroll-behavior: contain;
      background: #fffefb;
      border: 1px solid var(--line);
      box-shadow: var(--shadow);
      padding: 24px;
      -webkit-overflow-scrolling: touch;
    }

    .modal h2 {
      margin: 0 0 8px;
      font-size: 28px;
      letter-spacing: 0;
      overflow-wrap: anywhere;
      word-break: keep-all;
    }

    .startBrand {
      display: flex;
      align-items: center;
      gap: 14px;
      margin-bottom: 18px;
      padding-bottom: 18px;
      border-bottom: 1px solid var(--line);
    }

    .startBrand .mark {
      position: static;
      flex: 0 0 auto;
    }

    .startBrand strong {
      display: block;
      font-size: 28px;
      line-height: 1;
      font-weight: 950;
      overflow-wrap: anywhere;
    }

    .startBrand span {
      display: block;
      margin-top: 5px;
      color: var(--muted);
      font-size: 13px;
      font-weight: 750;
      line-height: 1.35;
      white-space: pre-line;
    }

    .modal p {
      margin: 0 0 18px;
      color: var(--muted);
      line-height: 1.45;
      font-weight: 600;
      overflow-wrap: anywhere;
      word-break: keep-all;
    }

    .rps {
      display: grid;
      grid-template-columns: repeat(3, 1fr);
      gap: 10px;
    }

    .rps button {
      min-height: 92px;
      display: grid;
      place-items: center;
      gap: 6px;
      background: white;
      color: var(--ink);
      border-color: var(--line);
      min-width: 0;
    }

    .rpsIcon {
      font-size: 30px;
      line-height: 1;
      font-weight: 900;
      color: inherit;
    }

    .result {
      margin-top: 16px;
      min-height: 22px;
      font-weight: 850;
      white-space: pre-line;
    }

    .resultScore {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 10px;
      margin: 18px 0;
    }

    .resultScore div {
      background: white;
      border: 1px solid var(--line);
      padding: 13px;
    }

    .resultScore strong {
      display: block;
      color: var(--muted);
      font-size: 12px;
      margin-bottom: 7px;
    }

    .resultScore span {
      display: block;
      font-size: 28px;
      line-height: 1;
      font-weight: 950;
    }

    .hidden { display: none; }

    @media (max-width: 900px) {
      html,
      body {
        height: 100%;
        min-height: 100%;
        overflow-x: hidden;
        overflow-y: hidden;
      }
      .app {
        grid-template-columns: 1fr;
        grid-template-rows: auto minmax(0, 1fr);
        height: 100vh;
        height: 100dvh;
        min-height: 0;
        overflow: hidden;
      }
      .stage {
        height: clamp(300px, 45dvh, 430px);
        min-height: 0;
        padding: max(12px, env(safe-area-inset-top)) 12px 12px;
      }
      .brand {
        left: 24px;
        top: 24px;
      }
      h1 { font-size: 22px; }
      aside {
        height: auto;
        min-height: 0;
        overflow: hidden;
        padding: 0 12px max(12px, env(safe-area-inset-bottom));
      }
      .sideShell {
        height: 100%;
        max-height: 100%;
        min-height: 0;
        overflow-x: hidden;
        overflow-y: auto;
        overscroll-behavior: contain;
        -webkit-overflow-scrolling: touch;
      }
      .scoreGrid {
        grid-template-columns: 1fr 1fr;
      }
      .panel {
        padding: 14px;
      }
      .rps button {
        min-height: 82px;
      }
    }

    @media (max-width: 520px) {
      .stage {
        height: clamp(280px, 42dvh, 390px);
        min-height: 0;
      }
      .sideShell {
        padding: 10px;
      }
      .log {
        max-height: 130px;
      }
      .manualCard {
        padding: 10px;
      }
      .manualStep {
        grid-template-columns: 24px 1fr;
        gap: 8px;
        font-size: 12px;
      }
      .manualStep b {
        width: 24px;
        height: 24px;
      }
      .brand {
        transform: scale(0.86);
        transform-origin: top left;
      }
      .scoreValue {
        font-size: 27px;
      }
      .btnRow {
        grid-template-columns: 1fr;
      }
      .segmented button {
        min-height: 42px;
      }
      .rps {
        grid-template-columns: 1fr;
      }
      .rps button {
        min-height: 62px;
        grid-template-columns: 38px 1fr;
        justify-items: start;
        padding: 10px 14px;
      }
      .topbar {
        align-items: stretch;
      }
      .topActions {
        width: 100%;
      }
      .select {
        flex: 1 1 auto;
      }
      .modal {
        width: calc(100vw - 32px);
        max-height: calc(100dvh - 18px);
        padding: 18px;
      }
      .startBrand {
        gap: 10px;
      }
      .startBrand strong {
        font-size: 24px;
      }
      .modal h2 {
        font-size: 26px;
      }
      .modal p {
        font-size: 15px;
        line-height: 1.5;
      }
      .modal p,
      .manualStep span,
      .skinButton,
      .siteLinks a {
        word-break: break-all;
        overflow-wrap: anywhere;
        white-space: normal;
      }
      .resultScore {
        grid-template-columns: 1fr;
      }
      .footerActions {
        grid-template-columns: 1fr;
      }
      .footerMenu {
        grid-template-columns: 1fr 1fr;
      }
      .appFooter {
        padding: 10px;
      }
    }

    @media (max-width: 380px) {
      .stage {
        height: clamp(250px, 39dvh, 340px);
      }
      .brand {
        max-width: calc(100% - 36px);
      }
      h1 {
        font-size: 20px;
      }
      .tagline,
      .startBrand span {
        font-size: 12px;
      }
      .scoreGrid,
      .skinGrid,
      .segmented.two,
      .segmented.three {
        grid-template-columns: 1fr;
      }
      .segmented.dotGrid {
        grid-template-columns: repeat(2, 1fr);
      }
    }
  </style>
  <meta name="google-adsense-account" content="ca-pub-6956999120497477">
  <script>
    (function () {
      const OWNER_KEY = 'dotconquest-admin-2026';
      const STORAGE_KEY = 'dotConquestOwnerExcluded';
      const params = new URLSearchParams(window.location.search);
      const storage = {
        get() {
          try { return localStorage.getItem(STORAGE_KEY); } catch (error) { return null; }
        },
        set() {
          try { localStorage.setItem(STORAGE_KEY, '1'); } catch (error) {}
        },
        remove() {
          try { localStorage.removeItem(STORAGE_KEY); } catch (error) {}
        }
      };
      if (params.get('ownerKey') === OWNER_KEY) {
        storage.set();
        params.delete('ownerKey');
        const cleanUrl = `${window.location.pathname}${params.toString() ? `?${params}` : ''}${window.location.hash}`;
        window.history.replaceState({}, document.title, cleanUrl);
      }
      if (params.get('owner') === 'off') {
        storage.remove();
        params.delete('owner');
        const cleanUrl = `${window.location.pathname}${params.toString() ? `?${params}` : ''}${window.location.hash}`;
        window.history.replaceState({}, document.title, cleanUrl);
      }
      const isOwnerExcluded = storage.get() === '1';
      window.DOTCONQUEST_OWNER_EXCLUDED = isOwnerExcluded;
      window.dotConquestTrack = function () {
        if (window.DOTCONQUEST_OWNER_EXCLUDED) return false;
        return true;
      };
      if (isOwnerExcluded) {
        document.documentElement.classList.add('ownerExcluded');
        return;
      }
      const ADSENSE_ENABLED = false;
      window.DOTCONQUEST_ADSENSE_ENABLED = ADSENSE_ENABLED;
      if (!ADSENSE_ENABLED) return;
      const script = document.createElement('script');
      script.async = true;
      script.crossOrigin = 'anonymous';
      script.src = 'https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-6956999120497477';
      document.head.appendChild(script);
    })();
  </script>
  <!-- Google tag (gtag.js) -->
  <script async src="https://www.googletagmanager.com/gtag/js?id=G-4VBNG7HNSM"></script>
  <script>
    window.dataLayer = window.dataLayer || [];
    function gtag(){
      if (window['ga-disable-G-4VBNG7HNSM']) return;
      dataLayer.push(arguments);
    }
    (function () {
      const OWNER_KEY = 'dotconquest-admin-2026';
      const STORAGE_KEY = 'dotConquestOwnerExcluded';
      const params = new URLSearchParams(window.location.search);
      const storage = {
        get() { try { return localStorage.getItem(STORAGE_KEY); } catch (error) { return null; } },
        set() { try { localStorage.setItem(STORAGE_KEY, '1'); } catch (error) {} },
        remove() { try { localStorage.removeItem(STORAGE_KEY); } catch (error) {} }
      };
      if (params.get('ownerKey') === OWNER_KEY) {
        storage.set();
        params.delete('ownerKey');
        const cleanUrl = `${window.location.pathname}${params.toString() ? `?${params}` : ''}${window.location.hash}`;
        window.history.replaceState({}, document.title, cleanUrl);
      }
      if (params.get('owner') === 'off') {
        storage.remove();
        params.delete('owner');
        const cleanUrl = `${window.location.pathname}${params.toString() ? `?${params}` : ''}${window.location.hash}`;
        window.history.replaceState({}, document.title, cleanUrl);
      }
      window['ga-disable-G-4VBNG7HNSM'] = window.DOTCONQUEST_OWNER_EXCLUDED === true || storage.get() === '1';
    })();
    gtag('js', new Date());
    gtag('config', 'G-4VBNG7HNSM');
  </script>
  <!-- Microsoft Clarity -->
  <script type="text/javascript">
    (function(c,l,a,r,i,t,y){
      const STORAGE_KEY = 'dotConquestOwnerExcluded';
      let ownerExcluded = c.DOTCONQUEST_OWNER_EXCLUDED === true;
      try { ownerExcluded = ownerExcluded || localStorage.getItem(STORAGE_KEY) === '1'; } catch (error) {}
      if (ownerExcluded) return;
      c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
      t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
      y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
    })(window, document, "clarity", "script", "wynsvyinjm");
  </script>
</head>
<body>
  <div class="app">
    <main class="stage">
      <canvas id="board" aria-label="DOTCONQUEST game board"></canvas>
      <div class="brand">
        <div class="mark"><img src="dotconquest-logo-vo30.svg" alt="DOTCONQUEST"></div>
        <div>
          <h1>DOTCONQUEST</h1>
          <div class="tagline" data-i18n="tagline">Draw a line. Start the strategy.
Secure two sides first to claim triangle territory.</div>
        </div>
      </div>
    </main>

    <aside>
      <div class="sideShell">
      <section class="panel topbar">
        <strong id="topModeTitle">AI Match</strong>
        <div class="topActions">
          <button class="iconButton" id="themeToggle" type="button" aria-label="Toggle day night">🌙</button>
          <select class="select" id="language" aria-label="Language selection">
            <option value="en">English</option>
            <option value="ko">한국어</option>
          </select>
        </div>
      </section>

      <section class="panel modeHelp hidden" id="friendModeHelp">
        <div class="modeHelpTitle"><span data-i18n="friendHowTitle">친구대전 사용법</span><span class="menuBadge">1-6</span></div>
        <ol>
          <li data-i18n="friendHow1">방을 만드는 사람이 먼저 친구대전을 누르고 방 만들기를 누릅니다. 랜덤 방 암호가 자동으로 생성됩니다.</li>
          <li data-i18n="friendHow2">생성된 방 암호를 가족이나 친구에게 그대로 보냅니다. 대소문자는 신경 쓰지 않아도 됩니다.</li>
          <li data-i18n="friendHow3">상대방은 같은 사이트에서 친구대전을 누르고 받은 방 암호를 입력한 뒤 입장을 누릅니다.</li>
          <li data-i18n="friendHow4">양쪽 화면에 게임 시작이 표시되면 각자 자기 기기에서 가위바위보를 선택합니다.</li>
          <li data-i18n="friendHow5">가위바위보에서 이긴 사람은 빨강이 되어 먼저 공격하고, 진 사람은 파랑이 됩니다.</li>
          <li data-i18n="friendHow6">닷 개수, AI 레벨 표시, 점선 표시, 닷 스킨은 새 대국 전 자유롭게 바꿀 수 있습니다. 대국 중에는 자기 차례에만 선을 긋고 게임판은 자동으로 반영됩니다.</li>
        </ol>
      </section>

      <section class="panel manual" id="triangleGuidePanel">
        <div class="panelTitle"><span data-i18n="howToPlay">사용 설명서</span><span class="menuBadge">1-3</span></div>
        <div class="boardGraphic" id="triangleGraphPanel" aria-hidden="true">
          <svg viewBox="0 0 260 110">
            <polygon points="24,86 116,18 228,84" fill="rgba(226,58,58,0.18)" stroke="#e23a3a" stroke-width="5" stroke-linejoin="round"/>
            <polygon points="70,88 116,18 228,84" fill="rgba(32,105,216,0.18)" stroke="#2069d8" stroke-width="5" stroke-linejoin="round"/>
            <line x1="24" y1="86" x2="228" y2="84" stroke="#151515" stroke-width="3" stroke-dasharray="6 7"/>
            <circle cx="24" cy="86" r="8" fill="#fffefb" stroke="#151515" stroke-width="4"/>
            <circle cx="116" cy="18" r="8" fill="#fffefb" stroke="#151515" stroke-width="4"/>
            <circle cx="228" cy="84" r="8" fill="#fffefb" stroke="#151515" stroke-width="4"/>
            <circle cx="70" cy="88" r="7" fill="#fffefb" stroke="#151515" stroke-width="3"/>
            <text x="130" y="64" text-anchor="middle" fill="#151515" font-size="14" font-weight="900">AREA + LAND</text>
          </svg>
        </div>
        <div class="manualCard">
          <div class="manualStep"><b>1</b><span data-i18n="manual1">연결된 점 두 개를 선택해 선을 차지하세요.</span></div>
          <div class="manualStep"><b>2</b><span data-i18n="manual2">삼각형의 두 변을 먼저 차지하면 그 땅이 예약됩니다.</span></div>
          <div class="manualStep"><b>3</b><span data-i18n="manual3">면적으로 승부하고, 면적이 같으면 땅 개수로 판정합니다.</span></div>
        </div>
      </section>

      <section class="panel manual hidden" id="starGuidePanel">
        <div class="panelTitle"><span data-i18n="starGuideTitle">별 모양 그래프 사용법</span><span class="menuBadge">5</span></div>
        <div class="starGuideGraphic" aria-hidden="true">
          <svg viewBox="0 0 260 120">
            <polygon points="130,12 212,50 190,106 60,106 38,50" fill="rgba(32,105,216,0.12)" stroke="#2069d8" stroke-width="5" stroke-linejoin="round"/>
            <line x1="130" y1="12" x2="212" y2="50" stroke="#e23a3a" stroke-width="7" stroke-linecap="round"/>
            <line x1="212" y1="50" x2="190" y2="106" stroke="#e23a3a" stroke-width="7" stroke-linecap="round"/>
            <line x1="190" y1="106" x2="60" y2="106" stroke="#e23a3a" stroke-width="7" stroke-linecap="round"/>
            <line x1="60" y1="106" x2="38" y2="50" stroke="#e23a3a" stroke-width="7" stroke-linecap="round"/>
            <line x1="38" y1="50" x2="130" y2="12" stroke="#151515" stroke-width="4" stroke-dasharray="7 7" stroke-linecap="round"/>
            <circle cx="130" cy="12" r="8" fill="#fffefb" stroke="#151515" stroke-width="4"/>
            <circle cx="212" cy="50" r="8" fill="#fffefb" stroke="#151515" stroke-width="4"/>
            <circle cx="190" cy="106" r="8" fill="#fffefb" stroke="#151515" stroke-width="4"/>
            <circle cx="60" cy="106" r="8" fill="#fffefb" stroke="#151515" stroke-width="4"/>
            <circle cx="38" cy="50" r="8" fill="#fffefb" stroke="#151515" stroke-width="4"/>
            <text x="130" y="70" text-anchor="middle" fill="#151515" font-size="14" font-weight="900">4 / 5</text>
          </svg>
        </div>
        <div class="manualCard">
          <div class="manualStep"><b>1</b><span data-i18n="starGuide1">별은 다섯 개의 점을 잇는 5변 영토입니다.</span></div>
          <div class="manualStep"><b>2</b><span data-i18n="starGuide2">다섯 변 중 네 변을 먼저 확보하면 별 영토가 예약됩니다.</span></div>
          <div class="manualStep"><b>3</b><span data-i18n="starGuide3">별 AI는 20~70개 닷과 1~5레벨까지 무료입니다.</span></div>
        </div>
      </section>

      <section class="panel turn">
        <div>
          <div class="turnName" id="turnName">가위바위보</div>
        </div>
        <div class="timerBox" id="timerBox">10</div>
      </section>

      <section class="scoreGrid">
        <div class="score" id="leftScoreCard">
          <strong id="leftScoreLabel" data-i18n="you">나</strong>
          <div class="scoreValue" id="redScore">0</div>
          <small id="redLand">0 tiles</small>
        </div>
        <div class="score" id="rightScoreCard">
          <strong id="rightScoreLabel" data-i18n="bot">AI 봇</strong>
          <div class="scoreValue" id="blueScore">0</div>
          <small id="blueLand">0 tiles</small>
        </div>
      </section>

      <section class="panel controls">
        <div class="panelTitle"><span data-i18n="matchSetup">대국 설정</span><span class="menuBadge" id="setupModeBadge">AI</span></div>
        <div class="field">
          <label><span data-i18n="gameMode">게임 모드</span><span id="modeLabel">AI</span></label>
          <div class="segmented two" id="modeChoices">
            <button type="button" data-mode="ai" data-i18n="aiMatch" class="active">AI 대전</button>
            <button type="button" data-mode="friend" data-i18n="friendMatch">친구대전</button>
          </div>
        </div>
        <div class="field">
          <label><span data-i18n="boardType">보드 규칙</span><span id="boardTypeLabel">삼각형</span></label>
          <div class="segmented two" id="boardTypeChoices">
            <button type="button" data-board-type="triangle" data-i18n="triangleMode" class="active">삼각형</button>
            <button type="button" data-board-type="star" data-i18n="starMode">별</button>
          </div>
        </div>
        <div class="field onlineBox" id="onlineBox">
          <label><span data-i18n="onlineMatch">온라인 친구대전</span><span id="onlineStatus" data-i18n="offlineReady">로컬 준비</span></label>
          <div class="onlineRow">
            <button type="button" id="createRoom" class="secondary" data-i18n="createRoom">방 만들기</button>
            <button type="button" id="joinRoom" class="secondary" data-i18n="joinRoom">입장</button>
          </div>
          <input id="roomInput" class="onlineInput" maxlength="8" autocomplete="off" inputmode="latin" placeholder="ROOM" aria-label="Room code" />
          <div class="roomCode" id="roomCodeLabel" data-i18n="roomHelp">방 코드를 가족이나 친구에게 보내세요.</div>
          <div class="roomCode hidden" id="friendRoleStatus"></div>
        </div>
        <div class="field">
          <label><span data-i18n="dots">점 개수</span><span id="dotCountLabel">30</span></label>
          <div class="segmented dotGrid" id="dotChoices">
            <button type="button" data-dots="10">10</button>
            <button type="button" data-dots="20">20</button>
            <button type="button" data-dots="30" class="active">30</button>
            <button type="button" data-dots="40">40</button>
            <button type="button" data-dots="50">50</button>
            <button type="button" data-dots="60">60</button>
            <button type="button" data-dots="70">70</button>
            <button type="button" data-dots="80">80</button>
            <button type="button" data-dots="90">90</button>
            <button type="button" data-dots="100">100</button>
          </div>
        </div>
        <div class="field">
          <label><span data-i18n="aiLevel">AI 레벨</span><span id="aiTierLabel">레벨 1</span></label>
          <div class="levelControl">
            <input id="aiLevel" type="range" min="1" max="100" value="1" />
            <div class="levelBox" id="aiLevelBox">1</div>
          </div>
        </div>
        <div class="field">
          <label><span data-i18n="guideMode">점선 표시</span><span id="guideModeLabel">초보</span></label>
          <div class="segmented three" id="guideChoices">
            <button type="button" data-guide="beginner" data-i18n="guideBeginner" class="active">초보</button>
            <button type="button" data-guide="normal" data-i18n="guideNormal">보통</button>
            <button type="button" data-guide="expert" data-i18n="guideExpert">고수</button>
          </div>
        </div>
        <div class="btnRow">
          <button id="newGame" data-i18n="newGame">새 대국</button>
          <button class="secondary" id="hint" data-i18n="hint">힌트</button>
        </div>
      </section>

      <section class="panel">
        <div class="panelTitle"><span data-i18n="skinShop">닷 스킨 상점</span><span class="menuBadge">10</span></div>
        <div class="skinGrid" id="skinChoices">
          <button type="button" class="skinButton active" data-skin="circle" data-free="true"><span class="skinIcon"></span><span data-i18n="skinCircle">기본 원형</span></button>
          <button type="button" class="skinButton" data-skin="square"><span class="skinIcon skin-square"></span><span data-i18n="skinSquare">작은 사각형</span></button>
          <button type="button" class="skinButton" data-skin="diamond"><span class="skinIcon skin-diamond"></span><span data-i18n="skinDiamond">다이아몬드</span></button>
          <button type="button" class="skinButton" data-skin="neon"><span class="skinIcon skin-neon"></span><span data-i18n="skinNeon">네온 점</span></button>
          <button type="button" class="skinButton" data-skin="ink"><span class="skinIcon skin-ink"></span><span data-i18n="skinInk">잉크 점</span></button>
          <button type="button" class="skinButton" data-skin="star"><span class="skinIcon skin-star"></span><span data-i18n="skinStar">별 점</span></button>
          <button type="button" class="skinButton" data-skin="planet"><span class="skinIcon skin-planet"></span><span data-i18n="skinPlanet">미니 행성</span></button>
          <button type="button" class="skinButton" data-skin="pixel"><span class="skinIcon skin-pixel"></span><span data-i18n="skinPixel">픽셀 점</span></button>
          <button type="button" class="skinButton" data-skin="ring"><span class="skinIcon skin-ring"></span><span data-i18n="skinRing">링 점</span></button>
          <button type="button" class="skinButton" data-skin="hex"><span class="skinIcon skin-hex"></span><span data-i18n="skinHex">육각형 점</span></button>
        </div>
      </section>

      <section class="panel rules hidden" id="rules">
        점과 점을 이어 선을 그으세요. 삼각형의 세 변 중 두 변을 먼저 차지하면, 마지막 선을 누가 긋든 그 삼각형은 당신의 땅이 됩니다.
      </section>

      <section class="adSpace hidden" aria-label="Advertisement space">
        <span data-i18n="adNote">Google AdSense 광고 영역입니다.</span>
      </section>

      <footer class="appFooter">
        <div class="copyright">
          <span><span class="copyrightMark" aria-hidden="true">&copy;</span>2026 DOTCONQUEST</span>
          <span class="copyrightIcon"><img src="dotconquest-logo-vo30.svg" alt="DOTCONQUEST"></span>
        </div>
        <div class="footerActions">
          <button class="footerButton" id="installApp" type="button" data-i18n="installApp">앱 설치</button>
          <button class="footerButton" id="updateApp" type="button" data-i18n="updateApp">업데이트</button>
        </div>
        <div class="footerMenu" aria-label="Footer menu">
          <button type="button" data-info-modal="about" data-i18n="about">소개</button>
          <button type="button" data-info-modal="guide" data-i18n="guide">가이드</button>
          <button type="button" data-info-modal="faq" data-i18n="faq">FAQ</button>
          <a href="/blog/today/" data-blog-link data-i18n="blog">블로그</a>
          <button type="button" data-info-modal="privacy" data-i18n="privacy">개인정보</button>
          <button type="button" data-info-modal="terms" data-i18n="terms">이용약관</button>
          <button type="button" data-info-modal="contact" data-i18n="contactTitle">문의하기</button>
          <a href="/blog/today/?view=archive&version=VO91" data-archive-link data-i18n="archive">보관</a>
        </div>
      </footer>

      <section class="panel log hidden" id="log" aria-live="polite"></section>
      </div>
    </aside>
  </div>

  <div class="overlay" id="rpsOverlay">
    <div class="modal">
      <div class="startBrand">
        <div class="mark"><img src="dotconquest-logo-vo30.svg" alt="DOTCONQUEST"></div>
        <div>
          <strong>DOTCONQUEST</strong>
          <span data-i18n="startBrand">선을 그으면 전략이 시작됩니다.
두 선을 선점해 삼각형을 차지하세요.</span>
        </div>
      </div>
      <h2 data-i18n="rpsTitle">공수 정하기</h2>
      <p data-i18n="rpsBody">AI와 가위바위보로 선공을 정합니다.</p>
      <div class="rps">
        <button data-throw="rock"><span class="rpsIcon">✊</span><span data-i18n="rock">바위</span></button>
        <button data-throw="paper"><span class="rpsIcon">✋</span><span data-i18n="paper">보</span></button>
        <button data-throw="scissors"><span class="rpsIcon">✌️</span><span data-i18n="scissors">가위</span></button>
      </div>
      <div class="result" id="rpsResult"></div>
    </div>
  </div>

  <div class="overlay hidden" id="gameOverOverlay">
    <div class="modal">
      <div class="startBrand">
        <div class="mark"><img src="dotconquest-logo-vo30.svg" alt="DOTCONQUEST"></div>
        <div>
          <strong>DOTCONQUEST</strong>
          <span data-i18n="finalResult">Final result</span>
        </div>
      </div>
      <h2 id="winnerTitle">You Win</h2>
      <p id="winnerBody">You conquered more land by area.</p>
      <div class="resultScore">
        <div>
          <strong id="finalLeftLabel" data-i18n="you">You</strong>
          <span id="finalHumanScore">0</span>
        </div>
        <div>
          <strong id="finalRightLabel" data-i18n="bot">AI Bot</strong>
          <span id="finalBotScore">0</span>
        </div>
      </div>
      <button id="playAgain" data-i18n="newGame">New Match</button>
    </div>
  </div>

  <div class="overlay hidden" id="infoOverlay">
    <div class="modal">
      <div class="startBrand">
        <div class="mark"><img src="dotconquest-logo-vo30.svg" alt="DOTCONQUEST"></div>
        <div>
          <strong id="infoTitle">DOTCONQUEST</strong>
          <span>DOTCONQUEST</span>
        </div>
      </div>
      <div id="infoBody"></div>
      <button id="closeInfo" type="button" data-i18n="close">닫기</button>
    </div>
  </div>

  <script>
    const canvas = document.getElementById('board');
    const ctx = canvas.getContext('2d');
    const logEl = document.getElementById('log');
    const langSelect = document.getElementById('language');
    const dotCountLabel = document.getElementById('dotCountLabel');
    const aiLevelInput = document.getElementById('aiLevel');
    const aiLevelBox = document.getElementById('aiLevelBox');
    const aiTierLabel = document.getElementById('aiTierLabel');
    const guideModeLabel = document.getElementById('guideModeLabel');
    const guideChoices = document.getElementById('guideChoices');
    const modeLabel = document.getElementById('modeLabel');
    const topModeTitle = document.getElementById('topModeTitle');
    const setupModeBadge = document.getElementById('setupModeBadge');
    const friendModeHelp = document.getElementById('friendModeHelp');
    const modeChoices = document.getElementById('modeChoices');
    const boardTypeChoices = document.getElementById('boardTypeChoices');
    const boardTypeLabel = document.getElementById('boardTypeLabel');
    const triangleGuidePanel = document.getElementById('triangleGuidePanel');
    const triangleGraphPanel = document.getElementById('triangleGraphPanel');
    const starGuidePanel = document.getElementById('starGuidePanel');
    const dotChoices = document.getElementById('dotChoices');
    const skinChoices = document.getElementById('skinChoices');
    const createRoomButton = document.getElementById('createRoom');
    const joinRoomButton = document.getElementById('joinRoom');
    const roomInput = document.getElementById('roomInput');
    const roomCodeLabel = document.getElementById('roomCodeLabel');
    const onlineStatus = document.getElementById('onlineStatus');
    const rpsOverlay = document.getElementById('rpsOverlay');
    const rpsResult = document.getElementById('rpsResult');
    const friendRoleStatus = document.getElementById('friendRoleStatus');
    const gameOverOverlay = document.getElementById('gameOverOverlay');
    const timerBox = document.getElementById('timerBox');
    const themeToggle = document.getElementById('themeToggle');
    const installAppButton = document.getElementById('installApp');
    const updateAppButton = document.getElementById('updateApp');
    const infoOverlay = document.getElementById('infoOverlay');
    const infoTitle = document.getElementById('infoTitle');
    const infoBody = document.getElementById('infoBody');
    const closeInfo = document.getElementById('closeInfo');
    const APP_VERSION = 'VO91-DOT-CONQUEST-ARCHIVE-DIRECT';
    const OWNER_EXCLUDED = window.DOTCONQUEST_OWNER_EXCLUDED === true;
    const FREE_DOT_COUNTS = [10, 20, 30, 40, 50];
    const FREE_MAX_LEVEL = 10;
    const STAR_DOT_COUNTS = [20, 30, 40, 50, 60, 70];
    const STAR_FREE_MAX_LEVEL = 5;
    const PREMIUM_PLANS = [
      { key: 'premiumAllPlan', price: '$4.60/mo', desc: 'premiumAllDesc' }
    ];
    let deferredInstallPrompt = null;
    let waitingServiceWorker = null;
    let serviceWorkerRegistration = null;
    let activeInfoModal = null;

    const i18n = {
      en: {
        tagline: 'Draw a line and the strategy begins.\nSecure two sides first to claim the triangle.',
        startBrand: 'Draw a line and the strategy begins.\nSecure two sides first to claim the triangle.',
        mode: 'Bot Match',
        you: 'You',
        bot: 'AI Bot',
        dots: 'Dots',
        difficulty: 'AI Difficulty',
        aiLevel: 'AI Level',
        level: 'Level',
        skinShop: 'Dot Skin Shop',
        skinCircle: 'Basic Circle',
        skinSquare: 'Small Square',
        skinDiamond: 'Diamond',
        skinNeon: 'Neon Dot',
        skinInk: 'Ink Dot',
        skinStar: 'Star Dot',
        skinPlanet: 'Mini Planet',
        skinPixel: 'Pixel Dot',
        skinRing: 'Ring Dot',
        skinHex: 'Hex Dot',
        guideMode: 'Guide Lines',
        guideBeginner: 'Beginner',
        guideNormal: 'Normal',
        guideExpert: 'Expert',
        gameMode: 'Game Mode',
        boardType: 'Board Rule',
        aiMatch: 'AI Match',
        friendMatch: 'Friend Match',
        triangleMode: 'Triangle',
        starMode: 'Star',
        triangleModeLabel: 'Triangle',
        starModeLabel: 'Star',
        starPreviewReason: 'Star Mode AI Match is free with 20-70 dots through Level 5.',
        starRangeReason: 'Star Mode starts at 20 dots and supports randomized 20-70-dot boards.',
        starPremiumReason: 'Star Friend Match and Levels 6-100 are Premium settings.',
        aiModeLabel: 'AI Match',
        friendModeLabel: 'Friend Match',
        onlineMatch: 'Online Friend Match',
        offlineReady: 'Local ready',
        onlineReady: 'Online ready',
        onlineTurn: 'Online turn',
        waitingFriend: 'Waiting for friend',
        createRoom: 'Create Room',
        joinRoom: 'Join',
        roomHelp: 'Create a random room code, then share it with family or a friend on a phone, tablet, or computer.',
        roomCreated: 'Room created. Share this random code.',
        roomJoined: 'Room joined.',
        gameStarted: 'Game started.',
        roomWaiting: 'Room created. Waiting for your friend to join.',
        roomMissing: 'Enter a room code.',
        friendHowTitle: 'Friend Match Guide',
        friendHow1: 'The room creator taps Friend Match, then taps Create Room. A random room code is generated automatically.',
        friendHow2: 'Send that room code to your family member or friend. Phones, tablets, and computers can join the same room.',
        friendHow3: 'The other player opens dotconquest.com on any device, taps Friend Match, enters the room code, then taps Join.',
        friendHow4: 'After the invited player presses Join, rock paper scissors opens on both screens.',
        friendHow5: 'Choose on each device. The winner always becomes red and attacks first. The loser always becomes blue.',
        friendHow6: 'For the most stable view, match the same device type: phone vs phone, tablet vs tablet, or desktop vs desktop. The Friend Match winner advances one level after each win.',
        onlineNeedsConfig: 'Online rooms need Cloudflare Pages Functions or a Worker route at /api/rooms. Local friend match is available now.',
        onlineSyncError: 'Online sync failed. Check the room code or network.',
        notYourTurn: 'Wait for your turn.',
        notYourTurnFriendRed: 'You are RED. Wait until the RED turn returns.',
        notYourTurnFriendBlue: 'You are BLUE and move second. RED draws first; then BLUE can draw a line.',
        matchSetup: 'Match Setup',
        howToPlay: 'How to Play',
        manual1: 'Choose two connected dots to claim a line.',
        manual2: 'Own two sides first to reserve that triangle.',
        manual3: 'Win by area. Land count breaks area ties.',
        starGuideTitle: 'Star Graph Guide',
        starGuide1: 'A star graph is a five-sided territory made from five connected dots.',
        starGuide2: 'Secure four of the five sides first to reserve the star territory.',
        starGuide3: 'Star AI is free with 20-70 dots through Level 5.',
        beginner: 'Beginner',
        intermediate: 'Intermediate',
        expert: 'Expert',
        newGame: 'New Match',
        hint: 'Hint',
        rpsTitle: 'Choose Attack Order',
        rpsBody: 'Play rock paper scissors against the bot. The winner takes the first turn as red.',
        friendRpsBody: 'When both players enter the room, choose rock paper scissors on each device. The winner becomes red, and the loser becomes blue.',
        friendRpsWaiting: 'Choice saved. Waiting for your friend.',
        friendRpsTie: 'Same choice. Choose again.',
        friendRpsWin: 'You won rock paper scissors. You are RED and attack first.',
        friendRpsLose: 'You lost rock paper scissors. You are BLUE and move second. RED attacks first.',
        friendRoleRed: 'My role: RED · First attack. Friend: BLUE · Second.',
        friendRoleBlue: 'My role: BLUE · Second. Friend: RED · First attack.',
        friendTurnRedFirst: 'You are RED. Your turn first.',
        friendTurnBlueSecond: 'You are BLUE. Friend attacks first.',
        friendRoleChoosing: 'Choose rock paper scissors. Winner becomes RED, loser becomes BLUE.',
        friendRpsFriendWin: 'Friend won rock paper scissors and starts as red.',
        friendRpsFriendLose: 'Friend lost rock paper scissors and starts as blue.',
        youRed: 'You (RED)',
        youBlue: 'You (BLUE)',
        friendRed: 'Friend (RED)',
        friendBlue: 'Friend (BLUE)',
        rock: 'Rock',
        paper: 'Paper',
        scissors: 'Scissors',
        rules: 'Draw lines between dots. Triangle Mode reserves land by securing two sides first. Star Mode reserves land by securing four of five sides first.',
        rps: 'Rock Paper Scissors',
        choose: 'Choose attack order.',
        friendChoose: 'Friend Match started. Red attacks first.',
        yourTurn: 'Your Turn',
        redTurn: 'Red turn',
        blueTurn: 'Blue turn',
        botTurn: 'AI Turn',
        gameOver: 'Game over',
        red: 'RED',
        blue: 'BLUE',
        tie: 'Tie. Choose again.',
        rpsTie: 'Draw.\nThe attack right has not been decided yet.',
        winRps: 'You are attacking.\nUse your first line to pressure the AI territory.',
        loseRps: 'You are defending.\nBlock the AI first move, then counterattack.',
        invalid: 'Pick an open line on the triangle grid.',
        claimed: 'claimed',
        byArea: 'area',
        tiles: 'tiles',
        normal: 'Normal',
        easy: 'Easy',
        hard: 'Hard',
        adNote: 'Advertisement area for Google AdSense.',
        ownerExcludedNote: 'Owner device mode is active. Ads and internal view tracking are excluded on this device.',
        about: 'About',
        guide: 'Guide',
        faq: 'FAQ',
        blog: 'Blog',
        privacy: 'Privacy',
        terms: 'Terms',
        contactTitle: 'Contact',
        archive: 'Archive',
        contactName: 'Name',
        contactEmail: 'Email',
        contactMessage: 'Message',
        contactSubmit: 'Send',
        premiumTitle: 'Premium Coming Soon',
        premiumLead: 'This setting is part of the Premium All Access monthly plan at $4.60/month. In Korea, this is approximately ₩6,990, and the final local amount may vary by PayPal or card exchange rates.',
        premiumFree: 'Free play includes Triangle Mode with 10, 20, 30, 40, or 50 dots through Levels 1-10, plus Star AI with 20-70 dots through Level 5.',
        premiumDotReason: '60-100 dots are premium board sizes.',
        premiumLevelReason: 'Levels 11-100 are premium difficulty levels.',
        premiumGuideReason: 'Normal and Expert guide-line modes are premium options.',
        premiumAiPlan: 'AI Match',
        premiumFriendPlan: 'Friend Match',
        premiumAllPlan: 'Premium All Access',
        premiumAiDesc: 'AI levels and larger AI boards.',
        premiumFriendDesc: 'Friend Match levels and larger friend boards.',
        premiumAllDesc: 'Unlock Triangle and Star premium settings, including Star Friend Match and Star AI Levels 6-100. Korea reference price: about ₩6,990.',
        premiumPayPalSoon: 'PayPal USD $4.60 - Coming Soon',
        installApp: 'Install App',
        updateApp: 'Update App',
        installUnsupported: 'To install, open this site from http://localhost or your HTTPS domain, then use the browser install button.',
        updateChecking: 'Checking for app updates...',
        updateReady: 'Update ready. Applying now.',
        updateCurrent: 'DOTCONQUEST VO30 is already up to date.',
        updateUnsupported: 'App updates are available after opening this site from a web server.',
        close: 'Close',
        hintMsg: 'Hint marked: this line has the best tactical value now.',
        noMoves: 'No moves left.',
        winnerYou: 'You win by area.',
        winnerBot: 'AI wins by area.',
        winnerTie: 'Draw by area.',
        progressWin: 'AI Level {from} cleared. Next match starts at Level {to}.',
        progressLose: 'Defeated at AI Level {from}. Restarting from checkpoint Level {to}.',
        progressDraw: 'Draw at AI Level {from}. Retry Level {to}.',
        friendProgressWin: 'Friend Match winner advances from Level {from} to Level {to}.',
        timeExtend: '10 more seconds. Move now or the turn passes.',
        timePass: 'No move after 20 seconds. Turn passed.',
        redWinTitle: 'Red Wins',
        blueWinTitle: 'Blue Wins',
        redWinBody: 'Red conquered more land by the final score.',
        blueWinBody: 'Blue conquered more land by the final score.',
        finalResult: 'Final result',
        youWinTitle: 'You Win',
        botWinTitle: 'AI Wins',
        drawTitle: 'Draw',
        youWinBody: 'You conquered more land by area.',
        botWinBody: 'The AI conquered more land by area.',
        drawBody: 'Both sides conquered the same total area.'
      },
      ko: {
        tagline: '선을 그으면 전략이 시작됩니다.\n두 선을 선점해 삼각형을 차지하세요.',
        startBrand: '선을 그으면 전략이 시작됩니다.\n두 선을 선점해 삼각형을 차지하세요.',
        mode: 'AI 대전',
        you: '나',
        bot: 'AI 봇',
        dots: '점 개수',
        difficulty: 'AI 난이도',
        aiLevel: 'AI 레벨',
        level: '레벨',
        skinShop: '닷 스킨 상점',
        skinCircle: '기본 원형',
        skinSquare: '작은 사각형',
        skinDiamond: '다이아몬드',
        skinNeon: '네온 점',
        skinInk: '잉크 점',
        skinStar: '별 점',
        skinPlanet: '미니 행성',
        skinPixel: '픽셀 점',
        skinRing: '링 점',
        skinHex: '육각형 점',
        guideMode: '점선 표시',
        guideBeginner: '초보',
        guideNormal: '보통',
        guideExpert: '고수',
        gameMode: '게임 모드',
        boardType: '보드 규칙',
        aiMatch: 'AI 대전',
        friendMatch: '친구대전',
        triangleMode: '삼각형',
        starMode: '별',
        triangleModeLabel: '삼각형',
        starModeLabel: '별',
        starPreviewReason: '별 모드 AI 대전은 20~70개 닷, 1~5레벨까지 무료입니다.',
        starRangeReason: '별 모드는 20개 닷부터 시작하며 20~70개 닷 랜덤형 보드를 지원합니다.',
        starPremiumReason: '별 친구대전과 6~100레벨은 프리미엄 설정입니다.',
        aiModeLabel: 'AI 대전',
        friendModeLabel: '친구대전',
        onlineMatch: '온라인 친구대전',
        offlineReady: '로컬 준비',
        onlineReady: '온라인 준비',
        onlineTurn: '온라인 턴',
        waitingFriend: '친구 대기 중',
        createRoom: '방 만들기',
        joinRoom: '입장',
        roomHelp: '랜덤 방 암호를 만들고 휴대폰, 태블릿, 컴퓨터를 쓰는 가족이나 친구에게 공유하세요.',
        roomCreated: '방을 만들었습니다. 이 랜덤 암호를 공유하세요.',
        roomJoined: '방에 입장했습니다.',
        gameStarted: '게임 시작',
        roomWaiting: '방을 만들었습니다. 친구가 입장할 때까지 기다리세요.',
        roomMissing: '방 코드를 입력하세요.',
        friendHowTitle: '친구대전 사용법',
        friendHow1: '방을 만드는 사람이 먼저 친구대전을 누르고 방 만들기를 누릅니다. 랜덤 방 암호가 자동으로 생성됩니다.',
        friendHow2: '생성된 방 암호를 가족이나 친구에게 그대로 보냅니다. 휴대폰, 태블릿, 컴퓨터 모두 같은 방에 입장할 수 있습니다.',
        friendHow3: '상대방은 어떤 기기에서든 dotconquest.com을 열고 친구대전을 누른 뒤 방 암호를 입력하고 입장을 누릅니다.',
        friendHow4: '초대받은 사람이 입장을 누르면 양쪽 화면에 공수정하기 가위바위보가 뜹니다.',
        friendHow5: '각자 자기 기기에서 선택합니다. 이긴 사람은 무조건 빨강으로 먼저 공격하고, 진 사람은 무조건 파랑입니다.',
        friendHow6: '화면 오류를 줄이려면 같은 종류의 기기끼리 대전하세요. 핸드폰은 핸드폰끼리, 태블릿은 태블릿끼리, 데스크탑은 데스크탑끼리 친구대전을 요청하는 것이 가장 안정적입니다. 친구대전 승자는 이길 때마다 레벨이 올라갑니다.',
        onlineNeedsConfig: '온라인 방은 Cloudflare Pages Functions 또는 Worker의 /api/rooms 경로가 필요합니다. 로컬 친구대전은 바로 사용할 수 있습니다.',
        onlineSyncError: '온라인 동기화에 실패했습니다. 방 코드나 네트워크를 확인하세요.',
        notYourTurn: '상대 차례입니다.',
        notYourTurnFriendRed: '나는 빨강입니다. 빨강 차례가 돌아올 때까지 기다리세요.',
        notYourTurnFriendBlue: '나는 파랑 후공입니다. 빨강이 먼저 선을 그은 뒤 파랑 차례에 선을 이을 수 있습니다.',
        matchSetup: '대국 설정',
        howToPlay: '사용 설명서',
        manual1: '연결된 점 두 개를 선택해 선을 차지하세요.',
        manual2: '삼각형의 두 변을 먼저 차지하면 그 땅이 예약됩니다.',
        manual3: '면적으로 승부하고, 면적이 같으면 땅 개수로 판정합니다.',
        starGuideTitle: '별 모양 그래프 사용법',
        starGuide1: '별은 다섯 개의 점을 잇는 5변 영토입니다.',
        starGuide2: '다섯 변 중 네 변을 먼저 확보하면 별 영토가 예약됩니다.',
        starGuide3: '별 AI는 20~70개 닷과 1~5레벨까지 무료입니다.',
        beginner: '초수',
        intermediate: '중수',
        expert: '고수',
        newGame: '새 대국',
        hint: '힌트',
        rpsTitle: '공수 정하기',
        rpsBody: 'AI와 가위바위보로 선공을 정합니다.',
        friendRpsBody: '양쪽이 방에 입장하면 각자 기기에서 가위바위보를 선택합니다. 이긴 사람은 빨강, 진 사람은 파랑이 됩니다.',
        friendRpsWaiting: '선택을 저장했습니다. 친구의 선택을 기다립니다.',
        friendRpsTie: '같은 선택입니다. 다시 선택하세요.',
        friendRpsWin: '가위바위보 승리. 나는 빨강, 선공입니다.',
        friendRpsLose: '가위바위보 패배. 나는 파랑, 후공입니다. 빨강이 먼저 공격합니다.',
        friendRoleRed: '내 역할: 빨강 · 선공. 친구: 파랑 · 후공.',
        friendRoleBlue: '내 역할: 파랑 · 후공. 친구: 빨강 · 선공.',
        friendTurnRedFirst: '나는 빨강입니다. 내가 선공입니다.',
        friendTurnBlueSecond: '나는 파랑입니다. 친구가 선공입니다.',
        friendRoleChoosing: '가위바위보를 선택하세요. 이긴 사람은 빨강, 진 사람은 파랑입니다.',
        friendRpsFriendWin: '친구가 가위바위보에서 이겨 빨강으로 시작합니다.',
        friendRpsFriendLose: '친구가 가위바위보에서 져서 파랑으로 시작합니다.',
        youRed: '나 (빨강)',
        youBlue: '나 (파랑)',
        friendRed: '친구 (빨강)',
        friendBlue: '친구 (파랑)',
        rock: '바위',
        paper: '보',
        scissors: '가위',
        rules: '점과 점을 이어 선을 그으세요. 삼각형은 두 변을 먼저 확보하면 예약되고, 별은 다섯 변 중 네 변을 먼저 확보하면 예약됩니다.',
        rps: '가위바위보',
        choose: '공수를 정하세요.',
        friendChoose: '친구대전 시작. 빨강이 먼저 공격합니다.',
        yourTurn: '당신 차례입니다',
        redTurn: '빨강 턴',
        blueTurn: '파랑 턴',
        botTurn: 'AI 차례입니다',
        gameOver: '게임 종료',
        red: '빨강',
        blue: '파랑',
        tie: '비겼습니다. 다시 선택하세요.',
        rpsTie: '무승부입니다.\n아직 공격권은 정해지지 않았습니다.',
        winRps: '당신이 공격입니다.\n첫 선으로 AI의 영토를 압박하세요.',
        loseRps: '당신이 수비입니다.\nAI의 첫 수를 막고 역습하세요.',
        invalid: '삼각형 격자의 빈 선을 선택하세요.',
        claimed: '점령',
        byArea: '면적',
        tiles: '칸',
        normal: '보통',
        easy: '쉬움',
        hard: '어려움',
        adNote: 'Google AdSense 광고 영역입니다.',
        ownerExcludedNote: '내 기기 제외 모드가 켜졌습니다. 이 기기에서는 광고와 내부 조회 집계가 제외됩니다.',
        about: '소개',
        guide: '가이드',
        faq: 'FAQ',
        blog: '블로그',
        privacy: '개인정보',
        terms: '이용약관',
        contactTitle: '문의하기',
        archive: '보관',
        contactName: '이름',
        contactEmail: '이메일',
        contactMessage: '문의 내용을 입력하세요',
        contactSubmit: '보내기',
        premiumTitle: '프리미엄 준비 중',
        premiumLead: '이 설정은 월 $4.60 프리미엄 통합 월구독 기능입니다. 한국 기준 약 6,990원이며, 실제 원화 금액은 PayPal 또는 카드사 환율에 따라 달라질 수 있습니다.',
        premiumFree: '무료 플레이는 삼각형 모드 10, 20, 30, 40, 50개 닷과 1~10레벨, 별 AI 20~70개 닷 1~5레벨까지 지원합니다.',
        premiumDotReason: '60~100개 닷은 프리미엄 보드 크기입니다.',
        premiumLevelReason: '11~100레벨은 프리미엄 난이도입니다.',
        premiumGuideReason: '보통/고수 점선 표시는 프리미엄 옵션입니다.',
        premiumAiPlan: 'AI 대전',
        premiumFriendPlan: '친구대전',
        premiumAllPlan: '프리미엄 통합 이용',
        premiumAiDesc: 'AI 레벨과 더 큰 AI 보드를 이용합니다.',
        premiumFriendDesc: '친구대전 레벨과 더 큰 친구대전 보드를 이용합니다.',
        premiumAllDesc: '삼각형과 별 프리미엄 설정, 별 친구대전과 별 AI 6~100레벨을 이용합니다. 한국 기준 약 6,990원입니다.',
        premiumPayPalSoon: 'PayPal USD $4.60 - 준비 중',
        installApp: '앱 설치',
        updateApp: '업데이트',
        installUnsupported: '설치는 http://localhost 또는 HTTPS 도메인에서 연 뒤 브라우저 설치 버튼으로 진행하세요.',
        updateChecking: '앱 업데이트를 확인합니다...',
        updateReady: '업데이트가 준비되었습니다. 바로 적용합니다.',
        updateCurrent: 'DOTCONQUEST VO30 최신 버전입니다.',
        updateUnsupported: '앱 업데이트는 웹서버 주소로 열었을 때 사용할 수 있습니다.',
        close: '닫기',
        hintMsg: '힌트 표시: 현재 전술 가치가 가장 높은 선입니다.',
        noMoves: '남은 수가 없습니다.',
        winnerYou: '면적 점수로 승리했습니다.',
        winnerBot: 'AI가 면적 점수로 승리했습니다.',
        winnerTie: '면적 점수 동점입니다.',
        progressWin: 'AI 레벨 {from} 승리. 다음 대국은 레벨 {to}에서 시작합니다.',
        progressLose: 'AI 레벨 {from} 패배. 체크포인트 레벨 {to}부터 다시 시작합니다.',
        progressDraw: 'AI 레벨 {from} 무승부. 레벨 {to}에 다시 도전합니다.',
        friendProgressWin: '친구대전 승자는 레벨 {from}에서 레벨 {to}로 올라갑니다.',
        timeExtend: '10초를 한 번 더 줍니다. 이번에도 공격하지 않으면 턴이 넘어갑니다.',
        timePass: '20초 동안 공격하지 않아 턴이 넘어갑니다.',
        redWinTitle: '빨강 승리',
        blueWinTitle: '파랑 승리',
        redWinBody: '최종 점수에서 빨강이 더 많은 땅을 정복했습니다.',
        blueWinBody: '최종 점수에서 파랑이 더 많은 땅을 정복했습니다.',
        finalResult: '최종 결과',
        youWinTitle: '당신의 승리',
        botWinTitle: 'AI 승리',
        drawTitle: '무승부',
        youWinBody: '더 넓은 땅을 정복했습니다.',
        botWinBody: 'AI가 더 넓은 땅을 정복했습니다.',
        drawBody: '양쪽이 같은 면적을 정복했습니다.'
      }
    };

    const infoContent = {
      ko: {
        about: {
          title: '소개',
          lines: [
            'DOTCONQUEST는 단순한 점잇기 게임이 아닙니다.',
            '점과 선을 이용해 삼각형 영토를 점령하는 전략 게임입니다.',
            '매 턴 하나의 선을 선택해 삼각형의 두 변을 먼저 확보하면 해당 영토를 예약할 수 있으며, 하나의 선은 공격이 될 수도, 방어가 될 수도, 상대를 함정에 빠뜨리는 전략이 될 수도 있습니다.',
            'AI 레벨이 올라갈수록 더 정교한 수비와 공격 전략을 사용합니다.'
          ]
        },
        guide: {
          title: '가이드',
          lines: [
            '1. 가위바위보로 선공을 정합니다.',
            '2. 연결 가능한 두 점을 선택해 선을 차지합니다.',
            '3. 삼각형의 두 변을 먼저 차지하면 그 땅을 예약합니다.',
            '4. AI 대전은 승리하면 다음 레벨로 올라가고, 패배하면 10단위 체크포인트부터 다시 시작합니다.',
            '5. AI 레벨이 올라갈수록 더 정교한 수비와 공격 전략을 사용합니다.'
          ]
        },
        faq: {
          title: 'FAQ',
          lines: [
            'Q. 친구대전은 온라인인가요? 네. 배포된 사이트에서는 휴대폰, 태블릿, 컴퓨터가 같은 방 코드로 온라인 1:1 대전에 입장할 수 있습니다. 같은 기기에서는 로컬 친구대전도 바로 플레이할 수 있습니다.',
            'Q. 앱 설치는 어디서 하나요? PC 웹사이트에서는 브라우저 주소창이나 설치 아이콘이 표시될 때 설치할 수 있습니다. 모바일에서는 게임 화면에 들어온 뒤 메뉴를 아래로 내려 하단의 앱 설치 버튼을 누르면 설치를 진행할 수 있습니다.',
            'Q. 업데이트는 어떻게 설치되나요? 게임 메뉴 하단의 업데이트 버튼에서 새 버전을 확인할 수 있습니다. 현재는 테스트 기간이라 환경에 따라 작동 방식이 달라질 수 있으며, 점차 안정적으로 개선할 예정입니다.'
          ]
        },
        blog: {
          title: '블로그',
          lines: [
            'DOTCONQUEST 개발 기록과 전략 팁을 이곳에 연결할 예정입니다.',
            '초기 버전에서는 AI 레벨, 모바일 조작감, PWA 설치 경험을 중심으로 개선하고 있습니다.'
          ]
        },
        privacy: {
          title: '개인정보',
          lines: [
            'DOTCONQUEST는 계정 가입 없이 이용할 수 있으며, 현재 버전에는 로그인, 결제, 분석 도구가 포함되어 있지 않습니다. 온라인 친구대전은 방 코드와 익명 플레이어 ID를 이용해 대국 상태를 동기화할 수 있습니다.',
            '언어 설정, AI 레벨 진행도, PWA 설치와 오프라인 실행을 위한 앱 캐시는 사용자의 브라우저 또는 기기 안에 저장될 수 있습니다.',
            '업데이트 버튼은 최신 버전 적용을 위해 브라우저 캐시를 삭제하거나 새 앱 리소스로 갱신할 수 있습니다.',
            'Google AdSense 광고 서비스가 적용되어 있으며, 광고 제공자가 쿠키 또는 유사 기술을 사용할 수 있습니다. 광고 관련 선택과 정책은 Google 및 광고 제공자의 기준이 함께 적용될 수 있습니다.',
            '브라우저 설정에서 캐시, 쿠키, 사이트 데이터를 삭제할 수 있고, 설치한 앱은 브라우저 또는 운영체제 앱 관리 화면에서 제거할 수 있습니다.'
          ]
        },
        terms: {
          title: '이용약관',
          lines: [
            'DOTCONQUEST는 점과 선을 이용해 삼각형 영토를 점령하는 턴제 전략 게임입니다. AI 대전, 로컬 친구대전, Cloudflare 방 API를 통한 온라인 친구대전 기능을 제공합니다.',
            '현재 버전에는 실제 결제, 유료 아이템, 현금성 구매 기능이 포함되어 있지 않습니다.',
            'Google AdSense 광고가 적용되어 있으며, 광고는 게임 플레이를 방해하지 않는 방식으로 배치하는 것을 목표로 합니다. 광고 제공자의 정책과 쿠키 설정이 함께 적용될 수 있습니다.',
            '언어 설정, AI 레벨 진행도, 앱 캐시는 사용자의 브라우저 또는 기기 안에 저장될 수 있으며, 사이트 데이터 삭제 시 진행도와 일부 설정이 초기화될 수 있습니다.',
            'DOTCONQUEST 이름, 로고, UI, 코드, 게임 구성 요소는 권리자에게 귀속되며 사전 허가 없이 복제, 재배포, 상업적 이용을 할 수 없습니다.'
          ]
        }
      },
      en: {
        about: {
          title: 'About',
          lines: [
            'DOTCONQUEST is not just a dot-connecting game.',
            'It is a territory strategy game where you use dots and lines to conquer triangular land.',
            'Each turn, you choose one line. If you secure two sides of a triangle first, you reserve that territory. A single line can become an attack, a defense, or a trap that forces your opponent into a bad position.',
            'As AI levels rise, the AI uses more refined defensive and offensive strategies.'
          ]
        },
        guide: {
          title: 'Guide',
          lines: [
            '1. Decide the first attacker with rock paper scissors.',
            '2. Select two connected dots to claim a line.',
            '3. Own two sides first to reserve a triangle.',
            '4. In AI Match, winning advances one level. Losing returns you to the current 10-level checkpoint.',
            '5. As AI levels rise, the AI uses more refined defensive and offensive strategies.'
          ]
        },
        faq: {
          title: 'FAQ',
          lines: [
            'Q. Is Friend Match online? Yes. On the deployed site, phones, tablets, and computers can join the same online 1:1 room with one room code. Local Friend Match also works on the same device.',
            'Q. Where do I install the app? On desktop, use the install icon or prompt shown by the browser when it is available. On mobile, open the game, scroll to the bottom of the menu, and tap the Install App button.',
            'Q. How are updates installed? Use the Update button at the bottom of the game menu to check for a new version. The app is still in a test period, so update behavior may vary by browser while it is being improved.'
          ]
        },
        blog: {
          title: 'Blog',
          lines: [
            'Development notes and strategy tips for DOTCONQUEST will be connected here.',
            'The current focus is AI levels, mobile controls, and the installable PWA experience.'
          ]
        },
        privacy: {
          title: 'Privacy',
          lines: [
            'DOTCONQUEST can be used without account registration. The current version does not include login, payments, or analytics tools. Online Friend Match can synchronize a room code and anonymous player ID when the Cloudflare room API is configured.',
            'Language preference, AI level progress, PWA installation data, and cached app resources for offline use may be stored locally in your browser or device.',
            'The Update button may clear browser cache or refresh app resources to apply the latest version.',
            'Google AdSense advertising is enabled. Ad providers may use cookies or similar technologies, and Google or ad provider policies and choices may apply.',
            'You can clear cache, cookies, and site data in browser settings. Installed apps can be removed from browser or operating system app settings.'
          ]
        },
        terms: {
          title: 'Terms',
          lines: [
            'DOTCONQUEST is a turn-based territory strategy game where players use dots and lines to conquer triangular land. The current version supports AI Match, local Friend Match, and online Friend Match rooms through the Cloudflare room API.',
            'The current version does not include real-money purchases, paid items, or payment features.',
            'Google AdSense advertising is enabled. Ads are intended to avoid blocking essential gameplay, and ad provider policies and cookie settings may also apply.',
            'Language preference, AI level progress, and app cache may be stored locally in your browser or device. Clearing site data may reset progress and some settings.',
            'The DOTCONQUEST name, logo, UI, code, and game elements belong to their rights holder and may not be copied, redistributed, or used commercially without permission.'
          ]
        }
      }
    };

    const blogPosts = { ko: [], en: [] };

    const savedLang = localStorage.getItem('dotConquestLang');
    const browserLang = (navigator.language || navigator.userLanguage || '').toLowerCase();
    const detectedLang = browserLang.startsWith('ko') ? 'ko' : 'en';
    const initialLang = ['ko', 'en'].includes(savedLang) ? savedLang : detectedLang;
    const savedAiLevel = Number(localStorage.getItem('dotConquestAiLevel'));
    const initialAiLevel = Number.isInteger(savedAiLevel) && savedAiLevel >= 1 && savedAiLevel <= 100 ? savedAiLevel : 1;

    function koreaTodayString() {
      return new Intl.DateTimeFormat('en-CA', {
        timeZone: 'Asia/Seoul',
        year: 'numeric',
        month: '2-digit',
        day: '2-digit'
      }).format(new Date());
    }

    const state = {
      lang: initialLang,
      points: [],
      triangles: [],
      edgeMap: new Map(),
      edges: [],
      selected: null,
      current: 'red',
      human: 'red',
      bot: 'blue',
      gameMode: 'ai',
      boardType: 'triangle',
      dotCount: 30,
      difficulty: initialAiLevel,
      matchStartLevel: initialAiLevel,
      dotSkin: 'circle',
      guideMode: 'beginner',
      theme: 'day',
      phase: 'rps',
      log: [],
      hintEdge: null,
      hover: null,
      suppressClickUntil: 0,
      timerId: null,
      timeLeft: 10,
      turnExtended: false,
      result: null,
      online: {
        enabled: false,
        roomCode: '',
        playerId: localStorage.getItem('dotConquestPlayerId') || '',
        role: '',
        seat: '',
        redPlayerId: '',
        bluePlayerId: '',
        host: false,
        syncing: false,
        pollId: null,
        lastVersion: 0
      },
      rps: {
        choices: {},
        result: null
      }
    };

    if (!state.online.playerId) {
      state.online.playerId = `p${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
      localStorage.setItem('dotConquestPlayerId', state.online.playerId);
    }

    function resetOnlinePlayerId() {
      state.online.playerId = `p${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
      localStorage.setItem('dotConquestPlayerId', state.online.playerId);
      return state.online.playerId;
    }

    function t(key) {
      return i18n[state.lang][key] || i18n.en[key] || key;
    }

    function checkpointForLevel(level) {
      return level < 10 ? 1 : Math.floor(level / 10) * 10;
    }

    function progressText(key, from, to) {
      return t(key).replace('{from}', from).replace('{to}', to);
    }

    function setAiLevel(level) {
      state.difficulty = Math.max(1, Math.min(100, Number(level)));
      if (state.phase !== 'play') state.matchStartLevel = state.difficulty;
      localStorage.setItem('dotConquestAiLevel', String(state.difficulty));
    }

    function isFreeConfig(config = {}) {
      const boardType = config.boardType ?? state.boardType;
      const gameMode = config.gameMode ?? state.gameMode;
      const dotCount = Number(config.dotCount ?? state.dotCount);
      const difficulty = Number(config.difficulty ?? state.difficulty);
      const guideMode = config.guideMode ?? state.guideMode;
      if (boardType === 'star') {
        return gameMode === 'ai' && STAR_DOT_COUNTS.includes(dotCount) && difficulty <= STAR_FREE_MAX_LEVEL && guideMode === 'beginner';
      }
      return FREE_DOT_COUNTS.includes(dotCount) && difficulty <= FREE_MAX_LEVEL && guideMode === 'beginner';
    }

    function premiumReason(config = {}) {
      const boardType = config.boardType ?? state.boardType;
      const gameMode = config.gameMode ?? state.gameMode;
      const dotCount = Number(config.dotCount ?? state.dotCount);
      const difficulty = Number(config.difficulty ?? state.difficulty);
      const guideMode = config.guideMode ?? state.guideMode;
      if (boardType === 'star') {
        if (!STAR_DOT_COUNTS.includes(dotCount)) return t('starRangeReason');
        if (gameMode !== 'ai' || difficulty > STAR_FREE_MAX_LEVEL) return t('starPremiumReason');
        if (guideMode !== 'beginner') return t('premiumGuideReason');
        return '';
      }
      if (!FREE_DOT_COUNTS.includes(dotCount)) return t('premiumDotReason');
      if (difficulty > FREE_MAX_LEVEL) return t('premiumLevelReason');
      if (guideMode !== 'beginner') return t('premiumGuideReason');
      return '';
    }

    function openPremiumModal(reason = '') {
      activeInfoModal = 'premium';
      infoTitle.textContent = t('premiumTitle');
      infoBody.innerHTML = '';
      const box = document.createElement('div');
      box.className = 'premiumBox';

      const lead = document.createElement('p');
      lead.className = 'premiumLead';
      lead.textContent = reason ? `${reason} ${t('premiumLead')}` : t('premiumLead');
      box.appendChild(lead);

      const free = document.createElement('p');
      free.className = 'premiumNote';
      free.textContent = t('premiumFree');
      box.appendChild(free);

      const plans = document.createElement('div');
      plans.className = 'premiumPlanGrid';
      PREMIUM_PLANS.forEach(plan => {
        const card = document.createElement('div');
        card.className = 'premiumPlan';
        const title = document.createElement('strong');
        title.innerHTML = `<span>${t(plan.key)}</span><span>${plan.price}</span>`;
        const desc = document.createElement('span');
        desc.textContent = t(plan.desc);
        const button = document.createElement('button');
        button.type = 'button';
        button.disabled = true;
        button.textContent = t('premiumPayPalSoon');
        card.append(title, desc, button);
        plans.appendChild(card);
      });
      box.appendChild(plans);
      infoBody.appendChild(box);
      infoOverlay.classList.remove('hidden');
    }

    function updateAiProgress(outcome) {
      if (state.gameMode !== 'ai') return '';
      const from = state.difficulty;
      let to = from;
      let key = 'progressDraw';
      if (outcome === 'you') {
        to = Math.min(100, from + 1);
        key = 'progressWin';
      } else if (outcome === 'bot') {
        to = checkpointForLevel(from);
        key = 'progressLose';
      }
      setAiLevel(to);
      const message = progressText(key, from, to);
      addLog(message);
      return message;
    }

    function updateFriendProgress(outcome) {
      if (state.gameMode !== 'friend' || !['red', 'blue'].includes(outcome)) return '';
      const from = Math.max(1, Math.min(100, Number(state.matchStartLevel) || state.difficulty || 1));
      const to = Math.min(100, from + 1);
      setAiLevel(to);
      state.matchStartLevel = to;
      const message = progressText('friendProgressWin', from, to);
      addLog(message);
      return message;
    }

    function openInfoModal(key) {
      if (key === 'contact') {
        renderContactForm();
        return;
      }
      const content = infoContent[state.lang][key] || infoContent.ko[key];
      if (!content) return;
      activeInfoModal = key;
      infoTitle.textContent = content.title;
      infoBody.innerHTML = '';
      if (key === 'blog') {
        renderBlogList();
        infoOverlay.classList.remove('hidden');
        return;
      }
      content.lines.forEach(line => {
        const paragraph = document.createElement('p');
        paragraph.textContent = line;
        infoBody.appendChild(paragraph);
      });
      infoOverlay.classList.remove('hidden');
    }

    function renderContactForm() {
      activeInfoModal = 'contact';
      infoTitle.textContent = t('contactTitle');
      infoBody.innerHTML = '';
      const form = document.createElement('form');
      form.className = 'contactForm';
      form.action = 'https://formspree.io/f/xjgzjnaa';
      form.method = 'POST';

      const nameInput = document.createElement('input');
      nameInput.type = 'text';
      nameInput.name = 'name';
      nameInput.autocomplete = 'name';
      nameInput.placeholder = t('contactName');
      nameInput.setAttribute('aria-label', t('contactName'));

      const emailInput = document.createElement('input');
      emailInput.type = 'email';
      emailInput.name = 'email';
      emailInput.autocomplete = 'email';
      emailInput.placeholder = t('contactEmail');
      emailInput.setAttribute('aria-label', t('contactEmail'));
      emailInput.required = true;

      const messageInput = document.createElement('textarea');
      messageInput.name = 'message';
      messageInput.placeholder = t('contactMessage');
      messageInput.setAttribute('aria-label', t('contactMessage'));
      messageInput.required = true;

      const subjectInput = document.createElement('input');
      subjectInput.type = 'hidden';
      subjectInput.name = '_subject';
      subjectInput.value = 'DOTCONQUEST Contact';

      const submitButton = document.createElement('button');
      submitButton.type = 'submit';
      submitButton.className = 'footerButton';
      submitButton.textContent = t('contactSubmit');

      form.append(nameInput, emailInput, messageInput, subjectInput, submitButton);
      infoBody.appendChild(form);
      infoOverlay.classList.remove('hidden');
      setTimeout(() => nameInput.focus(), 60);
    }

    function renderBlogList() {
      infoBody.innerHTML = '';
      const posts = blogPosts[state.lang] || blogPosts.ko;
      const intro = document.createElement('p');
      intro.textContent = state.lang === 'en'
        ? 'Open the blog page to see only today\'s English and Korean published articles.'
        : '블로그 페이지에서는 오늘 발행된 한국어 글 1개와 영어 글 1개만 확인할 수 있습니다.';
      infoBody.appendChild(intro);
      const grid = document.createElement('div');
      grid.className = 'blogTopicGrid';
      const today = koreaTodayString();
      posts.forEach((post, index) => {
        const button = document.createElement('button');
        button.type = 'button';
        button.className = 'blogTopicButton';
        button.dataset.blogSlug = post.slug;
        const isFuture = post.publishDate && post.publishDate > today;
        const status = post.publishDate
          ? (isFuture
              ? (state.lang === 'en' ? `Scheduled ${post.publishDate}` : `${post.publishDate} 예약`)
              : (state.lang === 'en' ? `Published ${post.publishDate}` : `${post.publishDate} 공개`))
          : '';
        button.textContent = `${index + 1}. ${post.title}${status ? ` - ${status}` : ''}`;
        grid.appendChild(button);
      });
      infoBody.appendChild(grid);
    }

    function escapeArticleHtml(value) {
      return String(value)
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#39;');
    }

    function renderLaunchArticleHtml(post) {
      const isEn = state.lang === 'en';
      const hook = isEn
        ? 'When one line can attack, defend, trap, and reserve territory at the same time, DOTCONQUEST becomes more than a dot connection game.'
        : '선 하나가 공격, 방어, 함정, 영토 예약을 동시에 바꾸는 순간 DOTCONQUEST는 단순한 점 연결 게임이 아니라 새로운 삼각형 영토 전략게임이 된다.';
      const sections = isEn
        ? [
            ['1. Why This Topic Defines DOTCONQUEST', [
              `${post.title} is a long-form DOTCONQUEST strategy article built for players who want a distinct dot and line strategy game rather than another ordinary connection puzzle.`,
              'DOTCONQUEST is different because the board is read through triangle territory. A line is not only a visual connection. It can become attack, defense, future influence, or a two-side reservation before the triangle is fully completed.',
              'This is why search ideas such as triangle territory game, mobile territory strategy game, AI line drawing strategy game, and chess-like mobile strategy game naturally fit DOTCONQUEST.'
            ]],
            ['2. Three Standards Before Drawing a Line', [
              'The first standard is two-side reservation. If two sides of a triangle are already controlled, the opponent must respect that future territory even before the score changes.',
              'The second standard is the opponent next line. A move that looks aggressive can be weak when it gives the opponent an easy triangle claim on the following turn.',
              'The third standard is board size. Free 10, 20, 30, 40, and 50-dot boards with levels 1 to 10 are useful training grounds because cause and effect appear quickly.'
            ]],
            ['3. AI Battle and Friend Match Strategy', [
              'In AI battle, the safest plan is to reduce loose edges and protect structure before chasing immediate score. AI pressure often punishes careless lines.',
              'In friend match, the strongest line is often the one that makes every reply slightly uncomfortable. Human opponents can overreact to visible triangles and miss quiet defensive pressure.',
              'On mobile, fast play still needs a clear question before every important move: which triangle does this line prepare, which triangle does it prevent, and what follow-up remains?'
            ]],
            ['Conclusion. Move Density Is the Strength of DOTCONQUEST', [
              'DOTCONQUEST is fast to play, but it is not shallow. One line can carry several meanings at once, and that density is the foundation of the game.',
              'A player who reads area, timing, and follow-up pressure through a single line will understand why DOTCONQUEST stands apart as a dot and line strategy game and a triangle territory game.',
              'The practical task is simple. Before committing to a line, compare one more candidate and choose the move that changes territory without opening an easy counter.'
            ]]
          ]
        : [
            ['1. 왜 이 주제가 DOTCONQUEST의 핵심인가', [
              `이 글의 대제목은 ${post.title}이다. DOTCONQUEST는 평범한 점 연결 퍼즐이 아니라, 선 하나의 방향이 공격권과 방어권을 동시에 바꾸는 점과 선 전략게임이다.`,
              '삼각형 영토 게임이라는 구조는 기존 점 연결 게임과 다르다. 삼각형이 완전히 닫히기 전에도 두 변을 먼저 확보하면 실질적인 영토 압박이 생긴다.',
              '체스처럼 한 수가 중요하고, 바둑처럼 영역을 읽지만, 점과 선으로 더 빠르게 즐기는 모바일 영토 전략게임이라는 점이 DOTCONQUEST의 차별화된 SEO 핵심이다.'
            ]],
            ['2. 실전에서 먼저 읽어야 할 세 가지 기준', [
              '첫 번째 기준은 두 변 선점이다. 삼각형이 완전히 닫히지 않았더라도 두 변을 먼저 잡으면 상대는 그 주변 선택을 의식할 수밖에 없다.',
              '두 번째 기준은 상대의 다음 선이다. 내 점수만 보고 선을 긋는 플레이는 AI 대전에서 쉽게 막히고, 친구대전에서는 역공의 빌미가 된다.',
              '세 번째 기준은 보드 크기와 레벨이다. 10개, 20개, 30개, 40개, 50개 닷과 1~10레벨 무료 구간은 어떤 선이 영역을 넓히고 어떤 선이 상대에게 길을 열어주는지 확인하기 좋은 훈련 구간이다.'
            ]],
            ['3. AI 대전과 친구대전에서 달라지는 운영법', [
              'AI 대전에서는 실수 축소가 가장 중요하다. AI는 느슨한 가장자리, 방치된 두 변, 쉽게 이어지는 삼각형 후보를 빠르게 압박한다.',
              '친구대전에서는 심리전이 더 강하게 작동한다. 좋은 수는 상대가 무엇을 골라도 작은 손해를 보게 만드는 선이다.',
              '모바일 플레이에서는 조작 속도와 판단 속도가 함께 필요하다. 그러나 빠르게 고른다는 말은 대충 고른다는 뜻이 아니다. 선을 긋기 전 이 선이 예약하는 삼각형과 막는 삼각형을 함께 확인해야 한다.'
            ]],
            ['결론. DOTCONQUEST의 차별화는 한 수의 밀도에 있다', [
              'DOTCONQUEST의 차별화는 화려한 그래픽이나 복잡한 규칙이 아니라 한 수의 밀도에 있다. 선 하나가 공격, 방어, 함정, 삼각형 영토 예약을 동시에 만들 수 있기 때문이다.',
              '이 구조 때문에 점과 선 전략게임, 삼각형 영토 게임, AI 선 긋기 전략게임, 모바일 영토 전략게임이라는 검색어가 DOTCONQUEST와 자연스럽게 연결된다.',
              '오늘의 연습은 단순하다. 중요한 선을 긋기 전에 이 선이 완성하는 삼각형은 무엇이고, 막는 삼각형은 무엇인지 먼저 묻는다. 답이 분명하지 않으면 다른 후보 선을 하나 더 비교한다.'
            ]]
          ];
      const body = sections.map(([heading, paragraphs]) => {
        const sectionClass = heading.startsWith('결론') || heading.startsWith('Conclusion') ? 'conclusionSection' : 'articleSection';
        return `<section class="${sectionClass}"><h2>${escapeArticleHtml(heading)}</h2>${paragraphs.map(paragraph => `<p>${escapeArticleHtml(paragraph)}</p>`).join('')}</section>`;
      }).join('');
      return `<p class="meta">${escapeArticleHtml(post.publishDate || koreaTodayString())}</p><h1 class="articleTitle">${escapeArticleHtml(post.title)}</h1><p class="hook">${escapeArticleHtml(hook)}</p>${body}`;
    }

    async function renderBlogArticle(slug) {
      const posts = blogPosts[state.lang] || blogPosts.ko;
      const post = posts.find(item => item.slug === slug);
      if (!post) return;
      infoTitle.textContent = post.title;
      infoBody.innerHTML = '';
      const backButton = document.createElement('button');
      backButton.type = 'button';
      backButton.className = 'blogBackButton';
      backButton.dataset.blogBack = 'true';
      backButton.textContent = state.lang === 'en' ? 'Blog List' : '블로그 목록';
      infoBody.appendChild(backButton);
      const articleWrap = document.createElement('div');
      articleWrap.className = 'blogArticle';
      const today = koreaTodayString();
      if (post.publishDate && post.publishDate > today) {
        articleWrap.innerHTML = state.lang === 'en'
          ? `<h1 class="articleTitle">${escapeArticleHtml(post.title)}</h1><p class="hook">Scheduled for ${escapeArticleHtml(post.publishDate)}</p><p>This DOTCONQUEST launch article is reserved and will open automatically on its scheduled date.</p>`
          : `<h1 class="articleTitle">${escapeArticleHtml(post.title)}</h1><p class="hook">${escapeArticleHtml(post.publishDate)} 예약 공개</p><p>이 DOTCONQUEST 런칭 블로그는 예약 글이며, 공개일이 되면 자동으로 열린다.</p>`;
        infoBody.appendChild(articleWrap);
        return;
      }
      articleWrap.innerHTML = renderLaunchArticleHtml(post);
      infoBody.appendChild(articleWrap);
    }

    function closeInfoModal() {
      activeInfoModal = null;
      infoOverlay.classList.add('hidden');
    }

    function resize() {
      const rect = canvas.getBoundingClientRect();
      const dpr = Math.max(1, window.devicePixelRatio || 1);
      canvas.width = Math.floor(rect.width * dpr);
      canvas.height = Math.floor(rect.height * dpr);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      draw();
    }

    function makeKey(a, b) {
      return a < b ? `${a}-${b}` : `${b}-${a}`;
    }

    function generatePoints(count) {
      const rect = canvas.getBoundingClientRect();
      const margin = Math.min(rect.width, rect.height) < 520 ? 52 : 72;
      const pts = [];
      let guard = 0;
      while (pts.length < count && guard < 9000) {
        guard++;
        const p = {
          x: margin + Math.random() * Math.max(80, rect.width - margin * 2),
          y: margin + Math.random() * Math.max(80, rect.height - margin * 2)
        };
        const minDistance = count >= 90 ? 20 : count >= 70 ? 24 : count >= 50 ? 30 : count >= 40 ? 34 : count >= 30 ? 40 : count >= 20 ? 48 : 58;
        if (pts.every(q => dist(p, q) > minDistance)) pts.push(p);
      }
      return pts;
    }

    function dist(a, b) {
      const dx = a.x - b.x;
      const dy = a.y - b.y;
      return Math.hypot(dx, dy);
    }

    function areaOf(a, b, c) {
      return Math.abs((a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)) / 2);
    }

    function polygonArea(ids) {
      let area = 0;
      ids.forEach((id, index) => {
        const current = state.points[id];
        const next = state.points[ids[(index + 1) % ids.length]];
        area += current.x * next.y - next.x * current.y;
      });
      return Math.abs(area / 2);
    }

    function generateStarBoard(count) {
      const points = generatePoints(count);
      const tiles = [];
      const seen = new Set();
      const targetTiles = Math.max(18, Math.min(count * 2, count + 34));
      const shuffled = [...points.keys()].sort(() => Math.random() - 0.5);
      shuffled.forEach(centerId => {
        if (tiles.length >= targetTiles) return;
        const center = points[centerId];
        const nearest = points
          .map((point, id) => ({ id, distance: Math.hypot(point.x - center.x, point.y - center.y) }))
          .filter(item => item.id !== centerId)
          .sort((a, b) => a.distance - b.distance)
          .slice(0, 8);
        const candidates = [centerId, ...nearest.slice(0, 4).map(item => item.id)];
        const cx = candidates.reduce((sum, id) => sum + points[id].x, 0) / candidates.length;
        const cy = candidates.reduce((sum, id) => sum + points[id].y, 0) / candidates.length;
        const ids = candidates.sort((a, b) => {
          const pa = points[a];
          const pb = points[b];
          return Math.atan2(pa.y - cy, pa.x - cx) - Math.atan2(pb.y - cy, pb.x - cx);
        });
        const key = [...ids].sort((a, b) => a - b).join('-');
        if (seen.has(key)) return;
        seen.add(key);
        const area = ids.reduce((sum, id, index) => {
          const current = points[id];
          const next = points[ids[(index + 1) % ids.length]];
          return sum + current.x * next.y - next.x * current.y;
        }, 0);
        if (Math.abs(area) > 250) tiles.push({ ids, owner: null, area: Math.abs(area / 2) });
      });
      if (tiles.length < 8) {
        const triangles = delaunay(points);
        triangles.forEach(tri => {
          if (tiles.length >= targetTiles) return;
          const ids = [...tri.ids];
          const cx = ids.reduce((sum, id) => sum + points[id].x, 0) / ids.length;
          const cy = ids.reduce((sum, id) => sum + points[id].y, 0) / ids.length;
          const extra = points
            .map((point, id) => ({ id, distance: Math.hypot(point.x - cx, point.y - cy) }))
            .filter(item => !ids.includes(item.id))
            .sort((a, b) => a.distance - b.distance)
            .slice(0, 2)
            .map(item => item.id);
          const pentagon = ids.concat(extra);
          const ordered = pentagon.sort((a, b) => {
            const pa = points[a];
            const pb = points[b];
            return Math.atan2(pa.y - cy, pa.x - cx) - Math.atan2(pb.y - cy, pb.x - cx);
          });
          tiles.push({ ids: ordered, owner: null, area: polygonAreaFromPoints(points, ordered) });
        });
      }
      return { points, tiles };
    }

    function polygonAreaFromPoints(points, ids) {
      const area = ids.reduce((sum, id, index) => {
        const current = points[id];
        const next = points[ids[(index + 1) % ids.length]];
        return sum + current.x * next.y - next.x * current.y;
      }, 0);
      return Math.abs(area / 2);
    }

    function circumcircle(a, b, c) {
      const d = 2 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y));
      if (Math.abs(d) < 0.001) return { x: 0, y: 0, r: -1 };
      const ux = ((a.x*a.x + a.y*a.y) * (b.y - c.y) + (b.x*b.x + b.y*b.y) * (c.y - a.y) + (c.x*c.x + c.y*c.y) * (a.y - b.y)) / d;
      const uy = ((a.x*a.x + a.y*a.y) * (c.x - b.x) + (b.x*b.x + b.y*b.y) * (a.x - c.x) + (c.x*c.x + c.y*c.y) * (b.x - a.x)) / d;
      return { x: ux, y: uy, r: Math.hypot(ux - a.x, uy - a.y) };
    }

    function delaunay(points) {
      const rect = canvas.getBoundingClientRect();
      const span = Math.max(rect.width, rect.height) * 12;
      const superPts = [
        { x: -span, y: -span },
        { x: rect.width / 2, y: span },
        { x: span, y: -span }
      ];
      const all = points.concat(superPts);
      let tris = [{ ids: [points.length, points.length + 1, points.length + 2] }];
      all.forEach((p, idx) => {
        if (idx >= points.length) return;
        const bad = [];
        tris.forEach((tri, triIdx) => {
          const cc = circumcircle(all[tri.ids[0]], all[tri.ids[1]], all[tri.ids[2]]);
          if (cc.r > 0 && Math.hypot(p.x - cc.x, p.y - cc.y) < cc.r) bad.push(triIdx);
        });
        const polygon = new Map();
        bad.forEach(triIdx => {
          const ids = tris[triIdx].ids;
          [[ids[0], ids[1]], [ids[1], ids[2]], [ids[2], ids[0]]].forEach(([a, b]) => {
            const key = makeKey(a, b);
            if (polygon.has(key)) polygon.delete(key);
            else polygon.set(key, [a, b]);
          });
        });
        tris = tris.filter((_, triIdx) => !bad.includes(triIdx));
        polygon.forEach(edge => tris.push({ ids: [edge[0], edge[1], idx] }));
      });
      return tris
        .filter(tri => tri.ids.every(id => id < points.length))
        .map(tri => ({ ids: tri.ids, owner: null, area: areaOf(points[tri.ids[0]], points[tri.ids[1]], points[tri.ids[2]]) }))
        .filter(tri => tri.area > 120);
    }

    function buildEdges() {
      state.edgeMap.clear();
      state.edges = [];
      state.triangles.forEach((tri, triIdx) => {
        const pairs = tri.ids.map((id, index) => [id, tri.ids[(index + 1) % tri.ids.length]]);
        pairs.forEach(([a, b]) => {
          const key = makeKey(a, b);
          if (!state.edgeMap.has(key)) {
            const edge = { a: Math.min(a, b), b: Math.max(a, b), owner: null, tris: [] };
            state.edgeMap.set(key, edge);
            state.edges.push(edge);
          }
          state.edgeMap.get(key).tris.push(triIdx);
        });
      });
    }

    function newGame(showRps = true) {
      if (!isFreeConfig()) {
        openPremiumModal(premiumReason());
        if (state.boardType === 'star') {
          state.gameMode = 'ai';
          state.dotCount = STAR_DOT_COUNTS.includes(state.dotCount) ? state.dotCount : 20;
          if (state.difficulty > STAR_FREE_MAX_LEVEL) setAiLevel(STAR_FREE_MAX_LEVEL);
        } else {
          state.dotCount = FREE_DOT_COUNTS.includes(state.dotCount) ? state.dotCount : 30;
          if (state.difficulty > FREE_MAX_LEVEL) setAiLevel(FREE_MAX_LEVEL);
        }
        if (state.guideMode !== 'beginner') state.guideMode = 'beginner';
      }
      stopTurnTimer();
      if (state.boardType === 'star' && !STAR_DOT_COUNTS.includes(state.dotCount)) state.dotCount = 20;
      if (!state.online.enabled) stopOnlinePolling();
      state.matchStartLevel = state.difficulty;
      if (state.boardType === 'star') {
        const starBoard = generateStarBoard(state.dotCount);
        state.points = starBoard.points;
        state.triangles = starBoard.tiles;
      } else {
        state.points = generatePoints(state.dotCount);
        state.triangles = delaunay(state.points);
      }
      buildEdges();
      state.selected = null;
      state.current = 'red';
      state.human = 'red';
      state.bot = 'blue';
      state.phase = showRps ? 'rps' : 'play';
      state.log = [];
      state.hintEdge = null;
      state.turnExtended = false;
      state.timeLeft = 10;
      state.rps = { choices: {}, result: null };
      state.result = null;
      rpsResult.textContent = '';
      addLog(state.gameMode === 'friend' ? t('friendChoose') : t('choose'));
      rpsOverlay.classList.toggle('hidden', !showRps);
      updateUI();
      draw();
      if (state.phase === 'play') startTurnTimer();
    }

    function openFriendRps(message = '') {
      stopTurnTimer();
      state.phase = 'rps';
      state.current = 'red';
      state.online.role = '';
      state.online.redPlayerId = '';
      state.online.bluePlayerId = '';
      state.online.rpsChoice = '';
      state.rps = { choices: {}, result: null };
      state.result = null;
      rpsResult.textContent = message || '';
      rpsOverlay.classList.remove('hidden');
      updateUI();
    }

    function stopTurnTimer() {
      if (state.timerId) clearInterval(state.timerId);
      state.timerId = null;
    }

    function startTurnTimer() {
      stopTurnTimer();
      if (state.phase !== 'play') return;
      state.timeLeft = 10;
      state.turnExtended = false;
      timerBox.textContent = state.timeLeft;
      if (state.online.enabled && state.current !== state.online.role) return;
      state.timerId = setInterval(() => {
        if (state.phase !== 'play') {
          stopTurnTimer();
          return;
        }
        state.timeLeft -= 1;
        timerBox.textContent = state.timeLeft;
        if (state.timeLeft > 0) return;
        if (!state.turnExtended) {
          state.turnExtended = true;
          state.timeLeft = 10;
          timerBox.textContent = state.timeLeft;
          addLog(t('timeExtend'));
          return;
        }
        passTurnByTimer();
      }, 1000);
    }

    function passTurnByTimer() {
      stopTurnTimer();
      state.selected = null;
      state.hintEdge = null;
      addLog(t('timePass'));
      state.current = state.current === 'red' ? 'blue' : 'red';
      updateUI();
      draw();
      startTurnTimer();
      if (state.gameMode === 'ai' && state.current === state.bot) setTimeout(botTurn, 520);
      if (state.online.enabled) {
        state.online.syncing = true;
        const payload = serializeGame();
        syncOnlineGameState(payload)
          .catch(() => addLog(t('onlineSyncError')))
          .finally(() => { state.online.syncing = false; });
      }
    }

    function addLog(message) {
      state.log.unshift(message);
      state.log = state.log.slice(0, 28);
      logEl.innerHTML = state.log.map(line => `<div class="logLine">${line}</div>`).join('');
    }

    function onlineApiBase() {
      const config = window.DOTCONQUEST_ONLINE || {};
      return (config.apiBase || '').replace(/\/$/, '');
    }

    function onlineAvailable() {
      return true;
    }

    function roomEndpoint(roomCode) {
      return `${onlineApiBase()}/api/rooms/${roomCode}`;
    }

    function normalizeRoomCode(value) {
      const raw = String(value || '').trim();
      const simple = raw.toUpperCase().replace(/[^A-Z0-9]/g, '');
      if (simple.length >= 4) return simple.slice(0, 8);
      return '';
    }

    function makeRoomCode() {
      const random = new Uint32Array(2);
      if (window.crypto && window.crypto.getRandomValues) {
        window.crypto.getRandomValues(random);
        return Array.from(random).map(value => value.toString(36).toUpperCase().padStart(6, '0')).join('').slice(0, 8);
      }
      return Math.random().toString(36).slice(2, 10).toUpperCase();
    }

    function beats(a, b) {
      return (a === 'rock' && b === 'scissors') || (a === 'paper' && b === 'rock') || (a === 'scissors' && b === 'paper');
    }

    function applyFriendRoles(redPlayerId, bluePlayerId) {
      state.online.redPlayerId = redPlayerId || '';
      state.online.bluePlayerId = bluePlayerId || '';
      if (!redPlayerId || !bluePlayerId || redPlayerId === bluePlayerId) {
        state.online.role = '';
        state.human = 'red';
        state.bot = 'blue';
        return;
      }
      if (state.online.playerId === redPlayerId) {
        state.online.role = 'red';
      } else if (state.online.playerId === bluePlayerId) {
        state.online.role = 'blue';
      } else {
        state.online.role = '';
      }
      if (state.online.role === 'blue') {
        state.human = 'blue';
        state.bot = 'red';
      } else if (state.online.role === 'red') {
        state.human = 'red';
        state.bot = 'blue';
      } else {
        state.human = 'red';
        state.bot = 'blue';
      }
    }

    function friendRoleIds(data) {
      const result = data?.rps?.result;
      if (result && !result.tie) {
        return {
          red: result.winner || result.red || data.red || '',
          blue: result.loser || result.blue || data.blue || ''
        };
      }
      return {
        red: data?.red || '',
        blue: data?.blue || ''
      };
    }

    function friendRpsOutcomeMessage(data) {
      const result = data?.rps?.result;
      if (!result) return '';
      if (result.tie) return t('friendRpsTie');
      const ids = friendRoleIds(data);
      const winner = ids.red;
      const loser = ids.blue;
      if (winner === state.online.playerId) return t('friendRpsWin');
      if (loser === state.online.playerId) return t('friendRpsLose');
      return '';
    }

    function serializeGame(version = Date.now()) {
      return {
        version,
        dotCount: state.dotCount,
        boardType: state.boardType,
        difficulty: state.difficulty,
        matchStartLevel: state.matchStartLevel,
        dotSkin: state.dotSkin,
        guideMode: state.guideMode,
        points: state.points,
        triangles: state.triangles.map(tri => ({ ids: tri.ids, area: tri.area, owner: tri.owner || null })),
        edges: state.edges.map(edge => ({ a: edge.a, b: edge.b, owner: edge.owner || null })),
        current: state.current,
        phase: state.phase,
        rps: state.rps,
        result: state.result,
        updatedAt: Date.now()
      };
    }

    function hydrateGame(data) {
      if (!data || !Array.isArray(data.points) || !Array.isArray(data.triangles) || !Array.isArray(data.edges)) return;
      const wasRps = state.phase === 'rps';
      stopTurnTimer();
      state.dotCount = data.dotCount || state.dotCount;
      state.boardType = data.boardType === 'star' ? 'star' : 'triangle';
      state.matchStartLevel = Math.max(1, Math.min(100, Number(data.matchStartLevel || data.difficulty || state.matchStartLevel || state.difficulty || 1)));
      if (state.phase !== 'over' && data.phase !== 'over') {
        state.difficulty = data.difficulty || state.difficulty;
      }
      state.dotSkin = data.dotSkin || state.dotSkin;
      state.guideMode = data.guideMode || state.guideMode;
      state.points = data.points;
      state.triangles = data.triangles.map(tri => ({ ids: tri.ids, area: tri.area, owner: tri.owner || null }));
      state.edges = data.edges.map((edge, index) => ({ id: index, a: edge.a, b: edge.b, owner: edge.owner || null, tris: [] }));
      state.edgeMap = new Map();
      state.edges.forEach(edge => state.edgeMap.set(makeKey(edge.a, edge.b), edge));
      state.triangles.forEach((tri, triIdx) => {
        tri.ids.map((id, index) => [id, tri.ids[(index + 1) % tri.ids.length]]).forEach(([a, b]) => {
          const edge = state.edgeMap.get(makeKey(a, b));
          if (edge && !edge.tris.includes(triIdx)) edge.tris.push(triIdx);
        });
      });
      state.current = data.current || 'red';
      state.phase = data.phase || 'play';
      state.rps = data.rps || { choices: {}, result: null };
      state.result = data.result || null;
      const roleIds = friendRoleIds(data);
      if (roleIds.red || roleIds.blue) applyFriendRoles(roleIds.red, roleIds.blue);
      state.selected = null;
      state.hintEdge = null;
      state.online.lastVersion = data.version || Date.now();
      updateUI();
      draw();
      if (state.gameMode === 'friend' && data.phase === 'rps') {
        state.online.role = '';
        state.online.redPlayerId = '';
        state.online.bluePlayerId = '';
        state.online.rpsChoice = '';
        if (data.rps && data.rps.result && data.rps.result.tie) {
          rpsResult.textContent = t('friendRpsTie');
        } else {
          rpsResult.textContent = t('friendRpsWaiting');
        }
        rpsOverlay.classList.remove('hidden');
      } else if (wasRps && data.phase === 'rps' && data.rps && data.rps.result && data.rps.result.tie) {
        rpsResult.textContent = t('friendRpsTie');
        rpsOverlay.classList.remove('hidden');
      } else if (wasRps && data.phase === 'play' && data.rps && data.rps.result && data.rps.result.red) {
        const message = friendRpsOutcomeMessage(data);
        rpsResult.textContent = message;
        if (message) addLog(message);
        rpsOverlay.classList.remove('hidden');
        setTimeout(() => {
          rpsOverlay.classList.add('hidden');
        }, 900);
      } else if (state.phase === 'over') {
        const redScore = score('red');
        const blueScore = score('blue');
        const compare = redScore.area === blueScore.area ? redScore.tiles - blueScore.tiles : redScore.area - blueScore.area;
        const outcome = state.result?.outcome || (compare > 0 ? 'red' : compare < 0 ? 'blue' : 'draw');
        showGameOver(outcome, redScore, blueScore, state.result?.progressMessage || '');
        rpsOverlay.classList.add('hidden');
      } else {
        rpsOverlay.classList.toggle('hidden', state.phase !== 'rps');
      }
      if (state.phase === 'play') startTurnTimer();
    }

    async function writeRoom(payload) {
      if (!state.online.enabled || !onlineAvailable()) return;
      const response = await fetch(roomEndpoint(state.online.roomCode), {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        cache: 'no-store',
        body: JSON.stringify(payload)
      });
      if (!response.ok) throw new Error('Room write failed');
    }

    async function syncOnlineGameState(payload) {
      const existing = await readRoom();
      const merged = {
        ...(existing || {}),
        ...payload,
        host: existing?.host || payload.host,
        guest: existing?.guest || payload.guest,
        red: existing?.red || payload.red,
        blue: existing?.blue || payload.blue,
        rps: existing?.rps || payload.rps,
        version: payload.version || Date.now()
      };
      await writeRoom(merged);
      state.online.lastVersion = merged.version;
    }

    async function readRoom() {
      if (!state.online.enabled || !onlineAvailable()) return null;
      const response = await fetch(roomEndpoint(state.online.roomCode), { cache: 'no-store' });
      if (!response.ok) throw new Error('Room read failed');
      return response.json();
    }

    async function patchRoom(patch) {
      if (!state.online.enabled || !onlineAvailable()) return null;
      const response = await fetch(roomEndpoint(state.online.roomCode), {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(patch)
      });
      if (!response.ok) throw new Error('Room patch failed');
      return response.json();
    }

    function sleep(ms) {
      return new Promise(resolve => setTimeout(resolve, ms));
    }

    async function waitForFriendRpsResult() {
      for (let attempt = 0; attempt < 30; attempt++) {
        await sleep(650);
        const latest = await readRoom().catch(() => null);
        if (!latest) continue;
        if (latest.phase === 'play' || latest.rps?.result?.tie) {
          hydrateGame(latest);
          const message = friendRpsOutcomeMessage(latest);
          if (message) {
            rpsResult.textContent = message;
            addLog(message);
          }
          return true;
        }
      }
      return false;
    }

    function stopOnlinePolling() {
      if (state.online.pollId) clearInterval(state.online.pollId);
      state.online.pollId = null;
    }

    function startOnlinePolling() {
      stopOnlinePolling();
      state.online.pollId = setInterval(async () => {
        if (state.online.syncing) return;
        try {
          const data = await readRoom();
          if (!data) return;
          const roleIds = friendRoleIds(data);
          if (roleIds.red || roleIds.blue) applyFriendRoles(roleIds.red, roleIds.blue);
          if ((data.version || 0) > state.online.lastVersion) hydrateGame(data);
          onlineStatus.textContent = state.current === state.online.role ? t('onlineTurn') : t('waitingFriend');
        } catch (error) {
          addLog(t('onlineSyncError'));
        }
      }, 1400);
    }

    async function createOnlineRoom() {
      if (!isFreeConfig({ gameMode: 'friend' })) {
        openPremiumModal(premiumReason({ gameMode: 'friend' }));
        updateUI();
        return;
      }
      state.gameMode = 'friend';
      const code = makeRoomCode();
      state.online.enabled = true;
      state.online.roomCode = code;
      state.online.role = '';
      state.online.seat = 'host';
      state.online.host = true;
      roomInput.value = code;
      roomCodeLabel.textContent = code;
      newGame(false);
      state.phase = 'waiting';
      stopTurnTimer();
      rpsOverlay.classList.add('hidden');
      const payload = serializeGame();
      payload.host = state.online.playerId;
      payload.guest = null;
      payload.red = null;
      payload.blue = null;
      payload.phase = 'waiting';
      payload.rps = { choices: {}, result: null };
      await writeRoom(payload);
      addLog(`${t('roomCreated')} ${code}`);
      addLog(t('roomWaiting'));
      startOnlinePolling();
      updateUI();
      onlineStatus.textContent = t('roomWaiting');
    }

    async function joinOnlineRoom() {
      if (!isFreeConfig({ gameMode: 'friend' })) {
        openPremiumModal(premiumReason({ gameMode: 'friend' }));
        updateUI();
        return;
      }
      const code = normalizeRoomCode(roomInput.value);
      if (!code) {
        addLog(t('roomMissing'));
        return;
      }
      state.gameMode = 'friend';
      try {
        state.online.enabled = true;
        state.online.roomCode = code;
        state.online.role = '';
        state.online.seat = 'guest';
        state.online.host = false;
        const data = await readRoom();
        if (!data) throw new Error('Room not found');
        if (data.host && data.host === state.online.playerId) {
          resetOnlinePlayerId();
        }
        const joined = await patchRoom({ type: 'join', playerId: state.online.playerId });
        roomCodeLabel.textContent = code;
        hydrateGame(joined);
        openFriendRps(t('friendRpsWaiting'));
        addLog(`${t('roomJoined')} ${code}`);
        addLog(t('gameStarted'));
        startOnlinePolling();
      } catch (error) {
        state.online.enabled = false;
        addLog(t('onlineSyncError'));
      }
      updateUI();
      if (state.online.enabled) onlineStatus.textContent = t('gameStarted');
    }

    async function startOnlineNewMatch() {
      if (!state.online.enabled || state.gameMode !== 'friend') {
        newGame(true);
        return;
      }
      const existing = await readRoom().catch(() => null);
      newGame(true);
      state.phase = 'rps';
      const payload = {
        ...(existing || {}),
        ...serializeGame(),
        host: existing?.host || (state.online.host ? state.online.playerId : undefined),
        guest: existing?.guest || (!state.online.host ? state.online.playerId : undefined),
        red: null,
        blue: null,
        rps: { choices: {}, result: null },
        version: Date.now()
      };
      await writeRoom(payload);
      openFriendRps(t('friendRpsWaiting'));
      addLog(t('friendChoose'));
      startOnlinePolling();
    }

    async function handleFriendRps(choice) {
      if (!state.online.enabled) {
        const first = state.rps.firstChoice;
        if (!first) {
          state.rps.firstChoice = choice;
          rpsResult.textContent = t('friendRpsWaiting');
          return;
        }
        if (first === choice) {
          state.rps.firstChoice = null;
          rpsResult.textContent = t('friendRpsTie');
          return;
        }
        const firstWins = beats(first, choice);
        state.human = 'red';
        state.bot = 'blue';
        state.current = 'red';
        state.phase = 'play';
        rpsResult.textContent = firstWins ? t('friendRpsFriendWin') : t('friendRpsFriendLose');
        setTimeout(() => {
          rpsOverlay.classList.add('hidden');
          updateUI();
          draw();
          startTurnTimer();
        }, 700);
        return;
      }

      if (state.online.role) {
        const latest = await readRoom().catch(() => null);
        rpsResult.textContent = friendRpsOutcomeMessage(latest) || t('friendRpsWaiting');
        return;
      }

      if (state.online.rpsChoice && state.phase === 'rps') {
        rpsResult.textContent = t('friendRpsWaiting');
        return;
      }

      state.online.syncing = true;
      try {
        let data = await readRoom();
        if (!data) throw new Error('Room not found');
        const host = data.host || (state.online.host ? state.online.playerId : null);
        const guest = data.guest || (!state.online.host ? state.online.playerId : null);
        if (!host || !guest) {
          rpsResult.textContent = t('friendRpsWaiting');
          return;
        }
        state.online.rpsChoice = choice;
        data = await patchRoom({ type: 'rpsChoice', playerId: state.online.playerId, choice });

        if (data.phase === 'rps' && data.rps && data.rps.result && data.rps.result.tie) {
          state.online.rpsChoice = '';
          state.rps = data.rps;
          rpsResult.textContent = t('friendRpsTie');
          hydrateGame(data);
          return;
        }

        if (data.phase === 'rps') {
          hydrateGame(data);
          rpsResult.textContent = t('friendRpsWaiting');
          await waitForFriendRpsResult();
          return;
        }

        hydrateGame(data);
        const message = friendRpsOutcomeMessage(data);
        rpsResult.textContent = message;
        if (message) addLog(message);
        setTimeout(() => {
          rpsOverlay.classList.add('hidden');
          updateUI();
          draw();
          startTurnTimer();
        }, 700);
      } catch (error) {
        state.online.rpsChoice = '';
        addLog(t('onlineSyncError'));
      } finally {
        state.online.syncing = false;
      }
    }

    function availableEdges() {
      return state.edges.filter(edge => !edge.owner);
    }

    function edgeValue(edge, player, level = state.difficulty, depth = 0) {
      let value = 0;
      const opponent = player === 'red' ? 'blue' : 'red';
      const skill = level / 100;
      edge.tris.forEach(triIdx => {
        const tri = state.triangles[triIdx];
        if (tri.owner) return;
        const owners = tileEdgeOwners(tri);
        const mine = owners.filter(x => x === player).length;
        const theirs = owners.filter(x => x === opponent).length;
        const open = owners.filter(x => !x).length;
        const reserveNeed = reserveThreshold(tri);
        if (mine >= reserveNeed - 1) value += (380 + level * 8) + tri.area * (0.8 + skill);
        if (theirs >= reserveNeed - 1) value += (260 + level * 6) + tri.area * (0.35 + skill * 0.8);
        if (mine === reserveNeed - 1 && open >= 1) value += level * 4;
        if (theirs === reserveNeed - 1 && open >= 1) value += level * 3.3;
        value += tri.area * (0.006 + skill * 0.025);
      });
      if (level >= 71 && depth === 0) value -= opponentBestReplyPenalty(edge, player, level);
      if (level >= 91) value += edge.tris.length * 14 + centerPressure(edge) * 0.08;
      return value + Math.random() * Math.max(2, 80 - level);
    }

    function centerPressure(edge) {
      const rect = canvas.getBoundingClientRect();
      const a = state.points[edge.a];
      const b = state.points[edge.b];
      const mx = (a.x + b.x) / 2;
      const my = (a.y + b.y) / 2;
      const d = Math.hypot(mx - rect.width / 2, my - rect.height / 2);
      return Math.max(0, Math.max(rect.width, rect.height) - d);
    }

    function opponentBestReplyPenalty(edge, player, level) {
      edge.owner = player;
      const opponent = player === 'red' ? 'blue' : 'red';
      const replies = availableEdges()
        .map(move => edgeValue(move, opponent, Math.min(100, level + 6), 1))
        .sort((a, b) => b - a);
      edge.owner = null;
      return (replies[0] || 0) * (level >= 91 ? 0.24 : 0.14);
    }

    function bestMove(player) {
      const moves = availableEdges();
      if (!moves.length) return null;
      const level = state.difficulty;
      const randomRate = Math.max(0.02, 0.62 - level * 0.0058);
      if (level <= 10 && Math.random() < randomRate) return moves[Math.floor(Math.random() * moves.length)];
      const scored = moves.map(edge => ({ edge, value: edgeValue(edge, player, level) })).sort((a, b) => b.value - a.value);
      if (Math.random() < randomRate && scored.length > 1) {
        const spread = level < 31 ? 5 : level < 71 ? 3 : 2;
        return scored[Math.min(scored.length - 1, Math.floor(Math.random() * spread))].edge;
      }
      return scored[0].edge;
    }

    function reserveThreshold(tile) {
      return Math.max(2, tile.ids.length - 1);
    }

    function tileEdgeOwners(tile) {
      return tile.ids.map((id, index) => {
        const next = tile.ids[(index + 1) % tile.ids.length];
        return state.edgeMap.get(makeKey(id, next)).owner;
      });
    }

    function applyMove(edge, player, syncOnline = true) {
      if (!edge || edge.owner || state.phase !== 'play') return false;
      stopTurnTimer();
      edge.owner = player;
      state.hintEdge = null;
      let captured = 0;
      edge.tris.forEach(triIdx => {
        const tri = state.triangles[triIdx];
        if (tri.owner) return;
        const owners = tileEdgeOwners(tri);
        const redCount = owners.filter(x => x === 'red').length;
        const blueCount = owners.filter(x => x === 'blue').length;
        const threshold = reserveThreshold(tri);
        if (redCount >= threshold) {
          tri.owner = 'red';
          captured++;
        } else if (blueCount >= threshold) {
          tri.owner = 'blue';
          captured++;
        }
      });
      const name = state.gameMode === 'friend'
        ? (player === 'red' ? t('red') : t('blue'))
        : (player === state.human ? t('you') : t('bot'));
      addLog(`${name} ${t('claimed')} ${captured} ${t('tiles')}`);
      state.current = player === 'red' ? 'blue' : 'red';
      checkEnd();
      updateUI();
      draw();
      if (state.phase === 'play') {
        startTurnTimer();
        if (state.gameMode === 'ai' && state.current === state.bot) setTimeout(botTurn, 520);
      }
      if (state.online.enabled && syncOnline) {
        state.online.syncing = true;
        const payload = serializeGame();
        payload.red = state.online.role === 'red' ? state.online.playerId : undefined;
        payload.blue = state.online.role === 'blue' ? state.online.playerId : undefined;
        syncOnlineGameState(payload)
          .catch(() => addLog(t('onlineSyncError')))
          .finally(() => { state.online.syncing = false; });
      }
      return true;
    }

    function botTurn() {
      if (state.phase !== 'play' || state.current !== state.bot) return;
      const move = bestMove(state.bot);
      if (move) applyMove(move, state.bot);
    }

    function score(owner) {
      const owned = state.triangles.filter(tri => tri.owner === owner);
      return {
        area: Math.round(owned.reduce((sum, tri) => sum + tri.area, 0) / 100),
        tiles: owned.length
      };
    }

    function checkEnd() {
      if (availableEdges().length) return;
      state.phase = 'over';
      stopTurnTimer();
      if (state.gameMode === 'friend') {
        const redScore = score('red');
        const blueScore = score('blue');
        const redValue = redScore.area;
        const blueValue = blueScore.area;
        const compare = redValue === blueValue ? redScore.tiles - blueScore.tiles : redValue - blueValue;
        const outcome = compare > 0 ? 'red' : compare < 0 ? 'blue' : 'draw';
        if (outcome === 'red') addLog(t('redWinTitle'));
        else if (outcome === 'blue') addLog(t('blueWinTitle'));
        else addLog(t('winnerTie'));
        const progressMessage = updateFriendProgress(outcome);
        state.result = { outcome, progressMessage };
        showGameOver(outcome, redScore, blueScore, progressMessage);
        return;
      }
      const humanScore = score(state.human);
      const botScore = score(state.bot);
      const human = humanScore.area;
      const bot = botScore.area;
      const compare = human === bot ? humanScore.tiles - botScore.tiles : human - bot;
      let outcome = 'draw';
      if (compare > 0) {
        outcome = 'you';
        addLog(state.gameMode === 'friend' && state.human === 'red' ? t('redWinTitle') : t('winnerYou'));
      } else if (compare < 0) {
        outcome = 'bot';
        addLog(state.gameMode === 'friend' && state.bot === 'blue' ? t('blueWinTitle') : t('winnerBot'));
      } else {
        addLog(t('winnerTie'));
      }
      const progressMessage = updateAiProgress(outcome);
      showGameOver(outcome, humanScore, botScore, progressMessage);
    }

    function showGameOver(outcome, humanScore, botScore, progressMessage = '') {
      let titleKey = outcome === 'you' ? 'youWinTitle' : outcome === 'bot' ? 'botWinTitle' : 'drawTitle';
      let bodyKey = outcome === 'you' ? 'youWinBody' : outcome === 'bot' ? 'botWinBody' : 'drawBody';
      const finalLeftLabel = document.getElementById('finalLeftLabel');
      const finalRightLabel = document.getElementById('finalRightLabel');
      const winnerTitle = document.getElementById('winnerTitle');
      const finalLabels = [finalLeftLabel, finalRightLabel];
      finalLabels.forEach(element => element.classList.remove('roleRed', 'roleBlue'));
      winnerTitle.classList.remove('roleRed', 'roleBlue');
      if (state.gameMode === 'friend') {
        titleKey = outcome === 'red' ? 'redWinTitle' : outcome === 'blue' ? 'blueWinTitle' : 'drawTitle';
        bodyKey = outcome === 'red' ? 'redWinBody' : outcome === 'blue' ? 'blueWinBody' : 'drawBody';
        finalLeftLabel.textContent = t('red');
        finalRightLabel.textContent = t('blue');
        finalLeftLabel.classList.add('roleRed');
        finalRightLabel.classList.add('roleBlue');
        if (outcome === 'red') winnerTitle.classList.add('roleRed');
        if (outcome === 'blue') winnerTitle.classList.add('roleBlue');
      } else {
        finalLeftLabel.textContent = outcome === 'you' ? t('you') : t('you');
        finalRightLabel.textContent = t('bot');
        finalLeftLabel.classList.add(state.human === 'red' ? 'roleRed' : 'roleBlue');
        finalRightLabel.classList.add(state.bot === 'red' ? 'roleRed' : 'roleBlue');
      }
      winnerTitle.textContent = t(titleKey);
      document.getElementById('winnerBody').textContent = progressMessage ? `${t(bodyKey)} ${progressMessage}` : t(bodyKey);
      document.getElementById('finalHumanScore').textContent = `${humanScore.area} / ${humanScore.tiles}`;
      document.getElementById('finalBotScore').textContent = `${botScore.area} / ${botScore.tiles}`;
      gameOverOverlay.classList.remove('hidden');
    }

    function updateUI() {
      document.documentElement.lang = state.lang;
      langSelect.value = state.lang;
      document.querySelectorAll('[data-i18n]').forEach(el => {
        el.textContent = t(el.dataset.i18n);
      });
      document.querySelectorAll('[data-blog-link]').forEach(el => {
        el.href = '/blog/today/';
      });
      document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
        el.placeholder = t(el.dataset.i18nPlaceholder);
      });
      document.body.classList.toggle('night', state.theme === 'night');
      themeToggle.textContent = state.theme === 'night' ? '☀️' : '🌙';
      document.getElementById('rules').textContent = t('rules');
      document.querySelector('[data-i18n="rpsBody"]').textContent = state.gameMode === 'friend' ? t('friendRpsBody') : t('rpsBody');
      const activeModeText = state.gameMode === 'ai' ? t('aiModeLabel') : t('friendModeLabel');
      topModeTitle.textContent = activeModeText;
      modeLabel.textContent = activeModeText;
      setupModeBadge.textContent = state.gameMode === 'ai' ? 'AI' : '2P';
      friendModeHelp.classList.toggle('hidden', state.gameMode !== 'friend');
      if (!state.online.enabled) onlineStatus.textContent = t('offlineReady');
      if (state.online.enabled) {
        onlineStatus.textContent = state.online.role && state.current === state.online.role ? t('onlineTurn') : t('waitingFriend');
      }
      friendRoleStatus.classList.toggle('hidden', state.gameMode !== 'friend');
      if (state.gameMode === 'friend') {
        if (state.online.role === 'red') {
          friendRoleStatus.textContent = t('friendRoleRed');
        } else if (state.online.role === 'blue') {
          friendRoleStatus.textContent = t('friendRoleBlue');
        } else {
          friendRoleStatus.textContent = t('friendRoleChoosing');
        }
      }
      if (!state.online.roomCode) roomCodeLabel.textContent = t('roomHelp');
      modeChoices.querySelectorAll('button').forEach(button => {
        button.classList.toggle('active', button.dataset.mode === state.gameMode);
      });
      boardTypeLabel.textContent = state.boardType === 'star' ? t('starModeLabel') : t('triangleModeLabel');
      triangleGuidePanel.classList.toggle('hidden', state.boardType === 'star');
      triangleGraphPanel.classList.toggle('hidden', state.boardType === 'star');
      starGuidePanel.classList.toggle('hidden', state.boardType !== 'star');
      boardTypeChoices.querySelectorAll('button').forEach(button => {
        button.classList.toggle('active', button.dataset.boardType === state.boardType);
      });
      dotCountLabel.textContent = state.dotCount;
      aiLevelInput.value = state.difficulty;
      aiLevelInput.disabled = false;
      aiLevelBox.textContent = state.difficulty;
      aiTierLabel.textContent = `${t('level')} ${state.difficulty}`;
      guideModeLabel.textContent = t(`guide${state.guideMode[0].toUpperCase()}${state.guideMode.slice(1)}`);
      guideChoices.querySelectorAll('[data-guide]').forEach(button => {
        button.classList.toggle('active', button.dataset.guide === state.guideMode);
      });
      dotChoices.querySelectorAll('button').forEach(button => {
        const dots = Number(button.dataset.dots);
        const hiddenForStar = state.boardType === 'star' && !STAR_DOT_COUNTS.includes(dots);
        button.hidden = hiddenForStar;
        button.setAttribute('aria-hidden', hiddenForStar ? 'true' : 'false');
        button.classList.toggle('active', dots === state.dotCount);
      });
      skinChoices.querySelectorAll('[data-skin]').forEach(button => {
        button.classList.toggle('active', button.dataset.skin === state.dotSkin);
        button.classList.remove('locked');
      });
      const redFixedScore = score('red');
      const blueFixedScore = score('blue');
      const humanScore = score(state.human);
      const botScore = score(state.bot);
      let leftLabel = t('you');
      let rightLabel = t('bot');
      let leftRoleColor = state.human;
      let rightRoleColor = state.bot;
      let leftScoreData = humanScore;
      let rightScoreData = botScore;
      if (state.gameMode === 'friend') {
        leftLabel = t('red');
        rightLabel = t('blue');
        leftRoleColor = 'red';
        rightRoleColor = 'blue';
        leftScoreData = redFixedScore;
        rightScoreData = blueFixedScore;
      }
      const leftScoreCard = document.getElementById('leftScoreCard');
      const rightScoreCard = document.getElementById('rightScoreCard');
      const leftScoreLabel = document.getElementById('leftScoreLabel');
      const rightScoreLabel = document.getElementById('rightScoreLabel');
      [leftScoreCard, rightScoreCard, leftScoreLabel, rightScoreLabel].forEach(element => {
        element.classList.remove('roleRed', 'roleBlue');
      });
      leftScoreCard.classList.add(leftRoleColor === 'red' ? 'roleRed' : 'roleBlue');
      rightScoreCard.classList.add(rightRoleColor === 'red' ? 'roleRed' : 'roleBlue');
      leftScoreLabel.classList.add(leftRoleColor === 'red' ? 'roleRed' : 'roleBlue');
      rightScoreLabel.classList.add(rightRoleColor === 'red' ? 'roleRed' : 'roleBlue');
      leftScoreLabel.textContent = leftLabel;
      rightScoreLabel.textContent = rightLabel;
      document.getElementById('redScore').textContent = leftScoreData.area;
      document.getElementById('blueScore').textContent = rightScoreData.area;
      document.getElementById('redLand').textContent = `${leftScoreData.tiles} ${t('tiles')} · ${t('red')}`;
      document.getElementById('blueLand').textContent = `${rightScoreData.tiles} ${t('tiles')} · ${t('blue')}`;
      const turnName = document.getElementById('turnName');
      if (state.phase === 'rps') {
        turnName.textContent = state.gameMode === 'friend' ? t('friendMatch') : t('rps');
        timerBox.textContent = '--';
      } else if (state.phase === 'waiting') {
        turnName.textContent = t('waitingFriend');
        timerBox.textContent = '--';
      } else if (state.phase === 'over') {
        turnName.textContent = t('gameOver');
        timerBox.textContent = '--';
      } else {
        timerBox.textContent = state.timeLeft;
        const humanTurn = state.current === state.human;
        if (state.gameMode === 'friend') {
          if (state.online.role === 'red') {
            turnName.textContent = state.current === 'red' ? t('friendTurnRedFirst') : t('blueTurn');
          } else if (state.online.role === 'blue') {
            turnName.textContent = state.current === 'red' ? t('friendTurnBlueSecond') : t('blueTurn');
          } else {
            turnName.textContent = state.current === 'red' ? t('redTurn') : t('blueTurn');
          }
        } else {
          turnName.textContent = humanTurn ? t('yourTurn') : t('botTurn');
        }
      }
    }

    function draw() {
      const rect = canvas.getBoundingClientRect();
      ctx.clearRect(0, 0, rect.width, rect.height);
      ctx.save();

      state.triangles.forEach(tri => {
        if (!tri.owner) return;
        const pts = tri.ids.map(id => state.points[id]);
        ctx.beginPath();
        ctx.moveTo(pts[0].x, pts[0].y);
        pts.slice(1).forEach(point => ctx.lineTo(point.x, point.y));
        ctx.closePath();
        ctx.fillStyle = tri.owner === 'red' ? 'rgba(226,58,58,0.28)' : 'rgba(32,105,216,0.28)';
        ctx.fill();
      });

      state.edges.forEach(edge => {
        if (!edge.owner && !shouldShowGuideEdge(edge)) return;
        const a = state.points[edge.a];
        const b = state.points[edge.b];
        const isHint = state.hintEdge === edge;
        ctx.beginPath();
        ctx.moveTo(a.x, a.y);
        ctx.lineTo(b.x, b.y);
        ctx.lineWidth = edge.owner ? 4 : isHint ? 3 : 1.1;
        ctx.strokeStyle = edge.owner === 'red' ? '#e23a3a' : edge.owner === 'blue' ? '#2069d8' : isHint ? '#188b63' : state.theme === 'night' ? '#48494c' : '#deddd4';
        ctx.setLineDash(edge.owner || isHint ? [] : [3, 7]);
        ctx.stroke();
      });
      ctx.setLineDash([]);

      if (state.selected !== null) {
        const p = state.points[state.selected];
        ctx.beginPath();
        ctx.arc(p.x, p.y, 13, 0, Math.PI * 2);
        ctx.strokeStyle = state.theme === 'night' ? '#f4f1e8' : '#151515';
        ctx.lineWidth = 3;
        ctx.stroke();
      }

      state.points.forEach((p, idx) => {
        const selected = idx === state.selected;
        drawDot(p.x, p.y, selected);
      });

      ctx.restore();
    }

    function shouldShowGuideEdge(edge) {
      if (edge.owner || state.hintEdge === edge) return true;
      if (state.guideMode === 'expert') return false;
      if (state.guideMode === 'normal') {
        return state.selected !== null && (edge.a === state.selected || edge.b === state.selected);
      }
      return true;
    }

    function drawDot(x, y, selected) {
      const size = selected ? 7.5 : 5.8;
      ctx.save();
      ctx.translate(x, y);
      ctx.lineWidth = selected ? 3 : 2;
      const ink = state.theme === 'night' ? '#f4f1e8' : '#151515';
      const paper = state.theme === 'night' ? '#1a1b1c' : '#fffefb';
      ctx.strokeStyle = ink;
      ctx.fillStyle = selected ? ink : paper;
      ctx.beginPath();
      if (state.dotSkin === 'square') {
        ctx.rect(-size, -size, size * 2, size * 2);
      } else if (state.dotSkin === 'diamond') {
        ctx.moveTo(0, -size * 1.25);
        ctx.lineTo(size * 1.25, 0);
        ctx.lineTo(0, size * 1.25);
        ctx.lineTo(-size * 1.25, 0);
        ctx.closePath();
      } else if (state.dotSkin === 'star') {
        for (let i = 0; i < 10; i++) {
          const r = i % 2 ? size * 0.55 : size * 1.35;
          const a = -Math.PI / 2 + i * Math.PI / 5;
          const px = Math.cos(a) * r;
          const py = Math.sin(a) * r;
          if (i === 0) ctx.moveTo(px, py);
          else ctx.lineTo(px, py);
        }
        ctx.closePath();
      } else if (state.dotSkin === 'pixel') {
        ctx.rect(-size, -size, size * 0.95, size * 0.95);
        ctx.rect(0, -size, size * 0.95, size * 0.95);
        ctx.rect(-size, 0, size * 0.95, size * 0.95);
        ctx.rect(0, 0, size * 0.95, size * 0.95);
      } else if (state.dotSkin === 'ring') {
        ctx.arc(0, 0, size * 1.08, 0, Math.PI * 2);
      } else if (state.dotSkin === 'hex') {
        for (let i = 0; i < 6; i++) {
          const a = -Math.PI / 6 + i * Math.PI / 3;
          const px = Math.cos(a) * size * 1.25;
          const py = Math.sin(a) * size * 1.25;
          if (i === 0) ctx.moveTo(px, py);
          else ctx.lineTo(px, py);
        }
        ctx.closePath();
      } else {
        ctx.arc(0, 0, size, 0, Math.PI * 2);
      }
      if (state.dotSkin === 'neon') {
        ctx.shadowBlur = 16;
        ctx.shadowColor = '#32ffc8';
        ctx.fillStyle = selected ? ink : '#32ffc8';
      }
      if (state.dotSkin === 'ink') {
        ctx.scale(1.15, 0.9);
      }
      if (state.dotSkin === 'planet') {
        ctx.fillStyle = selected ? ink : '#2069d8';
      }
      if (state.dotSkin === 'ring') {
        ctx.fillStyle = selected ? ink : paper;
        ctx.lineWidth = selected ? 4 : 3;
      }
      ctx.fill();
      ctx.stroke();
      if (state.dotSkin === 'planet' && !selected) {
        ctx.beginPath();
        ctx.arc(-2, -2, size * 0.35, 0, Math.PI * 2);
        ctx.fillStyle = 'rgba(255,255,255,0.72)';
        ctx.fill();
      }
      ctx.restore();
    }

    function nearestPoint(x, y) {
      let best = null;
      let bestDist = 18;
      state.points.forEach((p, idx) => {
        const d = Math.hypot(p.x - x, p.y - y);
        if (d < bestDist) {
          bestDist = d;
          best = idx;
        }
      });
      return best;
    }

    function boardPick(clientX, clientY) {
      if (state.phase !== 'play') return;
      if (state.gameMode === 'ai' && state.current !== state.human) return;
      if (state.online.enabled && state.current !== state.online.role) {
        if (state.gameMode === 'friend' && state.online.role === 'blue') {
          addLog(t('notYourTurnFriendBlue'));
          onlineStatus.textContent = t('notYourTurnFriendBlue');
        } else if (state.gameMode === 'friend' && state.online.role === 'red') {
          addLog(t('notYourTurnFriendRed'));
          onlineStatus.textContent = t('notYourTurnFriendRed');
        } else {
          addLog(t('notYourTurn'));
        }
        return;
      }
      const rect = canvas.getBoundingClientRect();
      const idx = nearestPoint(clientX - rect.left, clientY - rect.top);
      if (idx === null) return;
      if (state.selected === null) {
        state.selected = idx;
        draw();
        return;
      }
      if (state.selected === idx) {
        state.selected = null;
        draw();
        return;
      }
      const edge = state.edgeMap.get(makeKey(state.selected, idx));
      state.selected = null;
      if (!edge || edge.owner) {
        addLog(t('invalid'));
        draw();
        return;
      }
      applyMove(edge, state.gameMode === 'friend' ? state.current : state.human);
    }

    canvas.addEventListener('click', event => {
      if (Date.now() < state.suppressClickUntil) return;
      boardPick(event.clientX, event.clientY);
    });

    canvas.addEventListener('pointerdown', event => {
      if (event.pointerType === 'mouse') return;
      event.preventDefault();
      state.suppressClickUntil = Date.now() + 450;
      boardPick(event.clientX, event.clientY);
    }, { passive: false });

    document.getElementById('newGame').addEventListener('click', () => {
      gameOverOverlay.classList.add('hidden');
      if (state.online.enabled && state.gameMode === 'friend') {
        startOnlineNewMatch().catch(() => addLog(t('onlineSyncError')));
      } else {
        newGame(true);
      }
    });
    document.getElementById('playAgain').addEventListener('click', () => {
      gameOverOverlay.classList.add('hidden');
      if (state.online.enabled && state.gameMode === 'friend') {
        startOnlineNewMatch().catch(() => addLog(t('onlineSyncError')));
      } else {
        newGame(true);
      }
    });
    document.getElementById('hint').addEventListener('click', () => {
      if (state.phase !== 'play') return;
      if (state.gameMode === 'ai' && state.current !== state.human) return;
      state.hintEdge = bestMove(state.current);
      addLog(t('hintMsg'));
      draw();
    });

    modeChoices.addEventListener('click', event => {
      const button = event.target.closest('[data-mode]');
      if (!button) return;
      const requestedMode = button.dataset.mode;
      if (!isFreeConfig({ gameMode: requestedMode })) {
        openPremiumModal(premiumReason({ gameMode: requestedMode }));
        updateUI();
        return;
      }
      state.gameMode = requestedMode;
      state.online.enabled = false;
      state.online.roomCode = '';
      state.online.role = '';
      state.online.seat = '';
      state.online.redPlayerId = '';
      state.online.bluePlayerId = '';
      state.online.host = false;
      roomCodeLabel.textContent = t('roomHelp');
      onlineStatus.textContent = t('offlineReady');
      newGame(state.gameMode === 'ai');
    });

    boardTypeChoices.addEventListener('click', event => {
      const button = event.target.closest('[data-board-type]');
      if (!button) return;
      const requestedBoardType = button.dataset.boardType;
      if (!isFreeConfig({ boardType: requestedBoardType })) {
        openPremiumModal(premiumReason({ boardType: requestedBoardType }));
        if (requestedBoardType === 'star') {
          state.gameMode = 'ai';
          state.dotCount = STAR_DOT_COUNTS.includes(state.dotCount) ? state.dotCount : 20;
          if (state.difficulty > STAR_FREE_MAX_LEVEL) setAiLevel(STAR_FREE_MAX_LEVEL);
          state.guideMode = 'beginner';
        }
      }
      state.boardType = requestedBoardType;
      state.online.enabled = false;
      state.online.roomCode = '';
      state.online.role = '';
      newGame(state.gameMode === 'ai');
    });

    createRoomButton.addEventListener('click', () => {
      createOnlineRoom().catch(() => addLog(t('onlineSyncError')));
    });

    joinRoomButton.addEventListener('click', () => {
      joinOnlineRoom().catch(() => addLog(t('onlineSyncError')));
    });

    dotChoices.addEventListener('click', event => {
      const button = event.target.closest('[data-dots]');
      if (!button) return;
      const requestedDotCount = Number(button.dataset.dots);
      if (state.boardType === 'star' && !STAR_DOT_COUNTS.includes(requestedDotCount)) {
        state.dotCount = 20;
        updateUI();
        return;
      }
      if (!isFreeConfig({ dotCount: requestedDotCount })) {
        openPremiumModal(premiumReason({ dotCount: requestedDotCount }));
        updateUI();
        return;
      }
      state.dotCount = requestedDotCount;
      if (state.online.enabled && state.gameMode === 'friend') {
        startOnlineNewMatch().catch(() => addLog(t('onlineSyncError')));
      } else {
        newGame(true);
      }
    });

    aiLevelInput.addEventListener('input', () => {
      if (!isFreeConfig({ difficulty: Number(aiLevelInput.value) })) {
        aiLevelBox.textContent = aiLevelInput.value;
        aiTierLabel.textContent = `${t('level')} ${aiLevelInput.value}`;
        return;
      }
      setAiLevel(aiLevelInput.value);
      updateUI();
    });

    aiLevelInput.addEventListener('change', () => {
      if (!isFreeConfig({ difficulty: Number(aiLevelInput.value) })) {
        openPremiumModal(premiumReason({ difficulty: Number(aiLevelInput.value) }));
        setAiLevel(state.boardType === 'star' ? STAR_FREE_MAX_LEVEL : FREE_MAX_LEVEL);
        updateUI();
        return;
      }
      setAiLevel(aiLevelInput.value);
      if (state.online.enabled && state.gameMode === 'friend') {
        startOnlineNewMatch().catch(() => addLog(t('onlineSyncError')));
      } else {
        updateUI();
      }
    });

    themeToggle.addEventListener('click', () => {
      state.theme = state.theme === 'night' ? 'day' : 'night';
      updateUI();
      draw();
    });

    guideChoices.addEventListener('click', event => {
      const button = event.target.closest('[data-guide]');
      if (!button) return;
      const requestedGuide = button.dataset.guide;
      if (!isFreeConfig({ guideMode: requestedGuide })) {
        openPremiumModal(premiumReason({ guideMode: requestedGuide }));
        updateUI();
        return;
      }
      state.guideMode = requestedGuide;
      updateUI();
      draw();
    });

    skinChoices.addEventListener('click', event => {
      const button = event.target.closest('[data-skin]');
      if (!button) return;
      const skin = button.dataset.skin;
      state.dotSkin = skin;
      updateUI();
      draw();
    });

    document.querySelectorAll('[data-info-modal]').forEach(button => {
      button.addEventListener('click', () => openInfoModal(button.dataset.infoModal));
    });

    document.querySelectorAll('[data-archive-link]').forEach(link => {
      link.addEventListener('click', event => {
        event.preventDefault();
        window.location.assign(`/blog/today/?view=archive&version=${encodeURIComponent(APP_VERSION)}&refresh=${Date.now()}`);
      });
    });

    infoBody.addEventListener('click', event => {
      const topicButton = event.target.closest('[data-blog-slug]');
      if (topicButton) {
        renderBlogArticle(topicButton.dataset.blogSlug);
        return;
      }
      const backButton = event.target.closest('[data-blog-back]');
      if (backButton) {
        infoTitle.textContent = (infoContent[state.lang].blog || infoContent.ko.blog).title;
        renderBlogList();
      }
    });

    closeInfo.addEventListener('click', closeInfoModal);
    infoOverlay.addEventListener('click', event => {
      if (event.target === infoOverlay) closeInfoModal();
    });

    window.addEventListener('keydown', event => {
      if (event.key === 'Escape' && !infoOverlay.classList.contains('hidden')) closeInfoModal();
    });

    langSelect.addEventListener('change', () => {
      state.lang = langSelect.value;
      localStorage.setItem('dotConquestLang', state.lang);
      document.documentElement.lang = state.lang;
      updateUI();
      if (activeInfoModal) openInfoModal(activeInfoModal);
      logEl.innerHTML = state.log.map(line => `<div class="logLine">${line}</div>`).join('');
    });

    document.querySelectorAll('[data-throw]').forEach(button => {
      button.addEventListener('click', async () => {
        const user = button.dataset.throw;
        if (state.gameMode === 'friend') {
          await handleFriendRps(user);
          return;
        }
        const choices = ['rock', 'paper', 'scissors'];
        const bot = choices[Math.floor(Math.random() * choices.length)];
        const wins = beats(user, bot);
        if (user === bot) {
          rpsResult.textContent = `${t('rpsTie')} (${t(user)} / ${t(bot)})`;
          return;
        }
        const userStarts = wins;
        state.human = userStarts ? 'red' : 'blue';
        state.bot = userStarts ? 'blue' : 'red';
        state.current = 'red';
        state.phase = 'play';
        rpsResult.textContent = userStarts ? t('winRps') : t('loseRps');
        addLog(userStarts ? t('winRps') : t('loseRps'));
        setTimeout(() => {
          rpsOverlay.classList.add('hidden');
          updateUI();
          draw();
          startTurnTimer();
          if (state.gameMode === 'ai' && state.current === state.bot) botTurn();
        }, 700);
      });
    });

    function isStandaloneApp() {
      return window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true;
    }

    window.addEventListener('beforeinstallprompt', event => {
      if (isStandaloneApp()) return;
      event.preventDefault();
      deferredInstallPrompt = event;
      installAppButton.classList.remove('hidden');
    });

    window.addEventListener('appinstalled', () => {
      deferredInstallPrompt = null;
      installAppButton.classList.add('hidden');
    });

    installAppButton.addEventListener('click', async () => {
      if (!deferredInstallPrompt) {
        addLog(t('installUnsupported'));
        return;
      }
      deferredInstallPrompt.prompt();
      await deferredInstallPrompt.userChoice;
      deferredInstallPrompt = null;
      installAppButton.classList.add('hidden');
    });

    async function clearAppCaches() {
      if ('caches' in window) {
        const keys = await caches.keys();
        await Promise.all(keys.filter(key => key.startsWith('dotconquest-')).map(key => caches.delete(key)));
      }
    }

    function reloadLatestVersion() {
      const url = new URL(window.location.href);
      url.searchParams.set('version', APP_VERSION);
      url.searchParams.set('refresh', Date.now().toString());
      window.location.replace(url.toString());
    }

    async function applyLatestVersion() {
      addLog(t('updateChecking'));
      try {
        if (serviceWorkerRegistration) {
          await serviceWorkerRegistration.update();
          const worker = serviceWorkerRegistration.waiting || waitingServiceWorker;
          if (worker) {
            addLog(t('updateReady'));
            worker.postMessage({ type: 'SKIP_WAITING' });
            return;
          }
        }
        await clearAppCaches();
        addLog(t('updateCurrent'));
        setTimeout(reloadLatestVersion, 250);
      } catch (error) {
        await clearAppCaches().catch(() => {});
        addLog(t('updateUnsupported'));
        setTimeout(reloadLatestVersion, 250);
      }
    }

    updateAppButton.addEventListener('click', async () => {
      await applyLatestVersion();
    });

    window.addEventListener('resize', resize);
    if ('serviceWorker' in navigator) {
      window.addEventListener('load', () => {
        navigator.serviceWorker.register('service-worker.js', { updateViaCache: 'none' }).then(registration => {
          serviceWorkerRegistration = registration;
          const showUpdate = worker => {
            waitingServiceWorker = worker;
            updateAppButton.classList.remove('hidden');
          };
          if (registration.waiting) showUpdate(registration.waiting);
          registration.addEventListener('updatefound', () => {
            const worker = registration.installing;
            if (!worker) return;
            worker.addEventListener('statechange', () => {
              if (worker.state === 'installed' && navigator.serviceWorker.controller) {
                showUpdate(worker);
              }
            });
          });
          registration.update().catch(() => {});
        }).catch(() => {});
        let refreshing = false;
        navigator.serviceWorker.addEventListener('controllerchange', () => {
          if (refreshing) return;
          refreshing = true;
          reloadLatestVersion();
        });
      });
    }
    resize();
    newGame(true);
    if (OWNER_EXCLUDED) {
      addLog(t('ownerExcludedNote'));
    }
  </script>
</body>
</html>




















