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]
int[][] orders = [[0, 1, 3], [1, 2, 3], [2, 3, 3]];
int[] MoveOneByOne(int[] start, int[][] orders)
{
var ships = (int[])start.Clone();
foreach (int[] order in orders)
{
int take = Math.Min(order[2], ships[order[0]]);
ships[order[0]] -= take;
ships[order[1]] += take;
}
return ships;
}
int[] MoveTogether(int[] start, int[][] orders)
{
var ships = (int[])start.Clone();
var takes = new int[orders.Length];
// Primero todas las flotas salen de sus sistemas...
for (int i = 0; i < orders.Length; i++)
{
takes[i] = Math.Min(orders[i][2], ships[orders[i][0]]);
ships[orders[i][0]] -= takes[i];
}
// ...y solo entonces llegan.
for (int i = 0; i < orders.Length; i++) ships[orders[i][1]] += takes[i];
return ships;
}
int[] start = [3, 0, 0, 0];
Console.WriteLine("una por una: " + Show(MoveOneByOne(start, orders)));
Console.WriteLine("a la vez: " + Show(MoveTogether(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.

(int[])start.Clone() hace una copia: sin ella la función cambiaría el arreglo 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
EventDef[] eventDefs =
[
new EventDef("meteor", 20, 1),
new EventDef("vein", 15, 1),
new EventDef("pirates", 20, 6),
new EventDef("breakthrough", 15, 1),
new EventDef("unrest", 10, 5),
new EventDef("boom", 15, 1),
new EventDef("flare", 10, 1),
new EventDef("derelict", 8, 3)
];
int PickWeighted(Rng rng, int[] weights)
{
int total = 0;
foreach (int weight in weights) total += weight;
int roll = rng.NextInt(total);
for (int i = 0; i < weights.Length; i++)
{
if (roll < weights[i]) return i;
roll -= weights[i];
}
return weights.Length - 1;
}
Roll? RollEvent(Rng rng, int turn)
{
if (!rng.Chance(EventChance)) return null;
int empire = rng.NextInt(names.Length);
var weights = new int[eventDefs.Length];
for (int i = 0; i < weights.Length; i++)
{
weights[i] = turn >= eventDefs[i].MinTurn ? eventDefs[i].Weight : 0;
}
return new Roll(PickWeighted(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:

var rng = new Rng(7);
for (int turn = 1; turn <= 12; turn++)
{
Roll? roll = RollEvent(rng, turn);
if (roll != null) Console.WriteLine("turno " + turn + ": " + eventDefs[roll.Kind].Id + " → " + names[roll.Empire]);
}
var counts = new int[eventDefs.Length];
for (int i = 0; i < 1000; i++)
{
Roll? roll = RollEvent(rng, 99);
if (roll != null) counts[roll.Kind]++;
}
Console.WriteLine("Sucesos en 1000 turnos:");
for (int i = 0; i < counts.Length; i++)
{
Console.WriteLine(eventDefs[i].Id + " " + eventDefs[i].Weight + ": " + 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 de ellos.
static partial class Cosmos
{
public const int EVENT_CHANCE = 40;
public static Planet? PickEmpirePlanet(World world, Empire empire)
{
List<Planet> list = OwnedPlanets(world, empire.Id);
if (list.Count == 0) return null;
return list[world.Rng.NextInt(list.Count)];
}
public static int[] PirateFleet(World world)
{
int strength = world.Turn / 6;
return new int[] { 2 + strength, strength / 2, strength / 3, 0, 0 };
}
public static List<(string Name, object Value)>? ApplyEvent(World world, string kind, Empire empire)
{
Random rng = world.Rng;
List<(string Name, object Value)> vars = new List<(string Name, object Value)> { ("empire", EmpireName(empire)) };
if (kind == "meteor")
{
Planet? planet = PickEmpirePlanet(world, empire);
if (planet == null) return null;
int loss = MinOf(empire.Res[0], 8 + rng.NextInt(8));
empire.Res[0] -= loss;
vars.Add(("planet", PlanetName(world, planet)));
vars.Add(("n", loss));
}
else if (kind == "vein")
{
Planet? planet = PickEmpirePlanet(world, empire);
if (planet == null) return null;
int r = rng.NextInt(RES_COUNT);
int tries = 0;
while (planet.Rich[r] >= 5 && tries < RES_COUNT)
{
r = (r + 1) % RES_COUNT;
tries++;
}
if (planet.Rich[r] >= 5) return null;
planet.Rich[r]++;
vars.Add(("planet", PlanetName(world, planet)));
vars.Add(("res", T("res." + r)));
}
else if (kind == "pirates")
{
Planet? planet = PickEmpirePlanet(world, empire);
if (planet == null) return null;
int systemId = planet.SystemId;
int[] mine = EmptyCounts();
Fleet? fleet = FleetAt(world, empire.Id, systemId);
for (int k = 0; k < SHIP_KIND_COUNT; k++) mine[k] = fleet == null ? 0 : fleet.Ships[k];
mine[FORT_KIND] = FortsIn(world, empire.Id, systemId);
BattleResult result = RunBattle(SideStatsOf(empire), mine, SideStatsOf(null), PirateFleet(world));
for (int k = 0; k < SHIP_KIND_COUNT; k++)
{
if (fleet != null) fleet.Ships[k] = result.A[k];
}
string systemName = world.Systems[systemId].Name;
vars.Add(("system", systemName));
vars.Add(("fleet", CountsText(PirateFleet(world))));
if (result.Winner == "a")
{
empire.Res[0] += 20;
empire.Res[1] += 10;
vars.Add(("outcome", T("event.pirates.win")));
}
else
{
int m = empire.Res[0] / 5;
int g = empire.Res[1] / 5;
empire.Res[0] -= m;
empire.Res[1] -= g;
vars.Add(("outcome", T("event.pirates.lose", ("m", m), ("g", g))));
}
if (!world.Quiet) world.LastBattle = BattleLines(systemName, EmpireName(empire), T("pirates"), result);
world.Stats.Battles++;
world.Stats.Lost += TotalOf(mine) - TotalOf(result.A) + TotalOf(PirateFleet(world)) - TotalOf(result.B);
}
else if (kind == "breakthrough")
{
int n = 6 + rng.NextInt(7);
empire.Progress += n;
vars.Add(("n", n));
}
else if (kind == "unrest")
{
Planet? planet = PickEmpirePlanet(world, empire);
if (planet == null || planet.Mines == 0) return null;
planet.Mines--;
vars.Add(("planet", PlanetName(world, planet)));
}
else if (kind == "boom")
{
empire.Res[1] += 20;
empire.Res[2] += 10;
}
else if (kind == "flare")
{
empire.Flare = 1;
}
else if (kind == "derelict")
{
Planet? planet = PickEmpirePlanet(world, empire);
if (planet == null) return null;
AddShips(world, empire.Id, planet.SystemId, new int[] { 0, 0, 2, 0 });
vars.Add(("system", world.Systems[planet.SystemId].Name));
}
return vars;
}
public static void RollEvent(World world)
{
Random rng = world.Rng;
if (!rng.Chance(EVENT_CHANCE)) return;
List<Empire> living = new List<Empire>();
foreach (Empire e in world.Empires)
{
if (e.Alive) living.Add(e);
}
if (living.Count == 0) return;
Empire empire = living[rng.NextInt(living.Count)];
List<EventDef> allowed = new List<EventDef>();
List<int> weights = new List<int>();
foreach (EventDef eventDef in EVENT_DEFS)
{
if (world.Turn >= eventDef.MinTurn)
{
allowed.Add(eventDef);
weights.Add(eventDef.Weight);
}
}
EventDef def = allowed[PickWeighted(rng, weights.ToArray())];
List<(string Name, object Value)>? vars = ApplyEvent(world, def.Id, empire);
if (vars == null) return;
world.Stats.Events++;
world.Bus.Publish(new GameEvent { Type = "event", Kind = def.Id, Empire = empire.Id, Vars = vars });
}
// Los suscriptores: las noticias para el jugador, el rencor tras las batallas y las conquistas.
public static void AddNews(World world, string text)
{
world.News.Add(world.Turn + ": " + text);
while (world.News.Count > 60) world.News.RemoveAt(0);
world.TurnLines.Add(text);
}
public static void InstallSubscribers(World world, bool quiet)
{
EventBus bus = world.Bus;
Func<int, int, bool> involvesHuman = (a, b) => world.Empires[a].Human || world.Empires[b].Human;
bus.Subscribe("battle", e =>
{
if (e.LostA > 0) world.Empires[e.B].Grudge[e.A]++;
if (e.LostB > 0) world.Empires[e.A].Grudge[e.B]++;
});
bus.Subscribe("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.
bus.Subscribe("battle", e =>
{
if (involvesHuman(e.A, e.B)) world.Alert++;
});
bus.Subscribe("conquest", e =>
{
if (world.Empires[e.Empire].Human || (e.From >= 0 && world.Empires[e.From].Human)) world.Alert++;
});
bus.Subscribe("war", e =>
{
if (involvesHuman(e.A, e.B)) world.Alert++;
});
bus.Subscribe("siege", e =>
{
if (world.Empires[e.From].Human) world.Alert++;
});
bus.Subscribe("offer", e =>
{
if (world.Empires[e.To].Human) world.Alert++;
});
if (quiet) return;
bus.Subscribe("battle", e =>
{
if (involvesHuman(e.A, e.B))
{
foreach (string line in e.Lines) AddNews(world, line);
}
else
{
AddNews(world, T("news.battle",
("system", world.Systems[e.SystemId].Name), ("a", EmpireName(world.Empires[e.A])), ("b", EmpireName(world.Empires[e.B])),
("la", e.LostA), ("lb", e.LostB)));
}
});
bus.Subscribe("colony", e =>
{
AddNews(world, T("news.colony", ("empire", EmpireName(world.Empires[e.Empire])), ("planet", PlanetName(world, world.Planets[e.PlanetId]))));
});
bus.Subscribe("tech", e =>
{
if (world.Empires[e.Empire].Human) AddNews(world, T("news.tech", ("tech", Nm(TECHS.Get(e.TechId)))));
});
bus.Subscribe("war", e =>
{
AddNews(world, T("news.war", ("a", EmpireName(world.Empires[e.A])), ("b", EmpireName(world.Empires[e.B]))));
});
bus.Subscribe("peace", e =>
{
AddNews(world, T("news.peace", ("a", EmpireName(world.Empires[e.A])), ("b", EmpireName(world.Empires[e.B]))));
});
bus.Subscribe("offer", e =>
{
AddNews(world, T("news.offer", ("empire", EmpireName(world.Empires[e.From])), ("letter", world.Empires[e.From].Def.Letter)));
});
bus.Subscribe("siege", e =>
{
AddNews(world, T("news.siege", ("empire", EmpireName(world.Empires[e.Empire])), ("planet", PlanetName(world, world.Planets[e.PlanetId]))));
});
bus.Subscribe("conquest", e =>
{
AddNews(world, T("news.conquest", ("empire", EmpireName(world.Empires[e.Empire])), ("planet", PlanetName(world, world.Planets[e.PlanetId]))));
});
bus.Subscribe("eliminated", e =>
{
AddNews(world, T("news.eliminated", ("empire", EmpireName(world.Empires[e.Empire]))));
});
bus.Subscribe("event", e =>
{
AddNews(world, T("event." + e.Kind, e.Vars.ToArray()));
});
}
}

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
{
public int Turn = 1;
public int[,] Ships = new int[3, 4];
public int[,] War = new int[3, 3];
public int[,] Since = new int[3, 3];
public int[,] Grudge = new int[3, 3];
}
bool AtWar(World world, int a, int b)
{
return world.War[a, b] == 1;
}
int Power(World world, int empire)
{
int total = 0;
for (int s = 0; s < 4; s++) total += world.Ships[empire, s];
return total;
}
void SetStance(World world, int a, int b, int war)
{
world.War[a, b] = war;
world.War[b, a] = war;
world.Since[a, b] = world.Turn;
world.Since[b, a] = world.Turn;
}
string? DeclareWar(World world, int a, int b)
{
if (AtWar(world, a, b)) return "alreadyWar";
// La paz dura al menos MIN_PEACE turnos.
if (world.Turn - world.Since[a, b] < MinPeace && world.Turn > MinPeace) return "tooSoon";
SetStance(world, a, b, 1);
return null;
}
void MakePeace(World world, int a, int b)
{
SetStance(world, a, b, 0);
world.Grudge[a, b] = 0;
world.Grudge[b, a] = 0;
}
int PeaceWish(World world, int me, int other)
{
// Cuánto más débil es el imperio que el otro.
int ratio = Math.Min(150, Power(world, me) * 100 / (Power(world, other) + 1));
int otherWars = 0;
for (int x = 0; x < 3; x++)
{
if (x != me && x != other && AtWar(world, me, x)) otherWars++;
}
return Math.Max(0, peace[me] + (150 - ratio) / 2 + otherWars * 15 - world.Grudge[me, other] * 3);
}
string? ProposePeace(World world, int a, int b)
{
if (!AtWar(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 (PeaceWish(world, b, a) >= 90)
{
MakePeace(world, a, b);
return null;
}
// Un rechazo guarda rencor: la próxima paz es más difícil.
world.Grudge[b, a]++;
return "refused";
}
void Step(World world, int turn, string kind, int a, int b)
{
world.Turn = turn;
int wish = kind == "peace" ? PeaceWish(world, b, a) : 0;
string? error = kind == "war" ? DeclareWar(world, a, b) : ProposePeace(world, a, b);
string shown = kind == "peace" && (error == null || error == "refused") ? " (deseo " + wish + ")" : "";
Console.WriteLine("turno " + turn + ": " + kind + " " + names[a] + " → " + names[b] + ": " + (error ?? "ok") + shown);
}
var world = new 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).

bool AtWar(World world, int a, int b)
{
    return world.War[a, b] == 1;
}

int Power(World world, int empire)
{
    int total = 0;
    for (int s = 0; s < 4; s++) total += world.Ships[empire, s];
    return total;
}

void SetStance(World world, int a, int b, int war)
{
    world.War[a, b] = war;
    world.War[b, a] = war;
    world.Since[a, b] = world.Turn;
    world.Since[b, a] = world.Turn;
}

string? DeclareWar(World world, int a, int b)
{
    if (AtWar(world, a, b)) return "alreadyWar";
    // La paz dura al menos MIN_PEACE turnos.
    if (world.Turn - world.Since[a, b] < MinPeace && world.Turn > MinPeace) return "tooSoon";
    SetStance(world, a, b, 1);
    return null;
}

void MakePeace(World world, int a, int b)
{
    SetStance(world, a, b, 0);
    world.Grudge[a, b] = 0;
    world.Grudge[b, a] = 0;
}

int PeaceWish(World world, int me, int other)
{
    // Cuánto más débil es el imperio que el otro.
    int ratio = Math.Min(150, Power(world, me) * 100 / (Power(world, other) + 1));
    int otherWars = 0;
    for (int x = 0; x < 3; x++)
    {
        if (x != me && x != other && AtWar(world, me, x)) otherWars++;
    }
    return Math.Max(0, peace[me] + (150 - ratio) / 2 + otherWars * 15 - world.Grudge[me, other] * 3);
}

string? ProposePeace(World world, int a, int b)
{
    if (!AtWar(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 (PeaceWish(world, b, a)  90)
    {
        MakePeace(world, a, b);
        return null;
    }
    // Un rechazo guarda rencor: la próxima paz es más difícil.
    world.Grudge[b, a]++;
    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.
static partial class Cosmos
{
public static bool AtWar(World world, int a, int b)
{
return world.Empires[a].War[b] == 1;
}
public static void SetStance(World world, int a, int b, int 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;
}
public static string? DeclareWar(World world, int a, int b)
{
if (a == b) return "dip.self";
if (!world.Empires[b].Alive) return "dip.dead";
if (AtWar(world, a, b)) return "dip.alreadyWar";
if (world.Turn - world.Empires[a].Since[b] < MIN_PEACE && world.Turn > MIN_PEACE) return "dip.tooSoon";
SetStance(world, a, b, 1);
world.Bus.Publish(new GameEvent { Type = "war", A = a, B = b });
return null;
}
public static void MakePeace(World world, int a, int b)
{
SetStance(world, a, b, 0);
world.Empires[a].Grudge[b] = 0;
world.Empires[b].Grudge[a] = 0;
world.Bus.Publish(new GameEvent { Type = "peace", A = a, B = b });
}
// La menor distancia en rutas entre sistemas con planetas de los dos imperios.
public static int EmpireDistance(World world, int a, int b)
{
int best = 99;
foreach (Planet pa in world.Planets)
{
if (pa.Owner != a) continue;
int[] dist = Bfs(world.Systems, pa.SystemId).Dist;
foreach (Planet pb in world.Planets)
{
if (pb.Owner == b && dist[pb.SystemId] >= 0 && dist[pb.SystemId] < best) best = dist[pb.SystemId];
}
}
return best;
}
public static int PowerRatio(World world, int a, int b, int top)
{
return Clamp(Percent((long)EmpirePower(world, a) * 100, (long)EmpirePower(world, b) * 100 + 100), 0, top);
}
// Cuánto quiere el imperio la paz con el otro (0 y más; desde 90 acepta).
public static int PeaceWish(World world, int me, int other)
{
Empire e = world.Empires[me];
int weaker = 150 - PowerRatio(world, me, other, 150);
int otherWars = 0;
foreach (Empire x in world.Empires)
{
if (x.Alive && x.Id != me && x.Id != other && e.War[x.Id] == 1) otherWars++;
}
int wish = e.Personality.Peace + weaker / 2 + otherWars * 15 - e.Grudge[other] * 3;
return MaxOf(0, wish);
}
// Cuánto quiere el imperio la guerra con el otro.
public static int WarWish(World world, int me, int other)
{
Empire e = world.Empires[me];
int ratio = PowerRatio(world, me, other, 300);
int wish = e.Personality.Attack / 2;
if (ratio > 100) wish += (ratio - 100) / 4;
else wish -= (100 - ratio) / 2;
if (EmpireDistance(world, me, other) <= 3) wish += 25;
foreach (Empire x in world.Empires)
{
if (x.Alive && x.Id != me && e.War[x.Id] == 1) wish -= 30;
}
return wish;
}
public static bool HasOffer(World world, int from, int to)
{
foreach (Offer offer in world.Offers)
{
if (offer.From == from && offer.To == to && world.Turn - offer.Turn <= 3) return true;
}
return false;
}
// Una oferta de paz de a a b. Devuelve una clave de error o null (la paz está hecha).
public static string? ProposePeace(World world, int a, int b)
{
if (a == b) return "dip.self";
if (!world.Empires[b].Alive) return "dip.dead";
if (!AtWar(world, a, b)) return "dip.noWar";
if (world.Turn - world.Empires[a].Since[b] < 2) return "dip.tooSoon";
if (world.Empires[b].Human)
{
if (HasOffer(world, b, a))
{
MakePeace(world, a, b);
return null;
}
if (!HasOffer(world, a, b)) world.Offers.Add(new Offer { From = a, To = b, Turn = world.Turn });
return "dip.sent";
}
if (HasOffer(world, b, a) || PeaceWish(world, b, a) >= 90)
{
MakePeace(world, a, b);
return null;
}
world.Empires[b].Grudge[a]++;
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.
public static void AiDiplomacy(World world, Empire empire)
{
foreach (Empire other in world.Empires)
{
if (!other.Alive || other.Id == empire.Id) continue;
if (AtWar(world, empire.Id, other.Id))
{
if (world.Turn - empire.Since[other.Id] >= 3 && PeaceWish(world, empire.Id, other.Id) >= 90)
{
if (other.Human)
{
if (!HasOffer(world, empire.Id, other.Id))
{
world.Offers.Add(new Offer { From = empire.Id, To = other.Id, Turn = world.Turn });
world.Bus.Publish(new GameEvent { Type = "offer", From = empire.Id, To = other.Id });
}
}
else
{
ProposePeace(world, empire.Id, other.Id);
}
}
}
else if (world.Turn - empire.Since[other.Id] >= MIN_PEACE + 2 && world.Turn > MIN_PEACE + 2)
{
if (WarWish(world, empire.Id, other.Id) >= 95 && world.Rng.Chance(40)) DeclareWar(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».

record Event(string Type, int A, int B, int Place, int N)
{
public Event(string type, int a, int b) : this(type, a, b, 0, 0) { }
}
class EventBus
{
public Dictionary<string, List<Action<Event>>> Handlers = new Dictionary<string, List<Action<Event>>>();
public void Subscribe(string type, Action<Event> handler)
{
if (!Handlers.ContainsKey(type)) Handlers[type] = new List<Action<Event>>();
Handlers[type].Add(handler);
}
public void Publish(Event gameEvent)
{
if (!Handlers.ContainsKey(gameEvent.Type)) return;
foreach (Action<Event> handler in new List<Action<Event>>(Handlers[gameEvent.Type])) handler(gameEvent);
}
}

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:

string? DeclareWar(World world, int a, int b)
{
if (AtWar(world, a, b)) return "alreadyWar";
// La paz dura al menos MIN_PEACE turnos.
if (world.Turn - world.Since[a, b] < MinPeace && world.Turn > MinPeace) return "tooSoon";
SetStance(world, a, b, 1);
world.Bus.Publish(new Event("war", a, b));
return null;
}
void MakePeace(World world, int a, int b)
{
SetStance(world, a, b, 0);
world.Grudge[a, b] = 0;
world.Grudge[b, a] = 0;
world.Bus.Publish(new 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:

var world = new World();
world.Ships[1, 1] = 1;
world.Grudge[1, 0] = 1;
// Las noticias escuchan todos los eventos, el rencor solo las batallas.
foreach (string type in newsTypes)
{
world.Bus.Subscribe(type, e => Console.WriteLine(NewsText(e)));
}
world.Bus.Subscribe("battle", e =>
{
world.Grudge[e.A, e.B]++;
world.Grudge[e.B, e.A]++;
});
world.Turn = 1;
Report(world, DeclareWar(world, 1, 0), 1, 0);
world.Bus.Publish(new Event("battle", 1, 0, 1, 2));
world.Turn = 3;
Report(world, ProposePeace(world, 0, 1), 0, 1);
Report(world, DeclareWar(world, 1, 0), 1, 0);
Console.WriteLine("rencor " + names[0] + " → " + names[1] + ": " + world.Grudge[0, 1]);
Console.WriteLine("rencor " + names[1] + " → " + names[0] + ": " + 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ó).

Action<Event> es un delegado con un parámetro: el manejador es una lambda, e => ....
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
{
public int Turn = 1;
public string Phase = "";
public Rng Rng;
public EventBus Bus = new EventBus();
public int[,] Ships = new int[,] { { 3, 0, 0, 0 }, { 0, 0, 0, 4 }, { 0, 2, 0, 0 } };
public int[] Metal = [50, 50, 50];
public int[,] War = new int[3, 3];
public int[,] Since = new int[3, 3];
public int[,] Grudge = new int[3, 3];
public World(uint seed)
{
Rng = new Rng(seed);
}
}

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]
int[][] orders = [[1, 1, 3, 2, 4], [1, 0, 0, 1, 3], [2, 1, 2, 1, 4]];
// [turno, tipo (0 guerra, 1 paz), desde, hacia]
int[][] 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):

void ScriptedDiplomacy(World world)
{
world.Phase = "diplomacia";
foreach (int[] step in diplo)
{
if (step[0] != world.Turn) continue;
string? error = step[1] == 0 ? DeclareWar(world, step[2], step[3]) : ProposePeace(world, step[2], step[3]);
Report(world, error, step[2], step[3]);
}
}
void ResolveMovement(World world)
{
world.Phase = "movimiento";
var moving = new List<Move>();
// Primero todas las flotas salen de sus sistemas...
foreach (int[] order in orders)
{
if (order[0] != world.Turn) continue;
int take = Math.Min(order[4], world.Ships[order[1], order[2]]);
world.Ships[order[1], order[2]] -= take;
if (take > 0) moving.Add(new Move(order[1], order[2], order[3], take));
}
// ...y solo entonces llegan.
foreach (Move move in moving)
{
world.Ships[move.Empire, move.To] += move.N;
world.Bus.Publish(new Event("move", move.Empire, move.From, move.To, move.N));
}
}
void ResolveBattles(World world)
{
world.Phase = "batallas";
for (int system = 0; system < 4; system++)
{
for (int a = 0; a < 3; a++)
{
for (int b = a + 1; b < 3; b++)
{
if (!AtWar(world, a, b) || world.Ships[a, system] == 0 || world.Ships[b, system] == 0) continue;
int lost = Math.Min(world.Ships[a, system], world.Ships[b, system]);
world.Ships[a, system] -= lost;
world.Ships[b, system] -= lost;
world.Bus.Publish(new Event("battle", a, b, system, lost));
}
}
}
}
void Produce(World world)
{
world.Phase = "producción";
for (int empire = 0; empire < 3; empire++) 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:

void RollEvent(World world)
{
world.Phase = "suceso";
if (!world.Rng.Chance(EventChance)) return;
int empire = world.Rng.NextInt(3);
var weights = new int[eventDefs.Length];
for (int i = 0; i < weights.Length; i++)
{
weights[i] = world.Turn >= eventDefs[i].MinTurn ? eventDefs[i].Weight : 0;
}
string kind = eventDefs[PickWeighted(world.Rng, weights)].Id;
if (kind == "meteor")
{
int loss = Math.Min(world.Metal[empire], 8 + world.Rng.NextInt(8));
world.Metal[empire] -= loss;
world.Bus.Publish(new Event("meteor", empire, 0, 0, loss));
}
else if (kind == "boom")
{
world.Metal[empire] += 20;
world.Bus.Publish(new Event("boom", empire, 0, 0, 20));
}
else
{
world.Ships[empire, home[empire]] += 2;
world.Bus.Publish(new Event("derelict", empire, 0, home[empire], 2));
}
}
void RunTurn(World world)
{
// Las fases van en este orden, y el orden importa.
ScriptedDiplomacy(world);
ResolveMovement(world);
ResolveBattles(world);
Produce(world);
RollEvent(world);
world.Turn++;
}

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:

var world = new World(7);
world.Bus.Subscribe("battle", e =>
{
world.Grudge[e.A, e.B]++;
world.Grudge[e.B, e.A]++;
});
foreach (string type in newsTypes)
{
world.Bus.Subscribe(type, e => Console.WriteLine(" [" + world.Phase + "] " + NewsText(e)));
}
for (int i = 0; i < 5; i++)
{
Console.WriteLine(Fill("-- turno {0} --", world.Turn));
RunTurn(world);
}
Console.WriteLine("-- fin --");
for (int empire = 0; empire < 3; empire++)
{
Console.WriteLine(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».

void RunTurn(World world)
{
// Las fases van en este orden, y el orden importa.
ScriptedDiplomacy(world);
ResolveMovement(world);
ResolveBattles(world);
Produce(world);
RollEvent(world);
world.Turn++;
}
class EventBus
{
public Dictionary<string, List<Action<Event>>> Handlers = new Dictionary<string, List<Action<Event>>>();
public void Subscribe(string type, Action<Event> handler)
{
if (!Handlers.ContainsKey(type)) Handlers[type] = new List<Action<Event>>();
Handlers[type].Add(handler);
}
public void Publish(Event gameEvent)
{
if (!Handlers.ContainsKey(gameEvent.Type)) return;
foreach (Action<Event> handler in new List<Action<Event>>(Handlers[gameEvent.Type])) handler(gameEvent);
}
}
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.
class Move
{
public int Owner;
public int To;
public int Dest;
public int[] Ships = new int[4];
}
static partial class Cosmos
{
public static void ResolveMovement(World world)
{
List<Move> moving = new List<Move>();
foreach (Empire empire in world.Empires)
{
if (!empire.Alive) continue;
foreach (Order order in empire.Orders)
{
Fleet? fleet = FleetAt(world, empire.Id, order.From);
if (fleet == null) continue;
int[] ships = { 0, 0, 0, 0 };
for (int k = 0; k < SHIP_KIND_COUNT; k++)
{
ships[k] = MinOf(order.Ships[k], fleet.Ships[k]);
fleet.Ships[k] -= ships[k];
}
if (TotalOf(ships) > 0) moving.Add(new Move { Owner = empire.Id, To = order.To, Dest = order.Dest, Ships = ships });
}
empire.Orders = new List<Order>();
}
foreach (Move move in moving)
{
AddShips(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.Add(new Order { From = move.To, To = NextStep(world.Systems, move.To, move.Dest), Dest = move.Dest, Ships = (int[])move.Ships.Clone() });
}
}
RemoveEmptyFleets(world);
}
public static bool HasUnits(World world, int owner, int systemId)
{
Fleet? fleet = FleetAt(world, owner, systemId);
return (fleet != null && TotalOf(fleet.Ships) > 0) || FortsNow(world, owner, systemId) > 0;
}
public static void ResolveBattles(World world)
{
foreach (StarSystem system in world.Systems)
{
for (int a = 0; a < world.Empires.Count; a++)
{
for (int b = a + 1; b < world.Empires.Count; b++)
{
if (!world.Empires[a].Alive || !world.Empires[b].Alive || !AtWar(world, a, b)) continue;
if (HasUnits(world, a, system.Id) && HasUnits(world, b, system.Id)) FightAt(world, system.Id, a, b);
}
}
}
RemoveEmptyFleets(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.
public static void ResolveConquest(World world)
{
foreach (StarSystem system in world.Systems)
{
foreach (int pid in system.Planets)
{
Planet planet = world.Planets[pid];
if (planet.Owner < 0) continue;
int owner = planet.Owner;
int attacker = -1;
if (!HasUnits(world, owner, system.Id))
{
foreach (Empire e in world.Empires)
{
if (!e.Alive || e.Id == owner || !AtWar(world, e.Id, owner)) continue;
Fleet? fleet = FleetAt(world, e.Id, system.Id);
if (fleet != null && TotalOf(fleet.Ships) > 0)
{
attacker = e.Id;
break;
}
}
}
if (attacker < 0)
{
planet.Siege = 0;
continue;
}
planet.Siege++;
if (planet.Siege < SIEGE_TURNS)
{
world.Bus.Publish(new GameEvent { Type = "siege", Empire = attacker, From = owner, PlanetId = planet.Id });
continue;
}
planet.Siege = 0;
planet.Owner = attacker;
planet.Mines = MaxOf(0, planet.Mines - 1);
planet.Lab = 0;
planet.Yard = 0;
planet.Fort = 0;
world.Bus.Publish(new GameEvent { Type = "conquest", Empire = attacker, From = owner, PlanetId = planet.Id });
}
}
foreach (Empire empire in world.Empires)
{
if (!empire.Alive || OwnedPlanets(world, empire.Id).Count > 0) continue;
empire.Alive = false;
List<Fleet> kept = new List<Fleet>();
foreach (Fleet fleet in world.Fleets)
{
if (fleet.Owner != empire.Id) kept.Add(fleet);
}
world.Fleets = kept;
empire.Orders = new List<Order>();
world.Bus.Publish(new GameEvent { Type = "eliminated", Empire = empire.Id });
}
}
public static void RunTurn(World world)
{
world.TurnLines = new List<string>();
world.FortLeft = new Dictionary<int, int>();
foreach (Empire empire in world.Empires)
{
if (empire.Alive && !empire.Human) AiDiplomacy(world, empire);
}
foreach (Empire empire in world.Empires)
{
if (empire.Alive && !empire.Human) AiPlay(world, empire);
}
ResolveMovement(world);
ResolveBattles(world);
ResolveConquest(world);
foreach (Empire empire in world.Empires)
{
if (!empire.Alive) continue;
Produce(world, empire);
CompleteResearch(world, empire);
}
FinishQueue(world);
RollEvent(world);
CheckVictory(world);
world.Turn++;
if (world.Turn % 5 == 0)
{
foreach (Empire empire in world.Empires)
{
for (int i = 0; i < empire.Grudge.Length; i++) empire.Grudge[i] = MaxOf(0, empire.Grudge[i] - 1);
}
}
List<Offer> fresh = new List<Offer>();
foreach (Offer offer in world.Offers)
{
if (world.Turn - offer.Turn <= 3) fresh.Add(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 GameEvent
{
public string Type = "";
public int SystemId;
public int A;
public int B;
public int LostA;
public int LostB;
public string Winner = "";
public List<string> Lines = new List<string>();
public int Empire;
public int From;
public int To;
public int PlanetId;
public string TechId = "";
public string Kind = "";
public List<(string Name, object Value)> Vars = new List<(string Name, object Value)>();
}
class EventBus
{
public Dictionary<string, List<Action<GameEvent>>> Handlers = new Dictionary<string, List<Action<GameEvent>>>();
public void Subscribe(string type, Action<GameEvent> handler)
{
if (!Handlers.ContainsKey(type)) Handlers[type] = new List<Action<GameEvent>>();
Handlers[type].Add(handler);
}
public void Publish(GameEvent gameEvent)
{
if (!Handlers.ContainsKey(gameEvent.Type)) return;
foreach (Action<GameEvent> handler in new List<Action<GameEvent>>(Handlers[gameEvent.Type])) handler(gameEvent);
}
}

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?

int[] MoveTogether(int[] start, int[][] orders)
{
var ships = (int[])start.Clone();
var takes = new int[orders.Length];
// Primero todas las flotas salen de sus sistemas...
for (int i = 0; i < orders.Length; i++)
{
takes[i] = Math.Min(orders[i][2], ships[orders[i][0]]);
ships[orders[i][0]] -= takes[i];
}
// ...y solo entonces llegan.
for (int i = 0; i < orders.Length; i++) ships[orders[i][1]] += takes[i];
return ships;
}
string Show(int[] ships)
{
return string.Join(" ", ships);
}
int[][] orders = [[0, 1, 3], [1, 2, 2]];
Console.WriteLine(Show(MoveTogether([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:

int[] start = [3, 0, 0, 0];
Console.WriteLine("a la vez: " + Show(MoveTogether(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 ·