QuanAI只读快照

共享会话

改進

分享于 2026年9月6日 22:49
提问者

改進: 一,規則改動

增加規則:先手第一步棋要佔據棋盤中心四個棋位的其中三個。

在MCTS和alpha-beta中,如果控制的是先手,則首步棋將隨機找一個旋轉角度放置(只有四種情況)。

當先手方是人類控制時,若第一手棋沒有落對地方,將給出消息框文字提示,並在當前棋子旋轉角度能落子的棋盤格子上產生一個紅色或藍色的閃爍圓點,2秒後消失。

為中文及英文規則說明增加這條規則。

二,更改棋盤繪製

棋盤的中心四個正方形,應加上稍粗稍黑的框線。即縱三條横三條線。

三,修復MCTS搜尋的問題

當在MCTS搜尋時,偶爾會出現到達時限時,並不行動,而是從頭開始搜尋。

如果是因為當前搜尋出的行動結果在當前盤面不能執行,則應修復這個問題。

當在控制區中點擊「AI、人類」圖標,若連續點擊時AI正在思考,則思考的結束時限應停止在當前AI等級所對應的時限(而不是停在簡單AI的時限)。

回答要求:明確給出所要修改的地方和所要替換的代碼。

vino2276.htmltext/plain · 321.4 KB预览下载
QuanAI

以下按函式名稱/原始代碼定位修改即可。這組修改同時處理:

  • 先手首步必須覆蓋中心四格中的三格。
  • 人類落錯時顯示雙語提示和 2 秒紅/藍閃爍圓點。
  • AI 先手從四種合法旋轉中隨機選一種。
  • MCTS、Alpha-Beta 皆遵守新規則。
  • 棋盤中心四格加粗、加深邊框。
  • 搜尋結果無法執行時改用當前盤面的合法備選行動,不再重新搜尋。
  • 連續切換 AI 等級時,MCTS 使用目前等級的即時時限,不再固定停在搜尋開始時的簡單 AI 時限。

一、加入中心格及首步錨點常數

找到:

js
const PIECE_SCALE = 0.86;

const PIECE_ORDER = ['fino', 'mino', 'vino'];

替換成:

js
const PIECE_SCALE = 0.86;

/*
 * 棋盤座標使用 0 起算。
 * 中心四格為:
 * (4,4) (4,5)
 * (5,4) (5,5)
 */
const OPENING_CENTER_CELLS = Object.freeze([
  Object.freeze({ row: 4, col: 4 }),
  Object.freeze({ row: 4, col: 5 }),
  Object.freeze({ row: 5, col: 4 }),
  Object.freeze({ row: 5, col: 5 })
]);

const OPENING_CENTER_KEYS = new Set(
  OPENING_CENTER_CELLS.map(cell => `${cell.row},${cell.col}`)
);

/*
 * 法諾四種方向要佔據中心四格中的三格時,
 * 唯一合法的四個錨點。
 */
const OPENING_FINO_ANCHORS = Object.freeze({
  tl: Object.freeze({ row: 4, col: 4 }),
  tr: Object.freeze({ row: 4, col: 5 }),
  br: Object.freeze({ row: 5, col: 5 }),
  bl: Object.freeze({ row: 5, col: 4 })
});

const PIECE_ORDER = ['fino', 'mino', 'vino'];

四種合法情況為:

方向錨點,0 起算
tlrow 4, col 4
trrow 4, col 5
brrow 5, col 5
blrow 5, col 4

二、加入雙語提示文字及規則說明

1. 英文提示文字

在英文 I18N.en 中找到:

js
ownContact: 'Fino and Mino may not touch another piece of the same player, including diagonally.',
movedAlready: 'A Mino has already moved during this turn.',

替換成:

js
ownContact: 'Fino and Mino may not touch another piece of the same player, including diagonally.',
openingCenterRule:
  "The first player's opening Fino must occupy three of the four central cells. Place it on the flashing marker.",
movedAlready: 'A Mino has already moved during this turn.',

2. 中文提示文字

在中文 I18N.zh 中找到:

js
ownContact: '法諾和米諾不能與本方既有棋子接觸,包括角對角接觸。',
movedAlready: '本回合已經移動過一個米諾。',

替換成:

js
ownContact: '法諾和米諾不能與本方既有棋子接觸,包括角對角接觸。',
openingCenterRule: '先手第一步的法諾必須佔據棋盤中心四格中的三格;請依閃爍圓點落子。',
movedAlready: '本回合已經移動過一個米諾。',

3. 英文規則說明

在英文 rulesHtml 中找到:

html
<article class="rule-block">
  <span class="rule-index">03</span>
  <h3>Board</h3>
  <p>
    The board is a 10 × 10 grid of cells.
  </p>
</article>

替換成:

html
<article class="rule-block">
  <span class="rule-index">03</span>
  <h3>Board and opening move</h3>
  <p>
    The board is a 10 × 10 grid of cells. On the first move
    of each round, the first player's Fino must occupy three
    of the four central cells.
  </p>
</article>

4. 中文規則說明

在中文 rulesHtml 中找到:

html
<article class="rule-block">
  <span class="rule-index">03</span>
  <h3>棋盤</h3>
  <p>
    棋盤為 10 × 10 方格。
  </p>
</article>

替換成:

html
<article class="rule-block">
  <span class="rule-index">03</span>
  <h3>棋盤及先手首步</h3>
  <p>
    棋盤為 10 × 10 方格。每局先手第一步落下的法諾,
    必須佔據棋盤中心四格中的三格。
  </p>
</article>

三、中心四格加粗、加深框線

1. 加入 CSS

找到:

css
.grid-lines {
  fill: none;
  stroke: var(--board-grid);
  stroke-width: 1.2;
  vector-effect: non-scaling-stroke;
}

在它後面加入:

css
.center-grid-lines {
  fill: none;
  stroke: rgba(34, 38, 46, 0.76);
  stroke-width: 2.6;
  stroke-linecap: square;
  vector-effect: non-scaling-stroke;
  pointer-events: none;
}

2. 完整替換 buildBoardGrid()

找到完整的:

js
function buildBoardGrid() {
  let pathData = '';

  for (let index = 1; index < BOARD_SIZE; index += 1) {
    const position = index * CELL_SIZE;
    pathData += `M ${position} 0 V 480 `;
    pathData += `M 0 ${position} H 480 `;
  }

  const path = document.createElementNS(SVG_NS, 'path');
  path.setAttribute('d', pathData.trim());
  path.setAttribute('class', 'grid-lines');

  els.gridLayer.replaceChildren(path);
}

替換成:

js
function buildBoardGrid() {
  let pathData = '';

  for (let index = 1; index < BOARD_SIZE; index += 1) {
    const position = index * CELL_SIZE;

    pathData += `M ${position} 0 V 480 `;
    pathData += `M 0 ${position} H 480 `;
  }

  const normalPath = document.createElementNS(SVG_NS, 'path');
  normalPath.setAttribute('d', pathData.trim());
  normalPath.setAttribute('class', 'grid-lines');

  /*
   * 中心四格由第 4、5、6 條格線包圍:
   * x = 192、240、288
   * y = 192、240、288
   *
   * 因此總共另外繪製三條直線及三條橫線。
   */
  const centerPositions = [4, 5, 6].map(index => index * CELL_SIZE);

  let centerPathData = '';

  centerPositions.forEach(position => {
    centerPathData += `M ${position} ${4 * CELL_SIZE} V ${6 * CELL_SIZE} `;
    centerPathData += `M ${4 * CELL_SIZE} ${position} H ${6 * CELL_SIZE} `;
  });

  const centerPath = document.createElementNS(SVG_NS, 'path');
  centerPath.setAttribute('d', centerPathData.trim());
  centerPath.setAttribute('class', 'center-grid-lines');

  els.gridLayer.replaceChildren(normalPath, centerPath);
}

這會畫出中心區域的三直、三橫粗線,而不是把整條棋盤線都加粗。


四、加入首步錯誤時的閃爍圓點圖層

1. 修改 SVG

找到:

html
<g id="gridLayer" pointer-events="none"></g>
<g id="placedLayer" clip-path="url(#boardClip)"></g>
<g id="previewLayer" clip-path="url(#boardClip)"></g>
<g id="moveLayer" clip-path="url(#boardClip)"></g>
<g id="lastMoveLayer" clip-path="url(#boardClip)" pointer-events="none"></g>

替換成:

html
<g id="gridLayer" pointer-events="none"></g>
<g id="placedLayer" clip-path="url(#boardClip)"></g>
<g id="previewLayer" clip-path="url(#boardClip)"></g>
<g
  id="openingHintLayer"
  clip-path="url(#boardClip)"
  pointer-events="none"
></g>
<g id="moveLayer" clip-path="url(#boardClip)"></g>
<g id="lastMoveLayer" clip-path="url(#boardClip)" pointer-events="none"></g>

2. 加入圓點 CSS

建議放在 .last-turn-marker 後面:

css
.opening-hint-dot {
  stroke-width: 2.4;
  vector-effect: non-scaling-stroke;
  pointer-events: none;
  transform-box: fill-box;
  transform-origin: center;
  animation: opening-hint-pulse 620ms ease-in-out infinite;
}

.opening-hint-dot.owner-1 {
  fill: var(--p1);
  stroke: var(--p1-dark);
  filter: drop-shadow(0 0 7px rgba(255, 128, 111, 0.88));
}

.opening-hint-dot.owner-2 {
  fill: var(--p2);
  stroke: var(--p2-dark);
  filter: drop-shadow(0 0 7px rgba(96, 172, 248, 0.88));
}

@keyframes opening-hint-pulse {
  0%,
  100% {
    opacity: 0.3;
    transform: scale(0.72);
  }

  50% {
    opacity: 1;
    transform: scale(1.24);
  }
}

3. 加入元素引用

const els = { ... } 中找到:

js
previewLayer: document.getElementById('previewLayer'),
moveLayer: document.getElementById('moveLayer'),
lastMoveLayer: document.getElementById('lastMoveLayer'),

替換成:

js
previewLayer: document.getElementById('previewLayer'),
openingHintLayer: document.getElementById('openingHintLayer'),
moveLayer: document.getElementById('moveLayer'),
lastMoveLayer: document.getElementById('lastMoveLayer'),

4. 加入狀態

state 中找到:

js
lastTurnMarkers: null,

minoFlightMode: {

替換成:

js
lastTurnMarkers: null,

// 人類先手首步落錯時的閃爍錨點提示。
openingHintPlayer: 0,
openingHintTimer: null,

minoFlightMode: {

五、加入首步規則判定及提示函式

validatePlacement() 前面加入以下函式:

js
function getRoundStarter(roundNumber = state.roundNumber) {
  return roundNumber === 1 ? 1 : 2;
}

function isStarterOpeningPlacement(player, type) {
  return (
    type === 'fino' &&
    state.gameStarted &&
    !state.roundEnded &&
    !state.gameOver &&
    state.pieces.length === 0 &&
    state.currentPlayer === player &&
    getRoundStarter() === player
  );
}

function countOpeningCenterCells(cells) {
  let count = 0;

  cells.forEach(cell => {
    if (OPENING_CENTER_KEYS.has(cellKey(cell.row, cell.col))) {
      count += 1;
    }
  });

  return count;
}

function satisfiesOpeningCenterRule(cells) {
  return countOpeningCenterCells(cells) >= 3;
}

function clearOpeningRuleHint() {
  window.clearTimeout(state.openingHintTimer);

  state.openingHintTimer = null;
  state.openingHintPlayer = 0;

  if (els.openingHintLayer) {
    els.openingHintLayer.replaceChildren();
  }
}

function showOpeningRuleHint(active, duration = 2000) {
  clearOpeningRuleHint();

  if (
    !active ||
    active.type !== 'fino' ||
    !isStarterOpeningPlacement(active.player, active.type)
  ) {
    return;
  }

  state.openingHintPlayer = active.player;
  renderOpeningRuleHint();

  state.openingHintTimer = window.setTimeout(() => {
    state.openingHintTimer = null;
    state.openingHintPlayer = 0;
    renderOpeningRuleHint();
  }, duration);
}

六、在一般棋盤規則中限制首步

validatePlacement() 裡找到重疊判定:

js
const overlaps = cells.some(({ row, col }) => state.board[row][col] !== null);

if (overlaps) {
  return {
    ok: false,
    reason: 'overlap',
    cells
  };
}

if (type === 'fino' || type === 'mino') {

替換成:

js
const overlaps = cells.some(({ row, col }) => state.board[row][col] !== null);

if (overlaps) {
  return {
    ok: false,
    reason: 'overlap',
    cells
  };
}

/*
 * 每局先手的第一步必須使用法諾覆蓋中心四格中的三格。
 */
if (
  isStarterOpeningPlacement(player, type) &&
  !satisfiesOpeningCenterRule(cells)
) {
  return {
    ok: false,
    reason: 'opening-center',
    cells
  };
}

if (type === 'fino' || type === 'mino') {

這樣以下功能都會自動遵守新規則:

  • 人類落子
  • hasAnyLegalPlacement()
  • 棋譜導入
  • 預覽合法性
  • 自動階段判定

七、繪製閃爍提示

renderLastTurnMarkers() 前面加入:

js
function renderOpeningRuleHint() {
  els.openingHintLayer.replaceChildren();

  if (!state.openingHintPlayer) {
    return;
  }

  const active = getActivePiece();

  if (
    !active ||
    active.player !== state.openingHintPlayer ||
    active.type !== 'fino' ||
    !isStarterOpeningPlacement(active.player, active.type)
  ) {
    return;
  }

  const orientation =
    state.orientationMemory[active.player].fino || active.orientation || 'tl';

  const anchor =
    OPENING_FINO_ANCHORS[orientation] || OPENING_FINO_ANCHORS.tl;

  const dot = document.createElementNS(SVG_NS, 'circle');

  dot.setAttribute('cx', (anchor.col + 0.5) * CELL_SIZE);
  dot.setAttribute('cy', (anchor.row + 0.5) * CELL_SIZE);
  dot.setAttribute('r', 8);
  dot.setAttribute(
    'class',
    `opening-hint-dot owner-${active.player}`
  );

  els.openingHintLayer.appendChild(dot);
}

renderBoard() 中找到:

js
renderPlacedPieces();
renderPreview();
renderMoveArrows();
renderLastTurnMarkers();

替換成:

js
renderPlacedPieces();
renderPreview();
renderOpeningRuleHint();
renderMoveArrows();
renderLastTurnMarkers();

八、人類首步落錯時顯示消息和圓點

handleBoardClick() 中找到:

js
if (!validation.ok) {
  const toastKey =
    {
      bounds: 'outOfBounds',
      overlap: 'overlap',
      contact: 'ownContact'
    }[validation.reason] || 'overlap';

  showToast(toastKey);
  renderMoveArrows();
  return;
}

替換成:

js
if (!validation.ok) {
  if (validation.reason === 'opening-center') {
    showToast('openingCenterRule', {}, 2000);
    showOpeningRuleHint(active, 2000);
    renderMoveArrows();
    return;
  }

  const toastKey =
    {
      bounds: 'outOfBounds',
      overlap: 'overlap',
      contact: 'ownContact'
    }[validation.reason] || 'overlap';

  showToast(toastKey);
  renderMoveArrows();
  return;
}

AI 不經過 handleBoardClick(),因此這個提示只會在人類控制時出現。


九、旋轉棋子時同步移動閃爍圓點

handleOrientationDragMove() 中找到:

js
renderPieces();
renderPreview();

替換成:

js
renderPieces();
renderPreview();
renderOpeningRuleHint();

handlePieceFrameClick() 最後找到:

js
renderPieces();
renderPreview();

同樣替換成:

js
renderPieces();
renderPreview();
renderOpeningRuleHint();

如此提示顯示期間如果玩家旋轉法諾,圓點會切換到新方向所對應的合法錨點。


十、落子、重開、回退或返回標題時清除提示

1. placePiece()

在函式開頭:

js
function placePiece(validation, active) {
  const pendingMoveForRecord =

改成:

js
function placePiece(validation, active) {
  clearOpeningRuleHint();

  const pendingMoveForRecord =

2. startRound()

在函式開頭找到:

js
function startRound(roundNumber) {
  hideResultModal();
  clearToast();

替換成:

js
function startRound(roundNumber) {
  hideResultModal();
  clearToast();
  clearOpeningRuleHint();

3. restoreSnapshot()

在函式開頭加入:

js
function restoreSnapshot(snapshot) {
  clearOpeningRuleHint();

  state.pieces = snapshot.pieces.map(piece => ({

4. restoreImportBackup()

在函式開頭加入:

js
function restoreImportBackup(backup) {
  clearOpeningRuleHint();

  IMPORT_BACKUP_KEYS.forEach(key => {

5. returnToTitle()

找到:

js
hideFlightConfirmDialog();
clearToast();

state.pendingFlight = null;

替換成:

js
hideFlightConfirmDialog();
clearToast();
clearOpeningRuleHint();

state.pendingFlight = null;

十一、棋譜導入的錯誤提示增加首步規則

addImportedPiece() 中找到:

js
const reason = {
  bounds: state.lang === 'zh' ? '棋子超出棋盤。' : 'The piece extends beyond the board.',
  overlap: state.lang === 'zh' ? '棋子與其他棋子重疊。' : 'The piece overlaps another piece.',
  contact:
    state.lang === 'zh' ? '法諾或米諾與本方棋子接觸。' : 'The Fino or Mino touches another friendly piece.'
}[validation.reason];

替換成:

js
const reason = {
  bounds:
    state.lang === 'zh'
      ? '棋子超出棋盤。'
      : 'The piece extends beyond the board.',

  overlap:
    state.lang === 'zh'
      ? '棋子與其他棋子重疊。'
      : 'The piece overlaps another piece.',

  contact:
    state.lang === 'zh'
      ? '法諾或米諾與本方棋子接觸。'
      : 'The Fino or Mino touches another friendly piece.',

  'opening-center':
    state.lang === 'zh'
      ? '先手第一步的法諾必須佔據棋盤中心四格中的三格。'
      : "The first player's opening Fino must occupy three of the four central cells."
}[validation.reason];

十二、AI 核心加入相同的首步規則

1. 加入中心四格 Bitboard

找到:

js
const CELL_BITS = Array.from({ length: 100 }, (_, index) => 1n << BigInt(index));

const CELL_CLEAR_MASKS = CELL_BITS.map(bit => FULL_BOARD_MASK ^ bit);

替換成:

js
const CELL_BITS = Array.from(
  { length: 100 },
  (_, index) => 1n << BigInt(index)
);

/*
 * 中心四格的 Bitboard:
 * 44 = row 4, col 4
 * 45 = row 4, col 5
 * 54 = row 5, col 4
 * 55 = row 5, col 5
 */
const CORE_OPENING_CENTER_MASK =
  CELL_BITS[44] |
  CELL_BITS[45] |
  CELL_BITS[54] |
  CELL_BITS[55];

const CELL_CLEAR_MASKS = CELL_BITS.map(
  bit => FULL_BOARD_MASK ^ bit
);

2. 修改 isLegalPlacementMask()

找到:

js
function isLegalPlacementMask(position, player, type, mask) {
  if (!mask) {
    return false;
  }

  const occupied = getOccupiedBits(position);

  if (mask & occupied) {
    return false;
  }

  if (type === CORE_FINO || type === CORE_MINO) {

替換成:

js
function isLegalPlacementMask(position, player, type, mask) {
  if (!mask) {
    return false;
  }

  const occupied = getOccupiedBits(position);

  if (mask & occupied) {
    return false;
  }

  /*
   * 空棋盤上的第一個法諾必須覆蓋中心四格中的至少三格。
   */
  if (
    type === CORE_FINO &&
    occupied === 0n &&
    popcountBigInt(mask & CORE_OPENING_CENTER_MASK) < 3
  ) {
    return false;
  }

  if (type === CORE_FINO || type === CORE_MINO) {

3. 修改 generateCoreActions()

找到:

js
if (requiredType === CORE_FINO || requiredType === CORE_MINO) {
  const orientationCount = requiredType === CORE_MINO ? 1 : 4;

  const ownNeighbors = neighborMask(oldOwn);

  for (let orientation = 0; orientation < orientationCount; orientation += 1) {
    for (let anchor = 0; anchor < 100; anchor += 1) {
      const mask = PLACEMENT_MASKS[requiredType][orientation][anchor];

      if (!mask || (mask & occupied) !== 0n || (mask & ownNeighbors) !== 0n) {
        continue;
      }

替換成:

js
if (requiredType === CORE_FINO || requiredType === CORE_MINO) {
  const orientationCount = requiredType === CORE_MINO ? 1 : 4;

  const ownNeighbors = neighborMask(oldOwn);

  const isOpeningFino =
    requiredType === CORE_FINO &&
    occupied === 0n;

  for (let orientation = 0; orientation < orientationCount; orientation += 1) {
    for (let anchor = 0; anchor < 100; anchor += 1) {
      const mask = PLACEMENT_MASKS[requiredType][orientation][anchor];

      if (
        !mask ||
        (mask & occupied) !== 0n ||
        (mask & ownNeighbors) !== 0n ||
        (
          isOpeningFino &&
          popcountBigInt(mask & CORE_OPENING_CENTER_MASK) < 3
        )
      ) {
        continue;
      }

這一步非常重要,因為 generateCoreActions() 原本沒有呼叫 isLegalPlacementMask(),只修改後者不足以約束 MCTS 和 Alpha-Beta 的實際行動生成。


十三、AI 先手首步隨機選四種旋轉

encodeSkip() 後面加入:

js
/*
 * 先手首步四種合法法諾。
 * action 與玩家無關;真正的持有者由 position.currentPlayer 決定。
 */
const CORE_OPENING_ACTIONS = Object.freeze([
  encodePlacement(CORE_FINO, 0, 44), // tl,row 4 col 4
  encodePlacement(CORE_FINO, 1, 45), // tr,row 4 col 5
  encodePlacement(CORE_FINO, 2, 55), // br,row 5 col 5
  encodePlacement(CORE_FINO, 3, 54)  // bl,row 5 col 4
]);

function isCoreOpeningTurn(position) {
  return (
    !position.terminalWinner &&
    Boolean(position.currentPlayer) &&
    getOccupiedBits(position) === 0n &&
    getCoreRequiredType(
      position,
      position.currentPlayer
    ) === CORE_FINO
  );
}

function pickRandomCoreOpeningAction(position) {
  if (!isCoreOpeningTurn(position)) {
    return null;
  }

  const player = position.currentPlayer;
  const legalActions = [];

  CORE_OPENING_ACTIONS.forEach(action => {
    const orientation = actionPlacementOrientation(action);
    const anchor = actionPlacementAnchor(action);
    const mask =
      PLACEMENT_MASKS[CORE_FINO][orientation][anchor];

    if (
      isLegalPlacementMask(
        position,
        player,
        CORE_FINO,
        mask
      )
    ) {
      legalActions.push(action);
    }
  });

  if (!legalActions.length) {
    return null;
  }

  const randomIndex = Math.floor(
    Math.random() * legalActions.length
  );

  return legalActions[randomIndex];
}

此判斷放在 MCTS/Alpha-Beta 選擇之前,所以無論原本準備使用哪一種引擎,只要是 AI 控制的先手首步,都會從四種旋轉中隨機選一種。


十四、修正 MCTS 時限固定在搜尋開始等級的問題

目前 MCTSEngine.search() 接收固定的 timeMilliseconds,所以搜尋開始時若是簡單 AI,就算之後連續點到困難/專家,仍然會在 3 秒停止。

完整替換 MCTSEngine 中的 search()

js
async search(
  position,
  timeBudgetMilliseconds,
  signal,
  onProgress
) {
  this.syncRoot(position);

  const startTime = performance.now();

  /*
   * timeBudgetMilliseconds 可以是固定數值,
   * 也可以是每次檢查時重新讀取的函式。
   *
   * 使用函式時,連續切換 AI 等級後,
   * 搜尋會立即採用目前等級的時限。
   */
  const readTimeBudget = () => {
    const rawValue =
      typeof timeBudgetMilliseconds === 'function'
        ? timeBudgetMilliseconds()
        : timeBudgetMilliseconds;

    const numericValue = Number(rawValue);

    return Math.max(
      1,
      Number.isFinite(numericValue)
        ? numericValue
        : 1000
    );
  };

  const progressIntervalMs = 100;
  let lastProgressTime = startTime;

  let simulations = 0;

  while (true) {
    if (signal.aborted) {
      throw createAbortError();
    }

    const nowBeforeSim = performance.now();

    if (
      nowBeforeSim - startTime >= readTimeBudget()
    ) {
      break;
    }

    this.runSimulation();
    simulations += 1;

    const now = performance.now();

    if (now - lastProgressTime >= progressIntervalMs) {
      onProgress(
        (now - startTime) / 1000,
        simulations
      );

      lastProgressTime = now;

      await yieldToBrowser();
    }
  }

  if (signal.aborted) {
    throw createAbortError();
  }

  /*
   * 極短時限下仍至少進行一次模擬,
   * 確保能選出一個行動。
   */
  if (simulations === 0) {
    this.runSimulation();
    simulations = 1;
  }

  return this.pickBestFromRoot(simulations);
}

然後在 runCurrentAITurn() 中,兩處 MCTS 呼叫都要改成讀取即時設定。

Alpha-Beta 超時後的 MCTS

找到:

js
result = await mctsEngine.search(
  searchPosition,
  Math.max(1, control.timeSeconds) * 1000,
  signal,

替換成:

js
result = await mctsEngine.search(
  searchPosition,
  () =>
    Math.max(
      1,
      controls[player].timeSeconds
    ) * 1000,
  signal,

一般 MCTS

另一處相同代碼也替換為:

js
result = await mctsEngine.search(
  searchPosition,
  () =>
    Math.max(
      1,
      controls[player].timeSeconds
    ) * 1000,
  signal,

例如搜尋期間連續點擊:

text
簡單 3 秒 → 困難 6 秒 → 專家 12 秒

本次搜尋會以開始搜尋後的總經過時間計算,最後在目前的 12 秒 時限停止,而不是仍在 3 秒 停止。


十五、修正 AI 行動執行的部分修改問題

原本 ACTION_MOVE_VINO 可能先真的移動米諾,然後才發現維諾不能落下。這會使真實盤面進入中間狀態,與搜尋盤面不一致。

完整替換 applyAIAction()

js
function applyAIAction(action) {
  const player = state.currentPlayer;

  if (
    !player ||
    state.roundEnded ||
    state.gameOver
  ) {
    return false;
  }

  const kind = actionKind(action);

  if (kind === ACTION_SKIP) {
    const type = actionSkipType(action);
    const typeName = TYPE_ID_TO_NAME[type];

    if (
      !typeName ||
      !canManualSkip(player, typeName)
    ) {
      return false;
    }

    state.phaseSkipped[player][typeName] = true;
    state.hoverCell = null;
    state.selectedMinoId = null;

    preparePlayerPhase(player);
    renderAll();

    observeCommittedAction(action);
    return true;
  }

  if (kind === ACTION_PLACE) {
    const type = actionPlacementType(action);
    const orientation =
      actionPlacementOrientation(action);
    const anchor = actionPlacementAnchor(action);

    if (anchor < 0 || anchor >= 100) {
      return false;
    }

    const typeName = TYPE_ID_TO_NAME[type];

    const orientationName =
      type === CORE_MINO
        ? 'single'
        : ORIENTATION_NAMES[orientation];

    if (!typeName || !orientationName) {
      return false;
    }

    const active = getActivePiece();

    if (
      !active ||
      active.player !== player ||
      active.type !== typeName
    ) {
      return false;
    }

    const validation = validatePlacement(
      player,
      typeName,
      orientationName,
      indexRow(anchor),
      indexCol(anchor)
    );

    if (!validation.ok) {
      return false;
    }

    state.orientationMemory[player][typeName] =
      orientationName;

    active.orientation = orientationName;

    placePiece(validation, active);
    return true;
  }

  if (kind === ACTION_MOVE_VINO) {
    const from = actionMoveFrom(action);
    const to = actionMoveTo(action);
    const anchor = actionMoveAnchor(action);
    const orientation =
      actionMoveOrientation(action);

    if (
      from < 0 ||
      from >= 100 ||
      to < 0 ||
      to >= 100 ||
      anchor < 0 ||
      anchor >= 100
    ) {
      return false;
    }

    const orientationName =
      ORIENTATION_NAMES[orientation];

    if (!orientationName) {
      return false;
    }

    const active = getActivePiece();

    if (
      !active ||
      active.player !== player ||
      active.type !== 'vino'
    ) {
      return false;
    }

    let placementValidation;

    if (state.pendingMinoMove) {
      /*
       * 如果盤面本來已處於「米諾已移動,等待維諾」
       * 的中間狀態,搜尋行動必須與該移動完全一致。
       */
      const pending = state.pendingMinoMove;

      const pendingFrom = boardIndex(
        pending.from.row,
        pending.from.col
      );

      const pendingTo = boardIndex(
        pending.to.row,
        pending.to.col
      );

      if (
        pending.player !== player ||
        pendingFrom !== from ||
        pendingTo !== to
      ) {
        return false;
      }

      placementValidation = validatePlacement(
        player,
        'vino',
        orientationName,
        indexRow(anchor),
        indexCol(anchor)
      );

      if (!placementValidation.ok) {
        return false;
      }
    } else {
      const mino = findRealMinoAt(from, player);

      if (!mino) {
        return false;
      }

      const destination = {
        row: indexRow(to),
        col: indexCol(to)
      };

      /*
       * 先確認 to 確實是這個米諾的正常合法移動位置。
       */
      const legalDestinations =
        getLegalMinoDestinations(mino.id);

      if (
        !legalDestinations.has(
          cellKey(
            destination.row,
            destination.col
          )
        )
      ) {
        return false;
      }

      /*
       * 先在暫時盤面驗證維諾。
       * 驗證成功後才真正移動米諾,避免失敗時留下半手棋。
       */
      placementValidation =
        withTemporaryMinoMove(
          mino,
          destination,
          () =>
            validatePlacement(
              player,
              'vino',
              orientationName,
              indexRow(anchor),
              indexCol(anchor)
            )
        );

      if (!placementValidation.ok) {
        return false;
      }

      performNormalMinoMove(
        mino.id,
        destination
      );
    }

    state.orientationMemory[player].vino =
      orientationName;

    active.orientation = orientationName;

    placePiece(placementValidation, active);
    return true;
  }

  if (kind === ACTION_FLIGHT) {
    const from = actionMoveFrom(action);
    const to = actionMoveTo(action);

    if (
      from < 0 ||
      from >= 100 ||
      to < 0 ||
      to >= 100 ||
      state.pendingMinoMove
    ) {
      return false;
    }

    const mino = findRealMinoAt(from, player);

    if (!mino) {
      return false;
    }

    const destination = {
      row: indexRow(to),
      col: indexCol(to)
    };

    if (
      state.board[destination.row]?.[
        destination.col
      ]
    ) {
      return false;
    }

    const automaticFlight =
      isMinoFlightPhase(player);

    if (!automaticFlight) {
      const active = getActivePiece();

      if (
        !active ||
        active.type !== 'vino' ||
        piecesPlacedCount(player, 'vino') === 0
      ) {
        return false;
      }
    }

    /*
     * 能以一般米諾移動到達的格子不能作為飛子落點。
     */
    const normalDestinations =
      getLegalMinoDestinations(mino.id);

    if (
      normalDestinations.has(
        cellKey(
          destination.row,
          destination.col
        )
      )
    ) {
      return false;
    }

    attemptMinoFlight(
      mino.id,
      destination
    );

    /*
     * AI 自動接受主動飛子的確認窗口。
     */
    if (state.pendingFlight) {
      confirmPendingFlight();
    }

    return (
      state.playerEnded[player] ||
      state.roundEnded ||
      state.currentPlayer !== player
    );
  }

  return false;
}

十六、搜尋結果不能執行時改用合法備選行動

原本:

js
const applied = applyAIAction(result.action);

if (!applied) {
  mctsEngine.resetTo(captureCorePosition());
  return;
}

之後 finally 又呼叫:

js
queueAIForCurrentTurn();

由於仍然輪到同一個 AI,因此便會從頭搜尋,這正是「到達時限卻不行動」的主要原因。

applyAIAction() 後面加入:

js
/*
 * 用獨立緩衝區重新生成當前盤面的合法行動。
 * 不使用剛完成搜尋的 MCTS 共用 actions 緩衝區。
 */
const aiRecoveryCore = new CorePosition();
const aiRecoveryActions =
  new Int32Array(MAX_ACTIONS);
const aiRecoveryScores =
  new Float64Array(MAX_ACTIONS);

function applyBestAvailableAIAction(
  position,
  preferredAction
) {
  aiRecoveryCore.copyFrom(position);
  normalizeCorePosition(aiRecoveryCore);

  const count = generateCoreActions(
    aiRecoveryCore,
    aiRecoveryActions,
    aiRecoveryScores
  );

  if (count <= 0) {
    return null;
  }

  let preferredIndex = -1;

  if (Number.isInteger(preferredAction)) {
    for (let index = 0; index < count; index += 1) {
      if (
        aiRecoveryActions[index] ===
        preferredAction
      ) {
        preferredIndex = index;
        break;
      }
    }
  }

  /*
   * 搜尋結果仍是當前盤面的合法行動時,
   * 優先嘗試搜尋結果。
   */
  if (
    preferredIndex >= 0 &&
    applyAIAction(preferredAction)
  ) {
    return preferredAction;
  }

  /*
   * 搜尋結果過期、損壞或無法映射到真實盤面時,
   * 依目前盤面的合法行動排序逐一嘗試。
   */
  for (let index = 0; index < count; index += 1) {
    const action = aiRecoveryActions[index];

    if (
      index === preferredIndex ||
      action === preferredAction
    ) {
      continue;
    }

    if (applyAIAction(action)) {
      return action;
    }
  }

  return null;
}

十七、修改 runCurrentAITurn()

1. 搜尋開始前同步真實階段

找到開頭:

js
async function runCurrentAITurn() {
  if (runtime.running || runtime.applying || !isCurrentTurnAI()) {
    return;
  }

  const player = state.currentPlayer;

替換成:

js
async function runCurrentAITurn() {
  if (
    runtime.running ||
    runtime.applying ||
    !isCurrentTurnAI()
  ) {
    return;
  }

  /*
   * 確保真實盤面與 CorePosition 的自動階段切換一致。
   */
  preparePlayerPhase(state.currentPlayer);

  if (!isCurrentTurnAI()) {
    return;
  }

  const player = state.currentPlayer;

在:

js
runtime.activePlayer = player;

後面加入:

js
runtime.forcedAction = null;

2. 首步先隨機選擇,不進行完整搜尋

找到:

js
try {
  let result;

  if (shouldUseAlphaBeta(searchPosition, control)) {

替換成:

js
try {
  let result;

  const randomOpeningAction =
    pickRandomCoreOpeningAction(searchPosition);

  if (randomOpeningAction !== null) {
    /*
     * AI 控制先手首步時,從四種合法旋轉中隨機選擇。
     * 此分支位於引擎選擇之前,因此同時適用於
     * MCTS 和 Alpha-Beta 控制流程。
     */
    setThinkingView({
      kind: 'mcts',
      player,
      elapsed: 0,
      visits: 0
    });

    await yieldToBrowser();

    if (signal.aborted) {
      throw createAbortError();
    }

    result = {
      action: randomOpeningAction,
      evaluation: 0,
      winRate: 50,
      simulations: 0,
      openingRandom: true
    };
  } else if (
    shouldUseAlphaBeta(searchPosition, control)
  ) {

後面的 Alpha-Beta 和一般 MCTS 代碼保持原有結構。

3. 替換搜尋完成後的行動執行部分

找到從:

js
if (signal.aborted) {
  throw createAbortError();
}

const currentPosition = captureCorePosition();

if (
  state.currentPlayer !== player ||
  !sameCorePosition(searchPosition, currentPosition) ||
  !result ||
  !Number.isInteger(result.action)
) {
  return;
}

runtime.applying = true;

const applied = applyAIAction(result.action);

runtime.applying = false;

if (!applied) {
  mctsEngine.resetTo(captureCorePosition());

  return;
}

const totalElapsed = (performance.now() - searchStart) / 1000;

到上述 totalElapsed 為止,替換成:

js
if (signal.aborted) {
  throw createAbortError();
}

const currentPosition = captureCorePosition();

/*
 * 玩家、盤面已在搜尋期間改變時,正常放棄舊結果。
 * 這種情況不是行動失效修復的範圍。
 */
if (
  state.currentPlayer !== player ||
  !sameCorePosition(
    searchPosition,
    currentPosition
  )
) {
  return;
}

const preferredAction =
  result && Number.isInteger(result.action)
    ? result.action
    : null;

runtime.applying = true;

const appliedAction =
  applyBestAvailableAIAction(
    currentPosition,
    preferredAction
  );

runtime.applying = false;

if (appliedAction === null) {
  /*
   * 理論上只要 CorePosition 還有合法行動便不會進入這裡。
   * 若真實盤面和核心盤面出現不可恢復的不一致,
   * 不再重新開始一輪完整搜尋,改成人類控制並保留盤面。
   */
  console.error(
    'AI could not apply any legal action to the current board.'
  );

  mctsEngine.resetTo(
    captureCorePosition()
  );

  controls[player].mode =
    AI_MODE_HUMAN;

  hideThinkingImmediately();
  renderAISettings();

  return;
}

const usedFallback =
  preferredAction === null ||
  appliedAction !== preferredAction;

const totalElapsed =
  (performance.now() - searchStart) / 1000;

然後找到緊接著的:

js
const engineKind = Number.isFinite(result.nodes) ? 'alpha' : 'mcts';

const totalSearchCount = Number.isFinite(result.nodes)
  ? result.nodes
  : Number.isFinite(result.simulations)
    ? result.simulations
    : undefined;

showThinkingFinal(player, totalElapsed, result.evaluation, result.winRate, totalSearchCount, engineKind);

替換成:

js
const engineKind =
  result && Number.isFinite(result.nodes)
    ? 'alpha'
    : 'mcts';

const totalSearchCount =
  result && Number.isFinite(result.nodes)
    ? result.nodes
    : result &&
        Number.isFinite(result.simulations)
      ? result.simulations
      : undefined;

/*
 * 使用備選行動時,原搜尋結果的評估值並不屬於
 * 實際執行的行動,因此顯示中立值。
 */
const displayedEvaluation =
  !usedFallback &&
  result &&
  Number.isFinite(result.evaluation)
    ? result.evaluation
    : 0;

const displayedWinRate =
  !usedFallback &&
  result &&
  Number.isFinite(result.winRate)
    ? result.winRate
    : 50;

showThinkingFinal(
  player,
  totalElapsed,
  displayedEvaluation,
  displayedWinRate,
  totalSearchCount,
  engineKind
);

十八、修正「強制套用目前最佳走法」也可能重新搜尋

runCurrentAITurn()catch 中找到:

js
runtime.applying = true;

const applied = applyAIAction(forced.action);

runtime.applying = false;

if (applied) {
  const totalElapsed = (performance.now() - searchStart) / 1000;

  showThinkingFinal(
    player,
    totalElapsed,
    forced.evaluation,
    forced.winRate,
    forced.simulations,
    'mcts'
  );
} else {
  mctsEngine.resetTo(captureCorePosition());
}

替換成:

js
runtime.applying = true;

const appliedAction =
  applyBestAvailableAIAction(
    currentPosition,
    forced.action
  );

runtime.applying = false;

if (appliedAction !== null) {
  const totalElapsed =
    (performance.now() - searchStart) / 1000;

  const usedFallback =
    appliedAction !== forced.action;

  showThinkingFinal(
    player,
    totalElapsed,
    usedFallback
      ? 0
      : forced.evaluation,
    usedFallback
      ? 50
      : forced.winRate,
    forced.simulations,
    'mcts'
  );
} else {
  /*
   * 不再讓 finally 重新開始完整搜尋。
   */
  mctsEngine.resetTo(
    captureCorePosition()
  );

  controls[player].mode =
    AI_MODE_HUMAN;

  hideThinkingImmediately();
  renderAISettings();
}

十九、修改後應測試的情況

  1. 第一局玩家一為人類

    • 在中心以外落法諾。
    • 應顯示中文/英文消息。
    • 根據法諾方向顯示一個紅色閃爍圓點。
    • 2 秒後圓點消失。
  2. 第二局玩家二為人類

    • 同樣落錯。
    • 應顯示藍色閃爍圓點。
  3. 四種法諾方向

    • tl 圓點位於 (4,4)
    • tr 圓點位於 (4,5)
    • br 圓點位於 (5,5)
    • bl 圓點位於 (5,4)
  4. AI 為先手

    • 重開多次。
    • 第一手只會出現上述四種合法法諾。
    • 四種方向由 Math.random() 隨機選擇。
  5. 棋譜導入

    • 導入不符合中心規則的第一手時,應直接報錯。
    • 符合四種合法首步的棋譜應正常導入。
  6. MCTS 搜尋結束

    • 到達時限後必須落子。
    • 即使原搜尋 action 無法映射,也應改用當前盤面的其他合法 action。
    • 不應重新從 0 秒開始搜尋。
  7. 連續點擊 AI 圖標

    • AI 以簡單模式開始思考。
    • 思考途中點到困難、再點到專家。
    • 本次 MCTS 應在約 12 秒總時限停止,而不是 3 秒。
  8. 中心框線

    • 中心四格周圍應有三條較深直線及三條較深橫線。
    • 棋盤其他格線維持原粗細。