共享会话
當前的計分規則仍然有誤,檢查代碼,看當前是怎麼看待「叉零」棋子在構建圍牆時的作用,「叉零」應相當於一個「能與任何棋子相…
分享于 2026年9月19日 08:52
QuanAI已完成靜態檢查。問題不只在「叉零」的圍牆判定,還有 AI Worker 與主程式棋子資料格式不一致,以及 undo/redo 直接信任快照中的衍生分數,可能恢復出過期的計分狀態。
找到的問題
-
「叉零」目前被定義成四面都沒有實邊
jsconst DEFAULT_EDGES = [[0], [3, 1], [0, 3], [0, 1, 3], []];最後的
[]令叉零在區域計分中完全不構成圍牆。 -
不能只把叉零改成四面實邊
如果直接改成
[0, 1, 2, 3],現有落子檢查會認為叉零與有實邊的鄰居發生「實邊重合」,造成叉零不能與任何棋子相鄰。正確做法是分開兩種概念:
- 區域計分:叉零四面都是實邊。
- 落子相容性:只要其中一枚是叉零,就不檢查實邊重合。
-
主程式的提子移動函式讀錯棋子格式
主程式棋盤上的棋子是:
js{ player, type, rot }但
movementDestinations()卻使用:jstile[1] tile[2]導致所有非叉零棋子都沒有移動方向。
-
Worker 有相反的格式錯誤
Worker 棋子格式是:
js[player, type, rotation]但 Worker 的
movementDestinations()使用了:jstile.type tile.rot所以 AI 在提子階段也無法正確搜尋路徑,常會錯誤直接判負或只提叉零,看起來像計分出錯。
-
undo/redo 完全信任快照裡的
placementScore和roundSummary一旦快照內的衍生分數曾由錯誤規則計算,恢復歷史時不會重新同步,畫面、最終結果和 AI 狀態便可能不一致。
修改方案
一、修改兩處 DEFAULT_EDGES
Worker 和主程式中各有一處,兩處都改成:
// 北、東、南、西。
// 叉零在區域計分中視為四面都是實邊;落子相容性另外處理。
const DEFAULT_EDGES = [[0], [3, 1], [0, 3], [0, 1, 3], [0, 1, 2, 3]];也就是把最後的:
[]改為:
[0, 1, 2, 3]二、修正 Worker 的落子相容性
在 Worker 的 hasEdge() 後面加入:
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() 中,把:
if (hasEdge(candidate, d) && hasEdge(neighbour, (d + 2) & 3)) {
return false;
}改成:
if (placementEdgesConflict(candidate, d, neighbour)) {
return false;
}三、修正主程式的落子相容性
在主程式的 tileHasEdge() 後面加入:
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() 中,把:
if (tileHasEdge(candidate, direction) && tileHasEdge(neighbour, (direction + 2) & 3)) {
return 'edgeConflict';
}改成:
if (placementEdgesConflict(candidate, direction, neighbour)) {
return 'edgeConflict';
}這樣會得到正確語義:
- 普通棋子與普通棋子:兩條實邊仍不能重合。
- 叉零與任何棋子:永遠可以相鄰。
- 區域計分時:叉零的四邊全部阻斷區域連通。
四、修正 Worker 的提子移動格式
把 Worker 內整個 movementDestinations() 替換為:
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() 替換為:
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() 後面加入:
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() 中找到:
state.placementScore = calculatePlacementScore(state.board);改成:
state.placementScore = calculatePlacementScore(state.board);
state.placementFinal = state.placementScore;這樣 placementFinal 始終是最後一次有效的落子分。進入提子階段後,不再根據已經被提走棋子的棋盤重新計算。
原本進入提子階段時的:
state.placementFinal = state.placementScore;可以保留,雖然此時已是重複賦值。
八、讓即時分數使用一致的落子分來源
把:
function liveNetScore(gameState = state) {
return gameState.placementScore + gameState.extractRaw[0] - gameState.extractRaw[1];
}改成:
function liveNetScore(gameState = state) {
return (
effectivePlacementScore(gameState) +
gameState.extractRaw[0] -
gameState.extractRaw[1]
);
}九、修正局末摘要的分數來源
把 makeRoundSummary() 改成:
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() 改成:
function pushHistory() {
// 快照前先同步所有衍生計分資料。
synchronizeScoreState(state);
timeline = timeline.slice(0, historyIndex + 1);
timeline.push(clone(state));
historyIndex = timeline.length - 1;
}把 restoreHistory() 改成:
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() 中,把:
ps: state.placementScore,改成:
ps: effectivePlacementScore(state),完整相關部分應為:
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
};
}十二、同步規則文字
目前規則只說叉零有交叉線,沒有說明其圍牆作用。建議中文改成:
<li>
<strong>叉零:</strong>
沒有三角形;計算落子區域時四面都視為實邊,但落子時可以與任何棋子相鄰。
</li>英文改成:
<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>落子規則的實邊重合條款也建議改為:
<li>除「叉零」外,相鄰棋子的兩條實邊不能在同一條格線上重合;「叉零」可與任何棋子相鄰。</li>修正後的預期行為
- 叉零的上、右、下、左四邊都會阻斷區域連通。
- 叉零本身沒有三角形,所以不直接產生分數。
- 叉零形成的單獨區域若只有其自身,仍因只有一方棋子而得零分。
- 叉零可以和任何普通棋子或另一枚叉零相鄰。
- 普通棋子之間仍然禁止實邊重合。
- 人類及 AI 都能以非叉零棋子建立正常提子路徑。
- undo/redo 後會重新同步:
- 落子分;
- 提子累計分;
- 即時淨分;
- 局末摘要;
- AI 收到的評估分數。
- 提子階段不會因棋子已被移除而錯誤重算落子分,會使用進入提子階段時凍結的
placementFinal。
另外可把導出版本:
version: 3,提升為:
version: 4,以區分這次修正後的棋譜規則版本。