Цялата стратегия
Накратко: Играта свършва с първата от пет проверки, която каже „да“: завладяване, поражение, наука, икономика или срок (точки). Записът е текст „ключ=стойност“ с версия и контролна сума, зареждането проверява всичко, преди да пипне играта, а балансът се мери със стотици партии без екран.
Проблем
Кога свършва играта?
Пет урока строихме части: галактика от семе, дърво на технологиите, флоти и битки, съперници с характер и ход с фази и събития. Но играта още няма край. Ако никой никога не победи, партията не свършва, а ако победата е лоша, играчът играе сто и петдесет хода и разбира, че всичко е било решено в първите двайсет.
Този урок затваря модула с три неща, на които стои всяка цяла игра: победа (кога и как свършва партията), запис (как се продължава утре) и баланс (как знаеш, че играта е честна, без да я изиграеш хиляда пъти на ръка). После играта е тук, цяла.
Ето как свършва минаването, с което се проверява играта на четирите езика. Земната империя проучава Звездната врата на ход 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, печели този# с най-много точки. Ако играчът е унищожен, играта свършва с поражение.def empire_score(world, empire): return len(owned_planets(world, empire.id)) * 10 + len(empire.techs) * 6 + empire_power(world, empire.id) // 10 + empire.produced // 40
def best_scoring(world): best = None best_score = -1 for e in world.empires: if not e.alive: continue score = empire_score(world, e) if score > best_score: best_score = score best = e return best
def check_victory(world): if world.victory is not None: return if world.living_count() == 1: for e in world.empires: if e.alive: world.victory = SimpleNamespace(empire=e.id, kind='conquest', turn=world.turn) return for e in world.empires: if e.human and not e.alive: world.victory = SimpleNamespace(empire=best_scoring(world).id, kind='lost', turn=world.turn) return for e in world.empires: if e.alive and knows(e, 'stargate'): world.victory = SimpleNamespace(empire=e.id, kind='science', turn=world.turn) return for e in world.empires: if e.alive and e.produced >= ECON_GOAL: world.victory = SimpleNamespace(empire=e.id, kind='economy', turn=world.turn) return if world.turn >= MAX_TURNS: world.victory = SimpleNamespace(empire=best_scoring(world).id, kind='points', turn=world.turn)checkVictory не е една проверка, а пет в точен ред, и първата, която каже „да“, решава. Това е ранен изход: щом знаеш отговора, връщаш. Редът не е случаен. Ако е жива само една империя, няма смисъл да питаш за вратата. Ако играчът е унищожен, партията е загубена, колкото и да е произвел някой. А срокът е последен, защото е най-слабата победа.
Малката програма прави същото със същите правила, но с три империи и без галактика. Империята знае колко точки е:
class Empire: def __init__(self, name, human, planets, techs, power, produced): self.name = name self.human = human self.alive = True self.stargate = False self.planets = planets self.techs = techs self.power = power self.produced = produced
def score(self): return self.planets * 10 + self.techs * 6 + self.power // 10 + self.produced // 40class Victory: def __init__(self, kind, winner): self.kind = kind self.winner = winnerПроверките са същите пет, в същия ред (ECON_GOAL е 9800, MAX_TURNS е 150):
def check_victory(empires, turn): living = [e for e in empires if e.alive] if len(living) == 1: return Victory("завладяване", living[0].name) for e in empires: if e.human and not e.alive: return Victory("поражение", best_scoring(empires).name) for e in empires: if e.alive and e.stargate: return Victory("наука", e.name) for e in empires: if e.alive and e.produced >= ECON_GOAL: return Victory("икономика", e.name) if turn >= MAX_TURNS: return Victory("точки", best_scoring(empires).name) return NoneСедем света, седем отговора. Последните два показват най-важното: унищожен играч дава поражение, а ход 150 без друга победа дава точки.
game = fresh()show("ход 40, нищо не е свършило", game, 40)
game = fresh()game[2].stargate = Trueshow("проучена Звездна врата", game, 40)
game = fresh()game[1].produced = ECON_GOALshow("произведено 9800", game, 40)
game = fresh()game[2].stargate = Truegame[2].produced = ECON_GOALshow("и врата, и 9800 в един ход", game, 40)
game = fresh()game[1].alive = Falsegame[2].alive = Falseshow("останала е една империя", game, 40)
game = fresh()game[0].alive = Falseshow("играчът е унищожен", game, 40)
show("ход 150, без друга победа", fresh(), 150)ход 40, нищо не е свършило: -проучена Звездна врата: наука (Академия Вега)произведено 9800: икономика (Хегемония Орион)и врата, и 9800 в един ход: наука (Академия Вега)останала е една империя: завладяване (Земна империя)играчът е унищожен: поражение (Хегемония Орион)ход 150, без друга победа: точки (Земна империя)Всеки случай започва от една и съща свежа игра и променя едно-две неща. Четвъртият е хитрият: Академия Вега е проучила Звездната врата и е произвела 9800 в един и същ ход. Печели науката, защото е по-рано в реда.
Виж го
Първото „да“ решава
Четири от тези случаи в движение. Следи кои проверки се изпълняват и кои потъмняват, защото никога не стигат до тях:
def check_victory(empires, turn): living = [e for e in empires if e.alive] if len(living) == 1: return Victory("завладяване", living[0].name) for e in empires: if e.human and not e.alive: return Victory("поражение", best_scoring(empires).name) for e in empires: if e.alive and e.stargate: return Victory("наука", e.name) for e in empires: if e.alive and e.produced >= ECON_GOAL: return Victory("икономика", e.name) if turn >= MAX_TURNS: return Victory("точки", best_scoring(empires).name) return NoneСтъпка по стъпка, с думи
- След всеки ход светът пита пет въпроса по ред: останала ли е една империя, унищожен ли е играчът, има ли Звездна врата, има ли 9800 произведено, дошъл ли е ход 150. Първото „да“ решава.
- Ход 40. И трите империи са живи, никой няма Звездна врата, никой не е стигнал 9800.
- Пет пъти „не“. checkVictory връща null и играта върви към следващия ход.
- Академия Вега: Звездната врата е проучена и 9800 са произведени, и двете в един и същ ход. Две победи наведнъж.
- Третата проверка (наука) казва „да“ и връща веднага. Четвъртата, икономиката, не се стига: редът на проверките решава кой печели.
- Хегемония Орион и Академия Вега са унищожени. Остава една империя: Земна империя.
- Първата проверка казва „да“: завладяване. Четирите след нея не се изпълняват.
- Ход 150: срокът. Никой не е победил по друг начин.
- Четири „не“ и накрая срокът: печели този с най-много точки. Земна империя и Хегемония Орион имат равни точки, а равенството го печели по-ранният.
Защо първото „да“ връща веднага? Защото после няма какво да се решава: победата е една на партия. Но има и практическа причина. Ако четвъртата проверка се изпълнеше след третата, функцията трябваше да избира между две победи, а това е ново правило. С ранния изход правилото е редът, и се чете направо от кода: сменяш два реда и си сменил кой печели при равенство на условията.
По-точно, за любопитните
Тук победата се проверява веднъж на ход, след като всичко друго в хода е свършило. Така играчът, който е стигнал 9800, не печели по средата на хода, преди съперниците му да са изиграли своя. В региона turn на канона checkVictory(world) е последната фаза на хода, след производството, науката и събитието.
Играта пази победата в света (world.victory), а не я връща: екранът на края, записът и симулацията четат едно и също място. Така същата проверка върши работа и за играч, и за симулация без екран.
Идеята
Точките: равенство и унищожените
Срокът е най-простото от четирите, но има две скрити правила. Първо, унищожените империи не се броят: империя с много точки, която вече е изчезнала, не бива да печели. Второ, равенството: две империи с еднакви точки трябва да имат предвидим победител, иначе симулацията не е детерминистична.
def best_scoring(empires): best = None best_score = -1 for e in empires: if not e.alive: continue score = e.score() if score > best_score: best_score = score best = e return bestbestScore започва от −1, за да може и империя с нула точки да стане водач. Сравнението е строго (>), затова при равенство остава по-ранният. Програмата сравнява четири империи, една унищожена и две с еднакви точки:
rivals = [ Empire("Земна империя", False, 5, 9, 400, 3000), Empire("Хегемония Орион", False, 3, 11, 450, 3120), Empire("Академия Вега", False, 4, 8, 300, 2000),]lyra = Empire("Съюз Лира", False, 8, 14, 900, 6000)lyra.alive = Falserivals.append(lyra)
for e in rivals: note = "" if e.alive else " (унищожена)" print(e.name + ": " + str(e.score()) + note)print("Най-много точки: " + best_scoring(rivals).name)Земна империя: 219Хегемония Орион: 219Академия Вега: 168Съюз Лира: 404 (унищожена)Най-много точки: Земна империяСъюз Лира има най-много точки (404), но е унищожена и отпада. Земна империя и Хегемония Орион са на 219, а победител е първата. Деленията са целочислени, както навсякъде в играта (// в Python), и по тях точките са еднакви на четирите езика.
Идеята
Запис: текст „ключ=стойност“
Играч, който затваря играта на ход 80, иска утре да продължи от ход 80. Записът е четене на света обратно, и има три въпроса: какво се записва, в какъв вид и как се знае, че файлът е цял.
Какво. Не всичко. Галактиката (системи, пътища, видове планети) се прави от семето: същото семе, същата галактика (урок 1 от модула). Затова записът пази само нещата, които се променят: чия е всяка планета, какво е построено, ресурсите, технологиите, флотовете, опашката за строеж, мира и войната и състоянието на генератора, за да продължат заровете от същото място.
Във вид. Текст, ред по ред, ключ=стойност. Може да се прочете с поглед, да се сравни с друг запис, да се поправи на ръка в краен случай и форматът не е вързан за езика на играта: същият текст може да го пише и чете версия на всеки език. Първият ред е заглавие (format=COSMOS), вторият е версия на формата, а последният е контролна сума на всичко над него.
def save_world(world): lines = []
def put(key, value): lines.append(key + '=' + num_str(value))
put('format', SAVE_FORMAT) put('version', SAVE_VERSION) put('summary', save_summary(world)) put('seed', world.seed) put('rng', world.rng.state) put('turn', world.turn) put('systems', len(world.systems)) put('planets', len(world.planets)) put('empires', len(world.empires)) for planet in world.planets: put('planet.' + str(planet.id), join_ints([planet.owner, planet.mines, planet.lab, planet.yard, planet.fort, planet.siege] + planet.rich, ',')) for empire in world.empires: prefix = 'empire.' + str(empire.id) put(prefix, join_ints([empire.def_.id, empire.personality.id, 1 if empire.alive else 0, empire.progress, empire.produced, empire.flare] + empire.res, ',')) put(prefix + '.researching', '-' if empire.researching == '' else empire.researching) put(prefix + '.techs', join_or_dash(empire.techs)) put(prefix + '.war', join_ints(empire.war, ',')) put(prefix + '.since', join_ints(empire.since, ',')) put(prefix + '.grudge', join_ints(empire.grudge, ',')) orders = [] for order in empire.orders: orders.append(join_ints([order.from_, order.to, order.dest] + order.ships, ':')) put(prefix + '.orders', join_or_dash(orders)) put('fleets', len(world.fleets)) for i in range(len(world.fleets)): fleet = world.fleets[i] put('fleet.' + str(i), join_ints([fleet.owner, fleet.system_id] + fleet.ships, ',')) put('queue', len(world.queue)) for i in range(len(world.queue)): item = world.queue[i] put('queue.' + str(i), join_ints([item.owner, item.planet_id, item.kind, item.count, item.left], ',')) offers = [] for offer in world.offers: offers.append(str(offer.from_) + ':' + str(offer.to) + ':' + str(offer.turn)) put('offers', join_or_dash(offers)) s = world.stats put('stats', join_ints([s.battles, s.lost, s.colonies, s.techs, s.events], ',')) body = '\n'.join(lines) + '\n' return body + 'checksum=' + str(checksum(body)) + '\n'Малката програма записва същото в малко: семе, ход и три империи (планети, произведено, технологии), с версия и контролна сума в края:
class Game: def __init__(self, seed, turn): self.seed = seed self.turn = turn self.empires = []def save(game): lines = ["format=COSMOS", "version=" + str(VERSION)] lines.append("seed=" + str(game.seed)) lines.append("turn=" + str(game.turn)) lines.append("empires=" + str(len(game.empires))) for i, e in enumerate(game.empires): lines.append("empire." + str(i) + "=" + ",".join(str(x) for x in e)) body = "\n".join(lines) + "\n" return body + "checksum=" + str(checksum(body)) + "\n"print(save(sample()), end="")format=COSMOSversion=1seed=2026turn=12empires=3empire.0=5,3120,9empire.1=4,2750,7empire.2=3,4180,10checksum=987468948Ето записа, ред по ред. Версията стои там не за днес, а за утре: когато формата се промени (нов ключ, друг ред), старите записи трябва да се познаят и да се откажат, или да се преобразуват. Без версия един запис от стара версия би се прочел погрешно и тихо.
Идеята
Контролна сума: една промяна и сумата не съвпада
Последният ред на записа е числото, което се получава от всичко над него. При зареждане играта го смята наново и го сравнява със записаното: ако файлът е отрязан, развален или пипнат на ръка, числата са различни. Алгоритъмът е малък:
def checksum(text): total = 0 for i in range(len(text)): total = (total * 31 + ord(text[i])) % 1000000007 return totalЗа всеки знак сумата се умножава по 31, добавя се кодът на знака и се взема остатък по 1000000007. Умножението по 31 е важното: така редът има значение. Сборът на кодовете би дал едно и също число за „ab“ и „ba“, а разменени две цифри в записа са точно онази грешка, която искаме да хванем.
texts = ["ab", "ba", "turn=12", "turn=21"]for word in texts: print(word + " -> " + str(checksum(word)))ab -> 3105ba -> 3135turn=12 -> 408655515turn=21 -> 408655545„ab“ е 97 × 31 + 98 = 3105, а „ba“ е 98 × 31 + 97 = 3135. Остатъкът държи числото под милярд, така че да се събира в 32 бита, а Python няма препълване.
def checksum(text):
total = 0
for ch in text:
total = (total * 31 + ord(ch)) 1000000007
return totalОстатъкът (%) връща сумата в границите под 1000000007 след всеки знак. Без него числото ще расте толкова, колкото е дълъг записът.
По-точно, за любопитните
Контролната сума хваща грешки, не подправяне. Който знае формулата, променя стойност и преизчислява сумата (програмата по-долу прави точно това с reseal, за да провери другите си защити). За игра, която се пази на твоя диск, това е достатъчно: целта е да не заредиш половин запис. За състезателна класация на сървър е нужно друго: подпис с таен ключ (HMAC), който играчът няма как да смята.
Алгоритъмът е от семейството на „полиномиалните сборове“, същият като hashCode на низ в Java. За истински хешове (SHA-256) има готови библиотеки, но играта пази 32-битова проверка без нищо отвън: тя се чете с поглед и дава еднакви числа на четирите езика.
Идеята
Зареждане, което не вярва на никого
Записът идва от диска, а дискът може да върне всичко: отрязан файл, запис от друга версия на играта, файл, редактиран в бележник. Ако играта го чете с вяра, един лош запис я срива или (по-лошо) я оставя в половин състояние, което се вижда чак на ход 90. Затова зареждането проверява всичко, преди да пипне играта, и казва точно какво не е наред.
class SaveReader: def __init__(self, text): self.values = {} lines = text.split('\n') while len(lines) > 0 and len(lines[-1]) == 0: lines.pop() if len(lines) == 0 or lines[0] != 'format=' + SAVE_FORMAT: raise SaveError(t('load.badHeader')) for i in range(len(lines)): eq = lines[i].find('=') if eq <= 0: raise SaveError(t('load.badLine', {'line': i + 1})) self.values[lines[i][:eq]] = lines[i][eq + 1:] version = self.text('version') if version != str(SAVE_VERSION): raise SaveError(t('load.badVersion', {'found': version, 'expected': SAVE_VERSION})) last = lines[-1] if not last.startswith('checksum='): raise SaveError(t('load.missing', {'key': 'checksum'})) body = '\n'.join(lines[:-1]) + '\n' actual = checksum(body) if parse_int_strict(last[9:]) != actual: raise SaveError(t('load.badChecksum', {'expected': last[9:], 'actual': actual}))
def text(self, key): if key not in self.values: raise SaveError(t('load.missing', {'key': key})) return self.values[key]
# Число в граници [low, high]. def integer(self, key, low, high): value = parse_int_strict(self.text(key)) if value is None or value < low or value > high: raise SaveError(t('load.badValue', {'key': key})) return value
# Списък от точно count числа, всяко в границите си. def ints(self, key, count, low, high): parts = self.text(key).split(',') if len(parts) != count: raise SaveError(t('load.badValue', {'key': key})) result = [] for part in parts: value = parse_int_strict(part) if value is None or value < low or value > high: raise SaveError(t('load.badValue', {'key': key})) result.append(value) return result
def items(self, key): return split_list(self.text(key))Редът на проверките е важен: заглавие, всеки ред да е ключ=стойност, версия, контролна сума, и чак тогава стойностите. Всяка стойност се чете през int или ints, които проверяват границите (ход от 1 до 150, брой империи от 1 до 9). Малката версия е същата в малко:
class SaveReader: def __init__(self, text): self.values = {} lines = text.split("\n") while len(lines) > 0 and len(lines[-1]) == 0: lines.pop() if len(lines) == 0 or lines[0] != "format=COSMOS": raise SaveError("това не е запис на Epic Cosmos") for i, line in enumerate(lines): eq = line.find("=") if eq <= 0: raise SaveError("ред " + str((i + 1)) + " не е „ключ=стойност“") self.values[line[:eq]] = line[eq + 1:] version = self.text("version") if version != str(VERSION): raise SaveError("версия " + version + ", а играта чете версия " + str(VERSION)) last = lines[-1] if not last.startswith("checksum="): raise SaveError("липсва „" + "checksum" + "“") body = "\n".join(lines[:-1]) + "\n" actual = checksum(body) if digits(last[9:]) != actual: raise SaveError("сумата не съвпада (записана " + last[9:] + ", изчислена " + str(actual) + "), файлът е пипан")
def text(self, key): if key not in self.values: raise SaveError("липсва „" + key + "“") return self.values[key]
def integer(self, key, low, high): value = digits(self.text(key)) if value < low or value > high: raise SaveError("грешна стойност на „" + key + "“") return value
def numbers(self, key, count, low, high): parts = self.text(key).split(",") if len(parts) != count: raise SaveError("грешна стойност на „" + key + "“") result = [] for part in parts: value = digits(part) if value < low or value > high: raise SaveError("грешна стойност на „" + key + "“") result.append(value) return resultdef load(text): reader = SaveReader(text) game = Game(reader.integer("seed", 0, 99999), reader.integer("turn", 1, MAX_TURNS)) count = reader.integer("empires", 1, 9) for i in range(count): game.empires.append(reader.numbers("empire." + str(i), 3, 0, 99999)) return gameload създава нова игра от прочетеното и връща нея, без да пипа текущата. Грешката е изключение SaveError, което носи думите за играча. Този, който вика, я хваща и показва, а играта продължава със стария свят:
def show_load(label, text): try: game = load(text) produced = 0 for e in game.empires: produced += e[1] print(label + ": " + "ход " + str(game.turn) + ", империи " + str(len(game.empires)) + ", произведено " + str(produced)) except SaveError as error: print(label + ": " + str(error))Шест записа, шест отговора. Два от тях са преправени така, че сумата да мине (затова проверката на границите и на ключовете е нужна), а един е пипнат на ръка и сумата го хваща:
good = save(sample())body = good[:good.index("checksum=")]show_load("цял запис", good)show_load("пипан на ръка", good.replace("3120", "99999"))show_load("друга версия", good.replace("version=1", "version=2"))show_load("число извън границите", reseal(body.replace("turn=12", "turn=999")))show_load("липсва ред", reseal(body.replace("seed=2026\n", "")))show_load("не е запис", "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 }: или новият свят, или съобщение какво не е наред.def load_world(text): try: return SimpleNamespace(world=read_world(text), error=None) except SaveError as error: return SimpleNamespace(world=None, error=str(error))Идеята
Баланс: как знаеш, че играта е честна
В играта има три характера на изкуствения интелект: воин (цени нападението), строител (разширяването и строежа) и учен (науката и Звездната врата): характерът е набор от тегла за решенията на империята. Честни ли са? Ако един печели в 80% от партиите, останалите са украса. Да изиграеш хиляда партии на ръка не може, а компютърът го прави за секунди: играе сам срещу себе си.
--simulate=200 пуска двеста партии без екран, изкуствен интелект срещу изкуствен интелект. Всичко, което помага: играта не печата сама (екранът е отделно), случайността е със семе (резултатът е повторяем) и характерите се въртят по местата:
# Балансът: стотици партии без екран, изкуствен интелект срещу изкуствен интелект. Характерите се# въртят по местата, така че всеки играе еднакво често на всяка позиция; таблицата казва колко# партии печели всеки характер и как свършват партиите.PERSONALITY_IDS = ['warlord', 'builder', 'scholar']VICTORY_KINDS = ['conquest', 'science', 'economy', 'points']
def simulate(io, games, seed): io.write(t('sim.title', {'games': num_str(games), 'seed': num_str(seed)}) + '\n') io.write(t('sim.note1', {'turns': MAX_TURNS, 'goal': ECON_GOAL}) + '\n\n') played = [0, 0, 0] wins = [0, 0, 0] win_turns = [0, 0, 0] kinds = [0, 0, 0, 0] total_turns = 0 total_systems = 0 i = 0 while i < games: order = [] for k in range(4): order.append(PERSONALITY_IDS[(i + k) % 3]) world = new_world(seed + i, False, order, True) total_systems += len(world.systems) while world.victory is None: run_turn(world) for empire in world.empires: played[empire.personality.index] += 1 winner = world.empires[world.victory.empire] wins[winner.personality.index] += 1 win_turns[winner.personality.index] += world.victory.turn kinds[VICTORY_KINDS.index(world.victory.kind)] += 1 total_turns += world.victory.turn i += 1 io.write(pad(t('sim.personality'), 20) + pad_left(t('sim.played'), 10) + pad_left(t('sim.wins'), 10) + pad_left(t('sim.share'), 8) + pad_left(t('sim.avgTurn'), 12) + '\n') for personality in PERSONALITIES.values(): i = personality.index avg = 0 if wins[i] == 0 else win_turns[i] // wins[i] io.write(pad(cap(nm(personality)), 20) + pad_left(str(played[i]), 10) + pad_left(str(wins[i]), 10) + pad_left(str(percent(wins[i], played[i])) + '%', 8) + pad_left(str(avg), 12) + '\n') parts = [] for k in range(len(VICTORY_KINDS)): parts.append(t('victory.' + VICTORY_KINDS[k]) + ' ' + str(kinds[k])) io.write('\n' + t('sim.kinds', {'list': ', '.join(parts)}) + '\n') io.write(t('sim.averages', {'turns': math.floor(total_turns / games), 'systems': math.floor(total_systems / games)}) + '\n')Въртенето (i + k) % 3 дава на всеки характер всяко място еднакво често. Иначе първото място (при равенство печели по-ранният) би качвало процента на онзи, който е винаги първи.
Малка версия на същата идея. Три характера с еднакъв среден приход, 30 на ход, но различно разсейване: воинът има късмет и пропуски, строителят е равен. Първият до 600 печели:
def play_game(rng, seats): produced = [0] * len(seats) turn = 0 while True: turn += 1 for k in range(len(seats)): p = seats[k] produced[k] += BASE[p] + rng.next_int(SPREAD[p]) for k in range(len(seats)): if produced[k] >= GOAL: return [seats[k], turn]games = 200rng = Random(7)played = [0, 0, 0]wins = [0, 0, 0]win_turns = [0, 0, 0]total_turns = 0for i in range(games): seats = [(i + k) % 3 for k in range(4)] for p in seats: played[p] += 1 winner, turn = play_game(rng, seats) wins[winner] += 1 win_turns[winner] += turn total_turns += turn
names = ["Воин", "Строител", "Учен"]print("Симулация на баланса: " + str(games) + " партии, семе 7, цел " + str(GOAL) + ".")print()print(pad("Характер", 20) + pad_left("Партии", 10) + pad_left("Победи", 10) + pad_left("Дял", 8) + pad_left("Ср. ход", 12))for i in range(3): share = wins[i] * 100 // played[i] avg = 0 if wins[i] == 0 else win_turns[i] // wins[i] print(pad(names[i], 20) + pad_left(str(played[i]), 10) + pad_left(str(wins[i]), 10) + pad_left(str(share) + "%", 8) + pad_left(str(avg), 12))print()print("Среден ход на победата: " + str(total_turns // 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). Сложи го в празна папка и го пусни:
python cosmos.pyС --plain печата кадрите като обикновен текст, с --seed=42 галактиката е винаги една и съща, с --lang=en е на английски (има и de и es). Симулацията, която видя, е --simulate=200 (с --seed=7 дава същата таблица). Записите са във файлове в папка saves до играта, а --memory-saves ги държи в паметта.
Играта е написана четири пъти със същите имена и дава байт по байт един и същ изход на всички езици. Проверката на сайта пуска всяка версия със записаното минаване (192 команди) и със симулацията и ги сравнява с един файл. Затова можеш да четеш играта на езика, който знаеш, и да си сигурен, че е същата.
Идеята
Идеи за продължение
Играта е твоя: имаш целия код. Ето неща, които са точно по мярка и всяко ползва нещо от този урок.
- Дипломатическа победа. Нова проверка в
checkVictory: мир с всички живи империи за 20 хода подред. Къде в реда трябва да е? Преди или след икономиката? Сложи я и пусни симулацията, за да видиш колко партии свършват така. - Автозапис. При всяко
endзаписвай в слот 0. Какво става, ако играта падне посред запис? (Подсказка: пиши във временен файл и го преименувай.) - Нова версия на записа. Добави ключ (например
difficulty) и вдигни версията на 2. Зареждането да чете и стара версия 1, като слага стойност по подразбиране. Така минава от версия към версия всяка игра. - Запис на партията като команди. Семето и списъкът с командите са цялата партия: това е самото минаване, с което проверяваш играта. Направи
replay <файл>. - Баланс по твой вкус. Пусни
--simulate=1000с различни числа за цена на технология и приход и опиши в таблица кое е най-честно. - Друга галактика. Карти от файл вместо от семе, или по-голяма галактика (мащабът на пътищата, графът от първия урок).
- Играч срещу играч по мрежа. Командите вече са текст по един ред: ходовете могат да пътуват по сокет. Играта не се променя, променя се само откъде идва редът.
- Нов език. Пренеси играта на Go, Rust или Kotlin и пусни същото минаване: изходът трябва да е същият файл.
Пробвай
Предвиди изхода
Какво печата програмата?
def checksum(text): total = 0 for ch in text: total = (total * 31 + ord(ch)) % 1000000007 return total
print(checksum("ab"), checksum("ba"), checksum("abc"), sep=", ")„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: в Python и JavaScript го смятат без препълване, но формулата си остава същата.
Още задачи
- Нова победа. Добави дипломатическа победа към програмата
victory(мир с всички живи империи 20 хода подред). Къде в реда да е? - Версия 2. Добави в записа ключ
difficultyи вдигниVERSIONна 2. Некаloadприема и версия 1 със стойност по подразбиране 1. Какво става с контролната сума на стар запис? - Равен баланс. В програмата
balanceподбери числата на строителя (приход и разсейване) така, че и трите характера да печелят между 30% и 36% от партиите, като средният приход остане 30. - Хващане на втори запис. Какво става, ако в записа два реда имат един и същ ключ? Прочети
SaveReader: коя стойност остава? Напиши проверка, която го отказва.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Изключения · Хвърляне и свои изключения · Списъци: append, remove и sort · dict и set
- Задачи по темата: Epic Cosmos: империята