feat: select paired emote lines from own character

This commit is contained in:
2026-10-15 11:38:28 +09:00
parent 6a3a0ad4b9
commit fc5e475f24
10 changed files with 75 additions and 33 deletions
+7 -5
View File
@@ -1,9 +1,11 @@
export const EMOTE_LABELS = { greet: '인사', thanks: '감사', taunt: '조롱', threaten: '위협' } as const; export const EMOTE_LABELS = { greet: '인사', thanks: '감사', mistake: '실수', threaten: '위협' } as const;
export type EmoteId = keyof typeof EMOTE_LABELS; export type EmoteId = keyof typeof EMOTE_LABELS;
export const EMOTE_IDS = Object.keys(EMOTE_LABELS) as EmoteId[]; export const EMOTE_IDS = Object.keys(EMOTE_LABELS) as EmoteId[];
export const EMOTE_DURATION_MS = 3000; export const EMOTE_DURATION_MS = 3000;
export const EMOTE_COOLDOWN_MS = 4000; export const EMOTE_COOLDOWN_MS = 4000;
export const EMOTE_LINES: readonly Record<EmoteId, string>[] = [ export const EMOTE_LINES: Record<EmoteId, readonly [string, string]> = {
{ greet: '잘 부탁해!', thanks: '고마워!', taunt: '벌써 지친 거야?', threaten: '이번엔 끝낸다!' }, greet: ['반가워', '안녕하세요?'],
{ greet: '한 수 겨뤄 보자.', thanks: '고맙군.', taunt: '그게 전부인가?', threaten: '각오하는 게 좋겠군.' }, thanks: ['고마워', '감사합니다'],
]; mistake: ['실수였어', '실수할 수도 있지'],
threaten: ['끝장내주지', '도망칠 기회는 지금뿐이다'],
};
+4 -4
View File
@@ -14,7 +14,7 @@ interface FriendConfig {
matchId: string; slot: 0 | 1; engine: MatchEngine; peer: GamePeer; clock: GameClock; matchId: string; slot: 0 | 1; engine: MatchEngine; peer: GamePeer; clock: GameClock;
setOffset(offset: number): void; changed(): void; finished(): void; setOffset(offset: number): void; changed(): void; finished(): void;
saved?: { confirmed?: MatchCheckpoint; epoch: number }; saved?: { confirmed?: MatchCheckpoint; epoch: number };
onEmote?(id: unknown): void; onEmote?(id: unknown, variant: unknown): void;
} }
export function createFriendMatch(config: FriendConfig) { export function createFriendMatch(config: FriendConfig) {
const { peer, engine, clock } = config; const { peer, engine, clock } = config;
@@ -162,7 +162,7 @@ export function createFriendMatch(config: FriendConfig) {
if (message.type === 'clock:pong') { if (Number.isFinite(message.remoteAt)) clockReplies.get(message.sentAt)?.resolve(message.remoteAt); clockReplies.delete(message.sentAt); return; } if (message.type === 'clock:pong') { if (Number.isFinite(message.remoteAt)) clockReplies.get(message.sentAt)?.resolve(message.remoteAt); clockReplies.delete(message.sentAt); return; }
if (message.type === 'pong') return; if (message.type === 'pong') return;
if (message.type === 'emote') { if (message.type === 'emote') {
if (!recovering && engine.view().phase !== 'finished') config.onEmote?.(message.id); if (!recovering && engine.view().phase !== 'finished') config.onEmote?.(message.id, message.variant);
return; return;
} }
if (message.type === 'ready') { if (message.slot === config.slot) throw new Error('INVALID_SLOT'); peerReady = true; maybeStart(); return; } if (message.type === 'ready') { if (message.slot === config.slot) throw new Error('INVALID_SLOT'); peerReady = true; maybeStart(); return; }
@@ -210,9 +210,9 @@ export function createFriendMatch(config: FriendConfig) {
catch { abort('connection_failed'); } catch { abort('connection_failed'); }
}, },
get recovering() { return recovering; }, get recovering() { return recovering; },
sendEmote(id: import('../../constants/emotes.js').EmoteId) { sendEmote(id: import('../../constants/emotes.js').EmoteId, variant: 0 | 1) {
if (stopped || recovering || !peer.connected || engine.view().phase === 'finished') return false; if (stopped || recovering || !peer.connected || engine.view().phase === 'finished') return false;
try { send('emote', { id }); return true; } catch { return false; } try { send('emote', { id, variant }); return true; } catch { return false; }
}, },
submitAction() { if (!recovering && !stopped) run(() => exchange!.commit(engine.view().ownAction)); }, submitAction() { if (!recovering && !stopped) run(() => exchange!.commit(engine.view().ownAction)); },
requestRematch() { engine.requestRematch(config.slot); if (peer.connected) send('match:rematch'); config.changed(); }, requestRematch() { engine.requestRematch(config.slot); if (peer.connected) send('match:rematch'); config.changed(); },
+2 -2
View File
@@ -68,7 +68,7 @@ export function createMatchController(config: ControllerConfig) {
if (!config.peer) throw new Error('PEER_REQUIRED'); if (!config.peer) throw new Error('PEER_REQUIRED');
friend = createFriendMatch({ matchId: config.activeMatch.matchId, slot: config.selfIndex, engine, peer: config.peer, clock, friend = createFriendMatch({ matchId: config.activeMatch.matchId, slot: config.selfIndex, engine, peer: config.peer, clock,
setOffset: value => { offset = value; }, changed: publish, finished: () => { void record(); }, saved, setOffset: value => { offset = value; }, changed: publish, finished: () => { void record(); }, saved,
onEmote: id => { if (emotes.send(config.selfIndex === 0 ? 1 : 0, id, clock.now() + offset)) publish(); } }); onEmote: (id, variant) => { if (emotes.send(config.selfIndex === 0 ? 1 : 0, id, clock.now() + offset, variant)) publish(); } });
await friend.start(); return; await friend.start(); return;
} }
if (!saved) engine.start(); if (!saved) engine.start();
@@ -96,7 +96,7 @@ export function createMatchController(config: ControllerConfig) {
const now = clock.now() + offset; const now = clock.now() + offset;
if (config.activeMatch.mode === 'friend' && !config.peer?.connected) return false; if (config.activeMatch.mode === 'friend' && !config.peer?.connected) return false;
if (!emotes.send(config.selfIndex, id, now)) return false; if (!emotes.send(config.selfIndex, id, now)) return false;
if (friend && !friend.sendEmote(id)) return false; if (friend && !friend.sendEmote(id, emotes.view(now)[config.selfIndex]!.variant)) return false;
publish(); return true; publish(); return true;
}, },
view, view,
+34 -10
View File
@@ -10,7 +10,7 @@ import { EMOTE_COOLDOWN_MS, EMOTE_IDS, EMOTE_LABELS, EMOTE_LINES, type EmoteId }
import { sound } from './sound.js'; import { sound } from './sound.js';
const fighter = (i: number, appearance?: FighterAppearance) => `<div class="fighter" data-fighter="${i}"> const fighter = (i: number, appearance?: FighterAppearance) => `<div class="fighter" data-fighter="${i}">
<div class="avatar"><div class="emote-bubble" role="status" hidden></div><div class="aura"></div><div class="motion-trails"><i></i><i></i><i></i></div><div class="character-direction">${fighterArt(i, false, appearance)}</div>${ashPileArt()}<div class="projectile"></div><div class="shield"></div><div class="impact"></div></div> <div class="avatar"><div class="emote-bubble" role="status" hidden></div><div class="aura"></div><div class="motion-trails"><i></i><i></i><i></i></div><div class="character-direction">${fighterArt(i, false, appearance)}</div>${ashPileArt()}<div class="projectile"></div><div class="shield"></div><div class="impact"></div><button type="button" class="emote-trigger" data-emote-trigger="${i}" aria-label="내 캐릭터 감정표현 선택" aria-expanded="false" aria-controls="emote-choices" hidden></button></div>
<div class="fighter-caption"><span class="choice-status"></span><span class="action-label"></span><span class="energy-change" aria-hidden="true"></span></div> <div class="fighter-caption"><span class="choice-status"></span><span class="action-label"></span><span class="energy-change" aria-hidden="true"></span></div>
</div>`; </div>`;
@@ -19,17 +19,38 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
<div class="timing"><span class="selection-status" role="status"></span><span class="time-label" aria-label="남은 시간"></span><div class="time-track" role="progressbar" aria-label="남은 선택 시간" aria-valuemin="0" aria-valuemax="100"><span></span></div></div> <div class="timing"><span class="selection-status" role="status"></span><span class="time-label" aria-label="남은 시간"></span><div class="time-track" role="progressbar" aria-label="남은 선택 시간" aria-valuemin="0" aria-valuemax="100"><span></span></div></div>
<div class="arena"><div class="arena-floor" aria-hidden="true"></div><div class="result-banner" hidden><h2></h2><p class="finish-reason"></p></div>${fighter(0, appearances?.[0])}<div class="duel-cue"><span class="versus" aria-hidden="true">VS</span><strong class="countdown-number" role="status"></strong></div><span class="clash-label" role="status"></span><div class="clash-burst" aria-hidden="true"></div>${fighter(1, appearances?.[1])}</div> <div class="arena"><div class="arena-floor" aria-hidden="true"></div><div class="result-banner" hidden><h2></h2><p class="finish-reason"></p></div>${fighter(0, appearances?.[0])}<div class="duel-cue"><span class="versus" aria-hidden="true">VS</span><strong class="countdown-number" role="status"></strong></div><span class="clash-label" role="status"></span><div class="clash-burst" aria-hidden="true"></div>${fighter(1, appearances?.[1])}</div>
<div class="player-controls" role="group" aria-label="내 행동 선택"><div class="energy">${actionIcon('charge')}<strong class="energy-count"></strong></div><div class="action-buttons">${ACTION_IDS.map(id => `<button data-action="${id}" aria-label="${ACTIONS[id].name}, ${ACTIONS[id].effect}${ACTIONS[id].cost ? `, 기 ${ACTIONS[id].cost} 소모` : ''}"><span class="action-symbol">${actionIcon(id)}</span><strong>${ACTIONS[id].name}</strong><small>${id === 'charge' || id === 'guard' ? ACTIONS[id].effect : `기 ${ACTIONS[id].cost} 소모`}</small><span class="chosen-mark">${checkIcon}</span></button>`).join('')}</div></div> <div class="player-controls" role="group" aria-label="내 행동 선택"><div class="energy">${actionIcon('charge')}<strong class="energy-count"></strong></div><div class="action-buttons">${ACTION_IDS.map(id => `<button data-action="${id}" aria-label="${ACTIONS[id].name}, ${ACTIONS[id].effect}${ACTIONS[id].cost ? `, 기 ${ACTIONS[id].cost} 소모` : ''}"><span class="action-symbol">${actionIcon(id)}</span><strong>${ACTIONS[id].name}</strong><small>${id === 'charge' || id === 'guard' ? ACTIONS[id].effect : `기 ${ACTIONS[id].cost} 소모`}</small><span class="chosen-mark">${checkIcon}</span></button>`).join('')}</div></div>
<details class="emote-menu"><summary>감정표현</summary><div>${EMOTE_IDS.map(id => `<button type="button" data-emote="${id}">${EMOTE_LABELS[id]}</button>`).join('')}</div></details> <div class="emote-menu" id="emote-choices" role="group" aria-label="감정표현 선택" hidden>${EMOTE_IDS.map(id => `<button type="button" data-emote="${id}">${EMOTE_LABELS[id]}</button>`).join('')}</div>
<div class="match-result" hidden><div class="result-stats"></div><p class="save-status" role="status"></p><div class="split"><button class="rematch primary">다시 대전</button><button class="home-return">시작 화면</button></div><p class="rematch-status" role="status"></p></div>`; <div class="match-result" hidden><div class="result-stats"></div><p class="save-status" role="status"></p><div class="split"><button class="rematch primary">다시 대전</button><button class="home-return">시작 화면</button></div><p class="rematch-status" role="status"></p></div>`;
const arena = container.querySelector<HTMLElement>('.arena')!; const arena = container.querySelector<HTMLElement>('.arena')!;
const emoteMenu = container.querySelector<HTMLDetailsElement>('.emote-menu')!; const emoteMenu = container.querySelector<HTMLElement>('.emote-menu')!;
let emoteReadyAt = 0, sendingEmote = false; let emoteReadyAt = 0, sendingEmote = false, emoteOpen = false;
const emoteTriggers = Array.from(container.querySelectorAll<HTMLButtonElement>('[data-emote-trigger]'));
function closeEmotes(restoreFocus = false) {
emoteOpen = false; emoteMenu.hidden = true;
emoteTriggers.forEach(button => button.setAttribute('aria-expanded', 'false'));
if (restoreFocus) emoteTriggers.find(button => Number(button.dataset.emoteTrigger) === current?.selfIndex)?.focus();
}
emoteTriggers.forEach(button => {
button.onclick = () => {
if (!current || !onEmote || Number(button.dataset.emoteTrigger) !== current.selfIndex || current.phase === 'finished' || !current.opponentPresent) return;
if (emoteOpen) { closeEmotes(); return; }
const actor = button.getBoundingClientRect(), root = container.getBoundingClientRect();
emoteMenu.style.left = `${Math.max(8, Math.min(root.width - 164, actor.left - root.left + actor.width / 2 - 78))}px`;
emoteMenu.style.top = `${Math.max(72, actor.top - root.top - 104)}px`;
emoteOpen = true; tick();
button.setAttribute('aria-expanded', 'true');
emoteMenu.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();
};
});
container.onpointerdown = event => {
if (event.target instanceof Element && !event.target.closest('.emote-menu, .emote-trigger')) closeEmotes();
};
container.onkeydown = event => { if (event.key === 'Escape' && emoteOpen) { event.preventDefault(); closeEmotes(true); } };
emoteMenu.querySelectorAll<HTMLButtonElement>('[data-emote]').forEach(button => { emoteMenu.querySelectorAll<HTMLButtonElement>('[data-emote]').forEach(button => {
button.onclick = async () => { button.onclick = async () => {
if (!current || sendingEmote || Date.now() < emoteReadyAt) return; if (!current || sendingEmote || Date.now() < emoteReadyAt) return;
const matchId = current.matchId; const matchId = current.matchId;
sendingEmote = true; emoteMenu.open = false; sendingEmote = true; closeEmotes(true);
emoteMenu.querySelector('summary')?.focus();
try { if (await onEmote?.(button.dataset.emote as EmoteId) && current?.matchId === matchId) emoteReadyAt = Date.now() + EMOTE_COOLDOWN_MS; } try { if (await onEmote?.(button.dataset.emote as EmoteId) && current?.matchId === matchId) emoteReadyAt = Date.now() + EMOTE_COOLDOWN_MS; }
finally { sendingEmote = false; } finally { sendingEmote = false; }
}; };
@@ -70,7 +91,10 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
arena.hidden = !!introduction; arena.hidden = !!introduction;
const revealing = resultReported !== s.matchId && !!s.reveal && now < s.reveal.revealEndsAt; const revealing = resultReported !== s.matchId && !!s.reveal && now < s.reveal.revealEndsAt;
const resultVisible = s.phase === 'finished' && !revealing; const resultVisible = s.phase === 'finished' && !revealing;
emoteMenu.hidden = !onEmote || !!introduction || s.phase === 'finished' || !s.opponentPresent; const emotesAvailable = !!onEmote && !introduction && s.phase !== 'finished' && s.opponentPresent;
if (!emotesAvailable) closeEmotes();
emoteMenu.hidden = !emoteOpen;
emoteTriggers.forEach(button => { button.hidden = !emotesAvailable || Number(button.dataset.emoteTrigger) !== s.selfIndex; });
emoteMenu.querySelectorAll<HTMLButtonElement>('button').forEach(button => { button.disabled = sendingEmote || Date.now() < emoteReadyAt; }); emoteMenu.querySelectorAll<HTMLButtonElement>('button').forEach(button => { button.disabled = sendingEmote || Date.now() < emoteReadyAt; });
if (resultVisible && resultReported !== s.matchId) renderer.reset(); if (resultVisible && resultReported !== s.matchId) renderer.reset();
const recovering = !resultVisible && !s.opponentPresent; const recovering = !resultVisible && !s.opponentPresent;
@@ -113,7 +137,7 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
const bubble = node.querySelector<HTMLElement>('.emote-bubble'); const bubble = node.querySelector<HTMLElement>('.emote-bubble');
const emote = s.emotes?.[i]; const emote = s.emotes?.[i];
if (bubble) { if (bubble) {
const line = emote && emote.expiresAt > now && !resultVisible ? EMOTE_LINES[(s.appearances ?? appearances)?.[i]?.characterId ?? i]?.[emote.id] : ''; const line = emote && emote.expiresAt > now && !resultVisible ? EMOTE_LINES[emote.id]?.[emote.variant] : '';
bubble.hidden = !line; bubble.hidden = !line;
if (bubble.textContent !== (line ?? '')) bubble.textContent = line ?? ''; if (bubble.textContent !== (line ?? '')) bubble.textContent = line ?? '';
} }
@@ -154,7 +178,7 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
update(state: LocalMatchState) { update(state: LocalMatchState) {
if (current?.matchId === state.matchId && current.version > state.version) return; if (current?.matchId === state.matchId && current.version > state.version) return;
if (lastId !== state.matchId) { if (lastId !== state.matchId) {
emoteReadyAt = 0; emoteMenu.open = false; emoteReadyAt = 0; closeEmotes();
cutscene.reset(); renderer.reset(); lastId = state.matchId; busy = false; submission++; cutscene.reset(); renderer.reset(); lastId = state.matchId; busy = false; submission++;
el('.match-introduction .queue-fighter').innerHTML = fighterArt(state.selfIndex, false, (state.appearances ?? appearances)?.[state.selfIndex]); el('.match-introduction .queue-fighter').innerHTML = fighterArt(state.selfIndex, false, (state.appearances ?? appearances)?.[state.selfIndex]);
} }
@@ -181,6 +205,6 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
} }
cutscene.update(state); cutscene.update(state);
tick(); tick();
}, destroy() { submission++; clearInterval(interval); cutscene.destroy(); renderer.destroy(); sound.stop(); container.innerHTML = ''; current = undefined; }, }, destroy() { submission++; clearInterval(interval); cutscene.destroy(); renderer.destroy(); sound.stop(); container.onpointerdown = null; container.onkeydown = null; container.innerHTML = ''; current = undefined; },
}; };
} }
+2 -5
View File
@@ -1,11 +1,8 @@
/* Match layout only; character poses, projectiles and finish cutscenes stay in game.css. */ /* Match layout only; character poses, projectiles and finish cutscenes stay in game.css. */
.emote-menu { position: absolute; z-index: 16; right: var(--edge); top: calc(110px + env(safe-area-inset-top)); } .emote-menu { position: absolute; z-index: 16; display: grid; grid-template-columns: repeat(2, 68px); gap: 4px; padding: 6px; background: #fffdf1; border: 1px solid #56615a; }
.emote-menu summary { cursor: pointer; padding: 8px 12px; border: 1px solid #56615a; background: #fffdf1; color: #232a2c; }
.emote-menu > div { position: absolute; top: 100%; right: 0; display: grid; grid-template-columns: repeat(2, 64px); gap: 4px; padding: 6px; background: #fffdf1; border: 1px solid #56615a; }
.emote-menu button { padding: 10px 6px; min-height: 44px; } .emote-menu button { padding: 10px 6px; min-height: 44px; }
.emote-bubble { position: absolute; z-index: 7; top: -28px; left: 50%; transform: translateX(-50%); width: max-content; max-width: min(180px, 39vw); padding: 9px 12px; border: 2px solid #36433b; border-radius: 14px; background: #fffdf1; color: #232a2c; font-size: clamp(12px, 2vw, 17px); text-align: center; overflow-wrap: anywhere; pointer-events: none; } .emote-bubble { position: absolute; z-index: 7; bottom: 100%; left: 50%; transform: translateX(-50%); width: max-content; max-width: min(220px, 43vw); padding: 10px 12px; border: 2px solid #36433b; border-radius: 14px; background: #fffdf1; color: #232a2c; font-family: var(--ui-display); font-weight: 400; font-size: clamp(15px, 2vw, 20px); line-height: 1.4; text-align: center; word-break: keep-all; overflow-wrap: anywhere; pointer-events: none; }
.emote-bubble::after { content: ''; position: absolute; top: 100%; left: 50%; border: 7px solid transparent; border-top-color: #36433b; } .emote-bubble::after { content: ''; position: absolute; top: 100%; left: 50%; border: 7px solid transparent; border-top-color: #36433b; }
@media (max-height: 500px) { .emote-menu { top: 66px; } }
body:is([data-screen=match], [data-screen=result]) { body:is([data-screen=match], [data-screen=result]) {
overflow: hidden; overflow: hidden;
background: #d9ddd4; background: #d9ddd4;
+8
View File
@@ -1,4 +1,12 @@
/* Import after game.css, match.css and home.css. */ /* Import after game.css, match.css and home.css. */
body.player-app #match .emote-trigger,
body.player-app #match .emote-trigger:hover,
body.player-app #match .emote-trigger:active {
position: absolute; inset: 0; z-index: 8; width: 100%; height: 100%;
margin: 0; padding: 0; border: 0; border-radius: 0; background: transparent;
box-shadow: none; transform: none; cursor: pointer;
}
body.player-app #match .emote-trigger:focus-visible { outline: 3px solid #266acc; outline-offset: 3px; }
@font-face { @font-face {
font-family: 'Black Han Sans'; font-family: 'Black Han Sans';
src: url('./fonts/BlackHanSans-Regular.ttf') format('truetype'); src: url('./fonts/BlackHanSans-Regular.ttf') format('truetype');
+4 -3
View File
@@ -1,14 +1,15 @@
import { EMOTE_COOLDOWN_MS, EMOTE_DURATION_MS, EMOTE_IDS, type EmoteId } from '../../constants/emotes.js'; import { EMOTE_COOLDOWN_MS, EMOTE_DURATION_MS, EMOTE_IDS, type EmoteId } from '../../constants/emotes.js';
import type { Pair } from './types.js'; import type { Pair } from './types.js';
export interface Emote { id: EmoteId; at: number; expiresAt: number } export interface Emote { id: EmoteId; variant: 0 | 1; at: number; expiresAt: number }
export function createEmotes() { export function createEmotes() {
const latest: Pair<Emote | null> = [null, null]; const latest: Pair<Emote | null> = [null, null];
return { return {
send(slot: 0 | 1, id: unknown, now: number): boolean { send(slot: 0 | 1, id: unknown, now: number, variant: unknown = Math.random() < 0.5 ? 0 : 1): boolean {
if (variant !== 0 && variant !== 1) return false;
if (!EMOTE_IDS.includes(id as EmoteId) || !Number.isFinite(now)) return false; if (!EMOTE_IDS.includes(id as EmoteId) || !Number.isFinite(now)) return false;
if (latest[slot] && now - latest[slot]!.at < EMOTE_COOLDOWN_MS) return false; if (latest[slot] && now - latest[slot]!.at < EMOTE_COOLDOWN_MS) return false;
latest[slot] = { id: id as EmoteId, at: now, expiresAt: now + EMOTE_DURATION_MS }; latest[slot] = { id: id as EmoteId, variant, at: now, expiresAt: now + EMOTE_DURATION_MS };
return true; return true;
}, },
view(now: number): Pair<Emote | null> { view(now: number): Pair<Emote | null> {
+1 -1
View File
@@ -41,7 +41,7 @@ export interface PeerHeader {
protocolVersion: number; rulesVersion: string; matchId: string; connectionEpoch: number; turnId: number; protocolVersion: number; rulesVersion: string; matchId: string; connectionEpoch: number; turnId: number;
} }
export type PeerMessage = PeerHeader & ( export type PeerMessage = PeerHeader & (
| { type: 'emote'; id: EmoteId } | { type: 'emote'; id: EmoteId; variant: 0 | 1 }
| { type: 'ready'; slot: 0 | 1 } | { type: 'ready'; slot: 0 | 1 }
| { type: 'clock:ping' | 'ping'; sentAt: number } | { type: 'clock:ping' | 'ping'; sentAt: number }
| { type: 'clock:pong' | 'pong'; sentAt: number; remoteAt: number } | { type: 'clock:pong' | 'pong'; sentAt: number; remoteAt: number }
+11 -2
View File
@@ -7,12 +7,21 @@ test('emotes validate fixed choices, rate limit each player and expire without c
const emotes = createEmotes(); const emotes = createEmotes();
assert.equal(emotes.send(0, '<script>', 1000), false); assert.equal(emotes.send(0, '<script>', 1000), false);
assert.equal(emotes.send(0, 'greet', 1000), true); assert.equal(emotes.send(0, 'greet', 1000), true);
assert.equal(emotes.send(0, 'taunt', 1001), false); assert.equal(emotes.send(0, 'mistake', 1001), false);
assert.equal(emotes.send(1, 'thanks', 1001), true); assert.equal(emotes.send(1, 'thanks', 1001), true);
assert.equal(emotes.view(1001)[0]?.id, 'greet'); assert.equal(emotes.view(1001)[0]?.id, 'greet');
const snapshot = emotes.view(1001); const snapshot = emotes.view(1001);
snapshot[0]!.id = 'threaten'; snapshot[0]!.id = 'threaten';
assert.equal(emotes.view(1001)[0]?.id, 'greet'); assert.equal(emotes.view(1001)[0]?.id, 'greet');
assert.equal(emotes.view(1000 + EMOTE_DURATION_MS)[0], null); assert.equal(emotes.view(1000 + EMOTE_DURATION_MS)[0], null);
assert.equal(emotes.send(0, 'taunt', 1000 + EMOTE_COOLDOWN_MS), true); assert.equal(emotes.send(0, 'mistake', 1000 + EMOTE_COOLDOWN_MS), true);
});
test('emote variants are fixed when sent and invalid variants are rejected', () => {
const emotes = createEmotes();
assert.equal(emotes.send(0, 'greet', 0, 2), false);
assert.equal(emotes.send(0, 'greet', 0, 0), true);
assert.equal(emotes.send(1, 'greet', 0, 1), true);
assert.equal(emotes.view(1)[0]?.variant, 0);
assert.equal(emotes.view(2)[1]?.variant, 1);
}); });
+2 -1
View File
@@ -39,9 +39,10 @@ test('peer emotes reach the correct slot without exposing choices or changing th
await a.submitAction('charge'); await h.clock.advance(1); await a.submitAction('charge'); await h.clock.advance(1);
const before = b.view(); const before = b.view();
assert.equal(a.sendEmote('greet'), true); assert.equal(a.sendEmote('greet'), true);
assert.equal(a.sendEmote('taunt'), false); assert.equal(a.sendEmote('mistake'), false);
await h.clock.advance(1); await h.clock.advance(1);
assert.equal(b.view().emotes?.[0]?.id, 'greet'); assert.equal(b.view().emotes?.[0]?.id, 'greet');
assert.equal(b.view().emotes?.[0]?.variant, a.view().emotes?.[0]?.variant);
assert.equal(b.view().ownAction, null); assert.equal(b.view().ownAction, null);
assert.equal(b.view().turnId, before.turnId); assert.equal(b.view().turnId, before.turnId);
assert.deepEqual(b.view().energy, before.energy); assert.deepEqual(b.view().energy, before.energy);