Към съдържанието
wannacode

Ходът на галактиката

Накратко: Ходът на галактиката е редица от фази в твърд ред; флотите се движат едновременно (първо тръгват всички, после пристигат), събитията се теглят по тегла, мирът и войната имат правило за най-малко MIN_PEACE хода, а новините и озлобването слушат шината от събития, без публикуващият да знае за тях.

Проблем

Всички играят наведнъж, а редът решава

В стратегията по ходове играчът пише end (или wait 3 за три хода подред) и цялата галактика се мърда: съперниците решават какво да правят, флотите тръгват, стават битки, планетите произвеждат, понякога дойдат пирати. Това, което за играча е едно натискане на клавиш, в кода е ход: поредица от фази в твърд ред.

Редът не е украса. Вземи най-простия пример: три флота във верига, A→B, B→C, C→D, и всичките 3 кораба са в A. Ако обработваш поръчките една след друга и местиш корабите в същия масив, корабите стигат от A до D за един ход: първата поръчка ги слага в B, втората ги вижда там и ги праща в C, третата в D. Играч, който види това, разбира веднага, че флотите са телепорти.

Този урок строи хода на малка галактика от три империи (A, B, C) и четири системи по права линия, със същите четири идеи като в канона:

  • фазите на хода и едновременното движение;
  • случайните събития: колко често идват и кои по-често;
  • мирът и войната: правило, което не позволява да се обявява и сключва мир всеки ход;
  • шината от събития: как новините, озлобването и сигналите научават какво се е случило, без никой да вика никого.

В края има цял ход от фази, който печата какво става, и сцена, която го показва.

Идеята

Фази и едновременно движение

Ходът е функция, която вика фази една след друга. В канона те са: дипломация на изкуствения интелект, неговите поръчки, движение на всички флотове, битки, завладяване, производство и наука, събитие, край на хода. В нашата малка галактика са пет: дипломация, движение, битки, производство, събитие.

Движението се разбива на две: първо всички флоти напускат системите си, чак тогава пристигат. Ето двата начина върху веригата от по-горе. Поръчката е [от, към, кораби]; корабите са 3 в системата 0:

// [от, към, кораби]
static final int[][] ORDERS = {{0, 1, 3}, {1, 2, 3}, {2, 3, 3}};
static int[] moveOneByOne(int[] start, int[][] orders) {
int[] ships = start.clone();
for (int[] order : orders) {
int take = Math.min(order[2], ships[order[0]]);
ships[order[0]] -= take;
ships[order[1]] += take;
}
return ships;
}
static int[] moveTogether(int[] start, int[][] orders) {
int[] ships = start.clone();
int[] takes = new int[orders.length];
// Първо всички флоти напускат системите си...
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];
}
// ...и чак тогава пристигат.
for (int i = 0; i < orders.length; i++) ships[orders[i][1]] += takes[i];
return ships;
}
int[] start = {3, 0, 0, 0};
System.out.println("последователно: " + show(moveOneByOne(start, ORDERS)));
System.out.println("едновременно: " + show(moveTogether(start, ORDERS)));
Изход
последователно: 0 0 0 3
едновременно: 0 3 0 0

Последователният вариант е тихо грешен: програмата не се чупи, флотите просто стават бързи. Едновременният взима за всяка поръчка min(кораби от поръчката, кораби в системата в началото) и ги маха (напускане), а после ги слага (пристигане). Втората поръчка (B→C) вижда в B нула кораби, защото първите още не са пристигнали, и не мести нищо. Затова веригата е 0 3 0 0: корабите са стигнали само до B.

int[] takes пази колко кораба е взела всяка поръчка: между двата цикъла това е единствената памет.
По-точно, за любопитните

Същото правило важи за всичко, което се случва „наведнъж“: две империи, които си разменят системи, два флота, които се разминават. Ако движиш ги един по един, вторият вижда света, който първият вече е променил, и резултатът зависи от реда на играчите. Правилото на двете стъпки (вземи всичко, после върни всичко) е същото като при размяна на две променливи с помощна: нужна е памет за „старото“.

В канона движението е още по-близо до нашето, но с една добавка: дългият път продължава сам. Поръчката помни крайната цел (dest), а след пристигане се записва нова поръчка за следващата стъпка. Затова флот, пратен през пет системи, върви пет хода, по една система на ход.

Идеята

Случайни събития по тегла

На всеки ход с шанс 40% се случва едно събитие на случайна империя. Кое точно? Ако всички видове имат равен шанс, пиратите ще идват на първия ход, когато империята няма и един кораб. Затова всеки вид има тегло (колко често е) и най-ранен ход (от кога е позволен). Ето таблицата на канона и избора:

// id, тегло, най-ранен ход
static final EventDef[] EVENT_DEFS = {
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)
};
static int pickWeighted(Rng rng, int[] weights) {
int total = 0;
for (int weight : 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;
}
static Roll rollEvent(Rng rng, int turn) {
if (!rng.chance(EVENT_CHANCE)) return null;
int empire = rng.nextInt(NAMES.length);
int[] weights = new int[EVENT_DEFS.length];
for (int i = 0; i < weights.length; i++) {
weights[i] = turn >= EVENT_DEFS[i].minTurn() ? EVENT_DEFS[i].weight() : 0;
}
return new Roll(pickWeighted(rng, weights), empire);
}

Изборът по тегла е „рулетка“: хвърля се число от 0 до сбора на теглата и се върви по видовете, като се изважда теглото на всеки, докато числото стане по-малко от текущото тегло. Вид, който още не е позволен, получава тегло 0 и никога не излиза, а сборът намалява, така че другите стават по-вероятни. Програмата пуска дванайсет хода и после още хиляда, за да се види колко пъти излиза всеки вид:

Rng rng = new Rng(7);
for (int turn = 1; turn <= 12; turn++) {
Roll roll = rollEvent(rng, turn);
if (roll != null) System.out.println("ход " + turn + ": " + EVENT_DEFS[roll.kind()].id() + " → " + NAMES[roll.empire()]);
}
int[] counts = new int[EVENT_DEFS.length];
for (int i = 0; i < 1000; i++) {
Roll roll = rollEvent(rng, 99);
if (roll != null) counts[roll.kind()]++;
}
System.out.println("Събития за 1000 хода:");
for (int i = 0; i < counts.length; i++) {
System.out.println(EVENT_DEFS[i].id() + " " + EVENT_DEFS[i].weight() + ": " + counts[i]);
}
Изход
ход 1: boom → Съюз Лира
ход 2: breakthrough → Академия Вега
ход 3: vein → Академия Вега
ход 4: boom → Академия Вега
ход 8: pirates → Съюз Лира
ход 9: breakthrough → Земна империя
Събития за 1000 хода:
meteor 20: 70
vein 15: 53
pirates 20: 78
breakthrough 15: 58
unrest 10: 30
boom 15: 50
flare 10: 28
derelict 8: 28

Пиратите (най-ранен ход 6) се виждат чак на ход 8. В хилядата хода най-често излизат пиратите (78) и метеоритите (70) с тегло 20, най-рядко изригването (28) и изоставените кораби (28) с тегла 10 и 8. Бройките не са равни на теглата, защото е случайно, но редът им е същият. Шансът за събитие е 40% (EVENT_CHANCE), а генераторът е със семе: един и същ вход дава един и същи изход на четирите езика, затова записът на играта може да се повтори.

По-точно, за любопитните

В канона всяко събитие прави нещо различно: метеоритите отнемат метал, жилата вдига богатството на една планета, пиратите се бият с флота на империята (и се печелят или губят ресурси), пробивът в науката дава точки, размириците свалят ниво на мина, бумът дава газ и кристал, изригването спира лабораториите за един ход, изоставените кораби дават две фрегати. Ето всичките (регион events от канона). Обърни внимание на края на rollEvent: резултатът не се пише на екрана, а се публикува. Какво значи това, е в картата за шината.

// Случайните събития: на всеки ход с 40% шанс се случва едно на случайна империя. Видът е по
// тегла (само от най-ранния му ход нататък). Всичко се публикува в шината; новините, статистиката
// и озлобването са абонати, а събитията не знаят за тях.
static final int EVENT_CHANCE = 40;
static Planet pickEmpirePlanet(World world, Empire empire) {
List<Planet> list = ownedPlanets(world, empire.id);
if (list.size() == 0) return null;
return list.get(world.rng.nextInt(list.size()));
}
static int[] pirateFleet(World world) {
int strength = world.turn / 6;
return new int[] {2 + strength, strength / 2, strength / 3, 0, 0};
}
static Map<String, Object> applyEvent(World world, String kind, Empire empire) {
Random rng = world.rng;
Map<String, Object> vars = new LinkedHashMap<>();
vars.put("empire", empireName(empire));
if (kind.equals("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.put("planet", planetName(world, planet));
vars.put("n", loss);
} else if (kind.equals("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.put("planet", planetName(world, planet));
vars.put("res", t("res." + r));
} else if (kind.equals("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(sideStats(empire), mine, sideStats(null), pirateFleet(world));
for (int k = 0; k < SHIP_KIND_COUNT; k++) {
if (fleet != null) fleet.ships[k] = result.a[k];
}
vars.put("system", world.systems.get(systemId).name);
vars.put("fleet", countsText(pirateFleet(world)));
if (result.winner.equals("a")) {
empire.res[0] += 20;
empire.res[1] += 10;
vars.put("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.put("outcome", t("event.pirates.lose", "m", m, "g", g));
}
if (!world.quiet) world.last.battle = battleLines((String) vars.get("system"), 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.equals("breakthrough")) {
int n = 6 + rng.nextInt(7);
empire.progress += n;
vars.put("n", n);
} else if (kind.equals("unrest")) {
Planet planet = pickEmpirePlanet(world, empire);
if (planet == null || planet.mines == 0) return null;
planet.mines--;
vars.put("planet", planetName(world, planet));
} else if (kind.equals("boom")) {
empire.res[1] += 20;
empire.res[2] += 10;
} else if (kind.equals("flare")) {
empire.flare = 1;
} else if (kind.equals("derelict")) {
Planet planet = pickEmpirePlanet(world, empire);
if (planet == null) return null;
addShips(world, empire.id, planet.systemId, new int[] {0, 0, 2, 0});
vars.put("system", world.systems.get(planet.systemId).name);
}
return vars;
}
static void rollEvent(World world) {
Random rng = world.rng;
if (!rng.chance(EVENT_CHANCE)) return;
List<Empire> living = new ArrayList<>();
for (Empire e : world.empires) {
if (e.alive) living.add(e);
}
if (living.size() == 0) return;
Empire empire = living.get(rng.nextInt(living.size()));
List<EventDef> allowed = new ArrayList<>();
List<Integer> weightList = new ArrayList<>();
for (EventDef def : EVENT_DEFS) {
if (world.turn >= def.minTurn) {
allowed.add(def);
weightList.add(def.weight);
}
}
int[] weights = new int[weightList.size()];
for (int i = 0; i < weights.length; i++) weights[i] = weightList.get(i);
EventDef def = allowed.get(pickWeighted(rng, weights));
Map<String, Object> vars = applyEvent(world, def.id, empire);
if (vars == null) return;
world.stats.events++;
GameEvent event = new GameEvent("event");
event.kind = def.id;
event.empire = empire.id;
event.vars = vars;
world.bus.publish(event);
}
// Абонатите: новините за играча, озлобването след битки и завладяване.
static void addNews(World world, String text) {
world.news.add(world.turn + ": " + text);
while (world.news.size() > 60) world.news.remove(0);
world.turnLines.add(text);
}
static void installSubscribers(World world, boolean quiet) {
EventBus bus = world.bus;
BiPredicate<Integer, Integer> involvesHuman = (a, b) -> world.empires.get(a).human || world.empires.get(b).human;
bus.subscribe("battle", e -> {
if (e.lostA > 0) world.empires.get(e.b).grudge[e.a]++;
if (e.lostB > 0) world.empires.get(e.a).grudge[e.b]++;
});
bus.subscribe("conquest", e -> {
if (e.from >= 0) world.empires.get(e.from).grudge[e.empire] += 3;
});
// Сигнал към играча: нещо се случи, при което „wait“ спира.
bus.subscribe("battle", e -> {
if (involvesHuman.test(e.a, e.b)) world.alert++;
});
bus.subscribe("conquest", e -> {
if (world.empires.get(e.empire).human || (e.from >= 0 && world.empires.get(e.from).human)) world.alert++;
});
bus.subscribe("war", e -> {
if (involvesHuman.test(e.a, e.b)) world.alert++;
});
bus.subscribe("siege", e -> {
if (world.empires.get(e.from).human) world.alert++;
});
bus.subscribe("offer", e -> {
if (world.empires.get(e.to).human) world.alert++;
});
if (quiet) return;
bus.subscribe("battle", e -> {
if (involvesHuman.test(e.a, e.b)) {
for (String line : e.lines) addNews(world, line);
} else {
addNews(world, t("news.battle",
"system", world.systems.get(e.systemId).name, "a", empireName(world.empires.get(e.a)), "b", empireName(world.empires.get(e.b)),
"la", e.lostA, "lb", e.lostB));
}
});
bus.subscribe("colony", e -> {
addNews(world, t("news.colony", "empire", empireName(world.empires.get(e.empire)), "planet", planetName(world, world.planets.get(e.planetId))));
});
bus.subscribe("tech", e -> {
if (world.empires.get(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.get(e.a)), "b", empireName(world.empires.get(e.b))));
});
bus.subscribe("peace", e -> {
addNews(world, t("news.peace", "a", empireName(world.empires.get(e.a)), "b", empireName(world.empires.get(e.b))));
});
bus.subscribe("offer", e -> {
addNews(world, t("news.offer", "empire", empireName(world.empires.get(e.from)), "letter", world.empires.get(e.from).def.letter));
});
bus.subscribe("siege", e -> {
addNews(world, t("news.siege", "empire", empireName(world.empires.get(e.empire)), "planet", planetName(world, world.planets.get(e.planetId))));
});
bus.subscribe("conquest", e -> {
addNews(world, t("news.conquest", "empire", empireName(world.empires.get(e.empire)), "planet", planetName(world, world.planets.get(e.planetId))));
});
bus.subscribe("eliminated", e -> {
addNews(world, t("news.eliminated", "empire", empireName(world.empires.get(e.empire))));
});
bus.subscribe("event", e -> {
Empire empire = world.empires.get(e.empire);
addNews(world, tm("event." + e.kind, e.vars));
});
}

Идеята

Мир и война: правило срещу бъркотията

Дипломацията в играта е два обекта: двойка империи е във война или в мир (war[a][b]). Кое е трудното? Без правило империя би обявявала война на един ход, сключвала мир на следващия и пак война след него. Затова:

  • мирът важи най-малко MIN_PEACE = 4 хода преди нова война (turn − since < MIN_PEACE; в началото на играта правилото не важи, за да може някой да започне);
  • предложение за мир е позволено най-рано два хода след промяната;
  • изкуственият интелект приема мир по желание: колко е по-слаб от другия, какъв е характерът му, колко други войни води и колко е озлобен. От 90 нагоре приема.

В програмата желанието е опростено: характер + (150 − съотношение на корабите) / 2 + други войни × 15 − озлобление × 3, където характерът е 40 за Орион (воин) и 120 за двете други (строители). Отказът увеличава озлоблението, затова следващият опит е по-труден. Сценарият е от историята на сцената: Орион (B) обявява война на Земната империя (A) на ход 1, Лира (C) на Орион на ход 3, а A предлага мир:

class World {
int turn = 1;
int[][] ships = new int[3][4];
int[][] war = new int[3][3];
int[][] since = new int[3][3];
int[][] grudge = new int[3][3];
}
static boolean atWar(World world, int a, int b) {
return world.war[a][b] == 1;
}
static int power(World world, int empire) {
int total = 0;
for (int n : world.ships[empire]) total += n;
return total;
}
static 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;
}
static String declareWar(World world, int a, int b) {
if (atWar(world, a, b)) return "alreadyWar";
// Мирът важи най-малко MIN_PEACE хода.
if (world.turn - world.since[a][b] < MIN_PEACE && world.turn > MIN_PEACE) return "tooSoon";
setStance(world, a, b, 1);
return null;
}
static void makePeace(World world, int a, int b) {
setStance(world, a, b, 0);
world.grudge[a][b] = 0;
world.grudge[b][a] = 0;
}
static int peaceWish(World world, int me, int other) {
// Колко по-слаба е империята от другата.
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);
}
static 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";
// От 90 нагоре империята приема мир.
if (peaceWish(world, b, a) >= 90) {
makePeace(world, a, b);
return null;
}
// Отказът озлобява: следващият мир е по-труден.
world.grudge[b][a]++;
return "refused";
}
static void step(World world, int turn, String kind, int a, int b) {
world.turn = turn;
int wish = kind.equals("peace") ? peaceWish(world, b, a) : 0;
String error = kind.equals("war") ? declareWar(world, a, b) : proposePeace(world, a, b);
String shown = kind.equals("peace") && (error == null || error.equals("refused")) ? " (желание " + wish + ")" : "";
System.out.println("ход " + turn + ": " + kind + " " + NAMES[a] + " → " + NAMES[b] + ": " + (error == null ? "ok" : error) + shown);
}
World 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);
Изход
ход 1: war Хегемония Орион → Земна империя: ok
ход 2: war Хегемония Орион → Земна империя: alreadyWar
ход 2: peace Земна империя → Хегемония Орион: tooSoon
ход 3: war Съюз Лира → Хегемония Орион: ok
ход 3: peace Земна империя → Хегемония Орион: refused (желание 77)
ход 4: peace Земна империя → Хегемония Орион: ok (желание 124)
ход 5: war Хегемония Орион → Земна империя: tooSoon
ход 9: war Хегемония Орион → Земна империя: ok

Ход 2: Орион пак обявява война на същия противник (alreadyWar), а A предлага мир едва един ход след войната (tooSoon). На ход 3 A предлага мир, но желанието на Орион е 40 + 25 + 15 − 3 = 77 (характер, един кораб срещу нула, още една война с Лира, озлобление 1) и е под 90: отказ, а озлоблението става 2. На ход 4 Орион няма кораби и желанието му е 40 + 75 + 15 − 6 = 124: приема. Ход 5: Орион иска нова война, но мирът е от ход 4 и 5 − 4 = 1 е по-малко от 4: tooSoon. На ход 9 вече е позволено (9 − 4 = 5).

static boolean atWar(World world, int a, int b) {
    return world.war[a][b] == 1;
}

static int power(World world, int empire) {
    int total = 0;
    for (int n : world.ships[empire]) total += n;
    return total;
}

static 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;
}

static String declareWar(World world, int a, int b) {
    if (atWar(world, a, b)) return "alreadyWar";
    // Мирът важи най-малко MIN_PEACE хода.
    if (world.turn - world.since[a][b] < MIN_PEACE && world.turn > MIN_PEACE) return "tooSoon";
    setStance(world, a, b, 1);
    return null;
}

static void makePeace(World world, int a, int b) {
    setStance(world, a, b, 0);
    world.grudge[a][b] = 0;
    world.grudge[b][a] = 0;
}

static int peaceWish(World world, int me, int other) {
    // Колко по-слаба е империята от другата.
    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);
}

static 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";
    // От 90 нагоре империята приема мир.
    if (peaceWish(world, b, a)  90) {
        makePeace(world, a, b);
        return null;
    }
    // Отказът озлобява: следващият мир е по-труден.
    world.grudge[b][a]++;
    return "refused";
}
По-точно, за любопитните

Резултатът от функциите е низ или нищо: null, ако промяната е станала, и ключ на грешка (alreadyWar, tooSoon, refused, noWar), ако не. Грешките са ключове, а не готови изречения, за да ги превежда екранът (играта е на четири езика). Така същата функция служи и на играча (команда peace B), и на изкуствения интелект.

Канонът има и предложения към играча: изкуственият интелект не може да сключи мир с човека сам, затова записва предложение (offers), което важи три хода, и публикува събитие „offer“. Ето целия регион diplomacy:

// Дипломацията е мир или война между две империи. Мирът важи най-малко MIN_PEACE хода преди
// нова война. Изкуственият интелект приема мир по мерило: колко е по-слаб, какъв е характерът
// му и колко е озлобен. Предложение към играча се записва и важи три хода.
static boolean atWar(World world, int a, int b) {
return world.empires.get(a).war[b] == 1;
}
static void setStance(World world, int a, int b, int war) {
world.empires.get(a).war[b] = war;
world.empires.get(b).war[a] = war;
world.empires.get(a).since[b] = world.turn;
world.empires.get(b).since[a] = world.turn;
}
static String declareWar(World world, int a, int b) {
if (a == b) return "dip.self";
if (!world.empires.get(b).alive) return "dip.dead";
if (atWar(world, a, b)) return "dip.alreadyWar";
if (world.turn - world.empires.get(a).since[b] < MIN_PEACE && world.turn > MIN_PEACE) return "dip.tooSoon";
setStance(world, a, b, 1);
GameEvent event = new GameEvent("war");
event.a = a;
event.b = b;
world.bus.publish(event);
return null;
}
static void makePeace(World world, int a, int b) {
setStance(world, a, b, 0);
world.empires.get(a).grudge[b] = 0;
world.empires.get(b).grudge[a] = 0;
GameEvent event = new GameEvent("peace");
event.a = a;
event.b = b;
world.bus.publish(event);
}
// Най-малкото разстояние в пътища между системи с планети на двете империи.
static int empireDistance(World world, int a, int b) {
int best = 99;
for (Planet pa : world.planets) {
if (pa.owner != a) continue;
int[] dist = bfs(world.systems, pa.systemId).dist;
for (Planet pb : world.planets) {
if (pb.owner == b && dist[pb.systemId] >= 0 && dist[pb.systemId] < best) best = dist[pb.systemId];
}
}
return best;
}
static int powerRatio(World world, int a, int b, int top) {
return clamp(percent(100L * empirePower(world, a), 100L * empirePower(world, b) + 100), 0, top);
}
// Колко иска империята мир с другата (0 и нагоре; от 90 нагоре приема).
static int peaceWish(World world, int me, int other) {
Empire e = world.empires.get(me);
int weaker = 150 - powerRatio(world, me, other, 150);
int otherWars = 0;
for (Empire x : 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);
}
// Колко иска империята война с другата.
static int warWish(World world, int me, int other) {
Empire e = world.empires.get(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;
for (Empire x : world.empires) {
if (x.alive && x.id != me && e.war[x.id] == 1) wish -= 30;
}
return wish;
}
static boolean hasOffer(World world, int from, int to) {
for (Offer offer : world.offers) {
if (offer.from == from && offer.to == to && world.turn - offer.turn <= 3) return true;
}
return false;
}
// Предложение за мир от a към b. Връща ключ на грешка или null (мирът е сключен).
static String proposePeace(World world, int a, int b) {
if (a == b) return "dip.self";
if (!world.empires.get(b).alive) return "dip.dead";
if (!atWar(world, a, b)) return "dip.noWar";
if (world.turn - world.empires.get(a).since[b] < 2) return "dip.tooSoon";
if (world.empires.get(b).human) {
if (hasOffer(world, b, a)) {
makePeace(world, a, b);
return null;
}
if (!hasOffer(world, a, b)) world.offers.add(new Offer(a, b, world.turn));
return "dip.sent";
}
if (hasOffer(world, b, a) || peaceWish(world, b, a) >= 90) {
makePeace(world, a, b);
return null;
}
world.empires.get(b).grudge[a]++;
return "dip.refused";
}
// Дипломацията на изкуствения интелект в края на хода: мир, когато е по-слаб, война, когато е по-силен.
static void aiDiplomacy(World world, Empire empire) {
for (Empire other : 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(empire.id, other.id, world.turn));
GameEvent event = new GameEvent("offer");
event.from = empire.id;
event.to = other.id;
world.bus.publish(event);
}
} 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);
}
}
}

Идеята

Шина от събития: който публикува, не знае кой слуша

Когато се обяви война, трябва да стане много: да се напише новина, да порасне озлобването на двете империи, да светне сигнал на играча, да се преброи в статистиката. Най-простото е declareWar да вика всичко това. Тогава declareWar знае за новините, за озлобването, за сигнала и за статистиката, а всяка нова възможност е още един ред в нея и още една причина да се счупи. Шината обръща посоката: declareWar казва „обявена е война между A и B“ и свършва. Който се интересува, се е абонирал за „war“.

record Event(String type, int a, int b, int place, int n) {
Event(String type, int a, int b) {
this(type, a, b, 0, 0);
}
}
class EventBus {
Map<String, List<Consumer<Event>>> handlers = new HashMap<>();
void subscribe(String type, Consumer<Event> handler) {
if (!handlers.containsKey(type)) handlers.put(type, new ArrayList<>());
handlers.get(type).add(handler);
}
void publish(Event event) {
if (!handlers.containsKey(event.type())) return;
for (Consumer<Event> handler : new ArrayList<>(handlers.get(event.type()))) handler.accept(event);
}
}

subscribe записва обработчик за вид събитие, publish вика всички обработчици на този вид. Вид без абонати не е грешка: събитието отива в празното. Ето публикуването в двете места, където става (обява на война и сключване на мир). Единственото ново е редът с publish:

static String declareWar(World world, int a, int b) {
if (atWar(world, a, b)) return "alreadyWar";
// Мирът важи най-малко MIN_PEACE хода.
if (world.turn - world.since[a][b] < MIN_PEACE && world.turn > MIN_PEACE) return "tooSoon";
setStance(world, a, b, 1);
world.bus.publish(new Event("war", a, b));
return null;
}
static 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));
}

А ето програмата. Новините слушат всички видове, озлоблението само битките, а report публикува и неуспешните опити (refused, tooSoon) като събития:

World world = new World();
world.ships[1][1] = 1;
world.grudge[1][0] = 1;
// Новините слушат всички видове събития, озлобването само битките.
for (String type : NEWS_TYPES) {
world.bus.subscribe(type, event -> System.out.println(newsText(event)));
}
world.bus.subscribe("battle", event -> {
world.grudge[event.a()][event.b()]++;
world.grudge[event.b()][event.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);
System.out.println("озлобление " + NAMES[0] + " → " + NAMES[1] + ": " + world.grudge[0][1]);
System.out.println("озлобление " + NAMES[1] + " → " + NAMES[0] + ": " + world.grudge[1][0]);
Изход
Хегемония Орион обявява война на Земна империя.
Битка при Vesta: Хегемония Орион и Земна империя губят кораби (по 2).
Хегемония Орион отказва мир с Земна империя.
Хегемония Орион вече е във война с Земна империя.
озлобление Земна империя → Хегемония Орион: 1
озлобление Хегемония Орион → Земна империя: 3

Опитът на ход 1 е успешен, битката е публикувана на ръка, отказът на ход 3 е събитие (refused), а повторната война е alreadyWar. Озлоблението на A към B е 1 (от битката), а на B към A е 3: едно старо, едно от битката и едно от отказа (отказът вдига озлоблението на този, който отказва, към предложилия).

Consumer<Event> е функционален интерфейс с един метод accept: обработчикът е ламбда, event -> ....
По-точно, за любопитните

Без шина всяко ново нещо, което трябва да научи за война, иска промяна в declareWar. С шината нов абонат е само нов ред при създаването на света, а функцията за война не се пипа. Това е шаблонът наблюдател (observer) от модула за шаблони за дизайн.

Малка, но важна подробност: publish върти по копие на списъка с абонати. Обработчик може да се абонира за друго събитие, докато го викат; ако списъкът се променя по време на обхождането, абонатите се прескачат или се повтарят. Копието прави правилото ясно: новият абонат чува от следващото публикуване.

Редът на абонатите е редът на subscribe. Тук няма значение (новините и озлоблението не си пречат), но ако два абоната зависят един от друг, редът ще решава изхода, и това е причина да ги държиш независими.

Идеята

Целият ход

Сега всичко заедно. Светът е World: ход, фаза, генератор, шина, флотите на трите империи (по системи), метал и таблиците за война (war), от кой ход (since) и озлобление (grudge):

class World {
int turn = 1;
String phase = "";
Rng rng;
EventBus bus = new EventBus();
int[][] ships = {{3, 0, 0, 0}, {0, 0, 0, 4}, {0, 2, 0, 0}};
int[] metal = {50, 50, 50};
int[][] war = new int[3][3];
int[][] since = new int[3][3];
int[][] grudge = new int[3][3];
World(long seed) {
rng = new Rng(seed);
}
}

Поръчките и дипломацията на хода са сценарий (в играта ги решава изкуственият интелект или играчът): поръчката е [ход, империя, от, към, кораби], а дипломацията [ход, вид, от, към].

// [ход, империя, от, към, кораби]
static final int[][] ORDERS = {{1, 1, 3, 2, 4}, {1, 0, 0, 1, 3}, {2, 1, 2, 1, 4}};
// [ход, вид (0 война, 1 мир), от, към]
static final int[][] DIPLO = {{1, 0, 1, 0}, {3, 0, 2, 1}, {3, 1, 0, 1}, {4, 1, 0, 1}, {5, 0, 1, 0}};

Фазите са функции. Дипломацията изпълнява сценария за хода и публикува грешките. Движението е точно двете стъпки от първата карта. Битките обхождат системите: където две империи във война имат кораби, всяка губи толкова, колкото е по-малката флота, и се публикува „battle“ (озлоблението се абонира за него):

static void scriptedDiplomacy(World world) {
world.phase = "дипломация";
for (int[] step : 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]);
}
}
static void resolveMovement(World world) {
world.phase = "движение";
List<Move> moving = new ArrayList<>();
// Първо всички флоти напускат системите си...
for (int[] order : 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));
}
// ...и чак тогава пристигат.
for (Move move : moving) {
world.ships[move.empire()][move.to()] += move.n();
world.bus.publish(new Event("move", move.empire(), move.from(), move.to(), move.n()));
}
}
static void resolveBattles(World world) {
world.phase = "битки";
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));
}
}
}
}
static void produce(World world) {
world.phase = "производство";
for (int empire = 0; empire < 3; empire++) world.metal[empire] += 10;
}

След тях е събитието: теглене по теглата от втората карта, с тези ефекти: метеор отнема метал, бум дава метал, изоставените кораби дават два кораба в столицата. Накрая runTurn ги вика в ред:

static void rollEvent(World world) {
world.phase = "събитие";
if (!world.rng.chance(EVENT_CHANCE)) return;
int empire = world.rng.nextInt(3);
int[] weights = new int[EVENT_DEFS.length];
for (int i = 0; i < weights.length; i++) {
weights[i] = world.turn >= EVENT_DEFS[i].minTurn() ? EVENT_DEFS[i].weight() : 0;
}
String kind = EVENT_DEFS[pickWeighted(world.rng, weights)].id();
if (kind.equals("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.equals("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));
}
}
static void runTurn(World world) {
// Фазите вървят в този ред и редът е важен.
scriptedDiplomacy(world);
resolveMovement(world);
resolveBattles(world);
produce(world);
rollEvent(world);
world.turn++;
}

Програмата абонира новините (те слагат пред всеки ред фазата, в която е станало събитието) и озлоблението, пуска пет хода и печата резултата. Редовете са точно новините на играта:

World world = new World(7);
world.bus.subscribe("battle", event -> {
world.grudge[event.a()][event.b()]++;
world.grudge[event.b()][event.a()]++;
});
for (String type : NEWS_TYPES) {
world.bus.subscribe(type, event -> System.out.println(" [" + world.phase + "] " + newsText(event)));
}
for (int i = 0; i < 5; i++) {
System.out.println(fill("-- ход {0} --", world.turn));
runTurn(world);
}
System.out.println("-- край --");
for (int empire = 0; empire < 3; empire++) {
System.out.println(fill("{0}: кораби {1}, метал {2}", NAMES[empire], power(world, empire), world.metal[empire]));
}
Изход
-- ход 1 --
[дипломация] Хегемония Орион обявява война на Земна империя.
[движение] Хегемония Орион праща флот от Brisa към Altan (кораби: 4).
[движение] Земна империя праща флот от Kora към Vesta (кораби: 3).
[събитие] Метеоритен дъжд: Съюз Лира губи 14 метал.
-- ход 2 --
[движение] Хегемония Орион праща флот от Altan към Vesta (кораби: 4).
[битки] Битка при Vesta: Земна империя и Хегемония Орион губят кораби (по 3).
-- ход 3 --
[дипломация] Съюз Лира обявява война на Хегемония Орион.
[дипломация] Хегемония Орион отказва мир с Земна империя.
[битки] Битка при Vesta: Хегемония Орион и Съюз Лира губят кораби (по 1).
-- ход 4 --
[дипломация] Мир между Земна империя и Хегемония Орион.
[събитие] Метеоритен дъжд: Съюз Лира губи 8 метал.
-- ход 5 --
[дипломация] Хегемония Орион още не може да смени отношенията си с Земна империя: прекалено скоро.
[събитие] Хегемония Орион намира изоставени кораби при Brisa (кораби: 2).
-- край --
Земна империя: кораби 0, метал 100
Хегемония Орион: кораби 2, метал 100
Съюз Лира: кораби 1, метал 78

Прочети изхода като история. Ход 1: Орион обявява война, двата флота (на Орион и на Земната империя) тръгват едновременно, а Лира губи метал от метеор. Ход 2: флотът на Орион пристига във Vesta, където вече е Земната империя, и двамата губят по 3 кораба. Ход 3: Лира се включва във войната и в боя във Vesta губят по 1; преди това A предлага мир и получава отказ. Ход 4: Орион няма кораби, желанието му е 124 и мирът е сключен. Ход 5: нова война е „прекалено скоро“, а Орион намира два изоставени кораба в столицата си.

Виж го

Един ход на галактиката

Ето същото по ходове. Горе светва фазата, в която е станало нещо. По средата са системите и флотите на трите империи: при движението всички флотове излитат наведнъж и чак тогава кацат. Под тях се вижда дали всяка двойка е във война, а най-долу шината: публикуваното събитие светва абоната „новини“, а ако е битка, и „озлобление“.

static void runTurn(World world) {
// Фазите вървят в този ред и редът е важен.
scriptedDiplomacy(world);
resolveMovement(world);
resolveBattles(world);
produce(world);
rollEvent(world);
world.turn++;
}
class EventBus {
Map<String, List<Consumer<Event>>> handlers = new HashMap<>();
void subscribe(String type, Consumer<Event> handler) {
if (!handlers.containsKey(type)) handlers.put(type, new ArrayList<>());
handlers.get(type).add(handler);
}
void publish(Event event) {
if (!handlers.containsKey(event.type())) return;
for (Consumer<Event> handler : new ArrayList<>(handlers.get(event.type()))) handler.accept(event);
}
}
Стъпка по стъпка, с думи
  1. A = Земна империя, B = Хегемония Орион, C = Съюз Лира. Четири системи по права линия; ходът минава през фазите отгоре надолу.
  2. ход 1 · дипломация: Хегемония Орион обявява война на Земна империя.
  3. ход 1 · движение: Хегемония Орион праща флот от Brisa към Altan (кораби: 4). Земна империя праща флот от Kora към Vesta (кораби: 3).
  4. ход 1 · събитие: Метеоритен дъжд: Съюз Лира губи 14 метал.
  5. ход 2 · движение: Хегемония Орион праща флот от Altan към Vesta (кораби: 4).
  6. ход 2 · битки: Битка при Vesta: Земна империя и Хегемония Орион губят кораби (по 3).
  7. ход 3 · дипломация: Съюз Лира обявява война на Хегемония Орион. Хегемония Орион отказва мир с Земна империя.
  8. ход 3 · битки: Битка при Vesta: Хегемония Орион и Съюз Лира губят кораби (по 1).
  9. ход 4 · дипломация: Мир между Земна империя и Хегемония Орион.
  10. ход 4 · събитие: Метеоритен дъжд: Съюз Лира губи 8 метал.
  11. ход 5 · дипломация: Хегемония Орион още не може да смени отношенията си с Земна империя: прекалено скоро.
  12. ход 5 · събитие: Хегемония Орион намира изоставени кораби при Brisa (кораби: 2).
  13. След 5 хода: Земна империя: кораби 0, метал 100 · Хегемония Орион: кораби 2, метал 100 · Съюз Лира: кораби 1, метал 78

Кодът до сцената е runTurn и EventBus от програмата. Светят редовете, които работят в момента.

Идеята

В играта

Това, което построихме, е същото като в канона, само по-малко. Ето истинския ход (регион turn). Над runTurn са движението, което прави същите две стъпки (напускане, пристигане), битките и завладяването на планети; runTurn вика фазите в твърд ред, а в края на хода озлоблението остарява (на всеки пети ход намалява с 1) и се изхвърлят остарелите предложения за мир.

// Един ход на галактиката има фази: дипломация на изкуствения интелект, поръчките му, движение на
// всички флотове наведнъж, битки, завладяване, производство и наука, събитие, край на хода.
// Движението е едновременно: първо всички кораби напускат системите си, чак тогава пристигат.
static class Move {
int owner;
int to;
int dest;
int[] ships;
Move(int owner, int to, int dest, int[] ships) {
this.owner = owner;
this.to = to;
this.dest = dest;
this.ships = ships;
}
}
static void resolveMovement(World world) {
List<Move> moving = new ArrayList<>();
for (Empire empire : world.empires) {
if (!empire.alive) continue;
for (Order order : 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(empire.id, order.to, order.dest, ships));
}
empire.orders = new ArrayList<>();
}
for (Move move : moving) {
addShips(world, move.owner, move.to, move.ships);
// дългият път продължава сам: нова поръчка от мястото, където стигнаха
if (move.to != move.dest) {
world.empires.get(move.owner).orders.add(new Order(move.to, nextStep(world.systems, move.to, move.dest), move.dest, move.ships.clone()));
}
}
removeEmptyFleets(world);
}
static boolean 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;
}
static void resolveBattles(World world) {
for (StarSystem system : world.systems) {
for (int a = 0; a < world.empires.size(); a++) {
for (int b = a + 1; b < world.empires.size(); b++) {
if (!world.empires.get(a).alive || !world.empires.get(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);
}
// Планета пада, когато враг е във война с владетеля ѝ, има кораби в системата, а владетелят няма
// нито кораби, нито оцелели крепости там, и така SIEGE_TURNS хода подред: първите ходове са обсада,
// последният е превземане. Защитникът има време да доведе флот. Постройките се разрушават.
static void resolveConquest(World world) {
for (StarSystem system : world.systems) {
for (int pid : system.planets) {
Planet planet = world.planets.get(pid);
if (planet.owner < 0) continue;
int owner = planet.owner;
int attacker = -1;
if (!hasUnits(world, owner, system.id)) {
for (Empire e : 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) {
GameEvent siege = new GameEvent("siege");
siege.empire = attacker;
siege.from = owner;
siege.planetId = planet.id;
world.bus.publish(siege);
continue;
}
planet.siege = 0;
planet.owner = attacker;
planet.mines = maxOf(0, planet.mines - 1);
planet.lab = 0;
planet.yard = 0;
planet.fort = 0;
GameEvent conquest = new GameEvent("conquest");
conquest.empire = attacker;
conquest.from = owner;
conquest.planetId = planet.id;
world.bus.publish(conquest);
}
}
for (Empire empire : world.empires) {
if (!empire.alive || ownedPlanets(world, empire.id).size() > 0) continue;
empire.alive = false;
List<Fleet> kept = new ArrayList<>();
for (Fleet fleet : world.fleets) {
if (fleet.owner != empire.id) kept.add(fleet);
}
world.fleets = kept;
empire.orders = new ArrayList<>();
GameEvent eliminated = new GameEvent("eliminated");
eliminated.empire = empire.id;
world.bus.publish(eliminated);
}
}
static void runTurn(World world) {
world.turnLines = new ArrayList<>();
world.fortLeft = new LinkedHashMap<>();
for (Empire empire : world.empires) {
if (empire.alive && !empire.human) aiDiplomacy(world, empire);
}
for (Empire empire : world.empires) {
if (empire.alive && !empire.human) aiPlay(world, empire);
}
resolveMovement(world);
resolveBattles(world);
resolveConquest(world);
for (Empire empire : 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) {
for (Empire empire : world.empires) {
for (int i = 0; i < empire.grudge.length; i++) empire.grudge[i] = maxOf(0, empire.grudge[i] - 1);
}
}
List<Offer> fresh = new ArrayList<>();
for (Offer offer : world.offers) {
if (world.turn - offer.turn <= 3) fresh.add(offer);
}
world.offers = fresh;
}

А ето шината, която слушат новините, озлоблението и сигналът към играча. Събитията са един клас с повече полета (в JavaScript — обект), а шината е същата:

// Шината от събития: който публикува, не знае кой слуша. Новините, злопаметността на империите и
// статистиката се абонират сами, а битката само казва „флот се би при тази система“.
// В JavaScript събитието е обект с различни полета; тук е един клас, а полетата, които видът не
// ползва, остават празни.
static class GameEvent {
String type;
int systemId = 0;
int a = 0;
int b = 0;
int lostA = 0;
int lostB = 0;
String winner = "";
List<String> lines = new ArrayList<>();
int empire = 0;
int planetId = 0;
String techId = "";
int from = 0;
int to = 0;
String kind = "";
Map<String, Object> vars = new LinkedHashMap<>();
GameEvent(String type) {
this.type = type;
}
}
static class EventBus {
Map<String, List<Consumer<GameEvent>>> handlers = new LinkedHashMap<>();
void subscribe(String type, Consumer<GameEvent> handler) {
if (!this.handlers.containsKey(type)) this.handlers.put(type, new ArrayList<>());
this.handlers.get(type).add(handler);
}
void publish(GameEvent event) {
if (!this.handlers.containsKey(event.type)) return;
for (Consumer<GameEvent> handler : new ArrayList<>(this.handlers.get(event.type))) handler.accept(event);
}
}

Разликите от нашата програма са много, и затова са важни:

  • Няма сценарий. Поръчките и дипломацията идват от aiPlay и aiDiplomacy (урокът за съперниците) и от командите на играча.
  • Галактиката е граф (урокът за генерирането ѝ), а не права линия. Поръчката помни крайната цел и продължава сама.
  • Битката (fightAt, урокът за флотите) е с роли на корабите и по шест рунда, а не „всеки губи по по-малкото“. Има и крепости, и завладяване на планета (две обсади).
  • Желанието за мир ползва силата на империята (не броя кораби), характера ѝ и нейното желание за война (warWish). Предложение към играча се записва за три хода.
  • Производството тук е плоски 10 метал на империя; в канона зависи от планетите, мините и технологиите.
  • Събитията са осем вида, с по-различен ефект (пиратите се бият със същия модел на битка).
  • Шината носи и други видове: завладяване, обсада, откриване на технология, колония, унищожена империя. Новините и сигналът към играча ги слушат, а wait спира, щом сигналът порасне.

Пробвай

Предвиди изхода

Два флота: първата поръчка праща 3-те кораба от системата 0 към 1, втората — 2 кораба от системата 1 към 2. Поръчките се обработват едновременно. Какво печата програмата?

static int[] moveTogether(int[] start, int[][] orders) {
int[] ships = start.clone();
int[] takes = new int[orders.length];
// Първо всички флоти напускат системите си...
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];
}
// ...и чак тогава пристигат.
for (int i = 0; i < orders.length; i++) ships[orders[i][1]] += takes[i];
return ships;
}
static String show(int[] ships) {
String text = "";
for (int i = 0; i < ships.length; i++) text += (i > 0 ? " " : "") + ships[i];
return text;
}
int[][] orders = {{0, 1, 3}, {1, 2, 2}};
System.out.println(show(moveTogether(new int[] {3, 0, 0, 0}, orders)));
Отговор

Пробвай

Намери грешката

Колега „оправи“ едновременното движение, но при него корабите пак минават по няколко системи на ход:

int[] start = {3, 0, 0, 0};
System.out.println("едновременно: " + show(moveTogether(start, ORDERS)));
Изход
едновременно: 0 0 0 3

Резултатът е като при последователното движение. Кой ред праща корабите на местоназначението твърде рано?

Избери реда с грешката.

По-точно

По-точно: ред, справедливост и случайност

Редът на играчите. При едновременно движение редът на поръчките не променя резултата, защото първо всички вземат от началото на хода. При битките обаче има ред: система по система, двойка по двойка (a < b). Ако на една система са три империи във война, първата двойка в реда се бие първа и оцелелите стигат до следващата. Редът е твърд, затова играта остава повторяема.

Фазите са договор. Дипломацията е преди движението, защото тогава ново обявената война може да започне битка в същия ход. Производството е след завладяването: планета, взета този ход, не произвежда за стария си господар. Събитието е почти накрая и работи със света такъв, какъвто е станал (метеорът взима от метала, който вече е изкаран). Размени две фази и играта остава „същата“, но изходът е друг. Затова редът е на едно място (runTurn) и се чете като списък.

Случайност и повторяемост. Всичко случайно (шанс за събитие, коя империя, кой вид, колко метал) минава през един генератор със семе. Ако две фази си разменят местата и двете теглят числа, поредицата е друга. В нашата програма само rollEvent ползва генератора, затова изходът е стабилен; в канона дипломацията и изкуственият интелект също теглят числа, и редът на фазите решава кой взема кое число.

Какво не е в шината. Шината е за новини: „стана това“. Тя не е за команди („направи това“): публикуващият не знае дали някой е чул и не чака отговор. Публикувай факти, не заповеди.

Упражнение

Сигнал за играча

Реши задачата в редактора си. Тръгни от програмата за цял ход.

Сигнал. В играта wait спира, щом нещо се случи на играча. Добави абонат „сигнал“, който брои събитията war, peace и battle, в които участва империя 0 (играчът): като a или като b. След обобщението програмата печата още един ред със сигналите (сигнали: …). Не пипай declareWar, makePeace и resolveBattles: абонатът е само нов ред при създаването на света и една функция.

Изходът е (последният ред е новият):

Изход
-- ход 1 --
[дипломация] Хегемония Орион обявява война на Земна империя.
[движение] Хегемония Орион праща флот от Brisa към Altan (кораби: 4).
[движение] Земна империя праща флот от Kora към Vesta (кораби: 3).
[събитие] Метеоритен дъжд: Съюз Лира губи 14 метал.
-- ход 2 --
[движение] Хегемония Орион праща флот от Altan към Vesta (кораби: 4).
[битки] Битка при Vesta: Земна империя и Хегемония Орион губят кораби (по 3).
-- ход 3 --
[дипломация] Съюз Лира обявява война на Хегемония Орион.
[дипломация] Хегемония Орион отказва мир с Земна империя.
[битки] Битка при Vesta: Хегемония Орион и Съюз Лира губят кораби (по 1).
-- ход 4 --
[дипломация] Мир между Земна империя и Хегемония Орион.
[събитие] Метеоритен дъжд: Съюз Лира губи 8 метал.
-- ход 5 --
[дипломация] Хегемония Орион още не може да смени отношенията си с Земна империя: прекалено скоро.
[събитие] Хегемония Орион намира изоставени кораби при Brisa (кораби: 2).
-- край --
Земна империя: кораби 0, метал 100
Хегемония Орион: кораби 2, метал 100
Съюз Лира: кораби 1, метал 78
сигнали: 3

Още задачи

  • Статистика. Добави още един абонат, който брои битките и сбора на загубените кораби (всяка битка губи n на всяка от двете страни). Печата ги след сигналите.
  • Примирие. Намали MIN_PEACE на 2 и пусни програмата. Кои редове от изхода се менят и защо ход 5 вече не е „прекалено скоро“?
  • Нов вид събитие. Добави plague с най-ранен ход 4 и тегло 10: империята губи 5 метал. Кои места променяш: таблицата, rollEvent, newsText, списъка с видовете за новините? А шината?

Тест

Провери се

1. Три поръчки са наредени във верига: A→B, B→C, C→D, по 3 кораба във всяка. В началото 3-те кораба са в A. Последователно (поръчка след поръчка) корабите по системи A, B, C, D стават 0 0 0 3 (всички в D), а едновременно 0 3 0 0 (всички в B). Защо?
2. В таблицата на канона 8 вида събитие имат тегла със сбор 113. На ход 3 пиратите (най-ранен ход 6) и размириците (5) още не са позволени. Каква е вероятността метеоритен дъжд (тегло 20) да е събитието, ако на хода се случва събитие?
3. Орион и Земната империя сключват мир на ход 4. На ход 5 Орион пак иска война. Защо му казват „прекалено скоро“?
4. Защо publish върти по копие на списъка с абонати, а не по самия списък?
5. Искаш да броиш колко пъти е обявена война. Какво променяш в declareWar?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·