공정성 확인: 결과 화면에서 브라우저가 SHA-256(씨앗)을 직접 계산해 시작 때 공개한 값과 비교
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -612,17 +612,40 @@ function ResultPanel({ room, me, players, onLeave }: { room: RoomView; me: Publi
|
|||||||
방 나가기
|
방 나가기
|
||||||
</button>
|
</button>
|
||||||
{room.lastSeed && (
|
{room.lastSeed && (
|
||||||
<details className="small muted">
|
<FairnessCheck seedHash={room.seedHash} seed={room.lastSeed} />
|
||||||
<summary>공정성 확인</summary>
|
|
||||||
<p>시작할 때 공개한 값: {room.seedHash ?? '-'}</p>
|
|
||||||
<p>이번 판의 난수 씨앗: {room.lastSeed}</p>
|
|
||||||
<p>씨앗의 SHA-256이 위 값과 같으면 서버가 게임 도중 난수를 바꾸지 않았다는 뜻이에요.</p>
|
|
||||||
</details>
|
|
||||||
)}
|
)}
|
||||||
</Sheet>
|
</Sheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* docs/04 §5: the seed's SHA-256 was published when the game started; the seed is revealed at the end.
|
||||||
|
* The browser recomputes the hash itself so players don't have to trust the server's word.
|
||||||
|
*/
|
||||||
|
function FairnessCheck({ seedHash, seed }: { seedHash: string | null; seed: string }) {
|
||||||
|
const [verdict, setVerdict] = useState<null | boolean>(null);
|
||||||
|
const check = async () => {
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(seed));
|
||||||
|
const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
setVerdict(hex === seedHash);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<details className="small muted">
|
||||||
|
<summary>공정성 확인</summary>
|
||||||
|
<p>시작할 때 공개한 값(SHA-256): {seedHash ?? '-'}</p>
|
||||||
|
<p>끝나고 공개한 난수 씨앗: {seed}</p>
|
||||||
|
<button type="button" className="btn btn-secondary btn-sm" onClick={check}>
|
||||||
|
내 브라우저에서 직접 확인하기
|
||||||
|
</button>
|
||||||
|
{verdict !== null && (
|
||||||
|
<p style={{ fontWeight: 700, color: verdict ? 'var(--success)' : 'var(--danger)' }}>
|
||||||
|
{verdict ? '✓ 일치해요. 게임 도중 카드 순서나 난수가 바뀌지 않았어요.' : '✗ 일치하지 않아요. 관리자에게 알려 주세요.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- chat
|
// ---------------------------------------------------------------- chat
|
||||||
|
|
||||||
function ChatButton({ onOpen }: { onOpen: () => void }) {
|
function ChatButton({ onOpen }: { onOpen: () => void }) {
|
||||||
|
|||||||
@@ -129,6 +129,10 @@ try {
|
|||||||
await a.getByText('🎉 이겼어요!').waitFor();
|
await a.getByText('🎉 이겼어요!').waitFor();
|
||||||
await b.getByText('아쉽게 졌어요').waitFor();
|
await b.getByText('아쉽게 졌어요').waitFor();
|
||||||
await a.waitForTimeout(500); // let the sheet animation finish
|
await a.waitForTimeout(500); // let the sheet animation finish
|
||||||
|
// Fairness check: the browser recomputes SHA-256(seed) and compares with the published hash.
|
||||||
|
await a.getByText('공정성 확인').click();
|
||||||
|
await a.getByRole('button', { name: '내 브라우저에서 직접 확인하기' }).click();
|
||||||
|
await a.getByText('✓ 일치해요').waitFor();
|
||||||
await a.screenshot({ path: join(SHOTS, '3-result-pc.png') });
|
await a.screenshot({ path: join(SHOTS, '3-result-pc.png') });
|
||||||
await b.screenshot({ path: join(SHOTS, '4-result-phone.png') });
|
await b.screenshot({ path: join(SHOTS, '4-result-phone.png') });
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user