# 가위바위보

> 닉네임 메뉴로 다른 사람에게 가위바위보를 신청하고, 양쪽이 보낸 값을 확인해 결과를 알리는 예제예요.

닉네임 메뉴에서 상대에게 가위바위보를 신청하고, 결과를 채팅방 모두의 화면에 표시해요. 도전마다 고유 아이디와 만료 시각을 두고, 결과는 각자의 화면에서 두 사람이 직접 보낸 값으로 계산해요.

## 흐름 {#flow}

1. 신청자가 상대 닉네임 메뉴에서 **가위바위보**를 누르고 낼 것을 골라요. 낸 것은 바로 공개하지 않고 해시(SHA-256)만 모두에게 보내요(`start`).
2. 상대가 수락하고 낼 것을 고르면 모두에게 보내요(`play`). 거절하면 신청자에게만 알려요(`decline`, `sendTo`).
3. 신청자의 화면이 상대의 값을 받으면 처음 고른 값과 비밀 문자열을 공개해요(`reveal`).
4. 모든 사람의 화면이 공개된 값이 처음 해시와 같은지 확인하고 결과를 표시해요.

## 코드 {#code}

```html
<script>U=window.U=window.U||{},U.events=U.events||[],U.chat=function(n){return{on:function(e,t){U.events.push([n,e,t])},off:function(e){for(var t=U.events.length;t>0;)U.events[--t][0]==n&&U.events[t][1]==e&&U.events.splice(t,1)}}};</script>
<script async src="https://client.uchat.io/uchat.js"></script>
<u-chat room="my-room" style="display:inline-block;width:100%;max-width:400px;height:500px"></u-chat>

<script>
U.chat('my-room').on('after.create#rps-demo', function (room) {
	var ui = room.skin;
	var channel = room.plugin.add('rps-demo');
	var TO_ALL = room.plugin.TO_ALL;
	var ONLY_ME = room.plugin.ONLY_ME;
	var HANDS = { r: '✊ 바위', p: '✋ 보', s: '✌️ 가위' };
	var LIMIT = 60000; // 도전 유효 시간(밀리초)
	var games = {}; // 도전 아이디 → 진행 상태(구경하는 사람도 각자 기록)
	var secrets = {}; // 내가 신청한 도전 아이디 → { hand, salt }

	function esc(value) {
		return String(value).replace(/[&<>"']/g, function (c) {
			return '&#' + c.charCodeAt(0) + ';';
		});
	}
	function hex(bytes) {
		return Array.from(bytes, function (b) {
			return b.toString(16).padStart(2, '0');
		}).join('');
	}
	function random(size) {
		return hex(crypto.getRandomValues(new Uint8Array(size)));
	}
	function commitOf(id, hand, salt) {
		var text = new TextEncoder().encode(id + ':' + hand + ':' + salt);
		return crypto.subtle.digest('SHA-256', text).then(function (buffer) {
			return hex(new Uint8Array(buffer));
		});
	}
	function winner(a, b) {
		if (a === b) return 0;
		return (a === 'r' && b === 's') || (a === 's' && b === 'p') || (a === 'p' && b === 'r') ? 1 : 2;
	}
	function alive(game) {
		return !!game && !game.done && room.now() <= game.expires;
	}
	function pick(title) {
		return ui.dialog.select(title, '낼 것을 고르세요.', HANDS, 'r');
	}

	// 1. 신청: 낸 것은 숨기고 해시만 모두에게
	ui.userMenu.add({
		id: 'rps-demo.challenge',
		label: '가위바위보',
		condition: function (ctx) {
			return !!ctx.target && !!ctx.room.my.nick && ctx.target.nick !== ctx.room.my.nick;
		},
		onSelect: function (ctx) {
			var target = ctx.target.nick;
			pick(target + '님에게 도전').then(function (hand) {
				if (!HANDS[hand]) return;
				var id = random(8);
				var salt = random(16);
				secrets[id] = { hand: hand, salt: salt };
				return commitOf(id, hand, salt).then(function (commit) {
					channel.send(['start', id, target, String(room.now() + LIMIT), commit]);
				});
			});
		},
	});

	// 2. 상대: 수락하면 낸 것을 모두에게, 거절하면 신청자에게만
	function answer(game) {
		ui.dialog.confirm('가위바위보', game.a + '님이 가위바위보를 신청했습니다. 할까요?').then(function (ok) {
			if (!ok) return decline(game);
			return pick(game.a + '님과 대결').then(function (hand) {
				if (!HANDS[hand]) return decline(game);
				if (!alive(game)) return room.print('시간이 지나 가위바위보가 취소되었습니다.');
				channel.send(['play', game.id, hand]);
			});
		});
	}
	function decline(game) {
		game.done = true;
		if (room.now() <= game.expires) channel.sendTo(game.a, ['decline', game.id]);
	}

	function showResult(game, handA) {
		var w = winner(handA, game.handB);
		var text =
			esc(game.a) + ' ' + HANDS[handA] + ' vs ' + HANDS[game.handB] + ' ' + esc(game.b) + ' → ';
		room.print(text + (w === 0 ? '비겼습니다' : esc(w === 1 ? game.a : game.b) + '님 승리!'));
	}

	channel.onReceived(function (room, data) {
		// 보낸 사람 닉네임은 서버가 붙여 주므로 데이터 안의 이름 대신 이것을 믿는다
		var from = data.user && data.user.nick;
		var msg = data.data;
		if (!from || !Array.isArray(msg)) return;
		var game = games[msg[1]];

		if (msg[0] === 'start' && data.type === TO_ALL) {
			var now = room.now();
			var expires = Number(msg[3]);
			if (game || !/^[0-9a-f]{16}$/.test(msg[1])) return; // 같은 아이디는 한 번만
			if (typeof msg[2] !== 'string' || msg[2] === from) return;
			if (!(expires > now && expires <= now + LIMIT + 5000)) return; // 만료됐거나 너무 긴 도전
			if (!/^[0-9a-f]{64}$/.test(msg[4])) return;
			game = games[msg[1]] = { id: msg[1], a: from, b: msg[2], expires: expires, commit: msg[4] };
			if (game.a === room.my.nick) {
				room.print(esc(game.b) + '님에게 가위바위보를 신청했습니다.');
				setTimeout(function () {
					if (game.done) return;
					game.done = true;
					room.print('응답이 없어 가위바위보가 취소되었습니다.');
				}, expires - now + 1000);
			}
			if (game.b === room.my.nick) answer(game);
			return;
		}

		// 상대 본인이 보낸 값만, 한 번만 받는다
		if (msg[0] === 'play' && data.type === TO_ALL) {
			if (!alive(game) || from !== game.b || game.handB || !HANDS[msg[2]]) return;
			game.handB = msg[2];
			var secret = secrets[game.id];
			if (game.a === room.my.nick && secret) channel.send(['reveal', game.id, secret.hand, secret.salt]);
			return;
		}

		// 신청자 본인이 공개한 값이 처음 해시와 같을 때만 결과를 낸다
		if (msg[0] === 'reveal' && data.type === TO_ALL) {
			if (!alive(game) || from !== game.a || !game.handB || !HANDS[msg[2]]) return;
			if (typeof msg[3] !== 'string') return;
			commitOf(game.id, msg[2], msg[3]).then(function (commit) {
				if (commit !== game.commit || game.done) return;
				game.done = true;
				showResult(game, msg[2]);
			});
			return;
		}

		if (msg[0] === 'decline' && data.type === ONLY_ME) {
			if (!game || game.done || from !== game.b || game.a !== room.my.nick) return;
			game.done = true;
			room.print(esc(from) + '님이 거절했습니다.');
		}
	});
});
</script>
```

## 무엇을 확인하나 {#checks}

| 확인 | 막는 것 |
| --- | --- |
| 도전마다 무작위 아이디(16자리 16진수), 같은 아이디는 한 번만 등록 | 지난 도전을 다시 보내거나 다른 도전과 섞는 것 |
| 만료 시각이 지금보다 뒤이고 `LIMIT`에 5초 여유를 더한 것보다 멀지 않을 것 | 끝나지 않는 도전, 오래된 메시지 |
| `play`는 상대(`game.b`) 본인이 보낸 것만, 처음 한 번만 | 구경꾼이 대신 내거나 상대가 값을 바꾸는 것 |
| `reveal`은 신청자(`game.a`) 본인이 보낸 것만, 해시가 같을 때만 | 신청자가 상대 값을 본 뒤 낸 것을 바꾸는 것 |
| `decline`은 나에게만 온 것(`data.type === ONLY_ME`)이고 상대가 보낸 것만 | 다른 사람이 거절을 꾸미는 것 |

`data.user`는 서버가 보낸 사람의 정보로 채워 주므로 바꿀 수 없어요. 반대로 `data.data` 안의 값은 누구든 마음대로 만들 수 있으므로 닉네임 같은 값을 그 안에서 읽어 믿지 않아요.

## 짚어 볼 점 {#notes}

- 시각은 `room.now()`(서버 기준 밀리초)로 비교하므로 사람마다 컴퓨터 시계가 달라도 같은 기준을 써요.
- `crypto.subtle`은 HTTPS 페이지에서만 쓸 수 있어요.
- 신청자가 결과가 불리하다고 공개하지 않으면 도전은 만료로 끝나요. 결과를 강제해야 하는 게임이라면 서버가 판정하고 [서버 REST API](/doc/3.6)로 결과를 보내세요.
- 한 판에 전체 전송이 세 번(`start`, `play`, `reveal`) 일어나요. 무료 채팅방은 전체 전송마다 접속자 수만큼 전송 한도가 차감되므로 사람이 많은 방에서는 [채널 API와 한도](/doc/3.5)를 확인하세요.
- 상대가 `play` 전에, 신청자가 `reveal` 전에 닉네임을 바꾸면 그 도전은 만료로 끝나요. 그 뒤에 바꾸면 결과는 예전 닉네임으로 나와요.
- 대화상자(`dialog.confirm`, `dialog.select`)는 취소하면 각각 `false`, `null`을 돌려줘요. 세부는 [스킨 UI API](/doc/3.4)를 보세요.
