diff --git a/docs/live-share-timer-sync.md b/docs/live-share-timer-sync.md index f65cfa85..cfd9497c 100644 --- a/docs/live-share-timer-sync.md +++ b/docs/live-share-timer-sync.md @@ -37,6 +37,9 @@ - 정지 상태, 정지 값에서 재개, 기준 시각이 미래이거나 2초 넘게 지난 경우는 보정하지 않습니다. 4. 사회자는 남은 시간을 **올림한 정수 초**로 보내므로 실행 중 스냅샷은 실제보다 최대 1초 큽니다. 실행 중 재동기화 차이가 1초 미만이면 기존 목표 시각과 새 목표 시각 중 **더 이른 쪽**을 택해, 5초 주기 SYNC마다 표시가 튀지 않게 합니다. +5. 자유토론(1회 발언 시간 사용)에서 `PLAY`/`STOP` 등은 발언 시간만 보내므로, 전체 시간을 따로 카운트다운하면 지연 보정이 발언 시간에만 적용되어 두 값의 초 경계가 어긋납니다. 사회자는 두 시간을 같이 시작·정지하므로, 청중은 `전체 − 발언` 차이를 오프셋으로 두고 **전체 시간 = 발언 시간 + 오프셋**으로 파생해 두 값이 같은 틱에 바뀌게 합니다. 오프셋은 설정 변경·`SYNC`·`RESET`·`TEAM_SWITCH`(새 팀)에서만 다시 계산합니다. (`useAudienceTimeBasedCountdown`) +6. 자유토론 초기화(`RESET`)는 순서 설정값이 아니라 **현재 턴을 시작했던 시간**(순서 진입 또는 팀 전환 시점의 전체/발언 시간)으로 되돌립니다. 이벤트 발행 시점의 React 상태는 아직 초기화 전 값이므로, 사회자는 턴 시작 시간을 직접 읽어 `remainingTime`과 양 팀 전체 시간(`prosRemainingTime`/`consRemainingTime`)에 담아 보냅니다. 청중은 이 값으로 현재 팀을 되돌리고, 양 팀 전체 시간이 없으면(이전 버전 사회자) 설정값으로 초기화합니다. (`useTimeBasedTimer.getTurnStartTimes`, `useAudienceTimeBasedCountdown`) + - 보정하지 않는 구간: 사회자 → 서버 구간 지연, 1초 이상의 비정상 지연. - 청중 간 차이는 정수 초 표시 특성상 최대 1초까지 날 수 있습니다. diff --git a/src/page/AudienceSharePage/hooks/EventInterpreter.test.ts b/src/page/AudienceSharePage/hooks/EventInterpreter.test.ts index f33417d6..00a53633 100644 --- a/src/page/AudienceSharePage/hooks/EventInterpreter.test.ts +++ b/src/page/AudienceSharePage/hooks/EventInterpreter.test.ts @@ -358,6 +358,26 @@ describe('eventInterpreter 순수 함수', () => { sequence: 0, }); }); + + it('TIME_BASED는 초기화된 현재 팀 시간과 양 팀 전체 시간을 담는다', () => { + const payload: TimerDataPayload = { + timerType: 'TIME_BASED', + remainingTime: 30, + sequence: 1, + currentTeam: 'CONS', + prosRemainingTime: 80, + consRemainingTime: 94, + }; + + const result = getDisplayDataByEvent('RESET', payload, null, mockTable); + expect(result).toMatchObject({ + timerType: 'TIME_BASED', + currentTeam: 'CONS', + isRunning: false, + consTime: 30, + teamTotalTimes: { pros: 80, cons: 94 }, + }); + }); }); describe('BEFORE 이벤트', () => { diff --git a/src/page/AudienceSharePage/hooks/EventInterpreter.ts b/src/page/AudienceSharePage/hooks/EventInterpreter.ts index eb249b5d..e65fd22f 100644 --- a/src/page/AudienceSharePage/hooks/EventInterpreter.ts +++ b/src/page/AudienceSharePage/hooks/EventInterpreter.ts @@ -111,7 +111,7 @@ export function createDisplayData( } /** - * 자유토론 SYNC 페이로드에서 양 팀의 총 남은 시간을 꺼냅니다. + * 자유토론 SYNC/RESET 페이로드에서 양 팀의 총 남은 시간을 꺼냅니다. * 둘 중 하나라도 없으면 `undefined`를 반환합니다. */ function getTeamTotalTimes( @@ -211,6 +211,8 @@ export function getDisplayDataByEvent( data.sequence, data.remainingTime, ), + // 자유토론은 사회자가 현재 턴 시작 시간으로 되돌린 값을 보냄 + teamTotalTimes: getTeamTotalTimes(data), }); case 'BEFORE': { diff --git a/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.test.ts b/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.test.ts index 10990b4d..c03883e9 100644 --- a/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.test.ts +++ b/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.test.ts @@ -127,6 +127,54 @@ describe('useAudienceTimeBasedCountdown', () => { expect(result.current.pros.currentSpeakingRemainingTime).toBe(27); }); + it('네트워크 지연이 있는 재생에서도 전체 시간과 현재 시간이 같은 순간에 감소한다', () => { + vi.setSystemTime(new Date('2026-09-27T00:00:00Z')); + const { result, rerender } = renderHook( + ({ displayData, syncedAt }) => + useAudienceTimeBasedCountdown({ + displayData, + timePerTeam: 120, + timePerSpeaking: 30, + syncedAt, + }), + { + initialProps: { + displayData: createDisplayData({ + isRunning: false, + eventType: 'STOP', + prosTime: 30, + revision: 1, + }), + syncedAt: Date.now() as number | null, + }, + }, + ); + + rerender({ + displayData: createDisplayData({ + isRunning: true, + eventType: 'PLAY', + prosTime: 30, + revision: 2, + }), + syncedAt: Date.now() - 400, + }); + + for (let step = 0; step < 30; step += 1) { + act(() => { + vi.advanceTimersByTime(100); + }); + + expect( + (result.current.pros.totalRemainingTime ?? 0) - + (result.current.pros.currentSpeakingRemainingTime ?? 0), + ).toBe(90); + } + + expect(result.current.pros.currentSpeakingRemainingTime).toBe(27); + expect(result.current.pros.totalRemainingTime).toBe(117); + }); + it('TEAM_SWITCH는 새 팀 현재 시간을 초기화하고 실행 상태를 유지한다', () => { const { result, rerender } = renderHook( ({ displayData }) => @@ -172,6 +220,51 @@ describe('useAudienceTimeBasedCountdown', () => { expect(result.current.cons.currentSpeakingRemainingTime).toBe(29); }); + it('설정 변경과 TEAM_SWITCH가 겹치면 새 팀 전체 시간을 새 설정값으로 표시한다', () => { + const { result, rerender } = renderHook( + ({ displayData, timePerTeam }) => + useAudienceTimeBasedCountdown({ + displayData, + timePerTeam, + timePerSpeaking: 30, + }), + { + initialProps: { + displayData: createDisplayData({ + currentTeam: 'CONS', + isRunning: true, + eventType: 'PLAY', + prosTime: null, + consTime: 30, + revision: 1, + }), + timePerTeam: 120, + }, + }, + ); + + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(result.current.cons.totalRemainingTime).toBe(118); + + rerender({ + displayData: createDisplayData({ + currentTeam: 'CONS', + isRunning: true, + eventType: 'TEAM_SWITCH', + prosTime: 30, + consTime: null, + sequence: 1, + revision: 2, + }), + timePerTeam: 200, + }); + + expect(result.current.cons.totalRemainingTime).toBe(200); + expect(result.current.cons.currentSpeakingRemainingTime).toBe(30); + }); + it('RESET은 현재 팀의 전체/현재 시간을 API 설정값으로 초기화한다', () => { const { result, rerender } = renderHook( ({ displayData }) => @@ -208,6 +301,89 @@ describe('useAudienceTimeBasedCountdown', () => { expect(result.current.pros.isRunning).toBe(false); }); + it('RESET은 받은 턴 시작 시간으로 현재 팀을 되돌린다', () => { + const { result, rerender } = renderHook( + ({ displayData }) => + useAudienceTimeBasedCountdown({ + displayData, + timePerTeam: 120, + timePerSpeaking: 30, + }), + { + initialProps: { + displayData: createDisplayData({ + currentTeam: 'CONS', + isRunning: true, + eventType: 'SYNC', + prosTime: null, + consTime: 30, + teamTotalTimes: { pros: 80, cons: 94 }, + revision: 1, + }), + }, + }, + ); + + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(result.current.cons.totalRemainingTime).toBe(89); + + rerender({ + displayData: createDisplayData({ + currentTeam: 'CONS', + isRunning: false, + eventType: 'RESET', + prosTime: null, + consTime: 30, + teamTotalTimes: { pros: 80, cons: 94 }, + revision: 2, + }), + }); + + expect(result.current.cons.totalRemainingTime).toBe(94); + expect(result.current.cons.currentSpeakingRemainingTime).toBe(30); + expect(result.current.pros.totalRemainingTime).toBe(80); + expect(result.current.cons.isRunning).toBe(false); + }); + + it('전체 시간만 쓰는 RESET은 받은 턴 시작 전체 시간으로 되돌린다', () => { + const { result, rerender } = renderHook( + ({ displayData }) => + useAudienceTimeBasedCountdown({ + displayData, + timePerTeam: 120, + timePerSpeaking: null, + }), + { + initialProps: { + displayData: createDisplayData({ + isRunning: true, + eventType: 'PLAY', + prosTime: 94, + revision: 1, + }), + }, + }, + ); + + act(() => { + vi.advanceTimersByTime(5000); + }); + + rerender({ + displayData: createDisplayData({ + isRunning: false, + eventType: 'RESET', + prosTime: 94, + teamTotalTimes: { pros: 94, cons: 120 }, + revision: 2, + }), + }); + + expect(result.current.pros.totalRemainingTime).toBe(94); + }); + it('전체 시간 또는 현재 시간이 끝나면 0초에서 함께 정지한다', () => { const { result } = renderHook(() => useAudienceTimeBasedCountdown({ diff --git a/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.ts b/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.ts index ad953b41..2c563516 100644 --- a/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.ts +++ b/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.ts @@ -8,6 +8,12 @@ interface TeamCountdownInput { speakingTime: number | null; totalSyncKey: number; speakingSyncKey: number; + /** + * 1회당 발언 시간 모드에서 `전체 시간 - 발언 시간` (정수 초) + * - 사회자는 두 시간을 같이 시작·정지하므로 실행 중 차이가 일정하다. + * - 전체 시간을 발언 카운트다운에서 파생해 두 값이 같은 틱에 바뀌게 한다. + */ + totalOffset: number | null; } interface CountdownInputs { @@ -48,6 +54,7 @@ const EMPTY_TEAM_INPUT: TeamCountdownInput = { speakingTime: null, totalSyncKey: 0, speakingSyncKey: 0, + totalOffset: null, }; function createInitialInputs(): CountdownInputs { @@ -70,9 +77,43 @@ function createTeamInput( speakingTime, totalSyncKey: previousInput.totalSyncKey + 1, speakingSyncKey: previousInput.speakingSyncKey + 1, + totalOffset: speakingTime === null ? null : totalTime - speakingTime, }; } +/** + * 발언 시간만 받았을 때의 오프셋 + * - 설정이 막 바뀌어 전체 시간이 아직 흐르지 않았다면 받은 발언 시간 기준으로 다시 계산 + * - 그 외에는 두 시간이 함께 흘렀으므로 기존 오프셋 유지 + */ +function getSpeakingOnlyTotalOffset( + input: TeamCountdownInput, + receivedSpeakingTime: number | null, + hasConfigurationChanged: boolean, +) { + if ( + !hasConfigurationChanged || + receivedSpeakingTime === null || + input.totalTime === null + ) { + return input.totalOffset; + } + + return input.totalTime - receivedSpeakingTime; +} + +function getDerivedTotalTime( + countdownTotal: number | null, + speaking: number | null, + totalOffset: number | null, +) { + if (speaking === null || totalOffset === null) { + return countdownTotal; + } + + return Math.max(0, speaking + totalOffset); +} + function getTeamValue(team: TimeBasedStance, prosValue: T, consValue: T) { return team === 'PROS' ? prosValue : consValue; } @@ -131,9 +172,10 @@ export function useAudienceTimeBasedCountdown({ displayData.currentTeam === 'CONS' && !isConsLocallyStopped; + // 1회당 발언 시간 모드에서는 전체 시간을 발언 카운트다운에서 파생하므로 돌리지 않음 const prosTotalCountdown = useAudienceCountdown({ receivedTime: inputs.pros.totalTime, - isRunning: isProsRequestedToRun, + isRunning: isProsRequestedToRun && timePerSpeaking === null, minimumTime: 0, shouldResetOnRunStateChange: false, syncKey: inputs.pros.totalSyncKey, @@ -147,9 +189,10 @@ export function useAudienceTimeBasedCountdown({ syncKey: inputs.pros.speakingSyncKey, syncedAt, }); + // 1회당 발언 시간 모드에서는 전체 시간을 발언 카운트다운에서 파생하므로 돌리지 않음 const consTotalCountdown = useAudienceCountdown({ receivedTime: inputs.cons.totalTime, - isRunning: isConsRequestedToRun, + isRunning: isConsRequestedToRun && timePerSpeaking === null, minimumTime: 0, shouldResetOnRunStateChange: false, syncKey: inputs.cons.totalSyncKey, @@ -164,10 +207,18 @@ export function useAudienceTimeBasedCountdown({ syncedAt, }); - const prosTotal = prosTotalCountdown.currentSeconds; const prosSpeaking = prosSpeakingCountdown.currentSeconds; - const consTotal = consTotalCountdown.currentSeconds; const consSpeaking = consSpeakingCountdown.currentSeconds; + const prosTotal = getDerivedTotalTime( + prosTotalCountdown.currentSeconds, + prosSpeaking, + inputs.pros.totalOffset, + ); + const consTotal = getDerivedTotalTime( + consTotalCountdown.currentSeconds, + consSpeaking, + inputs.cons.totalOffset, + ); const displaySequence = displayData?.sequence ?? null; const displayRevision = displayData?.revision ?? 0; const displayEventType = displayData?.eventType ?? null; @@ -254,13 +305,18 @@ export function useAudienceTimeBasedCountdown({ } : previousInputs; + // 설정이 막 바뀌었다면 표시 중인 값은 이전 순서의 것이므로 새 설정값을 사용 + const getLatestTotal = (team: TimeBasedStance) => + hasConfigurationChanged + ? timePerTeam + : getTeamValue( + team, + latestValuesRef.current.prosTotal, + latestValuesRef.current.consTotal, + ); const currentTeam = displayCurrentTeam; const opponentTeam = currentTeam === 'PROS' ? 'CONS' : 'PROS'; - const latestCurrentTotal = getTeamValue( - currentTeam, - latestValuesRef.current.prosTotal, - latestValuesRef.current.consTotal, - ); + const latestCurrentTotal = getLatestTotal(currentTeam); const currentInput = getTeamValue( currentTeam, nextInputs.pros, @@ -317,11 +373,23 @@ export function useAudienceTimeBasedCountdown({ } if (displayEventType === 'RESET') { - const opponentTotal = getTeamValue( - opponentTeam, - latestValuesRef.current.prosTotal, - latestValuesRef.current.consTotal, - ); + // 사회자는 현재 턴을 시작했던 시간으로 되돌린 값을 양 팀 전체 시간과 함께 보냄 + if (displayProsTotalTime !== null && displayConsTotalTime !== null) { + const turnStartTotal = getTeamValue( + currentTeam, + displayProsTotalTime, + displayConsTotalTime, + ); + const turnStartSpeakingTime = + timePerSpeaking === null ? null : receivedCurrentTime; + + return updateTeamInput(nextInputs, currentTeam, (input) => + createTeamInput(turnStartTotal, turnStartSpeakingTime, input), + ); + } + + // 양 팀 전체 시간이 없는 이전 버전 사회자는 설정값으로 초기화 + const opponentTotal = getLatestTotal(opponentTeam); const resetSpeakingTime = getNextSpeakingTime({ totalRemainingTime: timePerTeam, timePerSpeaking, @@ -340,11 +408,7 @@ export function useAudienceTimeBasedCountdown({ displayProsTime, displayConsTime, ); - const previousTeamTotal = getTeamValue( - previousTeam, - latestValuesRef.current.prosTotal, - latestValuesRef.current.consTotal, - ); + const previousTeamTotal = getLatestTotal(previousTeam); const newSpeakingTime = getNextSpeakingTime({ totalRemainingTime: currentTotal, timePerSpeaking, @@ -358,7 +422,14 @@ export function useAudienceTimeBasedCountdown({ ...input, ...(timePerSpeaking === null ? { totalTime: receivedPreviousTime ?? input.totalTime } - : { speakingTime: receivedPreviousTime ?? input.speakingTime }), + : { + speakingTime: receivedPreviousTime ?? input.speakingTime, + totalOffset: getSpeakingOnlyTotalOffset( + input, + receivedPreviousTime, + hasConfigurationChanged, + ), + }), totalSyncKey: timePerSpeaking === null ? input.totalSyncKey + 1 @@ -372,6 +443,8 @@ export function useAudienceTimeBasedCountdown({ ...input, speakingTime: newSpeakingTime, speakingSyncKey: input.speakingSyncKey + 1, + totalOffset: + newSpeakingTime === null ? null : currentTotal - newSpeakingTime, })); return nextInputs; @@ -381,7 +454,14 @@ export function useAudienceTimeBasedCountdown({ ...input, ...(timePerSpeaking === null ? { totalTime: receivedCurrentTime ?? input.totalTime } - : { speakingTime: receivedCurrentTime ?? input.speakingTime }), + : { + speakingTime: receivedCurrentTime ?? input.speakingTime, + totalOffset: getSpeakingOnlyTotalOffset( + input, + receivedCurrentTime, + hasConfigurationChanged, + ), + }), totalSyncKey: timePerSpeaking === null ? input.totalSyncKey + 1 diff --git a/src/page/TimerPage/TimerPage.tsx b/src/page/TimerPage/TimerPage.tsx index 72ecd0f6..5958d5df 100644 --- a/src/page/TimerPage/TimerPage.tsx +++ b/src/page/TimerPage/TimerPage.tsx @@ -41,6 +41,12 @@ import { buildTimerPayloadForShare } from './buildTimerPayloadForShare'; const IS_LIVE_SHARE_ENABLED = true; // 토론 타이머 실행, 라운드 이동, 종료 흐름을 관리하는 메인 페이지다. +interface SharedTimes { + remainingTime: number | null; + prosTotalTime: number | null; + consTotalTime: number | null; +} + export default function TimerPage() { const { t } = useTranslation(); const [answerTime, setAnswerTime] = useState(30); @@ -129,7 +135,7 @@ export default function TimerPage() { const prosTotalTime = timer1.totalTimer; const consTotalTime = timer2.totalTimer; const buildTimerPayload = useCallback( - (eventType: TimerEventTypes) => { + (eventType: TimerEventTypes, sharedTimes?: SharedTimes) => { const isTimeBasedTimerRunning = prosConsSelected === 'PROS' ? isProsTimerRunning : isConsTimerRunning; @@ -138,13 +144,13 @@ export default function TimerPage() { timerType, sequence: index, currentTeam: prosConsSelected, - remainingTime, + remainingTime: sharedTimes ? sharedTimes.remainingTime : remainingTime, isCurrentTimerRunning: timerType === 'NORMAL' ? isNormalTimerRunning : isTimeBasedTimerRunning, - prosTotalTime, - consTotalTime, + prosTotalTime: sharedTimes ? sharedTimes.prosTotalTime : prosTotalTime, + consTotalTime: sharedTimes ? sharedTimes.consTotalTime : consTotalTime, }); }, [ @@ -160,6 +166,39 @@ export default function TimerPage() { ], ); + // 자유토론 초기화 직후 공유할 시간을 만든다. + // 이벤트 발행 시점에는 React 상태가 아직 초기화 전 값이므로, 현재 팀은 턴 시작 시간을 직접 읽는다. + const getResetSharedTimes = (): SharedTimes | undefined => { + if (timerType !== 'TIME_BASED') { + return undefined; + } + + const prosTimes = + prosConsSelected === 'PROS' ? timer1.getTurnStartTimes() : timer1; + const consTimes = + prosConsSelected === 'CONS' ? timer2.getTurnStartTimes() : timer2; + + return { + remainingTime: getRemainingTimeForShare({ + timerType, + normalTimer: normalTimer.timer, + currentTeam: prosConsSelected, + prosTimer: { + totalTimer: prosTimes.totalTimer, + speakingTimer: prosTimes.speakingTimer, + isSpeakingTimerAvailable: timer1.isSpeakingTimerAvailable, + }, + consTimer: { + totalTimer: consTimes.totalTimer, + speakingTimer: consTimes.speakingTimer, + isSpeakingTimerAvailable: timer2.isSpeakingTimerAvailable, + }, + }), + prosTotalTime: prosTimes.totalTimer, + consTotalTime: consTimes.totalTimer, + }; + }; + // 타이머 이벤트를 핸들링하는 래퍼 함수 선언 const handleTimerEvent = (invoke: () => void, eventType: SocketEventType) => { // 이벤트 실행 @@ -178,7 +217,10 @@ export default function TimerPage() { return; } - const payload = buildTimerPayload(eventType); + const payload = buildTimerPayload( + eventType, + eventType === 'RESET' ? getResetSharedTimes() : undefined, + ); if (payload === null) { return; } diff --git a/src/page/TimerPage/components/TimeBasedTimer.tsx b/src/page/TimerPage/components/TimeBasedTimer.tsx index 23e3bf98..509699d9 100644 --- a/src/page/TimerPage/components/TimeBasedTimer.tsx +++ b/src/page/TimerPage/components/TimeBasedTimer.tsx @@ -59,8 +59,11 @@ export default function TimeBasedTimer({ Math.abs((speakingTimer ?? 0) % 60), ); + // 1회당 발언 시간 사용 여부는 순서 설정값으로 판단 + const isSpeakingTimerMode = item.timePerSpeaking !== null; + const initRawProgress = (): number => { - if (speakingTimer === null) { + if (!isSpeakingTimerMode) { // 1회당 발언 시간 X일 때... if (item.timePerTeam && totalTimer && item.timePerTeam > 0) { // 팀당 발언 시간 타이머가 정상 동작 중이고 남은 시간이 있을 경우, 진행도를 계산 @@ -136,7 +139,7 @@ export default function TimeBasedTimer({ className="size-[400px] xl:size-[560px]" > {/* 1회당 발언 시간 X */} - {speakingTimer === null && ( + {!isSpeakingTimerMode && (

{minute}

:

@@ -145,7 +148,7 @@ export default function TimeBasedTimer({ )} {/* 1회당 발언 시간 O */} - {speakingTimer !== null && ( + {isSpeakingTimerMode && (

{t('전체 시간')} diff --git a/src/page/TimerPage/components/TimerView.tsx b/src/page/TimerPage/components/TimerView.tsx index 722cf633..cc2d04dc 100644 --- a/src/page/TimerPage/components/TimerView.tsx +++ b/src/page/TimerPage/components/TimerView.tsx @@ -28,6 +28,7 @@ export default function TimerView({ isAdditionalTimerAvailable, handleActivateTeam, switchCamp, + canSwitchCamp, } = state; const { answerTimerState, handleClickAnswerTimer } = useAnswerTimer({ @@ -90,7 +91,7 @@ export default function TimerView({ startTimer: () => onEvent(timer1.startTimer, 'PLAY'), pauseTimer: () => onEvent(timer1.pauseTimer, 'STOP'), resetCurrentTimer: () => - onEvent(() => timer1.resetCurrentTimer(timer2.isDone), 'RESET'), + onEvent(() => timer1.resetCurrentTimer(), 'RESET'), }} item={data.table[index]} isSelected={prosConsSelected === 'PROS'} @@ -114,7 +115,12 @@ export default function TimerView({ {/* ENTER 버튼 */}