diff --git a/src/main/lib/types.ts b/src/main/lib/types.ts index 844c0fd..01bf951 100644 --- a/src/main/lib/types.ts +++ b/src/main/lib/types.ts @@ -849,11 +849,15 @@ export interface KataGoMoveAnalysis { before: { winrate: number scoreLead: number + ownership?: number[] + ownershipStdev?: number[] topMoves: KataGoCandidate[] } after: { winrate: number scoreLead: number + ownership?: number[] + ownershipStdev?: number[] topMoves: KataGoCandidate[] } playedMove?: { diff --git a/src/main/services/analysis/cache.ts b/src/main/services/analysis/cache.ts index 6ac33f3..2726198 100644 --- a/src/main/services/analysis/cache.ts +++ b/src/main/services/analysis/cache.ts @@ -49,6 +49,7 @@ export interface AnalysisCacheRequirement { minBestVisits?: number minActualVisits?: number minTier?: AnalysisCacheTier + requireOwnership?: boolean requireStablePv?: boolean requireMediumConfidence?: boolean allowStaleMs?: number @@ -152,6 +153,9 @@ function cacheEntryMeetsRequirement(entry: AnalysisCacheEntry, requirement: Anal if (requirement.minActualVisits && entry.quality.actualVisits < requirement.minActualVisits) { return { status: 'lower-quality', entry, reason: `actualVisits ${entry.quality.actualVisits} is below ${requirement.minActualVisits}` } } + if (requirement.requireOwnership && !hasRootOwnership(entry.analysis)) { + return { status: 'lower-quality', entry, reason: 'cached analysis does not include root ownership' } + } if (requirement.requireStablePv && pvRank[entry.quality.pvConfidence ?? 'unstable'] < pvRank.medium) { return { status: 'lower-quality', entry, reason: `PV confidence ${entry.quality.pvConfidence ?? 'unknown'} is not stable enough` } } @@ -161,6 +165,17 @@ function cacheEntryMeetsRequirement(entry: AnalysisCacheEntry, requirement: Anal return null } +function hasRootOwnership(analysis: KataGoMoveAnalysis): boolean { + const boardSize = Math.max(2, Math.round(analysis.boardSize || 19)) + const expected = boardSize * boardSize + const afterOwnership = analysis.after.ownership + const beforeOwnership = analysis.moveNumber === 0 ? analysis.before.ownership : undefined + return Boolean( + (Array.isArray(afterOwnership) && afterOwnership.length >= expected) || + (Array.isArray(beforeOwnership) && beforeOwnership.length >= expected) + ) +} + export function readAnalysisCache(input: AnalysisCacheKeyInput, requirement: AnalysisCacheRequirement = {}): AnalysisCacheLookupResult { const path = analysisCachePath(input) if (!existsSync(path)) return { status: 'miss', path, reason: 'cache file does not exist' } diff --git a/src/main/services/analysis/runtimeIntegration.ts b/src/main/services/analysis/runtimeIntegration.ts index 93717c3..093fe77 100644 --- a/src/main/services/analysis/runtimeIntegration.ts +++ b/src/main/services/analysis/runtimeIntegration.ts @@ -162,6 +162,7 @@ function cacheRequirementForProfile(profile: AdaptiveAnalysisProfile): AnalysisC minTier: profile.cacheTier, minBestVisits, minActualVisits: teaching ? Math.max(60, Math.min(profile.maxVisits, Math.round(profile.maxVisits * 0.12))) : undefined, + requireOwnership: profile.includeOwnership, requireStablePv: teaching, requireMediumConfidence: teaching, allowStaleMs: profile.cacheTier === 'oracle' ? -1 : 1000 * 60 * 60 * 24 * 30 diff --git a/src/main/services/katago.ts b/src/main/services/katago.ts index 15f1311..1715ffd 100644 --- a/src/main/services/katago.ts +++ b/src/main/services/katago.ts @@ -39,6 +39,8 @@ interface KataGoResponse { scoreStdev?: number utility?: number scoreMean?: number + ownership?: number[] + ownershipStdev?: number[] } moveInfos?: Array<{ move?: string @@ -265,7 +267,7 @@ function responseSideToMove(response: KataGoResponse, fallback: GameMove['color' : fallback } -function root(response: KataGoResponse, sideToMove: GameMove['color']): { winrate: number; scoreLead: number } { +function root(response: KataGoResponse, sideToMove: GameMove['color']): { winrate: number; scoreLead: number; ownership?: number[]; ownershipStdev?: number[] } { if (!response.rootInfo) { throw new Error(`KataGo 没有返回 rootInfo${response.error ? `: ${response.error}` : ''}`) } @@ -274,7 +276,9 @@ function root(response: KataGoResponse, sideToMove: GameMove['color']): { winrat const rawScoreLead = Number(response.rootInfo.scoreLead ?? response.rootInfo.scoreMean ?? 0) return { winrate: blackWinrateFromSideToMove(rawWinrate, actualSideToMove), - scoreLead: blackScoreLeadFromSideToMove(rawScoreLead, actualSideToMove) + scoreLead: blackScoreLeadFromSideToMove(rawScoreLead, actualSideToMove), + ownership: Array.isArray(response.rootInfo.ownership) ? response.rootInfo.ownership.map(Number) : undefined, + ownershipStdev: Array.isArray(response.rootInfo.ownershipStdev) ? response.rootInfo.ownershipStdev.map(Number) : undefined } } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 62a5b84..6848a83 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,4 +1,4 @@ -import type { FormEvent, KeyboardEvent, PointerEvent, ReactElement, ReactNode } from 'react' +import type { FormEvent, KeyboardEvent, PointerEvent, ReactElement, ReactNode, WheelEvent } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { AnalyzeGameQuickProgress, @@ -42,8 +42,10 @@ import lizzieWhiteStoneUrl from './assets/lizzie/white.png' import logoUrl from '../../../assets/logo.png' import { GoBoardV2 } from './features/board/GoBoardV2' import type { KeyMoveSummary } from './features/board/KeyMoveNavigator' +import { TerritoryControlPanel, TerritorySummaryStrip } from './features/board/TerritoryJudgementPanel' import { WinrateTimelineV2 } from './features/board/WinrateTimelineV2' -import { boardPointLabel, parseBoardPoint, type BoardPoint, type RenderKeyMove } from './features/board/boardGeometry' +import { boardPointLabel, parseBoardPoint, renderStones, type BoardPoint, type RenderKeyMove } from './features/board/boardGeometry' +import { buildTerritoryJudgement } from './features/board/territoryJudgement' import { addTrialMove, clearTrialMoves, @@ -167,6 +169,27 @@ const emptyDashboard: DashboardData = { } } +const REPLAY_WHEEL_STEP_PX = 44 +const REPLAY_WHEEL_MAX_STEPS_PER_EVENT = 6 + +function isReplayNavigationTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return true + } + return !target.closest('input, textarea, select, button, [contenteditable="true"], [role="textbox"], [role="combobox"], [role="listbox"], [data-disable-replay-wheel="true"]') +} + +function normalizedWheelPixels(event: WheelEvent): number { + const primaryDelta = Math.abs(event.deltaY) >= Math.abs(event.deltaX) ? event.deltaY : event.deltaX + if (event.deltaMode === 1) { + return primaryDelta * 16 + } + if (event.deltaMode === 2) { + return primaryDelta * 360 + } + return primaryDelta +} + function uniqueModelOptions(models: string[]): string[] { return Array.from(new Set(models.map((model) => model.trim()).filter(Boolean))) } @@ -800,6 +823,8 @@ export function App(): ReactElement { const [prompt, setPrompt] = useState('') const [moveRange, setMoveRange] = useState<{ start: number; end: number } | null>(null) const [boardFlash, setBoardFlash] = useState<(BoardPoint & { nonce: number; label: string }) | null>(null) + const [territoryEnabled, setTerritoryEnabled] = useState(false) + const [territoryBusy, setTerritoryBusy] = useState(false) const [busy, setBusy] = useState('') const [graphBusy, setGraphBusy] = useState(false) const [graphProgress, setGraphProgress] = useState('') @@ -827,6 +852,7 @@ export function App(): ReactElement { const graphRunId = useRef('') const liveAnalysisRunId = useRef('') const trialAnalysisRunId = useRef('') + const territoryAnalysisRunId = useRef('') const trialAnalysisCacheRef = useRef>({}) const autoAnalysisRequestId = useRef('') const userPausedLiveAnalysisRef = useRef(false) @@ -834,6 +860,7 @@ export function App(): ReactElement { const recordRef = useRef(record) const trialBranchRef = useRef(trialBranch) const jumpToMoveRef = useRef<(next: number) => void>(() => {}) + const replayWheelAccumulatorRef = useRef(0) const teacherBusyRef = useRef(false) const activeTeacherRunRef = useRef(null) const selectedGameIdRef = useRef('') @@ -1078,6 +1105,14 @@ export function App(): ReactElement { const boardRecord = trialRecord ?? record const boardMoveNumber = trialRecord ? trialRecord.moves.length : moveNumber const boardAnalysis = trialBranch.active ? trialAnalysis : currentAnalysis + const boardTerritoryStones = useMemo( + () => boardRecord ? renderStones(boardRecord, boardMoveNumber) : [], + [boardRecord, boardMoveNumber] + ) + const territoryJudgement = useMemo( + () => buildTerritoryJudgement(boardAnalysis, boardRecord?.boardSize ?? record?.boardSize ?? 19, boardTerritoryStones), + [boardAnalysis, boardRecord?.boardSize, boardTerritoryStones, record?.boardSize] + ) const displayBoardKeyMoveMarks = trialBranch.active ? [] : currentBoardKeyMoveMarks useEffect(() => { @@ -1853,6 +1888,33 @@ export function App(): ReactElement { } } + function handleReplayWheel(event: WheelEvent): void { + const rec = recordRef.current + if (!rec || teacherBusyRef.current || event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || !isReplayNavigationTarget(event.target)) { + return + } + const delta = normalizedWheelPixels(event) + if (!Number.isFinite(delta) || Math.abs(delta) < 1) { + return + } + event.preventDefault() + replayWheelAccumulatorRef.current += delta + const rawSteps = Math.trunc(replayWheelAccumulatorRef.current / REPLAY_WHEEL_STEP_PX) + if (rawSteps === 0) { + return + } + const steps = Math.max(-REPLAY_WHEEL_MAX_STEPS_PER_EVENT, Math.min(REPLAY_WHEEL_MAX_STEPS_PER_EVENT, rawSteps)) + replayWheelAccumulatorRef.current -= steps * REPLAY_WHEEL_STEP_PX + const currentMove = moveNumberRef.current + const nextMove = Math.max(0, Math.min(rec.moves.length, currentMove + steps)) + if (nextMove === currentMove) { + replayWheelAccumulatorRef.current = 0 + return + } + moveNumberRef.current = nextMove + jumpToMoveRef.current(nextMove) + } + function trialCacheKey(gameId: string, branch: TrialBranch): string { return `${gameId}:${branch.branchHash}:${dashboard.settings.katagoModelPreset || dashboard.settings.katagoModel || 'model'}` } @@ -2455,6 +2517,124 @@ export function App(): ReactElement { } } + async function runTerritoryJudgementAnalysis(): Promise { + const gameId = selectedGame?.id + const targetRecord = record + const targetMove = moveNumber + if (!gameId || !targetRecord || busy === 'teacher') { + return + } + const runId = crypto.randomUUID() + territoryAnalysisRunId.current = runId + setTerritoryEnabled(true) + setTerritoryBusy(true) + setError('') + setLiveAnalysis((current) => ({ + ...current, + running: true, + status: `形势判断中 · 第 ${targetMove} 手`, + targetMoveNumber: targetMove, + visits: 0, + bestVisits: 0, + visitsPerSecond: 0 + })) + await cancelKataGoWork({ group: 'single' }) + const disposeSearchProgress = window.goagent.onAnalyzePositionSearchProgress((progress) => { + if ( + territoryAnalysisRunId.current !== runId || + progress.runId !== runId || + progress.gameId !== gameId || + progress.moveNumber !== targetMove || + selectedGameIdRef.current !== gameId + ) { + return + } + setLiveAnalysis((current) => ({ + ...current, + running: progress.isDuringSearch, + status: progress.isDuringSearch ? `形势判断搜索 ${formatVisits(progress.visits)}` : current.status, + visits: Math.max(current.visits, progress.visits), + visitsPerSecond: progress.visitsPerSecond > 0 ? progress.visitsPerSecond : current.visitsPerSecond, + targetMoveNumber: targetMove + })) + }) + const disposeProgress = window.goagent.onAnalyzePositionProgress((progress) => { + if ( + territoryAnalysisRunId.current !== runId || + progress.runId !== runId || + progress.gameId !== gameId || + progress.moveNumber !== targetMove || + selectedGameIdRef.current !== gameId + ) { + return + } + rememberEvaluation(progress.analysis, { force: true }) + if (moveNumberRef.current === targetMove) { + setAnalysis(progress.analysis) + } + setLiveAnalysis((current) => ({ + ...current, + running: !progress.isFinal, + status: progress.isFinal ? '形势判断完成' : `形势判断搜索 ${formatVisits(candidateVisitsTotal(progress.analysis))}`, + visits: Math.max(current.visits, candidateVisitsTotal(progress.analysis)), + bestVisits: Math.max(current.bestVisits, candidateBestVisits(progress.analysis)), + targetMoveNumber: targetMove + })) + }) + try { + const nextAnalysis = await window.goagent.analyzePositionStream({ + gameId, + moveNumber: targetMove, + maxVisits: 1400, + runId, + group: 'single', + reportDuringSearchEvery: 0.25 + }) + if (territoryAnalysisRunId.current !== runId || selectedGameIdRef.current !== gameId) { + return + } + rememberEvaluation(nextAnalysis, { force: true }) + if (moveNumberRef.current === targetMove) { + setAnalysis(nextAnalysis) + } + setLiveAnalysis((current) => ({ + ...current, + running: false, + status: '形势判断完成', + visits: Math.max(current.visits, candidateVisitsTotal(nextAnalysis)), + bestVisits: Math.max(current.bestVisits, candidateBestVisits(nextAnalysis)), + targetMoveNumber: targetMove + })) + } catch (cause) { + if (territoryAnalysisRunId.current === runId) { + const message = kataGoRuntimeErrorMessage(cause) || String(cause) + setError(`形势判断失败: ${message}`) + setLiveAnalysis((current) => ({ + ...current, + running: false, + status: message + })) + } + } finally { + disposeProgress() + disposeSearchProgress() + if (territoryAnalysisRunId.current === runId) { + setTerritoryBusy(false) + } + } + } + + function toggleTerritoryJudgement(): void { + if (territoryEnabled) { + setTerritoryEnabled(false) + return + } + setTerritoryEnabled(true) + if (!territoryJudgement.available && !territoryBusy) { + void runTerritoryJudgementAnalysis() + } + } + async function runMoveAnalysisAt(targetMoveNumber: number): Promise { if (!record || !selectedGame || busy !== '') { return @@ -2782,7 +2962,7 @@ export function App(): ReactElement { ) : null} -
+
{record ? ( + )} onStart={() => void startLiveAnalysis()} onPause={() => pauseLiveAnalysis(t('pausedFineReview'), true)} onToggleTrial={trialBranch.active ? exitTrialMode : enterTrialMode} @@ -2821,6 +3011,7 @@ export function App(): ReactElement { keyMoves={displayBoardKeyMoveMarks} flashPoint={boardFlash} trialBranch={trialBranch.active ? trialBranch : null} + territoryJudgement={territoryEnabled ? territoryJudgement : null} onPointClick={trialBranch.active ? playTrialPoint : undefined} onPointContextMenu={trialBranch.active ? undoTrialBranch : undefined} t={t} @@ -2857,7 +3048,9 @@ export function App(): ReactElement { onRangeClear={handleTimelineRangeClear} rangeStart={moveRange?.start ?? null} rangeEnd={moveRange?.end ?? null} - summary={timelineFinalRecordScore ? ( + summary={territoryEnabled ? ( + + ) : timelineFinalRecordScore ? (
{t('recordScoreLead')} {timelineFinalRecordScore.displayLead} @@ -5292,6 +5485,7 @@ function BoardContextBar({ liveAnalysis, disabled, trialBranch, + territoryControl, onStart, onPause, onToggleTrial, @@ -5306,6 +5500,7 @@ function BoardContextBar({ liveAnalysis: LiveAnalysisState disabled: boolean trialBranch: TrialBranch + territoryControl?: ReactElement onStart: () => void onPause: () => void onToggleTrial: () => void @@ -5317,12 +5512,7 @@ function BoardContextBar({ const scoreLead = analysis?.after.scoreLead const winrate = analysis?.after.winrate const isCurrentLiveTarget = liveAnalysis.targetMoveNumber === moveNumber - const totalVisits = isCurrentLiveTarget ? liveAnalysis.visits : candidateVisitsTotal(analysis) - const bestVisits = isCurrentLiveTarget ? liveAnalysis.bestVisits : candidateBestVisits(analysis) const finalRecordScore = !trialBranch.active && moveNumber === record.moves.length ? gameResultLeadForUi(record.game, t) : null - const status = isCurrentLiveTarget - ? liveAnalysis.status - : (analysis ? t('analysisSearched', { visits: formatVisits(totalVisits) }) : t('analysisWaiting')) const speedLabel = isCurrentLiveTarget && liveAnalysis.visitsPerSecond > 0 ? formatSearchSpeed(liveAnalysis.visitsPerSecond) : isCurrentLiveTarget && liveAnalysis.running @@ -5347,10 +5537,6 @@ function BoardContextBar({ {t('katagoScoreLead')}: {formatScoreLead(scoreLead, t)} ) : null}
-
- {status} - {t('totalAndBestVisits', { total: formatVisits(totalVisits), best: formatVisits(bestVisits) })} -
{t('searchSpeed')} {speedLabel} @@ -5385,6 +5571,12 @@ function BoardContextBar({ ) : null} + {territoryControl ? ( + <> + + {territoryControl} + + ) : null} + {enabled ? ( + <> + + {t('territoryConfidence')}{confidenceLabel(judgement.confidence, t)} + + + + {t('territoryBlackTerritory')} + + {t('territoryWhiteTerritory')} + + {t('territoryContested')} + + {busy ? {t('timelineLoading')} : null} + + ) : null} +
+ ) +} + +export function TerritorySummaryStrip({ judgement, compact = false, t }: TerritorySummaryStripProps): ReactElement { + const whiteShare = Math.max(0, Math.min(100, 100 - judgement.blackShare)) + if (compact) { + return ( +
+ + {t('territoryJudgement')} + {judgement.leadText} + + + + + + + {confidenceLabel(judgement.confidence, t)} + +
+ ) + } + return ( +
+
+
+ {t('territoryLead')} + {judgement.leadText} +
+
+ {t('territoryBlackStrong')} + {judgement.available ? judgement.blackStrong : '—'} +
+
+ {t('territoryWhiteStrong')} + {judgement.available ? judgement.whiteStrong : '—'} +
+
+ {t('territoryUnsettled')} + {judgement.available ? judgement.unsettled : '—'} +
+
+ {t('territoryConfidence')} + {confidenceLabel(judgement.confidence, t)} +
+
+
+ + + {judgement.available ? `${judgement.blackShare}% / ${whiteShare}%` : t('territoryNeedDeepen')} +
+
+ {judgement.available && judgement.regions.length > 0 ? judgement.regions.slice(0, 4).map((region) => ( + + + {region.label} + {region.owner === 'B' ? t('black') : region.owner === 'W' ? t('white') : t('territoryUnclear')} + + )) : ( + + + {judgement.note} + + )} +
+
+ ) +} diff --git a/src/renderer/src/features/board/WinrateTimelineV2.tsx b/src/renderer/src/features/board/WinrateTimelineV2.tsx index 2aaeb49..9312235 100644 --- a/src/renderer/src/features/board/WinrateTimelineV2.tsx +++ b/src/renderer/src/features/board/WinrateTimelineV2.tsx @@ -128,7 +128,7 @@ export function WinrateTimelineV2({ }: WinrateTimelineV2Props): ReactElement { const t = providedT ?? ((key: string, vars?: Record) => { const fallback: Record = { - timelineAria: '胜率图,点击后可用左右方向键切换手数', + timelineAria: '胜率图,可用鼠标滚轮或左右方向键切换手数', timelineTitle: '胜率走势', timelineLoading: '分析中', timelineCurrentBlackWinrate: '当前黑胜率', diff --git a/src/renderer/src/features/board/board-v2.css b/src/renderer/src/features/board/board-v2.css index f6b929d..d9c9dc5 100644 --- a/src/renderer/src/features/board/board-v2.css +++ b/src/renderer/src/features/board/board-v2.css @@ -49,6 +49,86 @@ fill: rgba(39, 22, 11, 0.72); } +.ks-territory-layer { + pointer-events: none; + mix-blend-mode: normal; + transition: opacity var(--ks-motion-med, 180ms ease); +} + +.ks-territory-layer--low { + opacity: 0.82; +} + +.ks-territory-layer--medium, +.ks-territory-layer--high { + opacity: 0.96; +} + +.ks-territory-cell, +.ks-territory-mark, +.ks-territory-dot { + pointer-events: none; + vector-effect: non-scaling-stroke; +} + +.ks-territory-cell--B, +.ks-territory-mark--B { + fill: rgba(18, 22, 24, 0.52); + stroke: rgba(9, 11, 12, 0.52); + stroke-width: 1.1; +} + +.ks-territory-cell--W, +.ks-territory-mark--W { + fill: rgba(255, 252, 241, 0.66); + stroke: rgba(42, 59, 61, 0.42); + stroke-width: 1.15; +} + +.ks-territory-cell--unclear, +.ks-territory-mark--unclear { + fill: rgba(224, 169, 71, 0.5); + stroke: rgba(131, 86, 24, 0.3); + stroke-width: 0.9; +} + +.ks-territory-dot--B { + fill: rgba(8, 10, 11, 0.9); + stroke: rgba(255, 244, 211, 0.24); + stroke-width: 0.9; +} + +.ks-territory-dot--W { + fill: rgba(255, 255, 250, 0.96); + stroke: rgba(30, 39, 42, 0.68); + stroke-width: 1.1; +} + +.ks-territory-dot--unclear { + fill: rgba(215, 147, 35, 0.78); + stroke: rgba(255, 248, 221, 0.5); + stroke-width: 0.65; +} + +.ks-territory-layer--heat .ks-territory-cell { + filter: drop-shadow(0 1px 2px rgb(73 43 15 / 0.14)); +} + +.ks-territory-layer--blocks .ks-territory-cell { + filter: none; + stroke-width: 1; + shape-rendering: geometricPrecision; +} + +.ks-territory-layer--marks { + mix-blend-mode: normal; +} + +.ks-territory-layer--marks .ks-territory-mark { + stroke-width: 1.1; + filter: drop-shadow(0 1px 1px rgb(82 55 20 / 0.18)); +} + .ks-board-coordinates-v2 text { fill: rgba(50, 31, 17, 0.58); font-size: 12px; @@ -96,6 +176,56 @@ opacity: 0.24; } +.ks-stone--territory-stranded .ks-stone-body { + opacity: 0.86; +} + +.ks-stone--territory-stranded .ks-stone-highlight { + opacity: 0.18; +} + +.ks-territory-stranded-stones-layer { + pointer-events: none; +} + +.ks-territory-stranded-stone { + pointer-events: none; + filter: drop-shadow(0 1.4px 2px rgb(40 25 10 / 0.18)); +} + +.ks-territory-stranded-stone__chip, +.ks-territory-stranded-stone__chip-core { + vector-effect: non-scaling-stroke; +} + +.ks-territory-stranded-stone__chip { + fill: var(--ks-territory-stranded-fill); + stroke: var(--ks-territory-stranded-stroke); + stroke-width: 1.35; + paint-order: stroke; +} + +.ks-territory-stranded-stone__chip-core { + fill: var(--ks-territory-stranded-core); + opacity: 0.72; +} + +.ks-territory-stranded-stone--strong .ks-territory-stranded-stone__chip { + stroke-width: 1.55; +} + +.ks-territory-stranded-stone--in-B { + --ks-territory-stranded-fill: rgba(12, 15, 17, 0.88); + --ks-territory-stranded-stroke: rgba(255, 247, 221, 0.72); + --ks-territory-stranded-core: rgba(255, 247, 221, 0.44); +} + +.ks-territory-stranded-stone--in-W { + --ks-territory-stranded-fill: rgba(255, 251, 238, 0.92); + --ks-territory-stranded-stroke: rgba(20, 26, 28, 0.62); + --ks-territory-stranded-core: rgba(20, 26, 28, 0.3); +} + .ks-trial-stones-layer { pointer-events: none; } diff --git a/src/renderer/src/features/board/territoryJudgement.ts b/src/renderer/src/features/board/territoryJudgement.ts new file mode 100644 index 0000000..a1f3801 --- /dev/null +++ b/src/renderer/src/features/board/territoryJudgement.ts @@ -0,0 +1,265 @@ +import type { KataGoMoveAnalysis } from '@main/lib/types' +import { boardPointLabel, type BoardPoint } from './boardGeometry' + +export type TerritoryDisplayMode = 'heat' | 'blocks' | 'marks' +export type TerritoryOwner = 'B' | 'W' +export type TerritorySource = 'root' | 'best-continuation' | 'unavailable' +export type TerritoryConfidence = 'high' | 'medium' | 'low' | 'missing' + +export interface TerritoryCell extends BoardPoint { + owner: TerritoryOwner + value: number + strength: number + label: string +} + +export interface TerritoryBoardStone extends BoardPoint { + color: TerritoryOwner +} + +export interface TerritoryRegionSummary { + id: string + label: string + owner: TerritoryOwner | 'unclear' + average: number + points: string[] +} + +export interface TerritoryJudgement { + available: boolean + source: TerritorySource + confidence: TerritoryConfidence + boardSize: number + moveNumber?: number + scoreLead?: number + leadText: string + blackStrong: number + whiteStrong: number + unsettled: number + blackInfluence: number + whiteInfluence: number + blackShare: number + cells: TerritoryCell[] + regions: TerritoryRegionSummary[] + note: string +} + +const OWNERSHIP_VISUAL_THRESHOLD = 0.055 +const STRONG_OWNERSHIP_THRESHOLD = 0.52 +const UNSETTLED_THRESHOLD = 0.24 +const OWNERSHIP_STONE_CALIBRATION_THRESHOLD = 0.18 + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)) +} + +function finite(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function validOwnership(value: unknown, boardSize: number): number[] | null { + if (!Array.isArray(value) || value.length < boardSize * boardSize) { + return null + } + const numbers = value.slice(0, boardSize * boardSize).map((item) => Number(item)) + return numbers.every((item) => Number.isFinite(item)) ? numbers : null +} + +function ownershipSource(analysis: KataGoMoveAnalysis | null | undefined, boardSize: number): { source: TerritorySource; ownership: number[] | null } { + const root = validOwnership(analysis?.after.ownership, boardSize) + if (root) { + return { source: 'root', ownership: root } + } + const beforeRoot = analysis?.moveNumber === 0 ? validOwnership(analysis?.before.ownership, boardSize) : null + if (beforeRoot) { + return { source: 'root', ownership: beforeRoot } + } + const bestContinuation = + validOwnership(analysis?.after.topMoves?.[0]?.ownership, boardSize) ?? + validOwnership(analysis?.before.topMoves?.[0]?.ownership, boardSize) + return { source: bestContinuation ? 'best-continuation' : 'unavailable', ownership: bestContinuation } +} + +function calibratedOwnershipSign(ownership: number[], boardSize: number, stones: TerritoryBoardStone[] = []): 1 | -1 { + let agreement = 0 + let evidence = 0 + for (const stone of stones) { + if (stone.x < 0 || stone.y < 0 || stone.x >= boardSize || stone.y >= boardSize) { + continue + } + const raw = finite(ownership[stone.y * boardSize + stone.x]) + if (typeof raw !== 'number' || Math.abs(raw) < OWNERSHIP_STONE_CALIBRATION_THRESHOLD) { + continue + } + agreement += stone.color === 'B' ? raw : -raw + evidence += Math.abs(raw) + } + // KataGo ownership should usually agree with stones already on the board. + // Some engines/transports expose the sign from the other perspective; flip it + // when the actual stones give clear opposite-color evidence. + return evidence >= 3 && agreement < -evidence * 0.18 ? -1 : 1 +} + +function confidenceFor(input: { + source: TerritorySource + analysis?: KataGoMoveAnalysis | null + unsettled: number + boardSize: number +}): TerritoryConfidence { + if (input.source === 'unavailable') { + return 'missing' + } + const bestVisits = Math.max( + 0, + finite(input.analysis?.after.topMoves?.[0]?.visits) ?? 0, + finite(input.analysis?.before.topMoves?.[0]?.visits) ?? 0, + finite(input.analysis?.analysisQuality?.bestVisits) ?? 0 + ) + const unsettledRatio = input.unsettled / Math.max(1, input.boardSize * input.boardSize) + if (input.source === 'root' && bestVisits >= 700 && unsettledRatio < 0.42) { + return 'high' + } + if (input.source === 'root' && bestVisits >= 220) { + return 'medium' + } + if (input.source === 'best-continuation' && bestVisits >= 500) { + return 'medium' + } + return 'low' +} + +function leadText(scoreLead: number | undefined): string { + if (typeof scoreLead !== 'number' || !Number.isFinite(scoreLead)) { + return '待分析' + } + if (Math.abs(scoreLead) < 0.05) { + return '均势' + } + return `${scoreLead > 0 ? '黑' : '白'}领先 ${Math.abs(scoreLead).toFixed(1)}目` +} + +function regionFor(point: BoardPoint, boardSize: number): string { + const third = boardSize / 3 + const twoThirds = boardSize - third + if (point.x >= third && point.x < twoThirds && point.y >= third && point.y < twoThirds) return 'center' + const horizontal = point.x < boardSize / 2 ? 'left' : 'right' + const vertical = point.y < boardSize / 2 ? 'top' : 'bottom' + return `${vertical}-${horizontal}` +} + +function regionLabel(id: string): string { + const labels: Record = { + 'top-left': '左上', + 'top-right': '右上', + 'bottom-left': '左下', + 'bottom-right': '右下', + center: '中腹' + } + return labels[id] ?? id +} + +function buildRegions(cells: TerritoryCell[], boardSize: number): TerritoryRegionSummary[] { + const buckets = new Map() + for (const cell of cells) { + const id = regionFor(cell, boardSize) + const bucket = buckets.get(id) ?? [] + bucket.push(cell) + buckets.set(id, bucket) + } + return Array.from(buckets.entries()).map(([id, bucket]) => { + const average = bucket.reduce((sum, cell) => sum + cell.value, 0) / Math.max(1, bucket.length) + const owner: TerritoryRegionSummary['owner'] = Math.abs(average) < 0.18 ? 'unclear' : average > 0 ? 'B' : 'W' + return { + id, + label: regionLabel(id), + owner, + average: Math.round(average * 100) / 100, + points: bucket + .slice() + .sort((a, b) => b.strength - a.strength) + .slice(0, 3) + .map((cell) => cell.label) + } + }).sort((a, b) => Math.abs(b.average) - Math.abs(a.average)).slice(0, 5) +} + +export function buildTerritoryJudgement( + analysis: KataGoMoveAnalysis | null | undefined, + boardSizeInput = 19, + stones: TerritoryBoardStone[] = [] +): TerritoryJudgement { + const boardSize = Math.max(2, Math.round(boardSizeInput || analysis?.boardSize || 19)) + const { source, ownership } = ownershipSource(analysis, boardSize) + const scoreLead = finite(analysis?.after.scoreLead) + if (!ownership) { + return { + available: false, + source: 'unavailable', + confidence: 'missing', + boardSize, + moveNumber: analysis?.moveNumber, + scoreLead, + leadText: leadText(scoreLead), + blackStrong: 0, + whiteStrong: 0, + unsettled: 0, + blackInfluence: 0, + whiteInfluence: 0, + blackShare: 50, + cells: [], + regions: [], + note: '当前分析没有 ownership 数据。请加深当前手分析后再看形势判断。' + } + } + + const sign = calibratedOwnershipSign(ownership, boardSize, stones) + let blackStrong = 0 + let whiteStrong = 0 + let unsettled = 0 + let blackInfluence = 0 + let whiteInfluence = 0 + const cells: TerritoryCell[] = [] + for (let y = 0; y < boardSize; y += 1) { + for (let x = 0; x < boardSize; x += 1) { + const value = clamp(Number(ownership[y * boardSize + x] ?? 0) * sign, -1, 1) + const strength = Math.abs(value) + if (value >= STRONG_OWNERSHIP_THRESHOLD) blackStrong += 1 + if (value <= -STRONG_OWNERSHIP_THRESHOLD) whiteStrong += 1 + if (strength <= UNSETTLED_THRESHOLD) unsettled += 1 + if (value > 0) blackInfluence += value + if (value < 0) whiteInfluence += Math.abs(value) + if (strength >= OWNERSHIP_VISUAL_THRESHOLD) { + const point = { x, y } + cells.push({ + ...point, + owner: value >= 0 ? 'B' : 'W', + value, + strength, + label: boardPointLabel(point, boardSize) + }) + } + } + } + const totalInfluence = blackInfluence + whiteInfluence + const confidence = confidenceFor({ source, analysis, unsettled, boardSize }) + return { + available: true, + source, + confidence, + boardSize, + moveNumber: analysis?.moveNumber, + scoreLead, + leadText: leadText(scoreLead), + blackStrong, + whiteStrong, + unsettled, + blackInfluence: Math.round(blackInfluence * 10) / 10, + whiteInfluence: Math.round(whiteInfluence * 10) / 10, + blackShare: totalInfluence > 0 ? Math.round((blackInfluence / totalInfluence) * 100) : 50, + cells, + regions: buildRegions(cells, boardSize), + note: source === 'root' + ? '基于 KataGo 当前局面的 ownership。' + : '当前没有 root ownership,暂用一选后续作为参考,不能当作实战最终归属。' + } +} diff --git a/src/renderer/src/features/gallery/UiGallery.tsx b/src/renderer/src/features/gallery/UiGallery.tsx index fac46a4..e97adc6 100644 --- a/src/renderer/src/features/gallery/UiGallery.tsx +++ b/src/renderer/src/features/gallery/UiGallery.tsx @@ -4,8 +4,10 @@ import { BoardInsightPanel } from '../board/BoardInsightPanel' import { CandidateTooltip } from '../board/CandidateTooltip' import { GoBoardV2 } from '../board/GoBoardV2' import { KeyMoveNavigator } from '../board/KeyMoveNavigator' +import { TerritoryControlPanel, TerritorySummaryStrip } from '../board/TerritoryJudgementPanel' import { WinrateTimelineV2 } from '../board/WinrateTimelineV2' -import { parseBoardPoint, type RenderKeyMove } from '../board/boardGeometry' +import { parseBoardPoint, renderStones, type RenderKeyMove } from '../board/boardGeometry' +import { buildTerritoryJudgement } from '../board/territoryJudgement' import { DiagnosticsPanel } from '../diagnostics/DiagnosticsPanel' import { BetaAcceptancePanel } from '../release/BetaAcceptancePanel' import { StudentBindingDialog } from '../student/StudentBindingDialog' @@ -14,6 +16,7 @@ import { KataGoAssetsPanel } from '../settings/KataGoAssetsPanel' import { RuntimeSettingsPanel } from '../settings/RuntimeSettingsPanel' import { TeacherComposerPro } from '../teacher/TeacherComposerPro' import { TeacherRunCardPro } from '../teacher/TeacherRunCardPro' +import { createUiTranslator } from '../../i18n' import { galleryAnalysis, galleryEvaluations, @@ -48,8 +51,14 @@ function noopForm(event: FormEvent): void { export function UiGallery(): ReactElement { const [moveNumber, setMoveNumber] = useState(24) const [composerValue, setComposerValue] = useState('') + const [territoryEnabled, setTerritoryEnabled] = useState(true) const [dialogOpen, setDialogOpen] = useState(() => new URLSearchParams(window.location.search).has('dialog')) const boardKeyMoves = useMemo(() => keyMoveMarks(), []) + const territoryJudgement = useMemo( + () => buildTerritoryJudgement(galleryAnalysis, galleryRecord.boardSize, renderStones(galleryRecord, moveNumber)), + [moveNumber] + ) + const t = useMemo(() => createUiTranslator('zh-CN'), []) return (
@@ -73,7 +82,16 @@ export function UiGallery(): ReactElement { moveNumber={moveNumber} analysis={galleryAnalysis} keyMoves={boardKeyMoves} + territoryJudgement={territoryEnabled ? territoryJudgement : null} /> +
+ setTerritoryEnabled((value) => !value)} + t={t} + /> +
} /> { + const x = index % boardSize + const y = Math.floor(index / boardSize) + const leftBias = x < 7 ? 0.46 : 0 + const rightBias = x > 12 ? -0.38 : 0 + const topLeft = x < 8 && y < 8 ? 0.35 : 0 + const bottomRight = x > 10 && y > 10 ? -0.42 : 0 + const centerNoise = Math.sin((x + 1) * 0.9) * Math.cos((y + 2) * 0.7) * 0.16 + return Math.max(-0.96, Math.min(0.96, leftBias + rightBias + topLeft + bottomRight + centerNoise)) + }) +} + export const galleryGame: LibraryGame = { id: 'gallery-game-01', title: 'Sprint 7 UI Gallery', @@ -90,6 +103,7 @@ export const galleryAnalysis: KataGoMoveAnalysis = { after: { winrate: 43, scoreLead: -1.8, + ownership: mockOwnership(19), topMoves: [ { move: 'Q10', winrate: 60, scoreLead: 4.4, visits: 2410, order: 1, prior: 0.19, pv: ['Q10', 'Q8', 'O10'] }, { move: 'K16', winrate: 55, scoreLead: 2.7, visits: 1488, order: 2, prior: 0.14, pv: ['K16', 'N17'] }, diff --git a/src/renderer/src/i18n.ts b/src/renderer/src/i18n.ts index 9bdaed2..c6e6047 100644 --- a/src/renderer/src/i18n.ts +++ b/src/renderer/src/i18n.ts @@ -122,7 +122,7 @@ const ZH_CN = { candidateScore: '目差', candidateVisits: '访问', candidatePrior: '先验', - timelineAria: '胜率图,点击后可用左右方向键切换手数', + timelineAria: '胜率图,可用鼠标滚轮或左右方向键切换手数', timelineTitle: '胜率走势', timelineLoading: '分析中', timelineSubtitle: '胜率 / 目差曲线', @@ -158,6 +158,23 @@ const ZH_CN = { pauseAnalysis: '暂停分析', startAnalysis: '开始分析', currentBoardMetrics: '当前局面数据', + territoryJudgement: '形势判断', + territoryConfidence: '置信 ', + territoryConfidenceHigh: '高', + territoryConfidenceMedium: '中', + territoryConfidenceLow: '低', + territoryConfidenceMissing: '待分析', + territoryLead: '形势', + territoryBlackStrong: '黑势', + territoryWhiteStrong: '白势', + territoryUnsettled: '未定', + territoryBalance: '黑白势力比例', + territoryNeedDeepen: '需要加深分析', + territoryUnclear: '未定', + territoryLegend: '形势图例', + territoryBlackTerritory: '黑地', + territoryWhiteTerritory: '白地', + territoryContested: '争夺', settingsTitle: 'GoAgent 设置', settingsSubtitle: '模型、分析与语音', settingsDescription: '管理 AI 老师、棋盘分析和讲解语音。', @@ -687,7 +704,7 @@ const EN_US: Record = { candidateScore: 'Score', candidateVisits: 'Visits', candidatePrior: 'Prior', - timelineAria: 'Winrate timeline. Click, then use left and right arrow keys to change moves.', + timelineAria: 'Winrate timeline. Use the mouse wheel or left and right arrow keys to change moves.', timelineTitle: 'Winrate trend', timelineLoading: 'Analyzing', timelineSubtitle: 'Winrate / score lead', @@ -723,6 +740,23 @@ const EN_US: Record = { pauseAnalysis: 'Pause', startAnalysis: 'Start', currentBoardMetrics: 'Current position metrics', + territoryJudgement: 'Position', + territoryConfidence: 'Confidence ', + territoryConfidenceHigh: 'High', + territoryConfidenceMedium: 'Medium', + territoryConfidenceLow: 'Low', + territoryConfidenceMissing: 'Needs analysis', + territoryLead: 'Position', + territoryBlackStrong: 'Black', + territoryWhiteStrong: 'White', + territoryUnsettled: 'Unsettled', + territoryBalance: 'Territory balance', + territoryNeedDeepen: 'Deep analysis needed', + territoryUnclear: 'Unclear', + territoryLegend: 'Territory legend', + territoryBlackTerritory: 'Black territory', + territoryWhiteTerritory: 'White territory', + territoryContested: 'Contested', settingsTitle: 'GoAgent Settings', settingsSubtitle: 'Models, analysis, and voice', settingsDescription: 'Manage the AI teacher, board analysis, and spoken explanations.', diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 319982f..19e905f 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -1779,10 +1779,6 @@ label, line-height: 1.05; } -.board-contextbar__metric--search { - min-width: 128px; -} - .board-contextbar__metric--score { min-width: 92px; } @@ -1837,6 +1833,7 @@ label, } .board-table { + position: relative; display: grid; grid-template-rows: minmax(0, 1fr); place-items: center; @@ -1856,6 +1853,213 @@ label, width: min(760px, 100%); } +.territory-control { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + max-width: calc(100% - 32px); + min-height: 38px; + padding: 4px; + gap: 6px; + border: 1px solid rgb(79 65 42 / 0.16); + border-radius: 999px; + background: rgb(255 252 245 / 0.88); + box-shadow: 0 14px 34px rgb(65 43 21 / 0.15), inset 0 1px 0 rgb(255 255 255 / 0.78); + backdrop-filter: blur(18px) saturate(1.18); +} + +.territory-control button { + height: 29px; + border: 0; + border-radius: 999px; + background: transparent; + color: #384047; + font-size: 12px; + font-weight: 850; + white-space: nowrap; +} + +.territory-control button:disabled { + cursor: default; + opacity: 0.48; +} + +.territory-control__toggle { + display: inline-flex; + align-items: center; + padding: 0 12px 0 9px; + gap: 7px; +} + +.territory-control__spark { + width: 15px; + height: 15px; + border-radius: 5px; + background: + linear-gradient(90deg, rgb(35 43 47 / 0.92) 0 47%, rgb(207 231 235 / 0.96) 53% 100%); + box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.5), 0 2px 8px rgb(28 85 86 / 0.2); +} + +.territory-control.is-enabled .territory-control__toggle { + background: rgb(21 120 119 / 0.1); + color: #0b6d69; +} + +.territory-control__confidence { + display: inline-flex; + align-items: center; + height: 24px; + padding: 0 8px; + border-radius: 999px; + background: rgb(255 255 255 / 0.55); + color: #66717a; + font-size: 10px; + font-weight: 850; + white-space: nowrap; +} + +.territory-control__confidence--high, +.territory-control__confidence--medium { + color: #0a706b; +} + +.territory-control__confidence--low, +.territory-control__confidence--missing { + color: #9b6a2e; +} + +.territory-control__legend { + display: inline-flex; + align-items: center; + height: 28px; + padding: 0 8px; + gap: 4px; + color: #5f656a; + font-size: 10px; + font-weight: 850; + white-space: nowrap; +} + +.territory-control__legend-dot { + display: inline-block; + width: 8px; + height: 8px; + margin-left: 3px; + border-radius: 50%; + box-shadow: 0 1px 2px rgb(47 37 22 / 0.18); +} + +.territory-control__legend-dot:first-child { + margin-left: 0; +} + +.territory-control__legend-dot--black { + background: #15191c; + border: 1px solid rgb(255 244 211 / 0.28); +} + +.territory-control__legend-dot--white { + background: #fffdf4; + border: 1px solid rgb(35 45 48 / 0.62); +} + +.territory-control__legend-dot--unclear { + background: #d89428; + border: 1px solid rgb(255 248 220 / 0.56); +} + +.territory-control--compact { + max-width: none; + min-height: 28px; + padding: 2px; + gap: 3px; + border-color: rgb(128 104 72 / 0.16); + background: rgb(255 252 245 / 0.58); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.68); + backdrop-filter: none; +} + +.territory-control--compact button { + height: 24px; + font-size: 10.5px; +} + +.territory-control--compact .territory-control__toggle { + padding: 0 8px 0 6px; + gap: 5px; +} + +.territory-control--compact .territory-control__spark { + width: 12px; + height: 12px; + border-radius: 4px; +} + +.territory-control--compact .territory-control__confidence { + display: none; +} + +.territory-control--compact .territory-control__legend { + height: 24px; + padding: 0 6px; + gap: 3px; + font-size: 9.5px; +} + +.territory-control--compact .territory-control__legend-dot { + width: 7px; + height: 7px; +} + +.territory-control__busy { + display: inline-flex; + align-items: center; + height: 24px; + padding: 0 7px; + border-radius: 999px; + color: #0b6d69; + font-size: 9.5px; + font-weight: 900; + white-space: nowrap; + background: rgb(221 246 241 / 0.7); +} + +.analysis-control-strip__divider { + width: 1px; + height: 22px; + background: rgb(128 104 72 / 0.16); +} + +@media (max-width: 1180px) { + .board-contextbar .territory-control--compact .territory-control__legend { + display: none; + } +} + +@media (max-width: 720px) { + .territory-control { + max-width: calc(100% - 20px); + gap: 4px; + padding: 3px; + } + + .territory-control__toggle { + padding: 0 9px 0 7px; + } + + .territory-control__confidence { + display: none; + } + + .territory-control__legend { + display: none; + } + + .territory-control__busy { + display: none; + } +} + .board-insight-stack { display: grid; width: min(760px, 100%); @@ -2059,6 +2263,202 @@ label, height: 100%; } +.territory-summary { + display: grid; + width: min(520px, 28vw); + min-width: 320px; + gap: 7px; +} + +.territory-summary--compact { + display: inline-flex; + align-items: center; + width: min(360px, 100%); + min-width: 250px; + height: 28px; + gap: 8px; + padding: 2px 8px; + overflow: hidden; + border: 1px solid rgb(69 56 42 / 0.11); + border-radius: 999px; + background: rgb(255 253 247 / 0.64); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.62); +} + +.territory-summary__metrics { + display: grid; + grid-template-columns: 1.18fr repeat(4, minmax(42px, 0.62fr)); + gap: 1px; + overflow: hidden; + border: 1px solid rgb(69 56 42 / 0.12); + border-radius: 12px; + background: rgb(255 253 247 / 0.52); +} + +.territory-summary__metric { + display: grid; + min-width: 0; + padding: 6px 8px; + background: rgb(255 255 255 / 0.5); + line-height: 1.05; +} + +.territory-summary__metric span { + overflow: hidden; + color: #7b8289; + font-size: 9px; + font-weight: 850; + text-overflow: ellipsis; + white-space: nowrap; +} + +.territory-summary__metric strong { + margin-top: 4px; + overflow: hidden; + color: #252c32; + font-size: 13px; + font-weight: 920; + text-overflow: ellipsis; + white-space: nowrap; +} + +.territory-summary__metric--lead strong { + color: #0d746f; +} + +.territory-summary__balance { + position: relative; + display: flex; + align-items: center; + height: 16px; + overflow: hidden; + border-radius: 999px; + background: rgb(222 226 228 / 0.74); + box-shadow: inset 0 1px 2px rgb(30 35 38 / 0.12); +} + +.territory-summary__bar { + display: block; + height: 100%; + transition: width 180ms ease; +} + +.territory-summary__bar--black { + background: linear-gradient(90deg, #2a3238, #657078); +} + +.territory-summary__bar--white { + background: linear-gradient(90deg, #d9eef1, #85c7d2); +} + +.territory-summary__balance em { + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: rgb(24 29 32 / 0.82); + font-size: 9px; + font-style: normal; + font-weight: 900; + text-shadow: 0 1px 0 rgb(255 255 255 / 0.45); +} + +.territory-summary__regions { + display: flex; + gap: 5px; + overflow: hidden; +} + +.territory-summary__region { + display: inline-flex; + align-items: center; + min-width: 0; + height: 20px; + padding: 0 8px; + gap: 5px; + border: 1px solid rgb(69 56 42 / 0.1); + border-radius: 999px; + background: rgb(255 255 255 / 0.54); + color: #5b626a; + font-size: 10px; + font-weight: 820; + white-space: nowrap; +} + +.territory-summary__region i { + width: 7px; + height: 7px; + border-radius: 50%; + background: #9aa3aa; +} + +.territory-summary__region b { + color: #252c32; + font-weight: 920; +} + +.territory-summary__region--B i { + background: #303940; +} + +.territory-summary__region--W i { + background: #83c7d0; +} + +.territory-summary__region--unclear { + flex: 1; +} + +.territory-summary__compact-lead { + display: inline-flex; + flex: 0 1 auto; + align-items: baseline; + min-width: 0; + gap: 5px; +} + +.territory-summary__compact-lead i { + color: #7b8289; + font-size: 9px; + font-style: normal; + font-weight: 880; + letter-spacing: 0.02em; + white-space: nowrap; +} + +.territory-summary__compact-lead strong { + min-width: 0; + overflow: hidden; + color: #0d746f; + font-size: 11.5px; + font-weight: 930; + text-overflow: ellipsis; + white-space: nowrap; +} + +.territory-summary__compact-balance { + display: inline-flex; + flex: 1 1 66px; + min-width: 56px; + height: 7px; + overflow: hidden; + border-radius: 999px; + background: rgb(222 226 228 / 0.74); + box-shadow: inset 0 1px 2px rgb(30 35 38 / 0.12); +} + +.territory-summary__compact-confidence { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + height: 18px; + padding: 0 7px; + border-radius: 999px; + font-size: 9px; + font-weight: 900; + white-space: nowrap; +} + .timeline-issues { display: grid; grid-template-rows: auto minmax(0, 1fr); @@ -3885,32 +4285,6 @@ label small { box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.72); } -.analysis-control-strip__stats { - display: grid; - min-width: 126px; - padding-left: 9px; - line-height: 1.05; -} - -.analysis-control-strip__stats span { - max-width: 180px; - overflow: hidden; - color: var(--ks-text-main); - font-size: 11px; - font-weight: 850; - text-overflow: ellipsis; - white-space: nowrap; -} - -.analysis-control-strip__stats em { - margin-top: 3px; - color: var(--ks-text-muted); - font-size: 10px; - font-style: normal; - font-weight: 750; - white-space: nowrap; -} - .analysis-mini-button { display: inline-flex; align-items: center; diff --git a/tests/mouse-wheel-replay-contract.test.mjs b/tests/mouse-wheel-replay-contract.test.mjs new file mode 100644 index 0000000..14d71b4 --- /dev/null +++ b/tests/mouse-wheel-replay-contract.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import test from 'node:test' + +const root = process.cwd() + +function read(relativePath) { + return readFileSync(join(root, relativePath), 'utf8') +} + +test('board workspace supports mouse-wheel move replay without hijacking form controls', () => { + const app = read('src/renderer/src/App.tsx') + + assert.match(app, /REPLAY_WHEEL_STEP_PX/) + assert.match(app, /REPLAY_WHEEL_MAX_STEPS_PER_EVENT/) + assert.match(app, /function isReplayNavigationTarget/) + assert.match(app, /input, textarea, select, button/) + assert.match(app, /function normalizedWheelPixels/) + assert.match(app, /function handleReplayWheel/) + assert.match(app, /event\.preventDefault\(\)/) + assert.match(app, /jumpToMoveRef\.current\(nextMove\)/) + assert.match(app, /
/) +}) diff --git a/tests/territory-judgement-contract.test.mjs b/tests/territory-judgement-contract.test.mjs new file mode 100644 index 0000000..35ebb86 --- /dev/null +++ b/tests/territory-judgement-contract.test.mjs @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import test from 'node:test' + +const root = process.cwd() +const read = (path) => readFileSync(join(root, path), 'utf8') + +test('KataGo root ownership is preserved for positional judgement', () => { + const types = read('src/main/lib/types.ts') + const katago = read('src/main/services/katago.ts') + + assert.match(types, /ownership\?: number\[\]/) + assert.match(types, /ownershipStdev\?: number\[\]/) + assert.match(katago, /rootInfo\?: \{/) + assert.match(katago, /rootInfo\.ownership/) + assert.match(katago, /rootInfo\.ownershipStdev/) + assert.match(katago, /ownership: Array\.isArray\(response\.rootInfo\.ownership\)/) +}) + +test('renderer has a reusable territory judgement model and premium board overlay', () => { + const model = read('src/renderer/src/features/board/territoryJudgement.ts') + const board = read('src/renderer/src/features/board/GoBoardV2.tsx') + const css = read('src/renderer/src/features/board/board-v2.css') + + assert.match(model, /export function buildTerritoryJudgement/) + assert.match(model, /function calibratedOwnershipSign/) + assert.match(model, /OWNERSHIP_STONE_CALIBRATION_THRESHOLD/) + assert.match(model, /TerritoryDisplayMode = 'heat' \| 'blocks' \| 'marks'/) + assert.match(model, /source: 'root'/) + assert.match(model, /best-continuation/) + assert.match(board, /territoryJudgement\?: TerritoryJudgement/) + assert.match(board, /function TerritoryOverlay/) + assert.match(board, /function territoryStrandedStones/) + assert.match(board, /function TerritoryStrandedStoneMark/) + assert.match(board, /candidates\.length > Math\.max\(8, stones\.length \* 0\.32\)/) + assert.match(board, /transform="rotate\(45\)"/) + assert.doesNotMatch(board, /rotate\(45 \$\{15\} \$\{-22\}\)/) + assert.match(board, /ks-territory-layer/) + assert.match(board, /ks-territory-stranded-stones-layer/) + assert.match(board, /ks-territory-stranded-stone--in-/) + assert.match(board, /ks-territory-dot--/) + assert.match(board, /visualOwner = isUnclear \? 'unclear' : cellInfo\.owner/) + assert.match(css, /\.ks-territory-layer--heat/) + assert.match(css, /\.ks-territory-stranded-stones-layer/) + assert.match(css, /\.ks-territory-stranded-stone--in-B/) + assert.match(css, /\.ks-territory-stranded-stone--in-W/) + assert.match(css, /\.ks-territory-stranded-stone__chip/) + assert.doesNotMatch(css, /ks-territory-stranded-stone__ring/) + assert.doesNotMatch(css, /ks-territory-stranded-stone__halo/) + assert.match(css, /\.ks-territory-layer--blocks/) + assert.match(css, /\.ks-territory-layer--marks/) + assert.match(css, /\.ks-territory-dot--B/) + assert.match(css, /\.ks-territory-dot--W/) + assert.match(css, /\.ks-territory-dot--unclear/) +}) + +test('workbench exposes positional judgement controls and summary without replacing analysis flow', () => { + const app = read('src/renderer/src/App.tsx') + const panel = read('src/renderer/src/features/board/TerritoryJudgementPanel.tsx') + const styles = read('src/renderer/src/styles.css') + const i18n = read('src/renderer/src/i18n.ts') + + assert.match(app, /const \[territoryEnabled, setTerritoryEnabled\]/) + assert.match(app, /buildTerritoryJudgement\(boardAnalysis/) + assert.match(app, /runTerritoryJudgementAnalysis/) + assert.match(app, /function toggleTerritoryJudgement/) + assert.match(app, /!territoryJudgement\.available/) + assert.match(app, /void runTerritoryJudgementAnalysis\(\)/) + assert.match(app, /territoryControl=\{\(/) + const boardTableStart = app.indexOf('
') + const boardComponentStart = app.indexOf('{boardRecord && boardRecord.boardSize >= 2', boardTableStart) + assert.ok(boardTableStart >= 0 && boardComponentStart > boardTableStart) + assert.doesNotMatch(app.slice(boardTableStart, boardComponentStart), / { + const gallery = read('src/renderer/src/features/gallery/UiGallery.tsx') + const mock = read('src/renderer/src/features/gallery/uiGalleryMock.ts') + + assert.match(mock, /function mockOwnership/) + assert.match(mock, /ownership: mockOwnership\(19\)/) + assert.match(gallery, /renderStones\(galleryRecord, moveNumber\)/) + assert.match(gallery, /TerritoryControlPanel/) + assert.match(gallery, /TerritorySummaryStrip/) + assert.match(gallery, /buildTerritoryJudgement\(galleryAnalysis/) +}) diff --git a/tests/top3-v2-development-contract.test.mjs b/tests/top3-v2-development-contract.test.mjs index 283d0cf..dd0e317 100644 --- a/tests/top3-v2-development-contract.test.mjs +++ b/tests/top3-v2-development-contract.test.mjs @@ -20,6 +20,9 @@ test('top3 v2 adds main-side analysis cache and adaptive analysis profile contra assert.match(cache, /readAnalysisCache/) assert.match(cache, /writeAnalysisCache/) assert.match(cache, /qualityFromAnalysis/) + assert.match(cache, /requireOwnership\?: boolean/) + assert.match(cache, /hasRootOwnership/) + assert.match(cache, /cached analysis does not include root ownership/) assert.match(cache, /summarizeAnalysisCache/) assert.match(profile, /AdaptiveAnalysisIntent/) assert.match(profile, /buildAdaptiveAnalysisProfile/) diff --git a/tests/top3-v3-runtime-integration-contract.test.mjs b/tests/top3-v3-runtime-integration-contract.test.mjs index 428d396..29454f3 100644 --- a/tests/top3-v3-runtime-integration-contract.test.mjs +++ b/tests/top3-v3-runtime-integration-contract.test.mjs @@ -71,5 +71,5 @@ test('V3 analysis cache is tiered and runtime cache keys include engine fingerpr assert.match(runtime, /gameFingerprint/) assert.match(runtime, /runtimeFingerprints/) assert.match(runtime, /cacheRequirementForProfile/) + assert.match(runtime, /requireOwnership: profile\.includeOwnership/) }) -