Към съдържанието
wannacode

Цялата стратегия

Накратко: Играта свършва с първата от пет проверки, която каже „да“: завладяване, поражение, наука, икономика или срок (точки). Записът е текст „ключ=стойност“ с версия и контролна сума, зареждането проверява всичко, преди да пипне играта, а балансът се мери със стотици партии без екран.

Проблем

Кога свършва играта?

Пет урока строихме части: галактика от семе, дърво на технологиите, флоти и битки, съперници с характер и ход с фази и събития. Но играта още няма край. Ако никой никога не победи, партията не свършва, а ако победата е лоша, играчът играе сто и петдесет хода и разбира, че всичко е било решено в първите двайсет.

Този урок затваря модула с три неща, на които стои всяка цяла игра: победа (кога и как свършва партията), запис (как се продължава утре) и баланс (как знаеш, че играта е честна, без да я изиграеш хиляда пъти на ръка). После играта е тук, цяла.

Ето как свършва минаването, с което се проверява играта на четирите езика. Земната империя проучава Звездната врата на ход 59, а екранът на края показва класирането:

КРАЙ НА ИГРАТА
Земна империя проучи Звездната врата на ход 59.
Класиране (планети, технологии, точки):
A Земна империя 7 13 376
C Съюз Лира 0 3 38 унищожена
D Академия Вега 7 12 319
B Хегемония Орион 2 8 154
Битки 27, загубени единици 159, колонии 12, технологии 36,
събития 27.
(Enter: изход)

Три неща се виждат в този екран: вид на победата (наука), класиране (планети, технологии, точки на всяка империя; Съюз Лира е унищожена) и статистика на партията. Те идват от три малки функции, които ще видиш тук.

Идеята

Четири победи, един ред от проверки

Играта има четири начина да свърши и още един, за да не виси вечно:

  1. Завладяване: остава жива само една империя.
  2. Поражение: играчът е унищожен (партията свършва, а най-силният от останалите се записва като победител).
  3. Наука: някоя жива империя е проучила Звездната врата, технологията в края на дървото.
  4. Икономика: някоя жива империя е произвела общо 9800.
  5. Срок: дошъл е ход 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;
}
Стъпка по стъпка, с думи
  1. След всеки ход светът пита пет въпроса по ред: останала ли е една империя, унищожен ли е играчът, има ли Звездна врата, има ли 9800 произведено, дошъл ли е ход 150. Първото „да“ решава.
  2. Ход 40. И трите империи са живи, никой няма Звездна врата, никой не е стигнал 9800.
  3. Пет пъти „не“. checkVictory връща null и играта върви към следващия ход.
  4. Академия Вега: Звездната врата е проучена и 9800 са произведени, и двете в един и същ ход. Две победи наведнъж.
  5. Третата проверка (наука) казва „да“ и връща веднага. Четвъртата, икономиката, не се стига: редът на проверките решава кой печели.
  6. Хегемония Орион и Академия Вега са унищожени. Остава една империя: Земна империя.
  7. Първата проверка казва „да“: завладяване. Четирите след нея не се изпълняват.
  8. Ход 150: срокът. Никой не е победил по друг начин.
  9. Четири „не“ и накрая срокът: печели този с най-много точки. Земна империя и Хегемония Орион имат равни точки, а равенството го печели по-ранният.

Защо първото „да“ връща веднага? Защото после няма какво да се решава: победата е една на партия. Но има и практическа причина. Ако четвъртата проверка се изпълнеше след третата, функцията трябваше да избира между две победи, а това е ново правило. С ранния изход правилото е редът, и се чете направо от кода: сменяш два реда и си сменил кой печели при равенство на условията.

По-точно, за любопитните

Тук победата се проверява веднъж на ход, след като всичко друго в хода е свършило. Така играчът, който е стигнал 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=COSMOS
version=1
seed=2026
turn=12
empires=3
empire.0=5,3120,9
empire.1=4,2750,7
empire.2=3,4180,10
checksum=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 -> 3105
ba -> 3135
turn=12 -> 408655515
turn=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;
}
По-точно, за любопитните

Контролната сума хваща грешки, не подправяне. Който знае формулата, променя стойност и преизчислява сумата (програмата по-долу прави точно това с 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. Файлът е част от проверката, както и минаването:

ХарактерПартииПобедиДялСреден ход
Воин2236127%47
Строител2205926%57
Учен2138037%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 Cosmos
cd Cosmos
# смени Program.cs с Cosmos.cs
dotnet 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"));
Отговор

Пробвай

Намери грешката

Колега реши да „поправи“ един ред в bestScoring. Двете империи имат еднакви точки, а програмата печата, че печели по-късната (Хегемония Орион). Правилото е да печели по-ранната. Кой ред е виновен?

Избери реда с грешката.

Упражнение

Контролна сума на всеки ред

Реши задачата в редактора си.

Контролна сума на всеки ред. Програмата чете редове до края на входа и за всеки непразен ред печата реда, интервал и контролната му сума (по формулата от урока, остатък по 1000000007).

С вход turn=12 | turn=13 | seed=2026 | COSMOS (по един на ред) изходът е:

Изход
turn=12 408655515
turn=13 408655516
seed=2026 801283199
COSMOS 993660451

Провери с „ab“ и „ba“: 3105 и 3135. Внимавай с междинния резултат sum * 31: в Java и C# трябва да е long, иначе препълва за по-дълъг ред.

Още задачи

  • Нова победа. Добави дипломатическа победа към програмата victory (мир с всички живи империи 20 хода подред). Къде в реда да е?
  • Версия 2. Добави в записа ключ difficulty и вдигни VERSION на 2. Нека load приема и версия 1 със стойност по подразбиране 1. Какво става с контролната сума на стар запис?
  • Равен баланс. В програмата balance подбери числата на строителя (приход и разсейване) така, че и трите характера да печелят между 30% и 36% от партиите, като средният приход остане 30.
  • Хващане на втори запис. Какво става, ако в записа два реда имат един и същ ключ? Прочети SaveReader: коя стойност остава? Напиши проверка, която го отказва.

Тест

Провери се

1. Една империя е проучила Звездната врата и в същия ход е стигнала 9800 произведено. Коя победа се отчита?
2. В края на ход 150 две живи империи имат по 219 точки. Коя печели?
3. Защо контролната сума е sum × 31 + код на знака, а не просто сбор на кодовете?
4. Някой промени ръчно стойност в записа и сам преизчисли контролната сума. Какво прави зареждането?
5. Защо симулацията върти характерите по местата ((i + k) % 3), вместо да ги слага винаги в един и същ ред?
6. Симулацията на Cosmos показва „точки 0“ от 200 партии. Какво значи това?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·