QuanAI只读快照

共享会话

當前的計分規則仍然有誤,檢查代碼,看當前是怎麼看待「叉零」棋子在構建圍牆時的作用,「叉零」應相當於一個「能與任何棋子相…

分享于 2026年9月19日 08:52
提问者

當前的計分規則仍然有誤,檢查代碼,看當前是怎麼看待「叉零」棋子在構建圍牆時的作用,「叉零」應相當於一個「能與任何棋子相鄰的四面都是實邊」的棋子。同時檢查計分還可能在哪裡出錯,以及解決在undo和redo時,計分更新狀態出錯的問題。

battlenumber22.htmltext/plain · 142.9 KB预览下载
QuanAI

已完成靜態檢查。問題不只在「叉零」的圍牆判定,還有 AI Worker 與主程式棋子資料格式不一致,以及 undo/redo 直接信任快照中的衍生分數,可能恢復出過期的計分狀態。

找到的問題

  1. 「叉零」目前被定義成四面都沒有實邊

    js
    const DEFAULT_EDGES = [[0], [3, 1], [0, 3], [0, 1, 3], []];

    最後的 [] 令叉零在區域計分中完全不構成圍牆。

  2. 不能只把叉零改成四面實邊

    如果直接改成 [0, 1, 2, 3],現有落子檢查會認為叉零與有實邊的鄰居發生「實邊重合」,造成叉零不能與任何棋子相鄰。

    正確做法是分開兩種概念:

    • 區域計分:叉零四面都是實邊。
    • 落子相容性:只要其中一枚是叉零,就不檢查實邊重合。
  3. 主程式的提子移動函式讀錯棋子格式

    主程式棋盤上的棋子是:

    js
    { player, type, rot }

    movementDestinations() 卻使用:

    js
    tile[1]
    tile[2]

    導致所有非叉零棋子都沒有移動方向。

  4. Worker 有相反的格式錯誤

    Worker 棋子格式是:

    js
    [player, type, rotation]

    但 Worker 的 movementDestinations() 使用了:

    js
    tile.type
    tile.rot

    所以 AI 在提子階段也無法正確搜尋路徑,常會錯誤直接判負或只提叉零,看起來像計分出錯。

  5. undo/redo 完全信任快照裡的 placementScoreroundSummary

    一旦快照內的衍生分數曾由錯誤規則計算,恢復歷史時不會重新同步,畫面、最終結果和 AI 狀態便可能不一致。


修改方案

一、修改兩處 DEFAULT_EDGES

Worker 和主程式中各有一處,兩處都改成:

js
// 北、東、南、西。
// 叉零在區域計分中視為四面都是實邊;落子相容性另外處理。
const DEFAULT_EDGES = [[0], [3, 1], [0, 3], [0, 1, 3], [0, 1, 2, 3]];

也就是把最後的:

js
[]

改為:

js
[0, 1, 2, 3]

二、修正 Worker 的落子相容性

在 Worker 的 hasEdge() 後面加入:

js
function placementEdgesConflict(tile, direction, neighbour) {
  if (!tile || !neighbour) return false;

  // 叉零雖然在計分時四面都是實邊,
  // 但落子時可以與任何棋子相鄰,包括另一枚叉零。
  if (tile[1] === TYPE_CROSS || neighbour[1] === TYPE_CROSS) {
    return false;
  }

  return hasEdge(tile, direction) && hasEdge(neighbour, (direction + 2) & 3);
}

然後在 Worker 的 canPlace() 中,把:

js
if (hasEdge(candidate, d) && hasEdge(neighbour, (d + 2) & 3)) {
  return false;
}

改成:

js
if (placementEdgesConflict(candidate, d, neighbour)) {
  return false;
}

三、修正主程式的落子相容性

在主程式的 tileHasEdge() 後面加入:

js
function placementEdgesConflict(tile, direction, neighbour) {
  if (!tile || !neighbour) return false;

  // 叉零在區域計分中四面都是實邊,
  // 但落子時可與任何棋子相鄰,不受實邊重合限制。
  if (tile.type === TYPE_CROSS || neighbour.type === TYPE_CROSS) {
    return false;
  }

  return (
    tileHasEdge(tile, direction) &&
    tileHasEdge(neighbour, (direction + 2) & 3)
  );
}

validatePlacement() 中,把:

js
if (tileHasEdge(candidate, direction) && tileHasEdge(neighbour, (direction + 2) & 3)) {
  return 'edgeConflict';
}

改成:

js
if (placementEdgesConflict(candidate, direction, neighbour)) {
  return 'edgeConflict';
}

這樣會得到正確語義:

  • 普通棋子與普通棋子:兩條實邊仍不能重合。
  • 叉零與任何棋子:永遠可以相鄰。
  • 區域計分時:叉零的四邊全部阻斷區域連通。

四、修正 Worker 的提子移動格式

把 Worker 內整個 movementDestinations() 替換為:

js
function movementDestinations(board, index) {
  const tile = board[index];

  if (!tile) return [];

  const type = tile[1];
  const rot = tile[2];

  if (type === TYPE_CROSS) return [];

  const row = Math.floor(index / N);
  const col = index % N;
  const movement = [];

  if (type === TYPE_SINGLE) {
    movement.push([-1, 0, 1]);
  } else if (type === TYPE_DOUBLE) {
    movement.push([0, -1, 1], [0, 1, 1]);
  } else if (type === TYPE_CORNER) {
    movement.push([-1, -1, 1], [-1, -1, 2]);
  } else if (type === TYPE_SURROUND) {
    movement.push([-1, 0, 1], [0, -1, 1], [0, 1, 1]);
  }

  const destinations = [];
  const used = new Set();

  for (const [baseDr, baseDc, distance] of movement) {
    const [dr, dc] = rotateVector(baseDr, baseDc, rot);
    const nextRow = row + dr * distance;
    const nextCol = col + dc * distance;

    if (nextRow < 0 || nextRow >= N || nextCol < 0 || nextCol >= N) {
      continue;
    }

    const next = nextRow * N + nextCol;

    if (!used.has(next)) {
      used.add(next);
      destinations.push(next);
    }
  }

  return destinations;
}

五、修正主程式的提子移動格式

把主程式內整個 movementDestinations() 替換為:

js
function movementDestinations(board, index) {
  const tile = board[index];

  if (!tile) return [];

  const type = tile.type;
  const rot = tile.rot;

  if (type === TYPE_CROSS) return [];

  const row = Math.floor(index / N);
  const col = index % N;
  const raw = [];

  if (type === TYPE_SINGLE) {
    raw.push([-1, 0, 1]);
  } else if (type === TYPE_DOUBLE) {
    raw.push([0, -1, 1], [0, 1, 1]);
  } else if (type === TYPE_CORNER) {
    raw.push([-1, -1, 1], [-1, -1, 2]);
  } else if (type === TYPE_SURROUND) {
    raw.push([-1, 0, 1], [0, -1, 1], [0, 1, 1]);
  }

  const result = [];
  const used = new Set();

  for (const [baseDr, baseDc, distance] of raw) {
    const [dr, dc] = rotateVector(baseDr, baseDc, rot);
    const nextRow = row + dr * distance;
    const nextCol = col + dc * distance;

    if (
      nextRow < 0 ||
      nextRow >= N ||
      nextCol < 0 ||
      nextCol >= N
    ) {
      continue;
    }

    const next = nextRow * N + nextCol;

    if (!used.has(next)) {
      used.add(next);
      result.push(next);
    }
  }

  return result;
}

這一項雖然不是區域計分公式本身,但原本會導致:

  • 非叉零棋子沒有合法提子路徑。
  • 系統錯誤判定無合法提子。
  • 提子分沒有正確產生。
  • AI 搜尋結果與主程式不一致。

六、建立統一的分數同步函式

在主程式的 calculatePlacementScore() 後面加入:

js
function effectivePlacementScore(gameState) {
  // 落子階段可以從當前棋盤重新計算。
  if (gameState.phase === 'place') {
    return calculatePlacementScore(gameState.board);
  }

  // 提子開始後棋盤上的棋子會被移除,
  // 因此不能再由當前棋盤重算落子分,必須使用凍結值。
  if (Number.isFinite(gameState.placementFinal)) {
    return gameState.placementFinal;
  }

  return Number.isFinite(gameState.placementScore)
    ? gameState.placementScore
    : 0;
}

function synchronizeScoreState(gameState) {
  const placement = effectivePlacementScore(gameState);

  gameState.placementScore = placement;

  // 在落子階段持續同步凍結值。
  // 進入提子階段後不再改動 placementFinal。
  if (
    gameState.phase === 'place' ||
    !Number.isFinite(gameState.placementFinal)
  ) {
    gameState.placementFinal = placement;
  }

  if (!Array.isArray(gameState.extractRaw)) {
    gameState.extractRaw = [0, 0];
  }

  gameState.extractRaw = [
    Number.isFinite(Number(gameState.extractRaw[0]))
      ? Number(gameState.extractRaw[0])
      : 0,
    Number.isFinite(Number(gameState.extractRaw[1]))
      ? Number(gameState.extractRaw[1])
      : 0
  ];

  // roundSummary 也是衍生資料,恢復歷史時必須重新建立。
  if (gameState.roundSummary) {
    gameState.roundSummary = makeRoundSummary(gameState);
  }
}

七、每次落子同步 placementFinal

commitPlacement() 中找到:

js
state.placementScore = calculatePlacementScore(state.board);

改成:

js
state.placementScore = calculatePlacementScore(state.board);
state.placementFinal = state.placementScore;

這樣 placementFinal 始終是最後一次有效的落子分。進入提子階段後,不再根據已經被提走棋子的棋盤重新計算。

原本進入提子階段時的:

js
state.placementFinal = state.placementScore;

可以保留,雖然此時已是重複賦值。


八、讓即時分數使用一致的落子分來源

把:

js
function liveNetScore(gameState = state) {
  return gameState.placementScore + gameState.extractRaw[0] - gameState.extractRaw[1];
}

改成:

js
function liveNetScore(gameState = state) {
  return (
    effectivePlacementScore(gameState) +
    gameState.extractRaw[0] -
    gameState.extractRaw[1]
  );
}

九、修正局末摘要的分數來源

makeRoundSummary() 改成:

js
function makeRoundSummary(gameState) {
  const placement = effectivePlacementScore(gameState);
  const extractionNet =
    gameState.extractRaw[0] - gameState.extractRaw[1];

  const scoreP1 = placement + extractionNet;

  return {
    round: gameState.round,
    first: gameState.first,
    placement,
    extractRaw: gameState.extractRaw.slice(),
    extractionNet,
    scores: [scoreP1, -scoreP1],
    forfeit: gameState.forfeit
      ? clone(gameState.forfeit)
      : null
  };
}

如此即使是 undo/redo 恢復到:

  • 落子階段;
  • 提子階段;
  • 已結束狀態;
  • 直接判負狀態;

摘要也會使用正確的落子分來源。


十、修正歷史快照和 undo/redo

pushHistory() 改成:

js
function pushHistory() {
  // 快照前先同步所有衍生計分資料。
  synchronizeScoreState(state);

  timeline = timeline.slice(0, historyIndex + 1);
  timeline.push(clone(state));
  historyIndex = timeline.length - 1;
}

restoreHistory() 改成:

js
function restoreHistory(index) {
  if (index < 0 || index >= timeline.length) return;

  cancelAI(true);

  historyIndex = index;
  state = clone(timeline[index]);

  // 不直接信任快照內可能過期的衍生分數。
  synchronizeScoreState(state);

  manualPath = [];
  messageOverride = null;
  aiStatus = null;
  extractionStartCache = null;
  stateRevision++;
  resultDismissed = false;

  resultModal.hidden = true;
  renderAll();

  if (state.status !== 'playing') {
    showResultModal();
  } else {
    scheduleCurrentTurn(120);
  }
}

這會解決 undo/redo 時:

  • 面板分數沒有恢復;
  • 最終結果表仍顯示後一步的分數;
  • roundSummary 與面板分數不一致;
  • AI 收到與畫面不同的落子分;

等問題。


十一、Worker 序列化時使用同步後的落子分

serializeWorkerState() 中,把:

js
ps: state.placementScore,

改成:

js
ps: effectivePlacementScore(state),

完整相關部分應為:

js
function serializeWorkerState() {
  return {
    board: state.board.map(tile =>
      tile ? [tile.player, tile.type, tile.rot] : null
    ),
    inv: state.inventories.map(row => row.slice()),
    phase: state.phase,
    turn: state.turn,
    first: state.first,
    placeCount: state.placementCount.slice(),
    ps: effectivePlacementScore(state),
    raw: state.extractRaw.slice(),
    extractIndex: state.extractIndex,
    done: false
  };
}

十二、同步規則文字

目前規則只說叉零有交叉線,沒有說明其圍牆作用。建議中文改成:

html
<li>
  <strong>叉零:</strong>
  沒有三角形;計算落子區域時四面都視為實邊,但落子時可以與任何棋子相鄰。
</li>

英文改成:

html
<li>
  <strong>Cross Zero:</strong>
  no triangles. For placement scoring, all four sides count as solid edges,
  but it may be placed next to any piece.
</li>

落子規則的實邊重合條款也建議改為:

html
<li>除「叉零」外,相鄰棋子的兩條實邊不能在同一條格線上重合;「叉零」可與任何棋子相鄰。</li>

修正後的預期行為

  1. 叉零的上、右、下、左四邊都會阻斷區域連通。
  2. 叉零本身沒有三角形,所以不直接產生分數。
  3. 叉零形成的單獨區域若只有其自身,仍因只有一方棋子而得零分。
  4. 叉零可以和任何普通棋子或另一枚叉零相鄰。
  5. 普通棋子之間仍然禁止實邊重合。
  6. 人類及 AI 都能以非叉零棋子建立正常提子路徑。
  7. undo/redo 後會重新同步:
    • 落子分;
    • 提子累計分;
    • 即時淨分;
    • 局末摘要;
    • AI 收到的評估分數。
  8. 提子階段不會因棋子已被移除而錯誤重算落子分,會使用進入提子階段時凍結的 placementFinal

另外可把導出版本:

js
version: 3,

提升為:

js
version: 4,

以區分這次修正後的棋譜規則版本。