لوحات صدارة عادلة دون كود خادم: جولات موثّقة وحزم sus وملفات تعريف اللاعبين
باختصار
أنشئ لوحات صدارة يتحقق منها الخادم دون كود خادم: تذاكر جولات أحادية الاستخدام، وحزم sus قابلة لإعادة التشغيل، وعملة يملكها الخادم، وملفات اللاعبين.
ينتهي الأسبوع الأول من أي لوحة صدارة جديدة عادةً بالطريقة نفسها: في مكان ما بين المراكز العشرة الأولى النزيهة وبقية اللوحة يقبع لاعب برصيد 2,147,483,647 نقطة، وهي أكبر قيمة يمكن أن يحملها عدد صحيح بإشارة بطول 32-bit. لم يلعب أحد تلك الجولة. بل كتبها محرر ذاكرة أو Proxy يعترض الطلبات، لأن عميل اللعبة في معظم لوحات الصدارة هو الشاهد الوحيد والقاضي في آن واحد.
يعالج هذا الإصدار نقطة الضعف هذه من جهتين. تنقل Validated Actions قرار ما يُحتسب إلى الخادم، بينما تحفظ حزم جولات sus الجديدة كل جولة مشبوهة كملف قضية كامل قابل لإعادة التشغيل. وفي الوقت نفسه تصبح لوحات الصدارة أكثر شخصية: كل إدخال يحمل الآن ملف تعريف للاعب يتضمن صورة رمزية وإطارًا وما يصل إلى ثلاث شارات، ويمكن فتح العناصر التجميلية عبر رموز الهدايا. ستجد أدناه كيف تعمل كل قطعة، والأرقام التي تقف خلفها، وكودًا عمليًا لـ Unity وGodot، والحدود التي نريدك أن تعرفها قبل أن تعتمد عليها.
لماذا لا يمكن الوثوق بالنتائج التي يُبلغ عنها العميل
إرسال النتيجة التقليدي طلب واحد: معرّف اللاعب، النتيجة، انتهى. كل ما يعرفه الخادم عن تلك الجولة يأتي من الجهاز الذي لديه أقوى دافع للكذب بشأنها. وتشترك الإجراءات المضادة المعتادة كلها في العيب نفسه:
- توقيع الطلب داخل العميل. يُشحن مفتاح التوقيع داخل build لعبتك. واستخراجه من ملف IL2CPP ثنائي أو من تصدير GDScript لا يستغرق أكثر من بضع ساعات، ومن تلك اللحظة تبدو الطلبات المزوّرة صالحة تمامًا.
- تعمية النتيجة في الذاكرة. يبطئ هذا محررات الذاكرة العادية، لكن Proxy يعيد كتابة جسم طلب HTTP لا يلمس تخطيط ذاكرتك أصلًا.
- فحوص المعقولية داخل العميل. كل ما يعمل على الجهاز يمكن تعطيله بالترقيع على الجهاز نفسه.
الإجابة المتينة هي أن يقرر الخادم ما يُحتسب. والنسخة النموذجية من ذلك هي المحاكاة المرجعية (authoritative): يعمل منطق لعبتك على عتاد تتحكم فيه، ولا يرسل العميل سوى المدخلات. وبالنسبة إلى لعبة أكشن سريعة فيها آلاف الجولات المتزامنة، يعني ذلك أسابيع من العمل على Netcode إضافة إلى فاتورة استضافة دائمة. معظم الفرق المستقلة لا تحتاج إلى المحاكاة الكاملة، بل تحتاج إلى ثلاث ضمانات أرخص: أن يعرف الخادم متى بدأت الجولة، وأي قواعد تنطبق عليها، وما الأدلة الموجودة بعدها. وهذه بالضبط هي الفجوة التي تسدّها Validated Actions.
كيف تعمل الجولة الموثّقة
للجولة الموثّقة أربع خطوات، ويملك الخادم الخطوتين الأهم:
- بدء الجولة. تستدعي اللعبة
StartRun. يصدر الخادم تذكرة موقّعة أحادية الاستخدام مرتبطة باللاعب ومفتاح API، واختياريًا بلوحة صدارة واحدة. تحمل التذكرة Seed يختاره الخادم (من 0 إلى 2,147,483,646) ومدة صلاحية: 2 ساعة افتراضيًا، قابلة للضبط من 60 ثانية حتى 6 ساعات. - اللعب. تهيّئ اللعبة مولّد العشوائية لديها باستخدام Seed الخادم، وتسجّل مدخلات اللاعب في سجل بايتات مضغوط.
- نهاية الجولة. تستدعي اللعبة
SubmitValidatedمع النتيجة، ومرحلة اختيارية، وقيم مكتسبة اختيارية، وسجل المدخلات. يرسل الـ SDK بصمة SHA-256 للسجل، فيبقى السجل نفسه على الجهاز في الوقت الحالي. - فحص الخادم. يتحقق الخادم من التذكرة، ويقيس المدة بنفسه (من إصدار التذكرة حتى الإرسال)، ويطبّق قواعدك. وعندها فقط يكتب أي شيء.
تُحتسب التذكرة مرة واحدة بالضبط، عبر جميع المناطق. والجولة المرفوضة تحرق تذكرتها أيضًا، فلا يستطيع أحد تحسّس عتباتك بإعادة المحاولة بالتذكرة نفسها ونتائج أقل قليلًا. ولأن الخادم يختار الـ Seed ويحدّ عدد التذاكر لكل لاعب في الساعة (60 افتراضيًا)، يصبح البحث عن Seed محظوظ بطيئًا ومكشوفًا.
var va = ValidatedActionsManager.Instance;
// Run start: single-use ticket plus server seed
ValidatedRun run = await va.StartRun("weekly");
if (run == null) { Debug.Log(va.LastErrorCode); return; } // e.g. RUN_RATE_LIMITED
var random = new System.Random(run.seed);
// ... play, record the inputs into inputLog (byte[]) ...
// Run end: the SDK hashes the log and sends the SHA-256 with the ticket
ValidatedSubmitResult result = await va.SubmitValidated(18250, inputLog);
if (result == null)
{
// e.g. DURATION_TOO_SHORT; HasActiveRun tells you if the ticket survived
Debug.Log($"{va.LastErrorCode}, run kept: {va.HasActiveRun}");
return;
}
Debug.Log($"Rank {result.rank}, {result.durationSeconds}s measured by the server, sus: {result.sus}");
التدفق نفسه متاح في Godot (Horizon.validatedActions.startRun وsubmitValidated) وفي Unreal (Horizon->ValidatedActions)، ولكل منهما مثال كامل في الـ SDK.
قواعد لا يعرفها إلا الخادم
يحصل كل مفتاح API على مجموعة قواعد واحدة، تُحرَّر في لوحة التحكم كنموذج أو بصيغة JSON. لا تظهر القواعد أبدًا في استجابات التطبيق أو رسائل الخطأ: كل ما يعرفه العميل هو رمز قابل للقراءة آليًا. إليك مجموعة قواعد واقعية للعبة أركيد قائمة على الموجات:
{
"formatVersion": 1,
"ticketLifetimeSeconds": 3600,
"maxRunsPerPlayerPerHour": 30,
"defaults": {
"maxScore": 250000,
"minDurationSeconds": 45,
"maxScorePerSecond": 900.0,
"stages": {
"wave_10": { "maxScore": 40000, "minDurationSeconds": 60 }
},
"soft": { "maxScore": 180000, "maxScorePerSecond": 600.0 }
},
"leaderboards": {
"speedrun": { "minScore": 95000, "minDurationSeconds": 95 }
},
"values": {
"gold": { "maxPerRun": 500, "minPerRun": -1000, "dailyCap": 5000 }
}
}
لنمرّر بعض عمليات الإرسال عبر هذه القواعد على لوحة weekly:
| الجولة المُرسلة | رد الخادم |
|---|---|
| 9,999,999 نقطة | 422 SCORE_ABOVE_MAX |
| 21,000 نقطة، بعد 3 ثوانٍ من التذكرة | 422 DURATION_TOO_SHORT |
| 150,000 نقطة في 120 ثانية (1,250 في الثانية) | 422 SCORE_RATE_TOO_HIGH |
| 190,000 نقطة في 240 ثانية | مقبولة، لكنها sus (أعلى من الحد الأقصى المرن البالغ 180,000) |
| التذكرة نفسها للمرة الثانية | 422 TICKET_CONSUMED |
يفحص الخادم بترتيب ثابت (المرحلة، النتيجة، قاعدة المرحلة، المدة، النتيجة في الثانية، القيم المكتسبة، ثم العتبات المرنة)، وأول إخفاق هو الذي يحسم. تنطبق قواعد النتيجة فقط على الجولات المرتبطة بلوحة؛ أما الجولة غير المرتبطة بلوحة فتُفحص قواعد المرحلة والمدة الخاصة بها رغم ذلك.
بمجرد أن تستقر القواعد، حوّل اللوحة إلى وضع "الإرسالات الموثّقة فقط". من تلك اللحظة يُرفض أي SubmitScore عادي إلى تلك اللوحة بالرمز 403 VALIDATED_SUBMIT_REQUIRED، بينما تواصل جميع اللوحات الأخرى قبول الإرسال العادي. وحتى مع مجموعة قواعد فارغة، تضمن اللوحة المقتصرة على الجولات الموثّقة تذاكر الخادم، والاستخدام لمرة واحدة، والارتباط باللاعب، والحدود الساعية، وبصمة سجل محفوظة.
العتبات المرنة وجولات sus
القواعد الصارمة ترفض. أما العتبات المرنة فتكتفي بوسم الجولة، ومن هنا ينبغي أن تبدأ: أسبوع من الحدود المرنة يريك كيف يبدو اللعب الحقيقي قبل أن تحوّل الأرقام إلى رفض صارم. تكون الجولة sus إذا قُبلت وتجاوزت عتبة مرنة واحدة على الأقل. الجولات المرفوضة والأخطاء التقنية ليست sus أبدًا. تحمل نتيجة الإرسال علامة sus بسيطة؛ أما العتبة التي انطلقت فتبقى معلومة لدى الخادم وحده.
الجزء المثير هو ما يحدث بعد ذلك. مع هذا التحديث يحتفظ الخادم بـ حزمة sus لكل جولة sus، بصرف النظر عن أفضل نتيجة للاعب، أو Top N، أو أي تغييرات لاحقة على النتيجة. والغشاش الذي ينشر جولة عبثية ثم جولة عادية لم يعد قادرًا على دفن الدليل تحت إدخال أفضل.
سياق البدء: ما الذي بدأت به الجولة
لا يكون Replay مفيدًا إلا إذا استطعت إعادة إنتاج ظروف البدء بدقة. لذلك يسجّل الخادم الآن عند StartRun سياق البدء: نسخة القواعد السارية، والبايتات الفعلية لـ Cloud Save الخاص باللاعب (مع SHA-256 ورقم المراجعة)، والقيم التي يملكها الخادم، والـ Seed، ووقت البدء. ويمكن للعبة أن تضيف روايتها الخاصة:
var context = new ValidatedRunContext(
gameVersion: Application.version, // e.g. "1.4.2"
contentVersion: "levels-2026-10",
simulationVersion: "sim-7",
replayFormatVersion: "input-v3",
contentDigest: ValidatedActionsManager.ComputeInputLogHash(levelBytes),
initialState: serializedLevelState); // bytes the simulation starts from
ValidatedRun run = await ValidatedActionsManager.Instance.StartRun("weekly", context);
تبقى قيم الخادم وقيم العميل منفصلة تمامًا، وكلاهما يغذّي نصًا معياريًا تُحفظ بصمة SHA-256 الخاصة به على التذكرة. كما يُفحص الإرسال مقابل نسخة القواعد عند البدء، لذا فإن تشديد حدٍّ ما أثناء جولة جارية لا يغيّر أبدًا الحكم على تلك الجولة. ولمنع إساءة استخدام تعديلات القواعد كأداة تحسّس، تقتصر التغييرات على 30 تغييرًا لكل مفتاح API في الساعة.
ما الذي تحتويه حزمة sus
تُحفظ كل حزمة تحت معرّف الجولة وتجمع كل ما يحتاجه المراجع: سياق البدء، والقواعد المرتبطة، والنتيجة، والقيم التي يملكها الخادم قبل الجولة وبعدها، وسجل المدخلات (يرفعه الـ SDK تلقائيًا، تمامًا كما في جولات Top N). في تبويب Review في لوحة التحكم تصفّي حسب sus وتصدّر الحزمة كملف ZIP:
<runId>.hzn-va-package.zip
├── manifest.json format horizon.validated-actions.package v1
├── start-context.txt canonical start context, hashed on the ticket
├── rules.json the rule version the run was checked against
├── cloud-save.bin the player's save at run start
├── initial-state.bin the game's initial simulation state
├── input-log.bin the recorded inputs
└── SHA256SUMS
عند كل تصدير يعيد الخادم حساب جميع المجاميع الاختبارية ويُبلغ عن النتيجة في manifest.integrity وفي ترويسة X-Package-Integrity (ok أو mismatch). وإذا تجاوز جزء واحد حد الحجم، يُحتفظ بمجموعه الاختباري فقط ويُوسم الجزء بـ OMITTED_SIZE_LIMIT، فتعرف دائمًا ما الذي ينقص ولماذا.
مرّر cloud-save.bin وinitial-state.bin والـ Seed وinput-log.bin إلى محاكاتك الحتمية الخاصة، وعندها إما أن تعيد إنتاج النتيجة أو لا. تبقى عمليات Replay والأحكام والعقوبات بين يديك؛ فالخادم يَسِم ويؤرشف، ولا يشغّل كود لعبتك أبدًا.
عملة لا يستطيع العميل الكتابة إليها
لوحات الصدارة ليست الشيء الوحيد الذي يغري بالغش. باستخدام القيم التي يملكها الخادم تعرّف عدّادات مثل gold أو gems في مجموعة القواعد نفسها. ولا يغيّرها إلا الجولات الموثّقة المقبولة: لا توجد نقطة نهاية (endpoint) في التطبيق ولا دالة في الـ SDK تضبط الرصيد. وتُقرأ القاعدة من JSON أعلاه هكذا:
maxPerRun: 500: الجولة التي تدّعي 800 قطعة ذهبية تُرفض بـEARNED_ABOVE_MAX.minPerRun: -1000: يمكن للجولة أن تنفق حتى 1,000 قطعة ذهبية؛ وإنفاق ما يتجاوز الرصيد يفشل بـINSUFFICIENT_BALANCE.dailyCap: 5000: الإضافات الموجبة في كل يوم UTC تُقتطع ولا تُرفض. فاللاعب الذي ربح اليوم 4,800 بالفعل وأنهى جولة بقيمة 500 قطعة ذهبية يُضاف إلى رصيده 200 فقط.
تعرض الاستجابة requested وcredited لكل مفتاح متأثر، فتستطيع اللعبة عرض "تم بلوغ الحد اليومي" بدلًا من خسارة العملات بصمت. وبالنسبة إلى المشتريات فالقاعدة بسيطة: امنح العنصر فقط عندما يُحتسب الإنفاق بالكامل (IsFullyCredited في Unity وUnreal). وهذا هو المنطق نفسه وراء نقل متاجر الترقيات بعيدًا عن القيم الافتراضية في جانب العميل: الجهاز يمكنه أن يطلب، والخادم وحده يمنح.
يستمر Cloud Save في العمل كما كان ويصبح مرآة. انسخ قيم الخادم إلى ملف الحفظ بعد كل جولة مقبولة لعرضها دون اتصال، واستبدل تلك النسخة بـ GetState عند بدء التشغيل، ولا ترسل أبدًا قيمة من ملف الحفظ على أنها رصيد.
ملفات تعريف اللاعبين في كل إدخال على لوحة الصدارة
يتناول النصف الثاني من هذا التحديث الأشخاص على اللوحة لا الأرقام. يحمل كل إدخال في لوحة الصدارة ضمن Top وAround وRank الآن ملف تعريف يتضمن avatarId وframeId وما يصل إلى ثلاث badges:
{ "position": 1, "username": "Gravedigger", "score": 15000,
"profile": { "avatarId": "avatar.zombie_07", "frameId": "frame.gold", "badges": ["badge.supporter"] } }
يقرأ الخادم ملف التعريف مع اسم العرض من ذاكرة التخزين المؤقت لأسماء المستخدمين، فلا تكلّف قائمة الصدارة أي استعلام إضافي لكل إدخال. وتظهر تغييرات ملف التعريف على كل خادم خلال 10 دقائق كحد أقصى.
كتالوج معرّفات، لا صور
يدير كل مشروع كتالوجًا للعناصر التجميلية في لوحة التحكم. لكل إدخال معرّف (مثل avatar.zombie_07) ونوع (avatar أو frame أو badge) وعلامة locked. يمكن لأي لاعب اختيار الإدخالات المجانية؛ أما المقفلة فلا يختارها إلا اللاعبون الذين فتحوها. يخزّن الخادم المعرّفات فقط ولا يخزّن صورًا أبدًا: تربط لعبتك كل معرّف بـ sprites الخاصة بها، فتبقى الرسوم داخل build لعبتك ولا يكلّفك إطار جديد سوى صف واحد في الكتالوج.
يبدو عرض صف من اللوحة في Godot هكذا:
var entries := await Horizon.leaderboard.getTop(10, true, "weekly")
for entry in entries:
var row := RowScene.instantiate()
row.set_rank(entry.position, entry.username, entry.score)
if entry.profile.hasAvatar():
row.set_avatar(AVATARS.get(entry.profile.avatarId, DEFAULT_AVATAR))
if entry.profile.hasFrame():
row.set_frame(FRAMES.get(entry.profile.frameId))
for badge_id in entry.profile.badges:
row.add_badge(BADGES.get(badge_id))
$Board.add_child(row)
ينبغي عرض المعرّفات غير المعروفة (مثلًا بعد حذف إدخال من الكتالوج) ببساطة على أنها "غير محددة": يحتفظ اللاعبون بالمعرّف القديم حتى يغيّروا ملفات تعريفهم، ولا ينكسر شيء.
الفتح عبر رموز الهدايا
لا يكتب عمليات الفتح إلا الخادم، وذلك اليوم عبر رموز الهدايا التي تتضمن grants أو يدويًا في لوحة التحكم. يمكن للاعب أن يملك حتى 25 عنصرًا مفتوحًا. واسترداد رمز يتضمن grants يُعيد المعرّفات الممنوحة ويُسقط ملف التعريف المخزّن مؤقتًا، فيُظهر استدعاء GetProfile التالي العنصر الجديد على أنه متاح:
var result := await Horizon.giftCodes.redeem("SPOOKY-FRAME-2026")
if not result.is_empty() and not result["grantedUnlocks"].is_empty():
await Horizon.playerProfile.getProfile() # reloads catalog and unlocks
if Horizon.playerProfile.isAvailable("frame.spooky"):
await Horizon.playerProfile.setProfile("avatar.zombie_07", "frame.spooky", ["badge.supporter"])
اختيار عنصر مقفل دون فتحه يفشل بـ 403 COSMETIC_LOCKED، واختيار أكثر من ثلاث شارات يفشل بـ 400 INVALID_BADGES. وبهذا تتحول مكافآت الفعاليات، وهدايا Discord، وامتيازات الداعمين إلى رمز هدية واحد لكل منها، دون نقطة نهاية مخصصة للفتح. كما اختفى أثر متبقٍّ من الـ API القديم إلى الأبد: المعامل metadata في إرسال النتائج لم يُخزَّن قط وأصبح الآن مُهمَلًا (deprecated). المعلومات الخاصة بكل لاعب مكانها ملف التعريف.
حدود النتائج تُحتسب الآن لكل مفتاح API
تغيير أصغر لكن بأثر حقيقي على الاستوديوهات التي تدير عدة ألعاب أو بيئات في حساب واحد: أصبح حد النتائج يُطبَّق الآن لكل مفتاح API. يمكن لكل مفتاح أن يحمل من صفوف النتائج ما تسمح به الباقة (الصف الواحد هو لاعب واحد على لوحة واحدة)، محسوبة على جميع لوحات ذلك المفتاح، وكل مفتاح إضافي يحصل على حصته الكاملة. يمكن دائمًا تحسين الصفوف الموجودة؛ ولا يمكن أن يصطدم بـ 403 عند امتلاء المفتاح إلا أول نتيجة للاعب على لوحة ما.
أفضل الممارسات للوحات صدارة عادلة
- ابدأ بالمرونة ثم شدّد. شغّل أسبوعًا بعتبات
softفقط، وراجع جولات sus في تبويب Review، ثم حوّل الأرقام إلى قواعد صارمة بهامش أمان يتراوح بين 20 و30 بالمئة فوق أفضل جولة مشروعة. - استدعِ
StartRunعندما تبدأ الجولة فعلًا. تبدأ الساعة مع التذكرة. والبدء من القائمة أو قبل شاشة تحميل مدتها 40 ثانية يجعلminDurationSecondsبلا معنى. - اجعل سجلات المدخلات صغيرة وحتمية. سجّل إطارات مدخلات ثابتة باستخدام ترميز الفروق (delta encoding). الحد الأقصى للأدلة هو 32 KB لكل سجل، وجولة مدتها 10 دقائق بمعدل 30 إطار مدخلات في الثانية و1 بايت لكل إطار تتسع بسهولة.
- ضع رقم نسخة لكل شيء في سياق البدء. دون
simulationVersionوcontentDigestقد يُعاد تشغيل حزمة من الشهر الماضي على توازن هذا الشهر فتفشل للسبب الخطأ. - تعامل مع ملف الحفظ كمرآة، لا كمصدر أبدًا. تتدفق الأرصدة من الخادم إلى Cloud Save، ولا تعود منه أبدًا.
ما الذي لا تفعله
نفضّل أن نقول هذا بوضوح. العميل المعدَّل الذي يُبلغ عن قيم معقولة ضمن قواعدك سيظل مقبولًا: تحدّ Validated Actions من مقدار ما يمكن أن يكسبه الغشاش وتجعل الجولات المشبوهة قابلة للمراجعة، لكنها لا تجعل الغش مستحيلًا. يتحقق الخادم من الحدود والتوقيت والاستخدام لمرة واحدة ويؤرشف الأدلة، لكنه لا يشغّل كود لعبتك ولا يعيد تشغيله، ولا يوجد حتى الآن حكم تلقائي على الأدلة. تعمل Validated Actions في السحابة فقط؛ وعلى simpleServer المستضاف ذاتيًا تُبلغ الـ SDKs عن NOT_SUPPORTED. تحتاج كل جولة إلى لاعب مسجّل الدخول، وتحتاج الجولات على لوحة صدارة أيضًا إلى اسم عرض.
السعة حسب الباقة
كل ميزة في هذا المقال متاحة في جميع الخطط، بما فيها FREE. السعة وحدها هي ما يزداد:
| FREE | BASIC | PRO | ENTERPRISE | |
|---|---|---|---|---|
| الجولات الموثّقة لكل حساب وساعة UTC | 300 | 3,000 | 20,000 | 200,000 |
| القيم التي يملكها الخادم لكل مفتاح API | 8 | 16 | 32 | 64 |
| خانات الأدلة لجولات Top N | 50 | 500 | 2,500 | 25,000 |
| حزم sus المحفوظة | 10 | 100 | 1,000 | 10,000 |
| مدة الاحتفاظ بحزم sus | 14 يومًا | 30 يومًا | 90 يومًا | 180 يومًا |
| كتالوج العناصر التجميلية لكل مفتاح API | 50 | 200 | 500 | 1,000 |
| صفوف النتائج لكل مفتاح API | 5,000 | 25,000 | 200,000 | 2,500,000 |
عندما تمتلئ حصة حزم sus، تظل جولات sus الجديدة مقبولة ويُبلَّغ عنها على أنها sus، غاية الأمر أنها لا تُؤرشف. ولا تُزاح الحزم المحفوظة أبدًا لصالح حزم جديدة.
ابدأ الآن
حدّث إلى أحدث SDK لـ Unity أو Godot أو Unreal، واختر لوحة واحدة، وأضف مجموعة قواعد تحتوي على عتبات مرنة فقط. أضف ValidatedRunContext إلى استدعاء StartRun، ثم املأ كتالوج العناصر التجميلية بالصور الرمزية والإطارات الموجودة أصلًا في لعبتك. تلخّص صفحة ميزة Validated Actions وصفحة ميزة لوحة الصدارة القواعد والحدود، ويرشدك دليل البدء السريع عبر المحركات الثلاثة. هل أنت مستعد لجعل لوحة صدارتك القادمة عادلة وشخصية في آن واحد؟ جرّب horizOn مجانًا أو تعمّق في توثيق الـ API.