Ходът на галактиката
Накратко: Ходът на галактиката е редица от фази в твърд ред; флотите се движат едновременно (първо тръгват всички, после пристигат), събитията се теглят по тегла, мирът и войната имат правило за най-малко MIN_PEACE хода, а новините и озлобването слушат шината от събития, без публикуващият да знае за тях.
Проблем
Всички играят наведнъж, а редът решава
В стратегията по ходове играчът пише end (или wait 3 за три хода подред) и цялата галактика се мърда: съперниците решават какво да правят, флотите тръгват, стават битки, планетите произвеждат, понякога дойдат пирати. Това, което за играча е едно натискане на клавиш, в кода е ход: поредица от фази в твърд ред.
Редът не е украса. Вземи най-простия пример: три флота във верига, A→B, B→C, C→D, и всичките 3 кораба са в A. Ако обработваш поръчките една след друга и местиш корабите в същия масив, корабите стигат от A до D за един ход: първата поръчка ги слага в B, втората ги вижда там и ги праща в C, третата в D. Играч, който види това, разбира веднага, че флотите са телепорти.
Този урок строи хода на малка галактика от три империи (A, B, C) и четири системи по права линия, със същите четири идеи като в канона:
- фазите на хода и едновременното движение;
- случайните събития: колко често идват и кои по-често;
- мирът и войната: правило, което не позволява да се обявява и сключва мир всеки ход;
- шината от събития: как новините, озлобването и сигналите научават какво се е случило, без никой да вика никого.
В края има цял ход от фази, който печата какво става, и сцена, която го показва.
Идеята
Фази и едновременно движение
Ходът е функция, която вика фази една след друга. В канона те са: дипломация на изкуствения интелект, неговите поръчки, движение на всички флотове, битки, завладяване, производство и наука, събитие, край на хода. В нашата малка галактика са пет: дипломация, движение, битки, производство, събитие.
Движението се разбива на две: първо всички флоти напускат системите си, чак тогава пристигат. Ето двата начина върху веригата от по-горе. Поръчката е [от, към, кораби]; корабите са 3 в системата 0:
# [от, към, кораби]ORDERS = [(0, 1, 3), (1, 2, 3), (2, 3, 3)]
def move_one_by_one(start, orders): ships = start[:] for source, target, count in orders: take = min(count, ships[source]) ships[source] -= take ships[target] += take return ships
def move_together(start, orders): ships = start[:] takes = [] # Първо всички флоти напускат системите си... for source, target, count in orders: take = min(count, ships[source]) ships[source] -= take takes.append(take) # ...и чак тогава пристигат. for i, order in enumerate(orders): ships[order[1]] += takes[i] return shipsstart = [3, 0, 0, 0]print("последователно: " + show(move_one_by_one(start, ORDERS)))print("едновременно: " + show(move_together(start, ORDERS)))последователно: 0 0 0 3едновременно: 0 3 0 0Последователният вариант е тихо грешен: програмата не се чупи, флотите просто стават бързи. Едновременният взима за всяка поръчка min(кораби от поръчката, кораби в системата в началото) и ги маха (напускане), а после ги слага (пристигане). Втората поръчка (B→C) вижда в B нула кораби, защото първите още не са пристигнали, и не мести нищо. Затова веригата е 0 3 0 0: корабите са стигнали само до B.
start[:] е копие на списъка: без него функцията би променила списъка на викащия.По-точно, за любопитните
Същото правило важи за всичко, което се случва „наведнъж“: две империи, които си разменят системи, два флота, които се разминават. Ако движиш ги един по един, вторият вижда света, който първият вече е променил, и резултатът зависи от реда на играчите. Правилото на двете стъпки (вземи всичко, после върни всичко) е същото като при размяна на две променливи с помощна: нужна е памет за „старото“.
В канона движението е още по-близо до нашето, но с една добавка: дългият път продължава сам. Поръчката помни крайната цел (dest), а след пристигане се записва нова поръчка за следващата стъпка. Затова флот, пратен през пет системи, върви пет хода, по една система на ход.
Идеята
Случайни събития по тегла
На всеки ход с шанс 40% се случва едно събитие на случайна империя. Кое точно? Ако всички видове имат равен шанс, пиратите ще идват на първия ход, когато империята няма и един кораб. Затова всеки вид има тегло (колко често е) и най-ранен ход (от кога е позволен). Ето таблицата на канона и избора:
# id, тегло, най-ранен ходEVENT_DEFS = [ ("meteor", 20, 1), ("vein", 15, 1), ("pirates", 20, 6), ("breakthrough", 15, 1), ("unrest", 10, 5), ("boom", 15, 1), ("flare", 10, 1), ("derelict", 8, 3)]
def pick_weighted(rng, weights): total = sum(weights) roll = rng.next_int(total) for i, weight in enumerate(weights): if roll < weight: return i roll -= weight return len(weights) - 1
def roll_event(rng, turn): if not rng.chance(EVENT_CHANCE): return None empire = rng.next_int(len(NAMES)) weights = [weight if turn >= min_turn else 0 for _, weight, min_turn in EVENT_DEFS] return (pick_weighted(rng, weights), empire)Изборът по тегла е „рулетка“: хвърля се число от 0 до сбора на теглата и се върви по видовете, като се изважда теглото на всеки, докато числото стане по-малко от текущото тегло. Вид, който още не е позволен, получава тегло 0 и никога не излиза, а сборът намалява, така че другите стават по-вероятни. Програмата пуска дванайсет хода и после още хиляда, за да се види колко пъти излиза всеки вид:
rng = Rng(7)for turn in range(1, 13): roll = roll_event(rng, turn) if roll is not None: print("ход " + str(turn) + ": " + EVENT_DEFS[roll[0]][0] + " → " + NAMES[roll[1]])
counts = [0] * len(EVENT_DEFS)for _ in range(1000): roll = roll_event(rng, 99) if roll is not None: counts[roll[0]] += 1print("Събития за 1000 хода:")for i, (event_id, weight, _) in enumerate(EVENT_DEFS): print(event_id + " " + str(weight) + ": " + str(counts[i]))ход 1: boom → Съюз Лираход 2: breakthrough → Академия Вегаход 3: vein → Академия Вегаход 4: boom → Академия Вегаход 8: pirates → Съюз Лираход 9: breakthrough → Земна империяСъбития за 1000 хода:meteor 20: 70vein 15: 53pirates 20: 78breakthrough 15: 58unrest 10: 30boom 15: 50flare 10: 28derelict 8: 28Пиратите (най-ранен ход 6) се виждат чак на ход 8. В хилядата хода най-често излизат пиратите (78) и метеоритите (70) с тегло 20, най-рядко изригването (28) и изоставените кораби (28) с тегла 10 и 8. Бройките не са равни на теглата, защото е случайно, но редът им е същият. Шансът за събитие е 40% (EVENT_CHANCE), а генераторът е със семе: един и същ вход дава един и същи изход на четирите езика, затова записът на играта може да се повтори.
По-точно, за любопитните
В канона всяко събитие прави нещо различно: метеоритите отнемат метал, жилата вдига богатството на една планета, пиратите се бият с флота на империята (и се печелят или губят ресурси), пробивът в науката дава точки, размириците свалят ниво на мина, бумът дава газ и кристал, изригването спира лабораториите за един ход, изоставените кораби дават две фрегати. Ето всичките (регион events от канона). Обърни внимание на края на rollEvent: резултатът не се пише на екрана, а се публикува. Какво значи това, е в картата за шината.
# Случайните събития: на всеки ход с 40% шанс се случва едно на случайна империя. Видът е по# тегла (само от най-ранния му ход нататък). Всичко се публикува в шината; новините, статистиката# и озлоблението са абонати, а събитията не знаят за тях.EVENT_CHANCE = 40
def pick_empire_planet(world, empire): items = owned_planets(world, empire.id) if len(items) == 0: return None return items[world.rng.next_int(len(items))]
def pirate_fleet(world): strength = world.turn // 6 return [2 + strength, strength // 2, strength // 3, 0, 0]
def apply_event(world, kind, empire): rng = world.rng values = {'empire': empire_name(empire)} if kind == 'meteor': planet = pick_empire_planet(world, empire) if planet is None: return None loss = min_of(empire.res[0], 8 + rng.next_int(8)) empire.res[0] -= loss values['planet'] = planet_name(world, planet) values['n'] = loss elif kind == 'vein': planet = pick_empire_planet(world, empire) if planet is None: return None r = rng.next_int(RES_COUNT) tries = 0 while planet.rich[r] >= 5 and tries < RES_COUNT: r = (r + 1) % RES_COUNT tries += 1 if planet.rich[r] >= 5: return None planet.rich[r] += 1 values['planet'] = planet_name(world, planet) values['res'] = t('res.' + str(r)) elif kind == 'pirates': planet = pick_empire_planet(world, empire) if planet is None: return None system_id = planet.system_id mine = empty_counts() fleet = fleet_at(world, empire.id, system_id) for k in range(SHIP_KIND_COUNT): mine[k] = 0 if fleet is None else fleet.ships[k] mine[FORT_KIND] = forts_in(world, empire.id, system_id) result = run_battle(side_stats(empire), mine, side_stats(None), pirate_fleet(world)) for k in range(SHIP_KIND_COUNT): if fleet is not None: fleet.ships[k] = result.a[k] values['system'] = world.systems[system_id].name values['fleet'] = counts_text(pirate_fleet(world)) if result.winner == 'a': empire.res[0] += 20 empire.res[1] += 10 values['outcome'] = t('event.pirates.win') else: m = empire.res[0] // 5 g = empire.res[1] // 5 empire.res[0] -= m empire.res[1] -= g values['outcome'] = t('event.pirates.lose', {'m': m, 'g': g}) if not world.quiet: world.last.battle = battle_lines(values['system'], empire_name(empire), t('pirates'), result) world.stats.battles += 1 world.stats.lost += total_of(mine) - total_of(result.a) + total_of(pirate_fleet(world)) - total_of(result.b) elif kind == 'breakthrough': n = 6 + rng.next_int(7) empire.progress += n values['n'] = n elif kind == 'unrest': planet = pick_empire_planet(world, empire) if planet is None or planet.mines == 0: return None planet.mines -= 1 values['planet'] = planet_name(world, planet) elif kind == 'boom': empire.res[1] += 20 empire.res[2] += 10 elif kind == 'flare': empire.flare = 1 elif kind == 'derelict': planet = pick_empire_planet(world, empire) if planet is None: return None add_ships(world, empire.id, planet.system_id, [0, 0, 2, 0]) values['system'] = world.systems[planet.system_id].name return values
def roll_event(world): rng = world.rng if not rng.chance(EVENT_CHANCE): return living = [] for e in world.empires: if e.alive: living.append(e) if len(living) == 0: return empire = living[rng.next_int(len(living))] allowed = [] weights = [] for definition in EVENT_DEFS: if world.turn >= definition.min_turn: allowed.append(definition) weights.append(definition.weight) definition = allowed[pick_weighted(rng, weights)] values = apply_event(world, definition.id, empire) if values is None: return world.stats.events += 1 world.bus.publish(SimpleNamespace(type='event', kind=definition.id, empire=empire.id, vars=values))
# Абонатите: новините за играча, озлобването след битки и завладяване.def add_news(world, text): world.news.append(str(world.turn) + ': ' + text) while len(world.news) > 60: world.news.pop(0) world.turn_lines.append(text)
def install_subscribers(world, quiet): bus = world.bus
def involves_human(a, b): return world.empires[a].human or world.empires[b].human
def grudge_after_battle(e): if e.lost_a > 0: world.empires[e.b].grudge[e.a] += 1 if e.lost_b > 0: world.empires[e.a].grudge[e.b] += 1
def grudge_after_conquest(e): if e.from_ >= 0: world.empires[e.from_].grudge[e.empire] += 3
# Сигнал към играча: нещо се случи, при което „wait“ спира. def alert_battle(e): if involves_human(e.a, e.b): world.alert += 1
def alert_conquest(e): if world.empires[e.empire].human or (e.from_ >= 0 and world.empires[e.from_].human): world.alert += 1
def alert_war(e): if involves_human(e.a, e.b): world.alert += 1
def alert_siege(e): if world.empires[e.from_].human: world.alert += 1
def alert_offer(e): if world.empires[e.to].human: world.alert += 1
bus.subscribe('battle', grudge_after_battle) bus.subscribe('conquest', grudge_after_conquest) bus.subscribe('battle', alert_battle) bus.subscribe('conquest', alert_conquest) bus.subscribe('war', alert_war) bus.subscribe('siege', alert_siege) bus.subscribe('offer', alert_offer) if quiet: return
def news_battle(e): if involves_human(e.a, e.b): for line in e.lines: add_news(world, line) else: add_news(world, t('news.battle', { 'system': world.systems[e.system_id].name, 'a': empire_name(world.empires[e.a]), 'b': empire_name(world.empires[e.b]), 'la': e.lost_a, 'lb': e.lost_b, }))
def news_colony(e): add_news(world, t('news.colony', {'empire': empire_name(world.empires[e.empire]), 'planet': planet_name(world, world.planets[e.planet_id])}))
def news_tech(e): if world.empires[e.empire].human: add_news(world, t('news.tech', {'tech': nm(TECHS[e.tech_id])}))
def news_war(e): add_news(world, t('news.war', {'a': empire_name(world.empires[e.a]), 'b': empire_name(world.empires[e.b])}))
def news_peace(e): add_news(world, t('news.peace', {'a': empire_name(world.empires[e.a]), 'b': empire_name(world.empires[e.b])}))
def news_offer(e): add_news(world, t('news.offer', {'empire': empire_name(world.empires[e.from_]), 'letter': world.empires[e.from_].def_.letter}))
def news_siege(e): add_news(world, t('news.siege', {'empire': empire_name(world.empires[e.empire]), 'planet': planet_name(world, world.planets[e.planet_id])}))
def news_conquest(e): add_news(world, t('news.conquest', {'empire': empire_name(world.empires[e.empire]), 'planet': planet_name(world, world.planets[e.planet_id])}))
def news_eliminated(e): add_news(world, t('news.eliminated', {'empire': empire_name(world.empires[e.empire])}))
def news_event(e): add_news(world, t('event.' + e.kind, e.vars))
bus.subscribe('battle', news_battle) bus.subscribe('colony', news_colony) bus.subscribe('tech', news_tech) bus.subscribe('war', news_war) bus.subscribe('peace', news_peace) bus.subscribe('offer', news_offer) bus.subscribe('siege', news_siege) bus.subscribe('conquest', news_conquest) bus.subscribe('eliminated', news_eliminated) bus.subscribe('event', news_event)Идеята
Мир и война: правило срещу бъркотията
Дипломацията в играта е два обекта: двойка империи е във война или в мир (war[a][b]). Кое е трудното? Без правило империя би обявявала война на един ход, сключвала мир на следващия и пак война след него. Затова:
- мирът важи най-малко MIN_PEACE =
4хода преди нова война (turn − since < MIN_PEACE; в началото на играта правилото не важи, за да може някой да започне); - предложение за мир е позволено най-рано два хода след промяната;
- изкуственият интелект приема мир по желание: колко е по-слаб от другия, какъв е характерът му, колко други войни води и колко е озлобен. От 90 нагоре приема.
В програмата желанието е опростено: характер + (150 − съотношение на корабите) / 2 + други войни × 15 − озлобление × 3, където характерът е 40 за Орион (воин) и 120 за двете други (строители). Отказът увеличава озлоблението, затова следващият опит е по-труден. Сценарият е от историята на сцената: Орион (B) обявява война на Земната империя (A) на ход 1, Лира (C) на Орион на ход 3, а A предлага мир:
class World: def __init__(self): self.turn = 1 self.ships = [[0] * 4 for _ in range(3)] self.war = [[0] * 3 for _ in range(3)] self.since = [[0] * 3 for _ in range(3)] self.grudge = [[0] * 3 for _ in range(3)]
def at_war(world, a, b): return world.war[a][b] == 1
def power(world, empire): return sum(world.ships[empire])
def set_stance(world, a, b, war): world.war[a][b] = war world.war[b][a] = war world.since[a][b] = world.turn world.since[b][a] = world.turn
def declare_war(world, a, b): if at_war(world, a, b): return "alreadyWar" # Мирът важи най-малко MIN_PEACE хода. if world.turn - world.since[a][b] < MIN_PEACE and world.turn > MIN_PEACE: return "tooSoon" set_stance(world, a, b, 1) return None
def make_peace(world, a, b): set_stance(world, a, b, 0) world.grudge[a][b] = 0 world.grudge[b][a] = 0
def peace_wish(world, me, other): # Колко по-слаба е империята от другата. ratio = min(150, power(world, me) * 100 // (power(world, other) + 1)) other_wars = 0 for x in range(3): if x != me and x != other and at_war(world, me, x): other_wars += 1 return max(0, PEACE[me] + (150 - ratio) // 2 + other_wars * 15 - world.grudge[me][other] * 3)
def propose_peace(world, a, b): if not at_war(world, a, b): return "noWar" if world.turn - world.since[a][b] < 2: return "tooSoon" # От 90 нагоре империята приема мир. if peace_wish(world, b, a) >= 90: make_peace(world, a, b) return None # Отказът озлобява: следващият мир е по-труден. world.grudge[b][a] += 1 return "refused"def step(world, turn, kind, a, b): world.turn = turn wish = peace_wish(world, b, a) if kind == "peace" else 0 error = declare_war(world, a, b) if kind == "war" else propose_peace(world, a, b) shown = " (желание " + str(wish) + ")" if kind == "peace" and error in (None, "refused") else "" print("ход " + str(turn) + ": " + kind + " " + NAMES[a] + " → " + NAMES[b] + ": " + (error or "ok") + shown)
world = World()world.ships[1][1] = 1world.grudge[1][0] = 1step(world, 1, "war", 1, 0)step(world, 2, "war", 1, 0)step(world, 2, "peace", 0, 1)step(world, 3, "war", 2, 1)step(world, 3, "peace", 0, 1)world.ships[1][1] = 0step(world, 4, "peace", 0, 1)step(world, 5, "war", 1, 0)step(world, 9, "war", 1, 0)ход 1: war Хегемония Орион → Земна империя: okход 2: war Хегемония Орион → Земна империя: alreadyWarход 2: peace Земна империя → Хегемония Орион: tooSoonход 3: war Съюз Лира → Хегемония Орион: okход 3: peace Земна империя → Хегемония Орион: refused (желание 77)ход 4: peace Земна империя → Хегемония Орион: ok (желание 124)ход 5: war Хегемония Орион → Земна империя: tooSoonход 9: war Хегемония Орион → Земна империя: okХод 2: Орион пак обявява война на същия противник (alreadyWar), а A предлага мир едва един ход след войната (tooSoon). На ход 3 A предлага мир, но желанието на Орион е 40 + 25 + 15 − 3 = 77 (характер, един кораб срещу нула, още една война с Лира, озлобление 1) и е под 90: отказ, а озлоблението става 2. На ход 4 Орион няма кораби и желанието му е 40 + 75 + 15 − 6 = 124: приема. Ход 5: Орион иска нова война, но мирът е от ход 4 и 5 − 4 = 1 е по-малко от 4: tooSoon. На ход 9 вече е позволено (9 − 4 = 5).
def at_war(world, a, b):
return world.war[a][b] == 1
def power(world, empire):
return sum(world.ships[empire])
def set_stance(world, a, b, war):
world.war[a][b] = war
world.war[b][a] = war
world.since[a][b] = world.turn
world.since[b][a] = world.turn
def declare_war(world, a, b):
if at_war(world, a, b):
return "alreadyWar"
# Мирът важи най-малко MIN_PEACE хода.
if world.turn - world.since[a][b] < MIN_PEACE and world.turn > MIN_PEACE:
return "tooSoon"
set_stance(world, a, b, 1)
return None
def make_peace(world, a, b):
set_stance(world, a, b, 0)
world.grudge[a][b] = 0
world.grudge[b][a] = 0
def peace_wish(world, me, other):
# Колко по-слаба е империята от другата.
ratio = min(150, power(world, me) * 100 // (power(world, other) + 1))
other_wars = 0
for x in range(3):
if x != me and x != other and at_war(world, me, x):
other_wars += 1
return max(0, PEACE[me] + (150 - ratio) // 2 + other_wars * 15 - world.grudge[me][other] * 3)
def propose_peace(world, a, b):
if not at_war(world, a, b):
return "noWar"
if world.turn - world.since[a][b] < 2:
return "tooSoon"
# От 90 нагоре империята приема мир.
if peace_wish(world, b, a) 90:
make_peace(world, a, b)
return None
# Отказът озлобява: следващият мир е по-труден.
world.grudge[b][a] += 1
return "refused"Прагът е „от 90 нагоре“, т.е. 90 е достатъчно: >=. С > желание точно 90 би било отказ, а правилото в играта казва обратното.
По-точно, за любопитните
Резултатът от функциите е низ или нищо: null, ако промяната е станала, и ключ на грешка (alreadyWar, tooSoon, refused, noWar), ако не. Грешките са ключове, а не готови изречения, за да ги превежда екранът (играта е на четири езика). Така същата функция служи и на играча (команда peace B), и на изкуствения интелект.
Канонът има и предложения към играча: изкуственият интелект не може да сключи мир с човека сам, затова записва предложение (offers), което важи три хода, и публикува събитие „offer“. Ето целия регион diplomacy:
# Дипломацията е мир или война между две империи. Мирът важи най-малко MIN_PEACE хода преди# нова война. Изкуственият интелект приема мир по мерило: колко е по-слаб, какъв е характерът# му и колко е озлобен. Предложение към играча се записва и важи три хода.def at_war(world, a, b): return world.empires[a].war[b] == 1
def set_stance(world, a, b, war): world.empires[a].war[b] = war world.empires[b].war[a] = war world.empires[a].since[b] = world.turn world.empires[b].since[a] = world.turn
def declare_war(world, a, b): if a == b: return 'dip.self' if not world.empires[b].alive: return 'dip.dead' if at_war(world, a, b): return 'dip.alreadyWar' if world.turn - world.empires[a].since[b] < MIN_PEACE and world.turn > MIN_PEACE: return 'dip.tooSoon' set_stance(world, a, b, 1) world.bus.publish(SimpleNamespace(type='war', a=a, b=b)) return None
def make_peace(world, a, b): set_stance(world, a, b, 0) world.empires[a].grudge[b] = 0 world.empires[b].grudge[a] = 0 world.bus.publish(SimpleNamespace(type='peace', a=a, b=b))
# Най-малкото разстояние в пътища между системи с планети на двете империи.def empire_distance(world, a, b): best = 99 for pa in world.planets: if pa.owner != a: continue dist = bfs(world.systems, pa.system_id).dist for pb in world.planets: if pb.owner == b and dist[pb.system_id] >= 0 and dist[pb.system_id] < best: best = dist[pb.system_id] return best
def power_ratio(world, a, b, top): return clamp(percent(empire_power(world, a) * 100, empire_power(world, b) * 100 + 100), 0, top)
# Колко иска империята мир с другата (0 и нагоре; от 90 нагоре приема).def peace_wish(world, me, other): e = world.empires[me] weaker = 150 - power_ratio(world, me, other, 150) other_wars = 0 for x in world.empires: if x.alive and x.id != me and x.id != other and e.war[x.id] == 1: other_wars += 1 wish = e.personality.peace + weaker // 2 + other_wars * 15 - e.grudge[other] * 3 return max_of(0, wish)
# Колко иска империята война с другата.def war_wish(world, me, other): e = world.empires[me] ratio = power_ratio(world, me, other, 300) wish = e.personality.attack // 2 if ratio > 100: wish += (ratio - 100) // 4 else: wish -= (100 - ratio) // 2 if empire_distance(world, me, other) <= 3: wish += 25 for x in world.empires: if x.alive and x.id != me and e.war[x.id] == 1: wish -= 30 return wish
def has_offer(world, src, to): for offer in world.offers: if offer.from_ == src and offer.to == to and world.turn - offer.turn <= 3: return True return False
# Предложение за мир от a към b. Връща ключ на грешка или None (мирът е сключен).def propose_peace(world, a, b): if a == b: return 'dip.self' if not world.empires[b].alive: return 'dip.dead' if not at_war(world, a, b): return 'dip.noWar' if world.turn - world.empires[a].since[b] < 2: return 'dip.tooSoon' if world.empires[b].human: if has_offer(world, b, a): make_peace(world, a, b) return None if not has_offer(world, a, b): world.offers.append(SimpleNamespace(from_=a, to=b, turn=world.turn)) return 'dip.sent' if has_offer(world, b, a) or peace_wish(world, b, a) >= 90: make_peace(world, a, b) return None world.empires[b].grudge[a] += 1 return 'dip.refused'
# Дипломацията на изкуствения интелект в края на хода: мир, когато е по-слаб, война, когато е по-силен.def ai_diplomacy(world, empire): for other in world.empires: if not other.alive or other.id == empire.id: continue if at_war(world, empire.id, other.id): if world.turn - empire.since[other.id] >= 3 and peace_wish(world, empire.id, other.id) >= 90: if other.human: if not has_offer(world, empire.id, other.id): world.offers.append(SimpleNamespace(from_=empire.id, to=other.id, turn=world.turn)) world.bus.publish(SimpleNamespace(type='offer', from_=empire.id, to=other.id)) else: propose_peace(world, empire.id, other.id) elif world.turn - empire.since[other.id] >= MIN_PEACE + 2 and world.turn > MIN_PEACE + 2: if war_wish(world, empire.id, other.id) >= 95 and world.rng.chance(40): declare_war(world, empire.id, other.id)Идеята
Шина от събития: който публикува, не знае кой слуша
Когато се обяви война, трябва да стане много: да се напише новина, да порасне озлобването на двете империи, да светне сигнал на играча, да се преброи в статистиката. Най-простото е declareWar да вика всичко това. Тогава declareWar знае за новините, за озлобването, за сигнала и за статистиката, а всяка нова възможност е още един ред в нея и още една причина да се счупи. Шината обръща посоката: declareWar казва „обявена е война между A и B“ и свършва. Който се интересува, се е абонирал за „war“.
@dataclass(frozen=True)class Event: type: str a: int b: int place: int = 0 n: int = 0
class EventBus: def __init__(self): self.handlers = {}
def subscribe(self, event_type, handler): if event_type not in self.handlers: self.handlers[event_type] = [] self.handlers[event_type].append(handler)
def publish(self, event): if event.type not in self.handlers: return for handler in list(self.handlers[event.type]): handler(event)subscribe записва обработчик за вид събитие, publish вика всички обработчици на този вид. Вид без абонати не е грешка: събитието отива в празното. Ето публикуването в двете места, където става (обява на война и сключване на мир). Единственото ново е редът с publish:
def declare_war(world, a, b): if at_war(world, a, b): return "alreadyWar" # Мирът важи най-малко MIN_PEACE хода. if world.turn - world.since[a][b] < MIN_PEACE and world.turn > MIN_PEACE: return "tooSoon" set_stance(world, a, b, 1) world.bus.publish(Event("war", a, b)) return None
def make_peace(world, a, b): set_stance(world, a, b, 0) world.grudge[a][b] = 0 world.grudge[b][a] = 0 world.bus.publish(Event("peace", a, b))А ето програмата. Новините слушат всички видове, озлоблението само битките, а report публикува и неуспешните опити (refused, tooSoon) като събития:
world = World()world.ships[1][1] = 1world.grudge[1][0] = 1
def grudge(event): world.grudge[event.a][event.b] += 1 world.grudge[event.b][event.a] += 1
# Новините слушат всички видове събития, озлобването само битките.for event_type in NEWS_TYPES: world.bus.subscribe(event_type, lambda event: print(news_text(event)))world.bus.subscribe("battle", grudge)
world.turn = 1report(world, declare_war(world, 1, 0), 1, 0)world.bus.publish(Event("battle", 1, 0, 1, 2))world.turn = 3report(world, propose_peace(world, 0, 1), 0, 1)report(world, declare_war(world, 1, 0), 1, 0)print("озлобление " + NAMES[0] + " → " + NAMES[1] + ": " + str(world.grudge[0][1]))print("озлобление " + NAMES[1] + " → " + NAMES[0] + ": " + str(world.grudge[1][0]))Хегемония Орион обявява война на Земна империя.Битка при Vesta: Хегемония Орион и Земна империя губят кораби (по 2).Хегемония Орион отказва мир с Земна империя.Хегемония Орион вече е във война с Земна империя.озлобление Земна империя → Хегемония Орион: 1озлобление Хегемония Орион → Земна империя: 3Опитът на ход 1 е успешен, битката е публикувана на ръка, отказът на ход 3 е събитие (refused), а повторната война е alreadyWar. Озлоблението на A към B е 1 (от битката), а на B към A е 3: едно старо, едно от битката и едно от отказа (отказът вдига озлоблението на този, който отказва, към предложилия).
lambda); класът EventBus не знае нищо за нея освен че може да я вика.По-точно, за любопитните
Без шина всяко ново нещо, което трябва да научи за война, иска промяна в declareWar. С шината нов абонат е само нов ред при създаването на света, а функцията за война не се пипа. Това е шаблонът наблюдател (observer) от модула за шаблони за дизайн.
Малка, но важна подробност: publish върти по копие на списъка с абонати. Обработчик може да се абонира за друго събитие, докато го викат; ако списъкът се променя по време на обхождането, абонатите се прескачат или се повтарят. Копието прави правилото ясно: новият абонат чува от следващото публикуване.
Редът на абонатите е редът на subscribe. Тук няма значение (новините и озлоблението не си пречат), но ако два абоната зависят един от друг, редът ще решава изхода, и това е причина да ги държиш независими.
Идеята
Целият ход
Сега всичко заедно. Светът е World: ход, фаза, генератор, шина, флотите на трите империи (по системи), метал и таблиците за война (war), от кой ход (since) и озлобление (grudge):
class World: def __init__(self, seed): self.turn = 1 self.phase = "" self.rng = Rng(seed) self.bus = EventBus() self.ships = [[3, 0, 0, 0], [0, 0, 0, 4], [0, 2, 0, 0]] self.metal = [50, 50, 50] self.war = [[0] * 3 for _ in range(3)] self.since = [[0] * 3 for _ in range(3)] self.grudge = [[0] * 3 for _ in range(3)]Поръчките и дипломацията на хода са сценарий (в играта ги решава изкуственият интелект или играчът): поръчката е [ход, империя, от, към, кораби], а дипломацията [ход, вид, от, към].
# [ход, империя, от, към, кораби]ORDERS = [(1, 1, 3, 2, 4), (1, 0, 0, 1, 3), (2, 1, 2, 1, 4)]# [ход, вид (0 война, 1 мир), от, към]DIPLO = [(1, 0, 1, 0), (3, 0, 2, 1), (3, 1, 0, 1), (4, 1, 0, 1), (5, 0, 1, 0)]Фазите са функции. Дипломацията изпълнява сценария за хода и публикува грешките. Движението е точно двете стъпки от първата карта. Битките обхождат системите: където две империи във война имат кораби, всяка губи толкова, колкото е по-малката флота, и се публикува „battle“ (озлоблението се абонира за него):
def scripted_diplomacy(world): world.phase = "дипломация" for turn, kind, a, b in DIPLO: if turn != world.turn: continue error = declare_war(world, a, b) if kind == 0 else propose_peace(world, a, b) report(world, error, a, b)def resolve_movement(world): world.phase = "движение" moving = [] # Първо всички флоти напускат системите си... for turn, empire, source, target, count in ORDERS: if turn != world.turn: continue take = min(count, world.ships[empire][source]) world.ships[empire][source] -= take if take > 0: moving.append((empire, source, target, take)) # ...и чак тогава пристигат. for empire, source, target, take in moving: world.ships[empire][target] += take world.bus.publish(Event("move", empire, source, target, take))def resolve_battles(world): world.phase = "битки" for system in range(4): for a in range(3): for b in range(a + 1, 3): if not at_war(world, a, b) or world.ships[a][system] == 0 or world.ships[b][system] == 0: continue lost = min(world.ships[a][system], world.ships[b][system]) world.ships[a][system] -= lost world.ships[b][system] -= lost world.bus.publish(Event("battle", a, b, system, lost))
def produce(world): world.phase = "производство" for empire in range(3): world.metal[empire] += 10След тях е събитието: теглене по теглата от втората карта, с тези ефекти: метеор отнема метал, бум дава метал, изоставените кораби дават два кораба в столицата. Накрая runTurn ги вика в ред:
def roll_event(world): world.phase = "събитие" if not world.rng.chance(EVENT_CHANCE): return empire = world.rng.next_int(3) weights = [weight if world.turn >= min_turn else 0 for _, weight, min_turn in EVENT_DEFS] kind = EVENT_DEFS[pick_weighted(world.rng, weights)][0] if kind == "meteor": loss = min(world.metal[empire], 8 + world.rng.next_int(8)) world.metal[empire] -= loss world.bus.publish(Event("meteor", empire, 0, 0, loss)) elif kind == "boom": world.metal[empire] += 20 world.bus.publish(Event("boom", empire, 0, 0, 20)) else: world.ships[empire][HOME[empire]] += 2 world.bus.publish(Event("derelict", empire, 0, HOME[empire], 2))def run_turn(world): # Фазите вървят в този ред и редът е важен. scripted_diplomacy(world) resolve_movement(world) resolve_battles(world) produce(world) roll_event(world) world.turn += 1Програмата абонира новините (те слагат пред всеки ред фазата, в която е станало събитието) и озлоблението, пуска пет хода и печата резултата. Редовете са точно новините на играта:
world = World(7)
def grudge(event): world.grudge[event.a][event.b] += 1 world.grudge[event.b][event.a] += 1
def news(event): print(" [" + world.phase + "] " + news_text(event))
world.bus.subscribe("battle", grudge)for event_type in NEWS_TYPES: world.bus.subscribe(event_type, news)
for _ in range(5): print(fill("-- ход {0} --", world.turn)) run_turn(world)print("-- край --")for empire in range(3): print(fill("{0}: кораби {1}, метал {2}", NAMES[empire], power(world, empire), world.metal[empire]))-- ход 1 -- [дипломация] Хегемония Орион обявява война на Земна империя. [движение] Хегемония Орион праща флот от Brisa към Altan (кораби: 4). [движение] Земна империя праща флот от Kora към Vesta (кораби: 3). [събитие] Метеоритен дъжд: Съюз Лира губи 14 метал.-- ход 2 -- [движение] Хегемония Орион праща флот от Altan към Vesta (кораби: 4). [битки] Битка при Vesta: Земна империя и Хегемония Орион губят кораби (по 3).-- ход 3 -- [дипломация] Съюз Лира обявява война на Хегемония Орион. [дипломация] Хегемония Орион отказва мир с Земна империя. [битки] Битка при Vesta: Хегемония Орион и Съюз Лира губят кораби (по 1).-- ход 4 -- [дипломация] Мир между Земна империя и Хегемония Орион. [събитие] Метеоритен дъжд: Съюз Лира губи 8 метал.-- ход 5 -- [дипломация] Хегемония Орион още не може да смени отношенията си с Земна империя: прекалено скоро. [събитие] Хегемония Орион намира изоставени кораби при Brisa (кораби: 2).-- край --Земна империя: кораби 0, метал 100Хегемония Орион: кораби 2, метал 100Съюз Лира: кораби 1, метал 78Прочети изхода като история. Ход 1: Орион обявява война, двата флота (на Орион и на Земната империя) тръгват едновременно, а Лира губи метал от метеор. Ход 2: флотът на Орион пристига във Vesta, където вече е Земната империя, и двамата губят по 3 кораба. Ход 3: Лира се включва във войната и в боя във Vesta губят по 1; преди това A предлага мир и получава отказ. Ход 4: Орион няма кораби, желанието му е 124 и мирът е сключен. Ход 5: нова война е „прекалено скоро“, а Орион намира два изоставени кораба в столицата си.
Виж го
Един ход на галактиката
Ето същото по ходове. Горе светва фазата, в която е станало нещо. По средата са системите и флотите на трите империи: при движението всички флотове излитат наведнъж и чак тогава кацат. Под тях се вижда дали всяка двойка е във война, а най-долу шината: публикуваното събитие светва абоната „новини“, а ако е битка, и „озлобление“.
def run_turn(world): # Фазите вървят в този ред и редът е важен. scripted_diplomacy(world) resolve_movement(world) resolve_battles(world) produce(world) roll_event(world) world.turn += 1
class EventBus: def __init__(self): self.handlers = {}
def subscribe(self, event_type, handler): if event_type not in self.handlers: self.handlers[event_type] = [] self.handlers[event_type].append(handler)
def publish(self, event): if event.type not in self.handlers: return for handler in list(self.handlers[event.type]): handler(event)Стъпка по стъпка, с думи
- A = Земна империя, B = Хегемония Орион, C = Съюз Лира. Четири системи по права линия; ходът минава през фазите отгоре надолу.
- ход 1 · дипломация: Хегемония Орион обявява война на Земна империя.
- ход 1 · движение: Хегемония Орион праща флот от Brisa към Altan (кораби: 4). Земна империя праща флот от Kora към Vesta (кораби: 3).
- ход 1 · събитие: Метеоритен дъжд: Съюз Лира губи 14 метал.
- ход 2 · движение: Хегемония Орион праща флот от Altan към Vesta (кораби: 4).
- ход 2 · битки: Битка при Vesta: Земна империя и Хегемония Орион губят кораби (по 3).
- ход 3 · дипломация: Съюз Лира обявява война на Хегемония Орион. Хегемония Орион отказва мир с Земна империя.
- ход 3 · битки: Битка при Vesta: Хегемония Орион и Съюз Лира губят кораби (по 1).
- ход 4 · дипломация: Мир между Земна империя и Хегемония Орион.
- ход 4 · събитие: Метеоритен дъжд: Съюз Лира губи 8 метал.
- ход 5 · дипломация: Хегемония Орион още не може да смени отношенията си с Земна империя: прекалено скоро.
- ход 5 · събитие: Хегемония Орион намира изоставени кораби при Brisa (кораби: 2).
- След 5 хода: Земна империя: кораби 0, метал 100 · Хегемония Орион: кораби 2, метал 100 · Съюз Лира: кораби 1, метал 78
Кодът до сцената е runTurn и EventBus от програмата. Светят редовете, които работят в момента.
Идеята
В играта
Това, което построихме, е същото като в канона, само по-малко. Ето истинския ход (регион turn). Над runTurn са движението, което прави същите две стъпки (напускане, пристигане), битките и завладяването на планети; runTurn вика фазите в твърд ред, а в края на хода озлоблението остарява (на всеки пети ход намалява с 1) и се изхвърлят остарелите предложения за мир.
# Един ход на галактиката има фази: дипломация на изкуствения интелект, поръчките му, движение на# всички флотове наведнъж, битки, завладяване, производство и наука, събитие, край на хода.# Движението е едновременно: първо всички кораби напускат системите си, чак тогава пристигат.def resolve_movement(world): moving = [] for empire in world.empires: if not empire.alive: continue for order in empire.orders: fleet = fleet_at(world, empire.id, order.from_) if fleet is None: continue ships = [0, 0, 0, 0] for k in range(SHIP_KIND_COUNT): ships[k] = min_of(order.ships[k], fleet.ships[k]) fleet.ships[k] -= ships[k] if total_of(ships) > 0: moving.append(SimpleNamespace(owner=empire.id, to=order.to, dest=order.dest, ships=ships)) empire.orders = [] for move in moving: add_ships(world, move.owner, move.to, move.ships) # дългият път продължава сам: нова поръчка от мястото, където стигнаха if move.to != move.dest: world.empires[move.owner].orders.append(SimpleNamespace(from_=move.to, to=next_step(world.systems, move.to, move.dest), dest=move.dest, ships=list(move.ships))) remove_empty_fleets(world)
def has_units(world, owner, system_id): fleet = fleet_at(world, owner, system_id) return (fleet is not None and total_of(fleet.ships) > 0) or forts_now(world, owner, system_id) > 0
def resolve_battles(world): for system in world.systems: for a in range(len(world.empires)): for b in range(a + 1, len(world.empires)): if not world.empires[a].alive or not world.empires[b].alive or not at_war(world, a, b): continue if has_units(world, a, system.id) and has_units(world, b, system.id): fight_at(world, system.id, a, b) remove_empty_fleets(world)
# Планета пада, когато враг е във война с владетеля ѝ, има кораби в системата, а владетелят няма# нито кораби, нито оцелели крепости там, и така SIEGE_TURNS хода подред: първите ходове са обсада,# последният е превземане. Защитникът има време да доведе флот. Постройките се разрушават.def resolve_conquest(world): for system in world.systems: for pid in system.planets: planet = world.planets[pid] if planet.owner < 0: continue owner = planet.owner attacker = -1 if not has_units(world, owner, system.id): for e in world.empires: if not e.alive or e.id == owner or not at_war(world, e.id, owner): continue fleet = fleet_at(world, e.id, system.id) if fleet is not None and total_of(fleet.ships) > 0: attacker = e.id break if attacker < 0: planet.siege = 0 continue planet.siege += 1 if planet.siege < SIEGE_TURNS: world.bus.publish(SimpleNamespace(type='siege', empire=attacker, from_=owner, planet_id=planet.id)) continue planet.siege = 0 planet.owner = attacker planet.mines = max_of(0, planet.mines - 1) planet.lab = 0 planet.yard = 0 planet.fort = 0 world.bus.publish(SimpleNamespace(type='conquest', empire=attacker, from_=owner, planet_id=planet.id)) for empire in world.empires: if not empire.alive or len(owned_planets(world, empire.id)) > 0: continue empire.alive = False kept = [] for fleet in world.fleets: if fleet.owner != empire.id: kept.append(fleet) world.fleets = kept empire.orders = [] world.bus.publish(SimpleNamespace(type='eliminated', empire=empire.id))
def run_turn(world): world.turn_lines = [] world.fort_left = {} for empire in world.empires: if empire.alive and not empire.human: ai_diplomacy(world, empire) for empire in world.empires: if empire.alive and not empire.human: ai_play(world, empire) resolve_movement(world) resolve_battles(world) resolve_conquest(world) for empire in world.empires: if not empire.alive: continue produce(world, empire) complete_research(world, empire) finish_queue(world) roll_event(world) check_victory(world) world.turn += 1 if world.turn % 5 == 0: for empire in world.empires: for i in range(len(empire.grudge)): empire.grudge[i] = max_of(0, empire.grudge[i] - 1) fresh = [] for offer in world.offers: if world.turn - offer.turn <= 3: fresh.append(offer) world.offers = freshА ето шината, която слушат новините, озлоблението и сигналът към играча. Събитията са един клас с повече полета (в JavaScript — обект), а шината е същата:
# Шината от събития: който публикува, не знае кой слуша. Новините, злопаметността на империите и# статистиката се абонират сами, а битката само казва „флот се би при тази система“.class EventBus: def __init__(self): self.handlers = {}
def subscribe(self, event_type, handler): if event_type not in self.handlers: self.handlers[event_type] = [] self.handlers[event_type].append(handler)
def publish(self, event): if event.type not in self.handlers: return for handler in list(self.handlers[event.type]): handler(event)Разликите от нашата програма са много, и затова са важни:
- Няма сценарий. Поръчките и дипломацията идват от
aiPlayиaiDiplomacy(урокът за съперниците) и от командите на играча. - Галактиката е граф (урокът за генерирането ѝ), а не права линия. Поръчката помни крайната цел и продължава сама.
- Битката (
fightAt, урокът за флотите) е с роли на корабите и по шест рунда, а не „всеки губи по по-малкото“. Има и крепости, и завладяване на планета (две обсади). - Желанието за мир ползва силата на империята (не броя кораби), характера ѝ и нейното желание за война (
warWish). Предложение към играча се записва за три хода. - Производството тук е плоски 10 метал на империя; в канона зависи от планетите, мините и технологиите.
- Събитията са осем вида, с по-различен ефект (пиратите се бият със същия модел на битка).
- Шината носи и други видове: завладяване, обсада, откриване на технология, колония, унищожена империя. Новините и сигналът към играча ги слушат, а
waitспира, щом сигналът порасне.
Пробвай
Предвиди изхода
Два флота: първата поръчка праща 3-те кораба от системата 0 към 1, втората — 2 кораба от системата 1 към 2. Поръчките се обработват едновременно. Какво печата програмата?
def move_together(start, orders): ships = start[:] takes = [] # Първо всички флоти напускат системите си... for source, target, count in orders: take = min(count, ships[source]) ships[source] -= take takes.append(take) # ...и чак тогава пристигат. for i, order in enumerate(orders): ships[order[1]] += takes[i] return ships
def show(ships): return " ".join(str(n) for n in ships)
orders = [(0, 1, 3), (1, 2, 2)]print(show(move_together([3, 0, 0, 0], orders)))Първо всички тръгват: първата поръчка взима 3 кораба от системата 0, а втората вижда в системата 1 нула кораби (първите още не са пристигнали) и взима 0. После пристигат: 3 кораба в системата 1, 0 в системата 2. Втората възможност е резултатът при поръчки една след друга: 2 от пристигналите кораби тръгват пак същия ход; третата е началното състояние, без движение.
Пробвай
Намери грешката
Колега „оправи“ едновременното движение, но при него корабите пак минават по няколко системи на ход:
start = [3, 0, 0, 0]print("едновременно: " + show(move_together(start, ORDERS)))едновременно: 0 0 0 3Резултатът е като при последователното движение. Кой ред праща корабите на местоназначението твърде рано?
Избери реда с грешката.
Корабите пристигат (+= в местоназначението) още в цикъла за напускане. Когато дойде втората поръчка, корабите на първата вече са там и тя ги взима. Правилно: в първия цикъл само се вземат (-=) и се помнят в takes; пристигането е втори цикъл, след като всички са тръгнали.
По-точно
По-точно: ред, справедливост и случайност
Редът на играчите. При едновременно движение редът на поръчките не променя резултата, защото първо всички вземат от началото на хода. При битките обаче има ред: система по система, двойка по двойка (a < b). Ако на една система са три империи във война, първата двойка в реда се бие първа и оцелелите стигат до следващата. Редът е твърд, затова играта остава повторяема.
Фазите са договор. Дипломацията е преди движението, защото тогава ново обявената война може да започне битка в същия ход. Производството е след завладяването: планета, взета този ход, не произвежда за стария си господар. Събитието е почти накрая и работи със света такъв, какъвто е станал (метеорът взима от метала, който вече е изкаран). Размени две фази и играта остава „същата“, но изходът е друг. Затова редът е на едно място (runTurn) и се чете като списък.
Случайност и повторяемост. Всичко случайно (шанс за събитие, коя империя, кой вид, колко метал) минава през един генератор със семе. Ако две фази си разменят местата и двете теглят числа, поредицата е друга. В нашата програма само rollEvent ползва генератора, затова изходът е стабилен; в канона дипломацията и изкуственият интелект също теглят числа, и редът на фазите решава кой взема кое число.
Какво не е в шината. Шината е за новини: „стана това“. Тя не е за команди („направи това“): публикуващият не знае дали някой е чул и не чака отговор. Публикувай факти, не заповеди.
Упражнение
Сигнал за играча
Реши задачата в редактора си. Тръгни от програмата за цял ход.
Сигнал. В играта wait спира, щом нещо се случи на играча. Добави абонат „сигнал“, който брои събитията war, peace и battle, в които участва империя 0 (играчът): като a или като b. След обобщението програмата печата още един ред със сигналите (сигнали: …). Не пипай declareWar, makePeace и resolveBattles: абонатът е само нов ред при създаването на света и една функция.
Изходът е (последният ред е новият):
-- ход 1 -- [дипломация] Хегемония Орион обявява война на Земна империя. [движение] Хегемония Орион праща флот от Brisa към Altan (кораби: 4). [движение] Земна империя праща флот от Kora към Vesta (кораби: 3). [събитие] Метеоритен дъжд: Съюз Лира губи 14 метал.-- ход 2 -- [движение] Хегемония Орион праща флот от Altan към Vesta (кораби: 4). [битки] Битка при Vesta: Земна империя и Хегемония Орион губят кораби (по 3).-- ход 3 -- [дипломация] Съюз Лира обявява война на Хегемония Орион. [дипломация] Хегемония Орион отказва мир с Земна империя. [битки] Битка при Vesta: Хегемония Орион и Съюз Лира губят кораби (по 1).-- ход 4 -- [дипломация] Мир между Земна империя и Хегемония Орион. [събитие] Метеоритен дъжд: Съюз Лира губи 8 метал.-- ход 5 -- [дипломация] Хегемония Орион още не може да смени отношенията си с Земна империя: прекалено скоро. [събитие] Хегемония Орион намира изоставени кораби при Brisa (кораби: 2).-- край --Земна империя: кораби 0, метал 100Хегемония Орион: кораби 2, метал 100Съюз Лира: кораби 1, метал 78сигнали: 3Още задачи
- Статистика. Добави още един абонат, който брои битките и сбора на загубените кораби (всяка битка губи
nна всяка от двете страни). Печата ги след сигналите. - Примирие. Намали
MIN_PEACEна 2 и пусни програмата. Кои редове от изхода се менят и защо ход 5 вече не е „прекалено скоро“? - Нов вид събитие. Добави
plagueс най-ранен ход 4 и тегло 10: империята губи 5 метал. Кои места променяш: таблицата,rollEvent,newsText, списъка с видовете за новините? А шината?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Списъци: append, remove и sort · dict и set · Ламбди · dataclass, match и шаблони
- Задачи по темата: Epic Cosmos: империята