หัวข้อก่อนหน้าของบทนี้พูดถึงเกมที่เราส่งมอบให้ผู้เล่นในวันแรกที่เปิดตัว หัวข้อนี้พูดถึงสิ่งที่เกิดขึ้นหลังจากนั้น เกมแบบ live game จะเปลี่ยนแปลงต่อเนื่องหลังเปิดตัว สัปดาห์แล้วสัปดาห์เล่า เป็นเวลาหลายปี โดยผู้เล่นไม่ต้องติดตั้งเกมใหม่เลยสักครั้ง เกม Genshin Impact และ Honkai: Star Rail ของ HoYoverse ทำงานตามโมเดลนี้เป๊ะ ๆ คือมีเนื้อเรื่องบทใหม่ ตัวละครเล่นได้ตัวใหม่ และ event จำกัดเวลาใหม่ ๆ ตามตารางที่แน่นอน โดยรายได้ส่วนใหญ่มาจากระบบ gacha ที่ขายการสุ่มตัวละครและอาวุธ หัวข้อนี้จะอธิบายว่าบริการต่อเนื่องแบบนี้ถูกสร้างขึ้นมาอย่างไรจริง ๆ ตั้งแต่คอนเทนต์ที่ส่งผ่าน server config แทนที่จะเขียนโค้ดใหม่ การสุ่ม gacha ที่ต้องรันบนเซิร์ฟเวอร์เท่านั้น ห้ามรันบนเครื่องผู้เล่นเด็ดขาด ระบบ pity ที่กำหนดเพดานสูงสุดของโชคร้าย เศรษฐกิจสกุลเงินที่รักษาราคาให้คงที่ได้นานหลายปี และกฎระเบียบที่สตูดิโอต้องทำตามตลอดกระบวนการทั้งหมดนี้
Live-ops (ย่อมาจาก live operations) หมายถึงการดูแลเกมในฐานะบริการที่ดำเนินต่อเนื่องไปเรื่อย ๆ แทนที่จะเป็นสินค้าที่ขายครั้งเดียวจบ เกมกล่องสมัยก่อนที่ขายเป็นแผ่นดิสก์จะ "จบงาน" ตั้งแต่วันที่วางขาย อาจมี patch ตามมาอีกหนึ่งหรือสองครั้งแล้วก็หยุด เกม live-ops อย่าง Genshin Impact หรือ Honkai: Star Rail ไม่มีวัน "จบ" ในความหมายนั้น เวอร์ชันที่ติดตั้งอยู่ในมือถือผู้เล่นวันนี้ ในอีกหกเดือนข้างหน้าจะต้องหน้าตาต่างไปจากเดิม มีตัวละครใหม่ เนื้อเรื่องบทใหม่ event จำกัดเวลาใหม่ ๆ การปรับบาลานซ์ใหม่ ๆ ทยอยมาตามตารางที่แน่นอน ต่อเนื่องกันหลายปี โดยที่ผู้เล่นไม่ต้องซื้อเกมใหม่อีกเลย
สิ่งนี้เปลี่ยนว่าใครทำงานกับเกมและทำนานแค่ไหน ทีมของเกมกล่องส่วนใหญ่จะยุบตัวหลังจากส่งมอบงานเสร็จ แต่เกม live-ops จะคงทีมงานทั้งชุดไว้ทำงานต่อแบบไม่มีกำหนดสิ้นสุด live-ops producer วางแผนว่าจะปล่อยอะไรและเมื่อไหร่ ดีไซเนอร์สร้างคอนเทนต์ใหม่ วิศวกรฝั่งเซิร์ฟเวอร์ดูแลระบบหลังบ้านให้ทำงานต่อเนื่อง และนักวิเคราะห์ข้อมูลคอยดูว่าผู้เล่นตอบสนองต่อการเปลี่ยนแปลงแต่ละอย่างอย่างไร ตัวเกมเองคือสินค้า แต่ตารางอัปเดตที่ดำเนินต่อเนื่องต่างหากที่ทำให้ผู้เล่นกลับมาเล่นซ้ำ และทำให้เกมยังคงมีรายได้เข้ามาต่อเนื่องไปอีกนานหลังเปิดตัว
ทีม live-ops ไม่ได้ปล่อยคอนเทนต์ตามใจเมื่อไหร่ที่ทำเสร็จพอดี แต่ปล่อยตาม cadence (จังหวะการปล่อยที่แน่นอนและวนซ้ำเป็นรอบ) ที่คงที่และคาดเดาได้ เพราะผู้เล่นวางแผนเวลาเล่นของตัวเองตามจังหวะนี้ และรายได้ของเกมก็ขึ้นอยู่กับว่าจังหวะนี้จะไม่มีวันขาดตอน มีคำสี่คำที่อธิบายชิ้นส่วนของตารางนี้ได้:
ทั้งสี่ชิ้นนี้จะซ้อนทับกันและวนซ้ำเป็นรอบ ตัวอย่างช่วงหกสัปดาห์ทั่วไปอาจหน้าตาแบบนี้:
ไม่มีอะไรในนี้ที่สุ่มเอาเลย ทุกกล่องในไดอะแกรมนี้ถูกทีม live-ops วางตารางล่วงหน้าเป็นสัปดาห์หรือเป็นเดือน บ่อยครั้งวางแผนล่วงหน้าไปทั้ง season หรือสอง season เพื่อให้ทีมอาร์ต ทีมเขียนบท ทีมแปล และทีม QA มีเวลาทำงานเสร็จก่อนถึงวันที่กำหนดไว้ในปฏิทิน ความรู้สึกของผู้เล่นที่ว่า "มีอะไรใหม่ให้ทำตลอดเวลา" คือผลโดยตรงจากการวางแผนแบบนี้ ไม่ใช่เรื่องบังเอิญ
การปล่อย patch ใหม่ให้ client เป็นกระบวนการที่ช้า ต้องคอมไพล์ build ต้องให้ QA ทดสอบ และบนมือถือต้องผ่านการรีวิวจาก app store ของ Apple หรือ Google ซึ่งอาจใช้เวลาตั้งแต่ไม่กี่ชั่วโมงไปจนถึงหลายวัน แถมยังมีโอกาสถูกปฏิเสธได้เฉย ๆ ด้วย ถ้าการหมุน banner ทุกสองสัปดาห์และ event ทุกสัปดาห์ต้องส่งเรื่องเข้า app store ใหม่ทั้งหมดทุกครั้ง ทีม live-ops จะไม่มีทางตามจังหวะ cadence จากหัวข้อ 2 ได้ทัน
ทางแก้คือ data-driven content คือออกแบบเกมให้ดีไซเนอร์สามารถเปิด event ใหม่ ปรับตัวละครที่ชูใน banner หรือเปลี่ยน drop rate ได้ด้วยการแก้ data (config ธรรมดา ๆ เป็นตัวเลข ข้อความ หรือ JSON) ที่ client ดาวน์โหลดตอนรันไทม์ แทนที่จะต้องเขียน code ใหม่ (โลจิกที่คอมไพล์แล้วต้องส่งไปพร้อมกับตัวแอปและผ่านการรีวิว) client จะถูกปล่อยออกไปครั้งเดียวพร้อมระบบทั่วไปที่ใช้ซ้ำได้ อย่างเช่น "ระบบ banner" "ระบบ event" "ระบบ battle pass" แล้วระบบเหล่านี้ก็อ่าน config อะไรก็ตามที่เซิร์ฟเวอร์ส่งมาให้ ณ ขณะนั้น
// A general-purpose event system, shipped once inside the client.
// It never mentions a specific event by name -- it just reads
// whatever EventConfig the server currently sends it.
[Serializable]
public class EventConfig
{
public string EventId;
public string DisplayName;
public long StartUnixTime;
public long EndUnixTime;
public string BannerImageUrl;
public List<string> FeaturedItemIds;
}
public class EventRunner
{
private List<EventConfig> activeEvents = new List<EventConfig>();
public void LoadEvents(List<EventConfig> configsFromServer, long nowUnixTime)
{
activeEvents.Clear();
foreach (EventConfig cfg in configsFromServer)
{
if (nowUnixTime >= cfg.StartUnixTime && nowUnixTime < cfg.EndUnixTime)
{
activeEvents.Add(cfg);
}
}
}
public void PrintActiveEvents()
{
foreach (EventConfig e in activeEvents)
{
Console.WriteLine($"Active: {e.DisplayName} (id={e.EventId})");
}
}
}
ไล่ trace ทีละขั้น: สมมติเซิร์ฟเวอร์ส่ง event มาสองอัน คือ "Lantern Festival" ที่รันตั้งแต่เวลา 1000 ถึง 2000 กับ "Winter Trials" ที่รันตั้งแต่ 2500 ถึง 3500 client เรียก LoadEvents โดยตั้งเวลาปัจจุบันเป็น 1500:
var configs = new List<EventConfig> {
new EventConfig { EventId="lantern", DisplayName="Lantern Festival",
StartUnixTime=1000, EndUnixTime=2000, FeaturedItemIds=new List<string>() },
new EventConfig { EventId="winter", DisplayName="Winter Trials",
StartUnixTime=2500, EndUnixTime=3500, FeaturedItemIds=new List<string>() },
};
var runner = new EventRunner();
runner.LoadEvents(configs, nowUnixTime: 1500);
runner.PrintActiveEvents();
ผลลัพธ์ที่คาดหวัง:
Active: Lantern Festival (id=lantern)
มีแค่ Lantern Festival ที่ active ตอนเวลา 1500 เพราะ 1500 อยู่ระหว่างเวลาเริ่มกับเวลาจบของมัน แต่ยังมาไม่ถึงเวลาที่ Winter Trials จะเริ่ม ไม่มีใครต้องเขียน if (currentEvent == "Lantern Festival") ไว้ที่ไหนใน EventRunner เลย คลาสเดียวกันนี้จะรัน event ทุกอันที่ทีมดีไซน์คิดขึ้นในอนาคตได้ตลอดไป ตราบใดที่อธิบายมันออกมาเป็น EventConfig ได้ การปิด Lantern Festival ในสัปดาห์หน้าแล้วเปิด event อื่นที่ต่างไปโดยสิ้นเชิง ก็แค่เปลี่ยน data บนเซิร์ฟเวอร์ มีผลใช้งานจริงภายในไม่กี่นาที โดยไม่ต้องเขียนโค้ด client ใหม่เลยสักบรรทัด และไม่ต้องผ่านการรีวิวจาก app store เลย
Remote config คือชื่อเรียกรวม ๆ ของการตั้งค่าใด ๆ ที่ client ดึงมาจากเซิร์ฟเวอร์ตอนรันไทม์ แทนที่จะฝังไว้ตายตัวใน build ลิสต์ EventConfig จากหัวข้อ 3 ก็เป็นตัวอย่างหนึ่ง แต่แนวคิดเดียวกันนี้ครอบคลุมถึง drop rate ของ gacha ราคาในร้านค้า และการปรับความยากด้วย Feature flag (สวิตช์เปิด/ปิดที่มีชื่อเรียก ควบคุมจากเซิร์ฟเวอร์ ใช้ตัดสินว่าโค้ดที่ปล่อยไปแล้วชิ้นหนึ่งจะทำงานหรือไม่) คืออีกครึ่งหนึ่งของแนวคิดเดียวกันนี้ โค้ดของฟีเจอร์หนึ่งสามารถถูกส่งไปพร้อมกับ client ได้ล่วงหน้าหลายสัปดาห์ก่อนที่จะถึงเวลาให้เห็น โดยนอนรออยู่เฉย ๆ จนกว่าเซิร์ฟเวอร์จะเปิด flag ของมัน
public class FeatureFlags
{
private Dictionary<string, bool> flags;
public FeatureFlags(Dictionary<string, bool> flagsFromServer)
{
flags = flagsFromServer;
}
public bool IsEnabled(string flagName)
{
return flags.TryGetValue(flagName, out bool value) && value;
}
}
// Later, anywhere in the client:
var flags = new FeatureFlags(new Dictionary<string, bool> {
{ "winter_event_2026", false },
{ "new_banner_ui", true },
});
if (flags.IsEnabled("winter_event_2026"))
{
Console.WriteLine("Showing Winter Event button");
}
else
{
Console.WriteLine("Winter Event button hidden");
}
ผลลัพธ์ที่คาดหวัง:
Winter Event button hidden
โค้ดและ asset ของ winter event สามารถนอนอยู่ในแอปที่ติดตั้งไว้แล้วตอนนี้ได้เลย เพียงแต่ flag ยังปิดอยู่ พอถึงวันเปิดตัว ทีม live-ops ก็แค่เปลี่ยน winter_event_2026 เป็น true บนเซิร์ฟเวอร์ แล้ว client ทุกเครื่องที่เชื่อมต่ออยู่ก็จะเริ่มแสดงมันภายในไม่กี่นาที โดยไม่ต้องดาวน์โหลดอะไรใหม่ ไม่ต้องผ่านการรีวิวจาก app store เพราะตัว client เองไม่ได้เปลี่ยนอะไรเลย feature flag ยังทำให้สตูดิโอทำ staged rollout ได้ด้วย คือเปิด flag ให้ผู้เล่นแค่ 5% ก่อน คอยดูว่ามี crash หรือคำร้องเรียนไหม แล้วค่อยขยับขึ้นไปถึง 100% เมื่อดูปลอดภัยดีแล้ว และยังทำหน้าที่เป็น kill switch ได้ถ้ามีอะไรผิดพลาดขึ้นมา แค่เปลี่ยน flag ตัวเดิมกลับเป็น false ฟีเจอร์ที่พังก็จะหายไปทันที โดยไม่ต้องออก patch ฉุกเฉิน
เมื่อรวมหัวข้อ 3 กับ 4 เข้าด้วยกัน จะได้ live-ops content pipeline ฉบับเต็ม คือเส้นทางที่ event หรือ banner ใหม่เดินทางจากแผนของดีไซเนอร์ไปจนถึงหน้าจอผู้เล่น:
มีสองลูปที่สำคัญตรงนี้ ลูปบนคือวิธีที่คอนเทนต์ถูกส่งออกไป ดีไซเนอร์เขียน data ไม่ใช่ code แล้วมันก็ไปถึง client ผ่าน config service และ CDN (content delivery network คือเซิร์ฟเวอร์ที่กระจายอยู่ทั่วโลกเพื่อส่งไฟล์ให้ผู้เล่นที่อยู่ใกล้ ๆ ได้เร็วขึ้น) ลูปล่างคือวิธีที่ทีมรู้ว่าคอนเทนต์นั้นได้ผลหรือไม่ ทุกการกระทำของผู้เล่นจะถูกบันทึกเป็น telemetry แล้วไหลย้อนกลับไปหาดีไซเนอร์คนเดิมที่วางแผน event นั้น ปิดลูปให้ครบวงจร หัวข้อ 11 จะพูดถึง feedback loop นี้แบบละเอียด
ระบบ gacha (ตั้งชื่อตามเครื่องหยอดเหรียญกาชาปองของญี่ปุ่น) เป็นกลไกที่ผู้เล่นเสียเงินในเกมเพื่อรับไอเทมสุ่มหนึ่งชิ้นจาก pool ที่กำหนดไว้ โดยมีการเปิดเผยโอกาสได้รับต่อ rarity tier (หมวดหมู่อย่าง 3-star, 4-star, 5-star ที่จัดกลุ่มไอเทมตามความหายากและความแรง) นี่คือกลไกทำเงินหลักเบื้องหลังเกมอย่าง Genshin Impact และ Honkai: Star Rail ผู้เล่น pull เพื่อลุ้นได้ตัวละครหรืออาวุธเฉพาะตัว แทนที่จะซื้อโดยตรง
กฎทางวิศวกรรมที่สำคัญที่สุดสำหรับระบบ gacha คือ การสุ่มต้องเกิดขึ้นบนเซิร์ฟเวอร์เท่านั้น ห้ามรันบนเครื่องผู้เล่นเด็ดขาด ลองคิดดูว่าถ้าสุ่มฝั่ง client จะเกิดอะไรขึ้น:
client ของเกมแบบนี้ปกติจะรันอยู่บนอุปกรณ์ที่สตูดิโอควบคุมไม่ได้เต็มร้อย นั่นคือมือถือหรือ PC ของผู้เล่นเอง ซึ่งผู้เล่นสามารถส่องดูได้ด้วย debugger, memory editor หรือ network client ที่ถูกแก้ไข การตัดสินใจใด ๆ ที่ทำอยู่บน client ล้วน ๆ (การสุ่มผลลัพธ์ ไอเทมได้รับหรือไม่ ใช้เงินไปเท่าไหร่) สามารถถูกแก้ไขแทรกแซงได้ด้วยเครื่องมือแบบนั้นเป๊ะ ๆ นี่คือบทเรียนเดียวกันกับที่บทเครือข่ายก่อนหน้านี้เรียกว่าดีไซน์แบบ server-authoritative (เซิร์ฟเวอร์ถือสำเนาที่แท้จริงของสถานะเกมเพียงชุดเดียว client ทำได้แค่ขอให้เปลี่ยนแปลง ไม่มีสิทธิ์เปลี่ยนแปลงเองโดยตรง) ระบบ gacha เป็นหนึ่งในจุดที่เดิมพันสูงที่สุดในการเอากฎนี้ไปใช้ เพราะมีเงินจริงเข้ามาเกี่ยวข้อง
pool ของ gacha ไม่ใช่การโยนเหรียญง่าย ๆ ที่ทุกไอเทมมีโอกาสเท่ากัน แต่แต่ละไอเทม หรือที่พบบ่อยกว่าคือแต่ละ rarity tier จะมี weight เป็นของตัวเอง (ตัวเลขที่เป็นสัดส่วนกับโอกาสที่ไอเทมนั้นจะถูกสุ่มได้ weight ไม่จำเป็นต้องรวมกันได้ 100 สนใจแค่อัตราส่วนระหว่างกัน) และสตูดิโอต่าง ๆ ก็ถูกเรียกร้องมากขึ้นเรื่อย ๆ ให้เปิดเผย drop rate ที่คำนวณได้ (ความน่าจะเป็นจริง ๆ ปกติแสดงเป็นเปอร์เซ็นต์ ของการได้รับไอเทมจาก tier หนึ่งใน 1 pull) ไว้ในเกมโดยตรง หัวข้อ 12 จะอธิบายว่าทำไมการเปิดเผยนี้ถึงไม่ใช่แค่มารยาทเฉย ๆ
วิธีมาตรฐานที่ใช้แปลงลิสต์ของ weight ให้กลายเป็นการสุ่มเลือกหนึ่งชิ้น คือวิธี cumulative weight เอา weight ของแต่ละไอเทมมาเรียงต่อกันไปตามเส้นจำนวน สุ่มจุดหนึ่งบนเส้นนั้น แล้วดูว่าจุดนั้นตกอยู่ในช่วงของไอเทมไหน
public class GachaItem
{
public string Id;
public string Rarity; // "3-star", "4-star", "5-star"
public int Weight; // relative weight, not a percentage
}
public class GachaPool
{
public List<GachaItem> Items = new List<GachaItem>();
public int TotalWeight()
{
int sum = 0;
foreach (GachaItem item in Items) sum += item.Weight;
return sum;
}
// roll is an integer already chosen in [0, TotalWeight()) by the caller --
// kept separate from the random number generator itself so this method
// can be tested with an exact, known input (see the worked trace below).
public GachaItem PickByRoll(int roll)
{
int cursor = 0;
foreach (GachaItem item in Items)
{
cursor += item.Weight;
if (roll < cursor) return item;
}
throw new InvalidOperationException("roll was outside total weight");
}
}
ไล่ trace ทีละขั้น: pool เล็ก ๆ ที่มีสามไอเทม weight เป็น 940, 51, 9 (รวม weight ทั้งหมด 1000) ป้อนค่า roll ที่กำหนดไว้แน่นอนสี่ค่าแทนเลขสุ่มจริง เพื่อให้ตรวจผลลัพธ์ทุกอันด้วยมือได้:
var pool = new GachaPool();
pool.Items.Add(new GachaItem { Id="common_sword", Rarity="3-star", Weight=940 });
pool.Items.Add(new GachaItem { Id="rare_bow", Rarity="4-star", Weight=51 });
pool.Items.Add(new GachaItem { Id="legendary_gem", Rarity="5-star", Weight=9 });
int[] scriptedRolls = { 100, 950, 991, 945 };
foreach (int roll in scriptedRolls)
{
GachaItem result = pool.PickByRoll(roll);
Console.WriteLine($"roll={roll} -> {result.Id} ({result.Rarity})");
}
ผลลัพธ์ที่คาดหวัง:
roll=100 -> common_sword (3-star)
roll=950 -> rare_bow (4-star)
roll=991 -> legendary_gem (5-star)
roll=945 -> rare_bow (4-star)
ลองไล่ด้วยมือดูว่าทำไม: ไอเทมทั้งสามชิ้นครอบครองช่วง [0, 940) สำหรับ 3-star, [940, 991) สำหรับ 4-star, และ [991, 1000) สำหรับ 5-star เพราะช่วงของแต่ละไอเทมจะเริ่มตรงจุดที่ cursor ของไอเทมก่อนหน้าหยุดไว้ roll ที่ได้ 100 จะตกอยู่กลาง ๆ ช่วงแรกสบาย ๆ roll ที่ได้ 950 เกิน 940 มาแล้วแต่ยังไม่ถึง 991 จึงตกอยู่ในช่วงของ 4-star roll ที่ได้ 991 ตกอยู่พอดีบนขอบเขต ซึ่งเงื่อนไข roll < cursor จะจัดให้อยู่ในช่วงของ 5-star (991 ไม่น้อยกว่า 991 ซึ่งเป็น cursor ของ 4-star แต่มันน้อยกว่า 1000 ซึ่งเป็น cursor ของ 5-star) เซิร์ฟเวอร์จริงจะสุ่มเลขจำนวนเต็มนี้ด้วย random number generator ที่เหมาะสม โดย seed มาจากแหล่งที่ปลอดภัย กระจายสม่ำเสมอในช่วง [0, TotalWeight()) แต่ตัวโลจิกการเลือกเองก็คือการเดินแบบ cumulative-weight นี้เป๊ะ ๆ ทุกครั้ง
การแปลง weight เหล่านี้ให้เป็น drop rate ที่สตูดิโอเอาไปเปิดเผยจริง ๆ ก็แค่หารเลขครั้งเดียว 9 จาก weight รวม 1000 คือ base rate 0.9% สำหรับ tier 5-star ใน 1 pull ตัวเลขตัวเดียวนี้ — 0.9% — คือสิ่งที่กฎระเบียบในหัวข้อ 12 บังคับให้บางสตูดิโอต้องพิมพ์ไว้บนหน้าจอ pull โดยตรง
การสุ่มแบบ weighted ล้วน ๆ ที่อัตรา 0.9% มีจุดที่แย่อยู่ในตัวเอง คือในทางคณิตศาสตร์เป็นไปได้ (แม้จะไม่น่าจะเกิด) ที่จะ pull ติดกันสองร้อยครั้งโดยไม่เจอ 5-star เลยสักครั้ง pity counter (ตัวนับจำนวน pull สะสมตั้งแต่ได้ไอเทมหายากครั้งล่าสุด ใช้ดันโอกาสให้สูงขึ้น หรือสุดท้ายก็การันตีผลลัพธ์เมื่อสตรีคนั้นยาวขึ้นเรื่อย ๆ) มีไว้เพื่อกำหนดเพดานสูงสุดของโชคร้ายแบบนี้โดยเฉพาะ ปกติจะมีสองเกณฑ์ทำงานร่วมกัน:
เขียนเป็นโค้ด C# ต้นไม้การตัดสินใจแบบเดียวกันนี้จะหน้าตาแบบนี้:
public class PityState
{
public int PullsSinceLastFiveStar = 0;
}
public class PityConfig
{
public double BaseFiveStarRate = 0.009; // 0.9%, matches Section 6's pool
public int SoftPityStart = 74; // rate starts ramping on this pull
public int HardPityAt = 90; // this pull is always a 5-star
public double SoftPityStep = 0.06; // rate added per pull inside soft pity
}
public class PityGacha
{
// nextRoll returns a value in [0.0, 1.0); kept as a function parameter
// so the worked trace below can feed it exact, known numbers.
public static string RollOnePull(PityState pity, PityConfig cfg, Func<double> nextRoll)
{
int thisPullNumber = pity.PullsSinceLastFiveStar + 1;
double rate = cfg.BaseFiveStarRate;
if (thisPullNumber >= cfg.HardPityAt)
{
rate = 1.0;
}
else if (thisPullNumber >= cfg.SoftPityStart)
{
int stepsIntoSoftPity = thisPullNumber - cfg.SoftPityStart;
rate = cfg.BaseFiveStarRate + stepsIntoSoftPity * cfg.SoftPityStep;
if (rate > 1.0) rate = 1.0;
}
double roll = nextRoll();
bool gotFiveStar = roll < rate;
if (gotFiveStar)
{
pity.PullsSinceLastFiveStar = 0;
return $"5-star! (pull #{thisPullNumber}, rate was {rate:P1})";
}
else
{
pity.PullsSinceLastFiveStar = thisPullNumber;
return $"no 5-star (pull #{thisPullNumber}, rate was {rate:P1})";
}
}
}
ไล่ trace ทีละขั้น: ผู้เล่นคนหนึ่ง pity counter เดินมาแล้ว 88 pull (โชคร้ายมาตลอด) เราป้อนค่า roll ที่กำหนดไว้สามค่าเพื่อดูว่า pull สามครั้งถัดไปจะเป็นยังไง:
var pity = new PityState { PullsSinceLastFiveStar = 88 };
var cfg = new PityConfig();
double[] scriptedRolls = { 0.95, 0.95, 0.5 };
int i = 0;
Func<double> nextRoll = () => scriptedRolls[i++];
for (int n = 0; n < 3; n++)
{
Console.WriteLine(PityGacha.RollOnePull(pity, cfg, nextRoll));
}
ผลลัพธ์ที่คาดหวัง:
no 5-star (pull #89, rate was 90.9%)
5-star! (pull #90, rate was 100.0%)
no 5-star (pull #1, rate was 0.9%)
pull ที่ 89 อยู่ในช่วง soft pity แล้ว (ผ่าน pull 74 มา 15 ก้าว ทำให้ rate ไต่ขึ้นไปแล้วเป็น 0.9% + 15 * 6% = 90.9%) แต่ roll ที่ได้ 0.95 ก็ยังไม่ต่ำกว่า rate ที่สูงขนาดนั้นอยู่ดี เลยพลาด นี่พิสูจน์ว่า soft pity แค่ดันโอกาสให้สูงขึ้น แต่ไม่ได้การันตีอะไรด้วยตัวมันเอง pull ที่ 90 ชน hard pity พอดี ซึ่งโค้ดจะบังคับ rate = 1.0 ทำให้ roll สูงแบบเดิม (0.95) กลายเป็นชนะแทน เพราะ 0.95 < 1.0 เป็นจริงเสมอไม่ว่า roll จะได้อะไร counter จะรีเซ็ตกลับเป็น 0 แล้ว pull ที่สามก็เริ่มนับใหม่ที่ base rate ปกติ 0.9% ซึ่ง roll ที่ได้ 0.5 ก็พลาดง่าย ๆ อีกครั้ง
PullsSinceLastFiveStar ไว้บนเซิร์ฟเวอร์ ผูกไว้กับ account ของผู้เล่น เหมือนกับสกุลเงิน (หัวข้อ 9) ห้ามเก็บไว้ในค่าที่ client ส่งขึ้นมาพร้อมกับ request แต่ละครั้งเด็ดขาด ถ้า client รายงาน pity count ของตัวเองได้ client ที่ถูกแก้ไขก็แค่อ้างว่าตัวเองอยู่ที่ pull 90 ตลอดเวลาได้เลยหัวข้อ 6 และ 7 ตอบคำถามคนละข้อกัน หัวข้อ 6 ตัดสินว่า ไอเทมชิ้นไหนเจาะจง ที่จะออกมาจาก pool เมื่อรู้ rarity tier แล้ว ส่วนหัวข้อ 7 ตัดสินว่า pull นี้จะชน tier หายากหรือไม่ การสุ่ม gacha ฝั่งเซิร์ฟเวอร์จริง ๆ จะรวมทั้งสองอย่างเข้าด้วยกัน ทำต่อกันทันทีในการทำงานครั้งเดียว:
public class GachaService
{
// Separate weighted pools per tier -- each one uses the exact same
// cumulative-weight PickByRoll from Section 6.
public GachaPool FiveStarItems;
public GachaPool StandardItems; // everything below 5-star
public GachaItem RollPull(PityState pity, PityConfig cfg, Random rng)
{
int thisPullNumber = pity.PullsSinceLastFiveStar + 1;
double rate = cfg.BaseFiveStarRate;
if (thisPullNumber >= cfg.HardPityAt)
{
rate = 1.0;
}
else if (thisPullNumber >= cfg.SoftPityStart)
{
int stepsIntoSoftPity = thisPullNumber - cfg.SoftPityStart;
rate = cfg.BaseFiveStarRate + stepsIntoSoftPity * cfg.SoftPityStep;
if (rate > 1.0) rate = 1.0;
}
bool gotFiveStar = rng.NextDouble() < rate;
if (gotFiveStar)
{
pity.PullsSinceLastFiveStar = 0;
int roll = rng.Next(0, FiveStarItems.TotalWeight());
return FiveStarItems.PickByRoll(roll);
}
else
{
pity.PullsSinceLastFiveStar = thisPullNumber;
int roll = rng.Next(0, StandardItems.TotalWeight());
return StandardItems.PickByRoll(roll);
}
}
}
โค้ดเวอร์ชันนี้ใช้ System.Random ตัวจริง (ในโปรดักชันจริง แหล่งสุ่มแบบ cryptographically secure จะปลอดภัยกว่า เพื่อไม่ให้ใครทำนาย roll ครั้งถัดไปได้จากการดู roll ที่ผ่านมา) แทนที่จะใช้ Func<double> ที่กำหนดสคริปต์ไว้แบบใน trace ด้านบน ตั้งใจให้เป็นแบบนี้ เวอร์ชันสคริปต์มีไว้แค่เพื่อให้ตัวอย่างในบทนี้ให้ผลลัพธ์ที่ตรวจสอบด้วยมือได้ ในขณะที่ GachaService.RollPull คือรูปแบบที่รันจริงบนเซิร์ฟเวอร์โปรดักชัน ถูกเรียกครั้งเดียวต่อหนึ่ง pull ทำงานอยู่ภายในโปรเซสของเซิร์ฟเวอร์ทั้งหมด โดย client จะได้รับแค่ผลลัพธ์ GachaItem ที่ตัดสินใจเสร็จแล้วเท่านั้น
การลองคำนวณเลขจริง ๆ เบื้องหลังความรู้สึก "ฉันโชคร้าย" สักครั้งด้วยตัวเลขที่ทำให้ง่ายลงจะช่วยได้ แทนที่จะเชื่อความรู้สึกเฉย ๆ ลองใช้ base rate แบบเรียบ (ยังไม่มี pity เข้ามาเกี่ยว) ที่ 2% ต่อ pull แล้วถามว่า โอกาสที่จะได้ 5-star อย่างน้อยหนึ่งครั้งใน 10 pull แรกคือเท่าไหร่?
กับดักง่าย ๆ ที่คนมักตกคือเอา 2% มาบวกสิบครั้งแล้วคาดหวัง 20% ซึ่งผิด เพราะมันมองข้ามว่าแต่ละ pull เป็นเหตุการณ์อิสระจากกัน แทนที่จะทำแบบนั้น ให้ลองตั้งคำถามในมุมตรงข้ามก่อน คือโอกาสที่จะพลาดทุกครั้งในทั้ง 10 pull คือเท่าไหร่?
ดังนั้นแม้จะอยู่ที่อัตรา 2% ต่อ pull สิบ pull ก็ให้โอกาสสำเร็จแค่ 18.3% ไม่ใช่ 20% และความต่างนี้จะยิ่งมากขึ้นเมื่อจำนวน pull เพิ่มขึ้น เพราะ "พลาดทุกครั้งเลย" มีโอกาสน้อยลงเรื่อย ๆ แต่ไม่ได้ลดลงเป็นเส้นตรง นี่คือเหตุผลว่าทำไม hard pity (หัวข้อ 7) ถึงต้องมีอยู่เป็นกลไกแยกต่างหาก แทนที่จะเชื่อ base rate อย่างเดียว ถ้าไม่มีมัน ผู้เล่นสัดส่วนหนึ่งที่น้อยแต่มีอยู่จริง ประมาณ 1 ใน 5 ในตัวอย่างที่ทำให้ง่ายแบบนี้ (แย่กว่านั้นอีกที่อัตราจริง 0.9%) ก็จะยังไม่ได้อะไรเลยหลังจาก pull มาเป็นจำนวนมากที่ผู้เล่นส่วนใหญ่คิดว่า "น่าจะได้แล้วนะ" โดยไม่มีการการันตีเลยว่าจะได้เมื่อไหร่ hard pity เปลี่ยนคำว่า "น่าจะได้สักวัน" ให้กลายเป็น "ได้แน่นอน ภายใน pull 90" ซึ่งเป็นคำสัญญาที่ต่างกันมากที่จะให้กับผู้เล่นที่จ่ายเงินจริง
แทบทุกเกม live-ops จะมีสกุลเงินแยกกันอย่างน้อยสองสกุล:
การแยกสองสกุลนี้ออกจากกันทำให้สตูดิโอปรับแต่ละตัวได้อย่างอิสระ soft currency สามารถแจกได้แบบใจกว้าง เพราะมันขับเคลื่อน engagement ในชีวิตประจำวันและไม่เคยมีต้นทุนโดยตรงต่อสตูดิโอเลยที่จะแจกออกไป ส่วน premium currency ต้องคงความหายากไว้ และราคาของมันในรูปเงินจริงต้องยังมีความหมายอยู่ เพราะมันคือสินค้าตัวจริงที่ขายอยู่ ถ้าเอาสองสกุลนี้มารวมเป็นสกุลเดียว จะบีบให้ต้องเลือกอะไรที่อึดอัดใจ คือต้องทำให้รางวัลในเกมขี้เหนียว (เพื่อปกป้องมูลค่าของสกุลเงินที่จ่ายเงิน) หรือไม่ก็ทำให้สกุลเงินที่จ่ายเงินหาได้ฟรีง่ายเกินไป (ซึ่งทำลายมูลค่าของร้านค้าเอง)
public class PlayerWallet
{
public long SoftCurrency; // e.g. "Coins" -- earned by playing
public long PremiumCurrency; // e.g. "Crystals" -- bought or rarely gifted
// Two separate methods on purpose -- never one generic
// AddCurrency(type, amount) that treats both the same way.
public void EarnSoftCurrency(long amount)
{
SoftCurrency += amount;
}
public void GrantPremiumCurrency(long amount, string reason)
{
PremiumCurrency += amount;
Console.WriteLine($"Granted {amount} premium currency: {reason}");
}
}
var wallet = new PlayerWallet();
wallet.EarnSoftCurrency(500);
wallet.GrantPremiumCurrency(100, "first-time login bonus");
Console.WriteLine($"Coins: {wallet.SoftCurrency}, Crystals: {wallet.PremiumCurrency}");
ผลลัพธ์ที่คาดหวัง:
Granted 100 premium currency: first-time login bonus
Coins: 500, Crystals: 100
ทุกแหล่งที่เติมสกุลเงินเข้าเกมเรียกว่า faucet (รางวัลล็อกอินรายวัน การทำเควสสำเร็จ รางวัลจาก event) ทุกจุดที่ดึงสกุลเงินออกจากเกมเรียกว่า sink (การ pull gacha การอัปเกรดอุปกรณ์ การซื้อของตกแต่ง) นี่คือปัญหาเดียวกับที่ธนาคารกลางของประเทศต้องกังวล ถ้า faucet ผลิตออกมาเร็วกว่า sink ดูดเข้าไปตลอด ผู้เล่นจะสะสมสกุลเงินไว้มากกว่าที่เกมให้เหตุผลให้ใช้จ่าย ราคาต่าง ๆ จะรู้สึกไร้ความหมาย และโดยเฉพาะ premium currency จะเสียความรู้สึก "พิเศษ" ไป ซึ่งเป็นรูปแบบความล้มเหลวเดียวกับเงินเฟ้อในโลกจริง ถ้า sink ดูดเร็วกว่า faucet ผลิต ผู้เล่นจะหมดเงิน รู้สึกถูกลงโทษจากการเล่นตามปกติ แล้วก็เลิกเล่นไปในที่สุด
ทีม live-ops จะติดตามเรื่องนี้ด้วยตัวเลขจริง ไม่ใช่การเดา คือรวมผลผลิตที่คาดไว้ต่อสัปดาห์ของทุก faucet ทั่วทั้งฐานผู้เล่น รวมการดูดที่คาดไว้ของทุก sink แล้วคอยดูส่วนต่างของทั้งสองไปเรื่อย ๆ ตามเวลา
public class EconomyReport
{
public static void CheckBalance(long totalFaucetOutput, long totalSinkCapacity)
{
long difference = totalFaucetOutput - totalSinkCapacity;
double percentOff = (double)difference / totalSinkCapacity * 100.0;
if (Math.Abs(percentOff) < 5.0)
{
Console.WriteLine($"Balanced: faucets vs sinks off by {percentOff:F1}%");
}
else if (percentOff > 0)
{
Console.WriteLine($"Warning: faucets exceed sinks by {percentOff:F1}% -- inflation risk");
}
else
{
Console.WriteLine($"Warning: sinks exceed faucets by {Math.Abs(percentOff):F1}% -- currency drought risk");
}
}
}
EconomyReport.CheckBalance(totalFaucetOutput: 1_040_000, totalSinkCapacity: 1_000_000);
ผลลัพธ์ที่คาดหวัง:
Balanced: faucets vs sinks off by 4.0%
สตูดิโอจริง ๆ จะรันการตรวจสอบแบบนี้ต่อเนื่องกับ telemetry ที่เก็บมาสด ๆ (หัวข้อ 11) ไม่ใช่ทำแค่ครั้งเดียว event ใหม่ที่แจกรางวัล soft-currency ใจกว้างผิดปกติ หรืออุปกรณ์ tier ใหม่ที่จู่ ๆ อัปเกรดถูกลงมาก สามารถทำให้สมดุลเสียได้ภายในไม่กี่วัน และทีม live-ops ก็อยากจะจับสัญญาณนั้นได้จาก dashboard มากกว่าจะรู้จากผู้เล่นบ่นในโซเชียลมีเดียหลังจากนั้นเป็นสัปดาห์
กฎ server-authoritative แบบเดียวกับในหัวข้อ 5 ใช้ได้กับการเปลี่ยนแปลงสกุลเงินทุกอย่าง ไม่ใช่แค่การสุ่ม gacha client ห้ามตั้งค่าสกุลเงินรวมของตัวเองโดยตรงเด็ดขาด มันแค่ส่ง request ไป เซิร์ฟเวอร์เป็นคนตัดสินว่าจะอนุมัติหรือไม่ ทำการเปลี่ยนแปลง แล้วบอกสถานะใหม่กลับไปให้ client
การซื้อด้วยเงินจริงมีขั้นตอนที่จำเป็นเพิ่มมาอีกหนึ่งขั้น คือ receipt validation เมื่อผู้เล่นซื้อ premium currency app store (Apple App Store, Google Play) คือคนที่เก็บเงินจากบัตรของผู้เล่นจริง ๆ แล้วมันจะส่ง receipt (โทเคนที่เซ็นรับรองแล้วว่าซื้อจริง) ให้กับแอป แต่ receipt นั้นต้องเดินทางย้อนกลับผ่านเซิร์ฟเวอร์ของเกมเองก่อน แล้วถูกตรวจสอบยืนยันกับเซิร์ฟเวอร์ของ app store ก่อนที่จะให้สกุลเงินใด ๆ ทั้งนี้เพื่อไม่ให้ client ที่ถูกแก้ไขสามารถปลอม receipt ขึ้นมาแล้วให้สกุลเงินตัวเองได้ฟรี ๆ
การบันทึก receipt ID ทุกใบทันทีที่ถูกใช้แลกแล้ว คือสิ่งที่ทำให้การทำงานนี้เป็น idempotent (รันมากกว่าหนึ่งครั้งด้วย input เดิม ให้ผลลัพธ์เหมือนกับรันครั้งเดียว) transaction ledger เล็ก ๆ (บันทึกของ receipt ที่ถูกใช้แลกไปแล้วทุกใบ) จะทำให้เห็นภาพชัดขึ้น:
public class ReceiptLedger
{
private HashSet<string> redeemedReceiptIds = new HashSet<string>();
public bool TryRedeem(string receiptId, long crystalAmount, PlayerWallet wallet)
{
if (redeemedReceiptIds.Contains(receiptId))
{
Console.WriteLine($"Rejected: receipt {receiptId} already redeemed");
return false;
}
wallet.GrantPremiumCurrency(crystalAmount, "purchase " + receiptId);
redeemedReceiptIds.Add(receiptId);
return true;
}
}
var ledger = new ReceiptLedger();
var wallet2 = new PlayerWallet();
ledger.TryRedeem("receipt_A1", 1000, wallet2);
ledger.TryRedeem("receipt_A1", 1000, wallet2); // the same receipt sent again
ผลลัพธ์ที่คาดหวัง:
Granted 1000 premium currency: purchase receipt_A1
Rejected: receipt receipt_A1 already redeemed
ปัญหาเครือข่ายสะดุดที่ทำให้ client ส่ง receipt เดิมซ้ำสองครั้ง หรือ client ที่ประสงค์ร้ายจงใจส่ง receipt เก่ากลับมาซ้ำ ทั้งสองกรณีจะถูกปฏิเสธในความพยายามครั้งที่สอง เพราะเซิร์ฟเวอร์มี receipt ID ตัวนั้นทำเครื่องหมายว่าใช้ไปแล้ว นี่คือแนวคิดเดียวกับที่บท persistence ก่อนหน้านี้ใช้กับ write-ahead log ของระบบ save คือบันทึกถาวรแบบ append-only ที่ทำให้ทีม support ตอบคำถามว่า "สกุลเงินของผู้เล่นคนนี้หายไปไหน" ได้ในอีกหลายเดือนต่อมา แค่อ่านบันทึกแทนที่จะต้องเดา
Telemetry (สตรีมของ event ที่ client และเซิร์ฟเวอร์บันทึกอัตโนมัติเมื่อผู้เล่นทำอะไรบางอย่าง เช่น เปิดร้านค้า เริ่ม pull จบด่าน) คือวิธีที่ทีม live-ops รู้ว่า event หนึ่ง ๆ ได้ผลจริงหรือเปล่า แทนที่จะเดาเอา event ทั่วไปจะถูกบันทึกเป็นเรคคอร์ดที่มีโครงสร้างเล็ก ๆ:
public class TelemetryEvent
{
public string EventName; // "gacha_pull_started", "shop_opened", ...
public string PlayerId;
public long TimestampUnix;
public Dictionary<string, object> Params;
}
var evt = new TelemetryEvent {
EventName = "gacha_pull_started",
PlayerId = "player_4471",
TimestampUnix = 1_737_020_000,
Params = new Dictionary<string, object> {
{ "banner_id", "characterA_banner" },
{ "pull_count", 10 },
{ "currency_spent", 1600 },
}
};
Console.WriteLine($"{evt.EventName} by {evt.PlayerId}: banner={evt.Params["banner_id"]}, pulls={evt.Params["pull_count"]}");
ผลลัพธ์ที่คาดหวัง:
gacha_pull_started by player_4471: banner=characterA_banner, pulls=10
เมื่อคูณด้วยผู้เล่นหลายล้านคน event เหล่านี้ก็ตอบคำถามอย่างเช่น มีผู้เล่นกี่คนที่เปิดร้านค้าแล้วไม่ซื้ออะไรเลย (funnel คืออัตราการหลุดออกระหว่างขั้นตอนหนึ่งไปอีกขั้นตอนหนึ่ง)? มีผู้เล่นสัดส่วนเท่าไหร่ที่ยังเล่นอยู่หลังติดตั้งไปแล้วหนึ่งวัน เจ็ดวัน สามสิบวัน (D1/D7/D30 retention)? ผู้เล่นที่จ่ายเงินโดยเฉลี่ยใช้จ่ายเท่าไหร่ (ARPPU คือรายได้เฉลี่ยต่อผู้ใช้ที่จ่ายเงิน)?
A/B testing ใช้กลไก feature-flag แบบเดียวกับหัวข้อ 4 เพื่อตอบคำถามที่คมกว่านั้น คือการเปลี่ยนสิ่งใดสิ่งหนึ่งโดยเฉพาะ ช่วยได้จริงไหม? แทนที่จะมี flag เดียวเปิดหรือปิดให้ทุกคนเหมือนกันหมด ผู้เล่นจะถูกแบ่งเป็นกลุ่มด้วยการ hash player ID ให้ตกอยู่ใน bucket ที่คงที่ ทำให้ผู้เล่นคนเดิมตกอยู่กลุ่มเดิมเสมอในทุกเซสชัน วิธีนี้ต้องใช้ hash ที่ให้ผลเหมือนเดิมทุกครั้งที่เกมรัน ไม่ใช่ string.GetHashCode() ซึ่ง .NET จงใจสุ่มให้ต่างกันทุกครั้งที่โปรเซสเริ่มใหม่ จึงห้ามเอามาใช้กับอะไรก็ตามที่ต้องคงที่ข้ามเซสชันเด็ดขาด:
public static int StableHash(string input)
{
int hash = 0;
foreach (char c in input)
{
hash = (hash * 31 + c) % 1000000;
}
return Math.Abs(hash);
}
public static string AssignVariant(string playerId, int percentInVariantB)
{
int bucket = StableHash(playerId) % 100; // 0..99, stable across runs
return bucket < percentInVariantB ? "B" : "A";
}
string[] players = { "alice", "bob", "carol", "dave" };
foreach (string p in players)
{
Console.WriteLine($"{p} -> variant {AssignVariant(p, percentInVariantB: 50)}");
}
ผลลัพธ์ที่คาดหวัง:
alice -> variant B
bob -> variant B
carol -> variant B
dave -> variant A
ลองคำนวณ hash ด้วยมือดูก็ยืนยันได้ alice ตกอยู่ bucket 40, bob ตก bucket 17, carol ตก bucket 9 ทั้งหมดต่ำกว่าเกณฑ์ 50 จึงได้ "B" ทั้งหมด ในขณะที่ dave ตก bucket 76 จึงได้ "A" ด้วยตัวอย่างผู้เล่นแค่สี่คน การแบ่งนี้ดูไม่ใกล้เคียง 50/50 เลย ซึ่งจะเกิดขึ้นก็ต่อเมื่อแบ่ง bucket ผู้เล่นเป็นพัน ๆ คนเท่านั้น เหตุผลเดียวกันเป๊ะ ๆ กับที่การคำนวณโยนเหรียญในหัวข้อ 8 ต้องใช้ pull จำนวนมากก่อนที่อัตราจริงจะปรากฏออกมาอย่างน่าเชื่อถือ Variant A อาจเห็นภาพอาร์ตของ banner แบบเดิม Variant B เห็นหน้าจอ banner ที่ออกแบบใหม่ ทั้งสองกลุ่มเล่นเกมตามปกติ telemetry บันทึกว่าแต่ละกลุ่ม pull และใช้จ่ายไปเท่าไหร่จริง ๆ พอมีผู้เล่นผ่านทั้งสอง variant มากพอแล้ว ทีม live-ops ก็เปรียบเทียบตัวเลขของสองกลุ่มเพื่อตัดสินใจว่าเวอร์ชันไหนจะปล่อยให้ทุกคน
ในเชิงกลไก ระบบ gacha ก็คือสิ่งเดียวกับ loot box หรือสล็อตแมชชีน คือจ่ายสิ่งที่มีมูลค่าเพื่อแลกกับรางวัลแบบสุ่ม หน่วยงานกำกับดูแลในหลายประเทศมากขึ้นเรื่อย ๆ มองมันแบบนั้น และกฎด้านล่างนี้ไม่ใช่ตัวเลือกเสริม แต่เป็นข้อบังคับทางกฎหมายในหลายตลาดที่เกมอย่าง Genshin Impact วางขายอยู่จริง ๆ
ทั้งหมดนี้ไม่ได้บอกว่ากลไกนี้ไม่ซื่อสัตย์โดยธรรมชาติ drop rate ที่เปิดเผยและเพดาน hard pity ในเชิงกลไกแล้วถือเป็นข้อตกลงที่เป็นธรรม เพราะมันถูกเปิดเผยและถูกจำกัดขอบเขตไว้ ผู้เล่นที่เลือกจะ pull สามารถตรวจดูล่วงหน้าได้ว่ากรณีที่แย่ที่สุดที่อาจเจอคืออะไร และโอกาสที่แน่นอนที่ตัวเองกำลังยอมรับคืออะไร รูปแบบความล้มเหลวทางจริยธรรมที่ทีม live-ops ควรหลีกเลี่ยงคือด้านตรงข้าม คือการซ่อนโอกาสที่แท้จริง การออกแบบภาพ "เกือบได้" ที่ตั้งใจทำให้การพลาดรู้สึกเหมือนเกือบชนะ หรือการใช้ตัวนับถอยหลังและความขาดแคลนที่สร้างขึ้นมาเพื่อกดดันให้ตัดสินใจซื้อเร็วกว่าที่ผู้เล่นจะตัดสินใจเองตามปกติ สตูดิโอที่เปิดเผย rate อย่างตรงไปตรงมา รักษากลไก pity ให้มีขอบเขตจริง ๆ และเคารพการป้องกันด้านการใช้จ่ายและอายุตามที่กฎหมายท้องถิ่นกำหนด ไม่ได้ทำอะไรต่างไปจากหัวข้อ 5 ถึง 9 ของบทนี้ในเชิงกลไกเลย เพียงแต่ทำมันอย่างโปร่งใส แทนที่จะซ่อนมันไว้
"exclusive_skin" rarity "5-star-exclusive" weight เท่ากับ 1 โดยไม่เปลี่ยน weight ของสามไอเทมเดิมเลย เขียนช่วง cumulative ใหม่ของทั้งสี่ไอเทม แล้วไล่ PickByRoll ด้วยมือสำหรับ roll 995 และ 1000 ว่าแต่ละอันคืนไอเทมอะไรการเพิ่มไอเทมที่สี่ทำให้ total weight เพิ่มเป็น 1001 ช่วงของแต่ละไอเทมเริ่มตรงจุดที่ cursor ของไอเทมก่อนหน้าหยุดไว้ common_sword ครอบครอง [0, 940), rare_bow ครอบครอง [940, 991), legendary_gem ครอบครอง [991, 1000) และ exclusive_skin ตัวใหม่ครอบครอง [1000, 1001) ซึ่งเป็นช่วงที่เล็กที่สุดเท่าที่จะเป็นไปได้ ตรงกับ weight ของมันที่เป็น 1
pool.Items.Add(new GachaItem { Id="exclusive_skin", Rarity="5-star-exclusive", Weight=1 });
int[] scriptedRolls = { 995, 1000 };
foreach (int roll in scriptedRolls)
{
GachaItem result = pool.PickByRoll(roll);
Console.WriteLine($"roll={roll} -> {result.Id} ({result.Rarity})");
}
roll=995 -> legendary_gem (5-star)
roll=1000 -> exclusive_skin (5-star-exclusive)
995 เกิน 991 มาแล้วแต่ยังไม่ถึง 1000 จึงตกอยู่ในช่วงของ legendary_gem ส่วน 1000 ไม่ผ่านเงื่อนไขของ legendary_gem (1000 < 1000 เป็นเท็จ) แต่ผ่านเงื่อนไขของ exclusive_skin (1000 < 1001 เป็นจริง) จึงตกอยู่ในช่วงเล็ก ๆ ใหม่นี้ ตรงตามที่ weight ของมัน 1 จาก 1001 ทั้งหมดบอกไว้ว่ามันควรจะแทบไม่เกิดขึ้นเลย
PullsSinceLastFiveStar = 72 (ใช้ค่า default ของ PityConfig เดียวกับหัวข้อ 7 คือ base rate 0.9%, soft pity เริ่มที่ pull 74, soft pity step 6% ต่อ pull, hard pity ที่ pull 90) พวกเขา pull ติดกันสี่ครั้ง โดยค่า nextRoll ที่กำหนดไว้คือ 0.95, 0.95, 0.03, 0.95 ใช้ PityGacha.RollOnePull ไล่ด้วยมือว่าแต่ละ pull ทั้งสี่ครั้งคืนอะไร และ pity counter หลังจากแต่ละครั้งเป็นเท่าไหร่
var pity = new PityState { PullsSinceLastFiveStar = 72 };
var cfg = new PityConfig();
double[] scriptedRolls = { 0.95, 0.95, 0.03, 0.95 };
int i = 0;
Func<double> nextRoll = () => scriptedRolls[i++];
for (int n = 0; n < 4; n++)
{
Console.WriteLine(PityGacha.RollOnePull(pity, cfg, nextRoll));
}
no 5-star (pull #73, rate was 0.9%)
no 5-star (pull #74, rate was 0.9%)
5-star! (pull #75, rate was 6.9%)
no 5-star (pull #1, rate was 0.9%)
pull ที่ 1 คือ pull number 73 (72 + 1) ยังต่ำกว่าเกณฑ์ soft pity ที่ 74 อยู่ rate จึงยังคงที่ 0.9% เฉย ๆ และ 0.95 พลาดง่าย ๆ pull ที่ 2 คือ pull number 74 ตรงเกณฑ์ soft pity พอดี แต่ stepsIntoSoftPity เป็น 0 ตรงจุดนี้ rate จึงยังเป็น base 0.9% เป๊ะ ๆ และพลาดอีกครั้ง pull ที่ 3 คือ pull number 75 เข้าสู่ soft pity ไปหนึ่งก้าว ทำให้ rate ดันขึ้นเป็น 0.9% + 1 * 6% = 6.9% คราวนี้ roll ต่ำที่ 0.03 ต่ำกว่า rate นั้น จึงชนะ และ counter รีเซ็ตกลับเป็น 0 pull ที่ 4 เริ่มนับใหม่ทั้งหมดที่ pull number 1 กลับไปที่ base rate 0.9% ซึ่ง 0.95 ก็พลาดอีกครั้ง
function OnPurchaseReceiptReceived(playerId, receipt):
isValid = VerifyReceiptWithAppStore(receipt)
if not isValid:
return Error("invalid receipt")
GrantPremiumCurrency(playerId, receipt.crystalAmount)
return Success("granted " + receipt.crystalAmount + " crystals")
ฟังก์ชันนี้ขาด check "ใช้แลกไปแล้วหรือยัง" ที่ ReceiptLedger ในหัวข้อ 10 มีอยู่แล้ว VerifyReceiptWithAppStore ยืนยันได้แค่ว่า receipt เป็นโทเคนแท้ที่เซ็นรับรองแล้วซึ่ง app store ออกให้จริง แต่มันไม่รู้ และไม่มีทางรู้ได้ ว่าเซิร์ฟเวอร์ของเกมจ่ายรางวัลให้ receipt ใบเดียวกันนี้ไปแล้วครั้งหนึ่งหรือยัง receipt ที่แท้จริงก็ยังคงแท้อยู่ดีไม่ว่าจะถูกตรวจสอบกี่ครั้งก็ตาม ดังนั้นการส่ง receipt ที่ valid ตัวเดิมซ้ำสามครั้งจะผ่าน isValid ทั้งสามครั้ง และ GrantPremiumCurrency ก็จะรันสามครั้งตามไปด้วย
function OnPurchaseReceiptReceived(playerId, receipt):
isValid = VerifyReceiptWithAppStore(receipt)
if not isValid:
return Error("invalid receipt")
if ReceiptAlreadyRedeemed(receipt.id):
return Error("receipt already redeemed")
GrantPremiumCurrency(playerId, receipt.crystalAmount)
MarkReceiptAsRedeemed(receipt.id)
return Success("granted " + receipt.crystalAmount + " crystals")
ทางแก้คือเพิ่ม check ตัวเดียวกับที่ ReceiptLedger.TryRedeem ทำอยู่แล้วในหัวข้อ 10 คือค้นหา ID เฉพาะของ receipt ในบันทึกถาวรของ receipt ที่ใช้แลกไปแล้ว ก่อนที่จะให้รางวัลใด ๆ แล้วเขียน ID นั้นลงในบันทึกทันทีที่ให้รางวัลเสร็จ คำขอชุดที่สองและสามของ request เดิมตอนนี้จะไม่ผ่าน check ตัวใหม่และไม่ได้รับอะไรเลย ในขณะที่ครั้งแรกสุดยังคงสำเร็จตามปกติ ทำให้ endpoint นี้กลายเป็น idempotent ตรงตามที่หัวข้อ 10 นิยามคำนี้ไว้เป๊ะ ๆ