در قلبِ ماو، جایی که زمان معنا ندارد، ماشینی عظیم وجود دارد که جریانِ ارواح را کنترل میکند. شما دیگر یک سرباز نیستید؛ شما معمارِ این بازی هستید.
در این فاز، شما باید Server بازی را بنویسید. کلاینتهایی که در فاز قبل نوشته شدند، هیچ منطقی از قوانین کلی ندارند. آنها فقط نمایشگر هستند. سرور شما Source of Truth است . سرور تعیین میکند چه کسی چه کارتی خرید، چه کسی برنده شد و چه کسی مُرد.
هدف: طراحی و پیادهسازی یک سرور Multi-threaded یا Asynchronous که بتواند یک لابی ۴ نفره را مدیریت کند، Synchronization انجام دهد و قوانین بازی را اعمال کند.
نمودار زیر جریان کامل بازی از اتصال بازیکنان تا پایان بازی را نشان میدهد:
همانطور که در نمودار مشخص است، بازی از حالت LOBBY شروع میشود و پس از اتصال ۴ بازیکن، به مرحله انتخاب قهرمان (HERO_SELECT) میرود. پس از آن، فاز RECRUIT (استخدام) با زیرحالتهای SHOP_OPEN، BUY_SELL و REFRESH شروع میشود. با پایان تایمر، بازی به فاز COMBAT_CALC و سپس LOG_REPLAY میرود. اگر بیش از یک بازیکن زنده بماند، بازی دوباره به فاز RECRUIT برمیگردد و این چرخه تا زمانی که تنها یک بازیکن باقی بماند ادامه دارد.
بخش خوشگلسازی و UI تموم شد. اونجا هر گندی میزدید نهایتاً یه دکمه کج میشد یا یه پیکسل میسوخت. ولی اینجا Backend هست. اینجا "منطق" حرف اول رو میزنه. اینجا اگه اشتباه کنید، دیتابیس به فنا میره، Game Loop کرش میکنه و …
داستان بازی میگه شما "معمارِ ماو" هستید. قشنگه، نه؟ ولی در واقعیت، شما مسئول نوشتن یک ماشین حالت (State Machine) توزیعشده هستید که باید ۴ تا کلاینت غیرقابلاعتماد رو مدیریت کنه.
قانون طلایی بکاند اینه: کلاینت همیشه دروغ میگه.
کلاینت یه محیط ناامنه که دست یوزره و یوزرها (مخصوصاً گیمرها) ذاتاً دوست دارن سیستم رو دور بزنن.
اگر کلاینت ریکوئست زد:
{"action": "BUY", "card_id": "XY", "cost": 0}در حالی که قیمت واقعی ۳ تا گلده...
شما نباید بگید "آخی، حتماً کوپن تخفیف داشته".
شما باید بگید: "بشین سر جات!"، درخواست رو رد کنید و شاید حتی یه ارور CHEAT_DETECTED برگردونید.
فرض کنید تو کل بازی فقط یه دونه کارت "Mama Bear" توی Pool باقی مونده.
Player 1 و Player 2 دقیقاً در یک میلیثانیه روی دکمه خرید کلیک میکنن.
اگه کدتون رو شلووِل نوشته باشید (بدون مدیریت همزمانی)، سرور به جفتشون کارت رو میفروشه.
نتیجه؟ موجودی کارت تو دیتابیس میشه -1. لاجیک بازی میپاشه. کائنات بهم میریزه.
باید سروری بنویسید که این درخواستها رو مدیریت کنه.
روش اشتباه: استفاده از Global Variable بدون قفل (Lock). این مثل اینه که مسواکتون رو بذارید تو توالت عمومی و بگید "همه استفاده کنن".
روش درست: استفاده از صف (Queue) یا مدل asyncio در پایتون که درخواستها رو به نوبت پردازش کنه (Atomic Operations).
نمودار زیر نحوه مدیریت درخواستهای همزمان را نشان میدهد:
همانطور که در نمودار مشخص است، تمام درخواستهای بازیکنان از طریق WebSocket به Server Action Queue ارسال میشوند. Game Logic به صورت اتمیک (Atomic) و به نوبت هر درخواست را پردازش میکند، اعتبارسنجی را انجام میدهد و در صورت موفقیت، Game State را بهروزرسانی میکند. سپس تغییرات به همه کلاینتها Broadcast میشود. این معماری از Race Condition و مشکلات همزمانی جلوگیری میکند.
ما اینجا Call of Duty نمیسازیم. نیازی نیست موقعیت هر مینیون رو هر لحظه بفرستید.
تکنیک ما: Deterministic Log Replay
- فاز نبرد شروع میشه.
- سرور کل دعوا رو تو کسری از ثانیه تو حافظه خودش شبیهسازی میکنه (کی زد، کی مرد، کی سامن شد).
- نتیجه رو میریزه تو یه فایل JSON طولانی (همون Combat Log).
- این فایل رو پرت میکنه سمت کلاینتها.
- کلاینتها این فایل رو میگیرن و مثل یه فیلم "پخش" میکنن.
کامنت توسعهدهنده: اگه کلاینت وسط انیمیشن نبرد سیم کامپیوتر رو بکشه و دوباره وصل شه، سرور ما از قبل میدونسته که اون باخته. پس وقتی ریکانکت شد، صفحه "Game Over" رو نشونش میدیم. بیرحمانه ولی بهینه.
از time.sleep(1) استفاده نکنید. جدی میگم.
اگه تو لوپ اصلی بنویسید "یک ثانیه بخواب"، با لگ سیستم عامل و پردازشهای دیگه، بعد از ۱۰ دقیقه، تایمر بازی ۵ ثانیه عقب میافته (Time Drift). از Delta Time استفاده کنید:
import time
import asyncio
async def game_loop():
last_tick = time.time()
while game_running:
now = time.time()
delta = now - last_tick # زمان واقعی که گذشته
last_tick = now
update_timers(delta) # تایمرها رو دقیق کم کن
check_phases()
await asyncio.sleep(0.1) # یه استراحت ریز به CPU بدهاینترنت قطعی داره. یوزرها وسط بازی دیسکانکت میشن.
بدترین کاری که میتونید بکنید اینه که وقتی یوزر برگشت، یه "بازیکن جدید" بسازید یا بازی رو ریست کنید.
استراتژی بازگشت به زندگی:
- توکن (Token): اول بازی به هر یوزر یه UUID بدید. این هویت واقعی اونه، نه سوکتش.
- زامبیکُشی: اگه اینترنتش قطع شد، بازی ادامه داره (تایمر وامینیسه!). اگه با همون توکن برگشت (RECONNECT packet)، سوکت قبلی (که الان زامبی شده) رو بکشید و سوکت جدید رو وصل کنید.
- Full State Update: وضعیت کامل (کل میز، دست، شاپ و تایمر فعلی) رو براش بفرستید تا بفهمه چه خبره.
نمودار زیر فرآیند کامل قطع ارتباط و اتصال مجدد را نمایش میدهد:
در این نمودار مشخص است که:
- وقتی کلاینت قطع میشود، سرور Socket قدیمی را به عنوان "Zombie" علامتگذاری میکند اما وضعیت بازی را حفظ میکند
- بازی ادامه پیدا میکند و تایمرها به کار خود ادامه میدهند
- وقتی کلاینت با توکن معتبر دوباره وصل میشود، Socket قدیمی (Zombie) حذف میشود
- سرور یک FULL_GAME_STATE به کلاینت میفرستد تا کلاینت بتواند UI خود را با وضعیت فعلی سرور همگام کند
ما باید بر بستر TCP Socket (ترجیحاً WebSockets) کد بزنیم. فرمت پیامها JSON است و باید با داکیومنت mock_payloads.md مو نمیزد.
نکته: WebSocket در لایه Application (لایه ۷) از مدل OSI کار میکند و بر روی TCP (لایه Transport) ساخته شده است:
{
"action": "BUY_MINION",
"token": "user-uuid-1234",
"payload": {
"shop_slot": 1,
"expected_card_id": "BG_004"
}
}Optimistic Locking: اگه شاپ عوض شده بود، نخر!
{
"type": "state_delta",
"payload": {
"gold_change": -3,
"hand_add": {
"instance_id": "inst-999",
"card_id": "BG_004"
},
"shop_remove": 1
}
}نکته گیکی: فرق Card ID و Instance ID رو نمیدونید؟
- Card ID یعنی "نوع ماشین: پراید"
- Instance ID یعنی "پلاک اون پراید خاص"
اگه بگید "پراید رو بفروش"، سرور نمیفهمه کدوم یکی رو میگید. همیشه با Instance ID (UUID) کار کنید.
Optimistic Locking یک استراتژی Concurrency Control است که در آن فرض میکنیم تداخل بین درخواستهای همزمان به ندرت رخ میدهد. به جای اینکه داده را در لحظه خواندن «قفل» کنیم (که باعث کندی سیستم میشود)، اجازه میدهیم همه بخوانند و بنویسند، اما در لحظهی ثبت نهایی (Commit) چک میکنیم که آیا داده از زمان خوانده شدن تغییر کرده است یا خیر.
این مکانیزم معمولاً با استفاده از یک فیلد version یا timestamp در دیتابیس پیادهسازی میشود.
فرض کنید بازیکن p1 میخواهد کارتی را بخرد. او ۷ سکه طلا دارد و کارت ۳ سکه قیمت دارد.
۱. خواندن (Read): کلاینت (یا سرور در مرحله اول) وضعیت بازیکن را میخواند:
Gold: 7
Version: 10
۲. پردازش (Process): کلاینت محاسبه میکند که بعد از خرید، طلا باید 4 شود.
۳. نوشتن (Write/Update): درخواست آپدیت به دیتابیس ارسال میشود با این شرط:
«طلا را به ۴ تغییر بده، فقط اگر نسخه هنوز ۱۰ باشد.»
سناریوی موفق: اگر در این فاصله اتفاقی نیفتاده باشد، دیتابیس میبیند نسخه ۱۰ است. طلا را ۴ میکند و نسخه را به ۱۱ ارتقا میدهد.
سناریوی شکست (Race Condition): فرض کنید همزمان با درخواست خرید، یک ایونت دیگر (مثلاً پایان تایمر نوبت) وضعیت طلا یا نسخه را تغییر داده و نسخه به ۱۱ رسیده است. وقتی درخواست خرید (با شرط Version = 10) میرسد، دیتابیس میبیند نسخه فعلی ۱۱ است. شرط برقرار نیست و آپدیت انجام نمیشود (0 rows affected). در این حالت سرور یک خطا (مثلاً StateMismatch) برمیگرداند و کلاینت مجبور است وضعیت جدید را بگیرد و دوباره تلاش کند.
از اونجایی که شما معمار maw هستید باید به اینا دقت کنید:
- (Card Pool): کارتها نامحدود نیستن. اگر همه Rat Packها خریده شده باشن، رفرش کردن شاپ نباید اون کارت رو بیاره.
- Sylvanas Logic: سرور باید یادش بمونه تو نبرد قبلی کی مرده. کلاینت نمیتونه لیست مردهها رو بفرسته (چون دروغ میگه). خودتون باید Cache کنید.
- Yogg-Saron: اگه هیرو پاور زد و pool خالی بود، پولش رو نخورید. یا ارور بدید یا پول رو پس بدید. (ما بلیزارد هستیم ولی دزد نیستیم... حداقل نه تو این پروژه).
- تایمر: وقتی تایمر ۰ شد، ۲ ثانیه وقت اضافه (Grace Period) بدید برای پکتهای دیررسیده، بعد به زور همه رو بفرستید کامبت.
میخوام که لاجیک بزنید و ازونجایی که نمیخواستم با مموری سگمنت فالت و … درگیر بشید:
- Language: Python
- Core Lib: asyncio
- Serialization: کتابخانه orjson (چون json استاندارد پایتون کنده)
- Infrastructure: Docker
نکته: یه Dockerfile بذارید تنگش که برنگردین بگین رو سیستم من کار میکرد و ….
پروژه وقتی تمومه که این سناریو پاس بشه:
- Run Server: سرور بالا بیاد و روی پورت 8888 گوش بده.
- Connect: چهار تا کلاینت وصل بشن و بازی استارت بخوره.
- Cheat Test: یکی سعی کنه بدون پول خرید کنه → سرور باید ارور بده و کرش نکنه.
- Disconnect Test: وسط بازی سیم یکی رو بکشید → بقیه بازی کنن → اون یکی برگرده و بازی رو ادامه بده.
- Data Integrity: آخر بازی مجموع کارتها درست باشه.
تنبلی نکنید :)
اگه سوالی بود... اول گوگل کنید، بعد لاگها رو بخونید، اگه نشد بیاید پیش من.



