Ir al contenido
wannacode

El turno de la galaxia

En resumen: El turno de la galaxia es una secuencia de fases en un orden fijo; las flotas se mueven a la vez (primero salen todas y luego llegan), los sucesos se sortean por peso, la paz y la guerra tienen una regla de al menos MIN_PEACE turnos, y las noticias y el rencor escuchan el bus de eventos sin que quien publica sepa de ellos.

Problema

Todos actúan a la vez, y el orden decide

En un juego de estrategia por turnos el jugador escribe end (o wait 3 para tres turnos seguidos) y toda la galaxia se mueve: los rivales deciden qué hacer, las flotas parten, hay batallas, los planetas producen, a veces aparecen piratas. Lo que para el jugador es una pulsación de tecla, en el código es un turno: una secuencia de fases en un orden fijo.

El orden no es decoración. Toma el ejemplo más simple: tres flotas en cadena, A→B, B→C, C→D, y las 3 naves están en A. Si procesas las órdenes una tras otra y mueves las naves en el mismo arreglo, las naves llegan de A a D en un turno: la primera orden las pone en B, la segunda las ve allí y las manda a C, la tercera a D. Un jugador que lo vea entiende enseguida que las flotas son teletransportes.

Esta lección construye el turno de una pequeña galaxia de tres imperios (A, B, C) y cuatro sistemas en línea, con las mismas cuatro ideas que en el canon:

  • las fases de un turno y el movimiento simultáneo;
  • los sucesos aleatorios: con qué frecuencia vienen y cuáles más a menudo;
  • la paz y la guerra: una regla que no permite declarar la guerra y hacer la paz en cada turno;
  • el bus de eventos: cómo las noticias, el rencor y las alertas se enteran de lo ocurrido sin que nadie llame a nadie.

Al final hay un turno completo de fases que imprime lo que pasa y una escena que lo muestra.

La idea

Fases y movimiento simultáneo

Un turno es una función que llama a las fases una tras otra. En el canon son: la diplomacia de la IA, sus órdenes, el movimiento de todas las flotas, las batallas, la conquista, la producción y la investigación, el suceso, el final del turno. En nuestra pequeña galaxia son cinco: diplomacia, movimiento, batallas, producción, suceso.

El movimiento se divide en dos: primero todas las flotas salen de sus sistemas, solo entonces llegan. Aquí están las dos formas en la cadena de arriba. Una orden es [desde, hacia, naves]; las 3 naves están en el sistema 0:

# [desde, hacia, naves]
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 = []
# Primero todas las flotas salen de sus sistemas...
for source, target, count in orders:
take = min(count, ships[source])
ships[source] -= take
takes.append(take)
# ...y solo entonces llegan.
for i, order in enumerate(orders):
ships[order[1]] += takes[i]
return ships
start = [3, 0, 0, 0]
print("una por una: " + show(move_one_by_one(start, ORDERS)))
print("a la vez: " + show(move_together(start, ORDERS)))
Salida
una por una: 0 0 0 3
a la vez: 0 3 0 0

La versión una por una está mal sin hacer ruido: el programa no se cae, las flotas simplemente se vuelven rápidas. La simultánea toma para cada orden min(naves de la orden, naves en el sistema al empezar) y las quita (salida), y después las pone (llegada). La segunda orden (B→C) ve cero naves en B, porque las primeras aún no han llegado, y no mueve nada. Por eso la cadena es 0 3 0 0: las naves solo han llegado hasta B.

start[:] es una copia de la lista: sin ella la función cambiaría la lista de quien la llama.
Más precisamente, para curiosos

La misma regla vale para todo lo que ocurre «a la vez»: dos imperios que intercambian sistemas, dos flotas que se cruzan. Si las mueves una por una, la segunda ve el mundo que la primera ya cambió, y el resultado depende del orden de los jugadores. La regla de los dos pasos (toma todo, luego devuelve todo) es la misma que al intercambiar dos variables con una auxiliar: hace falta memoria para lo «viejo».

En el canon el movimiento se parece aún más al nuestro, con una adición: un viaje largo continúa solo. La orden recuerda el destino final (dest), y tras la llegada se anota una orden nueva para el paso siguiente. Así, una flota enviada a través de cinco sistemas viaja cinco turnos, un sistema por turno.

La idea

Sucesos aleatorios por peso

En cada turno hay un 40 % de probabilidad de que ocurra un suceso a un imperio al azar. ¿Cuál? Si todos los tipos tuvieran la misma probabilidad, los piratas vendrían en el primer turno, cuando el imperio no tiene ni una nave. Por eso cada tipo tiene un peso (lo frecuente que es) y un turno más temprano (desde cuándo está permitido). Aquí está la tabla del canon y la elección:

# id, peso, turno más temprano
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)

Elegir por peso es una «ruleta»: se sortea un número de 0 hasta la suma de los pesos y se recorren los tipos restando el peso de cada uno, hasta que el número sea menor que el peso actual. Un tipo que aún no está permitido recibe el peso 0 y nunca sale, y la suma disminuye, así que los demás se vuelven más probables. El programa corre doce turnos y después mil más, para ver cuántas veces sale cada tipo:

rng = Rng(7)
for turn in range(1, 13):
roll = roll_event(rng, turn)
if roll is not None:
print("turno " + 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]] += 1
print("Sucesos en 1000 turnos:")
for i, (event_id, weight, _) in enumerate(EVENT_DEFS):
print(event_id + " " + str(weight) + ": " + str(counts[i]))
Salida
turno 1: boom → Unión de Lira
turno 2: breakthrough → Academia de Vega
turno 3: vein → Academia de Vega
turno 4: boom → Academia de Vega
turno 8: pirates → Unión de Lira
turno 9: breakthrough → Imperio Terrano
Sucesos en 1000 turnos:
meteor 20: 70
vein 15: 53
pirates 20: 78
breakthrough 15: 58
unrest 10: 30
boom 15: 50
flare 10: 28
derelict 8: 28

Los piratas (turno más temprano 6) solo aparecen en el turno 8. En los mil turnos salen con más frecuencia los piratas (78) y los meteoritos (70), con peso 20, y con menos frecuencia la erupción solar (28) y las naves abandonadas (28), con pesos 10 y 8. Las cantidades no son iguales a los pesos, porque es azar, pero su orden es el mismo. La probabilidad de un suceso es 40 % (EVENT_CHANCE), y el generador tiene semilla: la misma entrada da la misma salida en los cuatro lenguajes, así que se puede repetir una grabación del juego.

Más precisamente, para curiosos

En el canon cada suceso hace algo distinto: los meteoritos quitan metal, un filón sube la riqueza de un planeta, los piratas luchan contra la flota del imperio (y se ganan o se pierden recursos), un avance da puntos de ciencia, los disturbios bajan un nivel de mina, un auge da gas y cristal, una erupción silencia los laboratorios durante un turno, las naves abandonadas dan dos fragatas. Aquí están todos (la región events del canon). Fíjate en el final de rollEvent: el resultado no se escribe en la pantalla, se publica. Qué significa eso está en la carta sobre el bus.

# Los sucesos aleatorios: en cada turno hay un 40% de que ocurra uno a un imperio al azar. El tipo es por
# pesos (solo desde su turno más temprano). Se publica en el bus; las noticias, las estadísticas
# y el rencor son suscriptores, y los sucesos no saben nada de ellos.
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))
# Los suscriptores: las noticias para el jugador, el rencor tras las batallas y las conquistas.
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
# Una señal al jugador: ocurrió algo ante lo que "wait" se detiene.
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)

La idea

Paz y guerra: una regla contra el caos

La diplomacia en el juego es una relación entre dos imperios: un par de imperios está en guerra o en paz (war[a][b]). ¿Qué tiene de difícil? Sin una regla, un imperio declararía la guerra en un turno, haría la paz en el siguiente y declararía la guerra otra vez justo después. Por eso:

  • la paz dura al menos MIN_PEACE = 4 turnos antes de una nueva guerra (turn − since < MIN_PEACE; al comienzo del juego la regla no se aplica, para que alguien pueda empezar);
  • una oferta de paz se permite como pronto dos turnos después del cambio;
  • la IA acepta la paz según su deseo: cuánto más débil es que el otro, qué carácter tiene, cuántas otras guerras libra y cuánto rencor guarda. A partir de 90 acepta.

En el programa el deseo está simplificado: carácter + (150 − proporción de naves) / 2 + otras guerras × 15 − rencor × 3, donde el carácter es 40 para Orión (señor de la guerra) y 120 para los otros dos (constructores). Un rechazo sube el rencor, por eso el siguiente intento es más difícil. El guion es la historia de la escena: Orión (B) declara la guerra al Imperio Terrano (A) en el turno 1, Lira (C) a Orión en el turno 3, y A ofrece la paz:

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"
# La paz dura al menos MIN_PEACE turnos.
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):
# Cuánto más débil es el imperio que el otro.
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"
# A partir de 90 el imperio acepta la paz.
if peace_wish(world, b, a) >= 90:
make_peace(world, a, b)
return None
# Un rechazo guarda rencor: la próxima paz es más difícil.
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 = " (deseo " + str(wish) + ")" if kind == "peace" and error in (None, "refused") else ""
print("turno " + str(turn) + ": " + kind + " " + NAMES[a] + " → " + NAMES[b] + ": " + (error or "ok") + shown)
world = World()
world.ships[1][1] = 1
world.grudge[1][0] = 1
step(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] = 0
step(world, 4, "peace", 0, 1)
step(world, 5, "war", 1, 0)
step(world, 9, "war", 1, 0)
Salida
turno 1: war Hegemonía de Orión → Imperio Terrano: ok
turno 2: war Hegemonía de Orión → Imperio Terrano: alreadyWar
turno 2: peace Imperio Terrano → Hegemonía de Orión: tooSoon
turno 3: war Unión de Lira → Hegemonía de Orión: ok
turno 3: peace Imperio Terrano → Hegemonía de Orión: refused (deseo 77)
turno 4: peace Imperio Terrano → Hegemonía de Orión: ok (deseo 124)
turno 5: war Hegemonía de Orión → Imperio Terrano: tooSoon
turno 9: war Hegemonía de Orión → Imperio Terrano: ok

Turno 2: Orión declara otra vez la guerra al mismo oponente (alreadyWar), y A ofrece la paz solo un turno después de la guerra (tooSoon). En el turno 3 A ofrece la paz, pero el deseo de Orión es 40 + 25 + 15 − 3 = 77 (carácter, una nave contra ninguna, una guerra más con Lira, rencor 1) y está por debajo de 90: rechazo, y el rencor pasa a 2. En el turno 4 Orión no tiene naves y su deseo es 40 + 75 + 15 − 6 = 124: acepta. Turno 5: Orión quiere una guerra nueva, pero la paz es del turno 4, y 5 − 4 = 1 es menos que 4: tooSoon. En el turno 9 ya se permite (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"
    # La paz dura al menos MIN_PEACE turnos.
    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):
    # Cuánto más débil es el imperio que el otro.
    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"
    # A partir de 90 el imperio acepta la paz.
    if peace_wish(world, b, a)  90:
        make_peace(world, a, b)
        return None
    # Un rechazo guarda rencor: la próxima paz es más difícil.
    world.grudge[b][a] += 1
    return "refused"
Más precisamente, para curiosos

El resultado de estas funciones es una cadena o nada: null si el cambio se hizo, y una clave de error (alreadyWar, tooSoon, refused, noWar) si no. Los errores son claves, no frases hechas, para que la pantalla pueda traducirlos (el juego viene en cuatro idiomas). Así la misma función sirve al jugador (el comando peace B) y a la IA.

El canon tiene además ofertas al jugador: la IA no puede hacer la paz con una persona por sí sola, así que anota una oferta (offers) que vale tres turnos y publica un evento «offer». Aquí está la región diplomacy entera:

# La diplomacia es paz o guerra entre dos imperios. La paz dura al menos MIN_PEACE turnos antes de
# una nueva guerra. La inteligencia artificial acepta la paz según una medida: cuánto más débil es, cuál es su carácter
# y cuánto rencor tiene. Una oferta al jugador se anota y vale tres turnos.
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))
# La menor distancia en rutas entre sistemas con planetas de los dos imperios.
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)
# Cuánto quiere el imperio la paz con el otro (0 y más; desde 90 acepta).
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)
# Cuánto quiere el imperio la guerra con el otro.
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
# Una oferta de paz de a a b. Devuelve una clave de error o None (la paz está hecha).
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'
# La diplomacia de la inteligencia artificial al final del turno: paz cuando es más débil, guerra cuando es más fuerte.
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)

La idea

Un bus de eventos: quien publica no sabe quién escucha

Cuando se declara la guerra, tienen que pasar muchas cosas: hay que escribir una noticia, tiene que crecer el rencor de los dos imperios, tiene que encenderse una alerta para el jugador, hay que contar la guerra en las estadísticas. Lo más simple es que declareWar llame a todo eso. Entonces declareWar conoce las noticias, el rencor, la alerta y las estadísticas, y cada función nueva es una línea más en ella y un motivo más para que se rompa. El bus invierte la dirección: declareWar dice «se ha declarado la guerra entre A y B» y termina. A quien le interese, se ha suscrito a «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 anota un manejador para un tipo de evento, publish llama a todos los manejadores de ese tipo. Un tipo sin suscriptores no es un error: el evento no llega a nadie. Aquí está la publicación en los dos sitios donde ocurre (declarar la guerra y hacer la paz). Lo único nuevo es la línea con publish:

def declare_war(world, a, b):
if at_war(world, a, b):
return "alreadyWar"
# La paz dura al menos MIN_PEACE turnos.
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))

Y aquí está el programa. Las noticias escuchan todos los tipos, el rencor solo las batallas, y report publica también los intentos fallidos (refused, tooSoon) como eventos:

world = World()
world.ships[1][1] = 1
world.grudge[1][0] = 1
def grudge(event):
world.grudge[event.a][event.b] += 1
world.grudge[event.b][event.a] += 1
# Las noticias escuchan todos los eventos, el rencor solo las batallas.
for event_type in NEWS_TYPES:
world.bus.subscribe(event_type, lambda event: print(news_text(event)))
world.bus.subscribe("battle", grudge)
world.turn = 1
report(world, declare_war(world, 1, 0), 1, 0)
world.bus.publish(Event("battle", 1, 0, 1, 2))
world.turn = 3
report(world, propose_peace(world, 0, 1), 0, 1)
report(world, declare_war(world, 1, 0), 1, 0)
print("rencor " + NAMES[0] + " → " + NAMES[1] + ": " + str(world.grudge[0][1]))
print("rencor " + NAMES[1] + " → " + NAMES[0] + ": " + str(world.grudge[1][0]))
Salida
Hegemonía de Orión declara la guerra a Imperio Terrano.
Batalla en Vesta: Hegemonía de Orión y Imperio Terrano pierden naves (2 cada uno).
Hegemonía de Orión rechaza la paz con Imperio Terrano.
Hegemonía de Orión ya está en guerra con Imperio Terrano.
rencor Imperio Terrano → Hegemonía de Orión: 1
rencor Hegemonía de Orión → Imperio Terrano: 3

El intento del turno 1 sale bien, la batalla se publica a mano, el rechazo del turno 3 es un evento (refused), y la guerra repetida es alreadyWar. El rencor de A hacia B es 1 (de la batalla) y el de B hacia A es 3: uno viejo, uno de la batalla y uno del rechazo (un rechazo sube el rencor de quien rechaza hacia quien ofreció).

El manejador es una función corriente (o un lambda); la clase EventBus no sabe nada de él salvo que puede llamarlo.
Más precisamente, para curiosos

Sin bus, todo lo nuevo que deba enterarse de una guerra pide un cambio en declareWar. Con el bus, un suscriptor nuevo es solo una línea nueva al crear el mundo, y la función de la guerra no se toca. Este es el patrón observador (observer) del módulo de patrones de diseño.

Un detalle pequeño pero importante: publish recorre una copia de la lista de suscriptores. Un manejador puede suscribirse a otro evento mientras lo llaman; si la lista cambia durante el recorrido, se saltan o se repiten suscriptores. La copia deja clara la regla: un suscriptor nuevo oye desde la próxima publicación.

El orden de los suscriptores es el orden de subscribe. Aquí no importa (las noticias y el rencor no se estorban), pero si dos suscriptores dependen uno del otro, el orden decidirá el resultado, y esa es una razón para mantenerlos independientes.

La idea

El turno completo

Ahora todo junto. El mundo es World: turno, fase, generador, bus, las flotas de los tres imperios (por sistemas), metal y las tablas de guerra (war), desde qué turno (since) y rencor (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)]

Las órdenes y la diplomacia de cada turno son un guion (en el juego las decide la IA o el jugador): una orden es [turno, imperio, desde, hacia, naves], y una entrada de diplomacia [turno, tipo, desde, hacia].

# [turno, imperio, desde, hacia, naves]
ORDERS = [(1, 1, 3, 2, 4), (1, 0, 0, 1, 3), (2, 1, 2, 1, 4)]
# [turno, tipo (0 guerra, 1 paz), desde, hacia]
DIPLO = [(1, 0, 1, 0), (3, 0, 2, 1), (3, 1, 0, 1), (4, 1, 0, 1), (5, 0, 1, 0)]

Las fases son funciones. La diplomacia ejecuta el guion del turno y publica los errores. El movimiento son exactamente los dos pasos de la primera carta. Las batallas recorren los sistemas: donde dos imperios en guerra tienen naves, cada uno pierde tantas como la flota menor, y se publica una «battle» (el rencor se suscribe a ella):

def scripted_diplomacy(world):
world.phase = "diplomacia"
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 = "movimiento"
moving = []
# Primero todas las flotas salen de sus sistemas...
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))
# ...y solo entonces llegan.
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 = "batallas"
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 = "producción"
for empire in range(3):
world.metal[empire] += 10

Después viene el suceso: un sorteo según los pesos de la segunda carta, con estos efectos: un meteorito quita metal, un auge da metal, las naves abandonadas dan dos naves en la capital. Por último runTurn las llama en orden:

def roll_event(world):
world.phase = "suceso"
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):
# Las fases van en este orden, y el orden importa.
scripted_diplomacy(world)
resolve_movement(world)
resolve_battles(world)
produce(world)
roll_event(world)
world.turn += 1

El programa suscribe las noticias (que ponen delante de cada línea la fase en que ocurrió el suceso) y el rencor, corre cinco turnos e imprime el resultado. Las líneas son exactamente las noticias del juego:

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("-- turno {0} --", world.turn))
run_turn(world)
print("-- fin --")
for empire in range(3):
print(fill("{0}: naves {1}, metal {2}", NAMES[empire], power(world, empire), world.metal[empire]))
Salida
-- turno 1 --
[diplomacia] Hegemonía de Orión declara la guerra a Imperio Terrano.
[movimiento] Hegemonía de Orión envía una flota de Brisa a Altan (naves: 4).
[movimiento] Imperio Terrano envía una flota de Kora a Vesta (naves: 3).
[suceso] Lluvia de meteoritos: Unión de Lira pierde 14 de metal.
-- turno 2 --
[movimiento] Hegemonía de Orión envía una flota de Altan a Vesta (naves: 4).
[batallas] Batalla en Vesta: Imperio Terrano y Hegemonía de Orión pierden naves (3 cada uno).
-- turno 3 --
[diplomacia] Unión de Lira declara la guerra a Hegemonía de Orión.
[diplomacia] Hegemonía de Orión rechaza la paz con Imperio Terrano.
[batallas] Batalla en Vesta: Hegemonía de Orión y Unión de Lira pierden naves (1 cada uno).
-- turno 4 --
[diplomacia] Paz entre Imperio Terrano y Hegemonía de Orión.
[suceso] Lluvia de meteoritos: Unión de Lira pierde 8 de metal.
-- turno 5 --
[diplomacia] Hegemonía de Orión aún no puede cambiar sus relaciones con Imperio Terrano: demasiado pronto.
[suceso] Hegemonía de Orión encuentra naves abandonadas en Brisa (naves: 2).
-- fin --
Imperio Terrano: naves 0, metal 100
Hegemonía de Orión: naves 2, metal 100
Unión de Lira: naves 1, metal 78

Lee la salida como una historia. Turno 1: Orión declara la guerra, las dos flotas (la de Orión y la del Imperio Terrano) parten a la vez, y Lira pierde metal por un meteorito. Turno 2: la flota de Orión llega a Vesta, donde ya está el Imperio Terrano, y los dos pierden 3 naves. Turno 3: Lira entra en la guerra y en la batalla de Vesta pierden 1 cada uno; antes, A ofrece la paz y es rechazado. Turno 4: Orión no tiene naves, su deseo es 124 y la paz se hace. Turno 5: una guerra nueva es «demasiado pronto», y Orión encuentra dos naves abandonadas en su capital.

Míralo

Un turno de la galaxia

Aquí está lo mismo turno a turno. Arriba se enciende la fase en la que ocurrió algo. En el centro están los sistemas y las flotas de los tres imperios: en el movimiento todas las flotas despegan a la vez y solo entonces aterrizan. Debajo se ve si cada pareja está en guerra, y abajo del todo el bus: el evento publicado enciende al suscriptor «noticias», y si es una batalla, también «rencor».

def run_turn(world):
# Las fases van en este orden, y el orden importa.
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)
Paso a paso, en palabras
  1. A = Imperio Terrano, B = Hegemonía de Orión, C = Unión de Lira. Cuatro sistemas en línea; el turno recorre las fases de arriba abajo.
  2. turno 1 · diplomacia: Hegemonía de Orión declara la guerra a Imperio Terrano.
  3. turno 1 · movimiento: Hegemonía de Orión envía una flota de Brisa a Altan (naves: 4). Imperio Terrano envía una flota de Kora a Vesta (naves: 3).
  4. turno 1 · suceso: Lluvia de meteoritos: Unión de Lira pierde 14 de metal.
  5. turno 2 · movimiento: Hegemonía de Orión envía una flota de Altan a Vesta (naves: 4).
  6. turno 2 · batallas: Batalla en Vesta: Imperio Terrano y Hegemonía de Orión pierden naves (3 cada uno).
  7. turno 3 · diplomacia: Unión de Lira declara la guerra a Hegemonía de Orión. Hegemonía de Orión rechaza la paz con Imperio Terrano.
  8. turno 3 · batallas: Batalla en Vesta: Hegemonía de Orión y Unión de Lira pierden naves (1 cada uno).
  9. turno 4 · diplomacia: Paz entre Imperio Terrano y Hegemonía de Orión.
  10. turno 4 · suceso: Lluvia de meteoritos: Unión de Lira pierde 8 de metal.
  11. turno 5 · diplomacia: Hegemonía de Orión aún no puede cambiar sus relaciones con Imperio Terrano: demasiado pronto.
  12. turno 5 · suceso: Hegemonía de Orión encuentra naves abandonadas en Brisa (naves: 2).
  13. Tras 5 turnos: Imperio Terrano: naves 0, metal 100 · Hegemonía de Orión: naves 2, metal 100 · Unión de Lira: naves 1, metal 78

El código junto a la escena es runTurn y EventBus del programa. Se encienden las líneas que trabajan en ese momento.

La idea

En el juego

Lo que construimos es lo mismo que en el canon, solo que más pequeño. Aquí está el turno real (la región turn). Encima de runTurn están el movimiento, que hace los mismos dos pasos (salir, llegar), las batallas y la conquista de planetas; runTurn llama a las fases en un orden fijo, y al final del turno el rencor se atenúa (cada quinto turno baja 1) y se descartan las ofertas de paz viejas.

# Un turno de la galaxia tiene fases: diplomacia de la inteligencia artificial, sus órdenes, movimiento de
# todas las flotas a la vez, batallas, conquista, producción y ciencia, un suceso, fin del turno.
# El movimiento es simultáneo: primero todas las naves salen de sus sistemas, solo después llegan.
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)
# el camino largo continúa solo: una orden nueva desde el lugar donde llegaron
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)
# Un planeta cae cuando un enemigo está en guerra con su dueño, tiene naves en el sistema y el dueño no tiene
# ni naves ni fortalezas supervivientes allí, durante SIEGE_TURNS turnos seguidos: los primeros turnos son asedio,
# el último es la toma. El defensor tiene tiempo de traer una flota. Los edificios se destruyen.
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

Y aquí está el bus que escuchan las noticias, el rencor y la alerta al jugador. Los eventos son una clase con más campos (en JavaScript, un objeto), y el bus es el mismo:

# El bus de sucesos: quien publica no sabe quién escucha. Las noticias, el rencor de los imperios y
# las estadísticas se suscriben solas, y la batalla solo dice "una flota combatió en este sistema".
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)

Las diferencias con nuestro programa son muchas, y por eso importan:

  • No hay guion. Las órdenes y la diplomacia vienen de aiPlay y aiDiplomacy (la lección sobre los rivales) y de los comandos del jugador.
  • La galaxia es un grafo (la lección sobre cómo se genera), no una línea. Una orden recuerda su destino final y continúa sola.
  • La batalla (fightAt, la lección sobre las flotas) tiene roles de naves y seis rondas, no «cada uno pierde la cifra menor». También hay fortalezas y la conquista de un planeta (dos asedios).
  • El deseo de paz usa la fuerza del imperio (no el número de naves), su carácter y su deseo de guerra (warWish). Una oferta al jugador se anota durante tres turnos.
  • La producción aquí es un 10 de metal fijo por imperio; en el canon depende de los planetas, las minas y las tecnologías.
  • Los sucesos son ocho tipos, con efectos más variados (los piratas luchan con el mismo modelo de batalla).
  • El bus lleva también otros tipos: conquista, asedio, una tecnología descubierta, una colonia, un imperio destruido. Las noticias y la alerta al jugador los escuchan, y wait se detiene cuando la alerta crece.

Pruébalo

Predice la salida

Dos flotas: la primera orden envía las 3 naves del sistema 0 al 1, la segunda envía 2 naves del sistema 1 al 2. Las órdenes se procesan a la vez. ¿Qué imprime el programa?

def move_together(start, orders):
ships = start[:]
takes = []
# Primero todas las flotas salen de sus sistemas...
for source, target, count in orders:
take = min(count, ships[source])
ships[source] -= take
takes.append(take)
# ...y solo entonces llegan.
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)))
Respuesta

Pruébalo

Encuentra el error

Un colega «arregló» el movimiento simultáneo, pero con él las naves siguen pasando por varios sistemas en un turno:

start = [3, 0, 0, 0]
print("a la vez: " + show(move_together(start, ORDERS)))
Salida
a la vez: 0 0 0 3

El resultado es como con el movimiento una por una. ¿Qué línea envía las naves al destino demasiado pronto?

Elige la línea con el error.

Más precisamente

Con más precisión: orden, justicia y azar

El orden de los jugadores. Con movimiento simultáneo el orden de las órdenes no cambia el resultado, porque todos toman primero del comienzo del turno. En las batallas, en cambio, hay un orden: sistema a sistema, pareja a pareja (a < b). Si hay tres imperios en guerra en un sistema, la primera pareja del orden lucha primero y los supervivientes pasan a la siguiente. El orden es fijo, por eso el juego sigue siendo repetible.

Las fases son un contrato. La diplomacia va antes del movimiento, porque así una guerra recién declarada puede empezar una batalla en el mismo turno. La producción va después de la conquista: un planeta tomado en este turno ya no produce para su antiguo dueño. El suceso va casi al final y trabaja con el mundo tal como ha quedado (el meteorito toma del metal que ya se produjo). Cambia dos fases de sitio y el juego sigue «igual», pero el resultado es otro. Por eso el orden está en un solo lugar (runTurn) y se lee como una lista.

Azar y repetibilidad. Todo lo aleatorio (la probabilidad de un suceso, qué imperio, qué tipo, cuánto metal) pasa por un generador con semilla. Si dos fases se intercambian y las dos sortean números, la secuencia es otra. En nuestro programa solo rollEvent usa el generador, por eso la salida es estable; en el canon también sortean números la diplomacia y la IA, y el orden de las fases decide quién recibe cada número.

Qué no va en el bus. El bus es para noticias: «esto ha pasado». No es para órdenes («haz esto»): quien publica no sabe si alguien lo oyó y no espera respuesta. Publica hechos, no mandatos.

Ejercicio

Una alerta para el jugador

Resuelve la tarea en tu editor. Parte del programa del turno completo.

Alerta. En el juego wait se detiene en cuanto algo le pasa al jugador. Añade un suscriptor «alerta» que cuente los eventos war, peace y battle en los que participa el imperio 0 (el jugador), como a o como b. Después del resumen el programa imprime una línea más con las alertas (alertas: …). No toques declareWar, makePeace ni resolveBattles: el suscriptor es solo una línea nueva al crear el mundo y una función.

La salida es (la última línea es la nueva):

Salida
-- turno 1 --
[diplomacia] Hegemonía de Orión declara la guerra a Imperio Terrano.
[movimiento] Hegemonía de Orión envía una flota de Brisa a Altan (naves: 4).
[movimiento] Imperio Terrano envía una flota de Kora a Vesta (naves: 3).
[suceso] Lluvia de meteoritos: Unión de Lira pierde 14 de metal.
-- turno 2 --
[movimiento] Hegemonía de Orión envía una flota de Altan a Vesta (naves: 4).
[batallas] Batalla en Vesta: Imperio Terrano y Hegemonía de Orión pierden naves (3 cada uno).
-- turno 3 --
[diplomacia] Unión de Lira declara la guerra a Hegemonía de Orión.
[diplomacia] Hegemonía de Orión rechaza la paz con Imperio Terrano.
[batallas] Batalla en Vesta: Hegemonía de Orión y Unión de Lira pierden naves (1 cada uno).
-- turno 4 --
[diplomacia] Paz entre Imperio Terrano y Hegemonía de Orión.
[suceso] Lluvia de meteoritos: Unión de Lira pierde 8 de metal.
-- turno 5 --
[diplomacia] Hegemonía de Orión aún no puede cambiar sus relaciones con Imperio Terrano: demasiado pronto.
[suceso] Hegemonía de Orión encuentra naves abandonadas en Brisa (naves: 2).
-- fin --
Imperio Terrano: naves 0, metal 100
Hegemonía de Orión: naves 2, metal 100
Unión de Lira: naves 1, metal 78
alertas: 3

Más tareas

  • Estadística. Añade otro suscriptor que cuente las batallas y el total de naves perdidas (cada batalla pierde n en cada uno de los dos lados). Las imprime después de las alertas.
  • Tregua. Baja MIN_PEACE a 2 y ejecuta el programa. ¿Qué líneas de la salida cambian y por qué el turno 5 ya no es «demasiado pronto»?
  • Un tipo de suceso nuevo. Añade plague con turno más temprano 4 y peso 10: el imperio pierde 5 de metal. ¿Qué sitios cambias: la tabla, rollEvent, newsText, la lista de tipos para las noticias? ¿Y el bus?

Test

Ponte a prueba

1. Tres órdenes forman una cadena: A→B, B→C, C→D, con 3 naves en cada una. Al principio las 3 naves están en A. Orden tras orden, las naves en los sistemas A, B, C, D quedan 0 0 0 3 (todas en D), y a la vez quedan 0 3 0 0 (todas en B). ¿Por qué?
2. En la tabla del canon, 8 tipos de suceso tienen pesos que suman 113. En el turno 3 los piratas (turno más temprano 6) y los disturbios (5) todavía no están permitidos. ¿Qué probabilidad hay de que una lluvia de meteoritos (peso 20) sea el suceso, si en el turno ocurre un suceso?
3. Orión y el Imperio Terrano hacen la paz en el turno 4. En el turno 5 Orión quiere guerra otra vez. ¿Por qué le dicen «demasiado pronto»?
4. ¿Por qué publish recorre una copia de la lista de suscriptores y no la lista misma?
5. Quieres contar cuántas veces se ha declarado la guerra. ¿Qué cambias en declareWar?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·