Цялата стратегия
Накратко: Играта свършва с първата от пет проверки, която каже „да“: завладяване, поражение, наука, икономика или срок (точки). Записът е текст „ключ=стойност“ с версия и контролна сума, зареждането проверява всичко, преди да пипне играта, а балансът се мери със стотици партии без екран.
Проблем
Кога свършва играта?
Пет урока строихме части: галактика от семе, дърво на технологиите, флоти и битки, съперници с характер и ход с фази и събития. Но играта още няма край. Ако никой никога не победи, партията не свършва, а ако победата е лоша, играчът играе сто и петдесет хода и разбира, че всичко е било решено в първите двайсет.
Този урок затваря модула с три неща, на които стои всяка цяла игра: победа (кога и как свършва партията), запис (как се продължава утре) и баланс (как знаеш, че играта е честна, без да я изиграеш хиляда пъти на ръка). После играта е тук, цяла.
Ето как свършва минаването, с което се проверява играта на четирите езика. Земната империя проучава Звездната врата на ход 59, а екранът на края показва класирането:
КРАЙ НА ИГРАТА
Земна империя проучи Звездната врата на ход 59.
Класиране (планети, технологии, точки):A Земна империя 7 13 376C Съюз Лира 0 3 38 унищоженаD Академия Вега 7 12 319B Хегемония Орион 2 8 154
Битки 27, загубени единици 159, колонии 12, технологии 36,събития 27.
(Enter: изход)Три неща се виждат в този екран: вид на победата (наука), класиране (планети, технологии, точки на всяка империя; Съюз Лира е унищожена) и статистика на партията. Те идват от три малки функции, които ще видиш тук.
Идеята
Четири победи, един ред от проверки
Играта има четири начина да свърши и още един, за да не виси вечно:
- Завладяване: остава жива само една империя.
- Поражение: играчът е унищожен (партията свършва, а най-силният от останалите се записва като победител).
- Наука: някоя жива империя е проучила Звездната врата, технологията в края на дървото.
- Икономика: някоя жива империя е произвела общо 9800.
- Срок: дошъл е ход 150 и печели този с най-много точки.
Точките на империя са 10 за планета, 6 за технология, мощта на флота делена на 10 и произведеното делено на 40. Ето истинският код от играта:
// Три победи: завладяване (останала е само една империя), наука (проучена Звездна врата) и// икономика (натрупано производство ECON_GOAL). Ако никой не победи до MAX_TURNS, печели този// с най-много точки. Ако играчът е унищожен, играта свършва с поражение.static partial class Cosmos{ public static int EmpireScore(World world, Empire empire) { return OwnedPlanets(world, empire.Id).Count * 10 + empire.Techs.Count * 6 + EmpirePower(world, empire.Id) / 10 + empire.Produced / 40; }
public static Empire BestScoring(World world) { Empire? best = null; int bestScore = -1; foreach (Empire e in world.Empires) { if (!e.Alive) continue; int score = EmpireScore(world, e); if (score > bestScore) { bestScore = score; best = e; } } return best!; }
public static void CheckVictory(World world) { if (world.Victory != null) return; if (world.LivingCount() == 1) { foreach (Empire e in world.Empires) { if (e.Alive) world.Victory = new Victory { Empire = e.Id, Kind = "conquest", Turn = world.Turn }; } return; } foreach (Empire e in world.Empires) { if (e.Human && !e.Alive) { world.Victory = new Victory { Empire = BestScoring(world).Id, Kind = "lost", Turn = world.Turn }; return; } } foreach (Empire e in world.Empires) { if (e.Alive && Knows(e, "stargate")) { world.Victory = new Victory { Empire = e.Id, Kind = "science", Turn = world.Turn }; return; } } foreach (Empire e in world.Empires) { if (e.Alive && e.Produced >= ECON_GOAL) { world.Victory = new Victory { Empire = e.Id, Kind = "economy", Turn = world.Turn }; return; } } if (world.Turn >= MAX_TURNS) world.Victory = new Victory { Empire = BestScoring(world).Id, Kind = "points", Turn = world.Turn }; }}checkVictory не е една проверка, а пет в точен ред, и първата, която каже „да“, решава. Това е ранен изход: щом знаеш отговора, връщаш. Редът не е случаен. Ако е жива само една империя, няма смисъл да питаш за вратата. Ако играчът е унищожен, партията е загубена, колкото и да е произвел някой. А срокът е последен, защото е най-слабата победа.
Малката програма прави същото със същите правила, но с три империи и без галактика. Империята знае колко точки е:
class Empire{ public string Name; public bool Human; public bool Alive = true; public bool Stargate = false; public int Planets; public int Techs; public int Power; public int Produced;
public Empire(string name, bool human, int planets, int techs, int power, int produced) { Name = name; Human = human; Planets = planets; Techs = techs; Power = power; Produced = produced; }
public int Score() { return Planets * 10 + Techs * 6 + Power / 10 + Produced / 40; }}class Victory{ public string Kind; public string Winner;
public Victory(string kind, string winner) { Kind = kind; Winner = winner; }}Проверките са същите пет, в същия ред (ECON_GOAL е 9800, MAX_TURNS е 150):
static Victory? CheckVictory(List<Empire> empires, int turn){ int living = 0; Empire? last = null; foreach (Empire e in empires) { if (e.Alive) { living++; last = e; } } if (living == 1) return new Victory("завладяване", last!.Name); foreach (Empire e in empires) { if (e.Human && !e.Alive) return new Victory("поражение", BestScoring(empires)!.Name); } foreach (Empire e in empires) { if (e.Alive && e.Stargate) return new Victory("наука", e.Name); } foreach (Empire e in empires) { if (e.Alive && e.Produced >= EconGoal) return new Victory("икономика", e.Name); } if (turn >= MaxTurns) return new Victory("точки", BestScoring(empires)!.Name); return null;}Седем света, седем отговора. Последните два показват най-важното: унищожен играч дава поражение, а ход 150 без друга победа дава точки.
var game = Fresh();Show("ход 40, нищо не е свършило", game, 40);
game = Fresh();game[2].Stargate = true;Show("проучена Звездна врата", game, 40);
game = Fresh();game[1].Produced = 9800;Show("произведено 9800", game, 40);
game = Fresh();game[2].Stargate = true;game[2].Produced = 9800;Show("и врата, и 9800 в един ход", game, 40);
game = Fresh();game[1].Alive = false;game[2].Alive = false;Show("останала е една империя", game, 40);
game = Fresh();game[0].Alive = false;Show("играчът е унищожен", game, 40);
Show("ход 150, без друга победа", Fresh(), 150);ход 40, нищо не е свършило: -проучена Звездна врата: наука (Академия Вега)произведено 9800: икономика (Хегемония Орион)и врата, и 9800 в един ход: наука (Академия Вега)останала е една империя: завладяване (Земна империя)играчът е унищожен: поражение (Хегемония Орион)ход 150, без друга победа: точки (Земна империя)Всеки случай започва от една и съща свежа игра и променя едно-две неща. Четвъртият е хитрият: Академия Вега е проучила Звездната врата и е произвела 9800 в един и същ ход. Печели науката, защото е по-рано в реда.
Виж го
Първото „да“ решава
Четири от тези случаи в движение. Следи кои проверки се изпълняват и кои потъмняват, защото никога не стигат до тях:
static Victory? CheckVictory(List<Empire> empires, int turn){ int living = 0; Empire? last = null; foreach (Empire e in empires) { if (e.Alive) { living++; last = e; } } if (living == 1) return new Victory("завладяване", last!.Name); foreach (Empire e in empires) { if (e.Human && !e.Alive) return new Victory("поражение", BestScoring(empires)!.Name); } foreach (Empire e in empires) { if (e.Alive && e.Stargate) return new Victory("наука", e.Name); } foreach (Empire e in empires) { if (e.Alive && e.Produced >= EconGoal) return new Victory("икономика", e.Name); } if (turn >= MaxTurns) return new Victory("точки", BestScoring(empires)!.Name); return null;}Стъпка по стъпка, с думи
- След всеки ход светът пита пет въпроса по ред: останала ли е една империя, унищожен ли е играчът, има ли Звездна врата, има ли 9800 произведено, дошъл ли е ход 150. Първото „да“ решава.
- Ход 40. И трите империи са живи, никой няма Звездна врата, никой не е стигнал 9800.
- Пет пъти „не“. checkVictory връща null и играта върви към следващия ход.
- Академия Вега: Звездната врата е проучена и 9800 са произведени, и двете в един и същ ход. Две победи наведнъж.
- Третата проверка (наука) казва „да“ и връща веднага. Четвъртата, икономиката, не се стига: редът на проверките решава кой печели.
- Хегемония Орион и Академия Вега са унищожени. Остава една империя: Земна империя.
- Първата проверка казва „да“: завладяване. Четирите след нея не се изпълняват.
- Ход 150: срокът. Никой не е победил по друг начин.
- Четири „не“ и накрая срокът: печели този с най-много точки. Земна империя и Хегемония Орион имат равни точки, а равенството го печели по-ранният.
Защо първото „да“ връща веднага? Защото после няма какво да се решава: победата е една на партия. Но има и практическа причина. Ако четвъртата проверка се изпълнеше след третата, функцията трябваше да избира между две победи, а това е ново правило. С ранния изход правилото е редът, и се чете направо от кода: сменяш два реда и си сменил кой печели при равенство на условията.
По-точно, за любопитните
Тук победата се проверява веднъж на ход, след като всичко друго в хода е свършило. Така играчът, който е стигнал 9800, не печели по средата на хода, преди съперниците му да са изиграли своя. В региона turn на канона checkVictory(world) е последната фаза на хода, след производството, науката и събитието.
Играта пази победата в света (world.victory), а не я връща: екранът на края, записът и симулацията четат едно и също място. Така същата проверка върши работа и за играч, и за симулация без екран.
Идеята
Точките: равенство и унищожените
Срокът е най-простото от четирите, но има две скрити правила. Първо, унищожените империи не се броят: империя с много точки, която вече е изчезнала, не бива да печели. Второ, равенството: две империи с еднакви точки трябва да имат предвидим победител, иначе симулацията не е детерминистична.
static Empire? BestScoring(List<Empire> empires){ Empire? best = null; int bestScore = -1; foreach (Empire e in empires) { if (!e.Alive) continue; int score = e.Score(); if (score > bestScore) { bestScore = score; best = e; } } return best;}bestScore започва от −1, за да може и империя с нула точки да стане водач. Сравнението е строго (>), затова при равенство остава по-ранният. Програмата сравнява четири империи, една унищожена и две с еднакви точки:
var rivals = new List<Empire>();rivals.Add(new Empire("Земна империя", false, 5, 9, 400, 3000));rivals.Add(new Empire("Хегемония Орион", false, 3, 11, 450, 3120));rivals.Add(new Empire("Академия Вега", false, 4, 8, 300, 2000));var lyra = new Empire("Съюз Лира", false, 8, 14, 900, 6000);lyra.Alive = false;rivals.Add(lyra);
foreach (Empire e in rivals){ string note = e.Alive ? "" : " (унищожена)"; Console.WriteLine(e.Name + ": " + e.Score() + note);}Console.WriteLine("Най-много точки: " + BestScoring(rivals)!.Name);Земна империя: 219Хегемония Орион: 219Академия Вега: 168Съюз Лира: 404 (унищожена)Най-много точки: Земна империяСъюз Лира има най-много точки (404), но е унищожена и отпада. Земна империя и Хегемония Орион са на 219, а победител е първата. Деленията са целочислени, както навсякъде в играта (/ върху цели числа в Java и C#), и по тях точките са еднакви на четирите езика.
Идеята
Запис: текст „ключ=стойност“
Играч, който затваря играта на ход 80, иска утре да продължи от ход 80. Записът е четене на света обратно, и има три въпроса: какво се записва, в какъв вид и как се знае, че файлът е цял.
Какво. Не всичко. Галактиката (системи, пътища, видове планети) се прави от семето: същото семе, същата галактика (урок 1 от модула). Затова записът пази само нещата, които се променят: чия е всяка планета, какво е построено, ресурсите, технологиите, флотовете, опашката за строеж, мира и войната и състоянието на генератора, за да продължат заровете от същото място.
Във вид. Текст, ред по ред, ключ=стойност. Може да се прочете с поглед, да се сравни с друг запис, да се поправи на ръка в краен случай и форматът не е вързан за езика на играта: същият текст може да го пише и чете версия на всеки език. Първият ред е заглавие (format=COSMOS), вторият е версия на формата, а последният е контролна сума на всичко над него.
public static string SaveWorld(World world){ List<string> lines = new List<string>(); void Put(string key, object value) { lines.Add(key + "=" + value); } Put("format", SAVE_FORMAT); Put("version", SAVE_VERSION); Put("summary", SaveSummary(world)); Put("seed", world.Seed); Put("rng", world.Rng.State); Put("turn", world.Turn); Put("systems", world.Systems.Count); Put("planets", world.Planets.Count); Put("empires", world.Empires.Count); foreach (Planet planet in world.Planets) { List<int> values = new List<int> { planet.Owner, planet.Mines, planet.Lab, planet.Yard, planet.Fort, planet.Siege }; values.AddRange(planet.Rich); Put("planet." + planet.Id, string.Join(",", values)); } foreach (Empire empire in world.Empires) { string prefix = "empire." + empire.Id; List<object> head = new List<object> { empire.Def.Id, empire.Personality.Id, empire.Alive ? 1 : 0, empire.Progress, empire.Produced, empire.Flare }; foreach (int amount in empire.Res) head.Add(amount); Put(prefix, string.Join(",", head)); Put(prefix + ".researching", empire.Researching == "" ? "-" : empire.Researching); Put(prefix + ".techs", JoinOrDash(empire.Techs)); Put(prefix + ".war", string.Join(",", empire.War)); Put(prefix + ".since", string.Join(",", empire.Since)); Put(prefix + ".grudge", string.Join(",", empire.Grudge)); List<string> orders = new List<string>(); foreach (Order order in empire.Orders) { List<int> values = new List<int> { order.From, order.To, order.Dest }; values.AddRange(order.Ships); orders.Add(string.Join(":", values)); } Put(prefix + ".orders", JoinOrDash(orders)); } Put("fleets", world.Fleets.Count); for (int i = 0; i < world.Fleets.Count; i++) { Fleet fleet = world.Fleets[i]; List<int> values = new List<int> { fleet.Owner, fleet.SystemId }; values.AddRange(fleet.Ships); Put("fleet." + i, string.Join(",", values)); } Put("queue", world.Queue.Count); for (int i = 0; i < world.Queue.Count; i++) { QueueItem item = world.Queue[i]; Put("queue." + i, string.Join(",", new int[] { item.Owner, item.PlanetId, item.Kind, item.Count, item.Left })); } List<string> offers = new List<string>(); foreach (Offer offer in world.Offers) offers.Add(offer.From + ":" + offer.To + ":" + offer.Turn); Put("offers", JoinOrDash(offers)); WorldStats s = world.Stats; Put("stats", string.Join(",", new int[] { s.Battles, s.Lost, s.Colonies, s.Techs, s.Events })); string body = string.Join("\n", lines) + "\n"; return body + "checksum=" + Checksum(body) + "\n";}Малката програма записва същото в малко: семе, ход и три империи (планети, произведено, технологии), с версия и контролна сума в края:
class Game{ public int Seed; public int Turn; public List<int[]> Empires = new List<int[]>();
public Game(int seed, int turn) { Seed = seed; Turn = turn; }}static string Save(Game state){ var lines = new List<string>(); lines.Add("format=COSMOS"); lines.Add("version=" + Version); lines.Add("seed=" + state.Seed); lines.Add("turn=" + state.Turn); lines.Add("empires=" + state.Empires.Count); for (int i = 0; i < state.Empires.Count; i++) { int[] e = state.Empires[i]; lines.Add("empire." + i + "=" + e[0] + "," + e[1] + "," + e[2]); } string body = string.Join("\n", lines) + "\n"; return body + "checksum=" + Checksum(body) + "\n";}Console.Write(Save(Sample()));format=COSMOSversion=1seed=2026turn=12empires=3empire.0=5,3120,9empire.1=4,2750,7empire.2=3,4180,10checksum=987468948Ето записа, ред по ред. Версията стои там не за днес, а за утре: когато формата се промени (нов ключ, друг ред), старите записи трябва да се познаят и да се откажат, или да се преобразуват. Без версия един запис от стара версия би се прочел погрешно и тихо.
Идеята
Контролна сума: една промяна и сумата не съвпада
Последният ред на записа е числото, което се получава от всичко над него. При зареждане играта го смята наново и го сравнява със записаното: ако файлът е отрязан, развален или пипнат на ръка, числата са различни. Алгоритъмът е малък:
// Сумата минава 32 бита (сума по 31 и знак), затова е в long.public static long Checksum(string text){ long sum = 0; for (int i = 0; i < text.Length; i++) sum = (sum * 31 + text[i]) % 1000000007; return sum;}За всеки знак сумата се умножава по 31, добавя се кодът на знака и се взема остатък по 1000000007. Умножението по 31 е важното: така редът има значение. Сборът на кодовете би дал едно и също число за „ab“ и „ba“, а разменени две цифри в записа са точно онази грешка, която искаме да хванем.
string[] texts = {"ab", "ba", "turn=12", "turn=21"};foreach (string word in texts){ Console.WriteLine(word + " -> " + Checksum(word));}ab -> 3105ba -> 3135turn=12 -> 408655515turn=21 -> 408655545„ab“ е 97 × 31 + 98 = 3105, а „ba“ е 98 × 31 + 97 = 3135. Остатъкът държи числото под милярд, така че да се събира в 32 бита, а long пази междинния резултат sum * 31, който е над два милярда.
static long Checksum(string text)
{
long sum = 0;
for (int i = 0; i < text.Length; i++)
{
sum = (sum * 31 + text[i]) 1000000007;
}
return sum;
}Остатъкът (%) връща сумата в границите под 1000000007 след всеки знак. Без него числото ще расте толкова, колкото е дълъг записът.
По-точно, за любопитните
Контролната сума хваща грешки, не подправяне. Който знае формулата, променя стойност и преизчислява сумата (програмата по-долу прави точно това с reseal, за да провери другите си защити). За игра, която се пази на твоя диск, това е достатъчно: целта е да не заредиш половин запис. За състезателна класация на сървър е нужно друго: подпис с таен ключ (HMAC), който играчът няма как да смята.
Алгоритъмът е от семейството на „полиномиалните сборове“, същият като hashCode на низ в Java. За истински хешове (SHA-256) има готови библиотеки, но играта пази 32-битова проверка без нищо отвън: тя се чете с поглед и дава еднакви числа на четирите езика.
Идеята
Зареждане, което не вярва на никого
Записът идва от диска, а дискът може да върне всичко: отрязан файл, запис от друга версия на играта, файл, редактиран в бележник. Ако играта го чете с вяра, един лош запис я срива или (по-лошо) я оставя в половин състояние, което се вижда чак на ход 90. Затова зареждането проверява всичко, преди да пипне играта, и казва точно какво не е наред.
class SaveReader{ public Dictionary<string, string> Values = new Dictionary<string, string>();
public SaveReader(string text) { List<string> lines = new List<string>(text.Split('\n')); while (lines.Count > 0 && lines[lines.Count - 1].Length == 0) lines.RemoveAt(lines.Count - 1); if (lines.Count == 0 || lines[0] != "format=" + SAVE_FORMAT) throw new SaveError(T("load.badHeader")); for (int i = 0; i < lines.Count; i++) { int eq = lines[i].IndexOf('='); if (eq <= 0) throw new SaveError(T("load.badLine", ("line", i + 1))); Values[lines[i].Substring(0, eq)] = lines[i].Substring(eq + 1); } string version = Text("version"); if (version != SAVE_VERSION.ToString()) throw new SaveError(T("load.badVersion", ("found", version), ("expected", SAVE_VERSION))); string last = lines[lines.Count - 1]; if (!last.StartsWith("checksum=", StringComparison.Ordinal)) throw new SaveError(T("load.missing", ("key", "checksum"))); string body = string.Join("\n", lines.GetRange(0, lines.Count - 1)) + "\n"; long actual = Checksum(body); if (ParseIntStrict(last.Substring(9)) != actual) { throw new SaveError(T("load.badChecksum", ("expected", last.Substring(9)), ("actual", actual))); } }
public string Text(string key) { if (!Values.ContainsKey(key)) throw new SaveError(T("load.missing", ("key", key))); return Values[key]; }
// Число в граници [low, high]. public long Int(string key, long low, long high) { long? value = ParseIntStrict(Text(key)); if (value == null || value < low || value > high) throw new SaveError(T("load.badValue", ("key", key))); return value.Value; }
// Списък от точно count числа, всяко в границите си. public int[] Ints(string key, int count, int low, int high) { string[] parts = Text(key).Split(','); if (parts.Length != count) throw new SaveError(T("load.badValue", ("key", key))); int[] result = new int[count]; for (int i = 0; i < parts.Length; i++) { long? value = ParseIntStrict(parts[i]); if (value == null || value < low || value > high) throw new SaveError(T("load.badValue", ("key", key))); result[i] = (int)value.Value; } return result; }
public List<string> List(string key) { return SplitList(Text(key)); }}Редът на проверките е важен: заглавие, всеки ред да е ключ=стойност, версия, контролна сума, и чак тогава стойностите. Всяка стойност се чете през int или ints, които проверяват границите (ход от 1 до 150, брой империи от 1 до 9). Малката версия е същата в малко:
В Java и C# функциите са в класа на програмата (Main и Program), а SaveReader е отделен клас и ги вика с името на класа. В канона всичко е във вътрешни класове на един клас и името не е нужно.
class SaveReader{ Dictionary<string, string> values = new Dictionary<string, string>();
public SaveReader(string text) { var lines = new List<string>(text.Split('\n')); while (lines.Count > 0 && lines[lines.Count - 1].Length == 0) lines.RemoveAt(lines.Count - 1); if (lines.Count == 0 || lines[0] != "format=COSMOS") throw new SaveError("това не е запис на Epic Cosmos"); for (int i = 0; i < lines.Count; i++) { int eq = lines[i].IndexOf('='); if (eq <= 0) throw new SaveError("ред " + (i + 1) + " не е „ключ=стойност“"); values[lines[i].Substring(0, eq)] = lines[i].Substring(eq + 1); } string version = Text("version"); if (version != Program.Version.ToString()) throw new SaveError("версия " + version + ", а играта чете версия " + Program.Version); string last = lines[lines.Count - 1]; if (!last.StartsWith("checksum=")) throw new SaveError("липсва „" + "checksum" + "“"); string body = string.Join("\n", lines.GetRange(0, lines.Count - 1)) + "\n"; long actual = Program.Checksum(body); if (Program.Digits(last.Substring(9)) != actual) { throw new SaveError("сумата не съвпада (записана " + last.Substring(9) + ", изчислена " + actual + "), файлът е пипан"); } }
public string Text(string key) { if (!values.ContainsKey(key)) throw new SaveError("липсва „" + key + "“"); return values[key]; }
public int Integer(string key, int low, int high) { long value = Program.Digits(Text(key)); if (value < low || value > high) throw new SaveError("грешна стойност на „" + key + "“"); return (int)value; }
public int[] Numbers(string key, int count, int low, int high) { string[] parts = Text(key).Split(','); if (parts.Length != count) throw new SaveError("грешна стойност на „" + key + "“"); var result = new int[count]; for (int i = 0; i < count; i++) { long value = Program.Digits(parts[i]); if (value < low || value > high) throw new SaveError("грешна стойност на „" + key + "“"); result[i] = (int)value; } return result; }}static Game Load(string text){ var reader = new SaveReader(text); var state = new Game(reader.Integer("seed", 0, 99999), reader.Integer("turn", 1, MaxTurns)); int count = reader.Integer("empires", 1, 9); for (int i = 0; i < count; i++) { state.Empires.Add(reader.Numbers("empire." + i, 3, 0, 99999)); } return state;}load създава нова игра от прочетеното и връща нея, без да пипа текущата. Грешката е изключение SaveError, което носи думите за играча. Този, който вика, я хваща и показва, а играта продължава със стария свят:
static void ShowLoad(string label, string text){ try { Game loaded = Load(text); int produced = 0; foreach (int[] e in loaded.Empires) produced += e[1]; Console.WriteLine(label + ": " + "ход " + loaded.Turn + ", империи " + loaded.Empires.Count + ", произведено " + produced); } catch (SaveError error) { Console.WriteLine(label + ": " + error.Message); }}Шест записа, шест отговора. Два от тях са преправени така, че сумата да мине (затова проверката на границите и на ключовете е нужна), а един е пипнат на ръка и сумата го хваща:
string good = Save(Sample());string body = good.Substring(0, good.IndexOf("checksum="));ShowLoad("цял запис", good);ShowLoad("пипан на ръка", good.Replace("3120", "99999"));ShowLoad("друга версия", good.Replace("version=1", "version=2"));ShowLoad("число извън границите", Reseal(body.Replace("turn=12", "turn=999")));ShowLoad("липсва ред", Reseal(body.Replace("seed=2026\n", "")));ShowLoad("не е запис", "hello");цял запис: ход 12, империи 3, произведено 10050пипан на ръка: сумата не съвпада (записана 987468948, изчислена 410613307), файлът е пипандруга версия: версия 2, а играта чете версия 1число извън границите: грешна стойност на „turn“липсва ред: липсва „seed“не е запис: това не е запис на Epic CosmosИстинската игра прави същото. В слот 3 на минаването стои нарочно развален запис, като ръчно вдигнати ресурси, и играта го отказва, без да падне и без да пипне текущата партия:
> slotsЗаписи (save N, load N)Слот 1: Ход 5, Земна империя, 2 планетиСлот 2: празенСлот 3: Ход 99, без пазач> load 3Записът е развален: сумата не съвпада (записана 123456,изчислена 285754014), файлът е пипан.Накрая канонът свързва двете: loadWorld хваща SaveError и връща или нов свят, или съобщение. Така екранът никога не вижда счупена игра.
// Връща { world, error }: или новият свят, или съобщение какво не е наред.public static LoadResult LoadWorld(string text){ try { return new LoadResult { World = ReadWorld(text), Error = null }; } catch (SaveError error) { return new LoadResult { World = null, Error = error.Message }; }}Идеята
Баланс: как знаеш, че играта е честна
В играта има три характера на изкуствения интелект: воин (цени нападението), строител (разширяването и строежа) и учен (науката и Звездната врата): характерът е набор от тегла за решенията на империята. Честни ли са? Ако един печели в 80% от партиите, останалите са украса. Да изиграеш хиляда партии на ръка не може, а компютърът го прави за секунди: играе сам срещу себе си.
--simulate=200 пуска двеста партии без екран, изкуствен интелект срещу изкуствен интелект. Всичко, което помага: играта не печата сама (екранът е отделно), случайността е със семе (резултатът е повторяем) и характерите се въртят по местата:
// Балансът: стотици партии без екран, изкуствен интелект срещу изкуствен интелект. Характерите се// въртят по местата, така че всеки играе еднакво често на всяка позиция; таблицата казва колко// партии печели всеки характер и как свършват партиите.static partial class Cosmos{ public static readonly string[] PERSONALITY_IDS = { "warlord", "builder", "scholar" }; public static readonly string[] VICTORY_KINDS = { "conquest", "science", "economy", "points" };
public static void Simulate(IGameIo io, int games, long seed) { io.Write(T("sim.title", ("games", games), ("seed", seed)) + "\n"); io.Write(T("sim.note1", ("turns", MAX_TURNS), ("goal", ECON_GOAL)) + "\n\n"); int[] played = { 0, 0, 0 }; int[] wins = { 0, 0, 0 }; int[] winTurns = { 0, 0, 0 }; int[] kinds = { 0, 0, 0, 0 }; int totalTurns = 0; int totalSystems = 0; for (int i = 0; i < games; i++) { List<string> order = new List<string>(); for (int k = 0; k < 4; k++) order.Add(PERSONALITY_IDS[(i + k) % 3]); World world = NewWorld(seed + i, false, order, true); totalSystems += world.Systems.Count; while (world.Victory == null) RunTurn(world); foreach (Empire empire in world.Empires) played[empire.Personality.Index]++; Empire winner = world.Empires[world.Victory.Empire]; wins[winner.Personality.Index]++; winTurns[winner.Personality.Index] += world.Victory.Turn; kinds[Array.IndexOf(VICTORY_KINDS, world.Victory.Kind)]++; totalTurns += world.Victory.Turn; } io.Write(Pad(T("sim.personality"), 20) + PadLeft(T("sim.played"), 10) + PadLeft(T("sim.wins"), 10) + PadLeft(T("sim.share"), 8) + PadLeft(T("sim.avgTurn"), 12) + "\n"); foreach (Personality personality in PERSONALITIES.Values()) { int i = personality.Index; int avg = wins[i] == 0 ? 0 : winTurns[i] / wins[i]; io.Write(Pad(Cap(Nm(personality)), 20) + PadLeft(played[i].ToString(), 10) + PadLeft(wins[i].ToString(), 10) + PadLeft(Percent(wins[i], played[i]) + "%", 8) + PadLeft(avg.ToString(), 12) + "\n"); } List<string> parts = new List<string>(); for (int k = 0; k < VICTORY_KINDS.Length; k++) parts.Add(T("victory." + VICTORY_KINDS[k]) + " " + kinds[k]); io.Write("\n" + T("sim.kinds", ("list", string.Join(", ", parts))) + "\n"); io.Write(T("sim.averages", ("turns", totalTurns / games), ("systems", totalSystems / games)) + "\n"); }}Въртенето (i + k) % 3 дава на всеки характер всяко място еднакво често. Иначе първото място (при равенство печели по-ранният) би качвало процента на онзи, който е винаги първи.
Малка версия на същата идея. Три характера с еднакъв среден приход, 30 на ход, но различно разсейване: воинът има късмет и пропуски, строителят е равен. Първият до 600 печели:
static int[] PlayGame(Random rng, int[] seats){ var produced = new int[seats.Length]; int turn = 0; while (true) { turn++; for (int k = 0; k < seats.Length; k++) { int p = seats[k]; produced[k] += BaseIncome[p] + rng.NextInt(Spread[p]); } for (int k = 0; k < seats.Length; k++) { if (produced[k] >= Goal) return new[] { seats[k], turn }; } }}int games = 200;var rng = new Random(7);var played = new int[3];var wins = new int[3];var winTurns = new int[3];int totalTurns = 0;for (int i = 0; i < games; i++){ var seats = new int[4]; for (int k = 0; k < 4; k++) { seats[k] = (i + k) % 3; played[seats[k]]++; } int[] result = PlayGame(rng, seats); wins[result[0]]++; winTurns[result[0]] += result[1]; totalTurns += result[1];}
string[] names = { "Воин", "Строител", "Учен" };Console.WriteLine("Симулация на баланса: " + games + " партии, семе 7, цел " + Goal + ".");Console.WriteLine();Console.WriteLine(Pad("Характер", 20) + PadLeft("Партии", 10) + PadLeft("Победи", 10) + PadLeft("Дял", 8) + PadLeft("Ср. ход", 12));for (int i = 0; i < 3; i++){ int share = wins[i] * 100 / played[i]; int avg = wins[i] == 0 ? 0 : winTurns[i] / wins[i]; Console.WriteLine(Pad(names[i], 20) + PadLeft("" + played[i], 10) + PadLeft("" + wins[i], 10) + PadLeft(share + "%", 8) + PadLeft("" + avg, 12));}Console.WriteLine();Console.WriteLine("Среден ход на победата: " + totalTurns / games + ".");Симулация на баланса: 200 партии, семе 7, цел 600.
Характер Партии Победи Дял Ср. ходВоин 267 79 29% 19Строител 267 46 17% 20Учен 266 75 28% 20
Среден ход на победата: 19.Средният приход е еднакъв, а строителят печели само 17% срещу 29% при воина и 28% при учения. Защо? В състезание до цел печели този, който в някой момент е най-напред, а не този с най-добра средна. Равният играч никога не е далеч от средата, но и рядко е пръв; разсеяният понякога се откъсва. Затова средното не е достатъчно, и затова се симулира: числото излиза само от играта.
Ето и истинската симулация на Epic Cosmos: двеста партии, семе 7, от simulate.bg.out. Файлът е част от проверката, както и минаването:
| Характер | Партии | Победи | Дял | Среден ход |
|---|---|---|---|---|
| Воин | 223 | 61 | 27% | 47 |
| Строител | 220 | 59 | 26% | 57 |
| Учен | 213 | 80 | 37% | 56 |
Победи по вид: завладяване 50, наука 90, икономика 60, точки 0. Средният край на партията е на ход 53, а в галактика има средно 15 системи.
Какво казва таблицата? Ученият печели най-често (37%), воинът и строителят са близо. Точки: 0 от 200. Нито една партия не стига ход 150, защото някой завладява, проучва вратата или произвежда 9800 много по-рано. Срокът е предпазна мрежа, не победа, която изкуственият интелект избира. Това е добър знак: точките са за партия, в която никой не прави нищо.
Балансът се настройва с числата в данните (цена на технология, приход от мина, сила на кораб), като след всяка промяна пускаш симулацията отново. Ако ученият е 37%, пробваш по-скъпа Звездна врата и гледаш как се движи таблицата.
По-точно, за любопитните
Симулацията е и тест на играта. Две двеста партии със същото семе трябва да дадат един и същ файл, и това е проверката: simulate.bg.out се сравнява байт по байт на четирите езика. Ако порт има разлика в един зар, партията тръгва другояче и таблицата се различава. Така една таблица от стотици партии хваща грешки, които минаването от 192 команди не стига.
Пробвай
Играй
Това е цялата игра, JavaScript версията на канона, пусната на страницата. Ти си Земната империя; на картата са системите и пътищата между тях. Три начина да спечелиш: проучи Звездната врата, произведи 9800 или остави на съперниците да се унищожат. Колкото по-рано, толкова по-добре, но срокът е ход 150.
Epic Cosmos: империята
Четири империи, една галактика и сто и петдесет хода. Победата е наука, икономика или завладяване.
Пиши командите на реда отдолу и натисни Enter (help показва всички; празен ред е map). Записите остават в този браузър.
Команди (пиши ги на реда под екрана): help показва всички, map е картата, sys 3 е система 3, tech е дървото на технологиите, r stargate проучва Звездната врата, b mine 2 строи мина на планета 2, ship i 2 1 строи два прехващача, go 1 3 праща флота, emp са империите, а end и wait 3 довършват хода (или до три хода). save 1, load 1 и slots работят със записите: те остават в браузъра.
Пробвай всичко от урока: запиши в слот 1, играй още малко, зареди слот 1 и виж, че си се върнал. Погледни emp: колко точки има всеки. Кара ли те играта да искаш друга цена на технология? Това е балансът.
Идеята
Пусни я на компютъра
Целият файл е тук: [Cosmos.java](/games/cosmos/Cosmos.java), [Cosmos.cs](/games/cosmos/Cosmos.cs), [cosmos.py](/games/cosmos/cosmos.py), [cosmos.js](/games/cosmos/cosmos.js). Сложи го в празна папка и го пусни:
dotnet new console -n Cosmoscd Cosmos# смени Program.cs с Cosmos.csdotnet runС --plain печата кадрите като обикновен текст, с --seed=42 галактиката е винаги една и съща, с --lang=en е на английски (има и de и es). Симулацията, която видя, е --simulate=200 (с --seed=7 дава същата таблица). Записите са във файлове в папка saves до играта, а --memory-saves ги държи в паметта.
Main в Cosmos.Играта е написана четири пъти със същите имена и дава байт по байт един и същ изход на всички езици. Проверката на сайта пуска всяка версия със записаното минаване (192 команди) и със симулацията и ги сравнява с един файл. Затова можеш да четеш играта на езика, който знаеш, и да си сигурен, че е същата.
Идеята
Идеи за продължение
Играта е твоя: имаш целия код. Ето неща, които са точно по мярка и всяко ползва нещо от този урок.
- Дипломатическа победа. Нова проверка в
checkVictory: мир с всички живи империи за 20 хода подред. Къде в реда трябва да е? Преди или след икономиката? Сложи я и пусни симулацията, за да видиш колко партии свършват така. - Автозапис. При всяко
endзаписвай в слот 0. Какво става, ако играта падне посред запис? (Подсказка: пиши във временен файл и го преименувай.) - Нова версия на записа. Добави ключ (например
difficulty) и вдигни версията на 2. Зареждането да чете и стара версия 1, като слага стойност по подразбиране. Така минава от версия към версия всяка игра. - Запис на партията като команди. Семето и списъкът с командите са цялата партия: това е самото минаване, с което проверяваш играта. Направи
replay <файл>. - Баланс по твой вкус. Пусни
--simulate=1000с различни числа за цена на технология и приход и опиши в таблица кое е най-честно. - Друга галактика. Карти от файл вместо от семе, или по-голяма галактика (мащабът на пътищата, графът от първия урок).
- Играч срещу играч по мрежа. Командите вече са текст по един ред: ходовете могат да пътуват по сокет. Играта не се променя, променя се само откъде идва редът.
- Нов език. Пренеси играта на Go, Rust или Kotlin и пусни същото минаване: изходът трябва да е същият файл.
Пробвай
Предвиди изхода
Какво печата програмата?
static long Checksum(string text){ long sum = 0; for (int i = 0; i < text.Length; i++) { sum = (sum * 31 + text[i]) % 1000000007; } return sum;}
Console.WriteLine(Checksum("ab") + ", " + Checksum("ba") + ", " + Checksum("abc"));„ab“ е 97 × 31 + 98 = 3105, „ba“ е 98 × 31 + 97 = 3135, а „abc“ е 3105 × 31 + 99 = 96354. Втората възможност е за контролна сума, която не различава реда (но тази умножава с 31, затова „ab“ и „ba“ са различни). Третата е сборът на кодовете (195, 195, 294): така би се получило, ако нямаше умножение по 31.
Пробвай
Намери грешката
Колега реши да „поправи“ един ред в bestScoring. Двете империи имат еднакви точки, а програмата печата, че печели по-късната (Хегемония Орион). Правилото е да печели по-ранната. Кой ред е виновен?
Избери реда с грешката.
>= вместо >: при равенство новият става водач, затова печели последният от равните, не първият. С > сравнението е строго и равният не измества водача. Грешката не се вижда при различни точки, затова се лови трудно: проявява се само при равенство.
Упражнение
Контролна сума на всеки ред
Реши задачата в редактора си.
Контролна сума на всеки ред. Програмата чете редове до края на входа и за всеки непразен ред печата реда, интервал и контролната му сума (по формулата от урока, остатък по 1000000007).
С вход turn=12 | turn=13 | seed=2026 | COSMOS (по един на ред) изходът е:
turn=12 408655515turn=13 408655516seed=2026 801283199COSMOS 993660451Провери с „ab“ и „ba“: 3105 и 3135. Внимавай с междинния резултат sum * 31: в Java и C# трябва да е long, иначе препълва за по-дълъг ред.
Още задачи
- Нова победа. Добави дипломатическа победа към програмата
victory(мир с всички живи империи 20 хода подред). Къде в реда да е? - Версия 2. Добави в записа ключ
difficultyи вдигниVERSIONна 2. Некаloadприема и версия 1 със стойност по подразбиране 1. Какво става с контролната сума на стар запис? - Равен баланс. В програмата
balanceподбери числата на строителя (приход и разсейване) така, че и трите характера да печелят между 30% и 36% от партиите, като средният приход остане 30. - Хващане на втори запис. Какво става, ако в записа два реда имат един и същ ключ? Прочети
SaveReader: коя стойност остава? Напиши проверка, която го отказва.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Изключения · Хвърляне и свои изключения · Списъци · Dictionary и HashSet
- Задачи по темата: Epic Cosmos: империята