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 shipsstart = [3, 0, 0, 0]print("una por una: " + show(move_one_by_one(start, ORDERS)))print("a la vez: " + show(move_together(start, ORDERS)))una por una: 0 0 0 3a la vez: 0 3 0 0La 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 tempranoEVENT_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]] += 1print("Sucesos en 1000 turnos:")for i, (event_id, weight, _) in enumerate(EVENT_DEFS): print(event_id + " " + str(weight) + ": " + str(counts[i]))turno 1: boom → Unión de Liraturno 2: breakthrough → Academia de Vegaturno 3: vein → Academia de Vegaturno 4: boom → Academia de Vegaturno 8: pirates → Unión de Liraturno 9: breakthrough → Imperio TerranoSucesos en 1000 turnos:meteor 20: 70vein 15: 53pirates 20: 78breakthrough 15: 58unrest 10: 30boom 15: 50flare 10: 28derelict 8: 28Los 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 =
4turnos 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] = 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)turno 1: war Hegemonía de Orión → Imperio Terrano: okturno 2: war Hegemonía de Orión → Imperio Terrano: alreadyWarturno 2: peace Imperio Terrano → Hegemonía de Orión: tooSoonturno 3: war Unión de Lira → Hegemonía de Orión: okturno 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: tooSoonturno 9: war Hegemonía de Orión → Imperio Terrano: okTurno 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"El umbral es «a partir de 90», es decir, 90 basta: >=. Con > un deseo de exactamente 90 sería un rechazo, y la regla del juego dice lo contrario.
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] = 1world.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 = 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("rencor " + NAMES[0] + " → " + NAMES[1] + ": " + str(world.grudge[0][1]))print("rencor " + NAMES[1] + " → " + NAMES[0] + ": " + str(world.grudge[1][0]))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: 1rencor Hegemonía de Orión → Imperio Terrano: 3El 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ó).
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] += 10Despué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 += 1El 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]))-- 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 100Hegemonía de Orión: naves 2, metal 100Unión de Lira: naves 1, metal 78Lee 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
- 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.
- turno 1 · diplomacia: Hegemonía de Orión declara la guerra a Imperio Terrano.
- 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).
- turno 1 · 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).
- turno 2 · 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. Hegemonía de Orión rechaza la paz con Imperio Terrano.
- turno 3 · 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.
- turno 4 · 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.
- turno 5 · suceso: Hegemonía de Orión encuentra naves abandonadas en Brisa (naves: 2).
- 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 = freshY 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
aiPlayyaiDiplomacy(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
waitse 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)))Primero parten todas: la primera orden toma 3 naves del sistema 0, y la segunda ve cero naves en el sistema 1 (las primeras aún no han llegado) y toma 0. Luego llegan: 3 naves en el sistema 1, 0 en el sistema 2. La segunda opción es el resultado con órdenes una tras otra: 2 de las naves llegadas salen otra vez en el mismo turno; la tercera es el estado inicial, sin movimiento.
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)))a la vez: 0 0 0 3El 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.
Las naves llegan (+= en el destino) ya en el bucle de salida. Cuando llega la segunda orden, las naves de la primera ya están allí y las toma. Lo correcto: en el primer bucle solo se toman (-=) y se recuerdan en takes; la llegada es un segundo bucle, cuando todas ya han salido.
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):
-- 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 100Hegemonía de Orión: naves 2, metal 100Unión de Lira: naves 1, metal 78alertas: 3Más tareas
- Estadística. Añade otro suscriptor que cuente las batallas y el total de naves perdidas (cada batalla pierde
nen cada uno de los dos lados). Las imprime después de las alertas. - Tregua. Baja
MIN_PEACEa 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
plaguecon 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
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Listas: append, remove y sort · dict y set · Lambdas · dataclass, match y patrones
- Ejercicios sobre el tema: Epic Cosmos: el imperio