وقتی چند نفر در شهرهای مختلف یک بازی را تماشا میکنند، پخش هرکس **چند ثانیه با بقیه فاصله دارد** و نتیجهاش این است که **یکی گل را زودتر از بقیه لو میدهد!** راهحل یک اتاق تماشای مشترک است که همه در آن یک چیز را همزمان میبینند، با هم چت میکنند، رأی میدهند و برای ادامهٔ بازی پیشبینی میکنند. در این سوال قرار است همین اتاق را بسازید. اتاق شامل چت، نظرسنجی، پیشبینی، جدول امتیاز و واکنشهای شناور است. پیادهسازی کل این چالش با `React` و `TypeScript` است. حجم پیادهسازی این سوال بسیار زیاد است، پس پیش از شروع کل صورت سوال را یک بار بخوانید.

**هدف این سوال پیادهسازی مدیریت وضعیت مشترک در یک برنامهٔ `React` است**: چند بخش که همگی روی یک وضعیت اتاق کار میکنند، بهعلاوهٔ محدودیت نرخ، رتبهبندی، پشتهٔ بازگشت و ماندگاری. این سنگینترین سوال این مسابقه است.
# **پروژهٔ اولیه**
**پروژهٔ اولیه** را از [این لینک](/contest/assignments/103143/download_problem_initial_project/356842/) دانلود کنید. این سوال یک پروژهٔ `Vite` با `React` و `TypeScript` و `Tailwind` است.
<details class="green">
<summary>**ساختار فایلها و پروژه اولیه**</summary>
```plaintext
initial_project/
├─ src/
│ ├─ data/
│ │ └─ <mark class="green" title="پیادهسازی شده؛ آماده است و نباید تغییر کند">room.ts</mark>
│ ├─ engine/
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">room.ts</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">chat.ts</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">polls.ts</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">predictions.ts</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">leaderboard.ts</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">reactions.ts</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">timeline.ts</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">command.ts</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">virtual.ts</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">persistence.ts</mark>
│ │ └─ <mark class="orange" title="باید پیادهسازی شود">format.ts</mark>
│ ├─ hooks/
│ │ └─ <mark class="orange" title="باید پیادهسازی شود">useMatchClock.ts</mark>
│ ├─ context/
│ │ └─ <mark class="orange" title="باید پیادهسازی شود">RoomContext.tsx</mark>
│ ├─ components/
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">ChatPanel.tsx</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">PollPanel.tsx</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">PollCard.tsx</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">PredictionPanel.tsx</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">Leaderboard.tsx</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">PresenceRail.tsx</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">TimelineRibbon.tsx</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">EmojiStorm.tsx</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">Toast.tsx</mark>
│ │ └─ <mark class="orange" title="باید پیادهسازی شود">ThemeToggle.tsx</mark>
│ ├─ pages/
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">RoomPage.tsx</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">PollsPage.tsx</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">PredictionsPage.tsx</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">LeaderboardPage.tsx</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">TimelinePage.tsx</mark>
│ │ └─ <mark class="orange" title="باید پیادهسازی شود">SettingsPage.tsx</mark>
│ └─ <mark class="orange" title="باید پیادهسازی شود">App.tsx</mark>
```
+ **نکته:** داخل فایلهای پروژهٔ اولیه، در هر بخش کامنتهایی جهت انجام راهنمایی برای پیادهسازی قرار گرفتهاند: قرارداد هر تابع، حالتهای مرزی و نکتههای پیادهسازی. پیش از شروع هر فایل، کامنتهای بالای آن را بخوانید.
+ **نکته:** درخت بالا ساختار پروژه اولیه است. منطق در `engine/` قرار میگیرد، هوکها وضعیت را نگه میدارند و کامپوننتها فقط نمایش میدهند و نباید هیچ منطقی داشته باشند. اتاق تماشا از چند بخش مستقل ساخته میشود: **چت، نظرسنجی، پیشبینی، واکنشها، خط زمان و جدول امتیاز!**
+ **نکته:** نام فایلها و مسیرشان را تغییر ندهید. **سیستم داوری** این ماژولها را مستقیم `import` میکند و هر مسیر جابهجاشده باعث میشود که **سیستم داوری نمره صفر را لحاظ کند.**
هر فایل شامل تمام `export`های لازم بهصورت `TODO` است. نام فایلها و نام `export`ها را **تغییر ندهید.**
</details>
<details class="green">
<summary>**نکته: آنچه از قبل آماده و در فایل پروژه اولیه قرار گرفته است**</summary>
فایل `src/data/room.ts` اینها را `export` میکند:
+ `Member` و `TimelineEvent` تایپها
+ `MATCH` مشخصات بازی
+ `SEED_MEMBERS` فهرست اعضای اتاق و `ME_ID` شناسهٔ خود کاربر
+ `TIMELINE` سناریوی رویدادهای بازی
+ `REACTION_EMOJIS` فهرست ایموجیهای مجاز
</details>
# **جزئیات پیادهسازی**
برنامه شش صفحه دارد که با روتینگ بینشان جابهجا میشوید:
| **صفحه** | **مسیر** | `data-testid` |
| :-: | :-: | :-: |
| اتاق | `/` | `room-page` |
| نظرسنجیها | `/polls` | `polls-page` |
| پیشبینیها | `/predictions` | `predictions-page` |
| جدول امتیاز | `/leaderboard` | `leaderboard-page` |
| خط زمان | `/timeline` | `timeline-page` |
| تنظیمات | `/settings` | `settings-page` |
+ **نکته:** توابع `engine` باید **خالص** *(Pure)* باشند: به `DOM` دسترسی نداشته باشند (بهجز `persistence.ts` که `Storage` را بهعنوان پارامتر میگیرد) و هیچوقت ورودیشان را **تغییر ندهند.**
|  |
| :-: |
| چت، واکنش و نظرسنجی همزمان در یک اتاق؛ همین جریان را در اجرای واقعی برنامه نشان میدهد |
<details class="green">
<summary>**ترتیب پیشنهادی پیادهسازی**</summary>
1. `format.ts` و `room.ts` کوچکاند و بقیه ماژولها به آنها نیاز دارند.
2. `chat.ts` و `polls.ts` و `reactions.ts` هرکدام مستقلاند.
3. `predictions.ts` و `leaderboard.ts` با هم پیش میروند.
4. `timeline.ts` و `command.ts` و `virtual.ts` و `persistence.ts` کوچکاند.
5. و **آخر** از همه به سراغ پیادهسازی `RoomContext` و کامپوننتها و صفحهها بروید.
</details>
<details class="blue">
<summary>**پیادهسازی `engine/format.ts`**</summary>
این فایل رشتههای آمادهٔ نمایش را میسازد تا کامپوننتها درگیر قالببندی نشوند و شکل اعداد در کل برنامه یکدست بماند. همهٔ این توابع **خالص**اند؛ یعنی فقط از روی ورودیشان رشته میسازند. شش تابع زیر را بنویسید:
+ `formatMinute(minute)` دقیقهٔ بازی را برای نمایش آماده میکند و برای دقیقههای بعد از نود، قالب وقت اضافه میدهد.
+ `formatScore(home, away)` دو عدد گل را به یک نتیجهٔ واحد تبدیل میکند. فاصلههای دو طرف خط تیره بخشی از قالباند.
+ `percent(value)` عدد را به رشتهٔ درصد با عدد صحیح تبدیل میکند.
+ `formatRankDelta(delta)` جابهجایی رتبه را با علامتش نشان میدهد و برای «بدون تغییر» رشتهٔ **خالی** برمیگرداند، نه صفر.
+ `onlineLabel(count)` تعداد کاربران آنلاین را به برچسب نوار بالا تبدیل میکند.
+ `pointsLabel(points)` امتیاز را با واحدش برمیگرداند.
خروجی دقیق هر تابع را از نمونهٔ زیر بردارید؛ سیستم داوری رشته را کاراکتربهکاراکتر میسنجد:
```js
formatMinute(12) // "12'"
formatMinute(93) // "90+3'"
formatScore(2, 1) // "2 - 1"
percent(66.6) // "67%"
formatRankDelta(2) // "+2"
formatRankDelta(-1) // "-1"
onlineLabel(4) // "4 online"
pointsLabel(10) // "10 pts"
```
+ **نکته:** بلوک بالا قالب نمایش دقیقهٔ بازی را نشان میدهد. دقیقه با علامت پرایم نوشته میشود و برای وقت اضافه قالب جداگانه دارد، پس عددهای بالای `90` باید به شکل `"90+n'"` دربیایند.
+ **نکته:** خروجی همیشه رشته است و مستقیم روی صفحه مینشیند، پس فاصله و علامتها هم دقیقاً باید همان باشند.
+ **نکته:** دقیقهٔ بعد از نود به شکل وقت اضافه نوشته میشود، یعنی دقیقهٔ ۹۳ میشود `90+3'`.
**تابع** `tokenizeChat(text)` متن پیام را به آرایهای از توکنها میشکند تا بتوانید منشنها را جدا از متن عادی رندر کنید. هر توکن شکل `{ text: string, mention: boolean }` دارد؛ مثلاً `tokenizeChat("hi @Mina")` سه توکن میدهد که فقط توکن `"@Mina"` مقدار `mention: true` دارد.
</details>
<details class="green">
<summary>**پیادهسازی `engine/room.ts` و `engine/chat.ts`**</summary>
**اتاق تماشا وضعیت حضور کاربران را نگه میدارد:** چه کسانی عضو اتاق هستند، چه کسانی در حال حاضر آنلایناند و میزبان اتاق چه کسی است. سه چیز را از هم جدا نگه دارید، چون هر سه معنای متفاوتی دارند: `members` همهٔ اعضا، `online` فقط شناسهٔ کسانی که آنلایناند و `hostId` شناسهٔ میزبان. در قسمت زیر تعاریف و نحوه پیادهسازی هر کدام از توابع این ماژول بررسی میشوند:
|  |
| :-: |
| اتاق تماشا با فهرست اعضا و جریان چت |
- **تابع `createRoom(members)`:** اتاق تازهای میسازد و شیئی به شکل `{ members, online, hostId }` برمیگرداند. آرایهٔ `online` فقط شناسه نگه میدارد و **بدون تکرار** است؛ یعنی یک عضو نمیتواند دو بار آنلاین باشد.
- **تابع `presenceOrder(state)`:** فهرست اعضا را به همان ترتیبی برمیگرداند که باید در نوار حضور نمایش داده شوند. یک قاعدهٔ ثابت دارد: **میزبان همیشه اول است.**
- **توابع `join(state, id)` و `leave(state, id)`:** عضو را آنلاین یا آفلاین میکنند. آنلاین کردن عضوی که از قبل آنلاین است نباید شناسهاش را دوباره به فهرست اضافه کند و آفلاین کردن عضوی که از قبل آفلاین است هم نباید خطا بدهد.
- **توابع `isOnline(state, id)` و `onlineCount(state)`:** اولی میگوید یک عضو مشخص آنلاین است یا نه و مقدار بولی برمیگرداند؛ دومی تعداد کل اعضای آنلاین را میدهد.
- **توابع `isHost(state, id)` و `transferHost(state, id)`:** اولی میگوید این عضو میزبان است یا نه. دومی میزبانی اتاق را به عضو دیگری میدهد. چون ترتیب نوار حضور به میزبان وابسته است، بعد از انتقال میزبانی ترتیب نمایش هم عوض میشود.
- **تابع `memberById(state, id)`:** عضو را با شناسهاش پیدا میکند و برای شناسهٔ ناشناخته `null` میدهد، نه خطا.
**چت** یک محدودیت نرخ ارسال دارد تا کسی اتاق را پر نکند:
```ts
export const RATE_LIMIT_MS = 1000;
export const MAX_BURST = 3;
```
> این ثابت فاصلهٔ مجاز میان دو پیام یک کاربر است. بدون آن، کاربر میتواند چت را پر کند؛ با آن، پیامی که زودتر از این فاصله فرستاده شود رد میشود.
>
> منطق محدودیت باید بر پایهٔ زمان آخرین پیام همان کاربر باشد، نه زمان کل اتاق. کاربران دیگر نباید تحت تأثیر قرار بگیرند.
- **تابع `canSend(messages, memberId, now)`:** میگوید این عضو در این لحظه اجازهٔ ارسال دارد یا نه و مقدار بولی برمیگرداند. قاعدهاش پنجرهٔ کشویی است: تعداد پیامهای **همان عضو** در بازهٔ `RATE_LIMIT_MS` گذشته باید **کمتر از** `MAX_BURST` باشد. یعنی با سقف سه پیام، عضو میتواند سه پیام پشتسرهم بفرستد ولی پیام چهارم داخل همان یک ثانیه رد میشود؛ بعد از گذشتن پنجره دوباره اجازه پیدا میکند. پیامهای بقیهٔ اعضا در این محاسبه اثری ندارند.
- **تابع `sendMessage(messages, members, memberId, text, now)`:** پیام تازه را به فهرست اضافه میکند و **فهرست تازه** برمیگرداند؛ فهرست ورودی دستنخورده میماند. سه فیلتر پیش از افزودن اعمال میشود: متن `trim` میشود، پیامی که بعد از `trim` خالی بماند اصلاً اضافه نمیشود و اگر محدودیت نرخ اجازه ندهد پیام رد میشود. در هر سه حالتِ رد، تابع خطا نمیدهد و فقط فهرست بدون تغییر برمیگردد.
- **تابع `parseMentions(text, members)`:** متن پیام را میگردد و شناسهٔ اعضایی را که با `@` نام برده شدهاند برمیگرداند. فقط نامهایی که به عضو واقعی اتاق میخورند به حساب میآیند؛ یک `@` با نام ناشناخته منشن نیست.
- **تابع `mentionsOf(messages, memberId)`:** از میان همهٔ پیامها، آنهایی را برمیگرداند که این عضو در آنها منشن شده است. نوار «منشنهای من» از همین تغذیه میشود.
- **توابع `messageCount(messages)` و `resetChatSeq()`:** اولی تعداد پیامها را برمیگرداند. دومی شمارندهٔ شناسهٔ پیامها را از نو شروع میکند تا شناسهها تکرارپذیر بمانند.
</details>
<details class="violet">
<summary>**پیادهسازی `engine/polls.ts`**</summary>
نظرسنجی زنده همان چیزی است که میزبان وسط بازی میپرسد و بقیه رأی میدهند. نکتهٔ طراحیاش این است که رأیها را **بر اساس عضو** نگه میدارید، نه بر اساس گزینه؛ یعنی برای هر عضو یک گزینه ذخیره میشود. همین انتخاب باعث میشود **«هر عضو فقط یک رأی»** خودبهخود برقرار بماند و عوض کردن رأی هم ساده شود. در قسمت زیر تعاریف و نحوه پیادهسازی هر کدام از توابع این ماژول بررسی میشوند:
|  |
| :-: |
| نظرسنجی زنده و درصد آرای هر گزینه |
- **تابع `createPoll(question, optionLabels, createdAt = 0)`:** نظرسنجی تازه میسازد. شناسهٔ گزینهها را خودش تولید میکند، به ترتیب `"o1"` و `"o2"` و همینطور جلو. برچسبهای خالی پیش از ساخت حذف میشوند. این تابع تنها جایی در کل سوال است که باید **خطا پرتاب کند**: اگر پرسش خالی باشد یا بعد از حذف برچسبهای خالی کمتر از دو گزینه بماند، نظرسنجی معنا ندارد و باید `throw` کند.
- **توابع `vote(poll, memberId, optionId)` و `unvote(poll, memberId)`:** اولی رأی یک عضو را ثبت میکند؛ چون هر عضو فقط یک رأی دارد، رأی دادن دوباره جایگزین رأی قبلی میشود نه اینکه رأی تازهای اضافه کند. دومی رأی عضو را پس میگیرد. هیچکدام روی نظرسنجی بسته اثر ندارند.
- **تابع `closePoll(poll)`:** نظرسنجی را میبندد. بعد از بسته شدن، هیچ رأی تازهای پذیرفته نمیشود ولی نتیجه همچنان قابل خواندن است.
- **توابع `totalVotes(poll)` و `votesFor(poll, optionId)` و `percentFor(poll, optionId)`:** اولی تعداد کل رأیهای ثبتشده، دومی تعداد رأیهای یک گزینه و سومی سهم آن گزینه را برمیگرداند. خروجی `percentFor` یک **عدد** درصدِ صحیح است، نه رشته. حالت مرزیاش تقسیم بر صفر است: وقتی هنوز هیچ رأیی ثبت نشده، برای همهٔ گزینهها `0` برمیگردد نه `NaN`.
- **تابع `leadingOptions(poll)`:** شناسهٔ گزینه یا گزینههای پیشتاز را برمیگرداند. عمداً آرایه است تا حالت تساوی هم پوشش داده شود؛ اگر چند گزینه بیشترین رأی را داشته باشند، **همهشان** برمیگردند و رابط کاربری هم باید همه را متمایز نشان بدهد.
- **توابع `hasVoted(poll, memberId)` و `votedOption(poll, memberId)`:** اولی میگوید این عضو رأی داده است یا نه و مقدار بولی برمیگرداند. دومی شناسهٔ گزینهای را که عضو انتخاب کرده برمیگرداند و اگر رأی نداده باشد `null`. رابط کاربری با همین دو، گزینهٔ انتخابشدهٔ کاربر را برجسته میکند.
- **تابع `resetPollSeq()`:** شمارندهٔ شناسهٔ نظرسنجیها را از نو شروع میکند تا شناسهها تکرارپذیر بمانند.
</details>
<details class="orange">
<summary>**پیادهسازی `engine/predictions.ts` و `engine/leaderboard.ts`**</summary>
**پیشبینی با نظرسنجی فرق دارد:** نظرسنجی نظر جمع را میگیرد، ولی پیشبینی **جواب درست** دارد و بعداً امتیاز میآورد. هر پرسش دو مرحله دارد؛ تا وقتی باز است اعضا میتوانند نظرشان را عوض کنند و بهمحض اینکه میزبان جواب را ثبت کند پرسش **قفل** میشود و دیگر تغییر نمیپذیرد. در قسمت زیر تعاریف و نحوه پیادهسازی هر کدام از توابع این ماژول بررسی میشوند:
|  |
| :-: |
| جدول امتیاز بر پایهٔ درستی پیشبینیها |
هر پرسش پیشبینی چهار گزینه دارد: `goal`، `card`، `var` و `none`.
```ts
export const POINTS_CORRECT = 10;
```
> این ثابتها نرخ امتیازدهی پیشبینیاند. پیشبینی درست امتیاز کامل میگیرد و حالتهای نزدیک امتیاز کمتر، تا جدول امتیاز فقط دو حالت درست و غلط نداشته باشد.
>
> این مقادیر را در کد پخش نکنید؛ همهجا از همین ثابتها بخوانید تا تغییرشان یکجا ممکن باشد.
- **تابع `makePrediction(predictions, memberId, questionId, choice)`:** پیشبینی یک عضو برای یک پرسش را ثبت میکند و ساختار تازه برمیگرداند. تا وقتی پرسش قفل نشده، ثبت دوباره انتخاب قبلی را جایگزین میکند؛ بعد از قفل شدن، هیچ تغییری پذیرفته نمیشود.
- **تابع `predictionFor(predictions, memberId, questionId)`:** انتخاب فعلی یک عضو برای یک پرسش را برمیگرداند و اگر چیزی ثبت نکرده باشد `null` میدهد. یک نکتهٔ ظریف هم که سیستم داوری میسنجد این است که گزینهٔ `none` یعنی «کاربر پیشبینی کرد که هیچکدام از اینها رخ نمیدهد» و یک انتخاب کاملاً معتبر است؛ این با `null` که یعنی «کاربر اصلاً شرکت نکرده» فرق دارد.
- **توابع `resolveQuestion(question, answer)` و `isLocked(question)`:** اولی جواب درست را روی پرسش ثبت میکند و همان لحظه قفلش میکند. دومی میگوید پرسش قفل شده است یا نه و مقدار بولی برمیگرداند؛ رابط کاربری با همین، گزینهها را غیرفعال میکند.
- **تابع `wasCorrect(predictions, question, memberId)`:** میگوید پیشبینی این عضو برای این پرسش درست بوده یا نه. برای پرسشی که هنوز جواب نگرفته یا عضوی که شرکت نکرده، نتیجه درست حساب نمیشود.
- **تابع `scoreMember(predictions, questions, memberId)`:** امتیاز کل پیشبینیهای یک عضو را روی همهٔ پرسشهای حلشده جمع میزند و برمیگرداند. نرخ امتیاز را از ثابتهای بالای همین فایل بخوانید و عدد را در کد پخش نکنید.
**جدول امتیاز** امتیاز پیشبینی و امتیاز واکنش را با هم جمع میزند:
```ts
export const REACTION_POINT = 1;
```
> این ثابت امتیاز هر واکنش است و در جمع امتیاز کاربر لحاظ میشود. سهمش عمداً کوچک است تا واکنشفرستادن جای پیشبینی درست را نگیرد.
>
> در محاسبهٔ جدول امتیاز، امتیاز پیشبینی و امتیاز واکنش با هم جمع میشوند.
- **تابع `totalScore(row)`:** امتیاز کل یک عضو را برمیگرداند، یعنی امتیاز پیشبینیها بهعلاوهٔ امتیاز واکنشهایش.
- **تابع `rank(rows)`:** فهرست را بر اساس امتیاز مرتب میکند و به هر ردیف یک `rank` میدهد. دو قاعده دارد که خروجی را قطعی میکنند و سیستم داوری روی هر دو حساب میکند:
+ تساوی امتیاز با **شناسهٔ عضو به ترتیب صعودی الفبایی** شکسته میشود، تا جدول با هر بار محاسبه یکسان دربیاید.
+ شمارهگذاری رتبهها به سبک **مسابقهای** است: دو نفر مساوی هر دو رتبهٔ `1` میگیرند و نفر بعدی رتبهٔ `3` میشود، نه `2`.
- **تابع `rankMap(ranked)`:** از فهرست رتبهبندیشده یک نگاشت از شناسهٔ عضو به رتبهاش میسازد، تا پیدا کردن رتبهٔ یک نفر بدون گشتن در کل فهرست ممکن باشد.
- **تابع `rankDelta(prevRanked, currRanked, memberId)`:** جابهجایی رتبهٔ یک عضو را نسبت به حالت قبلی برمیگرداند. علامتش مهم است: مقدار **مثبت یعنی صعود**، چون رتبهٔ کوچکتر بهتر است. نشانگر بالا و پایین کنار نام هر عضو از همین عدد میآید.
- **تابع `leader(ranked)`:** عضو صدرنشین جدول را برمیگرداند و برای فهرست خالی `null` میدهد.
</details>
<details class="teal">
<summary>**پیادهسازی `engine/reactions.ts` و `engine/timeline.ts`**</summary>
**واکنشها ایموجیهایی هستند** که روی صفحه بالا میروند و بعد از مدتی محو میشوند:
```ts
export const REACTION_LIFETIME_MS = 1600;
```
> این ثابت میگوید هر واکنش شناور چقدر روی صفحه بماند. بعد از این مدت باید از فهرست واکنشهای زنده حذف شود، وگرنه فهرست بینهایت بزرگ میشود.
>
> حذف باید بر اساس زمان ثبت هر واکنش انجام شود، نه با یک تایمر جداگانه برای هرکدام.
- **تابع `pushReaction(buffer, emoji, memberId, at, xSeed?)`:** واکنش تازهای به بافر اضافه میکند و بافر تازه برمیگرداند. هر واکنش زمان ثبتش را با خودش نگه میدارد؛ همین زمان است که بعداً تعیین میکند کِی باید محو شود. پارامتر اختیاری `xSeed` جای افقی ایموجی روی صفحه را مشخص میکند و عمداً پارامتر است تا انیمیشن **تکرارپذیر** بماند و به تصادف وابسته نباشد.
- **تابع `decay(buffer, now, lifetime)`:** واکنشهایی را که عمرشان تمام شده از بافر بیرون میاندازد و بافر تازه برمیگرداند. تصمیم حذف بر اساس زمان ثبت هر واکنش گرفته میشود، نه با تایمر جداگانه برای هرکدام. بدون این تابع، بافر با هر واکنش تازه بزرگتر میشود و هیچوقت کوچک نمیشود.
- **تابع `activeCount(buffer, now, lifetime)`:** تعداد واکنشهایی را که در این لحظه هنوز زندهاند برمیگرداند، بدون اینکه چیزی را از بافر حذف کند.
- **تابع `aggregate(buffer)`:** شمارش هر ایموجی را به شکل نگاشتی از ایموجی به تعداد برمیگرداند، مثلاً `{ "🔥": 3, "👏": 1 }`. نوار خلاصهٔ واکنشها از همین ساخته میشود.
- **تابع `totalReactions(buffer)`:** تعداد کل واکنشهای داخل بافر را برمیگرداند.
- **تابع `topEmoji(buffer)`:** پرتکرارترین ایموجی را برمیگرداند. دو حالت مرزی دارد: برای بافر خالی **رشتهٔ خالی** میدهد نه `null` و در تساوی، ایموجیای برنده است که **زودتر** در بافر دیده شده باشد؛ همین قاعده خروجی را قطعی میکند.
- **تابع `countsByMember(buffer)`:** نگاشتی از شناسهٔ عضو به تعداد واکنشهایش برمیگرداند. امتیاز واکنش در جدول امتیاز از همینجا میآید.
**خط زمان** رویدادهای بازی را یکییکی آزاد میکند:
```ts
export const initialTimeline: TimelineState = { cursor: 0, released: [], minute: 0 };
```
> این وضعیت اولیهٔ خط زمان است. مقدار `cursor` میگوید تا کجای رویدادها پیش رفتهایم و `released` رویدادهایی است که تا این لحظه منتشر شدهاند.
>
> خط زمان با هر تیک ساعت جلو میرود و رویدادهای رسیده را به `released` اضافه میکند. این شیء را تغییر ندهید و در هر گام وضعیت تازه بسازید.
خط زمان یک بازیِ از پیش نوشتهشده را شبیهسازی میکند: `script` همهٔ رویدادهای بازی را از قبل دارد، ولی کاربر باید آنها را یکییکی و بهمرور ببیند. وضعیت خط زمان میگوید تا کجای این فهرست پیش رفتهایم. همهٔ توابع زیر وضعیت ورودی را تغییر نمیدهند و وضعیت تازه برمیگردانند.
- **توابع `advance(state, script)` و `advanceBy(state, script, n)`:** اولی یک رویداد و دومی `n` رویداد جلو میرود و رویدادهای تازه را به فهرست منتشرشده اضافه میکند. وقتی به انتهای فهرست رسیدید، جلو رفتن بیشتر نباید خطا بدهد یا از انتها عبور کند؛ وضعیت همانجا میماند.
- **تابع `isComplete(state, script)`:** میگوید همهٔ رویدادهای بازی آزاد شدهاند یا نه و مقدار بولی برمیگرداند.
- **تابع `progress(state, script)`:** نسبت پیشرفت را بهصورت عددی بین `0` و `1` برمیگرداند تا نوار پیشرفت بازی از رویش ساخته شود. برای `script` خالی باید عدد معتبر بدهد، نه `NaN`.
- **تابع `score(state, home, away)`:** نتیجهٔ فعلی بازی را **از روی رویدادهای آزادشده** حساب میکند و شیئی به شکل `{ home, away }` برمیگرداند. یعنی نتیجه جایی ذخیره نمیشود و همیشه از خط زمان مشتق میشود؛ در شروع بازی هر دو صفرند و با هر رویداد گل، عدد سمت مربوطه بالا میرود.
- **تابع `eventsOfType(state, type)`:** از میان رویدادهای آزادشده فقط آنهایی را برمیگرداند که نوعشان با مقدار دادهشده میخواند؛ فیلترهای بالای خط زمان از همین استفاده میکنند.
</details>
<details class="purple">
<summary>**پیادهسازی `engine/command.ts` و `engine/virtual.ts` و `engine/persistence.ts`**</summary>
**پشتهٔ فرمان** برای بازگشت و بازانجام کارهای میزبان است:
```ts
export const emptyStack: CommandStack = { past: [], future: [] };
```
> این ساختار پشتهٔ بازگشت و بازانجام است. هر تغییر به `past` اضافه میشود و `future` هم چیزی است که با بازگشت کنار گذاشته شده و میشود دوباره اعمالش کرد.
>
> با انجام یک عمل تازه، `future` باید خالی شود؛ وگرنه بازانجامِ چیزی ممکن میشود که دیگر با وضعیت فعلی نمیخواند.
- **تابع `push(stack, command)`:** فرمان تازهای ثبت میکند و پشتهٔ تازه برمیگرداند. یک کار دوم هم انجام میدهد که فراموش کردنش خطای رایج این الگوست: شاخهٔ `future` را **پاک میکند**. اگر بعد از یک بازگشت، کار تازهای انجام شود، دیگر نباید بشود چیزی را که کنار گذاشته شده بازانجام کرد، چون با وضعیت فعلی نمیخواند.
- **توابع `canUndo(stack)` و `canRedo(stack)`:** میگویند فرمانی برای بازگشت یا بازانجام مانده است یا نه و مقدار بولی برمیگردانند. دکمههای میزبان با همین دو فعال و غیرفعال میشوند.
- **توابع `undo(stack)` و `redo(stack)`:** یک قدم عقب یا جلو میروند. نکتهٔ مهمشان شکل خروجی است: شیئی به شکل `{ stack, command }` برمیگردانند، یعنی هم پشتهٔ تازه و هم **خود فرمانی که باید اثرش برگردانده یا دوباره اعمال شود**. دلیلش این است که پشته فقط تاریخچه را نگه میدارد و اعمال واقعی جای دیگری انجام میشود. اگر کاری برای انجام نمانده باشد، `command` برابر `null` است و پشته بدون تغییر برمیگردد.
- **توابع `depth(stack)` و `lastLabel(stack)`:** اولی تعداد فرمانهای ثبتشده را برمیگرداند. دومی برچسب آخرین فرمان را میدهد تا کنار دکمهٔ بازگشت نوشته شود و برای پشتهٔ خالی **رشتهٔ خالی** برمیگرداند نه `null`.
**مجازیسازی فهرست (virtualization)** یعنی از یک فهرست طولانی چت فقط همان بخشی که در کادر دید کاربر است رندر شود، تا صفحه با هزاران پیام کند نشود:
- **تابع `computeRange({ itemCount, rowHeight, viewportHeight, scrollTop })`:** مغز مجازیسازی است. از روی موقعیت فعلی اسکرول و ابعاد، حساب میکند کدام بازه از ردیفها باید رندر شود و شیئی با **چهار** فیلد برمیگرداند:
+ `start` و `end` اندیس ابتدا و انتهای بازهٔ قابل نمایش. کمی `overscan` هم لحاظ میشود، یعنی چند ردیف بیشتر از کادر دید رندر میشود تا هنگام اسکرول سریع، جای خالی دیده نشود. مقدار `end` هیچوقت از `itemCount` بیرون نمیزند.
+ `offsetTop` فاصلهای که ردیفهای رندرشده باید از بالا داشته باشند تا سر جای درستشان بنشینند؛ برابر است با `start` ضربدر ارتفاع هر ردیف.
+ `totalHeight` ارتفاع کل فهرست اگر همهاش رندر میشد؛ همین است که نوار اسکرول را به اندازهٔ واقعی نگه میدارد در حالی که فقط چند ردیف در `DOM` هستند.
حالت مرزیاش مهم است: وقتی فهرست خالی است یا ارتفاع ردیف صفر است، هر دو `start` و `end` باید صفر باشند و تابع نباید تقسیم بر صفر انجام بدهد.
- **تابع `sliceWindow(items, range)`:** از آرایهٔ کامل، فقط همان بازهای را که `computeRange` مشخص کرده برمیدارد. همین آرایهٔ کوچک است که واقعاً رندر میشود.
- **تابع `bottomOffset(itemCount, rowHeight, viewportHeight)`:** مقدار اسکرول لازم برای رسیدن به انتهای فهرست را برمیگرداند. وقتی پیام تازهای میآید، چت با همین مقدار تا پایین اسکرول میشود. اگر کل فهرست کوتاهتر از کادر دید باشد، جایی برای اسکرول نیست و خروجی نباید منفی شود.
**ذخیرهسازی**:
```ts
export const STORAGE_KEY = "kickoff.room.v1";
```
> این کلید همان جایی است که وضعیت اتاق زیر آن در `localStorage` ذخیره میشود و پسوند نسخه در نامش عمدی است.
>
> هنگام بازیابی، اگر مقدار ذخیرهشده خراب یا ناسازگار بود، برنامه باید با وضعیت اولیه بالا بیاید و خطا ندهد.
- **تابع `saveRoom(data, storage = localStorage)`:** وضعیت اتاق را زیر کلید بالا ذخیره میکند. شکل `data` دقیقاً `{ polls, predictions, chat, reactions }` است و هر چهار مقدار باید آرایه باشند.
- **تابع `loadRoom(storage = localStorage)`:** وضعیت ذخیرهشده را میخواند و برمیگرداند. مقاومتش در برابر دادهٔ خراب همان چیزی است که سنجیده میشود؛ در هر سه حالت زیر باید `null` برگردد و **هیچ خطایی پرتاب نشود**:
+ وقتی هنوز چیزی ذخیره نشده باشد.
+ وقتی مقدار ذخیرهشده `JSON` معتبری نباشد.
+ وقتی هر کدام از آن چهار آرایه در دادهٔ ذخیرهشده نباشد یا آرایه نباشد.
پارامتر `storage` مثل بقیهٔ جاها تزریق میشود تا بشود این دو تابع را با یک حافظهٔ ساختگی و بدون مرورگر هم سنجید.
</details>
<details class="yellow">
<summary>**پیادهسازی `hooks/useMatchClock.ts` و `RoomContext`**</summary>
- **هوک `useMatchClock(onTick, options)`:** ضربان برنامه است: هر چند ثانیه یکبار تابع `onTick` را صدا میزند تا خط زمان بازی یک قدم جلو برود. پارامتر `options` سه چیز را قابل تنظیم میکند: `intervalMs` فاصلهٔ میان تیکها با پیشفرض `1800`، `autoStart` که میگوید ساعت از همان ابتدا راه بیفتد یا نه با پیشفرض `true` و توابع `setInterval` و `clearInterval` که **تزریقشدنی**اند.
تزریقپذیر بودن تایمر عمدی است و همان کاری را میکند که تزریق `storage` در ماژول ذخیرهسازی: اجازه میدهد در سیسم داوری یک تایمر ساختگی داده شود و زمان دستی جلو برود، بدون اینکه واقعاً منتظر بمانیم. حواستان به پاکسازی هم باشد؛ وقتی کامپوننت برداشته میشود، تایمر باید متوقف شود وگرنه بعد از خروج از صفحه هم تیک میزند.
|  |
| :-: |
| همان صفحه در تم تیره |
- **کامپوننت `RoomProvider` و هوک `useRoom()`:** `RoomProvider` کل وضعیت برنامه را یکجا نگه میدارد: اتاق، چت، نظرسنجیها، پیشبینیها، واکنشها، خط زمان، پشتهٔ فرمان و تم. هوک `useRoom()` راه دسترسی کامپوننتها به این وضعیت است و اگر بیرون از `RoomProvider` صدا زده شود باید **خطا پرتاب کند**؛ اینطور بهجای یک `undefined` مرموز در عمق کامپوننت، خطای روشنی میگیرید.
این کامپوننت یک پراپ اختیاری `now?: () => number` میگیرد که پیشفرضش `Date.now` است. هر جا به زمان فعلی نیاز دارید (مهر زمانی پیامها و واکنشها) باید از همین تابع بخوانید تا سیستم داوری بتواند زمان را کنترل کنند.
همچنین `RoomProvider` باید دو پرسش پیشبینی اولیه بسازد: یکی با شناسهٔ `q1` و یکی با شناسهٔ `q2`. هر دو چهار گزینهٔ `goal` و `card` و `var` و `none` دارند و در شروع باز (قفلنشده) هستند.
+ **توجه: نقش کاربر.** کاربر فعلی (`ME_ID`) میزبان **نیست**. هر کنترلی که کار میزبان است باید برای مهمان اصلاً **رندر نشود**، نه اینکه `disabled` شود. اینها شامل `undo-button` و فرم `poll-form` و دکمههای `resolve-<questionId>-<choice>` میشوند.
**تم** بهصورت ویژگی `data-theme` روی `document.documentElement` نوشته میشود؛ مقدارش `dark` یا `light` است، **در شروع `light`**. انتخاب کاربر در `localStorage` ذخیره میشود.
</details>
<details class="olive">
<summary>**کامپوننتها و صفحهها**</summary>
هر کامپوننت باید `default export` داشته باشد.
**پوستهٔ مشترک:**
+ لینکهای منو: `nav-polls`، `nav-predictions`، `nav-leaderboard`، `nav-timeline` و `nav-settings`.
+ نتیجهٔ زنده با `data-testid="live-score"` که با فرمت `formatScore` نوشته میشود.
+ تعداد آنلاینها با `data-testid="online-count"`.
+ امتیاز خود کاربر با `data-testid="my-score"`.
+ دکمهٔ `theme-toggle` و شمارندهٔ `command-depth` که متنش به شکل `12 actions` است.
+ دکمهٔ `undo-button` که فقط برای میزبان رندر میشود؛ چون کاربر فعلی مهمان است، در صفحه نباید باشد.
+ عنصر `settings-role` که باید کلمهٔ `host` یا `guest` را داشته باشد.
+ ناحیهٔ اعلان با `data-testid="toast"`. وقتی پیامی بهخاطر محدودیت نرخ رد میشود، یک اعلان نشان داده میشود و **خودش بعد از حداکثر سه ثانیه** پاک میشود. وقتی اعلانی در کار نیست، این عنصر اصلاً رندر نمیشود.
|  |
| :-: |
| فرم ثبت پیشبینی پیش از شروع بازی |
**صفحهٔ اتاق (`room-page`):**
+ نوار حضور با `presence-rail` که داخلش `presence-list` است و برای هر عضو یک `presence-<id>` که ویژگی `data-online` با مقدار `"true"` یا `"false"` دارد. عضو میزبان یک `host-badge-<id>` میگیرد.
+ پنل چت با `chat-panel` که شامل `chat-list` (با `aria-live="polite"`)، فرم `chat-form`، ورودی `chat-input` و دکمهٔ `chat-send` است. تا وقتی پیامی نیست، `chat-empty` نشان داده میشود. منشنها داخل متن با `data-testid="mention"` مشخص میشوند و متن هر پیام باید داخل عنصر جدای خودش باشد، جدا از نام فرستنده.
+ نوار واکنش با `reaction-bar` که برای هر ایموجی یک دکمهٔ `react-<emoji>` با `aria-label="Send <emoji>"` دارد، بههمراه `reaction-total` که `aria-live="polite"` دارد و متنش به شکل `0 reactions in the air` است.
+ لایهٔ ایموجیهای شناور با `emoji-storm` و `storm-layer` که `aria-hidden="true"` میگیرد.
+ نوار خط زمان با `timeline-ribbon` که داخلش یک عنصر با `role="progressbar"` دارد. تا وقتی هیچ رویدادی آزاد نشده، همینجا `timeline-empty` نشان داده میشود.
**صفحهٔ نظرسنجی (`polls-page`):** فرم `poll-form` (فقط برای میزبان؛ برای مهمان اصلاً رندر نمیشود)، فهرست `poll-list` و برای هر نظرسنجی یک کارت با گزینههای `choice-<pollId>-<optionId>`. تا وقتی نظرسنجیای نیست، `poll-list` هم رندر نمیشود و فقط `polls-empty` دیده میشود.
**صفحهٔ پیشبینی (`predictions-page`):** پنل `prediction-panel` که برای هر پرسش یک `question-<id>` دارد و داخل آن بخش گزینهها `choices-<id>` با دکمههای `choice-<questionId>-<choice>` که هرکدام `aria-pressed` میگیرند. پرسش قفلشده `question-locked-<id>` میگیرد. دکمههای `resolve-<questionId>-<choice>` کار میزباناند و برای مهمان اصلاً رندر نمیشوند.
|  |
| :-: |
| کنترلهای اضافی که فقط میزبان اتاق میبیند |
**صفحهٔ جدول (`leaderboard-page`):** فهرست `leaderboard-list` که برای هر عضو یک `leader-row-<id>` دارد، بههمراه `leader-rank-<id>` و `leader-total-<id>`.
**صفحهٔ خط زمان (`timeline-page`):** برای هر رویداد آزادشده یک `timeline-event-<id>`؛ تا وقتی چیزی آزاد نشده `timeline-empty`.
**صفحهٔ تنظیمات (`settings-page`):** بخشهای `settings-role`، `settings-persistence` و `settings-log`.
</details>
# **آنچه سیستم داوری بررسی میکند**
**سیستم داوری** توابع `engine` را مستقیم `import` میکنند و برنامه را داخل `MemoryRouter` و `RoomProvider` اجرا میکند. رنگها، فونتها و اندازهٔ پیکسلی در سیستم داوری بررسی نمیشوند. آنچه در سیستم بررسی میشود:
+ **مقدار بازگشتی توابع** `format` و `polls` و `chat` و `timeline` و `command`، دقیقاً مطابق مثالهای بالا
+ **محدودیت نرخ ارسال چت با زمان تزریقشده**
+ **برگرداندن** همهٔ گزینههای پیشتاز در `leadingOptions` هنگام تساوی
+ **پایدار بودن** ترتیب در `rank` هنگام تساوی امتیاز
+ **خالی شدن** `future` هنگام `push` در پشتهٔ فرمان و برگشت `command: null` وقتی کاری نیست
+ **برگشت** `null` در `loadRoom` برای مقدار خراب
+ **پرتاب شدن** خطا وقتی `useRoom()` بیرون از `RoomProvider` صدا زده شود
+ **وجود** `default export` برای هر کامپوننت
+ **وجود** عناصر با `data-testid`های گفتهشده و ترتیب اعضا در نوار حضور
+ مقدار `data-theme` روی `document.documentElement`
+ **ذخیره و بازیابی وضعیت** زیر کلید `kickoff.room.v1`
آنچه در سیستم داوری بررسی **نمیشود** و در پیادهسازی آن میتوانید به دلخواه عمل کنید:
+ **مسیر حرکت** و **انیمیشن** ایموجیهای شناور
+ **چیدمان** و **طراحی** بصری صفحهها
# **آنچه باید آپلود کنید**
- **توجه:** فایل زیپ شما باید همان ساختار پروژهٔ اولیه را داشته باشد و پوشهٔ `node_modules` را **نداشته باشد.**
```plaintext
└── src/
├── components/
│ ├── ChatPanel.tsx
│ ├── EmojiStorm.tsx
│ ├── Leaderboard.tsx
│ ├── PollCard.tsx
│ ├── PollPanel.tsx
│ ├── PredictionPanel.tsx
│ ├── PresenceRail.tsx
│ ├── ThemeToggle.tsx
│ ├── TimelineRibbon.tsx
│ └── Toast.tsx
├── context/
│ └── RoomContext.tsx
├── engine/
│ ├── chat.ts
│ ├── command.ts
│ ├── format.ts
│ ├── leaderboard.ts
│ ├── persistence.ts
│ ├── polls.ts
│ ├── predictions.ts
│ ├── reactions.ts
│ ├── room.ts
│ ├── timeline.ts
│ └── virtual.ts
├── hooks/
│ └── useMatchClock.ts
├── pages/
│ ├── LeaderboardPage.tsx
│ ├── PollsPage.tsx
│ ├── PredictionsPage.tsx
│ ├── RoomPage.tsx
│ ├── SettingsPage.tsx
│ └── TimelinePage.tsx
└── App.tsx
```
- **توجه:** درخت بالا دقیقاً همان چیزی است که باید در فایل زیپ آپلود کنید. پوشهٔ `src/` باید در ریشهٔ فایل زیپ باشد و ساختار داخلش همان ساختار پروژهٔ اولیه بماند.
- **توجه:** پوشهٔ `node_modules` را در فایل زیپ ارسالی نگذارید و فایل تازهای اضافه نکنید.
- **توجه:** فایلها باید در **ریشهٔ** فایل زیپ باشند، نه داخل یک پوشهٔ اضافه. اگر همهچیز را داخل پوشهای به اسم `answer` بگذارید، مسیرهای موردانتظار پیدا نمیشوند و داوری اجرا نمیشود.
- **توجه: فایل جدیدی اضافه نکنید.** سیستم داوری کوئرا فقط فایلهای بالا را برمیدارد، پس اگر ماژول جدیدی بسازید و از جایی `import`ش کنید، بیلد پروژه هنگام داوری شکست میخورد و سیستم داوری نمره صفر را لحاظ میکند. کد کمکی را داخل همان فایلهای موجود بنویسید.
- **توجه:** تمام متنهای داخل صفحه **انگلیسی** هستند.
- **توجه:** ارزیابی به رنگ و فونت و فاصله کاری ندارد؛ مقدار بازگشتی توابع، رفتار کامپوننتها و `data-testid`ها بررسی میشوند.
- **توجه:** هیچجا از `Math.random()` استفاده نکنید تا نتیجهها تکرارپذیر بمانند.